Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9678ae58cf |
No files matched your search
@@ -32,6 +32,8 @@ python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
|
|||||||
|
|
||||||
- By default, image tasks talk to `http://<host>:<port>/v1/chat/completions`; video tasks talk to `/v1/videos`.
|
- By default, image tasks talk to `http://<host>:<port>/v1/chat/completions`; video tasks talk to `/v1/videos`.
|
||||||
- If you run the server on another host or port, pass `--base-url` accordingly.
|
- If you run the server on another host or port, pass `--base-url` accordingly.
|
||||||
|
- To compare diffusion multi-replica serving, restart the server with different
|
||||||
|
`runtime.num_replicas` values and keep the benchmark parameters unchanged.
|
||||||
|
|
||||||
## 2. Supported Datasets
|
## 2. Supported Datasets
|
||||||
|
|
||||||
@@ -93,6 +95,35 @@ You can point to your own trace using `--dataset-path`.
|
|||||||
|
|
||||||
## 3. Benchmark Parameters
|
## 3. Benchmark Parameters
|
||||||
|
|
||||||
|
### Multi-replica sweep
|
||||||
|
|
||||||
|
For replica scaling, use the same model, prompt count, image/video size, and
|
||||||
|
step count for every run. Change only the serving stage config:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runtime:
|
||||||
|
devices: "0,1,2,3"
|
||||||
|
num_replicas: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run a fixed benchmark and write one JSON file per replica count:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
|
||||||
|
--base-url http://localhost:8099 \
|
||||||
|
--model Qwen/Qwen-Image \
|
||||||
|
--task t2i \
|
||||||
|
--dataset random \
|
||||||
|
--num-prompts 32 \
|
||||||
|
--max-concurrency 8 \
|
||||||
|
--request-rate inf \
|
||||||
|
--num-inference-steps 10 \
|
||||||
|
--width 512 --height 512 \
|
||||||
|
--output-file diffusion_replica_4.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat for `num_replicas=1`, `2`, `3`, and `4`, using matching `runtime.devices`.
|
||||||
|
|
||||||
### Basic flags
|
### Basic flags
|
||||||
|
|
||||||
- `--base-url`: Server address; `--endpoint` selects the path appended to this base URL.
|
- `--base-url`: Server address; `--endpoint` selects the path appended to this base URL.
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
|||||||
|
# Diffusion Multi-Replica Serving
|
||||||
|
|
||||||
|
Diffusion stages can be replicated in online serving by setting `runtime.num_replicas` on a `stage_type: diffusion` stage. Each replica owns an independent diffusion engine process and receives requests through round-robin routing with request affinity.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
Use multi-replica serving when one diffusion process does not saturate the available GPUs, or when you want higher request throughput for many independent image/video generation requests. It improves concurrency throughput; it does not make a single request faster.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
A replica consumes the devices required by that diffusion stage. For single-device diffusion pipelines, set `runtime.devices` to one device per replica:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
stages:
|
||||||
|
- stage_id: 0
|
||||||
|
stage_type: diffusion
|
||||||
|
runtime:
|
||||||
|
devices: "0,1,2,3"
|
||||||
|
num_replicas: 4
|
||||||
|
engine_args:
|
||||||
|
model: Qwen/Qwen-Image
|
||||||
|
parallel_config:
|
||||||
|
world_size: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
For multi-device diffusion pipelines, `parallel_config.world_size` is treated as the number of devices per replica. For example, four 2-GPU replicas need eight devices:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
stages:
|
||||||
|
- stage_id: 0
|
||||||
|
stage_type: diffusion
|
||||||
|
runtime:
|
||||||
|
devices: "0,1,2,3,4,5,6,7"
|
||||||
|
num_replicas: 4
|
||||||
|
engine_args:
|
||||||
|
model: Wan-AI/Wan2.2-T2V-A14B-Diffusers
|
||||||
|
parallel_config:
|
||||||
|
world_size: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime splits the device list into contiguous chunks per replica. With the second example, replica 0 uses `0,1`, replica 1 uses `2,3`, replica 2 uses `4,5`, and replica 3 uses `6,7`.
|
||||||
|
|
||||||
|
## Serving
|
||||||
|
|
||||||
|
Start the server with the stage config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vllm serve Qwen/Qwen-Image \
|
||||||
|
--omni \
|
||||||
|
--port 8099 \
|
||||||
|
--stage-configs-path /path/to/diffusion_multi_replica.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Then send normal OpenAI-compatible diffusion chat requests. The orchestrator routes new requests across replicas and keeps updates/abort calls on the same replica for each request.
|
||||||
|
|
||||||
|
## Benchmarking
|
||||||
|
|
||||||
|
Benchmark each replica count by changing `runtime.num_replicas` and `runtime.devices`, restarting the server, then running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python benchmarks/diffusion/diffusion_benchmark_serving.py \
|
||||||
|
--base-url http://localhost:8099 \
|
||||||
|
--model Qwen/Qwen-Image \
|
||||||
|
--task t2i \
|
||||||
|
--dataset random \
|
||||||
|
--num-prompts 8 \
|
||||||
|
--request-rate inf \
|
||||||
|
--num-inference-steps 2 \
|
||||||
|
--width 256 --height 256 \
|
||||||
|
--output-file diffusion_replica_4.json
|
||||||
|
```
|
||||||
|
|
||||||
|
For a quick scaling sweep, test `num_replicas` values `1`, `2`, `3`, and `4` with the same prompt count and generation parameters. Compare throughput and end-to-end latency from the JSON outputs.
|
||||||
|
|
||||||
|
## Notes and Limitations
|
||||||
|
|
||||||
|
- Multi-replica serving is currently supported for diffusion stages. Single-stage mode still rejects non-diffusion replicas.
|
||||||
|
- `runtime.devices` must provide exactly `num_replicas * devices_per_replica` devices.
|
||||||
|
- Request-level latency may stay similar or slightly increase because each request still runs on one replica.
|
||||||
|
- Throughput improves only when enough concurrent requests are issued and each replica has enough GPU memory.
|
||||||
@@ -257,8 +257,11 @@ async def run_single_request(
|
|||||||
new_chunks = audio_data[audio_list_consumed:]
|
new_chunks = audio_data[audio_list_consumed:]
|
||||||
audio_list_consumed = len(audio_data)
|
audio_list_consumed = len(audio_data)
|
||||||
elif isinstance(audio_data, torch.Tensor):
|
elif isinstance(audio_data, torch.Tensor):
|
||||||
new_chunks = [audio_data]
|
if audio_data is not audio_last_tensor:
|
||||||
audio_last_tensor = audio_data
|
new_chunks = [audio_data]
|
||||||
|
audio_last_tensor = audio_data
|
||||||
|
else:
|
||||||
|
new_chunks = []
|
||||||
else:
|
else:
|
||||||
new_chunks = []
|
new_chunks = []
|
||||||
|
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Qwen3-Omni end2end functional test script
|
||||||
|
# Uses default built-in assets, iterates over query-type x output-modalities combinations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export VLLM_OMNI_USE_V2_RUNNER=1
|
||||||
|
|
||||||
|
MODEL_PATH="/models/Qwen3-Omni-30B-A3B-Instruct"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
END2END="$SCRIPT_DIR/end2end.py"
|
||||||
|
LOG_DIR="$SCRIPT_DIR/test_logs"
|
||||||
|
SUMMARY_FILE="$LOG_DIR/summary.log"
|
||||||
|
|
||||||
|
rm -rf "$LOG_DIR"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
> "$SUMMARY_FILE"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
TOTAL=0
|
||||||
|
|
||||||
|
run_test() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local log_file="$LOG_DIR/${name}.log"
|
||||||
|
local output_dir="$LOG_DIR/${name}_output"
|
||||||
|
|
||||||
|
TOTAL=$((TOTAL + 1))
|
||||||
|
echo "========================================"
|
||||||
|
echo "[${TOTAL}] Running: ${name}"
|
||||||
|
echo " Command: python $END2END $*"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
local start_time
|
||||||
|
start_time=$(date +%s)
|
||||||
|
|
||||||
|
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
|
||||||
|
local end_time
|
||||||
|
end_time=$(date +%s)
|
||||||
|
local elapsed=$((end_time - start_time))
|
||||||
|
echo " => PASS (${elapsed}s)"
|
||||||
|
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
local exit_code=$?
|
||||||
|
local end_time
|
||||||
|
end_time=$(date +%s)
|
||||||
|
local elapsed=$((end_time - start_time))
|
||||||
|
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
|
||||||
|
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
|
||||||
|
# Append last 30 lines of log to summary
|
||||||
|
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
|
||||||
|
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
|
||||||
|
echo " --- end ---" >> "$SUMMARY_FILE"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test cases
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- 1. Text-only input ---
|
||||||
|
run_test "text_to_text" \
|
||||||
|
--model "$MODEL_PATH" --query-type text --modalities text
|
||||||
|
|
||||||
|
run_test "text_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type text --modalities text,audio
|
||||||
|
|
||||||
|
run_test "text_to_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type text --modalities audio
|
||||||
|
|
||||||
|
# --- 2. Image input ---
|
||||||
|
run_test "image_to_text" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_image --modalities text
|
||||||
|
|
||||||
|
run_test "image_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_image --modalities text,audio
|
||||||
|
|
||||||
|
# --- 3. Audio input ---
|
||||||
|
run_test "audio_to_text" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_audio --modalities text
|
||||||
|
|
||||||
|
run_test "audio_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_audio --modalities text,audio
|
||||||
|
|
||||||
|
# --- 4. Video input ---
|
||||||
|
run_test "video_to_text" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_video --modalities text
|
||||||
|
|
||||||
|
run_test "video_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_video --modalities text,audio
|
||||||
|
|
||||||
|
# --- 5. Multi-audio input ---
|
||||||
|
run_test "multi_audios_to_text" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_multi_audios --modalities text
|
||||||
|
|
||||||
|
run_test "multi_audios_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_multi_audios --modalities text,audio
|
||||||
|
|
||||||
|
# --- 6. Mixed modality input (audio+image+video) ---
|
||||||
|
run_test "mixed_to_text" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_mixed_modalities --modalities text
|
||||||
|
|
||||||
|
run_test "mixed_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_mixed_modalities --modalities text,audio
|
||||||
|
|
||||||
|
# --- 7. Audio from video ---
|
||||||
|
run_test "audio_in_video_to_text" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_audio_in_video --modalities text
|
||||||
|
|
||||||
|
run_test "audio_in_video_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_audio_in_video --modalities text,audio
|
||||||
|
|
||||||
|
# --- 8. py-generator mode ---
|
||||||
|
run_test "text_to_text_generator" \
|
||||||
|
--model "$MODEL_PATH" --query-type text --modalities text --py-generator
|
||||||
|
|
||||||
|
run_test "audio_to_text_audio_generator" \
|
||||||
|
--model "$MODEL_PATH" --query-type use_audio --modalities text,audio --py-generator
|
||||||
|
|
||||||
|
# --- 9. Multi-prompt batch inference ---
|
||||||
|
run_test "text_to_text_batch3" \
|
||||||
|
--model "$MODEL_PATH" --query-type text --modalities text --num-prompts 3
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Summary
|
||||||
|
# =============================================================================
|
||||||
|
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "Detailed logs: $LOG_DIR"
|
||||||
|
echo "Summary report: $SUMMARY_FILE"
|
||||||
|
|
||||||
|
exit $FAIL
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Qwen3-Omni end2end_async_chunk functional test script
|
||||||
|
# Uses default built-in assets, iterates over AsyncOmni query-type x output-modalities combinations
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export VLLM_OMNI_USE_V2_RUNNER=1
|
||||||
|
|
||||||
|
MODEL_PATH="/models/Qwen3-Omni-30B-A3B-Instruct"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
END2END="$SCRIPT_DIR/end2end_async_chunk.py"
|
||||||
|
STAGE_CONFIGS="$SCRIPT_DIR/../../../vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml"
|
||||||
|
LOG_DIR="$SCRIPT_DIR/test_logs_async_chunk"
|
||||||
|
SUMMARY_FILE="$LOG_DIR/summary.log"
|
||||||
|
|
||||||
|
rm -rf "$LOG_DIR"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
> "$SUMMARY_FILE"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
TOTAL=0
|
||||||
|
|
||||||
|
run_test() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local log_file="$LOG_DIR/${name}.log"
|
||||||
|
local output_dir="$LOG_DIR/${name}_output"
|
||||||
|
|
||||||
|
TOTAL=$((TOTAL + 1))
|
||||||
|
echo "========================================"
|
||||||
|
echo "[${TOTAL}] Running: ${name}"
|
||||||
|
echo " Command: python $END2END $*"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
local start_time
|
||||||
|
start_time=$(date +%s)
|
||||||
|
|
||||||
|
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
|
||||||
|
local end_time
|
||||||
|
end_time=$(date +%s)
|
||||||
|
local elapsed=$((end_time - start_time))
|
||||||
|
echo " => PASS (${elapsed}s)"
|
||||||
|
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
local exit_code=$?
|
||||||
|
local end_time
|
||||||
|
end_time=$(date +%s)
|
||||||
|
local elapsed=$((end_time - start_time))
|
||||||
|
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
|
||||||
|
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
|
||||||
|
# Append last 30 lines of log to summary
|
||||||
|
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
|
||||||
|
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
|
||||||
|
echo " --- end ---" >> "$SUMMARY_FILE"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test cases
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- 1. Text-only input ---
|
||||||
|
run_test "text_to_text" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type text --modalities text
|
||||||
|
|
||||||
|
run_test "text_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type text --modalities text,audio
|
||||||
|
|
||||||
|
run_test "text_to_audio" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type text --modalities audio
|
||||||
|
|
||||||
|
# --- 2. Image input ---
|
||||||
|
run_test "image_to_text" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type use_image --modalities text
|
||||||
|
|
||||||
|
run_test "image_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type use_image --modalities text,audio
|
||||||
|
|
||||||
|
# --- 3. Audio input ---
|
||||||
|
run_test "audio_to_text" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type use_audio --modalities text
|
||||||
|
|
||||||
|
run_test "audio_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type use_audio --modalities text,audio
|
||||||
|
|
||||||
|
# --- 4. Video input ---
|
||||||
|
run_test "video_to_text" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type use_video --modalities text
|
||||||
|
|
||||||
|
run_test "video_to_text_audio" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type use_video --modalities text,audio
|
||||||
|
|
||||||
|
# --- 5. Multi-prompt batch inference ---
|
||||||
|
run_test "text_to_text_batch3" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type text --modalities text --num-prompts 3
|
||||||
|
|
||||||
|
# --- 6. Concurrent requests (max-in-flight) ---
|
||||||
|
run_test "text_to_text_audio_concurrent2" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type text --modalities text,audio --num-prompts 2 --max-in-flight 2
|
||||||
|
|
||||||
|
# --- 7. stream-audio-to-disk mode ---
|
||||||
|
run_test "audio_to_text_audio_stream" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type use_audio --modalities text,audio --stream-audio-to-disk
|
||||||
|
|
||||||
|
# --- 8. request-timeout test (long enough timeout, should PASS) ---
|
||||||
|
run_test "text_to_text_with_timeout" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type text --modalities text --request-timeout-s 600
|
||||||
|
|
||||||
|
# --- 9. batch-timeout test (long enough timeout, should PASS) ---
|
||||||
|
run_test "text_to_text_audio_batch_timeout" \
|
||||||
|
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
|
||||||
|
--query-type text --modalities text,audio --batch-timeout-s 600
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Summary
|
||||||
|
# =============================================================================
|
||||||
|
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "Detailed logs: $LOG_DIR"
|
||||||
|
echo "Summary report: $SUMMARY_FILE"
|
||||||
|
|
||||||
|
exit $FAIL
|
||||||
@@ -135,17 +135,18 @@ def _estimate_prompt_len(
|
|||||||
return 2048
|
return 2048
|
||||||
|
|
||||||
|
|
||||||
def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
|
def get_custom_voice_query(use_batch_sample: bool = False, model_name: str | None = None) -> QueryResult:
|
||||||
"""Build CustomVoice sample inputs.
|
"""Build CustomVoice sample inputs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
|
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
|
||||||
|
model_name: Override the default HF model path (e.g. with a local path).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QueryResult with Omni inputs and the CustomVoice model path.
|
QueryResult with Omni inputs and the CustomVoice model path.
|
||||||
"""
|
"""
|
||||||
task_type = "CustomVoice"
|
task_type = "CustomVoice"
|
||||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||||
if use_batch_sample:
|
if use_batch_sample:
|
||||||
texts = [
|
texts = [
|
||||||
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
|
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
|
||||||
@@ -196,17 +197,18 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
|
def get_voice_design_query(use_batch_sample: bool = False, model_name: str | None = None) -> QueryResult:
|
||||||
"""Build VoiceDesign sample inputs.
|
"""Build VoiceDesign sample inputs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
|
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
|
||||||
|
model_name: Override the default HF model path (e.g. with a local path).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QueryResult with Omni inputs and the VoiceDesign model path.
|
QueryResult with Omni inputs and the VoiceDesign model path.
|
||||||
"""
|
"""
|
||||||
task_type = "VoiceDesign"
|
task_type = "VoiceDesign"
|
||||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||||
if use_batch_sample:
|
if use_batch_sample:
|
||||||
texts = [
|
texts = [
|
||||||
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
|
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
|
||||||
@@ -255,18 +257,19 @@ def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> QueryResult:
|
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl", model_name: str | None = None) -> QueryResult:
|
||||||
"""Build Base (voice clone) sample inputs.
|
"""Build Base (voice clone) sample inputs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
use_batch_sample: When True, return a batch of prompts (Case 2).
|
use_batch_sample: When True, return a batch of prompts (Case 2).
|
||||||
mode_tag: "icl" or "xvec_only" to control x_vector_only_mode behavior.
|
mode_tag: "icl" or "xvec_only" to control x_vector_only_mode behavior.
|
||||||
|
model_name: Override the default HF model path (e.g. with a local path).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QueryResult with Omni inputs and the Base model path.
|
QueryResult with Omni inputs and the Base model path.
|
||||||
"""
|
"""
|
||||||
task_type = "Base"
|
task_type = "Base"
|
||||||
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||||
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
|
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
|
||||||
ref_audio_single = ref_audio_path_1
|
ref_audio_single = ref_audio_path_1
|
||||||
ref_text_single = (
|
ref_text_single = (
|
||||||
@@ -335,14 +338,17 @@ def _build_inputs(args) -> tuple[str, list]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
query_func = query_map[args.query_type]
|
query_func = query_map[args.query_type]
|
||||||
|
model_override = args.model if args.model else None
|
||||||
if args.query_type in {"CustomVoice", "VoiceDesign"}:
|
if args.query_type in {"CustomVoice", "VoiceDesign"}:
|
||||||
query_result = query_func(use_batch_sample=args.use_batch_sample)
|
query_result = query_func(use_batch_sample=args.use_batch_sample, model_name=model_override)
|
||||||
elif args.query_type == "Base":
|
elif args.query_type == "Base":
|
||||||
query_result = query_func(use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag)
|
query_result = query_func(
|
||||||
|
use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag, model_name=model_override
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
query_result = query_func()
|
query_result = query_func()
|
||||||
|
|
||||||
model_name = query_result.model_name
|
model_name = args.model if args.model else query_result.model_name
|
||||||
|
|
||||||
if args.txt_prompts:
|
if args.txt_prompts:
|
||||||
with open(args.txt_prompts) as f:
|
with open(args.txt_prompts) as f:
|
||||||
@@ -359,7 +365,10 @@ def _build_inputs(args) -> tuple[str, list]:
|
|||||||
for t in lines
|
for t in lines
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
inputs = query_result.inputs if isinstance(query_result.inputs, list) else [query_result.inputs]
|
if isinstance(query_result.inputs, list):
|
||||||
|
inputs = query_result.inputs * args.num_prompts
|
||||||
|
else:
|
||||||
|
inputs = [query_result.inputs] * args.num_prompts
|
||||||
|
|
||||||
return model_name, inputs
|
return model_name, inputs
|
||||||
|
|
||||||
@@ -434,6 +443,12 @@ async def main_streaming(args):
|
|||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = TrackingArgumentParser(description="Demo on using vLLM for offline inference with audio language models")
|
parser = TrackingArgumentParser(description="Demo on using vLLM for offline inference with audio language models")
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Override the default model path for the selected query type.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--query-type",
|
"--query-type",
|
||||||
"-q",
|
"-q",
|
||||||
|
|||||||
@@ -0,0 +1,640 @@
|
|||||||
|
"""Offline benchmark script for Qwen3 TTS via vLLM Omni.
|
||||||
|
|
||||||
|
Supports warmup + test rounds with a single Omni instance to avoid
|
||||||
|
repeated model loading. Prints per-round wall time summary at the end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
import soundfile as sf
|
||||||
|
import torch
|
||||||
|
|
||||||
|
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||||
|
|
||||||
|
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||||
|
|
||||||
|
from vllm_omni import AsyncOmni, Omni
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QueryResult(NamedTuple):
|
||||||
|
"""Container for a prepared Omni request."""
|
||||||
|
|
||||||
|
inputs: dict
|
||||||
|
model_name: str
|
||||||
|
|
||||||
|
|
||||||
|
_model_cache: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_prompt_len(
|
||||||
|
additional_information: dict[str, Any],
|
||||||
|
model_name: str,
|
||||||
|
) -> int:
|
||||||
|
"""Estimate prompt_token_ids placeholder length for the Talker stage."""
|
||||||
|
try:
|
||||||
|
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import Qwen3TTSConfig
|
||||||
|
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
|
||||||
|
Qwen3TTSTalkerForConditionalGeneration,
|
||||||
|
)
|
||||||
|
|
||||||
|
if model_name not in _model_cache:
|
||||||
|
from transformers import AutoTokenizer
|
||||||
|
|
||||||
|
tok = AutoTokenizer.from_pretrained(
|
||||||
|
model_name, trust_remote_code=True, padding_side="left", fix_mistral_regex=True
|
||||||
|
)
|
||||||
|
cfg = Qwen3TTSConfig.from_pretrained(model_name, trust_remote_code=True)
|
||||||
|
|
||||||
|
speech_tok = None
|
||||||
|
try:
|
||||||
|
from transformers.utils import cached_file
|
||||||
|
|
||||||
|
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_tokenizer import Qwen3TTSTokenizer
|
||||||
|
|
||||||
|
st_cfg_path = cached_file(model_name, "speech_tokenizer/config.json")
|
||||||
|
if st_cfg_path:
|
||||||
|
speech_tok = Qwen3TTSTokenizer.from_pretrained(
|
||||||
|
# os.path.dirname(st_cfg_path), torch_dtype=torch.bfloat16
|
||||||
|
os.path.dirname(st_cfg_path),
|
||||||
|
torch_dtype=torch.bfloat16,
|
||||||
|
fix_mistral_regex=True,
|
||||||
|
)
|
||||||
|
logger.info("Loaded speech tokenizer for exact ref_code_len estimation")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not load speech tokenizer: %s", e)
|
||||||
|
|
||||||
|
_model_cache[model_name] = (tok, getattr(cfg, "talker_config", None), speech_tok)
|
||||||
|
|
||||||
|
tok, tcfg, speech_tok = _model_cache[model_name]
|
||||||
|
task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
|
||||||
|
|
||||||
|
def _estimate_ref_code_len(ref_audio: object) -> int | None:
|
||||||
|
if not isinstance(ref_audio, (str, list)):
|
||||||
|
return None
|
||||||
|
audio_path = ref_audio[0] if isinstance(ref_audio, list) else ref_audio
|
||||||
|
if not isinstance(audio_path, str) or not audio_path.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from vllm.multimodal.media import MediaConnector
|
||||||
|
|
||||||
|
connector = MediaConnector(allowed_local_media_path="/")
|
||||||
|
audio, sr = connector.fetch_audio(audio_path)
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
wav_np = np.asarray(audio, dtype=np.float32)
|
||||||
|
|
||||||
|
if speech_tok is not None:
|
||||||
|
enc = speech_tok.encode(wav_np, sr=int(sr), return_dict=True)
|
||||||
|
ref_code = getattr(enc, "audio_codes", None)
|
||||||
|
if isinstance(ref_code, list):
|
||||||
|
ref_code = ref_code[0] if ref_code else None
|
||||||
|
if ref_code is not None and hasattr(ref_code, "shape"):
|
||||||
|
shape = ref_code.shape
|
||||||
|
return int(shape[0]) if len(shape) == 2 else int(shape[1]) if len(shape) == 3 else None
|
||||||
|
|
||||||
|
codec_hz = getattr(tcfg, "codec_frame_rate", None) or 12
|
||||||
|
return int(len(audio) / sr * codec_hz)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
|
||||||
|
additional_information=additional_information,
|
||||||
|
task_type=task_type,
|
||||||
|
tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
|
||||||
|
codec_language_id=getattr(tcfg, "codec_language_id", None),
|
||||||
|
spk_is_dialect=getattr(tcfg, "spk_is_dialect", None),
|
||||||
|
estimate_ref_code_len=_estimate_ref_code_len,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to estimate prompt length, using fallback 2048: %s", exc)
|
||||||
|
return 2048
|
||||||
|
|
||||||
|
|
||||||
|
def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
|
||||||
|
task_type = "CustomVoice"
|
||||||
|
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||||
|
if use_batch_sample:
|
||||||
|
texts = [
|
||||||
|
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
|
||||||
|
"She said she would be here by noon.",
|
||||||
|
"I like you very much.",
|
||||||
|
"Really, you do?",
|
||||||
|
"Yes, absolutely.",
|
||||||
|
]
|
||||||
|
instructs = ["", "Very happy.", "Very happy.", "Very happy.", "Very happy."]
|
||||||
|
languages = ["Chinese", "English", "English", "English", "English"]
|
||||||
|
speakers = ["Vivian", "Ryan", "Ryan", "Ryan", "Ryan"]
|
||||||
|
inputs = []
|
||||||
|
for text, instruct, language, speaker in zip(texts, instructs, languages, speakers):
|
||||||
|
additional_information = {
|
||||||
|
"task_type": [task_type],
|
||||||
|
"text": [text],
|
||||||
|
"instruct": [instruct],
|
||||||
|
"language": [language],
|
||||||
|
"speaker": [speaker],
|
||||||
|
"max_new_tokens": [2048],
|
||||||
|
}
|
||||||
|
inputs.append(
|
||||||
|
{
|
||||||
|
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||||
|
"additional_information": additional_information,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = "其实我真的有发现,我是一个特别善于观察别人情绪的人。"
|
||||||
|
language = "Chinese"
|
||||||
|
speaker = "Vivian"
|
||||||
|
instruct = "用特别愤怒的语气说"
|
||||||
|
additional_information = {
|
||||||
|
"task_type": [task_type],
|
||||||
|
"text": [text],
|
||||||
|
"language": [language],
|
||||||
|
"speaker": [speaker],
|
||||||
|
"instruct": [instruct],
|
||||||
|
"max_new_tokens": [2048],
|
||||||
|
}
|
||||||
|
inputs = {
|
||||||
|
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||||
|
"additional_information": additional_information,
|
||||||
|
}
|
||||||
|
return QueryResult(inputs=inputs, model_name=model_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
|
||||||
|
task_type = "VoiceDesign"
|
||||||
|
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||||
|
if use_batch_sample:
|
||||||
|
texts = [
|
||||||
|
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
|
||||||
|
"It's in the top drawer... wait, it's empty? No way, that's impossible! I'm sure I put it there!",
|
||||||
|
]
|
||||||
|
instructs = [
|
||||||
|
"体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。",
|
||||||
|
"Speak in an incredulous tone, but with a hint of panic beginning to creep into your voice.",
|
||||||
|
]
|
||||||
|
languages = ["Chinese", "English"]
|
||||||
|
inputs = []
|
||||||
|
for text, instruct, language in zip(texts, instructs, languages):
|
||||||
|
additional_information = {
|
||||||
|
"task_type": [task_type],
|
||||||
|
"text": [text],
|
||||||
|
"language": [language],
|
||||||
|
"instruct": [instruct],
|
||||||
|
"max_new_tokens": [2048],
|
||||||
|
"non_streaming_mode": [True],
|
||||||
|
}
|
||||||
|
inputs.append(
|
||||||
|
{
|
||||||
|
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||||
|
"additional_information": additional_information,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = "哥哥,你回来啦,人家等了你好久好久了,要抱抱!"
|
||||||
|
instruct = "体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。"
|
||||||
|
language = "Chinese"
|
||||||
|
additional_information = {
|
||||||
|
"task_type": [task_type],
|
||||||
|
"text": [text],
|
||||||
|
"language": [language],
|
||||||
|
"instruct": [instruct],
|
||||||
|
"max_new_tokens": [2048],
|
||||||
|
"non_streaming_mode": [True],
|
||||||
|
}
|
||||||
|
inputs = {
|
||||||
|
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||||
|
"additional_information": additional_information,
|
||||||
|
}
|
||||||
|
return QueryResult(inputs=inputs, model_name=model_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> QueryResult:
|
||||||
|
task_type = "Base"
|
||||||
|
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||||
|
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
|
||||||
|
ref_audio_single = ref_audio_path_1
|
||||||
|
ref_text_single = (
|
||||||
|
"Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
|
||||||
|
)
|
||||||
|
syn_text_single = "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye."
|
||||||
|
syn_lang_single = "Auto"
|
||||||
|
x_vector_only_mode = mode_tag == "xvec_only"
|
||||||
|
if use_batch_sample:
|
||||||
|
syn_text_batch = [
|
||||||
|
"Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.",
|
||||||
|
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
|
||||||
|
]
|
||||||
|
syn_lang_batch = ["Chinese", "English"]
|
||||||
|
inputs = []
|
||||||
|
for text, language in zip(syn_text_batch, syn_lang_batch):
|
||||||
|
additional_information = {
|
||||||
|
"task_type": [task_type],
|
||||||
|
"ref_audio": [ref_audio_single],
|
||||||
|
"ref_text": [ref_text_single],
|
||||||
|
"text": [text],
|
||||||
|
"language": [language],
|
||||||
|
"x_vector_only_mode": [x_vector_only_mode],
|
||||||
|
"max_new_tokens": [2048],
|
||||||
|
}
|
||||||
|
inputs.append(
|
||||||
|
{
|
||||||
|
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||||
|
"additional_information": additional_information,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
additional_information = {
|
||||||
|
"task_type": [task_type],
|
||||||
|
"ref_audio": [ref_audio_single],
|
||||||
|
"ref_text": [ref_text_single],
|
||||||
|
"text": [syn_text_single],
|
||||||
|
"language": [syn_lang_single],
|
||||||
|
"x_vector_only_mode": [x_vector_only_mode],
|
||||||
|
"max_new_tokens": [2048],
|
||||||
|
}
|
||||||
|
inputs = {
|
||||||
|
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
|
||||||
|
"additional_information": additional_information,
|
||||||
|
}
|
||||||
|
return QueryResult(inputs=inputs, model_name=model_name)
|
||||||
|
|
||||||
|
|
||||||
|
query_map = {
|
||||||
|
"CustomVoice": get_custom_voice_query,
|
||||||
|
"VoiceDesign": get_voice_design_query,
|
||||||
|
"Base": get_base_query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_inputs(args) -> tuple[str, list]:
|
||||||
|
"""Resolve model name and inputs list from CLI args."""
|
||||||
|
if args.batch_size < 1 or (args.batch_size & (args.batch_size - 1)) != 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"--batch-size must be a power of two (got {args.batch_size}); "
|
||||||
|
"non-power-of-two values do not align with CUDA graph capture sizes "
|
||||||
|
"of Code2Wav."
|
||||||
|
)
|
||||||
|
|
||||||
|
query_func = query_map[args.query_type]
|
||||||
|
if args.query_type in {"CustomVoice", "VoiceDesign"}:
|
||||||
|
query_result = query_func(use_batch_sample=args.use_batch_sample)
|
||||||
|
elif args.query_type == "Base":
|
||||||
|
query_result = query_func(use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag)
|
||||||
|
else:
|
||||||
|
query_result = query_func()
|
||||||
|
|
||||||
|
model_name = query_result.model_name
|
||||||
|
|
||||||
|
if args.txt_prompts:
|
||||||
|
with open(args.txt_prompts) as f:
|
||||||
|
lines = [line.strip() for line in f if line.strip()]
|
||||||
|
if not lines:
|
||||||
|
raise ValueError(f"No valid prompts found in {args.txt_prompts}")
|
||||||
|
template = query_result.inputs if not isinstance(query_result.inputs, list) else query_result.inputs[0]
|
||||||
|
template_info = template["additional_information"]
|
||||||
|
inputs = [
|
||||||
|
{
|
||||||
|
"prompt_token_ids": [0] * _estimate_prompt_len({**template_info, "text": [t]}, model_name),
|
||||||
|
"additional_information": {**template_info, "text": [t]},
|
||||||
|
}
|
||||||
|
for t in lines
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
inputs = query_result.inputs if isinstance(query_result.inputs, list) else [query_result.inputs]
|
||||||
|
|
||||||
|
return model_name, inputs
|
||||||
|
|
||||||
|
|
||||||
|
def _save_wav(output_dir: str, request_id: str, mm: dict) -> None:
|
||||||
|
"""Concatenate audio chunks and write to a wav file."""
|
||||||
|
audio_data = mm["audio"]
|
||||||
|
sr_raw = mm["sr"]
|
||||||
|
sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
|
||||||
|
sr = sr_val.item() if hasattr(sr_val, "item") else int(sr_val)
|
||||||
|
audio_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data
|
||||||
|
out_wav = os.path.join(output_dir, f"output_{request_id}.wav")
|
||||||
|
sf.write(out_wav, audio_tensor.float().cpu().numpy().flatten(), samplerate=sr, format="WAV")
|
||||||
|
logger.info("Saved audio to %s", out_wav)
|
||||||
|
|
||||||
|
|
||||||
|
class _SummaryCollector:
|
||||||
|
"""Monkey-patches omni._log_summary_and_cleanup to capture summary dicts.
|
||||||
|
|
||||||
|
NOTE: This relies on a private method ``_log_summary_and_cleanup`` which may
|
||||||
|
break if the upstream Omni implementation is refactored. If Omni exposes a
|
||||||
|
public hook for summary collection in the future, prefer that instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, omni):
|
||||||
|
self.omni = omni
|
||||||
|
self.summaries: list[dict[str, Any]] = []
|
||||||
|
self.collecting = False
|
||||||
|
self._orig = omni._log_summary_and_cleanup
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
from pprint import pformat as _pformat
|
||||||
|
|
||||||
|
collector = self
|
||||||
|
|
||||||
|
def _patched(request_id: str) -> None:
|
||||||
|
req_state = collector.omni.request_states.get(request_id)
|
||||||
|
try:
|
||||||
|
if req_state is None or req_state.metrics is None:
|
||||||
|
return
|
||||||
|
summary = req_state.metrics.build_and_log_summary()
|
||||||
|
logger.info("[Summary] %s", _pformat(summary, sort_dicts=False))
|
||||||
|
if collector.collecting and summary:
|
||||||
|
collector.summaries.append(summary)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to build/log summary for req=%s", request_id)
|
||||||
|
finally:
|
||||||
|
collector.omni.request_states.pop(request_id, None)
|
||||||
|
|
||||||
|
collector.omni._log_summary_and_cleanup = _patched
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(round_times: list[float], summaries: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
"""Print per-round wall time and optional detailed pipeline summary."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("BENCHMARK SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
for i, t in enumerate(round_times):
|
||||||
|
print(f" Test round {i}: {t:.3f}s")
|
||||||
|
print("-" * 60)
|
||||||
|
avg = sum(round_times) / len(round_times)
|
||||||
|
print(f" Rounds: {len(round_times)}")
|
||||||
|
print(f" Total: {sum(round_times):.3f}s")
|
||||||
|
print(f" Average: {avg:.3f}s")
|
||||||
|
print(f" Min: {min(round_times):.3f}s")
|
||||||
|
print(f" Max: {max(round_times):.3f}s")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not summaries:
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Aggregate from captured summaries ---
|
||||||
|
overall_list = [s["overall_summary"] for s in summaries if "overall_summary" in s]
|
||||||
|
if not overall_list:
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
n = len(overall_list)
|
||||||
|
|
||||||
|
# Average Wall time
|
||||||
|
avg_wall = sum(d["e2e_wall_time_ms"] for d in overall_list) / n
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("DETAILED PIPELINE SUMMARY (test rounds average)")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" Avg Wall Time: {avg_wall:.3f} ms")
|
||||||
|
|
||||||
|
# Per-stage timing
|
||||||
|
stage_keys = sorted(k for k in overall_list[0] if k.startswith("e2e_stage_") and k.endswith("_wall_time_ms"))
|
||||||
|
if stage_keys:
|
||||||
|
print("-" * 60)
|
||||||
|
print(" Per-stage timing (avg):")
|
||||||
|
for sk in stage_keys:
|
||||||
|
stage_avg = sum(d.get(sk, 0.0) for d in overall_list) / n
|
||||||
|
# extract stage index from key like e2e_stage_0_wall_time_ms
|
||||||
|
stage_idx = sk.replace("e2e_stage_", "").replace("_wall_time_ms", "")
|
||||||
|
print(f" Stage {stage_idx} wall time: {stage_avg:.3f} ms")
|
||||||
|
|
||||||
|
# Per-stage gen time from stage_table
|
||||||
|
all_stage_gen: dict[int, list[float]] = {}
|
||||||
|
for s in summaries:
|
||||||
|
for req_entry in s.get("stage_table", []):
|
||||||
|
for stage_info in req_entry.get("stages", []):
|
||||||
|
sid = stage_info.get("stage_id", -1)
|
||||||
|
gen_ms = stage_info.get("stage_gen_time_ms", 0.0)
|
||||||
|
all_stage_gen.setdefault(sid, []).append(gen_ms)
|
||||||
|
if all_stage_gen:
|
||||||
|
print(" Per-stage gen time (avg):")
|
||||||
|
for sid in sorted(all_stage_gen):
|
||||||
|
vals = all_stage_gen[sid]
|
||||||
|
print(f" Stage {sid} gen time: {sum(vals) / len(vals):.3f} ms")
|
||||||
|
|
||||||
|
# Per-prompt throughput
|
||||||
|
avg_tokens_per_s = sum(d.get("e2e_avg_tokens_per_s", 0.0) for d in overall_list) / n
|
||||||
|
avg_time_per_req = sum(d.get("e2e_avg_time_per_request_ms", 0.0) for d in overall_list) / n
|
||||||
|
total_tokens_list = [d.get("e2e_total_tokens", 0) for d in overall_list]
|
||||||
|
avg_total_tokens = sum(total_tokens_list) / n
|
||||||
|
print("-" * 60)
|
||||||
|
print(" Per-prompt throughput (avg):")
|
||||||
|
print(f" Tokens per request: {avg_total_tokens:.1f}")
|
||||||
|
print(f" Time per request: {avg_time_per_req:.3f} ms")
|
||||||
|
print(f" Tokens/s: {avg_tokens_per_s:.3f}")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main(args):
|
||||||
|
"""Run sync benchmark: warmup rounds then test rounds."""
|
||||||
|
model_name, inputs = _build_inputs(args)
|
||||||
|
output_dir = args.output_dir
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
omni = Omni(
|
||||||
|
model=model_name,
|
||||||
|
stage_configs_path=args.stage_configs_path,
|
||||||
|
log_stats=args.log_stats,
|
||||||
|
stage_init_timeout=args.stage_init_timeout,
|
||||||
|
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
|
||||||
|
)
|
||||||
|
|
||||||
|
collector = _SummaryCollector(omni)
|
||||||
|
collector.install()
|
||||||
|
|
||||||
|
batch_size = args.batch_size
|
||||||
|
|
||||||
|
# --- Warmup ---
|
||||||
|
for r in range(args.warmup_rounds):
|
||||||
|
print(f"[Warmup {r}/{args.warmup_rounds}] running...")
|
||||||
|
for batch_start in range(0, len(inputs), batch_size):
|
||||||
|
batch = inputs[batch_start : batch_start + batch_size]
|
||||||
|
for _ in omni.generate(batch):
|
||||||
|
pass
|
||||||
|
print(f"[Warmup {r}/{args.warmup_rounds}] done")
|
||||||
|
|
||||||
|
# --- Test ---
|
||||||
|
collector.collecting = True
|
||||||
|
round_times = []
|
||||||
|
for r in range(args.test_rounds):
|
||||||
|
print(f"[Test {r}/{args.test_rounds}] running...")
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
for batch_start in range(0, len(inputs), batch_size):
|
||||||
|
batch = inputs[batch_start : batch_start + batch_size]
|
||||||
|
for stage_outputs in omni.generate(batch):
|
||||||
|
output = stage_outputs.request_output
|
||||||
|
_save_wav(
|
||||||
|
output_dir,
|
||||||
|
f"round{r}_{output.request_id}",
|
||||||
|
output.outputs[0].multimodal_output,
|
||||||
|
)
|
||||||
|
elapsed = time.perf_counter() - t_start
|
||||||
|
round_times.append(elapsed)
|
||||||
|
print(f"[Test {r}/{args.test_rounds}] done in {elapsed:.3f}s")
|
||||||
|
collector.collecting = False
|
||||||
|
|
||||||
|
_print_summary(round_times, collector.summaries if args.log_stats else None)
|
||||||
|
|
||||||
|
|
||||||
|
async def main_streaming(args):
|
||||||
|
"""Run async streaming benchmark: warmup rounds then test rounds."""
|
||||||
|
model_name, inputs = _build_inputs(args)
|
||||||
|
output_dir = args.output_dir
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
omni = AsyncOmni(
|
||||||
|
model=model_name,
|
||||||
|
stage_configs_path=args.stage_configs_path,
|
||||||
|
log_stats=args.log_stats,
|
||||||
|
stage_init_timeout=args.stage_init_timeout,
|
||||||
|
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
|
||||||
|
)
|
||||||
|
|
||||||
|
collector = _SummaryCollector(omni)
|
||||||
|
collector.install()
|
||||||
|
|
||||||
|
# --- Warmup ---
|
||||||
|
for r in range(args.warmup_rounds):
|
||||||
|
print(f"[Warmup {r}/{args.warmup_rounds}] running...")
|
||||||
|
for i, prompt in enumerate(inputs):
|
||||||
|
async for _ in omni.generate(prompt, request_id=f"warmup_{r}_{i}"):
|
||||||
|
pass
|
||||||
|
print(f"[Warmup {r}/{args.warmup_rounds}] done")
|
||||||
|
|
||||||
|
# --- Test ---
|
||||||
|
collector.collecting = True
|
||||||
|
round_times = []
|
||||||
|
for r in range(args.test_rounds):
|
||||||
|
print(f"[Test {r}/{args.test_rounds}] running...")
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
for i, prompt in enumerate(inputs):
|
||||||
|
request_id = f"round{r}_{i}"
|
||||||
|
t_req_start = time.perf_counter()
|
||||||
|
t_prev = t_req_start
|
||||||
|
chunk_idx = 0
|
||||||
|
async for stage_output in omni.generate(prompt, request_id=request_id):
|
||||||
|
mm = stage_output.request_output.outputs[0].multimodal_output
|
||||||
|
if not stage_output.finished:
|
||||||
|
t_now = time.perf_counter()
|
||||||
|
audio = mm.get("audio")
|
||||||
|
n = len(audio) if isinstance(audio, list) else (0 if audio is None else 1)
|
||||||
|
if chunk_idx == 0:
|
||||||
|
ttfa_ms = (t_now - t_req_start) * 1000
|
||||||
|
logger.info("Request %s: chunk %d samples=%d TTFA=%.1fms", request_id, chunk_idx, n, ttfa_ms)
|
||||||
|
else:
|
||||||
|
dt_ms = (t_now - t_prev) * 1000
|
||||||
|
logger.info(
|
||||||
|
"Request %s: chunk %d samples=%d inter_chunk=%.1fms", request_id, chunk_idx, n, dt_ms
|
||||||
|
)
|
||||||
|
t_prev = t_now
|
||||||
|
chunk_idx += 1
|
||||||
|
else:
|
||||||
|
t_end = time.perf_counter()
|
||||||
|
total_ms = (t_end - t_req_start) * 1000
|
||||||
|
logger.info("Request %s: done total=%.1fms chunks=%d", request_id, total_ms, chunk_idx)
|
||||||
|
_save_wav(output_dir, request_id, mm)
|
||||||
|
elapsed = time.perf_counter() - t_start
|
||||||
|
round_times.append(elapsed)
|
||||||
|
print(f"[Test {r}/{args.test_rounds}] done in {elapsed:.3f}s")
|
||||||
|
collector.collecting = False
|
||||||
|
|
||||||
|
_print_summary(round_times, collector.summaries if args.log_stats else None)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = FlexibleArgumentParser(description="Benchmark script for Qwen3 TTS via vLLM Omni")
|
||||||
|
parser.add_argument(
|
||||||
|
"--query-type",
|
||||||
|
"-q",
|
||||||
|
type=str,
|
||||||
|
default="CustomVoice",
|
||||||
|
choices=query_map.keys(),
|
||||||
|
help="Query type.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--warmup-rounds",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Number of warmup rounds (results discarded, default: 3).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--test-rounds",
|
||||||
|
type=int,
|
||||||
|
default=7,
|
||||||
|
help="Number of test rounds (results saved and timed, default: 7).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-stats",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Enable writing detailed statistics (default: disabled).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--stage-init-timeout",
|
||||||
|
type=int,
|
||||||
|
default=300,
|
||||||
|
help="Timeout for initializing a single stage in seconds (default: 300).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
default="output_audio",
|
||||||
|
help="Output directory for generated wav files (default: output_audio).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--txt-prompts",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Path to a .txt file with one prompt per line.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--stage-configs-path",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Path to a stage configs file.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--use-batch-sample",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Use batch input sample for CustomVoice/VoiceDesign/Base query.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mode-tag",
|
||||||
|
type=str,
|
||||||
|
default="icl",
|
||||||
|
choices=["icl", "xvec_only"],
|
||||||
|
help="Mode tag for Base query x_vector_only_mode (default: icl).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--streaming",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Stream audio chunks via AsyncOmni.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Number of prompts per batch (default: 1).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--enable-diffusion-pipeline-profiler",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable diffusion pipeline profiler to display stage durations.",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parse_args()
|
||||||
|
if args.streaming:
|
||||||
|
asyncio.run(main_streaming(args))
|
||||||
|
else:
|
||||||
|
main(args)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Qwen3-TTS end2end functional test script
|
||||||
|
# Iterates over query-type and mode combinations for qwen3_tts/end2end.py
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./run_tests.sh --custom-voice-model /path/a --voice-design-model /path/b --base-model /path/c
|
||||||
|
# ./run_tests.sh --custom-voice-model /path/a # only run CustomVoice tests
|
||||||
|
# ./run_tests.sh --base-model /path/c # only run Base tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export VLLM_OMNI_USE_V2_RUNNER=1
|
||||||
|
# export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
|
||||||
|
|
||||||
|
# ---- Parse named arguments ----
|
||||||
|
CUSTOM_VOICE_MODEL=""
|
||||||
|
VOICE_DESIGN_MODEL=""
|
||||||
|
BASE_MODEL=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 [--custom-voice-model PATH] [--voice-design-model PATH] [--base-model PATH]"
|
||||||
|
echo ""
|
||||||
|
echo "Pass one or more model paths. Only the query types with a model provided will be tested."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--custom-voice-model) CUSTOM_VOICE_MODEL="$2"; shift 2 ;;
|
||||||
|
--voice-design-model) VOICE_DESIGN_MODEL="$2"; shift 2 ;;
|
||||||
|
--base-model) BASE_MODEL="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "Unknown option: $1"; usage ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$CUSTOM_VOICE_MODEL" && -z "$VOICE_DESIGN_MODEL" && -z "$BASE_MODEL" ]]; then
|
||||||
|
echo "Error: at least one model path must be provided."
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
END2END="$SCRIPT_DIR/end2end.py"
|
||||||
|
LOG_DIR="$SCRIPT_DIR/test_logs"
|
||||||
|
SUMMARY_FILE="$LOG_DIR/summary.log"
|
||||||
|
|
||||||
|
rm -rf "$LOG_DIR"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
> "$SUMMARY_FILE"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
TOTAL=0
|
||||||
|
|
||||||
|
run_test() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local log_file="$LOG_DIR/${name}.log"
|
||||||
|
local output_dir="$LOG_DIR/${name}_output"
|
||||||
|
|
||||||
|
TOTAL=$((TOTAL + 1))
|
||||||
|
echo "========================================"
|
||||||
|
echo "[${TOTAL}] Running: ${name}"
|
||||||
|
echo " Command: python $END2END $*"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
local start_time
|
||||||
|
start_time=$(date +%s)
|
||||||
|
|
||||||
|
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
|
||||||
|
local end_time
|
||||||
|
end_time=$(date +%s)
|
||||||
|
local elapsed=$((end_time - start_time))
|
||||||
|
echo " => PASS (${elapsed}s)"
|
||||||
|
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
local exit_code=$?
|
||||||
|
local end_time
|
||||||
|
end_time=$(date +%s)
|
||||||
|
local elapsed=$((end_time - start_time))
|
||||||
|
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
|
||||||
|
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
|
||||||
|
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
|
||||||
|
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
|
||||||
|
echo " --- end ---" >> "$SUMMARY_FILE"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test cases — only run sections whose model path was provided
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- 1. CustomVoice ---
|
||||||
|
if [[ -n "$CUSTOM_VOICE_MODEL" ]]; then
|
||||||
|
run_test "custom_voice" \
|
||||||
|
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice
|
||||||
|
|
||||||
|
run_test "custom_voice_batch_sample" \
|
||||||
|
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --use-batch-sample
|
||||||
|
|
||||||
|
run_test "custom_voice_py_generator" \
|
||||||
|
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --py-generator
|
||||||
|
|
||||||
|
run_test "custom_voice_streaming" \
|
||||||
|
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --streaming
|
||||||
|
|
||||||
|
run_test "custom_voice_batch4" \
|
||||||
|
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --batch-size 4
|
||||||
|
|
||||||
|
run_test "custom_voice_batch4_batch_sample" \
|
||||||
|
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --batch-size 4 --use-batch-sample --stage-configs-path "$SCRIPT_DIR/../../../vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml"
|
||||||
|
|
||||||
|
run_test "custom_voice_num_prompts3" \
|
||||||
|
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --num-prompts 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 2. VoiceDesign ---
|
||||||
|
if [[ -n "$VOICE_DESIGN_MODEL" ]]; then
|
||||||
|
run_test "voice_design" \
|
||||||
|
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign
|
||||||
|
|
||||||
|
run_test "voice_design_batch_sample" \
|
||||||
|
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --use-batch-sample
|
||||||
|
|
||||||
|
run_test "voice_design_py_generator" \
|
||||||
|
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --py-generator
|
||||||
|
|
||||||
|
run_test "voice_design_streaming" \
|
||||||
|
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --streaming
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Base (icl mode) ---
|
||||||
|
if [[ -n "$BASE_MODEL" ]]; then
|
||||||
|
run_test "base_icl" \
|
||||||
|
--model "$BASE_MODEL" --query-type Base --mode-tag icl
|
||||||
|
|
||||||
|
run_test "base_icl_batch_sample" \
|
||||||
|
--model "$BASE_MODEL" --query-type Base --mode-tag icl --use-batch-sample
|
||||||
|
|
||||||
|
run_test "base_icl_py_generator" \
|
||||||
|
--model "$BASE_MODEL" --query-type Base --mode-tag icl --py-generator
|
||||||
|
|
||||||
|
run_test "base_icl_streaming" \
|
||||||
|
--model "$BASE_MODEL" --query-type Base --mode-tag icl --streaming
|
||||||
|
|
||||||
|
# --- 4. Base (xvec_only mode) ---
|
||||||
|
run_test "base_xvec_only" \
|
||||||
|
--model "$BASE_MODEL" --query-type Base --mode-tag xvec_only
|
||||||
|
|
||||||
|
run_test "base_xvec_only_batch_sample" \
|
||||||
|
--model "$BASE_MODEL" --query-type Base --mode-tag xvec_only --use-batch-sample
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Summary
|
||||||
|
# =============================================================================
|
||||||
|
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo "========================================" | tee -a "$SUMMARY_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "Detailed logs: $LOG_DIR"
|
||||||
|
echo "Summary report: $SUMMARY_FILE"
|
||||||
|
|
||||||
|
exit $FAIL
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Default model name
|
||||||
|
MODEL="${MODEL:-Qwen/Qwen2.5-Omni-7B}"
|
||||||
|
|
||||||
# Default query type
|
# Default query type
|
||||||
QUERY_TYPE="${1:-mixed_modalities}"
|
QUERY_TYPE="${1:-mixed_modalities}"
|
||||||
|
|
||||||
@@ -162,7 +165,7 @@ echo ""
|
|||||||
|
|
||||||
request_body=$(cat <<EOF
|
request_body=$(cat <<EOF
|
||||||
{
|
{
|
||||||
"model": "Qwen/Qwen2.5-Omni-7B",
|
"model": "$MODEL",
|
||||||
"sampling_params_list": $sampling_params_list,
|
"sampling_params_list": $sampling_params_list,
|
||||||
"mm_processor_kwargs": $mm_processor_kwargs,
|
"mm_processor_kwargs": $mm_processor_kwargs,
|
||||||
"modalities": $MODALITIES,
|
"modalities": $MODALITIES,
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
|
|||||||
"Qwen/Qwen-Image-Edit",
|
"Qwen/Qwen-Image-Edit",
|
||||||
True,
|
True,
|
||||||
id="image-to-image-dalle-endpoint",
|
id="image-to-image-dalle-endpoint",
|
||||||
|
marks=pytest.mark.skip(reason="Temporarily disabled due to failure."),
|
||||||
),
|
),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
ServerCase(
|
ServerCase(
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Tests for OmniGenerationScheduler update_from_output finish conditions.
|
||||||
|
|
||||||
|
The three-way finish condition in update_from_output determines when a
|
||||||
|
generation request (e.g. Code2Wav) is marked FINISHED_STOPPED:
|
||||||
|
|
||||||
|
1. No adapter (sync mode): finish when all prompt tokens are computed.
|
||||||
|
2. Adapter present, adapter done: finish when computed AND adapter signals done.
|
||||||
|
3. Adapter present, adapter NOT done: do NOT finish — wait for next chunk.
|
||||||
|
|
||||||
|
These cases are the core correctness guarantee for async_chunk batch mode.
|
||||||
|
Without condition #3, a request finishes prematurely after processing
|
||||||
|
only the first chunk, producing truncated audio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from vllm.v1.request import RequestStatus
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequest:
|
||||||
|
"""Minimal request for testing finish conditions."""
|
||||||
|
|
||||||
|
def __init__(self, request_id: str, prompt_len: int, num_computed: int):
|
||||||
|
self.request_id = request_id
|
||||||
|
self.prompt_token_ids = list(range(prompt_len))
|
||||||
|
self.num_computed_tokens = num_computed
|
||||||
|
self.status = RequestStatus.RUNNING
|
||||||
|
self.stop_reason = None
|
||||||
|
self.sampling_params = None
|
||||||
|
self.num_cached_tokens = 0
|
||||||
|
self.num_output_tokens = 0
|
||||||
|
self.num_output_placeholders = 0
|
||||||
|
self.num_external_computed_tokens = 0
|
||||||
|
self.trace_headers = None
|
||||||
|
self.num_nans_in_logits = 0
|
||||||
|
self.client_index = 0
|
||||||
|
|
||||||
|
def is_finished(self):
|
||||||
|
return self.status in (
|
||||||
|
RequestStatus.FINISHED_STOPPED,
|
||||||
|
RequestStatus.FINISHED_LENGTH_CAPPED,
|
||||||
|
RequestStatus.FINISHED_ABORTED,
|
||||||
|
RequestStatus.FINISHED_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_finished_reason(self):
|
||||||
|
return "stop"
|
||||||
|
|
||||||
|
def take_events(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeChunkAdapter:
|
||||||
|
"""Minimal chunk transfer adapter mock."""
|
||||||
|
|
||||||
|
def __init__(self, finished_request_ids: set[str] | None = None):
|
||||||
|
self.finished_requests = finished_request_ids or set()
|
||||||
|
|
||||||
|
def cleanup(self, req_id, external_req_id=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_finish_condition(request, chunk_transfer_adapter):
|
||||||
|
"""Extract the three-way finish logic from update_from_output."""
|
||||||
|
_all_computed = request.num_computed_tokens >= len(request.prompt_token_ids)
|
||||||
|
_adapter_done = (
|
||||||
|
chunk_transfer_adapter is not None and request.request_id in chunk_transfer_adapter.finished_requests
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
request.status == RequestStatus.FINISHED_STOPPED
|
||||||
|
or (_all_computed and chunk_transfer_adapter is None)
|
||||||
|
or (_all_computed and _adapter_done)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinishConditionSyncMode(unittest.TestCase):
|
||||||
|
"""Case 1: No adapter (sync mode) — finish when all tokens computed."""
|
||||||
|
|
||||||
|
def test_finishes_when_all_computed(self):
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=100)
|
||||||
|
assert _evaluate_finish_condition(req, chunk_transfer_adapter=None)
|
||||||
|
|
||||||
|
def test_does_not_finish_when_partially_computed(self):
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=50)
|
||||||
|
assert not _evaluate_finish_condition(req, chunk_transfer_adapter=None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinishConditionAsyncAdapterDone(unittest.TestCase):
|
||||||
|
"""Case 2: Adapter present AND adapter signals done — finish."""
|
||||||
|
|
||||||
|
def test_finishes_when_computed_and_adapter_done(self):
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=100)
|
||||||
|
adapter = FakeChunkAdapter(finished_request_ids={"r1"})
|
||||||
|
assert _evaluate_finish_condition(req, adapter)
|
||||||
|
|
||||||
|
def test_does_not_finish_when_computed_but_adapter_not_done(self):
|
||||||
|
"""Case 3: Adapter present but NOT done — must NOT finish."""
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=100)
|
||||||
|
adapter = FakeChunkAdapter(finished_request_ids=set())
|
||||||
|
assert not _evaluate_finish_condition(req, adapter)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinishConditionAsyncNotComputed(unittest.TestCase):
|
||||||
|
"""Adapter present, tokens not all computed — never finishes."""
|
||||||
|
|
||||||
|
def test_does_not_finish_partial_compute_adapter_done(self):
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=50)
|
||||||
|
adapter = FakeChunkAdapter(finished_request_ids={"r1"})
|
||||||
|
assert not _evaluate_finish_condition(req, adapter)
|
||||||
|
|
||||||
|
def test_does_not_finish_partial_compute_adapter_not_done(self):
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=50)
|
||||||
|
adapter = FakeChunkAdapter(finished_request_ids=set())
|
||||||
|
assert not _evaluate_finish_condition(req, adapter)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFinishConditionAlreadyStopped(unittest.TestCase):
|
||||||
|
"""Request already FINISHED_STOPPED — always returns True."""
|
||||||
|
|
||||||
|
def test_already_stopped_no_adapter(self):
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=0)
|
||||||
|
req.status = RequestStatus.FINISHED_STOPPED
|
||||||
|
assert _evaluate_finish_condition(req, chunk_transfer_adapter=None)
|
||||||
|
|
||||||
|
def test_already_stopped_with_adapter(self):
|
||||||
|
req = FakeRequest("r1", prompt_len=100, num_computed=0)
|
||||||
|
req.status = RequestStatus.FINISHED_STOPPED
|
||||||
|
adapter = FakeChunkAdapter(finished_request_ids=set())
|
||||||
|
assert _evaluate_finish_condition(req, adapter)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -61,6 +61,7 @@ models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
|
|||||||
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
|
pytestmark = [pytest.mark.full_model, pytest.mark.omni]
|
||||||
|
|
||||||
_CI_DEPLOY = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
|
_CI_DEPLOY = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
|
||||||
|
_CI_MIN_DAILY_OMNI_ACCURACY = "0.685"
|
||||||
|
|
||||||
|
|
||||||
def get_chunk_config(config_path: str | None = None):
|
def get_chunk_config(config_path: str | None = None):
|
||||||
@@ -119,9 +120,11 @@ def test_qwen3_omni_daily_omni_accuracy_bench(omni_server) -> None:
|
|||||||
_require_vllm_cli()
|
_require_vllm_cli()
|
||||||
pytest.importorskip("datasets")
|
pytest.importorskip("datasets")
|
||||||
pytest.importorskip("huggingface_hub")
|
pytest.importorskip("huggingface_hub")
|
||||||
ns = _acc_bench.parse_acc_benchmark_args(
|
argv = build_acc_benchmark_cli_argv(omni_server, skip_seed=True, skip_daily=False)
|
||||||
build_acc_benchmark_cli_argv(omni_server, skip_seed=True, skip_daily=False)
|
# Daily-Omni's full-set score can move by a few MCQ items across GPU and
|
||||||
)
|
# batching schedules; keep CI below the nominal 0.69 CLI baseline.
|
||||||
|
argv.extend(["--min-daily-omni-accuracy", _CI_MIN_DAILY_OMNI_ACCURACY])
|
||||||
|
ns = _acc_bench.parse_acc_benchmark_args(argv)
|
||||||
assert _acc_bench.run_acc_benchmark(ns) == 0
|
assert _acc_bench.run_acc_benchmark(ns) == 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from tests.helpers.mark import hardware_test
|
||||||
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||||
|
from vllm_omni.outputs import OmniRequestOutput
|
||||||
|
from vllm_omni.platforms import current_omni_platform
|
||||||
|
|
||||||
|
# ruff: noqa: E402
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from vllm_omni import Omni
|
||||||
|
|
||||||
|
# Prefer a tiny/random checkpoint for CI.
|
||||||
|
# Override in CI if needed: AUDIOX_TEST_MODEL=<model-or-local-path>
|
||||||
|
models = [os.environ.get("AUDIOX_TEST_MODEL", "zhangj1an/audiox_random")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.core_model
|
||||||
|
@pytest.mark.diffusion
|
||||||
|
@hardware_test(res={"cuda": "L4", "xpu": "B60"})
|
||||||
|
@pytest.mark.parametrize("model_name", models)
|
||||||
|
def test_audiox_model(model_name: str):
|
||||||
|
m = Omni(model=model_name, model_class_name="AudioXPipeline")
|
||||||
|
|
||||||
|
# Keep runtime short for CI.
|
||||||
|
seconds_total = 2.0
|
||||||
|
# AudioXPipeline always emits 44.1 kHz stereo (advertised via class-level
|
||||||
|
# ``audio_sample_rate``); the trimmed output should match this rate.
|
||||||
|
sample_rate = 44100
|
||||||
|
|
||||||
|
outputs = m.generate(
|
||||||
|
prompts={"prompt": "A dog barking in a quiet park."},
|
||||||
|
sampling_params_list=OmniDiffusionSamplingParams(
|
||||||
|
num_inference_steps=4,
|
||||||
|
guidance_scale=6.0,
|
||||||
|
generator=torch.Generator(current_omni_platform.device_type).manual_seed(42),
|
||||||
|
num_outputs_per_prompt=1,
|
||||||
|
extra_args={
|
||||||
|
"audiox_task": "t2a",
|
||||||
|
"seconds_start": 0.0,
|
||||||
|
"seconds_total": seconds_total,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outputs is not None
|
||||||
|
first_output = outputs[0]
|
||||||
|
assert first_output.final_output_type == "audio"
|
||||||
|
assert hasattr(first_output, "request_output") and first_output.request_output
|
||||||
|
|
||||||
|
req_out = first_output.request_output
|
||||||
|
assert isinstance(req_out, OmniRequestOutput)
|
||||||
|
assert req_out.final_output_type == "audio"
|
||||||
|
assert hasattr(req_out, "multimodal_output") and req_out.multimodal_output
|
||||||
|
|
||||||
|
audio = req_out.multimodal_output.get("audio")
|
||||||
|
assert isinstance(audio, np.ndarray)
|
||||||
|
# audio shape: (batch, channels, samples)
|
||||||
|
assert audio.ndim == 3
|
||||||
|
assert audio.shape[0] == 1
|
||||||
|
assert audio.shape[1] == 2
|
||||||
|
assert audio.shape[2] > 0
|
||||||
|
expected_samples = int(seconds_total * sample_rate)
|
||||||
|
assert abs(audio.shape[2] - expected_samples) <= 2 * 1024
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
"""
|
||||||
|
End-to-end test for ErnieImage text-to-image generation.
|
||||||
|
|
||||||
|
Equivalent to running:
|
||||||
|
vllm serve baidu/ERNIE-Image --omni
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from tests.helpers.runtime import OmniRunner
|
||||||
|
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||||
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||||
|
|
||||||
|
MODEL = "baidu/ERNIE-Image"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.core_model
|
||||||
|
@pytest.mark.diffusion
|
||||||
|
def test_ernie_image_text_to_image():
|
||||||
|
with OmniRunner(
|
||||||
|
MODEL,
|
||||||
|
parallel_config=DiffusionParallelConfig(
|
||||||
|
tensor_parallel_size=2,
|
||||||
|
),
|
||||||
|
enable_cpu_offload=True,
|
||||||
|
) as runner:
|
||||||
|
omni_outputs = list(
|
||||||
|
runner.omni.generate(
|
||||||
|
prompts=["A photo of a cat sitting on a laptop"],
|
||||||
|
sampling_params_list=OmniDiffusionSamplingParams(
|
||||||
|
height=512,
|
||||||
|
width=512,
|
||||||
|
num_inference_steps=2,
|
||||||
|
guidance_scale=4.0,
|
||||||
|
seed=42,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(omni_outputs) > 0
|
||||||
|
output = omni_outputs[0]
|
||||||
|
images = None
|
||||||
|
if output.images:
|
||||||
|
images = output.images
|
||||||
|
elif hasattr(output, "request_output") and output.request_output:
|
||||||
|
for stage_out in output.request_output:
|
||||||
|
if hasattr(stage_out, "images") and stage_out.images:
|
||||||
|
images = stage_out.images
|
||||||
|
break
|
||||||
|
|
||||||
|
assert images is not None
|
||||||
|
assert len(images) > 0
|
||||||
|
assert isinstance(images[0], Image.Image)
|
||||||
|
assert images[0].size == (512, 512)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
"""Offline E2E test for InternVLA-A1 open-loop inference."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers.mark import hardware_test
|
||||||
|
|
||||||
|
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
EXAMPLE_SCRIPT = REPO_ROOT / "examples" / "offline_inference" / "internvla_a1" / "end2end.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _required_env(name: str) -> str:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if not value:
|
||||||
|
pytest.skip(f"{name} is not set")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.core_model
|
||||||
|
@pytest.mark.advanced_model
|
||||||
|
@pytest.mark.diffusion
|
||||||
|
@hardware_test(res={"cuda": "H100", "rocm": "MI325"})
|
||||||
|
def test_internvla_a1_offline_open_loop(run_level: str) -> None:
|
||||||
|
if run_level != "advanced_model":
|
||||||
|
pytest.skip("InternVLA-A1 offline evaluation requires real local checkpoints and dataset.")
|
||||||
|
|
||||||
|
model_dir = _required_env("INTERNVLA_A1_MODEL_DIR")
|
||||||
|
dataset_dir = _required_env("INTERNVLA_A1_DATASET_DIR")
|
||||||
|
processor_dir = _required_env("INTERNVLA_A1_PROCESSOR_DIR")
|
||||||
|
cosmos_dir = _required_env("INTERNVLA_A1_COSMOS_DIR")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["INTERNVLA_A1_PROCESSOR_DIR"] = processor_dir
|
||||||
|
env["INTERNVLA_A1_COSMOS_DIR"] = cosmos_dir
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="internvla_a1_e2e_") as tmpdir:
|
||||||
|
output_dir = Path(tmpdir) / "outputs"
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(EXAMPLE_SCRIPT),
|
||||||
|
"--model-dir",
|
||||||
|
model_dir,
|
||||||
|
"--dataset-dir",
|
||||||
|
dataset_dir,
|
||||||
|
"--output-dir",
|
||||||
|
str(output_dir),
|
||||||
|
"--num-samples",
|
||||||
|
"1",
|
||||||
|
"--num-episodes",
|
||||||
|
"1",
|
||||||
|
"--dtype",
|
||||||
|
"float32",
|
||||||
|
"--attn-implementation",
|
||||||
|
"eager",
|
||||||
|
"--skip-plots",
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True, env=env)
|
||||||
|
|
||||||
|
summary_path = output_dir / "summary.json"
|
||||||
|
assert summary_path.exists(), f"Missing summary file: {summary_path}"
|
||||||
|
|
||||||
|
with open(summary_path, encoding="utf-8") as f:
|
||||||
|
summary = json.load(f)
|
||||||
|
|
||||||
|
assert summary["mode"] == "registry"
|
||||||
|
assert summary["attn_implementation"] == "eager"
|
||||||
|
assert summary["dtype"] == "float32"
|
||||||
|
assert len(summary["results"]) == 1
|
||||||
|
assert "registry" in summary["eval_summaries"]
|
||||||
|
|
||||||
|
eval_summary = summary["eval_summaries"]["registry"]
|
||||||
|
assert eval_summary["num_episodes"] == 1
|
||||||
|
assert eval_summary["average_mse"] is not None
|
||||||
|
assert eval_summary["average_mae"] is not None
|
||||||
|
|
||||||
|
log_path = output_dir / "registry" / "log.json"
|
||||||
|
assert log_path.exists(), f"Missing evaluation log: {log_path}"
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""E2E offline inference tests for MOSS-TTS-Nano single-stage pipeline.
|
||||||
|
|
||||||
|
Every request needs a reference audio clip. We test upstream's
|
||||||
|
recommended ``voice_clone`` mode (no transcript needed). We fetch the
|
||||||
|
upstream sample (assets/audio/zh_1.wav, ~50 KB) once per test session
|
||||||
|
and reuse it for all cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
from vllm import SamplingParams
|
||||||
|
|
||||||
|
from tests.helpers.mark import hardware_test
|
||||||
|
from vllm_omni import Omni
|
||||||
|
|
||||||
|
MODEL_NAME = "OpenMOSS-Team/MOSS-TTS-Nano"
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
REF_AUDIO_URL = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
|
||||||
|
|
||||||
|
DEFAULT_SAMPLING = SamplingParams(
|
||||||
|
temperature=1.0,
|
||||||
|
top_p=1.0,
|
||||||
|
top_k=50,
|
||||||
|
max_tokens=4096,
|
||||||
|
seed=42,
|
||||||
|
detokenize=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def ref_audio_path(tmp_path_factory) -> str:
|
||||||
|
"""Download the upstream reference clip once per test session.
|
||||||
|
|
||||||
|
Bounded with a 30 s timeout (urlretrieve has no timeout kwarg, so we use
|
||||||
|
urlopen + write ourselves). Fetch failures are hard failures, not skips,
|
||||||
|
so a broken network path can't silently mask regressions. Set
|
||||||
|
``MOSS_TTS_NANO_SKIP_ON_NET_FAIL=1`` to opt into skipping for air-gapped
|
||||||
|
environments.
|
||||||
|
"""
|
||||||
|
cache_dir = tmp_path_factory.mktemp("moss_tts_nano_ref")
|
||||||
|
target = cache_dir / "zh_1.wav"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(REF_AUDIO_URL, timeout=30) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
target.write_bytes(data)
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Cannot fetch upstream reference clip {REF_AUDIO_URL}: {e}"
|
||||||
|
if os.environ.get("MOSS_TTS_NANO_SKIP_ON_NET_FAIL"):
|
||||||
|
pytest.skip(msg)
|
||||||
|
pytest.fail(msg)
|
||||||
|
if not target.exists() or os.path.getsize(target) == 0:
|
||||||
|
pytest.fail(f"Reference clip empty after download: {target}")
|
||||||
|
return str(target)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(
|
||||||
|
text: str,
|
||||||
|
prompt_audio_path: str,
|
||||||
|
prompt_text: str | None = None,
|
||||||
|
mode: str = "voice_clone",
|
||||||
|
max_new_frames: int = 100, # short for tests
|
||||||
|
seed: int = 42,
|
||||||
|
) -> dict:
|
||||||
|
"""Build a MOSS-TTS-Nano offline request.
|
||||||
|
|
||||||
|
Upstream forbids ``prompt_text`` in ``voice_clone`` mode; only forward
|
||||||
|
it when explicitly supplied (and typically with ``mode='continuation'``).
|
||||||
|
"""
|
||||||
|
additional: dict = {
|
||||||
|
"text": [text],
|
||||||
|
"mode": [mode],
|
||||||
|
"prompt_audio_path": [prompt_audio_path],
|
||||||
|
"max_new_frames": [max_new_frames],
|
||||||
|
"seed": [seed],
|
||||||
|
}
|
||||||
|
if prompt_text is not None:
|
||||||
|
additional["prompt_text"] = [prompt_text]
|
||||||
|
return {
|
||||||
|
"prompt": "<|im_start|>assistant\n",
|
||||||
|
"additional_information": additional,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_audio(omni: Omni, request: dict) -> tuple[torch.Tensor, int]:
|
||||||
|
"""Run a single request and return (waveform, sample_rate)."""
|
||||||
|
for stage_outputs in omni.generate(request, DEFAULT_SAMPLING):
|
||||||
|
for req_output in stage_outputs.request_output:
|
||||||
|
mm = req_output.outputs[0].multimodal_output
|
||||||
|
assert mm is not None, "Expected multimodal_output to be non-None"
|
||||||
|
audio = mm.get("audio")
|
||||||
|
sr = mm.get("sr")
|
||||||
|
assert audio is not None, "Expected 'audio' key in multimodal_output"
|
||||||
|
assert isinstance(audio, torch.Tensor), f"audio should be Tensor, got {type(audio)}"
|
||||||
|
return audio.cpu(), int(sr.item()) if sr is not None else SAMPLE_RATE
|
||||||
|
raise AssertionError("No stage outputs received")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def omni_engine():
|
||||||
|
"""Module-scoped Omni engine to avoid re-loading the model for each test."""
|
||||||
|
return Omni(model=MODEL_NAME, stage_init_timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"})
|
||||||
|
def test_moss_tts_nano_english(omni_engine, ref_audio_path):
|
||||||
|
"""English voice_clone mode (no prompt_text) produces non-empty 48 kHz audio."""
|
||||||
|
req = _build_request("Hello, this is a short voice cloning demo for testing.", ref_audio_path)
|
||||||
|
audio, sr = _collect_audio(omni_engine, req)
|
||||||
|
|
||||||
|
assert sr == SAMPLE_RATE, f"Expected sample_rate={SAMPLE_RATE}, got {sr}"
|
||||||
|
assert audio.numel() > 0, "Audio tensor should not be empty"
|
||||||
|
assert not torch.all(audio == 0), "Audio should not be all-zeros (silence)"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"})
|
||||||
|
def test_moss_tts_nano_chinese(omni_engine, ref_audio_path):
|
||||||
|
"""Chinese voice_clone mode produces non-empty audio."""
|
||||||
|
req = _build_request("你好,这是语音合成测试。", ref_audio_path)
|
||||||
|
audio, sr = _collect_audio(omni_engine, req)
|
||||||
|
|
||||||
|
assert sr == SAMPLE_RATE
|
||||||
|
assert audio.numel() > 0
|
||||||
|
assert not torch.all(audio == 0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"})
|
||||||
|
def test_moss_tts_nano_deterministic(omni_engine, ref_audio_path):
|
||||||
|
"""Same seed produces identical waveforms."""
|
||||||
|
req = _build_request("Reproducible output test.", ref_audio_path, seed=123)
|
||||||
|
audio1, _ = _collect_audio(omni_engine, req)
|
||||||
|
audio2, _ = _collect_audio(omni_engine, req)
|
||||||
|
|
||||||
|
assert audio1.shape == audio2.shape, "Waveform shapes should match with same seed"
|
||||||
|
assert torch.allclose(audio1, audio2, atol=1e-4), "Waveforms should match with same seed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"})
|
||||||
|
def test_moss_tts_nano_batch(omni_engine, ref_audio_path):
|
||||||
|
"""Batch of two requests returns audio for each."""
|
||||||
|
requests = [
|
||||||
|
_build_request("First request.", ref_audio_path),
|
||||||
|
_build_request("Second request.", ref_audio_path),
|
||||||
|
]
|
||||||
|
results = []
|
||||||
|
for stage_outputs in omni_engine.generate(requests, [DEFAULT_SAMPLING] * 2):
|
||||||
|
for req_output in stage_outputs.request_output:
|
||||||
|
mm = req_output.outputs[0].multimodal_output
|
||||||
|
assert mm is not None
|
||||||
|
results.append(mm["audio"].cpu())
|
||||||
|
|
||||||
|
assert len(results) == 2, f"Expected 2 outputs, got {len(results)}"
|
||||||
|
for i, audio in enumerate(results):
|
||||||
|
assert audio.numel() > 0, f"Audio {i} is empty"
|
||||||
@@ -32,6 +32,7 @@ _OMNI_RUNNER_PARAM = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
|
pytest.mark.skip(reason="issue#4361"),
|
||||||
pytest.mark.full_model,
|
pytest.mark.full_model,
|
||||||
pytest.mark.tts,
|
pytest.mark.tts,
|
||||||
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
|
pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True),
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""
|
||||||
|
E2E online serving test for AudioX text-to-audio diffusion.
|
||||||
|
|
||||||
|
Mirrors `tests/e2e/online_serving/test_sd3_expansion.py` for image diffusion: spin up
|
||||||
|
`vllm-omni` with `--model-class-name AudioXPipeline` and validate via the standard
|
||||||
|
`send_diffusion_request` helper (now audio-aware).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers.mark import hardware_marks
|
||||||
|
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data
|
||||||
|
|
||||||
|
# Tiny / random checkpoint usable in CI; override to a real bundle locally.
|
||||||
|
AUDIOX_TEST_MODEL = os.environ.get("AUDIOX_TEST_MODEL", "zhangj1an/audiox_random")
|
||||||
|
T2A_PROMPT = "A quiet living room with soft fabric rustle and gentle cat breathing."
|
||||||
|
|
||||||
|
SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "L4"})
|
||||||
|
|
||||||
|
|
||||||
|
def _audiox_server_cases(model: str):
|
||||||
|
return [
|
||||||
|
pytest.param(
|
||||||
|
OmniServerParams(
|
||||||
|
model=model,
|
||||||
|
server_args=["--model-class-name", "AudioXPipeline"],
|
||||||
|
),
|
||||||
|
id="t2a",
|
||||||
|
marks=SINGLE_CARD_FEATURE_MARKS,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.core_model
|
||||||
|
@pytest.mark.diffusion
|
||||||
|
@pytest.mark.parametrize("omni_server", _audiox_server_cases(AUDIOX_TEST_MODEL), indirect=True)
|
||||||
|
def test_audiox_t2a_online(omni_server: OmniServer, openai_client: OpenAIClientHandler) -> None:
|
||||||
|
"""AudioX text-to-audio: chat completion returns a non-empty WAV in `message.audio.data`."""
|
||||||
|
request_config = {
|
||||||
|
"model": omni_server.model,
|
||||||
|
"messages": dummy_messages_from_mix_data(content_text=T2A_PROMPT),
|
||||||
|
"extra_body": {
|
||||||
|
"num_inference_steps": 4,
|
||||||
|
"guidance_scale": 6.0,
|
||||||
|
"seed": 42,
|
||||||
|
"audiox_task": "t2a",
|
||||||
|
"seconds_start": 0.0,
|
||||||
|
"seconds_total": 2.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
openai_client.send_diffusion_request(request_config)
|
||||||
@@ -26,6 +26,12 @@ _SKIP_NEED_4_H100_NOT_CI = pytest.mark.skip(
|
|||||||
reason="Requires 4x H100 GPUs; skipped in CI for now.",
|
reason="Requires 4x H100 GPUs; skipped in CI for now.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.omni, pytest.mark.full_model]
|
||||||
|
|
||||||
|
_SKIP_NEED_4_H100_NOT_CI = pytest.mark.skip(
|
||||||
|
reason="Requires 4x H100 GPUs; skipped in CI for now.",
|
||||||
|
)
|
||||||
|
|
||||||
models = ["Jonathan1909/Ming-flash-omni-2.0"]
|
models = ["Jonathan1909/Ming-flash-omni-2.0"]
|
||||||
|
|
||||||
# Use thinker-only topology to test text outputs
|
# Use thinker-only topology to test text outputs
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""
|
||||||
|
E2E Online tests for MOSS-TTS-Nano model with text input and audio output.
|
||||||
|
|
||||||
|
These tests verify the /v1/audio/speech endpoint works correctly with
|
||||||
|
actual model inference, not mocks. The server uses upstream's
|
||||||
|
``voice_clone`` mode (no transcript needed); ``ref_text`` and ``voice``
|
||||||
|
are accepted in the OpenAI schema but ignored. One case includes a
|
||||||
|
non-empty ``ref_text`` to verify it does not break the pipeline. The
|
||||||
|
reference clip is fetched from the upstream repo (assets/audio/zh_1.wav,
|
||||||
|
~50 KB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||||
|
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers.mark import hardware_test
|
||||||
|
from tests.helpers.runtime import OmniServerParams
|
||||||
|
from tests.helpers.stage_config import get_deploy_config_path
|
||||||
|
|
||||||
|
MODEL = "OpenMOSS-Team/MOSS-TTS-Nano"
|
||||||
|
REF_AUDIO_URL = "https://raw.githubusercontent.com/OpenMOSS/MOSS-TTS-Nano/main/assets/audio/zh_1.wav"
|
||||||
|
REF_AUDIO_TRANSCRIPT = "欢迎关注模思智能、上海创智学院与复旦大学自然语言处理实验室。"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def ref_audio_data_url() -> str:
|
||||||
|
"""Fetch the upstream sample clip and return it as a base64 data URL.
|
||||||
|
|
||||||
|
The fetch failure is escalated to a hard failure (not pytest.skip) so that
|
||||||
|
a broken network path does not silently mask regressions in
|
||||||
|
/v1/audio/speech. Set ``MOSS_TTS_NANO_SKIP_ON_NET_FAIL=1`` to opt into
|
||||||
|
skipping in air-gapped environments.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(REF_AUDIO_URL, timeout=30) as resp:
|
||||||
|
data = resp.read()
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Cannot fetch upstream reference clip {REF_AUDIO_URL}: {e}"
|
||||||
|
if os.environ.get("MOSS_TTS_NANO_SKIP_ON_NET_FAIL"):
|
||||||
|
pytest.skip(msg)
|
||||||
|
pytest.fail(msg)
|
||||||
|
if not data:
|
||||||
|
pytest.fail(f"Reference clip empty: {REF_AUDIO_URL}")
|
||||||
|
return f"data:audio/wav;base64,{base64.b64encode(data).decode('ascii')}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt(prompt_type="text"):
|
||||||
|
"""Text prompt for text-to-audio tests.
|
||||||
|
|
||||||
|
Avoid the model's own name ("MOSS-TTS-Nano") in the test input — the
|
||||||
|
codec consistently mispronounces it ("MOS's TTS Nano", "Was TTS Nano",
|
||||||
|
etc.), which trips the transcript-similarity assertion in CI without
|
||||||
|
indicating a real regression. Use plain natural-sounding sentences.
|
||||||
|
"""
|
||||||
|
prompts = {
|
||||||
|
"text": "Hello, this is a short voice cloning demo for testing.",
|
||||||
|
"chinese": "你好,这是一段简单的语音合成测试。",
|
||||||
|
}
|
||||||
|
return prompts.get(prompt_type, prompts["text"])
|
||||||
|
|
||||||
|
|
||||||
|
tts_server_params = [
|
||||||
|
pytest.param(
|
||||||
|
OmniServerParams(
|
||||||
|
model=MODEL,
|
||||||
|
stage_config_path=get_deploy_config_path("moss_tts_nano.yaml"),
|
||||||
|
server_args=["--disable-log-stats"],
|
||||||
|
),
|
||||||
|
id="moss_tts_nano",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.advanced_model
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||||
|
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||||
|
def test_text_to_audio_001(omni_server, openai_client, ref_audio_data_url) -> None:
|
||||||
|
"""
|
||||||
|
Test voice_clone mode (no ref_text) via /v1/audio/speech.
|
||||||
|
Deploy Setting: default yaml
|
||||||
|
Input Modal: text + reference audio (no transcript)
|
||||||
|
Output Modal: audio (48 kHz, WAV)
|
||||||
|
Input Setting: stream=False
|
||||||
|
Datasets: single request
|
||||||
|
"""
|
||||||
|
request_config = {
|
||||||
|
"model": omni_server.model,
|
||||||
|
"input": get_prompt(),
|
||||||
|
"stream": False,
|
||||||
|
"response_format": "wav",
|
||||||
|
"ref_audio": ref_audio_data_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
openai_client.send_audio_speech_request(request_config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.advanced_model
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||||
|
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||||
|
def test_text_to_audio_002(omni_server, openai_client, ref_audio_data_url) -> None:
|
||||||
|
"""
|
||||||
|
Test streaming voice_clone via /v1/audio/speech.
|
||||||
|
Deploy Setting: default yaml
|
||||||
|
Input Modal: text + reference audio (no transcript)
|
||||||
|
Output Modal: audio (48 kHz, PCM stream)
|
||||||
|
Input Setting: stream=True
|
||||||
|
Datasets: single request
|
||||||
|
|
||||||
|
NOTE: ``min_hnr_db=-5.0`` overrides the conftest default of 1.0 dB.
|
||||||
|
MOSS-TTS-Nano's voice_clone output is intrinsically noisy by that
|
||||||
|
metric — verified on H20, both streaming and non-streaming clips
|
||||||
|
measure HNR around -2 dB even when the audio sounds correct. We
|
||||||
|
keep the PCM-stream check in place to catch catastrophic decoder
|
||||||
|
failures (which produce HNR much further below 0), just with a
|
||||||
|
threshold MOSS can clear in normal operation.
|
||||||
|
"""
|
||||||
|
request_config = {
|
||||||
|
"model": omni_server.model,
|
||||||
|
"input": get_prompt(),
|
||||||
|
"stream": True,
|
||||||
|
"response_format": "pcm",
|
||||||
|
"ref_audio": ref_audio_data_url,
|
||||||
|
"min_hnr_db": -5.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
openai_client.send_audio_speech_request(request_config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.advanced_model
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||||
|
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||||
|
def test_text_to_audio_003(omni_server, openai_client, ref_audio_data_url) -> None:
|
||||||
|
"""
|
||||||
|
Test Chinese voice_clone via /v1/audio/speech.
|
||||||
|
Deploy Setting: default yaml
|
||||||
|
Input Modal: text (Chinese) + reference audio (no transcript)
|
||||||
|
Output Modal: audio (48 kHz, WAV)
|
||||||
|
Input Setting: stream=False
|
||||||
|
Datasets: single request
|
||||||
|
"""
|
||||||
|
request_config = {
|
||||||
|
"model": omni_server.model,
|
||||||
|
"input": get_prompt("chinese"),
|
||||||
|
"stream": False,
|
||||||
|
"response_format": "wav",
|
||||||
|
"ref_audio": ref_audio_data_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
openai_client.send_audio_speech_request(request_config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.advanced_model
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||||
|
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
|
||||||
|
def test_text_to_audio_004_ref_text_ignored(omni_server, openai_client, ref_audio_data_url) -> None:
|
||||||
|
"""
|
||||||
|
Sending ``ref_text`` alongside ``ref_audio`` is accepted but ignored.
|
||||||
|
|
||||||
|
The OpenAI ``ref_text`` field is part of the schema, so clients may
|
||||||
|
send it. The server uses upstream's voice_clone mode regardless and
|
||||||
|
drops the transcript; this case verifies the request still completes
|
||||||
|
and produces audio.
|
||||||
|
"""
|
||||||
|
request_config = {
|
||||||
|
"model": omni_server.model,
|
||||||
|
"input": get_prompt("chinese"),
|
||||||
|
"stream": False,
|
||||||
|
"response_format": "wav",
|
||||||
|
"ref_audio": ref_audio_data_url,
|
||||||
|
"ref_text": REF_AUDIO_TRANSCRIPT,
|
||||||
|
}
|
||||||
|
|
||||||
|
openai_client.send_audio_speech_request(request_config)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""
|
||||||
|
E2E online tests for Qwen3-Omni /v1/realtime WebSocket (streaming PCM in, audio out).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import wave
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from tests.helpers.mark import hardware_test
|
||||||
|
from tests.helpers.media import (
|
||||||
|
convert_audio_bytes_to_text,
|
||||||
|
cosine_similarity_text,
|
||||||
|
generate_synthetic_audio,
|
||||||
|
)
|
||||||
|
from tests.helpers.runtime import OmniServerParams
|
||||||
|
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
|
||||||
|
|
||||||
|
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||||
|
|
||||||
|
MODEL = "Qwen/Qwen3-Omni-30B-A3B-Instruct"
|
||||||
|
|
||||||
|
# The new-schema CI overlay bakes in async_chunk: False and covers CUDA/ROCm/XPU
|
||||||
|
# via its ``platforms:`` section, so one path serves all three.
|
||||||
|
default_stage_config = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def _realtime_stage_config_path() -> str:
|
||||||
|
"""CI omni layout without async_chunk; stage 0 thinker max_tokens=10."""
|
||||||
|
return modify_stage_config(
|
||||||
|
default_stage_config,
|
||||||
|
updates={"stages": {0: {"default_sampling_params.max_tokens": 10}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
realtime_server_params = [
|
||||||
|
pytest.param(
|
||||||
|
OmniServerParams(
|
||||||
|
model=MODEL,
|
||||||
|
stage_config_path=_realtime_stage_config_path(),
|
||||||
|
use_stage_cli=True,
|
||||||
|
),
|
||||||
|
id="thinker_max_tokens_10",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pcm16_mono_16k_from_wav_bytes(wav_bytes: bytes) -> bytes:
|
||||||
|
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
|
||||||
|
if wf.getnchannels() != 1:
|
||||||
|
raise ValueError(f"Expected mono WAV, got {wf.getnchannels()} channels")
|
||||||
|
if wf.getsampwidth() != 2:
|
||||||
|
raise ValueError(f"Expected 16-bit PCM, sampwidth={wf.getsampwidth()}")
|
||||||
|
if wf.getframerate() != 16000:
|
||||||
|
raise ValueError(f"Expected 16 kHz input for /v1/realtime, got {wf.getframerate()} Hz")
|
||||||
|
if wf.getcomptype() != "NONE":
|
||||||
|
raise ValueError(f"Expected uncompressed PCM, comptype={wf.getcomptype()!r}")
|
||||||
|
return wf.readframes(wf.getnframes())
|
||||||
|
|
||||||
|
|
||||||
|
def _wav_bytes_from_pcm16(pcm: bytes, sample_rate_hz: int) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with wave.open(buf, "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(sample_rate_hz)
|
||||||
|
wf.writeframes(pcm)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_realtime_audio_roundtrip(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
model: str,
|
||||||
|
pcm16: bytes,
|
||||||
|
*,
|
||||||
|
chunk_ms: int = 100,
|
||||||
|
) -> dict:
|
||||||
|
uri = f"ws://{host}:{port}/v1/realtime"
|
||||||
|
incremental: list[bytes] = []
|
||||||
|
output_sr = 24000
|
||||||
|
text_chunks: list[str] = []
|
||||||
|
final_text = ""
|
||||||
|
delta_events = 0
|
||||||
|
|
||||||
|
bytes_per_ms = 16000 * 2 // 1000
|
||||||
|
chunk_bytes = max(bytes_per_ms * chunk_ms, 2)
|
||||||
|
|
||||||
|
async with websockets.connect(uri, max_size=64 * 1024 * 1024) as ws:
|
||||||
|
await ws.send(json.dumps({"type": "session.update", "model": model}))
|
||||||
|
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": False}))
|
||||||
|
|
||||||
|
for i in range(0, len(pcm16), chunk_bytes):
|
||||||
|
chunk = pcm16[i : i + chunk_bytes]
|
||||||
|
await ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "input_audio_buffer.append",
|
||||||
|
"audio": base64.b64encode(chunk).decode("utf-8"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
message = await asyncio.wait_for(ws.recv(), timeout=600)
|
||||||
|
if isinstance(message, bytes):
|
||||||
|
continue
|
||||||
|
|
||||||
|
event = json.loads(message)
|
||||||
|
event_type = event.get("type")
|
||||||
|
|
||||||
|
if event_type == "session.created":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "response.audio.delta":
|
||||||
|
delta_events += 1
|
||||||
|
sr = event.get("sample_rate_hz")
|
||||||
|
if isinstance(sr, int) and sr > 0:
|
||||||
|
output_sr = sr
|
||||||
|
audio_b64 = event.get("audio", "")
|
||||||
|
if audio_b64:
|
||||||
|
incremental.append(base64.b64decode(audio_b64))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "transcription.delta":
|
||||||
|
d = event.get("delta", "")
|
||||||
|
if d:
|
||||||
|
text_chunks.append(d)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "transcription.done":
|
||||||
|
final_text = event.get("text", "") or "".join(text_chunks)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if event_type == "response.audio.done":
|
||||||
|
break
|
||||||
|
|
||||||
|
if event_type == "error":
|
||||||
|
raise AssertionError(f"WebSocket error: {event}")
|
||||||
|
|
||||||
|
raise AssertionError(f"Unexpected WebSocket event: {event}")
|
||||||
|
|
||||||
|
out_pcm = b"".join(incremental)
|
||||||
|
return {
|
||||||
|
"output_pcm": out_pcm,
|
||||||
|
"output_sample_rate": output_sr,
|
||||||
|
"transcription_text": final_text if final_text else "".join(text_chunks),
|
||||||
|
"delta_events": delta_events,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestQwen3OmniRealtimeWebSocket:
|
||||||
|
@pytest.mark.advanced_model
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
|
||||||
|
@pytest.mark.parametrize("omni_server", realtime_server_params, indirect=True)
|
||||||
|
def test_streaming_audio_input_pcm_output(self, omni_server) -> None:
|
||||||
|
"""
|
||||||
|
Short streamed 16 kHz mono PCM16 input; expect streamed PCM16 audio deltas and
|
||||||
|
transcription. Verify Whisper(output audio) aligns with model text (same idea
|
||||||
|
as multimodal omni e2e).
|
||||||
|
"""
|
||||||
|
syn = generate_synthetic_audio(10, 1, sample_rate=16000)
|
||||||
|
wav_bytes = base64.b64decode(syn["base64"])
|
||||||
|
pcm16 = _pcm16_mono_16k_from_wav_bytes(wav_bytes)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
_run_realtime_audio_roundtrip(
|
||||||
|
omni_server.host,
|
||||||
|
omni_server.port,
|
||||||
|
omni_server.model,
|
||||||
|
pcm16,
|
||||||
|
chunk_ms=100,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
out_pcm = result["output_pcm"]
|
||||||
|
assert result["delta_events"] >= 1
|
||||||
|
assert out_pcm, "No output PCM from response.audio.delta"
|
||||||
|
assert len(out_pcm) % 2 == 0
|
||||||
|
assert len(out_pcm) >= 4096, "Output audio unexpectedly small"
|
||||||
|
assert result["output_sample_rate"] > 0
|
||||||
|
|
||||||
|
final_text = (result["transcription_text"] or "").strip()
|
||||||
|
assert final_text, "Expected non-empty transcription (model text stream)"
|
||||||
|
|
||||||
|
wav_out = _wav_bytes_from_pcm16(out_pcm, result["output_sample_rate"])
|
||||||
|
whisper_text = convert_audio_bytes_to_text(wav_out).strip()
|
||||||
|
assert whisper_text, "Whisper returned empty string for synthesized output audio"
|
||||||
|
|
||||||
|
sim = cosine_similarity_text(whisper_text.lower(), final_text.lower())
|
||||||
|
assert sim > 0.9, (
|
||||||
|
f"Output audio transcript should match model text (sim={sim:.3f}): "
|
||||||
|
f"whisper={whisper_text!r}, model_text={final_text!r}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""
|
||||||
|
Offline inference tests: Qwen3-TTS.
|
||||||
|
See examples/offline_inference/qwen3_tts/README.md
|
||||||
|
|
||||||
|
Picks classic test examples covering all three query types (CustomVoice,
|
||||||
|
VoiceDesign, Base) and the streaming execution path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
from tests.examples.helpers import EXAMPLES, run_cmd
|
||||||
|
from tests.helpers.mark import hardware_test
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.advanced_model, pytest.mark.example]
|
||||||
|
|
||||||
|
END2END = str(EXAMPLES / "offline_inference" / "text_to_speech" / "qwen3_tts" / "end2end.py")
|
||||||
|
|
||||||
|
CUSTOM_VOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
||||||
|
VOICE_DESIGN_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
|
||||||
|
BASE_MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||||
|
|
||||||
|
|
||||||
|
MIN_DURATION_S = 0.5
|
||||||
|
EXPECTED_SAMPLE_RATE = 24000
|
||||||
|
MR_V2_ENV = {
|
||||||
|
"VLLM_WORKER_MULTIPROC_METHOD": "spawn",
|
||||||
|
"VLLM_OMNI_USE_V2_RUNNER": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_wav_output(output_dir: str) -> None:
|
||||||
|
"""Assert that at least one valid .wav file was produced.
|
||||||
|
|
||||||
|
Checks: file exists, sample rate, minimum duration, and not silence.
|
||||||
|
"""
|
||||||
|
wav_files = list(Path(output_dir).glob("*.wav"))
|
||||||
|
assert len(wav_files) > 0, f"No .wav files found in {output_dir}"
|
||||||
|
for wav in wav_files:
|
||||||
|
data, sr = sf.read(wav, dtype="float32")
|
||||||
|
assert sr == EXPECTED_SAMPLE_RATE, f"{wav.name}: sample_rate={sr}, expected {EXPECTED_SAMPLE_RATE}"
|
||||||
|
duration = len(data) / sr
|
||||||
|
assert duration >= MIN_DURATION_S, f"{wav.name}: duration={duration:.3f}s, expected >= {MIN_DURATION_S}s"
|
||||||
|
assert np.max(np.abs(data)) > 0.01, f"{wav.name}: audio appears to be silence"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||||
|
def test_custom_voice():
|
||||||
|
"""CustomVoice single prompt — the most common TTS use case."""
|
||||||
|
with tempfile.TemporaryDirectory() as output_dir:
|
||||||
|
command = [
|
||||||
|
"python",
|
||||||
|
END2END,
|
||||||
|
"--model",
|
||||||
|
CUSTOM_VOICE_MODEL,
|
||||||
|
"--query-type",
|
||||||
|
"CustomVoice",
|
||||||
|
"--output-dir",
|
||||||
|
output_dir,
|
||||||
|
]
|
||||||
|
run_cmd(command, env=MR_V2_ENV)
|
||||||
|
_assert_wav_output(output_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||||
|
def test_voice_design():
|
||||||
|
"""VoiceDesign single prompt — generates speech from a voice description."""
|
||||||
|
with tempfile.TemporaryDirectory() as output_dir:
|
||||||
|
command = [
|
||||||
|
"python",
|
||||||
|
END2END,
|
||||||
|
"--model",
|
||||||
|
VOICE_DESIGN_MODEL,
|
||||||
|
"--query-type",
|
||||||
|
"VoiceDesign",
|
||||||
|
"--output-dir",
|
||||||
|
output_dir,
|
||||||
|
]
|
||||||
|
run_cmd(command, env=MR_V2_ENV)
|
||||||
|
_assert_wav_output(output_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||||
|
def test_base_icl():
|
||||||
|
"""Base ICL mode — voice cloning with reference audio and transcript."""
|
||||||
|
with tempfile.TemporaryDirectory() as output_dir:
|
||||||
|
command = [
|
||||||
|
"python",
|
||||||
|
END2END,
|
||||||
|
"--model",
|
||||||
|
BASE_MODEL,
|
||||||
|
"--query-type",
|
||||||
|
"Base",
|
||||||
|
"--mode-tag",
|
||||||
|
"icl",
|
||||||
|
"--output-dir",
|
||||||
|
output_dir,
|
||||||
|
]
|
||||||
|
run_cmd(command, env=MR_V2_ENV)
|
||||||
|
_assert_wav_output(output_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.omni
|
||||||
|
@hardware_test(res={"cuda": "H100"}, num_cards=1)
|
||||||
|
def test_custom_voice_streaming():
|
||||||
|
"""CustomVoice streaming — exercises the AsyncOmni streaming path."""
|
||||||
|
with tempfile.TemporaryDirectory() as output_dir:
|
||||||
|
command = [
|
||||||
|
"python",
|
||||||
|
END2END,
|
||||||
|
"--model",
|
||||||
|
CUSTOM_VOICE_MODEL,
|
||||||
|
"--query-type",
|
||||||
|
"CustomVoice",
|
||||||
|
"--streaming",
|
||||||
|
"--output-dir",
|
||||||
|
output_dir,
|
||||||
|
]
|
||||||
|
run_cmd(command, env=MR_V2_ENV)
|
||||||
|
_assert_wav_output(output_dir)
|
||||||
@@ -115,13 +115,16 @@ def test_send_multimodal_request_002(omni_server) -> None:
|
|||||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||||
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
|
||||||
def test_send_multimodal_request_003(omni_server) -> None:
|
def test_send_multimodal_request_003(omni_server) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["MODEL"] = omni_server.model
|
||||||
|
|
||||||
command = [
|
command = [
|
||||||
"bash",
|
"bash",
|
||||||
os.path.join(example_dir, "qwen2_5_omni/run_curl_multimodal_generation.sh"),
|
os.path.join(example_dir, "qwen2_5_omni/run_curl_multimodal_generation.sh"),
|
||||||
"mixed_modalities",
|
"mixed_modalities",
|
||||||
]
|
]
|
||||||
|
|
||||||
result = run_cmd(command)
|
result = run_cmd(command, env=env)
|
||||||
|
|
||||||
text_content = extract_content_after_keyword("Output of request:", result)
|
text_content = extract_content_after_keyword("Output of request:", result)
|
||||||
|
|
||||||
|
|||||||
@@ -541,6 +541,15 @@ def assert_audio_speech_response(response: Any, request_config: dict[str, Any],
|
|||||||
|
|
||||||
assert response.success, "The request failed."
|
assert response.success, "The request failed."
|
||||||
|
|
||||||
|
# Optional floor on decoded audio size (models with very short clips may use a lower value).
|
||||||
|
min_audio = request_config.get("min_audio_bytes")
|
||||||
|
if min_audio is not None:
|
||||||
|
n = int(min_audio)
|
||||||
|
if n > 0:
|
||||||
|
ab = response.audio_bytes
|
||||||
|
assert ab is not None, "Expected audio bytes when min_audio_bytes is set"
|
||||||
|
assert len(ab) > n, f"Audio payload too small: {len(ab)} bytes, expected more than {n} (min_audio_bytes)"
|
||||||
|
|
||||||
# Optional floor on decoded audio size (models with very short clips may use a lower value).
|
# Optional floor on decoded audio size (models with very short clips may use a lower value).
|
||||||
min_audio = request_config.get("min_audio_bytes")
|
min_audio = request_config.get("min_audio_bytes")
|
||||||
if min_audio is not None:
|
if min_audio is not None:
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni import (
|
||||||
|
Qwen2_5OmniForConditionalGeneration,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.core_model,
|
||||||
|
pytest.mark.cpu,
|
||||||
|
pytest.mark.skip(
|
||||||
|
reason="Methods _build_thinker_to_talker_latent and _build_talker_decode_reply_cache not yet implemented in Qwen2_5OmniForConditionalGeneration"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_thinker_to_talker_latent_adds_token_embeddings():
|
||||||
|
model = object.__new__(Qwen2_5OmniForConditionalGeneration)
|
||||||
|
hidden = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
|
||||||
|
embeds = torch.tensor([[10.0, 20.0], [30.0, 40.0]])
|
||||||
|
|
||||||
|
latent = model._build_thinker_to_talker_latent(hidden, embeds, input_ids=None)
|
||||||
|
|
||||||
|
assert torch.equal(latent, hidden + embeds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_thinker_to_talker_latent_masks_multimodal_token_embeddings():
|
||||||
|
model = object.__new__(Qwen2_5OmniForConditionalGeneration)
|
||||||
|
nn.Module.__init__(model)
|
||||||
|
model.thinker_config = SimpleNamespace(
|
||||||
|
audio_token_index=7,
|
||||||
|
image_token_index=8,
|
||||||
|
video_token_index=9,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _FakeThinker:
|
||||||
|
@staticmethod
|
||||||
|
def embed_input_ids(input_ids: torch.Tensor) -> torch.Tensor:
|
||||||
|
values = input_ids.to(torch.float32)
|
||||||
|
return torch.stack([values, values + 100.0], dim=-1)
|
||||||
|
|
||||||
|
model.thinker = _FakeThinker()
|
||||||
|
hidden = torch.zeros((4, 2), dtype=torch.float32)
|
||||||
|
input_ids = torch.tensor([1, 7, 2, 8], dtype=torch.long)
|
||||||
|
multimodal_embeds = torch.full((4, 2), 999.0, dtype=torch.float32)
|
||||||
|
|
||||||
|
latent = model._build_thinker_to_talker_latent(hidden, multimodal_embeds, input_ids)
|
||||||
|
|
||||||
|
assert torch.equal(
|
||||||
|
latent,
|
||||||
|
torch.tensor(
|
||||||
|
[
|
||||||
|
[1.0, 101.0],
|
||||||
|
[0.0, 0.0],
|
||||||
|
[2.0, 102.0],
|
||||||
|
[0.0, 0.0],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_talker_decode_reply_cache_appends_text_eos_pad():
|
||||||
|
model = object.__new__(Qwen2_5OmniForConditionalGeneration)
|
||||||
|
model.embed_text_eos_token = torch.tensor([[100.0, 101.0]])
|
||||||
|
model.embed_text_pad_token = torch.tensor([[200.0, 201.0]])
|
||||||
|
thinker_result = torch.tensor([[1.0, 1.0], [2.0, 2.0], [3.0, 3.0]])
|
||||||
|
|
||||||
|
reply = model._build_talker_decode_reply_cache(
|
||||||
|
thinker_result,
|
||||||
|
dtype=torch.float32,
|
||||||
|
device=torch.device("cpu"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert torch.equal(
|
||||||
|
reply,
|
||||||
|
torch.tensor(
|
||||||
|
[
|
||||||
|
[2.0, 2.0],
|
||||||
|
[3.0, 3.0],
|
||||||
|
[100.0, 101.0],
|
||||||
|
[200.0, 201.0],
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_talker_decode_keeps_final_reply_pad_vector():
|
||||||
|
model = object.__new__(Qwen2_5OmniForConditionalGeneration)
|
||||||
|
nn.Module.__init__(model)
|
||||||
|
model.model = nn.Linear(2, 2)
|
||||||
|
|
||||||
|
class _FakeTalker:
|
||||||
|
@staticmethod
|
||||||
|
def embed_input_ids(input_ids: torch.Tensor) -> torch.Tensor:
|
||||||
|
return torch.zeros((input_ids.numel(), 2), dtype=torch.float32)
|
||||||
|
|
||||||
|
model.talker = _FakeTalker()
|
||||||
|
input_ids = torch.tensor([123], dtype=torch.long)
|
||||||
|
input_embeds = torch.zeros((1, 2), dtype=torch.float32)
|
||||||
|
final_pad = torch.tensor([[5.0, 6.0]], dtype=torch.float32)
|
||||||
|
|
||||||
|
_, out_embeds, update = model.thinker_to_talker_decode_one_step(
|
||||||
|
input_ids,
|
||||||
|
input_embeds,
|
||||||
|
{"embed": {"thinker_reply": final_pad}},
|
||||||
|
)
|
||||||
|
|
||||||
|
torch.testing.assert_close(out_embeds, final_pad)
|
||||||
|
assert update == {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeToken2Wav(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.weight = nn.Parameter(torch.zeros(()))
|
||||||
|
self.calls = []
|
||||||
|
self.token2wav = SimpleNamespace(
|
||||||
|
factor=SimpleNamespace(factor=2),
|
||||||
|
code2wav_dit_model=SimpleNamespace(mel_dim=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(
|
||||||
|
self,
|
||||||
|
code: torch.Tensor,
|
||||||
|
*,
|
||||||
|
conditioning: torch.Tensor,
|
||||||
|
reference_mel: torch.Tensor,
|
||||||
|
num_steps: int = 10,
|
||||||
|
**_: object,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"code": code.clone(),
|
||||||
|
"conditioning": conditioning.clone(),
|
||||||
|
"reference_mel": reference_mel.clone(),
|
||||||
|
"num_steps": num_steps,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return torch.arange(6, dtype=torch.float32)
|
||||||
|
|
||||||
|
def process_chunk(self, **_: object) -> tuple[torch.Tensor, torch.Tensor]:
|
||||||
|
raise AssertionError("_codec_to_audio should not truncate codec through process_chunk")
|
||||||
|
|
||||||
|
|
||||||
|
def test_codec_to_audio_uses_full_token2wav_forward():
|
||||||
|
model = object.__new__(Qwen2_5OmniForConditionalGeneration)
|
||||||
|
nn.Module.__init__(model)
|
||||||
|
token2wav = _FakeToken2Wav()
|
||||||
|
cond = torch.ones((1, 4), dtype=torch.float32)
|
||||||
|
ref_mel = torch.full((1, 300, 3), 2.0, dtype=torch.float32)
|
||||||
|
model.token2wav = token2wav
|
||||||
|
model.token2wav_config = SimpleNamespace(
|
||||||
|
dit_config=SimpleNamespace(enc_emb_dim=4, mel_dim=3),
|
||||||
|
)
|
||||||
|
model._token2wav_conds = {"Chelsie": cond}
|
||||||
|
model._token2wav_ref_mels = {"Chelsie": ref_mel}
|
||||||
|
|
||||||
|
out = model._codec_to_audio(torch.arange(80, dtype=torch.long), "Chelsie")
|
||||||
|
|
||||||
|
assert len(token2wav.calls) == 1
|
||||||
|
call = token2wav.calls[0]
|
||||||
|
torch.testing.assert_close(call["code"], torch.arange(80, dtype=torch.long).unsqueeze(0))
|
||||||
|
torch.testing.assert_close(call["conditioning"], cond)
|
||||||
|
torch.testing.assert_close(call["reference_mel"], ref_mel)
|
||||||
|
assert call["num_steps"] == 10
|
||||||
|
torch.testing.assert_close(out, torch.arange(6, dtype=torch.float32))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.model_executor.models.qwen3_omni.qwen3_omni import (
|
||||||
|
Qwen3OmniMoeForConditionalGeneration,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_thinker_mrope_positions_do_not_require_multimodal_kwargs():
|
||||||
|
model = object.__new__(Qwen3OmniMoeForConditionalGeneration)
|
||||||
|
model.model_stage = "talker"
|
||||||
|
|
||||||
|
positions, delta = model.get_mrope_input_positions([1, 2, 3])
|
||||||
|
|
||||||
|
assert delta == 0
|
||||||
|
assert torch.equal(
|
||||||
|
positions,
|
||||||
|
torch.tensor(
|
||||||
|
[
|
||||||
|
[0, 1, 2],
|
||||||
|
[0, 1, 2],
|
||||||
|
[0, 1, 2],
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vllm_omni.model_executor.stage_input_processors.qwen2_5_omni import (
|
||||||
|
TALKER_CODEC_END_TOKEN_ID,
|
||||||
|
TALKER_CODEC_PAD_TOKEN_ID,
|
||||||
|
TALKER_CODEC_START_TOKEN_ID,
|
||||||
|
talker2code2wav,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.core_model,
|
||||||
|
pytest.mark.cpu,
|
||||||
|
pytest.mark.skip(
|
||||||
|
reason="qwen2_5_omni stage input processor tests need alignment with model changes that are not yet implemented"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _source_outputs(output):
|
||||||
|
return [SimpleNamespace(outputs=[output])]
|
||||||
|
|
||||||
|
|
||||||
|
def test_talker2code2wav_accepts_completion_output_token_ids():
|
||||||
|
output = SimpleNamespace(token_ids=[TALKER_CODEC_START_TOKEN_ID, 11, 22, TALKER_CODEC_END_TOKEN_ID])
|
||||||
|
|
||||||
|
prompts = talker2code2wav(_source_outputs(output))
|
||||||
|
|
||||||
|
assert [prompt["prompt_token_ids"] for prompt in prompts] == [[11, 22]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_talker2code2wav_prefers_cumulative_token_ids_when_present():
|
||||||
|
output = SimpleNamespace(
|
||||||
|
token_ids=[999],
|
||||||
|
cumulative_token_ids=[TALKER_CODEC_START_TOKEN_ID, 33, 44, TALKER_CODEC_END_TOKEN_ID],
|
||||||
|
)
|
||||||
|
|
||||||
|
prompts = talker2code2wav(_source_outputs(output))
|
||||||
|
|
||||||
|
assert [prompt["prompt_token_ids"] for prompt in prompts] == [[33, 44]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_talker2code2wav_strips_terminal_codec_pad():
|
||||||
|
output = SimpleNamespace(cumulative_token_ids=[TALKER_CODEC_START_TOKEN_ID, 55, 66, TALKER_CODEC_PAD_TOKEN_ID])
|
||||||
|
|
||||||
|
prompts = talker2code2wav(_source_outputs(output))
|
||||||
|
|
||||||
|
assert [prompt["prompt_token_ids"] for prompt in prompts] == [[55, 66]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_talker2code2wav_keeps_empty_codec_terminal_input():
|
||||||
|
output = SimpleNamespace(cumulative_token_ids=[TALKER_CODEC_START_TOKEN_ID, TALKER_CODEC_END_TOKEN_ID])
|
||||||
|
|
||||||
|
prompts = talker2code2wav(_source_outputs(output))
|
||||||
|
|
||||||
|
assert [prompt["prompt_token_ids"] for prompt in prompts] == [[]]
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -76,6 +77,26 @@ def test_get_streaming_codec_delta_len_increments_and_finishes(_streaming_contex
|
|||||||
assert state.talker2code2wav_last_seq_len == 0
|
assert state.talker2code2wav_last_seq_len == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_talker2code2wav_async_chunk_emits_eof_marker_when_finished_empty() -> None:
|
||||||
|
transfer_manager = SimpleNamespace(
|
||||||
|
code_prompt_token_ids=defaultdict(list),
|
||||||
|
connector=SimpleNamespace(config={"extra": {"codec_chunk_frames": 25, "codec_left_context_frames": 25}}),
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(external_req_id="r1", is_finished=lambda: True)
|
||||||
|
|
||||||
|
for multimodal_output in (None, {"codes": {"audio": torch.empty((0,), dtype=torch.long)}}):
|
||||||
|
payload = q3.talker2code2wav_async_chunk(
|
||||||
|
transfer_manager=transfer_manager,
|
||||||
|
multimodal_output=multimodal_output,
|
||||||
|
request=request,
|
||||||
|
is_finished=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["codes"] == {"audio": []}
|
||||||
|
assert payload["meta"]["left_context_size"] == 0
|
||||||
|
assert payload["meta"]["finished"].item() is True
|
||||||
|
|
||||||
|
|
||||||
def test_streaming_input_prefill_chunk_is_cached() -> None:
|
def test_streaming_input_prefill_chunk_is_cached() -> None:
|
||||||
transfer_manager = SimpleNamespace(_pending_streaming_prefills={})
|
transfer_manager = SimpleNamespace(_pending_streaming_prefills={})
|
||||||
request = SimpleNamespace(
|
request = SimpleNamespace(
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_ar_model_runner_calls_bookkeeping_with_current_vllm_signature():
|
||||||
|
runner = object.__new__(GPUARModelRunner)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def bookkeeping_sync(*args):
|
||||||
|
calls.append(args)
|
||||||
|
return (0, None, [], {}, [], {}, [])
|
||||||
|
|
||||||
|
runner._bookkeeping_sync = bookkeeping_sync
|
||||||
|
runner._bookkeeping_accepts_spec_decode_metadata = False
|
||||||
|
scheduler_output = SimpleNamespace(total_num_scheduled_tokens=3)
|
||||||
|
sampler_output = SimpleNamespace()
|
||||||
|
logits = torch.empty(1, 1)
|
||||||
|
hidden_states = torch.empty(1, 1)
|
||||||
|
|
||||||
|
result = runner._bookkeeping_sync_compat(
|
||||||
|
scheduler_output,
|
||||||
|
sampler_output,
|
||||||
|
logits,
|
||||||
|
hidden_states,
|
||||||
|
scheduler_output.total_num_scheduled_tokens,
|
||||||
|
spec_decode_metadata=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == (0, None, [], {}, [], {}, [])
|
||||||
|
assert calls == [
|
||||||
|
(
|
||||||
|
scheduler_output,
|
||||||
|
sampler_output,
|
||||||
|
logits,
|
||||||
|
hidden_states,
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_gpu_ar_model_runner_calls_bookkeeping_with_current_vllm_signature()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bookkeeping_compat_does_not_reflect_per_call():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
source = inspect.getsource(GPUARModelRunner._bookkeeping_sync_compat)
|
||||||
|
assert "inspect.signature" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_tokens_passes_spec_decode_metadata_to_bookkeeping_compat():
|
||||||
|
import ast
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
source = inspect.getsource(GPUARModelRunner.sample_tokens)
|
||||||
|
tree = ast.parse(source)
|
||||||
|
|
||||||
|
calls = [
|
||||||
|
node
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Attribute)
|
||||||
|
and node.func.attr == "_bookkeeping_sync_compat"
|
||||||
|
]
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert any(isinstance(arg, ast.Name) and arg.id == "spec_decode_metadata" for arg in calls[0].args)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from vllm_omni.core.sched.omni_generation_scheduler import (
|
||||||
|
_get_request_num_cached_tokens,
|
||||||
|
_make_engine_core_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_request_num_cached_tokens_defaults_to_zero_when_field_is_missing():
|
||||||
|
request = SimpleNamespace(request_id="rid", num_computed_tokens=5)
|
||||||
|
|
||||||
|
assert _get_request_num_cached_tokens(request) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_engine_core_output_ignores_fields_missing_from_current_vllm():
|
||||||
|
output = _make_engine_core_output(
|
||||||
|
request_id="rid",
|
||||||
|
new_token_ids=[1],
|
||||||
|
finish_reason=None,
|
||||||
|
new_logprobs=None,
|
||||||
|
new_prompt_logprobs_tensors=None,
|
||||||
|
pooling_output=None,
|
||||||
|
stop_reason=None,
|
||||||
|
events=[],
|
||||||
|
kv_transfer_params=None,
|
||||||
|
trace_headers={},
|
||||||
|
num_cached_tokens=3,
|
||||||
|
num_external_computed_tokens=4,
|
||||||
|
routed_experts=None,
|
||||||
|
num_nans_in_logits=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.request_id == "rid"
|
||||||
|
assert output.new_token_ids == [1]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_get_request_num_cached_tokens_defaults_to_zero_when_field_is_missing()
|
||||||
|
test_make_engine_core_output_ignores_fields_missing_from_current_vllm()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import vllm_omni.worker.gpu_generation_worker as worker
|
||||||
|
from vllm_omni.worker.gpu_generation_worker import _make_compilation_times
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_compilation_times_matches_current_vllm_shape():
|
||||||
|
result = _make_compilation_times(0.0)
|
||||||
|
|
||||||
|
if isinstance(result, float):
|
||||||
|
assert result == 0.0
|
||||||
|
else:
|
||||||
|
assert result.language_model == 0.0
|
||||||
|
assert result.encoder == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_make_compilation_times_matches_current_vllm_shape()
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_compilation_times_filters_to_current_fields():
|
||||||
|
result = _make_compilation_times(1.5, speculative_model=2.0)
|
||||||
|
|
||||||
|
if isinstance(result, float):
|
||||||
|
assert result == 1.5
|
||||||
|
else:
|
||||||
|
assert result.language_model == 1.5
|
||||||
|
assert result.encoder == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compilation_times_import_is_lazy():
|
||||||
|
source_before_helper = inspect.getsource(worker).split("def _make_compilation_times", 1)[0]
|
||||||
|
assert "CompilationTimes" not in source_before_helper
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from vllm_omni.core.sched.omni_ar_scheduler import (
|
||||||
|
_get_request_num_cached_tokens,
|
||||||
|
_get_request_num_external_computed_tokens,
|
||||||
|
_make_engine_core_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_request_num_cached_tokens_defaults_to_zero_when_field_is_missing():
|
||||||
|
request = SimpleNamespace(request_id="rid", num_computed_tokens=5)
|
||||||
|
|
||||||
|
assert _get_request_num_cached_tokens(request) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_request_num_external_computed_tokens_defaults_to_zero_when_field_is_missing():
|
||||||
|
request = SimpleNamespace(request_id="rid")
|
||||||
|
|
||||||
|
assert _get_request_num_external_computed_tokens(request) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_engine_core_output_ignores_fields_missing_from_current_vllm():
|
||||||
|
output = _make_engine_core_output(
|
||||||
|
request_id="rid",
|
||||||
|
new_token_ids=[1],
|
||||||
|
finish_reason=None,
|
||||||
|
new_logprobs=None,
|
||||||
|
new_prompt_logprobs_tensors=None,
|
||||||
|
pooling_output=None,
|
||||||
|
stop_reason=None,
|
||||||
|
events=[],
|
||||||
|
kv_transfer_params=None,
|
||||||
|
trace_headers={},
|
||||||
|
num_cached_tokens=3,
|
||||||
|
num_external_computed_tokens=4,
|
||||||
|
routed_experts=None,
|
||||||
|
num_nans_in_logits=0,
|
||||||
|
is_segment_finished=None,
|
||||||
|
new_prompt_len_snapshot=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output.request_id == "rid"
|
||||||
|
assert output.new_token_ids == [1]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_get_request_num_cached_tokens_defaults_to_zero_when_field_is_missing()
|
||||||
|
test_get_request_num_external_computed_tokens_defaults_to_zero_when_field_is_missing()
|
||||||
|
test_make_engine_core_output_ignores_fields_missing_from_current_vllm()
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.core.sched.output import OmniCachedRequestData
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
|
||||||
|
class _Array:
|
||||||
|
def __init__(self):
|
||||||
|
self.np = [0]
|
||||||
|
self.writes = []
|
||||||
|
self.applied = False
|
||||||
|
|
||||||
|
def stage_write_elem(self, idx, value):
|
||||||
|
self.writes.append((idx, value))
|
||||||
|
|
||||||
|
def apply_write(self):
|
||||||
|
self.applied = True
|
||||||
|
|
||||||
|
|
||||||
|
class _TokenIds:
|
||||||
|
def __init__(self):
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
def stage_write(self, idx, start, values):
|
||||||
|
self.writes.append((idx, start, list(values)))
|
||||||
|
|
||||||
|
|
||||||
|
class _ReqStates:
|
||||||
|
def __init__(self, has_request=True):
|
||||||
|
self.req_id_to_index = {"r1": 0} if has_request else {}
|
||||||
|
self.prompt_len = SimpleNamespace(np=[0])
|
||||||
|
self.prefill_len = SimpleNamespace(np=[0])
|
||||||
|
self.total_len = _Array()
|
||||||
|
self.all_token_ids = _TokenIds()
|
||||||
|
self.num_computed_tokens = _Array()
|
||||||
|
self.num_computed_prefill_tokens = [7]
|
||||||
|
self.applied = False
|
||||||
|
self.removed = []
|
||||||
|
|
||||||
|
def apply_staged_writes(self):
|
||||||
|
self.applied = True
|
||||||
|
|
||||||
|
def remove_request(self, req_id):
|
||||||
|
self.removed.append(req_id)
|
||||||
|
self.req_id_to_index.pop(req_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
class _IntermediateBuffer:
|
||||||
|
def __init__(self):
|
||||||
|
self.buffers = [{"stale": "value", "req_id": "r1"}]
|
||||||
|
|
||||||
|
def remove_request(self, idx):
|
||||||
|
self.buffers[idx] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_chunk_update_clears_stale_intermediate_buffer():
|
||||||
|
runner = object.__new__(OmniGenerationModelRunner)
|
||||||
|
runner.req_states = _ReqStates()
|
||||||
|
runner.model_state = SimpleNamespace(intermediate_buffer=_IntermediateBuffer())
|
||||||
|
cached = OmniCachedRequestData(
|
||||||
|
req_ids=["r1"],
|
||||||
|
resumed_req_ids=set(),
|
||||||
|
new_token_ids=[],
|
||||||
|
all_token_ids=[],
|
||||||
|
new_block_ids=[],
|
||||||
|
num_computed_tokens=[],
|
||||||
|
num_output_tokens=[],
|
||||||
|
prompt_token_ids={"r1": [1, 2, 3]},
|
||||||
|
additional_information={"r1": {"fresh": "value"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
runner._handle_async_chunk_updates(SimpleNamespace(scheduled_cached_reqs=cached))
|
||||||
|
|
||||||
|
assert runner.model_state.intermediate_buffer.buffers[0] == {}
|
||||||
|
assert runner.req_states.prompt_len.np[0] == 3
|
||||||
|
assert runner.req_states.prefill_len.np[0] == 3
|
||||||
|
assert runner.req_states.all_token_ids.writes == [(0, 0, [1, 2, 3])]
|
||||||
|
assert runner.req_states.num_computed_prefill_tokens[0] == 0
|
||||||
|
assert runner.req_states.applied is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_chunk_update_readds_released_cached_request():
|
||||||
|
runner = object.__new__(OmniGenerationModelRunner)
|
||||||
|
runner.req_states = _ReqStates(has_request=False)
|
||||||
|
runner.model_state = SimpleNamespace(intermediate_buffer=_IntermediateBuffer())
|
||||||
|
added = []
|
||||||
|
runner.add_requests = lambda scheduler_output: added.extend(scheduler_output.scheduled_new_reqs)
|
||||||
|
cached = OmniCachedRequestData(
|
||||||
|
req_ids=["r1"],
|
||||||
|
resumed_req_ids=set(),
|
||||||
|
new_token_ids=[],
|
||||||
|
all_token_ids={"r1": [1, 2, 3]},
|
||||||
|
new_block_ids=[([7],)],
|
||||||
|
num_computed_tokens=[0],
|
||||||
|
num_output_tokens=[0],
|
||||||
|
prompt_token_ids={"r1": [1, 2, 3]},
|
||||||
|
additional_information={"r1": {"fresh": "value"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
runner._handle_async_chunk_updates(SimpleNamespace(scheduled_cached_reqs=cached))
|
||||||
|
|
||||||
|
assert len(added) == 1
|
||||||
|
assert added[0].req_id == "r1"
|
||||||
|
assert added[0].prompt_token_ids == [1, 2, 3]
|
||||||
|
assert added[0].prefill_token_ids == [1, 2, 3]
|
||||||
|
assert added[0].block_ids == ([7],)
|
||||||
|
assert added[0].additional_information == {"fresh": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runner_for_sample(async_chunk):
|
||||||
|
runner = object.__new__(OmniGenerationModelRunner)
|
||||||
|
runner._gen_model_output = OmniOutput(
|
||||||
|
text_hidden_states=torch.zeros(1, 1),
|
||||||
|
multimodal_outputs={"audio": [torch.zeros(1)]},
|
||||||
|
)
|
||||||
|
runner._gen_input_batch = SimpleNamespace(
|
||||||
|
num_reqs=1,
|
||||||
|
idx_mapping_np=[0],
|
||||||
|
req_ids=["r1"],
|
||||||
|
)
|
||||||
|
runner._gen_kv_connector_output = None
|
||||||
|
runner.execute_model_state = object()
|
||||||
|
runner.model_config = SimpleNamespace(async_chunk=async_chunk)
|
||||||
|
runner.req_states = _ReqStates()
|
||||||
|
runner.model_state = SimpleNamespace(
|
||||||
|
intermediate_buffer=_IntermediateBuffer(),
|
||||||
|
remove_request=lambda idx: runner.model_state.intermediate_buffer.remove_request(idx),
|
||||||
|
)
|
||||||
|
removed = []
|
||||||
|
runner._remove_request = lambda req_id: removed.append(req_id) or True
|
||||||
|
return runner, removed
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_sample_keeps_runner_slot_without_async_chunk():
|
||||||
|
runner, removed = _make_runner_for_sample(async_chunk=False)
|
||||||
|
|
||||||
|
output = runner.sample_tokens()
|
||||||
|
|
||||||
|
assert output.req_ids == ["r1"]
|
||||||
|
assert removed == []
|
||||||
|
assert runner.model_state.intermediate_buffer.buffers[0] == {"stale": "value", "req_id": "r1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_sample_releases_runner_slot_after_chunk_output():
|
||||||
|
runner, removed = _make_runner_for_sample(async_chunk=True)
|
||||||
|
|
||||||
|
output = runner.sample_tokens()
|
||||||
|
|
||||||
|
assert output.req_ids == ["r1"]
|
||||||
|
assert removed == ["r1"]
|
||||||
|
assert runner.model_state.intermediate_buffer.buffers[0] == {}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_async_chunk_update_clears_stale_intermediate_buffer()
|
||||||
|
test_async_chunk_update_readds_released_cached_request()
|
||||||
|
test_generation_sample_keeps_runner_slot_without_async_chunk()
|
||||||
|
test_generation_sample_releases_runner_slot_after_chunk_output()
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.omni_ar_model_runner import OmniAsyncOutput
|
||||||
|
|
||||||
|
|
||||||
|
def test_omni_async_output_does_not_require_external_copy_event():
|
||||||
|
param = inspect.signature(OmniAsyncOutput.__init__).parameters["copy_event"]
|
||||||
|
|
||||||
|
assert param.default is None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_omni_async_output_does_not_require_external_copy_event()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.omni_ar_model_runner import OmniARModelRunner
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_model_runners_accept_vllm_020_is_profile_keyword():
|
||||||
|
for runner_cls in (OmniARModelRunner, OmniGenerationModelRunner, OmniGPUModelRunner):
|
||||||
|
assert "is_profile" in inspect.signature(runner_cls.execute_model).parameters
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_v2_model_runners_accept_vllm_020_is_profile_keyword()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.omni_model_runner import _make_execute_model_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_execute_model_state_ignores_fields_missing_from_current_vllm():
|
||||||
|
state = _make_execute_model_state(
|
||||||
|
input_batch="input",
|
||||||
|
attn_metadata=None,
|
||||||
|
slot_mappings_by_layer=None,
|
||||||
|
hidden_states="hidden",
|
||||||
|
aux_hidden_states=None,
|
||||||
|
# finished_req_ids is a real field on vLLM 0.23.0's ExecuteModelState.
|
||||||
|
finished_req_ids=set(),
|
||||||
|
# These are fields the omni runners still pass but current vLLM no longer
|
||||||
|
# declares; they must be filtered out rather than crash the constructor.
|
||||||
|
kv_connector_output=None,
|
||||||
|
num_tokens_across_dp=8,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state.input_batch == "input"
|
||||||
|
assert state.hidden_states == "hidden"
|
||||||
|
assert not hasattr(state, "num_tokens_across_dp")
|
||||||
|
assert not hasattr(state, "kv_connector_output")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_make_execute_model_state_ignores_fields_missing_from_current_vllm()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from vllm.compilation.cuda_graph import CUDAGraphMode
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||||
|
|
||||||
|
|
||||||
|
def test_eager_dispatch_uses_eager_without_cudagraph_manager():
|
||||||
|
runner = object.__new__(OmniGPUModelRunner)
|
||||||
|
runner.cudagraph_manager = None
|
||||||
|
runner.dp_size = 1
|
||||||
|
runner.dp_rank = 0
|
||||||
|
|
||||||
|
batch_desc, num_tokens_across_dp = runner._dispatch_batch_descriptor(
|
||||||
|
num_reqs=1,
|
||||||
|
num_toks=8,
|
||||||
|
uniform_tok_count=8,
|
||||||
|
use_eager=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert batch_desc.cg_mode == CUDAGraphMode.NONE
|
||||||
|
assert batch_desc.num_tokens == 8
|
||||||
|
assert batch_desc.num_reqs == 1
|
||||||
|
assert num_tokens_across_dp is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_eager_dispatch_syncs_dp_padding(monkeypatch):
|
||||||
|
runner = object.__new__(OmniGPUModelRunner)
|
||||||
|
runner.cudagraph_manager = None
|
||||||
|
runner.dp_size = 2
|
||||||
|
runner.dp_rank = 1
|
||||||
|
expected_tokens_across_dp = object()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def sync_dp(cudagraph_manager, batch_desc, num_tokens, num_reqs, uniform_token_count, dp_size, dp_rank):
|
||||||
|
calls.append((cudagraph_manager, batch_desc, num_tokens, num_reqs, uniform_token_count, dp_size, dp_rank))
|
||||||
|
return batch_desc, expected_tokens_across_dp
|
||||||
|
|
||||||
|
import vllm.v1.worker.gpu.dp_utils as dp_utils
|
||||||
|
|
||||||
|
monkeypatch.setattr(dp_utils, "sync_cudagraph_and_dp_padding", sync_dp)
|
||||||
|
|
||||||
|
batch_desc, num_tokens_across_dp = runner._dispatch_batch_descriptor(
|
||||||
|
num_reqs=1,
|
||||||
|
num_toks=8,
|
||||||
|
uniform_tok_count=8,
|
||||||
|
use_eager=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert batch_desc.cg_mode == CUDAGraphMode.NONE
|
||||||
|
assert batch_desc.num_tokens == 8
|
||||||
|
assert batch_desc.num_reqs == 1
|
||||||
|
assert num_tokens_across_dp is expected_tokens_across_dp
|
||||||
|
assert calls == [(None, batch_desc, 8, 1, 8, 2, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_eager_dispatch_uses_cudagraph_manager():
|
||||||
|
runner = object.__new__(OmniGPUModelRunner)
|
||||||
|
expected = SimpleNamespace(cg_mode=CUDAGraphMode.PIECEWISE, num_tokens=8, num_reqs=1)
|
||||||
|
calls = []
|
||||||
|
runner.cudagraph_manager = SimpleNamespace(dispatch=lambda *args: calls.append(args) or expected)
|
||||||
|
runner.dp_size = 1
|
||||||
|
runner.dp_rank = 0
|
||||||
|
|
||||||
|
batch_desc, num_tokens_across_dp = runner._dispatch_batch_descriptor(
|
||||||
|
num_reqs=1,
|
||||||
|
num_toks=8,
|
||||||
|
uniform_tok_count=8,
|
||||||
|
use_eager=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert batch_desc is expected
|
||||||
|
assert calls == [(1, 8, 8)]
|
||||||
|
assert num_tokens_across_dp is None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_eager_dispatch_uses_eager_without_cudagraph_manager()
|
||||||
|
test_non_eager_dispatch_uses_cudagraph_manager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_compiled_uses_eager_dispatch_without_overwriting_batch_descriptor():
|
||||||
|
source = __import__("inspect").getsource(OmniGPUModelRunner.execute_model)
|
||||||
|
|
||||||
|
assert "use_eager=is_profile or skip_compiled" in source
|
||||||
|
assert "if self.is_encoder_decoder and scheduler_output.scheduled_encoder_inputs" not in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_fullgraph_requires_cudagraph_manager_contract():
|
||||||
|
source = __import__("inspect").getsource(OmniGPUModelRunner.execute_model)
|
||||||
|
|
||||||
|
assert "assert self.cudagraph_manager is not None" in source
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.omni_ar_model_runner import OmniARModelRunner
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||||
|
|
||||||
|
|
||||||
|
def test_v2_model_runners_have_shutdown_for_vllm_020_worker_exit():
|
||||||
|
for runner_cls in (OmniARModelRunner, OmniGenerationModelRunner, OmniGPUModelRunner):
|
||||||
|
assert hasattr(runner_cls, "shutdown")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_v2_model_runners_have_shutdown_for_vllm_020_worker_exit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_model_uses_class_patch_lock():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
source = inspect.getsource(OmniGPUModelRunner.load_model)
|
||||||
|
assert "_model_state_patch_lock" in source
|
||||||
Whitespace-only changes.
@@ -0,0 +1,83 @@
|
|||||||
|
"""Unit tests for init_omni_model_state factory dispatch."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.model_states import (
|
||||||
|
_OMNI_ARCHITECTURES,
|
||||||
|
init_omni_model_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_vllm_config(architectures):
|
||||||
|
model_config = SimpleNamespace(architectures=architectures)
|
||||||
|
return SimpleNamespace(model_config=model_config)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vllm_omni.worker_v2.model_states.omni_model_state.OmniModelState.__init__", return_value=None)
|
||||||
|
def test_qwen3_omni_dispatches_to_omni_model_state(mock_init):
|
||||||
|
cfg = _make_vllm_config(["Qwen3OmniMoeForConditionalGeneration"])
|
||||||
|
model = MagicMock()
|
||||||
|
device = torch.device("cpu")
|
||||||
|
|
||||||
|
state = init_omni_model_state(cfg, model, None, device)
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||||
|
|
||||||
|
assert isinstance(state, OmniModelState)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vllm_omni.worker_v2.model_states.omni_model_state.OmniModelState.__init__", return_value=None)
|
||||||
|
def test_mammoth_dispatches_to_omni_model_state(mock_init):
|
||||||
|
cfg = _make_vllm_config(["MammothModa2ForConditionalGeneration"])
|
||||||
|
model = MagicMock()
|
||||||
|
device = torch.device("cpu")
|
||||||
|
|
||||||
|
state = init_omni_model_state(cfg, model, None, device)
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||||
|
|
||||||
|
assert isinstance(state, OmniModelState)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vllm_omni.worker_v2.model_states._upstream_init_model_state")
|
||||||
|
def test_unknown_arch_delegates_to_upstream(mock_upstream):
|
||||||
|
mock_upstream.return_value = MagicMock()
|
||||||
|
cfg = _make_vllm_config(["LlamaForCausalLM"])
|
||||||
|
model = MagicMock()
|
||||||
|
device = torch.device("cpu")
|
||||||
|
|
||||||
|
state = init_omni_model_state(cfg, model, None, device)
|
||||||
|
|
||||||
|
mock_upstream.assert_called_once_with(cfg, model, None, device)
|
||||||
|
assert state is mock_upstream.return_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_omni_architectures_set_contains_expected():
|
||||||
|
expected = {
|
||||||
|
"Qwen3OmniMoeForConditionalGeneration",
|
||||||
|
"Qwen2_5OmniForConditionalGeneration",
|
||||||
|
"MammothModa2ForConditionalGeneration",
|
||||||
|
"MiMoAudioForConditionalGeneration",
|
||||||
|
"MammothModa2ARForConditionalGeneration",
|
||||||
|
"Qwen3TTSTalkerForConditionalGeneration",
|
||||||
|
"Qwen3TTSCode2Wav",
|
||||||
|
}
|
||||||
|
assert _OMNI_ARCHITECTURES == expected
|
||||||
|
|
||||||
|
|
||||||
|
@patch("vllm_omni.worker_v2.model_states._upstream_init_model_state")
|
||||||
|
def test_none_architectures_delegates_to_upstream(mock_upstream):
|
||||||
|
mock_upstream.return_value = MagicMock()
|
||||||
|
cfg = _make_vllm_config(None)
|
||||||
|
model = MagicMock()
|
||||||
|
device = torch.device("cpu")
|
||||||
|
|
||||||
|
init_omni_model_state(cfg, model, None, device)
|
||||||
|
|
||||||
|
mock_upstream.assert_called_once()
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Unit tests for OmniIntermediateBuffer lifecycle."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.model_states.intermediate_buffer import (
|
||||||
|
OmniIntermediateBuffer,
|
||||||
|
_resolve_additional_information,
|
||||||
|
_resolve_prompt_embeds,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyInputBatch:
|
||||||
|
def __init__(self, idx_mapping):
|
||||||
|
self.idx_mapping_np = idx_mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _make_new_req_data(req_id="r1", prompt_embeds=None, mm_features=None, additional_information=None):
|
||||||
|
ns = SimpleNamespace(
|
||||||
|
req_id=req_id,
|
||||||
|
mm_features=mm_features or [],
|
||||||
|
)
|
||||||
|
if prompt_embeds is not None:
|
||||||
|
ns.prompt_embeds = prompt_embeds
|
||||||
|
if additional_information is not None:
|
||||||
|
ns.additional_information = additional_information
|
||||||
|
return ns
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# _resolve_prompt_embeds
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_prompt_embeds_tensor():
|
||||||
|
t = torch.randn(3, 4, device="cpu")
|
||||||
|
result = _resolve_prompt_embeds(t)
|
||||||
|
assert isinstance(result, torch.Tensor)
|
||||||
|
assert result.shape == (3, 4)
|
||||||
|
assert result.is_contiguous()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_prompt_embeds_none():
|
||||||
|
assert _resolve_prompt_embeds(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# _resolve_additional_information
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_additional_information_dict_passthrough():
|
||||||
|
d = {"key": "val"}
|
||||||
|
assert _resolve_additional_information(d) == d
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_additional_information_none():
|
||||||
|
assert _resolve_additional_information(None) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# OmniIntermediateBuffer core lifecycle
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_and_remove():
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=4)
|
||||||
|
req = _make_new_req_data(req_id="r1")
|
||||||
|
buf.add_request(0, req)
|
||||||
|
assert buf.buffers[0]["req_id"] == "r1"
|
||||||
|
|
||||||
|
buf.remove_request(0)
|
||||||
|
assert buf.buffers[0] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_with_prompt_embeds():
|
||||||
|
pe = torch.randn(2, 8)
|
||||||
|
req = _make_new_req_data(req_id="r1", prompt_embeds=pe)
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=4)
|
||||||
|
buf.add_request(0, req)
|
||||||
|
assert "prompt_embeds_cpu" in buf.buffers[0]
|
||||||
|
assert buf.buffers[0]["prompt_embeds_cpu"].shape == (2, 8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_with_mm_features():
|
||||||
|
features = [torch.randn(3, 4)]
|
||||||
|
req = _make_new_req_data(req_id="r1", mm_features=features)
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=4)
|
||||||
|
buf.add_request(1, req)
|
||||||
|
assert buf.buffers[1]["mm_features"] is features
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_with_additional_information():
|
||||||
|
info = {"audio_embeds": torch.randn(5)}
|
||||||
|
req = _make_new_req_data(req_id="r1", additional_information=info)
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=4)
|
||||||
|
buf.add_request(0, req)
|
||||||
|
assert torch.allclose(buf.buffers[0]["audio_embeds"], info["audio_embeds"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# gather
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_gather_returns_batch_order():
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=4)
|
||||||
|
buf.add_request(0, _make_new_req_data(req_id="r0"))
|
||||||
|
buf.add_request(2, _make_new_req_data(req_id="r2"))
|
||||||
|
buf.add_request(3, _make_new_req_data(req_id="r3"))
|
||||||
|
|
||||||
|
batch = _DummyInputBatch(idx_mapping=[2, 0, 3])
|
||||||
|
gathered = buf.gather(batch)
|
||||||
|
assert [g["req_id"] for g in gathered] == ["r2", "r0", "r3"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# update
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_merges_tensors_to_cpu():
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=2)
|
||||||
|
buf.add_request(0, _make_new_req_data(req_id="r0"))
|
||||||
|
|
||||||
|
buf.update(0, {"hidden": torch.randn(4)})
|
||||||
|
assert "hidden" in buf.buffers[0]
|
||||||
|
assert buf.buffers[0]["hidden"].device == torch.device("cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_gpu_resident_keys():
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=2)
|
||||||
|
buf.add_request(0, _make_new_req_data(req_id="r0"))
|
||||||
|
|
||||||
|
t = torch.randn(4)
|
||||||
|
buf.update(0, {"kv": t}, gpu_resident_keys={"kv"})
|
||||||
|
assert "kv" in buf.buffers[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_list_values():
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=2)
|
||||||
|
buf.add_request(0, _make_new_req_data(req_id="r0"))
|
||||||
|
|
||||||
|
items = [torch.randn(2), "text"]
|
||||||
|
buf.update(0, {"items": items})
|
||||||
|
stored = buf.buffers[0]["items"]
|
||||||
|
assert isinstance(stored[0], torch.Tensor)
|
||||||
|
assert stored[1] == "text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_empty_is_noop():
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=2)
|
||||||
|
buf.add_request(0, _make_new_req_data(req_id="r0"))
|
||||||
|
before = dict(buf.buffers[0])
|
||||||
|
buf.update(0, {})
|
||||||
|
assert buf.buffers[0] == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_accumulates():
|
||||||
|
buf = OmniIntermediateBuffer(max_num_reqs=2)
|
||||||
|
buf.add_request(0, _make_new_req_data(req_id="r0"))
|
||||||
|
buf.update(0, {"a": torch.tensor([1.0])})
|
||||||
|
buf.update(0, {"b": torch.tensor([2.0])})
|
||||||
|
assert "a" in buf.buffers[0] and "b" in buf.buffers[0]
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""Unit tests for OmniARModelRunner v2."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.data_entry_keys import unflatten_payload
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.worker_v2.omni_ar_model_runner import (
|
||||||
|
OmniARModelRunner,
|
||||||
|
_async_copy_mm,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# _build_pooler_output_from_cpu (was _build_pooler_output)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconstruct_raw_model_output_preserves_omni_multimodal_outputs():
|
||||||
|
hidden = torch.randn(3, 4)
|
||||||
|
latent = torch.randn(3, 4)
|
||||||
|
raw = OmniARModelRunner._reconstruct_raw_model_output(
|
||||||
|
hidden_states=hidden,
|
||||||
|
multimodal_outputs={"latent": latent},
|
||||||
|
aux=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(raw, OmniOutput)
|
||||||
|
assert raw.text_hidden_states is hidden
|
||||||
|
assert raw.multimodal_outputs["latent"] is latent
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconstruct_raw_model_output_keeps_aux_tuple_without_multimodal_outputs():
|
||||||
|
hidden = torch.randn(3, 4)
|
||||||
|
aux = {"layers": torch.randn(3, 2)}
|
||||||
|
raw = OmniARModelRunner._reconstruct_raw_model_output(
|
||||||
|
hidden_states=hidden,
|
||||||
|
multimodal_outputs=None,
|
||||||
|
aux=aux,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raw == (hidden, aux)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconstruct_raw_model_output_ignores_empty_multimodal_outputs():
|
||||||
|
hidden = torch.randn(3, 4)
|
||||||
|
raw = OmniARModelRunner._reconstruct_raw_model_output(
|
||||||
|
hidden_states=hidden,
|
||||||
|
multimodal_outputs={},
|
||||||
|
aux=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raw is hidden
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pooler_output_basic():
|
||||||
|
"""Verify _build_pooler_output_from_cpu slices per-request hidden + mm."""
|
||||||
|
hidden = torch.randn(6, 8)
|
||||||
|
mm = {"audio": torch.randn(6, 2)}
|
||||||
|
|
||||||
|
pooler = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
hidden,
|
||||||
|
mm,
|
||||||
|
query_start_loc_np=np.array([0, 3]),
|
||||||
|
num_scheduled_tokens=np.array([3, 3], dtype=np.int32),
|
||||||
|
num_reqs=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(pooler) == 2
|
||||||
|
assert pooler[0]["hidden"].shape == (3, 8)
|
||||||
|
assert pooler[1]["hidden"].shape == (3, 8)
|
||||||
|
assert pooler[0]["audio"].shape == (3, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pooler_output_empty_mm():
|
||||||
|
hidden = torch.randn(4, 8)
|
||||||
|
|
||||||
|
pooler = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
hidden,
|
||||||
|
{},
|
||||||
|
query_start_loc_np=np.array([0]),
|
||||||
|
num_scheduled_tokens=np.array([4], dtype=np.int32),
|
||||||
|
num_reqs=1,
|
||||||
|
)
|
||||||
|
assert len(pooler) == 1
|
||||||
|
assert "hidden" in pooler[0]
|
||||||
|
assert len(pooler[0]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# _async_copy_mm (was _copy_mm_to_cpu)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_mm_to_cpu_tensor():
|
||||||
|
total = 10
|
||||||
|
t = torch.randn(10, 4)
|
||||||
|
result = _async_copy_mm({"feat": t}, total)
|
||||||
|
assert "feat" in result
|
||||||
|
assert result["feat"].shape == (10, 4)
|
||||||
|
assert result["feat"].device == torch.device("cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_mm_to_cpu_dict():
|
||||||
|
total = 10
|
||||||
|
d = {"inner": torch.randn(10, 2)}
|
||||||
|
result = _async_copy_mm({"nested": d}, total)
|
||||||
|
assert "nested" in result
|
||||||
|
assert "inner" in result["nested"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_mm_to_cpu_list():
|
||||||
|
result = _async_copy_mm({"items": [torch.randn(3), "text"]}, 10)
|
||||||
|
assert "items" in result
|
||||||
|
assert isinstance(result["items"][0], torch.Tensor)
|
||||||
|
assert result["items"][1] == "text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_mm_to_cpu_empty():
|
||||||
|
assert _async_copy_mm({}, 10) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Slicing via _build_pooler_output_from_cpu (was _slice_mm_payload)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_slice_mm_payload_tensor():
|
||||||
|
hidden = torch.randn(6, 4)
|
||||||
|
mm_cpu = {"feat": torch.randn(6, 2)}
|
||||||
|
|
||||||
|
pooler = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
hidden,
|
||||||
|
mm_cpu,
|
||||||
|
query_start_loc_np=np.array([0, 3]),
|
||||||
|
num_scheduled_tokens=np.array([3, 3], dtype=np.int32),
|
||||||
|
num_reqs=2,
|
||||||
|
)
|
||||||
|
assert pooler[0]["feat"].shape == (3, 2)
|
||||||
|
assert pooler[1]["feat"].shape == (3, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slice_mm_payload_list():
|
||||||
|
hidden = torch.randn(6, 4)
|
||||||
|
mm_cpu = {"items": [torch.randn(2), torch.randn(3)]}
|
||||||
|
|
||||||
|
pooler = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
hidden,
|
||||||
|
mm_cpu,
|
||||||
|
query_start_loc_np=np.array([0, 3]),
|
||||||
|
num_scheduled_tokens=np.array([3, 3], dtype=np.int32),
|
||||||
|
num_reqs=2,
|
||||||
|
)
|
||||||
|
assert isinstance(pooler[0]["items"], torch.Tensor)
|
||||||
|
assert isinstance(pooler[1]["items"], torch.Tensor)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slice_mm_payload_dict():
|
||||||
|
hidden = torch.randn(6, 4)
|
||||||
|
mm_cpu = {"nested": {"a": torch.randn(6, 2)}}
|
||||||
|
|
||||||
|
pooler = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
hidden,
|
||||||
|
mm_cpu,
|
||||||
|
query_start_loc_np=np.array([0, 3]),
|
||||||
|
num_scheduled_tokens=np.array([3, 3], dtype=np.int32),
|
||||||
|
num_reqs=2,
|
||||||
|
)
|
||||||
|
assert pooler[1]["nested"]["a"].shape == (3, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pooler_output_flattens_nested_payload_for_msgspec():
|
||||||
|
hidden = torch.randn(4, 4)
|
||||||
|
mm_cpu = {"codes": {"audio": torch.randn(4, 16)}}
|
||||||
|
|
||||||
|
pooler = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
hidden,
|
||||||
|
mm_cpu,
|
||||||
|
query_start_loc_np=np.array([0, 2]),
|
||||||
|
num_scheduled_tokens=np.array([2, 2], dtype=np.int32),
|
||||||
|
num_reqs=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "codes" not in pooler[0]
|
||||||
|
assert pooler[0]["codes.audio"].shape == (2, 16)
|
||||||
|
assert pooler[1]["codes.audio"].shape == (2, 16)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pooler_output_preserves_qwen3_nested_payload():
|
||||||
|
hidden = torch.randn(4, 4)
|
||||||
|
mm = {
|
||||||
|
"hidden_states": {
|
||||||
|
"layers": {
|
||||||
|
0: torch.randn(4, 4),
|
||||||
|
24: torch.randn(4, 4),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"embed": {
|
||||||
|
"tts_bos": [torch.randn(1, 1, 4)],
|
||||||
|
"tts_eos": [torch.randn(1, 1, 4)],
|
||||||
|
"tts_pad": [torch.randn(1, 1, 4)],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mm_cpu = _async_copy_mm(mm, total_tokens=4)
|
||||||
|
pooler = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
hidden,
|
||||||
|
mm_cpu,
|
||||||
|
query_start_loc_np=np.array([0, 2]),
|
||||||
|
num_scheduled_tokens=np.array([2, 2], dtype=np.int32),
|
||||||
|
num_reqs=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = unflatten_payload(pooler[0])
|
||||||
|
assert payload["hidden_states"]["layers"][0].shape == (2, 4)
|
||||||
|
assert payload["hidden_states"]["layers"][24].shape == (2, 4)
|
||||||
|
assert payload["embed"]["tts_bos"].shape == (1, 1, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clamp_sampling_prompt_token_ids_to_logits_vocab():
|
||||||
|
prompt_token_ids = torch.tensor([[3, 99, 152064]])
|
||||||
|
input_batch = SimpleNamespace(
|
||||||
|
vocab_size=152064,
|
||||||
|
sampling_metadata=SimpleNamespace(
|
||||||
|
no_penalties=False,
|
||||||
|
prompt_token_ids=prompt_token_ids,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
OmniARModelRunner._clamp_sampling_prompt_token_ids(
|
||||||
|
input_batch,
|
||||||
|
logits_vocab_size=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prompt_token_ids.tolist() == [[3, 9, 9]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_clamps_prompt_token_ids_from_actual_logits_vocab():
|
||||||
|
runner = object.__new__(OmniARModelRunner)
|
||||||
|
|
||||||
|
class Model:
|
||||||
|
def compute_logits(self, *_args, **_kwargs):
|
||||||
|
return torch.empty(1, 7)
|
||||||
|
|
||||||
|
runner.model = Model()
|
||||||
|
prompt_token_ids = torch.tensor([[3, 99, 152064]])
|
||||||
|
input_batch = SimpleNamespace(
|
||||||
|
vocab_size=152064,
|
||||||
|
sampling_metadata=SimpleNamespace(
|
||||||
|
no_penalties=False,
|
||||||
|
prompt_token_ids=prompt_token_ids,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sample_seen_prompt_ids = []
|
||||||
|
|
||||||
|
def sample(text_hidden, input_batch_arg, grammar_output):
|
||||||
|
assert grammar_output is None
|
||||||
|
runner.model.compute_logits(text_hidden)
|
||||||
|
sample_seen_prompt_ids.append(input_batch_arg.sampling_metadata.prompt_token_ids.clone())
|
||||||
|
return "sampler", "num_sampled", "num_rejected"
|
||||||
|
|
||||||
|
runner.sample = sample
|
||||||
|
|
||||||
|
result = runner._sample_with_prompt_token_compat(
|
||||||
|
torch.empty(1, 4),
|
||||||
|
input_batch,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == ("sampler", "num_sampled", "num_rejected")
|
||||||
|
assert prompt_token_ids.tolist() == [[3, 6, 6]]
|
||||||
|
assert sample_seen_prompt_ids[0].tolist() == [[3, 6, 6]]
|
||||||
|
assert runner.model.compute_logits.__func__ is Model.compute_logits
|
||||||
|
assert "compute_logits" not in runner.model.__dict__
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
def _load_add_forward_compat_kwargs():
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
module_path = root / "vllm_omni" / "worker_v2" / "forward_compat.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("omni_forward_compat", module_path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module.add_forward_compat_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_forward_compat_kwargs_include_sampling_metadata_and_sampler():
|
||||||
|
add_forward_compat_kwargs = _load_add_forward_compat_kwargs()
|
||||||
|
model_inputs = {"input_ids": object()}
|
||||||
|
sampling_metadata = object()
|
||||||
|
sampler = object()
|
||||||
|
input_batch = SimpleNamespace(
|
||||||
|
sampling_metadata=sampling_metadata,
|
||||||
|
logits_indices=[3, 7],
|
||||||
|
)
|
||||||
|
|
||||||
|
add_forward_compat_kwargs(model_inputs, input_batch, sampler)
|
||||||
|
|
||||||
|
assert model_inputs["sampling_metadata"] is sampling_metadata
|
||||||
|
assert model_inputs["logits_index"] == [3, 7]
|
||||||
|
assert model_inputs["sampler"] is sampler
|
||||||
|
|
||||||
|
|
||||||
|
def test_forward_compat_kwargs_do_not_clobber_explicit_values():
|
||||||
|
add_forward_compat_kwargs = _load_add_forward_compat_kwargs()
|
||||||
|
model_inputs = {
|
||||||
|
"sampling_metadata": "explicit-metadata",
|
||||||
|
"logits_index": "explicit-logits",
|
||||||
|
"sampler": "explicit-sampler",
|
||||||
|
}
|
||||||
|
input_batch = SimpleNamespace(
|
||||||
|
sampling_metadata="batch-metadata",
|
||||||
|
logits_indices="batch-logits",
|
||||||
|
)
|
||||||
|
|
||||||
|
add_forward_compat_kwargs(model_inputs, input_batch, sampler="runner-sampler")
|
||||||
|
|
||||||
|
assert model_inputs["sampling_metadata"] == "explicit-metadata"
|
||||||
|
assert model_inputs["logits_index"] == "explicit-logits"
|
||||||
|
assert model_inputs["sampler"] == "explicit-sampler"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_forward_compat_kwargs_include_sampling_metadata_and_sampler()
|
||||||
|
test_forward_compat_kwargs_do_not_clobber_explicit_values()
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Tests for OmniGenerationModelRunner.sample_tokens (V2).
|
||||||
|
|
||||||
|
Covers the core pooler_output construction paths via _build_pooler_output:
|
||||||
|
- OmniOutput with batched tensor multimodal_outputs → per-request slicing
|
||||||
|
- OmniOutput with list multimodal_outputs → direct mapping (including None)
|
||||||
|
- OmniOutput with dict scalar values → broadcast to all requests
|
||||||
|
- None model output → returns None
|
||||||
|
- Non-dict multimodal_outputs → [None] * num_reqs
|
||||||
|
- sampled_token_ids always emits empty lists per request (no token sampling)
|
||||||
|
- req_states.num_computed_tokens updated to prompt_len after sample_tokens
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||||
|
|
||||||
|
pytestmark = []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeInputBatch:
|
||||||
|
"""Minimal input batch for sample_tokens."""
|
||||||
|
|
||||||
|
def __init__(self, num_reqs: int = 1, req_ids: list[str] | None = None):
|
||||||
|
self.num_reqs = num_reqs
|
||||||
|
self.req_ids = req_ids or [f"req-{i}" for i in range(num_reqs)]
|
||||||
|
self.idx_mapping_np = np.arange(num_reqs, dtype=np.int32)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStagedField:
|
||||||
|
"""Minimal mock for req_states fields that support staged writes."""
|
||||||
|
|
||||||
|
def __init__(self, data: np.ndarray):
|
||||||
|
self.np = data
|
||||||
|
self._staged: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
def stage_write_elem(self, idx: int, value: int) -> None:
|
||||||
|
self._staged.append((idx, value))
|
||||||
|
|
||||||
|
def apply_write(self) -> None:
|
||||||
|
for idx, value in self._staged:
|
||||||
|
self.np[idx] = value
|
||||||
|
self._staged.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeNpField:
|
||||||
|
"""Minimal mock for req_states fields with .np attribute."""
|
||||||
|
|
||||||
|
def __init__(self, data: np.ndarray):
|
||||||
|
self.np = data
|
||||||
|
|
||||||
|
|
||||||
|
def _make_omni_output(multimodal_outputs: dict | None = None) -> OmniOutput:
|
||||||
|
"""Create an OmniOutput with given multimodal_outputs."""
|
||||||
|
return OmniOutput(
|
||||||
|
text_hidden_states=torch.zeros(1),
|
||||||
|
multimodal_outputs=multimodal_outputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runner(
|
||||||
|
model_output,
|
||||||
|
num_reqs: int = 1,
|
||||||
|
prompt_len: int = 10,
|
||||||
|
):
|
||||||
|
"""Build a minimal OmniGenerationModelRunner for sample_tokens testing."""
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import (
|
||||||
|
OmniGenerationModelRunner,
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = object.__new__(OmniGenerationModelRunner)
|
||||||
|
runner.device = torch.device("cpu")
|
||||||
|
|
||||||
|
mc = MagicMock()
|
||||||
|
del mc.eos_token_id
|
||||||
|
mc.hf_text_config = None
|
||||||
|
runner.model_config = mc
|
||||||
|
|
||||||
|
runner.postprocess = lambda *a, **kw: None
|
||||||
|
|
||||||
|
input_batch = _FakeInputBatch(num_reqs)
|
||||||
|
runner._gen_model_output = model_output
|
||||||
|
runner._gen_input_batch = input_batch
|
||||||
|
runner._gen_kv_connector_output = None
|
||||||
|
runner.execute_model_state = None
|
||||||
|
|
||||||
|
req_states = MagicMock()
|
||||||
|
req_states.prompt_len = _FakeNpField(
|
||||||
|
np.full(num_reqs, prompt_len, dtype=np.int32),
|
||||||
|
)
|
||||||
|
req_states.num_computed_tokens = _FakeStagedField(
|
||||||
|
np.zeros(num_reqs, dtype=np.int32),
|
||||||
|
)
|
||||||
|
runner.req_states = req_states
|
||||||
|
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampleTokensTensorOutput(unittest.TestCase):
|
||||||
|
def test_single_request(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output({"model_outputs": torch.randn(1, 4, 8)})
|
||||||
|
runner = _make_runner(output, num_reqs=1)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert isinstance(result, OmniModelRunnerOutput)
|
||||||
|
assert len(result.pooler_output) == 1
|
||||||
|
assert result.pooler_output[0]["model_outputs"].shape == (4, 8)
|
||||||
|
|
||||||
|
def test_multi_request(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output({"model_outputs": torch.randn(3, 2, 5)})
|
||||||
|
runner = _make_runner(output, num_reqs=3)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert len(result.pooler_output) == 3
|
||||||
|
for i in range(3):
|
||||||
|
assert result.pooler_output[i]["model_outputs"].shape == (2, 5)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampleTokensListOutput(unittest.TestCase):
|
||||||
|
def test_list_of_tensors(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output({"model_outputs": [torch.randn(3, 2)]})
|
||||||
|
runner = _make_runner(output, num_reqs=1)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert len(result.pooler_output) == 1
|
||||||
|
assert result.pooler_output[0]["model_outputs"].shape == (3, 2)
|
||||||
|
|
||||||
|
def test_list_with_none(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output({"model_outputs": [None]})
|
||||||
|
runner = _make_runner(output, num_reqs=1)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert len(result.pooler_output) == 1
|
||||||
|
assert result.pooler_output[0]["model_outputs"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampleTokensDictOutput(unittest.TestCase):
|
||||||
|
def test_dict_with_batched_tensor(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output({"audio": torch.randn(2, 16000), "sr": 24000})
|
||||||
|
runner = _make_runner(output, num_reqs=2)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert len(result.pooler_output) == 2
|
||||||
|
assert result.pooler_output[0]["audio"].shape == (16000,)
|
||||||
|
assert result.pooler_output[1]["audio"].shape == (16000,)
|
||||||
|
assert result.pooler_output[0]["sr"] == 24000
|
||||||
|
|
||||||
|
def test_dict_with_list_values(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output({"chunks": [torch.randn(10), torch.randn(20)]})
|
||||||
|
runner = _make_runner(output, num_reqs=2)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert len(result.pooler_output) == 2
|
||||||
|
assert result.pooler_output[0]["chunks"].shape == (10,)
|
||||||
|
assert result.pooler_output[1]["chunks"].shape == (20,)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampleTokensNoneOutput(unittest.TestCase):
|
||||||
|
def test_none_model_output(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
runner = _make_runner(None, num_reqs=1)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonDictMultimodalOutputs(unittest.TestCase):
|
||||||
|
"""When multimodal_outputs is None or non-dict, pooler_output is [None]*num_reqs."""
|
||||||
|
|
||||||
|
def test_none_multimodal_outputs(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output(multimodal_outputs=None)
|
||||||
|
runner = _make_runner(output, num_reqs=2)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert len(result.pooler_output) == 2
|
||||||
|
assert result.pooler_output[0] is None
|
||||||
|
assert result.pooler_output[1] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSampledTokenIds(unittest.TestCase):
|
||||||
|
def test_empty_sampled_token_ids_per_request(self):
|
||||||
|
"""Generation models emit empty sampled_token_ids (no token sampling)."""
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output({"model_outputs": torch.randn(3, 2)})
|
||||||
|
runner = _make_runner(output, num_reqs=3)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert len(result.sampled_token_ids) == 3
|
||||||
|
for ids in result.sampled_token_ids:
|
||||||
|
assert ids == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestReqStatesUpdate(unittest.TestCase):
|
||||||
|
"""Verify that sample_tokens marks all tokens as computed."""
|
||||||
|
|
||||||
|
def test_num_computed_tokens_set_to_prompt_len(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
prompt_len = 15
|
||||||
|
output = _make_omni_output({"model_outputs": torch.randn(2, 4)})
|
||||||
|
runner = _make_runner(output, num_reqs=2, prompt_len=prompt_len)
|
||||||
|
|
||||||
|
OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
for i in range(2):
|
||||||
|
assert runner.req_states.num_computed_tokens.np[i] == prompt_len
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultimodalOutputsPassthrough(unittest.TestCase):
|
||||||
|
"""multimodal_outputs is a per-request list (tensor-only) on OmniModelRunnerOutput.
|
||||||
|
|
||||||
|
OmniGenerationScheduler indexes it as mm_outputs[req_index], so it must be a
|
||||||
|
list (not the raw dict). Each entry mirrors the per-request pooler payload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_multimodal_outputs_on_result(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
mm = {"audio": [torch.randn(10)]}
|
||||||
|
output = _make_omni_output(mm)
|
||||||
|
runner = _make_runner(output, num_reqs=1)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
assert isinstance(result.multimodal_outputs, list)
|
||||||
|
assert len(result.multimodal_outputs) == 1
|
||||||
|
assert "audio" in result.multimodal_outputs[0]
|
||||||
|
assert torch.is_tensor(result.multimodal_outputs[0]["audio"])
|
||||||
|
|
||||||
|
def test_none_multimodal_outputs_becomes_empty_dict(self):
|
||||||
|
from vllm_omni.worker_v2.omni_generation_model_runner import OmniGenerationModelRunner
|
||||||
|
|
||||||
|
output = _make_omni_output(multimodal_outputs=None)
|
||||||
|
runner = _make_runner(output, num_reqs=1)
|
||||||
|
result = OmniGenerationModelRunner.sample_tokens(runner)
|
||||||
|
|
||||||
|
# No multimodal data -> one empty dict per request.
|
||||||
|
assert result.multimodal_outputs == [{}]
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Unit tests for OmniGPUModelRunner v2 overrides."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner, _needs_capture_tensor_unwrap
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyInputBatch:
|
||||||
|
def __init__(self, idx_mapping=None):
|
||||||
|
self.idx_mapping_np = idx_mapping or []
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runner():
|
||||||
|
"""Create an OmniGPUModelRunner without calling __init__."""
|
||||||
|
runner = object.__new__(OmniGPUModelRunner)
|
||||||
|
runner.model = MagicMock()
|
||||||
|
runner.req_states = SimpleNamespace(req_id_to_index={"r1": 0, "r2": 1})
|
||||||
|
runner.execute_model_state = None
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_requests_calls_remove_for_finished():
|
||||||
|
runner = _make_runner()
|
||||||
|
mock_state = MagicMock()
|
||||||
|
runner.model_state = mock_state
|
||||||
|
|
||||||
|
sched_output = SimpleNamespace(
|
||||||
|
finished_req_ids={"r1"},
|
||||||
|
preempted_req_ids=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(type(runner).__bases__[0], "finish_requests", return_value=None):
|
||||||
|
runner.finish_requests(sched_output)
|
||||||
|
|
||||||
|
mock_state.remove_request.assert_called_once_with(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_requests_calls_remove_for_preempted():
|
||||||
|
runner = _make_runner()
|
||||||
|
mock_state = MagicMock()
|
||||||
|
runner.model_state = mock_state
|
||||||
|
|
||||||
|
sched_output = SimpleNamespace(
|
||||||
|
finished_req_ids=set(),
|
||||||
|
preempted_req_ids={"r2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(type(runner).__bases__[0], "finish_requests", return_value=None):
|
||||||
|
runner.finish_requests(sched_output)
|
||||||
|
|
||||||
|
mock_state.remove_request.assert_called_once_with(1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_requests_ignores_unknown_req_ids():
|
||||||
|
runner = _make_runner()
|
||||||
|
mock_state = MagicMock()
|
||||||
|
runner.model_state = mock_state
|
||||||
|
|
||||||
|
sched_output = SimpleNamespace(
|
||||||
|
finished_req_ids={"unknown"},
|
||||||
|
preempted_req_ids=set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(type(runner).__bases__[0], "finish_requests", return_value=None):
|
||||||
|
runner.finish_requests(sched_output)
|
||||||
|
|
||||||
|
mock_state.remove_request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_finish_requests_handles_both_finished_and_preempted():
|
||||||
|
runner = _make_runner()
|
||||||
|
mock_state = MagicMock()
|
||||||
|
runner.model_state = mock_state
|
||||||
|
|
||||||
|
sched_output = SimpleNamespace(
|
||||||
|
finished_req_ids={"r1"},
|
||||||
|
preempted_req_ids={"r2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(type(runner).__bases__[0], "finish_requests", return_value=None):
|
||||||
|
runner.finish_requests(sched_output)
|
||||||
|
|
||||||
|
assert mock_state.remove_request.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinker_stage_needs_capture_tensor_unwrap():
|
||||||
|
assert _needs_capture_tensor_unwrap(SimpleNamespace(model_stage="thinker"))
|
||||||
|
assert not _needs_capture_tensor_unwrap(SimpleNamespace(model_stage="talker"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_model_unwraps_tuple_outputs():
|
||||||
|
runner = object.__new__(OmniGPUModelRunner)
|
||||||
|
hidden = torch.ones(1, 2)
|
||||||
|
|
||||||
|
def original_forward():
|
||||||
|
return hidden, {"layers": {}}
|
||||||
|
|
||||||
|
runner.model = SimpleNamespace(forward=original_forward)
|
||||||
|
runner._model_returns_tuple = True
|
||||||
|
runner._exclude_full_graph = False
|
||||||
|
|
||||||
|
def capture_model(_self):
|
||||||
|
assert torch.equal(runner.model.forward(), hidden)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
with patch.object(type(runner).__bases__[0], "capture_model", capture_model):
|
||||||
|
assert runner.capture_model() == 3
|
||||||
|
|
||||||
|
assert runner.model.forward is original_forward
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_model_unwraps_omni_outputs():
|
||||||
|
runner = object.__new__(OmniGPUModelRunner)
|
||||||
|
hidden = torch.ones(1, 2)
|
||||||
|
|
||||||
|
def original_forward():
|
||||||
|
return OmniOutput(text_hidden_states=hidden, multimodal_outputs={})
|
||||||
|
|
||||||
|
runner.model = SimpleNamespace(forward=original_forward)
|
||||||
|
runner._model_returns_tuple = True
|
||||||
|
runner._exclude_full_graph = False
|
||||||
|
|
||||||
|
def capture_model(_self):
|
||||||
|
assert torch.equal(runner.model.forward(), hidden)
|
||||||
|
return 5
|
||||||
|
|
||||||
|
with patch.object(type(runner).__bases__[0], "capture_model", capture_model):
|
||||||
|
assert runner.capture_model() == 5
|
||||||
|
|
||||||
|
assert runner.model.forward is original_forward
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""Unit tests for OmniModelState core methods and plugin dispatch."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.engine.serialization import serialize_additional_information
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||||
|
from vllm_omni.worker_v2.model_states.plugin import OmniModelStatePlugin
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Stubs
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyInputBatch:
|
||||||
|
def __init__(self, indices):
|
||||||
|
self.idx_mapping_np = indices
|
||||||
|
self.num_reqs = len(indices)
|
||||||
|
self.num_scheduled_tokens = [1] * len(indices)
|
||||||
|
self.query_start_loc_np = list(range(len(indices)))
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyReqState:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _SpyPlugin(OmniModelStatePlugin):
|
||||||
|
"""Plugin that records calls for verification."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.add_calls: list = []
|
||||||
|
self.remove_calls: list = []
|
||||||
|
self.prepare_calls: list = []
|
||||||
|
self.postprocess_calls: list = []
|
||||||
|
|
||||||
|
def on_add_request(self, req_index, new_req_data):
|
||||||
|
self.add_calls.append((req_index, new_req_data))
|
||||||
|
|
||||||
|
def on_remove_request(self, req_index):
|
||||||
|
self.remove_calls.append(req_index)
|
||||||
|
|
||||||
|
def prepare_extra_inputs(self, input_batch, req_states):
|
||||||
|
self.prepare_calls.append(True)
|
||||||
|
return {"plugin_key": "plugin_value"}
|
||||||
|
|
||||||
|
def postprocess(self, text_hidden, multimodal_outputs, input_batch, req_states):
|
||||||
|
self.postprocess_calls.append(True)
|
||||||
|
return text_hidden, multimodal_outputs
|
||||||
|
|
||||||
|
|
||||||
|
def _make_state(
|
||||||
|
max_num_reqs=4, has_preprocess=False, has_postprocess=False, have_multimodal_outputs=False, plugins=None
|
||||||
|
):
|
||||||
|
"""Create an OmniModelState without calling real __init__."""
|
||||||
|
state = object.__new__(OmniModelState)
|
||||||
|
|
||||||
|
# Minimal model mock
|
||||||
|
model = MagicMock()
|
||||||
|
model.has_preprocess = has_preprocess
|
||||||
|
model.has_postprocess = has_postprocess
|
||||||
|
model.have_multimodal_outputs = have_multimodal_outputs
|
||||||
|
model.preprocess_in_forward = False
|
||||||
|
model.get_omni_plugins = MagicMock(return_value=[])
|
||||||
|
state.model = model
|
||||||
|
|
||||||
|
# scheduler_config mock
|
||||||
|
state.scheduler_config = SimpleNamespace(max_num_seqs=max_num_reqs)
|
||||||
|
|
||||||
|
# Skip DefaultModelState.__init__ side effects
|
||||||
|
state.has_preprocess = has_preprocess
|
||||||
|
state.has_postprocess = has_postprocess
|
||||||
|
state.have_multimodal_outputs = have_multimodal_outputs
|
||||||
|
state.plugins = plugins or []
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.model_states.intermediate_buffer import (
|
||||||
|
OmniIntermediateBuffer,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.intermediate_buffer = OmniIntermediateBuffer(max_num_reqs)
|
||||||
|
state._static_inputs_embeds = None
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _make_new_req_data(req_id="r1"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
req_id=req_id,
|
||||||
|
mm_features=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# add_request / remove_request
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_request_populates_buffer():
|
||||||
|
state = _make_state()
|
||||||
|
req = _make_new_req_data("r1")
|
||||||
|
|
||||||
|
with patch.object(type(state).__bases__[0], "add_request", return_value=None):
|
||||||
|
state.add_request(0, req)
|
||||||
|
|
||||||
|
assert state.intermediate_buffer.buffers[0]["req_id"] == "r1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_request_dispatches_to_plugins():
|
||||||
|
plugin = _SpyPlugin()
|
||||||
|
state = _make_state(plugins=[plugin])
|
||||||
|
req = _make_new_req_data("r1")
|
||||||
|
|
||||||
|
with patch.object(type(state).__bases__[0], "add_request", return_value=None):
|
||||||
|
state.add_request(0, req)
|
||||||
|
|
||||||
|
assert len(plugin.add_calls) == 1
|
||||||
|
assert plugin.add_calls[0][0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_request_unflattens_serialized_additional_information():
|
||||||
|
state = _make_state()
|
||||||
|
hidden = torch.randn(2, 4)
|
||||||
|
embeds = torch.randn(3, 4)
|
||||||
|
req = SimpleNamespace(
|
||||||
|
req_id="r1",
|
||||||
|
mm_features=[],
|
||||||
|
additional_information=serialize_additional_information(
|
||||||
|
{
|
||||||
|
"hidden_states": {"output": hidden},
|
||||||
|
"embed": {"prefill": embeds},
|
||||||
|
"ids": {"prompt": [1, 2, 3], "output": [4]},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(type(state).__bases__[0], "add_request", return_value=None):
|
||||||
|
state.add_request(0, req)
|
||||||
|
|
||||||
|
buf = state.intermediate_buffer.buffers[0]
|
||||||
|
assert torch.equal(buf["hidden_states"]["output"], hidden)
|
||||||
|
assert torch.equal(buf["embed"]["prefill"], embeds)
|
||||||
|
assert buf["ids"]["prompt"] == [1, 2, 3]
|
||||||
|
assert buf["ids"]["output"] == [4]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_request_clears_buffer():
|
||||||
|
state = _make_state()
|
||||||
|
req = _make_new_req_data("r1")
|
||||||
|
|
||||||
|
with patch.object(type(state).__bases__[0], "add_request", return_value=None):
|
||||||
|
state.add_request(0, req)
|
||||||
|
|
||||||
|
state.remove_request(0)
|
||||||
|
assert state.intermediate_buffer.buffers[0] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_request_dispatches_to_plugins():
|
||||||
|
plugin = _SpyPlugin()
|
||||||
|
state = _make_state(plugins=[plugin])
|
||||||
|
state.remove_request(2)
|
||||||
|
assert plugin.remove_calls == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_intermediate_buffer_update_merges_nested_and_tuple_keys():
|
||||||
|
state = _make_state()
|
||||||
|
trailing = torch.randn(2, 4)
|
||||||
|
last = torch.randn(4)
|
||||||
|
codes = torch.ones(1, 16, dtype=torch.long)
|
||||||
|
state.intermediate_buffer.buffers[0] = {
|
||||||
|
"req_id": "r1",
|
||||||
|
"hidden_states": {"trailing_text": trailing},
|
||||||
|
}
|
||||||
|
|
||||||
|
state.intermediate_buffer.update(
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
("codes", "audio"): codes,
|
||||||
|
"hidden_states": {"last": last},
|
||||||
|
},
|
||||||
|
{("codes", "audio"), ("hidden_states", "last")},
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = state.intermediate_buffer.buffers[0]
|
||||||
|
assert ("codes", "audio") not in buf
|
||||||
|
assert torch.equal(buf["codes"]["audio"], codes)
|
||||||
|
assert torch.equal(buf["hidden_states"]["trailing_text"], trailing)
|
||||||
|
assert torch.equal(buf["hidden_states"]["last"], last)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# prepare_inputs
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_inputs_injects_buffer_and_runtime_info():
|
||||||
|
state = _make_state()
|
||||||
|
req = _make_new_req_data("r1")
|
||||||
|
|
||||||
|
with patch.object(type(state).__bases__[0], "add_request", return_value=None):
|
||||||
|
state.add_request(0, req)
|
||||||
|
|
||||||
|
batch = _DummyInputBatch([0])
|
||||||
|
|
||||||
|
with patch.object(type(state).__bases__[0], "prepare_inputs", return_value={}):
|
||||||
|
result = state.prepare_inputs(batch, _DummyReqState())
|
||||||
|
|
||||||
|
assert "model_intermediate_buffer" in result
|
||||||
|
assert "runtime_additional_information" in result
|
||||||
|
assert len(result["model_intermediate_buffer"]) == 1
|
||||||
|
assert result["model_intermediate_buffer"][0]["req_id"] == "r1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_inputs_merges_plugin_extra():
|
||||||
|
plugin = _SpyPlugin()
|
||||||
|
state = _make_state(plugins=[plugin])
|
||||||
|
|
||||||
|
batch = _DummyInputBatch([])
|
||||||
|
|
||||||
|
with patch.object(type(state).__bases__[0], "prepare_inputs", return_value={"base": True}):
|
||||||
|
result = state.prepare_inputs(batch, _DummyReqState())
|
||||||
|
|
||||||
|
assert result["base"] is True
|
||||||
|
assert result["plugin_key"] == "plugin_value"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# prepare_dummy_inputs
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_dummy_inputs_has_buffer_keys():
|
||||||
|
state = _make_state()
|
||||||
|
with patch.object(type(state).__bases__[0], "prepare_dummy_inputs", return_value={}):
|
||||||
|
result = state.prepare_dummy_inputs(num_reqs=2, num_tokens=16)
|
||||||
|
|
||||||
|
assert "model_intermediate_buffer" in result
|
||||||
|
assert len(result["model_intermediate_buffer"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# postprocess_model_output
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_postprocess_omni_output_with_multimodal():
|
||||||
|
state = _make_state(have_multimodal_outputs=True)
|
||||||
|
hidden = torch.randn(4, 8)
|
||||||
|
mm = {"audio": torch.randn(4, 2)}
|
||||||
|
omni = OmniOutput(text_hidden_states=hidden, multimodal_outputs=mm)
|
||||||
|
|
||||||
|
batch = _DummyInputBatch([0])
|
||||||
|
th, mo = state.postprocess_model_output(omni, batch, _DummyReqState())
|
||||||
|
assert th is hidden
|
||||||
|
assert mo is mm
|
||||||
|
|
||||||
|
|
||||||
|
def test_postprocess_tuple_output():
|
||||||
|
state = _make_state(have_multimodal_outputs=False)
|
||||||
|
del state.model.make_omni_output
|
||||||
|
hidden = torch.randn(4, 8)
|
||||||
|
|
||||||
|
batch = _DummyInputBatch([0])
|
||||||
|
th, mo = state.postprocess_model_output((hidden, {}), batch, _DummyReqState())
|
||||||
|
assert th is hidden
|
||||||
|
assert mo == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_postprocess_raw_tensor():
|
||||||
|
state = _make_state()
|
||||||
|
del state.model.make_omni_output
|
||||||
|
hidden = torch.randn(4, 8)
|
||||||
|
|
||||||
|
batch = _DummyInputBatch([0])
|
||||||
|
th, mo = state.postprocess_model_output(hidden, batch, _DummyReqState())
|
||||||
|
assert th is hidden
|
||||||
|
assert mo == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_postprocess_calls_make_omni_output_when_not_omni():
|
||||||
|
"""When model output is not OmniOutput but model has make_omni_output, it should be called."""
|
||||||
|
state = _make_state(have_multimodal_outputs=True)
|
||||||
|
hidden = torch.randn(4, 8)
|
||||||
|
mm = {"audio": torch.randn(4, 2)}
|
||||||
|
expected_omni = OmniOutput(text_hidden_states=hidden, multimodal_outputs=mm)
|
||||||
|
|
||||||
|
state.model.make_omni_output = MagicMock(return_value=expected_omni)
|
||||||
|
|
||||||
|
batch = _DummyInputBatch([0])
|
||||||
|
th, mo = state.postprocess_model_output((hidden, {}), batch, _DummyReqState())
|
||||||
|
state.model.make_omni_output.assert_called_once()
|
||||||
|
assert th is hidden
|
||||||
|
|
||||||
|
|
||||||
|
def test_postprocess_dispatches_to_plugins():
|
||||||
|
plugin = _SpyPlugin()
|
||||||
|
state = _make_state(plugins=[plugin])
|
||||||
|
hidden = torch.randn(4, 8)
|
||||||
|
|
||||||
|
batch = _DummyInputBatch([0])
|
||||||
|
state.postprocess_model_output(hidden, batch, _DummyReqState())
|
||||||
|
assert len(plugin.postprocess_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_postprocess_does_not_duplicate_hidden_states_kwarg():
|
||||||
|
state = _make_state(has_postprocess=True)
|
||||||
|
state.model.gpu_resident_buffer_keys = set()
|
||||||
|
seen = {}
|
||||||
|
trailing = torch.ones(1, 2)
|
||||||
|
|
||||||
|
def postprocess(hidden_states, **info):
|
||||||
|
seen["hidden_states"] = hidden_states
|
||||||
|
seen["info"] = info
|
||||||
|
return {"hidden_states": {"last": hidden_states[-1].detach()}}
|
||||||
|
|
||||||
|
state.model.postprocess = postprocess
|
||||||
|
state.intermediate_buffer.buffers[0] = {
|
||||||
|
"req_id": "r1",
|
||||||
|
"hidden_states": {"trailing_text": trailing},
|
||||||
|
"meta": {"codec_streaming": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
hidden = torch.randn(1, 2)
|
||||||
|
state.run_postprocess(hidden, _DummyInputBatch([0]))
|
||||||
|
|
||||||
|
assert torch.equal(seen["hidden_states"], hidden)
|
||||||
|
assert "hidden_states" not in seen["info"]
|
||||||
|
assert seen["info"]["meta"] == {"codec_streaming": True}
|
||||||
|
assert torch.equal(state.intermediate_buffer.buffers[0]["hidden_states"]["trailing_text"], trailing)
|
||||||
|
assert torch.equal(state.intermediate_buffer.buffers[0]["hidden_states"]["last"], hidden[-1])
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Unit tests for OmniModelStatePlugin interface correctness."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm_omni.worker_v2.model_states.plugin import OmniModelStatePlugin
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||||
|
|
||||||
|
|
||||||
|
class _ConcretePlugin(OmniModelStatePlugin):
|
||||||
|
"""Minimal concrete plugin for testing the ABC."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.added: list[tuple[int, Any]] = []
|
||||||
|
self.removed: list[int] = []
|
||||||
|
|
||||||
|
def on_add_request(self, req_index, new_req_data):
|
||||||
|
self.added.append((req_index, new_req_data))
|
||||||
|
|
||||||
|
def on_remove_request(self, req_index):
|
||||||
|
self.removed.append(req_index)
|
||||||
|
|
||||||
|
def postprocess(self, text_hidden, multimodal_outputs, input_batch, req_states):
|
||||||
|
return text_hidden, multimodal_outputs
|
||||||
|
|
||||||
|
|
||||||
|
def test_concrete_plugin_instantiates():
|
||||||
|
plugin = _ConcretePlugin()
|
||||||
|
assert isinstance(plugin, OmniModelStatePlugin)
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_add_request_called():
|
||||||
|
plugin = _ConcretePlugin()
|
||||||
|
plugin.on_add_request(0, SimpleNamespace(req_id="r1"))
|
||||||
|
assert len(plugin.added) == 1
|
||||||
|
assert plugin.added[0][0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_remove_request_called():
|
||||||
|
plugin = _ConcretePlugin()
|
||||||
|
plugin.on_remove_request(3)
|
||||||
|
assert plugin.removed == [3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_extra_inputs_default_empty():
|
||||||
|
plugin = _ConcretePlugin()
|
||||||
|
result = plugin.prepare_extra_inputs(SimpleNamespace(), SimpleNamespace())
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dummy_run_default_noop():
|
||||||
|
plugin = _ConcretePlugin()
|
||||||
|
plugin.dummy_run(None, 128)
|
||||||
|
|
||||||
|
|
||||||
|
def test_postprocess_returns_inputs():
|
||||||
|
plugin = _ConcretePlugin()
|
||||||
|
hidden = torch.randn(4, 8)
|
||||||
|
mm = {"audio": torch.randn(4, 2)}
|
||||||
|
out_h, out_mm = plugin.postprocess(hidden, mm, None, None)
|
||||||
|
assert out_h is hidden
|
||||||
|
assert out_mm is mm
|
||||||
|
|
||||||
|
|
||||||
|
def test_abc_cannot_instantiate_directly():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
OmniModelStatePlugin()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def make_filtered_namedtuple(cls, known_extra_fields: set[str] | None = None, **kwargs: Any):
|
||||||
|
fields = set(cls._fields)
|
||||||
|
unknown = set(kwargs) - fields - (known_extra_fields or set())
|
||||||
|
values = {key: value for key, value in kwargs.items() if key in fields}
|
||||||
|
return cls(**values), unknown
|
||||||
|
|
||||||
|
|
||||||
|
def make_filtered_call(cls, known_extra_fields: set[str] | None = None, **kwargs: Any):
|
||||||
|
params = set(inspect.signature(cls).parameters)
|
||||||
|
unknown = set(kwargs) - params - (known_extra_fields or set())
|
||||||
|
values = {key: value for key, value in kwargs.items() if key in params}
|
||||||
|
return cls(**values), unknown
|
||||||
@@ -19,6 +19,7 @@ from vllm.v1.outputs import ModelRunnerOutput
|
|||||||
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
|
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
|
||||||
from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
||||||
|
|
||||||
|
from vllm_omni.compat import make_filtered_call
|
||||||
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
||||||
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
||||||
OmniSchedulingCoordinator,
|
OmniSchedulingCoordinator,
|
||||||
@@ -28,12 +29,40 @@ from vllm_omni.core.sched.utils import omni_routed_experts_for_request
|
|||||||
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
|
from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import (
|
||||||
OmniChunkTransferAdapter,
|
OmniChunkTransferAdapter,
|
||||||
)
|
)
|
||||||
from vllm_omni.engine import OmniEngineCoreOutput
|
from vllm_omni.engine.serialization import (
|
||||||
from vllm_omni.engine.serialization import deserialize_additional_information
|
deserialize_additional_information,
|
||||||
|
serialize_additional_information,
|
||||||
|
)
|
||||||
from vllm_omni.outputs import OmniConnectorOutput
|
from vllm_omni.outputs import OmniConnectorOutput
|
||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
_KNOWN_ENGINE_CORE_OUTPUT_COMPAT_FIELDS = {
|
||||||
|
"num_cached_tokens",
|
||||||
|
"num_external_computed_tokens",
|
||||||
|
"is_segment_finished",
|
||||||
|
"new_prompt_len_snapshot",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine_core_output(**kwargs):
|
||||||
|
output, unknown = make_filtered_call(
|
||||||
|
EngineCoreOutput,
|
||||||
|
known_extra_fields=_KNOWN_ENGINE_CORE_OUTPUT_COMPAT_FIELDS,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
logger.warning("Unknown fields passed to EngineCoreOutput: %s", sorted(unknown))
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_num_cached_tokens(request) -> int:
|
||||||
|
return max(getattr(request, "num_cached_tokens", 0), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_num_external_computed_tokens(request) -> int:
|
||||||
|
return max(getattr(request, "num_external_computed_tokens", 0), 0)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KVCacheTransferData:
|
class KVCacheTransferData:
|
||||||
@@ -249,6 +278,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
|||||||
req_id=nr.req_id,
|
req_id=nr.req_id,
|
||||||
external_req_id=(getattr(request, "external_req_id", None) if request else None),
|
external_req_id=(getattr(request, "external_req_id", None) if request else None),
|
||||||
prompt_token_ids=nr.prompt_token_ids,
|
prompt_token_ids=nr.prompt_token_ids,
|
||||||
|
prefill_token_ids=getattr(nr, "prefill_token_ids", None),
|
||||||
mm_features=nr.mm_features,
|
mm_features=nr.mm_features,
|
||||||
sampling_params=nr.sampling_params,
|
sampling_params=nr.sampling_params,
|
||||||
pooling_params=nr.pooling_params,
|
pooling_params=nr.pooling_params,
|
||||||
@@ -328,7 +358,7 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
|||||||
req = self.requests.get(req_id)
|
req = self.requests.get(req_id)
|
||||||
if req is not None and not req.is_finished():
|
if req is not None and not req.is_finished():
|
||||||
outputs[req.client_index].append(
|
outputs[req.client_index].append(
|
||||||
OmniEngineCoreOutput(
|
_make_engine_core_output(
|
||||||
request_id=req_id,
|
request_id=req_id,
|
||||||
new_token_ids=[],
|
new_token_ids=[],
|
||||||
kv_transfer_params={"kv_ready": True},
|
kv_transfer_params={"kv_ready": True},
|
||||||
@@ -466,21 +496,33 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
|||||||
# Get prompt logprobs for this request.
|
# Get prompt logprobs for this request.
|
||||||
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
||||||
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
||||||
|
# vLLM 0.23 strictly decodes EngineCoreOutput.pooling_output as a
|
||||||
|
# torch.Tensor. MR V2 runners hand off a per-request dict here, so
|
||||||
|
# serialize it onto the bytes channel; StagePool._poll_stage_raw
|
||||||
|
# rehydrates it. Tensor pooling outputs ride pooling_output as before.
|
||||||
|
pooling_output_wire = pooler_output
|
||||||
|
pooling_output_payload = None
|
||||||
|
if isinstance(pooler_output, dict):
|
||||||
|
pooling_output_payload = serialize_additional_information(pooler_output)
|
||||||
|
pooling_output_wire = None
|
||||||
# Add EngineCoreOutput for this Request.
|
# Add EngineCoreOutput for this Request.
|
||||||
outputs[request.client_index].append(
|
outputs[request.client_index].append(
|
||||||
OmniEngineCoreOutput(
|
_make_engine_core_output(
|
||||||
request_id=req_id,
|
request_id=req_id,
|
||||||
new_token_ids=new_token_ids,
|
new_token_ids=new_token_ids,
|
||||||
finish_reason=finish_reason,
|
finish_reason=finish_reason,
|
||||||
new_logprobs=new_logprobs,
|
new_logprobs=new_logprobs,
|
||||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||||
pooling_output=pooler_output,
|
pooling_output=pooling_output_wire,
|
||||||
|
pooling_output_payload=pooling_output_payload,
|
||||||
multimodal_output=mm_output,
|
multimodal_output=mm_output,
|
||||||
stop_reason=request.stop_reason,
|
stop_reason=request.stop_reason,
|
||||||
events=request.take_events(),
|
events=request.take_events(),
|
||||||
prefill_stats=request.take_prefill_stats(),
|
prefill_stats=request.take_prefill_stats(),
|
||||||
kv_transfer_params=kv_transfer_params,
|
kv_transfer_params=kv_transfer_params,
|
||||||
trace_headers=request.trace_headers,
|
trace_headers=request.trace_headers,
|
||||||
|
num_cached_tokens=_get_request_num_cached_tokens(request),
|
||||||
|
num_external_computed_tokens=_get_request_num_external_computed_tokens(request),
|
||||||
routed_experts=routed_experts,
|
routed_experts=routed_experts,
|
||||||
num_nans_in_logits=request.num_nans_in_logits,
|
num_nans_in_logits=request.num_nans_in_logits,
|
||||||
is_segment_finished=is_segment_finished,
|
is_segment_finished=is_segment_finished,
|
||||||
@@ -507,12 +549,13 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
|||||||
self.finish_requests(failed_kv_load_req_ids, RequestStatus.FINISHED_ERROR)
|
self.finish_requests(failed_kv_load_req_ids, RequestStatus.FINISHED_ERROR)
|
||||||
for request in requests:
|
for request in requests:
|
||||||
outputs[request.client_index].append(
|
outputs[request.client_index].append(
|
||||||
OmniEngineCoreOutput(
|
_make_engine_core_output(
|
||||||
request_id=request.request_id,
|
request_id=request.request_id,
|
||||||
new_token_ids=[],
|
new_token_ids=[],
|
||||||
finish_reason=request.get_finished_reason(),
|
finish_reason=request.get_finished_reason(),
|
||||||
events=request.take_events(),
|
events=request.take_events(),
|
||||||
trace_headers=request.trace_headers,
|
trace_headers=request.trace_headers,
|
||||||
|
num_cached_tokens=_get_request_num_cached_tokens(request),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.chunk_transfer_adapter is not None:
|
if self.chunk_transfer_adapter is not None:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -23,6 +24,7 @@ from vllm.v1.metrics.perf import PerfStats
|
|||||||
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
|
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
|
||||||
from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
from vllm.v1.spec_decode.metrics import SpecDecodingStats
|
||||||
|
|
||||||
|
from vllm_omni.compat import make_filtered_call
|
||||||
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
|
||||||
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
from vllm_omni.core.sched.omni_scheduling_coordinator import (
|
||||||
OmniSchedulingCoordinator,
|
OmniSchedulingCoordinator,
|
||||||
@@ -34,10 +36,38 @@ from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapt
|
|||||||
OmniChunkTransferAdapter,
|
OmniChunkTransferAdapter,
|
||||||
)
|
)
|
||||||
from vllm_omni.engine import OmniEngineCoreOutput
|
from vllm_omni.engine import OmniEngineCoreOutput
|
||||||
|
from vllm_omni.engine.serialization import serialize_additional_information
|
||||||
from vllm_omni.outputs import OmniConnectorOutput, OmniModelRunnerOutput
|
from vllm_omni.outputs import OmniConnectorOutput, OmniModelRunnerOutput
|
||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
VLLM_OMNI_USE_V2_RUNNER = bool(int(os.environ.get("VLLM_OMNI_USE_V2_RUNNER", "0")))
|
||||||
|
|
||||||
|
_KNOWN_ENGINE_CORE_OUTPUT_COMPAT_FIELDS = {
|
||||||
|
"num_cached_tokens",
|
||||||
|
"num_external_computed_tokens",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine_core_output(**kwargs):
|
||||||
|
output, unknown = make_filtered_call(
|
||||||
|
EngineCoreOutput,
|
||||||
|
known_extra_fields=_KNOWN_ENGINE_CORE_OUTPUT_COMPAT_FIELDS,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
logger.warning("Unknown fields passed to EngineCoreOutput: %s", sorted(unknown))
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_num_cached_tokens(request) -> int:
|
||||||
|
return max(getattr(request, "num_cached_tokens", 0), 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_request_num_cached_tokens_if_present(request, value: int) -> None:
|
||||||
|
if hasattr(request, "num_cached_tokens"):
|
||||||
|
request.num_cached_tokens = value
|
||||||
|
|
||||||
|
|
||||||
class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -314,6 +344,7 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
|||||||
req_id=nr.req_id,
|
req_id=nr.req_id,
|
||||||
external_req_id=(getattr(request, "external_req_id", None) if request else None),
|
external_req_id=(getattr(request, "external_req_id", None) if request else None),
|
||||||
prompt_token_ids=nr.prompt_token_ids,
|
prompt_token_ids=nr.prompt_token_ids,
|
||||||
|
prefill_token_ids=nr.prompt_token_ids,
|
||||||
mm_features=nr.mm_features,
|
mm_features=nr.mm_features,
|
||||||
sampling_params=nr.sampling_params,
|
sampling_params=nr.sampling_params,
|
||||||
pooling_params=nr.pooling_params,
|
pooling_params=nr.pooling_params,
|
||||||
@@ -561,21 +592,36 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
|||||||
# Get prompt logprobs for this request.
|
# Get prompt logprobs for this request.
|
||||||
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
prompt_logprobs_tensors = prompt_logprobs_dict.get(req_id)
|
||||||
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
if new_token_ids or mm_output is not None or pooler_output is not None or kv_transfer_params or stopped:
|
||||||
|
num_cached = _get_request_num_cached_tokens(request)
|
||||||
|
if num_cached < 0:
|
||||||
|
logger.warning("Negative num_cached_tokens (%d) for request %s, clamping to 0", num_cached, req_id)
|
||||||
|
num_cached = 0
|
||||||
|
# vLLM 0.23 strictly decodes EngineCoreOutput.pooling_output as a
|
||||||
|
# torch.Tensor; a dict-shaped MR V2 payload rides the bytes channel
|
||||||
|
# instead (rehydrated in StagePool._poll_stage_raw). See OmniARScheduler.
|
||||||
|
pooling_output_wire = pooler_output
|
||||||
|
pooling_output_payload = None
|
||||||
|
if isinstance(pooler_output, dict):
|
||||||
|
pooling_output_payload = serialize_additional_information(pooler_output)
|
||||||
|
pooling_output_wire = None
|
||||||
# Add EngineCoreOutput for this Request.
|
# Add EngineCoreOutput for this Request.
|
||||||
outputs[request.client_index].append(
|
outputs[request.client_index].append(
|
||||||
OmniEngineCoreOutput(
|
_make_engine_core_output(
|
||||||
request_id=req_id,
|
request_id=req_id,
|
||||||
new_token_ids=new_token_ids,
|
new_token_ids=new_token_ids,
|
||||||
finish_reason=finish_reason,
|
finish_reason=finish_reason,
|
||||||
new_logprobs=new_logprobs,
|
new_logprobs=new_logprobs,
|
||||||
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
new_prompt_logprobs_tensors=prompt_logprobs_tensors,
|
||||||
pooling_output=pooler_output,
|
pooling_output=pooling_output_wire,
|
||||||
|
pooling_output_payload=pooling_output_payload,
|
||||||
multimodal_output=mm_output,
|
multimodal_output=mm_output,
|
||||||
stop_reason=request.stop_reason,
|
stop_reason=request.stop_reason,
|
||||||
events=request.take_events(),
|
events=request.take_events(),
|
||||||
prefill_stats=request.take_prefill_stats(),
|
prefill_stats=request.take_prefill_stats(),
|
||||||
kv_transfer_params=kv_transfer_params,
|
kv_transfer_params=kv_transfer_params,
|
||||||
trace_headers=request.trace_headers,
|
trace_headers=request.trace_headers,
|
||||||
|
num_cached_tokens=num_cached,
|
||||||
|
num_external_computed_tokens=getattr(request, "num_external_computed_tokens", 0),
|
||||||
routed_experts=routed_experts,
|
routed_experts=routed_experts,
|
||||||
num_nans_in_logits=request.num_nans_in_logits,
|
num_nans_in_logits=request.num_nans_in_logits,
|
||||||
)
|
)
|
||||||
@@ -627,13 +673,20 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler):
|
|||||||
requests = [self.requests[req_id] for req_id in failed_kv_load_req_ids]
|
requests = [self.requests[req_id] for req_id in failed_kv_load_req_ids]
|
||||||
self.finish_requests(failed_kv_load_req_ids, RequestStatus.FINISHED_ERROR)
|
self.finish_requests(failed_kv_load_req_ids, RequestStatus.FINISHED_ERROR)
|
||||||
for request in requests:
|
for request in requests:
|
||||||
|
num_cached = _get_request_num_cached_tokens(request)
|
||||||
|
if num_cached < 0:
|
||||||
|
logger.warning(
|
||||||
|
"Negative num_cached_tokens (%d) for request %s, clamping to 0", num_cached, request.request_id
|
||||||
|
)
|
||||||
|
num_cached = 0
|
||||||
outputs[request.client_index].append(
|
outputs[request.client_index].append(
|
||||||
OmniEngineCoreOutput(
|
_make_engine_core_output(
|
||||||
request_id=request.request_id,
|
request_id=request.request_id,
|
||||||
new_token_ids=[],
|
new_token_ids=[],
|
||||||
finish_reason=request.get_finished_reason(),
|
finish_reason=request.get_finished_reason(),
|
||||||
events=request.take_events(),
|
events=request.take_events(),
|
||||||
trace_headers=request.trace_headers,
|
trace_headers=request.trace_headers,
|
||||||
|
num_cached_tokens=num_cached,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.chunk_transfer_adapter is not None:
|
if self.chunk_transfer_adapter is not None:
|
||||||
|
|||||||
@@ -338,18 +338,31 @@ def _serialize_tensor(t: torch.Tensor) -> AdditionalInformationEntry:
|
|||||||
from vllm_omni.engine import AdditionalInformationEntry
|
from vllm_omni.engine import AdditionalInformationEntry
|
||||||
|
|
||||||
t_cpu = t.detach().to("cpu").contiguous()
|
t_cpu = t.detach().to("cpu").contiguous()
|
||||||
|
try:
|
||||||
|
tensor_data = t_cpu.numpy().tobytes()
|
||||||
|
except TypeError:
|
||||||
|
# numpy has no equivalent for some torch dtypes (bfloat16, float8_*).
|
||||||
|
# Fall back to a raw byte view; _deserialize_tensor reconstructs via torch.
|
||||||
|
tensor_data = t_cpu.view(torch.uint8).numpy().tobytes()
|
||||||
return AdditionalInformationEntry(
|
return AdditionalInformationEntry(
|
||||||
tensor_data=t_cpu.numpy().tobytes(),
|
tensor_data=tensor_data,
|
||||||
tensor_shape=list(t_cpu.shape),
|
tensor_shape=list(t_cpu.shape),
|
||||||
tensor_dtype=_dtype_to_name(t_cpu.dtype),
|
tensor_dtype=_dtype_to_name(t_cpu.dtype),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _deserialize_tensor(entry: AdditionalInformationEntry) -> torch.Tensor:
|
def _deserialize_tensor(entry: AdditionalInformationEntry) -> torch.Tensor:
|
||||||
dt = np.dtype(entry.tensor_dtype or "float32")
|
name = entry.tensor_dtype or "float32"
|
||||||
arr = np.frombuffer(entry.tensor_data, dtype=dt) # type: ignore[arg-type]
|
try:
|
||||||
arr = arr.reshape(entry.tensor_shape)
|
dt = np.dtype(name)
|
||||||
return torch.from_numpy(arr.copy())
|
arr = np.frombuffer(entry.tensor_data, dtype=dt) # type: ignore[arg-type]
|
||||||
|
return torch.from_numpy(arr.copy().reshape(entry.tensor_shape))
|
||||||
|
except TypeError:
|
||||||
|
# numpy can't represent this dtype (bfloat16, float8_*): reconstruct from
|
||||||
|
# the raw byte view written by _serialize_tensor.
|
||||||
|
torch_dtype = getattr(torch, name)
|
||||||
|
flat = torch.frombuffer(bytearray(entry.tensor_data), dtype=torch_dtype)
|
||||||
|
return flat.reshape(entry.tensor_shape).clone()
|
||||||
|
|
||||||
|
|
||||||
def serialize_payload(
|
def serialize_payload(
|
||||||
|
|||||||
@@ -121,6 +121,13 @@ class OmniEngineCoreOutput(EngineCoreOutput):
|
|||||||
# pooling_output is inherited from EngineCoreOutput as torch.Tensor | None
|
# pooling_output is inherited from EngineCoreOutput as torch.Tensor | None
|
||||||
# and retains its original vLLM semantics for pooling/embedding tasks.
|
# and retains its original vLLM semantics for pooling/embedding tasks.
|
||||||
multimodal_output: dict[str, torch.Tensor] | None = None
|
multimodal_output: dict[str, torch.Tensor] | None = None
|
||||||
|
# Bytes-serialized carrier for a dict-shaped pooling_output (MR V2 cross-stage
|
||||||
|
# hidden/codec handoff). vLLM 0.23 strictly decodes ``pooling_output`` as a
|
||||||
|
# ``torch.Tensor``, so a per-request dict payload cannot ride that field. The
|
||||||
|
# producer (OmniARScheduler) sets ``pooling_output=None`` and serializes the
|
||||||
|
# dict here; the consumer (StagePool._poll_stage_raw) rehydrates it back into
|
||||||
|
# ``pooling_output`` after decode. Tensor pooling outputs are unaffected.
|
||||||
|
pooling_output_payload: AdditionalInformationPayload | None = None
|
||||||
# Finished flag for streaming input segment
|
# Finished flag for streaming input segment
|
||||||
is_segment_finished: bool | None = False
|
is_segment_finished: bool | None = False
|
||||||
# Streaming update prompt length
|
# Streaming update prompt length
|
||||||
|
|||||||
@@ -1532,6 +1532,28 @@ class AsyncOmniEngine:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ── Release CuMem allocator memory pool ──────────────────────────────
|
||||||
|
# When enable_sleep_mode is in use, the CuMem (CUDA Virtual Memory
|
||||||
|
# Management) allocator holds model weights in a singleton memory pool
|
||||||
|
# that lives in the parent process. Killing the engine-core subprocess
|
||||||
|
# does NOT release this pool — the weights stay resident on the GPU
|
||||||
|
# and can cause CUDA OOM for subsequent engine instances (especially
|
||||||
|
# large models like BAGEL-7B-MoT whose weights alone consume ~134 GiB).
|
||||||
|
#
|
||||||
|
# Discard mode (level=2) is correct at shutdown: there is no benefit to
|
||||||
|
# keeping a CPU backup when the engine is being torn down.
|
||||||
|
try:
|
||||||
|
from vllm.device_allocator.cumem import CuMemAllocator, cumem_available
|
||||||
|
|
||||||
|
if cumem_available:
|
||||||
|
allocator = CuMemAllocator.get_instance()
|
||||||
|
# Sleep at level 2 discards all pool memory from the GPU
|
||||||
|
# without creating CPU backups — cheapest and fastest.
|
||||||
|
allocator.sleep()
|
||||||
|
logger.debug("[AsyncOmniEngine] Released CuMem memory pool during shutdown")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _try_shutdown(self, *args, **kwargs) -> None:
|
def _try_shutdown(self, *args, **kwargs) -> None:
|
||||||
try:
|
try:
|
||||||
self.shutdown()
|
self.shutdown()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from vllm_omni.engine.stage_client import (
|
|||||||
StagePoolDiffusionClient,
|
StagePoolDiffusionClient,
|
||||||
StagePoolLLMClient,
|
StagePoolLLMClient,
|
||||||
)
|
)
|
||||||
|
from vllm_omni.data_entry_keys import flatten_payload
|
||||||
|
from vllm_omni.engine.serialization import deserialize_additional_information
|
||||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||||
from vllm_omni.metrics import definitions as defs
|
from vllm_omni.metrics import definitions as defs
|
||||||
from vllm_omni.metrics.stats import StageRequestStats as StageRequestMetrics
|
from vllm_omni.metrics.stats import StageRequestStats as StageRequestMetrics
|
||||||
@@ -1020,8 +1022,30 @@ class StagePool:
|
|||||||
outputs = await client.get_output_async()
|
outputs = await client.get_output_async()
|
||||||
if not outputs.outputs:
|
if not outputs.outputs:
|
||||||
return None
|
return None
|
||||||
|
self._rehydrate_pooling_output_payloads(outputs)
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rehydrate_pooling_output_payloads(outputs: EngineCoreOutputs) -> None:
|
||||||
|
"""Restore dict-shaped pooling_output from its bytes carrier (MR V2).
|
||||||
|
|
||||||
|
vLLM 0.23 decodes EngineCoreOutput.pooling_output as a torch.Tensor, so MR
|
||||||
|
V2 runners ship the per-request dict handoff via ``pooling_output_payload``
|
||||||
|
with ``pooling_output=None``. Decode it back here so downstream stage-input
|
||||||
|
processors see ``pooling_output`` exactly as on vLLM <= 0.22.
|
||||||
|
"""
|
||||||
|
for eco in outputs.outputs:
|
||||||
|
payload = getattr(eco, "pooling_output_payload", None)
|
||||||
|
if payload is None:
|
||||||
|
continue
|
||||||
|
if getattr(eco, "pooling_output", None) is None:
|
||||||
|
# deserialize_* returns the nested OmniPayload form; the producer
|
||||||
|
# serialized a flat (dotted-key) pooler dict, and downstream
|
||||||
|
# consumers (e.g. talker2code2wav_full_payload) read flat keys like
|
||||||
|
# "codes.audio". Re-flatten to restore the exact on-wire shape.
|
||||||
|
eco.pooling_output = flatten_payload(deserialize_additional_information(payload))
|
||||||
|
eco.pooling_output_payload = None
|
||||||
|
|
||||||
async def process_llm_raw_outputs(
|
async def process_llm_raw_outputs(
|
||||||
self,
|
self,
|
||||||
replica_id: int,
|
replica_id: int,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from collections.abc import Sequence
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import huggingface_hub
|
import huggingface_hub
|
||||||
|
from vllm import envs
|
||||||
from vllm.logger import init_logger
|
from vllm.logger import init_logger
|
||||||
from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
|
from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
|
||||||
|
|
||||||
@@ -29,6 +30,10 @@ from vllm_omni.model_executor.model_loader.weight_utils import download_weights_
|
|||||||
from vllm_omni.outputs import OmniRequestOutput
|
from vllm_omni.outputs import OmniRequestOutput
|
||||||
from vllm_omni.utils.tracking_parser import TrackingNamespace
|
from vllm_omni.utils.tracking_parser import TrackingNamespace
|
||||||
|
|
||||||
|
_omni_use_v2 = os.environ.get("VLLM_OMNI_USE_V2_RUNNER", "0")
|
||||||
|
os.environ.setdefault("VLLM_USE_V2_MODEL_RUNNER", _omni_use_v2)
|
||||||
|
envs.VLLM_USE_V2_MODEL_RUNNER = _omni_use_v2 == "1"
|
||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ QWEN2_5_OMNI_PIPELINE = PipelineConfig(
|
|||||||
custom_process_next_stage_input_func=f"{_PROC}.talker2code2wav_full_payload",
|
custom_process_next_stage_input_func=f"{_PROC}.talker2code2wav_full_payload",
|
||||||
sampling_constraints={
|
sampling_constraints={
|
||||||
"detokenize": True,
|
"detokenize": True,
|
||||||
"stop_token_ids": [8294],
|
"stop_token_ids": [8292, 8294],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
StagePipelineConfig(
|
StagePipelineConfig(
|
||||||
|
|||||||
@@ -320,6 +320,12 @@ class Qwen3OmniMoeForConditionalGeneration(
|
|||||||
self,
|
self,
|
||||||
input_tokens: list[int],
|
input_tokens: list[int],
|
||||||
mm_features: list[MultiModalFeatureSpec] | None = None,
|
mm_features: list[MultiModalFeatureSpec] | None = None,
|
||||||
|
# V1 runner passes the full multimodal RoPE inputs explicitly, while
|
||||||
|
# vLLM 0.20 calls this method with only input_tokens/mm_features.
|
||||||
|
hf_config: object | None = None,
|
||||||
|
image_grid_thw: list[list[int]] | torch.Tensor | None = None,
|
||||||
|
video_grid_thw: list[list[int]] | torch.Tensor | None = None,
|
||||||
|
second_per_grid_ts: list[float] | None = None,
|
||||||
**kwargs: object,
|
**kwargs: object,
|
||||||
) -> tuple[torch.Tensor, int]:
|
) -> tuple[torch.Tensor, int]:
|
||||||
if self.model_stage == "thinker":
|
if self.model_stage == "thinker":
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from typing import Any, cast
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
import torch.nn.functional as F
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from transformers import PretrainedConfig
|
from transformers import PretrainedConfig
|
||||||
from transformers import __version__ as TRANSFORMERS_VERSION
|
from transformers import __version__ as TRANSFORMERS_VERSION
|
||||||
@@ -163,66 +162,42 @@ class Qwen3Omni_VisionTransformer(_Qwen3Omni_VisionTransformer):
|
|||||||
rotary_pos_emb_cos, rotary_pos_emb_sin = self.rot_pos_emb(grid_thw)
|
rotary_pos_emb_cos, rotary_pos_emb_sin = self.rot_pos_emb(grid_thw)
|
||||||
|
|
||||||
if isinstance(grid_thw, torch.Tensor):
|
if isinstance(grid_thw, torch.Tensor):
|
||||||
grid_thw_tensor = grid_thw.to(self.device)
|
grid_thw_np = grid_thw.cpu().numpy().astype(np.int32)
|
||||||
else:
|
else:
|
||||||
grid_thw_tensor = torch.as_tensor(grid_thw, dtype=torch.int32, device=self.device)
|
grid_thw_np = np.array(grid_thw, dtype=np.int32)
|
||||||
|
|
||||||
try:
|
cu_seqlens = np.repeat(grid_thw_np[:, 1] * grid_thw_np[:, 2], grid_thw_np[:, 0]).cumsum(axis=0, dtype=np.int32)
|
||||||
cu_seqlens = torch.repeat_interleave(
|
cu_seqlens = np.concatenate([np.zeros(1, dtype=np.int32), cu_seqlens])
|
||||||
grid_thw_tensor[:, 1] * grid_thw_tensor[:, 2],
|
|
||||||
grid_thw_tensor[:, 0],
|
|
||||||
).cumsum(
|
|
||||||
dim=0,
|
|
||||||
dtype=grid_thw_tensor.dtype if torch.jit.is_tracing() else torch.int32,
|
|
||||||
)
|
|
||||||
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
|
|
||||||
except RuntimeError:
|
|
||||||
logger.warning(
|
|
||||||
"torch.repeat_interleave not executable, switching to vectorized searchsorted implementation."
|
|
||||||
)
|
|
||||||
repeat_counts = grid_thw_tensor[:, 0]
|
|
||||||
values = grid_thw_tensor[:, 1] * grid_thw_tensor[:, 2]
|
|
||||||
repeat_cumsum = repeat_counts.cumsum(0)
|
|
||||||
total_items = repeat_cumsum[-1].item()
|
|
||||||
indices = torch.searchsorted(
|
|
||||||
repeat_cumsum,
|
|
||||||
torch.arange(total_items, device=grid_thw_tensor.device),
|
|
||||||
right=True,
|
|
||||||
)
|
|
||||||
cu_seqlens = values[indices].cumsum(
|
|
||||||
dim=0,
|
|
||||||
dtype=grid_thw_tensor.dtype if torch.jit.is_tracing() else torch.int32,
|
|
||||||
)
|
|
||||||
cu_seqlens = F.pad(cu_seqlens, (1, 0), value=0)
|
|
||||||
|
|
||||||
hidden_states = hidden_states.unsqueeze(1)
|
sequence_lengths = MMEncoderAttention.maybe_compute_seq_lens(self.attn_backend, cu_seqlens, self.device)
|
||||||
rotary_pos_emb_cos = rotary_pos_emb_cos.to(hidden_states.device)
|
max_seqlen = torch.tensor(
|
||||||
rotary_pos_emb_sin = rotary_pos_emb_sin.to(hidden_states.device)
|
MMEncoderAttention.compute_max_seqlen(self.attn_backend, cu_seqlens),
|
||||||
max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
|
dtype=torch.int32,
|
||||||
|
device=self.device,
|
||||||
grid_thw_np = grid_thw_tensor.cpu().numpy().astype(np.int32)
|
|
||||||
cu_seqlens_np = np.repeat(grid_thw_np[:, 1] * grid_thw_np[:, 2], grid_thw_np[:, 0]).cumsum(
|
|
||||||
axis=0, dtype=np.int32
|
|
||||||
)
|
)
|
||||||
cu_seqlens_np = np.concatenate([np.zeros(1, dtype=np.int32), cu_seqlens_np])
|
cu_seqlens = MMEncoderAttention.maybe_recompute_cu_seqlens(
|
||||||
sequence_lengths = MMEncoderAttention.maybe_compute_seq_lens(
|
|
||||||
self.attn_backend,
|
self.attn_backend,
|
||||||
cu_seqlens_np,
|
cu_seqlens,
|
||||||
|
self.hidden_size,
|
||||||
|
self.tp_size,
|
||||||
self.device,
|
self.device,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hidden_states = hidden_states.unsqueeze(1)
|
||||||
|
|
||||||
hidden_states_list = []
|
hidden_states_list = []
|
||||||
deepstack_visual_indexes = self.deepstack_visual_indexes
|
deepstack_visual_indexes = self.deepstack_visual_indexes
|
||||||
|
|
||||||
for layer_num, blk in enumerate(self.blocks):
|
for layer_num, blk in enumerate(self.blocks):
|
||||||
hidden_states = blk(
|
hidden_states = hidden_states + blk.attn(
|
||||||
hidden_states,
|
blk.norm1(hidden_states),
|
||||||
cu_seqlens=cu_seqlens,
|
cu_seqlens=cu_seqlens,
|
||||||
rotary_pos_emb_cos=rotary_pos_emb_cos,
|
rotary_pos_emb_cos=rotary_pos_emb_cos,
|
||||||
rotary_pos_emb_sin=rotary_pos_emb_sin,
|
rotary_pos_emb_sin=rotary_pos_emb_sin,
|
||||||
max_seqlen=max_seqlen,
|
max_seqlen=max_seqlen,
|
||||||
sequence_lengths=sequence_lengths,
|
sequence_lengths=sequence_lengths,
|
||||||
)
|
)
|
||||||
|
hidden_states = hidden_states + blk.mlp(blk.norm2(hidden_states))
|
||||||
|
|
||||||
if deepstack_visual_indexes is not None and layer_num in deepstack_visual_indexes:
|
if deepstack_visual_indexes is not None and layer_num in deepstack_visual_indexes:
|
||||||
hidden_states_list.append(hidden_states)
|
hidden_states_list.append(hidden_states)
|
||||||
@@ -784,13 +759,19 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
|
|||||||
mm_item_counts,
|
mm_item_counts,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if use_audio_in_video and "audio" in mm_prompt_updates:
|
if use_audio_in_video:
|
||||||
|
# When use_audio_in_video=True, audio is extracted from video and embedded
|
||||||
|
# in the video placeholder tokens. We should:
|
||||||
|
# 1. Filter out audio from prompt updates (audio has no separate placeholder)
|
||||||
|
# 2. Apply remaining updates (video, image, etc.)
|
||||||
|
# 3. Derive audio placeholders from video placeholders
|
||||||
filtered_updates = {k: v for k, v in mm_prompt_updates.items() if k != "audio"}
|
filtered_updates = {k: v for k, v in mm_prompt_updates.items() if k != "audio"}
|
||||||
prompt_ids, mm_placeholders = self._apply_prompt_updates(
|
prompt_ids, mm_placeholders = self._apply_prompt_updates(
|
||||||
prompt_ids,
|
prompt_ids,
|
||||||
filtered_updates,
|
filtered_updates,
|
||||||
)
|
)
|
||||||
mm_placeholders = self._derive_audio_from_video_placeholders(mm_placeholders, mm_prompt_updates)
|
# Derive audio placeholders from video placeholders
|
||||||
|
mm_placeholders = self._derive_audio_from_video_placeholders(mm_placeholders, mm_item_counts)
|
||||||
else:
|
else:
|
||||||
prompt_ids, mm_placeholders = self._apply_prompt_updates(
|
prompt_ids, mm_placeholders = self._apply_prompt_updates(
|
||||||
prompt_ids,
|
prompt_ids,
|
||||||
@@ -909,7 +890,7 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
|
|||||||
|
|
||||||
def get_replacement_qwen2_use_audio_in_video(item_idx: int):
|
def get_replacement_qwen2_use_audio_in_video(item_idx: int):
|
||||||
nonlocal audio_in_video_item_idx
|
nonlocal audio_in_video_item_idx
|
||||||
audio_num_features = audio_output_lengths[audio_in_video_item_idx]
|
audio_num_features = audio_output_lengths[audio_in_video_item_idx + item_idx]
|
||||||
video_grid_thw = out_mm_data["video_grid_thw"][item_idx]
|
video_grid_thw = out_mm_data["video_grid_thw"][item_idx]
|
||||||
|
|
||||||
audio_in_video_item_idx += 1
|
audio_in_video_item_idx += 1
|
||||||
@@ -955,17 +936,27 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
|
|||||||
def _derive_audio_from_video_placeholders(
|
def _derive_audio_from_video_placeholders(
|
||||||
self,
|
self,
|
||||||
placeholders: Mapping[str, list[PlaceholderFeaturesInfo]],
|
placeholders: Mapping[str, list[PlaceholderFeaturesInfo]],
|
||||||
mm_prompt_updates: MultiModalPromptUpdates,
|
mm_item_counts: Mapping[str, int],
|
||||||
) -> Mapping[str, list[PlaceholderFeaturesInfo]]:
|
) -> Mapping[str, list[PlaceholderFeaturesInfo]]:
|
||||||
"""
|
"""
|
||||||
Helper to derive audio placeholders from video placeholders when
|
Helper to derive audio placeholders from video placeholders when
|
||||||
use_audio_in_video=True.
|
use_audio_in_video=True.
|
||||||
|
|
||||||
|
In use_audio_in_video mode, audio is extracted from video and embedded
|
||||||
|
within the video placeholder tokens. This function creates audio placeholder
|
||||||
|
info by extracting the audio token positions from video placeholders.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
placeholders: Current placeholders (should contain "video")
|
||||||
|
mm_item_counts: Counts of multimodal items from mm_items.get_all_counts()
|
||||||
"""
|
"""
|
||||||
if "video" not in placeholders:
|
if "video" not in placeholders:
|
||||||
return placeholders
|
return placeholders
|
||||||
|
|
||||||
|
# Validate audio and video counts match
|
||||||
|
# In use_audio_in_video mode, audio count comes from mm_items (extracted from video)
|
||||||
num_videos = len(placeholders["video"])
|
num_videos = len(placeholders["video"])
|
||||||
num_audios = len(mm_prompt_updates.get("audio", []))
|
num_audios = mm_item_counts.get("audio", 0)
|
||||||
if num_audios != num_videos:
|
if num_audios != num_videos:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"use_audio_in_video requires equal number of audio and video items, got {num_audios=}, {num_videos=}"
|
f"use_audio_in_video requires equal number of audio and video items, got {num_audios=}, {num_videos=}"
|
||||||
@@ -1285,7 +1276,7 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# The result multimodal_embeddings is tuple of tensors, with each
|
# The result multimodal_embeddings is tuple of tensors, with each
|
||||||
# tensor corresponding to a multimodal data item (image or video).
|
# tensor correspoending to a multimodal data item (image or video).
|
||||||
multimodal_embeddings: tuple[torch.Tensor, ...] = ()
|
multimodal_embeddings: tuple[torch.Tensor, ...] = ()
|
||||||
|
|
||||||
# NOTE: It is important to iterate over the keys in this dictionary
|
# NOTE: It is important to iterate over the keys in this dictionary
|
||||||
@@ -1309,6 +1300,7 @@ class Qwen3OmniMoeThinkerForConditionalGeneration(
|
|||||||
multimodal_embeddings: MultiModalEmbeddings | None = None,
|
multimodal_embeddings: MultiModalEmbeddings | None = None,
|
||||||
*,
|
*,
|
||||||
is_multimodal: torch.Tensor | None = None,
|
is_multimodal: torch.Tensor | None = None,
|
||||||
|
handle_oov_mm_token: bool = False,
|
||||||
) -> torch.Tensor:
|
) -> torch.Tensor:
|
||||||
inputs_embeds = self._embed_text_input_ids(
|
inputs_embeds = self._embed_text_input_ids(
|
||||||
input_ids,
|
input_ids,
|
||||||
|
|||||||
@@ -536,6 +536,10 @@ class Qwen3TTSConfig(PretrainedConfig):
|
|||||||
if any("Code2Wav" in str(a) for a in archs):
|
if any("Code2Wav" in str(a) for a in archs):
|
||||||
if hasattr(config, "rope_parameters"):
|
if hasattr(config, "rope_parameters"):
|
||||||
delattr(config, "rope_parameters")
|
delattr(config, "rope_parameters")
|
||||||
|
# Code2Wav doesn't use position embeddings, but vLLM validates
|
||||||
|
# max_model_len <= max_position_embeddings. Set a large value
|
||||||
|
# to allow the pipeline.yaml max_model_len (65536) to pass validation.
|
||||||
|
config.max_position_embeddings = 131072
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -800,6 +800,14 @@ def thinker2talker_token_only(
|
|||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
|
|
||||||
|
def _eof_payload(transfer_manager: Any) -> OmniPayloadStruct:
|
||||||
|
"""Return an EOF marker payload when the request is finished with no codes."""
|
||||||
|
return {
|
||||||
|
"codes": {"audio": []},
|
||||||
|
"meta": {"left_context_size": 0, "finished": torch.tensor(True)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def talker2code2wav_async_chunk(
|
def talker2code2wav_async_chunk(
|
||||||
transfer_manager: Any,
|
transfer_manager: Any,
|
||||||
multimodal_output: OmniPayload | dict[str, Any],
|
multimodal_output: OmniPayload | dict[str, Any],
|
||||||
@@ -810,18 +818,28 @@ def talker2code2wav_async_chunk(
|
|||||||
Multimodal output version.
|
Multimodal output version.
|
||||||
"""
|
"""
|
||||||
if not isinstance(multimodal_output, Mapping):
|
if not isinstance(multimodal_output, Mapping):
|
||||||
|
if is_finished:
|
||||||
|
return _eof_payload(transfer_manager)
|
||||||
return None
|
return None
|
||||||
talker_codes = multimodal_output.get("codes", {})
|
talker_codes = multimodal_output.get("codes", {})
|
||||||
if not isinstance(talker_codes, dict):
|
if not isinstance(talker_codes, dict):
|
||||||
|
if is_finished:
|
||||||
|
return _eof_payload(transfer_manager)
|
||||||
return None
|
return None
|
||||||
code_predictor_codes = talker_codes.get("audio")
|
code_predictor_codes = talker_codes.get("audio")
|
||||||
if code_predictor_codes is None:
|
if code_predictor_codes is None:
|
||||||
|
if is_finished:
|
||||||
|
return _eof_payload(transfer_manager)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if code_predictor_codes.numel() == 0:
|
if code_predictor_codes.numel() == 0:
|
||||||
|
if is_finished:
|
||||||
|
return _eof_payload(transfer_manager)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not code_predictor_codes.any():
|
if not code_predictor_codes.any():
|
||||||
|
if is_finished:
|
||||||
|
return _eof_payload(transfer_manager)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
connector = getattr(transfer_manager, "connector", None)
|
connector = getattr(transfer_manager, "connector", None)
|
||||||
@@ -839,6 +857,8 @@ def talker2code2wav_async_chunk(
|
|||||||
first_codebook = int(code_predictor_codes[0, 0].item())
|
first_codebook = int(code_predictor_codes[0, 0].item())
|
||||||
if first_codebook in stop_token_ids:
|
if first_codebook in stop_token_ids:
|
||||||
logger.debug("skip stop-token codec frame: first_codebook=%s", first_codebook)
|
logger.debug("skip stop-token codec frame: first_codebook=%s", first_codebook)
|
||||||
|
if is_finished:
|
||||||
|
return _eof_payload(transfer_manager)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
request_id = request.external_req_id
|
request_id = request.external_req_id
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from vllm_omni.worker.mixins import OmniWorkerMixin
|
|||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
VLLM_OMNI_USE_V2_RUNNER = bool(int(os.environ.get("VLLM_OMNI_USE_V2_RUNNER", "0")))
|
||||||
|
|
||||||
|
|
||||||
class GPUARWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
class GPUARWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
||||||
"""GPU worker for autoregressive omni model stages.
|
"""GPU worker for autoregressive omni model stages.
|
||||||
@@ -59,7 +61,7 @@ class GPUARWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
|||||||
f"({visible_device_count})."
|
f"({visible_device_count})."
|
||||||
)
|
)
|
||||||
self.device = torch.device(f"cuda:{self.local_rank}")
|
self.device = torch.device(f"cuda:{self.local_rank}")
|
||||||
torch.accelerator.set_device_index(self.device)
|
current_platform.set_device(self.device)
|
||||||
|
|
||||||
current_platform.check_if_supports_dtype(self.model_config.dtype)
|
current_platform.check_if_supports_dtype(self.model_config.dtype)
|
||||||
|
|
||||||
@@ -94,18 +96,63 @@ class GPUARWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
|||||||
num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
|
num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
|
||||||
init_workspace_manager(self.device, num_ubatches)
|
init_workspace_manager(self.device, num_ubatches)
|
||||||
|
|
||||||
if self.use_v2_model_runner:
|
if VLLM_OMNI_USE_V2_RUNNER or self.use_v2_model_runner:
|
||||||
# OMNI: v2 model runner does not yet include omni hooks.
|
from vllm_omni.worker_v2.omni_ar_model_runner import (
|
||||||
logger.warning("OMNI GPUARWorker forces v1 model runner for omni hooks.")
|
OmniARModelRunner,
|
||||||
self.use_v2_model_runner = False
|
)
|
||||||
|
|
||||||
# Construct the model runner
|
logger.info("Using MR v2 OmniARModelRunner for omni AR stage.")
|
||||||
self.model_runner = GPUARModelRunner(self.vllm_config, self.device)
|
self.use_v2_model_runner = True
|
||||||
|
self.model_runner = OmniARModelRunner(self.vllm_config, self.device)
|
||||||
|
else:
|
||||||
|
self.model_runner = GPUARModelRunner(self.vllm_config, self.device)
|
||||||
|
|
||||||
if self.rank == 0:
|
if self.rank == 0:
|
||||||
# If usage stat is enabled, collect relevant info.
|
# If usage stat is enabled, collect relevant info.
|
||||||
report_usage_stats(self.vllm_config)
|
report_usage_stats(self.vllm_config)
|
||||||
|
|
||||||
|
@instrument(span_name="Compile/warmup")
|
||||||
|
def compile_or_warm_up_model(self) -> float:
|
||||||
|
"""Skip warmup_kernels for V2 Omni AR models.
|
||||||
|
|
||||||
|
Upstream ``compile_or_warm_up_model`` calls ``warmup_kernels()``
|
||||||
|
only for V2 model runners. ``warmup_kernels`` creates fake
|
||||||
|
requests with hardcoded block_ids that bypass KVCacheManager
|
||||||
|
and runs real (non-dummy) execute_model + sample_tokens. For
|
||||||
|
Omni AR models with preprocess, this pollutes model-internal
|
||||||
|
state and produces incorrect logits for subsequent real
|
||||||
|
requests (observed 30x more decode steps than V1).
|
||||||
|
|
||||||
|
This override runs the parent implementation but patches out
|
||||||
|
the ``warmup_kernels`` call, matching V1 behavior.
|
||||||
|
"""
|
||||||
|
if not self.use_v2_model_runner:
|
||||||
|
return super().compile_or_warm_up_model()
|
||||||
|
|
||||||
|
# Temporarily disable warmup_kernels during parent call.
|
||||||
|
# Must patch both the module attribute AND the local name in
|
||||||
|
# gpu_worker (from-import creates a local binding).
|
||||||
|
import vllm.v1.worker.gpu_worker as _gw
|
||||||
|
|
||||||
|
_orig = _gw.warmup_kernels
|
||||||
|
|
||||||
|
def _noop(*a, **kw):
|
||||||
|
return None
|
||||||
|
|
||||||
|
_gw.warmup_kernels = _noop
|
||||||
|
# Also patch the module where it was originally defined
|
||||||
|
import vllm.v1.worker.gpu.warmup as _wm
|
||||||
|
|
||||||
|
_orig_wm = _wm.warmup_kernels
|
||||||
|
_wm.warmup_kernels = _noop
|
||||||
|
try:
|
||||||
|
result = super().compile_or_warm_up_model()
|
||||||
|
finally:
|
||||||
|
_gw.warmup_kernels = _orig
|
||||||
|
_wm.warmup_kernels = _orig_wm
|
||||||
|
logger.info("compile_or_warm_up_model: skipped warmup_kernels for Omni AR V2")
|
||||||
|
return result
|
||||||
|
|
||||||
def handle_sleep_task(self, task: OmniSleepTask | dict) -> OmniACK:
|
def handle_sleep_task(self, task: OmniSleepTask | dict) -> OmniACK:
|
||||||
"""
|
"""
|
||||||
Explicitly handle sleep commands.
|
Explicitly handle sleep commands.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from vllm.v1.utils import report_usage_stats
|
|||||||
from vllm.v1.worker.gpu_worker import init_worker_distributed_environment
|
from vllm.v1.worker.gpu_worker import init_worker_distributed_environment
|
||||||
from vllm.v1.worker.workspace import init_workspace_manager
|
from vllm.v1.worker.workspace import init_workspace_manager
|
||||||
|
|
||||||
|
from vllm_omni.compat import make_filtered_namedtuple
|
||||||
from vllm_omni.worker.base import OmniGPUWorkerBase
|
from vllm_omni.worker.base import OmniGPUWorkerBase
|
||||||
from vllm_omni.worker.gpu_generation_model_runner import GPUGenerationModelRunner
|
from vllm_omni.worker.gpu_generation_model_runner import GPUGenerationModelRunner
|
||||||
from vllm_omni.worker.memory_utils import request_memory_tolerant
|
from vllm_omni.worker.memory_utils import request_memory_tolerant
|
||||||
@@ -18,6 +19,21 @@ from vllm_omni.worker.mixins import OmniWorkerMixin
|
|||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
VLLM_OMNI_USE_V2_RUNNER = bool(int(os.environ.get("VLLM_OMNI_USE_V2_RUNNER", "0")))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_compilation_times(language_model_time: float, **kwargs):
|
||||||
|
try:
|
||||||
|
from vllm.v1.worker.gpu_worker import CompilationTimes
|
||||||
|
except ImportError:
|
||||||
|
return language_model_time
|
||||||
|
|
||||||
|
values = {"language_model": language_model_time, "encoder": 0.0, **kwargs}
|
||||||
|
result, unknown = make_filtered_namedtuple(CompilationTimes, **values)
|
||||||
|
if unknown:
|
||||||
|
logger.warning("Unknown fields passed to CompilationTimes: %s", sorted(unknown))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class GPUGenerationWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
class GPUGenerationWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
||||||
"""GPU Worker for Generation model (non-autoregressive waveform generation).
|
"""GPU Worker for Generation model (non-autoregressive waveform generation).
|
||||||
@@ -28,7 +44,7 @@ class GPUGenerationWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
|||||||
|
|
||||||
@instrument(span_name="Init device")
|
@instrument(span_name="Init device")
|
||||||
def init_device(self):
|
def init_device(self):
|
||||||
if self.device_config.device_type in ("cuda", "musa"):
|
if self.device_config.device_type == "cuda":
|
||||||
# This env var set by Ray causes exceptions with graph building.
|
# This env var set by Ray causes exceptions with graph building.
|
||||||
os.environ.pop("NCCL_ASYNC_ERROR_HANDLING", None)
|
os.environ.pop("NCCL_ASYNC_ERROR_HANDLING", None)
|
||||||
parallel_config = self.parallel_config
|
parallel_config = self.parallel_config
|
||||||
@@ -51,7 +67,7 @@ class GPUGenerationWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
|||||||
assert self.local_rank < torch.accelerator.device_count(), (
|
assert self.local_rank < torch.accelerator.device_count(), (
|
||||||
f"DP adjusted local rank {self.local_rank} is out of bounds. "
|
f"DP adjusted local rank {self.local_rank} is out of bounds. "
|
||||||
)
|
)
|
||||||
visible_device_count = torch.accelerator.device_count()
|
visible_device_count = torch.accelerator.device_count() if torch.cuda.is_available() else 0
|
||||||
assert self.parallel_config.local_world_size <= visible_device_count, (
|
assert self.parallel_config.local_world_size <= visible_device_count, (
|
||||||
f"local_world_size ({self.parallel_config.local_world_size}) must "
|
f"local_world_size ({self.parallel_config.local_world_size}) must "
|
||||||
f"be less than or equal to the number of visible devices "
|
f"be less than or equal to the number of visible devices "
|
||||||
@@ -93,13 +109,28 @@ class GPUGenerationWorker(OmniWorkerMixin, OmniGPUWorkerBase):
|
|||||||
num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
|
num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1
|
||||||
init_workspace_manager(self.device, num_ubatches)
|
init_workspace_manager(self.device, num_ubatches)
|
||||||
|
|
||||||
if self.use_v2_model_runner:
|
if VLLM_OMNI_USE_V2_RUNNER or self.use_v2_model_runner:
|
||||||
# OMNI: v2 model runner does not yet include omni hooks.
|
from vllm_omni.worker_v2.omni_generation_model_runner import (
|
||||||
logger.warning("OMNI GPUGenerationWorker forces v1 model runner for omni hooks.")
|
OmniGenerationModelRunner,
|
||||||
self.use_v2_model_runner = False
|
)
|
||||||
|
|
||||||
self.model_runner = GPUGenerationModelRunner(self.vllm_config, self.device)
|
logger.info("Using MR v2 OmniGenerationModelRunner for generation stage.")
|
||||||
|
self.use_v2_model_runner = True
|
||||||
|
self.model_runner = OmniGenerationModelRunner(self.vllm_config, self.device)
|
||||||
|
else:
|
||||||
|
self.model_runner = GPUGenerationModelRunner(self.vllm_config, self.device)
|
||||||
|
|
||||||
if self.rank == 0:
|
if self.rank == 0:
|
||||||
# If usage stat is enabled, collect relevant info.
|
# If usage stat is enabled, collect relevant info.
|
||||||
report_usage_stats(self.vllm_config)
|
report_usage_stats(self.vllm_config)
|
||||||
|
|
||||||
|
@instrument(span_name="Compile/warmup")
|
||||||
|
def compile_or_warm_up_model(self) -> float:
|
||||||
|
"""Generation stages have no KV cache or sampler — skip warmup_kernels."""
|
||||||
|
if not self.use_v2_model_runner:
|
||||||
|
return super().compile_or_warm_up_model()
|
||||||
|
import time
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
self.model_runner.profile_run()
|
||||||
|
return _make_compilation_times(time.perf_counter() - start)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,33 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
|
||||||
|
"""Forward-call compatibility helpers for Omni MR V2 runners."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def add_forward_compat_kwargs(
|
||||||
|
model_inputs: dict[str, Any],
|
||||||
|
input_batch: Any,
|
||||||
|
sampler: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Add Omni model forward kwargs that V1 model runners provided.
|
||||||
|
|
||||||
|
Some Omni model stages inspect these kwargs inside ``forward`` rather
|
||||||
|
than only in the runner. Qwen2.5-Omni's talker, for example, rewrites
|
||||||
|
text-model pad ids in ``sampling_metadata`` to a talker-vocab pad id
|
||||||
|
before sampling codec tokens.
|
||||||
|
"""
|
||||||
|
model_inputs.setdefault(
|
||||||
|
"sampling_metadata",
|
||||||
|
getattr(input_batch, "sampling_metadata", None),
|
||||||
|
)
|
||||||
|
logits_index = getattr(
|
||||||
|
input_batch,
|
||||||
|
"logits_indices",
|
||||||
|
getattr(input_batch, "logits_index", None),
|
||||||
|
)
|
||||||
|
model_inputs.setdefault("logits_index", logits_index)
|
||||||
|
model_inputs.setdefault("sampler", sampler)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Omni-aware ``init_model_state`` factory.
|
||||||
|
|
||||||
|
Extends the upstream v2 factory with Omni architecture dispatch.
|
||||||
|
Non-Omni architectures fall through to the upstream ``init_model_state``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from vllm.config import VllmConfig
|
||||||
|
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
|
||||||
|
from vllm.v1.worker.gpu.model_states import (
|
||||||
|
init_model_state as _upstream_init_model_state,
|
||||||
|
)
|
||||||
|
from vllm.v1.worker.gpu.model_states.interface import ModelState
|
||||||
|
|
||||||
|
# Keep in sync: when adding a new Omni model architecture, add it here
|
||||||
|
# AND update the corresponding test in tests/worker_v2/test_init_model_state.py.
|
||||||
|
_OMNI_ARCHITECTURES: set[str] = {
|
||||||
|
"Qwen3OmniMoeForConditionalGeneration",
|
||||||
|
"Qwen2_5OmniForConditionalGeneration",
|
||||||
|
"MammothModa2ForConditionalGeneration",
|
||||||
|
"MiMoAudioForConditionalGeneration",
|
||||||
|
"MammothModa2ARForConditionalGeneration",
|
||||||
|
"Qwen3TTSTalkerForConditionalGeneration",
|
||||||
|
"Qwen3TTSCode2Wav",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def init_omni_model_state(
|
||||||
|
vllm_config: VllmConfig,
|
||||||
|
model: nn.Module,
|
||||||
|
encoder_cache: EncoderCache | None,
|
||||||
|
device: torch.device,
|
||||||
|
) -> ModelState:
|
||||||
|
"""Create the appropriate ``ModelState`` for *model*.
|
||||||
|
|
||||||
|
Returns an ``OmniModelState`` when the configured architecture is a
|
||||||
|
known Omni model; otherwise delegates to the upstream v2 factory.
|
||||||
|
"""
|
||||||
|
archs = set(vllm_config.model_config.architectures or [])
|
||||||
|
if archs & _OMNI_ARCHITECTURES:
|
||||||
|
from vllm_omni.worker_v2.model_states.omni_model_state import (
|
||||||
|
OmniModelState,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OmniModelState(vllm_config, model, encoder_cache, device)
|
||||||
|
return _upstream_init_model_state(vllm_config, model, encoder_cache, device)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""OmniIntermediateBuffer — per-request cross-stage state for Omni pipelines.
|
||||||
|
|
||||||
|
Uses ``req_index`` (not ``req_id``) for O(1) access, aligned with v2's
|
||||||
|
``RequestState`` slot management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from vllm.logger import init_logger
|
||||||
|
from vllm.v1.core.sched.output import NewRequestData
|
||||||
|
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_prompt_embeds(pe: Any) -> torch.Tensor | None:
|
||||||
|
"""Convert a prompt_embeds payload to a contiguous CPU tensor."""
|
||||||
|
if pe is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if isinstance(pe, torch.Tensor):
|
||||||
|
return pe.detach().cpu().contiguous()
|
||||||
|
data = getattr(pe, "data", None)
|
||||||
|
shape = getattr(pe, "shape", None)
|
||||||
|
if data is not None and shape is not None:
|
||||||
|
dt = np.dtype(getattr(pe, "dtype", "float32"))
|
||||||
|
arr = np.frombuffer(data, dtype=dt).reshape(shape)
|
||||||
|
return torch.from_numpy(arr.copy())
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to decode prompt_embeds payload")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_additional_information(payload: Any) -> dict[str, Any]:
|
||||||
|
"""Convert an additional_information payload to a plain dict."""
|
||||||
|
if payload is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
from vllm_omni.data_entry_keys import unflatten_payload
|
||||||
|
from vllm_omni.engine.serialization import deserialize_additional_information
|
||||||
|
|
||||||
|
info = deserialize_additional_information(payload)
|
||||||
|
if isinstance(info, dict) and any(isinstance(key, str) and "." in key for key in info):
|
||||||
|
info = unflatten_payload(info)
|
||||||
|
return info if isinstance(info, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to decode additional_information payload")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class OmniIntermediateBuffer:
|
||||||
|
"""Per-request intermediate state for multi-stage Omni pipelines.
|
||||||
|
|
||||||
|
Stores ``prompt_embeds``, ``additional_information``, ``mm_features``,
|
||||||
|
``req_id`` and any runtime updates written back by model postprocess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_num_reqs: int):
|
||||||
|
self.buffers: list[dict[str, Any]] = [{} for _ in range(max_num_reqs)]
|
||||||
|
|
||||||
|
def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
|
||||||
|
info: dict[str, Any] = {}
|
||||||
|
|
||||||
|
pe = getattr(new_req_data, "prompt_embeds", None)
|
||||||
|
if pe is not None:
|
||||||
|
pe_cpu = _resolve_prompt_embeds(pe)
|
||||||
|
if pe_cpu is not None:
|
||||||
|
info["prompt_embeds_cpu"] = pe_cpu
|
||||||
|
|
||||||
|
ai = getattr(new_req_data, "additional_information", None)
|
||||||
|
if ai is not None:
|
||||||
|
info.update(_resolve_additional_information(ai))
|
||||||
|
|
||||||
|
if new_req_data.mm_features:
|
||||||
|
info["mm_features"] = new_req_data.mm_features
|
||||||
|
|
||||||
|
info["req_id"] = new_req_data.req_id
|
||||||
|
self.buffers[req_index] = info
|
||||||
|
|
||||||
|
def remove_request(self, req_index: int) -> None:
|
||||||
|
self.buffers[req_index] = {}
|
||||||
|
|
||||||
|
def gather(self, input_batch: InputBatch) -> list[dict[str, Any]]:
|
||||||
|
"""Return buffer dicts in current batch order (via ``idx_mapping_np``)."""
|
||||||
|
return [self.buffers[idx] for idx in input_batch.idx_mapping_np]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_gpu_keys(gpu_resident_keys: set[Any] | None) -> tuple[set[Any], dict[Any, set[Any]]]:
|
||||||
|
top_level: set[Any] = set()
|
||||||
|
nested: dict[Any, set[Any]] = {}
|
||||||
|
for key in gpu_resident_keys or set():
|
||||||
|
if isinstance(key, tuple) and len(key) == 2:
|
||||||
|
nested.setdefault(key[0], set()).add(key[1])
|
||||||
|
else:
|
||||||
|
top_level.add(key)
|
||||||
|
return top_level, nested
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _store_value(dest: dict[Any, Any], key: Any, value: Any, gpu_keys: set[Any]) -> None:
|
||||||
|
if isinstance(value, torch.Tensor):
|
||||||
|
if key in gpu_keys:
|
||||||
|
dest[key] = value.detach().clone()
|
||||||
|
else:
|
||||||
|
dest[key] = value.detach().cpu().contiguous()
|
||||||
|
elif isinstance(value, list):
|
||||||
|
dest[key] = [
|
||||||
|
(item.detach().cpu().contiguous() if isinstance(item, torch.Tensor) else item) for item in value
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
dest[key] = value
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
req_index: int,
|
||||||
|
updates: dict[Any, Any],
|
||||||
|
gpu_resident_keys: set[Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Merge *updates* into the buffer at *req_index*.
|
||||||
|
|
||||||
|
Tensors are detached; those whose key is **not** in
|
||||||
|
*gpu_resident_keys* are moved to CPU.
|
||||||
|
"""
|
||||||
|
if not updates:
|
||||||
|
return
|
||||||
|
top_gpu_keys, nested_gpu_keys = self._split_gpu_keys(gpu_resident_keys)
|
||||||
|
existing = self.buffers[req_index]
|
||||||
|
for k, v in updates.items():
|
||||||
|
if isinstance(k, tuple) and len(k) == 2:
|
||||||
|
type_key, qualifier = k
|
||||||
|
existing_sub = existing.setdefault(type_key, {})
|
||||||
|
if not isinstance(existing_sub, dict):
|
||||||
|
existing_sub = {}
|
||||||
|
existing[type_key] = existing_sub
|
||||||
|
self._store_value(existing_sub, qualifier, v, nested_gpu_keys.get(type_key, set()))
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
existing_sub = existing.setdefault(k, {})
|
||||||
|
if not isinstance(existing_sub, dict):
|
||||||
|
existing_sub = {}
|
||||||
|
existing[k] = existing_sub
|
||||||
|
for qualifier, value in v.items():
|
||||||
|
self._store_value(existing_sub, qualifier, value, nested_gpu_keys.get(k, set()))
|
||||||
|
else:
|
||||||
|
self._store_value(existing, k, v, top_gpu_keys)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,513 @@
|
|||||||
|
"""OmniModelState — generic ModelState base for all Omni model stages.
|
||||||
|
|
||||||
|
Extends ``DefaultModelState`` with:
|
||||||
|
|
||||||
|
* Cross-stage intermediate buffer (``OmniIntermediateBuffer``)
|
||||||
|
* ``model_intermediate_buffer`` / ``runtime_additional_information`` injection
|
||||||
|
into ``model_inputs`` via ``prepare_inputs()``
|
||||||
|
* ``OmniOutput`` → ``(text_hidden, multimodal_outputs)`` post-processing
|
||||||
|
* Plugin lifecycle dispatch (``OmniModelStatePlugin``)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from vllm.v1.worker.utils import AttentionGroup
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from vllm.config import VllmConfig
|
||||||
|
from vllm.config.compilation import CUDAGraphMode
|
||||||
|
from vllm.logger import init_logger
|
||||||
|
from vllm.v1.core.sched.output import NewRequestData
|
||||||
|
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||||
|
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
|
||||||
|
from vllm.v1.worker.gpu.model_states.default import DefaultModelState
|
||||||
|
from vllm.v1.worker.gpu.states import RequestState
|
||||||
|
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.worker_v2.model_states.intermediate_buffer import (
|
||||||
|
OmniIntermediateBuffer,
|
||||||
|
)
|
||||||
|
from vllm_omni.worker_v2.model_states.plugin import OmniModelStatePlugin
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
_rope_patch_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_mrope_positions(
|
||||||
|
self_model: Any,
|
||||||
|
input_tokens: list[int],
|
||||||
|
mm_features: list,
|
||||||
|
) -> tuple[torch.Tensor, int]:
|
||||||
|
"""Return 3D sequential positions with zero delta.
|
||||||
|
|
||||||
|
For non-vision Omni models (e.g. TTS Talker), all 3 M-RoPE
|
||||||
|
dimensions use the same sequential positions. Delta=0 keeps decode
|
||||||
|
positions sequential, identical to the 1D case but broadcast to 3 dims.
|
||||||
|
"""
|
||||||
|
n = len(input_tokens)
|
||||||
|
pos = torch.arange(n, dtype=torch.long)
|
||||||
|
return pos.unsqueeze(0).expand(3, -1), 0
|
||||||
|
|
||||||
|
|
||||||
|
def _make_safe_get_rope(orig_get_rope):
|
||||||
|
from vllm.v1.worker.gpu.mm.rope import RopeState
|
||||||
|
|
||||||
|
def _safe_get_rope(model_config: Any, mdl: Any, **kwargs: Any) -> Any:
|
||||||
|
try:
|
||||||
|
result = orig_get_rope(model_config, mdl, **kwargs)
|
||||||
|
except (AssertionError, TypeError):
|
||||||
|
result = None
|
||||||
|
|
||||||
|
needs_mrope = bool(getattr(model_config, "uses_mrope", False))
|
||||||
|
if result is not None and (not needs_mrope or getattr(result, "num_dims", 0) >= 3):
|
||||||
|
return result
|
||||||
|
if not needs_mrope:
|
||||||
|
return None
|
||||||
|
if not hasattr(mdl, "get_mrope_input_positions"):
|
||||||
|
mdl.get_mrope_input_positions = types.MethodType(_default_mrope_positions, mdl)
|
||||||
|
return RopeState(num_dims=3, has_delta=True, **kwargs)
|
||||||
|
|
||||||
|
return _safe_get_rope
|
||||||
|
|
||||||
|
|
||||||
|
class OmniModelState(DefaultModelState):
|
||||||
|
"""Generic Omni ``ModelState`` — works for **all** Omni model stages.
|
||||||
|
|
||||||
|
Model-specific behaviour is injected via ``OmniModelStatePlugin``
|
||||||
|
instances or subclasses; this class itself is model-agnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
vllm_config: VllmConfig,
|
||||||
|
model: nn.Module,
|
||||||
|
encoder_cache: EncoderCache | None,
|
||||||
|
device: torch.device,
|
||||||
|
) -> None:
|
||||||
|
# DefaultModelState.__init__ calls get_rope_state() which asserts
|
||||||
|
# isinstance(model, SupportsMRoPE). Two categories of Omni models:
|
||||||
|
#
|
||||||
|
# 1. Models that implement SupportsMRoPE (e.g. Qwen3-Omni Thinker):
|
||||||
|
# get_rope_state() succeeds normally, _safe_get_rope is a no-op.
|
||||||
|
# These models get correct 3D M-RoPE positions from the runner.
|
||||||
|
#
|
||||||
|
# 2. Models that do NOT implement SupportsMRoPE (e.g. Qwen3-TTS
|
||||||
|
# Talker, Code2Wav, FishSpeech): get_rope_state() would assert.
|
||||||
|
# These models compute their own position encoding internally
|
||||||
|
# (via model.forward kwargs or fixed 1D positions from
|
||||||
|
# InputBatch.positions), so rope_state = None is correct —
|
||||||
|
# DefaultModelState.prepare_inputs returns {} when rope_state
|
||||||
|
# is None, and upstream execute_model falls back to
|
||||||
|
# InputBatch.positions (1D sequential).
|
||||||
|
# Patch get_rope_state to handle Omni models that declare
|
||||||
|
# M-RoPE in config (mrope_section) but do not implement the
|
||||||
|
# SupportsMRoPE interface. For these models we create a
|
||||||
|
# RopeState with 3D sequential positions (matching V1 MR).
|
||||||
|
#
|
||||||
|
# The patch is applied via a class-level lock to prevent
|
||||||
|
# concurrent OmniModelState instances (e.g. different stages
|
||||||
|
# in a thread pool) from overwriting each other's patch.
|
||||||
|
from vllm.v1.worker.gpu.model_states import default as _default_mod
|
||||||
|
|
||||||
|
with _rope_patch_lock:
|
||||||
|
orig_get_rope = _default_mod.get_rope_state
|
||||||
|
_default_mod.get_rope_state = _make_safe_get_rope(orig_get_rope)
|
||||||
|
try:
|
||||||
|
super().__init__(vllm_config, model, encoder_cache, device)
|
||||||
|
finally:
|
||||||
|
_default_mod.get_rope_state = orig_get_rope
|
||||||
|
max_num_reqs = self.scheduler_config.max_num_seqs
|
||||||
|
self.intermediate_buffer = OmniIntermediateBuffer(max_num_reqs)
|
||||||
|
self.has_preprocess: bool = getattr(model, "has_preprocess", False)
|
||||||
|
self.has_postprocess: bool = getattr(model, "has_postprocess", False)
|
||||||
|
self.have_multimodal_outputs: bool = getattr(model, "have_multimodal_outputs", False)
|
||||||
|
self.plugins: list[OmniModelStatePlugin] = []
|
||||||
|
|
||||||
|
# Talker's codec_embedding dim may differ from hf_text_config.hidden_size; probe real dim.
|
||||||
|
self._embed_dim = self._get_embed_dim(model, device) if self.has_preprocess else 0
|
||||||
|
|
||||||
|
# Static inputs_embeds buffer for FULL CUDA graph — preprocess fills it in-place each step.
|
||||||
|
self._static_inputs_embeds: torch.Tensor | None = None
|
||||||
|
if self._embed_dim > 0:
|
||||||
|
self._static_inputs_embeds = torch.zeros(
|
||||||
|
(self.max_num_tokens, self._embed_dim),
|
||||||
|
dtype=self.dtype,
|
||||||
|
device=device,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static MTP buffers so _run_batched_mtp uses .copy_() instead of torch.cat().
|
||||||
|
self._mtp_input_ids: torch.Tensor | None = None
|
||||||
|
self._mtp_input_embeds: torch.Tensor | None = None
|
||||||
|
self._mtp_hidden: torch.Tensor | None = None
|
||||||
|
self._mtp_text_step: torch.Tensor | None = None
|
||||||
|
if self._embed_dim > 0 and hasattr(model, "talker_mtp"):
|
||||||
|
max_bs = max_num_reqs
|
||||||
|
self._mtp_input_ids = torch.zeros(max_bs, dtype=torch.long, device=device)
|
||||||
|
self._mtp_input_embeds = torch.zeros((max_bs, self._embed_dim), dtype=self.dtype, device=device)
|
||||||
|
self._mtp_hidden = torch.zeros((max_bs, self._embed_dim), dtype=self.dtype, device=device)
|
||||||
|
self._mtp_text_step = torch.zeros((max_bs, self._embed_dim), dtype=self.dtype, device=device)
|
||||||
|
|
||||||
|
if hasattr(model, "get_omni_plugins"):
|
||||||
|
for plugin in model.get_omni_plugins():
|
||||||
|
self.register_plugin(plugin)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_embed_dim(model: nn.Module, device: torch.device) -> int:
|
||||||
|
"""Return the embedding dim that ``embed_input_ids`` produces (may differ from hf_text_config)."""
|
||||||
|
if hasattr(model, "embed_input_ids"):
|
||||||
|
dummy = torch.zeros(1, dtype=torch.long, device=device)
|
||||||
|
with torch.no_grad():
|
||||||
|
out = model.embed_input_ids(dummy)
|
||||||
|
return out.shape[-1]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Attention metadata: use actual max_seq_len, not max_model_len
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def prepare_attn(
|
||||||
|
self,
|
||||||
|
input_batch: InputBatch,
|
||||||
|
cudagraph_mode: CUDAGraphMode,
|
||||||
|
block_tables: tuple[torch.Tensor, ...],
|
||||||
|
slot_mappings: torch.Tensor,
|
||||||
|
attn_groups: list[list[AttentionGroup]],
|
||||||
|
kv_cache_config: Any,
|
||||||
|
for_capture: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Override to use actual max_seq_len instead of max_model_len.
|
||||||
|
|
||||||
|
Upstream DefaultModelState uses ``self.max_model_len`` as
|
||||||
|
``max_seq_len`` for attention metadata. This causes
|
||||||
|
FlashAttention to use a different scheduler/tiling strategy
|
||||||
|
than V1 MR (which uses the actual maximum sequence length).
|
||||||
|
The different tiling changes the float accumulation order in
|
||||||
|
bf16, causing numerically different attention outputs that
|
||||||
|
snowball through the transformer layers and produce completely
|
||||||
|
different logits—leading to 30x more decode steps for TTS.
|
||||||
|
"""
|
||||||
|
from vllm.v1.worker.gpu.attn_utils import build_attn_metadata
|
||||||
|
|
||||||
|
if cudagraph_mode == CUDAGraphMode.FULL:
|
||||||
|
num_reqs = input_batch.num_reqs_after_padding
|
||||||
|
num_tokens = input_batch.num_tokens_after_padding
|
||||||
|
else:
|
||||||
|
num_reqs = input_batch.num_reqs
|
||||||
|
num_tokens = input_batch.num_tokens
|
||||||
|
|
||||||
|
query_start_loc_cpu = torch.from_numpy(input_batch.query_start_loc_np)
|
||||||
|
max_query_len = input_batch.num_scheduled_tokens.max().item()
|
||||||
|
|
||||||
|
# Use actual max seq_len (matching V1 MR behavior) instead of
|
||||||
|
# max_model_len. For CUDA graph capture, use max_model_len to
|
||||||
|
# ensure the captured kernel covers all possible seq_lens.
|
||||||
|
if for_capture:
|
||||||
|
max_seq_len = self.max_model_len
|
||||||
|
else:
|
||||||
|
max_seq_len = int(input_batch.seq_lens[:num_reqs].max().item())
|
||||||
|
|
||||||
|
return build_attn_metadata(
|
||||||
|
attn_groups=attn_groups,
|
||||||
|
num_reqs=num_reqs,
|
||||||
|
num_tokens=num_tokens,
|
||||||
|
query_start_loc_gpu=input_batch.query_start_loc,
|
||||||
|
query_start_loc_cpu=query_start_loc_cpu,
|
||||||
|
max_query_len=max_query_len,
|
||||||
|
seq_lens=input_batch.seq_lens,
|
||||||
|
max_seq_len=max_seq_len,
|
||||||
|
block_tables=block_tables,
|
||||||
|
slot_mappings=slot_mappings,
|
||||||
|
kv_cache_config=kv_cache_config,
|
||||||
|
dcp_local_seq_lens=input_batch.dcp_local_seq_lens,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Plugin management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def register_plugin(self, plugin: OmniModelStatePlugin) -> None:
|
||||||
|
self.plugins.append(plugin)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Request lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
|
||||||
|
super().add_request(req_index, new_req_data)
|
||||||
|
self.intermediate_buffer.add_request(req_index, new_req_data)
|
||||||
|
for plugin in self.plugins:
|
||||||
|
plugin.on_add_request(req_index, new_req_data)
|
||||||
|
|
||||||
|
def remove_request(self, req_index: int) -> None:
|
||||||
|
self.intermediate_buffer.remove_request(req_index)
|
||||||
|
for plugin in self.plugins:
|
||||||
|
plugin.on_remove_request(req_index)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Input preparation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def prepare_inputs(self, input_batch: InputBatch, req_states: RequestState) -> dict[str, Any]:
|
||||||
|
base = super().prepare_inputs(input_batch, req_states)
|
||||||
|
buffer_list = self.intermediate_buffer.gather(input_batch)
|
||||||
|
base["model_intermediate_buffer"] = buffer_list
|
||||||
|
base["runtime_additional_information"] = buffer_list
|
||||||
|
base["seq_token_counts"] = [int(input_batch.num_scheduled_tokens[i]) for i in range(input_batch.num_reqs)]
|
||||||
|
# Return static inputs_embeds so FULL graph replay uses the same
|
||||||
|
# tensor address that was captured. Preprocess fills it in-place.
|
||||||
|
if self._static_inputs_embeds is not None:
|
||||||
|
base["inputs_embeds"] = self._static_inputs_embeds[: input_batch.num_tokens_after_padding]
|
||||||
|
for plugin in self.plugins:
|
||||||
|
base.update(plugin.prepare_extra_inputs(input_batch, req_states))
|
||||||
|
return base
|
||||||
|
|
||||||
|
def prepare_dummy_inputs(self, num_reqs: int, num_tokens: int) -> dict[str, Any]:
|
||||||
|
base = super().prepare_dummy_inputs(num_reqs, num_tokens)
|
||||||
|
dummy_buffer = [{} for _ in range(num_reqs)]
|
||||||
|
base["model_intermediate_buffer"] = dummy_buffer
|
||||||
|
base["runtime_additional_information"] = dummy_buffer
|
||||||
|
if num_reqs > 0:
|
||||||
|
per_req = num_tokens // num_reqs
|
||||||
|
remainder = num_tokens % num_reqs
|
||||||
|
counts = [per_req] * num_reqs
|
||||||
|
counts[-1] += remainder
|
||||||
|
base["seq_token_counts"] = counts
|
||||||
|
else:
|
||||||
|
base["seq_token_counts"] = []
|
||||||
|
# Return static inputs_embeds for FULL graph capture so the graph
|
||||||
|
# captures this tensor's address.
|
||||||
|
if self._static_inputs_embeds is not None:
|
||||||
|
base["inputs_embeds"] = self._static_inputs_embeds[:num_tokens]
|
||||||
|
return base
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Pre-forward: per-request preprocess + batched MTP
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_preprocess(self, input_batch: InputBatch, model_inputs: dict[str, Any]) -> None:
|
||||||
|
"""Per-request preprocess + MTP before model forward.
|
||||||
|
|
||||||
|
Modifies ``model_inputs["input_ids"]`` and ``model_inputs["inputs_embeds"]``
|
||||||
|
in-place. Collects decode-step MTP inputs and runs a single batched MTP
|
||||||
|
forward at the end.
|
||||||
|
|
||||||
|
Skipped when the model declares ``preprocess_in_forward = True``,
|
||||||
|
meaning it handles preprocess internally inside forward().
|
||||||
|
"""
|
||||||
|
if not self.has_preprocess:
|
||||||
|
return
|
||||||
|
# Model does preprocess+MTP inside forward() — skip external preprocess.
|
||||||
|
if getattr(self.model, "preprocess_in_forward", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
embeds = model_inputs.get("inputs_embeds")
|
||||||
|
if embeds is None:
|
||||||
|
embeds = self.model.embed_input_ids(input_batch.input_ids[: input_batch.num_tokens])
|
||||||
|
model_inputs["inputs_embeds"] = embeds
|
||||||
|
|
||||||
|
input_ids = model_inputs.get("input_ids")
|
||||||
|
if input_ids is None:
|
||||||
|
input_ids = input_batch.input_ids
|
||||||
|
|
||||||
|
gpu_keys: set[str] = getattr(self.model, "gpu_resident_buffer_keys", set())
|
||||||
|
mtp_batches: list[tuple[int, int, tuple[torch.Tensor, torch.Tensor]]] = []
|
||||||
|
|
||||||
|
for i in range(input_batch.num_reqs):
|
||||||
|
req_idx = int(input_batch.idx_mapping_np[i])
|
||||||
|
buf = self.intermediate_buffer.buffers[req_idx]
|
||||||
|
|
||||||
|
# Skip warmup/dummy requests that have no real buffer data.
|
||||||
|
# Warmup creates fake requests without the metadata that
|
||||||
|
# model.preprocess() requires (e.g. additional_information.text).
|
||||||
|
if not buf or "req_id" not in buf:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start = int(input_batch.query_start_loc_np[i])
|
||||||
|
n_tok = int(input_batch.num_scheduled_tokens[i])
|
||||||
|
|
||||||
|
ids_slice = input_ids[start : start + n_tok]
|
||||||
|
emb_slice = embeds[start : start + n_tok]
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = {key: value for key, value in buf.items() if isinstance(key, str)}
|
||||||
|
new_ids, new_emb, updates = self.model.preprocess(ids_slice, emb_slice, **info)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"preprocess failed for req_idx=%d (req_id=%s); skipping preprocess for this request",
|
||||||
|
req_idx,
|
||||||
|
buf.get("req_id", "?"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Write back in-place
|
||||||
|
seg = min(n_tok, new_ids.shape[0])
|
||||||
|
input_ids[start : start + seg] = new_ids[:seg]
|
||||||
|
embeds[start : start + seg] = new_emb[:seg]
|
||||||
|
|
||||||
|
# Collect MTP inputs for decode steps (n_tok == 1 with mtp_inputs)
|
||||||
|
mtp_inputs = updates.pop("mtp_inputs", None)
|
||||||
|
if mtp_inputs is not None and n_tok == 1:
|
||||||
|
mtp_batches.append((i, start, mtp_inputs))
|
||||||
|
|
||||||
|
self.intermediate_buffer.update(req_idx, updates, gpu_keys)
|
||||||
|
|
||||||
|
if mtp_batches and hasattr(self.model, "talker_mtp"):
|
||||||
|
self._run_batched_mtp(mtp_batches, input_ids, embeds, input_batch, gpu_keys)
|
||||||
|
|
||||||
|
def _run_batched_mtp(
|
||||||
|
self,
|
||||||
|
mtp_batches: list[tuple[int, int, tuple[torch.Tensor, torch.Tensor]]],
|
||||||
|
input_ids: torch.Tensor,
|
||||||
|
embeds: torch.Tensor,
|
||||||
|
input_batch: InputBatch,
|
||||||
|
gpu_keys: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Batch MTP forward for all decode-step requests.
|
||||||
|
|
||||||
|
Uses pre-allocated static buffers to avoid per-step torch.cat
|
||||||
|
memory allocations.
|
||||||
|
"""
|
||||||
|
from vllm.forward_context import set_forward_context
|
||||||
|
|
||||||
|
bsz = len(mtp_batches)
|
||||||
|
|
||||||
|
# Fill static buffers via .copy_() — no allocation.
|
||||||
|
if self._mtp_input_ids is not None and bsz <= self._mtp_input_ids.shape[0]:
|
||||||
|
for j, (_i, start, (past_hidden, text_step)) in enumerate(mtp_batches):
|
||||||
|
self._mtp_input_ids[j] = input_ids[start]
|
||||||
|
self._mtp_input_embeds[j].copy_(embeds[start])
|
||||||
|
self._mtp_hidden[j].copy_(past_hidden.reshape(-1)[: self._mtp_hidden.shape[1]])
|
||||||
|
self._mtp_text_step[j].copy_(text_step.reshape(-1)[: self._mtp_text_step.shape[1]])
|
||||||
|
batch_ids = self._mtp_input_ids[:bsz]
|
||||||
|
batch_emb = self._mtp_input_embeds[:bsz]
|
||||||
|
batch_hidden = self._mtp_hidden[:bsz]
|
||||||
|
batch_step = self._mtp_text_step[:bsz]
|
||||||
|
else:
|
||||||
|
# Fallback to torch.cat if static buffers not available.
|
||||||
|
ids_list, emb_list, hidden_list, step_list = [], [], [], []
|
||||||
|
for _i, start, (past_hidden, text_step) in mtp_batches:
|
||||||
|
ids_list.append(input_ids[start : start + 1])
|
||||||
|
emb_list.append(embeds[start : start + 1])
|
||||||
|
hidden_list.append(past_hidden)
|
||||||
|
step_list.append(text_step)
|
||||||
|
batch_ids = torch.cat(ids_list)
|
||||||
|
batch_emb = torch.cat(emb_list)
|
||||||
|
batch_hidden = torch.cat(hidden_list)
|
||||||
|
batch_step = torch.cat(step_list)
|
||||||
|
|
||||||
|
with set_forward_context(
|
||||||
|
None,
|
||||||
|
self.vllm_config,
|
||||||
|
num_tokens=bsz,
|
||||||
|
):
|
||||||
|
new_emb, codes = self.model.talker_mtp(
|
||||||
|
batch_ids,
|
||||||
|
batch_emb,
|
||||||
|
batch_hidden,
|
||||||
|
batch_step,
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_key = getattr(self.model, "talker_mtp_output_key", ("codes", "audio"))
|
||||||
|
for j, (i, start, _) in enumerate(mtp_batches):
|
||||||
|
embeds[start : start + 1] = new_emb[j : j + 1]
|
||||||
|
req_idx = int(input_batch.idx_mapping_np[i])
|
||||||
|
if isinstance(audio_key, tuple) and len(audio_key) == 2:
|
||||||
|
updates = {audio_key[0]: {audio_key[1]: codes[j : j + 1]}}
|
||||||
|
elif isinstance(audio_key, str):
|
||||||
|
updates = {audio_key: codes[j : j + 1]}
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"talker_mtp_output_key must be a string or 2-tuple, got {type(audio_key).__name__}: {audio_key!r}"
|
||||||
|
)
|
||||||
|
self.intermediate_buffer.update(req_idx, updates, gpu_keys)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Post-forward: per-request postprocess
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_postprocess(self, hidden_states: torch.Tensor, input_batch: InputBatch) -> None:
|
||||||
|
"""Per-request postprocess after model forward.
|
||||||
|
|
||||||
|
Extracts per-request updates from hidden_states and writes them
|
||||||
|
back to the intermediate buffer (e.g. ``last_talker_hidden``).
|
||||||
|
|
||||||
|
Skipped when the model declares ``preprocess_in_forward = True``
|
||||||
|
(the flag covers both pre- and post-processing — both run inside
|
||||||
|
the model's forward()).
|
||||||
|
"""
|
||||||
|
if not self.has_postprocess:
|
||||||
|
return
|
||||||
|
# preprocess_in_forward also covers postprocess — both run inside forward()
|
||||||
|
if getattr(self.model, "preprocess_in_forward", False):
|
||||||
|
return
|
||||||
|
gpu_keys: set[str] = getattr(self.model, "gpu_resident_buffer_keys", set())
|
||||||
|
for i in range(input_batch.num_reqs):
|
||||||
|
req_idx = int(input_batch.idx_mapping_np[i])
|
||||||
|
buf = self.intermediate_buffer.buffers[req_idx]
|
||||||
|
if not buf or "req_id" not in buf:
|
||||||
|
continue
|
||||||
|
start = int(input_batch.query_start_loc_np[i])
|
||||||
|
n_tok = int(input_batch.num_scheduled_tokens[i])
|
||||||
|
h_slice = hidden_states[start : start + n_tok]
|
||||||
|
info = {key: value for key, value in buf.items() if isinstance(key, str) and key != "hidden_states"}
|
||||||
|
updates = self.model.postprocess(h_slice, **info)
|
||||||
|
if updates:
|
||||||
|
self.intermediate_buffer.update(req_idx, updates, gpu_keys)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Output post-processing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def postprocess_model_output(
|
||||||
|
self,
|
||||||
|
model_output: Any,
|
||||||
|
input_batch: InputBatch,
|
||||||
|
req_states: RequestState,
|
||||||
|
) -> tuple[torch.Tensor, dict]:
|
||||||
|
"""Convert raw model output to ``(text_hidden, multimodal_outputs)``.
|
||||||
|
|
||||||
|
Handles ``OmniOutput`` unwrapping and ``make_omni_output``
|
||||||
|
conversion, then dispatches to registered plugins.
|
||||||
|
"""
|
||||||
|
if not isinstance(model_output, OmniOutput) and hasattr(self.model, "make_omni_output"):
|
||||||
|
if isinstance(model_output, (list, tuple)) or self.have_multimodal_outputs:
|
||||||
|
buffer_list = self.intermediate_buffer.gather(input_batch)
|
||||||
|
try:
|
||||||
|
model_output = self.model.make_omni_output(
|
||||||
|
model_output,
|
||||||
|
model_intermediate_buffer=buffer_list,
|
||||||
|
runtime_additional_information=buffer_list,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_desc = type(model_output).__name__
|
||||||
|
if isinstance(model_output, (list, tuple)):
|
||||||
|
_desc += f"(len={len(model_output)})"
|
||||||
|
logger.warning(
|
||||||
|
"make_omni_output failed for %s; multimodal outputs will be empty",
|
||||||
|
_desc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(model_output, OmniOutput):
|
||||||
|
text_hidden = model_output.text_hidden_states
|
||||||
|
multimodal_outputs: dict = model_output.multimodal_outputs or {}
|
||||||
|
elif isinstance(model_output, (list, tuple)):
|
||||||
|
text_hidden = model_output[0]
|
||||||
|
multimodal_outputs = {}
|
||||||
|
else:
|
||||||
|
text_hidden = model_output
|
||||||
|
multimodal_outputs = {}
|
||||||
|
|
||||||
|
for plugin in self.plugins:
|
||||||
|
text_hidden, multimodal_outputs = plugin.postprocess(
|
||||||
|
text_hidden, multimodal_outputs, input_batch, req_states
|
||||||
|
)
|
||||||
|
|
||||||
|
return text_hidden, multimodal_outputs
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""OmniModelStatePlugin — abstract extension point for model-specific behavior.
|
||||||
|
|
||||||
|
Plugins are registered on OmniModelState at model-load time and receive
|
||||||
|
callbacks at well-defined points in the request/step lifecycle. The base
|
||||||
|
class provides default no-op implementations for optional hooks so that
|
||||||
|
concrete plugins only need to override what they use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from vllm.v1.core.sched.output import NewRequestData
|
||||||
|
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||||
|
from vllm.v1.worker.gpu.states import RequestState
|
||||||
|
|
||||||
|
|
||||||
|
class OmniModelStatePlugin(ABC):
|
||||||
|
"""Extension point for model-specific behavior in OmniModelState."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_add_request(self, req_index: int, new_req_data: NewRequestData) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_remove_request(self, req_index: int) -> None: ...
|
||||||
|
|
||||||
|
def prepare_extra_inputs(self, input_batch: InputBatch, req_states: RequestState) -> dict[str, Any]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def postprocess(
|
||||||
|
self,
|
||||||
|
text_hidden: Any,
|
||||||
|
multimodal_outputs: dict,
|
||||||
|
input_batch: InputBatch,
|
||||||
|
req_states: RequestState,
|
||||||
|
) -> tuple[Any, dict]:
|
||||||
|
"""Post-process model output. Default is pass-through."""
|
||||||
|
return text_hidden, multimodal_outputs
|
||||||
|
|
||||||
|
def dummy_run(self, state: Any, num_tokens: int) -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
"""OmniARModelRunner — autoregressive stage runner on MR V2.
|
||||||
|
|
||||||
|
Extends ``OmniGPUModelRunner`` with:
|
||||||
|
|
||||||
|
* ``OmniOutput`` post-processing in ``sample_tokens``
|
||||||
|
* Per-request ``pooler_output`` construction (hidden + multimodal slices)
|
||||||
|
* Async D2H copy via ``OmniAsyncOutput`` for non-blocking output transfer
|
||||||
|
* Cross-stage KV extraction before state cleanup
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from vllm.logger import init_logger
|
||||||
|
from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
|
||||||
|
from vllm.v1.outputs import AsyncModelRunnerOutput, ModelRunnerOutput
|
||||||
|
|
||||||
|
from vllm_omni.data_entry_keys import flatten_payload
|
||||||
|
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
|
||||||
|
OmniKVTransferManager,
|
||||||
|
)
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||||
|
from vllm_omni.worker_v2.omni_model_runner import OmniGPUModelRunner
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OmniARModelRunner(OmniGPUModelRunner):
|
||||||
|
"""AR stage runner. Produces per-request hidden states + multimodal outputs."""
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.kv_transfer_manager: OmniKVTransferManager | None = None
|
||||||
|
self._kv_extracted_req_ids: list[str] | None = None
|
||||||
|
|
||||||
|
def _ensure_kv_transfer_manager(self) -> OmniKVTransferManager:
|
||||||
|
if self.kv_transfer_manager is None:
|
||||||
|
self.kv_transfer_manager = OmniKVTransferManager.from_vllm_config(self.vllm_config, self.model_config)
|
||||||
|
return self.kv_transfer_manager
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# execute_model: KV transfer pre-hook + delegate to super
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def execute_model(
|
||||||
|
self,
|
||||||
|
scheduler_output: SchedulerOutput,
|
||||||
|
intermediate_tensors: Any | None = None,
|
||||||
|
dummy_run: bool = False,
|
||||||
|
skip_attn_for_dummy_run: bool = False,
|
||||||
|
is_profile: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
if not dummy_run:
|
||||||
|
self._handle_kv_transfer_pre(scheduler_output)
|
||||||
|
return super().execute_model(
|
||||||
|
scheduler_output,
|
||||||
|
intermediate_tensors,
|
||||||
|
dummy_run=dummy_run,
|
||||||
|
skip_attn_for_dummy_run=skip_attn_for_dummy_run,
|
||||||
|
is_profile=is_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# sample_tokens: OmniOutput handling + pooler_output + async D2H
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sample_tokens(
|
||||||
|
self, grammar_output: GrammarOutput | None
|
||||||
|
) -> OmniAsyncOutput | OmniModelRunnerOutput | ModelRunnerOutput | None:
|
||||||
|
kv_extracted = self._kv_extracted_req_ids
|
||||||
|
self._kv_extracted_req_ids = None
|
||||||
|
|
||||||
|
if self.execute_model_state is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
input_batch = self.execute_model_state.input_batch
|
||||||
|
hidden_states = self.execute_model_state.hidden_states
|
||||||
|
# kv_connector_output is no longer a field on vLLM 0.23.0's ExecuteModelState;
|
||||||
|
# the base execute_model stashes it here after post_forward().
|
||||||
|
kv_connector_output = self._last_kv_connector_output
|
||||||
|
self._last_kv_connector_output = None
|
||||||
|
self.execute_model_state = None
|
||||||
|
|
||||||
|
if not self.is_last_pp_rank:
|
||||||
|
from vllm.v1.worker.gpu.pp_utils import pp_receive
|
||||||
|
|
||||||
|
sampled, num_sampled, num_rejected = pp_receive(
|
||||||
|
input_batch.num_reqs,
|
||||||
|
max_sample_len=self.num_speculative_steps + 1,
|
||||||
|
)
|
||||||
|
# vLLM 0.23.0 renamed postprocess() -> postprocess_sampled() and now
|
||||||
|
# takes idx_mapping (+ query_start_loc) instead of the InputBatch.
|
||||||
|
self.postprocess_sampled(
|
||||||
|
input_batch.idx_mapping,
|
||||||
|
sampled,
|
||||||
|
num_sampled,
|
||||||
|
num_rejected,
|
||||||
|
input_batch.query_start_loc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- Omni: reconstruct raw model output and post-process ---
|
||||||
|
aux = self._last_aux_output
|
||||||
|
self._last_aux_output = None
|
||||||
|
multimodal_outputs = self._last_multimodal_outputs
|
||||||
|
self._last_multimodal_outputs = None
|
||||||
|
raw_output = self._reconstruct_raw_model_output(
|
||||||
|
hidden_states=hidden_states,
|
||||||
|
multimodal_outputs=multimodal_outputs,
|
||||||
|
aux=aux,
|
||||||
|
)
|
||||||
|
text_hidden, multimodal_outputs = self.model_state.postprocess_model_output(
|
||||||
|
raw_output, input_batch, self.req_states
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Standard v2 sampling ---
|
||||||
|
sampler_output, num_sampled, num_rejected = self._sample_with_prompt_token_compat(
|
||||||
|
text_hidden,
|
||||||
|
input_batch,
|
||||||
|
grammar_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.use_pp:
|
||||||
|
from vllm.v1.worker.gpu.pp_utils import pp_broadcast
|
||||||
|
|
||||||
|
pp_broadcast(sampler_output.sampled_token_ids, num_sampled, num_rejected)
|
||||||
|
|
||||||
|
# --- Omni: prompt logprobs ---
|
||||||
|
assert self.prompt_logprobs_worker is not None
|
||||||
|
# vLLM 0.23.0 dropped the prefill_len / num_computed_prefill_tokens args;
|
||||||
|
# compute_prompt_logprobs now reads prefill_len_np /
|
||||||
|
# num_computed_prefill_tokens_np from input_batch instead. Mirror the
|
||||||
|
# parent GPUModelRunner's 6-arg call exactly.
|
||||||
|
prompt_logprobs_dict = self.prompt_logprobs_worker.compute_prompt_logprobs(
|
||||||
|
self.model.compute_logits,
|
||||||
|
text_hidden,
|
||||||
|
input_batch,
|
||||||
|
self.req_states.all_token_ids.gpu,
|
||||||
|
self.req_states.num_computed_tokens.gpu,
|
||||||
|
self.req_states.prompt_len.np,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Omni: pooler_output ---
|
||||||
|
engine_output_type = getattr(self.vllm_config.model_config, "engine_output_type", "text")
|
||||||
|
need_pooler = engine_output_type != "text"
|
||||||
|
|
||||||
|
# --- Build base output ---
|
||||||
|
model_runner_output = OmniModelRunnerOutput(
|
||||||
|
req_ids=input_batch.req_ids,
|
||||||
|
req_id_to_index={rid: i for i, rid in enumerate(input_batch.req_ids)},
|
||||||
|
sampled_token_ids=None, # type: ignore[arg-type]
|
||||||
|
prompt_logprobs_dict=prompt_logprobs_dict,
|
||||||
|
kv_connector_output=kv_connector_output,
|
||||||
|
)
|
||||||
|
model_runner_output.kv_extracted_req_ids = kv_extracted
|
||||||
|
|
||||||
|
# --- Async D2H via OmniAsyncOutput ---
|
||||||
|
async_output = OmniAsyncOutput(
|
||||||
|
model_runner_output=model_runner_output,
|
||||||
|
sampler_output=sampler_output,
|
||||||
|
num_sampled_tokens=num_sampled,
|
||||||
|
main_stream=self.main_stream,
|
||||||
|
copy_stream=self.output_copy_stream,
|
||||||
|
text_hidden=text_hidden if need_pooler else None,
|
||||||
|
multimodal_outputs=multimodal_outputs if need_pooler else None,
|
||||||
|
input_batch=input_batch if need_pooler else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Postprocess AFTER creating async output (so copy_event is
|
||||||
|
# recorded before postprocess, matching upstream pattern).
|
||||||
|
self.postprocess_sampled(
|
||||||
|
input_batch.idx_mapping,
|
||||||
|
sampler_output.sampled_token_ids,
|
||||||
|
num_sampled,
|
||||||
|
num_rejected,
|
||||||
|
input_batch.query_start_loc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.use_async_scheduling:
|
||||||
|
return async_output
|
||||||
|
return async_output.get_output()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# pooler_output construction
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reconstruct_raw_model_output(
|
||||||
|
*,
|
||||||
|
hidden_states: torch.Tensor,
|
||||||
|
multimodal_outputs: dict[str, Any] | None,
|
||||||
|
aux: Any | None,
|
||||||
|
) -> Any:
|
||||||
|
if multimodal_outputs:
|
||||||
|
return OmniOutput(
|
||||||
|
text_hidden_states=hidden_states,
|
||||||
|
multimodal_outputs=multimodal_outputs,
|
||||||
|
)
|
||||||
|
if aux is not None:
|
||||||
|
return (hidden_states, aux)
|
||||||
|
return hidden_states
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_pooler_output_from_cpu(
|
||||||
|
hidden_cpu: torch.Tensor,
|
||||||
|
mm_cpu: dict[str, Any],
|
||||||
|
query_start_loc_np: np.ndarray,
|
||||||
|
num_scheduled_tokens: np.ndarray,
|
||||||
|
num_reqs: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build pooler_output from already-CPU tensors."""
|
||||||
|
total = hidden_cpu.shape[0]
|
||||||
|
pooler: list[dict[str, Any]] = []
|
||||||
|
for i in range(num_reqs):
|
||||||
|
start = int(query_start_loc_np[i])
|
||||||
|
end = start + int(num_scheduled_tokens[i])
|
||||||
|
payload: dict[str, Any] = {"hidden": hidden_cpu[start:end]}
|
||||||
|
for k, v in mm_cpu.items():
|
||||||
|
payload[k] = _slice_pooler_value(
|
||||||
|
v,
|
||||||
|
req_index=i,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
total_tokens=total,
|
||||||
|
)
|
||||||
|
pooler.append(flatten_payload(payload))
|
||||||
|
return pooler
|
||||||
|
|
||||||
|
def _sample_with_prompt_token_compat(
|
||||||
|
self,
|
||||||
|
text_hidden: torch.Tensor,
|
||||||
|
input_batch: Any,
|
||||||
|
grammar_output: GrammarOutput | None,
|
||||||
|
) -> tuple[Any, Any, Any]:
|
||||||
|
"""Run upstream sampling while restoring V1 prompt-id compatibility.
|
||||||
|
|
||||||
|
Some Omni AR stages sample from a logits vocabulary smaller than the
|
||||||
|
tokenizer/input vocabulary. V1 corrected ``prompt_token_ids`` after it
|
||||||
|
had the real logits tensor. MR V2's upstream ``sample`` owns logits
|
||||||
|
computation, so wrap ``compute_logits`` for this call and clamp from the
|
||||||
|
actual logits shape instead of guessing from config.
|
||||||
|
"""
|
||||||
|
compute_logits = getattr(self.model, "compute_logits", None)
|
||||||
|
if not callable(compute_logits):
|
||||||
|
return self.sample(text_hidden, input_batch, grammar_output)
|
||||||
|
|
||||||
|
def compute_logits_with_prompt_token_compat(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
logits = compute_logits(*args, **kwargs)
|
||||||
|
logits_shape = getattr(logits, "shape", ()) if logits is not None else ()
|
||||||
|
logits_vocab_size = logits_shape[-1] if logits_shape else None
|
||||||
|
if isinstance(logits_vocab_size, int):
|
||||||
|
self._clamp_sampling_prompt_token_ids(input_batch, logits_vocab_size)
|
||||||
|
return logits
|
||||||
|
|
||||||
|
model_dict = getattr(self.model, "__dict__", {})
|
||||||
|
had_instance_compute_logits = isinstance(model_dict, dict) and "compute_logits" in model_dict
|
||||||
|
original_instance_compute_logits = model_dict.get("compute_logits") if had_instance_compute_logits else None
|
||||||
|
setattr(self.model, "compute_logits", compute_logits_with_prompt_token_compat)
|
||||||
|
try:
|
||||||
|
return self.sample(text_hidden, input_batch, grammar_output)
|
||||||
|
finally:
|
||||||
|
if had_instance_compute_logits:
|
||||||
|
setattr(self.model, "compute_logits", original_instance_compute_logits)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
delattr(self.model, "compute_logits")
|
||||||
|
except AttributeError:
|
||||||
|
setattr(self.model, "compute_logits", compute_logits)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clamp_sampling_prompt_token_ids(
|
||||||
|
input_batch: Any,
|
||||||
|
logits_vocab_size: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Clamp sampler prompt IDs to the stage logits vocabulary.
|
||||||
|
|
||||||
|
V1 Omni AR runner did this after computing logits. MR V2 samples via
|
||||||
|
the upstream helper, so normalize the metadata before that call.
|
||||||
|
"""
|
||||||
|
if logits_vocab_size is None or logits_vocab_size <= 0:
|
||||||
|
return
|
||||||
|
if getattr(input_batch, "vocab_size", logits_vocab_size) <= logits_vocab_size:
|
||||||
|
return
|
||||||
|
|
||||||
|
sampling_metadata = getattr(input_batch, "sampling_metadata", None)
|
||||||
|
if sampling_metadata is None or getattr(sampling_metadata, "no_penalties", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt_token_ids = getattr(sampling_metadata, "prompt_token_ids", None)
|
||||||
|
if prompt_token_ids is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
max_token_id = logits_vocab_size - 1
|
||||||
|
if isinstance(prompt_token_ids, torch.Tensor):
|
||||||
|
prompt_token_ids.clamp_(max=max_token_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(prompt_token_ids, list):
|
||||||
|
sampling_metadata.prompt_token_ids = [
|
||||||
|
[min(int(tok), max_token_id) for tok in ids] if isinstance(ids, list) else min(int(ids), max_token_id)
|
||||||
|
for ids in prompt_token_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# KV transfer
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_kv_transfer_pre(self, scheduler_output: SchedulerOutput) -> None:
|
||||||
|
finished: dict = getattr(scheduler_output, "finished_requests_needing_kv_transfer", {})
|
||||||
|
if not finished:
|
||||||
|
return
|
||||||
|
|
||||||
|
kv_caches = getattr(self, "kv_caches", None)
|
||||||
|
if kv_caches is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if hasattr(self.model, "get_kv_transfer_metadata"):
|
||||||
|
for req_id, data in finished.items():
|
||||||
|
try:
|
||||||
|
meta = self.model.get_kv_transfer_metadata(req_id)
|
||||||
|
if meta:
|
||||||
|
existing = data.get("custom_metadata") or {}
|
||||||
|
existing.update(meta)
|
||||||
|
data["custom_metadata"] = existing
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to get KV transfer metadata for %s",
|
||||||
|
req_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mgr = self._ensure_kv_transfer_manager()
|
||||||
|
self._kv_extracted_req_ids = mgr.handle_finished_requests_kv_transfer(
|
||||||
|
finished_reqs=finished,
|
||||||
|
kv_caches=kv_caches,
|
||||||
|
block_size=self.cache_config.block_size,
|
||||||
|
cache_dtype=str(self.cache_config.cache_dtype),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# OmniAsyncOutput — async D2H for Omni AR outputs
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _async_copy_to_np(x: torch.Tensor) -> np.ndarray:
|
||||||
|
return x.to("cpu", non_blocking=True).numpy()
|
||||||
|
|
||||||
|
|
||||||
|
def _async_copy_tensor(x: torch.Tensor) -> torch.Tensor:
|
||||||
|
return x.to("cpu", non_blocking=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _async_copy_mm_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, torch.Tensor):
|
||||||
|
return _async_copy_tensor(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _async_copy_mm_value(val) for key, val in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_async_copy_mm_value(val) for val in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _async_copy_mm(mm_outputs: dict | None, total_tokens: int) -> dict[str, Any]:
|
||||||
|
"""Non-blocking D2H copy of multimodal output tensors."""
|
||||||
|
if not mm_outputs:
|
||||||
|
return {}
|
||||||
|
cpu: dict[str, Any] = {}
|
||||||
|
for k, v in mm_outputs.items():
|
||||||
|
try:
|
||||||
|
cpu[k] = _async_copy_mm_value(v)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error async-copying multimodal output %s", k)
|
||||||
|
return cpu
|
||||||
|
|
||||||
|
|
||||||
|
def _slice_pooler_value(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
req_index: int,
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
total_tokens: int,
|
||||||
|
) -> Any:
|
||||||
|
if isinstance(value, torch.Tensor):
|
||||||
|
if value.dim() > 0 and value.shape[0] == total_tokens:
|
||||||
|
return value[start:end].contiguous()
|
||||||
|
return value.clone()
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _slice_pooler_value(
|
||||||
|
val,
|
||||||
|
req_index=req_index,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
)
|
||||||
|
for key, val in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
elem = value[req_index] if req_index < len(value) else value[0]
|
||||||
|
return _slice_pooler_value(
|
||||||
|
elem,
|
||||||
|
req_index=req_index,
|
||||||
|
start=start,
|
||||||
|
end=end,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_tensor_values(payload: dict[str, Any]) -> dict[str, torch.Tensor]:
|
||||||
|
"""Convert a flattened payload to strictly ``dict[str, torch.Tensor]``.
|
||||||
|
|
||||||
|
Non-tensor scalars (int/float/bool) are wrapped with ``torch.tensor()``;
|
||||||
|
values that cannot be safely converted are dropped. Enforces the tensor-only
|
||||||
|
invariant required by ``OmniEngineCoreOutput.multimodal_output`` (the channel
|
||||||
|
the async_chunk stage-input processor reads). Mirrors the V1 runner helper.
|
||||||
|
"""
|
||||||
|
result: dict[str, torch.Tensor] = {}
|
||||||
|
for key, val in payload.items():
|
||||||
|
if isinstance(val, torch.Tensor):
|
||||||
|
result[key] = val
|
||||||
|
elif isinstance(val, (int, float, bool)):
|
||||||
|
result[key] = torch.tensor(val)
|
||||||
|
elif isinstance(val, (list, tuple)):
|
||||||
|
try:
|
||||||
|
result[key] = torch.tensor(val)
|
||||||
|
except (ValueError, TypeError, RuntimeError):
|
||||||
|
logger.warning(
|
||||||
|
"Dropping non-tensorizable multimodal output key '%s' (type=%s).",
|
||||||
|
key,
|
||||||
|
type(val).__name__,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Dropping non-tensor multimodal output key '%s' (type=%s).",
|
||||||
|
key,
|
||||||
|
type(val).__name__,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class OmniAsyncOutput(AsyncModelRunnerOutput):
|
||||||
|
"""Async D2H copy for Omni AR model outputs.
|
||||||
|
|
||||||
|
Mirrors upstream ``AsyncOutput`` but additionally handles
|
||||||
|
``pooler_output`` (hidden states + multimodal outputs) via
|
||||||
|
non-blocking copies on the copy stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_runner_output: OmniModelRunnerOutput,
|
||||||
|
sampler_output: Any,
|
||||||
|
num_sampled_tokens: torch.Tensor,
|
||||||
|
main_stream: torch.cuda.Stream,
|
||||||
|
copy_stream: torch.cuda.Stream,
|
||||||
|
copy_event: torch.cuda.Event | None = None,
|
||||||
|
text_hidden: torch.Tensor | None = None,
|
||||||
|
multimodal_outputs: dict | None = None,
|
||||||
|
input_batch: Any | None = None,
|
||||||
|
):
|
||||||
|
self.model_runner_output = model_runner_output
|
||||||
|
self.sampler_output = sampler_output
|
||||||
|
self.num_sampled_tokens = num_sampled_tokens
|
||||||
|
self.copy_event = copy_event if copy_event is not None else torch.cuda.Event()
|
||||||
|
|
||||||
|
# Snapshot input_batch metadata needed for pooler_output slicing
|
||||||
|
self._need_pooler = text_hidden is not None
|
||||||
|
self._query_start_loc_np: np.ndarray | None = None
|
||||||
|
self._num_scheduled_tokens: np.ndarray | None = None
|
||||||
|
self._num_reqs: int = 0
|
||||||
|
if self._need_pooler and input_batch is not None:
|
||||||
|
self._query_start_loc_np = input_batch.query_start_loc_np.copy()
|
||||||
|
self._num_scheduled_tokens = np.array(input_batch.num_scheduled_tokens, dtype=np.int32)
|
||||||
|
self._num_reqs = input_batch.num_reqs
|
||||||
|
|
||||||
|
# Perform all D2H copies on the copy stream (non-blocking).
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _stream(to_stream, from_stream):
|
||||||
|
try:
|
||||||
|
torch.cuda.set_stream(to_stream)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
torch.cuda.set_stream(from_stream)
|
||||||
|
|
||||||
|
with _stream(copy_stream, main_stream):
|
||||||
|
copy_stream.wait_stream(main_stream)
|
||||||
|
|
||||||
|
# Sampled token ids
|
||||||
|
self.sampled_token_ids_np = _async_copy_to_np(sampler_output.sampled_token_ids)
|
||||||
|
self.num_sampled_tokens_np = _async_copy_to_np(num_sampled_tokens)
|
||||||
|
|
||||||
|
# Logprobs
|
||||||
|
self.logprobs_tensors = None
|
||||||
|
if sampler_output.logprobs_tensors is not None:
|
||||||
|
self.logprobs_tensors = sampler_output.logprobs_tensors.to_cpu_nonblocking()
|
||||||
|
self.num_nans: np.ndarray | None = None
|
||||||
|
if sampler_output.num_nans is not None:
|
||||||
|
self.num_nans = _async_copy_to_np(sampler_output.num_nans)
|
||||||
|
|
||||||
|
# Prompt logprobs
|
||||||
|
self.prompt_logprobs_dict = {
|
||||||
|
k: v.to_cpu_nonblocking() if v is not None else None
|
||||||
|
for k, v in self.model_runner_output.prompt_logprobs_dict.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pooler output (hidden + multimodal) — async D2H
|
||||||
|
self._hidden_cpu: torch.Tensor | None = None
|
||||||
|
self._mm_cpu: dict[str, Any] = {}
|
||||||
|
if self._need_pooler and text_hidden is not None:
|
||||||
|
self._hidden_cpu = _async_copy_tensor(text_hidden)
|
||||||
|
total_tokens = text_hidden.shape[0]
|
||||||
|
self._mm_cpu = _async_copy_mm(multimodal_outputs, total_tokens)
|
||||||
|
|
||||||
|
self.copy_event.record(copy_stream)
|
||||||
|
|
||||||
|
def get_output(self) -> OmniModelRunnerOutput:
|
||||||
|
self.copy_event.synchronize()
|
||||||
|
|
||||||
|
# Sampled token ids
|
||||||
|
sampled_token_ids: list[list[int]] = self.sampled_token_ids_np.tolist()
|
||||||
|
num_sampled_tokens: list[int] = self.num_sampled_tokens_np.tolist()
|
||||||
|
for token_ids, num_tokens in zip(sampled_token_ids, num_sampled_tokens):
|
||||||
|
del token_ids[num_tokens:]
|
||||||
|
self.model_runner_output.sampled_token_ids = sampled_token_ids
|
||||||
|
|
||||||
|
# Logprobs
|
||||||
|
if self.num_nans is not None:
|
||||||
|
self.model_runner_output.num_nans_in_logits = dict(
|
||||||
|
zip(self.model_runner_output.req_ids, self.num_nans.tolist())
|
||||||
|
)
|
||||||
|
if self.logprobs_tensors is not None:
|
||||||
|
self.model_runner_output.logprobs = self.logprobs_tensors.tolists()
|
||||||
|
self.model_runner_output.prompt_logprobs_dict = self.prompt_logprobs_dict
|
||||||
|
|
||||||
|
# Pooler output. Populate two channels from the same per-request payloads,
|
||||||
|
# mirroring the V1 runner:
|
||||||
|
# * pooler_output -> sync/full-payload path (inline pooling_output bridge)
|
||||||
|
# * multimodal_outputs -> wire multimodal_output, which the async_chunk
|
||||||
|
# stage-input processor (talker2code2wav_async_chunk) reads for codes.
|
||||||
|
if self._need_pooler and self._hidden_cpu is not None:
|
||||||
|
pooler_output = OmniARModelRunner._build_pooler_output_from_cpu(
|
||||||
|
self._hidden_cpu,
|
||||||
|
self._mm_cpu,
|
||||||
|
self._query_start_loc_np,
|
||||||
|
self._num_scheduled_tokens,
|
||||||
|
self._num_reqs,
|
||||||
|
)
|
||||||
|
self.model_runner_output.pooler_output = pooler_output
|
||||||
|
self.model_runner_output.multimodal_outputs = [
|
||||||
|
_ensure_tensor_values(p) if p else {} for p in pooler_output
|
||||||
|
]
|
||||||
|
|
||||||
|
return self.model_runner_output
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
"""OmniGenerationModelRunner — non-autoregressive stage runner on MR V2.
|
||||||
|
|
||||||
|
Used for stages like Code2Wav that convert codec codes to audio waveforms.
|
||||||
|
No token sampling or logits computation — model output goes directly into
|
||||||
|
``pooler_output``. Inherits from ``OmniGPUModelRunner`` for intermediate
|
||||||
|
buffer and lifecycle hooks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from vllm.forward_context import set_forward_context
|
||||||
|
from vllm.logger import init_logger
|
||||||
|
from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
|
||||||
|
from vllm.v1.outputs import ModelRunnerOutput
|
||||||
|
from vllm.v1.worker.gpu.model_runner import (
|
||||||
|
BatchDescriptor,
|
||||||
|
IntermediateTensors,
|
||||||
|
get_uniform_token_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vllm_omni.core.sched.output import OmniCachedRequestData, OmniNewRequestData
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||||
|
from vllm_omni.worker_v2.forward_compat import add_forward_compat_kwargs
|
||||||
|
from vllm_omni.worker_v2.omni_ar_model_runner import _ensure_tensor_values
|
||||||
|
from vllm_omni.worker_v2.omni_model_runner import (
|
||||||
|
OmniGPUModelRunner,
|
||||||
|
_make_execute_model_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OmniGenerationModelRunner(OmniGPUModelRunner):
|
||||||
|
"""Non-autoregressive generation runner (e.g. Code2Wav).
|
||||||
|
|
||||||
|
Overrides ``execute_model`` to skip the tensor-only assertion and
|
||||||
|
``sample_tokens`` to construct ``pooler_output`` from multimodal
|
||||||
|
model outputs without performing token sampling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._gen_model_output: Any = None
|
||||||
|
self._gen_input_batch: Any = None
|
||||||
|
self._gen_kv_connector_output: Any = None
|
||||||
|
# Placeholder for ExecuteModelState.hidden_states — allocated
|
||||||
|
# once and reused every step to avoid per-forward allocation.
|
||||||
|
self._dummy_hidden = torch.zeros(1, dtype=self.dtype, device=self.device)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Async-chunk support: replace prompt_token_ids for cached requests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_async_chunk_updates(self, scheduler_output: SchedulerOutput) -> None:
|
||||||
|
"""In-place update cached requests whose prompt_token_ids changed.
|
||||||
|
|
||||||
|
In async_chunk mode, the ``ChunkTransferAdapter`` replaces
|
||||||
|
``Request.prompt_token_ids`` with new codec frames for each
|
||||||
|
chunk and resets ``num_computed_tokens`` to 0. The scheduler
|
||||||
|
propagates the new ``prompt_token_ids`` via
|
||||||
|
``OmniCachedRequestData``.
|
||||||
|
|
||||||
|
Instead of remove + re-add (which involves free_indices churn
|
||||||
|
and redundant model_state init), we update the existing slot
|
||||||
|
in-place. This is safe for Code2Wav because:
|
||||||
|
- No KV cache / rope state to reinitialize
|
||||||
|
- staged writes are applied once at the end
|
||||||
|
|
||||||
|
The old intermediate buffer for this slot is cleared here; the
|
||||||
|
inherited ``OmniGPUModelRunner.update_requests`` (called right after
|
||||||
|
this method in ``execute_model``) writes the current chunk state.
|
||||||
|
"""
|
||||||
|
cached = scheduler_output.scheduled_cached_reqs
|
||||||
|
if not cached.req_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(cached, OmniCachedRequestData):
|
||||||
|
return
|
||||||
|
|
||||||
|
new_prompt_ids = cached.prompt_token_ids
|
||||||
|
if not new_prompt_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
released_chunks: list[OmniNewRequestData] = []
|
||||||
|
|
||||||
|
for i, req_id in enumerate(cached.req_ids):
|
||||||
|
new_ids = new_prompt_ids.get(req_id)
|
||||||
|
if new_ids is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
req_idx = self.req_states.req_id_to_index.get(req_id)
|
||||||
|
if req_idx is None:
|
||||||
|
block_ids = cached.new_block_ids[i]
|
||||||
|
released_chunks.append(
|
||||||
|
OmniNewRequestData(
|
||||||
|
req_id=req_id,
|
||||||
|
prompt_token_ids=new_ids,
|
||||||
|
mm_features=[],
|
||||||
|
sampling_params=None,
|
||||||
|
pooling_params=None,
|
||||||
|
block_ids=block_ids if block_ids is not None else tuple(),
|
||||||
|
num_computed_tokens=0,
|
||||||
|
lora_request=None,
|
||||||
|
prompt_embeds=None,
|
||||||
|
prefill_token_ids=new_ids,
|
||||||
|
additional_information=cached.additional_information.get(req_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.model_state.intermediate_buffer.remove_request(req_idx)
|
||||||
|
|
||||||
|
# In-place update token state — same slot, no remove/re-add.
|
||||||
|
# .np[] = direct write (no GPU buffer); stage_write = GPU-synced.
|
||||||
|
n = len(new_ids)
|
||||||
|
self.req_states.prompt_len.np[req_idx] = n
|
||||||
|
self.req_states.prefill_len.np[req_idx] = n
|
||||||
|
self.req_states.total_len.stage_write_elem(req_idx, n)
|
||||||
|
self.req_states.all_token_ids.stage_write(req_idx, 0, new_ids)
|
||||||
|
self.req_states.num_computed_tokens.stage_write_elem(req_idx, 0)
|
||||||
|
self.req_states.num_computed_prefill_tokens[req_idx] = 0
|
||||||
|
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if released_chunks:
|
||||||
|
self.add_requests(SimpleNamespace(scheduled_new_reqs=released_chunks))
|
||||||
|
if updated:
|
||||||
|
self.req_states.apply_staged_writes()
|
||||||
|
|
||||||
|
def _release_generation_slots(self, input_batch: Any) -> None:
|
||||||
|
if not getattr(self.model_config, "async_chunk", False):
|
||||||
|
return
|
||||||
|
model_state = getattr(self, "model_state", None)
|
||||||
|
remove_request = getattr(self, "_remove_request", None)
|
||||||
|
if model_state is None or remove_request is None:
|
||||||
|
return
|
||||||
|
for i in range(input_batch.num_reqs):
|
||||||
|
req_id = input_batch.req_ids[i]
|
||||||
|
req_idx = int(input_batch.idx_mapping_np[i])
|
||||||
|
model_state.remove_request(req_idx)
|
||||||
|
remove_request(req_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# profile / warmup — skip sampler since there are no logits
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def profile_run(self) -> None:
|
||||||
|
"""Generation models have no KV cache — skip profiling.
|
||||||
|
|
||||||
|
Code2Wav shares GPU memory with the Talker stage (same device);
|
||||||
|
its memory footprint is managed via ``gpu_memory_utilization``
|
||||||
|
config, not profiled dynamically. Running the real model with
|
||||||
|
random input_ids causes out-of-bounds indexing in codec lookup
|
||||||
|
tables.
|
||||||
|
"""
|
||||||
|
torch.accelerator.synchronize()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# execute_model — run the generation model, store raw output
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def execute_model(
|
||||||
|
self,
|
||||||
|
scheduler_output: SchedulerOutput,
|
||||||
|
intermediate_tensors: IntermediateTensors | None = None,
|
||||||
|
dummy_run: bool = False,
|
||||||
|
skip_attn_for_dummy_run: bool = False,
|
||||||
|
is_profile: bool = False,
|
||||||
|
) -> ModelRunnerOutput | IntermediateTensors | None:
|
||||||
|
if not dummy_run:
|
||||||
|
self.finish_requests(scheduler_output)
|
||||||
|
self.free_states(scheduler_output)
|
||||||
|
# Handle async_chunk prompt_token_ids replacement for cached
|
||||||
|
# requests BEFORE add/update — update the existing slot
|
||||||
|
# in-place with the new chunk's tokens.
|
||||||
|
self._handle_async_chunk_updates(scheduler_output)
|
||||||
|
self.add_requests(scheduler_output)
|
||||||
|
self.update_requests(scheduler_output)
|
||||||
|
self.block_tables.apply_staged_writes()
|
||||||
|
if scheduler_output.total_num_scheduled_tokens == 0:
|
||||||
|
return self.kv_connector.no_forward(scheduler_output)
|
||||||
|
|
||||||
|
num_reqs = len(scheduler_output.num_scheduled_tokens)
|
||||||
|
num_toks = scheduler_output.total_num_scheduled_tokens
|
||||||
|
max_query_len = max(scheduler_output.num_scheduled_tokens.values())
|
||||||
|
uniform_tok_count = get_uniform_token_count(num_reqs, num_toks, max_query_len)
|
||||||
|
batch_desc, _ = self._dispatch_batch_descriptor(
|
||||||
|
num_reqs=num_reqs,
|
||||||
|
num_toks=num_toks,
|
||||||
|
uniform_tok_count=uniform_tok_count,
|
||||||
|
use_eager=is_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
if batch_desc.num_tokens == 0:
|
||||||
|
return self.kv_connector.no_forward(scheduler_output)
|
||||||
|
|
||||||
|
if not dummy_run:
|
||||||
|
input_batch = self.prepare_inputs(scheduler_output, batch_desc)
|
||||||
|
else:
|
||||||
|
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||||
|
|
||||||
|
input_batch = InputBatch.make_dummy(
|
||||||
|
batch_desc.num_reqs or num_reqs,
|
||||||
|
batch_desc.num_tokens,
|
||||||
|
self.input_buffers,
|
||||||
|
)
|
||||||
|
|
||||||
|
attn_metadata = None
|
||||||
|
slot_mappings_by_layer = None
|
||||||
|
|
||||||
|
inputs_embeds = None
|
||||||
|
if self.supports_mm_inputs and self.is_first_pp_rank:
|
||||||
|
# vLLM 0.23 dropped the req_states arg from get_mm_embeddings.
|
||||||
|
inputs_embeds = self.model_state.get_mm_embeddings(
|
||||||
|
scheduler_output.scheduled_encoder_inputs,
|
||||||
|
input_batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_inputs = {
|
||||||
|
"input_ids": input_batch.input_ids,
|
||||||
|
"positions": input_batch.positions,
|
||||||
|
"inputs_embeds": inputs_embeds,
|
||||||
|
"intermediate_tensors": intermediate_tensors,
|
||||||
|
**self.model_state.prepare_inputs(input_batch, self.req_states),
|
||||||
|
}
|
||||||
|
add_forward_compat_kwargs(model_inputs, input_batch, self.sampler)
|
||||||
|
|
||||||
|
batch_descriptor = BatchDescriptor(
|
||||||
|
num_tokens=input_batch.num_tokens_after_padding,
|
||||||
|
has_lora=self.lora_config is not None,
|
||||||
|
)
|
||||||
|
with set_forward_context(
|
||||||
|
attn_metadata,
|
||||||
|
self.vllm_config,
|
||||||
|
num_tokens=input_batch.num_tokens_after_padding,
|
||||||
|
cudagraph_runtime_mode=batch_desc.cg_mode,
|
||||||
|
batch_descriptor=batch_descriptor,
|
||||||
|
slot_mapping=slot_mappings_by_layer,
|
||||||
|
):
|
||||||
|
self.kv_connector.pre_forward(scheduler_output)
|
||||||
|
model_output = self.model(**model_inputs)
|
||||||
|
|
||||||
|
kv_connector_output = self.kv_connector.post_forward(scheduler_output.finished_req_ids)
|
||||||
|
|
||||||
|
# Convert raw model output to OmniOutput.
|
||||||
|
if not isinstance(model_output, OmniOutput):
|
||||||
|
buffer_list = self.model_state.intermediate_buffer.gather(input_batch)
|
||||||
|
try:
|
||||||
|
model_output = self.model.make_omni_output(
|
||||||
|
model_output,
|
||||||
|
model_intermediate_buffer=buffer_list,
|
||||||
|
runtime_additional_information=buffer_list,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
"make_omni_output failed; returning empty output",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
self._gen_model_output = None
|
||||||
|
self.execute_model_state = _make_execute_model_state(
|
||||||
|
input_batch=input_batch,
|
||||||
|
attn_metadata=None,
|
||||||
|
slot_mappings_by_layer=None,
|
||||||
|
hidden_states=self._dummy_hidden,
|
||||||
|
aux_hidden_states=None,
|
||||||
|
finished_req_ids=scheduler_output.finished_req_ids,
|
||||||
|
kv_connector_output=kv_connector_output,
|
||||||
|
num_tokens_across_dp=None,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._gen_model_output = model_output
|
||||||
|
self._gen_input_batch = input_batch
|
||||||
|
self._gen_kv_connector_output = kv_connector_output
|
||||||
|
|
||||||
|
# ExecuteModelState is required by the upstream engine loop
|
||||||
|
# (EngineCore checks execute_model_state is not None before
|
||||||
|
# calling sample_tokens).
|
||||||
|
self.execute_model_state = _make_execute_model_state(
|
||||||
|
input_batch=input_batch,
|
||||||
|
attn_metadata=None,
|
||||||
|
slot_mappings_by_layer=None,
|
||||||
|
hidden_states=self._dummy_hidden,
|
||||||
|
aux_hidden_states=None,
|
||||||
|
finished_req_ids=scheduler_output.finished_req_ids,
|
||||||
|
kv_connector_output=kv_connector_output,
|
||||||
|
num_tokens_across_dp=None,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# sample_tokens — build pooler_output, no actual sampling
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def sample_tokens(self, grammar_output: GrammarOutput | None = None) -> OmniModelRunnerOutput | None:
|
||||||
|
model_output = self._gen_model_output
|
||||||
|
input_batch = self._gen_input_batch
|
||||||
|
kv_connector_output = self._gen_kv_connector_output
|
||||||
|
self._gen_model_output = None
|
||||||
|
self._gen_input_batch = None
|
||||||
|
self._gen_kv_connector_output = None
|
||||||
|
self.execute_model_state = None
|
||||||
|
|
||||||
|
if model_output is None or input_batch is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
num_reqs = input_batch.num_reqs
|
||||||
|
|
||||||
|
# Mark all scheduled tokens as computed so the scheduler does
|
||||||
|
# not re-schedule them. Unlike AR stages we do NOT call
|
||||||
|
# self.postprocess() — that kernel advances num_computed_tokens
|
||||||
|
# by 1 and emits sampled tokens, which would cause check_stop
|
||||||
|
# to fire. Instead, set num_computed_tokens = prompt_len
|
||||||
|
# directly, matching V1's behavior.
|
||||||
|
for i in range(num_reqs):
|
||||||
|
req_idx = int(input_batch.idx_mapping_np[i])
|
||||||
|
prompt_len = int(self.req_states.prompt_len.np[req_idx])
|
||||||
|
self.req_states.num_computed_tokens.stage_write_elem(req_idx, prompt_len)
|
||||||
|
self.req_states.num_computed_tokens.apply_write()
|
||||||
|
|
||||||
|
# Build pooler_output from OmniOutput.multimodal_outputs (dict).
|
||||||
|
pooler_output = self._build_pooler_output(model_output, num_reqs)
|
||||||
|
|
||||||
|
req_ids = input_batch.req_ids
|
||||||
|
|
||||||
|
# Generation models don't do token sampling. Return one empty
|
||||||
|
# list per request so the scheduler does NOT trigger check_stop
|
||||||
|
# (which would prematurely finish the request). The request
|
||||||
|
# stays RUNNING until the orchestrator marks it done via
|
||||||
|
# chunk_transfer_adapter.finished_requests.
|
||||||
|
sampled_token_ids: list[list[int]] = [[] for _ in range(len(req_ids))]
|
||||||
|
self._release_generation_slots(input_batch)
|
||||||
|
|
||||||
|
# Per-request multimodal_output (tensor-only) built from the same payloads.
|
||||||
|
# OmniGenerationScheduler indexes this as mm_outputs[req_index], and for the
|
||||||
|
# final (code2wav) stage it becomes the user-facing outputs[0].multimodal_output.
|
||||||
|
# Must be a per-request list (not the raw dict), matching the V1 runner.
|
||||||
|
multimodal_outputs = [_ensure_tensor_values(p) if p else {} for p in pooler_output]
|
||||||
|
|
||||||
|
return OmniModelRunnerOutput(
|
||||||
|
req_ids=req_ids,
|
||||||
|
req_id_to_index={rid: i for i, rid in enumerate(req_ids)},
|
||||||
|
sampled_token_ids=sampled_token_ids,
|
||||||
|
pooler_output=pooler_output,
|
||||||
|
multimodal_outputs=multimodal_outputs,
|
||||||
|
kv_connector_output=kv_connector_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_pooler_output(
|
||||||
|
model_output: OmniOutput,
|
||||||
|
num_reqs: int,
|
||||||
|
) -> list[dict[str, Any] | None]:
|
||||||
|
"""Extract per-request pooler payloads from model output.
|
||||||
|
|
||||||
|
Code2Wav's ``make_omni_output`` returns
|
||||||
|
``{"model_outputs": [tensor_per_req, ...], "sr": [...]}``,
|
||||||
|
so each value is a ``list`` with ``len == num_reqs``.
|
||||||
|
"""
|
||||||
|
mm = model_output.multimodal_outputs
|
||||||
|
if not isinstance(mm, dict):
|
||||||
|
logger.warning(
|
||||||
|
"Unexpected multimodal_outputs type: %s; returning empty pooler_output",
|
||||||
|
type(mm).__name__ if mm is not None else "None",
|
||||||
|
)
|
||||||
|
return [None] * num_reqs
|
||||||
|
|
||||||
|
pooler: list[dict[str, Any] | None] = []
|
||||||
|
for i in range(num_reqs):
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for key, val in mm.items():
|
||||||
|
# Primary path: val is list[Tensor] with len == num_reqs
|
||||||
|
# (Code2Wav make_omni_output format).
|
||||||
|
if isinstance(val, list) and len(val) == num_reqs:
|
||||||
|
out = val[i]
|
||||||
|
payload[key] = out.detach().cpu().contiguous() if isinstance(out, torch.Tensor) else out
|
||||||
|
elif isinstance(val, torch.Tensor):
|
||||||
|
if val.dim() > 0 and val.shape[0] == num_reqs:
|
||||||
|
payload[key] = val[i].detach().cpu().contiguous()
|
||||||
|
else:
|
||||||
|
payload[key] = val.detach().cpu().contiguous()
|
||||||
|
else:
|
||||||
|
payload[key] = val
|
||||||
|
pooler.append(payload)
|
||||||
|
return pooler
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
"""Omni v2 GPU model runner hooks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from vllm.config.compilation import CUDAGraphMode
|
||||||
|
from vllm.forward_context import set_forward_context
|
||||||
|
from vllm.logger import init_logger
|
||||||
|
from vllm.v1.core.sched.output import SchedulerOutput
|
||||||
|
from vllm.v1.worker.gpu.model_runner import (
|
||||||
|
BatchDescriptor,
|
||||||
|
BatchExecutionDescriptor,
|
||||||
|
ExecuteModelState,
|
||||||
|
GPUModelRunner,
|
||||||
|
IntermediateTensors,
|
||||||
|
build_slot_mappings_by_layer,
|
||||||
|
get_uniform_token_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vllm_omni.compat import make_filtered_namedtuple
|
||||||
|
from vllm_omni.model_executor.models.output_templates import OmniOutput
|
||||||
|
from vllm_omni.worker_v2.forward_compat import add_forward_compat_kwargs
|
||||||
|
from vllm_omni.worker_v2.model_states import init_omni_model_state
|
||||||
|
from vllm_omni.worker_v2.model_states.intermediate_buffer import (
|
||||||
|
_resolve_additional_information,
|
||||||
|
)
|
||||||
|
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
_model_state_patch_lock = threading.RLock()
|
||||||
|
_GPU_MODEL_RUNNER_HAS_SHUTDOWN = hasattr(GPUModelRunner, "shutdown")
|
||||||
|
# Fields the omni runners still pass to ExecuteModelState that current vLLM no
|
||||||
|
# longer declares on the namedtuple. They are filtered out by
|
||||||
|
# make_filtered_namedtuple and carried out-of-band instead:
|
||||||
|
# - num_tokens_across_dp: only consumed inside execute_model's forward context.
|
||||||
|
# - kv_connector_output: removed from ExecuteModelState in vLLM 0.23.0 (post_forward
|
||||||
|
# now runs in sample_tokens). Carried via self._last_kv_connector_output (AR) /
|
||||||
|
# self._gen_kv_connector_output (generation).
|
||||||
|
_KNOWN_EXECUTE_MODEL_STATE_COMPAT_FIELDS = {"num_tokens_across_dp", "kv_connector_output"}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_execute_model_state(**kwargs):
|
||||||
|
state, unknown = make_filtered_namedtuple(
|
||||||
|
ExecuteModelState,
|
||||||
|
known_extra_fields=_KNOWN_EXECUTE_MODEL_STATE_COMPAT_FIELDS,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if unknown:
|
||||||
|
logger.warning("Unknown fields passed to ExecuteModelState: %s", sorted(unknown))
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_capture_tensor_unwrap(model: Any) -> bool:
|
||||||
|
return bool(getattr(model, "_returns_tuple", False) or getattr(model, "model_stage", None) == "thinker")
|
||||||
|
|
||||||
|
|
||||||
|
class OmniGPUModelRunner(GPUModelRunner):
|
||||||
|
"""Thin layer over v2 ``GPUModelRunner`` for Omni lifecycle hooks."""
|
||||||
|
|
||||||
|
model_state: OmniModelState
|
||||||
|
_last_aux_output: Any
|
||||||
|
_last_multimodal_outputs: dict[str, Any] | None
|
||||||
|
_model_returns_tuple: bool
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
if _GPU_MODEL_RUNNER_HAS_SHUTDOWN:
|
||||||
|
super().shutdown()
|
||||||
|
|
||||||
|
def load_model(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
import vllm.v1.worker.gpu.model_runner as _mr_module
|
||||||
|
|
||||||
|
with _model_state_patch_lock:
|
||||||
|
_orig = _mr_module.init_model_state
|
||||||
|
_mr_module.init_model_state = init_omni_model_state
|
||||||
|
try:
|
||||||
|
super().load_model(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
_mr_module.init_model_state = _orig
|
||||||
|
self._last_aux_output = None
|
||||||
|
self._last_multimodal_outputs = None
|
||||||
|
self._last_kv_connector_output = None
|
||||||
|
self._model_returns_tuple = _needs_capture_tensor_unwrap(self.model)
|
||||||
|
self._exclude_full_graph = self._model_returns_tuple or hasattr(self.model, "_last_captured_layers")
|
||||||
|
|
||||||
|
# Preprocess models own embedding buffers; encoder_runner sizing would mismatch.
|
||||||
|
if getattr(self.model, "has_preprocess", False) and self.supports_mm_inputs:
|
||||||
|
self.supports_mm_inputs = False
|
||||||
|
self.encoder_cache = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CUDA Graph: conditionally exclude FULL mode
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def capture_model(self) -> int:
|
||||||
|
"""Handle CUDA graph capture for Omni models.
|
||||||
|
|
||||||
|
Exclude FULL mode for tuple-returning models because
|
||||||
|
``run_fullgraph`` bypasses Python-level tuple intercept.
|
||||||
|
|
||||||
|
For PIECEWISE capture, the warmup pass runs with
|
||||||
|
``CUDAGraphMode.NONE`` which hits ``torch.empty_like(hidden_states)``
|
||||||
|
in the cudagraph framework. If the model returns a tuple, that call
|
||||||
|
crashes. We temporarily wrap the model's forward to extract only the
|
||||||
|
tensor part during capture, then restore the original forward.
|
||||||
|
"""
|
||||||
|
if self._exclude_full_graph:
|
||||||
|
mgr = self.cudagraph_manager
|
||||||
|
# NOTE: ``_capture_descs`` / ``_candidates`` are private CudaGraphManager
|
||||||
|
# internals (present and same-typed as of vLLM 0.23.0). Guard with hasattr
|
||||||
|
# so a future vLLM that renames/removes them degrades to a warning instead
|
||||||
|
# of an AttributeError; the worst case is FULL graphs not being excluded.
|
||||||
|
if hasattr(mgr, "_capture_descs") and hasattr(mgr, "_candidates"):
|
||||||
|
if CUDAGraphMode.FULL in mgr._capture_descs:
|
||||||
|
del mgr._capture_descs[CUDAGraphMode.FULL]
|
||||||
|
for i, descs in enumerate(mgr._candidates):
|
||||||
|
mgr._candidates[i] = [d for d in descs if d.cg_mode != CUDAGraphMode.FULL]
|
||||||
|
logger.info("Excluded FULL CUDA graph capture for Omni model. PIECEWISE graphs will still be captured.")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"CudaGraphManager lacks _capture_descs/_candidates; cannot exclude "
|
||||||
|
"FULL graph capture for tuple-returning Omni model. vLLM internals "
|
||||||
|
"may have changed — verify omni_model_runner.capture_model()."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wrap model forward during capture so tuple returns don't crash
|
||||||
|
# torch.empty_like() in the PIECEWISE warmup pass.
|
||||||
|
if self._model_returns_tuple:
|
||||||
|
original_forward = self.model.forward
|
||||||
|
|
||||||
|
def _capture_forward(*args: Any, **kwargs: Any) -> torch.Tensor:
|
||||||
|
output = original_forward(*args, **kwargs)
|
||||||
|
if isinstance(output, OmniOutput):
|
||||||
|
return output.text_hidden_states
|
||||||
|
if isinstance(output, tuple):
|
||||||
|
return output[0]
|
||||||
|
return output
|
||||||
|
|
||||||
|
self.model.forward = _capture_forward # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
return super().capture_model()
|
||||||
|
finally:
|
||||||
|
self.model.forward = original_forward # type: ignore[assignment]
|
||||||
|
return super().capture_model()
|
||||||
|
|
||||||
|
def _dispatch_batch_descriptor(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
num_reqs: int,
|
||||||
|
num_toks: int,
|
||||||
|
uniform_tok_count: int,
|
||||||
|
use_eager: bool,
|
||||||
|
):
|
||||||
|
if use_eager:
|
||||||
|
batch_desc = BatchExecutionDescriptor(
|
||||||
|
cg_mode=CUDAGraphMode.NONE,
|
||||||
|
num_tokens=num_toks,
|
||||||
|
num_reqs=num_reqs,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
batch_desc = self.cudagraph_manager.dispatch(num_reqs, num_toks, uniform_tok_count)
|
||||||
|
if self.dp_size > 1:
|
||||||
|
from vllm.v1.worker.gpu.dp_utils import sync_cudagraph_and_dp_padding
|
||||||
|
|
||||||
|
return sync_cudagraph_and_dp_padding(
|
||||||
|
self.cudagraph_manager,
|
||||||
|
batch_desc,
|
||||||
|
num_toks,
|
||||||
|
num_reqs,
|
||||||
|
uniform_tok_count,
|
||||||
|
self.dp_size,
|
||||||
|
self.dp_rank,
|
||||||
|
)
|
||||||
|
return batch_desc, None
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def execute_model(
|
||||||
|
self,
|
||||||
|
scheduler_output: SchedulerOutput,
|
||||||
|
intermediate_tensors: IntermediateTensors | None = None,
|
||||||
|
dummy_run: bool = False,
|
||||||
|
skip_attn_for_dummy_run: bool = False,
|
||||||
|
is_profile: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
if not dummy_run:
|
||||||
|
self.finish_requests(scheduler_output)
|
||||||
|
self.free_states(scheduler_output)
|
||||||
|
self.add_requests(scheduler_output)
|
||||||
|
self.update_requests(scheduler_output)
|
||||||
|
self.block_tables.apply_staged_writes()
|
||||||
|
if scheduler_output.total_num_scheduled_tokens == 0:
|
||||||
|
return self.kv_connector.no_forward(scheduler_output)
|
||||||
|
|
||||||
|
num_reqs = len(scheduler_output.num_scheduled_tokens)
|
||||||
|
num_toks = scheduler_output.total_num_scheduled_tokens
|
||||||
|
max_query_len = max(scheduler_output.num_scheduled_tokens.values())
|
||||||
|
uniform_tok_count = get_uniform_token_count(num_reqs, num_toks, max_query_len)
|
||||||
|
# Encoder-decoder models: disable compilation when encoder inputs
|
||||||
|
# are scheduled (dynamic cross-attention cache updates).
|
||||||
|
skip_compiled = self.is_encoder_decoder and bool(scheduler_output.scheduled_encoder_inputs)
|
||||||
|
batch_desc, num_tokens_across_dp = self._dispatch_batch_descriptor(
|
||||||
|
num_reqs=num_reqs,
|
||||||
|
num_toks=num_toks,
|
||||||
|
uniform_tok_count=uniform_tok_count,
|
||||||
|
use_eager=is_profile or skip_compiled,
|
||||||
|
)
|
||||||
|
|
||||||
|
if batch_desc.num_tokens == 0:
|
||||||
|
return self.kv_connector.no_forward(scheduler_output)
|
||||||
|
|
||||||
|
if not dummy_run:
|
||||||
|
input_batch = self.prepare_inputs(scheduler_output, batch_desc)
|
||||||
|
block_tables, slot_mappings = self.prepare_attn(input_batch)
|
||||||
|
|
||||||
|
if self.lora_config:
|
||||||
|
lora_inputs = self.lora_state.make_lora_inputs(
|
||||||
|
input_batch.req_ids,
|
||||||
|
input_batch.idx_mapping_np,
|
||||||
|
input_batch.num_scheduled_tokens,
|
||||||
|
)
|
||||||
|
self._set_active_loras(*lora_inputs)
|
||||||
|
else:
|
||||||
|
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||||
|
|
||||||
|
input_batch = InputBatch.make_dummy(
|
||||||
|
batch_desc.num_reqs or num_reqs,
|
||||||
|
batch_desc.num_tokens,
|
||||||
|
self.input_buffers,
|
||||||
|
)
|
||||||
|
if not skip_attn_for_dummy_run:
|
||||||
|
block_tables, slot_mappings = self.prepare_dummy_attn(input_batch)
|
||||||
|
else:
|
||||||
|
block_tables = None
|
||||||
|
slot_mappings = None
|
||||||
|
|
||||||
|
attn_metadata = None
|
||||||
|
slot_mappings_by_layer = None
|
||||||
|
if not (dummy_run and skip_attn_for_dummy_run):
|
||||||
|
assert slot_mappings is not None
|
||||||
|
slot_mappings_by_layer = build_slot_mappings_by_layer(slot_mappings, self.kv_cache_config)
|
||||||
|
assert block_tables is not None
|
||||||
|
attn_metadata = self.model_state.prepare_attn(
|
||||||
|
input_batch,
|
||||||
|
batch_desc.cg_mode,
|
||||||
|
block_tables,
|
||||||
|
slot_mappings,
|
||||||
|
self.attn_groups,
|
||||||
|
self.kv_cache_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
inputs_embeds = None
|
||||||
|
if self.supports_mm_inputs and self.is_first_pp_rank:
|
||||||
|
# vLLM 0.23 dropped the req_states arg from get_mm_embeddings.
|
||||||
|
inputs_embeds = self.model_state.get_mm_embeddings(
|
||||||
|
scheduler_output.scheduled_encoder_inputs,
|
||||||
|
input_batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_inputs: dict[str, Any] = {
|
||||||
|
"input_ids": input_batch.input_ids,
|
||||||
|
"positions": input_batch.positions,
|
||||||
|
"inputs_embeds": inputs_embeds,
|
||||||
|
"intermediate_tensors": intermediate_tensors,
|
||||||
|
**self.model_state.prepare_inputs(input_batch, self.req_states),
|
||||||
|
}
|
||||||
|
add_forward_compat_kwargs(model_inputs, input_batch, self.sampler)
|
||||||
|
if not self.is_first_pp_rank:
|
||||||
|
model_inputs["input_ids"] = None
|
||||||
|
model_inputs["inputs_embeds"] = None
|
||||||
|
assert intermediate_tensors is not None
|
||||||
|
|
||||||
|
# ★ PRE-FORWARD: per-request preprocess + batched MTP.
|
||||||
|
# Runs for ALL graph modes (FULL, PIECEWISE, NONE).
|
||||||
|
# For FULL graph: OmniModelState provides a static inputs_embeds
|
||||||
|
# buffer that was captured by the graph. Preprocess writes
|
||||||
|
# in-place to this buffer, and FULL graph replay reads the
|
||||||
|
# updated values from the same tensor address.
|
||||||
|
if not dummy_run:
|
||||||
|
self.model_state.run_preprocess(input_batch, model_inputs)
|
||||||
|
|
||||||
|
# --- Model forward ---
|
||||||
|
if batch_desc.cg_mode == CUDAGraphMode.FULL:
|
||||||
|
# FULL graph replay. Preprocess already wrote to the static
|
||||||
|
# inputs_embeds buffer above.
|
||||||
|
assert self.cudagraph_manager is not None
|
||||||
|
self.kv_connector.pre_forward(scheduler_output)
|
||||||
|
model_output = self.cudagraph_manager.run_fullgraph(batch_desc)
|
||||||
|
hidden_states = model_output
|
||||||
|
self._last_aux_output = None
|
||||||
|
self._last_multimodal_outputs = None
|
||||||
|
else:
|
||||||
|
batch_descriptor = BatchDescriptor(
|
||||||
|
num_tokens=input_batch.num_tokens_after_padding,
|
||||||
|
has_lora=self.lora_config is not None,
|
||||||
|
)
|
||||||
|
with set_forward_context(
|
||||||
|
attn_metadata,
|
||||||
|
self.vllm_config,
|
||||||
|
num_tokens=input_batch.num_tokens_after_padding,
|
||||||
|
cudagraph_runtime_mode=batch_desc.cg_mode,
|
||||||
|
num_tokens_across_dp=num_tokens_across_dp,
|
||||||
|
batch_descriptor=batch_descriptor,
|
||||||
|
slot_mapping=slot_mappings_by_layer,
|
||||||
|
skip_compiled=skip_compiled,
|
||||||
|
):
|
||||||
|
self.kv_connector.pre_forward(scheduler_output)
|
||||||
|
model_output = self.model(**model_inputs)
|
||||||
|
|
||||||
|
# Extract hidden_states from model output.
|
||||||
|
self._last_aux_output = None
|
||||||
|
self._last_multimodal_outputs = None
|
||||||
|
if isinstance(model_output, OmniOutput):
|
||||||
|
hidden_states = model_output.text_hidden_states
|
||||||
|
if model_output.multimodal_outputs:
|
||||||
|
self._last_multimodal_outputs = model_output.multimodal_outputs
|
||||||
|
elif isinstance(model_output, tuple) and len(model_output) == 2:
|
||||||
|
hidden_states, self._last_aux_output = model_output
|
||||||
|
if hasattr(self.model, "_last_captured_layers"):
|
||||||
|
self.model._last_captured_layers = self._last_aux_output
|
||||||
|
else:
|
||||||
|
hidden_states = model_output
|
||||||
|
|
||||||
|
if not dummy_run and isinstance(hidden_states, torch.Tensor):
|
||||||
|
self.model_state.run_postprocess(hidden_states, input_batch)
|
||||||
|
|
||||||
|
kv_connector_output = self.kv_connector.post_forward(scheduler_output.finished_req_ids)
|
||||||
|
self._last_kv_connector_output = kv_connector_output
|
||||||
|
self.execute_model_state = _make_execute_model_state(
|
||||||
|
input_batch=input_batch,
|
||||||
|
attn_metadata=attn_metadata,
|
||||||
|
slot_mappings_by_layer=slot_mappings_by_layer,
|
||||||
|
hidden_states=hidden_states,
|
||||||
|
aux_hidden_states=None,
|
||||||
|
finished_req_ids=scheduler_output.finished_req_ids,
|
||||||
|
kv_connector_output=kv_connector_output,
|
||||||
|
num_tokens_across_dp=num_tokens_across_dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.is_last_pp_rank:
|
||||||
|
assert isinstance(hidden_states, IntermediateTensors)
|
||||||
|
hidden_states.kv_connector_output = kv_connector_output
|
||||||
|
return hidden_states
|
||||||
|
assert isinstance(hidden_states, torch.Tensor)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Request lifecycle: update intermediate buffer from cached requests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def update_requests(self, scheduler_output: SchedulerOutput) -> None:
|
||||||
|
"""Merge updated additional_information into intermediate_buffer.
|
||||||
|
|
||||||
|
In async_chunk mode, chunk_transfer_adapter attaches updated
|
||||||
|
additional_information (e.g. thinker_decode_embeddings) to
|
||||||
|
OmniCachedRequestData for cached requests every schedule step.
|
||||||
|
Upstream GPUModelRunner.update_requests does not handle this
|
||||||
|
field, so we merge it into the intermediate buffer here.
|
||||||
|
"""
|
||||||
|
super().update_requests(scheduler_output)
|
||||||
|
|
||||||
|
cached = scheduler_output.scheduled_cached_reqs
|
||||||
|
addl_info = getattr(cached, "additional_information", None)
|
||||||
|
if not addl_info:
|
||||||
|
return
|
||||||
|
for req_id, info in addl_info.items():
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
req_idx = self.req_states.req_id_to_index.get(req_id)
|
||||||
|
if req_idx is None:
|
||||||
|
continue
|
||||||
|
resolved = _resolve_additional_information(info)
|
||||||
|
if resolved:
|
||||||
|
self.model_state.intermediate_buffer.update(req_idx, resolved)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Request lifecycle: clean up intermediate buffer on finish
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def finish_requests(self, scheduler_output: SchedulerOutput) -> None:
|
||||||
|
# IMPORTANT: Must query req_id_to_index BEFORE super().finish_requests()
|
||||||
|
# because super() calls req_states.remove_request(req_id) which pops the
|
||||||
|
# mapping and returns the slot index to free_indices.
|
||||||
|
finished = scheduler_output.finished_req_ids
|
||||||
|
preempted = scheduler_output.preempted_req_ids
|
||||||
|
all_done = finished | preempted if preempted else finished
|
||||||
|
for req_id in all_done:
|
||||||
|
idx = self.req_states.req_id_to_index.get(req_id)
|
||||||
|
if idx is not None:
|
||||||
|
self.model_state.remove_request(idx)
|
||||||
|
super().finish_requests(scheduler_output)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Re-export OmniModelRunnerOutput for worker_v2 consumers."""
|
||||||
|
|
||||||
|
from vllm_omni.outputs import OmniModelRunnerOutput
|
||||||
|
|
||||||
|
__all__ = ["OmniModelRunnerOutput"]
|
||||||
Reference in new issue
Block a user