diff --git a/.buildkite/ci_config_intel.yaml b/.buildkite/ci_config_intel.yaml index a1c0091e0f10..cd6bec005d11 100644 --- a/.buildkite/ci_config_intel.yaml +++ b/.buildkite/ci_config_intel.yaml @@ -2,17 +2,17 @@ name: vllm_intel_ci job_dirs: - ".buildkite/intel_jobs" run_all_patterns: + - ".buildkite/ci_config_intel.yaml" + - ".buildkite/scripts/hardware_ci/run-intel-test.sh" - "docker/Dockerfile" + - "docker/Dockerfile.xpu" - "CMakeLists.txt" - "requirements/common.txt" - "requirements/xpu.txt" - - "requirements/build/cuda.txt" - - "requirements/test/cuda.txt" - "setup.py" - "csrc/" - "cmake/" run_all_exclude_patterns: - - "docker/Dockerfile." - "csrc/cpu/" - "csrc/rocm/" - "cmake/hipify.py" diff --git a/.buildkite/ci_config_rocm.yaml b/.buildkite/ci_config_rocm.yaml index 23f323400719..408ccee58ed1 100644 --- a/.buildkite/ci_config_rocm.yaml +++ b/.buildkite/ci_config_rocm.yaml @@ -8,6 +8,7 @@ run_all_patterns: - "docker/docker-bake-rocm.hcl" - ".buildkite/hardware_tests/amd.yaml" - ".buildkite/scripts/ci-bake-rocm.sh" + - ".buildkite/scripts/rocm/" - ".buildkite/scripts/hardware_ci/run-amd-test.py" - ".buildkite/scripts/hardware_ci/run-amd-test.sh" - "CMakeLists.txt" diff --git a/.buildkite/hardware_tests/amd.yaml b/.buildkite/hardware_tests/amd.yaml index c2510f38aabb..d47a7e394a46 100644 --- a/.buildkite/hardware_tests/amd.yaml +++ b/.buildkite/hardware_tests/amd.yaml @@ -1,18 +1,45 @@ group: Hardware - AMD Build + +# ROCm image flow: +# 1. Refresh the long-lived ROCm base image only when Dockerfile.rocm_base changes. +# 2. Build ci_base from either the stable base or the freshly refreshed base. +# 3. Build the per-commit ROCm CI image and smoke-test it before GPU jobs run. steps: + - label: "AMD: :docker: refresh ROCm base" + key: refresh-rocm-base-amd + depends_on: [] + device: amd_cpu + no_plugin: true + commands: + - bash .buildkite/scripts/rocm/refresh-base-image.sh + env: + DOCKER_BUILDKIT: "1" + BUILDKIT_PROGRESS: "tty" + TERM: "xterm-256color" + retry: + automatic: + - exit_status: -1 # Agent was lost + limit: 1 + - exit_status: -10 # Agent was lost + limit: 1 + # Ensure ci_base is up-to-date before building the test image. # Compares a content hash of ci_base-affecting files against the remote # image label. If hashes match the build is skipped (< 30 s); if they # differ ci_base is rebuilt and pushed automatically. - label: "AMD: :docker: ensure ci_base" key: ensure-ci-base-amd - depends_on: [] + soft_fail: false + depends_on: + - refresh-rocm-base-amd device: amd_cpu no_plugin: true commands: - - bash .buildkite/scripts/ci-bake-rocm.sh ci-base-rocm-ci-with-deps + - bash .buildkite/scripts/rocm/build-ci-base.sh env: DOCKER_BUILDKIT: "1" + BUILDKIT_PROGRESS: "tty" + TERM: "xterm-256color" VLLM_BAKE_FILE: "docker/docker-bake-rocm.hcl" PYTORCH_ROCM_ARCH: "gfx90a;gfx942;gfx950" REMOTE_VLLM: "1" @@ -26,40 +53,18 @@ steps: - label: "AMD: :docker: build test image and artifacts" key: image-build-amd + soft_fail: false depends_on: - ensure-ci-base-amd device: amd_cpu no_plugin: true commands: - - | - if [[ "${ROCM_CI_ARTIFACT_ONLY:-0}" == "1" ]]; then - echo "ROCM_CI_ARTIFACT_ONLY=1; building ROCm wheel artifact only" - IMAGE_TAG="" bash .buildkite/scripts/ci-bake-rocm.sh test-rocm-ci-with-artifacts - else - bash .buildkite/scripts/ci-bake-rocm.sh test-rocm-ci-with-wheel - fi - - | - docker run --rm --network=none --entrypoint /bin/bash "rocm/vllm-ci:${BUILDKITE_COMMIT}" -ec ' - if [ ! -d /vllm-workspace ]; then echo Missing directory: /vllm-workspace >&2; exit 1; fi - if [ ! -d /vllm-workspace/tests ]; then echo Missing directory: /vllm-workspace/tests >&2; exit 1; fi - if [ ! -d /vllm-workspace/src/vllm ]; then echo Missing directory: /vllm-workspace/src/vllm >&2; exit 1; fi - if [ ! -x /vllm-workspace/src/vllm/vllm-rs ]; then echo Missing executable: /vllm-workspace/src/vllm/vllm-rs >&2; exit 1; fi - command -v python3 - command -v uv - command -v pytest - if ! command -v amd-smi >/dev/null 2>&1 && ! command -v rocminfo >/dev/null 2>&1; then - echo No ROCm CLI found in image >&2 - exit 1 - fi - python3 - <- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'bash .buildkite/scripts/hardware_ci/run-intel-ci-test.sh example' + - label: "XPU V1 test" + depends_on: + - image-build-xpu + timeout_in_minutes: 30 + optional: true + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + source_file_dependencies: + - .buildkite/hardware_tests/intel_xpu_ci/test-intel.yaml + - .buildkite/scripts/hardware_ci/run-intel-ci-test.sh + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'bash .buildkite/scripts/hardware_ci/run-intel-ci-test.sh v1' + - label: "XPU server test" + depends_on: + - image-build-xpu + timeout_in_minutes: 30 + optional: true + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + source_file_dependencies: + - .buildkite/hardware_tests/intel_xpu_ci/test-intel.yaml + - .buildkite/scripts/hardware_ci/run-intel-ci-test.sh + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'bash .buildkite/scripts/hardware_ci/run-intel-ci-test.sh server' diff --git a/.buildkite/image_build/image_build.sh b/.buildkite/image_build/image_build.sh index 10c03c3e1773..6c8d0b9b36c4 100755 --- a/.buildkite/image_build/image_build.sh +++ b/.buildkite/image_build/image_build.sh @@ -79,12 +79,18 @@ setup_buildx_builder() { docker buildx ls | grep -E '^\*|^NAME' || docker buildx ls } +annotate_image_tags() { + .buildkite/scripts/annotate-image-build.sh \ + "${IMAGE_TAG:-}" "${IMAGE_TAG_LATEST:-}" +} + check_and_skip_if_image_exists() { if [[ -n "${IMAGE_TAG:-}" ]]; then echo "--- :mag: Checking if image exists" if docker manifest inspect "${IMAGE_TAG}" >/dev/null 2>&1; then echo "Image already exists: ${IMAGE_TAG}" echo "Skipping build" + annotate_image_tags exit 0 fi echo "Image not found, proceeding with build" @@ -254,3 +260,5 @@ echo "--- :docker: Building ${TARGET}" docker --debug buildx bake -f "${VLLM_BAKE_FILE_PATH}" -f "${CI_HCL_PATH}" --progress plain "${TARGET}" echo "--- :white_check_mark: Build complete" + +annotate_image_tags diff --git a/.buildkite/image_build/image_build_arm64.sh b/.buildkite/image_build/image_build_arm64.sh index 5baa55a19659..3f73987846c9 100755 --- a/.buildkite/image_build/image_build_arm64.sh +++ b/.buildkite/image_build/image_build_arm64.sh @@ -9,29 +9,30 @@ fi REGISTRY=$1 REPO=$2 BUILDKITE_COMMIT=$3 +IMAGE="$REGISTRY/$REPO:$BUILDKITE_COMMIT-arm64" # authenticate with AWS ECR aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY" || true # skip build if image already exists -if [[ -z $(docker manifest inspect "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-arm64) ]]; then - echo "Image not found, proceeding with build..." -else +if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then echo "Image found" - exit 0 +else + echo "Image not found, proceeding with build..." + # build for arm64 GPU targets: Grace/GH200 (sm_90) and DGX Spark/GB10 + # (sm_121, family-covered by 12.0 under CUDA 13) + docker build --file docker/Dockerfile \ + --platform linux/arm64 \ + --build-arg max_jobs=16 \ + --build-arg nvcc_threads=4 \ + --build-arg torch_cuda_arch_list="9.0 12.0" \ + --build-arg USE_SCCACHE=1 \ + --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ + --tag "$IMAGE" \ + --target test \ + --progress plain . + # push + docker push "$IMAGE" fi -# build (Grace/GH200 is the arm64 GPU target; sm_90) -docker build --file docker/Dockerfile \ - --platform linux/arm64 \ - --build-arg max_jobs=16 \ - --build-arg nvcc_threads=4 \ - --build-arg torch_cuda_arch_list="9.0" \ - --build-arg USE_SCCACHE=1 \ - --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ - --tag "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-arm64 \ - --target test \ - --progress plain . - -# push -docker push "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-arm64 +.buildkite/scripts/annotate-image-build.sh "$IMAGE" diff --git a/.buildkite/image_build/image_build_cpu.sh b/.buildkite/image_build/image_build_cpu.sh index 035f070ab891..3ac1a110f601 100755 --- a/.buildkite/image_build/image_build_cpu.sh +++ b/.buildkite/image_build/image_build_cpu.sh @@ -9,26 +9,26 @@ fi REGISTRY=$1 REPO=$2 BUILDKITE_COMMIT=$3 +IMAGE="$REGISTRY/$REPO:$BUILDKITE_COMMIT-cpu" # authenticate with AWS ECR aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY" || true # skip build if image already exists -if [[ -z $(docker manifest inspect "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-cpu) ]]; then - echo "Image not found, proceeding with build..." -else +if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then echo "Image found" - exit 0 +else + echo "Image not found, proceeding with build..." + # build + docker build --file docker/Dockerfile.cpu \ + --build-arg max_jobs=16 \ + --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ + --build-arg VLLM_CPU_X86=true \ + --tag "$IMAGE" \ + --target vllm-test \ + --progress plain . + # push + docker push "$IMAGE" fi -# build -docker build --file docker/Dockerfile.cpu \ - --build-arg max_jobs=16 \ - --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ - --build-arg VLLM_CPU_X86=true \ - --tag "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-cpu \ - --target vllm-test \ - --progress plain . - -# push -docker push "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-cpu +.buildkite/scripts/annotate-image-build.sh "$IMAGE" diff --git a/.buildkite/image_build/image_build_cpu_arm64.sh b/.buildkite/image_build/image_build_cpu_arm64.sh index b561e2c2e463..7bb4f10f729e 100755 --- a/.buildkite/image_build/image_build_cpu_arm64.sh +++ b/.buildkite/image_build/image_build_cpu_arm64.sh @@ -9,25 +9,25 @@ fi REGISTRY=$1 REPO=$2 BUILDKITE_COMMIT=$3 +IMAGE="$REGISTRY/$REPO:$BUILDKITE_COMMIT-arm64-cpu" # authenticate with AWS ECR aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY" || true # skip build if image already exists -if [[ -z $(docker manifest inspect "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-arm64-cpu) ]]; then - echo "Image not found, proceeding with build..." -else +if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then echo "Image found" - exit 0 +else + echo "Image not found, proceeding with build..." + # build + docker build --file docker/Dockerfile.cpu \ + --build-arg max_jobs=16 \ + --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ + --tag "$IMAGE" \ + --target vllm-test \ + --progress plain . + # push + docker push "$IMAGE" fi -# build -docker build --file docker/Dockerfile.cpu \ - --build-arg max_jobs=16 \ - --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ - --tag "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-arm64-cpu \ - --target vllm-test \ - --progress plain . - -# push -docker push "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-arm64-cpu +.buildkite/scripts/annotate-image-build.sh "$IMAGE" diff --git a/.buildkite/image_build/image_build_hpu.sh b/.buildkite/image_build/image_build_hpu.sh index df900dc60342..35b02c7ec988 100755 --- a/.buildkite/image_build/image_build_hpu.sh +++ b/.buildkite/image_build/image_build_hpu.sh @@ -9,26 +9,26 @@ fi REGISTRY=$1 REPO=$2 BUILDKITE_COMMIT=$3 +IMAGE="$REGISTRY/$REPO:$BUILDKITE_COMMIT-hpu" # authenticate with AWS ECR aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY" || true # skip build if image already exists -if [[ -z $(docker manifest inspect "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-hpu) ]]; then - echo "Image not found, proceeding with build..." -else +if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then echo "Image found" - exit 0 +else + echo "Image not found, proceeding with build..." + # build + docker build \ + --file tests/pytorch_ci_hud_benchmark/Dockerfile.hpu \ + --build-arg max_jobs=16 \ + --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ + --tag "$IMAGE" \ + --progress plain \ + https://github.com/vllm-project/vllm-gaudi.git + # push + docker push "$IMAGE" fi -# build -docker build \ - --file tests/pytorch_ci_hud_benchmark/Dockerfile.hpu \ - --build-arg max_jobs=16 \ - --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ - --tag "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-hpu \ - --progress plain \ - https://github.com/vllm-project/vllm-gaudi.git - -# push -docker push "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-hpu +.buildkite/scripts/annotate-image-build.sh "$IMAGE" diff --git a/.buildkite/image_build/image_build_torch_nightly.sh b/.buildkite/image_build/image_build_torch_nightly.sh index cbd08aa7bd0b..d13b3351d3ec 100755 --- a/.buildkite/image_build/image_build_torch_nightly.sh +++ b/.buildkite/image_build/image_build_torch_nightly.sh @@ -40,6 +40,7 @@ docker buildx ls echo "--- :mag: Checking if image already exists" if docker manifest inspect "$IMAGE_TAG" >/dev/null 2>&1; then echo "Image found: $IMAGE_TAG — skipping build" + .buildkite/scripts/annotate-image-build.sh "$IMAGE_TAG" exit 0 fi echo "Image not found, proceeding with build..." @@ -66,3 +67,5 @@ docker buildx build --file docker/Dockerfile \ --progress plain . echo "--- :white_check_mark: Torch nightly image build complete: $IMAGE_TAG" + +.buildkite/scripts/annotate-image-build.sh "$IMAGE_TAG" diff --git a/.buildkite/image_build/image_build_xpu.sh b/.buildkite/image_build/image_build_xpu.sh index 45417b7339be..adc544a2331e 100755 --- a/.buildkite/image_build/image_build_xpu.sh +++ b/.buildkite/image_build/image_build_xpu.sh @@ -9,26 +9,26 @@ fi REGISTRY=$1 REPO=$2 BUILDKITE_COMMIT=$3 +IMAGE="$REGISTRY/$REPO:$BUILDKITE_COMMIT-xpu" # authenticate with AWS ECR aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin "$REGISTRY" || true aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 936637512419.dkr.ecr.us-east-1.amazonaws.com || true # skip build if image already exists -if ! docker manifest inspect "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-xpu &> /dev/null; then - echo "Image not found, proceeding with build..." -else +if docker manifest inspect "$IMAGE" &> /dev/null; then echo "Image found" - exit 0 +else + echo "Image not found, proceeding with build..." + # build + docker build \ + --file docker/Dockerfile.xpu \ + --build-arg max_jobs=16 \ + --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ + --tag "$IMAGE" \ + --progress plain . + # push + docker push "$IMAGE" fi -# build -docker build \ - --file docker/Dockerfile.xpu \ - --build-arg max_jobs=16 \ - --build-arg buildkite_commit="$BUILDKITE_COMMIT" \ - --tag "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-xpu \ - --progress plain . - -# push -docker push "$REGISTRY"/"$REPO":"$BUILDKITE_COMMIT"-xpu +.buildkite/scripts/annotate-image-build.sh "$IMAGE" diff --git a/.buildkite/intel_jobs/basic_correctness.yaml b/.buildkite/intel_jobs/basic_correctness.yaml new file mode 100644 index 000000000000..fa472a7d3be1 --- /dev/null +++ b/.buildkite/intel_jobs/basic_correctness.yaml @@ -0,0 +1,27 @@ +group: Basic Correctness +depends_on: + - image-build-xpu +steps: +- label: XPU Sleep Mode + timeout_in_minutes: 30 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/basic_correctness/test_cumem.py + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + export VLLM_WORKER_MULTIPROC_METHOD=spawn && + pytest -v -s basic_correctness/test_cpu_offload.py && + pytest -v -s basic_correctness/test_mem.py::test_end_to_end' diff --git a/.buildkite/intel_jobs/engine_intel.yaml b/.buildkite/intel_jobs/engine_intel.yaml index c66576d40991..d1dc95b1d401 100644 --- a/.buildkite/intel_jobs/engine_intel.yaml +++ b/.buildkite/intel_jobs/engine_intel.yaml @@ -5,6 +5,10 @@ steps: - label: Engine (1 GPU) timeout_in_minutes: 30 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true working_dir: "." env: diff --git a/.buildkite/intel_jobs/expert_parallelism_intel.yaml b/.buildkite/intel_jobs/expert_parallelism_intel.yaml new file mode 100644 index 000000000000..24dfb07f5f9a --- /dev/null +++ b/.buildkite/intel_jobs/expert_parallelism_intel.yaml @@ -0,0 +1,27 @@ +group: Expert Parallelism +depends_on: + - image-build-xpu +steps: +- label: EPLB Algorithm + key: eplb-algorithm + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/distributed/eplb + - tests/distributed/test_eplb_algo.py + - tests/distributed/test_eplb_utils.py + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + pytest -v -s distributed/test_eplb_algo.py' diff --git a/.buildkite/intel_jobs/kernels_intel.yaml b/.buildkite/intel_jobs/kernels_intel.yaml index 66a8db25f02e..1407b02055b6 100644 --- a/.buildkite/intel_jobs/kernels_intel.yaml +++ b/.buildkite/intel_jobs/kernels_intel.yaml @@ -5,6 +5,10 @@ steps: - label: vLLM IR Tests timeout_in_minutes: 30 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true working_dir: "." env: diff --git a/.buildkite/intel_jobs/lora_intel.yaml b/.buildkite/intel_jobs/lora_intel.yaml index 32a56ef59b3f..76121432f320 100644 --- a/.buildkite/intel_jobs/lora_intel.yaml +++ b/.buildkite/intel_jobs/lora_intel.yaml @@ -5,6 +5,10 @@ steps: - label: LoRA Runtime + Utils timeout_in_minutes: 45 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ no_plugin: true working_dir: "." env: @@ -34,6 +38,10 @@ steps: - label: LoRA Fused/MoE Kernels timeout_in_minutes: 45 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true working_dir: "." env: @@ -54,6 +62,10 @@ steps: - label: LoRA Punica Kernels timeout_in_minutes: 45 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true working_dir: "." env: @@ -69,11 +81,17 @@ steps: 'cd tests && export VLLM_WORKER_MULTIPROC_METHOD=spawn && set -o pipefail && - pytest -v -s lora/test_punica_ops.py --deselect="tests/lora/test_punica_ops.py::test_kernels_hidden_size[expand-0-xpu:0-dtype0-3-43264-32-4-4]" --deselect="tests/lora/test_punica_ops.py::test_kernels[shrink-0-xpu:0-dtype1-1-2049-64-128-16]" --deselect="tests/lora/test_punica_ops.py::test_kernels[shrink-0-xpu:0-dtype0-1-2049-128-1-32]" --deselect="tests/lora/test_punica_ops.py::test_kernels[shrink-0-xpu:0-dtype0-1-2049-256-1-4]" --deselect="tests/lora/test_punica_ops.py::test_kernels[shrink-0-xpu:0-dtype0-1-2049-256-8-4]" --deselect="tests/lora/test_punica_ops.py::test_kernels[expand-0-xpu:0-dtype0-3-2049-128-8-16]" --deselect="tests/lora/test_punica_ops.py::test_kernels[shrink-0-xpu:0-dtype0-1-2049-128-8-32]" --deselect="tests/lora/test_punica_ops.py::test_kernels[expand-0-xpu:0-dtype1-1-2049-256-128-32]" --deselect="tests/lora/test_punica_ops.py::test_kernels_hidden_size[shrink-0-xpu:0-dtype0-3-64256-32-4-4]" --deselect="tests/lora/test_punica_ops.py::test_kernels_hidden_size[shrink-0-xpu:0-dtype1-2-29696-32-4-4]" --deselect="tests/lora/test_punica_ops.py::test_kernels_hidden_size[shrink-0-xpu:0-dtype1-3-49408-32-4-4]" --deselect="tests/lora/test_punica_ops.py::test_kernels_hidden_size[shrink-0-xpu:0-dtype0-2-16384-32-4-4]" --deselect="tests/lora/test_punica_ops.py::test_kernels_hidden_size[expand-0-xpu:0-dtype0-2-51328-32-4-4]"' + pytest -v -s lora/test_punica_ops.py::test_kernels && + pytest -v -s lora/test_punica_ops.py::test_kernels_hidden_size && + pytest -v -s lora/test_punica_ops.py::test_add_lora_fused_moe_early_exit' - label: LoRA Punica FP8/XPU Ops timeout_in_minutes: 45 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true working_dir: "." env: @@ -94,6 +112,10 @@ steps: - label: LoRA Models timeout_in_minutes: 45 device: intel_gpu + agent_tags: + label: production + gpu: 2+ + mem: 24+ no_plugin: true working_dir: "." env: @@ -108,15 +130,19 @@ steps: bash .buildkite/scripts/hardware_ci/run-intel-test.sh 'cd tests && export VLLM_WORKER_MULTIPROC_METHOD=spawn && - (pytest -v -s lora/test_mixtral.py --deselect="tests/lora/test_mixtral.py::test_mixtral_lora[4]" || true) && pytest -v -s lora/test_quant_model.py --deselect="tests/lora/test_quant_model.py::test_quant_model_lora[model0]" --deselect="tests/lora/test_quant_model.py::test_quant_model_lora[model1]" --deselect="tests/lora/test_quant_model.py::test_quant_model_tp_equality[model0]" && pytest -v -s lora/test_transformers_model.py && pytest -v -s lora/test_chatglm3_tp.py && + pytest -v -s lora/test_llama_tp.py::test_llama_lora && pytest -s -v lora/test_minicpmv_tp.py' - label: LoRA Multimodal timeout_in_minutes: 45 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true working_dir: "." env: diff --git a/.buildkite/intel_jobs/misc_intel.yaml b/.buildkite/intel_jobs/misc_intel.yaml index 864128bb5338..656df8791b9e 100644 --- a/.buildkite/intel_jobs/misc_intel.yaml +++ b/.buildkite/intel_jobs/misc_intel.yaml @@ -5,6 +5,10 @@ steps: - label: V1 Core + KV + Metrics timeout_in_minutes: 30 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true working_dir: "." env: @@ -31,6 +35,10 @@ steps: - label: V1 Sample + Logits timeout_in_minutes: 30 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ no_plugin: true working_dir: "." env: @@ -38,7 +46,17 @@ steps: REPO: "vllm-ci-test-repo" VLLM_TEST_DEVICE: "xpu" source_file_dependencies: - - vllm/ + - vllm/config/ + - vllm/distributed/ + - vllm/engine/ + - vllm/inputs/ + - vllm/logger.py + - vllm/model_executor/ + - vllm/platforms/ + - vllm/sampling_params.py + - vllm/transformers_utils/ + - vllm/utils/ + - vllm/v1/ - tests/v1/sample - tests/v1/logits_processors - tests/v1/test_oracle.py @@ -47,9 +65,199 @@ steps: commands: - >- bash .buildkite/scripts/hardware_ci/run-intel-test.sh - 'export VLLM_WORKER_MULTIPROC_METHOD=spawn && + 'pip install lm_eval[api]>=0.4.12 && + export VLLM_WORKER_MULTIPROC_METHOD=spawn && cd tests && pytest -v -s v1/logits_processors --ignore=v1/logits_processors/test_custom_online.py --ignore=v1/logits_processors/test_custom_offline.py && pytest -v -s v1/test_oracle.py && pytest -v -s v1/test_request.py && - pytest -v -s v1/test_outputs.py' + pytest -v -s v1/test_outputs.py && + pytest -v -s v1/sample/test_topk_topp_sampler.py && + pytest -v -s v1/sample/test_logprobs.py && + pytest -v -s v1/sample/test_logprobs_e2e.py' + +- label: Basic Models Tests (Initialization) + timeout_in_minutes: 60 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/models/test_initialization.py + - tests/models/registry.py + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'export VLLM_XPU_FUSED_MOE_USE_REF=1 && + cd tests && + pytest -v -s models/test_initialization.py::test_can_initialize_large_subset[Eagle3MiniMaxM2ForCausalLM]' + +- label: XPU CPU Offload + timeout_in_minutes: 60 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - vllm/v1/kv_offload/ + - vllm/v1/kv_connector/ + - tests/v1/kv_offload/ + - tests/v1/kv_connector/unit/test_offloading_connector.py + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'export VLLM_WORKER_MULTIPROC_METHOD=spawn && + cd tests && + pytest -v -s v1/kv_offload && + pytest -v -s v1/kv_connector/unit/test_offloading_connector.py' + +- label: NixlConnector PD accuracy (2 GPUs) + timeout_in_minutes: 60 + num_devices: 2 + device: intel_gpu + agent_tags: + label: production + gpu: 2+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - vllm/v1/worker/kv_connector_model_runner_mixin.py + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/xpu.py + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + bash v1/kv_connector/nixl_integration/run_xpu_disagg_accuracy_test.sh' + +- label: Regression + key: regression + timeout_in_minutes: 30 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/config/ + - vllm/distributed/ + - vllm/engine/ + - vllm/inputs/ + - vllm/model_executor/ + - vllm/multimodal/ + - vllm/platforms/ + - vllm/sampling_params.py + - vllm/transformers_utils/ + - vllm/utils/ + - vllm/v1/ + - tests/test_regression + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'pip install modelscope\<1.38 && + cd tests && + pytest -v -s test_regression.py' + +- label: Metrics, Tracing (2 GPUs) + key: metrics-tracing-2-gpus + timeout_in_minutes: 30 + num_devices: 2 + device: intel_gpu + agent_tags: + label: production + gpu: 2+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/config/ + - vllm/distributed/ + - vllm/engine/ + - vllm/inputs/ + - vllm/model_executor/ + - vllm/multimodal/ + - vllm/platforms/ + - vllm/sampling_params.py + - vllm/tracing/ + - vllm/transformers_utils/ + - vllm/utils/ + - vllm/v1/ + - tests/v1/tracing + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'pip install opentelemetry-sdk\>=1.26.0 opentelemetry-api\>=1.26.0 opentelemetry-exporter-otlp\>=1.26.0 opentelemetry-semantic-conventions-ai\>=0.4.1 && + cd tests && + pytest -v -s v1/tracing' + +- label: Async Engine, Inputs, Utils, Worker + key: async-engine-inputs-utils-worker + timeout_in_minutes: 30 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/assets/ + - vllm/config/ + - vllm/distributed/ + - vllm/engine/ + - vllm/inputs/ + - vllm/model_executor/ + - vllm/multimodal/ + - vllm/platforms/ + - vllm/sampling_params.py + - vllm/tokenizers/ + - vllm/transformers_utils/ + - vllm/utils/ + - vllm/v1/ + - tests/detokenizer + - tests/multimodal + - tests/utils_ + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + pip install av && + pytest -v -s detokenizer && + pytest -v -s -m "not cpu_test" ./multimodal && + pytest -v -s utils_ --ignore=utils_/test_mem_utils.py' diff --git a/.buildkite/intel_jobs/model_runner_v2_intel.yaml b/.buildkite/intel_jobs/model_runner_v2_intel.yaml new file mode 100644 index 000000000000..0311b5dffb72 --- /dev/null +++ b/.buildkite/intel_jobs/model_runner_v2_intel.yaml @@ -0,0 +1,62 @@ +group: Model Runner V2 Intel +depends_on: + - image-build-xpu +steps: +- label: Model Runner V2 Core Tests (Intel) + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 2+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/v1/worker/gpu/ + - vllm/v1/worker/gpu_worker.py + - vllm/v1/core/sched/ + - vllm/v1/attention/ + - tests/v1/engine/test_llm_engine.py + - tests/v1/e2e/ + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'export VLLM_USE_V2_MODEL_RUNNER=1 && + cd tests && + pytest -v -s v1/engine/test_llm_engine.py -k "not test_engine_metrics" && + ENFORCE_EAGER=1 pytest -v -s v1/e2e/general/test_async_scheduling.py -k "not ngram" && + pytest -v -s v1/e2e/general/test_min_tokens.py' + +- label: Model Runner V2 Examples (Intel) + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/v1/worker/gpu/ + - vllm/v1/core/sched/ + - vllm/v1/worker/gpu_worker.py + - examples/basic/offline_inference/ + - examples/generate/multimodal/ + - examples/features/ + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'export VLLM_USE_V2_MODEL_RUNNER=1 && + cd examples && + python3 basic/offline_inference/chat.py && + python3 basic/offline_inference/generate.py --model facebook/opt-125m && + python3 generate/multimodal/vision_language_offline.py --seed 0 && + python3 features/automatic_prefix_caching/prefix_caching_offline.py' diff --git a/.buildkite/intel_jobs/models_distributed_intel.yaml b/.buildkite/intel_jobs/models_distributed_intel.yaml new file mode 100644 index 000000000000..604f7744c10f --- /dev/null +++ b/.buildkite/intel_jobs/models_distributed_intel.yaml @@ -0,0 +1,27 @@ +group: Models - Distributed +depends_on: + - image-build-xpu +steps: +- label: Distributed Model Tests (2 GPUs) + key: distributed-model-tests-2-gpus + timeout_in_minutes: 50 + device: intel_gpu + agent_tags: + label: production + gpu: 2+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/model_executor/model_loader/sharded_state_loader.py + - vllm/model_executor/models/ + - tests/model_executor/model_loader/test_sharded_state_loader.py + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + pytest -v -s model_executor/model_loader/test_sharded_state_loader.py -m "not slow_test"' diff --git a/.buildkite/intel_jobs/models_multimodal_intel.yaml b/.buildkite/intel_jobs/models_multimodal_intel.yaml new file mode 100644 index 000000000000..e12c2658f50b --- /dev/null +++ b/.buildkite/intel_jobs/models_multimodal_intel.yaml @@ -0,0 +1,127 @@ +group: Models - Multimodal +depends_on: + - image-build-xpu +steps: +- label: "Multi-Modal Models (Standard) 1: qwen2" + key: multi-modal-models-standard-1-qwen2 + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/models/multimodal + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'pip install av && + cd tests && + pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "qwen2" && + pytest -v -s models/multimodal/generation/test_ultravox.py -m core_model' + +- label: "Multi-Modal Models (Standard) 2: qwen3 + gemma" + key: multi-modal-models-standard-2-qwen3-gemma + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/models/multimodal + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + pytest -v -s models/multimodal/generation/test_qwen2_5_vl.py -m core_model' + +- label: "Multi-Modal Models (Standard) 3: llava + qwen2_vl" + key: multi-modal-models-standard-3-llava-qwen2-vl + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/models/multimodal + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "not qwen2 and not qwen3 and not gemma" && + pytest -v -s models/multimodal/generation/test_qwen2_vl.py -m core_model' + +- label: "Multi-Modal Models (Standard) 4: other + whisper" + key: multi-modal-models-standard-4-other-whisper + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/models/multimodal + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'pip install av && + cd tests && + pytest -v -s models/multimodal -m core_model --ignore models/multimodal/generation/test_common.py --ignore models/multimodal/generation/test_ultravox.py --ignore models/multimodal/generation/test_qwen2_5_vl.py --ignore models/multimodal/generation/test_qwen2_vl.py --ignore models/multimodal/generation/test_whisper.py --ignore models/multimodal/generation/test_memory_leak.py --ignore models/multimodal/processing' + +- label: Multi-Modal Processor # 44min + key: multi-modal-processor + timeout_in_minutes: 45 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + working_dir: "." + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/models/multimodal + - tests/models/registry.py + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'pip install av matplotlib ftfy && + pip install open-clip-torch --no-deps && + cd tests && + pytest -v -s models/multimodal/processing/test_tensor_schema.py + --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB' + parallelism: 4 diff --git a/.buildkite/intel_jobs/quantization.yaml b/.buildkite/intel_jobs/quantization.yaml new file mode 100644 index 000000000000..a5c11da9dd23 --- /dev/null +++ b/.buildkite/intel_jobs/quantization.yaml @@ -0,0 +1,28 @@ +group: Quantization +depends_on: + - image-build-xpu +steps: +- label: Quantization + key: quantization + timeout_in_minutes: 30 + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + no_plugin: true + working_dir: "." + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + source_file_dependencies: + - csrc/ + - vllm/model_executor/layers/quantization + - tests/quantization + commands: + # - VLLM_TEST_FORCE_LOAD_FORMAT=auto pytest -v -s quantization/ --ignore quantization/test_blackwell_moe.py + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'VLLM_TEST_FORCE_LOAD_FORMAT=auto pytest -v -s tests/quantization/test_per_token_kv_cache.py --deselect="tests/quantization/test_per_token_kv_cache.py::test_triton_unified_attention_per_token_head_scale[int4-16-128-num_heads0-seq_lens1]"' + diff --git a/.buildkite/intel_jobs/test-intel.yaml b/.buildkite/intel_jobs/test-intel.yaml index 805b7e54f120..d20ae36a2e36 100644 --- a/.buildkite/intel_jobs/test-intel.yaml +++ b/.buildkite/intel_jobs/test-intel.yaml @@ -19,6 +19,10 @@ steps: - image-build-xpu timeout_in_minutes: 30 device: intel_gpu + agent_tags: + label: production + gpu: 2+ + mem: 24+ no_plugin: true env: REGISTRY: "public.ecr.aws/q9t5s3a7" @@ -38,15 +42,46 @@ steps: python3 examples/basic/offline_inference/generate.py --model facebook/opt-125m --block-size 64 --enforce-eager --kv-cache-dtype fp8 && python3 examples/basic/offline_inference/generate.py --model nvidia/Llama-3.1-8B-Instruct-FP8 --block-size 64 --enforce-eager --quantization modelopt --kv-cache-dtype fp8 --attention-backend TRITON_ATTN --max-model-len 4096 && python3 examples/basic/offline_inference/generate.py --model superjob/Qwen3-4B-Instruct-2507-GPTQ-Int4 --block-size 64 --enforce-eager --max-model-len 8192 && + python3 examples/basic/offline_inference/generate.py --model TheBloke/TinyLlama-1.1B-Chat-v0.3-AWQ --block-size 64 --enforce-eager && python3 examples/basic/offline_inference/generate.py --model ibm-research/PowerMoE-3b --block-size 64 --enforce-eager -tp 2 && python3 examples/basic/offline_inference/generate.py --model ibm-research/PowerMoE-3b --block-size 64 --enforce-eager -tp 2 --enable-expert-parallel && - python3 examples/basic/offline_inference/generate.py --model superjob/Qwen3-4B-Instruct-2507-GPTQ-Int4 --max-model-len 8192 + python3 examples/basic/offline_inference/generate.py --model superjob/Qwen3-4B-Instruct-2507-GPTQ-Int4 --max-model-len 8192 && + VLLM_XPU_FUSED_MOE_USE_REF=1 python3 examples/basic/offline_inference/generate.py --model Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 --enforce-eager -tp 2 --max-model-len 8192 && + python3 examples/basic/offline_inference/generate.py --model INCModel/Qwen3-30B-A3B-Instruct-2507-MXFP4-LLMC --enforce-eager -tp 2 --max-model-len 8192 + ' + - label: "XPU W8A8 FP8 Linear Examples" + depends_on: + - image-build-xpu + timeout_in_minutes: 60 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ + no_plugin: true + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - .buildkite/intel_jobs/test-intel.yaml + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'python3 examples/basic/offline_inference/generate.py --linear-backend xpu --model RedHatAI/Meta-Llama-3.1-8B-Instruct-FP8 --enforce-eager --max-model-len 4096 && + python3 examples/basic/offline_inference/generate.py --linear-backend xpu --model neuralmagic/Llama-3.2-1B-Instruct-FP8-dynamic --enforce-eager --max-model-len 4096 && + python3 examples/basic/offline_inference/generate.py --linear-backend xpu --model meta-llama/Llama-3.2-1B-Instruct --quantization fp8 --enforce-eager --max-model-len 4096 ' - label: "XPU V1 test" depends_on: - image-build-xpu timeout_in_minutes: 30 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 24+ no_plugin: true env: REGISTRY: "public.ecr.aws/q9t5s3a7" @@ -64,13 +99,17 @@ steps: pytest -v -s v1/worker --ignore=v1/worker/test_gpu_model_runner.py --ignore=v1/worker/test_worker_memory_snapshot.py && pytest -v -s v1/structured_output && pytest -v -s v1/test_serial_utils.py && - pytest -v -s v1/spec_decode --ignore=v1/spec_decode/test_max_len.py --ignore=v1/spec_decode/test_speculators_eagle3.py --ignore=v1/spec_decode/test_acceptance_length.py && + pytest -v -s v1/spec_decode --ignore=v1/spec_decode/test_max_len.py --ignore=v1/spec_decode/test_speculators_eagle3.py --ignore=v1/spec_decode/test_acceptance_length.py --ignore=v1/spec_decode/test_speculators_correctness.py && pytest -v -s v1/kv_connector/unit --ignore=v1/kv_connector/unit/test_multi_connector.py --ignore=v1/kv_connector/unit/test_example_connector.py --ignore=v1/kv_connector/unit/test_lmcache_integration.py --ignore=v1/kv_connector/unit/test_hf3fs_client.py --ignore=v1/kv_connector/unit/test_hf3fs_connector.py --ignore=v1/kv_connector/unit/test_hf3fs_metadata_server.py --ignore=v1/kv_connector/unit/test_offloading_connector.py' - label: "XPU server test" depends_on: - image-build-xpu timeout_in_minutes: 30 device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ no_plugin: true env: REGISTRY: "public.ecr.aws/q9t5s3a7" @@ -83,5 +122,49 @@ steps: bash .buildkite/scripts/hardware_ci/run-intel-test.sh 'pip install av && cd tests && - pytest -v -s entrypoints/openai/chat_completion/test_audio_in_video.py && + pytest -v -s entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py && pytest -v -s benchmarks/test_serve_cli.py' + - label: "XPU quantization test" + depends_on: + - image-build-xpu + timeout_in_minutes: 30 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + source_file_dependencies: + - vllm/ + - .buildkite/intel_jobs/test-intel.yaml + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + pytest -v -s quantization/test_auto_round.py' + - label: "XPU compressed tensors FP8 test" + depends_on: + - image-build-xpu + timeout_in_minutes: 60 + device: intel_gpu + agent_tags: + label: production + gpu: 1+ + mem: 16+ + no_plugin: true + env: + REGISTRY: "public.ecr.aws/q9t5s3a7" + REPO: "vllm-ci-test-repo" + VLLM_TEST_DEVICE: "xpu" + source_file_dependencies: + - vllm/ + - tests/quantization/test_compressed_tensors.py + - .buildkite/intel_jobs/test-intel.yaml + commands: + - >- + bash .buildkite/scripts/hardware_ci/run-intel-test.sh + 'cd tests && + pytest -v -s quantization/test_compressed_tensors.py::test_compressed_tensors_fp8' \ No newline at end of file diff --git a/.buildkite/lm-eval-harness/configs/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8.yaml b/.buildkite/lm-eval-harness/configs/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8.yaml index a87328fcdccb..164733cca6f6 100644 --- a/.buildkite/lm-eval-harness/configs/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8.yaml +++ b/.buildkite/lm-eval-harness/configs/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8.yaml @@ -6,9 +6,7 @@ tasks: value: 0.7142 - name: "exact_match,flexible-extract" value: 0.4579 -env_vars: - VLLM_USE_FLASHINFER_MOE_FP8: "1" - VLLM_FLASHINFER_MOE_BACKEND: "throughput" +moe_backend: "flashinfer_cutlass" limit: 1319 num_fewshot: 5 max_model_len: 262144 diff --git a/.buildkite/lm-eval-harness/test_lm_eval_correctness.py b/.buildkite/lm-eval-harness/test_lm_eval_correctness.py index d34e603b9e26..dd2fd5f05b42 100644 --- a/.buildkite/lm-eval-harness/test_lm_eval_correctness.py +++ b/.buildkite/lm-eval-harness/test_lm_eval_correctness.py @@ -68,6 +68,10 @@ def launch_lm_eval(eval_config, tp_size): if current_platform.is_rocm() and "Nemotron-3" in eval_config["model_name"]: model_args += "attention_backend=TRITON_ATTN" + moe_backend = eval_config.get("moe_backend", None) + if moe_backend is not None: + model_args += f"moe_backend={moe_backend}," + env_vars = eval_config.get("env_vars", None) with scoped_env_vars(env_vars): results = lm_eval.simple_evaluate( diff --git a/.buildkite/release-pipeline.yaml b/.buildkite/release-pipeline.yaml index a34f534e54da..b1fe875bc2d8 100644 --- a/.buildkite/release-pipeline.yaml +++ b/.buildkite/release-pipeline.yaml @@ -1,12 +1,25 @@ # CUDA architecture lists — following PyTorch RELEASE.md # (https://github.com/pytorch/pytorch/blob/main/RELEASE.md) # SM86 included for broader Ampere coverage; SM89 for marlin fp8 support +# These requested arches are filtered by CMake's CUDA_SUPPORTED_ARCHS before +# per-kernel arch selection. Do not add +PTX here: top-level +PTX is stripped +# during that filtering, so kernels that need PTX must request it locally. env: - CUDA_ARCH_X86: "7.5 8.0 8.6 8.9 9.0 10.0 12.0+PTX" - # aarch64 only architectures: 8.7 for Orin, 11.0 for Thor (since CUDA 13) - CUDA_ARCH_AARCH64: "8.0 8.7 8.9 9.0 10.0 11.0 12.0+PTX" + # for CUDA >=13, sm_100+ targets have family specifiers (see CMakeLists.txt) + # so targets like 10.3 and 12.1 are automatically supported with this list + CUDA_ARCH_X86: "7.5 8.0 8.6 8.9 9.0 10.0 12.0" + # aarch64-only targets: Orin (8.7), Thor (11.0, CUDA 13+) + CUDA_ARCH_AARCH64: "8.0 8.7 8.9 9.0 10.0 11.0 12.0" + + # for CUDA <13, we need to specify all needed targets + # some targets (10.3, 12.1) are skipped to limit the wheel size (< 500MB) + # please use CUDA 13 wheels or compile yourself on these new devices CUDA_ARCH_X86_CU129: "7.5 8.0 8.6 8.9 9.0 10.0 12.0" CUDA_ARCH_AARCH64_CU129: "8.0 8.7 8.9 9.0 10.0 12.0" + + # pre-built mooncake wheels + # the manylinux_2_35 wheel has compatibility issue on Ubuntu 24.04 + # so we use different wheels for the time being MOONCAKE_WHEEL_AARCH64_2_35: "https://vllm-wheels.s3.amazonaws.com/mooncake/mooncake_transfer_engine-0.3.10.post2-0da9dfea3-cp312-cp312-manylinux_2_35_aarch64.whl" MOONCAKE_WHEEL_AARCH64_2_39: "https://vllm-wheels.s3.amazonaws.com/mooncake/mooncake_transfer_engine-0.3.10.post2-0da9dfea3-cp312-cp312-manylinux_2_39_aarch64.whl" MOONCAKE_WHEEL_X86_64: "https://vllm-wheels.s3.amazonaws.com/mooncake/mooncake_transfer_engine-0.3.10.post2-0da9dfea3-cp312-cp312-manylinux_2_35_x86_64.whl" @@ -337,6 +350,25 @@ steps: - "docker push public.ecr.aws/q9t5s3a7/vllm-release-repo:$BUILDKITE_COMMIT-$(uname -m)-cu129-ubuntu2404" - 'bash .buildkite/scripts/annotate-build-artifact.sh "$$BUILDKITE_LABEL" "public.ecr.aws/q9t5s3a7/vllm-release-repo:$BUILDKITE_COMMIT-$(uname -m)-cu129-ubuntu2404"' + - label: ":docker: Build release image - x86_64 - XPU" + depends_on: ~ + id: build-xpu-release-image + agents: + queue: cpu_queue_release + commands: + - "aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7" + - | + DOCKER_BUILDKIT=1 docker build \ + $(bash .buildkite/scripts/docker-build-metadata-args.sh xpu) \ + --build-arg GIT_REPO_CHECK=1 \ + --target vllm-openai \ + --progress plain \ + -f docker/Dockerfile.xpu . + - "docker push public.ecr.aws/q9t5s3a7/vllm-release-repo:$BUILDKITE_COMMIT-$(uname -m)-xpu" + - 'bash .buildkite/scripts/annotate-build-artifact.sh "$$BUILDKITE_LABEL" "public.ecr.aws/q9t5s3a7/vllm-release-repo:$BUILDKITE_COMMIT-$(uname -m)-xpu"' + env: + DOCKER_BUILDKIT: "1" + - block: "Build release image for x86_64 CPU" key: block-cpu-release-image-build depends_on: ~ @@ -432,6 +464,16 @@ steps: - "docker manifest push public.ecr.aws/q9t5s3a7/vllm-release-repo:$BUILDKITE_COMMIT-cu129-ubuntu2404" - 'bash .buildkite/scripts/annotate-build-artifact.sh "Manifest: CUDA 12.9 Ubuntu 24.04" "public.ecr.aws/q9t5s3a7/vllm-release-repo:$BUILDKITE_COMMIT-cu129-ubuntu2404"' + - label: "Create manifest - XPU" + depends_on: + - build-xpu-release-image + id: create-manifest-xpu + agents: + queue: small_cpu_queue_release + commands: + - "bash .buildkite/scripts/xpu/create-xpu-ecr-manifest.sh" + - 'bash .buildkite/scripts/annotate-build-artifact.sh "Manifest: XPU" "public.ecr.aws/q9t5s3a7/vllm-release-repo:$BUILDKITE_COMMIT-xpu"' + - label: "Publish nightly multi-arch image to DockerHub" depends_on: - create-multi-arch-manifest @@ -846,7 +888,6 @@ steps: allow_failure: true - step: build-cpu-release-image-arm64 allow_failure: true - if: build.env("NIGHTLY") != "1" - label: "Publish release images to DockerHub" depends_on: diff --git a/.buildkite/scripts/annotate-image-build.sh b/.buildkite/scripts/annotate-image-build.sh new file mode 100755 index 000000000000..174d88bf6744 --- /dev/null +++ b/.buildkite/scripts/annotate-image-build.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# +# Append the Docker image tag(s) an image-build step pushed to a Buildkite +# annotation, so the built image tags show up on the build page instead of +# being buried in the job logs. +# +# Usage: annotate-image-build.sh [ ...] +set -euo pipefail + +# buildkite-agent only exists on Buildkite agents; no-op elsewhere so the +# image build scripts stay runnable locally. +if ! command -v buildkite-agent >/dev/null 2>&1; then + echo "buildkite-agent not found; skipping image tag annotation" + exit 0 +fi + +label="${BUILDKITE_LABEL:-Image build}" +content="" +for image in "$@"; do + [[ -n "$image" ]] || continue + content+="- **${label}**: \`${image}\`"$'\n' +done + +if [[ -z "$content" ]]; then + echo "No image tags provided; nothing to annotate" + exit 0 +fi + +# Best-effort: a flaky annotation must never fail an otherwise successful +# (and expensive) image build. +if ! printf '%s' "$content" | \ + buildkite-agent annotate --append --style 'info' --context 'docker-images'; then + echo "warning: failed to annotate build with image tags" +fi diff --git a/.buildkite/scripts/ci-bake-rocm.sh b/.buildkite/scripts/ci-bake-rocm.sh index 1289939180dc..90078fc0c1dd 100644 --- a/.buildkite/scripts/ci-bake-rocm.sh +++ b/.buildkite/scripts/ci-bake-rocm.sh @@ -15,9 +15,10 @@ set -euo pipefail DEFAULT_REPO_SLUG="vllm-project/vllm" DEFAULT_CI_HCL_SOURCE="docker/ci-rocm.hcl" -DEFAULT_CI_BASE_CONTENT_FILES="requirements/common.txt requirements/rocm.txt requirements/test/rocm.txt docker/Dockerfile.rocm_base tools/install_torchcodec_rocm.sh tests/vllm_test_utils" +DEFAULT_CI_BASE_CONTENT_FILES="requirements/common.txt requirements/rocm.txt requirements/test/rocm.txt docker/Dockerfile.rocm_base docker/ci-rocm.hcl docker/docker-bake-rocm.hcl tools/install_torchcodec_rocm.sh tests/vllm_test_utils .buildkite/scripts/ci-bake-rocm.sh .buildkite/scripts/rocm/build-ci-base.sh" DEFAULT_CI_BASE_DOCKERFILE="docker/Dockerfile.rocm" DEFAULT_CI_BASE_DOCKERFILE_STAGES="base build_rixl build_rocshmem build_deepep mori_base ci_base" +DEFAULT_CI_BASE_METADATA_VERSION="1" IMAGE_EXISTED_BEFORE_BUILD=0 TARGET="" @@ -392,6 +393,16 @@ should_upload_wheel_artifacts() { || "${TARGET}" == *"artifact"* ]] } +set_buildkite_metadata() { + local key="$1" + local value="$2" + + [[ -n "${value}" ]] || return 0 + if command -v buildkite-agent >/dev/null 2>&1; then + buildkite-agent meta-data set "${key}" "${value}" || true + fi +} + get_remote_image_label() { local image_ref="$1" local label_key="$2" @@ -525,6 +536,22 @@ get_remote_image_label_with_retry() { return 0 } +remote_ci_base_metadata_is_current() { + local image_ref="$1" + local metadata_version="" + + metadata_version=$(get_remote_image_label "${image_ref}" "vllm.ci_base.metadata_version") + [[ "${metadata_version}" == "${CI_BASE_METADATA_VERSION:-${DEFAULT_CI_BASE_METADATA_VERSION}}" ]] +} + +remote_ci_base_metadata_is_current_with_retry() { + local image_ref="$1" + local metadata_version="" + + metadata_version=$(get_remote_image_label_with_retry "${image_ref}" "vllm.ci_base.metadata_version") + [[ "${metadata_version}" == "${CI_BASE_METADATA_VERSION:-${DEFAULT_CI_BASE_METADATA_VERSION}}" ]] +} + remote_image_exists() { local image_ref="$1" docker manifest inspect "${image_ref}" >/dev/null 2>&1 @@ -581,6 +608,7 @@ init_config() { CI_BASE_CONTENT_FILES="${CI_BASE_CONTENT_FILES:-${DEFAULT_CI_BASE_CONTENT_FILES}}" CI_BASE_DOCKERFILE="${CI_BASE_DOCKERFILE:-${DEFAULT_CI_BASE_DOCKERFILE}}" CI_BASE_DOCKERFILE_STAGES="${CI_BASE_DOCKERFILE_STAGES:-${DEFAULT_CI_BASE_DOCKERFILE_STAGES}}" + CI_BASE_METADATA_VERSION="${CI_BASE_METADATA_VERSION:-${DEFAULT_CI_BASE_METADATA_VERSION}}" CI_BASE_IMAGE_TAG="${CI_BASE_IMAGE_TAG:-rocm/vllm-dev:ci_base}" export PYTORCH_ROCM_ARCH @@ -635,6 +663,10 @@ load_ci_hcl() { echo "Copied ${CI_HCL_SOURCE} to ${CI_HCL_PATH}" } +init_bake_files() { + BAKE_FILES=(-f "${VLLM_BAKE_FILE}" -f "${CI_HCL_PATH}") +} + compute_ci_base_hash_if_needed() { if [[ -z "${CI_BASE_CONTENT_FILES:-}" ]]; then return 0 @@ -676,12 +708,14 @@ configure_ci_base_image_refs() { fi content_tag=$(ci_base_tag_with_suffix "${stable_tag}" "${CI_BASE_CONTENT_HASH}") + CI_BASE_IMAGE_TAG_CONTENT_REF="${content_tag}" if [[ -n "${BUILDKITE_COMMIT:-}" ]]; then commit_tag=$(ci_base_tag_with_suffix "${stable_tag}" "${BUILDKITE_COMMIT}") - CI_BASE_IMAGE_TAG_COMMIT="${commit_tag}" - export CI_BASE_IMAGE_TAG_COMMIT fi + CI_BASE_IMAGE_TAG_COMMIT_REF="${commit_tag}" + # *_REF is the logical tag recorded in metadata. *_EXTRA is only passed to + # bake when that tag is not already the primary tag, avoiding duplicates. if should_push_stable_ci_base_tag; then primary_tag="${content_tag}" CI_BASE_IMAGE_TAG_STABLE="${stable_tag}" @@ -691,19 +725,35 @@ configure_ci_base_image_refs() { fi CI_BASE_IMAGE_TAG="${primary_tag}" if [[ "${primary_tag}" == "${content_tag}" ]]; then - CI_BASE_IMAGE_TAG_CONTENT="" + CI_BASE_IMAGE_TAG_CONTENT_EXTRA="" + else + CI_BASE_IMAGE_TAG_CONTENT_EXTRA="${content_tag}" + fi + if [[ -n "${commit_tag}" && "${commit_tag}" != "${primary_tag}" ]]; then + CI_BASE_IMAGE_TAG_COMMIT_EXTRA="${commit_tag}" else - CI_BASE_IMAGE_TAG_CONTENT="${content_tag}" + CI_BASE_IMAGE_TAG_COMMIT_EXTRA="" fi - export CI_BASE_IMAGE_TAG CI_BASE_IMAGE_TAG_CONTENT CI_BASE_IMAGE_TAG_STABLE + export CI_BASE_IMAGE_TAG + export CI_BASE_IMAGE_TAG_COMMIT_EXTRA + export CI_BASE_IMAGE_TAG_CONTENT_EXTRA + export CI_BASE_IMAGE_TAG_CONTENT_REF + export CI_BASE_IMAGE_TAG_COMMIT_REF + export CI_BASE_IMAGE_TAG_STABLE if is_ci_base_target; then IMAGE_TAG="${primary_tag}" + CI_BASE_IMAGE="${primary_tag}" + export CI_BASE_IMAGE export IMAGE_TAG echo "ci_base primary image tag: ${CI_BASE_IMAGE_TAG}" - if [[ -n "${CI_BASE_IMAGE_TAG_COMMIT:-}" ]]; then - echo "ci_base commit image tag: ${CI_BASE_IMAGE_TAG_COMMIT}" + if [[ -n "${commit_tag}" ]]; then + if [[ "${commit_tag}" == "${primary_tag}" ]]; then + echo "ci_base commit image tag: ${commit_tag} (primary)" + else + echo "ci_base commit image tag: ${commit_tag}" + fi fi echo "ci_base content image tag: ${content_tag}" if [[ -n "${CI_BASE_IMAGE_TAG_STABLE}" ]]; then @@ -712,6 +762,10 @@ configure_ci_base_image_refs() { echo "ci_base stable alias will not be pushed for this build" echo "Set NIGHTLY=1 on ${CI_BASE_STABLE_BRANCH:-main} to refresh ${stable_tag}" fi + set_buildkite_metadata "rocm-ci-base-image" "${CI_BASE_IMAGE_TAG}" + set_buildkite_metadata "rocm-ci-base-image-content" "${content_tag}" + set_buildkite_metadata "rocm-ci-base-image-commit" "${CI_BASE_IMAGE_TAG_COMMIT:-}" + set_buildkite_metadata "rocm-ci-base-image-stable" "${CI_BASE_IMAGE_TAG_STABLE:-}" return 0 fi @@ -728,8 +782,8 @@ ci_base_candidate_refs() { printf '%s\n' \ "${IMAGE_TAG:-}" \ "${CI_BASE_IMAGE_TAG:-}" \ - "${CI_BASE_IMAGE_TAG_COMMIT:-}" \ - "${CI_BASE_IMAGE_TAG_CONTENT:-}" \ + "${CI_BASE_IMAGE_TAG_COMMIT_EXTRA:-}" \ + "${CI_BASE_IMAGE_TAG_CONTENT_EXTRA:-}" \ "${CI_BASE_IMAGE_TAG_STABLE:-}" \ | awk 'NF && !seen[$0]++' } @@ -743,6 +797,10 @@ find_matching_ci_base_ref() { remote_image_exists "${candidate}" || continue candidate_hash=$(get_remote_image_label "${candidate}" "vllm.ci_base.content_hash") if [[ "${candidate_hash}" == "${CI_BASE_CONTENT_HASH}" ]]; then + if ! remote_ci_base_metadata_is_current "${candidate}"; then + echo "Found matching ci_base content hash but stale metadata: ${candidate}" >&2 + continue + fi printf '%s\n' "${candidate}" return 0 fi @@ -817,6 +875,10 @@ maybe_skip_existing_image() { if [[ -n "${remote_hash}" ]]; then echo "Remote ci_base content hash: ${remote_hash:0:16}..." if [[ "${remote_hash}" == "${CI_BASE_CONTENT_HASH}" ]]; then + if ! remote_ci_base_metadata_is_current "${IMAGE_TAG}"; then + echo "Content hashes match but ci_base metadata is stale; rebuilding to refresh metadata" + return 0 + fi if ! refresh_ci_base_tags_from_ref "${IMAGE_TAG}"; then echo "ci_base tag refresh failed; rebuilding to push expected tags" return 0 @@ -998,12 +1060,104 @@ prepare_git_cache_metadata() { fi } +ci_base_metadata_pairs() { + local dockerfile="${CI_BASE_DOCKERFILE:-${DEFAULT_CI_BASE_DOCKERFILE}}" + local stages="${CI_BASE_DOCKERFILE_STAGES:-${DEFAULT_CI_BASE_DOCKERFILE_STAGES}}" + local content_files="${CI_BASE_CONTENT_FILES:-${DEFAULT_CI_BASE_CONTENT_FILES}}" + local content_files_hash="" + local base_image="" + local base_image_digest="" + local git_branch="" + local -a content_paths=() + local -a content_args=() + + read -r -a content_paths <<< "${content_files}" + if [[ ${#content_paths[@]} -gt 0 ]]; then + content_files_hash=$(compute_content_hash "${content_paths[@]}") + fi + mapfile -t content_args < <( + get_content_arg_names "${dockerfile}" "${stages}" "${CI_BASE_CONTENT_ARGS:-}" + ) + + base_image=$(resolve_dockerfile_arg_value "${dockerfile}" "BASE_IMAGE") + if [[ -n "${base_image}" ]]; then + base_image_digest=$(resolve_image_digest "${base_image}") + fi + git_branch="${BUILDKITE_BRANCH:-${VLLM_BRANCH:-}}" + + metadata_pair "vllm.ci_base.metadata_version" "${CI_BASE_METADATA_VERSION:-${DEFAULT_CI_BASE_METADATA_VERSION}}" + metadata_pair "vllm.ci_base.content_hash" "${CI_BASE_CONTENT_HASH:-}" + metadata_pair "vllm.ci_base.content_files_hash" "${content_files_hash}" + metadata_pair "vllm.ci_base.content_files" "${content_files}" + metadata_pair "vllm.ci_base.content_args" "$(join_words "${content_args[@]}")" + metadata_pair "vllm.ci_base.dockerfile" "${dockerfile}" + metadata_pair "vllm.ci_base.dockerfile_stages" "${stages}" + metadata_pair "vllm.ci_base.image.primary" "${CI_BASE_IMAGE_TAG:-}" + metadata_pair "vllm.ci_base.image.content" "${CI_BASE_IMAGE_TAG_CONTENT_REF:-${CI_BASE_IMAGE_TAG_CONTENT_EXTRA:-}}" + metadata_pair "vllm.ci_base.image.commit" "${CI_BASE_IMAGE_TAG_COMMIT_REF:-${CI_BASE_IMAGE_TAG_COMMIT_EXTRA:-}}" + metadata_pair "vllm.ci_base.image.stable" "${CI_BASE_IMAGE_TAG_STABLE:-}" + metadata_pair "vllm.ci_base.git_commit" "${BUILDKITE_COMMIT:-}" + metadata_pair "vllm.ci_base.git_branch" "${git_branch}" + metadata_pair "vllm.ci_base.vllm_branch" "${VLLM_BRANCH:-}" + metadata_pair "vllm.ci_base.stable_branch" "${CI_BASE_STABLE_BRANCH:-main}" + + metadata_pair "vllm.rocm.base_image" "${base_image}" + metadata_pair "vllm.rocm.base_image_digest" "${base_image_digest}" + metadata_pair "vllm.rocm.pytorch_rocm_arch" "${PYTORCH_ROCM_ARCH:-}" + metadata_pair "vllm.rocm.nic_backend" "$(resolve_dockerfile_arg_value "${dockerfile}" "NIC_BACKEND")" + metadata_pair "vllm.rocm.ainic_version" "$(resolve_dockerfile_arg_value "${dockerfile}" "AINIC_VERSION")" + metadata_pair "vllm.rocm.ubuntu_codename" "$(resolve_dockerfile_arg_value "${dockerfile}" "UBUNTU_CODENAME")" + metadata_pair "vllm.rocm.rixl_repo" "$(resolve_dockerfile_arg_value "${dockerfile}" "RIXL_REPO")" + metadata_pair "vllm.rocm.rixl_commit" "${RIXL_BRANCH:-$(resolve_dockerfile_arg_value "${dockerfile}" "RIXL_BRANCH")}" + metadata_pair "vllm.rocm.ucx_repo" "$(resolve_dockerfile_arg_value "${dockerfile}" "UCX_REPO")" + metadata_pair "vllm.rocm.ucx_commit" "${UCX_BRANCH:-$(resolve_dockerfile_arg_value "${dockerfile}" "UCX_BRANCH")}" + metadata_pair "vllm.rocm.rocshmem_repo" "$(resolve_dockerfile_arg_value "${dockerfile}" "ROCSHMEM_REPO")" + metadata_pair "vllm.rocm.rocshmem_commit" "${ROCSHMEM_BRANCH:-$(resolve_dockerfile_arg_value "${dockerfile}" "ROCSHMEM_BRANCH")}" + metadata_pair "vllm.rocm.deepep_repo" "$(resolve_dockerfile_arg_value "${dockerfile}" "DEEPEP_REPO")" + metadata_pair "vllm.rocm.deepep_commit" "${DEEPEP_BRANCH:-$(resolve_dockerfile_arg_value "${dockerfile}" "DEEPEP_BRANCH")}" + metadata_pair "vllm.rocm.deepep_nic" "$(resolve_dockerfile_arg_value "${dockerfile}" "DEEPEP_NIC")" + metadata_pair "vllm.rocm.deepep_rocm_arch" "$(resolve_dockerfile_arg_value "${dockerfile}" "DEEPEP_ROCM_ARCH")" + metadata_pair "vllm.rocm.rixl_cache_key" "${RIXL_CACHE_KEY:-}" + metadata_pair "vllm.rocm.rocshmem_cache_key" "${ROCSHMEM_CACHE_KEY:-}" + metadata_pair "vllm.rocm.deepep_cache_key" "${DEEPEP_CACHE_KEY:-}" + + metadata_pair "vllm.buildkite.build_number" "${BUILDKITE_BUILD_NUMBER:-}" + metadata_pair "vllm.buildkite.build_id" "${BUILDKITE_BUILD_ID:-}" +} + +write_ci_base_metadata_annotations() { + local metadata="$1" + local key="" + local value="" + local annotation="" + + [[ -n "${metadata}" ]] || return 0 + while IFS=$'\t' read -r key value; do + [[ -n "${key}" && -n "${value}" ]] || continue + annotation="manifest:${key}=${value}" + printf ' "%s",\n' "$(hcl_escape_string "${annotation}")" + done <<< "${metadata}" +} + +write_ci_base_metadata_labels() { + local metadata="$1" + local key="" + local value="" + + [[ -n "${metadata}" ]] || return 0 + while IFS=$'\t' read -r key value; do + [[ -n "${key}" && -n "${value}" ]] || continue + printf ' "%s" = "%s"\n' \ + "$(hcl_escape_string "${key}")" \ + "$(hcl_escape_string "${value}")" + done <<< "${metadata}" +} + write_ci_base_label_override() { local target_name="" + local metadata="" local -a ci_base_targets=() - BAKE_FILES=(-f "${VLLM_BAKE_FILE}" -f "${CI_HCL_PATH}") - if [[ -z "${CI_BASE_CONTENT_HASH:-}" ]]; then return 0 fi @@ -1019,16 +1173,23 @@ write_ci_base_label_override() { return 0 fi + metadata=$(ci_base_metadata_pairs) + : > "${CI_BASE_LABEL_OVERRIDE_PATH}" for target_name in "${ci_base_targets[@]}"; do cat >> "${CI_BASE_LABEL_OVERRIDE_PATH}" <> "${CI_BASE_LABEL_OVERRIDE_PATH}" + cat >> "${CI_BASE_LABEL_OVERRIDE_PATH}" <> "${CI_BASE_LABEL_OVERRIDE_PATH}" + cat >> "${CI_BASE_LABEL_OVERRIDE_PATH}" < BEL) +sed -i 's/\x1B_bk;t=[0-9]*\x07//g' "$INPUT_FILE" + # Strip colorization sed -i -r 's/\x1B\[[0-9;]*[mK]//g' "$INPUT_FILE" diff --git a/.buildkite/scripts/ci-fetch-log.sh b/.buildkite/scripts/ci-fetch-log.sh index 3f99bc50a57d..4830135a1120 100755 --- a/.buildkite/scripts/ci-fetch-log.sh +++ b/.buildkite/scripts/ci-fetch-log.sh @@ -1,74 +1,178 @@ #!/bin/bash -# Usage: ./ci-fetch-log.sh [output_file] -# ./ci-fetch-log.sh [output_file] +# Fetch vLLM Buildkite CI logs (public; no login required). # -# Downloads the raw log for a Buildkite job from the public, unauthenticated -# /organizations//pipelines//builds//jobs//download -# endpoint, then strips ANSI/timestamps via ci-clean-log.sh. +# Usage: +# ci-fetch-log.sh [--soft|--all] --pr [] failed jobs in the PR's latest +# build (current branch if omitted) +# ci-fetch-log.sh [--soft|--all] failed jobs in that build +# ci-fetch-log.sh [output] one job; both # and +# ?sid= URL forms work +# ci-fetch-log.sh [output] # -# Find and via: -# gh pr checks --repo vllm-project/vllm -# Each failing row's URL is .../builds/#. -# -# Default output path: ci--.log (e.g. -# ci-68478-019e6b07-daae.log). Jobs in the same build share the UUID's -# first 8 chars, so the second segment is needed for uniqueness when -# fetching multiple jobs in parallel. The script refuses to overwrite an -# existing output file; pass an explicit path or set CI_FETCH_LOG_FORCE=1 -# to override. +# --soft also fetches soft-failed jobs; --all fetches every finished job. +# Saves each log as ci--.log (ANSI/timestamps stripped) and +# prints "\t" per job. [output] is single-job only; "-" +# streams to stdout. Existing files are kept; CI_FETCH_LOG_FORCE=1 refetches. set -euo pipefail ORG="vllm" PIPELINE="ci" +UA="vllm-ci-fetch-log" +UUID_RE='[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' usage() { - echo "Usage: $0 [output_file]" - echo " $0 [output_file]" + sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' + exit 1 +} + +die() { + echo "$1" >&2 exit 1 } -if [ $# -lt 1 ]; then usage; fi +BUILD="" JOB="" SID="" OUT="" +SCOPE="failed" + +while :; do + case "${1:-}" in + --soft) SCOPE="soft" ;; + --all) SCOPE="all" ;; + *) break ;; + esac + shift +done -if [[ "$1" == https://* ]]; then +case "${1:-}" in +--pr) + PR="${2:-}" + # gh pr checks exits non-zero when checks are failing; that is the + # expected case here. + URL=$(gh pr checks ${PR:+"$PR"} --repo vllm-project/vllm 2>/dev/null | + grep -oE "https://buildkite.com/${ORG}/${PIPELINE}/builds/[0-9]+" | + sort -t/ -k7 -n | tail -1 || true) + [ -n "$URL" ] || die "No Buildkite build found via: gh pr checks ${PR:-}" + BUILD="${URL##*/}" + ;; +https://*) BUILD=$(echo "$1" | sed -nE 's#.*/builds/([0-9]+).*#\1#p') - JOB=$(echo "$1" | grep -oE '[0-9a-f]{8}-[0-9a-f-]+' | head -n 1) + JOB=$(echo "$1" | grep -oE "#${UUID_RE}" | head -n 1 | cut -c2- || true) + SID=$(echo "$1" | grep -oE "[?&]sid=${UUID_RE}" | head -n 1 | sed 's/.*sid=//' || true) OUT="${2:-}" -else - if [ $# -lt 2 ]; then usage; fi + [ -n "$BUILD" ] || die "Could not parse build number from: $1" + ;; +[0-9]*) + [ $# -ge 2 ] || usage BUILD="$1" JOB="$2" OUT="${3:-}" -fi - -if [ -z "$BUILD" ] || [ -z "$JOB" ]; then - echo "Could not parse build number or job UUID from: $1" >&2 + ;; +*) usage -fi + ;; +esac -# Jobs in the same build share the UUID's first segment, so include the -# second segment (chars 9-13, e.g. "019e6b07-daae") to keep default filenames -# unique when fetching multiple jobs from one build in parallel. -if [ -z "$OUT" ]; then - OUT="ci-${BUILD}-${JOB:0:13}.log" +COOKIES=$(mktemp) +JOBS_TSV=$(mktemp) +trap 'rm -f "$COOKIES" "$JOBS_TSV"' EXIT + +# Buildkite issues a session cookie on first hit; later requests need it. +curl -fsSL -c "$COOKIES" -A "$UA" \ + "https://buildkite.com/${ORG}/${PIPELINE}/builds/${BUILD}" -o /dev/null + +# The build's job list (id, step uuid, state, name) is served as JSON from +# the user-facing /data/jobs endpoint. Flatten it to TSV for easy filtering: +# job_id step_uuid failed soft_failed finished slug name +curl -fsSL -b "$COOKIES" -A "$UA" \ + "https://buildkite.com/${ORG}/${PIPELINE}/builds/${BUILD}/data/jobs" | + python3 -c ' +import json, re, sys + +data = json.load(sys.stdin) +if data.get("has_next_page"): + print("warning: job list is paginated; some jobs not shown", file=sys.stderr) +for r in data["records"]: + if r.get("type") != "script": + continue + name = (r.get("name") or "").replace("\t", " ").replace("\n", " ") + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:60] + print("\t".join([ + r["id"], + r.get("step_uuid") or "", + str(r.get("passed") is False), + str(bool(r.get("soft_failed"))), + str(bool(r.get("finished_at"))), + slug, + name, + ])) +' >"$JOBS_TSV" || die "Could not list jobs for build ${BUILD}" + +if [ -n "$SID" ] && [ -z "$JOB" ]; then + # The ?sid= in builds//list URLs is the *step* uuid, not the job uuid. + JOB=$(awk -F'\t' -v s="$SID" '$1 == s || $2 == s {print $1; exit}' "$JOBS_TSV") + [ -n "$JOB" ] || die "No job matching sid=${SID} in build ${BUILD}" fi -if [ -e "$OUT" ] && [ -z "${CI_FETCH_LOG_FORCE:-}" ]; then - echo "Refusing to overwrite existing $OUT (set CI_FETCH_LOG_FORCE=1 or pass an explicit output path)." >&2 - exit 1 +fetch_job() { # + curl -fsSL -b "$COOKIES" -A "$UA" \ + "https://buildkite.com/organizations/${ORG}/pipelines/${PIPELINE}/builds/${BUILD}/jobs/$1/download" \ + -o "$2" + bash "$(dirname "$0")/ci-clean-log.sh" "$2" +} + +if [ -n "$JOB" ]; then + # Single-job mode. + NAME=$(awk -F'\t' -v j="$JOB" '$1 == j {print $7; exit}' "$JOBS_TSV") + SLUG=$(awk -F'\t' -v j="$JOB" '$1 == j {print $6; exit}' "$JOBS_TSV") + [ -n "$OUT" ] || OUT="ci-${BUILD}-${SLUG:-${JOB:0:13}}.log" + if [ "$OUT" = "-" ]; then + TMP=$(mktemp) + fetch_job "$JOB" "$TMP" + cat "$TMP" + rm -f "$TMP" + exit 0 + fi + if [ -e "$OUT" ] && [ -z "${CI_FETCH_LOG_FORCE:-}" ]; then + die "Refusing to overwrite existing ${OUT} (set CI_FETCH_LOG_FORCE=1 or pass an output path)." + fi + fetch_job "$JOB" "$OUT" + printf '%s\t%s\n' "$OUT" "${NAME:-$JOB}" + exit 0 fi -COOKIES=$(mktemp) -trap 'rm -f "$COOKIES"' EXIT +# Build-wide mode: fetch finished jobs matching $SCOPE. +[ -z "$OUT" ] || die "[output_file] is only valid when fetching a single job." -# Buildkite issues a session cookie on first hit; subsequent /download needs it. -curl -fsSL -c "$COOKIES" -A "vllm-ci-fetch-log" \ - "https://buildkite.com/${ORG}/${PIPELINE}/builds/${BUILD}" -o /dev/null +case "$SCOPE" in +failed) FILTER='$3 == "True" && $4 == "False" && $5 == "True"' ;; +soft) FILTER='$3 == "True" && $5 == "True"' ;; +all) FILTER='$5 == "True"' ;; +esac -curl -fsSL -b "$COOKIES" -A "vllm-ci-fetch-log" \ - "https://buildkite.com/organizations/${ORG}/pipelines/${PIPELINE}/builds/${BUILD}/jobs/${JOB}/download" \ - -o "$OUT" +if [ "$SCOPE" = "failed" ]; then + SOFT=$(awk -F'\t' '$3 == "True" && $4 == "True"' "$JOBS_TSV" | wc -l) + [ "$SOFT" -eq 0 ] || echo "Skipping ${SOFT} soft-failed job(s); use --soft to include them." >&2 +fi -bash "$(dirname "$0")/ci-clean-log.sh" "$OUT" +FOUND=0 +EMITTED=" " +while IFS=$'\t' read -r job_id _ _ _ _ slug name; do + FOUND=$((FOUND + 1)) + out="ci-${BUILD}-${slug:-${job_id:0:13}}.log" + # Retries share a name with the original job; disambiguate by uuid. + case "$EMITTED" in + *" $out "*) out="ci-${BUILD}-${slug:-job}-${job_id:0:13}.log" ;; + esac + EMITTED="${EMITTED}${out} " + if [ -e "$out" ] && [ -z "${CI_FETCH_LOG_FORCE:-}" ]; then + echo "Keeping existing ${out} (set CI_FETCH_LOG_FORCE=1 to refetch)." >&2 + elif ! fetch_job "$job_id" "$out"; then + echo "Failed to download log for job ${job_id} (${name})." >&2 + continue + fi + printf '%s\t%s\n' "$out" "$name" +done < <(awk -F'\t' "$FILTER" "$JOBS_TSV") -echo "$OUT" +if [ "$FOUND" -eq 0 ]; then + echo "No matching jobs in build ${BUILD} (scope: ${SCOPE})." >&2 +fi diff --git a/.buildkite/scripts/hardware_ci/run-amd-test.sh b/.buildkite/scripts/hardware_ci/run-amd-test.sh index 953074c38826..fee9ab04f4bd 100755 --- a/.buildkite/scripts/hardware_ci/run-amd-test.sh +++ b/.buildkite/scripts/hardware_ci/run-amd-test.sh @@ -28,8 +28,21 @@ ############################################################################### set -o pipefail -# Export Python path -export PYTHONPATH=".." +: "${BUILDKIT_PROGRESS:=plain}" +: "${TERM:=xterm-256color}" +: "${FORCE_COLOR:=1}" +: "${CLICOLOR_FORCE:=1}" +: "${PY_COLORS:=1}" +: "${ROCM_DOCKER_TTY:=1}" +if [[ " ${PYTEST_ADDOPTS:-} " != *" --color"* ]]; then + PYTEST_ADDOPTS="${PYTEST_ADDOPTS:+${PYTEST_ADDOPTS} }--color=yes" +fi +export BUILDKIT_PROGRESS TERM FORCE_COLOR CLICOLOR_FORCE PY_COLORS PYTEST_ADDOPTS ROCM_DOCKER_TTY + +# Export Python path for commands that run directly on the host. Containerized +# tests set this to /vllm-workspace below so spawned Python processes do not +# depend on their current working directory. +export PYTHONPATH="${PYTHONPATH:-..}" ############################################################################### # Helper Functions @@ -147,6 +160,7 @@ EOF echo "--- Building local ROCm test image" docker build \ --pull=false \ + --progress "${BUILDKIT_PROGRESS}" \ --build-arg "BASE_IMAGE=${base_image}" \ -t "${artifact_image}" \ "${context_dir}" || return 1 @@ -365,6 +379,20 @@ remove_docker_container() { } trap remove_docker_container EXIT +# python_only_compile.sh runs `python setup.py develop` and needs the full repo tree +# under /vllm-workspace (Dockerfile.rocm test stage: mkdir src && mv vllm). +# The ROCm wheel artifact tarball only ships a thin tree (tests, etc.), so +# artifact images cannot satisfy that test — use the full rocm/vllm-ci image. +_cmd_probe="${VLLM_TEST_COMMANDS:-}" +if [[ -z "${_cmd_probe}" ]]; then + _cmd_probe="$*" +fi +if [[ "${VLLM_CI_USE_ARTIFACTS:-0}" == "1" && "${_cmd_probe}" == *python_only_compile.sh* ]]; then + echo "INFO: disabling VLLM_CI_USE_ARTIFACTS for python_only_compile (requires full /vllm-workspace tree)" + export VLLM_CI_USE_ARTIFACTS=0 +fi +unset -v _cmd_probe + if ! prepare_artifact_image; then echo "Using full ROCm CI image: ${image_name}" docker pull "${image_name}" || exit 1 @@ -377,6 +405,14 @@ HF_CACHE="$(realpath ~)/huggingface" mkdir -p "${HF_CACHE}" HF_MOUNT="/root/.cache/huggingface" +# Hugging Face Hub defaults to 10s request/download timeouts, while the ROCm +# CI image currently raises downloads to 60s. AMD model-test jobs routinely +# start from a cold or partially-populated shared cache, and the 60s read cap +# has still timed out before pytest reached the vLLM behavior under test. +# Keep the CI default explicit and overridable from the Buildkite environment. +: "${HF_HUB_DOWNLOAD_TIMEOUT:=300}" +: "${HF_HUB_ETAG_TIMEOUT:=60}" + # ---- Command source selection ---- # Prefer VLLM_TEST_COMMANDS (preserves all inner quoting intact). # Fall back to $* for backward compatibility, but warn that inner @@ -416,7 +452,34 @@ fi echo "Final commands: $commands" -MYPYTHONPATH=".." +# The ROCm test image often ships /vllm-workspace without .git (artifact tarball unpack). +# tests/standalone_tests/python_only_compile.sh uses merge-base(HEAD, origin/main) for +# wheels.vllm.ai; compute on the agent (full git checkout) and pass into the container. +vllm_standalone_merge_base="" +checkout="${BUILDKITE_BUILD_CHECKOUT_PATH:-}" +if [[ -z "${checkout}" || ! -d "${checkout}" ]]; then + checkout="." +fi +# Pass safe.directory per-command (-c) because buildkite runs will always fail +# the next check on git 2.35.2+ due to mixed uses of root and buildkite-agent/uids. +if git -c "safe.directory=${checkout}" -C "${checkout}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + vllm_standalone_merge_base="$( + git -c "safe.directory=${checkout}" -C "${checkout}" merge-base HEAD origin/main 2>/dev/null || true + )" +fi +if [[ -z "${vllm_standalone_merge_base}" ]]; then + vllm_standalone_merge_base="${BUILDKITE_COMMIT:-}" +fi +echo "INFO: passing VLLM_STANDALONE_MERGE_BASE into container: ${vllm_standalone_merge_base}" + +MYPYTHONPATH="/vllm-workspace" + +container_job_id="${BUILDKITE_JOB_ID:-${BUILDKITE_PARALLEL_JOB:-0}}" +container_job_id="${container_job_id//[^A-Za-z0-9_.-]/_}" +container_job_id_short="${container_job_id:0:8}" +CONTAINER_TMPDIR="/tmp/vllm-${container_job_id_short}" +CONTAINER_CACHE_ROOT="/tmp/vllm-buildkite-${container_job_id}/cache" +CONTAINER_PREFLIGHT="mkdir -p \"\$TMPDIR\" \"\$TORCHINDUCTOR_CACHE_DIR\" \"\$TRITON_CACHE_DIR\" \"\$VLLM_CACHE_ROOT\" \"\$XDG_CACHE_HOME\" && python -c \"import encodings, importlib.metadata as im, importlib.util as iu; [im.version(d) for d in ('transformers', 'torch', 'ray', 'sympy', 'markupsafe', 'vllm')]; missing=[m for m in ('torch.utils.model_zoo', 'transformers.models.nomic_bert', 'ray.dag', 'sympy.physics', 'markupsafe._speedups') if iu.find_spec(m) is None]; assert not missing, missing\"" # Verify GPU access render_gid=$(getent group render | cut -d: -f3) @@ -484,26 +547,62 @@ if is_multi_node "$commands"; then else echo "--- Single-node job" echo "Render devices: $BUILDKITE_AGENT_META_DATA_RENDER_DEVICES" + docker_run_terminal_args=(-i) + if [[ "${ROCM_DOCKER_TTY}" == "1" ]]; then + docker_run_terminal_args+=(-t) + echo "Docker interactive stdin: enabled; TTY allocation: enabled" + else + echo "Docker interactive stdin: enabled; TTY allocation: disabled" + fi + + ulimit_core_hard=$(ulimit -H -c) + if [[ "$ulimit_core_hard" == "unlimited" ]]; then + # docker run can't pass "unlimited" to --ulimit + ulimit_core_hard="-1" + fi + # Disable core dumps in the ROCm test container unless the ROCm debug agent is enabled + coredump_flags="--ulimit core=0:$ulimit_core_hard" + if [[ "$commands" == *"ROCm debug agent enabled"* ]]; then + # Works around https://github.com/rocm/rocm-systems/issues/6206 + coredump_flags='-e HSA_COREDUMP_PATTERN="/tmp/gpucore.%p"' + else + echo "ROCm debug agent not enabled, coredumps are disabled in the test container." + fi docker run \ + "${docker_run_terminal_args[@]}" \ --device /dev/kfd $BUILDKITE_AGENT_META_DATA_RENDER_DEVICES \ $RDMA_FLAGS \ --network=host \ --shm-size=16gb \ --group-add "$render_gid" \ --rm \ + $coredump_flags \ -e HF_TOKEN \ + -e "HF_HUB_DOWNLOAD_TIMEOUT=${HF_HUB_DOWNLOAD_TIMEOUT}" \ + -e "HF_HUB_ETAG_TIMEOUT=${HF_HUB_ETAG_TIMEOUT}" \ -e AWS_ACCESS_KEY_ID \ -e AWS_SECRET_ACCESS_KEY \ -e BUILDKITE_PARALLEL_JOB \ -e BUILDKITE_PARALLEL_JOB_COUNT \ + -e TERM \ + -e FORCE_COLOR \ + -e CLICOLOR_FORCE \ + -e PY_COLORS \ + -e PYTEST_ADDOPTS \ -v "${HF_CACHE}:${HF_MOUNT}" \ -e "HF_HOME=${HF_MOUNT}" \ -e "PYTHONPATH=${MYPYTHONPATH}" \ + -e "TMPDIR=${CONTAINER_TMPDIR}/tmp" \ + -e "TORCHINDUCTOR_CACHE_DIR=${CONTAINER_CACHE_ROOT}/torchinductor" \ + -e "TRITON_CACHE_DIR=${CONTAINER_CACHE_ROOT}/triton" \ + -e "VLLM_CACHE_ROOT=${CONTAINER_CACHE_ROOT}/vllm" \ + -e "XDG_CACHE_HOME=${CONTAINER_CACHE_ROOT}/xdg" \ -e "PYTORCH_ROCM_ARCH=" \ + -e "VLLM_STANDALONE_MERGE_BASE=${vllm_standalone_merge_base}" \ --name "${container_name}" \ "${image_name}" \ - /bin/bash -c "${commands}" + /bin/bash -c "${CONTAINER_PREFLIGHT} && ${commands}" exit_code=$? handle_pytest_exit "$exit_code" diff --git a/.buildkite/scripts/hardware_ci/run-cpu-test-arm.sh b/.buildkite/scripts/hardware_ci/run-cpu-test-arm.sh index 35513727f160..2d11dd477eac 100755 --- a/.buildkite/scripts/hardware_ci/run-cpu-test-arm.sh +++ b/.buildkite/scripts/hardware_ci/run-cpu-test-arm.sh @@ -8,7 +8,7 @@ set -ex CORE_RANGE=${CORE_RANGE:-0-31} OMP_CORE_RANGE=${OMP_CORE_RANGE:-0-31} -export CMAKE_BUILD_PARALLEL_LEVEL=16 +export CMAKE_BUILD_PARALLEL_LEVEL=32 # Setup cleanup remove_docker_container() { @@ -37,8 +37,10 @@ function cpu_tests() { pytest -x -v -s tests/kernels/test_onednn.py pytest -x -v -s tests/kernels/attention/test_cpu_attn.py pytest -x -v -s tests/kernels/core/test_cpu_activation.py - pytest -x -v -s tests/kernels/moe/test_moe.py -k test_cpu_fused_moe_basic - pytest -x -v -s tests/kernels/mamba/cpu/test_cpu_gdn_ops.py" + pytest -x -v -s tests/kernels/moe/test_cpu_fused_moe.py + pytest -x -v -s tests/kernels/mamba/cpu/test_cpu_gdn_ops.py + pytest -x -v -s tests/kernels/moe/test_cpu_int4_moe.py + pytest -x -v -s tests/kernels/mamba/test_cpu_short_conv.py" # skip tests requiring model downloads if HF_TOKEN is not set # due to rate-limits @@ -62,7 +64,6 @@ function cpu_tests() { set -e pytest -x -v -s tests/quantization/test_compressed_tensors.py::test_compressed_tensors_w8a8_logprobs" - # basic online serving docker exec cpu-test bash -c ' set -e diff --git a/.buildkite/scripts/hardware_ci/run-cpu-test.sh b/.buildkite/scripts/hardware_ci/run-cpu-test.sh index 27ec0068668f..032d8e78333b 100644 --- a/.buildkite/scripts/hardware_ci/run-cpu-test.sh +++ b/.buildkite/scripts/hardware_ci/run-cpu-test.sh @@ -7,10 +7,49 @@ set -euox pipefail # allow to bind to different cores CORE_RANGE=${CORE_RANGE:-48-95} NUMA_NODE=${NUMA_NODE:-1} -IMAGE_NAME="cpu-test-$NUMA_NODE" +AGENT_SLOT=${AGENT_SLOT:-} +IMAGE_NAME="cpu-test-${NUMA_NODE}${AGENT_SLOT:+-${AGENT_SLOT}}" TIMEOUT_VAL=$1 TEST_COMMAND=$2 +# Disk hygiene knobs. Reclaim space only once the Docker root filesystem crosses +# DISK_USAGE_THRESHOLD percent, and cap the shared BuildKit cache at +# BUILDKIT_CACHE_MAX so subsequent builds keep reusing the hottest layers. +DISK_USAGE_THRESHOLD=${DISK_USAGE_THRESHOLD:-70} +BUILDKIT_CACHE_MAX=${BUILDKIT_CACHE_MAX:-80GB} + +# Reclaim disk only when the host is under pressure. We trim (not purge) the +# shared BuildKit cache so cross-job/cross-agent reuse stays intact, and only +# touch dangling images; other agents' uniquely tagged images are left alone. +prune_if_disk_pressure() { + local docker_root disk_usage + docker_root=$(docker info -f '{{.DockerRootDir}}' 2>/dev/null || true) + if [ -z "$docker_root" ]; then + return 0 + fi + disk_usage=$(df "$docker_root" 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%') + if [ "${disk_usage:-0}" -gt "$DISK_USAGE_THRESHOLD" ]; then + echo "--- :broom: Disk usage ${disk_usage}% exceeds ${DISK_USAGE_THRESHOLD}%, reclaiming space" + docker image prune -f || true + docker builder prune -f --keep-storage="$BUILDKIT_CACHE_MAX" || true + else + echo "Disk usage ${disk_usage:-unknown}% within ${DISK_USAGE_THRESHOLD}% threshold; skipping prune" + fi +} + +# Always drop this agent's image once the job ends (the default builder never +# uses it as a cache source, so removing it costs no rebuild speed), then +# reclaim space if needed. Guard every docker call with `|| true` so the trap +# never overrides the test's exit code. +cleanup() { + docker image rm -f "$IMAGE_NAME" || true + prune_if_disk_pressure +} +trap cleanup EXIT + +# Free space up front so a nearly-full host doesn't fail the build. +prune_if_disk_pressure + # building the docker image echo "--- :docker: Building Docker image" docker build --progress plain --tag "$IMAGE_NAME" --target vllm-test -f docker/Dockerfile.cpu . diff --git a/.buildkite/scripts/hardware_ci/run-intel-ci-test.sh b/.buildkite/scripts/hardware_ci/run-intel-ci-test.sh new file mode 100644 index 000000000000..d59ab35db3b7 --- /dev/null +++ b/.buildkite/scripts/hardware_ci/run-intel-ci-test.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +set -euo pipefail + +test_suite="${1:-}" + +if [[ -z "${test_suite}" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +case "${test_suite}" in + example) + pip install tblib==3.1.0 + + python3 examples/basic/offline_inference/generate.py --model facebook/opt-125m --block-size 64 --enforce-eager + python3 examples/basic/offline_inference/generate.py --model facebook/opt-125m --block-size 64 -O3 -cc.cudagraph_mode=NONE + python3 examples/basic/offline_inference/generate.py --model facebook/opt-125m --block-size 64 --enforce-eager -tp 2 --distributed-executor-backend mp + python3 examples/basic/offline_inference/generate.py --model facebook/opt-125m --block-size 64 --enforce-eager --attention-backend=TRITON_ATTN + python3 examples/basic/offline_inference/generate.py --model facebook/opt-125m --block-size 64 --enforce-eager --quantization fp8 + python3 examples/basic/offline_inference/generate.py --model facebook/opt-125m --block-size 64 --enforce-eager --kv-cache-dtype fp8 + python3 examples/basic/offline_inference/generate.py --model nvidia/Llama-3.1-8B-Instruct-FP8 --block-size 64 --enforce-eager --quantization modelopt --kv-cache-dtype fp8 --attention-backend TRITON_ATTN --max-model-len 4096 + python3 examples/basic/offline_inference/generate.py --model superjob/Qwen3-4B-Instruct-2507-GPTQ-Int4 --block-size 64 --enforce-eager --max-model-len 8192 + python3 examples/basic/offline_inference/generate.py --model TheBloke/TinyLlama-1.1B-Chat-v0.3-AWQ --block-size 64 --enforce-eager + python3 examples/basic/offline_inference/generate.py --model ibm-research/PowerMoE-3b --block-size 64 --enforce-eager -tp 2 + python3 examples/basic/offline_inference/generate.py --model ibm-research/PowerMoE-3b --block-size 64 --enforce-eager -tp 2 --enable-expert-parallel + python3 examples/basic/offline_inference/generate.py --model superjob/Qwen3-4B-Instruct-2507-GPTQ-Int4 --max-model-len 8192 + ;; + v1) + cd tests + + pytest -v -s v1/core --ignore=v1/core/test_reset_prefix_cache_e2e.py --ignore=v1/core/test_scheduler_e2e.py + pytest -v -s v1/engine --ignore=v1/engine/test_output_processor.py + pytest -v -s v1/sample --ignore=v1/sample/test_logprobs.py --ignore=v1/sample/test_logprobs_e2e.py -k "not test_topk_only and not test_topp_only and not test_topk_and_topp" + pytest -v -s v1/worker --ignore=v1/worker/test_gpu_model_runner.py --ignore=v1/worker/test_worker_memory_snapshot.py + pytest -v -s v1/structured_output + pytest -v -s v1/test_serial_utils.py + pytest -v -s v1/spec_decode --ignore=v1/spec_decode/test_max_len.py --ignore=v1/spec_decode/test_speculators_eagle3.py --ignore=v1/spec_decode/test_acceptance_length.py --ignore=v1/spec_decode/test_speculators_correctness.py + pytest -v -s v1/kv_connector/unit --ignore=v1/kv_connector/unit/test_multi_connector.py --ignore=v1/kv_connector/unit/test_example_connector.py --ignore=v1/kv_connector/unit/test_lmcache_integration.py --ignore=v1/kv_connector/unit/test_hf3fs_client.py --ignore=v1/kv_connector/unit/test_hf3fs_connector.py --ignore=v1/kv_connector/unit/test_hf3fs_metadata_server.py --ignore=v1/kv_connector/unit/test_offloading_connector.py + ;; + server) + pip install av + cd tests + + pytest -v -s entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py + pytest -v -s benchmarks/test_serve_cli.py + ;; + *) + echo "Unknown Intel test suite: ${test_suite}" >&2 + exit 1 + ;; +esac diff --git a/.buildkite/scripts/hardware_ci/run-intel-test.sh b/.buildkite/scripts/hardware_ci/run-intel-test.sh index 0cbe1b5a0f09..83cde9ad16cc 100755 --- a/.buildkite/scripts/hardware_ci/run-intel-test.sh +++ b/.buildkite/scripts/hardware_ci/run-intel-test.sh @@ -243,8 +243,10 @@ container_name="xpu_${BUILDKITE_COMMIT}_$(tr -dc A-Za-z0-9 < /dev/urandom | head # ---- Command source selection ---- commands="" +commands_source="" if [[ -n "${VLLM_TEST_COMMANDS:-}" ]]; then commands="${VLLM_TEST_COMMANDS}" + commands_source="env" echo "Commands sourced from VLLM_TEST_COMMANDS (quoting preserved)" elif [[ $# -gt 0 ]]; then all_yaml=true @@ -303,8 +305,12 @@ if [[ -z "$commands" ]]; then fi echo "Raw commands: $commands" -commands=$(re_quote_pytest_markers "$commands") -echo "After re-quoting: $commands" +if [[ "$commands_source" != "env" ]]; then + commands=$(re_quote_pytest_markers "$commands") + echo "After re-quoting: $commands" +else + echo "Skipping re-quoting for VLLM_TEST_COMMANDS input" +fi commands=$(apply_intel_test_overrides "$commands") echo "Final commands: $commands" @@ -324,23 +330,6 @@ IMAGE="${IMAGE_TAG_XPU:-${image_name}}" echo "Using image: ${IMAGE}" -if docker image inspect "${IMAGE}" >/dev/null 2>&1; then - echo "Image already exists locally, skipping pull" -else - echo "Image not found locally, waiting for lock..." - - flock /tmp/docker-pull.lock bash -c " - if docker image inspect '${IMAGE}' >/dev/null 2>&1; then - echo 'Image already pulled by another runner' - else - echo 'Pulling image...' - timeout 900 docker pull '${IMAGE}' - fi - " - - echo "Pull step completed" -fi - remove_docker_container() { docker rm -f "${container_name}" || true } @@ -357,9 +346,12 @@ export HF_TOKEN ZE_AFFINITY_MASK { flock 9 - if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then - echo 'Image missing before container creation, pulling again...' + if docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "Image already exists locally, skipping pull" + else + echo "Image not found locally, pulling image..." timeout 900 docker pull "${IMAGE}" + echo "Pull step completed" fi docker create \ @@ -368,14 +360,16 @@ export HF_TOKEN ZE_AFFINITY_MASK --ipc=host \ --privileged \ -v /dev/dri/by-path:/dev/dri/by-path \ - -v "${HOME}/.cache/huggingface:/root/.cache/huggingface" \ + -v "/data/huggingface:/root/.cache/huggingface" \ --entrypoint='' \ -e HF_TOKEN \ -e ZE_AFFINITY_MASK \ + -e BUILDKITE_PARALLEL_JOB \ + -e BUILDKITE_PARALLEL_JOB_COUNT \ -e CMDS \ --name "${container_name}" \ "${IMAGE}" \ - bash -c 'set -e; echo "ZE_AFFINITY_MASK is ${ZE_AFFINITY_MASK:-}"; eval "$CMDS"' \ + bash -c 'set -e; source /opt/intel/oneapi/setvars.sh --force; source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force; echo "ZE_AFFINITY_MASK is ${ZE_AFFINITY_MASK:-}"; eval "$CMDS"' \ >/dev/null } 9>/tmp/docker-pull.lock diff --git a/.buildkite/scripts/hardware_ci/run-npu-test.sh b/.buildkite/scripts/hardware_ci/run-npu-test.sh index 9d33a8c0b227..b925b74c4cb8 100644 --- a/.buildkite/scripts/hardware_ci/run-npu-test.sh +++ b/.buildkite/scripts/hardware_ci/run-npu-test.sh @@ -85,7 +85,7 @@ RUN pip config set global.index-url http://cache-service-vllm.nginx-pypi-cache.s # Install for pytest to make the docker build cache layer always valid RUN --mount=type=cache,target=/root/.cache/pip \ - pip install pytest>=6.0 modelscope + pip install pytest>=6.0 'modelscope<1.38' WORKDIR /workspace/vllm diff --git a/.buildkite/scripts/install-kv-connectors.sh b/.buildkite/scripts/install-kv-connectors.sh index 34c502e6b9a8..b1e024709e17 100755 --- a/.buildkite/scripts/install-kv-connectors.sh +++ b/.buildkite/scripts/install-kv-connectors.sh @@ -4,6 +4,11 @@ set -euo pipefail +if python3 -c "import torch; raise SystemExit(0 if torch.version.hip is not None else 1)"; then + uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + exit 0 +fi + REQUIREMENTS_FILE="${KV_CONNECTORS_REQUIREMENTS:-/vllm-workspace/requirements/kv_connectors.txt}" uv pip install --system -r "${REQUIREMENTS_FILE}" diff --git a/.buildkite/scripts/rocm/build-ci-base.sh b/.buildkite/scripts/rocm/build-ci-base.sh new file mode 100755 index 000000000000..23d17e17b4d0 --- /dev/null +++ b/.buildkite/scripts/rocm/build-ci-base.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Build the ROCm ci_base image, optionally from a freshly rebuilt ROCm base. + +set -euo pipefail + +metadata_get() { + local key="$1" + if command -v buildkite-agent >/dev/null 2>&1; then + buildkite-agent meta-data get "${key}" 2>/dev/null || true + fi +} + +main() { + local base_refreshed="" + + base_refreshed="$(metadata_get rocm-base-refresh)" + if [[ "${base_refreshed}" == "1" ]]; then + export BASE_IMAGE + export CI_BASE_PUSH_STABLE_TAG + + BASE_IMAGE="$(metadata_get rocm-base-image)" + CI_BASE_PUSH_STABLE_TAG="$(metadata_get rocm-base-push-stable-tag)" + CI_BASE_PUSH_STABLE_TAG="${CI_BASE_PUSH_STABLE_TAG:-0}" + + echo "Using refreshed ROCm base image for ci_base: ${BASE_IMAGE}" + echo "Push stable ci_base tag: ${CI_BASE_PUSH_STABLE_TAG}" + fi + + bash .buildkite/scripts/ci-bake-rocm.sh ci-base-rocm-ci-with-deps +} + +main "$@" diff --git a/.buildkite/scripts/rocm/build-test-image.sh b/.buildkite/scripts/rocm/build-test-image.sh new file mode 100755 index 000000000000..9803e20d02e8 --- /dev/null +++ b/.buildkite/scripts/rocm/build-test-image.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build the ROCm CI test image or wheel artifact. +# +# When Dockerfile.rocm_base changes, always build the full image so downstream +# ROCm tests can validate the freshly rebuilt base -> ci_base -> ci image chain. + +set -euo pipefail + +metadata_get() { + local key="$1" + if command -v buildkite-agent >/dev/null 2>&1; then + buildkite-agent meta-data get "${key}" 2>/dev/null || true + fi +} + +use_refreshed_base_if_present() { + local base_refreshed="" + + base_refreshed="$(metadata_get rocm-base-refresh)" + if [[ "${base_refreshed}" != "1" ]]; then + return 1 + fi + + export BASE_IMAGE + export CI_BASE_IMAGE + export IMAGE_TAG_LATEST + + BASE_IMAGE="$(metadata_get rocm-base-image)" + CI_BASE_IMAGE="$(metadata_get rocm-ci-base-image)" + IMAGE_TAG_LATEST="$(metadata_get rocm-ci-image-descriptive)" + + echo "Using refreshed ROCm base image for test image: ${BASE_IMAGE}" + echo "Using refreshed ROCm ci_base image for test image: ${CI_BASE_IMAGE}" + if [[ -n "${IMAGE_TAG_LATEST}" ]]; then + echo "Also tagging full ROCm CI image as: ${IMAGE_TAG_LATEST}" + fi + + return 0 +} + +main() { + local base_refreshed=0 + + if use_refreshed_base_if_present; then + base_refreshed=1 + fi + + if [[ "${ROCM_CI_ARTIFACT_ONLY:-0}" == "1" && "${base_refreshed}" != "1" ]]; then + echo "ROCM_CI_ARTIFACT_ONLY=1; building ROCm wheel artifact only" + IMAGE_TAG="" bash .buildkite/scripts/ci-bake-rocm.sh test-rocm-ci-with-artifacts + return + fi + + bash .buildkite/scripts/ci-bake-rocm.sh test-rocm-ci-with-wheel +} + +main "$@" diff --git a/.buildkite/scripts/rocm/refresh-base-image.sh b/.buildkite/scripts/rocm/refresh-base-image.sh new file mode 100755 index 000000000000..06e3e80c9670 --- /dev/null +++ b/.buildkite/scripts/rocm/refresh-base-image.sh @@ -0,0 +1,513 @@ +#!/usr/bin/env bash +# Build and publish a fresh ROCm base image when Dockerfile.rocm_base changes. +# +# Normal AMD CI builds should not pay for this path. The script no-ops unless +# docker/Dockerfile.rocm_base changed relative to the branch base, the previous +# main commit, or ROCM_BASE_REFRESH_FORCE=1 is set. + +set -euo pipefail + +DOCKERFILE="${ROCM_BASE_DOCKERFILE:-docker/Dockerfile.rocm_base}" +BASE_REPO="${ROCM_BASE_IMAGE_REPO:-rocm/vllm-dev}" +CI_IMAGE_REPO="${ROCM_CI_IMAGE_REPO:-rocm/vllm-ci}" +BUILDER_NAME="${ROCM_BASE_BUILDER_NAME:-vllm-rocm-base-builder}" +DEFAULT_ROCM_BASE_METADATA_VERSION="1" +DEFAULT_ROCM_BASE_CONTENT_FILES="${DOCKERFILE}" +DEFAULT_ROCM_BASE_CONTENT_ARGS="BASE_IMAGE TRITON_BRANCH TRITON_REPO PYTORCH_BRANCH PYTORCH_REPO PYTORCH_VISION_BRANCH PYTORCH_VISION_REPO PYTORCH_AUDIO_BRANCH PYTORCH_AUDIO_REPO FA_BRANCH FA_REPO AITER_BRANCH AITER_REPO MORI_BRANCH MORI_REPO PYTORCH_ROCM_ARCH PYTHON_VERSION USE_SCCACHE" + +metadata_set() { + local key="$1" + local value="$2" + + [[ -n "${value}" ]] || return 0 + if command -v buildkite-agent >/dev/null 2>&1; then + buildkite-agent meta-data set "${key}" "${value}" || true + fi +} + +compute_content_hash() { + local path="" + local file="" + + for path in "$@"; do + if [[ -d "${path}" ]]; then + while IFS= read -r -d '' file; do + printf 'file:%s\n' "${file}" + sha256sum "${file}" + done < <(find "${path}" -type f -print0 | sort -z) + elif [[ -f "${path}" ]]; then + printf 'file:%s\n' "${path}" + sha256sum "${path}" + else + printf 'missing:%s\n' "${path}" + fi + done | sha256sum | cut -d' ' -f1 +} + +clean_docker_tag() { + local input="$1" + echo "${input}" | sed 's/[^a-zA-Z0-9._-]/_/g' | cut -c1-128 +} + +tag_component() { + local input="$1" + local max_chars="${2:-24}" + + clean_docker_tag "${input:-unknown}" | cut -c1-"${max_chars}" +} + +extract_arg_default() { + local arg_name="$1" + + sed -n -E "s/^[[:space:]]*ARG[[:space:]]+${arg_name}=\"?([^\"[:space:]]+)\"?.*/\\1/p" \ + "${DOCKERFILE}" | head -1 +} + +resolve_image_digest() { + local image_ref="$1" + + docker buildx imagetools inspect "${image_ref}" 2>/dev/null \ + | sed -n -E 's/^Digest:[[:space:]]+//p' \ + | head -1 || true +} + +resolve_rocm_base_arg_value() { + local arg_name="$1" + local use_sccache="$2" + + case "${arg_name}" in + USE_SCCACHE) + printf '%s\n' "${use_sccache}" + ;; + *) + extract_arg_default "${arg_name}" + ;; + esac +} + +hash_rocm_base_arg_values() { + local use_sccache="$1" + local base_image_digest="$2" + local arg_name="" + local arg_value="" + shift 2 || true + + for arg_name in "$@"; do + [[ -n "${arg_name}" ]] || continue + arg_value=$(resolve_rocm_base_arg_value "${arg_name}" "${use_sccache}") + printf 'arg:%s=%s\n' "${arg_name}" "${arg_value:-}" + if [[ "${arg_name}" == "BASE_IMAGE" && -n "${arg_value}" ]]; then + printf 'arg:%s.digest=%s\n' "${arg_name}" "${base_image_digest:-unknown}" + fi + done +} + +rocm_version_from_base_image() { + local base_image="$1" + local version="" + + version="$(sed -n -E 's/.*:([0-9]+\.[0-9]+(\.[0-9]+)?)-.*/\1/p' <<<"${base_image}")" + tag_component "${version:-${base_image}}" 16 +} + +git_diff_changed_base() { + local range="$1" + [[ -n "$(git diff --name-only "${range}" -- "${DOCKERFILE}" 2>/dev/null)" ]] +} + +short_git_ref() { + local ref="$1" + + git rev-parse --short "${ref}" 2>/dev/null || printf '%s\n' "${ref}" +} + +extract_arg_default_from_ref() { + local ref="$1" + local arg_name="$2" + local content="" + + content="$(git show "${ref}:${DOCKERFILE}" 2>/dev/null || true)" + sed -n -E "s/^[[:space:]]*ARG[[:space:]]+${arg_name}=\"?([^\"[:space:]]+)\"?.*/\\1/p" \ + <<<"${content}" | head -1 +} + +log_arg_default_changes() { + local old_ref="$1" + local new_ref="$2" + local content_args="${ROCM_BASE_CONTENT_ARGS:-${DEFAULT_ROCM_BASE_CONTENT_ARGS}}" + local arg_name="" + local old_value="" + local new_value="" + local changed=0 + + echo "Changed ROCm base ARG defaults:" + for arg_name in ${content_args}; do + old_value="$(extract_arg_default_from_ref "${old_ref}" "${arg_name}")" + new_value="$(extract_arg_default_from_ref "${new_ref}" "${arg_name}")" + if [[ "${old_value}" != "${new_value}" ]]; then + echo " - ${arg_name}: ${old_value:-} -> ${new_value:-}" + changed=1 + fi + done + + if [[ "${changed}" == "0" ]]; then + echo " - none detected; Dockerfile instructions changed outside tracked ARG defaults" + fi +} + +log_arg_line_diff() { + local range="$1" + local arg_diff="" + + arg_diff="$( + git diff --unified=0 "${range}" -- "${DOCKERFILE}" 2>/dev/null \ + | awk '/^[+-][[:space:]]*ARG[[:space:]]/ && $0 !~ /^(---|\+\+\+)/ { print " " $0 }' \ + || true + )" + + if [[ -n "${arg_diff}" ]]; then + echo "Changed Dockerfile ARG lines:" + printf '%s\n' "${arg_diff}" + fi +} + +log_rocm_base_change_check() { + local context="$1" + local range="$2" + local old_ref="$3" + local old_short="" + local head_short="" + + old_short="$(short_git_ref "${old_ref}")" + head_short="$(short_git_ref HEAD)" + + echo "--- :mag: ROCm base refresh check" + echo "Context: ${context}" + echo "Dockerfile: ${DOCKERFILE}" + echo "Base revision: ${old_short}" + echo "Head revision: ${head_short}" + echo "Git diff range: ${range}" +} + +log_rocm_base_rebuild_reason() { + local context="$1" + local range="$2" + local old_ref="$3" + local changed_files="" + + log_rocm_base_change_check "${context}" "${range}" "${old_ref}" + + changed_files="$(git diff --name-only "${range}" -- "${DOCKERFILE}" 2>/dev/null || true)" + echo "Changed files:" + if [[ -n "${changed_files}" ]]; then + sed 's/^/ - /' <<<"${changed_files}" + else + echo " - ${DOCKERFILE}" + fi + log_arg_default_changes "${old_ref}" HEAD + log_arg_line_diff "${range}" + echo "Decision: rebuilding ROCm base image because ${DOCKERFILE} changed." +} + +rocm_base_changed_in_range() { + local context="$1" + local range="$2" + local old_ref="$3" + + if git_diff_changed_base "${range}"; then + log_rocm_base_rebuild_reason "${context}" "${range}" "${old_ref}" + return 0 + fi + + log_rocm_base_change_check "${context}" "${range}" "${old_ref}" + echo "Decision: ROCm base refresh not required; ${DOCKERFILE} is unchanged." + return 1 +} + +rocm_base_changed() { + local base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}" + local base_ref="refs/remotes/origin/${base_branch}" + local merge_base="" + + if [[ "${ROCM_BASE_REFRESH_SKIP:-0}" == "1" ]]; then + echo "ROCM_BASE_REFRESH_SKIP=1 set; skipping ROCm base refresh" + return 1 + fi + + if [[ "${ROCM_BASE_REFRESH_FORCE:-0}" == "1" ]]; then + echo "ROCM_BASE_REFRESH_FORCE=1 set; refreshing ROCm base image" + return 0 + fi + + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Not in a git checkout; skipping ROCm base refresh unless forced" + return 1 + fi + + if [[ "${BUILDKITE_PULL_REQUEST:-false}" != "false" ]]; then + git fetch --no-tags --depth=200 origin \ + "+refs/heads/${base_branch}:${base_ref}" >/dev/null 2>&1 || true + merge_base=$(git merge-base HEAD "${base_ref}" 2>/dev/null || true) + if [[ -z "${merge_base}" ]]; then + echo "Unable to determine merge base with PR base ${base_ref}; skipping ROCm base refresh unless forced" + return 1 + fi + if rocm_base_changed_in_range \ + "pull request build against ${base_ref}" \ + "${merge_base}...HEAD" \ + "${merge_base}"; then + return 0 + fi + elif [[ "${BUILDKITE_BRANCH:-}" == "${ROCM_BASE_STABLE_BRANCH:-main}" ]] \ + && git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + if rocm_base_changed_in_range \ + "stable branch build; comparing against previous ${ROCM_BASE_STABLE_BRANCH:-main} commit" \ + "HEAD~1..HEAD" \ + "HEAD~1"; then + return 0 + fi + else + git fetch --no-tags --depth=200 origin \ + "+refs/heads/${base_branch}:${base_ref}" >/dev/null 2>&1 || true + merge_base=$(git merge-base HEAD "${base_ref}" 2>/dev/null || true) + if [[ -z "${merge_base}" ]]; then + echo "Unable to determine merge base with branch base ${base_ref}; skipping ROCm base refresh unless forced" + return 1 + fi + if rocm_base_changed_in_range \ + "branch build against ${base_ref}" \ + "${merge_base}...HEAD" \ + "${merge_base}"; then + return 0 + fi + fi + + return 1 +} + +should_push_stable_tag() { + if [[ "${BUILDKITE_PULL_REQUEST:-false}" != "false" ]]; then + return 1 + fi + + if [[ "${ROCM_BASE_PUSH_STABLE_TAG:-}" == "1" ]]; then + return 0 + fi + if [[ "${ROCM_BASE_PUSH_STABLE_TAG:-}" == "0" ]]; then + return 1 + fi + + [[ "${BUILDKITE_PULL_REQUEST:-false}" == "false" \ + && "${BUILDKITE_BRANCH:-}" == "${ROCM_BASE_STABLE_BRANCH:-main}" ]] +} + +setup_builder() { + echo "--- :buildkite: Setting up buildx builder for ROCm base" + if docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then + docker buildx use "${BUILDER_NAME}" + else + docker buildx create --name "${BUILDER_NAME}" --driver docker-container --use + fi + docker buildx inspect --bootstrap +} + +compute_base_content_hash() { + local use_sccache="$1" + local base_image_digest="$2" + local content_files="${ROCM_BASE_CONTENT_FILES:-${DEFAULT_ROCM_BASE_CONTENT_FILES}}" + local content_args="${ROCM_BASE_CONTENT_ARGS:-${DEFAULT_ROCM_BASE_CONTENT_ARGS}}" + local -a content_paths=() + local -a content_arg_names=() + + read -r -a content_paths <<< "${content_files}" + read -r -a content_arg_names <<< "${content_args}" + + { + printf 'content-files-hash:%s\n' "$(compute_content_hash "${content_paths[@]}")" + printf 'dockerfile:%s\n' "${DOCKERFILE}" + printf 'resolved-build-args:\n' + hash_rocm_base_arg_values \ + "${use_sccache}" "${base_image_digest}" "${content_arg_names[@]}" + } | sha256sum | cut -d' ' -f1 +} + +build_base_image() { + local use_sccache="${ROCM_BASE_USE_SCCACHE:-${USE_SCCACHE:-0}}" + local base_hash="" + local build_date="" + local build_suffix="" + local base_image_arg="" + local base_image_digest="" + local rocm_version="" + local triton_arg="" + local pytorch_arg="" + local pytorch_vision_arg="" + local pytorch_audio_arg="" + local fa_arg="" + local aiter_arg="" + local mori_arg="" + local python_version_arg="" + local pytorch_rocm_arch_arg="" + local pytorch_branch="" + local aiter_branch="" + local dependency_summary="" + local descriptor="" + local ci_descriptor="" + local descriptive_tag="" + local stable_tag="${BASE_REPO}:base" + local ci_descriptive_tag="" + local content_files="${ROCM_BASE_CONTENT_FILES:-${DEFAULT_ROCM_BASE_CONTENT_FILES}}" + local content_args="${ROCM_BASE_CONTENT_ARGS:-${DEFAULT_ROCM_BASE_CONTENT_ARGS}}" + local content_files_hash="" + local metadata_version="${ROCM_BASE_METADATA_VERSION:-${DEFAULT_ROCM_BASE_METADATA_VERSION}}" + local -a tags=() + local -a no_cache_args=() + local -a sccache_args=() + local -a content_paths=() + + if [[ ! -f "${DOCKERFILE}" ]]; then + echo "Error: ROCm base Dockerfile not found: ${DOCKERFILE}" >&2 + exit 1 + fi + + build_date="${ROCM_BASE_TAG_DATE:-$(date -u +%Y%m%d)}" + if [[ -n "${BUILDKITE_BUILD_NUMBER:-}" ]]; then + build_suffix="_bk_${BUILDKITE_BUILD_NUMBER}" + fi + base_image_arg="$(extract_arg_default BASE_IMAGE)" + base_image_digest="$(resolve_image_digest "${base_image_arg}")" + read -r -a content_paths <<< "${content_files}" + content_files_hash="$(compute_content_hash "${content_paths[@]}")" + base_hash=$(compute_base_content_hash "${use_sccache}" "${base_image_digest}") + rocm_version="$(rocm_version_from_base_image "${base_image_arg}")" + triton_arg="$(extract_arg_default TRITON_BRANCH)" + pytorch_arg="$(extract_arg_default PYTORCH_BRANCH)" + pytorch_vision_arg="$(extract_arg_default PYTORCH_VISION_BRANCH)" + pytorch_audio_arg="$(extract_arg_default PYTORCH_AUDIO_BRANCH)" + fa_arg="$(extract_arg_default FA_BRANCH)" + aiter_arg="$(extract_arg_default AITER_BRANCH)" + mori_arg="$(extract_arg_default MORI_BRANCH)" + python_version_arg="$(extract_arg_default PYTHON_VERSION)" + pytorch_rocm_arch_arg="$(extract_arg_default PYTORCH_ROCM_ARCH)" + pytorch_branch="$(tag_component "${pytorch_arg}" 16)" + aiter_branch="$(tag_component "${aiter_arg}" 24)" + dependency_summary="base=${base_image_arg},rocm=${rocm_version},python=${python_version_arg},pytorch=${pytorch_arg},torchvision=${pytorch_vision_arg},torchaudio=${pytorch_audio_arg},triton=${triton_arg},flash-attn=${fa_arg},aiter=${aiter_arg},mori=${mori_arg},pytorch-rocm-arch=${pytorch_rocm_arch_arg}" + descriptor="$(clean_docker_tag "base_custom_aiter_${aiter_branch}_torch_${pytorch_branch}_${build_date}${build_suffix}")" + ci_descriptor="$(clean_docker_tag "ci_custom_aiter_${aiter_branch}_torch_${pytorch_branch}_${build_date}${build_suffix}")" + + descriptive_tag="${BASE_REPO}:${descriptor}" + ci_descriptive_tag="${CI_IMAGE_REPO}:${ci_descriptor}" + + tags=(-t "${descriptive_tag}") + if should_push_stable_tag; then + tags+=(-t "${stable_tag}") + metadata_set "rocm-base-push-stable-tag" "1" + else + metadata_set "rocm-base-push-stable-tag" "0" + fi + + if [[ "${ROCM_BASE_NO_CACHE:-1}" == "1" ]]; then + no_cache_args=(--no-cache) + fi + + for env_name in \ + SCCACHE_DOWNLOAD_URL \ + SCCACHE_ENDPOINT \ + SCCACHE_BUCKET_NAME \ + SCCACHE_REGION_NAME \ + SCCACHE_S3_NO_CREDENTIALS; do + if [[ -n "${!env_name:-}" ]]; then + sccache_args+=(--build-arg "${env_name}=${!env_name}") + fi + done + + echo "--- :docker: Building ROCm base image" + echo "Dockerfile: ${DOCKERFILE}" + echo "Descriptive tag: ${descriptive_tag}" + echo "Stable tag: ${stable_tag} ($(should_push_stable_tag && echo enabled || echo disabled))" + echo "Content hash: ${base_hash}" + echo "Dependency summary: ${dependency_summary}" + echo "USE_SCCACHE: ${use_sccache}" + + docker buildx build \ + "${no_cache_args[@]}" \ + --pull \ + --progress "${BUILDKIT_PROGRESS:-plain}" \ + --file "${DOCKERFILE}" \ + --build-arg "USE_SCCACHE=${use_sccache}" \ + "${sccache_args[@]}" \ + --label "org.opencontainers.image.source=https://github.com/vllm-project/vllm" \ + --label "org.opencontainers.image.vendor=vLLM" \ + --label "org.opencontainers.image.title=vLLM ROCm base" \ + --label "org.opencontainers.image.revision=${BUILDKITE_COMMIT:-}" \ + --label "vllm.rocm_base.metadata_version=${metadata_version}" \ + --label "vllm.rocm_base.content_hash=${base_hash}" \ + --label "vllm.rocm_base.content_files_hash=${content_files_hash}" \ + --label "vllm.rocm_base.dockerfile=${DOCKERFILE}" \ + --label "vllm.rocm_base.image.descriptive=${descriptive_tag}" \ + --label "vllm.rocm_base.image.stable=${stable_tag}" \ + --label "vllm.rocm_base.git_commit=${BUILDKITE_COMMIT:-}" \ + --label "vllm.rocm_base.stable_branch=${ROCM_BASE_STABLE_BRANCH:-main}" \ + --label "vllm.rocm_base.descriptor=${descriptor}" \ + --label "vllm.rocm_base.dependency_summary=${dependency_summary}" \ + --label "vllm.rocm_base.base_image=${base_image_arg}" \ + --label "vllm.rocm_base.base_image_digest=${base_image_digest}" \ + --label "vllm.rocm_base.dependency.rocm=${rocm_version}" \ + --label "vllm.rocm_base.dependency.python=${python_version_arg}" \ + --label "vllm.rocm_base.dependency.pytorch=${pytorch_arg}" \ + --label "vllm.rocm_base.dependency.torchvision=${pytorch_vision_arg}" \ + --label "vllm.rocm_base.dependency.torchaudio=${pytorch_audio_arg}" \ + --label "vllm.rocm_base.dependency.triton=${triton_arg}" \ + --label "vllm.rocm_base.dependency.flash_attention=${fa_arg}" \ + --label "vllm.rocm_base.dependency.aiter=${aiter_arg}" \ + --label "vllm.rocm_base.dependency.mori=${mori_arg}" \ + --label "vllm.rocm_base.pytorch_rocm_arch=${pytorch_rocm_arch_arg}" \ + "${tags[@]}" \ + --push \ + . + + docker buildx imagetools inspect "${descriptive_tag}" >/dev/null + + metadata_set "rocm-base-refresh" "1" + metadata_set "rocm-base-image" "${descriptive_tag}" + metadata_set "rocm-base-image-descriptive" "${descriptive_tag}" + metadata_set "rocm-base-image-stable" "${stable_tag}" + metadata_set "rocm-base-image-ci-descriptive" "${ci_descriptive_tag}" + metadata_set "rocm-base-metadata-version" "${metadata_version}" + metadata_set "rocm-base-content-hash" "${base_hash}" + metadata_set "rocm-base-content-files-hash" "${content_files_hash}" + metadata_set "rocm-base-content-files" "${content_files}" + metadata_set "rocm-base-content-args" "${content_args}" + metadata_set "rocm-base-base-image-digest" "${base_image_digest}" + metadata_set "rocm-base-dockerfile" "${DOCKERFILE}" + metadata_set "rocm-base-descriptor" "${descriptor}" + metadata_set "rocm-base-dependency-summary" "${dependency_summary}" + metadata_set "rocm-base-dependency-rocm" "${rocm_version}" + metadata_set "rocm-base-dependency-python" "${python_version_arg}" + metadata_set "rocm-base-dependency-pytorch" "${pytorch_arg}" + metadata_set "rocm-base-dependency-torchvision" "${pytorch_vision_arg}" + metadata_set "rocm-base-dependency-torchaudio" "${pytorch_audio_arg}" + metadata_set "rocm-base-dependency-triton" "${triton_arg}" + metadata_set "rocm-base-dependency-flash-attention" "${fa_arg}" + metadata_set "rocm-base-dependency-aiter" "${aiter_arg}" + metadata_set "rocm-base-dependency-mori" "${mori_arg}" + metadata_set "rocm-base-pytorch-rocm-arch" "${pytorch_rocm_arch_arg}" + metadata_set "rocm-ci-image-descriptive" "${ci_descriptive_tag}" + + echo "--- :white_check_mark: ROCm base image published" + echo "Use BASE_IMAGE=${descriptive_tag} for downstream ROCm CI builds" +} + +main() { + metadata_set "rocm-base-refresh" "0" + + if ! rocm_base_changed; then + echo "ROCm base Dockerfile did not change; skipping base image refresh" + return 0 + fi + + setup_builder + build_base_image +} + +main "$@" diff --git a/.buildkite/scripts/rocm/smoke-test-image.sh b/.buildkite/scripts/rocm/smoke-test-image.sh new file mode 100755 index 000000000000..ed511c9b77a4 --- /dev/null +++ b/.buildkite/scripts/rocm/smoke-test-image.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Fast structural smoke test for the full ROCm CI image. + +set -euo pipefail + +image_ref="${VLLM_CI_SMOKE_IMAGE:-rocm/vllm-ci:${BUILDKITE_COMMIT:?BUILDKITE_COMMIT is required}}" + +docker run --rm --network=none --entrypoint /bin/bash "${image_ref}" -ec ' + if [ ! -d /vllm-workspace ]; then echo Missing directory: /vllm-workspace >&2; exit 1; fi + if [ ! -d /vllm-workspace/tests ]; then echo Missing directory: /vllm-workspace/tests >&2; exit 1; fi + if [ ! -d /vllm-workspace/src/vllm ]; then echo Missing directory: /vllm-workspace/src/vllm >&2; exit 1; fi + if [ ! -x /vllm-workspace/src/vllm/vllm-rs ]; then echo Missing executable: /vllm-workspace/src/vllm/vllm-rs >&2; exit 1; fi + + command -v python3 + command -v uv + command -v pytest + + if ! command -v amd-smi >/dev/null 2>&1 && ! command -v rocminfo >/dev/null 2>&1; then + echo No ROCm CLI found in image >&2 + exit 1 + fi + + python3 - </dev/null 2>&1; then + return + fi + + log_section "Installing cargo-deny" + install_cargo_binstall + cargo binstall --no-confirm cargo-deny +} + install_cargo_nextest() { if command -v cargo-nextest >/dev/null 2>&1; then return @@ -110,8 +125,39 @@ install_uv() { | env UV_INSTALL_DIR="$CARGO_HOME/bin" sh } +setup_pyo3_python() { + local python_version="${PYO3_PYTHON_VERSION:-3.12}" + + log_section "Installing Python ${python_version} for PyO3 tests" + uv python install "$python_version" + PYO3_PYTHON="$(uv python find \ + --managed-python \ + --no-project \ + --resolve-links \ + "$python_version")" + export PYO3_PYTHON + + local python_libdir + python_libdir="$("$PYO3_PYTHON" - <<'PY' +import pathlib +import sysconfig + +libdir = pathlib.Path(sysconfig.get_config_var("LIBDIR")) +ldlibrary = sysconfig.get_config_var("LDLIBRARY") +assert sysconfig.get_config_var("Py_ENABLE_SHARED") == 1 +assert ldlibrary +assert (libdir / ldlibrary).exists(), libdir / ldlibrary +print(libdir) +PY +)" + + export LD_LIBRARY_PATH="${python_libdir}:${LD_LIBRARY_PATH:-}" + export LIBRARY_PATH="${python_libdir}:${LIBRARY_PATH:-}" +} + run_style_clippy() { install_cargo_sort + install_cargo_deny log_section "Checking Rust formatting" cargo fmt --manifest-path rust/Cargo.toml --all -- --check @@ -119,6 +165,13 @@ run_style_clippy() { log_section "Checking Cargo.toml ordering" cargo sort --workspace --check rust + log_section "Checking Rust dependency bans" + cargo deny \ + --manifest-path rust/Cargo.toml \ + check \ + --config rust/deny.toml \ + bans + log_section "Running clippy" cargo clippy \ --manifest-path rust/Cargo.toml \ @@ -132,6 +185,7 @@ run_style_clippy() { run_tests() { install_uv + setup_pyo3_python install_cargo_nextest log_section "Running cargo nextest" diff --git a/.buildkite/scripts/scheduled_integration_test/deepseek_v2_lite_prefetch_offload.sh b/.buildkite/scripts/scheduled_integration_test/deepseek_v2_lite_prefetch_offload.sh index 0eadfa1f80b4..e1808835fdf5 100755 --- a/.buildkite/scripts/scheduled_integration_test/deepseek_v2_lite_prefetch_offload.sh +++ b/.buildkite/scripts/scheduled_integration_test/deepseek_v2_lite_prefetch_offload.sh @@ -33,6 +33,14 @@ if [[ -n "${ATTENTION_BACKEND:-}" ]]; then EXTRA_ARGS+=(--attention-backend "${ATTENTION_BACKEND}") fi +# ROCm: run eager to avoid intermittent HIP-graph decode corruption. +# See https://github.com/ROCm/clr/issues/279 +# TODO(aarushjain29): Revert after TheRock 7.14 +if command -v rocm-smi &> /dev/null || command -v amd-smi &> /dev/null || [[ -d /opt/rocm ]] || [[ -n "${ROCM_PATH:-}" ]]; then + echo "ROCm platform detected: adding --enforce-eager to avoid HIP-graph decode corruption" + EXTRA_ARGS+=(--enforce-eager) +fi + cleanup() { if [[ -n "${SERVER_PID:-}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then kill "${SERVER_PID}" 2>/dev/null || true diff --git a/.buildkite/scripts/scheduled_integration_test/qwen30b_a3b_fp8_dp4_async_eplb.sh b/.buildkite/scripts/scheduled_integration_test/qwen30b_a3b_fp8_dp4_async_eplb.sh index 06743f16b687..82d4e27c2193 100755 --- a/.buildkite/scripts/scheduled_integration_test/qwen30b_a3b_fp8_dp4_async_eplb.sh +++ b/.buildkite/scripts/scheduled_integration_test/qwen30b_a3b_fp8_dp4_async_eplb.sh @@ -18,6 +18,10 @@ wait_for_server() { MODEL="Qwen/Qwen3-30B-A3B-FP8" BACK="allgather_reducescatter" +if command -v rocm-smi &> /dev/null || [[ -d /opt/rocm ]] || [[ -n "${ROCM_PATH:-}" ]]; then + # Disable MOE padding for ROCm since it is causing eplb to fail. + export VLLM_ROCM_MOE_PADDING=0 +fi cleanup() { if [[ -n "${SERVER_PID:-}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then diff --git a/.buildkite/scripts/xpu/create-xpu-ecr-manifest.sh b/.buildkite/scripts/xpu/create-xpu-ecr-manifest.sh new file mode 100644 index 000000000000..78f2c33b3645 --- /dev/null +++ b/.buildkite/scripts/xpu/create-xpu-ecr-manifest.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail + +REGISTRY="public.ecr.aws/q9t5s3a7" +REPO="vllm-release-repo" +ARCH_TAG="${BUILDKITE_COMMIT}-$(uname -m)-xpu" +PLATFORM_TAG="${BUILDKITE_COMMIT}-xpu" + +aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin ${REGISTRY} +docker manifest rm ${REGISTRY}/${REPO}:${PLATFORM_TAG} || true +docker manifest create ${REGISTRY}/${REPO}:${PLATFORM_TAG} ${REGISTRY}/${REPO}:${ARCH_TAG} --amend +docker manifest push ${REGISTRY}/${REPO}:${PLATFORM_TAG} \ No newline at end of file diff --git a/.buildkite/test-amd.yaml b/.buildkite/test-amd.yaml index c7338b4828d2..0b72c852e799 100644 --- a/.buildkite/test-amd.yaml +++ b/.buildkite/test-amd.yaml @@ -8,7 +8,6 @@ # Documentation # label(str): the name of the test. emojis allowed. # fast_check(bool): whether to run this on each commit on the fastcheck pipeline. -# torch_nightly(bool): whether to run this on vllm against the torch nightly pipeline. # fast_check_only(bool): run this test on the fastcheck pipeline only # optional(bool): never run this test by default (i.e. need to unblock manually) unless it's a scheduled nightly run. # soft_fail(bool): allow this step to fail without failing the entire pipeline (useful for flaky or experimental tests). @@ -88,16 +87,16 @@ # - Do NOT remove `VLLM_WORKER_MULTIPROC_METHOD=spawn` setting as ROCm requires this for certain models to function. # # * [Transformers Nightly Models]: Whisper needs `VLLM_WORKER_MULTIPROC_METHOD=spawn` to avoid deadlock. # # * [Plugin Tests (2 GPUs)]: # -# - {`pytest -v -s entrypoints/openai/test_oot_registration.py`}: It needs a clean process # -# - {`pytest -v -s models/test_oot_registration.py`}: It needs a clean process # -# - {`pytest -v -s plugins/lora_resolvers`}: Unit tests for in-tree lora resolver plugins # +# - {`pytest -v -s plugins_tests/test_oot_registration_online.py`}: It needs a clean process # +# - {`pytest -v -s plugins_tests/test_oot_registration_offline.py`}: It needs a clean process # +# - {`pytest -v -s plugins_tests/lora_resolvers`}: Unit tests for in-tree lora resolver plugins # # * [LoRA TP (Distributed)]: # # - There is some Tensor Parallelism related processing logic in LoRA that requires multi-GPU testing for validation. # # - {`pytest -v -s -x lora/test_gptoss_tp.py`}: Disabled for now because MXFP4 backend on non-cuda platform doesn't support # # LoRA yet. # # * [Distributed Tests (NxGPUs)(HW-TAG)]: Don't test llama model here, it seems hf implementation is buggy. See: # # https://github.com/vllm-project/vllm/pull/5689 # -# * [Distributed Tests (NxGPUs)(HW-TAG)]: Some old E2E tests were removed in https://github.com/vllm-project/vllm/pull/33293 # +# * [Distributed Tests (NxGPUs)(HW-TAG)]: Some old E2E tests were removed in https://github.com/vllm-project/vllm/pull/33293 # # in favor of new tests in fusions_e2e. We avoid replicating the new jobs in # # this file as it's deprecated. # # # @@ -112,79 +111,13 @@ steps: # # ######################################################################################################################################### -#----------------------------------------------------- mi250 · basic_correctness -----------------------------------------------------# - -- label: Distributed Model Tests (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - num_gpus: 2 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/model_executor/model_loader/sharded_state_loader.py - - vllm/model_executor/models/ - - vllm/model_executor/layers/ - - vllm/v1/attention/backends/ - - vllm/v1/attention/selector.py - - tests/basic_correctness/ - - tests/model_executor/model_loader/test_sharded_state_loader.py - - tests/models/ - - vllm/_aiter_ops.py - - vllm/platforms/rocm.py - commands: - - TARGET_TEST_SUITE=L4 pytest basic_correctness/ -v -s -m 'distributed(num_gpus=2)' - - CUDA_VISIBLE_DEVICES=0,1 pytest -v -s model_executor/model_loader/test_sharded_state_loader.py -m '(not slow_test)' - - pytest models/test_transformers.py -v -s -m 'distributed(num_gpus=2)' - - pytest models/language -v -s -m 'distributed(num_gpus=2)' - - pytest models/multimodal -v -s -m 'distributed(num_gpus=2)' --ignore models/multimodal/generation/test_whisper.py - - VLLM_WORKER_MULTIPROC_METHOD=spawn pytest models/multimodal/generation/test_whisper.py -v -s -m 'distributed(num_gpus=2)' - #---------------------------------------------------------- mi250 · compile ----------------------------------------------------------# -- label: PyTorch Compilation Unit Tests # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - torch_nightly: true - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/compilation/ - - vllm/model_executor/layers/ - - vllm/v1/worker/ - - vllm/v1/attention/ - - vllm/v1/cudagraph_dispatcher.py - - vllm/config/compilation.py - - csrc/ - - tests/compile - - vllm/platforms/rocm.py - commands: - - "find compile/ -maxdepth 1 -name 'test_*.py' -print0 | xargs -0 -n1 -I{} pytest -s -v '{}'" - -- label: PyTorch Fullgraph # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - optional: true - torch_nightly: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/compilation/ - - vllm/model_executor/ - - vllm/v1/attention/ - - vllm/config/compilation.py - - csrc/ - - tests/compile - - vllm/platforms/rocm.py - commands: - - pytest -v -s compile/fullgraph/test_full_graph.py -k 'not test_fp8_kv_scale_compile' - - label: PyTorch Fullgraph Smoke Test # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] agent_pool: mi250_1 optional: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/compilation/ @@ -197,103 +130,8 @@ steps: commands: - "find compile/fullgraph/ -name 'test_*.py' -not -name 'test_full_graph.py' -exec pytest -s -v {} \\\\;" -- label: Distributed Compile + RPC Tests (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - num_gpus: 2 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/compilation/ - - vllm/distributed/ - - vllm/engine/ - - vllm/executor/ - - vllm/worker/worker_base.py - - vllm/v1/engine/ - - vllm/v1/worker/ - - tests/compile/fullgraph/test_basic_correctness.py - - tests/compile/test_wrapper.py - - tests/entrypoints/llm/test_collective_rpc.py - - vllm/platforms/rocm.py - commands: - - pytest -v -s entrypoints/llm/test_collective_rpc.py - - pytest -v -s ./compile/fullgraph/test_basic_correctness.py - - pytest -v -s ./compile/test_wrapper.py - #-------------------------------------------------------- mi250 · distributed --------------------------------------------------------# -- label: Distributed Comm Ops # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - num_gpus: 2 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed - - tests/distributed - - vllm/platforms/rocm.py - commands: - - pytest -v -s distributed/test_comm_ops.py - - pytest -v -s distributed/test_shm_broadcast.py - - pytest -v -s distributed/test_shm_buffer.py - - pytest -v -s distributed/test_shm_storage.py - -- label: Distributed Torchrun + Shutdown Tests (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - num_gpus: 2 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/ - - vllm/engine/ - - vllm/executor/ - - vllm/worker/worker_base.py - - vllm/v1/engine/ - - vllm/v1/worker/ - - tests/distributed/ - - tests/v1/shutdown - - tests/v1/worker/test_worker_memory_snapshot.py - - vllm/platforms/rocm.py - commands: - - VLLM_TEST_SAME_HOST=1 torchrun --nproc-per-node=4 distributed/test_same_node.py | grep 'Same node test passed' - - VLLM_TEST_SAME_HOST=1 VLLM_TEST_WITH_DEFAULT_DEVICE_SET=1 torchrun --nproc-per-node=4 distributed/test_same_node.py | grep 'Same node test passed' - - CUDA_VISIBLE_DEVICES=0,1 pytest -v -s v1/shutdown - - pytest -v -s v1/worker/test_worker_memory_snapshot.py - -- label: Elastic EP Scaling Test # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_4 - num_gpus: 4 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/ - - vllm/engine/ - - vllm/executor/ - - vllm/compilation/ - - tests/distributed/ - - vllm/platforms/rocm.py - commands: - - pytest -v -s distributed/test_elastic_ep.py - -- label: EPLB Execution # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_4 - num_gpus: 4 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/eplb - - tests/distributed/test_eplb_execute.py - - tests/distributed/test_eplb_spec_decode.py - - vllm/platforms/rocm.py - commands: - - pytest -v -s distributed/test_eplb_execute.py - - pytest -v -s distributed/test_eplb_spec_decode.py - - label: Pipeline + Context Parallelism (4 GPUs) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] @@ -315,96 +153,8 @@ steps: - pytest -v -s distributed/test_pp_cudagraph.py - pytest -v -s distributed/test_pipeline_parallel.py -#---------------------------------------------------------- mi250 · engine -----------------------------------------------------------# - -- label: Engine # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/engine - - tests/test_sequence - - tests/test_config - - tests/test_logger - - tests/test_vllm_port - commands: - - pytest -v -s engine test_sequence.py test_config.py test_logger.py test_vllm_port.py - -#----------------------------------------------------------- mi250 · evals -----------------------------------------------------------# - -- label: Multi-Modal Accuracy Eval (Small Models) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - optional: true - working_dir: "/vllm-workspace/.buildkite/lm-eval-harness" - source_file_dependencies: - - vllm/multimodal/ - - vllm/inputs/ - - vllm/v1/core/ - - vllm/platforms/rocm.py - - vllm/model_executor/model_loader/ - commands: - - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-mm-small.txt --tp-size=1 - -#--------------------------------------------------------- mi250 · examples ----------------------------------------------------------# - -- label: Examples # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - optional: true - working_dir: "/vllm-workspace/examples" - source_file_dependencies: - - vllm/entrypoints - - vllm/multimodal - - examples/ - - vllm/platforms/rocm.py - commands: - - pip install tensorizer - # Basic - - python3 basic/offline_inference/chat.py --attention-backend TRITON_ATTN - - python3 basic/offline_inference/generate.py --model facebook/opt-125m - - python3 basic/offline_inference/generate.py --model meta-llama/Llama-2-13b-chat-hf --cpu-offload-gb 10 - - python3 basic/offline_inference/classify.py - - python3 basic/offline_inference/embed.py - - python3 basic/offline_inference/score.py - # Multi-modal models - - python3 generate/multimodal/audio_language_offline.py --seed 0 - - python3 generate/multimodal/vision_language_offline.py --seed 0 - - python3 generate/multimodal/vision_language_multi_image_offline.py --seed 0 - - python3 generate/multimodal/encoder_decoder_multimodal_offline.py --model-type whisper --seed 0 - # Pooling models - - python3 pooling/embed/vision_embedding_offline.py --seed 0 - # Features demo - - python3 features/automatic_prefix_caching/prefix_caching_offline.py - - python3 deployment/llm_engine_example.py - - python3 features/tensorize_vllm_model.py --model facebook/opt-125m serialize --serialized-directory /tmp/ --suffix v1 && python3 features/tensorize_vllm_model.py --model facebook/opt-125m deserialize --path-to-tensors /tmp/vllm/facebook/opt-125m/v1/model.tensors - - python3 features/speculative_decoding/spec_decode_offline.py --test --method eagle --num_spec_tokens 3 --dataset-name hf --dataset-path philschmid/mt-bench --num-prompts 80 --temp 0 --top-p 1.0 --top-k -1 --tp 1 --enable-chunked-prefill --max-model-len 2048 - - python3 features/speculative_decoding/spec_decode_offline.py --test --method eagle3 --num_spec_tokens 3 --dataset-name hf --dataset-path philschmid/mt-bench --num-prompts 80 --temp 0 --top-p 1.0 --top-k -1 --tp 1 --enable-chunked-prefill --max-model-len 1536 - #---------------------------------------------------------- mi250 · kernels ----------------------------------------------------------# -- label: Kernels Core Operation Test # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - csrc/ - - tests/kernels/core - - tests/kernels/test_top_k_per_row.py - - tests/kernels/test_concat_mla_q.py - - vllm/model_executor/layers/rotary_embedding/ - - vllm/_aiter_ops.py - - vllm/platforms/rocm.py - commands: - - pytest -v -s kernels/core --ignore=kernels/core/test_minimax_reduce_rms.py kernels/test_concat_mla_q.py kernels/test_top_k_per_row.py - - label: Kernels Helion Test # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] @@ -416,7 +166,7 @@ steps: - tests/kernels/helion/ - vllm/platforms/rocm.py commands: - - pip install helion==1.0.0 + - pip install helion==1.1.0 - pytest -v -s kernels/helion/ - label: Kernels Mamba Test # TBD @@ -433,22 +183,6 @@ steps: commands: - pytest -v -s kernels/mamba -#----------------------------------------------------------- mi250 · lora ------------------------------------------------------------# - -- label: LoRA %N # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - parallelism: 4 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/lora - - tests/lora - - vllm/platforms/rocm.py - commands: - - pytest -v -s lora --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --ignore=lora/test_chatglm3_tp.py --ignore=lora/test_llama_tp.py --ignore=lora/test_qwen3_with_multi_loras.py --ignore=lora/test_olmoe_tp.py --ignore=lora/test_deepseekv2_tp.py --ignore=lora/test_gptoss_tp.py --ignore=lora/test_qwen3moe_tp.py --ignore=lora/test_qwen35_densemodel_lora.py - #------------------------------------------------------ mi250 · models / basic -------------------------------------------------------# - label: Basic Models Test (Other CPU) # TBD @@ -457,7 +191,6 @@ steps: agent_pool: mi250_1 no_gpu: true optional: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -466,50 +199,6 @@ steps: commands: - pytest -v -s models/test_utils.py models/test_vision.py -- label: Basic Models Tests (Extra Initialization) %N # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - torch_nightly: true - parallelism: 2 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/model_executor/models/ - - vllm/model_executor/layers/ - - tests/models/test_initialization.py - - tests/models/registry.py - - vllm/_aiter_ops.py - - vllm/platforms/rocm.py - commands: - - pytest -v -s models/test_initialization.py -k 'not test_can_initialize_small_subset' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB - -- label: Basic Models Tests (Initialization) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - torch_nightly: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/models/test_initialization.py - - tests/models/registry.py - commands: - - pytest -v -s models/test_initialization.py::test_can_initialize_small_subset - -- label: Basic Models Tests (Other) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - torch_nightly: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/models/test_terratorch.py - - tests/models/test_transformers.py - - tests/models/test_registry.py - commands: - - pytest -v -s models/test_terratorch.py models/test_transformers.py models/test_registry.py - #----------------------------------------------------- mi250 · models / language -----------------------------------------------------# - label: Language Models Test (MTEB) # TBD @@ -534,111 +223,21 @@ steps: commands: - pytest -v -s models/language/generation_ppl_test -- label: Language Models Tests (Extra Standard) %N # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - torch_nightly: true - parallelism: 2 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/model_executor/models/ - - vllm/model_executor/model_loader/ - - vllm/model_executor/layers/ - - vllm/v1/attention/backends/ - - vllm/v1/attention/selector.py - - tests/models/language/pooling/test_embedding.py - - tests/models/language/generation/test_common.py - - tests/models/language/pooling/test_classification.py - - vllm/_aiter_ops.py - - vllm/platforms/rocm.py - commands: - - pip freeze | grep -E 'torch' - - pytest -v -s models/language -m 'core_model and slow_test' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB - #---------------------------------------------------- mi250 · models / multimodal ----------------------------------------------------# -- label: Multi-Modal Models (Extended Generation 2) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/models/multimodal/generation - commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - - pytest -v -s models/multimodal/generation/test_common.py -m 'split(group=0) and not core_model' - -- label: Multi-Modal Models (Extended Pooling) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/models/multimodal/pooling - commands: - - pytest -v -s models/multimodal/pooling -m 'not core_model' - -- label: "Multi-Modal Models (Standard) 3: llava + qwen2_vl" # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - torch_nightly: true - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/models/multimodal - commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "not qwen2 and not qwen3 and not gemma" - - pytest -v -s models/multimodal/generation/test_qwen2_vl.py -m core_model - -#---------------------------------------------------------- mi250 · plugins ----------------------------------------------------------# - -- label: Plugin Tests (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - num_gpus: 2 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/plugins/ - - tests/plugins/ - - vllm/platforms/rocm.py - commands: - # BEGIN: platform plugin and general plugin tests, all the code in-between runs on dummy platform - - pip install -e ./plugins/vllm_add_dummy_platform - - pytest -v -s plugins_tests/test_platform_plugins.py - - pip uninstall vllm_add_dummy_platform -y - # END: platform plugin tests - # BEGIN: `io_processor` plugins test, all the code in between uses the `prithvi_io_processor` plugin - - pip install -e ./plugins/prithvi_io_processor_plugin - - pytest -v -s plugins_tests/test_io_processor_plugins.py - - pytest -v -s plugins_tests/test_terratorch_io_processor_plugins.py - - pip uninstall prithvi_io_processor_plugin -y - # END: `io_processor` plugins test - # BEGIN: `bge_m3_sparse io_processor` test - - pip install -e ./plugins/bge_m3_sparse_plugin - - pytest -v -s plugins_tests/test_bge_m3_sparse_io_processor_plugins.py - - pip uninstall bge_m3_sparse_plugin -y - # END: `bge_m3_sparse io_processor` test - # BEGIN: `stat_logger` plugins test - - pip install -e ./plugins/vllm_add_dummy_stat_logger - - pytest -v -s plugins_tests/test_stats_logger_plugins.py - - pip uninstall dummy_stat_logger -y - # END: `stat_logger` plugins test - # BEGIN: other tests - - pytest -v -s plugins_tests/test_scheduler_plugins.py - - pip install -e ./plugins/vllm_add_dummy_model - - pytest -v -s distributed/test_distributed_oot.py - - pytest -v -s entrypoints/openai/chat_completion/test_oot_registration.py - - pytest -v -s models/test_oot_registration.py - - pytest -v -s plugins/lora_resolvers - +- label: "Multi-Modal Models (Standard) 3: llava + qwen2_vl" # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] + agent_pool: mi250_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/ + - tests/models/multimodal + commands: + - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "not qwen2 and not qwen3 and not gemma" + - pytest -v -s models/multimodal/generation/test_qwen2_vl.py -m core_model + #------------------------------------------------------------ mi250 · v1 -------------------------------------------------------------# - label: Batch Invariance (H100-MI250) # TBD @@ -676,7 +275,7 @@ steps: - pytest -v -s v1/cudagraph/test_cudagraph_mode.py - label: e2e Core (1 GPU) # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 35 mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] agent_pool: mi250_1 optional: true @@ -766,90 +365,6 @@ steps: commands: - pytest -v -s v1/attention -- label: V1 Sample + Logits # TBD - timeout_in_minutes: 60 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_1 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/v1/sample - - tests/v1/logits_processors - - tests/v1/test_oracle.py - - tests/v1/test_request.py - - tests/v1/test_outputs.py - commands: - - pytest -v -s v1/sample - - pytest -v -s v1/logits_processors - - pytest -v -s v1/test_oracle.py - - pytest -v -s v1/test_request.py - - pytest -v -s v1/test_outputs.py - -- label: Distributed DP Tests (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - num_gpus: 2 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/ - - vllm/engine/ - - vllm/executor/ - - vllm/worker/worker_base.py - - vllm/v1/engine/ - - vllm/v1/worker/ - - tests/v1/distributed - - tests/entrypoints/openai/test_multi_api_servers.py - - vllm/platforms/rocm.py - commands: - - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_async_llm_dp.py - - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_eagle_dp.py - - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_external_lb_dp.py - - DP_SIZE=2 pytest -v -s entrypoints/openai/test_multi_api_servers.py - -- label: NixlConnector PD + Spec Decode acceptance (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - num_gpus: 2 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py - - vllm/v1/worker/kv_connector_model_runner_mixin.py - - tests/v1/kv_connector/nixl_integration/ - - vllm/platforms/rocm.py - commands: - - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - ATTENTION_BACKEND=ROCM_ATTN bash v1/kv_connector/nixl_integration/spec_decode_acceptance_test.sh - -- label: V1 e2e (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_2 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/ - - tests/v1/e2e - commands: - - pytest -v -s v1/e2e/spec_decode/test_spec_decode.py -k "tensor_parallelism" - -- label: Distributed NixlConnector PD accuracy (4 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] - agent_pool: mi250_4 - num_gpus: 4 - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py - - tests/v1/kv_connector/nixl_integration/ - - vllm/platforms/rocm.py - commands: - - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - ROCM_ATTN=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - #------------------------------------------------------------- mi250 · misc ------------------------------------------------------------# - label: Async Engine, Inputs, Utils, Worker, Config (CPU) # TBD @@ -865,6 +380,7 @@ steps: - tests/test_outputs.py - tests/test_pooling_params.py - tests/test_ray_env.py + - tests/test_sampling_params.py - tests/multimodal - tests/renderers - tests/standalone_tests/lazy_imports.py @@ -880,15 +396,68 @@ steps: - pytest -v -s test_outputs.py - pytest -v -s test_pooling_params.py - pytest -v -s test_ray_env.py + - pytest -v -s test_sampling_params.py - pytest -v -s -m 'cpu_test' multimodal - pytest -v -s renderers - pytest -v -s tokenizers_ - - pytest -v -s reasoning --ignore=reasoning/test_seedoss_reasoning_parser.py --ignore=reasoning/test_glm4_moe_reasoning_parser.py + - pytest -v -s reasoning - pytest -v -s tool_parsers - pytest -v -s parser - pytest -v -s transformers_utils - pytest -v -s config +#------------------------------------------------------------ mi250 · rust -----------------------------------------------------------# + +- label: Rust Frontend Cargo Style + Clippy # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] + agent_pool: mi250_1 + no_gpu: true + working_dir: "/vllm-workspace" + source_file_dependencies: + - rust/ + - rust-toolchain.toml + - .buildkite/test_areas/rust_frontend_cargo.yaml + - .buildkite/scripts/run-rust-frontend-cargo-ci.sh + commands: + - bash .buildkite/scripts/run-rust-frontend-cargo-ci.sh style-clippy + +- label: Rust Frontend Cargo Tests # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] + agent_pool: mi250_1 + no_gpu: true + working_dir: "/vllm-workspace" + source_file_dependencies: + - rust/ + - rust-toolchain.toml + - .buildkite/test_areas/rust_frontend_cargo.yaml + - .buildkite/scripts/run-rust-frontend-cargo-ci.sh + commands: + - bash .buildkite/scripts/run-rust-frontend-cargo-ci.sh test + +#----------------------------------------------------------- mi250 · docker ----------------------------------------------------------# + +- label: Docker Build Metadata (ROCm) # TBD + timeout_in_minutes: 20 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250] + agent_pool: mi250_1 + no_gpu: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - .buildkite/scripts/docker-build-metadata-args.sh + - .buildkite/scripts/ci-bake-rocm.sh + - docker/Dockerfile + - docker/Dockerfile.cpu + - docker/Dockerfile.rocm + - docker/Dockerfile.rocm_base + - docker/ci-rocm.hcl + - docker/docker-bake.hcl + - docker/docker-bake-rocm.hcl + - tests/tools/test_docker_build_metadata_args.py + commands: + - pytest -v -s tools/test_docker_build_metadata_args.py + ######################################################################################################################################### # # # MI300 (gfx942) tests # @@ -898,25 +467,24 @@ steps: #----------------------------------------------------- mi300 · basic_correctness -----------------------------------------------------# - label: Basic Correctness # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 95 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/basic_correctness/test_basic_correctness - tests/basic_correctness/test_cpu_offload - - tests/basic_correctness/test_cumem.py + - tests/basic_correctness/test_mem.py commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s basic_correctness/test_cumem.py - - pytest -v -s basic_correctness/test_basic_correctness.py + - pytest -v -s basic_correctness/test_mem.py + - VLLM_TARGET_TEST_SUITE=MI300 pytest -v -s basic_correctness/test_basic_correctness.py - pytest -v -s basic_correctness/test_cpu_offload.py - label: Distributed Model Tests (2 GPUs) # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 110 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_2 num_gpus: 2 @@ -934,9 +502,9 @@ steps: - tests/model_executor/model_loader/test_sharded_state_loader.py - tests/models/ commands: - - TARGET_TEST_SUITE=L4 pytest basic_correctness/ -v -s -m 'distributed(num_gpus=2)' + - TARGET_TEST_SUITE=MI300 pytest basic_correctness/ -v -s -m 'distributed(num_gpus=2)' - CUDA_VISIBLE_DEVICES=0,1 pytest -v -s model_executor/model_loader/test_sharded_state_loader.py -m '(not slow_test)' - - pytest models/test_transformers.py -v -s -m 'distributed(num_gpus=2)' + - pytest models/transformers/test_backend.py -v -s -m 'distributed(num_gpus=2)' - pytest models/language -v -s -m 'distributed(num_gpus=2)' - pytest models/multimodal -v -s -m 'distributed(num_gpus=2)' --ignore models/multimodal/generation/test_whisper.py --ignore models/multimodal/generation/test_phi4siglip.py - pytest models/multimodal/generation/test_phi4siglip.py -v -s -m 'distributed(num_gpus=2)' @@ -948,6 +516,7 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -957,6 +526,25 @@ steps: #---------------------------------------------------------- mi300 · compile ----------------------------------------------------------# +- label: PyTorch Compilation Unit Tests # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/compilation/ + - vllm/model_executor/layers/ + - vllm/v1/worker/ + - vllm/v1/attention/ + - vllm/v1/cudagraph_dispatcher.py + - vllm/config/compilation.py + - csrc/ + - tests/compile + - vllm/platforms/rocm.py + commands: + - "find compile/ -maxdepth 1 -name 'test_*.py' -print0 | xargs -0 -n1 -I{} pytest -s -v '{}'" + - label: Fusion E2E Config Sweep (H100-MI300) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] @@ -1009,6 +597,23 @@ steps: commands: - pytest -s -v compile/passes --ignore compile/passes/distributed +- label: PyTorch Fullgraph # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/compilation/ + - vllm/model_executor/ + - vllm/v1/attention/ + - vllm/config/compilation.py + - csrc/ + - tests/compile + - vllm/platforms/rocm.py + commands: + - pytest -v -s compile/fullgraph/test_full_graph.py -k 'not test_fp8_kv_scale_compile' + - label: Pytorch Nightly Dependency Override Check # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] @@ -1042,7 +647,7 @@ steps: #----------------------------------------------------------- mi300 · cuda ------------------------------------------------------------# -- label: Platform Tests (CUDA) # TBD +- label: Platform Tests # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 @@ -1073,10 +678,27 @@ steps: #-------------------------------------------------------- mi300 · distributed --------------------------------------------------------# +- label: Distributed Comm Ops # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/distributed + - tests/distributed + - vllm/platforms/rocm.py + commands: + - pytest -v -s distributed/test_comm_ops.py + - pytest -v -s distributed/test_shm_broadcast.py + - pytest -v -s distributed/test_shm_buffer.py + - pytest -v -s distributed/test_shm_storage.py + - label: EPLB Algorithm # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/distributed/eplb @@ -1086,9 +708,24 @@ steps: - pytest -v -s distributed/test_eplb_algo.py - pytest -v -s distributed/test_eplb_utils.py -- label: Distributed Tests (2xH100-2xMI250) # TBD +- label: EPLB Execution # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_4 + num_gpus: 4 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/distributed/eplb + - tests/distributed/test_eplb_execute.py + - tests/distributed/test_eplb_spec_decode.py + - vllm/platforms/rocm.py + commands: + - pytest -v -s distributed/test_eplb_execute.py + - pytest -v -s distributed/test_eplb_spec_decode.py + +- label: Distributed Tests (2xH100-2xMI300) # TBD + timeout_in_minutes: 75 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_2 num_gpus: 2 working_dir: "/vllm-workspace/" @@ -1098,13 +735,19 @@ steps: - vllm/model_executor/layers/fused_moe/ - vllm/v1/attention/backends/ - vllm/v1/attention/selector.py + - tests/v1/distributed/test_dbo.py - tests/distributed/test_context_parallel.py - examples/features/data_parallel/data_parallel_offline.py - vllm/_aiter_ops.py - vllm/platforms/rocm.py commands: - pytest -v -s tests/distributed/test_context_parallel.py + - VLLM_ALLOW_INSECURE_SERIALIZATION=1 python3 examples/rl/rlhf_async_new_apis.py + - VLLM_LOGGING_LEVEL=DEBUG python3 examples/features/data_parallel/data_parallel_offline.py --model=Qwen/Qwen1.5-MoE-A2.7B -tp=1 -dp=2 --max-model-len=2048 --all2all-backend=deepep_high_throughput - VLLM_LOGGING_LEVEL=DEBUG python3 examples/features/data_parallel/data_parallel_offline.py --model=Qwen/Qwen1.5-MoE-A2.7B -tp=1 -dp=2 --max-model-len=2048 --all2all-backend=allgather_reducescatter --disable-nccl-for-dp-synchronization + - pytest -v -s tests/v1/distributed/test_dbo.py + - VLLM_ALLOW_INSECURE_SERIALIZATION=1 pytest -v -s tests/distributed/test_weight_transfer.py + - pytest -v -s tests/distributed/test_packed_tensor.py - label: Distributed Tests (4xA100-4xMI300) # TBD timeout_in_minutes: 180 @@ -1118,7 +761,7 @@ steps: commands: - pytest -v -s distributed/test_custom_all_reduce.py - torchrun --nproc_per_node=2 distributed/test_ca_buffer_sharing.py - - TARGET_TEST_SUITE=A100 pytest basic_correctness/ -v -s -m 'distributed(num_gpus=2)' + - TARGET_TEST_SUITE=MI300 pytest basic_correctness/ -v -s -m 'distributed(num_gpus=2)' - pytest -v -s -x lora/test_mixtral.py - label: Distributed Torchrun + Examples (4 GPUs) # TBD @@ -1205,66 +848,75 @@ steps: #-------------------------------------------------------- mi300 · entrypoints --------------------------------------------------------# -- label: Entrypoints Integration (API Server 2) # TBD +- label: Entrypoints Unit Tests # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + fast_check: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/entrypoints + - tests/entrypoints/unit_tests + - tests/entrypoints/weight_transfer + - vllm/platforms/rocm.py + commands: + - pytest -v -s entrypoints/unit_tests + - pytest -v -s entrypoints/weight_transfer + +- label: Entrypoints Integration (LLM) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 optional: true fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/serve + - tests/entrypoints/llm commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/serve --ignore=entrypoints/serve/dev/rpc - - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc + - pytest -v -s entrypoints/llm --ignore=entrypoints/llm/test_generate.py --ignore=entrypoints/llm/test_collective_rpc.py --ignore=entrypoints/llm/offline_mode + - pytest -v -s entrypoints/llm/test_generate.py # it needs a clean process + - pytest -v -s entrypoints/llm/offline_mode # Needs to avoid interference with other tests -- label: Entrypoints Integration (API Server openai - Part 1) # TBD +- label: Entrypoints Integration (API Server) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 - fast_check: true - torch_nightly: true optional: true + fast_check: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/openai - - tests/entrypoints/test_chat_utils + - tests/entrypoints/serve + - tests/entrypoints/scale_out commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai/chat_completion --ignore=entrypoints/openai/chat_completion/test_oot_registration.py + - pytest -v -s entrypoints/serve --ignore=entrypoints/serve/dev/rpc + - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc + - pytest -v -s entrypoints/scale_out -- label: Entrypoints Integration (API Server openai - Part 2) # TBD +- label: Entrypoints Integration (API Server OpenAI - Part 1) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 - optional: true fast_check: true - torch_nightly: true + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/entrypoints/openai - tests/entrypoints/test_chat_utils - - tests/entrypoints/generate - - tests/tool_use commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai/completion --ignore=entrypoints/openai/completion/test_tensorizer_entrypoint.py - - pytest -v -s entrypoints/test_chat_utils.py - - pytest -v -s entrypoints/generate - - pytest -v -s tool_use + - pytest -v -s entrypoints/openai/ --ignore=entrypoints/openai/completion --ignore=entrypoints/openai/chat_completion --ignore=entrypoints/openai/responses --ignore=entrypoints/openai/correctness -- label: Entrypoints Integration (API Server openai - Part 3) # TBD +- label: Entrypoints Integration (API Server OpenAI - Part 2) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 - optional: true fast_check: true - torch_nightly: true + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -1272,91 +924,90 @@ steps: - tests/entrypoints/test_chat_utils commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai --ignore=entrypoints/openai/chat_completion --ignore=entrypoints/openai/completion --ignore=entrypoints/openai/correctness/ --ignore=entrypoints/openai/tool_parsers/ --ignore=entrypoints/openai/responses --ignore=entrypoints/openai/test_multi_api_servers.py + - pytest -v -s entrypoints/openai/chat_completion + - pytest -v -s entrypoints/openai/completion --ignore=entrypoints/openai/completion/test_tensorizer_entrypoint.py -- label: Entrypoints Integration (Speech to Text) # TBD +- label: Entrypoints Integration (API Server Generate) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/speech_to_text + - tests/tool_use + - tests/entrypoints/tool_parsers + - tests/entrypoints/anthropic + - tests/entrypoints/generate commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/speech_to_text + - pytest -v -s tool_use + - pytest -v -s entrypoints/tool_parsers + - pytest -v -s entrypoints/generate + - pytest -v -s entrypoints/anthropic -- label: Entrypoints Integration (LLM) # TBD +- label: Entrypoints Integration (Responses API) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 - optional: true fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/llm - - tests/entrypoints/offline_mode + - tests/entrypoints/openai/responses commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/llm --ignore=entrypoints/llm/test_generate.py --ignore=entrypoints/llm/test_collective_rpc.py - - pytest -v -s entrypoints/llm/test_generate.py - - pytest -v -s entrypoints/offline_mode + - pytest -v -s entrypoints/openai/responses -- label: Entrypoints Integration (Pooling) # TBD +- label: Entrypoints Integration (Speech to Text) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/pooling + - tests/entrypoints/speech_to_text commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/pooling + - pytest -v -s entrypoints/speech_to_text -- label: Entrypoints Integration (Responses API) # TBD +- label: Entrypoints Integration (Multimodal) timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/openai/responses + - tests/entrypoints/multimodal commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai/responses + - pytest -v -s entrypoints/multimodal -- label: Entrypoints Unit Tests # TBD +- label: Entrypoints Integration (Pooling) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 fast_check: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - - vllm/entrypoints - - tests/entrypoints/ - - vllm/platforms/rocm.py + - vllm/ + - tests/entrypoints/pooling commands: - - pytest -v -s entrypoints/openai/tool_parsers - - pytest -v -s entrypoints/ --ignore=entrypoints/llm --ignore=entrypoints/offline_mode --ignore=entrypoints/openai --ignore=entrypoints/serve --ignore=entrypoints/test_chat_utils.py --ignore=entrypoints/pooling --ignore=entrypoints/speech_to_text --ignore=tests/entrypoints/generate + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s entrypoints/pooling - label: OpenAI API correctness # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - csrc/ - vllm/entrypoints/openai/ - - vllm/model_executor/models/whisper.py - vllm/model_executor/layers/ - vllm/v1/attention/backends/ - vllm/v1/attention/selector.py @@ -1406,10 +1057,28 @@ steps: commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-small.txt +- label: MRCR Eval Small Models # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/model_executor/models/ + - vllm/model_executor/model_loader/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + - tests/evals/mrcr/ + commands: + - pytest -s -v evals/mrcr/test_mrcr_correctness.py --config-list-file=evals/mrcr/configs/models-small.txt + - label: LM Eval Small Models (MI300) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true working_dir: "/vllm-workspace/.buildkite/lm-eval-harness" source_file_dependencies: - csrc/ @@ -1423,6 +1092,21 @@ steps: commands: - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-small-rocm.txt +- label: Multi-Modal Accuracy Eval (Small Models) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/.buildkite/lm-eval-harness" + source_file_dependencies: + - vllm/multimodal/ + - vllm/inputs/ + - vllm/v1/core/ + - vllm/platforms/rocm.py + - vllm/model_executor/model_loader/ + commands: + - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-mm-small.txt --tp-size=1 + - label: GPQA Eval (GPT-OSS) (2xH100-2xMI300) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] @@ -1524,6 +1208,27 @@ steps: commands: - bash .buildkite/scripts/scheduled_integration_test/qwen30b_a3b_fp8_block_ep_eplb.sh 0.8 200 8020 +- label: Qwen3-30B-A3B-FP8 DP4 Async EPLB Accuracy (4xH100-4xMI300) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_4 + num_gpus: 4 + optional: true + working_dir: "/vllm-workspace" + source_file_dependencies: + - vllm/model_executor/models/ + - vllm/model_executor/model_loader/ + - vllm/model_executor/layers/quantization/ + - vllm/distributed/eplb + - vllm/model_executor/layers/fused_moe/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - .buildkite/scripts/scheduled_integration_test/ + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + commands: + - bash .buildkite/scripts/scheduled_integration_test/qwen30b_a3b_fp8_dp4_async_eplb.sh 0.8 200 8050 + - label: Qwen3-Next-80B-A3B-Instruct MTP Async EPLB Accuracy # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] @@ -1568,10 +1273,31 @@ steps: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-mi3xx.txt +- label: ROCm LM Eval Large Models (8 GPUs) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_8 + optional: true + num_gpus: 8 + working_dir: "/vllm-workspace/.buildkite/lm-eval-harness" + source_file_dependencies: + - vllm/model_executor/models/ + - vllm/model_executor/model_loader/ + - vllm/model_executor/layers/quantization/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - vllm/model_executor/layers/layernorm.py + - csrc/ + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + commands: + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-large-rocm.txt --tp-size=8 + #--------------------------------------------------------- mi300 · examples ----------------------------------------------------------# - label: Examples # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 90 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 optional: true @@ -1584,7 +1310,7 @@ steps: commands: - pip install tensorizer # Basic - - python3 basic/offline_inference/chat.py --attention-backend TRITON_ATTN + - python3 basic/offline_inference/chat.py - python3 basic/offline_inference/generate.py --model facebook/opt-125m - python3 basic/offline_inference/generate.py --model meta-llama/Llama-2-13b-chat-hf --cpu-offload-gb 10 - python3 basic/offline_inference/classify.py @@ -1606,10 +1332,26 @@ steps: #---------------------------------------------------------- mi300 · kernels ----------------------------------------------------------# -- label: Kernels Attention Test %N # TBD +- label: vLLM IR Tests # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/" + source_file_dependencies: + - vllm/ir + - vllm/kernels + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + commands: + - pytest -v -s tests/ir + - pytest -v -s tests/kernels/ir + +- label: Kernels Attention Test %N # TBD + timeout_in_minutes: 100 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true parallelism: 2 working_dir: "/vllm-workspace/tests" source_file_dependencies: @@ -1626,118 +1368,311 @@ steps: - label: Kernels Core Operation Test # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] - agent_pool: mi300_1 + agent_pool: mi300_1 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - csrc/ + - tests/kernels/core + - tests/kernels/test_top_k_per_row.py + - tests/kernels/test_concat_mla_q.py + - vllm/model_executor/layers/rotary_embedding/ + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + commands: + - pytest -v -s kernels/core --ignore=kernels/core/test_minimax_reduce_rms.py kernels/test_concat_mla_q.py kernels/test_top_k_per_row.py + +- label: Kernels KDA Test # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/model_executor/layers/fla/ops/kda.py + - vllm/model_executor/layers/fla/ops/chunk_delta_h.py + - vllm/model_executor/layers/fla/ops/l2norm.py + - tests/kernels/test_kda.py + - vllm/platforms/rocm.py + commands: + - pytest -v -s kernels/test_kda.py + +- label: Kernels MoE Test %N # TBD + timeout_in_minutes: 95 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + parallelism: 5 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - csrc/quantization/cutlass_w8a8/moe/ + - csrc/moe/ + - tests/kernels/moe + - vllm/model_executor/layers/fused_moe/ + - vllm/distributed/device_communicators/ + - vllm/envs.py + - vllm/config + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + commands: + - pytest -v -s kernels/moe --ignore=kernels/moe/test_modular_oai_triton_moe.py --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT + - pytest -v -s kernels/moe/test_modular_oai_triton_moe.py --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT + +- label: Kernels Quantization Test %N # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + parallelism: 2 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - csrc/quantization/ + - vllm/model_executor/layers/quantization + - tests/kernels/quantization + - tests/kernels/quantization/test_rocm_skinny_gemms.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + - vllm/model_executor/kernels/ + commands: + - pytest -v -s kernels/quantization --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT + +- label: Kernels FP8 MoE Test (2xH100-2xMI300) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - csrc/moe/ + - csrc/quantization/w8a8/cutlass/moe/ + - vllm/model_executor/layers/fused_moe/ + - tests/kernels/moe/test_deepep_moe.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + - vllm/envs.py + commands: + - pytest -v -s kernels/moe/test_deepep_moe.py + +#----------------------------------------------------------- mi300 · lora ------------------------------------------------------------# + +- label: LoRA %N # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + parallelism: 4 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/lora + - tests/lora + - vllm/platforms/rocm.py + commands: + - pytest -v -s lora --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --ignore=lora/test_chatglm3_tp.py --ignore=lora/test_llama_tp.py --ignore=lora/test_qwen3_with_multi_loras.py --ignore=lora/test_olmoe_tp.py --ignore=lora/test_deepseekv2_tp.py --ignore=lora/test_gptoss_tp.py --ignore=lora/test_qwen3moe_tp.py --ignore=lora/test_qwen35_densemodel_lora.py + +- label: LoRA TP (Distributed) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_4 + num_gpus: 4 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/lora + - tests/lora + - vllm/platforms/rocm.py + commands: + - pytest -v -s -x lora/test_chatglm3_tp.py + - pytest -v -s -x lora/test_llama_tp.py + - pytest -v -s -x lora/test_qwen3_with_multi_loras.py + - pytest -v -s -x lora/test_olmoe_tp.py + - pytest -v -s -x lora/test_gptoss_tp.py + - pytest -v -s -x lora/test_qwen35_densemodel_lora.py + +#------------------------------------------------------ mi300 · model_executor -------------------------------------------------------# + +- label: Model Executor # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/engine/arg_utils.py + - vllm/config/model.py + - vllm/model_executor + - tests/model_executor + - tests/entrypoints/openai/completion/test_tensorizer_entrypoint.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + commands: + - apt-get update && apt-get install -y curl libsodium23 + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s model_executor -m '(not slow_test)' + - pytest -v -s entrypoints/openai/completion/test_tensorizer_entrypoint.py + +#---------------------------------------------------- mi300 · model_runner_v2 -------------------------------------------------------# + +- label: Model Runner V2 Core Tests # TBD + timeout_in_minutes: 60 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/v1/worker/gpu/ + - vllm/v1/worker/gpu_worker.py + - vllm/v1/core/sched/ + - vllm/v1/attention/ + - tests/v1/engine/test_llm_engine.py + - tests/v1/e2e/ + - tests/entrypoints/llm/test_struct_output_generate.py + - vllm/platforms/rocm.py + commands: + - set -x + - export VLLM_USE_V2_MODEL_RUNNER=1 + - pytest -v -s v1/engine/test_llm_engine.py -k "not test_engine_metrics" + - ENFORCE_EAGER=1 pytest -v -s v1/e2e/general/test_async_scheduling.py -k "not ngram" + - pytest -v -s v1/e2e/general/test_context_length.py + - pytest -v -s v1/e2e/general/test_min_tokens.py + - pytest -v -s entrypoints/llm/test_struct_output_generate.py -k "xgrammar and not speculative_config6 and not speculative_config7 and not speculative_config8 and not speculative_config0" + +- label: Model Runner V2 Examples # TBD + timeout_in_minutes: 60 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/examples" + source_file_dependencies: + - vllm/v1/worker/gpu/ + - vllm/v1/core/sched/ + - vllm/v1/worker/gpu_worker.py + - examples/basic/offline_inference/ + - examples/generate/multimodal/ + - examples/features/ + - examples/pooling/embed/vision_embedding_offline.py + - examples/features/tensorize_vllm_model.py + - examples/deployment/llm_engine_example.py + - vllm/platforms/rocm.py + commands: + - set -x + - export VLLM_USE_V2_MODEL_RUNNER=1 + - pip install tensorizer + - python3 basic/offline_inference/chat.py + - python3 basic/offline_inference/generate.py --model facebook/opt-125m + - python3 generate/multimodal/audio_language_offline.py --seed 0 + - python3 generate/multimodal/vision_language_offline.py --seed 0 + - python3 generate/multimodal/vision_language_multi_image_offline.py --seed 0 + - python3 generate/multimodal/encoder_decoder_multimodal_offline.py --model-type whisper --seed 0 + - python3 pooling/embed/vision_embedding_offline.py --seed 0 + - python3 features/automatic_prefix_caching/prefix_caching_offline.py + - python3 deployment/llm_engine_example.py + - python3 features/tensorize_vllm_model.py --model facebook/opt-125m serialize --serialized-directory /tmp/ --suffix v1 && python3 features/tensorize_vllm_model.py --model facebook/opt-125m deserialize --path-to-tensors /tmp/vllm/facebook/opt-125m/v1/model.tensors + - python3 features/speculative_decoding/spec_decode_offline.py --test --method eagle --num_spec_tokens 3 --dataset-name hf --dataset-path philschmid/mt-bench --num-prompts 80 --temp 0 --top-p 1.0 --top-k -1 --tp 1 --enable-chunked-prefill --max-model-len 2048 + - python3 features/speculative_decoding/spec_decode_offline.py --test --method eagle3 --num_spec_tokens 3 --dataset-name hf --dataset-path philschmid/mt-bench --num-prompts 80 --temp 0 --top-p 1.0 --top-k -1 --tp 1 --enable-chunked-prefill --max-model-len 1536 + +- label: Model Runner V2 Distributed (2 GPUs) # TBD + timeout_in_minutes: 60 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - - csrc/ - - tests/kernels/core - - tests/kernels/test_top_k_per_row.py - - tests/kernels/test_concat_mla_q.py - - vllm/model_executor/layers/rotary_embedding/ - - vllm/_aiter_ops.py + - vllm/v1/worker/gpu/ + - vllm/v1/worker/gpu_worker.py + - tests/basic_correctness/test_basic_correctness.py + - tests/v1/distributed/test_async_llm_dp.py + - tests/v1/distributed/test_eagle_dp.py - vllm/platforms/rocm.py commands: - - pytest -v -s kernels/core --ignore=kernels/core/test_minimax_reduce_rms.py kernels/test_concat_mla_q.py kernels/test_top_k_per_row.py + - set -x + - export VLLM_USE_V2_MODEL_RUNNER=1 + - TARGET_TEST_SUITE=MI300 pytest -v -s basic_correctness/test_basic_correctness.py -m 'distributed(num_gpus=2)' -k "not ray and not True" + - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_async_llm_dp.py -k "not ray" + - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_eagle_dp.py -- label: Kernels MoE Test %N # TBD +- label: Model Runner V2 Pipeline Parallelism (4 GPUs) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] - agent_pool: mi300_1 - parallelism: 4 + agent_pool: mi300_4 + num_gpus: 4 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - - csrc/quantization/cutlass_w8a8/moe/ - - csrc/moe/ - - tests/kernels/moe - - vllm/model_executor/layers/fused_moe/ - - vllm/distributed/device_communicators/ - - vllm/envs.py - - vllm/config - - vllm/_aiter_ops.py + - vllm/v1/worker/gpu/ + - vllm/v1/worker/gpu_worker.py + - tests/distributed/test_pipeline_parallel.py + - tests/distributed/test_pp_cudagraph.py - vllm/platforms/rocm.py commands: - - pytest -v -s kernels/moe --ignore=kernels/moe/test_modular_oai_triton_moe.py --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT - - pytest -v -s kernels/moe/test_modular_oai_triton_moe.py --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT + - set -x + - export VLLM_USE_V2_MODEL_RUNNER=1 + - pytest -v -s distributed/test_pipeline_parallel.py -k "not ray and not Jamba" + - pytest -v -s distributed/test_pp_cudagraph.py -k "not ray" -- label: Kernels Quantization Test %N # TBD +- label: Model Runner V2 Spec Decode # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 optional: true - parallelism: 2 working_dir: "/vllm-workspace/tests" source_file_dependencies: - - csrc/quantization/ - - vllm/model_executor/layers/quantization - - tests/kernels/quantization - - tests/kernels/quantization/test_rocm_skinny_gemms.py - - vllm/_aiter_ops.py + - vllm/v1/worker/gpu/ + - vllm/v1/worker/gpu_worker.py + - tests/v1/spec_decode/test_max_len.py + - tests/v1/spec_decode/test_rejection_sampler_utils.py + - tests/v1/spec_decode/test_synthetic_rejection_sampler_utils.py + - tests/v1/e2e/spec_decode/test_spec_decode.py - vllm/platforms/rocm.py - - vllm/model_executor/kernels/ commands: - - pytest -v -s kernels/quantization --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT + - set -x + - export VLLM_USE_V2_MODEL_RUNNER=1 + - pytest -v -s v1/spec_decode/test_max_len.py -k "eagle or mtp" + - pytest -v -s v1/spec_decode/test_rejection_sampler_utils.py + - pytest -v -s v1/spec_decode/test_synthetic_rejection_sampler_utils.py + - pytest -v -s v1/e2e/spec_decode/test_spec_decode.py -k "eagle or mtp" -- label: Kernels FP8 MoE Test (2xH100-2xMI300) # TBD +#------------------------------------------------------ mi300 · models / basic -------------------------------------------------------# + +- label: Basic Models Tests (Extra Initialization) %N # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] - agent_pool: mi300_2 - optional: true + agent_pool: mi300_1 + parallelism: 6 working_dir: "/vllm-workspace/tests" source_file_dependencies: - - csrc/moe/ - - csrc/quantization/w8a8/cutlass/moe/ - - vllm/model_executor/layers/fused_moe/ - - tests/kernels/moe/test_deepep_moe.py + - vllm/model_executor/models/ + - vllm/model_executor/layers/ + - tests/models/test_initialization.py + - tests/models/registry.py - vllm/_aiter_ops.py - vllm/platforms/rocm.py - - vllm/envs.py commands: - - pytest -v -s kernels/moe/test_deepep_moe.py - -#----------------------------------------------------------- mi300 · lora ------------------------------------------------------------# + - pytest -v -s models/test_initialization.py -k 'not test_can_initialize_small_subset' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB -- label: LoRA TP (Distributed) # TBD +- label: Basic Models Tests (Initialization) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] - agent_pool: mi300_4 - num_gpus: 4 + agent_pool: mi300_1 working_dir: "/vllm-workspace/tests" source_file_dependencies: - - vllm/lora - - tests/lora - - vllm/platforms/rocm.py + - vllm/ + - tests/models/test_initialization.py + - tests/models/registry.py commands: - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True - - pytest -v -s -x lora/test_chatglm3_tp.py - - pytest -v -s -x lora/test_llama_tp.py - - pytest -v -s -x lora/test_qwen3_with_multi_loras.py - - pytest -v -s -x lora/test_olmoe_tp.py - - pytest -v -s -x lora/test_gptoss_tp.py - - pytest -v -s -x lora/test_qwen35_densemodel_lora.py - -#------------------------------------------------------ mi300 · model_executor -------------------------------------------------------# + - pytest -v -s models/test_initialization.py::test_can_initialize_small_subset -- label: Model Executor # TBD - timeout_in_minutes: 180 +- label: Basic Models Tests (Other) # TBD + timeout_in_minutes: 90 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 optional: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - - vllm/engine/arg_utils.py - - vllm/config/model.py - - vllm/model_executor - - tests/model_executor - - tests/entrypoints/openai/completion/test_tensorizer_entrypoint.py - - vllm/_aiter_ops.py - - vllm/platforms/rocm.py + - vllm/ + - tests/models/test_terratorch.py + - tests/models/transformers/test_backend.py + - tests/models/test_registry.py commands: - - apt-get update && apt-get install -y curl libsodium23 - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s model_executor -m '(not slow_test)' - - pytest -v -s entrypoints/openai/completion/test_tensorizer_entrypoint.py + - pytest -v -s models/test_terratorch.py models/transformers/test_backend.py models/test_registry.py #----------------------------------------------------- mi300 · models / language -----------------------------------------------------# @@ -1758,7 +1693,6 @@ steps: mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 optional: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -1767,6 +1701,28 @@ steps: - pip freeze | grep -E 'torch' - pytest -v -s models/language -m 'core_model and (not slow_test)' +- label: Language Models Tests (Extra Standard) %N # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + parallelism: 2 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/model_executor/models/ + - vllm/model_executor/model_loader/ + - vllm/model_executor/layers/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - tests/models/language/pooling/test_embedding.py + - tests/models/language/generation/test_common.py + - tests/models/language/pooling/test_classification.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + commands: + - pip freeze | grep -E 'torch' + - pytest -v -s models/language -m 'core_model and slow_test' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB + #---------------------------------------------------- mi300 · models / multimodal ----------------------------------------------------# - label: Multi-Modal Models (Extended Generation 1) # TBD @@ -1780,10 +1736,8 @@ steps: - tests/models/multimodal/generation - tests/models/multimodal/test_mapping.py commands: - - uv pip install --system --no-build-isolation 'git+https://github.com/AndreasKaratzas/mamba@rocm-7.0-v2.3.0' - - uv pip install --system --no-build-isolation 'git+https://github.com/Dao-AILab/causal-conv1d@v1.6.0' - - pytest -v -s models/language/generation -m hybrid_model --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB - + - pytest -v -s models/multimodal/generation -m 'not core_model' --ignore models/multimodal/generation/test_common.py + - pytest -v -s models/multimodal/test_mapping.py - label: Multi-Modal Models (Extended Generation 2) # TBD timeout_in_minutes: 180 @@ -1795,9 +1749,7 @@ steps: - vllm/ - tests/models/multimodal/generation commands: - - uv pip install --system --no-build-isolation 'git+https://github.com/AndreasKaratzas/mamba@rocm-7.0-v2.3.0' - - uv pip install --system --no-build-isolation 'git+https://github.com/Dao-AILab/causal-conv1d@v1.6.0' - - pytest -v -s models/language/generation -m '(not core_model) and (not hybrid_model)' + - pytest -v -s models/multimodal/generation/test_common.py -m 'split(group=0) and not core_model' - label: Multi-Modal Models (Extended Generation 3) # TBD @@ -1810,21 +1762,18 @@ steps: - vllm/ - tests/models/multimodal/generation commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m 'split(group=1) and not core_model' - label: "Multi-Modal Models (Standard) 1: qwen2" # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 - torch_nightly: true optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/models/multimodal commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "qwen2" - pytest -v -s models/multimodal/generation/test_ultravox.py -m core_model @@ -1832,7 +1781,6 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 - torch_nightly: true optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: @@ -1840,7 +1788,6 @@ steps: - tests/models/multimodal/generation - tests/models/multimodal/test_mapping.py commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "not qwen2 and not qwen3 and not gemma" - pytest -v -s models/multimodal/generation/test_qwen2_vl.py -m core_model @@ -1848,14 +1795,12 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/models/multimodal/generation - tests/models/multimodal/test_mapping.py commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal -m core_model --ignore models/multimodal/generation/test_common.py --ignore models/multimodal/generation/test_ultravox.py --ignore models/multimodal/generation/test_qwen2_5_vl.py --ignore models/multimodal/generation/test_qwen2_vl.py --ignore models/multimodal/generation/test_whisper.py --ignore models/multimodal/generation/test_memory_leak.py --ignore models/multimodal/processing - pytest -v -s models/multimodal/generation/test_memory_leak.py -m core_model - cd .. && VLLM_WORKER_MULTIPROC_METHOD=spawn pytest -v -s tests/models/multimodal/generation/test_whisper.py -m core_model @@ -1871,7 +1816,6 @@ steps: - tests/models/multimodal - tests/models/registry.py commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/processing/test_tensor_schema.py - label: Multi-Modal Processor (CPU) %N # TBD @@ -1887,7 +1831,6 @@ steps: - tests/models/multimodal - tests/models/registry.py commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/processing --ignore models/multimodal/processing/test_tensor_schema.py --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB #----------------------------------------------------- mi300 · models / quantized -----------------------------------------------------# @@ -1933,27 +1876,218 @@ steps: - label: Transformers Nightly Models (Single) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] - agent_pool: mi300_1 + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/" + source_file_dependencies: + - vllm/model_executor/models/ + - vllm/model_executor/model_loader/ + - vllm/multimodal/ + - vllm/model_executor/layers/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + - tests/models/ + - examples/ + commands: + - pip install --upgrade git+https://github.com/huggingface/transformers + - pytest -v -s tests/models/transformers/test_backend.py + - pytest -v -s tests/models/multimodal/test_mapping.py + - python3 examples/basic/offline_inference/chat.py + - python3 examples/generate/multimodal/vision_language_offline.py --model-type qwen2_5_vl + - VLLM_WORKER_MULTIPROC_METHOD=spawn python3 examples/generate/multimodal/audio_language_offline.py --model-type whisper + +#---------------------------------------------------------- mi300 · plugins ----------------------------------------------------------# + +- label: Plugin Tests (2 GPUs) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/plugins/ + - tests/plugins/ + - vllm/platforms/rocm.py + commands: + # BEGIN: platform plugin and general plugin tests, all the code in-between runs on dummy platform + - pip install -e ./plugins/vllm_add_dummy_platform + - pytest -v -s plugins_tests/test_platform_plugins.py + - pip uninstall vllm_add_dummy_platform -y + # END: platform plugin tests + # BEGIN: `io_processor` plugins test, all the code in between uses the `prithvi_io_processor` plugin + - pip install -e ./plugins/prithvi_io_processor_plugin + - pytest -v -s plugins_tests/test_io_processor_plugins.py + - pytest -v -s plugins_tests/test_terratorch_io_processor_plugins.py + - pip uninstall prithvi_io_processor_plugin -y + # END: `io_processor` plugins test + # BEGIN: `bge_m3_sparse io_processor` test + - pip install -e ./plugins/bge_m3_sparse_plugin + - pytest -v -s plugins_tests/test_bge_m3_sparse_io_processor_plugins.py + - pip uninstall bge_m3_sparse_plugin -y + # END: `bge_m3_sparse io_processor` test + # BEGIN: `colbert_query io_processor` test + - pip install -e ./plugins/colbert_query_plugin + - pytest -v -s plugins_tests/test_colbert_query_io_processor_plugins.py + - pip uninstall colbert_query_plugin -y + # END: `colbert_query io_processor` test + # BEGIN: `stat_logger` plugins test + - pip install -e ./plugins/vllm_add_dummy_stat_logger + - pytest -v -s plugins_tests/test_stats_logger_plugins.py + - pip uninstall dummy_stat_logger -y + # END: `stat_logger` plugins test + # BEGIN: `endpoint` plugins test + - pip install -e ./plugins/vllm_add_dummy_endpoint_plugin + - pytest -v -s plugins_tests/test_endpoint_plugins.py + - pip uninstall vllm_add_dummy_endpoint_plugin -y + # END: `endpoint` plugins test + # BEGIN: other tests + - pytest -v -s plugins_tests/test_scheduler_plugins.py + - pip install -e ./plugins/vllm_add_dummy_model + - pytest -v -s distributed/test_distributed_oot.py + - pytest -v -s plugins_tests/test_oot_registration_online.py # it needs a clean process + - pytest -v -s plugins_tests/test_oot_registration_offline.py # it needs a clean process + - pytest -v -s plugins_tests/lora_resolvers # unit tests for in-tree lora resolver plugins + +- label: GGUF Plugin # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + soft_fail: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/model_executor/layers/quantization + - tests/plugins_tests/test_gguf_plugin.py + - tests/plugins_tests/gguf + - vllm/platforms/rocm.py + commands: + - pip install "vllm-gguf-plugin >= 0.0.2" + - pytest -v -s plugins_tests/gguf + +#------------------------------------------------------- mi300 · rust_frontend -------------------------------------------------------# + +- label: Rust Frontend OpenAI Coverage # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - rust/ + - vllm/benchmarks/ + - vllm/entrypoints/openai/ + - vllm/entrypoints/serve/ + - vllm/v1/sample/ + - tests/utils.py + - tests/benchmarks/test_serve_cli.py + - tests/entrypoints/openai/chat_completion/test_chat_completion.py + - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py + - tests/entrypoints/openai/completion/test_shutdown.py + - tests/entrypoints/openai/test_return_token_ids.py + - tests/entrypoints/openai/test_uds.py + - tests/v1/sample/test_logprobs_e2e.py + - vllm/platforms/rocm.py + commands: + - export VLLM_USE_RUST_FRONTEND=1 + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s benchmarks/test_serve_cli.py -k "not insecure and not (test_bench_serve and not test_bench_serve_chat)" + - pytest -v -s entrypoints/openai/chat_completion/test_chat_completion.py -k "not test_invalid_json_schema and not test_invalid_regex" + - pytest -v -s entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py -k "not multiple" + - pytest -v -s entrypoints/openai/completion/test_shutdown.py -k "not engine_failure and not test_abort_timeout_exits_quickly" + - pytest -v -s entrypoints/openai/test_return_token_ids.py -k "not test_comparison" + - pytest -v -s entrypoints/openai/test_uds.py + - pytest -v -s v1/sample/test_logprobs_e2e.py -k "test_prompt_logprobs_e2e_server" + +- label: Rust Frontend Serve Admin Coverage # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - rust/ + - vllm/entrypoints/openai/ + - vllm/entrypoints/serve/ + - vllm/v1/engine/ + - tests/utils.py + - tests/entrypoints/serve/dev/rpc/test_collective_rpc.py + - tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py + - tests/entrypoints/serve/instrumentator/test_basic.py + - tests/entrypoints/serve/instrumentator/test_metrics.py + - tests/entrypoints/serve/tokenize/test_tokenization.py + - vllm/platforms/rocm.py + commands: + - export VLLM_USE_RUST_FRONTEND=1 + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc/test_collective_rpc.py + - pytest -v -s entrypoints/serve/instrumentator/test_basic.py -k "not show_version and not server_load" + - pytest -v -s entrypoints/scale_out/token_in_token_out/test_serving_tokens.py -k "not stream and not lora and not test_generate_logprobs and not stop_string_workflow" + - pytest -v -s entrypoints/serve/instrumentator/test_metrics.py -k "text and not show and not run_batch and not test_metrics_counts and not test_metrics_exist" + - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info" + +- label: Rust Frontend Core Correctness # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - rust/ + - vllm/entrypoints/openai/ + - tests/utils.py + - tests/entrypoints/openai/correctness/test_lmeval.py + - vllm/platforms/rocm.py + commands: + - export VLLM_USE_RUST_FRONTEND=1 + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -s entrypoints/openai/correctness/test_lmeval.py::test_lm_eval_accuracy_v1_engine + +- label: Rust Frontend Tool Use # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - rust/ + - vllm/entrypoints/openai/ + - vllm/tool_parsers/ + - tests/utils.py + - tests/tool_use/ + - vllm/platforms/rocm.py + commands: + - export VLLM_USE_RUST_FRONTEND=1 + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s tool_use --ignore=tool_use/mistral --models llama3.2 -k "not test_response_format_with_tool_choice_required and not test_parallel_tool_calls_false and not test_tool_call_and_choice" + +- label: Rust Frontend Distributed # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_4 + num_gpus: 4 optional: true - working_dir: "/vllm-workspace/" + working_dir: "/vllm-workspace/tests" source_file_dependencies: - - vllm/model_executor/models/ - - vllm/model_executor/model_loader/ - - vllm/multimodal/ - - vllm/model_executor/layers/ - - vllm/v1/attention/backends/ - - vllm/v1/attention/selector.py - - vllm/_aiter_ops.py + - rust/ + - vllm/distributed/ + - vllm/engine/ + - vllm/executor/ + - vllm/v1/engine/ + - vllm/v1/worker/ + - tests/utils.py + - tests/v1/distributed/test_external_lb_dp.py + - tests/v1/distributed/test_hybrid_lb_dp.py + - tests/v1/distributed/test_internal_lb_dp.py - vllm/platforms/rocm.py - - tests/models/ - - examples/ commands: - - pip install --upgrade git+https://github.com/huggingface/transformers - - pytest -v -s tests/models/test_transformers.py - - pytest -v -s tests/models/multimodal/test_mapping.py - - python3 examples/basic/offline_inference/chat.py - - python3 examples/generate/multimodal/vision_language_offline.py --model-type qwen2_5_vl - - VLLM_WORKER_MULTIPROC_METHOD=spawn python3 examples/generate/multimodal/audio_language_offline.py --model-type whisper + - export VLLM_USE_RUST_FRONTEND=1 + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - TP_SIZE=1 DP_SIZE=4 pytest -v -s v1/distributed/test_internal_lb_dp.py -k "not 4 and not server_info" + - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_external_lb_dp.py -k "not 4 and not server_info" + - TP_SIZE=1 DP_SIZE=4 pytest -v -s v1/distributed/test_hybrid_lb_dp.py -k "not 4 and not server_info" #------------------------------------------------------- mi300 · quantization --------------------------------------------------------# @@ -2042,7 +2176,7 @@ steps: - vllm/ - tests/test_regression commands: - - pip install modelscope + - pip install 'modelscope<1.38' - pytest -v -s test_regression.py #--------------------------------------------------------- mi300 · ray_compat ---------------------------------------------------------# @@ -2076,19 +2210,6 @@ steps: - export VLLM_ALLOW_INSECURE_SERIALIZATION=1 - pytest -v -s v1/spec_decode/test_acceptance_length.py -m slow_test -- label: e2e Core (1 GPU) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] - agent_pool: mi300_1 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/v1/ - - tests/v1/e2e/ - - vllm/platforms/rocm.py - commands: - - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py - - label: e2e Scheduling (1 GPU) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] @@ -2134,9 +2255,10 @@ steps: - pytest -v -s v1/e2e/spec_decode -k "draft_model or no_sync or batch_inference" - label: Spec Decode Eagle # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 90 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/v1/spec_decode/ @@ -2184,10 +2306,73 @@ steps: commands: - pytest -v -s v1/e2e/spec_decode -k "speculators or mtp_correctness" +- label: Speculators Correctness # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_1 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/config/speculative.py + - vllm/engine/arg_utils.py + - vllm/transformers_utils/config.py + - vllm/transformers_utils/configs/speculators/ + - vllm/v1/spec_decode/ + - vllm/v1/worker/gpu/spec_decode/ + - vllm/v1/worker/gpu_model_runner.py + - vllm/v1/sample/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - vllm/model_executor/model_loader/ + - vllm/model_executor/layers/ + - vllm/model_executor/models/llama_eagle3.py + - vllm/model_executor/models/qwen3.py + - vllm/model_executor/models/qwen3_dflash.py + - vllm/model_executor/models/registry.py + - vllm/_aiter_ops.py + - tests/evals/gsm8k/ + - tests/v1/spec_decode/test_speculators_correctness.py + - vllm/platforms/rocm.py + commands: + - export VLLM_ALLOW_INSECURE_SERIALIZATION=1 + - pytest -v -s v1/spec_decode/test_speculators_correctness.py -m slow_test + +- label: Extract Hidden States Integration # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/config/speculative.py + - vllm/distributed/kv_transfer/kv_connector/ + - vllm/model_executor/layers/attention/ + - vllm/model_executor/layers/mamba/ + - vllm/model_executor/model_loader/ + - vllm/model_executor/models/extract_hidden_states.py + - vllm/model_executor/models/llama.py + - vllm/model_executor/models/qwen3_5.py + - vllm/model_executor/models/qwen3_next.py + - vllm/model_executor/models/registry.py + - vllm/transformers_utils/configs/extract_hidden_states.py + - vllm/transformers_utils/configs/qwen3_5.py + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - vllm/v1/kv_cache_interface.py + - vllm/v1/spec_decode/extract_hidden_states.py + - vllm/v1/worker/gpu_model_runner.py + - vllm/_aiter_ops.py + - tests/v1/kv_connector/extract_hidden_states_integration/ + - vllm/platforms/rocm.py + commands: + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s v1/kv_connector/extract_hidden_states_integration + - label: V1 attention (H100-MI300) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_1 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/config/attention.py @@ -2222,7 +2407,7 @@ steps: - pytest -v -s v1/worker - pytest -v -s -m 'not cpu_test' v1/kv_connector/unit - pytest -v -s -m 'not cpu_test' v1/metrics - - pip install -U git+https://github.com/robertgshaw2-redhat/lm-evaluation-harness.git@streaming-api + - pip install -U git+https://github.com/vllm-project/lm-evaluation-harness.git@streaming-api # - export HSA_NO_SCRATCH_RECLAIM=1 - pytest -v -s entrypoints/openai/correctness/test_lmeval.py::test_lm_eval_accuracy_v1_engine @@ -2267,6 +2452,7 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_2 + optional: true num_gpus: 2 working_dir: "/vllm-workspace/tests" source_file_dependencies: @@ -2285,30 +2471,11 @@ steps: - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_external_lb_dp.py - DP_SIZE=2 pytest -v -s entrypoints/openai/test_multi_api_servers.py -- label: Distributed Tests (2xH100-2xMI300) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] - agent_pool: mi300_2 - num_gpus: 2 - working_dir: "/vllm-workspace/" - source_file_dependencies: - - vllm/distributed/ - - vllm/v1/distributed/ - - vllm/model_executor/layers/fused_moe/ - - tests/v1/distributed/test_dbo.py - - vllm/_aiter_ops.py - - vllm/platforms/rocm.py - commands: - - VLLM_ALLOW_INSECURE_SERIALIZATION=1 python3 examples/rl/rlhf_async_new_apis.py - - VLLM_LOGGING_LEVEL=DEBUG python3 examples/features/data_parallel/data_parallel_offline.py --model=Qwen/Qwen1.5-MoE-A2.7B -tp=1 -dp=2 --max-model-len=2048 --all2all-backend=deepep_high_throughput - - pytest -v -s tests/v1/distributed/test_dbo.py - - VLLM_ALLOW_INSECURE_SERIALIZATION=1 pytest -v -s tests/distributed/test_weight_transfer.py - - pytest -v -s tests/distributed/test_packed_tensor.py - - label: Metrics, Tracing (2 GPUs) # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 65 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_2 + optional: true num_gpus: 2 working_dir: "/vllm-workspace/tests" source_file_dependencies: @@ -2334,11 +2501,28 @@ steps: commands: - pytest -v -s v1/e2e/spec_decode/test_spec_decode.py -k "tensor_parallelism" +- label: NixlConnector PD + Spec Decode acceptance (2 GPUs) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - vllm/v1/worker/kv_connector_model_runner_mixin.py + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_spec_decode_test.sh + - label: CrossLayer KV layout Distributed NixlConnector PD accuracy tests (4 GPUs) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_4 num_gpus: 4 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py @@ -2346,7 +2530,7 @@ steps: - vllm/platforms/rocm.py commands: - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - CROSS_LAYERS_BLOCKS=True ROCM_ATTN=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + - CROSS_LAYERS_BLOCKS=True ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - label: Distributed DP Tests (4 GPUs) # TBD timeout_in_minutes: 180 @@ -2377,32 +2561,34 @@ steps: optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ - tests/v1/kv_connector/nixl_integration/ - vllm/platforms/rocm.py commands: - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - ROCM_ATTN=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + - ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - label: DP EP Distributed NixlConnector PD accuracy tests (4 GPUs) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_4 num_gpus: 4 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ - tests/v1/kv_connector/nixl_integration/ - vllm/platforms/rocm.py commands: - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - DP_EP=1 ROCM_ATTN=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + - DP_EP=1 ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - label: Hybrid SSM NixlConnector PD accuracy tests (4 GPUs) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_4 num_gpus: 4 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py @@ -2410,7 +2596,60 @@ steps: - vllm/platforms/rocm.py commands: - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - HYBRID_SSM=1 ROCM_ATTN=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + - HYBRID_SSM=1 ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + +- label: Hybrid SSM NixlConnector PD prefix cache test (2 GPUs) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - vllm/v1/core/sched/ + - vllm/v1/core/kv_cache_coordinator.py + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/run_mamba_prefix_cache_test.sh + +- label: MultiConnector (Nixl+Offloading) PD accuracy (2 GPUs) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py + - vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py + - vllm/distributed/kv_transfer/kv_connector/v1/offloading/ + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh + +- label: MultiConnector (Nixl+Offloading) PD edge cases (2 GPUs) # TBD + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] + agent_pool: mi300_2 + num_gpus: 2 + optional: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py + - vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py + - vllm/distributed/kv_transfer/kv_connector/v1/offloading/ + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/run_multi_connector_edge_case_test.sh - label: V1 e2e (4 GPUs) # TBD timeout_in_minutes: 180 @@ -2439,7 +2678,7 @@ steps: #------------------------------------------------------ mi300 · weight_loading -------------------------------------------------------# - label: Weight Loading Multiple GPU # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 75 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_2 num_gpus: 2 @@ -2451,7 +2690,7 @@ steps: - bash weight_loading/run_model_weight_loading_test.sh -c weight_loading/models-amd.txt - label: Weight Loading Multiple GPU - Large Models # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 75 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300] agent_pool: mi300_2 num_gpus: 2 @@ -2546,6 +2785,7 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi325] agent_pool: mi325_1 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -2555,7 +2795,7 @@ steps: - tests/test_logger - tests/test_vllm_port commands: - - pytest -v -s engine test_sequence.py test_config.py test_logger.py test_vllm_port.py + - pytest -v -s engine test_sequence.py test_config.py test_logger.py test_vllm_port.py test_jit_monitor.py #----------------------------------------------------------- mi325 · evals -----------------------------------------------------------# @@ -2579,27 +2819,6 @@ steps: - export VLLM_USE_DEEP_GEMM=0 - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-large-rocm-fp8.txt --tp-size=4 -- label: ROCm LM Eval Large Models (8 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi325] - agent_pool: mi325_8 - optional: true - num_gpus: 8 - working_dir: "/vllm-workspace/.buildkite/lm-eval-harness" - source_file_dependencies: - - vllm/model_executor/models/ - - vllm/model_executor/model_loader/ - - vllm/model_executor/layers/quantization/ - - vllm/v1/attention/backends/ - - vllm/v1/attention/selector.py - - vllm/model_executor/layers/layernorm.py - - csrc/ - - vllm/_aiter_ops.py - - vllm/platforms/rocm.py - commands: - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-large-rocm.txt --tp-size=8 - #----------------------------------------------------- mi325 · models / language -----------------------------------------------------# - label: Language Models Test (Extended Generation) # TBD @@ -2619,7 +2838,6 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi325] agent_pool: mi325_1 - torch_nightly: true parallelism: 2 optional: true working_dir: "/vllm-workspace/tests" @@ -2637,6 +2855,7 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi325] agent_pool: mi325_1 + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -2648,14 +2867,12 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi325] agent_pool: mi325_1 - torch_nightly: true optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/models/multimodal commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "qwen3 or gemma" - pytest -v -s models/multimodal/generation/test_qwen2_5_vl.py -m core_model @@ -2692,7 +2909,7 @@ steps: - vllm/_aiter_ops.py - vllm/platforms/rocm.py commands: - - python3 benchmarks/attention_benchmarks/benchmark.py --backends ROCM_ATTN ROCM_AITER_FA ROCM_AITER_UNIFIED_ATTN --batch-specs "8q1s1k" --repeats 1 --warmup-iters 1 + - python3 benchmarks/attention_benchmarks/benchmark.py --backends ROCM_ATTN ROCM_AITER_FA ROCM_AITER_UNIFIED_ATTN --batch-specs "8q1s1k" #-------------------------------------------------------- mi355 · distributed --------------------------------------------------------# @@ -2724,40 +2941,44 @@ steps: - vllm/envs.py - examples/offline_inference/data_parallel.py - tests/distributed/test_context_parallel.py + - tests/distributed/test_rocm_aiter_custom_ar.py - tests/distributed/test_rocm_quick_reduce.py - tests/distributed/test_quick_all_reduce.py + - tests/v1/e2e/general/test_rocm_aiter_custom_ar.py - tests/v1/distributed/test_dbo.py - tests/utils.py commands: - pytest -v -s tests/distributed/test_context_parallel.py - - pytest -v -s tests/v1/distributed/test_dbo.py + - pytest -v -s tests/distributed/test_rocm_aiter_custom_ar.py + - pytest -v -s tests/v1/e2e/general/test_rocm_aiter_custom_ar.py - pytest -v -s tests/distributed/test_rocm_quick_reduce.py - pytest -v -s tests/distributed/test_quick_all_reduce.py + - pytest -v -s tests/v1/distributed/test_dbo.py #-------------------------------------------------------- mi355 · entrypoints --------------------------------------------------------# -- label: Entrypoints Integration (API Server 2) # TBD +- label: Entrypoints Integration (API Server) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 optional: true fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/entrypoints/serve + - tests/entrypoints/scale_out commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -v -s entrypoints/serve --ignore=entrypoints/serve/dev/rpc - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc + - pytest -v -s entrypoints/scale_out -- label: Entrypoints Integration (API Server openai - Part 1) # TBD +- label: Entrypoints Integration (API Server OpenAI - Part 1) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 fast_check: true - torch_nightly: true optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: @@ -2766,50 +2987,49 @@ steps: - tests/entrypoints/test_chat_utils commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai/chat_completion --ignore=entrypoints/openai/chat_completion/test_oot_registration.py + - pytest -v -s entrypoints/openai --ignore=entrypoints/openai/completion --ignore=entrypoints/openai/chat_completion --ignore=entrypoints/openai/responses --ignore=entrypoints/openai/correctness -- label: Entrypoints Integration (API Server openai - Part 2) # TBD +- label: Entrypoints Integration (API Server OpenAI - Part 2) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 fast_check: true - torch_nightly: true optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/entrypoints/openai - tests/entrypoints/test_chat_utils - - tests/entrypoints/generate - - tests/tool_use commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s entrypoints/openai/chat_completion - pytest -v -s entrypoints/openai/completion --ignore=entrypoints/openai/completion/test_tensorizer_entrypoint.py - - pytest -v -s entrypoints/test_chat_utils.py - - pytest -v -s entrypoints/generate - - pytest -v -s tool_use -- label: Entrypoints Integration (API Server openai - Part 3) # TBD +- label: Entrypoints Integration (API Server Generate) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 fast_check: true - torch_nightly: true + optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/openai - - tests/entrypoints/test_chat_utils + - tests/tool_use + - tests/entrypoints/tool_parsers + - tests/entrypoints/anthropic + - tests/entrypoints/generate commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai --ignore=entrypoints/openai/chat_completion --ignore=entrypoints/openai/completion --ignore=entrypoints/openai/correctness/ --ignore=entrypoints/openai/tool_parsers/ --ignore=entrypoints/openai/responses --ignore=entrypoints/openai/test_multi_api_servers.py + - pytest -v -s tool_use + - pytest -v -s entrypoints/tool_parsers + - pytest -v -s entrypoints/generate + - pytest -v -s entrypoints/anthropic - label: Entrypoints Integration (Speech to Text) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi355] agent_pool: mi355_1 fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -2818,12 +3038,24 @@ steps: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -v -s entrypoints/speech_to_text +- label: Entrypoints Integration (Multimodal) + timeout_in_minutes: 180 + mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi355] + agent_pool: mi355_1 + fast_check: true + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/ + - tests/entrypoints/multimodal + commands: + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s entrypoints/multimodal + - label: Entrypoints Integration (Pooling) # TBD timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 fast_check: true - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -2868,7 +3100,6 @@ steps: - vllm/model_executor/models/qwen3_5_mtp.py - vllm/transformers_utils/configs/qwen3_5.py - vllm/transformers_utils/configs/qwen3_5_moe.py - - vllm/model_executor/models/qwen.py - vllm/model_executor/models/qwen2.py - vllm/model_executor/models/qwen3.py - vllm/model_executor/models/qwen3_next.py @@ -2940,7 +3171,7 @@ steps: #--------------------------------------------------------- mi355 · examples ----------------------------------------------------------# - label: Examples # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 100 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 working_dir: "/vllm-workspace/examples" @@ -2952,7 +3183,7 @@ steps: commands: - pip install tensorizer # Basic - - python3 basic/offline_inference/chat.py --attention-backend TRITON_ATTN + - python3 basic/offline_inference/chat.py - python3 basic/offline_inference/generate.py --model facebook/opt-125m - python3 basic/offline_inference/generate.py --model meta-llama/Llama-2-13b-chat-hf --cpu-offload-gb 10 - python3 basic/offline_inference/classify.py @@ -2975,7 +3206,7 @@ steps: #---------------------------------------------------------- mi355 · kernels ----------------------------------------------------------# - label: Kernels (B200-MI355) # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 15 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 working_dir: "/vllm-workspace/" @@ -2995,15 +3226,15 @@ steps: - vllm/_aiter_ops.py commands: - rocm-smi - - python3 examples/basic/offline_inference/chat.py + - python3 examples/basic/offline_inference/chat.py --attention-backend TRITON_ATTN - pytest -v -s tests/kernels/attention/test_attention_selector.py + - pytest -v -s tests/kernels/attention/test_rocm_aiter_mla_decode_metadata.py - label: Kernels Attention Test %N # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 100 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 parallelism: 2 - optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - csrc/attention/ @@ -3017,10 +3248,10 @@ steps: - pytest -v -s kernels/attention --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT - label: Kernels MoE Test %N # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 50 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 - parallelism: 4 + parallelism: 5 working_dir: "/vllm-workspace/tests" source_file_dependencies: - csrc/quantization/cutlass_w8a8/moe/ @@ -3106,7 +3337,6 @@ steps: - vllm/model_executor/models/qwen3_5_mtp.py - vllm/transformers_utils/configs/qwen3_5.py - vllm/transformers_utils/configs/qwen3_5_moe.py - - vllm/model_executor/models/qwen.py - vllm/model_executor/models/qwen2.py - vllm/model_executor/models/qwen3.py - vllm/model_executor/models/qwen3_next.py @@ -3128,7 +3358,6 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 - torch_nightly: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -3150,7 +3379,6 @@ steps: - tests/models/multimodal/generation - tests/models/multimodal/test_mapping.py commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation -m 'not core_model' --ignore models/multimodal/generation/test_common.py - pytest -v -s models/multimodal/test_mapping.py @@ -3164,7 +3392,6 @@ steps: - vllm/ - tests/models/multimodal/generation commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m 'split(group=1) and not core_model' - label: Multi-Modal Models (Extended Pooling) # TBD @@ -3183,14 +3410,12 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 - torch_nightly: true optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/models/multimodal commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "qwen2" - pytest -v -s models/multimodal/generation/test_ultravox.py -m core_model @@ -3198,14 +3423,12 @@ steps: timeout_in_minutes: 180 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_1 - torch_nightly: true optional: true working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/models/multimodal/generation commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal -m core_model --ignore models/multimodal/generation/test_common.py --ignore models/multimodal/generation/test_ultravox.py --ignore models/multimodal/generation/test_qwen2_5_vl.py --ignore models/multimodal/generation/test_qwen2_vl.py --ignore models/multimodal/generation/test_whisper.py --ignore models/multimodal/generation/test_memory_leak.py --ignore models/multimodal/processing - pytest -v -s models/multimodal/generation/test_memory_leak.py -m core_model - cd .. && VLLM_WORKER_MULTIPROC_METHOD=spawn pytest -v -s tests/models/multimodal/generation/test_whisper.py -m core_model @@ -3311,7 +3534,7 @@ steps: - pytest -v -s v1/worker - pytest -v -s -m 'not cpu_test' v1/kv_connector/unit - pytest -v -s -m 'not cpu_test' v1/metrics - - pip install -U git+https://github.com/robertgshaw2-redhat/lm-evaluation-harness.git@streaming-api + - pip install -U git+https://github.com/vllm-project/lm-evaluation-harness.git@streaming-api - pytest -v -s entrypoints/openai/correctness/test_lmeval.py::test_lm_eval_accuracy_v1_engine - label: V1 Sample + Logits # TBD @@ -3345,56 +3568,10 @@ steps: commands: - pytest -v -s -m 'not slow_test' v1/spec_decode -- label: NixlConnector PD + Spec Decode acceptance (2 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] - agent_pool: mi355_2 - num_gpus: 2 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py - - vllm/v1/worker/kv_connector_model_runner_mixin.py - - tests/v1/kv_connector/nixl_integration/ - - vllm/platforms/rocm.py - commands: - - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - ATTENTION_BACKEND=ROCM_ATTN bash v1/kv_connector/nixl_integration/spec_decode_acceptance_test.sh - -- label: Distributed NixlConnector PD accuracy (4 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] - agent_pool: mi355_4 - num_gpus: 4 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py - - tests/v1/kv_connector/nixl_integration/ - - vllm/platforms/rocm.py - commands: - - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - ROCM_ATTN=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - -- label: DP EP Distributed NixlConnector PD accuracy tests (4 GPUs) # TBD - timeout_in_minutes: 180 - mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] - agent_pool: mi355_4 - num_gpus: 4 - optional: true - working_dir: "/vllm-workspace/tests" - source_file_dependencies: - - vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py - - tests/v1/kv_connector/nixl_integration/ - - vllm/platforms/rocm.py - commands: - - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt - - DP_EP=1 ROCM_ATTN=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - #------------------------------------------------------ mi355 · weight_loading -------------------------------------------------------# - label: Weight Loading Multiple GPU # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 75 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_2 num_gpus: 2 @@ -3406,7 +3583,7 @@ steps: - bash weight_loading/run_model_weight_loading_test.sh -c weight_loading/models-amd.txt - label: Weight Loading Multiple GPU - Large Models # TBD - timeout_in_minutes: 180 + timeout_in_minutes: 75 mirror_hardwares: [amdexperimental, amdproduction, amdgfx950nightly, amdmi355] agent_pool: mi355_2 working_dir: "/vllm-workspace/tests" @@ -3430,5 +3607,5 @@ steps: - vllm/ - tests/test_regression commands: - - pip install modelscope + - pip install 'modelscope<1.38' - pytest -v -s test_regression.py diff --git a/.buildkite/test_areas/attention.yaml b/.buildkite/test_areas/attention.yaml index d3947a03162b..01e43b501497 100644 --- a/.buildkite/test_areas/attention.yaml +++ b/.buildkite/test_areas/attention.yaml @@ -2,8 +2,8 @@ group: Attention depends_on: - image-build steps: -- label: V1 attention (H100) - key: v1-attention-h100 +- label: V1 attention (H100-MI300) + key: v1-attention-h100-mi300 timeout_in_minutes: 30 device: h100 source_file_dependencies: @@ -13,6 +13,20 @@ steps: - tests/v1/attention commands: - pytest -v -s v1/attention + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 70 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/config/attention.py + - vllm/model_executor/layers/attention + - vllm/v1/attention + - tests/v1/attention + - vllm/_aiter_ops.py + - vllm/envs.py + - vllm/platforms/rocm.py - label: V1 attention (B200) key: v1-attention-b200 diff --git a/.buildkite/test_areas/basic_correctness.yaml b/.buildkite/test_areas/basic_correctness.yaml index 5d547cd48637..d7173b6438d8 100644 --- a/.buildkite/test_areas/basic_correctness.yaml +++ b/.buildkite/test_areas/basic_correctness.yaml @@ -10,9 +10,15 @@ steps: - vllm/ - tests/basic_correctness/test_basic_correctness - tests/basic_correctness/test_cpu_offload - - tests/basic_correctness/test_cumem.py + - tests/basic_correctness/test_mem.py commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s basic_correctness/test_cumem.py + - pytest -v -s basic_correctness/test_mem.py - pytest -v -s basic_correctness/test_basic_correctness.py - pytest -v -s basic_correctness/test_cpu_offload.py + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 50 + depends_on: + - image-build-amd diff --git a/.buildkite/test_areas/benchmarks.yaml b/.buildkite/test_areas/benchmarks.yaml index 85f804780179..622ebd44f409 100644 --- a/.buildkite/test_areas/benchmarks.yaml +++ b/.buildkite/test_areas/benchmarks.yaml @@ -11,6 +11,11 @@ steps: - tests/benchmarks/ commands: - pytest -v -s benchmarks/ + mirror: + amd: + device: mi300_1 + depends_on: + - image-build-amd - label: Attention Benchmarks Smoke Test (B200) key: attention-benchmarks-smoke-test-b200 @@ -23,4 +28,4 @@ steps: - benchmarks/attention_benchmarks/ - vllm/v1/attention/ commands: - - python3 benchmarks/attention_benchmarks/benchmark.py --backends flash flashinfer --batch-specs "8q1s1k" --repeats 1 --warmup-iters 1 + - python3 benchmarks/attention_benchmarks/benchmark.py --backends flash flashinfer --batch-specs "8q1s1k" diff --git a/.buildkite/test_areas/cuda.yaml b/.buildkite/test_areas/cuda.yaml index b56e635bea63..956c76cf05f5 100644 --- a/.buildkite/test_areas/cuda.yaml +++ b/.buildkite/test_areas/cuda.yaml @@ -2,8 +2,8 @@ group: CUDA depends_on: - image-build steps: -- label: Platform Tests (CUDA) - key: platform-tests-cuda +- label: Platform Tests + key: platform-tests timeout_in_minutes: 15 device: h200_18gb source_file_dependencies: diff --git a/.buildkite/test_areas/disaggregated.yaml b/.buildkite/test_areas/disaggregated.yaml index c9d5237b67b5..4cab698f322b 100644 --- a/.buildkite/test_areas/disaggregated.yaml +++ b/.buildkite/test_areas/disaggregated.yaml @@ -13,6 +13,20 @@ steps: commands: - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh - bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + mirror: + amd: + device: mi300_4 + timeout_in_minutes: 110 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + - label: Distributed FlashInfer NixlConnector PD accuracy (4 GPUs) key: distributed-flashinfer-nixlconnector-pd-accuracy-4-gpus timeout_in_minutes: 30 @@ -25,6 +39,19 @@ steps: - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh - FLASHINFER=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh +- label: Push NixlConnector PP prefill PD accuracy (4 GPUs) + key: push-nixlconnector-pp-prefill-pd-accuracy-4-gpus + timeout_in_minutes: 30 + working_dir: "/vllm-workspace/tests" + num_devices: 4 + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - tests/v1/kv_connector/nixl_integration/ + - tests/v1/kv_connector/nixl_push_integration/ + commands: + - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh + - bash v1/kv_connector/nixl_push_integration/config_sweep_accuracy_test.sh + - label: DP EP Distributed NixlConnector PD accuracy tests (4 GPUs) key: dp-ep-distributed-nixlconnector-pd-accuracy-tests-4-gpus timeout_in_minutes: 30 @@ -36,6 +63,19 @@ steps: commands: - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh - DP_EP=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + mirror: + amd: + device: mi300_4 + timeout_in_minutes: 50 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - DP_EP=1 ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - label: CrossLayer KV layout Distributed NixlConnector PD accuracy tests (4 GPUs) key: crosslayer-kv-layout-distributed-nixlconnector-pd-accuracy-tests-4-gpus @@ -48,6 +88,19 @@ steps: commands: - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh - CROSS_LAYERS_BLOCKS=True bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + mirror: + amd: + device: mi300_4 + timeout_in_minutes: 110 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - CROSS_LAYERS_BLOCKS=True ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh - label: Hybrid SSM NixlConnector PD accuracy tests (4 GPUs) key: hybrid-ssm-nixlconnector-pd-accuracy-tests-4-gpus @@ -60,6 +113,33 @@ steps: commands: - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh - HYBRID_SSM=1 bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + mirror: + amd: + device: mi300_4 + timeout_in_minutes: 60 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - HYBRID_SSM=1 ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh + +- label: Hybrid SSM NixlConnector PD prefix cache test (2 GPUs) + key: hybrid-ssm-nixlconnector-pd-prefix-cache-2-gpus + timeout_in_minutes: 25 + working_dir: "/vllm-workspace/tests" + num_devices: 2 + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - vllm/v1/core/sched/ + - vllm/v1/core/kv_cache_coordinator.py + - tests/v1/kv_connector/nixl_integration/ + commands: + - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh + - bash v1/kv_connector/nixl_integration/run_mamba_prefix_cache_test.sh - label: MultiConnector (Nixl+Offloading) PD accuracy (2 GPUs) key: multiconnector-nixl-offloading-pd-accuracy-2-gpus @@ -89,6 +169,20 @@ steps: commands: - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh - bash v1/kv_connector/nixl_integration/config_sweep_spec_decode_test.sh + mirror: + amd: + device: mi300_2 + timeout_in_minutes: 60 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/nixl/ + - vllm/v1/worker/kv_connector_model_runner_mixin.py + - tests/v1/kv_connector/nixl_integration/ + - vllm/platforms/rocm.py + commands: + - uv pip install --system -r /vllm-workspace/requirements/kv_connectors_rocm.txt + - ATTENTION_BACKEND=TRITON_ATTN bash v1/kv_connector/nixl_integration/config_sweep_spec_decode_test.sh - label: MultiConnector (Nixl+Offloading) PD edge cases (2 GPUs) key: multiconnector-nixl-offloading-pd-edge-cases-2-gpus diff --git a/.buildkite/test_areas/distributed.yaml b/.buildkite/test_areas/distributed.yaml index 8aa41a9a26ab..0b81cdb9d116 100644 --- a/.buildkite/test_areas/distributed.yaml +++ b/.buildkite/test_areas/distributed.yaml @@ -37,6 +37,21 @@ steps: - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_eagle_dp.py - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_external_lb_dp.py - DP_SIZE=2 pytest -v -s entrypoints/openai/test_multi_api_servers.py + mirror: + amd: + device: mi300_2 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/distributed/ + - vllm/engine/ + - vllm/executor/ + - vllm/worker/worker_base.py + - vllm/v1/engine/ + - vllm/v1/worker/ + - tests/v1/distributed + - tests/entrypoints/openai/test_multi_api_servers.py + - vllm/platforms/rocm.py - label: Distributed Compile + RPC Tests (2 GPUs) key: distributed-compile-rpc-tests-2-gpus @@ -159,8 +174,8 @@ steps: # test multi-node TP with multiproc executor (simulated on single node) - pytest -v -s distributed/test_multiproc_executor.py::test_multiproc_executor_multi_node -- label: Distributed Tests (8 GPUs)(H100) - key: distributed-tests-8-gpus-h100 +- label: Distributed Tests (8xH100) + key: distributed-tests-8xh100 timeout_in_minutes: 10 device: h100 num_devices: 8 @@ -180,8 +195,8 @@ steps: # test with torchrun tp=2 and dp=4 with ep - torchrun --nproc-per-node=8 ../examples/features/torchrun/torchrun_dp_example_offline.py --tp-size=2 --pp-size=1 --dp-size=4 --enable-ep -- label: Distributed Tests (4 GPUs)(A100) - key: distributed-tests-4-gpus-a100 +- label: Distributed Tests (4xA100) + key: distributed-tests-4xa100 device: a100 optional: true num_devices: 4 @@ -195,8 +210,8 @@ steps: - TARGET_TEST_SUITE=A100 pytest basic_correctness/ -v -s -m 'distributed(num_gpus=2)' - pytest -v -s -x lora/test_mixtral.py -- label: Distributed Tests (2 GPUs)(H100) - key: distributed-tests-2-gpus-h100 +- label: Distributed Tests (2xH100-2xMI300) + key: distributed-tests-2xh100-2xmi300 timeout_in_minutes: 15 device: h100 optional: true @@ -210,15 +225,15 @@ steps: - VLLM_ALLOW_INSECURE_SERIALIZATION=1 pytest -v -s tests/distributed/test_weight_transfer.py - pytest -v -s tests/distributed/test_packed_tensor.py -- label: Distributed Tests (2 GPUs)(B200) - key: distributed-tests-2-gpus-b200 +- label: Distributed Tests (2xB200) + key: distributed-tests-2xb200 device: b200-k8s optional: true working_dir: "/vllm-workspace/" num_devices: 2 commands: - pytest -v -s tests/distributed/test_context_parallel.py - - pytest -v -s tests/distributed/test_nccl_symm_mem_allreduce.py + - pytest -v -s tests/distributed/test_nccl_symm_mem.py - pytest -v -s tests/v1/distributed/test_dbo.py - pytest -v -s tests/distributed/test_mnnvl_alltoall.py diff --git a/.buildkite/test_areas/e2e_integration.yaml b/.buildkite/test_areas/e2e_integration.yaml index 88039a339607..3f87e3958d0b 100644 --- a/.buildkite/test_areas/e2e_integration.yaml +++ b/.buildkite/test_areas/e2e_integration.yaml @@ -2,8 +2,8 @@ group: E2E Integration depends_on: - image-build steps: -- label: DeepSeek V2-Lite Sync EPLB Accuracy - key: deepseek-v2-lite-sync-eplb-accuracy +- label: DeepSeek V2-Lite Sync EPLB Accuracy (4xH100) + key: deepseek-v2-lite-sync-eplb-accuracy-4xh100 timeout_in_minutes: 60 device: h100 optional: true @@ -12,8 +12,8 @@ steps: commands: - bash .buildkite/scripts/scheduled_integration_test/deepseek_v2_lite_ep_eplb.sh 0.25 200 8010 -- label: Qwen3-30B-A3B-FP8-block Sync EPLB Accuracy - key: qwen3-30b-a3b-fp8-block-sync-eplb-accuracy +- label: Qwen3-30B-A3B-FP8-block Sync EPLB Accuracy (4xH100) + key: qwen3-30b-a3b-fp8-block-sync-eplb-accuracy-4xh100 timeout_in_minutes: 60 device: h100 optional: true @@ -22,8 +22,8 @@ steps: commands: - bash .buildkite/scripts/scheduled_integration_test/qwen30b_a3b_fp8_block_ep_eplb.sh 0.8 200 8020 -- label: Qwen3-30B-A3B-FP8-block Sync EPLB Accuracy (B200) - key: qwen3-30b-a3b-fp8-block-sync-eplb-accuracy-b200 +- label: Qwen3-30B-A3B-FP8-block Sync EPLB Accuracy (2xB200) + key: qwen3-30b-a3b-fp8-block-sync-eplb-accuracy-2xb200 timeout_in_minutes: 60 device: b200-k8s optional: true diff --git a/.buildkite/test_areas/engine.yaml b/.buildkite/test_areas/engine.yaml index f3862789eee2..9edd9343dedb 100644 --- a/.buildkite/test_areas/engine.yaml +++ b/.buildkite/test_areas/engine.yaml @@ -26,6 +26,12 @@ steps: - tests/test_jit_monitor.py commands: - pytest -v -s engine test_sequence.py test_config.py test_logger.py test_vllm_port.py test_jit_monitor.py + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 60 + depends_on: + - image-build-amd - label: Engine (1 GPU) key: engine-1-gpu @@ -68,6 +74,16 @@ steps: - tests/v1/e2e/general/ commands: - pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py + mirror: + amd: + device: mi250_1 + timeout_in_minutes: 35 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/v1/ + - tests/v1/e2e/general/ + - vllm/platforms/rocm.py - label: V1 e2e (2 GPUs) key: v1-e2e-2-gpus @@ -96,6 +112,11 @@ steps: commands: # Only run tests that need exactly 2 GPUs - pytest -v -s v1/e2e/spec_decode/test_spec_decode.py -k "tensor_parallelism" + mirror: + amd: + device: mi300_2 + depends_on: + - image-build-amd - label: V1 e2e (4 GPUs) key: v1-e2e-4-gpus diff --git a/.buildkite/test_areas/entrypoints.yaml b/.buildkite/test_areas/entrypoints.yaml index 548174ed7485..5ef88d4b97b5 100644 --- a/.buildkite/test_areas/entrypoints.yaml +++ b/.buildkite/test_areas/entrypoints.yaml @@ -8,10 +8,11 @@ steps: working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/entrypoints - - tests/entrypoints/ + - tests/entrypoints/unit_tests + - tests/entrypoints/weight_transfer commands: - - pytest -v -s entrypoints/openai/tool_parsers - - pytest -v -s entrypoints/ --ignore=entrypoints/llm --ignore=entrypoints/offline_mode --ignore=entrypoints/openai --ignore=entrypoints/serve --ignore=entrypoints/test_chat_utils.py --ignore=entrypoints/pooling --ignore=entrypoints/speech_to_text --ignore=tests/entrypoints/generate + - pytest -v -s entrypoints/unit_tests + - pytest -v -s entrypoints/weight_transfer - label: Entrypoints Integration (LLM) key: entrypoints-integration-llm @@ -20,65 +21,60 @@ steps: source_file_dependencies: - vllm/ - tests/entrypoints/llm - - tests/entrypoints/offline_mode commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/llm --ignore=entrypoints/llm/test_generate.py --ignore=entrypoints/llm/test_collective_rpc.py + - pytest -v -s entrypoints/llm --ignore=entrypoints/llm/test_generate.py --ignore=entrypoints/llm/test_collective_rpc.py --ignore=entrypoints/llm/offline_mode - pytest -v -s entrypoints/llm/test_generate.py # it needs a clean process - - pytest -v -s entrypoints/offline_mode # Needs to avoid interference with other tests + - pytest -v -s entrypoints/llm/offline_mode # Needs to avoid interference with other tests mirror: amd: device: mi325_1 + # TODO(akaratza): Test after Torch >= 2.12 bump soft_fail: true depends_on: - image-build-amd -- label: Entrypoints Integration (API Server openai - Part 1) - key: entrypoints-integration-api-server-openai-part-1 - timeout_in_minutes: 50 +- label: Entrypoints Integration (API Server) + key: entrypoints-integration-api-server + device: h200_35gb + timeout_in_minutes: 130 working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/openai - - tests/entrypoints/test_chat_utils + - tests/entrypoints/serve + - tests/entrypoints/scale_out commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai/chat_completion --ignore=entrypoints/openai/chat_completion/test_oot_registration.py + - pytest -v -s entrypoints/serve --ignore=entrypoints/serve/dev/rpc + - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc + - pytest -v -s entrypoints/scale_out mirror: amd: device: mi325_1 - soft_fail: true - timeout_in_minutes: 80 depends_on: - image-build-amd -- label: Entrypoints Integration (API Server openai - Part 2) - key: entrypoints-integration-api-server-openai-part-2 +- label: Entrypoints Integration (API Server OpenAI - Part 1) + key: entrypoints-integration-api-server-openai-part-1 timeout_in_minutes: 50 working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - tests/entrypoints/openai - tests/entrypoints/test_chat_utils - - tests/entrypoints/generate - - tests/tool_use commands: - - pytest -v -s entrypoints/openai/completion --ignore=entrypoints/openai/completion/test_tensorizer_entrypoint.py - - pytest -v -s entrypoints/test_chat_utils.py - - pytest -v -s entrypoints/generate - - pytest -v -s tool_use + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s entrypoints/openai --ignore=entrypoints/openai/completion --ignore=entrypoints/openai/chat_completion --ignore=entrypoints/openai/responses --ignore=entrypoints/openai/correctness mirror: amd: device: mi325_1 - soft_fail: true - timeout_in_minutes: 60 + timeout_in_minutes: 80 depends_on: - image-build-amd -- label: Entrypoints Integration (API Server openai - Part 3) - key: entrypoints-integration-api-server-openai-part-3 +- label: Entrypoints Integration (API Server OpenAI - Part 2) + key: entrypoints-integration-api-server-openai-part-2 timeout_in_minutes: 50 - device: h200_18gb working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ @@ -86,34 +82,47 @@ steps: - tests/entrypoints/test_chat_utils commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/openai --ignore=entrypoints/openai/chat_completion --ignore=entrypoints/openai/completion --ignore=entrypoints/openai/correctness/ --ignore=entrypoints/openai/tool_parsers/ --ignore=entrypoints/openai/responses --ignore=entrypoints/openai/test_multi_api_servers.py + - pytest -v -s entrypoints/openai/chat_completion + - pytest -v -s entrypoints/openai/completion --ignore=entrypoints/openai/completion/test_tensorizer_entrypoint.py mirror: amd: device: mi325_1 - soft_fail: true - timeout_in_minutes: 60 + timeout_in_minutes: 80 depends_on: - image-build-amd -- label: Entrypoints Integration (API Server 2) - device: h200_35gb - key: entrypoints-integration-api-server-2 - timeout_in_minutes: 130 +- label: Entrypoints Integration (API Server Generate) + key: entrypoints-integration-api-server-generate + timeout_in_minutes: 50 working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/serve + - tests/tool_use + - tests/entrypoints/tool_parsers + - tests/entrypoints/anthropic + - tests/entrypoints/generate commands: - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/serve --ignore=entrypoints/serve/dev/rpc - - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc + - pytest -v -s tool_use + - pytest -v -s entrypoints/tool_parsers + - pytest -v -s entrypoints/generate + - pytest -v -s entrypoints/anthropic mirror: amd: device: mi325_1 - soft_fail: true + timeout_in_minutes: 60 depends_on: - image-build-amd +- label: Entrypoints Integration (Responses API) + key: entrypoints-integration-responses-api + timeout_in_minutes: 50 + working_dir: "/vllm-workspace/tests" + source_file_dependencies: + - vllm/ + - tests/entrypoints/openai/responses + commands: + - pytest -v -s entrypoints/openai/responses + - label: Entrypoints Integration (Speech to Text) device: h200_35gb key: entrypoints-integration-speech_to_text @@ -126,26 +135,28 @@ steps: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -v -s entrypoints/speech_to_text -- label: Entrypoints Integration (Pooling) - key: entrypoints-integration-pooling +- label: Entrypoints Integration (Multimodal) + device: h200_35gb + key: entrypoints-integration-multimodal timeout_in_minutes: 50 working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/pooling + - tests/entrypoints/multimodal commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -v -s entrypoints/pooling + - pytest -v -s entrypoints/multimodal -- label: Entrypoints Integration (Responses API) - key: entrypoints-integration-responses-api +- label: Entrypoints Integration (Pooling) + key: entrypoints-integration-pooling timeout_in_minutes: 50 working_dir: "/vllm-workspace/tests" source_file_dependencies: - vllm/ - - tests/entrypoints/openai/responses + - tests/entrypoints/pooling commands: - - pytest -v -s entrypoints/openai/responses + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s entrypoints/pooling - label: OpenAI API Correctness key: openai-api-correctness @@ -156,3 +167,20 @@ steps: - vllm/entrypoints/openai/ commands: # LMEval - pytest -s entrypoints/openai/correctness/ + mirror: + amd: + device: mi325_1 + depends_on: + - image-build-amd + source_file_dependencies: + - csrc/ + - vllm/entrypoints/openai/ + - vllm/model_executor/layers/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + - vllm/model_executor/model_loader/ + commands: + - bash ../tools/install_torchcodec_rocm.sh || exit 1 + - pytest -s entrypoints/openai/correctness/ diff --git a/.buildkite/test_areas/expert_parallelism.yaml b/.buildkite/test_areas/expert_parallelism.yaml index 0f7ab0d7157c..ccb3054f2e9c 100644 --- a/.buildkite/test_areas/expert_parallelism.yaml +++ b/.buildkite/test_areas/expert_parallelism.yaml @@ -14,6 +14,16 @@ steps: commands: - pytest -v -s distributed/test_eplb_algo.py - pytest -v -s distributed/test_eplb_utils.py + mirror: + amd: + device: mi300_1 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/distributed/eplb + - tests/distributed/test_eplb_algo.py + - tests/distributed/test_eplb_utils.py + - vllm/platforms/rocm.py - label: EPLB Execution # 17min key: eplb-execution diff --git a/.buildkite/test_areas/kernels.yaml b/.buildkite/test_areas/kernels.yaml index 68e6a5762ef1..10c132da095a 100644 --- a/.buildkite/test_areas/kernels.yaml +++ b/.buildkite/test_areas/kernels.yaml @@ -21,8 +21,9 @@ steps: - csrc/ - tests/kernels/core - tests/kernels/test_concat_mla_q.py + - tests/kernels/test_fused_qk_norm_rope_gate.py commands: - - pytest -v -s kernels/core --ignore=kernels/core/test_minimax_reduce_rms.py kernels/test_concat_mla_q.py + - pytest -v -s kernels/core --ignore=kernels/core/test_minimax_reduce_rms.py kernels/test_concat_mla_q.py kernels/test_fused_qk_norm_rope_gate.py - label: Kernels MiniMax Reduce RMS Test (2 GPUs) key: kernels-minimax-reduce-rms-test-2-gpus @@ -46,8 +47,10 @@ steps: - csrc/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu - vllm/models/deepseek_v4/common/ops/ - tests/kernels/test_fused_deepseek_v4_qnorm_rope_kv_insert.py + - tests/kernels/test_top_k_per_row.py # it runs on Blackwell too - some kernels have arch-specific optimizations commands: - pytest -v -s kernels/test_fused_deepseek_v4_*.py + - pytest -v -s kernels/test_top_k_per_row.py - label: Deepseek V4 Kernel Test (B200) key: deepseek-v4-kernel-test-b200 @@ -73,6 +76,33 @@ steps: commands: - pytest -v -s kernels/attention --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT parallelism: 2 + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 55 + depends_on: + - image-build-amd + source_file_dependencies: + - csrc/attention/ + - vllm/v1/attention + - vllm/model_executor/layers/attention + - tests/kernels/attention + - vllm/_aiter_ops.py + - vllm/envs.py + - vllm/platforms/rocm.py + +- label: Kernels Attention DiffKV Test (H100) + key: kernels-attention-diffkv-test-h100 + timeout_in_minutes: 20 + device: h100 + num_devices: 1 + source_file_dependencies: + - vllm/v1/attention/ops/triton_unified_attention_diffkv.py + - vllm/v1/attention/backends/triton_attn_diffkv.py + - vllm/v1/attention/backends/flash_attn_diffkv.py + - tests/kernels/attention/test_triton_unified_attention_diffkv.py + commands: + - pytest -v -s kernels/attention/test_triton_unified_attention_diffkv.py - label: Kernels Quantization Test %N key: kernels-quantization-test @@ -90,6 +120,7 @@ steps: source_file_dependencies: - csrc/quantization/ - vllm/model_executor/layers/quantization + - vllm/config/ - tests/kernels/quantization - tests/kernels/quantization/test_rocm_skinny_gemms.py - vllm/_aiter_ops.py @@ -113,6 +144,22 @@ steps: - pytest -v -s kernels/moe --ignore=kernels/moe/test_modular_oai_triton_moe.py --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT - pytest -v -s kernels/moe/test_modular_oai_triton_moe.py --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT parallelism: 5 + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 50 + source_file_dependencies: + - csrc/quantization/cutlass_w8a8/moe/ + - csrc/moe/ + - tests/kernels/moe + - vllm/model_executor/layers/fused_moe/ + - vllm/distributed/device_communicators/ + - vllm/envs.py + - vllm/config + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py + depends_on: + - image-build-amd - label: Kernels Mamba Test key: kernels-mamba-test @@ -223,12 +270,12 @@ steps: - vllm/utils/import_utils.py - tests/kernels/helion/ commands: - - pip install helion==1.0.0 + - pip install helion==1.1.0 - pytest -v -s kernels/helion/ -- label: Kernels FP8 MoE Test (1 H100) - key: kernels-fp8-moe-test-1-h100 +- label: Kernels FP8 MoE Test (1xH100) + key: kernels-fp8-moe-test-1xh100 timeout_in_minutes: 90 device: h100 num_devices: 1 @@ -244,8 +291,8 @@ steps: - pytest -v -s kernels/moe/test_triton_moe_no_act_mul.py - pytest -v -s kernels/moe/test_triton_moe_ptpc_fp8.py -- label: Kernels FP8 MoE Test (2 H100s) - key: kernels-fp8-moe-test-2-h100s +- label: Kernels FP8 MoE Test (2xH100) + key: kernels-fp8-moe-test-2xh100 timeout_in_minutes: 90 device: h100 num_devices: 2 @@ -299,3 +346,4 @@ steps: - vllm/config commands: - pytest -v -s kernels/moe/test_moe_layer.py + - pytest -v -s kernels/moe/test_deepep_v2_moe.py diff --git a/.buildkite/test_areas/lm_eval.yaml b/.buildkite/test_areas/lm_eval.yaml index 06f530ecc2a0..feba8f26eb32 100644 --- a/.buildkite/test_areas/lm_eval.yaml +++ b/.buildkite/test_areas/lm_eval.yaml @@ -12,8 +12,24 @@ steps: autorun_on_main: true commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-small.txt + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 55 + depends_on: + - image-build-amd + source_file_dependencies: + - csrc/ + - vllm/model_executor/layers/quantization + - vllm/model_executor/models/ + - vllm/model_executor/model_loader/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py -# - label: LM Eval Large Models (4 GPUs)(A100) +# - label: LM Eval Large Models (4xA100) +# key: lm-eval-large-models-4xa100 # device: a100 # optional: true # num_devices: 4 @@ -25,8 +41,8 @@ steps: # - export VLLM_WORKER_MULTIPROC_METHOD=spawn # - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-large.txt --tp-size=4 -- label: LM Eval Large Models (4 GPUs)(H100) - key: lm-eval-large-models-4-gpus-h100 +- label: LM Eval Large Models (4xH100) + key: lm-eval-large-models-4xh100 device: h100 optional: true num_devices: 4 @@ -38,8 +54,8 @@ steps: - export VLLM_USE_DEEP_GEMM=0 # We found Triton is faster than DeepGEMM for H100 - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-large-hopper.txt --tp-size=4 -- label: LM Eval Small Models (B200) - key: lm-eval-small-models-b200 +- label: LM Eval Small Models (1xB200) + key: lm-eval-small-models-1xb200 timeout_in_minutes: 120 device: b200-k8s optional: true @@ -49,8 +65,21 @@ steps: commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-blackwell.txt -- label: LM Eval Large Models (B200, EP) - key: lm-eval-large-models-b200-ep +- label: LM Eval Small Models Distributed (2xB200) + key: lm-eval-small-models-distributed-2xb200 + timeout_in_minutes: 120 + device: b200-k8s + num_devices: 2 + optional: true + source_file_dependencies: + - csrc/ + - vllm/model_executor/layers/quantization + autorun_on_main: true + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-small-tp.txt + +- label: LM Eval Large Models EP (2xB200) + key: lm-eval-large-models-ep-2xb200 timeout_in_minutes: 120 device: b200-k8s optional: true @@ -61,8 +90,8 @@ steps: commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-blackwell-ep.txt -- label: LM Eval Qwen3.5 Models (B200) - key: lm-eval-qwen3-5-models-b200 +- label: LM Eval Qwen3.5 Models (2xB200) + key: lm-eval-qwen3-5-models-2xb200 timeout_in_minutes: 120 device: b200-k8s optional: true @@ -78,14 +107,24 @@ steps: commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-qwen35-blackwell.txt -- label: LM Eval Large Models (H200) - key: lm-eval-large-models-h200 +- label: LM Eval Large Models (8xH200) + key: lm-eval-large-models-8xh200 timeout_in_minutes: 60 device: h200 optional: true num_devices: 8 commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-h200.txt + mirror: + amd: + device: mi300_8 + timeout_in_minutes: 180 + depends_on: + - image-build-amd + commands: + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - export PYTORCH_ROCM_ARCH=gfx942 # Limit Quark compilation to save time + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=configs/models-mi3xx.txt - label: MoE Refactor Integration Test (H100 - TEMPORARY) key: moe-refactor-integration-test-h100-temporary @@ -111,6 +150,97 @@ steps: commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/moe-refactor-dp-ep/config-b200.txt +- label: LM Eval Humming f16 (A100 - TEMPORARY) + key: lm-eval-humming-f16-a100 + timeout_in_minutes: 120 + device: a100 + optional: true + num_devices: 1 + source_file_dependencies: + - vllm/model_executor/layers/quantization/humming.py + - vllm/model_executor/layers/quantization/utils/humming_utils.py + - vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py + - vllm/model_executor/layers/fused_moe/oracle/ + - vllm/model_executor/kernels/linear/ + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config.txt + +- label: LM Eval Humming Act int8 (A100 - TEMPORARY) + key: lm-eval-humming-act-a100 + timeout_in_minutes: 120 + device: a100 + optional: true + num_devices: 1 + source_file_dependencies: + - vllm/model_executor/layers/quantization/humming.py + - vllm/model_executor/layers/quantization/utils/humming_utils.py + - vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py + - vllm/model_executor/layers/fused_moe/oracle/ + - vllm/model_executor/kernels/linear/ + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config-act-int8.txt + +- label: LM Eval Humming f16 (H100 - TEMPORARY) + key: lm-eval-humming-f16-h100 + timeout_in_minutes: 120 + device: h100 + optional: true + num_devices: 1 + source_file_dependencies: + - vllm/model_executor/layers/quantization/humming.py + - vllm/model_executor/layers/quantization/utils/humming_utils.py + - vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py + - vllm/model_executor/layers/fused_moe/oracle/ + - vllm/model_executor/kernels/linear/ + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config.txt + +- label: LM Eval Humming Act fp8/int8 (H100 - TEMPORARY) + key: lm-eval-humming-act-h100 + timeout_in_minutes: 120 + device: h100 + optional: true + num_devices: 1 + source_file_dependencies: + - vllm/model_executor/layers/quantization/humming.py + - vllm/model_executor/layers/quantization/utils/humming_utils.py + - vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py + - vllm/model_executor/layers/fused_moe/oracle/ + - vllm/model_executor/kernels/linear/ + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config-act-fp8.txt + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config-act-int8.txt + +- label: LM Eval Humming f16 (B200 - TEMPORARY) + key: lm-eval-humming-f16-b200 + timeout_in_minutes: 120 + device: b200-k8s + optional: true + num_devices: 1 + source_file_dependencies: + - vllm/model_executor/layers/quantization/humming.py + - vllm/model_executor/layers/quantization/utils/humming_utils.py + - vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py + - vllm/model_executor/layers/fused_moe/oracle/ + - vllm/model_executor/kernels/linear/ + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config.txt + +- label: LM Eval Humming Act fp8/int8 (B200 - TEMPORARY) + key: lm-eval-humming-act-b200 + timeout_in_minutes: 120 + device: b200-k8s + optional: true + num_devices: 1 + source_file_dependencies: + - vllm/model_executor/layers/quantization/humming.py + - vllm/model_executor/layers/quantization/utils/humming_utils.py + - vllm/model_executor/layers/fused_moe/experts/fused_humming_moe.py + - vllm/model_executor/layers/fused_moe/oracle/ + - vllm/model_executor/kernels/linear/ + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config-act-fp8.txt + - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/humming/config-act-int8.txt - label: LM Eval TurboQuant KV Cache key: lm-eval-turboquant-kv-cache @@ -124,8 +254,8 @@ steps: commands: - pytest -s -v evals/gsm8k/test_gsm8k_correctness.py --config-list-file=evals/gsm8k/configs/models-turboquant.txt -- label: GPQA Eval (GPT-OSS) (H100) - key: gpqa-eval-gpt-oss-h100 +- label: GPQA Eval (GPT-OSS) (2xH100) + key: gpqa-eval-gpt-oss-2xh100 timeout_in_minutes: 120 device: h100 optional: true @@ -138,8 +268,8 @@ steps: - uv pip install --system 'gpt-oss[eval]==0.0.5' - pytest -s -v evals/gpt_oss/test_gpqa_correctness.py --config-list-file=configs/models-h100.txt -- label: GPQA Eval (GPT-OSS) (B200) - key: gpqa-eval-gpt-oss-b200 +- label: GPQA Eval (GPT-OSS) (2xB200) + key: gpqa-eval-gpt-oss-2xb200 timeout_in_minutes: 120 device: b200-k8s optional: true @@ -152,6 +282,63 @@ steps: - uv pip install --system 'gpt-oss[eval]==0.0.5' - pytest -s -v evals/gpt_oss/test_gpqa_correctness.py --config-list-file=configs/models-b200.txt +- label: GPQA Eval (GPT-OSS) (DGX Spark) + key: gpqa-eval-gpt-oss-spark + timeout_in_minutes: 120 + device: dgx-spark + optional: true + num_devices: 1 + depends_on: + - arm64-image-build + source_file_dependencies: + - csrc/ + - vllm/model_executor/layers/quantization + - tests/evals/gpt_oss/ + commands: + - uv pip install --system 'gpt-oss[eval]==0.0.5' + - pytest -s -v evals/gpt_oss/test_gpqa_correctness.py --config-list-file=configs/models-spark.txt + +- label: LM Eval KV-Offload (1xH200) + key: kv-offload-small + timeout_in_minutes: 30 + device: h200_35gb + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/offloading/ + - vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py + - vllm/v1/kv_offload/ + - vllm/v1/simple_kv_offload/ + - tests/evals/gsm8k/test_gsm8k_offloading.py + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "nemotron-h-8b or gemma-4-e4b-it" + +- label: LM Eval KV-Offload (2xH100) + key: kv-offload-medium + timeout_in_minutes: 60 + device: h100 + num_devices: 2 + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/offloading/ + - vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py + - vllm/v1/kv_offload/ + - vllm/v1/simple_kv_offload/ + - tests/evals/gsm8k/test_gsm8k_offloading.py + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "qwen3.5-35b" + +- label: LM Eval KV-Offload (4xH100) + key: kv-offload-large + timeout_in_minutes: 60 + device: h100 + num_devices: 4 + source_file_dependencies: + - vllm/distributed/kv_transfer/kv_connector/v1/offloading/ + - vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py + - vllm/v1/kv_offload/ + - vllm/v1/simple_kv_offload/ + - tests/evals/gsm8k/test_gsm8k_offloading.py + commands: + - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "deepseek-v4-flash" + - label: MRCR Eval Small Models device: h200_35gb timeout_in_minutes: 30 diff --git a/.buildkite/test_areas/lora.yaml b/.buildkite/test_areas/lora.yaml index 3ccf92f9a7ad..bd437c52265f 100644 --- a/.buildkite/test_areas/lora.yaml +++ b/.buildkite/test_areas/lora.yaml @@ -12,6 +12,17 @@ steps: commands: - pytest -v -s lora --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --ignore=lora/test_chatglm3_tp.py --ignore=lora/test_llama_tp.py --ignore=lora/test_qwen3_with_multi_loras.py --ignore=lora/test_olmoe_tp.py --ignore=lora/test_deepseekv2_tp.py --ignore=lora/test_gptoss_tp.py --ignore=lora/test_qwen3moe_tp.py --ignore=lora/test_qwen35_densemodel_lora.py parallelism: 4 + mirror: + amd: + device: mi325_1 + working_dir: "/vllm-workspace/tests" + timeout_in_minutes: 60 + source_file_dependencies: + - vllm/lora + - tests/lora + - vllm/platforms/rocm.py + depends_on: + - image-build-amd - label: LoRA TP (Distributed) diff --git a/.buildkite/test_areas/misc.yaml b/.buildkite/test_areas/misc.yaml index e04016d6dcc2..fd6ef2e61bae 100644 --- a/.buildkite/test_areas/misc.yaml +++ b/.buildkite/test_areas/misc.yaml @@ -21,6 +21,12 @@ steps: - export VLLM_WORKER_MULTIPROC_METHOD=spawn # TODO: create another `optional` test group for slow tests - pytest -v -s -m 'not slow_test' v1/spec_decode + mirror: + amd: + device: mi300_1 + timeout_in_minutes: 65 + depends_on: + - image-build-amd - label: V1 Sample + Logits key: v1-sample-logits @@ -97,8 +103,14 @@ steps: - pytest -v -s -m 'not cpu_test' v1/kv_connector/unit - pytest -v -s -m 'not cpu_test' v1/metrics # Integration test for streaming correctness (requires special branch). - - pip install -U git+https://github.com/robertgshaw2-redhat/lm-evaluation-harness.git@streaming-api + - pip install -U git+https://github.com/vllm-project/lm-evaluation-harness.git@streaming-api - pytest -v -s entrypoints/openai/correctness/test_lmeval.py::test_lm_eval_accuracy_v1_engine + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 60 + depends_on: + - image-build-amd - label: V1 Others (CPU) key: v1-others-cpu @@ -138,11 +150,26 @@ steps: - vllm/v1/spec_decode/extract_hidden_states.py - vllm/model_executor/models/extract_hidden_states.py - vllm/transformers_utils/configs/extract_hidden_states.py + - vllm/distributed/kv_transfer/kv_connector/v1/example_hidden_states_connector.py - tests/v1/kv_connector/extract_hidden_states_integration commands: - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -v -s v1/kv_connector/extract_hidden_states_integration +- label: Extract Hidden States Integration (2 GPUs) + key: extract-hidden-states-integration-2-gpus + timeout_in_minutes: 20 + num_devices: 2 + source_file_dependencies: + - vllm/v1/spec_decode/extract_hidden_states.py + - vllm/model_executor/models/extract_hidden_states.py + - vllm/transformers_utils/configs/extract_hidden_states.py + - vllm/distributed/kv_transfer/kv_connector/v1/example_hidden_states_connector.py + - tests/v1/kv_connector/extract_hidden_states_integration + commands: + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -v -s -m 'distributed' v1/kv_connector/extract_hidden_states_integration + - label: Regression key: regression timeout_in_minutes: 20 @@ -161,7 +188,7 @@ steps: - vllm/v1/ - tests/test_regression commands: - - pip install modelscope + - pip install 'modelscope<1.38' - pytest -v -s test_regression.py working_dir: "/vllm-workspace/tests" # optional @@ -197,6 +224,16 @@ steps: - python3 features/speculative_decoding/spec_decode_offline.py --test --method eagle --num_spec_tokens 3 --dataset-name hf --dataset-path philschmid/mt-bench --num-prompts 80 --temp 0 --top-p 1.0 --top-k -1 --tp 1 --enable-chunked-prefill --max-model-len 2048 # https://github.com/vllm-project/vllm/pull/26682 uses slightly more memory in PyTorch 2.9+ causing this test to OOM in 1xL4 GPU - python3 features/speculative_decoding/spec_decode_offline.py --test --method eagle3 --num_spec_tokens 3 --dataset-name hf --dataset-path philschmid/mt-bench --num-prompts 80 --temp 0 --top-p 1.0 --top-k -1 --tp 1 --enable-chunked-prefill --max-model-len 1536 + mirror: + amd: + device: mi325_1 + source_file_dependencies: + - vllm/entrypoints + - vllm/multimodal + - examples/ + - vllm/platforms/rocm.py + depends_on: + - image-build-amd - label: Metrics, Tracing (2 GPUs) key: metrics-tracing-2-gpus @@ -223,6 +260,12 @@ steps: 'opentelemetry-exporter-otlp>=1.26.0' \ 'opentelemetry-semantic-conventions-ai>=0.4.1'" - pytest -v -s v1/tracing + mirror: + amd: + device: mi325_2 + depends_on: + - image-build-amd + optional: true - label: Python-only Installation key: python-only-installation @@ -235,6 +278,16 @@ steps: - setup.py commands: - bash standalone_tests/python_only_compile.sh + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 20 + depends_on: + - image-build-amd + source_file_dependencies: + - tests/standalone_tests/python_only_compile.sh + - setup.py + - vllm/platforms/rocm.py - label: Async Engine, Inputs, Utils, Worker device: h200_35gb @@ -293,31 +346,35 @@ steps: - vllm/transformers_utils/ - vllm/utils/ - vllm/v1/ + - tests/test_envs.py - tests/test_inputs.py - tests/test_outputs.py - tests/test_pooling_params.py - tests/test_ray_env.py + - tests/test_sampling_params.py - tests/multimodal - tests/renderers - tests/standalone_tests/lazy_imports.py - - tests/tokenizers_ - tests/reasoning - tests/tool_parsers + - tests/tokenizers_ - tests/parser - tests/transformers_utils - tests/config device: cpu-small commands: - python3 standalone_tests/lazy_imports.py + - pytest -v -s test_envs.py - pytest -v -s test_inputs.py - pytest -v -s test_outputs.py - pytest -v -s test_pooling_params.py - pytest -v -s test_ray_env.py + - pytest -v -s test_sampling_params.py - pytest -v -s -m 'cpu_test' multimodal - pytest -v -s renderers - - pytest -v -s tokenizers_ - - pytest -v -s reasoning --ignore=reasoning/test_seedoss_reasoning_parser.py --ignore=reasoning/test_glm4_moe_reasoning_parser.py + - pytest -v -s reasoning - pytest -v -s tool_parsers + - pytest -v -s tokenizers_ - pytest -v -s parser - pytest -v -s transformers_utils - pytest -v -s config diff --git a/.buildkite/test_areas/model_executor.yaml b/.buildkite/test_areas/model_executor.yaml index e34b7eadfacf..aaf85b4f2753 100644 --- a/.buildkite/test_areas/model_executor.yaml +++ b/.buildkite/test_areas/model_executor.yaml @@ -23,3 +23,16 @@ steps: # calls that the signal method cannot interrupt. - pytest -v -s model_executor -m '(not slow_test)' --timeout=900 --timeout-method=thread - pytest -v -s entrypoints/openai/completion/test_tensorizer_entrypoint.py --timeout=900 --timeout-method=thread + mirror: + amd: + device: mi300_1 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/engine/arg_utils.py + - vllm/config/model.py + - vllm/model_executor + - tests/model_executor + - tests/entrypoints/openai/completion/test_tensorizer_entrypoint.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py diff --git a/.buildkite/test_areas/model_runner_v2.yaml b/.buildkite/test_areas/model_runner_v2.yaml index 617c80b2fec7..dbb35df80be9 100644 --- a/.buildkite/test_areas/model_runner_v2.yaml +++ b/.buildkite/test_areas/model_runner_v2.yaml @@ -18,9 +18,7 @@ steps: - set -x - export VLLM_USE_V2_MODEL_RUNNER=1 - pytest -v -s v1/engine/test_llm_engine.py -k "not test_engine_metrics" - # This requires eager until we sort out CG correctness issues. - # TODO: remove ENFORCE_EAGER here after https://github.com/vllm-project/vllm/pull/32936 is merged. - - ENFORCE_EAGER=1 pytest -v -s v1/e2e/general/test_async_scheduling.py -k "not ngram" + - pytest -v -s v1/e2e/general/test_async_scheduling.py -k "not ngram" - pytest -v -s v1/e2e/general/test_context_length.py - pytest -v -s v1/e2e/general/test_min_tokens.py # Temporary hack filter to exclude ngram spec decoding based tests. diff --git a/.buildkite/test_areas/models_basic.yaml b/.buildkite/test_areas/models_basic.yaml index 4e47cbb77948..3a113f1982a1 100644 --- a/.buildkite/test_areas/models_basic.yaml +++ b/.buildkite/test_areas/models_basic.yaml @@ -6,7 +6,6 @@ steps: key: basic-models-tests-initialization timeout_in_minutes: 45 device: h200_18gb - torch_nightly: true source_file_dependencies: - vllm/ - tests/models/test_initialization.py @@ -14,8 +13,6 @@ steps: commands: # Run a subset of model initialization tests - pytest -v -s models/test_initialization.py::test_can_initialize_small_subset - mirror: - torch_nightly: {} - label: Basic Models Tests (Extra Initialization) %N device: h200_35gb @@ -31,8 +28,6 @@ steps: # test.) Also run if model initialization test file is modified - pytest -v -s models/test_initialization.py -k 'not test_can_initialize_small_subset' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB parallelism: 2 - mirror: - torch_nightly: {} - label: Basic Models Tests (Other) device: h200_35gb @@ -41,10 +36,15 @@ steps: source_file_dependencies: - vllm/ - tests/models/test_terratorch.py - - tests/models/test_transformers.py + - tests/models/transformers/test_backend.py - tests/models/test_registry.py commands: - - pytest -v -s models/test_terratorch.py models/test_transformers.py models/test_registry.py + - pytest -v -s models/test_terratorch.py models/transformers/test_backend.py models/test_registry.py + mirror: + amd: + device: mi325_1 + depends_on: + - image-build-amd - label: Basic Models Test (Other CPU) # 5min key: basic-models-test-other-cpu @@ -55,6 +55,7 @@ steps: - vllm/ - tests/models/test_utils.py - tests/models/test_vision.py + - tests/models/transformers/fusers/ device: cpu-small commands: - - pytest -v -s models/test_utils.py models/test_vision.py + - pytest -v -s models/test_utils.py models/test_vision.py models/transformers/fusers/ diff --git a/.buildkite/test_areas/models_distributed.yaml b/.buildkite/test_areas/models_distributed.yaml index b5758c55affa..a3ee7666ed09 100644 --- a/.buildkite/test_areas/models_distributed.yaml +++ b/.buildkite/test_areas/models_distributed.yaml @@ -17,7 +17,7 @@ steps: - TARGET_TEST_SUITE=L4 pytest basic_correctness/ -v -s -m 'distributed(num_gpus=2)' - CUDA_VISIBLE_DEVICES=0,1 pytest -v -s model_executor/model_loader/test_sharded_state_loader.py -m '(not slow_test)' # Avoid importing model tests that cause CUDA reinitialization error - - pytest models/test_transformers.py -v -s -m 'distributed(num_gpus=2)' + - pytest models/transformers/test_backend.py -v -s -m 'distributed(num_gpus=2)' - pytest models/language -v -s -m 'distributed(num_gpus=2)' - pytest models/multimodal/generation/test_phi4siglip.py -v -s -m 'distributed(num_gpus=2)' - pytest models/multimodal -v -s -m 'distributed(num_gpus=2)' --ignore models/multimodal/generation/test_whisper.py --ignore models/multimodal/generation/test_phi4siglip.py diff --git a/.buildkite/test_areas/models_language.yaml b/.buildkite/test_areas/models_language.yaml index b9f7861d117c..2c163bc80491 100644 --- a/.buildkite/test_areas/models_language.yaml +++ b/.buildkite/test_areas/models_language.yaml @@ -14,7 +14,10 @@ steps: - pip freeze | grep -E 'torch' - pytest -v -s models/language -m 'core_model and (not slow_test)' mirror: - torch_nightly: {} + amd: + device: mi300_1 + depends_on: + - image-build-amd - label: Language Models Tests (Extra Standard) %N key: language-models-tests-extra-standard @@ -31,7 +34,21 @@ steps: - pytest -v -s models/language -m 'core_model and slow_test' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB parallelism: 2 mirror: - torch_nightly: {} + amd: + device: mi300_1 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/model_executor/models/ + - vllm/model_executor/model_loader/ + - vllm/model_executor/layers/ + - vllm/v1/attention/backends/ + - vllm/v1/attention/selector.py + - tests/models/language/pooling/test_embedding.py + - tests/models/language/generation/test_common.py + - tests/models/language/pooling/test_classification.py + - vllm/_aiter_ops.py + - vllm/platforms/rocm.py - label: Language Models Tests (Hybrid) %N key: language-models-tests-hybrid @@ -48,9 +65,9 @@ steps: - pytest -v -s models/language/generation -m hybrid_model --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB parallelism: 2 mirror: - torch_nightly: {} amd: device: mi325_1 + timeout_in_minutes: 90 depends_on: - image-build-amd commands: diff --git a/.buildkite/test_areas/models_multimodal.yaml b/.buildkite/test_areas/models_multimodal.yaml index 06cfe11f08cf..a721efa6067e 100644 --- a/.buildkite/test_areas/models_multimodal.yaml +++ b/.buildkite/test_areas/models_multimodal.yaml @@ -10,7 +10,6 @@ steps: - vllm/ - tests/models/multimodal commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "qwen2" - pytest -v -s models/multimodal/generation/test_ultravox.py -m core_model mirror: @@ -27,10 +26,9 @@ steps: - vllm/ - tests/models/multimodal commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "qwen3 or gemma" + - pytest -v -s models/multimodal/generation/test_mm_prefix_lm.py -m core_model - pytest -v -s models/multimodal/generation/test_qwen2_5_vl.py -m core_model - - pytest -v -s models/multimodal/generation/test_vit_cudagraph.py -m core_model mirror: amd: device: mi325_1 @@ -45,7 +43,6 @@ steps: - vllm/ - tests/models/multimodal commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m core_model -k "not qwen2 and not qwen3 and not gemma" - pytest -v -s models/multimodal/generation/test_qwen2_vl.py -m core_model mirror: @@ -62,10 +59,15 @@ steps: - vllm/ - tests/models/multimodal commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - - pytest -v -s models/multimodal -m core_model --ignore models/multimodal/generation/test_common.py --ignore models/multimodal/generation/test_ultravox.py --ignore models/multimodal/generation/test_qwen2_5_vl.py --ignore models/multimodal/generation/test_qwen2_vl.py --ignore models/multimodal/generation/test_whisper.py --ignore models/multimodal/generation/test_memory_leak.py --ignore models/multimodal/processing + - pytest -v -s models/multimodal -m core_model --ignore models/multimodal/generation/test_common.py --ignore models/multimodal/generation/test_ultravox.py --ignore models/multimodal/generation/test_qwen2_5_vl.py --ignore models/multimodal/generation/test_qwen2_vl.py --ignore models/multimodal/generation/test_whisper.py --ignore models/multimodal/generation/test_mm_prefix_lm.py --ignore models/multimodal/generation/test_memory_leak.py --ignore models/multimodal/generation/test_vit_cudagraph.py --ignore models/multimodal/processing + - pytest -v -s models/multimodal/generation/test_vit_cudagraph.py -m core_model - pytest models/multimodal/generation/test_memory_leak.py -m core_model - cd .. && VLLM_WORKER_MULTIPROC_METHOD=spawn pytest -v -s tests/models/multimodal/generation/test_whisper.py -m core_model # Otherwise, mp_method="spawn" doesn't work + mirror: + amd: + device: mi325_1 + depends_on: + - image-build-amd - label: Multi-Modal Processor (CPU) key: multi-modal-processor-cpu @@ -78,7 +80,6 @@ steps: - tests/models/registry.py device: cpu-medium commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/processing --ignore models/multimodal/processing/test_tensor_schema.py - label: Multi-Modal Processor # 44min @@ -90,7 +91,6 @@ steps: - tests/models/multimodal - tests/models/registry.py commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/processing/test_tensor_schema.py - label: Multi-Modal Accuracy Eval (Small Models) # 50min @@ -104,6 +104,17 @@ steps: - vllm/v1/core/ commands: - pytest -s -v test_lm_eval_correctness.py --config-list-file=configs/models-mm-small.txt --tp-size=1 + mirror: + amd: + device: mi300_1 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/multimodal/ + - vllm/inputs/ + - vllm/v1/core/ + - vllm/platforms/rocm.py + - vllm/model_executor/model_loader/ - label: Multi-Modal Models (Extended Generation 1) key: multi-modal-models-extended-generation-1 @@ -113,7 +124,6 @@ steps: - tests/models/multimodal/generation - tests/models/multimodal/test_mapping.py commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation -m 'not core_model' --ignore models/multimodal/generation/test_common.py - pytest -v -s models/multimodal/test_mapping.py mirror: @@ -130,7 +140,6 @@ steps: - vllm/ - tests/models/multimodal/generation commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m 'split(group=0) and not core_model' - label: Multi-Modal Models (Extended Generation 3) @@ -141,7 +150,6 @@ steps: - vllm/ - tests/models/multimodal/generation commands: - - pip install git+https://github.com/TIGER-AI-Lab/Mantis.git - pytest -v -s models/multimodal/generation/test_common.py -m 'split(group=1) and not core_model' - label: Multi-Modal Models (Extended Pooling) @@ -153,3 +161,12 @@ steps: - tests/models/multimodal/pooling commands: - pytest -v -s models/multimodal/pooling -m 'not core_model' + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 60 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/ + - tests/models/multimodal/pooling diff --git a/.buildkite/test_areas/plugins.yaml b/.buildkite/test_areas/plugins.yaml index 0d23180f3ef7..5effe17513d4 100644 --- a/.buildkite/test_areas/plugins.yaml +++ b/.buildkite/test_areas/plugins.yaml @@ -27,16 +27,39 @@ steps: - pip install -e ./plugins/bge_m3_sparse_plugin - pytest -v -s plugins_tests/test_bge_m3_sparse_io_processor_plugins.py - pip uninstall bge_m3_sparse_plugin -y + # test colbert_query io_processor plugin + - pip install -e ./plugins/colbert_query_plugin + - pytest -v -s plugins_tests/test_colbert_query_io_processor_plugins.py + - pip uninstall colbert_query_plugin -y # end io_processor plugins test # begin stat_logger plugins test - pip install -e ./plugins/vllm_add_dummy_stat_logger - pytest -v -s plugins_tests/test_stats_logger_plugins.py - pip uninstall dummy_stat_logger -y # end stat_logger plugins test + # begin endpoint plugins test + - pip install -e ./plugins/vllm_add_dummy_endpoint_plugin + - pytest -v -s plugins_tests/test_endpoint_plugins.py + - pip uninstall vllm_add_dummy_endpoint_plugin -y + # end endpoint plugins test # other tests continue here: - pytest -v -s plugins_tests/test_scheduler_plugins.py - pip install -e ./plugins/vllm_add_dummy_model - pytest -v -s distributed/test_distributed_oot.py - - pytest -v -s entrypoints/openai/chat_completion/test_oot_registration.py # it needs a clean process - - pytest -v -s models/test_oot_registration.py # it needs a clean process - - pytest -v -s plugins/lora_resolvers # unit tests for in-tree lora resolver plugins + - pytest -v -s plugins_tests/test_oot_registration_online.py # it needs a clean process + - pytest -v -s plugins_tests/test_oot_registration_offline.py # it needs a clean process + - pytest -v -s plugins_tests/lora_resolvers # unit tests for in-tree lora resolver plugins + + +- label: GGUF Plugin + key: gguf-plugin + device: h200_18gb + timeout_in_minutes: 30 + soft_fail: true + optional: true + source_file_dependencies: + - vllm/model_executor/layers/quantization + - tests/plugins_tests/test_gguf_plugin.py + commands: + - pip install "vllm-gguf-plugin >= 0.0.2" + - pytest -v -s plugins_tests/gguf diff --git a/.buildkite/test_areas/pytorch.yaml b/.buildkite/test_areas/pytorch.yaml index 6866d5e3695d..5c3060582aa8 100644 --- a/.buildkite/test_areas/pytorch.yaml +++ b/.buildkite/test_areas/pytorch.yaml @@ -107,6 +107,12 @@ steps: - tests/compile/passes commands: - pytest -s -v compile/passes --ignore compile/passes/distributed + mirror: + amd: + device: mi300_1 + timeout_in_minutes: 180 + depends_on: + - image-build-amd - label: PyTorch Fullgraph Smoke Test key: pytorch-fullgraph-smoke-test @@ -189,3 +195,11 @@ steps: - requirements/test/nightly-torch.txt commands: - bash standalone_tests/pytorch_nightly_dependency.sh + mirror: + amd: + device: mi300_1 + depends_on: + - image-build-amd + source_file_dependencies: + - requirements/test/nightly-torch.txt + - vllm/platforms/rocm.py diff --git a/.buildkite/test_areas/quantization.yaml b/.buildkite/test_areas/quantization.yaml index 8a9a36da4481..a92ee24f4aac 100644 --- a/.buildkite/test_areas/quantization.yaml +++ b/.buildkite/test_areas/quantization.yaml @@ -21,6 +21,18 @@ steps: - uv pip install --system conch-triton-kernels - VLLM_TEST_FORCE_LOAD_FORMAT=auto pytest -v -s quantization/ --ignore quantization/test_blackwell_moe.py +- label: Quantized Fusions + key: quantized-fusions + timeout_in_minutes: 30 + source_file_dependencies: + - tests/fusion + - vllm/model_executor/layers/fusion + - vllm/model_executor/kernels/linear + - vllm/model_executor/layers/quantization/compressed_tensors + - vllm/model_executor/layers/quantization/modelopt.py + commands: + - pytest -v -s fusion/ + - label: Quantized MoE Test (B200) key: quantized-moe-test-b200 timeout_in_minutes: 60 diff --git a/.buildkite/test_areas/rust_frontend.yaml b/.buildkite/test_areas/rust_frontend.yaml index 16d69f773450..1dfe912aa89e 100644 --- a/.buildkite/test_areas/rust_frontend.yaml +++ b/.buildkite/test_areas/rust_frontend.yaml @@ -15,24 +15,26 @@ steps: - tests/utils.py - tests/benchmarks/test_serve_cli.py - tests/entrypoints/openai/chat_completion/test_chat_completion.py - # - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py + - tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py # - tests/entrypoints/openai/completion/test_prompt_validation.py - tests/entrypoints/openai/completion/test_shutdown.py - # - tests/entrypoints/openai/test_return_token_ids.py - # - tests/entrypoints/openai/test_uds.py + - tests/entrypoints/openai/test_return_token_ids.py + - tests/entrypoints/openai/test_uds.py - tests/v1/sample/test_logprobs_e2e.py commands: - export VLLM_USE_RUST_FRONTEND=1 - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -v -s benchmarks/test_serve_cli.py -k "not insecure and not (test_bench_serve and not test_bench_serve_chat)" - - pytest -v -s entrypoints/openai/chat_completion/test_chat_completion.py - # - pytest -v -s entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py -k "not invalid" + - pytest -v -s entrypoints/openai/chat_completion/test_chat_completion.py -k "not test_invalid_json_schema and not test_invalid_regex" + - pytest -v -s entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py -k "not multiple" # - pytest -v -s entrypoints/openai/completion/test_prompt_validation.py -k "not prompt_embeds" - pytest -v -s entrypoints/openai/completion/test_shutdown.py -k "not engine_failure and not test_abort_timeout_exits_quickly" - # - pytest -v -s entrypoints/openai/test_return_token_ids.py - # - pytest -v -s entrypoints/openai/test_uds.py + # test_comparison streams differently: Rust emits a separate first (prompt_token_ids) chunk and + # finish chunk without logprobs, while the test reads `logprobs.tokens` on every chunk. + - pytest -v -s entrypoints/openai/test_return_token_ids.py -k "not test_comparison" + - pytest -v -s entrypoints/openai/test_uds.py - pytest -v -s v1/sample/test_logprobs_e2e.py -k "test_prompt_logprobs_e2e_server" - label: Rust Frontend Serve/Admin Coverage @@ -45,19 +47,24 @@ steps: - vllm/entrypoints/serve/ - vllm/v1/engine/ - tests/utils.py - # - tests/entrypoints/serve/dev/rpc/test_collective_rpc.py - - tests/entrypoints/serve/disagg/test_serving_tokens.py + - tests/entrypoints/serve/dev/rpc/test_collective_rpc.py + - tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py - tests/entrypoints/serve/instrumentator/test_basic.py - tests/entrypoints/serve/instrumentator/test_metrics.py # - tests/entrypoints/serve/dev/test_sleep.py + - tests/entrypoints/serve/tokenize/test_tokenization.py commands: - export VLLM_USE_RUST_FRONTEND=1 - export VLLM_WORKER_MULTIPROC_METHOD=spawn - # - pytest -v -s entrypoints/serve/dev/rpc/test_collective_rpc.py + - PYTHONPATH=/vllm-workspace pytest -v -s entrypoints/serve/dev/rpc/test_collective_rpc.py + # server_load can be flaky under the Rust frontend; keep it excluded for now. - pytest -v -s entrypoints/serve/instrumentator/test_basic.py -k "not show_version and not server_load" - - pytest -v -s entrypoints/serve/disagg/test_serving_tokens.py -k "not stream and not lora and not test_generate_logprobs and not stop_string_workflow" + # test_generate_logprobs expects Python-style top_logprobs truncation (dedup sampled + cap at max(k, 1)). + - pytest -v -s entrypoints/scale_out/token_in_token_out/test_serving_tokens.py -k "not stream and not lora and not test_generate_logprobs and not stop_string_workflow" - pytest -v -s entrypoints/serve/instrumentator/test_metrics.py -k "text and not show and not run_batch and not test_metrics_counts and not test_metrics_exist" # - pytest -v -s entrypoints/serve/dev/test_sleep.py + # /tokenizer_info is not implemented in the Rust frontend (the CLI flag is accepted as a no-op). + - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info" - label: Rust Frontend Core Correctness timeout_in_minutes: 30 @@ -99,9 +106,13 @@ steps: - vllm/v1/engine/ - vllm/v1/worker/ - tests/utils.py + - tests/v1/distributed/test_external_lb_dp.py + - tests/v1/distributed/test_hybrid_lb_dp.py - tests/v1/distributed/test_internal_lb_dp.py commands: - export VLLM_USE_RUST_FRONTEND=1 - export VLLM_WORKER_MULTIPROC_METHOD=spawn - export NCCL_CUMEM_HOST_ENABLE=0 - TP_SIZE=1 DP_SIZE=4 pytest -v -s v1/distributed/test_internal_lb_dp.py -k "not 4 and not server_info" + - TP_SIZE=1 DP_SIZE=2 pytest -v -s v1/distributed/test_external_lb_dp.py -k "not 4 and not server_info" + - TP_SIZE=1 DP_SIZE=4 pytest -v -s v1/distributed/test_hybrid_lb_dp.py -k "not 4 and not server_info" diff --git a/.buildkite/test_areas/spec_decode.yaml b/.buildkite/test_areas/spec_decode.yaml index 68dc8e7ef320..671638f6f642 100644 --- a/.buildkite/test_areas/spec_decode.yaml +++ b/.buildkite/test_areas/spec_decode.yaml @@ -12,6 +12,20 @@ steps: - tests/v1/e2e/spec_decode/ commands: - pytest -v -s v1/e2e/spec_decode -k "eagle_correctness" + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 45 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/v1/spec_decode/ + - vllm/v1/worker/gpu/spec_decode/ + - vllm/model_executor/model_loader/ + - vllm/v1/sample/ + - vllm/model_executor/layers/ + - tests/v1/e2e/spec_decode/ + - vllm/platforms/rocm.py - label: Spec Decode Eagle Nightly B200 key: spec-decode-eagle-nightly-b200 @@ -37,6 +51,21 @@ steps: - tests/v1/e2e/spec_decode/ commands: - pytest -v -s v1/e2e/spec_decode -k "speculators or mtp_correctness" + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 65 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/v1/spec_decode/ + - vllm/v1/worker/gpu/spec_decode/ + - vllm/model_executor/model_loader/ + - vllm/v1/sample/ + - vllm/model_executor/layers/ + - vllm/transformers_utils/configs/speculators/ + - tests/v1/e2e/spec_decode/ + - vllm/platforms/rocm.py - label: Spec Decode Speculators + MTP Nightly B200 key: spec-decode-speculators-mtp-nightly-b200 @@ -61,6 +90,22 @@ steps: - tests/v1/e2e/spec_decode/ commands: - pytest -v -s v1/e2e/spec_decode -k "ngram or suffix" + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 65 + # TODO(akaratza): Test after Torch >= 2.12 bump + soft_fail: true + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/v1/spec_decode/ + - vllm/v1/worker/gpu/spec_decode/ + - vllm/model_executor/model_loader/ + - vllm/v1/sample/ + - vllm/model_executor/layers/ + - tests/v1/e2e/spec_decode/ + - vllm/platforms/rocm.py - label: Spec Decode Draft Model key: spec-decode-draft-model @@ -72,6 +117,20 @@ steps: - tests/v1/e2e/spec_decode/ commands: - pytest -v -s v1/e2e/spec_decode -k "draft_model or no_sync or batch_inference" + mirror: + amd: + device: mi325_1 + timeout_in_minutes: 50 + depends_on: + - image-build-amd + source_file_dependencies: + - vllm/v1/spec_decode/ + - vllm/v1/worker/gpu/spec_decode/ + - vllm/model_executor/model_loader/ + - vllm/v1/sample/ + - vllm/model_executor/layers/ + - tests/v1/e2e/spec_decode/ + - vllm/platforms/rocm.py - label: Spec Decode Draft Model Nightly B200 key: spec-decode-draft-model-nightly-b200 diff --git a/.buildkite/test_areas/weight_loading.yaml b/.buildkite/test_areas/weight_loading.yaml index 01c6bb7809bc..9d7bd0bce91c 100644 --- a/.buildkite/test_areas/weight_loading.yaml +++ b/.buildkite/test_areas/weight_loading.yaml @@ -13,6 +13,13 @@ steps: - tests/weight_loading commands: - bash weight_loading/run_model_weight_loading_test.sh -c weight_loading/models.txt + mirror: + amd: + device: mi300_2 + depends_on: + - image-build-amd + commands: + - bash weight_loading/run_model_weight_loading_test.sh -c weight_loading/models-amd.txt # - label: Weight Loading Multiple GPU - Large Models # optional # working_dir: "/vllm-workspace/tests" diff --git a/.claude/skills/ci-fails-buildkite/SKILL.md b/.claude/skills/ci-fails-buildkite/SKILL.md new file mode 100644 index 000000000000..d195c02f7231 --- /dev/null +++ b/.claude/skills/ci-fails-buildkite/SKILL.md @@ -0,0 +1,35 @@ +--- +name: ci-fails-buildkite +description: Fetch and diagnose vLLM Buildkite CI failure logs. Use when investigating failing CI jobs on a PR or build, when the user pastes a buildkite.com URL, or asks to fetch/diagnose CI logs. +--- + +# Diagnosing vLLM Buildkite CI Failures + +Buildkite logs are public; no login needed. + +`.buildkite/scripts/ci-fetch-log.sh` saves each log as `ci--.log`, stripped of timestamps and ANSI codes. Existing files are kept; set `CI_FETCH_LOG_FORCE=1` to refetch. + +## Fetching logs + +```bash +# All failed jobs in a PR's latest build (current branch's PR if omitted): +.buildkite/scripts/ci-fetch-log.sh --pr + +# All failed jobs in a build (--soft also includes soft-failed jobs; +# --all fetches every finished job): +.buildkite/scripts/ci-fetch-log.sh "https://buildkite.com/vllm/ci/builds/" + +# One job — `gh pr checks` URLs (#) and web UI URLs (?sid=) both +# work; pass "-" as a second argument to stream to stdout: +.buildkite/scripts/ci-fetch-log.sh "https://buildkite.com/vllm/ci/builds/#" +``` + +To clean an already-downloaded log with `.buildkite/scripts/ci-clean-log.sh`: + +```bash +./ci-clean-log.sh ci.log +``` + +## Reference + +See [docs/contributing/ci/failures.md](../../../docs/contributing/ci/failures.md) for the full guide: filing CI failure issues, investigating/bisecting, reproducing flaky tests, and daily triage. diff --git a/.dockerignore b/.dockerignore index 8396cbf08ded..66447272e95a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,8 +33,3 @@ share/python-wheels/ *.egg MANIFEST rust/target/ -# Not needed in Docker builds -docs/ -.github/ -.pre-commit-config.yaml -format.sh diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index beaaa5d8642e..57166d9d9b78 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,17 +2,16 @@ # for more info about CODEOWNERS file # This lists cover the "core" components of vLLM that require careful review -/vllm/compilation @zou3519 @youkaichao @ProExpertProg @BoyuanFeng @vadiklyutiy -/vllm/distributed/kv_transfer @NickLucche @ApostaC @orozery @xuechendi +/vllm/compilation @zou3519 @youkaichao @ProExpertProg @BoyuanFeng +/vllm/distributed/kv_transfer @NickLucche @ApostaC @orozery @xuechendi @ivanium /vllm/lora @jeejeelee /vllm/model_executor/layers/attention @LucasWilkinson @MatthewBonanni /vllm/model_executor/layers/fused_moe @mgoin @pavanimajety @zyongye /vllm/model_executor/layers/quantization @mgoin @robertgshaw2-redhat @tlrmchlsmth @yewentao256 @pavanimajety @zyongye /vllm/model_executor/layers/mamba @tdoublep @tomeras91 -/vllm/model_executor/layers/mamba/gdn_linear_attn.py @tdoublep @ZJY0516 @vadiklyutiy -/vllm/model_executor/layers/rotary_embedding.py @vadiklyutiy +/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py @tdoublep @ZJY0516 @vadiklyutiy /vllm/model_executor/model_loader @22quinn -/vllm/model_executor/layers/batch_invariant.py @yewentao256 +/vllm/model_executor/layers/batch_invariant.py @yewentao256 /vllm/ir @ProExpertProg /vllm/kernels/ @ProExpertProg @tjtanaa /vllm/kernels/helion @ProExpertProg @zou3519 @@ -23,8 +22,13 @@ # Any change to the VllmConfig changes can have a large user-facing impact, # so spam a lot of people -/vllm/config @WoosukKwon @youkaichao @robertgshaw2-redhat @mgoin @tlrmchlsmth @houseroad @hmellor @yewentao256 @ProExpertProg -/vllm/config/cache.py @heheda12345 +/vllm/config @WoosukKwon @youkaichao @robertgshaw2-redhat @mgoin @tlrmchlsmth @houseroad @yewentao256 @ProExpertProg +/vllm/config/cache.py @heheda12345 @ivanium + +# Config utils +/vllm/config/utils.py @hmellor +/vllm/engine/arg_utils.py @hmellor +/vllm/utils/argparse_utils.py # Entrypoints /vllm/entrypoints/anthropic @mgoin @DarkLight1337 @@ -34,10 +38,11 @@ /vllm/entrypoints/speech_to_text/realtime @njhill /vllm/entrypoints/speech_to_text @NickLucche /vllm/entrypoints/pooling @noooop -/vllm/entrypoints/sagemaker @DarkLight1337 +/vllm/entrypoints/serve/sagemaker @DarkLight1337 /vllm/entrypoints/serve @njhill /vllm/entrypoints/*.py @njhill /vllm/entrypoints/chat_utils.py @DarkLight1337 +/vllm/entrypoints/offline_utils.py @DarkLight1337 /vllm/entrypoints/llm.py @DarkLight1337 # Rust Frontend @@ -62,19 +67,20 @@ /vllm/v1/attention/backends/flashinfer.py @mgoin @pavanimajety @vadiklyutiy /vllm/v1/attention/backends/triton_attn.py @tdoublep /vllm/v1/attention/backends/gdn_attn.py @ZJY0516 @vadiklyutiy -/vllm/v1/core @WoosukKwon @robertgshaw2-redhat @njhill @ywang96 @alexm-redhat @heheda12345 @ApostaC @orozery +/vllm/v1/core @WoosukKwon @robertgshaw2-redhat @njhill @ywang96 @alexm-redhat @heheda12345 @ApostaC @orozery @ivanium /vllm/v1/sample @22quinn @houseroad @njhill /vllm/v1/spec_decode @benchislett @luccafong @MatthewBonanni /vllm/v1/structured_output @mgoin @russellb @aarnphm @benchislett -/vllm/v1/kv_cache_interface.py @heheda12345 +/vllm/v1/kv_cache_interface.py @heheda12345 @ivanium /vllm/v1/kv_offload @ApostaC @orozery +/vllm/v1/simple_kv_offload @ivanium /vllm/v1/engine @njhill /vllm/v1/executor @njhill /vllm/v1/worker @njhill -/vllm/v1/worker/kv_connector_model_runner_mixin.py @orozery @NickLucche +/vllm/v1/worker/kv_connector_model_runner_mixin.py @orozery @NickLucche @ivanium # Model runner V2 -/vllm/v1/worker/gpu @WoosukKwon @njhill +/vllm/v1/worker/gpu @WoosukKwon @njhill @yewentao256 /vllm/v1/worker/gpu/kv_connector.py @orozery # CI & building @@ -98,13 +104,14 @@ /tests/test_inputs.py @DarkLight1337 @ywang96 /tests/entrypoints/llm/test_struct_output_generate.py @mgoin @russellb @aarnphm /tests/v1/structured_output @mgoin @russellb @aarnphm -/tests/v1/core @WoosukKwon @robertgshaw2-redhat @njhill @ywang96 @alexm-redhat @heheda12345 @ApostaC @orozery +/tests/v1/core @WoosukKwon @robertgshaw2-redhat @njhill @ywang96 @alexm-redhat @heheda12345 @ApostaC @orozery @ivanium /tests/weight_loading @mgoin @youkaichao @yewentao256 /tests/lora @jeejeelee /tests/models/language/generation/test_hybrid.py @tdoublep @tomeras91 /tests/v1/kv_connector/nixl_integration @NickLucche -/tests/v1/kv_connector @ApostaC @orozery +/tests/v1/kv_connector @ApostaC @orozery @ivanium /tests/v1/kv_offload @ApostaC @orozery +/tests/v1/simple_kv_offload @ivanium /tests/v1/determinism @yewentao256 /tests/reasoning @aarnphm @chaunceyjiang @sfeng33 @bbrowning /tests/tool_parsers @aarnphm @chaunceyjiang @sfeng33 @bbrowning @@ -112,17 +119,7 @@ # Transformers modeling backend /vllm/model_executor/models/transformers @hmellor -/tests/models/test_transformers.py @hmellor - -# Observability -/vllm/config/observability.py @markmc -/vllm/v1/metrics @markmc -/tests/v1/metrics @markmc -/vllm/tracing.py @markmc -/tests/v1/tracing/test_tracing.py @markmc -/vllm/config/kv_events.py @markmc -/vllm/distributed/kv_events.py @markmc -/tests/distributed/test_events.py @markmc +/tests/models/transformers @hmellor # Docs /docs/mkdocs @hmellor diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000000..082e8a9eb90b --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,7 @@ +# Custom self-hosted runner labels (e.g. the autoscaling vllm-runners pool) so +# actionlint doesn't flag them as unknown in `runs-on`. +self-hosted-runner: + labels: + - vllm-runners + # Not yet in actionlint's known-label set. + - macos-26 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a017d69be991..944929fc55e5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,7 +21,6 @@ updates: - dependency-name: "torchvision" - dependency-name: "xformers" - dependency-name: "lm-format-enforcer" - - dependency-name: "gguf" - dependency-name: "compressed-tensors" - dependency-name: "ray[cgraph]" # Ray Compiled Graph - dependency-name: "lm-eval" diff --git a/.github/mergify.yml b/.github/mergify.yml index 6caec515d322..4333c6e646da 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -21,6 +21,9 @@ pull_request_rules: - check-failure=pre-commit - -closed - -draft + - or: + - label=ready + - label=verified actions: comment: message: | @@ -36,18 +39,6 @@ pull_request_rules: For future commits, `pre-commit` will run automatically on changed files before each commit. - > [!TIP] - >
- > Is mypy failing? - >
- > mypy is run differently in CI. If the failure is related to this check, please use the following command to run it locally: - > - > ```bash - > # For mypy (substitute "3.10" with the failing version if needed) - > pre-commit run --hook-stage manual mypy-3.10 - > ``` - >
- - name: comment-dco-failure description: Comment on PR when DCO check fails conditions: @@ -153,12 +144,12 @@ pull_request_rules: - label != stale - or: - files~=^examples/.*mistral.*\.py - - files~=^tests/.*mistral.*\.py - - files~=^vllm/model_executor/models/.*mistral.*\.py + - files~=^tests/.*(?:mistral|voxtral|mixtral|pixtral).*\.py + - files~=^vllm/model_executor/models/.*(?:mistral|voxtral|mixtral|pixtral).*\.py - files~=^vllm/reasoning/.*mistral.*\.py - files~=^vllm/tool_parsers/.*mistral.*\.py - - files~=^vllm/transformers_utils/.*mistral.*\.py - - title~=(?i)Mistral + - files~=^vllm/transformers_utils/.*(?:mistral|voxtral|pixtral).*\.py + - title~=(?i)(?:mistral|ministral|voxtral|mixtral|pixtral) actions: label: add: @@ -397,9 +388,13 @@ pull_request_rules: - or: - files~=^tests/tool_use/ - files~=^tests/tool_parsers/ + - files~=^tests/parser/ + - files~=^tests/reasoning/ - files~=^tests/entrypoints/openai/.*tool.* - files~=^tests/entrypoints/anthropic/.*tool.* - files~=^vllm/tool_parsers/ + - files~=^vllm/parser/ + - files~=^vllm/reasoning/ - files=docs/features/tool_calling.md - files~=^examples/tool_calling/ actions: diff --git a/.github/workflows/issue_autolabel.yml b/.github/workflows/issue_autolabel.yml index 4eac3d7b789c..7a98ce7cc08a 100644 --- a/.github/workflows/issue_autolabel.yml +++ b/.github/workflows/issue_autolabel.yml @@ -327,7 +327,7 @@ jobs: message: 'CC {users} for ROCm-related issue', }, mistral: { - users: ['patrickvonplaten', 'juliendenize', 'andylolu2'], + users: ['patrickvonplaten', 'juliendenize', 'andylolu2', 'NickLucche'], message: 'CC {users} for Mistral-related issue', }, // Add more label -> user mappings here diff --git a/.github/workflows/macos-smoke-test.yml b/.github/workflows/macos-smoke-test.yml index ea1c8b0feac3..011bf84feb32 100644 --- a/.github/workflows/macos-smoke-test.yml +++ b/.github/workflows/macos-smoke-test.yml @@ -11,13 +11,25 @@ permissions: jobs: macos-m1-smoke-test: - runs-on: macos-latest + # macos-26 (the supported target) is still a preview runner, so gate on GA + # macos-15 and keep macos-26 non-blocking. + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + required: true + - os: macos-26 + required: false + name: macos-m1-smoke-test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + continue-on-error: ${{ !matrix.required }} timeout-minutes: 30 steps: - - uses: actions/checkout@v6.0.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true cache-dependency-glob: | @@ -72,14 +84,11 @@ jobs: # Test health endpoint curl -f http://localhost:8000/health - # Test completion - curl -f http://localhost:8000/v1/completions \ + # Long prompt: hits the split-KV path that short prompts skip (#46769). + PAYLOAD=$(python -c "import json; print(json.dumps({'model': 'Qwen/Qwen3-0.6B', 'prompt': 'The quick brown fox jumps over the lazy dog. ' * 24, 'max_tokens': 16}))") + curl -f --max-time 120 http://localhost:8000/v1/completions \ -H "Content-Type: application/json" \ - -d '{ - "model": "Qwen/Qwen3-0.6B", - "prompt": "Hello", - "max_tokens": 5 - }' + -d "$PAYLOAD" # Cleanup kill "$SERVER_PID" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 93a5a5ff0ae3..143fc427a49e 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -28,7 +28,8 @@ jobs: pull_number: context.payload.pull_request.number, }); - const hasReadyLabel = pr.labels.some(l => l.name === 'ready'); + const readyLabels = ['ready', 'ready-run-all-tests']; + const hasReadyLabel = pr.labels.some(l => readyLabels.includes(l.name)); const hasVerifiedLabel = pr.labels.some(l => l.name === 'verified'); const { data: mergedPRs } = await github.rest.search.issuesAndPullRequests({ @@ -40,18 +41,22 @@ jobs: if (hasReadyLabel || hasVerifiedLabel || mergedCount >= 4) { core.info(`Check passed: verified label=${hasVerifiedLabel}, ready label=${hasReadyLabel}, 4+ merged PRs=${mergedCount >= 4}`); } else { - core.setFailed(`PR must have the 'verified' or 'ready' (which also triggers tests) label or the author must have at least 4 merged PRs (found ${mergedCount}).`); + core.setFailed(`PR must have the 'verified', 'ready', or 'ready-run-all-tests' label (the ready labels also trigger tests) or the author must have at least 4 merged PRs (found ${mergedCount}).`); } pre-commit: needs: pre-run-check if: always() && (needs.pre-run-check.result == 'success' || needs.pre-run-check.result == 'skipped') - runs-on: ubuntu-latest + runs-on: [self-hosted, linux, x64, vllm-runners] steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.12" + # Provide shellcheck on PATH so tools/pre_commit/shellcheck.sh skips its + # wget + tar -xJ self-download, which the self-hosted runner image lacks + # (no wget/xz). Pinned to shellcheck 0.10.0 to match the script's "stable". + - run: python -m pip install shellcheck-py==0.10.0.1 - run: echo "::add-matcher::.github/workflows/matchers/actionlint.json" - run: echo "::add-matcher::.github/workflows/matchers/markdownlint.json" - run: echo "::add-matcher::.github/workflows/matchers/mypy.json" diff --git a/.github/workflows/scripts/build.sh b/.github/workflows/scripts/build.sh index eb3971c42bfc..335ec735e62c 100644 --- a/.github/workflows/scripts/build.sh +++ b/.github/workflows/scripts/build.sh @@ -9,7 +9,7 @@ PATH=${cuda_home}/bin:$PATH LD_LIBRARY_PATH=${cuda_home}/lib64:$LD_LIBRARY_PATH # Install requirements -if [ "$(echo $2 | cut -d. -f1)" = "12" ]; then +if [ "$(echo "$2" | cut -d. -f1)" = "12" ]; then sed -i 's/^nvidia-cutlass-dsl\[cu13\]>=/nvidia-cutlass-dsl>=/' requirements/cuda.txt fi $python_executable -m pip install -r requirements/build/cuda.txt -r requirements/cuda.txt @@ -17,7 +17,10 @@ $python_executable -m pip install -r requirements/build/cuda.txt -r requirements # Limit the number of parallel jobs to avoid OOM export MAX_JOBS=1 # Make sure release wheels are built for the following architectures -export TORCH_CUDA_ARCH_LIST="7.5 8.0 8.6 8.9 9.0 10.0 12.0+PTX" +# Do not add +PTX here: vLLM filters torch's top-level PTX flag when it +# converts global gencode flags into per-kernel arch lists. If a specific +# kernel needs PTX, add +PTX to that kernel's CMake arch list instead. +export TORCH_CUDA_ARCH_LIST="7.5 8.0 8.6 8.9 9.0 10.0 12.0" bash tools/check_repo.sh diff --git a/.gitignore b/.gitignore index 2c4e135e58dc..26cd21a015d0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ vllm/third_party/flashmla/flash_mla_interface.py # DeepGEMM vendored package built from source vllm/third_party/deep_gemm/ +# fmha_sm100 vendored package built from source +vllm/third_party/fmha_sm100/ + # triton jit .triton @@ -196,7 +199,9 @@ cython_debug/ .vscode/ # Claude -.claude/ +.claude/* +!.claude/skills/ +!.claude/skills/** # Codex .codex/ @@ -233,7 +238,7 @@ actionlint shellcheck*/ # Ignore moe/marlin_moe gen code -csrc/moe/marlin_moe_wna16/kernel_* +csrc/libtorch_stable/moe/marlin_moe_wna16/kernel_* # Ignore ep_kernels_workspace folder ep_kernels_workspace/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c11a80683f86..1eb470ee54fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: rev: v21.1.2 hooks: - id: clang-format - exclude: 'csrc/(moe/topk_softmax_kernels.cu|libtorch_stable/quantization/gguf/(ggml-common.h|dequantize.cuh|vecdotq.cuh|mmq.cuh|mmvq.cuh))|vllm/third_party/.*' + exclude: 'csrc/libtorch_stable/moe/topk_softmax_kernels.cu|vllm/third_party/.*' types_or: [c++, cuda] args: [--style=file, --verbose] - repo: https://github.com/DavidAnson/markdownlint-cli2 @@ -131,6 +131,19 @@ repos: --python-version, "3.12", ] files: ^requirements/(common|xpu|test/xpu)\.(in|txt)$ + - id: pip-compile + alias: pip-compile-cpu + name: pip-compile-cpu + args: [ + requirements/test/cuda.in, + -o, requirements/test/cpu.txt, + --index-strategy, unsafe-best-match, + --torch-backend, cpu, + --python-platform, x86_64-manylinux_2_28, + --python-version, "3.12", + ] + files: ^requirements/(common|cpu|test/(cuda|cpu))\.(in|txt)$ + exclude: ^requirements/test/cuda\.txt$ - id: pip-compile alias: pip-compile-docs name: pip-compile-docs @@ -148,33 +161,27 @@ repos: language: python entry: python tools/pre_commit/generate_nightly_torch_test.py files: ^requirements/test/cuda\.(in|txt)$ - - id: mypy-local - name: Run mypy locally for lowest supported Python version - entry: python tools/pre_commit/mypy.py 0 "3.10" - stages: [pre-commit] # Don't run in CI + - id: mypy-3.10 # TODO: Use https://github.com/pre-commit/mirrors-mypy when mypy setup is less awkward + name: Run mypy for Python 3.10 + entry: python tools/pre_commit/mypy.py "3.10" <<: &mypy_common language: python types_or: [python, pyi] require_serial: true - additional_dependencies: ["mypy[faster-cache]==1.19.1", regex, types-cachetools, types-setuptools, types-PyYAML, types-requests, types-torch, pydantic] - - id: mypy-3.10 # TODO: Use https://github.com/pre-commit/mirrors-mypy when mypy setup is less awkward - name: Run mypy for Python 3.10 - entry: python tools/pre_commit/mypy.py 1 "3.10" - <<: *mypy_common - stages: [manual] # Only run in CI + additional_dependencies: ["mypy==1.20.2", regex, types-cachetools, types-setuptools, types-PyYAML, types-requests, types-torch, pydantic] - id: mypy-3.11 # TODO: Use https://github.com/pre-commit/mirrors-mypy when mypy setup is less awkward name: Run mypy for Python 3.11 - entry: python tools/pre_commit/mypy.py 1 "3.11" + entry: python tools/pre_commit/mypy.py "3.11" <<: *mypy_common stages: [manual] # Only run in CI - id: mypy-3.12 # TODO: Use https://github.com/pre-commit/mirrors-mypy when mypy setup is less awkward name: Run mypy for Python 3.12 - entry: python tools/pre_commit/mypy.py 1 "3.12" + entry: python tools/pre_commit/mypy.py "3.12" <<: *mypy_common stages: [manual] # Only run in CI - id: mypy-3.13 # TODO: Use https://github.com/pre-commit/mirrors-mypy when mypy setup is less awkward name: Run mypy for Python 3.13 - entry: python tools/pre_commit/mypy.py 1 "3.13" + entry: python tools/pre_commit/mypy.py "3.13" <<: *mypy_common stages: [manual] # Only run in CI - id: shellcheck diff --git a/AGENTS.md b/AGENTS.md index 6566523f48e5..a53b81873cf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Do not open one-off PRs for tiny edits (single typo, isolated style change, one - PR descriptions for AI-assisted work **must** include: - Why this is not duplicating an existing PR. - Test commands run and results. + - Model evaluation results when the change affects output, accuracy, or serving. - Clear statement that AI assistance was used. ### Fail-closed behavior @@ -66,23 +67,38 @@ VLLM_USE_PRECOMPILED=1 uv pip install -e . --torch-backend=auto uv pip install -e . --torch-backend=auto ``` -### Running tests +### Tests > Requires [Environment setup](#environment-setup) and [Installing dependencies](#installing-dependencies). ```bash -# Install test dependencies. -# requirements/test/cuda.txt is pinned to x86_64; on other platforms, use the -# unpinned source file instead: -uv pip install -r requirements/test/cuda.in # resolves for current platform -# Or on x86_64: -uv pip install -r requirements/test/cuda.txt - -# Run a specific test file (use .venv/bin/python directly; -# `source activate` does not persist in non-interactive shells): +# Install test dependencies (use cuda.in on non-x86_64): +uv pip install -r requirements/test/cuda.in + +# Run a specific test file: .venv/bin/python -m pytest tests/path/to/test_file.py -v ``` +When adding tests: + +- **Design before you write.** Answer four questions first: what is the module + for, what is its I/O contract, what failure am I guarding against, and what is + the cheapest level that catches it (unit over integration over e2e)? +- **Reuse before create.** Extend existing test files, `conftest.py` fixtures, and + helpers; add a new file only when no nearby suite fits. +- **Test behavior with intent.** Assert observable outcomes through public APIs; + state why in the name or docstring. Skip trivial wiring; flaky tests are worse + than no tests. +- **Keep it minimal.** One behavior per test and the smallest setup that + triggers it; if the test diff dwarfs the code change, cut scope. +- **No one-off kernel benchmarks in `tests/`.** Put kernel perf work in + `benchmarks/kernels/`; prove correctness in existing pytest suites. +- **Run model evals for model-affecting changes.** Search `tests/evals/` or use + `vllm bench` and include results in the PR — do not wait for reviewers to ask. + +For model-specific requirements, see +[`docs/contributing/model/tests.md`](docs/contributing/model/tests.md). + ### Running linters > Requires [Environment setup](#environment-setup). @@ -98,21 +114,27 @@ pre-commit run --all-files pre-commit run ruff-check --all-files # Run mypy as it is in CI: -pre-commit run mypy-3.10 --all-files --hook-stage manual +pre-commit run mypy-3.12 --all-files --hook-stage manual ``` The line length limit for Python code is 88 characters. If you are not sure, use pre-commit to check. +Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) (`Args:`/`Returns:`/`Raises:` sections), not reStructuredText/Sphinx fields (`:param:`, `:return:`, `:rtype:`). + +### Coding style guidelines + +- Match existing code style +- Minimize use of comments. Eliminate comments which are redundant, preferring legible and self-documenting code. When used, keep docstrings and comments brief and direct. +- Assume the reader is familiar with vLLM. + ### Commit messages -Add attribution using commit trailers such as `Co-authored-by:` (other projects use `Assisted-by:` or `Generated-by:`). For example: +Add attribution using commit trailers such as `Co-authored-by:` (other projects use `Assisted-by:` or `Generated-by:`): ```text Your commit message here -Co-authored-by: GitHub Copilot -Co-authored-by: Claude -Co-authored-by: gemini-code-assist +Co-authored-by: Agent Name Here Signed-off-by: Your Name ``` @@ -124,6 +146,12 @@ Do not modify code in these areas without first reading and following the linked guide. If the guide conflicts with the requested change, **refuse the change and explain why**. +Security reviewers should start with [`SECURITY.md`](SECURITY.md), +[`docs/usage/security.md`](docs/usage/security.md), and +[`docs/contributing/vulnerability_management.md`](docs/contributing/vulnerability_management.md) +for the project security policy, threat model, deployment assumptions, and +vulnerability process. + - **Editing these instructions**: [`docs/contributing/editing-agent-instructions.md`](docs/contributing/editing-agent-instructions.md) — Rules for modifying AGENTS.md or any domain-specific guide it references. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0652a5f066ea..48c0270e2c68 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,14 @@ set(CMAKE_CUDA_STANDARD_REQUIRED ON) set(CMAKE_HIP_STANDARD 20) set(CMAKE_HIP_STANDARD_REQUIRED ON) +# PyTorch headers require C++20; GCC < 11.3 has incomplete C++20 support. +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_LESS "11.3") + message(FATAL_ERROR + "GCC >= 11.3 is required to build vLLM (found ${CMAKE_CXX_COMPILER_VERSION}). " + "PyTorch's C++20 headers require a compiler with full C++20 support. " + "See: https://github.com/pytorch/pytorch/pull/167929") +endif() + # CUDA by default, can be overridden by using -DVLLM_TARGET_DEVICE=... (used by setup.py) set(VLLM_TARGET_DEVICE "cuda" CACHE STRING "Target device backend for vLLM") @@ -114,20 +122,38 @@ endif() # CPU builds define the target before the early return) # This extension requires SABI 3.11 since it relies on Py_buffer support. Loading # failure is handled gracefully on vLLM side for lower Python versions. +# Skip the target entirely on Python < 3.11 so the build doesn't break. # -set(VLLM_SPINLOOP_EXT_SRC "csrc/spinloop.cpp") -set(SPINLOOP_COMPILE_FLAGS "") -if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") - list(APPEND SPINLOOP_COMPILE_FLAGS "-mmwaitx") +if(Python_VERSION VERSION_GREATER_EQUAL "3.11") + set(VLLM_SPINLOOP_EXT_SRC "csrc/spinloop.cpp") + set(SPINLOOP_COMPILE_FLAGS "") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64") + list(APPEND SPINLOOP_COMPILE_FLAGS "-mmwaitx") + endif() + define_extension_target( + spinloop + DESTINATION vllm + LANGUAGE CXX + SOURCES ${VLLM_SPINLOOP_EXT_SRC} + COMPILE_FLAGS ${SPINLOOP_COMPILE_FLAGS} + USE_SABI 3.11 + WITH_SOABI) +endif() + +# +# fs_io extension (pure CXX; must stay above the non-CUDA device branch +# so CPU builds define the target before the early return). +# GIL-releasing filesystem helpers for FileSystemTierManager. +# +if(Python_VERSION VERSION_GREATER_EQUAL "3.11") + define_extension_target( + fs_io_C + DESTINATION vllm + LANGUAGE CXX + SOURCES csrc/fs_io.cpp + USE_SABI 3.11 + WITH_SOABI) endif() -define_extension_target( - spinloop - DESTINATION vllm - LANGUAGE CXX - SOURCES ${VLLM_SPINLOOP_EXT_SRC} - COMPILE_FLAGS ${SPINLOOP_COMPILE_FLAGS} - USE_SABI 3.11 - WITH_SOABI) # # Forward the non-CUDA device extensions to external CMake scripts. @@ -179,6 +205,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") # the set of architectures we want to compile for and remove the from the # CMAKE_CUDA_FLAGS so that they are not applied globally. # + # `+PTX` in TORCH_CUDA_ARCH_LIST is not preserved here. It is emitted by torch + # as `code=compute_*`, while extract_unique_cuda_archs_ascending() records only + # `arch=compute_*`. If a kernel really needs PTX, add `+PTX` to that kernel's + # component-specific arch list below. + # clear_cuda_arches(CUDA_ARCH_FLAGS) extract_unique_cuda_archs_ascending(CUDA_ARCHS "${CUDA_ARCH_FLAGS}") message(STATUS "CUDA target architectures: ${CUDA_ARCHS}") @@ -254,6 +285,16 @@ if(VLLM_GPU_LANG STREQUAL "HIP") # set(CMAKE_${VLLM_GPU_LANG}_FLAGS "${CMAKE_${VLLM_GPU_LANG}_FLAGS} -Wno-unused-result -Wno-unused-value") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-unused-result -Wno-unused-value") + + # When using LTO then *.cpp files must be compiled with same compiler as used linker + # So if HIP uses clang linker we also must use it + # Otherwise symbols will be missing from .so + if (CMAKE_CXX_FLAGS MATCHES "\-flto") + if(NOT CMAKE_CXX_COMPILER_ID STREQUAL CMAKE_HIP_COMPILER_ID) + message(FATAL_ERROR "LTO is enabled for ROCm build, but the C++ compiler (${CMAKE_CXX_COMPILER_ID}) and HIP compiler (${CMAKE_HIP_COMPILER_ID}) are different which is not supported. " + "Please ensure they are same by setting CXX=${CMAKE_HIP_COMPILER} environment variable. Or alternatively disable LTO.") + endif() + endif() endif() # @@ -303,323 +344,33 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") endif() # -# _C extension +# Legacy _C extension (ROCm only — CUDA ops migrated to _C_stable_libtorch) # -set(VLLM_EXT_SRC - "csrc/cuda_view.cu" - "csrc/quantization/fused_kernels/fused_silu_mul_block_quant.cu" - "csrc/quantization/activation_kernels.cu" - "csrc/cuda_utils_kernels.cu" - "csrc/torch_bindings.cpp") - -if(VLLM_GPU_LANG STREQUAL "CUDA") - SET(CUTLASS_ENABLE_HEADERS_ONLY ON CACHE BOOL "Enable only the header library") - - # Set CUTLASS_REVISION. Used for FetchContent. Also fixes some bogus messages when building. - set(CUTLASS_REVISION "v4.4.2") - - # Use the specified CUTLASS source directory for compilation if VLLM_CUTLASS_SRC_DIR is provided - if (DEFINED ENV{VLLM_CUTLASS_SRC_DIR}) - set(VLLM_CUTLASS_SRC_DIR $ENV{VLLM_CUTLASS_SRC_DIR}) - endif() - - if(VLLM_CUTLASS_SRC_DIR) - if(NOT IS_ABSOLUTE VLLM_CUTLASS_SRC_DIR) - get_filename_component(VLLM_CUTLASS_SRC_DIR "${VLLM_CUTLASS_SRC_DIR}" ABSOLUTE) - endif() - message(STATUS "The VLLM_CUTLASS_SRC_DIR is set, using ${VLLM_CUTLASS_SRC_DIR} for compilation") - FetchContent_Declare(cutlass SOURCE_DIR ${VLLM_CUTLASS_SRC_DIR}) - else() - FetchContent_Declare( - cutlass - GIT_REPOSITORY https://github.com/nvidia/cutlass.git - # Please keep this in sync with CUTLASS_REVISION line above. - GIT_TAG ${CUTLASS_REVISION} - GIT_PROGRESS TRUE - - # Speed up CUTLASS download by retrieving only the specified GIT_TAG instead of the history. - # Important: If GIT_SHALLOW is enabled then GIT_TAG works only with branch names and tags. - # So if the GIT_TAG above is updated to a commit hash, GIT_SHALLOW must be set to FALSE - GIT_SHALLOW TRUE - ) - endif() - FetchContent_MakeAvailable(cutlass) - - list(APPEND VLLM_EXT_SRC - "csrc/cutlass_extensions/common.cpp") - - set_gencode_flags_for_srcs( - SRCS "${VLLM_EXT_SRC}" - CUDA_ARCHS "${CUDA_ARCHS}") - - # Only build Marlin kernels if we are building for at least some compatible archs. - # Keep building Marlin for 9.0 as there are some group sizes and shapes that - # are not supported by Machete yet. - - # marlin arches for fp16 output - # Family-conditional 12.0f (one cubin for SM12x family) requires CUDA >= 13.0; - # fall back to architecture-specific 12.0a;12.1a on CUDA < 13.0 (e.g. 12.8). - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(MARLIN_ARCHS "8.0+PTX;12.0f" "${CUDA_ARCHS}") - else() - cuda_archs_loose_intersection(MARLIN_ARCHS "8.0+PTX;12.0a;12.1a" "${CUDA_ARCHS}") - endif() - # marlin has limited support for turing - cuda_archs_loose_intersection(MARLIN_SM75_ARCHS "7.5" "${CUDA_ARCHS}") - # marlin arches for bf16 output (we need 9.0 for bf16 atomicAdd PTX) - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(MARLIN_BF16_ARCHS "8.0+PTX;9.0+PTX;12.0f" "${CUDA_ARCHS}") - else() - cuda_archs_loose_intersection(MARLIN_BF16_ARCHS "8.0+PTX;9.0+PTX;12.0a;12.1a" "${CUDA_ARCHS}") - endif() - # marlin arches for fp8 input - # - sm80 doesn't support fp8 computation - # - sm90 and sm100 don't support QMMA.16832.F32.E4M3.E4M3 SAAS instruction - # so we only enable fp8 computation for SM89 (e.g. RTX 40x0) and 12.0 (e.g. RTX 50x0) - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(MARLIN_FP8_ARCHS "8.9;12.0f" "${CUDA_ARCHS}") - else() - cuda_archs_loose_intersection(MARLIN_FP8_ARCHS "8.9;12.0a;12.1a" "${CUDA_ARCHS}") - endif() - # marlin arches for other files - cuda_archs_loose_intersection(MARLIN_OTHER_ARCHS "7.5;8.0+PTX" "${CUDA_ARCHS}") - - if (MARLIN_OTHER_ARCHS) - - # - # For the Marlin kernels we automatically generate sources for various - # preselected input type pairs and schedules. - # Generate sources: - set(MARLIN_GEN_SCRIPT - ${CMAKE_CURRENT_SOURCE_DIR}/csrc/quantization/marlin/generate_kernels.py) - file(MD5 ${MARLIN_GEN_SCRIPT} MARLIN_GEN_SCRIPT_HASH) - list(JOIN CUDA_ARCHS "," CUDA_ARCHS_STR) - set(MARLIN_GEN_SCRIPT_HASH_AND_ARCH "${MARLIN_GEN_SCRIPT_HASH}(ARCH:${CUDA_ARCHS_STR})") - - message(STATUS "Marlin generation script hash: ${MARLIN_GEN_SCRIPT_HASH_AND_ARCH}") - message(STATUS "Last run Marlin generate script hash: $CACHE{MARLIN_GEN_SCRIPT_HASH_AND_ARCH}") - - if (NOT DEFINED CACHE{MARLIN_GEN_SCRIPT_HASH_AND_ARCH} - OR NOT $CACHE{MARLIN_GEN_SCRIPT_HASH_AND_ARCH} STREQUAL ${MARLIN_GEN_SCRIPT_HASH_AND_ARCH}) - execute_process( - COMMAND ${CMAKE_COMMAND} -E env - PYTHONPATH=$ENV{PYTHONPATH} - ${Python_EXECUTABLE} ${MARLIN_GEN_SCRIPT} ${CUDA_ARCHS_STR} - RESULT_VARIABLE marlin_generation_result - OUTPUT_VARIABLE marlin_generation_result - OUTPUT_FILE ${CMAKE_CURRENT_BINARY_DIR}/marlin_generation.log - ERROR_FILE ${CMAKE_CURRENT_BINARY_DIR}/marlin_generation.log - ) - - if (NOT marlin_generation_result EQUAL 0) - message(FATAL_ERROR "Marlin generation failed." - " Result: \"${marlin_generation_result}\"" - "\nCheck the log for details: " - "${CMAKE_CURRENT_BINARY_DIR}/marlin_generation.log") - else() - set(MARLIN_GEN_SCRIPT_HASH_AND_ARCH ${MARLIN_GEN_SCRIPT_HASH_AND_ARCH} - CACHE STRING "Last run Marlin generate script hash and arch" FORCE) - message(STATUS "Marlin generation completed successfully.") - endif() - else() - message(STATUS "Marlin generation script has not changed, skipping generation.") - endif() - - if (MARLIN_ARCHS) - file(GLOB MARLIN_TEMPLATE_KERNEL_SRC "csrc/quantization/marlin/sm80_kernel_*_float16.cu") - set_gencode_flags_for_srcs( - SRCS "${MARLIN_TEMPLATE_KERNEL_SRC}" - CUDA_ARCHS "${MARLIN_ARCHS}") - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) - set_source_files_properties(${MARLIN_TEMPLATE_KERNEL_SRC} - PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") - endif() - list(APPEND VLLM_EXT_SRC ${MARLIN_TEMPLATE_KERNEL_SRC}) - - file(GLOB MARLIN_TEMPLATE_BF16_KERNEL_SRC "csrc/quantization/marlin/sm80_kernel_*_bfloat16.cu") - set_gencode_flags_for_srcs( - SRCS "${MARLIN_TEMPLATE_BF16_KERNEL_SRC}" - CUDA_ARCHS "${MARLIN_BF16_ARCHS}") - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) - set_source_files_properties(${MARLIN_TEMPLATE_BF16_KERNEL_SRC} - PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") - endif() - list(APPEND VLLM_EXT_SRC ${MARLIN_TEMPLATE_BF16_KERNEL_SRC}) - endif() - - if (MARLIN_SM75_ARCHS) - file(GLOB MARLIN_TEMPLATE_SM75_KERNEL_SRC "csrc/quantization/marlin/sm75_kernel_*.cu") - set_gencode_flags_for_srcs( - SRCS "${MARLIN_TEMPLATE_SM75_KERNEL_SRC}" - CUDA_ARCHS "${MARLIN_SM75_ARCHS}") - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) - set_source_files_properties(${MARLIN_TEMPLATE_SM75_KERNEL_SRC} - PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") - endif() - list(APPEND VLLM_EXT_SRC ${MARLIN_TEMPLATE_SM75_KERNEL_SRC}) - endif() - - if (MARLIN_FP8_ARCHS) - file(GLOB MARLIN_TEMPLATE_FP8_KERNEL_SRC "csrc/quantization/marlin/sm89_kernel_*.cu") - set_gencode_flags_for_srcs( - SRCS "${MARLIN_TEMPLATE_FP8_KERNEL_SRC}" - CUDA_ARCHS "${MARLIN_FP8_ARCHS}") - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) - set_source_files_properties(${MARLIN_TEMPLATE_FP8_KERNEL_SRC} - PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") - endif() - list(APPEND VLLM_EXT_SRC ${MARLIN_TEMPLATE_FP8_KERNEL_SRC}) - endif() - - set(MARLIN_SRCS - "csrc/quantization/marlin/marlin.cu" - "csrc/quantization/marlin/marlin_int4_fp8_preprocess.cu" - "csrc/quantization/marlin/gptq_marlin_repack.cu" - "csrc/quantization/marlin/awq_marlin_repack.cu") - set_gencode_flags_for_srcs( - SRCS "${MARLIN_SRCS}" - CUDA_ARCHS "${MARLIN_OTHER_ARCHS}") - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) - set_source_files_properties(${MARLIN_SRCS} - PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") - endif() - list(APPEND VLLM_EXT_SRC "${MARLIN_SRCS}") - - message(STATUS "Building Marlin kernels for archs: ${MARLIN_OTHER_ARCHS}") - else() - message(STATUS "Not building Marlin kernels as no compatible archs found" - " in CUDA target architectures") - endif() - - # Expert-specialization MXFP8 blockscaled grouped kernels (SM100+). - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(ES_MXFP8_GROUPED_MM_ARCHS "10.0f;11.0f" "${CUDA_ARCHS}") - else() - cuda_archs_loose_intersection(ES_MXFP8_GROUPED_MM_ARCHS "10.0a;10.1a;10.3a" "${CUDA_ARCHS}") - endif() - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND ES_MXFP8_GROUPED_MM_ARCHS) - set(SRCS - "csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm.cu" - "csrc/libtorch_stable/moe/mxfp8_moe/mxfp8_experts_quant.cu") - set_gencode_flags_for_srcs( - SRCS "${SRCS}" - CUDA_ARCHS "${ES_MXFP8_GROUPED_MM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") - list(APPEND VLLM_GPU_FLAGS "-DENABLE_ES_MXFP8_GROUPED_MM_SM100=1") - message(STATUS "Building ES MXFP8 grouped kernels for archs: ${ES_MXFP8_GROUPED_MM_ARCHS}") - else() - if (NOT ${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 - AND ES_MXFP8_GROUPED_MM_ARCHS) - message(STATUS "Not building ES MXFP8 grouped kernels as CUDA Compiler version is " - "not >= 12.8.") - else() - message(STATUS "Not building ES MXFP8 grouped kernels as no compatible archs found " - "in CUDA target architectures.") - endif() - endif() - - # - # Machete kernels - - # The machete kernels only work on hopper and require CUDA 12.0 or later. - # Only build Machete kernels if we are building for something compatible with sm90a - cuda_archs_loose_intersection(MACHETE_ARCHS "9.0a" "${CUDA_ARCHS}") - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND MACHETE_ARCHS) - # - # For the Machete kernels we automatically generate sources for various - # preselected input type pairs and schedules. - # Generate sources: - set(MACHETE_GEN_SCRIPT - ${CMAKE_CURRENT_SOURCE_DIR}/csrc/quantization/machete/generate.py) - file(MD5 ${MACHETE_GEN_SCRIPT} MACHETE_GEN_SCRIPT_HASH) - - message(STATUS "Machete generation script hash: ${MACHETE_GEN_SCRIPT_HASH}") - message(STATUS "Last run machete generate script hash: $CACHE{MACHETE_GEN_SCRIPT_HASH}") - - if (NOT DEFINED CACHE{MACHETE_GEN_SCRIPT_HASH} - OR NOT $CACHE{MACHETE_GEN_SCRIPT_HASH} STREQUAL ${MACHETE_GEN_SCRIPT_HASH}) - execute_process( - COMMAND ${CMAKE_COMMAND} -E env - PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR}/csrc/cutlass_extensions/:${CUTLASS_DIR}/python/:${VLLM_PYTHON_PATH}:$ENV{PYTHONPATH} - ${Python_EXECUTABLE} ${MACHETE_GEN_SCRIPT} - RESULT_VARIABLE machete_generation_result - OUTPUT_VARIABLE machete_generation_output - OUTPUT_FILE ${CMAKE_CURRENT_BINARY_DIR}/machete_generation.log - ERROR_FILE ${CMAKE_CURRENT_BINARY_DIR}/machete_generation.log - ) - - if (NOT machete_generation_result EQUAL 0) - message(FATAL_ERROR "Machete generation failed." - " Result: \"${machete_generation_result}\"" - "\nCheck the log for details: " - "${CMAKE_CURRENT_BINARY_DIR}/machete_generation.log") - else() - set(MACHETE_GEN_SCRIPT_HASH ${MACHETE_GEN_SCRIPT_HASH} - CACHE STRING "Last run machete generate script hash" FORCE) - message(STATUS "Machete generation completed successfully.") - endif() - else() - message(STATUS "Machete generation script has not changed, skipping generation.") - endif() - - # Add machete generated sources - file(GLOB MACHETE_GEN_SOURCES "csrc/quantization/machete/generated/*.cu") - list(APPEND VLLM_EXT_SRC ${MACHETE_GEN_SOURCES}) - - # forward compatible - set_gencode_flags_for_srcs( - SRCS "${MACHETE_GEN_SOURCES}" - CUDA_ARCHS "${MACHETE_ARCHS}") - - list(APPEND VLLM_EXT_SRC - csrc/quantization/machete/machete_pytorch.cu) - - message(STATUS "Building Machete kernels for archs: ${MACHETE_ARCHS}") - else() - if (NOT ${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 - AND MACHETE_ARCHS) - message(STATUS "Not building Machete kernels as CUDA Compiler version is " - "not >= 12.0, we recommend upgrading to CUDA 12.0 or " - "later if you intend on running w4a16 quantized models on " - "Hopper.") - else() - message(STATUS "Not building Machete kernels as no compatible archs " - "found in CUDA target architectures") - endif() - endif() - - - -# if CUDA endif -endif() - -if (VLLM_GPU_LANG STREQUAL "HIP") - # Add QuickReduce kernels (ROCm-only; not part of stable ABI migration). - list(APPEND VLLM_EXT_SRC - "csrc/custom_quickreduce.cu" - ) -# if ROCM endif -endif() +if(VLLM_GPU_LANG STREQUAL "HIP") + set(VLLM_EXT_SRC + "csrc/torch_bindings.cpp" + "csrc/custom_quickreduce.cu") -message(STATUS "Enabling C extension.") -define_extension_target( - _C - DESTINATION vllm - LANGUAGE ${VLLM_GPU_LANG} - SOURCES ${VLLM_EXT_SRC} - COMPILE_FLAGS ${VLLM_GPU_FLAGS} - ARCHITECTURES ${VLLM_GPU_ARCHES} - INCLUDE_DIRECTORIES ${CUTLASS_INCLUDE_DIR} - INCLUDE_DIRECTORIES ${CUTLASS_TOOLS_UTIL_INCLUDE_DIR} - USE_SABI 3 - WITH_SOABI) + message(STATUS "Enabling C extension.") + define_extension_target( + _C + DESTINATION vllm + LANGUAGE ${VLLM_GPU_LANG} + SOURCES ${VLLM_EXT_SRC} + COMPILE_FLAGS ${VLLM_GPU_FLAGS} + ARCHITECTURES ${VLLM_GPU_ARCHES} + INCLUDE_DIRECTORIES ${CUTLASS_INCLUDE_DIR} + INCLUDE_DIRECTORIES ${CUTLASS_TOOLS_UTIL_INCLUDE_DIR} + USE_SABI 3 + WITH_SOABI) -# If CUTLASS is compiled on NVCC >= 12.5, it by default uses -# cudaGetDriverEntryPointByVersion as a wrapper to avoid directly calling the -# driver API. This causes problems when linking with earlier versions of CUDA. -# Setting this variable sidesteps the issue by calling the driver directly. -target_compile_definitions(_C PRIVATE CUTLASS_ENABLE_DIRECT_CUDA_DRIVER_CALL=1) + # If CUTLASS is compiled on NVCC >= 12.5, it by default uses + # cudaGetDriverEntryPointByVersion as a wrapper to avoid directly calling the + # driver API. This causes problems when linking with earlier versions of CUDA. + # Setting this variable sidesteps the issue by calling the driver directly. + target_compile_definitions(_C PRIVATE CUTLASS_ENABLE_DIRECT_CUDA_DRIVER_CALL=1) +endif() # _C HIP endif if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # @@ -627,59 +378,323 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # set(VLLM_STABLE_EXT_SRC "csrc/libtorch_stable/torch_bindings.cpp" + "csrc/libtorch_stable/cuda_view.cu" + "csrc/libtorch_stable/cuda_utils_kernels.cu" "csrc/libtorch_stable/activation_kernels.cu" + "csrc/libtorch_stable/quantization/activation_kernels.cu" "csrc/libtorch_stable/quantization/w8a8/int8/scaled_quant.cu" "csrc/libtorch_stable/quantization/w8a8/fp8/common.cu" "csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu" "csrc/libtorch_stable/quantization/w8a8/int8/per_token_group_quant.cu" + "csrc/libtorch_stable/permute_cols.cu" "csrc/libtorch_stable/quantization/gptq/q_gemm.cu" - "csrc/libtorch_stable/quantization/gguf/gguf_kernel.cu" "csrc/libtorch_stable/pos_encoding_kernels.cu" "csrc/libtorch_stable/fused_qknorm_rope_kernel.cu" + "csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu" "csrc/libtorch_stable/layernorm_kernels.cu" "csrc/libtorch_stable/layernorm_quant_kernels.cu" "csrc/libtorch_stable/quantization/fused_kernels/fused_layernorm_dynamic_per_token_quant.cu" + "csrc/libtorch_stable/quantization/fused_kernels/fused_silu_mul_block_quant.cu" "csrc/libtorch_stable/attention/merge_attn_states.cu" "csrc/libtorch_stable/sampler.cu" "csrc/libtorch_stable/topk.cu" "csrc/libtorch_stable/mamba/selective_scan_fwd.cu" - "csrc/libtorch_stable/attention/paged_attention_v1.cu" - "csrc/libtorch_stable/attention/paged_attention_v2.cu" - "csrc/libtorch_stable/cache_kernels.cu" "csrc/libtorch_stable/cache_kernels.cu" "csrc/libtorch_stable/cache_kernels_fused.cu" "csrc/libtorch_stable/custom_all_reduce.cu" "csrc/libtorch_stable/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu") + if(VLLM_GPU_LANG STREQUAL "CUDA" AND + DEFINED CMAKE_CUDA_COMPILER_VERSION AND + CMAKE_CUDA_COMPILER_VERSION VERSION_GREATER_EQUAL 12.0) + + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) + cuda_archs_loose_intersection(COOPERATIVE_TOPK_ARCHS + "9.0a;10.0f;10.1f;10.3f;11.0f;12.0f;12.1f" "${CUDA_ARCHS}") + else() + cuda_archs_loose_intersection(COOPERATIVE_TOPK_ARCHS + "9.0a;10.0a;10.1a;10.3a;12.0a;12.1a" "${CUDA_ARCHS}") + endif() + + if(COOPERATIVE_TOPK_ARCHS) + list(APPEND VLLM_GPU_FLAGS "-DVLLM_ENABLE_COOPERATIVE_TOPK=1") + + endif() + endif() + if(VLLM_GPU_LANG STREQUAL "CUDA") + SET(CUTLASS_ENABLE_HEADERS_ONLY ON CACHE BOOL "Enable only the header library") + + # Set CUTLASS_REVISION. Used for FetchContent. Also fixes some bogus messages when building. + set(CUTLASS_REVISION "v4.4.2") + + # Use the specified CUTLASS source directory for compilation if VLLM_CUTLASS_SRC_DIR is provided + if (DEFINED ENV{VLLM_CUTLASS_SRC_DIR}) + set(VLLM_CUTLASS_SRC_DIR $ENV{VLLM_CUTLASS_SRC_DIR}) + endif() + + if(VLLM_CUTLASS_SRC_DIR) + if(NOT IS_ABSOLUTE VLLM_CUTLASS_SRC_DIR) + get_filename_component(VLLM_CUTLASS_SRC_DIR "${VLLM_CUTLASS_SRC_DIR}" ABSOLUTE) + endif() + message(STATUS "The VLLM_CUTLASS_SRC_DIR is set, using ${VLLM_CUTLASS_SRC_DIR} for compilation") + FetchContent_Declare(cutlass SOURCE_DIR ${VLLM_CUTLASS_SRC_DIR}) + else() + FetchContent_Declare( + cutlass + GIT_REPOSITORY https://github.com/nvidia/cutlass.git + # Please keep this in sync with CUTLASS_REVISION line above. + GIT_TAG ${CUTLASS_REVISION} + GIT_PROGRESS TRUE + + # Speed up CUTLASS download by retrieving only the specified GIT_TAG instead of the history. + # Important: If GIT_SHALLOW is enabled then GIT_TAG works only with branch names and tags. + # So if the GIT_TAG above is updated to a commit hash, GIT_SHALLOW must be set to FALSE + GIT_SHALLOW TRUE + ) + endif() + FetchContent_MakeAvailable(cutlass) + list(APPEND VLLM_STABLE_EXT_SRC - "csrc/cuda_utils_kernels.cu" - "csrc/cutlass_extensions/common.cpp" + "csrc/libtorch_stable/cutlass_extensions/common.cpp" "csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_entry.cu" "csrc/libtorch_stable/quantization/fp4/nvfp4_quant_entry.cu" "csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_entry.cu" - "csrc/libtorch_stable/permute_cols.cu" - "csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu" - "csrc/libtorch_stable/quantization/w8a8/int8/per_token_group_quant.cu" "csrc/libtorch_stable/quantization/awq/gemm_kernels.cu" "csrc/libtorch_stable/minimax_reduce_rms_kernel.cu") + # + # Machete kernels + # + # The machete kernels only work on hopper and require CUDA 12.0 or later. + # Only build Machete kernels if we are building for something compatible with sm90a + cuda_archs_loose_intersection(MACHETE_ARCHS "9.0a" "${CUDA_ARCHS}") + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND MACHETE_ARCHS) + # + # For the Machete kernels we automatically generate sources for various + # preselected input type pairs and schedules. + # Generate sources: + set(MACHETE_GEN_SCRIPT + ${CMAKE_CURRENT_SOURCE_DIR}/csrc/libtorch_stable/quantization/machete/generate.py) + file(MD5 ${MACHETE_GEN_SCRIPT} MACHETE_GEN_SCRIPT_HASH) + + message(STATUS "Machete generation script hash: ${MACHETE_GEN_SCRIPT_HASH}") + message(STATUS "Last run machete generate script hash: $CACHE{MACHETE_GEN_SCRIPT_HASH}") + + if (NOT DEFINED CACHE{MACHETE_GEN_SCRIPT_HASH} + OR NOT $CACHE{MACHETE_GEN_SCRIPT_HASH} STREQUAL ${MACHETE_GEN_SCRIPT_HASH}) + execute_process( + COMMAND ${CMAKE_COMMAND} -E env + PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR}/csrc/cutlass_extensions/:${CUTLASS_DIR}/python/:${VLLM_PYTHON_PATH}:$ENV{PYTHONPATH} + ${Python_EXECUTABLE} ${MACHETE_GEN_SCRIPT} + RESULT_VARIABLE machete_generation_result + OUTPUT_VARIABLE machete_generation_output + OUTPUT_FILE ${CMAKE_CURRENT_BINARY_DIR}/machete_generation.log + ERROR_FILE ${CMAKE_CURRENT_BINARY_DIR}/machete_generation.log + ) + + if (NOT machete_generation_result EQUAL 0) + message(FATAL_ERROR "Machete generation failed." + " Result: \"${machete_generation_result}\"" + "\nCheck the log for details: " + "${CMAKE_CURRENT_BINARY_DIR}/machete_generation.log") + else() + set(MACHETE_GEN_SCRIPT_HASH ${MACHETE_GEN_SCRIPT_HASH} + CACHE STRING "Last run machete generate script hash" FORCE) + message(STATUS "Machete generation completed successfully.") + endif() + else() + message(STATUS "Machete generation script has not changed, skipping generation.") + endif() + + # Add machete generated sources + file(GLOB MACHETE_GEN_SOURCES "csrc/libtorch_stable/quantization/machete/generated/*.cu") + list(APPEND VLLM_STABLE_EXT_SRC ${MACHETE_GEN_SOURCES}) + + # forward compatible + set_gencode_flags_for_srcs( + SRCS "${MACHETE_GEN_SOURCES}" + CUDA_ARCHS "${MACHETE_ARCHS}") + + list(APPEND VLLM_STABLE_EXT_SRC + csrc/libtorch_stable/quantization/machete/machete_pytorch.cu) + message(STATUS "Building Machete kernels for archs: ${MACHETE_ARCHS}") + else() + if (NOT ${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 + AND MACHETE_ARCHS) + message(STATUS "Not building Machete kernels as CUDA Compiler version is " + "not >= 12.0, we recommend upgrading to CUDA 12.0 or " + "later if you intend on running w4a16 quantized models on " + "Hopper.") + else() + message(STATUS "Not building Machete kernels as no compatible archs " + "found in CUDA target architectures") + endif() + endif() + set_gencode_flags_for_srcs( SRCS "${VLLM_STABLE_EXT_SRC}" CUDA_ARCHS "${CUDA_ARCHS}") + if(COOPERATIVE_TOPK_ARCHS) + list(APPEND VLLM_STABLE_EXT_SRC + "csrc/libtorch_stable/cooperative_topk.cu") + set_gencode_flags_for_srcs( + SRCS "csrc/libtorch_stable/cooperative_topk.cu" + CUDA_ARCHS "${COOPERATIVE_TOPK_ARCHS}") + endif() + + # Only build Marlin kernels if we are building for at least some compatible archs. + # Keep building Marlin for 9.0 as there are some group sizes and shapes that + # are not supported by Machete yet. + + # marlin arches for fp16 output + # Family-conditional 12.0f (one cubin for SM12x family) requires CUDA >= 13.0; + # fall back to architecture-specific 12.0a;12.1a on CUDA < 13.0 (e.g. 12.8). + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) + cuda_archs_loose_intersection(MARLIN_ARCHS "8.0+PTX;12.0f" "${CUDA_ARCHS}") + else() + cuda_archs_loose_intersection(MARLIN_ARCHS "8.0+PTX;12.0a;12.1a" "${CUDA_ARCHS}") + endif() + # marlin has limited support for turing + cuda_archs_loose_intersection(MARLIN_SM75_ARCHS "7.5" "${CUDA_ARCHS}") + # marlin arches for bf16 output (we need 9.0 for bf16 atomicAdd PTX) + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) + cuda_archs_loose_intersection(MARLIN_BF16_ARCHS "8.0+PTX;9.0+PTX;12.0f" "${CUDA_ARCHS}") + else() + cuda_archs_loose_intersection(MARLIN_BF16_ARCHS "8.0+PTX;9.0+PTX;12.0a;12.1a" "${CUDA_ARCHS}") + endif() + # marlin arches for fp8 input + # - sm80 doesn't support fp8 computation + # - sm90 and sm100 don't support QMMA.16832.F32.E4M3.E4M3 SAAS instruction + # so we only enable fp8 computation for SM89 (e.g. RTX 40x0) and 12.0 (e.g. RTX 50x0) + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) + cuda_archs_loose_intersection(MARLIN_FP8_ARCHS "8.9;12.0f" "${CUDA_ARCHS}") + else() + cuda_archs_loose_intersection(MARLIN_FP8_ARCHS "8.9;12.0a;12.1a" "${CUDA_ARCHS}") + endif() + # marlin arches for other files + cuda_archs_loose_intersection(MARLIN_OTHER_ARCHS "7.5;8.0+PTX" "${CUDA_ARCHS}") + + if (MARLIN_OTHER_ARCHS) + + # + # For the Marlin kernels we automatically generate sources for various + # preselected input type pairs and schedules. + # Generate sources: + set(MARLIN_GEN_SCRIPT + ${CMAKE_CURRENT_SOURCE_DIR}/csrc/libtorch_stable/quantization/marlin/generate_kernels.py) + file(MD5 ${MARLIN_GEN_SCRIPT} MARLIN_GEN_SCRIPT_HASH) + list(JOIN CUDA_ARCHS "," CUDA_ARCHS_STR) + set(MARLIN_GEN_SCRIPT_HASH_AND_ARCH "${MARLIN_GEN_SCRIPT_HASH}(ARCH:${CUDA_ARCHS_STR})") + + message(STATUS "Marlin generation script hash: ${MARLIN_GEN_SCRIPT_HASH_AND_ARCH}") + message(STATUS "Last run Marlin generate script hash: $CACHE{MARLIN_GEN_SCRIPT_HASH_AND_ARCH}") + + if (NOT DEFINED CACHE{MARLIN_GEN_SCRIPT_HASH_AND_ARCH} + OR NOT $CACHE{MARLIN_GEN_SCRIPT_HASH_AND_ARCH} STREQUAL ${MARLIN_GEN_SCRIPT_HASH_AND_ARCH}) + execute_process( + COMMAND ${CMAKE_COMMAND} -E env + PYTHONPATH=$ENV{PYTHONPATH} + ${Python_EXECUTABLE} ${MARLIN_GEN_SCRIPT} ${CUDA_ARCHS_STR} + RESULT_VARIABLE marlin_generation_result + OUTPUT_VARIABLE marlin_generation_result + OUTPUT_FILE ${CMAKE_CURRENT_BINARY_DIR}/marlin_generation.log + ERROR_FILE ${CMAKE_CURRENT_BINARY_DIR}/marlin_generation.log + ) + + if (NOT marlin_generation_result EQUAL 0) + message(FATAL_ERROR "Marlin generation failed." + " Result: \"${marlin_generation_result}\"" + "\nCheck the log for details: " + "${CMAKE_CURRENT_BINARY_DIR}/marlin_generation.log") + else() + set(MARLIN_GEN_SCRIPT_HASH_AND_ARCH ${MARLIN_GEN_SCRIPT_HASH_AND_ARCH} + CACHE STRING "Last run Marlin generate script hash and arch" FORCE) + message(STATUS "Marlin generation completed successfully.") + endif() + else() + message(STATUS "Marlin generation script has not changed, skipping generation.") + endif() + + if (MARLIN_ARCHS) + file(GLOB MARLIN_TEMPLATE_KERNEL_SRC "csrc/libtorch_stable/quantization/marlin/sm80_kernel_*_float16.cu") + set_gencode_flags_for_srcs( + SRCS "${MARLIN_TEMPLATE_KERNEL_SRC}" + CUDA_ARCHS "${MARLIN_ARCHS}") + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) + set_source_files_properties(${MARLIN_TEMPLATE_KERNEL_SRC} + PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") + endif() + list(APPEND VLLM_STABLE_EXT_SRC ${MARLIN_TEMPLATE_KERNEL_SRC}) + + file(GLOB MARLIN_TEMPLATE_BF16_KERNEL_SRC "csrc/libtorch_stable/quantization/marlin/sm80_kernel_*_bfloat16.cu") + set_gencode_flags_for_srcs( + SRCS "${MARLIN_TEMPLATE_BF16_KERNEL_SRC}" + CUDA_ARCHS "${MARLIN_BF16_ARCHS}") + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) + set_source_files_properties(${MARLIN_TEMPLATE_BF16_KERNEL_SRC} + PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") + endif() + list(APPEND VLLM_STABLE_EXT_SRC ${MARLIN_TEMPLATE_BF16_KERNEL_SRC}) + endif() + + if (MARLIN_SM75_ARCHS) + file(GLOB MARLIN_TEMPLATE_SM75_KERNEL_SRC "csrc/libtorch_stable/quantization/marlin/sm75_kernel_*.cu") + set_gencode_flags_for_srcs( + SRCS "${MARLIN_TEMPLATE_SM75_KERNEL_SRC}" + CUDA_ARCHS "${MARLIN_SM75_ARCHS}") + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) + set_source_files_properties(${MARLIN_TEMPLATE_SM75_KERNEL_SRC} + PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") + endif() + list(APPEND VLLM_STABLE_EXT_SRC ${MARLIN_TEMPLATE_SM75_KERNEL_SRC}) + endif() + + if (MARLIN_FP8_ARCHS) + file(GLOB MARLIN_TEMPLATE_FP8_KERNEL_SRC "csrc/libtorch_stable/quantization/marlin/sm89_kernel_*.cu") + set_gencode_flags_for_srcs( + SRCS "${MARLIN_TEMPLATE_FP8_KERNEL_SRC}" + CUDA_ARCHS "${MARLIN_FP8_ARCHS}") + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) + set_source_files_properties(${MARLIN_TEMPLATE_FP8_KERNEL_SRC} + PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") + endif() + list(APPEND VLLM_STABLE_EXT_SRC ${MARLIN_TEMPLATE_FP8_KERNEL_SRC}) + endif() + + set(MARLIN_SRCS + "csrc/libtorch_stable/quantization/marlin/marlin.cu" + "csrc/libtorch_stable/quantization/marlin/marlin_int4_fp8_preprocess.cu" + "csrc/libtorch_stable/quantization/marlin/gptq_marlin_repack.cu" + "csrc/libtorch_stable/quantization/marlin/awq_marlin_repack.cu") + set_gencode_flags_for_srcs( + SRCS "${MARLIN_SRCS}" + CUDA_ARCHS "${MARLIN_OTHER_ARCHS}") + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) + set_source_files_properties(${MARLIN_SRCS} + PROPERTIES COMPILE_FLAGS "-static-global-template-stub=false") + endif() + list(APPEND VLLM_STABLE_EXT_SRC "${MARLIN_SRCS}") + + message(STATUS "Building Marlin kernels for archs: ${MARLIN_OTHER_ARCHS}") + else() + message(STATUS "Not building Marlin kernels as no compatible archs found" + " in CUDA target architectures") + endif() + # DeepSeek V3 fused A GEMM kernel (requires SM 9.0+, Hopper and later) if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(DSV3_FUSED_A_GEMM_ARCHS "9.0a;10.0f;11.0f" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(DSV3_FUSED_A_GEMM_ARCHS "9.0a;10.0f;11.0f;12.0f" "${CUDA_ARCHS}") else() - cuda_archs_loose_intersection(DSV3_FUSED_A_GEMM_ARCHS "9.0a;10.0a;10.1a;10.3a" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(DSV3_FUSED_A_GEMM_ARCHS "9.0a;10.0a;10.1a;10.3a;12.0a;12.1a" "${CUDA_ARCHS}") endif() if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND DSV3_FUSED_A_GEMM_ARCHS) - set(SRCS "csrc/libtorch_stable/dsv3_fused_a_gemm.cu") + set(DSV3_FUSED_A_GEMM_SRCS "csrc/libtorch_stable/dsv3_fused_a_gemm.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${DSV3_FUSED_A_GEMM_SRCS}" CUDA_ARCHS "${DSV3_FUSED_A_GEMM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${DSV3_FUSED_A_GEMM_SRCS}") message(STATUS "Building dsv3_fused_a_gemm for archs: ${DSV3_FUSED_A_GEMM_ARCHS}") else() message(STATUS "Not building dsv3_fused_a_gemm as no compatible archs found " @@ -689,13 +704,13 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # FP32 router GEMM (H=3072, E=256, M<=32). Requires SM90+ and CUDA >= 12.0. cuda_archs_sm90plus(FP32_ROUTER_GEMM_ARCHS "${CUDA_ARCHS}") if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND FP32_ROUTER_GEMM_ARCHS) - set(SRCS + set(FP32_ROUTER_GEMM_SRCS "csrc/libtorch_stable/fp32_router_gemm_entry.cu" "csrc/libtorch_stable/fp32_router_gemm.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${FP32_ROUTER_GEMM_SRCS}" CUDA_ARCHS "${FP32_ROUTER_GEMM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${FP32_ROUTER_GEMM_SRCS}") message(STATUS "Building fp32_router_gemm for archs: ${FP32_ROUTER_GEMM_ARCHS}") else() message(STATUS "Not building fp32_router_gemm as no compatible archs found " @@ -705,13 +720,13 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # Only build AllSpark kernels if we are building for at least some compatible archs. cuda_archs_loose_intersection(ALLSPARK_ARCHS "8.0;8.6;8.7;8.9" "${CUDA_ARCHS}") if (ALLSPARK_ARCHS) - set(SRCS + set(ALLSPARK_SRCS "csrc/libtorch_stable/quantization/gptq_allspark/allspark_repack.cu" "csrc/libtorch_stable/quantization/gptq_allspark/allspark_qgemm_w8a16.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${ALLSPARK_SRCS}" CUDA_ARCHS "${ALLSPARK_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${ALLSPARK_SRCS}") message(STATUS "Building AllSpark kernels for archs: ${ALLSPARK_ARCHS}") else() message(STATUS "Not building AllSpark kernels as no compatible archs found" @@ -726,16 +741,16 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # CUDA 12.0 or later cuda_archs_loose_intersection(SCALED_MM_ARCHS "9.0a;" "${CUDA_ARCHS}") if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND SCALED_MM_ARCHS) - set(SRCS + set(SCALED_MM_SM90_SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c3x_sm90.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_fp8.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_int8.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_azp_sm90_int8.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${SCALED_MM_SM90_SRCS}" CUDA_ARCHS "${SCALED_MM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${SCALED_MM_SM90_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_SCALED_MM_SM90=1") # Let scaled_mm_c2x know it doesn't need to build these arches list(APPEND SCALED_MM_3X_ARCHS "${SCALED_MM_ARCHS}") @@ -761,15 +776,15 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") cuda_archs_loose_intersection(SCALED_MM_ARCHS "12.0a;12.1a" "${CUDA_ARCHS}") endif() if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND SCALED_MM_ARCHS) - set(SRCS + set(SCALED_MM_SM120_SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c3x_sm120.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm120_fp8.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm120_fp8.cu" ) set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${SCALED_MM_SM120_SRCS}" CUDA_ARCHS "${SCALED_MM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${SCALED_MM_SM120_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_SCALED_MM_SM120=1") # Let scaled_mm_c2x know it doesn't need to build these arches list(APPEND SCALED_MM_3X_ARCHS "${SCALED_MM_ARCHS}") @@ -795,15 +810,15 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") cuda_archs_loose_intersection(SCALED_MM_ARCHS "10.0a;10.1a;10.3a" "${CUDA_ARCHS}") endif() if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND SCALED_MM_ARCHS) - set(SRCS + set(SCALED_MM_SM100_SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c3x_sm100.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm100_fp8.cu" "csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm100_fp8.cu" ) set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${SCALED_MM_SM100_SRCS}" CUDA_ARCHS "${SCALED_MM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${SCALED_MM_SM100_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_SCALED_MM_SM100=1") # Let scaled_mm_c2x know it doesn't need to build these arches list(APPEND SCALED_MM_3X_ARCHS "${SCALED_MM_ARCHS}") @@ -829,11 +844,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # subtract out the archs that are already built for 3x list(REMOVE_ITEM SCALED_MM_2X_ARCHS ${SCALED_MM_3X_ARCHS}) if (SCALED_MM_2X_ARCHS) - set(SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c2x.cu") + set(SCALED_MM_C2X_SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c2x.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${SCALED_MM_C2X_SRCS}" CUDA_ARCHS "${SCALED_MM_2X_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${SCALED_MM_C2X_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_SCALED_MM_C2X=1") message(STATUS "Building scaled_mm_c2x for archs: ${SCALED_MM_2X_ARCHS}") else() @@ -855,11 +870,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # if it's possible to compile MoE kernels that use its output. cuda_archs_loose_intersection(SCALED_MM_ARCHS "9.0a" "${CUDA_ARCHS}") if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.3 AND SCALED_MM_ARCHS) - set(SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x_sm90.cu") + set(CUTLASS_MOE_SM90_SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x_sm90.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${CUTLASS_MOE_SM90_SRCS}" CUDA_ARCHS "${SCALED_MM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${CUTLASS_MOE_SM90_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_CUTLASS_MOE_SM90=1") message(STATUS "Building grouped_mm_c3x for archs: ${SCALED_MM_ARCHS}") else() @@ -879,11 +894,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") cuda_archs_loose_intersection(SCALED_MM_ARCHS "10.0a;10.1a;10.3a" "${CUDA_ARCHS}") endif() if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND SCALED_MM_ARCHS) - set(SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x_sm100.cu") + set(CUTLASS_MOE_SM100_SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x_sm100.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${CUTLASS_MOE_SM100_SRCS}" CUDA_ARCHS "${SCALED_MM_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${CUTLASS_MOE_SM100_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_CUTLASS_MOE_SM100=1") message(STATUS "Building grouped_mm_c3x for archs: ${SCALED_MM_ARCHS}") else() @@ -904,11 +919,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") cuda_archs_loose_intersection(CUTLASS_MOE_DATA_ARCHS "9.0a;10.0a;10.1a;10.3a;12.0a;12.1a" "${CUDA_ARCHS}") endif() if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.3 AND CUTLASS_MOE_DATA_ARCHS) - set(SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/moe/moe_data.cu") + set(CUTLASS_MOE_DATA_SRCS "csrc/libtorch_stable/quantization/w8a8/cutlass/moe/moe_data.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${CUTLASS_MOE_DATA_SRCS}" CUDA_ARCHS "${CUTLASS_MOE_DATA_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${CUTLASS_MOE_DATA_SRCS}") message(STATUS "Building moe_data for archs: ${CUTLASS_MOE_DATA_ARCHS}") else() if (NOT ${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.3 AND CUTLASS_MOE_DATA_ARCHS) @@ -925,71 +940,64 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # FP4/NVFP4 kernels (moved from _C to _C_stable_libtorch) # - # The nvfp4_scaled_mm_sm120 kernels for Blackwell SM12x require - # CUDA 12.8 or later + # SM12x FP4 kernels. These share some generic NVFP4 quantization entry + # sources with the SM10x/11x block below; set_gencode_flags_for_srcs appends + # per-source flags, so shared files accumulate both SM12x and SM10x/11x + # gencodes when both families are requested. if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(FP4_ARCHS "12.0f" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(FP4_SM120_ARCHS "12.0f" "${CUDA_ARCHS}") else() - cuda_archs_loose_intersection(FP4_ARCHS "12.0a;12.1a" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(FP4_SM120_ARCHS "12.0a;12.1a" "${CUDA_ARCHS}") endif() - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND FP4_ARCHS) - set(SRCS + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND FP4_SM120_ARCHS) + set(FP4_SM120_SRCS "csrc/libtorch_stable/quantization/fp4/nvfp4_quant_kernels.cu" "csrc/libtorch_stable/quantization/fp4/activation_nvfp4_quant_fusion_kernels.cu" "csrc/libtorch_stable/quantization/fp4/nvfp4_experts_quant.cu" "csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_sm120_kernels.cu" - "csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu") - set_gencode_flags_for_srcs( - SRCS "${SRCS}" - CUDA_ARCHS "${FP4_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") - set(NVFP4_KV_SRC "csrc/libtorch_stable/nvfp4_kv_cache_kernels.cu") + "csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu" + "csrc/libtorch_stable/nvfp4_kv_cache_kernels.cu") set_gencode_flags_for_srcs( - SRCS "${NVFP4_KV_SRC}" - CUDA_ARCHS "${FP4_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${NVFP4_KV_SRC}") - target_compile_definitions(_C PRIVATE ENABLE_NVFP4_SM120=1) + SRCS "${FP4_SM120_SRCS}" + CUDA_ARCHS "${FP4_SM120_ARCHS}") + list(APPEND VLLM_STABLE_EXT_SRC "${FP4_SM120_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_NVFP4_SM120=1") list(APPEND VLLM_GPU_FLAGS "-DENABLE_CUTLASS_MOE_SM120=1") - message(STATUS "Building NVFP4 for archs: ${FP4_ARCHS}") + message(STATUS "Building SM12x NVFP4 for archs: ${FP4_SM120_ARCHS}") else() - message(STATUS "Not building NVFP4 as no compatible archs were found.") - # clear FP4_ARCHS - set(FP4_ARCHS) + message(STATUS "Not building SM12x NVFP4 as no compatible archs were found.") endif() - # FP4 Archs and flags + # SM10x/11x FP4 kernels. MXFP4 experts quantization is currently compiled + # only in this block; SM12x has separate NVFP4 matmul/MoE kernels above. if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(FP4_ARCHS "10.0f;11.0f" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(FP4_SM100_ARCHS "10.0f;11.0f" "${CUDA_ARCHS}") else() - cuda_archs_loose_intersection(FP4_ARCHS "10.0a;10.1a;10.3a" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(FP4_SM100_ARCHS "10.0a;10.1a;10.3a" "${CUDA_ARCHS}") endif() - if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND FP4_ARCHS) - set(SRCS + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND FP4_SM100_ARCHS) + set(FP4_SM100_SRCS "csrc/libtorch_stable/quantization/fp4/nvfp4_quant_kernels.cu" "csrc/libtorch_stable/quantization/fp4/activation_nvfp4_quant_fusion_kernels.cu" "csrc/libtorch_stable/quantization/fp4/nvfp4_experts_quant.cu" "csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_kernels.cu" "csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu" "csrc/libtorch_stable/quantization/fp4/mxfp4_experts_quant.cu" - "csrc/libtorch_stable/quantization/fp4/mxfp4_blockwise_moe_kernel.cu") - set_gencode_flags_for_srcs( - SRCS "${SRCS}" - CUDA_ARCHS "${FP4_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") - set(NVFP4_KV_SRC "csrc/libtorch_stable/nvfp4_kv_cache_kernels.cu") + "csrc/libtorch_stable/quantization/fp4/mxfp4_blockwise_moe_kernel.cu" + "csrc/libtorch_stable/nvfp4_kv_cache_kernels.cu") + if(NOT ${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.9) + message(STATUS + "Building mxfp4_experts_quant unsupported stubs because CUDA compiler version is not >= 12.9 (found ${CMAKE_CUDA_COMPILER_VERSION}).") + endif() set_gencode_flags_for_srcs( - SRCS "${NVFP4_KV_SRC}" - CUDA_ARCHS "${FP4_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${NVFP4_KV_SRC}") - target_compile_definitions(_C PRIVATE ENABLE_NVFP4_SM100=1) + SRCS "${FP4_SM100_SRCS}" + CUDA_ARCHS "${FP4_SM100_ARCHS}") + list(APPEND VLLM_STABLE_EXT_SRC "${FP4_SM100_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_NVFP4_SM100=1") list(APPEND VLLM_GPU_FLAGS "-DENABLE_CUTLASS_MOE_SM100=1") - message(STATUS "Building NVFP4 for archs: ${FP4_ARCHS}") + message(STATUS "Building SM10x/11x NVFP4/MXFP4 for archs: ${FP4_SM100_ARCHS}") else() - message(STATUS "Not building NVFP4 as no compatible archs were found.") - # clear FP4_ARCHS - set(FP4_ARCHS) + message(STATUS "Not building SM10x/11x NVFP4/MXFP4 as no compatible archs were found.") endif() # @@ -999,17 +1007,17 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # Only build W4A8 kernels if we are building for something compatible with sm90a cuda_archs_loose_intersection(W4A8_ARCHS "9.0a" "${CUDA_ARCHS}") if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND W4A8_ARCHS) - set(SRCS + set(W4A8_SRCS "csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_mm_entry.cu" "csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_grouped_mm_entry.cu" "csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_utils.cu" ) set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${W4A8_SRCS}" CUDA_ARCHS "${W4A8_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${W4A8_SRCS}") message(STATUS "Building W4A8 kernels for archs: ${W4A8_ARCHS}") else() @@ -1025,22 +1033,24 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") endif() endif() - # CUTLASS MLA Archs and flags + # CUTLASS MLA Archs and flags. + # Runtime dispatch is gated in + # vllm/v1/attention/backends/mla/cutlass_mla.py. if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(MLA_ARCHS "10.0f;11.0f;12.0f" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(MLA_ARCHS "10.0f;11.0f" "${CUDA_ARCHS}") else() - cuda_archs_loose_intersection(MLA_ARCHS "10.0a;10.1a;10.3a;12.0a;12.1a" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(MLA_ARCHS "10.0a;10.1a;10.3a" "${CUDA_ARCHS}") endif() if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND MLA_ARCHS) - set(SRCS + set(CUTLASS_MLA_SRCS "csrc/libtorch_stable/attention/mla/sm100_cutlass_mla_kernel.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${CUTLASS_MLA_SRCS}" CUDA_ARCHS "${MLA_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${CUTLASS_MLA_SRCS}") list(APPEND VLLM_GPU_FLAGS "-DENABLE_CUTLASS_MLA=1") # Add MLA-specific include directories only to MLA source files - set_source_files_properties(${SRCS} + set_source_files_properties(${CUTLASS_MLA_SRCS} PROPERTIES INCLUDE_DIRECTORIES "${CUTLASS_DIR}/examples/77_blackwell_fmha;${CUTLASS_DIR}/examples/common") message(STATUS "Building CUTLASS MLA for archs: ${MLA_ARCHS}") else() @@ -1052,11 +1062,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") # Hadacore kernels cuda_archs_loose_intersection(HADACORE_ARCHS "8.0+PTX;9.0+PTX" "${CUDA_ARCHS}") if(HADACORE_ARCHS) - set(SRCS "csrc/libtorch_stable/quantization/hadamard/hadacore/hadamard_transform_cuda.cu") + set(HADACORE_SRCS "csrc/libtorch_stable/quantization/hadamard/hadacore/hadamard_transform_cuda.cu") set_gencode_flags_for_srcs( - SRCS "${SRCS}" + SRCS "${HADACORE_SRCS}" CUDA_ARCHS "${HADACORE_ARCHS}") - list(APPEND VLLM_STABLE_EXT_SRC "${SRCS}") + list(APPEND VLLM_STABLE_EXT_SRC "${HADACORE_SRCS}") message(STATUS "Building hadacore") endif() @@ -1064,6 +1074,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") endif() message(STATUS "Enabling C_stable extension.") + list(REMOVE_DUPLICATES VLLM_STABLE_EXT_SRC) define_extension_target( _C_stable_libtorch DESTINATION vllm @@ -1076,15 +1087,19 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") WITH_SOABI) # Set TORCH_TARGET_VERSION for stable ABI compatibility. - # This ensures we only use C-shim APIs available in PyTorch 2.10. + # This ensures we only use C-shim APIs available in PyTorch 2.11. # _C_stable_libtorch is abi compatible with PyTorch >= TORCH_TARGET_VERSION - # which is currently set to 2.10. + # which is currently set to 2.11. target_compile_definitions(_C_stable_libtorch PRIVATE - TORCH_TARGET_VERSION=0x020A000000000000ULL) + TORCH_TARGET_VERSION=0x020B000000000000ULL) # Needed to use cuda/hip APIs from C-shim if(VLLM_GPU_LANG STREQUAL "CUDA") target_compile_definitions(_C_stable_libtorch PRIVATE USE_CUDA) + if(COOPERATIVE_TOPK_ARCHS) + target_compile_definitions(_C_stable_libtorch PRIVATE + VLLM_ENABLE_COOPERATIVE_TOPK=1) + endif() # Needed by CUTLASS kernels target_compile_definitions(_C_stable_libtorch PRIVATE CUTLASS_ENABLE_DIRECT_CUDA_DRIVER_CALL=1) @@ -1113,25 +1128,25 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP") endif() # -# _moe_C extension +# _moe_C_stable_libtorch extension # set(VLLM_MOE_EXT_SRC - "csrc/moe/torch_bindings.cpp" - "csrc/moe/moe_align_sum_kernels.cu" - "csrc/moe/topk_softmax_kernels.cu" - "csrc/moe/topk_softplus_sqrt_kernels.cu") + "csrc/libtorch_stable/moe/torch_bindings.cpp" + "csrc/libtorch_stable/moe/moe_align_sum_kernels.cu" + "csrc/libtorch_stable/moe/topk_softmax_kernels.cu" + "csrc/libtorch_stable/moe/topk_softplus_sqrt_kernels.cu") if(VLLM_GPU_LANG STREQUAL "CUDA") list(APPEND VLLM_MOE_EXT_SRC - "csrc/moe/moe_wna16.cu" - "csrc/moe/grouped_topk_kernels.cu") + "csrc/libtorch_stable/moe/moe_wna16.cu" + "csrc/libtorch_stable/moe/grouped_topk_kernels.cu") endif() if(VLLM_GPU_LANG STREQUAL "CUDA") set(MOE_PERMUTE_SRC - "csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu" - "csrc/moe/moe_permute_unpermute_op.cu") + "csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu" + "csrc/libtorch_stable/moe/moe_permute_unpermute_op.cu") list(APPEND VLLM_MOE_EXT_SRC "${MOE_PERMUTE_SRC}") endif() @@ -1142,7 +1157,7 @@ set_gencode_flags_for_srcs( if(VLLM_GPU_LANG STREQUAL "CUDA") set(VLLM_MOE_WNA16_SRC - "csrc/moe/moe_wna16.cu") + "csrc/libtorch_stable/moe/moe_wna16.cu") set_gencode_flags_for_srcs( SRCS "${VLLM_MOE_WNA16_SRC}" @@ -1163,7 +1178,11 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") # - sm80 doesn't support fp8 computation # - sm90 and sm100 don't support QMMA.16832.F32.E4M3.E4M3 SAAS instruction # so we only enable fp8 computation for SM89 (e.g. RTX 40x0) and 12.0 (e.g. RTX 50x0) - cuda_archs_loose_intersection(MARLIN_MOE_FP8_ARCHS "8.9;12.0;12.1" "${CUDA_ARCHS}") + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) + cuda_archs_loose_intersection(MARLIN_MOE_FP8_ARCHS "8.9;12.0f" "${CUDA_ARCHS}") + else() + cuda_archs_loose_intersection(MARLIN_MOE_FP8_ARCHS "8.9;12.0a;12.1a" "${CUDA_ARCHS}") + endif() # moe marlin arches for other files cuda_archs_loose_intersection(MARLIN_MOE_OTHER_ARCHS "7.5;8.0+PTX" "${CUDA_ARCHS}") if (MARLIN_MOE_OTHER_ARCHS) @@ -1173,7 +1192,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") # preselected input type pairs and schedules. # Generate sources: set(MOE_MARLIN_GEN_SCRIPT - ${CMAKE_CURRENT_SOURCE_DIR}/csrc/moe/marlin_moe_wna16/generate_kernels.py) + ${CMAKE_CURRENT_SOURCE_DIR}/csrc/libtorch_stable/moe/marlin_moe_wna16/generate_kernels.py) file(MD5 ${MOE_MARLIN_GEN_SCRIPT} MOE_MARLIN_GEN_SCRIPT_HASH) list(JOIN CUDA_ARCHS "," CUDA_ARCHS_STR) set(MOE_MARLIN_GEN_SCRIPT_HASH_AND_ARCH "${MOE_MARLIN_GEN_SCRIPT_HASH}(ARCH:${CUDA_ARCHS_STR})") @@ -1208,7 +1227,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") endif() if (MARLIN_MOE_ARCHS) - file(GLOB MARLIN_MOE_SRC "csrc/moe/marlin_moe_wna16/sm80_kernel_*.cu") + file(GLOB MARLIN_MOE_SRC "csrc/libtorch_stable/moe/marlin_moe_wna16/sm80_kernel_*.cu") set_gencode_flags_for_srcs( SRCS "${MARLIN_MOE_SRC}" CUDA_ARCHS "${MARLIN_MOE_ARCHS}") @@ -1220,7 +1239,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") endif() if (MARLIN_MOE_SM75_ARCHS) - file(GLOB MARLIN_MOE_SM75_SRC "csrc/moe/marlin_moe_wna16/sm75_kernel_*.cu") + file(GLOB MARLIN_MOE_SM75_SRC "csrc/libtorch_stable/moe/marlin_moe_wna16/sm75_kernel_*.cu") set_gencode_flags_for_srcs( SRCS "${MARLIN_MOE_SM75_SRC}" CUDA_ARCHS "${MARLIN_MOE_SM75_ARCHS}") @@ -1232,7 +1251,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") endif() if (MARLIN_MOE_FP8_ARCHS) - file(GLOB MARLIN_MOE_FP8_SRC "csrc/moe/marlin_moe_wna16/sm89_kernel_*.cu") + file(GLOB MARLIN_MOE_FP8_SRC "csrc/libtorch_stable/moe/marlin_moe_wna16/sm89_kernel_*.cu") set_gencode_flags_for_srcs( SRCS "${MARLIN_MOE_FP8_SRC}" CUDA_ARCHS "${MARLIN_MOE_FP8_ARCHS}") @@ -1243,7 +1262,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") list(APPEND VLLM_MOE_EXT_SRC ${MARLIN_MOE_FP8_SRC}) endif() - set(MARLIN_MOE_OTHER_SRC "csrc/moe/marlin_moe_wna16/ops.cu") + set(MARLIN_MOE_OTHER_SRC "csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu") set_gencode_flags_for_srcs( SRCS "${MARLIN_MOE_OTHER_SRC}" CUDA_ARCHS "${MARLIN_MOE_OTHER_ARCHS}") @@ -1264,9 +1283,9 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") cuda_archs_sm90plus(SM90PLUS_ROUTER_GEMM_ARCHS "${CUDA_ARCHS}") if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.0 AND SM90PLUS_ROUTER_GEMM_ARCHS) set(DSV3_ROUTER_GEMM_SRC - "csrc/moe/dsv3_router_gemm_entry.cu" - "csrc/moe/dsv3_router_gemm_float_out.cu" - "csrc/moe/dsv3_router_gemm_bf16_out.cu") + "csrc/libtorch_stable/moe/dsv3_router_gemm_entry.cu" + "csrc/libtorch_stable/moe/dsv3_router_gemm_float_out.cu" + "csrc/libtorch_stable/moe/dsv3_router_gemm_bf16_out.cu") set_gencode_flags_for_srcs( SRCS "${DSV3_ROUTER_GEMM_SRC}" CUDA_ARCHS "${SM90PLUS_ROUTER_GEMM_ARCHS}") @@ -1279,9 +1298,9 @@ if(VLLM_GPU_LANG STREQUAL "CUDA") endif() endif() -message(STATUS "Enabling moe extension.") +message(STATUS "Enabling MoE C_stable extension.") define_extension_target( - _moe_C + _moe_C_stable_libtorch DESTINATION vllm LANGUAGE ${VLLM_GPU_LANG} SOURCES ${VLLM_MOE_EXT_SRC} @@ -1292,6 +1311,42 @@ define_extension_target( USE_SABI 3 WITH_SOABI) +# Set TORCH_TARGET_VERSION for stable ABI compatibility. +# This ensures we only use C-shim APIs available in PyTorch 2.11. +# _moe_C_stable_libtorch is abi compatible with PyTorch >= TORCH_TARGET_VERSION +# which is currently set to 2.11. +target_compile_definitions(_moe_C_stable_libtorch PRIVATE + TORCH_TARGET_VERSION=0x020B000000000000ULL) + +# Needed to use cuda/hip APIs from C-shim +if(VLLM_GPU_LANG STREQUAL "CUDA") + target_compile_definitions(_moe_C_stable_libtorch PRIVATE USE_CUDA) + # Needed by CUTLASS kernels + target_compile_definitions(_moe_C_stable_libtorch PRIVATE + CUTLASS_ENABLE_DIRECT_CUDA_DRIVER_CALL=1) +elseif(VLLM_GPU_LANG STREQUAL "HIP") + target_compile_definitions(_moe_C_stable_libtorch PRIVATE USE_ROCM) +endif() + +# On ROCm, _moe_C_stable_libtorch calls raw HIP APIs (e.g. hipGetDevice in +# get_device_prop()) which must resolve to the same libamdhip64.so that +# PyTorch uses. When PyTorch bundles its own copy (pip/conda wheels), +# the raw HIP calls would otherwise resolve to the system ROCm copy, +# initializing a second HIP runtime that corrupts device state (wrong +# device on DeviceGuard, core dumps on multi-GPU tests). +# +# If PyTorch doesn't bundle libamdhip64 (built from source against system +# ROCm), there is only one copy in the process and no action is needed — +# the HIP compiler already links the system libamdhip64 automatically. +if(VLLM_GPU_LANG STREQUAL "HIP") + find_library(_MOE_STABLE_TORCH_AMDHIP64 amdhip64 + PATHS "${TORCH_INSTALL_PREFIX}/lib" NO_DEFAULT_PATH) + if(_MOE_STABLE_TORCH_AMDHIP64) + message(STATUS "Found PyTorch-bundled libamdhip64 for _moe_C_stable_libtorch at ${_MOE_STABLE_TORCH_AMDHIP64}") + target_link_libraries(_moe_C_stable_libtorch PRIVATE ${_MOE_STABLE_TORCH_AMDHIP64}) + endif() +endif() + if(VLLM_GPU_LANG STREQUAL "HIP") # # _rocm_C extension @@ -1306,7 +1361,8 @@ if(VLLM_GPU_LANG STREQUAL "HIP") set(VLLM_ROCM_HAS_GFX1100 ON) list(APPEND VLLM_ROCM_EXT_SRC "csrc/rocm/q_gemm_rdna3.cu" - "csrc/rocm/q_gemm_rdna3_wmma.cu") + "csrc/rocm/q_gemm_rdna3_wmma.cu" + "csrc/rocm/moe_q_gemm_rdna3.cu") endif() define_extension_target( @@ -1338,6 +1394,7 @@ endif() # For CUDA we also build and ship some external projects. if (VLLM_GPU_LANG STREQUAL "CUDA") include(cmake/external_projects/deepgemm.cmake) + include(cmake/external_projects/fmha_sm100.cmake) include(cmake/external_projects/flashmla.cmake) include(cmake/external_projects/qutlass.cmake) diff --git a/MANIFEST.in b/MANIFEST.in index fb3cccbb4a9c..cbb136e6b76c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include requirements/cuda.txt include requirements/rocm.txt include requirements/cpu.txt include CMakeLists.txt +include tools/build_rust.py recursive-include cmake * recursive-include csrc * diff --git a/SECURITY.md b/SECURITY.md index d6319cdb1ac2..1e2a5a0adefb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,15 @@ Vulnerabilities that cause denial of service or partial disruption, but do not a Minor issues such as informational disclosures, logging errors, non-exploitable flaws, or weaknesses that require local or high-privilege access and offer negligible impact. Examples include side channel attacks or hash collisions. These issues often have CVSS scores less than 4.0 +## Fix disclosure policy + +When a security report is accepted, the fix process depends on the severity: + +* **CRITICAL and HIGH severity**: Fixes are developed in a private security fork and coordinated with the prenotification group before public disclosure. +* **MODERATE and LOW severity**: Fixes are developed and submitted as public pull requests. These issues do not require embargo since they do not enable arbitrary code execution or significant data breach, and public visibility accelerates community review and adoption of the fix. + +The vulnerability management team reserves the right to adjust the disclosure approach on a case-by-case basis, taking into account factors such as active exploitation, unusual attack surface, or coordination requirements with downstream vendors. + ## Prenotification policy For certain security issues of CRITICAL, HIGH, or MODERATE severity level, we may prenotify certain organizations or vendors that ship vLLM. The purpose of this prenotification is to allow for a coordinated release of fixes for severe issues. diff --git a/benchmarks/attention_benchmarks/README.md b/benchmarks/attention_benchmarks/README.md index afce34433167..944ceb91af91 100644 --- a/benchmarks/attention_benchmarks/README.md +++ b/benchmarks/attention_benchmarks/README.md @@ -108,7 +108,6 @@ python benchmark.py \ --backends flash triton flashinfer \ --batch-specs "q2k" "8q1s1k" "2q2k_32q1s1k" \ --num-layers 10 \ - --repeats 5 \ --output-csv results.csv ``` @@ -164,14 +163,17 @@ python benchmark.py \ # Model configuration --num-layers N # Number of layers --head-dim N # Head dimension +--v-head-dim N # Value head dimension (defaults to --head-dim) --num-q-heads N # Query heads --num-kv-heads N # KV heads --block-size N # Block size +--kv-lora-rank N # MLA KV LoRA rank +--qk-nope-head-dim N # MLA non-RoPE QK head dim +--qk-rope-head-dim N # MLA RoPE QK head dim # Benchmark settings --device DEVICE # Device (default: cuda:0) ---repeats N # Repetitions ---warmup-iters N # Warmup iterations +--warmup-ms N # Warmup window in ms for triton do_bench --profile-memory # Profile memory usage # Parameter sweeps @@ -211,8 +213,6 @@ config = BenchmarkConfig( num_kv_heads=1, block_size=128, device="cuda:0", - repeats=5, - warmup_iters=3, ) # CUTLASS MLA with specific num_kv_splits @@ -253,14 +253,10 @@ formatter.save_json(results, "output.json") ## Tips -**1. Warmup matters** - Use `--warmup-iters 10` for stable results +**1. Save results** - Always use `--output-csv` or `--output-json` -**2. Multiple repeats** - Use `--repeats 20` for low variance +**2. Test incrementally** - Start with `--num-layers 1` -**3. Save results** - Always use `--output-csv` or `--output-json` +**3. Extended grammar** - Leverage spec decode, chunked prefill patterns -**4. Test incrementally** - Start with `--num-layers 1 --repeats 1` - -**5. Extended grammar** - Leverage spec decode, chunked prefill patterns - -**6. Parameter sweeps** - Use `--sweep-param` and `--sweep-values` to find optimal values +**4. Parameter sweeps** - Use `--sweep-param` and `--sweep-values` to find optimal values diff --git a/benchmarks/attention_benchmarks/benchmark.py b/benchmarks/attention_benchmarks/benchmark.py index c4c331f7f8ef..9860d4b2d1c2 100644 --- a/benchmarks/attention_benchmarks/benchmark.py +++ b/benchmarks/attention_benchmarks/benchmark.py @@ -26,6 +26,9 @@ """ import argparse +import os +import shutil +import subprocess import sys from dataclasses import replace from pathlib import Path @@ -50,6 +53,16 @@ from vllm.v1.worker.workspace import init_workspace_manager +def _str2bool(v) -> bool: + if isinstance(v, bool): + return v + if v.lower() in ("true", "1", "yes", "t"): + return True + if v.lower() in ("false", "0", "no", "f"): + return False + raise argparse.ArgumentTypeError(f"expected a boolean, got {v!r}") + + def run_standard_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult: """Run standard attention benchmark (Flash/Triton/FlashInfer).""" from runner import run_attention_benchmark @@ -83,13 +96,15 @@ def run_benchmark(config: BenchmarkConfig, **kwargs) -> BenchmarkResult: else: return run_standard_attention_benchmark(config) except Exception as e: + error_msg = str(e) or repr(e) return BenchmarkResult( config=config, mean_time=float("inf"), + median_time=float("inf"), std_time=0, min_time=float("inf"), max_time=float("inf"), - error=str(e), + error=error_msg, ) @@ -115,9 +130,12 @@ def run_model_parameter_sweep( """ all_results = [] - console.print( - f"[yellow]Model sweep mode: testing {sweep.param_name} = {sweep.values}[/]" + sweep_desc = ( + f"{sweep.param_name} = {sweep.values}" + if sweep.param_name + else f"{len(sweep.values)} configurations" ) + console.print(f"[yellow]Model sweep mode: testing {sweep_desc}[/]") total = len(backends) * len(batch_specs) * len(sweep.values) @@ -125,9 +143,9 @@ def run_model_parameter_sweep( for backend in backends: for spec in batch_specs: for value in sweep.values: - # Create config with modified model parameter + # Create config with modified model parameter(s) config_args = base_config_args.copy() - config_args[sweep.param_name] = value + sweep.apply(config_args, value) # Create config with original backend for running clean_config = BenchmarkConfig( @@ -144,13 +162,21 @@ def run_model_parameter_sweep( all_results.append(result) if not result.success: + err_label = ( + f"{sweep.param_name}={value}" + if sweep.param_name + else f"{value}" + ) console.print( - f"[red]Error {backend} {spec} {sweep.param_name}=" - f"{value}: {result.error}[/]" + f"[red]Error {backend} {spec} {err_label}" + f": {result.error}[/]" ) pbar.update(1) + if base_config_args.get("ncu_profile"): + return all_results + # Display sweep results - create separate table for each parameter value console.print("\n[bold green]Model Parameter Sweep Results:[/]") formatter = ResultsFormatter(console) @@ -184,7 +210,10 @@ def run_model_parameter_sweep( ) for param_value in sorted_param_values: - console.print(f"\n[bold cyan]{sweep.param_name} = {param_value}[/]") + label = ( + f"{sweep.param_name} = {param_value}" if sweep.param_name else param_value + ) + console.print(f"\n[bold cyan]{label}[/]") param_results = by_param_value[param_value] # Create modified results with original backend names @@ -200,8 +229,9 @@ def run_model_parameter_sweep( formatter.print_table(modified_results, backends, compare_to_fastest=True) # Show optimal backend for each (param_value, batch_spec) combination + sweep_name = sweep.param_name or "config" console.print( - f"\n[bold cyan]Optimal backend for each ({sweep.param_name}, batch_spec):[/]" + f"\n[bold cyan]Optimal backend for each ({sweep_name}, batch_spec):[/]" ) # Group by (param_value, batch_spec) @@ -236,7 +266,10 @@ def run_model_parameter_sweep( for param_value, spec in sorted_keys: # Print header when param value changes if param_value != current_param_value: - console.print(f"\n [bold]{sweep.param_name}={param_value}:[/]") + header = ( + f"{sweep.param_name}={param_value}" if sweep.param_name else param_value + ) + console.print(f"\n [bold]{header}:[/]") current_param_value = param_value results = by_param_and_spec[(param_value, spec)] @@ -322,6 +355,9 @@ def run_parameter_sweep( pbar.update(1) + if base_config_args.get("ncu_profile"): + return all_results + # Display sweep results console.print("\n[bold green]Sweep Results:[/]") backend_labels = [sweep.get_label(b, v) for b in backends for v in sweep_values] @@ -459,6 +495,20 @@ def main(): help="Prefill backends to compare (fa2, fa3, fa4). " "Uses the first decode backend for impl construction.", ) + parser.add_argument( + "--fp8-output-scale", + type=float, + help="Static per-tensor scale enabling the MLA prefill FP8-output " + "comparison on FA4 (fused write vs standalone post-quant).", + ) + parser.add_argument( + "--fuse-quant-op", + nargs="+", + type=_str2bool, + help="FP8-output write path(s) to run: false = bf16 attention + " + "standalone static-FP8 quant, true = FA4 writes FP8 directly. " + "Default: both.", + ) # Batch specifications parser.add_argument( @@ -474,11 +524,35 @@ def main(): parser.add_argument("--num-q-heads", type=int, default=32, help="Query heads") parser.add_argument("--num-kv-heads", type=int, default=8, help="KV heads") parser.add_argument("--block-size", type=int, default=16, help="Block size") + parser.add_argument( + "--v-head-dim", + type=int, + default=None, + help="Value head dimension (defaults to --head-dim if unset)", + ) + + # MLA-specific model dimensions + parser.add_argument( + "--kv-lora-rank", type=int, default=None, help="MLA KV LoRA rank" + ) + parser.add_argument( + "--qk-nope-head-dim", type=int, default=None, help="MLA non-RoPE QK head dim" + ) + parser.add_argument( + "--qk-rope-head-dim", type=int, default=None, help="MLA RoPE QK head dim" + ) # Benchmark settings parser.add_argument("--device", default="cuda:0", help="Device") - parser.add_argument("--repeats", type=int, default=1, help="Repetitions") - parser.add_argument("--warmup-iters", type=int, default=3, help="Warmup iterations") + parser.add_argument( + "--warmup-ms", + type=int, + default=None, + help=( + "Warmup window in ms for triton's do_bench (default: triton's own). " + "Has no effect with CUDA graphs; pass --no-cuda-graphs to use it." + ), + ) parser.add_argument("--profile-memory", action="store_true", help="Profile memory") parser.add_argument( "--kv-cache-dtype", @@ -491,10 +565,33 @@ def main(): action=argparse.BooleanOptionalAction, default=True, help=( - "Launch kernels with CUDA graphs to eliminate CPU overhead" - "in measurements (default: True)" + "Use triton do_bench_cudagraph (True) or do_bench (False) " + "for timing. CUDA graphs eliminate CPU launch overhead " + "(default: True)" + ), + ) + parser.add_argument( + "--num-splits", + type=int, + default=None, + help="FlashAttention split-K factor (0=auto heuristic, 1=disabled, >1=force N)", + ) + parser.add_argument( + "--ncu-profile", + action="store_true", + default=False, + help=( + "Enable Nsight Compute profiling mode. Automatically wraps the " + "script with ncu, capturing a profile with source correlation. " + "Use --ncu-output to set the output file name." ), ) + parser.add_argument( + "--ncu-output", + type=str, + default="profile", + help="Output file name for ncu profile (default: 'profile').", + ) # Parameter sweep (use YAML config for advanced sweeps) parser.add_argument( @@ -545,6 +642,12 @@ def main(): # Prefill backends (e.g., ["fa3", "fa4"]) args.prefill_backends = yaml_config.get("prefill_backends", None) + # FP8 output benchmark knobs; CLI wins. + if args.fp8_output_scale is None: + args.fp8_output_scale = yaml_config.get("fp8_output_scale", None) + if args.fuse_quant_op is None: + args.fuse_quant_op = yaml_config.get("fuse_quant_op", None) + # Check for special modes args.mode = yaml_config.get("mode", None) @@ -576,23 +679,28 @@ def main(): model = yaml_config["model"] args.num_layers = model.get("num_layers", args.num_layers) args.head_dim = model.get("head_dim", args.head_dim) + args.v_head_dim = model.get("v_head_dim", args.v_head_dim) args.num_q_heads = model.get("num_q_heads", args.num_q_heads) args.num_kv_heads = model.get("num_kv_heads", args.num_kv_heads) args.block_size = model.get("block_size", args.block_size) + # MLA-specific dimensions + args.kv_lora_rank = model.get("kv_lora_rank", args.kv_lora_rank) + args.qk_nope_head_dim = model.get("qk_nope_head_dim", args.qk_nope_head_dim) + args.qk_rope_head_dim = model.get("qk_rope_head_dim", args.qk_rope_head_dim) # Benchmark settings (top-level keys) if "device" in yaml_config: args.device = yaml_config["device"] - if "repeats" in yaml_config: - args.repeats = yaml_config["repeats"] - if "warmup_iters" in yaml_config: - args.warmup_iters = yaml_config["warmup_iters"] + if "warmup_ms" in yaml_config: + args.warmup_ms = yaml_config["warmup_ms"] if "profile_memory" in yaml_config: args.profile_memory = yaml_config["profile_memory"] if "kv_cache_dtype" in yaml_config: args.kv_cache_dtype = yaml_config["kv_cache_dtype"] if "cuda_graphs" in yaml_config: args.cuda_graphs = yaml_config["cuda_graphs"] + if "ncu_profile" in yaml_config: + args.ncu_profile = yaml_config["ncu_profile"] # Parameter sweep configuration if "parameter_sweep" in yaml_config: @@ -612,7 +720,7 @@ def main(): if "model_parameter_sweep" in yaml_config: sweep_config = yaml_config["model_parameter_sweep"] args.model_parameter_sweep = ModelParameterSweep( - param_name=sweep_config["param_name"], + param_name=sweep_config.get("param_name"), values=sweep_config["values"], label_format=sweep_config.get( "label_format", "{backend}_{param_name}_{value}" @@ -631,6 +739,32 @@ def main(): console.print() + # Re-exec under ncu if --ncu-profile and not already inside ncu. This runs + # after YAML processing so ncu_profile set via config file is honored. + if args.ncu_profile and "_NCU_INNER" not in os.environ: + ncu = shutil.which("ncu") + if ncu is None: + print("Error: 'ncu' not found in PATH", file=sys.stderr) + sys.exit(1) + cmd = [ + ncu, + "--profile-from-start", + "off", + "--set", + "full", + "--import-source", + "yes", + "-o", + args.ncu_output, + sys.executable, + *sys.argv, + ] + env = os.environ.copy() + env["CUTE_DSL_LINEINFO"] = "1" + env["_NCU_INNER"] = "1" + print(f"Launching: {' '.join(cmd)}") + sys.exit(subprocess.call(cmd, env=env)) + # Handle CLI-based parameter sweep (if not from YAML) if ( (not hasattr(args, "parameter_sweep") or args.parameter_sweep is None) @@ -655,6 +789,18 @@ def main(): console.print(f"Batch specs: {', '.join(args.batch_specs)}") console.print(f"KV cache dtype: {args.kv_cache_dtype}") console.print(f"CUDA graphs: {args.cuda_graphs}") + if args.warmup_ms is not None and args.cuda_graphs: + console.print( + "[yellow]Warning: --warmup-ms is ignored with CUDA graphs " + "(do_bench_cudagraph warms up internally). Pass --no-cuda-graphs " + "to use it.[/]" + ) + if args.num_splits == 0 and args.cuda_graphs: + console.print( + "[yellow]Warning: --num-splits 0 (FA3 heuristic) is not CUDA-graph " + "compatible and may fail or fall back. Pass --no-cuda-graphs or use " + "--num-splits >=1.[/]" + ) console.print() init_workspace_manager(args.device) @@ -662,8 +808,68 @@ def main(): # Run benchmarks all_results = [] + # Under ncu profiling the kernels run only to be captured by the profiler; + # timings are placeholder zeros, so the result tables and saved metrics are + # skipped. The Nsight Compute report (--ncu-output) holds the real data. + if args.ncu_profile: + console.print( + "[dim]ncu profiling enabled: result tables and saved metrics are " + "skipped (timings are placeholder zeros).[/]" + ) + + # FA4 fused FP8 output vs standalone post-quant, on the same fa4 kernel: + # the delta is the post-quant kernel the fused path removes. + fp8_output_scale = getattr(args, "fp8_output_scale", None) + if fp8_output_scale is not None: + decode_backend = backends[0] + fuse_variants = args.fuse_quant_op or [False, True] + label_of = {False: "post_quant", True: "fused"} + console.print( + f"[yellow]FP8 output comparison @ scale={fp8_output_scale} " + f"(prefill=fa4, decode impl={decode_backend})[/]" + ) + fp8_results = [] + total = len(fuse_variants) * len(args.batch_specs) + with tqdm(total=total, desc="FP8 output benchmarking") as pbar: + for spec in args.batch_specs: + for fuse in fuse_variants: + config = BenchmarkConfig( + backend=decode_backend, + batch_spec=spec, + num_layers=args.num_layers, + head_dim=args.head_dim, + num_q_heads=args.num_q_heads, + num_kv_heads=args.num_kv_heads, + block_size=args.block_size, + device=args.device, + repeats=args.repeats, + warmup_iters=args.warmup_iters, + profile_memory=args.profile_memory, + kv_cache_dtype=args.kv_cache_dtype, + use_cuda_graphs=args.cuda_graphs, + prefill_backend="fa4", + ) + result = run_benchmark( + config, output_scale=fp8_output_scale, fuse_quant_op=fuse + ) + label = label_of[fuse] + labeled_config = replace(result.config, backend=label) + result = replace(result, config=labeled_config) + fp8_results.append(result) + + if not result.success: + console.print(f"[red]Error {label} {spec}: {result.error}[/]") + + pbar.update(1) + + console.print("\n[bold green]FP8 Output Results:[/]") + formatter = ResultsFormatter(console) + labels = [label_of[f] for f in fuse_variants] + formatter.print_table(fp8_results, labels, compare_to_fastest=True) + all_results = fp8_results + # Handle special mode: decode_vs_prefill comparison - if hasattr(args, "mode") and args.mode == "decode_vs_prefill": + elif hasattr(args, "mode") and args.mode == "decode_vs_prefill": console.print("[yellow]Mode: Decode vs Prefill pipeline comparison[/]") console.print( "[dim]For each query length, testing both decode and prefill pipelines[/]" @@ -708,11 +914,11 @@ def main(): num_kv_heads=args.num_kv_heads, block_size=args.block_size, device=args.device, - repeats=args.repeats, - warmup_iters=args.warmup_iters, profile_memory=args.profile_memory, kv_cache_dtype=args.kv_cache_dtype, use_cuda_graphs=args.cuda_graphs, + ncu_profile=args.ncu_profile, + warmup_ms=args.warmup_ms, ) # Add decode pipeline config @@ -749,6 +955,7 @@ def main(): result = BenchmarkResult( config=config, mean_time=timing["mean"], + median_time=timing.get("median", timing["mean"]), std_time=timing["std"], min_time=timing["min"], max_time=timing["max"], @@ -770,6 +977,7 @@ def main(): result = BenchmarkResult( config=config, mean_time=float("inf"), + median_time=float("inf"), std_time=0, min_time=float("inf"), max_time=float("inf"), @@ -779,6 +987,9 @@ def main(): pbar.update(1) + if args.ncu_profile: + return + # Display decode vs prefill results console.print("\n[bold green]Decode vs Prefill Results:[/]") @@ -858,15 +1069,20 @@ def main(): base_config_args = { "num_layers": args.num_layers, "head_dim": args.head_dim, + "v_head_dim": args.v_head_dim, "num_q_heads": args.num_q_heads, "num_kv_heads": args.num_kv_heads, "block_size": args.block_size, "device": args.device, - "repeats": args.repeats, - "warmup_iters": args.warmup_iters, "profile_memory": args.profile_memory, "kv_cache_dtype": args.kv_cache_dtype, "use_cuda_graphs": args.cuda_graphs, + "ncu_profile": args.ncu_profile, + "warmup_ms": args.warmup_ms, + "num_splits": args.num_splits, + "kv_lora_rank": args.kv_lora_rank, + "qk_nope_head_dim": args.qk_nope_head_dim, + "qk_rope_head_dim": args.qk_rope_head_dim, } all_results = run_model_parameter_sweep( backends, @@ -882,15 +1098,17 @@ def main(): base_config_args = { "num_layers": args.num_layers, "head_dim": args.head_dim, + "v_head_dim": args.v_head_dim, "num_q_heads": args.num_q_heads, "num_kv_heads": args.num_kv_heads, "block_size": args.block_size, "device": args.device, - "repeats": args.repeats, - "warmup_iters": args.warmup_iters, "profile_memory": args.profile_memory, "kv_cache_dtype": args.kv_cache_dtype, "use_cuda_graphs": args.cuda_graphs, + "ncu_profile": args.ncu_profile, + "warmup_ms": args.warmup_ms, + "num_splits": args.num_splits, } all_results = run_parameter_sweep( backends, args.batch_specs, base_config_args, args.parameter_sweep, console @@ -914,15 +1132,17 @@ def main(): batch_spec=spec, num_layers=args.num_layers, head_dim=args.head_dim, + v_head_dim=getattr(args, "v_head_dim", None), num_q_heads=args.num_q_heads, num_kv_heads=args.num_kv_heads, block_size=args.block_size, device=args.device, - repeats=args.repeats, - warmup_iters=args.warmup_iters, profile_memory=args.profile_memory, kv_cache_dtype=args.kv_cache_dtype, use_cuda_graphs=args.cuda_graphs, + ncu_profile=args.ncu_profile, + warmup_ms=args.warmup_ms, + num_splits=args.num_splits, ) result = run_benchmark(config) @@ -935,9 +1155,10 @@ def main(): pbar.update(1) - console.print("\n[bold green]Results:[/]") - formatter = ResultsFormatter(console) - formatter.print_table(decode_results, backends) + if not args.ncu_profile: + console.print("\n[bold green]Results:[/]") + formatter = ResultsFormatter(console) + formatter.print_table(decode_results, backends) # Run prefill backend comparison if prefill_backends: @@ -962,9 +1183,8 @@ def main(): num_kv_heads=args.num_kv_heads, block_size=args.block_size, device=args.device, - repeats=args.repeats, - warmup_iters=args.warmup_iters, profile_memory=args.profile_memory, + warmup_ms=args.warmup_ms, prefill_backend=pb, ) @@ -980,16 +1200,17 @@ def main(): pbar.update(1) - console.print("\n[bold green]Prefill Backend Results:[/]") - formatter = ResultsFormatter(console) - formatter.print_table( - prefill_results, prefill_backends, compare_to_fastest=True - ) + if not args.ncu_profile: + console.print("\n[bold green]Prefill Backend Results:[/]") + formatter = ResultsFormatter(console) + formatter.print_table( + prefill_results, prefill_backends, compare_to_fastest=True + ) all_results = decode_results + prefill_results - # Save results - if all_results: + # Save results (skip ncu profiling runs: timings are placeholder zeros) + if all_results and not args.ncu_profile: formatter = ResultsFormatter(console) if args.output_csv: formatter.save_csv(all_results, args.output_csv) diff --git a/benchmarks/attention_benchmarks/common.py b/benchmarks/attention_benchmarks/common.py index 74d9e239725d..106d7854804f 100644 --- a/benchmarks/attention_benchmarks/common.py +++ b/benchmarks/attention_benchmarks/common.py @@ -15,6 +15,8 @@ from rich.console import Console from rich.table import Table +from vllm.triton_utils import triton + def batch_spec_sort_key(spec: str) -> tuple[int, int, int]: """ @@ -34,6 +36,30 @@ def batch_spec_sort_key(spec: str) -> tuple[int, int, int]: return (0, 0, 0) +def run_do_bench( + benchmark_fn, + use_cuda_graphs: bool, + warmup_ms: int | None = None, +) -> list[float]: + kwargs: dict[str, Any] = {"return_mode": "all"} + if use_cuda_graphs: + result = triton.testing.do_bench_cudagraph(benchmark_fn, **kwargs) + else: + if warmup_ms is not None: + kwargs["warmup"] = warmup_ms + result = triton.testing.do_bench(benchmark_fn, **kwargs) + return result + + +def run_ncu_profile(benchmark_fn) -> None: + benchmark_fn() + torch.accelerator.synchronize() + torch.cuda.cudart().cudaProfilerStart() + benchmark_fn() + torch.accelerator.synchronize() + torch.cuda.cudart().cudaProfilerStop() + + # Mock classes for vLLM attention infrastructure @@ -182,18 +208,37 @@ def get_label(self, backend: str, value: Any) -> str: @dataclass class ModelParameterSweep: - """Configuration for sweeping a model configuration parameter.""" + """Configuration for sweeping model configuration parameter(s). - param_name: str # Name of the model config parameter to sweep (e.g., "num_q_heads") - values: list[Any] # List of values to test - label_format: str = "{backend}_{param_name}_{value}" # Result label template + Supports two modes: + - Single param: param_name="head_dim", values=[128, 256, 512] + - Multi param: values=[{head_dim: 192, v_head_dim: 128}, {head_dim: 256}] + When values are dicts, each dict's keys are applied as config overrides. + """ + + param_name: str | None = None + values: list[Any] | None = None + label_format: str = "{backend}_{param_name}_{value}" def get_label(self, backend: str, value: Any) -> str: """Generate a label for a specific parameter value.""" + if isinstance(value, dict): + return self.label_format.format( + backend=backend, param_name=self.param_name, value=value, **value + ) return self.label_format.format( backend=backend, param_name=self.param_name, value=value ) + def apply(self, config_args: dict, value: Any) -> None: + """Apply a sweep value to config args.""" + if isinstance(value, dict): + config_args.update(value) + elif self.param_name is not None: + config_args[self.param_name] = value + else: + raise ValueError("param_name must be set if sweep values are not dicts") + @dataclass class BenchmarkConfig: @@ -208,10 +253,10 @@ class BenchmarkConfig: block_size: int device: str dtype: torch.dtype = torch.float16 - repeats: int = 1 - warmup_iters: int = 3 profile_memory: bool = False use_cuda_graphs: bool = False + ncu_profile: bool = False + warmup_ms: int | None = None # "auto" or "fp8" kv_cache_dtype: str = "auto" @@ -226,6 +271,7 @@ class BenchmarkConfig: # Backend-specific tuning num_kv_splits: int | None = None # CUTLASS MLA reorder_batch_threshold: int | None = None # FlashAttn MLA, FlashMLA + num_splits: int | None = None # FlashAttention split-K (0=auto, 1=disabled) @dataclass @@ -234,6 +280,7 @@ class BenchmarkResult: config: BenchmarkConfig mean_time: float # seconds + median_time: float # seconds std_time: float # seconds min_time: float # seconds max_time: float # seconds @@ -252,6 +299,7 @@ def to_dict(self) -> dict[str, Any]: return { "config": asdict(self.config), "mean_time": self.mean_time, + "median_time": self.median_time, "std_time": self.std_time, "min_time": self.min_time, "max_time": self.max_time, diff --git a/benchmarks/attention_benchmarks/configs/mla_decode.yaml b/benchmarks/attention_benchmarks/configs/mla_decode.yaml index 8f12ac723064..c1d47bf5748b 100644 --- a/benchmarks/attention_benchmarks/configs/mla_decode.yaml +++ b/benchmarks/attention_benchmarks/configs/mla_decode.yaml @@ -56,8 +56,6 @@ backends: - TOKENSPEED_MLA # Blackwell + R1 dims + FP8 KV (use --kv-cache-dtype fp8) device: "cuda:0" -repeats: 100 -warmup_iters: 10 profile_memory: true # Backend-specific tuning diff --git a/benchmarks/attention_benchmarks/configs/mla_fa4_fp8_output.yaml b/benchmarks/attention_benchmarks/configs/mla_fa4_fp8_output.yaml new file mode 100644 index 000000000000..85588fcf9584 --- /dev/null +++ b/benchmarks/attention_benchmarks/configs/mla_fa4_fp8_output.yaml @@ -0,0 +1,44 @@ +# MLA prefill FP8-output microbenchmark (FA4). +# Compares the fused FP8 write against bf16 attention + a standalone static-FP8 +# quant; the delta is the post-quant kernel the fused path removes. +# DeepSeek-Coder-V2-Lite dims; FA4 needs SM100/110. +# +# Usage: +# python benchmark.py --config configs/mla_fa4_fp8_output.yaml + +description: "MLA prefill FA4 fused-FP8 output vs post-quant" + +model: + name: "deepseek-v2-lite" + num_layers: 27 + num_q_heads: 16 + num_kv_heads: 1 + head_dim: 576 + kv_lora_rank: 512 + qk_nope_head_dim: 128 + qk_rope_head_dim: 64 + v_head_dim: 128 + block_size: 128 + +# Pure prefill (q_len == kv_len) so every token goes through forward_mha. +batch_specs: + - "q512" + - "q1k" + - "q2k" + - "q4k" + - "q8k" + - "2q4k" + - "4q4k" + - "8q4k" + +# Only used to construct the MLA impl; the pure-prefill specs skip decode. +decode_backends: + - CUTLASS_MLA + +# Sweep the two FP8 write paths (prefill backend is fixed to fa4). +fp8_output_scale: 0.1 +fuse_quant_op: [false, true] + +device: "cuda:0" +repeats: 50 +warmup_iters: 10 diff --git a/benchmarks/attention_benchmarks/configs/mla_mixed_batch.yaml b/benchmarks/attention_benchmarks/configs/mla_mixed_batch.yaml index c342e9fb8c1a..fcb1d8639b73 100644 --- a/benchmarks/attention_benchmarks/configs/mla_mixed_batch.yaml +++ b/benchmarks/attention_benchmarks/configs/mla_mixed_batch.yaml @@ -51,8 +51,6 @@ backends: - FLASHMLA # Hopper only device: "cuda:0" -repeats: 5 -warmup_iters: 3 profile_memory: true # Analyze chunked prefill workspace size impact diff --git a/benchmarks/attention_benchmarks/configs/mla_prefill.yaml b/benchmarks/attention_benchmarks/configs/mla_prefill.yaml index 1e1ab264bace..f39cdd8d1c2f 100644 --- a/benchmarks/attention_benchmarks/configs/mla_prefill.yaml +++ b/benchmarks/attention_benchmarks/configs/mla_prefill.yaml @@ -124,5 +124,3 @@ prefill_backends: - tokenspeed device: "cuda:0" -repeats: 20 -warmup_iters: 5 diff --git a/benchmarks/attention_benchmarks/configs/mla_sparse_decode.yaml b/benchmarks/attention_benchmarks/configs/mla_sparse_decode.yaml index 689c9f3c3c66..c791638241f7 100644 --- a/benchmarks/attention_benchmarks/configs/mla_sparse_decode.yaml +++ b/benchmarks/attention_benchmarks/configs/mla_sparse_decode.yaml @@ -53,6 +53,4 @@ backends: - FLASHINFER_MLA_SPARSE device: "cuda:0" -repeats: 100 -warmup_iters: 10 profile_memory: true diff --git a/benchmarks/attention_benchmarks/configs/mla_sparse_prefill.yaml b/benchmarks/attention_benchmarks/configs/mla_sparse_prefill.yaml index ef6b2cb07dc7..fd8a0e22c5e0 100644 --- a/benchmarks/attention_benchmarks/configs/mla_sparse_prefill.yaml +++ b/benchmarks/attention_benchmarks/configs/mla_sparse_prefill.yaml @@ -57,6 +57,4 @@ backends: - FLASHINFER_MLA_SPARSE device: "cuda:0" -repeats: 10 -warmup_iters: 3 profile_memory: true diff --git a/benchmarks/attention_benchmarks/configs/reorder_threshold.yaml b/benchmarks/attention_benchmarks/configs/reorder_threshold.yaml index 0d76ef0a358c..9f53eac2c9cb 100644 --- a/benchmarks/attention_benchmarks/configs/reorder_threshold.yaml +++ b/benchmarks/attention_benchmarks/configs/reorder_threshold.yaml @@ -63,8 +63,6 @@ model: # Benchmark settings device: "cuda:0" -repeats: 15 # More repeats for spec decode variance -warmup_iters: 5 profile_memory: false # Output diff --git a/benchmarks/attention_benchmarks/configs/speculative_decode.yaml b/benchmarks/attention_benchmarks/configs/speculative_decode.yaml index 47b6d3604d1d..5e8775f0a426 100644 --- a/benchmarks/attention_benchmarks/configs/speculative_decode.yaml +++ b/benchmarks/attention_benchmarks/configs/speculative_decode.yaml @@ -49,8 +49,6 @@ backends: # Benchmark settings device: "cuda:0" -repeats: 10 # More repeats for statistical significance -warmup_iters: 5 profile_memory: false # Test these threshold values for optimization diff --git a/benchmarks/attention_benchmarks/configs/standard_attention.yaml b/benchmarks/attention_benchmarks/configs/standard_attention.yaml index deb5a4b27ff3..ccd44a426b90 100644 --- a/benchmarks/attention_benchmarks/configs/standard_attention.yaml +++ b/benchmarks/attention_benchmarks/configs/standard_attention.yaml @@ -43,6 +43,4 @@ backends: - FLASHINFER device: "cuda:0" -repeats: 5 -warmup_iters: 3 profile_memory: false diff --git a/benchmarks/attention_benchmarks/configs/standard_decode.yaml b/benchmarks/attention_benchmarks/configs/standard_decode.yaml new file mode 100644 index 000000000000..0861bd63dad3 --- /dev/null +++ b/benchmarks/attention_benchmarks/configs/standard_decode.yaml @@ -0,0 +1,142 @@ +# Standard attention decode benchmark configuration +# Sweeps num_q_heads and num_kv_heads to isolate effects of: +# 1. GQA ratio (fixed num_q_heads=32, vary num_kv_heads) +# 2. Absolute head count (fixed 4:1 ratio, vary scale) + +model: + num_layers: 32 + num_q_heads: 32 # Base value, overridden by sweep + num_kv_heads: 8 # Base value, overridden by sweep + head_dim: 128 + block_size: 16 + +# Head count sweep: each entry overrides num_q_heads, num_kv_heads, and +# head_dim where it differs from the base (128). Head counts are per-GPU +# (i.e. after TP sharding). +# +# Group A — vary GQA ratio (fixed q=32, head_dim=128): +# 32:32 (MHA), 32:8 (GQA 4:1), 32:4 (GQA 8:1), 32:1 (MQA) +# +# Groups B-E — real model configs at various TP degrees: +# Model head_dim Full TP2 TP4 TP8 +# Llama 3 8B 128 32:8 16:4 8:2 4:1 +# Llama 3 70B 128 64:8 32:4 16:2 8:1 +# GPT-OSS 120B 64 64:8 32:4 16:2 8:1 +# Llama 3 405B 128 128:8 64:4 32:2 16:1 +model_parameter_sweep: + values: + # --- head_dim=128 (Llama 3 family) --- + - { num_q_heads: 32, num_kv_heads: 32, head_dim: 128 } # MHA 1:1 + - { num_q_heads: 32, num_kv_heads: 1, head_dim: 128 } # MQA 32:1 + - { num_q_heads: 4, num_kv_heads: 1, head_dim: 128 } # Llama 3 8B TP8 + - { num_q_heads: 8, num_kv_heads: 2, head_dim: 128 } # Llama 3 8B TP4 + - { num_q_heads: 16, num_kv_heads: 4, head_dim: 128 } # Llama 3 8B TP2 + - { num_q_heads: 32, num_kv_heads: 8, head_dim: 128 } # Llama 3 8B TP1 / GQA 4:1 + - { num_q_heads: 8, num_kv_heads: 1, head_dim: 128 } # Llama 3 70B TP8 + - { num_q_heads: 16, num_kv_heads: 2, head_dim: 128 } # Llama 3 70B TP4 + - { num_q_heads: 32, num_kv_heads: 4, head_dim: 128 } # Llama 3 70B TP2 / GQA 8:1 + - { num_q_heads: 64, num_kv_heads: 8, head_dim: 128 } # Llama 3 70B TP1 + - { num_q_heads: 16, num_kv_heads: 1, head_dim: 128 } # Llama 3 405B TP8 + - { num_q_heads: 32, num_kv_heads: 2, head_dim: 128 } # Llama 3 405B TP4 + - { num_q_heads: 64, num_kv_heads: 4, head_dim: 128 } # Llama 3 405B TP2 + - { num_q_heads: 128, num_kv_heads: 8, head_dim: 128 } # Llama 3 405B TP1 + # --- head_dim=64 (GPT-OSS 120B) --- + - { num_q_heads: 8, num_kv_heads: 1, head_dim: 64 } # GPT-OSS 120B TP8 + - { num_q_heads: 16, num_kv_heads: 2, head_dim: 64 } # GPT-OSS 120B TP4 + - { num_q_heads: 32, num_kv_heads: 4, head_dim: 64 } # GPT-OSS 120B TP2 + - { num_q_heads: 64, num_kv_heads: 8, head_dim: 64 } # GPT-OSS 120B TP1 + label_format: "{backend}_q{num_q_heads}kv{num_kv_heads}d{head_dim}" + +batch_specs: + # ---- batch_size x seq_len grid (decode: q_len=1) ---- + # Small grid for quick iteration. Uncomment for full sweep. + + # Batch size 1 + - "q1s1k" + - "q1s512" + - "q1s2k" + - "q1s4k" + - "q1s8k" + - "q1s16k" + - "q1s32k" + + # Batch size 2 + - "2q1s512" + - "2q1s1k" + - "2q1s2k" + - "2q1s4k" + - "2q1s8k" + - "2q1s16k" + - "2q1s32k" + + # Batch size 4 + - "4q1s512" + - "4q1s1k" + - "4q1s2k" + - "4q1s4k" + - "4q1s8k" + - "4q1s16k" + - "4q1s32k" + + # Batch size 8 + - "8q1s1k" + - "8q1s512" + - "8q1s2k" + - "8q1s4k" + - "8q1s8k" + - "8q1s16k" + - "8q1s32k" + + # Batch size 16 + - "16q1s512" + - "16q1s1k" + - "16q1s2k" + - "16q1s4k" + - "16q1s8k" + - "16q1s16k" + - "16q1s32k" + + # Batch size 32 + - "32q1s512" + - "32q1s1k" + - "32q1s2k" + - "32q1s4k" + - "32q1s8k" + - "32q1s16k" + - "32q1s32k" + + # Batch size 64 + - "64q1s1k" + - "64q1s512" + - "64q1s2k" + - "64q1s4k" + - "64q1s8k" + - "64q1s16k" + - "64q1s32k" + + # Batch size 128 + - "128q1s512" + - "128q1s1k" + - "128q1s2k" + - "128q1s4k" + - "128q1s8k" + - "128q1s16k" + - "128q1s32k" + + # Batch size 256 + - "256q1s1k" + - "256q1s512" + - "256q1s2k" + - "256q1s4k" + - "256q1s8k" + - "256q1s16k" + - "256q1s32k" + +# Available backends: FLASH_ATTN, TRITON_ATTN, FLASHINFER +backends: + - FLASH_ATTN + - TRITON_ATTN + - FLASHINFER + +device: "cuda:0" +profile_memory: false diff --git a/benchmarks/attention_benchmarks/configs/standard_prefill.yaml b/benchmarks/attention_benchmarks/configs/standard_prefill.yaml new file mode 100644 index 000000000000..278b6347f652 --- /dev/null +++ b/benchmarks/attention_benchmarks/configs/standard_prefill.yaml @@ -0,0 +1,108 @@ +# Standard attention prefill benchmark configuration +# Sweeps num_q_heads and num_kv_heads to isolate effects of: +# 1. GQA ratio (fixed num_q_heads=32, vary num_kv_heads) +# 2. Absolute head count (fixed 4:1 ratio, vary scale) + +model: + num_layers: 32 + num_q_heads: 32 # Base value, overridden by sweep + num_kv_heads: 8 # Base value, overridden by sweep + head_dim: 128 + block_size: 16 + +# Head count sweep: each entry overrides num_q_heads, num_kv_heads, and +# head_dim where it differs from the base (128). Head counts are per-GPU +# (i.e. after TP sharding). +# +# Group A — vary GQA ratio (fixed q=32, head_dim=128): +# 32:32 (MHA), 32:8 (GQA 4:1), 32:4 (GQA 8:1), 32:1 (MQA) +# +# Groups B-E — real model configs at various TP degrees: +# Model head_dim Full TP2 TP4 TP8 +# Llama 3 8B 128 32:8 16:4 8:2 4:1 +# Llama 3 70B 128 64:8 32:4 16:2 8:1 +# GPT-OSS 120B 64 64:8 32:4 16:2 8:1 +# Llama 3 405B 128 128:8 64:4 32:2 16:1 +model_parameter_sweep: + values: + # --- head_dim=128 (Llama 3 family) --- + - { num_q_heads: 32, num_kv_heads: 32, head_dim: 128 } # MHA 1:1 + - { num_q_heads: 32, num_kv_heads: 1, head_dim: 128 } # MQA 32:1 + - { num_q_heads: 4, num_kv_heads: 1, head_dim: 128 } # Llama 3 8B TP8 + - { num_q_heads: 8, num_kv_heads: 2, head_dim: 128 } # Llama 3 8B TP4 + - { num_q_heads: 16, num_kv_heads: 4, head_dim: 128 } # Llama 3 8B TP2 + - { num_q_heads: 32, num_kv_heads: 8, head_dim: 128 } # Llama 3 8B TP1 / GQA 4:1 + - { num_q_heads: 8, num_kv_heads: 1, head_dim: 128 } # Llama 3 70B TP8 + - { num_q_heads: 16, num_kv_heads: 2, head_dim: 128 } # Llama 3 70B TP4 + - { num_q_heads: 32, num_kv_heads: 4, head_dim: 128 } # Llama 3 70B TP2 / GQA 8:1 + - { num_q_heads: 64, num_kv_heads: 8, head_dim: 128 } # Llama 3 70B TP1 + - { num_q_heads: 16, num_kv_heads: 1, head_dim: 128 } # Llama 3 405B TP8 + - { num_q_heads: 32, num_kv_heads: 2, head_dim: 128 } # Llama 3 405B TP4 + - { num_q_heads: 64, num_kv_heads: 4, head_dim: 128 } # Llama 3 405B TP2 + - { num_q_heads: 128, num_kv_heads: 8, head_dim: 128 } # Llama 3 405B TP1 + # --- head_dim=64 (GPT-OSS 120B) --- + - { num_q_heads: 8, num_kv_heads: 1, head_dim: 64 } # GPT-OSS 120B TP8 + - { num_q_heads: 16, num_kv_heads: 2, head_dim: 64 } # GPT-OSS 120B TP4 + - { num_q_heads: 32, num_kv_heads: 4, head_dim: 64 } # GPT-OSS 120B TP2 + - { num_q_heads: 64, num_kv_heads: 8, head_dim: 64 } # GPT-OSS 120B TP1 + label_format: "{backend}_q{num_q_heads}kv{num_kv_heads}d{head_dim}" + +batch_specs: + # ---- batch_size x prefill_len grid (prefill: q_len == seq_len) ---- + # Total tokens = batch_size * prefill_len, and prefill compute scales with + # prefill_len^2, so the largest cells are expensive. Trim batch sizes or + # lengths for quick iteration. + + # Batch size 1 + - "q512" + - "q1k" + - "q2k" + - "q4k" + - "q8k" + - "q16k" + - "q32k" + + # Batch size 2 + - "2q512" + - "2q1k" + - "2q2k" + - "2q4k" + - "2q8k" + - "2q16k" + - "2q32k" + + # Batch size 4 + - "4q512" + - "4q1k" + - "4q2k" + - "4q4k" + - "4q8k" + - "4q16k" + - "4q32k" + + # Batch size 8 + - "8q512" + - "8q1k" + - "8q2k" + - "8q4k" + - "8q8k" + - "8q16k" + - "8q32k" + + # Batch size 16 + - "16q512" + - "16q1k" + - "16q2k" + - "16q4k" + - "16q8k" + - "16q16k" + - "16q32k" + +# Available backends: FLASH_ATTN, TRITON_ATTN, FLASHINFER +backends: + - FLASH_ATTN + - TRITON_ATTN + - FLASHINFER + +device: "cuda:0" +profile_memory: false diff --git a/benchmarks/attention_benchmarks/mla_runner.py b/benchmarks/attention_benchmarks/mla_runner.py index abab1e2edbac..c9b3fb29bb92 100644 --- a/benchmarks/attention_benchmarks/mla_runner.py +++ b/benchmarks/attention_benchmarks/mla_runner.py @@ -8,6 +8,8 @@ needing full VllmConfig integration. """ +import statistics + import numpy as np import torch from batch_spec import parse_batch_spec @@ -17,6 +19,8 @@ MockIndexer, MockKVBProj, MockLayer, + run_do_bench, + run_ncu_profile, setup_mla_dims, ) @@ -704,6 +708,8 @@ def _run_single_benchmark( device: torch.device, indexer=None, kv_cache_dtype: str | None = None, + output_scale: float | None = None, + fuse_quant_op: bool = False, ) -> BenchmarkResult: """ Run a single benchmark iteration. @@ -717,6 +723,11 @@ def _run_single_benchmark( mla_dims: MLA dimension configuration device: Target device indexer: Optional MockIndexer for sparse backends + output_scale: Static per-tensor FP8 scale for prefill output. None + keeps the plain bf16 output (no quantization). + fuse_quant_op: With output_scale set, True lets the prefill kernel write + FP8 directly; False runs bf16 attention then a standalone static-FP8 + quant. The delta isolates the saved post-quant kernel. Returns: BenchmarkResult with timing statistics @@ -820,63 +831,86 @@ def _run_single_benchmark( num_prefill, mla_dims, query_fmt, device, torch.bfloat16 ) - # Build forward function + # Prefill FP8 output: fused (kernel writes e4m3) vs separate post-quant. + prefill_fp8_output = None + prefill_output_scale = None + prefill_quant_op = None + if has_prefill and output_scale is not None: + from vllm.platforms import current_platform + + prefill_output_scale = torch.tensor( + [output_scale], device=device, dtype=torch.float32 + ) + if fuse_quant_op: + prefill_fp8_output = torch.empty_like( + prefill_inputs["output"], dtype=current_platform.fp8_dtype() + ) + else: + from vllm.model_executor.layers.quantization.input_quant_fp8 import ( + QuantFP8, + ) + from vllm.model_executor.layers.quantization.utils.quant_utils import ( + GroupShape, + ) + + prefill_quant_op = QuantFP8(static=True, group_shape=GroupShape.PER_TENSOR) + + fused_output = output_scale is not None and fuse_quant_op + + # Build forward function (runs a single decode/prefill pass) def forward_fn(): results = [] if has_decode: results.append(impl.forward_mqa(decode_inputs, kv_cache, metadata, layer)) if has_prefill: - results.append( - impl.forward_mha( - prefill_inputs["q"], - prefill_inputs["k_c_normed"], - prefill_inputs["k_pe"], - kv_cache, - metadata, - prefill_inputs["k_scale"], - prefill_inputs["output"], - ) + out = impl.forward_mha( + prefill_inputs["q"], + prefill_inputs["k_c_normed"], + prefill_inputs["k_pe"], + kv_cache, + metadata, + prefill_inputs["k_scale"], + prefill_fp8_output if fused_output else prefill_inputs["output"], + prefill_output_scale if fused_output else None, ) + if fused_output: + out = prefill_fp8_output + elif prefill_quant_op is not None: + out, _ = prefill_quant_op( + prefill_inputs["output"], prefill_output_scale + ) + results.append(out) return results[0] if len(results) == 1 else tuple(results) - # Warmup - for _ in range(config.warmup_iters): - forward_fn() - torch.accelerator.synchronize() - - # Optionally capture a CUDA graph after warmup. - # Graph replay eliminates CPU launch overhead so timings reflect pure - # kernel time. - if config.use_cuda_graphs: - graph = torch.cuda.CUDAGraph() - with torch.cuda.graph(graph): + def benchmark_fn(): + for _ in range(config.num_layers): forward_fn() - benchmark_fn = graph.replay - else: - benchmark_fn = forward_fn - # Benchmark - times = [] - for _ in range(config.repeats): - start = torch.cuda.Event(enable_timing=True) - end = torch.cuda.Event(enable_timing=True) + if config.ncu_profile: + run_ncu_profile(benchmark_fn) + return BenchmarkResult( + config=config, + mean_time=0.0, + median_time=0.0, + std_time=0.0, + min_time=0.0, + max_time=0.0, + throughput_tokens_per_sec=0.0, + ) - start.record() - for _ in range(config.num_layers): - benchmark_fn() - end.record() + all_ms = run_do_bench(benchmark_fn, config.use_cuda_graphs, config.warmup_ms) - torch.accelerator.synchronize() - elapsed_ms = start.elapsed_time(end) - times.append(elapsed_ms / 1000.0 / config.num_layers) + # Convert ms to seconds per layer + times = [t / 1000.0 / config.num_layers for t in all_ms] + mean_time = statistics.mean(times) - mean_time = float(np.mean(times)) return BenchmarkResult( config=config, mean_time=mean_time, - std_time=float(np.std(times)), - min_time=float(np.min(times)), - max_time=float(np.max(times)), + median_time=statistics.median(times), + std_time=statistics.stdev(times) if len(times) > 1 else 0.0, + min_time=min(times), + max_time=max(times), throughput_tokens_per_sec=total_q / mean_time if mean_time > 0 else 0, ) @@ -886,6 +920,8 @@ def _run_mla_benchmark_batched( configs_with_params: list[tuple], # [(config, threshold, num_splits), ...] index_topk: int = 2048, prefill_backend: str | None = None, + output_scale: float | None = None, + fuse_quant_op: bool = False, ) -> list[BenchmarkResult]: """ Unified batched MLA benchmark runner for all backends. @@ -1025,6 +1061,8 @@ def _run_mla_benchmark_batched( device, indexer=indexer, kv_cache_dtype=kv_cache_dtype, + output_scale=output_scale, + fuse_quant_op=fuse_quant_op, ) results.append(result) @@ -1052,6 +1090,8 @@ def run_mla_benchmark( num_kv_splits: int | None = None, index_topk: int = 2048, prefill_backend: str | None = None, + output_scale: float | None = None, + fuse_quant_op: bool = False, ) -> BenchmarkResult | list[BenchmarkResult]: """ Unified MLA benchmark runner for all backends. @@ -1071,6 +1111,9 @@ def run_mla_benchmark( index_topk: Topk value for sparse MLA backends (default 2048) prefill_backend: Prefill backend name (e.g., "fa3", "fa4"). When set, forces the specified FlashAttention version for prefill. + output_scale: Static per-tensor FP8 scale for prefill output (None = bf16). + fuse_quant_op: With output_scale set, fuse the FP8 write into the prefill + kernel vs a standalone post-quant kernel. See _run_single_benchmark. Returns: BenchmarkResult (single mode) or list of BenchmarkResult (batched mode) @@ -1095,7 +1138,12 @@ def run_mla_benchmark( # Use unified batched execution results = _run_mla_benchmark_batched( - backend, configs_with_params, index_topk, prefill_backend=prefill_backend + backend, + configs_with_params, + index_topk, + prefill_backend=prefill_backend, + output_scale=output_scale, + fuse_quant_op=fuse_quant_op, ) # Return single result or list based on input diff --git a/benchmarks/attention_benchmarks/runner.py b/benchmarks/attention_benchmarks/runner.py index aa636cd9cb53..8cd20dced179 100644 --- a/benchmarks/attention_benchmarks/runner.py +++ b/benchmarks/attention_benchmarks/runner.py @@ -9,13 +9,20 @@ """ import logging +import statistics import types from contextlib import contextmanager -import numpy as np import torch from batch_spec import parse_batch_spec, reorder_for_flashinfer -from common import BenchmarkConfig, BenchmarkResult, MockLayer, get_attention_scale +from common import ( + BenchmarkConfig, + BenchmarkResult, + MockLayer, + get_attention_scale, + run_do_bench, + run_ncu_profile, +) from vllm.config import ( CacheConfig, @@ -208,6 +215,13 @@ def _create_backend_impl( scale = get_attention_scale(config.head_dim) + # Set v_head_dim for diff-headdim backends. Always reset (defaulting to + # head_dim) so a prior run's value doesn't leak into this one via the + # backend's class-level state. + if hasattr(backend_class, "set_head_size_v"): + v_dim = config.v_head_dim if config.v_head_dim is not None else config.head_dim + backend_class.set_head_size_v(v_dim) + impl = backend_class.get_impl_cls()( num_heads=config.num_q_heads, head_size=config.head_dim, @@ -300,6 +314,7 @@ def _create_input_tensors( from vllm.platforms import current_platform q_dtype = current_platform.fp8_dtype() + v_dim = config.v_head_dim if config.v_head_dim is not None else config.head_dim q_list = [ torch.randn( total_q, config.num_q_heads, config.head_dim, device=device, dtype=dtype @@ -313,9 +328,7 @@ def _create_input_tensors( for _ in range(config.num_layers) ] v_list = [ - torch.randn( - total_q, config.num_kv_heads, config.head_dim, device=device, dtype=dtype - ) + torch.randn(total_q, config.num_kv_heads, v_dim, device=device, dtype=dtype) for _ in range(config.num_layers) ] return q_list, k_list, v_list @@ -389,14 +402,17 @@ def _run_single_benchmark( device: torch.device, dtype: torch.dtype, ) -> tuple: - """Run single benchmark iteration with warmup and timing loop.""" + """Run single benchmark using triton's do_bench_cudagraph/do_bench. + + Returns: + (timing_stats, mem_stats) where timing_stats is a dict with + mean/std/min/max in seconds per layer. + """ total_q = q_list[0].shape[0] - out = torch.empty( - total_q, config.num_q_heads, config.head_dim, device=device, dtype=dtype - ) + v_dim = config.v_head_dim if config.v_head_dim is not None else config.head_dim + out = torch.empty(total_q, config.num_q_heads, v_dim, device=device, dtype=dtype) - # Warmup - for _ in range(config.warmup_iters): + def benchmark_fn(): for i in range(config.num_layers): impl.forward( layer, @@ -407,52 +423,22 @@ def _run_single_benchmark( attn_metadata, output=out, ) - torch.accelerator.synchronize() - - # Optionally capture a CUDA graph after warmup. - # Graph replay eliminates CPU launch overhead so timings reflect pure - # kernel time. - if config.use_cuda_graphs: - graph = torch.cuda.CUDAGraph() - with torch.cuda.graph(graph): - for i in range(config.num_layers): - impl.forward( - layer, - q_list[i], - k_list[i], - v_list[i], - cache_list[i], - attn_metadata, - output=out, - ) - benchmark_fn = graph.replay - else: - - def benchmark_fn(): - for i in range(config.num_layers): - impl.forward( - layer, - q_list[i], - k_list[i], - v_list[i], - cache_list[i], - attn_metadata, - output=out, - ) - - # Benchmark - times = [] - for _ in range(config.repeats): - start = torch.cuda.Event(enable_timing=True) - end = torch.cuda.Event(enable_timing=True) - start.record() - benchmark_fn() - end.record() - - torch.accelerator.synchronize() - elapsed_ms = start.elapsed_time(end) - times.append(elapsed_ms / 1000.0 / config.num_layers) # seconds per layer + if config.ncu_profile: + run_ncu_profile(benchmark_fn) + timing_stats = dict.fromkeys(("mean", "median", "std", "min", "max"), 0.0) + else: + all_ms = run_do_bench(benchmark_fn, config.use_cuda_graphs, config.warmup_ms) + + # Convert ms to seconds per layer + times = [t / 1000.0 / config.num_layers for t in all_ms] + timing_stats = { + "mean": statistics.mean(times), + "std": statistics.stdev(times) if len(times) > 1 else 0.0, + "min": min(times), + "max": max(times), + "median": statistics.median(times), + } mem_stats = {} if config.profile_memory: @@ -461,7 +447,7 @@ def benchmark_fn(): "reserved_mb": torch.accelerator.memory_reserved(device) / 1024**2, } - return times, mem_stats + return timing_stats, mem_stats # ============================================================================ @@ -541,6 +527,12 @@ def run_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult: common_attn_metadata=common_metadata, ) + # Override num_splits for split-K testing (FlashAttention only) + if config.num_splits is not None and hasattr( + attn_metadata, "max_num_splits" + ): + attn_metadata.max_num_splits = config.num_splits + # Only quantize queries when the impl supports it quantize_query = config.kv_cache_dtype.startswith("fp8") and getattr( impl, "supports_quant_query_input", False @@ -553,7 +545,7 @@ def run_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult: config, max_num_blocks, backend_class, device, dtype ) - times, mem_stats = _run_single_benchmark( + timing_stats, mem_stats = _run_single_benchmark( config, impl, layer, @@ -566,15 +558,16 @@ def run_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult: dtype, ) - mean_time = np.mean(times) + mean_time = timing_stats["mean"] throughput = total_q / mean_time if mean_time > 0 else 0 return BenchmarkResult( config=config, mean_time=mean_time, - std_time=np.std(times), - min_time=np.min(times), - max_time=np.max(times), + median_time=timing_stats["median"], + std_time=timing_stats["std"], + min_time=timing_stats["min"], + max_time=timing_stats["max"], throughput_tokens_per_sec=throughput, memory_allocated_mb=mem_stats.get("allocated_mb"), memory_reserved_mb=mem_stats.get("reserved_mb"), diff --git a/benchmarks/backend_request_func.py b/benchmarks/backend_request_func.py index a69637bfc437..6349095ad729 100644 --- a/benchmarks/backend_request_func.py +++ b/benchmarks/backend_request_func.py @@ -12,7 +12,7 @@ import aiohttp import huggingface_hub.constants from tqdm.asyncio import tqdm -from transformers import AutoTokenizer, PreTrainedTokenizer, PreTrainedTokenizerFast +from transformers import AutoTokenizer, PythonBackend, TokenizersBackend # NOTE(simon): do not import vLLM here so the benchmark script # can run without vLLM installed. @@ -609,7 +609,7 @@ def get_tokenizer( tokenizer_mode: str = "auto", trust_remote_code: bool = False, **kwargs, -) -> PreTrainedTokenizer | PreTrainedTokenizerFast: +) -> PythonBackend | TokenizersBackend: if pretrained_model_name_or_path is not None and not os.path.exists( pretrained_model_name_or_path ): diff --git a/benchmarks/benchmark_hidden_state_extraction.py b/benchmarks/benchmark_hidden_state_extraction.py index 6056fcdd072c..f0a35a0cf150 100644 --- a/benchmarks/benchmark_hidden_state_extraction.py +++ b/benchmarks/benchmark_hidden_state_extraction.py @@ -92,7 +92,6 @@ def run_baseline( llm = LLM( model=model, enable_prefix_caching=False, - enable_chunked_prefill=False, **extra_args, ) sampling_params = SamplingParams(max_tokens=1) @@ -194,7 +193,6 @@ async def _run_extraction_async( engine_args = AsyncEngineArgs( model=model, enable_prefix_caching=False, - enable_chunked_prefill=False, max_num_batched_tokens=40960, max_model_len=40960, speculative_config={ diff --git a/benchmarks/benchmark_pin_memory.py b/benchmarks/benchmark_pin_memory.py new file mode 100644 index 000000000000..63a6b75d914e --- /dev/null +++ b/benchmarks/benchmark_pin_memory.py @@ -0,0 +1,358 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Benchmark and regression-test pinned (page-locked) CPU memory for vLLM. + +Verifies that enabling pinned memory does not regress throughput or latency +compared to unpinned memory. Each condition runs in an isolated ``spawn`` +subprocess so both start from a cold CUDA context, giving an unbiased +comparison. + +Usage +----- +Run all tests with the default model:: + + python benchmarks/benchmark_pin_memory.py -v + +Override the model and optional max-model-len:: + + python benchmarks/benchmark_pin_memory.py --model unsloth/Qwen3-1.7B -v + python benchmarks/benchmark_pin_memory.py --model unsloth/Qwen3-1.7B \ + --max-model-len 8192 -v + +Run only throughput or latency tests:: + + python benchmarks/benchmark_pin_memory.py -v -k test_throughput + python benchmarks/benchmark_pin_memory.py -v -k test_latency + +Run only the v1 or v2 runner variant:: + + python benchmarks/benchmark_pin_memory.py -v -k v1 + python benchmarks/benchmark_pin_memory.py -v -k v2 + +Note: on WSL2, v1 runner tests are skipped because pin memory is not available +for the v1 runner without cpu_offload_gb. Run on other platforms to exercise v1. +""" + +import argparse +import json +import multiprocessing +import sys +import tempfile + +import pytest + +# Allow up to 2% degradation. Both benchmark runs start from an identical +# cold CUDA context (separate spawn subprocesses), so the measured difference +# reflects the genuine pin_memory overhead rather than cold/warm ordering bias. +_THROUGHPUT_TOLERANCE = 0.98 +_THROUGHPUT_NUM_REQUESTS = 200 +_THROUGHPUT_INPUT_LEN = 128 +_THROUGHPUT_OUTPUT_LEN = 512 +_THROUGHPUT_MAX_NUM_SEQS = 128 + +# Latency benchmark constants — match latency.py defaults. +_LATENCY_TOLERANCE = 1.02 # Allow up to 2% latency regression. +_LATENCY_BATCH_SIZE = 64 +_LATENCY_INPUT_LEN = 32 +_LATENCY_OUTPUT_LEN = 128 +_LATENCY_WARMUP_ITERS = 5 +_LATENCY_BENCH_ITERS = 15 + +_DEFAULT_MODEL = "unsloth/Qwen3-1.7B" +_DEFAULT_MAX_MODEL_LEN = 16384 + + +def _benchmark_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--model", default=_DEFAULT_MODEL) + parser.add_argument("--max-model-len", type=int, default=_DEFAULT_MAX_MODEL_LEN) + args, _ = parser.parse_known_args() + return args + + +@pytest.fixture +def model() -> str: + return _benchmark_args().model + + +@pytest.fixture +def max_model_len() -> int: + return _benchmark_args().max_model_len + + +def _skip_if_pin_memory_not_available(engine_args_kwargs: dict) -> None: + """Skip the current pytest test if pin_memory is unavailable for this config.""" + import vllm.utils.platform_utils as pu + from vllm.config import set_current_vllm_config + from vllm.engine.arg_utils import EngineArgs + + vllm_config = EngineArgs(**engine_args_kwargs).create_engine_config() + with set_current_vllm_config(vllm_config): + pu.is_pin_memory_available.cache_clear() + if not pu.is_pin_memory_available(): + import os + + runner = "v2" if os.environ.get("VLLM_USE_V2_MODEL_RUNNER") == "1" else "v1" + model = engine_args_kwargs.get("model", "unknown") + print( + f"\033[33mSKIP: pin_memory not available for " + f"{runner} runner, model={model}\033[0m" + ) + pytest.skip("pin_memory not available for this configuration") + + +def _throughput_worker( + pin: bool, + engine_args_kwargs: dict, + q: "multiprocessing.Queue[float]", + v2_mode: bool = False, +) -> None: + """Run throughput benchmark in a fresh spawn subprocess. + + Delegates to vllm/benchmarks/throughput.py main() using the random dataset, + so the methodology matches the official benchmark. Results are written to a + temp JSON file and forwarded through the queue as tokens/s. + + v2_mode: when True, monkeypatches is_uva_available() to always return True + so the v2 model runner's UVA buffers remain functional even when pin=False. + This isolates the non-UVA pin_memory paths in v2. + """ + import vllm.utils.platform_utils as pu + from vllm.platforms import current_platform + + pu.is_pin_memory_available.cache_clear() + pu.is_uva_available.cache_clear() + type(current_platform).is_pin_memory_available = classmethod(lambda cls: pin) + if v2_mode: + pu.is_uva_available = lambda: True + + from vllm.benchmarks.throughput import add_cli_args + from vllm.benchmarks.throughput import main as throughput_main + + parser = argparse.ArgumentParser() + add_cli_args(parser) + args = parser.parse_args([]) + + for key, val in engine_args_kwargs.items(): + setattr(args, key, val) + args.max_num_seqs = _THROUGHPUT_MAX_NUM_SEQS + args.dataset_name = "random" + args.input_len = _THROUGHPUT_INPUT_LEN + args.output_len = _THROUGHPUT_OUTPUT_LEN + # Nullify defaults that conflict with explicit input/output_len. + args.random_input_len = None + args.random_output_len = None + args.random_prefix_len = None + args.num_prompts = _THROUGHPUT_NUM_REQUESTS + args.seed = 0 + args.disable_detokenize = True + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + tmp_path = f.name + args.output_json = tmp_path + + throughput_main(args) + + with open(tmp_path) as f: + results = json.load(f) + q.put(results["tokens_per_second"]) + + +def _run_throughput_benchmark( + pin: bool, + engine_args_kwargs: dict, + v2_mode: bool = False, +) -> float: + ctx = multiprocessing.get_context("spawn") + q = ctx.Queue() + p = ctx.Process( + target=_throughput_worker, + args=(pin, engine_args_kwargs, q, v2_mode), + ) + p.start() + p.join() + if p.exitcode != 0: + raise RuntimeError( + f"Throughput benchmark subprocess (pin={pin}) exited with code {p.exitcode}" + ) + return q.get() + + +def _latency_worker( + pin: bool, + engine_args_kwargs: dict, + q: "multiprocessing.Queue[dict]", + v2_mode: bool = False, +) -> None: + """Run latency benchmark in a fresh spawn subprocess. + + Follows latency.py methodology: fixed batch of dummy token IDs, warmup + iterations to reach steady state, then timed iterations reduced to avg + and percentiles. Results are written to a temp JSON file by latency_main + and forwarded through the queue. + """ + import vllm.utils.platform_utils as pu + from vllm.platforms import current_platform + + pu.is_pin_memory_available.cache_clear() + pu.is_uva_available.cache_clear() + type(current_platform).is_pin_memory_available = classmethod(lambda cls: pin) + if v2_mode: + pu.is_uva_available = lambda: True + + from vllm.benchmarks.latency import add_cli_args + from vllm.benchmarks.latency import main as latency_main + + parser = argparse.ArgumentParser() + add_cli_args(parser) + args = parser.parse_args([]) + + for key, val in engine_args_kwargs.items(): + setattr(args, key, val) + args.input_len = _LATENCY_INPUT_LEN + args.output_len = _LATENCY_OUTPUT_LEN + args.batch_size = _LATENCY_BATCH_SIZE + args.num_iters_warmup = _LATENCY_WARMUP_ITERS + args.num_iters = _LATENCY_BENCH_ITERS + args.profile = False + args.disable_detokenize = True + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + tmp_path = f.name + args.output_json = tmp_path + + latency_main(args) + + with open(tmp_path) as f: + results = json.load(f) + q.put(results) + + +def _run_latency_benchmark( + pin: bool, + engine_args_kwargs: dict, + v2_mode: bool = False, +) -> dict: + ctx = multiprocessing.get_context("spawn") + q = ctx.Queue() + p = ctx.Process( + target=_latency_worker, + args=(pin, engine_args_kwargs, q, v2_mode), + ) + p.start() + p.join() + if p.exitcode != 0: + raise RuntimeError( + f"Latency benchmark subprocess (pin={pin}) exited with code {p.exitcode}" + ) + return q.get() + + +@pytest.mark.parametrize( + "test_v2_runner", + [ + pytest.param(False, id="v1"), + pytest.param(True, id="v2"), + ], +) +class TestPinnedMemory: + """Verify pinned memory yields >= throughput vs unpinned via real vLLM inference.""" + + def test_throughput(self, monkeypatch, test_v2_runner, model, max_model_len): + """Benchmark throughput with pin_memory forced on then off. + + Delegates to vllm/benchmarks/throughput.py main() with the random + dataset. Each condition runs in an isolated spawn subprocess so both + start from a cold CUDA context, giving an unbiased comparison. + """ + monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0") + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if test_v2_runner else "0") + + engine_args_kwargs = dict( + model=model, + gpu_memory_utilization=0.88, + max_model_len=max_model_len, + enable_prefix_caching=False, + ) + + _skip_if_pin_memory_not_available(engine_args_kwargs) + + unpinned_tps = _run_throughput_benchmark( + False, engine_args_kwargs, v2_mode=test_v2_runner + ) + pinned_tps = _run_throughput_benchmark( + True, engine_args_kwargs, v2_mode=test_v2_runner + ) + + pct_diff = (pinned_tps - unpinned_tps) / unpinned_tps * 100 + runner = "v2" if test_v2_runner else "v1" + print( + f"\n=== Throughput results ({runner} runner, {model}) ===" + f"\npin_memory=True: {pinned_tps:.1f} tok/s" + f"\npin_memory=False: {unpinned_tps:.1f} tok/s" + f"\nDifference: {pct_diff:+.1f}% (pinned vs unpinned)" + ) + + assert pinned_tps >= unpinned_tps * _THROUGHPUT_TOLERANCE, ( + f"Pinned throughput ({pinned_tps:.1f} tok/s) fell more than " + f"{(1.0 - _THROUGHPUT_TOLERANCE) * 100:.1f}% below " + f"unpinned ({unpinned_tps:.1f} tok/s)." + ) + + def test_latency(self, monkeypatch, test_v2_runner, model, max_model_len): + """Benchmark per-batch latency with pin_memory forced on then off. + + Follows vllm/benchmarks/latency.py: fixed dummy-token batch, warmup + iterations to reach steady state, then timed iterations reduced to avg + and percentiles. Subprocesses run serially so each gets a cold CUDA + context without GPU memory pressure from the other run. + """ + monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0") + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if test_v2_runner else "0") + + engine_args_kwargs = dict( + model=model, + gpu_memory_utilization=0.88, + max_model_len=max_model_len, + enable_prefix_caching=False, + ) + + _skip_if_pin_memory_not_available(engine_args_kwargs) + + unpinned = _run_latency_benchmark( + False, engine_args_kwargs, v2_mode=test_v2_runner + ) + pinned = _run_latency_benchmark( + True, engine_args_kwargs, v2_mode=test_v2_runner + ) + + pct_diff = ( + (pinned["avg_latency"] - unpinned["avg_latency"]) + / unpinned["avg_latency"] + * 100 + ) + runner = "v2" if test_v2_runner else "v1" + print( + f"\n=== Latency results ({runner} runner, {model}) ===" + f"\npin_memory=True: avg={pinned['avg_latency']:.3f}s" + f" p50={pinned['percentiles']['50']:.3f}s" + f" p99={pinned['percentiles']['99']:.3f}s" + f"\npin_memory=False: avg={unpinned['avg_latency']:.3f}s" + f" p50={unpinned['percentiles']['50']:.3f}s" + f" p99={unpinned['percentiles']['99']:.3f}s" + f"\nDifference: {pct_diff:+.1f}% (pinned vs unpinned)" + ) + + assert pinned["avg_latency"] <= unpinned["avg_latency"] * _LATENCY_TOLERANCE, ( + f"Pinned avg latency ({pinned['avg_latency']:.3f}s) exceeded " + f"unpinned ({unpinned['avg_latency']:.3f}s) by more than " + f"{(_LATENCY_TOLERANCE - 1.0) * 100:.1f}%." + ) + + +if __name__ == "__main__": + _parser = argparse.ArgumentParser(add_help=False) + _parser.add_argument("--model", default=_DEFAULT_MODEL) + _parser.add_argument("--max-model-len", type=int, default=_DEFAULT_MAX_MODEL_LEN) + _, _remaining = _parser.parse_known_args() + sys.exit(pytest.main([__file__] + _remaining)) diff --git a/benchmarks/disagg_benchmarks/disagg_overhead_benchmark.sh b/benchmarks/disagg_benchmarks/disagg_overhead_benchmark.sh deleted file mode 100644 index d683835db96a..000000000000 --- a/benchmarks/disagg_benchmarks/disagg_overhead_benchmark.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/bin/bash - -# benchmark the overhead of disaggregated prefill. -# methodology: -# - send all request to prefill vLLM instance. It will buffer KV cache. -# - then send all request to decode instance. -# - The TTFT of decode instance is the overhead. - -set -ex - -kill_gpu_processes() { - # kill all processes on GPU. - pgrep pt_main_thread | xargs -r kill -9 - pgrep python3 | xargs -r kill -9 - # vLLM now names the process with VLLM prefix after https://github.com/vllm-project/vllm/pull/21445 - pgrep VLLM | xargs -r kill -9 - sleep 10 - - # remove vllm config file - rm -rf ~/.config/vllm - - # Print the GPU memory usage - # so that we know if all GPU processes are killed. - gpu_memory_usage=$(nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits -i 0) - # The memory usage should be 0 MB. - echo "GPU 0 Memory Usage: $gpu_memory_usage MB" -} - -wait_for_server() { - # wait for vllm server to start - # return 1 if vllm server crashes - local port=$1 - timeout 1200 bash -c " - until curl -s localhost:${port}/v1/completions > /dev/null; do - sleep 1 - done" && return 0 || return 1 -} - - -benchmark() { - - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_HOST_IP=$(hostname -I | awk '{print $1}') - - # compare chunked prefill with disaggregated prefill - - results_folder="./results" - model="meta-llama/Meta-Llama-3.1-8B-Instruct" - dataset_name="sonnet" - dataset_path="../sonnet_4x.txt" - num_prompts=10 - qps=$1 - prefix_len=50 - input_len=2048 - output_len=$2 - - - CUDA_VISIBLE_DEVICES=0 vllm serve $model \ - --port 8100 \ - --max-model-len 10000 \ - --gpu-memory-utilization 0.6 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_producer","kv_rank":0,"kv_parallel_size":2,"kv_buffer_size":5e9}' & - - - CUDA_VISIBLE_DEVICES=1 vllm serve $model \ - --port 8200 \ - --max-model-len 10000 \ - --gpu-memory-utilization 0.6 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_consumer","kv_rank":1,"kv_parallel_size":2,"kv_buffer_size":5e9}' & - - wait_for_server 8100 - wait_for_server 8200 - - # let the prefill instance finish prefill - vllm bench serve \ - --backend vllm \ - --model $model \ - --dataset-name $dataset_name \ - --dataset-path $dataset_path \ - --sonnet-input-len $input_len \ - --sonnet-output-len "$output_len" \ - --sonnet-prefix-len $prefix_len \ - --num-prompts $num_prompts \ - --port 8100 \ - --save-result \ - --result-dir $results_folder \ - --result-filename disagg_prefill_tp1.json \ - --request-rate "inf" - - - # send the request to decode. - # The TTFT of this command will be the overhead of disagg prefill impl. - vllm bench serve \ - --backend vllm \ - --model $model \ - --dataset-name $dataset_name \ - --dataset-path $dataset_path \ - --sonnet-input-len $input_len \ - --sonnet-output-len "$output_len" \ - --sonnet-prefix-len $prefix_len \ - --num-prompts $num_prompts \ - --port 8200 \ - --save-result \ - --result-dir $results_folder \ - --result-filename disagg_prefill_tp1_overhead.json \ - --request-rate "$qps" - kill_gpu_processes - -} - - -main() { - - (which wget && which curl) || (apt-get update && apt-get install -y wget curl) - (which jq) || (apt-get -y install jq) - (which socat) || (apt-get -y install socat) - - pip install quart httpx datasets - - cd "$(dirname "$0")" - - cd .. - # create sonnet-4x.txt - echo "" > sonnet_4x.txt - for _ in {1..4} - do - cat sonnet.txt >> sonnet_4x.txt - done - cd disagg_benchmarks - - rm -rf results - mkdir results - - default_qps=1 - default_output_len=1 - benchmark $default_qps $default_output_len - -} - - -main "$@" diff --git a/benchmarks/disagg_benchmarks/disagg_performance_benchmark.sh b/benchmarks/disagg_benchmarks/disagg_performance_benchmark.sh deleted file mode 100644 index 35c86cc84522..000000000000 --- a/benchmarks/disagg_benchmarks/disagg_performance_benchmark.sh +++ /dev/null @@ -1,157 +0,0 @@ -#!/bin/bash - -# Requirement: 2x GPUs. - - -# Model: meta-llama/Meta-Llama-3.1-8B-Instruct -# Query: 1024 input tokens, 6 output tokens, QPS 2/4/6/8, 100 requests -# Resource: 2x GPU -# Approaches: -# 2. Chunked prefill: 2 vllm instance with tp=4, equivalent to 1 tp=4 instance with QPS 4 -# 3. Disaggregated prefill: 1 prefilling instance and 1 decoding instance -# Prefilling instance: max_output_token=1 -# Decoding instance: force the input tokens be the same across requests to bypass prefilling - -set -ex - -kill_gpu_processes() { - # kill all processes on GPU. - pgrep pt_main_thread | xargs -r kill -9 - pgrep python3 | xargs -r kill -9 - # vLLM now names the process with VLLM prefix after https://github.com/vllm-project/vllm/pull/21445 - pgrep VLLM | xargs -r kill -9 - for port in 8000 8100 8200; do lsof -t -i:$port | xargs -r kill -9; done - sleep 1 -} - -wait_for_server() { - # wait for vllm server to start - # return 1 if vllm server crashes - local port=$1 - timeout 1200 bash -c " - until curl -s localhost:${port}/v1/completions > /dev/null; do - sleep 1 - done" && return 0 || return 1 -} - - -launch_chunked_prefill() { - model="meta-llama/Meta-Llama-3.1-8B-Instruct" - # disagg prefill - CUDA_VISIBLE_DEVICES=0 vllm serve $model \ - --port 8100 \ - --max-model-len 10000 \ - --enable-chunked-prefill \ - --gpu-memory-utilization 0.6 & - CUDA_VISIBLE_DEVICES=1 vllm serve $model \ - --port 8200 \ - --max-model-len 10000 \ - --enable-chunked-prefill \ - --gpu-memory-utilization 0.6 & - wait_for_server 8100 - wait_for_server 8200 - python3 round_robin_proxy.py & - sleep 1 -} - - -launch_disagg_prefill() { - model="meta-llama/Meta-Llama-3.1-8B-Instruct" - # disagg prefill - CUDA_VISIBLE_DEVICES=0 vllm serve $model \ - --port 8100 \ - --max-model-len 10000 \ - --gpu-memory-utilization 0.6 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_producer","kv_rank":0,"kv_parallel_size":2,"kv_buffer_size":5e9}' & - - CUDA_VISIBLE_DEVICES=1 vllm serve $model \ - --port 8200 \ - --max-model-len 10000 \ - --gpu-memory-utilization 0.6 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_consumer","kv_rank":1,"kv_parallel_size":2,"kv_buffer_size":5e9}' & - - wait_for_server 8100 - wait_for_server 8200 - python3 disagg_prefill_proxy_server.py & - sleep 1 -} - - -benchmark() { - results_folder="./results" - model="meta-llama/Meta-Llama-3.1-8B-Instruct" - dataset_name="sonnet" - dataset_path="../sonnet_4x.txt" - num_prompts=100 - qps=$1 - prefix_len=50 - input_len=1024 - output_len=$2 - tag=$3 - - vllm bench serve \ - --backend vllm \ - --model $model \ - --dataset-name $dataset_name \ - --dataset-path $dataset_path \ - --sonnet-input-len $input_len \ - --sonnet-output-len "$output_len" \ - --sonnet-prefix-len $prefix_len \ - --num-prompts $num_prompts \ - --port 8000 \ - --save-result \ - --result-dir $results_folder \ - --result-filename "$tag"-qps-"$qps".json \ - --request-rate "$qps" - - sleep 2 -} - - -main() { - - (which wget && which curl) || (apt-get update && apt-get install -y wget curl) - (which jq) || (apt-get -y install jq) - (which socat) || (apt-get -y install socat) - (which lsof) || (apt-get -y install lsof) - - pip install quart httpx matplotlib aiohttp datasets - - cd "$(dirname "$0")" - - cd .. - # create sonnet-4x.txt so that we can sample 2048 tokens for input - echo "" > sonnet_4x.txt - for _ in {1..4} - do - cat sonnet.txt >> sonnet_4x.txt - done - cd disagg_benchmarks - - rm -rf results - mkdir results - - default_output_len=6 - - export VLLM_HOST_IP=$(hostname -I | awk '{print $1}') - - launch_chunked_prefill - for qps in 2 4 6 8; do - benchmark $qps $default_output_len chunked_prefill - done - kill_gpu_processes - - launch_disagg_prefill - for qps in 2 4 6 8; do - benchmark $qps $default_output_len disagg_prefill - done - kill_gpu_processes - - python3 visualize_benchmark_results.py - -} - - -main "$@" diff --git a/benchmarks/disagg_benchmarks/disagg_prefill_proxy_server.py b/benchmarks/disagg_benchmarks/disagg_prefill_proxy_server.py deleted file mode 100644 index d072c03c440b..000000000000 --- a/benchmarks/disagg_benchmarks/disagg_prefill_proxy_server.py +++ /dev/null @@ -1,260 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import argparse -import asyncio -import logging -import os -import time -import uuid -from urllib.parse import urlparse - -import aiohttp -from quart import Quart, Response, make_response, request - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -def parse_args(): - """parse command line arguments""" - parser = argparse.ArgumentParser(description="vLLM P/D disaggregation proxy server") - - # Add args - parser.add_argument( - "--timeout", - type=float, - default=6 * 60 * 60, - help="Timeout for backend service requests in seconds (default: 21600)", - ) - parser.add_argument( - "--port", - type=int, - default=8000, - help="Port to run the server on (default: 8000)", - ) - parser.add_argument( - "--prefill-url", - type=str, - default="http://localhost:8100", - help="Prefill service base URL (protocol + host[:port])", - ) - parser.add_argument( - "--decode-url", - type=str, - default="http://localhost:8200", - help="Decode service base URL (protocol + host[:port])", - ) - parser.add_argument( - "--kv-host", - type=str, - default="localhost", - help="Hostname or IP used by KV transfer (default: localhost)", - ) - parser.add_argument( - "--prefill-kv-port", - type=int, - default=14579, - help="Prefill KV port (default: 14579)", - ) - parser.add_argument( - "--decode-kv-port", - type=int, - default=14580, - help="Decode KV port (default: 14580)", - ) - - return parser.parse_args() - - -def main(): - """parse command line arguments""" - args = parse_args() - - # Initialize configuration using command line parameters - AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(total=args.timeout) - PREFILL_SERVICE_URL = args.prefill_url - DECODE_SERVICE_URL = args.decode_url - PORT = args.port - - PREFILL_KV_ADDR = f"{args.kv_host}:{args.prefill_kv_port}" - DECODE_KV_ADDR = f"{args.kv_host}:{args.decode_kv_port}" - - logger.info( - "Proxy resolved KV addresses -> prefill: %s, decode: %s", - PREFILL_KV_ADDR, - DECODE_KV_ADDR, - ) - - app = Quart(__name__) - - # Attach the configuration object to the application instance so helper - # coroutines can read the resolved backend URLs and timeouts without using - # globals. - app.config.update( - { - "AIOHTTP_TIMEOUT": AIOHTTP_TIMEOUT, - "PREFILL_SERVICE_URL": PREFILL_SERVICE_URL, - "DECODE_SERVICE_URL": DECODE_SERVICE_URL, - "PREFILL_KV_ADDR": PREFILL_KV_ADDR, - "DECODE_KV_ADDR": DECODE_KV_ADDR, - } - ) - - def _normalize_base_url(url: str) -> str: - """Remove any trailing slash so path joins behave predictably.""" - return url.rstrip("/") - - def _get_host_port(url: str) -> str: - """Return the hostname:port portion for logging and KV headers.""" - parsed = urlparse(url) - host = parsed.hostname or "localhost" - port = parsed.port - if port is None: - port = 80 if parsed.scheme == "http" else 443 - return f"{host}:{port}" - - PREFILL_BASE = _normalize_base_url(PREFILL_SERVICE_URL) - DECODE_BASE = _normalize_base_url(DECODE_SERVICE_URL) - KV_TARGET = _get_host_port(DECODE_SERVICE_URL) - - def _build_headers(request_id: str) -> dict[str, str]: - """Construct the headers expected by vLLM's P2P disagg connector.""" - headers: dict[str, str] = {"X-Request-Id": request_id, "X-KV-Target": KV_TARGET} - api_key = os.environ.get("OPENAI_API_KEY") - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - return headers - - async def _run_prefill( - request_path: str, - payload: dict, - headers: dict[str, str], - request_id: str, - ): - url = f"{PREFILL_BASE}{request_path}" - start_ts = time.perf_counter() - logger.info("[prefill] start request_id=%s url=%s", request_id, url) - try: - async with ( - aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session, - session.post(url=url, json=payload, headers=headers) as resp, - ): - if resp.status != 200: - error_text = await resp.text() - raise RuntimeError( - f"Prefill backend error {resp.status}: {error_text}" - ) - await resp.read() - logger.info( - "[prefill] done request_id=%s status=%s elapsed=%.2fs", - request_id, - resp.status, - time.perf_counter() - start_ts, - ) - except asyncio.TimeoutError as exc: - raise RuntimeError(f"Prefill service timeout at {url}") from exc - except aiohttp.ClientError as exc: - raise RuntimeError(f"Prefill service unavailable at {url}") from exc - - async def _stream_decode( - request_path: str, - payload: dict, - headers: dict[str, str], - request_id: str, - ): - url = f"{DECODE_BASE}{request_path}" - # Stream tokens from the decode service once the prefill stage has - # materialized KV caches on the target workers. - logger.info("[decode] start request_id=%s url=%s", request_id, url) - try: - async with ( - aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session, - session.post(url=url, json=payload, headers=headers) as resp, - ): - if resp.status != 200: - error_text = await resp.text() - logger.error( - "Decode backend error %s - %s", resp.status, error_text - ) - err_msg = ( - '{"error": "Decode backend error ' + str(resp.status) + '"}' - ) - yield err_msg.encode() - return - logger.info( - "[decode] streaming response request_id=%s status=%s", - request_id, - resp.status, - ) - async for chunk_bytes in resp.content.iter_chunked(1024): - yield chunk_bytes - logger.info("[decode] finished streaming request_id=%s", request_id) - except asyncio.TimeoutError: - logger.error("Decode service timeout at %s", url) - yield b'{"error": "Decode service timeout"}' - except aiohttp.ClientError as exc: - logger.error("Decode service error at %s: %s", url, exc) - yield b'{"error": "Decode service unavailable"}' - - async def process_request(): - """Process a single request through prefill and decode stages""" - try: - original_request_data = await request.get_json() - - # Create prefill request (max_tokens=1) - prefill_request = original_request_data.copy() - prefill_request["max_tokens"] = 1 - if "max_completion_tokens" in prefill_request: - prefill_request["max_completion_tokens"] = 1 - - # Execute prefill stage - # The request id encodes both KV socket addresses so the backend can - # shuttle tensors directly via NCCL once the prefill response - # completes. - request_id = ( - f"___prefill_addr_{PREFILL_KV_ADDR}___decode_addr_" - f"{DECODE_KV_ADDR}_{uuid.uuid4().hex}" - ) - - headers = _build_headers(request_id) - await _run_prefill(request.path, prefill_request, headers, request_id) - - # Execute decode stage and stream response - # Pass the unmodified user request so the decode phase can continue - # sampling with the already-populated KV cache. - generator = _stream_decode( - request.path, original_request_data, headers, request_id - ) - response = await make_response(generator) - response.timeout = None # Disable timeout for streaming response - return response - - except Exception: - logger.exception("Error processing request") - return Response( - response=b'{"error": "Internal server error"}', - status=500, - content_type="application/json", - ) - - @app.route("/v1/completions", methods=["POST"]) - async def handle_request(): - """Handle incoming API requests with concurrency and rate limiting""" - try: - return await process_request() - except asyncio.CancelledError: - logger.warning("Request cancelled") - return Response( - response=b'{"error": "Request cancelled"}', - status=503, - content_type="application/json", - ) - - # Start the Quart server with host can be set to 0.0.0.0 - app.run(port=PORT) - - -if __name__ == "__main__": - main() diff --git a/benchmarks/disagg_benchmarks/round_robin_proxy.py b/benchmarks/disagg_benchmarks/round_robin_proxy.py deleted file mode 100644 index b1df2f255822..000000000000 --- a/benchmarks/disagg_benchmarks/round_robin_proxy.py +++ /dev/null @@ -1,63 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import asyncio -import itertools - -import aiohttp -from aiohttp import web - - -class RoundRobinProxy: - def __init__(self, target_ports): - self.target_ports = target_ports - self.port_cycle = itertools.cycle(self.target_ports) - - async def handle_request(self, request): - target_port = next(self.port_cycle) - target_url = f"http://localhost:{target_port}{request.path_qs}" - - async with aiohttp.ClientSession() as session: - try: - # Forward the request - async with session.request( - method=request.method, - url=target_url, - headers=request.headers, - data=request.content, - ) as response: - # Start sending the response - resp = web.StreamResponse( - status=response.status, headers=response.headers - ) - await resp.prepare(request) - - # Stream the response content - async for chunk in response.content.iter_any(): - await resp.write(chunk) - - await resp.write_eof() - return resp - - except Exception as e: - return web.Response(text=f"Error: {str(e)}", status=500) - - -async def main(): - proxy = RoundRobinProxy([8100, 8200]) - app = web.Application() - app.router.add_route("*", "/{path:.*}", proxy.handle_request) - - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, "localhost", 8000) - await site.start() - - print("Proxy server started on http://localhost:8000") - - # Keep the server running - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/benchmarks/disagg_benchmarks/visualize_benchmark_results.py b/benchmarks/disagg_benchmarks/visualize_benchmark_results.py deleted file mode 100644 index 74fa56d076cf..000000000000 --- a/benchmarks/disagg_benchmarks/visualize_benchmark_results.py +++ /dev/null @@ -1,47 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import json - -import matplotlib.pyplot as plt -import pandas as pd - -if __name__ == "__main__": - data = [] - for name in ["disagg_prefill", "chunked_prefill"]: - for qps in [2, 4, 6, 8]: - with open(f"results/{name}-qps-{qps}.json") as f: - x = json.load(f) - x["name"] = name - x["qps"] = qps - data.append(x) - - df = pd.DataFrame.from_dict(data) - dis_df = df[df["name"] == "disagg_prefill"] - chu_df = df[df["name"] == "chunked_prefill"] - - plt.style.use("bmh") - plt.rcParams["font.size"] = 20 - - for key in [ - "mean_ttft_ms", - "median_ttft_ms", - "p99_ttft_ms", - "mean_itl_ms", - "median_itl_ms", - "p99_itl_ms", - ]: - fig, ax = plt.subplots(figsize=(11, 7)) - plt.plot( - dis_df["qps"], dis_df[key], label="disagg_prefill", marker="o", linewidth=4 - ) - plt.plot( - chu_df["qps"], chu_df[key], label="chunked_prefill", marker="o", linewidth=4 - ) - ax.legend() - - ax.set_xlabel("QPS") - ax.set_ylabel(key) - ax.set_ylim(bottom=0) - fig.savefig(f"results/{key}.png") - plt.close(fig) diff --git a/benchmarks/kernels/benchmark_flydsl_moe_w4a16.py b/benchmarks/kernels/benchmark_flydsl_moe_w4a16.py new file mode 100644 index 000000000000..09a01b301bea --- /dev/null +++ b/benchmarks/kernels/benchmark_flydsl_moe_w4a16.py @@ -0,0 +1,277 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Copyright (c) 2025 FlyDSL Project Contributors + +import json +import os + +import torch +from aiter.test_common import run_perftest + +from vllm.model_executor.layers.fused_moe import fused_experts +from vllm.model_executor.layers.fused_moe.activation import MoEActivation +from vllm.model_executor.layers.fused_moe.config import ( + int4_w4a16_moe_quant_config, +) +from vllm.model_executor.layers.fused_moe.fused_flydsl_moe import fused_flydsl_moe +from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe import ( # noqa: E501 + compressed_tensors_moe_w4a16_flydsl, +) +from vllm.utils.platform_utils import get_device_name_as_file_name + +RoutingBuffers = tuple[ + torch.Tensor, # sorted_token_ids + torch.Tensor, # sorted_weights + torch.Tensor, # sorted_expert_ids + torch.Tensor, # num_valid_ids (shape [1], i32) + int, # sorted_size + int, # blocks +] + +MODEL_PARAMS_TO_TUNE = [ + # (num_experts, inter_dim, hidden_size, topk) + (384, 256, 7168, 8), # Kimi K2.5 TP=8 + (384, 512, 7168, 8), # Kimi K2.5 TP=4 +] + +NUM_TOKENS_TO_TUNE = [ + 1, + 2, + 4, + 8, + 16, + 24, + 32, + 48, + 64, + 128, + 256, + 512, + 1024, + 2048, + 4096, + 8192, +] + +TILE_M_SEARCH_SPACE = [16, 32, 64, 128, 256] +TILE_N_SEARCH_SPACE = [16, 32, 64, 128, 256] +TILE_K_SEARCH_SPACE = [16, 32, 64, 128, 256, 512] +TILE_N2_SEARCH_SPACE = [16, 32, 64, 128, 256] +TILE_K2_SEARCH_SPACE = [16, 32, 64, 128, 256, 512] + +TILE_CONFIGS = [] +for tile_m in TILE_M_SEARCH_SPACE: + for tile_n in TILE_N_SEARCH_SPACE: + for tile_k in TILE_K_SEARCH_SPACE: + for tile_n2 in TILE_N2_SEARCH_SPACE: + for tile_k2 in TILE_K2_SEARCH_SPACE: + TILE_CONFIGS.append( + { + "tile_m": tile_m, + "tile_n": tile_n, + "tile_k": tile_k, + "tile_n2": tile_n2, + "tile_k2": tile_k2, + } + ) + + +def tune_flydsl_moe_w4a16( + device: str = "cuda", num_iters: int = 100, num_warmup: int = 10 +): + packed_factor = 8 + w13_num_shards = 2 + params_dtype = torch.bfloat16 + group_size = 32 + scale_factor = 0.01 + + for model_params in MODEL_PARAMS_TO_TUNE: + num_experts = model_params[0] + inter_dim = model_params[1] + hidden_size = model_params[2] + topk = model_params[3] + print( + f"\nTuning: num_experts={num_experts}, inter_dim={inter_dim}, " + f"hidden_size={hidden_size}, topk={topk}...\n" + ) + + w2_scales_size = inter_dim + num_groups_w2 = w2_scales_size // group_size + num_groups_w13 = hidden_size // group_size + + w13_weight = torch.randint( + 0, + 255, + (num_experts, hidden_size // packed_factor, w13_num_shards * inter_dim), + dtype=torch.int32, + device=device, + ) + + w2_weight = torch.randint( + 0, + 255, + (num_experts, inter_dim // packed_factor, hidden_size), + dtype=torch.int32, + device=device, + ) + w13_scale = scale_factor * torch.randn( + num_experts, + num_groups_w13, + w13_num_shards * inter_dim, + dtype=params_dtype, + device=device, + ) + w2_scale = scale_factor * torch.randn( + num_experts, num_groups_w2, hidden_size, dtype=params_dtype, device=device + ) + + w13 = w13_weight + w13 = compressed_tensors_moe_w4a16_flydsl._gptq_int32_to_flydsl_packed(w13) + w13 = w13.view(-1).contiguous() + + w2 = w2_weight + w2 = compressed_tensors_moe_w4a16_flydsl._gptq_int32_to_flydsl_packed(w2) + w2 = w2.view(-1).contiguous() + + w13_scale_flydsl = w13_scale + w2_scale_flydsl = w2_scale + + if group_size > 0 and w13_scale.dim() == 3 and w13_scale.shape[1] > 1: + E, G, N = w13_scale.shape + w13_scale_flydsl = ( + w13_scale_flydsl.view(E, G // 2, 2, N) + .permute(0, 1, 3, 2) + .contiguous() + .view(-1) + .contiguous() + ) + elif w13_scale.dim() == 3 and w13_scale.shape[1] == 1: + w13_scale_flydsl = w13_scale_flydsl.squeeze(1) + + if group_size > 0 and w2_scale.dim() == 3 and w2_scale.shape[1] > 1: + E, G, N = w2_scale.shape + w2_scale_flydsl = ( + w2_scale_flydsl.view(E, G // 2, 2, N) + .permute(0, 1, 3, 2) + .contiguous() + .view(-1) + .contiguous() + ) + elif w2_scale.dim() == 3 and w2_scale.shape[1] == 1: + w2_scale_flydsl = w2_scale_flydsl.squeeze(1) + + w13_scale_flydsl = w13_scale_flydsl.contiguous() + w2_scale_flydsl = w2_scale_flydsl.contiguous() + + w13.is_shuffled = True + w2.is_shuffled = True + + w13_weight_scale = w13_scale.transpose(1, 2).contiguous() + w2_weight_scale = w2_scale.transpose(1, 2).contiguous() + w13_weight_packed = w13_weight.transpose(1, 2).contiguous().view(torch.uint8) + w2_weight_packed = w2_weight.transpose(1, 2).contiguous().view(torch.uint8) + + moe_quant_config = int4_w4a16_moe_quant_config( + w1_scale=w13_weight_scale, + w2_scale=w2_weight_scale, + w1_zp=None, + w2_zp=None, + block_shape=[0, group_size], + ) + + tuned_config = {} + + for num_tokens in NUM_TOKENS_TO_TUNE: + score = torch.rand( + (num_tokens, num_experts), device=device, dtype=torch.float32 + ) + topk_vals, topk_ids = torch.topk(score, k=topk, dim=1) + topk_weights = torch.softmax(topk_vals, dim=1).to(torch.float32) + x = torch.randn( + (num_tokens, hidden_size), dtype=torch.bfloat16, device=device + ) + us_best = float("inf") + for tile_config in TILE_CONFIGS: + try: + tile_m = tile_config["tile_m"] + tile_n = tile_config["tile_n"] + tile_k = tile_config["tile_k"] + tile_n2 = tile_config["tile_n2"] + tile_k2 = tile_config["tile_k2"] + + model_dim = x.shape[1] + assert model_dim % 64 == 0 + assert model_dim % tile_k == 0 + assert inter_dim % tile_n == 0 + assert model_dim % tile_n2 == 0 + assert inter_dim % tile_k2 == 0 + assert ((tile_m * tile_k2) % 256) == 0 + bytes_per_thread_x = (tile_m * tile_k2) // 256 + assert (bytes_per_thread_x % 4) == 0 + + out, _us = run_perftest( + fused_flydsl_moe, + x, + w13, + w2, + num_experts, + inter_dim, + topk_weights, + topk_ids, + num_iters=num_iters, + num_warmup=num_warmup, + w1_scale=w13_scale_flydsl, + w2_scale=w2_scale_flydsl, + topk=topk_weights.shape[-1], + group_size=group_size, + doweight_stage1=False, + scale_is_bf16=True, + config=tile_config, + ) + torch.accelerator.synchronize() + except Exception: + torch.accelerator.synchronize() + continue + else: + us = _us.item() + if us < us_best: + out_ref = fused_experts( + x, + w13_weight_packed, + w2_weight_packed, + topk_weights=topk_weights, + topk_ids=topk_ids, + activation=MoEActivation.SILU, + apply_router_weight_on_input=False, + global_num_experts=num_experts, + expert_map=None, + quant_config=moe_quant_config, + ) + try: + assert torch.allclose(out, out_ref, atol=0.5, rtol=0.1) + except Exception: + continue + else: + print( + f"For [num_tokens={num_tokens}, num_experts={num_experts}, " # noqa: E501 + f"inter_dim={inter_dim}] found new best " # noqa: E501 + f"config={tile_config}, us={us:0.3f}" + ) + us_best = us + tuned_config[str(num_tokens)] = tile_config + device_name = get_device_name_as_file_name() + tuned_config_file_name = ( + f"E={num_experts},N={inter_dim},device_name={device_name}," + f"dtype=int4_w4a16,backend=flydsl.json" + ) + tuner_dir_path = os.path.dirname(os.path.realpath(__file__)) + store_path = os.path.join(tuner_dir_path, tuned_config_file_name) + with open(store_path, "w") as f: + json.dump(tuned_config, f, indent=4) + print( + f"\nTuned config for num_tokens={num_tokens} was stored at {store_path}\n" # noqa: E501 + ) + + +if __name__ == "__main__": + tune_flydsl_moe_w4a16(device="cuda") diff --git a/benchmarks/kernels/benchmark_fused_collective.py b/benchmarks/kernels/benchmark_fused_collective.py index 36cbd715f18d..c999c16021bf 100644 --- a/benchmarks/kernels/benchmark_fused_collective.py +++ b/benchmarks/kernels/benchmark_fused_collective.py @@ -80,13 +80,17 @@ 2: 64 * MiB, # 64MB 4: 64 * MiB, # 64MB 8: 64 * MiB, # 64MB + 16: 64 * MiB, # 64MB (multi-node) } # Global workspace tensors for FlashInfer (keyed by backend name) _FI_WORKSPACES: dict = {} -# Backends to benchmark -FLASHINFER_BACKENDS = ["trtllm", "mnnvl"] +# Backends to benchmark. trtllm is single-node only and can hang cross-node, so +# multi-node sweeps can restrict to mnnvl via FI_BACKENDS=mnnvl. +FLASHINFER_BACKENDS = [ + b for b in os.environ.get("FI_BACKENDS", "trtllm,mnnvl").split(",") if b +] def setup_flashinfer_workspace( @@ -995,7 +999,10 @@ def main(): rank = int(os.environ["RANK"]) world_size = int(os.environ["WORLD_SIZE"]) - device = torch.device(f"cuda:{rank}") + # Use LOCAL_RANK for the device so multi-node runs (global rank >= GPUs per + # node) map to a valid local GPU; falls back to global rank single-node. + local_rank = int(os.environ.get("LOCAL_RANK", rank)) + device = torch.device(f"cuda:{local_rank}") torch.accelerator.set_device_index(device) torch.set_default_device(device) diff --git a/benchmarks/kernels/benchmark_moe.py b/benchmarks/kernels/benchmark_moe.py index 4463a23772ee..1531cc96920e 100644 --- a/benchmarks/kernels/benchmark_moe.py +++ b/benchmarks/kernels/benchmark_moe.py @@ -250,7 +250,7 @@ def run(): num_experts=num_experts, experts_per_token=topk, hidden_dim=hidden_size, - intermediate_size_per_partition=shard_intermediate_size, + intermediate_size=shard_intermediate_size, num_local_experts=num_experts, num_logical_experts=num_experts, activation=MoEActivation.SILU, @@ -271,7 +271,6 @@ def run(): moe_config=moe_config, quant_config=quant_config, ), - inplace=not disable_inplace(), ) with override_config(config): @@ -279,7 +278,6 @@ def run(): x, input_gating, topk, renormalize=not use_deep_gemm ) - inplace = not disable_inplace() if use_deep_gemm: return deep_gemm_experts.apply( x, @@ -298,7 +296,6 @@ def run(): w2, topk_weights, topk_ids, - inplace=inplace, quant_config=quant_config, ) @@ -394,16 +391,19 @@ def get_configs_compute_bound(use_fp16, block_quant_shape) -> list[dict[str, int config = dict(zip(keys, config_values)) configs.append(config) - # Remove configs that are not compatible with fp8 block quantization - # BLOCK_SIZE_K must be a multiple of block_k - # BLOCK_SIZE_N must be a multiple of block_n + # Drop configs incompatible with fp8 block quantization. A tile must align + # to the quant-block scale grid, i.e. tile and block must divide one + # another. The kernel indexes scales per element (offs_bn // group_n, + # k_start // group_k), so a tile narrower than the block (e.g. N=64 with + # block_n=128) is valid -- and often faster at small batch. An exact + # multiple was required before, which dropped those smaller tiles entirely. if block_quant_shape is not None and not use_fp16: block_n, block_k = block_quant_shape[0], block_quant_shape[1] for config in configs[:]: - if ( - config["BLOCK_SIZE_K"] % block_k != 0 - or config["BLOCK_SIZE_N"] % block_n != 0 - ): + bn, bk = config["BLOCK_SIZE_N"], config["BLOCK_SIZE_K"] + n_aligned = bn % block_n == 0 or block_n % bn == 0 + k_aligned = bk % block_k == 0 or block_k % bk == 0 + if not (n_aligned and k_aligned): configs.remove(config) return configs @@ -795,6 +795,12 @@ def get_model_params(config): topk = text_config.num_experts_per_tok intermediate_size = text_config.moe_intermediate_size hidden_size = text_config.hidden_size + elif architecture == "DiffusionGemmaForBlockDiffusion": + text_config = config.get_text_config() + E = text_config.num_experts + topk = text_config.top_k_experts + intermediate_size = text_config.moe_intermediate_size + hidden_size = text_config.hidden_size elif architecture == "HunYuanMoEV1ForCausalLM": E = config.num_experts topk = config.moe_topk[0] diff --git a/benchmarks/kernels/benchmark_paged_attention.py b/benchmarks/kernels/benchmark_paged_attention.py index b6a0b7ad8cac..f4249d6270aa 100644 --- a/benchmarks/kernels/benchmark_paged_attention.py +++ b/benchmarks/kernels/benchmark_paged_attention.py @@ -19,13 +19,11 @@ logger = init_logger(__name__) NUM_BLOCKS = 128 * 1024 -PARTITION_SIZE = 512 PARTITION_SIZE_ROCM = 256 @torch.inference_mode() def main( - version: str, num_seqs: int, seq_len: int, num_query_heads: int, @@ -82,27 +80,20 @@ def main( # Prepare for the paged attention kernel. output = torch.empty_like(query) - if version == "v2": - if current_platform.is_rocm(): - global PARTITION_SIZE - if not args.custom_paged_attn and not current_platform.is_navi(): - PARTITION_SIZE = 1024 - else: - PARTITION_SIZE = PARTITION_SIZE_ROCM - num_partitions = (max_seq_len + PARTITION_SIZE - 1) // PARTITION_SIZE - tmp_output = torch.empty( - size=(num_seqs, num_query_heads, num_partitions, head_size), - dtype=output.dtype, - device=output.device, - ) - exp_sums = torch.empty( - size=(num_seqs, num_query_heads, num_partitions), - dtype=torch.float32, - device=output.device, - ) - max_logits = torch.empty_like(exp_sums) - - def run_cuda_benchmark(num_iters: int, profile: bool = False) -> float: + num_partitions = (max_seq_len + PARTITION_SIZE_ROCM - 1) // PARTITION_SIZE_ROCM + tmp_output = torch.empty( + size=(num_seqs, num_query_heads, num_partitions, head_size), + dtype=output.dtype, + device=output.device, + ) + exp_sums = torch.empty( + size=(num_seqs, num_query_heads, num_partitions), + dtype=torch.float32, + device=output.device, + ) + max_logits = torch.empty_like(exp_sums) + + def run_benchmark(num_iters: int, profile: bool = False) -> float: torch.accelerator.synchronize() if profile: torch.cuda.cudart().cudaProfilerStart() @@ -112,67 +103,26 @@ def run_cuda_benchmark(num_iters: int, profile: bool = False) -> float: k_scale = v_scale = torch.tensor(1.0, dtype=torch.float32, device=device) for _ in range(num_iters): - if version == "v1": - ops.paged_attention_v1( - output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - ) - elif version == "v2": - if not args.custom_paged_attn: - ops.paged_attention_v2( - output, - exp_sums, - max_logits, - tmp_output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - ) - else: - ops.paged_attention_rocm( - output, - exp_sums, - max_logits, - tmp_output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - None, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - ) - else: - raise ValueError(f"Invalid version: {version}") + ops.paged_attention_rocm( + output, + exp_sums, + max_logits, + tmp_output, + query, + key_cache, + value_cache, + num_kv_heads, + scale, + block_tables, + seq_lens, + None, + block_size, + max_seq_len, + alibi_slopes, + kv_cache_dtype, + k_scale, + v_scale, + ) torch.accelerator.synchronize() end_time = time.perf_counter() @@ -182,7 +132,6 @@ def run_cuda_benchmark(num_iters: int, profile: bool = False) -> float: # Warmup. print("Warming up...") - run_benchmark = run_cuda_benchmark run_benchmark(num_iters=3, profile=False) # Benchmark. @@ -195,12 +144,13 @@ def run_cuda_benchmark(num_iters: int, profile: bool = False) -> float: if __name__ == "__main__": logger.warning( - "This script benchmarks the paged attention kernel. " + "This script benchmarks the ROCm paged attention kernel. " "By default this is no longer used in vLLM inference." ) + if not current_platform.is_rocm(): + raise RuntimeError("This benchmark requires the ROCm platform.") parser = FlexibleArgumentParser(description="Benchmark the paged attention kernel.") - parser.add_argument("--version", type=str, choices=["v1", "v2"], default="v2") parser.add_argument("--batch-size", type=int, default=8) parser.add_argument("--seq-len", type=int, default=4096) parser.add_argument("--num-query-heads", type=int, default=64) @@ -208,7 +158,7 @@ def run_cuda_benchmark(num_iters: int, profile: bool = False) -> float: parser.add_argument( "--head-size", type=int, - choices=[64, 80, 96, 112, 120, 128, 192, 256], + choices=[64, 128], default=128, ) parser.add_argument("--block-size", type=int, choices=[16, 32], default=16) @@ -224,11 +174,7 @@ def run_cuda_benchmark(num_iters: int, profile: bool = False) -> float: choices=["auto", "fp8", "fp8_e5m2", "fp8_e4m3"], default="auto", help="Data type for kv cache storage. If 'auto', will use model " - "data type. CUDA 11.8+ supports fp8 (=fp8_e4m3) and fp8_e5m2. " - "ROCm (AMD GPU) supports fp8 (=fp8_e4m3)", - ) - parser.add_argument( - "--custom-paged-attn", action="store_true", help="Use custom paged attention" + "data type. ROCm (AMD GPU) supports fp8 (=fp8_e4m3)", ) args = parser.parse_args() print(args) @@ -236,7 +182,6 @@ def run_cuda_benchmark(num_iters: int, profile: bool = False) -> float: if args.num_query_heads % args.num_kv_heads != 0: raise ValueError("num_query_heads must be divisible by num_kv_heads") main( - version=args.version, num_seqs=args.batch_size, seq_len=args.seq_len, num_query_heads=args.num_query_heads, diff --git a/benchmarks/kernels/benchmark_w8a8_block_fp8.py b/benchmarks/kernels/benchmark_w8a8_block_fp8.py index 36dce1b6388a..590d4cfdc6d9 100644 --- a/benchmarks/kernels/benchmark_w8a8_block_fp8.py +++ b/benchmarks/kernels/benchmark_w8a8_block_fp8.py @@ -19,6 +19,7 @@ from vllm.platforms import current_platform from vllm.triton_utils import triton from vllm.utils.argparse_utils import FlexibleArgumentParser +from vllm.utils.platform_utils import get_device_name_as_file_name mp.set_start_method("spawn", force=True) @@ -264,7 +265,7 @@ def save_configs( input_type="fp8", ) -> None: os.makedirs(save_path, exist_ok=True) - device_name = current_platform.get_device_name().replace(" ", "_") + device_name = get_device_name_as_file_name() json_file_name = ( f"N={N},K={K},device_name={device_name},dtype={input_type}_w8a8," f"block_shape=[{block_n},{block_k}].json" diff --git a/benchmarks/kernels/cpu/benchmark_cpu_fused_moe.py b/benchmarks/kernels/cpu/benchmark_cpu_fused_moe.py index aff443083a55..f5a5ed1dc55d 100644 --- a/benchmarks/kernels/cpu/benchmark_cpu_fused_moe.py +++ b/benchmarks/kernels/cpu/benchmark_cpu_fused_moe.py @@ -7,6 +7,7 @@ import numpy as np import torch +from vllm.platforms import CpuArchEnum, current_platform from vllm.utils.argparse_utils import FlexibleArgumentParser from vllm.utils.torch_utils import set_random_seed @@ -14,17 +15,15 @@ try: from vllm._custom_ops import cpu_fused_moe, cpu_prepack_moe_weight except (ImportError, AttributeError) as e: - print("ERROR: CPU fused MoE operations are not available on this platform.") - print("This benchmark requires x86 CPU with proper vLLM CPU extensions compiled.") - print( - "The cpu_fused_moe kernel is typically available on Linux x86_64 " - "with AVX2/AVX512." - ) print(f"Import error: {e}") sys.exit(1) # ISA selection following test_cpu_fused_moe.py pattern -ISA_CHOICES = ["amx", "vec"] if torch.cpu._is_amx_tile_supported() else ["vec"] +ISA_CHOICES = ["vec"] +if torch.cpu._is_amx_tile_supported(): + ISA_CHOICES.append("amx") +if current_platform.get_cpu_architecture() == CpuArchEnum.ARM: + ISA_CHOICES.append("neon") @torch.inference_mode() @@ -145,7 +144,7 @@ def run_benchmark(iters: int) -> list[float]: "--isa", type=str, choices=ISA_CHOICES, - default=ISA_CHOICES[0], + default="vec", help=f"ISA to use (available: {ISA_CHOICES})", ) parser.add_argument("--seed", type=int, default=0) diff --git a/benchmarks/kv_cache_watermark.sh b/benchmarks/kv_cache_watermark.sh new file mode 100755 index 000000000000..258afa9fce1e --- /dev/null +++ b/benchmarks/kv_cache_watermark.sh @@ -0,0 +1,248 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# +# Reproducible demonstration of the KV cache watermark (`--watermark`) for +# reducing preemption thrashing. +# +# The watermark is the fraction of total KV cache blocks the scheduler keeps +# free when admitting a waiting/preempted request into the running queue. +# +# Why this workload triggers thrashing: +# Requests are admitted based on the KV cache they need *at admission time*. +# With `--scheduler-reserve-full-isl` (default) the input length is reserved up +# front, but the *output* length is unknown and unreserved. A decode-heavy +# workload (output >> input) at high concurrency therefore over-admits while +# requests are short, then runs out of KV cache as they all grow during decode +# -> the scheduler preempts (recompute) recently-admitted requests, re-prefills +# them later, and repeats. The watermark keeps a block of KV cache free so +# running requests can grow into it instead of triggering this churn. +# +# This script launches `vllm serve` under a deliberately KV-constrained config +# and a decode-heavy workload, sweeping the watermark across several values, and +# reports the preemption count (scraped from /metrics), throughput, and latency +# percentiles for each. It then plots the results. +# +# Default workload: concurrency 200, input ~300 tokens, output ~4000 tokens +# (+/- 20% variance), sized to run each config for ~5 minutes. +# +# Usage: +# benchmarks/kv_cache_watermark.sh +# MODEL=Qwen/Qwen2.5-14B-Instruct TP=2 benchmarks/kv_cache_watermark.sh +# +# Run inside the vLLM virtualenv (so `vllm` and `python` resolve to it). +set -euo pipefail + +# ---- Config (override via environment) ------------------------------------- +MODEL=${MODEL:-Qwen/Qwen2.5-7B-Instruct} +TP=${TP:-1} +PORT=${PORT:-8000} +URL="http://127.0.0.1:${PORT}" +# Constrain the KV cache to a *near-critical* size: large enough that the engine +# can run stably, but small enough that greedy over-admission tips it into +# preemption thrashing. (Independent of GPU size, so the demo is reproducible.) +# At the default workload this fits ~1.5x the mean concurrent KV demand. +KV_CACHE_MEMORY_GB=${KV_CACHE_MEMORY_GB:-16} +MAX_MODEL_LEN=${MAX_MODEL_LEN:-8192} +MAX_NUM_SEQS=${MAX_NUM_SEQS:-256} +# Optional weight loader (e.g. fastsafetensors on the GCP cluster). +LOAD_FORMAT=${LOAD_FORMAT:-auto} +# Decode-heavy workload: moderate input, long output, with length variance. The +# long output means preempted requests have generated a lot before eviction, so +# resuming them re-prefills a long sequence (high recomputation cost). +INPUT_LEN=${INPUT_LEN:-1000} +OUTPUT_LEN=${OUTPUT_LEN:-5000} +RANGE_RATIO=${RANGE_RATIO:-0.2} +CONCURRENCY=${CONCURRENCY:-128} +# Enough prompts to keep each config saturated for ~5+ minutes. +NUM_PROMPTS=${NUM_PROMPTS:-450} +OUTDIR=${OUTDIR:-./watermark_bench_results} +# Watermark fractions compared. "label value" per line; value=0 disables it. +CONFIGS=${CONFIGS:-"off 0 +w0.02 0.02 +w0.05 0.05 +w0.10 0.10 +w0.15 0.15"} + +KV_CACHE_MEMORY_BYTES=$((KV_CACHE_MEMORY_GB * 1024 * 1024 * 1024)) +mkdir -p "$OUTDIR" + +SERVER_PID="" +cleanup() { [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true; } +trap cleanup EXIT + +scrape_preemptions() { + # Sum the vllm:num_preemptions_total counter across engines. + python - "${URL}/metrics" <<'PY' +import sys, urllib.request +total = 0.0 +try: + body = urllib.request.urlopen(sys.argv[1], timeout=10).read().decode("utf-8", "replace") + for line in body.splitlines(): + if line.startswith("vllm:num_preemptions_total"): + total += float(line.rsplit(" ", 1)[-1]) +except Exception as e: # noqa: BLE001 + print(f"scrape error: {e}", file=sys.stderr) +print(int(total)) +PY +} + +wait_for_server() { + for _ in $(seq 1 300); do + if curl -s "${URL}/health" >/dev/null 2>&1; then return 0; fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "ERROR: server process exited during startup" >&2; return 1 + fi + sleep 5 + done + echo "ERROR: server did not become ready" >&2; return 1 +} + +run_one() { + local label=$1 watermark=$2 + echo + echo "==================== watermark: ${label} (${watermark}) ====================" + vllm serve "$MODEL" \ + --tensor-parallel-size "$TP" \ + --load-format "$LOAD_FORMAT" \ + --kv-cache-memory-bytes "$KV_CACHE_MEMORY_BYTES" \ + --max-model-len "$MAX_MODEL_LEN" \ + --max-num-seqs "$MAX_NUM_SEQS" \ + --no-enable-prefix-caching \ + --watermark "$watermark" \ + --port "$PORT" >"${OUTDIR}/serve_${label}.log" 2>&1 & + SERVER_PID=$! + wait_for_server + sleep 5 + + local pre post + pre=$(scrape_preemptions) + vllm bench serve \ + --backend vllm \ + --base-url "$URL" \ + --model "$MODEL" \ + --dataset-name random \ + --random-input-len "$INPUT_LEN" \ + --random-output-len "$OUTPUT_LEN" \ + --random-range-ratio "$RANGE_RATIO" \ + --ignore-eos \ + --num-prompts "$NUM_PROMPTS" \ + --max-concurrency "$CONCURRENCY" \ + --percentile-metrics "ttft,tpot,itl,e2el" \ + --metric-percentiles "50,90,99" \ + --save-result \ + --result-dir "$OUTDIR" \ + --result-filename "bench_${label}.json" + post=$(scrape_preemptions) + echo "${label} ${watermark} $((post - pre))" >>"${OUTDIR}/preemptions.txt" + + kill "$SERVER_PID" 2>/dev/null || true + for _ in $(seq 1 60); do curl -s "${URL}/health" >/dev/null 2>&1 || break; sleep 2; done + SERVER_PID="" + sleep 10 +} + +: >"${OUTDIR}/preemptions.txt" +while read -r label watermark; do + [[ -z "${label:-}" ]] && continue + run_one "$label" "$watermark" +done <<<"$CONFIGS" + +echo +echo "==================== summary ====================" +python - "$OUTDIR" <<'PY' +import json, os, sys +outdir = sys.argv[1] +pre = {} +order = [] +for line in open(os.path.join(outdir, "preemptions.txt")): + label, watermark, n = line.split() + pre[label] = (float(watermark), int(n)) + order.append(label) + +def g(d, *names): + for n in names: + if d.get(n) is not None: + return d[n] + return float("nan") + +cols = ["watermark", "frac", "preempt", "out_tok/s", "req/s", + "TTFT_p50", "TTFT_p99", "ITL_p99", "E2EL_p50"] +print(" ".join(f"{c:>10}" for c in cols)) +rows = [] +for label in order: + watermark, n = pre[label] + d = json.load(open(os.path.join(outdir, f"bench_{label}.json"))) + rows.append(dict( + label=label, watermark=watermark, preempt=n, + out_tok_s=g(d, "output_throughput"), + req_s=g(d, "request_throughput"), + ttft_p50=g(d, "p50_ttft_ms", "median_ttft_ms"), + ttft_p99=g(d, "p99_ttft_ms"), + itl_p99=g(d, "p99_itl_ms"), + e2el_p50=g(d, "p50_e2el_ms", "median_e2el_ms"), + )) + print(" ".join(f"{str(v):>10}" for v in [ + label, watermark, n, + f"{rows[-1]['out_tok_s']:.0f}", + f"{rows[-1]['req_s']:.3f}", + f"{rows[-1]['ttft_p50']/1000:.2f}", + f"{rows[-1]['ttft_p99']/1000:.2f}", + f"{rows[-1]['itl_p99']:.2f}", + f"{rows[-1]['e2el_p50']/1000:.1f}", + ])) +print("\n(TTFT/E2EL in seconds; ITL in ms. Lower preempt is better.)") + +# ---- Plot ------------------------------------------------------------------- +try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt +except Exception as e: # noqa: BLE001 + print(f"\n(skip plot: matplotlib unavailable: {e})") + sys.exit(0) + +x = [r["watermark"] for r in rows] +xt = [f"{r['watermark']:g}\n({r['label']})" for r in rows] +idx = list(range(len(rows))) + +fig, axes = plt.subplots(2, 2, figsize=(12, 8)) +fig.suptitle( + f"KV cache watermark sweep — {os.path.basename(os.path.abspath(outdir))}", + fontsize=12, +) + +ax = axes[0][0] +ax.bar(idx, [r["preempt"] for r in rows], color="tab:red") +ax.set_title("Preemptions (lower is better)") +ax.set_ylabel("preemptions") +ax.set_xticks(idx); ax.set_xticklabels(xt) + +ax = axes[0][1] +ax.plot(idx, [r["out_tok_s"] for r in rows], "o-", color="tab:green") +ax.set_title("Output throughput (higher is better)") +ax.set_ylabel("tokens/s") +ax.set_xticks(idx); ax.set_xticklabels(xt) + +ax = axes[1][0] +ax.plot(idx, [r["itl_p99"] for r in rows], "o-", color="tab:blue") +ax.set_title("Inter-token latency p99 (lower is better)") +ax.set_ylabel("ITL p99 (ms)") +ax.set_xlabel("watermark fraction") +ax.set_xticks(idx); ax.set_xticklabels(xt) + +ax = axes[1][1] +ax.plot(idx, [r["ttft_p50"] / 1000 for r in rows], "o-", label="TTFT p50") +ax.plot(idx, [r["ttft_p99"] / 1000 for r in rows], "o-", label="TTFT p99") +ax.plot(idx, [r["e2el_p50"] / 1000 for r in rows], "o-", label="E2EL p50") +ax.set_title("Latency (lower is better)") +ax.set_ylabel("seconds") +ax.set_xlabel("watermark fraction") +ax.set_xticks(idx); ax.set_xticklabels(xt) +ax.legend() + +fig.tight_layout(rect=(0, 0, 1, 0.95)) +out_png = os.path.join(outdir, "watermark_results.png") +fig.savefig(out_png, dpi=120) +print(f"\nWrote plot: {out_png}") +PY diff --git a/benchmarks/multi_turn/benchmark_serving_multi_turn.py b/benchmarks/multi_turn/benchmark_serving_multi_turn.py index 2f56099c66fd..5a60d9c66883 100644 --- a/benchmarks/multi_turn/benchmark_serving_multi_turn.py +++ b/benchmarks/multi_turn/benchmark_serving_multi_turn.py @@ -65,6 +65,32 @@ class RequestArgs(NamedTuple): limit_min_tokens: int # Use negative value for no limit limit_max_tokens: int # Use negative value for no limit timeout_sec: int + send_conversation_id: bool + headers: dict[str, str] + + +def parse_custom_header(header: str) -> tuple[str, str]: + separators = (":", "=") + for separator in separators: + if separator in header: + key, value = header.split(separator, 1) + key = key.strip() + value = value.strip() + if key: + return key, value + break + raise argparse.ArgumentTypeError( + "Headers must be provided as 'Header-Name: value' or 'Header-Name=value'" + ) + + +def build_request_headers( + api_key: str | None, custom_headers: list[tuple[str, str]] | None +) -> dict[str, str]: + headers = dict(custom_headers or []) + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers class BenchmarkArgs(NamedTuple): @@ -218,12 +244,11 @@ async def send_request( max_tokens: int | None = None, timeout_sec: int = 120, conversation_id: str | None = None, + headers: dict[str, str] | None = None, ) -> ServerResponse: payload = { "model": model, "messages": messages, - "seed": 0, - "temperature": 0.0, } if conversation_id is not None: @@ -233,13 +258,17 @@ async def send_request( payload["stream"] = True payload["stream_options"] = {"include_usage": False} - if min_tokens is not None: - payload["min_tokens"] = min_tokens + # if min_tokens is not None: + # payload["min_tokens"] = min_tokens if max_tokens is not None: payload["max_tokens"] = max_tokens - headers = {"Content-Type": "application/json"} + request_headers = {"Content-Type": "application/json"} + if conversation_id is not None: + request_headers["X-Session-ID"] = str(conversation_id) + if headers is not None: + request_headers.update(headers) # Calculate the timeout for the request if max_tokens is not None: @@ -265,7 +294,7 @@ async def send_request( most_recent_timestamp: int = start_time async with session.post( - url=chat_url, json=payload, headers=headers, timeout=timeout + url=chat_url, json=payload, headers=request_headers, timeout=timeout ) as response: http_status = HTTPStatus(response.status) if http_status == HTTPStatus.OK: @@ -317,6 +346,8 @@ async def send_request( latency = time.perf_counter_ns() - start_time if ttft is None: + if stream: + valid_response = False # The response was a single chunk ttft = latency @@ -423,7 +454,8 @@ async def send_turn( min_tokens, max_tokens, req_args.timeout_sec, - conversation_id=conv_id, + conversation_id=conv_id if req_args.send_conversation_id else None, + headers=req_args.headers, ) if response.valid is False: @@ -872,6 +904,7 @@ def get_client_config( # Arguments for API requests chat_url = f"{args.url}/v1/chat/completions" model_name = args.served_model_name if args.served_model_name else args.model + headers = build_request_headers(args.api_key, args.header) req_args = RequestArgs( chat_url=chat_url, @@ -880,6 +913,8 @@ def get_client_config( limit_min_tokens=args.limit_min_tokens, limit_max_tokens=args.limit_max_tokens, timeout_sec=args.request_timeout_sec, + send_conversation_id=args.send_conversation_id, + headers=headers, ) return client_args, req_args @@ -1245,19 +1280,19 @@ def process_statistics( ) -async def get_server_info(url: str) -> None: +async def get_server_info(url: str, headers: dict[str, str] | None = None) -> None: logger.info(f"{Color.BLUE}Collecting information from server: {url}{Color.RESET}") async with aiohttp.ClientSession() as session: # Get server version (not mandatory, "version" endpoint may not exist) url_version = f"{url}/version" - async with session.get(url_version) as response: + async with session.get(url_version, headers=headers) as response: if HTTPStatus(response.status) == HTTPStatus.OK: text = await response.text() logger.info(f"{Color.BLUE}Server version: {text}{Color.RESET}") # Get available models url_models = f"{url}/v1/models" - async with session.get(url_models) as response: + async with session.get(url_models, headers=headers) as response: if HTTPStatus(response.status) == HTTPStatus.OK: text = await response.text() logger.info(f"{Color.BLUE}Models:{Color.RESET}") @@ -1323,6 +1358,22 @@ async def main() -> None: help="Base URL for the LLM API server", ) + parser.add_argument( + "--api-key", + type=str, + default=None, + help="API key to send as an Authorization bearer token", + ) + parser.add_argument( + "--header", + action="append", + type=parse_custom_header, + default=None, + metavar="KEY=VALUE", + help="Custom request header. Can be specified multiple times. " + "Accepts 'Header-Name: value' or 'Header-Name=value'.", + ) + parser.add_argument( "-p", "--num-clients", @@ -1437,6 +1488,22 @@ async def main() -> None: help="Disable stream/streaming mode (set 'stream' to False in the API request)", ) + parser.add_argument( + "--send-conversation-id", + default=False, + action="store_true", + help=( + "Inject a `conversation_id` field into each Chat Completions " + "payload. This is a non-standard OpenAI extension consumed by " + "vLLM's disaggregated multi-turn proxy " + "(examples/disaggregated/disaggregated_serving/" + "disagg_proxy_multiturn.py) to key cross-turn KV cache reuse. " + "Leave disabled (default) when targeting strict " + "OpenAI-compatible endpoints; enable when benchmarking the " + "disaggregated proxy." + ), + ) + parser.add_argument( "-e", "--excel-output", @@ -1525,7 +1592,8 @@ async def main() -> None: args.model, trust_remote_code=args.trust_remote_code ) - await get_server_info(args.url) + headers = build_request_headers(args.api_key, args.header) + await get_server_info(args.url, headers=headers) # Load the input file (either conversations of configuration file) logger.info(f"Reading input file: {args.input_file}") diff --git a/build_rust.sh b/build_rust.sh index 98871ec8abcf..1efc1ce39f16 100755 --- a/build_rust.sh +++ b/build_rust.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Build the vllm-rs Rust frontend binary and install it into the vllm package. +# Build vLLM Rust artifacts and install them into the vllm package. # Usage: ./build_rust.sh [--debug] # # By default builds in release mode. Pass --debug for faster compile times @@ -8,8 +8,6 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" -RUST_DIR="$REPO_ROOT/rust" -TARGET_PATH="${VLLM_RS_TARGET_PATH:-$REPO_ROOT/vllm/vllm-rs}" # Read the required toolchain from rust-toolchain.toml. TOOLCHAIN=$(grep '^channel' "$REPO_ROOT/rust-toolchain.toml" | sed 's/.*= *"\(.*\)"/\1/') @@ -27,18 +25,9 @@ if ! rustup run "$TOOLCHAIN" rustc --version &>/dev/null; then fi if [[ "${1:-}" == "--debug" ]]; then - PROFILE_ARGS=() - PROFILE_DIR="debug" + PROFILE_ARG="--debug" else - PROFILE_ARGS=(--release) - PROFILE_DIR="release" + PROFILE_ARG="--release" fi -cargo +"$TOOLCHAIN" build "${PROFILE_ARGS[@]}" \ - --manifest-path "$RUST_DIR/Cargo.toml" \ - --bin vllm-rs \ - --features native-tls-vendored - -mkdir -p "$(dirname "$TARGET_PATH")" -cp "$RUST_DIR/target/$PROFILE_DIR/vllm-rs" "$TARGET_PATH" -echo "Installed vllm-rs to $TARGET_PATH" +python3 "$REPO_ROOT/tools/build_rust.py" "$PROFILE_ARG" diff --git a/build_vllm_ppc64le.sh b/build_vllm_ppc64le.sh new file mode 100644 index 000000000000..3c0b74cc74d3 --- /dev/null +++ b/build_vllm_ppc64le.sh @@ -0,0 +1,241 @@ +#!/bin/bash +set -eoux pipefail + +######################################## +# Resolve repo root (IMPORTANT) +######################################## +REPO_ROOT="$(pwd)" + +cd "$REPO_ROOT" + +######################################## +# DevPI configuration +######################################## + +IBM_DEVPI_URL=${IBM_DEVPI_URL:-"https://wheels.developerfirst.ibm.com/ppc64le/linux/+simple/"} +RHOAI_INDEX_URL=${RHOAI_INDEX_URL:-"https://console.redhat.com/api/pypi/public-rhai/rhoai/3.4/cpu-ubi9/simple/"} + +######################################## +# wheel dir +######################################## + +WHEEL_DIR=${WHEEL_DIR:-"/tmp/wheels"} +mkdir -p "$WHEEL_DIR" + +######################################## +# Helpers +######################################## +try_install_from_devpi() { + local pkg=$1 + uv pip install \ + --extra-index-url "${IBM_DEVPI_URL}" \ + --index-strategy unsafe-best-match \ + --no-build-isolation \ + "${pkg}" +} + +######################################## +# Package Versions +######################################## +cd "$REPO_ROOT" +TORCH_VERSION=${TORCH_VERSION:-$(grep -E '^torch==.+==\s*"ppc64le"' requirements/cpu.txt | grep -Eo '\b[0-9\.]+\b' || true)} +TORCH_VERSION=${TORCH_VERSION:-2.11.0} + +TORCHVISION_VERSION=${TORCHVISION_VERSION:-0.26.0} +TORCHAUDIO_VERSION=${TORCHAUDIO_VERSION:-${TORCH_VERSION}} + +export TORCH_VERSION +export TORCHVISION_VERSION +export TORCHAUDIO_VERSION +export OPENCV_VERSION=${OPENCV_VERSION:-4.13.0.92} +export XGRAMMAR_VERSION=${XGRAMMAR_VERSION:-0.2.1} + +######################################## +# install system dependencies +######################################## + +rpm -ivh https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm || true + +microdnf install -y \ + python3.12 python3.12-devel python3.12-pip gcc \ + git jq gcc-toolset-14 gcc-toolset-14-libatomic-devel \ + automake libtool clang-devel openssl-devel \ + harfbuzz-devel kmod lcms2-devel libimagequant-devel libjpeg-turbo-devel \ + llvm15-devel libraqm-devel libtiff-devel libwebp-devel libxcb-devel \ + ninja-build openjpeg2-devel pkgconfig \ + tcl-devel tk-devel xsimd-devel zeromq-devel zlib-devel patchelf file openblas openblas-devel protobuf numactl numactl-devel openmpi openmpi-devel + +rpm -ivh --nodeps \ + https://mirror.stream.centos.org/9-stream/CRB/ppc64le/os/Packages/protobuf-lite-devel-3.14.0-17.el9.ppc64le.rpm + +rpm -ivh --nodeps \ + https://mirror.stream.centos.org/9-stream/CRB/ppc64le/os/Packages/protobuf-devel-3.14.0-17.el9.ppc64le.rpm + +rpm -ivh --nodeps \ + https://mirror.stream.centos.org/9-stream/CRB/ppc64le/os/Packages/protobuf-compiler-3.14.0-17.el9.ppc64le.rpm + +######################################## +# Python 3.12 virtual environment +######################################## + +python3.12 -m venv /opt/vllm +source /opt/vllm/bin/activate + +export PATH=/opt/vllm/bin:$PATH + +python --version + +######################################## +# install build tools (stable uv) +######################################## + +pip install -U pip setuptools-rust +pip install uv +pip install "setuptools<70" build wheel cmake auditwheel +uv pip install "setuptools<70" cython meson-python pybind11 "sympy>=1.13.3" --no-build-isolation + +######################################## +# Rust +######################################## + +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +source /root/.cargo/env + +######################################## +# Compiler env +######################################## + +source /opt/rh/gcc-toolset-14/enable + +export PATH=/usr/lib64/llvm15/bin:$PATH +export LLVM_CONFIG=/usr/lib64/llvm15/bin/llvm-config +export CMAKE_ARGS="-DPython3_EXECUTABLE=python" + +export MAX_JOBS=${MAX_JOBS:-$(nproc)} +export GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 + +######################################## +# Install Packages From Devpi +######################################## +uv pip install numpy==2.3.5 pillow==12.2.0 --extra-index-url "$IBM_DEVPI_URL" +try_install_from_devpi "opencv-python-headless==${OPENCV_VERSION}" +try_install_from_devpi "torch==${TORCH_VERSION}" +try_install_from_devpi "torchvision==${TORCHVISION_VERSION}" + +######################################## +# torch audio +######################################## + +TEMP_BUILD_DIR=$(mktemp -d) +cd "${TEMP_BUILD_DIR}" +export BUILD_SOX=1 BUILD_KALDI=1 BUILD_RNNT=1 USE_FFMPEG=0 USE_ROCM=0 USE_CUDA=0 +export TORCHAUDIO_TEST_ALLOW_SKIP_IF_NO_FFMPEG=1 +git clone --recursive https://github.com/pytorch/audio.git -b v${TORCHAUDIO_VERSION} +cd audio +#patching +sed -i ' +s|_CSRC_DIR / "_torchaudio.cpp"|str(_CSRC_DIR / "_torchaudio.cpp")|; +s|_CSRC_DIR / "utils.cpp"|str(_CSRC_DIR / "utils.cpp")|; +s|sources=\[_CSRC_DIR / s for s in sources\]|sources=[str(_CSRC_DIR / s) for s in sources]|; +' tools/setup_helpers/extension.py +MAX_JOBS=${MAX_JOBS:-$(nproc)} \ +BUILD_VERSION=${TORCHAUDIO_VERSION} \ +uv build --wheel --out-dir "${WHEEL_DIR}" --no-build-isolation +uv pip install "${WHEEL_DIR}"/torchaudio*.whl +cd "${REPO_ROOT}" +rm -rf "${TEMP_BUILD_DIR}" + +######################################## +# Xgrammar +######################################## +uv pip install \ + "scikit-build-core==0.11.6" \ + "pyproject-metadata<0.8" \ + pathspec \ + packaging \ + distro \ + "setuptools<70" \ + setuptools_scm \ + cmake \ + ninja \ + pybind11 \ + nanobind +uv pip install apache-tvm-ffi==0.1.12 \ + --no-build-isolation \ + --no-cache + +TEMP_BUILD_DIR=$(mktemp -d) + +pushd "${TEMP_BUILD_DIR}" + +export CFLAGS="-fno-lto -mcpu=power9" +export CXXFLAGS="-fno-lto -mcpu=power9" +export LDFLAGS="-fno-lto" +export PATH=/opt/vllm/bin:$PATH + +export Python_EXECUTABLE=/opt/vllm/bin/python3 +export Python3_EXECUTABLE=/opt/vllm/bin/python3 +export PYTHON_EXECUTABLE=/opt/vllm/bin/python3 + +export Python_ROOT_DIR=/opt/vllm +export Python3_ROOT_DIR=/opt/vllm + +git clone \ + --recursive \ + https://github.com/mlc-ai/xgrammar \ + -b "v${XGRAMMAR_VERSION}" + +cd xgrammar + +cp cmake/config.cmake . +export PYTHONPATH=/opt/vllm/lib64/python3.12/site-packages:/opt/vllm/lib/python3.12/site-packages:${PYTHONPATH:-} + +uv build \ + --wheel \ + --out-dir "${WHEEL_DIR}" \ + --no-build-isolation + +uv pip install "${WHEEL_DIR}"/xgrammar*.whl -v + +popd + +rm -rf "${TEMP_BUILD_DIR}" +cd "${REPO_ROOT}" + +######################################## +# RHOAI Binary Downloads +######################################## +pip download \ + --index-url "${RHOAI_INDEX_URL}" \ + --only-binary=:all: \ + --no-deps \ + llvmlite==0.47.0 \ + -d "${WHEEL_DIR}" + +pip download \ + --index-url "${RHOAI_INDEX_URL}" \ + --only-binary=:all: \ + --no-deps \ + Numba==0.65.0 \ + -d "${WHEEL_DIR}" + +######################################## +# install built wheels +######################################## +uv pip install setuptools_scm maturin setuptools-rust ninja scikit-build-core pybind11 nanobind \ + --no-build-isolation +uv pip install "${WHEEL_DIR}"/*.whl + +######################################## +# install remaining deps +######################################## + +sed -i.bak -e 's/.*torch.*//g' pyproject.toml requirements/*.txt + +uv pip install "setuptools>=78.1.1" --no-build-isolation + +export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig:/usr/lib64/pkgconfig + +uv pip install -r requirements/common.txt \ + -r requirements/cpu.txt \ + -r requirements/build/cpu.txt --index-strategy unsafe-best-match diff --git a/cmake/cpu_extension.cmake b/cmake/cpu_extension.cmake index 6f836ff53544..3aca9bcea91d 100644 --- a/cmake/cpu_extension.cmake +++ b/cmake/cpu_extension.cmake @@ -15,6 +15,7 @@ endif() # set(ENABLE_X86_ISA $ENV{VLLM_CPU_X86}) set(ENABLE_ARM_BF16 $ENV{VLLM_CPU_ARM_BF16}) +set(ENABLE_RVV_BF16 $ENV{VLLM_CPU_RVV_BF16}) include_directories("${CMAKE_SOURCE_DIR}/csrc") @@ -24,7 +25,10 @@ set (ENABLE_NUMA TRUE) # Check the compile flags # if(MACOSX_FOUND) + # Apple clang needs -Xpreprocessor to enable OpenMP. No runtime link is + # needed: _C is a dynamic_lookup bundle and resolves libomp from torch. list(APPEND CXX_COMPILE_FLAGS + "-Xpreprocessor" "-fopenmp" "-DVLLM_CPU_EXTENSION") else() list(APPEND CXX_COMPILE_FLAGS @@ -107,6 +111,13 @@ else() set(ARM_BF16_FOUND ON) message(STATUS "ARM BF16 support enabled via VLLM_CPU_ARM_BF16 environment variable") endif() + # Some kernels (e.g. Bianbu on Spacemit X100) do not report zvfbfmin + # in /proc/cpuinfo despite hardware support. VLLM_CPU_RVV_BF16=1 + # overrides the detection result. + if (ENABLE_RVV_BF16) + set(RVV_BF16_FOUND ON) + message(STATUS "RVV BF16 support enabled via VLLM_CPU_RVV_BF16 environment variable") + endif() endif() if (CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|amd64" OR ENABLE_X86_ISA) @@ -166,11 +177,19 @@ elseif (S390_FOUND) "-mtune=native") elseif (CMAKE_SYSTEM_PROCESSOR MATCHES "riscv64") message(STATUS "RISC-V detected") + if(DEFINED VLLM_RVV_VLEN AND VLLM_RVV_VLEN LESS 0) + message(FATAL_ERROR + "VLLM_RVV_VLEN must be zero or a positive integer; got '${VLLM_RVV_VLEN}'") + endif() # VLLM_RVV_VLEN selects the target VLEN. Auto-detected from /proc/cpuinfo - # by default; override with -DVLLM_RVV_VLEN=128 or -DVLLM_RVV_VLEN=256. + # by default; set -DVLLM_RVV_VLEN=0 to force scalar RISC-V build. + # Override with -DVLLM_RVV_VLEN=128 or -DVLLM_RVV_VLEN=256 for RVV. if(NOT DEFINED VLLM_RVV_VLEN) # Auto-detect: find the largest zvlb in /proc/cpuinfo isa line. - if(EXISTS /proc/cpuinfo) + # Skip when cross-compiling — /proc/cpuinfo describes the build host. + if(CMAKE_CROSSCOMPILING) + message(STATUS "Cross-compiling: skipping VLEN auto-detection from /proc/cpuinfo") + elseif(EXISTS /proc/cpuinfo) file(READ /proc/cpuinfo _cpuinfo) set(_best 0) foreach(_n IN ITEMS 128 256 512 1024) @@ -178,6 +197,13 @@ elseif (CMAKE_SYSTEM_PROCESSOR MATCHES "riscv64") set(_best ${_n}) endif() endforeach() + # Only VLEN=128 and VLEN=256 are supported by the RVV kernels. + if(_best GREATER 256) + message(WARNING + "Detected VLEN=${_best} but only 128/256 are supported; " + "clamping to 256") + set(_best 256) + endif() if(_best GREATER 0) set(VLLM_RVV_VLEN ${_best}) endif() @@ -187,10 +213,9 @@ elseif (CMAKE_SYSTEM_PROCESSOR MATCHES "riscv64") if(NOT DEFINED VLLM_RVV_VLEN AND (RVV_FP16_FOUND OR RVV_BF16_FOUND)) message(FATAL_ERROR "RISC-V RVV is available but VLEN could not be auto-detected. " - "Please specify VLEN explicitly:\n" - " -DVLLM_RVV_VLEN=128 (for VLEN=128 hardware)\n" - " -DVLLM_RVV_VLEN=256 (for VLEN=256 hardware, e.g. Spacemit X100)\n" - " -DVLLM_RVV_VLEN=0 (force scalar, no RVV)") + "Please specify VLEN explicitly via CMAKE_ARGS:\n" + " CMAKE_ARGS='-DVLLM_RVV_VLEN=128' (for VLEN=128 hardware)\n" + " CMAKE_ARGS='-DVLLM_RVV_VLEN=256' (for VLEN=256 hardware, e.g. Spacemit X100)") endif() endif() if(VLLM_RVV_VLEN AND VLLM_RVV_VLEN GREATER 0) @@ -202,7 +227,7 @@ elseif (CMAKE_SYSTEM_PROCESSOR MATCHES "riscv64") message(STATUS "BF16 extension detected") set(MARCH_FLAGS -march=rv64gcv_zvfh_zfbfmin_zvfbfmin_zvl${VLLM_RVV_VLEN}b -mrvv-vector-bits=zvl -mabi=lp64d) elseif(RVV_FP16_FOUND) - message(WARNING "BF16 functionality is not available") + message(WARNING "BF16 functionality is not available.") set(MARCH_FLAGS -march=rv64gcv_zvfh_zvl${VLLM_RVV_VLEN}b -mrvv-vector-bits=zvl -mabi=lp64d) else() message(STATUS "compile riscv with scalar (no FP16/BF16)") @@ -219,7 +244,7 @@ endif() # Build oneDNN for GEMM kernels -if (ENABLE_X86_ISA OR (ASIMD_FOUND AND NOT APPLE_SILICON_FOUND) OR POWER9_FOUND OR POWER10_FOUND OR POWER11_FOUND) +if (ENABLE_X86_ISA OR (ASIMD_FOUND AND NOT APPLE_SILICON_FOUND) OR POWER9_FOUND OR POWER10_FOUND OR POWER11_FOUND OR RVV_FP16_FOUND OR RVV_BF16_FOUND) # Fetch and build Arm Compute Library (ACL) as oneDNN's backend for AArch64 # TODO [fadara01]: remove this once ACL can be fetched and built automatically as a dependency of oneDNN set(ONEDNN_AARCH64_USE_ACL OFF CACHE BOOL "") @@ -322,7 +347,7 @@ if (ENABLE_X86_ISA OR (ASIMD_FOUND AND NOT APPLE_SILICON_FOUND) OR POWER9_FOUND set(ONEDNN_ENABLE_PRIMITIVE "MATMUL;REORDER") set(ONEDNN_BUILD_GRAPH "OFF") set(ONEDNN_ENABLE_JIT_PROFILING "ON") - set(ONEDNN_ENABLE_ITT_TASKS "OFF") + set(ONEDNN_ENABLE_ITT_TASKS "ON") set(ONEDNN_ENABLE_MAX_CPU_ISA "ON") set(ONEDNN_ENABLE_CPU_ISA_HINTS "ON") set(ONEDNN_VERBOSE "ON") @@ -420,6 +445,8 @@ if (ASIMD_FOUND AND NOT APPLE_SILICON_FOUND) set(VLLM_EXT_SRC "csrc/cpu/shm.cpp" "csrc/cpu/activation_lut_bf16.cpp" + "csrc/cpu/cpu_tanhf_neon.hpp" + "csrc/cpu/cpu_fused_moe.cpp" ${VLLM_EXT_SRC}) endif() @@ -435,6 +462,12 @@ if(USE_ONEDNN) ${VLLM_EXT_SRC}) endif() +if (CMAKE_SYSTEM_PROCESSOR MATCHES "riscv64") + set(VLLM_EXT_SRC + "csrc/cpu/sgl-kernels/gemm_int4.cpp" + ${VLLM_EXT_SRC}) +endif() + if (ENABLE_X86_ISA) set(VLLM_EXT_SRC_SGL "csrc/cpu/sgl-kernels/conv.cpp" diff --git a/cmake/external_projects/deepgemm.cmake b/cmake/external_projects/deepgemm.cmake index 183c42dc7953..38d218d00acb 100644 --- a/cmake/external_projects/deepgemm.cmake +++ b/cmake/external_projects/deepgemm.cmake @@ -8,43 +8,74 @@ if (DEFINED ENV{DEEPGEMM_SRC_DIR}) set(DEEPGEMM_SRC_DIR $ENV{DEEPGEMM_SRC_DIR}) endif() +# Local tree: set deepgemm_SOURCE_DIR directly (no FetchContent download). +# Upstream git: use FetchContent_Populate with explicit options (CMP0169 NEW +# disallows one-argument Populate(dep) after Declare; MakeAvailable would run +# DeepGEMM's top-level CMakeLists.txt, which vLLM must not load). if(DEEPGEMM_SRC_DIR) - FetchContent_Declare( - deepgemm - SOURCE_DIR ${DEEPGEMM_SRC_DIR} - CONFIGURE_COMMAND "" - BUILD_COMMAND "" - ) + # cmake_path(ABSOLUTE_PATH ...) reads the path from ; NORMALIZE is a + # flag (no trailing path argument). Resolve relative paths against vLLM root. + set(_deepgemm_user_src "${DEEPGEMM_SRC_DIR}") + cmake_path(ABSOLUTE_PATH _deepgemm_user_src + BASE_DIRECTORY "${CMAKE_SOURCE_DIR}" + NORMALIZE) + set(DEEPGEMM_SRC_DIR "${_deepgemm_user_src}") + if(NOT IS_DIRECTORY "${DEEPGEMM_SRC_DIR}") + message(FATAL_ERROR + "DEEPGEMM_SRC_DIR is not an existing directory: '${DEEPGEMM_SRC_DIR}'") + endif() + set(deepgemm_SOURCE_DIR "${DEEPGEMM_SRC_DIR}") + message(STATUS "DeepGEMM using local DEEPGEMM_SRC_DIR: ${deepgemm_SOURCE_DIR}") else() - # This ref should be kept in sync with tools/install_deepgemm.sh - FetchContent_Declare( - deepgemm - GIT_REPOSITORY https://github.com/deepseek-ai/DeepGEMM.git - GIT_TAG 891d57b4db1071624b5c8fa0d1e51cb317fa709f - GIT_SUBMODULES "third-party/cutlass" "third-party/fmt" - GIT_PROGRESS TRUE - CONFIGURE_COMMAND "" - BUILD_COMMAND "" - ) -endif() + # Keep in sync with tools/install_deepgemm.sh + set(_DEEPGEMM_UPSTREAM_REPO "https://github.com/deepseek-ai/DeepGEMM.git") + # NOTE: This is currently targeting nv-dev branch due to sm120 support + set(_DEEPGEMM_UPSTREAM_TAG "a6b593d2826719dcf4892609af7b84ee23aaf32a") + + set(_deepgemm_fc_root "${FETCHCONTENT_BASE_DIR}") + if(NOT _deepgemm_fc_root) + set(_deepgemm_fc_root "${CMAKE_BINARY_DIR}/_deps") + endif() + set(_deepgemm_src "${_deepgemm_fc_root}/deepgemm-src") + set(_deepgemm_bin "${_deepgemm_fc_root}/deepgemm-build") + set(_deepgemm_sub "${_deepgemm_fc_root}/deepgemm-subbuild") -# Use FetchContent_Populate (not MakeAvailable) to avoid processing -# DeepGEMM's own CMakeLists.txt which has incompatible find_package calls. -FetchContent_GetProperties(deepgemm) -if(NOT deepgemm_POPULATED) - FetchContent_Populate(deepgemm) + if(EXISTS "${_deepgemm_src}/csrc/python_api.cpp") + set(deepgemm_SOURCE_DIR "${_deepgemm_src}") + set(deepgemm_BINARY_DIR "${_deepgemm_bin}") + else() + FetchContent_Populate( + deepgemm + SUBBUILD_DIR "${_deepgemm_sub}" + SOURCE_DIR "${_deepgemm_src}" + BINARY_DIR "${_deepgemm_bin}" + GIT_REPOSITORY "${_DEEPGEMM_UPSTREAM_REPO}" + GIT_TAG "${_DEEPGEMM_UPSTREAM_TAG}" + GIT_SUBMODULES "third-party/cutlass" "third-party/fmt" + GIT_PROGRESS TRUE + ) + endif() + message(STATUS "DeepGEMM is available at ${deepgemm_SOURCE_DIR}") endif() -message(STATUS "DeepGEMM is available at ${deepgemm_SOURCE_DIR}") -# DeepGEMM requires CUDA 12.3+ for SM90, 12.9+ for SM100 +# DeepGEMM requires CUDA 12.3+ for SM90, 12.9+ for SM100 (official upstream), +# and 12.8+ for SM120 / SM12x. CUDA 13+ can use the family-specific SM12x +# arch; CUDA 12.x builds the arch-specific SM120/SM121 variants. set(DEEPGEMM_SUPPORT_ARCHS) if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.3) list(APPEND DEEPGEMM_SUPPORT_ARCHS "9.0a") endif() -if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.9) - list(APPEND DEEPGEMM_SUPPORT_ARCHS "10.0f") -elseif(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) - list(APPEND DEEPGEMM_SUPPORT_ARCHS "10.0a") +if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8) + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.9) + list(APPEND DEEPGEMM_SUPPORT_ARCHS "10.0f") + else() + list(APPEND DEEPGEMM_SUPPORT_ARCHS "10.0a") + endif() + if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) + list(APPEND DEEPGEMM_SUPPORT_ARCHS "12.0f") + else() + list(APPEND DEEPGEMM_SUPPORT_ARCHS "12.0a" "12.1a") + endif() endif() cuda_archs_loose_intersection(DEEPGEMM_ARCHS diff --git a/cmake/external_projects/fmha_sm100.cmake b/cmake/external_projects/fmha_sm100.cmake new file mode 100644 index 000000000000..052966b27552 --- /dev/null +++ b/cmake/external_projects/fmha_sm100.cmake @@ -0,0 +1,73 @@ +include(FetchContent) + +# If FMHA_SM100_SRC_DIR is set, fmha_sm100 is installed from that directory +# instead of downloading. This is useful for local MSA development. +if(DEFINED ENV{FMHA_SM100_SRC_DIR}) + set(FMHA_SM100_SRC_DIR $ENV{FMHA_SM100_SRC_DIR}) +endif() + +if(FMHA_SM100_SRC_DIR) + FetchContent_Declare( + fmha_sm100 + SOURCE_DIR ${FMHA_SM100_SRC_DIR} + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + ) +else() + FetchContent_Declare( + fmha_sm100 + GIT_REPOSITORY https://github.com/vllm-project/MSA.git + GIT_TAG 2e63ec37a0fc29bc20f39cd1a52e0f5affc33a73 + GIT_PROGRESS TRUE + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + ) +endif() + +FetchContent_GetProperties(fmha_sm100) +if(NOT fmha_sm100_POPULATED) + FetchContent_Populate(fmha_sm100) +endif() +message(STATUS "fmha_sm100 is available at ${fmha_sm100_SOURCE_DIR}") + +add_custom_target(fmha_sm100) + +set(FMHA_SM100_PY_ROOT "${fmha_sm100_SOURCE_DIR}/python/fmha_sm100") + +install(FILES + "${FMHA_SM100_PY_ROOT}/__init__.py" + "${FMHA_SM100_PY_ROOT}/api.py" + "${FMHA_SM100_PY_ROOT}/bench_utils.py" + "${FMHA_SM100_PY_ROOT}/jit.py" + "${FMHA_SM100_PY_ROOT}/sparse.py" + "${FMHA_SM100_PY_ROOT}/sparse_fmha_adapter.py" + DESTINATION vllm/third_party/fmha_sm100 + COMPONENT fmha_sm100) + +install(DIRECTORY "${FMHA_SM100_PY_ROOT}/csrc/" + DESTINATION vllm/third_party/fmha_sm100/csrc + COMPONENT fmha_sm100 + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE + PATTERN ".git*" EXCLUDE) + +install(DIRECTORY "${FMHA_SM100_PY_ROOT}/cute/" + DESTINATION vllm/third_party/fmha_sm100/cute + COMPONENT fmha_sm100 + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE + PATTERN ".git*" EXCLUDE) + +install(DIRECTORY "${FMHA_SM100_PY_ROOT}/cutlass/include/" + DESTINATION vllm/third_party/fmha_sm100/cutlass/include + COMPONENT fmha_sm100 + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE + PATTERN ".git*" EXCLUDE) + +install(DIRECTORY "${FMHA_SM100_PY_ROOT}/cutlass/tools/util/include/" + DESTINATION vllm/third_party/fmha_sm100/cutlass/tools/util/include + COMPONENT fmha_sm100 + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE + PATTERN ".git*" EXCLUDE) diff --git a/cmake/external_projects/qutlass.cmake b/cmake/external_projects/qutlass.cmake index 273fe754bed1..29c5c6528b9c 100644 --- a/cmake/external_projects/qutlass.cmake +++ b/cmake/external_projects/qutlass.cmake @@ -6,25 +6,47 @@ if(DEFINED ENV{QUTLASS_SRC_DIR}) set(QUTLASS_SRC_DIR $ENV{QUTLASS_SRC_DIR}) endif() +# CMP0169 NEW: one-argument FetchContent_Populate(name) after Declare is invalid. +# Use explicit Populate(...) for git, or set SOURCE_DIR for local trees. if(QUTLASS_SRC_DIR) - FetchContent_Declare( - qutlass - SOURCE_DIR ${QUTLASS_SRC_DIR} - CONFIGURE_COMMAND "" - BUILD_COMMAND "" - ) + set(_qutlass_user_src "${QUTLASS_SRC_DIR}") + cmake_path(ABSOLUTE_PATH _qutlass_user_src + BASE_DIRECTORY "${CMAKE_SOURCE_DIR}" + NORMALIZE) + set(QUTLASS_SRC_DIR "${_qutlass_user_src}") + if(NOT IS_DIRECTORY "${QUTLASS_SRC_DIR}") + message(FATAL_ERROR + "[QUTLASS] QUTLASS_SRC_DIR is not an existing directory: '${QUTLASS_SRC_DIR}'") + endif() + set(qutlass_SOURCE_DIR "${QUTLASS_SRC_DIR}") + set(qutlass_BINARY_DIR "${CMAKE_BINARY_DIR}/qutlass-binary-dir-unused") else() - FetchContent_Declare( - qutlass - GIT_REPOSITORY https://github.com/IST-DASLab/qutlass.git - GIT_TAG 830d2c4537c7396e14a02a46fbddd18b5d107c65 - GIT_PROGRESS TRUE - CONFIGURE_COMMAND "" - BUILD_COMMAND "" - ) -endif() + set(_QUTLASS_UPSTREAM_REPO "https://github.com/IST-DASLab/qutlass.git") + set(_QUTLASS_UPSTREAM_TAG "830d2c4537c7396e14a02a46fbddd18b5d107c65") -FetchContent_Populate(qutlass) + set(_qutlass_fc_root "${FETCHCONTENT_BASE_DIR}") + if(NOT _qutlass_fc_root) + set(_qutlass_fc_root "${CMAKE_BINARY_DIR}/_deps") + endif() + set(_qutlass_src "${_qutlass_fc_root}/qutlass-src") + set(_qutlass_bin "${_qutlass_fc_root}/qutlass-build") + set(_qutlass_sub "${_qutlass_fc_root}/qutlass-subbuild") + + if(EXISTS "${_qutlass_src}/qutlass/csrc/bindings.cpp") + set(qutlass_SOURCE_DIR "${_qutlass_src}") + set(qutlass_BINARY_DIR "${_qutlass_bin}") + else() + FetchContent_Populate( + qutlass + SUBBUILD_DIR "${_qutlass_sub}" + SOURCE_DIR "${_qutlass_src}" + BINARY_DIR "${_qutlass_bin}" + GIT_REPOSITORY "${_QUTLASS_UPSTREAM_REPO}" + GIT_TAG "${_QUTLASS_UPSTREAM_TAG}" + GIT_PROGRESS TRUE + ) + endif() +endif() if(NOT qutlass_SOURCE_DIR) message(FATAL_ERROR "[QUTLASS] source directory could not be resolved.") @@ -32,22 +54,35 @@ endif() message(STATUS "[QUTLASS] QuTLASS is available at ${qutlass_SOURCE_DIR}") if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(QUTLASS_ARCHS "10.0f;12.0f" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(QUTLASS_SM120_ARCHS "12.0f" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(QUTLASS_SM100_ARCHS "10.0f" "${CUDA_ARCHS}") else() - cuda_archs_loose_intersection(QUTLASS_ARCHS "12.0a;12.1a;10.0a;10.3a" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(QUTLASS_SM120_ARCHS "12.0a;12.1a" "${CUDA_ARCHS}") + cuda_archs_loose_intersection(QUTLASS_SM100_ARCHS "10.0a;10.3a" "${CUDA_ARCHS}") endif() -if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND QUTLASS_ARCHS) - - if(QUTLASS_ARCHS MATCHES "10\\.(0a|3a|0f)") - set(QUTLASS_TARGET_CC 100) - elseif(QUTLASS_ARCHS MATCHES "12\\.[01][af]?") - set(QUTLASS_TARGET_CC 120) - else() - message(FATAL_ERROR "[QUTLASS] internal error parsing CUDA_ARCHS='${QUTLASS_ARCHS}'.") +# QUTLASS uses TARGET_CUDA_ARCH as a single preprocessor selector for all its +# sources. Do not compile a mixed SM100/SM120 arch list with one selector; prefer +# SM100 when both families are requested because that is the primary deployed +# target for this extension today. +if(QUTLASS_SM100_ARCHS) + set(QUTLASS_ARCHS "${QUTLASS_SM100_ARCHS}") + set(QUTLASS_TARGET_CC 100) + if(QUTLASS_SM120_ARCHS) + message(WARNING + "[QUTLASS] Both SM100 and SM120 archs were requested; selecting SM100 " + "because TARGET_CUDA_ARCH is a single compile-time selector.") endif() +elseif(QUTLASS_SM120_ARCHS) + set(QUTLASS_ARCHS "${QUTLASS_SM120_ARCHS}") + set(QUTLASS_TARGET_CC 120) +else() + set(QUTLASS_ARCHS) +endif() +if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND QUTLASS_ARCHS) set(QUTLASS_SOURCES + csrc/qutlass_registration.cpp ${qutlass_SOURCE_DIR}/qutlass/csrc/bindings.cpp ${qutlass_SOURCE_DIR}/qutlass/csrc/gemm.cu ${qutlass_SOURCE_DIR}/qutlass/csrc/gemm_ada.cu @@ -66,8 +101,19 @@ if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND QUTLASS_ARCHS) if(CUTLASS_INCLUDE_DIR AND EXISTS "${CUTLASS_INCLUDE_DIR}/cutlass/cutlass.h") list(APPEND QUTLASS_INCLUDES "${CUTLASS_INCLUDE_DIR}") + if(CUTLASS_TOOLS_UTIL_INCLUDE_DIR AND + EXISTS "${CUTLASS_TOOLS_UTIL_INCLUDE_DIR}/cutlass/util/packed_stride.hpp") + list(APPEND QUTLASS_INCLUDES "${CUTLASS_TOOLS_UTIL_INCLUDE_DIR}") + else() + get_filename_component(_qutlass_cutlass_root "${CUTLASS_INCLUDE_DIR}" DIRECTORY) + if(EXISTS "${_qutlass_cutlass_root}/tools/util/include/cutlass/util/packed_stride.hpp") + list(APPEND QUTLASS_INCLUDES "${_qutlass_cutlass_root}/tools/util/include") + endif() + endif() elseif(EXISTS "${qutlass_SOURCE_DIR}/qutlass/third_party/cutlass/include/cutlass/cutlass.h") - list(APPEND QUTLASS_INCLUDES "${qutlass_SOURCE_DIR}/qutlass/third_party/cutlass/include") + list(APPEND QUTLASS_INCLUDES + "${qutlass_SOURCE_DIR}/qutlass/third_party/cutlass/include" + "${qutlass_SOURCE_DIR}/qutlass/third_party/cutlass/tools/util/include") message(STATUS "[QUTLASS] Using QuTLASS vendored CUTLASS headers (no vLLM CUTLASS detected).") else() message(FATAL_ERROR "[QUTLASS] CUTLASS headers not found. " @@ -79,12 +125,23 @@ if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 12.8 AND QUTLASS_ARCHS) CUDA_ARCHS "${QUTLASS_ARCHS}" ) - target_sources(_C PRIVATE ${QUTLASS_SOURCES}) - target_include_directories(_C PRIVATE ${QUTLASS_INCLUDES}) - target_compile_definitions(_C PRIVATE + # QuTLASS uses legacy ATen headers and cannot be built with TORCH_TARGET_VERSION. + # Keep it as its own extension (registers torch.ops._qutlass_C). + define_extension_target( + _qutlass_C + DESTINATION vllm + LANGUAGE ${VLLM_GPU_LANG} + SOURCES ${QUTLASS_SOURCES} + COMPILE_FLAGS ${VLLM_GPU_FLAGS} + ARCHITECTURES ${VLLM_GPU_ARCHES} + INCLUDE_DIRECTORIES ${QUTLASS_INCLUDES} + USE_SABI 3 + WITH_SOABI) + + target_compile_definitions(_qutlass_C PRIVATE QUTLASS_DISABLE_PYBIND=1 TARGET_CUDA_ARCH=${QUTLASS_TARGET_CC} - ) + CUTLASS_ENABLE_DIRECT_CUDA_DRIVER_CALL=1) set_property(SOURCE ${QUTLASS_SOURCES} APPEND PROPERTY COMPILE_OPTIONS $<$:--expt-relaxed-constexpr --use_fast_math -O3> @@ -99,4 +156,5 @@ else() "[QUTLASS] Skipping build: no supported arch (12.0f / 10.0f) found in " "CUDA_ARCHS='${CUDA_ARCHS}'.") endif() + add_custom_target(_qutlass_C) endif() diff --git a/cmake/external_projects/vllm_flash_attn.cmake b/cmake/external_projects/vllm_flash_attn.cmake index 1e4feb0ff9eb..c8b1d6891876 100644 --- a/cmake/external_projects/vllm_flash_attn.cmake +++ b/cmake/external_projects/vllm_flash_attn.cmake @@ -39,7 +39,7 @@ else() FetchContent_Declare( vllm-flash-attn GIT_REPOSITORY https://github.com/vllm-project/flash-attention.git - GIT_TAG dd62dac706b1cf7895bd99b18c6cb7e7e117ee25 + GIT_TAG b3964b1d8b95d8e8447435668ab169a2700bab65 GIT_PROGRESS TRUE # Don't share the vllm-flash-attn build between build types BINARY_DIR ${CMAKE_BINARY_DIR}/vllm-flash-attn diff --git a/cmake/utils.cmake b/cmake/utils.cmake index dd2034c1c5ee..e3e766541df1 100644 --- a/cmake/utils.cmake +++ b/cmake/utils.cmake @@ -487,9 +487,9 @@ endfunction() function(cuda_archs_sm90plus OUT_CUDA_ARCHS TGT_CUDA_ARCHS) if(${CMAKE_CUDA_COMPILER_VERSION} VERSION_GREATER_EQUAL 13.0) - cuda_archs_loose_intersection(_archs "9.0a;10.0f;11.0f" "${TGT_CUDA_ARCHS}") + cuda_archs_loose_intersection(_archs "9.0a;10.0f;11.0f;12.0f" "${TGT_CUDA_ARCHS}") else() - cuda_archs_loose_intersection(_archs "9.0a;10.0a;10.1a;10.3a" "${TGT_CUDA_ARCHS}") + cuda_archs_loose_intersection(_archs "9.0a;10.0a;10.1a;10.3a;12.0a;12.1a" "${TGT_CUDA_ARCHS}") endif() set(${OUT_CUDA_ARCHS} ${_archs} PARENT_SCOPE) endfunction() diff --git a/csrc/cpu/activation.cpp b/csrc/cpu/activation.cpp index 039b8d5c30d4..2f06813a1942 100644 --- a/csrc/cpu/activation.cpp +++ b/csrc/cpu/activation.cpp @@ -126,6 +126,18 @@ void gelu_tanh_and_mul(torch::Tensor& out, // [..., d] }); } +void gelu_tanh(torch::Tensor& out, torch::Tensor& input) { + int num_tokens = input.numel() / input.size(-1); + int d = input.size(-1); + + VLLM_DISPATCH_FLOATING_TYPES(input.scalar_type(), "gelu_tanh_impl", [&] { + CPU_KERNEL_GUARD_IN(gelu_tanh_impl) + activation_kernel( + num_tokens, d, input.data_ptr(), out.data_ptr()); + CPU_KERNEL_GUARD_OUT(gelu_tanh_impl) + }); +} + void gelu_new(torch::Tensor& out, torch::Tensor& input) { int num_tokens = input.numel() / input.size(-1); int d = input.size(-1); diff --git a/csrc/cpu/cpu_attn.cpp b/csrc/cpu/cpu_attn.cpp index 26b881f4f143..fa22861157e2 100644 --- a/csrc/cpu/cpu_attn.cpp +++ b/csrc/cpu/cpu_attn.cpp @@ -11,13 +11,26 @@ static inline cpu_attention::Fp8KVCacheDataType parse_fp8_kv_dtype( return cpu_attention::Fp8KVCacheDataType::kAuto; } +bool cpu_attn_has_isa(const std::string& isa) { + if (isa == "rvv") { +#if defined(__riscv) && defined(__riscv_v_min_vlen) && \ + (__riscv_v_min_vlen == 128 || __riscv_v_min_vlen == 256) + return true; +#else + return false; +#endif + } + return false; +} + torch::Tensor get_scheduler_metadata( const int64_t num_req, const int64_t num_heads_q, const int64_t num_heads_kv, const int64_t head_dim, const torch::Tensor& seq_lens, at::ScalarType dtype, - const torch::Tensor& query_start_loc, const bool casual, + const torch::Tensor& query_start_loc, const bool causal, const int64_t window_size, const std::string& isa_hint, - const bool enable_kv_split) { + const bool enable_kv_split, + const std::optional& dynamic_causal) { cpu_attention::ISA isa; if (isa_hint == "amx") { isa = cpu_attention::ISA::AMX; @@ -44,24 +57,13 @@ torch::Tensor get_scheduler_metadata( input.head_dim = head_dim; input.query_start_loc = query_start_loc.data_ptr(); input.seq_lens = seq_lens.data_ptr(); - if (window_size != -1) { - input.left_sliding_window_size = window_size - 1; - if (casual) { - input.right_sliding_window_size = 0; - } else { - input.right_sliding_window_size = window_size - 1; - } - } else { - input.left_sliding_window_size = -1; - if (casual) { - input.right_sliding_window_size = 0; - } else { - input.right_sliding_window_size = -1; - } - } - input.casual = casual; + + input.sliding_window_size = window_size; + input.causal = causal; input.isa = isa; input.enable_kv_split = enable_kv_split; + input.dynamic_causal = + dynamic_causal.has_value() ? dynamic_causal->data_ptr() : nullptr; VLLM_DISPATCH_FLOATING_TYPES(dtype, "get_scheduler_metadata", [&]() { CPU_ATTN_DISPATCH(head_dim, isa, 0, [&]() { @@ -175,10 +177,11 @@ void cpu_attention_with_kv_cache( const torch::Tensor& seq_lens, // [num_tokens] const double scale, const bool causal, const std::optional& alibi_slopes, // [num_heads] - const int64_t sliding_window_left, const int64_t sliding_window_right, + const int64_t sliding_window, const torch::Tensor& block_table, // [num_tokens, max_block_num] const double softcap, const torch::Tensor& scheduler_metadata, - const std::optional& s_aux, // [num_heads] + const std::optional& s_aux, // [num_heads] + const std::optional& dynamic_causal, // [num_reqs] const double k_scale = 1.0, const double v_scale = 1.0, const std::string& kv_cache_dtype = "auto") { TORCH_CHECK_EQ(query.dim(), 3); @@ -220,13 +223,11 @@ void cpu_attention_with_kv_cache( input.alibi_slopes = alibi_slopes.has_value() ? alibi_slopes->data_ptr() : nullptr; input.s_aux = s_aux.has_value() ? s_aux->data_ptr() : nullptr; + input.dynamic_causal = + dynamic_causal.has_value() ? dynamic_causal->data_ptr() : nullptr; input.scale = scale; input.causal = causal; - input.sliding_window_left = sliding_window_left; - input.sliding_window_right = sliding_window_right; - if (input.causal) { - input.sliding_window_right = 0; - } + input.sliding_window_size = sliding_window; input.softcap = static_cast(softcap); if (is_fp8) { diff --git a/csrc/cpu/cpu_attn_impl.hpp b/csrc/cpu/cpu_attn_impl.hpp index 70081b36ee5b..907f8895682e 100644 --- a/csrc/cpu/cpu_attn_impl.hpp +++ b/csrc/cpu/cpu_attn_impl.hpp @@ -124,7 +124,7 @@ struct AttentionMetadata { workitem_group_num(workitem_group_num), reduction_item_num(reduction_item_num), reduction_split_num(reduction_split_num), - thread_num(omp_get_max_threads()), + thread_num(cpu_utils::get_max_threads()), effective_thread_num(thread_num), split_kv_q_token_num_threshold(split_kv_q_token_num_threshold), attention_scratchpad_size_per_thread(0), @@ -388,13 +388,13 @@ class AttentionScheduler { int32_t head_dim; int32_t* query_start_loc; int32_t* seq_lens; - int32_t left_sliding_window_size; - int32_t right_sliding_window_size; - bool casual; + int32_t sliding_window_size; + bool causal; cpu_attention::ISA isa; int32_t max_num_q_per_iter; // max Q head num can be hold in registers int32_t kv_block_alignment; // context length alignment requirement bool enable_kv_split; + bool* dynamic_causal; }; static constexpr int32_t MaxQTileIterNum = 128; @@ -403,8 +403,9 @@ class AttentionScheduler { : available_cache_size_(cpu_utils::get_available_l2_size()) {} torch::Tensor schedule(const ScheduleInput& input) const { - const bool casual = input.casual; - const int32_t thread_num = omp_get_max_threads(); + const bool causal = input.causal; + const bool is_dynamic_causal = input.dynamic_causal != nullptr; + const int32_t thread_num = cpu_utils::get_max_threads(); const int64_t cache_size = cpu_utils::get_available_l2_size(); const int32_t max_num_q_per_iter = input.max_num_q_per_iter; const int32_t kv_len_alignment = input.kv_block_alignment; @@ -416,8 +417,10 @@ class AttentionScheduler { has_decode_request = has_decode_request || (q_token_num == 1); decode_only_batch = decode_only_batch && (q_token_num == 1); } - int32_t q_head_per_kv = input.num_heads_q / input.num_heads_kv; - const bool supports_gqa = q_head_per_kv <= max_num_q_per_iter; + const int32_t original_q_head_per_kv = + input.num_heads_q / input.num_heads_kv; + int32_t q_head_per_kv = original_q_head_per_kv; + const bool supports_gqa = original_q_head_per_kv <= max_num_q_per_iter; const bool use_gqa_fast_path = supports_gqa && decode_only_batch; const bool use_gqa_scratchpad = supports_gqa && has_decode_request; if (!use_gqa_scratchpad) { @@ -434,8 +437,7 @@ class AttentionScheduler { const int32_t default_tile_token_num = default_tile_size / q_head_per_kv; const int32_t split_kv_q_token_num_threshold = input.enable_kv_split ? 1 : 0; - const int32_t left_sliding_window_size = input.left_sliding_window_size; - const int32_t right_sliding_window_size = input.right_sliding_window_size; + const int32_t sliding_window_size = input.sliding_window_size; TORCH_CHECK_LE(split_kv_q_token_num_threshold * q_head_per_kv, 16); // get total kv len @@ -444,7 +446,9 @@ class AttentionScheduler { const int32_t seq_len = input.seq_lens[req_id]; const int32_t q_token_num = input.query_start_loc[req_id + 1] - input.query_start_loc[req_id]; - const int32_t q_start_pos = (casual ? (seq_len - q_token_num) : 0); + const bool req_causal = + is_dynamic_causal ? input.dynamic_causal[req_id] : causal; + const int32_t q_start_pos = seq_len - q_token_num; const int32_t kv_start_pos = 0; const int32_t kv_end_pos = seq_len; @@ -456,7 +460,7 @@ class AttentionScheduler { const int32_t q_tile_pos_right = q_tile_pos_left + q_tile_token_num; const auto [kv_tile_pos_left, kv_tile_pos_right] = calcu_kv_tile_pos( kv_start_pos, kv_end_pos, q_tile_pos_left, q_tile_pos_right, - left_sliding_window_size, right_sliding_window_size); + sliding_window_size, req_causal); const auto [aligned_kv_tile_pos_left, aligned_kv_tile_pos_right] = align_kv_tile_pos(kv_tile_pos_left, kv_tile_pos_right, kv_len_alignment); @@ -484,7 +488,9 @@ class AttentionScheduler { const int32_t seq_len = input.seq_lens[req_id]; const int32_t q_token_num = input.query_start_loc[req_id + 1] - input.query_start_loc[req_id]; - const int32_t q_start_pos = (casual ? (seq_len - q_token_num) : 0); + const bool req_causal = + is_dynamic_causal ? input.dynamic_causal[req_id] : causal; + const int32_t q_start_pos = seq_len - q_token_num; const int32_t kv_start_pos = 0; const int32_t kv_end_pos = seq_len; int32_t local_split_id = 0; @@ -498,7 +504,7 @@ class AttentionScheduler { const int32_t q_tile_pos_right = q_tile_pos_left + q_tile_token_num; const auto [kv_tile_pos_left, kv_tile_pos_right] = calcu_kv_tile_pos( kv_start_pos, kv_end_pos, q_tile_pos_left, q_tile_pos_right, - left_sliding_window_size, right_sliding_window_size); + sliding_window_size, req_causal); const auto [aligned_kv_tile_pos_left, aligned_kv_tile_pos_right] = align_kv_tile_pos(kv_tile_pos_left, kv_tile_pos_right, kv_len_alignment); @@ -667,22 +673,62 @@ class AttentionScheduler { metadata_ptr->effective_thread_num = effective_thread_num; { - // when q_tile_size = max_num_q_per_iter, requires max - // attention_scratchpad_size AttentionScratchPad sc(0, *metadata_ptr, 0x0); - int64_t n = AttentionScheduler::calcu_tile_size_with_constant_q( - cache_size, input.head_dim, input.elem_size, input.q_buffer_elem_size, - input.logits_buffer_elem_size, input.output_buffer_elem_size, - max_num_q_per_iter, kv_len_alignment, max_num_q_per_iter, true); - sc.update(input.head_dim, input.q_buffer_elem_size, - input.logits_buffer_elem_size, input.output_buffer_elem_size, - max_num_q_per_iter, max_num_q_per_iter, n); + int64_t max_attention_scratchpad_size = 0; + + for (const AttentionWorkItemGroup& item : workitems) { + const bool curr_use_gqa = + use_gqa_fast_path || (supports_gqa && item.q_token_num == 1); + const int32_t curr_q_heads_per_kv = + curr_use_gqa ? original_q_head_per_kv : 1; + const int32_t curr_default_q_tile_token_num = + default_tile_size / curr_q_heads_per_kv; + + for (int32_t q_token_offset = 0; q_token_offset < item.q_token_num; + q_token_offset += curr_default_q_tile_token_num) { + const int32_t actual_q_token_num = std::min( + curr_default_q_tile_token_num, item.q_token_num - q_token_offset); + const int32_t q_head_tile_size = + actual_q_token_num * curr_q_heads_per_kv; + const int32_t rounded_q_head_tile_size = + ((q_head_tile_size + max_num_q_per_iter - 1) / + max_num_q_per_iter) * + max_num_q_per_iter; + + const int64_t n = AttentionScheduler::calcu_tile_size_with_constant_q( + cache_size, input.head_dim, input.elem_size, + input.q_buffer_elem_size, input.logits_buffer_elem_size, + input.output_buffer_elem_size, max_num_q_per_iter, + kv_len_alignment, rounded_q_head_tile_size, + rounded_q_head_tile_size <= max_num_q_per_iter); + + sc.update(input.head_dim, input.q_buffer_elem_size, + input.logits_buffer_elem_size, + input.output_buffer_elem_size, max_num_q_per_iter, + rounded_q_head_tile_size, n); + + max_attention_scratchpad_size = std::max( + max_attention_scratchpad_size, sc.get_thread_scratchpad_size()); + } + } + metadata_ptr->attention_scratchpad_size_per_thread = - ((sc.get_thread_scratchpad_size() + 63) / 64) * 64; + ((max_attention_scratchpad_size + 63) / 64) * 64; + + int32_t max_reduction_q_head_tile_size = 0; + for (const ReductionWorkItemGroup& item : reduce_workitems) { + const bool curr_use_gqa = + use_gqa_fast_path || (supports_gqa && item.q_token_id_num == 1); + const int32_t curr_q_heads_per_kv = + curr_use_gqa ? original_q_head_per_kv : 1; + + max_reduction_q_head_tile_size = + std::max(max_reduction_q_head_tile_size, + item.q_token_id_num * curr_q_heads_per_kv); + } sc.update(0, metadata_ptr->reduction_split_num, input.head_dim, - q_head_per_kv * split_kv_q_token_num_threshold, - input.output_buffer_elem_size); + max_reduction_q_head_tile_size, input.output_buffer_elem_size); metadata_ptr->reduction_scratchpad_size_per_kv_head = ((sc.get_reduction_scratchpad_size() + 63) / 64) * 64; } @@ -708,15 +754,41 @@ class AttentionScheduler { return metadata_tensor; } + FORCE_INLINE static std::pair calcu_sliding_window_size( + int32_t window_size, bool causal) { + int32_t left_sliding_window_size, right_sliding_window_size; + if (window_size != -1) { + left_sliding_window_size = window_size - 1; + if (causal) { + right_sliding_window_size = 0; + } else { + right_sliding_window_size = window_size - 1; + } + } else { + left_sliding_window_size = -1; + if (causal) { + right_sliding_window_size = 0; + } else { + right_sliding_window_size = -1; + } + } + + return {left_sliding_window_size, right_sliding_window_size}; + } + FORCE_INLINE static std::pair calcu_kv_tile_pos( int32_t kv_left_pos, int32_t kv_right_pos, int32_t q_left_pos, - int32_t q_right_pos, int32_t sliding_window_left, - int32_t sliding_window_right) { - if (sliding_window_left != -1) { - kv_left_pos = std::max(kv_left_pos, q_left_pos - sliding_window_left); + int32_t q_right_pos, int32_t window_size, bool causal) { + auto [left_sliding_window_size, right_sliding_window_size] = + calcu_sliding_window_size(window_size, causal); + + if (left_sliding_window_size != -1) { + kv_left_pos = + std::max(kv_left_pos, q_left_pos - left_sliding_window_size); } - if (sliding_window_right != -1) { - kv_right_pos = std::min(kv_right_pos, q_right_pos + sliding_window_right); + if (right_sliding_window_size != -1) { + kv_right_pos = + std::min(kv_right_pos, q_right_pos + right_sliding_window_size); } return {kv_left_pos, kv_right_pos}; } @@ -805,10 +877,10 @@ struct AttentionInput { int32_t* block_table; float* alibi_slopes; c10::BFloat16* s_aux; + bool* dynamic_causal; float scale; bool causal; - int32_t sliding_window_left; - int32_t sliding_window_right; + int32_t sliding_window_size; float softcap; // FP8 KV cache scales (used by FP8 attention implementations) float k_scale_fp8 = 1.0f; @@ -822,8 +894,8 @@ struct AttentionInput { logits_buffer_t *__restrict__ logits_buffer, \ float *__restrict__ partial_q_buffer, float *__restrict__ max_buffer, \ float *__restrict__ sum_buffer, int32_t *__restrict__ block_table, \ - const int32_t kv_tile_start_pos, const int32_t kv_tile_end_pos, \ - const int32_t kv_tile_token_num, \ + const int32_t kv_end_pos, const int32_t kv_tile_start_pos, \ + const int32_t kv_tile_end_pos, const int32_t kv_tile_token_num, \ const int64_t kv_cache_num_blocks_stride, const int32_t q_head_num, \ const int32_t q_token_num, const int32_t q_tile_start_pos, \ const int32_t q_heads_per_kv, const int32_t block_size, \ @@ -834,7 +906,7 @@ struct AttentionInput { #define CPU_ATTENTION_PARAMS \ q_heads_buffer, k_head_cache_ptr, v_head_cache_ptr, logits_buffer, \ - partial_q_buffer, max_buffer, sum_buffer, block_table, \ + partial_q_buffer, max_buffer, sum_buffer, block_table, kv_end_pos, \ kv_tile_start_pos, kv_tile_end_pos, kv_tile_token_num, \ kv_cache_num_blocks_stride, q_head_num, q_token_num, q_tile_start_pos, \ q_heads_per_kv, block_size, left_window_size, right_window_size, scale, \ @@ -857,12 +929,10 @@ struct VecTypeTrait { using vec_t = vec_op::BF16Vec16; }; -#if !defined(__powerpc__) template <> struct VecTypeTrait { using vec_t = vec_op::FP16Vec16; }; -#endif template void print_logits(const char* name, T* ptr, int32_t row, int32_t col, @@ -917,6 +987,7 @@ class AttentionMainLoop { // - max_buffer: [MaxQHeadNumPerIteration, 1], store max logits // - sum_buffer: [MaxQHeadNumPerIteration, 1], store sum of exp // - block_table + // - kv_end_pos: un-aligned end position of KV cache // - kv_tile_start_pos: start position of KV cache, aligned to // BlockSizeAlignment // - kv_tile_end_pos: end position of KV cache, aligned to @@ -1043,7 +1114,7 @@ class AttentionMainLoop { } apply_mask(logits_buffer, kv_tile_token_num, q_tile_start_pos, - kv_tile_start_pos, kv_tile_end_pos, q_token_num, + kv_end_pos, kv_tile_start_pos, kv_tile_end_pos, q_token_num, q_heads_per_kv, left_window_size, right_window_size); // if (debug_info){ @@ -1126,7 +1197,7 @@ class AttentionMainLoop { void apply_mask(logits_buffer_t* __restrict__ logits_buffer, const int64_t logits_buffer_stride, - const int32_t q_tile_start_pos, + const int32_t q_tile_start_pos, const int32_t kv_end_pos, const int32_t kv_tile_start_pos, const int32_t kv_tile_end_pos, const int32_t q_token_num, const int32_t q_heads_per_kv, @@ -1154,7 +1225,7 @@ class AttentionMainLoop { std::max(kv_tile_start_pos, curr_token_pos + sliding_window_right + 1)); } - return pos; + return std::min(pos, kv_end_pos); }(); int32_t left_invalid_token_num = left_kv_pos - kv_tile_start_pos; @@ -1394,7 +1465,7 @@ class AttentionMainLoop { public: void operator()(const AttentionInput* input) { - const int thread_num = omp_get_max_threads(); + const int thread_num = cpu_utils::get_max_threads(); TORCH_CHECK_EQ(input->metadata->thread_num, thread_num); std::atomic guard_counter(0); std::atomic* guard_counter_ptr = &guard_counter; @@ -1441,15 +1512,16 @@ class AttentionMainLoop { const int64_t q_head_num_stride = input->query_num_heads_stride; const int64_t kv_cache_head_num_stride = input->cache_num_kv_heads_stride; const int64_t kv_cache_block_num_stride = input->cache_num_blocks_stride; - const int32_t sliding_window_left = input->sliding_window_left; - const int32_t sliding_window_right = input->sliding_window_right; + const int32_t sliding_window_size = input->sliding_window_size; const int32_t block_size = input->block_size; const float scale = input->scale; const float softcap_scale = input->softcap; const float* alibi_slopes = input->alibi_slopes; const c10::BFloat16* s_aux = input->s_aux; + const bool* dynamic_causal = input->dynamic_causal; + const bool is_dynamic_causal = dynamic_causal != nullptr; - const bool casual = input->causal; + const bool causal = input->causal; int32_t* const block_table = input->block_table; const int64_t block_table_stride = input->blt_num_tokens_stride; @@ -1532,6 +1604,11 @@ class AttentionMainLoop { &curr_workitem_groups[workitem_group_idx]; const int32_t current_group_idx = current_workitem_group->req_id; + const int32_t current_group_causal = + is_dynamic_causal ? dynamic_causal[current_group_idx] : causal; + auto [sliding_window_left, sliding_window_right] = + AttentionScheduler::calcu_sliding_window_size( + sliding_window_size, current_group_causal); const int32_t kv_start_pos = current_workitem_group->kv_split_pos_start; const int32_t kv_end_pos = current_workitem_group->kv_split_pos_end; @@ -1559,8 +1636,7 @@ class AttentionMainLoop { const int32_t q_end = input->query_start_loc[current_group_idx + 1]; const int32_t q_start = input->query_start_loc[current_group_idx]; const int32_t seq_len = input->seq_lens[current_group_idx]; - const int32_t q_start_pos = - (casual ? seq_len - (q_end - q_start) : 0); + const int32_t q_start_pos = seq_len - (q_end - q_start); const int32_t block_num = (seq_len + block_size - 1) / block_size; // Only apply sink for the first KV split bool use_sink = (s_aux != nullptr && @@ -1610,8 +1686,8 @@ class AttentionMainLoop { const auto [kv_tile_start_pos, kv_tile_end_pos] = AttentionScheduler::calcu_kv_tile_pos( kv_start_pos, kv_end_pos, q_tile_start_pos, - q_tile_end_pos, sliding_window_left, - sliding_window_right); + q_tile_end_pos, sliding_window_size, + current_group_causal); const auto [rounded_kv_tile_start_pos, rounded_kv_tile_end_pos] = AttentionScheduler::align_kv_tile_pos( kv_tile_start_pos, kv_tile_end_pos, blocksize_alignment); @@ -1724,8 +1800,8 @@ class AttentionMainLoop { actual_kv_tile_pos_right] = AttentionScheduler::calcu_kv_tile_pos( kv_tile_pos_left, kv_tile_pos_right, q_tile_pos_left, - q_tile_pos_right, sliding_window_left, - sliding_window_right); + q_tile_pos_right, sliding_window_size, + current_group_causal); const int32_t q_iter_idx = q_head_tile_token_offset / curr_max_q_token_num_per_iter; @@ -1789,7 +1865,7 @@ class AttentionMainLoop { attn_impl.template execute_attention( curr_q_heads_buffer, curr_k_cache, curr_v_cache, logits_buffer, curr_partial_q_buffer, curr_max_buffer, - curr_sum_buffer, curr_block_table, + curr_sum_buffer, curr_block_table, kv_end_pos, aligned_actual_kv_tile_pos_left, aligned_actual_kv_tile_pos_right, actual_kv_token_num, kv_cache_block_num_stride, q_tile_head_num, diff --git a/csrc/cpu/cpu_attn_vsx.hpp b/csrc/cpu/cpu_attn_vsx.hpp index c7e1502bcb05..562a53125717 100644 --- a/csrc/cpu/cpu_attn_vsx.hpp +++ b/csrc/cpu/cpu_attn_vsx.hpp @@ -50,7 +50,16 @@ FORCE_INLINE void load_row8_B_as_f32(const c10::BFloat16* p, b1 = (__vector float)vec_mergel(zeros, raw); } -// Note: c10::Half (FP16) is not supported on PowerPC architecture +// [3] Half (FP16) Specialization +template <> +FORCE_INLINE void load_row8_B_as_f32(const c10::Half* p, + __vector float& b0, + __vector float& b1) { + vec_op::FP16Vec8 fp16_vec(p); + vec_op::FP32Vec8 fp32_vec(fp16_vec); + b0 = fp32_vec.reg.val[0]; + b1 = fp32_vec.reg.val[1]; +} template FORCE_INLINE void gemm_micro_ppc64le_Mx8_Ku4( @@ -314,8 +323,6 @@ class AttentionImpl { const int64_t num_blocks_stride, const int64_t cache_head_num_stride, const int64_t block_size, const int64_t block_size_stride, const float k_inv = 0.0f, const float v_inv = 0.0f) { - // k_inv and v_inv are unused on VSX: FP8 KV cache is not supported on - // PowerPC. The parameters are present to match the common interface. #pragma omp parallel for collapse(2) for (int64_t token_idx = 0; token_idx < token_num; ++token_idx) { for (int64_t head_idx = 0; head_idx < head_num; ++head_idx) { diff --git a/csrc/cpu/cpu_fused_moe.cpp b/csrc/cpu/cpu_fused_moe.cpp index 5839d6c2aaf3..07b0aaf86888 100644 --- a/csrc/cpu/cpu_fused_moe.cpp +++ b/csrc/cpu/cpu_fused_moe.cpp @@ -14,6 +14,18 @@ #define AMX_DISPATCH(...) case cpu_utils::ISA::AMX: #endif +#if defined(ARM_BF16_SUPPORT) + #include "cpu/micro_gemm/cpu_micro_gemm_neon.hpp" + #define NEON_DISPATCH(...) \ + case cpu_utils::ISA::NEON: { \ + using gemm_t = \ + cpu_micro_gemm::MicroGemm; \ + return __VA_ARGS__(); \ + } +#else + #define NEON_DISPATCH(...) case cpu_utils::ISA::NEON: +#endif + #define CPU_ISA_DISPATCH_IMPL(ISA_TYPE, ...) \ [&] { \ switch (ISA_TYPE) { \ @@ -23,6 +35,7 @@ cpu_micro_gemm::MicroGemm; \ return __VA_ARGS__(); \ } \ + NEON_DISPATCH(__VA_ARGS__) \ default: { \ TORCH_CHECK(false, "Invalid CPU ISA type."); \ } \ @@ -57,10 +70,12 @@ void swigluoai_and_mul(float* __restrict__ input, scalar_t* __restrict__ output, const int32_t input_stride, const int32_t output_stride) { using scalar_vec_t = typename cpu_utils::VecTypeTrait::vec_t; +#if !defined(__aarch64__) // For GPT-OSS interleaved gate-up weights alignas(64) static int32_t index[16] = {0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30}; vec_op::INT32Vec16 index_vec(index); +#endif vec_op::FP32Vec16 gate_up_max_vec(7.0); vec_op::FP32Vec16 up_min_vec(-7.0); vec_op::FP32Vec16 alpha_vec(1.702); @@ -70,8 +85,15 @@ void swigluoai_and_mul(float* __restrict__ input, scalar_t* __restrict__ output, for (int32_t m = 0; m < m_size; ++m) { for (int32_t n = 0; n < n_size; n += 32) { + // Note: AdvSIMD does not support gather loads +#if defined(__aarch64__) + vec_op::FP32Vec16 gate_vec(vec_op::uninit); + vec_op::FP32Vec16 up_vec(vec_op::uninit); + vec_op::FP32Vec16::load_even_odd(input + n, gate_vec, up_vec); +#else vec_op::FP32Vec16 gate_vec(input + n, index_vec); vec_op::FP32Vec16 up_vec(input + n + 1, index_vec); +#endif gate_vec = gate_vec.min(gate_up_max_vec); up_vec = up_vec.clamp(up_min_vec, gate_up_max_vec); auto sigmoid_vec = one_vec / (one_vec + fast_exp(-gate_vec * alpha_vec)); @@ -163,7 +185,6 @@ void gelu_tanh_and_mul(float* __restrict__ input, scalar_t* __restrict__ output, vec_op::FP32Vec16 w1_vec(0.7978845608028654); vec_op::FP32Vec16 w2_vec(0.5); vec_op::FP32Vec16 w3_vec(0.044715); - alignas(64) float temp[16]; for (int32_t m = 0; m < m_size; ++m) { for (int32_t n = 0; n < dim; n += 16) { @@ -171,12 +192,9 @@ void gelu_tanh_and_mul(float* __restrict__ input, scalar_t* __restrict__ output, vec_op::FP32Vec16 up_vec(up + n); auto gate_pow3_vec = gate_vec * gate_vec * gate_vec; auto inner_vec = w1_vec * (gate_vec + w3_vec * gate_pow3_vec); - - inner_vec.save(temp); - for (int32_t i = 0; i < 16; ++i) { - temp[i] = std::tanh(temp[i]); - } - vec_op::FP32Vec16 tanh_vec(temp); + // Note: can't use fast_exp form because diffusiongemma will generate + // wrong results + auto tanh_vec = inner_vec.tanh(); auto gelu_tanh = gate_vec * w2_vec * (one_vec + tanh_vec); auto gated_output_fp32 = up_vec * gelu_tanh; scalar_vec_t gated_output = scalar_vec_t(gated_output_fp32); @@ -242,13 +260,14 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, constexpr int32_t gemm_n_tile_size = gemm_t::NSize; constexpr int32_t gemm_m_tile_size = gemm_t::MaxMSize; constexpr int32_t min_w13_n_tile_size = 2 * gemm_n_tile_size; + constexpr bool pack_a = gemm_t::PackA; static_assert(gemm_n_tile_size % 16 == 0); TORCH_CHECK_EQ(output_size_13 % min_w13_n_tile_size, 0); TORCH_CHECK_EQ(output_size_2 % gemm_n_tile_size, 0); TORCH_CHECK_EQ(output_size_13 / 2, input_size_2); - const int32_t thread_num = omp_get_max_threads(); + const int32_t thread_num = cpu_utils::get_max_threads(); const int32_t w13_input_buffer_size = cpu_utils::round_up<64>( gemm_m_tile_size * input_size_13 * sizeof(scalar_t)); @@ -268,12 +287,18 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, const int32_t w2_input_tile_size = cpu_utils::round_up<64>( gemm_m_tile_size * input_size_2 * sizeof(scalar_t)); + // use w2 input buffer only when we need to pack input + const int32_t w2_input_buffer_size = + pack_a ? cpu_utils::round_up<64>(gemm_m_tile_size * input_size_2 * + sizeof(scalar_t)) + : 0; const int32_t w2_n_tile_size = [&]() { const int64_t cache_size = cpu_utils::get_available_l2_size(); - // input tile + weight + // input tile + optional packed input + weight const int32_t n_size_cache_limit = - (cache_size - w2_input_tile_size) / (input_size_2 * sizeof(scalar_t)); + (cache_size - (pack_a ? w2_input_buffer_size : w2_input_tile_size)) / + (input_size_2 * sizeof(scalar_t)); const int32_t n_size_thread_limit = output_size_2 / std::max(1, thread_num / topk_num); const int32_t n_size = cpu_utils::round_down( @@ -326,6 +351,9 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, const int32_t w13_output_buffer_offset = w13_thread_buffer_offset; w13_thread_buffer_offset += w13_output_buffer_size; + const int32_t w2_input_buffer_offset = w13_thread_buffer_offset; + w13_thread_buffer_offset += w2_input_buffer_size; + // Weighted sum thread buffer const int32_t ws_output_buffer_size = cpu_utils::round_up<64>(output_size_2 * sizeof(float)); @@ -405,7 +433,8 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, gemm_t gemm; const int32_t input_size_13_bytes = input_size_13 * sizeof(scalar_t); - const int32_t w13_n_group_stride = 16 * input_size_13; + const int32_t w13_n_group_stride = + gemm_t::WeightOCGroupSize * input_size_13; const int32_t w13_n_tile_stride = gemm_n_tile_size * input_size_13; for (;;) { @@ -468,8 +497,23 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, token_idx += gemm_m_tile_size) { const int32_t actual_token_num = std::min(gemm_m_tile_size, curr_token_num - token_idx); - // copy inputs - { + + scalar_t* __restrict__ curr_w13_gemm_input_buffer = nullptr; + if constexpr (pack_a) { + // copy and pack inputs + curr_w13_gemm_input_buffer = w13_input_buffer; + const scalar_t* w13_input_rows[gemm_m_tile_size]; + for (int32_t i = 0; i < actual_token_num; ++i) { + w13_input_rows[i] = + input + curr_expand_token_id_buffer[i] * input_size_13; + } + gemm_t::pack_input_from_rows(w13_input_rows, + curr_w13_gemm_input_buffer, + actual_token_num, input_size_13); + curr_expand_token_id_buffer += actual_token_num; + } else { + // copy inputs + curr_w13_gemm_input_buffer = curr_w13_input_buffer; scalar_t* __restrict__ curr_w13_input_buffer_iter = curr_w13_input_buffer; for (int32_t i = 0; i < actual_token_num; ++i) { @@ -501,14 +545,12 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, scalar_t* __restrict__ w13_weight_ptr_1_iter = w13_weight_ptr_1; scalar_t* __restrict__ w13_bias_ptr_0_iter = w13_bias_ptr_0; scalar_t* __restrict__ w13_bias_ptr_1_iter = w13_bias_ptr_1; - scalar_t* __restrict__ curr_w13_input_buffer_iter = - curr_w13_input_buffer; float* __restrict__ w13_output_buffer_0_iter = w13_output_buffer; float* __restrict__ w13_output_buffer_1_iter = w13_output_buffer + actual_n_tile_size / 2; for (int32_t i = 0; i < actual_n_tile_size; i += min_w13_n_tile_size) { - gemm.gemm(curr_w13_input_buffer_iter, w13_weight_ptr_0_iter, + gemm.gemm(curr_w13_gemm_input_buffer, w13_weight_ptr_0_iter, w13_output_buffer_0_iter, actual_token_num, input_size_13, input_size_13, w13_n_group_stride, actual_n_tile_size, false); @@ -521,7 +563,7 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, w13_bias_ptr_0_iter += gemm_n_tile_size; } - gemm.gemm(curr_w13_input_buffer_iter, w13_weight_ptr_1_iter, + gemm.gemm(curr_w13_gemm_input_buffer, w13_weight_ptr_1_iter, w13_output_buffer_1_iter, actual_token_num, input_size_13, input_size_13, w13_n_group_stride, actual_n_tile_size, false); @@ -574,7 +616,8 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, gemm_t gemm; const int32_t w2_n_tile_stride = gemm_n_tile_size * input_size_2; - const int32_t w2_n_group_stride = 16 * input_size_2; + const int32_t w2_n_group_stride = + gemm_t::WeightOCGroupSize * input_size_2; for (;;) { int32_t task_id = counter_ptr->acquire_counter(); @@ -613,13 +656,30 @@ void fused_moe_impl(scalar_t* __restrict__ output, scalar_t* __restrict__ input, token_idx += gemm_m_tile_size) { const int32_t actual_token_num = std::min(gemm_m_tile_size, curr_token_num - token_idx); + scalar_t* __restrict__ curr_w2_gemm_input_buffer = + curr_w13_gemm_output_buffer; + if constexpr (pack_a) { + uint8_t* __restrict__ thread_buffer = + thread_buffer_start + thread_id * w13_thread_buffer_offset; + scalar_t* __restrict__ w2_input_buffer = + reinterpret_cast(thread_buffer + + w2_input_buffer_offset); + curr_w2_gemm_input_buffer = w2_input_buffer; + const scalar_t* w2_input_rows[gemm_m_tile_size]; + for (int32_t i = 0; i < actual_token_num; ++i) { + w2_input_rows[i] = curr_w13_gemm_output_buffer + i * input_size_2; + } + gemm_t::pack_input_from_rows(w2_input_rows, + curr_w2_gemm_input_buffer, + actual_token_num, input_size_2); + } scalar_t* __restrict__ w2_weight_ptr_iter = w2_weight_ptr; scalar_t* __restrict__ w2_bias_ptr_iter = w2_bias_ptr; float* __restrict__ curr_w2_gemm_output_buffer_iter = curr_w2_gemm_output_buffer; for (int32_t i = 0; i < actual_n_tile_size; i += gemm_n_tile_size) { - gemm.gemm(curr_w13_gemm_output_buffer, w2_weight_ptr_iter, + gemm.gemm(curr_w2_gemm_input_buffer, w2_weight_ptr_iter, curr_w2_gemm_output_buffer_iter, actual_token_num, input_size_2, input_size_2, w2_n_group_stride, output_size_2, false); diff --git a/csrc/cpu/cpu_tanhf_neon.hpp b/csrc/cpu/cpu_tanhf_neon.hpp new file mode 100644 index 000000000000..2ea7f3365134 --- /dev/null +++ b/csrc/cpu/cpu_tanhf_neon.hpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +#ifndef CPU_TANHF_NEON_HPP +#define CPU_TANHF_NEON_HPP + +#include +#include + +namespace vec_op { + +namespace { + +struct TanhfConstants { + float32x4_t special_bound; + float32x4_t two; + float32x4_t c0; + float32x4_t c2; + int32x4_t exponent_bias; + float c1; + float c3; + float two_over_ln2; + float c4; + float ln2_hi; + float ln2_lo; +}; + +const TanhfConstants kTanhfConstants = { + // 9.01, above which tanhf rounds to 1 (or -1 for negative). + .special_bound = vdupq_n_f32(0x1.205966p+3f), + .two = vdupq_n_f32(0x1.0p+1f), + .c0 = vdupq_n_f32(0x1.fffffep-2f), + .c2 = vdupq_n_f32(0x1.555736p-5f), + .exponent_bias = vdupq_n_s32(0x3f800000), + .c1 = 0x1.5554aep-3f, + .c3 = 0x1.12287cp-7f, + .two_over_ln2 = 0x1.715476p+1f, + .c4 = 0x1.6b55a2p-10f, + .ln2_hi = 0x1.62e4p-1f, + .ln2_lo = 0x1.7f7d1cp-20f, +}; + +// Return the ptr but hide it's value from the compiler so accesses +// through it can't be optimised based on contents. +template +inline const T* ptr_barrier(const T* ptr) { + const T* opaque_ptr = ptr; + __asm__("" : "+r"(opaque_ptr)); + return opaque_ptr; +} + +// Check whether any lanes in the mask are set +inline bool any_u32(uint32x4_t x) { return vmaxvq_u32(x) != 0; } + +// e^2x - 1 inline helper +inline float32x4_t e2xm1f_inline(float32x4_t x, const TanhfConstants* d) { + float32x2_t ln2 = vld1_f32(&d->ln2_hi); + float32x4_t lane_consts = vld1q_f32(&d->c1); + + // Reduce argument: f in [-ln2/2, ln2/2], i is exact. + float32x4_t j = vrndaq_f32(vmulq_laneq_f32(x, lane_consts, 2)); + int32x4_t i = vcvtq_s32_f32(j); + float32x4_t f = vaddq_f32(x, x); + f = vfmsq_lane_f32(f, j, ln2, 0); + f = vfmsq_lane_f32(f, j, ln2, 1); + + // Approximate expm1(f) with polynomial P, expm1(f) ~= f + f^2 * P(f) + float32x4_t f2 = vmulq_f32(f, f); + float32x4_t f4 = vmulq_f32(f2, f2); + float32x4_t p01 = vfmaq_laneq_f32(d->c0, f, lane_consts, 0); + float32x4_t p23 = vfmaq_laneq_f32(d->c2, f, lane_consts, 1); + float32x4_t poly = vfmaq_f32(p01, f2, p23); + poly = vfmaq_laneq_f32(poly, f4, lane_consts, 3); + poly = vfmaq_f32(f, f2, poly); + + // scale = 2^i + int32x4_t u = vaddq_s32(vshlq_n_s32(i, 23), d->exponent_bias); + float32x4_t scale = vreinterpretq_f32_s32(u); + return vfmaq_f32(vsubq_f32(scale, vdupq_n_f32(1.0f)), poly, scale); +} + +// Calculate the result tanh(x) = q / (q+2) and set special lanes to ±1 +inline float32x4_t special_case(float32x4_t x, float32x4_t q, + uint32x4_t special) { + const TanhfConstants* d = ptr_barrier(&kTanhfConstants); + + float32x4_t y = vdivq_f32(q, vaddq_f32(q, d->two)); + uint32x4_t ix = vreinterpretq_u32_f32(x); + uint32x4_t one_bits = vreinterpretq_u32_s32(d->exponent_bias); + uint32x4_t sign_mask = vdupq_n_u32(0x80000000u); + uint32x4_t special_bits = vbslq_u32(sign_mask, ix, one_bits); + float32x4_t special_y = vreinterpretq_f32_u32(special_bits); + return vbslq_f32(special, special_y, y); +} + +} // namespace + +// Implementation of tanhf adapted from Arm Optimized Routines (tanhf +// AdvSIMD) +// https://github.com/ARM-software/optimized-routines/blob/master/math/aarch64/advsimd/tanhf.c +// +// Approximation for single-precision vector tanh(x), using a simplified +// version of expm1f. The maximum error is 2.08 + 0.5 ULP: +// _ZGVnN4v_tanhf (0x1.fa5eep-5) got 0x1.f9ba02p-5 want 0x1.f9ba08p-5. +inline float32x4_t fast_tanhf_f32x4(float32x4_t x) { + const TanhfConstants* d = ptr_barrier(&kTanhfConstants); + + // tanh(x) = (e^2x - 1) / (e^2x + 1) + // q = e^2x -1 + float32x4_t q = e2xm1f_inline(x, d); + + // Check for special cases + uint32x4_t special = vcagtq_f32(x, d->special_bound); + + // Fall back to vectorised special case for any lanes which would cause + // expm1 to overflow + if (any_u32(special)) { + return special_case(x, q, special); + } + + // Complete fast path if no special lanes + // tanh(x) = q / (q+2) + return vdivq_f32(q, vaddq_f32(q, d->two)); +} + +} // namespace vec_op + +#endif // CPU_TANHF_NEON_HPP \ No newline at end of file diff --git a/csrc/cpu/cpu_types.hpp b/csrc/cpu/cpu_types.hpp index 744c80c8f53c..50ef5e10b830 100644 --- a/csrc/cpu/cpu_types.hpp +++ b/csrc/cpu/cpu_types.hpp @@ -4,7 +4,7 @@ #if defined(__x86_64__) // x86 implementation #include "cpu_types_x86.hpp" -#elif defined(__POWER9_VECTOR__) +#elif defined(__powerpc__) // ppc implementation #include "cpu_types_vsx.hpp" #elif defined(__s390x__) @@ -25,4 +25,20 @@ #include #endif -#endif \ No newline at end of file +#include + +namespace cpu_utils { +// Without OpenMP the omp pragmas compile to serial loops, so report 1: kernels +// that barrier on the thread count would otherwise deadlock. +inline int get_max_threads() { +#ifdef _OPENMP + return omp_get_max_threads(); +#else + TORCH_WARN_ONCE( + "vLLM CPU was built without OpenMP; running single-threaded."); + return 1; +#endif +} +} // namespace cpu_utils + +#endif diff --git a/csrc/cpu/cpu_types_arm.hpp b/csrc/cpu/cpu_types_arm.hpp index b408731f40d1..294dee90bd80 100644 --- a/csrc/cpu/cpu_types_arm.hpp +++ b/csrc/cpu/cpu_types_arm.hpp @@ -3,6 +3,8 @@ #include +#include "cpu/cpu_tanhf_neon.hpp" + #include #include #include @@ -345,6 +347,10 @@ struct FP32Vec4 : public VectorizedRegWrapper { explicit FP32Vec4(float32x4_t data) : Base(VectorizedT(data)) {}; explicit FP32Vec4(const FP32Vec4& data) : Base(data) {}; + + FORCE_INLINE FP32Vec4 tanh() const { + return FP32Vec4(fast_tanhf_f32x4(reg.val[0])); + } }; struct FP32Vec8 : public VectorizedRegWrapper { @@ -391,6 +397,13 @@ struct FP32Vec8 : public VectorizedRegWrapper { reg.val[1] = Vectorized(data.val[1]); } + FORCE_INLINE FP32Vec8 tanh() const { + FP32Vec8 r(uninit); + r.reg.val[0] = Vectorized(fast_tanhf_f32x4(reg.val[0])); + r.reg.val[1] = Vectorized(fast_tanhf_f32x4(reg.val[1])); + return r; + } + FORCE_INLINE float reduce_sum() const noexcept { float answer = 0; std::plus add; @@ -497,6 +510,35 @@ struct FP32Vec16 : public VectorizedRegWrapper { reg.val[3] = Vectorized(vcvt_f32_f16(vget_high_f16(v.reg.val[1]))); }; + FORCE_INLINE FP32Vec16 tanh() const { + FP32Vec16 r(uninit); + r.reg.val[0] = Vectorized(fast_tanhf_f32x4(reg.val[0])); + r.reg.val[1] = Vectorized(fast_tanhf_f32x4(reg.val[1])); + r.reg.val[2] = Vectorized(fast_tanhf_f32x4(reg.val[2])); + r.reg.val[3] = Vectorized(fast_tanhf_f32x4(reg.val[3])); + return r; + } + + static FORCE_INLINE void load_even_odd(const float* ptr, FP32Vec16& even, + FP32Vec16& odd) noexcept { + const float32x4x2_t x01 = vuzpq_f32(vld1q_f32(ptr), vld1q_f32(ptr + 4)); + const float32x4x2_t x23 = + vuzpq_f32(vld1q_f32(ptr + 8), vld1q_f32(ptr + 12)); + const float32x4x2_t x45 = + vuzpq_f32(vld1q_f32(ptr + 16), vld1q_f32(ptr + 20)); + const float32x4x2_t x67 = + vuzpq_f32(vld1q_f32(ptr + 24), vld1q_f32(ptr + 28)); + + even.reg.val[0] = VectorizedT(x01.val[0]); + even.reg.val[1] = VectorizedT(x23.val[0]); + even.reg.val[2] = VectorizedT(x45.val[0]); + even.reg.val[3] = VectorizedT(x67.val[0]); + odd.reg.val[0] = VectorizedT(x01.val[1]); + odd.reg.val[1] = VectorizedT(x23.val[1]); + odd.reg.val[2] = VectorizedT(x45.val[1]); + odd.reg.val[3] = VectorizedT(x67.val[1]); + } + FORCE_INLINE FP32Vec16 operator+(const FP32Vec16& b) const noexcept { FP32Vec16 r(uninit); r.reg.val[0] = reg.val[0] + b.reg.val[0]; @@ -515,6 +557,15 @@ struct FP32Vec16 : public VectorizedRegWrapper { return r; } + FORCE_INLINE FP32Vec16 operator-() const noexcept { + FP32Vec16 r(uninit); + r.reg.val[0] = reg.val[0].neg(); + r.reg.val[1] = reg.val[1].neg(); + r.reg.val[2] = reg.val[2].neg(); + r.reg.val[3] = reg.val[3].neg(); + return r; + } + FORCE_INLINE FP32Vec16 operator*(const FP32Vec16& b) const noexcept { FP32Vec16 r(uninit); r.reg.val[0] = reg.val[0] * b.reg.val[0]; @@ -933,4 +984,4 @@ inline void storeFP32(float v, c10::BFloat16* ptr) { inline void prefetch(const void* addr) { __builtin_prefetch(addr, 0, 1); }; -}; // namespace vec_op \ No newline at end of file +}; // namespace vec_op diff --git a/csrc/cpu/cpu_types_riscv_defs.hpp b/csrc/cpu/cpu_types_riscv_defs.hpp index 8871617f05f2..16475505d9f5 100644 --- a/csrc/cpu/cpu_types_riscv_defs.hpp +++ b/csrc/cpu/cpu_types_riscv_defs.hpp @@ -3,13 +3,17 @@ // VLEN-to-LMUL mapping for RISC-V Vector extension. // -// LMUL_ expands to the LMUL suffix giving N total bits of vector data: -// VLEN=128: LMUL_128=m1, LMUL_256=m2, LMUL_512=m4, LMUL_1024=m8 -// VLEN=256: LMUL_128=mf2, LMUL_256=m1, LMUL_512=m2, LMUL_1024=m4 +// LMUL_ expands to the LMUL suffix giving N total bits of vector data. +// LMUL_64 is used by 8-lane int8/uint8 vectors. +// VLEN=128: +// LMUL_64=mf2, LMUL_128=m1, LMUL_256=m2, LMUL_512=m4, LMUL_1024=m8 +// VLEN=256: +// LMUL_64=mf4, LMUL_128=mf2, LMUL_256=m1, LMUL_512=m2, LMUL_1024=m4 #include #if __riscv_v_min_vlen == 128 + #define LMUL_64 mf2 #define LMUL_128 m1 #define LMUL_256 m2 #define LMUL_512 m4 @@ -17,6 +21,7 @@ #define BOOL_256 b16 #define BOOL_512 b8 #elif __riscv_v_min_vlen == 256 + #define LMUL_64 mf4 #define LMUL_128 mf2 #define LMUL_256 m1 #define LMUL_512 m2 @@ -41,6 +46,16 @@ // ---- Semantic fixed-vector typedefs (named by element count) ---- +// uint8 / int8 +typedef RVVTYPE(vuint8, LMUL_64, _t) fixed_u8x8_t + __attribute__((riscv_rvv_vector_bits(64))); +typedef RVVTYPE(vint8, LMUL_64, _t) fixed_i8x8_t + __attribute__((riscv_rvv_vector_bits(64))); + +// int16 +typedef RVVTYPE(vint16, LMUL_128, _t) fixed_i16x8_t + __attribute__((riscv_rvv_vector_bits(128))); + // float16 typedef RVVTYPE(vfloat16, LMUL_128, _t) fixed_fp16x8_t __attribute__((riscv_rvv_vector_bits(128))); @@ -57,6 +72,10 @@ typedef RVVTYPE(vfloat32, LMUL_512, _t) fixed_fp32x16_t typedef RVVTYPE(vfloat32, LMUL_1024, _t) fixed_fp32x32_t __attribute__((riscv_rvv_vector_bits(1024))); +// int8 +typedef RVVTYPE(vint8, LMUL_128, _t) fixed_i8x16_t + __attribute__((riscv_rvv_vector_bits(128))); + // int32 typedef RVVTYPE(vint32, LMUL_256, _t) fixed_i32x8_t __attribute__((riscv_rvv_vector_bits(256))); diff --git a/csrc/cpu/cpu_types_riscv_impl.hpp b/csrc/cpu/cpu_types_riscv_impl.hpp index 06a38c780a2b..70cb0ab52de0 100644 --- a/csrc/cpu/cpu_types_riscv_impl.hpp +++ b/csrc/cpu/cpu_types_riscv_impl.hpp @@ -9,10 +9,14 @@ #include #include +#include #include #include #include #include + +#include "float_convert.hpp" + namespace vec_op { // FP8 KV cache is not supported on RISC-V. These tag types and the @@ -210,11 +214,18 @@ struct BF16Vec32 : public Vec { explicit BF16Vec32(const BF16Vec8& v) { fixed_u16x8_t u16_val = bf16_to_u16(v.reg); - fixed_u16x32_t u16_combined = - RVVI4(__riscv_vcreate_v_u16, LMUL_128, _u16, LMUL_512)( - u16_val, u16_val, u16_val, u16_val); - reg = RVVI4(__riscv_vreinterpret_v_u16, LMUL_512, _bf16, - LMUL_512)(u16_combined); + // Widen LMUL_128 → LMUL_256 so vslideup operands share a type. + // At VLEN=256 this is mf2→m1 (both integer); at VLEN=128 it is m1→m2. + fixed_u16x16_t ext = + RVVI4(__riscv_vlmul_ext_v_u16, LMUL_128, _u16, LMUL_256)(u16_val); + // Build 16-element half: place the 8 elements at offsets 0 and 8. + fixed_u16x16_t half = RVVI(__riscv_vmv_v_x_u16, LMUL_256)(0, 16); + half = RVVI(__riscv_vslideup_vx_u16, LMUL_256)(half, ext, 0, 8); + half = RVVI(__riscv_vslideup_vx_u16, LMUL_256)(half, ext, 8, 16); + // Double to LMUL_512 (m1→m2 at VLEN=256, m2→m4 at VLEN=128). + fixed_u16x32_t dst = + RVVI4(__riscv_vcreate_v_u16, LMUL_256, _u16, LMUL_512)(half, half); + reg = RVVI4(__riscv_vreinterpret_v_u16, LMUL_512, _bf16, LMUL_512)(dst); }; void save(void* ptr) const { @@ -245,8 +256,7 @@ struct BF16Vec8 : public Vec { const uint16_t* u16 = static_cast(ptr); float tmp[8]; for (int i = 0; i < 8; ++i) { - uint32_t v = static_cast(u16[i]) << 16; - std::memcpy(&tmp[i], &v, 4); + tmp[i] = bf16_to_float(u16[i]); } reg_fp32 = RVVI(__riscv_vle32_v_f32, LMUL_256)(tmp, 8); } @@ -256,9 +266,7 @@ struct BF16Vec8 : public Vec { RVVI(__riscv_vse32_v_f32, LMUL_256)(tmp, reg_fp32, 8); uint16_t* u16 = static_cast(ptr); for (int i = 0; i < 8; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - u16[i] = static_cast(v >> 16); + u16[i] = float_to_bf16(tmp[i]); } } void save(void* ptr, int elem_num) const { @@ -266,9 +274,7 @@ struct BF16Vec8 : public Vec { RVVI(__riscv_vse32_v_f32, LMUL_256)(tmp, reg_fp32, 8); uint16_t* u16 = static_cast(ptr); for (int i = 0; i < elem_num; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - u16[i] = static_cast(v >> 16); + u16[i] = float_to_bf16(tmp[i]); } } void save_strided(void* ptr, ptrdiff_t stride) const { @@ -277,10 +283,8 @@ struct BF16Vec8 : public Vec { uint8_t* u8 = static_cast(ptr); ptrdiff_t byte_stride = stride * sizeof(uint16_t); for (int i = 0; i < 8; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - uint16_t val = static_cast(v >> 16); - *reinterpret_cast(u8 + i * byte_stride) = val; + *reinterpret_cast(u8 + i * byte_stride) = + float_to_bf16(tmp[i]); } } }; @@ -292,8 +296,7 @@ struct BF16Vec16 : public Vec { const uint16_t* u16 = static_cast(ptr); float tmp[16]; for (int i = 0; i < 16; ++i) { - uint32_t v = static_cast(u16[i]) << 16; - std::memcpy(&tmp[i], &v, 4); + tmp[i] = bf16_to_float(u16[i]); } reg_fp32 = RVVI(__riscv_vle32_v_f32, LMUL_512)(tmp, 16); } @@ -306,9 +309,7 @@ struct BF16Vec16 : public Vec { RVVI(__riscv_vse32_v_f32, LMUL_512)(tmp, reg_fp32, 16); uint16_t* u16 = static_cast(ptr); for (int i = 0; i < 16; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - u16[i] = static_cast(v >> 16); + u16[i] = float_to_bf16(tmp[i]); } } void save(void* ptr, int elem_num) const { @@ -316,9 +317,7 @@ struct BF16Vec16 : public Vec { RVVI(__riscv_vse32_v_f32, LMUL_512)(tmp, reg_fp32, 16); uint16_t* u16 = static_cast(ptr); for (int i = 0; i < elem_num; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - u16[i] = static_cast(v >> 16); + u16[i] = float_to_bf16(tmp[i]); } } void save_strided(void* ptr, ptrdiff_t stride) const { @@ -327,10 +326,8 @@ struct BF16Vec16 : public Vec { uint8_t* u8 = static_cast(ptr); ptrdiff_t byte_stride = stride * sizeof(uint16_t); for (int i = 0; i < 16; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - uint16_t val = static_cast(v >> 16); - *reinterpret_cast(u8 + i * byte_stride) = val; + *reinterpret_cast(u8 + i * byte_stride) = + float_to_bf16(tmp[i]); } } }; @@ -343,8 +340,7 @@ struct BF16Vec32 : public Vec { const uint16_t* u16 = static_cast(ptr); float tmp[32]; for (int i = 0; i < 32; ++i) { - uint32_t v = static_cast(u16[i]) << 16; - std::memcpy(&tmp[i], &v, 4); + tmp[i] = bf16_to_float(u16[i]); } reg_fp32 = RVVI(__riscv_vle32_v_f32, LMUL_1024)(tmp, 32); } @@ -371,9 +367,7 @@ struct BF16Vec32 : public Vec { RVVI(__riscv_vse32_v_f32, LMUL_1024)(tmp, reg_fp32, 32); uint16_t* u16 = static_cast(ptr); for (int i = 0; i < 32; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - u16[i] = static_cast(v >> 16); + u16[i] = float_to_bf16(tmp[i]); } } @@ -382,9 +376,7 @@ struct BF16Vec32 : public Vec { RVVI(__riscv_vse32_v_f32, LMUL_1024)(tmp, reg_fp32, 32); uint16_t* u16 = static_cast(ptr); for (int i = 0; i < elem_num; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - u16[i] = static_cast(v >> 16); + u16[i] = float_to_bf16(tmp[i]); } } @@ -394,10 +386,8 @@ struct BF16Vec32 : public Vec { uint8_t* u8 = static_cast(ptr); ptrdiff_t byte_stride = stride * sizeof(uint16_t); for (int i = 0; i < 32; ++i) { - uint32_t v; - std::memcpy(&v, &tmp[i], 4); - uint16_t val = static_cast(v >> 16); - *reinterpret_cast(u8 + i * byte_stride) = val; + *reinterpret_cast(u8 + i * byte_stride) = + float_to_bf16(tmp[i]); } } }; @@ -640,17 +630,29 @@ struct FP32Vec16 : public Vec { data.reg, data.reg)) {}; explicit FP32Vec16(const FP32Vec16& data) : reg(data.reg) {}; explicit FP32Vec16(int64_t value, const FP32Vec16& lut) { - const uint64_t q_values = static_cast(value); - auto packed = RVVI(__riscv_vmv_v_x_u64, LMUL_1024)(q_values, VEC_ELEM_NUM); - auto lane_ids = RVVI(__riscv_vid_v_u64, LMUL_1024)(VEC_ELEM_NUM); - auto shifts = - RVVI(__riscv_vsll_vx_u64, LMUL_1024)(lane_ids, 2, VEC_ELEM_NUM); - auto shifted = - RVVI(__riscv_vsrl_vv_u64, LMUL_1024)(packed, shifts, VEC_ELEM_NUM); - auto idx64 = - RVVI(__riscv_vand_vx_u64, LMUL_1024)(shifted, 0xF, VEC_ELEM_NUM); - auto idx32 = RVVI(__riscv_vnsrl_wx_u32, LMUL_512)(idx64, 0, VEC_ELEM_NUM); - reg = RVVI(__riscv_vrgather_vv_f32, LMUL_512)(lut.reg, idx32, VEC_ELEM_NUM); + // Split into two 32-bit halves to avoid u64 @ LMUL_1024 (m8 on + // VLEN=128 / m4 on VLEN=256), which causes heavy register spilling. + constexpr int HALF = VEC_ELEM_NUM / 2; + const auto q = static_cast(value); + const uint32_t lo = static_cast(q); + const uint32_t hi = static_cast(q >> 32); + + auto lane_ids = RVVI(__riscv_vid_v_u32, LMUL_256)(HALF); + auto shifts = RVVI(__riscv_vsll_vx_u32, LMUL_256)(lane_ids, 2, HALF); + + auto packed_lo = RVVI(__riscv_vmv_v_x_u32, LMUL_256)(lo, HALF); + auto idx_lo = RVVI(__riscv_vand_vx_u32, LMUL_256)( + RVVI(__riscv_vsrl_vv_u32, LMUL_256)(packed_lo, shifts, HALF), 0xF, + HALF); + + auto packed_hi = RVVI(__riscv_vmv_v_x_u32, LMUL_256)(hi, HALF); + auto idx_hi = RVVI(__riscv_vand_vx_u32, LMUL_256)( + RVVI(__riscv_vsrl_vv_u32, LMUL_256)(packed_hi, shifts, HALF), 0xF, + HALF); + + auto idx = + RVVI4(__riscv_vcreate_v_u32, LMUL_256, _u32, LMUL_512)(idx_lo, idx_hi); + reg = RVVI(__riscv_vrgather_vv_f32, LMUL_512)(lut.reg, idx, VEC_ELEM_NUM); } explicit FP32Vec16(const FP16Vec16& v); @@ -734,10 +736,18 @@ struct FP32Vec16 : public Vec { return FP32Vec16( RVVI(__riscv_vfmax_vv_f32, LMUL_512)(reg, b.reg, VEC_ELEM_NUM)); } + FP32Vec16 max(const FP32Vec16& b, const int elem_num) const { + return FP32Vec16( + RVVI(__riscv_vfmax_vv_f32, LMUL_512)(reg, b.reg, elem_num)); + } FP32Vec16 min(const FP32Vec16& b) const { return FP32Vec16( RVVI(__riscv_vfmin_vv_f32, LMUL_512)(reg, b.reg, VEC_ELEM_NUM)); } + FP32Vec16 min(const FP32Vec16& b, const int elem_num) const { + return FP32Vec16( + RVVI(__riscv_vfmin_vv_f32, LMUL_512)(reg, b.reg, elem_num)); + } FP32Vec16 abs() const { return FP32Vec16(RVVI(__riscv_vfabs_v_f32, LMUL_512)(reg, VEC_ELEM_NUM)); } @@ -867,6 +877,27 @@ struct FP32Vec16 : public Vec { } }; +struct INT8Vec16 : public Vec { + constexpr static int VEC_ELEM_NUM = 16; + fixed_i8x16_t reg; + + explicit INT8Vec16(const FP32Vec16& vec) { + auto i32_vec = + RVVI(__riscv_vfcvt_x_f_v_i32, LMUL_512)(vec.reg, VEC_ELEM_NUM); + auto i16_vec = RVVI(__riscv_vnclip_wx_i16, LMUL_256)( + i32_vec, 0, __RISCV_VXRM_RNU, VEC_ELEM_NUM); + reg = RVVI(__riscv_vnclip_wx_i8, LMUL_128)(i16_vec, 0, __RISCV_VXRM_RNU, + VEC_ELEM_NUM); + } + + void save(int8_t* ptr) const { + RVVI(__riscv_vse8_v_i8, LMUL_128)(ptr, reg, VEC_ELEM_NUM); + } + void save(int8_t* ptr, int elem_num) const { + RVVI(__riscv_vse8_v_i8, LMUL_128)(ptr, reg, elem_num); + } +}; + // ============================================================================ // Type Traits & Global Helpers // ============================================================================ @@ -956,9 +987,7 @@ inline BF16Vec16::BF16Vec16(const FP32Vec16& v) #else template <> inline void storeFP32(float v, c10::BFloat16* ptr) { - uint32_t val; - std::memcpy(&val, &v, 4); - *reinterpret_cast(ptr) = static_cast(val >> 16); + *reinterpret_cast(ptr) = float_to_bf16(v); } inline BF16Vec8::BF16Vec8(const FP32Vec8& v) : reg_fp32(v.reg) {} inline BF16Vec16::BF16Vec16(const FP32Vec16& v) : reg_fp32(v.reg) {} diff --git a/csrc/cpu/cpu_types_scalar.hpp b/csrc/cpu/cpu_types_scalar.hpp index d1c2fc85933a..94b5179b1714 100644 --- a/csrc/cpu/cpu_types_scalar.hpp +++ b/csrc/cpu/cpu_types_scalar.hpp @@ -363,6 +363,13 @@ struct FP32Vec16 : public Vec { return FP32Vec16(ret); } + FP32Vec16 tanh() const { + f32x16_t ret; + unroll_loop( + [&ret, this](int i) { ret.val[i] = std::tanh(reg.val[i]); }); + return FP32Vec16(ret); + } + float reduce_sum() const { float result = 0.0f; unroll_loop( diff --git a/csrc/cpu/cpu_types_vsx.hpp b/csrc/cpu/cpu_types_vsx.hpp index ba65e27a15e9..250c870dbe4b 100644 --- a/csrc/cpu/cpu_types_vsx.hpp +++ b/csrc/cpu/cpu_types_vsx.hpp @@ -13,10 +13,10 @@ namespace vec_op { struct fp8_e4m3_tag {}; struct fp8_e5m2_tag {}; -// FIXME: FP16 is not fully supported in Torch-CPU -#define VLLM_DISPATCH_CASE_FLOATING_TYPES(...) \ - AT_DISPATCH_CASE(at::ScalarType::Float, __VA_ARGS__) \ - AT_DISPATCH_CASE(at::ScalarType::BFloat16, __VA_ARGS__) +#define VLLM_DISPATCH_CASE_FLOATING_TYPES(...) \ + AT_DISPATCH_CASE(at::ScalarType::Float, __VA_ARGS__) \ + AT_DISPATCH_CASE(at::ScalarType::BFloat16, __VA_ARGS__) \ + AT_DISPATCH_CASE(at::ScalarType::Half, __VA_ARGS__) #define VLLM_DISPATCH_FLOATING_TYPES(TYPE, NAME, ...) \ AT_DISPATCH_SWITCH(TYPE, NAME, VLLM_DISPATCH_CASE_FLOATING_TYPES(__VA_ARGS__)) @@ -34,6 +34,87 @@ struct fp8_e5m2_tag {}; #define FORCE_INLINE __attribute__((always_inline)) inline namespace { + +FORCE_INLINE __vector float fp16_to_fp32_bits(__vector unsigned int x) { + const __vector unsigned int mask_sign = {0x8000, 0x8000, 0x8000, 0x8000}; + const __vector unsigned int mask_exp = {0x7C00, 0x7C00, 0x7C00, 0x7C00}; + const __vector unsigned int mask_mant = {0x03FF, 0x03FF, 0x03FF, 0x03FF}; + const __vector unsigned int bias_adj = {112, 112, 112, 112}; + const __vector unsigned int exp_max_fp16 = {0x1F, 0x1F, 0x1F, 0x1F}; + const __vector unsigned int exp_max_fp32 = {0xFF, 0xFF, 0xFF, 0xFF}; + + __vector unsigned int s = (x & mask_sign) << 16; + __vector unsigned int e = (x & mask_exp) >> 10; + __vector unsigned int m = (x & mask_mant) << 13; + + __vector __bool int is_nan_inf = vec_cmpeq(e, exp_max_fp16); + + __vector unsigned int e_normal = e + bias_adj; + e = vec_sel(e_normal, exp_max_fp32, is_nan_inf); + + return (__vector float)(s | (e << 23) | m); +} + +FORCE_INLINE __vector unsigned int fp32_to_fp16_bits(__vector float f_in) { + __vector unsigned int in = (__vector unsigned int)f_in; + + const __vector unsigned int mask_sign_32 = {0x80000000, 0x80000000, + 0x80000000, 0x80000000}; + const __vector unsigned int mask_exp_32 = {0x7F800000, 0x7F800000, 0x7F800000, + 0x7F800000}; + const __vector unsigned int mask_mant_32 = {0x007FFFFF, 0x007FFFFF, + 0x007FFFFF, 0x007FFFFF}; + + const __vector signed int bias_adj = {112, 112, 112, 112}; + const __vector signed int zero = {0, 0, 0, 0}; + const __vector signed int max_exp = {31, 31, 31, 31}; + const __vector unsigned int exp_max_fp32 = {0xFF, 0xFF, 0xFF, 0xFF}; + const __vector unsigned int exp_max_fp16 = {0x1F, 0x1F, 0x1F, 0x1F}; + + __vector unsigned int s = (in & mask_sign_32) >> 16; + __vector unsigned int e_u = (in & mask_exp_32) >> 23; + + __vector __bool int is_nan_inf = vec_cmpeq(e_u, exp_max_fp32); + + __vector signed int e_s = (__vector signed int)e_u; + e_s = vec_sub(e_s, bias_adj); + e_s = vec_max(e_s, zero); + e_s = vec_min(e_s, max_exp); + __vector unsigned int e_normal = (__vector unsigned int)e_s; + + __vector unsigned int e_final = vec_sel(e_normal, exp_max_fp16, is_nan_inf); + + const __vector unsigned int one_v = {1, 1, 1, 1}; + const __vector unsigned int mask_sticky = {0xFFF, 0xFFF, 0xFFF, 0xFFF}; + + __vector unsigned int round_bit = (in >> 12) & one_v; + __vector unsigned int sticky = in & mask_sticky; + __vector unsigned int m = (in & mask_mant_32) >> 13; + __vector unsigned int lsb = m & one_v; + + // Round up if: round_bit && (sticky || lsb) + __vector __bool int sticky_nonzero = + vec_cmpgt(sticky, (__vector unsigned int){0, 0, 0, 0}); + __vector __bool int lsb_set = vec_cmpeq(lsb, one_v); + __vector __bool int round_up = + vec_and(vec_cmpeq(round_bit, one_v), vec_or(sticky_nonzero, lsb_set)); + + m = vec_sel(m, m + one_v, round_up); + + const __vector unsigned int mant_mask = {0x3FF, 0x3FF, 0x3FF, 0x3FF}; + const __vector unsigned int max_normal_exp = {0x1E, 0x1E, 0x1E, 0x1E}; + __vector __bool int mant_overflows = vec_cmpgt(m, mant_mask); + __vector __bool int would_overflow_to_inf = + vec_and(mant_overflows, vec_cmpeq(e_final, max_normal_exp)); + __vector unsigned int e_inc = vec_min(e_final + one_v, exp_max_fp16); + e_final = vec_sel(e_final, e_inc, mant_overflows); + m = vec_and(m, mant_mask); + e_final = vec_sel(e_final, max_normal_exp, would_overflow_to_inf); + m = vec_sel(m, mant_mask, would_overflow_to_inf); + + return s | (e_final << 10) | m; +} + template constexpr void unroll_loop_item(std::integer_sequence, F&& f) { (f(std::integral_constant{}), ...); @@ -89,6 +170,19 @@ struct BF16Vec8 : public Vec { } }; +struct FP16Vec8 : public Vec { + constexpr static int VEC_ELEM_NUM = 8; + + __vector signed short reg; + + explicit FP16Vec8(const void* ptr) : reg(*(__vector signed short*)ptr) {} + explicit FP16Vec8(const FP32Vec8&); + + void save(void* ptr) const { + *reinterpret_cast<__vector signed short*>(ptr) = reg; + } +}; + struct FP16Vec16 : public Vec { constexpr static int VEC_ELEM_NUM = 16; ss16x8x2_t reg; @@ -124,13 +218,11 @@ struct BF16Vec16 : public Vec { ss16x8x2_t reg; explicit BF16Vec16(const void* ptr) { - // Load 256 bits in two parts reg.val[0] = (__vector signed short)vec_xl(0, (signed short*)ptr); reg.val[1] = (__vector signed short)vec_xl(16, (signed short*)ptr); } explicit BF16Vec16(bool, const void* ptr) : BF16Vec16(ptr) {} - explicit BF16Vec16(const FP32Vec16&); void save(void* ptr) const { @@ -142,20 +234,16 @@ struct BF16Vec16 : public Vec { void save(void* ptr, const int elem_num) const { const int clamped_elem = std::max(0, std::min(elem_num, 16)); - // Calculate elements to store in each 128-bit part (8 elements each) const int elements_val0 = std::min(clamped_elem, 8); const int elements_val1 = std::max(clamped_elem - 8, 0); - // Convert elements to bytes (2 bytes per element) const size_t bytes_val0 = elements_val0 * sizeof(signed short); const size_t bytes_val1 = elements_val1 * sizeof(signed short); signed short* dest = static_cast(ptr); - // Store the first part using vec_xst_len if (bytes_val0 > 0) { vec_xst_len(reg.val[0], dest, bytes_val0); } - // Store the second part if needed if (bytes_val1 > 0) { vec_xst_len(reg.val[1], dest + elements_val0, bytes_val1); } @@ -238,6 +326,15 @@ struct FP32Vec8 : public Vec { reg.val[1] = (__vector float)vec_mergel(zero, v.reg); } + explicit FP32Vec8(const FP16Vec8& v) { + __vector unsigned short raw_u = (__vector unsigned short)v.reg; + __vector unsigned int raw_hi = + (__vector unsigned int)vec_unpackh((__vector signed short)raw_u); + __vector unsigned int raw_lo = + (__vector unsigned int)vec_unpackl((__vector signed short)raw_u); + reg.val[0] = fp16_to_fp32_bits(raw_hi); + reg.val[1] = fp16_to_fp32_bits(raw_lo); + } float reduce_sum() const { AliasReg ar; ar.reg = reg; @@ -247,53 +344,133 @@ struct FP32Vec8 : public Vec { return result; } - FP32Vec8 exp() const { - // TODO: Vectorize this - AliasReg ar; - ar.reg = reg; - f32x4x4_t ret; - ret.val[0][0] = std::exp(ar.values[0]); - ret.val[0][1] = std::exp(ar.values[1]); - ret.val[0][2] = std::exp(ar.values[2]); - ret.val[0][3] = std::exp(ar.values[3]); - ret.val[1][0] = std::exp(ar.values[4]); - ret.val[1][1] = std::exp(ar.values[5]); - ret.val[1][2] = std::exp(ar.values[6]); - ret.val[1][3] = std::exp(ar.values[7]); - return FP32Vec8(f32x4x2_t({ret.val[0], ret.val[1]})); + f32x4x2_t out; + const __vector float log2e = vec_splats(1.44269504088896341f); + const __vector float one = vec_splats(1.0f); + const __vector float min_x = vec_splats(-87.3f); + const __vector float max_x = vec_splats(88.7f); + + // 5th-degree minimax polynomial for 2^r (r in [0,1)) + const __vector float c1 = vec_splats(0.6931471805599453f); + const __vector float c2 = vec_splats(0.240226506959101f); + const __vector float c3 = vec_splats(0.05550410866482158f); + const __vector float c4 = vec_splats(0.009618129107628477f); + const __vector float c5 = vec_splats(0.0013333558146428443f); + + for (int i = 0; i < 2; i++) { + __vector float x = reg.val[i]; + x = vec_max(x, min_x); + x = vec_min(x, max_x); + + __vector float y = vec_mul(x, log2e); + + __vector float kf = vec_floor(y); + __vector float r = vec_sub(y, kf); + + // Convert float to signed integer. Use vec_cts for PowerPC AltiVec + // compatibility. + __vector signed int k = vec_cts(kf, 0); + const __vector signed int min_k = vec_splats((signed int)-126); + const __vector signed int max_k = vec_splats((signed int)127); + k = vec_min(vec_max(k, min_k), max_k); + + // Build 2^k from exponent bits + __vector signed int exp_int = vec_add(k, vec_splats((signed int)127)); + __vector unsigned int bits = (__vector unsigned int)exp_int; + bits = vec_sl(bits, vec_splats((unsigned int)23)); + __vector float pow2k = (__vector float)bits; + + // Improved minimax polynomial + __vector float poly = vec_madd(c5, r, c4); + poly = vec_madd(poly, r, c3); + poly = vec_madd(poly, r, c2); + poly = vec_madd(poly, r, c1); + poly = vec_madd(poly, r, one); + + out.val[i] = vec_mul(pow2k, poly); + } + return FP32Vec8(out); } FP32Vec8 tanh() const { - // TODO: Vectorize this - AliasReg ar; - ar.reg = reg; - f32x4x4_t ret; - ret.val[0][0] = std::tanh(ar.values[0]); - ret.val[0][1] = std::tanh(ar.values[1]); - ret.val[0][2] = std::tanh(ar.values[2]); - ret.val[0][3] = std::tanh(ar.values[3]); - ret.val[1][0] = std::tanh(ar.values[4]); - ret.val[1][1] = std::tanh(ar.values[5]); - ret.val[1][2] = std::tanh(ar.values[6]); - ret.val[1][3] = std::tanh(ar.values[7]); - return FP32Vec8(f32x4x2_t({ret.val[0], ret.val[1]})); + const __vector float one = vec_splats(1.0f); + const __vector float two = vec_splats(2.0f); + const __vector float zero = vec_splats(0.0f); + const __vector float sat = vec_splats(9.0f); + + f32x4x2_t out; + + for (int i = 0; i < 2; i++) { + __vector float x = reg.val[i]; + __vector float ax = vec_abs(x); + + __vector bool int mask = vec_cmpge(x, zero); + __vector float sign = vec_sel(vec_splats(-1.0f), one, mask); + + __vector bool int saturated = vec_cmpge(ax, sat); + + __vector float two_x = vec_mul(x, two); + f32x4x2_t tmp; + tmp.val[0] = two_x; + tmp.val[1] = two_x; + FP32Vec8 temp_vec(tmp); + vector float e = temp_vec.exp().reg.val[0]; + + vector float num = vec_sub(e, one); + vector float den = vec_add(e, one); + vector float t = vec_div(num, den); + + out.val[i] = vec_sel(t, sign, saturated); + } + return FP32Vec8(out); } FP32Vec8 er() const { - // TODO: Vectorize this - AliasReg ar; - ar.reg = reg; - f32x4x4_t ret; - ret.val[0][0] = std::erf(ar.values[0]); - ret.val[0][1] = std::erf(ar.values[1]); - ret.val[0][2] = std::erf(ar.values[2]); - ret.val[0][3] = std::erf(ar.values[3]); - ret.val[1][0] = std::erf(ar.values[4]); - ret.val[1][1] = std::erf(ar.values[5]); - ret.val[1][2] = std::erf(ar.values[6]); - ret.val[1][3] = std::erf(ar.values[7]); - return FP32Vec8(f32x4x2_t({ret.val[0], ret.val[1]})); + const vector float a1 = vec_splats(0.254829592f); + const vector float a2 = vec_splats(-0.284496736f); + const vector float a3 = vec_splats(1.421413741f); + const vector float a4 = vec_splats(-1.453152027f); + const vector float a5 = vec_splats(1.061405429f); + const vector float p = vec_splats(0.3275911f); + const vector float one = vec_splats(1.0f); + const vector float zero = vec_splats(0.0f); + const vector float sat = vec_splats(6.0f); + + f32x4x2_t ret; + + for (int i = 0; i < 2; i++) { + vector float x = reg.val[i]; + vector float ax = vec_abs(x); + + vector bool int mask = vec_cmpge(x, zero); + vector float sign = vec_sel(vec_splats(-1.0f), one, mask); + + vector bool int saturated = vec_cmpge(ax, sat); + + vector float t = vec_div(one, vec_madd(p, ax, one)); + + vector float poly = a5; + poly = vec_madd(poly, t, a4); + poly = vec_madd(poly, t, a3); + poly = vec_madd(poly, t, a2); + poly = vec_madd(poly, t, a1); + poly = vec_mul(poly, t); + + vector float x_squared = vec_mul(x, x); + vector float neg_x_squared = vec_mul(vec_splats(-1.0f), x_squared); + f32x4x2_t tmp; + tmp.val[0] = neg_x_squared; + tmp.val[1] = neg_x_squared; + FP32Vec8 exp_input(tmp); + vector float exp_term = exp_input.exp().reg.val[0]; + + vector float y = vec_nmsub(poly, exp_term, one); + vector float erf_val = vec_mul(sign, y); + + ret.val[i] = vec_sel(erf_val, sign, saturated); + } + return FP32Vec8(ret); } FP32Vec8 operator*(const FP32Vec8& b) const { @@ -410,8 +587,9 @@ struct FP32Vec16 : public Vec { reg.val[3] = vec_xl(48, ptr); } + explicit FP32Vec16(const c10::Half* ptr) : FP32Vec16(FP16Vec16(ptr)) {} + explicit FP32Vec16(const FP16Vec16&); explicit FP32Vec16(bool, const float* ptr) : FP32Vec16(ptr) {} - explicit FP32Vec16(f32x4x4_t data) : reg(data) {} explicit FP32Vec16(const FP32Vec16& data) { @@ -435,7 +613,6 @@ struct FP32Vec16 : public Vec { reg.val[3] = data.reg.val[1]; } - explicit FP32Vec16(const FP16Vec16& v); explicit FP32Vec16(const BF16Vec16& v) { reg.val[0] = (__vector float)vec_mergeh(zero, v.reg.val[0]); reg.val[1] = (__vector float)vec_mergel(zero, v.reg.val[0]); @@ -502,28 +679,20 @@ struct FP32Vec16 : public Vec { FP32Vec16 max(const FP32Vec16& b, int elem_num) const { FP32Vec16 result; - // Create a vector of element indices for each chunk __vector unsigned int indices = {0, 1, 2, 3}; __vector unsigned int elem_num_vec = vec_splats(static_cast(elem_num)); - // Compute masks for each chunk - __vector unsigned int chunk_offset0 = {0, 0, 0, - 0}; // Chunk 0: Elements 0-3 - __vector unsigned int chunk_offset1 = {4, 4, 4, - 4}; // Chunk 1: Elements 4-7 - __vector unsigned int chunk_offset2 = {8, 8, 8, - 8}; // Chunk 2: Elements 8-11 - __vector unsigned int chunk_offset3 = {12, 12, 12, - 12}; // Chunk 3: Elements 12-15 - - // Compute masks for each chunk + __vector unsigned int chunk_offset0 = {0, 0, 0, 0}; + __vector unsigned int chunk_offset1 = {4, 4, 4, 4}; + __vector unsigned int chunk_offset2 = {8, 8, 8, 8}; + __vector unsigned int chunk_offset3 = {12, 12, 12, 12}; + __vector bool int mask0 = vec_cmplt(indices + chunk_offset0, elem_num_vec); __vector bool int mask1 = vec_cmplt(indices + chunk_offset1, elem_num_vec); __vector bool int mask2 = vec_cmplt(indices + chunk_offset2, elem_num_vec); __vector bool int mask3 = vec_cmplt(indices + chunk_offset3, elem_num_vec); - // Apply masks to compute the result for each chunk result.reg.val[0] = vec_sel(this->reg.val[0], vec_max(this->reg.val[0], b.reg.val[0]), mask0); result.reg.val[1] = vec_sel(this->reg.val[1], @@ -626,6 +795,16 @@ struct FP32Vec16 : public Vec { vec_xst(reg.val[3], 48, ptr); } + void save(c10::Half* ptr) const { + FP16Vec16 fp16_vec(*this); + fp16_vec.save(ptr); + } + + void save(c10::Half* ptr, const int elem_num) const { + FP16Vec16 fp16_vec(*this); + fp16_vec.save(ptr, elem_num); + } + void save(float* ptr, const int elem_num) const { const int elements_in_chunk1 = (elem_num >= 0) ? ((elem_num >= 4) ? 4 : elem_num) : 0; @@ -659,7 +838,7 @@ struct FP32Vec16 : public Vec { }; struct INT8Vec16 : public Vec { - constexpr static int VEC_NUM_ELEM = 16; // 128 bits / 8 bits = 16 + constexpr static int VEC_NUM_ELEM = 16; union AliasReg { __vector signed char reg; @@ -707,6 +886,11 @@ struct VecType { using vec_type = BF16Vec8; }; +template <> +struct VecType { + using vec_type = FP16Vec8; +}; + template void storeFP32(float v, T* ptr) { *ptr = v; @@ -723,6 +907,15 @@ inline void storeFP32(float v, c10::BFloat16* ptr) { *ptr = *(v_ptr + 1); } +template <> +inline void storeFP32(float v, c10::Half* ptr) { + __vector float v_vec = {v, 0.0f, 0.0f, 0.0f}; + __vector unsigned int fp16_bits = fp32_to_fp16_bits(v_vec); + unsigned short result = + (unsigned short)((__vector unsigned short)fp16_bits)[0]; + *reinterpret_cast(ptr) = result; +} + #ifndef __VEC_CLASS_FP_NAN #define __VEC_CLASS_FP_NAN (1 << 6) #endif @@ -769,38 +962,39 @@ inline BF16Vec8::BF16Vec8(const FP32Vec8& v) { #endif } -inline FP16Vec16::FP16Vec16(const FP32Vec16& v) { - alignas(16) float temp_fp32[16]; - alignas(16) c10::Half temp_fp16[16]; - - vec_xst(v.reg.val[0], 0, temp_fp32); - vec_xst(v.reg.val[1], 16, temp_fp32); - vec_xst(v.reg.val[2], 32, temp_fp32); - vec_xst(v.reg.val[3], 48, temp_fp32); - - for (int i = 0; i < 16; i++) { - temp_fp16[i] = c10::Half(temp_fp32[i]); - } +inline FP16Vec8::FP16Vec8(const FP32Vec8& v) { + __vector unsigned int fp16_hi = fp32_to_fp16_bits(v.reg.val[0]); + __vector unsigned int fp16_lo = fp32_to_fp16_bits(v.reg.val[1]); + reg = (__vector signed short)vec_perm((__vector unsigned char)fp16_hi, + (__vector unsigned char)fp16_lo, omask); +} - reg.val[0] = (__vector signed short)vec_xl(0, (signed short*)temp_fp16); - reg.val[1] = (__vector signed short)vec_xl(16, (signed short*)temp_fp16); +inline FP16Vec16::FP16Vec16(const FP32Vec16& v) { + __vector unsigned int fp16_0 = fp32_to_fp16_bits(v.reg.val[0]); + __vector unsigned int fp16_1 = fp32_to_fp16_bits(v.reg.val[1]); + __vector unsigned int fp16_2 = fp32_to_fp16_bits(v.reg.val[2]); + __vector unsigned int fp16_3 = fp32_to_fp16_bits(v.reg.val[3]); + reg.val[0] = (__vector signed short)vec_perm( + (__vector unsigned char)fp16_0, (__vector unsigned char)fp16_1, omask); + reg.val[1] = (__vector signed short)vec_perm( + (__vector unsigned char)fp16_2, (__vector unsigned char)fp16_3, omask); } inline FP32Vec16::FP32Vec16(const FP16Vec16& v) { - alignas(16) c10::Half temp_fp16[16]; - alignas(16) float temp_fp32[16]; - - vec_xst(v.reg.val[0], 0, (signed short*)temp_fp16); - vec_xst(v.reg.val[1], 16, (signed short*)temp_fp16); - - for (int i = 0; i < 16; i++) { - temp_fp32[i] = float(temp_fp16[i]); - } - - reg.val[0] = vec_xl(0, temp_fp32); - reg.val[1] = vec_xl(16, temp_fp32); - reg.val[2] = vec_xl(32, temp_fp32); - reg.val[3] = vec_xl(48, temp_fp32); + __vector unsigned short raw_u0 = (__vector unsigned short)v.reg.val[0]; + __vector unsigned short raw_u1 = (__vector unsigned short)v.reg.val[1]; + __vector unsigned int raw_hi0 = + (__vector unsigned int)vec_unpackh((__vector signed short)raw_u0); + __vector unsigned int raw_lo0 = + (__vector unsigned int)vec_unpackl((__vector signed short)raw_u0); + __vector unsigned int raw_hi1 = + (__vector unsigned int)vec_unpackh((__vector signed short)raw_u1); + __vector unsigned int raw_lo1 = + (__vector unsigned int)vec_unpackl((__vector signed short)raw_u1); + reg.val[0] = fp16_to_fp32_bits(raw_hi0); + reg.val[1] = fp16_to_fp32_bits(raw_lo0); + reg.val[2] = fp16_to_fp32_bits(raw_hi1); + reg.val[3] = fp16_to_fp32_bits(raw_lo1); } inline BF16Vec16::BF16Vec16(const FP32Vec16& v) { @@ -864,7 +1058,6 @@ inline void prefetch(const void* addr) { struct INT8Vec64 { __vector signed char data[4]; - INT8Vec64() = default; explicit INT8Vec64(const int8_t* ptr) { @@ -900,5 +1093,4 @@ struct INT8Vec64 { void nt_save(int8_t* ptr) const { save(ptr); } }; } // namespace vec_op - #endif diff --git a/csrc/cpu/cpu_types_vxe.hpp b/csrc/cpu/cpu_types_vxe.hpp index 2e0af466b649..bf96554a8dff 100644 --- a/csrc/cpu/cpu_types_vxe.hpp +++ b/csrc/cpu/cpu_types_vxe.hpp @@ -3,7 +3,9 @@ #define CPU_TYPES_VXE_HPP #include +#include #include +#include #include #include namespace vec_op { @@ -817,8 +819,7 @@ inline void storeFP32<::c10::Half>(float v, ::c10::Half* ptr) { // intrinsics for FP32 to FP16 conversion does not use IEEE rounding and can // produce incorrect results for some inputs. Process each of the 4 vectors // separately. - uint32_t in; - std::memcpy(&in, &v, sizeof(in)); + uint32_t in = std::bit_cast(v); uint32_t s = (in & 0x80000000) >> 16; // Sign uint32_t e = (in & 0x7F800000) >> 23; // Exponent diff --git a/csrc/cpu/cpu_types_x86.hpp b/csrc/cpu/cpu_types_x86.hpp index 396b9b7e041f..d2a72ce9ccd7 100644 --- a/csrc/cpu/cpu_types_x86.hpp +++ b/csrc/cpu/cpu_types_x86.hpp @@ -3,6 +3,7 @@ #define CPU_TYPES_X86_HPP #include +#include #include #ifndef __AVX2__ @@ -592,6 +593,8 @@ struct FP32Vec16 : public Vec { FP32Vec16 abs() const { return FP32Vec16(_mm512_abs_ps(reg)); } + FP32Vec16 tanh() const { return FP32Vec16(Sleef_tanhf16_u10(reg)); } + float reduce_sum() const { return _mm512_reduce_add_ps(reg); } float reduce_max() const { return _mm512_reduce_max_ps(reg); } @@ -789,6 +792,12 @@ struct FP32Vec16 : public Vec { _mm256_andnot_ps(sign_mask, reg_high)); } + FP32Vec16 tanh() const { + FP32Vec8 low(reg_low); + FP32Vec8 high(reg_high); + return FP32Vec16(low.tanh().reg, high.tanh().reg); + } + FP32Vec16 min(const FP32Vec16& b) const { return FP32Vec16(_mm256_min_ps(reg_low, b.reg_low), _mm256_min_ps(reg_high, b.reg_high)); diff --git a/csrc/cpu/cpu_wna16.cpp b/csrc/cpu/cpu_wna16.cpp index 533f20963541..ae7aef74c445 100644 --- a/csrc/cpu/cpu_wna16.cpp +++ b/csrc/cpu/cpu_wna16.cpp @@ -4,6 +4,9 @@ #ifdef CPU_CAPABILITY_AMXBF16 #include "cpu/micro_gemm/cpu_micro_gemm_amx.hpp" #endif +#if defined(__riscv_v) + #include "cpu/micro_gemm/cpu_micro_gemm_rvv.hpp" +#endif #include "cpu/micro_gemm/cpu_micro_gemm_vec.hpp" #define VLLM_DISPATCH_CASE_16B_TYPES(...) \ @@ -152,7 +155,7 @@ void cpu_gemm_wna16_impl( constexpr int32_t gemm_m_tile_size = gemm_t::MaxMSize; constexpr int32_t n_block_size = 16; static_assert(gemm_n_tile_size % n_block_size == 0); - const int32_t thread_num = omp_get_max_threads(); + const int32_t thread_num = cpu_utils::get_max_threads(); // a simple schedule policy, just to hold more B tiles in L2 and make sure // each thread has tasks @@ -319,6 +322,8 @@ void cpu_gemm_wna16( return ISA::AMX; } else if (isa_hint == "vec") { return ISA::VEC; + } else if (isa_hint == "rvv") { + return ISA::RVV; } else { TORCH_CHECK(false, "unsupported isa hint: " + isa_hint); } @@ -397,6 +402,40 @@ void cpu_gemm_wna16( pack_factor); return; } + } else if (isa == ISA::RVV) { + using gemm_t = cpu_micro_gemm::MicroGemm; + if (has_zp) { + using dequantizer_t = Dequantizer4b; + cpu_gemm_wna16_impl( + input.data_ptr(), q_weight.data_ptr(), + output.data_ptr(), scales.data_ptr(), zeros_ptr, + g_idx_ptr, bias.has_value() ? bias->data_ptr() : nullptr, + a_m_size, b_n_size, a_k_size, a_m_stride, output_m_stride, + scales_group_stride, zeros_group_stride, group_num, group_size, + pack_factor); + return; + } + if (use_desc_act) { + using dequantizer_t = Dequantizer4b; + cpu_gemm_wna16_impl( + input.data_ptr(), q_weight.data_ptr(), + output.data_ptr(), scales.data_ptr(), zeros_ptr, + g_idx_ptr, bias.has_value() ? bias->data_ptr() : nullptr, + a_m_size, b_n_size, a_k_size, a_m_stride, output_m_stride, + scales_group_stride, zeros_group_stride, group_num, group_size, + pack_factor); + return; + } else { + using dequantizer_t = Dequantizer4b; + cpu_gemm_wna16_impl( + input.data_ptr(), q_weight.data_ptr(), + output.data_ptr(), scales.data_ptr(), zeros_ptr, + g_idx_ptr, bias.has_value() ? bias->data_ptr() : nullptr, + a_m_size, b_n_size, a_k_size, a_m_stride, output_m_stride, + scales_group_stride, zeros_group_stride, group_num, group_size, + pack_factor); + return; + } } }); } diff --git a/csrc/cpu/dnnl_kernels.cpp b/csrc/cpu/dnnl_kernels.cpp index 058fe25b0e26..6dda09296160 100644 --- a/csrc/cpu/dnnl_kernels.cpp +++ b/csrc/cpu/dnnl_kernels.cpp @@ -202,7 +202,7 @@ void dynamic_quant_epilogue(const float* input, scalar_t* output, using cvt_vec_t = typename KernelVecType::cvt_vec_type; constexpr int vec_elem_num = load_vec_t::VEC_ELEM_NUM; - const int64_t thread_num = omp_get_max_threads(); + const int64_t thread_num = cpu_utils::get_max_threads(); if (num_tokens > thread_num) { #pragma omp parallel for for (int64_t i = 0; i < num_tokens; ++i) { diff --git a/csrc/cpu/float_convert.hpp b/csrc/cpu/float_convert.hpp index c792bf131ccd..0682ef402837 100644 --- a/csrc/cpu/float_convert.hpp +++ b/csrc/cpu/float_convert.hpp @@ -1,14 +1,15 @@ +#pragma once -static float bf16_to_float(uint16_t bf16) { +#include +#include + +inline float bf16_to_float(uint16_t bf16) { uint32_t bits = static_cast(bf16) << 16; - float fp32; - std::memcpy(&fp32, &bits, sizeof(fp32)); - return fp32; + return std::bit_cast(bits); } -static uint16_t float_to_bf16(float fp32) { - uint32_t bits; - std::memcpy(&bits, &fp32, sizeof(fp32)); +inline uint16_t float_to_bf16(float fp32) { + uint32_t bits = std::bit_cast(fp32); return static_cast(bits >> 16); } @@ -18,14 +19,13 @@ static uint16_t float_to_bf16(float fp32) { * Codes below copied from * https://github.com/PrincetonVision/marvin/tree/master/tools/tensorIO_matlab *************************************************/ -static uint16_t float_to_fp16(float fp32) { +inline uint16_t float_to_fp16(float fp32) { uint16_t fp16; - unsigned x; unsigned u, remainder, shift, lsb, lsb_s1, lsb_m1; unsigned sign, exponent, mantissa; - std::memcpy(&x, &fp32, sizeof(fp32)); + uint32_t x = std::bit_cast(fp32); u = (x & 0x7fffffff); // Get rid of +NaN/-NaN case first. @@ -77,12 +77,11 @@ static uint16_t float_to_fp16(float fp32) { return fp16; } -static float fp16_to_float(uint16_t fp16) { +inline float fp16_to_float(uint16_t fp16) { unsigned sign = ((fp16 >> 15) & 1); unsigned exponent = ((fp16 >> 10) & 0x1f); unsigned mantissa = ((fp16 & 0x3ff) << 13); - int temp; - float fp32; + uint32_t temp; if (exponent == 0x1f) { /* NaN or Inf */ mantissa = (mantissa ? (sign = 0, 0x7fffff) : 0); exponent = 0xff; @@ -101,6 +100,5 @@ static float fp16_to_float(uint16_t fp16) { exponent += 0x70; } temp = ((sign << 31) | (exponent << 23) | mantissa); - std::memcpy(&fp32, &temp, sizeof(temp)); - return fp32; + return std::bit_cast(temp); } diff --git a/csrc/cpu/generate_cpu_attn_dispatch.py b/csrc/cpu/generate_cpu_attn_dispatch.py index 7c7123a6def5..95ce9e66927e 100644 --- a/csrc/cpu/generate_cpu_attn_dispatch.py +++ b/csrc/cpu/generate_cpu_attn_dispatch.py @@ -11,7 +11,7 @@ HEAD_DIMS_32 = [32, 64, 96, 128, 160, 192, 224, 256, 512] # Head dimensions divisible by 16 but not 32 (VEC16 only) -HEAD_DIMS_16 = [80, 112] +HEAD_DIMS_16 = [48, 80, 112] # ISA types ISA_TYPES = { diff --git a/csrc/cpu/layernorm.cpp b/csrc/cpu/layernorm.cpp index a76ad08928a2..704fb146338e 100644 --- a/csrc/cpu/layernorm.cpp +++ b/csrc/cpu/layernorm.cpp @@ -4,8 +4,9 @@ namespace { template void rms_norm_impl(scalar_t* __restrict__ out, const scalar_t* __restrict__ input, - const scalar_t* __restrict__ weight, const float epsilon, - const int num_tokens, const int hidden_size) { + const scalar_t* __restrict__ weight, const bool has_weight, + const float epsilon, const int num_tokens, + const int hidden_size) { using scalar_vec_t = vec_op::vec_t; constexpr int VEC_ELEM_NUM = scalar_vec_t::get_elem_num(); TORCH_CHECK(hidden_size % VEC_ELEM_NUM == 0); @@ -27,12 +28,15 @@ void rms_norm_impl(scalar_t* __restrict__ out, for (int j = 0; j < hidden_size; j += VEC_ELEM_NUM) { scalar_vec_t x(input_p + j); - scalar_vec_t w(weight + j); - vec_op::FP32Vec8 fp32_x(x); - vec_op::FP32Vec8 fp32_w(w); - - vec_op::FP32Vec8 fp32_out = fp32_x * fp32_s_variance * fp32_w; + vec_op::FP32Vec8 fp32_out; + if (has_weight) { + scalar_vec_t w(weight + j); + vec_op::FP32Vec8 fp32_w(w); + fp32_out = fp32_x * fp32_s_variance * fp32_w; + } else { + fp32_out = fp32_x * fp32_s_variance; + } scalar_vec_t out(fp32_out); out.save(output_p + j); @@ -44,8 +48,8 @@ template void fused_add_rms_norm_impl(scalar_t* __restrict__ input, scalar_t* __restrict__ residual, const scalar_t* __restrict__ weight, - const float epsilon, const int num_tokens, - const int hidden_size) { + const bool has_weight, const float epsilon, + const int num_tokens, const int hidden_size) { using scalar_vec_t = vec_op::vec_t; constexpr int VEC_ELEM_NUM = scalar_vec_t::get_elem_num(); TORCH_CHECK(hidden_size % VEC_ELEM_NUM == 0); @@ -72,13 +76,18 @@ void fused_add_rms_norm_impl(scalar_t* __restrict__ input, vec_op::FP32Vec8 fp32_s_variance(s_variance); for (int j = 0; j < hidden_size; j += VEC_ELEM_NUM) { - scalar_vec_t w(weight + j); - scalar_vec_t res(residual_p + j); - - vec_op::FP32Vec8 fp32_w(w); - vec_op::FP32Vec8 fp32_res(res); - - vec_op::FP32Vec8 fp32_out = fp32_res * fp32_s_variance * fp32_w; + vec_op::FP32Vec8 fp32_out; + if (has_weight) { + scalar_vec_t w(weight + j); + scalar_vec_t res(residual_p + j); + vec_op::FP32Vec8 fp32_w(w); + vec_op::FP32Vec8 fp32_res(res); + fp32_out = fp32_res * fp32_s_variance * fp32_w; + } else { + scalar_vec_t res(residual_p + j); + vec_op::FP32Vec8 fp32_res(res); + fp32_out = fp32_res * fp32_s_variance; + } scalar_vec_t out(fp32_out); out.save(input_p + j); @@ -87,31 +96,41 @@ void fused_add_rms_norm_impl(scalar_t* __restrict__ input, } } // namespace -void rms_norm(torch::Tensor& out, torch::Tensor& input, torch::Tensor& weight, - double epsilon) { +void rms_norm(torch::Tensor& out, torch::Tensor& input, + std::optional weight, double epsilon) { int hidden_size = input.size(-1); int num_tokens = input.numel() / hidden_size; + const bool has_weight = weight.has_value(); + if (has_weight) { + TORCH_CHECK(weight->is_contiguous()); + } VLLM_DISPATCH_FLOATING_TYPES(input.scalar_type(), "rms_norm_impl", [&] { CPU_KERNEL_GUARD_IN(rms_norm_impl) rms_norm_impl(out.data_ptr(), input.data_ptr(), - weight.data_ptr(), epsilon, num_tokens, - hidden_size); + has_weight ? weight->data_ptr() : nullptr, + has_weight, epsilon, num_tokens, hidden_size); CPU_KERNEL_GUARD_OUT(rms_norm_impl) }); } void fused_add_rms_norm(torch::Tensor& input, torch::Tensor& residual, - torch::Tensor& weight, double epsilon) { + std::optional weight, double epsilon) { int hidden_size = input.size(-1); int num_tokens = input.numel() / hidden_size; + const bool has_weight = weight.has_value(); + if (has_weight) { + TORCH_CHECK(weight->scalar_type() == input.scalar_type()); + TORCH_CHECK(weight->is_contiguous()); + } VLLM_DISPATCH_FLOATING_TYPES( input.scalar_type(), "fused_add_rms_norm_impl", [&] { CPU_KERNEL_GUARD_IN(fused_add_rms_norm_impl) fused_add_rms_norm_impl( input.data_ptr(), residual.data_ptr(), - weight.data_ptr(), epsilon, num_tokens, hidden_size); + has_weight ? weight->data_ptr() : nullptr, has_weight, + epsilon, num_tokens, hidden_size); CPU_KERNEL_GUARD_OUT(fused_add_rms_norm_impl) }); } diff --git a/csrc/cpu/micro_gemm/cpu_micro_gemm_amx.hpp b/csrc/cpu/micro_gemm/cpu_micro_gemm_amx.hpp index 357c7cf1d784..99e7c4a1d5c7 100644 --- a/csrc/cpu/micro_gemm/cpu_micro_gemm_amx.hpp +++ b/csrc/cpu/micro_gemm/cpu_micro_gemm_amx.hpp @@ -213,6 +213,8 @@ class MicroGemm { public: static constexpr int32_t MaxMSize = 32; static constexpr int32_t NSize = 32; + static constexpr int32_t WeightOCGroupSize = 16; + static constexpr bool PackA = false; public: MicroGemm() : curr_m_(-1) { diff --git a/csrc/cpu/micro_gemm/cpu_micro_gemm_impl.hpp b/csrc/cpu/micro_gemm/cpu_micro_gemm_impl.hpp index 23e78a681b5f..f0471f714703 100644 --- a/csrc/cpu/micro_gemm/cpu_micro_gemm_impl.hpp +++ b/csrc/cpu/micro_gemm/cpu_micro_gemm_impl.hpp @@ -21,6 +21,9 @@ class MicroGemm { public: static constexpr int32_t MaxMSize = 16; static constexpr int32_t NSize = 16; + static constexpr int32_t WeightOCGroupSize = 16; + // callers must pack A matrix before GEMM + static constexpr bool PackA = false; public: void gemm(DEFINE_CPU_MICRO_GEMM_PARAMS) { diff --git a/csrc/cpu/micro_gemm/cpu_micro_gemm_neon.hpp b/csrc/cpu/micro_gemm/cpu_micro_gemm_neon.hpp new file mode 100644 index 000000000000..7d4898852bb3 --- /dev/null +++ b/csrc/cpu/micro_gemm/cpu_micro_gemm_neon.hpp @@ -0,0 +1,503 @@ +#ifndef CPU_MICRO_GEMM_NEON_HPP +#define CPU_MICRO_GEMM_NEON_HPP + +#include +#include + +#include "cpu/micro_gemm/cpu_micro_gemm_impl.hpp" + +#include +#include + +namespace cpu_micro_gemm { + +namespace { + +constexpr int32_t K = 4; +constexpr int32_t Cols = 2; +constexpr int32_t TileSize = K * Cols; +constexpr int32_t Mr = 8; +constexpr int32_t Nr = 8; +constexpr int32_t Nr_gemv = 16; + +// a = [a0, a1, a2, a3], b = [b0, b1, b2, b3] -> [a0, a1, b0, b1] +FORCE_INLINE float32x4_t zip1_f32x4(const float32x4_t a, const float32x4_t b) { + return vreinterpretq_f32_f64( + vzip1q_f64(vreinterpretq_f64_f32(a), vreinterpretq_f64_f32(b))); +} + +// a = [a0, a1, a2, a3], b = [b0, b1, b2, b3] -> [a2, a3, b2, b3] +FORCE_INLINE float32x4_t zip2_f32x4(const float32x4_t a, const float32x4_t b) { + return vreinterpretq_f32_f64( + vzip2q_f64(vreinterpretq_f64_f32(a), vreinterpretq_f64_f32(b))); +} + +FORCE_INLINE void init_acc_rowpair(float32x4_t& acc01, float32x4_t& acc23, + float32x4_t& acc45, float32x4_t& acc67, + const float* __restrict__ c_ptr, + const int64_t ldc, const int32_t m_rows, + const bool accum_c) { + if (!accum_c || m_rows == 0) { + acc01 = vdupq_n_f32(0.0f); + acc23 = vdupq_n_f32(0.0f); + acc45 = vdupq_n_f32(0.0f); + acc67 = vdupq_n_f32(0.0f); + return; + } + + const float32x4_t row0_0123 = vld1q_f32(c_ptr); + const float32x4_t row0_4567 = vld1q_f32(c_ptr + 4); + const float32x4_t row1_0123 = + (m_rows == 2) ? vld1q_f32(c_ptr + ldc) : vdupq_n_f32(0.0f); + const float32x4_t row1_4567 = + (m_rows == 2) ? vld1q_f32(c_ptr + ldc + 4) : vdupq_n_f32(0.0f); + + acc01 = zip1_f32x4(row0_0123, row1_0123); + acc23 = zip2_f32x4(row0_0123, row1_0123); + acc45 = zip1_f32x4(row0_4567, row1_4567); + acc67 = zip2_f32x4(row0_4567, row1_4567); +} + +FORCE_INLINE void store_acc_rowpair(const float32x4_t acc01, + const float32x4_t acc23, + const float32x4_t acc45, + const float32x4_t acc67, + float* __restrict__ c_ptr, + const int64_t ldc, const int32_t m_rows) { + if (m_rows == 0) { + return; + } + + vst1q_f32(c_ptr, zip1_f32x4(acc01, acc23)); + vst1q_f32(c_ptr + 4, zip1_f32x4(acc45, acc67)); + + if (m_rows == 2) { + vst1q_f32(c_ptr + ldc, zip2_f32x4(acc01, acc23)); + vst1q_f32(c_ptr + ldc + 4, zip2_f32x4(acc45, acc67)); + } +} + +FORCE_INLINE void gemm_micro_bfmmla_8x8_packed_a( + const bfloat16_t* __restrict__ a_packed, + const bfloat16_t* __restrict__ b_packed, float* __restrict__ c_ptr, + const int32_t m, const int32_t k_size, const int64_t ldc, + const bool accum_c) { + float32x4_t acc0101, acc0123, acc0145, acc0167; + float32x4_t acc2301, acc2323, acc2345, acc2367; + float32x4_t acc4501, acc4523, acc4545, acc4567; + float32x4_t acc6701, acc6723, acc6745, acc6767; + + init_acc_rowpair(acc0101, acc0123, acc0145, acc0167, c_ptr, ldc, + std::min(2, m), accum_c); + init_acc_rowpair(acc2301, acc2323, acc2345, acc2367, c_ptr + 2 * ldc, ldc, + std::min(2, std::max(0, m - 2)), accum_c); + init_acc_rowpair(acc4501, acc4523, acc4545, acc4567, c_ptr + 4 * ldc, ldc, + std::min(2, std::max(0, m - 4)), accum_c); + init_acc_rowpair(acc6701, acc6723, acc6745, acc6767, c_ptr + 6 * ldc, ldc, + std::min(2, std::max(0, m - 6)), accum_c); + + const bfloat16_t* __restrict__ a_tile = a_packed; + const bfloat16_t* __restrict__ b_tile = b_packed; + +#pragma GCC unroll 8 + for (int32_t k_idx = 0; k_idx < k_size; k_idx += K) { + const bfloat16x8_t a_tile01 = vld1q_bf16(a_tile); + const bfloat16x8_t a_tile23 = vld1q_bf16(a_tile + TileSize); + const bfloat16x8_t a_tile45 = vld1q_bf16(a_tile + 2 * TileSize); + const bfloat16x8_t a_tile67 = vld1q_bf16(a_tile + 3 * TileSize); + + const bfloat16x8_t b_tile01 = vld1q_bf16(b_tile); + const bfloat16x8_t b_tile23 = vld1q_bf16(b_tile + TileSize); + const bfloat16x8_t b_tile45 = vld1q_bf16(b_tile + 2 * TileSize); + const bfloat16x8_t b_tile67 = vld1q_bf16(b_tile + 3 * TileSize); + + acc0101 = vbfmmlaq_f32(acc0101, a_tile01, b_tile01); + acc2301 = vbfmmlaq_f32(acc2301, a_tile23, b_tile01); + acc4501 = vbfmmlaq_f32(acc4501, a_tile45, b_tile01); + acc6701 = vbfmmlaq_f32(acc6701, a_tile67, b_tile01); + + acc0123 = vbfmmlaq_f32(acc0123, a_tile01, b_tile23); + acc2323 = vbfmmlaq_f32(acc2323, a_tile23, b_tile23); + acc4523 = vbfmmlaq_f32(acc4523, a_tile45, b_tile23); + acc6723 = vbfmmlaq_f32(acc6723, a_tile67, b_tile23); + + acc0145 = vbfmmlaq_f32(acc0145, a_tile01, b_tile45); + acc2345 = vbfmmlaq_f32(acc2345, a_tile23, b_tile45); + acc4545 = vbfmmlaq_f32(acc4545, a_tile45, b_tile45); + acc6745 = vbfmmlaq_f32(acc6745, a_tile67, b_tile45); + + acc0167 = vbfmmlaq_f32(acc0167, a_tile01, b_tile67); + acc2367 = vbfmmlaq_f32(acc2367, a_tile23, b_tile67); + acc4567 = vbfmmlaq_f32(acc4567, a_tile45, b_tile67); + acc6767 = vbfmmlaq_f32(acc6767, a_tile67, b_tile67); + + a_tile += 4 * TileSize; + b_tile += Nr * K; + } + + store_acc_rowpair(acc0101, acc0123, acc0145, acc0167, c_ptr, ldc, + std::min(2, m)); + store_acc_rowpair(acc2301, acc2323, acc2345, acc2367, c_ptr + 2 * ldc, ldc, + std::min(2, std::max(0, m - 2))); + store_acc_rowpair(acc4501, acc4523, acc4545, acc4567, c_ptr + 4 * ldc, ldc, + std::min(2, std::max(0, m - 4))); + store_acc_rowpair(acc6701, acc6723, acc6745, acc6767, c_ptr + 6 * ldc, ldc, + std::min(2, std::max(0, m - 6))); +} + +FORCE_INLINE void gemm_micro_bfmmla_4x16_packed_a( + const bfloat16_t* __restrict__ a_packed, + const bfloat16_t* __restrict__ b_packed, float* __restrict__ c_ptr, + const int32_t m, const int32_t k_size, const int64_t b_n_group_stride, + const int64_t ldc, const bool accum_c) { + const int32_t m_rows_01 = std::min(2, m); + const int32_t m_rows_23 = std::min(2, std::max(0, m - 2)); + + float32x4_t acc0101, acc0123, acc0145, acc0167; + float32x4_t acc2301, acc2323, acc2345, acc2367; + float32x4_t acc0189, acc011011, acc011213, acc011415; + float32x4_t acc2389, acc231011, acc231213, acc231415; + + init_acc_rowpair(acc0101, acc0123, acc0145, acc0167, c_ptr, ldc, m_rows_01, + accum_c); + init_acc_rowpair(acc2301, acc2323, acc2345, acc2367, c_ptr + 2 * ldc, ldc, + m_rows_23, accum_c); + init_acc_rowpair(acc0189, acc011011, acc011213, acc011415, c_ptr + 8, ldc, + m_rows_01, accum_c); + init_acc_rowpair(acc2389, acc231011, acc231213, acc231415, + c_ptr + 2 * ldc + 8, ldc, m_rows_23, accum_c); + + const bfloat16_t* __restrict__ a_tile = a_packed; + const bfloat16_t* __restrict__ b_tile0 = b_packed; + const bfloat16_t* __restrict__ b_tile1 = b_packed + b_n_group_stride; + +#pragma GCC unroll 8 + for (int32_t k_idx = 0; k_idx < k_size; k_idx += K) { + const bfloat16x8_t a_tile01 = vld1q_bf16(a_tile); + const bfloat16x8_t a_tile23 = vld1q_bf16(a_tile + TileSize); + const bfloat16x8_t b_tile01 = vld1q_bf16(b_tile0); + const bfloat16x8_t b_tile23 = vld1q_bf16(b_tile0 + TileSize); + const bfloat16x8_t b_tile45 = vld1q_bf16(b_tile0 + 2 * TileSize); + const bfloat16x8_t b_tile67 = vld1q_bf16(b_tile0 + 3 * TileSize); + const bfloat16x8_t b_tile89 = vld1q_bf16(b_tile1); + const bfloat16x8_t b_tile1011 = vld1q_bf16(b_tile1 + TileSize); + const bfloat16x8_t b_tile1213 = vld1q_bf16(b_tile1 + 2 * TileSize); + const bfloat16x8_t b_tile1415 = vld1q_bf16(b_tile1 + 3 * TileSize); + + acc0101 = vbfmmlaq_f32(acc0101, a_tile01, b_tile01); + acc2301 = vbfmmlaq_f32(acc2301, a_tile23, b_tile01); + acc0123 = vbfmmlaq_f32(acc0123, a_tile01, b_tile23); + acc2323 = vbfmmlaq_f32(acc2323, a_tile23, b_tile23); + + acc0145 = vbfmmlaq_f32(acc0145, a_tile01, b_tile45); + acc2345 = vbfmmlaq_f32(acc2345, a_tile23, b_tile45); + acc0167 = vbfmmlaq_f32(acc0167, a_tile01, b_tile67); + acc2367 = vbfmmlaq_f32(acc2367, a_tile23, b_tile67); + + acc0189 = vbfmmlaq_f32(acc0189, a_tile01, b_tile89); + acc2389 = vbfmmlaq_f32(acc2389, a_tile23, b_tile89); + acc011011 = vbfmmlaq_f32(acc011011, a_tile01, b_tile1011); + acc231011 = vbfmmlaq_f32(acc231011, a_tile23, b_tile1011); + + acc011213 = vbfmmlaq_f32(acc011213, a_tile01, b_tile1213); + acc231213 = vbfmmlaq_f32(acc231213, a_tile23, b_tile1213); + acc011415 = vbfmmlaq_f32(acc011415, a_tile01, b_tile1415); + acc231415 = vbfmmlaq_f32(acc231415, a_tile23, b_tile1415); + + a_tile += 2 * TileSize; + b_tile0 += Nr * K; + b_tile1 += Nr * K; + } + + store_acc_rowpair(acc0101, acc0123, acc0145, acc0167, c_ptr, ldc, m_rows_01); + store_acc_rowpair(acc2301, acc2323, acc2345, acc2367, c_ptr + 2 * ldc, ldc, + m_rows_23); + store_acc_rowpair(acc0189, acc011011, acc011213, acc011415, c_ptr + 8, ldc, + m_rows_01); + store_acc_rowpair(acc2389, acc231011, acc231213, acc231415, + c_ptr + 2 * ldc + 8, ldc, m_rows_23); +} + +} // namespace + +template +class MicroGemm { + public: + static constexpr int32_t MaxMSize = 8; + static constexpr int32_t NSize = 32; + static constexpr int32_t WeightOCGroupSize = Nr; + static constexpr bool PackA = false; + + public: + void gemm(DEFINE_CPU_MICRO_GEMM_PARAMS) { + TORCH_CHECK(false, "NEON BFMMLA MicroGemm only supports bfloat16."); + } + + static void pack_weight(const scalar_t* __restrict__ /*weight*/, + scalar_t* __restrict__ /*packed_weight*/, + const int32_t /*output_size*/, + const int32_t /*input_size*/) { + TORCH_CHECK(false, "NEON BFMMLA MicroGemm only supports bfloat16."); + } +}; + +template <> +class MicroGemm { + public: + using scalar_t = c10::BFloat16; + + static constexpr int32_t MaxMSize = 8; + static constexpr int32_t NSize = 32; + static constexpr int32_t WeightOCGroupSize = Nr; + static constexpr bool PackA = true; + + public: + // physical layout [ + // M / 8; Mr is 8 + // K / 4; K for bfmmla is 4 + // 4, ; 4 row-pairs for each 8 rows + // 2, ; row-pair is 2 rows + // 4 ; 4 elements per row + // ] + + static void pack_input_from_rows(const scalar_t* const* __restrict__ rows, + scalar_t* __restrict__ a_packed, + const int32_t m, const int32_t k) { + TORCH_CHECK(m > 0 && m <= MaxMSize); + TORCH_CHECK_EQ(k % K, 0); + + auto* __restrict__ out = reinterpret_cast(a_packed); + const bfloat16x8_t zero_q = vdupq_n_bf16(bfloat16_t{}); + const bfloat16x4_t zero = vget_low_bf16(zero_q); + + for (int32_t row_base = 0; row_base < m; row_base += Mr) { + const int32_t actual_m = std::min(Mr, m - row_base); + const bfloat16_t* __restrict__ row[Mr]; + for (int32_t i = 0; i < actual_m; ++i) { + row[i] = reinterpret_cast(rows[row_base + i]); + } + + if (actual_m == 8) { + int32_t k_idx = 0; + for (; k_idx + 8 <= k; k_idx += 8) { + bfloat16_t* __restrict__ block0 = out; + bfloat16_t* __restrict__ block1 = out + 4 * TileSize; + + bfloat16x8_t a0 = vld1q_bf16(row[0] + k_idx); + bfloat16x8_t a1 = vld1q_bf16(row[1] + k_idx); + vst1q_bf16(block0, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + a0 = vld1q_bf16(row[2] + k_idx); + a1 = vld1q_bf16(row[3] + k_idx); + vst1q_bf16(block0 + TileSize, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1 + TileSize, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + a0 = vld1q_bf16(row[4] + k_idx); + a1 = vld1q_bf16(row[5] + k_idx); + vst1q_bf16(block0 + 2 * TileSize, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1 + 2 * TileSize, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + a0 = vld1q_bf16(row[6] + k_idx); + a1 = vld1q_bf16(row[7] + k_idx); + vst1q_bf16(block0 + 3 * TileSize, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1 + 3 * TileSize, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + out += 8 * TileSize; + } + + for (; k_idx < k; k_idx += K) { + bfloat16x4_t a0 = vld1_bf16(row[0] + k_idx); + bfloat16x4_t a1 = vld1_bf16(row[1] + k_idx); + vst1q_bf16(out, vcombine_bf16(a0, a1)); + + a0 = vld1_bf16(row[2] + k_idx); + a1 = vld1_bf16(row[3] + k_idx); + vst1q_bf16(out + TileSize, vcombine_bf16(a0, a1)); + + a0 = vld1_bf16(row[4] + k_idx); + a1 = vld1_bf16(row[5] + k_idx); + vst1q_bf16(out + 2 * TileSize, vcombine_bf16(a0, a1)); + + a0 = vld1_bf16(row[6] + k_idx); + a1 = vld1_bf16(row[7] + k_idx); + vst1q_bf16(out + 3 * TileSize, vcombine_bf16(a0, a1)); + + out += 4 * TileSize; + } + continue; + } + + if (actual_m == 4) { + int32_t k_idx = 0; + for (; k_idx + 8 <= k; k_idx += 8) { + bfloat16_t* __restrict__ block0 = out; + bfloat16_t* __restrict__ block1 = out + 2 * TileSize; + + bfloat16x8_t a0 = vld1q_bf16(row[0] + k_idx); + bfloat16x8_t a1 = vld1q_bf16(row[1] + k_idx); + vst1q_bf16(block0, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + a0 = vld1q_bf16(row[2] + k_idx); + a1 = vld1q_bf16(row[3] + k_idx); + vst1q_bf16(block0 + TileSize, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1 + TileSize, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + out += 4 * TileSize; + } + + for (; k_idx < k; k_idx += K) { + bfloat16x4_t a0 = vld1_bf16(row[0] + k_idx); + bfloat16x4_t a1 = vld1_bf16(row[1] + k_idx); + vst1q_bf16(out, vcombine_bf16(a0, a1)); + + a0 = vld1_bf16(row[2] + k_idx); + a1 = vld1_bf16(row[3] + k_idx); + vst1q_bf16(out + TileSize, vcombine_bf16(a0, a1)); + + out += 2 * TileSize; + } + continue; + } + + const int32_t row_pair_count = (actual_m <= 4) ? 2 : Mr / 2; + + int32_t k_idx = 0; + for (; k_idx + 8 <= k; k_idx += 8) { + bfloat16_t* __restrict__ block0 = out; + bfloat16_t* __restrict__ block1 = out + row_pair_count * TileSize; + + bfloat16x8_t a0 = vld1q_bf16(row[0] + k_idx); + bfloat16x8_t a1 = (actual_m > 1) ? vld1q_bf16(row[1] + k_idx) : zero_q; + vst1q_bf16(block0, vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + a0 = (actual_m > 2) ? vld1q_bf16(row[2] + k_idx) : zero_q; + a1 = (actual_m > 3) ? vld1q_bf16(row[3] + k_idx) : zero_q; + vst1q_bf16(block0 + TileSize, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1 + TileSize, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + if (actual_m > 4) { + a0 = vld1q_bf16(row[4] + k_idx); + a1 = (actual_m > 5) ? vld1q_bf16(row[5] + k_idx) : zero_q; + vst1q_bf16(block0 + 2 * TileSize, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1 + 2 * TileSize, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + + a0 = (actual_m > 6) ? vld1q_bf16(row[6] + k_idx) : zero_q; + a1 = (actual_m > 7) ? vld1q_bf16(row[7] + k_idx) : zero_q; + vst1q_bf16(block0 + 3 * TileSize, + vcombine_bf16(vget_low_bf16(a0), vget_low_bf16(a1))); + vst1q_bf16(block1 + 3 * TileSize, + vcombine_bf16(vget_high_bf16(a0), vget_high_bf16(a1))); + } + + out += 2 * row_pair_count * TileSize; + } + + for (; k_idx < k; k_idx += K) { + bfloat16x4_t a0 = vld1_bf16(row[0] + k_idx); + bfloat16x4_t a1 = (actual_m > 1) ? vld1_bf16(row[1] + k_idx) : zero; + vst1q_bf16(out, vcombine_bf16(a0, a1)); + + a0 = (actual_m > 2) ? vld1_bf16(row[2] + k_idx) : zero; + a1 = (actual_m > 3) ? vld1_bf16(row[3] + k_idx) : zero; + vst1q_bf16(out + TileSize, vcombine_bf16(a0, a1)); + + if (actual_m > 4) { + a0 = vld1_bf16(row[4] + k_idx); + a1 = (actual_m > 5) ? vld1_bf16(row[5] + k_idx) : zero; + vst1q_bf16(out + 2 * TileSize, vcombine_bf16(a0, a1)); + + a0 = (actual_m > 6) ? vld1_bf16(row[6] + k_idx) : zero; + a1 = (actual_m > 7) ? vld1_bf16(row[7] + k_idx) : zero; + vst1q_bf16(out + 3 * TileSize, vcombine_bf16(a0, a1)); + } + out += row_pair_count * TileSize; + } + } + } + + void gemm(DEFINE_CPU_MICRO_GEMM_PARAMS) { + (void)lda; // A is packed, so lda is not needed + TORCH_CHECK_EQ(k % K, 0); + + for (int32_t n_idx = 0; n_idx < NSize; n_idx += Nr_gemv) { + const bfloat16_t* __restrict__ b_panel = + reinterpret_cast(b_ptr) + n_idx * k; + + for (int32_t row_base = 0; row_base < m; row_base += Mr) { + const int32_t panel_m = std::min(Mr, m - row_base); + const bfloat16_t* __restrict__ a_panel = + reinterpret_cast(a_ptr) + row_base * k; + float* __restrict__ c_panel = c_ptr + row_base * ldc + n_idx; + + if (panel_m <= 4) { + gemm_micro_bfmmla_4x16_packed_a(a_panel, b_panel, c_panel, panel_m, k, + b_n_group_stride, ldc, accum_c); + } else { + gemm_micro_bfmmla_8x8_packed_a(a_panel, b_panel, c_panel, panel_m, k, + ldc, accum_c); + gemm_micro_bfmmla_8x8_packed_a(a_panel, b_panel + b_n_group_stride, + c_panel + Nr, panel_m, k, ldc, + accum_c); + } + } + } + } + + // physical layout [ + // N / 8; Nr is 8 + // K / 4; K for bfmmla is 4 + // 4, ; 4 col-pairs for each 8 cols + // 2, ; col-pair is 2 cols + // 4 ; 4 elements per col + // ] + static void pack_weight(const c10::BFloat16* __restrict__ weight, + c10::BFloat16* __restrict__ packed_weight, + const int32_t output_size, const int32_t input_size) { + TORCH_CHECK_EQ(output_size % NSize, 0); + TORCH_CHECK_EQ(input_size % K, 0); + + for (int32_t o_idx = 0; o_idx < output_size; o_idx += Nr) { + c10::BFloat16* __restrict__ dst = packed_weight + o_idx * input_size; + for (int32_t k_idx = 0; k_idx < input_size; k_idx += K) { + for (int32_t pair_idx = 0; pair_idx < Nr; pair_idx += Cols) { + const c10::BFloat16* __restrict__ row0 = + weight + (o_idx + pair_idx) * input_size; + const c10::BFloat16* __restrict__ row1 = row0 + input_size; + dst[0] = row0[k_idx + 0]; + dst[1] = row0[k_idx + 1]; + dst[2] = row0[k_idx + 2]; + dst[3] = row0[k_idx + 3]; + dst[4] = row1[k_idx + 0]; + dst[5] = row1[k_idx + 1]; + dst[6] = row1[k_idx + 2]; + dst[7] = row1[k_idx + 3]; + dst += TileSize; + } + } + } + } +}; + +} // namespace cpu_micro_gemm + +#endif diff --git a/csrc/cpu/micro_gemm/cpu_micro_gemm_rvv.hpp b/csrc/cpu/micro_gemm/cpu_micro_gemm_rvv.hpp new file mode 100644 index 000000000000..3e3c056f6496 --- /dev/null +++ b/csrc/cpu/micro_gemm/cpu_micro_gemm_rvv.hpp @@ -0,0 +1,228 @@ +#ifndef CPU_MICRO_GEMM_RVV_HPP +#define CPU_MICRO_GEMM_RVV_HPP + +#include "cpu/micro_gemm/cpu_micro_gemm_impl.hpp" + +#if defined(__riscv_v) + +namespace cpu_micro_gemm { +namespace { + +constexpr int32_t RVV_MGEMM_N8 = 8; +constexpr int32_t RVV_MGEMM_B_GROUP_STRIDE = 16; + +template +FORCE_INLINE fixed_fp32x8_t load_row8_b_as_f32(const scalar_t* ptr); + +template <> +FORCE_INLINE fixed_fp32x8_t load_row8_b_as_f32(const float* ptr) { + return RVVI(__riscv_vle32_v_f32, LMUL_256)(ptr, RVV_MGEMM_N8); +} + +template <> +FORCE_INLINE fixed_fp32x8_t +load_row8_b_as_f32(const c10::Half* ptr) { + #if defined(__riscv_zvfh) + fixed_fp16x8_t vec = RVVI(__riscv_vle16_v_f16, LMUL_128)( + reinterpret_cast(ptr), RVV_MGEMM_N8); + return RVVI(__riscv_vfwcvt_f_f_v_f32, LMUL_256)(vec, RVV_MGEMM_N8); + #else + alignas(32) float values[RVV_MGEMM_N8]; + for (int32_t i = 0; i < RVV_MGEMM_N8; ++i) { + values[i] = static_cast(ptr[i]); + } + return RVVI(__riscv_vle32_v_f32, LMUL_256)(values, RVV_MGEMM_N8); + #endif +} + +template <> +FORCE_INLINE fixed_fp32x8_t +load_row8_b_as_f32(const c10::BFloat16* ptr) { + #if defined(__riscv_zvfbfmin) + fixed_u16x8_t raw = RVVI(__riscv_vle16_v_u16, LMUL_128)( + reinterpret_cast(ptr), RVV_MGEMM_N8); + fixed_bf16x8_t vec = + RVVI4(__riscv_vreinterpret_v_u16, LMUL_128, _bf16, LMUL_128)(raw); + return RVVI(__riscv_vfwcvtbf16_f_f_v_f32, LMUL_256)(vec, RVV_MGEMM_N8); + #else + fixed_u16x8_t raw = RVVI(__riscv_vle16_v_u16, LMUL_128)( + reinterpret_cast(ptr), RVV_MGEMM_N8); + auto wide = RVVI(__riscv_vzext_vf2_u32, LMUL_256)(raw, RVV_MGEMM_N8); + auto shifted = RVVI(__riscv_vsll_vx_u32, LMUL_256)(wide, 16, RVV_MGEMM_N8); + return RVVI4(__riscv_vreinterpret_v_u32, LMUL_256, _f32, LMUL_256)(shifted); + #endif +} + +// Mx8 RVV kernel. B points at one 8-channel half of a 16-channel packed group, +// with rows separated by RVV_MGEMM_B_GROUP_STRIDE scalar elements. +template +FORCE_INLINE void gemm_micro_rvv_fma_mx8_ku4(const scalar_t* __restrict__ a_ptr, + const scalar_t* __restrict__ b_ptr, + float* __restrict__ c_ptr, + const int64_t lda, + const int64_t ldc, const int32_t k, + const bool accum_c) { + static_assert(0 < M && M <= 8); + + #define RVV_ROWS_APPLY(OP) OP(0) OP(1) OP(2) OP(3) OP(4) OP(5) OP(6) OP(7) + #define RVV_IF_M(i) if constexpr (M > (i)) + + #define RVV_DECL_A(i) const scalar_t* __restrict__ a##i = a_ptr + (i) * lda; + RVV_ROWS_APPLY(RVV_DECL_A) + #undef RVV_DECL_A + + #define RVV_DECL_ACC(i) fixed_fp32x8_t acc##i; + RVV_ROWS_APPLY(RVV_DECL_ACC) + #undef RVV_DECL_ACC + + #define RVV_INIT_ACC(i) \ + RVV_IF_M(i) { \ + if (accum_c) { \ + acc##i = RVVI(__riscv_vle32_v_f32, LMUL_256)(c_ptr + (i) * ldc, \ + RVV_MGEMM_N8); \ + } else { \ + acc##i = RVVI(__riscv_vfmv_v_f_f32, LMUL_256)(0.0f, RVV_MGEMM_N8); \ + } \ + } + RVV_ROWS_APPLY(RVV_INIT_ACC) + #undef RVV_INIT_ACC + + int32_t k_idx = 0; + for (; k_idx + 3 < k; k_idx += 4) { + #define RVV_FMA_ROW(i, K_OFFSET) \ + RVV_IF_M(i) { \ + acc##i = RVVI(__riscv_vfmacc_vf_f32, LMUL_256)( \ + acc##i, static_cast(*(a##i + k_idx + (K_OFFSET))), b, \ + RVV_MGEMM_N8); \ + } + + #define RVV_STEP_K(K_OFFSET) \ + { \ + fixed_fp32x8_t b = load_row8_b_as_f32( \ + b_ptr + (k_idx + (K_OFFSET)) * RVV_MGEMM_B_GROUP_STRIDE); \ + RVV_FMA_ROW(0, K_OFFSET) \ + RVV_FMA_ROW(1, K_OFFSET) \ + RVV_FMA_ROW(2, K_OFFSET) \ + RVV_FMA_ROW(3, K_OFFSET) \ + RVV_FMA_ROW(4, K_OFFSET) \ + RVV_FMA_ROW(5, K_OFFSET) \ + RVV_FMA_ROW(6, K_OFFSET) \ + RVV_FMA_ROW(7, K_OFFSET) \ + } + + RVV_STEP_K(0) + RVV_STEP_K(1) + RVV_STEP_K(2) + RVV_STEP_K(3) + #undef RVV_STEP_K + #undef RVV_FMA_ROW + } + + for (; k_idx < k; ++k_idx) { + fixed_fp32x8_t b = + load_row8_b_as_f32(b_ptr + k_idx * RVV_MGEMM_B_GROUP_STRIDE); + #define RVV_TAIL_ROW(i) \ + RVV_IF_M(i) { \ + acc##i = RVVI(__riscv_vfmacc_vf_f32, LMUL_256)( \ + acc##i, static_cast(*(a##i + k_idx)), b, RVV_MGEMM_N8); \ + } + RVV_ROWS_APPLY(RVV_TAIL_ROW) + #undef RVV_TAIL_ROW + } + + #define RVV_STORE_ROW(i) \ + RVV_IF_M(i) { \ + RVVI(__riscv_vse32_v_f32, LMUL_256)(c_ptr + (i) * ldc, acc##i, \ + RVV_MGEMM_N8); \ + } + RVV_ROWS_APPLY(RVV_STORE_ROW) + #undef RVV_STORE_ROW + + #undef RVV_ROWS_APPLY + #undef RVV_IF_M +} + +template +FORCE_INLINE void gemm_micro_rvv_mx32_ku4(DEFINE_CPU_MICRO_GEMM_PARAMS) { + static_assert(0 < M && M <= 8); + scalar_t* __restrict__ curr_b_0 = b_ptr; + scalar_t* __restrict__ curr_b_1 = b_ptr + b_n_group_stride; + + gemm_micro_rvv_fma_mx8_ku4(a_ptr, curr_b_0, c_ptr, lda, ldc, k, accum_c); + gemm_micro_rvv_fma_mx8_ku4(a_ptr, curr_b_0 + RVV_MGEMM_N8, + c_ptr + RVV_MGEMM_N8, lda, ldc, k, accum_c); + gemm_micro_rvv_fma_mx8_ku4(a_ptr, curr_b_1, c_ptr + 16, lda, ldc, k, + accum_c); + gemm_micro_rvv_fma_mx8_ku4(a_ptr, curr_b_1 + RVV_MGEMM_N8, c_ptr + 24, lda, + ldc, k, accum_c); +} + +class TileGemmRVV { + public: + template + FORCE_INLINE static void gemm(DEFINE_CPU_MICRO_GEMM_PARAMS) { + switch (m) { + case 1: + gemm_micro_rvv_mx32_ku4<1>(CPU_MICRO_GEMM_PARAMS); + break; + case 2: + gemm_micro_rvv_mx32_ku4<2>(CPU_MICRO_GEMM_PARAMS); + break; + case 3: + gemm_micro_rvv_mx32_ku4<3>(CPU_MICRO_GEMM_PARAMS); + break; + case 4: + gemm_micro_rvv_mx32_ku4<4>(CPU_MICRO_GEMM_PARAMS); + break; + case 5: + gemm_micro_rvv_mx32_ku4<5>(CPU_MICRO_GEMM_PARAMS); + break; + case 6: + gemm_micro_rvv_mx32_ku4<6>(CPU_MICRO_GEMM_PARAMS); + break; + case 7: + gemm_micro_rvv_mx32_ku4<7>(CPU_MICRO_GEMM_PARAMS); + break; + case 8: + gemm_micro_rvv_mx32_ku4<8>(CPU_MICRO_GEMM_PARAMS); + break; + } + } +}; + +} // namespace + +template +class MicroGemm { + public: + static constexpr int32_t MaxMSize = 8; + static constexpr int32_t NSize = 32; + + public: + void gemm(DEFINE_CPU_MICRO_GEMM_PARAMS) { + TileGemmRVV::gemm(CPU_MICRO_GEMM_PARAMS); + } + + static void pack_weight(const scalar_t* __restrict__ weight, + scalar_t* __restrict__ packed_weight, + const int32_t output_size, const int32_t input_size) { + TORCH_CHECK_EQ(output_size % 16, 0); + for (int32_t o_idx = 0; o_idx < output_size; ++o_idx) { + const scalar_t* __restrict__ curr_weight = weight + o_idx * input_size; + scalar_t* __restrict__ curr_packed_weight = + packed_weight + (o_idx / 16) * (16 * input_size) + o_idx % 16; + for (int32_t i_idx = 0; i_idx < input_size; ++i_idx) { + *curr_packed_weight = *curr_weight; + + curr_packed_weight += 16; + ++curr_weight; + } + } + } +}; + +} // namespace cpu_micro_gemm + +#endif // defined(__riscv_v) + +#endif // CPU_MICRO_GEMM_RVV_HPP diff --git a/csrc/cpu/micro_gemm/cpu_micro_gemm_vec.hpp b/csrc/cpu/micro_gemm/cpu_micro_gemm_vec.hpp index 1c605a2851d7..ad7d4be113ee 100644 --- a/csrc/cpu/micro_gemm/cpu_micro_gemm_vec.hpp +++ b/csrc/cpu/micro_gemm/cpu_micro_gemm_vec.hpp @@ -104,6 +104,8 @@ class MicroGemm { public: static constexpr int32_t MaxMSize = 8; static constexpr int32_t NSize = 32; + static constexpr int32_t WeightOCGroupSize = 16; + static constexpr bool PackA = false; public: void gemm(DEFINE_CPU_MICRO_GEMM_PARAMS) { diff --git a/csrc/cpu/mla_decode.cpp b/csrc/cpu/mla_decode.cpp index 582c480c3bee..702912a5bcca 100644 --- a/csrc/cpu/mla_decode.cpp +++ b/csrc/cpu/mla_decode.cpp @@ -18,17 +18,9 @@ struct KernelVecType { template <> struct KernelVecType { -#if defined(__powerpc64__) - // Power specific vector types - using qk_load_vec_type = vec_op::FP32Vec16; - using qk_vec_type = vec_op::FP32Vec16; - using v_load_vec_type = vec_op::FP32Vec16; -#else - // Fallback for other architectures, including x86 using qk_load_vec_type = vec_op::FP16Vec16; using qk_vec_type = vec_op::FP32Vec16; using v_load_vec_type = vec_op::FP16Vec16; -#endif }; #ifdef __AVX512BF16__ @@ -259,7 +251,7 @@ void mla_decode_kvcache_cpu_impl( constexpr int QK_NUM_ELEM = qk_vec_type::VEC_ELEM_NUM; // shared across threads - const int max_threads = omp_get_max_threads(); + const int max_threads = cpu_utils::get_max_threads(); const int acc_out_nbytes = max_threads * num_heads * V_HEAD_DIM * sizeof(float); float* acc_out = static_cast(std::aligned_alloc(64, acc_out_nbytes)); diff --git a/csrc/cpu/pos_encoding.cpp b/csrc/cpu/pos_encoding.cpp index 9f41e4e222bd..b241918902e5 100644 --- a/csrc/cpu/pos_encoding.cpp +++ b/csrc/cpu/pos_encoding.cpp @@ -1,4 +1,3 @@ - #include "cpu_types.hpp" namespace { @@ -97,6 +96,91 @@ void rotary_embedding_impl( } } +template <> +void rotary_embedding_impl( + const int64_t* __restrict__ positions, c10::Half* __restrict__ query, + c10::Half* __restrict__ key, const c10::Half* __restrict__ cos_sin_cache, + const int rot_dim, const int64_t query_stride, const int64_t key_stride, + const int num_heads, const int num_kv_heads, const int head_size, + const int num_tokens) { + using scalar_vec_t = vec_op::FP16Vec8; + constexpr int VEC_ELEM_NUM = scalar_vec_t::get_elem_num(); + + const int embed_dim = rot_dim / 2; + bool flag = (embed_dim % VEC_ELEM_NUM == 0); + const int loop_upper = flag ? embed_dim : embed_dim - VEC_ELEM_NUM; + + auto compute_loop = [&](const int64_t token_head, const c10::Half* cache_ptr, + c10::Half* qk) { + int j = 0; + for (; j < loop_upper; j += VEC_ELEM_NUM) { + const int rot_offset = j; + const int x_index = rot_offset; + const int y_index = embed_dim + rot_offset; + + const int64_t out_x = token_head + x_index; + const int64_t out_y = token_head + y_index; + + const vec_op::FP16Vec8 cos_fp16(cache_ptr + x_index); + const vec_op::FP16Vec8 sin_fp16(cache_ptr + y_index); + const vec_op::FP16Vec8 q_x_fp16(qk + out_x); + const vec_op::FP16Vec8 q_y_fp16(qk + out_y); + + const vec_op::FP32Vec8 fp32_cos(cos_fp16); + const vec_op::FP32Vec8 fp32_sin(sin_fp16); + const vec_op::FP32Vec8 fp32_q_x(q_x_fp16); + const vec_op::FP32Vec8 fp32_q_y(q_y_fp16); + + auto out1 = fp32_q_x * fp32_cos - fp32_q_y * fp32_sin; + auto out2 = fp32_q_y * fp32_cos + fp32_q_x * fp32_sin; + + vec_op::FP16Vec8(out1).save(qk + out_x); + vec_op::FP16Vec8(out2).save(qk + out_y); + } + if (!flag) { + for (; j < embed_dim; ++j) { + const int x_index = j; + const int y_index = embed_dim + j; + + const int64_t out_x = token_head + x_index; + const int64_t out_y = token_head + y_index; + + const float fp32_cos = static_cast(cache_ptr[x_index]); + const float fp32_sin = static_cast(cache_ptr[y_index]); + const float fp32_q_x = static_cast(qk[out_x]); + const float fp32_q_y = static_cast(qk[out_y]); + + qk[out_x] = + static_cast(fp32_q_x * fp32_cos - fp32_q_y * fp32_sin); + qk[out_y] = + static_cast(fp32_q_y * fp32_cos + fp32_q_x * fp32_sin); + } + } + }; + +#pragma omp parallel for + for (int token_idx = 0; token_idx < num_tokens; ++token_idx) { + int64_t pos = positions[token_idx]; + const c10::Half* cache_ptr = cos_sin_cache + pos * rot_dim; + + for (int i = 0; i < num_heads; ++i) { + const int head_idx = i; + const int64_t token_head = + token_idx * query_stride + head_idx * head_size; + compute_loop(token_head, cache_ptr, query); + } + + if (key != nullptr) { + for (int i = 0; i < num_kv_heads; ++i) { + const int head_idx = i; + const int64_t token_head = + token_idx * key_stride + head_idx * head_size; + compute_loop(token_head, cache_ptr, key); + } + } + } +} + template void rotary_embedding_gptj_impl( const int64_t* __restrict__ positions, // [batch_size, seq_len] or @@ -174,6 +258,75 @@ void rotary_embedding_gptj_impl( } } } + +template <> +void rotary_embedding_gptj_impl( + const int64_t* __restrict__ positions, c10::Half* __restrict__ query, + c10::Half* __restrict__ key, const c10::Half* __restrict__ cos_sin_cache, + const int rot_dim, const int64_t query_stride, const int64_t key_stride, + const int num_heads, const int num_kv_heads, const int head_size, + const int num_tokens) { + const int embed_dim = rot_dim / 2; + +#pragma omp parallel for collapse(2) + for (int token_idx = 0; token_idx < num_tokens; ++token_idx) { + for (int i = 0; i < num_heads; ++i) { + int64_t pos = positions[token_idx]; + const c10::Half* cache_ptr = cos_sin_cache + pos * rot_dim; + const c10::Half* cos_cache_ptr = cache_ptr; + const c10::Half* sin_cache_ptr = cache_ptr + embed_dim; + const int head_idx = i; + const int64_t token_head = + token_idx * query_stride + head_idx * head_size; + c10::Half* head_query = token_head + query; + for (int j = 0; j < embed_dim; j += 1) { + const int rot_offset = j; + const int x_index = 2 * rot_offset; + const int y_index = 2 * rot_offset + 1; + + const float cos = static_cast(cos_cache_ptr[rot_offset]); + const float sin = static_cast(sin_cache_ptr[rot_offset]); + + const float x = static_cast(head_query[x_index]); + const float y = static_cast(head_query[y_index]); + + head_query[x_index] = static_cast(x * cos - y * sin); + head_query[y_index] = static_cast(y * cos + x * sin); + } + } + } + + if (key == nullptr) { + return; + } + +#pragma omp parallel for collapse(2) + for (int token_idx = 0; token_idx < num_tokens; ++token_idx) { + for (int i = 0; i < num_kv_heads; ++i) { + int64_t pos = positions[token_idx]; + const c10::Half* cache_ptr = cos_sin_cache + pos * rot_dim; + const c10::Half* cos_cache_ptr = cache_ptr; + const c10::Half* sin_cache_ptr = cache_ptr + embed_dim; + const int head_idx = i; + const int64_t token_head = token_idx * key_stride + head_idx * head_size; + c10::Half* head_key = key + token_head; + for (int j = 0; j < embed_dim; j += 1) { + const int rot_offset = j; + const int x_index = 2 * rot_offset; + const int y_index = 2 * rot_offset + 1; + + const float cos = static_cast(cos_cache_ptr[rot_offset]); + const float sin = static_cast(sin_cache_ptr[rot_offset]); + + const float x = static_cast(head_key[x_index]); + const float y = static_cast(head_key[y_index]); + + head_key[x_index] = static_cast(x * cos - y * sin); + head_key[y_index] = static_cast(y * cos + x * sin); + } + } + } +} }; // namespace void rotary_embedding(torch::Tensor& positions, torch::Tensor& query, diff --git a/csrc/cpu/sgl-kernels/conv.cpp b/csrc/cpu/sgl-kernels/conv.cpp index 15114732aac1..b918aed8bff8 100644 --- a/csrc/cpu/sgl-kernels/conv.cpp +++ b/csrc/cpu/sgl-kernels/conv.cpp @@ -289,19 +289,18 @@ void causal_conv1d_fwd_kernel_impl( } } -#define LAUNCH_TINYGEMM_VARLEN_KERNEL(K, NB_SIZE) \ - tinygemm_kernel::apply( \ - input + batch_offset * dim + mb_start * dim + nb_start, \ - weight + nb_start * width, \ - out + batch_offset * dim + mb_start * dim + nb_start, \ - has_bias ? bias + nb_start : nullptr, \ - nullptr, \ - false, \ - mb_size, \ - dim, \ +#define LAUNCH_TINYGEMM_VARLEN_KERNEL(K, NB_SIZE) \ + tinygemm_kernel::apply( \ + input + batch_offset * dim + mb_start * dim + nb_start, \ + weight + nb_start * width, \ + out + batch_offset * dim + mb_start * dim + nb_start, \ + has_bias ? bias + nb_start : nullptr, \ + has_conv_states ? conv_states + conv_state_index * conv_state_slot_stride + nb_start : nullptr, \ + has_initial_states_value, \ + mb_size, \ + dim, \ mb_start == 0); -// TODO: add `has_initial_state` support for varlen kernel template void causal_conv1d_fwd_varlen_kernel_impl( scalar_t* __restrict__ out, @@ -343,6 +342,9 @@ void causal_conv1d_fwd_varlen_kernel_impl( int64_t nb_start = nb * BLOCK_N; int64_t nb_size = std::min(dim - nb_start, BLOCK_N); + const bool has_initial_states_value = has_conv_states ? has_initial_state[bs] : false; + int32_t conv_state_index = has_conv_indices ? conv_indices[bs] : bs; + switch (width << 4 | nb_size >> 4) { case 0x42: LAUNCH_TINYGEMM_VARLEN_KERNEL(4, 32); @@ -373,7 +375,7 @@ void causal_conv1d_fwd_varlen_kernel_impl( width, dim, seqlen, - /* has_initial_state */ false); + has_initial_state[bs]); } }); } diff --git a/csrc/cpu/sgl-kernels/gemm_int4.cpp b/csrc/cpu/sgl-kernels/gemm_int4.cpp index 5b66b2a5aee7..6dbd09080d05 100644 --- a/csrc/cpu/sgl-kernels/gemm_int4.cpp +++ b/csrc/cpu/sgl-kernels/gemm_int4.cpp @@ -268,6 +268,142 @@ void _dequant_gemm_accum_small_M( _dequant_gemm_accum_small_M(C, A, scales_a, qzeros_a, B, scales_b, qzeros_b, K, lda, ldc); #endif +template +inline int32_t load_uint4_vnni(const uint8_t* __restrict__ B, int64_t k, int64_t n) { + // B is packed as [_block_k / 4, N / 2, 4] for VNNI4. Each byte stores two + // columns from adjacent 8-column groups for one K lane. + constexpr int64_t n_group_size = 8; + constexpr int64_t vnni_size = 4; + static_assert(N % (2 * n_group_size) == 0); + + int64_t n_group = n / n_group_size; + int64_t ni = n % n_group_size; + int64_t ki = k % vnni_size; + int64_t k_base = k - ki; + int64_t packed_n = (n_group / 2) * n_group_size + ni; + uint8_t packed = B[k_base * ldb + packed_n * vnni_size + ki]; + return (n_group % 2 == 0) ? (packed & 0x0f) : ((packed >> 4) & 0x0f); +} + +#if defined(CPU_CAPABILITY_RVV) +template +inline fixed_i8x8_t load_uint4_as_int8_rvv(const uint8_t* __restrict__ B, int64_t k) { + constexpr int64_t n_group_size = 8; + constexpr int64_t vnni_size = 4; + static_assert(N == 32); + static_assert(ldb == N / 2); + static_assert(group >= 0 && group < N / n_group_size); + + // Unpack: gather 8 packed int4 values from the VNNI4 layout. + const int64_t ki = k % vnni_size; + const int64_t k_base = k - ki; + constexpr int64_t packed_group = group / 2; + const uint8_t* packed_ptr = B + k_base * ldb + packed_group * n_group_size * vnni_size + ki; + + fixed_u8x8_t packed = RVVI(__riscv_vlse8_v_u8, LMUL_64)(packed_ptr, vnni_size, n_group_size); + if constexpr (group % 2 == 1) { + packed = RVVI(__riscv_vsrl_vx_u8, LMUL_64)(packed, 4, n_group_size); + } + fixed_u8x8_t nibbles = RVVI(__riscv_vand_vx_u8, LMUL_64)(packed, 0x0f, n_group_size); + return RVVI4(__riscv_vreinterpret_v_u8, LMUL_64, _i8, LMUL_64)(nibbles); +} + +inline fixed_i32x8_t gemm_accum_uint8_int8_rvv(fixed_i32x8_t acc, uint8_t a, fixed_i8x8_t b) { + constexpr int64_t vl = 8; + fixed_i16x8_t b_i16 = RVVI(__riscv_vsext_vf2_i16, LMUL_128)(b, vl); + return RVVI(__riscv_vwmacc_vx_i32, LMUL_256)(acc, static_cast(a), b_i16, vl); +} + +template +inline fixed_i32x8_t gemm_accum_uint4_rvv( + fixed_i32x8_t acc, + const uint8_t* __restrict__ B, + const int8_t* __restrict__ qzeros_b, + uint8_t a, + int64_t k) { + constexpr int64_t n_group_size = 8; + fixed_i8x8_t b = load_uint4_as_int8_rvv(B, k); + fixed_i8x8_t qzeros = + RVVI(__riscv_vle8_v_i8, LMUL_64)(qzeros_b + group * n_group_size, n_group_size); + b = RVVI(__riscv_vsub_vv_i8, LMUL_64)(b, qzeros, n_group_size); + return gemm_accum_uint8_int8_rvv(acc, a, b); +} + +template +inline void _dequant_and_store_rvv( + float* __restrict__ C, + fixed_i32x8_t acc, + const float* __restrict__ scales_a, + const int32_t* __restrict__ qzeros_a, + const float* __restrict__ scales_b, + const int32_t* __restrict__ compensation, + int64_t m, + int64_t ldc) { + constexpr int64_t n_group_size = 8; + constexpr int64_t n = group * n_group_size; + constexpr int64_t vl = n_group_size; + + // Dequant compensation: remove activation zero-point contribution. + fixed_i32x8_t comp = RVVI(__riscv_vle32_v_i32, LMUL_256)(compensation + n, vl); + fixed_i32x8_t zp_comp = RVVI(__riscv_vmul_vx_i32, LMUL_256)(comp, qzeros_a[m], vl); + acc = RVVI(__riscv_vsub_vv_i32, LMUL_256)(acc, zp_comp, vl); + + // Scale: convert int32 accumulators to fp32 and apply activation/weight scales. + fixed_fp32x8_t acc_f = RVVI(__riscv_vfcvt_f_x_v_f32, LMUL_256)(acc, vl); + acc_f = RVVI(__riscv_vfmul_vf_f32, LMUL_256)(acc_f, scales_a[m], vl); + fixed_fp32x8_t scale_b = RVVI(__riscv_vle32_v_f32, LMUL_256)(scales_b + n, vl); + acc_f = RVVI(__riscv_vfmul_vv_f32, LMUL_256)(acc_f, scale_b, vl); + + // Store: accumulate into the float scratch buffer that already holds bias/zero. + float* c_ptr = C + m * ldc + n; + fixed_fp32x8_t c_old = RVVI(__riscv_vle32_v_f32, LMUL_256)(c_ptr, vl); + fixed_fp32x8_t c_new = RVVI(__riscv_vfadd_vv_f32, LMUL_256)(c_old, acc_f, vl); + RVVI(__riscv_vse32_v_f32, LMUL_256)(c_ptr, c_new, vl); +} + +template +void _dequant_gemm_accum_rvv( + float* __restrict__ C, + const uint8_t* __restrict__ A, + const float* __restrict__ scales_a, + const int32_t* __restrict__ qzeros_a, + const uint8_t* __restrict__ B, + const float* __restrict__ scales_b, + const int8_t* __restrict__ qzeros_b, + const int32_t* __restrict__ compensation, + int64_t M, + int64_t K, + int64_t lda, + int64_t ldc) { + static_assert(N == 32); + static_assert(ldb == N / 2); + constexpr int64_t vl = 8; + + // Accumulate one C row over the 32-column block. + for (int64_t m = 0; m < M; ++m) { + fixed_i32x8_t acc0 = RVVI(__riscv_vmv_v_x_i32, LMUL_256)(0, vl); + fixed_i32x8_t acc1 = RVVI(__riscv_vmv_v_x_i32, LMUL_256)(0, vl); + fixed_i32x8_t acc2 = RVVI(__riscv_vmv_v_x_i32, LMUL_256)(0, vl); + fixed_i32x8_t acc3 = RVVI(__riscv_vmv_v_x_i32, LMUL_256)(0, vl); + // A[m][k] @ B[k][0:32] -> acc[m][0:32] + for (int64_t k = 0; k < K; ++k) { + // GEMM K step: one scalar activation updates four 8-column RVV tiles. + const uint8_t a = A[m * lda + k]; + acc0 = gemm_accum_uint4_rvv(acc0, B, qzeros_b, a, k); + acc1 = gemm_accum_uint4_rvv(acc1, B, qzeros_b, a, k); + acc2 = gemm_accum_uint4_rvv(acc2, B, qzeros_b, a, k); + acc3 = gemm_accum_uint4_rvv(acc3, B, qzeros_b, a, k); + } + + // Dequant/scale/store each 8-column group back into C. + _dequant_and_store_rvv<0>(C, acc0, scales_a, qzeros_a, scales_b, compensation, m, ldc); + _dequant_and_store_rvv<1>(C, acc1, scales_a, qzeros_a, scales_b, compensation, m, ldc); + _dequant_and_store_rvv<2>(C, acc2, scales_a, qzeros_a, scales_b, compensation, m, ldc); + _dequant_and_store_rvv<3>(C, acc3, scales_a, qzeros_a, scales_b, compensation, m, ldc); + } +} +#endif + template void _dequant_gemm_accum( float* C, @@ -319,9 +455,31 @@ void _dequant_gemm_accum( _dequant_and_store( C, C_i32, scales_a, qzeros_a, scales_b, compensation, M, N /*ldi*/, ldc, 1 /*ldsa*/); } else +#elif defined(CPU_CAPABILITY_RVV) + if constexpr (!sym_quant_act && N == BLOCK_N && ldb == BLOCK_N / 2) { + _dequant_gemm_accum_rvv(C, A, scales_a, qzeros_a, B, scales_b, qzeros_b, compensation, M, K, lda, ldc); + return; + } else #endif { - TORCH_CHECK(false, "tinygemm_kernel: scalar path not implemented!"); + for (int64_t m = 0; m < M; ++m) { + for (int64_t n = 0; n < N; ++n) { + int32_t acc = 0; + for (int64_t k = 0; k < K; ++k) { + int32_t b = load_uint4_vnni(B, k, n) - qzeros_b[n]; + if constexpr (sym_quant_act) { + const int8_t* A_s8 = reinterpret_cast(A); + acc += static_cast(A_s8[m * lda + k]) * b; + } else { + acc += static_cast(A[m * lda + k]) * b; + } + } + if constexpr (!sym_quant_act) { + acc -= qzeros_a[m] * compensation[n]; + } + C[m * ldc + n] += static_cast(acc) * scales_a[m] * scales_b[n]; + } + } } } @@ -496,9 +654,11 @@ void _da8w4_linear_impl( store_out(C_tmp, output + mci * block_m * N + nc * BLOCK_N, m_size, N /*lda*/); } } +#if defined(CPU_CAPABILITY_AVX512) if (use_brgemm) { at::native::cpublas::brgemm_release(); } +#endif }); } diff --git a/csrc/cpu/sgl-kernels/vec.h b/csrc/cpu/sgl-kernels/vec.h index 77ffeec9fe7e..407cfe604343 100644 --- a/csrc/cpu/sgl-kernels/vec.h +++ b/csrc/cpu/sgl-kernels/vec.h @@ -9,11 +9,19 @@ #define CPU_CAPABILITY_AVX512 #endif +#if defined(__riscv_v_min_vlen) && (__riscv_v_min_vlen == 128 || __riscv_v_min_vlen == 256) +#define CPU_CAPABILITY_RVV +#endif + #include #include #if defined(CPU_CAPABILITY_AVX512) #include #endif + +#if defined(CPU_CAPABILITY_RVV) +#include "../cpu_types_riscv_defs.hpp" +#endif namespace { using namespace at::vec; @@ -245,7 +253,7 @@ quantize_row_int8(uint8_t* __restrict__ Aq, float& As, const scalar_t* __restric for (int64_t k = 0; k < K; ++k) { const float val = static_cast(A[k]) * inv_scale; - Aq[k] = (uint8_t)(std::round(val)) + 128; + Aq[k] = static_cast(static_cast(std::round(val)) + 128); } As = scale; } diff --git a/csrc/cpu/spec_decode_utils.cpp b/csrc/cpu/spec_decode_utils.cpp index a76b8bc69376..30192196b959 100644 --- a/csrc/cpu/spec_decode_utils.cpp +++ b/csrc/cpu/spec_decode_utils.cpp @@ -208,6 +208,89 @@ void copy_and_expand_eagle_inputs_kernel_impl( } } +void copy_and_expand_dflash_inputs_kernel_impl( + const torch::Tensor& next_token_ids, const torch::Tensor& target_positions, + torch::Tensor& out_input_ids, torch::Tensor& out_context_positions, + torch::Tensor& out_query_positions, torch::Tensor& out_context_slot_mapping, + torch::Tensor& out_query_slot_mapping, torch::Tensor& out_token_indices, + const torch::Tensor& block_table, const torch::Tensor& query_start_loc, + const std::optional& num_rejected_tokens, + const int64_t parallel_drafting_token_id, const int64_t block_size, + const int64_t num_query_per_req, const int64_t num_speculative_tokens, + const int64_t total_input_tokens, const bool has_num_rejected) { + const int64_t num_reqs = query_start_loc.size(0) - 1; + + const int64_t* next_ids_ptr = next_token_ids.data_ptr(); + const int64_t* target_pos_ptr = target_positions.data_ptr(); + const int32_t* block_table_ptr = block_table.data_ptr(); + const int32_t* query_start_ptr = query_start_loc.data_ptr(); + const int64_t* rejected_ptr = + has_num_rejected && num_rejected_tokens.has_value() + ? num_rejected_tokens.value().data_ptr() + : nullptr; + + int64_t* out_ids_ptr = out_input_ids.data_ptr(); + int64_t* out_ctx_pos_ptr = out_context_positions.data_ptr(); + int64_t* out_query_pos_ptr = out_query_positions.data_ptr(); + int64_t* out_ctx_slot_ptr = out_context_slot_mapping.data_ptr(); + int64_t* out_query_slot_ptr = out_query_slot_mapping.data_ptr(); + int32_t* out_token_idx_ptr = out_token_indices.data_ptr(); + + const int64_t block_table_stride = block_table.stride(0); + +#pragma omp parallel for + for (int64_t req_idx = 0; req_idx < num_reqs; ++req_idx) { + int32_t ctx_start = query_start_ptr[req_idx]; + int32_t ctx_end = query_start_ptr[req_idx + 1]; + int64_t num_ctx = ctx_end - ctx_start; + int64_t valid_ctx_end = ctx_end; + if (rejected_ptr != nullptr) { + valid_ctx_end -= rejected_ptr[req_idx]; + } + // Guard against out-of-bounds: ensure valid_ctx_end > ctx_start so that + // valid_ctx_end - 1 never reads before the request's context range. + valid_ctx_end = + std::max(valid_ctx_end, static_cast(ctx_start + 1)); + + int64_t last_pos = target_pos_ptr[valid_ctx_end - 1]; + + for (int64_t j = 0; j < num_ctx; ++j) { + int64_t ctx_idx = ctx_start + j; + int64_t ctx_pos_idx = std::min(ctx_idx, total_input_tokens - 1); + int64_t position = target_pos_ptr[ctx_pos_idx]; + int64_t block_num = position / block_size; + block_num = std::min(block_num, block_table_stride - 1); + int32_t block_id = + block_table_ptr[req_idx * block_table_stride + block_num]; + int64_t slot = block_id * block_size + (position % block_size); + + out_ctx_pos_ptr[ctx_idx] = position; + out_ctx_slot_ptr[ctx_idx] = slot; + } + + for (int64_t query_off = 0; query_off < num_query_per_req; ++query_off) { + int64_t query_out = req_idx * num_query_per_req + query_off; + int64_t position = last_pos + 1 + query_off; + int64_t block_num = position / block_size; + block_num = std::min(block_num, block_table_stride - 1); + int32_t block_id = + block_table_ptr[req_idx * block_table_stride + block_num]; + int64_t slot = block_id * block_size + (position % block_size); + + out_query_pos_ptr[query_out] = position; + out_query_slot_ptr[query_out] = slot; + out_ids_ptr[query_out] = + query_off == 0 ? next_ids_ptr[req_idx] : parallel_drafting_token_id; + + if (query_off > 0) { + int64_t sample_out_idx = + req_idx * num_speculative_tokens + (query_off - 1); + out_token_idx_ptr[sample_out_idx] = query_out; + } + } + } +} + void rejection_greedy_sample_kernel_impl( torch::Tensor& output_token_ids, const torch::Tensor& cu_num_draft_tokens, const torch::Tensor& draft_token_ids, const torch::Tensor& target_argmax, diff --git a/csrc/cpu/torch_bindings.cpp b/csrc/cpu/torch_bindings.cpp index 7a8188b8c8c7..cfa296e73b6e 100644 --- a/csrc/cpu/torch_bindings.cpp +++ b/csrc/cpu/torch_bindings.cpp @@ -146,13 +146,16 @@ at::Tensor causal_conv1d_update_cpu( void activation_lut_bf16(torch::Tensor& out, torch::Tensor& input, const std::string& activation); +bool cpu_attn_has_isa(const std::string& isa); + torch::Tensor get_scheduler_metadata( const int64_t num_req, const int64_t num_heads_q, const int64_t num_heads_kv, const int64_t head_dim, const torch::Tensor& seq_lens, at::ScalarType dtype, const torch::Tensor& query_start_loc, const bool casual, const int64_t window_size, const std::string& isa_hint, - const bool enable_kv_split); + const bool enable_kv_split, + const std::optional& dynamic_causal); void cpu_attn_reshape_and_cache(const torch::Tensor& key, const torch::Tensor& value, @@ -169,10 +172,10 @@ void cpu_attention_with_kv_cache( const torch::Tensor& query_start_loc, const torch::Tensor& seq_lens, const double scale, const bool causal, const std::optional& alibi_slopes, - const int64_t sliding_window_left, const int64_t sliding_window_right, - const torch::Tensor& block_table, const double softcap, - const torch::Tensor& scheduler_metadata, - const std::optional& s_aux, const double k_scale, + const int64_t sliding_window_left, const torch::Tensor& block_table, + const double softcap, const torch::Tensor& scheduler_metadata, + const std::optional& s_aux, + const std::optional& dynamic_causal, const double k_scale, const double v_scale, const std::string& kv_cache_dtype); // Note: just for avoiding importing errors @@ -234,6 +237,16 @@ void copy_and_expand_eagle_inputs_kernel_impl( const int64_t padding_token_id, const int64_t parallel_drafting_token_id, const int64_t total_input_tokens, const int64_t num_padding_slots_per_request, const bool shift_input_ids); +void copy_and_expand_dflash_inputs_kernel_impl( + const torch::Tensor& next_token_ids, const torch::Tensor& target_positions, + torch::Tensor& out_input_ids, torch::Tensor& out_context_positions, + torch::Tensor& out_query_positions, torch::Tensor& out_context_slot_mapping, + torch::Tensor& out_query_slot_mapping, torch::Tensor& out_token_indices, + const torch::Tensor& block_table, const torch::Tensor& query_start_loc, + const std::optional& num_rejected_tokens, + const int64_t parallel_drafting_token_id, const int64_t block_size, + const int64_t num_query_per_req, const int64_t num_speculative_tokens, + const int64_t total_input_tokens, const bool has_num_rejected); void rejection_greedy_sample_kernel_impl( torch::Tensor& output_token_ids, const torch::Tensor& cu_num_draft_tokens, const torch::Tensor& draft_token_ids, const torch::Tensor& target_argmax, @@ -265,7 +278,8 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { ops.def( "dynamic_4bit_int_moe(" "Tensor x, Tensor topk_ids, Tensor topk_weights," - "Tensor w13_packed, Tensor w2_packed, int H, int I, int I2," + "Tensor w13_packed, Tensor w2_packed," + "int hidden_size, int intermediate_size," "int group_size, bool apply_router_weight_on_input, int activation_kind" ") -> Tensor"); @@ -285,6 +299,10 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { ops.def("gelu_tanh_and_mul(Tensor! out, Tensor input) -> ()"); ops.impl("gelu_tanh_and_mul", torch::kCPU, &gelu_tanh_and_mul); + // GELU tanh implementation. + ops.def("gelu_tanh(Tensor! out, Tensor input) -> ()"); + ops.impl("gelu_tanh", torch::kCPU, &gelu_tanh); + // GELU implementation used in GPT-2. ops.def("gelu_new(Tensor! out, Tensor input) -> ()"); ops.impl("gelu_new", torch::kCPU, &gelu_new); @@ -309,13 +327,13 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { // Layernorm // Apply Root Mean Square (RMS) Normalization to the input tensor. ops.def( - "rms_norm(Tensor! out, Tensor input, Tensor weight, float epsilon) -> " + "rms_norm(Tensor! out, Tensor input, Tensor? weight, float epsilon) -> " "()"); ops.impl("rms_norm", torch::kCPU, &rms_norm); // In-place fused Add and RMS Normalization. ops.def( - "fused_add_rms_norm(Tensor! input, Tensor! residual, Tensor weight, " + "fused_add_rms_norm(Tensor! input, Tensor! residual, Tensor? weight, " "float epsilon) -> ()"); ops.impl("fused_add_rms_norm", torch::kCPU, &fused_add_rms_norm); @@ -329,8 +347,9 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { ops.impl("rotary_embedding", torch::kCPU, &rotary_embedding); // Quantization -#if defined(__AVX512F__) || defined(__AVX2__) || \ - (defined(__aarch64__) && !defined(__APPLE__)) || defined(__powerpc64__) +#if defined(__AVX512F__) || defined(__AVX2__) || \ + (defined(__aarch64__) && !defined(__APPLE__)) || defined(__powerpc64__) || \ + defined(__riscv_v) // Helper function to release oneDNN handlers ops.def("release_dnnl_matmul_handler(int handler) -> ()", &release_dnnl_matmul_handler); @@ -428,19 +447,6 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { ops.impl("int8_scaled_mm_with_quant", torch::kCPU, &int8_scaled_mm_with_quant); - // Adapted from sglang: INT4 W4A8 kernels - ops.def( - "convert_weight_packed_scale_zp(Tensor weight, Tensor qzeros, Tensor " - "scales, int quant_method_4bit) -> (Tensor, " - "Tensor, Tensor)"); - ops.impl("convert_weight_packed_scale_zp", torch::kCPU, - &convert_weight_packed_scale_zp); - - ops.def( - "int4_scaled_mm_cpu(Tensor(a0!) x, Tensor(a1!) w, Tensor(a2!) w_zeros, " - "Tensor(a3!) w_scales, Tensor? bias) -> Tensor"); - ops.impl("int4_scaled_mm_cpu", torch::kCPU, &int4_scaled_mm_cpu); - // Adapted from sglang: FP8 W8A16 kernel ops.def( "fp8_scaled_mm_cpu(Tensor(a0!) mat1, Tensor(a1!) mat2, Tensor(a2!) " @@ -467,6 +473,23 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { ops.impl("causal_conv1d_update_cpu", torch::kCPU, &causal_conv1d_update_cpu); #endif +#if (defined(__AVX512BF16__) && defined(__AVX512F__) && \ + defined(__AVX512VNNI__)) || \ + defined(__riscv) + // Adapted from sglang: INT4 W4A8 kernels + ops.def( + "convert_weight_packed_scale_zp(Tensor weight, Tensor qzeros, Tensor " + "scales, int quant_method_4bit) -> (Tensor, " + "Tensor, Tensor)"); + ops.impl("convert_weight_packed_scale_zp", torch::kCPU, + &convert_weight_packed_scale_zp); + + ops.def( + "int4_scaled_mm_cpu(Tensor(a0!) x, Tensor(a1!) w, Tensor(a2!) w_zeros, " + "Tensor(a3!) w_scales, Tensor? bias) -> Tensor"); + ops.impl("int4_scaled_mm_cpu", torch::kCPU, &int4_scaled_mm_cpu); +#endif + // Adapted from sglang: GDN kernels ops.def( "chunk_gated_delta_rule_cpu(Tensor query, Tensor key, Tensor value, " @@ -491,11 +514,12 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { ops.impl("fused_gdn_gating_cpu", torch::kCPU, &fused_gdn_gating_cpu); // CPU attention kernels + ops.def("cpu_attn_has_isa(str isa) -> bool", &cpu_attn_has_isa); ops.def( "get_scheduler_metadata(int num_req, int num_heads_q, int num_heads_kv, " "int head_dim, Tensor seq_lens, ScalarType dtype, Tensor " "query_start_loc, bool casual, int window_size, str isa_hint, bool " - "enable_kv_split) -> Tensor", + "enable_kv_split, Tensor? dynamic_causal) -> Tensor", &get_scheduler_metadata); ops.def( "cpu_attn_reshape_and_cache(Tensor key, Tensor value, Tensor(a2!) " @@ -507,8 +531,9 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { "cpu_attention_with_kv_cache(Tensor query, Tensor key_cache, Tensor " "value_cache, Tensor(a3!) output, Tensor query_start_loc, Tensor " "seq_lens, float scale, bool causal, Tensor? alibi_slopes, SymInt " - "sliding_window_left, SymInt sliding_window_right, Tensor block_table, " - "float softcap, Tensor scheduler_metadata, Tensor? s_aux, " + "sliding_window_size, Tensor block_table, " + "float softcap, Tensor scheduler_metadata, Tensor? s_aux, Tensor? " + "dynamic_causal, " "float k_scale=1.0, float v_scale=1.0, str kv_cache_dtype=\"auto\") -> " "()", &cpu_attention_with_kv_cache); @@ -528,7 +553,7 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { #endif // fused moe -#if defined(__AVX512F__) +#if defined(__AVX512F__) || (defined(ARM_BF16_SUPPORT)) ops.def( "prepack_moe_weight(Tensor weight, Tensor(a1!) packed_weight, str isa) " "-> ()"); @@ -539,7 +564,7 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { "bool skip_weighted, " "str act, str isa) -> ()"); ops.impl("cpu_fused_moe", torch::kCPU, &cpu_fused_moe); -#endif +#endif // #if defined(__AVX512F__) || (defined(ARM_BF16_SUPPORT)) ops.def( "mla_decode_kvcache(" " Tensor! out, Tensor query, Tensor kv_cache," @@ -589,6 +614,19 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { "SymInt total_input_tokens, SymInt num_padding_slots_per_request, " "bool shift_input_ids) -> ()", &cpu_utils::copy_and_expand_eagle_inputs_kernel_impl); + ops.def( + "copy_and_expand_dflash_inputs_kernel_impl(" + "Tensor next_token_ids, Tensor target_positions, " + "Tensor(a2!) out_input_ids, Tensor(a3!) out_context_positions, " + "Tensor(a4!) out_query_positions, " + "Tensor(a5!) out_context_slot_mapping, " + "Tensor(a6!) out_query_slot_mapping, " + "Tensor(a7!) out_token_indices, Tensor block_table, " + "Tensor query_start_loc, Tensor? num_rejected_tokens, " + "SymInt parallel_drafting_token_id, SymInt block_size, " + "SymInt num_query_per_req, SymInt num_speculative_tokens, " + "SymInt total_input_tokens, bool has_num_rejected) -> ()", + &cpu_utils::copy_and_expand_dflash_inputs_kernel_impl); ops.def( "rejection_greedy_sample_kernel_impl(" "Tensor(a0!) output_token_ids, Tensor cu_num_draft_tokens, " diff --git a/csrc/cpu/utils.hpp b/csrc/cpu/utils.hpp index 394e67e3a034..78ee7081b240 100644 --- a/csrc/cpu/utils.hpp +++ b/csrc/cpu/utils.hpp @@ -2,19 +2,24 @@ #define UTILS_HPP #include +#include #include #include #include "cpu/cpu_types.hpp" namespace cpu_utils { -enum class ISA { AMX, VEC }; +enum class ISA { AMX, VEC, RVV, NEON }; inline ISA get_isa(const std::string& isa) { if (isa == "amx") { return ISA::AMX; } else if (isa == "vec") { return ISA::VEC; + } else if (isa == "rvv") { + return ISA::RVV; + } else if (isa == "neon") { + return ISA::NEON; } else { TORCH_CHECK(false, "Invalid isa type: " + isa); } @@ -71,14 +76,14 @@ inline int64_t get_available_l2_size() { if (l2_cache_size == 0) { l2_cache_size = 256 * 1024; } - return static_cast(l2_cache_size) >> 1; // use 50% of L2 cache + return static_cast(l2_cache_size) >> 1; }(); return size; #else static int64_t size = []() { auto caps = at::cpu::get_cpu_capabilities(); const uint32_t l2_cache_size = caps.at("l2_cache_size").toInt(); - return l2_cache_size >> 1; // use 50% of L2 cache + return l2_cache_size >> 1; }(); return size; #endif diff --git a/csrc/cuda_view.cu b/csrc/cuda_view.cu deleted file mode 100644 index 73b368cb6003..000000000000 --- a/csrc/cuda_view.cu +++ /dev/null @@ -1,59 +0,0 @@ -#include -#include -#include - -// This function assumes that `cpu_tensor` is a CPU tensor, -// and that UVA (Unified Virtual Addressing) is enabled. -torch::Tensor get_cuda_view_from_cpu_tensor(torch::Tensor& cpu_tensor) { - TORCH_CHECK(cpu_tensor.device().is_cpu(), "Input tensor must be on CPU"); - - // handle empty tensor - if (cpu_tensor.numel() == 0) { - return torch::empty(cpu_tensor.sizes(), - cpu_tensor.options().device(torch::kCUDA)); - } - - if (cpu_tensor.is_pinned()) { - // If CPU tensor is pinned, directly get the device pointer. - void* host_ptr = const_cast(cpu_tensor.data_ptr()); - void* device_ptr = nullptr; - cudaError_t err = cudaHostGetDevicePointer(&device_ptr, host_ptr, 0); - TORCH_CHECK(err == cudaSuccess, - "cudaHostGetDevicePointer failed: ", cudaGetErrorString(err)); - - return torch::from_blob( - device_ptr, cpu_tensor.sizes(), cpu_tensor.strides(), - [base = cpu_tensor](void*) {}, // keep cpu tensor alive - cpu_tensor.options().device(torch::kCUDA)); - } - - // If CPU tensor is not pinned, allocate a new pinned memory buffer. - torch::Tensor contiguous_cpu = cpu_tensor.contiguous(); - size_t nbytes = contiguous_cpu.nbytes(); - - void* host_ptr = nullptr; - cudaError_t err = cudaHostAlloc(&host_ptr, nbytes, cudaHostAllocMapped); - if (err != cudaSuccess) { - AT_ERROR("cudaHostAlloc failed: ", cudaGetErrorString(err)); - } - - err = cudaMemcpy(host_ptr, contiguous_cpu.data_ptr(), nbytes, - cudaMemcpyDefault); - if (err != cudaSuccess) { - cudaFreeHost(host_ptr); - AT_ERROR("cudaMemcpy failed: ", cudaGetErrorString(err)); - } - - void* device_ptr = nullptr; - err = cudaHostGetDevicePointer(&device_ptr, host_ptr, 0); - if (err != cudaSuccess) { - cudaFreeHost(host_ptr); - AT_ERROR("cudaHostGetDevicePointer failed: ", cudaGetErrorString(err)); - } - - auto deleter = [host_ptr](void*) { cudaFreeHost(host_ptr); }; - - return torch::from_blob(device_ptr, contiguous_cpu.sizes(), - contiguous_cpu.strides(), deleter, - contiguous_cpu.options().device(torch::kCUDA)); -} \ No newline at end of file diff --git a/csrc/cumem_allocator.cpp b/csrc/cumem_allocator.cpp index 0b720d356e78..2329d51a149e 100644 --- a/csrc/cumem_allocator.cpp +++ b/csrc/cumem_allocator.cpp @@ -9,6 +9,7 @@ static const char* PYARGS_PARSE = "KKKK"; #else #include + #include #include #include @@ -46,6 +47,29 @@ static inline unsigned long long my_min(unsigned long long a, return a < b ? a : b; } +static CUresult reserve_rocm_address(CUdeviceptr* d_mem, size_t size, + size_t alignment, CUdeviceptr addr = 0) { + CUresult status = cuMemAddressReserve(d_mem, size, alignment, addr, 0); + if (status == CUresult(0) || alignment == 0) { + return status; + } + + // Some ROCm stacks can report OOM while reserving VA with an explicit + // alignment even when physical VRAM is free. Let HIP choose the default + // alignment, then verify that the returned address still satisfies the + // requested alignment before accepting it. + status = cuMemAddressReserve(d_mem, size, 0, addr, 0); + if (status != CUresult(0)) { + return status; + } + if (((std::uintptr_t)(*d_mem) % alignment) == 0) { + return status; + } + + (void)cuMemAddressFree(*d_mem, size); + return hipErrorNotSupported; +} + static const char* PYARGS_PARSE = "KKKO"; #endif @@ -325,7 +349,7 @@ void* my_malloc(ssize_t size, int device, CUstream stream) { return nullptr; } #else - CUDA_CHECK(cuMemAddressReserve(&d_mem, alignedSize, granularity, 0, 0)); + CUDA_CHECK(reserve_rocm_address(&d_mem, alignedSize, granularity)); if (error_code != 0) { return nullptr; } @@ -511,7 +535,14 @@ void my_free(void* ptr, ssize_t size, int device, CUstream stream) { Py_DECREF(py_result); PyGILState_Release(gstate); - unmap_and_release(device, size, d_mem, p_memHandle, chunk_sizes, num_chunks); + // An empty chunk list means this allocation is asleep: its physical chunks + // were already unmapped and released by sleep(), but the virtual address is + // still held as a placeholder reservation. Skip unmap/release (freeing the + // placeholder address happens below). + if (num_chunks > 0) { + unmap_and_release(device, size, d_mem, p_memHandle, chunk_sizes, + num_chunks); + } #else // Non-ROCm path: simple integer handle already extracted; drop temporary // Python refs while still holding the GIL, then release it. @@ -524,11 +555,13 @@ void my_free(void* ptr, ssize_t size, int device, CUstream stream) { unmap_and_release(device, size, d_mem, p_memHandle); #endif - // free address and the handle + // Free the virtual address. On ROCm this also covers an asleep allocation, + // whose placeholder reservation made by sleep() is still held here. CUDA_CHECK(cuMemAddressFree(d_mem, size)); #ifndef USE_ROCM free(p_memHandle); #else + // Only awake allocations have per-chunk handles to free. for (auto i = 0; i < num_chunks; ++i) { free(p_memHandle[i]); } @@ -648,6 +681,29 @@ static PyObject* python_unmap_and_release(PyObject* self, PyObject* args) { unmap_and_release(recv_device, recv_size, d_mem_ptr, p_memHandle, chunk_sizes, num_chunks); + // On ROCm/Linux, physical VRAM is only reclaimed once the virtual address + // range is freed; hipMemUnmap + hipMemRelease alone leave the memory + // resident (see ROCm#6021). Free the address to release physical memory, + // then immediately re-reserve the SAME address as an empty placeholder so + // the regular allocator cannot hand it out while we sleep. wake_up remaps + // physical chunks into this placeholder. + if (error_code == no_error) { + CUDA_CHECK(cuMemAddressFree(d_mem_ptr, recv_size)); + if (error_code == no_error) { + CUdeviceptr reserved = 0; + CUDA_CHECK(reserve_rocm_address(&reserved, recv_size, /*alignment=*/0, + d_mem_ptr)); + if (error_code == no_error && reserved != d_mem_ptr) { + (void)cuMemAddressFree(reserved, recv_size); + snprintf(error_msg, sizeof(error_msg), + "failed to re-reserve placeholder address on sleep " + "(requested %#llx, got %#llx)", + (unsigned long long)d_mem_ptr, (unsigned long long)reserved); + error_code = CUresult(1); + } + } + } + free(p_memHandle); free(chunk_sizes); #endif @@ -712,6 +768,7 @@ static PyObject* python_create_and_map(PyObject* self, PyObject* args) { chunk_sizes[i] = PyLong_AsUnsignedLongLong(size_py); } + // Address already reserved as a placeholder by sleep(); just remap chunks. create_and_map(recv_device, recv_size, d_mem_ptr, p_memHandle, chunk_sizes, num_chunks); diff --git a/csrc/custom_all_reduce_test.cu b/csrc/custom_all_reduce_test.cu deleted file mode 100644 index f7f0823465d3..000000000000 --- a/csrc/custom_all_reduce_test.cu +++ /dev/null @@ -1,361 +0,0 @@ -/** - * This is a standalone test for custom allreduce. - * To compile, make sure you have MPI and NCCL installed in your system. - * export MPI_HOME=XXX - * nvcc -O2 -arch=native -std=c++17 custom_all_reduce_test.cu -o - * custom_all_reduce_test -lnccl -I${MPI_HOME}/include -lmpi - * - * Warning: this C++ test is not designed to be very readable and was used - * during the rapid prototyping process. - * - * To run: - * mpirun --allow-run-as-root -np 8 ./custom_all_reduce_test - */ -#include -#include -#include -#include - -#include -#include - -#include "cuda_profiler_api.h" -#include "custom_all_reduce.cuh" -#include "mpi.h" -#ifdef USE_ROCM - #include -typedef __hip_bfloat16 nv_bfloat16; - #include "rccl/rccl.h" - #include "custom_all_reduce_hip.cuh" -#else - #include "nccl.h" - #include "custom_all_reduce.cuh" -#endif - -#define MPICHECK(cmd) \ - do { \ - int e = cmd; \ - if (e != MPI_SUCCESS) { \ - printf("Failed: MPI error %s:%d '%d'\n", __FILE__, __LINE__, e); \ - exit(EXIT_FAILURE); \ - } \ - } while (0) - -#define NCCLCHECK(cmd) \ - do { \ - ncclResult_t r = cmd; \ - if (r != ncclSuccess) { \ - printf("Failed, NCCL error %s:%d '%s'\n", __FILE__, __LINE__, \ - ncclGetErrorString(r)); \ - exit(EXIT_FAILURE); \ - } \ - } while (0) - -#ifdef USE_ROCM -__global__ void dummy_kernel() { - for (int i = 0; i < 100; i++) { - uint64_t start = wall_clock64(); - uint64_t cycles_elapsed; - do { - cycles_elapsed = wall_clock64() - start; - } while (cycles_elapsed < 100); - } - for (int i = 0; i < 100; i++) __nanosleep(1000000); // 100ms -} -#else -__global__ void dummy_kernel() { - #if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 700 - for (int i = 0; i < 100; i++) __nanosleep(1000000); // 100ms - #else - for (int i = 0; i < 100; i++) { - long long int start = clock64(); - while (clock64() - start < 150000000); // approximately 98.4ms on P40 - } - #endif -} -#endif - -template -__global__ void set_data(T* data, int size, int myRank) { - for (int idx = blockIdx.x * blockDim.x + threadIdx.x; idx < size; - idx += gridDim.x * blockDim.x) { - data[idx] = myRank * 0.11f; - } -} - -template -__global__ void convert_data(const T* data1, const T* data2, double* fdata1, - double* fdata2, int size) { - for (int idx = blockIdx.x * blockDim.x + threadIdx.x; idx < size; - idx += gridDim.x * blockDim.x) { - fdata1[idx] = data1[idx]; - fdata2[idx] = data2[idx]; - } -} - -__global__ void init_rand(curandState_t* state, int size, int nRanks) { - for (int idx = blockIdx.x * blockDim.x + threadIdx.x; idx < size; - idx += gridDim.x * blockDim.x) { - for (int i = 0; i < nRanks; i++) { - curand_init(i + 1, idx, 0, &state[idx * nRanks + i]); - } - } -} - -template -__global__ void gen_data(curandState_t* state, T* data, double* ground_truth, - int myRank, int nRanks, int size) { - for (int idx = blockIdx.x * blockDim.x + threadIdx.x; idx < size; - idx += gridDim.x * blockDim.x) { - double sum = 0.0; - for (int i = 0; i < nRanks; i++) { - double val = curand_uniform_double(&state[idx * nRanks + i]) * 4; - T hval = val; // downcast first - sum += static_cast(hval); - if (i == myRank) data[idx] = hval; - } - ground_truth[idx] = sum; - } -} - -template -void run(int myRank, int nRanks, ncclComm_t& comm, int threads, int block_limit, - int data_size, bool performance_test) { - T* result; - cudaStream_t stream; - CUDACHECK(cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking)); - CUDACHECK(cudaMalloc(&result, data_size * sizeof(T))); - CUDACHECK(cudaMemset(result, 0, data_size * sizeof(T))); - - cudaIpcMemHandle_t self_data_handle; - cudaIpcMemHandle_t data_handles[8]; - vllm::Signal* buffer; - T* self_data_copy; - /** - * Allocate IPC buffer - * - * The first section is a temporary buffer for storing intermediate allreduce - * results, if a particular algorithm requires it. The second section is for - * the input to the allreduce. The actual API takes the input pointer as an - * argument (that is, they can and usually should be allocated separately). - * But since the input pointers and the temporary buffer all require IPC - * registration, they are allocated and registered together in the test for - * convenience. - */ -#ifdef USE_ROCM - CUDACHECK(hipExtMallocWithFlags( - (void**)&buffer, 2 * data_size * sizeof(T) + sizeof(vllm::Signal), - hipDeviceMallocUncached)); -#else - CUDACHECK( - cudaMalloc(&buffer, 2 * data_size * sizeof(T) + sizeof(vllm::Signal))); -#endif - CUDACHECK( - cudaMemset(buffer, 0, 2 * data_size * sizeof(T) + sizeof(vllm::Signal))); - CUDACHECK(cudaMalloc(&self_data_copy, data_size * sizeof(T))); - CUDACHECK(cudaIpcGetMemHandle(&self_data_handle, buffer)); - - MPICHECK(MPI_Allgather(&self_data_handle, sizeof(cudaIpcMemHandle_t), - MPI_BYTE, data_handles, sizeof(cudaIpcMemHandle_t), - MPI_BYTE, MPI_COMM_WORLD)); - - void* rank_data; - size_t rank_data_sz = 16 * 1024 * 1024; - CUDACHECK(cudaMalloc(&rank_data, rank_data_sz)); - vllm::Signal* ipc_ptrs[8]; - for (int i = 0; i < nRanks; i++) { - if (i == myRank) - ipc_ptrs[i] = buffer; - else - CUDACHECK(cudaIpcOpenMemHandle((void**)&ipc_ptrs[i], data_handles[i], - cudaIpcMemLazyEnablePeerAccess)); - } - vllm::CustomAllreduce fa(ipc_ptrs, rank_data, rank_data_sz, myRank, nRanks); - auto* self_data = - reinterpret_cast(reinterpret_cast(buffer) + - sizeof(vllm::Signal) + data_size * sizeof(T)); - // hack buffer registration - { - void* data[8]; - for (int i = 0; i < nRanks; i++) { - data[i] = - ((char*)ipc_ptrs[i]) + sizeof(vllm::Signal) + data_size * sizeof(T); - } - fa.register_buffer(data); - } - - double* ground_truth; - CUDACHECK(cudaMallocHost(&ground_truth, data_size * sizeof(double))); - curandState_t* states; - CUDACHECK(cudaMalloc(&states, sizeof(curandState_t) * nRanks * data_size)); - init_rand<<<108, 1024, 0, stream>>>(states, data_size, nRanks); - gen_data<<<108, 1024, 0, stream>>>(states, self_data, ground_truth, myRank, - nRanks, data_size); - CUDACHECK(cudaMemcpyAsync(self_data_copy, self_data, data_size * sizeof(T), - cudaMemcpyDeviceToDevice, stream)); - cudaEvent_t start, stop; - CUDACHECK(cudaEventCreate(&start)); - CUDACHECK(cudaEventCreate(&stop)); - - ncclDataType_t ncclDtype; - if (std::is_same::value) { - ncclDtype = ncclFloat16; - } else if (std::is_same::value) { - ncclDtype = ncclBfloat16; - } else { - ncclDtype = ncclFloat; - } - double *nccl_result, *my_result; - CUDACHECK(cudaMallocHost(&nccl_result, data_size * sizeof(double))); - CUDACHECK(cudaMallocHost(&my_result, data_size * sizeof(double))); - if (performance_test) { - dummy_kernel<<<1, 1, 0, stream>>>(); - constexpr int warmup_iters = 5; - constexpr int num_iters = 100; - // warmup - for (int i = 0; i < warmup_iters; i++) { - NCCLCHECK(ncclAllReduce(result, result, data_size, ncclDtype, ncclSum, - comm, stream)); - } - CUDACHECK(cudaEventRecord(start, stream)); - for (int i = 0; i < num_iters; i++) { - NCCLCHECK(ncclAllReduce(result, result, data_size, ncclDtype, ncclSum, - comm, stream)); - } - CUDACHECK(cudaEventRecord(stop, stream)); - CUDACHECK(cudaStreamSynchronize(stream)); - float allreduce_ms = 0; - cudaEventElapsedTime(&allreduce_ms, start, stop); - - dummy_kernel<<<1, 1, 0, stream>>>(); - // warm up - for (int i = 0; i < warmup_iters; i++) { - fa.allreduce(stream, self_data, result, data_size, threads, - block_limit); - } - CUDACHECK(cudaEventRecord(start, stream)); - for (int i = 0; i < num_iters; i++) { - fa.allreduce(stream, self_data, result, data_size, threads, - block_limit); - } - CUDACHECK(cudaEventRecord(stop, stream)); - CUDACHECK(cudaStreamSynchronize(stream)); - - float duration_ms = 0; - cudaEventElapsedTime(&duration_ms, start, stop); - if (myRank == 0) - printf( - "Rank %d done, nGPUs:%d, sz (kb): %d, %d, %d, my time:%.2fus, nccl " - "time:%.2fus\n", - myRank, nRanks, data_size * sizeof(T) / 1024, threads, block_limit, - duration_ms * 1e3 / num_iters, allreduce_ms * 1e3 / num_iters); - - // And wait for all the queued up work to complete - CUDACHECK(cudaStreamSynchronize(stream)); - - NCCLCHECK(ncclAllReduce(self_data_copy, self_data, data_size, ncclDtype, - ncclSum, comm, stream)); - - convert_data<<<108, 1024, 0, stream>>>(self_data, result, nccl_result, - my_result, data_size); - CUDACHECK(cudaStreamSynchronize(stream)); - - for (unsigned long j = 0; j < data_size; j++) { - auto diff = abs(nccl_result[j] - my_result[j]); - if (diff >= 4e-2) { - printf("Rank %d: Verification mismatch at %lld: %f != (my) %f, gt=%f\n", - myRank, j, nccl_result[j], my_result[j], ground_truth[j]); - break; - } - } - long double nccl_diffs = 0.0; - long double my_diffs = 0.0; - for (int j = 0; j < data_size; j++) { - nccl_diffs += abs(nccl_result[j] - ground_truth[j]); - my_diffs += abs(my_result[j] - ground_truth[j]); - } - if (myRank == 0) - std::cout << "average abs diffs: nccl: " << nccl_diffs / data_size - << " me: " << my_diffs / data_size << std::endl; - } else { - for (int i = 0; i < 100; i++) { - fa.allreduce(stream, self_data, result, data_size, threads, - block_limit); - CUDACHECK(cudaStreamSynchronize(stream)); - NCCLCHECK(ncclAllReduce(self_data, self_data_copy, data_size, ncclDtype, - ncclSum, comm, stream)); - convert_data<<<108, 1024, 0, stream>>>( - self_data_copy, result, nccl_result, my_result, data_size); - CUDACHECK(cudaStreamSynchronize(stream)); - - for (unsigned long j = 0; j < data_size; j++) { - auto diff = abs(nccl_result[j] - my_result[j]); - if (diff >= 4e-2) { - printf( - "Rank %d: Verification mismatch at %lld: %f != (my) %f, gt=%f\n", - myRank, j, nccl_result[j], my_result[j], ground_truth[j]); - break; - } - } - } - if (myRank == 0) - printf("Test passed: nGPUs:%d, sz (kb): %d, %d, %d\n", nRanks, - data_size * sizeof(T) / 1024, threads, block_limit); - // long double nccl_diffs = 0.0; - // long double my_diffs = 0.0; - // for (int j = 0; j < data_size; j++) { - // nccl_diffs += abs(nccl_result[j] - ground_truth[j]); - // my_diffs += abs(my_result[j] - ground_truth[j]); - // } - // if (myRank == 0) - // std::cout << "average abs diffs: nccl: " << nccl_diffs / data_size - // << " me: " << my_diffs / data_size << std::endl; - } - - CUDACHECK(cudaFree(result)); - CUDACHECK(cudaFree(self_data_copy)); - CUDACHECK(cudaFree(rank_data)); - CUDACHECK(cudaFree(buffer)); - CUDACHECK(cudaFree(states)); - CUDACHECK(cudaFreeHost(ground_truth)); - CUDACHECK(cudaFreeHost(nccl_result)); - CUDACHECK(cudaFreeHost(my_result)); - CUDACHECK(cudaStreamDestroy(stream)); -} - -int main(int argc, char** argv) { - int nRanks, myRank; - MPICHECK(MPI_Init(&argc, &argv)); - MPICHECK(MPI_Comm_rank(MPI_COMM_WORLD, &myRank)); - MPICHECK(MPI_Comm_size(MPI_COMM_WORLD, &nRanks)); - CUDACHECK(cudaSetDevice(myRank)); - ncclUniqueId id; - ncclComm_t comm; - if (myRank == 0) ncclGetUniqueId(&id); - MPICHECK(MPI_Bcast(static_cast(&id), sizeof(id), MPI_BYTE, 0, - MPI_COMM_WORLD)); - NCCLCHECK(ncclCommInitRank(&comm, nRanks, id, myRank)); - - bool performance_test = true; - cudaProfilerStart(); -// Uncomment to scan through different block size configs. -// for (int threads : {256, 512, 1024}) { -// for (int block_limit = 16; block_limit < 112; block_limit += 4) { -// run(myRank, nRanks, comm, threads, block_limit, 1024 * 1024, -// performance_test); -// } -// } -#ifdef USE_ROCM - const int block_limit = 16; -#else - const int block_limit = 36; -#endif - // Scan through different sizes to test performance. - for (int sz = 512; sz <= (8 << 20); sz *= 2) { - run(myRank, nRanks, comm, 512, 36, sz + 8 * 47, performance_test); - } - - cudaProfilerStop(); - MPICHECK(MPI_Finalize()); - return EXIT_SUCCESS; -} \ No newline at end of file diff --git a/csrc/custom_quickreduce.cu b/csrc/custom_quickreduce.cu index 33d0d4a7226e..d4e5d179a54e 100644 --- a/csrc/custom_quickreduce.cu +++ b/csrc/custom_quickreduce.cu @@ -97,18 +97,28 @@ int64_t qr_max_size() { cast_bf2half>; \ template struct quickreduce::AllReduceTwoshot, cast_bf2half>; + // INT3 (CodecQ3) is restricted to TP2 only, so we only instantiate the + // world_size == 2 kernel for it. + #define INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(T, Codec, cast_bf2half) \ + template struct quickreduce::AllReduceTwoshot, cast_bf2half>; + INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecFP, false) INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ4, false) INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ6, false) INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ8, false) +INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(quickreduce::nv_bfloat16, + quickreduce::CodecQ3, false) INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecFP, true) INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ4, true) INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ6, true) INSTANTIATE_FOR_WORLDSIZE(quickreduce::nv_bfloat16, quickreduce::CodecQ8, true) +INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(quickreduce::nv_bfloat16, + quickreduce::CodecQ3, true) INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecFP, false) INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecQ4, false) INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecQ6, false) INSTANTIATE_FOR_WORLDSIZE(half, quickreduce::CodecQ8, false) +INSTANTIATE_FOR_WORLDSIZE_TP2_ONLY(half, quickreduce::CodecQ3, false) #endif // USE_ROCM \ No newline at end of file diff --git a/csrc/cutlass_extensions/vllm_cutlass_library_extension.py b/csrc/cutlass_extensions/vllm_cutlass_library_extension.py index 34fb64c413db..d692502f3ffd 100644 --- a/csrc/cutlass_extensions/vllm_cutlass_library_extension.py +++ b/csrc/cutlass_extensions/vllm_cutlass_library_extension.py @@ -57,13 +57,13 @@ class MixedInputKernelScheduleType(enum.Enum): } VLLMDataTypeTorchDataTypeTag: dict[VLLMDataType | DataType, str] = { - DataType.u8: "at::ScalarType::Byte", - DataType.s8: "at::ScalarType::Char", - DataType.e4m3: "at::ScalarType::Float8_e4m3fn", - DataType.s32: "at::ScalarType::Int", - DataType.f16: "at::ScalarType::Half", - DataType.bf16: "at::ScalarType::BFloat16", - DataType.f32: "at::ScalarType::Float", + DataType.u8: "torch::headeronly::ScalarType::Byte", + DataType.s8: "torch::headeronly::ScalarType::Char", + DataType.e4m3: "torch::headeronly::ScalarType::Float8_e4m3fn", + DataType.s32: "torch::headeronly::ScalarType::Int", + DataType.f16: "torch::headeronly::ScalarType::Half", + DataType.bf16: "torch::headeronly::ScalarType::BFloat16", + DataType.f32: "torch::headeronly::ScalarType::Float", } VLLMKernelScheduleTag: dict[MixedInputKernelScheduleType | KernelScheduleType, str] = { diff --git a/csrc/fs_io.cpp b/csrc/fs_io.cpp new file mode 100644 index 000000000000..fdf3e614e644 --- /dev/null +++ b/csrc/fs_io.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +#include + +#include + +#include + +extern "C" { + +static void _batch_lookup(const std::vector& paths, + std::vector& exists_flags) { + for (size_t i = 0; i < paths.size(); i++) { + exists_flags[i] = (access(paths[i], F_OK) == 0) ? 1 : 0; + } +} + +/// @brief Check file existence for a batch of paths. +/// @param paths list[str] – absolute paths to check. +/// @return list[bool] – True if the corresponding path exists, False otherwise. +/// @note Releases the GIL for the entire batch. File existence via access(2). +static PyObject* batch_lookup(PyObject* /*self*/, PyObject* args) { + PyObject* path_list; + if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &path_list)) { + return nullptr; + } + + const Py_ssize_t n = PyList_Size(path_list); + std::vector paths(n); + for (Py_ssize_t i = 0; i < n; i++) { + paths[i] = PyUnicode_AsUTF8AndSize(PyList_GetItem(path_list, i), nullptr); + if (paths[i] == nullptr) { + return nullptr; + } + } + + std::vector exists_flags(n); + { + Py_BEGIN_ALLOW_THREADS _batch_lookup(paths, exists_flags); + Py_END_ALLOW_THREADS + } + + PyObject* result = PyList_New(n); + if (result == nullptr) { + return nullptr; + } + for (Py_ssize_t i = 0; i < n; i++) { + PyList_SetItem(result, i, PyBool_FromLong(exists_flags[i])); + } + return result; +} + +static PyMethodDef fs_io_C_methods[] = { + {"batch_lookup", batch_lookup, METH_VARARGS, + "batch_lookup(paths: list[str]) -> list[bool]\n" + "\n" + "Check file existence for a batch of paths."}, + {nullptr, nullptr, 0, nullptr}, +}; + +static struct PyModuleDef fs_io_C_module = { + PyModuleDef_HEAD_INIT, "fs_io_C", "Filesystem helpers for KV offload", -1, + fs_io_C_methods, +}; + +PyMODINIT_FUNC PyInit_fs_io_C(void) { return PyModule_Create(&fs_io_C_module); } + +} // extern "C" diff --git a/csrc/libtorch_stable/activation_kernels.cu b/csrc/libtorch_stable/activation_kernels.cu index cdab456348e2..e1dc01346055 100644 --- a/csrc/libtorch_stable/activation_kernels.cu +++ b/csrc/libtorch_stable/activation_kernels.cu @@ -10,11 +10,20 @@ namespace vllm { -template __device__ __forceinline__ scalar_t compute(const scalar_t& x, const scalar_t& y, - const float limit) { + const float limit, + const float alpha, + const float beta) { if constexpr (act_first) { scalar_t gate = x; scalar_t up = y; @@ -22,7 +31,9 @@ __device__ __forceinline__ scalar_t compute(const scalar_t& x, gate = (scalar_t)fminf((float)gate, limit); up = (scalar_t)fmaxf(fminf((float)up, limit), -limit); } - return ACT_FN(gate) * up; + // act_first: gate is the activated half -> alpha applies to gate; + // beta is added to up (the non-activated half). + return (scalar_t)(ACT_FN(gate, alpha) * ((float)up + beta)); } else { scalar_t gate = x; scalar_t up = y; @@ -30,55 +41,68 @@ __device__ __forceinline__ scalar_t compute(const scalar_t& x, gate = (scalar_t)fmaxf(fminf((float)gate, limit), -limit); up = (scalar_t)fminf((float)up, limit); } - return gate * ACT_FN(up); + // !act_first: up is the activated half -> alpha applies to up; + // beta is added to gate (the non-activated half). + return (scalar_t)(((float)gate + beta) * ACT_FN(up, alpha)); } } -template __device__ __forceinline__ packed_t packed_compute(const packed_t& x, const packed_t& y, - const float limit) { + const float limit, + const float alpha, + const float beta) { if constexpr (act_first) { packed_t gate = x; packed_t up = y; + float2 u = cast_to_float2(up); if constexpr (HAS_CLAMP) { float2 g = cast_to_float2(gate); - float2 u = cast_to_float2(up); g.x = fminf(g.x, limit); g.y = fminf(g.y, limit); u.x = fmaxf(fminf(u.x, limit), -limit); u.y = fmaxf(fminf(u.y, limit), -limit); gate = cast_to_packed(g); - up = cast_to_packed(u); } - return packed_mul(PACKED_ACT_FN(gate), up); + // act_first: gate is the activated half -> alpha applies to gate; + // beta is added to up (the non-activated half). + float2 activated = cast_to_float2(PACKED_ACT_FN(gate, alpha)); + activated.x *= u.x + beta; + activated.y *= u.y + beta; + return cast_to_packed(activated); } else { packed_t gate = x; packed_t up = y; + float2 g = cast_to_float2(gate); if constexpr (HAS_CLAMP) { - float2 g = cast_to_float2(gate); float2 u = cast_to_float2(up); g.x = fmaxf(fminf(g.x, limit), -limit); g.y = fmaxf(fminf(g.y, limit), -limit); u.x = fminf(u.x, limit); u.y = fminf(u.y, limit); - gate = cast_to_packed(g); up = cast_to_packed(u); } - return packed_mul(gate, PACKED_ACT_FN(up)); + // !act_first: up is the activated half -> alpha applies to up; + // beta is added to gate (the non-activated half). + float2 activated = cast_to_float2(PACKED_ACT_FN(up, alpha)); + activated.x *= g.x + beta; + activated.y *= g.y + beta; + return cast_to_packed(activated); } } // Activation and gating kernel template. template + scalar_t (*ACT_FN)(const scalar_t&, const float), + packed_t (*PACKED_ACT_FN)(const packed_t&, const float), + bool act_first, bool use_vec, bool HAS_CLAMP, bool use_256b = false> __global__ void act_and_mul_kernel( scalar_t* __restrict__ out, // [..., d] const scalar_t* __restrict__ input, // [..., 2, d] - const int d, const float limit) { + const int d, const float limit, const float alpha, const float beta) { const scalar_t* x_ptr = input + blockIdx.x * 2 * d; const scalar_t* y_ptr = x_ptr + d; scalar_t* out_ptr = out + blockIdx.x * d; @@ -105,7 +129,7 @@ __global__ void act_and_mul_kernel( for (int j = 0; j < pvec_t::NUM_ELTS; j++) { x.elts[j] = packed_compute( - x.elts[j], y.elts[j], limit); + x.elts[j], y.elts[j], limit, alpha, beta); } if constexpr (use_256b) { st256(x, &out_vec[i]); @@ -118,29 +142,34 @@ __global__ void act_and_mul_kernel( for (int64_t idx = threadIdx.x; idx < d; idx += blockDim.x) { const scalar_t x = VLLM_LDG(&x_ptr[idx]); const scalar_t y = VLLM_LDG(&y_ptr[idx]); - out_ptr[idx] = - compute(x, y, limit); + out_ptr[idx] = compute( + x, y, limit, alpha, beta); } } } +// Gated activations take an `alpha` argument that scales the sigmoid input +// (`x * sigmoid(alpha * x)`). alpha defaults to 1.0 at all call sites, which +// is exactly SiLU; only the clamp path (silu_and_mul_with_clamp) passes a +// non-default alpha. Activations that do not use alpha simply ignore it. template -__device__ __forceinline__ T silu_kernel(const T& x) { - // x * sigmoid(x) - return (T)(((float)x) / (1.0f + expf((float)-x))); +__device__ __forceinline__ T silu_kernel(const T& x, const float alpha) { + // x * sigmoid(alpha * x) + return (T)(((float)x) / (1.0f + expf((float)-x * alpha))); } template -__device__ __forceinline__ packed_t packed_silu_kernel(const packed_t& val) { - // x * sigmoid(x) +__device__ __forceinline__ packed_t packed_silu_kernel(const packed_t& val, + const float alpha) { + // x * sigmoid(alpha * x) float2 fval = cast_to_float2(val); - fval.x = fval.x / (1.0f + expf(-fval.x)); - fval.y = fval.y / (1.0f + expf(-fval.y)); + fval.x = fval.x / (1.0f + expf(-fval.x * alpha)); + fval.y = fval.y / (1.0f + expf(-fval.y * alpha)); return cast_to_packed(fval); } template -__device__ __forceinline__ T gelu_kernel(const T& x) { +__device__ __forceinline__ T gelu_kernel(const T& x, const float /*alpha*/) { // Equivalent to PyTorch GELU with 'none' approximation. // Refer to: // https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L36-L38 @@ -150,7 +179,8 @@ __device__ __forceinline__ T gelu_kernel(const T& x) { } template -__device__ __forceinline__ packed_t packed_gelu_kernel(const packed_t& val) { +__device__ __forceinline__ packed_t packed_gelu_kernel(const packed_t& val, + const float /*alpha*/) { // Equivalent to PyTorch GELU with 'none' approximation. // Refer to: // https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L36-L38 @@ -162,7 +192,8 @@ __device__ __forceinline__ packed_t packed_gelu_kernel(const packed_t& val) { } template -__device__ __forceinline__ T gelu_tanh_kernel(const T& x) { +__device__ __forceinline__ T gelu_tanh_kernel(const T& x, + const float /*alpha*/) { // Equivalent to PyTorch GELU with 'tanh' approximation. // Refer to: // https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L25-L30 @@ -176,7 +207,7 @@ __device__ __forceinline__ T gelu_tanh_kernel(const T& x) { template __device__ __forceinline__ packed_t -packed_gelu_tanh_kernel(const packed_t& val) { +packed_gelu_tanh_kernel(const packed_t& val, const float /*alpha*/) { // Equivalent to PyTorch GELU with 'tanh' approximation. // Refer to: // https://github.com/pytorch/pytorch/blob/8ac9b20d4b090c213799e81acf48a55ea8d437d6/aten/src/ATen/native/cuda/ActivationGeluKernel.cu#L25-L30 @@ -202,7 +233,7 @@ packed_gelu_tanh_kernel(const packed_t& val) { // clamped (max only) and up input is clamped (both sides) before the // activation function is applied. #define LAUNCH_ACTIVATION_GATE_KERNEL(KERNEL, PACKED_KERNEL, ACT_FIRST, \ - HAS_CLAMP, LIMIT) \ + HAS_CLAMP, LIMIT, ALPHA, BETA) \ auto dtype = input.scalar_type(); \ int d = input.size(-1) / 2; \ int64_t num_tokens = input.numel() / input.size(-1); \ @@ -230,7 +261,7 @@ packed_gelu_tanh_kernel(const packed_t& val) { PACKED_KERNEL::Type>, \ ACT_FIRST, true, HAS_CLAMP, true><<>>( \ out.mutable_data_ptr(), \ - input.const_data_ptr(), d, LIMIT); \ + input.const_data_ptr(), d, LIMIT, ALPHA, BETA); \ }); \ } else { \ VLLM_STABLE_DISPATCH_FLOATING_TYPES(dtype, "act_and_mul_kernel", [&] { \ @@ -240,7 +271,7 @@ packed_gelu_tanh_kernel(const packed_t& val) { PACKED_KERNEL::Type>, \ ACT_FIRST, true, HAS_CLAMP, false><<>>( \ out.mutable_data_ptr(), \ - input.const_data_ptr(), d, LIMIT); \ + input.const_data_ptr(), d, LIMIT, ALPHA, BETA); \ }); \ } \ } else { \ @@ -252,7 +283,7 @@ packed_gelu_tanh_kernel(const packed_t& val) { PACKED_KERNEL::Type>, \ ACT_FIRST, false, HAS_CLAMP><<>>( \ out.mutable_data_ptr(), input.const_data_ptr(), \ - d, LIMIT); \ + d, LIMIT, ALPHA, BETA); \ }); \ } @@ -260,14 +291,18 @@ void silu_and_mul(torch::stable::Tensor& out, // [..., d] torch::stable::Tensor& input) // [..., 2 * d] { LAUNCH_ACTIVATION_GATE_KERNEL(vllm::silu_kernel, vllm::packed_silu_kernel, - true, false, 0.0f); + true, false, 0.0f, 1.0f, 0.0f); } void silu_and_mul_clamp(torch::stable::Tensor& out, // [..., d] torch::stable::Tensor& input, // [..., 2 * d] - double limit) { + double limit, double alpha, double beta) { + // out = (gate.clamp(max=limit) * sigmoid(alpha * gate.clamp(max=limit))) + // * (up.clamp(+-limit) + beta) + // alpha=1.0, beta=0.0 reduce this to silu(gate) * up. LAUNCH_ACTIVATION_GATE_KERNEL(vllm::silu_kernel, vllm::packed_silu_kernel, - true, true, (float)limit); + true, true, (float)limit, (float)alpha, + (float)beta); } void mul_and_silu(torch::stable::Tensor& out, // [..., d] @@ -276,21 +311,22 @@ void mul_and_silu(torch::stable::Tensor& out, // [..., d] // The difference between mul_and_silu and silu_and_mul is that mul_and_silu // applies the silu to the latter half of the input. LAUNCH_ACTIVATION_GATE_KERNEL(vllm::silu_kernel, vllm::packed_silu_kernel, - false, false, 0.0f); + false, false, 0.0f, 1.0f, 0.0f); } void gelu_and_mul(torch::stable::Tensor& out, // [..., d] torch::stable::Tensor& input) // [..., 2 * d] { LAUNCH_ACTIVATION_GATE_KERNEL(vllm::gelu_kernel, vllm::packed_gelu_kernel, - true, false, 0.0f); + true, false, 0.0f, 1.0f, 0.0f); } void gelu_tanh_and_mul(torch::stable::Tensor& out, // [..., d] torch::stable::Tensor& input) // [..., 2 * d] { - LAUNCH_ACTIVATION_GATE_KERNEL( - vllm::gelu_tanh_kernel, vllm::packed_gelu_tanh_kernel, true, false, 0.0f); + LAUNCH_ACTIVATION_GATE_KERNEL(vllm::gelu_tanh_kernel, + vllm::packed_gelu_tanh_kernel, true, false, + 0.0f, 1.0f, 0.0f); } namespace vllm { diff --git a/csrc/libtorch_stable/attention/attention_kernels.cuh b/csrc/libtorch_stable/attention/attention_kernels.cuh deleted file mode 100644 index c5f9a9876c3f..000000000000 --- a/csrc/libtorch_stable/attention/attention_kernels.cuh +++ /dev/null @@ -1,667 +0,0 @@ -/* - * Adapted from - * https://github.com/NVIDIA/FasterTransformer/blob/release/v5.3_tag/src/fastertransformer/kernels/decoder_masked_multihead_attention/decoder_masked_multihead_attention_template.hpp - * Copyright (c) 2023, The vLLM team. - * Copyright (c) 2020-2023, NVIDIA CORPORATION. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include - -#include "../../attention/attention_dtypes.h" -#include "attention_utils.cuh" -#include "../../cuda_compat.h" - -#ifdef USE_ROCM - #include - #include "../../quantization/w8a8/fp8/amd/quant_utils.cuh" -typedef __hip_bfloat16 __nv_bfloat16; -#else - #include "../../quantization/w8a8/fp8/nvidia/quant_utils.cuh" -#endif - -#define MAX(a, b) ((a) > (b) ? (a) : (b)) -#define MIN(a, b) ((a) < (b) ? (a) : (b)) -#define DIVIDE_ROUND_UP(a, b) (((a) + (b) - 1) / (b)) - -namespace vllm { - -// Utility function for attention softmax. -template -inline __device__ float block_sum(float* red_smem, float sum) { - // Decompose the thread index into warp / lane. - int warp = threadIdx.x / WARP_SIZE; - int lane = threadIdx.x % WARP_SIZE; - - // Compute the sum per warp. -#pragma unroll - for (int mask = WARP_SIZE / 2; mask >= 1; mask /= 2) { - sum += VLLM_SHFL_XOR_SYNC(sum, mask); - } - - // Warp leaders store the data to shared memory. - if (lane == 0) { - red_smem[warp] = sum; - } - - // Make sure the data is in shared memory. - __syncthreads(); - - // The warps compute the final sums. - if (lane < NUM_WARPS) { - sum = red_smem[lane]; - } - - // Parallel reduction inside the warp. -#pragma unroll - for (int mask = NUM_WARPS / 2; mask >= 1; mask /= 2) { - sum += VLLM_SHFL_XOR_SYNC(sum, mask); - } - - // Broadcast to other threads. - return VLLM_SHFL_SYNC(sum, 0); -} - -// TODO(woosuk): Merge the last two dimensions of the grid. -// Grid: (num_heads, num_seqs, max_num_partitions). -template // Zero means no partitioning. -__device__ void paged_attention_kernel( - float* __restrict__ exp_sums, // [num_seqs, num_heads, max_num_partitions] - float* __restrict__ max_logits, // [num_seqs, num_heads, - // max_num_partitions] - scalar_t* __restrict__ out, // [num_seqs, num_heads, max_num_partitions, - // head_size] - const scalar_t* __restrict__ q, // [num_seqs, num_heads, head_size] - const cache_t* __restrict__ k_cache, // [num_blocks, num_kv_heads, - // head_size/x, block_size, x] - const cache_t* __restrict__ v_cache, // [num_blocks, num_kv_heads, - // head_size, block_size] - const int num_kv_heads, // [num_heads] - const float scale, - const int* __restrict__ block_tables, // [num_seqs, max_num_blocks_per_seq] - const int* __restrict__ seq_lens, // [num_seqs] - const int max_num_blocks_per_seq, - const float* __restrict__ alibi_slopes, // [num_heads] - const int q_stride, const int kv_block_stride, const int kv_head_stride, - const float* k_scale, const float* v_scale, const int tp_rank, - const int blocksparse_local_blocks, const int blocksparse_vert_stride, - const int blocksparse_block_size, const int blocksparse_head_sliding_step) { - const int seq_idx = blockIdx.y; - const int partition_idx = blockIdx.z; - const int max_num_partitions = gridDim.z; - constexpr bool USE_PARTITIONING = PARTITION_SIZE > 0; - const int seq_len = seq_lens[seq_idx]; - if (USE_PARTITIONING && partition_idx * PARTITION_SIZE >= seq_len) { - // No work to do. Terminate the thread block. - return; - } - - const int num_seq_blocks = DIVIDE_ROUND_UP(seq_len, BLOCK_SIZE); - const int num_blocks_per_partition = - USE_PARTITIONING ? PARTITION_SIZE / BLOCK_SIZE : num_seq_blocks; - - // [start_block_idx, end_block_idx) is the range of blocks to process. - const int start_block_idx = - USE_PARTITIONING ? partition_idx * num_blocks_per_partition : 0; - const int end_block_idx = - MIN(start_block_idx + num_blocks_per_partition, num_seq_blocks); - const int num_blocks = end_block_idx - start_block_idx; - - // [start_token_idx, end_token_idx) is the range of tokens to process. - const int start_token_idx = start_block_idx * BLOCK_SIZE; - const int end_token_idx = - MIN(start_token_idx + num_blocks * BLOCK_SIZE, seq_len); - const int num_tokens = end_token_idx - start_token_idx; - - constexpr int THREAD_GROUP_SIZE = MAX(WARP_SIZE / BLOCK_SIZE, 1); - constexpr int NUM_THREAD_GROUPS = - NUM_THREADS / THREAD_GROUP_SIZE; // Note: This assumes THREAD_GROUP_SIZE - // divides NUM_THREADS - assert(NUM_THREADS % THREAD_GROUP_SIZE == 0); - constexpr int NUM_TOKENS_PER_THREAD_GROUP = - DIVIDE_ROUND_UP(BLOCK_SIZE, WARP_SIZE); - constexpr int NUM_WARPS = NUM_THREADS / WARP_SIZE; - const int thread_idx = threadIdx.x; - const int warp_idx = thread_idx / WARP_SIZE; - const int lane = thread_idx % WARP_SIZE; - - const int head_idx = blockIdx.x; - const int num_heads = gridDim.x; - const int num_queries_per_kv = num_heads / num_kv_heads; - const int kv_head_idx = head_idx / num_queries_per_kv; - const float alibi_slope = - alibi_slopes == nullptr ? 0.f : alibi_slopes[head_idx]; - - // A vector type to store a part of a key or a query. - // The vector size is configured in such a way that the threads in a thread - // group fetch or compute 16 bytes at a time. For example, if the size of a - // thread group is 4 and the data type is half, then the vector size is 16 / - // (4 * sizeof(half)) == 2. - constexpr int VEC_SIZE = MAX(16 / (THREAD_GROUP_SIZE * sizeof(scalar_t)), 1); - using K_vec = typename Vec::Type; - using Q_vec = typename Vec::Type; - using Quant_vec = typename Vec::Type; - - constexpr int NUM_ELEMS_PER_THREAD = HEAD_SIZE / THREAD_GROUP_SIZE; - constexpr int NUM_VECS_PER_THREAD = NUM_ELEMS_PER_THREAD / VEC_SIZE; - - const int thread_group_idx = thread_idx / THREAD_GROUP_SIZE; - const int thread_group_offset = thread_idx % THREAD_GROUP_SIZE; - - // Load the query to registers. - // Each thread in a thread group has a different part of the query. - // For example, if the thread group size is 4, then the first thread in - // the group has 0, 4, 8, ... th vectors of the query, and the second thread - // has 1, 5, 9, ... th vectors of the query, and so on. NOTE(woosuk): Because - // q is split from a qkv tensor, it may not be contiguous. - const scalar_t* q_ptr = q + seq_idx * q_stride + head_idx * HEAD_SIZE; - __shared__ Q_vec q_vecs[THREAD_GROUP_SIZE][NUM_VECS_PER_THREAD]; -#pragma unroll - for (int i = thread_group_idx; i < NUM_VECS_PER_THREAD; - i += NUM_THREAD_GROUPS) { - const int vec_idx = thread_group_offset + i * THREAD_GROUP_SIZE; - q_vecs[thread_group_offset][i] = - *reinterpret_cast(q_ptr + vec_idx * VEC_SIZE); - } - __syncthreads(); // TODO(naed90): possible speedup if this is replaced with a - // memory wall right before we use q_vecs - - // Memory planning. - extern __shared__ char shared_mem[]; - // NOTE(woosuk): We use FP32 for the softmax logits for better accuracy. - float* logits = reinterpret_cast(shared_mem); - // Workspace for reduction. - __shared__ float red_smem[2 * NUM_WARPS]; - - // x == THREAD_GROUP_SIZE * VEC_SIZE - // Each thread group fetches x elements from the key at a time. - constexpr int x = 16 / sizeof(cache_t); - float qk_max = -FLT_MAX; - - // Iterate over the key blocks. - // Each warp fetches a block of keys for each iteration. - // Each thread group in a warp fetches a key from the block, and computes - // dot product with the query. - const int* block_table = block_tables + seq_idx * max_num_blocks_per_seq; - - // blocksparse specific vars - int bs_block_offset; - int q_bs_block_id; - if constexpr (IS_BLOCK_SPARSE) { - // const int num_blocksparse_blocks = DIVIDE_ROUND_UP(seq_len, - // blocksparse_block_size); - q_bs_block_id = (seq_len - 1) / blocksparse_block_size; - if (blocksparse_head_sliding_step >= 0) - // sliding on q heads - bs_block_offset = - (tp_rank * num_heads + head_idx) * blocksparse_head_sliding_step + 1; - else - // sliding on kv heads - bs_block_offset = (tp_rank * num_kv_heads + kv_head_idx) * - (-blocksparse_head_sliding_step) + - 1; - } - - for (int block_idx = start_block_idx + warp_idx; block_idx < end_block_idx; - block_idx += NUM_WARPS) { - // NOTE(woosuk): The block number is stored in int32. However, we cast it to - // int64 because int32 can lead to overflow when this variable is multiplied - // by large numbers (e.g., kv_block_stride). - // For blocksparse attention: skip computation on blocks that are not - // attended - if constexpr (IS_BLOCK_SPARSE) { - const int k_bs_block_id = block_idx * BLOCK_SIZE / blocksparse_block_size; - const bool is_remote = - ((k_bs_block_id + bs_block_offset) % blocksparse_vert_stride == 0); - const bool is_local = - (k_bs_block_id > q_bs_block_id - blocksparse_local_blocks); - if (!is_remote && !is_local) { - for (int i = 0; i < NUM_TOKENS_PER_THREAD_GROUP; i++) { - const int physical_block_offset = - (thread_group_idx + i * WARP_SIZE) % BLOCK_SIZE; - const int token_idx = block_idx * BLOCK_SIZE + physical_block_offset; - - if (thread_group_offset == 0) { - // NOTE(linxihui): assign very large number to skipped tokens to - // avoid contribution to the sumexp softmax normalizer. This will - // not be used at computing sum(softmax*v) as the blocks will be - // skipped. - logits[token_idx - start_token_idx] = -FLT_MAX; - } - } - continue; - } - } - const int64_t physical_block_number = - static_cast(block_table[block_idx]); - - // Load a key to registers. - // Each thread in a thread group has a different part of the key. - // For example, if the thread group size is 4, then the first thread in - // the group has 0, 4, 8, ... th vectors of the key, and the second thread - // has 1, 5, 9, ... th vectors of the key, and so on. - for (int i = 0; i < NUM_TOKENS_PER_THREAD_GROUP; i++) { - const int physical_block_offset = - (thread_group_idx + i * WARP_SIZE) % BLOCK_SIZE; - const int token_idx = block_idx * BLOCK_SIZE + physical_block_offset; - K_vec k_vecs[NUM_VECS_PER_THREAD]; - -#pragma unroll - for (int j = 0; j < NUM_VECS_PER_THREAD; j++) { - const cache_t* k_ptr = - k_cache + physical_block_number * kv_block_stride + - kv_head_idx * kv_head_stride + physical_block_offset * x; - const int vec_idx = thread_group_offset + j * THREAD_GROUP_SIZE; - const int offset1 = (vec_idx * VEC_SIZE) / x; - const int offset2 = (vec_idx * VEC_SIZE) % x; - - if constexpr (KV_DTYPE == Fp8KVCacheDataType::kAuto) { - k_vecs[j] = *reinterpret_cast( - k_ptr + offset1 * BLOCK_SIZE * x + offset2); - } else { - // Vector conversion from Quant_vec to K_vec. - Quant_vec k_vec_quant = *reinterpret_cast( - k_ptr + offset1 * BLOCK_SIZE * x + offset2); - k_vecs[j] = fp8::scaled_convert( - k_vec_quant, *k_scale); - } - } - - // Compute dot product. - // This includes a reduction across the threads in the same thread group. - float qk = scale * Qk_dot::dot( - q_vecs[thread_group_offset], k_vecs); - // Add the ALiBi bias if slopes are given. - qk += (alibi_slope != 0) ? alibi_slope * (token_idx - seq_len + 1) : 0; - - if (thread_group_offset == 0) { - // Store the partial reductions to shared memory. - // NOTE(woosuk): It is required to zero out the masked logits. - const bool mask = token_idx >= seq_len; - logits[token_idx - start_token_idx] = mask ? 0.f : qk; - // Update the max value. - qk_max = mask ? qk_max : fmaxf(qk_max, qk); - } - } - } - - // Perform reduction across the threads in the same warp to get the - // max qk value for each "warp" (not across the thread block yet). - // The 0-th thread of each thread group already has its max qk value. -#pragma unroll - for (int mask = WARP_SIZE / 2; mask >= THREAD_GROUP_SIZE; mask /= 2) { - qk_max = fmaxf(qk_max, VLLM_SHFL_XOR_SYNC(qk_max, mask)); - } - if (lane == 0) { - red_smem[warp_idx] = qk_max; - } - __syncthreads(); - - // TODO(woosuk): Refactor this part. - // Get the max qk value for the sequence. - qk_max = lane < NUM_WARPS ? red_smem[lane] : -FLT_MAX; -#pragma unroll - for (int mask = NUM_WARPS / 2; mask >= 1; mask /= 2) { - qk_max = fmaxf(qk_max, VLLM_SHFL_XOR_SYNC(qk_max, mask)); - } - // Broadcast the max qk value to all threads. - qk_max = VLLM_SHFL_SYNC(qk_max, 0); - - // Get the sum of the exp values. - float exp_sum = 0.f; - for (int i = thread_idx; i < num_tokens; i += NUM_THREADS) { - float val = __expf(logits[i] - qk_max); - logits[i] = val; - exp_sum += val; - } - exp_sum = block_sum(&red_smem[NUM_WARPS], exp_sum); - - // Compute softmax. - const float inv_sum = __fdividef(1.f, exp_sum + 1e-6f); - for (int i = thread_idx; i < num_tokens; i += NUM_THREADS) { - logits[i] *= inv_sum; - } - __syncthreads(); - - // If partitioning is enabled, store the max logit and exp_sum. - if (USE_PARTITIONING && thread_idx == 0) { - float* max_logits_ptr = max_logits + - seq_idx * num_heads * max_num_partitions + - head_idx * max_num_partitions + partition_idx; - *max_logits_ptr = qk_max; - float* exp_sums_ptr = exp_sums + seq_idx * num_heads * max_num_partitions + - head_idx * max_num_partitions + partition_idx; - *exp_sums_ptr = exp_sum; - } - - // Each thread will fetch 16 bytes from the value cache at a time. - constexpr int V_VEC_SIZE = MIN(16 / sizeof(scalar_t), BLOCK_SIZE); - using V_vec = typename Vec::Type; - using L_vec = typename Vec::Type; - using V_quant_vec = typename Vec::Type; - using Float_L_vec = typename FloatVec::Type; - - constexpr int NUM_V_VECS_PER_ROW = BLOCK_SIZE / V_VEC_SIZE; - constexpr int NUM_ROWS_PER_ITER = WARP_SIZE / NUM_V_VECS_PER_ROW; - constexpr int NUM_ROWS_PER_THREAD = - DIVIDE_ROUND_UP(HEAD_SIZE, NUM_ROWS_PER_ITER); - - // NOTE(woosuk): We use FP32 for the accumulator for better accuracy. - float accs[NUM_ROWS_PER_THREAD]; -#pragma unroll - for (int i = 0; i < NUM_ROWS_PER_THREAD; i++) { - accs[i] = 0.f; - } - - scalar_t zero_value; - zero(zero_value); - for (int block_idx = start_block_idx + warp_idx; block_idx < end_block_idx; - block_idx += NUM_WARPS) { - // NOTE(woosuk): The block number is stored in int32. However, we cast it to - // int64 because int32 can lead to overflow when this variable is multiplied - // by large numbers (e.g., kv_block_stride). - // For blocksparse attention: skip computation on blocks that are not - // attended - if constexpr (IS_BLOCK_SPARSE) { - int v_bs_block_id = block_idx * BLOCK_SIZE / blocksparse_block_size; - if (!((v_bs_block_id + bs_block_offset) % blocksparse_vert_stride == 0) && - !((v_bs_block_id > q_bs_block_id - blocksparse_local_blocks))) { - continue; - } - } - const int64_t physical_block_number = - static_cast(block_table[block_idx]); - const int physical_block_offset = (lane % NUM_V_VECS_PER_ROW) * V_VEC_SIZE; - const int token_idx = block_idx * BLOCK_SIZE + physical_block_offset; - L_vec logits_vec; - from_float(logits_vec, *reinterpret_cast(logits + token_idx - - start_token_idx)); - - const cache_t* v_ptr = v_cache + physical_block_number * kv_block_stride + - kv_head_idx * kv_head_stride; -#pragma unroll - for (int i = 0; i < NUM_ROWS_PER_THREAD; i++) { - const int row_idx = lane / NUM_V_VECS_PER_ROW + i * NUM_ROWS_PER_ITER; - if (row_idx < HEAD_SIZE) { - const int offset = row_idx * BLOCK_SIZE + physical_block_offset; - V_vec v_vec; - - if constexpr (KV_DTYPE == Fp8KVCacheDataType::kAuto) { - v_vec = *reinterpret_cast(v_ptr + offset); - } else { - V_quant_vec v_quant_vec = - *reinterpret_cast(v_ptr + offset); - // Vector conversion from V_quant_vec to V_vec. - v_vec = fp8::scaled_convert(v_quant_vec, - *v_scale); - } - if (block_idx == num_seq_blocks - 1) { - // NOTE(woosuk): When v_vec contains the tokens that are out of the - // context, we should explicitly zero out the values since they may - // contain NaNs. See - // https://github.com/vllm-project/vllm/issues/641#issuecomment-1682544472 - scalar_t* v_vec_ptr = reinterpret_cast(&v_vec); -#pragma unroll - for (int j = 0; j < V_VEC_SIZE; j++) { - v_vec_ptr[j] = token_idx + j < seq_len ? v_vec_ptr[j] : zero_value; - } - } - accs[i] += dot(logits_vec, v_vec); - } - } - } - - // Perform reduction within each warp. -#pragma unroll - for (int i = 0; i < NUM_ROWS_PER_THREAD; i++) { - float acc = accs[i]; -#pragma unroll - for (int mask = NUM_V_VECS_PER_ROW / 2; mask >= 1; mask /= 2) { - acc += VLLM_SHFL_XOR_SYNC(acc, mask); - } - accs[i] = acc; - } - - // NOTE(woosuk): A barrier is required because the shared memory space for - // logits is reused for the output. - __syncthreads(); - - // Perform reduction across warps. - float* out_smem = reinterpret_cast(shared_mem); -#pragma unroll - for (int i = NUM_WARPS; i > 1; i /= 2) { - int mid = i / 2; - // Upper warps write to shared memory. - if (warp_idx >= mid && warp_idx < i) { - float* dst = &out_smem[(warp_idx - mid) * HEAD_SIZE]; -#pragma unroll - for (int i = 0; i < NUM_ROWS_PER_THREAD; i++) { - const int row_idx = lane / NUM_V_VECS_PER_ROW + i * NUM_ROWS_PER_ITER; - if (row_idx < HEAD_SIZE && lane % NUM_V_VECS_PER_ROW == 0) { - dst[row_idx] = accs[i]; - } - } - } - __syncthreads(); - - // Lower warps update the output. - if (warp_idx < mid) { - const float* src = &out_smem[warp_idx * HEAD_SIZE]; -#pragma unroll - for (int i = 0; i < NUM_ROWS_PER_THREAD; i++) { - const int row_idx = lane / NUM_V_VECS_PER_ROW + i * NUM_ROWS_PER_ITER; - if (row_idx < HEAD_SIZE && lane % NUM_V_VECS_PER_ROW == 0) { - accs[i] += src[row_idx]; - } - } - } - __syncthreads(); - } - - // Write the final output. - if (warp_idx == 0) { - scalar_t* out_ptr = - out + seq_idx * num_heads * max_num_partitions * HEAD_SIZE + - head_idx * max_num_partitions * HEAD_SIZE + partition_idx * HEAD_SIZE; -#pragma unroll - for (int i = 0; i < NUM_ROWS_PER_THREAD; i++) { - const int row_idx = lane / NUM_V_VECS_PER_ROW + i * NUM_ROWS_PER_ITER; - if (row_idx < HEAD_SIZE && lane % NUM_V_VECS_PER_ROW == 0) { - from_float(*(out_ptr + row_idx), accs[i]); - } - } - } -} - -// Grid: (num_heads, num_seqs, 1). -template -__global__ void paged_attention_v1_kernel( - scalar_t* __restrict__ out, // [num_seqs, num_heads, head_size] - const scalar_t* __restrict__ q, // [num_seqs, num_heads, head_size] - const cache_t* __restrict__ k_cache, // [num_blocks, num_kv_heads, - // head_size/x, block_size, x] - const cache_t* __restrict__ v_cache, // [num_blocks, num_kv_heads, - // head_size, block_size] - const int num_kv_heads, // [num_heads] - const float scale, - const int* __restrict__ block_tables, // [num_seqs, max_num_blocks_per_seq] - const int* __restrict__ seq_lens, // [num_seqs] - const int max_num_blocks_per_seq, - const float* __restrict__ alibi_slopes, // [num_heads] - const int q_stride, const int kv_block_stride, const int kv_head_stride, - const float* k_scale, const float* v_scale, const int tp_rank, - const int blocksparse_local_blocks, const int blocksparse_vert_stride, - const int blocksparse_block_size, const int blocksparse_head_sliding_step) { - paged_attention_kernel( - /* exp_sums */ nullptr, /* max_logits */ nullptr, out, q, k_cache, - v_cache, num_kv_heads, scale, block_tables, seq_lens, - max_num_blocks_per_seq, alibi_slopes, q_stride, kv_block_stride, - kv_head_stride, k_scale, v_scale, tp_rank, blocksparse_local_blocks, - blocksparse_vert_stride, blocksparse_block_size, - blocksparse_head_sliding_step); -} - -// Grid: (num_heads, num_seqs, max_num_partitions). -template -__global__ void paged_attention_v2_kernel( - float* __restrict__ exp_sums, // [num_seqs, num_heads, max_num_partitions] - float* __restrict__ max_logits, // [num_seqs, num_heads, - // max_num_partitions] - scalar_t* __restrict__ tmp_out, // [num_seqs, num_heads, - // max_num_partitions, head_size] - const scalar_t* __restrict__ q, // [num_seqs, num_heads, head_size] - const cache_t* __restrict__ k_cache, // [num_blocks, num_kv_heads, - // head_size/x, block_size, x] - const cache_t* __restrict__ v_cache, // [num_blocks, num_kv_heads, - // head_size, block_size] - const int num_kv_heads, // [num_heads] - const float scale, - const int* __restrict__ block_tables, // [num_seqs, max_num_blocks_per_seq] - const int* __restrict__ seq_lens, // [num_seqs] - const int max_num_blocks_per_seq, - const float* __restrict__ alibi_slopes, // [num_heads] - const int q_stride, const int kv_block_stride, const int kv_head_stride, - const float* k_scale, const float* v_scale, const int tp_rank, - const int blocksparse_local_blocks, const int blocksparse_vert_stride, - const int blocksparse_block_size, const int blocksparse_head_sliding_step) { - paged_attention_kernel( - exp_sums, max_logits, tmp_out, q, k_cache, v_cache, num_kv_heads, scale, - block_tables, seq_lens, max_num_blocks_per_seq, alibi_slopes, q_stride, - kv_block_stride, kv_head_stride, k_scale, v_scale, tp_rank, - blocksparse_local_blocks, blocksparse_vert_stride, blocksparse_block_size, - blocksparse_head_sliding_step); -} - -// Grid: (num_heads, num_seqs). -template -__global__ void paged_attention_v2_reduce_kernel( - scalar_t* __restrict__ out, // [num_seqs, num_heads, head_size] - const float* __restrict__ exp_sums, // [num_seqs, num_heads, - // max_num_partitions] - const float* __restrict__ max_logits, // [num_seqs, num_heads, - // max_num_partitions] - const scalar_t* __restrict__ tmp_out, // [num_seqs, num_heads, - // max_num_partitions, head_size] - const int* __restrict__ seq_lens, // [num_seqs] - const int max_num_partitions) { - const int num_heads = gridDim.x; - const int head_idx = blockIdx.x; - const int seq_idx = blockIdx.y; - const int seq_len = seq_lens[seq_idx]; - const int num_partitions = DIVIDE_ROUND_UP(seq_len, PARTITION_SIZE); - if (num_partitions == 1) { - // No need to reduce. Only copy tmp_out to out. - scalar_t* out_ptr = - out + seq_idx * num_heads * HEAD_SIZE + head_idx * HEAD_SIZE; - const scalar_t* tmp_out_ptr = - tmp_out + seq_idx * num_heads * max_num_partitions * HEAD_SIZE + - head_idx * max_num_partitions * HEAD_SIZE; - for (int i = threadIdx.x; i < HEAD_SIZE; i += blockDim.x) { - out_ptr[i] = tmp_out_ptr[i]; - } - // Terminate the thread block. - return; - } - - constexpr int NUM_WARPS = NUM_THREADS / WARP_SIZE; - const int warp_idx = threadIdx.x / WARP_SIZE; - const int lane = threadIdx.x % WARP_SIZE; - - // Size: 2 * num_partitions. - extern __shared__ char shared_mem[]; - // Workspace for reduction. - __shared__ float red_smem[2 * NUM_WARPS]; - - // Load max logits to shared memory. - float* shared_max_logits = reinterpret_cast(shared_mem); - const float* max_logits_ptr = max_logits + - seq_idx * num_heads * max_num_partitions + - head_idx * max_num_partitions; - float max_logit = -FLT_MAX; - for (int i = threadIdx.x; i < num_partitions; i += blockDim.x) { - const float l = max_logits_ptr[i]; - shared_max_logits[i] = l; - max_logit = fmaxf(max_logit, l); - } - __syncthreads(); - - // Get the global max logit. - // Reduce within the warp. -#pragma unroll - for (int mask = WARP_SIZE / 2; mask >= 1; mask /= 2) { - max_logit = fmaxf(max_logit, VLLM_SHFL_XOR_SYNC(max_logit, mask)); - } - if (lane == 0) { - red_smem[warp_idx] = max_logit; - } - __syncthreads(); - // Reduce across warps. - max_logit = lane < NUM_WARPS ? red_smem[lane] : -FLT_MAX; -#pragma unroll - for (int mask = NUM_WARPS / 2; mask >= 1; mask /= 2) { - max_logit = fmaxf(max_logit, VLLM_SHFL_XOR_SYNC(max_logit, mask)); - } - // Broadcast the max value to all threads. - max_logit = VLLM_SHFL_SYNC(max_logit, 0); - - // Load rescaled exp sums to shared memory. - float* shared_exp_sums = - reinterpret_cast(shared_mem + sizeof(float) * num_partitions); - const float* exp_sums_ptr = exp_sums + - seq_idx * num_heads * max_num_partitions + - head_idx * max_num_partitions; - float global_exp_sum = 0.0f; - for (int i = threadIdx.x; i < num_partitions; i += blockDim.x) { - float l = shared_max_logits[i]; - float rescaled_exp_sum = exp_sums_ptr[i] * expf(l - max_logit); - global_exp_sum += rescaled_exp_sum; - shared_exp_sums[i] = rescaled_exp_sum; - } - __syncthreads(); - global_exp_sum = block_sum(&red_smem[NUM_WARPS], global_exp_sum); - const float inv_global_exp_sum = __fdividef(1.0f, global_exp_sum + 1e-6f); - - // Aggregate tmp_out to out. - const scalar_t* tmp_out_ptr = - tmp_out + seq_idx * num_heads * max_num_partitions * HEAD_SIZE + - head_idx * max_num_partitions * HEAD_SIZE; - scalar_t* out_ptr = - out + seq_idx * num_heads * HEAD_SIZE + head_idx * HEAD_SIZE; -#pragma unroll - for (int i = threadIdx.x; i < HEAD_SIZE; i += NUM_THREADS) { - float acc = 0.0f; - for (int j = 0; j < num_partitions; ++j) { - acc += to_float(tmp_out_ptr[j * HEAD_SIZE + i]) * shared_exp_sums[j] * - inv_global_exp_sum; - } - from_float(out_ptr[i], acc); - } -} - -} // namespace vllm - -#undef MAX -#undef MIN -#undef DIVIDE_ROUND_UP diff --git a/csrc/libtorch_stable/attention/mla/sm100_cutlass_mla_kernel.cu b/csrc/libtorch_stable/attention/mla/sm100_cutlass_mla_kernel.cu index 55d75383476e..150e32462812 100644 --- a/csrc/libtorch_stable/attention/mla/sm100_cutlass_mla_kernel.cu +++ b/csrc/libtorch_stable/attention/mla/sm100_cutlass_mla_kernel.cu @@ -136,8 +136,12 @@ typename T::Fmha::Arguments args_from_options( StrideQ stride_Q_pe = cute::make_tuple( static_cast(q_pe.stride(1)), _1{}, static_cast(q_pe.stride(0))); + // Read the token and page strides from the cache tensor instead of assuming + // packed pages, so strided views (e.g. per-layer views into a cross-layer + // block-major cache) are addressed correctly. StrideK stride_C = cute::make_tuple( - static_cast(0 + D_latent + D_rope), _1{}, static_cast(page_size * (D_latent + D_rope))); + static_cast(kv_c_and_k_pe_cache.stride(1)), _1{}, + static_cast(kv_c_and_k_pe_cache.stride(0))); StrideLSE stride_PT = cute::make_stride(_1{}, page_count_per_seq); StrideLSE stride_LSE = cute::make_tuple(_1{}, 0 + H); StrideO stride_O = cute::make_tuple(static_cast(0 + D_latent), _1{}, static_cast(0 + H * D_latent)); @@ -268,9 +272,14 @@ int64_t sm100_cutlass_mla_get_workspace_size(int64_t max_seq_len, int64_t num_ba using TileShapeD = typename MlaSm100Type::TileShapeD; arguments.problem_shape = cute::make_tuple(TileShapeH{}, static_cast(max_seq_len), TileShapeD{}, static_cast(num_batches)); - // Assumes device 0 when getting sm_count. - arguments.hw_info.sm_count = - sm_count <= 0 ? cutlass::KernelHardwareInfo::query_device_multiprocessor_count(/*device_id=*/0) : sm_count; + if (sm_count <= 0) { + int current_device = 0; + cudaGetDevice(¤t_device); + arguments.hw_info.sm_count = + cutlass::KernelHardwareInfo::query_device_multiprocessor_count(current_device); + } else { + arguments.hw_info.sm_count = sm_count; + } arguments.split_kv = static_cast(num_kv_splits); MlaSm100Type::Fmha::set_split_kv(arguments); diff --git a/csrc/libtorch_stable/attention/paged_attention_v1.cu b/csrc/libtorch_stable/attention/paged_attention_v1.cu deleted file mode 100644 index 8fa417915936..000000000000 --- a/csrc/libtorch_stable/attention/paged_attention_v1.cu +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Adapted from - * https://github.com/NVIDIA/FasterTransformer/blob/release/v5.3_tag/src/fastertransformer/kernels/decoder_masked_multihead_attention/decoder_masked_multihead_attention_template.hpp - * Copyright (c) 2023, The vLLM team. - * Copyright (c) 2020-2023, NVIDIA CORPORATION. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -#include "../torch_utils.h" -#include "attention_kernels.cuh" -#include "../../cuda_compat.h" - -#define MAX(a, b) ((a) > (b) ? (a) : (b)) -#define MIN(a, b) ((a) < (b) ? (a) : (b)) -#define DIVIDE_ROUND_UP(a, b) (((a) + (b) - 1) / (b)) - -#define LAUNCH_PAGED_ATTENTION_V1(HEAD_SIZE) \ - VLLM_DevFuncAttribute_SET_MaxDynamicSharedMemorySize( \ - ((void*)vllm::paged_attention_v1_kernel), \ - shared_mem_size); \ - vllm::paged_attention_v1_kernel \ - <<>>( \ - out_ptr, query_ptr, key_cache_ptr, value_cache_ptr, num_kv_heads, \ - scale, block_tables_ptr, seq_lens_ptr, max_num_blocks_per_seq, \ - alibi_slopes_ptr, q_stride, kv_block_stride, kv_head_stride, \ - k_scale_ptr, v_scale_ptr, tp_rank, blocksparse_local_blocks, \ - blocksparse_vert_stride, blocksparse_block_size, \ - blocksparse_head_sliding_step); - -// TODO(woosuk): Tune NUM_THREADS. -template -void paged_attention_v1_launcher( - torch::stable::Tensor& out, torch::stable::Tensor& query, - torch::stable::Tensor& key_cache, torch::stable::Tensor& value_cache, - int num_kv_heads, float scale, torch::stable::Tensor& block_tables, - torch::stable::Tensor& seq_lens, int max_seq_len, - const std::optional& alibi_slopes, - torch::stable::Tensor& k_scale, torch::stable::Tensor& v_scale, - const int tp_rank, const int blocksparse_local_blocks, - const int blocksparse_vert_stride, const int blocksparse_block_size, - const int blocksparse_head_sliding_step) { - int num_seqs = query.size(0); - int num_heads = query.size(1); - int head_size = query.size(2); - int max_num_blocks_per_seq = block_tables.size(1); - int q_stride = query.stride(0); - int kv_block_stride = key_cache.stride(0); - int kv_head_stride = key_cache.stride(1); - - // NOTE: alibi_slopes is optional. - const float* alibi_slopes_ptr = - alibi_slopes - ? reinterpret_cast(alibi_slopes.value().data_ptr()) - : nullptr; - - T* out_ptr = reinterpret_cast(out.data_ptr()); - T* query_ptr = reinterpret_cast(query.data_ptr()); - CACHE_T* key_cache_ptr = reinterpret_cast(key_cache.data_ptr()); - CACHE_T* value_cache_ptr = reinterpret_cast(value_cache.data_ptr()); - int* block_tables_ptr = block_tables.mutable_data_ptr(); - int* seq_lens_ptr = seq_lens.mutable_data_ptr(); - const float* k_scale_ptr = reinterpret_cast(k_scale.data_ptr()); - const float* v_scale_ptr = reinterpret_cast(v_scale.data_ptr()); - - const int NUM_WARPS = NUM_THREADS / WARP_SIZE; - int padded_max_seq_len = - DIVIDE_ROUND_UP(max_seq_len, BLOCK_SIZE) * BLOCK_SIZE; - int logits_size = padded_max_seq_len * sizeof(float); - int outputs_size = (NUM_WARPS / 2) * head_size * sizeof(float); - // Python-side check in vllm.worker.worker._check_if_can_support_max_seq_len - // Keep that in sync with the logic here! - int shared_mem_size = std::max(logits_size, outputs_size); - - dim3 grid(num_heads, num_seqs, 1); - dim3 block(NUM_THREADS); - const torch::stable::accelerator::DeviceGuard device_guard( - query.get_device_index()); - const cudaStream_t stream = get_current_cuda_stream(); - switch (head_size) { - // NOTE(woosuk): To reduce the compilation time, we only compile for the - // head sizes that we use in the model. However, we can easily extend this - // to support any head size which is a multiple of 16. - case 32: - LAUNCH_PAGED_ATTENTION_V1(32); - break; - case 64: - LAUNCH_PAGED_ATTENTION_V1(64); - break; - case 80: - LAUNCH_PAGED_ATTENTION_V1(80); - break; - case 96: - LAUNCH_PAGED_ATTENTION_V1(96); - break; - case 112: - LAUNCH_PAGED_ATTENTION_V1(112); - break; - case 120: - LAUNCH_PAGED_ATTENTION_V1(120); - break; - case 128: - LAUNCH_PAGED_ATTENTION_V1(128); - break; - case 192: - LAUNCH_PAGED_ATTENTION_V1(192); - break; - case 256: - LAUNCH_PAGED_ATTENTION_V1(256); - break; - default: - STD_TORCH_CHECK(false, "Unsupported head size: ", head_size); - break; - } -} - -#define CALL_V1_LAUNCHER(T, CACHE_T, BLOCK_SIZE, KV_DTYPE, IS_BLOCK_SPARSE) \ - paged_attention_v1_launcher( \ - out, query, key_cache, value_cache, num_kv_heads, scale, block_tables, \ - seq_lens, max_seq_len, alibi_slopes, k_scale, v_scale, tp_rank, \ - blocksparse_local_blocks, blocksparse_vert_stride, \ - blocksparse_block_size, blocksparse_head_sliding_step); - -#define CALL_V1_LAUNCHER_SPARSITY(T, CACHE_T, BLOCK_SIZE, IS_FP8_KV_CACHE) \ - if (is_block_sparse) { \ - CALL_V1_LAUNCHER(T, CACHE_T, BLOCK_SIZE, IS_FP8_KV_CACHE, true); \ - } else { \ - CALL_V1_LAUNCHER(T, CACHE_T, BLOCK_SIZE, IS_FP8_KV_CACHE, false); \ - } - -// NOTE(woosuk): To reduce the compilation time, we omitted block sizes -// 1, 2, 4, 64, 128, 256. -#define CALL_V1_LAUNCHER_BLOCK_SIZE(T, CACHE_T, KV_DTYPE) \ - switch (block_size) { \ - case 8: \ - CALL_V1_LAUNCHER_SPARSITY(T, CACHE_T, 8, KV_DTYPE); \ - break; \ - case 16: \ - CALL_V1_LAUNCHER_SPARSITY(T, CACHE_T, 16, KV_DTYPE); \ - break; \ - case 32: \ - CALL_V1_LAUNCHER_SPARSITY(T, CACHE_T, 32, KV_DTYPE); \ - break; \ - default: \ - STD_TORCH_CHECK(false, "Unsupported block size: ", block_size); \ - break; \ - } - -void paged_attention_v1( - torch::stable::Tensor& out, // [num_seqs, num_heads, head_size] - torch::stable::Tensor& query, // [num_seqs, num_heads, head_size] - torch::stable::Tensor& - key_cache, // [num_blocks, num_heads, head_size/x, block_size, x] - torch::stable::Tensor& - value_cache, // [num_blocks, num_heads, head_size, block_size] - int64_t num_kv_heads, // [num_heads] - double scale, - torch::stable::Tensor& block_tables, // [num_seqs, max_num_blocks_per_seq] - torch::stable::Tensor& seq_lens, // [num_seqs] - int64_t block_size, int64_t max_seq_len, - const std::optional& alibi_slopes, - const std::string& kv_cache_dtype, torch::stable::Tensor& k_scale, - torch::stable::Tensor& v_scale, const int64_t tp_rank, - const int64_t blocksparse_local_blocks, - const int64_t blocksparse_vert_stride, const int64_t blocksparse_block_size, - const int64_t blocksparse_head_sliding_step) { - const bool is_block_sparse = (blocksparse_vert_stride > 1); - - DISPATCH_BY_KV_CACHE_DTYPE(query.scalar_type(), kv_cache_dtype, - CALL_V1_LAUNCHER_BLOCK_SIZE) -} - -#undef MAX -#undef MIN -#undef DIVIDE_ROUND_UP diff --git a/csrc/libtorch_stable/attention/paged_attention_v2.cu b/csrc/libtorch_stable/attention/paged_attention_v2.cu deleted file mode 100644 index 4e8e56ae05c0..000000000000 --- a/csrc/libtorch_stable/attention/paged_attention_v2.cu +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Adapted from - * https://github.com/NVIDIA/FasterTransformer/blob/release/v5.3_tag/src/fastertransformer/kernels/decoder_masked_multihead_attention/decoder_masked_multihead_attention_template.hpp - * Copyright (c) 2023, The vLLM team. - * Copyright (c) 2020-2023, NVIDIA CORPORATION. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -#include "../torch_utils.h" -#include "attention_kernels.cuh" -#include "../../cuda_compat.h" - -#define MAX(a, b) ((a) > (b) ? (a) : (b)) -#define MIN(a, b) ((a) < (b) ? (a) : (b)) -#define DIVIDE_ROUND_UP(a, b) (((a) + (b) - 1) / (b)) - -#define LAUNCH_PAGED_ATTENTION_V2(HEAD_SIZE) \ - vllm::paged_attention_v2_kernel \ - <<>>( \ - exp_sums_ptr, max_logits_ptr, tmp_out_ptr, query_ptr, key_cache_ptr, \ - value_cache_ptr, num_kv_heads, scale, block_tables_ptr, \ - seq_lens_ptr, max_num_blocks_per_seq, alibi_slopes_ptr, q_stride, \ - kv_block_stride, kv_head_stride, k_scale_ptr, v_scale_ptr, tp_rank, \ - blocksparse_local_blocks, blocksparse_vert_stride, \ - blocksparse_block_size, blocksparse_head_sliding_step); \ - vllm::paged_attention_v2_reduce_kernel \ - <<>>( \ - out_ptr, exp_sums_ptr, max_logits_ptr, tmp_out_ptr, seq_lens_ptr, \ - max_num_partitions); - -template -void paged_attention_v2_launcher( - torch::stable::Tensor& out, torch::stable::Tensor& exp_sums, - torch::stable::Tensor& max_logits, torch::stable::Tensor& tmp_out, - torch::stable::Tensor& query, torch::stable::Tensor& key_cache, - torch::stable::Tensor& value_cache, int num_kv_heads, float scale, - torch::stable::Tensor& block_tables, torch::stable::Tensor& seq_lens, - int max_seq_len, const std::optional& alibi_slopes, - torch::stable::Tensor& k_scale, torch::stable::Tensor& v_scale, - const int tp_rank, const int blocksparse_local_blocks, - const int blocksparse_vert_stride, const int blocksparse_block_size, - const int blocksparse_head_sliding_step) { - int num_seqs = query.size(0); - int num_heads = query.size(1); - int head_size = query.size(2); - int max_num_blocks_per_seq = block_tables.size(1); - int q_stride = query.stride(0); - int kv_block_stride = key_cache.stride(0); - int kv_head_stride = key_cache.stride(1); - - // NOTE: alibi_slopes is optional. - const float* alibi_slopes_ptr = - alibi_slopes - ? reinterpret_cast(alibi_slopes.value().data_ptr()) - : nullptr; - - T* out_ptr = reinterpret_cast(out.data_ptr()); - float* exp_sums_ptr = reinterpret_cast(exp_sums.data_ptr()); - float* max_logits_ptr = reinterpret_cast(max_logits.data_ptr()); - T* tmp_out_ptr = reinterpret_cast(tmp_out.data_ptr()); - T* query_ptr = reinterpret_cast(query.data_ptr()); - CACHE_T* key_cache_ptr = reinterpret_cast(key_cache.data_ptr()); - CACHE_T* value_cache_ptr = reinterpret_cast(value_cache.data_ptr()); - int* block_tables_ptr = block_tables.mutable_data_ptr(); - int* seq_lens_ptr = seq_lens.mutable_data_ptr(); - const float* k_scale_ptr = reinterpret_cast(k_scale.data_ptr()); - const float* v_scale_ptr = reinterpret_cast(v_scale.data_ptr()); - - const int NUM_WARPS = NUM_THREADS / WARP_SIZE; - int max_num_partitions = DIVIDE_ROUND_UP(max_seq_len, PARTITION_SIZE); - int logits_size = PARTITION_SIZE * sizeof(float); - int outputs_size = (NUM_WARPS / 2) * head_size * sizeof(float); - - // For paged attention v2 kernel. - dim3 grid(num_heads, num_seqs, max_num_partitions); - int shared_mem_size = std::max(logits_size, outputs_size); - // For paged attention v2 reduce kernel. - dim3 reduce_grid(num_heads, num_seqs); - int reduce_shared_mem_size = 2 * max_num_partitions * sizeof(float); - - dim3 block(NUM_THREADS); - const torch::stable::accelerator::DeviceGuard device_guard( - query.get_device_index()); - const cudaStream_t stream = get_current_cuda_stream(); - switch (head_size) { - // NOTE(woosuk): To reduce the compilation time, we only compile for the - // head sizes that we use in the model. However, we can easily extend this - // to support any head size which is a multiple of 16. - case 32: - LAUNCH_PAGED_ATTENTION_V2(32); - break; - case 64: - LAUNCH_PAGED_ATTENTION_V2(64); - break; - case 80: - LAUNCH_PAGED_ATTENTION_V2(80); - break; - case 96: - LAUNCH_PAGED_ATTENTION_V2(96); - break; - case 112: - LAUNCH_PAGED_ATTENTION_V2(112); - break; - case 120: - LAUNCH_PAGED_ATTENTION_V2(120); - break; - case 128: - LAUNCH_PAGED_ATTENTION_V2(128); - break; - case 192: - LAUNCH_PAGED_ATTENTION_V2(192); - break; - case 256: - LAUNCH_PAGED_ATTENTION_V2(256); - break; - default: - STD_TORCH_CHECK(false, "Unsupported head size: ", head_size); - break; - } -} - -#define CALL_V2_LAUNCHER(T, CACHE_T, BLOCK_SIZE, KV_DTYPE, IS_BLOCK_SPARSE) \ - paged_attention_v2_launcher( \ - out, exp_sums, max_logits, tmp_out, query, key_cache, value_cache, \ - num_kv_heads, scale, block_tables, seq_lens, max_seq_len, alibi_slopes, \ - k_scale, v_scale, tp_rank, blocksparse_local_blocks, \ - blocksparse_vert_stride, blocksparse_block_size, \ - blocksparse_head_sliding_step); - -#define CALL_V2_LAUNCHER_SPARSITY(T, CACHE_T, BLOCK_SIZE, IS_FP8_KV_CACHE) \ - if (is_block_sparse) { \ - CALL_V2_LAUNCHER(T, CACHE_T, BLOCK_SIZE, IS_FP8_KV_CACHE, true); \ - } else { \ - CALL_V2_LAUNCHER(T, CACHE_T, BLOCK_SIZE, IS_FP8_KV_CACHE, false); \ - } - -// NOTE(woosuk): To reduce the compilation time, we omitted block sizes -// 1, 2, 4, 64, 128, 256. -#define CALL_V2_LAUNCHER_BLOCK_SIZE(T, CACHE_T, KV_DTYPE) \ - switch (block_size) { \ - case 8: \ - CALL_V2_LAUNCHER_SPARSITY(T, CACHE_T, 8, KV_DTYPE); \ - break; \ - case 16: \ - CALL_V2_LAUNCHER_SPARSITY(T, CACHE_T, 16, KV_DTYPE); \ - break; \ - case 32: \ - CALL_V2_LAUNCHER_SPARSITY(T, CACHE_T, 32, KV_DTYPE); \ - break; \ - default: \ - STD_TORCH_CHECK(false, "Unsupported block size: ", block_size); \ - break; \ - } - -void paged_attention_v2( - torch::stable::Tensor& out, // [num_seqs, num_heads, head_size] - torch::stable::Tensor& - exp_sums, // [num_seqs, num_heads, max_num_partitions] - torch::stable::Tensor& - max_logits, // [num_seqs, num_heads, max_num_partitions] - torch::stable::Tensor& - tmp_out, // [num_seqs, num_heads, max_num_partitions, head_size] - torch::stable::Tensor& query, // [num_seqs, num_heads, head_size] - torch::stable::Tensor& - key_cache, // [num_blocks, num_heads, head_size/x, block_size, x] - torch::stable::Tensor& - value_cache, // [num_blocks, num_heads, head_size, block_size] - int64_t num_kv_heads, // [num_heads] - double scale, - torch::stable::Tensor& block_tables, // [num_seqs, max_num_blocks_per_seq] - torch::stable::Tensor& seq_lens, // [num_seqs] - int64_t block_size, int64_t max_seq_len, - const std::optional& alibi_slopes, - const std::string& kv_cache_dtype, torch::stable::Tensor& k_scale, - torch::stable::Tensor& v_scale, const int64_t tp_rank, - const int64_t blocksparse_local_blocks, - const int64_t blocksparse_vert_stride, const int64_t blocksparse_block_size, - const int64_t blocksparse_head_sliding_step) { - const bool is_block_sparse = (blocksparse_vert_stride > 1); - DISPATCH_BY_KV_CACHE_DTYPE(query.scalar_type(), kv_cache_dtype, - CALL_V2_LAUNCHER_BLOCK_SIZE) -} - -#undef MAX -#undef MIN -#undef DIVIDE_ROUND_UP diff --git a/csrc/libtorch_stable/cache_kernels.cu b/csrc/libtorch_stable/cache_kernels.cu index eac93ac9a9f0..a1ac81cb10a4 100644 --- a/csrc/libtorch_stable/cache_kernels.cu +++ b/csrc/libtorch_stable/cache_kernels.cu @@ -127,7 +127,12 @@ void swap_blocks_batch(const torch::stable::Tensor& src_ptrs, return reinterpret_cast(fn_ptr); }(); - if (batch_fn != nullptr) { + // cuMemcpyBatchAsync rejects the legacy default stream (handle 0 / + // cudaStreamLegacy) with CUDA_ERROR_INVALID_VALUE; route it to the per-copy + // fallback below, which is correct on any stream. Real and per-thread-default + // streams take the batch fast path. + const bool usable_stream = stream != nullptr && stream != cudaStreamLegacy; + if (batch_fn != nullptr && usable_stream) { CUmemcpyAttributes attr = {}; // ANY lets the DMA engine prefetch source bytes out of stream order, // which is only safe when no GPU stream is concurrently writing the @@ -549,7 +554,7 @@ __global__ void indexer_k_quant_and_cache_kernel( const int head_dim, // dimension of each head const int quant_block_size, // quantization block size const int cache_block_size, // cache block size - const int cache_stride, // stride for each token in kv_cache + const int64_t cache_block_stride, // stride for each block in kv_cache const bool use_ue8m0 // use ue8m0 scale format ) { @@ -590,16 +595,15 @@ __global__ void indexer_k_quant_and_cache_kernel( scale = exp2f(ceilf(log2f(scale))); } - const int64_t dst_offset = block_idx * cache_block_size * cache_stride + - block_offset * head_dim + head_dim_idx; + const int64_t dst_offset = + block_idx * cache_block_stride + block_offset * head_dim + head_dim_idx; for (int i = 0; i < VEC_SIZE; i++) { kv_cache[dst_offset + i] = fp8::scaled_convert(k_val_ptr[i], scale); } if (threadIdx.x == 0) { const int64_t dst_scale_idx = - block_idx * cache_block_size * cache_stride + - cache_block_size * head_dim + + block_idx * cache_block_stride + cache_block_size * head_dim + (block_offset * head_dim + head_dim_idx) * 4 / quant_block_size; reinterpret_cast(kv_cache)[dst_scale_idx / 4] = scale; } @@ -1452,7 +1456,7 @@ void cp_gather_and_upconvert_fp8_kv_cache( reinterpret_cast(k.data_ptr()), \ reinterpret_cast(kv_cache.data_ptr()), \ slot_mapping.const_data_ptr(), head_dim, quant_block_size, \ - cache_block_size, cache_stride, use_ue8m0); + cache_block_size, cache_block_stride, use_ue8m0); void indexer_k_quant_and_cache( torch::stable::Tensor& k, // [num_tokens, head_dim] @@ -1463,7 +1467,7 @@ void indexer_k_quant_and_cache( int num_tokens = k.size(0); int head_dim = k.size(1); int cache_block_size = kv_cache.size(1); - int cache_stride = kv_cache.size(2); + int64_t cache_block_stride = kv_cache.stride(0); bool use_ue8m0 = scale_fmt == "ue8m0"; STD_TORCH_CHECK(k.device() == kv_cache.device(), diff --git a/csrc/libtorch_stable/concat_mla_q.cuh b/csrc/libtorch_stable/concat_mla_q.cuh index 68bcfa011fb3..10dd31b70f4e 100644 --- a/csrc/libtorch_stable/concat_mla_q.cuh +++ b/csrc/libtorch_stable/concat_mla_q.cuh @@ -1,9 +1,6 @@ #ifndef CONCAT_MLA_Q_CUH_ #define CONCAT_MLA_Q_CUH_ -#include -#include - #include "cuda_vec_utils.cuh" namespace vllm { diff --git a/csrc/libtorch_stable/cooperative_topk.cu b/csrc/libtorch_stable/cooperative_topk.cu new file mode 100644 index 000000000000..f388a9e6c8e7 --- /dev/null +++ b/csrc/libtorch_stable/cooperative_topk.cu @@ -0,0 +1,146 @@ +// Cooperative cluster TopK for DeepSeek V3 sparse attention indexer. +// See cooperative_topk.cuh for kernel implementation. + +#include + +#include "torch_utils.h" + +#ifndef USE_ROCM + #include "cooperative_topk.cuh" +namespace ct = vllm::cooperative; +namespace hist4096 = vllm::topk_histogram_4096; +#endif + +#ifndef USE_ROCM +template +void launch_cooperative_cluster(ct::CooperativeTopKParams& params, + size_t smem, cudaStream_t stream) { + auto kernel = []() { + if constexpr (CS == 16) { + return &ct::cooperative_topk_cs16; + } else if constexpr (CS == 8) { + return &ct::cooperative_topk_cs8; + } else { + static_assert(CS == 4, "unsupported cooperative_topk cluster size"); + return &ct::cooperative_topk_cs4; + } + }(); + if constexpr (CS > 8) { + cudaFuncSetAttribute(kernel, cudaFuncAttributeNonPortableClusterSizeAllowed, + 1); + } + cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, + smem); + + cudaLaunchConfig_t cfg = {}; + cfg.gridDim = dim3(params.num_rows, CS); + cfg.blockDim = dim3(hist4096::kBlockSize); + cfg.dynamicSmemBytes = smem; + cfg.stream = stream; + cudaLaunchAttribute attrs[1]; + attrs[0].id = cudaLaunchAttributeClusterDimension; + attrs[0].val.clusterDim = {1, CS, 1}; + cfg.numAttrs = 1; + cfg.attrs = attrs; + cudaError_t err = cudaLaunchKernelEx(&cfg, kernel, params); + STD_TORCH_CHECK(err == cudaSuccess, + "cooperative_topk launch failed: ", cudaGetErrorString(err)); +} + +template +void launch_cooperative_topk_impl(const torch::stable::Tensor& logits, + const torch::stable::Tensor& lengths, + torch::stable::Tensor& output, + torch::stable::Tensor& workspace, + int64_t max_seq_len) { + (void)max_seq_len; // Kept for signature parity with persistent_topk. + const int64_t num_rows = logits.size(0); + const cudaStream_t stream = get_current_cuda_stream(); + + const uint32_t stride = static_cast(logits.stride(0)); + // 32 = max clusters for CS=4 (32 x 4 = 128 CTAs = 66% of SMs, leaves + // headroom) + STD_TORCH_CHECK( + num_rows <= 32, + "cooperative_topk supports <=32 rows; use persistent_topk for " + "larger batches"); + + STD_TORCH_CHECK(stride % 4 == 0, + "cooperative_topk: stride must be multiple of 4 for TMA " + "alignment, got stride (max_model_len)=", + stride); + + STD_TORCH_CHECK(workspace.is_cuda(), "workspace must be CUDA tensor"); + STD_TORCH_CHECK( + workspace.scalar_type() == torch::headeronly::ScalarType::Byte, + "workspace must be uint8"); + + ct::CooperativeTopKParams params; + params.input = logits.const_data_ptr(); + params.output = output.mutable_data_ptr(); + params.lengths = lengths.const_data_ptr(); + params.num_rows = static_cast(num_rows); + params.stride = stride; + params.tie_ws = + reinterpret_cast(workspace.mutable_data_ptr()); + + constexpr uint32_t kTieWsPerRow = + TopK <= hist4096::kBlockSize ? hist4096::kMaxTies : TopK; + STD_TORCH_CHECK( + workspace.size(0) >= + static_cast(num_rows * kTieWsPerRow * sizeof(hist4096::Tie)), + "workspace too small"); + + const bool supports_cluster16 = get_device_prop()->major >= 10; + if (num_rows <= 4 && supports_cluster16) { + launch_cooperative_cluster(params, ct::kSmemSize8, stream); + } else if (num_rows <= 8) { + launch_cooperative_cluster(params, ct::kSmemSize8, stream); + } else { + launch_cooperative_cluster(params, ct::kSmemSize4, stream); + } +} +#endif // USE_ROCM + +void cooperative_topk(const torch::stable::Tensor& logits, + const torch::stable::Tensor& lengths, + torch::stable::Tensor& output, + torch::stable::Tensor& workspace, int64_t k, + int64_t max_seq_len) { +#ifndef USE_ROCM + STD_TORCH_CHECK(logits.is_cuda(), "logits must be CUDA tensor"); + STD_TORCH_CHECK(lengths.is_cuda(), "lengths must be CUDA tensor"); + STD_TORCH_CHECK(output.is_cuda(), "output must be CUDA tensor"); + STD_TORCH_CHECK(logits.scalar_type() == torch::headeronly::ScalarType::Float, + "Only float32 supported"); + STD_TORCH_CHECK(lengths.scalar_type() == torch::headeronly::ScalarType::Int, + "lengths must be int32"); + STD_TORCH_CHECK(output.scalar_type() == torch::headeronly::ScalarType::Int, + "output must be int32"); + STD_TORCH_CHECK(logits.dim() == 2, "logits must be 2D"); + STD_TORCH_CHECK(lengths.dim() == 1 || lengths.dim() == 2, + "lengths must be 1D or 2D"); + STD_TORCH_CHECK(lengths.is_contiguous(), "lengths must be contiguous"); + STD_TORCH_CHECK(output.dim() == 2, "output must be 2D"); + const int64_t num_rows = logits.size(0); + STD_TORCH_CHECK(lengths.numel() == num_rows, "lengths size mismatch"); + STD_TORCH_CHECK(output.size(0) == num_rows && output.size(1) == k, + "output size mismatch"); + STD_TORCH_CHECK( + k == 512 || k == 1024 || k == 2048, + "cooperative_topk supports k=512, k=1024, or k=2048, got k=", k); + + if (k == 512) { + launch_cooperative_topk_impl<512>(logits, lengths, output, workspace, + max_seq_len); + } else if (k == 1024) { + launch_cooperative_topk_impl<1024>(logits, lengths, output, workspace, + max_seq_len); + } else { + launch_cooperative_topk_impl<2048>(logits, lengths, output, workspace, + max_seq_len); + } +#else + STD_TORCH_CHECK(false, "cooperative_topk is not supported on ROCm"); +#endif +} diff --git a/csrc/libtorch_stable/cooperative_topk.cuh b/csrc/libtorch_stable/cooperative_topk.cuh new file mode 100644 index 000000000000..b43b9b8447d6 --- /dev/null +++ b/csrc/libtorch_stable/cooperative_topk.cuh @@ -0,0 +1,593 @@ +/* + * Cooperative TopK kernel for DSA Indexer + */ + +#ifndef COOPERATIVE_TOPK_CUH_ +#define COOPERATIVE_TOPK_CUH_ + +#include +#include +#include +#include +#include +#include +#include + +#include "topk_histogram_4096.cuh" + +namespace vllm { +namespace cooperative { + +namespace hist4096 = topk_histogram_4096; + +constexpr uint32_t kHistBits = 10; +constexpr uint32_t kHistBins = 1 << kHistBits; +constexpr uint32_t kMaxTopK = 2048; + +constexpr uint32_t kElemPerStage = 16; +constexpr uint32_t kSizePerStage = + kElemPerStage * hist4096::kBlockSize; // 16384 + +// CS=4 two-pass path uses two TMA stages as a double buffer. +constexpr uint32_t kStreamingStagesCS4 = 2; +// CS=8/16 fused paths keep all loaded TMA stages resident in smem. +constexpr uint32_t kFusedStagesCS8 = 2; +constexpr uint32_t kFusedStagesCS16 = 2; + +// CS=4 single-pass path +constexpr uint32_t kMaxSinglePassStages = 3; +constexpr uint32_t kMaxSinglePassPerBlock = + kMaxSinglePassStages * kSizePerStage; // 49152 + +template +struct CooperativeTopKParams { + const float* __restrict__ input; + int32_t* __restrict__ output; + const int32_t* __restrict__ lengths; + hist4096::Tie* __restrict__ tie_ws; // per-row tie workspace, see + // kTieWsPerRow + uint32_t num_rows, stride; +}; + +// ============================================================================ +// Cooperative helpers +// ============================================================================ + +// only CS adjacent lanes participate (sub-warp reduce), in opposite to +// warp_reduce_sum_full +template +__device__ __forceinline__ uint32_t warp_reduce_sum_subN(uint32_t v) { +#pragma unroll + for (uint32_t m = N >> 1; m > 0; m >>= 1) + v += __shfl_xor_sync(0xFFFFFFFF, v, m, 32); + return v; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +__device__ __forceinline__ uint32_t extract_coarse_bin(float x) { + return hist4096::extract_coarse_bin_N(x); +} + +__device__ __forceinline__ void mbarrier_init(uint64_t* a, uint32_t n) { + cuda::ptx::mbarrier_init(a, n); +} +__device__ __forceinline__ void mbarrier_wait(uint64_t* a, uint32_t p) { + while (!cuda::ptx::mbarrier_try_wait_parity(cuda::ptx::sem_relaxed, + cuda::ptx::scope_cta, a, p)); +} +__device__ __forceinline__ void mbarrier_arrive_expect_tx(uint64_t* a, + uint32_t t) { + cuda::ptx::mbarrier_arrive_expect_tx(cuda::ptx::sem_relaxed, + cuda::ptx::scope_cta, + cuda::ptx::space_shared, a, t); +} +__device__ __forceinline__ void tma_load(void* d, const void* s, uint32_t n, + uint64_t* m) { + cuda::ptx::cp_async_bulk(cuda::ptx::space_shared, cuda::ptx::space_global, d, + s, n, m); +} + +// ============================================================================ +// DSMEM histogram reduce +// ============================================================================ + +template +__device__ __forceinline__ void dsmem_hist_reduce(uint32_t* histogram) { + static_assert(kHistBins <= hist4096::kBlockSize); + auto cluster = cooperative_groups::this_cluster(); + cluster.sync(); + const auto tx = threadIdx.x; + const auto rank = blockIdx.y; + constexpr auto kLocal = kHistBins / CS; + const auto off = kLocal * rank; + if (tx < kHistBins) { + const auto addr = &histogram[off + tx / CS]; + const auto src = cluster.map_shared_rank(addr, tx % CS); + *src = warp_reduce_sum_subN(*src); + } + cluster.sync(); +} + +// ============================================================================ +// Find threshold from reduced histogram +// ============================================================================ + +// NOTE: caller must ensure a cluster.sync() or __syncthreads() happened +// before calling this, so warp_sum writes are visible across warps. +// The first internal __syncthreads() is still needed for the warp_sum exchange. +template +__device__ __forceinline__ void find_threshold(uint32_t* histogram, + uint32_t* warp_sum, + uint32_t* counter_gt, + uint32_t* counter_eq, + hist4096::MatchBin* match) { + const auto tx = threadIdx.x; + const auto li = tx % hist4096::kWarpSize, wi = tx / hist4096::kWarpSize; + const auto value = tx < kHistBins ? histogram[tx] : 0; + const auto winc = hist4096::warp_inclusive_sum(li, value); + if (li == hist4096::kWarpSize - 1) warp_sum[wi] = winc; + __syncthreads(); + const auto tmp = warp_sum[li]; + const auto total = hist4096::warp_reduce_sum_full(tmp); + auto pfx = hist4096::warp_reduce_sum_full(li < wi ? tmp : 0) + winc; + const auto above = total - pfx; + if (tx < kHistBins && above < TopK && above + value >= TopK) { + *counter_gt = *counter_eq = 0; + *match = {.bin = tx, .above_count = above, .equal_count = value}; + } + __syncthreads(); +} + +// Streams data through shared memory in chunks, processing each chunk before +// loading the next overwrites each buffer after processing it (the epilogue +// prefetch loads the next chunk into the same slot) +template +__device__ void tma_stream_pass(const float* scores, uint32_t length, + uint32_t thr_bin, int32_t* indices, + uint32_t* phases, SmemType* smem) { + const auto tx = threadIdx.x; + const auto lane = tx % hist4096::kWarpSize; + const auto ni = + (length + kSizePerStage - 1) / kSizePerStage; // total stages needed + const auto la = + (length + 3u) & ~3u; // length rounded up to float4 (TMA alignment) + const auto pass = + kIsScatter ? 1 : 0; // barrier dim: [0] for histogram, [1] for scatter + + // Prologue: issue initial TMA loads - prefill the pipeline + if (tx == 0) { +#pragma unroll + for (uint32_t i = 0; i < kStages; i++) { + if (i >= ni) { + break; + } + const auto o = i * kSizePerStage; + const auto sz = min(kSizePerStage, la - o) * sizeof(float); + tma_load(smem->score_buffer[i], scores + o, sz, + &smem->barrier[pass][i]); // cp.async.bulk is non-blocking + mbarrier_arrive_expect_tx(&smem->barrier[pass][i], sz); + } + } + + // Main loop: process stages + for (uint32_t it = 0; it < ni; it++) { + const auto b = it % kStages; // which buffer slot (0 or 1) + const auto o = it * kSizePerStage; + const auto sz = min(kSizePerStage, length - o); + + if (lane == 0) { + mbarrier_wait(&smem->barrier[pass][b], + phases[b] & 1); // wait for the data + } + phases[b]++; // advances the phase for next time this slot is reused + __syncwarp(); + +#pragma unroll + for (uint32_t i = 0; i < kElemPerStage; i++) { + const auto li = tx + i * hist4096::kBlockSize; + if (li >= sz) { + break; + } + const auto sc = smem->score_buffer[b][li]; + const auto bn = hist4096::extract_coarse_bin_N(sc); + if constexpr (kIsScatter) { // compile-time branch + // Scatter pass: place above-threshold and collect ties + const auto gi = o + li; + if (bn > thr_bin) { + indices[atomicAdd(&smem->counter_gt, 1)] = gi; + } else if (bn == thr_bin) { + const auto p = atomicAdd(&smem->counter_eq, 1); + if (p < hist4096::kMaxTies) { + smem->tie_buffer[p] = {gi, sc}; + } + } + } else { + // Histogram pass: just count + atomicAdd(&smem->histogram[bn], 1); + } + } + __syncthreads(); // ensures all threads finished processing their buffer + // before next TMA load + + // Epilogue: issue next TMA load + if (tx == 0 && it + kStages < ni) { + const auto no = (it + kStages) * kSizePerStage; + const auto nsz = min(kSizePerStage, la - no) * sizeof(float); + tma_load(smem->score_buffer[b], scores + no, nsz, + &smem->barrier[pass][b]); + mbarrier_arrive_expect_tx(&smem->barrier[pass][b], nsz); + } + } +} + +// ============================================================================ +// Fused path: single TMA pass, rescan smem for scatter +// ============================================================================ + +// Fused shared memory layout for cluster cooperative paths. +// kPasses=1 for single-pass (CS=8, CS=4 singlepass), kPasses=2 for two-pass +// (CS=4). +template +struct SmemFused { + uint64_t barrier[kPasses][kStages]; + alignas(128) uint32_t counter_gt; + alignas(128) uint32_t counter_eq; + alignas(128) hist4096::MatchBin match; + uint32_t warp_sum[hist4096::kNumWarps]; + union { + uint32_t histogram[kHistBins]; + hist4096::Tie tie_buffer[kMaxTopK]; + }; + alignas(128) float score_buffer[kStages][kSizePerStage]; +}; + +using Smem8 = SmemFused; +using Smem16 = SmemFused; +using Smem4 = SmemFused; +using SmemSinglePass = SmemFused; + +// Cluster-cooperative large path. +// kFused=true: all TMA stages resident, single-pass histogram + scatter (rescan +// from smem). kFused=false: TMA double-buffer streaming, two passes (histogram +// then scatter). +template +__device__ void large_topk(const float* __restrict__ row_input, + int32_t* __restrict__ row_output, uint32_t seq_len, + uint32_t* phases, hist4096::Tie* tie_ws) { + const auto rank = blockIdx.y; // this block's position in cluster + const auto tx = threadIdx.x; + const auto lane = tx % hist4096::kWarpSize; + + extern __shared__ uint8_t smem_raw[]; + auto* smem = reinterpret_cast(smem_raw); + int32_t* s_topk = reinterpret_cast(smem_raw + sizeof(SmemType)); + + // Partition row across cluster ranks + constexpr uint32_t kAlign = 4; + const auto units = + (seq_len + kAlign - 1) / kAlign; // float4-aligned element count + const auto base = units / CS, extra = units % CS; // elements per block + const auto lu = base + (rank < extra ? 1u : 0u); // remainder blocks + const auto ou = + rank * base + min(rank, extra); // this block's count (load-balanced) + const auto my_start = ou * kAlign; // global start offset + const auto my_len = min(my_start + lu * kAlign, seq_len) - + my_start; // actual length of this block + const auto num_iters = + (my_len + kSizePerStage - 1) / kSizePerStage; // TMA stages needed + const auto len_aligned = (my_len + 3u) & ~3u; + + if constexpr (kFused) { + // Fused init + TMA prologue + if (tx < kHistBins) { + smem->histogram[tx] = 0; // all threads zero histogram + } + if (tx == 0) { // thread 0 issues TMA - then all threads continue working + // until mbarrier sync + smem->counter_gt = 0; + smem->counter_eq = 0; + for (uint32_t i = 0; i < num_iters; i++) { + const auto off = i * kSizePerStage; + const auto sz = min(kSizePerStage, len_aligned - off) * sizeof(float); + tma_load(smem->score_buffer[i], row_input + my_start + off, sz, + &smem->barrier[0][i]); // cp.async.bulk of size kSizePerStage + // × sizeof(float) + mbarrier_arrive_expect_tx(&smem->barrier[0][i], sz); + } + } + __syncthreads(); + + // Histogram build. ILP unroll-by-2, no inter-stage sync + for (uint32_t iter = 0; iter < num_iters; iter++) { + const auto off = iter * kSizePerStage; + const auto sz = min(kSizePerStage, my_len - off); + if (lane == 0) { + mbarrier_wait(&smem->barrier[0][iter], + phases[iter] & 1); // wait for TMA + } + phases[iter]++; + __syncwarp(); +#pragma unroll + for (uint32_t i = 0; i < kElemPerStage; i += 2) { + const auto li0 = tx + i * hist4096::kBlockSize; + const auto li1 = tx + (i + 1) * hist4096::kBlockSize; + if (li0 >= sz) { + break; + } + const auto b0 = extract_coarse_bin(smem->score_buffer[iter][li0]); + if (li1 < sz) { + const auto b1 = extract_coarse_bin(smem->score_buffer[iter][li1]); + atomicAdd(&smem->histogram[b0], 1); + atomicAdd(&smem->histogram[b1], 1); + } else { + atomicAdd(&smem->histogram[b0], 1); + } + } + } + } else { + // Twopass: init then stream histogram pass + if (tx < kHistBins) { + smem->histogram[tx] = 0; + } + if (tx == 0) { + smem->counter_gt = 0; + smem->counter_eq = 0; + } + __syncthreads(); + tma_stream_pass( + row_input + my_start, my_len, 0, nullptr, phases, smem); + } + + // DSMEM all-reduce + find threshold + dsmem_hist_reduce( + smem->histogram); // each block histogram is summed across all CS blocks + find_threshold(smem->histogram, smem->warp_sum, &smem->counter_gt, + &smem->counter_eq, &smem->match); + + const auto thr = smem->match.bin; + + if constexpr (kFused) { + // Fused scatter: rescan score_buffer (still in smem) + for (uint32_t iter = 0; iter < num_iters; iter++) { + const auto off = iter * kSizePerStage; + const auto sz = min(kSizePerStage, my_len - off); +#pragma unroll + for (uint32_t i = 0; i < kElemPerStage; i++) { + const auto li = tx + i * hist4096::kBlockSize; + if (li >= sz) { + break; + } + const auto score = smem->score_buffer[iter][li]; // still in smem + const auto bin = extract_coarse_bin(score); + const auto gidx = off + li; + if (bin > thr) { + s_topk[atomicAdd(&smem->counter_gt, 1)] = gidx; // above -> s_topk + } else if (bin == thr) { + const auto p = atomicAdd(&smem->counter_eq, + 1); // equal -> ties (later refinement) + if (p < hist4096::kMaxTies) { + smem->tie_buffer[p] = {gidx, score}; + } + } + } + } + __syncthreads(); + } else { + // Twopass scatter: re-stream data via TMA + uint32_t scatter_phases[kStreamingStagesCS4] = {0, 0}; + tma_stream_pass( + row_input + my_start, my_len, thr, s_topk, scatter_phases, smem); + } + + // Output collection via DSMEM prefix sum + constexpr uint32_t kAboveBits = 16; + constexpr uint32_t kAboveMask = (1 << kAboveBits) - 1; + static_assert(kAboveMask >= TopK); + static_assert(kAboveMask >= kMaxSinglePassPerBlock, + "kAboveBits must cover max per-block element count"); + + const uint32_t la = smem->counter_gt; + const uint32_t le_full = smem->counter_eq; + const uint32_t le = + min(le_full, hist4096::kMaxTies); // written smem tie_buffer entries + + __shared__ uint32_t s_local_counts[CS]; + __shared__ uint32_t s_prefix_packed; + __shared__ uint32_t s_total_above, s_total_equal; + + auto cluster = cooperative_groups::this_cluster(); + if (tx < CS) { + // Pack written tie counts into 32-bit: (equal << 16) | above. + // `le_full` may exceed the per-block tie buffer cap; using it here creates + // holes in tie_ws and can make TopK=2048 refine unwritten workspace slots. + const uint32_t packed = (le << kAboveBits) | la; + const auto dst = cluster.map_shared_rank(s_local_counts, tx); + dst[rank] = packed; // write my count to every block's s_local_counts[rank] + } + cluster.sync(); + + // Thread 0 computes serial prefix sum + if (tx == 0) { + uint32_t prefix = 0, ta = 0, te = 0; + for (uint32_t i = 0; i < CS; i++) { + if (i == rank) { + s_prefix_packed = prefix; // my prefix + } + ta += s_local_counts[i] & kAboveMask; // total above + te += s_local_counts[i] >> kAboveBits; // total equal + prefix += s_local_counts[i]; + } + s_total_above = ta; + s_total_equal = te; + } + __syncthreads(); + + const uint32_t prefix_above = s_prefix_packed & kAboveMask; + const uint32_t prefix_equal = s_prefix_packed >> kAboveBits; + + // Write to global output + for (uint32_t i = tx; i < la; i += hist4096::kBlockSize) { + // indices are placed contiguously starting at prefix_above + row_output[prefix_above + i] = + s_topk[i] + my_start; // my_start: block-local -> row-global index + } + for (uint32_t i = tx; i < le; i += hist4096::kBlockSize) { + const auto t = smem->tie_buffer[i]; + uint32_t p = s_total_above + prefix_equal + i; + if (p < TopK) { + row_output[p] = t.idx + my_start; + } + uint32_t tp = prefix_equal + i; + if (tp < (TopK <= hist4096::kBlockSize ? hist4096::kMaxTies : TopK)) { + tie_ws[tp] = hist4096::Tie{t.idx + my_start, t.score}; + } + } + + // Tie refinement + cooperative_groups::this_cluster().sync(); + if (rank != 0) { // only rank 0 does tie refinement + return; + } + if (s_total_above + s_total_equal <= TopK) { // no ties to refine + return; + } + + // Tie-breaking uses FP32 (4-round radix sort) + if constexpr (TopK <= hist4096::kBlockSize) { + // copy ties from tie_ws back to smem, then refine + const uint32_t num_ties = min(s_total_equal, hist4096::kMaxTies); + // TODO (roberto): could vectorize with uint2 (8 bytes = exactly one Tie) + for (uint32_t i = tx; i < num_ties; i += hist4096::kBlockSize) { + smem->tie_buffer[i] = hist4096::Tie{tie_ws[i].idx, tie_ws[i].score}; + } + __syncthreads(); + hist4096::tie_handle(smem->tie_buffer, num_ties, s_total_above, + row_output, smem); + } else { + // TopK=2048: process directly from tie_ws (GMEM) + const uint32_t num_ties = min(s_total_equal, static_cast(TopK)); + hist4096::tie_handle_large(tie_ws, num_ties, s_total_above, + row_output, smem); + } +} + +// ============================================================================ +// Adapted from https://github.com/sgl-project/sglang/pull/23600 +// sgl-project/sglang +// (python/sglang/jit_kernel/include/sgl_kernel/deepseek_v4/topk/) +// ============================================================================ + +template +__device__ void cooperative_topk_body(CooperativeTopKParams params) { + const auto rank = blockIdx.y, row = blockIdx.x, tx = threadIdx.x; + const auto sl = params.lengths[row]; + int32_t* out = params.output + row * TopK; + const float* in = params.input + row * params.stride; + + // Trivial: seq_len <= TopK + if (sl <= static_cast(TopK)) { + if (rank == 0) { + for (uint32_t i = tx; i < TopK; i += hist4096::kBlockSize) { + out[i] = (i < static_cast(sl)) ? static_cast(i) : -1; + } + } + return; + } + + // Short-Medium path: histogram_4096_topk on rank 0 only - all data fits in RF + if (sl <= static_cast(hist4096::kHist4096MaxLen)) { + if (rank == 0) { + extern __shared__ uint8_t sr[]; + hist4096::histogram_4096_topk( + in, out, sl, sr); // 4096-bin (12-bit) histogram + } + return; + } + + // Large path: init mbarriers + state, then dispatch fused or twopass + const uint32_t per_block = + (params.stride + CS - 1) / CS; // how many elements per block + constexpr uint32_t kFusedMax = ((CS == 16) ? kFusedStagesCS16 + : (CS == 8) ? kFusedStagesCS8 + : kMaxSinglePassStages) * + kSizePerStage; + const bool use_singlepass = + per_block <= + kFusedMax; // single pass or TMA streaming: histogram+scatter + + // Select smem type and stage count at compile time based on CS + constexpr uint32_t kFusedStages = (CS == 16) ? kFusedStagesCS16 + : (CS == 8) ? kFusedStagesCS8 + : kMaxSinglePassStages; + using FusedSmem = SmemFused; + + extern __shared__ uint8_t sr[]; + + constexpr uint32_t kTieWsPerRow = + TopK <= hist4096::kBlockSize ? hist4096::kMaxTies : TopK; + hist4096::Tie* row_tie_ws = params.tie_ws + row * kTieWsPerRow; + + if (use_singlepass) { + auto* smem = reinterpret_cast(sr); + const uint32_t sp_stages = (per_block + kSizePerStage - 1) / kSizePerStage; + if (tx < sp_stages) { + mbarrier_init(&smem->barrier[0][tx], + 1); // init 1 barrier per TMA stage - + // signal when async copies complete + } + __syncthreads(); + uint32_t phases[kFusedStages] = + {}; // tracks the parity for mbarrier wait/arrive protocol + large_topk(in, out, sl, phases, row_tie_ws); + } else { + // Two-pass: only CS=4 in practice (CS=8 always fits in singlepass) + auto* smem = reinterpret_cast(sr); + if (tx < 2 * kStreamingStagesCS4) { + mbarrier_init(&smem->barrier[0][tx], + 1); // init 2×2=4 barriers (2 passes × 2 stages) + } + __syncthreads(); + uint32_t hp[kStreamingStagesCS4] = {0, + 0}; // histogram+scatter pass counters + large_topk(in, out, sl, hp, row_tie_ws); + } +} + +template +__global__ void __launch_bounds__(hist4096::kBlockSize, 1) + __cluster_dims__(1, 4, 1) + cooperative_topk_cs4(CooperativeTopKParams params) { + cooperative_topk_body(params); +} + +template +__global__ void __launch_bounds__(hist4096::kBlockSize, 1) + __cluster_dims__(1, 8, 1) + cooperative_topk_cs8(CooperativeTopKParams params) { + cooperative_topk_body(params); +} + +template +__global__ void __launch_bounds__(hist4096::kBlockSize, 1) + __cluster_dims__(1, 16, 1) + cooperative_topk_cs16(CooperativeTopKParams params) { + cooperative_topk_body(params); +} + +constexpr size_t kSmemSize4_base = sizeof(Smem4); +constexpr size_t kSmemSize4_sp = sizeof(SmemSinglePass); +constexpr size_t kSmemSize4 = + (kSmemSize4_base > kSmemSize4_sp ? kSmemSize4_base : kSmemSize4_sp) + + sizeof(int32_t) * 2048 + 128; +constexpr size_t kSmemSize8 = + sizeof(SmemFused) + sizeof(int32_t) * 2048 + 128; + +} // namespace cooperative + +} // namespace vllm + +#endif // COOPERATIVE_TOPK_CUH_ diff --git a/csrc/core/math.hpp b/csrc/libtorch_stable/core/math.hpp similarity index 100% rename from csrc/core/math.hpp rename to csrc/libtorch_stable/core/math.hpp diff --git a/csrc/cub_helpers.h b/csrc/libtorch_stable/cub_helpers.h similarity index 100% rename from csrc/cub_helpers.h rename to csrc/libtorch_stable/cub_helpers.h diff --git a/csrc/cuda_utils_kernels.cu b/csrc/libtorch_stable/cuda_utils_kernels.cu similarity index 100% rename from csrc/cuda_utils_kernels.cu rename to csrc/libtorch_stable/cuda_utils_kernels.cu diff --git a/csrc/libtorch_stable/cuda_vec_utils.cuh b/csrc/libtorch_stable/cuda_vec_utils.cuh index efbb09994d25..ec6e60724e65 100644 --- a/csrc/libtorch_stable/cuda_vec_utils.cuh +++ b/csrc/libtorch_stable/cuda_vec_utils.cuh @@ -21,7 +21,7 @@ // together enable 256-bit (v8.u32) PTX load/store instructions. // Use for PTX instruction selection with architecture fallback paths. #if !defined(USE_ROCM) && defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1000 && \ - defined(CUDA_VERSION) && CUDA_VERSION >= 12090 + defined(CUDART_VERSION) && CUDART_VERSION >= 12090 #define VLLM_256B_PTX_ENABLED 1 #else #define VLLM_256B_PTX_ENABLED 0 diff --git a/csrc/libtorch_stable/cuda_view.cu b/csrc/libtorch_stable/cuda_view.cu new file mode 100644 index 000000000000..7bf8267470ec --- /dev/null +++ b/csrc/libtorch_stable/cuda_view.cu @@ -0,0 +1,76 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// This function assumes that `cpu_tensor` is a CPU tensor, +// and that UVA (Unified Virtual Addressing) is enabled. +torch::stable::Tensor get_cuda_view_from_cpu_tensor( + torch::stable::Tensor& cpu_tensor) { + STD_TORCH_CHECK(cpu_tensor.device().is_cpu(), "Input tensor must be on CPU"); + + const auto dtype = cpu_tensor.scalar_type(); + const auto layout = cpu_tensor.layout(); + const torch::stable::Device cuda_dev(torch::headeronly::DeviceType::CUDA); + + // handle empty tensor + if (cpu_tensor.numel() == 0) { + return torch::stable::empty(cpu_tensor.sizes(), dtype, layout, cuda_dev); + } + + std::array is_pinned_stack{ + torch::stable::detail::from(cpu_tensor), + torch::stable::detail::from(std::nullopt)}; + TORCH_ERROR_CODE_CHECK(torch_call_dispatcher( + "aten::is_pinned", "", is_pinned_stack.data(), TORCH_ABI_VERSION)); + if (torch::stable::detail::to(is_pinned_stack[0])) { + // If CPU tensor is pinned, directly get the device pointer. + void* host_ptr = const_cast(cpu_tensor.mutable_data_ptr()); + void* device_ptr = nullptr; + cudaError_t err = cudaHostGetDevicePointer(&device_ptr, host_ptr, 0); + STD_TORCH_CHECK(err == cudaSuccess, "cudaHostGetDevicePointer failed: ", + cudaGetErrorString(err)); + + return torch::stable::from_blob( + device_ptr, cpu_tensor.sizes(), cpu_tensor.strides(), cuda_dev, dtype, + [base = cpu_tensor](void*) {}); // keep cpu tensor alive + } + + // If CPU tensor is not pinned, allocate a new pinned memory buffer. + torch::stable::Tensor contiguous_cpu = torch::stable::contiguous(cpu_tensor); + size_t nbytes = contiguous_cpu.numel() * contiguous_cpu.element_size(); + + void* host_ptr = nullptr; + cudaError_t err = cudaHostAlloc(&host_ptr, nbytes, cudaHostAllocMapped); + if (err != cudaSuccess) { + STD_TORCH_CHECK(false, "cudaHostAlloc failed: ", cudaGetErrorString(err)); + } + + err = cudaMemcpy(host_ptr, contiguous_cpu.const_data_ptr(), nbytes, + cudaMemcpyDefault); + if (err != cudaSuccess) { + cudaFreeHost(host_ptr); + STD_TORCH_CHECK(false, "cudaMemcpy failed: ", cudaGetErrorString(err)); + } + + void* device_ptr = nullptr; + err = cudaHostGetDevicePointer(&device_ptr, host_ptr, 0); + if (err != cudaSuccess) { + cudaFreeHost(host_ptr); + STD_TORCH_CHECK( + false, "cudaHostGetDevicePointer failed: ", cudaGetErrorString(err)); + } + + auto deleter = [host_ptr](void*) { cudaFreeHost(host_ptr); }; + + return torch::stable::from_blob(device_ptr, contiguous_cpu.sizes(), + contiguous_cpu.strides(), cuda_dev, + contiguous_cpu.scalar_type(), deleter); +} diff --git a/csrc/cutlass_extensions/common.cpp b/csrc/libtorch_stable/cutlass_extensions/common.cpp similarity index 90% rename from csrc/cutlass_extensions/common.cpp rename to csrc/libtorch_stable/cutlass_extensions/common.cpp index 3d2093ab9429..5bc9463bfa60 100644 --- a/csrc/cutlass_extensions/common.cpp +++ b/csrc/libtorch_stable/cutlass_extensions/common.cpp @@ -1,4 +1,4 @@ -#include "cutlass_extensions/common.hpp" +#include "common.hpp" int32_t get_sm_version_num() { int32_t major_capability, minor_capability; diff --git a/csrc/cutlass_extensions/common.hpp b/csrc/libtorch_stable/cutlass_extensions/common.hpp similarity index 100% rename from csrc/cutlass_extensions/common.hpp rename to csrc/libtorch_stable/cutlass_extensions/common.hpp diff --git a/csrc/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp b/csrc/libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp similarity index 100% rename from csrc/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp rename to csrc/libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp diff --git a/csrc/cutlass_extensions/torch_utils.hpp b/csrc/libtorch_stable/cutlass_extensions/torch_utils.hpp similarity index 100% rename from csrc/cutlass_extensions/torch_utils.hpp rename to csrc/libtorch_stable/cutlass_extensions/torch_utils.hpp diff --git a/csrc/cutlass_extensions/vllm_collective_builder.cuh b/csrc/libtorch_stable/cutlass_extensions/vllm_collective_builder.cuh similarity index 100% rename from csrc/cutlass_extensions/vllm_collective_builder.cuh rename to csrc/libtorch_stable/cutlass_extensions/vllm_collective_builder.cuh diff --git a/csrc/cutlass_extensions/vllm_numeric_conversion.cuh b/csrc/libtorch_stable/cutlass_extensions/vllm_numeric_conversion.cuh similarity index 100% rename from csrc/cutlass_extensions/vllm_numeric_conversion.cuh rename to csrc/libtorch_stable/cutlass_extensions/vllm_numeric_conversion.cuh diff --git a/csrc/libtorch_stable/dispatch_utils.h b/csrc/libtorch_stable/dispatch_utils.h index e9478236a0e1..cd67ac751c4f 100644 --- a/csrc/libtorch_stable/dispatch_utils.h +++ b/csrc/libtorch_stable/dispatch_utils.h @@ -30,6 +30,28 @@ THO_DISPATCH_SWITCH(TYPE, NAME, \ VLLM_STABLE_DISPATCH_CASE_FLOATING_TYPES(__VA_ARGS__)) +#define VLLM_STABLE_DISPATCH_CASE_INTEGRAL_TYPES(...) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::Byte, __VA_ARGS__) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::Char, __VA_ARGS__) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::Short, __VA_ARGS__) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::Int, __VA_ARGS__) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::Long, __VA_ARGS__) + +#define VLLM_STABLE_DISPATCH_CASE_INTEGRAL_AND_UNSIGNED_TYPES(...) \ + VLLM_STABLE_DISPATCH_CASE_INTEGRAL_TYPES(__VA_ARGS__) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::UInt16, __VA_ARGS__) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::UInt32, __VA_ARGS__) \ + THO_DISPATCH_CASE(torch::headeronly::ScalarType::UInt64, __VA_ARGS__) + +#define VLLM_STABLE_DISPATCH_INTEGRAL_TYPES(TYPE, NAME, ...) \ + THO_DISPATCH_SWITCH(TYPE, NAME, \ + VLLM_STABLE_DISPATCH_CASE_INTEGRAL_TYPES(__VA_ARGS__)) + +#define VLLM_STABLE_DISPATCH_INTEGRAL_AND_UNSIGNED_TYPES(TYPE, NAME, ...) \ + THO_DISPATCH_SWITCH( \ + TYPE, NAME, \ + VLLM_STABLE_DISPATCH_CASE_INTEGRAL_AND_UNSIGNED_TYPES(__VA_ARGS__)) + // FP8 type dispatch - ROCm uses FNUZ format, CUDA uses OCP format #ifdef USE_ROCM #define VLLM_STABLE_DISPATCH_CASE_FP8_TYPES(...) \ diff --git a/csrc/libtorch_stable/dsv3_fused_a_gemm.cu b/csrc/libtorch_stable/dsv3_fused_a_gemm.cu index bdf749ddfcf9..585004c047bf 100644 --- a/csrc/libtorch_stable/dsv3_fused_a_gemm.cu +++ b/csrc/libtorch_stable/dsv3_fused_a_gemm.cu @@ -328,7 +328,7 @@ struct GmemLoaderB { __device__ void issue_mainloop() { #if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900 - asm volatile("griddepcontrol.wait;"); + cudaGridDependencySynchronize(); #pragma unroll 1 for (int loop_idx = 0; loop_idx < k_iter_cnt; loop_idx++) { if (need_wait) { @@ -643,7 +643,7 @@ __global__ __launch_bounds__(256, 1) void fused_a_gemm_kernel( mma_computer.issue_mainloop(); mma_computer.epi(); } - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif } @@ -733,6 +733,8 @@ void dsv3_fused_a_gemm(torch::stable::Tensor& output, output.scalar_type() == torch::headeronly::ScalarType::BFloat16, "Only BFloat16 output dtype is supported"); + const torch::stable::accelerator::DeviceGuard device_guard( + mat_a.get_device_index()); STD_TORCH_CHECK(getSMVersion() >= 90, "required CUDA ARCH >= SM_90"); auto stream = get_current_cuda_stream(mat_a.get_device_index()); diff --git a/csrc/libtorch_stable/fp32_router_gemm.cu b/csrc/libtorch_stable/fp32_router_gemm.cu index 04397e0893c2..64393fad6198 100644 --- a/csrc/libtorch_stable/fp32_router_gemm.cu +++ b/csrc/libtorch_stable/fp32_router_gemm.cu @@ -100,7 +100,7 @@ __global__ __launch_bounds__(128, 1) void fp32_router_gemm_kernel( } #if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900) - asm volatile("griddepcontrol.wait;"); + cudaGridDependencySynchronize(); #endif for (int ki = 0; ki < k_iterations; ki++) { @@ -146,7 +146,7 @@ __global__ __launch_bounds__(128, 1) void fp32_router_gemm_kernel( } #if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif } @@ -175,49 +175,52 @@ void invokeFp32RouterGemm(float* output, InputT const* mat_a, } // --------------------------------------------------------------------------- -// Explicit instantiations: M=1..32, E=256, H=3072, for both input types +// Explicit instantiations: M=1..32, for both input types, for the supported +// (E, H) pairs: (256, 3072) [MiniMax-M2/M2.5] and (128, 6144) [MiniMax-M3]. // --------------------------------------------------------------------------- -#define INSTANTIATE(T, M) \ - template void invokeFp32RouterGemm( \ - float*, T const*, float const*, cudaStream_t); - -#define INSTANTIATE_ALL(T) \ - INSTANTIATE(T, 1) \ - INSTANTIATE(T, 2) \ - INSTANTIATE(T, 3) \ - INSTANTIATE(T, 4) \ - INSTANTIATE(T, 5) \ - INSTANTIATE(T, 6) \ - INSTANTIATE(T, 7) \ - INSTANTIATE(T, 8) \ - INSTANTIATE(T, 9) \ - INSTANTIATE(T, 10) \ - INSTANTIATE(T, 11) \ - INSTANTIATE(T, 12) \ - INSTANTIATE(T, 13) \ - INSTANTIATE(T, 14) \ - INSTANTIATE(T, 15) \ - INSTANTIATE(T, 16) \ - INSTANTIATE(T, 17) \ - INSTANTIATE(T, 18) \ - INSTANTIATE(T, 19) \ - INSTANTIATE(T, 20) \ - INSTANTIATE(T, 21) \ - INSTANTIATE(T, 22) \ - INSTANTIATE(T, 23) \ - INSTANTIATE(T, 24) \ - INSTANTIATE(T, 25) \ - INSTANTIATE(T, 26) \ - INSTANTIATE(T, 27) \ - INSTANTIATE(T, 28) \ - INSTANTIATE(T, 29) \ - INSTANTIATE(T, 30) \ - INSTANTIATE(T, 31) \ - INSTANTIATE(T, 32) - -INSTANTIATE_ALL(float) -INSTANTIATE_ALL(__nv_bfloat16) +#define INSTANTIATE(T, M, E, H) \ + template void invokeFp32RouterGemm(float*, T const*, \ + float const*, cudaStream_t); + +#define INSTANTIATE_ALL(T, E, H) \ + INSTANTIATE(T, 1, E, H) \ + INSTANTIATE(T, 2, E, H) \ + INSTANTIATE(T, 3, E, H) \ + INSTANTIATE(T, 4, E, H) \ + INSTANTIATE(T, 5, E, H) \ + INSTANTIATE(T, 6, E, H) \ + INSTANTIATE(T, 7, E, H) \ + INSTANTIATE(T, 8, E, H) \ + INSTANTIATE(T, 9, E, H) \ + INSTANTIATE(T, 10, E, H) \ + INSTANTIATE(T, 11, E, H) \ + INSTANTIATE(T, 12, E, H) \ + INSTANTIATE(T, 13, E, H) \ + INSTANTIATE(T, 14, E, H) \ + INSTANTIATE(T, 15, E, H) \ + INSTANTIATE(T, 16, E, H) \ + INSTANTIATE(T, 17, E, H) \ + INSTANTIATE(T, 18, E, H) \ + INSTANTIATE(T, 19, E, H) \ + INSTANTIATE(T, 20, E, H) \ + INSTANTIATE(T, 21, E, H) \ + INSTANTIATE(T, 22, E, H) \ + INSTANTIATE(T, 23, E, H) \ + INSTANTIATE(T, 24, E, H) \ + INSTANTIATE(T, 25, E, H) \ + INSTANTIATE(T, 26, E, H) \ + INSTANTIATE(T, 27, E, H) \ + INSTANTIATE(T, 28, E, H) \ + INSTANTIATE(T, 29, E, H) \ + INSTANTIATE(T, 30, E, H) \ + INSTANTIATE(T, 31, E, H) \ + INSTANTIATE(T, 32, E, H) + +INSTANTIATE_ALL(float, 256, 3072) +INSTANTIATE_ALL(__nv_bfloat16, 256, 3072) +INSTANTIATE_ALL(float, 128, 6144) +INSTANTIATE_ALL(__nv_bfloat16, 128, 6144) #undef INSTANTIATE_ALL #undef INSTANTIATE diff --git a/csrc/libtorch_stable/fp32_router_gemm_entry.cu b/csrc/libtorch_stable/fp32_router_gemm_entry.cu index 4baa740de930..fc09193c8643 100644 --- a/csrc/libtorch_stable/fp32_router_gemm_entry.cu +++ b/csrc/libtorch_stable/fp32_router_gemm_entry.cu @@ -22,36 +22,42 @@ inline int getSMVersion() { } // namespace -static constexpr int FP32_NUM_EXPERTS = 256; -static constexpr int FP32_HIDDEN_DIM = 3072; static constexpr int FP32_MAX_TOKENS = 32; +// Supported (hidden_dim, num_experts) pairs (must match the instantiations in +// fp32_router_gemm.cu): (3072, 256) for MiniMax-M2/M2.5, (6144, 128) for M3. +static inline bool fp32_router_gemm_supported(int hidden_dim, int num_experts) { + return (hidden_dim == 3072 && num_experts == 256) || + (hidden_dim == 6144 && num_experts == 128); +} + // Forward declarations — 4 template params must match fp32_router_gemm.cu template void invokeFp32RouterGemm(float* output, InputT const* mat_a, float const* mat_b, cudaStream_t stream); -// LoopUnroller templated on InputT -template +// LoopUnroller templated on InputT, kNumExperts and kHiddenDim +template struct Fp32LoopUnroller { static void unroll(int num_tokens, float* output, InputT const* mat_a, float const* mat_b, cudaStream_t stream) { if (num_tokens == kBegin) { - invokeFp32RouterGemm( + invokeFp32RouterGemm( output, mat_a, mat_b, stream); } else { - Fp32LoopUnroller::unroll(num_tokens, output, - mat_a, mat_b, stream); + Fp32LoopUnroller::unroll(num_tokens, output, mat_a, mat_b, stream); } } }; -template -struct Fp32LoopUnroller { +template +struct Fp32LoopUnroller { static void unroll(int num_tokens, float* output, InputT const* mat_a, float const* mat_b, cudaStream_t stream) { if (num_tokens == kEnd) { - invokeFp32RouterGemm( + invokeFp32RouterGemm( output, mat_a, mat_b, stream); } else { throw std::invalid_argument( @@ -60,6 +66,23 @@ struct Fp32LoopUnroller { } }; +// Dispatch over the supported (num_experts, hidden_dim) pairs. +template +void dispatchFp32RouterGemm(int num_experts, int hidden_dim, int num_tokens, + float* output, InputT const* mat_a, + float const* mat_b, cudaStream_t stream) { + if (num_experts == 256 && hidden_dim == 3072) { + Fp32LoopUnroller::unroll( + num_tokens, output, mat_a, mat_b, stream); + } else if (num_experts == 128 && hidden_dim == 6144) { + Fp32LoopUnroller::unroll( + num_tokens, output, mat_a, mat_b, stream); + } else { + throw std::invalid_argument( + "fp32_router_gemm: unsupported (hidden_dim, num_experts) pair"); + } +} + void fp32_router_gemm( torch::stable::Tensor& output, // [num_tokens, num_experts] torch::stable::Tensor const& mat_a, // [num_tokens, hidden_dim] @@ -85,10 +108,10 @@ void fp32_router_gemm( STD_TORCH_CHECK( mat_a.size(1) == mat_b.size(1), "fp32_router_gemm: mat_a and mat_b must have the same hidden_dim"); - STD_TORCH_CHECK(hidden_dim == FP32_HIDDEN_DIM, - "fp32_router_gemm: expected hidden_dim=3072"); - STD_TORCH_CHECK(num_experts == FP32_NUM_EXPERTS, - "fp32_router_gemm: expected num_experts=256"); + STD_TORCH_CHECK( + fp32_router_gemm_supported(hidden_dim, num_experts), + "fp32_router_gemm: supported (hidden_dim, num_experts) pairs are " + "(3072, 256) and (6144, 128)"); STD_TORCH_CHECK(num_tokens <= FP32_MAX_TOKENS, "fp32_router_gemm: num_tokens must be in [0, 32]"); STD_TORCH_CHECK( @@ -104,6 +127,8 @@ void fp32_router_gemm( return; } + const torch::stable::accelerator::DeviceGuard device_guard( + mat_a.get_device_index()); STD_TORCH_CHECK(getSMVersion() >= 90, "fp32_router_gemm: requires SM90+"); auto stream = get_current_cuda_stream(mat_a.get_device_index()); @@ -113,12 +138,13 @@ void fp32_router_gemm( if (mat_a.scalar_type() == torch::headeronly::ScalarType::BFloat16) { auto const* mat_a_ptr = reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()); - Fp32LoopUnroller<__nv_bfloat16, 1, FP32_MAX_TOKENS>::unroll( - num_tokens, out_ptr, mat_a_ptr, mat_b_ptr, stream); + dispatchFp32RouterGemm<__nv_bfloat16>(num_experts, hidden_dim, num_tokens, + out_ptr, mat_a_ptr, mat_b_ptr, + stream); } else { auto const* mat_a_ptr = reinterpret_cast(mat_a.data_ptr()); - Fp32LoopUnroller::unroll( - num_tokens, out_ptr, mat_a_ptr, mat_b_ptr, stream); + dispatchFp32RouterGemm(num_experts, hidden_dim, num_tokens, out_ptr, + mat_a_ptr, mat_b_ptr, stream); } } diff --git a/csrc/libtorch_stable/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu b/csrc/libtorch_stable/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu index a5f3f03de00c..7bc435b8e0da 100644 --- a/csrc/libtorch_stable/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu +++ b/csrc/libtorch_stable/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu @@ -18,7 +18,7 @@ * ROPE_DIM = 64 (RoPE applied to dims [NOPE_DIM, HEAD_DIM)) * NOPE_DIM = 448 * QUANT_BLOCK = 64 (UE8M0 FP8 quant block) - * FP8_MAX = 448.0f + * FP8_MAX = 224.0f on ROCm FNUZ / 448.0f on OCP * is_neox=false (GPT-J interleaved pairs) * cos_sin_cache layout [max_pos, rope_dim] = cos || sin (cos first, sin * second along last dim; each half is rope_dim/2 = 32 values) @@ -61,10 +61,11 @@ #ifdef USE_ROCM // ROCm-compatible FP8 conversion helpers __device__ __forceinline__ uint8_t rocm_cvt_float_to_fp8_e4m3(float val) { - #if defined(HIP_FP8_TYPE_OCP) - __hip_fp8_e4m3 fp8_val(val); - #else + // gfx942 uses FNUZ FP8; other ROCm targets use OCP E4M3. + #if defined(__gfx942__) __hip_fp8_e4m3_fnuz fp8_val(val); + #else + __hip_fp8_e4m3 fp8_val(val); #endif return reinterpret_cast(fp8_val); } @@ -90,7 +91,13 @@ constexpr int kQuantBlock = 64; constexpr int kNumQuantBlocks = kNopeDim / kQuantBlock; // 7 constexpr int kScaleBytesPerToken = kNumQuantBlocks + 1; // 8 (7 real + 1 pad) constexpr int kTokenDataBytes = kNopeDim + kRopeDim * 2; // 448 + 128 = 576 +// FNUZ on gfx942 / OCP elsewhere. FNUZ uses 224.0 (not the dtype's raw +// 240.0) to match the rest of vLLM's FNUZ pipeline. +#if defined(USE_ROCM) && defined(__gfx942__) +constexpr float kFp8Max = 224.0f; +#else constexpr float kFp8Max = 448.0f; +#endif #ifndef USE_ROCM // When num_tokens is less than this threshold, @@ -102,6 +109,35 @@ constexpr float NUM_TOKEN_CUTOFF = 1024; constexpr int kNumLanes = 32; constexpr int kElemsPerLane = kHeadDim / kNumLanes; // 16 +// Pack this lane's 16 fp32 elements into per-tensor E4M3 FP8 (one uint4 = 16 +// B), scaling by `scale` (a reciprocal scale) and saturating to ±448. Used by +// the FlashInfer full-cache path for both the Q and KV stores. +__device__ __forceinline__ uint4 packFp8E4M3x16(float const* values, + float const scale) { +#ifndef USE_ROCM + uint4 out; + auto* out2 = reinterpret_cast<__nv_fp8x2_storage_t*>(&out); + #pragma unroll + for (int i = 0; i < kElemsPerLane / 2; i++) { + float2 scaled = + make_float2(values[2 * i] * scale, values[2 * i + 1] * scale); + scaled.x = fminf(fmaxf(scaled.x, -kFp8Max), kFp8Max); + scaled.y = fminf(fmaxf(scaled.y, -kFp8Max), kFp8Max); + out2[i] = __nv_cvt_float2_to_fp8x2(scaled, __NV_SATFINITE, __NV_E4M3); + } + return out; +#else + uint8_t out_bytes[kElemsPerLane]; + #pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + float scaled = values[i] * scale; + scaled = fminf(fmaxf(scaled, -kFp8Max), kFp8Max); + out_bytes[i] = rocm_cvt_float_to_fp8_e4m3(scaled); + } + return *reinterpret_cast(out_bytes); +#endif +} + // ──────────────────────────────────────────────────────────────────────────── // Small inline helpers // ──────────────────────────────────────────────────────────────────────────── @@ -649,6 +685,257 @@ void launchFusedDeepseekV4QNormRopeKVRopeQuantInsert( #undef DISPATCH } +// ──────────────────────────────────────────────────────────────────────────── +// FlashInfer full-cache kernel +// ──────────────────────────────────────────────────────────────────────────── +// +// Sibling to the FlashMLA kernel above, used by the FlashInfer V4 sparse-MLA +// backend. Differences from the legacy path: +// * No Q head padding — output Q layout matches the input num_heads_q. +// * KV is written as a *contiguous* 512-wide row per token (token-strided), +// not the legacy UE8M0 paged layout with a separate scale tail. +// * Q/KV are stored either as bf16 or as per-tensor E4M3 FP8 (one global +// scale), selected by the STORE_Q_FP8 / STORE_KV_FP8 template flags. +// +// Grid: 1D, gridDim.x = ceil(num_tokens_full * (num_heads_q + 1) / warps). +// Each warp handles one (token, slot): slot < num_heads_q → Q, slot == +// num_heads_q → KV. +template +__global__ void fusedDeepseekV4FullCacheKernel( + scalar_t_in* __restrict__ q_inout, // [N, H, 512], in place (bf16) + uint8_t* __restrict__ q_fp8_out, // [N, H, 512] fp8, optional + int64_t const q_fp8_stride0, // elements (fp8 == bytes) + int64_t const q_fp8_stride1, // elements (fp8 == bytes) + scalar_t_in const* __restrict__ kv_in, // [N, 512] bf16 + uint8_t* __restrict__ k_cache, // contiguous bf16 or fp8 cache + int64_t const* __restrict__ slot_mapping, // [num_tokens_insert] i64 + int64_t const* __restrict__ position_ids, // [N] i64 + float const* __restrict__ cos_sin_cache, // [max_pos, 64] fp32 + float const* __restrict__ fp8_scale_ptr, // scalar, KV fp8 only + float const* __restrict__ q_fp8_scale_inv, // scalar, Q fp8 only + float const eps, + int const num_tokens_full, // = q.size(0) = kv.size(0) + int const num_tokens_insert, // = slot_mapping.size(0) + int const num_heads_q, // H (no padding) + int const cache_block_size, // tokens per cache block + int64_t const kv_block_stride, // bytes per cache block + int64_t const kv_token_stride) { // bytes per cache token +#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM) + if constexpr (std::is_same_v) { + return; + } else { +#endif + using Converter = vllm::_typeConvert; + int const warpsPerBlock = blockDim.x / 32; + int const warpId = threadIdx.x / 32; + int const laneId = threadIdx.x % 32; + int const globalWarpIdx = blockIdx.x * warpsPerBlock + warpId; + + int const slotsPerToken = num_heads_q + 1; + int const tokenIdx = globalWarpIdx / slotsPerToken; + int const slotIdx = globalWarpIdx % slotsPerToken; + if (tokenIdx >= num_tokens_full) return; + bool const isKV = (slotIdx == num_heads_q); + // KV branch: skip DP-padded tokens (no slot reserved for them). + if (isKV && tokenIdx >= num_tokens_insert) return; + +#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900) + cudaGridDependencySynchronize(); +#endif + + int const dim_base = laneId * kElemsPerLane; // in [0, 512) step 16 + scalar_t_in const* src_ptr; + if (isKV) { + src_ptr = kv_in + static_cast(tokenIdx) * kHeadDim + dim_base; + } else { + src_ptr = q_inout + + (static_cast(tokenIdx) * num_heads_q + slotIdx) * + kHeadDim + + dim_base; + } + uint4 const v0 = *reinterpret_cast(src_ptr); + uint4 const v1 = *reinterpret_cast(src_ptr + 8); + + // ── Decode bf16 → 16 fp32 registers ─────────────────────────────────── + float elements[kElemsPerLane]; + { + auto const* p0 = + reinterpret_cast(&v0); + auto const* p1 = + reinterpret_cast(&v1); +#pragma unroll + for (int i = 0; i < 4; i++) { + float2 f2 = Converter::convert(p0[i]); + elements[2 * i] = f2.x; + elements[2 * i + 1] = f2.y; + } +#pragma unroll + for (int i = 0; i < 4; i++) { + float2 f2 = Converter::convert(p1[i]); + elements[8 + 2 * i] = f2.x; + elements[8 + 2 * i + 1] = f2.y; + } + } + + // ── Q branch: RMSNorm (no weight) ───────────────────────────────────── + if (!isKV) { + float sumOfSquares = 0.0f; +#pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + sumOfSquares += elements[i] * elements[i]; + } + sumOfSquares = warpSum(sumOfSquares); + float const rms_rcp = + rsqrtf(sumOfSquares / static_cast(kHeadDim) + eps); +#pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + elements[i] = elements[i] * rms_rcp; + } + } + + // ── GPT-J RoPE on dims [NOPE_DIM, HEAD_DIM) ─────────────────────────── + bool const is_rope_lane = dim_base >= kNopeDim; + if (is_rope_lane) { + int64_t const pos = position_ids[tokenIdx]; + constexpr int kHalfRope = kRopeDim / 2; + float const* cos_ptr = cos_sin_cache + pos * kRopeDim; + float const* sin_ptr = cos_ptr + kHalfRope; + int const rope_local_base = dim_base - kNopeDim; + int const half_base = rope_local_base >> 1; + float4 const c0 = *reinterpret_cast(cos_ptr + half_base); + float4 const c1 = *reinterpret_cast(cos_ptr + half_base + 4); + float4 const s0 = *reinterpret_cast(sin_ptr + half_base); + float4 const s1 = *reinterpret_cast(sin_ptr + half_base + 4); + float const cos_arr[8] = {c0.x, c0.y, c0.z, c0.w, c1.x, c1.y, c1.z, c1.w}; + float const sin_arr[8] = {s0.x, s0.y, s0.z, s0.w, s1.x, s1.y, s1.z, s1.w}; +#pragma unroll + for (int p = 0; p < kElemsPerLane / 2; p++) { + float const x_even = elements[2 * p]; + float const x_odd = elements[2 * p + 1]; + elements[2 * p] = x_even * cos_arr[p] - x_odd * sin_arr[p]; + elements[2 * p + 1] = x_even * sin_arr[p] + x_odd * cos_arr[p]; + } + } + + // ── Store ───────────────────────────────────────────────────────────── + if (!isKV) { + if constexpr (STORE_Q_FP8) { + float const scale_inv = VLLM_LDG(q_fp8_scale_inv); + uint4 const out = packFp8E4M3x16(elements, scale_inv); + uint8_t* dst = q_fp8_out + + static_cast(tokenIdx) * q_fp8_stride0 + + static_cast(slotIdx) * q_fp8_stride1 + dim_base; + *reinterpret_cast(dst) = out; + } else { + uint4 out0, out1; + auto* po0 = reinterpret_cast(&out0); + auto* po1 = reinterpret_cast(&out1); +#pragma unroll + for (int i = 0; i < 4; i++) { + po0[i] = Converter::convert( + make_float2(elements[2 * i], elements[2 * i + 1])); + } +#pragma unroll + for (int i = 0; i < 4; i++) { + po1[i] = Converter::convert( + make_float2(elements[8 + 2 * i], elements[8 + 2 * i + 1])); + } + scalar_t_in* dst = + q_inout + + (static_cast(tokenIdx) * num_heads_q + slotIdx) * kHeadDim + + dim_base; + *reinterpret_cast(dst) = out0; + *reinterpret_cast(dst + 8) = out1; + } + } else { + int64_t const slot_id = slot_mapping[tokenIdx]; + if (slot_id >= 0) { + int64_t const block_idx = slot_id / cache_block_size; + int64_t const pos_in_block = slot_id % cache_block_size; + uint8_t* cache_row = + k_cache + block_idx * kv_block_stride + pos_in_block * kv_token_stride; + if constexpr (STORE_KV_FP8) { + float const inv_scale = 1.0f / VLLM_LDG(fp8_scale_ptr); + uint4 const out = packFp8E4M3x16(elements, inv_scale); + *reinterpret_cast(cache_row + dim_base) = out; + } else { + uint4 out0, out1; + auto* po0 = + reinterpret_cast(&out0); + auto* po1 = + reinterpret_cast(&out1); +#pragma unroll + for (int i = 0; i < 4; i++) { + po0[i] = Converter::convert( + make_float2(elements[2 * i], elements[2 * i + 1])); + } +#pragma unroll + for (int i = 0; i < 4; i++) { + po1[i] = Converter::convert( + make_float2(elements[8 + 2 * i], elements[8 + 2 * i + 1])); + } + scalar_t_in* dst = reinterpret_cast(cache_row) + dim_base; + *reinterpret_cast(dst) = out0; + *reinterpret_cast(dst + 8) = out1; + } + } + } + +#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900) + cudaTriggerProgrammaticLaunchCompletion(); +#endif +#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM) + } +#endif +} + +// Configure + launch helper shared by the bf16 and fp8 full-cache launchers. +template +static void launchFullCacheKernel( + scalar_t_in* q_inout, uint8_t* q_fp8_out, int64_t q_fp8_stride0, + int64_t q_fp8_stride1, scalar_t_in const* kv_in, uint8_t* k_cache, + int64_t const* slot_mapping, int64_t const* position_ids, + float const* cos_sin_cache, float const* fp8_scale, + float const* q_fp8_scale_inv, float const eps, int const num_tokens_full, + int const num_tokens_insert, int const num_heads_q, + int const cache_block_size, int64_t const kv_block_stride, + int64_t const kv_token_stride, char const* op_name, cudaStream_t stream) { + constexpr int kBlockSize = 256; + constexpr int kWarpsPerBlock = kBlockSize / 32; + int64_t const total_warps = + static_cast(num_tokens_full) * (num_heads_q + 1); + int const grid = + static_cast((total_warps + kWarpsPerBlock - 1) / kWarpsPerBlock); + auto* kernel = + fusedDeepseekV4FullCacheKernel; +#ifndef USE_ROCM + static int const sm_version = getSMVersion(); + STD_TORCH_CHECK(sm_version >= 80, op_name, + " requires sm_80+ (Ampere or newer); got sm_", sm_version); + cudaLaunchConfig_t config; + config.gridDim = dim3(grid); + config.blockDim = dim3(kBlockSize); + config.dynamicSmemBytes = 0; + config.stream = stream; + cudaLaunchAttribute attrs[1]; + attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization; + attrs[0].val.programmaticStreamSerializationAllowed = 1; + config.attrs = attrs; + config.numAttrs = (sm_version >= 90) ? 1 : 0; + cudaLaunchKernelEx(&config, kernel, q_inout, q_fp8_out, q_fp8_stride0, + q_fp8_stride1, kv_in, k_cache, slot_mapping, position_ids, + cos_sin_cache, fp8_scale, q_fp8_scale_inv, eps, + num_tokens_full, num_tokens_insert, num_heads_q, + cache_block_size, kv_block_stride, kv_token_stride); +#else + kernel<<>>( + q_inout, q_fp8_out, q_fp8_stride0, q_fp8_stride1, kv_in, k_cache, + slot_mapping, position_ids, cos_sin_cache, fp8_scale, q_fp8_scale_inv, + eps, num_tokens_full, num_tokens_insert, num_heads_q, cache_block_size, + kv_block_stride, kv_token_stride); +#endif +} + } // namespace deepseek_v4_fused_ops } // namespace vllm @@ -735,3 +1022,167 @@ torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert( }); return q_out; } + +// ──────────────────────────────────────────────────────────────────────────── +// FlashInfer full-cache torch ops +// ──────────────────────────────────────────────────────────────────────────── +void fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert( + torch::stable::Tensor& q, // [N, H, 512] bf16, in place + torch::stable::Tensor const& kv, // [N, 512] bf16, read-only + torch::stable::Tensor& k_cache, // [num_blocks, bs, 512] bf16 + torch::stable::Tensor const& slot_mapping, // [num_tokens_insert] int64 + torch::stable::Tensor const& position_ids, // [N] int64 + torch::stable::Tensor const& cos_sin_cache, // [max_pos, 64] float32 + double eps, int64_t cache_block_size) { + using torch::headeronly::ScalarType; + STD_TORCH_CHECK(q.device().is_cuda() && q.is_contiguous(), + "q must be contiguous CUDA"); + STD_TORCH_CHECK(kv.device().is_cuda() && kv.is_contiguous(), + "kv must be contiguous CUDA"); + STD_TORCH_CHECK(k_cache.device().is_cuda(), "k_cache must be CUDA"); + STD_TORCH_CHECK(slot_mapping.device().is_cuda() && + slot_mapping.scalar_type() == ScalarType::Long, + "slot_mapping must be int64 CUDA"); + STD_TORCH_CHECK(position_ids.device().is_cuda() && + position_ids.scalar_type() == ScalarType::Long, + "position_ids must be int64 CUDA"); + STD_TORCH_CHECK(cos_sin_cache.device().is_cuda() && + cos_sin_cache.scalar_type() == ScalarType::Float && + cos_sin_cache.dim() == 2 && cos_sin_cache.size(1) == 64, + "cos_sin_cache shape [max_pos, 64] float32"); + STD_TORCH_CHECK(q.dim() == 3 && q.size(2) == 512, "q shape [N, H, 512]"); + STD_TORCH_CHECK(kv.dim() == 2 && kv.size(1) == 512, "kv shape [N, 512]"); + STD_TORCH_CHECK(q.scalar_type() == ScalarType::BFloat16 && + kv.scalar_type() == ScalarType::BFloat16, + "q and kv must be bfloat16"); + STD_TORCH_CHECK(k_cache.dim() == 3 && k_cache.size(1) == cache_block_size && + k_cache.size(2) == 512 && k_cache.stride(2) == 1, + "k_cache shape [num_blocks, cache_block_size, 512] contiguous"); + STD_TORCH_CHECK(k_cache.scalar_type() == ScalarType::BFloat16, + "k_cache must be bfloat16"); + + int const num_tokens_full = static_cast(q.size(0)); + int const num_tokens_insert = static_cast(slot_mapping.size(0)); + STD_TORCH_CHECK(static_cast(kv.size(0)) == num_tokens_full && + static_cast(position_ids.size(0)) == num_tokens_full, + "q/kv/position_ids row counts must match"); + STD_TORCH_CHECK(num_tokens_insert <= num_tokens_full, + "slot_mapping must not exceed q row count"); + int const num_heads_q = static_cast(q.size(1)); + + const torch::stable::accelerator::DeviceGuard device_guard( + q.get_device_index()); + const cudaStream_t stream = get_current_cuda_stream(q.get_device_index()); + + // bf16 cache: 2 bytes/element -> byte strides for the uint8-addressed kernel. + int64_t const kv_block_stride = k_cache.stride(0) * 2; + int64_t const kv_token_stride = k_cache.stride(1) * 2; + + VLLM_STABLE_DISPATCH_HALF_TYPES( + q.scalar_type(), + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert", [&] { + vllm::deepseek_v4_fused_ops::launchFullCacheKernel( + reinterpret_cast(q.mutable_data_ptr()), nullptr, 0, 0, + reinterpret_cast(kv.const_data_ptr()), + reinterpret_cast(k_cache.mutable_data_ptr()), + slot_mapping.const_data_ptr(), + position_ids.const_data_ptr(), + cos_sin_cache.const_data_ptr(), nullptr, nullptr, + static_cast(eps), num_tokens_full, num_tokens_insert, + num_heads_q, static_cast(cache_block_size), kv_block_stride, + kv_token_stride, + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert", + stream); + }); +} + +void fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert( + torch::stable::Tensor const& q, // [N, H, 512] bf16, read-only + torch::stable::Tensor const& kv, // [N, 512] bf16, read-only + torch::stable::Tensor& q_fp8, // [N, H, 512] fp8 e4m3 + torch::stable::Tensor& k_cache, // [num_blocks, bs, 512] fp8 + torch::stable::Tensor const& slot_mapping, // [num_tokens_insert] int64 + torch::stable::Tensor const& position_ids, // [N] int64 + torch::stable::Tensor const& cos_sin_cache, // [max_pos, 64] float32 + torch::stable::Tensor const& fp8_scale, // scalar float32 (KV scale) + torch::stable::Tensor const& q_fp8_scale_inv, // scalar float32 (1 / Q scale) + double eps, int64_t cache_block_size) { + using torch::headeronly::ScalarType; + STD_TORCH_CHECK(q.device().is_cuda() && q.is_contiguous(), + "q must be contiguous CUDA"); + STD_TORCH_CHECK(kv.device().is_cuda() && kv.is_contiguous(), + "kv must be contiguous CUDA"); + STD_TORCH_CHECK(q_fp8.device().is_cuda() && q_fp8.is_contiguous() && + q_fp8.scalar_type() == ScalarType::Float8_e4m3fn && + q_fp8.dim() == 3 && q_fp8.size(0) == q.size(0) && + q_fp8.size(1) == q.size(1) && q_fp8.size(2) == q.size(2), + "q_fp8 must be a contiguous float8_e4m3fn tensor matching q"); + STD_TORCH_CHECK(k_cache.device().is_cuda(), "k_cache must be CUDA"); + STD_TORCH_CHECK(slot_mapping.device().is_cuda() && + slot_mapping.scalar_type() == ScalarType::Long, + "slot_mapping must be int64 CUDA"); + STD_TORCH_CHECK(position_ids.device().is_cuda() && + position_ids.scalar_type() == ScalarType::Long, + "position_ids must be int64 CUDA"); + STD_TORCH_CHECK(cos_sin_cache.device().is_cuda() && + cos_sin_cache.scalar_type() == ScalarType::Float && + cos_sin_cache.dim() == 2 && cos_sin_cache.size(1) == 64, + "cos_sin_cache shape [max_pos, 64] float32"); + STD_TORCH_CHECK(fp8_scale.device().is_cuda() && + fp8_scale.scalar_type() == ScalarType::Float && + fp8_scale.size(0) == 1, + "fp8_scale must be a scalar float32 CUDA tensor"); + STD_TORCH_CHECK(q_fp8_scale_inv.device().is_cuda() && + q_fp8_scale_inv.scalar_type() == ScalarType::Float && + q_fp8_scale_inv.size(0) == 1, + "q_fp8_scale_inv must be a scalar float32 CUDA tensor"); + STD_TORCH_CHECK(q.dim() == 3 && q.size(2) == 512, "q shape [N, H, 512]"); + STD_TORCH_CHECK(kv.dim() == 2 && kv.size(1) == 512, "kv shape [N, 512]"); + STD_TORCH_CHECK(q.scalar_type() == kv.scalar_type(), + "q and kv dtype must match"); + STD_TORCH_CHECK(k_cache.dim() == 3 && k_cache.size(1) == cache_block_size && + k_cache.size(2) == 512 && k_cache.stride(2) == 1, + "k_cache shape [num_blocks, cache_block_size, 512] contiguous"); + STD_TORCH_CHECK(k_cache.scalar_type() == ScalarType::Float8_e4m3fn, + "k_cache must be float8_e4m3fn"); + + int const num_tokens_full = static_cast(q.size(0)); + int const num_tokens_insert = static_cast(slot_mapping.size(0)); + STD_TORCH_CHECK(static_cast(kv.size(0)) == num_tokens_full && + static_cast(position_ids.size(0)) == num_tokens_full, + "q/kv/position_ids row counts must match"); + STD_TORCH_CHECK(num_tokens_insert <= num_tokens_full, + "slot_mapping must not exceed q row count"); + int const num_heads_q = static_cast(q.size(1)); + + const torch::stable::accelerator::DeviceGuard device_guard( + q.get_device_index()); + const cudaStream_t stream = get_current_cuda_stream(q.get_device_index()); + + VLLM_STABLE_DISPATCH_HALF_TYPES( + q.scalar_type(), + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert", [&] { + vllm::deepseek_v4_fused_ops::launchFullCacheKernel( + // q is read-only in the fp8 path (the kernel writes q_fp8); the + // launcher signature is non-const, so cast away const on the ptr. + reinterpret_cast( + const_cast(q.const_data_ptr())), + reinterpret_cast(q_fp8.mutable_data_ptr()), + q_fp8.stride(0), q_fp8.stride(1), + reinterpret_cast(kv.const_data_ptr()), + reinterpret_cast(k_cache.mutable_data_ptr()), + slot_mapping.const_data_ptr(), + position_ids.const_data_ptr(), + cos_sin_cache.const_data_ptr(), + fp8_scale.const_data_ptr(), + q_fp8_scale_inv.const_data_ptr(), static_cast(eps), + num_tokens_full, num_tokens_insert, num_heads_q, + static_cast(cache_block_size), + // fp8 cache: 1 byte/element -> stride already in bytes. + k_cache.stride(0), k_cache.stride(1), + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert", + stream); + }); +} diff --git a/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu b/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu new file mode 100644 index 000000000000..5429ab12d82c --- /dev/null +++ b/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu @@ -0,0 +1,776 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * SPDX-FileCopyrightText: Copyright contributors to the vLLM project + * + * Horizontally-fused MiniMax-M3 attention pre-processing kernel. + * + * Replaces the per-token Python sequence in + * ``MiniMaxM3SparseAttention.forward`` / ``MiniMaxM3Attention.forward``: + * + * q = q_norm(q); k = k_norm(k); q, k = rotary_emb(pos, q, k) + * index_q = index_q_norm(index_q); index_k = index_k_norm(index_k) + * index_q, index_k = rotary_emb(pos, index_q, index_k) + * _insert_kv(k, v, index_k) + * + * All branches share head_dim=128 and the *same* partial-NeoX RoPE table + * (``rotary_dim`` rotated, the trailing dims pass through). The four norms + * are Gemma-style RMSNorm (``x * rsqrt(mean(x^2)+eps) * (1 + weight)``) with + * independent weights. + * + * Everything lives in a single fused ``qkv`` tensor. The sparse layer's + * fused projection (MinimaxM3QKVParallelLinearWithIndexer) emits, per token:: + * + * [ q | k | v | index_q | index_k ] (the "5 results") + * + * while the dense layer emits just ``[ q | k | v ]``. The kernel reads the + * index branch straight out of that packed row -- no separate index tensors. + * + * One kernel, one grid; each warp owns one (token, head-slot) pair. Slot + * enumeration per token: + * [0, nq) Q heads -> norm(q_w) + RoPE, write + * qkv [nq, nq+nkv) K heads -> norm(k_w) + RoPE, write + * qkv + * (+ insert into key cache) + * [nq+nkv, nq+2*nkv) V heads -> insert into value cache + * IQ heads (niq) -> norm(iq_w) + RoPE, write iq + * IK (1) -> norm(ik_w) + RoPE + * (+ insert into index cache) + * + * The IQ/IK warps address the index_q/index_k sub-blocks *inside* qkv at the + * fixed physical offsets (nq+2*nkv)*128 and (nq+2*nkv+niq)*128. + * + * Dense vs sparse is a compile-time choice via the ``kIsSparse``/``kInsertKV`` + * template bools (3 instantiations: dense , sparse-profiling + * , sparse-serving ), so the index slots, the V slots + * and the cache inserts fold away entirely on paths that don't use them. The + * dense layer passes no caches/index: norm+RoPE happens in place and the + * generic ``Attention`` layer owns the cache write. + * + * Q/K and (sparse) index_q/index_k are all rewritten in place inside the fused + * ``qkv`` tensor. Caches (bf16) are scatter-written by slot. + */ + +#include +#include +#include + +#include "torch_utils.h" + +#include "../cuda_compat.h" +#include "type_convert.cuh" +#include "../attention/dtype_fp8.cuh" +#include "dispatch_utils.h" + +#ifdef USE_ROCM + #include "../quantization/w8a8/fp8/amd/quant_utils.cuh" +#else + #include "../quantization/w8a8/fp8/nvidia/quant_utils.cuh" +#endif + +// Direct float -> E4M3 FP8 conversion for the indexer Q / index-K outputs. +#ifndef USE_ROCM + #include +#else + #include +#endif + +#ifndef FINAL_MASK + #ifdef USE_ROCM + #define FINAL_MASK 0xffffffffffffffffULL + #else + #define FINAL_MASK 0xffffffffu + #endif +#endif + +#ifdef USE_ROCM +// ROCm-compatible direct float -> E4M3 FP8 conversion (mirrors the DeepSeek V4 +// fused kernel). +__device__ __forceinline__ uint8_t rocm_cvt_float_to_fp8_e4m3(float val) { + #if defined(HIP_FP8_TYPE_OCP) + __hip_fp8_e4m3 fp8_val(val); + #else + __hip_fp8_e4m3_fnuz fp8_val(val); + #endif + return reinterpret_cast(fp8_val); +} +#endif + +namespace vllm { +namespace minimax_m3_fused_ops { + +namespace { +inline int getSMVersion() { + auto* props = get_device_prop(); + return props->major * 10 + props->minor; +} +} // namespace + +// ──────────────────────────────────────────────────────────────────────────── +// Constants (hard-coded for MiniMax-M3-preview). +// ──────────────────────────────────────────────────────────────────────────── +constexpr int kHeadDim = 128; +constexpr int kNumLanes = 32; +constexpr int kElemsPerLane = kHeadDim / kNumLanes; // 4 + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── +__device__ __forceinline__ float warpReduceSum(float val) { +#pragma unroll + for (int mask = 16; mask > 0; mask >>= 1) { + val += __shfl_xor_sync(FINAL_MASK, val, mask, 32); + } + return val; +} + +// Gemma RMSNorm over the full head (no-op when ``weight == nullptr``), rounded +// back to scalar_t like the materialized unfused norm output, followed by +// partial NeoX RoPE on the leading ``rotary_dim`` dims. Each lane owns +// ``kElemsPerLane`` contiguous dims [laneId*4, laneId*4+4). +template +__device__ __forceinline__ void normAndRope( + float (&elems)[kElemsPerLane], int const laneId, float const eps, + scalar_t const* __restrict__ weight, // [kHeadDim] or nullptr (no norm) + bool const do_rope, int const rotary_dim, + scalar_t const* __restrict__ cos_ptr, // cos_sin_cache + pos*rotary_dim + bool const apply_norm) { + // ── Gemma RMSNorm: x * rsqrt(mean(x^2)+eps) * (1 + w) ────────────────── + if (apply_norm) { + float sumsq = 0.0f; +#pragma unroll + for (int i = 0; i < kElemsPerLane; i++) sumsq += elems[i] * elems[i]; + sumsq = warpReduceSum(sumsq); + float const rms_rcp = rsqrtf(sumsq / static_cast(kHeadDim) + eps); +#pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + int const dim = laneId * kElemsPerLane + i; + float const w = 1.0f + static_cast(weight[dim]); + elems[i] = elems[i] * rms_rcp * w; + } + } + + // ── Partial NeoX RoPE on dims [0, rotary_dim) ────────────────────────── + // half = rotary_dim/2. Pair (i, i+half) for i in [0, half). Lane L owns + // dims [4L, 4L+4); since half is a multiple of 4, a lane lies wholly in the + // first half (own=x[i]) or second half (own=x[i+half]); its partner lives + // ``half/4`` lanes away (XOR with that distance). + if (do_rope) { + int const half = rotary_dim / 2; + int const dim0 = laneId * kElemsPerLane; + bool const in_rope = dim0 < rotary_dim; + int const lane_xor = half / kElemsPerLane; // partner-lane distance + + float partner[kElemsPerLane]; +#pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + partner[i] = __shfl_xor_sync(FINAL_MASK, elems[i], lane_xor, 32); + } + if (in_rope) { + bool const first_half = dim0 < half; + int const i_base = first_half ? dim0 : (dim0 - half); // cos/sin index + scalar_t const* sin_ptr = cos_ptr + half; +#pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + float const c = static_cast(cos_ptr[i_base + i]); + float const s = static_cast(sin_ptr[i_base + i]); + if (first_half) { + elems[i] = elems[i] * c - partner[i] * s; + } else { + elems[i] = elems[i] * c + partner[i] * s; + } + } + } + } +} + +// Load 4 contiguous bf16 -> 4 fp32 registers. +template +__device__ __forceinline__ void loadElems(scalar_t const* __restrict__ src, + float (&elems)[kElemsPerLane]) { + using Converter = vllm::_typeConvert; + uint2 v = *reinterpret_cast(src); + auto const* p = + reinterpret_cast(&v); +#pragma unroll + for (int i = 0; i < kElemsPerLane / 2; i++) { + float2 f2 = Converter::convert(p[i]); + elems[2 * i] = f2.x; + elems[2 * i + 1] = f2.y; + } +} + +// Store 4 fp32 registers -> 4 contiguous bf16. +template +__device__ __forceinline__ void storeElems( + scalar_t* __restrict__ dst, float const (&elems)[kElemsPerLane]) { + using Converter = vllm::_typeConvert; + uint2 v; + auto* p = reinterpret_cast(&v); +#pragma unroll + for (int i = 0; i < kElemsPerLane / 2; i++) { + p[i] = Converter::convert(make_float2(elems[2 * i], elems[2 * i + 1])); + } + *reinterpret_cast(dst) = v; +} + +// Main K/V cache store. kAuto = unquantized (cache_t == scalar_t); fp8 cache +// dtypes use the scaled-convert path with identity scale. +template +__device__ __forceinline__ void storeCacheElems( + cache_t* __restrict__ dst, float const (&elems)[kElemsPerLane]) { + if constexpr (kv_dt == Fp8KVCacheDataType::kAuto) { + // kAuto means unquantized KV cache here: cache_t == scalar_t, so store the + // model dtype directly. FP8 cache dtypes use the conversion path below. + storeElems(reinterpret_cast(dst), elems); + } else { +#pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + dst[i] = fp8::scaled_convert(elems[i], 1.0f); + } + } +} + +// Store 4 fp32 registers -> 4 contiguous E4M3 FP8 bytes (direct cast, +// saturating to ±448). Used for the fp8 indexer-Q / index-K outputs; no scale +// (RMSNorm outputs are O(1) and the score path only needs relative block +// ordering). +__device__ __forceinline__ void storeElemsFp8( + uint8_t* __restrict__ dst, float const (&elems)[kElemsPerLane]) { + constexpr float kFp8Max = 448.0f; +#ifndef USE_ROCM + __nv_fp8x2_storage_t out2[kElemsPerLane / 2]; + #pragma unroll + for (int i = 0; i < kElemsPerLane / 2; i++) { + float2 vv = make_float2(elems[2 * i], elems[2 * i + 1]); + vv.x = fminf(fmaxf(vv.x, -kFp8Max), kFp8Max); + vv.y = fminf(fmaxf(vv.y, -kFp8Max), kFp8Max); + out2[i] = __nv_cvt_float2_to_fp8x2(vv, __NV_SATFINITE, __NV_E4M3); + } + *reinterpret_cast(dst) = *reinterpret_cast(out2); +#else + #pragma unroll + for (int i = 0; i < kElemsPerLane; i++) { + float vv = fminf(fmaxf(elems[i], -kFp8Max), kFp8Max); + dst[i] = rocm_cvt_float_to_fp8_e4m3(vv); + } +#endif +} + +// ──────────────────────────────────────────────────────────────────────────── +// Kernel +// ──────────────────────────────────────────────────────────────────────────── +// Grid: 1D, ceil(num_tokens * slots_per_token / warps_per_block). +// Each warp = one (token, slot). +// +// `kIsSparse` and `kInsertKV` are compile-time template bools, so all the +// branch decisions that distinguish the dense layer from the sparse layer +// (index slots, KV/index inserts, V slots) fold away per instantiation. +// Three instantiations are built: dense , sparse-profiling +// and sparse-serving . Slots per token: +// Q : nq (always — norm+RoPE) +// K : nkv (always — norm+RoPE; +K-cache insert) +// V : nkv only if kInsertKV (V-cache insert; no warps in dense) +// IQ: niq only if kIsSparse (norm+RoPE) +// IK: 1 only if kIsSparse (norm+RoPE; +index-cache insert) +// cache_t/kv_dt: main attention KV-cache dtype (auto/fp8). out_idx_t/kFp8Idx: +// indexer index-K cache + index-Q output dtype (scalar_t or e4m3 byte). +template +__global__ void fusedMiniMaxM3QNormRopeKVInsertKernel( + scalar_t* __restrict__ qkv, // [N, qkv_row] in/out (packs index if sparse) + scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr + out_idx_t* __restrict__ index_q_out, // [N, niq*128]; scalar_t or e4m3 byte + scalar_t const* __restrict__ q_norm_w, + scalar_t const* __restrict__ k_norm_w, + scalar_t const* __restrict__ iq_norm_w, + scalar_t const* __restrict__ ik_norm_w, + scalar_t const* __restrict__ cos_sin_cache, // [max_pos, rotary_dim] + int64_t const* __restrict__ positions, // [N] i64 + int64_t const* __restrict__ slot_mapping, // main K/V slots or nullptr + int64_t const* __restrict__ index_slot_mapping, // index K slots/nullptr + cache_t* __restrict__ kv_cache, // [nb,2,bs,nkv,128] or nullptr + out_idx_t* __restrict__ index_cache, // [nb*bs, 128]; scalar_t or e4m3 byte + float const eps, int const rotary_dim, int const num_tokens, int const nq, + int const nkv, int const niq, int const block_size, + // kv_cache strides (in elements) for logical shape [nb, 2, bs, nkv, 128]. + // The head_dim (last) dim is always innermost-contiguous (stride 1), so the + // NHD/HND layout choice is fully captured by these four strides: NHD keeps + // s_token < s_head, HND swaps them. dim_base addresses head_dim directly. + int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token, + int64_t const kv_s_head) { +#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM) + // _typeConvert is unavailable on pre-Ampere; the M3 kernel only + // runs with bf16/fp16 inputs in practice. Discard the bf16 body there. + if constexpr (std::is_same_v) { + return; + } else { +#endif + int const warpsPerBlock = blockDim.x / 32; + int const laneId = threadIdx.x % 32; + int const globalWarpIdx = blockIdx.x * warpsPerBlock + (threadIdx.x / 32); + + // Slot layout (compile-time gated: dense has neither V nor index slots). + int const v_slots = kInsertKV ? nkv : 0; + int const idx_slots = kIsSparse ? niq + 1 : 0; + int const slots_per_token = nq + nkv + v_slots + idx_slots; + + int const tokenIdx = globalWarpIdx / slots_per_token; + int const slot = globalWarpIdx % slots_per_token; + if (tokenIdx >= num_tokens) return; + + // Slot boundaries. + int const k_begin = nq; + int const v_begin = nq + nkv; // valid only when kInsertKV + int const iq_begin = nq + nkv + v_slots; // index block start + int const ik_slot = iq_begin + niq; // valid only when kIsSparse + + bool const isQ = slot < k_begin; + bool const isK = slot >= k_begin && slot < v_begin; + bool isV = false; + if constexpr (kInsertKV) isV = slot >= v_begin && slot < v_begin + nkv; + bool isIQ = false, isIK = false; + if constexpr (kIsSparse) { + isIQ = slot >= iq_begin && slot < ik_slot; + isIK = slot == ik_slot; + } + + int const dim_base = laneId * kElemsPerLane; + // Physical row width of qkv: the dense layer packs [q|k|v]; the sparse + // layer additionally packs [index_q (niq heads) | index_k (1 head)]. + int const qkv_row = (nq + 2 * nkv + (kIsSparse ? (niq + 1) : 0)) * kHeadDim; + + // ── Resolve source pointer + per-branch parameters. ──────────────────── + scalar_t* row_ptr = nullptr; // in-place output location + scalar_t const* norm_w = nullptr; // nullptr -> skip norm (V) + bool do_rope = true; + int head = 0; // kv head index for inserts + + if (isQ) { + row_ptr = + qkv + static_cast(tokenIdx) * qkv_row + slot * kHeadDim; + norm_w = q_norm_w; + } else if (isK) { + head = slot - k_begin; + row_ptr = + qkv + static_cast(tokenIdx) * qkv_row + slot * kHeadDim; + norm_w = k_norm_w; + } else if (isV) { + // qkv V section starts at slot index (nq + nkv): slot * kHeadDim is the + // correct in-tensor offset. + head = slot - v_begin; + row_ptr = + qkv + static_cast(tokenIdx) * qkv_row + slot * kHeadDim; + norm_w = nullptr; // V: no norm, no rope + do_rope = false; + } else if (isIQ) { + // index_q sub-block lives at physical offset (nq+2*nkv)*128 in qkv. + int const ih = slot - iq_begin; + row_ptr = qkv + static_cast(tokenIdx) * qkv_row + + (nq + 2 * nkv + ih) * kHeadDim; + norm_w = iq_norm_w; + } else { // isIK -- single shared index key at (nq+2*nkv+niq)*128. + row_ptr = qkv + static_cast(tokenIdx) * qkv_row + + (nq + 2 * nkv + niq) * kHeadDim; + norm_w = ik_norm_w; + } + + // Store destination. Q and index_q are gathered into dedicated contiguous + // output buffers (when provided) so the downstream SM100 sparse kernel's + // flat TMA descriptor can address them as [tokens*heads, head_dim]; this + // folds the de-interleaving into the store the kernel already does, instead + // of a separate q.contiguous() copy. Everything else stays in place. + scalar_t* store_ptr = row_ptr; + if (isQ && q_out != nullptr) { + store_ptr = q_out + static_cast(tokenIdx) * nq * kHeadDim + + slot * kHeadDim; + } else if (isIQ && index_q_out != nullptr) { + // bf16 index_q_out: gather here. fp8: written by the explicit fp8 store. + if constexpr (!kFp8Idx) { + store_ptr = index_q_out + + static_cast(tokenIdx) * niq * kHeadDim + + (slot - iq_begin) * kHeadDim; + } + } + + // PDL: wait for the predecessor kernel (the qkv-projection GEMM that + // produces ``qkv``) to finish before touching any global memory. No-op + // when PDL is not enabled on the launch. The CUDA runtime wrapper emits + // the griddepcontrol.wait PTX with the required memory clobber internally. +#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900) + cudaGridDependencySynchronize(); +#endif + + // ── Load -> norm+rope (fp32) -> store back in place. ─────────────────── + float elems[kElemsPerLane]; + loadElems(row_ptr + dim_base, elems); + + if (!isV) { + int64_t const pos = positions[tokenIdx]; + scalar_t const* cos_ptr = cos_sin_cache + pos * rotary_dim; + normAndRope(elems, laneId, eps, norm_w, do_rope, rotary_dim, + cos_ptr, /*apply_norm=*/norm_w != nullptr); + if constexpr (kFp8Idx) { + // index_q is e4m3 bytes; Q/K (and in-place index_k) stay scalar_t. + if (isIQ && index_q_out != nullptr) { + storeElemsFp8(index_q_out + + static_cast(tokenIdx) * niq * kHeadDim + + (slot - iq_begin) * kHeadDim + dim_base, + elems); + } else { + storeElems(store_ptr + dim_base, elems); + } + } else { + storeElems(store_ptr + dim_base, elems); + } + } + + // ── Cache inserts (sparse serving only). ─────────────────────────────── + if constexpr (kInsertKV) { + // Guard (not early-return) so every thread reaches the PDL trigger below. + int64_t const sm = (isK || isV) + ? slot_mapping[tokenIdx] + : (isIK ? index_slot_mapping[tokenIdx] : -1); + if (sm >= 0) { // skip padded / unscheduled tokens + if (isIK) { + if constexpr (kFp8Idx) { + storeElemsFp8(index_cache + sm * kHeadDim + dim_base, elems); + } else { + storeElems(index_cache + sm * kHeadDim + dim_base, elems); + } + } else if (isK || isV) { + // kv_cache logical shape [num_blocks, 2, block_size, nkv, head_dim]. + // Paging is logical (block = sm/block_size, token = sm%block_size); + // the physical NHD/HND layout is honoured via the passed strides. + int64_t const b = sm / block_size; + int64_t const t = sm % block_size; + int const kv = isK ? 0 : 1; + int64_t const off = + b * kv_s_block + kv * kv_s_kv + t * kv_s_token + head * kv_s_head; + storeCacheElems(kv_cache + off + dim_base, + elems); + } + } + } + + // PDL: signal that this kernel is done so a dependent successor may launch + // early. No-op when PDL is not enabled on the launch. +#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900) + cudaTriggerProgrammaticLaunchCompletion(); +#endif +#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM) + } +#endif +} + +// ──────────────────────────────────────────────────────────────────────────── +// Launch wrapper +// ──────────────────────────────────────────────────────────────────────────── +template +void launchFusedMiniMaxM3( + scalar_t* qkv, scalar_t* q_out, void* index_q_out, scalar_t const* q_norm_w, + scalar_t const* k_norm_w, scalar_t const* iq_norm_w, + scalar_t const* ik_norm_w, scalar_t const* cos_sin_cache, + int64_t const* positions, int64_t const* slot_mapping, + int64_t const* index_slot_mapping, cache_t* kv_cache, void* index_cache, + float const eps, int const rotary_dim, int const num_tokens, int const nq, + int const nkv, int const niq, int const block_size, + int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token, + int64_t const kv_s_head, bool const has_index, bool const insert_kv, + bool const fp8_idx, cudaStream_t stream) { + // Index outputs are scalar_t (bf16) or e4m3 bytes (uint8_t); reinterpret the + // void* pointers per instantiation in the LAUNCH macro. + // Slot count must match the kernel's compile-time gating. + int const v_slots = insert_kv ? nkv : 0; + int const idx_slots = has_index ? niq + 1 : 0; + int const slots_per_token = nq + nkv + v_slots + idx_slots; + + constexpr int kBlockSize = 256; + constexpr int kWarpsPerBlock = kBlockSize / 32; + int64_t const total_warps = + static_cast(num_tokens) * slots_per_token; + int const grid = + static_cast((total_warps + kWarpsPerBlock - 1) / kWarpsPerBlock); + if (grid == 0) return; + +#ifndef USE_ROCM + // PDL: enable programmatic stream serialization whenever the hardware + // supports it (SM90+). On pre-Hopper GPUs the attribute is unavailable, so + // leave numAttrs = 0 and launch as a regular kernel via cudaLaunchKernelEx. + static int const sm_version = getSMVersion(); + cudaLaunchConfig_t config; + config.gridDim = dim3(grid); + config.blockDim = dim3(kBlockSize); + config.dynamicSmemBytes = 0; + config.stream = stream; + cudaLaunchAttribute attrs[1]; + attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization; + attrs[0].val.programmaticStreamSerializationAllowed = 1; + config.attrs = attrs; + config.numAttrs = (sm_version >= 90) ? 1 : 0; + + #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \ + cudaLaunchKernelEx( \ + &config, \ + fusedMiniMaxM3QNormRopeKVInsertKernel, \ + qkv, q_out, reinterpret_cast(index_q_out), q_norm_w, k_norm_w, \ + iq_norm_w, ik_norm_w, cos_sin_cache, positions, slot_mapping, \ + index_slot_mapping, kv_cache, reinterpret_cast(index_cache), \ + eps, rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \ + kv_s_kv, kv_s_token, kv_s_head) +#else + // ROCm: standard kernel launch syntax (no PDL/stream serialization). + // clang-format off + #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \ + fusedMiniMaxM3QNormRopeKVInsertKernel \ + <<>>( \ + qkv, q_out, reinterpret_cast(index_q_out), q_norm_w, \ + k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \ + slot_mapping, index_slot_mapping, kv_cache, \ + reinterpret_cast(index_cache), eps, rotary_dim, \ + num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_kv, \ + kv_s_token, kv_s_head) + // clang-format on +#endif + + if (has_index) { + if (insert_kv) { + if (fp8_idx) { + LAUNCH(true, true, true, uint8_t); // sparse serving, fp8 index outputs + } else { + LAUNCH(true, true, false, scalar_t); // sparse serving, bf16 + } + } else { + if (fp8_idx) { + LAUNCH(true, false, true, uint8_t); // sparse profiling, fp8 index_q + } else { + LAUNCH(true, false, false, scalar_t); // sparse profiling, bf16 + } + } + } else { + // Dense layer: never has an index branch and never inserts here (the + // generic Attention layer owns the KV insert). + LAUNCH(false, false, false, scalar_t); + } +#undef LAUNCH +} + +} // namespace minimax_m3_fused_ops +} // namespace vllm + +#define CALL_FUSED_MINIMAX_M3(_RAW_T, CACHE_T, KV_DTYPE) \ + vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3( \ + reinterpret_cast(qkv.data_ptr()), \ + q_out.has_value() ? reinterpret_cast(q_out->data_ptr()) : nullptr, \ + index_q_out.has_value() \ + ? reinterpret_cast(index_q_out->data_ptr()) \ + : nullptr, \ + reinterpret_cast(q_norm_weight.data_ptr()), \ + reinterpret_cast(k_norm_weight.data_ptr()), \ + has_index ? reinterpret_cast(index_q_norm_weight->data_ptr()) \ + : nullptr, \ + has_index ? reinterpret_cast(index_k_norm_weight->data_ptr()) \ + : nullptr, \ + reinterpret_cast(cos_sin_cache.data_ptr()), \ + reinterpret_cast(positions.data_ptr()), \ + insert_kv ? reinterpret_cast(slot_mapping->data_ptr()) \ + : nullptr, \ + insert_kv ? reinterpret_cast( \ + effective_index_slot_mapping->data_ptr()) \ + : nullptr, \ + insert_kv ? reinterpret_cast(kv_cache->data_ptr()) : nullptr, \ + (insert_kv && has_index) \ + ? reinterpret_cast(index_cache->data_ptr()) \ + : nullptr, \ + static_cast(eps), static_cast(rotary_dim), num_tokens, nq, \ + nkv, niq, static_cast(block_size), kv_s_block, kv_s_kv, kv_s_token, \ + kv_s_head, has_index, insert_kv, fp8_idx, stream) + +// ──────────────────────────────────────────────────────────────────────────── +// Torch op wrapper +// ──────────────────────────────────────────────────────────────────────────── +void fused_minimax_m3_qknorm_rope_kv_insert( + torch::stable::Tensor& qkv, // [N, qkv_row] (packs index if sparse) + torch::stable::Tensor const& q_norm_weight, // [128] + torch::stable::Tensor const& k_norm_weight, // [128] + torch::stable::Tensor const& cos_sin_cache, // [max_pos, rotary_dim] + torch::stable::Tensor const& positions, // [N] i64 + int64_t num_heads, int64_t num_kv_heads, int64_t rotary_dim, double eps, + std::optional index_q_norm_weight, // [128] + std::optional index_k_norm_weight, // [128] + int64_t num_index_heads, // niq; 0 => dense + std::optional slot_mapping, // [N] i64 + std::optional index_slot_mapping, // [N] i64 + std::optional kv_cache, // [nb,2,bs,nkv,128] + std::optional index_cache, // [nb,bs,128] + int64_t block_size, + std::optional q_out, // [N, nq*128] contiguous + std::optional + index_q_out, // [N, niq*128] contiguous + const std::string& kv_cache_dtype) { + STD_TORCH_CHECK(qkv.is_cuda() && qkv.is_contiguous(), + "qkv must be contiguous CUDA"); + STD_TORCH_CHECK( + qkv.scalar_type() == torch::headeronly::ScalarType::Half || + qkv.scalar_type() == torch::headeronly::ScalarType::BFloat16, + "qkv must be float16 or bfloat16"); + STD_TORCH_CHECK( + positions.is_cuda() && + positions.scalar_type() == torch::headeronly::ScalarType::Long, + "positions must be int64 CUDA"); + STD_TORCH_CHECK(cos_sin_cache.is_cuda() && cos_sin_cache.is_contiguous(), + "cos_sin_cache must be contiguous CUDA"); + STD_TORCH_CHECK(cos_sin_cache.scalar_type() == qkv.scalar_type(), + "cos_sin_cache dtype must match qkv"); + STD_TORCH_CHECK( + cos_sin_cache.dim() == 2 && cos_sin_cache.size(1) == rotary_dim, + "cos_sin_cache shape [max_pos, rotary_dim]"); + + STD_TORCH_CHECK(q_norm_weight.scalar_type() == qkv.scalar_type() && + k_norm_weight.scalar_type() == qkv.scalar_type(), + "q/k norm weight dtype must match qkv"); + STD_TORCH_CHECK( + q_norm_weight.numel() == vllm::minimax_m3_fused_ops::kHeadDim && + k_norm_weight.numel() == vllm::minimax_m3_fused_ops::kHeadDim, + "q/k norm weight must have 128 elements"); + STD_TORCH_CHECK(rotary_dim > 0 && rotary_dim % 8 == 0 && + rotary_dim <= vllm::minimax_m3_fused_ops::kHeadDim, + "rotary_dim must be a positive multiple of 8 and <= 128"); + + int const num_tokens = static_cast(qkv.size(0)); + int const nq = static_cast(num_heads); + int const nkv = static_cast(num_kv_heads); + int const niq = static_cast(num_index_heads); + + // The sparse layer packs the index branch ([index_q (niq heads) | index_k + // (1 head)]) right after [q|k|v] in the same row; the dense layer does not. + bool const has_index = niq > 0; + bool const insert_kv = kv_cache.has_value(); + vllm::Fp8KVCacheDataType const kv_dt = + vllm::get_fp8_kv_cache_data_type(kv_cache_dtype); + int const kHeadDim = vllm::minimax_m3_fused_ops::kHeadDim; + int const expected_row = + (nq + 2 * nkv + (has_index ? niq + 1 : 0)) * kHeadDim; + STD_TORCH_CHECK(qkv.size(1) == expected_row, + "qkv last dim must be (num_heads + 2*num_kv_heads" + " + num_index_heads + 1) * 128 for sparse, " + "(num_heads + 2*num_kv_heads) * 128 for dense"); + + // Only the sparse layer inserts here (dense lets the generic Attention layer + // own the KV write); there is no dense+insert kernel instantiation. + STD_TORCH_CHECK( + !insert_kv || has_index, + "insert mode (kv_cache) requires the index branch (sparse layer)"); + if (has_index) { + STD_TORCH_CHECK( + index_q_norm_weight.has_value() && index_k_norm_weight.has_value(), + "index branch requires both index norm weights"); + STD_TORCH_CHECK(index_q_norm_weight->scalar_type() == qkv.scalar_type() && + index_k_norm_weight->scalar_type() == qkv.scalar_type(), + "index norm weights dtype must match qkv"); + STD_TORCH_CHECK(index_q_norm_weight->numel() == kHeadDim && + index_k_norm_weight->numel() == kHeadDim, + "index norm weights must have 128 elements"); + } + // kv_cache strides (logical shape [nb, 2, bs, nkv, head_dim]). Read straight + // off the tensor so the kernel honours whatever physical layout the attention + // backend allocated (NHD: stride order (0,1,2,3,4); HND: (0,1,3,2,4)). No new + // op argument is needed -- the strides ride along with the tensor itself. + int64_t kv_s_block = 0, kv_s_kv = 0, kv_s_token = 0, kv_s_head = 0; + torch::stable::Tensor const* effective_index_slot_mapping = nullptr; + if (insert_kv) { + STD_TORCH_CHECK( + slot_mapping.has_value() && slot_mapping->is_cuda() && + slot_mapping->scalar_type() == torch::headeronly::ScalarType::Long, + "insert mode requires int64 CUDA slot_mapping"); + STD_TORCH_CHECK( + !index_slot_mapping.has_value() || + (index_slot_mapping->is_cuda() && + index_slot_mapping->scalar_type() == + torch::headeronly::ScalarType::Long && + index_slot_mapping->numel() == slot_mapping->numel()), + "index_slot_mapping must be int64 CUDA with slot_mapping length"); + // Main attention KV cache: auto matches qkv, fp8 uses uint8 storage. + if (kv_dt == vllm::Fp8KVCacheDataType::kAuto) { + STD_TORCH_CHECK(kv_cache->scalar_type() == qkv.scalar_type(), + "auto kv_cache dtype must match qkv"); + } else { + STD_TORCH_CHECK( + kv_cache->scalar_type() == torch::headeronly::ScalarType::Byte, + "fp8 kv_cache must use uint8 storage"); + } + // Indexer index-K cache: independent dtype -- qkv dtype or fp8 e4m3. + STD_TORCH_CHECK( + index_cache.has_value() && + (index_cache->scalar_type() == qkv.scalar_type() || + index_cache->scalar_type() == + torch::headeronly::ScalarType::Float8_e4m3fn), + "insert mode requires index_cache matching qkv dtype or fp8 e4m3"); + STD_TORCH_CHECK(kv_cache->dim() == 5 && kv_cache->stride(4) == 1, + "kv_cache must be [nb,2,bs,nkv,head_dim] with contiguous " + "head_dim (stride(4)==1)"); + kv_s_block = kv_cache->stride(0); + kv_s_kv = kv_cache->stride(1); + kv_s_token = kv_cache->stride(2); + kv_s_head = kv_cache->stride(3); + effective_index_slot_mapping = index_slot_mapping.has_value() + ? &index_slot_mapping.value() + : &slot_mapping.value(); + } + // Optional contiguous gather targets: when given, the normed/roped q (and + // index_q) are written here instead of in place, so callers avoid a separate + // .contiguous() copy. index_q_out only makes sense on the sparse path. + if (q_out.has_value()) { + STD_TORCH_CHECK( + q_out->is_cuda() && q_out->is_contiguous() && + q_out->scalar_type() == qkv.scalar_type(), + "q_out must be a contiguous CUDA tensor matching qkv dtype"); + STD_TORCH_CHECK( + q_out->numel() == static_cast(num_tokens) * nq * kHeadDim, + "q_out must have num_tokens * num_heads * 128 elements"); + } + if (index_q_out.has_value()) { + STD_TORCH_CHECK( + has_index, + "index_q_out requires the index branch (num_index_heads > 0)"); + STD_TORCH_CHECK( + index_q_out->is_cuda() && index_q_out->is_contiguous() && + (index_q_out->scalar_type() == qkv.scalar_type() || + index_q_out->scalar_type() == + torch::headeronly::ScalarType::Float8_e4m3fn), + "index_q_out must be contiguous CUDA, qkv dtype or fp8 e4m3"); + STD_TORCH_CHECK(index_q_out->numel() == + static_cast(num_tokens) * niq * kHeadDim, + "index_q_out must have num_tokens * num_index_heads * 128 " + "elements"); + } + + // fp8 index path: the index-K cache and index-Q outputs are e4m3 bytes while + // q/k/v + q_out stay qkv dtype. Both index outputs must agree. + auto const kFp8 = torch::headeronly::ScalarType::Float8_e4m3fn; + bool const fp8_idx = + (index_cache.has_value() && index_cache->scalar_type() == kFp8) || + (index_q_out.has_value() && index_q_out->scalar_type() == kFp8); + if (fp8_idx) { + STD_TORCH_CHECK( + !index_cache.has_value() || index_cache->scalar_type() == kFp8, + "fp8 index path: index_cache must be fp8 e4m3"); + STD_TORCH_CHECK( + !index_q_out.has_value() || index_q_out->scalar_type() == kFp8, + "fp8 index path: index_q_out must be fp8 e4m3"); + } + + const torch::stable::accelerator::DeviceGuard device_guard( + qkv.get_device_index()); + auto stream = get_current_cuda_stream(qkv.get_device_index()); + + VLLM_STABLE_DISPATCH_HALF_TYPES( + qkv.scalar_type(), "fused_minimax_m3_qknorm_rope_kv_insert", [&] { + using st = scalar_t; + DISPATCH_BY_KV_CACHE_DTYPE(qkv.scalar_type(), kv_cache_dtype, + CALL_FUSED_MINIMAX_M3); + }); +} + +#undef CALL_FUSED_MINIMAX_M3 diff --git a/csrc/libtorch_stable/fused_qknorm_rope_kernel.cu b/csrc/libtorch_stable/fused_qknorm_rope_kernel.cu index c9b7ee9e4e9a..a8e6d32a1be6 100644 --- a/csrc/libtorch_stable/fused_qknorm_rope_kernel.cu +++ b/csrc/libtorch_stable/fused_qknorm_rope_kernel.cu @@ -22,7 +22,7 @@ #include "async_util.cuh" #include "../cuda_compat.h" -#include "../type_convert.cuh" +#include "type_convert.cuh" #include "dispatch_utils.h" #define CHECK_TYPE(x, st) \ diff --git a/csrc/libtorch_stable/layernorm_kernels.cu b/csrc/libtorch_stable/layernorm_kernels.cu index 37df6be329fb..878b44df936c 100644 --- a/csrc/libtorch_stable/layernorm_kernels.cu +++ b/csrc/libtorch_stable/layernorm_kernels.cu @@ -2,16 +2,16 @@ #include "torch_utils.h" -#include "../cub_helpers.h" +#include "cub_helpers.h" #include "../core/batch_invariant.hpp" -#include "../type_convert.cuh" +#include "type_convert.cuh" #include "dispatch_utils.h" #include "quantization/vectorization_utils.cuh" namespace vllm { // TODO(woosuk): Further optimize this kernel. -template +template __global__ void rms_norm_kernel( scalar_t* __restrict__ out, // [..., hidden_size] const scalar_t* __restrict__ input, // [..., hidden_size] @@ -20,20 +20,26 @@ __global__ void rms_norm_kernel( const int64_t input_stride_d4, // input.stride(-4) const int64_t input_shape_d2, // input.size(-2) const int64_t input_shape_d3, // input.size(-3) - const scalar_t* __restrict__ weight, // [hidden_size] + const scalar_t* __restrict__ weight, // [hidden_size] or + // [num_groups, hidden_size]; + // null if !HasWeight + const int64_t weight_stride, // 0 or weight.stride(0) const float epsilon, const int num_tokens, const int hidden_size) { __shared__ float s_variance; float variance = 0.0f; const scalar_t* input_row; + const scalar_t* weight_row; if constexpr (NUM_DIMS == 2) { // 2D for layernorm normal case [batch_size, hidden] input_row = input + blockIdx.x * input_stride_d2; + weight_row = weight + blockIdx.x * weight_stride; } else if constexpr (NUM_DIMS == 3) { // 3D for q/k norm [batch_size, num_heads, head_size] int batch_idx = blockIdx.x / input_shape_d2; int head_idx = blockIdx.x % input_shape_d2; input_row = input + batch_idx * input_stride_d3 + head_idx * input_stride_d2; + weight_row = weight + batch_idx * weight_stride; } else if constexpr (NUM_DIMS == 4) { // 4D for transformers model_impl qk norm [batch, seq, head, head_dim] int batch_idx = blockIdx.x / (input_shape_d3 * input_shape_d2); @@ -42,6 +48,7 @@ __global__ void rms_norm_kernel( int head_idx = remaining % input_shape_d2; input_row = input + batch_idx * input_stride_d4 + seq_idx * input_stride_d3 + head_idx * input_stride_d2; + weight_row = weight + batch_idx * weight_stride; } auto vec_op = [&variance](const vec_n_t& vec) { @@ -69,16 +76,24 @@ __global__ void rms_norm_kernel( scalar_t* out_row = out + blockIdx.x * hidden_size; auto* v_in = reinterpret_cast*>(input_row); - auto* v_w = reinterpret_cast*>(weight); + auto* v_w = reinterpret_cast*>(weight_row); auto* v_out = reinterpret_cast*>(out_row); for (int i = threadIdx.x; i < hidden_size / VEC_SIZE; i += blockDim.x) { vec_n_t dst; vec_n_t src1 = v_in[i]; - vec_n_t src2 = v_w[i]; + vec_n_t src2; + if constexpr (HasWeight) { + src2 = v_w[i]; + } #pragma unroll for (int j = 0; j < VEC_SIZE; j++) { float x = static_cast(src1.val[j]); - dst.val[j] = static_cast(x * s_variance) * src2.val[j]; + if constexpr (HasWeight) { + float w = static_cast(src2.val[j]); + dst.val[j] = static_cast(x * s_variance * w); + } else { + dst.val[j] = static_cast(x * s_variance); + } } v_out[i] = dst; } @@ -88,13 +103,13 @@ __global__ void rms_norm_kernel( Additional optimizations we can make in this case are packed and vectorized operations, which help with the memory latency bottleneck. */ -template +template __global__ std::enable_if_t<(width > 0) && _typeConvert::exists> fused_add_rms_norm_kernel( scalar_t* __restrict__ input, // [..., hidden_size] const int64_t input_stride, scalar_t* __restrict__ residual, // [..., hidden_size] - const scalar_t* __restrict__ weight, // [hidden_size] + const scalar_t* __restrict__ weight, // [hidden_size], null if !HasWeight const float epsilon, const int num_tokens, const int hidden_size) { // Sanity checks on our vector struct and type-punned pointer arithmetic static_assert(std::is_pod_v<_f16Vec>); @@ -136,13 +151,22 @@ fused_add_rms_norm_kernel( int id = blockIdx.x * vec_hidden_size + idx; int64_t strided_id = blockIdx.x * vec_input_stride + idx; _f16Vec res = residual_v[id]; - _f16Vec w = weight_v[idx]; _f16Vec out; using Converter = _typeConvert; + if constexpr (HasWeight) { + _f16Vec w = weight_v[idx]; +#pragma unroll + for (int j = 0; j < width; ++j) { + float x = Converter::convert(res.data[j]); + float wf = Converter::convert(w.data[j]); + out.data[j] = Converter::convert(x * s_variance * wf); + } + } else { #pragma unroll - for (int j = 0; j < width; ++j) { - float x = Converter::convert(res.data[j]); - out.data[j] = Converter::convert(x * s_variance) * w.data[j]; + for (int j = 0; j < width; ++j) { + float x = Converter::convert(res.data[j]); + out.data[j] = Converter::convert(x * s_variance); + } } input_v[strided_id] = out; } @@ -151,13 +175,13 @@ fused_add_rms_norm_kernel( /* Generic fused_add_rms_norm_kernel The width field is not used here but necessary for other specializations. */ -template +template __global__ std::enable_if_t<(width == 0) || !_typeConvert::exists> fused_add_rms_norm_kernel( scalar_t* __restrict__ input, // [..., hidden_size] const int64_t input_stride, scalar_t* __restrict__ residual, // [..., hidden_size] - const scalar_t* __restrict__ weight, // [hidden_size] + const scalar_t* __restrict__ weight, // [hidden_size], null if !HasWeight const float epsilon, const int num_tokens, const int hidden_size) { __shared__ float s_variance; float variance = 0.0f; @@ -181,23 +205,38 @@ fused_add_rms_norm_kernel( for (int idx = threadIdx.x; idx < hidden_size; idx += blockDim.x) { float x = (float)residual[blockIdx.x * hidden_size + idx]; - input[blockIdx.x * input_stride + idx] = - (scalar_t)(x * s_variance) * weight[idx]; + if constexpr (HasWeight) { + float w = (float)weight[idx]; + input[blockIdx.x * input_stride + idx] = (scalar_t)(x * s_variance * w); + } else { + input[blockIdx.x * input_stride + idx] = (scalar_t)(x * s_variance); + } } } } // namespace vllm -void rms_norm(torch::stable::Tensor& out, // [..., hidden_size] - torch::stable::Tensor& input, // [..., hidden_size] - torch::stable::Tensor& weight, // [hidden_size] - double epsilon) { +void rms_norm(torch::stable::Tensor& out, // [..., hidden_size] + torch::stable::Tensor& input, // [..., hidden_size] + std::optional weight, double epsilon) { STD_TORCH_CHECK(out.is_contiguous()); if (input.stride(-1) != 1) { input = torch::stable::contiguous(input); } STD_TORCH_CHECK(input.stride(-1) == 1); - STD_TORCH_CHECK(weight.is_contiguous()); + int64_t weight_stride = 0; + if (weight.has_value()) { + STD_TORCH_CHECK(weight->is_contiguous()); + if (weight->dim() == 1) { + STD_TORCH_CHECK(weight->size(0) == input.size(-1)); + } else if (weight->dim() == 2) { + STD_TORCH_CHECK(weight->size(0) == input.size(0)); + STD_TORCH_CHECK(weight->size(-1) == input.size(-1)); + weight_stride = weight->stride(0); + } else { + STD_TORCH_CHECK(false, "rms_norm weight must be 1D or 2D"); + } + } int hidden_size = input.size(-1); @@ -215,46 +254,69 @@ void rms_norm(torch::stable::Tensor& out, // [..., hidden_size] const torch::stable::accelerator::DeviceGuard device_guard( input.get_device_index()); const cudaStream_t stream = get_current_cuda_stream(); + const bool has_weight = weight.has_value(); VLLM_STABLE_DISPATCH_RANK234(num_dims, [&] { VLLM_STABLE_DISPATCH_FLOATING_TYPES( input.scalar_type(), "rms_norm_kernel", [&] { + const scalar_t* weight_ptr = + has_weight ? weight->const_data_ptr() : nullptr; const int calculated_vec_size = std::gcd(16 / sizeof(scalar_t), hidden_size); const int block_size = std::min(hidden_size / calculated_vec_size, max_block_size); dim3 block(block_size); VLLM_STABLE_DISPATCH_VEC_SIZE(calculated_vec_size, [&] { - vllm::rms_norm_kernel - <<>>( - out.mutable_data_ptr(), - input.const_data_ptr(), input_stride_d2, - input_stride_d3, input_stride_d4, input_shape_d2, - input_shape_d3, weight.const_data_ptr(), epsilon, - num_tokens, hidden_size); + if (has_weight) { + vllm::rms_norm_kernel + <<>>( + out.mutable_data_ptr(), + input.const_data_ptr(), input_stride_d2, + input_stride_d3, input_stride_d4, input_shape_d2, + input_shape_d3, weight_ptr, weight_stride, epsilon, + num_tokens, hidden_size); + } else { + vllm::rms_norm_kernel + <<>>( + out.mutable_data_ptr(), + input.const_data_ptr(), input_stride_d2, + input_stride_d3, input_stride_d4, input_shape_d2, + input_shape_d3, weight_ptr, /*weight_stride=*/0, epsilon, + num_tokens, hidden_size); + } }); }); }); } -#define LAUNCH_FUSED_ADD_RMS_NORM(width) \ - VLLM_STABLE_DISPATCH_FLOATING_TYPES( \ - input.scalar_type(), "fused_add_rms_norm_kernel", [&] { \ - vllm::fused_add_rms_norm_kernel \ - <<>>( \ - input.mutable_data_ptr(), input_stride, \ - residual.mutable_data_ptr(), \ - weight.const_data_ptr(), epsilon, num_tokens, \ - hidden_size); \ +#define LAUNCH_FUSED_ADD_RMS_NORM(width, has_weight) \ + VLLM_STABLE_DISPATCH_FLOATING_TYPES( \ + input.scalar_type(), "fused_add_rms_norm_kernel", [&] { \ + if (has_weight) { \ + vllm::fused_add_rms_norm_kernel \ + <<>>( \ + input.mutable_data_ptr(), input_stride, \ + residual.mutable_data_ptr(), \ + weight->const_data_ptr(), epsilon, num_tokens, \ + hidden_size); \ + } else { \ + vllm::fused_add_rms_norm_kernel \ + <<>>( \ + input.mutable_data_ptr(), input_stride, \ + residual.mutable_data_ptr(), nullptr, epsilon, \ + num_tokens, hidden_size); \ + } \ }); void fused_add_rms_norm(torch::stable::Tensor& input, // [..., hidden_size] torch::stable::Tensor& residual, // [..., hidden_size] - torch::stable::Tensor& weight, // [hidden_size] + std::optional weight, double epsilon) { - STD_TORCH_CHECK(weight.scalar_type() == input.scalar_type()); STD_TORCH_CHECK(input.scalar_type() == residual.scalar_type()); STD_TORCH_CHECK(residual.is_contiguous()); - STD_TORCH_CHECK(weight.is_contiguous()); + if (weight.has_value()) { + STD_TORCH_CHECK(weight->scalar_type() == input.scalar_type()); + STD_TORCH_CHECK(weight->is_contiguous()); + } int hidden_size = input.size(-1); int64_t input_stride = input.stride(-2); int num_tokens = input.numel() / hidden_size; @@ -269,30 +331,33 @@ void fused_add_rms_norm(torch::stable::Tensor& input, // [..., hidden_size] const torch::stable::accelerator::DeviceGuard device_guard( input.get_device_index()); const cudaStream_t stream = get_current_cuda_stream(); - /*If the tensor types are FP16/BF16, try to use the optimized kernel - with packed + vectorized ops. - Max optimization is achieved with a width-8 vector of FP16/BF16s - since we can load at most 128 bits at once in a global memory op. - However, this requires each tensor's data to be aligned to 16 - bytes. - */ + constexpr int vector_width = 8; + constexpr int req_alignment_bytes = vector_width * 2; auto inp_ptr = reinterpret_cast(input.data_ptr()); auto res_ptr = reinterpret_cast(residual.data_ptr()); - auto wt_ptr = reinterpret_cast(weight.data_ptr()); - constexpr int vector_width = 8; - constexpr int req_alignment_bytes = - vector_width * 2; // vector_width * sizeof(bfloat16 or float16) (float32 - // falls back to non-vectorized version anyway) - bool ptrs_are_aligned = inp_ptr % req_alignment_bytes == 0 && - res_ptr % req_alignment_bytes == 0 && - wt_ptr % req_alignment_bytes == 0; bool offsets_are_multiple_of_vector_width = hidden_size % vector_width == 0 && input_stride % vector_width == 0; bool batch_invariant_launch = vllm::vllm_is_batch_invariant(); - if (ptrs_are_aligned && offsets_are_multiple_of_vector_width && - !batch_invariant_launch) { - LAUNCH_FUSED_ADD_RMS_NORM(8); + const bool has_weight = weight.has_value(); + if (has_weight) { + auto wt_ptr = reinterpret_cast(weight->data_ptr()); + bool ptrs_are_aligned = inp_ptr % req_alignment_bytes == 0 && + res_ptr % req_alignment_bytes == 0 && + wt_ptr % req_alignment_bytes == 0; + if (ptrs_are_aligned && offsets_are_multiple_of_vector_width && + !batch_invariant_launch) { + LAUNCH_FUSED_ADD_RMS_NORM(8, true); + } else { + LAUNCH_FUSED_ADD_RMS_NORM(0, true); + } } else { - LAUNCH_FUSED_ADD_RMS_NORM(0); + bool ptrs_are_aligned = inp_ptr % req_alignment_bytes == 0 && + res_ptr % req_alignment_bytes == 0; + if (ptrs_are_aligned && offsets_are_multiple_of_vector_width && + !batch_invariant_launch) { + LAUNCH_FUSED_ADD_RMS_NORM(8, false); + } else { + LAUNCH_FUSED_ADD_RMS_NORM(0, false); + } } } diff --git a/csrc/libtorch_stable/layernorm_quant_kernels.cu b/csrc/libtorch_stable/layernorm_quant_kernels.cu index 32f3495f4e94..f3bf8882e775 100644 --- a/csrc/libtorch_stable/layernorm_quant_kernels.cu +++ b/csrc/libtorch_stable/layernorm_quant_kernels.cu @@ -9,10 +9,10 @@ #include "torch_utils.h" -#include "../cub_helpers.h" +#include "cub_helpers.h" #include "../core/batch_invariant.hpp" #include "../quantization/w8a8/fp8/common.cuh" -#include "../type_convert.cuh" +#include "type_convert.cuh" #include "dispatch_utils.h" #include "quantization/vectorization_utils.cuh" @@ -66,8 +66,13 @@ __global__ void rms_norm_static_fp8_quant_kernel( #pragma unroll for (int j = 0; j < VEC_SIZE; j++) { float x = static_cast(src1.val[j]); - // Multiply in weight's native dtype to match rms_norm_kernel. - scalar_t out_norm = static_cast(x * s_variance) * src2.val[j]; + float w = static_cast(src2.val[j]); + // Round normalized result through scalar_t to match the precision of the + // unfused composite (rms_norm writes scalar_t, then + // static_scaled_fp8_quant re-loads it as float before FP8 conversion). + // Without this round, the fused path is strictly more accurate and + // disagrees with the composite at exact E4M3 quantization tie boundaries. + scalar_t out_norm = static_cast(x * s_variance * w); out[blockIdx.x * hidden_size + idx * VEC_SIZE + j] = scaled_fp8_conversion(static_cast(out_norm), scale_inv); @@ -137,8 +142,12 @@ fused_add_rms_norm_static_fp8_quant_kernel( #pragma unroll for (int i = 0; i < width; ++i) { float x = Converter::convert(res.data[i]); - // Multiply in weight's native dtype to match fused_add_rms_norm_kernel. - HipT out_norm_h = Converter::convert(x * s_variance) * w.data[i]; + float wf = Converter::convert(w.data[i]); + // See note in rms_norm_static_fp8_quant_kernel: round through scalar_t + // to match the unfused composite path at FP8 boundaries. We use the + // backend's hip_type for the intermediate since c10::Half/BFloat16 has + // ambiguous conversions on CUDA and no implicit conversion on ROCm. + HipT out_norm_h = Converter::convert(x * s_variance * wf); out[id * width + i] = scaled_fp8_conversion( Converter::convert(out_norm_h), scale_inv); } @@ -183,8 +192,10 @@ fused_add_rms_norm_static_fp8_quant_kernel( for (int idx = threadIdx.x; idx < hidden_size; idx += blockDim.x) { float x = (float)residual[blockIdx.x * hidden_size + idx]; - // Multiply in weight's native dtype to match fused_add_rms_norm_kernel. - scalar_t out_norm = static_cast(x * s_variance) * weight[idx]; + float w = (float)weight[idx]; + // See note in rms_norm_static_fp8_quant_kernel: round through scalar_t + // to match the unfused composite path at FP8 boundaries. + scalar_t out_norm = static_cast(x * s_variance * w); out[blockIdx.x * hidden_size + idx] = scaled_fp8_conversion( static_cast(out_norm), scale_inv); } diff --git a/csrc/libtorch_stable/minimax_reduce_rms_kernel.cu b/csrc/libtorch_stable/minimax_reduce_rms_kernel.cu index d9af0f5efe0f..58d61b353d6d 100644 --- a/csrc/libtorch_stable/minimax_reduce_rms_kernel.cu +++ b/csrc/libtorch_stable/minimax_reduce_rms_kernel.cu @@ -249,7 +249,7 @@ __global__ void __launch_bounds__(1024) LamportComm comm(params.workspace, params.rank); int clear_access = comm.clear_size / kElemsPerAccess; #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.wait;"); + cudaGridDependencySynchronize(); #endif for (int idx = access_id; idx < tot_access; idx += access_stride, token_id += token_stride) { @@ -313,7 +313,7 @@ __global__ void __launch_bounds__(1024) } comm.update(params.size_q * NRanks); #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif } @@ -384,7 +384,7 @@ __global__ void __launch_bounds__(1024) DType norm_weight[kElemsPerAccess]{}; #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.wait;"); + cudaGridDependencySynchronize(); #endif if (is_q) { if (is_valid_q) { @@ -596,7 +596,7 @@ __global__ void __launch_bounds__(1024) } } // end group loop #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif int clear_access = static_cast(comm.clear_size / kElemsPerAccess); @@ -804,35 +804,6 @@ void minimax_reduce_rms_op(MiniMaxReduceRMSParams const& params) { } // namespace tensorrt_llm } // namespace vllm -torch::stable::Tensor minimax_allreduce_rms( - torch::stable::Tensor const& input, - torch::stable::Tensor const& norm_weight, torch::stable::Tensor workspace, - int64_t const rank, int64_t const nranks, double const eps) { - const torch::stable::accelerator::DeviceGuard device_guard( - input.get_device_index()); - auto allreduce_params = vllm::tensorrt_llm::MiniMaxReduceRMSParams(); - - allreduce_params.nranks = static_cast(nranks); - allreduce_params.rank = static_cast(rank); - allreduce_params.dtype = input.scalar_type(); - allreduce_params.size_q = static_cast(input.numel()); - allreduce_params.hidden_dim = static_cast(input.size(-1)); - allreduce_params.stride_q = allreduce_params.hidden_dim; - allreduce_params.workspace = - reinterpret_cast(workspace.mutable_data_ptr()); - allreduce_params.allreduce_in = const_cast(input.const_data_ptr()); - allreduce_params.rms_gamma = const_cast(norm_weight.const_data_ptr()); - allreduce_params.rms_eps = static_cast(eps); - allreduce_params.stream = get_current_cuda_stream(input.get_device_index()); - - torch::stable::Tensor rms_norm_out = torch::stable::empty_like(input); - allreduce_params.rms_norm_out = rms_norm_out.mutable_data_ptr(); - - vllm::tensorrt_llm::minimax_reduce_rms_op(allreduce_params); - - return rms_norm_out; -} - std::tuple minimax_allreduce_rms_qk(torch::stable::Tensor qkv, torch::stable::Tensor const& norm_weight_q, diff --git a/csrc/minimax_reduce_rms_kernel.h b/csrc/libtorch_stable/minimax_reduce_rms_kernel.h similarity index 100% rename from csrc/minimax_reduce_rms_kernel.h rename to csrc/libtorch_stable/minimax_reduce_rms_kernel.h diff --git a/csrc/moe/dsv3_router_gemm_bf16_out.cu b/csrc/libtorch_stable/moe/dsv3_router_gemm_bf16_out.cu similarity index 81% rename from csrc/moe/dsv3_router_gemm_bf16_out.cu rename to csrc/libtorch_stable/moe/dsv3_router_gemm_bf16_out.cu index b11ba991b26c..8695d1e8084f 100644 --- a/csrc/moe/dsv3_router_gemm_bf16_out.cu +++ b/csrc/libtorch_stable/moe/dsv3_router_gemm_bf16_out.cu @@ -18,14 +18,11 @@ * limitations under the License. */ -#include -#include +#include #include #include -#include "dsv3_router_gemm_utils.h" - // Custom FMA implementation using PTX assembly instructions __device__ __forceinline__ void fma(float2& d, float2 const& a, float2 const& b, float2 const& c) { @@ -81,7 +78,7 @@ __global__ __launch_bounds__(128, 1) void router_gemm_kernel_bf16_output( } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.wait;"); + cudaGridDependencySynchronize(); #endif // Process the GEMM in chunks @@ -166,7 +163,7 @@ __global__ __launch_bounds__(128, 1) void router_gemm_kernel_bf16_output( } } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif } @@ -289,3 +286,52 @@ template void invokeRouterGemmBf16Output<__nv_bfloat16, 15, 384, 7168>( template void invokeRouterGemmBf16Output<__nv_bfloat16, 16, 384, 7168>( __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +// Template instantiations for GLM-5 (DEFAULT_NUM_EXPERTS, hidden_dim=6144) +template void invokeRouterGemmBf16Output<__nv_bfloat16, 1, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 2, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 3, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 4, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 5, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 6, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 7, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 8, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 9, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 10, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 11, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 12, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 13, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 14, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 15, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmBf16Output<__nv_bfloat16, 16, 256, 6144>( + __nv_bfloat16*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); diff --git a/csrc/libtorch_stable/moe/dsv3_router_gemm_entry.cu b/csrc/libtorch_stable/moe/dsv3_router_gemm_entry.cu new file mode 100644 index 000000000000..c5ebaf11f10d --- /dev/null +++ b/csrc/libtorch_stable/moe/dsv3_router_gemm_entry.cu @@ -0,0 +1,210 @@ +/* + * Adapted from SGLang's sgl-kernel implementation, which was adapted from + * https://github.com/NVIDIA/TensorRT-LLM/blob/main/cpp/tensorrt_llm/kernels/dsv3MinLatencyKernels/dsv3RouterGemm.cu + * https://github.com/NVIDIA/TensorRT-LLM/blob/main/cpp/tensorrt_llm/thop/dsv3RouterGemmOp.cpp + * + * Copyright (c) 2019-2023, NVIDIA CORPORATION. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include "libtorch_stable/torch_utils.h" + +#include +#include + +#include + +namespace { + +inline int getSMVersion() { + auto* props = get_device_prop(); + return props->major * 10 + props->minor; +} + +} // namespace + +static constexpr int DEFAULT_NUM_EXPERTS = 256; +static constexpr int KIMI_K2_NUM_EXPERTS = 384; +static constexpr int DEFAULT_HIDDEN_DIM = 7168; +static constexpr int GLM_5_HIDDEN_DIM = 6144; + +template +void invokeRouterGemmFloatOutput(float* output, T const* mat_a, T const* mat_b, + cudaStream_t stream); + +template +void invokeRouterGemmBf16Output(__nv_bfloat16* output, T const* mat_a, + T const* mat_b, cudaStream_t stream); + +template +struct LoopUnroller { + static void unroll_float_output(int num_tokens, float* output, + __nv_bfloat16 const* input, + __nv_bfloat16 const* weights, + cudaStream_t stream) { + if (num_tokens == kBegin) { + invokeRouterGemmFloatOutput<__nv_bfloat16, kBegin, kNumExperts, + kHiddenDim>(output, input, weights, stream); + } else { + LoopUnroller::unroll_float_output(num_tokens, output, input, + weights, stream); + } + } + + static void unroll_bf16_output(int num_tokens, __nv_bfloat16* output, + __nv_bfloat16 const* input, + __nv_bfloat16 const* weights, + cudaStream_t stream) { + if (num_tokens == kBegin) { + invokeRouterGemmBf16Output<__nv_bfloat16, kBegin, kNumExperts, + kHiddenDim>(output, input, weights, stream); + } else { + LoopUnroller::unroll_bf16_output(num_tokens, output, input, + weights, stream); + } + } +}; + +template +struct LoopUnroller { + static void unroll_float_output(int num_tokens, float* output, + __nv_bfloat16 const* input, + __nv_bfloat16 const* weights, + cudaStream_t stream) { + if (num_tokens == kEnd) { + invokeRouterGemmFloatOutput<__nv_bfloat16, kEnd, kNumExperts, kHiddenDim>( + output, input, weights, stream); + } else { + throw std::invalid_argument("Invalid num_tokens, only supports 1 to 16"); + } + } + + static void unroll_bf16_output(int num_tokens, __nv_bfloat16* output, + __nv_bfloat16 const* input, + __nv_bfloat16 const* weights, + cudaStream_t stream) { + if (num_tokens == kEnd) { + invokeRouterGemmBf16Output<__nv_bfloat16, kEnd, kNumExperts, kHiddenDim>( + output, input, weights, stream); + } else { + throw std::invalid_argument("Invalid num_tokens, only supports 1 to 16"); + } + } +}; + +void dsv3_router_gemm( + torch::stable::Tensor& output, // [num_tokens, num_experts] + torch::stable::Tensor const& mat_a, // [num_tokens, hidden_dim] + torch::stable::Tensor const& mat_b // [num_experts, hidden_dim] +) { + STD_TORCH_CHECK(output.dim() == 2 && mat_a.dim() == 2 && mat_b.dim() == 2); + + const int num_tokens = mat_a.size(0); + const int num_experts = mat_b.size(0); + const int hidden_dim = mat_a.size(1); + + STD_TORCH_CHECK(mat_a.size(1) == mat_b.size(1), + "mat_a and mat_b must have the same hidden_dim"); + STD_TORCH_CHECK( + hidden_dim == DEFAULT_HIDDEN_DIM || hidden_dim == GLM_5_HIDDEN_DIM, + "Expected hidden_dim=", DEFAULT_HIDDEN_DIM, + " or hidden_dim=", GLM_5_HIDDEN_DIM, ", but got hidden_dim=", hidden_dim); + STD_TORCH_CHECK( + num_experts == DEFAULT_NUM_EXPERTS || num_experts == KIMI_K2_NUM_EXPERTS, + "Expected num_experts=", DEFAULT_NUM_EXPERTS, + " or num_experts=", KIMI_K2_NUM_EXPERTS, + ", but got num_experts=", num_experts); + // KIMI_K2_NUM_EXPERTS is only instantiated for the default hidden_dim. + STD_TORCH_CHECK( + hidden_dim == DEFAULT_HIDDEN_DIM || num_experts == DEFAULT_NUM_EXPERTS, + "hidden_dim=", GLM_5_HIDDEN_DIM, + " only supports num_experts=", DEFAULT_NUM_EXPERTS, + ", but got num_experts=", num_experts); + STD_TORCH_CHECK(num_tokens >= 1 && num_tokens <= 16, + "currently num_tokens must be less than or equal to 16 for " + "router_gemm"); + STD_TORCH_CHECK( + mat_a.scalar_type() == torch::headeronly::ScalarType::BFloat16, + "mat_a must be bf16"); + STD_TORCH_CHECK( + mat_b.scalar_type() == torch::headeronly::ScalarType::BFloat16, + "mat_b must be bf16"); + STD_TORCH_CHECK( + output.scalar_type() == torch::headeronly::ScalarType::Float || + output.scalar_type() == torch::headeronly::ScalarType::BFloat16, + "output must be float32 or bf16"); + + const torch::stable::accelerator::DeviceGuard device_guard( + mat_a.get_device_index()); + const int sm = getSMVersion(); + STD_TORCH_CHECK(sm >= 90, "required CUDA ARCH >= SM_90"); + + const cudaStream_t stream = get_current_cuda_stream(mat_a.get_device_index()); + + __nv_bfloat16 const* a_ptr = + reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()); + __nv_bfloat16 const* b_ptr = + reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()); + + if (output.scalar_type() == torch::headeronly::ScalarType::Float) { + float* out_ptr = reinterpret_cast(output.mutable_data_ptr()); + if (hidden_dim == DEFAULT_HIDDEN_DIM) { + if (num_experts == DEFAULT_NUM_EXPERTS) { + LoopUnroller<1, 16, DEFAULT_NUM_EXPERTS, + DEFAULT_HIDDEN_DIM>::unroll_float_output(num_tokens, + out_ptr, a_ptr, + b_ptr, stream); + } else { + LoopUnroller<1, 16, KIMI_K2_NUM_EXPERTS, + DEFAULT_HIDDEN_DIM>::unroll_float_output(num_tokens, + out_ptr, a_ptr, + b_ptr, stream); + } + } else { // GLM_5_HIDDEN_DIM + LoopUnroller<1, 16, DEFAULT_NUM_EXPERTS, + GLM_5_HIDDEN_DIM>::unroll_float_output(num_tokens, out_ptr, + a_ptr, b_ptr, stream); + } + } else if (output.scalar_type() == torch::headeronly::ScalarType::BFloat16) { + __nv_bfloat16* out_ptr = + reinterpret_cast<__nv_bfloat16*>(output.mutable_data_ptr()); + if (hidden_dim == DEFAULT_HIDDEN_DIM) { + if (num_experts == DEFAULT_NUM_EXPERTS) { + LoopUnroller<1, 16, DEFAULT_NUM_EXPERTS, + DEFAULT_HIDDEN_DIM>::unroll_bf16_output(num_tokens, + out_ptr, a_ptr, + b_ptr, stream); + } else { + LoopUnroller<1, 16, KIMI_K2_NUM_EXPERTS, + DEFAULT_HIDDEN_DIM>::unroll_bf16_output(num_tokens, + out_ptr, a_ptr, + b_ptr, stream); + } + } else { // GLM_5_HIDDEN_DIM + LoopUnroller<1, 16, DEFAULT_NUM_EXPERTS, + GLM_5_HIDDEN_DIM>::unroll_bf16_output(num_tokens, out_ptr, + a_ptr, b_ptr, stream); + } + } +} + +STABLE_TORCH_LIBRARY_IMPL(_moe_C, CUDA, m) { + m.impl("dsv3_router_gemm", TORCH_BOX(&dsv3_router_gemm)); +} diff --git a/csrc/moe/dsv3_router_gemm_float_out.cu b/csrc/libtorch_stable/moe/dsv3_router_gemm_float_out.cu similarity index 81% rename from csrc/moe/dsv3_router_gemm_float_out.cu rename to csrc/libtorch_stable/moe/dsv3_router_gemm_float_out.cu index 2756cba0b14f..58a2b44ae2f5 100644 --- a/csrc/moe/dsv3_router_gemm_float_out.cu +++ b/csrc/libtorch_stable/moe/dsv3_router_gemm_float_out.cu @@ -18,14 +18,11 @@ * limitations under the License. */ -#include -#include +#include #include #include -#include "dsv3_router_gemm_utils.h" - // Custom FMA implementation using PTX assembly instructions __device__ __forceinline__ void fma(float2& d, float2 const& a, float2 const& b, float2 const& c) { @@ -81,7 +78,7 @@ __global__ __launch_bounds__(128, 1) void router_gemm_kernel_float_output( } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.wait;"); + cudaGridDependencySynchronize(); #endif // Process the GEMM in chunks @@ -166,7 +163,7 @@ __global__ __launch_bounds__(128, 1) void router_gemm_kernel_float_output( } } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif } @@ -289,3 +286,52 @@ template void invokeRouterGemmFloatOutput<__nv_bfloat16, 15, 384, 7168>( template void invokeRouterGemmFloatOutput<__nv_bfloat16, 16, 384, 7168>( float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +// Template instantiations for GLM-5 (DEFAULT_NUM_EXPERTS, hidden_dim=6144) +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 1, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 2, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 3, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 4, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 5, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 6, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 7, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 8, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 9, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 10, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 11, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 12, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 13, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 14, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 15, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); + +template void invokeRouterGemmFloatOutput<__nv_bfloat16, 16, 256, 6144>( + float*, __nv_bfloat16 const*, __nv_bfloat16 const*, cudaStream_t); diff --git a/csrc/moe/grouped_topk_kernels.cu b/csrc/libtorch_stable/moe/grouped_topk_kernels.cu similarity index 92% rename from csrc/moe/grouped_topk_kernels.cu rename to csrc/libtorch_stable/moe/grouped_topk_kernels.cu index 6a4dad3be7c3..da9ef44d03f9 100644 --- a/csrc/moe/grouped_topk_kernels.cu +++ b/csrc/libtorch_stable/moe/grouped_topk_kernels.cu @@ -18,9 +18,14 @@ * limitations under the License. */ #include "moeTopKFuncs.cuh" -#include -#include + +#include +#include + +#include "libtorch_stable/torch_utils.h" + #include +#include #include #include #include @@ -43,7 +48,8 @@ static constexpr int NumTopGroupScores = 2; static constexpr int DefaultMaxNumTopExperts = 8; static constexpr int MaxSupportedTopExperts = 22; static constexpr int MaxNumTopGroups = 4; - +// The empirical value for small batch +static constexpr int PDLEnableTokens = 16; namespace warp_topk { template @@ -559,8 +565,8 @@ __global__ void grouped_topk_fused_kernel( T* s_group_scores = reinterpret_cast(ptr_u); #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.wait;"); // I think all prolog can be put before - // acqbulk because it's ptr arithmetic + cudaGridDependencySynchronize(); // I think all prolog can be put before + // acqbulk because it's ptr arithmetic #endif // phase 1: per-group scan @@ -604,7 +610,7 @@ __global__ void grouped_topk_fused_kernel( topk_values[i] = 1.0f / static_cast(topk_i32); } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif return; } @@ -665,7 +671,7 @@ __global__ void grouped_topk_fused_kernel( } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif } @@ -890,7 +896,8 @@ void invokeNoAuxTc(T* scores, float* topk_values, IdxT* topk_indices, int64_t const num_experts, int64_t const n_group, int64_t const topk_group, int64_t const topk, bool const renormalize, double const routed_scaling_factor, - bool enable_pdl = false, cudaStream_t const stream = 0) { + const bool enable_pdl = false, + cudaStream_t const stream = 0) { cudaLaunchConfig_t config; config.stream = stream; cudaLaunchAttribute attrs[1]; @@ -978,7 +985,7 @@ void invokeNoAuxTc(T* scores, float* topk_values, IdxT* topk_indices, int64_t const num_tokens, int64_t const num_experts, \ int64_t const n_group, int64_t const topk_group, int64_t const topk, \ bool const renormalize, double const routed_scaling_factor, \ - bool enable_pdl, cudaStream_t const stream); + const bool enable_pdl, cudaStream_t const stream); INSTANTIATE_NOAUX_TC(float, float, int32_t, SCORING_SIGMOID); INSTANTIATE_NOAUX_TC(float, half, int32_t, SCORING_SIGMOID); @@ -1001,38 +1008,43 @@ INSTANTIATE_NOAUX_TC(__nv_bfloat16, __nv_bfloat16, int32_t, SCORING_NONE); } // end namespace moe } // namespace vllm -std::tuple grouped_topk( - torch::Tensor const& scores, int64_t n_group, int64_t topk_group, +std::tuple grouped_topk( + torch::stable::Tensor const& scores, int64_t n_group, int64_t topk_group, int64_t topk, bool renormalize, double routed_scaling_factor, - torch::Tensor const& bias, int64_t scoring_func = 0) { - auto data_type = scores.scalar_type(); - auto bias_type = bias.scalar_type(); - auto input_size = scores.sizes(); - int64_t num_tokens = input_size[0]; - int64_t num_experts = input_size[1]; - TORCH_CHECK(input_size.size() == 2, "scores must be a 2D Tensor"); - TORCH_CHECK(n_group > 0, "n_group must be positive"); - TORCH_CHECK(topk > 0, "topk must be positive"); - TORCH_CHECK(topk_group > 0, "topk_group must be positive"); - TORCH_CHECK(topk_group <= n_group, "topk_group must be <= n_group"); - TORCH_CHECK(num_experts % n_group == 0, - "num_experts should be divisible by n_group"); - TORCH_CHECK(n_group <= 32, - "n_group should be smaller than or equal to 32 for now"); - TORCH_CHECK(topk <= 32, "topk should be smaller than or equal to 32 for now"); - TORCH_CHECK(topk <= topk_group * (num_experts / n_group), - "topk must be <= topk_group * (num_experts / n_group)"); - TORCH_CHECK(scoring_func == vllm::moe::SCORING_NONE || - scoring_func == vllm::moe::SCORING_SIGMOID, - "scoring_func must be SCORING_NONE (0) or SCORING_SIGMOID (1)"); + torch::stable::Tensor const& bias, int64_t scoring_func = 0) { + const auto data_type = scores.scalar_type(); + const auto bias_type = bias.scalar_type(); + STD_TORCH_CHECK(scores.dim() == 2, "scores must be a 2D Tensor"); + const int64_t num_tokens = scores.size(0); + const int64_t num_experts = scores.size(1); + STD_TORCH_CHECK(n_group > 0, "n_group must be positive"); + STD_TORCH_CHECK(topk > 0, "topk must be positive"); + STD_TORCH_CHECK(topk_group > 0, "topk_group must be positive"); + STD_TORCH_CHECK(topk_group <= n_group, "topk_group must be <= n_group"); + STD_TORCH_CHECK(num_experts % n_group == 0, + "num_experts should be divisible by n_group"); + STD_TORCH_CHECK(n_group <= 32, + "n_group should be smaller than or equal to 32 for now"); + STD_TORCH_CHECK(topk <= 32, + "topk should be smaller than or equal to 32 for now"); + STD_TORCH_CHECK(topk <= topk_group * (num_experts / n_group), + "topk must be <= topk_group * (num_experts / n_group)"); + STD_TORCH_CHECK( + scoring_func == vllm::moe::SCORING_NONE || + scoring_func == vllm::moe::SCORING_SIGMOID, + "scoring_func must be SCORING_NONE (0) or SCORING_SIGMOID (1)"); // Always output float32 for topk_values (eliminates Python-side conversion) - torch::Tensor topk_values = torch::empty( - {num_tokens, topk}, torch::dtype(torch::kFloat32).device(torch::kCUDA)); - torch::Tensor topk_indices = torch::empty( - {num_tokens, topk}, torch::dtype(torch::kInt32).device(torch::kCUDA)); - - auto stream = c10::cuda::getCurrentCUDAStream(scores.get_device()); + auto topk_values = torch::stable::new_empty( + scores, {num_tokens, topk}, torch::headeronly::ScalarType::Float); + auto topk_indices = torch::stable::new_empty( + scores, {num_tokens, topk}, torch::headeronly::ScalarType::Int); + const bool pdl_flag = num_tokens <= vllm::moe::PDLEnableTokens; + + const torch::stable::accelerator::DeviceGuard device_guard( + scores.get_device_index()); + const cudaStream_t stream = + get_current_cuda_stream(scores.get_device_index()); auto const sf = static_cast(scoring_func); #define LAUNCH_KERNEL_SF(T, BiasT, IdxT) \ @@ -1045,7 +1057,7 @@ std::tuple grouped_topk( reinterpret_cast(topk_indices.mutable_data_ptr()), \ reinterpret_cast(bias.data_ptr()), num_tokens, \ num_experts, n_group, topk_group, topk, renormalize, \ - routed_scaling_factor, false, stream); \ + routed_scaling_factor, pdl_flag, stream); \ break; \ case vllm::moe::SCORING_SIGMOID: \ vllm::moe::invokeNoAuxTc( \ @@ -1054,10 +1066,10 @@ std::tuple grouped_topk( reinterpret_cast(topk_indices.mutable_data_ptr()), \ reinterpret_cast(bias.data_ptr()), num_tokens, \ num_experts, n_group, topk_group, topk, renormalize, \ - routed_scaling_factor, false, stream); \ + routed_scaling_factor, pdl_flag, stream); \ break; \ default: \ - throw std::invalid_argument("Unsupported scoring_func"); \ + STD_TORCH_CHECK(false, "Unsupported scoring_func"); \ break; \ } \ } while (0) @@ -1065,17 +1077,18 @@ std::tuple grouped_topk( #define LAUNCH_KERNEL(T, IdxT) \ do { \ switch (bias_type) { \ - case torch::kFloat16: \ + case torch::headeronly::ScalarType::Half: \ LAUNCH_KERNEL_SF(T, half, IdxT); \ break; \ - case torch::kFloat32: \ + case torch::headeronly::ScalarType::Float: \ LAUNCH_KERNEL_SF(T, float, IdxT); \ break; \ - case torch::kBFloat16: \ + case torch::headeronly::ScalarType::BFloat16: \ LAUNCH_KERNEL_SF(T, __nv_bfloat16, IdxT); \ break; \ default: \ - throw std::invalid_argument( \ + STD_TORCH_CHECK( \ + false, \ "Invalid bias dtype, only supports float16, float32, and " \ "bfloat16"); \ break; \ @@ -1083,22 +1096,22 @@ std::tuple grouped_topk( } while (0) switch (data_type) { - case torch::kFloat16: + case torch::headeronly::ScalarType::Half: // Handle Float16 LAUNCH_KERNEL(half, int32_t); break; - case torch::kFloat32: + case torch::headeronly::ScalarType::Float: // Handle Float32 LAUNCH_KERNEL(float, int32_t); break; - case torch::kBFloat16: + case torch::headeronly::ScalarType::BFloat16: // Handle BFloat16 LAUNCH_KERNEL(__nv_bfloat16, int32_t); break; default: // Handle other data types - throw std::invalid_argument( - "Invalid dtype, only supports float16, float32, and bfloat16"); + STD_TORCH_CHECK( + false, "Invalid dtype, only supports float16, float32, and bfloat16"); break; } #undef LAUNCH_KERNEL diff --git a/csrc/moe/marlin_moe_wna16/.gitignore b/csrc/libtorch_stable/moe/marlin_moe_wna16/.gitignore similarity index 100% rename from csrc/moe/marlin_moe_wna16/.gitignore rename to csrc/libtorch_stable/moe/marlin_moe_wna16/.gitignore diff --git a/csrc/moe/marlin_moe_wna16/generate_kernels.py b/csrc/libtorch_stable/moe/marlin_moe_wna16/generate_kernels.py similarity index 99% rename from csrc/moe/marlin_moe_wna16/generate_kernels.py rename to csrc/libtorch_stable/moe/marlin_moe_wna16/generate_kernels.py index 6ddda1d51db5..64b47b607bb3 100644 --- a/csrc/moe/marlin_moe_wna16/generate_kernels.py +++ b/csrc/libtorch_stable/moe/marlin_moe_wna16/generate_kernels.py @@ -302,7 +302,7 @@ def generate_new_kernels(): if not SUPPORT_FP8 and kernel_selector_str != FILE_HEAD_COMMENT: kernel_selector_str += ( "else if (a_type == vllm::kFE4M3fn)\n" - " TORCH_CHECK(false, " + " STD_TORCH_CHECK(false, " '"marlin kernel with fp8 activation is not built.");' ) diff --git a/csrc/moe/marlin_moe_wna16/kernel.h b/csrc/libtorch_stable/moe/marlin_moe_wna16/kernel.h similarity index 95% rename from csrc/moe/marlin_moe_wna16/kernel.h rename to csrc/libtorch_stable/moe/marlin_moe_wna16/kernel.h index 09ed1a470bd6..783736ab5092 100644 --- a/csrc/moe/marlin_moe_wna16/kernel.h +++ b/csrc/libtorch_stable/moe/marlin_moe_wna16/kernel.h @@ -3,8 +3,8 @@ #define MARLIN_NAMESPACE_NAME marlin_moe_wna16 #endif -#include "quantization/marlin/marlin.cuh" -#include "quantization/marlin/marlin_dtypes.cuh" +#include "libtorch_stable/quantization/marlin/marlin.cuh" +#include "libtorch_stable/quantization/marlin/marlin_dtypes.cuh" #include "core/scalar_type.hpp" #define MARLIN_KERNEL_PARAMS \ diff --git a/csrc/moe/marlin_moe_wna16/marlin_template.h b/csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h similarity index 99% rename from csrc/moe/marlin_moe_wna16/marlin_template.h rename to csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h index 9858df94573e..04f90101be4f 100644 --- a/csrc/moe/marlin_moe_wna16/marlin_template.h +++ b/csrc/libtorch_stable/moe/marlin_moe_wna16/marlin_template.h @@ -23,10 +23,10 @@ #define MARLIN_NAMESPACE_NAME marlin_moe_wna16 #endif -#include "quantization/marlin/marlin.cuh" -#include "quantization/marlin/marlin_dtypes.cuh" -#include "quantization/marlin/dequant.h" -#include "quantization/marlin/marlin_mma.h" +#include "libtorch_stable/quantization/marlin/marlin.cuh" +#include "libtorch_stable/quantization/marlin/marlin_dtypes.cuh" +#include "libtorch_stable/quantization/marlin/dequant.h" +#include "libtorch_stable/quantization/marlin/marlin_mma.h" #include "core/scalar_type.hpp" #define STATIC_ASSERT_SCALAR_TYPE_VALID(scalar_t) \ diff --git a/csrc/moe/marlin_moe_wna16/ops.cu b/csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu similarity index 62% rename from csrc/moe/marlin_moe_wna16/ops.cu rename to csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu index 82cba2978b10..177eefa2c6f0 100644 --- a/csrc/moe/marlin_moe_wna16/ops.cu +++ b/csrc/libtorch_stable/moe/marlin_moe_wna16/ops.cu @@ -24,7 +24,15 @@ #endif #include "kernel.h" -#include "core/registration.h" + +#include +#include +#include +#include +#include +#include + +#include "libtorch_stable/torch_utils.h" #define STATIC_ASSERT_SCALAR_TYPE_VALID(scalar_t) \ static_assert(std::is_same::value || \ @@ -350,18 +358,18 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, bool m_block_size_8 = moe_block_size == 8; bool is_a_8bit = a_type.size_bits() == 8; - TORCH_CHECK(prob_m > 0 && prob_n > 0 && prob_k > 0, "Invalid MNK = [", prob_m, - ", ", prob_n, ", ", prob_k, "]"); + STD_TORCH_CHECK(prob_m > 0 && prob_n > 0 && prob_k > 0, "Invalid MNK = [", + prob_m, ", ", prob_n, ", ", prob_k, "]"); int group_blocks = 0; if (has_act_order) { if (is_k_full) { - TORCH_CHECK(group_size != -1); + STD_TORCH_CHECK(group_size != -1); group_blocks = group_size / 16; - TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, - " is not divisible by group_blocks = ", group_blocks); + STD_TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, + " is not divisible by group_blocks = ", group_blocks); } else { - TORCH_CHECK(group_size == 0); + STD_TORCH_CHECK(group_size == 0); group_blocks = 0; } } else { @@ -369,8 +377,8 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, group_blocks = -1; } else { group_blocks = group_size / 16; - TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, - " is not divisible by group_blocks = ", group_blocks); + STD_TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, + " is not divisible by group_blocks = ", group_blocks); } } @@ -407,7 +415,7 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, else if (moe_block_size == 64) kernel = permute_cols_kernel<64>; else - TORCH_CHECK(false, "unsupported moe_block_size ", moe_block_size); + STD_TORCH_CHECK(false, "unsupported moe_block_size ", moe_block_size); // avoid ">>>" being formatted to "> > >" // clang-format off @@ -428,25 +436,25 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, int max_shared_mem = 0; cudaDeviceGetAttribute(&max_shared_mem, cudaDevAttrMaxSharedMemoryPerBlockOptin, dev); - TORCH_CHECK(max_shared_mem > 0); + STD_TORCH_CHECK(max_shared_mem > 0); int major_capability, minor_capability; cudaDeviceGetAttribute(&major_capability, cudaDevAttrComputeCapabilityMajor, dev); cudaDeviceGetAttribute(&minor_capability, cudaDevAttrComputeCapabilityMinor, dev); - TORCH_CHECK(major_capability * 10 + minor_capability >= 75, - "marlin kernel only support Turing or newer GPUs."); + STD_TORCH_CHECK(major_capability * 10 + minor_capability >= 75, + "marlin kernel only support Turing or newer GPUs."); int stages = 4; if (major_capability == 7 && minor_capability == 5) { stages = 2; - TORCH_CHECK(a_type == vllm::kFloat16 || a_type == vllm::kS8, - "Turing only support FP16 or INT8 activation."); + STD_TORCH_CHECK(a_type == vllm::kFloat16 || a_type == vllm::kS8, + "Turing only support FP16 or INT8 activation."); } if (a_type == vllm::kFE4M3fn) { - TORCH_CHECK(major_capability * 10 + minor_capability >= 89, - "FP8 only support Ada Lovelace or newer GPUs."); - TORCH_CHECK( + STD_TORCH_CHECK(major_capability * 10 + minor_capability >= 89, + "FP8 only support Ada Lovelace or newer GPUs."); + STD_TORCH_CHECK( major_capability * 10 + minor_capability == 89 || major_capability == 12, "Marlin W4A8-FP8 only support SM89 or SM12x device (It is slower than " @@ -460,10 +468,10 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, thread_tfg = thread_config_t{thread_k, thread_n, thread_k * thread_n / 64}; if (blocks_per_sm == -1) blocks_per_sm = 1; exec_cfg = exec_config_t{blocks_per_sm, thread_tfg}; - TORCH_CHECK(prob_n % thread_n == 0, "prob_n = ", prob_n, - " is not divisible by thread_n = ", thread_n); - TORCH_CHECK(prob_k % thread_k == 0, "prob_k = ", prob_k, - " is not divisible by thread_k = ", thread_k); + STD_TORCH_CHECK(prob_n % thread_n == 0, "prob_n = ", prob_n, + " is not divisible by thread_n = ", thread_n); + STD_TORCH_CHECK(prob_k % thread_k == 0, "prob_k = ", prob_k, + " is not divisible by thread_k = ", thread_k); } else { // Auto config exec_cfg = determine_exec_config( @@ -484,19 +492,19 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, int thread_k_blocks = thread_k / 16; int thread_n_blocks = thread_n / 16; - TORCH_CHECK(is_valid_config(thread_tfg, m_block_size_8, thread_m_blocks, - prob_m, prob_n, prob_k, num_bits, group_size, - has_act_order, is_k_full, has_zp, is_zp_float, - is_a_8bit, stages, max_shared_mem), - "Invalid thread config: thread_m_blocks = ", thread_m_blocks, - ", thread_k = ", thread_tfg.thread_k, - ", thread_n = ", thread_tfg.thread_n, - ", num_threads = ", thread_tfg.num_threads, " for MKN = [", - prob_m, ", ", prob_k, ", ", prob_n, "] and num_bits = ", num_bits, - ", group_size = ", group_size, - ", has_act_order = ", has_act_order, ", is_k_full = ", is_k_full, - ", has_zp = ", has_zp, ", is_zp_float = ", is_zp_float, - ", max_shared_mem = ", max_shared_mem); + STD_TORCH_CHECK( + is_valid_config(thread_tfg, m_block_size_8, thread_m_blocks, prob_m, + prob_n, prob_k, num_bits, group_size, has_act_order, + is_k_full, has_zp, is_zp_float, is_a_8bit, stages, + max_shared_mem), + "Invalid thread config: thread_m_blocks = ", thread_m_blocks, + ", thread_k = ", thread_tfg.thread_k, + ", thread_n = ", thread_tfg.thread_n, + ", num_threads = ", thread_tfg.num_threads, " for MKN = [", prob_m, ", ", + prob_k, ", ", prob_n, "] and num_bits = ", num_bits, + ", group_size = ", group_size, ", has_act_order = ", has_act_order, + ", is_k_full = ", is_k_full, ", has_zp = ", has_zp, + ", is_zp_float = ", is_zp_float, ", max_shared_mem = ", max_shared_mem); int sh_cache_size = get_kernel_cache_size(thread_tfg, m_block_size_8, thread_m_blocks, prob_m, @@ -509,13 +517,13 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, num_threads, is_zp_float, stages); if (kernel == MarlinDefault) { - TORCH_CHECK(false, "Unsupported shapes: MNK = [", prob_m, ", ", prob_n, - ", ", prob_k, "]", ", has_act_order = ", has_act_order, - ", num_groups = ", num_groups, ", group_size = ", group_size, - ", thread_m_blocks = ", thread_m_blocks, - ", thread_n_blocks = ", thread_n_blocks, - ", thread_k_blocks = ", thread_k_blocks, - ", num_bits = ", num_bits); + STD_TORCH_CHECK( + false, "Unsupported shapes: MNK = [", prob_m, ", ", prob_n, ", ", + prob_k, "]", ", has_act_order = ", has_act_order, + ", num_groups = ", num_groups, ", group_size = ", group_size, + ", thread_m_blocks = ", thread_m_blocks, + ", thread_n_blocks = ", thread_n_blocks, + ", thread_k_blocks = ", thread_k_blocks, ", num_bits = ", num_bits); } cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, @@ -532,75 +540,81 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, } // namespace MARLIN_NAMESPACE_NAME -torch::Tensor moe_wna16_marlin_gemm( - torch::Tensor& a, std::optional c_or_none, - torch::Tensor& b_q_weight, - std::optional const& b_bias_or_none, torch::Tensor& b_scales, - std::optional const& a_scales_or_none, - std::optional const& global_scale_or_none, - std::optional const& b_zeros_or_none, - std::optional const& g_idx_or_none, - std::optional const& perm_or_none, torch::Tensor& workspace, - torch::Tensor& sorted_token_ids, torch::Tensor& expert_ids, - torch::Tensor& num_tokens_past_padded, torch::Tensor& topk_weights, - int64_t moe_block_size, int64_t top_k, bool mul_topk_weights, - vllm::ScalarTypeId const& b_type_id, int64_t size_m, int64_t size_n, - int64_t size_k, bool is_k_full, bool use_atomic_add, bool use_fp32_reduce, - bool is_zp_float, int64_t thread_k, int64_t thread_n, +torch::stable::Tensor moe_wna16_marlin_gemm( + torch::stable::Tensor& a, std::optional c_or_none, + torch::stable::Tensor& b_q_weight, + std::optional const& b_bias_or_none, + torch::stable::Tensor& b_scales, + std::optional const& a_scales_or_none, + std::optional const& global_scale_or_none, + std::optional const& b_zeros_or_none, + std::optional const& g_idx_or_none, + std::optional const& perm_or_none, + torch::stable::Tensor& workspace, torch::stable::Tensor& sorted_token_ids, + torch::stable::Tensor& expert_ids, + torch::stable::Tensor& num_tokens_past_padded, + torch::stable::Tensor& topk_weights, int64_t moe_block_size, int64_t top_k, + bool mul_topk_weights, vllm::ScalarTypeId const& b_type_id, int64_t size_m, + int64_t size_n, int64_t size_k, bool is_k_full, bool use_atomic_add, + bool use_fp32_reduce, bool is_zp_float, int64_t thread_k, int64_t thread_n, int64_t blocks_per_sm) { vllm::ScalarTypeId a_type_id, c_type_id, s_type_id; - auto c_dtype = a.dtype(); - if (a.scalar_type() == at::ScalarType::Half) { + auto c_dtype = a.scalar_type(); + if (a.scalar_type() == torch::headeronly::ScalarType::Half) { a_type_id = vllm::kFloat16.id(); c_type_id = vllm::kFloat16.id(); - } else if (a.scalar_type() == at::ScalarType::BFloat16) { + } else if (a.scalar_type() == torch::headeronly::ScalarType::BFloat16) { a_type_id = vllm::kBFloat16.id(); c_type_id = vllm::kBFloat16.id(); } else { - c_dtype = b_scales.dtype(); - if (b_scales.scalar_type() == at::ScalarType::Half) { + c_dtype = b_scales.scalar_type(); + if (b_scales.scalar_type() == torch::headeronly::ScalarType::Half) { c_type_id = vllm::kFloat16.id(); - } else if (b_scales.scalar_type() == at::ScalarType::BFloat16) { + } else if (b_scales.scalar_type() == + torch::headeronly::ScalarType::BFloat16) { c_type_id = vllm::kBFloat16.id(); } else { c_type_id = vllm::kBFloat16.id(); - TORCH_CHECK(c_or_none.has_value(), "c must be passed for W4A8-FP4"); - torch::Tensor c = c_or_none.value(); - c_dtype = c.dtype(); + STD_TORCH_CHECK(c_or_none.has_value(), "c must be passed for W4A8-FP4"); + torch::stable::Tensor c = c_or_none.value(); + c_dtype = c.scalar_type(); - if (c.scalar_type() == at::ScalarType::Half) { + if (c.scalar_type() == torch::headeronly::ScalarType::Half) { c_type_id = vllm::kFloat16.id(); - } else if (c.scalar_type() == at::ScalarType::BFloat16) { + } else if (c.scalar_type() == torch::headeronly::ScalarType::BFloat16) { c_type_id = vllm::kBFloat16.id(); } else { - TORCH_CHECK(false, "unsupported c dtype"); + STD_TORCH_CHECK(false, "unsupported c dtype"); } } - if (a.scalar_type() == at::ScalarType::Float8_e4m3fn) { + if (a.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fn) { a_type_id = vllm::kFE4M3fn.id(); - } else if (a.scalar_type() == at::ScalarType::Char) { + } else if (a.scalar_type() == torch::headeronly::ScalarType::Char) { a_type_id = vllm::kS8.id(); } else { - TORCH_CHECK(false, "unsupported `a` scalar_type"); + STD_TORCH_CHECK(false, "unsupported `a` scalar_type"); } } s_type_id = c_type_id; if (b_type_id == vllm::kFE2M1f.id()) { - if (b_scales.scalar_type() == at::ScalarType::Float8_e4m3fn) { + if (b_scales.scalar_type() == + torch::headeronly::ScalarType::Float8_e4m3fn) { s_type_id = vllm::kFE4M3fn.id(); - } else if (b_scales.scalar_type() == at::ScalarType::Float8_e8m0fnu) { + } else if (b_scales.scalar_type() == + torch::headeronly::ScalarType::Float8_e8m0fnu) { s_type_id = vllm::kFE8M0fnu.id(); } else { - TORCH_CHECK(false, - "When b_type = float4_e2m1f, b_scale scalar type must be", - "float8_e4m3fn (for NVFP4) or float8_e8m0fnu (for MXFP4)."); + STD_TORCH_CHECK( + false, "When b_type = float4_e2m1f, b_scale scalar type must be", + "float8_e4m3fn (for NVFP4) or float8_e8m0fnu (for MXFP4)."); } } else if (b_type_id == vllm::kFE4M3fn.id() && - b_scales.scalar_type() == at::ScalarType::Float8_e8m0fnu) { + b_scales.scalar_type() == + torch::headeronly::ScalarType::Float8_e8m0fnu) { s_type_id = vllm::kFE8M0fnu.id(); } @@ -613,58 +627,60 @@ torch::Tensor moe_wna16_marlin_gemm( int num_experts = b_q_weight.size(0); if (moe_block_size != 8) { - TORCH_CHECK(moe_block_size % 16 == 0, - "unsupported moe_block_size=", moe_block_size); - TORCH_CHECK(moe_block_size >= 16 && moe_block_size <= 64, - "unsupported moe_block_size=", moe_block_size); + STD_TORCH_CHECK(moe_block_size % 16 == 0, + "unsupported moe_block_size=", moe_block_size); + STD_TORCH_CHECK(moe_block_size >= 16 && moe_block_size <= 64, + "unsupported moe_block_size=", moe_block_size); } // Verify A - TORCH_CHECK(a.size(0) == size_m, "Shape mismatch: a.size(0) = ", a.size(0), - ", size_m = ", size_m); - TORCH_CHECK(a.size(1) == size_k, "Shape mismatch: a.size(1) = ", a.size(1), - ", size_k = ", size_k); + STD_TORCH_CHECK(a.size(0) == size_m, + "Shape mismatch: a.size(0) = ", a.size(0), + ", size_m = ", size_m); + STD_TORCH_CHECK(a.size(1) == size_k, + "Shape mismatch: a.size(1) = ", a.size(1), + ", size_k = ", size_k); // Verify B - TORCH_CHECK( + STD_TORCH_CHECK( size_k % MARLIN_NAMESPACE_NAME::tile_size == 0, "size_k = ", size_k, " is not divisible by tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); - TORCH_CHECK((size_k / MARLIN_NAMESPACE_NAME::tile_size) == b_q_weight.size(1), - "Shape mismatch: b_q_weight.size(1) = ", b_q_weight.size(1), - ", size_k = ", size_k, - ", tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); - TORCH_CHECK( + STD_TORCH_CHECK( + (size_k / MARLIN_NAMESPACE_NAME::tile_size) == b_q_weight.size(1), + "Shape mismatch: b_q_weight.size(1) = ", b_q_weight.size(1), + ", size_k = ", size_k, + ", tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); + STD_TORCH_CHECK( b_q_weight.size(2) % MARLIN_NAMESPACE_NAME::tile_size == 0, "b_q_weight.size(2) = ", b_q_weight.size(2), " is not divisible by tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); int actual_size_n = (b_q_weight.size(2) / MARLIN_NAMESPACE_NAME::tile_size) * pack_factor; - TORCH_CHECK(size_n == actual_size_n, "size_n = ", size_n, - ", actual_size_n = ", actual_size_n); + STD_TORCH_CHECK(size_n == actual_size_n, "size_n = ", size_n, + ", actual_size_n = ", actual_size_n); // Verify device and strides - TORCH_CHECK(a.device().is_cuda(), "A is not on GPU"); - TORCH_CHECK(a.is_contiguous(), "A is not contiguous"); + STD_TORCH_CHECK(a.device().is_cuda(), "A is not on GPU"); + STD_TORCH_CHECK(a.is_contiguous(), "A is not contiguous"); - TORCH_CHECK(b_q_weight.device().is_cuda(), "b_q_weight is not on GPU"); - TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); + STD_TORCH_CHECK(b_q_weight.device().is_cuda(), "b_q_weight is not on GPU"); + STD_TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); - TORCH_CHECK(b_scales.device().is_cuda(), "b_scales is not on GPU"); - TORCH_CHECK(b_scales.is_contiguous(), "b_scales is not contiguous"); + STD_TORCH_CHECK(b_scales.device().is_cuda(), "b_scales is not on GPU"); + STD_TORCH_CHECK(b_scales.is_contiguous(), "b_scales is not contiguous"); - torch::Tensor a_scales; - auto options = torch::TensorOptions().dtype(c_dtype).device(a.device()); - auto options_fp32 = - torch::TensorOptions().dtype(at::kFloat).device(a.device()); + torch::stable::Tensor a_scales; + constexpr auto kFloat = torch::headeronly::ScalarType::Float; if (a_scales_or_none.has_value()) { a_scales = a_scales_or_none.value(); - TORCH_CHECK(a_type.size_bits() == 8, - "a_scales can only be used for 8bit activation."); + STD_TORCH_CHECK(a_type.size_bits() == 8, + "a_scales can only be used for 8bit activation."); } else { - a_scales = torch::empty({0}, options_fp32); - TORCH_CHECK(a_type.size_bits() != 8, - "the a_scales parameter must be passed for 8bit activation."); + a_scales = torch::stable::new_empty(a, {0}, kFloat); + STD_TORCH_CHECK( + a_type.size_bits() != 8, + "the a_scales parameter must be passed for 8bit activation."); } // sms: number of SMs to use for the kernel @@ -672,82 +688,84 @@ torch::Tensor moe_wna16_marlin_gemm( cudaDeviceGetAttribute(&sms, cudaDevAttrMultiProcessorCount, a.get_device()); // Alloc buffers - const at::cuda::OptionalCUDAGuard device_guard(device_of(a)); - torch::Tensor c; + torch::stable::accelerator::DeviceGuard device_guard(a.get_device_index()); + torch::stable::Tensor c; if (c_or_none.has_value()) { c = c_or_none.value(); - TORCH_CHECK(c.device().is_cuda(), "c is not on GPU"); - TORCH_CHECK(c.is_contiguous(), "c is not contiguous"); - TORCH_CHECK(c.size(0) == size_m * top_k, - "Shape mismatch: c.size(0) = ", c.size(0), - ", size_m * topk = ", size_m * top_k); - TORCH_CHECK(c.size(1) == size_n, "Shape mismatch: c.size(1) = ", c.size(1), - ", size_n = ", size_n); + STD_TORCH_CHECK(c.device().is_cuda(), "c is not on GPU"); + STD_TORCH_CHECK(c.is_contiguous(), "c is not contiguous"); + STD_TORCH_CHECK(c.size(0) == size_m * top_k, + "Shape mismatch: c.size(0) = ", c.size(0), + ", size_m * topk = ", size_m * top_k); + STD_TORCH_CHECK(c.size(1) == size_n, + "Shape mismatch: c.size(1) = ", c.size(1), + ", size_n = ", size_n); } else { - c = torch::empty({size_m * top_k, size_n}, options); + c = torch::stable::new_empty(a, {size_m * top_k, size_n}, c_dtype); } // Alloc C tmp buffer that is going to be used for the global reduce - torch::Tensor c_tmp; + torch::stable::Tensor c_tmp; if (use_fp32_reduce && !use_atomic_add) { // max num of threadblocks is sms * 4 long max_c_tmp_size = min( (long)size_n * sorted_token_ids.size(0), (long)sms * 4 * moe_block_size * MARLIN_NAMESPACE_NAME::max_thread_n); if (moe_block_size == 8) max_c_tmp_size *= 2; - c_tmp = torch::empty({max_c_tmp_size}, options_fp32); + c_tmp = torch::stable::new_empty(a, {max_c_tmp_size}, kFloat); } else { - c_tmp = torch::empty({0}, options_fp32); + c_tmp = torch::stable::new_empty(a, {0}, kFloat); } // Detect groupsize and act_order int num_groups = -1; int group_size = -1; - int rank = b_scales.sizes().size(); - TORCH_CHECK(rank == 3, "b_scales rank = ", rank, " is not 3"); - TORCH_CHECK(b_scales.size(2) == size_n, "b_scales dim 2 = ", b_scales.size(2), - " is not size_n = ", size_n); + int rank = b_scales.dim(); + STD_TORCH_CHECK(rank == 3, "b_scales rank = ", rank, " is not 3"); + STD_TORCH_CHECK(b_scales.size(2) == size_n, + "b_scales dim 2 = ", b_scales.size(2), + " is not size_n = ", size_n); num_groups = b_scales.size(1); - torch::Tensor g_idx, perm, a_tmp; + torch::stable::Tensor g_idx, perm, a_tmp; if (g_idx_or_none.has_value() && perm_or_none.has_value()) { g_idx = g_idx_or_none.value(); perm = perm_or_none.value(); - TORCH_CHECK(g_idx.device().is_cuda(), "g_idx is not on GPU"); - TORCH_CHECK(g_idx.is_contiguous(), "g_idx is not contiguous"); - TORCH_CHECK(perm.device().is_cuda(), "perm is not on GPU"); - TORCH_CHECK(perm.is_contiguous(), "perm is not contiguous"); + STD_TORCH_CHECK(g_idx.device().is_cuda(), "g_idx is not on GPU"); + STD_TORCH_CHECK(g_idx.is_contiguous(), "g_idx is not contiguous"); + STD_TORCH_CHECK(perm.device().is_cuda(), "perm is not on GPU"); + STD_TORCH_CHECK(perm.is_contiguous(), "perm is not contiguous"); // Verify g_idx and perm - TORCH_CHECK((g_idx.size(-1) == 0 && perm.size(-1) == 0) || - (g_idx.size(-1) == size_k && perm.size(-1) == size_k), - "Unexpected g_idx.size(-1) = ", g_idx.size(-1), - " and perm.size(-1) = ", perm.size(-1), - ", where size_k = ", size_k); + STD_TORCH_CHECK((g_idx.size(-1) == 0 && perm.size(-1) == 0) || + (g_idx.size(-1) == size_k && perm.size(-1) == size_k), + "Unexpected g_idx.size(-1) = ", g_idx.size(-1), + " and perm.size(-1) = ", perm.size(-1), + ", where size_k = ", size_k); } else { - g_idx = torch::empty({0}, options); - perm = torch::empty({0}, options); - a_tmp = torch::empty({0}, options); + g_idx = torch::stable::new_empty(a, {0}, c_dtype); + perm = torch::stable::new_empty(a, {0}, c_dtype); + a_tmp = torch::stable::new_empty(a, {0}, c_dtype); } bool has_act_order = g_idx.size(-1) > 0 && perm.size(-1) > 0; if (has_act_order) { - a_tmp = torch::empty({size_m * top_k, size_k}, options); + a_tmp = torch::stable::new_empty(a, {size_m * top_k, size_k}, c_dtype); if (is_k_full) { - TORCH_CHECK(num_groups > 1, "For act_order, num_groups must be > 1"); - TORCH_CHECK(size_k % num_groups == 0, "size_k = ", size_k, - ", is not divisible by num_groups = ", num_groups); + STD_TORCH_CHECK(num_groups > 1, "For act_order, num_groups must be > 1"); + STD_TORCH_CHECK(size_k % num_groups == 0, "size_k = ", size_k, + ", is not divisible by num_groups = ", num_groups); group_size = size_k / num_groups; } else { group_size = 0; } } else { - a_tmp = torch::empty({0}, options); + a_tmp = torch::stable::new_empty(a, {0}, c_dtype); if (num_groups > 1) { - TORCH_CHECK( + STD_TORCH_CHECK( size_k % num_groups == 0, "size_k = ", size_k, ", is not divisible by b_scales.size(1) = ", b_scales.size(1)); group_size = size_k / num_groups; @@ -756,119 +774,125 @@ torch::Tensor moe_wna16_marlin_gemm( } } - torch::Tensor global_scale; + torch::stable::Tensor global_scale; if (global_scale_or_none.has_value()) { global_scale = global_scale_or_none.value(); - TORCH_CHECK(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn, - "global_scale can only be used for nvfp4 format."); + STD_TORCH_CHECK(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn, + "global_scale can only be used for nvfp4 format."); } else { - global_scale = torch::empty({0}, options_fp32); - TORCH_CHECK(!(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn), - "the global_scale parameter must be passed for nvfp4 format."); + global_scale = torch::stable::new_empty(a, {0}, kFloat); + STD_TORCH_CHECK( + !(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn), + "the global_scale parameter must be passed for nvfp4 format."); } bool has_bias = b_bias_or_none.has_value(); - torch::Tensor b_bias; + torch::stable::Tensor b_bias; if (has_bias) { b_bias = b_bias_or_none.value(); - TORCH_CHECK(b_bias.device().is_cuda(), "b_bias is not on GPU"); - TORCH_CHECK(b_bias.is_contiguous(), "b_bias is not contiguous"); - TORCH_CHECK(b_bias.size(1) == size_n, "b_bias.size(1) != size_n"); - TORCH_CHECK(b_bias.stride(1) == 1, "b_bias.stride(1) != 1"); + STD_TORCH_CHECK(b_bias.device().is_cuda(), "b_bias is not on GPU"); + STD_TORCH_CHECK(b_bias.is_contiguous(), "b_bias is not contiguous"); + STD_TORCH_CHECK(b_bias.size(1) == size_n, "b_bias.size(1) != size_n"); + STD_TORCH_CHECK(b_bias.stride(1) == 1, "b_bias.stride(1) != 1"); } else { - b_bias = torch::empty({0}, options); + b_bias = torch::stable::new_empty(a, {0}, c_dtype); } - torch::Tensor b_zeros; + torch::stable::Tensor b_zeros; if (b_zeros_or_none.has_value()) { b_zeros = b_zeros_or_none.value(); - TORCH_CHECK(b_zeros.device().is_cuda(), "b_zeros is not on GPU"); - TORCH_CHECK(b_zeros.is_contiguous(), "b_zeros is not contiguous"); + STD_TORCH_CHECK(b_zeros.device().is_cuda(), "b_zeros is not on GPU"); + STD_TORCH_CHECK(b_zeros.is_contiguous(), "b_zeros is not contiguous"); } else { - b_zeros = torch::empty({0}, options); + b_zeros = torch::stable::new_empty(a, {0}, c_dtype); } bool has_zp = b_zeros.size(-1) > 0; if (has_zp) { - TORCH_CHECK( + STD_TORCH_CHECK( b_type == vllm::kU4 || b_type == vllm::kU8, "b_type must be u4 or u8 when has_zp = True. Got = ", b_type.str()); } else { - TORCH_CHECK(b_type == vllm::kU4B8 || b_type == vllm::kU8B128 || - b_type == vllm::kS4 || b_type == vllm::kS8 || - b_type == vllm::kFE4M3fn || b_type == vllm::kFE2M1f, - "b_type must be uint4b8, uint8b128, int4, int8, " - "float8_e4m3fn or float4_e2m1f when has_zp = False. Got = ", - b_type.str()); + STD_TORCH_CHECK(b_type == vllm::kU4B8 || b_type == vllm::kU8B128 || + b_type == vllm::kS4 || b_type == vllm::kS8 || + b_type == vllm::kFE4M3fn || b_type == vllm::kFE2M1f, + "b_type must be uint4b8, uint8b128, int4, int8, " + "float8_e4m3fn or float4_e2m1f when has_zp = False. Got = ", + b_type.str()); } if (has_zp && is_zp_float) { - TORCH_CHECK(a.scalar_type() == at::ScalarType::Half, - "Computation type must be float16 (half) when using float zero " - "points."); + STD_TORCH_CHECK( + a.scalar_type() == torch::headeronly::ScalarType::Half, + "Computation type must be float16 (half) when using float zero " + "points."); } // Verify b_zeros if (has_zp) { - int rank = b_zeros.sizes().size(); - TORCH_CHECK(rank == 3, "b_zeros rank = ", rank, " is not 3"); + int rank = b_zeros.dim(); + STD_TORCH_CHECK(rank == 3, "b_zeros rank = ", rank, " is not 3"); if (is_zp_float) { - TORCH_CHECK(b_zeros.size(2) == size_n, - "b_zeros dim 2 = ", b_zeros.size(2), - " is not size_n = ", size_n); - TORCH_CHECK(num_groups == b_zeros.size(1), - "b_zeros dim 1 = ", b_zeros.size(1), - " is not num_groups = ", num_groups); - TORCH_CHECK(num_groups != -1, "num_groups must be != -1"); + STD_TORCH_CHECK(b_zeros.size(2) == size_n, + "b_zeros dim 2 = ", b_zeros.size(2), + " is not size_n = ", size_n); + STD_TORCH_CHECK(num_groups == b_zeros.size(1), + "b_zeros dim 1 = ", b_zeros.size(1), + " is not num_groups = ", num_groups); + STD_TORCH_CHECK(num_groups != -1, "num_groups must be != -1"); } else { - TORCH_CHECK(b_zeros.size(1) == num_groups, - "b_zeros dim 1 = ", b_zeros.size(1), - " is not num_groups = ", num_groups); - TORCH_CHECK(b_zeros.size(2) == size_n / pack_factor, - "b_zeros dim 2 = ", b_zeros.size(2), - " is not size_n / pack_factor = ", size_n / pack_factor); + STD_TORCH_CHECK(b_zeros.size(1) == num_groups, + "b_zeros dim 1 = ", b_zeros.size(1), + " is not num_groups = ", num_groups); + STD_TORCH_CHECK(b_zeros.size(2) == size_n / pack_factor, + "b_zeros dim 2 = ", b_zeros.size(2), + " is not size_n / pack_factor = ", size_n / pack_factor); } } // Verify workspace size - TORCH_CHECK(size_n % MARLIN_NAMESPACE_NAME::min_thread_n == 0, - "size_n = ", size_n, ", is not divisible by min_thread_n = ", - MARLIN_NAMESPACE_NAME::min_thread_n); + STD_TORCH_CHECK(size_n % MARLIN_NAMESPACE_NAME::min_thread_n == 0, + "size_n = ", size_n, ", is not divisible by min_thread_n = ", + MARLIN_NAMESPACE_NAME::min_thread_n); int max_n_tiles = size_n / MARLIN_NAMESPACE_NAME::min_thread_n; int min_workspace_size = min( max_n_tiles * (int)(sorted_token_ids.size(0) / moe_block_size), sms * 4); - TORCH_CHECK(workspace.numel() >= min_workspace_size, - "workspace.numel = ", workspace.numel(), - " is below min_workspace_size = ", min_workspace_size); + STD_TORCH_CHECK(workspace.numel() >= min_workspace_size, + "workspace.numel = ", workspace.numel(), + " is below min_workspace_size = ", min_workspace_size); int dev = a.get_device(); - TORCH_CHECK(a_scales.scalar_type() == at::ScalarType::Float, - "scalar type of a_scales must be float"); - TORCH_CHECK(global_scale.scalar_type() == at::ScalarType::Float, - "scalar type of global_scale must be float"); + STD_TORCH_CHECK( + a_scales.scalar_type() == torch::headeronly::ScalarType::Float, + "scalar type of a_scales must be float"); + STD_TORCH_CHECK( + global_scale.scalar_type() == torch::headeronly::ScalarType::Float, + "scalar type of global_scale must be float"); if (a_type.size_bits() == 16) { - TORCH_CHECK( + STD_TORCH_CHECK( a.scalar_type() == c.scalar_type(), "scalar type of a must be the same with c for 16 bit activation"); } MARLIN_NAMESPACE_NAME::marlin_mm( - a.data_ptr(), b_q_weight.data_ptr(), c.data_ptr(), c_tmp.data_ptr(), - b_bias.data_ptr(), a_scales.data_ptr(), b_scales.data_ptr(), - global_scale.data_ptr(), b_zeros.data_ptr(), g_idx.data_ptr(), - perm.data_ptr(), a_tmp.data_ptr(), sorted_token_ids.data_ptr(), - expert_ids.data_ptr(), num_tokens_past_padded.data_ptr(), - topk_weights.data_ptr(), moe_block_size, num_experts, top_k, - mul_topk_weights, size_m, size_n, size_k, workspace.data_ptr(), a_type, - b_type, c_type, s_type, has_bias, has_act_order, is_k_full, has_zp, - num_groups, group_size, dev, at::cuda::getCurrentCUDAStream(dev), + a.const_data_ptr(), b_q_weight.const_data_ptr(), c.mutable_data_ptr(), + c_tmp.mutable_data_ptr(), b_bias.mutable_data_ptr(), + a_scales.mutable_data_ptr(), b_scales.mutable_data_ptr(), + global_scale.mutable_data_ptr(), b_zeros.mutable_data_ptr(), + g_idx.mutable_data_ptr(), perm.mutable_data_ptr(), + a_tmp.mutable_data_ptr(), sorted_token_ids.mutable_data_ptr(), + expert_ids.mutable_data_ptr(), num_tokens_past_padded.mutable_data_ptr(), + topk_weights.mutable_data_ptr(), moe_block_size, num_experts, top_k, + mul_topk_weights, size_m, size_n, size_k, workspace.mutable_data_ptr(), + a_type, b_type, c_type, s_type, has_bias, has_act_order, is_k_full, + has_zp, num_groups, group_size, dev, get_current_cuda_stream(dev), thread_k, thread_n, sms, blocks_per_sm, use_atomic_add, use_fp32_reduce, is_zp_float); return c; } -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("moe_wna16_marlin_gemm", &moe_wna16_marlin_gemm); +STABLE_TORCH_LIBRARY_IMPL(_moe_C, CUDA, m) { + m.impl("moe_wna16_marlin_gemm", TORCH_BOX(&moe_wna16_marlin_gemm)); } diff --git a/csrc/moe/moeTopKFuncs.cuh b/csrc/libtorch_stable/moe/moeTopKFuncs.cuh similarity index 100% rename from csrc/moe/moeTopKFuncs.cuh rename to csrc/libtorch_stable/moe/moeTopKFuncs.cuh diff --git a/csrc/moe/moe_align_sum_kernels.cu b/csrc/libtorch_stable/moe/moe_align_sum_kernels.cu similarity index 63% rename from csrc/moe/moe_align_sum_kernels.cu rename to csrc/libtorch_stable/moe/moe_align_sum_kernels.cu index a8fa59b19398..1fa2c0d18e77 100644 --- a/csrc/moe/moe_align_sum_kernels.cu +++ b/csrc/libtorch_stable/moe/moe_align_sum_kernels.cu @@ -1,14 +1,18 @@ -#include -#include -#include +#include #include -#include -#include +#include +#include +#include +#include +#include +#include -#include "../cuda_compat.h" -#include "../dispatch_utils.h" -#include "core/math.hpp" +#include "../../cuda_compat.h" +#include "libtorch_stable/core/math.hpp" +#include "libtorch_stable/dispatch_utils.h" +#include "libtorch_stable/quantization/vectorization.cuh" +#include "libtorch_stable/torch_utils.h" #define CEILDIV(x, y) (((x) + (y) - 1) / (y)) @@ -346,19 +350,102 @@ __global__ void count_and_sort_expert_tokens_kernel( max_num_tokens_padded, nullptr, 0, topk_num, has_expert_map); } +// Reduce the topk expert outputs per token (summed in fp32). The output is +// dense [num_tokens, d]; the input is addressed by its strides so non- +// contiguous inputs work without a copy. A 16B-vectorized path is used when +// the hidden dim is contiguous (innermost stride 1) and aligned; otherwise a +// scalar kernel reads via arbitrary strides. topk is a compile-time constant +// for common values and runtime otherwise. + +// Elements per 16-byte vector (8 for bf16/fp16, 4 for fp32). +template +constexpr int MOE_SUM_VEC = 16 / sizeof(scalar_t); + template -__global__ void moe_sum_kernel( - scalar_t* __restrict__ out, // [..., d] - const scalar_t* __restrict__ input, // [..., topk, d] - const int d) { - const int64_t token_idx = blockIdx.x; - for (int64_t idx = threadIdx.x; idx < d; idx += blockDim.x) { - scalar_t x = 0.0; +__global__ void moe_sum_vec_kernel( + scalar_t* __restrict__ out, // [num_tokens, d], contiguous + const scalar_t* __restrict__ input, // [num_tokens, topk, d], d contiguous + const int64_t num_tokens, const int d, const int64_t stride_token, + const int64_t stride_topk) { + using vec_t = vllm::vec_n_t>; // 16-byte pack + constexpr int VEC = MOE_SUM_VEC; + const int64_t n_vec = d / VEC; + const int64_t total = num_tokens * n_vec; + for (int64_t i = blockIdx.x * blockDim.x + threadIdx.x; i < total; + i += (int64_t)gridDim.x * blockDim.x) { + const int64_t token = i / n_vec; + const int64_t v = i % n_vec; + const scalar_t* in_tok = input + token * stride_token + v * VEC; + + float acc[VEC]; +#pragma unroll + for (int j = 0; j < VEC; ++j) acc[j] = 0.f; + #pragma unroll for (int k = 0; k < TOPK; ++k) { - x += VLLM_LDG(&input[token_idx * TOPK * d + k * d + idx]); + vec_t packed = *reinterpret_cast(in_tok + k * stride_topk); +#pragma unroll + for (int j = 0; j < VEC; ++j) acc[j] += static_cast(packed.val[j]); + } + + vec_t outp; +#pragma unroll + for (int j = 0; j < VEC; ++j) outp.val[j] = static_cast(acc[j]); + *reinterpret_cast(out + token * d + v * VEC) = outp; + } +} + +// Runtime-topk variant of the above. +template +__global__ void moe_sum_vec_dynamic_kernel( + scalar_t* __restrict__ out, // [num_tokens, d], contiguous + const scalar_t* __restrict__ input, // [num_tokens, topk, d], d contiguous + const int64_t num_tokens, const int d, const int topk, + const int64_t stride_token, const int64_t stride_topk) { + using vec_t = vllm::vec_n_t>; + constexpr int VEC = MOE_SUM_VEC; + const int64_t n_vec = d / VEC; + const int64_t total = num_tokens * n_vec; + for (int64_t i = blockIdx.x * blockDim.x + threadIdx.x; i < total; + i += (int64_t)gridDim.x * blockDim.x) { + const int64_t token = i / n_vec; + const int64_t v = i % n_vec; + const scalar_t* in_tok = input + token * stride_token + v * VEC; + + float acc[VEC]; +#pragma unroll + for (int j = 0; j < VEC; ++j) acc[j] = 0.f; + + for (int k = 0; k < topk; ++k) { + vec_t packed = *reinterpret_cast(in_tok + k * stride_topk); +#pragma unroll + for (int j = 0; j < VEC; ++j) acc[j] += static_cast(packed.val[j]); + } + + vec_t outp; +#pragma unroll + for (int j = 0; j < VEC; ++j) outp.val[j] = static_cast(acc[j]); + *reinterpret_cast(out + token * d + v * VEC) = outp; + } +} + +// Stride-aware scalar fallback: handles unaligned/non-vectorizable hidden dims +// (including a non-contiguous hidden stride) via per-element strided reads. +template +__global__ void moe_sum_scalar_kernel( + scalar_t* __restrict__ out, // [num_tokens, d], contiguous + const scalar_t* __restrict__ input, // [num_tokens, topk, d] + const int d, const int topk, const int64_t stride_token, + const int64_t stride_topk, const int64_t stride_hidden) { + const int64_t token_idx = blockIdx.x; + const scalar_t* in_tok = input + token_idx * stride_token; + for (int64_t idx = threadIdx.x; idx < d; idx += blockDim.x) { + float x = 0.f; + for (int k = 0; k < topk; ++k) { + x += static_cast( + VLLM_LDG(&in_tok[k * stride_topk + idx * stride_hidden])); } - out[token_idx * d + idx] = x; + out[token_idx * d + idx] = static_cast(x); } } @@ -492,12 +579,15 @@ __global__ void moe_lora_align_block_size_small_batch_expert_kernel( // taken from // https://github.com/sgl-project/sglang/blob/8b5f83ed3b7d2a49ad5c5cd5aa61c5d502f47dbc -void moe_align_block_size(torch::Tensor topk_ids, int64_t num_experts, - int64_t block_size, torch::Tensor sorted_token_ids, - torch::Tensor experts_ids, - torch::Tensor num_tokens_post_pad, - std::optional maybe_expert_map) { - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); +void moe_align_block_size( + torch::stable::Tensor topk_ids, int64_t num_experts, int64_t block_size, + torch::stable::Tensor sorted_token_ids, torch::stable::Tensor experts_ids, + torch::stable::Tensor num_tokens_post_pad, + std::optional maybe_expert_map) { + const torch::stable::accelerator::DeviceGuard device_guard( + topk_ids.get_device_index()); + const cudaStream_t stream = + get_current_cuda_stream(topk_ids.get_device_index()); int64_t padded_num_experts = ((num_experts + WARP_SIZE - 1) / WARP_SIZE) * WARP_SIZE; @@ -506,19 +596,18 @@ void moe_align_block_size(torch::Tensor topk_ids, int64_t num_experts, threads = ((threads + WARP_SIZE - 1) / WARP_SIZE) * WARP_SIZE; // BlockScan uses 1024 threads and assigns one thread per expert. - TORCH_CHECK(padded_num_experts < 1024, - "padded_num_experts must be less than 1024"); - auto options_int = - torch::TensorOptions().dtype(torch::kInt).device(topk_ids.device()); + STD_TORCH_CHECK(padded_num_experts < 1024, + "padded_num_experts must be less than 1024"); bool has_expert_map = maybe_expert_map.has_value(); - torch::Tensor expert_map; + torch::stable::Tensor expert_map; if (has_expert_map) { expert_map = maybe_expert_map.value(); } else { - expert_map = torch::empty({0}, options_int); + expert_map = torch::stable::new_empty(topk_ids, {0}, + torch::headeronly::ScalarType::Int); } - VLLM_DISPATCH_INTEGRAL_AND_UNSIGNED_TYPES( + VLLM_STABLE_DISPATCH_INTEGRAL_AND_UNSIGNED_TYPES( topk_ids.scalar_type(), "moe_align_block_size_kernel", [&] { // calc needed amount of shared mem for `cumsum` tensors bool small_batch_expert_mode = @@ -538,16 +627,17 @@ void moe_align_block_size(torch::Tensor topk_ids, int64_t num_experts, scalar_t, fill_threads>; small_batch_expert_kernel<<<1, fill_threads + threads, shared_mem_size, stream>>>( - topk_ids.data_ptr(), - sorted_token_ids.data_ptr(), - experts_ids.data_ptr(), - num_tokens_post_pad.data_ptr(), - expert_map.data_ptr(), num_experts, block_size, - topk_ids.numel(), sorted_token_ids.size(0), topk_ids.size(1), - has_expert_map); + reinterpret_cast(topk_ids.const_data_ptr()), + reinterpret_cast(sorted_token_ids.mutable_data_ptr()), + reinterpret_cast(experts_ids.mutable_data_ptr()), + reinterpret_cast( + num_tokens_post_pad.mutable_data_ptr()), + reinterpret_cast(expert_map.mutable_data_ptr()), + num_experts, block_size, topk_ids.numel(), + sorted_token_ids.size(0), topk_ids.size(1), has_expert_map); } else { - torch::Tensor cumsum_buffer = - torch::empty({num_experts + 1}, options_int); + torch::stable::Tensor cumsum_buffer = torch::stable::new_empty( + topk_ids, {num_experts + 1}, torch::headeronly::ScalarType::Int); auto align_kernel = vllm::moe::moe_align_block_size_kernel; size_t num_warps = CEILDIV(padded_num_experts, experts_per_warp); @@ -558,14 +648,16 @@ void moe_align_block_size(torch::Tensor topk_ids, int64_t num_experts, // blockIdx.x == 0: counting experts and aligning // blockIdx.x == 1: filling sorted_token_ids align_kernel<<<2, threads, shared_mem_size, stream>>>( - topk_ids.data_ptr(), - sorted_token_ids.data_ptr(), - experts_ids.data_ptr(), - num_tokens_post_pad.data_ptr(), - expert_map.data_ptr(), num_experts, padded_num_experts, - experts_per_warp, block_size, topk_ids.numel(), - cumsum_buffer.data_ptr(), sorted_token_ids.size(0), - topk_ids.size(1), has_expert_map); + reinterpret_cast(topk_ids.const_data_ptr()), + reinterpret_cast(sorted_token_ids.mutable_data_ptr()), + reinterpret_cast(experts_ids.mutable_data_ptr()), + reinterpret_cast( + num_tokens_post_pad.mutable_data_ptr()), + reinterpret_cast(expert_map.mutable_data_ptr()), + num_experts, padded_num_experts, experts_per_warp, block_size, + topk_ids.numel(), + reinterpret_cast(cumsum_buffer.mutable_data_ptr()), + sorted_token_ids.size(0), topk_ids.size(1), has_expert_map); const int block_threads = std::min(256, (int)threads); const int num_blocks = @@ -577,9 +669,10 @@ void moe_align_block_size(torch::Tensor topk_ids, int64_t num_experts, auto sort_kernel = vllm::moe::count_and_sort_expert_tokens_kernel; sort_kernel<<>>( - topk_ids.data_ptr(), - sorted_token_ids.data_ptr(), - cumsum_buffer.data_ptr(), expert_map.data_ptr(), + reinterpret_cast(topk_ids.const_data_ptr()), + reinterpret_cast(sorted_token_ids.mutable_data_ptr()), + reinterpret_cast(cumsum_buffer.mutable_data_ptr()), + reinterpret_cast(expert_map.mutable_data_ptr()), topk_ids.numel(), num_experts, sorted_token_ids.size(0), topk_ids.size(1), has_expert_map); } @@ -588,111 +681,156 @@ void moe_align_block_size(torch::Tensor topk_ids, int64_t num_experts, void batched_moe_align_block_size(int64_t max_tokens_per_batch, int64_t block_size, - torch::Tensor const& batch_num_tokens, - torch::Tensor sorted_ids, - torch::Tensor batch_ids, - torch::Tensor num_tokens_post_pad) { + const torch::stable::Tensor& batch_num_tokens, + torch::stable::Tensor sorted_ids, + torch::stable::Tensor batch_ids, + torch::stable::Tensor num_tokens_post_pad) { namespace batched_kernel = vllm::moe::batched_moe_align_block_size; - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); + const torch::stable::accelerator::DeviceGuard device_guard( + batch_num_tokens.get_device_index()); + const cudaStream_t stream = + get_current_cuda_stream(batch_num_tokens.get_device_index()); int32_t const B = batch_num_tokens.size(0); int32_t const num_blocks_per_batch = round_to_next_multiple_of(max_tokens_per_batch, block_size) / block_size; int32_t const num_blocks = num_blocks_per_batch * B; int64_t const sorted_ids_size = num_blocks * block_size; - TORCH_CHECK(sorted_ids.size(0) == sorted_ids_size); - TORCH_CHECK(batch_ids.size(0) == sorted_ids_size / block_size); - TORCH_CHECK(num_tokens_post_pad.size(0) == 1); - TORCH_CHECK(B <= batched_kernel::num_threads); + STD_TORCH_CHECK(sorted_ids.size(0) == sorted_ids_size); + STD_TORCH_CHECK(batch_ids.size(0) == sorted_ids_size / block_size); + STD_TORCH_CHECK(num_tokens_post_pad.size(0) == 1); + STD_TORCH_CHECK(B <= batched_kernel::num_threads); batched_kernel::batched_moe_align_block_size_kernel<<< batched_kernel::num_blocks, batched_kernel::num_threads, 0, stream>>>( - B, max_tokens_per_batch, block_size, batch_num_tokens.data_ptr(), - sorted_ids.data_ptr(), batch_ids.data_ptr(), - num_tokens_post_pad.data_ptr()); + B, max_tokens_per_batch, block_size, + reinterpret_cast(batch_num_tokens.const_data_ptr()), + reinterpret_cast(sorted_ids.mutable_data_ptr()), + reinterpret_cast(batch_ids.mutable_data_ptr()), + reinterpret_cast(num_tokens_post_pad.mutable_data_ptr())); } -void moe_sum(torch::Tensor& input, // [num_tokens, topk, hidden_size] - torch::Tensor& output) // [num_tokens, hidden_size] +void moe_sum(torch::stable::Tensor& input, // [num_tokens, topk, hidden_size] + torch::stable::Tensor& output) // [num_tokens, hidden_size] { + // Output is dense and written in place, so it must be contiguous. The input + // is read by its strides (no copy); only the hidden dim needs to be + // contiguous to take the vectorized path. + STD_TORCH_CHECK(output.is_contiguous(), + "moe_sum expects a contiguous output"); + const int hidden_size = input.size(-1); - const auto num_tokens = output.numel() / hidden_size; + const int64_t num_tokens = output.numel() / hidden_size; const int topk = input.size(1); - - dim3 grid(num_tokens); - dim3 block(std::min(hidden_size, 1024)); - const at::cuda::OptionalCUDAGuard device_guard(device_of(output)); - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); - - switch (topk) { - case 2: - VLLM_DISPATCH_FLOATING_TYPES(input.scalar_type(), "moe_sum_kernel", [&] { - vllm::moe::moe_sum_kernel<<>>( - output.data_ptr(), input.data_ptr(), - hidden_size); - }); - break; - - case 3: - VLLM_DISPATCH_FLOATING_TYPES(input.scalar_type(), "moe_sum_kernel", [&] { - vllm::moe::moe_sum_kernel<<>>( - output.data_ptr(), input.data_ptr(), - hidden_size); - }); - break; - - case 4: - VLLM_DISPATCH_FLOATING_TYPES(input.scalar_type(), "moe_sum_kernel", [&] { - vllm::moe::moe_sum_kernel<<>>( - output.data_ptr(), input.data_ptr(), - hidden_size); - }); - break; - - default: - at::sum_out(output, input, 1); - break; - } + const int64_t stride_token = input.stride(0); + const int64_t stride_topk = input.stride(1); + const int64_t stride_hidden = input.stride(2); + + const torch::stable::accelerator::DeviceGuard device_guard( + output.get_device_index()); + const cudaStream_t stream = + get_current_cuda_stream(output.get_device_index()); + +#define LAUNCH_MOE_SUM_VEC(TOPK) \ + vllm::moe::moe_sum_vec_kernel \ + <<>>( \ + out_ptr, in_ptr, num_tokens, hidden_size, stride_token, stride_topk) + + VLLM_STABLE_DISPATCH_FLOATING_TYPES(input.scalar_type(), "moe_sum", [&] { + constexpr int VEC = vllm::moe::MOE_SUM_VEC; + constexpr int WIDTH = VEC * sizeof(scalar_t); // 16 bytes + auto* out_ptr = reinterpret_cast(output.mutable_data_ptr()); + auto* in_ptr = reinterpret_cast(input.const_data_ptr()); + + // Vectorize along hidden only when it is contiguous (innermost stride 1), + // a whole number of vectors, and every row offset stays 16B-aligned. + const bool can_vec = (stride_hidden == 1) && (hidden_size % VEC == 0) && + (stride_token % VEC == 0) && + (stride_topk % VEC == 0) && + (reinterpret_cast(in_ptr) % WIDTH == 0) && + (reinterpret_cast(out_ptr) % WIDTH == 0); + if (can_vec) { + const int64_t n_vec = hidden_size / VEC; + const int64_t total = num_tokens * n_vec; + const int block = 256; + const dim3 grid(std::min((total + block - 1) / block, 65535)); + switch (topk) { + case 1: + LAUNCH_MOE_SUM_VEC(1); + break; + case 2: + LAUNCH_MOE_SUM_VEC(2); + break; + case 4: + LAUNCH_MOE_SUM_VEC(4); + break; + case 6: + LAUNCH_MOE_SUM_VEC(6); + break; + case 8: + LAUNCH_MOE_SUM_VEC(8); + break; + case 9: + LAUNCH_MOE_SUM_VEC(9); + break; + default: + vllm::moe::moe_sum_vec_dynamic_kernel + <<>>(out_ptr, in_ptr, num_tokens, + hidden_size, topk, + stride_token, stride_topk); + break; + } + } else { + dim3 grid(num_tokens); + dim3 block(std::min(hidden_size, 1024)); + vllm::moe::moe_sum_scalar_kernel<<>>( + out_ptr, in_ptr, hidden_size, topk, stride_token, stride_topk, + stride_hidden); + } + }); +#undef LAUNCH_MOE_SUM_VEC } void moe_lora_align_block_size( - torch::Tensor topk_ids, torch::Tensor token_lora_mapping, + torch::stable::Tensor topk_ids, torch::stable::Tensor token_lora_mapping, int64_t num_experts, int64_t block_size, int64_t max_loras, int64_t max_num_tokens_padded, int64_t max_num_m_blocks, - torch::Tensor sorted_token_ids, torch::Tensor expert_ids, - torch::Tensor num_tokens_post_pad, torch::Tensor adapter_enabled, - torch::Tensor lora_ids, std::optional maybe_expert_map) { + torch::stable::Tensor sorted_token_ids, torch::stable::Tensor expert_ids, + torch::stable::Tensor num_tokens_post_pad, + torch::stable::Tensor adapter_enabled, torch::stable::Tensor lora_ids, + std::optional maybe_expert_map) { const int topk_num = topk_ids.size(1); - TORCH_CHECK(block_size > 0, "block_size should be greater than 0. "); + STD_TORCH_CHECK(block_size > 0, "block_size should be greater than 0. "); int device_max_shared_mem; - auto dev = topk_ids.get_device(); + int dev = topk_ids.get_device_index(); + const torch::stable::accelerator::DeviceGuard device_guard(dev); cudaDeviceGetAttribute(&device_max_shared_mem, cudaDevAttrMaxSharedMemoryPerBlockOptin, dev); - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); + const cudaStream_t stream = get_current_cuda_stream(dev); int64_t padded_num_experts = ((num_experts + WARP_SIZE - 1) / WARP_SIZE) * WARP_SIZE; // BlockScan uses 1024 threads and assigns one thread per expert. - TORCH_CHECK(padded_num_experts < 1024, - "padded_num_experts must be less than 1024"); + STD_TORCH_CHECK(padded_num_experts < 1024, + "padded_num_experts must be less than 1024"); - auto options_int = - torch::TensorOptions().dtype(torch::kInt).device(topk_ids.device()); - torch::Tensor token_mask = - torch::empty({max_loras * topk_ids.size(0)}, options_int); + torch::stable::Tensor token_mask = + torch::stable::new_empty(topk_ids, {max_loras * topk_ids.size(0)}, + torch::headeronly::ScalarType::Int); bool has_expert_map = maybe_expert_map.has_value(); - torch::Tensor expert_map; + torch::stable::Tensor expert_map; if (has_expert_map) { expert_map = maybe_expert_map.value(); } else { - expert_map = torch::empty({0}, options_int); + expert_map = torch::stable::new_empty(topk_ids, {0}, + torch::headeronly::ScalarType::Int); } - VLLM_DISPATCH_INTEGRAL_TYPES( + VLLM_STABLE_DISPATCH_INTEGRAL_TYPES( topk_ids.scalar_type(), "moe_lora_align_sum_kernel", [&] { bool small_batch_expert_mode = (topk_ids.numel() < 1024) && (num_experts <= 64); @@ -703,7 +841,7 @@ void moe_lora_align_block_size( (num_thread + 1) * num_experts * sizeof(int32_t) + (num_experts + 1) * sizeof(int32_t); if (shared_mem > device_max_shared_mem) { - TORCH_CHECK(false, "Shared memory usage exceeds device limit."); + STD_TORCH_CHECK(false, "Shared memory usage exceeds device limit."); } // threadIdx.x >= fill_threads: counting experts and aligning @@ -714,7 +852,7 @@ void moe_lora_align_block_size( auto kernel = vllm::moe::moe_lora_align_block_size_small_batch_expert_kernel< scalar_t, fill_threads>; - AT_CUDA_CHECK(VLLM_DevFuncAttribute_SET_MaxDynamicSharedMemorySize( + STD_CUDA_CHECK(VLLM_DevFuncAttribute_SET_MaxDynamicSharedMemorySize( (void*)kernel, shared_mem)); // Grid size is (max_loras + 1) because active_lora_ids has length // max_loras + 1: sorted-unique values of token_lora_mapping, which @@ -725,15 +863,21 @@ void moe_lora_align_block_size( // MoE-LoRA kernels. This mirrors the fix made for the Triton // _fused_moe_lora_kernel grid in vllm-project/vllm#32277. kernel<<>>( - topk_ids.data_ptr(), - token_lora_mapping.data_ptr(), block_size, - expert_map.data_ptr(), num_experts, max_loras, - topk_ids.numel(), max_num_tokens_padded, max_num_m_blocks, - sorted_token_ids.data_ptr(), - expert_ids.data_ptr(), topk_num, - num_tokens_post_pad.data_ptr(), - adapter_enabled.data_ptr(), lora_ids.data_ptr(), - token_mask.data_ptr(), has_expert_map); + reinterpret_cast(topk_ids.mutable_data_ptr()), + reinterpret_cast(token_lora_mapping.mutable_data_ptr()), + block_size, + reinterpret_cast(expert_map.mutable_data_ptr()), + num_experts, max_loras, topk_ids.numel(), max_num_tokens_padded, + max_num_m_blocks, + reinterpret_cast(sorted_token_ids.mutable_data_ptr()), + reinterpret_cast(expert_ids.mutable_data_ptr()), + topk_num, + reinterpret_cast( + num_tokens_post_pad.mutable_data_ptr()), + reinterpret_cast(adapter_enabled.mutable_data_ptr()), + reinterpret_cast(lora_ids.mutable_data_ptr()), + reinterpret_cast(token_mask.mutable_data_ptr()), + has_expert_map); } else { int num_thread = 1024; dim3 blockDim(num_thread); @@ -742,8 +886,9 @@ void moe_lora_align_block_size( size_t shared_mem_size = num_warps * WARP_SIZE * sizeof(int32_t); // cumsum buffer - torch::Tensor cumsum = - torch::zeros({max_loras * (num_experts + 1)}, options_int); + torch::stable::Tensor cumsum = torch::stable::new_zeros( + topk_ids, {max_loras * (num_experts + 1)}, + torch::headeronly::ScalarType::Int); auto align_kernel = vllm::moe::moe_lora_align_block_size_kernel; @@ -759,16 +904,23 @@ void moe_lora_align_block_size( // blockIdx.x % 2 == 1: filling sorted_token_ids align_kernel<<<(max_loras + 1) * 2, blockDim, shared_mem_size, stream>>>( - topk_ids.data_ptr(), - token_lora_mapping.data_ptr(), block_size, - expert_map.data_ptr(), num_experts, max_loras, - topk_ids.numel(), max_num_tokens_padded, max_num_m_blocks, - sorted_token_ids.data_ptr(), - expert_ids.data_ptr(), topk_num, - num_tokens_post_pad.data_ptr(), - adapter_enabled.data_ptr(), cumsum.data_ptr(), - WARP_SIZE, padded_num_experts, lora_ids.data_ptr(), - token_mask.data_ptr(), has_expert_map); + reinterpret_cast(topk_ids.mutable_data_ptr()), + reinterpret_cast(token_lora_mapping.mutable_data_ptr()), + block_size, + reinterpret_cast(expert_map.mutable_data_ptr()), + num_experts, max_loras, topk_ids.numel(), max_num_tokens_padded, + max_num_m_blocks, + reinterpret_cast(sorted_token_ids.mutable_data_ptr()), + reinterpret_cast(expert_ids.mutable_data_ptr()), + topk_num, + reinterpret_cast( + num_tokens_post_pad.mutable_data_ptr()), + reinterpret_cast(adapter_enabled.mutable_data_ptr()), + reinterpret_cast(cumsum.mutable_data_ptr()), WARP_SIZE, + padded_num_experts, + reinterpret_cast(lora_ids.mutable_data_ptr()), + reinterpret_cast(token_mask.mutable_data_ptr()), + has_expert_map); const int block_threads = std::min(256, (int)num_thread); const int num_blocks = @@ -785,12 +937,16 @@ void moe_lora_align_block_size( vllm::moe::lora_count_and_sort_expert_tokens_kernel; sort_kernel<<>>( - topk_ids.data_ptr(), - sorted_token_ids.data_ptr(), cumsum.data_ptr(), - expert_map.data_ptr(), topk_ids.numel(), num_experts, - max_num_tokens_padded, topk_num, token_mask.data_ptr(), - max_loras, lora_ids.data_ptr(), - adapter_enabled.data_ptr(), has_expert_map); + reinterpret_cast(topk_ids.const_data_ptr()), + reinterpret_cast(sorted_token_ids.mutable_data_ptr()), + reinterpret_cast(cumsum.mutable_data_ptr()), + reinterpret_cast(expert_map.mutable_data_ptr()), + topk_ids.numel(), num_experts, max_num_tokens_padded, topk_num, + reinterpret_cast(token_mask.mutable_data_ptr()), + max_loras, + reinterpret_cast(lora_ids.mutable_data_ptr()), + reinterpret_cast(adapter_enabled.mutable_data_ptr()), + has_expert_map); } }); } \ No newline at end of file diff --git a/csrc/libtorch_stable/moe/moe_ops.h b/csrc/libtorch_stable/moe/moe_ops.h new file mode 100644 index 000000000000..b60d2d548f57 --- /dev/null +++ b/csrc/libtorch_stable/moe/moe_ops.h @@ -0,0 +1,88 @@ +#pragma once + +#include + +#include +#include + +void topk_softmax(torch::stable::Tensor& topk_weights, + torch::stable::Tensor& topk_indices, + torch::stable::Tensor& token_expert_indices, + torch::stable::Tensor& gating_output, bool renormalize, + std::optional bias); + +void topk_sigmoid(torch::stable::Tensor& topk_weights, + torch::stable::Tensor& topk_indices, + torch::stable::Tensor& token_expert_indices, + torch::stable::Tensor& gating_output, bool renormalize, + std::optional bias, + double routed_scaling_factor); + +void topk_softplus_sqrt( + torch::stable::Tensor& topk_weights, torch::stable::Tensor& topk_indices, + torch::stable::Tensor& token_expert_indices, + torch::stable::Tensor& gating_output, bool renormalize, + double routed_scaling_factor, + const std::optional& correction_bias, + const std::optional& input_ids, + const std::optional& tid2eid); + +void moe_sum(torch::stable::Tensor& input, torch::stable::Tensor& output); + +void moe_align_block_size( + torch::stable::Tensor topk_ids, int64_t num_experts, int64_t block_size, + torch::stable::Tensor sorted_token_ids, torch::stable::Tensor experts_ids, + torch::stable::Tensor num_tokens_post_pad, + std::optional maybe_expert_map); + +void batched_moe_align_block_size( + int64_t max_tokens_per_batch, int64_t block_size, + const torch::stable::Tensor& expert_num_tokens, + torch::stable::Tensor sorted_ids, torch::stable::Tensor expert_ids, + torch::stable::Tensor num_tokens_post_pad); + +void moe_lora_align_block_size( + torch::stable::Tensor topk_ids, torch::stable::Tensor token_lora_mapping, + int64_t num_experts, int64_t block_size, int64_t max_loras, + int64_t max_num_tokens_padded, int64_t max_num_m_blocks, + torch::stable::Tensor sorted_token_ids, torch::stable::Tensor expert_ids, + torch::stable::Tensor num_tokens_post_pad, + torch::stable::Tensor adapter_enabled, torch::stable::Tensor lora_ids, + std::optional maybe_expert_map); +#ifndef USE_ROCM +torch::stable::Tensor moe_wna16_gemm( + torch::stable::Tensor input, torch::stable::Tensor output, + torch::stable::Tensor b_qweight, torch::stable::Tensor b_scales, + std::optional b_qzeros, + std::optional topk_weights, + torch::stable::Tensor sorted_token_ids, torch::stable::Tensor expert_ids, + torch::stable::Tensor num_tokens_post_pad, int64_t top_k, + int64_t BLOCK_SIZE_M, int64_t BLOCK_SIZE_N, int64_t BLOCK_SIZE_K, + int64_t bit); + +std::tuple grouped_topk( + const torch::stable::Tensor& scores, int64_t n_group, int64_t topk_group, + int64_t topk, bool renormalize, double routed_scaling_factor, + const torch::stable::Tensor& bias, int64_t scoring_func); +#endif + +bool moe_permute_unpermute_supported(); + +int64_t moe_permute_sort_workspace_size(int64_t num_expanded_rows, + int64_t num_expert); + +void shuffle_rows(const torch::stable::Tensor& input_tensor, + const torch::stable::Tensor& dst2src_map, + torch::stable::Tensor& output_tensor); + +#ifndef USE_ROCM +// DeepSeek V3 optimized router GEMM kernel for SM90+ +// Computes output = mat_a @ mat_b.T where: +// mat_a: [num_tokens, hidden_dim] in bf16 +// mat_b: [num_experts, hidden_dim] in bf16 +// output: [num_tokens, num_experts] in bf16 or fp32 +// Supports num_tokens in [1, 16], num_experts in {256, 384}, hidden_dim = 7168 +void dsv3_router_gemm(torch::stable::Tensor& output, + const torch::stable::Tensor& mat_a, + const torch::stable::Tensor& mat_b); +#endif diff --git a/csrc/libtorch_stable/moe/moe_permute_unpermute_op.cu b/csrc/libtorch_stable/moe/moe_permute_unpermute_op.cu new file mode 100644 index 000000000000..52cf68442f1e --- /dev/null +++ b/csrc/libtorch_stable/moe/moe_permute_unpermute_op.cu @@ -0,0 +1,325 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "core/registration.h" +#include "libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h" +#include "libtorch_stable/torch_utils.h" + +#include + +// moe_permute kernels require at least CUDA 12.0 +#if defined(CUDA_VERSION) && (CUDA_VERSION >= 12000) + +namespace { + +int64_t product_integers(torch::headeronly::IntHeaderOnlyArrayRef sizes) { + int64_t numel = 1; + for (int64_t s : sizes) { + numel *= s; + } + return numel; +} + +torch::stable::Tensor maybe_allocate_tensor( + const std::optional& maybe_tensor, + torch::headeronly::IntHeaderOnlyArrayRef expected_sizes, + torch::headeronly::ScalarType dtype, torch::stable::Device device, + char const* name) { + auto expected_numel = product_integers(expected_sizes); + if (maybe_tensor.has_value()) { + auto tensor = maybe_tensor.value(); + STD_TORCH_CHECK(tensor.device() == device, name, + " must be on the same device"); + STD_TORCH_CHECK(tensor.scalar_type() == dtype, name, + " has incorrect dtype"); + STD_TORCH_CHECK(tensor.is_contiguous(), name, " must be contiguous"); + STD_TORCH_CHECK(tensor.numel() >= expected_numel, name, + " is too small for the requested shape"); + auto flat_tensor = torch::stable::view(tensor, {tensor.numel()}); + return torch::stable::view( + torch::stable::narrow(flat_tensor, 0, 0, expected_numel), + expected_sizes); + } + return torch::stable::empty(expected_sizes, dtype, std::nullopt, device); +} + +} // namespace + +int64_t moe_permute_sort_workspace_size(int64_t num_expanded_rows, + int64_t n_expert) { + return static_cast( + CubKeyValueSorter::getWorkspaceSize(num_expanded_rows, n_expert)); +} + +void moe_permute_impl( + const torch::stable::Tensor& input, // [n_token, hidden] + const torch::stable::Tensor& topk_ids, // [n_token, topk] + const torch::stable::Tensor& token_expert_indices, // [n_token, topk] + const std::optional& expert_map, // [n_expert] + int64_t n_expert, int64_t n_local_expert, int64_t topk, + torch::stable::Tensor& permuted_input, // [permuted_size, hidden] + torch::stable::Tensor& expert_first_token_offset, // [n_local_expert + 1] + torch::stable::Tensor& inv_permuted_idx, // [n_token, topk] + torch::stable::Tensor& permuted_idx, // [permute_size] + const std::optional& maybe_sort_workspace, + const std::optional& maybe_permuted_experts_id, + const std::optional& maybe_sorted_row_idx, + const std::optional& maybe_topk_ids_for_sort) { + STD_TORCH_CHECK(expert_first_token_offset.scalar_type() == + torch::headeronly::ScalarType::Long, + "expert_first_token_offset must be int64"); + STD_TORCH_CHECK(topk_ids.scalar_type() == torch::headeronly::ScalarType::Int, + "topk_ids must be int32"); + STD_TORCH_CHECK( + token_expert_indices.scalar_type() == torch::headeronly::ScalarType::Int, + "token_expert_indices must be int32"); + STD_TORCH_CHECK( + inv_permuted_idx.scalar_type() == torch::headeronly::ScalarType::Int, + "inv_permuted_idx must be int32"); + STD_TORCH_CHECK(expert_first_token_offset.size(0) == n_local_expert + 1, + "expert_first_token_offset shape != n_local_expert+1"); + STD_TORCH_CHECK( + inv_permuted_idx.sizes().equals(token_expert_indices.sizes()), + "token_expert_indices shape must be same as inv_permuted_idx"); + + const torch::stable::accelerator::DeviceGuard device_guard( + input.get_device_index()); + auto device = input.device(); + auto n_token = input.sizes()[0]; + auto n_hidden = input.sizes()[1]; + auto expanded_rows = n_token * topk; + auto stream = get_current_cuda_stream(input.get_device_index()); + + auto sorter_size = moe_permute_sort_workspace_size(expanded_rows, n_expert); + auto sort_workspace = maybe_allocate_tensor( + maybe_sort_workspace, {sorter_size}, torch::headeronly::ScalarType::Char, + device, "sort_workspace"); + auto permuted_experts_id = maybe_allocate_tensor( + maybe_permuted_experts_id, topk_ids.sizes(), + torch::headeronly::ScalarType::Int, device, "permuted_experts_id"); + auto sorted_row_idx = maybe_allocate_tensor( + maybe_sorted_row_idx, inv_permuted_idx.sizes(), + torch::headeronly::ScalarType::Int, device, "sorted_row_idx"); + + CubKeyValueSorter sorter{}; + int64_t* valid_num_ptr = nullptr; + torch::stable::Tensor topk_ids_for_sort = topk_ids; + + if (expert_map.has_value()) { + const int* expert_map_ptr = get_ptr(expert_map.value()); + valid_num_ptr = + get_ptr(expert_first_token_offset) + n_local_expert; + topk_ids_for_sort = maybe_allocate_tensor( + maybe_topk_ids_for_sort, topk_ids.sizes(), + torch::headeronly::ScalarType::Int, device, "topk_ids_for_sort"); + torch::stable::copy_(topk_ids_for_sort, topk_ids); + preprocessTopkIdLauncher(get_ptr(topk_ids_for_sort), n_token * topk, + expert_map_ptr, n_expert, stream); + } + + sortAndScanExpert( + get_ptr(topk_ids_for_sort), get_ptr(token_expert_indices), + get_ptr(permuted_experts_id), get_ptr(sorted_row_idx), + get_ptr(expert_first_token_offset), n_token, n_expert, + n_local_expert, topk, sorter, get_ptr(sort_workspace), stream); + + MOE_DISPATCH(input.scalar_type(), [&] { + expandInputRowsKernelLauncher( + get_ptr(input), get_ptr(permuted_input), + get_ptr(sorted_row_idx), get_ptr(inv_permuted_idx), + get_ptr(permuted_idx), get_ptr(expert_first_token_offset), + n_token, valid_num_ptr, n_hidden, topk, n_local_expert, stream); + }); +} + +void moe_permute( + const torch::stable::Tensor& input, // [n_token, hidden] + const torch::stable::Tensor& topk_ids, // [n_token, topk] + const torch::stable::Tensor& token_expert_indices, // [n_token, topk] + const std::optional& expert_map, // [n_expert] + int64_t n_expert, int64_t n_local_expert, int64_t topk, + torch::stable::Tensor& permuted_input, // [permuted_size, hidden] + torch::stable::Tensor& expert_first_token_offset, // [n_local_expert + 1] + torch::stable::Tensor& inv_permuted_idx, // [n_token, topk] + torch::stable::Tensor& permuted_idx) { // [permute_size] + moe_permute_impl(input, topk_ids, token_expert_indices, expert_map, n_expert, + n_local_expert, topk, permuted_input, + expert_first_token_offset, inv_permuted_idx, permuted_idx, + std::nullopt, std::nullopt, std::nullopt, std::nullopt); +} + +void moe_permute_with_scratch( + const torch::stable::Tensor& input, const torch::stable::Tensor& topk_ids, + const torch::stable::Tensor& token_expert_indices, + const std::optional& expert_map, int64_t n_expert, + int64_t n_local_expert, int64_t topk, torch::stable::Tensor& permuted_input, + torch::stable::Tensor& expert_first_token_offset, + torch::stable::Tensor& inv_permuted_idx, + torch::stable::Tensor& permuted_idx, torch::stable::Tensor& sort_workspace, + torch::stable::Tensor& permuted_experts_id, + torch::stable::Tensor& sorted_row_idx, + torch::stable::Tensor& topk_ids_for_sort) { + moe_permute_impl(input, topk_ids, token_expert_indices, expert_map, n_expert, + n_local_expert, topk, permuted_input, + expert_first_token_offset, inv_permuted_idx, permuted_idx, + sort_workspace, permuted_experts_id, sorted_row_idx, + topk_ids_for_sort); +} + +void moe_unpermute( + const torch::stable::Tensor& + permuted_hidden_states, // [n_token * topk, hidden] + const torch::stable::Tensor& topk_weights, // [n_token, topk] + const torch::stable::Tensor& inv_permuted_idx, // [n_token, topk] + const std::optional& + expert_first_token_offset, // [n_local_expert+1] + int64_t topk, + torch::stable::Tensor& hidden_states) { // [n_token, hidden] + STD_TORCH_CHECK( + permuted_hidden_states.scalar_type() == hidden_states.scalar_type(), + "permuted_hidden_states dtype must be same as hidden_states"); + + const torch::stable::accelerator::DeviceGuard device_guard( + hidden_states.get_device_index()); + auto n_token = hidden_states.size(0); + auto n_hidden = hidden_states.size(1); + auto stream = get_current_cuda_stream(hidden_states.get_device_index()); + + int64_t const* valid_ptr = nullptr; + if (expert_first_token_offset.has_value()) { + int n_local_expert = expert_first_token_offset.value().size(0) - 1; + valid_ptr = + get_ptr(expert_first_token_offset.value()) + n_local_expert; + } + + MOE_DISPATCH(hidden_states.scalar_type(), [&] { + finalizeMoeRoutingKernelLauncher( + get_ptr(permuted_hidden_states), + get_ptr(hidden_states), get_ptr(topk_weights), + get_ptr(inv_permuted_idx), n_token, n_hidden, topk, valid_ptr, + stream); + }); +} + +template +__global__ void shuffleInputRowsKernel(const T* input, + const int32_t* dst2src_map, T* output, + int64_t num_src_rows, + int64_t num_dst_rows, int64_t num_cols) { + int64_t dest_row_idx = blockIdx.x; + int64_t const source_row_idx = dst2src_map[dest_row_idx]; + + if (blockIdx.x < num_dst_rows) { + // Load 128-bits per thread + constexpr int64_t ELEM_PER_THREAD = 128 / sizeof(T) / 8; + using DataElem = cutlass::Array; + + // Duplicate and permute rows + auto const* source_row_ptr = + reinterpret_cast(input + source_row_idx * num_cols); + auto* dest_row_ptr = + reinterpret_cast(output + dest_row_idx * num_cols); + + int64_t const start_offset = threadIdx.x; + int64_t const stride = blockDim.x; + int64_t const num_elems_in_col = num_cols / ELEM_PER_THREAD; + + for (int elem_index = start_offset; elem_index < num_elems_in_col; + elem_index += stride) { + dest_row_ptr[elem_index] = source_row_ptr[elem_index]; + } + } +} + +void shuffle_rows(const torch::stable::Tensor& input_tensor, + const torch::stable::Tensor& dst2src_map, + torch::stable::Tensor& output_tensor) { + STD_TORCH_CHECK(input_tensor.scalar_type() == output_tensor.scalar_type(), + "Input and output tensors must have the same data type"); + + const torch::stable::accelerator::DeviceGuard device_guard( + output_tensor.get_device_index()); + auto stream = get_current_cuda_stream(output_tensor.get_device_index()); + const int64_t blocks = output_tensor.size(0); + const int64_t threads = 256; + const int64_t num_dest_rows = output_tensor.size(0); + const int64_t num_src_rows = input_tensor.size(0); + const int64_t num_cols = input_tensor.size(1); + + STD_TORCH_CHECK(!(num_cols % (128 / input_tensor.element_size() / 8)), + "num_cols must be divisible by 128 / " + "input_tensor.element_size() / 8"); + + MOE_DISPATCH(input_tensor.scalar_type(), [&] { + shuffleInputRowsKernel<<>>( + reinterpret_cast(input_tensor.const_data_ptr()), + reinterpret_cast(dst2src_map.const_data_ptr()), + reinterpret_cast(output_tensor.mutable_data_ptr()), + num_src_rows, num_dest_rows, num_cols); + }); +} + +#else + +int64_t moe_permute_sort_workspace_size(int64_t num_expanded_rows, + int64_t n_expert) { + STD_TORCH_CHECK( + false, "moe_permute_sort_workspace_size is not supported on CUDA < 12.0"); +} + +void moe_permute(const torch::stable::Tensor& input, + const torch::stable::Tensor& topk_ids, + const torch::stable::Tensor& token_expert_indices, + const std::optional& expert_map, + int64_t n_expert, int64_t n_local_expert, int64_t topk, + torch::stable::Tensor& permuted_input, + torch::stable::Tensor& expert_first_token_offset, + torch::stable::Tensor& inv_permuted_idx, + torch::stable::Tensor& permuted_idx) { + STD_TORCH_CHECK(false, "moe_permute is not supported on CUDA < 12.0"); +} + +void moe_permute_with_scratch( + const torch::stable::Tensor& input, const torch::stable::Tensor& topk_ids, + const torch::stable::Tensor& token_expert_indices, + const std::optional& expert_map, int64_t n_expert, + int64_t n_local_expert, int64_t topk, torch::stable::Tensor& permuted_input, + torch::stable::Tensor& expert_first_token_offset, + torch::stable::Tensor& inv_permuted_idx, + torch::stable::Tensor& permuted_idx, torch::stable::Tensor& sort_workspace, + torch::stable::Tensor& permuted_experts_id, + torch::stable::Tensor& sorted_row_idx, + torch::stable::Tensor& topk_ids_for_sort) { + STD_TORCH_CHECK(false, + "moe_permute_with_scratch is not supported on CUDA < 12.0"); +} + +void moe_unpermute( + const torch::stable::Tensor& permuted_hidden_states, + const torch::stable::Tensor& topk_weights, + const torch::stable::Tensor& inv_permuted_idx, + const std::optional& expert_first_token_offset, + int64_t topk, torch::stable::Tensor& hidden_states) { + STD_TORCH_CHECK(false, "moe_unpermute is not supported on CUDA < 12.0"); +} + +#endif + +bool moe_permute_unpermute_supported() { +#if defined(CUDA_VERSION) && (CUDA_VERSION >= 12000) + return true; +#else + return false; +#endif +} + +STABLE_TORCH_LIBRARY_IMPL(_moe_C, CUDA, m) { + m.impl("moe_permute", TORCH_BOX(&moe_permute)); + m.impl("moe_permute_with_scratch", TORCH_BOX(&moe_permute_with_scratch)); + m.impl("moe_unpermute", TORCH_BOX(&moe_unpermute)); +} \ No newline at end of file diff --git a/csrc/moe/moe_wna16.cu b/csrc/libtorch_stable/moe/moe_wna16.cu similarity index 77% rename from csrc/moe/moe_wna16.cu rename to csrc/libtorch_stable/moe/moe_wna16.cu index 7b6a111c00ad..9345a7c9f789 100644 --- a/csrc/moe/moe_wna16.cu +++ b/csrc/libtorch_stable/moe/moe_wna16.cu @@ -1,11 +1,14 @@ +#include -#include -#include -#include #include +#include +#include +#include +#include #include #include +#include "libtorch_stable/torch_utils.h" #include "moe_wna16_utils.h" #define DIVIDE(x, size) (((x) + (size) - 1) / (size)) @@ -263,7 +266,7 @@ void run_moe_wna16_gemm(const scalar_t* input, scalar_t* output, } const int shared_mem_size = BLOCK_SIZE_M * BLOCK_SIZE_K * 2; - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); + const cudaStream_t stream = get_current_cuda_stream(); kernel<<>>( input, output, b_qweight, b_scales, b_qzeros, topk_weights, sorted_token_ids, expert_ids, num_tokens_post_pad, num_experts, @@ -271,17 +274,18 @@ void run_moe_wna16_gemm(const scalar_t* input, scalar_t* output, BLOCK_SIZE_K, has_zp, mul_topk_weight); } -torch::Tensor moe_wna16_gemm(torch::Tensor input, torch::Tensor output, - torch::Tensor b_qweight, torch::Tensor b_scales, - std::optional b_qzeros, - std::optional topk_weights, - torch::Tensor sorted_token_ids, - torch::Tensor expert_ids, - torch::Tensor num_tokens_post_pad, int64_t top_k, - int64_t BLOCK_SIZE_M, int64_t BLOCK_SIZE_N, - int64_t BLOCK_SIZE_K, int64_t bit) { - const at::cuda::OptionalCUDAGuard device_guard(device_of(input)); - output.zero_(); +torch::stable::Tensor moe_wna16_gemm( + torch::stable::Tensor input, torch::stable::Tensor output, + torch::stable::Tensor b_qweight, torch::stable::Tensor b_scales, + std::optional b_qzeros, + std::optional topk_weights, + torch::stable::Tensor sorted_token_ids, torch::stable::Tensor expert_ids, + torch::stable::Tensor num_tokens_post_pad, int64_t top_k, + int64_t BLOCK_SIZE_M, int64_t BLOCK_SIZE_N, int64_t BLOCK_SIZE_K, + int64_t bit) { + const torch::stable::accelerator::DeviceGuard device_guard( + input.get_device_index()); + torch::stable::zero_(output); const int num_experts = b_qweight.size(0); const int size_m = input.size(0); @@ -291,52 +295,56 @@ torch::Tensor moe_wna16_gemm(torch::Tensor input, torch::Tensor output, int64_t EM = sorted_token_ids.size(0); if (size_m <= BLOCK_SIZE_M) { - EM = min(EM, size_m * BLOCK_SIZE_M * top_k); + EM = std::min(EM, size_m * BLOCK_SIZE_M * top_k); } const int num_token_blocks = (EM + BLOCK_SIZE_M - 1) / BLOCK_SIZE_M; const uint32_t* b_qzeros_ptr; if (b_qzeros.has_value()) - b_qzeros_ptr = (const uint32_t*)b_qzeros.value().data_ptr(); + b_qzeros_ptr = (const uint32_t*)b_qzeros.value().const_data_ptr(); const float* topk_weights_ptr = nullptr; if (topk_weights.has_value()) - topk_weights_ptr = (const float*)topk_weights.value().data_ptr(); + topk_weights_ptr = + (const float*)topk_weights.value().const_data_ptr(); int groups_per_block_row = BLOCK_SIZE_K / group_size; - TORCH_CHECK(bit == 4 || bit == 8, "bit must be 4 or 8"); - TORCH_CHECK(size_k % BLOCK_SIZE_K == 0, - "size_k must divisible by BLOCK_SIZE_K"); - TORCH_CHECK(BLOCK_SIZE_K % group_size == 0, - "BLOCK_SIZE_K must divisible by group_size"); - TORCH_CHECK(BLOCK_SIZE_M <= 64, "BLOCK_SIZE_M must less or equal to 64"); - TORCH_CHECK(groups_per_block_row == 1 || groups_per_block_row == 2 || - groups_per_block_row == 4 || groups_per_block_row == 8, - "BLOCK_SIZE_K // group_size must be one of [1, 2, 4, 8]"); - - if (input.scalar_type() == at::ScalarType::Half) { + STD_TORCH_CHECK(bit == 4 || bit == 8, "bit must be 4 or 8"); + STD_TORCH_CHECK(size_k % BLOCK_SIZE_K == 0, + "size_k must divisible by BLOCK_SIZE_K"); + STD_TORCH_CHECK(BLOCK_SIZE_K % group_size == 0, + "BLOCK_SIZE_K must divisible by group_size"); + STD_TORCH_CHECK(BLOCK_SIZE_M <= 64, "BLOCK_SIZE_M must less or equal to 64"); + STD_TORCH_CHECK(groups_per_block_row == 1 || groups_per_block_row == 2 || + groups_per_block_row == 4 || groups_per_block_row == 8, + "BLOCK_SIZE_K // group_size must be one of [1, 2, 4, 8]"); + + if (input.scalar_type() == torch::headeronly::ScalarType::Half) { run_moe_wna16_gemm( - (const half*)input.data_ptr(), - (half*)output.data_ptr(), - (const uint32_t*)b_qweight.data_ptr(), - (const half*)b_scales.data_ptr(), b_qzeros_ptr, - topk_weights_ptr, sorted_token_ids.data_ptr(), - expert_ids.data_ptr(), num_tokens_post_pad.data_ptr(), - num_experts, group_size, num_token_blocks, top_k, size_m, size_n, - size_k, BLOCK_SIZE_M, BLOCK_SIZE_N, BLOCK_SIZE_K, bit, - b_qzeros.has_value(), topk_weights.has_value()); - } else if (input.scalar_type() == at::ScalarType::BFloat16) { + reinterpret_cast(input.const_data_ptr()), + reinterpret_cast(output.mutable_data_ptr()), + (const uint32_t*)b_qweight.const_data_ptr(), + reinterpret_cast(b_scales.const_data_ptr()), b_qzeros_ptr, + topk_weights_ptr, sorted_token_ids.const_data_ptr(), + expert_ids.const_data_ptr(), + num_tokens_post_pad.const_data_ptr(), num_experts, group_size, + num_token_blocks, top_k, size_m, size_n, size_k, BLOCK_SIZE_M, + BLOCK_SIZE_N, BLOCK_SIZE_K, bit, b_qzeros.has_value(), + topk_weights.has_value()); + } else if (input.scalar_type() == torch::headeronly::ScalarType::BFloat16) { run_moe_wna16_gemm( - (const nv_bfloat16*)input.data_ptr(), - (nv_bfloat16*)output.data_ptr(), - (const uint32_t*)b_qweight.data_ptr(), - (const nv_bfloat16*)b_scales.data_ptr(), b_qzeros_ptr, - topk_weights_ptr, sorted_token_ids.data_ptr(), - expert_ids.data_ptr(), num_tokens_post_pad.data_ptr(), - num_experts, group_size, num_token_blocks, top_k, size_m, size_n, - size_k, BLOCK_SIZE_M, BLOCK_SIZE_N, BLOCK_SIZE_K, bit, - b_qzeros.has_value(), topk_weights.has_value()); + reinterpret_cast(input.const_data_ptr()), + reinterpret_cast(output.mutable_data_ptr()), + (const uint32_t*)b_qweight.const_data_ptr(), + reinterpret_cast(b_scales.const_data_ptr()), + b_qzeros_ptr, topk_weights_ptr, + sorted_token_ids.const_data_ptr(), + expert_ids.const_data_ptr(), + num_tokens_post_pad.const_data_ptr(), num_experts, group_size, + num_token_blocks, top_k, size_m, size_n, size_k, BLOCK_SIZE_M, + BLOCK_SIZE_N, BLOCK_SIZE_K, bit, b_qzeros.has_value(), + topk_weights.has_value()); } else { - TORCH_CHECK(false, "moe_wna16_gemm only supports bfloat16 and float16"); + STD_TORCH_CHECK(false, "moe_wna16_gemm only supports bfloat16 and float16"); } return output; } diff --git a/csrc/moe/moe_wna16_utils.h b/csrc/libtorch_stable/moe/moe_wna16_utils.h similarity index 100% rename from csrc/moe/moe_wna16_utils.h rename to csrc/libtorch_stable/moe/moe_wna16_utils.h diff --git a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm.cu b/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm.cu deleted file mode 100644 index fda9bc020da6..000000000000 --- a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm.cu +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright contributors to the vLLM project -// Adapted from SGLang: -// https://github.com/sgl-project/sglang/blob/ded068a76e00878881d52d5bfb791e0f60d7311b/sgl-kernel/csrc/expert_specialization/es_sm100_mxfp8_blockscaled.cu - -#include -#include -#include "libtorch_stable/torch_utils.h" - -#include "cutlass_mxfp8_grouped_mm_launcher.cuh" - -void cutlass_mxfp8_grouped_mm(const torch::stable::Tensor& a, - const torch::stable::Tensor& b, - const torch::stable::Tensor& sfa, - const torch::stable::Tensor& sfb, - torch::stable::Tensor& d, - const torch::stable::Tensor& problem_sizes, - const torch::stable::Tensor& expert_offsets, - const torch::stable::Tensor& blockscale_offsets) { -#if defined(CUTLASS_ARCH_MMA_SM100_SUPPORTED) - STD_TORCH_CHECK(problem_sizes.dim() == 2, "problem_sizes must be 2D tensor"); - STD_TORCH_CHECK(problem_sizes.size(1) == 3, - "problem_sizes must have shape (num_experts, 3)"); - STD_TORCH_CHECK( - problem_sizes.size(0) == expert_offsets.size(0), - "Number of experts in problem_sizes must match expert_offsets"); - STD_TORCH_CHECK( - problem_sizes.scalar_type() == torch::headeronly::ScalarType::Int, - "problem_sizes must be int32"); - STD_TORCH_CHECK( - expert_offsets.scalar_type() == torch::headeronly::ScalarType::Int, - "expert_offsets must be int32"); - STD_TORCH_CHECK( - blockscale_offsets.scalar_type() == torch::headeronly::ScalarType::Int, - "blockscale_offsets must be int32"); - STD_TORCH_CHECK(a.dim() == 2, - "a must be a 2D tensor of shape (num_tokens, k)"); - STD_TORCH_CHECK(b.dim() == 3, - "b must be a 3D tensor of shape (num_experts, k, n)"); - STD_TORCH_CHECK(a.size(1) == b.size(1) && a.size(1) % 128 == 0, - "k should align 128"); - STD_TORCH_CHECK(b.size(2) % 128 == 0, "n should align 128"); - STD_TORCH_CHECK(a.stride(1) == 1, "a must be row major"); - STD_TORCH_CHECK(b.stride(1) == 1, "b must be column major"); - - const torch::stable::accelerator::DeviceGuard device_guard( - a.get_device_index()); - auto stream = get_current_cuda_stream(a.get_device_index()); - if (d.scalar_type() == torch::headeronly::ScalarType::BFloat16) { - expert_specialization::cutlass_mxfp8_grouped_mm_dispatch_out_dtype< - cutlass::bfloat16_t>(a, b, sfa, sfb, d, problem_sizes, expert_offsets, - blockscale_offsets, stream); - } else if (d.scalar_type() == torch::headeronly::ScalarType::Half) { - expert_specialization::cutlass_mxfp8_grouped_mm_dispatch_out_dtype< - cutlass::half_t>(a, b, sfa, sfb, d, problem_sizes, expert_offsets, - blockscale_offsets, stream); - } else { - STD_TORCH_CHECK(false, "dtype must be kFloat16 or kBFloat16"); - } -#else - STD_TORCH_CHECK(false, - "No implemented cutlass_mxfp8_grouped_mm for " - "current device"); -#endif -} - -STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { - m.impl("cutlass_mxfp8_grouped_mm", TORCH_BOX(&cutlass_mxfp8_grouped_mm)); -} diff --git a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_functor.cuh b/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_functor.cuh deleted file mode 100644 index 9fb1dbf8eef5..000000000000 --- a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_functor.cuh +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright contributors to the vLLM project -// Adapted from SGLang: -// https://github.com/sgl-project/sglang/blob/ded068a76e00878881d52d5bfb791e0f60d7311b/sgl-kernel/csrc/expert_specialization/es_sm100_mxfp8_blockscaled_functor.cuh - -#pragma once -#include - -#include "cute/tensor.hpp" -#include "cutlass/util/packed_stride.hpp" -#include "cutlass_mxfp8_grouped_mm_traits.cuh" - -namespace expert_specialization { - -using namespace cute; - -template -struct CutlassMxfp8GroupedMmOffsetFunctor { - using Gemm = typename GemmTraits::Gemm; - using ElementA = typename Gemm::ElementA; - using ElementB = typename Gemm::ElementB; - using ElementSF = typename GemmTraits::ElementSF; - using ElementD = typename GemmTraits::ElementOutput; - // Input - int* expert_offsets{nullptr}; - int* blockscale_offsets{nullptr}; - // Output - ElementA* a_base{nullptr}; - ElementB* b_base{nullptr}; - ElementSF* sfa_base{nullptr}; - ElementSF* sfb_base{nullptr}; - ElementD* d_base{nullptr}; - ElementA** a_offsets{nullptr}; - ElementB** b_offsets{nullptr}; - ElementSF** sfa_offsets{nullptr}; - ElementSF** sfb_offsets{nullptr}; - ElementD** d_offsets{nullptr}; - - CutlassMxfp8GroupedMmOffsetFunctor() = default; - CutlassMxfp8GroupedMmOffsetFunctor( - int* _expert_offsets, int* _blockscale_offsets, ElementA* _a_base, - ElementB* _b_base, ElementSF* _sfa_base, ElementSF* _sfb_base, - ElementD* _d_base, ElementA** _a_offsets, ElementB** _b_offsets, - ElementSF** _sfa_offsets, ElementSF** _sfb_offsets, ElementD** _d_offsets) - : expert_offsets{_expert_offsets}, - blockscale_offsets{_blockscale_offsets}, - a_base(_a_base), - b_base(_b_base), - sfa_base(_sfa_base), - sfb_base(_sfb_base), - d_base(_d_base), - a_offsets(_a_offsets), - b_offsets(_b_offsets), - sfa_offsets(_sfa_offsets), - sfb_offsets(_sfb_offsets), - d_offsets(_d_offsets) {} - - void CUTE_DEVICE operator()(int64_t expert_id, int m, int n, int k) { - int64_t expert_offset = static_cast(expert_offsets[expert_id]); - int64_t blockscale_offset = - static_cast(blockscale_offsets[expert_id]); - int64_t a_stride = expert_offset * k; - int64_t b_stride = expert_id * k * n; - int64_t d_stride = expert_offset * n; - int64_t sfa_stride = blockscale_offset * (k / 32); - int64_t sfb_stride = expert_id * n * (k / 32); - - a_offsets[expert_id] = a_base + a_stride; - b_offsets[expert_id] = b_base + b_stride; - sfa_offsets[expert_id] = sfa_base + sfa_stride; - sfb_offsets[expert_id] = sfb_base + sfb_stride; - d_offsets[expert_id] = d_base + d_stride; - } -}; - -template -struct CutlassMxfp8GroupedMmLayoutFunctor { - using Sm1xxBlkScaledConfig = typename GemmTraits::Sm1xxBlkScaledConfig; - using LayoutSFA = typename GemmTraits::LayoutSFA; - using LayoutSFB = typename GemmTraits::LayoutSFB; - LayoutSFA* layout_sfa_base{nullptr}; - LayoutSFB* layout_sfb_base{nullptr}; - - CutlassMxfp8GroupedMmLayoutFunctor() = default; - CutlassMxfp8GroupedMmLayoutFunctor(LayoutSFA* _layout_sfa_base, - LayoutSFB* _layout_sfb_base) - : layout_sfa_base(_layout_sfa_base), layout_sfb_base(_layout_sfb_base) {} - - void CUTE_DEVICE operator()(int64_t expert_id, int m, int n, int k) { - LayoutSFA* layout_sfa_ptr = layout_sfa_base + expert_id; - LayoutSFB* layout_sfb_ptr = layout_sfb_base + expert_id; - *layout_sfa_ptr = Sm1xxBlkScaledConfig::tile_atom_to_shape_SFA( - cute::make_shape(m, n, k, 1)); - *layout_sfb_ptr = Sm1xxBlkScaledConfig::tile_atom_to_shape_SFB( - cute::make_shape(m, n, k, 1)); - } -}; - -template -struct CutlassMxfp8GroupedMmStrideFunctor { - using StrideA = typename GemmTraits::StrideA; - using StrideB = typename GemmTraits::StrideB; - using StrideD = typename GemmTraits::StrideD; - StrideA* stride_A_base{nullptr}; - StrideB* stride_B_base{nullptr}; - StrideD* stride_D_base{nullptr}; - - CutlassMxfp8GroupedMmStrideFunctor() = default; - CutlassMxfp8GroupedMmStrideFunctor(StrideA* _stride_A_base, - StrideB* _stride_B_base, - StrideD* _stride_D_base) - : stride_A_base(_stride_A_base), - stride_B_base(_stride_B_base), - stride_D_base(_stride_D_base) {} - - void CUTE_DEVICE operator()(int64_t expert_id, int m, int n, int k) { - StrideA* stride_A = stride_A_base + expert_id; - StrideB* stride_B = stride_B_base + expert_id; - StrideD* stride_D = stride_D_base + expert_id; - *stride_A = cutlass::make_cute_packed_stride(StrideA{}, {m, k, 1}); - *stride_B = cutlass::make_cute_packed_stride(StrideB{}, {n, k, 1}); - *stride_D = cutlass::make_cute_packed_stride(StrideD{}, {m, n, 1}); - } -}; - -template -__global__ void cutlassMxfp8GroupedMmPreComputeKernel( - int* problem_sizes, OffsetFunctor offset_functor, - LayoutFunctor layout_functor, StrideFunctor stride_functor) { - int64_t expert_id = static_cast(threadIdx.x); - int m = problem_sizes[expert_id * 3 + 0]; - int n = problem_sizes[expert_id * 3 + 1]; - int k = problem_sizes[expert_id * 3 + 2]; - - offset_functor(expert_id, m, n, k); - layout_functor(expert_id, m, n, k); - stride_functor(expert_id, m, n, k); -} - -} // namespace expert_specialization \ No newline at end of file diff --git a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_launcher.cuh b/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_launcher.cuh deleted file mode 100644 index 82d6543b288c..000000000000 --- a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_launcher.cuh +++ /dev/null @@ -1,198 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright contributors to the vLLM project -// Adapted from SGLang: -// https://github.com/sgl-project/sglang/blob/ded068a76e00878881d52d5bfb791e0f60d7311b/sgl-kernel/csrc/expert_specialization/es_sm100_mxfp8_blockscaled_launcher.cuh - -#pragma once - -#include -#include - -#include -#include -#include - -#include "cute/tensor.hpp" -#include "cutlass_mxfp8_grouped_mm_functor.cuh" -#include "cutlass_mxfp8_grouped_mm_traits.cuh" -#include "libtorch_stable/torch_utils.h" - -namespace expert_specialization { - -template -void cutlass_mxfp8_grouped_mm_pre_compute( - torch::stable::Tensor& a_ptrs, torch::stable::Tensor& b_ptrs, - torch::stable::Tensor& sfa_ptrs, torch::stable::Tensor& sfb_ptrs, - torch::stable::Tensor& d_ptrs, torch::stable::Tensor& stride_a, - torch::stable::Tensor& stride_b, torch::stable::Tensor& stride_d, - torch::stable::Tensor& layout_sfa, torch::stable::Tensor& layout_sfb, - const torch::stable::Tensor& a, const torch::stable::Tensor& b, - const torch::stable::Tensor& sfa, const torch::stable::Tensor& sfb, - const torch::stable::Tensor& d, const torch::stable::Tensor& problem_sizes, - const torch::stable::Tensor& expert_offsets, - const torch::stable::Tensor& blockscale_offsets, cudaStream_t stream) { - using OffsetFunctor = CutlassMxfp8GroupedMmOffsetFunctor; - using ElementA = typename OffsetFunctor::ElementA; - using ElementB = typename OffsetFunctor::ElementB; - using ElementSF = typename OffsetFunctor::ElementSF; - using ElementD = typename OffsetFunctor::ElementD; - - using LayoutFunctor = CutlassMxfp8GroupedMmLayoutFunctor; - using LayoutSFA = typename LayoutFunctor::LayoutSFA; - using LayoutSFB = typename LayoutFunctor::LayoutSFB; - - using StrideFunctor = CutlassMxfp8GroupedMmStrideFunctor; - using StrideA = typename StrideFunctor::StrideA; - using StrideB = typename StrideFunctor::StrideB; - using StrideD = typename StrideFunctor::StrideD; - - int num_experts = static_cast(expert_offsets.size(0)); - STD_TORCH_CHECK(num_experts <= 1024, - "Number of experts cannot exceed 1024, the maximum number of " - "threads per block."); - - OffsetFunctor offset_functor( - reinterpret_cast(expert_offsets.data_ptr()), - reinterpret_cast(blockscale_offsets.data_ptr()), - reinterpret_cast(a.data_ptr()), - reinterpret_cast(b.data_ptr()), - reinterpret_cast(sfa.data_ptr()), - reinterpret_cast(sfb.data_ptr()), - reinterpret_cast(d.data_ptr()), - reinterpret_cast(a_ptrs.data_ptr()), - reinterpret_cast(b_ptrs.data_ptr()), - reinterpret_cast(sfa_ptrs.data_ptr()), - reinterpret_cast(sfb_ptrs.data_ptr()), - reinterpret_cast(d_ptrs.data_ptr())); - LayoutFunctor layout_functor( - reinterpret_cast(layout_sfa.data_ptr()), - reinterpret_cast(layout_sfb.data_ptr())); - StrideFunctor stride_functor(reinterpret_cast(stride_a.data_ptr()), - reinterpret_cast(stride_b.data_ptr()), - reinterpret_cast(stride_d.data_ptr())); - cutlassMxfp8GroupedMmPreComputeKernel<<<1, num_experts, 0, stream>>>( - static_cast(problem_sizes.data_ptr()), offset_functor, - layout_functor, stride_functor); -} - -template -void cutlass_mxfp8_grouped_mm(const torch::stable::Tensor& a_ptrs, - const torch::stable::Tensor& b_ptrs, - const torch::stable::Tensor& sfa_ptrs, - const torch::stable::Tensor& sfb_ptrs, - const torch::stable::Tensor& d_ptrs, - const torch::stable::Tensor& stride_a, - const torch::stable::Tensor& stride_b, - const torch::stable::Tensor& stride_d, - const torch::stable::Tensor& layout_sfa, - const torch::stable::Tensor& layout_sfb, - const torch::stable::Tensor& problem_sizes, - cudaStream_t stream) { - using Gemm = typename GemmTraits::Gemm; - using ElementA = typename Gemm::ElementA; - using ElementB = typename Gemm::ElementB; - using ElementSF = typename GemmTraits::ElementSF; - using ElementD = typename GemmTraits::ElementOutput; - using StrideA = typename GemmTraits::StrideA; - using StrideB = typename GemmTraits::StrideB; - using StrideD = typename GemmTraits::StrideD; - using LayoutSFA = typename GemmTraits::LayoutSFA; - using LayoutSFB = typename GemmTraits::LayoutSFB; - using UnderlyingProblemShape = - typename GemmTraits::ProblemShape::UnderlyingProblemShape; - - cutlass::KernelHardwareInfo hw_info; - hw_info.device_id = d_ptrs.get_device_index(); - hw_info.sm_count = get_device_prop()->multiProcessorCount; - hw_info.cluster_shape = GemmTraits::MMAConfig::preferred_cluster; - hw_info.cluster_shape_fallback = GemmTraits::MMAConfig::fallback_cluster; - - int num_experts = static_cast(problem_sizes.size(0)); - - UnderlyingProblemShape* underlying_problem_shape = - reinterpret_cast(problem_sizes.data_ptr()); - - typename Gemm::Arguments arguments = { - cutlass::gemm::GemmUniversalMode::kGrouped, - {num_experts, underlying_problem_shape, nullptr}, - {reinterpret_cast(a_ptrs.data_ptr()), - reinterpret_cast(stride_a.data_ptr()), - reinterpret_cast(b_ptrs.data_ptr()), - reinterpret_cast(stride_b.data_ptr()), - reinterpret_cast(sfa_ptrs.data_ptr()), - reinterpret_cast(layout_sfa.data_ptr()), - reinterpret_cast(sfb_ptrs.data_ptr()), - reinterpret_cast(layout_sfb.data_ptr())}, - {{}, - nullptr, - nullptr, - reinterpret_cast(d_ptrs.data_ptr()), - reinterpret_cast(stride_d.data_ptr())}, - hw_info, - {} // Scheduler - }; - - Gemm gemm; - - auto can_implement_status = gemm.can_implement(arguments); - STD_TORCH_CHECK(can_implement_status == cutlass::Status::kSuccess, - "Failed to implement GEMM"); - - size_t workspace_size = gemm.get_workspace_size(arguments); - torch::stable::Tensor workspace = torch::stable::empty( - {static_cast(workspace_size)}, - torch::headeronly::ScalarType::Byte, std::nullopt, d_ptrs.device()); - - auto status = gemm.initialize(arguments, workspace.data_ptr(), stream); - STD_TORCH_CHECK(status == cutlass::Status::kSuccess, - "Failed to initialize GEMM"); - - status = gemm.run(stream, nullptr, true); // Enable PDL - STD_TORCH_CHECK(status == cutlass::Status::kSuccess, "Failed to run GEMM"); -} - -template -void cutlass_mxfp8_grouped_mm_dispatch_out_dtype( - const torch::stable::Tensor& a, const torch::stable::Tensor& b, - const torch::stable::Tensor& sfa, const torch::stable::Tensor& sfb, - torch::stable::Tensor& d, const torch::stable::Tensor& problem_sizes, - const torch::stable::Tensor& expert_offsets, - const torch::stable::Tensor& blockscale_offsets, cudaStream_t stream) { - int num_experts = static_cast(problem_sizes.size(0)); - auto device = a.device(); - - torch::stable::Tensor a_ptrs = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - torch::stable::Tensor b_ptrs = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - torch::stable::Tensor sfa_ptrs = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - torch::stable::Tensor sfb_ptrs = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - torch::stable::Tensor d_ptrs = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - - torch::stable::Tensor stride_a = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - torch::stable::Tensor stride_b = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - torch::stable::Tensor stride_d = torch::stable::empty( - num_experts, torch::headeronly::ScalarType::Long, std::nullopt, device); - torch::stable::Tensor layout_sfa = - torch::stable::empty({num_experts, 5}, torch::headeronly::ScalarType::Int, - std::nullopt, device); - torch::stable::Tensor layout_sfb = - torch::stable::empty({num_experts, 5}, torch::headeronly::ScalarType::Int, - std::nullopt, device); - - using GemmTraits = CutlassMxfp8GroupedMmGemmTraits; - cutlass_mxfp8_grouped_mm_pre_compute( - a_ptrs, b_ptrs, sfa_ptrs, sfb_ptrs, d_ptrs, stride_a, stride_b, stride_d, - layout_sfa, layout_sfb, a, b, sfa, sfb, d, problem_sizes, expert_offsets, - blockscale_offsets, stream); - cutlass_mxfp8_grouped_mm( - a_ptrs, b_ptrs, sfa_ptrs, sfb_ptrs, d_ptrs, stride_a, stride_b, stride_d, - layout_sfa, layout_sfb, problem_sizes, stream); -} - -} // namespace expert_specialization diff --git a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_traits.cuh b/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_traits.cuh deleted file mode 100644 index ed8cd7ce0658..000000000000 --- a/csrc/libtorch_stable/moe/mxfp8_moe/cutlass_mxfp8_grouped_mm_traits.cuh +++ /dev/null @@ -1,127 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright contributors to the vLLM project -// Adapted from SGLang: -// https://github.com/sgl-project/sglang/blob/ded068a76e00878881d52d5bfb791e0f60d7311b/sgl-kernel/csrc/expert_specialization/es_sm100_mxfp8_blockscaled_traits.cuh - -#pragma once - -// Misc -#include "cute/tensor.hpp" -#include "cutlass/arch/arch.h" -#include "cutlass/arch/mma.h" -#include "cutlass/cutlass.h" -#include "cutlass/detail/sm100_blockscaled_layout.hpp" -#include "cutlass/epilogue/dispatch_policy.hpp" -#include "cutlass/gemm/dispatch_policy.hpp" -#include "cutlass/gemm/group_array_problem_shape.hpp" -#include "cutlass/layout/layout.h" -#include "cutlass/numeric_conversion.h" -#include "cutlass/numeric_size.h" - -// Collective Builder -#include "cutlass/epilogue/collective/collective_builder.hpp" -#include "cutlass/epilogue/fusion/sm90_callbacks_tma_warpspecialized.hpp" -#include "cutlass/epilogue/thread/activation.h" -#include "cutlass/gemm/collective/collective_builder.hpp" - -// Integration -#include "cutlass/gemm/device/gemm_universal_adapter.h" -#include "cutlass/gemm/kernel/gemm_universal.hpp" - -namespace expert_specialization { - -using namespace cute; - -// Different configs for 1SM and 2SM MMA kernel -struct MMA1SMConfig { - using MmaTileShape = Shape<_128, _128, _128>; - using KernelSchedule = - cutlass::gemm::KernelPtrArrayTmaWarpSpecialized1SmMxf8f6f4Sm100; - using EpilogueSchedule = cutlass::epilogue::PtrArrayTmaWarpSpecialized1Sm; - const static dim3 preferred_cluster; - const static dim3 fallback_cluster; -}; -const dim3 MMA1SMConfig::preferred_cluster(1, 4, 1); -const dim3 MMA1SMConfig::fallback_cluster(1, 2, 1); - -template -struct CutlassMxfp8GroupedMmGemmTraits { - using MMAConfig = _MMAConfig; - using ElementInput = cutlass::float_e4m3_t; - using ElementOutput = OutputDtype; - using ProblemShape = cutlass::gemm::GroupProblemShape>; - - // A matrix configuration - using ElementA = cutlass::mx_float8_t; - using LayoutA = cutlass::layout::RowMajor; - constexpr static int AlignmentA = 32; - - // B matrix configuration - using ElementB = cutlass::mx_float8_t; - using LayoutB = cutlass::layout::ColumnMajor; - constexpr static int AlignmentB = 32; - - // C/D matrix configuration - using ElementC = void; - using ElementD = ElementOutput; - using LayoutC = cutlass::layout::RowMajor; - using LayoutD = cutlass::layout::RowMajor; - constexpr static int AlignmentC = 128 / cutlass::sizeof_bits::value; - constexpr static int AlignmentD = 128 / cutlass::sizeof_bits::value; - using ElementAccumulator = float; - - static constexpr auto RoundStyle = cutlass::FloatRoundStyle::round_to_nearest; - using CustomEVTIdentity = // acc - cutlass::epilogue::fusion::Sm90EVT< - cutlass::epilogue::fusion::Sm90Compute< - cutlass::epilogue::thread::Identity, ElementD, ElementAccumulator, - RoundStyle>, - cutlass::epilogue::fusion::Sm90AccFetch>; - - // Core kernel configurations - using ArchTag = cutlass::arch::Sm100; - using OperatorClass = cutlass::arch::OpClassBlockScaledTensorOp; - using StageCountType = cutlass::gemm::collective::StageCountAuto; - - // Runtime Cluster Shape - using ClusterShape = Shape; - - // Define Epilogue - using CollectiveEpilogue = - typename cutlass::epilogue::collective::CollectiveBuilder< - ArchTag, OperatorClass, typename MMAConfig::MmaTileShape, - ClusterShape, Shape<_64, _64>, ElementAccumulator, ElementAccumulator, - ElementC, LayoutC*, AlignmentC, ElementD, LayoutD*, AlignmentD, - typename MMAConfig::EpilogueSchedule, - CustomEVTIdentity>::CollectiveOp; - - // Define Mainloop - using CollectiveMainloop = - typename cutlass::gemm::collective::CollectiveBuilder< - ArchTag, OperatorClass, ElementA, LayoutA*, AlignmentA, ElementB, - LayoutB*, AlignmentB, ElementAccumulator, - typename MMAConfig::MmaTileShape, ClusterShape, - cutlass::gemm::collective::StageCountAutoCarveout( - sizeof(typename CollectiveEpilogue::SharedStorage))>, - typename MMAConfig::KernelSchedule>::CollectiveOp; - - // Define GemmKernel - using GemmKernel = - cutlass::gemm::kernel::GemmUniversal; - using Gemm = cutlass::gemm::device::GemmUniversalAdapter; - - using ElementSF = typename Gemm::GemmKernel::ElementSF; - using StrideA = typename Gemm::GemmKernel::InternalStrideA; - using StrideB = typename Gemm::GemmKernel::InternalStrideB; - using StrideC = typename Gemm::GemmKernel::InternalStrideC; - using StrideD = typename Gemm::GemmKernel::InternalStrideD; - using LayoutSFA = - typename Gemm::GemmKernel::CollectiveMainloop::InternalLayoutSFA; - using LayoutSFB = - typename Gemm::GemmKernel::CollectiveMainloop::InternalLayoutSFB; - using Sm1xxBlkScaledConfig = - typename Gemm::GemmKernel::CollectiveMainloop::Sm1xxBlkScaledConfig; -}; - -} // namespace expert_specialization \ No newline at end of file diff --git a/csrc/libtorch_stable/moe/mxfp8_moe/mxfp8_experts_quant.cu b/csrc/libtorch_stable/moe/mxfp8_moe/mxfp8_experts_quant.cu deleted file mode 100644 index e075721c2a3a..000000000000 --- a/csrc/libtorch_stable/moe/mxfp8_moe/mxfp8_experts_quant.cu +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright contributors to the vLLM project -// Adapted from SGLang: -// https://github.com/sgl-project/sglang/blob/ded068a76e00878881d52d5bfb791e0f60d7311b/sgl-kernel/csrc/expert_specialization/es_sm100_mxfp8_blockscaled_group_quant.cu - -#include -#include -#include "libtorch_stable/torch_utils.h" - -#include "mxfp8_experts_quant.cuh" - -void mxfp8_experts_quant(const torch::stable::Tensor& input, - const torch::stable::Tensor& problem_sizes, - const torch::stable::Tensor& expert_offsets, - const torch::stable::Tensor& blockscale_offsets, - torch::stable::Tensor& quant_output, - torch::stable::Tensor& scale_factor) { -#if defined(CUTLASS_ARCH_MMA_SM100_SUPPORTED) - STD_TORCH_CHECK(input.dim() == 2, "input must be 2D tensor"); - STD_TORCH_CHECK(input.size(1) % 128 == 0, "k must align to 128"); - STD_TORCH_CHECK(input.stride(1) == 1, "input must be row major"); - STD_TORCH_CHECK(problem_sizes.dim() == 2, "problem_sizes must be 2D tensor"); - STD_TORCH_CHECK( - problem_sizes.scalar_type() == torch::headeronly::ScalarType::Int, - "problem_sizes must be int32"); - STD_TORCH_CHECK( - expert_offsets.scalar_type() == torch::headeronly::ScalarType::Int, - "expert_offsets must be int32"); - STD_TORCH_CHECK( - blockscale_offsets.scalar_type() == torch::headeronly::ScalarType::Int, - "blockscale_offsets must be int32"); - - auto groups = problem_sizes.size(0); - STD_TORCH_CHECK( - expert_offsets.dim() == 1 && expert_offsets.size(0) == groups, - "expert_offsets must be 1D and have size equal to the number of groups"); - STD_TORCH_CHECK( - blockscale_offsets.dim() == 1 && blockscale_offsets.size(0) == groups, - "blockscale_offsets must be 1D and have size equal to the number of " - "groups"); - - const torch::stable::accelerator::DeviceGuard device_guard( - input.get_device_index()); - if (input.scalar_type() == torch::headeronly::ScalarType::BFloat16) { - expert_specialization::launch_mxfp8_experts_quant<__nv_bfloat16>( - input, problem_sizes, expert_offsets, blockscale_offsets, quant_output, - scale_factor); - } else if (input.scalar_type() == torch::headeronly::ScalarType::Half) { - expert_specialization::launch_mxfp8_experts_quant<__half>( - input, problem_sizes, expert_offsets, blockscale_offsets, quant_output, - scale_factor); - } else { - STD_TORCH_CHECK(false, "dtype must be kFloat16 or kBFloat16"); - } -#else - STD_TORCH_CHECK(false, - "No implemented mxfp8_experts_quant for " - "current device"); -#endif -} - -// Registered here (not torch_bindings.cpp) because ENABLE_ES_MXFP8_GROUPED_MM -// is applied only under COMPILE_LANGUAGE:CUDA. -STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { - m.impl("mxfp8_experts_quant", TORCH_BOX(&mxfp8_experts_quant)); -} diff --git a/csrc/libtorch_stable/moe/mxfp8_moe/mxfp8_experts_quant.cuh b/csrc/libtorch_stable/moe/mxfp8_moe/mxfp8_experts_quant.cuh deleted file mode 100644 index a57e00e76c30..000000000000 --- a/csrc/libtorch_stable/moe/mxfp8_moe/mxfp8_experts_quant.cuh +++ /dev/null @@ -1,416 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright contributors to the vLLM project -// Adapted from SGLang: -// https://github.com/sgl-project/sglang/blob/ded068a76e00878881d52d5bfb791e0f60d7311b/sgl-kernel/csrc/expert_specialization/es_sm100_mxfp8_blockscaled_group_quant.cuh - -#pragma once -#include -#include -#include - -#include -#include -#include -#include - -#include - -#include "cute/tensor.hpp" -#include "libtorch_stable/torch_utils.h" - -namespace expert_specialization { - -using namespace cute; - -constexpr uint32_t THREAD_BLOCK_SIZE = 128; -constexpr uint32_t WARP_SIZE = 32; -constexpr int BLOCK_M = 128; -constexpr int BLOCK_K = 128; -using ThrLayout = Layout, Stride<_8, _1>>; -using ValLayout = Layout>; -using SfR2SThrLayout = Layout, Stride<_4, _1>>; -using SfR2SValLayout = Layout>; -using ScaleFactorTileLayout = - Layout, _4>, Stride, _1>>; - -// Fast reciprocal. -inline __device__ float reciprocal_approximate_ftz(float a) { - float b; - asm volatile("rcp.approx.ftz.f32 %0, %1;\n" : "=f"(b) : "f"(a)); - return b; -} - -// Some code references TRT-LLM: -// https://github.com/NVIDIA/TensorRT-LLM/blob/main/cpp/tensorrt_llm/kernels/quantization.cuh -template -__inline__ __device__ uint8_t cvt_warp_fp16_to_mxfp8(FragmentS& fragment_s, - FragmentD& fragment_d) { - using FragmentSLayout = typename FragmentS::layout_type; - using FragmentDLayout = typename FragmentD::layout_type; - FragmentSLayout fragment_s_layout; - FragmentDLayout fragment_d_layout; - static_assert(is_static::value && - size(fragment_s_layout) == 16); - static_assert(is_static::value && - size(fragment_d_layout) == 16); - - constexpr int eles_per_thr = 16; - using ValType = typename FragmentS::element_type; - using VecType = std::conditional_t, - __nv_bfloat162, __half2>; - VecType vec[8]; - // Assign vals - vec[0].x = fragment_s(Int<0>{}); - vec[0].y = fragment_s(Int<1>{}); - vec[1].x = fragment_s(Int<2>{}); - vec[1].y = fragment_s(Int<3>{}); - vec[2].x = fragment_s(Int<4>{}); - vec[2].y = fragment_s(Int<5>{}); - vec[3].x = fragment_s(Int<6>{}); - vec[3].y = fragment_s(Int<7>{}); - vec[4].x = fragment_s(Int<8>{}); - vec[4].y = fragment_s(Int<9>{}); - vec[5].x = fragment_s(Int<10>{}); - vec[5].y = fragment_s(Int<11>{}); - vec[6].x = fragment_s(Int<12>{}); - vec[6].y = fragment_s(Int<13>{}); - vec[7].x = fragment_s(Int<14>{}); - vec[7].y = fragment_s(Int<15>{}); - - auto local_max = __habs2(vec[0]); - for (int i = 1; i < eles_per_thr / 2; i++) { - local_max = __hmax2(__habs2(vec[i]), local_max); - } - local_max = __hmax2(__shfl_xor_sync(uint32_t(-1), local_max, 1), local_max); - - // Get the final absolute maximum values. - float block_max(0.0f); - if constexpr (std::is_same_v) { - block_max = __bfloat162float(__hmax(local_max.x, local_max.y)); - } else { - block_max = __half2float(__hmax(local_max.x, local_max.y)); - } - // Get the SF (max value of the vector / max value of mxfp8). - float sf_val = block_max * reciprocal_approximate_ftz(448.0f); - // 8 bits representation of the SF. - uint8_t fp8_sf_val; - - __nv_fp8_e8m0 tmp_sf_val; - tmp_sf_val.__x = - __nv_cvt_float_to_e8m0(sf_val, __NV_SATFINITE, cudaRoundPosInf); - sf_val = static_cast(tmp_sf_val); - fp8_sf_val = tmp_sf_val.__x; - // Get the output scale (reciprocal of the SFValue). - float output_scale = - block_max != 0.f ? reciprocal_approximate_ftz(sf_val) : 0.0f; - - // Convert the input to float. - float2 fp2_vals[eles_per_thr / 2]; - -#pragma unroll - for (int i = 0; i < eles_per_thr / 2; i++) { - if constexpr (std::is_same_v) { - fp2_vals[i] = __half22float2(vec[i]); - } else { - fp2_vals[i] = __bfloat1622float2(vec[i]); - } - fp2_vals[i].x *= output_scale; - fp2_vals[i].y *= output_scale; - } - union { - uint8_t bytes[16]; - __nv_fp8x2_e4m3 elts[8]; - } u; - u.elts[0] = __nv_fp8x2_e4m3(fp2_vals[0]); - u.elts[1] = __nv_fp8x2_e4m3(fp2_vals[1]); - u.elts[2] = __nv_fp8x2_e4m3(fp2_vals[2]); - u.elts[3] = __nv_fp8x2_e4m3(fp2_vals[3]); - u.elts[4] = __nv_fp8x2_e4m3(fp2_vals[4]); - u.elts[5] = __nv_fp8x2_e4m3(fp2_vals[5]); - u.elts[6] = __nv_fp8x2_e4m3(fp2_vals[6]); - u.elts[7] = __nv_fp8x2_e4m3(fp2_vals[7]); - fragment_d(Int<0>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[0]); - fragment_d(Int<1>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[1]); - fragment_d(Int<2>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[2]); - fragment_d(Int<3>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[3]); - fragment_d(Int<4>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[4]); - fragment_d(Int<5>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[5]); - fragment_d(Int<6>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[6]); - fragment_d(Int<7>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[7]); - fragment_d(Int<8>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[8]); - fragment_d(Int<9>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[9]); - fragment_d(Int<10>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[10]); - fragment_d(Int<11>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[11]); - fragment_d(Int<12>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[12]); - fragment_d(Int<13>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[13]); - fragment_d(Int<14>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[14]); - fragment_d(Int<15>{}) = cutlass::float_e4m3_t::bitcast(u.bytes[15]); - return fp8_sf_val; -} - -template -__inline__ __device__ void mxfp8_experts_quant_tile( - TensorS& tensor_s, TensorP& tensor_p, TensorD& tensor_d, - TensorSharedSF& tensor_shared_sf, TensorSF& tensor_sf, int m, - TiledCopyG2R& tiled_copy_g2r, TiledCopyR2G& tiled_copy_r2g, - TiledCopyR2S& tiled_copy_r2s) { - static_assert(size(get<0>(typename TensorS::layout_type{})) == 128 && - size(get<1>(typename TensorS::layout_type{})) == 128 && - stride(get<1>(typename TensorS::layout_type{})) == 1); - static_assert(size(get<0>(typename TensorD::layout_type{})) == 128 && - size(get<1>(typename TensorD::layout_type{})) == 128 && - stride(get<1>(typename TensorD::layout_type{})) == 1); - static_assert(size(get<0>(typename TensorP::layout_type{})) == 128 && - size(get<1>(typename TensorP::layout_type{})) == 128); - static_assert(size(get<0>(typename TensorSharedSF::layout_type{})) == 128 && - size(get<1>(typename TensorSharedSF::layout_type{})) == 4); - static_assert(size(get<0>(typename TensorSF::layout_type{})) == 128 && - size(get<1>(typename TensorSF::layout_type{})) == 4); - - using Tiler_MN = typename TiledCopyG2R::Tiler_MN; - auto tiler_mn = Tiler_MN{}; - static_assert(size<0>(tiler_mn) == 16 && size<1>(tiler_mn) == 128); - - auto tiled_tensor_s = tiled_divide(tensor_s, tiler_mn); - auto tiled_tensor_p = tiled_divide(tensor_p, tiler_mn); - auto tiled_tensor_d = tiled_divide(tensor_d, tiler_mn); - static_assert(size<2>(tiled_tensor_s) == 1); - static_assert(size<2>(tiled_tensor_p) == 1); - static_assert(size<2>(tiled_tensor_d) == 1); - auto squeeze_tiled_tensor_s = take<0, 2>(tiled_tensor_s); - auto squeeze_tiled_tensor_p = take<0, 2>(tiled_tensor_p); - auto squeeze_tiled_tensor_d = take<0, 2>(tiled_tensor_d); - - using SF_Tiler_MN = typename TiledCopyR2S::Tiler_MN; - auto sf_tiler_mn = SF_Tiler_MN{}; - static_assert(size<0>(sf_tiler_mn) == 16 && size<1>(sf_tiler_mn) == 4); - - auto tiled_tensor_sf = tiled_divide(tensor_sf, sf_tiler_mn); - auto tiled_tensor_shared_sf = tiled_divide(tensor_shared_sf, sf_tiler_mn); - auto squeeze_tiled_tensor_sf = take<0, 2>(tiled_tensor_sf); - auto squeeze_tiled_tensor_shared_sf = take<0, 2>(tiled_tensor_shared_sf); - - constexpr int tile_loop_count = size<1>(tiled_tensor_s); - constexpr int rows_in_tile = 16; - // We don't need to clear shared memory - // clear(squeeze_tiled_tensor_shared_sf); -#pragma unroll 4 - for (int t = 0; t < tile_loop_count; t++) { - if (t * rows_in_tile >= m) { - break; - } - auto current_copy_tile_s = tensor<0>(squeeze_tiled_tensor_s(_, t)); - auto current_copy_tile_p = tensor<0>(squeeze_tiled_tensor_p(_, t)); - auto current_copy_tile_d = tensor<0>(squeeze_tiled_tensor_d(_, t)); - auto current_copy_tile_sf = tensor<0>(squeeze_tiled_tensor_sf(_, t)); - auto current_copy_tile_shared_sf = - tensor<0>(squeeze_tiled_tensor_shared_sf(_, t)); - - // Global to Register copy - auto thr_copy_g2r = tiled_copy_g2r.get_thread_slice(threadIdx.x); - auto thr_tile_g2r_s = thr_copy_g2r.partition_S(current_copy_tile_s); - auto thr_tile_g2r_p = thr_copy_g2r.partition_S(current_copy_tile_p); - auto input_fragment = make_fragment_like(thr_tile_g2r_s); - - // Register to Global copy - auto thr_copy_r2g = tiled_copy_r2g.get_thread_slice(threadIdx.x); - auto thr_tile_r2g_d = thr_copy_r2g.partition_D(current_copy_tile_d); - auto thr_tile_r2g_p = thr_copy_r2g.partition_D(current_copy_tile_p); - auto output_fragment = make_fragment_like(thr_tile_r2g_d); - - // Register to Shared copy - auto thr_copy_r2s = tiled_copy_r2s.get_thread_slice(threadIdx.x / 2); - auto thr_tile_r2s_shared_sf = - thr_copy_r2s.partition_D(current_copy_tile_shared_sf); - auto shared_sf_fragment = make_fragment_like(thr_tile_r2s_shared_sf); - - // CopyG2R & convert & CopyR2G - copy_if(tiled_copy_g2r, thr_tile_g2r_p, thr_tile_g2r_s, input_fragment); - uint8_t fp8_sf_val = - cvt_warp_fp16_to_mxfp8(input_fragment, output_fragment); - copy_if(tiled_copy_r2g, thr_tile_r2g_p, output_fragment, thr_tile_r2g_d); - shared_sf_fragment[0] = fp8_sf_val; - - // Before first copy r2s, clear shared memory and wait previous group - if (t == 0 && threadIdx.x == 0) { - // Wait for the group to have completed reading from shared memory. - cuda::ptx::cp_async_bulk_wait_group_read(cuda::ptx::n32_t<0>()); - } - __syncthreads(); - - if (threadIdx.x % 2 == 0) { - copy(tiled_copy_r2s, shared_sf_fragment, thr_tile_r2s_shared_sf); - } - __syncthreads(); - } - - // Wait for shared memory writes to be visible to TMA engine. - cuda::ptx::fence_proxy_async(cuda::ptx::space_shared); // b) - __syncthreads(); - - if (threadIdx.x == 0) { - cuda::ptx::cp_async_bulk(cuda::ptx::space_global, cuda::ptx::space_shared, - squeeze_tiled_tensor_sf.data().get(), - squeeze_tiled_tensor_shared_sf.data().get(), 512); - // Wait for TMA transfer to have finished reading shared memory. - // Create a "bulk async-group" out of the previous bulk copy operation. - cuda::ptx::cp_async_bulk_commit_group(); - } - __syncthreads(); -} - -template -__global__ void mxfp8_experts_quant_kernel( - const T_IN* input, const int* problem_sizes, const int* expert_offsets, - const int* blockscale_offsets, cutlass::float_e4m3_t* quant_output, - uint8_t* scale_factor, int groups, TiledCopyG2R tiled_copy_g2r, - TiledCopyR2G tiled_copy_r2g, TiledCopyR2S tiled_copy_r2s) { -#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1000 - __shared__ __align__(512) uint8_t shared_memory[512]; - ScaleFactorTileLayout scale_factor_tile_layout{}; - auto scale_factor_shared = - make_tensor(make_smem_ptr(shared_memory), - scale_factor_tile_layout); // ((_32,_4), _4):((_16,_4), _1) - // TODO: Transform Groupwise Schedule into a more efficient Schedule - for (int g = 0; g < groups; g++) { - int m = problem_sizes[g * 3 + 0]; - int k = problem_sizes[g * 3 + 2]; - int64_t expert_offset = static_cast(expert_offsets[g]); - int64_t blockscale_offset = static_cast(blockscale_offsets[g]); - - auto input_tensor = make_tensor( - make_gmem_ptr(input + expert_offset * k), - make_layout(make_shape(m, k), - LayoutRight{})); // (M, K):(K, 1) half_t/bfloat16_t - - auto quant_output_tensor = make_tensor( - make_gmem_ptr(quant_output + expert_offset * k), - make_layout(make_shape(m, k), - LayoutRight{})); // (M, K):(K, 1) cutlass::float_e4m3_t - - auto scale_factor_shape = make_shape(ceil_div(m, 128) * 128, k / 32); - auto scale_factor_layout = tile_to_shape(scale_factor_tile_layout, - scale_factor_shape, LayoutRight{}); - // layout<0>(layout<0>(scale_factor_layout)) (_32,_4):(_16,_4) -- static - // layout<1>(layout<0>(scale_factor_layout)) M_align_128 / 128 -- dynamic - // shape dynamic stride layout<0>(layout<1>(scale_factor_layout)) _4:_1 -- - // static layout<1>(layout<1>(scale_factor_layout)) (K / 32) / 4 : _512 -- - // dynamic shape static stride - - // Reshape to zipped layout for 1D indexing - auto zipped_scale_factor_layout = make_layout( - make_layout(layout<0>(layout<0>(scale_factor_layout)), - layout<0>(layout<1>(scale_factor_layout))), - make_layout( - layout<1>(layout<0>(scale_factor_layout)), - layout<1>(layout<1>( - scale_factor_layout)))); // (((_32,_4),_4),(M_align_128 / - // 128,(K / 32) / - // 4)):(((_16,_4),_1),(?,_512)) - - auto scale_factor_tensor = - make_tensor(make_gmem_ptr(scale_factor + blockscale_offset * (k / 32)), - zipped_scale_factor_layout); - - // Used for cases where M is not divisible by 128 (most scenarios). - auto input_shape = shape(input_tensor); // (M, K):(K, 1) - auto identity_tensor = make_identity_tensor(input_shape); - auto predict_tensor = cute::lazy::transform( - identity_tensor, [&](auto c) { return elem_less(c, input_shape); }); - - // (_128, _128) - auto tiler = make_shape(Int{}, Int{}); - - auto tiled_input_tensor = zipped_divide( - input_tensor, tiler); // ((128, 128), (cdiv(M, 128), cdiv(K, 128))) - auto tiled_quant_output_tensor = - zipped_divide(quant_output_tensor, - tiler); // ((128, 128), (cdiv(M, 128), cdiv(K, 128))) - auto tiled_predict_tensor = zipped_divide( - predict_tensor, tiler); // ((128, 128), (cdiv(M, 128), cdiv(K, 128))) - - auto total_tiles = - size<1>(tiled_input_tensor); // cdiv(M, 128) * cdiv(K, 128) - decltype(total_tiles) blk_offset = blockIdx.x; - while (blk_offset < total_tiles) { - auto current_input_tile = tensor<0>(tiled_input_tensor(_, blk_offset)); - auto current_quant_output_tile = - tensor<0>(tiled_quant_output_tensor(_, blk_offset)); - auto current_predict_tile = - tensor<0>(tiled_predict_tensor(_, blk_offset)); - auto current_scale_factor_tile = - tensor<0>(scale_factor_tensor(_, blk_offset)); - - mxfp8_experts_quant_tile< - decltype(current_input_tile), decltype(current_predict_tile), - decltype(current_quant_output_tile), decltype(scale_factor_shared), - decltype(current_scale_factor_tile), TiledCopyG2R, TiledCopyR2G, - TiledCopyR2S>(current_input_tile, current_predict_tile, - current_quant_output_tile, scale_factor_shared, - current_scale_factor_tile, m, tiled_copy_g2r, - tiled_copy_r2g, tiled_copy_r2s); - blk_offset += gridDim.x; - } - } -#endif -} - -template -void launch_mxfp8_experts_quant(const torch::stable::Tensor& input, - const torch::stable::Tensor& problem_sizes, - const torch::stable::Tensor& expert_offsets, - const torch::stable::Tensor& blockscale_offsets, - torch::stable::Tensor& quant_output, - torch::stable::Tensor& scale_factor) { - ThrLayout thr_layout{}; - ValLayout val_layout{}; - SfR2SThrLayout r2s_thr_layout{}; - SfR2SValLayout r2s_val_layout{}; - - using CopyOpG2R = - UniversalCopy>; - using CopyAtomG2R = cute::Copy_Atom; - auto tiled_copy_g2r = cute::make_tiled_copy( - CopyAtomG2R{}, thr_layout, val_layout); // Tiler_MN: (16, 128) - - using CopyOpR2G = UniversalCopy< - cutlass::AlignedArray>; - using CopyAtomR2G = cute::Copy_Atom; - auto tiled_copy_r2g = cute::make_tiled_copy( - CopyAtomR2G{}, thr_layout, val_layout); // Tiler_MN: (16, 128) - - using CopyOpR2S = - UniversalCopy>; - using CopyAtomR2S = cute::Copy_Atom; - auto tiled_copy_r2s = cute::make_tiled_copy( - CopyAtomR2S{}, r2s_thr_layout, r2s_val_layout); // Tiler_MN: (16, 4) - - int max_active_blocks_per_sm = -1; - STD_CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor( - &max_active_blocks_per_sm, - mxfp8_experts_quant_kernel, - THREAD_BLOCK_SIZE, 0)); - - dim3 grid(get_device_prop()->multiProcessorCount * max_active_blocks_per_sm, - 1, 1); - dim3 block(THREAD_BLOCK_SIZE, 1, 1); - int num_experts = static_cast(problem_sizes.size(0)); - auto stream = get_current_cuda_stream(input.get_device_index()); - mxfp8_experts_quant_kernel - <<>>( - reinterpret_cast(input.data_ptr()), - reinterpret_cast(problem_sizes.data_ptr()), - reinterpret_cast(expert_offsets.data_ptr()), - reinterpret_cast(blockscale_offsets.data_ptr()), - reinterpret_cast(quant_output.data_ptr()), - reinterpret_cast(scale_factor.data_ptr()), num_experts, - tiled_copy_g2r, tiled_copy_r2g, tiled_copy_r2s); -} - -} // namespace expert_specialization \ No newline at end of file diff --git a/csrc/libtorch_stable/moe/permute_unpermute_kernels/dispatch.h b/csrc/libtorch_stable/moe/permute_unpermute_kernels/dispatch.h new file mode 100644 index 000000000000..976233dd484f --- /dev/null +++ b/csrc/libtorch_stable/moe/permute_unpermute_kernels/dispatch.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +#define MOE_SWITCH(TYPE, ...) \ + const auto _st = (TYPE); \ + switch (_st) { \ + __VA_ARGS__ \ + default: \ + STD_TORCH_CHECK(false, "[moe permute]data type dispatch fail!") \ + } + +#define MOE_DISPATCH_CASE(enum_type, ...) \ + case enum_type: { \ + using scalar_t = ScalarType2CudaType::type; \ + __VA_ARGS__(); \ + break; \ + } + +#define MOE_DISPATCH_FLOAT_CASE(...) \ + MOE_DISPATCH_CASE(torch::headeronly::ScalarType::Float, __VA_ARGS__) \ + MOE_DISPATCH_CASE(torch::headeronly::ScalarType::Half, __VA_ARGS__) \ + MOE_DISPATCH_CASE(torch::headeronly::ScalarType::BFloat16, __VA_ARGS__) \ + MOE_DISPATCH_CASE(torch::headeronly::ScalarType::Float8_e5m2, __VA_ARGS__) \ + MOE_DISPATCH_CASE(torch::headeronly::ScalarType::Float8_e4m3fn, __VA_ARGS__) \ + MOE_DISPATCH_CASE(torch::headeronly::ScalarType::Byte, __VA_ARGS__) + +#define MOE_DISPATCH(TYPE, ...) \ + MOE_SWITCH(TYPE, MOE_DISPATCH_FLOAT_CASE(__VA_ARGS__)) + +template +struct ScalarType2CudaType; + +template <> +struct ScalarType2CudaType { + using type = float; +}; +template <> +struct ScalarType2CudaType { + using type = half; +}; +template <> +struct ScalarType2CudaType { + using type = __nv_bfloat16; +}; +// uint8 for packed fp4 +template <> +struct ScalarType2CudaType { + using type = uint8_t; +}; +template <> +struct ScalarType2CudaType { + using type = __nv_fp8_e5m2; +}; +template <> +struct ScalarType2CudaType { + using type = __nv_fp8_e4m3; +}; \ No newline at end of file diff --git a/csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu b/csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu similarity index 95% rename from csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu rename to csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu index 2cc200321692..f5ec32c390f5 100644 --- a/csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu +++ b/csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.cu @@ -1,5 +1,7 @@ +#include +#include -#include "moe_permute_unpermute_kernel.h" +#include "libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h" // moe_permute kernels require at least CUDA 12.0 #if defined(CUDA_VERSION) && (CUDA_VERSION >= 12000) @@ -48,9 +50,10 @@ void CubKeyValueSorter::run(void* workspace, size_t const workspace_size, size_t expected_ws_size = getWorkspaceSize(num_key_value_pairs, num_experts_); size_t actual_ws_size = workspace_size; - TORCH_CHECK(expected_ws_size <= workspace_size, - "[CubKeyValueSorter::run] The allocated workspace is too small " - "to run this problem."); + STD_TORCH_CHECK( + expected_ws_size <= workspace_size, + "[CubKeyValueSorter::run] The allocated workspace is too small " + "to run this problem."); cub::DeviceRadixSort::SortPairs(workspace, actual_ws_size, keys_in, keys_out, values_in, values_out, num_key_value_pairs, 0, num_bits_, stream); diff --git a/csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h b/csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h similarity index 89% rename from csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h rename to csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h index fe44d301559a..89c278a4ed4b 100644 --- a/csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h +++ b/csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.h @@ -2,23 +2,24 @@ // reference from tensorrt_llm moe kernel implementation archive in // https://github.com/BBuf/tensorrt-llm-moe/tree/master -#include -#include -#include "dispatch.h" +#include + #include #include #include -#include "cutlass/numeric_size.h" + #include "cutlass/array.h" +#include "cutlass/numeric_size.h" +#include "libtorch_stable/moe/permute_unpermute_kernels/dispatch.h" template -inline T* get_ptr(torch::Tensor& t) { - return reinterpret_cast(t.data_ptr()); +inline T* get_ptr(torch::stable::Tensor& t) { + return reinterpret_cast(t.mutable_data_ptr()); } template -inline const T* get_ptr(const torch::Tensor& t) { - return reinterpret_cast(t.data_ptr()); +inline const T* get_ptr(const torch::stable::Tensor& t) { + return reinterpret_cast(t.const_data_ptr()); } class CubKeyValueSorter { diff --git a/csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.inl b/csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.inl similarity index 100% rename from csrc/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.inl rename to csrc/libtorch_stable/moe/permute_unpermute_kernels/moe_permute_unpermute_kernel.inl diff --git a/csrc/moe/topk_softmax_kernels.cu b/csrc/libtorch_stable/moe/topk_softmax_kernels.cu similarity index 83% rename from csrc/moe/topk_softmax_kernels.cu rename to csrc/libtorch_stable/moe/topk_softmax_kernels.cu index 57461a044f9d..b4bcd9479e9d 100644 --- a/csrc/moe/topk_softmax_kernels.cu +++ b/csrc/libtorch_stable/moe/topk_softmax_kernels.cu @@ -17,11 +17,16 @@ * limitations under the License. */ #include -#include -#include -#include -#include "../cuda_compat.h" + +#include +#include +#include +#include +#include + +#include "../../cuda_compat.h" #include "../cub_helpers.h" +#include "libtorch_stable/torch_utils.h" #ifndef USE_ROCM #include @@ -168,7 +173,8 @@ __launch_bounds__(TPB) __global__ void moeTopK( const int start_expert, const int end_expert, const bool renormalize, - const float* bias) + const float* bias, + const double routed_scaling_factor) { using cub_kvp = cub::KeyValuePair; @@ -236,14 +242,16 @@ __launch_bounds__(TPB) __global__ void moeTopK( __syncthreads(); } - // Renormalize the k weights for this row to sum to 1, if requested. - if (renormalize) { - if (threadIdx.x == 0) { + // Apply renormalization and routed scaling factor to final weights. + if (threadIdx.x == 0) { + float scale = static_cast(routed_scaling_factor); + if (renormalize) { const float denom = selected_sum > 0.f ? selected_sum : 1.f; - for (int k_idx = 0; k_idx < k; ++k_idx) { - const int idx = k * block_row + k_idx; - output[idx] = output[idx] / denom; - } + scale /= denom; + } + for (int k_idx = 0; k_idx < k; ++k_idx) { + const int idx = k * block_row + k_idx; + output[idx] = output[idx] * scale; } } } @@ -269,7 +277,7 @@ template || std::is_same_v || std::is_same_v, @@ -565,17 +573,17 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__ } } - // Renormalize the k weights for this row to sum to 1, if requested. - if (renormalize) { - if (thread_group_idx == 0) - { - const float denom = selected_sum > 0.f ? selected_sum : 1.f; - for (int k_idx = 0; k_idx < k; ++k_idx) - { - const int idx = k * thread_row + k_idx; - output[idx] = output[idx] / denom; - } - } + // Apply renormalization and routed scaling factor to final weights. + if (thread_group_idx == 0) { + float scale = static_cast(routed_scaling_factor); + if (renormalize) { + const float denom = selected_sum > 0.f ? selected_sum : 1.f; + scale /= denom; + } + for (int k_idx = 0; k_idx < k; ++k_idx) { + const int idx = k * thread_row + k_idx; + output[idx] = output[idx] * scale; + } } } @@ -597,7 +605,7 @@ struct TopkConstants template void topkGatingLauncherHelper(const InputType* input, const bool* finished, float* output, IndType* indices, int* source_row, const int num_rows, const int k, const int start_expert, const int end_expert, const bool renormalize, - const float* bias, cudaStream_t stream) + const float* bias, const double routed_scaling_factor, cudaStream_t stream) { static constexpr int BYTES_PER_LDG = MIN(MAX_BYTES_PER_LDG, sizeof(InputType) * EXPERTS); using Constants = detail::TopkConstants; @@ -608,7 +616,7 @@ void topkGatingLauncherHelper(const InputType* input, const bool* finished, floa dim3 block_dim(WARP_SIZE_PARAM, WARPS_PER_TB); topkGating<<>>( - input, finished, output, num_rows, indices, source_row, k, start_expert, end_expert, renormalize, bias); + input, finished, output, num_rows, indices, source_row, k, start_expert, end_expert, renormalize, bias, routed_scaling_factor); } #ifndef USE_ROCM @@ -619,7 +627,7 @@ void topkGatingLauncherHelper(const InputType* input, const bool* finished, floa IndType, InputType, SF>( \ gating_output, nullptr, topk_weights, topk_indices, \ token_expert_indices, num_tokens, topk, 0, num_experts, renormalize, \ - bias, stream); + bias, routed_scaling_factor, stream); #else #define LAUNCH_TOPK(NUM_EXPERTS, WARPS_PER_TB, MAX_BYTES) \ if (WARP_SIZE == 64) { \ @@ -627,13 +635,13 @@ void topkGatingLauncherHelper(const InputType* input, const bool* finished, floa IndType, InputType, SF>( \ gating_output, nullptr, topk_weights, topk_indices, \ token_expert_indices, num_tokens, topk, 0, num_experts, renormalize, \ - bias, stream); \ + bias, routed_scaling_factor, stream); \ } else if (WARP_SIZE == 32) { \ topkGatingLauncherHelper( \ gating_output, nullptr, topk_weights, topk_indices, \ token_expert_indices, num_tokens, topk, 0, num_experts, renormalize, \ - bias, stream); \ + bias, routed_scaling_factor, stream); \ } else { \ assert(false && \ "Unsupported warp size. Only 32 and 64 are supported for ROCm"); \ @@ -652,6 +660,7 @@ void topkGatingKernelLauncher( const int topk, const bool renormalize, const float* bias, + const double routed_scaling_factor, cudaStream_t stream) { static constexpr int WARPS_PER_TB = 4; static constexpr int BYTES_PER_LDG_POWER_OF_2 = 16; @@ -713,7 +722,7 @@ void topkGatingKernelLauncher( break; #endif default: { - TORCH_CHECK(workspace != nullptr, + STD_TORCH_CHECK(workspace != nullptr, "workspace must be provided for num_experts that are not a power of 2 or multiple of 64."); static constexpr int TPB = 256; if constexpr (SF == SCORING_SOFTMAX) { @@ -723,11 +732,11 @@ void topkGatingKernelLauncher( moeSigmoid<<>>( gating_output, nullptr, workspace, num_experts); } else { - TORCH_CHECK(false, "Unsupported scoring func"); + STD_TORCH_CHECK(false, "Unsupported scoring func"); } moeTopK<<>>( workspace, nullptr, topk_weights, topk_indices, token_expert_indices, - num_experts, topk, 0, num_experts, renormalize, bias); + num_experts, topk, 0, num_experts, renormalize, bias, routed_scaling_factor); } } } @@ -738,63 +747,66 @@ void topkGatingKernelLauncher( template void dispatch_topk_launch( - torch::Tensor& gating_output, - torch::Tensor& topk_weights, - torch::Tensor& topk_indices, - torch::Tensor& token_expert_indices, - torch::Tensor& softmax_workspace, + torch::stable::Tensor& gating_output, + torch::stable::Tensor& topk_weights, + torch::stable::Tensor& topk_indices, + torch::stable::Tensor& token_expert_indices, + torch::stable::Tensor& softmax_workspace, int num_tokens, int num_experts, int topk, bool renormalize, - std::optional bias, + std::optional bias, + double routed_scaling_factor, cudaStream_t stream) { const float* bias_ptr = nullptr; if (bias.has_value()) { - const torch::Tensor& bias_tensor = bias.value(); - TORCH_CHECK(bias_tensor.scalar_type() == at::ScalarType::Float, "bias tensor must be float32"); - TORCH_CHECK(bias_tensor.dim() == 1, "bias tensor must be 1D"); - TORCH_CHECK(bias_tensor.size(0) == num_experts, "bias size mismatch, expected: ", num_experts); - TORCH_CHECK(bias_tensor.is_contiguous(), "bias tensor must be contiguous"); - bias_ptr = bias_tensor.data_ptr(); + const torch::stable::Tensor& bias_tensor = bias.value(); + STD_TORCH_CHECK(bias_tensor.scalar_type() == torch::headeronly::ScalarType::Float, + "bias tensor must be float32"); + STD_TORCH_CHECK(bias_tensor.dim() == 1, "bias tensor must be 1D"); + STD_TORCH_CHECK(bias_tensor.size(0) == num_experts, + "bias size mismatch, expected: ", num_experts); + STD_TORCH_CHECK(bias_tensor.is_contiguous(), "bias tensor must be contiguous"); + bias_ptr = bias_tensor.const_data_ptr(); } - if (topk_indices.scalar_type() == at::ScalarType::Int) { + if (topk_indices.scalar_type() == torch::headeronly::ScalarType::Int) { vllm::moe::topkGatingKernelLauncher( - reinterpret_cast(gating_output.data_ptr()), - topk_weights.data_ptr(), - topk_indices.data_ptr(), - token_expert_indices.data_ptr(), - softmax_workspace.data_ptr(), + reinterpret_cast(gating_output.const_data_ptr()), + topk_weights.mutable_data_ptr(), + topk_indices.mutable_data_ptr(), + token_expert_indices.mutable_data_ptr(), + softmax_workspace.mutable_data_ptr(), num_tokens, num_experts, topk, renormalize, - bias_ptr, stream); - } else if (topk_indices.scalar_type() == at::ScalarType::UInt32) { + bias_ptr, routed_scaling_factor, stream); + } else if (topk_indices.scalar_type() == torch::headeronly::ScalarType::UInt32) { vllm::moe::topkGatingKernelLauncher( - reinterpret_cast(gating_output.data_ptr()), - topk_weights.data_ptr(), - topk_indices.data_ptr(), - token_expert_indices.data_ptr(), - softmax_workspace.data_ptr(), + reinterpret_cast(gating_output.const_data_ptr()), + topk_weights.mutable_data_ptr(), + topk_indices.mutable_data_ptr(), + token_expert_indices.mutable_data_ptr(), + softmax_workspace.mutable_data_ptr(), num_tokens, num_experts, topk, renormalize, - bias_ptr, stream); + bias_ptr, routed_scaling_factor, stream); } else { - TORCH_CHECK(topk_indices.scalar_type() == at::ScalarType::Long); + STD_TORCH_CHECK(topk_indices.scalar_type() == torch::headeronly::ScalarType::Long); vllm::moe::topkGatingKernelLauncher( - reinterpret_cast(gating_output.data_ptr()), - topk_weights.data_ptr(), - topk_indices.data_ptr(), - token_expert_indices.data_ptr(), - softmax_workspace.data_ptr(), + reinterpret_cast(gating_output.const_data_ptr()), + topk_weights.mutable_data_ptr(), + topk_indices.mutable_data_ptr(), + token_expert_indices.mutable_data_ptr(), + softmax_workspace.mutable_data_ptr(), num_tokens, num_experts, topk, renormalize, - bias_ptr, stream); + bias_ptr, routed_scaling_factor, stream); } } void topk_softmax( - torch::Tensor& topk_weights, // [num_tokens, topk] - torch::Tensor& topk_indices, // [num_tokens, topk] - torch::Tensor& token_expert_indices, // [num_tokens, topk] - torch::Tensor& gating_output, // [num_tokens, num_experts] + torch::stable::Tensor& topk_weights, // [num_tokens, topk] + torch::stable::Tensor& topk_indices, // [num_tokens, topk] + torch::stable::Tensor& token_expert_indices, // [num_tokens, topk] + torch::stable::Tensor& gating_output, // [num_tokens, num_experts] bool renormalize, - std::optional bias) + std::optional bias) { const int num_experts = gating_output.size(-1); const auto num_tokens = gating_output.numel() / num_experts; @@ -804,35 +816,37 @@ void topk_softmax( const bool needs_workspace = !is_pow_2 || num_experts > 256; const int64_t workspace_size = needs_workspace ? num_tokens * num_experts : 0; - const at::cuda::OptionalCUDAGuard device_guard(device_of(gating_output)); - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); - const auto workspace_options = gating_output.options().dtype(at::ScalarType::Float); - torch::Tensor softmax_workspace = torch::empty({workspace_size}, workspace_options); + torch::stable::accelerator::DeviceGuard guard(gating_output.get_device_index()); + const cudaStream_t stream = + get_current_cuda_stream(gating_output.get_device_index()); + auto softmax_workspace = torch::stable::new_empty( + gating_output, {workspace_size}, torch::headeronly::ScalarType::Float); - if (gating_output.scalar_type() == at::ScalarType::Float) { + if (gating_output.scalar_type() == torch::headeronly::ScalarType::Float) { dispatch_topk_launch(gating_output, topk_weights, topk_indices, token_expert_indices, softmax_workspace, num_tokens, num_experts, topk, renormalize, - bias, stream); - } else if (gating_output.scalar_type() == at::ScalarType::Half) { + bias, 1.0, stream); + } else if (gating_output.scalar_type() == torch::headeronly::ScalarType::Half) { dispatch_topk_launch<__half, vllm::moe::SCORING_SOFTMAX>(gating_output, topk_weights, topk_indices, token_expert_indices, softmax_workspace, num_tokens, num_experts, topk, renormalize, - bias, stream); - } else if (gating_output.scalar_type() == at::ScalarType::BFloat16) { + bias, 1.0, stream); + } else if (gating_output.scalar_type() == torch::headeronly::ScalarType::BFloat16) { dispatch_topk_launch<__nv_bfloat16, vllm::moe::SCORING_SOFTMAX>(gating_output, topk_weights, topk_indices, token_expert_indices, softmax_workspace, num_tokens, num_experts, topk, renormalize, - bias, stream); + bias, 1.0, stream); } else { - TORCH_CHECK(false, "Unsupported gating_output data type: ", gating_output.scalar_type()); + STD_TORCH_CHECK(false, "Unsupported gating_output data type: ", gating_output.scalar_type()); } } void topk_sigmoid( - torch::Tensor& topk_weights, // [num_tokens, topk] - torch::Tensor& topk_indices, // [num_tokens, topk] - torch::Tensor& token_expert_indices, // [num_tokens, topk] - torch::Tensor& gating_output, // [num_tokens, num_experts] + torch::stable::Tensor& topk_weights, // [num_tokens, topk] + torch::stable::Tensor& topk_indices, // [num_tokens, topk] + torch::stable::Tensor& token_expert_indices, // [num_tokens, topk] + torch::stable::Tensor& gating_output, // [num_tokens, num_experts] bool renormalize, - std::optional bias) + std::optional bias, + double routed_scaling_factor) { const int num_experts = gating_output.size(-1); const auto num_tokens = gating_output.numel() / num_experts; @@ -842,24 +856,25 @@ void topk_sigmoid( const bool needs_workspace = !is_pow_2 || num_experts > 256; const int64_t workspace_size = needs_workspace ? num_tokens * num_experts : 0; - const at::cuda::OptionalCUDAGuard device_guard(device_of(gating_output)); - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); - const auto workspace_options = gating_output.options().dtype(at::ScalarType::Float); - torch::Tensor workspace = torch::empty({workspace_size}, workspace_options); + torch::stable::accelerator::DeviceGuard guard(gating_output.get_device_index()); + const cudaStream_t stream = + get_current_cuda_stream(gating_output.get_device_index()); + auto workspace = torch::stable::new_empty( + gating_output, {workspace_size}, torch::headeronly::ScalarType::Float); - if (gating_output.scalar_type() == at::ScalarType::Float) { + if (gating_output.scalar_type() == torch::headeronly::ScalarType::Float) { dispatch_topk_launch(gating_output, topk_weights, topk_indices, token_expert_indices, workspace, num_tokens, num_experts, topk, renormalize, - bias, stream); - } else if (gating_output.scalar_type() == at::ScalarType::Half) { + bias, routed_scaling_factor, stream); + } else if (gating_output.scalar_type() == torch::headeronly::ScalarType::Half) { dispatch_topk_launch<__half, vllm::moe::SCORING_SIGMOID>(gating_output, topk_weights, topk_indices, token_expert_indices, workspace, num_tokens, num_experts, topk, renormalize, - bias, stream); - } else if (gating_output.scalar_type() == at::ScalarType::BFloat16) { + bias, routed_scaling_factor, stream); + } else if (gating_output.scalar_type() == torch::headeronly::ScalarType::BFloat16) { dispatch_topk_launch<__nv_bfloat16, vllm::moe::SCORING_SIGMOID>(gating_output, topk_weights, topk_indices, token_expert_indices, workspace, num_tokens, num_experts, topk, renormalize, - bias, stream); + bias, routed_scaling_factor, stream); } else { - TORCH_CHECK(false, "Unsupported gating_output data type: ", gating_output.scalar_type()); + STD_TORCH_CHECK(false, "Unsupported gating_output data type: ", gating_output.scalar_type()); } } diff --git a/csrc/moe/topk_softplus_sqrt_kernels.cu b/csrc/libtorch_stable/moe/topk_softplus_sqrt_kernels.cu similarity index 87% rename from csrc/moe/topk_softplus_sqrt_kernels.cu rename to csrc/libtorch_stable/moe/topk_softplus_sqrt_kernels.cu index d5bb8edadc67..095a76678311 100644 --- a/csrc/moe/topk_softplus_sqrt_kernels.cu +++ b/csrc/libtorch_stable/moe/topk_softplus_sqrt_kernels.cu @@ -18,11 +18,16 @@ * limitations under the License. */ #include -#include -#include -#include -#include "../cuda_compat.h" + +#include +#include +#include +#include +#include + +#include "../../cuda_compat.h" #include "../cub_helpers.h" +#include "libtorch_stable/torch_utils.h" #ifndef USE_ROCM #include #include @@ -168,7 +173,7 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__ float row_chunk[VPT]; #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.wait;"); + cudaGridDependencySynchronize(); #endif // NOTE(zhuhaoran): dispatch different input types loading, BF16/FP16 convert @@ -295,7 +300,7 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__ } } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif return; } else { @@ -420,7 +425,7 @@ __launch_bounds__(WARPS_PER_CTA* WARP_SIZE_PARAM) __global__ } } #if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) - asm volatile("griddepcontrol.launch_dependents;"); + cudaTriggerProgrammaticLaunchCompletion(); #endif } } @@ -618,7 +623,7 @@ void topkGatingSoftplusSqrtKernelLauncher( LAUNCH_SOFTPLUS_SQRT(576, WARPS_PER_TB, BYTES_PER_LDG_MULTIPLE_64_NARROW); break; default: { - TORCH_CHECK(false, "Unsupported expert number: ", num_experts); + STD_TORCH_CHECK(false, "Unsupported expert number: ", num_experts); } } } @@ -628,100 +633,109 @@ void topkGatingSoftplusSqrtKernelLauncher( template void dispatch_topk_softplus_sqrt_launch( - const ComputeType* gating_output, torch::Tensor& topk_weights, - torch::Tensor& topk_indices, torch::Tensor& token_expert_indices, - int num_tokens, int num_experts, int topk, bool renormalize, - double routed_scaling_factor, - const c10::optional& correction_bias, - const c10::optional& input_ids, - const c10::optional& tid2eid, cudaStream_t stream) { + const ComputeType* gating_output, torch::stable::Tensor& topk_weights, + torch::stable::Tensor& topk_indices, + torch::stable::Tensor& token_expert_indices, int num_tokens, + int num_experts, int topk, bool renormalize, double routed_scaling_factor, + const std::optional& correction_bias, + const std::optional& input_ids, + const std::optional& tid2eid, cudaStream_t stream) { const float* bias_ptr = nullptr; if (correction_bias.has_value()) { - bias_ptr = correction_bias.value().data_ptr(); + bias_ptr = correction_bias.value().const_data_ptr(); } bool use_hash = false; if (tid2eid.has_value()) { - TORCH_CHECK(input_ids.has_value(), "input_ids is required for hash MoE"); + STD_TORCH_CHECK(input_ids.has_value(), + "input_ids is required for hash MoE"); use_hash = true; } - if (topk_indices.scalar_type() == at::ScalarType::Int) { + if (topk_indices.scalar_type() == torch::headeronly::ScalarType::Int) { const int* input_ids_ptr = nullptr; const int* tid2eid_ptr = nullptr; if (tid2eid.has_value()) { - input_ids_ptr = input_ids.value().data_ptr(); - tid2eid_ptr = tid2eid.value().data_ptr(); + input_ids_ptr = input_ids.value().const_data_ptr(); + tid2eid_ptr = tid2eid.value().const_data_ptr(); } vllm::moe::topkGatingSoftplusSqrtKernelLauncher( - gating_output, topk_weights.data_ptr(), - topk_indices.data_ptr(), token_expert_indices.data_ptr(), - num_tokens, num_experts, topk, renormalize, routed_scaling_factor, - bias_ptr, use_hash, input_ids_ptr, tid2eid_ptr, stream); - } else if (topk_indices.scalar_type() == at::ScalarType::UInt32) { + gating_output, topk_weights.mutable_data_ptr(), + topk_indices.mutable_data_ptr(), + token_expert_indices.mutable_data_ptr(), num_tokens, num_experts, + topk, renormalize, routed_scaling_factor, bias_ptr, use_hash, + input_ids_ptr, tid2eid_ptr, stream); + } else if (topk_indices.scalar_type() == + torch::headeronly::ScalarType::UInt32) { const uint32_t* input_ids_ptr = nullptr; const uint32_t* tid2eid_ptr = nullptr; if (tid2eid.has_value()) { - input_ids_ptr = input_ids.value().data_ptr(); - tid2eid_ptr = tid2eid.value().data_ptr(); + input_ids_ptr = input_ids.value().const_data_ptr(); + tid2eid_ptr = tid2eid.value().const_data_ptr(); } vllm::moe::topkGatingSoftplusSqrtKernelLauncher( - gating_output, topk_weights.data_ptr(), - topk_indices.data_ptr(), token_expert_indices.data_ptr(), - num_tokens, num_experts, topk, renormalize, routed_scaling_factor, - bias_ptr, use_hash, input_ids_ptr, tid2eid_ptr, stream); + gating_output, topk_weights.mutable_data_ptr(), + topk_indices.mutable_data_ptr(), + token_expert_indices.mutable_data_ptr(), num_tokens, num_experts, + topk, renormalize, routed_scaling_factor, bias_ptr, use_hash, + input_ids_ptr, tid2eid_ptr, stream); } else { - TORCH_CHECK(topk_indices.scalar_type() == at::ScalarType::Long); + STD_TORCH_CHECK(topk_indices.scalar_type() == + torch::headeronly::ScalarType::Long); const int64_t* input_ids_ptr = nullptr; const int64_t* tid2eid_ptr = nullptr; if (tid2eid.has_value()) { - input_ids_ptr = input_ids.value().data_ptr(); - tid2eid_ptr = tid2eid.value().data_ptr(); + input_ids_ptr = input_ids.value().const_data_ptr(); + tid2eid_ptr = tid2eid.value().const_data_ptr(); } vllm::moe::topkGatingSoftplusSqrtKernelLauncher( - gating_output, topk_weights.data_ptr(), - topk_indices.data_ptr(), token_expert_indices.data_ptr(), - num_tokens, num_experts, topk, renormalize, routed_scaling_factor, - bias_ptr, use_hash, input_ids_ptr, tid2eid_ptr, stream); + gating_output, topk_weights.mutable_data_ptr(), + topk_indices.mutable_data_ptr(), + token_expert_indices.mutable_data_ptr(), num_tokens, num_experts, + topk, renormalize, routed_scaling_factor, bias_ptr, use_hash, + input_ids_ptr, tid2eid_ptr, stream); } } void topk_softplus_sqrt( - torch::Tensor& topk_weights, // [num_tokens, topk] - torch::Tensor& topk_indices, // [num_tokens, topk] - torch::Tensor& token_expert_indices, // [num_tokens, topk] - torch::Tensor& gating_output, // [num_tokens, num_experts] + torch::stable::Tensor& topk_weights, // [num_tokens, topk] + torch::stable::Tensor& topk_indices, // [num_tokens, topk] + torch::stable::Tensor& token_expert_indices, // [num_tokens, topk] + torch::stable::Tensor& gating_output, // [num_tokens, num_experts] bool renormalize, double routed_scaling_factor, - const c10::optional& correction_bias, - const c10::optional& input_ids, - const c10::optional& tid2eid) { + const std::optional& correction_bias, + const std::optional& input_ids, + const std::optional& tid2eid) { const int num_experts = gating_output.size(-1); const auto num_tokens = gating_output.numel() / num_experts; const int topk = topk_weights.size(-1); - const at::cuda::OptionalCUDAGuard device_guard(device_of(gating_output)); - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); + const torch::stable::accelerator::DeviceGuard guard( + gating_output.get_device_index()); + const cudaStream_t stream = + get_current_cuda_stream(gating_output.get_device_index()); - if (gating_output.scalar_type() == at::ScalarType::Float) { + if (gating_output.scalar_type() == torch::headeronly::ScalarType::Float) { dispatch_topk_softplus_sqrt_launch( - gating_output.data_ptr(), topk_weights, topk_indices, + gating_output.const_data_ptr(), topk_weights, topk_indices, token_expert_indices, num_tokens, num_experts, topk, renormalize, routed_scaling_factor, correction_bias, input_ids, tid2eid, stream); - } else if (gating_output.scalar_type() == at::ScalarType::Half) { + } else if (gating_output.scalar_type() == + torch::headeronly::ScalarType::Half) { dispatch_topk_softplus_sqrt_launch<__half>( - reinterpret_cast(gating_output.data_ptr()), + reinterpret_cast(gating_output.const_data_ptr()), topk_weights, topk_indices, token_expert_indices, num_tokens, num_experts, topk, renormalize, routed_scaling_factor, correction_bias, input_ids, tid2eid, stream); - } else if (gating_output.scalar_type() == at::ScalarType::BFloat16) { + } else if (gating_output.scalar_type() == + torch::headeronly::ScalarType::BFloat16) { dispatch_topk_softplus_sqrt_launch<__nv_bfloat16>( - reinterpret_cast( - gating_output.data_ptr()), + reinterpret_cast(gating_output.const_data_ptr()), topk_weights, topk_indices, token_expert_indices, num_tokens, num_experts, topk, renormalize, routed_scaling_factor, correction_bias, input_ids, tid2eid, stream); } else { - TORCH_CHECK(false, "Unsupported gating_output data type: ", - gating_output.scalar_type()); + STD_TORCH_CHECK(false, "Unsupported gating_output data type: ", + gating_output.scalar_type()); } } \ No newline at end of file diff --git a/csrc/moe/torch_bindings.cpp b/csrc/libtorch_stable/moe/torch_bindings.cpp similarity index 81% rename from csrc/moe/torch_bindings.cpp rename to csrc/libtorch_stable/moe/torch_bindings.cpp index 99230f03b4b0..ba5b9b896f18 100644 --- a/csrc/moe/torch_bindings.cpp +++ b/csrc/libtorch_stable/moe/torch_bindings.cpp @@ -1,32 +1,30 @@ #include "core/registration.h" #include "moe_ops.h" -TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { +#include + +STABLE_TORCH_LIBRARY_FRAGMENT(_moe_C, m) { // Apply topk softmax to the gating outputs. m.def( "topk_softmax(Tensor! topk_weights, Tensor! topk_indices, Tensor! " "token_expert_indices, Tensor gating_output, bool renormalize, Tensor? " "bias) -> ()"); - m.impl("topk_softmax", torch::kCUDA, &topk_softmax); // Apply topk sigmoid to the gating outputs. m.def( "topk_sigmoid(Tensor! topk_weights, Tensor! topk_indices, Tensor! " - "token_expert_indices, Tensor gating_output, bool renormalize, Tensor? " - "bias) -> ()"); - m.impl("topk_sigmoid", torch::kCUDA, &topk_sigmoid); + "token_expert_indices, Tensor gating_output, bool renormalize, " + "Tensor? bias, float routed_scaling_factor) -> ()"); m.def( "topk_softplus_sqrt(Tensor! topk_weights, Tensor! topk_indices, Tensor! " "token_expert_indices, Tensor gating_output, bool renormalize, float " "routed_scaling_factor, Tensor? " "bias, Tensor? input_ids, Tensor? tid2eid) -> ()"); - m.impl("topk_softplus_sqrt", torch::kCUDA, &topk_softplus_sqrt); // Calculate the result of moe by summing up the partial results // from all selected experts. m.def("moe_sum(Tensor input, Tensor! output) -> ()"); - m.impl("moe_sum", torch::kCUDA, &moe_sum); // Aligning the number of tokens to be processed by each expert such // that it is divisible by the block size. @@ -36,7 +34,6 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { " Tensor! experts_ids," " Tensor! num_tokens_post_pad," " Tensor? maybe_expert_map) -> ()"); - m.impl("moe_align_block_size", torch::kCUDA, &moe_align_block_size); // Aligning the number of tokens to be processed by each expert such // that it is divisible by the block size, but for the batched case. @@ -46,8 +43,6 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { " Tensor! sorted_token_ids," " Tensor! experts_ids," " Tensor! num_tokens_post_pad) -> ()"); - m.impl("batched_moe_align_block_size", torch::kCUDA, - &batched_moe_align_block_size); // Aligning the number of tokens to be processed by each expert such // that it is divisible by the block size. @@ -64,8 +59,6 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { " Tensor !adapter_enabled," " Tensor !lora_ids," " Tensor? maybe_expert_map) -> () "); - m.impl("moe_lora_align_block_size", torch::kCUDA, &moe_lora_align_block_size); - #ifndef USE_ROCM m.def( "moe_wna16_gemm(Tensor input, Tensor! output, Tensor b_qweight, " @@ -75,8 +68,6 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { "int top_k, int BLOCK_SIZE_M, int BLOCK_SIZE_N, int BLOCK_SIZE_K, " "int bit) -> Tensor"); - m.impl("moe_wna16_gemm", torch::kCUDA, &moe_wna16_gemm); - m.def( "moe_wna16_marlin_gemm(Tensor! a, Tensor? c_or_none," "Tensor! b_q_weight, Tensor? b_bias_or_none," @@ -118,14 +109,11 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { m.def( "moe_permute_sort_workspace_size(int num_expanded_rows, int n_expert) -> " "int"); - m.impl("moe_permute_unpermute_supported", &moe_permute_unpermute_supported); - m.impl("moe_permute_sort_workspace_size", &moe_permute_sort_workspace_size); // Row shuffle for MoE m.def( "shuffle_rows(Tensor input_tensor, Tensor dst2src_map, Tensor! " "output_tensor) -> ()"); - m.impl("shuffle_rows", torch::kCUDA, &shuffle_rows); // Apply grouped topk routing to select experts. m.def( @@ -133,7 +121,6 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { "topk_group, int topk, bool renormalize, float " "routed_scaling_factor, Tensor bias, int scoring_func) -> (Tensor, " "Tensor)"); - m.impl("grouped_topk", torch::kCUDA, &grouped_topk); // DeepSeek V3 optimized router GEMM for SM90+ m.def("dsv3_router_gemm(Tensor! output, Tensor mat_a, Tensor mat_b) -> ()"); @@ -141,4 +128,30 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, m) { #endif } -REGISTER_EXTENSION(TORCH_EXTENSION_NAME) +STABLE_TORCH_LIBRARY_IMPL(_moe_C, CUDA, m) { + m.impl("topk_softmax", TORCH_BOX(&topk_softmax)); + m.impl("topk_sigmoid", TORCH_BOX(&topk_sigmoid)); + m.impl("topk_softplus_sqrt", TORCH_BOX(&topk_softplus_sqrt)); + m.impl("moe_sum", TORCH_BOX(&moe_sum)); + m.impl("moe_align_block_size", TORCH_BOX(&moe_align_block_size)); + m.impl("batched_moe_align_block_size", + TORCH_BOX(&batched_moe_align_block_size)); + m.impl("moe_lora_align_block_size", TORCH_BOX(&moe_lora_align_block_size)); +#ifndef USE_ROCM + m.impl("moe_wna16_gemm", TORCH_BOX(&moe_wna16_gemm)); + m.impl("shuffle_rows", TORCH_BOX(&shuffle_rows)); + m.impl("grouped_topk", TORCH_BOX(&grouped_topk)); +#endif +} + +#ifndef USE_ROCM +// Primitive-only ops have no tensor to dispatch on. +STABLE_TORCH_LIBRARY_IMPL(_moe_C, CompositeExplicitAutograd, m) { + m.impl("moe_permute_unpermute_supported", + TORCH_BOX(&moe_permute_unpermute_supported)); + m.impl("moe_permute_sort_workspace_size", + TORCH_BOX(&moe_permute_sort_workspace_size)); +} +#endif + +REGISTER_EXTENSION(_moe_C_stable_libtorch) diff --git a/csrc/libtorch_stable/ops.h b/csrc/libtorch_stable/ops.h index dd27a6968d0a..0daa024a7672 100644 --- a/csrc/libtorch_stable/ops.h +++ b/csrc/libtorch_stable/ops.h @@ -2,6 +2,25 @@ #include #include +#include + +#include +#include +#include + +#include + +inline torch::stable::Tensor weak_ref_tensor(torch::stable::Tensor& tensor) { + // Ensure tensor is on CUDA + STD_TORCH_CHECK(tensor.device().is_cuda(), "Tensor must be on CUDA device"); + + // Get the raw data pointer + void* data_ptr = tensor.mutable_data_ptr(); + + /// Create a new tensor from the raw data pointer + return torch::stable::from_blob(data_ptr, tensor.sizes(), tensor.strides(), + tensor.device(), tensor.scalar_type()); +} void per_token_group_quant_fp8(const torch::stable::Tensor& input, torch::stable::Tensor& output_q, @@ -24,10 +43,10 @@ void per_token_group_quant_int8(const torch::stable::Tensor& input, int64_t group_size, double eps, double int8_min, double int8_max); -#ifndef USE_ROCM torch::stable::Tensor permute_cols(torch::stable::Tensor const& A, torch::stable::Tensor const& perm); +#ifndef USE_ROCM bool cutlass_scaled_mm_supports_fp8(int64_t cuda_device_capability); bool cutlass_scaled_mm_supports_block_fp8(int64_t cuda_device_capability); bool cutlass_group_gemm_supported(int64_t cuda_device_capability); @@ -164,6 +183,10 @@ torch::stable::Tensor awq_dequantize(torch::stable::Tensor _kernel, #endif +// CPU tensor -> CUDA UVA view (shared CUDA/ROCm) +torch::stable::Tensor get_cuda_view_from_cpu_tensor( + torch::stable::Tensor& cpu_tensor); + // Attention kernels (shared CUDA/ROCm) void merge_attn_states( torch::stable::Tensor& output, @@ -180,11 +203,12 @@ torch::stable::Tensor hadacore_transform(torch::stable::Tensor& x, // Layernorm kernels (shared CUDA/ROCm) void rms_norm(torch::stable::Tensor& out, torch::stable::Tensor& input, - torch::stable::Tensor& weight, double epsilon); + std::optional weight, double epsilon); void fused_add_rms_norm(torch::stable::Tensor& input, torch::stable::Tensor& residual, - torch::stable::Tensor& weight, double epsilon); + std::optional weight, + double epsilon); // Layernorm-quant kernels (shared CUDA/ROCm) void rms_norm_static_fp8_quant(torch::stable::Tensor& out, @@ -215,6 +239,13 @@ void rms_norm_per_block_quant(torch::stable::Tensor& out, std::optional residual, int64_t group_size, bool is_scale_transposed); +void silu_and_mul_per_block_quant(torch::stable::Tensor& out, + torch::stable::Tensor const& input, + torch::stable::Tensor& scales, + int64_t group_size, + std::optional scale_ub, + bool is_scale_transposed); + // Positional encoding kernels (shared CUDA/ROCm) void rotary_embedding(torch::stable::Tensor& positions, torch::stable::Tensor& query, @@ -238,11 +269,24 @@ torch::stable::Tensor fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert( torch::stable::Tensor const& cos_sin_cache, int64_t q_head_padded, double eps, int64_t cache_block_size); +void fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert( + torch::stable::Tensor& q, torch::stable::Tensor const& kv, + torch::stable::Tensor& k_cache, torch::stable::Tensor const& slot_mapping, + torch::stable::Tensor const& position_ids, + torch::stable::Tensor const& cos_sin_cache, double eps, + int64_t cache_block_size); + +void fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert( + torch::stable::Tensor const& q, torch::stable::Tensor const& kv, + torch::stable::Tensor& q_fp8, torch::stable::Tensor& k_cache, + torch::stable::Tensor const& slot_mapping, + torch::stable::Tensor const& position_ids, + torch::stable::Tensor const& cos_sin_cache, + torch::stable::Tensor const& fp8_scale, + torch::stable::Tensor const& q_fp8_scale_inv, double eps, + int64_t cache_block_size); + #ifndef USE_ROCM -torch::stable::Tensor minimax_allreduce_rms( - torch::stable::Tensor const& input, - torch::stable::Tensor const& norm_weight, torch::stable::Tensor workspace, - int64_t const rank, int64_t const nranks, double const eps); std::tuple minimax_allreduce_rms_qk(torch::stable::Tensor qkv, torch::stable::Tensor const& norm_weight_q, @@ -252,6 +296,25 @@ minimax_allreduce_rms_qk(torch::stable::Tensor qkv, int64_t const nranks, double const eps); #endif +// Horizontally-fused MiniMax-M3 QK-norm + partial NeoX RoPE (+ optional KV / +// index-cache insert). Dense layer: norm+RoPE only; sparse layer: also packs +// the index branch and scatters k/v/index_k into their paged caches. +void fused_minimax_m3_qknorm_rope_kv_insert( + torch::stable::Tensor& qkv, torch::stable::Tensor const& q_norm_weight, + torch::stable::Tensor const& k_norm_weight, + torch::stable::Tensor const& cos_sin_cache, + torch::stable::Tensor const& positions, int64_t num_heads, + int64_t num_kv_heads, int64_t rotary_dim, double eps, + std::optional index_q_norm_weight, + std::optional index_k_norm_weight, + int64_t num_index_heads, std::optional slot_mapping, + std::optional index_slot_mapping, + std::optional kv_cache, + std::optional index_cache, int64_t block_size, + std::optional q_out, + std::optional index_q_out, + const std::string& kv_cache_dtype); + // Sampler kernels (shared CUDA/ROCm) void apply_repetition_penalties_( torch::stable::Tensor& logits, const torch::stable::Tensor& prompt_mask, @@ -275,6 +338,14 @@ void persistent_topk(const torch::stable::Tensor& logits, torch::stable::Tensor& workspace, int64_t k, int64_t max_seq_len); +#ifdef VLLM_ENABLE_COOPERATIVE_TOPK +void cooperative_topk(const torch::stable::Tensor& logits, + const torch::stable::Tensor& lengths, + torch::stable::Tensor& output, + torch::stable::Tensor& workspace, int64_t k, + int64_t max_seq_len); +#endif + void selective_scan_fwd( const torch::stable::Tensor& u, const torch::stable::Tensor& delta, const torch::stable::Tensor& A, const torch::stable::Tensor& B, @@ -317,7 +388,20 @@ void free_shared_buffer(int64_t buffer); // Activation kernels (shared CUDA/ROCm) void silu_and_mul(torch::stable::Tensor& out, torch::stable::Tensor& input); void silu_and_mul_clamp(torch::stable::Tensor& out, - torch::stable::Tensor& input, double limit); + torch::stable::Tensor& input, double limit, + double alpha = 1.0, double beta = 0.0); + +void silu_and_mul_quant(torch::stable::Tensor& out, + torch::stable::Tensor& input, + torch::stable::Tensor& scale); + +void persistent_masked_m_silu_mul_quant( + const torch::stable::Tensor& input, // (E, T, 2*H) + const torch::stable::Tensor& tokens_per_expert, // (E) + torch::stable::Tensor& y_q, // (E, T, H) [OUT] + torch::stable::Tensor& y_s, // (E, T, H//group_size) [OUT] + bool use_ue8m0); + void mul_and_silu(torch::stable::Tensor& out, torch::stable::Tensor& input); void gelu_and_mul(torch::stable::Tensor& out, torch::stable::Tensor& input); void gelu_tanh_and_mul(torch::stable::Tensor& out, @@ -368,61 +452,6 @@ torch::stable::Tensor gptq_gemm(torch::stable::Tensor a, void gptq_shuffle(torch::stable::Tensor q_weight, torch::stable::Tensor q_perm, int64_t bit); -// GGML kernels (shared CUDA/ROCm) -torch::stable::Tensor ggml_dequantize( - torch::stable::Tensor W, int64_t type, int64_t m, int64_t n, - std::optional const& dtype); - -torch::stable::Tensor ggml_mul_mat_vec_a8(torch::stable::Tensor W, - torch::stable::Tensor X, int64_t type, - int64_t row); - -torch::stable::Tensor ggml_mul_mat_a8(torch::stable::Tensor W, - torch::stable::Tensor X, int64_t type, - int64_t row); - -torch::stable::Tensor ggml_moe_a8(torch::stable::Tensor X, - torch::stable::Tensor W, - torch::stable::Tensor sorted_token_ids, - torch::stable::Tensor expert_ids, - torch::stable::Tensor num_tokens_post_padded, - int64_t type, int64_t row, int64_t top_k, - int64_t tokens); - -torch::stable::Tensor ggml_moe_a8_vec(torch::stable::Tensor X, - torch::stable::Tensor W, - torch::stable::Tensor topk_ids, - int64_t top_k, int64_t type, int64_t row, - int64_t tokens); - -int64_t ggml_moe_get_block_size(int64_t type); - -void paged_attention_v1( - torch::stable::Tensor& out, torch::stable::Tensor& query, - torch::stable::Tensor& key_cache, torch::stable::Tensor& value_cache, - int64_t num_kv_heads, double scale, torch::stable::Tensor& block_tables, - torch::stable::Tensor& seq_lens, int64_t block_size, int64_t max_seq_len, - const std::optional& alibi_slopes, - const std::string& kv_cache_dtype, torch::stable::Tensor& k_scale, - torch::stable::Tensor& v_scale, const int64_t tp_rank, - const int64_t blocksparse_local_blocks, - const int64_t blocksparse_vert_stride, const int64_t blocksparse_block_size, - const int64_t blocksparse_head_sliding_step); - -void paged_attention_v2( - torch::stable::Tensor& out, torch::stable::Tensor& exp_sums, - torch::stable::Tensor& max_logits, torch::stable::Tensor& tmp_out, - torch::stable::Tensor& query, torch::stable::Tensor& key_cache, - torch::stable::Tensor& value_cache, int64_t num_kv_heads, double scale, - torch::stable::Tensor& block_tables, torch::stable::Tensor& seq_lens, - int64_t block_size, int64_t max_seq_len, - const std::optional& alibi_slopes, - const std::string& kv_cache_dtype, torch::stable::Tensor& k_scale, - torch::stable::Tensor& v_scale, const int64_t tp_rank, - const int64_t blocksparse_local_blocks, - const int64_t blocksparse_vert_stride, const int64_t blocksparse_block_size, - const int64_t blocksparse_head_sliding_step); - // Cache ops (shared CUDA/ROCm) void swap_blocks(torch::stable::Tensor& src, torch::stable::Tensor& dst, int64_t block_size_in_bytes, diff --git a/csrc/libtorch_stable/persistent_topk.cuh b/csrc/libtorch_stable/persistent_topk.cuh index 6b25dc9940e8..85618feeb8a0 100644 --- a/csrc/libtorch_stable/persistent_topk.cuh +++ b/csrc/libtorch_stable/persistent_topk.cuh @@ -11,6 +11,8 @@ #include #include +#include "topk_histogram_4096.cuh" + namespace vllm { namespace persistent { @@ -935,8 +937,16 @@ __global__ void __launch_bounds__(kThreadsPerBlock, 2) } // namespace persistent // ============================================================================ -// FlashInfer FilteredTopK (BS>32 dispatch) — float32 only. -// Extracted from flashinfer_topk.cuh. Lives in namespace vllm (not persistent). +// ============================================================================ +// Optimized FilteredTopK — single CTA per row for bs > 32. +// Kept with persistent_topk so the portable fallback owns the non-cluster path. +// ============================================================================ +namespace filtered_topk { + +namespace hist4096 = topk_histogram_4096; + +// ============================================================================ +// FilteredTopK — single CTA per row for bs > 32 // Adapted from https://github.com/flashinfer-ai/flashinfer/pull/2215 // ============================================================================ @@ -963,13 +973,6 @@ struct vec_t { data[i] = ptr[i]; } } - - FLASHINFER_INLINE void cast_store(T* ptr) const { -#pragma unroll - for (size_t i = 0; i < N; ++i) { - ptr[i] = data[i]; - } - } }; #undef FLASHINFER_INLINE @@ -1013,7 +1016,8 @@ constexpr size_t FILTERED_TOPK_SMEM_DYNAMIC = * \tparam IdType Index type (int32_t) * \tparam VEC_SIZE Vector size for input loads (1, 2, 4, or 8) */ -template +template __global__ void __launch_bounds__(FILTERED_TOPK_BLOCK_THREADS) FilteredTopKUnifiedKernel(const DType* __restrict__ input, IdType* __restrict__ output, @@ -1042,6 +1046,19 @@ __global__ void __launch_bounds__(FILTERED_TOPK_BLOCK_THREADS) return; } + // Short path + if (length <= 32768) { + extern __shared__ uint8_t _smem_reg[]; + if constexpr (UsePredicatedShortLoads) { + hist4096::histogram_4096_topk_predicated(score, dst, length, + _smem_reg); + } else { + hist4096::histogram_4096_topk(score, dst, length, + _smem_reg); + } + return; + } + // Static shared memory alignas(128) __shared__ int s_histogram_buf[2][RADIX + 128]; alignas(128) __shared__ int s_counter; @@ -1285,14 +1302,15 @@ cudaError_t FilteredTopKRaggedTransform(const DType* input, const int vec_size = ComputeFilteredTopKVecSize(max_len); -#define DISPATCH_VEC_SIZE(VS) \ - if (vec_size == VS) { \ - auto kernel = FilteredTopKUnifiedKernel; \ - FLASHINFER_CUDA_CALL(cudaFuncSetAttribute( \ - kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, smem_size)); \ - FLASHINFER_CUDA_CALL(cudaLaunchKernel((void*)kernel, grid, block, args, \ - smem_size, stream)); \ - return cudaSuccess; \ +#define DISPATCH_VEC_SIZE(VS) \ + if (vec_size == VS) { \ + auto kernel = \ + FilteredTopKUnifiedKernel; \ + FLASHINFER_CUDA_CALL(cudaFuncSetAttribute( \ + kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, smem_size)); \ + FLASHINFER_CUDA_CALL(cudaLaunchKernel((void*)kernel, grid, block, args, \ + smem_size, stream)); \ + return cudaSuccess; \ } DISPATCH_VEC_SIZE(1) @@ -1306,6 +1324,19 @@ cudaError_t FilteredTopKRaggedTransform(const DType* input, return cudaSuccess; } +} // namespace filtered_topk + +template +cudaError_t FilteredTopKRaggedTransform(const DType* input, + IdType* output_indices, + const IdType* lengths, + uint32_t num_rows, uint32_t top_k_val, + uint32_t max_len, + cudaStream_t stream = 0) { + return filtered_topk::FilteredTopKRaggedTransform( + input, output_indices, lengths, num_rows, top_k_val, max_len, stream); +} + } // namespace vllm #endif // PERSISTENT_TOPK_CUH_ diff --git a/csrc/quantization/activation_kernels.cu b/csrc/libtorch_stable/quantization/activation_kernels.cu similarity index 87% rename from csrc/quantization/activation_kernels.cu rename to csrc/libtorch_stable/quantization/activation_kernels.cu index 8cc645c33e2f..822a41969e7e 100644 --- a/csrc/quantization/activation_kernels.cu +++ b/csrc/libtorch_stable/quantization/activation_kernels.cu @@ -1,16 +1,12 @@ -#include -#include -#include +#include "libtorch_stable/torch_utils.h" #include -#include "core/math.hpp" -#include "../cuda_compat.h" -#include "dispatch_utils.h" +#include "libtorch_stable/core/math.hpp" +#include "cuda_compat.h" +#include "libtorch_stable/dispatch_utils.h" #include "quantization/w8a8/fp8/common.cuh" -#include - #ifndef USE_ROCM #include #include @@ -33,7 +29,6 @@ typedef __hip_fp8x4_e4m3_fnuz __nv_fp8x4_e4m3; #endif #endif -#include "core/registration.h" namespace vllm { template @@ -564,41 +559,47 @@ __global__ void silu_mul_fp8_quant_deep_gemm_kernel( } // namespace vllm // Launch activation, gating, and quantize kernel. -#define LAUNCH_ACTIVATION_GATE_KERNEL(KERNEL) \ - int d = input.size(-1) / 2; \ - int64_t num_tokens = input.numel() / input.size(-1); \ - dim3 grid(num_tokens, num_tokens > 16 ? num_tokens > 32 ? 1 : 2 : 4); \ - dim3 block(std::min(d, 512)); \ - const at::cuda::OptionalCUDAGuard device_guard(device_of(input)); \ - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); \ - VLLM_DISPATCH_FLOATING_TYPES( \ - input.scalar_type(), "act_and_mul_kernel", [&] { \ - VLLM_DISPATCH_FP8_TYPES( \ - out.scalar_type(), "fused_add_rms_norm_kernel_fp8_type", [&] { \ - vllm::act_and_mul_quant_kernel, \ - fp8_t> \ - <<>>(out.data_ptr(), \ - input.data_ptr(), \ - scale.data_ptr(), d); \ - }); \ +#define LAUNCH_ACTIVATION_GATE_KERNEL(KERNEL) \ + int d = input.size(-1) / 2; \ + int64_t num_tokens = input.numel() / input.size(-1); \ + dim3 grid(num_tokens, num_tokens > 16 ? num_tokens > 32 ? 1 : 2 : 4); \ + dim3 block(std::min(d, 512)); \ + const torch::stable::accelerator::DeviceGuard device_guard( \ + input.get_device_index()); \ + const cudaStream_t stream = \ + get_current_cuda_stream(input.get_device_index()); \ + VLLM_STABLE_DISPATCH_FLOATING_TYPES( \ + input.scalar_type(), "act_and_mul_kernel", [&] { \ + VLLM_STABLE_DISPATCH_FP8_TYPES( \ + out.scalar_type(), "act_and_mul_quant_kernel_fp8_type", [&] { \ + vllm::act_and_mul_quant_kernel, \ + fp8_t> \ + <<>>( \ + out.mutable_data_ptr(), \ + input.const_data_ptr(), \ + scale.const_data_ptr(), d); \ + }); \ }); -void silu_and_mul_quant(torch::Tensor& out, // [..., d] - torch::Tensor& input, // [..., 2 * d] - torch::Tensor& scale) { - TORCH_CHECK(out.dtype() == torch::kFloat8_e4m3fn || - out.dtype() == torch::kFloat8_e4m3fnuz); - TORCH_CHECK(input.dtype() == torch::kFloat16 || - input.dtype() == torch::kBFloat16); - TORCH_CHECK(input.size(-1) % 2 == 0); +void silu_and_mul_quant(torch::stable::Tensor& out, // [..., d] + torch::stable::Tensor& input, // [..., 2 * d] + torch::stable::Tensor& scale) { + STD_TORCH_CHECK( + out.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fn || + out.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fnuz); + STD_TORCH_CHECK( + input.scalar_type() == torch::headeronly::ScalarType::Half || + input.scalar_type() == torch::headeronly::ScalarType::BFloat16, + "Input must be FP16 or BF16"); + STD_TORCH_CHECK(input.size(-1) % 2 == 0); LAUNCH_ACTIVATION_GATE_KERNEL(vllm::silu_kernel); } void persistent_masked_m_silu_mul_quant( - const at::Tensor& input, // (E, T, 2*H) - const at::Tensor& tokens_per_expert, // (E) - at::Tensor& y_q, // (E, T, H) [OUT] - at::Tensor& y_s, // (E, T, H//group_size) [OUT] + const torch::stable::Tensor& input, // (E, T, 2*H) + const torch::stable::Tensor& tokens_per_expert, // (E) + torch::stable::Tensor& y_q, // (E, T, H) [OUT] + torch::stable::Tensor& y_s, // (E, T, H//group_size) [OUT] bool cast_scale_ue8m0) { #ifndef USE_ROCM @@ -606,14 +607,18 @@ void persistent_masked_m_silu_mul_quant( // fixed GROUP_SIZE of 128. static constexpr int GROUP_SIZE = 128; - TORCH_CHECK(input.dtype() == torch::kBFloat16); - TORCH_CHECK(y_q.dtype() == torch::kFloat8_e4m3fn || - y_q.dtype() == torch::kFloat8_e4m3fnuz); - TORCH_CHECK(input.size(-1) % (GROUP_SIZE * 2) == 0); + STD_TORCH_CHECK(input.scalar_type() == + torch::headeronly::ScalarType::BFloat16); + STD_TORCH_CHECK( + y_q.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fn || + y_q.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fnuz); + STD_TORCH_CHECK(input.size(-1) % (GROUP_SIZE * 2) == 0); bool const is_packed_ue8m0 = - (y_s.dtype() == torch::kInt32 && cast_scale_ue8m0); - TORCH_CHECK(y_s.dtype() == torch::kFloat32 || is_packed_ue8m0); + (y_s.scalar_type() == torch::headeronly::ScalarType::Int && + cast_scale_ue8m0); + STD_TORCH_CHECK(y_s.scalar_type() == torch::headeronly::ScalarType::Float || + is_packed_ue8m0); using Idx_t = int64_t; @@ -631,7 +636,7 @@ void persistent_masked_m_silu_mul_quant( int const NUM_GROUPS = H / GROUP_SIZE; - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); + const cudaStream_t stream = get_current_cuda_stream(input.get_device_index()); // TODO: Get this from cuda_arch ? static constexpr int SILU_V2_BLOCK_COUNT = 132 * 32; @@ -643,18 +648,21 @@ void persistent_masked_m_silu_mul_quant( static constexpr int max_shared_mem_bytes = \ GROUP_SIZE * 2 * STAGES * NUM_WARPS * 2; \ dim3 grid(sms), block(THREAD_COUNT); \ - const at::cuda::OptionalCUDAGuard device_guard(device_of(input)); \ - VLLM_DISPATCH_FP8_TYPES( \ + const torch::stable::accelerator::DeviceGuard device_guard( \ + input.get_device_index()); \ + VLLM_STABLE_DISPATCH_FP8_TYPES( \ y_q.scalar_type(), "silu_mul_fp8_quant_deep_gemm_kernel", [&] { \ vllm::silu_mul_fp8_quant_deep_gemm_kernel< \ BLOCK_COUNT, max_shared_mem_bytes, fp8_t, scale_t, THREAD_COUNT, \ Idx_t, CEIL_UE8M0, GROUP_SIZE, STAGES> \ <<>>( \ - reinterpret_cast<__nv_bfloat16*>(input.data_ptr()), \ - (fp8_t*)y_q.data_ptr(), \ - reinterpret_cast(y_s.data_ptr()), \ - reinterpret_cast(tokens_per_expert.data_ptr()), E, \ - T, H, stride_i_e, stride_i_t, stride_i_h, stride_yq_e, \ + reinterpret_cast( \ + input.const_data_ptr()), \ + y_q.mutable_data_ptr(), \ + reinterpret_cast(y_s.mutable_data_ptr()), \ + reinterpret_cast( \ + tokens_per_expert.const_data_ptr()), \ + E, T, H, stride_i_e, stride_i_t, stride_i_h, stride_yq_e, \ stride_yq_t, stride_yq_h, STRIDE_YS_E, STRIDE_YS_T, \ STRIDE_YS_G, STRIDE_YS_P, stride_counts_e); \ }); @@ -679,7 +687,7 @@ void persistent_masked_m_silu_mul_quant( Idx_t stride_ys_g = y_s.stride(2); Idx_t stride_ys_p = 0; if (!cast_scale_ue8m0) { - TORCH_CHECK(!is_packed_ue8m0); + STD_TORCH_CHECK(!is_packed_ue8m0); LAUNCH_ON_H(float, stride_ys_e, stride_ys_t, stride_ys_g, stride_ys_p, false); return; @@ -692,8 +700,8 @@ void persistent_masked_m_silu_mul_quant( return; } - TORCH_CHECK(cast_scale_ue8m0 && is_packed_ue8m0); - TORCH_CHECK(y_s.dtype() == torch::kInt32); + STD_TORCH_CHECK(cast_scale_ue8m0 && is_packed_ue8m0); + STD_TORCH_CHECK(y_s.scalar_type() == torch::headeronly::ScalarType::Int); // Int32 packed ue8m0 scales tensor. // Let E, T, G be the number to experts, number of tokens and number of groups diff --git a/csrc/libtorch_stable/quantization/cutlass_w4a8/get_group_starts.cuh b/csrc/libtorch_stable/quantization/cutlass_w4a8/get_group_starts.cuh index 5cda4c9750a2..721761791251 100644 --- a/csrc/libtorch_stable/quantization/cutlass_w4a8/get_group_starts.cuh +++ b/csrc/libtorch_stable/quantization/cutlass_w4a8/get_group_starts.cuh @@ -100,6 +100,8 @@ void run_get_group_gemm_starts( int64_t k = a_tensors.size(1); int64_t scale_k = cutlass::ceil_div(k, b_group_size); + const torch::stable::accelerator::DeviceGuard device_guard( + a_tensors.get_device_index()); auto stream = get_current_cuda_stream(a_tensors.get_device_index()); if (false) { diff --git a/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_grouped_mm_entry.cu b/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_grouped_mm_entry.cu index 1091d9d12308..1d7eb04093a2 100644 --- a/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_grouped_mm_entry.cu +++ b/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_grouped_mm_entry.cu @@ -17,11 +17,11 @@ #include #include #include "libtorch_stable/torch_utils.h" -#include "cutlass_extensions/torch_utils.hpp" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/torch_utils.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" #include "get_group_starts.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" #include "w4a8_utils.cuh" namespace vllm::cutlass_w4a8_moe { diff --git a/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_mm_entry.cu b/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_mm_entry.cu index c2b8c0c00dea..8f19394b3904 100644 --- a/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_mm_entry.cu +++ b/csrc/libtorch_stable/quantization/cutlass_w4a8/w4a8_mm_entry.cu @@ -6,7 +6,7 @@ #include #include #include "libtorch_stable/torch_utils.h" -#include "cutlass_extensions/torch_utils.hpp" +#include "libtorch_stable/cutlass_extensions/torch_utils.hpp" #include "w4a8_utils.cuh" #include "cutlass/cutlass.h" @@ -21,8 +21,8 @@ #include "cutlass/util/packed_stride.hpp" #include "cutlass/util/mixed_dtype_utils.hpp" -#include "cutlass_extensions/common.hpp" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" #include diff --git a/csrc/libtorch_stable/quantization/fp4/mxfp4_blockwise_moe_kernel.cu b/csrc/libtorch_stable/quantization/fp4/mxfp4_blockwise_moe_kernel.cu index 8a493fdf22c3..e4d2f2201250 100644 --- a/csrc/libtorch_stable/quantization/fp4/mxfp4_blockwise_moe_kernel.cu +++ b/csrc/libtorch_stable/quantization/fp4/mxfp4_blockwise_moe_kernel.cu @@ -12,7 +12,7 @@ #include -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" #include "cute/tensor.hpp" #include "cutlass/tensor_ref.h" @@ -142,6 +142,8 @@ void mxfp4_run_get_group_gemm_starts( torch::stable::Tensor const& sf_offsets, torch::stable::Tensor const& problem_sizes, int M, int N, int K) { int num_experts = (int)expert_offsets.size(0); + const torch::stable::accelerator::DeviceGuard device_guard( + a_tensors.get_device_index()); auto stream = get_current_cuda_stream(a_tensors.get_device_index()); STD_TORCH_CHECK(out_tensors.size(1) == N, @@ -172,6 +174,8 @@ void run_mxfp4_blockwise_scaled_group_mm_sm100( const torch::stable::Tensor& problem_sizes, const torch::stable::Tensor& expert_offsets, const torch::stable::Tensor& sf_offsets, int M, int N, int K) { + const torch::stable::accelerator::DeviceGuard device_guard( + a.get_device_index()); using ProblemShape = cutlass::gemm::GroupProblemShape>; using ElementType = cutlass::float_e2m1_t; diff --git a/csrc/libtorch_stable/quantization/fp4/mxfp4_experts_quant.cu b/csrc/libtorch_stable/quantization/fp4/mxfp4_experts_quant.cu index 062f6018653c..20f024bcef51 100644 --- a/csrc/libtorch_stable/quantization/fp4/mxfp4_experts_quant.cu +++ b/csrc/libtorch_stable/quantization/fp4/mxfp4_experts_quant.cu @@ -27,15 +27,24 @@ #include #include "libtorch_stable/torch_utils.h" #include "libtorch_stable/dispatch_utils.h" +#include "libtorch_stable/cutlass_extensions/common.hpp" #include "../../cuda_vec_utils.cuh" #include "cuda_utils.h" #include "nvfp4_utils.cuh" + +#if defined(CUDART_VERSION) && CUDART_VERSION >= 12090 + #define VLLM_MXFP4_EXPERTS_QUANT_SUPPORTED 1 static_assert(CVT_FP4_ELTS_PER_THREAD == 16, "MXFP4 experts quant requires PACK16 mode (CUDA >= 12.9)"); +#else + #define VLLM_MXFP4_EXPERTS_QUANT_SUPPORTED 0 +#endif #include "libtorch_stable/launch_bounds_utils.h" +#if VLLM_MXFP4_EXPERTS_QUANT_SUPPORTED + namespace vllm { // MXFP4 block size constants @@ -104,7 +113,7 @@ __global__ void __launch_bounds__(512, VLLM_BLOCKS_PER_SM(512)) &input_offset_by_experts[chunk_start + 12])); local_offsets[16] = __ldca(&input_offset_by_experts[chunk_start + 16]); -#pragma unroll + #pragma unroll for (int i = 0; i < 16; i++) { if (rowIdx >= local_offsets[i] && rowIdx < local_offsets[i + 1]) { rowIdx_in_expert = rowIdx - local_offsets[i]; @@ -309,14 +318,14 @@ void mxfp4_quant_impl(void* output, void* output_scale, void* input, } // namespace vllm -/*Quantization entry for mxfp4 experts quantization*/ -#define CHECK_TH_CUDA(x, m) \ - STD_TORCH_CHECK(x.is_cuda(), m, "must be a CUDA tensor") -#define CHECK_CONTIGUOUS(x, m) \ - STD_TORCH_CHECK(x.is_contiguous(), m, "must be contiguous") -#define CHECK_INPUT(x, m) \ - CHECK_TH_CUDA(x, m); \ - CHECK_CONTIGUOUS(x, m); + /*Quantization entry for mxfp4 experts quantization*/ + #define CHECK_TH_CUDA(x, m) \ + STD_TORCH_CHECK(x.is_cuda(), m, "must be a CUDA tensor") + #define CHECK_CONTIGUOUS(x, m) \ + STD_TORCH_CHECK(x.is_contiguous(), m, "must be contiguous") + #define CHECK_INPUT(x, m) \ + CHECK_TH_CUDA(x, m); \ + CHECK_CONTIGUOUS(x, m); constexpr auto HALF = torch::headeronly::ScalarType::Half; constexpr auto BF16 = torch::headeronly::ScalarType::BFloat16; @@ -364,12 +373,28 @@ static void validate_mxfp4_experts_quant_inputs( STD_TORCH_CHECK(output_scale.size(1) * 4 == padded_k); } +#endif // VLLM_MXFP4_EXPERTS_QUANT_SUPPORTED + +static bool mxfp4_experts_quant_sm_supported(int64_t cuda_device_capability) { +#if VLLM_MXFP4_EXPERTS_QUANT_SUPPORTED + return cuda_device_capability >= 100 && cuda_device_capability < 120; +#else + return false; +#endif +} + void mxfp4_experts_quant( torch::stable::Tensor& output, torch::stable::Tensor& output_scale, torch::stable::Tensor const& input, torch::stable::Tensor const& input_offset_by_experts, torch::stable::Tensor const& output_scale_offset_by_experts, int64_t n_experts) { +#if VLLM_MXFP4_EXPERTS_QUANT_SUPPORTED + int32_t sm = get_sm_version_num(); + STD_TORCH_CHECK(mxfp4_experts_quant_sm_supported(sm), + "No compiled MXFP4 experts quant kernel for SM ", sm, + ". Recompile with SM10x/11x FP4 support and CUDA >= 12.9."); + auto m_topk = input.size(0); auto k = input.size(1); @@ -390,6 +415,10 @@ void mxfp4_experts_quant( output_scale_offset_by_experts.data_ptr(), m_topk, k, n_experts, stream); }); +#else + STD_TORCH_CHECK_NOT_IMPLEMENTED(false, + "MXFP4 experts quant requires CUDA >= 12.9."); +#endif } void silu_and_mul_mxfp4_experts_quant( @@ -398,6 +427,12 @@ void silu_and_mul_mxfp4_experts_quant( torch::stable::Tensor const& input_offset_by_experts, torch::stable::Tensor const& output_scale_offset_by_experts, int64_t n_experts) { +#if VLLM_MXFP4_EXPERTS_QUANT_SUPPORTED + int32_t sm = get_sm_version_num(); + STD_TORCH_CHECK(mxfp4_experts_quant_sm_supported(sm), + "No compiled SiLU+Mul MXFP4 experts quant kernel for SM ", sm, + ". Recompile with SM10x/11x FP4 support and CUDA >= 12.9."); + auto m_topk = input.size(0); auto k_times_2 = input.size(1); STD_TORCH_CHECK(k_times_2 % 2 == 0, "input width must be even (gate || up)"); @@ -420,13 +455,29 @@ void silu_and_mul_mxfp4_experts_quant( output_scale_offset_by_experts.data_ptr(), m_topk, k, n_experts, stream); }); +#else + STD_TORCH_CHECK_NOT_IMPLEMENTED( + false, "SiLU+Mul MXFP4 experts quant requires CUDA >= 12.9."); +#endif } -// Registered here (not torch_bindings.cpp) because VLLM_GPU_FLAGS is applied -// only under COMPILE_LANGUAGE:CUDA, so ENABLE_NVFP4_SM100 is invisible to -// .cpp files and cannot gate the registration from there. +bool mxfp4_experts_quant_supported(int64_t cuda_device_capability) { + return mxfp4_experts_quant_sm_supported(cuda_device_capability); +} + +STABLE_TORCH_LIBRARY_FRAGMENT(_C, m) { + m.def("mxfp4_experts_quant_supported(int cuda_device_capability) -> bool"); +} + +// Registered here so the CUDA 12.8 stub and CUDA 12.9+ implementation stay +// tied to the same translation unit. STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { m.impl("mxfp4_experts_quant", TORCH_BOX(&mxfp4_experts_quant)); m.impl("silu_and_mul_mxfp4_experts_quant", TORCH_BOX(&silu_and_mul_mxfp4_experts_quant)); } + +STABLE_TORCH_LIBRARY_IMPL(_C, CompositeExplicitAutograd, m) { + m.impl("mxfp4_experts_quant_supported", + TORCH_BOX(&mxfp4_experts_quant_supported)); +} diff --git a/csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu b/csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu index b22308d25cae..b044db6d32ca 100644 --- a/csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu +++ b/csrc/libtorch_stable/quantization/fp4/nvfp4_blockwise_moe_kernel.cu @@ -20,7 +20,7 @@ #include -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" #include "cute/tensor.hpp" #include "cutlass/tensor_ref.h" @@ -173,6 +173,8 @@ void run_get_group_gemm_starts(const torch::stable::Tensor& a_starts, torch::stable::Tensor const& problem_sizes, int M, int N, int K) { int num_experts = (int)expert_offsets.size(0); + const torch::stable::accelerator::DeviceGuard device_guard( + a_tensors.get_device_index()); auto stream = get_current_cuda_stream(a_tensors.get_device_index()); STD_TORCH_CHECK(out_tensors.size(1) == N, @@ -206,6 +208,8 @@ void run_fp4_blockwise_scaled_group_mm_sm100( const torch::stable::Tensor& problem_sizes, const torch::stable::Tensor& expert_offsets, const torch::stable::Tensor& sf_offsets, int M, int N, int K) { + const torch::stable::accelerator::DeviceGuard device_guard( + a.get_device_index()); using ProblemShape = cutlass::gemm::GroupProblemShape>; using ElementType = cutlass::float_e2m1_t; @@ -411,6 +415,8 @@ void run_fp4_blockwise_scaled_group_mm_sm120( const torch::stable::Tensor& problem_sizes, const torch::stable::Tensor& expert_offsets, const torch::stable::Tensor& sf_offsets, int M, int N, int K) { + const torch::stable::accelerator::DeviceGuard device_guard( + a.get_device_index()); using ProblemShape = cutlass::gemm::GroupProblemShape>; using ElementType = cutlass::float_e2m1_t; diff --git a/csrc/libtorch_stable/quantization/fp4/nvfp4_quant_entry.cu b/csrc/libtorch_stable/quantization/fp4/nvfp4_quant_entry.cu index 8d4ba1accc7c..e1e7e7a74da2 100644 --- a/csrc/libtorch_stable/quantization/fp4/nvfp4_quant_entry.cu +++ b/csrc/libtorch_stable/quantization/fp4/nvfp4_quant_entry.cu @@ -18,7 +18,7 @@ #include "libtorch_stable/torch_utils.h" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" #include "nvfp4_utils.cuh" #if (defined(ENABLE_NVFP4_SM100) && ENABLE_NVFP4_SM100) || \ diff --git a/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_entry.cu b/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_entry.cu index d7b2a18e29cb..bfb526fcd401 100644 --- a/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_entry.cu +++ b/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_entry.cu @@ -18,7 +18,7 @@ #include "libtorch_stable/torch_utils.h" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" #if defined ENABLE_NVFP4_SM100 && ENABLE_NVFP4_SM100 void cutlass_scaled_fp4_mm_sm100a(torch::stable::Tensor& D, diff --git a/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_kernels.cu b/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_kernels.cu index fc83c6e8d348..af9f24a70e0b 100644 --- a/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_kernels.cu +++ b/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_kernels.cu @@ -18,7 +18,7 @@ #include "libtorch_stable/torch_utils.h" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" #include "cutlass/cutlass.h" @@ -31,7 +31,7 @@ #include "cutlass/util/packed_stride.hpp" -#include "core/math.hpp" +#include "libtorch_stable/core/math.hpp" #include "core/batch_invariant.hpp" using namespace cute; diff --git a/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_sm120_kernels.cu b/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_sm120_kernels.cu index 2baa00caa824..3a45ede8dfd5 100644 --- a/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_sm120_kernels.cu +++ b/csrc/libtorch_stable/quantization/fp4/nvfp4_scaled_mm_sm120_kernels.cu @@ -18,7 +18,7 @@ #include "libtorch_stable/torch_utils.h" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" #include "cutlass/cutlass.h" @@ -31,7 +31,7 @@ #include "cutlass/util/packed_stride.hpp" -#include "core/math.hpp" +#include "libtorch_stable/core/math.hpp" #include "core/batch_invariant.hpp" using namespace cute; diff --git a/csrc/libtorch_stable/quantization/fp4/nvfp4_utils.cuh b/csrc/libtorch_stable/quantization/fp4/nvfp4_utils.cuh index 0c04f010888d..667138f34873 100644 --- a/csrc/libtorch_stable/quantization/fp4/nvfp4_utils.cuh +++ b/csrc/libtorch_stable/quantization/fp4/nvfp4_utils.cuh @@ -22,15 +22,15 @@ #include "../../cuda_vec_utils.cuh" -#if defined(NVFP4_ENABLE_ELTS16) && defined(CUDA_VERSION) && \ - CUDA_VERSION >= 12090 +#if defined(NVFP4_ENABLE_ELTS16) && defined(CUDART_VERSION) && \ + CUDART_VERSION >= 12090 #define ELTS_PER_THREAD 16 + #define CVT_FP4_PACK16 1 constexpr int CVT_FP4_ELTS_PER_THREAD = 16; -constexpr bool CVT_FP4_PACK16 = true; #else #define ELTS_PER_THREAD 8 + #define CVT_FP4_PACK16 0 constexpr int CVT_FP4_ELTS_PER_THREAD = 8; -constexpr bool CVT_FP4_PACK16 = false; #endif constexpr int CVT_FP4_SF_VEC_SIZE = 16; @@ -237,21 +237,30 @@ __device__ __forceinline__ fp4_packed_t cvt_warp_fp16_to_fp4( // Get the final absolute maximum values. float vecMax = float(__hmax(localMax.x, localMax.y)); - // Get the SF (max value of the vector / max value of e2m1). - // maximum value of e2m1 = 6.0. - // TODO: use half as compute data type. - float SFValue = SFScaleVal * (vecMax * reciprocal_approximate_ftz(6.0f)); // 8 bits representation of the SF. + float SFValue; uint8_t fp8SFVal; - // Write the SF to global memory (STG.8). + if constexpr (UE8M0_SF) { - // Extract the 8 exponent bits from float32. - // float 32bits = 1 sign bit + 8 exponent bits + 23 mantissa bits. - uint32_t tmp = reinterpret_cast(SFValue) >> 23; - fp8SFVal = tmp & 0xff; - // Convert back to fp32. - reinterpret_cast(SFValue) = tmp << 23; + // OCP MX spec E8M0 scale computation (MXFP4 path): + // scale_exp = biased_exponent(round_up(vecMax)) - 2 + // -2 because max E2M1 value is 6.0 ≈ 2^2.58; we use 2^2=4 as the + // safe divisor so that max_val / scale <= 6.0 for values near 2^n. + uint32_t max_bits = __float_as_uint(vecMax); + // Add rounding bias at mantissa bit 21 (equivalent to bf16 val_to_add=32 + // at bit 5). Threshold: values with mantissa >= 0.75 (i.e. >= 1.75*2^n) + // round up to the next power of 2. + uint32_t rounded_bits = (max_bits + (1u << 21)) & 0xFF800000u; + uint32_t biased_exp = (rounded_bits >> 23) & 0xFFu; + uint32_t scale_exp = (biased_exp > 2u) ? (biased_exp - 2u) : 0u; + scale_exp = min(scale_exp, 254u); + fp8SFVal = static_cast(scale_exp); + // Reconstruct scale as float32: scale = 2^(scale_exp - 127) + uint32_t sf_bits = scale_exp << 23; + SFValue = __uint_as_float(sf_bits); } else { + // NVFP4 path: scale = max / 6.0, stored as E4M3. + SFValue = SFScaleVal * (vecMax * reciprocal_approximate_ftz(6.0f)); // Here SFValue is always positive, so E4M3 is the same as UE4M3. __nv_fp8_e4m3 tmp = __nv_fp8_e4m3(SFValue); reinterpret_cast<__nv_fp8_e4m3&>(fp8SFVal) = tmp; @@ -262,13 +271,21 @@ __device__ __forceinline__ fp4_packed_t cvt_warp_fp16_to_fp4( // Write the SF to global memory (STG.8). if (SFout) *SFout = fp8SFVal; - // Get the output scale. - // Recipe: final_scale = reciprocal(fp32(fp8(SFValue * SFScaleVal))) * - // reciprocal(SFScaleVal)) - float outputScale = - SFValue != 0.0f ? reciprocal_approximate_ftz( + // Get the output scale (= 1 / SFValue for the MXFP4/UE8M0 path where + // SFScaleVal=1). Use exact division for UE8M0 to ensure bit-exact scaling + // that matches the reference QDQ implementation (dividing by a power-of-2 + // scale is exact in IEEE 754). + float outputScale; + if constexpr (UE8M0_SF) { + // SFValue is always a power of 2 for UE8M0, so 1/SFValue is exact. + outputScale = SFValue != 0.0f ? (1.0f / SFValue) : 0.0f; + } else { + // NVFP4 path: use fast approximate reciprocal (original behavior). + outputScale = SFValue != 0.0f + ? reciprocal_approximate_ftz( SFValue * reciprocal_approximate_ftz(SFScaleVal)) : 0.0f; + } // Convert the input to float. float2 fp2Vals[CVT_FP4_ELTS_PER_THREAD / 2]; diff --git a/csrc/quantization/fused_kernels/fused_silu_mul_block_quant.cu b/csrc/libtorch_stable/quantization/fused_kernels/fused_silu_mul_block_quant.cu similarity index 63% rename from csrc/quantization/fused_kernels/fused_silu_mul_block_quant.cu rename to csrc/libtorch_stable/quantization/fused_kernels/fused_silu_mul_block_quant.cu index d5c76232599e..b32a7bd271fd 100644 --- a/csrc/quantization/fused_kernels/fused_silu_mul_block_quant.cu +++ b/csrc/libtorch_stable/quantization/fused_kernels/fused_silu_mul_block_quant.cu @@ -1,11 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project -#include -#include +#include "../../torch_utils.h" #include "../../dispatch_utils.h" -#include "libtorch_stable/quantization/fused_kernels/quant_conversions.cuh" +#include "quant_conversions.cuh" namespace vllm { @@ -105,64 +104,70 @@ __global__ void silu_and_mul_per_block_quant_kernel( } // namespace vllm -void silu_and_mul_per_block_quant(torch::Tensor& out, - torch::Tensor const& input, - torch::Tensor& scales, int64_t group_size, - std::optional scale_ub, +void silu_and_mul_per_block_quant(torch::stable::Tensor& out, + torch::stable::Tensor const& input, + torch::stable::Tensor& scales, + int64_t group_size, + std::optional scale_ub, bool is_scale_transposed) { - static c10::ScalarType kFp8Type = is_fp8_ocp() - ? c10::ScalarType::Float8_e4m3fn - : c10::ScalarType::Float8_e4m3fnuz; - - TORCH_CHECK(out.dtype() == kFp8Type || out.dtype() == torch::kInt8); - TORCH_CHECK(out.is_contiguous() && input.is_contiguous()); - TORCH_CHECK( - input.dtype() == torch::kFloat16 || input.dtype() == torch::kBFloat16, + static torch::headeronly::ScalarType kFp8Type = + is_fp8_ocp() ? torch::headeronly::ScalarType::Float8_e4m3fn + : torch::headeronly::ScalarType::Float8_e4m3fnuz; + + STD_TORCH_CHECK(out.scalar_type() == kFp8Type || + out.scalar_type() == torch::headeronly::ScalarType::Char); + STD_TORCH_CHECK(out.is_contiguous() && input.is_contiguous()); + STD_TORCH_CHECK( + input.scalar_type() == torch::headeronly::ScalarType::Half || + input.scalar_type() == torch::headeronly::ScalarType::BFloat16, "Input must be FP16 or BF16"); - TORCH_CHECK(scales.dtype() == torch::kFloat32, "Scales must be FP32"); - TORCH_CHECK(group_size == 128 || group_size == 64, - "Unsupported group size: ", group_size); + STD_TORCH_CHECK(scales.scalar_type() == torch::headeronly::ScalarType::Float); + STD_TORCH_CHECK(group_size == 128 || group_size == 64, + "Unsupported group size: ", group_size); if (scale_ub.has_value()) { - TORCH_CHECK(out.dtype() == kFp8Type); + STD_TORCH_CHECK(out.scalar_type() == kFp8Type); } int32_t hidden_size = out.size(-1); auto num_tokens = input.size(0); int32_t num_groups = hidden_size / group_size; - TORCH_CHECK(input.size(-1) == hidden_size * 2, - "input last dim must be 2x output hidden_size"); - TORCH_CHECK(hidden_size % group_size == 0, - "hidden_size must be divisible by group_size"); + STD_TORCH_CHECK(input.size(-1) == hidden_size * 2, + "input last dim must be 2x output hidden_size"); + STD_TORCH_CHECK(hidden_size % group_size == 0, + "hidden_size must be divisible by group_size"); - const at::cuda::OptionalCUDAGuard device_guard(device_of(input)); - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); + const torch::stable::accelerator::DeviceGuard device_guard( + input.get_device_index()); + const cudaStream_t stream = get_current_cuda_stream(input.get_device_index()); dim3 grid(num_tokens, num_groups); dim3 block(group_size); - VLLM_DISPATCH_FLOATING_TYPES( + VLLM_STABLE_DISPATCH_FLOATING_TYPES( input.scalar_type(), "silu_and_mul_per_block_quant", [&] { using scalar_in_t = scalar_t; - VLLM_DISPATCH_QUANT_TYPES( + VLLM_STABLE_DISPATCH_QUANT_TYPES( out.scalar_type(), "silu_and_mul_per_block_quant", [&] { using scalar_out_t = scalar_t; - VLLM_DISPATCH_GROUP_SIZE(group_size, gs, [&] { - VLLM_DISPATCH_BOOL(is_scale_transposed, transpose_scale, [&] { - vllm::silu_and_mul_per_block_quant_kernel< - scalar_in_t, scalar_out_t, transpose_scale, gs> - <<>>( - out.data_ptr(), - scales.data_ptr(), - input.data_ptr(), - scale_ub.has_value() ? scale_ub->data_ptr() - : nullptr, - hidden_size); - }); + VLLM_STABLE_DISPATCH_GROUP_SIZE(group_size, gs, [&] { + VLLM_STABLE_DISPATCH_BOOL( + is_scale_transposed, transpose_scale, [&] { + vllm::silu_and_mul_per_block_quant_kernel< + scalar_in_t, scalar_out_t, transpose_scale, gs> + <<>>( + out.mutable_data_ptr(), + scales.mutable_data_ptr(), + input.const_data_ptr(), + scale_ub.has_value() + ? scale_ub->const_data_ptr() + : nullptr, + hidden_size); + }); }); }); }); -} \ No newline at end of file +} diff --git a/csrc/libtorch_stable/quantization/fused_kernels/layernorm_utils.cuh b/csrc/libtorch_stable/quantization/fused_kernels/layernorm_utils.cuh index 290abedcf940..0139cfbc9561 100644 --- a/csrc/libtorch_stable/quantization/fused_kernels/layernorm_utils.cuh +++ b/csrc/libtorch_stable/quantization/fused_kernels/layernorm_utils.cuh @@ -8,7 +8,7 @@ #include "quantization/utils.cuh" #include "quant_conversions.cuh" -#include "../../../cub_helpers.h" +#include "../../cub_helpers.h" #include "../../../cuda_compat.h" namespace vllm { diff --git a/csrc/libtorch_stable/quantization/gguf/dequantize.cuh b/csrc/libtorch_stable/quantization/gguf/dequantize.cuh deleted file mode 100644 index 9d355003ef91..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/dequantize.cuh +++ /dev/null @@ -1,571 +0,0 @@ -// copied and adapted from https://github.com/ggerganov/llama.cpp/blob/b2899/ggml-cuda/convert.cu -// Dequant functions -static __device__ __forceinline__ void dequantize_q4_0(const void * vx, const int ib, const int iqs, dfloat2 & v){ - const block_q4_0 * x = (const block_q4_0 *) vx; - - const dfloat d = x[ib].d; - - const int vui = x[ib].qs[iqs]; - - v.x = __int2half_rn(vui & 0xF); - v.y = __int2half_rn(vui >> 4); - - v = __hsub2(v, __floats2half2_rn(8.0f, 8.0f)); - v = __hmul2(v, {d, d}); -} - -static __device__ __forceinline__ void dequantize_q4_1(const void * vx, const int ib, const int iqs, dfloat2 & v){ - const block_q4_1 * x = (const block_q4_1 *) vx; - - const dfloat d = __low2half(x[ib].dm); - const dfloat m = __high2half(x[ib].dm); - - const int vui = x[ib].qs[iqs]; - - v.x = __int2half_rn(vui & 0xF); - v.y = __int2half_rn(vui >> 4); - - v = __hmul2(v, {d, d}); - v = __hadd2(v, {m, m}); -} - -static __device__ __forceinline__ void dequantize_q5_0(const void * vx, const int ib, const int iqs, dfloat2 & v){ - const block_q5_0 * x = (const block_q5_0 *) vx; - - const dfloat d = x[ib].d; - - uint32_t qh; - memcpy(&qh, x[ib].qh, sizeof(qh)); - - const int xh_0 = ((qh >> (iqs + 0)) << 4) & 0x10; - const int xh_1 = ((qh >> (iqs + 12)) ) & 0x10; - - v.x = __int2half_rn((x[ib].qs[iqs] & 0xf) | xh_0); - v.y = __int2half_rn((x[ib].qs[iqs] >> 4) | xh_1); - - v = __hsub2(v, __floats2half2_rn(16.0f, 16.0f)); - v = __hmul2(v, {d, d}); -} - -static __device__ __forceinline__ void dequantize_q5_1(const void * vx, const int ib, const int iqs, dfloat2 & v){ - const block_q5_1 * x = (const block_q5_1 *) vx; - - const dfloat d = __low2half(x[ib].dm); - const dfloat m = __high2half(x[ib].dm); - - uint32_t qh; - memcpy(&qh, x[ib].qh, sizeof(qh)); - - const int xh_0 = ((qh >> (iqs + 0)) << 4) & 0x10; - const int xh_1 = ((qh >> (iqs + 12)) ) & 0x10; - - v.x = __int2half_rn((x[ib].qs[iqs] & 0xf) | xh_0); - v.y = __int2half_rn((x[ib].qs[iqs] >> 4) | xh_1); - - v = __hmul2(v, {d, d}); - v = __hadd2(v, {m, m}); -} - -static __device__ __forceinline__ void dequantize_q8_0(const void * vx, const int ib, const int iqs, dfloat2 & v){ - const block_q8_0 * x = (const block_q8_0 *) vx; - - const dfloat d = x[ib].d; - - v.x = __int2half_rn(x[ib].qs[iqs + 0]); - v.y = __int2half_rn(x[ib].qs[iqs + 1]); - - v = __hmul2(v, {d, d}); -} - -template -static __global__ void dequantize_block(const void * __restrict__ vx, dst_t * __restrict__ y, const int k) { - const int i = 2*(blockDim.x*blockIdx.x + threadIdx.x); - - if (i >= k) { - return; - } - - const int ib = i/qk; // block index - const int iqs = (i%qk)/qr; // quant index - const int iybs = i - i%qk; // y block start index - const int y_offset = qr == 1 ? 1 : qk/2; - - // dequantize - dfloat2 v; - dequantize_kernel(vx, ib, iqs, v); - - y[iybs + iqs + 0] = convert_from_half(v.x); - y[iybs + iqs + y_offset] = convert_from_half(v.y); -} - -template -static __global__ void dequantize_block_q2_K(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_q2_K * x = (const block_q2_K *) vx; - - const auto tid = threadIdx.x; - const int n = tid/32; - const int l = tid - 32*n; - const int is = 8*n + l/16; - - const uint8_t q = x[i].qs[32*n + l]; - dst_t * y = yy + i*QK_K + 128*n; - - half dall = __low2half(x[i].dm); - half dmin = __high2half(x[i].dm); - y[l+ 0] = convert_from_half(__hsub(__hmul(dall, __int2half_rn((x[i].scales[is+0] & 0xF) * ((q >> 0) & 3))), __hmul(dmin, __int2half_rn(x[i].scales[is+0] >> 4)))); - y[l+32] = convert_from_half(__hsub(__hmul(dall, __int2half_rn((x[i].scales[is+2] & 0xF) * ((q >> 2) & 3))), __hmul(dmin, __int2half_rn(x[i].scales[is+2] >> 4)))); - y[l+64] = convert_from_half(__hsub(__hmul(dall, __int2half_rn((x[i].scales[is+4] & 0xF) * ((q >> 4) & 3))), __hmul(dmin, __int2half_rn(x[i].scales[is+4] >> 4)))); - y[l+96] = convert_from_half(__hsub(__hmul(dall, __int2half_rn((x[i].scales[is+6] & 0xF) * ((q >> 6) & 3))), __hmul(dmin, __int2half_rn(x[i].scales[is+6] >> 4)))); -} - -template -static __global__ void dequantize_block_q3_K(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_q3_K * x = (const block_q3_K *) vx; - - const auto r = threadIdx.x/4; - const int tid = r/2; - const int is0 = r%2; - const int l0 = 16*is0 + 4*(threadIdx.x%4); - const int n = tid / 4; - const int j = tid - 4*n; - - uint8_t m = 1 << (4*n + j); - int is = 8*n + 2*j + is0; - int shift = 2*j; - - int8_t us = is < 4 ? (x[i].scales[is-0] & 0xF) | (((x[i].scales[is+8] >> 0) & 3) << 4) : - is < 8 ? (x[i].scales[is-0] & 0xF) | (((x[i].scales[is+4] >> 2) & 3) << 4) : - is < 12 ? (x[i].scales[is-8] >> 4) | (((x[i].scales[is+0] >> 4) & 3) << 4) : - (x[i].scales[is-8] >> 4) | (((x[i].scales[is-4] >> 6) & 3) << 4); - half d_all = x[i].d; - half dl = __hmul(d_all, __int2half_rn(us - 32)); - - dst_t * y = yy + i*QK_K + 128*n + 32*j; - const uint8_t * q = x[i].qs + 32*n; - const uint8_t * hm = x[i].hmask; - - for (int l = l0; l < l0+4; ++l) { - y[l] = convert_from_half(__hmul(dl, __int2half_rn((int8_t)((q[l] >> shift) & 3) - ((hm[l] & m) ? 0 : 4)))); - } -} - -static inline __device__ void get_scale_min_k4(int j, const uint8_t * q, uint8_t & d, uint8_t & m) { - if (j < 4) { - d = q[j] & 63; m = q[j + 4] & 63; - } else { - d = (q[j+4] & 0xF) | ((q[j-4] >> 6) << 4); - m = (q[j+4] >> 4) | ((q[j-0] >> 6) << 4); - } -} - -template -static __global__ void dequantize_block_q4_K(const void * __restrict__ vx, dst_t * __restrict__ yy) { - const block_q4_K * x = (const block_q4_K *) vx; - - const auto i = blockIdx.x; - - // assume 32 threads - const auto tid = threadIdx.x; - const int il = tid/8; - const int ir = tid%8; - const int is = 2*il; - const int n = 4; - - dst_t * y = yy + i*QK_K + 64*il + n*ir; - - const half dall = __low2half(x[i].dm); - const half dmin = __high2half(x[i].dm); - - const uint8_t * q = x[i].qs + 32*il + n*ir; - - uint8_t sc, m; - get_scale_min_k4(is + 0, x[i].scales, sc, m); - const half d1 = __hmul(dall, __int2half_rn(sc)); - const half m1 = __hmul(dmin, __int2half_rn(m)); - get_scale_min_k4(is + 1, x[i].scales, sc, m); - const half d2 = __hmul(dall, __int2half_rn(sc)); - const half m2 = __hmul(dmin, __int2half_rn(m)); - for (int l = 0; l < n; ++l) { - y[l + 0] = convert_from_half(__hsub(__hmul(d1, __int2half_rn(q[l] & 0xF)), m1)); - y[l +32] = convert_from_half(__hsub(__hmul(d2, __int2half_rn(q[l] >> 4)), m2)); - } -} - -template -static __global__ void dequantize_block_q5_K(const void * __restrict__ vx, dst_t * __restrict__ yy) { - const block_q5_K * x = (const block_q5_K *) vx; - - const auto i = blockIdx.x; - - // assume 64 threads - this is very slightly better than the one below - const auto tid = threadIdx.x; - const int il = tid/16; // il is in 0...3 - const int ir = tid%16; // ir is in 0...15 - const int is = 2*il; // is is in 0...6 - - dst_t * y = yy + i*QK_K + 64*il + 2*ir; - - const half dall = __low2half(x[i].dm); - const half dmin = __high2half(x[i].dm); - - const uint8_t * ql = x[i].qs + 32*il + 2*ir; - const uint8_t * qh = x[i].qh + 2*ir; - - uint8_t sc, m; - get_scale_min_k4(is + 0, x[i].scales, sc, m); - const half d1 = __hmul(dall, __int2half_rn(sc)); const half m1 = __hmul(dmin, __int2half_rn(m)); - get_scale_min_k4(is + 1, x[i].scales, sc, m); - const half d2 = __hmul(dall, __int2half_rn(sc)); const half m2 = __hmul(dmin, __int2half_rn(m)); - - uint8_t hm = 1 << (2*il); - y[ 0] = convert_from_half(__hsub(__hmul(d1, __int2half_rn((ql[0] & 0xF) + (qh[0] & hm ? 16 : 0))), m1)); - y[ 1] = convert_from_half(__hsub(__hmul(d1, __int2half_rn((ql[1] & 0xF) + (qh[1] & hm ? 16 : 0))), m1)); - hm <<= 1; - y[32] = convert_from_half(__hsub(__hmul(d2, __int2half_rn((ql[0] >> 4) + (qh[0] & hm ? 16 : 0))), m2)); - y[33] = convert_from_half(__hsub(__hmul(d2, __int2half_rn((ql[1] >> 4) + (qh[1] & hm ? 16 : 0))), m2)); -} - -template -static __global__ void dequantize_block_q6_K(const void * __restrict__ vx, dst_t * __restrict__ yy) { - const block_q6_K * x = (const block_q6_K *) vx; - - const auto i = blockIdx.x; - - // assume 64 threads - this is very slightly better than the one below - const auto tid = threadIdx.x; - const int ip = tid/32; // ip is 0 or 1 - const int il = tid - 32*ip; // 0...32 - const int is = 8*ip + il/16; - - dst_t * y = yy + i*QK_K + 128*ip + il; - - const half d = x[i].d; - - const uint8_t * ql = x[i].ql + 64*ip + il; - const uint8_t qh = x[i].qh[32*ip + il]; - const int8_t * sc = x[i].scales + is; - - y[ 0] = convert_from_half(__hmul(d, __int2half_rn(sc[0] * ((int8_t)((ql[ 0] & 0xF) | (((qh >> 0) & 3) << 4)) - 32)))); - y[32] = convert_from_half(__hmul(d, __int2half_rn(sc[2] * ((int8_t)((ql[32] & 0xF) | (((qh >> 2) & 3) << 4)) - 32)))); - y[64] = convert_from_half(__hmul(d, __int2half_rn(sc[4] * ((int8_t)((ql[ 0] >> 4) | (((qh >> 4) & 3) << 4)) - 32)))); - y[96] = convert_from_half(__hmul(d, __int2half_rn(sc[6] * ((int8_t)((ql[32] >> 4) | (((qh >> 6) & 3) << 4)) - 32)))); -} - -template -static __global__ void dequantize_block_iq2_xxs(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_iq2_xxs * x = (const block_iq2_xxs *) vx; - - const auto tid = threadIdx.x; - const int il = tid/8; // 0...3 - const int ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 8*il; - const uint16_t * q2 = x[i].qs + 4*ib; - const uint8_t * aux8 = (const uint8_t *)q2; - const uint8_t * grid = (const uint8_t *)(iq2xxs_grid + aux8[il]); - const uint32_t aux32 = q2[2] | (q2[3] << 16); - const float d = __half2float(x[i].d) * (0.5f + (aux32 >> 28)) * 0.25f; - const uint8_t signs = ksigns_iq2xs[(aux32 >> 7*il) & 127]; - for (int j = 0; j < 8; ++j) y[j] = d * grid[j] * (signs & kmask_iq2xs[j] ? -1.f : 1.f); -} - -template -static __global__ void dequantize_block_iq2_xs(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_iq2_xs * x = (const block_iq2_xs *) vx; - - const auto tid = threadIdx.x; - const int il = tid/8; // 0...3 - const int ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 8*il; - const uint16_t * q2 = x[i].qs + 4*ib; - const uint8_t * grid = (const uint8_t *)(iq2xs_grid + (q2[il] & 511)); - const float d = __half2float(x[i].d) * (0.5f + ((x[i].scales[ib] >> 4*(il/2)) & 0xf)) * 0.25f; - const uint8_t signs = ksigns_iq2xs[q2[il] >> 9]; - for (int j = 0; j < 8; ++j) y[j] = d * grid[j] * (signs & kmask_iq2xs[j] ? -1.f : 1.f); - -} - -template -static __global__ void dequantize_block_iq2_s(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_iq2_s * x = (const block_iq2_s *) vx; - - const auto tid = threadIdx.x; - const int il = tid/8; // 0...3 - const int ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 8*il; - const uint8_t * grid = (const uint8_t *)(iq2s_grid + (x[i].qs[4*ib+il] | ((x[i].qh[ib] << (8-2*il)) & 0x300))); - const float d = __half2float(x[i].d) * (0.5f + ((x[i].scales[ib] >> 4*(il/2)) & 0xf)) * 0.25f; - const uint8_t signs = x[i].qs[QK_K/8+4*ib+il]; - for (int j = 0; j < 8; ++j) y[j] = d * grid[j] * (signs & kmask_iq2xs[j] ? -1.f : 1.f); -} - -template -static __global__ void dequantize_block_iq3_xxs(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_iq3_xxs * x = (const block_iq3_xxs *) vx; - - const auto tid = threadIdx.x; - const int il = tid/8; // 0...3 - const int ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 8*il; - const uint8_t * q3 = x[i].qs + 8*ib; - const uint16_t * gas = (const uint16_t *)(x[i].qs + QK_K/4) + 2*ib; - const uint8_t * grid1 = (const uint8_t *)(iq3xxs_grid + q3[2*il+0]); - const uint8_t * grid2 = (const uint8_t *)(iq3xxs_grid + q3[2*il+1]); - const uint32_t aux32 = gas[0] | (gas[1] << 16); - const float d = __half2float(x[i].d) * (0.5f + (aux32 >> 28)) * 0.5f; - const uint8_t signs = ksigns_iq2xs[(aux32 >> 7*il) & 127]; - for (int j = 0; j < 4; ++j) { - y[j+0] = d * grid1[j] * (signs & kmask_iq2xs[j+0] ? -1.f : 1.f); - y[j+4] = d * grid2[j] * (signs & kmask_iq2xs[j+4] ? -1.f : 1.f); - } -} - -template -static __global__ void dequantize_block_iq3_s(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_iq3_s * x = (const block_iq3_s *) vx; - - const auto tid = threadIdx.x; - const int il = tid/8; // 0...3 - const int ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 8*il; - const uint8_t * qs = x[i].qs + 8*ib; - const uint8_t * grid1 = (const uint8_t *)(iq3xs_grid + (qs[2*il+0] | ((x[i].qh[ib] << (8-2*il)) & 256))); - const uint8_t * grid2 = (const uint8_t *)(iq3xs_grid + (qs[2*il+1] | ((x[i].qh[ib] << (7-2*il)) & 256))); - const float d = __half2float(x[i].d) * (0.5f + ((x[i].scales[ib/2] >> 4*(ib%2)) & 0xf)) * 0.5f; - const uint8_t signs = x[i].signs[4*ib + il]; - for (int j = 0; j < 4; ++j) { - y[j+0] = d * grid1[j] * (signs & kmask_iq2xs[j+0] ? -1.f : 1.f); - y[j+4] = d * grid2[j] * (signs & kmask_iq2xs[j+4] ? -1.f : 1.f); - } -} - -template -static __global__ void dequantize_block_iq1_s(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const int64_t i = blockIdx.x; - const block_iq1_s * x = (const block_iq1_s *) vx; - - const int64_t tid = threadIdx.x; - const int64_t il = tid/8; // 0...3 - const int64_t ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 8*il; - const float delta = x[i].qh[ib] & 0x8000 ? -1 - IQ1S_DELTA : -1 + IQ1S_DELTA; - const float d = __half2float(x[i].d) * (2*((x[i].qh[ib] >> 12) & 7) + 1); - uint32_t grid32[2]; const int8_t * q = (const int8_t *)grid32; - grid32[0] = iq1s_grid_gpu[x[i].qs[4*ib+il] | (((x[i].qh[ib] >> 3*il) & 7) << 8)]; - grid32[1] = (grid32[0] >> 4) & 0x0f0f0f0f; - grid32[0] &= 0x0f0f0f0f; - for (int j = 0; j < 8; ++j) { - y[j] = d * (q[j] + delta); - } -} - -template -static __global__ void dequantize_block_iq1_m(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const int64_t i = blockIdx.x; - const block_iq1_m * x = (const block_iq1_m *) vx; - - const int64_t tid = threadIdx.x; - const int64_t il = tid/8; // 0...3 - const int64_t ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 8*il; - const uint16_t * sc = (const uint16_t *)x[i].scales; - iq1m_scale_t scale; - scale.u16 = (sc[0] >> 12) | ((sc[1] >> 8) & 0x00f0) | ((sc[2] >> 4) & 0x0f00) | (sc[3] & 0xf000); - const int64_t ib16 = 2*ib + il/2; // sc[ib16/4] >> 3*(ib16%4) -> sc[ib/2] >> 3*((2*ib+il/2)%4); - const float d = __half2float(scale.f16) * (2*((sc[ib16/4] >> 3*(ib16%4)) & 0x7) + 1); - const float delta = x[i].qh[2*ib+il/2] & (0x08 << 4*(il%2)) ? -1 - IQ1M_DELTA : -1 + IQ1M_DELTA; - uint32_t grid32[2]; const int8_t * q = (const int8_t *)grid32; - grid32[0] = iq1s_grid_gpu[x[i].qs[4*ib+il] | (((x[i].qh[2*ib+il/2] >> 4*(il%2)) & 7) << 8)]; - grid32[1] = (grid32[0] >> 4) & 0x0f0f0f0f; - grid32[0] &= 0x0f0f0f0f; - for (int j = 0; j < 8; ++j) { - y[j] = d * (q[j] + delta); - } -} - -template -static __global__ void dequantize_block_iq4_nl(const void * __restrict__ vx, dst_t * __restrict__ yy) { - - const auto i = blockIdx.x; - const block_iq4_nl * x = (const block_iq4_nl *) vx + i*(QK_K/QK4_NL); - - const auto tid = threadIdx.x; - const int il = tid/8; // 0...3 - const int ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 4*il; - const uint8_t * q4 = x[ib].qs + 4*il; - const float d = __half2float(x[ib].d); - for (int j = 0; j < 4; ++j) { - y[j+ 0] = d * kvalues_iq4nl[q4[j] & 0xf]; - y[j+16] = d * kvalues_iq4nl[q4[j] >> 4]; - } - -} - -template -static __global__ void dequantize_block_iq4_xs(const void * __restrict__ vx, dst_t * __restrict__ yy) { - const auto i = blockIdx.x; - const block_iq4_xs * x = (const block_iq4_xs *)vx; - - const auto tid = threadIdx.x; - const int il = tid/8; // 0...3 - const int ib = tid%8; // 0...7 - dst_t * y = yy + i*QK_K + 32*ib + 4*il; - const uint8_t * q4 = x[i].qs + 16*ib + 4*il; - const float d = __half2float(x[i].d) * ((((x[i].scales_l[ib/2] >> 4*(ib%2)) & 0xf) | (((x[i].scales_h >> 2*ib) & 3) << 4)) - 32); - for (int j = 0; j < 4; ++j) { - y[j+ 0] = d * kvalues_iq4nl[q4[j] & 0xf]; - y[j+16] = d * kvalues_iq4nl[q4[j] >> 4]; - } -} - -template -static void dequantize_block_cuda(const void * __restrict__ vx, dst_t * __restrict__ y, const int k, cudaStream_t stream) { - const int num_blocks = (k + 2*CUDA_DEQUANTIZE_BLOCK_SIZE - 1) / (2*CUDA_DEQUANTIZE_BLOCK_SIZE); - dequantize_block<<>>(vx, y, k); -} - -template -static void dequantize_row_q2_K_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_q2_K<<>>(vx, y); -} - -template -static void dequantize_row_q3_K_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_q3_K<<>>(vx, y); -} - -template -static void dequantize_row_q4_K_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_q4_K<<>>(vx, y); -} - -template -static void dequantize_row_q5_K_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_q5_K<<>>(vx, y); -} - -template -static void dequantize_row_q6_K_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_q6_K<<>>(vx, y); -} - -template -static void dequantize_row_iq2_xxs_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_iq2_xxs<<>>(vx, y); -} - -template -static void dequantize_row_iq2_xs_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_iq2_xs<<>>(vx, y); -} - -template -static void dequantize_row_iq2_s_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_iq2_s<<>>(vx, y); -} - -template -static void dequantize_row_iq3_xxs_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_iq3_xxs<<>>(vx, y); -} - -template -static void dequantize_row_iq3_s_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_iq3_s<<>>(vx, y); -} - -template -static void dequantize_row_iq1_s_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_iq1_s<<>>(vx, y); -} - -template -static void dequantize_row_iq1_m_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = k / QK_K; - dequantize_block_iq1_m<<>>(vx, y); -} - -template -static void dequantize_row_iq4_nl_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = (k + QK_K - 1) / QK_K; - dequantize_block_iq4_nl<<>>(vx, y); -} - -template -static void dequantize_row_iq4_xs_cuda(const void * vx, dst_t * y, const int k, cudaStream_t stream) { - const int nb = (k + QK_K - 1) / QK_K; - dequantize_block_iq4_xs<<>>(vx, y); -} - -template -static to_cuda_ggml_t ggml_get_to_cuda(int64_t type) { - switch (type) { - case 2: - return dequantize_block_cuda; - case 3: - return dequantize_block_cuda; - case 6: - return dequantize_block_cuda; - case 7: - return dequantize_block_cuda; - case 8: - return dequantize_block_cuda; - case 10: - return dequantize_row_q2_K_cuda; - case 11: - return dequantize_row_q3_K_cuda; - case 12: - return dequantize_row_q4_K_cuda; - case 13: - return dequantize_row_q5_K_cuda; - case 14: - return dequantize_row_q6_K_cuda; - case 16: - return dequantize_row_iq2_xxs_cuda; - case 17: - return dequantize_row_iq2_xs_cuda; - case 18: - return dequantize_row_iq3_xxs_cuda; - case 19: - return dequantize_row_iq1_s_cuda; - case 20: - return dequantize_row_iq4_nl_cuda; - case 21: - return dequantize_row_iq3_s_cuda; - case 22: - return dequantize_row_iq2_s_cuda; - case 23: - return dequantize_row_iq4_xs_cuda; - case 29: - return dequantize_row_iq1_m_cuda; - default: - return nullptr; - } -} diff --git a/csrc/libtorch_stable/quantization/gguf/ggml-common.h b/csrc/libtorch_stable/quantization/gguf/ggml-common.h deleted file mode 100644 index 6bef5db3ccf1..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/ggml-common.h +++ /dev/null @@ -1,1150 +0,0 @@ -// copied from https://github.com/ggerganov/llama.cpp/blob/b2899/ggml-common.h -#define QK_K 256 -#define K_QUANTS_PER_ITERATION 2 -#define WARP_SIZE_GGUF 32 -#define K_SCALE_SIZE 12 -#define CUDA_DEQUANTIZE_BLOCK_SIZE 256 -#define CUDA_QUANTIZE_BLOCK_SIZE 256 -#define GGML_CUDA_DMMV_X 32 -#define GGML_CUDA_MMV_Y 1 - - -// Data Structures -// QK = number of values after dequantization -// QR = QK / number of values before dequantization -// QI = number of 32 bit integers before dequantization - -#define QK4_0 32 -#define QR4_0 2 -#define QI4_0 (QK4_0 / (4 * QR4_0)) -typedef struct { - half d; // delta - uint8_t qs[QK4_0 / 2]; // nibbles / quants -} block_q4_0; - -#define QK4_1 32 -#define QR4_1 2 -#define QI4_1 (QK4_1 / (4 * QR4_1)) -typedef struct { - half2 dm; // dm.x = delta, dm.y = min - uint8_t qs[QK4_1 / 2]; // nibbles / quants -} block_q4_1; - -#define QK5_0 32 -#define QR5_0 2 -#define QI5_0 (QK5_0 / (4 * QR5_0)) -typedef struct { - half d; // delta - uint8_t qh[4]; // 5-th bit of quants - uint8_t qs[QK5_0 / 2]; // nibbles / quants -} block_q5_0; - -#define QK5_1 32 -#define QR5_1 2 -#define QI5_1 (QK5_1 / (4 * QR5_1)) -typedef struct { - half2 dm; // dm.x = delta, dm.y = min - uint8_t qh[4]; // 5-th bit of quants - uint8_t qs[QK5_1 / 2]; // nibbles / quants -} block_q5_1; - -#define QK8_0 32 -#define QR8_0 1 -#define QI8_0 (QK8_0 / (4 * QR8_0)) -typedef struct { - half d; // delta - int8_t qs[QK8_0]; // quants -} block_q8_0; - -#define QK8_1 32 -#define QR8_1 1 -#define QI8_1 (QK8_1 / (4 * QR8_1)) -typedef struct { - half2 ds; // ds.x = delta, ds.y = sum - int8_t qs[QK8_0]; // quants -} block_q8_1; - -#define QR2_K 4 -#define QI2_K (QK_K / (4*QR2_K)) -typedef struct { - uint8_t scales[QK_K/16]; // scales and mins, quantized with 4 bits - uint8_t qs[QK_K/4]; // quants - half2 dm; // super-block scale for quantized scales/mins -} block_q2_K; - -#define QR3_K 4 -#define QI3_K (QK_K / (4*QR3_K)) -typedef struct { - uint8_t hmask[QK_K/8]; // quants - high bit - uint8_t qs[QK_K/4]; // quants - low 2 bits - uint8_t scales[K_SCALE_SIZE]; // scales, quantized with 6 bits - half d; // super-block scale -} block_q3_K; - -#define QR4_K 2 -#define QI4_K (QK_K / (4*QR4_K)) -typedef struct { - half2 dm; // super-block scale for quantized scales/mins - uint8_t scales[3*QK_K/64]; // scales, quantized with 6 bits - uint8_t qs[QK_K/2]; // 4--bit quants -} block_q4_K; - -#define QR5_K 2 -#define QI5_K (QK_K / (4*QR5_K)) -typedef struct { - half2 dm; // super-block scale for quantized scales/mins - uint8_t scales[K_SCALE_SIZE]; // scales and mins, quantized with 6 bits - uint8_t qh[QK_K/8]; // quants, high bit - uint8_t qs[QK_K/2]; // quants, low 4 bits -} block_q5_K; - -#define QR6_K 2 -#define QI6_K (QK_K / (4*QR6_K)) -typedef struct { - uint8_t ql[QK_K/2]; // quants, lower 4 bits - uint8_t qh[QK_K/4]; // quants, upper 2 bits - int8_t scales[QK_K/16]; // scales - half d; // delta -} block_q6_K; - -#define QR2_XXS 8 -#define QI2_XXS (QK_K / (4*QR2_XXS)) -typedef struct { - half d; - uint16_t qs[QK_K/8]; -} block_iq2_xxs; - -#define QR2_XS 8 -#define QI2_XS (QK_K / (4*QR2_XS)) -typedef struct { - half d; - uint16_t qs[QK_K/8]; - uint8_t scales[QK_K/32]; -} block_iq2_xs; - -#define QR2_S 8 -#define QI2_S (QK_K / (4*QR2_S)) -typedef struct { - half d; - uint8_t qs[QK_K/4]; - uint8_t qh[QK_K/32]; - uint8_t scales[QK_K/32]; -} block_iq2_s; - -#define QR3_XXS 8 -#define QI3_XXS (QK_K / (4*QR3_XXS)) -typedef struct { - half d; - uint8_t qs[3*(QK_K/8)]; -} block_iq3_xxs; - -#define QR3_XS 8 -#define QI3_XS (QK_K / (4*QR3_XS)) -#define IQ3S_N_SCALE QK_K/64 -typedef struct { - half d; - uint8_t qs[QK_K/4]; - uint8_t qh[QK_K/32]; - uint8_t signs[QK_K/8]; - uint8_t scales[IQ3S_N_SCALE]; -} block_iq3_s; - -// 1.5625 bpw -#define QR1_S 8 -#define QI1_S (QK_K / (4*QR1_S)) -typedef struct { - half d; - uint8_t qs[QK_K/8]; - uint16_t qh[QK_K/32]; -} block_iq1_s; - -// 1.75 bpw -#define QR1_M 8 -#define QI1_M (QK_K / (4*QR1_M)) -typedef struct { - uint8_t qs[QK_K/8]; // grid index, low 8 bits - uint8_t qh[QK_K/16]; // grid index, high 3 bits + grid shift bit (for two groups of 8) - uint8_t scales[QK_K/32]; // 3-bit block scales (4-bit if QK_K == 64) -} block_iq1_m; - -// Used by IQ1_M quants -typedef union { - half f16; - uint16_t u16; -} iq1m_scale_t; - -#define QK4_NL 32 -#define QR4_NL 2 -#define QI4_NL (QK4_NL / (4*QR4_NL)) -typedef struct { - half d; - uint8_t qs[QK4_NL/2]; -} block_iq4_nl; - -#define QR4_XS 8 -#define QI4_XS (QK_K / (4*QR4_XS)) -typedef struct { - half d; - uint16_t scales_h; - uint8_t scales_l[QK_K/64]; - uint8_t qs[QK_K/2]; -} block_iq4_xs; - -static const __device__ uint64_t iq2xxs_grid[256] = { - 0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, - 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x08080808082b0808, - 0x08080808082b082b, 0x08080808082b2b08, 0x08080808082b2b2b, 0x0808080819080819, - 0x0808080819081908, 0x0808080819190808, 0x0808080819192b08, 0x08080808192b0819, - 0x08080808192b1908, 0x080808082b080808, 0x080808082b08082b, 0x080808082b082b2b, - 0x080808082b2b082b, 0x0808081908080819, 0x0808081908081908, 0x0808081908190808, - 0x0808081908191919, 0x0808081919080808, 0x080808192b081908, 0x080808192b192b08, - 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b082b082b, 0x0808082b2b08082b, - 0x0808190808080819, 0x0808190808081908, 0x0808190808190808, 0x08081908082b0819, - 0x08081908082b1908, 0x0808190819080808, 0x080819081908082b, 0x0808190819082b08, - 0x08081908192b0808, 0x080819082b080819, 0x080819082b081908, 0x080819082b190808, - 0x080819082b2b1908, 0x0808191908080808, 0x080819190808082b, 0x0808191908082b08, - 0x08081919082b0808, 0x080819191908192b, 0x08081919192b2b19, 0x080819192b080808, - 0x080819192b190819, 0x0808192b08082b19, 0x0808192b08190808, 0x0808192b19080808, - 0x0808192b2b081908, 0x0808192b2b2b1908, 0x08082b0808080808, 0x08082b0808081919, - 0x08082b0808082b08, 0x08082b0808191908, 0x08082b08082b2b08, 0x08082b0819080819, - 0x08082b0819081908, 0x08082b0819190808, 0x08082b081919082b, 0x08082b082b082b08, - 0x08082b1908081908, 0x08082b1919080808, 0x08082b2b0808082b, 0x08082b2b08191908, - 0x0819080808080819, 0x0819080808081908, 0x0819080808190808, 0x08190808082b0819, - 0x0819080819080808, 0x08190808192b0808, 0x081908082b081908, 0x081908082b190808, - 0x081908082b191919, 0x0819081908080808, 0x0819081908082b08, 0x08190819082b0808, - 0x0819081919190808, 0x0819081919192b2b, 0x081908192b080808, 0x0819082b082b1908, - 0x0819082b19081919, 0x0819190808080808, 0x0819190808082b08, 0x08191908082b0808, - 0x08191908082b1919, 0x0819190819082b19, 0x081919082b080808, 0x0819191908192b08, - 0x08191919192b082b, 0x0819192b08080808, 0x0819192b0819192b, 0x08192b0808080819, - 0x08192b0808081908, 0x08192b0808190808, 0x08192b0819080808, 0x08192b082b080819, - 0x08192b1908080808, 0x08192b1908081919, 0x08192b192b2b0808, 0x08192b2b19190819, - 0x082b080808080808, 0x082b08080808082b, 0x082b080808082b2b, 0x082b080819081908, - 0x082b0808192b0819, 0x082b08082b080808, 0x082b08082b08082b, 0x082b0819082b2b19, - 0x082b081919082b08, 0x082b082b08080808, 0x082b082b0808082b, 0x082b190808080819, - 0x082b190808081908, 0x082b190808190808, 0x082b190819080808, 0x082b19081919192b, - 0x082b191908080808, 0x082b191919080819, 0x082b1919192b1908, 0x082b192b2b190808, - 0x082b2b0808082b08, 0x082b2b08082b0808, 0x082b2b082b191908, 0x082b2b2b19081908, - 0x1908080808080819, 0x1908080808081908, 0x1908080808190808, 0x1908080808192b08, - 0x19080808082b0819, 0x19080808082b1908, 0x1908080819080808, 0x1908080819082b08, - 0x190808081919192b, 0x19080808192b0808, 0x190808082b080819, 0x190808082b081908, - 0x190808082b190808, 0x1908081908080808, 0x19080819082b0808, 0x19080819192b0819, - 0x190808192b080808, 0x190808192b081919, 0x1908082b08080819, 0x1908082b08190808, - 0x1908082b19082b08, 0x1908082b1919192b, 0x1908082b192b2b08, 0x1908190808080808, - 0x1908190808082b08, 0x19081908082b0808, 0x190819082b080808, 0x190819082b192b19, - 0x190819190819082b, 0x19081919082b1908, 0x1908192b08080808, 0x19082b0808080819, - 0x19082b0808081908, 0x19082b0808190808, 0x19082b0819080808, 0x19082b0819081919, - 0x19082b1908080808, 0x19082b1919192b08, 0x19082b19192b0819, 0x19082b192b08082b, - 0x19082b2b19081919, 0x19082b2b2b190808, 0x1919080808080808, 0x1919080808082b08, - 0x1919080808190819, 0x1919080808192b19, 0x19190808082b0808, 0x191908082b080808, - 0x191908082b082b08, 0x1919081908081908, 0x191908191908082b, 0x191908192b2b1908, - 0x1919082b2b190819, 0x191919082b190808, 0x191919082b19082b, 0x1919191908082b2b, - 0x1919192b08080819, 0x1919192b19191908, 0x19192b0808080808, 0x19192b0808190819, - 0x19192b0808192b19, 0x19192b08192b1908, 0x19192b1919080808, 0x19192b2b08082b08, - 0x192b080808081908, 0x192b080808190808, 0x192b080819080808, 0x192b0808192b2b08, - 0x192b081908080808, 0x192b081919191919, 0x192b082b08192b08, 0x192b082b192b0808, - 0x192b190808080808, 0x192b190808081919, 0x192b191908190808, 0x192b19190819082b, - 0x192b19192b081908, 0x192b2b081908082b, 0x2b08080808080808, 0x2b0808080808082b, - 0x2b08080808082b2b, 0x2b08080819080819, 0x2b0808082b08082b, 0x2b08081908081908, - 0x2b08081908192b08, 0x2b08081919080808, 0x2b08082b08190819, 0x2b08190808080819, - 0x2b08190808081908, 0x2b08190808190808, 0x2b08190808191919, 0x2b08190819080808, - 0x2b081908192b0808, 0x2b08191908080808, 0x2b0819191908192b, 0x2b0819192b191908, - 0x2b08192b08082b19, 0x2b08192b19080808, 0x2b08192b192b0808, 0x2b082b080808082b, - 0x2b082b1908081908, 0x2b082b2b08190819, 0x2b19080808081908, 0x2b19080808190808, - 0x2b190808082b1908, 0x2b19080819080808, 0x2b1908082b2b0819, 0x2b1908190819192b, - 0x2b1908192b080808, 0x2b19082b19081919, 0x2b19190808080808, 0x2b191908082b082b, - 0x2b19190819081908, 0x2b19191919190819, 0x2b192b082b080819, 0x2b192b19082b0808, - 0x2b2b08080808082b, 0x2b2b080819190808, 0x2b2b08082b081919, 0x2b2b081908082b19, - 0x2b2b082b08080808, 0x2b2b190808192b08, 0x2b2b2b0819190808, 0x2b2b2b1908081908, -}; - -static const __device__ uint64_t iq2xs_grid[512] = { - 0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, - 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x080808080819192b, - 0x0808080808192b19, 0x08080808082b0808, 0x08080808082b082b, 0x08080808082b1919, - 0x08080808082b2b08, 0x0808080819080819, 0x0808080819081908, 0x080808081908192b, - 0x0808080819082b19, 0x0808080819190808, 0x080808081919082b, 0x0808080819191919, - 0x0808080819192b08, 0x08080808192b0819, 0x08080808192b1908, 0x080808082b080808, - 0x080808082b08082b, 0x080808082b081919, 0x080808082b082b08, 0x080808082b190819, - 0x080808082b191908, 0x080808082b192b19, 0x080808082b2b0808, 0x0808081908080819, - 0x0808081908081908, 0x080808190808192b, 0x0808081908082b19, 0x0808081908190808, - 0x080808190819082b, 0x0808081908191919, 0x0808081908192b08, 0x0808081908192b2b, - 0x08080819082b0819, 0x08080819082b1908, 0x0808081919080808, 0x080808191908082b, - 0x0808081919081919, 0x0808081919082b08, 0x0808081919190819, 0x0808081919191908, - 0x08080819192b0808, 0x08080819192b2b08, 0x080808192b080819, 0x080808192b081908, - 0x080808192b190808, 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b08081919, - 0x0808082b08082b08, 0x0808082b08190819, 0x0808082b08191908, 0x0808082b082b0808, - 0x0808082b19080819, 0x0808082b19081908, 0x0808082b19190808, 0x0808082b19191919, - 0x0808082b2b080808, 0x0808082b2b082b2b, 0x0808190808080819, 0x0808190808081908, - 0x080819080808192b, 0x0808190808082b19, 0x0808190808190808, 0x080819080819082b, - 0x0808190808191919, 0x0808190808192b08, 0x08081908082b0819, 0x08081908082b1908, - 0x0808190819080808, 0x080819081908082b, 0x0808190819081919, 0x0808190819082b08, - 0x0808190819190819, 0x0808190819191908, 0x080819081919192b, 0x08081908192b0808, - 0x080819082b080819, 0x080819082b081908, 0x080819082b190808, 0x0808191908080808, - 0x080819190808082b, 0x0808191908081919, 0x0808191908082b08, 0x0808191908190819, - 0x0808191908191908, 0x08081919082b0808, 0x0808191919080819, 0x0808191919081908, - 0x0808191919190808, 0x08081919192b0819, 0x080819192b080808, 0x0808192b08080819, - 0x0808192b08081908, 0x0808192b08190808, 0x0808192b082b192b, 0x0808192b19080808, - 0x0808192b1908082b, 0x0808192b2b081908, 0x08082b0808080808, 0x08082b080808082b, - 0x08082b0808081919, 0x08082b0808082b08, 0x08082b0808082b2b, 0x08082b0808190819, - 0x08082b0808191908, 0x08082b08082b0808, 0x08082b08082b1919, 0x08082b0819080819, - 0x08082b0819081908, 0x08082b0819190808, 0x08082b0819192b08, 0x08082b082b080808, - 0x08082b082b2b0808, 0x08082b082b2b2b2b, 0x08082b1908080819, 0x08082b1908081908, - 0x08082b1908190808, 0x08082b1919080808, 0x08082b192b080819, 0x08082b192b082b19, - 0x08082b2b08080808, 0x08082b2b082b0808, 0x08082b2b082b2b08, 0x08082b2b2b19192b, - 0x08082b2b2b2b0808, 0x0819080808080819, 0x0819080808081908, 0x081908080808192b, - 0x0819080808082b19, 0x0819080808190808, 0x081908080819082b, 0x0819080808191919, - 0x0819080808192b08, 0x08190808082b0819, 0x08190808082b1908, 0x0819080819080808, - 0x081908081908082b, 0x0819080819081919, 0x0819080819082b08, 0x0819080819190819, - 0x0819080819191908, 0x08190808192b0808, 0x08190808192b2b2b, 0x081908082b080819, - 0x081908082b081908, 0x081908082b190808, 0x0819081908080808, 0x081908190808082b, - 0x0819081908081919, 0x0819081908082b08, 0x0819081908190819, 0x0819081908191908, - 0x08190819082b0808, 0x0819081919080819, 0x0819081919081908, 0x0819081919190808, - 0x081908192b080808, 0x081908192b191908, 0x081908192b19192b, 0x0819082b08080819, - 0x0819082b08081908, 0x0819082b0808192b, 0x0819082b08190808, 0x0819082b19080808, - 0x0819082b192b0808, 0x0819190808080808, 0x081919080808082b, 0x0819190808081919, - 0x0819190808082b08, 0x0819190808190819, 0x0819190808191908, 0x08191908082b0808, - 0x0819190819080819, 0x0819190819081908, 0x0819190819082b19, 0x0819190819190808, - 0x08191908192b1908, 0x081919082b080808, 0x0819191908080819, 0x0819191908081908, - 0x0819191908190808, 0x0819191919080808, 0x0819192b08080808, 0x0819192b08191908, - 0x0819192b19082b19, 0x08192b0808080819, 0x08192b0808081908, 0x08192b0808190808, - 0x08192b080819082b, 0x08192b0819080808, 0x08192b0819191908, 0x08192b082b08192b, - 0x08192b1908080808, 0x08192b1908081919, 0x08192b19192b192b, 0x08192b2b19190819, - 0x08192b2b2b2b2b19, 0x082b080808080808, 0x082b08080808082b, 0x082b080808081919, - 0x082b080808082b08, 0x082b080808082b2b, 0x082b080808190819, 0x082b080808191908, - 0x082b0808082b0808, 0x082b080819080819, 0x082b080819081908, 0x082b080819190808, - 0x082b08082b080808, 0x082b08082b2b0808, 0x082b081908080819, 0x082b081908081908, - 0x082b081908190808, 0x082b081919080808, 0x082b081919082b08, 0x082b0819192b1919, - 0x082b082b08080808, 0x082b082b082b082b, 0x082b082b2b080808, 0x082b082b2b2b2b08, - 0x082b190808080819, 0x082b190808081908, 0x082b190808190808, 0x082b1908082b2b19, - 0x082b190819080808, 0x082b191908080808, 0x082b191919080819, 0x082b19191919082b, - 0x082b19192b192b19, 0x082b192b08080819, 0x082b192b08192b2b, 0x082b192b2b2b192b, - 0x082b2b0808080808, 0x082b2b0808082b08, 0x082b2b0808082b2b, 0x082b2b08082b0808, - 0x082b2b0819191919, 0x082b2b082b082b08, 0x082b2b082b2b082b, 0x082b2b19192b2b08, - 0x082b2b192b190808, 0x082b2b2b08082b08, 0x082b2b2b082b0808, 0x082b2b2b2b08082b, - 0x082b2b2b2b082b08, 0x082b2b2b2b082b2b, 0x1908080808080819, 0x1908080808081908, - 0x190808080808192b, 0x1908080808082b19, 0x1908080808190808, 0x190808080819082b, - 0x1908080808191919, 0x1908080808192b08, 0x19080808082b0819, 0x19080808082b1908, - 0x1908080819080808, 0x190808081908082b, 0x1908080819081919, 0x1908080819082b08, - 0x1908080819082b2b, 0x1908080819190819, 0x1908080819191908, 0x19080808192b0808, - 0x19080808192b1919, 0x190808082b080819, 0x190808082b081908, 0x190808082b190808, - 0x1908081908080808, 0x190808190808082b, 0x1908081908081919, 0x1908081908082b08, - 0x1908081908190819, 0x1908081908191908, 0x19080819082b0808, 0x1908081919080819, - 0x1908081919081908, 0x1908081919190808, 0x190808192b080808, 0x190808192b081919, - 0x190808192b2b082b, 0x1908082b08080819, 0x1908082b08081908, 0x1908082b08190808, - 0x1908082b0819082b, 0x1908082b082b2b19, 0x1908082b19080808, 0x1908190808080808, - 0x190819080808082b, 0x1908190808081919, 0x1908190808082b08, 0x1908190808190819, - 0x1908190808191908, 0x1908190808192b19, 0x19081908082b0808, 0x1908190819080819, - 0x1908190819081908, 0x1908190819190808, 0x190819082b080808, 0x190819082b191908, - 0x1908191908080819, 0x1908191908081908, 0x1908191908190808, 0x19081919082b1908, - 0x1908191919080808, 0x190819192b192b2b, 0x1908192b08080808, 0x1908192b08082b2b, - 0x1908192b19081908, 0x1908192b19190808, 0x19082b0808080819, 0x19082b0808081908, - 0x19082b0808190808, 0x19082b0819080808, 0x19082b0819081919, 0x19082b0819191908, - 0x19082b08192b082b, 0x19082b1908080808, 0x19082b1908190819, 0x19082b1919081908, - 0x19082b1919190808, 0x19082b19192b2b19, 0x19082b2b08081908, 0x1919080808080808, - 0x191908080808082b, 0x1919080808081919, 0x1919080808082b08, 0x1919080808190819, - 0x1919080808191908, 0x19190808082b0808, 0x19190808082b2b08, 0x1919080819080819, - 0x1919080819081908, 0x1919080819190808, 0x191908082b080808, 0x1919081908080819, - 0x1919081908081908, 0x1919081908190808, 0x1919081908191919, 0x1919081919080808, - 0x191908191908082b, 0x1919082b08080808, 0x1919082b19081908, 0x1919082b2b2b2b2b, - 0x1919190808080819, 0x1919190808081908, 0x1919190808190808, 0x19191908082b0819, - 0x1919190819080808, 0x19191908192b0808, 0x191919082b080819, 0x191919082b2b0819, - 0x1919191908080808, 0x1919191908082b08, 0x191919192b080808, 0x191919192b082b08, - 0x1919192b082b0819, 0x1919192b192b2b08, 0x1919192b2b2b0819, 0x19192b0808080808, - 0x19192b0808191908, 0x19192b0819080819, 0x19192b0819190808, 0x19192b082b192b19, - 0x19192b1908192b2b, 0x19192b1919080808, 0x19192b191908082b, 0x19192b2b2b081919, - 0x192b080808080819, 0x192b080808081908, 0x192b080808190808, 0x192b080819080808, - 0x192b080819191908, 0x192b0808192b082b, 0x192b08082b08192b, 0x192b08082b2b2b19, - 0x192b081908080808, 0x192b082b082b1908, 0x192b082b19082b2b, 0x192b082b2b19082b, - 0x192b190808080808, 0x192b19080819192b, 0x192b191908190808, 0x192b191919080808, - 0x192b191919081919, 0x192b19192b2b1908, 0x192b2b0808080819, 0x192b2b08192b2b2b, - 0x192b2b19082b1919, 0x192b2b2b0808192b, 0x192b2b2b19191908, 0x192b2b2b192b082b, - 0x2b08080808080808, 0x2b0808080808082b, 0x2b08080808081919, 0x2b08080808082b08, - 0x2b08080808190819, 0x2b08080808191908, 0x2b080808082b0808, 0x2b080808082b2b2b, - 0x2b08080819080819, 0x2b08080819081908, 0x2b08080819190808, 0x2b0808082b080808, - 0x2b0808082b08082b, 0x2b0808082b2b2b08, 0x2b0808082b2b2b2b, 0x2b08081908080819, - 0x2b08081908081908, 0x2b0808190808192b, 0x2b08081908190808, 0x2b08081919080808, - 0x2b08081919190819, 0x2b08081919192b19, 0x2b08082b08080808, 0x2b08082b082b0808, - 0x2b08082b2b080808, 0x2b08082b2b08082b, 0x2b08082b2b2b0808, 0x2b08082b2b2b2b08, - 0x2b08190808080819, 0x2b08190808081908, 0x2b08190808190808, 0x2b0819080819082b, - 0x2b08190808191919, 0x2b08190819080808, 0x2b081908192b0808, 0x2b0819082b082b19, - 0x2b08191908080808, 0x2b08191919081908, 0x2b0819192b2b1919, 0x2b08192b08192b08, - 0x2b08192b192b2b2b, 0x2b082b0808080808, 0x2b082b0808082b08, 0x2b082b08082b1919, - 0x2b082b0819192b2b, 0x2b082b082b080808, 0x2b082b082b08082b, 0x2b082b082b2b2b08, - 0x2b082b190808192b, 0x2b082b2b082b082b, 0x2b082b2b2b080808, 0x2b082b2b2b082b08, - 0x2b082b2b2b19192b, 0x2b082b2b2b2b2b08, 0x2b19080808080819, 0x2b19080808081908, - 0x2b19080808190808, 0x2b19080819080808, 0x2b1908081919192b, 0x2b1908082b081908, - 0x2b19081908080808, 0x2b190819082b082b, 0x2b190819192b1908, 0x2b19082b1919192b, - 0x2b19082b2b082b19, 0x2b19190808080808, 0x2b19190808081919, 0x2b19190819081908, - 0x2b19190819190808, 0x2b19190819192b08, 0x2b191919082b2b19, 0x2b1919192b190808, - 0x2b1919192b19082b, 0x2b19192b19080819, 0x2b192b0819190819, 0x2b192b082b2b192b, - 0x2b192b1919082b19, 0x2b192b2b08191919, 0x2b192b2b192b0808, 0x2b2b080808080808, - 0x2b2b08080808082b, 0x2b2b080808082b08, 0x2b2b080808082b2b, 0x2b2b0808082b0808, - 0x2b2b0808082b2b2b, 0x2b2b08082b2b0808, 0x2b2b081919190819, 0x2b2b081919192b19, - 0x2b2b08192b2b192b, 0x2b2b082b08080808, 0x2b2b082b0808082b, 0x2b2b082b08082b08, - 0x2b2b082b082b2b2b, 0x2b2b082b2b080808, 0x2b2b082b2b2b0808, 0x2b2b190819080808, - 0x2b2b19082b191919, 0x2b2b192b192b1919, 0x2b2b192b2b192b08, 0x2b2b2b0808082b2b, - 0x2b2b2b08082b0808, 0x2b2b2b08082b082b, 0x2b2b2b08082b2b08, 0x2b2b2b082b2b0808, - 0x2b2b2b082b2b2b08, 0x2b2b2b1908081908, 0x2b2b2b192b081908, 0x2b2b2b192b08192b, - 0x2b2b2b2b082b2b08, 0x2b2b2b2b082b2b2b, 0x2b2b2b2b2b190819, 0x2b2b2b2b2b2b2b2b, -}; - -static const __device__ uint64_t iq2s_grid[1024] = { - 0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, - 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x080808080819192b, - 0x0808080808192b19, 0x08080808082b0808, 0x08080808082b082b, 0x08080808082b1919, - 0x08080808082b2b08, 0x0808080819080819, 0x0808080819081908, 0x080808081908192b, - 0x0808080819082b19, 0x0808080819190808, 0x080808081919082b, 0x0808080819191919, - 0x0808080819192b08, 0x08080808192b0819, 0x08080808192b1908, 0x08080808192b192b, - 0x08080808192b2b19, 0x080808082b080808, 0x080808082b08082b, 0x080808082b081919, - 0x080808082b082b08, 0x080808082b190819, 0x080808082b191908, 0x080808082b2b0808, - 0x080808082b2b1919, 0x080808082b2b2b2b, 0x0808081908080819, 0x0808081908081908, - 0x080808190808192b, 0x0808081908082b19, 0x0808081908190808, 0x080808190819082b, - 0x0808081908191919, 0x0808081908192b08, 0x08080819082b0819, 0x08080819082b1908, - 0x0808081919080808, 0x080808191908082b, 0x0808081919081919, 0x0808081919082b08, - 0x0808081919190819, 0x0808081919191908, 0x080808191919192b, 0x0808081919192b19, - 0x08080819192b0808, 0x08080819192b1919, 0x08080819192b2b08, 0x080808192b080819, - 0x080808192b081908, 0x080808192b190808, 0x080808192b19082b, 0x080808192b191919, - 0x080808192b2b0819, 0x080808192b2b1908, 0x0808082b08080808, 0x0808082b0808082b, - 0x0808082b08081919, 0x0808082b08082b08, 0x0808082b08190819, 0x0808082b08191908, - 0x0808082b082b0808, 0x0808082b082b2b2b, 0x0808082b19080819, 0x0808082b19081908, - 0x0808082b1908192b, 0x0808082b19082b19, 0x0808082b19190808, 0x0808082b19191919, - 0x0808082b2b080808, 0x0808082b2b081919, 0x0808082b2b082b2b, 0x0808082b2b191908, - 0x0808082b2b2b082b, 0x0808190808080819, 0x0808190808081908, 0x080819080808192b, - 0x0808190808082b19, 0x0808190808190808, 0x080819080819082b, 0x0808190808191919, - 0x0808190808192b08, 0x08081908082b0819, 0x08081908082b1908, 0x08081908082b192b, - 0x08081908082b2b19, 0x0808190819080808, 0x080819081908082b, 0x0808190819081919, - 0x0808190819082b08, 0x0808190819082b2b, 0x0808190819190819, 0x0808190819191908, - 0x080819081919192b, 0x0808190819192b19, 0x08081908192b0808, 0x08081908192b082b, - 0x08081908192b1919, 0x080819082b080819, 0x080819082b081908, 0x080819082b08192b, - 0x080819082b082b19, 0x080819082b190808, 0x080819082b191919, 0x080819082b192b08, - 0x080819082b2b0819, 0x080819082b2b1908, 0x0808191908080808, 0x080819190808082b, - 0x0808191908081919, 0x0808191908082b08, 0x0808191908082b2b, 0x0808191908190819, - 0x0808191908191908, 0x080819190819192b, 0x0808191908192b19, 0x08081919082b0808, - 0x08081919082b1919, 0x08081919082b2b08, 0x0808191919080819, 0x0808191919081908, - 0x080819191908192b, 0x0808191919082b19, 0x0808191919190808, 0x080819191919082b, - 0x0808191919191919, 0x0808191919192b08, 0x08081919192b0819, 0x08081919192b1908, - 0x080819192b080808, 0x080819192b08082b, 0x080819192b081919, 0x080819192b082b08, - 0x080819192b190819, 0x080819192b191908, 0x080819192b2b0808, 0x0808192b08080819, - 0x0808192b08081908, 0x0808192b0808192b, 0x0808192b08082b19, 0x0808192b08190808, - 0x0808192b08191919, 0x0808192b19080808, 0x0808192b19081919, 0x0808192b19082b08, - 0x0808192b19190819, 0x0808192b19191908, 0x0808192b192b0808, 0x0808192b2b080819, - 0x0808192b2b081908, 0x0808192b2b190808, 0x08082b0808080808, 0x08082b080808082b, - 0x08082b0808081919, 0x08082b0808082b08, 0x08082b0808190819, 0x08082b0808191908, - 0x08082b080819192b, 0x08082b0808192b19, 0x08082b08082b0808, 0x08082b08082b1919, - 0x08082b08082b2b2b, 0x08082b0819080819, 0x08082b0819081908, 0x08082b081908192b, - 0x08082b0819082b19, 0x08082b0819190808, 0x08082b081919082b, 0x08082b0819191919, - 0x08082b0819192b08, 0x08082b08192b0819, 0x08082b08192b1908, 0x08082b082b080808, - 0x08082b082b081919, 0x08082b082b191908, 0x08082b082b2b2b2b, 0x08082b1908080819, - 0x08082b1908081908, 0x08082b1908190808, 0x08082b190819082b, 0x08082b1908191919, - 0x08082b1908192b08, 0x08082b19082b0819, 0x08082b1919080808, 0x08082b1919081919, - 0x08082b1919082b08, 0x08082b1919190819, 0x08082b1919191908, 0x08082b19192b0808, - 0x08082b192b080819, 0x08082b192b190808, 0x08082b2b08080808, 0x08082b2b08190819, - 0x08082b2b08191908, 0x08082b2b082b082b, 0x08082b2b082b2b08, 0x08082b2b082b2b2b, - 0x08082b2b19190808, 0x08082b2b2b192b19, 0x0819080808080819, 0x0819080808081908, - 0x081908080808192b, 0x0819080808082b19, 0x0819080808190808, 0x081908080819082b, - 0x0819080808191919, 0x0819080808192b08, 0x08190808082b0819, 0x08190808082b1908, - 0x08190808082b192b, 0x0819080819080808, 0x081908081908082b, 0x0819080819081919, - 0x0819080819082b08, 0x0819080819190819, 0x0819080819191908, 0x081908081919192b, - 0x0819080819192b19, 0x08190808192b0808, 0x08190808192b082b, 0x08190808192b1919, - 0x08190808192b2b08, 0x081908082b080819, 0x081908082b081908, 0x081908082b08192b, - 0x081908082b190808, 0x081908082b191919, 0x081908082b192b08, 0x081908082b2b0819, - 0x081908082b2b1908, 0x0819081908080808, 0x081908190808082b, 0x0819081908081919, - 0x0819081908082b08, 0x0819081908082b2b, 0x0819081908190819, 0x0819081908191908, - 0x081908190819192b, 0x0819081908192b19, 0x08190819082b0808, 0x08190819082b082b, - 0x08190819082b1919, 0x08190819082b2b08, 0x0819081919080819, 0x0819081919081908, - 0x081908191908192b, 0x0819081919082b19, 0x0819081919190808, 0x081908191919082b, - 0x0819081919191919, 0x0819081919192b08, 0x08190819192b0819, 0x08190819192b1908, - 0x081908192b080808, 0x081908192b08082b, 0x081908192b081919, 0x081908192b082b08, - 0x081908192b190819, 0x081908192b191908, 0x0819082b08080819, 0x0819082b08081908, - 0x0819082b08082b19, 0x0819082b08190808, 0x0819082b08191919, 0x0819082b082b0819, - 0x0819082b082b1908, 0x0819082b19080808, 0x0819082b19081919, 0x0819082b19190819, - 0x0819082b19191908, 0x0819082b2b080819, 0x0819082b2b081908, 0x0819082b2b190808, - 0x0819190808080808, 0x081919080808082b, 0x0819190808081919, 0x0819190808082b08, - 0x0819190808190819, 0x0819190808191908, 0x081919080819192b, 0x0819190808192b19, - 0x08191908082b0808, 0x08191908082b1919, 0x08191908082b2b08, 0x0819190819080819, - 0x0819190819081908, 0x081919081908192b, 0x0819190819082b19, 0x0819190819190808, - 0x081919081919082b, 0x0819190819191919, 0x0819190819192b08, 0x08191908192b0819, - 0x08191908192b1908, 0x081919082b080808, 0x081919082b08082b, 0x081919082b081919, - 0x081919082b082b08, 0x081919082b190819, 0x081919082b191908, 0x081919082b2b0808, - 0x0819191908080819, 0x0819191908081908, 0x081919190808192b, 0x0819191908082b19, - 0x0819191908190808, 0x081919190819082b, 0x0819191908191919, 0x0819191908192b08, - 0x08191919082b0819, 0x08191919082b1908, 0x0819191919080808, 0x081919191908082b, - 0x0819191919081919, 0x0819191919082b08, 0x0819191919190819, 0x0819191919191908, - 0x08191919192b0808, 0x081919192b080819, 0x081919192b081908, 0x081919192b190808, - 0x0819192b08080808, 0x0819192b08081919, 0x0819192b08082b08, 0x0819192b08190819, - 0x0819192b08191908, 0x0819192b082b0808, 0x0819192b19080819, 0x0819192b19081908, - 0x0819192b19190808, 0x0819192b2b080808, 0x0819192b2b2b2b2b, 0x08192b0808080819, - 0x08192b0808081908, 0x08192b080808192b, 0x08192b0808082b19, 0x08192b0808190808, - 0x08192b0808191919, 0x08192b0808192b08, 0x08192b08082b0819, 0x08192b0819080808, - 0x08192b081908082b, 0x08192b0819081919, 0x08192b0819082b08, 0x08192b0819190819, - 0x08192b0819191908, 0x08192b08192b0808, 0x08192b082b080819, 0x08192b082b081908, - 0x08192b1908080808, 0x08192b190808082b, 0x08192b1908081919, 0x08192b1908082b08, - 0x08192b1908190819, 0x08192b1908191908, 0x08192b19082b0808, 0x08192b1919080819, - 0x08192b1919081908, 0x08192b1919190808, 0x08192b19192b2b19, 0x08192b192b2b082b, - 0x08192b2b08081908, 0x08192b2b08190808, 0x08192b2b19080808, 0x08192b2b1919192b, - 0x082b080808080808, 0x082b08080808082b, 0x082b080808081919, 0x082b080808082b08, - 0x082b080808190819, 0x082b080808191908, 0x082b08080819192b, 0x082b080808192b19, - 0x082b0808082b0808, 0x082b0808082b1919, 0x082b0808082b2b2b, 0x082b080819080819, - 0x082b080819081908, 0x082b080819190808, 0x082b08081919082b, 0x082b080819191919, - 0x082b0808192b1908, 0x082b08082b080808, 0x082b08082b082b2b, 0x082b08082b191908, - 0x082b08082b2b2b2b, 0x082b081908080819, 0x082b081908081908, 0x082b081908190808, - 0x082b08190819082b, 0x082b081908191919, 0x082b0819082b0819, 0x082b081919080808, - 0x082b08191908082b, 0x082b081919081919, 0x082b081919190819, 0x082b081919191908, - 0x082b0819192b0808, 0x082b08192b080819, 0x082b08192b081908, 0x082b08192b190808, - 0x082b082b08080808, 0x082b082b08082b2b, 0x082b082b082b082b, 0x082b082b082b2b08, - 0x082b082b082b2b2b, 0x082b082b19081908, 0x082b082b19190808, 0x082b082b2b082b08, - 0x082b082b2b082b2b, 0x082b082b2b2b2b08, 0x082b190808080819, 0x082b190808081908, - 0x082b19080808192b, 0x082b190808082b19, 0x082b190808190808, 0x082b190808191919, - 0x082b190808192b08, 0x082b1908082b0819, 0x082b1908082b1908, 0x082b190819080808, - 0x082b19081908082b, 0x082b190819081919, 0x082b190819082b08, 0x082b190819190819, - 0x082b190819191908, 0x082b1908192b0808, 0x082b19082b080819, 0x082b19082b081908, - 0x082b19082b190808, 0x082b191908080808, 0x082b191908081919, 0x082b191908082b08, - 0x082b191908190819, 0x082b191908191908, 0x082b1919082b0808, 0x082b191919080819, - 0x082b191919081908, 0x082b191919190808, 0x082b1919192b192b, 0x082b19192b080808, - 0x082b192b08080819, 0x082b192b08081908, 0x082b192b08190808, 0x082b192b19080808, - 0x082b192b19192b19, 0x082b2b0808080808, 0x082b2b0808081919, 0x082b2b0808190819, - 0x082b2b0808191908, 0x082b2b0819080819, 0x082b2b0819081908, 0x082b2b0819190808, - 0x082b2b082b082b2b, 0x082b2b082b2b2b2b, 0x082b2b1908080819, 0x082b2b1908081908, - 0x082b2b1908190808, 0x082b2b192b191919, 0x082b2b2b08082b2b, 0x082b2b2b082b082b, - 0x082b2b2b192b1908, 0x082b2b2b2b082b08, 0x082b2b2b2b082b2b, 0x1908080808080819, - 0x1908080808081908, 0x190808080808192b, 0x1908080808082b19, 0x1908080808190808, - 0x190808080819082b, 0x1908080808191919, 0x1908080808192b08, 0x1908080808192b2b, - 0x19080808082b0819, 0x19080808082b1908, 0x19080808082b192b, 0x1908080819080808, - 0x190808081908082b, 0x1908080819081919, 0x1908080819082b08, 0x1908080819082b2b, - 0x1908080819190819, 0x1908080819191908, 0x190808081919192b, 0x1908080819192b19, - 0x19080808192b0808, 0x19080808192b082b, 0x19080808192b1919, 0x190808082b080819, - 0x190808082b081908, 0x190808082b190808, 0x190808082b191919, 0x190808082b192b08, - 0x190808082b2b0819, 0x190808082b2b1908, 0x1908081908080808, 0x190808190808082b, - 0x1908081908081919, 0x1908081908082b08, 0x1908081908190819, 0x1908081908191908, - 0x190808190819192b, 0x1908081908192b19, 0x19080819082b0808, 0x19080819082b082b, - 0x19080819082b1919, 0x1908081919080819, 0x1908081919081908, 0x190808191908192b, - 0x1908081919082b19, 0x1908081919190808, 0x190808191919082b, 0x1908081919191919, - 0x1908081919192b08, 0x19080819192b0819, 0x19080819192b1908, 0x190808192b080808, - 0x190808192b08082b, 0x190808192b081919, 0x190808192b082b08, 0x190808192b190819, - 0x190808192b191908, 0x190808192b2b0808, 0x1908082b08080819, 0x1908082b08081908, - 0x1908082b08190808, 0x1908082b0819082b, 0x1908082b08191919, 0x1908082b08192b08, - 0x1908082b082b1908, 0x1908082b19080808, 0x1908082b19081919, 0x1908082b19082b08, - 0x1908082b19190819, 0x1908082b19191908, 0x1908082b192b0808, 0x1908082b2b080819, - 0x1908082b2b081908, 0x1908190808080808, 0x190819080808082b, 0x1908190808081919, - 0x1908190808082b08, 0x1908190808082b2b, 0x1908190808190819, 0x1908190808191908, - 0x190819080819192b, 0x1908190808192b19, 0x19081908082b0808, 0x19081908082b082b, - 0x19081908082b1919, 0x19081908082b2b08, 0x1908190819080819, 0x1908190819081908, - 0x190819081908192b, 0x1908190819082b19, 0x1908190819190808, 0x190819081919082b, - 0x1908190819191919, 0x1908190819192b08, 0x19081908192b0819, 0x19081908192b1908, - 0x190819082b080808, 0x190819082b08082b, 0x190819082b081919, 0x190819082b082b08, - 0x190819082b190819, 0x190819082b191908, 0x190819082b2b0808, 0x1908191908080819, - 0x1908191908081908, 0x190819190808192b, 0x1908191908082b19, 0x1908191908190808, - 0x190819190819082b, 0x1908191908191919, 0x1908191908192b08, 0x19081919082b0819, - 0x19081919082b1908, 0x1908191919080808, 0x190819191908082b, 0x1908191919081919, - 0x1908191919082b08, 0x1908191919190819, 0x1908191919191908, 0x19081919192b0808, - 0x19081919192b2b2b, 0x190819192b080819, 0x190819192b081908, 0x190819192b190808, - 0x1908192b08080808, 0x1908192b0808082b, 0x1908192b08081919, 0x1908192b08082b08, - 0x1908192b08190819, 0x1908192b08191908, 0x1908192b082b0808, 0x1908192b19080819, - 0x1908192b19081908, 0x1908192b19190808, 0x1908192b2b080808, 0x1908192b2b2b1919, - 0x19082b0808080819, 0x19082b0808081908, 0x19082b0808082b19, 0x19082b0808190808, - 0x19082b080819082b, 0x19082b0808191919, 0x19082b0808192b08, 0x19082b08082b0819, - 0x19082b08082b1908, 0x19082b0819080808, 0x19082b081908082b, 0x19082b0819081919, - 0x19082b0819082b08, 0x19082b0819190819, 0x19082b0819191908, 0x19082b08192b0808, - 0x19082b082b081908, 0x19082b082b190808, 0x19082b1908080808, 0x19082b190808082b, - 0x19082b1908081919, 0x19082b1908082b08, 0x19082b1908190819, 0x19082b1908191908, - 0x19082b19082b0808, 0x19082b1919080819, 0x19082b1919081908, 0x19082b1919190808, - 0x19082b192b080808, 0x19082b192b19192b, 0x19082b2b08080819, 0x19082b2b08081908, - 0x19082b2b08190808, 0x19082b2b19080808, 0x1919080808080808, 0x191908080808082b, - 0x1919080808081919, 0x1919080808082b08, 0x1919080808190819, 0x1919080808191908, - 0x191908080819192b, 0x1919080808192b19, 0x19190808082b0808, 0x19190808082b082b, - 0x19190808082b1919, 0x19190808082b2b08, 0x1919080819080819, 0x1919080819081908, - 0x191908081908192b, 0x1919080819082b19, 0x1919080819190808, 0x191908081919082b, - 0x1919080819191919, 0x1919080819192b08, 0x19190808192b0819, 0x19190808192b1908, - 0x191908082b080808, 0x191908082b08082b, 0x191908082b081919, 0x191908082b082b08, - 0x191908082b190819, 0x191908082b191908, 0x1919081908080819, 0x1919081908081908, - 0x191908190808192b, 0x1919081908082b19, 0x1919081908190808, 0x191908190819082b, - 0x1919081908191919, 0x1919081908192b08, 0x19190819082b0819, 0x19190819082b1908, - 0x1919081919080808, 0x191908191908082b, 0x1919081919081919, 0x1919081919082b08, - 0x1919081919190819, 0x1919081919191908, 0x19190819192b0808, 0x191908192b080819, - 0x191908192b081908, 0x191908192b190808, 0x1919082b08080808, 0x1919082b08081919, - 0x1919082b08082b08, 0x1919082b08190819, 0x1919082b08191908, 0x1919082b082b0808, - 0x1919082b19080819, 0x1919082b19081908, 0x1919082b19190808, 0x1919082b192b2b19, - 0x1919082b2b080808, 0x1919190808080819, 0x1919190808081908, 0x191919080808192b, - 0x1919190808082b19, 0x1919190808190808, 0x191919080819082b, 0x1919190808191919, - 0x1919190808192b08, 0x19191908082b0819, 0x19191908082b1908, 0x1919190819080808, - 0x191919081908082b, 0x1919190819081919, 0x1919190819082b08, 0x1919190819190819, - 0x1919190819191908, 0x19191908192b0808, 0x191919082b080819, 0x191919082b081908, - 0x191919082b190808, 0x1919191908080808, 0x191919190808082b, 0x1919191908081919, - 0x1919191908082b08, 0x1919191908190819, 0x1919191908191908, 0x19191919082b0808, - 0x1919191919080819, 0x1919191919081908, 0x1919191919190808, 0x191919192b080808, - 0x1919192b08080819, 0x1919192b08081908, 0x1919192b08190808, 0x1919192b082b192b, - 0x1919192b19080808, 0x19192b0808080808, 0x19192b080808082b, 0x19192b0808081919, - 0x19192b0808082b08, 0x19192b0808190819, 0x19192b0808191908, 0x19192b08082b0808, - 0x19192b0819080819, 0x19192b0819081908, 0x19192b0819190808, 0x19192b0819192b2b, - 0x19192b082b080808, 0x19192b1908080819, 0x19192b1908081908, 0x19192b1908190808, - 0x19192b1919080808, 0x19192b2b08080808, 0x19192b2b08192b19, 0x19192b2b2b081919, - 0x19192b2b2b2b2b08, 0x192b080808080819, 0x192b080808081908, 0x192b08080808192b, - 0x192b080808190808, 0x192b08080819082b, 0x192b080808191919, 0x192b080808192b08, - 0x192b0808082b0819, 0x192b0808082b1908, 0x192b080819080808, 0x192b080819081919, - 0x192b080819082b08, 0x192b080819190819, 0x192b080819191908, 0x192b0808192b0808, - 0x192b08082b081908, 0x192b08082b190808, 0x192b081908080808, 0x192b08190808082b, - 0x192b081908081919, 0x192b081908082b08, 0x192b081908190819, 0x192b081908191908, - 0x192b0819082b0808, 0x192b081919080819, 0x192b081919081908, 0x192b081919190808, - 0x192b08192b080808, 0x192b08192b192b19, 0x192b082b08081908, 0x192b082b08190808, - 0x192b082b19080808, 0x192b082b1919192b, 0x192b082b2b2b0819, 0x192b190808080808, - 0x192b190808081919, 0x192b190808082b08, 0x192b190808190819, 0x192b190808191908, - 0x192b1908082b0808, 0x192b190819080819, 0x192b190819081908, 0x192b190819190808, - 0x192b19082b080808, 0x192b191908080819, 0x192b191908081908, 0x192b191908190808, - 0x192b191919080808, 0x192b191919082b2b, 0x192b1919192b2b08, 0x192b19192b19082b, - 0x192b192b08080808, 0x192b192b2b191908, 0x192b2b0808080819, 0x192b2b0808081908, - 0x192b2b0808190808, 0x192b2b08192b1919, 0x192b2b082b192b08, 0x192b2b1908080808, - 0x192b2b19082b2b2b, 0x192b2b2b1908082b, 0x192b2b2b2b2b0819, 0x2b08080808080808, - 0x2b0808080808082b, 0x2b08080808081919, 0x2b08080808082b08, 0x2b08080808190819, - 0x2b08080808191908, 0x2b08080808192b19, 0x2b080808082b0808, 0x2b080808082b1919, - 0x2b08080819080819, 0x2b08080819081908, 0x2b08080819190808, 0x2b0808081919082b, - 0x2b08080819191919, 0x2b08080819192b08, 0x2b080808192b0819, 0x2b0808082b080808, - 0x2b0808082b081919, 0x2b0808082b190819, 0x2b0808082b191908, 0x2b08081908080819, - 0x2b08081908081908, 0x2b08081908082b19, 0x2b08081908190808, 0x2b0808190819082b, - 0x2b08081908191919, 0x2b08081908192b08, 0x2b080819082b0819, 0x2b080819082b1908, - 0x2b08081919080808, 0x2b0808191908082b, 0x2b08081919081919, 0x2b08081919082b08, - 0x2b08081919190819, 0x2b08081919191908, 0x2b0808192b080819, 0x2b0808192b081908, - 0x2b0808192b190808, 0x2b0808192b2b2b19, 0x2b08082b08080808, 0x2b08082b08081919, - 0x2b08082b08082b2b, 0x2b08082b08190819, 0x2b08082b08191908, 0x2b08082b19080819, - 0x2b08082b19081908, 0x2b08082b19190808, 0x2b08190808080819, 0x2b08190808081908, - 0x2b0819080808192b, 0x2b08190808082b19, 0x2b08190808190808, 0x2b0819080819082b, - 0x2b08190808191919, 0x2b08190808192b08, 0x2b081908082b0819, 0x2b08190819080808, - 0x2b0819081908082b, 0x2b08190819081919, 0x2b08190819082b08, 0x2b08190819190819, - 0x2b08190819191908, 0x2b081908192b0808, 0x2b0819082b080819, 0x2b0819082b081908, - 0x2b0819082b190808, 0x2b08191908080808, 0x2b0819190808082b, 0x2b08191908081919, - 0x2b08191908082b08, 0x2b08191908190819, 0x2b08191908191908, 0x2b081919082b0808, - 0x2b08191919080819, 0x2b08191919081908, 0x2b08191919190808, 0x2b0819192b080808, - 0x2b0819192b082b2b, 0x2b08192b08080819, 0x2b08192b08081908, 0x2b08192b08190808, - 0x2b08192b082b2b19, 0x2b08192b19080808, 0x2b082b0808080808, 0x2b082b0808081919, - 0x2b082b0808190819, 0x2b082b0808191908, 0x2b082b0819080819, 0x2b082b0819081908, - 0x2b082b0819190808, 0x2b082b082b2b082b, 0x2b082b1908080819, 0x2b082b1908081908, - 0x2b082b1919080808, 0x2b082b19192b1919, 0x2b082b2b082b082b, 0x2b082b2b19192b08, - 0x2b082b2b19192b2b, 0x2b082b2b2b08082b, 0x2b082b2b2b2b082b, 0x2b19080808080819, - 0x2b19080808081908, 0x2b19080808082b19, 0x2b19080808190808, 0x2b1908080819082b, - 0x2b19080808191919, 0x2b19080808192b08, 0x2b190808082b1908, 0x2b19080819080808, - 0x2b1908081908082b, 0x2b19080819081919, 0x2b19080819082b08, 0x2b19080819190819, - 0x2b19080819191908, 0x2b190808192b0808, 0x2b1908082b080819, 0x2b1908082b081908, - 0x2b1908082b190808, 0x2b19081908080808, 0x2b19081908081919, 0x2b19081908190819, - 0x2b19081908191908, 0x2b19081919080819, 0x2b19081919081908, 0x2b19081919190808, - 0x2b19081919192b2b, 0x2b19082b08080819, 0x2b19082b08081908, 0x2b19082b08190808, - 0x2b19082b19080808, 0x2b19082b2b2b192b, 0x2b19190808080808, 0x2b1919080808082b, - 0x2b19190808081919, 0x2b19190808082b08, 0x2b19190808190819, 0x2b19190808191908, - 0x2b191908082b0808, 0x2b19190819080819, 0x2b19190819081908, 0x2b19190819190808, - 0x2b1919082b080808, 0x2b1919082b19192b, 0x2b19191908080819, 0x2b19191908081908, - 0x2b19191908190808, 0x2b19191919080808, 0x2b1919192b192b08, 0x2b1919192b2b0819, - 0x2b19192b08080808, 0x2b19192b1908192b, 0x2b19192b192b1908, 0x2b192b0808080819, - 0x2b192b0808081908, 0x2b192b0808190808, 0x2b192b08082b192b, 0x2b192b0819080808, - 0x2b192b082b2b2b19, 0x2b192b1908080808, 0x2b192b1919082b19, 0x2b192b191919082b, - 0x2b192b2b2b190808, 0x2b2b080808080808, 0x2b2b080808081919, 0x2b2b080808082b2b, - 0x2b2b080808191908, 0x2b2b0808082b082b, 0x2b2b0808082b2b2b, 0x2b2b080819080819, - 0x2b2b080819081908, 0x2b2b080819190808, 0x2b2b08082b2b082b, 0x2b2b08082b2b2b2b, - 0x2b2b081919080808, 0x2b2b0819192b1919, 0x2b2b082b0808082b, 0x2b2b082b08082b2b, - 0x2b2b082b082b082b, 0x2b2b082b082b2b08, 0x2b2b082b082b2b2b, 0x2b2b082b2b08082b, - 0x2b2b082b2b082b08, 0x2b2b082b2b082b2b, 0x2b2b082b2b2b2b08, 0x2b2b190808080819, - 0x2b2b190808081908, 0x2b2b190808190808, 0x2b2b190819080808, 0x2b2b19082b082b19, - 0x2b2b19082b2b1908, 0x2b2b191908080808, 0x2b2b191908192b19, 0x2b2b192b19190819, - 0x2b2b2b0808082b2b, 0x2b2b2b08082b2b08, 0x2b2b2b082b2b082b, 0x2b2b2b1919191908, - 0x2b2b2b192b08192b, 0x2b2b2b2b08082b08, 0x2b2b2b2b08082b2b, 0x2b2b2b2b082b0808, - 0x2b2b2b2b082b082b, 0x2b2b2b2b082b2b08, 0x2b2b2b2b2b082b08, 0x2b2b2b2b2b2b2b2b, -}; - -static const __device__ uint32_t iq3xxs_grid[256] = { - 0x04040404, 0x04040414, 0x04040424, 0x04040c0c, 0x04040c1c, 0x04040c3e, 0x04041404, 0x04041414, - 0x04041c0c, 0x04042414, 0x04043e1c, 0x04043e2c, 0x040c040c, 0x040c041c, 0x040c0c04, 0x040c0c14, - 0x040c140c, 0x040c142c, 0x040c1c04, 0x040c1c14, 0x040c240c, 0x040c2c24, 0x040c3e04, 0x04140404, - 0x04140414, 0x04140424, 0x04140c0c, 0x04141404, 0x04141414, 0x04141c0c, 0x04141c1c, 0x04141c3e, - 0x04142c0c, 0x04142c3e, 0x04143e2c, 0x041c040c, 0x041c043e, 0x041c0c04, 0x041c0c14, 0x041c142c, - 0x041c3e04, 0x04240c1c, 0x04241c3e, 0x04242424, 0x04242c3e, 0x04243e1c, 0x04243e2c, 0x042c040c, - 0x042c043e, 0x042c1c14, 0x042c2c14, 0x04341c2c, 0x04343424, 0x043e0c04, 0x043e0c24, 0x043e0c34, - 0x043e241c, 0x043e340c, 0x0c04040c, 0x0c04041c, 0x0c040c04, 0x0c040c14, 0x0c04140c, 0x0c04141c, - 0x0c041c04, 0x0c041c14, 0x0c041c24, 0x0c04243e, 0x0c042c04, 0x0c0c0404, 0x0c0c0414, 0x0c0c0c0c, - 0x0c0c1404, 0x0c0c1414, 0x0c14040c, 0x0c14041c, 0x0c140c04, 0x0c140c14, 0x0c14140c, 0x0c141c04, - 0x0c143e14, 0x0c1c0404, 0x0c1c0414, 0x0c1c1404, 0x0c1c1c0c, 0x0c1c2434, 0x0c1c3434, 0x0c24040c, - 0x0c24042c, 0x0c242c04, 0x0c2c1404, 0x0c2c1424, 0x0c2c2434, 0x0c2c3e0c, 0x0c34042c, 0x0c3e1414, - 0x0c3e2404, 0x14040404, 0x14040414, 0x14040c0c, 0x14040c1c, 0x14041404, 0x14041414, 0x14041434, - 0x14041c0c, 0x14042414, 0x140c040c, 0x140c041c, 0x140c042c, 0x140c0c04, 0x140c0c14, 0x140c140c, - 0x140c1c04, 0x140c341c, 0x140c343e, 0x140c3e04, 0x14140404, 0x14140414, 0x14140c0c, 0x14140c3e, - 0x14141404, 0x14141414, 0x14141c3e, 0x14142404, 0x14142c2c, 0x141c040c, 0x141c0c04, 0x141c0c24, - 0x141c3e04, 0x141c3e24, 0x14241c2c, 0x14242c1c, 0x142c041c, 0x142c143e, 0x142c240c, 0x142c3e24, - 0x143e040c, 0x143e041c, 0x143e0c34, 0x143e242c, 0x1c04040c, 0x1c040c04, 0x1c040c14, 0x1c04140c, - 0x1c04141c, 0x1c042c04, 0x1c04342c, 0x1c043e14, 0x1c0c0404, 0x1c0c0414, 0x1c0c1404, 0x1c0c1c0c, - 0x1c0c2424, 0x1c0c2434, 0x1c14040c, 0x1c14041c, 0x1c140c04, 0x1c14142c, 0x1c142c14, 0x1c143e14, - 0x1c1c0c0c, 0x1c1c1c1c, 0x1c241c04, 0x1c24243e, 0x1c243e14, 0x1c2c0404, 0x1c2c0434, 0x1c2c1414, - 0x1c2c2c2c, 0x1c340c24, 0x1c341c34, 0x1c34341c, 0x1c3e1c1c, 0x1c3e3404, 0x24040424, 0x24040c3e, - 0x24041c2c, 0x24041c3e, 0x24042c1c, 0x24042c3e, 0x240c3e24, 0x24141404, 0x24141c3e, 0x24142404, - 0x24143404, 0x24143434, 0x241c043e, 0x241c242c, 0x24240424, 0x24242c0c, 0x24243424, 0x242c142c, - 0x242c241c, 0x242c3e04, 0x243e042c, 0x243e0c04, 0x243e0c14, 0x243e1c04, 0x2c040c14, 0x2c04240c, - 0x2c043e04, 0x2c0c0404, 0x2c0c0434, 0x2c0c1434, 0x2c0c2c2c, 0x2c140c24, 0x2c141c14, 0x2c143e14, - 0x2c1c0414, 0x2c1c2c1c, 0x2c240c04, 0x2c24141c, 0x2c24143e, 0x2c243e14, 0x2c2c0414, 0x2c2c1c0c, - 0x2c342c04, 0x2c3e1424, 0x2c3e2414, 0x34041424, 0x34042424, 0x34042434, 0x34043424, 0x340c140c, - 0x340c340c, 0x34140c3e, 0x34143424, 0x341c1c04, 0x341c1c34, 0x34242424, 0x342c042c, 0x342c2c14, - 0x34341c1c, 0x343e041c, 0x343e140c, 0x3e04041c, 0x3e04042c, 0x3e04043e, 0x3e040c04, 0x3e041c14, - 0x3e042c14, 0x3e0c1434, 0x3e0c2404, 0x3e140c14, 0x3e14242c, 0x3e142c14, 0x3e1c0404, 0x3e1c0c2c, - 0x3e1c1c1c, 0x3e1c3404, 0x3e24140c, 0x3e24240c, 0x3e2c0404, 0x3e2c0414, 0x3e2c1424, 0x3e341c04, -}; - -static const __device__ uint32_t iq3xs_grid[512] = { - 0x04040404, 0x0404040c, 0x04040414, 0x0404042c, 0x0404043e, 0x04040c04, 0x04040c0c, 0x04040c14, - 0x04040c24, 0x04040c34, 0x04041404, 0x0404140c, 0x0404142c, 0x04041c1c, 0x04042404, 0x04042414, - 0x0404242c, 0x0404243e, 0x04042c0c, 0x04042c1c, 0x04043404, 0x04043414, 0x04043e0c, 0x04043e24, - 0x04043e3e, 0x040c0404, 0x040c040c, 0x040c0414, 0x040c0424, 0x040c0c04, 0x040c0c0c, 0x040c0c2c, - 0x040c1404, 0x040c141c, 0x040c143e, 0x040c1c0c, 0x040c1c2c, 0x040c2424, 0x040c340c, 0x040c342c, - 0x040c3e14, 0x04140404, 0x0414040c, 0x0414042c, 0x0414043e, 0x04140c04, 0x04140c1c, 0x04140c34, - 0x0414140c, 0x0414142c, 0x04141c04, 0x04141c24, 0x04142414, 0x0414242c, 0x0414243e, 0x04142c0c, - 0x04142c1c, 0x04143e04, 0x04143e1c, 0x041c041c, 0x041c0c0c, 0x041c0c2c, 0x041c1404, 0x041c1414, - 0x041c1c0c, 0x041c1c1c, 0x041c1c34, 0x041c2424, 0x041c2c04, 0x041c2c14, 0x041c343e, 0x041c3e0c, - 0x041c3e2c, 0x04240404, 0x04240c1c, 0x04240c3e, 0x0424140c, 0x04241424, 0x04241c14, 0x04242404, - 0x0424241c, 0x04242c0c, 0x04243e04, 0x042c0414, 0x042c0424, 0x042c1404, 0x042c1414, 0x042c1434, - 0x042c1c1c, 0x042c240c, 0x042c242c, 0x042c243e, 0x042c3434, 0x042c3e1c, 0x04340434, 0x04340c0c, - 0x04340c1c, 0x04341c0c, 0x04342c14, 0x04343e0c, 0x043e0404, 0x043e0414, 0x043e0424, 0x043e1404, - 0x043e1414, 0x043e1434, 0x043e1c1c, 0x043e2c04, 0x043e2c24, 0x0c040404, 0x0c04040c, 0x0c040414, - 0x0c040424, 0x0c040c04, 0x0c040c0c, 0x0c040c1c, 0x0c040c2c, 0x0c040c3e, 0x0c041404, 0x0c041414, - 0x0c041c0c, 0x0c041c24, 0x0c041c34, 0x0c042c24, 0x0c042c34, 0x0c04340c, 0x0c043e14, 0x0c0c0404, - 0x0c0c040c, 0x0c0c041c, 0x0c0c0434, 0x0c0c0c04, 0x0c0c0c24, 0x0c0c140c, 0x0c0c1c04, 0x0c0c1c1c, - 0x0c0c240c, 0x0c0c2c04, 0x0c0c2c14, 0x0c0c3e04, 0x0c0c3e34, 0x0c140404, 0x0c140c14, 0x0c140c2c, - 0x0c140c3e, 0x0c141404, 0x0c141424, 0x0c141c14, 0x0c142404, 0x0c14241c, 0x0c142c2c, 0x0c143404, - 0x0c143e14, 0x0c1c040c, 0x0c1c0424, 0x0c1c043e, 0x0c1c0c04, 0x0c1c0c1c, 0x0c1c140c, 0x0c1c143e, - 0x0c1c1c04, 0x0c1c1c24, 0x0c1c240c, 0x0c1c3414, 0x0c1c3e04, 0x0c24041c, 0x0c24042c, 0x0c240c14, - 0x0c240c24, 0x0c241c0c, 0x0c241c1c, 0x0c242414, 0x0c242434, 0x0c242c04, 0x0c242c24, 0x0c2c040c, - 0x0c2c0c04, 0x0c2c0c1c, 0x0c2c140c, 0x0c2c1c04, 0x0c2c1c14, 0x0c2c2c0c, 0x0c341404, 0x0c341424, - 0x0c34143e, 0x0c342424, 0x0c342434, 0x0c3e040c, 0x0c3e041c, 0x0c3e0c04, 0x0c3e0c14, 0x0c3e140c, - 0x0c3e1c2c, 0x0c3e240c, 0x0c3e3414, 0x0c3e3e04, 0x14040404, 0x1404040c, 0x1404041c, 0x1404042c, - 0x1404043e, 0x14040c04, 0x14040c14, 0x14040c24, 0x14040c34, 0x1404140c, 0x1404141c, 0x1404143e, - 0x14041c04, 0x14041c14, 0x1404240c, 0x1404241c, 0x1404242c, 0x14042c04, 0x14042c14, 0x1404343e, - 0x14043e04, 0x14043e1c, 0x14043e2c, 0x140c0404, 0x140c0414, 0x140c0c04, 0x140c0c1c, 0x140c0c3e, - 0x140c1414, 0x140c142c, 0x140c1c0c, 0x140c1c24, 0x140c2414, 0x140c2c0c, 0x1414040c, 0x14140424, - 0x1414043e, 0x1414140c, 0x1414141c, 0x14141c04, 0x14141c3e, 0x1414240c, 0x14142c1c, 0x14142c3e, - 0x14143e0c, 0x14143e24, 0x141c0404, 0x141c0414, 0x141c042c, 0x141c0c0c, 0x141c1414, 0x141c1424, - 0x141c1c0c, 0x141c1c1c, 0x141c2414, 0x141c2c04, 0x141c3434, 0x1424040c, 0x1424043e, 0x14241404, - 0x1424141c, 0x14241c14, 0x14241c2c, 0x1424240c, 0x14243e14, 0x14243e2c, 0x142c0424, 0x142c0c0c, - 0x142c1414, 0x142c1c3e, 0x142c2404, 0x142c2c1c, 0x142c3e04, 0x14340404, 0x14340414, 0x1434043e, - 0x1434140c, 0x14342c2c, 0x1434340c, 0x143e042c, 0x143e0c0c, 0x143e1434, 0x143e1c04, 0x143e241c, - 0x143e2c04, 0x1c040414, 0x1c040c0c, 0x1c040c1c, 0x1c040c2c, 0x1c040c3e, 0x1c041414, 0x1c041c0c, - 0x1c041c1c, 0x1c041c2c, 0x1c042414, 0x1c042424, 0x1c04243e, 0x1c042c0c, 0x1c04341c, 0x1c043e0c, - 0x1c0c040c, 0x1c0c041c, 0x1c0c042c, 0x1c0c0c24, 0x1c0c140c, 0x1c0c141c, 0x1c0c2404, 0x1c0c3404, - 0x1c0c3e14, 0x1c0c3e34, 0x1c140404, 0x1c140c14, 0x1c141404, 0x1c141c14, 0x1c141c24, 0x1c142c04, - 0x1c1c040c, 0x1c1c0c04, 0x1c1c0c24, 0x1c1c140c, 0x1c1c141c, 0x1c1c143e, 0x1c1c1c04, 0x1c1c240c, - 0x1c1c241c, 0x1c1c243e, 0x1c1c2c2c, 0x1c1c3e1c, 0x1c24041c, 0x1c240c0c, 0x1c240c34, 0x1c241414, - 0x1c241c0c, 0x1c242c14, 0x1c243404, 0x1c243424, 0x1c2c040c, 0x1c2c0c04, 0x1c2c0c14, 0x1c2c142c, - 0x1c2c1c14, 0x1c2c2424, 0x1c2c2c34, 0x1c2c3e1c, 0x1c340c34, 0x1c34240c, 0x1c3e040c, 0x1c3e041c, - 0x1c3e1404, 0x1c3e1414, 0x1c3e1c2c, 0x24040404, 0x24040424, 0x24040c14, 0x24041404, 0x24041424, - 0x2404143e, 0x24041c14, 0x2404240c, 0x24042c04, 0x24043e04, 0x240c0414, 0x240c043e, 0x240c0c0c, - 0x240c0c1c, 0x240c1414, 0x240c1c04, 0x240c1c2c, 0x240c241c, 0x240c2c0c, 0x240c2c2c, 0x2414040c, - 0x2414041c, 0x24140c04, 0x24140c2c, 0x2414140c, 0x24141c1c, 0x24142404, 0x24142c3e, 0x24143414, - 0x24143e04, 0x241c0424, 0x241c0c0c, 0x241c0c1c, 0x241c1404, 0x241c1414, 0x241c1c0c, 0x241c1c2c, - 0x24240404, 0x24240414, 0x24241424, 0x24241c3e, 0x24242404, 0x24243e0c, 0x242c042c, 0x242c043e, - 0x242c140c, 0x242c3414, 0x24340c1c, 0x24341c24, 0x24343404, 0x243e0c04, 0x243e0c2c, 0x243e1c04, - 0x243e241c, 0x243e2c0c, 0x2c040414, 0x2c040c04, 0x2c040c24, 0x2c041414, 0x2c042404, 0x2c042424, - 0x2c04243e, 0x2c042c14, 0x2c043434, 0x2c043e24, 0x2c0c040c, 0x2c0c041c, 0x2c0c042c, 0x2c0c0c14, - 0x2c0c140c, 0x2c0c1c14, 0x2c0c3e14, 0x2c140404, 0x2c140c0c, 0x2c14141c, 0x2c141c04, 0x2c141c34, - 0x2c142c1c, 0x2c1c0414, 0x2c1c043e, 0x2c1c0c04, 0x2c1c143e, 0x2c1c2424, 0x2c1c2c0c, 0x2c1c342c, - 0x2c1c3e1c, 0x2c24040c, 0x2c240424, 0x2c241404, 0x2c241c14, 0x2c242434, 0x2c2c0c14, 0x2c2c1434, - 0x2c2c2c0c, 0x2c2c2c1c, 0x2c342414, 0x2c3e0414, 0x2c3e0424, 0x2c3e1414, 0x34040c0c, 0x34040c1c, - 0x34040c2c, 0x34041c0c, 0x34041c1c, 0x34043404, 0x340c0404, 0x340c1404, 0x340c143e, 0x340c3424, - 0x34140c14, 0x34141c24, 0x34142414, 0x34142c2c, 0x34143414, 0x34143e04, 0x341c0404, 0x341c0c24, - 0x341c140c, 0x341c2404, 0x3424142c, 0x3424241c, 0x34243414, 0x342c0404, 0x342c041c, 0x342c1c24, - 0x342c3404, 0x3434042c, 0x34342404, 0x343e0c0c, 0x343e0c1c, 0x3e040404, 0x3e040424, 0x3e04043e, - 0x3e041404, 0x3e041414, 0x3e041c34, 0x3e042404, 0x3e042c24, 0x3e043414, 0x3e0c0414, 0x3e0c0c0c, - 0x3e0c1424, 0x3e0c241c, 0x3e0c242c, 0x3e14040c, 0x3e140424, 0x3e140c04, 0x3e140c34, 0x3e14140c, - 0x3e141c04, 0x3e142c0c, 0x3e1c0414, 0x3e1c1c14, 0x3e1c1c2c, 0x3e1c2c1c, 0x3e24040c, 0x3e24042c, - 0x3e240c1c, 0x3e241404, 0x3e242c04, 0x3e2c1414, 0x3e2c2414, 0x3e340414, 0x3e341c0c, 0x3e3e0404, -}; - -#define IQ1S_DELTA 0.125f -#define IQ1M_DELTA 0.125f -static const __device__ uint64_t iq1s_grid_gpu[2048] = { - 0x00000000, 0x00000002, 0x00000101, 0x00000200, 0x00000202, 0x00010001, 0x00010101, 0x00020000, - 0x00020002, 0x00020200, 0x00020202, 0x01000101, 0x01010001, 0x01010100, 0x01010102, 0x01020101, - 0x02000000, 0x02000002, 0x02000200, 0x02000202, 0x02010101, 0x02020000, 0x02020002, 0x02020200, - 0x02020202, 0x00000110, 0x00000111, 0x00010011, 0x00010110, 0x00010112, 0x00010211, 0x00010212, - 0x00020111, 0x01000011, 0x01000112, 0x01000211, 0x01010012, 0x01010111, 0x01010212, 0x01020011, - 0x01020110, 0x01020112, 0x01020210, 0x02000111, 0x02010011, 0x02010110, 0x02010112, 0x02020111, - 0x00000020, 0x00000022, 0x00000220, 0x00000222, 0x00010121, 0x00020020, 0x00020022, 0x00020220, - 0x00020222, 0x01000121, 0x01010021, 0x01010221, 0x01020120, 0x01020221, 0x02000020, 0x02000022, - 0x02000220, 0x02000222, 0x02010021, 0x02010121, 0x02010221, 0x02020020, 0x02020022, 0x02020220, - 0x02020222, 0x00011001, 0x00011100, 0x00011102, 0x00021101, 0x01001001, 0x01001201, 0x01011101, - 0x01011202, 0x01021100, 0x01021101, 0x02011001, 0x02011201, 0x02021101, 0x00001011, 0x00001110, - 0x00001111, 0x00001112, 0x00011111, 0x00011210, 0x00011212, 0x00021211, 0x01001010, 0x01001111, - 0x01001212, 0x01011010, 0x01011011, 0x01011110, 0x01011111, 0x01011112, 0x01011211, 0x01021010, - 0x01021012, 0x01021111, 0x01021210, 0x01021212, 0x02001011, 0x02011011, 0x02011111, 0x02011210, - 0x02011212, 0x02021011, 0x02021110, 0x02021111, 0x02021112, 0x02021211, 0x00011120, 0x00011221, - 0x01001021, 0x01001120, 0x01011020, 0x01011022, 0x01011121, 0x01011220, 0x01021020, 0x01021021, - 0x01021122, 0x01021221, 0x02001121, 0x02011021, 0x02011120, 0x02011221, 0x00002000, 0x00002002, - 0x00002200, 0x00002202, 0x00012101, 0x00022000, 0x00022002, 0x00022200, 0x00022202, 0x01002101, - 0x01012001, 0x01012102, 0x01022101, 0x02002000, 0x02002002, 0x02002200, 0x02002202, 0x02012101, - 0x02022000, 0x02022002, 0x02022200, 0x02022202, 0x00002111, 0x00012011, 0x00012110, 0x00012211, - 0x00022110, 0x00022111, 0x01002011, 0x01012010, 0x01012011, 0x01012111, 0x01022011, 0x01022110, - 0x01022211, 0x02012011, 0x02012110, 0x02012112, 0x02012211, 0x02022111, 0x00002020, 0x00002022, - 0x00002220, 0x00002222, 0x00012121, 0x00022020, 0x00022022, 0x00022220, 0x00022222, 0x01002121, - 0x01012021, 0x01012221, 0x01022021, 0x01022121, 0x02002020, 0x02002022, 0x02002121, 0x02002220, - 0x02002222, 0x02012121, 0x02022020, 0x02022022, 0x02022220, 0x02022222, 0x00110000, 0x00110001, - 0x00110100, 0x00110201, 0x00120100, 0x00120101, 0x01100001, 0x01100100, 0x01110000, 0x01110101, - 0x01110200, 0x01120001, 0x01120100, 0x01120101, 0x01120201, 0x02110001, 0x02110100, 0x02110102, - 0x02120001, 0x02120101, 0x00100011, 0x00100110, 0x00100112, 0x00100211, 0x00110010, 0x00110012, - 0x00110111, 0x00110210, 0x00120011, 0x00120110, 0x00120211, 0x01100111, 0x01100212, 0x01110010, - 0x01110011, 0x01110012, 0x01110110, 0x01110111, 0x01110112, 0x01110211, 0x01120010, 0x01120111, - 0x02100110, 0x02110012, 0x02110111, 0x02120011, 0x02120110, 0x00110021, 0x00110120, 0x00110122, - 0x00120121, 0x01100020, 0x01100122, 0x01100221, 0x01110022, 0x01110121, 0x01110220, 0x01110222, - 0x01120120, 0x01120122, 0x02100121, 0x02110021, 0x02110120, 0x02110122, 0x02120121, 0x00101001, - 0x00101102, 0x00101201, 0x00111100, 0x00111101, 0x00111200, 0x00111201, 0x00121001, 0x00121102, - 0x01101001, 0x01101101, 0x01101102, 0x01101200, 0x01101202, 0x01111001, 0x01111100, 0x01111101, - 0x01111102, 0x01111201, 0x01121002, 0x01121101, 0x01121200, 0x02101100, 0x02101201, 0x02111000, - 0x02111100, 0x02111101, 0x02111200, 0x02111201, 0x02111202, 0x02121001, 0x02121100, 0x02121101, - 0x02121201, 0x00101012, 0x00101111, 0x00101212, 0x00111011, 0x00111110, 0x00111111, 0x00111112, - 0x00111211, 0x00121010, 0x00121012, 0x00121111, 0x00121210, 0x00121212, 0x01101011, 0x01101110, - 0x01101111, 0x01101112, 0x01111011, 0x01111012, 0x01111110, 0x01111111, 0x01111112, 0x01111211, - 0x01111212, 0x01121011, 0x01121110, 0x01121111, 0x01121112, 0x01121211, 0x02101010, 0x02101012, - 0x02101110, 0x02101111, 0x02101210, 0x02101212, 0x02111010, 0x02111011, 0x02111110, 0x02111111, - 0x02111112, 0x02111211, 0x02111212, 0x02121010, 0x02121012, 0x02121111, 0x00101021, 0x00101120, - 0x00101121, 0x00101122, 0x00111121, 0x00111122, 0x00111220, 0x00111222, 0x00121021, 0x00121122, - 0x01101020, 0x01101022, 0x01101120, 0x01101121, 0x01101220, 0x01101222, 0x01111021, 0x01111121, - 0x01111122, 0x01111220, 0x01111221, 0x01121021, 0x01121120, 0x01121121, 0x01121220, 0x01121221, - 0x01121222, 0x02101122, 0x02101222, 0x02111022, 0x02111121, 0x02121120, 0x02121221, 0x00112001, - 0x00112102, 0x00122101, 0x01102001, 0x01102100, 0x01102102, 0x01102201, 0x01112000, 0x01112101, - 0x01112200, 0x01112202, 0x01122000, 0x01122001, 0x01122100, 0x01122102, 0x01122201, 0x02102101, - 0x02112001, 0x02112100, 0x02122101, 0x00112010, 0x00112012, 0x00112111, 0x00112212, 0x00122011, - 0x00122111, 0x01102012, 0x01102110, 0x01102111, 0x01102210, 0x01112011, 0x01112110, 0x01112111, - 0x01112112, 0x01112211, 0x01112212, 0x01122010, 0x01122111, 0x01122212, 0x02102211, 0x02112011, - 0x02112012, 0x02112111, 0x02112210, 0x02122011, 0x02122112, 0x02122211, 0x00102221, 0x00112122, - 0x00122120, 0x00122122, 0x01102120, 0x01102122, 0x01102221, 0x01112020, 0x01112022, 0x01112121, - 0x01112220, 0x01122021, 0x01122122, 0x01122221, 0x02102121, 0x02112021, 0x02112122, 0x02112222, - 0x00200000, 0x00200002, 0x00200200, 0x00200202, 0x00210101, 0x00220000, 0x00220002, 0x00220101, - 0x00220200, 0x00220202, 0x01200101, 0x01210001, 0x01210201, 0x01220001, 0x01220101, 0x02200000, - 0x02200002, 0x02200200, 0x02200202, 0x02210101, 0x02220000, 0x02220002, 0x02220101, 0x02220200, - 0x02220202, 0x00200111, 0x00210011, 0x00210110, 0x00210211, 0x00220111, 0x01200012, 0x01200110, - 0x01200211, 0x01210111, 0x01210210, 0x01210212, 0x01220011, 0x01220110, 0x01220111, 0x01220112, - 0x02200111, 0x02210010, 0x02210112, 0x02210211, 0x02220111, 0x00200021, 0x00200220, 0x00200222, - 0x00210021, 0x00210121, 0x00220020, 0x00220022, 0x00220220, 0x00220222, 0x01200121, 0x01210021, - 0x01210122, 0x01210221, 0x01220121, 0x02200021, 0x02200220, 0x02200222, 0x02210021, 0x02210121, - 0x02220020, 0x02220022, 0x02220220, 0x02220222, 0x00201101, 0x00211100, 0x00211102, 0x00211201, - 0x00221101, 0x01201100, 0x01201101, 0x01201102, 0x01201201, 0x01211002, 0x01211101, 0x01211200, - 0x01211202, 0x01221102, 0x02201101, 0x02211001, 0x02211100, 0x02211201, 0x02221001, 0x02221101, - 0x00201211, 0x00211111, 0x00221011, 0x00221211, 0x01201010, 0x01201111, 0x01201210, 0x01211011, - 0x01211110, 0x01211111, 0x01211211, 0x01221012, 0x01221111, 0x01221210, 0x02201211, 0x02211010, - 0x02211110, 0x02211111, 0x02211210, 0x02211212, 0x02221011, 0x02221110, 0x02221112, 0x02221211, - 0x00201121, 0x00211020, 0x00211022, 0x00211221, 0x00221121, 0x01201021, 0x01201221, 0x01211121, - 0x01221020, 0x01221021, 0x01221221, 0x02201120, 0x02201122, 0x02211020, 0x02211222, 0x00202000, - 0x00202002, 0x00202200, 0x00202202, 0x00212101, 0x00222000, 0x00222002, 0x00222200, 0x00222202, - 0x01202101, 0x01212001, 0x01212100, 0x01222101, 0x02202000, 0x02202002, 0x02202200, 0x02202202, - 0x02222000, 0x02222002, 0x02222200, 0x02222202, 0x00202211, 0x00212011, 0x00212110, 0x00212211, - 0x00222111, 0x01202112, 0x01202211, 0x01212012, 0x01212111, 0x01222011, 0x01222110, 0x01222112, - 0x01222211, 0x02202111, 0x02212010, 0x02212112, 0x02212211, 0x02222110, 0x02222111, 0x00202020, - 0x00202022, 0x00202220, 0x00202222, 0x00222020, 0x00222022, 0x00222220, 0x00222222, 0x01202121, - 0x01212021, 0x01212122, 0x01212221, 0x01222121, 0x02202020, 0x02202022, 0x02202220, 0x02202222, - 0x02212121, 0x02222020, 0x02222022, 0x02222220, 0x02222222, 0x10000101, 0x10010001, 0x10010102, - 0x10020101, 0x11000201, 0x11010002, 0x11010101, 0x11010200, 0x11010202, 0x11020001, 0x11020100, - 0x11020102, 0x12010100, 0x12010201, 0x12020001, 0x12020102, 0x10000010, 0x10000011, 0x10000110, - 0x10000112, 0x10000211, 0x10010012, 0x10010111, 0x10010112, 0x10010210, 0x10010212, 0x10020011, - 0x10020112, 0x10020211, 0x11000111, 0x11000210, 0x11000212, 0x11010011, 0x11010110, 0x11010111, - 0x11010112, 0x11010211, 0x11010212, 0x11020111, 0x11020210, 0x11020212, 0x12000011, 0x12000110, - 0x12000112, 0x12010010, 0x12010012, 0x12010111, 0x12020010, 0x12020011, 0x12020012, 0x10000121, - 0x10010021, 0x10010120, 0x10010122, 0x10020121, 0x11000021, 0x11010022, 0x11010121, 0x11010222, - 0x11020120, 0x11020221, 0x12000221, 0x12010120, 0x12020121, 0x10001001, 0x10011101, 0x10011201, - 0x10021201, 0x11001101, 0x11001200, 0x11001202, 0x11011001, 0x11011100, 0x11011101, 0x11011102, - 0x11021001, 0x11021002, 0x11021101, 0x11021200, 0x11021202, 0x12001001, 0x12001102, 0x12001201, - 0x12011000, 0x12011002, 0x12011101, 0x12021000, 0x12021001, 0x12021201, 0x10001011, 0x10001012, - 0x10001111, 0x10001212, 0x10011011, 0x10011110, 0x10011111, 0x10011112, 0x10011211, 0x10021010, - 0x10021111, 0x10021212, 0x11001011, 0x11001110, 0x11001111, 0x11001112, 0x11001211, 0x11011010, - 0x11011011, 0x11011110, 0x11011111, 0x11011112, 0x11011210, 0x11011211, 0x11021011, 0x11021110, - 0x11021111, 0x11021112, 0x11021211, 0x12001012, 0x12001110, 0x12001111, 0x12001210, 0x12011011, - 0x12011110, 0x12011111, 0x12011112, 0x12011211, 0x12011212, 0x12021111, 0x12021210, 0x12021212, - 0x10001021, 0x10001121, 0x10001221, 0x10011120, 0x10011121, 0x10011220, 0x10011222, 0x10021021, - 0x10021120, 0x10021221, 0x11001020, 0x11001022, 0x11001121, 0x11001220, 0x11011020, 0x11011021, - 0x11011022, 0x11011121, 0x11011122, 0x11011221, 0x11021022, 0x11021121, 0x11021220, 0x12001021, - 0x12001121, 0x12001222, 0x12011120, 0x12011121, 0x12021021, 0x12021120, 0x12021122, 0x10002101, - 0x10012001, 0x10012101, 0x10012202, 0x10022101, 0x11002002, 0x11002201, 0x11012000, 0x11012101, - 0x11012200, 0x11022001, 0x11022100, 0x11022102, 0x11022201, 0x12002101, 0x12012001, 0x12012100, - 0x12012102, 0x12012201, 0x12022101, 0x10002011, 0x10002111, 0x10002112, 0x10002212, 0x10012010, - 0x10012110, 0x10012111, 0x10012210, 0x10022011, 0x10022110, 0x10022112, 0x11002010, 0x11002111, - 0x11002212, 0x11012011, 0x11012012, 0x11012110, 0x11012111, 0x11012112, 0x11012211, 0x11022010, - 0x11022012, 0x11022111, 0x11022112, 0x11022212, 0x12002112, 0x12002211, 0x12012012, 0x12012111, - 0x12012112, 0x12012210, 0x12022011, 0x12022110, 0x12022112, 0x12022211, 0x10012122, 0x11002120, - 0x11002122, 0x11002221, 0x11012121, 0x11012220, 0x11012222, 0x11022120, 0x11022221, 0x12012120, - 0x12022121, 0x10100001, 0x10100100, 0x10100101, 0x10100102, 0x10100201, 0x10110002, 0x10110101, - 0x10110202, 0x10120001, 0x10120100, 0x10120201, 0x11100000, 0x11100101, 0x11100200, 0x11110001, - 0x11110100, 0x11110101, 0x11110102, 0x11110201, 0x11120101, 0x11120200, 0x12100102, 0x12100201, - 0x12110101, 0x12110200, 0x12120000, 0x12120001, 0x12120102, 0x12120201, 0x10100111, 0x10100210, - 0x10100211, 0x10100212, 0x10110011, 0x10110110, 0x10110111, 0x10110112, 0x10110210, 0x10110211, - 0x10120010, 0x10120111, 0x10120112, 0x10120210, 0x10120212, 0x11100011, 0x11100110, 0x11100111, - 0x11100112, 0x11100211, 0x11110010, 0x11110011, 0x11110012, 0x11110110, 0x11110111, 0x11110112, - 0x11110210, 0x11110211, 0x11110212, 0x11120011, 0x11120110, 0x11120111, 0x11120112, 0x11120211, - 0x12100012, 0x12100111, 0x12110011, 0x12110110, 0x12110111, 0x12110112, 0x12110211, 0x12120010, - 0x12120111, 0x12120212, 0x10100021, 0x10100122, 0x10110022, 0x10110121, 0x10110222, 0x10120021, - 0x10120120, 0x11100022, 0x11100121, 0x11100222, 0x11110021, 0x11110120, 0x11110121, 0x11110122, - 0x11110221, 0x11120022, 0x11120121, 0x12100121, 0x12110020, 0x12110022, 0x12110121, 0x12110221, - 0x12110222, 0x12120120, 0x10101100, 0x10101101, 0x10111001, 0x10111100, 0x10111101, 0x10111102, - 0x10111200, 0x10111201, 0x10121001, 0x10121101, 0x10121200, 0x10121202, 0x11101001, 0x11101100, - 0x11101101, 0x11101102, 0x11101201, 0x11101202, 0x11111000, 0x11111001, 0x11111100, 0x11111101, - 0x11111102, 0x11111200, 0x11111201, 0x11111202, 0x11121001, 0x11121002, 0x11121100, 0x11121101, - 0x11121102, 0x11121201, 0x12101000, 0x12101200, 0x12101202, 0x12111001, 0x12111100, 0x12111101, - 0x12111102, 0x12111201, 0x12121001, 0x12121100, 0x12121101, 0x12121202, 0x10101011, 0x10101012, - 0x10101110, 0x10101111, 0x10101112, 0x10101211, 0x10111010, 0x10111011, 0x10111012, 0x10111110, - 0x10111111, 0x10111112, 0x10111211, 0x10111212, 0x10121011, 0x10121110, 0x10121111, 0x10121112, - 0x10121211, 0x11101010, 0x11101011, 0x11101012, 0x11101110, 0x11101111, 0x11101112, 0x11101210, - 0x11101211, 0x11111010, 0x11111011, 0x11111012, 0x11111110, 0x11111111, 0x11111112, 0x11111210, - 0x11111211, 0x11111212, 0x11121010, 0x11121011, 0x11121110, 0x11121111, 0x11121112, 0x11121210, - 0x11121211, 0x11121212, 0x12101011, 0x12101110, 0x12101111, 0x12101211, 0x12101212, 0x12111010, - 0x12111011, 0x12111110, 0x12111111, 0x12111112, 0x12111210, 0x12111211, 0x12121011, 0x12121110, - 0x12121111, 0x12121112, 0x12121211, 0x10101020, 0x10101021, 0x10101022, 0x10101120, 0x10101122, - 0x10101220, 0x10101221, 0x10111021, 0x10111120, 0x10111121, 0x10111220, 0x10111221, 0x10121020, - 0x10121021, 0x10121022, 0x10121120, 0x10121121, 0x10121122, 0x10121220, 0x10121221, 0x11101021, - 0x11101121, 0x11101122, 0x11101220, 0x11101221, 0x11101222, 0x11111020, 0x11111021, 0x11111022, - 0x11111120, 0x11111121, 0x11111122, 0x11111220, 0x11111221, 0x11111222, 0x11121021, 0x11121120, - 0x11121121, 0x11121221, 0x12101022, 0x12101121, 0x12101122, 0x12101220, 0x12101221, 0x12101222, - 0x12111021, 0x12111121, 0x12111222, 0x12121022, 0x12121121, 0x12121122, 0x12121220, 0x12121221, - 0x10102100, 0x10102101, 0x10102102, 0x10102201, 0x10112000, 0x10112101, 0x10112200, 0x10122001, - 0x10122202, 0x11102101, 0x11102200, 0x11102202, 0x11112001, 0x11112100, 0x11112101, 0x11112102, - 0x11112200, 0x11112201, 0x11122000, 0x11122002, 0x11122100, 0x11122101, 0x12102002, 0x12102201, - 0x12112000, 0x12112002, 0x12112101, 0x12112200, 0x12122001, 0x12122201, 0x10102011, 0x10102012, - 0x10102111, 0x10102212, 0x10112011, 0x10112110, 0x10112111, 0x10112112, 0x10112211, 0x10122111, - 0x11102011, 0x11102110, 0x11102111, 0x11102112, 0x11102211, 0x11112010, 0x11112011, 0x11112012, - 0x11112110, 0x11112111, 0x11112112, 0x11112210, 0x11112211, 0x11112212, 0x11122011, 0x11122110, - 0x11122111, 0x11122112, 0x11122211, 0x12102011, 0x12102111, 0x12102211, 0x12112011, 0x12112110, - 0x12112111, 0x12112112, 0x12112210, 0x12112211, 0x12122111, 0x10102120, 0x10102220, 0x10112121, - 0x10112222, 0x10122020, 0x10122121, 0x10122122, 0x10122221, 0x11102121, 0x11102220, 0x11102221, - 0x11112021, 0x11112121, 0x11112122, 0x11112220, 0x11112221, 0x11122022, 0x11122121, 0x11122220, - 0x11122222, 0x12102021, 0x12102222, 0x12112022, 0x12112121, 0x12112122, 0x12112220, 0x12112222, - 0x12122021, 0x10200101, 0x10210100, 0x10210102, 0x10210201, 0x10220101, 0x11200100, 0x11210000, - 0x11210101, 0x11210102, 0x11210200, 0x11210202, 0x11220001, 0x11220100, 0x11220102, 0x11220201, - 0x12200001, 0x12210102, 0x12220101, 0x10200011, 0x10200110, 0x10200112, 0x10200211, 0x10210012, - 0x10210111, 0x10220011, 0x10220012, 0x10220112, 0x10220211, 0x11200111, 0x11200211, 0x11210011, - 0x11210111, 0x11210112, 0x11210211, 0x11220111, 0x11220112, 0x11220212, 0x12200110, 0x12200212, - 0x12210012, 0x12210111, 0x12220011, 0x12220112, 0x12220211, 0x10210021, 0x10210122, 0x10210221, - 0x11200020, 0x11200021, 0x11200122, 0x11210121, 0x11210122, 0x11210220, 0x11220020, 0x12200121, - 0x12210021, 0x12210122, 0x12220121, 0x10211001, 0x10211002, 0x10211101, 0x10211102, 0x10211202, - 0x10221001, 0x10221102, 0x10221201, 0x11201000, 0x11201002, 0x11201101, 0x11201200, 0x11201202, - 0x11211001, 0x11211100, 0x11211101, 0x11211102, 0x11211201, 0x11211202, 0x11221000, 0x11221002, - 0x11221101, 0x12201100, 0x12201101, 0x12201201, 0x12211000, 0x12211002, 0x12211100, 0x12211101, - 0x12211102, 0x12211200, 0x12211202, 0x12221001, 0x12221100, 0x12221201, 0x10201111, 0x10201210, - 0x10201212, 0x10211011, 0x10211111, 0x10211112, 0x10211211, 0x11201110, 0x11201111, 0x11201112, - 0x11201211, 0x11211010, 0x11211011, 0x11211110, 0x11211111, 0x11211112, 0x11211211, 0x11221011, - 0x11221110, 0x11221111, 0x11221112, 0x11221211, 0x12201112, 0x12201211, 0x12201212, 0x12211011, - 0x12211111, 0x12211112, 0x12211211, 0x12211212, 0x12221012, 0x12221111, 0x12221112, 0x12221210, - 0x10201022, 0x10201221, 0x10211121, 0x10221020, 0x10221122, 0x10221220, 0x10221221, 0x11201020, - 0x11201121, 0x11201220, 0x11201222, 0x11211021, 0x11211120, 0x11211121, 0x11211122, 0x11211220, - 0x11211222, 0x11221020, 0x11221121, 0x11221220, 0x12201020, 0x12201022, 0x12201121, 0x12201222, - 0x12211120, 0x12211122, 0x12211220, 0x12211221, 0x12221020, 0x12221120, 0x12221122, 0x12221222, - 0x10212102, 0x10212201, 0x10222101, 0x11202001, 0x11212002, 0x11212101, 0x11212202, 0x11222001, - 0x11222201, 0x12202101, 0x12212001, 0x12212200, 0x12222102, 0x10202011, 0x10202110, 0x10212010, - 0x10212111, 0x10222011, 0x10222110, 0x10222112, 0x10222211, 0x11202010, 0x11202011, 0x11202111, - 0x11202112, 0x11202210, 0x11212011, 0x11212110, 0x11212111, 0x11212112, 0x11212211, 0x11222010, - 0x11222111, 0x11222212, 0x12202012, 0x12202110, 0x12202212, 0x12212111, 0x12222011, 0x12222110, - 0x12222111, 0x12222211, 0x10212021, 0x10212122, 0x10212220, 0x11202021, 0x11202120, 0x11202221, - 0x11212020, 0x11212121, 0x11212220, 0x11212222, 0x11222120, 0x11222121, 0x11222221, 0x12202122, - 0x12212120, 0x12212220, 0x12212222, 0x12222122, 0x20000000, 0x20000002, 0x20000200, 0x20000202, - 0x20020000, 0x20020002, 0x20020200, 0x20020202, 0x21000101, 0x21010000, 0x21010001, 0x21010100, - 0x21010102, 0x21010201, 0x21020101, 0x22000000, 0x22000002, 0x22000200, 0x22000202, 0x22010101, - 0x22020000, 0x22020002, 0x22020200, 0x22020202, 0x20000111, 0x20010011, 0x20010110, 0x20010112, - 0x20010211, 0x20020111, 0x21000011, 0x21000110, 0x21000211, 0x21010010, 0x21010012, 0x21010111, - 0x21010112, 0x21010210, 0x21010211, 0x21020110, 0x21020112, 0x21020211, 0x22000111, 0x22000211, - 0x22010110, 0x22010112, 0x22010211, 0x22020111, 0x20000020, 0x20000022, 0x20000220, 0x20000222, - 0x20010121, 0x20020020, 0x20020022, 0x20020220, 0x20020222, 0x21010021, 0x21010120, 0x21010221, - 0x21020121, 0x22000020, 0x22000022, 0x22000220, 0x22000222, 0x22010121, 0x22020020, 0x22020022, - 0x22020220, 0x22020222, 0x20011100, 0x20011201, 0x21001001, 0x21001100, 0x21011001, 0x21011101, - 0x21011202, 0x21021001, 0x21021100, 0x21021201, 0x22011100, 0x22011201, 0x20001011, 0x20001211, - 0x20011012, 0x20011111, 0x20011212, 0x20021112, 0x20021211, 0x21001010, 0x21001011, 0x21001111, - 0x21001210, 0x21011011, 0x21011110, 0x21011111, 0x21011112, 0x21011211, 0x21011212, 0x21021111, - 0x21021112, 0x21021210, 0x21021212, 0x22001011, 0x22001110, 0x22001112, 0x22001211, 0x22011010, - 0x22011012, 0x22011111, 0x22011210, 0x22021112, 0x20011021, 0x20011122, 0x20011221, 0x20021121, - 0x21001021, 0x21001120, 0x21001221, 0x21001222, 0x21011020, 0x21011121, 0x21011221, 0x21011222, - 0x21021021, 0x21021122, 0x21021222, 0x22001121, 0x22011021, 0x22011222, 0x22021120, 0x20002000, - 0x20002002, 0x20002200, 0x20002202, 0x20012101, 0x20022000, 0x20022002, 0x20022200, 0x20022202, - 0x21002001, 0x21002101, 0x21012001, 0x21012100, 0x21012201, 0x21022101, 0x21022201, 0x22002000, - 0x22002002, 0x22002200, 0x22002202, 0x22012101, 0x22022000, 0x22022002, 0x22022200, 0x22022202, - 0x20002111, 0x20002112, 0x20012011, 0x20012110, 0x20012112, 0x20022111, 0x21002011, 0x21002110, - 0x21002112, 0x21002211, 0x21012010, 0x21012012, 0x21012111, 0x21012212, 0x21022011, 0x21022110, - 0x22002111, 0x22012112, 0x22012211, 0x22022111, 0x20002020, 0x20002022, 0x20002220, 0x20002222, - 0x20012121, 0x20022020, 0x20022022, 0x20022220, 0x20022222, 0x21002121, 0x21012021, 0x21012120, - 0x21012122, 0x22002020, 0x22002022, 0x22002220, 0x22002222, 0x22012121, 0x22022020, 0x22022022, - 0x22022220, 0x22022222, 0x20100101, 0x20110001, 0x20110102, 0x20110200, 0x20110201, 0x20120101, - 0x21100001, 0x21100102, 0x21100201, 0x21110101, 0x21110200, 0x21110202, 0x21120201, 0x21120202, - 0x22100101, 0x22110001, 0x22110100, 0x22110102, 0x22110201, 0x22120101, 0x20100011, 0x20100110, - 0x20100112, 0x20100211, 0x20110010, 0x20110111, 0x20110210, 0x20110212, 0x20120011, 0x20120110, - 0x20120112, 0x20120211, 0x21100010, 0x21100111, 0x21110010, 0x21110011, 0x21110110, 0x21110111, - 0x21110112, 0x21110211, 0x21120012, 0x21120111, 0x22100110, 0x22100112, 0x22110012, 0x22110111, - 0x22110210, 0x22120011, 0x22120110, 0x22120112, 0x22120211, 0x20100121, 0x20110021, 0x20110120, - 0x20110221, 0x20120121, 0x21100120, 0x21100122, 0x21100221, 0x21110020, 0x21110022, 0x21110121, - 0x21110220, 0x21120122, 0x21120221, 0x22100121, 0x22110120, 0x22110122, 0x22120221, 0x20101001, - 0x20101100, 0x20101102, 0x20111000, 0x20111101, 0x20111200, 0x20121102, 0x21101000, 0x21101202, - 0x21111001, 0x21111100, 0x21111101, 0x21111102, 0x21111200, 0x21111201, 0x21121000, 0x21121001, - 0x21121002, 0x21121101, 0x22101100, 0x22101102, 0x22111002, 0x22111100, 0x22111101, 0x22111200, - 0x22121001, 0x22121201, 0x20101010, 0x20101111, 0x20101210, 0x20101212, 0x20111010, 0x20111011, - 0x20111110, 0x20111111, 0x20111112, 0x20111211, 0x20121011, 0x20121111, 0x20121211, 0x20121212, - 0x21101011, 0x21101110, 0x21101111, 0x21101112, 0x21101211, 0x21111010, 0x21111011, 0x21111012, - 0x21111110, 0x21111111, 0x21111112, 0x21111210, 0x21111211, 0x21111212, 0x21121011, 0x21121110, - 0x21121111, 0x21121112, 0x21121211, 0x22101011, 0x22101111, 0x22101210, 0x22111011, 0x22111012, - 0x22111110, 0x22111111, 0x22111112, 0x22111211, 0x22111212, 0x22121010, 0x22121012, 0x22121111, - 0x22121210, 0x22121212, 0x20101021, 0x20101120, 0x20111020, 0x20111121, 0x20111221, 0x20121020, - 0x20121122, 0x20121221, 0x21101121, 0x21101220, 0x21101221, 0x21111021, 0x21111022, 0x21111121, - 0x21111122, 0x21111221, 0x21121121, 0x21121220, 0x22101022, 0x22101120, 0x22101221, 0x22101222, - 0x22111022, 0x22111120, 0x22111121, 0x22121120, 0x22121122, 0x22121221, 0x20102101, 0x20112102, - 0x20112201, 0x20122101, 0x21102001, 0x21102102, 0x21112000, 0x21112002, 0x21112101, 0x21112102, - 0x21112202, 0x21122100, 0x21122101, 0x22102101, 0x22112001, 0x22112102, 0x22112201, 0x22122101, - 0x20102110, 0x20102112, 0x20102211, 0x20112010, 0x20112012, 0x20112111, 0x20112210, 0x20112212, - 0x20122010, 0x20122011, 0x20122110, 0x20122112, 0x21102010, 0x21102012, 0x21102111, 0x21102210, - 0x21102212, 0x21112011, 0x21112110, 0x21112111, 0x21112112, 0x21112211, 0x21122012, 0x21122111, - 0x21122112, 0x21122212, 0x22102011, 0x22102110, 0x22112010, 0x22112012, 0x22112111, 0x22112212, - 0x22122011, 0x22122112, 0x20102121, 0x20112121, 0x20122121, 0x21102120, 0x21102122, 0x21102221, - 0x21112020, 0x21112121, 0x21112220, 0x21122021, 0x22102121, 0x22112021, 0x22112120, 0x22112121, - 0x22112122, 0x20200000, 0x20200002, 0x20200200, 0x20200202, 0x20210101, 0x20220000, 0x20220002, - 0x20220200, 0x20220202, 0x21200101, 0x21210001, 0x21210100, 0x21210102, 0x21210201, 0x22200000, - 0x22200002, 0x22200200, 0x22200202, 0x22210101, 0x22220000, 0x22220002, 0x22220200, 0x22220202, - 0x20200111, 0x20200211, 0x20210011, 0x20210110, 0x20210112, 0x20210211, 0x20210212, 0x21200112, - 0x21200211, 0x21210011, 0x21210111, 0x21210210, 0x21210212, 0x21220011, 0x21220110, 0x22200111, - 0x22210010, 0x22210012, 0x22210112, 0x22210211, 0x20200022, 0x20200220, 0x20200222, 0x20210020, - 0x20210221, 0x20220022, 0x20220220, 0x20220222, 0x21200121, 0x21210021, 0x21210122, 0x21210221, - 0x21220121, 0x22200020, 0x22200022, 0x22200220, 0x22200222, 0x22210121, 0x22220020, 0x22220022, - 0x22220220, 0x22220222, 0x20211201, 0x20221101, 0x21201001, 0x21201100, 0x21211000, 0x21211100, - 0x21211101, 0x21211200, 0x21211202, 0x21221001, 0x21221101, 0x21221102, 0x21221200, 0x21221201, - 0x22201101, 0x20201112, 0x20201211, 0x20211010, 0x20211012, 0x20211111, 0x20211210, 0x20221112, - 0x20221211, 0x21201012, 0x21201111, 0x21211011, 0x21211110, 0x21211111, 0x21211112, 0x21211211, - 0x21221111, 0x21221212, 0x22201011, 0x22201110, 0x22201111, 0x22201112, 0x22201211, 0x22211012, - 0x22211111, 0x22211210, 0x20201121, 0x20211021, 0x20211122, 0x20211222, 0x20221021, 0x20221121, - 0x21201120, 0x21201122, 0x21201222, 0x21211022, 0x21211121, 0x21211122, 0x21211220, 0x21221020, - 0x21221022, 0x22201122, 0x22211020, 0x22211121, 0x22211122, 0x22211221, 0x22221021, 0x22221120, - 0x22221122, 0x20202000, 0x20202002, 0x20202200, 0x20202202, 0x20222000, 0x20222002, 0x20222200, - 0x20222202, 0x21212001, 0x21212100, 0x21212102, 0x21212201, 0x22202000, 0x22202002, 0x22202200, - 0x22202202, 0x22212101, 0x22222000, 0x22222002, 0x22222200, 0x22222202, 0x20202111, 0x20212110, - 0x20212211, 0x20222011, 0x20222111, 0x21202011, 0x21212010, 0x21212111, 0x21212212, 0x21222011, - 0x21222112, 0x21222211, 0x22212010, 0x22212112, 0x20202020, 0x20202022, 0x20202220, 0x20202222, - 0x20222020, 0x20222022, 0x20222220, 0x20222222, 0x21212021, 0x21212120, 0x21212122, 0x22202020, - 0x22202022, 0x22202220, 0x22202222, 0x22212121, 0x22222020, 0x22222022, 0x22222220, 0x22222222, -}; - -static const __device__ uint8_t ksigns_iq2xs[128] = { - 0, 129, 130, 3, 132, 5, 6, 135, 136, 9, 10, 139, 12, 141, 142, 15, - 144, 17, 18, 147, 20, 149, 150, 23, 24, 153, 154, 27, 156, 29, 30, 159, - 160, 33, 34, 163, 36, 165, 166, 39, 40, 169, 170, 43, 172, 45, 46, 175, - 48, 177, 178, 51, 180, 53, 54, 183, 184, 57, 58, 187, 60, 189, 190, 63, - 192, 65, 66, 195, 68, 197, 198, 71, 72, 201, 202, 75, 204, 77, 78, 207, - 80, 209, 210, 83, 212, 85, 86, 215, 216, 89, 90, 219, 92, 221, 222, 95, - 96, 225, 226, 99, 228, 101, 102, 231, 232, 105, 106, 235, 108, 237, 238, 111, - 240, 113, 114, 243, 116, 245, 246, 119, 120, 249, 250, 123, 252, 125, 126, 255, -}; - -static const __device__ uint64_t ksigns64[128] = { - 0x0000000000000000, 0xff000000000000ff, 0xff0000000000ff00, 0x000000000000ffff, - 0xff00000000ff0000, 0x0000000000ff00ff, 0x0000000000ffff00, 0xff00000000ffffff, - 0xff000000ff000000, 0x00000000ff0000ff, 0x00000000ff00ff00, 0xff000000ff00ffff, - 0x00000000ffff0000, 0xff000000ffff00ff, 0xff000000ffffff00, 0x00000000ffffffff, - 0xff0000ff00000000, 0x000000ff000000ff, 0x000000ff0000ff00, 0xff0000ff0000ffff, - 0x000000ff00ff0000, 0xff0000ff00ff00ff, 0xff0000ff00ffff00, 0x000000ff00ffffff, - 0x000000ffff000000, 0xff0000ffff0000ff, 0xff0000ffff00ff00, 0x000000ffff00ffff, - 0xff0000ffffff0000, 0x000000ffffff00ff, 0x000000ffffffff00, 0xff0000ffffffffff, - 0xff00ff0000000000, 0x0000ff00000000ff, 0x0000ff000000ff00, 0xff00ff000000ffff, - 0x0000ff0000ff0000, 0xff00ff0000ff00ff, 0xff00ff0000ffff00, 0x0000ff0000ffffff, - 0x0000ff00ff000000, 0xff00ff00ff0000ff, 0xff00ff00ff00ff00, 0x0000ff00ff00ffff, - 0xff00ff00ffff0000, 0x0000ff00ffff00ff, 0x0000ff00ffffff00, 0xff00ff00ffffffff, - 0x0000ffff00000000, 0xff00ffff000000ff, 0xff00ffff0000ff00, 0x0000ffff0000ffff, - 0xff00ffff00ff0000, 0x0000ffff00ff00ff, 0x0000ffff00ffff00, 0xff00ffff00ffffff, - 0xff00ffffff000000, 0x0000ffffff0000ff, 0x0000ffffff00ff00, 0xff00ffffff00ffff, - 0x0000ffffffff0000, 0xff00ffffffff00ff, 0xff00ffffffffff00, 0x0000ffffffffffff, - 0xffff000000000000, 0x00ff0000000000ff, 0x00ff00000000ff00, 0xffff00000000ffff, - 0x00ff000000ff0000, 0xffff000000ff00ff, 0xffff000000ffff00, 0x00ff000000ffffff, - 0x00ff0000ff000000, 0xffff0000ff0000ff, 0xffff0000ff00ff00, 0x00ff0000ff00ffff, - 0xffff0000ffff0000, 0x00ff0000ffff00ff, 0x00ff0000ffffff00, 0xffff0000ffffffff, - 0x00ff00ff00000000, 0xffff00ff000000ff, 0xffff00ff0000ff00, 0x00ff00ff0000ffff, - 0xffff00ff00ff0000, 0x00ff00ff00ff00ff, 0x00ff00ff00ffff00, 0xffff00ff00ffffff, - 0xffff00ffff000000, 0x00ff00ffff0000ff, 0x00ff00ffff00ff00, 0xffff00ffff00ffff, - 0x00ff00ffffff0000, 0xffff00ffffff00ff, 0xffff00ffffffff00, 0x00ff00ffffffffff, - 0x00ffff0000000000, 0xffffff00000000ff, 0xffffff000000ff00, 0x00ffff000000ffff, - 0xffffff0000ff0000, 0x00ffff0000ff00ff, 0x00ffff0000ffff00, 0xffffff0000ffffff, - 0xffffff00ff000000, 0x00ffff00ff0000ff, 0x00ffff00ff00ff00, 0xffffff00ff00ffff, - 0x00ffff00ffff0000, 0xffffff00ffff00ff, 0xffffff00ffffff00, 0x00ffff00ffffffff, - 0xffffffff00000000, 0x00ffffff000000ff, 0x00ffffff0000ff00, 0xffffffff0000ffff, - 0x00ffffff00ff0000, 0xffffffff00ff00ff, 0xffffffff00ffff00, 0x00ffffff00ffffff, - 0x00ffffffff000000, 0xffffffffff0000ff, 0xffffffffff00ff00, 0x00ffffffff00ffff, - 0xffffffffffff0000, 0x00ffffffffff00ff, 0x00ffffffffffff00, 0xffffffffffffffff, -}; - -static const __device__ uint8_t kmask_iq2xs[8] = {1, 2, 4, 8, 16, 32, 64, 128}; -static const __device__ int8_t kvalues_iq4nl[16] = {-127, -104, -83, -65, -49, -35, -22, -10, 1, 13, 25, 38, 53, 69, 89, 113}; - - -typedef half dfloat; // dequantize float -typedef half2 dfloat2; -typedef void (*dequantize_kernel_t)(const void * vx, const int ib, const int iqs, dfloat2 & v); -template -using to_cuda_ggml_t = void (*)(const void * __restrict__ x, dst_t * __restrict__ y, int k, cudaStream_t stream); -typedef float (*vec_dot_q_cuda_t)(const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs); -typedef void (*allocate_tiles_cuda_t)(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc); -typedef void (*load_tiles_cuda_t)( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row); -typedef float (*vec_dot_q_mul_mat_cuda_t)( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ms, const int & i, const int & j, const int & k); - -// Utility function - -template -static __device__ __forceinline__ dst_t convert_from_half(half val) { - return val; -} - -template<> -__device__ __forceinline__ c10::BFloat16 convert_from_half(half val) { -#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 800 - return __float2bfloat16(__half2float(val)); -#else - return __half2float(val); -#endif // defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 800 -} - -template<> -__device__ __forceinline__ float convert_from_half(half val) { - return __half2float(val); -} - -#if defined(USE_ROCM) - -#ifndef __has_builtin - #define __has_builtin(x) 0 -#endif - -typedef int8_t int8x4_t __attribute__((ext_vector_type(4))); -static __device__ __forceinline__ int __vsubss4(const int a, const int b) { - const int8x4_t va = reinterpret_cast(a); - const int8x4_t vb = reinterpret_cast(b); -#if __has_builtin(__builtin_elementwise_sub_sat) - const int8x4_t c = __builtin_elementwise_sub_sat(va, vb); - return reinterpret_cast(c); -#else - int8x4_t c; - int16_t tmp; -#pragma unroll - for (int i = 0; i < 4; i++) { - tmp = va[i] - vb[i]; - if(tmp > std::numeric_limits::max()) tmp = std::numeric_limits::max(); - if(tmp < std::numeric_limits::min()) tmp = std::numeric_limits::min(); - c[i] = tmp; - } - return reinterpret_cast(c); -#endif // __has_builtin(__builtin_elementwise_sub_sat) -} - -static __device__ __forceinline__ int __dp4a(const int a, const int b, int c) { -#if __has_builtin(__builtin_amdgcn_sdot4) - c = __builtin_amdgcn_sdot4(a, b, c, false); -#else - const int8x4_t va = reinterpret_cast(a); - const int8x4_t vb = reinterpret_cast(b); - c += va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2] + va[3] * vb[3]; -#endif - return c; -} - -static __device__ __forceinline__ uint32_t __vcmpeq4(const uint32_t a, const uint32_t b) { - uint32_t neq = a^b; - return !(neq & 0xff000000) * 0xff000000 | - !(neq & 0x00ff0000) * 0x00ff0000 | - !(neq & 0x0000ff00) * 0x0000ff00 | - !(neq & 0x000000ff) * 0x000000ff; -} - -static __device__ __forceinline__ uint32_t __vsub4(const uint32_t a, const uint32_t b) { - return (static_cast(((a & 0xff000000) >> 24) - ((b & 0xff000000) >> 24)) << 24) + - (static_cast(((a & 0x00ff0000) >> 16) - ((b & 0x00ff0000) >> 16)) << 16) + - (static_cast(((a & 0x0000ff00) >> 8) - ((b & 0x0000ff00) >> 8)) << 8) + - (static_cast(((a & 0x000000ff) >> 0) - ((b & 0x000000ff) >> 0)) << 0); -} -#endif // defined(USE_ROCM) diff --git a/csrc/libtorch_stable/quantization/gguf/gguf_kernel.cu b/csrc/libtorch_stable/quantization/gguf/gguf_kernel.cu deleted file mode 100644 index 2a56d7a18f48..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/gguf_kernel.cu +++ /dev/null @@ -1,557 +0,0 @@ -#include -#include - -#include "../../../cuda_compat.h" -#include "../../dispatch_utils.h" -#include "../../torch_utils.h" - -#include - -#include "ggml-common.h" -#include "vecdotq.cuh" -#include "dequantize.cuh" -#include "mmvq.cuh" -#include "mmq.cuh" -#include "moe.cuh" -#include "moe_vec.cuh" - -// Q8 gemv -template -static __global__ void quantize_q8_1(const scalar_t* __restrict__ x, - void* __restrict__ vy, const int kx, - const int kx_padded) { - const auto ix = blockDim.x * blockIdx.x + threadIdx.x; - if (ix >= kx_padded) { - return; - } - const auto iy = blockDim.y * blockIdx.y + threadIdx.y; - const int i_padded = iy * kx_padded + ix; - - block_q8_1* y = (block_q8_1*)vy; - - const int ib = i_padded / QK8_1; // block index - const int iqs = i_padded % QK8_1; // quant index - - const float xi = ix < kx ? static_cast(x[iy * kx + ix]) : 0.0f; - float amax = fabsf(xi); - float sum = xi; - -#pragma unroll - for (int mask = 16; mask > 0; mask >>= 1) { - amax = fmaxf(amax, VLLM_SHFL_XOR_SYNC_WIDTH(amax, mask, 32)); - sum += VLLM_SHFL_XOR_SYNC_WIDTH(sum, mask, 32); - } - - const float d = amax / 127; - const int8_t q = amax == 0.0f ? 0 : roundf(xi / d); - - y[ib].qs[iqs] = q; - - if (iqs > 0) { - return; - } - - y[ib].ds.x = __float2half(d); - y[ib].ds.y = __float2half(sum); -} - -template -static void quantize_row_q8_1_cuda(const scalar_t* x, void* vy, const int kx, - const int ky, cudaStream_t stream) { - const int64_t kx_padded = (kx + 512 - 1) / 512 * 512; - const int block_num_x = - (kx_padded + CUDA_QUANTIZE_BLOCK_SIZE - 1) / CUDA_QUANTIZE_BLOCK_SIZE; - constexpr int MAX_BLOCK_SIZE = 65535; - for (int off = 0; off < ky; off += MAX_BLOCK_SIZE) { - const int num_blocks_y = std::min(ky, off + MAX_BLOCK_SIZE) - off; - const dim3 num_blocks(block_num_x, num_blocks_y, 1); - const dim3 block_size(CUDA_DEQUANTIZE_BLOCK_SIZE, 1, 1); - quantize_q8_1<<>>( - &x[off * kx], (int32_t*)vy + off * (kx_padded / 32 * 9), kx, kx_padded); - } -} - -torch::stable::Tensor ggml_dequantize( - torch::stable::Tensor W, // quant weight - int64_t type, int64_t m, int64_t n, - std::optional const& dtype) { - const torch::stable::accelerator::DeviceGuard device_guard( - W.get_device_index()); - auto dtype_ = dtype.value_or(torch::headeronly::ScalarType::Half); - auto DW = torch::stable::empty({m, n}, dtype_, std::nullopt, W.device()); - cudaStream_t stream = get_current_cuda_stream(); - - VLLM_STABLE_DISPATCH_FLOATING_TYPES(DW.scalar_type(), "ggml_dequantize", [&] { - auto to_cuda = ggml_get_to_cuda(type); - to_cuda((void*)W.data_ptr(), (scalar_t*)DW.data_ptr(), m * n, stream); - }); - - return DW; -} - -torch::stable::Tensor ggml_mul_mat_vec_a8( - torch::stable::Tensor W, // quant weight - torch::stable::Tensor X, // input - int64_t type, int64_t row) { - int col = X.sizes()[1]; - int vecs = X.sizes()[0]; - const int padded = (col + 512 - 1) / 512 * 512; - const torch::stable::accelerator::DeviceGuard device_guard( - X.get_device_index()); - auto Y = torch::stable::empty({vecs, row}, X.scalar_type(), std::nullopt, - W.device()); - cudaStream_t stream = get_current_cuda_stream(); - auto quant_X = torch::stable::empty({vecs, padded / 32 * 9}, - torch::headeronly::ScalarType::Int, - std::nullopt, W.device()); - VLLM_STABLE_DISPATCH_FLOATING_TYPES( - X.scalar_type(), "ggml_mul_mat_vec_a8", [&] { - quantize_row_q8_1_cuda((scalar_t*)X.data_ptr(), - (void*)quant_X.data_ptr(), col, vecs, - stream); - switch (type) { - case 2: - mul_mat_vec_q4_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 3: - mul_mat_vec_q4_1_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 6: - mul_mat_vec_q5_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 7: - mul_mat_vec_q5_1_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 8: - mul_mat_vec_q8_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 10: - mul_mat_vec_q2_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 11: - mul_mat_vec_q3_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 12: - mul_mat_vec_q4_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 13: - mul_mat_vec_q5_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 14: - mul_mat_vec_q6_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 16: - mul_mat_vec_iq2_xxs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 17: - mul_mat_vec_iq2_xs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 18: - mul_mat_vec_iq3_xxs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 19: - mul_mat_vec_iq1_s_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 20: - mul_mat_vec_iq4_nl_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 21: - mul_mat_vec_iq3_s_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 22: - mul_mat_vec_iq2_s_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 23: - mul_mat_vec_iq4_xs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - case 29: - mul_mat_vec_iq1_m_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, vecs, stream); - break; - } - }); - return Y; -} - -torch::stable::Tensor ggml_mul_mat_a8(torch::stable::Tensor W, // quant weight - torch::stable::Tensor X, // input - int64_t type, int64_t row) { - int col = X.sizes()[1]; - int padded = (col + 512 - 1) / 512 * 512; - int batch = X.sizes()[0]; - const torch::stable::accelerator::DeviceGuard device_guard( - X.get_device_index()); - auto Y = torch::stable::empty({batch, row}, X.scalar_type(), std::nullopt, - W.device()); - cudaStream_t stream = get_current_cuda_stream(); - auto quant_X = torch::stable::empty({batch, padded / 32 * 9}, - torch::headeronly::ScalarType::Int, - std::nullopt, W.device()); - VLLM_STABLE_DISPATCH_FLOATING_TYPES(X.scalar_type(), "ggml_mul_mat_a8", [&] { - quantize_row_q8_1_cuda((scalar_t*)X.data_ptr(), (void*)quant_X.data_ptr(), - col, batch, stream); - - switch (type) { - case 2: - ggml_mul_mat_q4_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 3: - ggml_mul_mat_q4_1_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 6: - ggml_mul_mat_q5_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 7: - ggml_mul_mat_q5_1_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 8: - ggml_mul_mat_q8_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 10: - ggml_mul_mat_q2_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 11: - ggml_mul_mat_q3_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 12: - ggml_mul_mat_q4_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 13: - ggml_mul_mat_q5_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - case 14: - ggml_mul_mat_q6_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), col, row, batch, padded, row, stream); - break; - } - }); - return Y; -} - -torch::stable::Tensor ggml_moe_a8(torch::stable::Tensor X, // input - torch::stable::Tensor W, // expert weights - torch::stable::Tensor sorted_token_ids, - torch::stable::Tensor expert_ids, - torch::stable::Tensor num_tokens_post_padded, - int64_t type, int64_t row, int64_t top_k, - int64_t tokens) { - int col = X.sizes()[1]; - int padded = (col + 512 - 1) / 512 * 512; - const torch::stable::accelerator::DeviceGuard device_guard( - X.get_device_index()); - auto Y = torch::stable::empty({tokens * top_k, row}, X.scalar_type(), - std::nullopt, W.device()); - cudaStream_t stream = get_current_cuda_stream(); - auto quant_X = torch::stable::empty({tokens, padded / 32 * 9}, - torch::headeronly::ScalarType::Int, - std::nullopt, W.device()); - VLLM_STABLE_DISPATCH_FLOATING_TYPES(X.scalar_type(), "ggml_moe_a8", [&] { - quantize_row_q8_1_cuda((scalar_t*)X.data_ptr(), (void*)quant_X.data_ptr(), - col, tokens, stream); - switch (type) { - case 2: - ggml_moe_q4_0_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 3: - ggml_moe_q4_1_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 6: - ggml_moe_q5_0_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 7: - ggml_moe_q5_1_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 8: - ggml_moe_q8_0_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 10: - ggml_moe_q2_K_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 11: - ggml_moe_q3_K_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 12: - ggml_moe_q4_K_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 13: - ggml_moe_q5_K_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - case 14: - ggml_moe_q6_K_q8_1_cuda( - (void*)quant_X.data_ptr(), (void*)W.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)sorted_token_ids.data_ptr(), - (int*)expert_ids.data_ptr(), - (int*)num_tokens_post_padded.data_ptr(), W.stride(0), col, row, - tokens, padded, row, top_k, sorted_token_ids.sizes()[0], stream); - break; - } - }); - return Y; -} - -torch::stable::Tensor ggml_moe_a8_vec( - torch::stable::Tensor X, // input - torch::stable::Tensor W, // expert weights - torch::stable::Tensor topk_ids, int64_t top_k, int64_t type, int64_t row, - int64_t tokens) { - int col = X.sizes()[1]; - const int padded = (col + 512 - 1) / 512 * 512; - const torch::stable::accelerator::DeviceGuard device_guard( - X.get_device_index()); - auto Y = torch::stable::empty({tokens * top_k, row}, X.scalar_type(), - std::nullopt, W.device()); - torch::stable::fill_(Y, 0.0); - cudaStream_t stream = get_current_cuda_stream(); - auto quant_X = torch::stable::empty({tokens, padded / 32 * 9}, - torch::headeronly::ScalarType::Int, - std::nullopt, W.device()); - VLLM_STABLE_DISPATCH_FLOATING_TYPES(X.scalar_type(), "ggml_moe_vec_a8", [&] { - quantize_row_q8_1_cuda((scalar_t*)X.data_ptr(), - (void*)quant_X.data_ptr(), col, tokens, - stream); - switch (type) { - case 2: - moe_vec_q4_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 3: - moe_vec_q4_1_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 6: - moe_vec_q5_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 7: - moe_vec_q5_1_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 8: - moe_vec_q8_0_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 10: - moe_vec_q2_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 11: - moe_vec_q3_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 12: - moe_vec_q4_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 13: - moe_vec_q5_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 14: - moe_vec_q6_K_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 16: - moe_vec_iq2_xxs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 17: - moe_vec_iq2_xs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 18: - moe_vec_iq3_xxs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 19: - moe_vec_iq1_s_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 20: - moe_vec_iq4_nl_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 21: - moe_vec_iq3_s_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 22: - moe_vec_iq2_s_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 23: - moe_vec_iq4_xs_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - case 29: - moe_vec_iq1_m_q8_1_cuda( - (void*)W.data_ptr(), (void*)quant_X.data_ptr(), - (scalar_t*)Y.data_ptr(), (int*)topk_ids.data_ptr(), top_k, tokens, - col, row, quant_X.stride(0), stream); - break; - } - }); - return Y; -} - -int64_t ggml_moe_get_block_size(int64_t type) { - switch (type) { - case 2: - return MOE_X_Q4_0; - case 3: - return MOE_X_Q4_1; - case 6: - return MOE_X_Q5_0; - case 7: - return MOE_X_Q5_1; - case 8: - return MOE_X_Q8_0; - case 10: - return MOE_X_Q2_K; - case 11: - return MOE_X_Q3_K; - case 12: - return MOE_X_Q4_K; - case 13: - return MOE_X_Q5_K; - case 14: - return MOE_X_Q6_K; - } - return 0; -} diff --git a/csrc/libtorch_stable/quantization/gguf/mmq.cuh b/csrc/libtorch_stable/quantization/gguf/mmq.cuh deleted file mode 100644 index 7c89918c23d8..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/mmq.cuh +++ /dev/null @@ -1,610 +0,0 @@ -// copied from https://github.com/ggerganov/llama.cpp/blob/b2899/ggml-cuda/mmq.cu -template -static __device__ __forceinline__ void mul_mat_q( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - - const block_q_t * x = (const block_q_t *) vx; - const block_q8_1 * y = (const block_q8_1 *) vy; - - const int blocks_per_row_x = ncols_x / qk; - const int blocks_per_col_y = nrows_y / QK8_1; - const int blocks_per_warp = WARP_SIZE_GGUF / qi; - - const int & ncols_dst = ncols_y; - - const auto row_dst_0 = blockIdx.x*mmq_y; - const int & row_x_0 = row_dst_0; - - const auto col_dst_0 = blockIdx.y*mmq_x; - const int & col_y_0 = col_dst_0; - - int * tile_x_ql = nullptr; - half2 * tile_x_dm = nullptr; - int * tile_x_qh = nullptr; - int * tile_x_sc = nullptr; - - allocate_tiles(&tile_x_ql, &tile_x_dm, &tile_x_qh, &tile_x_sc); - - __shared__ int tile_y_qs[mmq_x * WARP_SIZE_GGUF]; - __shared__ half2 tile_y_ds[mmq_x * WARP_SIZE_GGUF/QI8_1]; - - float sum[mmq_y/WARP_SIZE_GGUF][mmq_x/nwarps] = {{0.0f}}; - - for (int ib0 = 0; ib0 < blocks_per_row_x; ib0 += blocks_per_warp) { - - load_tiles(x + row_x_0*blocks_per_row_x + ib0, tile_x_ql, tile_x_dm, tile_x_qh, tile_x_sc, - threadIdx.y, nrows_x-row_x_0-1, threadIdx.x, blocks_per_row_x); - -#pragma unroll - for (int ir = 0; ir < qr && ib0 + ir * blocks_per_warp/qr < blocks_per_row_x; ++ir) { - const auto kqs = ir*WARP_SIZE_GGUF + threadIdx.x; - const int kbxd = kqs / QI8_1; - -#pragma unroll - for (int i = 0; i < mmq_x; i += nwarps) { - const int col_y_eff = min(col_y_0 + threadIdx.y + i, ncols_y-1); // to prevent out-of-bounds memory accesses - const block_q8_1 * by0 = &y[col_y_eff*blocks_per_col_y + ib0 * (qk/QK8_1) + kbxd]; - const int index_y = (threadIdx.y + i) * WARP_SIZE_GGUF + kqs % WARP_SIZE_GGUF; - tile_y_qs[index_y] = get_int_from_int8_aligned(by0->qs, threadIdx.x % QI8_1); - } - -#pragma unroll - for (int ids0 = 0; ids0 < mmq_x; ids0 += nwarps * QI8_1) { - const int ids = (ids0 + threadIdx.y * QI8_1 + threadIdx.x / (WARP_SIZE_GGUF/QI8_1)) % mmq_x; - const auto kby = threadIdx.x % (WARP_SIZE_GGUF/QI8_1); - const int col_y_eff = min(col_y_0 + ids, ncols_y-1); - - // if the sum is not needed it's faster to transform the scale to f32 ahead of time - const half2 * dsi_src = &y[col_y_eff*blocks_per_col_y + ib0 * (qk/QK8_1) + ir*(WARP_SIZE_GGUF/QI8_1) + kby].ds; - half2 * dsi_dst = &tile_y_ds[ids * (WARP_SIZE_GGUF/QI8_1) + kby]; - if (need_sum) { - *dsi_dst = *dsi_src; - } else { - float * dfi_dst = (float *) dsi_dst; - *dfi_dst = __low2float(*dsi_src); - } - } - - __syncthreads(); - -// #pragma unroll // unrolling this loop causes too much register pressure - for (int k = ir*WARP_SIZE_GGUF/qr; k < (ir+1)*WARP_SIZE_GGUF/qr; k += vdr) { -#pragma unroll - for (int j = 0; j < mmq_x; j += nwarps) { -#pragma unroll - for (int i = 0; i < mmq_y; i += WARP_SIZE_GGUF) { - sum[i/WARP_SIZE_GGUF][j/nwarps] += vec_dot( - tile_x_ql, tile_x_dm, tile_x_qh, tile_x_sc, tile_y_qs, tile_y_ds, - threadIdx.x + i, threadIdx.y + j, k); - } - } - } - __syncthreads(); - } - } - -#pragma unroll - for (int j = 0; j < mmq_x; j += nwarps) { - const auto col_dst = col_dst_0 + j + threadIdx.y; - if (col_dst >= ncols_dst) { - return; - } - -#pragma unroll - for (int i = 0; i < mmq_y; i += WARP_SIZE_GGUF) { - const auto row_dst = row_dst_0 + threadIdx.x + i; - if (row_dst >= nrows_dst) { - continue; - } - dst[col_dst*nrows_dst + row_dst] = sum[i/WARP_SIZE_GGUF][j/nwarps]; - } - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q4_0 64 -#define MMQ_Y_Q4_0 128 -#define NWARPS_Q4_0 8 -#else -#define MMQ_X_Q4_0 4 -#define MMQ_Y_Q4_0 32 -#define NWARPS_Q4_0 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q4_0, 2) -#endif -mul_mat_q4_0( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q4_0; - const int mmq_y = MMQ_Y_Q4_0; - const int nwarps = NWARPS_Q4_0; - - mul_mat_q, - load_tiles_q4_0, VDR_Q4_0_Q8_1_MMQ, vec_dot_q4_0_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q4_0_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - - int mmq_x = MMQ_X_Q4_0; - int mmq_y = MMQ_Y_Q4_0; - int nwarps = NWARPS_Q4_0; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q4_0<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q4_0<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q4_1 64 -#define MMQ_Y_Q4_1 128 -#define NWARPS_Q4_1 8 -#else -#define MMQ_X_Q4_1 4 -#define MMQ_Y_Q4_1 32 -#define NWARPS_Q4_1 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q4_1, 2) -#endif -mul_mat_q4_1( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q4_1; - const int mmq_y = MMQ_Y_Q4_1; - const int nwarps = NWARPS_Q4_1; - - mul_mat_q, - load_tiles_q4_1, VDR_Q4_1_Q8_1_MMQ, vec_dot_q4_1_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q4_1_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - - int mmq_x = MMQ_X_Q4_1; - int mmq_y = MMQ_Y_Q4_1; - int nwarps = NWARPS_Q4_1; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q4_1<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q4_1<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q5_0 64 -#define MMQ_Y_Q5_0 128 -#define NWARPS_Q5_0 8 -#else -#define MMQ_X_Q5_0 4 -#define MMQ_Y_Q5_0 32 -#define NWARPS_Q5_0 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q5_0, 2) -#endif -mul_mat_q5_0( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q5_0; - const int mmq_y = MMQ_Y_Q5_0; - const int nwarps = NWARPS_Q5_0; - - mul_mat_q, - load_tiles_q5_0, VDR_Q5_0_Q8_1_MMQ, vec_dot_q5_0_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q5_0_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - - const int mmq_x = MMQ_X_Q5_0; - const int mmq_y = MMQ_Y_Q5_0; - const int nwarps = NWARPS_Q5_0; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q5_0<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q5_0<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q5_1 64 -#define MMQ_Y_Q5_1 128 -#define NWARPS_Q5_1 8 -#else -#define MMQ_X_Q5_1 4 -#define MMQ_Y_Q5_1 32 -#define NWARPS_Q5_1 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q5_1, 2) -#endif -mul_mat_q5_1( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q5_1; - const int mmq_y = MMQ_Y_Q5_1; - const int nwarps = NWARPS_Q5_1; - - mul_mat_q, - load_tiles_q5_1, VDR_Q5_1_Q8_1_MMQ, vec_dot_q5_1_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q5_1_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - const int mmq_x = MMQ_X_Q5_1; - const int mmq_y = MMQ_Y_Q5_1; - const int nwarps = NWARPS_Q5_1; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q5_1<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q5_1<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q8_0 64 -#define MMQ_Y_Q8_0 128 -#define NWARPS_Q8_0 8 -#else -#define MMQ_X_Q8_0 4 -#define MMQ_Y_Q8_0 32 -#define NWARPS_Q8_0 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q8_0, 2) -#endif -mul_mat_q8_0( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q8_0; - const int mmq_y = MMQ_Y_Q8_0; - const int nwarps = NWARPS_Q8_0; - - mul_mat_q, - load_tiles_q8_0, VDR_Q8_0_Q8_1_MMQ, vec_dot_q8_0_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q8_0_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - const int mmq_x = MMQ_X_Q8_0; - const int mmq_y = MMQ_Y_Q8_0; - const int nwarps = NWARPS_Q8_0; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q8_0<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q8_0<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q2_K 64 -#define MMQ_Y_Q2_K 128 -#define NWARPS_Q2_K 8 -#else -#define MMQ_X_Q2_K 4 -#define MMQ_Y_Q2_K 32 -#define NWARPS_Q2_K 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q2_K, 2) -#endif -mul_mat_q2_K( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q2_K; - const int mmq_y = MMQ_Y_Q2_K; - const int nwarps = NWARPS_Q2_K; - - mul_mat_q, - load_tiles_q2_K, VDR_Q2_K_Q8_1_MMQ, vec_dot_q2_K_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q2_K_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - const int mmq_x = MMQ_X_Q2_K; - const int mmq_y = MMQ_Y_Q2_K; - const int nwarps = NWARPS_Q2_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q2_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q2_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q3_K 64 -#define MMQ_Y_Q3_K 128 -#define NWARPS_Q3_K 8 -#else -#define MMQ_X_Q3_K 4 -#define MMQ_Y_Q3_K 32 -#define NWARPS_Q3_K 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q3_K, 2) -#endif -mul_mat_q3_K( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - - const int mmq_x = MMQ_X_Q3_K; - const int mmq_y = MMQ_Y_Q3_K; - const int nwarps = NWARPS_Q3_K; - - mul_mat_q, - load_tiles_q3_K, VDR_Q3_K_Q8_1_MMQ, vec_dot_q3_K_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q3_K_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - - const int mmq_x = MMQ_X_Q3_K; - const int mmq_y = MMQ_Y_Q3_K; - const int nwarps = NWARPS_Q3_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q3_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q3_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q4_K 64 -#define MMQ_Y_Q4_K 128 -#define NWARPS_Q4_K 8 -#else -#define MMQ_X_Q4_K 4 -#define MMQ_Y_Q4_K 32 -#define NWARPS_Q4_K 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q4_K, 2) -#endif -mul_mat_q4_K( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q4_K; - const int mmq_y = MMQ_Y_Q4_K; - const int nwarps = NWARPS_Q4_K; - - mul_mat_q, - load_tiles_q4_K, VDR_Q4_K_Q8_1_MMQ, vec_dot_q4_K_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q4_K_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - const int mmq_x = MMQ_X_Q4_K; - const int mmq_y = MMQ_Y_Q4_K; - const int nwarps = NWARPS_Q4_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q4_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q4_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q5_K 64 -#define MMQ_Y_Q5_K 128 -#define NWARPS_Q5_K 8 -#else -#define MMQ_X_Q5_K 4 -#define MMQ_Y_Q5_K 32 -#define NWARPS_Q5_K 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q5_K, 2) -#endif -mul_mat_q5_K( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q5_K; - const int mmq_y = MMQ_Y_Q5_K; - const int nwarps = NWARPS_Q5_K; - - mul_mat_q, - load_tiles_q5_K, VDR_Q5_K_Q8_1_MMQ, vec_dot_q5_K_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q5_K_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - - const int mmq_x = MMQ_X_Q5_K; - const int mmq_y = MMQ_Y_Q5_K; - const int nwarps = NWARPS_Q5_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q5_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q5_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} - -#if defined(USE_ROCM) -#define MMQ_X_Q6_K 64 -#define MMQ_Y_Q6_K 128 -#define NWARPS_Q6_K 8 -#else -#define MMQ_X_Q6_K 4 -#define MMQ_Y_Q6_K 32 -#define NWARPS_Q6_K 4 -#endif - -template static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF*NWARPS_Q6_K, 2) -#endif -mul_mat_q6_K( - const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, const int nrows_dst) { - const int mmq_x = MMQ_X_Q6_K; - const int mmq_y = MMQ_Y_Q6_K; - const int nwarps = NWARPS_Q6_K; - - mul_mat_q, - load_tiles_q6_K, VDR_Q6_K_Q8_1_MMQ, vec_dot_q6_K_q8_1_mul_mat> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); -} - -template -static void ggml_mul_mat_q6_K_q8_1_cuda( - const void * vx, const void * vy, scalar_t * dst, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, cudaStream_t stream) { - const int mmq_x = MMQ_X_Q6_K; - const int mmq_y = MMQ_Y_Q6_K; - const int nwarps = NWARPS_Q6_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (ncols_y + mmq_x - 1) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - const bool need_check = false; - mul_mat_q6_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } else { - const bool need_check = true; - mul_mat_q6_K<<>> - (vx, vy, dst, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst); - } -} diff --git a/csrc/libtorch_stable/quantization/gguf/mmvq.cuh b/csrc/libtorch_stable/quantization/gguf/mmvq.cuh deleted file mode 100644 index e27bec7af5b7..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/mmvq.cuh +++ /dev/null @@ -1,212 +0,0 @@ -// copied and adapted from https://github.com/ggerganov/llama.cpp/blob/b2899/ggml-cuda/mmvq.cu -template -static __global__ void mul_mat_vec_q(const void * __restrict__ vx, const void * __restrict__ vy, scalar_t * __restrict__ dst, const int ncols, const int nrows, const int nvecs) { - const auto row = blockIdx.x*blockDim.y + threadIdx.y; - const auto vec = blockIdx.y; - - if (row >= nrows || vec >= nvecs) { - return; - } - - const int blocks_per_row = ncols / qk; - const int blocks_per_warp = vdr * WARP_SIZE / qi; - const int nrows_y = (ncols + 512 - 1) / 512 * 512; - - - // partial sum for each thread - float tmp = 0.0f; - - const block_q_t * x = (const block_q_t *) vx; - const block_q8_1 * y = (const block_q8_1 *) vy; - - for (auto i = threadIdx.x / (qi/vdr); i < blocks_per_row; i += blocks_per_warp) { - const int ibx = row*blocks_per_row + i; // x block index - - const int iby = vec*(nrows_y/QK8_1) + i * (qk/QK8_1); // y block index that aligns with ibx - - const int iqs = vdr * (threadIdx.x % (qi/vdr)); // x block quant index when casting the quants to int - - tmp += vec_dot_q_cuda(&x[ibx], &y[iby], iqs); - } - - // sum up partial sums and write back result -#pragma unroll - for (int mask = WARP_SIZE/2; mask > 0; mask >>= 1) { - tmp += VLLM_SHFL_XOR_SYNC(tmp, mask); - } - - if (threadIdx.x == 0) { - dst[vec*nrows + row] = tmp; - } -} - -template -static void mul_mat_vec_q4_0_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q4_1_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q5_0_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q5_1_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q8_0_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q2_K_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q3_K_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q4_K_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q5_K_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_q6_K_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq2_xxs_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq2_xs_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq2_s_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq3_xxs_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq1_s_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq1_m_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq4_nl_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq4_xs_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} - -template -static void mul_mat_vec_iq3_s_q8_1_cuda(const void * vx, const void * vy, scalar_t * dst, const int ncols, const int nrows, const int nvecs, cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, nvecs, 1); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - mul_mat_vec_q - <<>>(vx, vy, dst, ncols, nrows, nvecs); -} diff --git a/csrc/libtorch_stable/quantization/gguf/moe.cuh b/csrc/libtorch_stable/quantization/gguf/moe.cuh deleted file mode 100644 index a2f9f46c8f89..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/moe.cuh +++ /dev/null @@ -1,739 +0,0 @@ -#include - -/* Adapted from ./csrc/quantization/gguf/mmq.cuh - based on ./vllm/model_executor/layers/fused_moe/experts/triton_moe.py */ -template -static __device__ __forceinline__ void moe_q( - const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* __restrict__ sorted_token_ids, - const int* __restrict__ expert_ids, - const int* __restrict__ num_tokens_post_padded, const int exp_stride, - const int ncols_x, const int nrows_x, const int ncols_y, const int nrows_y, - const int nrows_dst, const int top_k) { - const int blocks_per_row_x = ncols_x / qk; - const int blocks_per_col_y = nrows_y / QK8_1; - const int blocks_per_warp = WARP_SIZE_GGUF / qi; - - const int ncols_dst = ncols_y * top_k; - - const auto row_dst_0 = blockIdx.x * mmq_y; - const int& row_x_0 = row_dst_0; - - const auto col_dst_0 = blockIdx.y * mmq_x; - - int token_offs[mmq_x / nwarps]; - for (int i = 0; i < mmq_x; i += nwarps) { - token_offs[i / nwarps] = sorted_token_ids[col_dst_0 + threadIdx.y + i]; - } - - const int exp_idx = expert_ids[blockIdx.y]; - if (exp_idx > 255 || exp_idx < 0) return; - if (blockIdx.y * mmq_x > num_tokens_post_padded[0]) return; - - const block_q_t* x = (const block_q_t*)((char*)vx + exp_idx * exp_stride); - const block_q8_1* y = (const block_q8_1*)(vy); - - int* tile_x_ql = nullptr; - half2* tile_x_dm = nullptr; - int* tile_x_qh = nullptr; - int* tile_x_sc = nullptr; - - allocate_tiles(&tile_x_ql, &tile_x_dm, &tile_x_qh, &tile_x_sc); - - __shared__ int tile_y_qs[mmq_x * WARP_SIZE_GGUF]; - __shared__ half2 tile_y_ds[mmq_x * WARP_SIZE_GGUF / QI8_1]; - - float sum[mmq_y / WARP_SIZE_GGUF][mmq_x / nwarps] = {{0.0f}}; - - for (int ib0 = 0; ib0 < blocks_per_row_x; ib0 += blocks_per_warp) { - load_tiles(x + row_x_0 * blocks_per_row_x + ib0, tile_x_ql, tile_x_dm, - tile_x_qh, tile_x_sc, threadIdx.y, nrows_x - row_x_0 - 1, - threadIdx.x, blocks_per_row_x); - - const int n_per_r = ((qk * blocks_per_warp) / qr); -#pragma unroll - for (int ir = 0; ir < qr && ib0 * qk + ir * n_per_r < ncols_x; ++ir) { - const auto kqs = ir * WARP_SIZE_GGUF + threadIdx.x; - const int kbxd = kqs / QI8_1; - -#pragma unroll - for (int i = 0; i < mmq_x; i += nwarps) { - const int col_y_eff = token_offs[i / nwarps] / top_k; - const int block_x = ib0 * (qk / QK8_1) + kbxd; - if (col_y_eff < ncols_y && block_x < blocks_per_col_y) { - const block_q8_1* by0 = &y[col_y_eff * blocks_per_col_y + block_x]; - const int index_y = - (threadIdx.y + i) * WARP_SIZE_GGUF + kqs % WARP_SIZE_GGUF; - tile_y_qs[index_y] = - get_int_from_int8_aligned(by0->qs, threadIdx.x % QI8_1); - } - } - - if (threadIdx.x < n_per_r / QK8_1) { - const auto kby = threadIdx.x % (WARP_SIZE_GGUF / QI8_1); - const int col_y_eff = token_offs[threadIdx.y] / top_k; - const int block_x = - ib0 * (qk / QK8_1) + ir * (WARP_SIZE_GGUF / QI8_1) + kby; - - if (col_y_eff < ncols_y && block_x < blocks_per_col_y) { - const half2* dsi_src = &y[col_y_eff * blocks_per_col_y + block_x].ds; - half2* dsi_dst = - &tile_y_ds[threadIdx.y * (WARP_SIZE_GGUF / QI8_1) + kby]; - - if (need_sum) { - *dsi_dst = *dsi_src; - } else { - float* dfi_dst = (float*)dsi_dst; - *dfi_dst = __low2float(*dsi_src); - } - } - } - __syncthreads(); - - // #pragma unroll // unrolling this loop causes too much register pressure - for (int k = ir * WARP_SIZE_GGUF / qr; k < (ir + 1) * WARP_SIZE_GGUF / qr; - k += vdr) { -#pragma unroll - for (int j = 0; j < mmq_x; j += nwarps) { -#pragma unroll - for (int i = 0; i < mmq_y; i += WARP_SIZE_GGUF) { - sum[i / WARP_SIZE_GGUF][j / nwarps] += - vec_dot(tile_x_ql, tile_x_dm, tile_x_qh, tile_x_sc, tile_y_qs, - tile_y_ds, threadIdx.x + i, threadIdx.y + j, k); - } - } - } - __syncthreads(); - } - } - -#pragma unroll - for (int j = 0; j < mmq_x; j += nwarps) { - const int col_dst = token_offs[j / nwarps]; - if (col_dst >= ncols_dst) { - return; - } - -#pragma unroll - for (int i = 0; i < mmq_y; i += WARP_SIZE_GGUF) { - const auto row_dst = row_dst_0 + threadIdx.x + i; - if (row_dst >= nrows_dst) { - continue; - } - dst[col_dst * nrows_dst + row_dst] = sum[i / WARP_SIZE_GGUF][j / nwarps]; - } - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q4_0 8 - #define MOE_Y_Q4_0 128 - #define NWARPS_Q4_0 8 -#else - #define MOE_X_Q4_0 4 - #define MOE_Y_Q4_0 32 - #define NWARPS_Q4_0 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q4_0, 2) -#endif - moe_q4_0(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q4_0; - const int mmq_y = MOE_Y_Q4_0; - const int nwarps = NWARPS_Q4_0; - - moe_q, load_tiles_q4_0, - VDR_Q4_0_Q8_1_MMQ, vec_dot_q4_0_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q4_0_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - int mmq_x = MOE_X_Q4_0; - int mmq_y = MOE_Y_Q4_0; - int nwarps = NWARPS_Q4_0; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q4_0<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q4_0<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q4_1 8 - #define MOE_Y_Q4_1 128 - #define NWARPS_Q4_1 8 -#else - #define MOE_X_Q4_1 4 - #define MOE_Y_Q4_1 32 - #define NWARPS_Q4_1 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q4_1, 2) -#endif - moe_q4_1(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q4_1; - const int mmq_y = MOE_Y_Q4_1; - const int nwarps = NWARPS_Q4_1; - - moe_q, load_tiles_q4_1, - VDR_Q4_1_Q8_1_MMQ, vec_dot_q4_1_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q4_1_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - int mmq_x = MOE_X_Q4_1; - int mmq_y = MOE_Y_Q4_1; - int nwarps = NWARPS_Q4_1; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q4_1<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q4_1<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q5_0 8 - #define MOE_Y_Q5_0 128 - #define NWARPS_Q5_0 8 -#else - #define MOE_X_Q5_0 4 - #define MOE_Y_Q5_0 32 - #define NWARPS_Q5_0 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q5_0, 2) -#endif - moe_q5_0(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q5_0; - const int mmq_y = MOE_Y_Q5_0; - const int nwarps = NWARPS_Q5_0; - - moe_q, load_tiles_q5_0, - VDR_Q5_0_Q8_1_MMQ, vec_dot_q5_0_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q5_0_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q5_0; - const int mmq_y = MOE_Y_Q5_0; - const int nwarps = NWARPS_Q5_0; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q5_0<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q5_0<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q5_1 8 - #define MOE_Y_Q5_1 128 - #define NWARPS_Q5_1 8 -#else - #define MOE_X_Q5_1 4 - #define MOE_Y_Q5_1 32 - #define NWARPS_Q5_1 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q5_1, 2) -#endif - moe_q5_1(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q5_1; - const int mmq_y = MOE_Y_Q5_1; - const int nwarps = NWARPS_Q5_1; - - moe_q, load_tiles_q5_1, - VDR_Q5_1_Q8_1_MMQ, vec_dot_q5_1_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q5_1_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q5_1; - const int mmq_y = MOE_Y_Q5_1; - const int nwarps = NWARPS_Q5_1; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q5_1<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q5_1<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q8_0 8 - #define MOE_Y_Q8_0 128 - #define NWARPS_Q8_0 8 -#else - #define MOE_X_Q8_0 4 - #define MOE_Y_Q8_0 32 - #define NWARPS_Q8_0 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q8_0, 2) -#endif - moe_q8_0(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q8_0; - const int mmq_y = MOE_Y_Q8_0; - const int nwarps = NWARPS_Q8_0; - - moe_q, load_tiles_q8_0, - VDR_Q8_0_Q8_1_MMQ, vec_dot_q8_0_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q8_0_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q8_0; - const int mmq_y = MOE_Y_Q8_0; - const int nwarps = NWARPS_Q8_0; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q8_0<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q8_0<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q2_K 8 - #define MOE_Y_Q2_K 128 - #define NWARPS_Q2_K 8 -#else - #define MOE_X_Q2_K 4 - #define MOE_Y_Q2_K 32 - #define NWARPS_Q2_K 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q2_K, 2) -#endif - moe_q2_K(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q2_K; - const int mmq_y = MOE_Y_Q2_K; - const int nwarps = NWARPS_Q2_K; - - moe_q, load_tiles_q2_K, - VDR_Q2_K_Q8_1_MMQ, vec_dot_q2_K_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q2_K_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q2_K; - const int mmq_y = MOE_Y_Q2_K; - const int nwarps = NWARPS_Q2_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q2_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q2_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q3_K 8 - #define MOE_Y_Q3_K 128 - #define NWARPS_Q3_K 8 -#else - #define MOE_X_Q3_K 4 - #define MOE_Y_Q3_K 32 - #define NWARPS_Q3_K 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q3_K, 2) -#endif - moe_q3_K(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - - const int mmq_x = MOE_X_Q3_K; - const int mmq_y = MOE_Y_Q3_K; - const int nwarps = NWARPS_Q3_K; - - moe_q, load_tiles_q3_K, - VDR_Q3_K_Q8_1_MMQ, vec_dot_q3_K_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} -template -static void ggml_moe_q3_K_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q3_K; - const int mmq_y = MOE_Y_Q3_K; - const int nwarps = NWARPS_Q3_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q3_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q3_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q4_K 8 - #define MOE_Y_Q4_K 128 - #define NWARPS_Q4_K 8 -#else - #define MOE_X_Q4_K 4 - #define MOE_Y_Q4_K 32 - #define NWARPS_Q4_K 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q4_K, 2) -#endif - moe_q4_K(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q4_K; - const int mmq_y = MOE_Y_Q4_K; - const int nwarps = NWARPS_Q4_K; - - moe_q, load_tiles_q4_K, - VDR_Q4_K_Q8_1_MMQ, vec_dot_q4_K_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q4_K_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q4_K; - const int mmq_y = MOE_Y_Q4_K; - const int nwarps = NWARPS_Q4_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q4_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q4_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q5_K 8 - #define MOE_Y_Q5_K 128 - #define NWARPS_Q5_K 8 -#else - #define MOE_X_Q5_K 4 - #define MOE_Y_Q5_K 32 - #define NWARPS_Q5_K 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q5_K, 2) -#endif - moe_q5_K(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q5_K; - const int mmq_y = MOE_Y_Q5_K; - const int nwarps = NWARPS_Q5_K; - - moe_q, load_tiles_q5_K, - VDR_Q5_K_Q8_1_MMQ, vec_dot_q5_K_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q5_K_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q5_K; - const int mmq_y = MOE_Y_Q5_K; - const int nwarps = NWARPS_Q5_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q5_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q5_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} - -#if defined(USE_ROCM) - #define MOE_X_Q6_K 8 - #define MOE_Y_Q6_K 128 - #define NWARPS_Q6_K 8 -#else - #define MOE_X_Q6_K 4 - #define MOE_Y_Q6_K 32 - #define NWARPS_Q6_K 4 -#endif - -template -static __global__ void -#if defined(USE_ROCM) -__launch_bounds__(WARP_SIZE_GGUF* NWARPS_Q6_K, 2) -#endif - moe_q6_K(const void* __restrict__ vx, const void* __restrict__ vy, - scalar_t* __restrict__ dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, - const int top_k) { - const int mmq_x = MOE_X_Q6_K; - const int mmq_y = MOE_Y_Q6_K; - const int nwarps = NWARPS_Q6_K; - - moe_q, load_tiles_q6_K, - VDR_Q6_K_Q8_1_MMQ, vec_dot_q6_K_q8_1_mul_mat>( - vx, vy, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); -} - -template -static void ggml_moe_q6_K_q8_1_cuda( - const void* inp, const void* w, scalar_t* dst, const int* sorted_token_ids, - const int* expert_ids, const int* num_tokens_post_padded, - const int exp_stride, const int ncols_x, const int nrows_x, - const int ncols_y, const int nrows_y, const int nrows_dst, const int top_k, - const int tokens_post_padded, cudaStream_t stream) { - const int mmq_x = MOE_X_Q6_K; - const int mmq_y = MOE_Y_Q6_K; - const int nwarps = NWARPS_Q6_K; - - const int block_num_x = (nrows_x + mmq_y - 1) / mmq_y; - const int block_num_y = (tokens_post_padded) / mmq_x; - const dim3 block_nums(block_num_x, block_num_y, 1); - const dim3 block_dims(WARP_SIZE_GGUF, nwarps, 1); - - if (nrows_x % mmq_y == 0) { - constexpr bool need_check = false; - moe_q6_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } else { - constexpr bool need_check = true; - moe_q6_K<<>>( - w, inp, dst, sorted_token_ids, expert_ids, num_tokens_post_padded, - exp_stride, ncols_x, nrows_x, ncols_y, nrows_y, nrows_dst, top_k); - } -} diff --git a/csrc/libtorch_stable/quantization/gguf/moe_vec.cuh b/csrc/libtorch_stable/quantization/gguf/moe_vec.cuh deleted file mode 100644 index 60f65a1bfdcb..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/moe_vec.cuh +++ /dev/null @@ -1,338 +0,0 @@ -// copied and adapted from -// https://github.com/ggerganov/llama.cpp/blob/b2899/ggml-cuda/mmvq.cu -template -static __global__ void moe_vec_q(const void* __restrict__ vx, - const void* __restrict__ vy, - scalar_t* __restrict__ dst, - const int* topk_ids, const int topk, - const int ncols, const int nrows, - const int token_stride) { - const auto row = blockIdx.x * blockDim.y + threadIdx.y; - - const auto token = blockIdx.z / topk; - const auto expert = (topk_ids)[blockIdx.z]; - - if (row >= nrows) { - return; - } - - const int blocks_per_row = ncols / qk; - const int blocks_per_warp = vdr * WARP_SIZE / qi; - - // partial sum for each thread - float tmp = 0.0f; - - const block_q_t* x = ((const block_q_t*)vx) + expert * nrows * blocks_per_row; - const block_q8_1* y = - (const block_q8_1*)(((const int*)vy) + token * token_stride); - - for (auto i = threadIdx.x / (qi / vdr); i < blocks_per_row; - i += blocks_per_warp) { - const int ibx = row * blocks_per_row + i; // x block index - - const int iby = i * (qk / QK8_1); // y block index that aligns with ibx - - const int iqs = - vdr * - (threadIdx.x % - (qi / vdr)); // x block quant index when casting the quants to int - - tmp += vec_dot_q_cuda(&x[ibx], &y[iby], iqs); - } - - // sum up partial sums and write back result -#pragma unroll - for (int mask = WARP_SIZE / 2; mask > 0; mask >>= 1) { - tmp += VLLM_SHFL_XOR_SYNC(tmp, mask); - } - - if (threadIdx.x == 0) { - dst[blockIdx.z * nrows + row] = tmp; - } -} - -template -static void moe_vec_q4_0_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q4_1_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q5_0_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q5_1_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q8_0_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q2_K_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q3_K_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q4_K_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q5_K_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_q6_K_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_iq2_xxs_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} - -template -static void moe_vec_iq2_xs_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} - -template -static void moe_vec_iq2_s_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} - -template -static void moe_vec_iq3_xxs_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} - -template -static void moe_vec_iq1_s_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} - -template -static void moe_vec_iq1_m_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} - -template -static void moe_vec_iq4_nl_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q<<>>( - vx, vy, dst, topk_ids, top_k, ncols, nrows, token_stride); -} - -template -static void moe_vec_iq4_xs_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} - -template -static void moe_vec_iq3_s_q8_1_cuda(const void* vx, const void* vy, - scalar_t* dst, const int* topk_ids, - const int top_k, const int tokens, - const int ncols, const int nrows, - const int token_stride, - cudaStream_t stream) { - const int block_num_y = (nrows + GGML_CUDA_MMV_Y - 1) / GGML_CUDA_MMV_Y; - const dim3 block_nums(block_num_y, 1, tokens * top_k); - const dim3 block_dims(WARP_SIZE, GGML_CUDA_MMV_Y, 1); - moe_vec_q - <<>>(vx, vy, dst, topk_ids, top_k, - ncols, nrows, token_stride); -} diff --git a/csrc/libtorch_stable/quantization/gguf/vecdotq.cuh b/csrc/libtorch_stable/quantization/gguf/vecdotq.cuh deleted file mode 100644 index d0d4c74ed379..000000000000 --- a/csrc/libtorch_stable/quantization/gguf/vecdotq.cuh +++ /dev/null @@ -1,1812 +0,0 @@ -// copied and adapted from https://github.com/ggerganov/llama.cpp/blob/b2899/ggml-cuda/vecdotq.cuh -// and https://github.com/ggerganov/llama.cpp/blob/b2899/ggml-cuda/mmq.cu -static __device__ __forceinline__ int get_int_b2(const void * x, const int & i32) { - const uint16_t * x16 = (const uint16_t *) x; // assume at least 2 byte alignment - - int x32 = x16[2*i32 + 0] << 0; - x32 |= x16[2*i32 + 1] << 16; - - return x32; -} - -static __device__ __forceinline__ int get_int_b4(const void * x, const int & i32) { - return ((const int *) x)[i32]; // assume at least 4 byte alignment -} - -static __device__ __forceinline__ int get_int_from_int8(const int8_t * x8, const int & i32) { - const uint16_t * x16 = (const uint16_t *) (x8 + sizeof(int) * i32); // assume at least 2 byte alignment - int x32 = 0; - x32 |= x16[0] << 0; - x32 |= x16[1] << 16; - return x32; -} - -static __device__ __forceinline__ int get_int_from_uint8(const uint8_t * x8, const int & i32) { - const uint16_t * x16 = (const uint16_t *) (x8 + sizeof(int) * i32); // assume at least 2 byte alignment - int x32 = 0; - x32 |= x16[0] << 0; - x32 |= x16[1] << 16; - return x32; -} - -static __device__ __forceinline__ int get_int_from_int8_aligned(const int8_t * x8, const int & i32) { - return *((const int *) (x8 + sizeof(int) * i32)); // assume at least 4 byte alignment -} - -static __device__ __forceinline__ int get_int_from_uint8_aligned(const uint8_t * x8, const int & i32) { - return *((const int *) (x8 + sizeof(int) * i32)); // assume at least 4 byte alignment -} - -// VDR = vec dot ratio, how many contiguous integers each thread processes when the vec dot kernel is called -// MMVQ = mul_mat_vec_q, MMQ = mul_mat_q - -#define VDR_Q4_0_Q8_1_MMVQ 2 -#define VDR_Q4_0_Q8_1_MMQ 4 - -template static __device__ __forceinline__ float vec_dot_q4_0_q8_1_impl( - const int * v, const int * u, const float & d4, const half2 & ds8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - int sumi = 0; - -#pragma unroll - for (int i = 0; i < vdr; ++i) { - const int vi0 = (v[i] >> 0) & 0x0F0F0F0F; - const int vi1 = (v[i] >> 4) & 0x0F0F0F0F; - - // SIMD dot product of quantized values - sumi = __dp4a(vi0, u[2*i+0], sumi); - sumi = __dp4a(vi1, u[2*i+1], sumi); - } - - const float2 ds8f = __half22float2(ds8); - - // second part effectively subtracts 8 from each quant value - return d4 * (sumi * ds8f.x - (8*vdr/QI4_0) * ds8f.y); -#endif -} - -#define VDR_Q4_1_Q8_1_MMVQ 2 -#define VDR_Q4_1_Q8_1_MMQ 4 - -template static __device__ __forceinline__ float vec_dot_q4_1_q8_1_impl( - const int * v, const int * u, const half2 & dm4, const half2 & ds8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - int sumi = 0; - -#pragma unroll - for (int i = 0; i < vdr; ++i) { - const int vi0 = (v[i] >> 0) & 0x0F0F0F0F; - const int vi1 = (v[i] >> 4) & 0x0F0F0F0F; - - // SIMD dot product of quantized values - sumi = __dp4a(vi0, u[2*i+0], sumi); - sumi = __dp4a(vi1, u[2*i+1], sumi); - } - - const float2 tmp = __half22float2(__hmul2(dm4, ds8)); - const float d4d8 = tmp.x; - const float m4s8 = tmp.y; - - // scale second part of sum by QI8_1/(vdr * QR4_1) to compensate for multiple threads adding it - return sumi * d4d8 + m4s8 / (QI8_1 / (vdr * QR4_1)); -#endif -} - -#define VDR_Q5_0_Q8_1_MMVQ 2 -#define VDR_Q5_0_Q8_1_MMQ 4 - -template static __device__ __forceinline__ float vec_dot_q5_0_q8_1_impl( - const int * vl, const int * vh, const int * u, const float & d5, const half2 & ds8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - int sumi = 0; - -#pragma unroll - for (int i = 0; i < vdr; ++i) { - int vi0 = (vl[i] >> 0) & 0x0F0F0F0F; // lower 4 qs bits, still need qh as 5th bits - vi0 |= (vh[i] << 4) & 0x00000010; // 0 -> 4 - vi0 |= (vh[i] << 11) & 0x00001000; // 1 -> 12 - vi0 |= (vh[i] << 18) & 0x00100000; // 2 -> 20 - vi0 |= (vh[i] << 25) & 0x10000000; // 3 -> 28 - sumi = __dp4a(vi0, u[2*i+0], sumi); // SIMD dot product of quantized values - - int vi1 = (vl[i] >> 4) & 0x0F0F0F0F; // upper 4 qs bits, still need qh as 5th bits - vi1 |= (vh[i] >> 12) & 0x00000010; // 16 -> 4 - vi1 |= (vh[i] >> 5) & 0x00001000; // 17 -> 12 - vi1 |= (vh[i] << 2) & 0x00100000; // 18 -> 20 - vi1 |= (vh[i] << 9) & 0x10000000; // 19 -> 28 - sumi = __dp4a(vi1, u[2*i+1], sumi); // SIMD dot product of quantized values - } - - const float2 ds8f = __half22float2(ds8); - - // second part effectively subtracts 16 from each quant value - return d5 * (sumi * ds8f.x - (16*vdr/QI5_0) * ds8f.y); -#endif -} - - -#define VDR_Q5_1_Q8_1_MMVQ 2 -#define VDR_Q5_1_Q8_1_MMQ 4 - -template static __device__ __forceinline__ float vec_dot_q5_1_q8_1_impl( - const int * vl, const int * vh, const int * u, const half2 & dm5, const half2 & ds8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - int sumi = 0; - -#pragma unroll - for (int i = 0; i < vdr; ++i) { - int vi0 = (vl[i] >> 0) & 0x0F0F0F0F; // lower 4 qs bits, still need qh as 5th bits - vi0 |= (vh[i] << 4) & 0x00000010; // 0 -> 4 - vi0 |= (vh[i] << 11) & 0x00001000; // 1 -> 12 - vi0 |= (vh[i] << 18) & 0x00100000; // 2 -> 20 - vi0 |= (vh[i] << 25) & 0x10000000; // 3 -> 28 - sumi = __dp4a(vi0, u[2*i+0], sumi); // SIMD dot product of quantized values - - int vi1 = (vl[i] >> 4) & 0x0F0F0F0F; // upper 4 qs bits, still need qh as 5th bits - vi1 |= (vh[i] >> 12) & 0x00000010; // 16 -> 4 - vi1 |= (vh[i] >> 5) & 0x00001000; // 17 -> 12 - vi1 |= (vh[i] << 2) & 0x00100000; // 18 -> 20 - vi1 |= (vh[i] << 9) & 0x10000000; // 19 -> 28 - sumi = __dp4a(vi1, u[2*i+1], sumi); // SIMD dot product of quantized values - } - - const float2 tmp = __half22float2(__hmul2(dm5, ds8)); - const float d5d8 = tmp.x; - const float m5s8 = tmp.y; - - // scale second part of sum by QI5_1 / vdr to compensate for multiple threads adding it - return sumi*d5d8 + m5s8 / (QI5_1 / vdr); -#endif -} - -#define VDR_Q8_0_Q8_1_MMVQ 2 -#define VDR_Q8_0_Q8_1_MMQ 8 - -template static __device__ __forceinline__ float vec_dot_q8_0_q8_1_impl( - const int * v, const int * u, const float & d8_0, const float & d8_1) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - int sumi = 0; - -#pragma unroll - for (int i = 0; i < vdr; ++i) { - // SIMD dot product of quantized values - sumi = __dp4a(v[i], u[i], sumi); - } - return d8_0*d8_1 * sumi; -#endif -} - -template static __device__ __forceinline__ float vec_dot_q8_1_q8_1_impl( - const int * v, const int * u, const half2 & dm8, const half2 & ds8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - - int sumi = 0; - -#pragma unroll - for (int i = 0; i < vdr; ++i) { - // SIMD dot product of quantized values - sumi = __dp4a(v[i], u[i], sumi); - } - - const float2 tmp = __half22float2(__hmul2(dm8, ds8)); - const float d8d8 = tmp.x; - const float m8s8 = tmp.y; - - // scale second part of sum by QI8_1/ vdr to compensate for multiple threads adding it - return sumi*d8d8 + m8s8 / (QI8_1 / vdr); -#endif -} - -#define VDR_Q2_K_Q8_1_MMVQ 1 -#define VDR_Q2_K_Q8_1_MMQ 2 - -// contiguous v/x values -static __device__ __forceinline__ float vec_dot_q2_K_q8_1_impl_mmvq( - const int & v, const int * __restrict__ u, const uint8_t * __restrict__ scales, - const half2 & dm2, const float * __restrict__ d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - float sumf_d = 0.0f; - float sumf_m = 0.0f; - -#pragma unroll - for (int i = 0; i < QR2_K; ++i) { - const int sc = scales[2*i]; - - const int vi = (v >> (2*i)) & 0x03030303; - - sumf_d += d8[i] * (__dp4a(vi, u[i], 0) * (sc & 0xF)); // SIMD dot product - - // fill int with 4x m - int m = sc >> 4; - m |= m << 8; - m |= m << 16; - sumf_m += d8[i] * __dp4a(m, u[i], 0); // multiply constant q2_K part with sum of q8_1 values - } - - const float2 dm2f = __half22float2(dm2); - - return dm2f.x*sumf_d - dm2f.y*sumf_m; -#endif -} - -static __device__ __forceinline__ float vec_dot_q2_K_q8_1_impl_mmq( - const int * __restrict__ v, const int * __restrict__ u, const uint8_t * __restrict__ scales, - const half2 & dm2, const float & d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - int sumi_d = 0; - int sumi_m = 0; - -#pragma unroll - for (int i0 = 0; i0 < QI8_1; i0 += QI8_1/2) { - int sumi_d_sc = 0; - - const int sc = scales[i0 / (QI8_1/2)]; - - // fill int with 4x m - int m = sc >> 4; - m |= m << 8; - m |= m << 16; - -#pragma unroll - for (int i = i0; i < i0 + QI8_1/2; ++i) { - sumi_d_sc = __dp4a(v[i], u[i], sumi_d_sc); // SIMD dot product - sumi_m = __dp4a(m, u[i], sumi_m); // multiply sum of q8_1 values with m - } - - sumi_d += sumi_d_sc * (sc & 0xF); - } - - const float2 dm2f = __half22float2(dm2); - - return d8 * (dm2f.x*sumi_d - dm2f.y*sumi_m); -#endif -} - -#define VDR_Q3_K_Q8_1_MMVQ 1 -#define VDR_Q3_K_Q8_1_MMQ 2 - -// contiguous v/x values -static __device__ __forceinline__ float vec_dot_q3_K_q8_1_impl_mmvq( - const int & vl, const int & vh, const int * __restrict__ u, const uint8_t * __restrict__ scales, - const int & scale_offset, const float & d3, const float * __restrict__ d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - - float sumf = 0.0f; - -#pragma unroll - for (int i = 0; i < QR3_K; ++i) { - const int isc = scale_offset + 2*i; - - const int isc_low = isc % (QK_K/32); - const int sc_shift_low = 4 * (isc / (QK_K/32)); - const int sc_low = (scales[isc_low] >> sc_shift_low) & 0xF; - - const int isc_high = isc % (QK_K/64); - const int sc_shift_high = 2 * (isc / (QK_K/64)); - const int sc_high = ((scales[(QK_K/32) + isc_high] >> sc_shift_high) & 3) << 4; - - const int sc = (sc_low | sc_high) - 32; - - const int vil = (vl >> (2*i)) & 0x03030303; - - const int vih = ((vh >> i) << 2) & 0x04040404; - - const int vi = __vsubss4(vil, vih); - - sumf += d8[i] * (__dp4a(vi, u[i], 0) * sc); // SIMD dot product - } - - return d3 * sumf; -#endif -} - -static __device__ __forceinline__ float vec_dot_q3_K_q8_1_impl_mmq( - const int * __restrict__ v, const int * __restrict__ u, const int8_t * __restrict__ scales, - const float & d3, const float & d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - int sumi = 0; - -#pragma unroll - for (int i0 = 0; i0 < QR3_K*VDR_Q3_K_Q8_1_MMQ; i0 += QI8_1/2) { - int sumi_sc = 0; - - for (int i = i0; i < i0 + QI8_1/2; ++i) { - sumi_sc = __dp4a(v[i], u[i], sumi_sc); // SIMD dot product - } - - sumi += sumi_sc * scales[i0 / (QI8_1/2)]; - } - - return d3*d8 * sumi; -#endif -} - -#define VDR_Q4_K_Q8_1_MMVQ 2 -#define VDR_Q4_K_Q8_1_MMQ 8 - -// contiguous v/x values -static __device__ __forceinline__ float vec_dot_q4_K_q8_1_impl_vmmq( - const int * __restrict__ v, const int * __restrict__ u, const uint8_t * __restrict__ sc, - const uint8_t * __restrict__ m, const half2 & dm4, const float * __restrict__ d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - - float sumf_d = 0.0f; - float sumf_m = 0.0f; - -#pragma unroll - for (int i = 0; i < QR4_K; ++i) { - const int v0i = (v[0] >> (4*i)) & 0x0F0F0F0F; - const int v1i = (v[1] >> (4*i)) & 0x0F0F0F0F; - - const int dot1 = __dp4a(v1i, u[2*i+1], __dp4a(v0i, u[2*i+0], 0)); // SIMD dot product - const int dot2 = __dp4a(0x01010101, u[2*i+1], __dp4a(0x01010101, u[2*i+0], 0)); // sum of u - - sumf_d += d8[i] * (dot1 * sc[i]); - sumf_m += d8[i] * (dot2 * m[i]); // multiply constant part of q4_K with sum of q8_1 values - } - - const float2 dm4f = __half22float2(dm4); - return dm4f.x*sumf_d - dm4f.y*sumf_m; -#endif -} - -static __device__ __forceinline__ float vec_dot_q4_K_q8_1_impl_mmq( - const int * __restrict__ v, const int * __restrict__ u, const uint8_t * __restrict__ sc, - const uint8_t * __restrict__ m, const half2 & dm4, const half2 * __restrict__ ds8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - float sumf_d = 0.0f; - float sumf_m = 0.0f; - -#pragma unroll - for (int i = 0; i < QR4_K*VDR_Q4_K_Q8_1_MMQ/QI8_1; ++i) { - int sumi_d = 0; - -#pragma unroll - for (int j = 0; j < QI8_1; ++j) { - sumi_d = __dp4a((v[j] >> (4*i)) & 0x0F0F0F0F, u[i*QI8_1 + j], sumi_d); // SIMD dot product - } - - const float2 ds8f = __half22float2(ds8[i]); - - sumf_d += ds8f.x * (sc[i] * sumi_d); - sumf_m += ds8f.y * m[i]; // sum of q8_1 block * q4_K min val - } - - const float2 dm4f = __half22float2(dm4); - - return dm4f.x*sumf_d - dm4f.y*sumf_m; -#endif -} - -#define VDR_Q5_K_Q8_1_MMVQ 2 -#define VDR_Q5_K_Q8_1_MMQ 8 - -static __device__ __forceinline__ float vec_dot_q5_K_q8_1_impl_vmmq( - const int * __restrict__ vl, const int * __restrict__ vh, const int * __restrict__ u, const uint8_t * __restrict__ sc, - const uint8_t * __restrict__ m, const half2 & dm5, const float * __restrict__ d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - - float sumf_d = 0.0f; - float sumf_m = 0.0f; - -#pragma unroll - for (int i = 0; i < QR5_K; ++i) { - const int vl0i = (vl[0] >> (4*i)) & 0x0F0F0F0F; - const int vl1i = (vl[1] >> (4*i)) & 0x0F0F0F0F; - - const int vh0i = ((vh[0] >> i) << 4) & 0x10101010; - const int vh1i = ((vh[1] >> i) << 4) & 0x10101010; - - const int v0i = vl0i | vh0i; - const int v1i = vl1i | vh1i; - - const int dot1 = __dp4a(v0i, u[2*i+0], __dp4a(v1i, u[2*i+1], 0)); // SIMD dot product - const int dot2 = __dp4a(0x01010101, u[2*i+0], __dp4a(0x01010101, u[2*i+1], 0)); // sum of u - - sumf_d += d8[i] * (dot1 * sc[i]); - sumf_m += d8[i] * (dot2 * m[i]); - } - - const float2 dm5f = __half22float2(dm5); - return dm5f.x*sumf_d - dm5f.y*sumf_m; -#endif -} - -static __device__ __forceinline__ float vec_dot_q5_K_q8_1_impl_mmq( - const int * __restrict__ v, const int * __restrict__ u, const uint8_t * __restrict__ sc, - const uint8_t * __restrict__ m, const half2 & dm4, const half2 * __restrict__ ds8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - float sumf_d = 0.0f; - float sumf_m = 0.0f; - -#pragma unroll - for (int i = 0; i < QR5_K*VDR_Q5_K_Q8_1_MMQ/QI8_1; ++i) { - int sumi_d = 0; - -#pragma unroll - for (int j = 0; j < QI8_1; ++j) { - sumi_d = __dp4a(v[i*QI8_1 + j], u[i*QI8_1 + j], sumi_d); // SIMD dot product - } - - const float2 ds8f = __half22float2(ds8[i]); - - sumf_d += ds8f.x * (sc[i] * sumi_d); - sumf_m += ds8f.y * m[i]; // sum of q8_1 block * q4_K min val - } - - const float2 dm4f = __half22float2(dm4); - - return dm4f.x*sumf_d - dm4f.y*sumf_m; -#endif -} - -#define VDR_Q6_K_Q8_1_MMVQ 1 -#define VDR_Q6_K_Q8_1_MMQ 8 - -// contiguous v/x values -static __device__ __forceinline__ float vec_dot_q6_K_q8_1_impl_mmvq( - const int & vl, const int & vh, const int * __restrict__ u, const int8_t * __restrict__ scales, - const float & d, const float * __restrict__ d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - float sumf = 0.0f; - -#pragma unroll - for (int i = 0; i < QR6_K; ++i) { - const int sc = scales[4*i]; - const int vil = (vl >> (4*i)) & 0x0F0F0F0F; - const int vih = ((vh >> (4*i)) << 4) & 0x30303030; - const int vi = __vsubss4((vil | vih), 0x20202020); // vi = (vil | vih) - 32 - - sumf += d8[i] * (__dp4a(vi, u[i], 0) * sc); // SIMD dot product - } - - return d*sumf; -#endif -} - -static __device__ __forceinline__ float vec_dot_q6_K_q8_1_impl_mmq( - const int * __restrict__ v, const int * __restrict__ u, const int8_t * __restrict__ sc, - const float & d6, const float * __restrict__ d8) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - float sumf_d = 0.0f; - -#pragma unroll - for (int i0 = 0; i0 < VDR_Q6_K_Q8_1_MMQ; i0 += 4) { - int2 sumi_d = {0, 0}; // 2 q6_K scales per q8_1 scale - -#pragma unroll - for (int i = i0; i < i0 + 2; ++i) { - sumi_d.x = __dp4a(v[2*i+0], u[2*i+0], sumi_d.x); // SIMD dot product - sumi_d.x = __dp4a(v[2*i+1], u[2*i+1], sumi_d.x); // SIMD dot product - - sumi_d.y = __dp4a(v[2*i+4], u[2*i+4], sumi_d.y); // SIMD dot product - sumi_d.y = __dp4a(v[2*i+5], u[2*i+5], sumi_d.y); // SIMD dot product - } - - sumf_d += d8[i0/4] * (sc[i0/2+0]*sumi_d.x + sc[i0/2+1]*sumi_d.y); - } - - return d6 * sumf_d; -#endif -} - -static __device__ __forceinline__ float vec_dot_q4_0_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q4_0 * bq4_0 = (const block_q4_0 *) vbq; - - int v[VDR_Q4_0_Q8_1_MMVQ]; - int u[2*VDR_Q4_0_Q8_1_MMVQ]; - -#pragma unroll - for (int i = 0; i < VDR_Q4_0_Q8_1_MMVQ; ++i) { - v[i] = get_int_from_uint8(bq4_0->qs, iqs + i); - u[2*i+0] = get_int_from_int8_aligned(bq8_1->qs, iqs + i); - u[2*i+1] = get_int_from_int8_aligned(bq8_1->qs, iqs + i + QI4_0); - } - - return vec_dot_q4_0_q8_1_impl(v, u, __half2float(bq4_0->d), bq8_1->ds); -} - -template static __device__ __forceinline__ void allocate_tiles_q4_0(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_qs[mmq_y * (WARP_SIZE_GGUF) + mmq_y]; - __shared__ float tile_x_d[mmq_y * (WARP_SIZE_GGUF/QI4_0) + mmq_y/QI4_0]; - *x_ql = tile_x_qs; - *x_dm = (half2 *) tile_x_d; -} - -template static __device__ __forceinline__ void load_tiles_q4_0( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI4_0; - const int kqsx = k % QI4_0; - - const block_q4_0 * bx0 = (const block_q4_0 *) vx; - float * x_dmf = (float *) x_dm; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - if (need_check) { - i = min(i, i_max); - } - const block_q4_0 * bxi = bx0 + i*blocks_per_row + kbx; - x_ql[i * (WARP_SIZE_GGUF + 1) + k] = get_int_from_uint8(bxi->qs, kqsx); - // x_dmf[i * (WARP_SIZE_GGUF/QI4_0) + i / QI4_0 + kbx] = bxi->d; - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI4_0; - const int kbxd = k % blocks_per_tile_x_row; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI4_0) { - int i = i0 + i_offset * QI4_0 + k / blocks_per_tile_x_row; - if (need_check) { - i = min(i, i_max); - } - const block_q4_0 * bxi = bx0 + i*blocks_per_row + kbxd; - x_dmf[i * (WARP_SIZE_GGUF/QI4_0) + i / QI4_0 + kbxd] = __half2float(bxi->d); - } -} - -static __device__ __forceinline__ float vec_dot_q4_0_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - (void)x_qh; (void)x_sc; - - const int kyqs = k % (QI8_1/2) + QI8_1 * (k / (QI8_1/2)); - const float * x_dmf = (const float *) x_dm; - - int u[2*VDR_Q4_0_Q8_1_MMQ]; - -#pragma unroll - for (int l = 0; l < VDR_Q4_0_Q8_1_MMQ; ++l) { - u[2*l+0] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l) % WARP_SIZE_GGUF]; - u[2*l+1] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l + QI4_0) % WARP_SIZE_GGUF]; - } - - return vec_dot_q4_0_q8_1_impl - (&x_ql[i * (WARP_SIZE_GGUF + 1) + k], u, x_dmf[i * (WARP_SIZE_GGUF/QI4_0) + i/QI4_0 + k/QI4_0], - y_ds[j * (WARP_SIZE_GGUF/QI8_1) + (2*k/QI8_1) % (WARP_SIZE_GGUF/QI8_1)]); -} - -static __device__ __forceinline__ float vec_dot_q4_1_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q4_1 * bq4_1 = (const block_q4_1 *) vbq; - - int v[VDR_Q4_1_Q8_1_MMVQ]; - int u[2*VDR_Q4_1_Q8_1_MMVQ]; - -#pragma unroll - for (int i = 0; i < VDR_Q4_1_Q8_1_MMVQ; ++i) { - v[i] = get_int_from_uint8_aligned(bq4_1->qs, iqs + i); - u[2*i+0] = get_int_from_int8_aligned(bq8_1->qs, iqs + i); - u[2*i+1] = get_int_from_int8_aligned(bq8_1->qs, iqs + i + QI4_1); - } - - return vec_dot_q4_1_q8_1_impl(v, u, bq4_1->dm, bq8_1->ds); -} - -template static __device__ __forceinline__ void allocate_tiles_q4_1(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_qs[mmq_y * (WARP_SIZE_GGUF) + + mmq_y]; - __shared__ half2 tile_x_dm[mmq_y * (WARP_SIZE_GGUF/QI4_1) + mmq_y/QI4_1]; - *x_ql = tile_x_qs; - *x_dm = tile_x_dm; -} - -template static __device__ __forceinline__ void load_tiles_q4_1( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI4_1; - const int kqsx = k % QI4_1; - - const block_q4_1 * bx0 = (const block_q4_1 *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - if (need_check) { - i = min(i, i_max); - } - const block_q4_1 * bxi = bx0 + i*blocks_per_row + kbx; - x_ql[i * (WARP_SIZE_GGUF + 1) + k] = get_int_from_uint8_aligned(bxi->qs, kqsx); - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI4_1; - const int kbxd = k % blocks_per_tile_x_row; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI4_1) { - int i = i0 + i_offset * QI4_1 + k / blocks_per_tile_x_row; - if (need_check) { - i = min(i, i_max); - } - const block_q4_1 * bxi = bx0 + i*blocks_per_row + kbxd; - x_dm[i * (WARP_SIZE_GGUF/QI4_1) + i / QI4_1 + kbxd] = bxi->dm; - } -} - -static __device__ __forceinline__ float vec_dot_q4_1_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - const int kyqs = k % (QI8_1/2) + QI8_1 * (k / (QI8_1/2)); - - int u[2*VDR_Q4_1_Q8_1_MMQ]; - -#pragma unroll - for (int l = 0; l < VDR_Q4_1_Q8_1_MMQ; ++l) { - u[2*l+0] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l) % WARP_SIZE_GGUF]; - u[2*l+1] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l + QI4_1) % WARP_SIZE_GGUF]; - } - - return vec_dot_q4_1_q8_1_impl - (&x_ql[i * (WARP_SIZE_GGUF + 1) + k], u, x_dm[i * (WARP_SIZE_GGUF/QI4_1) + i/QI4_1 + k/QI4_1], - y_ds[j * (WARP_SIZE_GGUF/QI8_1) + (2*k/QI8_1) % (WARP_SIZE_GGUF/QI8_1)]); -} - -static __device__ __forceinline__ float vec_dot_q5_0_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q5_0 * bq5_0 = (const block_q5_0 *) vbq; - - int vl[VDR_Q5_0_Q8_1_MMVQ]; - int vh[VDR_Q5_0_Q8_1_MMVQ]; - int u[2*VDR_Q5_0_Q8_1_MMVQ]; - -#pragma unroll - for (int i = 0; i < VDR_Q5_0_Q8_1_MMVQ; ++i) { - vl[i] = get_int_from_uint8(bq5_0->qs, iqs + i); - vh[i] = get_int_from_uint8(bq5_0->qh, 0) >> (4 * (iqs + i)); - u[2*i+0] = get_int_from_int8_aligned(bq8_1->qs, iqs + i); - u[2*i+1] = get_int_from_int8_aligned(bq8_1->qs, iqs + i + QI5_0); - } - - return vec_dot_q5_0_q8_1_impl(vl, vh, u, __half2float(bq5_0->d), bq8_1->ds); -} - -template static __device__ __forceinline__ void allocate_tiles_q5_0(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_ql[mmq_y * (2*WARP_SIZE_GGUF) + mmq_y]; - __shared__ float tile_x_d[mmq_y * (WARP_SIZE_GGUF/QI5_0) + mmq_y/QI5_0]; - - *x_ql = tile_x_ql; - *x_dm = (half2 *) tile_x_d; -} - -template static __device__ __forceinline__ void load_tiles_q5_0( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI5_0; - const int kqsx = k % QI5_0; - - const block_q5_0 * bx0 = (const block_q5_0 *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - - if (need_check) { - i = min(i, i_max); - } - const block_q5_0 * bxi = bx0 + i*blocks_per_row + kbx; - const int ql = get_int_from_uint8(bxi->qs, kqsx); - const int qh = get_int_from_uint8(bxi->qh, 0) >> (4 * (k % QI5_0)); - - int qs0 = (ql >> 0) & 0x0F0F0F0F; - qs0 |= (qh << 4) & 0x00000010; // 0 -> 4 - qs0 |= (qh << 11) & 0x00001000; // 1 -> 12 - qs0 |= (qh << 18) & 0x00100000; // 2 -> 20 - qs0 |= (qh << 25) & 0x10000000; // 3 -> 28 - qs0 = __vsubss4(qs0, 0x10101010); // subtract 16 - - x_ql[i * (2*WARP_SIZE_GGUF + 1) + 2*k+0] = qs0; - - int qs1 = (ql >> 4) & 0x0F0F0F0F; - qs1 |= (qh >> 12) & 0x00000010; // 16 -> 4 - qs1 |= (qh >> 5) & 0x00001000; // 17 -> 12 - qs1 |= (qh << 2) & 0x00100000; // 18 -> 20 - qs1 |= (qh << 9) & 0x10000000; // 19 -> 28 - qs1 = __vsubss4(qs1, 0x10101010); // subtract 16 - - x_ql[i * (2*WARP_SIZE_GGUF + 1) + 2*k+1] = qs1; - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI5_0; - const int kbxd = k % blocks_per_tile_x_row; - float * x_dmf = (float *) x_dm; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI5_0) { - int i = i0 + i_offset * QI5_0 + k / blocks_per_tile_x_row; - - if (need_check) { - i = min(i, i_max); - } - - const block_q5_0 * bxi = bx0 + i*blocks_per_row + kbxd; - x_dmf[i * (WARP_SIZE_GGUF/QI5_0) + i / QI5_0 + kbxd] = __half2float(bxi->d); - } -} - -static __device__ __forceinline__ float vec_dot_q5_0_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - const int kyqs = k % (QI8_1/2) + QI8_1 * (k / (QI8_1/2)); - const int index_bx = i * (WARP_SIZE_GGUF/QI5_0) + i/QI5_0 + k/QI5_0; - const float * x_dmf = (const float *) x_dm; - const float * y_df = (const float *) y_ds; - - int u[2*VDR_Q5_0_Q8_1_MMQ]; - -#pragma unroll - for (int l = 0; l < VDR_Q5_0_Q8_1_MMQ; ++l) { - u[2*l+0] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l) % WARP_SIZE_GGUF]; - u[2*l+1] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l + QI5_0) % WARP_SIZE_GGUF]; - } - - return vec_dot_q8_0_q8_1_impl - (&x_ql[i * (2*WARP_SIZE_GGUF + 1) + 2 * k], u, x_dmf[index_bx], y_df[j * (WARP_SIZE_GGUF/QI8_1) + (2*k/QI8_1) % (WARP_SIZE_GGUF/QI8_1)]); -} - -static __device__ __forceinline__ float vec_dot_q5_1_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q5_1 * bq5_1 = (const block_q5_1 *) vbq; - - int vl[VDR_Q5_1_Q8_1_MMVQ]; - int vh[VDR_Q5_1_Q8_1_MMVQ]; - int u[2*VDR_Q5_1_Q8_1_MMVQ]; - -#pragma unroll - for (int i = 0; i < VDR_Q5_1_Q8_1_MMVQ; ++i) { - vl[i] = get_int_from_uint8_aligned(bq5_1->qs, iqs + i); - vh[i] = get_int_from_uint8_aligned(bq5_1->qh, 0) >> (4 * (iqs + i)); - u[2*i+0] = get_int_from_int8_aligned(bq8_1->qs, iqs + i); - u[2*i+1] = get_int_from_int8_aligned(bq8_1->qs, iqs + i + QI5_1); - } - - return vec_dot_q5_1_q8_1_impl(vl, vh, u, bq5_1->dm, bq8_1->ds); -} - -template static __device__ __forceinline__ void allocate_tiles_q5_1(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_ql[mmq_y * (2*WARP_SIZE_GGUF) + mmq_y]; - __shared__ half2 tile_x_dm[mmq_y * (WARP_SIZE_GGUF/QI5_1) + mmq_y/QI5_1]; - - *x_ql = tile_x_ql; - *x_dm = tile_x_dm; -} - -template static __device__ __forceinline__ void load_tiles_q5_1( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI5_1; - const int kqsx = k % QI5_1; - - const block_q5_1 * bx0 = (const block_q5_1 *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - - if (need_check) { - i = min(i, i_max); - } - - const block_q5_1 * bxi = bx0 + i*blocks_per_row + kbx; - - const int ql = get_int_from_uint8_aligned(bxi->qs, kqsx); - const int qh = get_int_from_uint8_aligned(bxi->qh, 0) >> (4 * (k % QI5_1)); - - int qs0 = (ql >> 0) & 0x0F0F0F0F; - qs0 |= (qh << 4) & 0x00000010; // 0 -> 4 - qs0 |= (qh << 11) & 0x00001000; // 1 -> 12 - qs0 |= (qh << 18) & 0x00100000; // 2 -> 20 - qs0 |= (qh << 25) & 0x10000000; // 3 -> 28 - - x_ql[i * (2*WARP_SIZE_GGUF + 1) + 2*k+0] = qs0; - - int qs1 = (ql >> 4) & 0x0F0F0F0F; - qs1 |= (qh >> 12) & 0x00000010; // 16 -> 4 - qs1 |= (qh >> 5) & 0x00001000; // 17 -> 12 - qs1 |= (qh << 2) & 0x00100000; // 18 -> 20 - qs1 |= (qh << 9) & 0x10000000; // 19 -> 28 - - x_ql[i * (2*WARP_SIZE_GGUF + 1) + 2*k+1] = qs1; - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI5_1; - const int kbxd = k % blocks_per_tile_x_row; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI5_1) { - int i = i0 + i_offset * QI5_1 + k / blocks_per_tile_x_row; - - if (need_check) { - i = min(i, i_max); - } - - const block_q5_1 * bxi = bx0 + i*blocks_per_row + kbxd; - - x_dm[i * (WARP_SIZE_GGUF/QI5_1) + i / QI5_1 + kbxd] = bxi->dm; - } -} - -static __device__ __forceinline__ float vec_dot_q5_1_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - const int kyqs = k % (QI8_1/2) + QI8_1 * (k / (QI8_1/2)); - const int index_bx = i * (WARP_SIZE_GGUF/QI5_1) + + i/QI5_1 + k/QI5_1; - - int u[2*VDR_Q5_1_Q8_1_MMQ]; - -#pragma unroll - for (int l = 0; l < VDR_Q5_1_Q8_1_MMQ; ++l) { - u[2*l+0] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l) % WARP_SIZE_GGUF]; - u[2*l+1] = y_qs[j * WARP_SIZE_GGUF + (kyqs + l + QI5_1) % WARP_SIZE_GGUF]; - } - - return vec_dot_q8_1_q8_1_impl - (&x_ql[i * (2*WARP_SIZE_GGUF + 1) + 2 * k], u, x_dm[index_bx], y_ds[j * (WARP_SIZE_GGUF/QI8_1) + (2*k/QI8_1) % (WARP_SIZE_GGUF/QI8_1)]); -} - -static __device__ __forceinline__ float vec_dot_q8_0_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q8_0 * bq8_0 = (const block_q8_0 *) vbq; - - int v[VDR_Q8_0_Q8_1_MMVQ]; - int u[VDR_Q8_0_Q8_1_MMVQ]; - -#pragma unroll - for (int i = 0; i < VDR_Q8_0_Q8_1_MMVQ; ++i) { - v[i] = get_int_from_int8(bq8_0->qs, iqs + i); - u[i] = get_int_from_int8_aligned(bq8_1->qs, iqs + i); - } - - return vec_dot_q8_0_q8_1_impl(v, u, __half2float(bq8_0->d), __low2float(bq8_1->ds)); -} - -template static __device__ __forceinline__ void allocate_tiles_q8_0(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_qs[mmq_y * (WARP_SIZE_GGUF) + mmq_y]; - __shared__ float tile_x_d[mmq_y * (WARP_SIZE_GGUF/QI8_0) + mmq_y/QI8_0]; - - *x_ql = tile_x_qs; - *x_dm = (half2 *) tile_x_d; -} - -template static __device__ __forceinline__ void load_tiles_q8_0( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI8_0; - const int kqsx = k % QI8_0; - float * x_dmf = (float *) x_dm; - - const block_q8_0 * bx0 = (const block_q8_0 *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - - if (need_check) { - i = min(i, i_max); - } - const block_q8_0 * bxi = bx0 + i*blocks_per_row + kbx; - x_ql[i * (WARP_SIZE_GGUF + 1) + k] = get_int_from_int8(bxi->qs, kqsx); - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI8_0; - const int kbxd = k % blocks_per_tile_x_row; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI8_0) { - int i = i0 + i_offset * QI8_0 + k / blocks_per_tile_x_row; - - if (need_check) { - i = min(i, i_max); - } - const block_q8_0 * bxi = bx0 + i*blocks_per_row + kbxd; - x_dmf[i * (WARP_SIZE_GGUF/QI8_0) + i / QI8_0 + kbxd] = __half2float(bxi->d); - } -} - -static __device__ __forceinline__ float vec_dot_q8_0_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - const float * x_dmf = (const float *) x_dm; - const float * y_df = (const float *) y_ds; - - return vec_dot_q8_0_q8_1_impl - (&x_ql[i * (WARP_SIZE_GGUF + 1) + k], &y_qs[j * WARP_SIZE_GGUF + k], x_dmf[i * (WARP_SIZE_GGUF/QI8_0) + i/QI8_0 + k/QI8_0], - y_df[j * (WARP_SIZE_GGUF/QI8_1) + k/QI8_1]); -} - -static __device__ __forceinline__ float vec_dot_q2_K_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q2_K * bq2_K = (const block_q2_K *) vbq; - - const int bq8_offset = QR2_K * (iqs / QI8_1); - const int scale_offset = iqs - iqs % QI8_1 + (iqs % QI8_1) / (QI8_1/2); - - const uint8_t * scales = bq2_K->scales + scale_offset; - - const int v = get_int_from_uint8_aligned(bq2_K->qs, iqs); - int u[QR2_K]; - float d8[QR2_K]; - -#pragma unroll - for (int i = 0; i < QR2_K; ++ i) { - u[i] = get_int_from_int8_aligned(bq8_1[bq8_offset + i].qs, iqs % QI8_1); - d8[i] = __low2float(bq8_1[bq8_offset + i].ds); - } - - return vec_dot_q2_K_q8_1_impl_mmvq(v, u, scales, bq2_K->dm, d8); -} - -template static __device__ __forceinline__ void allocate_tiles_q2_K(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_ql[mmq_y * (WARP_SIZE_GGUF) + mmq_y]; - __shared__ half2 tile_x_dm[mmq_y * (WARP_SIZE_GGUF/QI2_K) + mmq_y/QI2_K]; - __shared__ int tile_x_sc[mmq_y * (WARP_SIZE_GGUF/4) + mmq_y/4]; - - *x_ql = tile_x_ql; - *x_dm = tile_x_dm; - *x_sc = tile_x_sc; -} - -template static __device__ __forceinline__ void load_tiles_q2_K( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI2_K; - const int kqsx = k % QI2_K; - - const block_q2_K * bx0 = (const block_q2_K *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - - if (need_check) { - i = min(i, i_max); - } - const block_q2_K * bxi = bx0 + i*blocks_per_row + kbx; - x_ql[i * (WARP_SIZE_GGUF + 1) + k] = get_int_from_uint8_aligned(bxi->qs, kqsx); - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI2_K; - const int kbxd = k % blocks_per_tile_x_row; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI2_K) { - int i = (i0 + i_offset * QI2_K + k / blocks_per_tile_x_row) % mmq_y; - - if (need_check) { - i = min(i, i_max); - } - const block_q2_K * bxi = bx0 + i*blocks_per_row + kbxd; - x_dm[i * (WARP_SIZE_GGUF/QI2_K) + i / QI2_K + kbxd] = bxi->dm; - } - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * 4) { - int i = i0 + i_offset * 4 + k / (WARP_SIZE_GGUF/4); - - if (need_check) { - i = min(i, i_max); - } - const block_q2_K * bxi = bx0 + i*blocks_per_row + (k % (WARP_SIZE_GGUF/4)) / (QI2_K/4); - x_sc[i * (WARP_SIZE_GGUF/4) + i / 4 + k % (WARP_SIZE_GGUF/4)] = get_int_from_uint8_aligned(bxi->scales, k % (QI2_K/4)); - } -} - -static __device__ __forceinline__ float vec_dot_q2_K_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - const int kbx = k / QI2_K; - const int ky = (k % QI2_K) * QR2_K; - const float * y_df = (const float *) y_ds; - - int v[QR2_K*VDR_Q2_K_Q8_1_MMQ]; - - const int kqsx = i * (WARP_SIZE_GGUF + 1) + kbx*QI2_K + (QI2_K/2) * (ky/(2*QI2_K)) + ky % (QI2_K/2); - const int shift = 2 * ((ky % (2*QI2_K)) / (QI2_K/2)); - -#pragma unroll - for (int l = 0; l < QR2_K*VDR_Q2_K_Q8_1_MMQ; ++l) { - v[l] = (x_ql[kqsx + l] >> shift) & 0x03030303; - } - - const uint8_t * scales = ((const uint8_t *) &x_sc[i * (WARP_SIZE_GGUF/4) + i/4 + kbx*4]) + ky/4; - - const int index_y = j * WARP_SIZE_GGUF + (QR2_K*k) % WARP_SIZE_GGUF; - return vec_dot_q2_K_q8_1_impl_mmq(v, &y_qs[index_y], scales, x_dm[i * (WARP_SIZE_GGUF/QI2_K) + i/QI2_K + kbx], y_df[index_y/QI8_1]); -} - -static __device__ __forceinline__ float vec_dot_q3_K_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q3_K * bq3_K = (const block_q3_K *) vbq; - - const int bq8_offset = QR3_K * (iqs / (QI3_K/2)); - const int scale_offset = iqs - iqs % QI8_1 + (iqs % QI8_1) / (QI8_1/2); - - const float d = __half2float(bq3_K->d); - - const int vl = get_int_from_uint8(bq3_K->qs, iqs); - - // invert the mask with ~ so that a 0/1 results in 4/0 being subtracted - const int vh = ~get_int_from_uint8(bq3_K->hmask, iqs % (QI3_K/2)) >> bq8_offset; - - int u[QR3_K]; - float d8[QR3_K]; - -#pragma unroll - for (int i = 0; i < QR3_K; ++i) { - u[i] = get_int_from_int8_aligned(bq8_1[bq8_offset + i].qs, iqs % QI8_1); - d8[i] = __low2float(bq8_1[bq8_offset + i].ds); - } - - return vec_dot_q3_K_q8_1_impl_mmvq(vl, vh, u, bq3_K->scales, scale_offset, d, d8); -} - -template static __device__ __forceinline__ void allocate_tiles_q3_K(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_ql[mmq_y * (WARP_SIZE_GGUF) + mmq_y]; - __shared__ half2 tile_x_dm[mmq_y * (WARP_SIZE_GGUF/QI3_K) + mmq_y/QI3_K]; - __shared__ int tile_x_qh[mmq_y * (WARP_SIZE_GGUF/2) + mmq_y/2]; - __shared__ int tile_x_sc[mmq_y * (WARP_SIZE_GGUF/4) + mmq_y/4]; - - *x_ql = tile_x_ql; - *x_dm = tile_x_dm; - *x_qh = tile_x_qh; - *x_sc = tile_x_sc; -} - -template static __device__ __forceinline__ void load_tiles_q3_K( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI3_K; - const int kqsx = k % QI3_K; - - const block_q3_K * bx0 = (const block_q3_K *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - if (need_check) { - i = min(i, i_max); - } - const block_q3_K * bxi = bx0 + i*blocks_per_row + kbx; - x_ql[i * (WARP_SIZE_GGUF + 1) + k] = get_int_from_uint8(bxi->qs, kqsx); - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI3_K; - const int kbxd = k % blocks_per_tile_x_row; - float * x_dmf = (float *) x_dm; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI3_K) { - int i = (i0 + i_offset * QI3_K + k / blocks_per_tile_x_row) % mmq_y; - if (need_check) { - i = min(i, i_max); - } - const block_q3_K * bxi = bx0 + i*blocks_per_row + kbxd; - x_dmf[i * (WARP_SIZE_GGUF/QI3_K) + i / QI3_K + kbxd] = __half2float(bxi->d); - } - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * 2) { - int i = i0 + i_offset * 2 + k / (WARP_SIZE_GGUF/2); - if (need_check) { - i = min(i, i_max); - } - const block_q3_K * bxi = bx0 + i*blocks_per_row + (k % (WARP_SIZE_GGUF/2)) / (QI3_K/2); - // invert the mask with ~ so that a 0/1 results in 4/0 being subtracted - x_qh[i * (WARP_SIZE_GGUF/2) + i / 2 + k % (WARP_SIZE_GGUF/2)] = ~get_int_from_uint8(bxi->hmask, k % (QI3_K/2)); - } - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * 4) { - int i = i0 + i_offset * 4 + k / (WARP_SIZE_GGUF/4); - if (need_check) { - i = min(i, i_max); - } - const block_q3_K * bxi = bx0 + i*blocks_per_row + (k % (WARP_SIZE_GGUF/4)) / (QI3_K/4); - - const int ksc = k % (QI3_K/4); - - const int ksc_low = ksc % (QI3_K/8); - const int shift_low = 4 * (ksc / (QI3_K/8)); - const int sc_low = (get_int_from_uint8(bxi->scales, ksc_low) >> shift_low) & 0x0F0F0F0F; - - const int ksc_high = QI3_K/8; - const int shift_high = 2 * ksc; - const int sc_high = ((get_int_from_uint8(bxi->scales, ksc_high) >> shift_high) << 4) & 0x30303030; - - const int sc = __vsubss4(sc_low | sc_high, 0x20202020); - - x_sc[i * (WARP_SIZE_GGUF/4) + i / 4 + k % (WARP_SIZE_GGUF/4)] = sc; - } -} - -static __device__ __forceinline__ float vec_dot_q3_K_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - - const int kbx = k / QI3_K; - const int ky = (k % QI3_K) * QR3_K; - const float * x_dmf = (const float *) x_dm; - const float * y_df = (const float *) y_ds; - - const int8_t * scales = ((const int8_t *) (x_sc + i * (WARP_SIZE_GGUF/4) + i/4 + kbx*4)) + ky/4; - - int v[QR3_K*VDR_Q3_K_Q8_1_MMQ]; - -#pragma unroll - for (int l = 0; l < QR3_K*VDR_Q3_K_Q8_1_MMQ; ++l) { - const int kqsx = i * (WARP_SIZE_GGUF + 1) + kbx*QI3_K + (QI3_K/2) * (ky/(2*QI3_K)) + ky % (QI3_K/2); - const int shift = 2 * ((ky % 32) / 8); - const int vll = (x_ql[kqsx + l] >> shift) & 0x03030303; - - const int vh = x_qh[i * (WARP_SIZE_GGUF/2) + i/2 + kbx * (QI3_K/2) + (ky+l)%8] >> ((ky+l) / 8); - const int vlh = (vh << 2) & 0x04040404; - - v[l] = __vsubss4(vll, vlh); - } - - const int index_y = j * WARP_SIZE_GGUF + (k*QR3_K) % WARP_SIZE_GGUF; - return vec_dot_q3_K_q8_1_impl_mmq(v, &y_qs[index_y], scales, x_dmf[i * (WARP_SIZE_GGUF/QI3_K) + i/QI3_K + kbx], y_df[index_y/QI8_1]); -} - -static __device__ __forceinline__ float vec_dot_q4_K_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - const block_q4_K * bq4_K = (const block_q4_K *) vbq; - - int v[2]; - int u[2*QR4_K]; - float d8[QR4_K]; - - // iqs is in 0,2..30. bq8_offset = iqs/4 -> bq8_offset = 0, 2, 4, 6 - const int bq8_offset = QR4_K * ((iqs/2) / (QI8_1/2)); - - // iqs = 0....3 -> bq8_offset = 0, want q4_offset = 0, 4, 8, 12 - // iqs = 4....7 -> bq8_offset = 2, want q4_offset = 32, 36, 40, 44 - // iqs = 8...11 -> bq8_offset = 4, want q4_offset = 64, 68, 72, 76 - // iqs = 12..15 -> bq8_offset = 6, want q4_offset = 96, 100, 104, 108 - - const int * q4 = (const int *)(bq4_K->qs + 16 * bq8_offset + 4 * ((iqs/2)%4)); - v[0] = q4[0]; - v[1] = q4[4]; - - const uint16_t * scales = (const uint16_t *)bq4_K->scales; - uint16_t aux[2]; - const int j = bq8_offset/2; - if (j < 2) { - aux[0] = scales[j+0] & 0x3f3f; - aux[1] = scales[j+2] & 0x3f3f; - } else { - aux[0] = ((scales[j+2] >> 0) & 0x0f0f) | ((scales[j-2] & 0xc0c0) >> 2); - aux[1] = ((scales[j+2] >> 4) & 0x0f0f) | ((scales[j-0] & 0xc0c0) >> 2); - } - const uint8_t * sc = (const uint8_t *)aux; - const uint8_t * m = sc + 2; - - for (int i = 0; i < QR4_K; ++i) { - const block_q8_1 * bq8i = bq8_1 + bq8_offset + i; - d8[i] = __low2float(bq8i->ds); - - const int * q8 = (const int *)bq8i->qs + ((iqs/2)%4); - u[2*i+0] = q8[0]; - u[2*i+1] = q8[4]; - } - - return vec_dot_q4_K_q8_1_impl_vmmq(v, u, sc, m, bq4_K->dm, d8); -} - -template static __device__ __forceinline__ void allocate_tiles_q4_K(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_ql[mmq_y * (WARP_SIZE_GGUF) + mmq_y]; - __shared__ half2 tile_x_dm[mmq_y * (WARP_SIZE_GGUF/QI4_K) + mmq_y/QI4_K]; - __shared__ int tile_x_sc[mmq_y * (WARP_SIZE_GGUF/8) + mmq_y/8]; - - *x_ql = tile_x_ql; - *x_dm = tile_x_dm; - *x_sc = tile_x_sc; -} - -template static __device__ __forceinline__ void load_tiles_q4_K( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI4_K; // == 0 if QK_K == 256 - const int kqsx = k % QI4_K; // == k if QK_K == 256 - - const block_q4_K * bx0 = (const block_q4_K *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - - if (need_check) { - i = min(i, i_max); - } - const block_q4_K * bxi = bx0 + i*blocks_per_row + kbx; - x_ql[i * (WARP_SIZE_GGUF + 1) + k] = get_int_from_uint8_aligned(bxi->qs, kqsx); - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI4_K; // == 1 if QK_K == 256 - const int kbxd = k % blocks_per_tile_x_row; // == 0 if QK_K == 256 - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI4_K) { - int i = (i0 + i_offset * QI4_K + k / blocks_per_tile_x_row) % mmq_y; - if (need_check) { - i = min(i, i_max); - } - const block_q4_K * bxi = bx0 + i*blocks_per_row + kbxd; - x_dm[i * (WARP_SIZE_GGUF/QI4_K) + i / QI4_K + kbxd] = bxi->dm; - } - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * 8) { - int i = (i0 + i_offset * 8 + k / (WARP_SIZE_GGUF/8)) % mmq_y; - - if (need_check) { - i = min(i, i_max); - } - - const block_q4_K * bxi = bx0 + i*blocks_per_row + (k % (WARP_SIZE_GGUF/8)) / (QI4_K/8); - - const int * scales = (const int *) bxi->scales; - - const int ksc = k % (WARP_SIZE_GGUF/8); - // scale arrangement after the following two lines: sc0,...,sc3, sc4,...,sc7, m0,...,m3, m4,...,m8 - int scales8 = (scales[(ksc%2) + (ksc!=0)] >> (4 * (ksc & (ksc/2)))) & 0x0F0F0F0F; // lower 4 bits - scales8 |= (scales[ksc/2] >> (2 * (ksc % 2))) & 0x30303030; // upper 2 bits - - x_sc[i * (WARP_SIZE_GGUF/8) + i / 8 + ksc] = scales8; - } -} - -static __device__ __forceinline__ float vec_dot_q4_K_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - (void)x_qh; - - const uint8_t * sc = ((const uint8_t *) &x_sc[i * (WARP_SIZE_GGUF/8) + i/8 + k/16]) + 2*((k % 16) / 8); - - const int index_y = j * WARP_SIZE_GGUF + (QR4_K*k) % WARP_SIZE_GGUF; - return vec_dot_q4_K_q8_1_impl_mmq(&x_ql[i * (WARP_SIZE_GGUF + 1) + k], &y_qs[index_y], sc, sc+8, - x_dm[i * (WARP_SIZE_GGUF/QI4_K) + i/QI4_K], &y_ds[index_y/QI8_1]); -} - -static __device__ __forceinline__ float vec_dot_q5_K_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q5_K * bq5_K = (const block_q5_K *) vbq; - - int vl[2]; - int vh[2]; - int u[2*QR5_K]; - float d8[QR5_K]; - - const int bq8_offset = QR5_K * ((iqs/2) / (QI8_1/2)); - const int * ql = (const int *)(bq5_K->qs + 16 * bq8_offset + 4 * ((iqs/2)%4)); - const int * qh = (const int *)(bq5_K->qh + 4 * ((iqs/2)%4)); - - vl[0] = ql[0]; - vl[1] = ql[4]; - - vh[0] = qh[0] >> bq8_offset; - vh[1] = qh[4] >> bq8_offset; - - const uint16_t * scales = (const uint16_t *)bq5_K->scales; - uint16_t aux[2]; - const int j = bq8_offset/2; - if (j < 2) { - aux[0] = scales[j+0] & 0x3f3f; - aux[1] = scales[j+2] & 0x3f3f; - } else { - aux[0] = ((scales[j+2] >> 0) & 0x0f0f) | ((scales[j-2] & 0xc0c0) >> 2); - aux[1] = ((scales[j+2] >> 4) & 0x0f0f) | ((scales[j-0] & 0xc0c0) >> 2); - } - const uint8_t * sc = (const uint8_t *)aux; - const uint8_t * m = sc + 2; - -#pragma unroll - for (int i = 0; i < QR5_K; ++i) { - const block_q8_1 * bq8i = bq8_1 + bq8_offset + i; - d8[i] = __low2float(bq8i->ds); - - const int * q8 = (const int *)bq8i->qs + ((iqs/2)%4); - u[2*i+0] = q8[0]; - u[2*i+1] = q8[4]; - } - - return vec_dot_q5_K_q8_1_impl_vmmq(vl, vh, u, sc, m, bq5_K->dm, d8); -} - -template static __device__ __forceinline__ void allocate_tiles_q5_K(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_ql[mmq_y * (2*WARP_SIZE_GGUF) + mmq_y]; - __shared__ half2 tile_x_dm[mmq_y * (WARP_SIZE_GGUF/QI5_K) + mmq_y/QI5_K]; - __shared__ int tile_x_sc[mmq_y * (WARP_SIZE_GGUF/8) + mmq_y/8]; - - *x_ql = tile_x_ql; - *x_dm = tile_x_dm; - *x_sc = tile_x_sc; -} - -template static __device__ __forceinline__ void load_tiles_q5_K( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI5_K; // == 0 if QK_K == 256 - const int kqsx = k % QI5_K; // == k if QK_K == 256 - - const block_q5_K * bx0 = (const block_q5_K *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - - if (need_check) { - i = min(i, i_max); - } - - const block_q5_K * bxi = bx0 + i*blocks_per_row + kbx; - const int ky = QR5_K*kqsx; - - const int ql = get_int_from_uint8_aligned(bxi->qs, kqsx); - const int ql0 = (ql >> 0) & 0x0F0F0F0F; - const int ql1 = (ql >> 4) & 0x0F0F0F0F; - - const int qh = get_int_from_uint8_aligned(bxi->qh, kqsx % (QI5_K/4)); - const int qh0 = ((qh >> (2 * (kqsx / (QI5_K/4)) + 0)) << 4) & 0x10101010; - const int qh1 = ((qh >> (2 * (kqsx / (QI5_K/4)) + 1)) << 4) & 0x10101010; - - const int kq0 = ky - ky % (QI5_K/2) + k % (QI5_K/4) + 0; - const int kq1 = ky - ky % (QI5_K/2) + k % (QI5_K/4) + (QI5_K/4); - - x_ql[i * (2*WARP_SIZE_GGUF + 1) + kq0] = ql0 | qh0; - x_ql[i * (2*WARP_SIZE_GGUF + 1) + kq1] = ql1 | qh1; - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI5_K; // == 1 if QK_K == 256 - const int kbxd = k % blocks_per_tile_x_row; // == 0 if QK_K == 256 - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI5_K) { - int i = (i0 + i_offset * QI5_K + k / blocks_per_tile_x_row) % mmq_y; - - if (need_check) { - i = min(i, i_max); - } - - const block_q5_K * bxi = bx0 + i*blocks_per_row + kbxd; - x_dm[i * (WARP_SIZE_GGUF/QI5_K) + i / QI5_K + kbxd] = bxi->dm; - } - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * 8) { - int i = (i0 + i_offset * 8 + k / (WARP_SIZE_GGUF/8)) % mmq_y; - - if (need_check) { - i = min(i, i_max); - } - - const block_q5_K * bxi = bx0 + i*blocks_per_row + (k % (WARP_SIZE_GGUF/8)) / (QI5_K/8); - - const int * scales = (const int *) bxi->scales; - - const int ksc = k % (WARP_SIZE_GGUF/8); - - // scale arrangement after the following two lines: sc0,...,sc3, sc4,...,sc7, m0,...,m3, m4,...,m8 - int scales8 = (scales[(ksc%2) + (ksc!=0)] >> (4 * (ksc & (ksc/2)))) & 0x0F0F0F0F; // lower 4 bits - scales8 |= (scales[ksc/2] >> (2 * (ksc % 2))) & 0x30303030; // upper 2 bits - - x_sc[i * (WARP_SIZE_GGUF/8) + i / 8 + ksc] = scales8; - } -} - -static __device__ __forceinline__ float vec_dot_q5_K_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - const uint8_t * sc = ((const uint8_t *) &x_sc[i * (WARP_SIZE_GGUF/8) + i/8 + k/16]) + 2 * ((k % 16) / 8); - - const int index_x = i * (QR5_K*WARP_SIZE_GGUF + 1) + QR5_K*k; - const int index_y = j * WARP_SIZE_GGUF + (QR5_K*k) % WARP_SIZE_GGUF; - return vec_dot_q5_K_q8_1_impl_mmq(&x_ql[index_x], &y_qs[index_y], sc, sc+8, - x_dm[i * (WARP_SIZE_GGUF/QI5_K) + i/QI5_K], &y_ds[index_y/QI8_1]); -} - -static __device__ __forceinline__ float vec_dot_q6_K_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - - const block_q6_K * bq6_K = (const block_q6_K *) vbq; - - const int bq8_offset = 2 * QR6_K * (iqs / (QI6_K/2)) + (iqs % (QI6_K/2)) / (QI6_K/4); - const int scale_offset = (QI6_K/4) * (iqs / (QI6_K/2)) + (iqs % (QI6_K/2)) / (QI6_K/8); - const int vh_shift = 2 * ((iqs % (QI6_K/2)) / (QI6_K/4)); - - const int vl = get_int_from_uint8(bq6_K->ql, iqs); - const int vh = get_int_from_uint8(bq6_K->qh, (QI6_K/4) * (iqs / (QI6_K/2)) + iqs % (QI6_K/4)) >> vh_shift; - - const int8_t * scales = bq6_K->scales + scale_offset; - - int u[QR6_K]; - float d8[QR6_K]; - -#pragma unroll - for (int i = 0; i < QR6_K; ++i) { - u[i] = get_int_from_int8_aligned(bq8_1[bq8_offset + 2*i].qs, iqs % QI8_1); - d8[i] = __low2float(bq8_1[bq8_offset + 2*i].ds); - } - - return vec_dot_q6_K_q8_1_impl_mmvq(vl, vh, u, scales, __half2float(bq6_K->d), d8); -} - -template static __device__ __forceinline__ void allocate_tiles_q6_K(int ** x_ql, half2 ** x_dm, int ** x_qh, int ** x_sc) { - __shared__ int tile_x_ql[mmq_y * (2*WARP_SIZE_GGUF) + mmq_y]; - __shared__ half2 tile_x_dm[mmq_y * (WARP_SIZE_GGUF/QI6_K) + mmq_y/QI6_K]; - __shared__ int tile_x_sc[mmq_y * (WARP_SIZE_GGUF/8) + mmq_y/8]; - - *x_ql = tile_x_ql; - *x_dm = tile_x_dm; - *x_sc = tile_x_sc; -} - -template static __device__ __forceinline__ void load_tiles_q6_K( - const void * __restrict__ vx, int * __restrict__ x_ql, half2 * __restrict__ x_dm, int * __restrict__ x_qh, - int * __restrict__ x_sc, const int & i_offset, const int & i_max, const int & k, const int & blocks_per_row) { - const int kbx = k / QI6_K; // == 0 if QK_K == 256 - const int kqsx = k % QI6_K; // == k if QK_K == 256 - - const block_q6_K * bx0 = (const block_q6_K *) vx; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps) { - int i = i0 + i_offset; - - if (need_check) { - i = min(i, i_max); - } - - const block_q6_K * bxi = bx0 + i*blocks_per_row + kbx; - const int ky = QR6_K*kqsx; - - const int ql = get_int_from_uint8(bxi->ql, kqsx); - const int ql0 = (ql >> 0) & 0x0F0F0F0F; - const int ql1 = (ql >> 4) & 0x0F0F0F0F; - - const int qh = get_int_from_uint8(bxi->qh, (QI6_K/4) * (kqsx / (QI6_K/2)) + kqsx % (QI6_K/4)); - const int qh0 = ((qh >> (2 * ((kqsx % (QI6_K/2)) / (QI6_K/4)))) << 4) & 0x30303030; - const int qh1 = (qh >> (2 * ((kqsx % (QI6_K/2)) / (QI6_K/4)))) & 0x30303030; - - const int kq0 = ky - ky % QI6_K + k % (QI6_K/2) + 0; - const int kq1 = ky - ky % QI6_K + k % (QI6_K/2) + (QI6_K/2); - - x_ql[i * (2*WARP_SIZE_GGUF + 1) + kq0] = __vsubss4(ql0 | qh0, 0x20202020); - x_ql[i * (2*WARP_SIZE_GGUF + 1) + kq1] = __vsubss4(ql1 | qh1, 0x20202020); - } - - const int blocks_per_tile_x_row = WARP_SIZE_GGUF / QI6_K; // == 1 if QK_K == 256 - const int kbxd = k % blocks_per_tile_x_row; // == 0 if QK_K == 256 - float * x_dmf = (float *) x_dm; - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * QI6_K) { - int i = (i0 + i_offset * QI6_K + k / blocks_per_tile_x_row) % mmq_y; - - if (need_check) { - i = min(i, i_max); - } - - const block_q6_K * bxi = bx0 + i*blocks_per_row + kbxd; - - x_dmf[i * (WARP_SIZE_GGUF/QI6_K) + i / QI6_K + kbxd] = __half2float(bxi->d); - } - -#pragma unroll - for (int i0 = 0; i0 < mmq_y; i0 += nwarps * 8) { - int i = (i0 + i_offset * 8 + k / (WARP_SIZE_GGUF/8)) % mmq_y; - - if (need_check) { - i = min(i, i_max); - } - - const block_q6_K * bxi = bx0 + i*blocks_per_row + (k % (WARP_SIZE_GGUF/8)) / 4; - - x_sc[i * (WARP_SIZE_GGUF/8) + i / 8 + k % (WARP_SIZE_GGUF/8)] = get_int_from_int8(bxi->scales, k % (QI6_K/8)); - } -} - -static __device__ __forceinline__ float vec_dot_q6_K_q8_1_mul_mat( - const int * __restrict__ x_ql, const half2 * __restrict__ x_dm, const int * __restrict__ x_qh, const int * __restrict__ x_sc, - const int * __restrict__ y_qs, const half2 * __restrict__ y_ds, const int & i, const int & j, const int & k) { - const float * x_dmf = (const float *) x_dm; - const float * y_df = (const float *) y_ds; - - const int8_t * sc = ((const int8_t *) &x_sc[i * (WARP_SIZE_GGUF/8) + i/8 + k/8]); - - const int index_x = i * (QR6_K*WARP_SIZE_GGUF + 1) + QR6_K*k; - const int index_y = j * WARP_SIZE_GGUF + (QR6_K*k) % WARP_SIZE_GGUF; - return vec_dot_q6_K_q8_1_impl_mmq(&x_ql[index_x], &y_qs[index_y], sc, x_dmf[i * (WARP_SIZE_GGUF/QI6_K) + i/QI6_K], &y_df[index_y/QI8_1]); -} - -static __device__ __forceinline__ float vec_dot_iq2_xxs_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - const block_iq2_xxs * bq2 = (const block_iq2_xxs *) vbq; - - const int ib32 = iqs; - const uint16_t * q2 = bq2->qs + 4*ib32; - const uint8_t * aux8 = (const uint8_t *)q2; - const int8_t * q8 = bq8_1[ib32].qs; - uint32_t aux32 = q2[2] | (q2[3] << 16); - int sumi = 0; - for (int l = 0; l < 4; ++l) { - const uint8_t * grid = (const uint8_t *)(iq2xxs_grid + aux8[l]); - const uint8_t signs = ksigns_iq2xs[aux32 & 127]; - for (int j = 0; j < 8; ++j) { - sumi += q8[j] * grid[j] * (signs & kmask_iq2xs[j] ? -1 : 1); - } - q8 += 8; - aux32 >>= 7; - } - const float d = __half2float(bq2->d) * (0.5f + aux32) * __half2float(bq8_1[ib32].ds.x) * 0.25f; - return d * sumi; -} - -static __device__ __forceinline__ float vec_dot_iq2_xs_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { - const block_iq2_xs * bq2 = (const block_iq2_xs *) vbq; - - const int ib32 = iqs; - const uint16_t * q2 = bq2->qs + 4*ib32; - const int8_t * q8 = bq8_1[ib32].qs; - const uint8_t ls1 = bq2->scales[ib32] & 0xf; - const uint8_t ls2 = bq2->scales[ib32] >> 4; - int sumi1 = 0; - for (int l = 0; l < 2; ++l) { - const uint8_t * grid = (const uint8_t *)(iq2xs_grid + (q2[l] & 511)); - const uint8_t signs = ksigns_iq2xs[q2[l] >> 9]; - for (int j = 0; j < 8; ++j) { - sumi1 += q8[j] * grid[j] * (signs & kmask_iq2xs[j] ? -1 : 1); - } - q8 += 8; - } - int sumi2 = 0; - for (int l = 2; l < 4; ++l) { - const uint8_t * grid = (const uint8_t *)(iq2xs_grid + (q2[l] & 511)); - const uint8_t signs = ksigns_iq2xs[q2[l] >> 9]; - for (int j = 0; j < 8; ++j) { - sumi2 += q8[j] * grid[j] * (signs & kmask_iq2xs[j] ? -1 : 1); - } - q8 += 8; - } - const float d = __half2float(bq2->d) * __half2float(bq8_1[ib32].ds.x) * 0.25f; - return d * ((0.5f + ls1) * sumi1 + (0.5f + ls2) * sumi2); -} - -static __device__ __forceinline__ float vec_dot_iq2_s_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - const block_iq2_s * bq2 = (const block_iq2_s *) vbq; - - const int ib32 = iqs; - const int8_t * q8 = bq8_1[ib32].qs; - const uint8_t * signs = bq2->qs + QK_K/8 + 4*ib32; - const uint8_t ls1 = bq2->scales[ib32] & 0xf; - const uint8_t ls2 = bq2->scales[ib32] >> 4; - int sumi1 = 0; - for (int l = 0; l < 2; ++l) { - const uint32_t * grid = (const uint32_t *)(iq2s_grid + (bq2->qs[4*ib32+l] | ((bq2->qh[ib32] << (8-2*l)) & 0x300))); - const uint32_t signs0 = __vcmpeq4(((signs[l] & 0xf) * 0x01010101) & 0x08040201, 0x08040201); - const uint32_t signs1 = __vcmpeq4(((signs[l] >> 4) * 0x01010101) & 0x08040201, 0x08040201); - const int grid_l = __vsub4(grid[0] ^ signs0, signs0); - const int grid_h = __vsub4(grid[1] ^ signs1, signs1); - sumi1 = __dp4a(grid_l, *((const int *)q8 + 0), sumi1); - sumi1 = __dp4a(grid_h, *((const int *)q8 + 1), sumi1); - q8 += 8; - } - int sumi2 = 0; - for (int l = 2; l < 4; ++l) { - const uint32_t * grid = (const uint32_t *)(iq2s_grid + (bq2->qs[4*ib32+l] | ((bq2->qh[ib32] << (8-2*l)) & 0x300))); - const uint32_t signs0 = __vcmpeq4(((signs[l] & 0xf) * 0x01010101) & 0x08040201, 0x08040201); - const uint32_t signs1 = __vcmpeq4(((signs[l] >> 4) * 0x01010101) & 0x08040201, 0x08040201); - const int grid_l = __vsub4(grid[0] ^ signs0, signs0); - const int grid_h = __vsub4(grid[1] ^ signs1, signs1); - sumi2 = __dp4a(grid_l, *((const int *)q8 + 0), sumi2); - sumi2 = __dp4a(grid_h, *((const int *)q8 + 1), sumi2); - q8 += 8; - } - const float d = __half2float(bq2->d) * __low2float(bq8_1[ib32].ds) * 0.25f; - return d * ((0.5f + ls1) * sumi1 + (0.5f + ls2) * sumi2); -#endif -} - -static __device__ __forceinline__ float vec_dot_iq3_xxs_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - const block_iq3_xxs * bq2 = (const block_iq3_xxs *) vbq; - - const int ib32 = iqs; - const uint8_t * q3 = bq2->qs + 8*ib32; - const uint16_t * gas = (const uint16_t *)(bq2->qs + QK_K/4) + 2*ib32; - const int8_t * q8 = bq8_1[ib32].qs; - uint32_t aux32 = gas[0] | (gas[1] << 16); - int sumi = 0; - for (int l = 0; l < 4; ++l) { - const uint32_t * grid1 = iq3xxs_grid + q3[2*l+0]; - const uint32_t * grid2 = iq3xxs_grid + q3[2*l+1]; - const uint32_t * signs = (const uint32_t *)(ksigns64 + (aux32 & 127)); - const int grid_l = __vsub4(grid1[0] ^ signs[0], signs[0]); - const int grid_h = __vsub4(grid2[0] ^ signs[1], signs[1]); - sumi = __dp4a(grid_l, *((int *)q8+0), sumi); - sumi = __dp4a(grid_h, *((int *)q8+1), sumi); - q8 += 8; - aux32 >>= 7; - } - const float d = __half2float(bq2->d) * (0.5f + aux32) * __low2float(bq8_1[ib32].ds) * 0.5f; - return d * sumi; -#endif -} - -static __device__ __forceinline__ float vec_dot_iq3_s_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - const block_iq3_s * bq2 = (const block_iq3_s *) vbq; - - const int ib32 = iqs; - const uint8_t * qs = bq2->qs + 8*ib32; - const int8_t * q8 = bq8_1[ib32].qs; - int sumi = 0; - for (int l = 0; l < 4; ++l) { - const uint32_t * grid1 = iq3xs_grid + (qs[2*l+0] | ((bq2->qh[ib32] << (8 - 2*l)) & 256)); - const uint32_t * grid2 = iq3xs_grid + (qs[2*l+1] | ((bq2->qh[ib32] << (7 - 2*l)) & 256)); - uint32_t signs0 = __vcmpeq4(((bq2->signs[4*ib32+l] & 0xf) * 0x01010101) & 0x08040201, 0x08040201); - uint32_t signs1 = __vcmpeq4(((bq2->signs[4*ib32+l] >> 4) * 0x01010101) & 0x08040201, 0x08040201); - const int grid_l = __vsub4(grid1[0] ^ signs0, signs0); - const int grid_h = __vsub4(grid2[0] ^ signs1, signs1); - sumi = __dp4a(grid_l, *((int *)q8+0), sumi); - sumi = __dp4a(grid_h, *((int *)q8+1), sumi); - q8 += 8; - } - const float d = __half2float(bq2->d) * (0.5f + ((bq2->scales[ib32/2] >> 4*(ib32%2)) & 0xf)) * __low2float(bq8_1[ib32].ds) * 0.5f; - return d * sumi; -#endif -} - -static __device__ __forceinline__ float vec_dot_iq1_s_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - const block_iq1_s * bq1 = (const block_iq1_s *) vbq; - - const int qs_packed = get_int_b2(bq1->qs, iqs); - const uint8_t * qs = (const uint8_t *) &qs_packed; - - const int qh = bq1->qh[iqs]; - - int sumi = 0; -#pragma unroll - for (int l0 = 0; l0 < 8; l0 += 2) { - const int grid = iq1s_grid_gpu[qs[l0/2] | (((qh >> 3*(l0/2)) & 0x07) << 8)]; - - const int grid0 = (grid >> 0) & 0x0F0F0F0F; - const int grid1 = (grid >> 4) & 0x0F0F0F0F; - - const int u0 = get_int_b4(bq8_1[iqs].qs, l0 + 0); - const int u1 = get_int_b4(bq8_1[iqs].qs, l0 + 1); - - sumi = __dp4a(grid0, u0, sumi); - sumi = __dp4a(grid1, u1, sumi); - } - - const float d1q = __half2float(bq1->d) * (((qh >> 11) & 0x0E) + 1); - const float delta = -1.0f + IQ1S_DELTA - (qh & 0x8000) * (2.0f*IQ1S_DELTA/0x8000); - const float2 ds = __half22float2(bq8_1[iqs].ds); - return d1q * (ds.x*sumi + ds.y*delta); -#endif -} - -static __device__ __forceinline__ float vec_dot_iq1_m_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - - const block_iq1_m * bq1 = (const block_iq1_m *) vbq; - - const int qs_packed = get_int_b4(bq1->qs, iqs); - const uint8_t * qs = (const uint8_t *) &qs_packed; - - int sumi[2] = {0}; - float sumf[2] = {0.0f}; -#pragma unroll - for (int l0 = 0; l0 < 8; l0 += 2) { - const int qhl = bq1->qh[2*iqs + l0/4] >> (4 * ((l0/2) % 2)); - - const int grid = iq1s_grid_gpu[qs[l0/2] | ((qhl & 0x07) << 8)]; - - const int grid0 = (grid >> 0) & 0x0F0F0F0F; - const int grid1 = (grid >> 4) & 0x0F0F0F0F; - - const int u0 = get_int_b4(bq8_1[iqs].qs, l0 + 0); - const int u1 = get_int_b4(bq8_1[iqs].qs, l0 + 1); - - sumi[l0/4] = __dp4a(grid0, u0, sumi[l0/4]); - sumi[l0/4] = __dp4a(grid1, u1, sumi[l0/4]); - - const float delta = -1.0f + IQ1M_DELTA - (qhl & 0x08) * (2.0f*IQ1M_DELTA/0x08); - int sumy = 0; - sumy = __dp4a(u0, 0x01010101, sumy); - sumy = __dp4a(u1, 0x01010101, sumy); - sumf[l0/4] += delta*sumy; - } - - const uint16_t * sc = (const uint16_t *) bq1->scales; - - iq1m_scale_t scale; - scale.u16 = (sc[0] >> 12) | ((sc[1] >> 8) & 0x00F0) | ((sc[2] >> 4) & 0x0F00) | (sc[3] & 0xF000); - const float d = __half2float(scale.f16) * __low2float(bq8_1[iqs].ds); - - const int tmp = sc[iqs/2] >> (6*(iqs%2)); - const int sc0 = 2*((tmp >> 0) & 0x07) + 1; - const int sc1 = 2*((tmp >> 3) & 0x07) + 1; - return d * ((sumi[0] + sumf[0]) * sc0 + (sumi[1] + sumf[1]) * sc1); -#endif -} - -static __device__ __forceinline__ void get_int_from_table_16(const uint32_t & q4, const uint8_t * values, - int & val1, int & val2) { - - uint32_t aux32; const uint8_t * q8 = (const uint8_t *)&aux32; - aux32 = q4 & 0x0f0f0f0f; - uint16_t v1 = values[q8[0]] | (values[q8[1]] << 8); - uint16_t v2 = values[q8[2]] | (values[q8[3]] << 8); - val1 = v1 | (v2 << 16); - aux32 = (q4 >> 4) & 0x0f0f0f0f; - v1 = values[q8[0]] | (values[q8[1]] << 8); - v2 = values[q8[2]] | (values[q8[3]] << 8); - val2 = v1 | (v2 << 16); -} - -static __device__ __forceinline__ float vec_dot_iq4_nl_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - - const block_iq4_nl * bq = (const block_iq4_nl *) vbq; - - const uint16_t * q4 = (const uint16_t *)bq->qs + 2*iqs; - const int32_t * q8 = (const int32_t *)bq8_1->qs + iqs; - - const uint8_t * values = (const uint8_t *)kvalues_iq4nl; - - int v1, v2; - int sumi1 = 0, sumi2 = 0; - for (int l = 0; l < VDR_Q4_0_Q8_1_MMVQ; ++l) { - const uint32_t aux = q4[2*l] | (q4[2*l+1] << 16); - get_int_from_table_16(aux, values, v1, v2); - sumi1 = __dp4a(v1, q8[l+0], sumi1); - sumi2 = __dp4a(v2, q8[l+4], sumi2); - } - const float d = __half2float(bq->d) * __low2float(bq8_1->ds); - return d * (sumi1 + sumi2); -#endif -} - - -static __device__ __forceinline__ float vec_dot_iq4_xs_q8_1( - const void * __restrict__ vbq, const block_q8_1 * __restrict__ bq8_1, const int & iqs) { -#if defined __CUDA_ARCH__ && __CUDA_ARCH__ >= 610 || defined USE_ROCM - const block_iq4_xs * bq4 = (const block_iq4_xs *) vbq; - const uint8_t * values = (const uint8_t *)kvalues_iq4nl; - - // iqs is 0...7 - const int ib32 = iqs; - const int32_t * q8 = (const int *)bq8_1[ib32].qs; - const uint32_t * q4 = (const uint32_t *)bq4->qs + 4*ib32; - const int8_t ls = ((bq4->scales_l[ib32/2] >> 4*(ib32%2)) & 0xf) | (((bq4->scales_h >> 2*ib32) & 3) << 4); - const float d = __half2float(bq4->d) * (ls - 32) * __low2float(bq8_1[ib32].ds); - int v1, v2; - int sumi1 = 0, sumi2 = 0; - for (int j = 0; j < 4; ++j) { - get_int_from_table_16(q4[j], values, v1, v2); - sumi1 = __dp4a(v1, q8[j+0], sumi1); - sumi2 = __dp4a(v2, q8[j+4], sumi2); - } - return d * (sumi1 + sumi2); -#endif -} \ No newline at end of file diff --git a/csrc/libtorch_stable/quantization/gptq_allspark/allspark_repack.cu b/csrc/libtorch_stable/quantization/gptq_allspark/allspark_repack.cu index b325d30a041a..9a00f66241b5 100644 --- a/csrc/libtorch_stable/quantization/gptq_allspark/allspark_repack.cu +++ b/csrc/libtorch_stable/quantization/gptq_allspark/allspark_repack.cu @@ -150,6 +150,8 @@ void rearrange_kn_weight_as_n32k16_order( void* b_zero_reorder = has_zp ? b_zeros_reorder.value().mutable_data_ptr() : nullptr; + const torch::stable::accelerator::DeviceGuard device_guard( + b_qweight.get_device_index()); cudaStream_t stream = get_current_cuda_stream(); if (b_scales.scalar_type() == torch::headeronly::ScalarType::Half) { allspark::rearrange_kn_weight_as_n32k16_order_ldg16<__half>( diff --git a/csrc/libtorch_stable/quantization/gptq_allspark/allspark_utils.cuh b/csrc/libtorch_stable/quantization/gptq_allspark/allspark_utils.cuh index ce96c2d11fea..ac33d5f2ce6c 100644 --- a/csrc/libtorch_stable/quantization/gptq_allspark/allspark_utils.cuh +++ b/csrc/libtorch_stable/quantization/gptq_allspark/allspark_utils.cuh @@ -6,7 +6,7 @@ #include -#include "quantization/marlin/marlin_dtypes.cuh" +#include "libtorch_stable/quantization/marlin/marlin_dtypes.cuh" using marlin::MarlinScalarType2; namespace allspark { diff --git a/csrc/quantization/machete/Readme.md b/csrc/libtorch_stable/quantization/machete/Readme.md similarity index 100% rename from csrc/quantization/machete/Readme.md rename to csrc/libtorch_stable/quantization/machete/Readme.md diff --git a/csrc/quantization/machete/generate.py b/csrc/libtorch_stable/quantization/machete/generate.py similarity index 95% rename from csrc/quantization/machete/generate.py rename to csrc/libtorch_stable/quantization/machete/generate.py index e12601e9e974..11a5bbdd13c1 100644 --- a/csrc/quantization/machete/generate.py +++ b/csrc/libtorch_stable/quantization/machete/generate.py @@ -39,10 +39,10 @@ {% for impl_config in impl_configs %} {% set type_sig = gen_type_sig(impl_config.types) -%} {% for s in impl_config.schedules %} -extern torch::Tensor impl_{{type_sig}}_sch_{{gen_sch_sig(s)}}(MMArgs); +extern torch::stable::Tensor impl_{{type_sig}}_sch_{{gen_sch_sig(s)}}(MMArgs); {%- endfor %} -torch::Tensor mm_dispatch_{{type_sig}}(MMArgs args) { +torch::stable::Tensor mm_dispatch_{{type_sig}}(MMArgs args) { [[maybe_unused]] auto M = args.A.size(0); [[maybe_unused]] auto N = args.B.size(1); [[maybe_unused]] auto K = args.A.size(1); @@ -59,14 +59,14 @@ if (*args.maybe_schedule == "{{ gen_sch_sig(s) }}") return impl_{{type_sig}}_sch_{{ gen_sch_sig(s) }}(args); {%- endfor %} - TORCH_CHECK_NOT_IMPLEMENTED(false, "machete_gemm(..) is not implemented for " + STD_TORCH_CHECK_NOT_IMPLEMENTED(false, "machete_gemm(..) is not implemented for " "schedule = ", *args.maybe_schedule); } {%- endfor %} -static inline std::optional maybe_scalartype( - std::optional const& t) { +static inline std::optional maybe_scalartype( + std::optional const& t) { if (!t) { return std::nullopt; } else { @@ -74,7 +74,7 @@ }; } -torch::Tensor mm_dispatch(MMArgs args) { +torch::stable::Tensor mm_dispatch(MMArgs args) { auto out_type = args.maybe_out_type.value_or(args.A.scalar_type()); auto a_type = args.A.scalar_type(); auto maybe_g_scales_type = maybe_scalartype(args.maybe_group_scales); @@ -105,19 +105,19 @@ } {%- endfor %} - TORCH_CHECK_NOT_IMPLEMENTED( + STD_TORCH_CHECK_NOT_IMPLEMENTED( false, "machete_mm(..) is not implemented for " - "a_type=", args.A.scalar_type(), + "a_type=", torch::headeronly::toString(args.A.scalar_type()), ", b_type=", args.b_type.str(), - ", out_type=", out_type, + ", out_type=", torch::headeronly::toString(out_type), ", with_group_scale_type=", maybe_g_scales_type - ? toString(*maybe_g_scales_type) : "None", + ? torch::headeronly::toString(*maybe_g_scales_type) : "None", ", with_group_zeropoint_type=", maybe_g_zeros_type - ? toString(*maybe_g_zeros_type) : "None", + ? torch::headeronly::toString(*maybe_g_zeros_type) : "None", ", with_channel_scale_type=", maybe_ch_scales_type - ? toString(*maybe_ch_scales_type) : "None", + ? torch::headeronly::toString(*maybe_ch_scales_type) : "None", ", with_token_scale_type=", maybe_tok_scales_type - ? toString(*maybe_tok_scales_type) : "None", + ? torch::headeronly::toString(*maybe_tok_scales_type) : "None", "; implemented types are: \\n", {%- for impl_config in impl_configs %} {% set t = impl_config.types -%} @@ -197,7 +197,7 @@ {% for sch in schs %} {% set sch_sig = gen_sch_sig(sch) -%} -torch::Tensor +torch::stable::Tensor impl_{{type_sig}}_sch_{{sch_sig}}(MMArgs args) { return run_impl>(args); } @@ -212,7 +212,7 @@ namespace machete { -torch::Tensor prepack_B_dispatch(PrepackBArgs args) { +torch::stable::Tensor prepack_B_dispatch(PrepackBArgs args) { auto convert_type = args.maybe_group_scales_type.value_or(args.a_type); {%- for t in types %} {% set b_type = unsigned_type_with_bitwidth(t.b_num_bits) %} @@ -231,12 +231,12 @@ } {%- endfor %} - TORCH_CHECK_NOT_IMPLEMENTED(false, + STD_TORCH_CHECK_NOT_IMPLEMENTED(false, "prepack_B_dispatch(..) is not implemented for " - "atype = ", args.a_type, + "atype = ", torch::headeronly::toString(args.a_type), ", b_type = ", args.b_type.str(), ", with_group_scales_type= ", args.maybe_group_scales_type ? - toString(*args.maybe_group_scales_type) : "None"); + torch::headeronly::toString(*args.maybe_group_scales_type) : "None"); } }; // namespace machete diff --git a/csrc/quantization/machete/machete_collective_builder.cuh b/csrc/libtorch_stable/quantization/machete/machete_collective_builder.cuh similarity index 94% rename from csrc/quantization/machete/machete_collective_builder.cuh rename to csrc/libtorch_stable/quantization/machete/machete_collective_builder.cuh index ee825583dee1..ba8da0af2c31 100644 --- a/csrc/quantization/machete/machete_collective_builder.cuh +++ b/csrc/libtorch_stable/quantization/machete/machete_collective_builder.cuh @@ -1,6 +1,6 @@ #pragma once -#include "cutlass_extensions/vllm_collective_builder.cuh" +#include "libtorch_stable/cutlass_extensions/vllm_collective_builder.cuh" #include "machete_mainloop.cuh" namespace cutlass::gemm::collective { diff --git a/csrc/quantization/machete/machete_interleaving_utils.cuh b/csrc/libtorch_stable/quantization/machete/machete_interleaving_utils.cuh similarity index 100% rename from csrc/quantization/machete/machete_interleaving_utils.cuh rename to csrc/libtorch_stable/quantization/machete/machete_interleaving_utils.cuh diff --git a/csrc/quantization/machete/machete_mainloop.cuh b/csrc/libtorch_stable/quantization/machete/machete_mainloop.cuh similarity index 100% rename from csrc/quantization/machete/machete_mainloop.cuh rename to csrc/libtorch_stable/quantization/machete/machete_mainloop.cuh diff --git a/csrc/quantization/machete/machete_mm_kernel.cuh b/csrc/libtorch_stable/quantization/machete/machete_mm_kernel.cuh similarity index 86% rename from csrc/quantization/machete/machete_mm_kernel.cuh rename to csrc/libtorch_stable/quantization/machete/machete_mm_kernel.cuh index cc50e68b058e..57655f369cdf 100644 --- a/csrc/quantization/machete/machete_mm_kernel.cuh +++ b/csrc/libtorch_stable/quantization/machete/machete_mm_kernel.cuh @@ -1,8 +1,6 @@ #pragma once -#include -#include -#include +#include // clang-format off // The cutlass include order matters (annoyingly) @@ -20,9 +18,9 @@ // clang-format on #include "cutlass_extensions/cute_utils.cuh" -#include "cutlass_extensions/vllm_numeric_conversion.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" -#include "cutlass_extensions/torch_utils.hpp" +#include "libtorch_stable/cutlass_extensions/vllm_numeric_conversion.cuh" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/torch_utils.hpp" #include "machete_collective_builder.cuh" #include "machete_prepacked_layout.cuh" #include "machete_interleaving_utils.cuh" @@ -175,19 +173,23 @@ struct MacheteKernelTemplate { static Arguments create_arguments( cudaStream_t stream, - torch::Tensor const& A, // MxK matrix - torch::Tensor const& B, // KxN prepacked matrix - torch::Tensor& D, // MxN matrix - std::optional const& maybe_g_scales, // scale_KxN matrix - std::optional const& maybe_g_zeros, // scale_KxN matrix + torch::stable::Tensor const& A, // MxK matrix + torch::stable::Tensor const& B, // KxN prepacked matrix + torch::stable::Tensor& D, // MxN matrix + std::optional const& + maybe_g_scales, // scale_KxN matrix + std::optional const& + maybe_g_zeros, // scale_KxN matrix std::optional maybe_group_size, - std::optional const& maybe_ch_scales, // len N vector - std::optional const& maybe_tok_scales) // len M vector + std::optional const& + maybe_ch_scales, // len N vector + std::optional const& + maybe_tok_scales) // len M vector { static_assert(!with_group_zeropoints || with_group_scales); int M = A.size(0), N = B.size(1), K = A.size(1); - TORCH_CHECK(D.size(0) == M && D.size(1) == N); + STD_TORCH_CHECK(D.size(0) == M && D.size(1) == N); auto layout_A = make_cute_layout(A, "A"); auto layout_D = make_cute_layout(D, "D"); @@ -216,29 +218,29 @@ struct MacheteKernelTemplate { maybe_group_size == -1 ? K : maybe_group_size.value_or(K); int const scale_k = (K + group_size - 1) / group_size; - TORCH_CHECK(size<0>(layout_A) == M && size<1>(layout_A) == K); - TORCH_CHECK(size<0>(layout_D) == M && size<1>(layout_D) == N); + STD_TORCH_CHECK(size<0>(layout_A) == M && size<1>(layout_A) == K); + STD_TORCH_CHECK(size<0>(layout_D) == M && size<1>(layout_D) == N); if constexpr (with_group_scales) { - TORCH_CHECK(S_group_ptr && layout_S_group); - TORCH_CHECK((size<0>(*layout_S_group) == scale_k && - size<1>(*layout_S_group) == N)); + STD_TORCH_CHECK(S_group_ptr && layout_S_group); + STD_TORCH_CHECK((size<0>(*layout_S_group) == scale_k && + size<1>(*layout_S_group) == N)); } else { - TORCH_CHECK(!S_group_ptr, "Scales not supported"); + STD_TORCH_CHECK(!S_group_ptr, "Scales not supported"); } if constexpr (with_group_zeropoints) { - TORCH_CHECK(Z_group_ptr && layout_Z_group); - TORCH_CHECK((size<0>(*layout_Z_group) == scale_k && - size<1>(*layout_Z_group) == N)); - TORCH_CHECK(layout_S_group && *layout_Z_group == *layout_S_group, - "Scales and zeros must have the same layout"); + STD_TORCH_CHECK(Z_group_ptr && layout_Z_group); + STD_TORCH_CHECK((size<0>(*layout_Z_group) == scale_k && + size<1>(*layout_Z_group) == N)); + STD_TORCH_CHECK(layout_S_group && *layout_Z_group == *layout_S_group, + "Scales and zeros must have the same layout"); } else { - TORCH_CHECK(!Z_group_ptr, "Zeropoints not supported"); + STD_TORCH_CHECK(!Z_group_ptr, "Zeropoints not supported"); } if constexpr (with_channel_scales || with_token_scales) { - TORCH_CHECK( + STD_TORCH_CHECK( (maybe_ch_scales->numel() == N || maybe_ch_scales->numel() == 1) && (maybe_tok_scales->numel() == M || maybe_tok_scales->numel() == 1)); } @@ -298,11 +300,12 @@ struct MacheteKernelTemplate { Gemm gemm_op; cutlass::Status status = gemm_op.initialize(args, workspace, stream); - TORCH_CHECK(status == cutlass::Status::kSuccess, - "Machete kernel failed to initialize workspace"); + STD_TORCH_CHECK(status == cutlass::Status::kSuccess, + "Machete kernel failed to initialize workspace"); status = gemm_op.run(stream); - TORCH_CHECK(status == cutlass::Status::kSuccess, "Machete kernel failed"); + STD_TORCH_CHECK(status == cutlass::Status::kSuccess, + "Machete kernel failed"); } }; diff --git a/csrc/libtorch_stable/quantization/machete/machete_mm_launcher.cuh b/csrc/libtorch_stable/quantization/machete/machete_mm_launcher.cuh new file mode 100644 index 000000000000..5ecf03daf790 --- /dev/null +++ b/csrc/libtorch_stable/quantization/machete/machete_mm_launcher.cuh @@ -0,0 +1,80 @@ +#pragma once + +#include "machete_mm_kernel.cuh" +#include "libtorch_stable/cutlass_extensions/torch_utils.hpp" +#include "core/scalar_type.hpp" +#include "libtorch_stable/torch_utils.h" + +#include +#include + +#include +#include +#include + +namespace machete { + +struct MMArgs { + torch::stable::Tensor const& A; + torch::stable::Tensor const& B; + vllm::ScalarType const& b_type; + std::optional const& maybe_out_type; + std::optional const& maybe_group_scales; + std::optional const& maybe_group_zeros; + std::optional maybe_group_size; + std::optional const& maybe_channel_scales; + std::optional const& maybe_token_scales; + std::optional maybe_schedule; +}; + +struct SupportedSchedulesArgs { + torch::headeronly::ScalarType a_type; + vllm::ScalarType b_type; + std::optional maybe_group_scales_type; + std::optional maybe_group_zeros_type; + std::optional maybe_channel_scales_type; + std::optional maybe_token_scales_type; + std::optional maybe_out_type; +}; + +torch::stable::Tensor mm_dispatch(MMArgs args); + +std::vector supported_schedules_dispatch( + SupportedSchedulesArgs args); + +template +torch::stable::Tensor run_impl(MMArgs args) { + const torch::stable::accelerator::DeviceGuard device_guard( + args.A.get_device_index()); + + auto device = args.A.device(); + auto stream = get_current_cuda_stream(device.index()); + + int M = args.A.size(0); + int N = args.B.size(1); + int K = args.A.size(1); + + // Allocate output + torch::stable::Tensor D = torch::stable::empty( + {M, N}, equivalent_scalar_type_v, + std::nullopt, device); + + auto arguments = MacheteKernel::create_arguments( + stream, // + args.A, args.B, D, args.maybe_group_scales, args.maybe_group_zeros, + args.maybe_group_size, args.maybe_channel_scales, + args.maybe_token_scales); + STD_TORCH_CHECK(MacheteKernel::can_implement(arguments), + "Machete kernel cannot be run with these arguments"); + + size_t workspace_size = MacheteKernel::get_workspace_size(arguments); + torch::stable::Tensor workspace = + torch::stable::empty(workspace_size, torch::headeronly::ScalarType::Byte, + std::nullopt, device); + + MacheteKernel::run(arguments, workspace.mutable_data_ptr(), stream); + + return D; +}; + +}; // namespace machete \ No newline at end of file diff --git a/csrc/quantization/machete/machete_prepack_kernel.cuh b/csrc/libtorch_stable/quantization/machete/machete_prepack_kernel.cuh similarity index 92% rename from csrc/quantization/machete/machete_prepack_kernel.cuh rename to csrc/libtorch_stable/quantization/machete/machete_prepack_kernel.cuh index d002355ca49d..6a28dceccc8d 100644 --- a/csrc/quantization/machete/machete_prepack_kernel.cuh +++ b/csrc/libtorch_stable/quantization/machete/machete_prepack_kernel.cuh @@ -2,7 +2,8 @@ #include "machete_mm_kernel.cuh" #include "cutlass_extensions/cute_utils.cuh" -#include "cutlass_extensions/torch_utils.hpp" +#include "libtorch_stable/cutlass_extensions/torch_utils.hpp" +#include namespace machete { @@ -60,8 +61,8 @@ static void prepack_B_template( auto ilvd_NKbNbKL_to_offset = PrepackedLayoutB::ilvd_NKbNbKL_to_offset(shape(B_layout)); - TORCH_CHECK(size<0>(B_layout) % size<0>(TileShapeNKL{}) == 0); - TORCH_CHECK(size<1>(B_layout) % size<1>(TileShapeNKL{}) == 0); + STD_TORCH_CHECK(size<0>(B_layout) % size<0>(TileShapeNKL{}) == 0); + STD_TORCH_CHECK(size<1>(B_layout) % size<1>(TileShapeNKL{}) == 0); auto N_tiles = size<0>(B_layout) / size<0>(TileShapeNKL{}); auto K_tiles = size<1>(B_layout) / size<1>(TileShapeNKL{}); diff --git a/csrc/quantization/machete/machete_prepack_launcher.cuh b/csrc/libtorch_stable/quantization/machete/machete_prepack_launcher.cuh similarity index 63% rename from csrc/quantization/machete/machete_prepack_launcher.cuh rename to csrc/libtorch_stable/quantization/machete/machete_prepack_launcher.cuh index 634b651a4d10..63af5e496b84 100644 --- a/csrc/quantization/machete/machete_prepack_launcher.cuh +++ b/csrc/libtorch_stable/quantization/machete/machete_prepack_launcher.cuh @@ -1,41 +1,49 @@ #pragma once #include "machete_prepack_kernel.cuh" -#include "cutlass_extensions/torch_utils.hpp" +#include "libtorch_stable/cutlass_extensions/torch_utils.hpp" #include "core/scalar_type.hpp" +#include "libtorch_stable/torch_utils.h" + +#include +#include + +#include namespace machete { struct PrepackBArgs { - torch::Tensor const& B; - at::ScalarType a_type; + torch::stable::Tensor const& B; + torch::headeronly::ScalarType a_type; vllm::ScalarType b_type; - std::optional maybe_group_scales_type; + std::optional maybe_group_scales_type; }; template -torch::Tensor prepack_impl(torch::Tensor const B) { - const at::cuda::OptionalCUDAGuard device_guard(device_of(B)); +torch::stable::Tensor prepack_impl(torch::stable::Tensor const& B) { + const torch::stable::accelerator::DeviceGuard device_guard( + B.get_device_index()); using ElementB = typename PrepackedLayoutB::ElementB; using PPBlockShape_NK = typename PrepackedLayoutB::PPBlockShape_NK; auto device = B.device(); - auto stream = at::cuda::getCurrentCUDAStream(device.index()); + auto stream = get_current_cuda_stream(device.index()); auto B_ptr = static_cast(B.const_data_ptr()); // elements per storage item for B auto eles_per_storage = - (B.dtype().itemsize() * 8) / cute::sizeof_bits_v; + (B.element_size() * 8) / cute::sizeof_bits_v; // torch B passed in is/should be (packed_K,N), the kernel expects (N,K,L) (to // match cutlass using (N,K,L) for B), so we transpose B to (N,packed_K,L) - auto Bt_packed = B.t(); + auto Bt_packed = torch::stable::transpose(B, 0, 1); - TORCH_CHECK( + STD_TORCH_CHECK( (B.size(0) * eles_per_storage) % size<1>(PPBlockShape_NK{}) == 0, "B.shape[0] (in terms of unpacked elements) must be a multiple of ", size<1>(PPBlockShape_NK{})); - TORCH_CHECK(B.size(1) % size<0>(PPBlockShape_NK{}) == 0, - "B.shape[1] must be a multiple of ", size<0>(PPBlockShape_NK{})); + STD_TORCH_CHECK(B.size(1) % size<0>(PPBlockShape_NK{}) == 0, + "B.shape[1] must be a multiple of ", + size<0>(PPBlockShape_NK{})); using StrideB = cutlass::detail::TagToStrideB_t; auto const l_Bt_packed = make_cute_layout(Bt_packed, "B"); @@ -49,7 +57,7 @@ torch::Tensor prepack_impl(torch::Tensor const B) { // new_shape = (N, packed_K, L) * (1, eles_per_storage, 1) -> (N, K, L) // new_stride = (s0, s1, s2) * (eles_per_storage, 1, eles_per_storage) // when s1 == 1 - TORCH_CHECK(stride<1>(l_Bt_packed) == 1); + STD_TORCH_CHECK(stride<1>(l_Bt_packed) == 1); // clang-format off auto const layout_Bt = make_layout( transform_with_idx(l_Bt_packed.shape(), [&](auto ele, auto idx) { @@ -61,7 +69,9 @@ torch::Tensor prepack_impl(torch::Tensor const B) { // clang-format on // Allocate output - torch::Tensor D = torch::empty_like(B, {}, at::MemoryFormat::Contiguous); + torch::stable::Tensor D = torch::stable::empty( + B.sizes(), B.scalar_type(), std::nullopt, B.device(), std::nullopt, + torch::headeronly::MemoryFormat::Contiguous); prepack_B_template( stream, B_ptr, layout_Bt, static_cast(D.mutable_data_ptr())); @@ -69,6 +79,6 @@ torch::Tensor prepack_impl(torch::Tensor const B) { return D; }; -torch::Tensor prepack_B_dispatch(PrepackBArgs args); +torch::stable::Tensor prepack_B_dispatch(PrepackBArgs args); }; // namespace machete \ No newline at end of file diff --git a/csrc/quantization/machete/machete_prepacked_layout.cuh b/csrc/libtorch_stable/quantization/machete/machete_prepacked_layout.cuh similarity index 99% rename from csrc/quantization/machete/machete_prepacked_layout.cuh rename to csrc/libtorch_stable/quantization/machete/machete_prepacked_layout.cuh index 4a7d6341e6c0..c16a2ab8a33d 100644 --- a/csrc/quantization/machete/machete_prepacked_layout.cuh +++ b/csrc/libtorch_stable/quantization/machete/machete_prepacked_layout.cuh @@ -1,9 +1,5 @@ #pragma once -#include -#include -#include - // clang-format off // The cutlass include order matters (annoyingly) diff --git a/csrc/libtorch_stable/quantization/machete/machete_pytorch.cu b/csrc/libtorch_stable/quantization/machete/machete_pytorch.cu new file mode 100644 index 000000000000..7736d5b3ece4 --- /dev/null +++ b/csrc/libtorch_stable/quantization/machete/machete_pytorch.cu @@ -0,0 +1,77 @@ +#include "machete_mm_launcher.cuh" +#include "machete_prepack_launcher.cuh" +#include "core/scalar_type.hpp" + +#include +#include +#include + +namespace machete { + +using namespace vllm; + +std::vector supported_schedules( + torch::headeronly::ScalarType a_type, int64_t b_type_id, + std::optional maybe_group_scales_type, + std::optional maybe_group_zeros_type, + std::optional maybe_channel_scales_type, + std::optional maybe_token_scales_type, + std::optional maybe_out_type) { + ScalarType const b_type = ScalarType::from_id(b_type_id); + return supported_schedules_dispatch({ + .a_type = a_type, + .b_type = b_type, + .maybe_group_scales_type = maybe_group_scales_type, + .maybe_group_zeros_type = maybe_group_zeros_type, + .maybe_channel_scales_type = maybe_channel_scales_type, + .maybe_token_scales_type = maybe_token_scales_type, + .maybe_out_type = maybe_out_type, + }); +} + +torch::stable::Tensor mm( + torch::stable::Tensor const& A, torch::stable::Tensor const& B, + int64_t b_type_id, + std::optional const& maybe_out_type, + std::optional const& maybe_group_scales, + std::optional const& maybe_group_zeros, + std::optional maybe_group_size, + std::optional const& maybe_channel_scales, + std::optional const& maybe_token_scales, + std::optional maybe_schedule) { + ScalarType const b_type = ScalarType::from_id(b_type_id); + return mm_dispatch({.A = A, + .B = B, + .b_type = b_type, + .maybe_out_type = maybe_out_type, + .maybe_group_scales = maybe_group_scales, + .maybe_group_zeros = maybe_group_zeros, + .maybe_group_size = maybe_group_size, + .maybe_channel_scales = maybe_channel_scales, + .maybe_token_scales = maybe_token_scales, + .maybe_schedule = maybe_schedule}); +} + +torch::stable::Tensor prepack_B( + torch::stable::Tensor const& B, torch::headeronly::ScalarType const& a_type, + int64_t b_type_id, + std::optional const& + maybe_group_scales_type) { + ScalarType const b_type = ScalarType::from_id(b_type_id); + return prepack_B_dispatch( + {.B = B, + .a_type = a_type, + .b_type = b_type, + .maybe_group_scales_type = maybe_group_scales_type}); +} + +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { + m.impl("machete_prepack_B", TORCH_BOX(&prepack_B)); + m.impl("machete_mm", TORCH_BOX(&mm)); +} + +STABLE_TORCH_LIBRARY_IMPL(_C, CompositeExplicitAutograd, m) { + m.impl("machete_supported_schedules", TORCH_BOX(&supported_schedules)); +} + +}; // namespace machete diff --git a/csrc/quantization/marlin/.gitignore b/csrc/libtorch_stable/quantization/marlin/.gitignore similarity index 100% rename from csrc/quantization/marlin/.gitignore rename to csrc/libtorch_stable/quantization/marlin/.gitignore diff --git a/csrc/quantization/marlin/awq_marlin_repack.cu b/csrc/libtorch_stable/quantization/marlin/awq_marlin_repack.cu similarity index 77% rename from csrc/quantization/marlin/awq_marlin_repack.cu rename to csrc/libtorch_stable/quantization/marlin/awq_marlin_repack.cu index 307bae6738ec..55ce5b4e732d 100644 --- a/csrc/quantization/marlin/awq_marlin_repack.cu +++ b/csrc/libtorch_stable/quantization/marlin/awq_marlin_repack.cu @@ -1,6 +1,13 @@ #include "marlin.cuh" -#include "core/registration.h" +#include +#include +#include +#include +#include +#include + +#include "libtorch_stable/torch_utils.h" namespace marlin { @@ -218,56 +225,55 @@ __global__ void awq_marlin_repack_kernel( b_q_weight_ptr, out_ptr, size_k, size_n); \ } -torch::Tensor awq_marlin_repack(torch::Tensor& b_q_weight, int64_t size_k, - int64_t size_n, int64_t num_bits, - bool is_a_8bit) { +torch::stable::Tensor awq_marlin_repack(torch::stable::Tensor& b_q_weight, + int64_t size_k, int64_t size_n, + int64_t num_bits, bool is_a_8bit) { // Verify compatibility with marlin tile of 16x64 - TORCH_CHECK(size_k % marlin::tile_k_size == 0, "size_k = ", size_k, - " is not divisible by tile_k_size = ", marlin::tile_k_size); - TORCH_CHECK(size_n % marlin::tile_n_size == 0, "size_n = ", size_n, - " is not divisible by tile_n_size = ", marlin::tile_n_size); + STD_TORCH_CHECK(size_k % marlin::tile_k_size == 0, "size_k = ", size_k, + " is not divisible by tile_k_size = ", marlin::tile_k_size); + STD_TORCH_CHECK(size_n % marlin::tile_n_size == 0, "size_n = ", size_n, + " is not divisible by tile_n_size = ", marlin::tile_n_size); - TORCH_CHECK(num_bits == 4 || num_bits == 8, - "num_bits must be 4 or 8. Got = ", num_bits); + STD_TORCH_CHECK(num_bits == 4 || num_bits == 8, + "num_bits must be 4 or 8. Got = ", num_bits); int const pack_factor = 32 / num_bits; // Verify B - TORCH_CHECK(b_q_weight.size(0) == size_k, - "b_q_weight.size(0) = ", b_q_weight.size(0), - " is not size_k = ", size_k); - TORCH_CHECK((size_n / pack_factor) == b_q_weight.size(1), - "Shape mismatch: b_q_weight.size(1) = ", b_q_weight.size(1), - ", size_n = ", size_n, ", pack_factor = ", pack_factor); + STD_TORCH_CHECK(b_q_weight.size(0) == size_k, + "b_q_weight.size(0) = ", b_q_weight.size(0), + " is not size_k = ", size_k); + STD_TORCH_CHECK((size_n / pack_factor) == b_q_weight.size(1), + "Shape mismatch: b_q_weight.size(1) = ", b_q_weight.size(1), + ", size_n = ", size_n, ", pack_factor = ", pack_factor); // Verify device and strides - TORCH_CHECK(b_q_weight.device().is_cuda(), "b_q_weight is not on GPU"); - TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); - TORCH_CHECK(b_q_weight.dtype() == at::kInt, "b_q_weight type is not kInt"); + STD_TORCH_CHECK(b_q_weight.is_cuda(), "b_q_weight is not on GPU"); + STD_TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); + STD_TORCH_CHECK( + b_q_weight.scalar_type() == torch::headeronly::ScalarType::Int, + "b_q_weight type is not kInt"); + + const int32_t device_index = b_q_weight.get_device_index(); + torch::stable::accelerator::DeviceGuard device_guard(device_index); + const cudaStream_t stream = get_current_cuda_stream(device_index); // Alloc buffers - const at::cuda::OptionalCUDAGuard device_guard(device_of(b_q_weight)); - auto options = torch::TensorOptions() - .dtype(b_q_weight.dtype()) - .device(b_q_weight.device()); - torch::Tensor out = torch::empty( + torch::stable::Tensor out = torch::stable::empty( {size_k / marlin::tile_size, size_n * marlin::tile_size / pack_factor}, - options); + b_q_weight.scalar_type(), std::nullopt, b_q_weight.device()); // Get ptrs uint32_t const* b_q_weight_ptr = - reinterpret_cast(b_q_weight.data_ptr()); - uint32_t* out_ptr = reinterpret_cast(out.data_ptr()); + reinterpret_cast(b_q_weight.const_data_ptr()); + uint32_t* out_ptr = reinterpret_cast(out.mutable_data_ptr()); - // Get dev info - int dev = b_q_weight.get_device(); - cudaStream_t stream = at::cuda::getCurrentCUDAStream(dev); int blocks; - cudaDeviceGetAttribute(&blocks, cudaDevAttrMultiProcessorCount, dev); + cudaDeviceGetAttribute(&blocks, cudaDevAttrMultiProcessorCount, device_index); int max_shared_mem = 0; cudaDeviceGetAttribute(&max_shared_mem, - cudaDevAttrMaxSharedMemoryPerBlockOptin, dev); - TORCH_CHECK(max_shared_mem > 0); + cudaDevAttrMaxSharedMemoryPerBlockOptin, device_index); + STD_TORCH_CHECK(max_shared_mem > 0); if (false) { } @@ -276,13 +282,13 @@ torch::Tensor awq_marlin_repack(torch::Tensor& b_q_weight, int64_t size_k, CALL_IF(4, true) CALL_IF(8, true) else { - TORCH_CHECK(false, "Unsupported repack config: num_bits = ", num_bits, - ", is_a_8bit = ", is_a_8bit); + STD_TORCH_CHECK(false, "Unsupported repack config: num_bits = ", num_bits, + ", is_a_8bit = ", is_a_8bit); } return out; } -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("awq_marlin_repack", &awq_marlin_repack); +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { + m.impl("awq_marlin_repack", TORCH_BOX(&awq_marlin_repack)); } diff --git a/csrc/quantization/marlin/dequant.h b/csrc/libtorch_stable/quantization/marlin/dequant.h similarity index 100% rename from csrc/quantization/marlin/dequant.h rename to csrc/libtorch_stable/quantization/marlin/dequant.h diff --git a/csrc/quantization/marlin/generate_kernels.py b/csrc/libtorch_stable/quantization/marlin/generate_kernels.py similarity index 99% rename from csrc/quantization/marlin/generate_kernels.py rename to csrc/libtorch_stable/quantization/marlin/generate_kernels.py index 7b316037ec63..2a038479893a 100644 --- a/csrc/quantization/marlin/generate_kernels.py +++ b/csrc/libtorch_stable/quantization/marlin/generate_kernels.py @@ -303,7 +303,7 @@ def generate_new_kernels(): if not SUPPORT_FP8 and kernel_selector_str != FILE_HEAD_COMMENT: kernel_selector_str += ( "else if (a_type == vllm::kFE4M3fn)\n" - " TORCH_CHECK(false, " + " STD_TORCH_CHECK(false, " '"marlin kernel with fp8 activation is not built.");' ) diff --git a/csrc/quantization/marlin/gptq_marlin_repack.cu b/csrc/libtorch_stable/quantization/marlin/gptq_marlin_repack.cu similarity index 77% rename from csrc/quantization/marlin/gptq_marlin_repack.cu rename to csrc/libtorch_stable/quantization/marlin/gptq_marlin_repack.cu index 796e6c5359da..cafa212bccb8 100644 --- a/csrc/quantization/marlin/gptq_marlin_repack.cu +++ b/csrc/libtorch_stable/quantization/marlin/gptq_marlin_repack.cu @@ -1,6 +1,13 @@ #include "marlin.cuh" -#include "core/registration.h" +#include +#include +#include +#include +#include +#include + +#include "libtorch_stable/torch_utils.h" namespace marlin { @@ -275,64 +282,66 @@ __global__ void gptq_marlin_repack_kernel( b_q_weight_ptr, perm_ptr, out_ptr, size_k, size_n); \ } -torch::Tensor gptq_marlin_repack(torch::Tensor& b_q_weight, torch::Tensor& perm, - int64_t size_k, int64_t size_n, - int64_t num_bits, bool is_a_8bit) { +torch::stable::Tensor gptq_marlin_repack(torch::stable::Tensor& b_q_weight, + torch::stable::Tensor& perm, + int64_t size_k, int64_t size_n, + int64_t num_bits, bool is_a_8bit) { // Verify compatibility with marlin tile of 16x64 - TORCH_CHECK(size_k % marlin::tile_k_size == 0, "size_k = ", size_k, - " is not divisible by tile_k_size = ", marlin::tile_k_size); - TORCH_CHECK(size_n % marlin::tile_n_size == 0, "size_n = ", size_n, - " is not divisible by tile_n_size = ", marlin::tile_n_size); + STD_TORCH_CHECK(size_k % marlin::tile_k_size == 0, "size_k = ", size_k, + " is not divisible by tile_k_size = ", marlin::tile_k_size); + STD_TORCH_CHECK(size_n % marlin::tile_n_size == 0, "size_n = ", size_n, + " is not divisible by tile_n_size = ", marlin::tile_n_size); - TORCH_CHECK(num_bits == 4 || num_bits == 8, - "num_bits must be 4 or 8. Got = ", num_bits); + STD_TORCH_CHECK(num_bits == 4 || num_bits == 8, + "num_bits must be 4 or 8. Got = ", num_bits); int const pack_factor = 32 / num_bits; // Verify B - TORCH_CHECK((size_k / pack_factor) == b_q_weight.size(0), - "Shape mismatch: b_q_weight.size(0) = ", b_q_weight.size(0), - ", size_k = ", size_k, ", pack_factor = ", pack_factor); - TORCH_CHECK(b_q_weight.size(1) == size_n, - "b_q_weight.size(1) = ", b_q_weight.size(1), - " is not size_n = ", size_n); + STD_TORCH_CHECK((size_k / pack_factor) == b_q_weight.size(0), + "Shape mismatch: b_q_weight.size(0) = ", b_q_weight.size(0), + ", size_k = ", size_k, ", pack_factor = ", pack_factor); + STD_TORCH_CHECK(b_q_weight.size(1) == size_n, + "b_q_weight.size(1) = ", b_q_weight.size(1), + " is not size_n = ", size_n); // Verify device and strides - TORCH_CHECK(b_q_weight.device().is_cuda(), "b_q_weight is not on GPU"); - TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); - TORCH_CHECK(b_q_weight.dtype() == at::kInt, "b_q_weight type is not kInt"); + STD_TORCH_CHECK(b_q_weight.is_cuda(), "b_q_weight is not on GPU"); + STD_TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); + STD_TORCH_CHECK( + b_q_weight.scalar_type() == torch::headeronly::ScalarType::Int, + "b_q_weight type is not kInt"); + + STD_TORCH_CHECK(perm.is_cuda(), "perm is not on GPU"); + STD_TORCH_CHECK(perm.is_contiguous(), "perm is not contiguous"); + STD_TORCH_CHECK(perm.scalar_type() == torch::headeronly::ScalarType::Int, + "perm type is not at::kInt"); - TORCH_CHECK(perm.device().is_cuda(), "perm is not on GPU"); - TORCH_CHECK(perm.is_contiguous(), "perm is not contiguous"); - TORCH_CHECK(perm.dtype() == at::kInt, "perm type is not at::kInt"); + const int32_t device_index = b_q_weight.get_device_index(); + torch::stable::accelerator::DeviceGuard device_guard(device_index); + const cudaStream_t stream = get_current_cuda_stream(device_index); // Alloc buffers - const at::cuda::OptionalCUDAGuard device_guard(device_of(b_q_weight)); - auto options = torch::TensorOptions() - .dtype(b_q_weight.dtype()) - .device(b_q_weight.device()); - torch::Tensor out = torch::empty( + torch::stable::Tensor out = torch::stable::empty( {size_k / marlin::tile_size, size_n * marlin::tile_size / pack_factor}, - options); + b_q_weight.scalar_type(), std::nullopt, b_q_weight.device()); // Detect if there is act_order bool has_perm = perm.size(0) != 0; // Get ptrs uint32_t const* b_q_weight_ptr = - reinterpret_cast(b_q_weight.data_ptr()); - uint32_t const* perm_ptr = reinterpret_cast(perm.data_ptr()); - uint32_t* out_ptr = reinterpret_cast(out.data_ptr()); + reinterpret_cast(b_q_weight.const_data_ptr()); + uint32_t const* perm_ptr = + reinterpret_cast(perm.const_data_ptr()); + uint32_t* out_ptr = reinterpret_cast(out.mutable_data_ptr()); - // Get dev info - int dev = b_q_weight.get_device(); - cudaStream_t stream = at::cuda::getCurrentCUDAStream(dev); int blocks; - cudaDeviceGetAttribute(&blocks, cudaDevAttrMultiProcessorCount, dev); + cudaDeviceGetAttribute(&blocks, cudaDevAttrMultiProcessorCount, device_index); int max_shared_mem = 0; cudaDeviceGetAttribute(&max_shared_mem, - cudaDevAttrMaxSharedMemoryPerBlockOptin, dev); - TORCH_CHECK(max_shared_mem > 0); + cudaDevAttrMaxSharedMemoryPerBlockOptin, device_index); + STD_TORCH_CHECK(max_shared_mem > 0); if (false) { } @@ -345,13 +354,13 @@ torch::Tensor gptq_marlin_repack(torch::Tensor& b_q_weight, torch::Tensor& perm, CALL_IF(8, false, true) else { - TORCH_CHECK(false, "Unsupported repack config: num_bits = ", num_bits, - ", has_perm = ", has_perm, ", is_a_8bit = ", is_a_8bit); + STD_TORCH_CHECK(false, "Unsupported repack config: num_bits = ", num_bits, + ", has_perm = ", has_perm, ", is_a_8bit = ", is_a_8bit); } return out; } -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("gptq_marlin_repack", &gptq_marlin_repack); +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { + m.impl("gptq_marlin_repack", TORCH_BOX(&gptq_marlin_repack)); } diff --git a/csrc/quantization/marlin/kernel.h b/csrc/libtorch_stable/quantization/marlin/kernel.h similarity index 100% rename from csrc/quantization/marlin/kernel.h rename to csrc/libtorch_stable/quantization/marlin/kernel.h diff --git a/csrc/quantization/marlin/marlin.cu b/csrc/libtorch_stable/quantization/marlin/marlin.cu similarity index 61% rename from csrc/quantization/marlin/marlin.cu rename to csrc/libtorch_stable/quantization/marlin/marlin.cu index 721c206c33f1..63fea239e4a4 100644 --- a/csrc/quantization/marlin/marlin.cu +++ b/csrc/libtorch_stable/quantization/marlin/marlin.cu @@ -24,7 +24,15 @@ #endif #include "kernel.h" -#include "core/registration.h" + +#include +#include +#include +#include +#include +#include + +#include "libtorch_stable/torch_utils.h" #define STATIC_ASSERT_SCALAR_TYPE_VALID(scalar_t) \ static_assert(std::is_same::value || \ @@ -46,19 +54,22 @@ __global__ void permute_cols_kernel(int4 const* __restrict__ a_int4_ptr, } // namespace marlin -torch::Tensor marlin_gemm( - torch::Tensor& a, std::optional c_or_none, - torch::Tensor& b_q_weight, - std::optional const& b_bias_or_none, torch::Tensor& b_scales, - std::optional const& b_zeros_or_none, - std::optional const& g_idx_or_none, - std::optional const& perm_or_none, torch::Tensor& workspace, - vllm::ScalarTypeId const& b_type_id, int64_t size_m, int64_t size_n, - int64_t size_k, bool is_k_full, bool use_atomic_add, bool use_fp32_reduce, - bool is_zp_float) { - TORCH_CHECK_NOT_IMPLEMENTED(false, - "marlin_gemm(..) requires CUDA_ARCH >= 7.5"); - return torch::empty({1, 1}); +torch::stable::Tensor marlin_gemm( + torch::stable::Tensor& a, std::optional c_or_none, + torch::stable::Tensor& b_q_weight, + std::optional const& b_bias_or_none, + torch::stable::Tensor& b_scales, + std::optional const& a_scales_or_none, + std::optional const& global_scale_or_none, + std::optional const& b_zeros_or_none, + std::optional const& g_idx_or_none, + std::optional const& perm_or_none, + torch::stable::Tensor& workspace, vllm::ScalarTypeId const& b_type_id, + int64_t size_m, int64_t size_n, int64_t size_k, bool is_k_full, + bool use_atomic_add, bool use_fp32_reduce, bool is_zp_float) { + STD_TORCH_CHECK_NOT_IMPLEMENTED(false, + "marlin_gemm(..) requires CUDA_ARCH >= 7.5"); + return torch::stable::empty({1, 1}); } #else @@ -323,18 +334,18 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, int thread_n_init, int sms, bool use_atomic_add, bool use_fp32_reduce, bool is_zp_float) { bool is_a_8bit = a_type.size_bits() == 8; - TORCH_CHECK(prob_m > 0 && prob_n > 0 && prob_k > 0, "Invalid MNK = [", prob_m, - ", ", prob_n, ", ", prob_k, "]"); + STD_TORCH_CHECK(prob_m > 0 && prob_n > 0 && prob_k > 0, "Invalid MNK = [", + prob_m, ", ", prob_n, ", ", prob_k, "]"); int group_blocks = 0; if (has_act_order) { if (is_k_full) { - TORCH_CHECK(group_size != -1); + STD_TORCH_CHECK(group_size != -1); group_blocks = group_size / 16; - TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, - " is not divisible by group_blocks = ", group_blocks); + STD_TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, + " is not divisible by group_blocks = ", group_blocks); } else { - TORCH_CHECK(group_size == 0); + STD_TORCH_CHECK(group_size == 0); group_blocks = 0; } } else { @@ -342,8 +353,8 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, group_blocks = -1; } else { group_blocks = group_size / 16; - TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, - " is not divisible by group_blocks = ", group_blocks); + STD_TORCH_CHECK(prob_k % group_blocks == 0, "prob_k = ", prob_k, + " is not divisible by group_blocks = ", group_blocks); } } @@ -384,25 +395,25 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, int max_shared_mem = 0; cudaDeviceGetAttribute(&max_shared_mem, cudaDevAttrMaxSharedMemoryPerBlockOptin, dev); - TORCH_CHECK(max_shared_mem > 0); + STD_TORCH_CHECK(max_shared_mem > 0); int major_capability, minor_capability; cudaDeviceGetAttribute(&major_capability, cudaDevAttrComputeCapabilityMajor, dev); cudaDeviceGetAttribute(&minor_capability, cudaDevAttrComputeCapabilityMinor, dev); - TORCH_CHECK(major_capability * 10 + minor_capability >= 75, - "marlin kernel only support Turing or newer GPUs."); + STD_TORCH_CHECK(major_capability * 10 + minor_capability >= 75, + "marlin kernel only support Turing or newer GPUs."); int stages = 4; if (major_capability == 7 && minor_capability == 5) { stages = 2; - TORCH_CHECK(a_type == vllm::kFloat16 || a_type == vllm::kS8, - "Turing only support FP16 or INT8 activation."); + STD_TORCH_CHECK(a_type == vllm::kFloat16 || a_type == vllm::kS8, + "Turing only support FP16 or INT8 activation."); } if (a_type == vllm::kFE4M3fn) { - TORCH_CHECK(major_capability * 10 + minor_capability >= 89, - "FP8 only support Ada Lovelace or newer GPUs."); - TORCH_CHECK( + STD_TORCH_CHECK(major_capability * 10 + minor_capability >= 89, + "FP8 only support Ada Lovelace or newer GPUs."); + STD_TORCH_CHECK( major_capability * 10 + minor_capability == 89 || major_capability == 12, "Marlin W4A8-FP8 only support SM89 or SM12x device (It is slower than " @@ -432,10 +443,10 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, if (thread_k != -1 && thread_n != -1) { thread_tfg = thread_config_t{thread_k, thread_n, default_threads}; exec_cfg = exec_config_t{1, thread_tfg}; - TORCH_CHECK(prob_n % thread_n == 0, "prob_n = ", prob_n, - " is not divisible by thread_n = ", thread_n); - TORCH_CHECK(prob_k % thread_k == 0, "prob_k = ", prob_k, - " is not divisible by thread_k = ", thread_k); + STD_TORCH_CHECK(prob_n % thread_n == 0, "prob_n = ", prob_n, + " is not divisible by thread_n = ", thread_n); + STD_TORCH_CHECK(prob_k % thread_k == 0, "prob_k = ", prob_k, + " is not divisible by thread_k = ", thread_k); } else { // Auto config exec_cfg = determine_exec_config( @@ -474,7 +485,7 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, int thread_k_blocks = thread_k / 16; int thread_n_blocks = thread_n / 16; - TORCH_CHECK( + STD_TORCH_CHECK( is_valid_config(thread_tfg, thread_m_blocks, prob_m_split, prob_n, prob_k, num_bits, group_size, has_act_order, is_k_full, has_zp, is_zp_float, is_a_8bit, stages, @@ -495,14 +506,15 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, num_threads, is_zp_float, stages); if (kernel == MarlinDefault) { - TORCH_CHECK(false, "Unsupported shapes: MNK = [", prob_m, ", ", prob_n, - ", ", prob_k, "]", ", has_act_order = ", has_act_order, - ", num_groups = ", num_groups, ", group_size = ", group_size, - ", prob_m_split = ", prob_m_split, - ", thread_m_blocks = ", thread_m_blocks, - ", thread_n_blocks = ", thread_n_blocks, - ", thread_k_blocks = ", thread_k_blocks, - ", num_threads = ", num_threads, ", num_bits = ", num_bits); + STD_TORCH_CHECK( + false, "Unsupported shapes: MNK = [", prob_m, ", ", prob_n, ", ", + prob_k, "]", ", has_act_order = ", has_act_order, + ", num_groups = ", num_groups, ", group_size = ", group_size, + ", prob_m_split = ", prob_m_split, + ", thread_m_blocks = ", thread_m_blocks, + ", thread_n_blocks = ", thread_n_blocks, + ", thread_k_blocks = ", thread_k_blocks, + ", num_threads = ", num_threads, ", num_bits = ", num_bits); } cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, @@ -530,71 +542,76 @@ void marlin_mm(const void* A, const void* B, void* C, void* C_tmp, void* b_bias, } // namespace marlin -torch::Tensor marlin_gemm( - torch::Tensor& a, std::optional c_or_none, - torch::Tensor& b_q_weight, - std::optional const& b_bias_or_none, torch::Tensor& b_scales, - std::optional const& a_scales_or_none, - std::optional const& global_scale_or_none, - std::optional const& b_zeros_or_none, - std::optional const& g_idx_or_none, - std::optional const& perm_or_none, torch::Tensor& workspace, - vllm::ScalarTypeId const& b_type_id, int64_t size_m, int64_t size_n, - int64_t size_k, bool is_k_full, bool use_atomic_add, bool use_fp32_reduce, - bool is_zp_float) { +torch::stable::Tensor marlin_gemm( + torch::stable::Tensor& a, std::optional c_or_none, + torch::stable::Tensor& b_q_weight, + std::optional const& b_bias_or_none, + torch::stable::Tensor& b_scales, + std::optional const& a_scales_or_none, + std::optional const& global_scale_or_none, + std::optional const& b_zeros_or_none, + std::optional const& g_idx_or_none, + std::optional const& perm_or_none, + torch::stable::Tensor& workspace, vllm::ScalarTypeId const& b_type_id, + int64_t size_m, int64_t size_n, int64_t size_k, bool is_k_full, + bool use_atomic_add, bool use_fp32_reduce, bool is_zp_float) { vllm::ScalarTypeId a_type_id, c_type_id, s_type_id; - auto c_dtype = a.dtype(); - if (a.scalar_type() == at::ScalarType::Half) { + auto c_scalar_type = a.scalar_type(); + if (a.scalar_type() == torch::headeronly::ScalarType::Half) { a_type_id = vllm::kFloat16.id(); c_type_id = vllm::kFloat16.id(); - } else if (a.scalar_type() == at::ScalarType::BFloat16) { + } else if (a.scalar_type() == torch::headeronly::ScalarType::BFloat16) { a_type_id = vllm::kBFloat16.id(); c_type_id = vllm::kBFloat16.id(); } else { - c_dtype = b_scales.dtype(); - if (b_scales.scalar_type() == at::ScalarType::Half) { + c_scalar_type = b_scales.scalar_type(); + if (b_scales.scalar_type() == torch::headeronly::ScalarType::Half) { c_type_id = vllm::kFloat16.id(); - } else if (b_scales.scalar_type() == at::ScalarType::BFloat16) { + } else if (b_scales.scalar_type() == + torch::headeronly::ScalarType::BFloat16) { c_type_id = vllm::kBFloat16.id(); } else { c_type_id = vllm::kBFloat16.id(); - TORCH_CHECK(c_or_none.has_value(), "c must be passed for W4A8-FP4"); - torch::Tensor c = c_or_none.value(); - c_dtype = c.dtype(); + STD_TORCH_CHECK(c_or_none.has_value(), "c must be passed for W4A8-FP4"); + torch::stable::Tensor c = c_or_none.value(); + c_scalar_type = c.scalar_type(); - if (c.scalar_type() == at::ScalarType::Half) { + if (c.scalar_type() == torch::headeronly::ScalarType::Half) { c_type_id = vllm::kFloat16.id(); - } else if (c.scalar_type() == at::ScalarType::BFloat16) { + } else if (c.scalar_type() == torch::headeronly::ScalarType::BFloat16) { c_type_id = vllm::kBFloat16.id(); } else { - TORCH_CHECK(false, "unsupported c dtype"); + STD_TORCH_CHECK(false, "unsupported c dtype"); } } - if (a.scalar_type() == at::ScalarType::Float8_e4m3fn) { + if (a.scalar_type() == torch::headeronly::ScalarType::Float8_e4m3fn) { a_type_id = vllm::kFE4M3fn.id(); - } else if (a.scalar_type() == at::ScalarType::Char) { + } else if (a.scalar_type() == torch::headeronly::ScalarType::Char) { a_type_id = vllm::kS8.id(); } else { - TORCH_CHECK(false, "unsupported `a` scalar_type"); + STD_TORCH_CHECK(false, "unsupported `a` scalar_type"); } } s_type_id = c_type_id; if (b_type_id == vllm::kFE2M1f.id()) { - if (b_scales.scalar_type() == at::ScalarType::Float8_e4m3fn) { + if (b_scales.scalar_type() == + torch::headeronly::ScalarType::Float8_e4m3fn) { s_type_id = vllm::kFE4M3fn.id(); - } else if (b_scales.scalar_type() == at::ScalarType::Float8_e8m0fnu) { + } else if (b_scales.scalar_type() == + torch::headeronly::ScalarType::Float8_e8m0fnu) { s_type_id = vllm::kFE8M0fnu.id(); } else { - TORCH_CHECK(false, - "When b_type = float4_e2m1f, b_scale scalar type must be", - "float8_e4m3fn (for NVFP4) or float8_e8m0fnu (for MXFP4)."); + STD_TORCH_CHECK( + false, "When b_type = float4_e2m1f, b_scale scalar type must be", + "float8_e4m3fn (for NVFP4) or float8_e8m0fnu (for MXFP4)."); } } else if (b_type_id == vllm::kFE4M3fn.id() && - b_scales.scalar_type() == at::ScalarType::Float8_e8m0fnu) { + b_scales.scalar_type() == + torch::headeronly::ScalarType::Float8_e8m0fnu) { s_type_id = vllm::kFE8M0fnu.id(); } @@ -606,54 +623,58 @@ torch::Tensor marlin_gemm( int pack_factor = 32 / b_type.size_bits(); // Verify A - TORCH_CHECK(a.size(0) == size_m, "Shape mismatch: a.size(0) = ", a.size(0), - ", size_m = ", size_m); - TORCH_CHECK(a.size(1) == size_k, "Shape mismatch: a.size(1) = ", a.size(1), - ", size_k = ", size_k); + STD_TORCH_CHECK(a.size(0) == size_m, + "Shape mismatch: a.size(0) = ", a.size(0), + ", size_m = ", size_m); + STD_TORCH_CHECK(a.size(1) == size_k, + "Shape mismatch: a.size(1) = ", a.size(1), + ", size_k = ", size_k); // Verify B - TORCH_CHECK( + STD_TORCH_CHECK( size_k % MARLIN_NAMESPACE_NAME::tile_size == 0, "size_k = ", size_k, " is not divisible by tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); - TORCH_CHECK((size_k / MARLIN_NAMESPACE_NAME::tile_size) == b_q_weight.size(0), - "Shape mismatch: b_q_weight.size(0) = ", b_q_weight.size(0), - ", size_k = ", size_k, - ", tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); - TORCH_CHECK( + STD_TORCH_CHECK( + (size_k / MARLIN_NAMESPACE_NAME::tile_size) == b_q_weight.size(0), + "Shape mismatch: b_q_weight.size(0) = ", b_q_weight.size(0), + ", size_k = ", size_k, + ", tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); + STD_TORCH_CHECK( b_q_weight.size(1) % MARLIN_NAMESPACE_NAME::tile_size == 0, "b_q_weight.size(1) = ", b_q_weight.size(1), " is not divisible by tile_size = ", MARLIN_NAMESPACE_NAME::tile_size); int actual_size_n = (b_q_weight.size(1) / MARLIN_NAMESPACE_NAME::tile_size) * pack_factor; - TORCH_CHECK(size_n == actual_size_n, "size_n = ", size_n, - ", actual_size_n = ", actual_size_n); + STD_TORCH_CHECK(size_n == actual_size_n, "size_n = ", size_n, + ", actual_size_n = ", actual_size_n); // Verify device and strides - TORCH_CHECK(a.device().is_cuda(), "A is not on GPU"); - TORCH_CHECK(a.stride(1) == 1, "A.stride(1) is not 1"); + STD_TORCH_CHECK(a.device().is_cuda(), "A is not on GPU"); + STD_TORCH_CHECK(a.stride(1) == 1, "A.stride(1) is not 1"); // We use int4 (16 bytes) to load A, so A must aligned to 16 bytes - TORCH_CHECK(a.stride(0) % 8 == 0, "A.stride(0) must divisible by 8"); - TORCH_CHECK(((uint64_t)a.data_ptr()) % 16 == 0, "A must aligned to 16 bytes"); + STD_TORCH_CHECK(a.stride(0) % 8 == 0, "A.stride(0) must divisible by 8"); + STD_TORCH_CHECK(((uint64_t)a.const_data_ptr()) % 16 == 0, + "A must aligned to 16 bytes"); - TORCH_CHECK(b_q_weight.device().is_cuda(), "b_q_weight is not on GPU"); - TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); + STD_TORCH_CHECK(b_q_weight.device().is_cuda(), "b_q_weight is not on GPU"); + STD_TORCH_CHECK(b_q_weight.is_contiguous(), "b_q_weight is not contiguous"); - TORCH_CHECK(b_scales.device().is_cuda(), "b_scales is not on GPU"); - TORCH_CHECK(b_scales.is_contiguous(), "b_scales is not contiguous"); + STD_TORCH_CHECK(b_scales.device().is_cuda(), "b_scales is not on GPU"); + STD_TORCH_CHECK(b_scales.is_contiguous(), "b_scales is not contiguous"); - torch::Tensor a_scales; - auto options = torch::TensorOptions().dtype(c_dtype).device(a.device()); - auto options_fp32 = - torch::TensorOptions().dtype(at::kFloat).device(a.device()); + torch::stable::Tensor a_scales; + const auto device = a.device(); if (a_scales_or_none.has_value()) { a_scales = a_scales_or_none.value(); - TORCH_CHECK(a_type.size_bits() == 8, - "a_scales can only be used for 8bit activation."); + STD_TORCH_CHECK(a_type.size_bits() == 8, + "a_scales can only be used for 8bit activation."); } else { - a_scales = torch::empty({0}, options_fp32); - TORCH_CHECK(a_type.size_bits() != 8, - "the a_scales parameter must be passed for 8bit activation."); + a_scales = torch::stable::empty({0}, torch::headeronly::ScalarType::Float, + std::nullopt, device); + STD_TORCH_CHECK( + a_type.size_bits() != 8, + "the a_scales parameter must be passed for 8bit activation."); } // thread_k: `k` size of a thread_tile in `weights` (can usually be left as @@ -664,84 +685,93 @@ torch::Tensor marlin_gemm( int thread_n = -1; // sms: number of SMs to use for the kernel int sms = -1; - cudaDeviceGetAttribute(&sms, cudaDevAttrMultiProcessorCount, a.get_device()); + const int32_t device_index = a.get_device_index(); + cudaDeviceGetAttribute(&sms, cudaDevAttrMultiProcessorCount, device_index); // Alloc buffers - const at::cuda::OptionalCUDAGuard device_guard(device_of(a)); - torch::Tensor c; + torch::stable::accelerator::DeviceGuard device_guard(device_index); + torch::stable::Tensor c; if (c_or_none.has_value()) { c = c_or_none.value(); - TORCH_CHECK(c.device().is_cuda(), "c is not on GPU"); - TORCH_CHECK(c.is_contiguous(), "c is not contiguous"); - TORCH_CHECK(c.size(0) == size_m, "Shape mismatch: c.size(0) = ", c.size(0), - ", size_m = ", size_m); - TORCH_CHECK(c.size(1) == size_n, "Shape mismatch: c.size(1) = ", c.size(1), - ", size_n = ", size_n); + STD_TORCH_CHECK(c.device().is_cuda(), "c is not on GPU"); + STD_TORCH_CHECK(c.is_contiguous(), "c is not contiguous"); + STD_TORCH_CHECK(c.size(0) == size_m, + "Shape mismatch: c.size(0) = ", c.size(0), + ", size_m = ", size_m); + STD_TORCH_CHECK(c.size(1) == size_n, + "Shape mismatch: c.size(1) = ", c.size(1), + ", size_n = ", size_n); } else { - c = torch::empty({size_m, size_n}, options); + c = torch::stable::empty({size_m, size_n}, c_scalar_type, std::nullopt, + device); } if (size_m == 0) return c; // Alloc C tmp buffer that is going to be used for the global reduce - torch::Tensor c_tmp; + torch::stable::Tensor c_tmp; if (use_fp32_reduce) { int max_m_block_size = (size_m + 16 - 1) / 16 * 16; max_m_block_size = min(max_m_block_size, 64); int max_c_tmp_size = sms * max_m_block_size * MARLIN_NAMESPACE_NAME::max_thread_n; - c_tmp = torch::empty({max_c_tmp_size}, options_fp32); + c_tmp = torch::stable::empty({max_c_tmp_size}, + torch::headeronly::ScalarType::Float, + std::nullopt, device); } else { - c_tmp = torch::empty({0}, options_fp32); + c_tmp = torch::stable::empty({0}, torch::headeronly::ScalarType::Float, + std::nullopt, device); } // Detect groupsize and act_order int num_groups = -1; int group_size = -1; - int rank = b_scales.sizes().size(); - TORCH_CHECK(rank == 2, "b_scales rank = ", rank, " is not 2"); - TORCH_CHECK(b_scales.size(1) == size_n, "b_scales dim 1 = ", b_scales.size(1), - " is not size_n = ", size_n); + int rank = b_scales.dim(); + STD_TORCH_CHECK(rank == 2, "b_scales rank = ", rank, " is not 2"); + STD_TORCH_CHECK(b_scales.size(1) == size_n, + "b_scales dim 1 = ", b_scales.size(1), + " is not size_n = ", size_n); num_groups = b_scales.size(0); - torch::Tensor g_idx, perm, a_tmp; + torch::stable::Tensor g_idx, perm, a_tmp; if (g_idx_or_none.has_value() && perm_or_none.has_value()) { g_idx = g_idx_or_none.value(); perm = perm_or_none.value(); - TORCH_CHECK(g_idx.device().is_cuda(), "g_idx is not on GPU"); - TORCH_CHECK(g_idx.is_contiguous(), "g_idx is not contiguous"); - TORCH_CHECK(perm.device().is_cuda(), "perm is not on GPU"); - TORCH_CHECK(perm.is_contiguous(), "perm is not contiguous"); + STD_TORCH_CHECK(g_idx.device().is_cuda(), "g_idx is not on GPU"); + STD_TORCH_CHECK(g_idx.is_contiguous(), "g_idx is not contiguous"); + STD_TORCH_CHECK(perm.device().is_cuda(), "perm is not on GPU"); + STD_TORCH_CHECK(perm.is_contiguous(), "perm is not contiguous"); // Verify g_idx and perm - TORCH_CHECK((g_idx.size(-1) == 0 && perm.size(-1) == 0) || - (g_idx.size(-1) == size_k && perm.size(-1) == size_k), - "Unexpected g_idx.size(-1) = ", g_idx.size(-1), - " and perm.size(-1) = ", perm.size(-1), - ", where size_k = ", size_k); + STD_TORCH_CHECK((g_idx.size(-1) == 0 && perm.size(-1) == 0) || + (g_idx.size(-1) == size_k && perm.size(-1) == size_k), + "Unexpected g_idx.size(-1) = ", g_idx.size(-1), + " and perm.size(-1) = ", perm.size(-1), + ", where size_k = ", size_k); } else { - g_idx = torch::empty({0}, options); - perm = torch::empty({0}, options); - a_tmp = torch::empty({0}, options); + g_idx = torch::stable::empty({0}, c_scalar_type, std::nullopt, device); + perm = torch::stable::empty({0}, c_scalar_type, std::nullopt, device); + a_tmp = torch::stable::empty({0}, c_scalar_type, std::nullopt, device); } bool has_act_order = g_idx.size(-1) > 0 && perm.size(-1) > 0; if (has_act_order) { - a_tmp = torch::empty({size_m, size_k}, options); + a_tmp = torch::stable::empty({size_m, size_k}, c_scalar_type, std::nullopt, + device); if (is_k_full) { - TORCH_CHECK(num_groups > 1, "For act_order, num_groups must be > 1"); - TORCH_CHECK(size_k % num_groups == 0, "size_k = ", size_k, - ", is not divisible by num_groups = ", num_groups); + STD_TORCH_CHECK(num_groups > 1, "For act_order, num_groups must be > 1"); + STD_TORCH_CHECK(size_k % num_groups == 0, "size_k = ", size_k, + ", is not divisible by num_groups = ", num_groups); group_size = size_k / num_groups; } else { group_size = 0; } } else { - a_tmp = torch::empty({0}, options); + a_tmp = torch::stable::empty({0}, c_scalar_type, std::nullopt, device); if (num_groups > 1) { - TORCH_CHECK( + STD_TORCH_CHECK( size_k % num_groups == 0, "size_k = ", size_k, ", is not divisible by b_scales.size(0) = ", b_scales.size(0)); group_size = size_k / num_groups; @@ -750,109 +780,114 @@ torch::Tensor marlin_gemm( } } - torch::Tensor global_scale; + torch::stable::Tensor global_scale; if (global_scale_or_none.has_value()) { global_scale = global_scale_or_none.value(); - TORCH_CHECK(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn, - "global_scale can only be used for nvfp4 format."); + STD_TORCH_CHECK(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn, + "global_scale can only be used for nvfp4 format."); } else { - global_scale = torch::empty({0}, options_fp32); - TORCH_CHECK(!(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn), - "the global_scale parameter must be passed for nvfp4 format."); + global_scale = torch::stable::empty( + {0}, torch::headeronly::ScalarType::Float, std::nullopt, device); + STD_TORCH_CHECK( + !(b_type == vllm::kFE2M1f && s_type == vllm::kFE4M3fn), + "the global_scale parameter must be passed for nvfp4 format."); } bool has_bias = b_bias_or_none.has_value(); - torch::Tensor b_bias; + torch::stable::Tensor b_bias; if (has_bias) { b_bias = b_bias_or_none.value(); - TORCH_CHECK(b_bias.device().is_cuda(), "b_bias is not on GPU"); - TORCH_CHECK(b_bias.is_contiguous(), "b_bias is not contiguous"); - TORCH_CHECK(b_bias.size(0) == size_n, "b_bias.size(0) != size_n"); - TORCH_CHECK(b_bias.stride(0) == 1, "b_bias.stride(0) != 1"); + STD_TORCH_CHECK(b_bias.device().is_cuda(), "b_bias is not on GPU"); + STD_TORCH_CHECK(b_bias.is_contiguous(), "b_bias is not contiguous"); + STD_TORCH_CHECK(b_bias.size(0) == size_n, "b_bias.size(0) != size_n"); + STD_TORCH_CHECK(b_bias.stride(0) == 1, "b_bias.stride(0) != 1"); } else { - b_bias = torch::empty({0}, options); + b_bias = torch::stable::empty({0}, c_scalar_type, std::nullopt, device); } - torch::Tensor b_zeros; + torch::stable::Tensor b_zeros; if (b_zeros_or_none.has_value()) { b_zeros = b_zeros_or_none.value(); - TORCH_CHECK(b_zeros.device().is_cuda(), "b_zeros is not on GPU"); - TORCH_CHECK(b_zeros.is_contiguous(), "b_zeros is not contiguous"); + STD_TORCH_CHECK(b_zeros.device().is_cuda(), "b_zeros is not on GPU"); + STD_TORCH_CHECK(b_zeros.is_contiguous(), "b_zeros is not contiguous"); } else { - b_zeros = torch::empty({0}, options); + b_zeros = torch::stable::empty({0}, c_scalar_type, std::nullopt, device); } bool has_zp = b_zeros.size(-1) > 0; if (has_zp) { - TORCH_CHECK( + STD_TORCH_CHECK( b_type == vllm::kU4 || b_type == vllm::kU8, "b_type must be u4 or u8 when has_zp = True. Got = ", b_type.str()); } else { - TORCH_CHECK(b_type == vllm::kU4B8 || b_type == vllm::kU8B128 || - b_type == vllm::kS4 || b_type == vllm::kS8 || - b_type == vllm::kFE4M3fn || b_type == vllm::kFE2M1f, - "b_type must be uint4b8, uint8b128, int4, int8, " - "float8_e4m3fn or float4_e2m1f when has_zp = False. Got = ", - b_type.str()); + STD_TORCH_CHECK(b_type == vllm::kU4B8 || b_type == vllm::kU8B128 || + b_type == vllm::kS4 || b_type == vllm::kS8 || + b_type == vllm::kFE4M3fn || b_type == vllm::kFE2M1f, + "b_type must be uint4b8, uint8b128, int4, int8, " + "float8_e4m3fn or float4_e2m1f when has_zp = False. Got = ", + b_type.str()); } if (has_zp && is_zp_float) { - TORCH_CHECK(a.scalar_type() == at::ScalarType::Half, - "Computation type must be float16 (half) when using float zero " - "points."); + STD_TORCH_CHECK( + a.scalar_type() == torch::headeronly::ScalarType::Half, + "Computation type must be float16 (half) when using float zero " + "points."); } // Verify b_zeros if (has_zp) { - int rank = b_zeros.sizes().size(); - TORCH_CHECK(rank == 2, "b_zeros rank = ", rank, " is not 2"); + int rank = b_zeros.dim(); + STD_TORCH_CHECK(rank == 2, "b_zeros rank = ", rank, " is not 2"); if (is_zp_float) { - TORCH_CHECK(b_zeros.size(1) == size_n, - "b_zeros dim 1 = ", b_zeros.size(1), - " is not size_n = ", size_n); - TORCH_CHECK(num_groups == b_zeros.size(0), - "b_zeros dim 0 = ", b_zeros.size(0), - " is not num_groups = ", num_groups); - TORCH_CHECK(num_groups != -1, "num_groups must be != -1"); + STD_TORCH_CHECK(b_zeros.size(1) == size_n, + "b_zeros dim 1 = ", b_zeros.size(1), + " is not size_n = ", size_n); + STD_TORCH_CHECK(num_groups == b_zeros.size(0), + "b_zeros dim 0 = ", b_zeros.size(0), + " is not num_groups = ", num_groups); + STD_TORCH_CHECK(num_groups != -1, "num_groups must be != -1"); } else { - TORCH_CHECK(b_zeros.size(0) == num_groups, - "b_zeros dim 0 = ", b_zeros.size(0), - " is not num_groups = ", num_groups); - TORCH_CHECK(b_zeros.size(1) == size_n / pack_factor, - "b_zeros dim 1 = ", b_zeros.size(1), - " is not size_n / pack_factor = ", size_n / pack_factor); + STD_TORCH_CHECK(b_zeros.size(0) == num_groups, + "b_zeros dim 0 = ", b_zeros.size(0), + " is not num_groups = ", num_groups); + STD_TORCH_CHECK(b_zeros.size(1) == size_n / pack_factor, + "b_zeros dim 1 = ", b_zeros.size(1), + " is not size_n / pack_factor = ", size_n / pack_factor); } } // Verify workspace size - TORCH_CHECK(size_n % MARLIN_NAMESPACE_NAME::min_thread_n == 0, - "size_n = ", size_n, ", is not divisible by min_thread_n = ", - MARLIN_NAMESPACE_NAME::min_thread_n); + STD_TORCH_CHECK(size_n % MARLIN_NAMESPACE_NAME::min_thread_n == 0, + "size_n = ", size_n, ", is not divisible by min_thread_n = ", + MARLIN_NAMESPACE_NAME::min_thread_n); int min_workspace_size = sms; - TORCH_CHECK(workspace.numel() >= min_workspace_size, - "workspace.numel = ", workspace.numel(), - " is below min_workspace_size = ", min_workspace_size); - - int dev = a.get_device(); - - TORCH_CHECK(a_scales.scalar_type() == at::ScalarType::Float, - "scalar type of a_scales must be float"); - TORCH_CHECK(global_scale.scalar_type() == at::ScalarType::Float, - "scalar type of global_scale must be float"); + STD_TORCH_CHECK(workspace.numel() >= min_workspace_size, + "workspace.numel = ", workspace.numel(), + " is below min_workspace_size = ", min_workspace_size); + + STD_TORCH_CHECK( + a_scales.scalar_type() == torch::headeronly::ScalarType::Float, + "scalar type of a_scales must be float"); + STD_TORCH_CHECK( + global_scale.scalar_type() == torch::headeronly::ScalarType::Float, + "scalar type of global_scale must be float"); if (a_type.size_bits() == 16) { - TORCH_CHECK( + STD_TORCH_CHECK( a.scalar_type() == c.scalar_type(), "scalar type of a must be the same with c for 16 bit activation"); } marlin::marlin_mm( - a.data_ptr(), b_q_weight.data_ptr(), c.data_ptr(), c_tmp.data_ptr(), - b_bias.data_ptr(), a_scales.data_ptr(), b_scales.data_ptr(), - global_scale.data_ptr(), b_zeros.data_ptr(), g_idx.data_ptr(), - perm.data_ptr(), a_tmp.data_ptr(), size_m, size_n, size_k, a.stride(0), - workspace.data_ptr(), a_type, b_type, c_type, s_type, has_bias, - has_act_order, is_k_full, has_zp, num_groups, group_size, dev, - at::cuda::getCurrentCUDAStream(dev), thread_k, thread_n, sms, + a.const_data_ptr(), b_q_weight.const_data_ptr(), c.mutable_data_ptr(), + c_tmp.mutable_data_ptr(), b_bias.mutable_data_ptr(), + a_scales.mutable_data_ptr(), b_scales.mutable_data_ptr(), + global_scale.mutable_data_ptr(), b_zeros.mutable_data_ptr(), + g_idx.mutable_data_ptr(), perm.mutable_data_ptr(), + a_tmp.mutable_data_ptr(), size_m, size_n, size_k, a.stride(0), + workspace.mutable_data_ptr(), a_type, b_type, c_type, s_type, has_bias, + has_act_order, is_k_full, has_zp, num_groups, group_size, device_index, + get_current_cuda_stream(device_index), thread_k, thread_n, sms, use_atomic_add, use_fp32_reduce, is_zp_float); return c; @@ -860,6 +895,6 @@ torch::Tensor marlin_gemm( #endif -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("marlin_gemm", &marlin_gemm); +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { + m.impl("marlin_gemm", TORCH_BOX(&marlin_gemm)); } diff --git a/csrc/quantization/marlin/marlin.cuh b/csrc/libtorch_stable/quantization/marlin/marlin.cuh similarity index 93% rename from csrc/quantization/marlin/marlin.cuh rename to csrc/libtorch_stable/quantization/marlin/marlin.cuh index d3a91568349f..bfb65e874b3d 100644 --- a/csrc/quantization/marlin/marlin.cuh +++ b/csrc/libtorch_stable/quantization/marlin/marlin.cuh @@ -2,14 +2,6 @@ #ifndef _marlin_cuh #define _marlin_cuh - // These torch headers are only needed by non-stable callers (e.g. ops.cu). - // Guard them so that stable ABI targets can still include marlin.cuh - // for Vec, constants, and cp_async helpers without pulling in torch/all.h. - #ifndef TORCH_TARGET_VERSION - #include - #include - #include - #endif #include #include #include diff --git a/csrc/quantization/marlin/marlin_dtypes.cuh b/csrc/libtorch_stable/quantization/marlin/marlin_dtypes.cuh similarity index 100% rename from csrc/quantization/marlin/marlin_dtypes.cuh rename to csrc/libtorch_stable/quantization/marlin/marlin_dtypes.cuh diff --git a/csrc/libtorch_stable/quantization/marlin/marlin_int4_fp8_preprocess.cu b/csrc/libtorch_stable/quantization/marlin/marlin_int4_fp8_preprocess.cu new file mode 100644 index 000000000000..f8ef6b12a01f --- /dev/null +++ b/csrc/libtorch_stable/quantization/marlin/marlin_int4_fp8_preprocess.cu @@ -0,0 +1,118 @@ + +#include "marlin.cuh" + +#include +#include +#include +#include +#include +#include + +#include "libtorch_stable/torch_utils.h" + +// for only non-zp format (like gptq) +__global__ void marlin_int4_fp8_preprocess_kernel_without_zp( + // qweight: (size_k * size_n // 8,) + const int32_t* __restrict__ qweight, + // output: same shape with qweight + int32_t* __restrict__ output) { + int32_t val = qweight[blockIdx.x * 32 + threadIdx.x]; + int32_t new_val = 0; + +#pragma unroll + for (int32_t i = 0; i < 8; i++) { + int32_t single_val = val & 0xF; + single_val = single_val >= 8 ? single_val - 8 : 15 - single_val; + new_val |= single_val << (i * 4); + val >>= 4; + } + + output[blockIdx.x * 32 + threadIdx.x] = new_val; +} + +// for awq format only (with zp and with awq weight layout) +__global__ void marlin_int4_fp8_preprocess_kernel_awq( + // AWQ qweight: (size_k, size_n // 8) + const int32_t* __restrict__ qweight, + // output: same shape with qweight + int32_t* __restrict__ output, + // AWQ zeros: (size_k // group_size, size_n // 8) + const int32_t* __restrict__ qzeros, int32_t size_n, int32_t size_k, + int32_t group_size) { + int32_t val = + qweight[(blockIdx.x * 32 + threadIdx.x) * size_n / 8 + blockIdx.y]; + int32_t zero = + qzeros[(blockIdx.x * 32 + threadIdx.x) / group_size * size_n / 8 + + blockIdx.y]; + int32_t new_val = 0; + +#pragma unroll + for (int32_t i = 0; i < 8; i++) { + int32_t single_val = val & 0xF; + int32_t single_zero = zero & 0xF; + + single_val = + single_val >= single_zero ? single_val - single_zero : 15 - single_val; + new_val |= single_val << (i * 4); + val >>= 4; + zero >>= 4; + } + + output[(blockIdx.x * 32 + threadIdx.x) * size_n / 8 + blockIdx.y] = new_val; +} + +torch::stable::Tensor marlin_int4_fp8_preprocess( + torch::stable::Tensor& qweight, + std::optional qzeros_or_none, bool inplace) { + STD_TORCH_CHECK(qweight.is_cuda(), "qweight is not on GPU"); + STD_TORCH_CHECK(qweight.scalar_type() == torch::headeronly::ScalarType::Int, + "qweight.dtype != torch.int32"); + + const int32_t device_index = qweight.get_device_index(); + torch::stable::accelerator::DeviceGuard device_guard(device_index); + const cudaStream_t stream = get_current_cuda_stream(device_index); + + torch::stable::Tensor output = + inplace ? qweight : torch::stable::empty_like(qweight); + + if (!qzeros_or_none.has_value()) { + STD_TORCH_CHECK(qweight.numel() * 8 % 256 == 0, + "qweight.numel() * 8 % 256 != 0"); + + int blocks = qweight.numel() * 8 / 256; + marlin_int4_fp8_preprocess_kernel_without_zp<<>>( + reinterpret_cast(qweight.const_data_ptr()), + reinterpret_cast(output.mutable_data_ptr())); + } else { + int32_t size_k = qweight.size(0); + int32_t size_n = qweight.size(1) * 8; + torch::stable::Tensor qzeros = qzeros_or_none.value(); + + STD_TORCH_CHECK(size_k % 32 == 0, "size_k % 32 != 0"); + STD_TORCH_CHECK(qzeros.is_cuda(), "qzeros is not on GPU"); + STD_TORCH_CHECK(qzeros.scalar_type() == torch::headeronly::ScalarType::Int, + "qweight.dtype != torch.int32"); + STD_TORCH_CHECK(qzeros.get_device_index() == device_index, + "qzeros is not on the same device with qweight"); + + int32_t group_size = qweight.size(0) / qzeros.size(0); + STD_TORCH_CHECK(qweight.size(1) == qzeros.size(1), + "qweight.size(1) != qzeros.size(1)"); + STD_TORCH_CHECK(qweight.size(0) % qzeros.size(0) == 0, + "qweight.size(0) % qzeros.size(0) != 0"); + STD_TORCH_CHECK(group_size % 8 == 0, "group_size % 8 != 0"); + + dim3 blocks(size_k / 32, size_n / 8); + marlin_int4_fp8_preprocess_kernel_awq<<>>( + reinterpret_cast(qweight.const_data_ptr()), + reinterpret_cast(output.mutable_data_ptr()), + reinterpret_cast(qzeros.const_data_ptr()), size_n, + size_k, group_size); + } + + return output; +} + +STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, m) { + m.impl("marlin_int4_fp8_preprocess", TORCH_BOX(&marlin_int4_fp8_preprocess)); +} diff --git a/csrc/quantization/marlin/marlin_mma.h b/csrc/libtorch_stable/quantization/marlin/marlin_mma.h similarity index 100% rename from csrc/quantization/marlin/marlin_mma.h rename to csrc/libtorch_stable/quantization/marlin/marlin_mma.h diff --git a/csrc/quantization/marlin/marlin_template.h b/csrc/libtorch_stable/quantization/marlin/marlin_template.h similarity index 100% rename from csrc/quantization/marlin/marlin_template.h rename to csrc/libtorch_stable/quantization/marlin/marlin_template.h diff --git a/csrc/libtorch_stable/quantization/vectorization_utils.cuh b/csrc/libtorch_stable/quantization/vectorization_utils.cuh index 98b491b7e23f..0cc89bf289d4 100644 --- a/csrc/libtorch_stable/quantization/vectorization_utils.cuh +++ b/csrc/libtorch_stable/quantization/vectorization_utils.cuh @@ -24,13 +24,21 @@ __device__ inline void vectorize_with_alignment( ScaOp&& scalar_op) { // InT -> OutT static_assert(VEC_SIZE > 0 && (VEC_SIZE & (VEC_SIZE - 1)) == 0, "VEC_SIZE must be a positive power-of-two"); - constexpr int WIDTH = VEC_SIZE * sizeof(InT); // eg: 64 B + constexpr int WIDTH = VEC_SIZE * sizeof(InT); // eg: 16 B + constexpr int OUT_WIDTH = VEC_SIZE * sizeof(OutT); // eg: 16 B uintptr_t addr = reinterpret_cast(in); - - // fast path when the whole region is already aligned - // Note: currently the output is guaranteed to be same as the input, so we - // don't check it here, comments here just for future reference. - bool can_vec = ((addr & (WIDTH - 1)) == 0) && ((len & (VEC_SIZE - 1)) == 0); + uintptr_t out_addr = reinterpret_cast(out); + + // fast path when input and output are both fully aligned. The vector + // load/store below go through vec_n_t, declared + // __align__(VEC_SIZE * sizeof(T)), so each side must be aligned to its + // own vector width. out is NOT generally co-aligned with in: e.g. + // reshape_and_cache_flash writes KV-cache rows whose byte offset is a + // multiple of head_size, which for head sizes that are not a multiple + // of VEC_SIZE puts some rows off the vector-width boundary. + bool can_vec = ((addr & (WIDTH - 1)) == 0) && + ((out_addr & (OUT_WIDTH - 1)) == 0) && + ((len & (VEC_SIZE - 1)) == 0); if (can_vec) { int num_vec = len / VEC_SIZE; @@ -55,6 +63,16 @@ __device__ inline void vectorize_with_alignment( prefix_elems /= sizeof(InT); prefix_elems = min(prefix_elems, len); // 0 ≤ prefix < 16 + // the prefix below aligns in; if that does not also align out (their + // addresses differ modulo the vector width), vectorizing is impossible + // and the whole copy must stay scalar. + if (((out_addr + prefix_elems * sizeof(OutT)) & (OUT_WIDTH - 1)) != 0) { + for (int i = tid; i < len; i += stride) { + scalar_op(out[i], in[i]); + } + return; + } + // 1. prefill the when it is unsafe to vectorize for (int i = tid; i < prefix_elems; i += stride) { scalar_op(out[i], in[i]); diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/cutlass_gemm_caller.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/cutlass_gemm_caller.cuh index ae40c0989e03..a93a136bf425 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/cutlass_gemm_caller.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/cutlass_gemm_caller.cuh @@ -19,8 +19,8 @@ #include "cutlass/gemm/collective/collective_builder.hpp" #include "cutlass/util/packed_stride.hpp" -#include "core/math.hpp" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/core/math.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" // clang-format on namespace vllm::c3x { @@ -37,6 +37,7 @@ void cutlass_gemm_caller( typename GemmKernel::MainloopArguments mainloop_args, typename GemmKernel::EpilogueArguments epilogue_args, typename GemmKernel::TileSchedulerArguments scheduler = {}) { + const torch::stable::accelerator::DeviceGuard device_guard(device.index()); cutlass::KernelHardwareInfo hw_info; typename GemmKernel::Arguments args{cutlass::gemm::GemmUniversalMode::kGemm, prob_shape, diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm.cuh index 952931103c67..7b7d4d714731 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm.cuh @@ -14,8 +14,8 @@ #include "cutlass/epilogue/collective/collective_builder.hpp" #include "cutlass/gemm/collective/collective_builder.hpp" -#include "core/math.hpp" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/core/math.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" // clang-format on /* diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_azp_sm90_int8.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_azp_sm90_int8.cu index bc088cf633f4..5178eed0722e 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_azp_sm90_int8.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_azp_sm90_int8.cu @@ -1,6 +1,6 @@ #include "scaled_mm_kernels.hpp" #include "scaled_mm_sm90_int8_dispatch.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" namespace vllm { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm100_fp8.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm100_fp8.cu index f3df69850ec6..b7930012265f 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm100_fp8.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm100_fp8.cu @@ -1,6 +1,6 @@ #include "scaled_mm_kernels.hpp" #include "scaled_mm_blockwise_sm100_fp8_dispatch.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" namespace vllm { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm120_fp8.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm120_fp8.cu index 7ceb0697df2a..426025ac131b 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm120_fp8.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm120_fp8.cu @@ -1,6 +1,6 @@ #include "scaled_mm_kernels.hpp" #include "scaled_mm_blockwise_sm120_fp8_dispatch.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" namespace vllm { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8.cu index d3318c487675..a97909d37ae8 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8.cu @@ -1,7 +1,7 @@ #include "scaled_mm_kernels.hpp" #include "scaled_mm_blockwise_sm90_fp8_dispatch.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" namespace vllm { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8_dispatch.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8_dispatch.cuh index cf62e81fd75b..529b28ceece5 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8_dispatch.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_blockwise_sm90_fp8_dispatch.cuh @@ -25,33 +25,43 @@ using namespace cute; template + class EpilogueScheduler, class MainloopScheduler, + bool swap_ab_ = false> struct cutlass_3x_gemm_fp8_blockwise { + static constexpr bool swap_ab = swap_ab_; using ElementAB = cutlass::float_e4m3_t; using ElementA = ElementAB; using LayoutA = cutlass::layout::RowMajor; + using LayoutA_Transpose = typename cutlass::layout::LayoutTranspose::type; static constexpr int AlignmentA = 128 / cutlass::sizeof_bits::value; using ElementB = ElementAB; using LayoutB = cutlass::layout::ColumnMajor; + using LayoutB_Transpose = typename cutlass::layout::LayoutTranspose::type; static constexpr int AlignmentB = 128 / cutlass::sizeof_bits::value; using ElementD = OutType; using LayoutD = cutlass::layout::RowMajor; + using LayoutD_Transpose = typename cutlass::layout::LayoutTranspose::type; static constexpr int AlignmentD = 128 / cutlass::sizeof_bits::value; using ElementC = void; // TODO: support bias using LayoutC = LayoutD; + using LayoutC_Transpose = LayoutD_Transpose; static constexpr int AlignmentC = AlignmentD; using ElementAccumulator = float; using ElementCompute = float; using ElementBlockScale = float; - using ScaleConfig = cutlass::detail::Sm90BlockwiseScaleConfig< + using ScaleConfig = conditional_t; + cute::GMMA::Major::K, cute::GMMA::Major::MN>, + cutlass::detail::Sm90BlockwiseScaleConfig< + ScaleGranularityM, ScaleGranularityN, ScaleGranularityK, + cute::GMMA::Major::MN, cute::GMMA::Major::K>>; using LayoutSFA = decltype(ScaleConfig::deduce_layoutSFA()); using LayoutSFB = decltype(ScaleConfig::deduce_layoutSFB()); @@ -71,30 +81,46 @@ struct cutlass_3x_gemm_fp8_blockwise { ElementAccumulator, ElementCompute, ElementC, - LayoutC, + conditional_t, AlignmentC, ElementD, - LayoutD, + conditional_t, AlignmentD, EpilogueScheduler, DefaultOperation >::CollectiveOp; - using CollectiveMainloop = typename cutlass::gemm::collective::CollectiveBuilder< - ArchTag, - OperatorClass, - ElementA, - cute::tuple, - AlignmentA, - ElementB, - cute::tuple, - AlignmentB, - ElementAccumulator, - MmaTileShape, - ClusterShape, - cutlass::gemm::collective::StageCountAutoCarveout(sizeof(typename CollectiveEpilogue::SharedStorage))>, - MainloopScheduler - >::CollectiveOp; + using CollectiveMainloop = conditional_t, + AlignmentB, + ElementA, + cute::tuple, + AlignmentA, + ElementAccumulator, + MmaTileShape, + ClusterShape, + cutlass::gemm::collective::StageCountAutoCarveout(sizeof(typename CollectiveEpilogue::SharedStorage))>, + MainloopScheduler + >::CollectiveOp, + typename cutlass::gemm::collective::CollectiveBuilder< + ArchTag, + OperatorClass, + ElementA, + cute::tuple, + AlignmentA, + ElementB, + cute::tuple, + AlignmentB, + ElementAccumulator, + MmaTileShape, + ClusterShape, + cutlass::gemm::collective::StageCountAutoCarveout(sizeof(typename CollectiveEpilogue::SharedStorage))>, + MainloopScheduler + >::CollectiveOp>; using KernelType = enable_sm90_or_later, CollectiveMainloop, CollectiveEpilogue>>; @@ -107,6 +133,7 @@ void cutlass_gemm_caller_blockwise(torch::stable::Tensor& out, torch::stable::Te torch::stable::Tensor const& b, torch::stable::Tensor const& a_scales, torch::stable::Tensor const& b_scales) { + static constexpr bool swap_ab = Gemm::swap_ab; using GemmKernel = typename Gemm::GemmKernel; using StrideA = typename Gemm::GemmKernel::StrideA; using StrideB = typename Gemm::GemmKernel::StrideB; @@ -122,8 +149,6 @@ void cutlass_gemm_caller_blockwise(torch::stable::Tensor& out, torch::stable::Te int32_t m = a.size(0), n = b.size(1), k = a.size(1); - STD_TORCH_CHECK(m % 4 == 0, "m must be divisible by 4"); - StrideA a_stride; StrideB b_stride; StrideC c_stride; @@ -132,12 +157,16 @@ void cutlass_gemm_caller_blockwise(torch::stable::Tensor& out, torch::stable::Te b_stride = cutlass::make_cute_packed_stride(StrideB{}, cute::make_shape(n, k, 1)); c_stride = - cutlass::make_cute_packed_stride(StrideC{}, cute::make_shape(m, n, 1)); + cutlass::make_cute_packed_stride( + StrideC{}, swap_ab ? cute::make_shape(n, m, 1) + : cute::make_shape(m, n, 1)); - LayoutSFA layout_SFA = - ScaleConfig::tile_atom_to_shape_SFA(make_shape(m, n, k, 1)); - LayoutSFB layout_SFB = - ScaleConfig::tile_atom_to_shape_SFB(make_shape(m, n, k, 1)); + LayoutSFA layout_SFA = swap_ab + ? ScaleConfig::tile_atom_to_shape_SFA(make_shape(n, m, k, 1)) + : ScaleConfig::tile_atom_to_shape_SFA(make_shape(m, n, k, 1)); + LayoutSFB layout_SFB = swap_ab + ? ScaleConfig::tile_atom_to_shape_SFB(make_shape(n, m, k, 1)) + : ScaleConfig::tile_atom_to_shape_SFB(make_shape(m, n, k, 1)); auto a_ptr = static_cast(a.data_ptr()); auto b_ptr = static_cast(b.data_ptr()); @@ -145,15 +174,25 @@ void cutlass_gemm_caller_blockwise(torch::stable::Tensor& out, torch::stable::Te auto b_scales_ptr = static_cast(b_scales.data_ptr()); typename GemmKernel::MainloopArguments mainloop_args{}; - mainloop_args.ptr_A = a_ptr; - mainloop_args.dA = a_stride; - mainloop_args.ptr_B = b_ptr; - mainloop_args.dB = b_stride; - mainloop_args.ptr_SFA = a_scales_ptr; mainloop_args.layout_SFA = layout_SFA; - mainloop_args.ptr_SFB = b_scales_ptr; mainloop_args.layout_SFB = layout_SFB; - auto prob_shape = cute::make_shape(m, n, k, 1); + if (swap_ab) { + mainloop_args.ptr_A = b_ptr; + mainloop_args.dA = b_stride; + mainloop_args.ptr_B = a_ptr; + mainloop_args.dB = a_stride; + mainloop_args.ptr_SFA = b_scales_ptr; + mainloop_args.ptr_SFB = a_scales_ptr; + } else { + mainloop_args.ptr_A = a_ptr; + mainloop_args.dA = a_stride; + mainloop_args.ptr_B = b_ptr; + mainloop_args.dB = b_stride; + mainloop_args.ptr_SFA = a_scales_ptr; + mainloop_args.ptr_SFB = b_scales_ptr; + } + auto prob_shape = swap_ab ? cute::make_shape(n, m, k, 1) + : cute::make_shape(m, n, k, 1); auto c_ptr = static_cast(out.data_ptr()); typename GemmKernel::EpilogueArguments epilogue_args{ @@ -168,12 +207,21 @@ void cutlass_gemm_blockwise_sm90_fp8_dispatch(torch::stable::Tensor& out, torch::stable::Tensor const& b, torch::stable::Tensor const& a_scales, torch::stable::Tensor const& b_scales) { - // TODO: better heuristics + bool swap_ab = (a.size(0) % 4) != 0; + if (!swap_ab) { + cutlass_gemm_caller_blockwise, + Shape<_1, _2, _1>, cutlass::epilogue::TmaWarpSpecializedCooperative, + cutlass::gemm::KernelTmaWarpSpecializedCooperativeFP8BlockScaledAccum>>( + out, a, b, a_scales, b_scales); + return; + } + cutlass_gemm_caller_blockwise, - Shape<_1, _2, _1>, cutlass::epilogue::TmaWarpSpecializedCooperative, - cutlass::gemm::KernelTmaWarpSpecializedCooperativeFP8BlockScaledAccum>>( - out, a, b, a_scales, b_scales); + OutType, 128, 1, 128, Shape<_128, _16, _128>, + Shape<_1, _1, _1>, cutlass::epilogue::TmaWarpSpecialized, + cutlass::gemm::KernelTmaWarpSpecializedPingpongFP8BlockScaledAccum, + true>>(out, a, b, a_scales, b_scales); } } // namespace vllm \ No newline at end of file diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_helper.hpp b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_helper.hpp index adb3de50fc1b..913436186c3a 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_helper.hpp +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_helper.hpp @@ -1,7 +1,7 @@ #include #include #include "cuda_utils.h" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" template void dispatch_scaled_mm(torch::stable::Tensor& c, diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm100_fp8_dispatch.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm100_fp8_dispatch.cuh index f790b3653d57..42f9e4d5bc83 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm100_fp8_dispatch.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm100_fp8_dispatch.cuh @@ -4,7 +4,7 @@ #include "scaled_mm.cuh" #include "cutlass_gemm_caller.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" /** * This file defines Gemm kernel configurations for SM100 (fp8) based on the diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm120_fp8.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm120_fp8.cu index 972d6c626062..1f709699eb9e 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm120_fp8.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm120_fp8.cu @@ -1,7 +1,7 @@ #include "scaled_mm_kernels.hpp" #include "scaled_mm_sm120_fp8_dispatch.cuh" #include "core/batch_invariant.hpp" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" namespace vllm { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_fp8_dispatch.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_fp8_dispatch.cuh index f78b8daea510..2fae3016c309 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_fp8_dispatch.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_fp8_dispatch.cuh @@ -4,7 +4,7 @@ #include "scaled_mm.cuh" #include "cutlass_gemm_caller.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" /** * This file defines Gemm kernel configurations for SM90 (fp8) based on the Gemm diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_int8.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_int8.cu index 717a2a588307..bf2bd030e3c3 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_int8.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/scaled_mm_sm90_int8.cu @@ -1,6 +1,6 @@ #include "scaled_mm_kernels.hpp" #include "scaled_mm_sm90_int8_dispatch.cuh" -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" namespace vllm { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/get_group_starts.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/get_group_starts.cuh index e073b4e64adb..6942c4b48ccb 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/get_group_starts.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/get_group_starts.cuh @@ -74,6 +74,8 @@ void run_get_group_gemm_starts( bool per_act_token = a_scales.numel() != 1; bool per_out_ch = b_scales.numel() != num_experts; + const torch::stable::accelerator::DeviceGuard device_guard( + a_tensors.get_device_index()); auto stream = get_current_cuda_stream(a_tensors.get_device_index()); if (false) { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x.cuh index 49df3fa4e7f2..b4cd520e9679 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/grouped_mm_c3x.cuh @@ -7,8 +7,9 @@ #include "cutlass/gemm/device/gemm_universal_adapter.h" #include -#include "cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/epilogue/scaled_mm_epilogues_c3x.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" +#include "libtorch_stable/torch_utils.h" #include "get_group_starts.cuh" using namespace cute; @@ -103,6 +104,8 @@ void cutlass_group_gemm_caller(torch::stable::Tensor& out_tensors, int num_experts = static_cast(expert_offsets.size(0)); + const torch::stable::accelerator::DeviceGuard device_guard( + a_tensors.get_device_index()); auto stream = get_current_cuda_stream(a_tensors.get_device_index()); auto device = a_tensors.device(); diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/moe_data.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/moe_data.cu index 2632989cc69d..47e0985b1237 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/moe_data.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/moe/moe_data.cu @@ -212,6 +212,8 @@ void get_cutlass_moe_mm_problem_sizes_from_expert_offsets_caller( "n and k must fit in int32"); int const num_experts = static_cast(num_experts64); + const torch::stable::accelerator::DeviceGuard device_guard( + expert_first_token_offset.get_device_index()); auto stream = get_current_cuda_stream(expert_first_token_offset.get_device_index()); @@ -241,6 +243,7 @@ void get_cutlass_moe_mm_data_caller( const std::optional& blockscale_offsets, const bool is_gated) { auto device = topk_ids.device(); + const torch::stable::accelerator::DeviceGuard device_guard(device.index()); auto stream = get_current_cuda_stream(device.index()); torch::stable::Tensor atomic_buffer = torch::stable::new_zeros( topk_ids, {num_experts}, torch::headeronly::ScalarType::Int); @@ -311,6 +314,8 @@ void get_cutlass_batched_moe_mm_data_caller( const torch::stable::Tensor& expert_num_tokens, const int64_t num_local_experts, const int64_t padded_m, const int64_t n, const int64_t k) { + const torch::stable::accelerator::DeviceGuard device_guard( + expert_offsets.get_device_index()); auto stream = get_current_cuda_stream(expert_offsets.get_device_index()); if (num_local_experts * padded_m > SWAP_AB_THRESHOLD) { diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c2x.cuh b/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c2x.cuh index 6eb2c051d00f..9f9e12e982ed 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c2x.cuh +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_c2x.cuh @@ -22,8 +22,8 @@ #include "cutlass/epilogue/threadblock/fusion/visitors.hpp" #include "cutlass/gemm/kernel/default_gemm_universal_with_visitor.h" -#include "core/math.hpp" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/core/math.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" // clang-format on using namespace cute; @@ -156,6 +156,7 @@ inline void cutlass_gemm_caller(torch::stable::Tensor& out, torch::stable::empty(workspace_size, torch::headeronly::ScalarType::Byte, std::nullopt, device); + const torch::stable::accelerator::DeviceGuard device_guard(device.index()); auto stream = get_current_cuda_stream(device.index()); CUTLASS_CHECK(gemm_op.can_implement(args)); diff --git a/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_entry.cu b/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_entry.cu index 2e5bbca4700a..51f84d2ffd95 100644 --- a/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_entry.cu +++ b/csrc/libtorch_stable/quantization/w8a8/cutlass/scaled_mm_entry.cu @@ -1,10 +1,11 @@ +#include #include #include #include "libtorch_stable/torch_utils.h" -#include "cutlass_extensions/common.hpp" +#include "libtorch_stable/cutlass_extensions/common.hpp" void cutlass_scaled_mm_sm75(torch::stable::Tensor& c, torch::stable::Tensor const& a, @@ -174,15 +175,20 @@ bool cutlass_scaled_mm_supports_block_fp8(int64_t cuda_device_capability) { bool cutlass_group_gemm_supported(int64_t cuda_device_capability) { // CUTLASS grouped FP8 kernels need at least CUDA 12.3 and SM90 (Hopper) - // or CUDA 12.8 and SM100 (Blackwell) + // or CUDA 12.8 and SM100 (Blackwell). Only report archs that have an + // actual cutlass_moe_mm dispatch compiled into this file. #if defined CUDA_VERSION - if (cuda_device_capability >= 100) { + #if defined ENABLE_CUTLASS_MOE_SM100 && ENABLE_CUTLASS_MOE_SM100 + if (cuda_device_capability >= 100 && cuda_device_capability < 120) { return CUDA_VERSION >= 12080; } - if (cuda_device_capability >= 90) { + #endif + #if defined ENABLE_CUTLASS_MOE_SM90 && ENABLE_CUTLASS_MOE_SM90 + if (cuda_device_capability >= 90 && cuda_device_capability < 100) { return CUDA_VERSION >= 12030; } + #endif #endif return false; diff --git a/csrc/libtorch_stable/quantization/w8a8/fp8/common.cu b/csrc/libtorch_stable/quantization/w8a8/fp8/common.cu index d02fc2296e61..86696e2f7a78 100644 --- a/csrc/libtorch_stable/quantization/w8a8/fp8/common.cu +++ b/csrc/libtorch_stable/quantization/w8a8/fp8/common.cu @@ -1,6 +1,6 @@ #include "../../../../quantization/w8a8/fp8/common.cuh" #include "../../../dispatch_utils.h" -#include "../../../../cub_helpers.h" +#include "../../../cub_helpers.h" #include "../../vectorization_utils.cuh" #include "../../../torch_utils.h" #include diff --git a/csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu b/csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu index 316a7d37522f..f4a0bd544289 100644 --- a/csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu +++ b/csrc/libtorch_stable/quantization/w8a8/fp8/per_token_group_quant.cu @@ -119,6 +119,10 @@ __global__ void per_token_group_quant_8bit_kernel( static_cast(output_q) + block_group_offset; scale_element_t* scale_output; +#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) + cudaGridDependencySynchronize(); +#endif + if constexpr (IS_COLUMN_MAJOR) { const int num_elems_per_pack = static_cast(sizeof(scale_packed_t) / sizeof(scale_element_t)); @@ -153,6 +157,10 @@ __global__ void per_token_group_quant_8bit_kernel( QuantizeGroup(smem_group, group_output, group_size, lane_id, threads_per_group, y_s, min_8bit, max_8bit); + +#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) + cudaTriggerProgrammaticLaunchCompletion(); +#endif } inline int GetGroupsPerBlock(int64_t num_groups) { @@ -195,6 +203,8 @@ void per_token_group_quant_8bit(const torch::stable::Tensor& input, STD_TORCH_CHECK(input.numel() % group_size == 0); STD_TORCH_CHECK(output_s.dim() == 2); + const torch::stable::accelerator::DeviceGuard device_guard( + input.get_device_index()); cudaStream_t stream = get_current_cuda_stream(); constexpr int THREADS_PER_GROUP = 16; @@ -209,45 +219,56 @@ void per_token_group_quant_8bit(const torch::stable::Tensor& input, const int scale_num_rows = output_s.size(1); const int scale_stride = output_s.stride(1); -#define LAUNCH_KERNEL(T, DST_DTYPE) \ - do { \ - dim3 grid(num_blocks); \ - dim3 block(num_threads); \ - size_t smem_bytes = \ - static_cast(groups_per_block) * group_size * sizeof(T); \ - if (is_column_major) { \ - if (scale_ue8m0) { \ - per_token_group_quant_8bit_kernel \ - <<>>( \ - static_cast(input.data_ptr()), output_q.data_ptr(), \ - static_cast(output_s.data_ptr()), group_size, \ - num_groups, groups_per_block, (float)eps, (float)min_8bit, \ - (float)max_8bit, scale_num_rows, scale_stride); \ - } else { \ - per_token_group_quant_8bit_kernel \ - <<>>( \ - static_cast(input.data_ptr()), output_q.data_ptr(), \ - static_cast(output_s.data_ptr()), group_size, \ - num_groups, groups_per_block, (float)eps, (float)min_8bit, \ - (float)max_8bit, scale_num_rows, scale_stride); \ - } \ - } else { \ - if (scale_ue8m0) { \ - per_token_group_quant_8bit_kernel \ - <<>>( \ - static_cast(input.data_ptr()), output_q.data_ptr(), \ - static_cast(output_s.data_ptr()), group_size, \ - num_groups, groups_per_block, (float)eps, (float)min_8bit, \ - (float)max_8bit); \ - } else { \ - per_token_group_quant_8bit_kernel \ - <<>>( \ - static_cast(input.data_ptr()), output_q.data_ptr(), \ - static_cast(output_s.data_ptr()), group_size, \ - num_groups, groups_per_block, (float)eps, (float)min_8bit, \ - (float)max_8bit); \ - } \ - } \ +#ifndef USE_ROCM + #define LAUNCH_KERNEL_INST(T, DST_DTYPE, COL_MAJOR, UE8M0, SMEM_BYTES) \ + do { \ + cudaLaunchConfig_t config = {}; \ + config.gridDim = dim3(num_blocks); \ + config.blockDim = dim3(num_threads); \ + config.dynamicSmemBytes = (SMEM_BYTES); \ + config.stream = stream; \ + cudaLaunchAttribute attrs[1]; \ + attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization; \ + attrs[0].val.programmaticStreamSerializationAllowed = 1; \ + config.numAttrs = 1; \ + config.attrs = attrs; \ + cudaLaunchKernelEx( \ + &config, \ + per_token_group_quant_8bit_kernel, \ + static_cast(input.data_ptr()), output_q.data_ptr(), \ + static_cast(output_s.data_ptr()), group_size, num_groups, \ + groups_per_block, (float)eps, (float)min_8bit, (float)max_8bit, \ + scale_num_rows, scale_stride); \ + } while (0) +#else + #define LAUNCH_KERNEL_INST(T, DST_DTYPE, COL_MAJOR, UE8M0, SMEM_BYTES) \ + do { \ + per_token_group_quant_8bit_kernel \ + <<>>( \ + static_cast(input.data_ptr()), output_q.data_ptr(), \ + static_cast(output_s.data_ptr()), group_size, \ + num_groups, groups_per_block, (float)eps, (float)min_8bit, \ + (float)max_8bit, scale_num_rows, scale_stride); \ + } while (0) +#endif + +#define LAUNCH_KERNEL(T, DST_DTYPE) \ + do { \ + size_t smem_bytes = \ + static_cast(groups_per_block) * group_size * sizeof(T); \ + if (is_column_major) { \ + if (scale_ue8m0) { \ + LAUNCH_KERNEL_INST(T, DST_DTYPE, true, true, smem_bytes); \ + } else { \ + LAUNCH_KERNEL_INST(T, DST_DTYPE, true, false, smem_bytes); \ + } \ + } else { \ + if (scale_ue8m0) { \ + LAUNCH_KERNEL_INST(T, DST_DTYPE, false, true, smem_bytes); \ + } else { \ + LAUNCH_KERNEL_INST(T, DST_DTYPE, false, false, smem_bytes); \ + } \ + } \ } while (0) VLLM_STABLE_DISPATCH_FLOATING_TYPES( @@ -262,6 +283,7 @@ void per_token_group_quant_8bit(const torch::stable::Tensor& input, })); #undef LAUNCH_KERNEL +#undef LAUNCH_KERNEL_INST } // Register-resident fast path for group_size==128. @@ -301,12 +323,21 @@ __global__ void per_token_group_quant_8bit_packed_register_kernel( const int sf_k_local = local_group_id % kGroupsPerBlockX; const int row_local = local_group_id / kGroupsPerBlockX; - const int sf_k_idx = blockIdx.x * kGroupsPerBlockX + sf_k_local; - const int mn_idx = blockIdx.y * kRowsPerBlock + row_local; + // Rows on grid.x: mn scales with tokens and can exceed the 65535 grid.y cap. + const int sf_k_idx = blockIdx.y * kGroupsPerBlockX + sf_k_local; + const int mn_idx = blockIdx.x * kRowsPerBlock + row_local; + +#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) + cudaGridDependencySynchronize(); +#endif if (mn_idx >= tma_aligned_mn) { +#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) + cudaTriggerProgrammaticLaunchCompletion(); +#endif return; } + const bool is_valid_group = (mn_idx < mn) && (sf_k_idx < groups_per_row); // Load 16 input elements (32 B) into registers as two adjacent uint4 @@ -417,6 +448,10 @@ __global__ void per_token_group_quant_8bit_packed_register_kernel( static_cast(mn_idx) * groups_per_row * GROUP_SIZE + sf_k_idx * GROUP_SIZE + lane_id * VEC_SIZE; *reinterpret_cast(group_output) = packed_out; + +#if (defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)) + cudaTriggerProgrammaticLaunchCompletion(); +#endif } // Public entry point: register-resident packed quant kernel. @@ -473,6 +508,8 @@ void per_token_group_quant_8bit_packed(const torch::stable::Tensor& input, "]; got [", output_s_packed.stride(0), ", ", output_s_packed.stride(1), "]."); + const torch::stable::accelerator::DeviceGuard device_guard( + input.get_device_index()); cudaStream_t stream = get_current_cuda_stream(); constexpr int THREADS_PER_GROUP = 8; @@ -484,34 +521,66 @@ void per_token_group_quant_8bit_packed(const torch::stable::Tensor& input, " is not a multiple of 4."); const int kx = GetGroupsPerBlockX(padded_groups_per_row); const int ry = 16 / kx; - const int64_t blocks_x = padded_groups_per_row / kx; - const int64_t blocks_y = (tma_aligned_mn + ry - 1) / ry; + const int64_t row_blocks = (tma_aligned_mn + ry - 1) / ry; + const int64_t sf_k_blocks = padded_groups_per_row / kx; const int num_threads = (kx * ry) * THREADS_PER_GROUP; - // CUDA caps grid.x and grid.y at 2^31 - 1; guard against pathological inputs. - STD_TORCH_CHECK(blocks_x <= static_cast(INT32_MAX) && - blocks_y <= static_cast(INT32_MAX), + // CUDA caps grid.x at 2^31 - 1 and grid.y at 2^16 - 1 (65535). + constexpr int64_t kMaxGridDimYZ = 65535; + STD_TORCH_CHECK(row_blocks <= static_cast(INT32_MAX) && + sf_k_blocks <= kMaxGridDimYZ, "per_token_group_quant_8bit_packed grid too large: (", - blocks_x, ", ", blocks_y, ")."); + row_blocks, ", ", sf_k_blocks, ")."); auto dst_type = output_q.scalar_type(); -#define LAUNCH_REG_KERNEL_INST(T, DST_DTYPE, KX, RY) \ - do { \ - dim3 grid(static_cast(blocks_x), \ - static_cast(blocks_y)); \ - dim3 block(num_threads); \ - per_token_group_quant_8bit_packed_register_kernel \ - <<>>( \ - static_cast(input.data_ptr()), output_q.data_ptr(), \ - reinterpret_cast(output_s_packed.data_ptr()), \ - static_cast(padded_groups_per_row), \ - static_cast(groups_per_row), static_cast(mn), \ - static_cast(output_q_mn_extent), \ - static_cast(tma_aligned_mn), num_scale_elems, \ - static_cast(eps), static_cast(min_8bit), \ - static_cast(max_8bit)); \ - } while (0) +// PDL (Programmatic Dependent Launch) is NVIDIA-only; ROCm/HIP has no +// equivalent launch attribute, so fall back to a classic launch there. +#ifndef USE_ROCM + #define LAUNCH_REG_KERNEL_INST(T, DST_DTYPE, KX, RY) \ + do { \ + cudaLaunchConfig_t config = {}; \ + config.gridDim = dim3(static_cast(row_blocks), \ + static_cast(sf_k_blocks)); \ + config.blockDim = dim3(num_threads); \ + config.dynamicSmemBytes = 0; \ + config.stream = stream; \ + cudaLaunchAttribute attrs[1]; \ + attrs[0].id = cudaLaunchAttributeProgrammaticStreamSerialization; \ + attrs[0].val.programmaticStreamSerializationAllowed = 1; \ + config.numAttrs = 1; \ + config.attrs = attrs; \ + cudaLaunchKernelEx( \ + &config, \ + per_token_group_quant_8bit_packed_register_kernel, \ + static_cast(input.data_ptr()), output_q.data_ptr(), \ + reinterpret_cast(output_s_packed.data_ptr()), \ + static_cast(padded_groups_per_row), \ + static_cast(groups_per_row), static_cast(mn), \ + static_cast(output_q_mn_extent), \ + static_cast(tma_aligned_mn), num_scale_elems, \ + static_cast(eps), static_cast(min_8bit), \ + static_cast(max_8bit)); \ + } while (0) +#else + #define LAUNCH_REG_KERNEL_INST(T, DST_DTYPE, KX, RY) \ + do { \ + dim3 grid(static_cast(row_blocks), \ + static_cast(sf_k_blocks)); \ + dim3 block(num_threads); \ + per_token_group_quant_8bit_packed_register_kernel \ + <<>>( \ + static_cast(input.data_ptr()), output_q.data_ptr(), \ + reinterpret_cast(output_s_packed.data_ptr()), \ + static_cast(padded_groups_per_row), \ + static_cast(groups_per_row), static_cast(mn), \ + static_cast(output_q_mn_extent), \ + static_cast(tma_aligned_mn), num_scale_elems, \ + static_cast(eps), static_cast(min_8bit), \ + static_cast(max_8bit)); \ + } while (0) +#endif #define LAUNCH_REG_KERNEL(T, DST_DTYPE) \ do { \ diff --git a/csrc/libtorch_stable/quantization/w8a8/int8/scaled_quant.cu b/csrc/libtorch_stable/quantization/w8a8/int8/scaled_quant.cu index ede7913a3558..e620c1818475 100644 --- a/csrc/libtorch_stable/quantization/w8a8/int8/scaled_quant.cu +++ b/csrc/libtorch_stable/quantization/w8a8/int8/scaled_quant.cu @@ -5,7 +5,7 @@ #include "../../../dispatch_utils.h" #include "../../../torch_utils.h" #include "../../vectorization_utils.cuh" -#include "../../../../cub_helpers.h" +#include "../../../cub_helpers.h" static inline __device__ int8_t float_to_int8_rn(float x) { #ifdef USE_ROCM diff --git a/csrc/libtorch_stable/sampler.cu b/csrc/libtorch_stable/sampler.cu index 68848b84566c..519e213281cd 100644 --- a/csrc/libtorch_stable/sampler.cu +++ b/csrc/libtorch_stable/sampler.cu @@ -665,6 +665,8 @@ void top_k_per_row_decode(const torch::stable::Tensor& logits, int64_t next_n, constexpr int kSortingAlgorithmThreshold = 12288; constexpr int kSplitWorkThreshold = 200 * 1000; constexpr int kNumThreadsPerBlock = 512; + const torch::stable::accelerator::DeviceGuard device_guard( + logits.get_device_index()); const cudaStream_t stream = get_current_cuda_stream(); const auto numColumns = logits.size(1); @@ -727,6 +729,8 @@ void top_k_per_row_prefill(const torch::stable::Tensor& logits, int64_t stride0, int64_t stride1, int64_t topK) { constexpr int kSortingAlgorithmThreshold = 12288; constexpr int kNumThreadsPerBlock = 512; + const torch::stable::accelerator::DeviceGuard device_guard( + logits.get_device_index()); const cudaStream_t stream = get_current_cuda_stream(); int numInsertionBlocks = diff --git a/csrc/libtorch_stable/topk.cu b/csrc/libtorch_stable/topk.cu index 7656ba8cf8f8..07b7521f8635 100644 --- a/csrc/libtorch_stable/topk.cu +++ b/csrc/libtorch_stable/topk.cu @@ -21,6 +21,8 @@ void launch_persistent_topk(const torch::stable::Tensor& logits, int64_t max_seq_len) { namespace P = vllm::persistent; + const torch::stable::accelerator::DeviceGuard device_guard( + logits.get_device_index()); const int64_t num_rows = logits.size(0); const int64_t stride = logits.stride(0); const cudaStream_t stream = get_current_cuda_stream(); @@ -260,6 +262,9 @@ void persistent_topk(const torch::stable::Tensor& logits, k == 512 || k == 1024 || k == 2048, "persistent_topk supports k=512, k=1024, or k=2048, got k=", k); + const torch::stable::accelerator::DeviceGuard device_guard( + logits.get_device_index()); + if (k == 512) { launch_persistent_topk<512>(logits, lengths, output, workspace, max_seq_len); diff --git a/csrc/libtorch_stable/topk_histogram_4096.cuh b/csrc/libtorch_stable/topk_histogram_4096.cuh new file mode 100644 index 000000000000..5f9f823a3399 --- /dev/null +++ b/csrc/libtorch_stable/topk_histogram_4096.cuh @@ -0,0 +1,563 @@ +/* + * Shared 4096-bin single-CTA TopK helpers. + */ + +#ifndef TOPK_HISTOGRAM_4096_CUH_ +#define TOPK_HISTOGRAM_4096_CUH_ + +#include +#include +#include + +namespace vllm { +namespace topk_histogram_4096 { + +constexpr uint32_t kBlockSize = 1024; +constexpr uint32_t RADIX = 256; +constexpr uint32_t kMaxTies = 1024; +static_assert(kMaxTies <= kBlockSize, + "tie_handle requires kMaxTies <= kBlockSize"); +constexpr uint32_t kWarpSize = 32; +constexpr uint32_t kNumWarps = kBlockSize / kWarpSize; + +// Register path +constexpr uint32_t kHist4096VecsPerThread = 4; +constexpr uint32_t kHist4096MaxLen = + kHist4096VecsPerThread * 4 * kBlockSize; // 16384 + +struct alignas(16) MatchBin { + uint32_t bin, above_count, equal_count; +}; +struct alignas(8) Tie { + uint32_t idx; + float score; +}; + +__device__ __forceinline__ void load_float4_predicated(const float* ptr, + int base, int seq_len, + float& v0, float& v1, + float& v2, float& v3) { + uint32_t r0, r1, r2, r3; + const int p0 = (base < seq_len); + const int p1 = (base + 1 < seq_len); + const int p2 = (base + 2 < seq_len); + const int p3 = (base + 3 < seq_len); + asm volatile( + "{\n" + " .reg .pred pr0, pr1, pr2, pr3;\n" + " setp.ne.u32 pr0, %4, 0;\n" + " setp.ne.u32 pr1, %5, 0;\n" + " setp.ne.u32 pr2, %6, 0;\n" + " setp.ne.u32 pr3, %7, 0;\n" + " mov.u32 %0, 0xFF800000;\n" + " mov.u32 %1, 0xFF800000;\n" + " mov.u32 %2, 0xFF800000;\n" + " mov.u32 %3, 0xFF800000;\n" + " @pr0 ld.global.cg.u32 %0, [%8];\n" + " @pr1 ld.global.cg.u32 %1, [%8+4];\n" + " @pr2 ld.global.cg.u32 %2, [%8+8];\n" + " @pr3 ld.global.cg.u32 %3, [%8+12];\n" + "}\n" + : "=r"(r0), "=r"(r1), "=r"(r2), "=r"(r3) + : "r"(p0), "r"(p1), "r"(p2), "r"(p3), "l"(ptr)); + v0 = __uint_as_float(r0); + v1 = __uint_as_float(r1); + v2 = __uint_as_float(r2); + v3 = __uint_as_float(r3); +} + +// converts the float32 score to a 32-bit ordered unsigned integer — the full +// precision key for radix sorting +__device__ __forceinline__ auto convert_to_uint32_v2(float x) -> uint32_t { + uint32_t bits = __float_as_uint(x); + return (bits & 0x80000000u) ? ~bits : (bits | 0x80000000u); +} + +// Converts each score to a 12-bit bin (FP16 sign-magnitude -> top 12 bits -> +// bin 0-4095) +template +__device__ __forceinline__ uint32_t extract_coarse_bin_N(float x) { + __half h = __float2half_rn(x); + uint16_t bits = __half_as_ushort(h); + uint16_t key = (bits & 0x8000) ? static_cast(~bits) + : static_cast(bits | 0x8000); + return key >> (16 - kBits); +} + +// running sum within each warp — thread 0 gets its own value, thread 1 gets +// thread 0 + thread 1, thread 2 gets threads 0+1+2, etc. +__device__ __forceinline__ uint32_t warp_inclusive_sum(uint32_t lane, + uint32_t v) { +#pragma unroll + for (uint32_t o = 1; o < 32; o *= 2) { + uint32_t n = __shfl_up_sync(0xFFFFFFFF, v, o); + if (lane >= o) v += n; + } + return v; +} + +// Returns the sum of a value across all 32 threads in the warp, and every +// thread gets the same result. SM80+ uses redux.sync.add.u32, a single PTX +// instruction for hardware warp-wide reduction. Older targets use the +// __shfl_xor_sync butterfly tree, like warp::reduce_sum() (5 shuffles for 32 +// lanes). +__device__ __forceinline__ uint32_t warp_reduce_sum_full(uint32_t v) { +#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 800) + uint32_t r; + asm("redux.sync.add.u32 %0, %1, 0xFFFFFFFF;" : "=r"(r) : "r"(v)); + return r; +#else + #pragma unroll + for (uint32_t mask = kWarpSize >> 1; mask > 0; mask >>= 1) { + v += __shfl_xor_sync(0xFFFFFFFF, v, mask); + } + return v; +#endif +} + +// ============================================================================ +// Tie refinement (single CTA): 4-round radix-256 topK on the full FP32 ordered +// key Each round narrows by 8 bits until ties are fully resolved +// ============================================================================ + +template +__device__ void tie_handle(const Tie* ties, uint32_t num_ties, + uint32_t num_above, int32_t* output, void* _smem) { + struct TS { + alignas(128) uint32_t counter; + alignas(128) MatchBin match; + uint32_t histogram[RADIX]; + uint32_t warp_sum[kNumWarps]; + }; + auto* s = static_cast(_smem); + const auto tx = threadIdx.x; + const auto li = tx % kWarpSize, wi = tx / kWarpSize; + + // Each thread loads one tie element. + const bool has = tx < num_ties; + const auto tie = has ? ties[tx] : Tie{0, 0.0f}; + const uint32_t key = convert_to_uint32_v2(tie.score); + + bool active = has; // tracks whether this thread's tie is still a candidate. + uint32_t remain = + TopK - num_above; // decreases each round as ties are resolved. + uint32_t wpos = TopK; // wpos will hold the final output position. + s->counter = 0; + __syncthreads(); + + // The 4-round radix loop - each round narrows by 8 bits until ties are fully + // resolved +#pragma unroll + for (int r = 0; r < 4; r++) { + uint32_t sh = 24 - r * 8; // round 0: bits 31-24, round 1: 23-16, etc. + uint32_t bin = (key >> sh) & 0xFF; // this tie's 8-bit bin for this round + + // Step 1: Build 256-bin histogram. + if (tx < RADIX) s->histogram[tx] = 0; + __syncthreads(); + if (active) atomicAdd(&s->histogram[bin], 1); + __syncthreads(); + + // Step 2: Prefix scan to find threshold + uint32_t hv = 0, wi2 = 0; + if (tx < RADIX) { + hv = s->histogram[tx]; + wi2 = warp_inclusive_sum(li, hv); + if (li == kWarpSize - 1) s->warp_sum[wi] = wi2; + } + __syncthreads(); + + if (tx < RADIX) { + auto tmp = (li < RADIX / kWarpSize) ? s->warp_sum[li] : 0; + auto tot = warp_reduce_sum_full(tmp); + auto inter = warp_reduce_sum_full(li < wi ? tmp : 0); + auto above = tot - (inter + wi2); + if (above < remain && above + hv >= remain) { + s->match = {tx, above, remain - above}; + } + } + __syncthreads(); + + // Step 3: Scatter + auto [thr, na, _] = s->match; // threshold bin, num above, unused + if (active) { + if (bin > thr) { + wpos = num_above + + atomicAdd(&s->counter, 1); // above -> place in output directly + active = false; + } else if (bin < thr) + active = false; // below -> discard + else if (r == 3) + wpos = TopK - atomicAdd(&s->match.equal_count, + -1u); // last round: place remaining + } + remain -= na; + if (!remain) break; // all ties resolved early + } + // Final write + if (wpos < TopK) output[wpos] = tie.idx; +} + +// Extended tie_handle for TopK > kBlockSize (e.g. TopK=2048). +// tie_handle assumes 1 tie per thread (max 1024). +// This version handles 2 ties per thread via kPerThread=2 +template +__device__ void tie_handle_large(const Tie* ties, uint32_t num_ties, + uint32_t num_above, int32_t* output, + void* _smem) { + static_assert(TopK > kBlockSize); + struct TS { + alignas(128) uint32_t counter; + alignas(128) MatchBin match; + uint32_t histogram[RADIX]; + uint32_t warp_sum[kNumWarps]; + }; + auto* s = static_cast(_smem); + const auto tx = threadIdx.x; + const auto li = tx % kWarpSize; + const auto wi = tx / kWarpSize; + + constexpr uint32_t kPerThread = (TopK + kBlockSize - 1) / kBlockSize; + Tie my_ties[kPerThread]; + uint32_t keys[kPerThread]; + bool active[kPerThread]; + + for (uint32_t e = 0; e < kPerThread; e++) { + uint32_t idx = e * kBlockSize + tx; + if (idx < num_ties) { + my_ties[e] = ties[idx]; + keys[e] = convert_to_uint32_v2(ties[idx].score); + active[e] = true; + } else { + my_ties[e] = {0, 0.0f}; + keys[e] = 0; + active[e] = false; + } + } + + uint32_t remain = TopK - num_above; + s->counter = 0; + __syncthreads(); + + for (int r = 0; r < 4; r++) { + uint32_t sh = 24 - r * 8; + if (tx < RADIX) { + s->histogram[tx] = 0; + } + __syncthreads(); + + for (uint32_t e = 0; e < kPerThread; e++) { + if (active[e]) { + atomicAdd(&s->histogram[(keys[e] >> sh) & 0xFF], 1); + } + } + __syncthreads(); + + uint32_t hv = 0; + if (tx < RADIX) { + hv = s->histogram[tx]; + auto wi2 = warp_inclusive_sum(li, hv); + if (li == kWarpSize - 1) { + s->warp_sum[wi] = wi2; + } + } + __syncthreads(); + if (tx < RADIX) { + auto tmp2 = (li < RADIX / kWarpSize) ? s->warp_sum[li] : 0; + auto total = warp_reduce_sum_full(tmp2); + auto inter = warp_reduce_sum_full(li < wi ? tmp2 : 0); + auto wi2 = warp_inclusive_sum(li, hv); + auto above = total - (inter + wi2); + if (above < remain && above + hv >= remain) { + s->match = { + .bin = tx, .above_count = above, .equal_count = remain - above}; + } + } + __syncthreads(); + + auto thr = s->match.bin; + auto na = s->match.above_count; + + for (uint32_t e = 0; e < kPerThread; e++) { + if (!active[e]) { + continue; + } + uint32_t bin = (keys[e] >> sh) & 0xFF; + if (bin > thr) { + uint32_t wpos = num_above + atomicAdd(&s->counter, 1); + if (wpos < TopK) { + output[wpos] = my_ties[e].idx; + } + active[e] = false; + } else if (bin < thr) { + active[e] = false; + } else if (r == 3) { + uint32_t wpos = TopK - atomicAdd(&s->match.equal_count, -1u); + if (wpos < TopK) { + output[wpos] = my_ties[e].idx; + } + } + } + + num_above += na; + remain -= na; + __syncthreads(); + s->counter = 0; + __syncthreads(); + } +} + +// ============================================================================ +// Register-based single-CTA fast path for seq_len <= 16384 +// 4 float4 per thread × 1024 threads = 16384 elements max +// Uses 4096-bin (12-bit) histogram for better precision +// ============================================================================ + +template +struct Histogram4096Smem { + static constexpr uint32_t HIST_BINS = 1 << HIST_BITS; + static constexpr uint32_t TIE_CAPACITY = TopK > kMaxTies ? TopK : kMaxTies; + alignas(128) uint32_t counter_gt; + alignas(128) uint32_t counter_eq; + MatchBin match; + uint32_t warp_sum[kNumWarps]; + union { + uint32_t histogram[HIST_BINS]; + Tie tie_buffer[TIE_CAPACITY]; + }; +}; + +template +__device__ void histogram_4096_topk(const float* __restrict__ scores, + int32_t* __restrict__ output, + uint32_t length, void* _smem) { + constexpr uint32_t HIST_BINS = 1 << HIST_BITS; + constexpr uint32_t ITEMS_PER_THREAD = HIST_BINS / kBlockSize; + static_assert(HIST_BINS >= kBlockSize, + "HIST_BITS must give >= kBlockSize bins"); + + using Smem = Histogram4096Smem; + auto* smem = static_cast(_smem); + const auto tx = threadIdx.x; + const auto lane_id = tx % kWarpSize; + const auto warp_id = tx / kWarpSize; + + // Phase 1: Load all data into RF + build histogram + float4 + vecs[VECS_PER_THREAD]; // 4 vectors x 4 floats = 16 elements per thread + if constexpr (ITEMS_PER_THREAD >= 4) { + // Zero the histogram (SMEM writes) + for (uint32_t i = 0; i < ITEMS_PER_THREAD / 4; i++) + reinterpret_cast( + smem->histogram)[tx * (ITEMS_PER_THREAD / 4) + i] = + make_uint4(0, 0, 0, 0); + } else { + if (tx < HIST_BINS) smem->histogram[tx] = 0; + } + if (tx == 0) { + smem->counter_gt = 0; + smem->counter_eq = 0; + } + if constexpr (UsePredicatedLoads) { + const bool row_aligned = (reinterpret_cast(scores) & 0xFu) == 0; +#pragma unroll + for (uint32_t v = 0; v < VECS_PER_THREAD; v++) { + const uint32_t base = (tx + v * kBlockSize) * 4; + if (base < length) { + if (row_aligned && base + 3 < length) { + vecs[v] = *reinterpret_cast(scores + base); + } else { + load_float4_predicated(scores + base, static_cast(base), + static_cast(length), vecs[v].x, vecs[v].y, + vecs[v].z, vecs[v].w); + } + } + } + } else { +#pragma unroll + for (uint32_t v = 0; v < VECS_PER_THREAD; v++) { + const uint32_t base = (tx + v * kBlockSize) * 4; + if (base < length) { + vecs[v] = *reinterpret_cast(scores + base); + } + } + } + __syncthreads(); + + // Build histogram from RF via atomic adds into the shared histogram + bool done = false; +#pragma unroll + for (uint32_t v = 0; v < VECS_PER_THREAD && !done; v++) { + const float* elems = reinterpret_cast(&vecs[v]); +#pragma unroll + for (uint32_t e = 0; e < 4 && !done; e++) { + const uint32_t idx = (tx + v * kBlockSize) * 4 + e; + if (idx >= length) { + done = true; + } else { + atomicAdd(&smem->histogram[extract_coarse_bin_N(elems[e])], + 1); + } + } + } + __syncthreads(); + + // Phase 2: Prefix scan to find threshold bin + // Multi-element scan (4096 bins: 4 per thread) + uint32_t orig[ITEMS_PER_THREAD]; + uint32_t local_sum = 0; + + // Step 1: Each thread sums its 4 bins +#pragma unroll + for (uint32_t i = 0; i < ITEMS_PER_THREAD; i++) { + orig[i] = smem->histogram[tx * ITEMS_PER_THREAD + i]; + local_sum += orig[i]; + } + + // Step 2: Warp-level inclusive prefix sum on local_sum + const auto warp_inc = warp_inclusive_sum(lane_id, local_sum); + if (lane_id == kWarpSize - 1) smem->warp_sum[warp_id] = warp_inc; + __syncthreads(); + + // Step 3: Inter-warp prefix across warp sums. + const auto tmp = smem->warp_sum[lane_id]; + uint32_t prefix = warp_reduce_sum_full( + lane_id < warp_id ? tmp : 0); // sum of all prior warps + prefix += + warp_inc - local_sum; // exclusive prefix within this thread's position + + // Step 4: Find threshold - scan 4 bins, accumulate prefix +#pragma unroll + for (uint32_t i = 0; i < ITEMS_PER_THREAD; i++) { + prefix += orig[i]; + const auto above = length - prefix; // elements in bins ABOVE this one + if (above < TopK && above + orig[i] >= TopK) { + smem->match = {.bin = tx * ITEMS_PER_THREAD + i, + .above_count = above, + .equal_count = orig[i]}; + } + } + + __syncthreads(); + + // Phase 3: Scatter from registers + const auto [thr_bin, num_above, num_equal] = smem->match; + const bool need_tie = (num_equal + num_above > TopK); + + done = false; +#pragma unroll + for (uint32_t v = 0; v < VECS_PER_THREAD && !done; v++) { + const float* elems = reinterpret_cast(&vecs[v]); +#pragma unroll + for (uint32_t e = 0; e < 4 && !done; e++) { + const uint32_t idx = (tx + v * kBlockSize) * 4 + e; + if (idx >= length) { + done = true; + } else { + const uint32_t bin = extract_coarse_bin_N(elems[e]); + if (bin > thr_bin) { + output[atomicAdd(&smem->counter_gt, 1)] = + idx; // above -> output directly + } else if (bin == thr_bin) { + const auto pos = atomicAdd(&smem->counter_eq, 1); + if (!need_tie) { + if (pos + num_above < TopK) { + output[pos + num_above] = idx; // all fit + } + } else { + if (pos < TopK) { + smem->tie_buffer[pos] = {idx, elems[e]}; // store for refirement + } + } + } + // else: bin < thr_bin - discard (not in top-k) + } + } + } + + // Phase 4: Tie-breaking + if (!need_tie) return; + __syncthreads(); + + // Fast warp-ballot tie-breaking for small tie counts + const uint32_t num_ties = min(num_equal, static_cast(TopK)); + const uint32_t topk_remain = + TopK - num_above; // pick exactly remaining elements to fill topK + + auto is_greater = [](const Tie& a, const Tie& b) { + return (a.score > b.score) || (a.score == b.score && a.idx < b.idx); + }; + + if (num_ties <= kWarpSize) { + // <=32 ties - Use warp ballot + // All-to-all comparison in one __ballot_sync. 32 ties x 32 warps = 1024 + // comparisons in one instruction per warp. O(1) work. + const auto lane_id = tx % kWarpSize; + const auto warp_id = tx / kWarpSize; + if (lane_id >= num_ties || warp_id >= num_ties) return; + const uint32_t mask = (1ull << num_ties) - 1u; + const auto tie = smem->tie_buffer[lane_id]; // each lane holds one tie + const auto target = + smem->tie_buffer[warp_id]; // each warp evaluates one candidate + const bool pred = + is_greater(tie, target); // compare all ties against target + const auto rank = static_cast( + __popc(__ballot_sync(mask, pred))); // count how many are greater + if (lane_id == 0 && rank < topk_remain) { + output[num_above + rank] = target.idx; // place at correct position + } + } else if (num_ties <= + kWarpSize * + 2) { // TODO (roberto): try to refactor this with <=32 case + // Same idea but each thread handles 2 tie elements + const auto lane_id = tx % kWarpSize; + const auto warp_id = tx / kWarpSize; + const auto lane1 = lane_id + kWarpSize; + const auto warp1 = warp_id + kWarpSize; + const auto invalid = Tie{0xFFFFFFFF, -__FLT_MAX__}; + const auto tie0 = smem->tie_buffer[lane_id]; + const auto tie1 = lane1 < num_ties ? smem->tie_buffer[lane1] : invalid; + if (warp_id < num_ties) { + const auto target = smem->tie_buffer[warp_id]; + const auto r0 = + __popc(__ballot_sync(0xFFFFFFFF, is_greater(tie0, target))); + const auto r1 = + __popc(__ballot_sync(0xFFFFFFFF, is_greater(tie1, target))); + if (lane_id == 0 && r0 + r1 < topk_remain) + output[num_above + r0 + r1] = target.idx; + } + if (warp1 < num_ties) { + const auto target = smem->tie_buffer[warp1]; + const auto r0 = + __popc(__ballot_sync(0xFFFFFFFF, is_greater(tie0, target))); + const auto r1 = + __popc(__ballot_sync(0xFFFFFFFF, is_greater(tie1, target))); + if (lane_id == 0 && r0 + r1 < topk_remain) + output[num_above + r0 + r1] = target.idx; + } + } else { + // Large tie count: fall back to 4-round radix-256 sort + if constexpr (TopK <= kBlockSize) { + tie_handle(smem->tie_buffer, num_ties, num_above, output, smem); + } else { + tie_handle_large(smem->tie_buffer, num_ties, num_above, output, + smem); + } + } +} + +template +__device__ __noinline__ void histogram_4096_topk_predicated( + const float* __restrict__ scores, int32_t* __restrict__ output, + uint32_t length, void* _smem) { + histogram_4096_topk(scores, output, + length, _smem); +} + +} // namespace topk_histogram_4096 +} // namespace vllm + +#endif // TOPK_HISTOGRAM_4096_CUH_ diff --git a/csrc/libtorch_stable/torch_bindings.cpp b/csrc/libtorch_stable/torch_bindings.cpp index e9a62a8666cc..66a93a5ba13b 100644 --- a/csrc/libtorch_stable/torch_bindings.cpp +++ b/csrc/libtorch_stable/torch_bindings.cpp @@ -1,4 +1,5 @@ #include "ops.h" +#include "cuda_utils.h" #include "core/registration.h" #include @@ -26,9 +27,87 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { "per_token_group_quant_int8(Tensor input, Tensor! output_q, Tensor! " "output_s, int group_size, float eps, float int8_min, float int8_max) -> " "()"); + ops.def("permute_cols(Tensor A, Tensor perm) -> Tensor"); + + ops.def("get_cuda_view_from_cpu_tensor(Tensor cpu_tensor) -> Tensor"); #ifndef USE_ROCM - ops.def("permute_cols(Tensor A, Tensor perm) -> Tensor"); + + // Note about marlin kernel 'workspace' arguments: + // Technically these should be mutable since they are modified by the kernel. + // But since they are set back to zero once the kernel is finished we can + // hand wave and say that they have no net effect. + // + // The reason to mark 'workspace' as immutable is so that they don't interfere + // with using ScalarType arguments in the ops. If they are marked as mutable, + // pytorch throws an assert in + // 'torch._higher_order_ops._register_effectful_op' that prevents these + // kernels from being torch.compile'd. + // See the following document for more info on custom types and ops that use + // custom types: + // https://docs.google.com/document/d/18fBMPuOJ0fY5ZQ6YyrHUppw9FA332CpNtgB6SOIgyuA + + // Machete (Dense) Optimized Mixed Precision GEMM for Hopper. + ops.def( + "machete_supported_schedules(" + " ScalarType a_type," + " int b_type," + " ScalarType? maybe_group_scales_type," + " ScalarType? maybe_group_zeros_type," + " ScalarType? maybe_channel_scales_type," + " ScalarType? maybe_token_scales_type," + " ScalarType? maybe_out_type" + ") -> str[]"); + ops.def( + "machete_mm(" + " Tensor A," + " Tensor B," + " int b_type," + " ScalarType? out_type," + " Tensor? group_scales," + " Tensor? group_zeros," + " int? group_size," + " Tensor? channel_scales," + " Tensor? token_scales," + " str? schedule" + ") -> Tensor"); + ops.def( + "machete_prepack_B(" + " Tensor B," + " ScalarType a_type," + " int b_type," + " ScalarType? group_scales_type" + ") -> Tensor"); + // conditionally compiled so impl registration is in source file + + // Marlin GEMM + ops.def( + "marlin_gemm(Tensor a, Tensor? c_or_none, Tensor b_q_weight, " + "Tensor? b_bias_or_none,Tensor b_scales, " + "Tensor? a_scales, Tensor? global_scale, Tensor? b_zeros_or_none, " + "Tensor? " + "g_idx_or_none, Tensor? perm_or_none, Tensor workspace, int b_type_id, " + "SymInt size_m, SymInt size_n, SymInt size_k, bool is_k_full, " + "bool use_atomic_add, bool use_fp32_reduce, bool is_zp_float) -> Tensor"); + // conditionally compiled so impl registrations are in source file + + // gptq_marlin repack from GPTQ. + ops.def( + "gptq_marlin_repack(Tensor b_q_weight, Tensor perm, " + "SymInt size_k, SymInt size_n, int num_bits, bool is_a_8bit) -> Tensor"); + // conditionally compiled so impl registrations are in source file + + // awq_marlin repack from AWQ. + ops.def( + "awq_marlin_repack(Tensor b_q_weight, SymInt size_k, " + "SymInt size_n, int num_bits, bool is_a_8bit) -> Tensor"); + // conditionally compiled so impl registrations are in source file + + // preprocess W-int4A-fp8 weight for marlin kernel + ops.def( + "marlin_int4_fp8_preprocess(Tensor qweight, " + "Tensor? qzeros_or_none, bool inplace) -> Tensor"); + // conditionally compiled so impl registrations are in source file #endif #ifndef USE_ROCM @@ -287,12 +366,13 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { // Apply Root Mean Square (RMS) Normalization to the input tensor. ops.def( - "rms_norm(Tensor! result, Tensor input, Tensor weight, float epsilon) -> " + "rms_norm(Tensor! result, Tensor input, Tensor? weight, float epsilon) " + "-> " "()"); // In-place fused Add and RMS Normalization. ops.def( - "fused_add_rms_norm(Tensor! input, Tensor! residual, Tensor weight, " + "fused_add_rms_norm(Tensor! input, Tensor! residual, Tensor? weight, " "float epsilon) -> ()"); // Layernorm-quant @@ -321,6 +401,16 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { "Tensor? scale_ub, Tensor!? residual, int group_size, " "bool is_scale_transposed) -> ()"); + // Fused SiLU+Mul + per-block quantization + ops.def( + "silu_and_mul_per_block_quant(" + "Tensor! out, " + "Tensor input, " + "Tensor! scales, " + "int group_size, " + "Tensor? scale_ub=None, " + "bool is_scale_transposed=False) -> ()"); + // Rotary embedding // Apply GPT-NeoX or GPT-J style rotary embedding to query and key. ops.def( @@ -343,11 +433,21 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { "Tensor slot_mapping, Tensor position_ids, Tensor cos_sin_cache, " "int q_head_padded, float eps, int cache_block_size) -> Tensor"); -#ifndef USE_ROCM + // FlashInfer V4 full-cache variants: write Q in place (bf16) or to a separate + // FP8 tensor, and KV into a contiguous 512-wide token-strided cache. + ops.def( + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(" + "Tensor! q, Tensor kv, Tensor! k_cache, Tensor slot_mapping, " + "Tensor position_ids, Tensor cos_sin_cache, float eps, " + "int cache_block_size) -> ()"); ops.def( - "minimax_allreduce_rms(" - "Tensor input, Tensor norm_weight, Tensor workspace, " - "int rank, int nranks, float eps) -> Tensor"); + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert(" + "Tensor q, Tensor kv, Tensor! q_fp8, Tensor! k_cache, " + "Tensor slot_mapping, Tensor position_ids, Tensor cos_sin_cache, " + "Tensor fp8_scale, Tensor q_fp8_scale_inv, float eps, " + "int cache_block_size) -> ()"); + +#ifndef USE_ROCM ops.def( "minimax_allreduce_rms_qk(" "Tensor qkv, Tensor norm_weight_q, Tensor norm_weight_k, " @@ -355,6 +455,19 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { "float eps) -> (Tensor, Tensor)"); #endif + // Horizontally-fused MiniMax-M3 QK-norm + partial NeoX RoPE + KV-insert. + ops.def( + "fused_minimax_m3_qknorm_rope_kv_insert(" + "Tensor! qkv, Tensor q_norm_weight, Tensor k_norm_weight, " + "Tensor cos_sin_cache, Tensor positions, int num_heads, " + "int num_kv_heads, int rotary_dim, float eps, " + "Tensor? index_q_norm_weight, Tensor? index_k_norm_weight, " + "int num_index_heads, " + "Tensor? slot_mapping, Tensor? index_slot_mapping, " + "Tensor!? kv_cache, Tensor!? index_cache, " + "int block_size, Tensor!? q_out, Tensor!? index_q_out, " + "str kv_cache_dtype) -> ()"); + // Apply repetition penalties to logits in-place. ops.def( "apply_repetition_penalties_(Tensor! logits, Tensor prompt_mask, " @@ -375,16 +488,33 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { "persistent_topk(Tensor logits, Tensor lengths, Tensor! output, " "Tensor workspace, int k, int max_seq_len) -> ()"); +#ifdef VLLM_ENABLE_COOPERATIVE_TOPK + ops.def( + "cooperative_topk(Tensor logits, Tensor lengths, Tensor! output, " + "Tensor workspace, int k, int max_seq_len) -> ()"); +#endif + // Activation ops + ops.def( + "persistent_masked_m_silu_mul_quant(Tensor input, Tensor counts, Tensor! " + "y_q, Tensor! y_s, bool use_ue8m0) -> ()"); + ops.def("weak_ref_tensor(Tensor input) -> Tensor"); + // Activation function used in SwiGLU. ops.def("silu_and_mul(Tensor! result, Tensor input) -> ()"); ops.def("mul_and_silu(Tensor! out, Tensor input) -> ()"); // SwiGLU activation with input clamping. + // alpha scales the sigmoid (gate * sigmoid(alpha * gate)); beta is added to + // the up half (up + beta). Defaults alpha=1.0, beta=0.0 give silu(gate)*up. ops.def( - "silu_and_mul_with_clamp(Tensor! result, Tensor input, float limit) " - "-> ()"); + "silu_and_mul_with_clamp(Tensor! result, Tensor input, float limit, " + "float alpha=1.0, float beta=0.0) -> ()"); + + // SwiGLU activation with FP8 quantization. + ops.def( + "silu_and_mul_quant(Tensor! result, Tensor input, Tensor scale) -> ()"); // Activation function used in GeGLU with `none` approximation. ops.def("gelu_and_mul(Tensor! out, Tensor input) -> ()"); @@ -451,34 +581,6 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { // Post processing for GPTQ. ops.def("gptq_shuffle(Tensor! q_weight, Tensor q_perm, int bit) -> ()"); - // Dequantization for GGML. - ops.def( - "ggml_dequantize(Tensor W, int type, SymInt m, SymInt n, ScalarType? " - "dtype) -> Tensor"); - - // mmvq kernel for GGML. - ops.def( - "ggml_mul_mat_vec_a8(Tensor W, Tensor X, int type, SymInt row) " - "-> Tensor"); - - // mmq kernel for GGML. - ops.def( - "ggml_mul_mat_a8(Tensor W, Tensor X, int type, SymInt row) -> Tensor"); - - // moe kernel for GGML. - ops.def( - "ggml_moe_a8(Tensor X, Tensor W, " - "Tensor sorted_token_ids, Tensor expert_ids, Tensor " - "num_tokens_post_padded, " - "int type, SymInt row, SymInt top_k, SymInt tokens) -> Tensor"); - - ops.def( - "ggml_moe_a8_vec(Tensor X, Tensor W, " - "Tensor topk_ids, int top_k, " - "int type, SymInt row, SymInt tokens) -> Tensor"); - - ops.def("ggml_moe_get_block_size(int type) -> int"); - // Mamba selective scan kernel ops.def( "selective_scan_fwd(Tensor! u, Tensor! delta," @@ -496,33 +598,6 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) { "Tensor? initial_state_idx," "Tensor? cu_chunk_seqlen," "Tensor? last_chunk_indices) -> ()"); - - // Attention ops - // Compute the attention between an input query and the cached - // keys/values using PagedAttention. - ops.def( - "paged_attention_v1(" - " Tensor! out, Tensor query, Tensor key_cache," - " Tensor value_cache, int num_kv_heads, float scale," - " Tensor block_tables, Tensor seq_lens, int block_size," - " int max_seq_len, Tensor? alibi_slopes," - " str kv_cache_dtype, Tensor k_scale, Tensor v_scale," - " int tp_rank, int blocksparse_local_blocks," - " int blocksparse_vert_stride, int blocksparse_block_size," - " int blocksparse_head_sliding_step) -> ()"); - - // PagedAttention V2. - ops.def( - "paged_attention_v2(" - " Tensor! out, Tensor! exp_sums, Tensor! max_logits," - " Tensor! tmp_out, Tensor query, Tensor key_cache," - " Tensor value_cache, int num_kv_heads, float scale," - " Tensor block_tables, Tensor seq_lens, int block_size," - " int max_seq_len, Tensor? alibi_slopes," - " str kv_cache_dtype, Tensor k_scale, Tensor v_scale," - " int tp_rank, int blocksparse_local_blocks," - " int blocksparse_vert_stride, int blocksparse_block_size," - " int blocksparse_head_sliding_step) -> ()"); } STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) { @@ -533,9 +608,7 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) { ops.impl("per_token_group_quant_int8", TORCH_BOX(&per_token_group_quant_int8)); -#ifndef USE_ROCM ops.impl("permute_cols", TORCH_BOX(&permute_cols)); -#endif #ifndef USE_ROCM // CUTLASS scaled_mm ops @@ -585,16 +658,25 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) { ops.impl("rms_norm_dynamic_per_token_quant", TORCH_BOX(&rms_norm_dynamic_per_token_quant)); ops.impl("rms_norm_per_block_quant", TORCH_BOX(&rms_norm_per_block_quant)); + ops.impl("silu_and_mul_per_block_quant", + TORCH_BOX(&silu_and_mul_per_block_quant)); // Positional encoding kernels (shared CUDA/ROCm) ops.impl("rotary_embedding", TORCH_BOX(&rotary_embedding)); ops.impl("fused_qk_norm_rope", TORCH_BOX(&fused_qk_norm_rope)); ops.impl("fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert", TORCH_BOX(&fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert)); + ops.impl( + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert", + TORCH_BOX(&fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert)); + ops.impl( + "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert", + TORCH_BOX(&fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert)); #ifndef USE_ROCM - ops.impl("minimax_allreduce_rms", TORCH_BOX(&minimax_allreduce_rms)); ops.impl("minimax_allreduce_rms_qk", TORCH_BOX(&minimax_allreduce_rms_qk)); #endif + ops.impl("fused_minimax_m3_qknorm_rope_kv_insert", + TORCH_BOX(&fused_minimax_m3_qknorm_rope_kv_insert)); // Sampler kernels (shared CUDA/ROCm) ops.impl("apply_repetition_penalties_", @@ -602,8 +684,15 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) { ops.impl("top_k_per_row_prefill", TORCH_BOX(&top_k_per_row_prefill)); ops.impl("top_k_per_row_decode", TORCH_BOX(&top_k_per_row_decode)); ops.impl("persistent_topk", TORCH_BOX(&persistent_topk)); +#ifdef VLLM_ENABLE_COOPERATIVE_TOPK + ops.impl("cooperative_topk", TORCH_BOX(&cooperative_topk)); +#endif // Activation kernels (shared CUDA/ROCm) + ops.impl("persistent_masked_m_silu_mul_quant", + TORCH_BOX(&persistent_masked_m_silu_mul_quant)); + ops.impl("weak_ref_tensor", TORCH_BOX(&weak_ref_tensor)); + ops.impl("silu_and_mul_quant", TORCH_BOX(&silu_and_mul_quant)); ops.impl("silu_and_mul", TORCH_BOX(&silu_and_mul)); ops.impl("mul_and_silu", TORCH_BOX(&mul_and_silu)); ops.impl("gelu_and_mul", TORCH_BOX(&gelu_and_mul)); @@ -629,16 +718,26 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) { ops.impl("gptq_gemm", TORCH_BOX(&gptq_gemm)); ops.impl("gptq_shuffle", TORCH_BOX(&gptq_shuffle)); - // GGML kernels - ops.impl("ggml_dequantize", TORCH_BOX(&ggml_dequantize)); - ops.impl("ggml_mul_mat_vec_a8", TORCH_BOX(&ggml_mul_mat_vec_a8)); - ops.impl("ggml_mul_mat_a8", TORCH_BOX(&ggml_mul_mat_a8)); - ops.impl("ggml_moe_a8", TORCH_BOX(&ggml_moe_a8)); - ops.impl("ggml_moe_a8_vec", TORCH_BOX(&ggml_moe_a8_vec)); + // Mamba kernels ops.impl("selective_scan_fwd", TORCH_BOX(&selective_scan_fwd)); +} + +STABLE_TORCH_LIBRARY_IMPL(_C, CPU, ops) { + ops.impl("get_cuda_view_from_cpu_tensor", + TORCH_BOX(&get_cuda_view_from_cpu_tensor)); +} + +STABLE_TORCH_LIBRARY_FRAGMENT(_C_cuda_utils, cuda_utils) { + cuda_utils.def("get_device_attribute(int attribute, int device_id) -> int"); + cuda_utils.def( + "get_max_shared_memory_per_block_device_attribute(int device_id) -> int"); +} - ops.impl("paged_attention_v1", TORCH_BOX(&paged_attention_v1)); - ops.impl("paged_attention_v2", TORCH_BOX(&paged_attention_v2)); +STABLE_TORCH_LIBRARY_IMPL(_C_cuda_utils, CompositeExplicitAutograd, + cuda_utils) { + cuda_utils.impl("get_device_attribute", TORCH_BOX(&get_device_attribute)); + cuda_utils.impl("get_max_shared_memory_per_block_device_attribute", + TORCH_BOX(&get_max_shared_memory_per_block_device_attribute)); } // These capability-check functions take only primitive args (no tensors), so @@ -656,9 +755,6 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CompositeExplicitAutograd, ops) { ops.impl("cutlass_scaled_mm_supports_fp4", TORCH_BOX(&cutlass_scaled_mm_supports_fp4)); #endif - - // GGML block size lookup (no tensor args) - ops.impl("ggml_moe_get_block_size", TORCH_BOX(&ggml_moe_get_block_size)); } // Cache ops diff --git a/csrc/type_convert.cuh b/csrc/libtorch_stable/type_convert.cuh similarity index 100% rename from csrc/type_convert.cuh rename to csrc/libtorch_stable/type_convert.cuh diff --git a/csrc/moe/dsv3_router_gemm_entry.cu b/csrc/moe/dsv3_router_gemm_entry.cu deleted file mode 100644 index 38fb681c2236..000000000000 --- a/csrc/moe/dsv3_router_gemm_entry.cu +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Adapted from SGLang's sgl-kernel implementation, which was adapted from - * https://github.com/NVIDIA/TensorRT-LLM/blob/main/cpp/tensorrt_llm/kernels/dsv3MinLatencyKernels/dsv3RouterGemm.cu - * https://github.com/NVIDIA/TensorRT-LLM/blob/main/cpp/tensorrt_llm/thop/dsv3RouterGemmOp.cpp - * - * Copyright (c) 2019-2023, NVIDIA CORPORATION. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include -#include -#include - -#include -#include - -#include "core/registration.h" -#include "dsv3_router_gemm_utils.h" - -static constexpr int DEFAULT_NUM_EXPERTS = 256; -static constexpr int KIMI_K2_NUM_EXPERTS = 384; -static constexpr int DEFAULT_HIDDEN_DIM = 7168; - -template -void invokeRouterGemmFloatOutput(float* output, T const* mat_a, T const* mat_b, - cudaStream_t stream); - -template -void invokeRouterGemmBf16Output(__nv_bfloat16* output, T const* mat_a, - T const* mat_b, cudaStream_t stream); - -template -struct LoopUnroller { - static void unroll_float_output(int num_tokens, float* output, - __nv_bfloat16 const* input, - __nv_bfloat16 const* weights, - cudaStream_t stream) { - if (num_tokens == kBegin) { - invokeRouterGemmFloatOutput<__nv_bfloat16, kBegin, kNumExperts, - kHiddenDim>(output, input, weights, stream); - } else { - LoopUnroller::unroll_float_output(num_tokens, output, input, - weights, stream); - } - } - - static void unroll_bf16_output(int num_tokens, __nv_bfloat16* output, - __nv_bfloat16 const* input, - __nv_bfloat16 const* weights, - cudaStream_t stream) { - if (num_tokens == kBegin) { - invokeRouterGemmBf16Output<__nv_bfloat16, kBegin, kNumExperts, - kHiddenDim>(output, input, weights, stream); - } else { - LoopUnroller::unroll_bf16_output(num_tokens, output, input, - weights, stream); - } - } -}; - -template -struct LoopUnroller { - static void unroll_float_output(int num_tokens, float* output, - __nv_bfloat16 const* input, - __nv_bfloat16 const* weights, - cudaStream_t stream) { - if (num_tokens == kEnd) { - invokeRouterGemmFloatOutput<__nv_bfloat16, kEnd, kNumExperts, kHiddenDim>( - output, input, weights, stream); - } else { - throw std::invalid_argument("Invalid num_tokens, only supports 1 to 16"); - } - } - - static void unroll_bf16_output(int num_tokens, __nv_bfloat16* output, - __nv_bfloat16 const* input, - __nv_bfloat16 const* weights, - cudaStream_t stream) { - if (num_tokens == kEnd) { - invokeRouterGemmBf16Output<__nv_bfloat16, kEnd, kNumExperts, kHiddenDim>( - output, input, weights, stream); - } else { - throw std::invalid_argument("Invalid num_tokens, only supports 1 to 16"); - } - } -}; - -void dsv3_router_gemm(at::Tensor& output, // [num_tokens, num_experts] - const at::Tensor& mat_a, // [num_tokens, hidden_dim] - const at::Tensor& mat_b // [num_experts, hidden_dim] -) { - TORCH_CHECK(output.dim() == 2 && mat_a.dim() == 2 && mat_b.dim() == 2); - - const int num_tokens = mat_a.size(0); - const int num_experts = mat_b.size(0); - const int hidden_dim = mat_a.size(1); - - TORCH_CHECK(mat_a.size(1) == mat_b.size(1), - "mat_a and mat_b must have the same hidden_dim"); - TORCH_CHECK(hidden_dim == DEFAULT_HIDDEN_DIM, - "Expected hidden_dim=", DEFAULT_HIDDEN_DIM, - ", but got hidden_dim=", hidden_dim); - TORCH_CHECK( - num_experts == DEFAULT_NUM_EXPERTS || num_experts == KIMI_K2_NUM_EXPERTS, - "Expected num_experts=", DEFAULT_NUM_EXPERTS, - " or num_experts=", KIMI_K2_NUM_EXPERTS, - ", but got num_experts=", num_experts); - TORCH_CHECK(num_tokens >= 1 && num_tokens <= 16, - "currently num_tokens must be less than or equal to 16 for " - "router_gemm"); - TORCH_CHECK(mat_a.dtype() == at::kBFloat16, "mat_a must be bf16"); - TORCH_CHECK(mat_b.dtype() == at::kBFloat16, "mat_b must be bf16"); - TORCH_CHECK(output.dtype() == at::kFloat || output.dtype() == at::kBFloat16, - "output must be float32 or bf16"); - - auto const sm = getSMVersion(); - TORCH_CHECK(sm >= 90 && sm <= 103, "required SM_103 >= CUDA ARCH >= SM_90"); - - const cudaStream_t stream = at::cuda::getCurrentCUDAStream(); - - if (output.dtype() == at::kFloat) { - if (num_experts == DEFAULT_NUM_EXPERTS) { - LoopUnroller<1, 16, DEFAULT_NUM_EXPERTS, DEFAULT_HIDDEN_DIM>:: - unroll_float_output( - num_tokens, reinterpret_cast(output.mutable_data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()), stream); - } else if (num_experts == KIMI_K2_NUM_EXPERTS) { - LoopUnroller<1, 16, KIMI_K2_NUM_EXPERTS, DEFAULT_HIDDEN_DIM>:: - unroll_float_output( - num_tokens, reinterpret_cast(output.mutable_data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()), stream); - } - } else if (output.dtype() == at::kBFloat16) { - if (num_experts == DEFAULT_NUM_EXPERTS) { - LoopUnroller<1, 16, DEFAULT_NUM_EXPERTS, DEFAULT_HIDDEN_DIM>:: - unroll_bf16_output( - num_tokens, - reinterpret_cast<__nv_bfloat16*>(output.mutable_data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()), stream); - } else if (num_experts == KIMI_K2_NUM_EXPERTS) { - LoopUnroller<1, 16, KIMI_K2_NUM_EXPERTS, DEFAULT_HIDDEN_DIM>:: - unroll_bf16_output( - num_tokens, - reinterpret_cast<__nv_bfloat16*>(output.mutable_data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_a.data_ptr()), - reinterpret_cast<__nv_bfloat16 const*>(mat_b.data_ptr()), stream); - } - } -} - -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("dsv3_router_gemm", &dsv3_router_gemm); -} diff --git a/csrc/moe/dsv3_router_gemm_utils.h b/csrc/moe/dsv3_router_gemm_utils.h deleted file mode 100644 index 9b533bcabfcc..000000000000 --- a/csrc/moe/dsv3_router_gemm_utils.h +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Adapted from SGLang's sgl-kernel implementation, which was adapted from - * https://github.com/NVIDIA/TensorRT-LLM/blob/main/cpp/tensorrt_llm/kernels/dsv3MinLatencyKernels/dsv3RouterGemm.cu - * https://github.com/NVIDIA/TensorRT-LLM/blob/main/cpp/tensorrt_llm/thop/dsv3RouterGemmOp.cpp - * - * Copyright (c) 2019-2023, NVIDIA CORPORATION. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#pragma once - -#include - -#include -#include - -inline int getSMVersion() { - auto* props = at::cuda::getCurrentDeviceProperties(); - return props->major * 10 + props->minor; -} diff --git a/csrc/moe/dynamic_4bit_int_moe_cpu.cpp b/csrc/moe/dynamic_4bit_int_moe_cpu.cpp index 58dc40201688..1b071d334ff4 100644 --- a/csrc/moe/dynamic_4bit_int_moe_cpu.cpp +++ b/csrc/moe/dynamic_4bit_int_moe_cpu.cpp @@ -29,25 +29,37 @@ enum ActivationKind : int64_t { torch::Tensor dynamic_4bit_int_moe_cpu( torch::Tensor x, torch::Tensor topk_ids, torch::Tensor topk_weights, - torch::Tensor w13_packed, torch::Tensor w2_packed, int64_t H, int64_t I, - int64_t I2, int64_t group_size, bool apply_router_weight_on_input, - int64_t activation_kind) { + torch::Tensor w13_packed, torch::Tensor w2_packed, int64_t hidden_size, + int64_t intermediate_size, int64_t group_size, + bool apply_router_weight_on_input, int64_t activation_kind) { TORCH_CHECK(x.dim() == 2, "x must be 2D"); TORCH_CHECK(topk_ids.dim() == 2 && topk_weights.dim() == 2, "topk tensors must be [T, K]"); TORCH_CHECK( w13_packed.size(0) == w2_packed.size(0), "w13_packed and w2_packed must have same number of experts in dim 0"); - TORCH_CHECK(I2 == 2 * I, "I2 must equal 2*I"); const int64_t T = x.size(0); const int64_t K = topk_ids.size(1); const int64_t E = w13_packed.size(0); const int64_t N = T * K; + const int64_t w13_out_features = 2 * intermediate_size; auto x_c = x.contiguous(); + // _dyn_quant_matmul_4bit kernel natively supports these pre-quant activation + // dtypes: + // - fp32: with channelwise and groupwise + // - bf16: with channelwise -> upcast to fp32 for groupwise + // - fp16: not supported -> upcast to fp32 for groupwise & channelwise + const auto output_dtype = x_c.scalar_type(); + const bool should_cast_input = + ((group_size != -1) && output_dtype == at::kBFloat16) || + output_dtype == at::kHalf; + if (should_cast_input) { + x_c = x_c.to(at::kFloat); + } auto ids_c = topk_ids.contiguous(); - auto gates_c = topk_weights.to(at::kFloat).contiguous(); + auto gates_c = topk_weights.to(x_c.scalar_type()).contiguous(); // bucketing tokens -> experts c10::SmallVector counts( @@ -63,35 +75,42 @@ torch::Tensor dynamic_4bit_int_moe_cpu( c10::SmallVector offsets(E + 1, 0); // ( E +1 ) for (int64_t e = 0; e < E; ++e) offsets[e + 1] = offsets[e] + counts[e]; + // expert_tokens = [tokens indices for expert 0, ...] + // expert_gates = [router weights for tokens assigned to expert 0, ...] auto expert_tokens = at::empty({offsets[E]}, ids_c.options()); auto expert_gates = at::empty({offsets[E]}, gates_c.options()); { c10::SmallVector cursor(E, 0); - const auto* ids_ptr = ids_c.data_ptr(); - const auto* gts_ptr = gates_c.data_ptr(); - auto* tok_ptr = expert_tokens.data_ptr(); - auto* gate_ptr = expert_gates.data_ptr(); - - for (int64_t t = 0; t < T; ++t) { - const int64_t base = t * K; - for (int64_t k = 0; k < K; ++k) { - const int64_t idx = base + k; - const int64_t e = ids_ptr[idx]; - const int64_t p = offsets[e] + (cursor[e]++); - tok_ptr[p] = t; - gate_ptr[p] = gts_ptr[idx]; - } - } + AT_DISPATCH_FLOATING_TYPES_AND2( + at::ScalarType::BFloat16, at::ScalarType::Half, gates_c.scalar_type(), + "bucket_expert_tokens_and_gates", [&] { + const auto* ids_ptr = ids_c.data_ptr(); + const auto* gts_ptr = gates_c.data_ptr(); + auto* tok_ptr = expert_tokens.data_ptr(); + auto* gate_ptr = expert_gates.data_ptr(); + + for (int64_t t = 0; t < T; ++t) { + const int64_t base = t * K; + for (int64_t k = 0; k < K; ++k) { + const int64_t idx = base + k; + const int64_t e = ids_ptr[idx]; + const int64_t p = offsets[e] + (cursor[e]++); + tok_ptr[p] = t; + gate_ptr[p] = gts_ptr[idx]; + } + } + }); } - const int64_t g_eff_13 = (group_size != -1) ? group_size : H; - const int64_t g_eff_2 = (group_size != -1) ? group_size : I; + const int64_t g_eff_13 = (group_size != -1) ? group_size : hidden_size; + const int64_t g_eff_2 = (group_size != -1) ? group_size : intermediate_size; + // X_all [num_tokens * K, hidden_size] auto X_all = x_c.index_select(/*dim=*/0, expert_tokens); if (apply_router_weight_on_input) { X_all = X_all.mul(expert_gates.unsqueeze(1)); } - auto Y_all = at::empty({offsets[E], H}, x_c.options()); + auto Y_all = at::empty({offsets[E], hidden_size}, x_c.options()); at::parallel_for(0, offsets[E], 0, [&](int64_t idx_begin, int64_t idx_end) { c10::InferenceMode guard; @@ -109,11 +128,13 @@ torch::Tensor dynamic_4bit_int_moe_cpu( auto w2_e = w2_packed.select(/*dim=*/0, e); // W13 - auto y13 = - mm(x_e, w13_e, g_eff_13, /*in_features=*/H, /*out_features=*/I2); + auto y13 = mm(x_e, w13_e, g_eff_13, /*in_features=*/hidden_size, + /*out_features=*/w13_out_features); - auto g_part = y13.narrow(/*dim=*/1, /*start=*/0, /*length=*/I); - auto u_part = y13.narrow(/*dim=*/1, /*start=*/I, /*length=*/I); + auto g_part = + y13.narrow(/*dim=*/1, /*start=*/0, /*length=*/intermediate_size); + auto u_part = y13.narrow(/*dim=*/1, /*start=*/intermediate_size, + /*length=*/intermediate_size); torch::Tensor act; if (activation_kind == ActivationKind::SwiGLUOAI) { // SwiGLUOAI @@ -128,7 +149,8 @@ torch::Tensor dynamic_4bit_int_moe_cpu( } // W2 - auto y = mm(act, w2_e, g_eff_2, /*in_features=*/I, /*out_features=*/H); + auto y = mm(act, w2_e, g_eff_2, /*in_features=*/intermediate_size, + /*out_features=*/hidden_size); // Store per-expert result Y_all.narrow(/*dim=*/0, /*start=*/start, /*length=*/te).copy_(y); @@ -138,8 +160,11 @@ torch::Tensor dynamic_4bit_int_moe_cpu( if (!apply_router_weight_on_input) { Y_all = Y_all.mul(expert_gates.unsqueeze(1)); } + if (Y_all.scalar_type() != output_dtype) { + Y_all = Y_all.to(output_dtype); + } - auto out = at::zeros({T, H}, x.options()); + auto out = at::zeros({T, hidden_size}, x.options()); out = at::index_add(out, /*dim=*/0, /*index=*/expert_tokens, /*source=*/Y_all); diff --git a/csrc/moe/moe_ops.h b/csrc/moe/moe_ops.h deleted file mode 100644 index ca2776c6edd7..000000000000 --- a/csrc/moe/moe_ops.h +++ /dev/null @@ -1,81 +0,0 @@ -#pragma once - -#include - -void topk_softmax(torch::Tensor& topk_weights, torch::Tensor& topk_indices, - torch::Tensor& token_expert_indices, - torch::Tensor& gating_output, bool renormalize, - std::optional bias); - -void topk_sigmoid(torch::Tensor& topk_weights, torch::Tensor& topk_indices, - torch::Tensor& token_expert_indices, - torch::Tensor& gating_output, bool renormalize, - std::optional bias); - -void topk_softplus_sqrt(torch::Tensor& topk_weights, - torch::Tensor& topk_indices, - torch::Tensor& token_expert_indices, - torch::Tensor& gating_output, bool renormalize, - double routed_scaling_factor, - const c10::optional& correction_bias, - const c10::optional& input_ids, - const c10::optional& tid2eid); - -void moe_sum(torch::Tensor& input, torch::Tensor& output); - -void moe_align_block_size(torch::Tensor topk_ids, int64_t num_experts, - int64_t block_size, torch::Tensor sorted_token_ids, - torch::Tensor experts_ids, - torch::Tensor num_tokens_post_pad, - std::optional maybe_expert_map); - -void batched_moe_align_block_size(int64_t max_tokens_per_batch, - int64_t block_size, - torch::Tensor const& expert_num_tokens, - torch::Tensor sorted_ids, - torch::Tensor expert_ids, - torch::Tensor num_tokens_post_pad); - -void moe_lora_align_block_size( - torch::Tensor topk_ids, torch::Tensor token_lora_mapping, - int64_t num_experts, int64_t block_size, int64_t max_loras, - int64_t max_num_tokens_padded, int64_t max_num_m_blocks, - torch::Tensor sorted_token_ids, torch::Tensor expert_ids, - torch::Tensor num_tokens_post_pad, torch::Tensor adapter_enabled, - torch::Tensor lora_ids, std::optional maybe_expert_map); -#ifndef USE_ROCM -torch::Tensor moe_wna16_gemm(torch::Tensor input, torch::Tensor output, - torch::Tensor b_qweight, torch::Tensor b_scales, - std::optional b_qzeros, - std::optional topk_weights, - torch::Tensor sorted_token_ids, - torch::Tensor expert_ids, - torch::Tensor num_tokens_post_pad, int64_t top_k, - int64_t BLOCK_SIZE_M, int64_t BLOCK_SIZE_N, - int64_t BLOCK_SIZE_K, int64_t bit); - -std::tuple grouped_topk( - torch::Tensor const& scores, int64_t n_group, int64_t topk_group, - int64_t topk, bool renormalize, double routed_scaling_factor, - torch::Tensor const& bias, int64_t scoring_func); -#endif - -bool moe_permute_unpermute_supported(); - -int64_t moe_permute_sort_workspace_size(int64_t num_expanded_rows, - int64_t num_experts); - -void shuffle_rows(const torch::Tensor& input_tensor, - const torch::Tensor& dst2src_map, - torch::Tensor& output_tensor); - -#ifndef USE_ROCM -// DeepSeek V3 optimized router GEMM kernel for SM90+ -// Computes output = mat_a @ mat_b.T where: -// mat_a: [num_tokens, hidden_dim] in bf16 -// mat_b: [num_experts, hidden_dim] in bf16 -// output: [num_tokens, num_experts] in bf16 or fp32 -// Supports num_tokens in [1, 16], num_experts in {256, 384}, hidden_dim = 7168 -void dsv3_router_gemm(torch::Tensor& output, const torch::Tensor& mat_a, - const torch::Tensor& mat_b); -#endif diff --git a/csrc/moe/moe_permute_unpermute_op.cu b/csrc/moe/moe_permute_unpermute_op.cu deleted file mode 100644 index 6fce009ae6dd..000000000000 --- a/csrc/moe/moe_permute_unpermute_op.cu +++ /dev/null @@ -1,286 +0,0 @@ -#include -#include -#include -#include "permute_unpermute_kernels/moe_permute_unpermute_kernel.h" -#include "permute_unpermute_kernels/dispatch.h" -#include "core/registration.h" - -// moe_permute kernels require at least CUDA 12.0 -#if defined(CUDA_VERSION) && (CUDA_VERSION >= 12000) - -namespace { - -torch::Tensor maybe_allocate_tensor( - const std::optional& maybe_tensor, - at::IntArrayRef expected_sizes, torch::ScalarType dtype, c10::Device device, - char const* name) { - auto expected_numel = c10::multiply_integers(expected_sizes); - if (maybe_tensor.has_value()) { - auto tensor = maybe_tensor.value(); - TORCH_CHECK(tensor.device() == device, name, " must be on the same device"); - TORCH_CHECK(tensor.scalar_type() == dtype, name, " has incorrect dtype"); - TORCH_CHECK(tensor.is_contiguous(), name, " must be contiguous"); - TORCH_CHECK(tensor.numel() >= expected_numel, name, - " is too small for the requested shape"); - auto flat_tensor = tensor.view({tensor.numel()}); - return flat_tensor.narrow(0, 0, expected_numel).view(expected_sizes); - } - return torch::empty(expected_sizes, torch::dtype(dtype).device(device)); -} - -} // namespace - -int64_t moe_permute_sort_workspace_size(int64_t num_expanded_rows, - int64_t n_expert) { - return static_cast( - CubKeyValueSorter::getWorkspaceSize(num_expanded_rows, n_expert)); -} - -void moe_permute_impl( - const torch::Tensor& input, // [n_token, hidden] - const torch::Tensor& topk_ids, // [n_token, topk] - const torch::Tensor& token_expert_indices, // [n_token, topk] - const std::optional& expert_map, // [n_expert] - int64_t n_expert, int64_t n_local_expert, int64_t topk, - torch::Tensor& permuted_input, // [permuted_size, hidden] - torch::Tensor& expert_first_token_offset, // [n_local_expert + 1] - torch::Tensor& inv_permuted_idx, // [n_token, topk] - torch::Tensor& permuted_idx, // [permute_size] - const std::optional& maybe_sort_workspace, - const std::optional& maybe_permuted_experts_id, - const std::optional& maybe_sorted_row_idx, - const std::optional& maybe_topk_ids_for_sort) { - TORCH_CHECK(expert_first_token_offset.scalar_type() == at::ScalarType::Long, - "expert_first_token_offset must be int64"); - TORCH_CHECK(topk_ids.scalar_type() == at::ScalarType::Int, - "topk_ids must be int32"); - TORCH_CHECK(token_expert_indices.scalar_type() == at::ScalarType::Int, - "token_expert_indices must be int32"); - TORCH_CHECK(inv_permuted_idx.scalar_type() == at::ScalarType::Int, - "inv_permuted_idx must be int32"); - TORCH_CHECK(expert_first_token_offset.size(0) == n_local_expert + 1, - "expert_first_token_offset shape != n_local_expert+1"); - TORCH_CHECK(inv_permuted_idx.sizes() == token_expert_indices.sizes(), - "token_expert_indices shape must be same as inv_permuted_idx"); - auto device = input.device(); - auto n_token = input.sizes()[0]; - auto n_hidden = input.sizes()[1]; - auto expanded_rows = n_token * topk; - auto stream = at::cuda::getCurrentCUDAStream().stream(); - - auto sorter_size = moe_permute_sort_workspace_size(expanded_rows, n_expert); - auto sort_workspace = - maybe_allocate_tensor(maybe_sort_workspace, {sorter_size}, torch::kInt8, - device, "sort_workspace"); - auto permuted_experts_id = - maybe_allocate_tensor(maybe_permuted_experts_id, topk_ids.sizes(), - at::ScalarType::Int, device, "permuted_experts_id"); - auto sorted_row_idx = - maybe_allocate_tensor(maybe_sorted_row_idx, inv_permuted_idx.sizes(), - at::ScalarType::Int, device, "sorted_row_idx"); - - CubKeyValueSorter sorter{}; - int64_t* valid_num_ptr = nullptr; - torch::Tensor topk_ids_for_sort = topk_ids; - - if (expert_map.has_value()) { - const int* expert_map_ptr = get_ptr(expert_map.value()); - valid_num_ptr = - get_ptr(expert_first_token_offset) + n_local_expert; - topk_ids_for_sort = - maybe_allocate_tensor(maybe_topk_ids_for_sort, topk_ids.sizes(), - at::ScalarType::Int, device, "topk_ids_for_sort"); - topk_ids_for_sort.copy_(topk_ids); - preprocessTopkIdLauncher(get_ptr(topk_ids_for_sort), n_token * topk, - expert_map_ptr, n_expert, stream); - } - - sortAndScanExpert( - get_ptr(topk_ids_for_sort), get_ptr(token_expert_indices), - get_ptr(permuted_experts_id), get_ptr(sorted_row_idx), - get_ptr(expert_first_token_offset), n_token, n_expert, - n_local_expert, topk, sorter, get_ptr(sort_workspace), stream); - - MOE_DISPATCH(input.scalar_type(), [&] { - expandInputRowsKernelLauncher( - get_ptr(input), get_ptr(permuted_input), - get_ptr(sorted_row_idx), get_ptr(inv_permuted_idx), - get_ptr(permuted_idx), get_ptr(expert_first_token_offset), - n_token, valid_num_ptr, n_hidden, topk, n_local_expert, stream); - }); -} - -void moe_permute( - const torch::Tensor& input, // [n_token, hidden] - const torch::Tensor& topk_ids, // [n_token, topk] - const torch::Tensor& token_expert_indices, // [n_token, topk] - const std::optional& expert_map, // [n_expert] - int64_t n_expert, int64_t n_local_expert, int64_t topk, - torch::Tensor& permuted_input, // [permuted_size, hidden] - torch::Tensor& expert_first_token_offset, // [n_local_expert + 1] - torch::Tensor& inv_permuted_idx, // [n_token, topk] - torch::Tensor& permuted_idx) { // [permute_size] - moe_permute_impl(input, topk_ids, token_expert_indices, expert_map, n_expert, - n_local_expert, topk, permuted_input, - expert_first_token_offset, inv_permuted_idx, permuted_idx, - std::nullopt, std::nullopt, std::nullopt, std::nullopt); -} - -void moe_permute_with_scratch( - const torch::Tensor& input, const torch::Tensor& topk_ids, - const torch::Tensor& token_expert_indices, - const std::optional& expert_map, int64_t n_expert, - int64_t n_local_expert, int64_t topk, torch::Tensor& permuted_input, - torch::Tensor& expert_first_token_offset, torch::Tensor& inv_permuted_idx, - torch::Tensor& permuted_idx, torch::Tensor& sort_workspace, - torch::Tensor& permuted_experts_id, torch::Tensor& sorted_row_idx, - torch::Tensor& topk_ids_for_sort) { - moe_permute_impl(input, topk_ids, token_expert_indices, expert_map, n_expert, - n_local_expert, topk, permuted_input, - expert_first_token_offset, inv_permuted_idx, permuted_idx, - sort_workspace, permuted_experts_id, sorted_row_idx, - topk_ids_for_sort); -} - -void moe_unpermute( - const torch::Tensor& permuted_hidden_states, // [n_token * topk, hidden] - const torch::Tensor& topk_weights, // [n_token, topk] - const torch::Tensor& inv_permuted_idx, // [n_token, topk] - const std::optional& - expert_first_token_offset, // [n_local_expert+1] - int64_t topk, - torch::Tensor& hidden_states // [n_token, hidden] -) { - TORCH_CHECK( - permuted_hidden_states.scalar_type() == hidden_states.scalar_type(), - "permuted_hidden_states dtype must be same as hidden_states"); - auto n_token = hidden_states.size(0); - auto n_hidden = hidden_states.size(1); - auto stream = at::cuda::getCurrentCUDAStream().stream(); - - int64_t const* valid_ptr = nullptr; - if (expert_first_token_offset.has_value()) { - int n_local_expert = expert_first_token_offset.value().size(0) - 1; - valid_ptr = - get_ptr(expert_first_token_offset.value()) + n_local_expert; - } - - MOE_DISPATCH(hidden_states.scalar_type(), [&] { - finalizeMoeRoutingKernelLauncher( - get_ptr(permuted_hidden_states), - get_ptr(hidden_states), get_ptr(topk_weights), - get_ptr(inv_permuted_idx), n_token, n_hidden, topk, valid_ptr, - stream); - }); -} - -template -__global__ void shuffleInputRowsKernel(const T* input, - const int32_t* dst2src_map, T* output, - int64_t num_src_rows, - int64_t num_dst_rows, int64_t num_cols) { - int64_t dest_row_idx = blockIdx.x; - int64_t const source_row_idx = dst2src_map[dest_row_idx]; - - if (blockIdx.x < num_dst_rows) { - // Load 128-bits per thread - constexpr int64_t ELEM_PER_THREAD = 128 / sizeof(T) / 8; - using DataElem = cutlass::Array; - - // Duplicate and permute rows - auto const* source_row_ptr = - reinterpret_cast(input + source_row_idx * num_cols); - auto* dest_row_ptr = - reinterpret_cast(output + dest_row_idx * num_cols); - - int64_t const start_offset = threadIdx.x; - int64_t const stride = blockDim.x; - int64_t const num_elems_in_col = num_cols / ELEM_PER_THREAD; - - for (int elem_index = start_offset; elem_index < num_elems_in_col; - elem_index += stride) { - dest_row_ptr[elem_index] = source_row_ptr[elem_index]; - } - } -} - -void shuffle_rows(const torch::Tensor& input_tensor, - const torch::Tensor& dst2src_map, - torch::Tensor& output_tensor) { - TORCH_CHECK(input_tensor.scalar_type() == output_tensor.scalar_type(), - "Input and output tensors must have the same data type"); - - auto stream = at::cuda::getCurrentCUDAStream().stream(); - int64_t const blocks = output_tensor.size(0); - int64_t const threads = 256; - int64_t const num_dest_rows = output_tensor.size(0); - int64_t const num_src_rows = input_tensor.size(0); - int64_t const num_cols = input_tensor.size(1); - - TORCH_CHECK(!(num_cols % (128 / sizeof(input_tensor.scalar_type()) / 8)), - "num_cols must be divisible by 128 / " - "sizeof(input_tensor.scalar_type()) / 8"); - - MOE_DISPATCH(input_tensor.scalar_type(), [&] { - shuffleInputRowsKernel<<>>( - reinterpret_cast(input_tensor.data_ptr()), - dst2src_map.data_ptr(), - reinterpret_cast(output_tensor.data_ptr()), num_src_rows, - num_dest_rows, num_cols); - }); -} - -#else - -int64_t moe_permute_sort_workspace_size(int64_t num_expanded_rows, - int64_t n_expert) { - TORCH_CHECK( - false, "moe_permute_sort_workspace_size is not supported on CUDA < 12.0"); -} - -void moe_permute(const torch::Tensor& input, const torch::Tensor& topk_ids, - const torch::Tensor& token_expert_indices, - const std::optional& expert_map, - int64_t n_expert, int64_t n_local_expert, int64_t topk, - torch::Tensor& permuted_input, - torch::Tensor& expert_first_token_offset, - torch::Tensor& inv_permuted_idx, torch::Tensor& permuted_idx) { - TORCH_CHECK(false, "moe_permute is not supported on CUDA < 12.0"); -} - -void moe_permute_with_scratch( - const torch::Tensor& input, const torch::Tensor& topk_ids, - const torch::Tensor& token_expert_indices, - const std::optional& expert_map, int64_t n_expert, - int64_t n_local_expert, int64_t topk, torch::Tensor& permuted_input, - torch::Tensor& expert_first_token_offset, torch::Tensor& inv_permuted_idx, - torch::Tensor& permuted_idx, torch::Tensor& sort_workspace, - torch::Tensor& permuted_experts_id, torch::Tensor& sorted_row_idx, - torch::Tensor& topk_ids_for_sort) { - TORCH_CHECK(false, - "moe_permute_with_scratch is not supported on CUDA < 12.0"); -} - -void moe_unpermute( - const torch::Tensor& permuted_hidden_states, - const torch::Tensor& topk_weights, const torch::Tensor& inv_permuted_idx, - const std::optional& expert_first_token_offset, int64_t topk, - torch::Tensor& hidden_states) { - TORCH_CHECK(false, "moe_unpermute is not supported on CUDA < 12.0"); -} - -#endif - -bool moe_permute_unpermute_supported() { -#if defined(CUDA_VERSION) && (CUDA_VERSION >= 12000) - return true; -#else - return false; -#endif -} - -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("moe_permute", &moe_permute); - m.impl("moe_permute_with_scratch", &moe_permute_with_scratch); - m.impl("moe_unpermute", &moe_unpermute); -} \ No newline at end of file diff --git a/csrc/moe/permute_unpermute_kernels/dispatch.h b/csrc/moe/permute_unpermute_kernels/dispatch.h deleted file mode 100644 index d0f1ea4aded3..000000000000 --- a/csrc/moe/permute_unpermute_kernels/dispatch.h +++ /dev/null @@ -1,59 +0,0 @@ -#pragma once -#include -#define MOE_SWITCH(TYPE, ...) \ - at::ScalarType _st = ::detail::scalar_type(TYPE); \ - switch (_st) { \ - __VA_ARGS__ \ - default: \ - TORCH_CHECK(false, "[moe permute]data type dispatch fail!") \ - } - -#define MOE_DISPATCH_CASE(enum_type, ...) \ - case enum_type: { \ - using scalar_t = ScalarType2CudaType::type; \ - __VA_ARGS__(); \ - break; \ - } -#define MOE_DISPATCH_FLOAT_CASE(...) \ - MOE_DISPATCH_CASE(at::ScalarType::Float, __VA_ARGS__) \ - MOE_DISPATCH_CASE(at::ScalarType::Half, __VA_ARGS__) \ - MOE_DISPATCH_CASE(at::ScalarType::BFloat16, __VA_ARGS__) \ - MOE_DISPATCH_CASE(at::ScalarType::Float8_e5m2, __VA_ARGS__) \ - MOE_DISPATCH_CASE(at::ScalarType::Float8_e4m3fn, __VA_ARGS__) \ - MOE_DISPATCH_CASE(at::ScalarType::Byte, __VA_ARGS__) - -#define MOE_DISPATCH(TYPE, ...) \ - MOE_SWITCH(TYPE, MOE_DISPATCH_FLOAT_CASE(__VA_ARGS__)) - -template -struct ScalarType2CudaType; - -template <> -struct ScalarType2CudaType { - using type = float; -}; -template <> -struct ScalarType2CudaType { - using type = half; -}; -template <> -struct ScalarType2CudaType { - using type = __nv_bfloat16; -}; -// uint8 for packed fp4 -template <> -struct ScalarType2CudaType { - using type = uint8_t; -}; - -// #if __CUDA_ARCH__ >= 890 -// fp8 -template <> -struct ScalarType2CudaType { - using type = __nv_fp8_e5m2; -}; -template <> -struct ScalarType2CudaType { - using type = __nv_fp8_e4m3; -}; -// #endif \ No newline at end of file diff --git a/csrc/ops.h b/csrc/ops.h index ed2fca26b0df..274cd52bea41 100644 --- a/csrc/ops.h +++ b/csrc/ops.h @@ -9,42 +9,14 @@ #include -torch::Tensor weak_ref_tensor(torch::Tensor& tensor) { - // Ensure tensor is on CUDA - if (!tensor.is_cuda()) { - throw std::runtime_error("Tensor must be on CUDA device"); - } - - // Get the raw data pointer - void* data_ptr = tensor.data_ptr(); - - // Get tensor sizes and strides - std::vector sizes = tensor.sizes().vec(); - std::vector strides = tensor.strides().vec(); - - // Get tensor options (dtype, device) - auto options = tensor.options(); - - // Create a new tensor from the raw data pointer - auto new_tensor = torch::from_blob(data_ptr, sizes, strides, options); - - return new_tensor; -} - // rms_norm and fused_add_rms_norm declarations also exist in // csrc/libtorch_stable/ops.h (torch::stable ABI for CUDA). They remain here // because the CPU build still uses these torch::Tensor declarations. -void rms_norm(torch::Tensor& out, torch::Tensor& input, torch::Tensor& weight, - double epsilon); +void rms_norm(torch::Tensor& out, torch::Tensor& input, + std::optional weight, double epsilon); void fused_add_rms_norm(torch::Tensor& input, torch::Tensor& residual, - torch::Tensor& weight, double epsilon); - -void silu_and_mul_per_block_quant(torch::Tensor& out, - torch::Tensor const& input, - torch::Tensor& scales, int64_t group_size, - std::optional scale_ub, - bool is_scale_transposed); + std::optional weight, double epsilon); // rotary_embedding also exist in csrc/libtorch_stable/ops.h (torch::stable // ABI for CUDA). It remains here because the CPU build still uses these @@ -56,36 +28,21 @@ void rotary_embedding(torch::Tensor& positions, torch::Tensor& query, void silu_and_mul(torch::Tensor& out, torch::Tensor& input); -void silu_and_mul_clamp(torch::Tensor& out, torch::Tensor& input, double limit); - -void silu_and_mul_quant(torch::Tensor& out, torch::Tensor& input, - torch::Tensor& scale); - -void persistent_masked_m_silu_mul_quant( - const at::Tensor& input, // (E, T, 2*H) - const at::Tensor& counts, // (E) - at::Tensor& y_q, // (E, T, H) [OUT] - at::Tensor& y_s, // (E, T, H//group_size) [OUT] - bool use_ue8m0); +void silu_and_mul_clamp(torch::Tensor& out, torch::Tensor& input, double limit, + double alpha = 1.0, double beta = 0.0); void gelu_and_mul(torch::Tensor& out, torch::Tensor& input); void gelu_tanh_and_mul(torch::Tensor& out, torch::Tensor& input); +void gelu_tanh(torch::Tensor& out, torch::Tensor& input); + void gelu_new(torch::Tensor& out, torch::Tensor& input); void gelu_fast(torch::Tensor& out, torch::Tensor& input); void gelu_quick(torch::Tensor& out, torch::Tensor& input); -void cutlass_mla_decode(torch::Tensor const& out, torch::Tensor const& q_nope, - torch::Tensor const& q_pe, - torch::Tensor const& kv_c_and_k_pe_cache, - torch::Tensor const& seq_lens, - torch::Tensor const& page_table, double scale); - -torch::Tensor get_cuda_view_from_cpu_tensor(torch::Tensor& cpu_tensor); - void static_scaled_int8_quant(torch::Tensor& out, torch::Tensor const& input, torch::Tensor const& scale, std::optional const& azp); @@ -96,9 +53,9 @@ void dynamic_scaled_int8_quant(torch::Tensor& out, torch::Tensor const& input, torch::Tensor dynamic_4bit_int_moe_cpu( torch::Tensor x, torch::Tensor topk_ids, torch::Tensor topk_weights, - torch::Tensor w13_packed, torch::Tensor w2_packed, int64_t H, int64_t I, - int64_t I2, int64_t group_size, bool apply_router_weight_on_input, - int64_t activation_kind); + torch::Tensor w13_packed, torch::Tensor w2_packed, int64_t hidden_size, + int64_t intermediate_size, int64_t group_size, + bool apply_router_weight_on_input, int64_t activation_kind); using fptr_t = int64_t; #ifdef USE_ROCM diff --git a/csrc/quantization/machete/machete_mm_launcher.cuh b/csrc/quantization/machete/machete_mm_launcher.cuh deleted file mode 100644 index cabe0af46f06..000000000000 --- a/csrc/quantization/machete/machete_mm_launcher.cuh +++ /dev/null @@ -1,75 +0,0 @@ -#pragma once - -#include -#include - -#include "machete_mm_kernel.cuh" -#include "cutlass_extensions/torch_utils.hpp" -#include "core/scalar_type.hpp" - -namespace machete { - -struct MMArgs { - torch::Tensor const& A; - torch::Tensor const& B; - vllm::ScalarType const& b_type; - std::optional const& maybe_out_type; - std::optional const& maybe_group_scales; - std::optional const& maybe_group_zeros; - std::optional maybe_group_size; - std::optional const& maybe_channel_scales; - std::optional const& maybe_token_scales; - std::optional maybe_schedule; -}; - -struct SupportedSchedulesArgs { - at::ScalarType a_type; - vllm::ScalarType b_type; - std::optional maybe_group_scales_type; - std::optional maybe_group_zeros_type; - std::optional maybe_channel_scales_type; - std::optional maybe_token_scales_type; - std::optional maybe_out_type; -}; - -torch::Tensor mm_dispatch(MMArgs args); - -std::vector supported_schedules_dispatch( - SupportedSchedulesArgs args); - -template -torch::Tensor run_impl(MMArgs args) { - const at::cuda::OptionalCUDAGuard device_guard(device_of(args.A)); - - auto device = args.A.device(); - auto stream = at::cuda::getCurrentCUDAStream(device.index()); - - int M = args.A.size(0); - int N = args.B.size(1); - int K = args.A.size(1); - - // Allocate output - torch::Tensor D = torch::empty( - {M, N}, - torch::TensorOptions() - .dtype(equivalent_scalar_type_v) - .device(device)); - - auto arguments = MacheteKernel::create_arguments( - stream, // - args.A, args.B, D, args.maybe_group_scales, args.maybe_group_zeros, - args.maybe_group_size, args.maybe_channel_scales, - args.maybe_token_scales); - TORCH_CHECK(MacheteKernel::can_implement(arguments), - "Machete kernel cannot be run with these arguments"); - - size_t workspace_size = MacheteKernel::get_workspace_size(arguments); - torch::Tensor workspace = torch::empty( - workspace_size, torch::TensorOptions().dtype(torch::kU8).device(device)); - - MacheteKernel::run(arguments, workspace.mutable_data_ptr(), stream); - - return D; -}; - -}; // namespace machete \ No newline at end of file diff --git a/csrc/quantization/machete/machete_pytorch.cu b/csrc/quantization/machete/machete_pytorch.cu deleted file mode 100644 index 05a51ee21ddb..000000000000 --- a/csrc/quantization/machete/machete_pytorch.cu +++ /dev/null @@ -1,73 +0,0 @@ -#include "machete_mm_launcher.cuh" -#include "machete_prepack_launcher.cuh" -#include "core/scalar_type.hpp" - -#include "core/registration.h" - -namespace machete { - -using namespace vllm; - -std::vector supported_schedules( - at::ScalarType a_type, int64_t b_type_id, - std::optional maybe_group_scales_type, - std::optional maybe_group_zeros_type, - std::optional maybe_channel_scales_type, - std::optional maybe_token_scales_type, - std::optional maybe_out_type) { - ScalarType const b_type = ScalarType::from_id(b_type_id); - return supported_schedules_dispatch({ - .a_type = a_type, - .b_type = b_type, - .maybe_group_scales_type = maybe_group_scales_type, - .maybe_group_zeros_type = maybe_group_zeros_type, - .maybe_channel_scales_type = maybe_channel_scales_type, - .maybe_token_scales_type = maybe_token_scales_type, - .maybe_out_type = maybe_out_type, - }); -} - -torch::Tensor mm(torch::Tensor const& A, torch::Tensor const& B, - int64_t b_type_id, - std::optional const& maybe_out_type, - std::optional const& maybe_group_scales, - std::optional const& maybe_group_zeros, - std::optional maybe_group_size, - std::optional const& maybe_channel_scales, - std::optional const& maybe_token_scales, - std::optional maybe_schedule) { - ScalarType const b_type = ScalarType::from_id(b_type_id); - return mm_dispatch({.A = A, - .B = B, - .b_type = b_type, - .maybe_out_type = maybe_out_type, - .maybe_group_scales = maybe_group_scales, - .maybe_group_zeros = maybe_group_zeros, - .maybe_group_size = maybe_group_size, - .maybe_channel_scales = maybe_channel_scales, - .maybe_token_scales = maybe_token_scales, - .maybe_schedule = maybe_schedule}); -} - -torch::Tensor prepack_B( - torch::Tensor const& B, at::ScalarType const& a_type, int64_t b_type_id, - std::optional const& maybe_group_scales_type) { - ScalarType const b_type = ScalarType::from_id(b_type_id); - return prepack_B_dispatch( - {.B = B, - .a_type = a_type, - .b_type = b_type, - .maybe_group_scales_type = maybe_group_scales_type}); -} - -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("machete_prepack_B", &prepack_B); - m.impl("machete_mm", &mm); -} - -// use CatchAll since supported_schedules has no tensor arguments -TORCH_LIBRARY_IMPL(TORCH_EXTENSION_NAME, CatchAll, m) { - m.impl("machete_supported_schedules", &supported_schedules); -} - -}; // namespace machete diff --git a/csrc/quantization/marlin/marlin_int4_fp8_preprocess.cu b/csrc/quantization/marlin/marlin_int4_fp8_preprocess.cu deleted file mode 100644 index 7d4c97fb57ed..000000000000 --- a/csrc/quantization/marlin/marlin_int4_fp8_preprocess.cu +++ /dev/null @@ -1,106 +0,0 @@ - - -#include "marlin.cuh" - -#include "core/registration.h" - -// for only non-zp format (like gptq) -__global__ void marlin_int4_fp8_preprocess_kernel_without_zp( - // qweight: (size_k * size_n // 8,) - const int32_t* __restrict__ qweight, - // output: same shape with qweight - int32_t* __restrict__ output) { - int32_t val = qweight[blockIdx.x * 32 + threadIdx.x]; - int32_t new_val = 0; - -#pragma unroll - for (int32_t i = 0; i < 8; i++) { - int32_t single_val = val & 0xF; - single_val = single_val >= 8 ? single_val - 8 : 15 - single_val; - new_val |= single_val << (i * 4); - val >>= 4; - } - - output[blockIdx.x * 32 + threadIdx.x] = new_val; -} - -// for awq format only (with zp and with awq weight layout) -__global__ void marlin_int4_fp8_preprocess_kernel_awq( - // AWQ qweight: (size_k, size_n // 8) - const int32_t* __restrict__ qweight, - // output: same shape with qweight - int32_t* __restrict__ output, - // AWQ zeros: (size_k // group_size, size_n // 8) - const int32_t* __restrict__ qzeros, int32_t size_n, int32_t size_k, - int32_t group_size) { - int32_t val = - qweight[(blockIdx.x * 32 + threadIdx.x) * size_n / 8 + blockIdx.y]; - int32_t zero = - qzeros[(blockIdx.x * 32 + threadIdx.x) / group_size * size_n / 8 + - blockIdx.y]; - int32_t new_val = 0; - -#pragma unroll - for (int32_t i = 0; i < 8; i++) { - int32_t single_val = val & 0xF; - int32_t single_zero = zero & 0xF; - - single_val = - single_val >= single_zero ? single_val - single_zero : 15 - single_val; - new_val |= single_val << (i * 4); - val >>= 4; - zero >>= 4; - } - - output[(blockIdx.x * 32 + threadIdx.x) * size_n / 8 + blockIdx.y] = new_val; -} - -torch::Tensor marlin_int4_fp8_preprocess( - torch::Tensor& qweight, std::optional qzeros_or_none, - bool inplace) { - TORCH_CHECK(qweight.device().is_cuda(), "qweight is not on GPU"); - TORCH_CHECK(qweight.scalar_type() == at::ScalarType::Int, - "qweight.dtype != torch.int32"); - - const at::cuda::OptionalCUDAGuard device_guard(device_of(qweight)); - - torch::Tensor output = inplace ? qweight : torch::empty_like(qweight); - - if (!qzeros_or_none.has_value()) { - TORCH_CHECK(qweight.numel() * 8 % 256 == 0, - "qweight.numel() * 8 % 256 != 0"); - - int blocks = qweight.numel() * 8 / 256; - marlin_int4_fp8_preprocess_kernel_without_zp<<>>( - (const int32_t*)qweight.data_ptr(), (int32_t*)output.data_ptr()); - } else { - int32_t size_k = qweight.size(0); - int32_t size_n = qweight.size(1) * 8; - torch::Tensor qzeros = qzeros_or_none.value(); - - TORCH_CHECK(size_k % 32 == 0, "size_k % 32 != 0"); - TORCH_CHECK(qzeros.device().is_cuda(), "qzeros is not on GPU"); - TORCH_CHECK(qzeros.scalar_type() == at::ScalarType::Int, - "qweight.dtype != torch.int32"); - TORCH_CHECK(device_of(qweight) == device_of(qzeros), - "qzeros is not on the same device with qweight"); - - int32_t group_size = qweight.size(0) / qzeros.size(0); - TORCH_CHECK(qweight.size(1) == qzeros.size(1), - "qweight.size(1) != qzeros.size(1)"); - TORCH_CHECK(qweight.size(0) % qzeros.size(0) == 0, - "qweight.size(0) % qzeros.size(0) != 0"); - TORCH_CHECK(group_size % 8 == 0, "group_size % 8 != 0"); - - dim3 blocks(size_k / 32, size_n / 8); - marlin_int4_fp8_preprocess_kernel_awq<<>>( - (const int32_t*)qweight.data_ptr(), (int32_t*)output.data_ptr(), - (const int32_t*)qzeros.data_ptr(), size_n, size_k, group_size); - } - - return output; -} - -TORCH_LIBRARY_IMPL_EXPAND(TORCH_EXTENSION_NAME, CUDA, m) { - m.impl("marlin_int4_fp8_preprocess", &marlin_int4_fp8_preprocess); -} diff --git a/csrc/quickreduce/base.h b/csrc/quickreduce/base.h index a2170e483207..6c3456d06f20 100644 --- a/csrc/quickreduce/base.h +++ b/csrc/quickreduce/base.h @@ -283,6 +283,29 @@ __quickreduce_device_inline__ int packed_rcp(int a) { return R.i; } +template +__quickreduce_device_inline__ int packed_from_int16_pair(int16_t low, + int16_t high); + +template <> +__quickreduce_device_inline__ int packed_from_int16_pair(int16_t low, + int16_t high) { + // Convert two signed integers to one fp16x2 packed 32-bit lane. + half2 h = __halves2half2(__int2half_rn(static_cast(low)), + __int2half_rn(static_cast(high))); + return __builtin_bit_cast(int, h); +} + +template <> +__quickreduce_device_inline__ int packed_from_int16_pair( + int16_t low, int16_t high) { + // Convert two signed integers to one bf16x2 packed 32-bit lane. + nv_bfloat16 bf_low = __float2bfloat16(static_cast(low)); + nv_bfloat16 bf_high = __float2bfloat16(static_cast(high)); + nv_bfloat162 bf2 = __halves2bfloat162(bf_low, bf_high); + return *reinterpret_cast(&bf2); +} + // changes dtype __quickreduce_device_inline__ float T2float_cast(half a) { return __half2float(a); diff --git a/csrc/quickreduce/quick_reduce.h b/csrc/quickreduce/quick_reduce.h index 4cc35300bf87..7506329972ba 100644 --- a/csrc/quickreduce/quick_reduce.h +++ b/csrc/quickreduce/quick_reduce.h @@ -59,11 +59,30 @@ allreduce_prototype_twoshot(T const* A, T* B, uint32_t N, uint32_t num_blocks, flag_color, this->kMaxProblemSize); \ } +// INT3 only retains good performance on TP2 (world_size == 2). On TP4/TP8 +// the 3-bit codec's pack/unpack overhead outweighs the reduced communication +// volume, so INT3 is restricted to a TP2-only dispatch here. +#define TWOSHOT_DISPATCH_TP2_ONLY(__codec) \ + if (world_size == 2) { \ + using LineCodec = __codec; \ + using AllReduceKernel = AllReduceTwoshot; \ + hipLaunchKernelGGL((allreduce_prototype_twoshot), \ + dim3(grid), dim3(kBlockTwoShot), 0, stream, A, B, N, \ + num_blocks, rank, dbuffer_list, data_offset, \ + flag_color, this->kMaxProblemSize); \ + } else { \ + throw std::runtime_error( \ + "INT3 quick all-reduce is only supported for world_size == 2 " \ + "(TP2); use INT4/NONE for larger world sizes."); \ + } + enum QuickReduceQuantLevel { - F16 = 0, - INT8 = 1, - INT6 = 2, - INT4 = 3, + // Keep these ids in sync with Python QuickReduceRegime enum. + F16 = 0, // full-precision fp16/bf16 communication + INT8 = 1, // symmetric int8 + per-block scale + INT6 = 2, // symmetric int6 + per-block scale + INT4 = 3, // symmetric int4 + per-block scale + INT3 = 4, // symmetric int3 + per-block scale (TP2 only) }; struct DeviceComms { @@ -184,6 +203,9 @@ struct DeviceComms { case QuickReduceQuantLevel::INT4: TWOSHOT_DISPATCH(CodecQ4) break; + case QuickReduceQuantLevel::INT3: + TWOSHOT_DISPATCH_TP2_ONLY(CodecQ3) + break; default: TWOSHOT_DISPATCH(CodecFP) break; diff --git a/csrc/quickreduce/quick_reduce_impl.cuh b/csrc/quickreduce/quick_reduce_impl.cuh index 38dc9938fc8a..e9586e40ff0d 100644 --- a/csrc/quickreduce/quick_reduce_impl.cuh +++ b/csrc/quickreduce/quick_reduce_impl.cuh @@ -2,6 +2,7 @@ #include #include "base.h" +#include namespace quickreduce { @@ -206,6 +207,168 @@ struct CodecQ4 : public CodecBase { } }; +// Int3 symmetric quantization codec. +// We quantize the FP16 data to block-scaled Int3 in blocks of 4 * +// kThreadGroupSize. Uniform symmetric quantization (round-to-int + clip), +// matching the structure of CodecQ4. Signed range is [-4, +3]. +template +struct CodecQ3 : public CodecBase { + static constexpr int kWorldSize = world_size; + + // Layout per quantization block (32 values = 8 threads * 4 fp16x2 lanes): + // - each thread owns 8 values and writes: + // * q2 payload : 8 * 2 bits -> uint16 (2 bytes) + // * q1 payload : 8 * 1 bit -> uint8 (1 byte) + // - one scale is shared per 32 values and written by group leader. + // + // kRankTileStride is split as: + // [0 .. 511] : q2 payload region (256 threads * 2 bytes) + // [512 .. 767] : q1 payload region (256 threads * 1 byte) + // [768 .. 895] : scale region (32 groups * 4 bytes) + static constexpr int kRankAtoms = kAtoms / kWorldSize; + static constexpr int kRankTileStride = 896; + static constexpr int kRankTileQ1Offset = 512; + static constexpr int kRankTileScaleOffset = 768; + static constexpr int kRankTransmittedTileSize = kRankTileStride * kRankAtoms; + static_assert(kRankTransmittedTileSize % 16 == 0, + "kRankTransmittedTileSize must be 16B aligned."); + + static constexpr int kRankBufferTileStride = + kRankTileStride / sizeof(int32x4_t); + + static constexpr int kTransmittedTileSize = + kRankTransmittedTileSize * kWorldSize; + + // {-1/4.0h, -1/4.0h}, f16x2_t / bf16x2_t. Sign-flipped so absmax maps + // to -4; the sign cancels with decoding_scale on the recv side. + static constexpr int kScaleFactor = + std::is_same::value ? 0xB400B400 : 0xBE80BE80; + + // {1e-7, 1e-7}, f16x2_t + static constexpr int kScaleEpsilon = + std::is_same::value ? 0x00010001 : 0x33D733D7; + + // {-4, -4}, f16x2_t / bf16x2_t + static constexpr int kRangeMin = + std::is_same::value ? 0xC400C400 : 0xC080C080; + + // {+3, +3}, f16x2_t / bf16x2_t + static constexpr int kRangeMax = + std::is_same::value ? 0x42004200 : 0x40404040; + + // {+4, +4}, int16x2_t -- shifts signed [-4, +3] to unsigned [0, 7]. + static constexpr int kRangeBias = 0x00040004; + + __quickreduce_device_inline__ CodecQ3(int thread, int rank) + : CodecBase(thread, rank) {} + + __quickreduce_device_inline__ void send(int32x4_t* __restrict__ send_buffer, + const int32x4_t* __restrict__ data) { + for (int k = 0; k < kRankAtoms; k++) { + int32x4_t const atom = data[k]; + + // 1) Per-group dynamic scale (shared across 32 values). + int wblockmax = group_abs_max(atom); + int decoding_scale = packed_mul(wblockmax, kScaleFactor); + int encoding_scale = packed_add(decoding_scale, kScaleEpsilon); + encoding_scale = packed_rcp(encoding_scale); + + // 2) Scale + clip to signed int3 range [-4, +3]. + int32x4_t w; + for (int i = 0; i < 4; i++) { + w[i] = packed_mul(atom[i], encoding_scale); + w[i] = packed_max(w[i], kRangeMin); + w[i] = packed_min(w[i], kRangeMax); + } + + // 3) Round to integer and bias to unsigned domain [0, 7]. + int32x4_t q; + { + int16_t* qi = reinterpret_cast(&q); + T* wh = reinterpret_cast(&w); + for (int i = 0; i < 8; i++) qi[i] = (int16_t)rintf(T2float_cast(wh[i])); + + for (int i = 0; i < 4; i++) { + q[i] = packed_add(q[i], kRangeBias); + } + } + + // 4) Split each 3-bit unsigned value into low-2-bit and high-1-bit + // halves, packed into one uint16 (low 2 bits per value) plus one + // uint8 (high 1 bit per value). + uint16_t q2w = 0; + uint8_t q1w = 0; + { + int16_t* tw = reinterpret_cast(&q); +#pragma unroll + for (int i = 0; i < 8; i++) { + uint32_t v = static_cast(tw[i]) & 0x7u; + q2w |= static_cast((v & 0x3u) << (i * 2)); + q1w |= static_cast(((v >> 2) & 0x1u) << i); + } + } + + uint8_t* atom_ptr = + reinterpret_cast(send_buffer + k * kRankBufferTileStride); + uint16_t* q2w_ptr = reinterpret_cast(atom_ptr) + thread; + uint8_t* q1w_ptr = + reinterpret_cast(atom_ptr + kRankTileQ1Offset) + thread; + int* qs_ptr = reinterpret_cast(atom_ptr + kRankTileScaleOffset) + + (thread / 8); + + __builtin_nontemporal_store(q2w, q2w_ptr); + *q1w_ptr = q1w; + if (threadIdx.x == group_leader) { + __builtin_nontemporal_store(decoding_scale, qs_ptr); + } + } + } + + __quickreduce_device_inline__ void recv(int32x4_t** __restrict__ recv_buffer, + int32x4_t* __restrict__ data) { + for (int k = 0; k < kRankAtoms; k++) { + uint8_t* atom_ptr = reinterpret_cast(*recv_buffer); + uint16_t* q2w_ptr = reinterpret_cast(atom_ptr) + thread; + uint8_t* q1w_ptr = + reinterpret_cast(atom_ptr + kRankTileQ1Offset) + thread; + int* qs_ptr = reinterpret_cast(atom_ptr + kRankTileScaleOffset) + + (thread / 8); + + uint16_t q2w = __builtin_nontemporal_load(q2w_ptr); + uint8_t q1w = *q1w_ptr; + int qs = __builtin_nontemporal_load(qs_ptr); + + *recv_buffer += kRankBufferTileStride; + + // Unpack unsigned values [0, 7] then shift back to signed domain + // [-4, +3] by adding kRangeMin. + int32x4_t w; + { + int16_t qv[8]; +#pragma unroll + for (int i = 0; i < 8; i++) { + uint32_t low2 = (q2w >> (2 * i)) & 0x3u; + uint32_t high1 = (q1w >> i) & 0x1u; + qv[i] = static_cast(low2 | (high1 << 2)); + } + +#pragma unroll + for (int i = 0; i < 4; i++) { + int qpack = packed_from_int16_pair(qv[2 * i], qv[2 * i + 1]); + w[i] = packed_add(qpack, kRangeMin); + } + } + + // Apply decode scale to reconstruct fp16/bf16 lanes. + for (int i = 0; i < 4; i++) { + w[i] = packed_mul(w[i], qs); + } + + data[k] = w; + } + } +}; + // Int6 symmetric quantization codec. // We quantize the FP16 data to block-scaled Int6 in blocks of 4 * // kThreadGroupSize. @@ -377,7 +540,6 @@ struct CodecQ6 : public CodecBase { w[i] = packed_mul(w[i], qs); } - // That's pretty much it... data[k] = w; } } diff --git a/csrc/qutlass_registration.cpp b/csrc/qutlass_registration.cpp new file mode 100644 index 000000000000..effb44041350 --- /dev/null +++ b/csrc/qutlass_registration.cpp @@ -0,0 +1,5 @@ +#include "core/registration.h" + +// QuTLASS registers torch.ops._qutlass_C via TORCH_LIBRARY in bindings.cpp. +// This stub lets Python import vllm._qutlass_C to trigger op registration. +REGISTER_EXTENSION(_qutlass_C) diff --git a/csrc/rocm/attention.cu b/csrc/rocm/attention.cu index 9e6c0726d19e..4ac255d0a75f 100644 --- a/csrc/rocm/attention.cu +++ b/csrc/rocm/attention.cu @@ -1045,7 +1045,7 @@ __launch_bounds__(NUM_THREADS) void paged_attention_ll4mi_QKV_mfma4_kernel( const scalar_t* q_ptr = q + query_start_off * q_stride + wg_start_head_idx * HEAD_SIZE; const _B16x8* q_ptrh8 = reinterpret_cast(q_ptr); - const int qhead_elemh8 = laneid / 4; + const int qhead_elemh8 = MIN(laneid / 4, HEAD_SIZE / 8 - 1); for (int h = 0; h < QHLOOP - 1; h++) { const int qhead_idx = h * 4 + lane4id; diff --git a/csrc/rocm/moe_q_gemm_rdna3.cu b/csrc/rocm/moe_q_gemm_rdna3.cu new file mode 100644 index 000000000000..6c25ed7e4bc0 --- /dev/null +++ b/csrc/rocm/moe_q_gemm_rdna3.cu @@ -0,0 +1,639 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project +// +// Fused MoE W4A16 GPTQ kernel for RDNA3 (gfx1100). +// +// Combines expert routing (sorted_token_ids / expert_ids) with the RDNA3 +// W4A16 dequant+dot from q_gemm_rdna3.cu into a single kernel launch. +// Each block processes BLOCK_SIZE_M tokens assigned to one expert, covering +// a tile of N output columns and K input positions. +// +// Weight format: same as the dense kernel — [E, K/8, N] uint32 shuffled, +// [E, groups, N] scales, [E, groups, N/8] packed zeros. +// +// Design: THREADS_X=256 (8 waves on wave32), BLOCK_KN_SIZE=256, each thread +// handles 4 N columns. Output via 64-bit packed CAS atomic-add directly to +// the pre-zeroed output tensor (no FP32 scratch buffer). + +#include + +#include +#include +#include + +#include +#include +#include + +#include "qdq_4_rdna3.cuh" + +#if defined(__HIPCC__) && defined(__gfx1100__) + #define __HIP__RDNA3__ +#endif + +namespace vllm { +namespace moe_gptq_rdna3 { + +#define BLOCK_KN_SIZE 256 +#define THREADS_X 256 + +#if defined(__HIP__RDNA3__) || !defined(__HIP_DEVICE_COMPILE__) + +using gptq_rdna3::bf162_t; +using gptq_rdna3::bf16_t; + +// --- Helpers (same as q_gemm_rdna3.cu) --- + +template +__forceinline__ __device__ T tzero(); + +template <> +__forceinline__ __device__ half tzero() { + return __float2half_rn(0.0f); +} + +template <> +__forceinline__ __device__ bf16_t tzero() { + return __float2bfloat16(0.0f); +} + +__forceinline__ __device__ float dot22_8_f(half2 (&dq)[4], const half* a_ptr) { + float result = 0.0f; + const half2* a2_ptr = (const half2*)a_ptr; + #pragma unroll + for (int i = 0; i < 4; i++) { + result = __builtin_amdgcn_fdot2(dq[i], *a2_ptr++, result, /*clamp=*/false); + } + return result; +} + +__forceinline__ __device__ float dot22_8_f(float (&dq)[8], + const bf16_t* a_ptr) { + float result = 0.0f; + #pragma unroll + for (int i = 0; i < 4; i++) { + uint32_t aw; + __builtin_memcpy(&aw, a_ptr + 2 * i, sizeof(uint32_t)); + float a_x = __uint_as_float((aw & 0xFFFFu) << 16); + float a_y = __uint_as_float(aw & 0xFFFF0000u); + result = __fmaf_rn(dq[2 * i + 0], a_x, result); + result = __fmaf_rn(dq[2 * i + 1], a_y, result); + } + return result; +} + +__forceinline__ __device__ void atomic_add_pk4_f16(half* addr, half2 v01, + half2 v23) { + unsigned long long* addr_u = reinterpret_cast(addr); + unsigned long long old = *addr_u; + while (true) { + union { + unsigned long long u; + half2 h2[2]; + } cur, sum; + cur.u = old; + sum.h2[0] = __hadd2(cur.h2[0], v01); + sum.h2[1] = __hadd2(cur.h2[1], v23); + unsigned long long prev = atomicCAS(addr_u, old, sum.u); + if (prev == old) break; + old = prev; + } +} + +__forceinline__ __device__ void atomic_add_pk4_bf16(bf16_t* addr, bf162_t v01, + bf162_t v23) { + unsigned long long* addr_u = reinterpret_cast(addr); + unsigned long long old = *addr_u; + while (true) { + union { + unsigned long long u; + bf162_t b2[2]; + } cur, sum; + cur.u = old; + sum.b2[0] = __hadd2(cur.b2[0], v01); + sum.b2[1] = __hadd2(cur.b2[1], v23); + unsigned long long prev = atomicCAS(addr_u, old, sum.u); + if (prev == old) break; + old = prev; + } +} + +__forceinline__ __device__ void load4_zeros(const uint32_t* qzeros_row, int n, + int (&zeros)[4]) { + int qcol = n / 8; + int shift = (n & 0x07) * 4; + uint32_t d = qzeros_row[qcol] >> shift; + zeros[0] = (int)(d & 0xF); + zeros[1] = (int)((d >> 4) & 0xF); + zeros[2] = (int)((d >> 8) & 0xF); + zeros[3] = (int)((d >> 12) & 0xF); +} + +template +__forceinline__ __device__ void load4_scales(const T* scales_row, int n, + T (&scales)[4]) { + scales[0] = scales_row[n + 0]; + scales[1] = scales_row[n + 1]; + scales[2] = scales_row[n + 2]; + scales[3] = scales_row[n + 3]; +} + +// --------------------------------------------------------------------------- +// Fused MoE kernel. +// --------------------------------------------------------------------------- + +template +__global__ void moe_gemm_q4_kernel_rdna3( + const T* __restrict__ a, // [size_m, size_k] or [M*topk, K] + T* __restrict__ c, // [M*topk, size_n] pre-zeroed + const uint32_t* __restrict__ b_q_weight, // [E, K/8, N] packed + const T* __restrict__ b_scales, // [E, groups, N] + const uint32_t* __restrict__ b_qzeros, // [E, groups, N/8] packed + const float* __restrict__ topk_weights, // [M*topk] or nullptr + const int32_t* __restrict__ sorted_token_ids, + const int32_t* __restrict__ expert_ids, + const int32_t* __restrict__ num_tokens_post_padded, + const int size_m, // total tokens (original M, or M*topk for w2) + const int size_n, // output features per expert + const int size_k, // input features + const int groups, // K / group_size + const int top_k, // routing top-k (1 for w2 pass) + // Per-expert strides (in elements, not bytes) + const int expert_weight_stride, // (K/8) * N + const int expert_scales_stride, // groups * N + const int expert_zeros_stride, // groups * (N/8) + const bool mul_topk_weight, + const int output_topk) { // >0: reduce output by token_id/output_topk + const int t = threadIdx.x; + const int token_block = blockIdx.x; + const int offset_n = blockIdx.y * BLOCK_KN_SIZE * 4; + const int offset_k = blockIdx.z * BLOCK_KN_SIZE; + const int end_k = min(offset_k + BLOCK_KN_SIZE, size_k); + const int n = offset_n + t * 4; + + // Early exit for padding blocks or invalid experts (expert_map = -1) + if (token_block * BLOCK_SIZE_M >= num_tokens_post_padded[0]) return; + + const int expert_id = expert_ids[token_block]; + if (expert_id == -1) return; + + // Expert-specific pointers + const uint32_t* expert_weights = + b_q_weight + (int64_t)expert_id * expert_weight_stride; + const T* expert_scales = b_scales + (int64_t)expert_id * expert_scales_stride; + const uint32_t* expert_qzeros = + b_qzeros + (int64_t)expert_id * expert_zeros_stride; + + // LDS for activations + constexpr int LDS_PAD = 8; + __shared__ T block_a[BLOCK_SIZE_M][BLOCK_KN_SIZE + LDS_PAD]; + + static_assert(BLOCK_KN_SIZE == THREADS_X, + "BLOCK_KN_SIZE must equal THREADS_X"); + + // For bf16 M=1, we can skip LDS and read A from global (same as dense). + // fp16 always needs LDS due to the dot22_8_f indexing pattern. + constexpr bool USE_LDS_A = (BLOCK_SIZE_M > 1) || std::is_same::value; + + const int offset_m_base = token_block * BLOCK_SIZE_M; + + if constexpr (USE_LDS_A) { + if (offset_k + t < end_k) { + #pragma unroll + for (int m = 0; m < BLOCK_SIZE_M; ++m) { + int32_t token_id = sorted_token_ids[offset_m_base + m]; + int token_row = token_id / top_k; + T av; + if (token_row < size_m) { + av = a[(int64_t)token_row * size_k + offset_k + t]; + } else { + av = tzero(); + } + block_a[m][t] = av; + } + } + __syncthreads(); + } + + if (n >= size_n) return; + + // Group bookkeeping + const int groupsize = size_k / groups; + int group = offset_k / groupsize; + int nextgroup = (group + 1) * groupsize; + + // Weight pointer for this expert + int qk = offset_k / 8; + const uint32_t* b_ptr = expert_weights + qk * size_n + n; + + // Per-column dequant constants (4 columns per thread) + half2 z1z16_h[4][2], y1y16_h[4][2]; + float z_b_f[4], y_b_f[4]; + + // GPTQv1: zero_offset = 1 + constexpr int zero_offset = 1; + + auto refresh_group = [&](int g) { + const uint32_t* qz_row = expert_qzeros + g * (size_n / 8); + const T* sc_row = expert_scales + g * size_n; + int zeros[4]; + T scales[4]; + load4_zeros(qz_row, n, zeros); + load4_scales(sc_row, n, scales); + if constexpr (std::is_same::value) { + #pragma unroll + for (int i = 0; i < 4; ++i) { + gptq_rdna3::prep_zero_scale_fp16((uint32_t)(zeros[i] + zero_offset), + scales[i], z1z16_h[i], y1y16_h[i]); + } + } else { + #pragma unroll + for (int i = 0; i < 4; ++i) { + gptq_rdna3::prep_zero_scale_bf16_f32((uint32_t)(zeros[i] + zero_offset), + scales[i], z_b_f[i], y_b_f[i]); + } + } + }; + + refresh_group(group); + + float block_c[BLOCK_SIZE_M][4]; + #pragma unroll + for (int m = 0; m < BLOCK_SIZE_M; ++m) { + #pragma unroll + for (int j = 0; j < 4; ++j) block_c[m][j] = 0.0f; + } + + // --- Main K-loop --- + int k = offset_k; + while (k < end_k) { + if (k == nextgroup) { + group++; + nextgroup += groupsize; + refresh_group(group); + } + + // Prefetch 4 weight words (128 bytes) + int4 b_w[4]; + #pragma unroll + for (int j = 0; j < 4; ++j) { + b_w[j] = *(const int4*)(b_ptr + j * size_n); + } + b_ptr += 4 * size_n; + + #pragma unroll + for (int j = 0; j < 4; ++j) { + const int a_off = (k - offset_k) + 8 * j; + + if constexpr (std::is_same::value) { + // fp16 path: dequant via bit-trick, dot via v_dot2_f32_f16 + half2 dq[4][4]; + gptq_rdna3::dequant_4bit_8_fp16((uint32_t)b_w[j].x, dq[0], z1z16_h[0], + y1y16_h[0]); + gptq_rdna3::dequant_4bit_8_fp16((uint32_t)b_w[j].y, dq[1], z1z16_h[1], + y1y16_h[1]); + gptq_rdna3::dequant_4bit_8_fp16((uint32_t)b_w[j].z, dq[2], z1z16_h[2], + y1y16_h[2]); + gptq_rdna3::dequant_4bit_8_fp16((uint32_t)b_w[j].w, dq[3], z1z16_h[3], + y1y16_h[3]); + + #pragma unroll + for (int m = 0; m < BLOCK_SIZE_M; ++m) { + const half* a_ptr = reinterpret_cast(&block_a[m][a_off]); + block_c[m][0] += dot22_8_f(dq[0], a_ptr); + block_c[m][1] += dot22_8_f(dq[1], a_ptr); + block_c[m][2] += dot22_8_f(dq[2], a_ptr); + block_c[m][3] += dot22_8_f(dq[3], a_ptr); + } + } else if constexpr (BLOCK_SIZE_M == 1) { + // bf16 M=1: v_dot2_f32_bf16 with InstCombine-defeating opacity + typedef short __attribute__((ext_vector_type(2))) bf16x2_t; + constexpr uint32_t BF16_MAGIC = 0x43004300u; + constexpr uint32_t BF16_ONES = 0x3F803F80u; + union pack4 { + float f[4]; + uint32_t u[4]; + }; + + uint32_t w[4]; + __builtin_memcpy(w, &b_w[j], sizeof(int4)); + + // Load activations — read from global (no LDS for bf16 M=1) + pack4 a_pack; + { + int32_t token_id = sorted_token_ids[offset_m_base]; + int token_row = token_id / top_k; + if (token_row < size_m) { + const uint32_t* a_words = reinterpret_cast( + a + (int64_t)token_row * size_k + offset_k + a_off); + a_pack.u[0] = a_words[0]; + a_pack.u[1] = a_words[1]; + a_pack.u[2] = a_words[2]; + a_pack.u[3] = a_words[3]; + } else { + a_pack.u[0] = 0; + a_pack.u[1] = 0; + a_pack.u[2] = 0; + a_pack.u[3] = 0; + } + } + + // sum_a for bias correction + float sum_a = 0.0f; + #pragma unroll + for (int b = 0; b < 4; ++b) { + sum_a = __builtin_amdgcn_fdot2_f32_bf16( + *((bf16x2_t*)(&a_pack.f[b])), *((const bf16x2_t*)&BF16_ONES), + sum_a, /*clamp=*/false); + } + + #pragma unroll 1 + for (int col = 0; col < 4; ++col) { + pack4 q_pack; + const uint32_t qa = w[col]; + q_pack.u[0] = ((qa >> 0) & 0x000F000Fu) | BF16_MAGIC; + q_pack.u[1] = ((qa >> 4) & 0x000F000Fu) | BF16_MAGIC; + q_pack.u[2] = ((qa >> 8) & 0x000F000Fu) | BF16_MAGIC; + q_pack.u[3] = ((qa >> 12) & 0x000F000Fu) | BF16_MAGIC; + + float partial = 0.0f; + #pragma unroll + for (int b = 0; b < 4; ++b) { + partial = __builtin_amdgcn_fdot2_f32_bf16( + *((bf16x2_t*)(&a_pack.f[b])), *((bf16x2_t*)(&q_pack.f[b])), + partial, /*clamp=*/false); + } + + block_c[0][col] = + __fmaf_rn(y_b_f[col], partial, + __fmaf_rn(z_b_f[col], sum_a, block_c[0][col])); + } + } else { + // bf16 M>1: v_dot2_f32_bf16 with LDS-staged activations + typedef short __attribute__((ext_vector_type(2))) bf16x2_t; + constexpr uint32_t BF16_MAGIC = 0x43004300u; + constexpr uint32_t BF16_ONES = 0x3F803F80u; + union pack4 { + float f[4]; + uint32_t u[4]; + }; + + uint32_t w[4]; + __builtin_memcpy(w, &b_w[j], sizeof(int4)); + + pack4 a_pack[BLOCK_SIZE_M]; + #pragma unroll + for (int m = 0; m < BLOCK_SIZE_M; ++m) { + const uint32_t* a_words = + reinterpret_cast(&block_a[m][a_off]); + a_pack[m].u[0] = a_words[0]; + a_pack[m].u[1] = a_words[1]; + a_pack[m].u[2] = a_words[2]; + a_pack[m].u[3] = a_words[3]; + } + + float sum_a[BLOCK_SIZE_M]; + #pragma unroll + for (int m = 0; m < BLOCK_SIZE_M; ++m) { + float s = 0.0f; + #pragma unroll + for (int b = 0; b < 4; ++b) { + s = __builtin_amdgcn_fdot2_f32_bf16(*((bf16x2_t*)(&a_pack[m].f[b])), + *((const bf16x2_t*)&BF16_ONES), + s, /*clamp=*/false); + } + sum_a[m] = s; + } + + #pragma unroll 1 + for (int col = 0; col < 4; ++col) { + pack4 q_pack; + const uint32_t qa = w[col]; + q_pack.u[0] = ((qa >> 0) & 0x000F000Fu) | BF16_MAGIC; + q_pack.u[1] = ((qa >> 4) & 0x000F000Fu) | BF16_MAGIC; + q_pack.u[2] = ((qa >> 8) & 0x000F000Fu) | BF16_MAGIC; + q_pack.u[3] = ((qa >> 12) & 0x000F000Fu) | BF16_MAGIC; + + #pragma unroll + for (int m = 0; m < BLOCK_SIZE_M; ++m) { + float partial = 0.0f; + #pragma unroll + for (int b = 0; b < 4; ++b) { + partial = __builtin_amdgcn_fdot2_f32_bf16( + *((bf16x2_t*)(&a_pack[m].f[b])), *((bf16x2_t*)(&q_pack.f[b])), + partial, /*clamp=*/false); + } + block_c[m][col] = + __fmaf_rn(y_b_f[col], partial, + __fmaf_rn(z_b_f[col], sum_a[m], block_c[m][col])); + } + } + } + } + k += 32; + } + + // --- Epilogue: apply topk_weight and atomic-add to output --- + #pragma unroll + for (int m = 0; m < BLOCK_SIZE_M; ++m) { + int32_t token_id = sorted_token_ids[offset_m_base + m]; + if (token_id / top_k >= size_m) continue; + + // Apply router weight + if (mul_topk_weight && topk_weights != nullptr) { + float tw = topk_weights[token_id]; + #pragma unroll + for (int j = 0; j < 4; ++j) block_c[m][j] *= tw; + } + + // output_topk > 0: reduce by mapping token_id back to original token + // (multiple experts write to the same row via atomics) + int64_t out_row = (output_topk > 0) ? (int64_t)(token_id / output_topk) + : (int64_t)token_id; + T* out = c + out_row * size_n + n; + if constexpr (std::is_same::value) { + half2 r01 = __halves2half2(__float2half_rn(block_c[m][0]), + __float2half_rn(block_c[m][1])); + half2 r23 = __halves2half2(__float2half_rn(block_c[m][2]), + __float2half_rn(block_c[m][3])); + atomic_add_pk4_f16(out, r01, r23); + } else { + bf162_t r01; + r01.x = __float2bfloat16(block_c[m][0]); + r01.y = __float2bfloat16(block_c[m][1]); + bf162_t r23; + r23.x = __float2bfloat16(block_c[m][2]); + r23.y = __float2bfloat16(block_c[m][3]); + atomic_add_pk4_bf16(out, r01, r23); + } + } +} + +#else // non-RDNA3: empty stub for symbol parity + +template +__global__ void moe_gemm_q4_kernel_rdna3( + const T*, T*, const uint32_t*, const T*, const uint32_t*, const float*, + const int32_t*, const int32_t*, const int32_t*, const int, const int, + const int, const int, const int, const int, const int, const int, + const bool, const int) {} + +#endif // __HIP__RDNA3__ || !__HIP_DEVICE_COMPILE__ + +// --------------------------------------------------------------------------- +// Launcher +// --------------------------------------------------------------------------- + +template +void launch_moe_gemm_q4( + const T* a, T* c, const uint32_t* b_q_weight, const T* b_scales, + const uint32_t* b_qzeros, const float* topk_weights, + const int32_t* sorted_token_ids, const int32_t* expert_ids, + const int32_t* num_tokens_post_padded, int num_token_blocks, int size_m, + int size_n, int size_k, int groups, int top_k, int expert_weight_stride, + int expert_scales_stride, int expert_zeros_stride, bool mul_topk_weight, + int output_topk, cudaStream_t stream) { + dim3 block(THREADS_X); + dim3 grid(num_token_blocks, + (size_n + BLOCK_KN_SIZE * 4 - 1) / (BLOCK_KN_SIZE * 4), + (size_k + BLOCK_KN_SIZE - 1) / BLOCK_KN_SIZE); + + moe_gemm_q4_kernel_rdna3<<>>( + a, c, b_q_weight, b_scales, b_qzeros, topk_weights, sorted_token_ids, + expert_ids, num_tokens_post_padded, size_m, size_n, size_k, groups, top_k, + expert_weight_stride, expert_scales_stride, expert_zeros_stride, + mul_topk_weight, output_topk); +} + +template +void dispatch_moe_gemm_q4( + const T* a, T* c, const uint32_t* b_q_weight, const T* b_scales, + const uint32_t* b_qzeros, const float* topk_weights, + const int32_t* sorted_token_ids, const int32_t* expert_ids, + const int32_t* num_tokens_post_padded, int num_token_blocks, int size_m, + int size_n, int size_k, int groups, int top_k, int block_size_m, + int expert_weight_stride, int expert_scales_stride, int expert_zeros_stride, + bool mul_topk_weight, int output_topk, cudaStream_t stream) { + // Dispatch to template instantiation based on block_size_m + switch (block_size_m) { + case 1: + launch_moe_gemm_q4( + a, c, b_q_weight, b_scales, b_qzeros, topk_weights, sorted_token_ids, + expert_ids, num_tokens_post_padded, num_token_blocks, size_m, size_n, + size_k, groups, top_k, expert_weight_stride, expert_scales_stride, + expert_zeros_stride, mul_topk_weight, output_topk, stream); + break; + case 2: + launch_moe_gemm_q4( + a, c, b_q_weight, b_scales, b_qzeros, topk_weights, sorted_token_ids, + expert_ids, num_tokens_post_padded, num_token_blocks, size_m, size_n, + size_k, groups, top_k, expert_weight_stride, expert_scales_stride, + expert_zeros_stride, mul_topk_weight, output_topk, stream); + break; + case 4: + launch_moe_gemm_q4( + a, c, b_q_weight, b_scales, b_qzeros, topk_weights, sorted_token_ids, + expert_ids, num_tokens_post_padded, num_token_blocks, size_m, size_n, + size_k, groups, top_k, expert_weight_stride, expert_scales_stride, + expert_zeros_stride, mul_topk_weight, output_topk, stream); + break; + case 8: + launch_moe_gemm_q4( + a, c, b_q_weight, b_scales, b_qzeros, topk_weights, sorted_token_ids, + expert_ids, num_tokens_post_padded, num_token_blocks, size_m, size_n, + size_k, groups, top_k, expert_weight_stride, expert_scales_stride, + expert_zeros_stride, mul_topk_weight, output_topk, stream); + break; + default: + TORCH_CHECK(false, + "moe_gptq_gemm_rdna3: block_size_m must be 1, 2, 4, or 8, " + "got ", + block_size_m); + } +} + +} // namespace moe_gptq_rdna3 +} // namespace vllm + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- +// +// Inputs: +// a [M, K] or [M*top_k, K] half or bfloat16 +// c [M*top_k, N] same dtype (pre-zeroed!) +// b_q_weight [E, K/8, N] uint32 (shuffled) +// b_scales [E, groups, N] same dtype as a +// b_qzeros [E, groups, N/8] uint32 (packed 4-bit) +// topk_weights [M*top_k] or empty float32 +// sorted_token_ids [num_blocks * block_m] int32 +// expert_ids [num_blocks] int32 +// num_tokens_post_padded [1] int32 +// top_k int +// block_size_m int (1, 2, 4, or 8) +// mul_topk_weight bool + +void moe_gptq_gemm_rdna3(torch::Tensor a, torch::Tensor c, + torch::Tensor b_q_weight, torch::Tensor b_scales, + torch::Tensor b_qzeros, torch::Tensor topk_weights, + torch::Tensor sorted_token_ids, + torch::Tensor expert_ids, + torch::Tensor num_tokens_post_padded, int64_t top_k, + int64_t block_size_m, bool mul_topk_weight, + int64_t output_topk) { + TORCH_CHECK(a.is_cuda(), "a must be a CUDA/HIP tensor"); + TORCH_CHECK(c.is_cuda(), "c must be a CUDA/HIP tensor"); + TORCH_CHECK(b_q_weight.is_cuda(), "b_q_weight must be a CUDA/HIP tensor"); + TORCH_CHECK(a.dim() == 2, "a must be 2D"); + TORCH_CHECK(c.dim() == 2, "c must be 2D"); + TORCH_CHECK(b_q_weight.dim() == 3, "b_q_weight must be 3D [E, K/8, N]"); + TORCH_CHECK(b_scales.dim() == 3, "b_scales must be 3D [E, groups, N]"); + TORCH_CHECK(b_qzeros.dim() == 3, "b_qzeros must be 3D [E, groups, N/8]"); + TORCH_CHECK( + a.scalar_type() == torch::kHalf || a.scalar_type() == torch::kBFloat16, + "a must be half or bfloat16"); + TORCH_CHECK(a.scalar_type() == b_scales.scalar_type(), + "b_scales dtype must match a"); + + const at::cuda::OptionalCUDAGuard device_guard(device_of(a)); + auto stream = at::cuda::getCurrentCUDAStream(); + + int size_m = (int)a.size(0); + int size_k = (int)a.size(1); + int size_n = (int)b_q_weight.size(2); + int groups = (int)b_scales.size(1); + + // Per-expert strides + int expert_weight_stride = (int)(b_q_weight.size(1) * b_q_weight.size(2)); + int expert_scales_stride = (int)(b_scales.size(1) * b_scales.size(2)); + int expert_zeros_stride = (int)(b_qzeros.size(1) * b_qzeros.size(2)); + + int num_token_blocks = (int)(sorted_token_ids.size(0) / block_size_m); + + const float* topk_w_ptr = + (topk_weights.numel() > 0) ? topk_weights.data_ptr() : nullptr; + + // Manual dtype dispatch using HIP native types (c10::Half/BFloat16 don't + // implicitly convert to half/__hip_bfloat16 in device code). + using vllm::gptq_rdna3::bf16_t; + + auto dispatch = [&](auto* a_ptr, auto* c_ptr, const auto* s_ptr) { + using T = std::remove_const_t>; + vllm::moe_gptq_rdna3::dispatch_moe_gemm_q4( + a_ptr, c_ptr, (const uint32_t*)b_q_weight.data_ptr(), s_ptr, + (const uint32_t*)b_qzeros.data_ptr(), topk_w_ptr, + sorted_token_ids.data_ptr(), expert_ids.data_ptr(), + num_tokens_post_padded.data_ptr(), num_token_blocks, size_m, + size_n, size_k, groups, (int)top_k, (int)block_size_m, + expert_weight_stride, expert_scales_stride, expert_zeros_stride, + mul_topk_weight, (int)output_topk, stream); + }; + + if (a.scalar_type() == torch::kHalf) { + dispatch((const half*)a.data_ptr(), (half*)c.data_ptr(), + (const half*)b_scales.data_ptr()); + } else { + dispatch((const bf16_t*)a.data_ptr(), (bf16_t*)c.data_ptr(), + (const bf16_t*)b_scales.data_ptr()); + } +} diff --git a/csrc/rocm/ops.h b/csrc/rocm/ops.h index 73197d8a5e20..549d50300d6f 100644 --- a/csrc/rocm/ops.h +++ b/csrc/rocm/ops.h @@ -27,6 +27,15 @@ torch::Tensor gptq_gemm_rdna3_wmma(torch::Tensor a, torch::Tensor b_q_weight, torch::Tensor b_scales, torch::Tensor b_g_idx, bool use_v2_format); +void moe_gptq_gemm_rdna3(torch::Tensor a, torch::Tensor c, + torch::Tensor b_q_weight, torch::Tensor b_scales, + torch::Tensor b_qzeros, torch::Tensor topk_weights, + torch::Tensor sorted_token_ids, + torch::Tensor expert_ids, + torch::Tensor num_tokens_post_padded, int64_t top_k, + int64_t block_size_m, bool mul_topk_weight, + int64_t output_topk); + void paged_attention( torch::Tensor& out, torch::Tensor& exp_sums, torch::Tensor& max_logits, torch::Tensor& tmp_out, torch::Tensor& query, torch::Tensor& key_cache, diff --git a/csrc/rocm/skinny_gemms.cu b/csrc/rocm/skinny_gemms.cu index 10e3cbf2e0bd..615cdabed585 100644 --- a/csrc/rocm/skinny_gemms.cu +++ b/csrc/rocm/skinny_gemms.cu @@ -70,6 +70,15 @@ bool on_gfx12() { return result; } +bool on_gfx1151() { + static const bool result = [] { + const auto* dprops = at::cuda::getCurrentDeviceProperties(); + const std::string device_arch = dprops->gcnArchName; + return device_arch.find("gfx1151") != std::string::npos; + }(); + return result; +} + #if defined(NDEBUG) #undef NDEBUG #include @@ -1237,6 +1246,45 @@ torch::Tensor wvSplitK(const at::Tensor& in_a, const at::Tensor& in_b, WVSPLITK_CFG(_THRDS, _WVPRGRP, 4, 2, __N) \ } +// WVSPLITK_CFG arguments are: (THRDS, WVPRGRP, YTILE, UNRL, N). +// THRDS = wavefront width (32 on GFX11/GFX12, 64 on GFX9) +// WVPRGRP= waves per group (always 16) +// YTILE = output rows per thread tile +// UNRL = K-loop unroll factor +// N = batch size (passed through from the switch in wvSplitK) +#define WVSPLIT_TILE(_sYT, __N) \ + { \ + if (on_gfx1151()) { \ + bool fit_lds = (Kbp_in * N_in <= max_lds_len); \ + if (_sYT <= 1) \ + WVSPLITK_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, /*YTILE=*/1, /*UNRL=*/4, \ + __N) \ + else if ((K_in % 1024 == 512) && K_in >= 1536 && \ + (_sYT >= 40 || K_in >= 4096)) \ + WVSPLITK_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, /*YTILE=*/4, /*UNRL=*/1, \ + __N) \ + else if (K_in < 1024) \ + WVSPLITK_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, /*YTILE=*/2, /*UNRL=*/4, \ + __N) \ + else if (K_in <= 2048 && (__N >= 2 || _sYT <= 26)) \ + WVSPLITK_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, /*YTILE=*/1, /*UNRL=*/4, \ + __N) \ + else if (__N >= 2 && !fit_lds) \ + WVSPLITK_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, /*YTILE=*/1, /*UNRL=*/4, \ + __N) \ + else if (__N == 1) \ + WVSPLITK_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, /*YTILE=*/1, /*UNRL=*/2, \ + __N) \ + else \ + WVSPLITK_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, /*YTILE=*/1, /*UNRL=*/1, \ + __N) \ + } else if (on_gfx1x()) { /* gfx1100/gfx1150/GFX12, wave32 */ \ + WVSPLIT_TILE_CFG(/*THRDS=*/32, /*WVPRGRP=*/16, _sYT, __N) \ + } else { /* GFX9, wave64 */ \ + WVSPLIT_TILE_CFG(/*THRDS=*/64, /*WVPRGRP=*/16, _sYT, __N) \ + } \ + } + AT_DISPATCH_REDUCED_FLOATING_TYPES(in_b.scalar_type(), "wvSplitK", [&] { using fptype = typename scalar::type; fptype* af4 = reinterpret_cast(in_a.data_ptr()); @@ -1251,37 +1299,21 @@ torch::Tensor wvSplitK(const at::Tensor& in_a, const at::Tensor& in_b, // then cut the active waves to balance their distribution... int sYT = (M_in + CuCount * 4 - 1) / (CuCount * 4); - const bool use_wave32 = on_gfx1x(); switch (N_in) { case 1: - if (use_wave32) - WVSPLIT_TILE_CFG(32, 16, sYT, 1) - else - WVSPLIT_TILE_CFG(64, 16, sYT, 1) + WVSPLIT_TILE(sYT, 1) break; case 2: - if (use_wave32) - WVSPLIT_TILE_CFG(32, 16, sYT, 2) - else - WVSPLIT_TILE_CFG(64, 16, sYT, 2) + WVSPLIT_TILE(sYT, 2) break; case 3: - if (use_wave32) - WVSPLIT_TILE_CFG(32, 16, sYT, 3) - else - WVSPLIT_TILE_CFG(64, 16, sYT, 3) + WVSPLIT_TILE(sYT, 3) break; case 4: - if (use_wave32) - WVSPLIT_TILE_CFG(32, 16, sYT, 4) - else - WVSPLIT_TILE_CFG(64, 16, sYT, 4) + WVSPLIT_TILE(sYT, 4) break; case 5: - if (use_wave32) - WVSPLIT_TILE_CFG(32, 16, sYT, 5) - else - WVSPLIT_TILE_CFG(64, 16, sYT, 5) + WVSPLIT_TILE(sYT, 5) break; default: throw std::runtime_error( diff --git a/csrc/rocm/torch_bindings.cpp b/csrc/rocm/torch_bindings.cpp index 1e589598c742..03de6dcd1576 100644 --- a/csrc/rocm/torch_bindings.cpp +++ b/csrc/rocm/torch_bindings.cpp @@ -50,6 +50,15 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, rocm_ops) { "gptq_gemm_rdna3_wmma(Tensor a, Tensor b_q_weight, Tensor b_qzeros, " "Tensor b_scales, Tensor b_g_idx, bool use_v2_format) -> Tensor"); rocm_ops.impl("gptq_gemm_rdna3_wmma", torch::kCUDA, &gptq_gemm_rdna3_wmma); + + rocm_ops.def( + "moe_gptq_gemm_rdna3(Tensor a, Tensor! c, Tensor b_q_weight, " + "Tensor b_scales, Tensor b_qzeros, Tensor topk_weights, " + "Tensor sorted_token_ids, Tensor expert_ids, " + "Tensor num_tokens_post_padded, " + "int top_k, int block_size_m, bool mul_topk_weight, " + "int output_topk) -> ()"); + rocm_ops.impl("moe_gptq_gemm_rdna3", torch::kCUDA, &moe_gptq_gemm_rdna3); #endif // Custom attention op diff --git a/csrc/torch_bindings.cpp b/csrc/torch_bindings.cpp index c078222bca09..bcf0ce3e1c48 100644 --- a/csrc/torch_bindings.cpp +++ b/csrc/torch_bindings.cpp @@ -2,7 +2,6 @@ // cache.h, which is no longer included here after cache ops moved to // _C_stable_libtorch). #include -#include "cuda_utils.h" #include "ops.h" #include "core/registration.h" #include @@ -18,146 +17,6 @@ // https://docs.google.com/document/d/1_W62p8WJOQQUzPsJYa7s701JXt0qf2OfLub2sbkHOaU/edit#heading=h.ptttacy8y1u9 // https://github.com/pytorch/pytorch/blob/main/aten/src/ATen/native/README.md#annotations -TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) { - // vLLM custom ops - // - - ops.def( - "persistent_masked_m_silu_mul_quant(Tensor input, Tensor counts, Tensor! " - "y_q, Tensor! y_s," - "bool use_ue8m0) -> ()"); - ops.impl("persistent_masked_m_silu_mul_quant", torch::kCUDA, - &persistent_masked_m_silu_mul_quant); - - ops.def("weak_ref_tensor(Tensor input) -> Tensor"); - ops.impl("weak_ref_tensor", torch::kCUDA, &weak_ref_tensor); - - ops.def("get_cuda_view_from_cpu_tensor(Tensor cpu_tensor) -> Tensor"); - ops.impl("get_cuda_view_from_cpu_tensor", torch::kCPU, - &get_cuda_view_from_cpu_tensor); - - // Activation ops (quantized only — basic ops moved to _C_stable_libtorch) - ops.def( - "silu_and_mul_quant(Tensor! result, Tensor input, Tensor scale) -> ()"); - ops.impl("silu_and_mul_quant", torch::kCUDA, &silu_and_mul_quant); - - // Fused SiLU+Mul + per-block quantization - ops.def( - "silu_and_mul_per_block_quant(" - "Tensor! out, " - "Tensor input, " - "Tensor! scales, " - "int group_size, " - "Tensor? scale_ub=None, " - "bool is_scale_transposed=False) -> ()"); - ops.impl("silu_and_mul_per_block_quant", torch::kCUDA, - &silu_and_mul_per_block_quant); - - // Horizontally-fused DeepseekV4-MLA: per-head RMSNorm + GPT-J RoPE for Q, and - // GPT-J RoPE + UE8M0 FP8 quant + paged cache insert for KV, all in one - // kernel launch. Registered in _C_stable_libtorch. - - // Quantization ops -#ifndef USE_ROCM - - // Note about marlin kernel 'workspace' arguments: - // Technically these should be mutable since they are modified by the kernel. - // But since they are set back to zero once the kernel is finished we can - // hand wave and say that they have no net effect. - // - // The reason to mark 'workspace' as immutable is so that they don't interfere - // with using ScalarType arguments in the ops. If they are marked as mutable, - // pytorch throws an assert in - // 'torch._higher_order_ops._register_effectful_op' that prevents these - // kernels from being torch.compile'd. - // See the following document for more info on custom types and ops that use - // custom types: - // https://docs.google.com/document/d/18fBMPuOJ0fY5ZQ6YyrHUppw9FA332CpNtgB6SOIgyuA - - // Machete (Dense) Optimized Mixed Precision GEMM for Hopper. - ops.def( - "machete_supported_schedules(" - " ScalarType a_type," - " int b_type," - " ScalarType? maybe_group_scales_type," - " ScalarType? maybe_group_zeros_type," - " ScalarType? maybe_channel_scales_type," - " ScalarType? maybe_token_scales_type," - " ScalarType? maybe_out_type" - ") -> str[]"); - ops.def( - "machete_mm(" - " Tensor A," - " Tensor B," - " int b_type," - " ScalarType? out_type," - " Tensor? group_scales," - " Tensor? group_zeros," - " int? group_size," - " Tensor? channel_scales," - " Tensor? token_scales," - " str? schedule" - ") -> Tensor"); - ops.def( - "machete_prepack_B(" - " Tensor B," - " ScalarType a_type," - " int b_type," - " ScalarType? group_scales_type" - ") -> Tensor"); - // conditionally compiled so impl registration is in source file - - // Marlin Optimized Quantized GEMM (supports GPTQ, AWQ, FP8, NVFP4, MXFP4). - ops.def( - "marlin_gemm(Tensor a, Tensor? c_or_none, Tensor b_q_weight, " - "Tensor? b_bias_or_none,Tensor b_scales, " - "Tensor? a_scales, Tensor? global_scale, Tensor? b_zeros_or_none, " - "Tensor? " - "g_idx_or_none, Tensor? perm_or_none, Tensor workspace, int b_type_id, " - "SymInt size_m, SymInt size_n, SymInt size_k, bool is_k_full, " - "bool use_atomic_add, bool use_fp32_reduce, bool is_zp_float) -> Tensor"); - // conditionally compiled so impl registration is in source file - - // gptq_marlin repack from GPTQ. - ops.def( - "gptq_marlin_repack(Tensor b_q_weight, Tensor perm, " - "SymInt size_k, SymInt size_n, int num_bits, bool is_a_8bit) -> Tensor"); - // conditionally compiled so impl registrations are in source file - - // awq_marlin repack from AWQ. - ops.def( - "awq_marlin_repack(Tensor b_q_weight, SymInt size_k, " - "SymInt size_n, int num_bits, bool is_a_8bit) -> Tensor"); - // conditionally compiled so impl registrations are in source file - - // preprocess W-int4A-fp8 weight for marlin kernel - ops.def( - "marlin_int4_fp8_preprocess(Tensor qweight, " - "Tensor? qzeros_or_none, bool inplace) -> Tensor"); - // conditionally compiled so impl registrations are in source file - -#endif - -#ifndef USE_ROCM - // Expert-specialization mxfp8 blockscaled grouped quantization (SM100+). - ops.def( - "mxfp8_experts_quant(" - " Tensor input, Tensor problem_sizes, Tensor expert_offsets," - " Tensor blockscale_offsets, Tensor! quant_output, Tensor! scale_factor)" - " -> ()"); - // conditionally compiled so impl registration is in source file - - // Expert-specialization mxfp8 blockscaled grouped GEMM (SM100+). - ops.def( - "cutlass_mxfp8_grouped_mm(" - " Tensor a, Tensor b, Tensor sfa, Tensor sfb, Tensor! out," - " Tensor problem_sizes, Tensor expert_offsets, Tensor blockscale_offsets)" - " -> ()"); - // conditionally compiled so impl registration is in source file - -#endif -} - #ifdef USE_ROCM TORCH_LIBRARY_FRAGMENT(CONCAT(TORCH_EXTENSION_NAME, _custom_ar), custom_ar) { // Quick Reduce all-reduce kernels (ROCm-only; stays on legacy _C). @@ -177,18 +36,4 @@ TORCH_LIBRARY_FRAGMENT(CONCAT(TORCH_EXTENSION_NAME, _custom_ar), custom_ar) { } #endif -TORCH_LIBRARY_EXPAND(CONCAT(TORCH_EXTENSION_NAME, _cuda_utils), cuda_utils) { - // Cuda utils - - // Gets the specified device attribute. - cuda_utils.def("get_device_attribute(int attribute, int device_id) -> int"); - cuda_utils.impl("get_device_attribute", &get_device_attribute); - - // Gets the maximum shared memory per block device attribute. - cuda_utils.def( - "get_max_shared_memory_per_block_device_attribute(int device_id) -> int"); - cuda_utils.impl("get_max_shared_memory_per_block_device_attribute", - &get_max_shared_memory_per_block_device_attribute); -} - REGISTER_EXTENSION(TORCH_EXTENSION_NAME) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9b4227cdf655..b47853a06c73 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -99,9 +99,16 @@ ARG INSTALL_KV_CONNECTORS=false # prepare basic build environment FROM ${BUILD_BASE_IMAGE} AS base +ARG TARGETPLATFORM ARG CUDA_VERSION ARG PYTHON_VERSION ARG BUILD_OS +ARG USE_SCCACHE +ARG SCCACHE_DOWNLOAD_URL +ARG SCCACHE_ENDPOINT +ARG SCCACHE_BUCKET_NAME=vllm-build-sccache +ARG SCCACHE_REGION_NAME=us-west-2 +ARG SCCACHE_S3_NO_CREDENTIALS=0 ENV DEBIAN_FRONTEND=noninteractive @@ -148,11 +155,13 @@ RUN if [ "${BUILD_OS}" = "manylinux" ]; then \ sudo \ python3-pip \ libibverbs-dev \ - # Upgrade to GCC 10 to avoid https://gcc.gnu.org/bugzilla/show_bug.cgi?id=92519 - # as it was causing spam when compiling the CUTLASS kernels - gcc-10 \ - g++-10 \ - && update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 110 --slave /usr/bin/g++ g++ /usr/bin/g++-10 \ + # GCC 10 was previously pinned to suppress spurious -Wredundant-move warnings + # from CUTLASS (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=92519). That bug + # was fixed in GCC 11. GCC >= 11.3 is now required because PyTorch's C++20 headers + # (pytorch/pytorch#167929) are not compatible with GCC < 11.3. + gcc-11 \ + g++-11 \ + && update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 110 --slave /usr/bin/g++ g++ /usr/bin/g++-11 \ # Install python dev headers if available (needed for cmake FindPython on Ubuntu 24.04 # which ships cmake 3.28 and requires Development.SABIModule; silently skipped on # Ubuntu 20.04/22.04 where python3.x-dev is not available without a PPA) @@ -160,6 +169,27 @@ RUN if [ "${BUILD_OS}" = "manylinux" ]; then \ && rm -rf /var/lib/apt/lists/*; \ fi +# Install sccache once in base so Rust and CMake/CUDA build stages share the +# same binary and remote cache configuration. +RUN if [ "$USE_SCCACHE" = "1" ]; then \ + echo "Installing sccache..." \ + && case "${TARGETPLATFORM}" in \ + linux/arm64) SCCACHE_ARCH="aarch64" ;; \ + linux/amd64) SCCACHE_ARCH="x86_64" ;; \ + *) echo "Unsupported TARGETPLATFORM for sccache: ${TARGETPLATFORM}" >&2; exit 1 ;; \ + esac \ + && export SCCACHE_DOWNLOAD_URL="${SCCACHE_DOWNLOAD_URL:-https://github.com/mozilla/sccache/releases/download/v0.8.1/sccache-v0.8.1-${SCCACHE_ARCH}-unknown-linux-musl.tar.gz}" \ + && curl -L -o sccache.tar.gz ${SCCACHE_DOWNLOAD_URL} \ + && tar -xzf sccache.tar.gz \ + && sudo mv sccache-v0.8.1-${SCCACHE_ARCH}-unknown-linux-musl/sccache /usr/bin/sccache \ + && rm -rf sccache.tar.gz sccache-v0.8.1-${SCCACHE_ARCH}-unknown-linux-musl; \ + fi + +ENV SCCACHE_BUCKET=${USE_SCCACHE:+${SCCACHE_BUCKET_NAME}} +ENV SCCACHE_REGION=${USE_SCCACHE:+${SCCACHE_REGION_NAME}} +ENV SCCACHE_S3_NO_CREDENTIALS=${USE_SCCACHE:+${SCCACHE_S3_NO_CREDENTIALS}} +ENV SCCACHE_IDLE_TIMEOUT=${USE_SCCACHE:+0} + # Install uv and bootstrap /opt/venv. Both paths converge on /opt/venv so all # downstream stages stay distro-agnostic. RUN mkdir -p "${UV_PYTHON_INSTALL_DIR}" "${UV_CACHE_DIR}" "${UV_INSTALL_DIR}" \ @@ -218,6 +248,10 @@ COPY requirements/common.txt requirements/common.txt COPY requirements/cuda.txt requirements/cuda.txt COPY use_existing_torch.py use_existing_torch.py COPY pyproject.toml pyproject.toml +# nvidia-cutlass-dsl[cu13] installs -libs-base and -libs-cu13 wheels that +# share paths with different content. uv can extract them in either order, +# leaving base files that break CUDA 13 CuTe DSL JIT. +# TODO(mmangkad): Remove this after NVIDIA/cutlass#3259 is fixed. RUN --mount=type=cache,target=/opt/uv/cache \ if [ "$(echo $CUDA_VERSION | cut -d. -f1)" = "12" ]; then \ sed -i 's/^nvidia-cutlass-dsl\[cu13\]/nvidia-cutlass-dsl/' requirements/cuda.txt; \ @@ -234,6 +268,13 @@ RUN --mount=type=cache,target=/opt/uv/cache \ else \ uv pip install --python /opt/venv/bin/python3 -r requirements/cuda.txt \ --extra-index-url ${PYTORCH_CUDA_INDEX_BASE_URL}/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.'); \ + fi \ + && if [ "$(echo $CUDA_VERSION | cut -d. -f1)" = "13" ]; then \ + CUTLASS_DSL_VERSION=$(uv pip show --python /opt/venv/bin/python3 nvidia-cutlass-dsl 2>/dev/null | awk '/^Version:/{print $2}') && \ + if [ -n "$CUTLASS_DSL_VERSION" ]; then \ + uv pip install --python /opt/venv/bin/python3 --force-reinstall --no-deps \ + "nvidia-cutlass-dsl-libs-cu13==${CUTLASS_DSL_VERSION}"; \ + fi; \ fi # Track PyTorch lib versions used during build and match in downstream instances. @@ -248,63 +289,78 @@ RUN --mount=type=cache,target=/opt/uv/cache \ # Explicitly set the list to avoid issues with torch 2.2 # See https://github.com/pytorch/pytorch/pull/123243 # From versions.json: .torch.cuda_arch_list -ARG torch_cuda_arch_list='7.5 8.0 8.6 8.9 9.0 10.0 11.0 12.0+PTX' +# Do not add +PTX here: vLLM filters torch's top-level PTX flag when it +# converts global gencode flags into per-kernel arch lists. If a specific +# kernel needs PTX, add +PTX to that kernel's CMake arch list instead. +ARG torch_cuda_arch_list='7.5 8.0 8.6 8.9 9.0 10.0 11.0 12.0' ENV TORCH_CUDA_ARCH_LIST=${torch_cuda_arch_list} #################### BUILD BASE IMAGE #################### #################### RUST BUILD IMAGE #################### # Build the Rust frontend (`vllm-rs`) in a dedicated stage so the main wheel # build stage doesn't need the rust toolchain, protoc, or the rust source. -# This stage runs in parallel with csrc-build/extensions-build. -FROM ${BUILD_BASE_IMAGE} AS rust-build +# This stage reuses the Python environment from base and runs in parallel with +# csrc-build/extensions-build. +FROM base AS rust-build ARG BUILD_OS +ARG USE_SCCACHE +ARG SCCACHE_ENDPOINT -ENV DEBIAN_FRONTEND=noninteractive - -# Install a basic C toolchain (some rust crates compile C in their build.rs -# scripts) and unzip (used to extract the pinned protoc release below). +# Install native tools needed only for Rust/protoc builds. RUN if [ "${BUILD_OS}" = "manylinux" ]; then \ dnf install -y --setopt=install_weak_deps=False \ - ca-certificates curl git gcc gcc-c++ make unzip \ + make unzip \ && dnf clean all && rm -rf /var/cache/dnf; \ else \ apt-get update -y \ && apt-get install -y --no-install-recommends \ - ca-certificates curl git build-essential unzip \ + make unzip \ && rm -rf /var/lib/apt/lists/*; \ fi COPY tools/install_protoc.sh /tmp/install_protoc.sh RUN /tmp/install_protoc.sh && rm /tmp/install_protoc.sh -# Install rustup; the toolchain itself is pinned by rust-toolchain.toml. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain none -ENV PATH="/root/.cargo/bin:${PATH}" - WORKDIR /workspace -# Copy only the rust workspace — the binary is the sole artifact we need. +COPY requirements/build/rust.txt requirements/build/rust.txt +RUN --mount=type=cache,target=/opt/uv/cache \ + uv pip install --python /opt/venv/bin/python3 -r requirements/build/rust.txt + +# Copy only the Rust build inputs; build_rust.sh publishes artifacts needed +# by the wheel build stage. COPY rust rust COPY rust-toolchain.toml rust-toolchain.toml +COPY tools/build_rust.py tools/build_rust.py COPY build_rust.sh build_rust.sh # Cap cargo parallelism to avoid exhausting the CI host's open-file limit # (rustc spawns enough concurrent processes to hit RLIMIT_NOFILE otherwise). ENV CARGO_BUILD_JOBS=4 -# Build the release binary. Cache cargo registry/git and target/, but copy the -# binary out of the target/ cache mount so it persists into the image layer -# for later COPY --from=rust-build. -RUN --mount=type=cache,target=/root/.cargo/registry \ - --mount=type=cache,target=/root/.cargo/git \ - --mount=type=cache,target=/workspace/rust/target \ - VLLM_RS_TARGET_PATH=/workspace/vllm-rs bash build_rust.sh +# BuildKit can run this stage in parallel with csrc-build. Keep Rust on a +# separate local sccache daemon while sharing the same remote cache backend. +ENV SCCACHE_SERVER_PORT=4227 + +# Build the release artifacts. Cache cargo registry/git, but not target/, +# because stale target metadata can outlive source updates across BuildKit +# cache reuse. +RUN --mount=type=cache,target=/root/.cargo/registry,sharing=locked \ + --mount=type=cache,target=/root/.cargo/git,sharing=locked \ + --mount=type=secret,id=aws-credentials,target=/root/.aws/credentials,required=false \ + if [ "$USE_SCCACHE" = "1" ]; then \ + if [ -n "${SCCACHE_ENDPOINT}" ]; then export SCCACHE_ENDPOINT="${SCCACHE_ENDPOINT}"; fi; \ + export RUSTC_WRAPPER=sccache; \ + sccache --show-stats; \ + fi \ + && bash build_rust.sh \ + && if [ "$USE_SCCACHE" = "1" ]; then \ + sccache --show-stats; \ + fi #################### RUST BUILD IMAGE #################### #################### CSRC BUILD IMAGE #################### FROM base AS csrc-build -ARG TARGETPLATFORM ARG PIP_INDEX_URL UV_INDEX_URL ARG PIP_EXTRA_INDEX_URL UV_EXTRA_INDEX_URL @@ -342,6 +398,7 @@ RUN --mount=type=cache,target=/opt/uv/cache \ WORKDIR /workspace COPY pyproject.toml setup.py CMakeLists.txt ./ +COPY tools/build_rust.py tools/build_rust.py COPY cmake cmake/ COPY csrc csrc/ COPY vllm/envs.py vllm/envs.py @@ -355,11 +412,7 @@ ARG nvcc_threads=8 ENV NVCC_THREADS=$nvcc_threads ARG USE_SCCACHE -ARG SCCACHE_DOWNLOAD_URL ARG SCCACHE_ENDPOINT -ARG SCCACHE_BUCKET_NAME=vllm-build-sccache -ARG SCCACHE_REGION_NAME=us-west-2 -ARG SCCACHE_S3_NO_CREDENTIALS=0 # Flag to control whether to use pre-built vLLM wheels ARG VLLM_USE_PRECOMPILED="" @@ -389,22 +442,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/opt/uv/cache \ --mount=type=secret,id=aws-credentials,target=/root/.aws/credentials,required=false \ if [ "$USE_SCCACHE" = "1" ]; then \ - echo "Installing sccache..." \ - && case "${TARGETPLATFORM}" in \ - linux/arm64) SCCACHE_ARCH="aarch64" ;; \ - linux/amd64) SCCACHE_ARCH="x86_64" ;; \ - *) echo "Unsupported TARGETPLATFORM for sccache: ${TARGETPLATFORM}" >&2; exit 1 ;; \ - esac \ - && export SCCACHE_DOWNLOAD_URL="${SCCACHE_DOWNLOAD_URL:-https://github.com/mozilla/sccache/releases/download/v0.8.1/sccache-v0.8.1-${SCCACHE_ARCH}-unknown-linux-musl.tar.gz}" \ - && curl -L -o sccache.tar.gz ${SCCACHE_DOWNLOAD_URL} \ - && tar -xzf sccache.tar.gz \ - && sudo mv sccache-v0.8.1-${SCCACHE_ARCH}-unknown-linux-musl/sccache /usr/bin/sccache \ - && rm -rf sccache.tar.gz sccache-v0.8.1-${SCCACHE_ARCH}-unknown-linux-musl \ - && if [ ! -z ${SCCACHE_ENDPOINT} ] ; then export SCCACHE_ENDPOINT=${SCCACHE_ENDPOINT} ; fi \ - && export SCCACHE_BUCKET=${SCCACHE_BUCKET_NAME} \ - && export SCCACHE_REGION=${SCCACHE_REGION_NAME} \ - && export SCCACHE_S3_NO_CREDENTIALS=${SCCACHE_S3_NO_CREDENTIALS} \ - && export SCCACHE_IDLE_TIMEOUT=0 \ + if [ -n "${SCCACHE_ENDPOINT}" ]; then export SCCACHE_ENDPOINT="${SCCACHE_ENDPOINT}"; fi \ && export CMAKE_BUILD_TYPE=Release \ && export VLLM_USE_PRECOMPILED="${VLLM_USE_PRECOMPILED}" \ && export VLLM_PRECOMPILED_WHEEL_COMMIT="${VLLM_MERGE_BASE_COMMIT}" \ @@ -506,9 +544,10 @@ WORKDIR /workspace COPY --from=csrc-build /workspace/dist /precompiled-wheels COPY . . -# Drop the pre-built rust frontend binary into the source tree. setup.py -# detects it and ships it as-is, skipping the local cargo build. -COPY --from=rust-build /workspace/vllm-rs vllm/vllm-rs +# Drop the pre-built Rust artifacts into the source tree. setup.py detects +# them and ships them as-is, skipping the local Rust build. +COPY --from=rust-build /workspace/vllm/vllm-rs vllm/vllm-rs +COPY --from=rust-build /workspace/vllm/_rust_*.so vllm/ ARG GIT_REPO_CHECK=0 RUN --mount=type=bind,source=.git,target=.git \ @@ -535,9 +574,17 @@ RUN --mount=type=cache,target=/opt/uv/cache \ fi && \ python3 setup.py bdist_wheel --dist-dir=dist --py-limited-api=cp38 +# Record the wheel checksum so downstream stages can bust their layer cache +# when the wheel changes, without copying the wheel itself into the image. +RUN sha256sum dist/*.whl > dist/wheel.sha256 + # Copy extension wheels from extensions-build stage for later use COPY --from=extensions-build /tmp/ep_kernels_workspace/dist /tmp/ep_kernels_workspace/dist +# Record the EP kernels wheel checksum for the same cache-busting purpose. +RUN sha256sum /tmp/ep_kernels_workspace/dist/*.whl \ + > /tmp/ep_kernels_workspace/dist/wheels.sha256 + # Check the size of the wheel if RUN_WHEEL_CHECK is true COPY .buildkite/check-wheel-size.py check-wheel-size.py # sync the default value with .buildkite/check-wheel-size.py @@ -745,6 +792,10 @@ ENV VLLM_ENABLE_CUDA_COMPATIBILITY=0 ARG PYTORCH_CUDA_INDEX_BASE_URL COPY requirements/common.txt /tmp/common.txt COPY requirements/cuda.txt /tmp/requirements-cuda.txt +# nvidia-cutlass-dsl[cu13] installs -libs-base and -libs-cu13 wheels that +# share paths with different content. uv can extract them in either order, +# leaving base files that break CUDA 13 CuTe DSL JIT. +# TODO(mmangkad): Remove this after NVIDIA/cutlass#3259 is fixed. RUN --mount=type=cache,target=/opt/uv/cache \ if [ "$(echo $CUDA_VERSION | cut -d. -f1)" = "12" ]; then \ sed -i 's/^nvidia-cutlass-dsl\[cu13\]/nvidia-cutlass-dsl/' /tmp/requirements-cuda.txt; \ @@ -752,12 +803,19 @@ RUN --mount=type=cache,target=/opt/uv/cache \ fi && \ uv pip install --system -r /tmp/requirements-cuda.txt \ --extra-index-url ${PYTORCH_CUDA_INDEX_BASE_URL}/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.') && \ + if [ "$(echo $CUDA_VERSION | cut -d. -f1)" = "13" ]; then \ + CUTLASS_DSL_VERSION=$(uv pip show --system nvidia-cutlass-dsl 2>/dev/null | awk '/^Version:/{print $2}') && \ + if [ -n "$CUTLASS_DSL_VERSION" ]; then \ + uv pip install --system --force-reinstall --no-deps \ + "nvidia-cutlass-dsl-libs-cu13==${CUTLASS_DSL_VERSION}"; \ + fi; \ + fi && \ rm /tmp/requirements-cuda.txt /tmp/common.txt # Install FlashInfer JIT cache (requires CUDA-version-specific index URL) # https://docs.flashinfer.ai/installation.html # From versions.json: .flashinfer.version -ARG FLASHINFER_VERSION=0.6.12 +ARG FLASHINFER_VERSION=0.6.13 RUN --mount=type=cache,target=/opt/uv/cache \ uv pip install --system flashinfer-jit-cache==${FLASHINFER_VERSION} \ --index-url https://flashinfer.ai/whl/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.') @@ -796,7 +854,7 @@ RUN --mount=type=cache,target=/opt/uv/cache \ else \ BITSANDBYTES_VERSION="${BITSANDBYTES_VERSION_X86}"; \ fi; \ - uv pip install --system accelerate modelscope \ + uv pip install --system accelerate 'modelscope<1.38' \ "bitsandbytes>=${BITSANDBYTES_VERSION}" "timm${TIMM_VERSION}" "runai-model-streamer[s3,gcs,azure]${RUNAI_MODEL_STREAMER_VERSION}" # ============================================================ @@ -814,6 +872,11 @@ ARG PYTORCH_NIGHTLY # Install vLLM wheel first, so that torch etc will be installed. # Check whether to install torch nightly instead of release for this build. COPY --from=base /workspace/torch_lib_versions.txt torch_lib_versions.txt +# Copy only the wheel checksum (a few bytes) so a wheel change invalidates this +# install layer. The wheel itself is bind-mounted below and never enters the +# image. Without this the bind mount is not part of the layer cache key, so a +# warm BuildKit agent can skip the install and ship a stale wheel. +COPY --from=build /workspace/dist/wheel.sha256 /tmp/vllm-wheel.sha256 RUN --mount=type=bind,from=build,src=/workspace/dist,target=/vllm-workspace/dist \ --mount=type=cache,target=/opt/uv/cache \ if [ "${PYTORCH_NIGHTLY}" = "1" ]; then \ @@ -836,18 +899,27 @@ uv pip list # Pytorch now installs NVSHMEM, setting LD_LIBRARY_PATH ENV LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH -# Install EP kernels wheels (DeepEP) that have been built in the `build` stage +# Install EP kernels wheels (DeepEP) that have been built in the `build` stage. +# As with the vLLM wheel above, copy only the checksum to bust the layer cache +# and bind-mount the wheel for the actual install to keep it out of the image. +COPY --from=build /tmp/ep_kernels_workspace/dist/wheels.sha256 /tmp/ep-kernels-wheels.sha256 RUN --mount=type=bind,from=build,src=/tmp/ep_kernels_workspace/dist,target=/vllm-workspace/ep_kernels/dist \ --mount=type=cache,target=/opt/uv/cache \ uv pip install --system ep_kernels/dist/*.whl --verbose \ --extra-index-url ${PYTORCH_CUDA_INDEX_BASE_URL}/cu$(echo $CUDA_VERSION | cut -d. -f1,2 | tr -d '.') -# Download FlashInfer precompiled cubins AFTER all pip installs are done. -# This must run after the vLLM wheel and EP kernels installs above, because -# those can reinstall/touch flashinfer packages. Downloading cubins earlier -# (in the flashinfer-jit-cache layer) causes ~2.5 GB of layer duplication -# when a later pip install overwrites flashinfer package files. -RUN flashinfer show-config && flashinfer download-cubin +# nvidia-cutlass-dsl[cu13] installs -libs-base and -libs-cu13 wheels that +# share paths with different content. Force -libs-cu13 last after runtime +# dependency installs so uv cannot leave base files behind. +# TODO(mmangkad): Remove this after NVIDIA/cutlass#3259 is fixed. +RUN --mount=type=cache,target=/opt/uv/cache \ + if [ "$(echo $CUDA_VERSION | cut -d. -f1)" = "13" ]; then \ + CUTLASS_DSL_VERSION=$(uv pip show --system nvidia-cutlass-dsl 2>/dev/null | awk '/^Version:/{print $2}') && \ + if [ -n "$CUTLASS_DSL_VERSION" ]; then \ + uv pip install --system --force-reinstall --no-deps \ + "nvidia-cutlass-dsl-libs-cu13==${CUTLASS_DSL_VERSION}"; \ + fi; \ + fi # CUDA image changed from /usr/local/nvidia to /usr/local/cuda in 12.8 but will # return to /usr/local/nvidia in 13.0 to allow container providers to mount drivers @@ -957,7 +1029,8 @@ ARG PIP_EXTRA_INDEX_URL UV_EXTRA_INDEX_URL ENV UV_HTTP_TIMEOUT=500 # install kv_connectors if requested -ARG torch_cuda_arch_list='7.5 8.0 8.6 8.9 9.0 10.0 11.0 12.0+PTX' +# Do not add +PTX here; see the main TORCH_CUDA_ARCH_LIST comment above. +ARG torch_cuda_arch_list='7.5 8.0 8.6 8.9 9.0 10.0 11.0 12.0' ENV TORCH_CUDA_ARCH_LIST=${torch_cuda_arch_list} RUN --mount=type=cache,target=/opt/uv/cache \ --mount=type=bind,source=requirements/kv_connectors.txt,target=/tmp/kv_connectors.txt,ro \ diff --git a/docker/Dockerfile.cpu b/docker/Dockerfile.cpu index e185c00cb2fa..a528ffbd8d1d 100644 --- a/docker/Dockerfile.cpu +++ b/docker/Dockerfile.cpu @@ -25,7 +25,6 @@ FROM ubuntu:22.04 AS base-common WORKDIR /workspace ARG PYTHON_VERSION=3.12 -ARG PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/cpu" ARG max_jobs=32 ENV MAX_JOBS=${max_jobs} @@ -53,8 +52,6 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH" ENV UV_HTTP_TIMEOUT=500 # Install Python dependencies -ENV PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL} -ENV UV_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL} ENV UV_INDEX_STRATEGY="unsafe-best-match" ENV UV_LINK_MODE="copy" @@ -64,7 +61,7 @@ COPY requirements/cpu.txt requirements/cpu.txt RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install --upgrade pip && \ - uv pip install -r requirements/cpu.txt + uv pip install -r requirements/cpu.txt --torch-backend cpu ARG TARGETARCH ENV TARGETARCH=${TARGETARCH} @@ -93,35 +90,34 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update -y \ && apt-get install -y --no-install-recommends \ - ca-certificates curl git build-essential unzip \ + ca-certificates curl git build-essential unzip python3 python3-pip \ && rm -rf /var/lib/apt/lists/* COPY tools/install_protoc.sh /tmp/install_protoc.sh RUN /tmp/install_protoc.sh && rm /tmp/install_protoc.sh -# Install rustup; the toolchain itself is pinned by rust-toolchain.toml. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain none -ENV PATH="/root/.cargo/bin:${PATH}" - WORKDIR /workspace -# Copy only the rust workspace — the binary is the sole artifact we need. +COPY requirements/build/rust.txt requirements/build/rust.txt +RUN python3 -m pip install --no-cache-dir -r requirements/build/rust.txt + +# Copy only the Rust build inputs; build_rust.sh publishes artifacts needed +# by the wheel build stage. COPY rust rust COPY rust-toolchain.toml rust-toolchain.toml +COPY tools/build_rust.py tools/build_rust.py COPY build_rust.sh build_rust.sh # Cap cargo parallelism to avoid exhausting the CI host's open-file limit # (rustc spawns enough concurrent processes to hit RLIMIT_NOFILE otherwise). ENV CARGO_BUILD_JOBS=4 -# Build the release binary. Cache cargo registry/git and target/, but copy the -# binary out of the target/ cache mount so it persists into the image layer -# for later COPY --from=rust-build. -RUN --mount=type=cache,target=/root/.cargo/registry \ - --mount=type=cache,target=/root/.cargo/git \ - --mount=type=cache,target=/workspace/rust/target \ - VLLM_RS_TARGET_PATH=/workspace/vllm-rs bash build_rust.sh +# Build the release artifacts. Cache cargo registry/git, but not target/, +# because stale target metadata can outlive source updates across BuildKit +# cache reuse. +RUN --mount=type=cache,target=/root/.cargo/registry,sharing=locked \ + --mount=type=cache,target=/root/.cargo/git,sharing=locked \ + bash build_rust.sh ######################### BUILD IMAGE ######################### FROM base AS vllm-build @@ -150,13 +146,14 @@ RUN if [ "$TARGETARCH" = "arm64" ] && [ "$VLLM_CPU_X86" != "0" ]; then \ COPY requirements/build/cpu.txt requirements/build/cpu.txt RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install -r requirements/build/cpu.txt + uv pip install -r requirements/build/cpu.txt --torch-backend cpu COPY . . -# Drop the pre-built rust frontend binary into the source tree. setup.py -# detects it and ships it as-is, skipping the local cargo build. -COPY --from=rust-build /workspace/vllm-rs vllm/vllm-rs +# Drop the pre-built Rust artifacts into the source tree. setup.py detects +# them and ships them as-is, skipping the local Rust build. +COPY --from=rust-build /workspace/vllm/vllm-rs vllm/vllm-rs +COPY --from=rust-build /workspace/vllm/_rust_*.so vllm/ RUN if [ "$GIT_REPO_CHECK" != 0 ]; then bash tools/check_repo.sh ; fi @@ -168,6 +165,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \ ######################### TRITON-CPU BUILD IMAGE ######################### FROM base AS vllm-triton-cpu-build +# Support for cross-compilation with x86 ISA including AVX2 and AVX512: docker build --build-arg VLLM_CPU_X86="true" ... +# Re-declared here because this stage is `FROM base` (not `vllm-build`), so it +# does not inherit the ARG/ENV defined there. Without it, the guard below would +# see an empty value and build triton-cpu on non-x86 targets (e.g. arm64). +ARG VLLM_CPU_X86=0 + WORKDIR /vllm-workspace RUN mkdir dist @@ -187,29 +190,19 @@ FROM base AS vllm-test-deps WORKDIR /vllm-workspace -# Copy test requirements -COPY requirements/test/cuda.in requirements/test/cpu.in - -RUN \ - sed -i '/mamba_ssm/d' requirements/test/cpu.in && \ - remove_packages_not_supported_on_aarch64() { \ - case "$(uname -m)" in \ - aarch64|arm64) \ - sed -i '/decord/d' requirements/test/cpu.in; \ - sed -i '/terratorch/d' requirements/test/cpu.in; \ - ;; \ - esac; \ - }; \ - remove_packages_not_supported_on_aarch64 && \ - sed -i 's/^torch==.*/torch==2.11.0/g' requirements/test/cpu.in && \ - sed -i 's/torchaudio.*/torchaudio/g' requirements/test/cpu.in && \ - sed -i 's/torchvision.*/torchvision/g' requirements/test/cpu.in && \ - # Related issue: https://github.com/vllm-project/vllm/pull/38800#issuecomment-4228314305 - sed -i 's/^sentence-transformers.*/sentence-transformers==5.3.0/g' requirements/test/cpu.in && \ - uv pip compile requirements/test/cpu.in -o requirements/test/cpu.txt --index-strategy unsafe-best-match --torch-backend cpu +# Test requirements are compiled from requirements/test/cuda.in into +# requirements/test/cpu.txt by the pip-compile-cpu pre-commit hook, which +# resolves CPU wheels via uv's --torch-backend cpu. +COPY requirements/test/cpu.txt requirements/test/cpu.txt + +# cpu.txt is compiled for x86_64, so platform markers are resolved away. Drop +# packages unavailable on aarch64 (decord, terratorch) for arm builds. +RUN case "$(uname -m)" in \ + aarch64|arm64) sed -i '/^decord==/d; /^terratorch==/d' requirements/test/cpu.txt ;; \ + esac RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install -r requirements/test/cpu.txt + uv pip install -r requirements/test/cpu.txt --torch-backend cpu ######################### DEV IMAGE ######################### FROM vllm-build AS vllm-dev @@ -235,7 +228,7 @@ COPY --from=vllm-test-deps /vllm-workspace/requirements/test/cpu.txt requirement RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install -r requirements/lint.txt && \ - uv pip install -r requirements/test/cpu.txt && \ + uv pip install -r requirements/test/cpu.txt --torch-backend cpu && \ pre-commit install --hook-type pre-commit --hook-type commit-msg ENTRYPOINT ["bash"] @@ -269,6 +262,11 @@ ENV HF_HUB_DOWNLOAD_TIMEOUT 60 ######################### RELEASE IMAGE ######################### FROM base AS vllm-openai +# Re-declared here because this stage is `FROM base` (not `vllm-build`), so the +# RUN below that gates the triton-cpu wheel install on $VLLM_CPU_X86 would +# otherwise see an empty value and try to install it on non-x86 targets. +ARG VLLM_CPU_X86=0 + WORKDIR /vllm-workspace RUN --mount=type=cache,target=/root/.cache/uv \ @@ -300,6 +298,12 @@ LABEL ai.vllm.build.cpu-x86="${VLLM_CPU_X86:-false}" LABEL ai.vllm.build.cpu-arm-bf16="${VLLM_CPU_ARM_BF16:-false}" LABEL ai.vllm.build.python-version="${PYTHON_VERSION:-3.12}" +# Copy the examples directory (including the chat/tool templates) so it is +# present in the released image, as the CUDA image ships it too. The vllm-test +# stage above adds examples/ for testing only, so without this the published +# vllm-openai-cpu image would not ship examples/*.jinja. +COPY examples examples + ENTRYPOINT ["vllm", "serve"] diff --git a/docker/Dockerfile.nightly_torch b/docker/Dockerfile.nightly_torch deleted file mode 100644 index 4fbfe832ac3c..000000000000 --- a/docker/Dockerfile.nightly_torch +++ /dev/null @@ -1,325 +0,0 @@ -####### -# -# THIS FILE IS DEPRECATED AND WILL BE REMOVED SHORTLY -# -# Please use the standard Dockerfile with PYTORCH_NIGHTLY=1 instead -# -####### - -# The vLLM Dockerfile is used to construct vLLM image against torch nightly that can be directly used for testing - -# for torch nightly, cuda >=12.6 is required, -# use 12.8 due to FlashAttention issue with cuda 12.6 (https://github.com/vllm-project/vllm/issues/15435#issuecomment-2775924628) -ARG CUDA_VERSION=12.8.0 -# -#################### BASE BUILD IMAGE #################### -# prepare basic build environment -FROM nvidia/cuda:${CUDA_VERSION}-devel-ubuntu22.04 AS base -ARG CUDA_VERSION=12.8.0 -ARG PYTHON_VERSION=3.12 -ARG TARGETPLATFORM -ENV DEBIAN_FRONTEND=noninteractive -# Install Python and other dependencies -RUN apt-get update -y \ - && apt-get install -y ccache software-properties-common git curl sudo \ - && for i in 1 2 3; do \ - add-apt-repository -y ppa:deadsnakes/ppa && break || \ - { echo "Attempt $i failed, retrying in 5s..."; sleep 5; }; \ - done \ - && apt-get update -y \ - && apt-get install -y python${PYTHON_VERSION} python${PYTHON_VERSION}-dev python${PYTHON_VERSION}-venv \ - && update-alternatives --install /usr/bin/python3 python3 /usr/bin/python${PYTHON_VERSION} 1 \ - && update-alternatives --set python3 /usr/bin/python${PYTHON_VERSION} \ - && ln -sf /usr/bin/python${PYTHON_VERSION}-config /usr/bin/python3-config \ - && curl -sS https://bootstrap.pypa.io/get-pip.py | python${PYTHON_VERSION} \ - && python3 --version \ - && python3 -m pip --version -# Install uv for faster pip installs -RUN --mount=type=cache,target=/root/.cache/uv \ - python3 -m pip install uv - -# This timeout (in seconds) is necessary when installing some dependencies via uv since it's likely to time out -# Reference: https://github.com/astral-sh/uv/pull/1694 -ENV UV_HTTP_TIMEOUT=500 - -# Upgrade to GCC 10 to avoid https://gcc.gnu.org/bugzilla/show_bug.cgi?id=92519 -# as it was causing spam when compiling the CUTLASS kernels -RUN apt-get install -y gcc-10 g++-10 -RUN update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 110 --slave /usr/bin/g++ g++ /usr/bin/g++-10 -RUN < torch_build_versions.txt -RUN cat torch_build_versions.txt - -# cuda arch list used by torch -# can be useful for `test` -# explicitly set the list to avoid issues with torch 2.2 -# see https://github.com/pytorch/pytorch/pull/123243 - -#################### BASE BUILD IMAGE #################### - -#################### RUST BUILD IMAGE #################### -# Build the Rust frontend (`vllm-rs`) in a dedicated stage so the wheel build -# stage doesn't need the rust toolchain or protoc. -FROM ubuntu:22.04 AS rust-build -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update -y \ - && apt-get install -y --no-install-recommends \ - ca-certificates curl git build-essential unzip \ - && rm -rf /var/lib/apt/lists/* - -COPY tools/install_protoc.sh /tmp/install_protoc.sh -RUN /tmp/install_protoc.sh && rm /tmp/install_protoc.sh - -# Install rustup; the toolchain itself is pinned by rust-toolchain.toml. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain none -ENV PATH="/root/.cargo/bin:${PATH}" - -WORKDIR /workspace - -COPY rust rust -COPY rust-toolchain.toml rust-toolchain.toml -COPY build_rust.sh build_rust.sh - -# Cap cargo parallelism to avoid exhausting the CI host's open-file limit -# (rustc spawns enough concurrent processes to hit RLIMIT_NOFILE otherwise). -ENV CARGO_BUILD_JOBS=4 - -RUN --mount=type=cache,target=/root/.cargo/registry \ - --mount=type=cache,target=/root/.cargo/git \ - --mount=type=cache,target=/workspace/rust/target \ - VLLM_RS_TARGET_PATH=/workspace/vllm-rs bash build_rust.sh -#################### RUST BUILD IMAGE #################### - -#################### WHEEL BUILD IMAGE #################### -FROM base AS build -ARG TARGETPLATFORM - -# This timeout (in seconds) is necessary when installing some dependencies via uv since it's likely to time out -# Reference: https://github.com/astral-sh/uv/pull/1694 -ENV UV_HTTP_TIMEOUT=500 - -COPY . . - -# Drop the pre-built rust frontend binary into the source tree. setup.py -# detects it and ships it as-is, skipping the local cargo build. -COPY --from=rust-build /workspace/vllm-rs vllm/vllm-rs - -RUN python3 use_existing_torch.py - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system -r requirements/build/cuda.txt - -ARG GIT_REPO_CHECK=0 -RUN --mount=type=bind,source=.git,target=.git \ - if [ "$GIT_REPO_CHECK" != "0" ]; then bash tools/check_repo.sh ; fi - -# Max jobs used by Ninja to build extensions -ARG max_jobs=16 -ENV MAX_JOBS=${max_jobs} -ARG nvcc_threads=2 -ENV NVCC_THREADS=$nvcc_threads - -ARG USE_SCCACHE -ARG SCCACHE_BUCKET_NAME=vllm-build-sccache -ARG SCCACHE_REGION_NAME=us-west-2 -ARG SCCACHE_S3_NO_CREDENTIALS=0 - -# if USE_SCCACHE is set, use sccache to speed up compilation -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=.git,target=.git \ - if [ "$USE_SCCACHE" = "1" ]; then \ - echo "Installing sccache..." \ - && curl -L -o sccache.tar.gz https://github.com/mozilla/sccache/releases/download/v0.8.1/sccache-v0.8.1-x86_64-unknown-linux-musl.tar.gz \ - && tar -xzf sccache.tar.gz \ - && sudo mv sccache-v0.8.1-x86_64-unknown-linux-musl/sccache /usr/bin/sccache \ - && rm -rf sccache.tar.gz sccache-v0.8.1-x86_64-unknown-linux-musl \ - && export SCCACHE_BUCKET=${SCCACHE_BUCKET_NAME} \ - && export SCCACHE_REGION=${SCCACHE_REGION_NAME} \ - && export SCCACHE_S3_NO_CREDENTIALS=${SCCACHE_S3_NO_CREDENTIALS} \ - && export SCCACHE_IDLE_TIMEOUT=0 \ - && export CMAKE_BUILD_TYPE=Release \ - && sccache --show-stats \ - && python3 setup.py bdist_wheel --dist-dir=dist --py-limited-api=cp38 \ - && sccache --show-stats; \ - fi - -ENV CCACHE_DIR=/root/.cache/ccache -RUN --mount=type=cache,target=/root/.cache/ccache \ - --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=.git,target=.git \ - if [ "$USE_SCCACHE" != "1" ]; then \ - # Clean any existing CMake artifacts - rm -rf .deps && \ - mkdir -p .deps && \ - python3 setup.py bdist_wheel --dist-dir=dist --py-limited-api=cp38; \ - fi - -#################### WHEEL BUILD IMAGE #################### - -################### VLLM INSTALLED IMAGE #################### -# Setup clean environment for vLLM and its dependencies for test and api server using ubuntu22.04 with AOT flashinfer -FROM nvidia/cuda:${CUDA_VERSION}-devel-ubuntu22.04 AS vllm-base -# prepare for environment starts -ARG CUDA_VERSION=12.8.0 -ARG PYTHON_VERSION=3.12 -WORKDIR /vllm-workspace -ENV DEBIAN_FRONTEND=noninteractive -ARG TARGETPLATFORM - -RUN PYTHON_VERSION_STR=$(echo ${PYTHON_VERSION} | sed 's/\.//g') && \ - echo "export PYTHON_VERSION_STR=${PYTHON_VERSION_STR}" >> /etc/environment - -# Install Python and other dependencies -RUN apt-get update -y \ - && apt-get install -y ccache software-properties-common git curl wget sudo vim python3-pip \ - && apt-get install -y ffmpeg libsm6 libxext6 libgl1 \ - && for i in 1 2 3; do \ - add-apt-repository -y ppa:deadsnakes/ppa && break || \ - { echo "Attempt $i failed, retrying in 5s..."; sleep 5; }; \ - done \ - && apt-get update -y \ - && apt-get install -y python${PYTHON_VERSION} python${PYTHON_VERSION}-dev python${PYTHON_VERSION}-venv libibverbs-dev \ - && update-alternatives --install /usr/bin/python3 python3 /usr/bin/python${PYTHON_VERSION} 1 \ - && update-alternatives --set python3 /usr/bin/python${PYTHON_VERSION} \ - && ln -sf /usr/bin/python${PYTHON_VERSION}-config /usr/bin/python3-config \ - && curl -sS https://bootstrap.pypa.io/get-pip.py | python${PYTHON_VERSION} \ - && python3 --version && python3 -m pip --version - -RUN --mount=type=cache,target=/root/.cache/uv \ - python3 -m pip install uv - -# This timeout (in seconds) is necessary when installing some dependencies via uv since it's likely to time out -# Reference: https://github.com/astral-sh/uv/pull/1694 -ENV UV_HTTP_TIMEOUT=500 - -# Workaround for https://github.com/openai/triton/issues/2507 and -# https://github.com/pytorch/pytorch/issues/107960 -- hopefully -# this won't be needed for future versions of this docker image -# or future versions of triton. -RUN ldconfig /usr/local/cuda-$(echo $CUDA_VERSION | cut -d. -f1,2)/compat/ - -# get the nightly torch version used in the build to make sure the version is the same -COPY --from=base /workspace/torch_build_versions.txt ./torch_build_versions.txt - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system $(cat torch_build_versions.txt | xargs) --index-url https://download.pytorch.org/whl/nightly/cu128 - -# install the vllm wheel -RUN --mount=type=bind,from=build,src=/workspace/dist,target=/vllm-workspace/vllm-dist \ - --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system vllm-dist/*.whl --verbose - -ARG torch_cuda_arch_list='8.0;8.6;8.9;9.0' - -# install package for build flashinfer -# see issue: https://github.com/flashinfer-ai/flashinfer/issues/738 -RUN pip install setuptools==75.6.0 packaging==23.2 ninja==1.11.1.3 build==1.2.2.post1 - - -# build flashinfer for torch nightly from source around 10 mins -# release version: v0.6.12 -# todo(elainewy): cache flashinfer build result for faster build -ENV CCACHE_DIR=/root/.cache/ccache -RUN --mount=type=cache,target=/root/.cache/ccache \ - --mount=type=cache,target=/root/.cache/uv \ - echo "git clone flashinfer..." \ - && git clone --depth 1 --branch v0.6.12 --recursive https://github.com/flashinfer-ai/flashinfer.git \ - && cd flashinfer \ - && git submodule update --init --recursive \ - && echo "finish git clone flashinfer..." \ - && rm -rf build \ - && export TORCH_CUDA_ARCH_LIST=${torch_cuda_arch_list} \ - && FLASHINFER_ENABLE_AOT=1 python3 setup.py bdist_wheel --dist-dir=../flashinfer-dist --verbose \ - && cd .. \ - && rm -rf flashinfer - -# install flashinfer -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system flashinfer-dist/*.whl --verbose - -# install common packages -COPY requirements/common.txt requirements/common.txt -COPY use_existing_torch.py use_existing_torch.py -COPY pyproject.toml pyproject.toml - -COPY examples examples -COPY benchmarks benchmarks -COPY ./vllm/collect_env.py . - -RUN python3 use_existing_torch.py -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system -r requirements/common.txt - -################### VLLM INSTALLED IMAGE #################### - - -#################### UNITTEST IMAGE ############################# -FROM vllm-base as test -COPY tests/ tests/ - -# install build and runtime dependencies without stable torch version -COPY requirements/test/nightly-torch.txt requirements/test/nightly-torch.txt - -# This timeout (in seconds) is necessary when installing some dependencies via uv since it's likely to time out -# Reference: https://github.com/astral-sh/uv/pull/1694 -ENV UV_HTTP_TIMEOUT=500 - -# install development dependencies (for testing) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system -e tests/vllm_test_utils - -# enable fast downloads from hf (for testing) -ENV HF_XET_HIGH_PERFORMANCE 1 - -# increase timeout for hf downloads (for testing) -ENV HF_HUB_DOWNLOAD_TIMEOUT 60 - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system -r requirements/test/nightly-torch.txt - -# Logging to confirm the torch versions -RUN pip freeze | grep -E 'torch|vllm|flashinfer' - -# Logging to confirm all the packages are installed -RUN pip freeze - -#################### UNITTEST IMAGE ############################# diff --git a/docker/Dockerfile.ppc64le b/docker/Dockerfile.ppc64le index 845d900c39cd..f0363d43be20 100644 --- a/docker/Dockerfile.ppc64le +++ b/docker/Dockerfile.ppc64le @@ -1,275 +1,80 @@ +# Base UBI image ARG BASE_UBI_IMAGE_TAG=9.6-1754584681 ############################################################### -# Stage to build openblas +# BUILDER STAGE # ############################################################### -FROM registry.access.redhat.com/ubi9/ubi-minimal:${BASE_UBI_IMAGE_TAG} AS openblas-builder - -ARG MAX_JOBS -ARG OPENBLAS_VERSION=0.3.30 -RUN microdnf install -y dnf && dnf install -y gcc-toolset-14 make wget unzip \ - && source /opt/rh/gcc-toolset-14/enable \ - && wget https://github.com/OpenMathLib/OpenBLAS/releases/download/v$OPENBLAS_VERSION/OpenBLAS-$OPENBLAS_VERSION.zip \ - && unzip OpenBLAS-$OPENBLAS_VERSION.zip \ - && cd OpenBLAS-$OPENBLAS_VERSION \ - && make -j${MAX_JOBS} TARGET=POWER9 BINARY=64 USE_OPENMP=1 USE_THREAD=1 NUM_THREADS=120 DYNAMIC_ARCH=1 INTERFACE64=0 \ - && cd /tmp && touch control - - -############################################################### -# base stage with dependencies coming from centos mirrors -############################################################### -FROM registry.access.redhat.com/ubi9/ubi-minimal:${BASE_UBI_IMAGE_TAG} AS centos-deps-builder -RUN microdnf install -y dnf && \ - dnf install -y https://mirror.stream.centos.org/9-stream/BaseOS/`arch`/os/Packages/centos-gpg-keys-9.0-26.el9.noarch.rpm \ - https://mirror.stream.centos.org/9-stream/BaseOS/`arch`/os/Packages/centos-stream-repos-9.0-26.el9.noarch.rpm \ - https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm && \ - dnf config-manager --set-enabled crb - -RUN dnf install -y openjpeg2-devel lcms2-devel tcl-devel tk-devel fribidi-devel yajl-devel && \ - dnf remove -y centos-gpg-keys-9.0-24.el9.noarch centos-stream-repos-9.0-26.el9.noarch - - -############################################################### -# base stage with basic dependencies -############################################################### - -FROM centos-deps-builder AS base-builder +FROM registry.access.redhat.com/ubi9/ubi-minimal:${BASE_UBI_IMAGE_TAG} AS builder-base +ARG VLLM_VERSION="0.22.1" ARG PYTHON_VERSION=3.12 -ARG OPENBLAS_VERSION=0.3.30 - -# Set Environment Variables for venv, cargo & openblas -ENV VIRTUAL_ENV=/opt/vllm -ENV PATH=${VIRTUAL_ENV}/bin:/root/.cargo/bin:$PATH -ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/ -ENV LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib64:/usr/local/lib:/usr/lib64:/usr/lib -ENV UV_LINK_MODE=copy +ARG VLLM_TARGET_DEVICE=cpu -# install gcc-13, python, rust, openblas -# Note: A symlink for libatomic.so is created for gcc-13 (linker fails to find libatomic otherwise - reqd. for sentencepiece) -# Note: A dummy file 'control' is created in /tmp/ to artificially create dependencies between stages when building stages in parallel -# when `--jobs=` is passed with podman build command - -COPY --from=openblas-builder /tmp/control /dev/null - -RUN --mount=type=bind,from=openblas-builder,source=/OpenBLAS-$OPENBLAS_VERSION/,target=/openblas/,rw \ - dnf install -y openssl-devel \ - && dnf install -y \ - git tar gcc-toolset-14 automake libtool \ - pkgconfig xsimd zeromq-devel kmod findutils protobuf* \ - libtiff-devel libjpeg-devel zlib-devel freetype-devel libwebp-devel \ - harfbuzz-devel libraqm-devel libimagequant-devel libxcb-devel \ - python${PYTHON_VERSION}-devel python${PYTHON_VERSION}-pip clang-devel \ - && dnf clean all \ - && PREFIX=/usr/local make -C /openblas install \ - && ln -sf /usr/lib64/libatomic.so.1 /usr/lib64/libatomic.so \ +USER root +WORKDIR /root + +ENV HOME=/root \ + WHEEL_DIR=/wheelsdir \ + VIRTUAL_ENV=/opt/vllm \ + GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 \ + CARGO_HOME=/root/.cargo \ + RUSTUP_HOME=/root/.rustup \ + UV_CACHE_DIR=$HOME/.cache/uv \ + PATH=/root/.cargo/bin:/root/.rustup/bin:${VIRTUAL_ENV}/bin:$PATH + +RUN echo "DEBUG: VLLM_VERSION=${VLLM_VERSION}" +RUN --mount=type=cache,target=/var/cache/dnf \ + microdnf install -y \ + python${PYTHON_VERSION}-devel python${PYTHON_VERSION}-pip \ && python${PYTHON_VERSION} -m venv ${VIRTUAL_ENV} \ - && python -m pip install -U pip uv \ - && uv pip install wheel build "setuptools<70" setuptools_scm setuptools_rust meson-python 'cmake<4' ninja cython scikit_build_core scikit_build \ - && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ - && cd /tmp && touch control - - -############################################################### -# Stage to build torch family -############################################################### + && python${PYTHON_VERSION} -m pip install -U pip uv --no-cache -FROM base-builder AS torch-builder +# Important: Copy only bare minimum required for the script to run +COPY requirements/ requirements/ +COPY pyproject.toml ./ -ARG MAX_JOBS -ARG TORCH_VERSION=2.7.0 -ARG _GLIBCXX_USE_CXX11_ABI=1 -ARG OPENBLAS_VERSION=0.3.30 +# The script is expected to install whatever python dependencies are missing +# as well as whatever system libraries need to be installed from source +COPY build_vllm_*.sh ./ RUN --mount=type=cache,target=/root/.cache/uv \ - source /opt/rh/gcc-toolset-14/enable && \ - git clone --recursive https://github.com/pytorch/pytorch.git -b v${TORCH_VERSION} && \ - cd pytorch && \ - uv pip install -r requirements.txt && \ - python setup.py develop && \ - rm -f dist/torch*+git*whl && \ - MAX_JOBS=${MAX_JOBS:-$(nproc)} \ - PYTORCH_BUILD_VERSION=${TORCH_VERSION} PYTORCH_BUILD_NUMBER=1 uv build --wheel --out-dir /torchwheels/ - -ARG TORCHVISION_VERSION=0.22.0 -ARG TORCHVISION_USE_NVJPEG=0 -ARG TORCHVISION_USE_FFMPEG=0 -RUN --mount=type=cache,target=/root/.cache/uv \ - source /opt/rh/gcc-toolset-14/enable && \ - git clone --recursive https://github.com/pytorch/vision.git -b v${TORCHVISION_VERSION} && \ - cd vision && \ - MAX_JOBS=${MAX_JOBS:-$(nproc)} \ - BUILD_VERSION=${TORCHVISION_VERSION} \ - uv build --wheel --out-dir /torchwheels/ --no-build-isolation - -ARG TORCHAUDIO_VERSION=2.7.0 -ARG BUILD_SOX=1 -ARG BUILD_KALDI=1 -ARG BUILD_RNNT=1 -ARG USE_FFMPEG=0 -ARG USE_ROCM=0 -ARG USE_CUDA=0 -ARG TORCHAUDIO_TEST_ALLOW_SKIP_IF_NO_FFMPEG=1 -RUN --mount=type=cache,target=/root/.cache/uv \ - source /opt/rh/gcc-toolset-14/enable && \ - git clone --recursive https://github.com/pytorch/audio.git -b v${TORCHAUDIO_VERSION} && \ - cd audio && \ - MAX_JOBS=${MAX_JOBS:-$(nproc)} \ - BUILD_VERSION=${TORCHAUDIO_VERSION} \ - uv build --wheel --out-dir /torchwheels/ --no-build-isolation - -############################################################### -# Stage to build pyarrow -############################################################### + sh ./build_vllm_$(uname -m).sh -FROM base-builder AS arrow-builder +# copy vllm source code to build cache +COPY . . -ARG MAX_JOBS -ARG PYARROW_PARALLEL -ARG PYARROW_VERSION=21.0.0 RUN --mount=type=cache,target=/root/.cache/uv \ source /opt/rh/gcc-toolset-14/enable && \ - git clone --recursive https://github.com/apache/arrow.git -b apache-arrow-${PYARROW_VERSION} && \ - cd arrow/cpp && \ - mkdir build && cd build && \ - cmake -DCMAKE_BUILD_TYPE=release \ - -DCMAKE_INSTALL_PREFIX=/usr/local \ - -DARROW_PYTHON=ON \ - -DARROW_BUILD_TESTS=OFF \ - -DARROW_JEMALLOC=ON \ - -DARROW_BUILD_STATIC="OFF" \ - -DARROW_PARQUET=ON \ - .. && \ - make install -j ${MAX_JOBS:-$(nproc)} && \ - cd ../../python/ && \ - uv pip install -v -r requirements-build.txt && uv pip install numpy==2.1.3 && \ - PYARROW_PARALLEL=${PYARROW_PARALLEL:-$(nproc)} \ - python setup.py build_ext \ - --build-type=release --bundle-arrow-cpp \ - bdist_wheel --dist-dir /arrowwheels/ - -############################################################### -# Stage to build opencv -############################################################### + pip install -U uv -FROM base-builder AS cv-builder - -ARG MAX_JOBS -ARG OPENCV_VERSION=86 -# patch for version 4.11.0.86 -ARG OPENCV_PATCH=97f3f39 -ARG ENABLE_HEADLESS=1 +# build & install vLLM so that all transitive dependencies are build/downloaded into the uv cache RUN --mount=type=cache,target=/root/.cache/uv \ source /opt/rh/gcc-toolset-14/enable && \ - git clone --recursive https://github.com/opencv/opencv-python.git -b ${OPENCV_VERSION} && \ - cd opencv-python && \ - sed -i -E -e 's/"setuptools.+",/"setuptools",/g' pyproject.toml && \ - cd opencv && git cherry-pick --no-commit $OPENCV_PATCH && cd .. && \ - uv pip install scikit-build && \ - python -m build --wheel --installer=uv --outdir /opencvwheels/ - -############################################################### -# Stage to build numactl -############################################################### - -FROM base-builder AS numa-builder - -# Note: Building numactl with gcc-11. Compiling with gcc-13 in this builder stage will -# trigger recompilation with gcc-11 (and require libtool) in the final stage where we do not have gcc-13 -ARG MAX_JOBS -ARG NUMACTL_VERSION=2.0.19 -RUN git clone --recursive https://github.com/numactl/numactl.git -b v${NUMACTL_VERSION} \ - && cd numactl \ - && autoreconf -i && ./configure \ - && make -j ${MAX_JOBS:-$(nproc)} - - -############################################################### -# Stage to build numba -############################################################### - -FROM base-builder AS numba-builder - -ARG MAX_JOBS -ARG NUMBA_VERSION=0.61.2 - -# Clone all required dependencies -RUN dnf install ninja-build llvm15 llvm15-devel -y && source /opt/rh/gcc-toolset-14/enable && export PATH=$PATH:/usr/lib64/llvm15/bin && \ - git clone --recursive https://github.com/numba/numba.git -b ${NUMBA_VERSION} && \ - cd ./numba && \ - if ! grep '#include "dynamic_annotations.h"' numba/_dispatcher.cpp; then \ - sed -i '/#include "internal\/pycore_atomic.h"/i\#include "dynamic_annotations.h"' numba/_dispatcher.cpp; \ - fi && python -m build --wheel --installer=uv --outdir /numbawheels/ - -############################################################### -# Stage to build vllm - this stage builds and installs -# vllm, tensorizer and vllm-tgis-adapter and builds uv cache -# for transitive dependencies - eg. grpcio -############################################################### - -FROM base-builder AS vllmcache-builder - -ENV LLVM_CONFIG=/usr/lib64/llvm15/bin/llvm-config -ENV PATH=/usr/lib64/llvm15/bin:$PATH - -COPY --from=torch-builder /tmp/control /dev/null -COPY --from=arrow-builder /tmp/control /dev/null -COPY --from=cv-builder /tmp/control /dev/null -COPY --from=numa-builder /tmp/control /dev/null -COPY --from=numba-builder /tmp/control /dev/null - -ARG VLLM_TARGET_DEVICE=cpu -ARG GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 - -# this step installs vllm and populates uv cache -# with all the transitive dependencies -RUN --mount=type=cache,target=/root/.cache/uv \ - dnf install llvm15 llvm15-devel -y && \ - rpm -ivh --nodeps https://mirror.stream.centos.org/9-stream/CRB/ppc64le/os/Packages/protobuf-lite-devel-3.14.0-16.el9.ppc64le.rpm && \ - source /opt/rh/gcc-toolset-14/enable && \ - git clone https://github.com/huggingface/xet-core.git && cd xet-core/hf_xet/ && \ - uv pip install maturin && \ - uv build --wheel --out-dir /hf_wheels/ - -ENV CXXFLAGS="-fno-lto -Wno-error=free-nonheap-object" \ - CFLAGS="-fno-lto" -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,from=torch-builder,source=/torchwheels/,target=/torchwheels/,ro \ - --mount=type=bind,from=arrow-builder,source=/arrowwheels/,target=/arrowwheels/,ro \ - --mount=type=bind,from=cv-builder,source=/opencvwheels/,target=/opencvwheels/,ro \ - --mount=type=bind,from=numa-builder,source=/numactl/,target=/numactl/,rw \ - --mount=type=bind,from=numba-builder,source=/numbawheels/,target=/numbawheels/,ro \ - --mount=type=bind,src=.,dst=/src/,rw \ - source /opt/rh/gcc-toolset-14/enable && \ - export PATH=$PATH:/usr/lib64/llvm15/bin && \ - uv pip install /opencvwheels/*.whl /arrowwheels/*.whl /torchwheels/*.whl /numbawheels/*.whl && \ - sed -i -e 's/.*torch.*//g' /src/pyproject.toml /src/requirements/*.txt && \ - sed -i -e 's/.*sentencepiece.*//g' /src/pyproject.toml /src/requirements/*.txt && \ - uv pip install sentencepiece==0.2.0 pandas pythran nanobind pybind11 /hf_wheels/*.whl && \ - make -C /numactl install && \ - # sentencepiece.pc is in some pkgconfig inside uv cache - export PKG_CONFIG_PATH=$(find / -type d -name "pkgconfig" 2>/dev/null | tr '\n' ':') && \ - nanobind_DIR=$(uv pip show nanobind | grep Location | sed 's/^Location: //;s/$/\/nanobind\/cmake/') && uv pip install -r /src/requirements/common.txt -r /src/requirements/cpu.txt -r /src/requirements/build/cuda.txt --no-build-isolation && \ - cd /src/ && \ - uv build --wheel --out-dir /vllmwheel/ --no-build-isolation && \ - uv pip install /vllmwheel/*.whl - - -############################################################### -# Stage to build lapack -############################################################### - -FROM base-builder AS lapack-builder - -ARG MAX_JOBS -ARG LAPACK_VERSION=3.12.1 -RUN git clone --recursive https://github.com/Reference-LAPACK/lapack.git -b v${LAPACK_VERSION} \ - && cd lapack && source /opt/rh/gcc-toolset-14/enable \ - && cmake -B build -S . \ - && cmake --build build -j ${MAX_JOBS:-$(nproc)} - + export PATH=/opt/rh/gcc-toolset-14/root/usr/bin:$PATH && \ + export CC=/opt/rh/gcc-toolset-14/root/usr/bin/gcc && \ + export CXX=/opt/rh/gcc-toolset-14/root/usr/bin/g++ && \ + export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/lib64/pkgconfig:$PKG_CONFIG_PATH && \ + export CMAKE_PREFIX_PATH=/usr/local:/usr:$CMAKE_PREFIX_PATH && \ + export Protobuf_PROTOC_EXECUTABLE=/usr/bin/protoc && \ + export CFLAGS="-mcpu=power10 -mtune=power10" && \ + export CXXFLAGS="-mcpu=power10 -mtune=power10" && \ + export DNNL_ARCH_OPT_FLAGS="-mcpu=power10 -mtune=power10" && \ + export C_INCLUDE_PATH=/usr/local/include:$C_INCLUDE_PATH && \ + export CPLUS_INCLUDE_PATH=/usr/local/include:$CPLUS_INCLUDE_PATH && \ + uv pip install 'setuptools>=78.1.1' && \ + export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/opt/OpenBLAS/lib/:/usr/local/lib64:/usr/local/lib && \ + export LIBGOMP=/opt/rh/gcc-toolset-14/root/usr/lib/gcc/ppc64le-redhat-linux/14/libgomp.so && \ + + export CMAKE_LIBRARY_PATH=$(dirname $LIBGOMP):${CMAKE_LIBRARY_PATH} && \ + export LIBRARY_PATH=$(dirname $LIBGOMP):${LIBRARY_PATH} && \ + export LD_LIBRARY_PATH=$(dirname $LIBGOMP):${LD_LIBRARY_PATH} && \ + + echo "LIBGOMP=${LIBGOMP}" && \ + find /root/.cache/uv -name "*.whl" && \ + SETUPTOOLS_SCM_PRETEND_VERSION="$VLLM_VERSION" uv build \ + --wheel --out-dir ${WHEEL_DIR} --no-build-isolation && \ + uv pip install "$(echo ${WHEEL_DIR}/vllm*.whl)[tensorizer]" --refresh ############################################################### # FINAL VLLM IMAGE STAGE # @@ -278,72 +83,74 @@ RUN git clone --recursive https://github.com/Reference-LAPACK/lapack.git -b v${L FROM registry.access.redhat.com/ubi9/ubi-minimal:${BASE_UBI_IMAGE_TAG} AS vllm-openai ARG PYTHON_VERSION=3.12 -ARG OPENBLAS_VERSION=0.3.30 +ENV VLLM_NO_USAGE_STATS=1 # Set Environment Variables for venv & openblas ENV VIRTUAL_ENV=/opt/vllm -ENV PATH=${VIRTUAL_ENV}/bin:$PATH -ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/ -ENV LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib64:/usr/local/lib:/usr/lib64:/usr/lib +ENV PCP_DIR=/opt/rh/gcc-toolset-14/root +ENV PATH=${VIRTUAL_ENV}/bin:${PCP_DIR}/usr/bin:/usr/local/bin:$PATH +ENV PKG_CONFIG_PATH=${PCP_DIR}/usr/lib64/pkgconfig:/usr/local/lib/pkgconfig/ +ENV C_INCLUDE_PATH="/usr/local/include:$C_INCLUDE_PATH" +ENV LD_LIBRARY_PATH=${PCP_DIR}/usr/lib64:${PCP_DIR}/usr/lib:${VIRTUAL_ENV}/lib64/python${PYTHON_VERSION}/site-packages/torch/lib:/usr/local/lib:$LD_LIBRARY_PATH:/usr/local/lib64:/usr/lib64:/usr/lib ENV UV_LINK_MODE=copy -ENV OMP_NUM_THREADS=16 - -# create artificial dependencies between stages for independent stages to build in parallel -COPY --from=torch-builder /tmp/control /dev/null -COPY --from=arrow-builder /tmp/control /dev/null -COPY --from=cv-builder /tmp/control /dev/null -COPY --from=vllmcache-builder /tmp/control /dev/null -COPY --from=numa-builder /tmp/control /dev/null -COPY --from=lapack-builder /tmp/control /dev/null -COPY --from=openblas-builder /tmp/control /dev/null -COPY --from=numba-builder /tmp/control /dev/null - -# install gcc-11, python, openblas, numactl, lapack +ARG VLLM_VERSION="0.22.1" +ARG UV_EXTRA_INDEX_URL="https://wheels.developerfirst.ibm.com/ppc64le/linux/+simple/" +ENV UV_EXTRA_INDEX_URL=${UV_EXTRA_INDEX_URL} +ENV UV_INDEX_STRATEGY=first-match + + RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,from=numa-builder,source=/numactl/,target=/numactl/,rw \ - --mount=type=bind,from=lapack-builder,source=/lapack/,target=/lapack/,rw \ - --mount=type=bind,from=openblas-builder,source=/OpenBLAS-$OPENBLAS_VERSION/,target=/openblas/,rw \ rpm -ivh https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm && \ microdnf install --nodocs -y \ - libomp libicu tar findutils openssl llvm15 llvm15-devel \ - pkgconfig xsimd g++ gcc-fortran libsndfile \ + libomp libicu tar autoconf automake libtool findutils openssl numactl numactl-devel \ + pkgconfig xsimd gcc-toolset-14 libsndfile \ libtiff libjpeg openjpeg2 zlib zeromq \ freetype lcms2 libwebp tcl tk utf8proc \ - harfbuzz fribidi libraqm libimagequant libxcb util-linux \ + harfbuzz fribidi libraqm libimagequant libxcb util-linux gperftools-libs \ python${PYTHON_VERSION}-devel python${PYTHON_VERSION}-pip \ - && export PATH=$PATH:/usr/lib64/llvm15/bin && microdnf clean all \ - && python${PYTHON_VERSION} -m venv ${VIRTUAL_ENV} \ - && python -m pip install -U pip uv --no-cache \ - && make -C /numactl install \ - && PREFIX=/usr/local make -C /openblas install \ - && uv pip install 'cmake<4' \ - && cmake --install /lapack/build \ - && uv pip uninstall cmake - -# consume previously built wheels (including vllm) -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,from=torch-builder,source=/torchwheels/,target=/torchwheels/,ro \ - --mount=type=bind,from=arrow-builder,source=/arrowwheels/,target=/arrowwheels/,ro \ - --mount=type=bind,from=cv-builder,source=/opencvwheels/,target=/opencvwheels/,ro \ - --mount=type=bind,from=vllmcache-builder,source=/hf_wheels/,target=/hf_wheels/,ro \ - --mount=type=bind,from=vllmcache-builder,source=/vllmwheel/,target=/vllmwheel/,ro \ - --mount=type=bind,from=numba-builder,source=/numbawheels/,target=/numbawheels/,ro \ - export PKG_CONFIG_PATH=$(find / -type d -name "pkgconfig" 2>/dev/null | tr '\n' ':') && uv pip install sentencepiece==0.2.0 && \ - HOME=/root uv pip install /opencvwheels/*.whl /arrowwheels/*.whl /torchwheels/*.whl /numbawheels/*.whl /hf_wheels/*.whl /vllmwheel/*.whl - - -COPY ./ /workspace/vllm -WORKDIR /workspace/vllm -ARG GIT_REPO_CHECK=0 -RUN --mount=type=bind,source=.git,target=.git \ - if [ "$GIT_REPO_CHECK" != 0 ]; then bash tools/check_repo.sh; fi - -# install development dependencies (for testing) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install -e tests/vllm_test_utils + && source /opt/rh/gcc-toolset-14/enable \ + && microdnf update -y \ + && microdnf clean all -WORKDIR /workspace/ +# The `lscpu` command was added as a requirement in part of https://github.com/vllm-project/vllm/pull/21032, so installing it. +RUN microdnf install --nodocs -y util-linux && \ + microdnf clean all -RUN ln -s /workspace/vllm/tests && ln -s /workspace/vllm/examples && ln -s /workspace/vllm/benchmarks +COPY --from=builder-base /usr/lib64/libprotobuf.so.25 /usr/lib64/ +COPY --from=builder-base /usr/lib64/libprotobuf.so.25.0.0 /usr/lib64/ + +# Use builder venv in final stage instead of wheel reinstallation +COPY --from=builder-base /opt/vllm /opt/vllm + +ENV LD_PRELOAD=/usr/lib64/libtcmalloc.so.4 + +WORKDIR /home/vllm + +# setup non-root user for OpenShift +RUN umask 002 && \ + useradd --uid 2000 --gid 0 vllm && \ + mkdir -p /home/vllm && \ + chmod g+rwx /home/vllm + +ENV HOME=/home/vllm + +# Add labels to document build configuration +LABEL org.opencontainers.image.title="vLLM CPU" +LABEL org.opencontainers.image.description="vLLM inference engine for CPU platforms" +LABEL org.opencontainers.image.vendor="vLLM Project" +LABEL org.opencontainers.image.source="https://github.com/vllm-project/vllm" + +# Build configuration labels +ARG TARGETARCH +ARG VLLM_CPU_PPC64LE +ARG PYTHON_VERSION + +LABEL ai.vllm.build.target-arch="${TARGETARCH}" +LABEL ai.vllm.build.cpu-ppc64le="${VLLM_CPU_PPC64LE:-false}" +LABEL ai.vllm.build.python-version="${PYTHON_VERSION:-3.12}" + +USER 2000 ENTRYPOINT ["vllm", "serve"] + + diff --git a/docker/Dockerfile.rocm b/docker/Dockerfile.rocm index 1e39306e39f6..02e4086d6256 100644 --- a/docker/Dockerfile.rocm +++ b/docker/Dockerfile.rocm @@ -98,7 +98,6 @@ RUN if [ "$USE_SCCACHE" = "1" ]; then \ ARG USE_SCCACHE ENV SCCACHE_BUCKET=${USE_SCCACHE:+${SCCACHE_BUCKET_NAME}} ENV SCCACHE_REGION=${USE_SCCACHE:+${SCCACHE_REGION_NAME}} -ENV SCCACHE_ENDPOINT=${USE_SCCACHE:+${SCCACHE_ENDPOINT}} ENV SCCACHE_S3_NO_CREDENTIALS=${USE_SCCACHE:+${SCCACHE_S3_NO_CREDENTIALS}} ENV SCCACHE_IDLE_TIMEOUT=${USE_SCCACHE:+0} @@ -130,6 +129,7 @@ FROM fetch_vllm_${REMOTE_VLLM} AS fetch_vllm # don't need the rust toolchain or protoc. FROM fetch_vllm AS rust-build ARG COMMON_WORKDIR +ARG USE_SCCACHE # protoc is used by tonic-build/prost-build. RUN apt-get update -q -y && apt-get install -q -y --no-install-recommends \ @@ -139,27 +139,36 @@ RUN apt-get update -q -y && apt-get install -q -y --no-install-recommends \ COPY tools/install_protoc.sh /tmp/install_protoc.sh RUN /tmp/install_protoc.sh && rm /tmp/install_protoc.sh -# Install rustup; the toolchain itself is pinned by rust-toolchain.toml. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain none -ENV PATH="/root/.cargo/bin:${PATH}" - # Cap cargo parallelism to avoid exhausting the AMD CI host's open-file limit # (rustc spawns enough concurrent processes to hit RLIMIT_NOFILE otherwise). ENV CARGO_BUILD_JOBS=4 ENV CARGO_NET_RETRY=10 ENV RUSTUP_MAX_RETRIES=10 +# BuildKit can run this stage in parallel with ROCm native builds. Keep Rust on +# a separate local sccache daemon while sharing the same remote cache backend. +ENV SCCACHE_SERVER_PORT=4227 + +RUN --mount=type=cache,id=vllm-rocm-uv,target=/root/.cache/uv \ + cd ${COMMON_WORKDIR}/vllm \ + && uv pip install --system -r requirements/build/rust.txt + # Build the release binary. Cargo's registry/git caches can be written by # concurrent BuildKit jobs on shared workers, so lock those cache mounts while -# keeping the cache benefit. Copy the binary out so it persists into the image -# layer for later COPY --from=rust-build. +# keeping the cache benefit. Do not cache target/, because stale target metadata +# can outlive source updates across BuildKit cache reuse. RUN --mount=type=cache,id=vllm-rocm-cargo-registry,target=/root/.cargo/registry,sharing=locked \ --mount=type=cache,id=vllm-rocm-cargo-git,target=/root/.cargo/git,sharing=locked \ - --mount=type=cache,id=vllm-rocm-cargo-target,target=${COMMON_WORKDIR}/vllm/rust/target,sharing=locked \ cd ${COMMON_WORKDIR}/vllm \ - && VLLM_RS_TARGET_PATH=/tmp/vllm-rs bash build_rust.sh \ - && test -x /tmp/vllm-rs + && if [ "$USE_SCCACHE" = "1" ]; then \ + export RUSTC_WRAPPER=sccache \ + && sccache --show-stats; \ + fi \ + && bash build_rust.sh \ + && test -x vllm/vllm-rs \ + && if [ "$USE_SCCACHE" = "1" ]; then \ + sccache --show-stats; \ + fi # ----------------------- # vLLM native build stages @@ -179,6 +188,7 @@ RUN --mount=type=cache,id=vllm-rocm-uv,target=/root/.cache/uv \ # pyproject.toml is bind-mounted in the RUN step so metadata-only changes do # not invalidate the expensive native build layer. COPY setup.py CMakeLists.txt ./ +COPY tools/build_rust.py tools/build_rust.py COPY cmake cmake/ COPY csrc csrc/ COPY vllm/envs.py vllm/envs.py @@ -210,9 +220,10 @@ ENV VLLM_TARGET_DEVICE=rocm COPY --from=csrc-build ${COMMON_WORKDIR}/vllm/dist /precompiled-wheels -# Drop the pre-built rust frontend binary into the source tree. setup.py -# detects it and ships it as-is, skipping the local cargo build. -COPY --from=rust-build /tmp/vllm-rs ${COMMON_WORKDIR}/vllm/vllm/vllm-rs +# Drop the pre-built Rust artifacts into the source tree. setup.py detects +# them and ships them as-is, skipping the local Rust build. +COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/vllm-rs ${COMMON_WORKDIR}/vllm/vllm/vllm-rs +COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/_rust_*.so ${COMMON_WORKDIR}/vllm/vllm/ RUN --mount=type=cache,id=vllm-rocm-uv,target=/root/.cache/uv \ cd vllm \ @@ -231,9 +242,18 @@ COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/requirements /requirements COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/benchmarks /benchmarks COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/tests /tests COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/examples /examples +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/tools/install_torchcodec_rocm.sh /tools/install_torchcodec_rocm.sh +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/docker/Dockerfile /docker/Dockerfile +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/docker/Dockerfile.cpu /docker/Dockerfile.cpu COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/docker/Dockerfile.rocm /docker/ +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/docker/Dockerfile.rocm_base /docker/Dockerfile.rocm_base +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/docker/ci-rocm.hcl /docker/ci-rocm.hcl +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/docker/docker-bake.hcl /docker/docker-bake.hcl +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/docker/docker-bake-rocm.hcl /docker/docker-bake-rocm.hcl COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/.buildkite /.buildkite COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/pyproject.toml /pyproject.toml +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/rust /rust +COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/rust-toolchain.toml /rust-toolchain.toml COPY --from=build_vllm ${COMMON_WORKDIR}/vllm/vllm/v1 /vllm_v1 # RIXL/UCX build stages @@ -418,9 +438,10 @@ FROM fetch_vllm AS build_vllm_wheel_release ARG COMMON_WORKDIR -# Drop the pre-built rust frontend binary into the source tree. setup.py -# detects it and ships it as-is, skipping the local cargo build. -COPY --from=rust-build /tmp/vllm-rs ${COMMON_WORKDIR}/vllm/vllm/vllm-rs +# Drop the pre-built Rust artifacts into the source tree. setup.py detects +# them and ships them as-is, skipping the local Rust build. +COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/vllm-rs ${COMMON_WORKDIR}/vllm/vllm/vllm-rs +COPY --from=rust-build ${COMMON_WORKDIR}/vllm/vllm/_rust_*.so ${COMMON_WORKDIR}/vllm/vllm/ # Create /install directory for custom wheels RUN mkdir -p /install @@ -514,9 +535,18 @@ COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/requirements /requir COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/benchmarks /benchmarks COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/tests /tests COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/examples /examples +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/tools/install_torchcodec_rocm.sh /tools/install_torchcodec_rocm.sh +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/docker/Dockerfile /docker/Dockerfile +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/docker/Dockerfile.cpu /docker/Dockerfile.cpu COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/docker/Dockerfile.rocm /docker/ +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/docker/Dockerfile.rocm_base /docker/Dockerfile.rocm_base +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/docker/ci-rocm.hcl /docker/ci-rocm.hcl +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/docker/docker-bake.hcl /docker/docker-bake.hcl +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/docker/docker-bake-rocm.hcl /docker/docker-bake-rocm.hcl COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/.buildkite /.buildkite COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/pyproject.toml /pyproject.toml +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/rust /rust +COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/rust-toolchain.toml /rust-toolchain.toml COPY --from=build_vllm_wheel_release ${COMMON_WORKDIR}/vllm/vllm/v1 /vllm_v1 # ----------------------- @@ -550,6 +580,7 @@ RUN apt-get update -q -y && apt-get install -q -y --no-install-recommends \ libibverbs1 \ ibverbs-providers \ ibverbs-utils \ + unzip \ pkg-config ffmpeg libavcodec-dev libavformat-dev libavutil-dev \ libswscale-dev libavdevice-dev libavfilter-dev libswresample-dev \ && rm -rf /var/lib/apt/lists/* @@ -573,6 +604,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \ ENV HF_XET_HIGH_PERFORMANCE=1 ENV HF_HUB_DOWNLOAD_TIMEOUT=60 +# Keep torch.cuda.is_available() fork-safe (see vllm/env_override.py). +ENV PYTORCH_NVML_BASED_CUDA_CHECK=1 + # Pre-install vLLM test dependencies. COPY requirements/test/rocm.txt /tmp/rocm-test-reqs.txt RUN --mount=type=cache,target=/root/.cache/uv \ @@ -693,6 +727,9 @@ ENV SAFETENSORS_FAST_GPU=1 # Performance environment variable. ENV HIP_FORCE_DEV_KERNARG=1 +# Keep torch.cuda.is_available() fork-safe (see vllm/env_override.py). +ENV PYTORCH_NVML_BASED_CUDA_CHECK=1 + # Workaround for ROCm profiler limits RUN echo "ROCTRACER_MAX_EVENTS=10000000" > ${COMMON_WORKDIR}/libkineto.conf ENV KINETO_CONFIG="${COMMON_WORKDIR}/libkineto.conf" diff --git a/docker/Dockerfile.rocm_base b/docker/Dockerfile.rocm_base index 208ce863f6b3..2faaf774cf62 100644 --- a/docker/Dockerfile.rocm_base +++ b/docker/Dockerfile.rocm_base @@ -1,7 +1,7 @@ ARG BASE_IMAGE=rocm/dev-ubuntu-22.04:7.2.3-complete -ARG TRITON_BRANCH="ba5c1517" +ARG TRITON_BRANCH="0f380657" ARG TRITON_REPO="https://github.com/ROCm/triton.git" -ARG PYTORCH_BRANCH="8514f051" # release/2.10 as of 3/17 +ARG PYTORCH_BRANCH="d0c8b1f3" # release/2.11 as of 6/09 ARG PYTORCH_REPO="https://github.com/ROCm/pytorch.git" ARG PYTORCH_VISION_BRANCH="v0.24.1" ARG PYTORCH_VISION_REPO="https://github.com/pytorch/vision.git" @@ -9,7 +9,7 @@ ARG PYTORCH_AUDIO_BRANCH="v2.9.0" ARG PYTORCH_AUDIO_REPO="https://github.com/pytorch/audio.git" ARG FA_BRANCH="0e60e394" ARG FA_REPO="https://github.com/Dao-AILab/flash-attention.git" -ARG AITER_BRANCH="v0.1.13.post1" +ARG AITER_BRANCH="v0.1.16.post3" ARG AITER_REPO="https://github.com/ROCm/aiter.git" ARG MORI_BRANCH="v1.1.0" ARG MORI_REPO="https://github.com/ROCm/mori.git" @@ -114,12 +114,10 @@ ARG TRITON_REPO RUN git clone ${TRITON_REPO} # Cherry picking the following # https://github.com/triton-lang/triton/pull/8991 -# https://github.com/triton-lang/triton/pull/9541 RUN cd triton \ && git checkout ${TRITON_BRANCH} \ && git config --global user.email "you@example.com" && git config --global user.name "Your Name" \ && git cherry-pick 555d04f \ - && git cherry-pick dd998b6 \ && if [ ! -f setup.py ]; then cd python; fi \ && python3 setup.py bdist_wheel --dist-dir=dist \ && mkdir -p /app/install && cp dist/*.whl /app/install @@ -246,7 +244,7 @@ RUN pip install pyyaml && cd aiter \ export HIP_CLANG_PATH=/opt/sccache-wrappers \ && sccache --show-stats; \ fi \ - && PREBUILD_KERNELS=1 GPU_ARCHS=${AITER_ROCM_ARCH} python3 setup.py bdist_wheel --dist-dir=dist \ + && PREBUILD_KERNELS=1 AITER_USE_SYSTEM_TRITON=1 GPU_ARCHS=${AITER_ROCM_ARCH} python3 setup.py bdist_wheel --dist-dir=dist \ && if [ "$USE_SCCACHE" = "1" ]; then sccache --show-stats; fi \ && ls /app/aiter/dist/*.whl RUN mkdir -p /app/install && cp /app/aiter/dist/*.whl /app/install diff --git a/docker/Dockerfile.s390x b/docker/Dockerfile.s390x index 554a7257c236..6d1c0c3452fc 100644 --- a/docker/Dockerfile.s390x +++ b/docker/Dockerfile.s390x @@ -249,7 +249,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ NUMBA_WHL_FILE=$(ls /tmp/numba-wheels/*.whl) && \ OPENCV_WHL_FILE=$(ls /tmp/opencv-wheels/*.whl) && \ GUIDANCE_WHL_FILE=$(ls /tmp/guidance-wheels/*.whl) && \ - uv pip install -v \ + uv pip install -v \ $ARROW_WHL_FILE \ $VISION_WHL_FILE \ $HF_XET_WHL_FILE \ @@ -257,6 +257,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ $NUMBA_WHL_FILE \ $OPENCV_WHL_FILE \ $GUIDANCE_WHL_FILE \ + --torch-backend cpu \ --index-strategy unsafe-best-match \ -r requirements/build/cpu.txt \ -r requirements/cpu.txt diff --git a/docker/Dockerfile.xpu b/docker/Dockerfile.xpu index ef05b4aa2e57..3bd16e8629ba 100644 --- a/docker/Dockerfile.xpu +++ b/docker/Dockerfile.xpu @@ -6,44 +6,48 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update -y \ && apt-get install -y --no-install-recommends \ - ca-certificates curl git build-essential unzip \ + ca-certificates curl git build-essential unzip python3 python3-pip \ && rm -rf /var/lib/apt/lists/* COPY tools/install_protoc.sh /tmp/install_protoc.sh RUN /tmp/install_protoc.sh && rm /tmp/install_protoc.sh -# Install rustup; the toolchain itself is pinned by rust-toolchain.toml. -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ - sh -s -- -y --profile minimal --default-toolchain none -ENV PATH="/root/.cargo/bin:${PATH}" - WORKDIR /workspace +COPY requirements/build/rust.txt requirements/build/rust.txt +RUN python3 -m pip install --no-cache-dir -r requirements/build/rust.txt + +# Copy only the Rust build inputs; build_rust.sh publishes artifacts needed +# by the wheel build stage. COPY rust rust COPY rust-toolchain.toml rust-toolchain.toml +COPY tools/build_rust.py tools/build_rust.py COPY build_rust.sh build_rust.sh # Cap cargo parallelism to avoid exhausting the CI host's open-file limit # (rustc spawns enough concurrent processes to hit RLIMIT_NOFILE otherwise). ENV CARGO_BUILD_JOBS=4 -RUN --mount=type=cache,target=/root/.cargo/registry \ - --mount=type=cache,target=/root/.cargo/git \ - --mount=type=cache,target=/workspace/rust/target \ - VLLM_RS_TARGET_PATH=/workspace/vllm-rs bash build_rust.sh +RUN --mount=type=cache,target=/root/.cargo/registry,sharing=locked \ + --mount=type=cache,target=/root/.cargo/git,sharing=locked \ + bash build_rust.sh -FROM intel/deep-learning-essentials:2025.3.2-0-devel-ubuntu24.04 AS vllm-base +FROM ubuntu:24.04 AS vllm-base + +ENV DEBIAN_FRONTEND=noninteractive WORKDIR /workspace/ ARG PYTHON_VERSION=3.12 ARG PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/xpu" -RUN apt clean && apt-get update -y && \ - apt-get install -y --no-install-recommends --fix-missing \ +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends \ + build-essential \ curl \ ffmpeg \ git \ + gpg \ libsndfile1 \ libsm6 \ libxext6 \ @@ -53,9 +57,11 @@ RUN apt clean && apt-get update -y && \ numactl \ wget \ vim \ + ca-certificates \ python3.12 \ python3.12-dev \ - python3-pip + python3-pip && \ + rm -rf /var/lib/apt/lists/* # Add oneAPI repo, pin oneAPI to 2025.3, then install pinned packages in one layer. RUN wget -O- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | gpg --dearmor | tee /usr/share/keyrings/oneapi-archive-keyring.gpg > /dev/null && \ @@ -75,13 +81,14 @@ RUN wget -O- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRO # Install UMD RUN mkdir neo && \ cd neo && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-core-2_2.24.8+20344_amd64.deb && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-opencl-2_2.24.8+20344_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/intel-ocloc_25.48.36300.8-0_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/intel-opencl-icd_25.48.36300.8-0_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libigdgmm12_22.8.2_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libze-intel-gpu1_25.48.36300.8-0_amd64.deb && \ - wget https://github.com/oneapi-src/level-zero/releases/download/v1.26.0/level-zero_1.26.0+u24.04_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-core-2_2.34.4+21428_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-opencl-2_2.34.4+21428_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/intel-ocloc_26.18.38308.1-0_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/intel-opencl-icd_26.18.38308.1-0_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/libigdgmm12_22.10.0_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/libze-intel-gpu1_26.18.38308.1-0_amd64.deb && \ + wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u24.04_amd64.deb && \ + wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero-devel_1.28.2+u24.04_amd64.deb && \ dpkg -i *.deb && \ cd .. && \ rm -rf neo @@ -89,8 +96,8 @@ RUN mkdir neo && \ ENV PATH="/root/.local/bin:$PATH" ENV VIRTUAL_ENV="/opt/venv" ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -RUN uv venv --python ${PYTHON_VERSION} --seed ${VIRTUAL_ENV} +RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ + && uv venv --python ${PYTHON_VERSION} --seed ${VIRTUAL_ENV} ENV PATH="$VIRTUAL_ENV/bin:$PATH" # This oneccl contains the BMG support which is not the case for default version of oneapi 2025.3. @@ -99,9 +106,15 @@ RUN wget "https://github.com/uxlfoundation/oneCCL/releases/download/2021.15.9/${ bash "${ONECCL_INSTALLER}" -a --silent --eula accept && \ rm "${ONECCL_INSTALLER}" && \ echo "source /opt/intel/oneapi/setvars.sh --force" >> /root/.bashrc && \ - echo "source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force" >> /root/.bashrc -RUN rm -f /opt/intel/oneapi/ccl/latest && \ - ln -s /opt/intel/oneapi/ccl/2021.15 /opt/intel/oneapi/ccl/latest + echo "source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force" >> /root/.bashrc && \ + rm -f /opt/intel/oneapi/ccl/latest && \ + ln -s /opt/intel/oneapi/ccl/2021.15 /opt/intel/oneapi/ccl/latest && \ + printf '%s\n' \ + '/opt/intel/oneapi/ccl/2021.15/lib' \ + '/opt/intel/oneapi/mpi/2021.15/lib' \ + '/opt/intel/oneapi/compiler/2025.3/lib' \ + > /etc/ld.so.conf.d/oneapi-ccl.conf && \ + ldconfig SHELL ["bash", "-c"] CMD ["bash", "-c", "source /root/.bashrc && exec bash"] @@ -119,103 +132,114 @@ ENV UV_LINK_MODE="copy" RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,src=requirements/common.txt,target=/workspace/vllm/requirements/common.txt \ --mount=type=bind,src=requirements/xpu.txt,target=/workspace/vllm/requirements/xpu.txt \ - --mount=type=bind,src=requirements/test/xpu.txt,target=/workspace/vllm/requirements/test/xpu.txt \ - uv pip install --upgrade pip && \ - uv pip install -r requirements/xpu.txt && \ - uv pip install grpcio-tools protobuf nanobind && \ - source /opt/intel/oneapi/setvars.sh --force && \ - source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force && \ - export CMAKE_PREFIX_PATH="$(python3 -c 'import site; print(site.getsitepackages()[0])'):${CMAKE_PREFIX_PATH}" && \ - uv pip install --no-build-isolation -r /workspace/vllm/requirements/test/xpu.txt + uv pip install --upgrade pip -ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/local/lib/" +ENV LD_LIBRARY_PATH=/opt/intel/oneapi/ccl/2021.15/lib:/opt/intel/oneapi/mpi/2021.15/lib:/opt/intel/oneapi/compiler/2025.3/lib:/usr/local/lib +CMD ["/bin/bash"] -COPY . . +######################### UCX + NIXL BUILD STAGE ######################### +# Build UCX and NIXL in a dedicated stage so compiler/autotools layers are +# never included in the final runtime image (mirrors ROCm's build_rixl stage). +FROM vllm-base AS ucx-nixl-build -# Drop the pre-built rust frontend binary into the source tree. setup.py -# detects it and ships it as-is, skipping the local cargo build. -COPY --from=rust-build /workspace/vllm-rs vllm/vllm-rs +ARG UCX_VERSION=v1.21.0-rc2 +ARG NIXL_VERSION=v1.2.0 -ARG GIT_REPO_CHECK=0 -RUN --mount=type=bind,source=.git,target=.git \ - if [ "$GIT_REPO_CHECK" != 0 ]; then bash tools/check_repo.sh; fi - -ENV VLLM_TARGET_DEVICE=xpu -ENV VLLM_WORKER_MULTIPROC_METHOD=spawn +# Build-time only: compiler, autotools, and verbs dev headers +RUN apt-get update -y && apt-get install -y --no-install-recommends \ + build-essential \ + autoconf \ + automake \ + libtool \ + pkg-config \ + libibverbs-dev \ + librdmacm-dev \ + && rm -rf /var/lib/apt/lists/* +# Build UCX and produce a NIXL wheel so the final image needs no compiler. +# patchelf (installed via uv) is used by the NIXL wheel build to rewrite +# RPATH entries, making the wheel portable across stages. RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=.git,target=.git \ - uv pip install --no-build-isolation . - -CMD ["/bin/bash"] + git clone --depth 1 --branch "${UCX_VERSION}" https://github.com/openucx/ucx /tmp/ucx_source && \ + cd /tmp/ucx_source && \ + bash autogen.sh && \ + ./configure --prefix=/tmp/ucx_install --with-ze=yes --enable-examples --enable-mt && \ + make CFLAGS="-Wno-error=incompatible-pointer-types" -j"$(nproc)" && make install && \ + git clone --depth 1 --branch "${NIXL_VERSION}" https://github.com/ai-dynamo/nixl /tmp/nixl_source && \ + cd /tmp/nixl_source && \ + uv pip install --upgrade meson pybind11 patchelf && \ + uv pip install -r requirements.txt && \ + PKG_CONFIG_PATH=/tmp/ucx_install/lib/pkgconfig \ + LD_LIBRARY_PATH=/tmp/ucx_install/lib \ + python -m pip wheel --no-deps . -w /tmp/nixl_wheels/ && \ + find /tmp/ucx_install -type f \( -name '*.a' -o -name '*.la' \) -delete && \ + rm -rf /tmp/ucx_install/{include,share,etc,bin} /tmp/ucx_install/lib/cmake \ + /tmp/ucx_source /tmp/nixl_source FROM vllm-base AS vllm-openai -# install development dependencies (for testing) -RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install -e tests/vllm_test_utils +ARG NIXL_VERSION=v1.2.0 -# install NIXL and UCX from source code -ARG UCX_VERSION=e5d98879705239d254ede40b4a52891850cb5349 -ARG NIXL_VERSION=0.7.0 +# Copy compiled UCX runtime libraries and the pre-built NIXL wheel. +# No compiler or autotools are installed in this stage. +COPY --from=ucx-nixl-build /tmp/ucx_install /tmp/ucx_install +COPY --from=ucx-nixl-build /tmp/nixl_wheels /tmp/nixl_wheels -RUN apt-get update && apt-get install -y \ - pciutils \ - net-tools \ - iproute2 \ - hwloc \ - numactl \ - wget \ - curl \ - git \ - build-essential \ - autoconf \ - automake \ - libtool \ - pkg-config \ +ENV LD_LIBRARY_PATH=/tmp/ucx_install/lib:${LD_LIBRARY_PATH} + +# Install RDMA runtime libraries (no build tools) and the pre-built NIXL wheel. +# Do not uninstall/reinstall large Python packages here to avoid extra layer +# churn; final package resolution remains in the later app install step. +RUN --mount=type=cache,target=/root/.cache/uv \ + apt-get update -y && apt-get install -y --no-install-recommends \ rdma-core \ - libibverbs-dev \ - ibverbs-utils \ libibverbs1 \ - librdmacm-dev \ librdmacm1 \ - libibumad-dev \ libibumad3 \ - libibmad-dev \ libibmad5 \ - infiniband-diags \ - perftest \ - ibutils \ libmlx5-1 \ libmlx4-1 \ ibverbs-providers \ - librdmacm1t64 + librdmacm1t64 \ + && rm -rf /var/lib/apt/lists/* \ + && uv pip install --no-deps /tmp/nixl_wheels/nixl*.whl \ + && uv pip install nixl==${NIXL_VERSION} && uv pip uninstall nixl-cu13 \ + && rm -rf /tmp/nixl_wheels -ENV PKG_CONFIG_PATH=/tmp/ucx_install/lib/pkgconfig:${PKG_CONFIG_PATH} -ENV LD_LIBRARY_PATH=/tmp/ucx_install/lib:${LD_LIBRARY_PATH} RUN --mount=type=cache,target=/root/.cache/uv \ - git clone https://github.com/openucx/ucx /tmp/ucx_source && \ - cd /tmp/ucx_source && git checkout "${UCX_VERSION}" && \ - bash autogen.sh && \ - ./configure --prefix=/tmp/ucx_install --with-ze=yes --enable-examples --enable-mt && \ - make CFLAGS="-Wno-error=incompatible-pointer-types" -j8 && make install && \ - git clone https://github.com/ai-dynamo/nixl /tmp/nixl_source && \ - cd /tmp/nixl_source && git checkout "${NIXL_VERSION}" && \ - cd /tmp/nixl_source && \ - uv pip install --upgrade meson pybind11 patchelf && \ - uv pip install -r requirements.txt && \ - uv pip install . && \ - rm -rf /tmp/ucx_source /tmp/nixl_source + --mount=type=bind,src=requirements/common.txt,target=/workspace/vllm/requirements/common.txt \ + --mount=type=bind,src=requirements/xpu.txt,target=/workspace/vllm/requirements/xpu.txt \ + --mount=type=bind,src=requirements/test/xpu.txt,target=/workspace/vllm/requirements/test/xpu.txt \ + uv pip install grpcio-tools protobuf nanobind && \ + uv pip install -r /workspace/vllm/requirements/xpu.txt && \ + uv pip install --no-build-isolation -r /workspace/vllm/requirements/test/xpu.txt && \ + uv pip uninstall triton triton-xpu && \ + uv pip install triton-xpu==3.7.1 && \ + uv pip uninstall oneccl oneccl-devel + +# Keep source-dependent layers near the end so frequent code-only changes +# don't invalidate heavy dependency and UCX/NIXL layers. +COPY . . + +# Drop the pre-built Rust artifacts into the source tree. setup.py detects +# them and ships them as-is, skipping the local Rust build. +COPY --from=rust-build /workspace/vllm/vllm-rs vllm/vllm-rs +COPY --from=rust-build /workspace/vllm/_rust_*.so vllm/ + +ARG GIT_REPO_CHECK=0 +RUN --mount=type=bind,source=.git,target=.git \ + if [ "$GIT_REPO_CHECK" != 0 ]; then bash tools/check_repo.sh; fi + +ENV VLLM_TARGET_DEVICE=xpu +ENV VLLM_WORKER_MULTIPROC_METHOD=spawn -# FIX triton RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip uninstall triton triton-xpu && \ - uv pip install triton-xpu==3.7.0 + --mount=type=bind,source=.git,target=.git \ + uv pip install --no-build-isolation --no-deps . -# remove torch bundled oneccl to avoid conflicts RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip uninstall oneccl oneccl-devel + uv pip install -e tests/vllm_test_utils ENTRYPOINT ["vllm", "serve"] diff --git a/docker/ci-rocm.hcl b/docker/ci-rocm.hcl index 138adcffcad6..f06e071c93dc 100644 --- a/docker/ci-rocm.hcl +++ b/docker/ci-rocm.hcl @@ -302,14 +302,22 @@ group "test-rocm-ci-with-wheel" { } # Image tags for the ci_base build. ci-bake-rocm.sh rewrites CI_BASE_IMAGE_TAG -# to the primary tag for this build. Non-nightly builds use a commit-scoped tag -# and also publish a content tag for reuse. NIGHTLY=1 builds on the stable branch -# can additionally set CI_BASE_IMAGE_TAG_STABLE to refresh rocm/vllm-dev:ci_base. +# to the primary tag for this build. Builds always publish a content-scoped tag +# when the ci_base content hash is available. Builds with BUILDKITE_COMMIT also +# publish a commit-scoped tag, either as the primary tag or an additional alias. +# NIGHTLY=1 builds on the stable branch can additionally set +# CI_BASE_IMAGE_TAG_STABLE to refresh rocm/vllm-dev:ci_base. variable "CI_BASE_IMAGE_TAG" { default = "rocm/vllm-dev:ci_base" } -variable "CI_BASE_IMAGE_TAG_CONTENT" { +# Supplemental tags only. ci-bake-rocm.sh leaves these empty when the same ref +# is already the primary CI_BASE_IMAGE_TAG. +variable "CI_BASE_IMAGE_TAG_COMMIT_EXTRA" { + default = "" +} + +variable "CI_BASE_IMAGE_TAG_CONTENT_EXTRA" { default = "" } @@ -357,7 +365,8 @@ target "ci-base-rocm-ci" { cache-from = concat( compact([ CI_BASE_IMAGE_TAG != "" ? "type=registry,ref=${CI_BASE_IMAGE_TAG}" : "", - CI_BASE_IMAGE_TAG_CONTENT != "" ? "type=registry,ref=${CI_BASE_IMAGE_TAG_CONTENT}" : "", + CI_BASE_IMAGE_TAG_COMMIT_EXTRA != "" ? "type=registry,ref=${CI_BASE_IMAGE_TAG_COMMIT_EXTRA}" : "", + CI_BASE_IMAGE_TAG_CONTENT_EXTRA != "" ? "type=registry,ref=${CI_BASE_IMAGE_TAG_CONTENT_EXTRA}" : "", CI_BASE_IMAGE_TAG_STABLE != "" ? "type=registry,ref=${CI_BASE_IMAGE_TAG_STABLE}" : "", ]), # Import upstream dependency caches so RIXL/ROCShmem/DeepEP stages @@ -365,7 +374,7 @@ target "ci-base-rocm-ci" { get_cache_from_rocm_deps(), ) cache-to = ["type=inline"] - tags = compact([CI_BASE_IMAGE_TAG, CI_BASE_IMAGE_TAG_CONTENT, CI_BASE_IMAGE_TAG_STABLE]) + tags = compact([CI_BASE_IMAGE_TAG, CI_BASE_IMAGE_TAG_COMMIT_EXTRA, CI_BASE_IMAGE_TAG_CONTENT_EXTRA, CI_BASE_IMAGE_TAG_STABLE]) output = ["type=registry"] } diff --git a/docker/versions.json b/docker/versions.json index 15f77648a9c0..4dffa00985c9 100644 --- a/docker/versions.json +++ b/docker/versions.json @@ -34,15 +34,6 @@ "INSTALL_KV_CONNECTORS": { "default": "false" }, - "TORCH_CUDA_ARCH_LIST": { - "default": "7.5 8.0 8.6 8.9 9.0 10.0 11.0 12.0+PTX" - }, - "MAX_JOBS": { - "default": "2" - }, - "NVCC_THREADS": { - "default": "8" - }, "SCCACHE_BUCKET_NAME": { "default": "vllm-build-sccache" }, @@ -52,6 +43,15 @@ "SCCACHE_S3_NO_CREDENTIALS": { "default": "0" }, + "TORCH_CUDA_ARCH_LIST": { + "default": "7.5 8.0 8.6 8.9 9.0 10.0 11.0 12.0" + }, + "MAX_JOBS": { + "default": "2" + }, + "NVCC_THREADS": { + "default": "8" + }, "vllm_target_device": { "default": "cuda" }, @@ -68,7 +68,7 @@ "default": "true" }, "FLASHINFER_VERSION": { - "default": "0.6.12" + "default": "0.6.13" }, "GDRCOPY_CUDA_VERSION": { "default": "12.8" diff --git a/docs/assets/contributing/dockerfile-stages-dependency.png b/docs/assets/contributing/dockerfile-stages-dependency.png index b4f505493add..8cb98a8f4e45 100644 Binary files a/docs/assets/contributing/dockerfile-stages-dependency.png and b/docs/assets/contributing/dockerfile-stages-dependency.png differ diff --git a/docs/benchmarking/cli.md b/docs/benchmarking/cli.md index 6d0b2a01aca5..692cb4918ba6 100644 --- a/docs/benchmarking/cli.md +++ b/docs/benchmarking/cli.md @@ -37,7 +37,7 @@ th { | HuggingFace-HumanEval | ✅ | ✅ | `openai/openai_humaneval` | | HuggingFace-GSM8K | ✅ | ✅ | `openai/gsm8k` | | HuggingFace-Blazedit | ✅ | ✅ | `vdaita/edit_5k_char`, `vdaita/edit_10k_char` | -| HuggingFace-ASR | ✅ | ✅ | `openslr/librispeech_asr`, `facebook/voxpopuli`, `LIUM/tedlium`, `edinburghcstr/ami`, `speechcolab/gigaspeech`, `kensho/spgispeech` | +| HuggingFace-ASR | ✅ | ✅ | `openslr/librispeech_asr`, `facebook/voxpopuli`, `LIUM/tedlium`, `edinburghcstr/ami`, `speechcolab/gigaspeech`, `kensho/spgispeech`, `ArtificialAnalysis/Earnings22-Cleaned-AA`, `D4nt3/esb-datasets-earnings22-validation-tiny-filtered` | | Spec Bench | ✅ | ✅ | `wget https://raw.githubusercontent.com/hemingkx/Spec-Bench/refs/heads/main/data/spec_bench/question.jsonl` | | SPEED-Bench | ✅ | ✅ | `curl -LsSf https://raw.githubusercontent.com/NVIDIA-NeMo/Skills/refs/heads/main/nemo_skills/dataset/speed-bench/prepare.py \| python3 -` | | Custom | ✅ | ✅ | Local file: `data.jsonl` | @@ -338,7 +338,7 @@ vllm bench serve \ --model meta-llama/Meta-Llama-3-8B-Instruct \ --dataset-name spec_bench \ --dataset-path "/data/spec_bench/question.jsonl" \ - --num-prompts -1 + --num-prompts -1 \ --spec-bench-category "summarization" ``` @@ -352,7 +352,7 @@ vllm bench serve \ First, download the dataset to a folder, using this one liner: ```bash -curl -LsSf https://raw.githubusercontent.com/NVIDIA-NeMo/Skills/refs/heads/main/nemo_skills/dataset/speed-bench/prepare.py \| python3 - +curl -LsSf https://raw.githubusercontent.com/NVIDIA-NeMo/Skills/refs/heads/main/nemo_skills/dataset/speed-bench/prepare.py | python3 - ``` The command supports also the following arguments: @@ -388,7 +388,7 @@ vllm bench serve \ --model meta-llama/Llama-3.3-70B-Instruct \ --dataset-name speed_bench \ --dataset-path "/data/speed_bench" \ - --num-prompts -1 + --num-prompts -1 \ --speed-bench-category "multilingual" ``` @@ -398,13 +398,54 @@ Run all categories in the Throughput split (2k ISL): vllm bench serve \ --model meta-llama/Llama-3.3-70B-Instruct \ --dataset-name speed_bench \ - --speed-bench-dataset-subset throughput_2k + --speed-bench-dataset-subset throughput_2k \ --dataset-path "/data/speed_bench/" \ --num-prompts -1 ``` Available categories include `[high_entropy, mixed, low_entropy]`, where high entropy data contains unstructued data such as creative writing while low entropy data contains more structured data such as coding, more details are in the dataset card. +#### BFCL (Tool-Calling) Benchmark + +The Berkeley Function Calling Leaderboard (BFCL) dataset measures serving +latency and throughput on realistic tool-calling traffic. Each request +carries a per-sample `tools` schema and chat history, so the server must +expose `/v1/chat/completions` with an auto-tool-choice parser enabled. +The benchmark client always uses the `openai-chat` backend. + +Start a tool-parser-enabled server, then run the bench. For example, with +`gpt-oss-20b`: + +```bash +# Server +vllm serve openai/gpt-oss-20b \ + --enable-auto-tool-choice \ + --tool-call-parser openai \ + --reasoning-parser openai_gptoss + +# Client +vllm bench serve \ + --backend openai-chat \ + --endpoint /v1/chat/completions \ + --model openai/gpt-oss-20b \ + --dataset-name hf \ + --dataset-path gorilla-llm/Berkeley-Function-Calling-Leaderboard \ + --bfcl-categories simple,live_simple,multiple \ + --num-prompts 200 +``` + +`--bfcl-categories` is a comma-separated list of BFCL v3 category names +(without the `BFCL_v3_` prefix or `.json` suffix). Defaults to +`simple,live_simple,multiple`. Other supported non-multi-turn categories +include `parallel`, `live_parallel`, `parallel_multiple`, +`live_parallel_multiple`, `irrelevance`, `live_irrelevance`, +`live_relevance`, `java`, `javascript`, and `rest`. Multi-turn categories +are not yet supported. + +The dataset class normalizes BFCL's loose schema dialect (`dict` → +`object`, `float` → `number`, `tuple` → `array`, `any` → `string`) so +modern grammar backends accept the translated tool definitions. + #### Other HuggingFaceDataset Examples ```bash @@ -491,7 +532,7 @@ vllm bench serve \ --blazedit-max-distance 0.99 ``` -`openslr/librispeech_asr`, `facebook/voxpopuli`, `LIUM/tedlium`, `edinburghcstr/ami`, `speechcolab/gigaspeech`, `kensho/spgispeech` +`openslr/librispeech_asr`, `facebook/voxpopuli`, `LIUM/tedlium`, `edinburghcstr/ami`, `speechcolab/gigaspeech`, `kensho/spgispeech`, `ArtificialAnalysis/Earnings22-Cleaned-AA`, `D4nt3/esb-datasets-earnings22-validation-tiny-filtered` ```bash vllm bench serve \ @@ -1296,7 +1337,7 @@ Serve and benchmark VLM2Vec: # Run this in another process vllm serve TIGER-Lab/VLM2Vec-Full --runner pooling \ --trust-remote-code \ - --chat-template examples/template_vlm2vec_phi3v.jinja + --chat-template examples/pooling/embed/template/vlm2vec_phi3v.jinja # Run these one by one after the server is up # download dataset diff --git a/docs/cli/README.md b/docs/cli/README.md index 08e986a74630..123f3f109a9d 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -50,6 +50,21 @@ vllm serve --help=max-num-seqs vllm serve --help=max ``` +!!! tip "Human-readable integer arguments" + Many integer arguments accept human-readable suffixes for convenience. For example: + + - `1k` = 1,000 (decimal kilo) + - `1K` = 1,024 (binary kibibyte) + - `1m` = 1,000,000 (decimal mega) + - `1M` = 1,048,576 (binary mebibyte) + - `1g` / `1G` = 1 billion / 1 gibibyte + - `1t` / `1T` = 1 trillion / 1 tebibyte + + Decimal suffixes (`k`, `m`, `g`, `t`) also accept floating point: `25.6k` = 25,600. + Binary suffixes (`K`, `M`, `G`, `T`) require integers: `32K` = 32,768. + + Supported arguments include: `--max-model-len`, `--max-num-batched-tokens`, `--max-num-scheduled-tokens`, `--kv-cache-memory-bytes`, `--safetensors-prefetch-block-size`. + See [vllm serve](./serve.md) for the full reference of all available arguments. ## launch @@ -80,6 +95,9 @@ vllm chat --url http://{vllm-serve-host}:{vllm-serve-port}/v1 # Quick chat with a single prompt vllm chat --quick "hi" + +# Print TTFT and throughput statistics after each response +vllm chat --stats ``` See [vllm chat](./chat.md) for the full reference of all available arguments. @@ -97,6 +115,9 @@ vllm complete --url http://{vllm-serve-host}:{vllm-serve-port}/v1 # Quick complete with a single prompt vllm complete --quick "The future of AI is" + +# Print TTFT and throughput statistics after each response +vllm complete --stats ``` See [vllm complete](./complete.md) for the full reference of all available arguments. diff --git a/docs/configuration/conserving_memory.md b/docs/configuration/conserving_memory.md index 2c098118dbb1..96a903bc31d3 100644 --- a/docs/configuration/conserving_memory.md +++ b/docs/configuration/conserving_memory.md @@ -42,7 +42,7 @@ and the maximum batch size (`max_num_seqs` option). ```python from vllm import LLM -llm = LLM(model="adept/fuyu-8b", max_model_len=2048, max_num_seqs=2) +llm = LLM(model="Qwen/Qwen2.5-VL-3B-Instruct", max_model_len=2048, max_num_seqs=2) ``` ## Reduce CUDA Graphs diff --git a/docs/configuration/optimization.md b/docs/configuration/optimization.md index 5bf789a0919b..efa0f8b9046d 100644 --- a/docs/configuration/optimization.md +++ b/docs/configuration/optimization.md @@ -16,6 +16,14 @@ vLLM provides 4 optimization levels (`-O0`, `-O1`, `-O2`, `-O3`) that allow user For more information, see the [optimization level documentation](../design/optimization_levels.md). +## Faster Startup + +Beyond the optimization levels, three mechanisms reduce time-to-first-token on repeated boots of the same (model, config, hardware) combination: + +- **Reuse the compile cache.** vLLM persists `torch.compile` artifacts under `VLLM_CACHE_ROOT` (default `~/.cache/vllm`), and the cache directory can be copied between machines or baked into a container image; see the [torch.compile design doc](../design/torch_compile.md). Set `VLLM_FORCE_AOT_LOAD=1` to fail loudly instead of silently recompiling when the cache misses (any change to the model, config, relevant `VLLM_*` environment variables, torch build, or GPU model invalidates it). +- **Skip memory profiling with `--kv-cache-memory`.** On startup, vLLM logs the exact `--kv-cache-memory` value that reproduces the current allocation. Passing it back on the next boot skips the memory-profiling measurement and the CUDA-graph memory estimation pass. Note that this has performance implications: the KV cache is sized to exactly the given value instead of being measured, so a conservative value caps batch concurrency (and therefore throughput), while an optimistic one fails at allocation time. The value is only valid on the same GPU with the same initial free memory; if a boot OOMs after hardware or co-tenant changes, remove the flag to re-profile. +- **Serve without CUDA graphs using `--enforce-eager`.** Skips both compilation and CUDA-graph capture for the fastest possible startup, at the cost of steady-state decode performance. Useful for development loops and for measuring how much of a boot is compile/capture. + ## Preemption Due to the autoregressive nature of transformer architecture, there are times when KV cache space is insufficient to handle all batched requests. @@ -109,7 +117,7 @@ from vllm import LLM # Combine pipeline and tensor parallelism llm = LLM( - model="meta-llama/Llama-3.3-70B-Instruct, + model="meta-llama/Llama-3.3-70B-Instruct", tensor_parallel_size=4, pipeline_parallel_size=2, ) @@ -276,8 +284,9 @@ By default vLLM uses the standard Hugging Face `tokenizers` library to power the fast tokenizer. For BPE tokenizers (Qwen, Llama, DeepSeek, GPT-OSS, etc.) you can switch to the [fastokens](https://github.com/crusoecloud/fastokens) Rust backend, a drop-in replacement that's substantially faster on -encode/decode and on streaming detokenization. Enable it by setting -`VLLM_USE_FASTOKENS=1`: +encode/decode and on streaming detokenization. `VLLM_USE_FASTOKENS` is +available in vLLM v0.23.0 and later. If your installed vLLM version does not +recognize the environment variable, upgrade vLLM before enabling the override: ```console VLLM_USE_FASTOKENS=1 vllm serve Qwen/Qwen3-8B @@ -296,8 +305,8 @@ llm = LLM(model="Qwen/Qwen3-8B") The `fastokens` Python package (>= 0.2.0) must be installed; if it isn't, vLLM raises a clear `ImportError` at tokenizer load. The override applies to any `--tokenizer-mode` that ends up loading an HF fast tokenizer (`hf`, -`deepseek_v32`, `deepseek_v4`, `qwen_vl`, …). Modes that don't use the HF -fast tokenizer (`mistral`, `grok2`, `kimi_audio`) ignore the flag. +`deepseek_v32`, `deepseek_v4`, …). Models that don't use the HF +fast tokenizer (`mistral`, `kimi_audio`) ignore the flag. Tokenizer-bound workloads — long shared prefixes, bursty short prompts, batch detokenization — see the largest wins. If your bottleneck is GPU diff --git a/docs/contributing/README.md b/docs/contributing/README.md index 9b5e26d0fed8..34dc385db78d 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -101,7 +101,7 @@ vLLM's `pre-commit` hooks will now run automatically every time you commit. Some `pre-commit` hooks only run in CI. If you need to, you can run them locally with: ```bash - pre-commit run --hook-stage manual mypy-3.10 + pre-commit run --hook-stage manual mypy-3.11 ``` ### Documentation @@ -304,9 +304,15 @@ review process: resources. The reviewer will add `ready` label to the PR when the PR is ready to merge or a full CI run is needed. -### Escalating Stalled Contributions +### Pull Request Limits and Escalation -If you have an important contribution that has not yet received maintainer attention, please email us at: +vLLM uses GitHub's [pull request limit](https://github.blog/open-source/maintainers/how-pull-request-limits-are-cutting-down-the-noise/) +for contributors without write access. The current cap is 6 open PRs. If this +blocks well-intentioned critical work, contact a committer to request bypass +list access. + +If you need an expedited review for an important contribution, please email us +at: diff --git a/docs/contributing/ci/failures.md b/docs/contributing/ci/failures.md index a0038f461a04..c57c430478f5 100644 --- a/docs/contributing/ci/failures.md +++ b/docs/contributing/ci/failures.md @@ -60,15 +60,21 @@ the failure? ## Logs Wrangling -Download a job's log (no Buildkite login required): - +Logs are public; no Buildkite login needed. [.buildkite/scripts/ci-fetch-log.sh](../../../.buildkite/scripts/ci-fetch-log.sh) +saves each log as `ci--.log`, stripped of timestamps and +ANSI codes: ```bash -# Find the failing job. Each row's URL is .../builds/#: -gh pr checks --repo vllm-project/vllm +# All failed jobs in a PR's latest build (current branch's PR if omitted): +.buildkite/scripts/ci-fetch-log.sh --pr + +# All failed jobs in a build (--soft also includes soft-failed jobs; +# --all fetches every finished job): +.buildkite/scripts/ci-fetch-log.sh "https://buildkite.com/vllm/ci/builds/" -# Download + strip timestamps/ANSI in one step: +# One job — `gh pr checks` URLs (#) and web UI URLs (?sid=) both +# work; pass "-" as a second argument to stream to stdout: .buildkite/scripts/ci-fetch-log.sh "https://buildkite.com/vllm/ci/builds/#" ``` diff --git a/docs/contributing/ci/nightly_builds.md b/docs/contributing/ci/nightly_builds.md index 8f3512db3d40..10c4a4372403 100644 --- a/docs/contributing/ci/nightly_builds.md +++ b/docs/contributing/ci/nightly_builds.md @@ -14,7 +14,7 @@ Wheels are built in the `Release` pipeline (`.buildkite/release-pipeline.yaml`) Each build step: 1. Builds the wheel in a Docker container. -2. Renames the wheel filename to use the correct manylinux tag (currently `manylinux_2_31`) for PEP 600 compliance. +2. Renames the wheel filename to use the correct manylinux tag (currently `manylinux_2_28`) for PEP 600 compliance. 3. Uploads the wheel to S3 bucket `vllm-wheels` under `/{commit_hash}/`. ### Index Generation diff --git a/docs/contributing/model/basic.md b/docs/contributing/model/basic.md index dceb78f52638..0cc24baae922 100644 --- a/docs/contributing/model/basic.md +++ b/docs/contributing/model/basic.md @@ -133,10 +133,10 @@ The model should inherit protocol `IsAttentionFree` and also implement class met For the mamba layers themselves, please use the [`MambaMixer`](../../../vllm/model_executor/layers/mamba/mamba_mixer.py) (for Mamba-1) or [`MambaMixer2`](../../../vllm/model_executor/layers/mamba/mamba_mixer2.py) (for Mamba-2) classes. The model should also be added to the `MODELS_CONFIG_MAP` dictionary in [vllm/model_executor/models/config.py](../../../vllm/model_executor/models/config.py) to ensure that the runtime defaults are optimized. -For case (2), we recommend using as a reference the implementation of [`JambaForCausalLM`](../../../vllm/model_executor/models/jamba.py) (for an example of a model that uses Mamba-1 and attention together) or [`BambaForCausalLM`](../../../vllm/model_executor/models/bamba.py) (for an example of a model that uses Mamba-2 and attention together). +For case (2), we recommend using as a reference the implementation of [`JambaForCausalLM`](../../../vllm/model_executor/models/jamba.py) (for an example of a model that uses Mamba-1 and attention together) or [`NemotronHForCausalLM`](../../../vllm/model_executor/models/nemotron_h.py) (for an example of a model that uses Mamba-2 and attention together). These models should follow the same instructions as case (1), but they should inherit protocol `IsHybrid` (instead of `IsAttentionFree`) and it is *not* necessary to add them to the `MODELS_CONFIG_MAP` (their runtime defaults will be inferred from the protocol). -For case (3), we recommend looking at the implementation of [`MiniMaxText01ForCausalLM`](../../../vllm/model_executor/models/minimax_text_01.py) or [`Lfm2ForCausalLM`](../../../vllm/model_executor/models/lfm2.py) as a reference, which use custom "mamba-like" layers `MiniMaxText01LinearAttention` and `ShortConv` respectively. +For case (3), we recommend looking at the implementation of [`Lfm2ForCausalLM`](../../../vllm/model_executor/models/lfm2.py) as a reference, which uses a custom "mamba-like" layer `ShortConv`. Please follow the same guidelines as case (2) for implementing these models. We use "mamba-like" to refer to layers that possess a state that is updated in-place, rather than being appended-to (like KV cache for attention). For implementing new custom mamba-like layers, one should inherit from `MambaBase` and implement the methods `get_state_dtype`, `get_state_shape` to calculate the data types and state shapes at runtime, as well as `mamba_type` and `get_attn_backend`. @@ -144,5 +144,5 @@ It is also necessary to implement the "attention meta-data" class which handles Please see [`LinearAttentionMetadata`](../../../vllm/v1/attention/backends/linear_attn.py) or [`ShortConvAttentionMetadata`](../../../vllm/v1/attention/backends/short_conv_attn.py) for examples of this. It is also worth noting that we should update `MambaAttentionBackendEnum` in [`registry.py`](../../../vllm/v1/attention/backends/registry.py) when adding a new mamba backend. Finally, if one wants to support torch compile and CUDA graphs, it necessary to wrap the call to the mamba-like layer inside a custom op and register it. -Please see the calls to `direct_register_custom_op` in [vllm/model_executor/models/minimax_text_01.py](../../../vllm/model_executor/models/minimax_text_01.py) or [vllm/model_executor/layers/mamba/short_conv.py](../../../vllm/model_executor/layers/mamba/short_conv.py) for examples of this. +Please see the calls to `direct_register_custom_op` in [vllm/model_executor/layers/mamba/linear/minimax_linear_attn.py](../../../vllm/model_executor/layers/mamba/linear/minimax_linear_attn.py) or [vllm/model_executor/layers/mamba/short_conv.py](../../../vllm/model_executor/layers/mamba/short_conv.py) for examples of this. The new custom op should then be added to the list `_attention_ops` in [vllm/config/compilation.py](../../../vllm/config/compilation.py) to ensure that piecewise CUDA graphs works as intended. diff --git a/docs/contributing/model/multimodal.md b/docs/contributing/model/multimodal.md index 67cde8df987e..33d89db75d3c 100644 --- a/docs/contributing/model/multimodal.md +++ b/docs/contributing/model/multimodal.md @@ -324,154 +324,44 @@ Assuming that the memory usage increases with the number of tokens, the dummy in return image_token * num_images ``` -=== "No input placeholders: Fuyu" +=== "No input placeholders: PaliGemma" - Looking at the code of HF's `FuyuForCausalLM`: + Unlike LLaVA, PaliGemma's HF processor does not expect image placeholder + tokens in the input prompt; the placeholder feature tokens are instead + inserted afterwards (see [Prompt updates](#prompt-updates)). So the dummy + prompt text is empty regardless of the number of images: - ??? code - - ```python - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/modeling_fuyu.py#L311-L322 - if image_patches is not None and past_key_values is None: - patch_embeddings = [ - self.vision_embed_tokens(patch.to(self.vision_embed_tokens.weight.dtype)) - .squeeze(0) - .to(inputs_embeds.device) - for patch in image_patches - ] - inputs_embeds = self.gather_continuous_embeddings( - word_embeddings=inputs_embeds, - continuous_embeddings=patch_embeddings, - image_patch_input_indices=image_patches_indices, - ) - ``` - - The number of placeholder feature tokens for the `i`th item in the batch is `patch_embeddings[i].shape[0]`, - which is the same as `image_patches[i].shape[0]`, i.e. `num_total_patches`. - - Unlike LLaVA, Fuyu does not define the number of patches inside the modeling file. Where can we get more information? - Considering that the model input comes from the output of `FuyuProcessor`, let's **look at the preprocessing files**. - - The image outputs are obtained by calling `FuyuImageProcessor.preprocess` and then - `FuyuImageProcessor.preprocess_with_tokenizer_info` inside `FuyuProcessor`. - - In `FuyuImageProcessor.preprocess`, the images are resized and padded to the target `FuyuImageProcessor.size`, - returning the dimensions after resizing (but before padding) as metadata. - - ??? code - - ```python - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/processing_fuyu.py#L541-L544 - image_encoding = self.image_processor.preprocess(images, **output_kwargs["images_kwargs"]) - batch_images = image_encoding["images"] - image_unpadded_heights = image_encoding["image_unpadded_heights"] - image_unpadded_widths = image_encoding["image_unpadded_widths"] - - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/image_processing_fuyu.py#L480-L - if do_resize: - batch_images = [ - [self.resize(image, size=size, input_data_format=input_data_format) for image in images] - for images in batch_images - ] - - image_sizes = [get_image_size(images[0], channel_dim=input_data_format) for images in batch_images] - image_unpadded_heights = [[image_size[0]] for image_size in image_sizes] - image_unpadded_widths = [[image_size[1]] for image_size in image_sizes] - - if do_pad: - batch_images = [ - [ - self.pad_image( - image, - size=size, - mode=padding_mode, - constant_values=padding_value, - input_data_format=input_data_format, - ) - for image in images - ] - for images in batch_images - ] - ``` - - In `FuyuImageProcessor.preprocess_with_tokenizer_info`, the images are split into patches based on this metadata: - - ??? code - - ```python - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/processing_fuyu.py#L417-L425 - model_image_input = self.image_processor.preprocess_with_tokenizer_info( - image_input=tensor_batch_images, - image_present=image_present, - image_unpadded_h=image_unpadded_heights, - image_unpadded_w=image_unpadded_widths, - image_placeholder_id=image_placeholder_id, - image_newline_id=image_newline_id, - variable_sized=True, - ) - - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/image_processing_fuyu.py#L638-L658 - image_height, image_width = image.shape[1], image.shape[2] - if variable_sized: # variable_sized=True - new_h = min( - image_height, - math.ceil(image_unpadded_h[batch_index, subseq_index] / patch_height) * patch_height, - ) - new_w = min( - image_width, - math.ceil(image_unpadded_w[batch_index, subseq_index] / patch_width) * patch_width, - ) - image = image[:, :new_h, :new_w] - image_height, image_width = new_h, new_w - - num_patches = self.get_num_patches(image_height=image_height, image_width=image_width) - tensor_of_image_ids = torch.full( - [num_patches], image_placeholder_id, dtype=torch.int32, device=image_input.device - ) - patches = self.patchify_image(image=image.unsqueeze(0)).squeeze(0) - assert num_patches == patches.shape[0] - ``` + ```python + def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str: + return "" + ``` - The number of patches is in turn defined by `FuyuImageProcessor.get_num_patches`: + PaliGemma resizes every image to a square of `vision_config.image_size`, so + the number of placeholder feature tokens per image is fixed at + `(image_size // patch_size) ** 2`. This is computed by the SigLIP vision + encoder that PaliGemma uses: ??? code ```python - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/image_processing_fuyu.py#L552-L562 - patch_size = patch_size if patch_size is not None else self.patch_size - patch_height, patch_width = self.patch_size["height"], self.patch_size["width"] - - if image_height % patch_height != 0: - raise ValueError(f"{image_height=} must be divisible by {patch_height}") - if image_width % patch_width != 0: - raise ValueError(f"{image_width=} must be divisible by {patch_width}") - - num_patches_per_dim_h = image_height // patch_height - num_patches_per_dim_w = image_width // patch_width - num_patches = num_patches_per_dim_h * num_patches_per_dim_w + # vllm/model_executor/models/siglip.py + class SiglipEncoderInfo(VisionEncoderInfo[SiglipVisionConfig]): + def get_num_image_tokens( + self, + *, + image_width: int, + image_height: int, + ) -> int: + return self.get_patch_grid_length() ** 2 + + def get_patch_grid_length(self) -> int: + image_size, patch_size = self.get_image_size(), self.get_patch_size() + return image_size // patch_size ``` - These image patches correspond to placeholder tokens (`|SPEAKER|`). So, we just need to maximize the number of image patches. Since input images are first resized - to fit within `image_processor.size`, we can maximize the number of image patches by inputting an image with size equal to `image_processor.size`. - - ```python - def get_image_size_with_most_features(self) -> ImageSize: - image_processor = self.get_image_processor() - return ImageSize( - width=image_processor.size["width"], - height=image_processor.size["height"], - ) - ``` - - Fuyu does not expect image placeholders in the inputs to HF processor, so - the dummy prompt text is empty regardless of the number of images. - - ```python - def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str: - return "" - ``` - - For the multimodal image profiling data, the logic is very similar to LLaVA: + Since the number of image tokens doesn't depend on the input image dimensions, + we can simply use a dummy image of the model's expected input size for the + multimodal profiling data: ??? code @@ -482,16 +372,18 @@ Assuming that the memory usage increases with the number of tokens, the dummy in mm_counts: Mapping[str, int], mm_options: Mapping[str, BaseDummyOptions], ) -> MultiModalDataDict: - target_width, target_height = \ - self.info.get_image_size_with_most_features() + hf_config = self.info.get_hf_config() + vision_config = hf_config.vision_config + max_image_size = vision_config.image_size + num_images = mm_counts.get("image", 0) image_overrides = mm_options.get("image") return { "image": self._get_dummy_images( - width=target_width, - height=target_height, + width=max_image_size, + height=max_image_size, num_images=num_images, overrides=image_overrides, ) @@ -545,28 +437,15 @@ return a schema of the tensors outputted by the HF processor that are related to Our [actual code](../../../vllm/model_executor/models/llava.py) additionally supports pre-computed image embeddings, which can be passed to be model via the `image_embeds` argument. -=== "With postprocessing: Fuyu" +=== "With postprocessing: Mistral3" - The `image_patches` output of `FuyuImageProcessor.preprocess_with_tokenizer_info` concatenates - the patches from each image belonging to an item in the batch: + The `pixel_values` output of Mistral3's HF processor pads every image in the + batch to a common size, so that they can be stacked into a single tensor. - ```python - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/image_processing_fuyu.py#L673-L679 - image_input_ids.append(tensor_of_image_ids) - image_patches.append(patches) - else: - image_input_ids.append(torch.tensor([], dtype=torch.int32, device=image_input.device)) - - batch_image_input_ids.append(image_input_ids) - batch_image_patches.append(image_patches) - ``` - - The shape of `image_patches` outputted by `FuyuImageProcessor` is therefore - `(1, num_images, num_patches, patch_width * patch_height * num_channels)`. - - In order to support the use of - [MultiModalFieldConfig.batched][vllm.multimodal.inputs.MultiModalFieldConfig.batched] - like in LLaVA, we remove the extra batch dimension by overriding + To use [MultiModalFieldConfig.batched][vllm.multimodal.inputs.MultiModalFieldConfig.batched] + like in LLaVA, each image's features must be independent of the others (which + is also required for prefix caching to work correctly). So, we un-pad each image + back to its own size by overriding [BaseMultiModalProcessor._call_hf_processor][vllm.multimodal.processing.BaseMultiModalProcessor._call_hf_processor]: ??? code @@ -586,33 +465,27 @@ return a schema of the tensors outputted by the HF processor that are related to tok_kwargs=tok_kwargs, ) - image_patches = processed_outputs.get("image_patches") - if image_patches is not None: - images = mm_data["images"] - assert isinstance(images, list) + pixel_values = processed_outputs.get("pixel_values") + if pixel_values is not None: + # Avoid padding since we need the output for each image to be + # independent of other images for the cache to work correctly + image_sizes = processed_outputs["image_sizes"] + assert len(pixel_values) == len(image_sizes) - # Original output: (1, num_images, Pn, Px * Py * C) - # New output: (num_images, Pn, Px * Py * C) - assert (isinstance(image_patches, list) - and len(image_patches) == 1) - assert (isinstance(image_patches[0], torch.Tensor) - and len(image_patches[0]) == len(images)) - - processed_outputs["image_patches"] = image_patches[0] + processed_outputs["pixel_values"] = [ + p[:, :h, :w] for p, (h, w) in zip(pixel_values, image_sizes) + ] return processed_outputs ``` - !!! note - Our [actual code](../../../vllm/model_executor/models/fuyu.py) has special handling - for text-only inputs to prevent unnecessary warnings from HF processor. - !!! note The `_call_hf_processor` method specifies both `mm_kwargs` and `tok_kwargs` for processing. `mm_kwargs` is used to both initialize and call the huggingface processor, whereas `tok_kwargs` is only used to call the huggingface processor. - This lets us override [_get_mm_fields_config][vllm.multimodal.processing.BaseMultiModalProcessor._get_mm_fields_config] as follows: + Since `pixel_values` is now a list with one tensor per image, we can override + [_get_mm_fields_config][vllm.multimodal.processing.BaseMultiModalProcessor._get_mm_fields_config] as follows: ```python def _get_mm_fields_config( @@ -620,9 +493,15 @@ return a schema of the tensors outputted by the HF processor that are related to hf_inputs: BatchFeature, hf_processor_mm_kwargs: Mapping[str, object], ) -> Mapping[str, MultiModalFieldConfig]: - return dict(image_patches=MultiModalFieldConfig.batched("image")) + return dict( + pixel_values=MultiModalFieldConfig.batched("image"), + image_embeds=MultiModalFieldConfig.batched("image"), + ) ``` + !!! note + See our [actual code](../../../vllm/model_executor/models/mistral3.py) for the full implementation. + ### Prompt updates Override [_get_prompt_updates][vllm.multimodal.processing.BaseMultiModalProcessor._get_prompt_updates] to @@ -678,121 +557,53 @@ Each [PromptUpdate][vllm.multimodal.processing.PromptUpdate] instance specifies ] ``` -=== "Handling additional tokens: Fuyu" - - Recall the layout of feature tokens from Step 2: - - ``` - |SPEAKER||SPEAKER|...|SPEAKER||NEWLINE| - |SPEAKER||SPEAKER|...|SPEAKER||NEWLINE| - ... - |SPEAKER||SPEAKER|...|SPEAKER||NEWLINE| - ``` - - We define a helper function to return `ncols` and `nrows` directly: - - ??? code - - ```python - def get_image_feature_grid_size( - self, - *, - image_width: int, - image_height: int, - ) -> tuple[int, int]: - image_processor = self.get_image_processor() - target_width = image_processor.size["width"] - target_height = image_processor.size["height"] - patch_width = image_processor.patch_size["width"] - patch_height = image_processor.patch_size["height"] - - if not (image_width <= target_width and image_height <= target_height): - height_scale_factor = target_height / image_height - width_scale_factor = target_width / image_width - optimal_scale_factor = min(height_scale_factor, width_scale_factor) - - image_height = int(image_height * optimal_scale_factor) - image_width = int(image_width * optimal_scale_factor) - - ncols = math.ceil(image_width / patch_width) - nrows = math.ceil(image_height / patch_height) - return ncols, nrows - ``` +=== "Handling additional tokens: PaliGemma" - Based on this, we can initially define our replacement tokens as: + PaliGemma's HF processor inserts, after the prompt's leading `` token, a + run of image tokens followed by a second `` token that marks the start of + the text prompt. We start by building the run of image tokens, one per + placeholder feature token: ??? code ```python - def get_replacement(item_idx: int): - images = mm_items.get_items("image", ImageProcessorItems) - image_size = images.get_image_size(item_idx) - - ncols, nrows = self.info.get_image_feature_grid_size( - image_width=image_size.width, - image_height=image_size.height, + def get_insertion(item_idx: int): + images = mm_items.get_items( + "image", (ImageEmbeddingItems, ImageProcessorItems) ) - # `_IMAGE_TOKEN_ID` corresponds to `|SPEAKER|` - # `_NEWLINE_TOKEN_ID` corresponds to `|NEWLINE|` - return ([_IMAGE_TOKEN_ID] * ncols + [_NEWLINE_TOKEN_ID]) * nrows - ``` - - However, this is not entirely correct. After `FuyuImageProcessor.preprocess_with_tokenizer_info` is called, - a BOS token (``) is also added to the prompt: - - ??? code + if isinstance(images, ImageEmbeddingItems): + num_image_tokens = images.get_feature_size(item_idx) + else: + image_size = images.get_image_size(item_idx) + num_image_tokens = self.info.get_num_image_tokens( + image_width=image_size.width, + image_height=image_size.height, + ) - ```python - # https://github.com/huggingface/transformers/blob/v4.48.3/src/transformers/models/fuyu/processing_fuyu.py#L417-L435 - model_image_input = self.image_processor.preprocess_with_tokenizer_info( - image_input=tensor_batch_images, - image_present=image_present, - image_unpadded_h=image_unpadded_heights, - image_unpadded_w=image_unpadded_widths, - image_placeholder_id=image_placeholder_id, - image_newline_id=image_newline_id, - variable_sized=True, - ) - prompt_tokens, prompts_length = _tokenize_prompts_with_image_and_batch( - tokenizer=self.tokenizer, - prompts=prompts, - scale_factors=scale_factors, - max_tokens_to_generate=self.max_tokens_to_generate, - max_position_embeddings=self.max_position_embeddings, - add_BOS=True, - add_beginning_of_answer_token=True, - ) + image_tokens = [image_token_id] * num_image_tokens + ... ``` - To assign the vision embeddings to only the image tokens, instead of a string - you can return an instance of [PromptUpdateDetails][vllm.multimodal.processing.PromptUpdateDetails]: + The trailing `` token is an additional token that must **not** receive a + vision embedding. To assign the vision embeddings to only the image tokens, + instead of returning the token ids directly you can return an instance of + [PromptUpdateDetails][vllm.multimodal.processing.PromptUpdateDetails] and mark + the embedding tokens with `embed_token_id`: ??? code ```python - hf_config = self.info.get_hf_config() - bos_token_id = hf_config.bos_token_id # `` - assert isinstance(bos_token_id, int) - - def get_replacement_fuyu(item_idx: int): - images = mm_items.get_items("image", ImageProcessorItems) - image_size = images.get_image_size(item_idx) - - ncols, nrows = self.info.get_image_feature_grid_size( - image_width=image_size.width, - image_height=image_size.height, - ) - image_tokens = ([_IMAGE_TOKEN_ID] * ncols + [_NEWLINE_TOKEN_ID]) * nrows - - return PromptUpdateDetails.select_token_id( - image_tokens + [bos_token_id], - embed_token_id=_IMAGE_TOKEN_ID, - ) + return PromptUpdateDetails.select_token_id( + image_tokens + [bos_token_id], + embed_token_id=image_token_id, + ) ``` - Finally, noticing that the HF processor removes the `|ENDOFTEXT|` token from the tokenized prompt, - we can search for it to conduct the replacement at the start of the string: + Putting it together, we override [_get_prompt_updates][vllm.multimodal.processing.BaseMultiModalProcessor._get_prompt_updates]. + Since these tokens are inserted (rather than replacing an existing placeholder) + after the prompt's leading ``, we use [PromptInsertion][vllm.multimodal.processing.PromptInsertion] + with a prefix target: ??? code @@ -804,33 +615,41 @@ Each [PromptUpdate][vllm.multimodal.processing.PromptUpdate] instance specifies out_mm_kwargs: MultiModalKwargsItems, ) -> Sequence[PromptUpdate]: hf_config = self.info.get_hf_config() - bos_token_id = hf_config.bos_token_id - assert isinstance(bos_token_id, int) + image_token_id = hf_config.image_token_index tokenizer = self.info.get_tokenizer() - eot_token_id = tokenizer.bos_token_id - assert isinstance(eot_token_id, int) - def get_replacement_fuyu(item_idx: int): - images = mm_items.get_items("image", ImageProcessorItems) - image_size = images.get_image_size(item_idx) + bos_token_id = tokenizer.bos_token_id + assert isinstance(bos_token_id, int) - ncols, nrows = self.info.get_image_feature_grid_size( - image_width=image_size.width, - image_height=image_size.height, + def get_insertion(item_idx: int): + images = mm_items.get_items( + "image", (ImageEmbeddingItems, ImageProcessorItems) ) - image_tokens = ([_IMAGE_TOKEN_ID] * ncols + [_NEWLINE_TOKEN_ID]) * nrows + + if isinstance(images, ImageEmbeddingItems): + num_image_tokens = images.get_feature_size(item_idx) + else: + image_size = images.get_image_size(item_idx) + num_image_tokens = self.info.get_num_image_tokens( + image_width=image_size.width, + image_height=image_size.height, + ) + + image_tokens = [image_token_id] * num_image_tokens return PromptUpdateDetails.select_token_id( image_tokens + [bos_token_id], - embed_token_id=_IMAGE_TOKEN_ID, + embed_token_id=image_token_id, ) return [ - PromptReplacement( + PromptInsertion( modality="image", - target=[eot_token_id], - replacement=get_replacement_fuyu, + target=PromptIndexTargets.prefix( + [bos_token_id] if tokenizer.add_bos_token else [] + ), + insertion=get_insertion, ) ] ``` @@ -873,7 +692,7 @@ Examples: Examples: - Chameleon (appends `sep_token`): [vllm/model_executor/models/chameleon.py](../../../vllm/model_executor/models/chameleon.py) -- Fuyu (appends `boa_token`): [vllm/model_executor/models/fuyu.py](../../../vllm/model_executor/models/fuyu.py) +- Molmo2 (prepends `bos_token`): [vllm/model_executor/models/molmo2.py](../../../vllm/model_executor/models/molmo2.py) - Molmo (applies chat template which is not defined elsewhere): [vllm/model_executor/models/molmo.py](../../../vllm/model_executor/models/molmo.py) ### Custom HF processor @@ -884,4 +703,3 @@ Examples: - DeepSeek-VL2: [vllm/model_executor/models/deepseek_vl2.py](../../../vllm/model_executor/models/deepseek_vl2.py) - InternVL: [vllm/model_executor/models/internvl.py](../../../vllm/model_executor/models/internvl.py) -- Qwen-VL: [vllm/model_executor/models/qwen_vl.py](../../../vllm/model_executor/models/qwen_vl.py) diff --git a/docs/deployment/frameworks/lws.md b/docs/deployment/frameworks/lws.md index 47586bcd7003..5aae73c8a380 100644 --- a/docs/deployment/frameworks/lws.md +++ b/docs/deployment/frameworks/lws.md @@ -7,108 +7,202 @@ vLLM can be deployed with [LWS](https://github.com/kubernetes-sigs/lws) on Kuber ## Prerequisites -* At least two Kubernetes nodes, each with 8 GPUs, are required. -* Install LWS by following the instructions found [here](https://lws.sigs.k8s.io/docs/installation/). +- At least two Kubernetes nodes, each with 8 GPUs, are required. +- Install LWS by following the instructions found [here](https://lws.sigs.k8s.io/docs/installation/). ## Deploy and Serve -Deploy the following yaml file `lws.yaml` - -??? code "Yaml" - - ```yaml - apiVersion: leaderworkerset.x-k8s.io/v1 - kind: LeaderWorkerSet - metadata: - name: vllm - spec: - replicas: 1 - leaderWorkerTemplate: - size: 2 - restartPolicy: RecreateGroupOnPodRestart - leaderTemplate: - metadata: - labels: - role: leader - spec: - containers: - - name: vllm-leader - image: docker.io/vllm/vllm-openai:latest - env: - - name: HF_TOKEN - value: - command: - - sh - - -c - - "bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh leader --ray_cluster_size=$(LWS_GROUP_SIZE); - vllm serve meta-llama/Meta-Llama-3.1-405B-Instruct --port 8080 --tensor-parallel-size 8 --pipeline_parallel_size 2" - resources: - limits: - nvidia.com/gpu: "8" - memory: 1124Gi - ephemeral-storage: 800Gi - requests: - ephemeral-storage: 800Gi - cpu: 125 - ports: - - containerPort: 8080 - readinessProbe: - tcpSocket: - port: 8080 - initialDelaySeconds: 15 - periodSeconds: 10 - volumeMounts: - - mountPath: /dev/shm - name: dshm - volumes: - - name: dshm - emptyDir: - medium: Memory - sizeLimit: 15Gi - workerTemplate: - spec: - containers: - - name: vllm-worker - image: docker.io/vllm/vllm-openai:latest - command: - - sh - - -c - - "bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh worker --ray_address=$(LWS_LEADER_ADDRESS)" - resources: - limits: - nvidia.com/gpu: "8" - memory: 1124Gi - ephemeral-storage: 800Gi - requests: - ephemeral-storage: 800Gi - cpu: 125 - env: - - name: HF_TOKEN - value: - volumeMounts: - - mountPath: /dev/shm - name: dshm - volumes: - - name: dshm - emptyDir: - medium: Memory - sizeLimit: 15Gi - --- - apiVersion: v1 - kind: Service - metadata: - name: vllm-leader - spec: - ports: - - name: http - port: 8080 - protocol: TCP - targetPort: 8080 - selector: - leaderworkerset.sigs.k8s.io/name: vllm - role: leader - type: ClusterIP - ``` +Deploy the following yaml file `lws.yaml` (we have examples that use multiprocessing or Ray): + +??? code "lws.yaml" + === "Multiprocessing (default)" + ```yaml + apiVersion: leaderworkerset.x-k8s.io/v1 + kind: LeaderWorkerSet + metadata: + name: vllm + spec: + replicas: 1 + leaderWorkerTemplate: + size: 2 + restartPolicy: RecreateGroupOnPodRestart + leaderTemplate: + metadata: + labels: + role: leader + spec: + containers: + - name: vllm-leader + image: docker.io/vllm/vllm-openai:latest + env: + - name: HF_TOKEN + value: + command: + - sh + - -c + - "vllm serve meta-llama/Meta-Llama-3.1-405B-Instruct --tensor-parallel-size 8 --pipeline-parallel-size $(LWS_GROUP_SIZE) --nnodes $(LWS_GROUP_SIZE) --node-rank $(LWS_WORKER_INDEX) --master-addr $(LWS_LEADER_ADDRESS) --port 8080" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + ports: + - containerPort: 8080 + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + workerTemplate: + spec: + containers: + - name: vllm-worker + image: docker.io/vllm/vllm-openai:latest + command: + - sh + - -c + - "vllm serve meta-llama/Meta-Llama-3.1-405B-Instruct --tensor-parallel-size 8 --pipeline-parallel-size $(LWS_GROUP_SIZE) --nnodes $(LWS_GROUP_SIZE) --node-rank $(LWS_WORKER_INDEX) --master-addr $(LWS_LEADER_ADDRESS) --headless" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + env: + - name: HF_TOKEN + value: + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + --- + apiVersion: v1 + kind: Service + metadata: + name: vllm-leader + spec: + ports: + - name: http + port: 8080 + protocol: TCP + targetPort: 8080 + selector: + leaderworkerset.sigs.k8s.io/name: vllm + role: leader + type: ClusterIP + ``` + + === "Ray" + ```yaml + apiVersion: leaderworkerset.x-k8s.io/v1 + kind: LeaderWorkerSet + metadata: + name: vllm + spec: + replicas: 1 + leaderWorkerTemplate: + size: 2 + restartPolicy: RecreateGroupOnPodRestart + leaderTemplate: + metadata: + labels: + role: leader + spec: + containers: + - name: vllm-leader + image: docker.io/vllm/vllm-openai:latest + env: + - name: HF_TOKEN + value: + command: + - sh + - -c + - "bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh leader --ray_cluster_size=$(LWS_GROUP_SIZE); + vllm serve meta-llama/Meta-Llama-3.1-405B-Instruct --port 8080 --tensor-parallel-size 8 --pipeline-parallel-size 2 --distributed-executor-backend ray" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + ports: + - containerPort: 8080 + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + workerTemplate: + spec: + containers: + - name: vllm-worker + image: docker.io/vllm/vllm-openai:latest + command: + - sh + - -c + - "bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh worker --ray_address=$(LWS_LEADER_ADDRESS)" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + env: + - name: HF_TOKEN + value: + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + --- + apiVersion: v1 + kind: Service + metadata: + name: vllm-leader + spec: + ports: + - name: http + port: 8080 + protocol: TCP + targetPort: 8080 + selector: + leaderworkerset.sigs.k8s.io/name: vllm + role: leader + type: ClusterIP + ``` ```bash kubectl apply -f lws.yaml @@ -130,16 +224,37 @@ vllm-0-1 1/1 Running 0 2s Verify that the distributed tensor-parallel inference works: -```bash -kubectl logs vllm-0 |grep -i "Loading model weights took" -``` +=== "Multiprocessing (default)" + ```bash + kubectl logs vllm-0 | grep -i "Model loading" + kubectl logs vllm-0-1 | grep -i "Model loading" + ``` -Should get something similar to this: + Should get something similar to this: -```text -INFO 05-08 03:20:24 model_runner.py:173] Loading model weights took 0.1189 GB -(RayWorkerWrapper pid=169, ip=10.20.0.197) INFO 05-08 03:20:28 model_runner.py:173] Loading model weights took 0.1189 GB -``` + POD 0 (PP Rank 0) + + ```text + (Worker_PP0_TP0 pid=601) INFO 04-28 08:16:58 [gpu_model_runner.py:4820] Model loading took 3.82 GiB memory and 157.996399 seconds + ``` + + POD 1 (PP Rank 1) + + ```text + (Worker_PP1_TP0 pid=396) INFO 04-28 08:17:09 [gpu_model_runner.py:4820] Model loading took 3.82 GiB memory and 168.878781 seconds + ``` + +=== "Ray" + ```bash + kubectl logs vllm-0 | grep -i "Loading model weights took" + ``` + + Should get something similar to this: + + ```text + INFO 05-08 03:20:24 model_runner.py:173] Loading model weights took 0.1189 GB + (RayWorkerWrapper pid=169, ip=10.20.0.197) INFO 05-08 03:20:28 model_runner.py:173] Loading model weights took 0.1189 GB + ``` ## Access ClusterIP service @@ -173,7 +288,6 @@ curl http://localhost:8080/v1/completions \ The output should be similar to the following ??? console "Output" - ```text { "id": "cmpl-1bb34faba88b43f9862cfbfb2200949d", diff --git a/docs/deployment/integrations/kthena.md b/docs/deployment/integrations/kthena.md index 03ef190e558c..7cc3f14a71eb 100644 --- a/docs/deployment/integrations/kthena.md +++ b/docs/deployment/integrations/kthena.md @@ -64,36 +64,74 @@ A simplified version of the example (`llama-multinode`) looks like: - `spec.replicas: 1` – one `ServingGroup` (one logical model deployment). - `roles`: - `entryTemplate` – defines **leader** pods that run: - - vLLM’s **multi-node cluster bootstrap script** (Ray cluster). + - vLLM’s **multi-node cluster bootstrap script**. - vLLM **OpenAI-compatible API server**. - - `workerTemplate` – defines **worker** pods that join the leader’s Ray cluster. + - `workerTemplate` – defines **worker** pods to join the leader’s Ray cluster (Ray backend) or to join same distributed process group (multiprocessing backend). Key points from the example YAML: -- **Image**: `vllm/vllm-openai:latest` (matches upstream vLLM images). -- **Command** (leader): - - ```yaml - command: - - sh - - -c - - > - bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh leader --ray_cluster_size=2; - vllm serve meta-llama/Llama-3.1-405B-Instruct - --port 8080 - --tensor-parallel-size 8 - --pipeline-parallel-size 2 - ``` - -- **Command** (worker): - - ```yaml - command: - - sh - - -c - - > - bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh worker --ray_address=$(ENTRY_ADDRESS) - ``` +Image: `vllm/vllm-openai:latest` (matches upstream vLLM images). +Commands: + +??? code "Yaml" + === "Multiprocessing (default)" + Leader: + + ```yaml + command: + - sh + - -c + - > + vllm serve meta-llama/Llama-3.1-405B-Instruct + --tensor-parallel-size 8 + --pipeline-parallel-size 2 + --nnodes=2 + --node-rank=0 + --master-addr=$(ENTRY_ADDRESS) + --port 8080 + ``` + + Worker: + + ```yaml + command: + - sh + - -c + - > + vllm serve meta-llama/Llama-3.1-405B-Instruct + --tensor-parallel-size 8 + --pipeline-parallel-size 2 + --nnodes=2 + --node-rank=1 + --master-addr=$(ENTRY_ADDRESS) + --headless + ``` + + === "Ray" + Leader: + + ```yaml + command: + - sh + - -c + - > + bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh + leader --ray_cluster_size=2; python3 -m + vllm.entrypoints.openai.api_server --port 8080 --model + meta-llama/Llama-3.1-405B-Instruct --tensor-parallel-size 8 + --pipeline-parallel-size 2 + ``` + + Worker: + + ```yaml + command: + - sh + - -c + - > + bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh + worker --ray_address=$(ENTRY_ADDRESS) + ``` --- @@ -111,96 +149,192 @@ kubectl create secret generic hf-token \ ### 3.2 Apply the `ModelServing` +Save one of the following manifests to `modelserving.yaml`: + +??? code "modelserving.yaml" + === "Multiprocessing (default)" + ```yaml + apiVersion: workload.serving.volcano.sh/v1alpha1 + kind: ModelServing + metadata: + name: llama-multinode + namespace: default + spec: + schedulerName: volcano + replicas: 1 # group replicas + template: + restartGracePeriodSeconds: 60 + gangPolicy: + minRoleReplicas: + 405b: 1 + roles: + - name: 405b + replicas: 2 + entryTemplate: + spec: + containers: + - name: leader + image: vllm/vllm-openai:latest + env: + - name: HUGGING_FACE_HUB_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: HUGGING_FACE_HUB_TOKEN + command: + - sh + - -c + - "vllm serve meta-llama/Llama-3.1-405B-Instruct --tensor-parallel-size 8 --pipeline-parallel-size 2 --nnodes 2 --node-rank 0 --master-addr $(ENTRY_ADDRESS) --distributed-executor-backend mp --port 8080" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + ports: + - containerPort: 8080 + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + workerReplicas: 1 + workerTemplate: + spec: + containers: + - name: worker + image: vllm/vllm-openai:latest + command: + - sh + - -c + - "vllm serve meta-llama/Llama-3.1-405B-Instruct --tensor-parallel-size 8 --pipeline-parallel-size 2 --nnodes 2 --node-rank 1 --master-addr $(ENTRY_ADDRESS) --distributed-executor-backend mp --headless" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + env: + - name: HUGGING_FACE_HUB_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: HUGGING_FACE_HUB_TOKEN + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + ``` + + === "Ray" + ```yaml + apiVersion: workload.serving.volcano.sh/v1alpha1 + kind: ModelServing + metadata: + name: llama-multinode + namespace: default + spec: + schedulerName: volcano + replicas: 1 # group replicas + template: + restartGracePeriodSeconds: 60 + gangPolicy: + minRoleReplicas: + 405b: 1 + roles: + - name: 405b + replicas: 2 + entryTemplate: + spec: + containers: + - name: leader + image: vllm/vllm-openai:latest + env: + - name: HUGGING_FACE_HUB_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: HUGGING_FACE_HUB_TOKEN + command: + - sh + - -c + - "bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh leader --ray_cluster_size=2; + vllm serve meta-llama/Llama-3.1-405B-Instruct --port 8080 --tensor-parallel-size 8 --pipeline-parallel-size 2" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + ports: + - containerPort: 8080 + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 10 + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + workerReplicas: 1 + workerTemplate: + spec: + containers: + - name: worker + image: vllm/vllm-openai:latest + command: + - sh + - -c + - "bash /vllm-workspace/examples/ray_serving/multi-node-serving.sh worker --ray_address=$(ENTRY_ADDRESS)" + resources: + limits: + nvidia.com/gpu: "8" + memory: 1124Gi + ephemeral-storage: 800Gi + requests: + ephemeral-storage: 800Gi + cpu: 125 + env: + - name: HUGGING_FACE_HUB_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: HUGGING_FACE_HUB_TOKEN + volumeMounts: + - mountPath: /dev/shm + name: dshm + volumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 15Gi + ``` + ```bash -cat < **\*** For sparse MLA, FP8 KV cache always prefers `FLASHINFER_MLA_SPARSE`. With BF16 KV cache, `FLASHINFER_MLA_SPARSE` is preferred for low query-head counts (<= 16), while `FLASHMLA_SPARSE` is preferred otherwise. > > **Note:** ROCm and CPU platforms have their own selection logic. See the platform-specific documentation for details. @@ -169,24 +159,39 @@ Priority is **1 = highest** (tried first). | Backend | Version | Dtypes | KV Dtypes | Block Sizes | Head Sizes | Sink | Non-Causal | MM Prefix | DCP | Attention Types | Compute Cap. | | ------- | ------- | ------ | --------- | ----------- | ---------- | ---- | ---------- | --------- | --- | --------------- | ------------ | -| `CPU_ATTN` | | fp16, bf16, fp32 | `auto`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | 32, 64, 80, 96, 112, 128, 160, 192, 224, 256, 512 | ❌ | ❌ | ❌ | ❌ | All | N/A | -| `FLASHINFER` | Native† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 16, 32, 64 | 64, 128, 256, 512 | ❌ | ❌ | ❌ | ✅ | Decoder | 7.x-9.x | -| `FLASHINFER` | TRTLLM† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `nvfp4` | 16, 32, 64 | 64, 128, 256, 512 | ✅ | ❌ | ❌ | ✅ | Decoder | 10.x | +| `CPU_ATTN` | | fp16, bf16, fp32 | `auto`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | 32, 64, 80, 96, 112, 128, 160, 192, 224, 256, 512 | ❌ | ✅ | ❌ | ❌ | All | N/A | +| `FLASHINFER` | Native† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 16, 32, 64, 128, 256, 512, 1024 | 64, 128, 256, 512 | ❌ | ✅ | ❌ | ✅ | Decoder | 8.x-9.x | +| `FLASHINFER` | XQA† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 16, 32, 64, 128, 256, 512, 1024 | 64, 128, 256, 512 | ❌ | ❌ | ❌ | ✅ | Decoder | 9.0 | +| `FLASHINFER` | trtllm-gen† | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `nvfp4` | 16, 32, 64, 128, 256, 512, 1024 | 64, 128, 256, 512 | ✅ | ✅ | ❌ | ✅ | Decoder | 10.x | | `FLASH_ATTN` | FA2* | fp16, bf16 | `auto`, `float16`, `bfloat16` | %16 | Any | ❌ | ✅ | ❌ | ✅ | All | ≥8.0 | | `FLASH_ATTN` | FA3* | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | Any | ✅ | ✅ | ❌ | ✅ | All | 9.x | | `FLASH_ATTN` | FA4* | fp16, bf16 | `auto`, `float16`, `bfloat16` | %16 | Any | ✅ | ✅ | ❌ | ✅ | All | ≥10.0 | | `FLASH_ATTN_DIFFKV` | | fp16, bf16 | `auto` | Any | Any | ❌ | ❌ | ❌ | ✅ | Decoder | Any | | `FLEX_ATTENTION` | | fp16, bf16, fp32 | `auto`, `float16`, `bfloat16` | %16 | Any | ❌ | ✅ | ✅ | ❌ | Decoder, Encoder Only | Any | +| `HPC_ATTN` | | fp16, bf16 | `auto`, `bfloat16`, `fp8_e4m3` | 64 | 128 | ❌ | ❌ | ❌ | ❌ | Decoder | ≥9.0 | | `ROCM_AITER_FA` | | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 16, 32 | 64, 128, 256 | ✅ | ✅ | ❌ | ❌ | Decoder | N/A | -| `ROCM_AITER_UNIFIED_ATTN` | | fp16, bf16 | `auto` | %16 | Any | ✅ | ❌ | ✅ | ❌ | All | N/A | +| `ROCM_AITER_UNIFIED_ATTN` | | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | Any | ✅ | ❌ | ✅ | ❌ | All | N/A | | `ROCM_ATTN` | | fp16, bf16, fp32 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | 32, 64, 80, 96, 128, 160, 192, 224, 256 | ❌ | ✅ | ✅ | ❌ | Decoder, Encoder, Encoder Only | N/A | -| `TRITON_ATTN` | | fp16, bf16, fp32 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `int8_per_token_head`, `fp8_per_token_head` | %16 | Any | ✅ | ❌ | ✅ | ❌ | All | Any | +| `TRITON_ATTN` | | fp16, bf16, fp32 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `int4_per_token_head`, `int8_per_token_head`, `fp8_per_token_head` | %16 | Any | ✅ | ✅ | ✅ | ❌ | All | Any | +| `TRITON_ATTN_DIFFKV` | | fp16, bf16 | `auto`, `bfloat16` | Any | Any | ❌ | ❌ | ❌ | ❌ | Decoder | Any | | `TURBOQUANT` | | fp16, bf16 | `turboquant_k8v4`, `turboquant_4bit_nc`, `turboquant_k3v4_nc`, `turboquant_3bit_nc` | 16, 32, 64, 128 | Any | ❌ | ❌ | ❌ | ❌ | Decoder | Any | -> **†** FlashInfer uses TRTLLM attention on Blackwell (SM100), which supports sinks. Disable via `--attention-config.use_trtllm_attention=0`. +> **†** FlashInfer Native is the regular FlashInfer path. XQA is the SM90 decode path exposed through FlashInfer's TRTLLM decode API. trtllm-gen is used on SM100 and supports sinks. Disable XQA/trtllm-gen via `--attention-config.use_trtllm_attention=0`. > > **\*** Specify the FlashAttention version via `--attention-config.flash_attn_version=2`, `3`, or `4`. Default is FA4 on SM100+ (Blackwell), FA3 on SM90 (Hopper), FA2 otherwise. +## MiniMax M3 Sparse Attention Backends + +Block-sparse GQA backend used by MiniMax M3 sparse ("lightning indexer") +layers. It is wired in directly by the model and is not part of the +automatic priority lists above. A lightning indexer scores KV blocks, the +top-k blocks (plus fixed init/local blocks) are selected, and attention +attends only to those blocks; index keys live in a separate side cache. + +| Backend | Dtypes | KV Dtypes | Block Sizes | Head Sizes | Sink | Non-Causal | MM Prefix | DCP | Attention Types | Compute Cap. | +| ------- | ------ | --------- | ----------- | ---------- | ---- | ---------- | --------- | --- | --------------- | ------------ | +| `MINIMAX_M3_SPARSE` | bf16, fp16 | `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 128 | 128 | ❌ | ❌ | ❌ | ❌ | Decoder | Any | + ## MLA (Multi-head Latent Attention) Backends MLA uses separate backends for prefill and decode phases. @@ -201,9 +206,9 @@ hardware and configuration. | Backend | Description | Dtypes | Compute Cap. | Notes | | ------- | ----------- | ------ | ------------ | ----- | | `FLASH_ATTN`‡ | FlashAttention varlen (FA2/FA3/FA4) | fp16, bf16 | Any | FA4 on SM100+, FA3 on SM90, FA2 otherwise | -| `TRTLLM_RAGGED` | TensorRT-LLM ragged attention | fp16, bf16 | 10.x | DeepSeek R1 dims only | -| `FLASHINFER` | FlashInfer CUTLASS backend | fp16, bf16 | 10.x | DeepSeek R1 dims only | -| `TOKENSPEED_MLA` | | fp16, bf16 | 10.x | DeepSeek R1 dims only | +| `TRTLLM_RAGGED` | TensorRT-LLM ragged attention | fp16, bf16 | 10.x | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) or (qk_nope_head_dim=192, qk_rope_head_dim=64, v_head_dim=256) only | +| `FLASHINFER` | FlashInfer CUTLASS backend | fp16, bf16 | 10.x | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) only | +| `TOKENSPEED_MLA` | | fp16, bf16 | 10.x | (qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128) only | > **‡** Automatic selection tries FlashAttention first. On Blackwell > (SM100), the fallback order is TRT-LLM Ragged, FlashInfer, then @@ -217,14 +222,31 @@ MLA decode backends are selected using the standard | Backend | Dtypes | KV Dtypes | Block Sizes | Head Sizes | Sink | Non-Causal | Sparse | MM Prefix | DCP | Attention Types | Compute Cap. | | ------- | ------ | --------- | ----------- | ---------- | ---- | ---------- | ------ | --------- | --- | --------------- | ------------ | | `CUTLASS_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 128 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 10.x | -| `FLASHINFER_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 32, 64 | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | 10.x | -| `FLASHINFER_MLA_SPARSE` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 32, 64 | 576 | ❌ | ❌ | ✅ | ❌ | ❌ | Decoder | 10.x | +| `FLASHINFER_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 32, 64 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 10.x | +| `FLASHINFER_MLA_SPARSE` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 32, 64 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 10.x | +| `FLASHINFER_MLA_SPARSE_SM120` | bf16 | `auto`, `fp8`, `fp8_e4m3`, `fp8_ds_mla` | 64, 256 | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | 12.x | | `FLASHMLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 64 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 9.x-10.x | | `FLASHMLA_SPARSE` | bf16 | `auto`, `bfloat16`, `fp8_ds_mla` | 64 | 576 | ❌ | ❌ | ✅ | ❌ | ❌ | Decoder | 9.x-10.x | | `FLASH_ATTN_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16` | %16 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | 9.x | +| `FLASH_ATTN_MLA_SPARSE` | fp16, bf16 | `auto`, `float16`, `bfloat16` | 64 | Any | ❌ | ❌ | ✅ | ❌ | ❌ | Decoder | 9.x | | `ROCM_AITER_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %1 | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | N/A | | `ROCM_AITER_MLA_SPARSE` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | 1, 64 | Any | ❌ | ❌ | ✅ | ❌ | ❌ | Decoder | N/A | | `ROCM_AITER_TRITON_MLA` | fp16, bf16 | `auto` | Any | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | N/A | | `TOKENSPEED_MLA` | fp16, bf16 | `fp8`, `fp8_e4m3` | 32, 64 | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | 10.x | | `TRITON_MLA` | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3` | %16 | Any | ❌ | ❌ | ❌ | ❌ | ✅ | Decoder | Any | | `XPU_MLA_SPARSE` | fp16, bf16 | `auto`, `float16`, `bfloat16` | Any | 576 | ❌ | ❌ | ✅ | ❌ | ❌ | Decoder | Any | + +### DeepSeek V4 Decode Backends + +DeepSeek V4 sparse MLA uses its own decode backends, selected via +`--attention-backend=` (e.g., `FLASHMLA_SPARSE_DSV4`, +`FLASHINFER_MLA_SPARSE_DSV4`). They share the V4 sparse-index +pipeline (compressor + SWA + indexer, 256-token blocks, head 512); +default on NVIDIA is `FLASHINFER_MLA_SPARSE_DSV4` on SM12x and +`FLASHMLA_SPARSE_DSV4` on other supported CUDA architectures. + +| Backend | Dtypes | KV Dtypes | Block Sizes | Head Sizes | Sink | Non-Causal | Sparse | MM Prefix | DCP | Attention Types | Compute Cap. | +| ------- | ------ | --------- | ----------- | ---------- | ---- | ---------- | ------ | --------- | --- | --------------- | ------------ | +| `FLASHINFER_MLA_SPARSE_DSV4` | bf16 | `auto`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_ds_mla` | 256 | 512 | ✅ | ❌ | ✅ | ❌ | ❌ | Decoder | 10.x, 12.x | +| `FLASHMLA_SPARSE_DSV4` | bf16 | `auto`, `fp8_ds_mla`, `fp8` | 256 | 512 | ✅ | ❌ | ✅ | ❌ | ❌ | Decoder | 9.x-10.x | +| `ROCM_FLASHMLA_SPARSE_DSV4` | fp16, bf16 | `auto` | Any | Any | ❌ | ❌ | ❌ | ❌ | ❌ | Decoder | N/A | diff --git a/docs/design/cuda_graphs.md b/docs/design/cuda_graphs.md index 718a4a8154d9..e274b68c7021 100644 --- a/docs/design/cuda_graphs.md +++ b/docs/design/cuda_graphs.md @@ -161,11 +161,11 @@ class AttentionCGSupport(enum.Enum): ALWAYS = 3 """CUDA Graphs always supported; supports mixed-prefill-decode""" UNIFORM_BATCH = 2 - """CUDA Graphs supported for batches the only contain query lengths that are + """CUDA Graphs supported for batches that only contain query lengths that are the same, this can be used for spec-decode i.e. "decodes" are 1 + num_speculative_tokens""" UNIFORM_SINGLE_TOKEN_DECODE = 1 - """CUDA Graphs supported for batches the only contain query_len==1 decodes""" + """CUDA Graphs supported for batches that only contain query_len==1 decodes""" NEVER = 0 """NO CUDA Graphs support""" ``` diff --git a/docs/design/cuda_graphs_multimodal.md b/docs/design/cuda_graphs_multimodal.md index 5a9edc1ad93e..7eab425d6e21 100644 --- a/docs/design/cuda_graphs_multimodal.md +++ b/docs/design/cuda_graphs_multimodal.md @@ -2,6 +2,8 @@ The [CUDA Graphs](cuda_graphs.md) infrastructure in vLLM primarily targets the **decoder** (language model) forward pass. vLLM also supports capturing the **encoder** (vision transformer) forward pass as CUDA Graphs, independently from the decoder. This is based on . +For two-tower vision encoders (e.g., DeepSeek-OCR's SAM + CLIP with dynamic tiling), a **dual-path graph** mode captures two independent sets of CUDA graphs — one for the global image path and one for the local patch path — enabling independent budget selection and partial eager fallback per path. This is based on . + !!! note Encoder CUDA Graphs are orthogonal to decoder CUDA Graphs — both can be enabled simultaneously. Encoder graphs capture the vision encoder execution (e.g., ViT in Qwen3-VL), while decoder graphs capture the language model execution as described in the [CUDA Graphs design document](cuda_graphs.md). @@ -11,6 +13,8 @@ Vision encoder inference incurs CUDA kernel launch overhead on the host side. Th Encoder CUDA Graphs eliminate this overhead by pre-capturing the full encoder forward pass at multiple token budget levels during model initialization, then replaying the appropriate graph at runtime. +For two-tower vision encoders such as DeepSeek-OCR (SAM + CLIP with dynamic tiling), the global image path and local patch path have independent token profiles (272 tokens per global image vs. 100 tokens per local patch). Capturing a single monolithic graph for both paths would significantly reduce packing efficiency. The dual-path graph mode captures each path as a separate set of budgets, allowing the manager to pack and replay each path independently. + ## Design The encoder CUDA Graph system uses a **budget-based capture/replay** strategy, managed by [EncoderCudaGraphManager][vllm.v1.worker.encoder_cudagraph.EncoderCudaGraphManager]. The system contains the following core components: @@ -37,10 +41,14 @@ class BudgetGraphMetadata: Budgets are auto-generated as power-of-2 levels from a model-provided range via `get_encoder_cudagraph_budget_range()`, with the maximum budget always included even if it does not fall on a power-of-2 boundary. Budgets can also be explicitly specified by the user via `encoder_cudagraph_token_budgets` in `CompilationConfig`. +When `EncoderCudaGraphConfig.enable_dual_path_graph` is `True`, the manager generates two independent budget lists — `global_token_budgets` (multiples of `global_token_per_image`) and `local_token_budgets` (multiples of `local_token_per_patch`) — and stores captured graphs under `budget_graphs["global"]` and `budget_graphs["local"]` respectively. + ### Greedy bin-packing at runtime When a batch of images arrives, the manager sorts images by output token count (smallest first) and greedily packs as many images as possible into each sub-batch while staying within the **largest** token budget and the maximum batch size. Once a sub-batch is finalized (the next image would overflow either constraint), the manager finds the **smallest** budget that fits the sub-batch's total tokens and replays the corresponding CUDA Graph. This repeats until the batch is exhausted. Images that exceed all budgets fall back to eager execution. +For dual-path models, the manager routes to `_execute_local_dual_path()`, which constrains both global and local token budgets simultaneously during packing (see [Dual-Path graph capture](#dual-path-graph-capture)). + For each graph replay: 1. Call `prepare_encoder_cudagraph_replay_buffers()` to compute buffer values (including `pixel_values` and precomputed metadata) from actual batch inputs. @@ -48,6 +56,42 @@ For each graph replay: 3. Replay the CUDA Graph. 4. Clone outputs from `output_buffer` (cloning is necessary since the buffer is reused across replays). +### Dual-Path graph capture + +For two-tower vision encoders (e.g., DeepSeek-OCR), the `EncoderCudaGraphConfig` sets `enable_dual_path_graph=True` and provides `global_token_per_image` / `local_token_per_patch`. The manager captures two independent sets of CUDA graphs — one for the **global** image path and one for the **local** patch path — stored under `budget_graphs["global"]` and `budget_graphs["local"]` respectively. + +**Budget generation.** Two separate budget lists are generated: + +* `global_token_budgets` — power-of-2 multiples of `global_token_per_image` (e.g., `[272, 544, 1088, 2176, 4352, 8704, 13824]` for DeepSeek-OCR). +* `local_token_budgets` — power-of-2 multiples of `local_token_per_patch` (e.g., `[0, 100, 200, 400, 800, 1600, 3200, 6400, 12800]` for DeepSeek-OCR). A budget of `0` is always included to handle images with no local patches (images ≤ 640×640 that produce only global features). + +Both lists are capped at the same `max_budget`. + +**Dual-path greedy packing.** Each `EncoderItemSpec` provides both `global_output_tokens` (constant per image) and `local_output_tokens` (proportional to the patch count). The dual-path packing algorithm constrains both budgets simultaneously: + +* Sort images by total output tokens (global + local), smallest first. +* Greedily pack images: an image is added to the current sub-batch only if both the accumulated global tokens ≤ `max_global_budget` **and** the accumulated local tokens ≤ `max_local_budget`, with the image count ≤ `max_batch_size`. +* Once either constraint would overflow, finalize the sub-batch and find the smallest fitting budget **independently** for each path. +* Repeat until all images are packed. + +**Partial graph fallback.** After packing, each sub-batch falls into one of four execution scenarios: + +| Global budget | Local budget | Execution | +| :---: | :---: | --- | +| Found | Found | Both paths use CUDA graph replay | +| Found | `None` | Global graph replay + local path skipped (no patches) | +| `None` | Found | Global eager fallback + local graph replay | +| `None` | `None` | Both paths fall back to eager execution | + +Note that the `0`-budget graph is never actually replayed for local — it signals that local patch processing should be skipped entirely. + +**Buffer keys per path.** Global and local paths use different buffer keys. For DeepSeek-OCR, the global path uses `pixel_values` (full images, shape `[B, 3, 1280, 1280]`) while the local path uses `images_crop` (patches, shape `[P, 3, 1024, 1024]`). The manager iterates over each captured graph's own `input_buffers.keys()` rather than a shared `buffer_keys` list, so both paths can use different buffers. + +**Post-processing.** The `postprocess_encoder_output` method receives a `local_output` parameter (a tensor or `None`) containing the local-path encoder output. The model is responsible for assembling global and local features into the final per-image embedding. For DeepSeek-OCR, this means reshaping the global output into `[B, 272, n_embed]`, the local output into `[P, 100, n_embed]`, assembling patch grids with newline tokens, and concatenating `[patches_grid, global, view_separator]` for each image. + +!!! note + The dual-path design enables partial CUDA graph coverage — one path can hit while the other falls back to eager. This avoids wasted compute on zero-padded patch buffers for untiled images and avoids graph invalidation caused by variable `crop_shape` per image. + ### Data-parallel support When `mm_encoder_tp_mode="data"`, the manager distributes images across TP ranks using load-balanced assignment via `get_load_balance_assignment`, executes locally on each rank, then gathers results back in the original order via `tensor_model_parallel_all_gather`. @@ -67,27 +111,33 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra * `get_encoder_cudagraph_config()` — returns static configuration (supported modalities, buffer keys, output hidden size, padding logics, max frames per video). * `get_encoder_cudagraph_budget_range(vllm_config)` — returns `(min_budget, max_budget)` for auto-inference of token budgets. -* `get_encoder_cudagraph_item_specs(mm_kwargs)` — returns `list[EncoderItemSpec]` describing each item with its input size and output token count. Replaces the former three separate methods (`get_num_items`, `get_per_item_output_tokens`, `get_per_item_input_sizes`). +* `get_encoder_cudagraph_item_specs(mm_kwargs)` — returns `list[EncoderItemSpec]` describing each item with its input size, total output token count (`output_tokens`), and optionally per-path token counts (`global_output_tokens`, `local_output_tokens`) for dual-path models. * `select_encoder_cudagraph_items(mm_kwargs, indices)` — extracts a sub-batch of items by index, used during greedy packing and DP sharding. -* `prepare_encoder_cudagraph_capture_inputs(...)` — creates dummy inputs for graph capture. Returns `EncoderCudaGraphCaptureInputs` with a single `values: dict[str, torch.Tensor]` that contains all buffers to be recorded into the graph. -* `prepare_encoder_cudagraph_replay_buffers(mm_kwargs, max_batch_size, max_frames_per_batch)` — computes buffer values from actual batch inputs. Returns `EncoderCudaGraphReplayBuffers` with a `values` dict whose keys match `buffer_keys` in the config. -* `encoder_cudagraph_forward(inputs: dict[str, torch.Tensor])` — forward pass accepting only fixed-shaped input tensors (the captured `values` dict). Called during both capture and replay. The `pixel_values` tensor is included in `inputs` alongside metadata buffers. -* `encoder_eager_forward(mm_kwargs)` — fallback eager forward when no graph fits. -* `postprocess_encoder_output(...)` — post-process encoder output, delegates to `scatter_output_slices` by default. +* `prepare_encoder_cudagraph_capture_inputs(..., path="default")` — creates dummy inputs for graph capture. The `path` parameter (`"global"` or `"local"`) tells the model which path to generate dummy inputs for. Returns `EncoderCudaGraphCaptureInputs` with a single `values: dict[str, torch.Tensor]` that contains all buffers to be recorded into the graph. +* `prepare_encoder_cudagraph_replay_buffers(mm_kwargs, max_batch_size, max_frames_per_batch, path="default")` — computes buffer values from actual batch inputs. The `path` parameter selects which modality keys to extract from `mm_kwargs`. Returns `EncoderCudaGraphReplayBuffers` with a `values` dict whose keys match the captured graph's `input_buffers.keys()`. +* `encoder_cudagraph_forward(inputs: dict[str, torch.Tensor], path="default")` — forward pass accepting only fixed-shaped input tensors (the captured `values` dict). Called during both capture and replay. The `path` parameter dispatches to the correct encoder sub-module (e.g., global vs. local path for DeepSeek-OCR). +* `encoder_eager_forward(mm_kwargs, path="default")` — fallback eager forward when no graph fits. When `path` is `"global"` or `"local"`, runs only that encoder path without graph capture. +* `postprocess_encoder_output(..., local_output=None)` — post-process encoder output. The `local_output` parameter receives the local-path encoder output tensor (or `None`), enabling dual-path models to assemble global and local features into the final per-image embedding. !!! note The `SupportsEncoderCudaGraph` protocol is designed to be model-agnostic. New vision encoder models can opt-in by implementing the protocol methods without modifying the manager. **Supported models:** -| Architecture | Models | CG for Image | CG for Video | -| ------------ | ------ | ------------ | ------------ | -| `InternVLChatModel` | `InternVL3.5`, `InternVL3`, `InternVL2.5`, `InternVL2` | ✅︎ | ✅︎ | -| `Qwen2VLForConditionalGeneration` | `Qwen2-VL` | ✅︎ | ✅︎ | -| `Qwen2_5_VLForConditionalGeneration` | `Qwen2.5-VL` | ✅︎ | ✅︎ | -| `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ | -| `Qwen3_5ForConditionalGeneration` | `Qwen3.5` | ✅︎ | ✅︎ | -| `Step3VLForConditionalGeneration` | `Step3-VL` | ✅︎ | ❌︎ | +| Architecture | Models | CG for Image | CG for Video | Dual-Path Graph | +| ------------ | ------ | ------------ | ------------ | --------------- | +| `DeepseekOCRForCausalLM` | `DeepSeek-OCR` | ✅︎ | ❌︎ | ✅︎ | +| `Gemma3ForConditionalGeneration` | `Gemma3` | ✅︎ | ❌︎ | ❌︎ | +| `Glm4vForConditionalGeneration` | `GLM-4.1V, GLM-4.6V-Flash` | ✅︎ | ✅︎ | ❌︎ | +| `InternVLChatModel` | `InternVL3.5`, `InternVL3`, `InternVL2.5`, `InternVL2` | ✅︎ | ✅︎ | ❌︎ | +| `KimiVLForConditionalGeneration` | `Kimi-VL` | ✅︎ | ❌︎ | ❌︎ | +| `Llama4ForConditionalGeneration` | `Llama 4` | ✅︎ | ❌︎ | ❌︎ | +| `Qwen2VLForConditionalGeneration` | `Qwen2-VL` | ✅︎ | ✅︎ | ❌︎ | +| `Qwen2_5_VLForConditionalGeneration` | `Qwen2.5-VL` | ✅︎ | ✅︎ | ❌︎ | +| `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ | ❌︎ | +| `Qwen3_5ForConditionalGeneration` | `Qwen3.5`, `Qwen3.6` | ✅︎ | ✅︎ | ❌︎ | +| `Qwen3_5MoeForConditionalGeneration` | `Qwen3.5-MoE`, `Qwen3.6-MoE` | ✅︎ | ✅︎ | ❌︎ | +| `Step3VLForConditionalGeneration` | `Step3-VL` | ✅︎ | ❌︎ | ✅︎ | !!! note Encoder CUDA Graphs have currently been tested with `--mm-encoder-attn-backend=FLASH_ATTN` and `--mm-encoder-attn-backend=FLASHINFER` on Blackwell GPUs. @@ -102,6 +152,8 @@ Three fields in `CompilationConfig` control encoder CUDA Graphs: * `encoder_cudagraph_max_vision_items_per_batch` (`int`, default `0`) — maximum number of images/videos per batch during capture. If 0 (default), auto-inferred as `max_budget // min_budget`. * `encoder_cudagraph_max_frames_per_batch` (`int`, default `None`) — maximum number of video frames per batch during capture. If `None` (default), auto-inferred as `encoder_cudagraph_max_vision_items_per_batch * max_frames_per_video` (`max_frames_per_video` is a model-specific value from `EncoderCudaGraphConfig`, computed by `get_max_frames_per_video()` on the model). If we limit the video count per prompt to `0`, it will also be set to `0` (i.e., fall back to image-only mode). +Dual-path mode is configured at the model level via `EncoderCudaGraphConfig` fields (`enable_dual_path_graph`, `global_token_per_image`, `local_token_per_patch`) — no additional user configuration is required. The manager automatically generates separate budget lists and routes to dual-path execution when the model opts in. + ## Usage guide ### Image inference @@ -113,6 +165,14 @@ vllm serve Qwen/Qwen3-VL-32B \ --compilation-config '{"cudagraph_mm_encoder": true}' ``` +For `Llama 4` (image only): + +```bash +vllm serve meta-llama/Llama-4-Scout-17B-16E-Instruct \ + --limit-mm-per-prompt '{"image": 1}' \ + --compilation-config '{"cudagraph_mm_encoder": true}' +``` + With explicit budgets: ```bash diff --git a/docs/design/endpoint_plugins.md b/docs/design/endpoint_plugins.md new file mode 100644 index 000000000000..9f38fe5da188 --- /dev/null +++ b/docs/design/endpoint_plugins.md @@ -0,0 +1,136 @@ +# Endpoint Plugins + +Endpoint plugins let out-of-tree packages add HTTP routes to the OpenAI compatible API server without editing `vllm/entrypoints/openai/api_server.py`. Their scope is +the **HTTP surface only** registering routes and optionally per app state used by those routes. A plugin reaches the engine the same way an in-tree serving handler does, through the `EngineClient` it is handed at startup (e.g. `engine_client.collective_rpc(...)`). No new engine access path is introduced. + +!!! warning "Security" + Endpoint plugins are **not loaded by default** and must be explicitly allowlisted. Read [Endpoint Plugins security posture](../usage/security.md#endpoint-plugins) before enabling one, especially the route shadowing warning. + +## The `EndpointPlugin` protocol + +Endpoint plugins implement the [`EndpointPlugin`][vllm.plugins.endpoint_plugins.interface.EndpointPlugin] runtime checkable `Protocol`: + +```python +class EndpointPlugin(Protocol): + name: str + required_tasks: tuple[SupportedTask, ...] | None + + def attach_router(self, app: FastAPI) -> None: ... + + async def init_state( + self, engine_client: EngineClient | None, state: State, args: Namespace + ) -> None: ... +``` + +- `name`: a unique identifier used in logs and for `VLLM_PLUGINS` allowlisting +- `required_tasks`: the tasks the server must support for this plugin to load. `None` means the plugin has no task requirement +- `attach_router`: registers routes on `app` +- `init_state`: initializes per app state the routes read at request time + +## The two phase lifecycle + +Routes are registered before the engine exists. This means the interface has to expose two hooks that run at two different points in server startup: + +| Phase | Called from | `engine_client` available? | Work | +| --- | --- | --- | --- | +| A. Route registration | `build_app()` | No | `attach_router(app)` add routes. Do not touch the engine here. | +| B. State init | `init_app_state()` | Usually but `None` on the CPU only render server | `init_state(engine_client, state, args)` build a serving handler holding `engine_client` and store it on `state`. | + +Because `app.state` *is* the `state` object passed to `init_app_state()`, an object stored during phase A is visible in phase B and an object stored in phase B is visible to route handlers at request time via `request.app.state`. This is the same pattern in-tree endpoints already use. + +### Engine less servers (the render server) + +The CPU only render server (`init_render_app_state()`) has no `EngineClient`. It still runs both phases for any plugin eligible for the `render` task (`required_tasks` is `None` or includes `"render"`). `attach_router` is called as usual but `init_state` is called with `engine_client=None`. + +A plugin that needs an engine to function has two options: + +- Exclude `"render"` from `required_tasks` so it is never loaded on the render server in the first place +- Accept being loaded on `render` and check for `None` in `init_state` or in the route handler returning an error response (e.g. HTTP 503) instead of dereferencing a client that doesn't exist + +`tests/plugins/vllm_add_dummy_endpoint_plugin` demonstrates the second option. Its route handler returns a 503 when `state.dummy_engine_client` is `None`. + +### Reaching the engine from a route handler + +`init_state` is where a plugin captures `engine_client` into a small serving handler and stashes it on `state`. The route added in `attach_router` reads that handler off `request.app.state` at request time and calls the engine through it, typically via `engine_client.collective_rpc(...)`. + +This minimal example omits the `None` check from the previous section for brevity since `required_tasks` is `None` here. It is in fact eligible for `render` and should handle `engine_client=None` the way `tests/plugins/vllm_add_dummy_endpoint_plugin` does before shipping it: + +```python +from fastapi import FastAPI, Request + + +class MyAdminEndpointPlugin: + name = "my_admin_endpoint_plugin" + required_tasks: tuple[str, ...] | None = None + + def attach_router(self, app: FastAPI) -> None: + @app.get("/plugins/my_admin_endpoint_plugin/scheduler_config") + async def scheduler_config(raw_request: Request): + engine_client = raw_request.app.state.my_engine_client + results = await engine_client.collective_rpc("get_scheduler_config") + return {"scheduler_config": results} + + async def init_state(self, engine_client, state, args) -> None: + state.my_engine_client = engine_client +``` + +A complete and tested version of this example is in-repo as `tests/plugins/vllm_add_dummy_endpoint_plugin` and is exercised e2e (including a real HTTP request) in `tests/plugins_tests/test_endpoint_plugins.py`. + +## Registering the entry point + +Register a zero argument factory (a class or function) under the `vllm.endpoint_plugins` group. The factory must return an object satisfying `EndpointPlugin`: + +```toml +# pyproject.toml +[project.entry-points."vllm.endpoint_plugins"] +my_admin_api = "my_pkg.endpoints:MyAdminEndpointPlugin" +``` + +```python +# setup.py equivalent +setup( + name="my_pkg", + entry_points={ + "vllm.endpoint_plugins": [ + "my_admin_api = my_pkg.endpoints:MyAdminEndpointPlugin" + ] + }, +) +``` + +The entry point name (`my_admin_api` above) is independent of the plugin's `name` attribute. `VLLM_PLUGINS` allowlisting matches on the **entry point name** following the same convention as `vllm.general_plugins` (see [Plugin System](plugin_system.md)). + +## Gating: `VLLM_PLUGINS` and `required_tasks` + +Endpoint plugins are discovered and gated by [`load_endpoint_plugins`][vllm.plugins.load_endpoint_plugins] which is stricter than the loader used for other plugin groups: + +- **Nothing loads unless `VLLM_PLUGINS` is set and names the plugin.** Other plugin groups load everything unless `VLLM_PLUGINS` narrows the set. Endpoint plugins invert that default because they add network exposed surface. See [Security](../usage/security.md#endpoint-plugins). +- **`required_tasks` must intersect the server's supported tasks** unless it is `None`. Use this to keep a plugin from attaching routes on a server that can't service them (e.g. a pooling only deployment). +- A factory that raises an issue during instantiation is logged and skipped. It does not abort server startup. + +Only the front end API server process loads endpoint plugins. There is no need to guard for worker or engine core processes. + +## Pairing with `vllm.general_plugins` + +Endpoint plugins cover the HTTP surface only. If a plugin also needs new engine side behavior (a new worker-side RPC method, a custom stat) that half ships separately through the existing `vllm.general_plugins` group which loads in worker processes (see [Plugin System](plugin_system.md)). The two entry points are registered and loaded **independently**. Neither implies the other. The recommended distribution shape is a single package exposing both: + +```toml +[project.entry-points."vllm.general_plugins"] +my_admin_engine = "my_pkg.engine:register" # adds the worker side method + +[project.entry-points."vllm.endpoint_plugins"] +my_admin_api = "my_pkg.endpoints:MyAdminEndpointPlugin" # adds the HTTP route +``` + +Do not expect a single endpoint plugin to also mutate engine/worker state. If your route needs a worker side method that doesn't already exist then add it via a paired `general_plugins` entry point. + +## Path-prefix convention + +There is currently no route conflict enforcement (tracked as a follow-up to RFC [#46565](https://github.com/vllm-project/vllm/issues/46565)). A plugin's `attach_router` can register a path that collides with a core route and routes attached later win. To avoid surprising operators: + +- Namespace your routes under a distinct prefix, e.g. `/plugins//...`, rather than reusing `/v1/...` or other core prefixes +- Only register routes under a core prefix (like the worked example's `/v1/admin/scheduler_config`) if you specifically intend to override or extend existing behavior and document that clearly for operators allowlisting your plugin + +## Compatibility + +`state`/serving handler internals (e.g. the shape of in-tree `OpenAIServing*` classes) are not a stable public contract yet. Treat them as use-at-your-own-risk and expect them to change between vLLM versions. `FastAPI`, `EngineClient` and the `EndpointPlugin` protocol itself are the supported surface. diff --git a/docs/design/fusions.md b/docs/design/fusions.md index 371a9c593202..c9991f75cdb8 100644 --- a/docs/design/fusions.md +++ b/docs/design/fusions.md @@ -30,7 +30,7 @@ or just on the low or high end. | [RMSNorm + Quant](#rmsnorm--quantization-fuse_norm_quant) | `fuse_norm_quant` | RMSNorm (+residual add) → FP8/FP4 quant | O1 (conditional) | 1-4% | No | Always | | [SiLU+Mul + Quant](#silumul--quantization-fuse_act_quant) | `fuse_act_quant` | SiLU+Mul activation → FP8/FP4 quant | O1 (conditional) | 1-4% | No | Always | | [RMSNorm + Padding](#rmsnorm--padding-fuse_act_padding) | `fuse_act_padding` | Residual add + RMSNorm → padding | O1 (ROCm/AITER only) | TBD | No | Always | -| [MLA Dual RMSNorm](#mla-dual-rmsnorm-fuse_mla_dual_rms_norm) | `fuse_mla_dual_rms_norm` | Paired Q + KV RMSNorm → single kernel | O1 (ROCm/AITER only) | ~2% | No | Always | +| [MLA Dual RMSNorm](#mla-dual-rmsnorm-fuse_mla_dual_rms_norm) | `fuse_mla_dual_rms_norm` | Paired Q + KV RMSNorm (+ FP8 quant) → 1 kernel | O1 (ROCm/AITER only) | 1-2% | No | Always | ## Support Matrix @@ -381,11 +381,32 @@ q_normed, kv_normed = fused_mla_dual_rms_norm( Requires: AMD ROCm with AITER enabled. Enabled by default at optimization level O1 and above when AITER is available. +**FP8 attention variant (per-token quant).** With a per-token FP8 `q_b_proj`, +only the *q* latent is FP8-quantized while *kv* stays bf16. +`RocmAiterRMSNormQuantFusionPass` first folds the q side into +`rocm_aiter_rmsnorm_fused_dynamic_quant`, leaving kv a plain +`rms_norm` — breaking the symmetric pattern above. The same pass then matches +this asymmetric pair and lowers it to `fused_mla_dual_rms_norm_per_token_quant`. + +```text +# Unfused (q norm+quant fused; kv still plain rms_norm): +q_c, kv_lora = split(projected, [q_dim, kv_dim]) +kv_c, k_pe = split(kv_lora, [kv_c_dim, k_pe_dim]) +q_fp8, q_scale = rocm_aiter_rmsnorm_fused_dynamic_quant(q_c, q_weight, eps, fp8) +kv_normed = rms_norm(kv_c, kv_weight, eps) # bf16 + +# Fused: +q_c, kv_lora = split(projected, [q_dim, kv_dim]) +kv_c, k_pe = split(kv_lora, [kv_c_dim, k_pe_dim]) +q_fp8, q_scale, kv_normed = fused_mla_dual_rms_norm_per_token_quant( + q_c, q_weight, kv_c, kv_weight, eps1, eps2) +``` + **Code locations.** -- Pass: [`vllm/compilation/passes/fusion/rocm_aiter_fusion.py`](https://github.com/vllm-project/vllm/blob/main/vllm/compilation/passes/fusion/rocm_aiter_fusion.py) (`MLADualRMSNormFusionPass`) -- Custom op: [`vllm/_aiter_ops.py`](https://github.com/vllm-project/vllm/blob/main/vllm/_aiter_ops.py) (`fused_mla_dual_rms_norm`) -- AITER kernel: [`fused_qk_rmsnorm`](https://github.com/ROCm/aiter/pull/2442) +- Pass: [`vllm/compilation/passes/fusion/rocm_aiter_fusion.py`](https://github.com/vllm-project/vllm/blob/main/vllm/compilation/passes/fusion/rocm_aiter_fusion.py) (`MLADualRMSNormFusionPass`, `MLADualRMSPerTokenQuantPattern`) +- Custom op: [`vllm/_aiter_ops.py`](https://github.com/vllm-project/vllm/blob/main/vllm/_aiter_ops.py) (`fused_mla_dual_rms_norm`, `fused_mla_dual_rms_norm_per_token_quant`) +- AITER kernels: [`fused_qk_rmsnorm`](https://github.com/ROCm/aiter/pull/2442), `fused_qk_rmsnorm_per_token_quant` ## See Also diff --git a/docs/design/hybrid_kv_cache_manager.md b/docs/design/hybrid_kv_cache_manager.md index 8f17b473adc0..82d54e9b5c1e 100644 --- a/docs/design/hybrid_kv_cache_manager.md +++ b/docs/design/hybrid_kv_cache_manager.md @@ -159,7 +159,7 @@ For simplicity, we assume `block_size=1` in this section. ### High level idea -The block pool uses a dict similar to `tuple(block_hash, group_id) -> block` to catch the full blocks. That means the same tokens of different groups are cached and evicted independently. +The block pool uses a dict similar to `tuple(block_hash, group_id) -> block` to cache the full blocks. That means the same tokens of different groups are cached and evicted independently. When a new request comes in, we check the cache hit prefix of each group, and return the intersection of these groups as the cached prefix of the request. See below for the detailed algorithm for checking the cache hit of one group & performing the intersection. diff --git a/docs/design/metrics.md b/docs/design/metrics.md index 0ae420399767..7b463b8750c7 100644 --- a/docs/design/metrics.md +++ b/docs/design/metrics.md @@ -685,7 +685,7 @@ documentation for this option states: > use of possibly costly and or blocking operations and hence might > have a performance impact. -The metrics were added by and who up in an OpenTelemetry trace +The metrics were added by and show up in an OpenTelemetry trace as: ```text diff --git a/docs/design/moe_kernel_features.md b/docs/design/moe_kernel_features.md index 1a11c6685a45..07d2a5398013 100644 --- a/docs/design/moe_kernel_features.md +++ b/docs/design/moe_kernel_features.md @@ -42,7 +42,7 @@ th { 1. All types: mxfp4, nvfp4, int4, int8, fp8 2. A,T quantization occurs after dispatch. 3. All quantization happens after dispatch. - 4. Controlled by different env vars (`VLLM_FLASHINFER_MOE_BACKEND` "throughput" or "latency") + 4. Controlled by `--moe-backend` (`flashinfer_cutlass` or `flashinfer_trtllm`) 5. This is a no-op dispatcher that can be used to pair with any modular experts to produce a modular kernel that runs without dispatch or combine. These cannot be selected via environment variable. These are generally use for testing or adapting an expert subclass to the `fused_experts` API. 6. This depends on the experts implementation. @@ -60,7 +60,7 @@ Modular kernels are supported by the following `FusedMoEMethodBase` classes. - [`CompressedTensorsW4A4Nvfp4MoEMethod`][vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe.compressed_tensors_moe_w4a4_nvfp4.CompressedTensorsW4A4Nvfp4MoEMethod] - [`CompressedTensorsW8A8Fp8MoEMethod`][vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe.compressed_tensors_moe_w8a8_fp8.CompressedTensorsW8A8Fp8MoEMethod] - [`GptOssMxfp4MoEMethod`][vllm.model_executor.layers.quantization.mxfp4.GptOssMxfp4MoEMethod] -- [`UnquantizedFusedMoEMethod`][vllm.model_executor.layers.fused_moe.layer.UnquantizedFusedMoEMethod] +- [`UnquantizedFusedMoEMethod`][vllm.model_executor.layers.fused_moe.UnquantizedFusedMoEMethod] ## Fused Experts Kernels @@ -89,6 +89,7 @@ To be used with a particular `FusedMoEPrepareAndFinalizeModular` subclass, MoE k | gpt oss triton | standard | N/A | N/A | 5 | Y | Y | [`triton_kernel_fused_experts`][vllm.model_executor.layers.fused_moe.experts.gpt_oss_triton_kernels_moe.triton_kernel_fused_experts],
[`OAITritonExperts`][vllm.model_executor.layers.fused_moe.experts.gpt_oss_triton_kernels_moe.OAITritonExperts] | | marlin | standard,
batched | 3 / N/A | 3 / N/A | silu,
swigluoai | Y | Y | [`fused_marlin_moe`][vllm.model_executor.layers.fused_moe.experts.marlin_moe.fused_marlin_moe],
[`MarlinExperts`][vllm.model_executor.layers.fused_moe.experts.marlin_moe.MarlinExperts],
[`BatchedMarlinExperts`][vllm.model_executor.layers.fused_moe.experts.marlin_moe.BatchedMarlinExperts] | | trtllm | standard | mxfp4,
nvfp4 | G(16),G(32) | 5 | N | Y | [`TrtLlmMxfp4ExpertsMonolithic`][vllm.model_executor.layers.fused_moe.experts.trtllm_mxfp4_moe.TrtLlmMxfp4ExpertsMonolithic],
[`TrtLlmMxfp4ExpertsModular`][vllm.model_executor.layers.fused_moe.experts.trtllm_mxfp4_moe.TrtLlmMxfp4ExpertsModular],
[`TrtLlmNvFp4ExpertsMonolithic`][vllm.model_executor.layers.fused_moe.experts.trtllm_nvfp4_moe.TrtLlmNvFp4ExpertsMonolithic],
[`TrtLlmNvfp4ExpertsModular`][vllm.model_executor.layers.fused_moe.experts.trtllm_nvfp4_moe.TrtLlmNvFp4ExpertsModular] | +| hpc | standard | fp8 | G(128),T | silu | Y | Y | [`HPCExperts`][vllm.model_executor.layers.fused_moe.hpc_moe.HPCExperts] | | rocm aiter moe | standard | mxfp4,
fp8 | G(32),G(128),A,T | silu, gelu,
swigluoai | Y | N | `rocm_aiter_fused_experts`,
`AiterExperts` | | cpu_fused_moe | standard | N/A | N/A | silu | N | N | [`CPUFusedMOE`][vllm.model_executor.layers.fused_moe.cpu_fused_moe.CPUFusedMOE] | | naive batched4 | batched | int8,
fp8 | G,A,T | silu, gelu | 6 | Y | [`NaiveBatchedExperts`][vllm.model_executor.layers.fused_moe.experts.fused_batched_moe.NaiveBatchedExperts] | diff --git a/docs/design/nixl_kv_cache_lease.md b/docs/design/nixl_kv_cache_lease.md index a3fdaafe3453..aa7683bb9e1d 100644 --- a/docs/design/nixl_kv_cache_lease.md +++ b/docs/design/nixl_kv_cache_lease.md @@ -128,7 +128,7 @@ The lease mechanism is controlled through `kv_connector_extra_config` in `--kv-t vllm serve \ --kv-transfer-config '{ "kv_connector": "NixlConnector", - "kv_role": "kv_both", + "kv_role": "kv_producer", "kv_connector_extra_config": {"kv_lease_duration": 60} }' ``` diff --git a/docs/design/nixl_kv_push_connector.md b/docs/design/nixl_kv_push_connector.md new file mode 100644 index 000000000000..b99ba6659f74 --- /dev/null +++ b/docs/design/nixl_kv_push_connector.md @@ -0,0 +1,256 @@ +# NIXL push-mode KV transfer + +The default NIXL connector is **pull-based**: the decode (D) instance +reads KV blocks from the prefill (P) instance via `NIXL READ` after +prefill completes. `NixlPushConnector` adds a **push-based** alternative +in which P writes the KV blocks directly into D's pre-allocated memory +via `NIXL WRITE`. + +This document describes the threading, queues, and scheduling +interactions specific to the push design. The pull-mode design is +unchanged; the push connector reuses the same handshake, NIXL agent +setup, and metadata path wherever possible. + +## High-level flow + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Proxy + participant DSched as D Scheduler + participant DWorker as D Worker (main) + participant DWriter as D Writer + participant PWriter as P Writer + participant PWorker as P Worker (main) + participant PSched as P Scheduler + + Client->>Proxy: POST /v1/completions + Proxy->>PSched: prefill leg (do_remote_decode=True, max_tokens=1) + Proxy->>DSched: decode leg (do_remote_prefill=True, P coordinates) + + note over DSched,DWriter: D side - register blocks with P + DSched->>DSched: update_state_after_alloc, stash registration, arm watchdog + DSched->>DWorker: build_connector_meta -> meta.push_registrations + DWorker->>DWriter: enqueue (req_id, reg_data) on _reg_send_inbox + DWriter->>PWriter: NIXL send_notif PUSH_REG msgpack + + note over PSched,PWriter: P side - prefill, stage finished blocks + PSched->>PSched: request_finished, stash blocks + PSched->>PWorker: build_connector_meta -> meta.push_finished_blocks + PWorker->>PWriter: enqueue (req_id, blocks) on _finished_blocks_inbox + + note over PWriter: P writer matches and WRITEs + PWriter->>PWriter: get_new_notifs returns PUSH_REG, route via _handle_push_reg_notif + alt PUSH_REG and finished blocks both present + PWriter->>PWriter: pop matching pair, fire WRITE + else only one side present + PWriter->>PWriter: stash and wait, self-poll only when blocks unmatched + end + PWriter->>PWriter: ensure D handshake (one-time) + PWriter->>DWriter: NIXL WRITE direct to D GPU + completion notif + + note over DWorker,DWriter: D side - completion accounting + DWriter-->>DWorker: forward HB and completion notifs via _pending_completion_notifs + DWorker->>DWorker: _get_new_notifs drains, HB extends lease, completion marks recv done + DWorker->>DSched: update_connector_output(finished_recving) + DSched->>DSched: clear watchdog deadline + + note over PWorker,PWriter: P side - reclaim + PWorker->>PWorker: get_finished, drain _sending_transfers, queue eviction + PWriter->>PWriter: drain _evict_finished_inbox, drop stale state + PWorker->>PSched: update_connector_output(finished_sending) + PSched->>PSched: free lease + + DWorker-->>Proxy: stream decode tokens + Proxy-->>Client: response +``` + +## Threads + +``NixlPushConnectorWorker`` introduces a single dedicated background +thread per worker (i.e. per TP rank), named ``nixl-push-writer``. +Each owns the new push-specific NIXL operations on its rank: + +* ``nixl_wrapper.get_new_notifs()`` — receive notifications. +* ``nixl_wrapper.send_notif(...)`` for the ``PUSH_REG:`` (D + side) and for the per-WRITE completion notif (P side). +* ``nixl_wrapper.make_prepped_xfer(...) / transfer(...)`` — submit the + WRITE itself. + +Heartbeats continue to go out from the engine main thread via the +existing base-worker ``_send_heartbeats`` plumbing inside +``start_load_kv``. + +### Wake model + +The writer thread blocks on ``_push_writer_wake`` (a +``threading.Event``) when it has no work. Three callers set the +event: + +1. **``start_load_kv``** (worker main thread, called once per engine + step with the scheduler's metadata) — sets the wake only when the + step actually hands the writer new work, i.e. when + ``meta.push_registrations`` or ``meta.push_finished_blocks`` is + non-empty. This is the wake for new transfers. +2. **``get_finished``** (worker main thread, called once per engine + step to report completions) — always sets the wake. The writer is + the sole consumer of ``nixl_wrapper.get_new_notifs()`` for push, + so this gives it a chance to drain inbound notifs (heartbeats from + D, completion notifs after a WRITE, late-arriving ``PUSH_REG``) + even when there is no new metadata to act on. +3. **Handshake-completion callback** (background handshake executor + thread) — when a deferred D→P handshake finishes successfully, the + future's done-callback re-enqueues the registration onto + ``_reg_send_inbox`` and sets the wake so the corresponding + ``send_notif`` runs on the writer (we never call ``send_notif`` from + the executor thread). On this second pass ``_ensure_handshake`` + returns ``None`` (the agent is now connected), so the writer sends + the ``PUSH_REG`` directly. If the handshake *failed*, the callback + fails the request instead of re-enqueuing, so there is no retry + loop. + +In addition to event-driven wakes, the writer self-polls at +``_PUSH_WRITER_POLL_INTERVAL_MS = 1.0`` ms while there are P-side +finished blocks waiting for an unmatched ``PUSH_REG``. + +When a request completes on P (lease expires or the WRITE finishes), +``get_finished`` enqueues the request id onto ``_evict_finished_inbox``, +which the writer drains to drop stale ``_push_finished_blocks`` / +``_pending_d_registrations`` and stop self-polling. + +## Writer-local matching tables + +| Table | Owner | Holds | +|--------------------------------|------------------|------------------------------------------------------------------------| +| `_pending_d_registrations` | writer | D registrations received from a remote D, waiting for P's blocks | +| `_push_finished_blocks` | writer | P blocks staged by the scheduler, waiting for a remote D registration | + +Either side can arrive first. The writer matches in both directions: +when a ``PUSH_REG`` arrives we look up ``_push_finished_blocks``, and +when finished blocks arrive we look up ``_pending_d_registrations``. +Both lookups try an exact ``request_id`` match first, then fall back +to comparing the ids after stripping the trailing per-engine random +suffix (via ``get_base_request_id``). The fallback exists because the +proxy hands the same ``X-Request-Id`` to both legs, so P and D wrap it +into the same ``cmpl--`` form and differ only by the +8-hex randomization suffix that ``input_processor.assign_request_id`` +appends per engine. Stripping just that suffix normalizes both sides +to the same id while preserving the completion index (so multi-prompt +sub-requests stay distinct). It also works whether or not +``VLLM_DISABLE_REQUEST_ID_RANDOMIZATION`` is set, which matters since +that env var is slated for removal upstream. + +## Wire format + +A push registration is sent as a NIXL notification: + +```text +PUSH_REG: +``` + +Fields in the dict: + +| Field | Set by | Meaning | +|----------------------|--------|------------------------------------------------------------------------| +| ``request_id`` | D | D's own vLLM request id; P's match key, echoed in the completion notif | +| ``decode_engine_id`` | D | D's engine id (P uses this for the reverse handshake) | +| ``decode_host`` | D | D's NIXL side-channel host | +| ``decode_port`` | D | D's NIXL side-channel port | +| ``decode_tp_size`` | D | D's tensor-parallel size | +| ``local_block_ids`` | D | per-group lists of D's *logical* block ids (preallocated) | +| ``remote_engine_id`` | D | P's engine id (for the existing P-side handshake) | +| ``remote_host`` | D | P's NIXL side-channel host | +| ``remote_port`` | D | P's NIXL side-channel port | +| ``remote_tp_size`` | D | P's tensor-parallel size | + +D ships **logical** block ids; P expands them to physical block ids at +WRITE-submission time using the ratio learned during the NIXL +handshake (`remote_physical_blocks_per_logical`). This matches the +pull-mode contract — schedulers ship logical ids, workers expand to +physical at submission. + +The completion notif sent from P to D after a WRITE is the existing +`:` format used in pull mode (here ``request_id`` +is D's own request id, taken from the registration), so the D-side +accounting code is unchanged. + +## Scheduler-side responsibilities + +`NixlPushConnectorScheduler` extends the base scheduler with: + +* **D side** — `update_state_after_alloc` stashes registration data in + `_push_pending_registrations` and arms a soft watchdog + (`_push_registration_deadlines`). `build_connector_meta` drains the + stash into `meta.push_registrations` and any expired entries are + dropped with a warning. +* **P side** — `request_finished` stashes block IDs in + `_finished_request_blocks` (for the lease and for + `has_pending_push_work`) and `_newly_finished_push_blocks` (for the + next worker step via `meta.push_finished_blocks`). +* **Both sides** — `has_pending_push_work` keeps the engine main loop + stepping while there is in-flight push state, so the writer always + gets at least one wake per step. + +`update_connector_output`: + +* `finished_sending` (P side) clears the lease entry. +* `finished_recving` (D side) clears the watchdog deadline. + +## Timeouts and watchdogs + +Two per-request timers are armed on the scheduler: + +* **D-side registration watchdog** — ``_push_registration_deadlines``. + If a registered request does not see a push completion within + ``push_registration_timeout`` seconds (defaults to + ``decoder_kv_blocks_ttl``), ``build_connector_meta`` drops the stale + registration and the pending entry, logs a warning, and stops trying + to resend the registration. The corresponding request remains tracked + in ``_reqs_need_recv``; it is the engine's request-level abort path + (or the user / proxy timing out the HTTP call) that ultimately fails + the request. +* **P-side block lease** — same ``_kv_lease_duration`` used by pull + mode. ``request_finished`` sets the expiration in ``_reqs_need_send`` + and ``update_connector_output(finished_sending=...)`` clears it on + successful WRITE. Stale leases are reaped by ``get_finished`` in the + base worker, which then enqueues the eviction onto + ``_evict_finished_inbox`` so the writer also stops self-polling. + +## Failure handling + +* **D-side handshake failure (P→D handshake before sending PUSH_REG)** — + the future's done-callback calls ``_handle_failed_transfer(rid, None)``, + which marks D's pre-allocated blocks invalid and enqueues onto + ``_failed_recv_reqs`` so the next ``get_finished`` reports the + request as a failed recv. Same recv-side accounting as pull mode. +* **D-side ``send_notif`` failure when shipping the PUSH_REG to P** — + identical handling: ``_handle_failed_transfer`` marks the recv as + failed. +* **P-side WRITE submission failure** — the WRITE handle (if any) is + released and ``xfer_stats.record_failed_transfer()`` bumps the + failure counter. We deliberately do not call + ``_handle_failed_transfer`` here: ``req_id`` on the P side has no + entry in ``_recving_metadata`` (P is not the receiver), so the + helper would put a P-local request id into ``_failed_recv_reqs`` + and trip the assertion in the base worker's ``get_finished``. The + outbound WRITE is dropped on the floor; D's lease watchdog handles + the missing completion. + +## Summary + +The push design is a small, well-contained extension on top of the +existing NIXL connector: + +* one new connector class, one new scheduler class, one new worker + class — all subclasses of the existing base classes; +* one dedicated background thread per worker; +* a few cross-thread queues, each with a single consumer (the writer); + most have one producer, except ``_reg_send_inbox``, which is fed both + by the engine main thread (new registrations) and by the + handshake-completion callback (registrations replayed after their + D→P handshake finishes); +* one new notification type (`PUSH_REG:`). + +Behavior on the engine main thread is otherwise unchanged. The writer +thread is event-driven and idle when there is no push work. diff --git a/docs/design/p2p_nccl_connector.md b/docs/design/p2p_nccl_connector.md deleted file mode 100644 index c1de955b6ffe..000000000000 --- a/docs/design/p2p_nccl_connector.md +++ /dev/null @@ -1,319 +0,0 @@ -# P2P NCCL Connector - -An implementation of xPyD with dynamic scaling based on point-to-point communication, partly inspired by Dynamo. - -## Detailed Design - -### Overall Process - -As shown in Figure 1, the overall process of this **PD disaggregation** solution is described through a request flow: - -1. The client sends an HTTP request to the Proxy/Router's `/v1/completions` interface. -2. The Proxy/Router selects a **1P1D (1 Prefill instance + 1 Decode instance)** through either through round-robin or random selection, generates a `request_id` (rules to be introduced later), modifies the `max_tokens` in the HTTP request message to **1**, and then forwards the request to the **P instance**. -3. Immediately afterward, the Proxy/Router forwards the **original HTTP request** to the **D instance**. -4. The **P instance** performs **Prefill** and then **actively sends the generated KV cache** to the D instance (using **PUT_ASYNC** mode). The D instance's `zmq_addr` can be resolved through the `request_id`. -5. The **D instance** has a **dedicated thread** for receiving the KV cache (to avoid blocking the main process). The received KV cache is saved into the **GPU memory buffer**, the size of which is determined by the vLLM startup parameter `kv_buffer_size`. When the GPU buffer is full, the KV cache is stored in the **local Tensor memory pool**. -6. During the **Decode**, the D instance's main process retrieves the KV cache (transmitted by the P instance) from either the **GPU buffer** or the **memory pool**, thereby **skipping Prefill**. -7. After completing **Decode**, the D instance returns the result to the **Proxy/Router**, which then forwards it to the **client**. - -![image1](https://github.com/user-attachments/assets/fb01bde6-755b-49f7-ad45-48a94b1e10a7) - -### Proxy/Router (Demo) - -A simple HTTP service acts as the entry point for client requests and starts a background thread to listen for P/D instances reporting their HTTP IP and PORT, as well as ZMQ IP and PORT. It maintains a dictionary of `http_addr -> zmq_addr`. The `http_addr` is the IP:PORT for the vLLM instance's request, while the `zmq_addr` is the address for KV cache handshake and metadata reception. - -The Proxy/Router is responsible for selecting 1P1D based on the characteristics of the client request, such as the prompt, and generating a corresponding `request_id`, for example: - -```text -cmpl-___prefill_addr_10.0.1.2:21001___decode_addr_10.0.1.3:22001_93923d63113b4b338973f24d19d4bf11-0 -``` - -Currently, to quickly verify whether xPyD can work, a round-robin selection of 1P1D is used. In the future, it is planned to use a trie combined with the load status of instances to select appropriate P and D. - -Each P/D instance periodically sends a heartbeat packet to the Proxy/Router (currently every 3 seconds) to register (i.e., report `http_addr -> zmq_addr`) and keep the connection alive. If an instance crashes and fails to send a ping for a certain period of time, the Proxy/Router will remove the timed-out instance (this feature has not yet been developed). - -### KV Cache Transfer Methods - -There are three methods for KVCache transfer: PUT, GET, and PUT_ASYNC. These methods can be specified using the `--kv-transfer-config` and `kv_connector_extra_config` parameters, specifically through the `send_type` field. Both PUT and PUT_ASYNC involve the P instance actively sending KVCache to the D instance. The difference is that PUT is a synchronous transfer method that blocks the main process, while PUT_ASYNC is an asynchronous transfer method. PUT_ASYNC uses a dedicated thread for sending KVCache, which means it does not block the main process. In contrast, the GET method involves the P instance saving the KVCache to the memory buffer after computing the prefill. The D instance then actively retrieves the computed KVCache from the P instance once it has allocated space for the KVCache. - -Experimental results have shown that the performance of these methods, from highest to lowest, is as follows: PUT_ASYNC → GET → PUT. - -### P2P Communication via ZMQ & NCCL - -As long as the address of the counterpart is known, point-to-point KV cache transfer (using NCCL) can be performed, without being constrained by rank and world size. To support dynamic scaling (expansion and contraction) of instances with PD disaggregation. This means that adding or removing P/D instances does not require a full system restart. - -Each P/D instance only needs to create a single `P2pNcclEngine` instance. This instance maintains a ZMQ Server, which runs a dedicated thread to listen on the `zmq_addr` address and receive control flow requests from other instances. These requests include requests to establish an NCCL connection and requests to send KVCache metadata (such as tensor shapes and data types). However, it does not actually transmit the KVCache data itself. - -When a P instance and a D instance transmit KVCache for the first time, they need to establish a ZMQ connection and an NCCL group. For subsequent KVCache transmissions, this ZMQ connection and NCCL group are reused. The NCCL group consists of only two ranks, meaning the world size is equal to 2. This design is intended to support dynamic scaling, which means that adding or removing P/D instances does not require a full system restart. As long as the address of the counterpart is known, point-to-point KVCache transmission can be performed, without being restricted by rank or world size. - -### NCCL Group Topology - -Currently, only symmetric TP (Tensor Parallelism) methods are supported for KVCache transmission. Asymmetric TP and PP (Pipeline Parallelism) methods will be supported in the future. Figure 2 illustrates the 1P2D setup, where each instance has a TP (Tensor Parallelism) degree of 2. There are a total of 7 NCCL groups: three vLLM instances each have one NCCL group with TP=2. Additionally, the 0th GPU card of the P instance establishes an NCCL group with the 0th GPU card of each D instance. Similarly, the 1st GPU card of the P instance establishes an NCCL group with the 1st GPU card of each D instance. - -![image2](https://github.com/user-attachments/assets/837e61d6-365e-4cbf-8640-6dd7ab295b36) - -Each NCCL group occupies a certain amount of GPU memory buffer for communication, the size of which is primarily influenced by the `NCCL_MAX_NCHANNELS` environment variable. When `NCCL_MAX_NCHANNELS=16`, an NCCL group typically occupies 100MB, while when `NCCL_MAX_NCHANNELS=8`, it usually takes up 52MB. For large-scale xPyD configurations—such as DeepSeek's 96P144D—this implementation is currently not feasible. Moving forward, we are considering using RDMA for point-to-point communication and are also keeping an eye on UCCL. - -### GPU Memory Buffer and Tensor Memory Pool - -The trade-off in the size of the memory buffer is as follows: For P instances, the memory buffer is not required in PUT and PUT_ASYNC modes, but it is necessary in GET mode. For D instances, a memory buffer is needed in all three modes. The memory buffer for D instances should not be too large. Similarly, for P instances in GET mode, the memory buffer should also not be too large. The memory buffer of D instances is used to temporarily store KVCache sent by P instances. If it is too large, it will reduce the KVCache space available for normal inference by D instances, thereby decreasing the inference batch size and ultimately leading to a reduction in output throughput. The size of the memory buffer is configured by the parameter `kv_buffer_size`, measured in bytes, and is typically set to 5%~10% of the memory size. - -If the `--max-num-seqs` parameter for P instances is set to a large value, due to the large batch size, P instances will generate a large amount of KVCache simultaneously. This may exceed the capacity of the memory buffer of D instances, resulting in KVCache loss. Once KVCache is lost, D instances need to recompute Prefill, which is equivalent to performing Prefill twice. Consequently, the time-to-first-token (TTFT) will significantly increase, leading to degraded performance. - -To address the above issues, I have designed and developed a local Tensor memory pool for storing KVCache, inspired by the buddy system used in Linux memory modules. Since the memory is sufficiently large, typically in the TB range on servers, there is no need to consider prefix caching or using block-based designs to reuse memory, thereby saving space. When the memory buffer is insufficient, KVCache can be directly stored in the Tensor memory pool, and D instances can subsequently retrieve KVCache from it. The read and write speed is that of PCIe, with PCIe 4.0 having a speed of approximately 21 GB/s, which is usually faster than the Prefill speed. Otherwise, solutions like Mooncake and lmcache would not be necessary. The Tensor memory pool acts as a flood diversion area, typically unused except during sudden traffic surges. In the worst-case scenario, my solution performs no worse than the normal situation with a Cache store. - -## Install vLLM - -```shell -pip install "vllm>=0.9.2" -``` - -## Run xPyD - -### Instructions - -- The following examples are run on an A800 (80GB) device, using the Meta-Llama-3.1-8B-Instruct model. -- Pay attention to the setting of the `kv_buffer_size` (in bytes). The empirical value is 10% of the GPU memory size. This is related to the kvcache size. If it is too small, the GPU memory buffer for temporarily storing the received kvcache will overflow, causing the kvcache to be stored in the tensor memory pool, which increases latency. If it is too large, the kvcache available for inference will be reduced, leading to a smaller batch size and decreased throughput. -- For Prefill instances, when using non-GET mode, the `kv_buffer_size` can be set to 1, as Prefill currently does not need to receive kvcache. However, when using GET mode, a larger `kv_buffer_size` is required because it needs to store the kvcache sent to the D instance. -- You may need to modify the `kv_buffer_size` and `port` in the following commands (if there is a conflict). -- `PUT_ASYNC` offers the best performance and should be prioritized. -- The `--port` must be consistent with the `http_port` in the `--kv-transfer-config`. -- The `disagg_proxy_p2p_nccl_xpyd.py` script will use port 10001 (for receiving client requests) and port 30001 (for receiving service discovery from P and D instances). -- The node running the proxy must have `quart` installed. -- Supports multiple nodes; you just need to modify the `proxy_ip` and `proxy_port` in `--kv-transfer-config`. -- In the following examples, it is assumed that **the proxy's IP is 10.0.1.1**. - -### Run 1P3D - -#### Proxy (e.g. 10.0.1.1) - -```shell -cd {your vllm directory}/examples/disaggregated/p2p_nccl_xpyd/ -python3 disagg_proxy_p2p_nccl_xpyd.py & -``` - -#### Prefill1 (e.g. 10.0.1.2 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=0 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20001 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.9 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_producer","kv_buffer_size":"1e1","kv_port":"21001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20001"}}' > /var/vllm.log 2>&1 & - ``` - -#### Decode1 (e.g. 10.0.1.3 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=1 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20002 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.7 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_consumer","kv_buffer_size":"8e9","kv_port":"22001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20002"}}' > /var/vllm.log 2>&1 & - ``` - -#### Decode2 (e.g. 10.0.1.4 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=2 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20003 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.7 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_consumer","kv_buffer_size":"8e9","kv_port":"23001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20003"}}' > /var/vllm.log 2>&1 & - ``` - -#### Decode3 (e.g. 10.0.1.5 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=3 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20004 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.7 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_consumer","kv_buffer_size":"8e9","kv_port":"24001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20004"}}' > /var/vllm.log 2>&1 & - ``` - -### Run 3P1D - -#### Proxy (e.g. 10.0.1.1) - -```shell -cd {your vllm directory}/examples/disaggregated/p2p_nccl_xpyd/ -python3 disagg_proxy_p2p_nccl_xpyd.py & -``` - -#### Prefill1 (e.g. 10.0.1.2 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=0 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20001 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.9 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_producer","kv_buffer_size":"1e1","kv_port":"21001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20001"}}' > /var/vllm.log 2>&1 & - ``` - -#### Prefill2 (e.g. 10.0.1.3 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=1 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20002 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.9 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_producer","kv_buffer_size":"1e1","kv_port":"22001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20002"}}' > /var/vllm.log 2>&1 & - ``` - -#### Prefill3 (e.g. 10.0.1.4 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=2 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20003 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.9 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_producer","kv_buffer_size":"1e1","kv_port":"23001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20003"}}' > /var/vllm.log 2>&1 & - ``` - -#### Decode1 (e.g. 10.0.1.5 or 10.0.1.1) - -??? console "Command" - - ```shell - CUDA_VISIBLE_DEVICES=3 vllm serve {your model directory} \ - --host 0.0.0.0 \ - --port 20004 \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --served-model-name base_model \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.7 \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_consumer","kv_buffer_size":"8e9","kv_port":"24001","kv_connector_extra_config":{"proxy_ip":"10.0.1.1","proxy_port":"30001","http_port":"20004"}}' > /var/vllm.log 2>&1 & - ``` - -## Single request - -```shell -curl -X POST -s http://10.0.1.1:10001/v1/completions \ --H "Content-Type: application/json" \ --d '{ - "model": "base_model", - "prompt": "San Francisco is a", - "max_tokens": 10, - "temperature": 0 -}' -``` - -## Benchmark - -??? console "Command" - - ```shell - vllm bench serve \ - --backend vllm \ - --model base_model \ - --tokenizer meta-llama/Llama-3.1-8B-Instruct \ - --dataset-name "random" \ - --host 10.0.1.1 \ - --port 10001 \ - --random-input-len 1024 \ - --random-output-len 1024 \ - --ignore-eos \ - --burstiness 100 \ - --percentile-metrics "ttft,tpot,itl,e2el" \ - --metric-percentiles "90,95,99" \ - --seed $(date +%s) \ - --trust-remote-code \ - --request-rate 3 \ - --num-prompts 1000 - ``` - -## Shut down - -```shell -pgrep python | xargs kill -9 && pkill -f python -``` - -## Test data - -### **Scenario**: 1K input & 200 output tokens, E2E P99 latency ~2s - -![testdata](https://github.com/user-attachments/assets/cef0953b-4567-4bf9-b940-405b92a28eb1) diff --git a/docs/design/paged_attention.md b/docs/design/paged_attention.md index 7c0132cd2a21..f4742c7faaa4 100644 --- a/docs/design/paged_attention.md +++ b/docs/design/paged_attention.md @@ -52,7 +52,7 @@ __device__ void paged_attention_kernel( ) ``` -There are also a list of template arguments above the function +There is also a list of template arguments above the function signature that are determined during compilation time. `scalar_t` represents the data type of the query, key, and value data elements, such as FP16. `HEAD_SIZE` indicates the number of elements in each @@ -178,7 +178,7 @@ const scalar_t* k_ptr = k_cache + physical_block_number * kv_block_stride + physical_block_offset * x; ``` -Unlike to `q_ptr`, `k_ptr` in each thread will point to different +Unlike `q_ptr`, `k_ptr` in each thread will point to different key token at different iterations. As shown above, that `k_ptr` points to key token data based on `k_cache` at assigned block, assigned head and assigned token. diff --git a/docs/design/plugin_system.md b/docs/design/plugin_system.md index e5c9cea17c28..dd49df0ef2f5 100644 --- a/docs/design/plugin_system.md +++ b/docs/design/plugin_system.md @@ -53,6 +53,8 @@ Every plugin has three parts: - **Stat logger plugins** (with group name `vllm.stat_logger_plugins`): The primary use case for these plugins is to register custom, out-of-the-tree loggers into vLLM. The entry point should be a class that subclasses StatLoggerBase. +- **Endpoint plugins** (with group name `vllm.endpoint_plugins`): The primary use case for these plugins is to register custom, out-of-the-tree HTTP routes on the OpenAI compatible API server. Unlike the other plugin groups above, endpoint plugins are loaded only in the API server front end process and are **not loaded by default**. See [Endpoint Plugins](endpoint_plugins.md) for the interface and [Security](../usage/security.md#endpoint-plugins) for the opt-in and trust model. + ## Guidelines for Writing Plugins - **Being re-entrant**: The function specified in the entry point should be re-entrant, meaning it can be called multiple times without causing issues. This is necessary because the function might be called multiple times in some processes. diff --git a/docs/design/prefix_caching.md b/docs/design/prefix_caching.md index 0f3100c9b735..f783f4a1bc85 100644 --- a/docs/design/prefix_caching.md +++ b/docs/design/prefix_caching.md @@ -27,7 +27,7 @@ In the example above, the KV cache in the first block can be uniquely identified For `vllm serve`, you can control the hashing algorithm via `--prefix-caching-hash-algo`: - `sha256` (default): Uses Python's `pickle` for serialization. Hashes may not be reproducible across different Python or vLLM versions. - `sha256_cbor`: Uses `cbor2` for serialization, providing a reproducible, cross-language compatible hash. This is recommended for deterministic caching across environments. - - `xxhash`: `Uses Pickle serialization with xxHash (128-bit) for faster, non-cryptographic hashing. Requires the optional `xxhash` package. IMPORTANT: Use of a hashing algorithm that is not considered cryptographically secure theoretically increases the risk of hash collisions, which can cause undefined behavior or even leak private information in multi-tenant environments. Even if collisions are still very unlikely, it is important to consider your security risk tolerance against the performance benefits before turning this on. + - `xxhash`: Uses Pickle serialization with xxHash (128-bit) for faster, non-cryptographic hashing. Requires the optional `xxhash` package. IMPORTANT: Use of a hashing algorithm that is not considered cryptographically secure theoretically increases the risk of hash collisions, which can cause undefined behavior or even leak private information in multi-tenant environments. Even if collisions are still very unlikely, it is important to consider your security risk tolerance against the performance benefits before turning this on. - `xxhash_cbor` combines canonical CBOR serialization with xxHash for reproducible hashing. Requires the optional `xxhash` package. **A hashing example with multi-modality inputs** @@ -197,7 +197,7 @@ As can be seen, block 3 is a new full block and is cached. However, it is redund When a request is finished, we free all its blocks if no other requests are using them (reference count = 0). In this example, we free request 1 and block 2, 3, 4, 8 associated with it. We can see that the freed blocks are added to the tail of the free queue in the *reverse* order. This is because the last block of a request must hash more tokens and is less likely to be reused by other requests. As a result, it should be evicted first. -![Free queue after a request us freed](../assets/design/prefix_caching/free.png) +![Free queue after a request is freed](../assets/design/prefix_caching/free.png) ### Eviction (LRU) diff --git a/docs/design/torch_compile_multimodal.md b/docs/design/torch_compile_multimodal.md index 8b745c8ce233..bb30de56bc14 100644 --- a/docs/design/torch_compile_multimodal.md +++ b/docs/design/torch_compile_multimodal.md @@ -88,7 +88,7 @@ If compilation fails for a multimodal model: 1. **Disable and test**: First verify the model works without compilation: ```bash - VLLM_TORCH_COMPILE_LEVEL=0 vllm serve --compilation-config='{"compile_mm_encoder":"false"}' + vllm serve --compilation-config='{"mode":0,"compile_mm_encoder":"false"}' ``` 2. **Check logs**: Enable debug logging to see compilation details: diff --git a/docs/examples/README.md b/docs/examples/README.md index 9d6126a65c41..a9a127a4d5d6 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -9,9 +9,10 @@ vLLM's examples are organized into the following categories: - **[`features/`](../../examples/features)** – Demonstrations of individual vLLM features: automatic prefix caching, speculative decoding, LoRA, structured outputs, prompt embedding, pause/resume, batch invariance, KV events, data parallelism, and more. - **[`reasoning/`](../../examples/reasoning)** – Examples for reasoning with vLLM. - **[`tool_calling/`](../../examples/tool_calling)** – Examples for function/tool calling with vLLM. -- **[`applications/`](../../examples/applications)** – Application examples such as chatbots and RAG (Retrieval-Augmented Generation). +- **[`applications/`](../../examples/applications)** – Application examples such as simpler api server, chatbots and RAG (Retrieval-Augmented Generation). - **[`rl/`](../../examples/rl)** – Reinforcement learning examples. - **[`deployment/`](../../examples/deployment)** – Examples for deploying vLLM in production. - **[`ray_serving/`](../../examples/ray_serving)** – Scalable serving using Ray. -- **[`disaggregated/`](../../examples/disaggregated)** – Examples for disaggregated serving (separate prefill and decode), including various kv cache connectors (LMCache, Mooncake, FlexKV, P2P NCCL) and failure recovery. +- **[`disaggregated/`](../../examples/disaggregated)** – Examples for Disaggregated P/D (Prefill/Decoding) inference, including various kv cache connectors (LMCache, Mooncake, FlexKV, P2P NCCL) and failure recovery. +- **[`scale_out/`](../../examples/scale_out)** – Examples for Token In <> Token Out API Server. - **[`observability/`](../../examples/observability)** – Metrics, logging, tracing (OpenTelemetry), and dashboards (Grafana, Perses). diff --git a/docs/features/batch_invariance.md b/docs/features/batch_invariance.md index b23631484508..37a9a7399908 100644 --- a/docs/features/batch_invariance.md +++ b/docs/features/batch_invariance.md @@ -17,10 +17,7 @@ Batch invariance is crucial for several use cases: ## Hardware Requirements -Batch invariance currently requires NVIDIA GPUs with compute capability 9.0 or higher: - -- **H-series**: H100, H200 -- **B-series**: B100, B200 +Batch invariance requires NVIDIA GPUs with compute capability 8.0 or higher. ## Enabling Batch Invariance @@ -107,7 +104,7 @@ Batch invariance has been tested and verified on the following models: - **Qwen3 (Dense)**: `Qwen/Qwen3-1.7B`, `Qwen/Qwen3-8B`, `Qwen/Qwen3-4B-AWQ`, `Qwen/Qwen3-8B-AWQ` - **Qwen3 (MoE)**: `Qwen/Qwen3-30B-A3B`, `Qwen/Qwen3-Next-80B-A3B-Instruct`, `Qwen/Qwen3-30B-A3B-Thinking-2507-FP8` - **Qwen2.5**: `Qwen/Qwen2.5-0.5B-Instruct`, `Qwen/Qwen2.5-1.5B-Instruct`, `Qwen/Qwen2.5-3B-Instruct`, `Qwen/Qwen2.5-7B-Instruct`, `Qwen/Qwen2.5-14B-Instruct`, `Qwen/Qwen2.5-32B-Instruct` -- **Llama 3**: `meta-llama/Llama-3.1-8B-Instruct`, `meta-llama/Llama-3.2-1B-Instruct` +- **Llama 3**: Llama3.1 and 3.2 series, `meta-llama/Llama-3.2-3B-Instruct` for example - **GPT-OSS**: `openai/gpt-oss-20b`, `openai/gpt-oss-120b` - **Mistral**: `mistralai/Mistral-7B-v0.3` diff --git a/docs/features/disagg_prefill.md b/docs/features/disagg_prefill.md index 1e959c55f133..578343096df7 100644 --- a/docs/features/disagg_prefill.md +++ b/docs/features/disagg_prefill.md @@ -17,19 +17,16 @@ Two main reasons: ## Usage example -Please refer to [examples/disaggregated/disaggregated_prefill.sh](../../examples/disaggregated/disaggregated_prefill.sh) for the example usage of disaggregated prefilling. - Now supports 9 types of connectors: - **ExampleConnector**: refer to [examples/disaggregated/example_connector/run.sh](../../examples/disaggregated/example_connector/run.sh) for the example usage of ExampleConnector disaggregated prefilling. -- **LMCacheConnectorV1**: refer to [examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_example_nixl.sh](../../examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_example_nixl.sh) for the example usage of LMCacheConnectorV1 disaggregated prefilling which uses NIXL as the underlying KV transmission. +- **LMCacheConnectorV1**: refer to [examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_example_nixl.sh](../../examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_example_nixl.sh) for the example usage of LMCacheConnectorV1 disaggregated prefilling which uses NIXL as the underlying KV transmission. LMCache also offers a multi-process (MP) mode via `LMCacheMPConnector`, where a standalone `lmcache server` holds the KV cache shared by one or more vLLM instances; see the [LMCache examples](../../examples/disaggregated/lmcache/README.md) and the [LMCache docs](https://docs.lmcache.ai) for setup. - **NixlConnector**: refer to [tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh](../../tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh) for the example usage of NixlConnector disaggregated prefilling which support fully async send/recv. For detailed usage guide, see [NixlConnector Usage Guide](nixl_connector_usage.md). For feature compatibility details, see [NixlConnector Compatibility Matrix](nixl_connector_compatibility.md). You may specify one or multiple NIXL transfer backends, such as: ```bash --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both", "kv_buffer_device":"cuda", "kv_connector_extra_config":{"backends":["UCX", "GDS"]}}' ``` -- **P2pNcclConnector**: refer to [examples/disaggregated/p2p_nccl_xpyd/disagg_example_p2p_nccl_xpyd.sh](../../examples/disaggregated/p2p_nccl_xpyd/disagg_example_p2p_nccl_xpyd.sh) for the example usage of P2pNcclConnector disaggregated prefilling. - **MooncakeConnector**: refer to [examples/disaggregated/mooncake_connector/run_mooncake_connector.sh](../../examples/disaggregated/mooncake_connector/run_mooncake_connector.sh) for the example usage of MooncakeConnector disaggregated prefilling. For detailed usage guide, see [MooncakeConnector Usage Guide](mooncake_connector_usage.md). - **MoRIIOConnector** (ROCm only): see [MoRI-IO Usage Guide](moriio_connector_usage.md) for example usage and detailed documentation. - **MultiConnector**: take advantage of the kv_connector_extra_config: dict[str, Any] already present in KVTransferConfig to stash all the connectors we want in an ordered list of kwargs.such as: @@ -44,16 +41,14 @@ Now supports 9 types of connectors: --kv-transfer-config '{"kv_connector":"OffloadingConnector","kv_role":"kv_both","kv_connector_extra_config":{"block_size": 64, "cpu_bytes_to_use": 1000000000}}' ``` + For multi-tier offloading (e.g., CPU + filesystem tier) and the full configuration reference, see the [KV Offloading Usage Guide](kv_offloading_usage.md). + - **FlexKVConnectorV1**: refer to [examples/disaggregated/flexkv_connector/prefix_caching_flexkv.py](../../examples/disaggregated/flexkv_connector/prefix_caching_flexkv.py) for the example usage of FlexKVConnectorV1. FlexKV is a distributed KV Store and multi-level cache management system for ultra-large-scale LLM inference. ```bash --kv-transfer-config '{"kv_connector":"FlexKVConnectorV1","kv_role":"kv_both"}' ``` -## Benchmarks - -Please refer to [benchmarks/disagg_benchmarks](../../benchmarks/disagg_benchmarks) for disaggregated prefilling benchmarks. - ## Development We implement disaggregated prefilling by running 2 vLLM instances. One for prefill (we call it prefill instance) and one for decode (we call it decode instance), and then use a connector to transfer the prefill KV caches and results from prefill instance to decode instance. diff --git a/docs/features/kv_offloading_usage.md b/docs/features/kv_offloading_usage.md new file mode 100644 index 000000000000..cff65753d99a --- /dev/null +++ b/docs/features/kv_offloading_usage.md @@ -0,0 +1,170 @@ +# KV Offloading Usage Guide + +This guide covers configuration of the [`OffloadingConnector`](disagg_prefill.md), which extends the prefix cache by offloading completed KV blocks to slower but larger tiers (CPU host memory, plus optional secondary tiers) as they are produced. Hits in the offload tiers are promoted back to GPU on demand. Transfers between GPU and CPU use DMA (`cudaMemcpyAsync`) and run asynchronously alongside model computation, so offloading adds minimal CPU- and GPU-core overhead. + +!!! note + The `OffloadingConnector` currently supports CUDA, ROCm, and XPU only. + +## Overview + +Two specs are available, selected by the `spec_name` key in `kv_connector_extra_config`: + +- `CPUOffloadingSpec` (default): single CPU tier. Completed GPU blocks are copied into pinned host memory. +- `TieringOffloadingSpec`: multi-tier. A CPU primary tier plus one or more secondary tiers. + +Only the CPU primary tier has direct GPU access. Secondary tiers cannot read from or write to GPU memory; all GPU↔secondary transfers are staged through the CPU primary tier. + +```mermaid +flowchart LR + GPU <--> CPU["CPU primary tier"] + CPU <--> S0["Secondary tier 0"] + CPU <--> S1["Secondary tier 1"] + CPU <--> SN["..."] +``` + +## Single-Tier Setup (CPU Only) + +```bash +vllm serve \ + --kv-transfer-config '{ + "kv_connector": "OffloadingConnector", + "kv_role": "kv_both", + "kv_connector_extra_config": { + "block_size": 64, + "cpu_bytes_to_use": 1000000000 + } + }' +``` + +## Multi-Tier Setup + +Set `spec_name` to `"TieringOffloadingSpec"` and supply a `secondary_tiers` list. Each entry is a dict with a required `type` key plus tier-specific fields. The list is ordered: tier 0 is consulted before tier 1, and so on. See [Secondary Tiers](#secondary-tiers) for tier-specific keys. + +```bash +vllm serve \ + --kv-transfer-config '{ + "kv_connector": "OffloadingConnector", + "kv_role": "kv_both", + "kv_connector_extra_config": { + "spec_name": "TieringOffloadingSpec", + "cpu_bytes_to_use": 10737418240, + "block_size": 16, + "eviction_policy": "lru", + "secondary_tiers": [ + { + "type": "fs", + "root_dir": "/mnt/kv_cache", + "n_read_threads": 32, + "n_write_threads": 16 + } + ] + } + }' +``` + +## `kv_connector_extra_config` Reference + +| Key | Required | Default | Scope | Notes | +| --- | --- | --- | --- | --- | +| `spec_name` | no | `CPUOffloadingSpec` | both | Set to `TieringOffloadingSpec` for multi-tier. | +| `cpu_bytes_to_use` | yes | — | both | Total bytes of host memory reserved for the CPU tier across all workers (not per-worker). | +| `block_size` | no | GPU block size | both | Offloaded block size in tokens; must be a multiple of the GPU block size. | +| `eviction_policy` | no | `lru` | both | Primary tier policy: `lru` or `arc`. | +| `store_threshold` | no | `0` | single-tier | Min lookups before a block is offloaded. Values ≥ 2 are rejected by `TieringOffloadingSpec`. | +| `max_tracker_size` | no | `64000` | single-tier | Max entries in the lookup tracker. | +| `secondary_tiers` | no | `[]` | multi-tier | List of secondary tier configs (see below). | +| `offload_prompt_only` | no | `true` | both | If `true`, only prompt (prefill) blocks are offloaded; decode blocks are skipped. | +| `self_describing_kv_events` | no | `false` | single-tier | Opt-in. When `true` *and* KV cache events are enabled (`--kv-events-config` with `enable_kv_cache_events`), the connector emits self-describing block-granular `BlockStored`/`BlockRemoved` payloads (constituent block hashes, whole-chunk `token_ids`, per-block `block_size`, parent hash, LoRA + group/cache-spec metadata) instead of the placeholder fallback, so external KV-event consumers can index offloaded blocks. Inert unless events are enabled. Currently rejected by `TieringOffloadingSpec`. Full-attention groups only; sliding-window/SSM groups keep the placeholder fallback. In chunk mode (`block_size` > GPU block size), overlapping chunks re-announce shared per-block hashes, so consumers must reference-count (deduplicate) repeated store/remove announcements. | +| `spec_module_path` | no | — | both | Python import path for a custom `OffloadingSpec` not in the built-in registry. Required only when `spec_name` is not built-in (advanced). | + +## Secondary Tiers + +Each entry in `secondary_tiers` is a dict with a required `type` field plus tier-specific fields. + +### Filesystem (FS) + +The filesystem tier (`type: "fs"`) writes blocks to a directory on local storage. + +| Key | Required | Default | Notes | +| --- | --- | --- | --- | +| `type` | yes | — | Must be `fs`. | +| `root_dir` | yes | — | Base directory; vLLM creates subdirectories beneath it (see [On-Disk Layout](#on-disk-layout)). | +| `n_read_threads` | no | `16` | Read-priority I/O threads (load path). | +| `n_write_threads` | no | `16` | Write-priority I/O threads (store path). | + +Each thread group prefers its own queue but pulls from the other when its primary queue is empty, so a write-heavy or read-heavy burst won't leave the off-priority queue waiting. Size the totals to your storage's effective concurrency. + +#### On-Disk Layout + +Under `root_dir`, vLLM creates a subdirectory `_`, where `` is the model name with `/` replaced by `_` (so HuggingFace IDs like `meta-llama/Llama-3-8B` don't nest), and `` is a short SHA256 prefix derived from the run configuration (model, block size, parallelism, dtype, etc.). Runs with the same configuration share the same subdirectory; runs with different configurations live side-by-side under the same `root_dir` without colliding. + +Inside that subdirectory, blocks are sharded across hash-prefix subdirectories to limit directory fan-out: + +```text +/ + _/ + config.json + __r/ + / # first 3 hex chars of the block hash + _g/ # next 2 hex chars + KV cache group index + .bin # full block hash (in hex) +``` + +`config.json` records the run (block size, number of KV groups, etc.) and is written on first start. Each rank writes blocks under its own `_r` sibling directory, so multiple ranks can safely share the same `root_dir`. + +#### Cross-Process Sharing + +To enable KV cache sharing between multiple vLLM instances using the same `root_dir` (e.g., via a shared PVC), the `PYTHONHASHSEED` environment variable must be set to the same fixed value (e.g., `"0"`) on every instance. Without this, each process initializes `NONE_HASH` (the chain-hash seed for block content hashes) with random bytes, producing different block filenames for identical token content. + +```bash +PYTHONHASHSEED=0 vllm serve ... +``` + +### P2P (Including P/D) + +The P2P tier (`type: "p2p"`) shares completed KV blocks between vLLM instances over RDMA via NIXL. Each instance binds a control socket on `host:port` and exchanges blocks directly with peers — no shared filesystem required. + +| Key | Required | Default | Notes | +| --- | --- | --- | --- | +| `type` | yes | — | Must be `p2p`. | +| `host` | no | `0.0.0.0` | Address the control socket binds to. | +| `port` | no | `7777` | Port for the control socket. Must be reachable from peers. | +| `backends` | no | `["UCX"]` | NIXL transport backends. See [NixlConnector Usage Guide](nixl_connector_usage.md#selecting-a-nixl-transport-backend-plugin) for available backends and selection guidance. | +| `num_threads` | no | `4` | NIXL agent worker threads. Only used when `backends` is UCX-only; ignored when any non-UCX backend is requested. | + +The `backends` and `num_threads` options mirror the conditional logic used by [`NixlConnector`](nixl_connector_usage.md#selecting-a-nixl-transport-backend-plugin): when any non-UCX backend is configured, NIXL is initialised with `backends=...`; otherwise it falls back to a UCX-only agent with the configured `num_threads`. This lets the P2P tier use a different transport (e.g. `MOONCAKE`, `GDS_MT`, `LIBFABRIC`) than the main `NixlConnector` running in the same process. + +## Tuning Tips + +- `cpu_bytes_to_use`: a bigger CPU tier means fewer trips to slower secondary tiers and a higher hit rate. The value is total across all workers, not per-worker. Leave headroom for the rest of the host workload. +- For single-tier (CPU-only) setups, set `cpu_bytes_to_use` larger than the aggregate GPU KV cache. Because offloading is immediate, a smaller CPU tier just mirrors what the GPU already holds and adds no hit rate. +- `block_size`: larger offloaded blocks reduce per-block bookkeeping overhead but increase the granularity of lookups. Must be a multiple of the GPU block size. +- FS thread counts: tune `n_read_threads` and `n_write_threads` to the parallelism your storage can sustain. Reads are latency-sensitive on the prefill path, so prefer more read threads when prefill hit rates are high. +- Sharing `root_dir` across runs: runs with the same model, `block_size`, parallelism layout, and dtype share files under the same `` subdirectory. Changing any of these produces a new subdirectory; old ones are orphaned but harmless. Delete them to reclaim disk. + +## Per-Request Selective Offload + +Individual requests can cap how many of their tokens are eligible for offload by setting `max_offload_tokens` in the request's `kv_transfer_params`. Only the first `max_offload_tokens` tokens of the request are offloaded; blocks beyond that point are skipped on the store path. This is useful when a known prefix (e.g., a system prompt or shared context) is worth caching but later request-specific tokens are not. + +| Key | Type | Notes | +| --- | --- | --- | +| `max_offload_tokens` | non-negative `int` | Upper bound on tokens to offload for this request. `0` disables offload for the request entirely; omit the key (or set to `None`) for no cap. Non-`int`, negative, or `bool` values are rejected with a warning and treated as no cap. | + +!!! note + `max_offload_tokens` is experimental and subject to change. + +Example (OpenAI-compatible completions request): + +```json +{ + "model": "", + "prompt": "...", + "kv_transfer_params": { + "max_offload_tokens": 1024 + } +} +``` + +## Further Reading + +- [vLLM blog: KV Offloading Connector](https://vllm.ai/blog/2026-01-08-kv-offloading-connector) — motivation, architecture (DMA-based async transfer), and benchmarks (TTFT and throughput). diff --git a/docs/features/mooncake_store_connector_usage.md b/docs/features/mooncake_store_connector_usage.md index f23acae10c4f..cb857856b78d 100644 --- a/docs/features/mooncake_store_connector_usage.md +++ b/docs/features/mooncake_store_connector_usage.md @@ -203,8 +203,10 @@ the vLLM JSON config. ### kv_connector_extra_config - `load_async` (bool): Enable asynchronous loading for better compute-I/O overlap. Default: `true`. +- `lookup_async` (bool): Run the external prefix-cache lookup on a background thread so it never blocks the scheduler step. The request is held until the in-flight lookup completes, then resumed on a later step. Default: `false`. - `enable_cross_layers_blocks` (bool): Enable cross-layer block packing for reduced store operations. Default: `false`. - `lookup_rpc_port` (int): Custom port for the ZMQ lookup RPC socket. Default: `0`. +- `cache_prefix` (str): Namespace prepended to every store key. Lets separate deployments share one Mooncake master without polluting each other — instances configured with different prefixes never see each other's cached blocks, even for identical prompts. All instances that should share a prefix cache must use the same value. Default: `""` (no prefix; keys are byte-identical to the unprefixed format). ## Notes diff --git a/docs/features/multimodal_inputs.md b/docs/features/multimodal_inputs.md index 847743dfff12..e44596626f80 100644 --- a/docs/features/multimodal_inputs.md +++ b/docs/features/multimodal_inputs.md @@ -816,6 +816,44 @@ Full example: [examples/generate/multimodal/openai_chat_completion_client_for_mu export VLLM_VIDEO_FETCH_TIMEOUT= ``` +#### Video Decoding Backend + +vLLM decodes video bytes into frames using a selectable decoding backend. Three +backends are supported: + +- `opencv` (default): OpenCV-based decoder. +- `pyav`: PyAV decoder. +- `torchcodec`: TorchCodec (PyTorch-native) decoder. + +All three backends are ultimately backed by FFmpeg. `torchcodec` lets +you choose which FFmpeg version is used while `opencv` and `pyav` rely on +whichever FFmpeg build they were linked against. + +Select the backend by passing the `backend` parameter via `--media-io-kwargs`: + +```bash +vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \ + --media-io-kwargs '{"video": {"backend": "torchcodec"}}' +``` + +**TorchCodec-specific parameters:** + +The following parameters only apply to the `torchcodec` backend: + +- `num_ffmpeg_threads`: Number of FFmpeg decoding threads. `0` (default) relies + on the FFmpeg default, which is `min(cpu_count + 1, 16)`. This allows you to + control thread over-subscription. +- `seek_mode`: Seek mode for the decoder. `"exact"` (default) guarantees + frame-accurate sampling by scanning the file when the decoder is created. + `"approximate"` skips that scan for faster decoder creation, at the cost of + relying on the file's metadata (which may yield less accurate seeking). + +```bash +# Example: TorchCodec with approximate seek mode and 4 FFmpeg threads +vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \ + --media-io-kwargs '{"video": {"backend": "torchcodec", "seek_mode": "approximate", "num_ffmpeg_threads": 4}}' +``` + #### Video Frame Recovery For improved robustness when processing potentially corrupted or truncated video files, vLLM supports optional frame recovery using a dynamic window forward-scan approach. When enabled, if a target frame fails to load during sequential reading, the next successfully grabbed frame (before the next target frame) will be used in its place. diff --git a/docs/features/nixl_connector_usage.md b/docs/features/nixl_connector_usage.md index cb5a3dca035a..03b05751c14d 100644 --- a/docs/features/nixl_connector_usage.md +++ b/docs/features/nixl_connector_usage.md @@ -50,7 +50,7 @@ To select a different backend, set `kv_connector_extra_config.backends` in `--kv vllm serve \ --kv-transfer-config '{ "kv_connector":"NixlConnector", - "kv_role":"kv_both", + "kv_role":"kv_producer", "kv_connector_extra_config":{"backends":["LIBFABRIC"]} }' ``` @@ -60,7 +60,7 @@ You can also pass JSON keys individually using dotted arguments, and you can app ```bash vllm serve \ --kv-transfer-config.kv_connector NixlConnector \ - --kv-transfer-config.kv_role kv_both \ + --kv-transfer-config.kv_role kv_producer \ --kv-transfer-config.kv_connector_extra_config.backends+ LIBFABRIC ``` @@ -81,7 +81,7 @@ VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \ vllm serve Qwen/Qwen3-0.6B \ --port 8100 \ --enforce-eager \ - --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_load_failure_policy":"fail"}' + --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer","kv_load_failure_policy":"fail"}' ``` ### Consumer (Decoder) Configuration @@ -96,7 +96,7 @@ VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \ vllm serve Qwen/Qwen3-0.6B \ --port 8200 \ --enforce-eager \ - --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_both","kv_load_failure_policy":"fail"}' + --kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_consumer","kv_load_failure_policy":"fail"}' ``` ### Proxy Server @@ -212,10 +212,21 @@ sequenceDiagram Enable bidirectional KV transfer by setting `bidirectional_kv_xfer` in `kv_connector_extra_config` on **both** P and D instances: ```bash +# Prefill instance vllm serve \ --kv-transfer-config '{ "kv_connector": "NixlConnector", - "kv_role": "kv_both", + "kv_role": "kv_producer", + "kv_connector_extra_config": { + "bidirectional_kv_xfer": true + } + }' + +# Decode instance +vllm serve \ + --kv-transfer-config '{ + "kv_connector": "NixlConnector", + "kv_role": "kv_consumer", "kv_connector_extra_config": { "bidirectional_kv_xfer": true } @@ -283,6 +294,21 @@ curl http://localhost:8000/v1/chat/completions \ !!! note The `conversation_id` field is a non-standard extension to the OpenAI API. It is consumed by the proxy and not forwarded to the vLLM engine. +### Benchmarking the multi-turn proxy + +[`benchmarks/multi_turn/benchmark_serving_multi_turn.py`](../../benchmarks/multi_turn/benchmark_serving_multi_turn.py) supports targeting the disaggregated multi-turn proxy with the `--send-conversation-id` flag, which injects a per-conversation `conversation_id` into every request payload so the proxy can key cross-turn KV cache reuse. + +The flag is **off by default** so the benchmark is compatible with strict OpenAI-compatible frontends that reject unknown top-level fields. When benchmarking the multi-turn proxy you must pass it explicitly — otherwise every turn lands as a cache MISS and the bidirectional KV transfer path is never exercised. + +```bash +python benchmarks/multi_turn/benchmark_serving_multi_turn.py \ + --model --served-model-name \ + --url http://:8000 \ + --input-file benchmarks/multi_turn/generate_multi_turn.json \ + --num-clients 2 --max-active-conversations 6 \ + --send-conversation-id +``` + ### Limitations - Requires a stateful proxy (or equivalent router) to track and forward `kv_transfer_params` between turns. @@ -359,11 +385,10 @@ For multi-host DP deployment, only need to provide the host/port of the head ins - **kv_producer**: For prefiller instances that generate KV caches - **kv_consumer**: For decoder instances that consume KV caches from prefiller -- **kv_both**: Enables symmetric functionality where the connector can act as both producer and consumer. This provides flexibility for experimental setups and scenarios where the role distinction is not predetermined. +- **kv_both** (deprecated): Previously used as a catch-all when the role was not predetermined. This value is now deprecated for NixlConnector and will be removed in a future release. -!!! tip - NixlConnector currently does not distinguish `kv_role`; the actual prefiller/decoder roles are determined by the upper-level proxy (e.g., `toy_proxy_server.py` using `--prefiller-hosts` and `--decoder-hosts`). - Therefore, `kv_role` in `--kv-transfer-config` is effectively a placeholder and does not affect NixlConnector's behavior. +!!! warning + `kv_role="kv_both"` is deprecated for NixlConnector. Please set `kv_role="kv_producer"` for prefill instances and `kv_role="kv_consumer"` for decode instances. See [#33702](https://github.com/vllm-project/vllm/issues/33702) for details. ### KV Load Failure Policy @@ -398,6 +423,54 @@ To enable this feature: --kv-transfer-config '{..., "kv_connector_extra_config": {"enable_cross_layers_blocks": "True"}}' ``` +## Metrics Reference + +vLLM periodically logs a `KV Transfer metrics` line summarising NIXL transfer +activity for the last reporting interval. Example output: + +```text +KV Transfer metrics: Num successful transfers=4, Avg xfer time (ms)=1.381, +P90 xfer time (ms)=2.601, Avg post time (ms)=0.672, P90 post time (ms)=0.801, +Avg MB per transfer=2.25, Throughput (MB/s)=1629.549, Avg number of descriptors=72.0 +``` + +The table below describes each field. All timing values cover only the +successful transfers recorded in the current interval; failed transfers are +counted separately via Prometheus (see +[Prometheus metrics](#prometheus-metrics) below). + +| Metric | Unit | Description | +| -------- | ------ | ------------- | +| `Num successful transfers` | count | Number of NIXL KV-block transfers that completed without error during the interval. A transfer corresponds to one prefill request's worth of KV cache being moved from the prefiller to the decoder (or vice versa in bidirectional mode). | +| `Avg xfer time (ms)` | ms | Mean end-to-end transfer duration (`xferDuration` in NIXL telemetry, converted from µs). Measured from when the request is posted to when the backend reports completion, so it includes both the posting step and the actual data movement. | +| `P90 xfer time (ms)` | ms | 90th-percentile transfer duration. Use this to identify tail latency: a large gap between average and P90 suggests occasional stragglers (e.g., network congestion or large KV blocks). | +| `Avg post time (ms)` | ms | Mean time to submit the transfer request to the RDMA backend (`postDuration` in NIXL telemetry). This is the synchronous cost of posting work to the NIC queue (descriptor setup, etc.) before the async data movement begins. | +| `P90 post time (ms)` | ms | 90th-percentile request-posting duration. Elevated P90 here (with low xfer P90) points to overhead in submitting requests rather than in the data transfer itself. | +| `Avg MB per transfer` | MB | Mean payload size per transfer, computed as `total bytes transferred / number of transfers`. Reflects the average KV cache footprint of a single request (sequence length × layers × head dimension × dtype bytes). | +| `Throughput (MB/s)` | MB/s | Effective bandwidth over the interval: `total MB transferred / total xfer time (s)` across all successful transfers. This is aggregate throughput, not per-request bandwidth. | +| `Avg number of descriptors` | count | Mean number of NIXL memory descriptors (scatter-gather segments) submitted per transfer. More descriptors indicate more fragmented or larger KV cache allocations; very high counts can increase descriptor-registration overhead. | + +### Prometheus metrics + +In addition to the periodic log line, the following Prometheus metrics are +exported when NixlConnector is active: + +| Metric name | Type | Description | +| ------------- | ------ | ------------- | +| `vllm:nixl_xfer_time_seconds` | Histogram | Per-transfer RDMA copy duration (seconds). | +| `vllm:nixl_post_time_seconds` | Histogram | Time to submit the transfer request to the RDMA backend (seconds). | +| `vllm:nixl_bytes_transferred` | Histogram | Bytes moved per transfer. | +| `vllm:nixl_num_descriptors` | Histogram | Descriptor count per transfer. | +| `vllm:nixl_num_failed_transfers` | Counter | Cumulative count of failed NIXL KV-block transfers. | +| `vllm:nixl_num_failed_notifications` | Counter | Cumulative count of failed completion notifications (`send_notif`). | +| `vllm:nixl_num_kv_expired_reqs` | Counter | Requests whose KV blocks expired on the prefiller before the decoder read them (tracked on the P instance). | + +!!! tip + High `vllm:nixl_num_kv_expired_reqs` indicates that the prefiller's lease + duration (`kv_lease_duration`) is too short for your network or workload. + Increase it via `--kv-transfer-config '{"kv_connector_extra_config": + {"kv_lease_duration": }}'`. + ## Example Scripts/Code Refer to these example scripts in the vLLM repository: diff --git a/docs/features/per_request_metrics.md b/docs/features/per_request_metrics.md new file mode 100644 index 000000000000..9bc64d2b86dc --- /dev/null +++ b/docs/features/per_request_metrics.md @@ -0,0 +1,127 @@ +# Per-Request Metrics + +vLLM can return per-request timing metrics directly in API responses. +This is useful for billing, SLA monitoring, and latency analysis at the +individual request level, as a complement to the server-aggregated Prometheus +metrics exposed at `/metrics`. + +## Enabling + +Start the server with `--enable-per-request-metrics`: + +```bash +vllm serve meta-llama/Llama-3.1-8B-Instruct --enable-per-request-metrics +``` + +When this flag is set, supported API responses include metrics for each +attributable request. + +!!! note + At high concurrency, enabling per-request metrics computation may introduce + non-negligible CPU overhead. Benchmark your specific workload to evaluate the + impact before enabling in production. + +## Response Format + +When per-request metrics are enabled, the response includes a `metrics` object: + +```json +{ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "model": "meta-llama/Llama-3.1-8B-Instruct", + "choices": [ ... ], + "usage": { + "prompt_tokens": 42, + "completion_tokens": 128, + "total_tokens": 170 + }, + "metrics": { + "time_to_first_token_ms": 85.2, + "generation_time_ms": 1240.5, + "queue_time_ms": 12.3, + "mean_itl_ms": 9.1, + "tokens_per_second": 103.2 + } +} +``` + +| Field | Description | +| --- | --- | +| `time_to_first_token_ms` | Time from when the request was scheduled until the first output token was generated (TTFT). | +| `generation_time_ms` | Decode time: time from the first output token to the last output token. Excludes both queue wait and prefill/TTFT. | +| `queue_time_ms` | Time the request spent waiting in the scheduler queue before processing began. | +| `mean_itl_ms` | Mean inter-token latency (average time between successive output tokens) during the decode phase. `null` for single-token responses. | +| `tokens_per_second` | Overall output token throughput: all generated tokens over the inference interval (scheduling to last output token). Unlike `generation_time_ms`, this includes the prefill phase, so it reflects end-to-end generation speed rather than pure decode speed. | + +All fields are `null` if the underlying timing data is not available for that +request. + +!!! note + Timing metrics describe a single generation stream, so they are only + returned when the request maps to exactly one. They are suppressed (the + `metrics` object is `null`) for requests with `n > 1`, because the + underlying timing data reflects only one of the `n` sequences and cannot be + accurately attributed to the request as a whole. Token usage + (`prompt_tokens`, `completion_tokens`) remains accurate in these cases. + Per-request metrics also require server-side statistics logging, which is + on by default. vLLM rejects `--enable-per-request-metrics` when + `--disable-log-stats` is also set. + +## Example Request + +=== "Non-streaming" + + ```python + from openai import OpenAI + + client = OpenAI(base_url="http://localhost:8000/v1", api_key="token") + + response = client.chat.completions.create( + model="meta-llama/Llama-3.1-8B-Instruct", + messages=[{"role": "user", "content": "What is the capital of France?"}], + ) + + print(response.usage) + print(response.model_extra.get("metrics")) + ``` + +=== "Streaming" + + In streaming responses, metrics are attached to the final usage chunk (the + chunk sent after all content chunks). That chunk is only emitted when usage + reporting is enabled with `stream_options.include_usage: true` or forced + server-side with `--enable-force-include-usage`. Without forced usage, a + streaming client must set `stream_options.include_usage: true` to receive + metrics. + + ```python + from openai import OpenAI + + client = OpenAI(base_url="http://localhost:8000/v1", api_key="token") + + stream = client.chat.completions.create( + model="meta-llama/Llama-3.1-8B-Instruct", + messages=[{"role": "user", "content": "What is the capital of France?"}], + stream=True, + stream_options={"include_usage": True}, + ) + + for chunk in stream: + if chunk.usage: + print("Usage:", chunk.usage) + print("Metrics:", chunk.model_extra.get("metrics")) + ``` + +## Completions API + +Per-request metrics are also available on the `/v1/completions` endpoint using +the same `metrics` response field. As with `n > 1`, metrics are omitted for +requests with multiple prompts, because the timing data cannot be attributed to +a single prompt's generation. + +## Relationship to Prometheus Metrics + +The `metrics` response field provides per-request values for a single request. +The `/metrics` Prometheus endpoint exposes server-level histograms (e.g. +`vllm:time_to_first_token_seconds`) that aggregate across all requests. diff --git a/docs/features/quantization/README.md b/docs/features/quantization/README.md index 6c4aa7d8aaac..69ece3607610 100644 --- a/docs/features/quantization/README.md +++ b/docs/features/quantization/README.md @@ -3,18 +3,19 @@ Quantization trades off model precision for smaller memory footprint, allowing large models to be run on a wider range of devices. !!! tip - To get started with quantization, see [LLM Compressor](llm_compressor.md), a library for optimizing models for deployment with vLLM that supports FP8, INT8, INT4, and other quantization formats. + To get started with quantization, see [LLM Compressor](llm_compressor/README.md), a library for optimizing models for deployment with vLLM that supports FP8, INT8, INT4, and other quantization formats. The following are the supported quantization formats for vLLM: - [AutoAWQ](auto_awq.md) - [BitsAndBytes](bnb.md) -- [GGUF](gguf.md) - [GPTQModel](gptqmodel.md) - [Intel Neural Compressor](inc.md) -- [INT4 W4A16](int4.md) -- [INT8 W8A8](int8.md) -- [FP8 W8A8](fp8.md) +- [LLM Compressor](llm_compressor/README.md) + - [FP8 W8A8](llm_compressor/fp8.md) + - [INT4 W4A16](llm_compressor/int4.md) + - [INT8 W4A8](llm_compressor/int8_w4a8.md) + - [INT8 W8A8](llm_compressor/int8_w8a8.md) - [NVIDIA Model Optimizer](modelopt.md) - [Online Quantization](online.md) - [AMD Quark](quark.md) @@ -46,16 +47,17 @@ th:not(:first-child) { } -| Implementation | Volta | Turing | Ampere | Ada | Hopper | AMD GPU | Intel GPU | x86 CPU | -| ------------------------- | ----- | ------ | ------ | --- | ------ | ------- | --------- | ------- | -| AWQ | ❌ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ✅︎ | ✅︎ | -| GPTQ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ✅︎ | ✅︎ | -| Marlin (GPTQ/AWQ/FP8/FP4) | ❌ | ✅︎* | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | -| INT8 (W8A8) | ❌ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ✅︎ | -| FP8 (W8A8) | ❌ | ❌ | ❌ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | -| bitsandbytes | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | -| DeepSpeedFP | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | -| GGUF | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | +| Implementation | Volta | Turing | Ampere | Ada | Hopper | AMD GPU | Intel GPU | x86 CPU | Arm CPU | +| ------------------------- | ----- | ------ | ------ | --- | ------ | ------- | --------- | ------- | ------- | +| AWQ | ❌ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ✅︎ | ✅︎ | ❌ | +| GPTQ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ✅︎ | ✅︎ | ❌ | +| Marlin (GPTQ/AWQ/FP8/FP4) | ❌ | ✅︎* | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | ❌ | +| llm-compressor INT8 (W8A8)| ❌ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ✅︎ | ✅︎ | +| llm-compressor INT8 (W4A8)| ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅︎ | +| llm-compressor FP8 (W8A8) | ❌ | ❌ | ❌ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | +| bitsandbytes | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | ❌ | +| DeepSpeedFP | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | ❌ | +| GGUF | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ✅︎ | ❌ | ❌ | ❌ | - Volta refers to SM 7.0, Turing to SM 7.5, Ampere to SM 8.0/8.6, Ada to SM 8.9, and Hopper to SM 9.0. - ✅︎ indicates that the quantization method is supported on the specified hardware. diff --git a/docs/features/quantization/auto_awq.md b/docs/features/quantization/auto_awq.md index e93005f26321..39dfd6fec110 100644 --- a/docs/features/quantization/auto_awq.md +++ b/docs/features/quantization/auto_awq.md @@ -49,7 +49,7 @@ To run an AWQ model with vLLM, you can use [TheBloke/Llama-2-7b-Chat-AWQ](https: ```bash python examples/deployment/llm_engine_example.py \ --model TheBloke/Llama-2-7b-Chat-AWQ \ - --quantization awq + --quantization auto_awq ``` AWQ models are also supported directly through the LLM entrypoint: @@ -70,7 +70,7 @@ AWQ models are also supported directly through the LLM entrypoint: sampling_params = SamplingParams(temperature=0.8, top_p=0.95) # Create an LLM. - llm = LLM(model="TheBloke/Llama-2-7b-Chat-AWQ", quantization="AWQ") + llm = LLM(model="TheBloke/Llama-2-7b-Chat-AWQ", quantization="auto_awq") # Generate texts from the prompts. The output is a list of RequestOutput objects # that contain the prompt, generated text, and other information. outputs = llm.generate(prompts, sampling_params) diff --git a/docs/features/quantization/gguf.md b/docs/features/quantization/gguf.md index 41912a506014..0aa76d679e15 100644 --- a/docs/features/quantization/gguf.md +++ b/docs/features/quantization/gguf.md @@ -3,8 +3,14 @@ !!! warning Please note that GGUF support in vLLM is highly experimental and under-optimized at the moment, it might be incompatible with other features. Currently, you can use GGUF as a way to reduce memory footprint. If you encounter any issues, please report them to the vLLM team. -!!! warning - Currently, vllm only supports loading single-file GGUF models. If you have a multi-files GGUF model, you can use [gguf-split](https://github.com/ggerganov/llama.cpp/pull/6135) tool to merge them to a single-file model. +!!! note + GGUF support has migrated to OOT [vllm-gguf-plugin](https://github.com/vllm-project/vllm-gguf-plugin). Make sure you have GGUF plugin installed before serving a GGUF model. + +Before serving a GGUF model, make sure to install the [vllm-gguf-plugin](https://github.com/vllm-project/vllm-gguf-plugin): + +```bash +uv pip install vllm-gguf-plugin +``` To run a GGUF model with vLLM, you can use the `repo_id:quant_type` format to load directly from HuggingFace. For example, to load a Q4_K_M quantized model from [unsloth/Qwen3-0.6B-GGUF](https://huggingface.co/unsloth/Qwen3-0.6B-GGUF): diff --git a/docs/features/quantization/gptqmodel.md b/docs/features/quantization/gptqmodel.md index 636a952b6551..235afee5f325 100644 --- a/docs/features/quantization/gptqmodel.md +++ b/docs/features/quantization/gptqmodel.md @@ -55,7 +55,7 @@ Here is an example of how to quantize `meta-llama/Llama-3.2-1B-Instruct`: ## Running a quantized model with vLLM -To run an GPTQModel quantized model with vLLM, you can use [DeepSeek-R1-Distill-Qwen-7B-gptqmodel-4bit-vortex-v2](https://huggingface.co/ModelCloud/DeepSeek-R1-Distill-Qwen-7B-gptqmodel-4bit-vortex-v2) with the following command: +To run a GPTQModel quantized model with vLLM, you can use [DeepSeek-R1-Distill-Qwen-7B-gptqmodel-4bit-vortex-v2](https://huggingface.co/ModelCloud/DeepSeek-R1-Distill-Qwen-7B-gptqmodel-4bit-vortex-v2) with the following command: ```bash python examples/deployment/llm_engine_example.py \ diff --git a/docs/features/quantization/inc.md b/docs/features/quantization/inc.md index adb6b3ae8e2f..ffb90cec8c1a 100644 --- a/docs/features/quantization/inc.md +++ b/docs/features/quantization/inc.md @@ -75,14 +75,11 @@ vllm serve Intel/DeepSeek-R1-0528-Qwen3-8B-int4-AutoRound \ --max-model-len 4096 ``` -!!! note - To deploy `wNa16` models on Intel GPU/CPU, please add `--enforce-eager` for now. - ## Evaluating the Quantized Model with vLLM ```bash lm_eval --model vllm \ - --model_args pretrained="Intel/DeepSeek-R1-0528-Qwen3-8B-int4-AutoRound,max_model_len=8192,max_num_batched_tokens=32768,max_num_seqs=128,gpu_memory_utilization=0.8,dtype=bfloat16,max_gen_toks=2048,enforce_eager=True" \ + --model_args pretrained="Intel/DeepSeek-R1-0528-Qwen3-8B-int4-AutoRound,max_model_len=8192,max_num_batched_tokens=32768,max_num_seqs=128,gpu_memory_utilization=0.8,dtype=bfloat16,max_gen_toks=2048" \ --tasks gsm8k \ --num_fewshot 5 \ --batch_size 128 diff --git a/docs/features/quantization/llm_compressor.md b/docs/features/quantization/llm_compressor/README.md similarity index 100% rename from docs/features/quantization/llm_compressor.md rename to docs/features/quantization/llm_compressor/README.md diff --git a/docs/features/quantization/fp8.md b/docs/features/quantization/llm_compressor/fp8.md similarity index 86% rename from docs/features/quantization/fp8.md rename to docs/features/quantization/llm_compressor/fp8.md index 2de71ce8da16..5dc1a7d43a01 100644 --- a/docs/features/quantization/fp8.md +++ b/docs/features/quantization/llm_compressor/fp8.md @@ -21,9 +21,17 @@ The FP8 types typically supported in hardware have two distinct representations, To produce performant FP8 quantized models with vLLM, you'll need to install the [llm-compressor](https://github.com/vllm-project/llm-compressor/) library: ```bash -pip install llmcompressor +(venv-llm-compressor) pip install llmcompressor ``` +Additionally, install `vllm` and `lm-evaluation-harness` for evaluation: + +```bash +(venv-vllm) pip install vllm "lm-eval[api]>=0.4.12" +``` + +Please use separate environments for vLLM and llm-compressor as they might not work together. + ## Quantization Process The quantization process involves three main steps: @@ -57,36 +65,28 @@ For FP8 quantization, we can recover accuracy with simple RTN quantization. We r Since simple RTN does not require data for weight quantization and the activations are quantized dynamically, we do not need any calibration data for this quantization flow. -??? code - - ```python - from llmcompressor import oneshot - from llmcompressor.modifiers.quantization import QuantizationModifier - - # Configure the simple PTQ quantization - recipe = QuantizationModifier( - targets="Linear", - scheme="FP8_DYNAMIC", - ignore=["lm_head"], - ) +```python +from llmcompressor import oneshot +from llmcompressor.modifiers.quantization import QuantizationModifier + +# Configure the simple PTQ quantization +recipe = QuantizationModifier( + targets="Linear", + scheme="FP8_DYNAMIC", + ignore=["lm_head"], +) - # Apply the quantization algorithm. - oneshot(model=model, recipe=recipe) +# Apply the quantization algorithm. +oneshot(model=model, recipe=recipe) - # Save the model: Meta-Llama-3-8B-Instruct-FP8-Dynamic - SAVE_DIR = MODEL_ID.split("/")[1] + "-FP8-Dynamic" - model.save_pretrained(SAVE_DIR) - tokenizer.save_pretrained(SAVE_DIR) - ``` +# Save the model: Meta-Llama-3-8B-Instruct-FP8-Dynamic +SAVE_DIR = MODEL_ID.split("/")[1] + "-FP8-Dynamic" +model.save_pretrained(SAVE_DIR) +tokenizer.save_pretrained(SAVE_DIR) +``` ### 3. Evaluating Accuracy -Install `vllm` and `lm-evaluation-harness` for evaluation: - -```bash -pip install vllm "lm-eval[api]>=0.4.12" -``` - Load and run the model in `vllm`: ```python diff --git a/docs/features/quantization/int4.md b/docs/features/quantization/llm_compressor/int4.md similarity index 62% rename from docs/features/quantization/int4.md rename to docs/features/quantization/llm_compressor/int4.md index 41c4b40574fe..0e54797397ac 100644 --- a/docs/features/quantization/int4.md +++ b/docs/features/quantization/llm_compressor/int4.md @@ -12,15 +12,17 @@ Please visit the HF collection of [quantized INT4 checkpoints of popular LLMs re To use INT4 quantization with vLLM, you'll need to install the [llm-compressor](https://github.com/vllm-project/llm-compressor/) library: ```bash -pip install llmcompressor +(venv-llm-compressor) pip install llmcompressor ``` Additionally, install `vllm` and `lm-evaluation-harness` for evaluation: ```bash -pip install vllm "lm-eval[api]>=0.4.12" +(venv-vllm) pip install vllm "lm-eval[api]>=0.4.12" ``` +Please use separate environments for vLLM and llm-compressor as they might not work together. + ## Quantization Process The quantization process involves four main steps: @@ -52,55 +54,51 @@ When quantizing weights to INT4, you need sample data to estimate the weight upd It's best to use calibration data that closely matches your deployment data. For a general-purpose instruction-tuned model, you can use a dataset like `ultrachat`: -??? code - - ```python - from datasets import load_dataset +```python +from datasets import load_dataset - NUM_CALIBRATION_SAMPLES = 512 - MAX_SEQUENCE_LENGTH = 2048 +NUM_CALIBRATION_SAMPLES = 512 +MAX_SEQUENCE_LENGTH = 2048 - # Load and preprocess the dataset - ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft") - ds = ds.shuffle(seed=42).select(range(NUM_CALIBRATION_SAMPLES)) +# Load and preprocess the dataset +ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft") +ds = ds.shuffle(seed=42).select(range(NUM_CALIBRATION_SAMPLES)) - def preprocess(example): - return {"text": tokenizer.apply_chat_template(example["messages"], tokenize=False)} - ds = ds.map(preprocess) +def preprocess(example): + return {"text": tokenizer.apply_chat_template(example["messages"], tokenize=False)} +ds = ds.map(preprocess) - def tokenize(sample): - return tokenizer(sample["text"], padding=False, max_length=MAX_SEQUENCE_LENGTH, truncation=True, add_special_tokens=False) - ds = ds.map(tokenize, remove_columns=ds.column_names) - ``` +def tokenize(sample): + return tokenizer(sample["text"], padding=False, max_length=MAX_SEQUENCE_LENGTH, truncation=True, add_special_tokens=False) +ds = ds.map(tokenize, remove_columns=ds.column_names) +``` ### 3. Applying Quantization Now, apply the quantization algorithms: -??? code - - ```python - from llmcompressor import oneshot - from llmcompressor.modifiers.quantization import GPTQModifier - from llmcompressor.modifiers.smoothquant import SmoothQuantModifier - - # Configure the quantization algorithms - recipe = GPTQModifier(targets="Linear", scheme="W4A16", ignore=["lm_head"]) - - # Apply quantization - oneshot( - model=model, - dataset=ds, - recipe=recipe, - max_seq_length=MAX_SEQUENCE_LENGTH, - num_calibration_samples=NUM_CALIBRATION_SAMPLES, - ) +```python +from llmcompressor import oneshot +from llmcompressor.modifiers.quantization import GPTQModifier +from llmcompressor.modifiers.smoothquant import SmoothQuantModifier + +# Configure the quantization algorithms +recipe = GPTQModifier(targets="Linear", scheme="W4A16", ignore=["lm_head"]) + +# Apply quantization +oneshot( + model=model, + dataset=ds, + recipe=recipe, + max_seq_length=MAX_SEQUENCE_LENGTH, + num_calibration_samples=NUM_CALIBRATION_SAMPLES, +) - # Save the compressed model: Meta-Llama-3-8B-Instruct-W4A16-G128 - SAVE_DIR = MODEL_ID.split("/")[1] + "-W4A16-G128" - model.save_pretrained(SAVE_DIR, save_compressed=True) - tokenizer.save_pretrained(SAVE_DIR) - ``` +# Save the compressed model: Meta-Llama-3-8B-Instruct-W4A16-G128 +SAVE_DIR = MODEL_ID.split("/")[1] + "-W4A16-G128" +model.save_pretrained(SAVE_DIR, save_compressed=True) +tokenizer.save_pretrained(SAVE_DIR) +``` This process creates a W4A16 model with weights quantized to 4-bit integers. @@ -141,36 +139,34 @@ lm_eval --model vllm \ The following is an example of an expanded quantization recipe you can tune to your own use case: -??? code - - ```python - from compressed_tensors.quantization import ( - QuantizationArgs, - QuantizationScheme, - QuantizationStrategy, - QuantizationType, - ) - recipe = GPTQModifier( - targets="Linear", - config_groups={ - "config_group": QuantizationScheme( - targets=["Linear"], - weights=QuantizationArgs( - num_bits=4, - type=QuantizationType.INT, - strategy=QuantizationStrategy.GROUP, - group_size=128, - symmetric=True, - dynamic=False, - actorder="weight", - ), +```python +from compressed_tensors.quantization import ( + QuantizationArgs, + QuantizationScheme, + QuantizationStrategy, + QuantizationType, +) +recipe = GPTQModifier( + targets="Linear", + config_groups={ + "config_group": QuantizationScheme( + targets=["Linear"], + weights=QuantizationArgs( + num_bits=4, + type=QuantizationType.INT, + strategy=QuantizationStrategy.GROUP, + group_size=128, + symmetric=True, + dynamic=False, + actorder="weight", ), - }, - ignore=["lm_head"], - update_size=NUM_CALIBRATION_SAMPLES, - dampening_frac=0.01, - ) - ``` + ), + }, + ignore=["lm_head"], + update_size=NUM_CALIBRATION_SAMPLES, + dampening_frac=0.01, +) +``` ## Troubleshooting and Support diff --git a/docs/features/quantization/llm_compressor/int8_w4a8.md b/docs/features/quantization/llm_compressor/int8_w4a8.md new file mode 100644 index 000000000000..cc6a09828327 --- /dev/null +++ b/docs/features/quantization/llm_compressor/int8_w4a8.md @@ -0,0 +1,217 @@ +# INT8 W4A8 + +vLLM supports quantizing weights to INT4 and activations to INT8 for memory savings and inference acceleration. +This quantization method is particularly useful for reducing model size while maintaining good performance. + +## Prerequisites + +To use INT8 W4A8 quantization with vLLM, you'll need to install the [llm-compressor](https://github.com/vllm-project/llm-compressor/) library. + +```bash +(venv-llm-compressor) pip install llmcompressor +``` + +Additionally, install `vllm` and `lm-evaluation-harness` for evaluation: + +```bash +(venv-vllm) pip install vllm "lm-eval[api]>=0.4.12" +``` + +Please use separate environments for vLLM and llm-compressor as they might not work together. + +## Quantization Process + +The quantization process involves four main steps: + +1. Loading the model +2. Preparing calibration data +3. Applying quantization +4. Evaluating accuracy in vLLM + +### 1. Loading the Model + +Load your model and tokenizer using the standard `transformers` AutoModel classes: + +```python +from transformers import AutoTokenizer, AutoModelForCausalLM + +MODEL_ID = "meta-llama/Meta-Llama-3-8B-Instruct" +model = AutoModelForCausalLM.from_pretrained( + MODEL_ID, + dtype="auto", +) +tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) +``` + +### 2. Preparing Calibration Data + +When quantizing activations to INT8 and weights to INT4, you need sample data to estimate the activation scales. +It's best to use calibration data that closely matches your deployment data. +For a general-purpose instruction-tuned model, you can use a dataset like `ultrachat`: + +```python +from datasets import load_dataset + +NUM_CALIBRATION_SAMPLES = 512 +MAX_SEQUENCE_LENGTH = 2048 + +# Load and preprocess the dataset +ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft") +ds = ds.shuffle(seed=42).select(range(NUM_CALIBRATION_SAMPLES)) + +def preprocess(example): + return {"text": tokenizer.apply_chat_template(example["messages"], tokenize=False)} +ds = ds.map(preprocess) + +def tokenize(sample): + return tokenizer(sample["text"], padding=False, max_length=MAX_SEQUENCE_LENGTH, truncation=True, add_special_tokens=False) +ds = ds.map(tokenize, remove_columns=ds.column_names) +``` + +### 3. Applying Quantization + +Now, apply the quantization algorithms. + +The following recipes create W4A8 models (int4 weights, int8 activations). On Arm® CPUs, this is accelerated through [KleidiAI](https://github.com/ARM-software/kleidiai). + +Use groupwise for best accuracy, and channelwise for best inference performance. + +=== "Groupwise" + + ```python + from llmcompressor import oneshot + from llmcompressor.modifiers.quantization import GPTQModifier + + # Configure the quantization algorithms + recipe = [ + GPTQModifier( + targets="Linear", + scheme="W4A8", + ignore=["lm_head"], + dampening_frac=0.01 + ), + ] + + # Apply quantization + oneshot( + model=model, + dataset=ds, + recipe=recipe, + max_seq_length=MAX_SEQUENCE_LENGTH, + num_calibration_samples=NUM_CALIBRATION_SAMPLES, + ) + + # Save the compressed model: Meta-Llama-3-8B-Instruct-W4A8-G128-Dynamic-Per-Token + SAVE_DIR = MODEL_ID.split("/")[1] + "-W4A8-G128-Dynamic-Per-Token" + model.save_pretrained(SAVE_DIR, save_compressed=True) + tokenizer.save_pretrained(SAVE_DIR) + ``` + +=== "Channelwise" + + ```python + from llmcompressor import oneshot + from llmcompressor.modifiers.quantization import GPTQModifier + from compressed_tensors.quantization import QuantizationStrategy, QuantizationType + + scheme = { + "targets": ["Linear"], + "weights": { + "num_bits": 4, + "type": QuantizationType.INT, + "strategy": QuantizationStrategy.CHANNEL, + "symmetric": True, + "dynamic": False, + "group_size": None, + }, + "input_activations": { + "num_bits": 8, + "type": QuantizationType.INT, + "strategy": QuantizationStrategy.TOKEN, + "dynamic": True, + "symmetric": False, + "observer": None, + }, + "output_activations": None, + } + + recipe = [ + GPTQModifier( + targets="Linear", + config_groups={"group_0": scheme}, + ignore=["lm_head"], + dampening_frac=0.01, + ), + ] + + oneshot( + model=model, + dataset=ds, + recipe=recipe, + max_seq_length=MAX_SEQUENCE_LENGTH, + num_calibration_samples=NUM_CALIBRATION_SAMPLES, + ) + + # Save the compressed model: Meta-Llama-3-8B-Instruct-W4A8-Channelwise-Dynamic-Per-Token + SAVE_DIR = MODEL_ID.split("/")[1] + "-W4A8-Channelwise-Dynamic-Per-Token" + model.save_pretrained(SAVE_DIR, save_compressed=True) + tokenizer.save_pretrained(SAVE_DIR) + ``` + +### 4. Evaluating Accuracy + +=== "Groupwise" + + After quantization, you can load and run the model in vLLM: + + ```python + from vllm import LLM + + llm = LLM("./Meta-Llama-3-8B-Instruct-W4A8-G128-Dynamic-Per-Token") + ``` + + To evaluate accuracy, you can use `lm_eval`: + + ```bash + lm_eval --model vllm \ + --model_args pretrained="./Meta-Llama-3-8B-Instruct-W4A8-G128-Dynamic-Per-Token",add_bos_token=true \ + --tasks gsm8k \ + --num_fewshot 5 \ + --limit 250 \ + --batch_size 'auto' + ``` + +=== "Channelwise" + + After quantization, you can load and run the model in vLLM: + + ```python + from vllm import LLM + + llm = LLM("./Meta-Llama-3-8B-Instruct-W4A8-Channelwise-Dynamic-Per-Token") + ``` + + To evaluate accuracy, you can use `lm_eval`: + + ```bash + lm_eval --model vllm \ + --model_args pretrained="./Meta-Llama-3-8B-Instruct-W4A8-Channelwise-Dynamic-Per-Token",add_bos_token=true \ + --tasks gsm8k \ + --num_fewshot 5 \ + --limit 250 \ + --batch_size 'auto' + ``` + +!!! note + Quantized models can be sensitive to the presence of the `bos` token. Make sure to include the `add_bos_token=True` argument when running evaluations. + +## Best Practices + +- Start with 512 samples for calibration data (increase if accuracy drops) +- Use a sequence length of 2048 as a starting point +- Employ the chat template or instruction template that the model was trained with +- If you've fine-tuned a model, consider using a sample of your training data for calibration + +## Troubleshooting and Support + +If you encounter any issues or have feature requests, please open an issue on the [vllm-project/llm-compressor](https://github.com/vllm-project/llm-compressor/issues) GitHub repository. diff --git a/docs/features/quantization/int8.md b/docs/features/quantization/llm_compressor/int8_w8a8.md similarity index 66% rename from docs/features/quantization/int8.md rename to docs/features/quantization/llm_compressor/int8_w8a8.md index 547eb5aedc21..64bce832c183 100644 --- a/docs/features/quantization/int8.md +++ b/docs/features/quantization/llm_compressor/int8_w8a8.md @@ -17,15 +17,17 @@ Please visit the HF collection of [quantized INT8 checkpoints of popular LLMs re To use INT8 quantization with vLLM, you'll need to install the [llm-compressor](https://github.com/vllm-project/llm-compressor/) library: ```bash -pip install llmcompressor +(venv-llm-compressor) pip install llmcompressor ``` Additionally, install `vllm` and `lm-evaluation-harness` for evaluation: ```bash -pip install vllm "lm-eval[api]>=0.4.12" +(venv-vllm) pip install vllm "lm-eval[api]>=0.4.12" ``` +Please use separate environments for vLLM and llm-compressor as they might not work together. + ## Quantization Process The quantization process involves four main steps: @@ -57,60 +59,54 @@ When quantizing activations to INT8, you need sample data to estimate the activa It's best to use calibration data that closely matches your deployment data. For a general-purpose instruction-tuned model, you can use a dataset like `ultrachat`: -??? code - - ```python - from datasets import load_dataset - - NUM_CALIBRATION_SAMPLES = 512 - MAX_SEQUENCE_LENGTH = 2048 +```python +from datasets import load_dataset - # Load and preprocess the dataset - ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft") - ds = ds.shuffle(seed=42).select(range(NUM_CALIBRATION_SAMPLES)) +NUM_CALIBRATION_SAMPLES = 512 +MAX_SEQUENCE_LENGTH = 2048 - def preprocess(example): - return {"text": tokenizer.apply_chat_template(example["messages"], tokenize=False)} - ds = ds.map(preprocess) +# Load and preprocess the dataset +ds = load_dataset("HuggingFaceH4/ultrachat_200k", split="train_sft") +ds = ds.shuffle(seed=42).select(range(NUM_CALIBRATION_SAMPLES)) - def tokenize(sample): - return tokenizer(sample["text"], padding=False, max_length=MAX_SEQUENCE_LENGTH, truncation=True, add_special_tokens=False) - ds = ds.map(tokenize, remove_columns=ds.column_names) - ``` +def preprocess(example): + return {"text": tokenizer.apply_chat_template(example["messages"], tokenize=False)} +ds = ds.map(preprocess) - +def tokenize(sample): + return tokenizer(sample["text"], padding=False, max_length=MAX_SEQUENCE_LENGTH, truncation=True, add_special_tokens=False) +ds = ds.map(tokenize, remove_columns=ds.column_names) +``` ### 3. Applying Quantization Now, apply the quantization algorithms: -??? code - - ```python - from llmcompressor import oneshot - from llmcompressor.modifiers.quantization import GPTQModifier - from llmcompressor.modifiers.smoothquant import SmoothQuantModifier - - # Configure the quantization algorithms - recipe = [ - SmoothQuantModifier(smoothing_strength=0.8), - GPTQModifier(targets="Linear", scheme="W8A8", ignore=["lm_head"]), - ] - - # Apply quantization - oneshot( - model=model, - dataset=ds, - recipe=recipe, - max_seq_length=MAX_SEQUENCE_LENGTH, - num_calibration_samples=NUM_CALIBRATION_SAMPLES, - ) - - # Save the compressed model: Meta-Llama-3-8B-Instruct-W8A8-Dynamic-Per-Token - SAVE_DIR = MODEL_ID.split("/")[1] + "-W8A8-Dynamic-Per-Token" - model.save_pretrained(SAVE_DIR, save_compressed=True) - tokenizer.save_pretrained(SAVE_DIR) - ``` +```python +from llmcompressor import oneshot +from llmcompressor.modifiers.quantization import GPTQModifier +from llmcompressor.modifiers.smoothquant import SmoothQuantModifier + +# Configure the quantization algorithms +recipe = [ + SmoothQuantModifier(smoothing_strength=0.8), + GPTQModifier(targets="Linear", scheme="W8A8", ignore=["lm_head"]), +] + +# Apply quantization +oneshot( + model=model, + dataset=ds, + recipe=recipe, + max_seq_length=MAX_SEQUENCE_LENGTH, + num_calibration_samples=NUM_CALIBRATION_SAMPLES, +) + +# Save the compressed model: Meta-Llama-3-8B-Instruct-W8A8-Dynamic-Per-Token +SAVE_DIR = MODEL_ID.split("/")[1] + "-W8A8-Dynamic-Per-Token" +model.save_pretrained(SAVE_DIR, save_compressed=True) +tokenizer.save_pretrained(SAVE_DIR) +``` This process creates a W8A8 model with weights and activations quantized to 8-bit integers. diff --git a/docs/features/quantization/online.md b/docs/features/quantization/online.md index 4b9571b38b98..a4da52557afb 100644 --- a/docs/features/quantization/online.md +++ b/docs/features/quantization/online.md @@ -62,6 +62,8 @@ weight name. Unset fields fall back to the `--quantization` shorthand's defaults, or for already-quantized checkpoints to whatever the checkpoint declares. +On XPU, non-block FP8 scaled-mm linear layers default to W8A16; setting `--linear-backend xpu` forces W8A8. Use `--linear-backend xpu_woq` to explicitly select weight-only quantization (W8A16). + The CLI accepts the same shape as JSON or as dotted keys: ```bash diff --git a/docs/features/quantization/quantized_kvcache.md b/docs/features/quantization/quantized_kvcache.md index 2c5bfd643946..50b1c5c2df30 100644 --- a/docs/features/quantization/quantized_kvcache.md +++ b/docs/features/quantization/quantized_kvcache.md @@ -49,6 +49,32 @@ You can configure how the quantization scales are computed in vLLM using three d - `kv_cache_dtype="fp8_e4m3"`: Supported on CUDA 11.8+ and ROCm (AMD GPUs) - `kv_cache_dtype="fp8_e5m2"`: Supported on CUDA 11.8+ +### Skipping Specific Layers from KV-Cache Quantization + +Some attention layer types (e.g. sliding-window) are more sensitive to KV-cache quantization. The `--kv-cache-dtype-skip-layers` flag leaves the specified layers at the model's native dtype while keeping the rest of the layers under the chosen quantized dtype. The flag accepts either layer indices or layer-type names: + +```bash +# Skip every sliding-window attention layer. +vllm serve \ + --kv-cache-dtype fp8 \ + --kv-cache-dtype-skip-layers sliding_window + +# Skip specific layer indices. +vllm serve \ + --kv-cache-dtype fp8 \ + --kv-cache-dtype-skip-layers 0 1 23 +``` + +Programmatic usage: + +```python +llm = LLM( + model="meta-llama/Llama-3.1-8B-Instruct", + kv_cache_dtype="fp8", + kv_cache_dtype_skip_layers=["sliding_window"], +) +``` + --- ## Examples diff --git a/docs/features/reasoning_outputs.md b/docs/features/reasoning_outputs.md index 92563a8b4bbd..50a58b8b3e37 100644 --- a/docs/features/reasoning_outputs.md +++ b/docs/features/reasoning_outputs.md @@ -439,7 +439,7 @@ Additionally, to enable structured output, you'll need to create a new `Reasoner end_token: str = "" @classmethod - def from_tokenizer(cls, tokenizer: PreTrainedTokenizer) -> Reasoner: + def from_tokenizer(cls, tokenizer: PythonBackend) -> Reasoner: return cls( start_token_id=tokenizer.encode("", add_special_tokens=False)[0], end_token_id=tokenizer.encode("", add_special_tokens=False)[0], diff --git a/docs/features/speculative_decoding/README.md b/docs/features/speculative_decoding/README.md index 768e9f78d401..ceb25dbfd02f 100644 --- a/docs/features/speculative_decoding/README.md +++ b/docs/features/speculative_decoding/README.md @@ -17,6 +17,7 @@ vLLM supports a variety of methods of speculative decoding. Model-based methods - [Suffix Decoding](suffix.md) - [Hidden State Extraction](extract_hidden_states.md) - [Custom Proposer Backend (Experimental)](#custom-proposer-backend-experimental) +- [Dynamic Speculative Decoding](dynamic_speculative_decoding.md) ## Method Selection at a Glance @@ -33,6 +34,7 @@ depend on your model family, traffic pattern, hardware, and sampling settings. | N-gram | Low to medium gain | Medium gain | Lightweight and easy to enable. | | Suffix decoding | Low to medium gain | Medium gain | No extra draft model; dynamic speculation depth. | | Custom Proposer | Varies | Varies | Bring your own proposer class (experimental). | +| Dynamic Speculative Decoding | High gain | Higher than base SD method | Useful for RL or workload with fluctuating QPS | For reproducible measurements in your environment, use [`examples/features/speculative_decoding/spec_decode_offline.py`](../../../examples/features/speculative_decoding/spec_decode_offline.py) @@ -84,6 +86,7 @@ only apply to model-based methods such as `draft_model`, `mtp`, `eagle3`, and | `parallel_drafting` | `boolean` | `false` | Enable parallel draft token generation. Only compatible with EAGLE and draft-model methods. | | `rejection_sample_method` | `string` | `strict` | `strict`, `probabilistic`, or `synthetic`. | | `synthetic_acceptance_rate` | `float` | `None` | Average acceptance rate to target when `rejection_sample_method` is `synthetic`. Valid range is `[0, 1]`. | + | `use_heterogeneous_vocab` | `boolean` | `false` | Allow draft and target models with different vocabularies. Builds a token-level intersection at initialisation and constrains draft logits to shared tokens only. Only compatible with `method=draft_model`. Probabilistic draft sampling (`draft_sample_method='probabilistic'`) is not yet supported when this option is enabled. | !!! note Gemma 4 assistant checkpoints are handled as Gemma 4 MTP speculators, not @@ -140,6 +143,33 @@ vllm serve \ }' ``` +#### Cross-Vocabulary Draft Models (TLI) + + By default, vLLM requires the draft and target models to share the same + vocabulary. Setting `use_heterogeneous_vocab: true` enables the + **Token-Level Intersection (TLI)** algorithm, which allows draft models + from a different model family with a different tokenizer. + + At initialisation, vLLM builds a mapping between the two vocabularies by + normalising token strings and computing their intersection. Draft logits are + constrained to the shared tokens before sampling, and the sampled token IDs + are translated to the target vocabulary before rejection sampling. + + ```python + from vllm import LLM, SamplingParams + + llm = LLM( + model="Qwen/Qwen3-8B", + speculative_config={ + "method": "draft_model", + "model": "HuggingFaceTB/SmolLM2-135M-Instruct", + "num_speculative_tokens": 3, + "use_heterogeneous_vocab": True, + }, + gpu_memory_utilization=0.5, + ) +``` + ### Notes - `--speculative-config` expects a JSON object on the CLI. In YAML config @@ -151,6 +181,7 @@ vllm serve \ - Internal fields such as `target_model_config`, `draft_model_config`, `target_parallel_config`, `draft_parallel_config`, and `draft_load_config` are populated by vLLM and are not intended to be set by users. +- `use_heterogeneous_vocab` currently supports greedy draft sampling only. Probabilistic acceptance (temperature > 0 draft sampling) is not yet supported and will be added in a future release. ## Lossless guarantees of Speculative Decoding @@ -169,7 +200,7 @@ speculative decoding, breaking down the guarantees into three key areas: > distribution. [View Test Code](https://github.com/vllm-project/vllm/blob/47b65a550866c7ffbd076ecb74106714838ce7da/tests/samplers/test_rejection_sampler.py#L252) > - **Greedy Sampling Equality**: Confirms that greedy sampling with speculative decoding matches greedy sampling > without it. This verifies that vLLM's speculative decoding framework, when integrated with the vLLM forward pass and the vLLM rejection sampler, - > provides a lossless guarantee. Almost all of the tests in [tests/spec_decode/e2e](/tests/v1/spec_decode). + > provides a lossless guarantee. Almost all of the tests in [tests/spec_decode/e2e](../../../tests/v1/spec_decode). > verify this property using [this assertion implementation](https://github.com/vllm-project/vllm/blob/b67ae00cdbbe1a58ffc8ff170f0c8d79044a684a/tests/spec_decode/e2e/conftest.py#L291) 3. **vLLM Logprob Stability** @@ -188,7 +219,7 @@ For mitigation strategies, please refer to the FAQ entry *Can the output of a pr ## Known Feature Incompatibility -1. Pipeline parallelism is not composible with speculative decoding as of `vllm<=0.15.0` +1. Pipeline parallelism is not composable with speculative decoding as of `vllm<=0.15.0` 2. Speculative decoding with a draft models is not supported in `vllm<=0.10.0` ## Resources for vLLM contributors diff --git a/docs/features/speculative_decoding/draft_model.md b/docs/features/speculative_decoding/draft_model.md index b4662e6438f9..636c797324cb 100644 --- a/docs/features/speculative_decoding/draft_model.md +++ b/docs/features/speculative_decoding/draft_model.md @@ -76,6 +76,34 @@ The code used to request as completions as a client remains unchanged: print(completion) ``` +## Draft Model Method with heterogeneous vocabs + + By default, vLLM requires the draft and target models to share the same vocabulary. Setting `use_heterogeneous_vocab: true` enables the **Token-Level Intersection (TLI)** algorithm, which allows draft models from a different model family with a different tokenizer. + + Currently,`use_heterogeneous_vocab` currently requires `draft_sample_method='greedy'` (the default). Probabilistic draft sampling is not yet supported and will be added in a + future release. + + ```python + from vllm import LLM, SamplingParams + + llm = LLM( + model="Qwen/Qwen3-8B", + speculative_config={ + "method": "draft_model", + "model": "HuggingFaceTB/SmolLM2-135M-Instruct", + "num_speculative_tokens": 3, + "use_heterogeneous_vocab": True, + }, + gpu_memory_utilization=0.5, + ) +outputs = llm.generate(prompts,sampling_params) + +for output in outputs: + prompt = output.prompt + generated_text = output.outputs[0].text + print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}") +``` + !!! warning Note: Please use `--speculative-config` to set all configurations related to speculative decoding. The previous method of specifying the model diff --git a/docs/features/speculative_decoding/dynamic_speculative_decoding.md b/docs/features/speculative_decoding/dynamic_speculative_decoding.md new file mode 100644 index 000000000000..682eaafd29d8 --- /dev/null +++ b/docs/features/speculative_decoding/dynamic_speculative_decoding.md @@ -0,0 +1,76 @@ +# Dynamic Speculative Decoding + +## Why is Dynamic SD needed? + +SD methods need to verify K tokens for each sequence during decoding. As BS increases, the effective BS becomes BS\*K which increases the compute requirement during verification. When this BS\*K goes beyond a critical BS then SD negatively impacts the decode speed (TPOT). DSD helps by tuning the K to an optimal value such that we continue to reap the benefits from SD. + +## Use cases + +* Variable concurrency workload using same deployment. K would decrease as concurrency increases. +* During RL rollout where we start off with high BS but then end up with small BS due to very few long tail request which end up generating a lot of tokens stalling the progress of the current rollout. Here K would go up during the end of rollout. + +## `--speculative-config` schema + +To use Dynamic SD, add `num_speculative_tokens_per_batch_size` to the config of an SD method which is a list of list. Here, an entry is `[start_bs, end_bs, optimal_K]` which means when the concurrency is within range `[start_bs, end_bs]` then `optimal_K` number of draft tokens are used. For e.g., + +```bash +--speculative-config '{ + "method": "eagle", + "model": "yuhuili/EAGLE-LLaMA3.1-Instruct-8B", + "num_speculative_tokens": 3, + "num_speculative_tokens_per_batch_size": [ + [1, 64, 3], + [65, 128, 1], + [129, 512, 0] + ] + }' +``` + +implies that: + +* K=3 will be used when the concurrency is in range [1, 64] +* K=1 will be used when the concurrency is in range [65, 128] +* K=0 will be used when the concurrency is in range [129, 512], i.e., no draft tokens will be produced. + +## Online Examples + +### Dynamic SD Eagle Drafter + +```bash +VLLM_USE_V2_MODEL_RUNNER=0 vllm serve meta-llama/Llama-3.1-8B-Instruct \ + --speculative-config '{ + "method": "eagle", + "model": "yuhuili/EAGLE-LLaMA3.1-Instruct-8B", + "num_speculative_tokens": 3, + "num_speculative_tokens_per_batch_size": [ + [1, 64, 3], + [65, 128, 1], + [129, 512, 0] + ] + }' +``` + +### Dynamic SD Eagle3 Drafter + +```bash +VLLM_USE_V2_MODEL_RUNNER=0 vllm serve meta-llama/Llama-3.1-8B-Instruct \ + --speculative-config '{ + "method": "eagle3", + "model": "yuhuili/EAGLE3-LLaMA3.1-Instruct-8B", + "num_speculative_tokens": 3, + "num_speculative_tokens_per_batch_size": [ + [1, 16, 5], + [17, 32, 4], + [33, 64, 3], + [65, 128, 1], + [129, 512, 0] + ] + }' + +``` + +## Limitations + +* Tested with Eagle, Eagle-3, and DFlash. Other SD methods may or may not work out of the box +* Full Cudagraph only works with Model Runner V2. MRv1 only supports piece-wise cuda graph with this feature +* Not compatible with data parallelism (`--data-parallel-size > 1`). Each DP rank schedules independently, so ranks can pick different K values, causing DP collective divergence and deadlocks. When DP is enabled, vLLM automatically disables `num_speculative_tokens_per_batch_size` and falls back to the static `num_speculative_tokens` value. diff --git a/docs/features/speculative_decoding/extract_hidden_states.md b/docs/features/speculative_decoding/extract_hidden_states.md index 2184a71f489f..b7df376d9ffa 100644 --- a/docs/features/speculative_decoding/extract_hidden_states.md +++ b/docs/features/speculative_decoding/extract_hidden_states.md @@ -19,7 +19,6 @@ from vllm.distributed.kv_transfer.kv_connector.v1 import ( with tempfile.TemporaryDirectory() as tmpdir: llm = LLM( model="Qwen/Qwen3-8B", - enable_chunked_prefill=False, speculative_config={ "method": "extract_hidden_states", "num_speculative_tokens": 1, @@ -59,17 +58,58 @@ For improved performance, it is recommended to use a RAM-mounted file system suc ```bash vllm serve Qwen/Qwen3-8B \ --speculative_config '{"method": "extract_hidden_states", "num_speculative_tokens": 1, "draft_model_config": {"hf_config": {"eagle_aux_hidden_state_layer_ids": [1, 2, 3, 4]}}}' \ - --kv_transfer_config '{"kv_connector": "ExampleHiddenStatesConnector", "kv_role": "kv_producer", "kv_connector_extra_config": {"shared_storage_path": "/dev/shm/hidden_states"}}' \ - --no-enable-chunked-prefill + --kv_transfer_config '{"kv_connector": "ExampleHiddenStatesConnector", "kv_role": "kv_producer", "kv_connector_extra_config": {"shared_storage_path": "/dev/shm/hidden_states"}}' +``` + +## Per-Request Options + +Both offline and online modes support per-request options via `kv_transfer_params`: + +| Parameter | Default | Description | +| --- | --- | --- | +| `hidden_states_path` | Auto-generated | Custom file path for saving hidden states. If not set, files are saved to `/.safetensors`. Requires `allow_custom_save_path` to be enabled in the server config. | +| `include_output_tokens` | `False` | When `True`, save hidden states for both prompt and generated output tokens. When `False`, only prompt token hidden states are saved. | + +### Offline usage + +Pass per-request options via `extra_args` on `SamplingParams`: + +```python +SamplingParams( + max_tokens=32, + extra_args={ + "kv_transfer_params": { + "hidden_states_path": "/tmp/my_output.safetensors", + "include_output_tokens": True, + } + }, +) +``` + +### Online usage + +Pass `kv_transfer_params` as a top-level field in the API request: + +```json +{ + "model": "Qwen/Qwen3-8B", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 32, + "kv_transfer_params": { + "hidden_states_path": "/tmp/my_output.safetensors", + "include_output_tokens": true + } +} ``` ## Configuration -The `kv_connector_extra_config` dict accepts these options: +The `kv_connector_extra_config` dict accepts these server-level options: | Parameter | Default | Description | | --- | --- | --- | -| `shared_storage_path` | `/tmp` | Directory where hidden state files are saved | +| `shared_storage_path` | `/tmp` | Directory where hidden state files are saved (used when `hidden_states_path` is not set per-request) | +| `allow_custom_save_path` | `False` | Allow API clients to specify custom file paths via `hidden_states_path`. When disabled, client-provided paths are ignored with a warning. Enable only with trusted clients — custom paths can write to arbitrary locations on the server. | | `num_writer_threads` | `8` | Thread pool size for async disk writes | | `use_synchronization_lock` | `True` | Use file locks so concurrent readers block until writes complete. Can be disabled for batch generation where synchronization is not needed. | diff --git a/docs/features/tool_calling.md b/docs/features/tool_calling.md index 95092734f3d4..ae65231919a3 100644 --- a/docs/features/tool_calling.md +++ b/docs/features/tool_calling.md @@ -109,24 +109,30 @@ vLLM supports the `tool_choice='none'` option in the chat completion API. When t ## Constrained Decoding Behavior -Whether vLLM enforces the tool parameter schema during generation depends on the `tool_choice` mode: +Whether vLLM enforces the tool parameter schema during generation depends on the `tool_choice` mode and the per-tool `strict` field: | `tool_choice` value | Schema-constrained decoding | Behavior | | --- | --- | --- | | Named function | Yes (via structured outputs backend) | Arguments are guaranteed to be valid JSON conforming to the function's parameter schema. | | `"required"` | Yes (via structured outputs backend) | Same as named function. The model must produce at least one tool call. | -| `"auto"` | No | The model generates freely. A tool-call parser extracts tool calls from the raw text. Arguments may be malformed or not match the schema. | +| `"auto"` | Only when `strict: true` is set on at least one tool | Structural-tag parsers constrain tool-call arguments when a tool opts in with `strict: true`. Without it, the model generates freely and tool calls are extracted from raw text. | | `"none"` | N/A | No tool calls are produced. | -When schema conformance matters, prefer `tool_choice="required"` or named function calling over `"auto"`. +### Strict Mode -### Strict Mode (`strict` parameter) +For `tool_choice="required"` or named function calling, structural-tag constraints are always applied regardless of the `strict` field. For `tool_choice="auto"`, setting `strict: true` on at least one tool opts in to structural-tag constraints; without it, the model generates freely and tool calls are extracted from raw text. The `strict` field is supported across all three API surfaces: Chat Completion, Responses, and Anthropic Messages. -The [OpenAI API](https://platform.openai.com/docs/guides/function-calling#strict-mode) supports a `strict` field on function definitions. When set to `true`, OpenAI uses constrained decoding to guarantee that tool-call arguments match the function schema, even in `tool_choice="auto"` mode. +For best compatibility with strict schema enforcement, define tool parameter schemas in the OpenAI strict-schema style: -vLLM **does not implement** `strict` mode today. The `strict` field is accepted in requests (to avoid breaking clients that set it), but it has no effect on decoding behavior. In auto mode, argument validity depends entirely on the model's output quality and the parser's extraction logic. +* Set `additionalProperties` to `false` for each object in `parameters`. +* Mark all fields in `properties` as required. +* Represent optional fields by allowing `null`, for example `{"type": ["string", "null"]}`. -Tracking issues: [#15526](https://github.com/vllm-project/vllm/issues/15526), [#16313](https://github.com/vllm-project/vllm/issues/16313). +vLLM also provides a global toggle via the `VLLM_ENFORCE_STRICT_TOOL_CALLING` environment variable (defaults to `true`). When set to `false`, vLLM does not attach structural tags for tool calling regardless of the per-tool `strict` field. This environment variable only affects structural-tag based tool calling; it does not change schema-derived structured outputs used by named function calling or `tool_choice="required"`. + +```bash +VLLM_ENFORCE_STRICT_TOOL_CALLING=false vllm serve ... +``` ## Automatic Function Calling @@ -146,7 +152,7 @@ from HuggingFace; and you can find an example of this in a `tokenizer_config.jso If your favorite tool-calling model is not supported, please feel free to contribute a parser & tool use chat template! !!! note - With `tool_choice="auto"`, tool-call arguments are extracted from the model's raw text output by the selected parser. No schema-level constraint is applied during decoding, so arguments may occasionally be malformed or violate the function's parameter schema. See [Constrained Decoding Behavior](#constrained-decoding-behavior) for details. + With `tool_choice="auto"`, schema-level constraint requires both `VLLM_ENFORCE_STRICT_TOOL_CALLING=true` (the default) and at least one tool with `strict: true`. When these conditions are met and the selected parser supports structural tags, vLLM constrains tool-call arguments. Otherwise, vLLM extracts tool calls from raw text, so arguments may occasionally be malformed or violate the function's parameter schema. ### Hermes Models (`hermes`) @@ -315,15 +321,6 @@ For Qwen2.5, the chat template in tokenizer_config.json has already included sup Flags: `--tool-call-parser hermes` -### MiniMax Models (`minimax_m1`) - -Supported models: - -* `MiniMaxAi/MiniMax-M1-40k` (use with [examples/tool_chat_template_minimax_m1.jinja](../../examples/tool_chat_template_minimax_m1.jinja)) -* `MiniMaxAi/MiniMax-M1-80k` (use with [examples/tool_chat_template_minimax_m1.jinja](../../examples/tool_chat_template_minimax_m1.jinja)) - -Flags: `--tool-call-parser minimax --chat-template examples/tool_chat_template_minimax_m1.jinja` - ### DeepSeek-V3 Models (`deepseek_v3`) Supported models: @@ -341,7 +338,7 @@ Supported models: Flags: `--tool-call-parser deepseek_v31 --chat-template {see_above}` -### OpenAI OSS Models ('openai`) +### OpenAI OSS Models (`openai`) Supported models: @@ -504,6 +501,13 @@ Flags: `--tool-call-parser pythonic --chat-template {see_above}` !!! warning Llama's smaller models frequently fail to emit tool calls in the correct format. Results may vary depending on the model. +## Benchmarking Tool-Calling Performance + +To measure serving latency and throughput on realistic tool-calling traffic, +use the BFCL (Berkeley Function Calling Leaderboard) dataset with +`vllm bench serve`. See the [BFCL benchmark example](../benchmarking/cli.md#bfcl-tool-calling-benchmark) +for the full server + client commands. + ## How to Write a Tool Parser Plugin A tool parser plugin is a Python file containing one or more ToolParser implementations. You can write a ToolParser similar to the `Hermes2ProToolParser` in [vllm/tool_parsers/hermes_tool_parser.py](../../vllm/tool_parsers/hermes_tool_parser.py). diff --git a/docs/getting_started/installation/cpu.apple.inc.md b/docs/getting_started/installation/cpu.apple.inc.md index e54afc493846..479b6d2c011a 100644 --- a/docs/getting_started/installation/cpu.apple.inc.md +++ b/docs/getting_started/installation/cpu.apple.inc.md @@ -15,6 +15,10 @@ Currently the CPU implementation for macOS supports FP32 and FP16 datatypes. - SDK: `XCode 15.4` or later with Command Line Tools - Compiler: `Apple Clang >= 15.0.0` +!!! note + The macOS CPU build is smoke-tested in CI on the latest GA Apple Silicon + runner; other macOS or Apple Clang versions are best-effort. + --8<-- [end:requirements] --8<-- [start:set-up-using-python] @@ -31,15 +35,10 @@ After installation of XCode and the Command Line Tools, which include Apple Clan ```bash git clone https://github.com/vllm-project/vllm.git cd vllm -uv pip install -r requirements/cpu.txt --index-strategy unsafe-best-match +uv pip install -r requirements/cpu.txt uv pip install -e . ``` -!!! tip - The `--index-strategy unsafe-best-match` flag is needed to resolve dependencies across multiple package indexes (PyTorch CPU index and PyPI). Without this flag, you may encounter `typing-extensions` version conflicts. - - The term "unsafe" refers to the package resolution strategy, not security. By default, `uv` only searches the first index where a package is found to prevent dependency confusion attacks. This flag allows `uv` to search all configured indexes to find the best compatible versions. Since both PyTorch and PyPI are trusted package sources, using this strategy is safe and appropriate for vLLM installation. - !!! note On macOS the `VLLM_TARGET_DEVICE` is automatically set to `cpu`, which is currently the only supported device. diff --git a/docs/getting_started/installation/cpu.arm.inc.md b/docs/getting_started/installation/cpu.arm.inc.md index f01ba429ee03..3950adc0251f 100644 --- a/docs/getting_started/installation/cpu.arm.inc.md +++ b/docs/getting_started/installation/cpu.arm.inc.md @@ -20,12 +20,12 @@ Pre-built vLLM wheels for Arm are available since version 0.11.2. These wheels c ```bash export VLLM_VERSION=$(curl -s https://api.github.com/repos/vllm-project/vllm/releases/latest | jq -r .tag_name | sed 's/^v//') -uv pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_35_aarch64.whl --torch-backend cpu +uv pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_34_aarch64.whl --torch-backend cpu ``` ??? console "pip" ```bash - pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_35_aarch64.whl --extra-index-url https://download.pytorch.org/whl/cpu + pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_34_aarch64.whl --extra-index-url https://download.pytorch.org/whl/cpu ``` !!! warning "set `LD_PRELOAD`" @@ -63,7 +63,7 @@ uv pip install vllm --extra-index-url https://wheels.vllm.ai/nightly/cpu --index If you insist on using `pip`, you have to specify the full URL (link address) of the wheel file (which can be obtained from https://wheels.vllm.ai/nightly/cpu/vllm). ```bash - pip install https://wheels.vllm.ai/4fa7ce46f31cbd97b4651694caf9991cc395a259/vllm-0.13.0rc2.dev104%2Bg4fa7ce46f.cpu-cp38-abi3-manylinux_2_35_aarch64.whl --extra-index-url https://download.pytorch.org/whl/cpu # current nightly build (the filename will change!) + pip install https://wheels.vllm.ai/2f3f441f84bd5b35ec8aa9fcfffb540f107da8a7/vllm-0.23.1rc1.dev901%2Bg2f3f441f8.cpu-cp38-abi3-manylinux_2_34_aarch64.whl --extra-index-url https://download.pytorch.org/whl/cpu # current nightly build (the filename will change!) ``` #### Install specific revisions @@ -96,8 +96,8 @@ cd vllm_source Third, install required dependencies: ```bash -uv pip install -r requirements/build/cpu.txt --torch-backend cpu -uv pip install -r requirements/cpu.txt --torch-backend cpu +uv pip install -r requirements/build/cpu.txt --torch-backend cpu --index-strategy unsafe-best-match +uv pip install -r requirements/cpu.txt --torch-backend cpu --index-strategy unsafe-best-match ``` ??? console "pip" diff --git a/docs/getting_started/installation/cpu.md b/docs/getting_started/installation/cpu.md index 7225d1d6c77b..8b3605e8557d 100644 --- a/docs/getting_started/installation/cpu.md +++ b/docs/getting_started/installation/cpu.md @@ -142,6 +142,10 @@ VLLM_USE_PRECOMPILED=1 VLLM_PRECOMPILED_WHEEL_VARIANT=cpu VLLM_TARGET_DEVICE=cpu === "IBM Z (S390X)" --8<-- "docs/getting_started/installation/cpu.s390x.inc.md:build-image-from-source" +## AMD Zen optimizations {#amd-zen-optimizations} + +--8<-- "docs/getting_started/installation/cpu.x86.inc.md:amd-zen-optimizations" + ## Related runtime environment variables - `VLLM_CPU_KVCACHE_SPACE`: specify the KV Cache size (e.g, `VLLM_CPU_KVCACHE_SPACE=40` means 40 GiB space for KV cache), larger setting will allow vLLM to run more requests in parallel. This parameter should be set based on the hardware configuration and memory management pattern of users. Default value is `0`. @@ -149,12 +153,14 @@ VLLM_USE_PRECOMPILED=1 VLLM_PRECOMPILED_WHEEL_VARIANT=cpu VLLM_TARGET_DEVICE=cpu - `VLLM_CPU_NUM_OF_RESERVED_CPU`: specify the number of CPU cores which are not dedicated to the OpenMP threads for each rank. The variable only takes effect when VLLM_CPU_OMP_THREADS_BIND is set to `auto`. Default value is `None`. If the value is not set and use `auto` thread binding, no CPU will be reserved for `world_size == 1`, 1 CPU per rank will be reserved for `world_size > 1`. - `CPU_VISIBLE_MEMORY_NODES`: specify visible NUMA memory nodes for vLLM CPU workers, similar to ```CUDA_VISIBLE_DEVICES```. The variable only takes effect when VLLM_CPU_OMP_THREADS_BIND is set to `auto`. The variable provides more control for the auto thread-binding feature, such as masking nodes and changing nodes binding sequence. - `VLLM_CPU_SGL_KERNEL` (x86 only, Experimental): whether to use small-batch optimized kernels for linear layer and MoE layer, especially for low-latency requirements like online serving. The kernels require AMX instruction set, BFloat16 weight type and weight shapes divisible by 32. Default is `0` (False). +- `VLLM_ZENTORCH_WEIGHT_PREPACK` (AMD Zen only): when `ZenCpuPlatform` is active, eagerly prepack linear weights into ZenDNN's blocked layout at model load time, eliminating per-inference layout conversion overhead. Default is `1` (enabled). See [AMD Zen optimizations](#amd-zen-optimizations). ## FAQ ### Which `dtype` should be used? - Currently, vLLM CPU uses model default settings as `dtype`. However, due to unstable float16 support in torch CPU, it is recommended to explicitly set `dtype=bfloat16` if there are any performance or accuracy problem. +- On AMD Zen CPUs (`ZenCpuPlatform`), `float16` is **not** supported. Only `bfloat16` and `float32` are accepted; models declared with `float16` are auto-downcast to `bfloat16` at model load time. See [AMD Zen optimizations](#amd-zen-optimizations). ### How to launch a vLLM service on CPU? @@ -227,6 +233,25 @@ By providing MODEL_FILTER and DTYPE_FILTER, only commands for related model ID a ON_CPU=1 SERVING_JSON=serving-tests-cpu-text.json DRY_RUN=1 MODEL_FILTER=meta-llama/Llama-3.1-8B-Instruct DTYPE_FILTER=bfloat16 bash .buildkite/performance-benchmarks/scripts/run-performance-benchmarks.sh ``` +### How do I enable AMD Zen optimizations? {#how-do-i-enable-amd-zen-optimizations} + +On an AMD Zen 4 / Zen 5 CPU, install the CPU wheel with the `zen` extra so vLLM pulls the tested `zentorch` version for that release: + +```bash +export VLLM_VERSION=$(curl -s https://api.github.com/repos/vllm-project/vllm/releases/latest | jq -r .tag_name | sed 's/^v//') +uv pip install "vllm[zen]" --extra-index-url https://wheels.vllm.ai/${VLLM_VERSION}/cpu --index-strategy first-index --torch-backend cpu +``` + +vLLM auto-detects the platform and routes linear layers through ZenDNN-optimized kernels - no flag needed. To verify it is engaged, look for the platform-selection line in the server's startup logs: + +```bash +vllm serve Qwen/Qwen3-0.6B 2>&1 | grep "AMD Zen CPU detected with zentorch installed" +``` + +For per-backend dispatch details (which kernel each linear layer was bound to), re-run with `VLLM_LOGGING_LEVEL=DEBUG` and grep for `CPU unquantized GEMM dispatch`. + +See [AMD Zen optimizations](#amd-zen-optimizations) for detection rules, supported dtypes, and the `VLLM_ZENTORCH_WEIGHT_PREPACK` knob. + ### How to decide `VLLM_CPU_OMP_THREADS_BIND`? - Default `auto` thread-binding is recommended for most cases. Ideally, each OpenMP thread will be bound to a dedicated physical core respectively, threads of each rank will be bound to the same NUMA node respectively, and 1 CPU per rank will be reserved for other vLLM components when `world_size > 1`. If you have any performance problems or unexpected binding behaviours, please try to bind threads as following. diff --git a/docs/getting_started/installation/cpu.s390x.inc.md b/docs/getting_started/installation/cpu.s390x.inc.md index 1e36b4317647..15baa487c2a0 100644 --- a/docs/getting_started/installation/cpu.s390x.inc.md +++ b/docs/getting_started/installation/cpu.s390x.inc.md @@ -48,10 +48,10 @@ Execute the following commands to build and install vLLM from source. ```bash uv pip install -v \ - --extra-index-url https://download.pytorch.org/whl/cpu \ - --torch-backend auto \ -r requirements/build/cpu.txt \ -r requirements/cpu.txt \ + --torch-backend cpu \ + --index-strategy unsafe-best-match && \ VLLM_TARGET_DEVICE=cpu python setup.py bdist_wheel && \ uv pip install dist/*.whl ``` diff --git a/docs/getting_started/installation/cpu.x86.inc.md b/docs/getting_started/installation/cpu.x86.inc.md index ad051d22dc8c..6ded3b508321 100644 --- a/docs/getting_started/installation/cpu.x86.inc.md +++ b/docs/getting_started/installation/cpu.x86.inc.md @@ -1,4 +1,4 @@ - + --8<-- [start:installation] vLLM supports basic model inferencing and serving on x86 CPU platform, with data types FP32, FP16 and BF16. @@ -24,13 +24,13 @@ Pre-built vLLM wheels for x86 with AVX512/AVX2 are available since version 0.17. export VLLM_VERSION=$(curl -s https://api.github.com/repos/vllm-project/vllm/releases/latest | jq -r .tag_name | sed 's/^v//') # use uv -uv pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_35_x86_64.whl --torch-backend cpu +uv pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_34_x86_64.whl --torch-backend cpu ``` ??? console "pip" ```bash # use pip - pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_35_x86_64.whl --extra-index-url https://download.pytorch.org/whl/cpu + pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cpu-cp38-abi3-manylinux_2_34_x86_64.whl --extra-index-url https://download.pytorch.org/whl/cpu ``` !!! warning "set `LD_PRELOAD`" Before use vLLM CPU installed via wheels, make sure TCMalloc and Intel OpenMP are installed and added to `LD_PRELOAD`: @@ -88,8 +88,8 @@ cd vllm_source Install the required dependencies: ```bash -uv pip install -r requirements/build/cpu.txt --torch-backend cpu -uv pip install -r requirements/cpu.txt --torch-backend cpu +uv pip install -r requirements/build/cpu.txt --torch-backend cpu --index-strategy unsafe-best-match +uv pip install -r requirements/cpu.txt --torch-backend cpu --index-strategy unsafe-best-match ``` ??? console "pip" @@ -200,7 +200,19 @@ docker build -f docker/Dockerfile.cpu \ --target vllm-openai . ``` -#### Launching the OpenAI server +#### Building with AMD Zen optimizations + +For AMD Zen 4 / Zen 5 hosts (`linux/amd64` only), use the `vllm-openai-zen` target. It extends the default `vllm-openai` image and adds `zentorch` via the `vllm[zen]` extra so `ZenCpuPlatform` auto-activates at runtime: + +```bash +docker build -f docker/Dockerfile.cpu \ + --tag vllm-cpu-zen-env \ + --target vllm-openai-zen . +``` + +The resulting image accepts the same arguments and environment variables as `vllm-openai` (see [Launching the OpenAI server](#launching-the-openai-server) below); no extra flag is needed to engage Zen optimizations. See [AMD Zen optimizations](cpu.md#amd-zen-optimizations) for runtime behavior and the supported-dtype caveats. + +#### Launching the OpenAI server {#launching-the-openai-server} ```bash docker run --rm \ @@ -216,5 +228,36 @@ docker run --rm \ ``` --8<-- [end:build-image-from-source] +--8<-- [start:amd-zen-optimizations] + +On AMD Zen CPUs, vLLM auto-selects `ZenCpuPlatform` (a subclass of `CpuPlatform`) which dispatches linear layers through [`zentorch`](https://github.com/amd/ZenDNN-pytorch-plugin)'s ZenDNN-optimized kernels. See the FAQ entry [How do I enable AMD Zen optimizations?](#how-do-i-enable-amd-zen-optimizations) for the install command. + +### Detection rules + +`ZenCpuPlatform` is selected when **all** of the following hold: + +- vLLM is built for CPU +- `/proc/cpuinfo` reports `AuthenticAMD` and `avx512` +- `import zentorch` succeeds + +Otherwise, vLLM falls back to the default `CpuPlatform` (oneDNN / sgl-kernel paths). + +### Supported dtypes + +`float16` is **not** supported on `ZenCpuPlatform`. `ZenCpuPlatform.supported_dtypes` advertises only `bfloat16` and `float32`, so models declared with `torch_dtype=float16` are auto-downcast to `bfloat16` at load time with the standard `"Your device 'cpu' doesn't support torch.float16. Falling back to torch.bfloat16 for compatibility."` warning emitted from `vllm/config/model.py`. + +### Environment variables + +- `VLLM_ZENTORCH_WEIGHT_PREPACK` (default `1`): eagerly prepacks linear weights into ZenDNN's blocked layout at model load time, eliminating per-inference layout conversion overhead. Set to `0` to disable. + +### Docker + +The `vllm-openai-zen` Docker target (in `docker/Dockerfile.cpu`) extends the default `vllm-openai` image with `vllm[zen]`. Build it with `docker build -f docker/Dockerfile.cpu --target vllm-openai-zen .` — see [Building with AMD Zen optimizations](#building-with-amd-zen-optimizations) for the full command and run instructions. + +### Reference + +For the design rationale, see [RFC #35089: In-Tree AMD Zen CPU Backend via zentorch](https://github.com/vllm-project/vllm/issues/35089). + +--8<-- [end:amd-zen-optimizations] --8<-- [start:extra-information] --8<-- [end:extra-information] diff --git a/docs/getting_started/installation/gpu.cuda.inc.md b/docs/getting_started/installation/gpu.cuda.inc.md index ec333b3ee1bf..0e86c0e6049c 100644 --- a/docs/getting_started/installation/gpu.cuda.inc.md +++ b/docs/getting_started/installation/gpu.cuda.inc.md @@ -43,7 +43,7 @@ As of now, vLLM's binaries are compiled with CUDA 12.9 and public PyTorch releas export VLLM_VERSION=$(curl -s https://api.github.com/repos/vllm-project/vllm/releases/latest | jq -r .tag_name | sed 's/^v//') export CUDA_VERSION=130 # or other export CPU_ARCH=$(uname -m) # x86_64 or aarch64 -uv pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cu${CUDA_VERSION}-cp38-abi3-manylinux_2_35_${CPU_ARCH}.whl --extra-index-url https://download.pytorch.org/whl/cu${CUDA_VERSION} +uv pip install https://github.com/vllm-project/vllm/releases/download/v${VLLM_VERSION}/vllm-${VLLM_VERSION}+cu${CUDA_VERSION}-cp38-abi3-manylinux_2_28_${CPU_ARCH}.whl --extra-index-url https://download.pytorch.org/whl/cu${CUDA_VERSION} ``` #### Install the latest code @@ -68,8 +68,8 @@ uv pip install -U vllm \ If you insist on using `pip`, you have to specify the full URL of the wheel file (which can be obtained from the web page). ```bash - pip install -U https://wheels.vllm.ai/nightly/vllm-0.11.2.dev399%2Bg3c7461c18-cp38-abi3-manylinux_2_31_x86_64.whl # current nightly build (the filename will change!) - pip install -U https://wheels.vllm.ai/${VLLM_COMMIT}/vllm-0.11.2.dev399%2Bg3c7461c18-cp38-abi3-manylinux_2_31_x86_64.whl # from specific commit + pip install -U https://wheels.vllm.ai/2f3f441f84bd5b35ec8aa9fcfffb540f107da8a7/vllm-0.23.1rc1.dev901%2Bg2f3f441f8-cp38-abi3-manylinux_2_28_x86_64.whl # current nightly build (the filename will change!) + pip install -U https://wheels.vllm.ai/${VLLM_COMMIT}/vllm-0.23.1rc1.dev901%2Bg2f3f441f8-cp38-abi3-manylinux_2_28_x86_64.whl # from specific commit ``` ##### Install specific revisions @@ -139,6 +139,15 @@ You can find more information about vLLM's wheels in [Install the latest code](# #### Full build (with compilation) {#full-build} +!!! note "Compiler requirement" + Building from source requires GCC/G++ ≥ 11.3. PyTorch's C++20 headers are + not compatible with GCC 10 or GCC < 11.3. On Ubuntu 22.04: + ```bash + sudo apt-get install -y gcc-11 g++-11 + sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-11 110 \ + --slave /usr/bin/g++ g++ /usr/bin/g++-11 + ``` + If you want to modify C++ or CUDA code, you'll need to build vLLM from source. This can take several minutes: ```bash diff --git a/docs/getting_started/installation/gpu.rocm.inc.md b/docs/getting_started/installation/gpu.rocm.inc.md index f8385997eea3..59c9723e666b 100644 --- a/docs/getting_started/installation/gpu.rocm.inc.md +++ b/docs/getting_started/installation/gpu.rocm.inc.md @@ -27,6 +27,19 @@ If you need a different ROCm version or want to use an existing PyTorch installa --8<-- [end:set-up-using-python] --8<-- [start:pre-built-wheels] +!!! warning "Python 3.12 required for ROCm wheels" + + ROCm pre-built wheels are only available for **Python 3.12**. If you are using a different Python version (e.g. 3.11 or 3.13), the installer **will silently fall back** to the CUDA wheel from PyPI, which will fail on AMD GPUs with errors like `libcudart.so: cannot open shared object file`. + + To check your Python version: `python3 --version` + + If you need Python 3.12, you can create an isolated environment with `uv`: + + ```bash + uv venv --python 3.12 --seed --managed-python + source .venv/bin/activate + ``` + To install the latest version of vLLM for Python 3.12, ROCm 7.0 and `glibc >= 2.35`. ```bash diff --git a/docs/getting_started/installation/gpu.xpu.inc.md b/docs/getting_started/installation/gpu.xpu.inc.md index f6cd88b97fcd..8564f2a7265b 100644 --- a/docs/getting_started/installation/gpu.xpu.inc.md +++ b/docs/getting_started/installation/gpu.xpu.inc.md @@ -27,6 +27,7 @@ Currently, there are no pre-built XPU wheels. - First, install required [driver](https://dgpu-docs.intel.com/driver/installation.html#installing-gpu-drivers). - Second, install Python packages for vLLM XPU backend building (Intel OneAPI dependencies are installed automatically as part of `torch-xpu`, see [PyTorch XPU get started](https://docs.pytorch.org/docs/stable/notes/get_start_xpu.html)): +- Start from vllm-xpu-kernels v0.1.10, we recommend user upgrade driver to [compute runtime 26.18](https://github.com/intel/compute-runtime/releases/tag/26.14.37833.4) release, to avoid potential compatibility issue. ```bash git clone https://github.com/vllm-project/vllm.git @@ -41,12 +42,12 @@ pip install -v -r requirements/xpu.txt ```bash pip uninstall -y triton triton-xpu - pip install triton-xpu==3.7.0 --extra-index-url https://download.pytorch.org/whl/xpu + pip install triton-xpu==3.7.1 --extra-index-url https://download.pytorch.org/whl/xpu ``` !!! note - `triton` (without suffix) is for NVIDIA GPUs only. On XPU, using it instead of `triton-xpu` can cause correctness or runtime issues. - - For torch 2.11 (the version used in `requirements/xpu.txt`), the matching package is `triton-xpu==3.7.0`. If you use a different version of torch, check the corresponding `triton-xpu` version in [docker/Dockerfile.xpu](https://github.com/vllm-project/vllm/blob/main/docker/Dockerfile.xpu). + - For torch 2.12 (the version used in `requirements/xpu.txt`), the matching package is `triton-xpu==3.7.1`. If you use a different version of torch, check the corresponding `triton-xpu` version in [docker/Dockerfile.xpu](https://github.com/vllm-project/vllm/blob/main/docker/Dockerfile.xpu). - Finally, build and install vLLM XPU backend: diff --git a/docs/mkdocs/hooks/generate_examples.py b/docs/mkdocs/hooks/generate_examples.py index 194db05e395e..07fbd7e4d555 100644 --- a/docs/mkdocs/hooks/generate_examples.py +++ b/docs/mkdocs/hooks/generate_examples.py @@ -32,7 +32,6 @@ def title(text: str) -> str: "mae": "MAE", "ner": "NER", "tpu": "TPU", - "gguf": "GGUF", "lora": "LoRA", "nccl": "NCCL", "rlhf": "RLHF", diff --git a/docs/models/hardware_supported_models/cpu.md b/docs/models/hardware_supported_models/cpu.md index 9c6dd9feb793..ddc519e8f16e 100644 --- a/docs/models/hardware_supported_models/cpu.md +++ b/docs/models/hardware_supported_models/cpu.md @@ -1,5 +1,8 @@ # CPU - Intel® Xeon® +!!! note "AMD Zen CPUs" + On AMD Zen 4 / Zen 5 CPUs, AMD Zen optimizations are auto-enabled when the [`zentorch`](https://github.com/amd/ZenDNN-pytorch-plugin) package is installed. All models supported by vLLM on CPU are supported on AMD Zen as well; model compatibility does not change. This page reflects the current CPU reference validation matrix on Intel systems. See [AMD Zen optimizations](../../getting_started/installation/cpu.md#amd-zen-optimizations) for details. + ## Validated Hardware | Hardware | diff --git a/docs/models/hardware_supported_models/xpu.md b/docs/models/hardware_supported_models/xpu.md index cfda6c76f05b..d065b4b68903 100644 --- a/docs/models/hardware_supported_models/xpu.md +++ b/docs/models/hardware_supported_models/xpu.md @@ -27,14 +27,12 @@ | Qwen/QwQ-32B | QwenForCausalLM | ✅ | | | | deepseek-ai/DeepSeek-V2-Lite | DeepSeekForCausalLM | ✅ | | | | meta-llama/Llama-3.1-8B-Instruct | LlamaForCausalLM | ✅ | | | -| baichuan-inc/Baichuan2-13B-Chat | BaichuanForCausalLM | ✅ | | | | THUDM/GLM-4-9B-chat | GLMForCausalLM | ✅ | | | | THUDM/CodeGeex4-All-9B | CodeGeexForCausalLM | ✅ | | | | chuhac/TeleChat2-35B | LlamaForCausalLM (TeleChat2 based on Llama arch) | ✅ | | | | 01-ai/Yi1.5-34B-Chat | YiForCausalLM | ✅ | | | | THUDM/CodeGeex4-All-9B | CodeGeexForCausalLM | ✅ | | | | deepseek-ai/DeepSeek-Coder-33B-base | DeepSeekCoderForCausalLM | ✅ | | | -| baichuan-inc/Baichuan2-13B-Chat | BaichuanForCausalLM | ✅ | | | | meta-llama/Llama-2-13b-chat-hf | LlamaForCausalLM | ✅ | | | | THUDM/CodeGeex4-All-9B | CodeGeexForCausalLM | ✅ | | | | Qwen/Qwen1.5-14B-Chat | QwenForCausalLM | ✅ | | | diff --git a/docs/models/pooling_models/README.md b/docs/models/pooling_models/README.md index 2a5357e4fee6..f8de9d437ad7 100644 --- a/docs/models/pooling_models/README.md +++ b/docs/models/pooling_models/README.md @@ -2,8 +2,7 @@ !!! note We currently support pooling models primarily for convenience. This is not guaranteed to provide any performance -improvements over using Hugging Face Transformers or Sentence Transformers directly. - + improvements over using Hugging Face Transformers or Sentence Transformers directly. We plan to optimize pooling models in vLLM. Please comment on if you have any suggestions! ## What are pooling models? @@ -63,7 +62,7 @@ please refer to [IO Processor Plugins](../../design/io_processor_plugins.md). !!! note Within classification tasks, there is a specialized subcategory: Cross-encoder (aka reranker) models. These models -are a subset of classification models that accept two prompts as input and output num_labels equal to 1. + are a subset of classification models that accept two prompts as input and output num_labels equal to 1. ### Pooling Types @@ -143,7 +142,7 @@ enabling the corresponding APIs. The [classify][vllm.LLM.classify] method outputs a probability vector for each prompt. It is primarily designed for [classification models](classify.md). -For more information about `LLM.embed`, see [this page](classify.md#offline-inference). +For more information about `LLM.classify`, see [this page](classify.md#offline-inference). ### `LLM.embed` @@ -184,7 +183,7 @@ Our online Server provides endpoints that correspond to the offline APIs: - Corresponding to `LLM.classify`: - [Classification API](classify.md#online-serving)(`/classify`) - Corresponding to `LLM.score`: - - [Score API](scoring.md#score-api)(`/score`) + - [Score API](scoring.md#score-api) (`/score`, `/v1/score`) - [Cohere Rerank API](scoring.md#rerank-api) (`/rerank`, `/v1/rerank`, `/v2/rerank`) - Pooling API (`/pooling`) is similar to `LLM.encode`, being applicable to all types of pooling models. @@ -302,7 +301,7 @@ Pooling models now support token-wise task. ### Score task -`score` task have has been removed in v0.21, use `classify` instead. Only when a classification model outputs num_labels +`score` task has been removed in v0.21, use `classify` instead. Only when a classification model outputs num_labels equal to 1 can it be used as a scoring model and have its scoring API enabled. ### Pooling multitask support diff --git a/docs/models/pooling_models/classify.md b/docs/models/pooling_models/classify.md index 6860b09c31ea..360f9294310d 100644 --- a/docs/models/pooling_models/classify.md +++ b/docs/models/pooling_models/classify.md @@ -31,7 +31,6 @@ The most fundamental application of classification models is to categorize input | Architecture | Models | Example HF Models | [LoRA](../../features/lora.md) | [PP](../../serving/parallelism_scaling.md) | | ------------ | ------ | ----------------- | ------------------------------ | ------------------------------------------ | -| `ErnieForSequenceClassification` | BERT-like Chinese ERNIE | `Forrest20231206/ernie-3.0-base-zh-cls` | | | | `GPT2ForSequenceClassification` | GPT2 | `nie3e/sentiment-polish-gpt2-small` | | | | `Qwen2ForSequenceClassification`C | Qwen2-based | `jason9693/Qwen2.5-1.5B-apeach` | | | | `*Model`C, `*ForCausalLM`C, etc. | Generative models | N/A | \* | \* | diff --git a/docs/models/pooling_models/embed.md b/docs/models/pooling_models/embed.md index 47f85b7440e2..1b9d14d7a0a4 100644 --- a/docs/models/pooling_models/embed.md +++ b/docs/models/pooling_models/embed.md @@ -39,7 +39,6 @@ You can compute pairwise similarity scores to build a similarity matrix using th | ------------ | ------ | ----------------- | ------------------------------ | ------------------------------------------ | | `BertModel` | BERT-based | `BAAI/bge-base-en-v1.5`, `Snowflake/snowflake-arctic-embed-xs`, etc. | | | | `BertSpladeSparseEmbeddingModel` | SPLADE | `naver/splade-v3` | | | -| `ErnieModel` | BERT-like Chinese ERNIE | `shibing624/text2vec-base-chinese-sentence` | | | | `Gemma2Model`C | Gemma 2-based | `BAAI/bge-multilingual-gemma2`, etc. | ✅︎ | ✅︎ | | `Gemma3TextModel`C | Gemma 3-based | `google/embeddinggemma-300m`, etc. | ✅︎ | ✅︎ | | `GritLM` | GritLM | `parasail-ai/GritLM-7B-vllm`. | ✅︎ | ✅︎ | diff --git a/docs/models/pooling_models/reward.md b/docs/models/pooling_models/reward.md index 4acacda50045..6049eb0a5f98 100644 --- a/docs/models/pooling_models/reward.md +++ b/docs/models/pooling_models/reward.md @@ -143,4 +143,4 @@ More examples can be found here: [examples/pooling/reward](../../../examples/poo ### `LLM.reward` -`llm.reward` api is deprecated and will be removed in v0.23. Please use `LLM.encode` with `pooling_task="classify"` or `pooling_task="token_classify"` instead. +`llm.reward` API is deprecated and was removed in v0.24. Please use `LLM.encode` with `pooling_task="classify"` or `pooling_task="token_classify"` instead. diff --git a/docs/models/pooling_models/scoring.md b/docs/models/pooling_models/scoring.md index c8b4c73cfb30..e3b54b020751 100644 --- a/docs/models/pooling_models/scoring.md +++ b/docs/models/pooling_models/scoring.md @@ -19,7 +19,7 @@ The score models is designed to compute similarity scores between two input prom - Offline APIs: - `LLM.score` - Online APIs: - - [Score API](scoring.md#score-api) (`/score`) + - [Score API](scoring.md#score-api) (`/score`, `/v1/score`) - [Cohere Rerank API](scoring.md#rerank-api) (`/rerank`, `/v1/rerank`, `/v2/rerank`) !!! note @@ -157,7 +157,7 @@ A code example can be found here: [examples/basic/offline_inference/score.py](.. ### Score API -Our Score API (`/score`) is similar to `LLM.score`, compute similarity scores between two input prompts. +Our Score API (`/score`, `/v1/score`) is similar to `LLM.score`, compute similarity scores between two input prompts. #### Parameters @@ -440,7 +440,7 @@ More examples can be found here: [examples/pooling/score](../../../examples/pool ## Supported Features -AS cross-encoder models are a subset of classification models that accept two prompts as input and output num_labels equal to 1, cross-encoder features should be consistent with (sequence) classification. For more information, see [this page](classify.md#supported-features). +As cross-encoder models are a subset of classification models that accept two prompts as input and output num_labels equal to 1, cross-encoder features should be consistent with (sequence) classification. For more information, see [this page](classify.md#supported-features). ### Score Template diff --git a/docs/models/pooling_models/token_classify.md b/docs/models/pooling_models/token_classify.md index 5c4798935bfb..6b2cefbde558 100644 --- a/docs/models/pooling_models/token_classify.md +++ b/docs/models/pooling_models/token_classify.md @@ -44,8 +44,8 @@ The BAAI/bge-m3 model leverages token classification for sparse retrieval. For m | Architecture | Models | Example HF Models | [LoRA](../../features/lora.md) | [PP](../../serving/parallelism_scaling.md) | | ------------ | ------ | ----------------- | --------------------------- | --------------------------------------- | | `BertForTokenClassification` | bert-based | `boltuix/NeuroBERT-NER` (see note), etc. | | | -| `ErnieForTokenClassification` | BERT-like Chinese ERNIE | `gyr66/Ernie-3.0-base-chinese-finetuned-ner` | | | | `ModernBertForTokenClassification` | ModernBERT-based | `disham993/electrical-ner-ModernBERT-base` | | | +| `OpenAIPrivacyFilterForTokenClassification` | gpt-oss-based encoder | `openai/privacy-filter` | | | | `Qwen3ForTokenClassification`C | Qwen3-based | `bd2lcco/Qwen3-0.6B-finetuned` | | | | `*Model`C, `*ForCausalLM`C, etc. | Generative models | N/A | \* | \* | diff --git a/docs/models/pooling_models/token_embed.md b/docs/models/pooling_models/token_embed.md index 02050b7f50f0..0c2a322e80fc 100644 --- a/docs/models/pooling_models/token_embed.md +++ b/docs/models/pooling_models/token_embed.md @@ -61,7 +61,7 @@ Models of any architecture can be converted into embedding models using `--conve | `ColModernVBertForRetrieval` | ColModernVBERT | T / I | `ModernVBERT/colmodernvbert-merged` | | | | `ColPaliForRetrieval` | ColPali | T / I | `vidore/colpali-v1.3-hf` | | | | `ColQwen3` | Qwen3-VL | T / I | `TomoroAI/tomoro-colqwen3-embed-4b`, `TomoroAI/tomoro-colqwen3-embed-8b` | | | -| `ColQwen3_5` | ColQwen3.5 | T + I + V | `athrael-soju/colqwen3.5-4.5B-v3` | | | +| `ColQwen3_5` | ColQwen3.5 | T + I + V | `athrael-soju/colqwen3.5-4.5B-v3`, `vultr/VultronRetrieverPrime-Qwen3.5-8B` | | | | `OpsColQwen3Model` | Qwen3-VL | T / I | `OpenSearch-AI/Ops-Colqwen3-4B`, `OpenSearch-AI/Ops-Colqwen3-8B` | | | | `Qwen3VLNemotronEmbedModel` | Qwen3-VL | T / I | `nvidia/nemotron-colembed-vl-4b-v2`, `nvidia/nemotron-colembed-vl-8b-v2` | ✅︎ | ✅︎ | | `*ForConditionalGeneration`C, `*ForCausalLM`C, etc. | Generative models | \* | N/A | \* | \* | diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 19cccdc12f54..f73ffd17c877 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -15,7 +15,7 @@ These models are what we list in [supported text models](#list-of-text-only-lang ### Transformers -vLLM also supports model implementations that are available in Transformers. You should expect the performance of a Transformers model implementation used in vLLM to be within <5% of the performance of a dedicated vLLM model implementation. We call this feature the "Transformers modeling backend". +vLLM also supports model implementations that are available in Transformers. We call this feature the "Transformers modeling backend". The performance of models loaded with the Transformers modeling backend should be identical to a dedicated vLLM model implementation. Currently, the Transformers modeling backend works for the following: @@ -140,7 +140,7 @@ Here is what happens in the background when this model is loaded: That's it! -For your model to be compatible with vLLM's tensor parallel and/or pipeline parallel features, you must add `base_model_tp_plan` and/or `base_model_pp_plan` to your model's config class: +For your model to be compatible with vLLM's tensor parallel and/or pipeline parallel features, you may need to add `base_model_tp_plan` and/or `base_model_pp_plan` to your model's config class:
configuration_my_model.py @@ -168,9 +168,11 @@ class MyConfig(PretrainedConfig):
- `base_model_tp_plan` is a `dict` that maps fully qualified layer name patterns to tensor parallel styles (currently only `"colwise"` and `"rowwise"` are supported). + - vLLM infers the tensor parallel style of standard attention (`q`/`k`/`v`/`o_proj`) and gated-MLP/experts (`gate`/`up`/`down_proj`) projections if it can fuse them, so these may not need to be listed. `base_model_tp_plan` is only _required_ for layers that do not follow these patterns; any linear that is neither fused nor named in the plan is replicated. - `base_model_pp_plan` is a `dict` that maps direct child layer names to `tuple`s of `list`s of `str`s: - You only need to do this for layers which are not present on all pipeline stages - vLLM assumes that there will be only one `nn.ModuleList`, which is distributed across the pipeline stages + - When no `base_model_pp_plan` is provided, the Transformers modelling backend infers the split from the text model's sole `nn.ModuleList`, keeping the parameter-bearing modules around it (input embeddings, final norm) on the first/last stage (depending on declaration order) and parameter-free modules (e.g. rotary embeddings) on every stage - The `list` in the first element of the `tuple` contains the names of the input arguments - The `list` in the last element of the `tuple` contains the names of the variables the layer outputs to in your modeling code @@ -240,50 +242,24 @@ Use the Hugging Face CLI to [manage models](https://huggingface.co/docs/huggingf ```bash # List cached models -hf scan-cache +hf cache list -q # Show detailed (verbose) output -hf scan-cache -v +hf cache list # Specify a custom cache directory -hf scan-cache --dir ~/.cache/huggingface/hub +hf cache list --dir ~/.cache/huggingface/hub ``` #### Delete a cached model -Use the Hugging Face CLI to interactively [delete downloaded model](https://huggingface.co/docs/huggingface_hub/guides/manage-cache#clean-your-cache) from the cache: +Use the Hugging Face CLI to [delete downloaded model](https://huggingface.co/docs/huggingface_hub/guides/manage-cache#clean-your-cache) from the cache: -
-Commands - -```console -# The `delete-cache` command requires extra dependencies to work with the TUI. -# Please run `pip install huggingface_hub[cli]` to install them. - -# Launch the interactive TUI to select models to delete -$ hf delete-cache -? Select revisions to delete: 1 revisions selected counting for 438.9M. - ○ None of the following (if selected, nothing will be deleted). -Model BAAI/bge-base-en-v1.5 (438.9M, used 1 week ago) -❯ ◉ a5beb1e3: main # modified 1 week ago - -Model BAAI/bge-large-en-v1.5 (1.3G, used 1 week ago) - ○ d4aa6901: main # modified 1 week ago - -Model BAAI/bge-reranker-base (1.1G, used 4 weeks ago) - ○ 2cfc18c9: main # modified 4 weeks ago - -Press to select, to validate and to quit without modification. - -# Need to confirm after selected -? Select revisions to delete: 1 revision(s) selected. -? 1 revisions selected counting for 438.9M. Confirm deletion ? Yes -Start deletion. -Done. Deleted 1 repo(s) and 0 revision(s) for a total of 438.9M. +```bash +# delete all the cached objects +hf cache rm $(hf cache list -q) ``` -
- #### Using a proxy Here are some tips for loading/downloading models from Hugging Face using a proxy: @@ -366,19 +342,16 @@ th { | ------------ | ------ | ----------------- | -------------------- | ------------------------- | | `AfmoeForCausalLM` | Afmoe | TBA | ✅︎ | ✅︎ | | `ApertusForCausalLM` | Apertus | `swiss-ai/Apertus-8B-2509`, `swiss-ai/Apertus-70B-Instruct-2509`, etc. | ✅︎ | ✅︎ | -| `AquilaForCausalLM` | Aquila, Aquila2 | `BAAI/Aquila-7B`, `BAAI/AquilaChat-7B`, etc. | ✅︎ | ✅︎ | | `ArceeForCausalLM` | Arcee (AFM) | `arcee-ai/AFM-4.5B-Base`, etc. | ✅︎ | ✅︎ | | `ArcticForCausalLM` | Arctic | `Snowflake/snowflake-arctic-base`, `Snowflake/snowflake-arctic-instruct`, etc. | | ✅︎ | | `AXK1ForCausalLM` | A.X-K1 | `skt/A.X-K1`, etc. | | ✅︎ | -| `BaiChuanForCausalLM` | Baichuan2, Baichuan | `baichuan-inc/Baichuan2-13B-Chat`, `baichuan-inc/Baichuan-7B`, etc. | ✅︎ | ✅︎ | | `BailingMoeForCausalLM` | Ling | `inclusionAI/Ling-lite-1.5`, `inclusionAI/Ling-plus`, etc. | ✅︎ | ✅︎ | | `BailingMoeV2ForCausalLM` | Ling | `inclusionAI/Ling-mini-2.0`, etc. | ✅︎ | ✅︎ | | `BailingMoeV2_5ForCausalLM` | Ling | `inclusionAI/Ling-2.5-1T`, `inclusionAI/Ring-2.5-1T` | | ✅︎ | -| `BambaForCausalLM` | Bamba | `ibm-ai-platform/Bamba-9B-fp8`, `ibm-ai-platform/Bamba-9B` | ✅︎ | ✅︎ | | `BloomForCausalLM` | BLOOM, BLOOMZ, BLOOMChat | `bigscience/bloom`, `bigscience/bloomz`, etc. | | ✅︎ | | `ChatGLMModel`, `ChatGLMForConditionalGeneration` | ChatGLM | `zai-org/chatglm2-6b`, `zai-org/chatglm3-6b`, `thu-coai/ShieldLM-6B-chatglm3`, etc. | ✅︎ | ✅︎ | | `CohereForCausalLM`, `Cohere2ForCausalLM` | Command-R, Command-A | `CohereLabs/c4ai-command-r-v01`, `CohereLabs/c4ai-command-r7b-12-2024`, `CohereLabs/c4ai-command-a-03-2025`, `CohereLabs/command-a-reasoning-08-2025`, etc. | ✅︎ | ✅︎ | -| `Cohere2MoeForCausalLM` | Command-A+ | `CohereLabs/command-a-plus-05-2026`, etc. | ✅︎ | ✅︎ | +| `Cohere2MoeForCausalLM` | North-Mini-Code | `CohereLabs/North-Mini-Code`, etc. | ✅︎ | ✅︎ | | `CwmForCausalLM` | CWM | `facebook/cwm`, etc. | ✅︎ | ✅︎ | | `DbrxForCausalLM` | DBRX | `databricks/dbrx-base`, `databricks/dbrx-instruct`, etc. | | ✅︎ | | `DeciLMForCausalLM` | DeciLM | `nvidia/Llama-3_3-Nemotron-Super-49B-v1`, etc. | ✅︎ | ✅︎ | @@ -386,7 +359,6 @@ th { | `DeepseekV2ForCausalLM` | DeepSeek-V2 | `deepseek-ai/DeepSeek-V2`, `deepseek-ai/DeepSeek-V2-Chat`, etc. | ✅︎ | ✅︎ | | `DeepseekV3ForCausalLM` | DeepSeek-V3 | `deepseek-ai/DeepSeek-V3`, `deepseek-ai/DeepSeek-R1`, `deepseek-ai/DeepSeek-V3.1`, etc. | ✅︎ | ✅︎ | | `DeepseekV4ForCausalLM` | DeepSeek-V4 | `deepseek-ai/DeepSeek-V4-Flash`, `deepseek-ai/DeepSeek-V4-Pro`, etc. | | ✅︎ | -| `Dots1ForCausalLM` | dots.llm1 | `rednote-hilab/dots.llm1.base`, `rednote-hilab/dots.llm1.inst`, etc. | | ✅︎ | | `DotsOCRForCausalLM` | dots_ocr | `rednote-hilab/dots.ocr` | ✅︎ | ✅︎ | | `Ernie4_5ForCausalLM` | Ernie4.5 | `baidu/ERNIE-4.5-0.3B-PT`, etc. | ✅︎ | ✅︎ | | `Ernie4_5_MoeForCausalLM` | Ernie4.5MoE | `baidu/ERNIE-4.5-21B-A3B-PT`, `baidu/ERNIE-4.5-300B-A47B-PT`, etc. | ✅︎ | ✅︎ | @@ -407,8 +379,8 @@ th { | `Glm4ForCausalLM` | GLM-4-0414 | `zai-org/GLM-4-32B-0414`, etc. | ✅︎ | ✅︎ | | `Glm4MoeForCausalLM` | GLM-4.5, GLM-4.6, GLM-4.7 | `zai-org/GLM-4.5`, etc. | ✅︎ | ✅︎ | | `Glm4MoeLiteForCausalLM` | GLM-4.7-Flash | `zai-org/GLM-4.7-Flash`, etc. | ✅︎ | ✅︎ | +| `GlmMoeDsaForCausalLM` | GLM-5, GLM-5.1, GLM-5.2 | `zai-org/GLM-5`, etc. | ✅︎ | ✅︎ | | `GPT2LMHeadModel` | GPT-2 | `openai-community/gpt2`, `openai-community/gpt2-xl`, etc. | | ✅︎ | -| `GPTBigCodeForCausalLM` | StarCoder, SantaCoder, WizardCoder | `bigcode/starcoder`, `bigcode/gpt_bigcode-santacoder`, `WizardLM/WizardCoder-15B-V1.0`, etc. | ✅︎ | ✅︎ | | `GPTJForCausalLM` | GPT-J | `EleutherAI/gpt-j-6b`, `nomic-ai/gpt4all-j`, etc. | | ✅︎ | | `GPTNeoXForCausalLM` | GPT-NeoX, Pythia, OpenAssistant, Dolly V2, StableLM | `EleutherAI/gpt-neox-20b`, `EleutherAI/pythia-12b`, `OpenAssistant/oasst-sft-4-pythia-12b-epoch-3.5`, `databricks/dolly-v2-12b`, `stabilityai/stablelm-tuned-alpha-7b`, etc. | | ✅︎ | | `GptOssForCausalLM` | GPT-OSS | `openai/gpt-oss-120b`, `openai/gpt-oss-20b` | ✅︎ | ✅︎ | @@ -417,13 +389,11 @@ th { | `GraniteMoeHybridForCausalLM` | Granite 4.0 MoE Hybrid | `ibm-granite/granite-4.0-tiny-preview`, etc. | ✅︎ | ✅︎ | | `GraniteMoeSharedForCausalLM` | Granite MoE Shared | `ibm-research/moe-7b-1b-active-shared-experts` (test model) | ✅︎ | ✅︎ | | `GritLM` | GritLM | `parasail-ai/GritLM-7B-vllm`. | ✅︎ | ✅︎ | -| `Grok1ModelForCausalLM` | Grok1 | `hpcai-tech/grok-1`. | ✅︎ | ✅︎ | -| `Grok1ForCausalLM` | Grok2 | `xai-org/grok-2` | ✅︎ | ✅︎ | +| `HrmTextForCausalLM` | HRM-Text | `sapientinc/HRM-Text-1B`, etc. | | | | `HunYuanDenseV1ForCausalLM` | Hunyuan Dense | `tencent/Hunyuan-7B-Instruct` | ✅︎ | ✅︎ | | `HunYuanMoEV1ForCausalLM` | Hunyuan-A13B | `tencent/Hunyuan-A13B-Instruct`, `tencent/Hunyuan-A13B-Pretrain`, `tencent/Hunyuan-A13B-Instruct-FP8`, etc. | ✅︎ | ✅︎ | | `HYV3ForCausalLM` | HY3 | `tencent/Hy3-preview-Base`, `tencent/Hy3-preview` | ✅︎ | ✅︎ | | `HyperCLOVAXForCausalLM` | HyperCLOVAX-SEED-Think-14B | `naver-hyperclovax/HyperCLOVAX-SEED-Think-14B` | ✅︎ | ✅︎ | -| `InternLMForCausalLM` | InternLM | `internlm/internlm-7b`, `internlm/internlm-chat-7b`, etc. | ✅︎ | ✅︎ | | `InternLM2ForCausalLM` | InternLM2 | `internlm/internlm2-7b`, `internlm/internlm2-chat-7b`, etc. | ✅︎ | ✅︎ | | `InternLM3ForCausalLM` | InternLM3 | `internlm/internlm3-8b-instruct`, etc. | ✅︎ | ✅︎ | | `IQuestCoderForCausalLM` | IQuestCoderV1 | `IQuestLab/IQuest-Coder-V1-40B-Instruct`, etc. | | | @@ -443,16 +413,14 @@ th { | `MiMoV2ForCausalLM` | MiMoV2Pro | `XiaomiMiMo/MiMo-V2.5-Pro`, etc. | | ✅︎ | | `MiniCPMForCausalLM` | MiniCPM | `openbmb/MiniCPM-2B-sft-bf16`, `openbmb/MiniCPM-2B-dpo-bf16`, `openbmb/MiniCPM-S-1B-sft`, etc. | ✅︎ | ✅︎ | | `MiniCPM3ForCausalLM` | MiniCPM3 | `openbmb/MiniCPM3-4B`, etc. | ✅︎ | ✅︎ | -| `MiniMaxForCausalLM` | MiniMax-Text | `MiniMaxAI/MiniMax-Text-01-hf`, etc. | | | | `MiniMaxM2ForCausalLM` | MiniMax-M2, MiniMax-M2.1 | `MiniMaxAI/MiniMax-M2`, etc. | ✅︎ | ✅︎ | +| `MiniMaxM3SparseForCausalLM` | MiniMax-M3 | `MiniMaxAI/MiniMax-M3`, `MiniMaxAI/MiniMax-M3-MXFP8`, etc. | | ✅︎ | | `MistralForCausalLM` | Ministral-3, Mistral, Mistral-Instruct | `mistralai/Ministral-3-3B-Instruct-2512`, `mistralai/Mistral-7B-v0.1`, `mistralai/Mistral-7B-Instruct-v0.1`, etc. | ✅︎ | ✅︎ | | `MistralLarge3ForCausalLM` | Mistral-Large-3-675B-Base-2512, Mistral-Large-3-675B-Instruct-2512 | `mistralai/Mistral-Large-3-675B-Base-2512`, `mistralai/Mistral-Large-3-675B-Instruct-2512`, etc. | ✅︎ | ✅︎ | | `MixtralForCausalLM` | Mixtral-8x7B, Mixtral-8x7B-Instruct | `mistralai/Mixtral-8x7B-v0.1`, `mistralai/Mixtral-8x7B-Instruct-v0.1`, `mistral-community/Mixtral-8x22B-v0.1`, etc. | ✅︎ | ✅︎ | | `MPTForCausalLM` | MPT, MPT-Instruct, MPT-Chat, MPT-StoryWriter | `mosaicml/mpt-7b`, `mosaicml/mpt-7b-storywriter`, `mosaicml/mpt-30b`, etc. | | ✅︎ | | `NemotronForCausalLM` | Nemotron-3, Nemotron-4, Minitron | `nvidia/Minitron-8B-Base`, `mgoin/Nemotron-4-340B-Base-hf-FP8`, etc. | ✅︎ | ✅︎ | | `NemotronHForCausalLM` | Nemotron-H | `nvidia/Nemotron-H-8B-Base-8K`, `nvidia/Nemotron-H-47B-Base-8K`, `nvidia/Nemotron-H-56B-Base-8K`, etc. | ✅︎ | ✅︎ | -| `OlmoForCausalLM` | OLMo | `allenai/OLMo-1B-hf`, `allenai/OLMo-7B-hf`, etc. | ✅︎ | ✅︎ | -| `Olmo2ForCausalLM` | OLMo2 | `allenai/OLMo-2-0425-1B`, etc. | ✅︎ | ✅︎ | | `Olmo3ForCausalLM` | OLMo3 | `allenai/Olmo-3-7B-Instruct`, `allenai/Olmo-3-32B-Think`, etc. | ✅︎ | ✅︎ | | `OlmoHybridForCausalLM` | OLMo Hybrid | `allenai/Olmo-Hybrid-7B` | ✅︎ | ✅︎ | | `OlmoeForCausalLM` | OLMoE | `allenai/OLMoE-1B-7B-0924`, `allenai/OLMoE-1B-7B-0924-Instruct`, etc. | | ✅︎ | @@ -466,10 +434,8 @@ th { | `PhiForCausalLM` | Phi | `microsoft/phi-1_5`, `microsoft/phi-2`, etc. | ✅︎ | ✅︎ | | `Phi3ForCausalLM` | Phi-4, Phi-3 | `microsoft/Phi-4-mini-instruct`, `microsoft/Phi-4`, `microsoft/Phi-3-mini-4k-instruct`, `microsoft/Phi-3-mini-128k-instruct`, `microsoft/Phi-3-medium-128k-instruct`, etc. | ✅︎ | ✅︎ | | `PhiMoEForCausalLM` | Phi-3.5-MoE | `microsoft/Phi-3.5-MoE-instruct`, etc. | ✅︎ | ✅︎ | -| `PersimmonForCausalLM` | Persimmon | `adept/persimmon-8b-base`, `adept/persimmon-8b-chat`, etc. | | ✅︎ | | `Plamo2ForCausalLM` | PLaMo2 | `pfnet/plamo-2-1b`, `pfnet/plamo-2-8b`, etc. | ✅ | ✅︎ | | `Plamo3ForCausalLM` | PLaMo3 | `pfnet/plamo-3-nict-2b-base`, `pfnet/plamo-3-nict-8b-base`, etc. | ✅ | ✅︎ | -| `QWenLMHeadModel` | Qwen | `Qwen/Qwen-7B`, `Qwen/Qwen-7B-Chat`, etc. | ✅︎ | ✅︎ | | `Qwen2ForCausalLM` | QwQ, Qwen2 | `Qwen/QwQ-32B-Preview`, `Qwen/Qwen2-7B-Instruct`, `Qwen/Qwen2-7B`, etc. | ✅︎ | ✅︎ | | `Qwen2MoeForCausalLM` | Qwen2MoE | `Qwen/Qwen1.5-MoE-A2.7B`, `Qwen/Qwen1.5-MoE-A2.7B-Chat`, etc. | ✅︎ | ✅︎ | | `Qwen3ForCausalLM` | Qwen3 | `Qwen/Qwen3-8B`, etc. | ✅︎ | ✅︎ | @@ -483,26 +449,22 @@ th { | `SolarForCausalLM` | Solar Pro | `upstage/solar-pro-preview-instruct`, etc. | ✅︎ | ✅︎ | | `StableLmForCausalLM` | StableLM | `stabilityai/stablelm-3b-4e1t`, `stabilityai/stablelm-base-alpha-7b-v2`, etc. | | | | `StableLMEpochForCausalLM` | StableLM Epoch | `stabilityai/stablelm-zephyr-3b`, etc. | | ✅︎ | -| `Starcoder2ForCausalLM` | Starcoder2 | `bigcode/starcoder2-3b`, `bigcode/starcoder2-7b`, `bigcode/starcoder2-15b`, etc. | | ✅︎ | | `Step1ForCausalLM` | Step-Audio | `stepfun-ai/Step-Audio-EditX`, etc. | ✅︎ | ✅︎ | | `Step3p5ForCausalLM` | Step-3.5-flash | `stepfun-ai/Step-3.5-Flash`, etc. | | ✅︎ | -| `TeleChatForCausalLM` | TeleChat | `chuhac/TeleChat2-35B`, etc. | ✅︎ | ✅︎ | | `TeleChat2ForCausalLM` | TeleChat2 | `Tele-AI/TeleChat2-3B`, `Tele-AI/TeleChat2-7B`, `Tele-AI/TeleChat2-35B`, etc. | ✅︎ | ✅︎ | | `TeleChat3ForCausalLM` | TeleChat3 | `Tele-AI/TeleChat3-36B-Thinking`, `Tele-AI/TeleChat3-Coder-36B-Thinking`, etc. | ✅︎ | ✅︎ | | `TeleFLMForCausalLM` | TeleFLM | `CofeAI/FLM-2-52B-Instruct-2407`, `CofeAI/Tele-FLM`, etc. | ✅︎ | ✅︎ | -| `XverseForCausalLM` | XVERSE | `xverse/XVERSE-7B-Chat`, `xverse/XVERSE-13B-Chat`, `xverse/XVERSE-65B-Chat`, etc. | ✅︎ | ✅︎ | -| `MiniMaxM1ForCausalLM` | MiniMax-Text | `MiniMaxAI/MiniMax-M1-40k`, `MiniMaxAI/MiniMax-M1-80k`, etc. | | | -| `MiniMaxText01ForCausalLM` | MiniMax-Text | `MiniMaxAI/MiniMax-Text-01`, etc. | | | | `Zamba2ForCausalLM` | Zamba2 | `Zyphra/Zamba2-7B-instruct`, `Zyphra/Zamba2-2.7B-instruct`, `Zyphra/Zamba2-1.2B-instruct`, etc. | | | -!!! note - Grok2 requires `tokenizer.tok.json` with `tiktoken` installed. You can optionally override MoE router renormalization with `moe_router_renormalize`. - Some models are supported only via the [Transformers modeling backend](#transformers). The purpose of the table below is to acknowledge models which we officially support in this way. The logs will say that the Transformers modeling backend is being used, and you will see no warning that this is fallback behaviour. This means that, if you have issues with any of the models listed below, please [make an issue](https://github.com/vllm-project/vllm/issues/new/choose) and we'll do our best to fix it! | Architecture | Models | Example HF Models | [LoRA](../features/lora.md) | [PP](../serving/parallelism_scaling.md) | | ------------ | ------ | ----------------- | -------------------- | ------------------------- | +| `GPTBigCodeForCausalLM` | StarCoder, SantaCoder, WizardCoder | `bigcode/starcoder`, `bigcode/gpt_bigcode-santacoder`, `WizardLM/WizardCoder-15B-V1.0`, etc. | ✅︎ | | +| `OlmoForCausalLM` | OLMo | `allenai/OLMo-1B-hf`, `allenai/OLMo-7B-hf`, etc. | ✅︎ | ✅︎ | +| `Olmo2ForCausalLM` | OLMo2 | `allenai/OLMo-2-0425-1B`, etc. | ✅︎ | ✅︎ | | `SmolLM3ForCausalLM` | SmolLM3 | `HuggingFaceTB/SmolLM3-3B` | ✅︎ | ✅︎ | +| `Starcoder2ForCausalLM` | Starcoder2 | `bigcode/starcoder2-3b`, `bigcode/starcoder2-7b`, `bigcode/starcoder2-15b`, etc. | ✅︎ | ✅︎ | !!! note Currently, the ROCm version of vLLM supports Mistral and Mixtral only for context lengths up to 4096. @@ -544,13 +506,12 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen | ------------ | ------ | ------ | ----------------- | -------------------- | ------------------------- | | `AriaForConditionalGeneration` | Aria | T + I+ | `rhymes-ai/Aria` | | | | `AudioFlamingo3ForConditionalGeneration` | AudioFlamingo3 | T + A | `nvidia/audio-flamingo-3-hf`, `nvidia/music-flamingo-hf` | ✅︎ | ✅︎ | -| `AyaVisionForConditionalGeneration` | Aya Vision | T + I+ | `CohereLabs/aya-vision-8b`, `CohereLabs/aya-vision-32b`, etc. | | ✅︎ | | `BagelForConditionalGeneration` | BAGEL | T + I+ | `ByteDance-Seed/BAGEL-7B-MoT` | ✅︎ | ✅︎ | | `BeeForConditionalGeneration` | Bee-8B | T + IE+ | `Open-Bee/Bee-8B-RL`, `Open-Bee/Bee-8B-SFT` | | ✅︎ | | `Blip2ForConditionalGeneration` | BLIP-2 | T + IE | `Salesforce/blip2-opt-2.7b`, `Salesforce/blip2-opt-6.7b`, etc. | ✅︎ | ✅︎ | | `ChameleonForConditionalGeneration` | Chameleon | T + I | `facebook/chameleon-7b`, etc. | | ✅︎ | | `CheersForConditionalGeneration` | Cheers | T + I | `ai9stars/Cheers` | | ✅︎ | -| `Cohere2VisionForConditionalGeneration` | Command A Vision | T + I+ | `CohereLabs/command-a-vision-07-2025`, etc. | | ✅︎ | +| `Cohere2VisionForConditionalGeneration` | Command A Vision, Command-A+ | T + I+ | `CohereLabs/command-a-vision-07-2025`, `CohereLabs/command-a-plus-05-2026`, etc. | | ✅︎ | | `Cosmos3ForConditionalGeneration` | Cosmos3 (understanding tower) | T + IE+ + VE+ | `nvidia/Cosmos3-Nano` | | ✅︎ | | `DeepseekVLV2ForCausalLM` | DeepSeek-VL2 | T + I+ | `deepseek-ai/deepseek-vl2-tiny`, `deepseek-ai/deepseek-vl2-small`, `deepseek-ai/deepseek-vl2`, etc. | | ✅︎ | | `DeepseekOCRForCausalLM` | DeepSeek-OCR | T + I+ | `deepseek-ai/DeepSeek-OCR`, etc. | ✅︎ | ✅︎ | @@ -558,7 +519,6 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen | `Eagle2_5_VLForConditionalGeneration` | Eagle2.5-VL | T + IE+ | `nvidia/Eagle2.5-8B`, etc. | ✅︎ | ✅︎ | | `Ernie4_5_VLMoeForConditionalGeneration` | Ernie4.5-VL | T + I+/ V+ | `baidu/ERNIE-4.5-VL-28B-A3B-PT`, `baidu/ERNIE-4.5-VL-424B-A47B-PT` | | ✅︎ | | `Exaone4_5_ForConditionalGeneration` | EXAONE-4.5 | T + IE+ | `LGAI-EXAONE/EXAONE-4.5-33B`, etc. | ✅︎ | ✅︎ | -| `FuyuForCausalLM` | Fuyu | T + I | `adept/fuyu-8b`, etc. | | ✅︎ | | `Gemma3ForConditionalGeneration` | Gemma 3 | T + IE+ | `google/gemma-3-4b-it`, `google/gemma-3-27b-it`, etc. | ✅︎ | ✅︎ | | `Gemma3nForConditionalGeneration` | Gemma 3n | T + I + A | `google/gemma-3n-E2B-it`, `google/gemma-3n-E4B-it`, etc. | | | | `Gemma4ForConditionalGeneration` | Gemma 4 | T + I+ + V + A* | `google/gemma-4-E2B-it`, etc. | | ✅︎ | @@ -569,6 +529,7 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen | `GlmOcrForConditionalGeneration` | GLM-OCR | T + IE+ | `zai-org/GLM-OCR`, etc. | ✅︎ | ✅︎ | | `Granite4VisionForConditionalGeneration` | Granite 4 Vision | T + IE+ | `ibm-granite/granite-4.1-3b-vision`, etc. | ✅︎ | ✅︎ | | `GraniteSpeechForConditionalGeneration` | Granite Speech | T + A | `ibm-granite/granite-speech-3.3-8b` | ✅︎ | ✅︎ | +| `GraniteSpeechPlusForConditionalGeneration` | Granite Speech Plus | T + A | `ibm-granite/granite-speech-4.1-2b-plus` | ✅︎ | ✅︎ | | `HCXVisionForCausalLM` | HyperCLOVAX-SEED-Vision-Instruct-3B | T + I+ + V+ | `naver-hyperclovax/HyperCLOVAX-SEED-Vision-Instruct-3B` | | | | `HCXVisionV2ForCausalLM` | HyperCLOVAX-SEED-Think-32B | T + I+ + V+ | `naver-hyperclovax/HyperCLOVAX-SEED-Think-32B` | | | | `H2OVLChatModel` | H2OVL | T + IE+ | `h2oai/h2ovl-mississippi-800m`, `h2oai/h2ovl-mississippi-2b`, etc. | ✅︎ | ✅︎ | @@ -578,7 +539,7 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen | `InternS1ForConditionalGeneration` | Intern-S1 | T + IE+ + VE+ | `internlm/Intern-S1`, `internlm/Intern-S1-mini`, etc. | ✅︎ | ✅︎ | | `InternS1ProForConditionalGeneration` | Intern-S1-Pro | T + IE+ + VE+ | `internlm/Intern-S1-Pro`, etc. | ✅︎ | ✅︎ | | `InternS2PreviewForConditionalGeneration` | Intern-S2-Preview | T + IE+ + VE+ | `internlm/Intern-S2-Preview`, etc. | ✅︎ | ✅︎ | -| `InternVLChatModel` | InternVL 3.5, InternVL 3.0, InternVideo 2.5, InternVL 2.5, Mono-InternVL, InternVL 2.0 | T + IE+ + (VE+) | `OpenGVLab/InternVL3_5-14B`, `OpenGVLab/InternVL3-9B`, `OpenGVLab/InternVideo2_5_Chat_8B`, `OpenGVLab/InternVL2_5-4B`, `OpenGVLab/Mono-InternVL-2B`, `OpenGVLab/InternVL2-4B`, etc. | ✅︎ | ✅︎ | +| `InternVLChatModel` | InternVL 3.5, InternVL 3.0, InternVideo 2.5, InternVL 2.5, InternVL 2.0 | T + IE+ + (VE+) | `OpenGVLab/InternVL3_5-14B`, `OpenGVLab/InternVL3-9B`, `OpenGVLab/InternVideo2_5_Chat_8B`, `OpenGVLab/InternVL2_5-4B`, `OpenGVLab/InternVL2-4B`, etc. | ✅︎ | ✅︎ | | `InternVLForConditionalGeneration` | InternVL 3.0 (HF format) | T + IE+ + VE+ | `OpenGVLab/InternVL3-1B-hf`, etc. | ✅︎ | ✅︎ | | `KananaVForConditionalGeneration` | Kanana-V | T + I+ | `kakaocorp/kanana-1.5-v-3b-instruct`, etc. | | ✅︎ | | `KeyeForConditionalGeneration` | Keye-VL-8B-Preview | T + IE+ + VE+ | `Kwai-Keye/Keye-VL-8B-Preview` | ✅︎ | ✅︎ | @@ -590,20 +551,23 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen | `Lfm2VlForConditionalGeneration` | LFM2-VL | T + I+ | `LiquidAI/LFM2-VL-450M`, `LiquidAI/LFM2-VL-3B`, `LiquidAI/LFM2-VL-8B-A1B`, etc. | ✅︎ | ✅︎ | | `Llama4ForConditionalGeneration` | Llama 4 | T + I+ | `meta-llama/Llama-4-Scout-17B-16E-Instruct`, `meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8`, `meta-llama/Llama-4-Maverick-17B-128E-Instruct`, etc. | ✅︎ | ✅︎ | | `Llama_Nemotron_Nano_VL` | Llama Nemotron Nano VL | T + IE+ | `nvidia/Llama-3.1-Nemotron-Nano-VL-8B-V1` | ✅︎ | ✅︎ | -| `LlavaForConditionalGeneration` | LLaVA-1.5, Pixtral (HF Transformers) | T + IE+ | `llava-hf/llava-1.5-7b-hf`, `TIGER-Lab/Mantis-8B-siglip-llama3` (see note), `mistral-community/pixtral-12b`, etc. | ✅︎ | ✅︎ | +| `LlavaForConditionalGeneration` | LLaVA-1.5, Pixtral (HF Transformers) | T + IE+ | `llava-hf/llava-1.5-7b-hf`, `mistral-community/pixtral-12b`, etc. | ✅︎ | ✅︎ | | `LlavaNextForConditionalGeneration` | LLaVA-NeXT, Granite Vision | T + IE+ | `llava-hf/llava-v1.6-mistral-7b-hf`, `llava-hf/llava-v1.6-vicuna-7b-hf`, `ibm-granite/granite-vision-3.3-2b`, etc. | | ✅︎ | | `LlavaNextVideoForConditionalGeneration` | LLaVA-NeXT-Video | T + V | `llava-hf/LLaVA-NeXT-Video-7B-hf`, etc. | | ✅︎ | +| `LlavaOnevision2ForConditionalGeneration` | LLaVA-OneVision-2 | T + I+ + V+ | `lmms-lab-encoder/LLaVA-OneVision-2-8B-Instruct` | | | | `LlavaOnevisionForConditionalGeneration` | LLaVA-Onevision | T + I+ + V+ | `llava-hf/llava-onevision-qwen2-7b-ov-hf`, `llava-hf/llava-onevision-qwen2-0.5b-ov-hf`, etc. | | ✅︎ | | `MiDashengLMModel` | MiDashengLM | T + A+ | `mispeech/midashenglm-7b` | | ✅︎ | | `MiMoV2OmniForCausalLM` | MiMo-V2.5-Omni | T + IE+ + VE+ + A+ | `XiaomiMiMo/MiMo-V2.5-Omni` | | ✅︎ | | `MiniCPMO` | MiniCPM-O | T + IE+ + VE+ + AE+ | `openbmb/MiniCPM-o-2_6`, etc. | ✅︎ | ✅︎ | -| `MiniCPMV` | MiniCPM-V | T + IE+ + VE+ | `openbmb/MiniCPM-V-2` (see note), `openbmb/MiniCPM-Llama3-V-2_5`, `openbmb/MiniCPM-V-2_6`, `openbmb/MiniCPM-V-4`, `openbmb/MiniCPM-V-4_5`, etc. | ✅︎ | | +| `MiniCPMV` | MiniCPM-V | T + IE+ + VE+ | `openbmb/MiniCPM-V-2` (see note), `openbmb/MiniCPM-Llama3-V-2_5`, `openbmb/MiniCPM-V-2_6`, `openbmb/MiniCPM-V-4`, `openbmb/MiniCPM-V-4_5`, `openbmb/MiniCPM-V-4_6`, etc. | ✅︎ | | +| `MiniMaxM3SparseForConditionalGeneration` | MiniMax-M3 | T + I+ + V+ | `MiniMaxAI/MiniMax-M3`, `MiniMaxAI/MiniMax-M3-MXFP8`, etc. | | ✅︎ | | `MiniMaxVL01ForConditionalGeneration` | MiniMax-VL | T + IE+ | `MiniMaxAI/MiniMax-VL-01`, etc. | | ✅︎ | | `Mistral3ForConditionalGeneration` | Mistral3 (HF Transformers) | T + I+ | `mistralai/Mistral-Small-3.1-24B-Instruct-2503`, etc. | ✅︎ | ✅︎ | | `MolmoForCausalLM` | Molmo | T + I+ | `allenai/Molmo-7B-D-0924`, `allenai/Molmo-7B-O-0924`, etc. | ✅︎ | ✅︎ | | `Molmo2ForConditionalGeneration` | Molmo2 | T + I+ / V | `allenai/Molmo2-4B`, `allenai/Molmo2-8B`, `allenai/Molmo2-O-7B`, `allenai/MolmoWeb-4B`^, `allenai/MolmoWeb-8B`^ | ✅︎ | ✅︎ | +| `MossAudioModel` | MOSS-Audio | T + A+ | `OpenMOSS-Team/MOSS-Audio-4B-Instruct`, `OpenMOSS-Team/MOSS-Audio-4B-Thinking`, `OpenMOSS-Team/MOSS-Audio-8B-Instruct`, `OpenMOSS-Team/MOSS-Audio-8B-Thinking` | ✅︎ | ✅︎ | +| `MossTranscribeDiarizeForConditionalGeneration` | MOSS-Transcribe-Diarize | T + A | `OpenMOSS-Team/MOSS-Transcribe-Diarize` | | ✅︎ | | `Moondream3ForCausalLM` | Moondream3 | T + I | `moondream/moondream3-preview` | | ✅︎ | -| `MusicFlamingoForConditionalGeneration` | MusicFlamingo | T + A | `nvidia/music-flamingo-2601-hf`, `nvidia/music-flamingo-think-2601-hf` | ✅︎ | ✅︎ | | `NVLM_D_Model` | NVLM-D 1.0 | T + I+ | `nvidia/NVLM-D-72B`, etc. | | ✅︎ | | `OpenCUAForConditionalGeneration` | OpenCUA-7B | T + IE+ | `xlangai/OpenCUA-7B` | ✅︎ | ✅︎ | | `OpenPanguVLForConditionalGeneration` | openpangu-VL | T + IE+ + VE+ | `FreedomIntelligence/openPangu-VL-7B` | ✅︎ | ✅︎ | @@ -619,7 +583,6 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen | `Phi4ForCausalLMV` | Phi-4-reasoning-vision | T + I+ | `microsoft/Phi-4-reasoning-vision-15B`, etc. | | ✅︎ | | `PixtralForConditionalGeneration` | Ministral 3 (Mistral format), Mistral 3 (Mistral format), Mistral Large 3 (Mistral format), Pixtral (Mistral format) | T + I+ | `mistralai/Ministral-3-3B-Instruct-2512`, `mistralai/Mistral-Small-3.1-24B-Instruct-2503`, `mistralai/Mistral-Large-3-675B-Instruct-2512` `mistralai/Pixtral-12B-2409` etc. | ✅︎ | ✅︎ | | `QianfanOCRForConditionalGeneration` | QianfanOCR | T + IE+ | `baidu/Qianfan-OCR`, etc. | ✅︎ | ✅︎ | -| `QwenVLForConditionalGeneration`^ | Qwen-VL | T + IE+ | `Qwen/Qwen-VL`, `Qwen/Qwen-VL-Chat`, etc. | ✅︎ | ✅︎ | | `Qwen2AudioForConditionalGeneration` | Qwen2-Audio | T + A+ | `Qwen/Qwen2-Audio-7B-Instruct` | | ✅︎ | | `Qwen2VLForConditionalGeneration` Q | QVQ, Qwen2-VL | T + IE+ + VE+ | `Qwen/QVQ-72B-Preview`, `Qwen/Qwen2-VL-7B-Instruct`, `Qwen/Qwen2-VL-72B-Instruct`, etc. | ✅︎ | ✅︎ | | `Qwen2_5_VLForConditionalGeneration` Q | Qwen2.5-VL | T + IE+ + VE+ | `Qwen/Qwen2.5-VL-3B-Instruct`, `Qwen/Qwen2.5-VL-72B-Instruct`, etc. | ✅︎ | ✅︎ | @@ -636,9 +599,8 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen | `Step3VLForConditionalGeneration` | Step3-VL | T + I+ | `stepfun-ai/step3` | | ✅︎ | | `StepVLForConditionalGeneration` | Step3-VL-10B | T + I+ | `stepfun-ai/Step3-VL-10B` | | ✅︎ | | `Step3p7ForConditionalGeneration` | Step-3.7-Flash | T + I+ | `stepfun-ai/Step-3.7-Flash` | | ✅︎ | -| `TarsierForConditionalGeneration` | Tarsier | T + IE+ | `omni-search/Tarsier-7b`, `omni-search/Tarsier-34b` | | ✅︎ | -| `Tarsier2ForConditionalGeneration`^ | Tarsier2 | T + IE+ + VE+ | `omni-research/Tarsier2-Recap-7b`, `omni-research/Tarsier2-7b-0115` | | ✅︎ | | `UltravoxModel` | Ultravox | T + AE+ | `fixie-ai/ultravox-v0_5-llama-3_2-1b` | ✅︎ | ✅︎ | +| `UnlimitedOCRForCausalLM` | Unlimited-OCR | T + I+ | `baidu/Unlimited-OCR`, etc. | ✅︎ | ✅︎ | Some models are supported only via the [Transformers modeling backend](#transformers). The purpose of the table below is to acknowledge models which we officially support in this way. The logs will say that the Transformers modeling backend is being used, and you will see no warning that this is fallback behaviour. This means that, if you have issues with any of the models listed below, please [make an issue](https://github.com/vllm-project/vllm/issues/new/choose) and we'll do our best to fix it! @@ -689,9 +651,6 @@ Some models are supported only via the [Transformers modeling backend](#transfor coordinate decoding and are not exposed by this vLLM implementation. See [Moondream3 prompt recipes](../features/multimodal_inputs.md#moondream3-prompt-recipes). -!!! note - To use `TIGER-Lab/Mantis-8B-siglip-llama3`, you have to pass `--hf_overrides '{"architectures": ["MantisForConditionalGeneration"]}'` when running vLLM. - !!! note The official `openbmb/MiniCPM-V-2` doesn't work yet, so we need to use a fork (`HwwwH/MiniCPM-V-2`) for now. For more details, please see: @@ -709,6 +668,8 @@ Speech2Text models trained specifically for Automatic Speech Recognition. | `Gemma3nForConditionalGeneration` | Gemma3n | `google/gemma-3n-E2B-it`, `google/gemma-3n-E4B-it`, etc. | | | | `GlmAsrForConditionalGeneration` | GLM-ASR | `zai-org/GLM-ASR-Nano-2512` | ✅︎ | ✅︎ | | `GraniteSpeechForConditionalGeneration` | Granite Speech | `ibm-granite/granite-4.0-1b-speech`, `ibm-granite/granite-speech-3.3-2b`, etc. | ✅︎ | ✅︎ | +| `GraniteSpeechPlusForConditionalGeneration` | Granite Speech Plus | `ibm-granite/granite-speech-4.1-2b-plus` | ✅︎ | ✅︎ | +| `MossTranscribeDiarizeForConditionalGeneration` | MOSS-Transcribe-Diarize | `OpenMOSS-Team/MOSS-Transcribe-Diarize` | | ✅︎ | | `Qwen3ASRForConditionalGeneration` | Qwen3-ASR | `Qwen/Qwen3-ASR-1.7B`, etc. | ✅︎ | ✅︎ | | `Qwen3OmniMoeThinkerForConditionalGeneration` | Qwen3-Omni | `Qwen/Qwen3-Omni-30B-A3B-Instruct`, etc. | | ✅︎ | | `VoxtralForConditionalGeneration` | Voxtral (Mistral format) | `mistralai/Voxtral-Mini-3B-2507`, `mistralai/Voxtral-Small-24B-2507`, etc. | ✅︎ | ✅︎ | diff --git a/docs/pre_run_check.sh b/docs/pre_run_check.sh index 464766c42ec4..d55f8c8db12d 100644 --- a/docs/pre_run_check.sh +++ b/docs/pre_run_check.sh @@ -3,6 +3,26 @@ if [ "$READTHEDOCS_VERSION_TYPE" != "external" ]; then exit 0 fi +# Use a GitHub token if provided to raise the API rate limit (60 -> 5000 +# requests/hour). Set GITHUB_TOKEN in the Read the Docs environment variables. +CURL_AUTH=() +if [ -n "$GITHUB_TOKEN" ]; then + CURL_AUTH=(-H "Authorization: Bearer $GITHUB_TOKEN") +fi + +# Docs builds are now manually enabled via the 'build-docs' label. +echo "Checking for the 'build-docs' label on PR #${READTHEDOCS_VERSION_NAME}..." +LABELS=$(curl -sS "${CURL_AUTH[@]}" "https://api.github.com/repos/vllm-project/vllm/issues/${READTHEDOCS_VERSION_NAME}/labels" | python3 -c "import sys, json; print('\n'.join(l.get('name', '') for l in json.load(sys.stdin)))") +if printf '%s\n' "$LABELS" | grep -qx "build-docs"; then + echo "PR has the 'build-docs' label; continuing build." + exit 0 +else + echo "PR does not have the 'build-docs' label; cancelling build." + # See https://docs.readthedocs.com/platform/latest/guides/build/skip-build.html for info on exit code + exit 183 +fi + +# Everything below this line is effectively disabled as a temporary measure. echo "Checking for changes to docs-affecting files vs origin/main..." DOCS_PATHS=( docs/ # Actual docs content @@ -25,7 +45,7 @@ MAX_WAIT=300 INTERVAL=60 ELAPSED=0 while :; do - RAW=$(curl -sS -w "\n%{http_code}" "https://api.github.com/repos/vllm-project/vllm/commits/${READTHEDOCS_GIT_COMMIT_HASH}/check-runs?check_name=pre-run-check&filter=latest") + RAW=$(curl -sS "${CURL_AUTH[@]}" -w "\n%{http_code}" "https://api.github.com/repos/vllm-project/vllm/commits/${READTHEDOCS_GIT_COMMIT_HASH}/check-runs?check_name=pre-run-check&filter=latest") HTTP_CODE=$(printf %s "$RAW" | tail -n1) BODY=$(printf %s "$RAW" | sed '$d') if [ "$HTTP_CODE" != "200" ]; then diff --git a/docs/serving/online_serving/README.md b/docs/serving/online_serving/README.md index 9fa1763108cb..60476fa5edb5 100644 --- a/docs/serving/online_serving/README.md +++ b/docs/serving/online_serving/README.md @@ -9,12 +9,13 @@ We currently support the following OpenAI APIs: - [Completions API](./openai_compatible_server.md#completions-api) (`/v1/completions`) - Only applicable to [text generation models](../../models/generative_models.md). - *Note: `suffix` parameter is not supported.* -- [Responses API](./openai_compatible_server.md#responses-api) (`/v1/responses`) - - Only applicable to [text generation models](../../models/generative_models.md). - [Chat Completions API](./openai_compatible_server.md#chat-api) (`/v1/chat/completions`) - Only applicable to [text generation models](../../models/generative_models.md) with a [chat template](./openai_compatible_server.md#chat-template). - *Note: `user` parameter is ignored.* - *Note:* Setting the `parallel_tool_calls` parameter to `false` ensures vLLM only returns zero or one tool call per request. Setting it to `true` (the default) allows returning more than one tool call per request. There is no guarantee more than one tool call will be returned if this is set to `true`, as that behavior is model dependent and not all models are designed to support parallel tool calls. +- [Chat Completions batch API](./openai_compatible_server.md#chat-api) (`/v1/chat/completions/batch`) +- [Responses API](./openai_compatible_server.md#responses-api) (`/v1/responses`, `/v1/responses/{response_id}`, `/v1/responses/{response_id}/cancel`) + - Only applicable to [text generation models](../../models/generative_models.md). - [Embeddings API](../../models/pooling_models/embed.md#openai-compatible-embeddings-api) (`/v1/embeddings`) - Only applicable to [embedding models](../../models/pooling_models/embed.md). - [Transcriptions API](./speech_to_text.md#transcriptions-api) (`/v1/audio/transcriptions`) @@ -24,7 +25,7 @@ We currently support the following OpenAI APIs: ## Anthropic APIs -- Anthropic messages API (`/v1/messages`) +- Anthropic messages API (`/v1/messages`, `/v1/messages/count_tokens`) ## Cohere APIs @@ -35,10 +36,6 @@ We currently support the following OpenAI APIs: - Implements [Jina AI's v1 rerank API](https://jina.ai/reranker/) - compatible with [Cohere's v1 & v2 rerank APIs](https://docs.cohere.com/v2/reference/rerank) -## SageMaker APIs - -- `/invocations` - SageMaker-compatible endpoint (routes to the same inference functions as `/v1` endpoints) - ## Pooling APIs For further details on pooling models, please refer to [this page](../../models/pooling_models/README.md). @@ -51,7 +48,7 @@ For further details on pooling models, please refer to [this page](../../models/ - [OpenAI-compatible Embeddings API](../../models/pooling_models/embed.md#openai-compatible-embeddings-api) (`/v1/embeddings`) - Only applicable to [embedding models](../../models/pooling_models/embed.md). - [Scoring Usages](../../models/pooling_models/scoring.md) - - [Score API](../../models/pooling_models/scoring.md#score-api) (`/score`) + - [Score API](../../models/pooling_models/scoring.md#score-api) (`/score`, `/v1/score`) - [Cohere Rerank API](../../models/pooling_models/scoring.md#rerank-api) (`/rerank`, `/v1/rerank`, `/v2/rerank`) - Applicable to [score models](../../models/pooling_models/scoring.md) (cross-encoder, bi-encoder, late-interaction). - [Pooling API](../../models/pooling_models/README.md#pooling-api) (`/pooling`) @@ -68,17 +65,6 @@ For further details on speech to text, please refer to [this page](speech_to_tex - [Realtime API](./speech_to_text.md#realtime-api) (`/v1/realtime`) - Only applicable to [Automatic Speech Recognition (ASR) models](../../models/supported_models.md#realtime-transcription). -## Disaggregated APIs - -### Renderer APIs - -For further details on renderer APIs, please refer to [this page](renderer.md). - -- [Completions Render API](renderer.md) (`/v1/completions/render`) - - Render completion requests -- [Chat Completions Render API](renderer.md) (`/v1/chat/completions/render`) - - Render chat completions - ## Custom APIs - [Classification API](../../models/pooling_models/classify.md#classification-api) (`/classify`) @@ -91,14 +77,79 @@ For further details on renderer APIs, please refer to [this page](renderer.md). - Applicable to [CausalLM models](../../models/generative_models.md) (task `"generate"`). - Computes next-token probabilities for specified `label_token_ids`. -## Utility APIs +## Instrumentator APIs + +### Basic APIs -- `/tokenize` - Tokenize text -- `/detokenize` - Detokenize tokens -- `/health` - Health check -- `/ping` - SageMaker health check - `/version` - Version information - `/load` - Server load metrics +- `/v1/models` - List available models +- `/health` - Health check + +### Metrics APIs + +For further details on metrics, please refer to [this page](../../design/metrics.md). + +- `/metrics` - Prometheus-compatible metrics HTTP endpoint + +### Offline API Documentation + +The FastAPI `/docs` endpoint requires an internet connection by default. To enable offline access in air-gapped environments, use the `--enable-offline-docs` flag: + +```bash +vllm serve NousResearch/Meta-Llama-3-8B-Instruct --enable-offline-docs +``` + +### LoRA dynamic loading + +LoRA dynamic loading & unloading is enabled in the API server. This should ONLY be used for local development! + +- `/v1/load_lora_adapter` - LoRA dynamic loading +- `/v1/unload_lora_adapter` - LoRA dynamic unloading + +### Profiling APIs + +For further details on profiling vLLM, please refer to [this page](../../contributing/profiling.md). + +- `/start_profile` - Start PyTorch profiler +- `/stop_profile` - Stop PyTorch profiler + +### SageMaker APIs + +- `/ping` - SageMaker health check +- `/invocations` - SageMaker-compatible endpoint (routes to the same inference functions as `/v1` endpoints) + +## Scale-Out APIs + +### Tokens IN <> Tokens OUT APIs + +- `/inference/v1/generate` - Generate completions +- `/abort_requests` - Abort in-flight requests (only when `--tokens-only` is also set) + +### Renderer APIs + +For further details on renderer APIs, please refer to [this page](renderer.md). + +- [Completions Render API](renderer.md) (`/v1/completions/render`) + - Render completion requests +- [Chat Completions Render API](renderer.md) (`/v1/chat/completions/render`) + - Render chat completions + +### Derenderer APIs + +- `/v1/completions/derender` - Derenderer completion requests +- `/v1/chat/completions/derender` - Derenderer chat completion requests + +## Tokenize APIs + +- `/tokenize` - Tokenize text +- `/detokenize` - Detokenize tokens +- `/tokenizer_info` - Get comprehensive tokenizer information including chat templates and configuration + +## Elastic Expert Parallelism (EEP) + +- `/scale_elastic_ep` - Trigger scaling operations +- `/is_scaling_elastic_ep` - Check if scaling is in progress ## Server in development mode @@ -120,7 +171,9 @@ For further details on Weight Transfer, please refer to [this page](../../traini - `/resume` - Resume generation - `/is_paused` - Check if generation is paused - `/init_weight_transfer_engine` - Initialize weight transfer engine for RLHF +- `/start_weight_update` - Prepares the inference engine for a weight update. - `/update_weights` - Update model weights (can alter model behavior) +- `/finish_weight_update` - Finalizes the weight update - `/get_world_size` - Get distributed world size ### Collective RPC @@ -189,14 +242,6 @@ the detected format, which can be one of: If the result is not what you expect, you can set the `--chat-template-content-format` CLI argument to override which format to use. -## Offline API Documentation - -The FastAPI `/docs` endpoint requires an internet connection by default. To enable offline access in air-gapped environments, use the `--enable-offline-docs` flag: - -```bash -vllm serve NousResearch/Meta-Llama-3-8B-Instruct --enable-offline-docs -``` - ## Ray Serve LLM Ray Serve LLM enables scalable, production-grade serving of the vLLM engine. It integrates tightly with vLLM and extends it with features such as auto-scaling, load balancing, and back-pressure. diff --git a/docs/serving/online_serving/openai_compatible_server.md b/docs/serving/online_serving/openai_compatible_server.md index 245de012bff1..e50754aa9c01 100644 --- a/docs/serving/online_serving/openai_compatible_server.md +++ b/docs/serving/online_serving/openai_compatible_server.md @@ -9,12 +9,13 @@ We currently support the following OpenAI APIs: - [Completions API](#completions-api) (`/v1/completions`) - Only applicable to [text generation models](../../models/generative_models.md). - *Note: `suffix` parameter is not supported.* -- [Responses API](#responses-api) (`/v1/responses`) - - Only applicable to [text generation models](../../models/generative_models.md). - [Chat Completions API](#chat-api) (`/v1/chat/completions`) - Only applicable to [text generation models](../../models/generative_models.md) with a [chat template](../online_serving/README.md#chat-template). - *Note: `user` parameter is ignored.* - *Note:* Setting the `parallel_tool_calls` parameter to `false` ensures vLLM only returns zero or one tool call per request. Setting it to `true` (the default) allows returning more than one tool call per request. There is no guarantee more than one tool call will be returned if this is set to `true`, as that behavior is model dependent and not all models are designed to support parallel tool calls. +- [Chat Completions batch API](#chat-api) (`/v1/chat/completions/batch`) +- [Responses API](#responses-api) (`/v1/responses`, `/v1/responses/{response_id}`, `/v1/responses/{response_id}/cancel`) + - Only applicable to [text generation models](../../models/generative_models.md). - [Embeddings API](../../models/pooling_models/embed.md#openai-compatible-embeddings-api) (`/v1/embeddings`) - Only applicable to [embedding models](../../models/pooling_models/embed.md). - [Transcriptions API](./speech_to_text.md#transcriptions-api) (`/v1/audio/transcriptions`) diff --git a/docs/training/layerwise.md b/docs/training/layerwise.md index d304c4a8425d..5072fdb6dfd6 100644 --- a/docs/training/layerwise.md +++ b/docs/training/layerwise.md @@ -28,9 +28,9 @@ For more information on implementation, see [Low Level `layerwise` API](#low-lev Online quantization refers to when a user provides full precision weights and those weights are quantized on-the-fly as they are loaded into the model. The layerwise reloading system handles this by treating online quantization as a **processing** step, which is then handled in an online way both during first-time load and during reload. A typical online quantization method implementation should look like this: ```python -class Fp8OnlineLinearMethod(Fp8LinearMethod): - """Online version of Fp8LinearMethod which loads a full precision checkpoint - and quantizes weights during loading.""" +class Fp8PerTensorOnlineLinearMethod(LinearMethodBase): + """Online version of FP8 per-tensor quantization which loads a full + precision checkpoint and quantizes weights during loading.""" uses_meta_device: bool = True @@ -58,7 +58,7 @@ class Fp8OnlineLinearMethod(Fp8LinearMethod): ### High Level Weight Transfer API -The layerwise reloading system is integrated with the post-training weight transfer system. To use layerwise reloading in conjunction to the weight transfer system, follow the examples found [here](../../examples/rl/). Layerwise reloading is controlled by the `WeightTransferUpdateInfo.is_checkpoint_format` flag and is set to `True` by default. +The layerwise reloading system is integrated with the post-training weight transfer system. To use layerwise reloading in conjunction to the weight transfer system, follow the examples found [here](../../examples/rl/). Checkpoint-format weight transfer engines (e.g. the NCCL and IPC backends) run layerwise reloading automatically inside their `start_weight_update`/`finish_weight_update` lifecycle. ### Mid Level `reload_weights` API diff --git a/docs/training/weight_transfer/README.md b/docs/training/weight_transfer/README.md index 4368483e8ba2..7579e5fd4d02 100644 --- a/docs/training/weight_transfer/README.md +++ b/docs/training/weight_transfer/README.md @@ -17,6 +17,7 @@ The weight transfer system follows a **four-phase protocol** with a pluggable ba | ------- | --------- | -------- | | [NCCL](nccl.md) | NCCL broadcast | Separate GPUs for training and inference | | [IPC](ipc.md) | CUDA IPC handles | Colocated training and inference on same GPU | +| [sparse_nccl](nccl.md#sparse-nccl) | NCCL broadcast | Sparse flat-index weight patches (TP=1/PP=1) | ## Configuration @@ -41,7 +42,7 @@ vllm serve my-model \ --weight-transfer-config '{"backend": "nccl"}' ``` -The `backend` field accepts `"nccl"` (default) or `"ipc"`. +The `backend` field accepts `"nccl"` (default), `"ipc"`, or `"sparse_nccl"`. ## API Endpoints @@ -69,7 +70,7 @@ Both backends provide static methods that the trainer calls to send weights. The EngineClass.trainer_init(init_info) # 2. Start weight update on inference side -llm.start_weight_update(is_checkpoint_format=True) +llm.start_weight_update() # 3. Send weights to inference workers EngineClass.trainer_send_weights( diff --git a/docs/training/weight_transfer/base.md b/docs/training/weight_transfer/base.md index ace228b00915..020826496623 100644 --- a/docs/training/weight_transfer/base.md +++ b/docs/training/weight_transfer/base.md @@ -11,15 +11,23 @@ The `WeightTransferEngine` is a generic abstract class parameterized by two data ### Abstract Methods -Subclasses must implement these four methods: +Subclasses must implement these methods: | Method | Side | Description | | ------ | ---- | ----------- | | `init_transfer_engine(init_info)` | Inference | Initialize the communication channel on each inference worker | -| `receive_weights(update_info, load_weights)` | Inference | Receive weights and call `load_weights` incrementally | +| `start_weight_update()` | Inference | Prepare for an update (e.g. begin layerwise reload); no-op for in-place engines | +| `finish_weight_update()` | Inference | Finalize the update (e.g. finalize layerwise reload); no-op for in-place engines | +| `receive_weights(update_info)` | Inference | Receive weights and load them into `self.model` | | `shutdown()` | Inference | Clean up resources | | `trainer_send_weights(iterator, trainer_args)` | Trainer | Static method to send weights from the trainer process | +The base class provides two methods: + +1. `__init__` : Engines receive `config` (`WeightTransferConfig`), `vllm_config` (`VllmConfig`), `device` (`torch.device`) and `model` (`nn.Module`) +2. `update_weights(update_info_dict)`: Thin wrapper for `receive_weights`: parses +the dict into user-specified data type, calls `receive_weights`, and synchronizes the device. Subclasses implement `receive_weights`. + ### Request Classes The API-level request classes provide backend-agnostic serialization using plain dictionaries. The engine's `parse_init_info` and `parse_update_info` methods convert these dictionaries into typed dataclasses. @@ -81,7 +89,7 @@ class MyUpdateInfo(WeightTransferUpdateInfo): ### 2. Implement the Engine ```python -from collections.abc import Callable, Iterator +from collections.abc import Iterator from typing import Any import torch @@ -93,18 +101,25 @@ class MyWeightTransferEngine(WeightTransferEngine[MyInitInfo, MyUpdateInfo]): # Set up connection to trainer using init_info.endpoint, etc. ... - def receive_weights( - self, - update_info: MyUpdateInfo, - load_weights: Callable[[list[tuple[str, torch.Tensor]]], None], - ) -> None: - # Receive each weight and call load_weights incrementally + def start_weight_update(self) -> None: + # Checkpoint-format engines: run initialize_layerwise_reload(self.model). + # In-place engines: no-op + ... + + def finish_weight_update(self) -> None: + # Checkpoint-format engines: run finalize_layerwise_reload(...). + # In-place engines: no-op + ... + + def receive_weights(self, update_info: MyUpdateInfo) -> None: + weights = [] for name, dtype_name, shape in zip( update_info.names, update_info.dtype_names, update_info.shapes ): dtype = getattr(torch, dtype_name) weight = self._fetch_weight(name, shape, dtype) - load_weights([(name, weight)]) + weights.append((name, weight)) + self.model.load_weights(weights) def shutdown(self) -> None: # Clean up resources @@ -121,9 +136,6 @@ class MyWeightTransferEngine(WeightTransferEngine[MyInitInfo, MyUpdateInfo]): ... ``` -!!! important - The `load_weights` callable passed to `receive_weights` should be called **incrementally** (one or a few weights at a time) rather than accumulating all weights first. This avoids GPU out-of-memory errors with large models. - ### 3. Register with the Factory ```python @@ -147,7 +159,7 @@ Once registered, users can select your backend via `WeightTransferConfig(backend ## WeightTransferEngineFactory -The factory uses a registry pattern with lazy loading. Built-in engines (`nccl` and `ipc`) are registered at import time but their modules are only loaded when the backend is actually requested. This avoids importing heavy dependencies (like NCCL communicators) when they aren't needed. +The factory uses a registry pattern with lazy loading. Built-in engines (`nccl`, `ipc`, and `sparse_nccl`) are registered at import time but their modules are only loaded when the backend is actually requested. This avoids importing heavy dependencies (like NCCL communicators) when they aren't needed. ```python from vllm.distributed.weight_transfer.factory import WeightTransferEngineFactory @@ -155,7 +167,8 @@ from vllm.distributed.weight_transfer.factory import WeightTransferEngineFactory # Create an engine from config engine = WeightTransferEngineFactory.create_engine( config=weight_transfer_config, - parallel_config=parallel_config, + vllm_config=vllm_config, + device=device, model=model, ) ``` diff --git a/docs/training/weight_transfer/ipc.md b/docs/training/weight_transfer/ipc.md index 21fc8ad70da6..f76272d2cb0b 100644 --- a/docs/training/weight_transfer/ipc.md +++ b/docs/training/weight_transfer/ipc.md @@ -55,7 +55,7 @@ trainer_args = IPCTrainerSendWeightsArgs( llm_handle=llm_actor_handle, ) # start -ray.get(llm_actor_handle.start_weight_update.remote(is_checkpoint_format=True)) +ray.get(llm_actor_handle.start_weight_update.remote()) # send weights IPCWeightTransferEngine.trainer_send_weights( iterator=model.named_parameters(), @@ -80,7 +80,7 @@ trainer_args = IPCTrainerSendWeightsArgs( # start base_url = "http://localhost:8000" url = f"{base_url}/start_weight_update" -response = requests.post(url, json={"is_checkpoint_format": True}, timeout=60) +response = requests.post(url, json={}, timeout=60) response.raise_for_status() # send weights IPCWeightTransferEngine.trainer_send_weights( diff --git a/docs/training/weight_transfer/nccl.md b/docs/training/weight_transfer/nccl.md index 7b531218568b..481b7c5f28e0 100644 --- a/docs/training/weight_transfer/nccl.md +++ b/docs/training/weight_transfer/nccl.md @@ -11,7 +11,7 @@ The NCCL weight transfer engine uses [NCCL](https://developer.nvidia.com/nccl) b ## How It Works 1. The trainer and all inference workers join a shared NCCL process group using `StatelessProcessGroup` (vLLM's torch.distributed-independent group abstraction). -2. The trainer broadcasts weights to all workers simultaneously. Each worker receives and loads weights incrementally. +2. The trainer broadcasts weights to all workers simultaneously. Each worker receives and loads the weights. 3. Optionally, **packed tensor broadcasting** batches multiple small tensors into larger buffers with double/triple buffering and CUDA stream overlap for higher throughput. This implementation is based on [NeMo-RL's packed tensor](https://github.com/NVIDIA-NeMo/RL/blob/main/nemo_rl/utils/packed_tensor.py). ## Initialization @@ -93,7 +93,7 @@ remaining three steps are: from vllm.distributed.weight_transfer.base import WeightTransferUpdateRequest # 1. Start the weight update -llm.start_weight_update(is_checkpoint_format=True) +llm.start_weight_update() # 2. Receive weights (can be called multiple times for chunked transfers) llm.update_weights( @@ -116,19 +116,22 @@ must match the order in which the trainer iterates over its parameters. `start_weight_update` must be called before `update_weights`, and `finish_weight_update` must be called after all weight chunks have been -transferred. The `is_checkpoint_format` flag controls whether layerwise reload -processing is applied (`True` for checkpoint-format weights, `False` for -pre-processed kernel-format weights). +transferred. The NCCL engine receives checkpoint-format weights and applies +layerwise reload processing automatically inside `start_weight_update` / +`finish_weight_update`. -Sparse NCCL patches still use `update_kind="sparse_flat"` inside -`update_info`, but they should be wrapped in -`start_weight_update(is_checkpoint_format=False)` because sparse patches apply -directly to runtime/kernel-format parameters. The current sparse MVP requires -`TP=1` and `PP=1`. +## Sparse NCCL + +Sparse, flat-index weight patches use a separate backend, +`WeightTransferConfig(backend="sparse_nccl")`, implemented by +`SparseNCCLWeightTransferEngine`. It shares only NCCL process-group +initialization with the dense engine; patches are applied directly in place to +existing parameters (no layerwise reload). The current sparse MVP requires +`TP=1` and `PP=1`. See the example below. ## Examples - [RLHF with NCCL weight syncing (offline, Ray)](../../../examples/rl/rlhf_nccl.py) - Trainer on one GPU, 2x tensor-parallel vLLM engine on two others, with packed NCCL weight broadcast -- [RLHF with sparse NCCL weight syncing (offline, Ray)](../../../examples/rl/rlhf_sparse_nccl.py) - Dense-vs-sparse equivalence demo with a real model on a 2-GPU trainer/inference setup; sparse patches use `start_weight_update(is_checkpoint_format=False)` and currently require `TP=1` and `PP=1` +- [RLHF with sparse NCCL weight syncing (offline, Ray)](../../../examples/rl/rlhf_sparse_nccl.py) - Dense-vs-sparse equivalence demo with a real model on a 2-GPU trainer/inference setup; sparse patches use `backend="sparse_nccl"` and currently require `TP=1` and `PP=1` - [RLHF with async weight syncing (offline, Ray)](../../../examples/rl/rlhf_async_new_apis.py) - Async generation with mid-flight pause, weight sync, resume, and validation against a fresh model - [RLHF with NCCL weight syncing (online serving, HTTP)](../../../examples/rl/rlhf_http_nccl.py) - Weight transfer with a running vLLM HTTP server using HTTP control plane and NCCL data plane diff --git a/docs/usage/security.md b/docs/usage/security.md index 1cc91c3a8a90..d222155b7709 100644 --- a/docs/usage/security.md +++ b/docs/usage/security.md @@ -85,6 +85,21 @@ significantly reduce the attack surface for these types of abuse. Also, consider setting `VLLM_MEDIA_URL_ALLOW_REDIRECTS=0` to prevent HTTP redirects from being followed to bypass domain restrictions. +### 5. **Restrict Media Decode Sizes:** + +Compressed media files can expand into gigabytes of memory during decoding. vLLM +enforces decode-size limits to prevent out-of-memory denial of service: + +| Environment Variable | Default | Description | +| --- | --- | --- | +| `VLLM_MAX_IMAGE_PIXELS` | `178956970` (~179M pixels) | Maximum decoded image size in pixels. Images exceeding this are rejected before raster memory is allocated. Default matches PIL's built-in 2x decompression-bomb threshold (~680 MB for RGB). | +| `VLLM_MAX_AUDIO_CLIP_FILESIZE_MB` | `25` | Maximum filesize in MB for a single audio file. | +| `VLLM_MAX_AUDIO_DECODE_DURATION_S` | `600` | Maximum decoded audio duration in seconds. Prevents compressed audio from expanding into gigabytes of float32 PCM. | + +Setting any of these to `0` disables the corresponding limit. This is **not +recommended** for deployments exposed to untrusted users, as it removes the +protection against resource-exhaustion attacks. + ## Security and Firewalls: Protecting Exposed vLLM Systems While vLLM is designed to allow unsafe network services to be isolated to @@ -311,6 +326,40 @@ vLLM supports dynamically loading and unloading LoRA adapters at runtime via the **Warning:** Dynamic LoRA loading is not a secure operation and should not be enabled in deployments exposed to untrusted clients. If you must enable dynamic LoRA loading, restrict access to the `/v1/load_lora_adapter` and `/v1/unload_lora_adapter` endpoints to trusted administrators only, using a reverse proxy or network-level access controls. Do not expose these endpoints to end users. For details on configuring LoRA adapters, see the [LoRA Adapters documentation](../features/lora.md). +## Endpoint Plugins + +vLLM supports loading out-of-tree HTTP routes via the `vllm.endpoint_plugins` entry point group (see [Endpoint Plugins](../design/endpoint_plugins.md) for how to write one). An endpoint plugin can register arbitrary FastAPI routes, including routes that reach the engine via `EngineClient.collective_rpc`, so it must be treated as part of the server's trusted code base and not as sandboxed or reviewed input. + +**Endpoint plugins are not loaded by default.** Unlike other vLLM plugin groups (`vllm.general_plugins`, `vllm.platform_plugins`, etc.), which load every discovered plugin unless `VLLM_PLUGINS` narrows the set, endpoint plugins load **none** unless `VLLM_PLUGINS` is set and explicitly names them. This mirrors the "off by default in production" posture used for development endpoints gated behind `VLLM_SERVER_DEV_MODE`. Both surfaces are only present when an operator has explicitly opted in. + +### Recommended Security Practices + +1. **Only allowlist plugins you trust.** Set `VLLM_PLUGINS` to the exact plugin names you intend to run and never wildcard or copy an allowlist between deployments without reviewing what each named plugin does. +2. **Audit routes before deploying.** A plugin's `attach_router` can add routes under any path, including ones that duplicate existing `/v1/*` paths. There is currently no route conflict enforcement (tracked as a follow-up to RFC [#46565](https://github.com/vllm-project/vllm/issues/46565)), so a malicious or buggy plugin can **shadow a core route** and silently replace its behavior. Prefer plugins that namespace their routes under a distinct prefix (e.g. `/plugins//...`) instead of reusing `/v1/...` and review `app.routes` after startup if you need certainty about what is actually being served. +3. **Treat plugin routes like any other unauthenticated by default surface.** `--api-key` only protects the `/v1`, `/v2`, and `/inference` path prefixes (see [API Key Authentication Limitations](#api-key-authentication-limitations)). A plugin route outside those prefixes is unauthenticated unless the plugin implements its own authentication. Deploy behind a reverse proxy that allowlists only the plugin routes you intend to expose externally. +4. **Remember the `vllm.general_plugins` pairing.** A plugin that also needs new engine side behavior ships that half separately via `vllm.general_plugins` which loads in every worker process under the default (load all unless restricted) posture. Allowlisting the endpoint plugin does not by itself restrict its paired engine side plugin. Need to review both. + +## gRPC Interface + +vLLM provides an optional gRPC Generate service on a separate TCP port, enabled via the `--grpc-port` flag. When not specified, no gRPC server is started. The gRPC listener binds to the same host address as the HTTP server. + +**Warning:** The gRPC interface is **insecure by default** — it does not implement authentication, authorization, or encryption. It should be considered a private, internal interface intended for use only between co-located services within a trusted network. Do not expose the gRPC port to the public internet or untrusted clients. If you enable the gRPC interface, protect it via network-level access controls such as firewall rules, network segmentation, or deployment on an isolated private network. + +### Security Implications + +An attacker who can reach the gRPC port can: + +1. **Run arbitrary inference** via the `Generate` and `GenerateStream` RPCs without any credentials +2. **Consume GPU and compute resources** by submitting unbounded generation requests +3. **Cause Denial of Service** by exploiting bugs in the gRPC interface that can crash vLLM. + +### Recommendations + +- Only enable `--grpc-port` when you have a specific need for gRPC-based inference +- Ensure the gRPC port is only accessible from trusted hosts or services +- Use firewall rules to block external access to the gRPC port +- Consider deploying the gRPC interface on a dedicated internal network interface + ## Cache Directory Security vLLM assumes that its cache directories are **private and trusted**. Cache contents are loaded without cryptographic integrity verification, including formats that support arbitrary code execution. If an untrusted user or process can write to vLLM's cache directories, they may be able to crash vLLM or cause it to execute arbitrary code. diff --git a/docs/usage/v1_guide.md b/docs/usage/v1_guide.md index 74d7e3eb2b03..5613d5ba4e85 100644 --- a/docs/usage/v1_guide.md +++ b/docs/usage/v1_guide.md @@ -125,10 +125,10 @@ We are working on enabling prefix caching and chunked prefill for more categorie Models using selective state-space mechanisms instead of standard transformer attention are supported. Models that use Mamba-2 and Mamba-1 layers (e.g., `Mamba2ForCausalLM`, `MambaForCausalLM`, `FalconMambaForCausalLM`) are supported. -Hybrid models that combine Mamba-2 and Mamba-1 layers with standard attention layers are also supported (e.g., `BambaForCausalLM`, +Hybrid models that combine Mamba-2 and Mamba-1 layers with standard attention layers are also supported (e.g., `Zamba2ForCausalLM`, `NemotronHForCausalLM`, `FalconH1ForCausalLM` and `GraniteMoeHybridForCausalLM`, `JambaForCausalLM`, `Plamo2ForCausalLM`). -Hybrid models with mechanisms different to Mamba are also supported (e.g, `MiniMaxText01ForCausalLM`, `MiniMaxM1ForCausalLM`, `Lfm2ForCausalLM`). +Hybrid models with mechanisms different to Mamba are also supported (e.g, `Lfm2ForCausalLM`). Please note that prefix caching is not yet supported for any of the above models. diff --git a/tests/entrypoints/offline_mode/__init__.py b/examples/__init__.py similarity index 100% rename from tests/entrypoints/offline_mode/__init__.py rename to examples/__init__.py diff --git a/examples/applications/chatbot/api_client.py b/examples/applications/api_server/client.py similarity index 94% rename from examples/applications/chatbot/api_client.py rename to examples/applications/api_server/client.py index 84854911bade..89207d854c90 100644 --- a/examples/applications/chatbot/api_client.py +++ b/examples/applications/api_server/client.py @@ -1,8 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Example Python client for `vllm.entrypoints.api_server` +"""Example Python client for `examples/applications/api_server/server.py` Start the demo server: - python -m vllm.entrypoints.api_server --model + python examples/applications/api_server/server.py --model NOTE: The API server is used only for demonstration and simple performance benchmarks. It is not intended for production use. diff --git a/vllm/entrypoints/api_server.py b/examples/applications/api_server/server.py similarity index 98% rename from vllm/entrypoints/api_server.py rename to examples/applications/api_server/server.py index 7512723515e0..adac4133210e 100644 --- a/vllm/entrypoints/api_server.py +++ b/examples/applications/api_server/server.py @@ -22,7 +22,7 @@ from vllm.engine.arg_utils import AsyncEngineArgs from vllm.engine.async_llm_engine import AsyncLLMEngine from vllm.entrypoints.launcher import serve_http -from vllm.entrypoints.utils import with_cancellation +from vllm.entrypoints.serve.utils.api_utils import with_cancellation from vllm.logger import init_logger from vllm.sampling_params import SamplingParams from vllm.usage.usage_lib import UsageContext @@ -31,7 +31,7 @@ from vllm.utils.system_utils import set_ulimit from vllm.version import __version__ as VLLM_VERSION -logger = init_logger("vllm.entrypoints.api_server") +logger = init_logger("api_server") app = FastAPI() engine = None diff --git a/examples/applications/chatbot/gradio_webserver.py b/examples/applications/chatbot/gradio_webserver.py index f75636409c2f..005bb7c68c9a 100644 --- a/examples/applications/chatbot/gradio_webserver.py +++ b/examples/applications/chatbot/gradio_webserver.py @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Example for starting a Gradio Webserver Start vLLM API server: - python -m vllm.entrypoints.api_server \ + python examples/applications/api_server/server.py \ --model meta-llama/Llama-2-7b-chat-hf Start Webserver: diff --git a/examples/disaggregated/disaggregated_prefill.py b/examples/disaggregated/disaggregated_prefill.py deleted file mode 100644 index f619fa584f80..000000000000 --- a/examples/disaggregated/disaggregated_prefill.py +++ /dev/null @@ -1,127 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -This file demonstrates the example usage of disaggregated prefilling -We will launch 2 vllm instances (GPU 0 for prefill and GPU 1 for decode), -and then transfer the KV cache between them. -""" - -import os -import time -from multiprocessing import Event, Process - -from vllm import LLM, SamplingParams -from vllm.config import KVTransferConfig - - -def run_prefill(prefill_done): - # We use GPU 0 for prefill node. - os.environ["CUDA_VISIBLE_DEVICES"] = "0" - - # The prefill node receives two requests, while the decode node receives - # three requests. So the decode node will only receive the KV Cache for - # requests 1 and 3. The decode node will use the KV Cache of requests 1 - # and 3 and do prefilling on request 2. - prompts = [ - "Hello, my name is", - "Hi, your name is", - # The decode node will actually "prefill" this request. - "Tell me a very long story", - ] - sampling_params = SamplingParams(temperature=0, top_p=0.95, max_tokens=1) - - # Using P2pNcclConnector to transmit KV caches between vLLM instances. - # This instance is the prefill node (kv_producer, rank 0). - # The number of parallel instances for KV cache transfer is set to 2, - # as required for P2pNcclConnector. - ktc = KVTransferConfig( - kv_connector="P2pNcclConnector", - kv_role="kv_producer", - kv_rank=0, - kv_parallel_size=2, - ) - - # Set GPU memory utilization to 0.8 for an A6000 GPU with 40GB - # memory. You may need to adjust the value to fit your GPU. - llm = LLM( - model="meta-llama/Meta-Llama-3.1-8B-Instruct", - kv_transfer_config=ktc, - max_model_len=2000, - gpu_memory_utilization=0.8, - ) - - llm.generate(prompts, sampling_params) - print("Prefill node is finished.") - prefill_done.set() - - # To keep the prefill node running in case the decode node is not done; - # otherwise, the script might exit prematurely, causing incomplete decoding. - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - print("Script stopped by user.") - - -def run_decode(prefill_done): - # We use GPU 1 for decode node. - os.environ["CUDA_VISIBLE_DEVICES"] = "1" - - prompts = [ - "Hello, my name is", - "Hi, your name is", - "Tell me a very long story", - ] - sampling_params = SamplingParams(temperature=0, top_p=0.95) - - # Using P2pNcclConnector to transmit KV caches between vLLM instances. - # This instance is the decode node (kv_consumer, rank 1). - # The number of parallel instances for KV cache transfer is set to 2, - # as required for P2pNcclConnector. - ktc = KVTransferConfig( - kv_connector="P2pNcclConnector", - kv_role="kv_consumer", - kv_rank=1, - kv_parallel_size=2, - ) - - # Set GPU memory utilization to 0.8 for an A6000 GPU with 40GB - # memory. You may need to adjust the value to fit your GPU. - llm = LLM( - model="meta-llama/Meta-Llama-3.1-8B-Instruct", - kv_transfer_config=ktc, - max_model_len=2000, - gpu_memory_utilization=0.8, - ) - - # Wait for the producer to start the pipe - print("Waiting for prefill node to finish...") - prefill_done.wait() - - # At this point when the prefill_done is set, the kv-cache should have been - # transferred to this decode node, so we can start decoding. - outputs = llm.generate(prompts, sampling_params) - for output in outputs: - prompt = output.prompt - generated_text = output.outputs[0].text - print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}") - - -def main(): - prefill_done = Event() - prefill_process = Process(target=run_prefill, args=(prefill_done,)) - decode_process = Process(target=run_decode, args=(prefill_done,)) - - # Start prefill node - prefill_process.start() - - # Start decode node - decode_process.start() - - # Terminate the prefill node when decode is finished - decode_process.join() - prefill_process.terminate() - - -if __name__ == "__main__": - main() diff --git a/examples/disaggregated/disaggregated_prefill.sh b/examples/disaggregated/disaggregated_prefill.sh deleted file mode 100644 index 3022711d7e12..000000000000 --- a/examples/disaggregated/disaggregated_prefill.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/bin/bash -# This file demonstrates the example usage of disaggregated prefilling -# We will launch 2 vllm instances (1 for prefill and 1 for decode), -# and then transfer the KV cache between them. - -set -xe - -echo "🚧🚧 Warning: The usage of disaggregated prefill is experimental and subject to change 🚧🚧" -sleep 1 - -# meta-llama/Meta-Llama-3.1-8B-Instruct or deepseek-ai/DeepSeek-V2-Lite -MODEL_NAME=${HF_MODEL_NAME:-meta-llama/Meta-Llama-3.1-8B-Instruct} - -# Trap the SIGINT signal (triggered by Ctrl+C) -trap 'cleanup' INT - -# Cleanup function -cleanup() { - echo "Caught Ctrl+C, cleaning up..." - # Cleanup commands - pgrep python | xargs kill -9 - pkill -f python - echo "Cleanup complete. Exiting." - exit 0 -} - - -if [[ -z "${VLLM_HOST_IP:-}" ]]; then - export VLLM_HOST_IP=127.0.0.1 - echo "Using default VLLM_HOST_IP=127.0.0.1 (override by exporting VLLM_HOST_IP before running this script)" -else - echo "Using provided VLLM_HOST_IP=${VLLM_HOST_IP}" -fi - - -# install quart first -- required for disagg prefill proxy serve -if python3 -c "import quart" &> /dev/null; then - echo "Quart is already installed." -else - echo "Quart is not installed. Installing..." - python3 -m pip install quart -fi - -# a function that waits vLLM server to start -wait_for_server() { - local port=$1 - timeout 1200 bash -c " - until curl -i localhost:${port}/v1/models > /dev/null; do - sleep 1 - done" && return 0 || return 1 -} - - -# You can also adjust --kv-ip and --kv-port for distributed inference. - -# prefilling instance, which is the KV producer -CUDA_VISIBLE_DEVICES=0 vllm serve "$MODEL_NAME" \ - --host 0.0.0.0 \ - --port 8100 \ - --max-model-len 100 \ - --gpu-memory-utilization 0.8 \ - --trust-remote-code \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_producer","kv_rank":0,"kv_parallel_size":2,"kv_buffer_size":"1e9","kv_port":"14579","kv_connector_extra_config":{"proxy_ip":"'"$VLLM_HOST_IP"'","proxy_port":"30001","http_ip":"'"$VLLM_HOST_IP"'","http_port":"8100","send_type":"PUT_ASYNC"}}' & - -# decoding instance, which is the KV consumer -CUDA_VISIBLE_DEVICES=1 vllm serve "$MODEL_NAME" \ - --host 0.0.0.0 \ - --port 8200 \ - --max-model-len 100 \ - --gpu-memory-utilization 0.8 \ - --trust-remote-code \ - --kv-transfer-config \ - '{"kv_connector":"P2pNcclConnector","kv_role":"kv_consumer","kv_rank":1,"kv_parallel_size":2,"kv_buffer_size":"1e10","kv_port":"14580","kv_connector_extra_config":{"proxy_ip":"'"$VLLM_HOST_IP"'","proxy_port":"30001","http_ip":"'"$VLLM_HOST_IP"'","http_port":"8200","send_type":"PUT_ASYNC"}}' & - -# wait until prefill and decode instances are ready -wait_for_server 8100 -wait_for_server 8200 - -# launch a proxy server that opens the service at port 8000 -# the workflow of this proxy: -# - send the request to prefill vLLM instance (port 8100), change max_tokens -# to 1 -# - after the prefill vLLM finishes prefill, send the request to decode vLLM -# instance -# NOTE: the usage of this API is subject to change --- in the future we will -# introduce "vllm connect" to connect between prefill and decode instances -python3 ../../benchmarks/disagg_benchmarks/disagg_prefill_proxy_server.py & -sleep 1 - -# serve two example requests -output1=$(curl -X POST -s http://localhost:8000/v1/completions \ --H "Content-Type: application/json" \ --d '{ -"model": "'"$MODEL_NAME"'", -"prompt": "San Francisco is a", -"max_tokens": 10, -"temperature": 0 -}') - -output2=$(curl -X POST -s http://localhost:8000/v1/completions \ --H "Content-Type: application/json" \ --d '{ -"model": "'"$MODEL_NAME"'", -"prompt": "Santa Clara is a", -"max_tokens": 10, -"temperature": 0 -}') - - -# Cleanup commands -pgrep python | xargs kill -9 -pkill -f python - -echo "" - -sleep 1 - -# Print the outputs of the curl requests -echo "" -echo "Output of first request: $output1" -echo "Output of second request: $output2" - -echo "🎉🎉 Successfully finished 2 test requests! 🎉🎉" -echo "" diff --git a/examples/disaggregated/disaggregated_serving/disagg_proxy_multiturn.py b/examples/disaggregated/disaggregated_serving/disagg_proxy_multiturn.py index 24d90eab0292..cc1cc402d29e 100644 --- a/examples/disaggregated/disaggregated_serving/disagg_proxy_multiturn.py +++ b/examples/disaggregated/disaggregated_serving/disagg_proxy_multiturn.py @@ -35,12 +35,36 @@ the JSON body) to scope the KV cache across turns. Without it, the proxy cannot link turns and falls back to no-cache behavior. + ``conversation_id`` is a non-standard extension to the OpenAI Chat + Completions schema, consumed by this proxy and not forwarded to the + vLLM engine. Strict OpenAI-compatible frontends reject unknown + fields, so clients must opt in only when targeting this proxy. + Usage: python disagg_proxy_multiturn.py \\ --host 0.0.0.0 --port 8000 \\ --prefiller-host 10.0.0.1 --prefiller-port 8100 \\ --decoder-host 10.0.0.2 --decoder-port 8200 +Benchmarking: + Use ``benchmarks/multi_turn/benchmark_serving_multi_turn.py`` with + the ``--send-conversation-id`` flag to inject a per-conversation + ``conversation_id`` into every request so this proxy can key + cross-turn KV cache reuse. The flag is *off by default*: without + it the benchmark sends OpenAI-schema-compliant payloads and every + turn lands as a cache MISS in this proxy. + + Example: + python benchmarks/multi_turn/benchmark_serving_multi_turn.py \\ + --model --served-model-name \\ + --url http://:8000 \\ + --input-file generate_multi_turn.json \\ + --num-clients 2 --max-active-conversations 6 \\ + --send-conversation-id + + See ``docs/features/nixl_connector_usage.md`` for the broader + bidirectional-KV-transfer setup these benchmarks exercise. + Dependencies: pip install fastapi uvicorn httpx """ @@ -373,7 +397,9 @@ async def _handle_request(api_path: str, request: Request): logger.warning( "[%s] No conversation_id provided — KV cache reuse disabled " "for this request. Add a 'conversation_id' field to enable " - "cross-turn KV sharing.", + "cross-turn KV sharing. When using " + "benchmarks/multi_turn/benchmark_serving_multi_turn.py, pass " + "--send-conversation-id (off by default).", request_id, ) diff --git a/examples/disaggregated/disaggregated_serving/disagg_proxy_pushconnector_demo.py b/examples/disaggregated/disaggregated_serving/disagg_proxy_pushconnector_demo.py new file mode 100644 index 000000000000..672162f8ba34 --- /dev/null +++ b/examples/disaggregated/disaggregated_serving/disagg_proxy_pushconnector_demo.py @@ -0,0 +1,440 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Push-mode disaggregated prefilling proxy demo. + +Companion to ``disagg_proxy_demo.py`` (pull mode). The client-facing API is +the same; the difference is in how P and D coordinate the KV transfer: + +* Pull mode: proxy forwards P's ``kv_transfer_params`` (including + ``remote_block_ids``) to D, and D pulls KV from P via NIXL READ. +* Push mode: proxy hands D **only** P's coordinates + (``remote_engine_id``, ``remote_host``, ``remote_port``, ``tp_size``, + ``pp_size``) and the shared ``remote_request_id``. D registers its locally + allocated blocks with P over a NIXL notification; P then pushes the KV to D via + NIXL WRITE. + +Launch multiple vLLM instances configured with ``NixlPushConnector`` and +matching ``engine_id`` / ``side_channel_port``, then start this proxy: + + python3 examples/disaggregated/disaggregated_serving/\ +disagg_proxy_pushconnector_demo.py \ + --model $model_name \ + --prefill localhost:8100 \ + --decode localhost:8200 \ + --prefill-engine-id prefill-engine-001 \ + --prefill-kv-host 10.0.0.1 \ + --prefill-side-channel-port 5600 \ + --prefill-tp-size 1 \ + --prefill-pp-size 1 \ + --port 8000 +""" + +import argparse +import contextlib +import ipaddress +import itertools +import json +import logging +import os +import sys +import uuid +from abc import ABC, abstractmethod +from collections.abc import Callable + +import aiohttp +import uvicorn +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse + +AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(total=6 * 60 * 60) +logger = logging.getLogger() +logging.basicConfig(level=logging.INFO) + + +class SchedulingPolicy(ABC): + @abstractmethod + def schedule(self, cycler: itertools.cycle): + raise NotImplementedError("Scheduling Proxy is not set.") + + +class RoundRobinSchedulingPolicy(SchedulingPolicy): + def schedule(self, cycler: itertools.cycle) -> str: + return next(cycler) + + +class PushProxy: + """Push-mode proxy. + + The structure mirrors the pull-mode ``Proxy`` in + ``disagg_proxy_demo.py``: an APIRouter with ``/v1/completions``, + ``/v1/chat/completions``, ``/status`` and ``/instances/add``, plus + round-robin scheduling across multiple P / D instances. + + Push-specific differences are confined to the request-handling + methods (``create_completion`` / ``create_chat_completion``): + + * D's ``kv_transfer_params`` is built from CLI-provided P + coordinates instead of being derived from P's response. + * P and D requests are issued concurrently — D registers blocks and + waits while P prefills and pushes. + """ + + def __init__( + self, + prefill_instances: list[str], + decode_instances: list[str], + model: str, + scheduling_policy: SchedulingPolicy, + prefill_engine_id: str, + prefill_kv_host: str, + prefill_side_channel_port: int, + prefill_tp_size: int, + prefill_pp_size: int, + custom_create_completion: Callable[[Request], StreamingResponse] | None = None, + custom_create_chat_completion: Callable[[Request], StreamingResponse] + | None = None, + ): + self.prefill_instances = prefill_instances + self.decode_instances = decode_instances + self.prefill_cycler = itertools.cycle(prefill_instances) + self.decode_cycler = itertools.cycle(decode_instances) + self.model = model + self.scheduling_policy = scheduling_policy + + # Push-mode metadata: D needs P's coordinates up-front. Pull mode + # learns these from P's response; push mode uses CLI args because + # D issues its registration before P responds. + self.push_metadata = { + "do_remote_decode": False, + "do_remote_prefill": True, + "remote_engine_id": prefill_engine_id, + "remote_host": prefill_kv_host, + "remote_port": prefill_side_channel_port, + "tp_size": prefill_tp_size, + "pp_size": prefill_pp_size, + } + + self.custom_create_completion = custom_create_completion + self.custom_create_chat_completion = custom_create_chat_completion + self.router = APIRouter() + self.setup_routes() + + # ── routes ──────────────────────────────────────────────────────── # + + def setup_routes(self): + self.router.post( + "/v1/completions", dependencies=[Depends(self.validate_json_request)] + )( + self.custom_create_completion + if self.custom_create_completion + else self.create_completion + ) + self.router.post( + "/v1/chat/completions", dependencies=[Depends(self.validate_json_request)] + )( + self.custom_create_chat_completion + if self.custom_create_chat_completion + else self.create_chat_completion + ) + self.router.get("/status", response_class=JSONResponse)(self.get_status) + + async def validate_json_request(self, raw_request: Request): + content_type = raw_request.headers.get("content-type", "").lower() + if content_type != "application/json": + raise HTTPException( + status_code=415, + detail="Unsupported Media Type: Only 'application/json' is allowed", + ) + + # ── HTTP forwarding ─────────────────────────────────────────────── # + + async def forward_request(self, url, data, headers, use_chunked=True): + async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session: + try: + async with session.post( + url=url, json=data, headers=headers + ) as response: + if 200 <= response.status < 300 or 400 <= response.status < 500: + if use_chunked: + async for chunk_bytes in response.content.iter_chunked( + 1024 + ): + yield chunk_bytes + else: + yield await response.read() + else: + error_content = await response.text() + with contextlib.suppress(json.JSONDecodeError): + error_content = json.loads(error_content) + logger.error( + "Request failed with status %s: %s", + response.status, + error_content, + ) + raise HTTPException( + status_code=response.status, + detail=f"Request failed with status {response.status}: " + f"{error_content}", + ) + except aiohttp.ClientError as e: + logger.error("ClientError occurred: %s", str(e)) + raise HTTPException( + status_code=502, + detail="Bad Gateway: Error communicating with upstream server.", + ) from e + except Exception as e: + logger.error("Unexpected error: %s", str(e)) + raise HTTPException(status_code=500, detail=str(e)) from e + + def schedule(self, cycler: itertools.cycle) -> str: + return self.scheduling_policy.schedule(cycler) + + async def get_status(self): + return { + "mode": "push", + "prefill_node_count": len(self.prefill_instances), + "decode_node_count": len(self.decode_instances), + "prefill_nodes": self.prefill_instances, + "decode_nodes": self.decode_instances, + "prefill_engine_id": self.push_metadata["remote_engine_id"], + "prefill_kv_host": self.push_metadata["remote_host"], + "prefill_side_channel_port": self.push_metadata["remote_port"], + "prefill_tp_size": self.push_metadata["tp_size"], + "prefill_pp_size": self.push_metadata["pp_size"], + } + + # ── push-mode request handling ──────────────────────────────────── # + + def _build_decode_kv_params(self, request_id: str) -> dict: + """Push-mode kv_transfer_params for D. + + ``remote_block_ids`` is intentionally omitted: D allocates its + own blocks and registers them with P; P determines the + prefill-side block IDs and ships them via the WRITE. + """ + params = self.push_metadata.copy() + params["remote_request_id"] = request_id + return params + + def _common_headers(self, request_id: str) -> dict: + h = {"X-Request-Id": request_id} + api_key = os.environ.get("OPENAI_API_KEY") + if api_key: + h["Authorization"] = f"Bearer {api_key}" + return h + + async def _push_completion(self, raw_request: Request, path: str): + """Shared body for /v1/completions and /v1/chat/completions. + + Push mode fires P and D concurrently: + * P runs a normal prefill (max_tokens=1, do_remote_decode=True). + * D runs the decode (do_remote_prefill=True, no remote_block_ids). + + D blocks waiting for P's WRITE; the response streamed back to the + client is the decode output from D. + """ + request = await raw_request.json() + request_id = str(uuid.uuid4()) + + # Prefill leg (max_tokens=1, signals P to keep KV around for D). + prefill_request = request.copy() + prefill_request["max_tokens"] = 1 + if "max_completion_tokens" in prefill_request: + prefill_request["max_completion_tokens"] = 1 + prefill_request["kv_transfer_params"] = { + "do_remote_decode": True, + "do_remote_prefill": False, + "remote_engine_id": None, + "remote_block_ids": None, + "remote_host": None, + "remote_port": None, + } + + # Decode leg (push mode: no remote_block_ids). + decode_request = request.copy() + decode_request["kv_transfer_params"] = self._build_decode_kv_params(request_id) + + prefill_instance = self.schedule(self.prefill_cycler) + decode_instance = self.schedule(self.decode_cycler) + headers = self._common_headers(request_id) + + # Fire prefill; we don't read its body but must drain the + # connection so the upstream server can free its slot. + async for _ in self.forward_request( + f"http://{prefill_instance}{path}", prefill_request, headers + ): + continue + + generator = self.forward_request( + f"http://{decode_instance}{path}", decode_request, headers + ) + return StreamingResponse(generator, media_type="application/json") + + async def create_completion(self, raw_request: Request): + try: + return await self._push_completion(raw_request, "/v1/completions") + except HTTPException: + raise + except Exception: + exc_info = sys.exc_info() + print("Error occurred in disagg push proxy server") + print(exc_info) + raise + + async def create_chat_completion(self, raw_request: Request): + try: + return await self._push_completion(raw_request, "/v1/chat/completions") + except HTTPException: + raise + except Exception: + exc_info = sys.exc_info() + error_messages = [str(e) for e in exc_info if e] + print("Error occurred in disagg push proxy server") + print(error_messages) + return StreamingResponse( + content=iter(error_messages), media_type="text/event-stream" + ) + + +class PushProxyServer: + def __init__( + self, + args: argparse.Namespace, + scheduling_policy: SchedulingPolicy | None = None, + create_completion: Callable[[Request], StreamingResponse] | None = None, + create_chat_completion: Callable[[Request], StreamingResponse] | None = None, + ): + self.validate_parsed_serve_args(args) + self.port = args.port + self.proxy_instance = PushProxy( + prefill_instances=[] if args.prefill is None else args.prefill, + decode_instances=[] if args.decode is None else args.decode, + model=args.model, + scheduling_policy=( + scheduling_policy + if scheduling_policy is not None + else RoundRobinSchedulingPolicy() + ), + prefill_engine_id=args.prefill_engine_id, + prefill_kv_host=args.prefill_kv_host, + prefill_side_channel_port=args.prefill_side_channel_port, + prefill_tp_size=args.prefill_tp_size, + prefill_pp_size=args.prefill_pp_size, + custom_create_completion=create_completion, + custom_create_chat_completion=create_chat_completion, + ) + + def validate_parsed_serve_args(self, args: argparse.Namespace): + if not args.prefill: + raise ValueError("Please specify at least one prefill node.") + if not args.decode: + raise ValueError("Please specify at least one decode node.") + if not args.prefill_engine_id: + raise ValueError( + "--prefill-engine-id is required in push mode (it must match " + "the engine_id passed to the prefill vLLM instance via " + "--kv-transfer-config)." + ) + if not args.prefill_kv_host: + raise ValueError( + "--prefill-kv-host is required in push mode (the IP / host " + "that the prefill vLLM advertises on its NIXL side channel)." + ) + self.validate_instances(args.prefill) + self.validate_instances(args.decode) + + def validate_instances(self, instances: list): + for instance in instances: + if len(instance.split(":")) != 2: + raise ValueError(f"Invalid instance format: {instance}") + host, port = instance.split(":") + try: + if host != "localhost": + ipaddress.ip_address(host) + port = int(port) + if not (0 < port < 65536): + raise ValueError(f"Invalid port number in instance: {instance}") + except Exception as e: + raise ValueError(f"Invalid instance {instance}: {str(e)}") from e + + def run_server(self): + app = FastAPI() + app.include_router(self.proxy_instance.router) + config = uvicorn.Config(app, port=self.port, loop="uvloop") + server = uvicorn.Server(config) + server.run() + + +def parse_args(): + parser = argparse.ArgumentParser("vLLM disaggregated push-mode proxy server.") + parser.add_argument("--model", "-m", type=str, required=True, help="Model name") + + parser.add_argument( + "--prefill", + "-p", + type=str, + nargs="+", + help="List of prefill node URLs (host:port)", + ) + + parser.add_argument( + "--decode", + "-d", + type=str, + nargs="+", + help="List of decode node URLs (host:port)", + ) + + parser.add_argument( + "--port", + type=int, + default=8000, + help="Server port number", + ) + + # Push-mode specific: P's coordinates that D needs in advance. + parser.add_argument( + "--prefill-engine-id", + type=str, + required=True, + help=( + "engine_id of the prefill vLLM instance (must match " + "--kv-transfer-config engine_id on the prefill server)" + ), + ) + parser.add_argument( + "--prefill-kv-host", + type=str, + required=True, + help=( + "IP / host the prefill vLLM advertises on its NIXL side " + "channel (VLLM_NIXL_SIDE_CHANNEL_HOST)" + ), + ) + parser.add_argument( + "--prefill-side-channel-port", + type=int, + default=5600, + help="NIXL side channel port on the prefill node " + "(VLLM_NIXL_SIDE_CHANNEL_PORT, default 5600)", + ) + parser.add_argument( + "--prefill-tp-size", + type=int, + default=1, + help="Tensor parallel size of the prefill vLLM instance", + ) + parser.add_argument( + "--prefill-pp-size", + type=int, + default=1, + help="Pipeline parallel size of the prefill vLLM instance", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + proxy_server = PushProxyServer(args=args) + proxy_server.run_server() diff --git a/examples/disaggregated/disaggregated_serving/moriio_toy_proxy_server.py b/examples/disaggregated/disaggregated_serving/moriio_toy_proxy_server.py index aceb7a9b81c3..07a462711d22 100644 --- a/examples/disaggregated/disaggregated_serving/moriio_toy_proxy_server.py +++ b/examples/disaggregated/disaggregated_serving/moriio_toy_proxy_server.py @@ -327,6 +327,9 @@ async def handle_request(api: str, request: Request): session, decode_response = await decode_request_task stream_generator = stream_decode_response(session, decode_response, request_id) response = await make_response(stream_generator) + response.headers["Content-Type"] = decode_response.headers.get( + "Content-Type", "application/json" + ) return response except Exception as e: logger.exception("An error occurred while handling the request: %s", e) diff --git a/examples/disaggregated/lmcache/README.md b/examples/disaggregated/lmcache/README.md index 759be55d6f1c..87fec8268424 100644 --- a/examples/disaggregated/lmcache/README.md +++ b/examples/disaggregated/lmcache/README.md @@ -1,10 +1,38 @@ # LMCache Examples -This folder demonstrates how to use LMCache for disaggregated prefilling, CPU offloading and KV cache sharing. +This folder demonstrates how to use LMCache with vLLM v1 for KV cache +offloading, disaggregated prefilling, and KV cache sharing. -## 1. Disaggregated Prefill in vLLM v1 +## Integration modes -This example demonstrates how to run LMCache with disaggregated prefill using NIXL on a single node. +LMCache integrates with vLLM v1 in two ways: + +- **In-process mode** (`LMCacheConnectorV1`): LMCache runs inside the vLLM + process and is configured through environment variables or a YAML config + file (`LMCACHE_CONFIG_FILE`). This is the simplest way to add single-node + CPU/disk offloading. +- **Multi-process (MP) mode** (`LMCacheMPConnector`): LMCache runs as a + standalone server (`lmcache server`) that owns the KV cache storage; one or + more vLLM instances connect to it. This is the recommended mode for + distributed KV storage and for sharing KV cache across instances. See the + [LMCache docs](https://docs.lmcache.ai) for the full MP setup. + +## 1. CPU offload (in-process) + +- `python cpu_offload_lmcache.py` - CPU offloading with `LMCacheConnectorV1` + for vLLM v1. + +## 2. CPU offload (multi-process) + +- `bash cpu_offload_lmcache_mp.sh` - CPU offloading with `LMCacheMPConnector`, + using a standalone `lmcache server`. vLLM provides a built-in shortcut for + this setup via `--kv-offloading-backend lmcache` and + `--kv-offloading-size `. + +## 3. Disaggregated Prefill in vLLM v1 + +This example demonstrates how to run LMCache with disaggregated prefill using +NIXL on a single node. ### Prerequisites @@ -46,15 +74,7 @@ The main script generates several log files: - `decoder.log` - Logs from the decode server - `proxy.log` - Logs from the proxy server -## 2. CPU Offload Examples - -- `python cpu_offload_lmcache.py -v v0` - CPU offloading implementation for vLLM v0 -- `python cpu_offload_lmcache.py -v v1` - CPU offloading implementation for vLLM v1 - -## 3. KV Cache Sharing - -The `kv_cache_sharing_lmcache_v1.py` example demonstrates how to share KV caches between vLLM v1 instances. - -## 4. Disaggregated Prefill in vLLM v0 +## 4. KV Cache Sharing -The `disaggregated_prefill_lmcache_v0.py` provides an example of how to run disaggregated prefill in vLLM v0. +The `kv_cache_sharing_lmcache_v1.py` example demonstrates how to share KV +caches between vLLM v1 instances through a centralized LMCache server. diff --git a/examples/disaggregated/lmcache/cpu_offload_lmcache.py b/examples/disaggregated/lmcache/cpu_offload_lmcache.py index 53036b3eb0ff..b67a929e5d96 100644 --- a/examples/disaggregated/lmcache/cpu_offload_lmcache.py +++ b/examples/disaggregated/lmcache/cpu_offload_lmcache.py @@ -1,20 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """ -This file demonstrates the example usage of cpu offloading -with LMCache in vLLM v1 or v0. - -Usage: - - Specify vLLM version - - -v v0 : Use LMCacheConnector - model = mistralai/Mistral-7B-Instruct-v0.2 - (Includes enable_chunked_prefill = True) - - -v v1 : Use LMCacheConnectorV1 (default) - model = meta-llama/Meta-Llama-3.1-8B-Instruct - (Without enable_chunked_prefill) +This file demonstrates the example usage of CPU offloading +with LMCache in vLLM v1. Note that `lmcache` is needed to run this example. Requirements: @@ -23,7 +11,6 @@ https://docs.lmcache.ai/getting_started/installation.html """ -import argparse import contextlib import os import time @@ -39,8 +26,6 @@ def setup_environment_variables(): # LMCache-related environment variables - # Use experimental features in LMCache - os.environ["LMCACHE_USE_EXPERIMENTAL"] = "True" # LMCache is set to use 256 tokens per chunk os.environ["LMCACHE_CHUNK_SIZE"] = "256" # Enable local CPU backend in LMCache @@ -50,9 +35,9 @@ def setup_environment_variables(): @contextlib.contextmanager -def build_llm_with_lmcache(lmcache_connector: str, model: str): +def build_llm_with_lmcache(model: str): ktc = KVTransferConfig( - kv_connector=lmcache_connector, + kv_connector="LMCacheConnectorV1", kv_role="kv_both", ) # Set GPU memory utilization to 0.8 for an A40 GPU with 40GB @@ -92,23 +77,10 @@ def print_output( print("-" * 50) -def parse_args(): - parser = argparse.ArgumentParser() - parser.add_argument( - "-v", - "--version", - choices=["v0", "v1"], - default="v1", - help="Specify vLLM version (default: v1)", - ) - return parser.parse_args() - - def main(): - lmcache_connector = "LMCacheConnectorV1" model = "meta-llama/Meta-Llama-3.1-8B-Instruct" setup_environment_variables() - with build_llm_with_lmcache(lmcache_connector, model) as llm: + with build_llm_with_lmcache(model) as llm: # This example script runs two requests with a shared prefix. # Define the shared prompt and specific prompts shared_prompt = "Hello, how are you?" * 1000 diff --git a/examples/disaggregated/lmcache/cpu_offload_lmcache_mp.sh b/examples/disaggregated/lmcache/cpu_offload_lmcache_mp.sh new file mode 100755 index 000000000000..2372eabe1a85 --- /dev/null +++ b/examples/disaggregated/lmcache/cpu_offload_lmcache_mp.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# +# CPU offloading with LMCache in multi-process (MP) mode. +# +# In MP mode, LMCache runs as a standalone server process (`lmcache server`) +# that owns the KV cache storage. One or more vLLM instances connect to it via +# the `LMCacheMPConnector`. This is the recommended way to run LMCache for +# distributed KV storage and for sharing KV cache across vLLM instances. +# +# vLLM ships a built-in shortcut for this setup: pass `--kv-offloading-backend +# lmcache` together with `--kv-offloading-size ` and vLLM wires up the +# `LMCacheMPConnector` for you (it defaults to the LMCache server at +# tcp://localhost:5555, matching the `lmcache server` default). +# +# Requires `lmcache` to be installed (`pip install lmcache`). +# Learn more: https://docs.lmcache.ai +set -euo pipefail + +MODEL=${MODEL:-meta-llama/Meta-Llama-3.1-8B-Instruct} + +# 1. Launch the standalone LMCache server (binds tcp://localhost:5555 by +# default). `--l1-size-gb` sets the CPU memory budget for the L1 cache. +echo "Starting LMCache server..." +lmcache server --host localhost --port 5555 --l1-size-gb 5 & +LMCACHE_SERVER_PID=$! +trap 'kill $LMCACHE_SERVER_PID 2>/dev/null || true' EXIT + +# 2. Launch vLLM and offload KV cache to the LMCache server. +# The MP connector currently requires the non-hybrid KV cache manager. +echo "Starting vLLM server with LMCache MP offloading..." +vllm serve "$MODEL" \ + --port 8000 \ + --kv-offloading-size 5 \ + --kv-offloading-backend lmcache \ + --disable-hybrid-kv-cache-manager + +# Equivalent explicit configuration (instead of the two flags above): +# --kv-transfer-config \ +# '{"kv_connector":"LMCacheMPConnector","kv_role":"kv_both", +# "kv_connector_extra_config":{"lmcache.mp.host":"tcp://localhost", +# "lmcache.mp.port":5555}}' diff --git a/examples/disaggregated/lmcache/disagg_prefill_lmcache_v0.py b/examples/disaggregated/lmcache/disagg_prefill_lmcache_v0.py deleted file mode 100644 index 6669eb3fb3d3..000000000000 --- a/examples/disaggregated/lmcache/disagg_prefill_lmcache_v0.py +++ /dev/null @@ -1,144 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -This file demonstrates the example usage of disaggregated prefilling -with LMCache. -We will launch 2 vllm instances (GPU 0 for prefill and GPU 1 for decode), -and launch an additional LMCache server. -KV cache is transferred in the following manner: -vLLM prefill node -> LMCache server -> vLLM decode node. - -Note that `pip install lmcache` is needed to run this example. -Learn more about LMCache in https://github.com/LMCache/LMCache. -""" - -import os -import subprocess -import time -from multiprocessing import Event, Process - -from lmcache.experimental.cache_engine import LMCacheEngineBuilder -from lmcache.integration.vllm.utils import ENGINE_NAME - -from vllm import LLM, SamplingParams -from vllm.config import KVTransferConfig - -# LMCache-related environment variables -# The port to start LMCache server -port = 8100 -# Use experimental features in LMCache -os.environ["LMCACHE_USE_EXPERIMENTAL"] = "True" -# LMCache is set to use 256 tokens per chunk -os.environ["LMCACHE_CHUNK_SIZE"] = "256" -# Disable local CPU backend in LMCache -os.environ["LMCACHE_LOCAL_CPU"] = "False" -# Set local CPU memory buffer limit to 5.0 GB -os.environ["LMCACHE_MAX_LOCAL_CPU_SIZE"] = "5.0" -# Set the remote URL for LMCache server -os.environ["LMCACHE_REMOTE_URL"] = f"lm://localhost:{port}" -# Set the serializer/deserializer between vllm and LMCache server -# `naive` indicates using raw bytes of the tensor without any compression -os.environ["LMCACHE_REMOTE_SERDE"] = "naive" - -prompts = [ - "Hello, how are you?" * 1000, -] - - -def run_prefill(prefill_done, prompts): - # We use GPU 0 for prefill node. - os.environ["CUDA_VISIBLE_DEVICES"] = "0" - - sampling_params = SamplingParams(temperature=0, top_p=0.95, max_tokens=1) - - ktc = KVTransferConfig( - kv_connector="LMCacheConnector", - kv_role="kv_producer", - kv_rank=0, - kv_parallel_size=2, - ) - # Set GPU memory utilization to 0.8 for an A40 GPU with 40GB - # memory. Reduce the value if your GPU has less memory. - llm = LLM( - model="mistralai/Mistral-7B-Instruct-v0.2", - kv_transfer_config=ktc, - max_model_len=8000, - gpu_memory_utilization=0.8, - enforce_eager=True, - ) - - # llm.generate(prompts, sampling_params) - outputs = llm.generate(prompts, sampling_params) - for output in outputs: - generated_text = output.outputs[0].text - print(f"Generated text: {generated_text!r}") - print("Prefill node is finished.") - prefill_done.set() - - # Clean up lmcache backend - LMCacheEngineBuilder.destroy(ENGINE_NAME) - - -def run_decode(prefill_done, prompts, timeout=1): - # We use GPU 1 for decode node. - os.environ["CUDA_VISIBLE_DEVICES"] = "1" - - sampling_params = SamplingParams(temperature=0, top_p=0.95, max_tokens=10) - - ktc = KVTransferConfig( - kv_connector="LMCacheConnector", - kv_role="kv_consumer", - kv_rank=1, - kv_parallel_size=2, - ) - # Set GPU memory utilization to 0.8 for an A40 GPU with 40GB - # of memory. Reduce the value if your GPU has less memory. - llm = LLM( - model="mistralai/Mistral-7B-Instruct-v0.2", - kv_transfer_config=ktc, - max_model_len=8000, - gpu_memory_utilization=0.8, - enforce_eager=True, - ) - - print("Waiting for prefill node to finish...") - prefill_done.wait() - time.sleep(timeout) - - outputs = llm.generate(prompts, sampling_params) - for output in outputs: - generated_text = output.outputs[0].text - print(f"Generated text: {generated_text!r}") - - # Clean up lmcache backend - LMCacheEngineBuilder.destroy(ENGINE_NAME) - - -def run_lmcache_server(port): - server_proc = subprocess.Popen( - ["python", "-m", "lmcache.experimental.server", "localhost", str(port)] - ) - return server_proc - - -def main(): - prefill_done = Event() - prefill_process = Process(target=run_prefill, args=(prefill_done, prompts)) - decode_process = Process(target=run_decode, args=(prefill_done, prompts)) - lmcache_server_process = run_lmcache_server(port) - - # Start prefill node - prefill_process.start() - - # Start decode node - decode_process.start() - - # Clean up the processes - decode_process.join() - prefill_process.terminate() - lmcache_server_process.terminate() - lmcache_server_process.wait() - - -if __name__ == "__main__": - main() diff --git a/examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_vllm_launcher.sh b/examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_vllm_launcher.sh index 363c35028aaa..61e578460c4b 100644 --- a/examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_vllm_launcher.sh +++ b/examples/disaggregated/lmcache/disagg_prefill_lmcache_v1/disagg_vllm_launcher.sh @@ -30,7 +30,6 @@ if [[ $1 == "prefiller" ]]; then UCX_TLS=cuda_ipc,cuda_copy,tcp \ LMCACHE_CONFIG_FILE=$prefill_config_file \ - LMCACHE_USE_EXPERIMENTAL=True \ VLLM_ENABLE_V1_MULTIPROCESSING=1 \ VLLM_WORKER_MULTIPROC_METHOD=spawn \ CUDA_VISIBLE_DEVICES=0 \ @@ -47,7 +46,6 @@ elif [[ $1 == "decoder" ]]; then UCX_TLS=cuda_ipc,cuda_copy,tcp \ LMCACHE_CONFIG_FILE=$decode_config_file \ - LMCACHE_USE_EXPERIMENTAL=True \ VLLM_ENABLE_V1_MULTIPROCESSING=1 \ VLLM_WORKER_MULTIPROC_METHOD=spawn \ CUDA_VISIBLE_DEVICES=1 \ diff --git a/examples/disaggregated/lmcache/kv_cache_sharing_lmcache_v1.py b/examples/disaggregated/lmcache/kv_cache_sharing_lmcache_v1.py index 46e2d903d4be..489ff1321229 100644 --- a/examples/disaggregated/lmcache/kv_cache_sharing_lmcache_v1.py +++ b/examples/disaggregated/lmcache/kv_cache_sharing_lmcache_v1.py @@ -26,8 +26,6 @@ # LMCache-related environment variables # The port to start LMCache server port = 8100 -# Use experimental features in LMCache -os.environ["LMCACHE_USE_EXPERIMENTAL"] = "True" # LMCache is set to use 256 tokens per chunk os.environ["LMCACHE_CHUNK_SIZE"] = "256" # Disable local CPU backend in LMCache diff --git a/examples/disaggregated/p2p_nccl_xpyd/disagg_example_p2p_nccl_xpyd.sh b/examples/disaggregated/p2p_nccl_xpyd/disagg_example_p2p_nccl_xpyd.sh deleted file mode 100644 index 603f9eb915ef..000000000000 --- a/examples/disaggregated/p2p_nccl_xpyd/disagg_example_p2p_nccl_xpyd.sh +++ /dev/null @@ -1,245 +0,0 @@ -#!/bin/bash - -# ============================================================================= -# vLLM Disaggregated Serving Script - P2P NCCL XpYd Architecture -# ============================================================================= -# This script demonstrates disaggregated prefill and decode serving using -# P2P NCCL communication. The architecture supports various XpYd configurations: -# -# - 1P3D: 1 Prefill server + 3 Decode servers (current default) -# - 3P1D: 3 Prefill servers + 1 Decode server -# - etc. -# -# Configuration can be customized via environment variables: -# MODEL: Model to serve -# PREFILL_GPUS: Comma-separated GPU IDs for prefill servers -# DECODE_GPUS: Comma-separated GPU IDs for decode servers -# PREFILL_PORTS: Comma-separated ports for prefill servers -# DECODE_PORTS: Comma-separated ports for decode servers -# PROXY_PORT: Proxy server port used to setup XpYd connection. -# TIMEOUT_SECONDS: Server startup timeout -# ============================================================================= - -# Configuration - can be overridden via environment variables -MODEL=${MODEL:-meta-llama/Llama-3.1-8B-Instruct} -TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-1200} -PROXY_PORT=${PROXY_PORT:-30001} - -# Default 1P3D configuration (1 Prefill + 3 Decode) -PREFILL_GPUS=${PREFILL_GPUS:-0} -DECODE_GPUS=${DECODE_GPUS:-1,2,3} -PREFILL_PORTS=${PREFILL_PORTS:-20003} -DECODE_PORTS=${DECODE_PORTS:-20005,20007,20009} - -echo "Warning: P2P NCCL disaggregated prefill XpYd support for vLLM v1 is experimental and subject to change." -echo "" -echo "Architecture Configuration:" -echo " Model: $MODEL" -echo " Prefill GPUs: $PREFILL_GPUS, Ports: $PREFILL_PORTS" -echo " Decode GPUs: $DECODE_GPUS, Ports: $DECODE_PORTS" -echo " Proxy Port: $PROXY_PORT" -echo " Timeout: ${TIMEOUT_SECONDS}s" -echo "" - -PIDS=() - -# Switch to the directory of the current script -cd "$(dirname "${BASH_SOURCE[0]}")" - -check_required_files() { - local files=("disagg_proxy_p2p_nccl_xpyd.py") - for file in "${files[@]}"; do - if [[ ! -f "$file" ]]; then - echo "Required file $file not found in $(pwd)" - exit 1 - fi - done -} - -check_hf_token() { - if [ -z "$HF_TOKEN" ]; then - echo "HF_TOKEN is not set. Please set it to your Hugging Face token." - echo "Example: export HF_TOKEN=your_token_here" - exit 1 - fi - if [[ "$HF_TOKEN" != hf_* ]]; then - echo "HF_TOKEN is not a valid Hugging Face token. Please set it to your Hugging Face token." - exit 1 - fi - echo "HF_TOKEN is set and valid." -} - -check_num_gpus() { - # Check if the number of GPUs are >=2 via nvidia-smi - num_gpus=$(nvidia-smi --query-gpu=name --format=csv,noheader | wc -l) - if [ "$num_gpus" -lt 2 ]; then - echo "You need at least 2 GPUs to run disaggregated prefill." - exit 1 - else - echo "Found $num_gpus GPUs." - fi -} - -ensure_python_library_installed() { - echo "Checking if $1 is installed..." - if ! python3 -c "import $1" > /dev/null 2>&1; then - echo "$1 is not installed. Please install it via pip install $1." - exit 1 - else - echo "$1 is installed." - fi -} - -cleanup() { - echo "Stopping everything…" - trap - INT TERM # prevent re-entrancy - pkill -9 -f "disagg_proxy_p2p_nccl_xpyd.py" - kill -- -$$ # negative PID == "this whole process-group" - wait # reap children so we don't leave zombies - exit 0 -} - -wait_for_server() { - local port=$1 - local timeout_seconds=$TIMEOUT_SECONDS - local start_time=$(date +%s) - - echo "Waiting for server on port $port..." - - while true; do - if curl -s "localhost:${port}/v1/completions" > /dev/null; then - echo "Server on port $port is ready." - return 0 - fi - - local now=$(date +%s) - if (( now - start_time >= timeout_seconds )); then - echo "Timeout waiting for server on port $port" - return 1 - fi - - sleep 1 - done -} - -main() { - check_required_files - check_hf_token - check_num_gpus - ensure_python_library_installed pandas - ensure_python_library_installed datasets - ensure_python_library_installed vllm - ensure_python_library_installed quart - - trap cleanup INT - trap cleanup USR1 - trap cleanup TERM - - echo "Launching disaggregated serving components..." - echo "Please check the log files for detailed output:" - echo " - prefill*.log: Prefill server logs" - echo " - decode*.log: Decode server logs" - echo " - proxy.log: Proxy server log" - - # ============================================================================= - # Launch Proxy Server - # ============================================================================= - echo "" - echo "Starting proxy server on port $PROXY_PORT..." - python3 disagg_proxy_p2p_nccl_xpyd.py & - PIDS+=($!) - - # Parse GPU and port arrays - IFS=',' read -ra PREFILL_GPU_ARRAY <<< "$PREFILL_GPUS" - IFS=',' read -ra DECODE_GPU_ARRAY <<< "$DECODE_GPUS" - IFS=',' read -ra PREFILL_PORT_ARRAY <<< "$PREFILL_PORTS" - IFS=',' read -ra DECODE_PORT_ARRAY <<< "$DECODE_PORTS" - - # ============================================================================= - # Launch Prefill Servers (X Producers) - # ============================================================================= - echo "" - echo "Starting ${#PREFILL_GPU_ARRAY[@]} prefill server(s)..." - for i in "${!PREFILL_GPU_ARRAY[@]}"; do - local gpu_id=${PREFILL_GPU_ARRAY[$i]} - local port=${PREFILL_PORT_ARRAY[$i]} - local kv_port=$((21001 + i)) - - echo " Prefill server $((i+1)): GPU $gpu_id, Port $port, KV Port $kv_port" - CUDA_VISIBLE_DEVICES=$gpu_id vllm serve "$MODEL" \ - --enforce-eager \ - --host 0.0.0.0 \ - --port "$port" \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.9 \ - --kv-transfer-config \ - "{\"kv_connector\":\"P2pNcclConnector\",\"kv_role\":\"kv_producer\",\"kv_buffer_size\":\"1e1\",\"kv_port\":\"$kv_port\",\"kv_connector_extra_config\":{\"proxy_ip\":\"0.0.0.0\",\"proxy_port\":\"$PROXY_PORT\",\"http_port\":\"$port\",\"send_type\":\"PUT_ASYNC\",\"nccl_num_channels\":\"16\"}}" > prefill$((i+1)).log 2>&1 & - PIDS+=($!) - done - - # ============================================================================= - # Launch Decode Servers (Y Decoders) - # ============================================================================= - echo "" - echo "Starting ${#DECODE_GPU_ARRAY[@]} decode server(s)..." - for i in "${!DECODE_GPU_ARRAY[@]}"; do - local gpu_id=${DECODE_GPU_ARRAY[$i]} - local port=${DECODE_PORT_ARRAY[$i]} - local kv_port=$((22001 + i)) - - echo " Decode server $((i+1)): GPU $gpu_id, Port $port, KV Port $kv_port" - CUDA_VISIBLE_DEVICES=$gpu_id vllm serve "$MODEL" \ - --enforce-eager \ - --host 0.0.0.0 \ - --port "$port" \ - --tensor-parallel-size 1 \ - --seed 1024 \ - --dtype float16 \ - --max-model-len 10000 \ - --max-num-batched-tokens 10000 \ - --max-num-seqs 256 \ - --trust-remote-code \ - --gpu-memory-utilization 0.7 \ - --kv-transfer-config \ - "{\"kv_connector\":\"P2pNcclConnector\",\"kv_role\":\"kv_consumer\",\"kv_buffer_size\":\"8e9\",\"kv_port\":\"$kv_port\",\"kv_connector_extra_config\":{\"proxy_ip\":\"0.0.0.0\",\"proxy_port\":\"$PROXY_PORT\",\"http_port\":\"$port\",\"send_type\":\"PUT_ASYNC\",\"nccl_num_channels\":\"16\"}}" > decode$((i+1)).log 2>&1 & - PIDS+=($!) - done - - # ============================================================================= - # Wait for All Servers to Start - # ============================================================================= - echo "" - echo "Waiting for all servers to start..." - for port in "${PREFILL_PORT_ARRAY[@]}" "${DECODE_PORT_ARRAY[@]}"; do - if ! wait_for_server "$port"; then - echo "Failed to start server on port $port" - cleanup - # shellcheck disable=SC2317 - exit 1 - fi - done - - echo "" - echo "All servers are up. Starting benchmark..." - - # ============================================================================= - # Run Benchmark - # ============================================================================= - cd ../../../benchmarks/ - vllm bench serve --port 10001 --seed "$(date +%s)" \ - --model "$MODEL" \ - --dataset-name random --random-input-len 7500 --random-output-len 200 \ - --num-prompts 200 --burstiness 100 --request-rate 2 | tee benchmark.log - - echo "Benchmarking done. Cleaning up..." - - cleanup -} - -main diff --git a/examples/disaggregated/p2p_nccl_xpyd/disagg_proxy_p2p_nccl_xpyd.py b/examples/disaggregated/p2p_nccl_xpyd/disagg_proxy_p2p_nccl_xpyd.py deleted file mode 100644 index 0c7d32d7862e..000000000000 --- a/examples/disaggregated/p2p_nccl_xpyd/disagg_proxy_p2p_nccl_xpyd.py +++ /dev/null @@ -1,190 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import os -import socket -import threading -import time -import uuid -from typing import Any - -import aiohttp -import msgpack -import zmq -from quart import Quart, make_response, request - -count = 0 -prefill_instances: dict[str, Any] = {} # http_address: (zmq_address, stamp) -decode_instances: dict[str, Any] = {} # http_address: (zmq_address, stamp) - -prefill_cv = threading.Condition() -decode_cv = threading.Condition() - -DEFAULT_PING_SECONDS = 5 - - -def _remove_oldest_instances(instances: dict[str, Any]) -> None: - oldest_key = next(iter(instances), None) - while oldest_key is not None: - value = instances[oldest_key] - if value[1] > time.time(): - break - print(f"🔴Remove [HTTP:{oldest_key}, ZMQ:{value[0]}, stamp:{value[1]}]") - instances.pop(oldest_key, None) - oldest_key = next(iter(instances), None) - - -def _listen_for_register(poller, router_socket): - while True: - socks = dict(poller.poll()) - if router_socket in socks: - remote_address, message = router_socket.recv_multipart() - # data: {"type": "P", "http_address": "ip:port", - # "zmq_address": "ip:port"} - data = msgpack.loads(message) - if data["type"] == "P": - global prefill_instances - global prefill_cv - with prefill_cv: - node = prefill_instances.get(data["http_address"], None) - prefill_instances[data["http_address"]] = ( - data["zmq_address"], - time.time() + DEFAULT_PING_SECONDS, - ) - _remove_oldest_instances(prefill_instances) - - elif data["type"] == "D": - global decode_instances - global decode_cv - with decode_cv: - node = decode_instances.get(data["http_address"], None) - decode_instances[data["http_address"]] = ( - data["zmq_address"], - time.time() + DEFAULT_PING_SECONDS, - ) - _remove_oldest_instances(decode_instances) - else: - print( - "Unexpected, Received message from %s, data: %s", - remote_address, - data, - ) - return - - if node is None: - print(f"🔵Add [HTTP:{data['http_address']}, ZMQ:{data['zmq_address']}]") - - -def start_service_discovery(hostname, port): - if not hostname: - hostname = socket.gethostname() - if port == 0: - raise ValueError("Port cannot be 0") - - context = zmq.Context() - router_socket = context.socket(zmq.ROUTER) - router_socket.bind(f"tcp://{hostname}:{port}") - - poller = zmq.Poller() - poller.register(router_socket, zmq.POLLIN) - - _listener_thread = threading.Thread( - target=_listen_for_register, args=[poller, router_socket], daemon=True - ) - _listener_thread.start() - return _listener_thread - - -AIOHTTP_TIMEOUT = aiohttp.ClientTimeout(total=6 * 60 * 60) - -app = Quart(__name__) - - -def random_uuid() -> str: - return str(uuid.uuid4().hex) - - -async def forward_request(url, data, request_id): - async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session: - headers = { - "Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}", - "X-Request-Id": request_id, - } - async with session.post(url=url, json=data, headers=headers) as response: - if response.status == 200: - if True: - async for chunk_bytes in response.content.iter_chunked(1024): - yield chunk_bytes - else: - content = await response.read() - yield content - - -@app.route("/v1/completions", methods=["POST"]) -@app.route("/v1/chat/completions", methods=["POST"]) -async def handle_request(): - try: - original_request_data = await request.get_json() - - prefill_request = original_request_data.copy() - # change max_tokens = 1 to let it only do prefill - prefill_request["max_tokens"] = 1 - if "max_completion_tokens" in prefill_request: - prefill_request["max_completion_tokens"] = 1 - - global count - global prefill_instances - global prefill_cv - with prefill_cv: - prefill_list = list(prefill_instances.items()) - prefill_addr, prefill_zmq_addr = prefill_list[count % len(prefill_list)] - prefill_zmq_addr = prefill_zmq_addr[0] - - global decode_instances - global decode_cv - with decode_cv: - decode_list = list(decode_instances.items()) - decode_addr, decode_zmq_addr = decode_list[count % len(decode_list)] - decode_zmq_addr = decode_zmq_addr[0] - - print( - f"handle_request count: {count}, [HTTP:{prefill_addr}, " - f"ZMQ:{prefill_zmq_addr}] 👉 [HTTP:{decode_addr}, " - f"ZMQ:{decode_zmq_addr}]" - ) - count += 1 - - request_id = ( - f"___prefill_addr_{prefill_zmq_addr}___decode_addr_" - f"{decode_zmq_addr}_{random_uuid()}" - ) - - # finish prefill - async for _ in forward_request( - f"http://{prefill_addr}{request.path}", prefill_request, request_id - ): - continue - - # return decode - generator = forward_request( - f"http://{decode_addr}{request.path}", original_request_data, request_id - ) - response = await make_response(generator) - response.timeout = None - - return response - - except Exception as e: - import sys - import traceback - - exc_info = sys.exc_info() - print("Error occurred in disagg prefill proxy server") - print(e) - print("".join(traceback.format_exception(*exc_info))) - - -if __name__ == "__main__": - t = start_service_discovery("0.0.0.0", 30001) - app.run(host="0.0.0.0", port=10001) - t.join() diff --git a/examples/features/kv_events/kv_events_subscriber.py b/examples/features/kv_events/kv_events_subscriber.py index 0512297fcf4f..cfe131f000d3 100644 --- a/examples/features/kv_events/kv_events_subscriber.py +++ b/examples/features/kv_events/kv_events_subscriber.py @@ -17,9 +17,7 @@ class EventBatch(msgspec.Struct, array_like=True, omit_defaults=True, gc=False): events: list[Any] -class KVCacheEvent( - msgspec.Struct, array_like=True, omit_defaults=True, gc=False, tag=True -): +class KVCacheEvent(msgspec.Struct, omit_defaults=True, gc=False, tag=True): """Base class for all KV cache-related events""" @@ -101,7 +99,7 @@ def main(): replay.send((last_seq + 1).to_bytes(8, "big")) while poller.poll(timeout=200): - seq_bytes, replay_payload = replay.recv_multipart() + _, seq_bytes, replay_payload = replay.recv_multipart() if not replay_payload: # End of replay marker is sent as an empty frame # for the payload diff --git a/examples/features/logits_processor/top_n_sigma.py b/examples/features/logits_processor/top_n_sigma.py new file mode 100644 index 000000000000..c1d62864d63c --- /dev/null +++ b/examples/features/logits_processor/top_n_sigma.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""This example demonstrates implementing Top-n-sigma logit truncation as a +custom logits processor in vLLM. + +Top-n-sigma is a logit-space dynamic truncation method that uses the standard +deviation of the logit distribution to determine the filtering threshold: + + threshold = max_logit - n * std_logit + logits[logits < threshold] = -inf + +Unlike probability-space filters (top_p, min_p), Top-n-sigma operates before +softmax and adapts to the "peakiness" of the logit distribution: +- Sharp distribution (model is certain): small std -> narrow threshold -> + fewer candidates +- Flat distribution (model is uncertain): large std -> wide threshold -> + more candidates + +Usage: + + SamplingParams( + temperature=0.8, + extra_args={"top_n_sigma": 2.0} # n=2.0 standard deviations + ) + +For a basic example of implementing a custom logits processor, see +the `DummyLogitsProcessor` implementation in `custom.py`. + +A batch is constructed with alternating requests that do and don't use +top_n_sigma, demonstrating how the processor coexists with normal sampling. +""" + +import torch + +from vllm import LLM, SamplingParams +from vllm.config import VllmConfig +from vllm.v1.sample.logits_processor import ( + BatchUpdate, + LogitsProcessor, +) +from vllm.v1.sample.logits_processor.builtin import process_dict_updates + + +class TopNSigmaLogitsProcessor(LogitsProcessor): + """Top-n-sigma logit truncation processor. + + Filters logits based on statistical outlier detection: tokens whose + logit value falls more than n standard deviations below the maximum + logit are masked to -inf before softmax. + + This is argmax-invariant because the argmax token always has the + highest logit and thus always survives the filter. + """ + + @classmethod + def validate_params(cls, params: SamplingParams): + n_sigma = (params.extra_args or {}).get("top_n_sigma") + if n_sigma is not None and ( + not isinstance(n_sigma, (int, float)) or n_sigma <= 0 + ): + raise ValueError( + f"top_n_sigma must be a positive number, got {n_sigma!r}" + ) + + def __init__( + self, vllm_config: VllmConfig, device: torch.device, is_pin_memory: bool + ): + self.device = device + self.is_pin_memory = is_pin_memory + self.req_info: dict[int, float] = {} + self._cached_rows_cpu: torch.Tensor | None = None + self._cached_n_sigmas_cpu: torch.Tensor | None = None + + def is_argmax_invariant(self) -> bool: + return True + + def update_state(self, batch_update: BatchUpdate | None): + def extract_n_sigma(params: SamplingParams) -> float | None: + self.validate_params(params) + return (params.extra_args or {}).get("top_n_sigma") + + needs_update = process_dict_updates( + self.req_info, + batch_update, + lambda params, _, __: extract_n_sigma(params), + ) + + # Only rebuild CPU caches when dict actually changed. + if needs_update: + if self.req_info: + self._cached_rows_cpu = torch.tensor( + list(self.req_info.keys()), dtype=torch.long, + pin_memory=self.is_pin_memory, + ) + self._cached_n_sigmas_cpu = torch.tensor( + list(self.req_info.values()), dtype=torch.float32, + pin_memory=self.is_pin_memory, + ) + else: + self._cached_rows_cpu = None + self._cached_n_sigmas_cpu = None + + def apply(self, logits: torch.Tensor) -> torch.Tensor: + if self._cached_rows_cpu is None: + return logits + + rows = self._cached_rows_cpu.to( + device=logits.device, non_blocking=True, + ) + n_sigmas = self._cached_n_sigmas_cpu.to( + device=logits.device, dtype=logits.dtype, non_blocking=True, + ) + + selected_logits = logits[rows] + + # Skip rows with NaN/Inf or all-equal logits (std == 0) + finite_mask = torch.isfinite(selected_logits).all(dim=-1) + std_all = selected_logits.std(dim=-1) + nonzero_std_mask = std_all != 0 + process_mask = finite_mask & nonzero_std_mask + + logits_to_process = selected_logits[process_mask] + + if logits_to_process.numel() == 0: + return logits + + rows_to_process = rows[process_mask] + n_sigmas_to_process = n_sigmas[process_mask] + max_logits = logits_to_process.max(dim=-1, keepdim=True).values + std_logits = std_all[process_mask].unsqueeze(-1) + + thresholds = max_logits - n_sigmas_to_process.unsqueeze(-1) * std_logits + logits_to_process[logits_to_process < thresholds] = float("-inf") + + logits[rows_to_process] = logits_to_process + + return logits + + +# Sample prompts with varying difficulty (certainty) levels. +prompts = [ + "The capital of France is", + "Hello, my name is", + "The future of AI is", + "The president of the United States is", +] + +# Create a mixture of requests with and without top_n_sigma +sampling_params_list = [ + # With top_n_sigma=2.0: keeps tokens within 2 std of max logit + SamplingParams( + temperature=0.8, max_tokens=20, extra_args={"top_n_sigma": 2.0} + ), + # Without top_n_sigma: normal sampling for comparison + SamplingParams(temperature=1.0, max_tokens=20), + # With top_n_sigma=1.0: more aggressive filtering + SamplingParams( + temperature=0.8, max_tokens=20, extra_args={"top_n_sigma": 1.0} + ), + # Without top_n_sigma: normal sampling for comparison + SamplingParams(temperature=0.8, max_tokens=20), +] + + +def main(): + llm = LLM( + model="facebook/opt-125m", + logits_processors=[TopNSigmaLogitsProcessor], + ) + # Ordered by config: [s0_p0, s0_p1, ..., s1_p0, s1_p1, ...] + all_params = [s for s in sampling_params_list for _ in prompts] + all_prompts = prompts * len(sampling_params_list) + + outputs = llm.generate(all_prompts, all_params) + config_labels = [ + "top_n_sigma=2.0 (temp=0.8)", + "baseline temp=1.0 (no filter)", + "top_n_sigma=1.0 (temp=0.8)", + "baseline temp=0.8 (no filter)", + ] + n_prompts = len(prompts) + + print("\nTop-n-sigma Logits Processor Demo\n" + "=" * 60) + for cfg_idx, label in enumerate(config_labels): + print(f"\n[{label}]") + print("-" * 60) + for p_idx in range(n_prompts): + out = outputs[cfg_idx * n_prompts + p_idx] + print(f"Prompt: {out.prompt!r}") + print(f"Output: {out.outputs[0].text!r}") + print() + + +if __name__ == "__main__": + main() diff --git a/examples/features/prompt_embed/prompt_embed_offline.py b/examples/features/prompt_embed/prompt_embed_offline.py index 29853bce9673..9e90aa46b5b7 100644 --- a/examples/features/prompt_embed/prompt_embed_offline.py +++ b/examples/features/prompt_embed/prompt_embed_offline.py @@ -19,7 +19,7 @@ """ import torch -from transformers import AutoModelForCausalLM, AutoTokenizer, PreTrainedTokenizer +from transformers import AutoModelForCausalLM, AutoTokenizer, PythonBackend from vllm import LLM @@ -34,7 +34,7 @@ def init_tokenizer_and_llm(model_name: str): def get_prompt_embeds( chat: list[dict[str, str]], - tokenizer: PreTrainedTokenizer, + tokenizer: PythonBackend, embedding_layer: torch.nn.Module, ): token_ids = tokenizer.apply_chat_template( @@ -45,7 +45,7 @@ def get_prompt_embeds( def single_prompt_inference( - llm: LLM, tokenizer: PreTrainedTokenizer, embedding_layer: torch.nn.Module + llm: LLM, tokenizer: PythonBackend, embedding_layer: torch.nn.Module ): chat = [{"role": "user", "content": "Please tell me about the capital of France."}] prompt_embeds = get_prompt_embeds(chat, tokenizer, embedding_layer) @@ -64,7 +64,7 @@ def single_prompt_inference( def batch_prompt_inference( - llm: LLM, tokenizer: PreTrainedTokenizer, embedding_layer: torch.nn.Module + llm: LLM, tokenizer: PythonBackend, embedding_layer: torch.nn.Module ): chats = [ [{"role": "user", "content": "Please tell me about the capital of France."}], diff --git a/examples/features/speculative_decoding/extract_hidden_states_offline.py b/examples/features/speculative_decoding/extract_hidden_states_offline.py index f8909566f402..5db315a043b2 100644 --- a/examples/features/speculative_decoding/extract_hidden_states_offline.py +++ b/examples/features/speculative_decoding/extract_hidden_states_offline.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import os import tempfile from vllm import LLM, SamplingParams @@ -18,7 +19,6 @@ with tempfile.TemporaryDirectory() as tmpdirname: llm = LLM( model="Qwen/Qwen3-8B", # Your target model - enable_chunked_prefill=False, # required speculative_config={ "method": "extract_hidden_states", "num_speculative_tokens": 1, @@ -38,13 +38,30 @@ kv_role="kv_producer", kv_connector_extra_config={ "shared_storage_path": tmpdirname, + "allow_custom_save_path": True, }, ), ) prompts = ["Generate a sentence with hidden states", "Write a python function"] - sampling_params = SamplingParams(max_tokens=1) - outputs = llm.generate(prompts, sampling_params) + + # One request uses defaults, the other uses a custom save path and + # includes output token hidden states via per-request kv_transfer_params. + sampling_params_list = [ + SamplingParams(max_tokens=1), + SamplingParams( + max_tokens=10, + extra_args={ + "kv_transfer_params": { + "hidden_states_path": os.path.join( + tmpdirname, "custom_output.safetensors" + ), + "include_output_tokens": True, + } + }, + ), + ] + outputs = llm.generate(prompts, sampling_params_list) for output in outputs: print("\nPrompt:", output.prompt) @@ -52,16 +69,16 @@ hidden_states_path = output.kv_transfer_params.get("hidden_states_path") assert hidden_states_path is not None - print("Prompt hidden states path:", hidden_states_path) + print("Hidden states path:", hidden_states_path) obj = example_hidden_states_connector.load_hidden_states(hidden_states_path) token_ids = obj["token_ids"] hidden_states = obj["hidden_states"] - print("Extracted token ids:", token_ids) # Matches prompt token ids + print("Extracted token ids:", token_ids) print( "Extracted hidden states shape:", hidden_states.shape - ) # [prompt_len, num_extracted_layers, hidden_size] + ) # [num_tokens, num_extracted_layers, hidden_size] print("Extracted hidden states:", hidden_states) example_hidden_states_connector.cleanup_hidden_states(hidden_states_path) diff --git a/examples/features/speculative_decoding/spec_decode_offline.py b/examples/features/speculative_decoding/spec_decode_offline.py index e60226ba67ed..593fdb2ad006 100644 --- a/examples/features/speculative_decoding/spec_decode_offline.py +++ b/examples/features/speculative_decoding/spec_decode_offline.py @@ -72,6 +72,7 @@ def parse_args(): parser.add_argument("--max-num-seqs", type=int, default=None) parser.add_argument("--parallel-drafting", action="store_true") parser.add_argument("--allowed-local-media-path", type=str, default="") + parser.add_argument("--use-heterogeneous-vocab", action="store_true") return parser.parse_args() @@ -135,6 +136,7 @@ def main(args): "enforce_eager": args.enforce_eager, "max_model_len": args.max_model_len, "parallel_drafting": args.parallel_drafting, + "use_heterogeneous_vocab": args.use_heterogeneous_vocab, } elif args.method == "mtp": speculative_config = { diff --git a/examples/generate/multimodal/audio_language_offline.py b/examples/generate/multimodal/audio_language_offline.py index c480f1b4145f..fc20e8fed186 100644 --- a/examples/generate/multimodal/audio_language_offline.py +++ b/examples/generate/multimodal/audio_language_offline.py @@ -91,44 +91,6 @@ def run_cohere_asr(question: str, audio_count: int) -> ModelRequestData: ) -# MusicFlamingo -def run_musicflamingo(question: str, audio_count: int) -> ModelRequestData: - model_name = "nvidia/music-flamingo-2601-hf" - engine_args = EngineArgs( - model=model_name, - max_model_len=4096, - max_num_seqs=2, - limit_mm_per_prompt={"audio": audio_count}, - enforce_eager=True, - ) - - # MusicFlamingo prompt placeholders use ; vLLM's MusicFlamingo - # multimodal processor expands each one into <|sound_bos|> + audio tokens + - # <|sound_eos|> based on extracted audio feature lengths. - audio_placeholder = "" * audio_count - system_prompt = ( - "You are Music Flamingo, a multimodal assistant for language and music. " - "On each turn you receive an audio clip which contains music and optional " - "text, you will receive at least one or both; use your world knowledge and " - "reasoning to help the user with any task. Interpret the entirety of the " - "content any input music--regardlenss of whether the user calls it audio, " - "music, or sound." - ) - - prompt = ( - "<|im_start|>system\n" - f"{system_prompt}<|im_end|>\n" - "<|im_start|>user\n" - f"{audio_placeholder}{question}<|im_end|>\n" - "<|im_start|>assistant\n" - ) - - return ModelRequestData( - engine_args=engine_args, - prompt=prompt, - ) - - # Gemma3N def run_gemma3n(question: str, audio_count: int) -> ModelRequestData: model_name = "google/gemma-3n-E2B-it" @@ -463,16 +425,15 @@ def run_ultravox(question: str, audio_count: int) -> ModelRequestData: # Voxtral # Make sure to install mistral-common[audio]. def run_voxtral(question: str, audio_count: int) -> ModelRequestData: - from mistral_common.audio import Audio from mistral_common.protocol.instruct.chunk import ( AudioChunk, - RawAudio, TextChunk, ) from mistral_common.protocol.instruct.messages import ( UserMessage, ) from mistral_common.protocol.instruct.request import ChatCompletionRequest + from mistral_common.tokens.tokenizers.audio import Audio from mistral_common.tokens.tokenizers.mistral import MistralTokenizer model_name = "mistralai/Voxtral-Mini-3B-2507" @@ -495,9 +456,7 @@ def run_voxtral(question: str, audio_count: int) -> ModelRequestData: Audio.from_file(str(audio_assets[i].get_local_path()), strict=False) for i in range(audio_count) ] - audio_chunks = [ - AudioChunk(input_audio=RawAudio.from_audio(audio)) for audio in audios - ] + audio_chunks = [AudioChunk.from_audio(audio) for audio in audios] messages = [UserMessage(content=[*audio_chunks, text_chunk])] @@ -568,7 +527,6 @@ def run_fireredlid(question: str, audio_count: int) -> ModelRequestData: "kimi_audio": run_kimi_audio, "midashenglm": run_midashenglm, "minicpmo": run_minicpmo, - "musicflamingo": run_musicflamingo, "phi4_mm": run_phi4mm, "qwen2_audio": run_qwen2_audio, "qwen2_5_omni": run_qwen2_5_omni, diff --git a/examples/generate/multimodal/vision_language_multi_image_offline.py b/examples/generate/multimodal/vision_language_multi_image_offline.py index 1b68a23b3bd0..c3541427742d 100644 --- a/examples/generate/multimodal/vision_language_multi_image_offline.py +++ b/examples/generate/multimodal/vision_language_multi_image_offline.py @@ -74,39 +74,6 @@ def load_aria(question: str, image_urls: list[str]) -> ModelRequestData: ) -def load_aya_vision(question: str, image_urls: list[str]) -> ModelRequestData: - model_name = "CohereLabs/aya-vision-8b" - - engine_args = EngineArgs( - model=model_name, - max_num_seqs=2, - limit_mm_per_prompt={"image": len(image_urls)}, - ) - - placeholders = [{"type": "image", "image": url} for url in image_urls] - messages = [ - { - "role": "user", - "content": [ - *placeholders, - {"type": "text", "text": question}, - ], - } - ] - - processor = AutoProcessor.from_pretrained(model_name) - - prompt = processor.apply_chat_template( - messages, tokenize=False, add_generation_prompt=True - ) - - return ModelRequestData( - engine_args=engine_args, - prompt=prompt, - image_data=[fetch_image(url) for url in image_urls], - ) - - def load_bee(question: str, image_urls: list[str]) -> ModelRequestData: model_name = "Open-Bee/Bee-8B-RL" @@ -1042,49 +1009,6 @@ def load_phi4siglip(question: str, image_urls: list[str]) -> ModelRequestData: ) -def load_qwen_vl_chat(question: str, image_urls: list[str]) -> ModelRequestData: - model_name = "Qwen/Qwen-VL-Chat" - engine_args = EngineArgs( - model=model_name, - trust_remote_code=True, - max_model_len=1024, - max_num_seqs=2, - hf_overrides={"architectures": ["QwenVLForConditionalGeneration"]}, - limit_mm_per_prompt={"image": len(image_urls)}, - ) - placeholders = "".join( - f"Picture {i}: \n" for i, _ in enumerate(image_urls, start=1) - ) - - # This model does not have a chat_template attribute on its tokenizer, - # so we need to explicitly pass it. We use ChatML since it's used in the - # generation utils of the model: - # https://huggingface.co/Qwen/Qwen-VL-Chat/blob/main/qwen_generation_utils.py#L265 - tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) - - # Copied from: https://huggingface.co/docs/transformers/main/en/chat_templating - chat_template = "{% if not add_generation_prompt is defined %}{% set add_generation_prompt = false %}{% endif %}{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}" # noqa: E501 - - messages = [{"role": "user", "content": f"{placeholders}\n{question}"}] - prompt = tokenizer.apply_chat_template( - messages, - tokenize=False, - add_generation_prompt=True, - chat_template=chat_template, - ) - - stop_tokens = ["<|endoftext|>", "<|im_start|>", "<|im_end|>"] - stop_token_ids = [tokenizer.convert_tokens_to_ids(i) for i in stop_tokens] - - return ModelRequestData( - engine_args=engine_args, - prompt=prompt, - stop_token_ids=stop_token_ids, - image_data=[fetch_image(url) for url in image_urls], - chat_template=chat_template, - ) - - def load_qwen2_vl(question: str, image_urls: list[str]) -> ModelRequestData: try: from qwen_vl_utils import smart_resize @@ -1318,55 +1242,6 @@ def load_step_vl(question: str, image_urls: list[str]) -> ModelRequestData: ) -def load_tarsier(question: str, image_urls: list[str]) -> ModelRequestData: - model_name = "omni-research/Tarsier-7b" - - engine_args = EngineArgs( - model=model_name, - trust_remote_code=True, - max_model_len=4096, - limit_mm_per_prompt={"image": len(image_urls)}, - ) - - prompt = f"USER: {'' * len(image_urls)}\n{question}\n ASSISTANT:" - image_data = [fetch_image(url) for url in image_urls] - - return ModelRequestData( - engine_args=engine_args, - prompt=prompt, - image_data=image_data, - ) - - -def load_tarsier2(question: str, image_urls: list[str]) -> ModelRequestData: - model_name = "omni-research/Tarsier2-Recap-7b" - - engine_args = EngineArgs( - model=model_name, - trust_remote_code=True, - max_model_len=32768, - limit_mm_per_prompt={"image": len(image_urls)}, - hf_overrides={ - "architectures": ["Tarsier2ForConditionalGeneration"], - "model_type": "tarsier2", - }, - ) - - prompt = ( - "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n" - f"<|im_start|>user\n<|vision_start|>{'<|image_pad|>' * len(image_urls)}" - f"<|vision_end|>{question}<|im_end|>\n" - "<|im_start|>assistant\n" - ) - image_data = [fetch_image(url) for url in image_urls] - - return ModelRequestData( - engine_args=engine_args, - prompt=prompt, - image_data=image_data, - ) - - # GLM-4.1V def load_glm4_1v(question: str, image_urls: list[str]) -> ModelRequestData: model_name = "zai-org/GLM-4.1V-9B-Thinking" @@ -1512,7 +1387,6 @@ def load_molmo2(question: str, image_urls: list[str]) -> ModelRequestData: model_example_map = { "aria": load_aria, - "aya_vision": load_aya_vision, "bee": load_bee, "command_a_vision": load_command_a_vision, "deepseek_vl_v2": load_deepseek_vl2, @@ -1544,15 +1418,12 @@ def load_molmo2(question: str, image_urls: list[str]) -> ModelRequestData: "phi4_mm": load_phi4mm, "phi4_siglip": load_phi4siglip, "pixtral_hf": load_pixtral_hf, - "qwen_vl_chat": load_qwen_vl_chat, "qwen2_vl": load_qwen2_vl, "qwen2_5_vl": load_qwen2_5_vl, "rvl": load_r_vl, "smolvlm": load_smolvlm, "step3": load_step3, "stepvl": load_step_vl, - "tarsier": load_tarsier, - "tarsier2": load_tarsier2, "glm4_1v": load_glm4_1v, "glm4_5v": load_glm4_5v, "glm4_5v_fp8": load_glm4_5v_fp8, diff --git a/examples/generate/multimodal/vision_language_offline.py b/examples/generate/multimodal/vision_language_offline.py index 4d47d9f8b453..bddf6388ae6f 100644 --- a/examples/generate/multimodal/vision_language_offline.py +++ b/examples/generate/multimodal/vision_language_offline.py @@ -68,28 +68,6 @@ def run_aria(questions: list[str], modality: str) -> ModelRequestData: ) -# Aya Vision -def run_aya_vision(questions: list[str], modality: str) -> ModelRequestData: - assert modality == "image" - model_name = "CohereLabs/aya-vision-8b" - - engine_args = EngineArgs( - model=model_name, - max_model_len=2048, - max_num_seqs=2, - mm_processor_kwargs={"crop_to_patches": True}, - limit_mm_per_prompt={modality: 1}, - ) - prompts = [ - f"<|START_OF_TURN_TOKEN|><|USER_TOKEN|>{question}<|END_OF_TURN_TOKEN|><|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>" - for question in questions - ] - return ModelRequestData( - engine_args=engine_args, - prompts=prompts, - ) - - # Bee-8B def run_bee(questions: list[str], modality: str) -> ModelRequestData: assert modality == "image" @@ -470,24 +448,6 @@ def run_exaone4_5(questions: list[str], modality: str) -> ModelRequestData: ) -# Fuyu -def run_fuyu(questions: list[str], modality: str) -> ModelRequestData: - assert modality == "image" - - prompts = [f"{question}\n" for question in questions] - engine_args = EngineArgs( - model="adept/fuyu-8b", - max_model_len=2048, - max_num_seqs=2, - limit_mm_per_prompt={modality: 1}, - ) - - return ModelRequestData( - engine_args=engine_args, - prompts=prompts, - ) - - # Gemma 3 def run_gemma3(questions: list[str], modality: str) -> ModelRequestData: assert modality == "image" @@ -1377,28 +1337,6 @@ def run_llava_onevision(questions: list[str], modality: str) -> ModelRequestData ) -# Mantis -def run_mantis(questions: list[str], modality: str) -> ModelRequestData: - assert modality == "image" - - llama3_template = "<|start_header_id|>user<|end_header_id|>\n\n{}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n" # noqa: E501 - prompts = [llama3_template.format(f"{question}\n") for question in questions] - - engine_args = EngineArgs( - model="TIGER-Lab/Mantis-8B-siglip-llama3", - max_model_len=4096, - hf_overrides={"architectures": ["MantisForConditionalGeneration"]}, - limit_mm_per_prompt={modality: 1}, - ) - stop_token_ids = [128009] - - return ModelRequestData( - engine_args=engine_args, - prompts=prompts, - stop_token_ids=stop_token_ids, - ) - - # MiniCPM-V def run_minicpmv_base(questions: list[str], modality: str, model_name): assert modality in ["image", "video", "image+video"] @@ -1481,39 +1419,6 @@ def run_minicpmv(questions: list[str], modality: str) -> ModelRequestData: return run_minicpmv_base(questions, modality, "openbmb/MiniCPM-V-2_6") -def run_minimax_vl_01(questions: list[str], modality: str) -> ModelRequestData: - assert modality == "image" - - model_name = "MiniMaxAI/MiniMax-VL-01" - - engine_args = EngineArgs( - model=model_name, - max_num_seqs=2, - limit_mm_per_prompt={modality: 1}, - trust_remote_code=True, - tensor_parallel_size=8, - ) - - tokenizer = AutoTokenizer.from_pretrained(model_name) - messages = [ - [ - { - "role": "user", - "content": [{"type": "image"}, {"type": "text", "text": question}], - } - ] - for question in questions - ] - prompts = tokenizer.apply_chat_template( - messages, add_generation_prompt=True, tokenize=False - ) - - return ModelRequestData( - engine_args=engine_args, - prompts=prompts, - ) - - # Mistral-3 HF-format def run_mistral3(questions: list[str], modality: str) -> ModelRequestData: assert modality == "image" @@ -1938,27 +1843,6 @@ def run_pixtral_hf(questions: list[str], modality: str) -> ModelRequestData: ) -# Qwen-VL -def run_qwen_vl(questions: list[str], modality: str) -> ModelRequestData: - assert modality == "image" - - engine_args = EngineArgs( - model="Qwen/Qwen-VL", - trust_remote_code=True, - max_model_len=1024, - max_num_seqs=2, - hf_overrides={"architectures": ["QwenVLForConditionalGeneration"]}, - limit_mm_per_prompt={modality: 1}, - ) - - prompts = [f"{question}Picture 1: \n" for question in questions] - - return ModelRequestData( - engine_args=engine_args, - prompts=prompts, - ) - - # Qwen2-VL def run_qwen2_vl(questions: list[str], modality: str) -> ModelRequestData: model_name = "Qwen/Qwen2-VL-7B-Instruct" @@ -2401,68 +2285,8 @@ def run_step_vl(questions: list[str], modality: str) -> ModelRequestData: ) -# omni-research/Tarsier-7b -def run_tarsier(questions: list[str], modality: str) -> ModelRequestData: - assert modality == "image" - model_name = "omni-research/Tarsier-7b" - - engine_args = EngineArgs( - model=model_name, - trust_remote_code=True, - max_model_len=4096, - limit_mm_per_prompt={modality: 1}, - ) - prompts = [(f"USER: \n{question} ASSISTANT:") for question in questions] - - return ModelRequestData( - engine_args=engine_args, - prompts=prompts, - ) - - -def run_tarsier2(questions: list[str], modality: str) -> ModelRequestData: - model_name = "omni-research/Tarsier2-Recap-7b" - - mm_limit = {"image": 1, "video": 1} if modality == "image+video" else {modality: 1} - engine_args = EngineArgs( - model=model_name, - max_model_len=4096, - hf_overrides={ - "architectures": ["Tarsier2ForConditionalGeneration"], - "model_type": "tarsier2", - }, - limit_mm_per_prompt=mm_limit, - ) - - image_placeholder = "<|vision_start|><|image_pad|><|vision_end|>" - video_placeholder = "<|vision_start|><|video_pad|><|vision_end|>" - - if modality == "image": - placeholder = image_placeholder - elif modality == "video": - placeholder = video_placeholder - elif modality == "image+video": - placeholder = image_placeholder + video_placeholder - - prompts = [ - ( - "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n" - f"<|im_start|>user\n{placeholder}" - f"{question}<|im_end|>\n" - "<|im_start|>assistant\n" - ) - for question in questions - ] - - return ModelRequestData( - engine_args=engine_args, - prompts=prompts, - ) - - model_example_map = { "aria": run_aria, - "aya_vision": run_aya_vision, "bagel": run_bagel, "cheers": run_cheers, "bee": run_bee, @@ -2476,7 +2300,6 @@ def run_tarsier2(questions: list[str], modality: str) -> ModelRequestData: "eagle2_5": run_eagle2_5, "ernie45_vl": run_ernie45_vl, "exaone4_5": run_exaone4_5, - "fuyu": run_fuyu, "gemma3": run_gemma3, "gemma3n": run_gemma3n, "glm4v": run_glm4v, @@ -2503,10 +2326,8 @@ def run_tarsier2(questions: list[str], modality: str) -> ModelRequestData: "llava-next": run_llava_next, "llava-next-video": run_llava_next_video, "llava-onevision": run_llava_onevision, - "mantis": run_mantis, "minicpmo": run_minicpmo, "minicpmv": run_minicpmv, - "minimax_vl_01": run_minimax_vl_01, "mistral3": run_mistral3, "molmo": run_molmo, "molmo2": run_molmo2, @@ -2522,7 +2343,6 @@ def run_tarsier2(questions: list[str], modality: str) -> ModelRequestData: "phi4_mm": run_phi4mm, "phi4_siglip": run_phi4siglip, "pixtral_hf": run_pixtral_hf, - "qwen_vl": run_qwen_vl, "qwen2_vl": run_qwen2_vl, "qwen2_5_vl": run_qwen2_5_vl, "qwen2_5_omni": run_qwen2_5_omni, @@ -2535,8 +2355,6 @@ def run_tarsier2(questions: list[str], modality: str) -> ModelRequestData: "smolvlm": run_smolvlm, "step3": run_step3, "stepvl": run_step_vl, - "tarsier": run_tarsier, - "tarsier2": run_tarsier2, } @@ -2554,14 +2372,18 @@ def run_tarsier2(questions: list[str], modality: str) -> ModelRequestData: MODELS_SUPPORT_VIT_CUDA_GRAPH = [ - "internvl_chat", + "llama4", + "qwen2_vl", "qwen2_5_vl", "qwen3_vl", "qwen3_vl_moe", - "qwen2_vl", + "kimi_vl", "qwen3_5", "qwen3_5_moe", + "internvl_chat", "stepvl", + "glm4_1v", + "deepseek_ocr", ] diff --git a/examples/pooling/score/colqwen3_5_rerank_online.py b/examples/pooling/score/colqwen3_5_rerank_online.py index c64bcfc81fce..00746634d5d1 100644 --- a/examples/pooling/score/colqwen3_5_rerank_online.py +++ b/examples/pooling/score/colqwen3_5_rerank_online.py @@ -7,11 +7,27 @@ It produces per-token embeddings and uses MaxSim scoring for retrieval and reranking. Supports both text and image inputs. +Works for any ColQwen3.5 checkpoint, e.g. `athrael-soju/colqwen3.5-4.5B-v3` +or `vultr/VultronRetrieverPrime-Qwen3.5-8B`. + Start the server with: - vllm serve athrael-soju/colqwen3.5-4.5B --max-model-len 4096 + vllm serve athrael-soju/colqwen3.5-4.5B-v3 --max-model-len 4096 \ + --mm-processor-kwargs '{"min_pixels": 65536, "max_pixels": 1835008}' Then run this script: python colqwen3_5_rerank_online.py + +Parity note (matching the native colpali ColQwen3_5Processor pipeline): + - Visual-token budget: ColQwen3_5Processor uses max_num_visual_tokens=1792, + i.e. max_pixels = 1792 * (patch_size*merge_size)^2 = 1792 * 32^2 = 1835008 + (with min_pixels = shortest_edge = 65536). Pass these via --mm-processor-kwargs + as above; the default budget gives fewer visual tokens and lower retrieval ndcg. + - When you build prompts yourself (token_embed), reproduce the processor exactly: + image (document): wrap in the instruction template + "<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>" + "Describe the image.<|im_end|><|endoftext|>" + query: append the augmentation suffix + "<|endoftext|>" * 10 + Omitting these reproduces a silent ~2.5 ndcg@10 drop vs the native pipeline. """ import requests diff --git a/examples/pooling/token_classify/forced_alignment_online.py b/examples/pooling/token_classify/forced_alignment_online.py new file mode 100644 index 000000000000..01cb618e28df --- /dev/null +++ b/examples/pooling/token_classify/forced_alignment_online.py @@ -0,0 +1,213 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adapted from Qwen3-ForcedAligner inference: +# https://github.com/QwenLM/Qwen3-ASR + +""" +Online forced alignment example using Qwen3-ForcedAligner-0.6B. + +Forced alignment takes audio and reference text as input and produces +word-level timestamps. The model predicts a time bin at each +token position; multiplying by ``timestamp_segment_time`` gives milliseconds. + +Start the server with: + + vllm serve Qwen/Qwen3-ForcedAligner-0.6B \\ + --runner pooling \\ + --enforce-eager \\ + --trust-request-chat-template \\ + --hf-overrides \\ + '{"architectures": ["Qwen3ASRForcedAlignerForTokenClassification"]}' + +Then run: + + python forced_alignment_online.py +""" + +import argparse +import json +import mimetypes +import wave +from io import BytesIO +from pathlib import Path +from typing import Any + +import numpy as np +import pybase64 as base64 +import requests +import torch +from huggingface_hub import hf_hub_download + +RAW_CONTENT_CHAT_TEMPLATE = "{{ messages[0]['content'] }}" + + +def build_prompt(words: list[str]) -> str: + """Build the forced alignment prompt from a word list. + + Format: <|audio_start|><|audio_pad|><|audio_end|> + word1word2... + """ + body = "".join(words) + "" + return f"<|audio_start|><|audio_pad|><|audio_end|>{body}" + + +def encode_audio_data_uri(audio_path: Path) -> str: + mime_type = mimetypes.guess_type(audio_path)[0] or "audio/wav" + audio_base64 = base64.b64encode(audio_path.read_bytes()).decode("utf-8") + return f"data:{mime_type};base64,{audio_base64}" + + +def encode_silent_wav_data_uri(sample_rate: int = 16000, duration_s: int = 5) -> str: + audio = np.zeros(sample_rate * duration_s, dtype=np.int16) + + with BytesIO() as audio_buffer: + with wave.open(audio_buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(np.dtype(np.int16).itemsize) + wav_file.setframerate(sample_rate) + wav_file.writeframes(audio.tobytes()) + + audio_base64 = base64.b64encode(audio_buffer.getvalue()).decode("utf-8") + + return f"data:audio/wav;base64,{audio_base64}" + + +def build_payload(model: str, prompt: str, audio_uri: str) -> dict[str, Any]: + return { + "model": model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "audio_url", "audio_url": {"url": audio_uri}}, + ], + } + ], + "task": "token_classify", + "chat_template": RAW_CONTENT_CHAT_TEMPLATE, + } + + +def post_http_request(payload: dict[str, Any], api_url: str) -> requests.Response: + headers = {"User-Agent": "Test Client"} + return requests.post(api_url, headers=headers, json=payload) + + +def parse_response(response: requests.Response) -> dict[str, Any]: + try: + result = response.json() + except ValueError as exc: + raise RuntimeError( + f"Server returned non-JSON response: {response.text}" + ) from exc + + if response.status_code != 200 or "data" not in result: + raise RuntimeError(f"Server error ({response.status_code}): {result}") + + return result + + +def load_timestamp_config(model: str) -> tuple[int, float]: + model_path = Path(model) + config_path = ( + model_path / "config.json" + if model_path.exists() + else Path(hf_hub_download(repo_id=model, filename="config.json")) + ) + + with config_path.open() as f: + config = json.load(f) + + return config["timestamp_token_id"], config["timestamp_segment_time"] + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", type=str, default="localhost") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument( + "--model", + type=str, + default="Qwen/Qwen3-ForcedAligner-0.6B", + ) + parser.add_argument( + "--audio-path", + type=Path, + default=None, + help="Optional audio file. Defaults to a 5-second silent WAV.", + ) + parser.add_argument( + "--words", + nargs="+", + default=["Hello", "world"], + help="Reference words to align against the audio.", + ) + return parser.parse_args() + + +def main(args): + from transformers import AutoTokenizer + + api_url = f"http://{args.host}:{args.port}/pooling" + prompt = build_prompt(args.words) + audio_uri = ( + encode_audio_data_uri(args.audio_path) + if args.audio_path + else encode_silent_wav_data_uri() + ) + payload = build_payload(args.model, prompt, audio_uri) + + pooling_response = post_http_request(payload=payload, api_url=api_url) + result = parse_response(pooling_response) + + tokenizer = AutoTokenizer.from_pretrained(args.model) + timestamp_token_id, timestamp_segment_time = load_timestamp_config(args.model) + + output = result["data"][0] + logits = torch.tensor(output["data"]) + predictions = logits.argmax(dim=-1) + token_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"] + audio_pad_token_id = tokenizer.convert_tokens_to_ids("<|audio_pad|>") + + usage = result.get("usage") or {} + prompt_tokens = usage.get("prompt_tokens") + if prompt_tokens is not None and prompt_tokens != len(predictions): + raise RuntimeError( + "The response length does not match the reported prompt token count." + ) + + try: + audio_pad_index = token_ids.index(audio_pad_token_id) + except ValueError as exc: + raise RuntimeError("The prompt does not contain the audio pad token.") from exc + + audio_token_shift = len(predictions) - len(token_ids) + if audio_token_shift < 0: + raise RuntimeError( + "The response is shorter than the locally tokenized prompt. " + "Check that the server was started with --trust-request-chat-template." + ) + + ts_predictions = [] + for i, token_id in enumerate(token_ids): + if token_id != timestamp_token_id: + continue + + prediction_index = i + audio_token_shift if i > audio_pad_index else i + ts_predictions.append( + predictions[prediction_index].item() * timestamp_segment_time + ) + + if len(ts_predictions) < len(args.words) * 2: + raise RuntimeError("The model did not return enough timestamp predictions.") + + for i, word in enumerate(args.words): + start_ms = ts_predictions[i * 2] + end_ms = ts_predictions[i * 2 + 1] + print(f"{word:15s} {start_ms / 1000:.3f}s - {end_ms / 1000:.3f}s") + + +if __name__ == "__main__": + args = parse_args() + main(args) diff --git a/examples/pooling/token_embed/jina_reranker_v3_online.py b/examples/pooling/token_embed/jina_reranker_v3_online.py new file mode 100644 index 000000000000..8350aee2f143 --- /dev/null +++ b/examples/pooling/token_embed/jina_reranker_v3_online.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# ruff: noqa: E501 + +""" +Example online usage of the Jina Reranker v3 score and rerank APIs with a task +instruction. + +Run `vllm serve jinaai/jina-reranker-v3 --runner pooling` to start up the +server in vLLM. +""" + +import argparse +import json + +import requests + + +def post_http_request(prompt: dict, api_url: str) -> requests.Response: + headers = {"User-Agent": "Test Client"} + response = requests.post(api_url, headers=headers, json=prompt) + return response + + +def print_response(name: str, prompt: dict, response: requests.Response) -> None: + print(f"\n{name} request:") + print(json.dumps(prompt, indent=2)) + print(f"\n{name} response:") + print(json.dumps(response.json(), indent=2)) + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", type=str, default="localhost") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--model", type=str, default="jinaai/jina-reranker-v3") + return parser.parse_args() + + +def main(args): + score_url = f"http://{args.host}:{args.port}/score" + rerank_url = f"http://{args.host}:{args.port}/rerank" + model_name = args.model + + query = "Which passage is about sports?" + documents = [ + "Basketball is played by two teams on a court.", + "Green tea contains antioxidants and may support metabolism.", + ] + instruction = "Rank passages about sports higher than passages about nutrition." + + score_prompt = { + "model": model_name, + "queries": query, + "documents": documents, + "instruction": instruction, + } + score_response = post_http_request(prompt=score_prompt, api_url=score_url) + print_response("Score", score_prompt, score_response) + + rerank_prompt = { + "model": model_name, + "query": query, + "documents": documents, + "instruction": instruction, + } + rerank_response = post_http_request(prompt=rerank_prompt, api_url=rerank_url) + print_response("Rerank", rerank_prompt, rerank_response) + + +if __name__ == "__main__": + args = parse_args() + main(args) diff --git a/examples/ray_serving/multi-node-serving.sh b/examples/ray_serving/multi-node-serving.sh index d2823bb8f9c0..644bc820ec03 100644 --- a/examples/ray_serving/multi-node-serving.sh +++ b/examples/ray_serving/multi-node-serving.sh @@ -11,7 +11,7 @@ # Example usage: # On the head node machine, start the Ray head node process and run a vLLM server. # ./multi-node-serving.sh leader --ray_port=6379 --ray_cluster_size= [] && \ -# vllm serve meta-llama/Meta-Llama-3.1-405B-Instruct --port 8080 --tensor-parallel-size 8 --pipeline_parallel_size 2 +# vllm serve meta-llama/Meta-Llama-3.1-405B-Instruct --port 8080 --tensor-parallel-size 8 --pipeline-parallel-size 2 --distributed-executor-backend ray # # On each worker node, start the Ray worker node process. # ./multi-node-serving.sh worker --ray_address= --ray_port=6379 [] diff --git a/examples/rl/rlhf_async_new_apis.py b/examples/rl/rlhf_async_new_apis.py index a6adc2088607..7043182ab180 100644 --- a/examples/rl/rlhf_async_new_apis.py +++ b/examples/rl/rlhf_async_new_apis.py @@ -190,12 +190,11 @@ def generate(self, token_ids: list[int], max_new_tokens: int) -> list[int]: # Build platform-specific env vars for Ray -ray_env_vars = { - # Prevent Ray from setting CUDA_VISIBLE_DEVICES - "RAY_EXPERIMENTAL_NOSET_CUDA_ENV_VAR": "1", -} +ray_env_vars = {} if current_platform.is_rocm(): + # Workaround for RCCL bug. See https://github.com/ROCm/rocm-systems/issues/5756 + ray_env_vars["RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES"] = "1" # For ROCm, BATCH_INVARIANT vllm is not supported ray_env_vars["VLLM_ROCM_USE_SKINNY_GEMM"] = "0" else: @@ -307,7 +306,7 @@ def generate(self, token_ids: list[int], max_new_tokens: int) -> list[int]: ray.get(llm.pause_after_n_tokens.remote()) -ray.get(llm.start_weight_update.remote(is_checkpoint_format=True)) +ray.get(llm.start_weight_update.remote()) inference_handle = llm.update_weights.remote( WeightTransferUpdateRequest( diff --git a/examples/rl/rlhf_http_ipc.py b/examples/rl/rlhf_http_ipc.py index 16c5be8dd65b..0a0efcbee361 100644 --- a/examples/rl/rlhf_http_ipc.py +++ b/examples/rl/rlhf_http_ipc.py @@ -80,14 +80,10 @@ def init_weight_transfer_engine(base_url: str) -> None: response.raise_for_status() -def start_weight_update( - base_url: str, - is_checkpoint_format: bool = True, -) -> None: +def start_weight_update(base_url: str) -> None: """Start a weight update via HTTP endpoint.""" url = f"{base_url}/start_weight_update" - payload = {"is_checkpoint_format": is_checkpoint_format} - response = requests.post(url, json=payload, timeout=60) + response = requests.post(url, json={}, timeout=60) response.raise_for_status() @@ -170,7 +166,7 @@ def main(): pause_generation(BASE_URL) # Start weight update, broadcast via IPC, then finish - start_weight_update(BASE_URL, is_checkpoint_format=False) + start_weight_update(BASE_URL) print("Broadcasting weights via CUDA IPC (HTTP)...") trainer_args = IPCTrainerSendWeightsArgs(send_mode="http", url=BASE_URL) diff --git a/examples/rl/rlhf_http_nccl.py b/examples/rl/rlhf_http_nccl.py index 01aafe43f160..b40b8de32fbb 100644 --- a/examples/rl/rlhf_http_nccl.py +++ b/examples/rl/rlhf_http_nccl.py @@ -83,14 +83,10 @@ def init_weight_transfer_engine( response.raise_for_status() -def start_weight_update( - base_url: str, - is_checkpoint_format: bool = True, -) -> None: +def start_weight_update(base_url: str) -> None: """Start a weight update via HTTP endpoint.""" url = f"{base_url}/start_weight_update" - payload = {"is_checkpoint_format": is_checkpoint_format} - response = requests.post(url, json=payload, timeout=60) + response = requests.post(url, json={}, timeout=60) response.raise_for_status() @@ -223,7 +219,7 @@ def main(): shapes.append(list(p.shape)) # Start weight update - start_weight_update(BASE_URL, is_checkpoint_format=True) + start_weight_update(BASE_URL) # Start the update_weights call in a separate thread since it will block # waiting for NCCL broadcasts diff --git a/examples/rl/rlhf_ipc.py b/examples/rl/rlhf_ipc.py index afebbd240a4e..cb8542898792 100644 --- a/examples/rl/rlhf_ipc.py +++ b/examples/rl/rlhf_ipc.py @@ -139,7 +139,7 @@ def broadcast_weights( ray.get(train_model.init_weight_transfer.remote()) # Start weight update, sync weights, then finish -ray.get(llm.start_weight_update.remote(is_checkpoint_format=True)) +ray.get(llm.start_weight_update.remote()) ray.get(train_model.broadcast_weights.remote(llm)) ray.get(llm.finish_weight_update.remote()) diff --git a/examples/rl/rlhf_ipc_fsdp_ep.py b/examples/rl/rlhf_ipc_fsdp_ep.py index 0fb0a93ca82f..77ac6b4cfca0 100644 --- a/examples/rl/rlhf_ipc_fsdp_ep.py +++ b/examples/rl/rlhf_ipc_fsdp_ep.py @@ -277,15 +277,8 @@ def init_weight_transfer(self): ] ) - def start_weight_update(self, is_checkpoint_format: bool = True): - ray.get( - [ - actor.start_weight_update.remote( - is_checkpoint_format=is_checkpoint_format - ) - for actor in self.llm_actors - ] - ) + def start_weight_update(self): + ray.get([actor.start_weight_update.remote() for actor in self.llm_actors]) def finish_weight_update(self): ray.get([actor.finish_weight_update.remote() for actor in self.llm_actors]) @@ -392,7 +385,7 @@ def main(): ray.get(inference_engine.wake_up.remote(tags=["weights"])) print("[sync] Starting weight update...") - ray.get(inference_engine.start_weight_update.remote(is_checkpoint_format=True)) + ray.get(inference_engine.start_weight_update.remote()) print("[sync] Packed IPC transfer FSDP → vLLM...") ray.get( diff --git a/examples/rl/rlhf_nccl.py b/examples/rl/rlhf_nccl.py index b94d5e4db827..bebd6bc70dfd 100644 --- a/examples/rl/rlhf_nccl.py +++ b/examples/rl/rlhf_nccl.py @@ -29,6 +29,7 @@ import os import ray +import torch from ray.util.placement_group import placement_group from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy from transformers import AutoModelForCausalLM @@ -39,12 +40,24 @@ NCCLTrainerSendWeightsArgs, NCCLWeightTransferEngine, ) +from vllm.platforms import current_platform from vllm.utils.network_utils import get_ip, get_open_port MODEL_NAME = "facebook/opt-125m" # MODEL_NAME = "inference-optimization/Qwen3-0.6B-W4A16-G128" +def get_assigned_gpu(): + """This is a temporary workaround for a runtime bug in RCCL on ROCm.""" + if not current_platform.is_rocm(): + return 0 + assigned_gpu = int(ray.get_gpu_ids()[0]) + os.environ.pop("CUDA_VISIBLE_DEVICES", None) + os.environ.pop("HIP_VISIBLE_DEVICES", None) + torch.accelerator.set_device_idx(assigned_gpu) + return assigned_gpu + + class MyLLM(LLM): """Configure the vLLM worker for Ray placement group execution.""" @@ -58,9 +71,11 @@ class TrainModel: """Ray actor that wraps the training model on a dedicated GPU.""" def __init__(self, model_name: str): + assigned_gpu = get_assigned_gpu() + self.model = AutoModelForCausalLM.from_pretrained( model_name, - ).to("cuda:0") + ).to(f"cuda:{assigned_gpu}") self.port = get_open_port() self.master_address = get_ip() @@ -187,7 +202,7 @@ def broadcast_weights(self, packed: bool = True): names, dtype_names, shapes = ray.get(train_model.get_weight_metadata.remote()) # Start weight update -ray.get(llm.start_weight_update.remote(is_checkpoint_format=True)) +ray.get(llm.start_weight_update.remote()) # Issue update_weights call with NCCL-specific update info # packed=True enables efficient batched tensor broadcasting diff --git a/examples/rl/rlhf_nccl_fsdp_ep.py b/examples/rl/rlhf_nccl_fsdp_ep.py index 4337e6fea5ac..860db058cac1 100644 --- a/examples/rl/rlhf_nccl_fsdp_ep.py +++ b/examples/rl/rlhf_nccl_fsdp_ep.py @@ -299,7 +299,7 @@ async def main(): print(f"[sync] Got metadata for {len(names)} parameters.") print("[sync] Starting weight update...") - await engine.start_weight_update(is_checkpoint_format=True) + await engine.start_weight_update() print("[sync] Broadcasting weights from FSDP → vLLM...") broadcast_handles = [ diff --git a/examples/rl/rlhf_sparse_nccl.py b/examples/rl/rlhf_sparse_nccl.py index bddd28b6485e..09cf5bfbaa02 100644 --- a/examples/rl/rlhf_sparse_nccl.py +++ b/examples/rl/rlhf_sparse_nccl.py @@ -44,11 +44,14 @@ from vllm import LLM, SamplingParams from vllm.config import WeightTransferConfig -from vllm.distributed.weight_transfer.base import SparseWeightPatch from vllm.distributed.weight_transfer.nccl_engine import ( NCCLTrainerSendWeightsArgs, NCCLWeightTransferEngine, ) +from vllm.distributed.weight_transfer.sparse_nccl_engine import ( + SparseNCCLWeightTransferEngine, + SparseWeightPatch, +) from vllm.utils.network_utils import get_ip, get_open_port MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct" @@ -244,7 +247,6 @@ def prepare_sparse_patch( dtype_names=[str(self.patched_param.dtype).split(".")[-1]], shapes=[list(self.patched_param.shape)], num_updates_list=[flat_indices.numel()], - update_kind="sparse_flat", ) return update_info, selected_token_ids, patch_digest, sparse_payload_bytes @@ -271,7 +273,7 @@ def broadcast_pending_sparse_patch(self) -> float: raise RuntimeError("Sparse patch has not been prepared") start = time.perf_counter() - NCCLWeightTransferEngine.trainer_send_sparse_weights( + SparseNCCLWeightTransferEngine.trainer_send_weights( iter(self.pending_sparse_patches), NCCLTrainerSendWeightsArgs(group=self.model_update_group), ) @@ -282,6 +284,7 @@ def broadcast_pending_sparse_patch(self) -> float: def launch_llm( scheduling_inference: PlacementGroupSchedulingStrategy, + backend: str = "nccl", ): return ray.remote( num_cpus=0, @@ -293,7 +296,7 @@ def launch_llm( tensor_parallel_size=1, distributed_executor_backend="ray", gpu_memory_utilization=0.7, - weight_transfer_config=WeightTransferConfig(backend="nccl"), + weight_transfer_config=WeightTransferConfig(backend=backend), ) @@ -332,7 +335,7 @@ def run_dense_phase( scheduling_inference: PlacementGroupSchedulingStrategy, ) -> dict[str, object]: ray.get(train_model.reset_model.remote()) - llm = launch_llm(scheduling_inference) + llm = launch_llm(scheduling_inference, backend="nccl") try: dense_before = collect_vllm_generations(llm) @@ -351,7 +354,7 @@ def run_dense_phase( ) trainer_init = train_model.init_weight_transfer_group.remote(world_size) ray.get([trainer_init, inference_init]) - ray.get(llm.start_weight_update.remote(is_checkpoint_format=True)) + ray.get(llm.start_weight_update.remote()) dense_update_info, dense_payload_bytes = ray.get( train_model.get_dense_update_info.remote() @@ -391,7 +394,7 @@ def run_sparse_phase( scheduling_inference: PlacementGroupSchedulingStrategy, ) -> dict[str, object]: ray.get(train_model.reset_model.remote()) - llm = launch_llm(scheduling_inference) + llm = launch_llm(scheduling_inference, backend="sparse_nccl") try: sparse_before = collect_vllm_generations(llm) @@ -410,7 +413,7 @@ def run_sparse_phase( ) trainer_init = train_model.init_weight_transfer_group.remote(world_size) ray.get([trainer_init, inference_init]) - ray.get(llm.start_weight_update.remote(is_checkpoint_format=False)) + ray.get(llm.start_weight_update.remote()) sparse_update_info, selected_token_ids, patch_digest, sparse_payload_bytes = ( ray.get(train_model.prepare_sparse_patch.remote(PROMPTS)) diff --git a/tests/entrypoints/openai/tool_parsers/__init__.py b/examples/scale_out/__init__.py similarity index 100% rename from tests/entrypoints/openai/tool_parsers/__init__.py rename to examples/scale_out/__init__.py diff --git a/examples/disaggregated/disaggregated_serving/example_mm_serve.py b/examples/scale_out/example_mm_serve.py similarity index 100% rename from examples/disaggregated/disaggregated_serving/example_mm_serve.py rename to examples/scale_out/example_mm_serve.py diff --git a/examples/generate/token_generation_client.py b/examples/scale_out/token_generation_client.py similarity index 100% rename from examples/generate/token_generation_client.py rename to examples/scale_out/token_generation_client.py diff --git a/examples/speech_to_text/openai/openai_transcription_client.py b/examples/speech_to_text/openai/openai_transcription_client.py index 396edba1155d..f928c06d45ea 100644 --- a/examples/speech_to_text/openai/openai_transcription_client.py +++ b/examples/speech_to_text/openai/openai_transcription_client.py @@ -33,15 +33,23 @@ def sync_openai( *, repetition_penalty: float = 1.3, hotwords: str = None, + prompt: str | None = None, ): """ Perform synchronous transcription using OpenAI-compatible API. + + The optional ``prompt`` is the OpenAI-API ``prompt`` field (style / + vocabulary hint). It is wired through model-by-model: Whisper uses it + as a ``<|prev|>`` continuation hint, Qwen3-ASR maps it into the + chat-template ``system`` turn. Models that do not consume it accept + it without effect. """ with open(audio_path, "rb") as f: transcription = client.audio.transcriptions.create( file=f, model=model, language="en", + prompt=prompt or "", response_format="json", temperature=0.0, # Additional sampling params not provided by OpenAI API. @@ -55,7 +63,11 @@ def sync_openai( async def stream_openai_response( - audio_path: str, client: AsyncOpenAI, model: str, hotwords: str = None + audio_path: str, + client: AsyncOpenAI, + model: str, + hotwords: str = None, + prompt: str | None = None, ): """ Perform asynchronous transcription using OpenAI-compatible API. @@ -66,6 +78,7 @@ async def stream_openai_response( file=f, model=model, language="en", + prompt=prompt or "", response_format="json", temperature=0.0, # Additional sampling params not provided by OpenAI API. @@ -146,6 +159,7 @@ def main(args): model=model, repetition_penalty=args.repetition_penalty, hotwords=args.hotwords, + prompt=args.prompt, ) # Run the asynchronous function @@ -160,6 +174,7 @@ def main(args): client, model, hotwords=args.hotwords, + prompt=args.prompt, ) ) else: @@ -193,5 +208,16 @@ def main(args): default=None, help="hotwords", ) + parser.add_argument( + "--prompt", + type=str, + default=None, + help=( + "Optional `prompt` (OpenAI transcription API: style/vocabulary " + "hint). Wired model-by-model: Whisper uses it as a `<|prev|>` " + "continuation hint, Qwen3-ASR maps it into the chat-template " + "system turn." + ), + ) args = parser.parse_args() main(args) diff --git a/examples/template_baichuan.jinja b/examples/template_baichuan.jinja deleted file mode 100644 index 42a8d9270a4c..000000000000 --- a/examples/template_baichuan.jinja +++ /dev/null @@ -1,13 +0,0 @@ -{{ (messages|selectattr('role', 'equalto', 'system')|list|last).content|trim if (messages|selectattr('role', 'equalto', 'system')|list) else '' }} - -{%- for message in messages -%} - {%- if message['role'] == 'user' -%} - {{- '' + message['content'] -}} - {%- elif message['role'] == 'assistant' -%} - {{- '' + message['content'] -}} - {%- endif -%} -{%- endfor -%} - -{%- if add_generation_prompt and messages[-1]['role'] != 'assistant' -%} - {{- '' -}} -{% endif %} \ No newline at end of file diff --git a/examples/tool_chat_template_gemma4.jinja b/examples/tool_chat_template_gemma4.jinja index ef765823106a..6ce01e6479a0 100644 --- a/examples/tool_chat_template_gemma4.jinja +++ b/examples/tool_chat_template_gemma4.jinja @@ -116,7 +116,9 @@ } {%- endmacro -%} {%- macro format_argument(argument, escape_keys=True) -%} - {%- if argument is string -%} + {%- if argument is none -%} + {{- 'null' -}} + {%- elif argument is string -%} {{- '<|"|>' + argument + '<|"|>' -}} {%- elif argument is boolean -%} {{- 'true' if argument else 'false' -}} @@ -172,18 +174,21 @@ {{- '' -}} {%- endmacro -%} -{%- set ns = namespace(prev_message_type=None) -%} +{#- ===== SETUP ===== -#} +{%- set ns = namespace(prev_message_type=None, prev_non_tool_role=None) -%} {%- set loop_messages = messages -%} +{%- set enable_thinking = enable_thinking | default(false) -%} +{%- set preserve_thinking = preserve_thinking | default(false) -%} {{- bos_token -}} {#- Handle System/Tool Definitions Block -#} -{%- if (enable_thinking is defined and enable_thinking) or tools or messages[0]['role'] in ['system', 'developer'] -%} +{%- if enable_thinking or tools or (messages and messages[0]['role'] in ['system', 'developer']) -%} {{- '<|turn>system\n' -}} {#- Inject Thinking token at the very top of the FIRST system turn -#} - {%- if enable_thinking is defined and enable_thinking -%} + {%- if enable_thinking -%} {{- '<|think|>\n' -}} {%- set ns.prev_message_type = 'think' -%} {%- endif -%} - {%- if messages[0]['role'] in ['system', 'developer'] -%} + {%- if messages and messages[0]['role'] in ['system', 'developer'] -%} {%- if messages[0]['content'] is string -%} {{- messages[0]['content'] | trim -}} {%- elif messages[0]['content'] is sequence -%} @@ -217,31 +222,24 @@ {%- if message['role'] != 'tool' -%} {%- set ns.prev_message_type = None -%} {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%} - {#- Detect continuation: suppress duplicate <|turn>model when previous non-tool message was also assistant -#} - {%- set prev_nt = namespace(role=None, found=false) -%} - {%- if loop.index0 > 0 -%} - {%- for j in range(loop.index0 - 1, -1, -1) -%} - {%- if not prev_nt.found -%} - {%- if loop_messages[j]['role'] != 'tool' -%} - {%- set prev_nt.role = loop_messages[j]['role'] -%} - {%- set prev_nt.found = true -%} - {%- endif -%} - {%- endif -%} - {%- endfor -%} - {%- endif -%} - {%- set continue_same_model_turn = (role == 'model' and prev_nt.role == 'assistant') -%} + {#- Detect continuation using tracked state — O(1) instead of O(n) backward scan -#} + {%- set continue_same_model_turn = (role == 'model' and ns.prev_non_tool_role == 'assistant') -%} {%- if not continue_same_model_turn -%} {{- '<|turn>' + role + '\n' }} + {%- if role == 'model' and not enable_thinking and not (message.get('reasoning') or message.get('reasoning_content')) -%} + {{- '<|channel>thought\n' -}} + {%- endif -%} {%- endif -%} {#- Render reasoning/reasoning_content as thinking channel -#} {%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%} - {%- if thinking_text and loop.index0 > ns_turn.last_user_idx and message.get('tool_calls') -%} + {%- set thinking_gate = (loop.index0 > ns_turn.last_user_idx) or preserve_thinking -%} + {%- if thinking_text and thinking_gate -%} {{- '<|channel>thought\n' + thinking_text + '\n' -}} {%- endif -%} - {%- if message['tool_calls'] -%} - {%- for tool_call in message['tool_calls'] -%} + {%- if message.get('tool_calls') -%} + {%- for tool_call in message.get('tool_calls') -%} {%- set function = tool_call['function'] -%} {{- '<|tool_call>call:' + function['name'] + '{' -}} {%- if function['arguments'] is mapping -%} @@ -251,8 +249,13 @@ {%- set ns_args.found_first = true -%} {{- key -}}:{{- format_argument(value, escape_keys=False) -}} {%- endfor -%} - {%- elif function['arguments'] is string -%} - {{- function['arguments'] -}} + {%- elif function['arguments'] is none -%} + {%- else -%} + {{- raise_exception( + "chat_template: tool_calls[].function.arguments must be a " + "JSON object (mapping), not a string. Deserialize arguments " + "before passing to the template." + ) -}} {%- endif -%} {{- '}' -}} {%- endfor -%} @@ -262,7 +265,7 @@ {%- set ns_tr_out = namespace(flag=false) -%} {%- if message.get('tool_responses') -%} {#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#} - {%- for tool_response in message['tool_responses'] -%} + {%- for tool_response in message.get('tool_responses') -%} {{- format_tool_response_block(tool_response['name'] | default('unknown', true), tool_response['response']) -}} {%- set ns_tr_out.flag = true -%} {%- set ns.prev_message_type = 'tool_response' -%} @@ -277,8 +280,8 @@ {%- else -%} {%- set follow = loop_messages[k] -%} {#- Resolve tool_call_id to function name -#} - {%- set ns_tname = namespace(name=follow.get('name') | default('unknown', true)) -%} - {%- for tc in message['tool_calls'] -%} + {%- set ns_tname = namespace(name=follow.get('name') or 'unknown') -%} + {%- for tc in message.get('tool_calls') -%} {%- if tc.get('id') == follow.get('tool_call_id') -%} {%- set ns_tname.name = tc['function']['name'] -%} {%- endif -%} @@ -296,9 +299,9 @@ {%- endfor -%} {{- format_tool_response_block(ns_tname.name, ns_txt.s) -}} {%- for part in tool_body -%} - {%- if part.get('type') == 'image' -%} + {%- if part.get('type') in ['image', 'image_url'] -%} {{- '<|image|>' -}} - {%- elif part.get('type') == 'audio' -%} + {%- elif part.get('type') in ['audio', 'input_audio'] -%} {{- '<|audio|>' -}} {%- elif part.get('type') == 'video' -%} {{- '<|video|>' -}} @@ -314,29 +317,26 @@ {%- endif -%} {%- set captured_content -%} - {%- if message['content'] is string -%} + {%- if message.get('content') is string -%} {%- if role == 'model' -%} {{- strip_thinking(message['content']) -}} {%- else -%} {{- message['content'] | trim -}} {%- endif -%} - {%- elif message['content'] is sequence -%} + {%- elif message.get('content') is sequence -%} {%- for item in message['content'] -%} - {%- if item['type'] == 'text' -%} + {%- if item.get('type') == 'text' -%} {%- if role == 'model' -%} {{- strip_thinking(item['text']) -}} {%- else -%} {{- item['text'] | trim -}} {%- endif -%} - {%- elif item['type'] == 'image' -%} + {%- elif item.get('type') in ['image', 'image_url'] -%} {{- '<|image|>' -}} - {%- set ns.prev_message_type = 'image' -%} - {%- elif item['type'] == 'audio' -%} + {%- elif item.get('type') in ['audio', 'input_audio'] -%} {{- '<|audio|>' -}} - {%- set ns.prev_message_type = 'audio' -%} - {%- elif item['type'] == 'video' -%} + {%- elif item.get('type') == 'video' -%} {{- '<|video|>' -}} - {%- set ns.prev_message_type = 'video' -%} {%- endif -%} {%- endfor -%} {%- endif -%} @@ -345,19 +345,43 @@ {{- captured_content -}} {%- set has_content = captured_content | trim | length > 0 -%} + {#- Forward-scan: find next non-tool message role for continuation detection -#} + {%- set next_nt = namespace(role=None, found=false) -%} + {%- for j in range(loop.index0 + 1, loop_messages | length) -%} + {%- if not next_nt.found -%} + {%- if loop_messages[j]['role'] != 'tool' -%} + {%- set next_nt.role = loop_messages[j]['role'] -%} + {%- set next_nt.found = true -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + + {%- set continues_into_next = ( + role == 'model' + and next_nt.role == 'assistant' + and (not message.get('tool_calls') or ns_tr_out.flag) + ) -%} + {%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%} {{- '<|tool_response>' -}} + {%- elif continues_into_next -%} + {{- '\n' -}} {%- elif not (ns_tr_out.flag and not has_content) -%} {{- '\n' -}} {%- endif -%} + + {#- Track previous non-tool role for next iteration (avoids O(n) backward scan) -#} + {%- set ns.prev_non_tool_role = message['role'] -%} {%- endif -%} {%- endfor -%} {%- if add_generation_prompt -%} {%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%} {{- '<|turn>model\n' -}} - {%- if not enable_thinking | default(false) -%} + {%- if not enable_thinking -%} {{- '<|channel>thought\n' -}} {%- endif -%} + {%- elif ns.prev_message_type == 'tool_response' and enable_thinking -%} + {{- '<|channel>thought\n' -}} {%- endif -%} -{%- endif -%} \ No newline at end of file +{%- endif -%} diff --git a/examples/tool_chat_template_minimax_m1.jinja b/examples/tool_chat_template_minimax_m1.jinja deleted file mode 100644 index 2d5bbf4de56f..000000000000 --- a/examples/tool_chat_template_minimax_m1.jinja +++ /dev/null @@ -1,91 +0,0 @@ -{{ '' -}} -{%- if custom_tools is defined %} - {%- set tools = custom_tools %} -{%- endif %} -{%- if not tools is defined %} - {%- set tools = none %} -{%- endif %} - -{#- Extract system message #} -{% set ns = namespace(system_prompt='') -%} -{%- if messages[0]['role'] == 'system' %} - {%- if messages[0]['content'] is string %} - {%- set ns.system_prompt = messages[0]['content']|trim %} - {%- else %} - {%- set ns.system_prompt = messages[0]['content'][0]['text']|trim %} - {%- endif %} - {%- set messages = messages[1:] %} -{%- else %} - {%- if tools is not none %} - {%- set ns.system_prompt = "You are a helpful assistant created by Minimax based on MiniMax-M1 model." %} - {%- else %} - {%- set ns.system_prompt = "You are a helpful assistant created by Minimax based on MiniMax-M1 model." %} - {%- endif %} -{%- endif %} - -{#- System message #} -{%- if ns.system_prompt != '' %} -{{ 'system ai_setting=assistant\n' + ns.system_prompt + '\n' -}} -{%- endif %} - -{#- Tools configuration #} -{%- if tools is not none %} -{{ 'system tool_setting=tools\nYou are provided with these tools:\n\n' -}} -{%- for tool in tools %} -{{ tool | tojson ~ '\n' -}} -{%- endfor %} -{{ '\n\nIf you need to call tools, please respond with XML tags, and provide tool-name and json-object of arguments, following the format below:\n\n{"name": , "arguments": }\n...\n\n' -}} -{%- endif %} - -{#- Process messages #} -{%- for message in messages %} - {%- if not (message.role == 'ipython' or message.role == 'tool' or 'tool_calls' in message) %} - {%- if message['role'] == 'user' %} -{{ 'user name=user\n' -}} -{%- if message['content'] is string %} -{{ message['content']|trim -}} -{%- else %} -{%- for content in message['content'] %} -{%- if content['type'] == 'text' %} -{{ content['text']|trim -}} -{%- endif %} -{%- endfor %} -{%- endif %} -{{ '\n' -}} - {%- elif message['role'] == 'assistant' %} -{{ 'ai name=assistant\n' -}} -{%- if message['content'] is string %} -{{ message['content']|trim -}} -{%- else %} -{%- for content in message['content'] | selectattr('type', 'equalto', 'text') %} -{{ content['text']|trim -}} -{%- endfor %} -{%- endif %} -{{ '\n' -}} - {%- endif %} - {%- elif 'tool_calls' in message %} -{{ 'ai name=assistant\n\n' -}} -{%- for tool_call in message.tool_calls %} -{{ '{"name": "' + tool_call.function.name + '", "arguments": ' + tool_call.function.arguments | tojson + '}\n' -}} -{%- endfor %} -{{ '\n' -}} - {%- elif message.role == "tool" or message.role == "ipython" %} -{{ 'tool name=tools\n' -}} -{%- if message.content is string %} -{{ 'tool result: ' + message.content + '\n\n' -}} -{%- else %} -{%- for content in message['content'] %} -{%- if content['type'] == 'text' %} -{{ 'tool result: ' + content['text'] + '\n\n' -}} -{%- elif content.get('name') %} -{{ 'tool name: ' + content['name'] + '\ntool result: ' + content['text'] + '\n\n' -}} -{%- endif %} -{%- endfor %} -{%- endif %} -{{ '\n' -}} - {%- endif %} -{%- endfor %} - -{%- if add_generation_prompt %} -{{ 'ai name=assistant\n' -}} -{%- endif %} \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index 097f7497fb22..a32cea618068 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -83,22 +83,22 @@ plugins: - "re:vllm\\._.*" # Internal modules - "vllm.third_party" - "vllm.vllm_flash_attn" - - "re:vllm\\.grpc\\..*_pb2.*" # Auto-generated protobuf files + - "vllm.transformers_utils.configs" + - "vllm.transformers_utils.processors" - !ENV [API_AUTONAV_EXCLUDE, "re:^$"] # Match nothing by default - mkdocstrings: handlers: python: options: - show_symbol_type_heading: true - show_symbol_type_toc: true - filters: - - "!.*_pb2_grpc" # Exclude auto-generated gRPC stubs - summary: - modules: true - show_signature_annotations: true - separate_signature: true + filters: [] show_overloads: true signature_crossrefs: true + # Recommendations from api-autonav + docstring_section_style: list + parameter_headings: true + show_symbol_type_heading: true + show_symbol_type_toc: true + summary: true inventories: - https://docs.python.org/3/objects.inv - https://typing-extensions.readthedocs.io/en/latest/objects.inv @@ -110,7 +110,11 @@ plugins: redirect_maps: features/spec_decode/README.md: features/speculative_decoding/README.md features/spec_decode/speculators.md: features/speculative_decoding/speculators.md + features/quantization/fp8.md: features/quantization/llm_compressor/fp8.md + features/quantization/int4.md: features/quantization/llm_compressor/int4.md + features/quantization/int8.md: features/quantization/llm_compressor/int8_w8a8.md serving/openai_compatible_server.md: serving/online_serving/README.md + examples/others/lmcache.md: examples/disaggregated/lmcache.md markdown_extensions: - attr_list diff --git a/pyproject.toml b/pyproject.toml index c782cc326bc1..3819ad7fc8e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,8 +129,9 @@ extend-exclude = ["tests/models/fixtures/*", "tests/prompts/*", "tests/tokenizer "docs/governance/process.md", "docs/assets/contributing/vllm_bench_serve_timeline.html", "tests/v1/engine/test_fast_incdec_prefix_err.py", ".git/*", "csrc/cpu/sgl-kernels/*", "rust/src/chat/src/renderer/deepseek_v32/fixtures/*", - "rust/src/tool-parser/src/gemma4.rs", "rust/src/text/src/output/decoded.rs", - "rust/src/tokenizer/src/incremental.rs", "rust/src/reasoning-parser/src/tests.rs"] + "rust/src/parser/src/tool/gemma4.rs", "rust/src/parser/src/unified/gemma4.rs", + "rust/src/text/src/output/decoded.rs", + "rust/src/tokenizer/src/incremental.rs", "rust/src/parser/src/reasoning/tests.rs"] ignore-hidden = false [tool.typos.default] @@ -162,6 +163,8 @@ dout = "dout" Pn = "Pn" arange = "arange" thw = "thw" +# temporal position ids (parallels hpos/wpos in vision RoPE) +tpos = "tpos" subtile = "subtile" HSA = "HSA" setp = "setp" diff --git a/requirements/build/cpu.txt b/requirements/build/cpu.txt index 640432ddd8cc..27a3ac65c986 100644 --- a/requirements/build/cpu.txt +++ b/requirements/build/cpu.txt @@ -1,4 +1,3 @@ ---extra-index-url https://download.pytorch.org/whl/cpu cmake>=3.26.1 ninja packaging>=24.2 diff --git a/requirements/build/rust.txt b/requirements/build/rust.txt new file mode 100644 index 000000000000..e2874dee0aba --- /dev/null +++ b/requirements/build/rust.txt @@ -0,0 +1,4 @@ +# Dependencies for building Rust artifacts through setuptools-rust. +setuptools>=77.0.3,<81.0.0 +setuptools-rust>=1.9.0 +wheel diff --git a/requirements/common.txt b/requirements/common.txt index d37ef1f1fedc..1652480c22fc 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -7,17 +7,18 @@ requests >= 2.26.0 tqdm blake3 py-cpuinfo -transformers >= 4.56.0, != 5.0.*, != 5.1.*, != 5.2.*, != 5.3.*, != 5.4.*, != 5.5.0 +transformers >= 5.5.3 tokenizers >= 0.21.1 # Required for fast incremental detokenization. safetensors >= 0.6.2 # MXFP4/MXFP6 dtype support (F8_E8M0, F4) added in 0.6.0: https://github.com/huggingface/safetensors/pull/611 protobuf >= 5.29.6, !=6.30.*, !=6.31.*, !=6.32.*, !=6.33.0.*, !=6.33.1.*, !=6.33.2.*, !=6.33.3.*, !=6.33.4.* # Required by LlamaTokenizer, gRPC. CVE-2026-0994 -fastapi[standard] >= 0.115.0 # Required by FastAPI's form models in the OpenAI API server's audio transcriptions endpoint. +fastapi[standard] >= 0.133.0, < 0.137.0 # First version supporting Starlette 1.0; < 0.137.0 avoids route-tree change that breaks model-hosting-container-standards handler overrides. +starlette >= 1.0.1 # CVE-2026-48710: Host header injection in < 1.0.1 aiohttp >= 3.13.3 openai >= 2.0.0 # For Responses API with reasoning content pydantic >= 2.12.0 prometheus_client >= 0.18.0 pillow # Required for image processing -prometheus-fastapi-instrumentator >= 7.0.0 +prometheus-fastapi-instrumentator >= 8.0.0 # v8 unblocks starlette >= 1.0 tiktoken >= 0.6.0 # Required for DBRX tokenizer lm-format-enforcer == 0.11.3 llguidance >= 1.7.0, < 1.8.0; platform_machine == "x86_64" or platform_machine == "arm64" or platform_machine == "aarch64" or platform_machine == "ppc64le" @@ -25,20 +26,20 @@ outlines_core == 0.2.14 # required for outlines backend disk cache diskcache == 5.6.3 lark == 1.2.2 -xgrammar >= 0.2.0, < 1.0.0; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le" +xgrammar >= 0.2.1, < 1.0.0; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le" typing_extensions >= 4.10 filelock >= 3.16.1 # need to contain https://github.com/tox-dev/filelock/pull/317 partial-json-parser # used for parsing partial JSON outputs +jsonschema >= 4.23.0 # required for MiniMax M3 tool schema validation pyzmq >= 25.0.0 msgspec -gguf >= 0.17.0 -mistral_common[image] >= 1.11.2 +mistral_common[image] >= 1.11.5 opencv-python-headless >= 4.13.0 # required for video IO pyyaml six>=1.16.0; python_version > '3.11' # transitive dependency of pandas that needs to be the latest version for python 3.12 setuptools>=77.0.3,<81.0.0; python_version > '3.11' # Setuptools is used by triton, we need to ensure a modern version is installed for 3.12+ so that it does not try to import distutils, which was removed in 3.12 einops # Required for Qwen2-VL. -compressed-tensors == 0.15.0.1 # required for compressed-tensors +compressed-tensors == 0.17.0 # required for compressed-tensors depyf==0.20.0 # required for profiling and debugging with compilation config cloudpickle # allows pickling lambda functions in model_executor/models/registry.py watchfiles # required for http server to monitor the updates of TLS files diff --git a/requirements/cpu.txt b/requirements/cpu.txt index 5ec338af7362..c0b98d22c9b9 100644 --- a/requirements/cpu.txt +++ b/requirements/cpu.txt @@ -1,4 +1,3 @@ ---extra-index-url https://download.pytorch.org/whl/cpu # Common dependencies -r common.txt @@ -16,6 +15,9 @@ torchaudio; platform_machine != "s390x" and platform_machine != "riscv64" # required for the image processor of phi3v, this must be updated alongside torch torchvision; platform_machine != "s390x" and platform_machine != "riscv64" +# required for the torchcodec video decoding backend +torchcodec >= 0.14; platform_machine != "s390x" and platform_machine != "riscv64" and platform_machine != "ppc64le" + # Intel Extension for PyTorch, only for x86_64 CPUs intel-openmp==2024.2.1; platform_machine == "x86_64" diff --git a/requirements/cuda.txt b/requirements/cuda.txt index b0e16d11c75c..116aca3c7c62 100644 --- a/requirements/cuda.txt +++ b/requirements/cuda.txt @@ -8,24 +8,25 @@ torch==2.11.0 torchaudio==2.11.0 # These must be updated alongside torch torchvision==0.26.0 # Required for phi3v processor. See https://github.com/pytorch/vision?tab=readme-ov-file#installation for corresponding version +torchcodec >= 0.14 +PyNvVideoCodec==2.0.4 # FlashInfer should be updated together with the Dockerfile -flashinfer-python==0.6.12 -flashinfer-cubin==0.6.12 +flashinfer-python==0.6.13 +flashinfer-cubin==0.6.13 apache-tvm-ffi==0.1.9 tilelang==0.1.9 -# Cap nvidia-cudnn-frontend (transitive dep of flashinfer) due to -# breaking changes in 1.19.0 -nvidia-cudnn-frontend>=1.13.0,<1.19.0 - +nvidia-cudnn-frontend>=1.19.1 +# Required for LLM_NVTX_SCOPES_FOR_PROFILING=1 +nvtx==0.2.15 # Required for faster safetensors model loading -fastsafetensors >= 0.2.2 +fastsafetensors >= 0.3.2 # QuACK and Cutlass DSL for FA4 (cute-DSL implementation) nvidia-cutlass-dsl[cu13]==4.5.2 quack-kernels>=0.3.3 # Tokenspeed_MLA for faster mla with spec decode -tokenspeed-mla==0.1.2 +tokenspeed-mla==0.1.2; platform_system == "Linux" # Humming kernels for quantization gemm -humming-kernels[cu13]==0.1.2 +humming-kernels[cu13]==0.1.10 diff --git a/requirements/kv_connectors.txt b/requirements/kv_connectors.txt index 7a5b5f25c37a..ce920816db3e 100644 --- a/requirements/kv_connectors.txt +++ b/requirements/kv_connectors.txt @@ -2,5 +2,5 @@ lmcache >= 0.3.9 # CuPy 14.1.0 imports pytest from cupy.testing._random. Use <14.1.0 # until a fixed newer release is verified for runtime images. cupy-cuda13x < 14.1.0 -nixl >= 1.1.0 # Required for disaggregated prefill +nixl == 1.3.0 mooncake-transfer-engine >= 0.3.8 diff --git a/requirements/rocm.txt b/requirements/rocm.txt index 0520f4ca1e91..5179f6ee8d74 100644 --- a/requirements/rocm.txt +++ b/requirements/rocm.txt @@ -19,7 +19,11 @@ setuptools-rust>=1.9.0 runai-model-streamer[s3,gcs,azure]==0.15.7 conch-triton-kernels==1.2.1 timm>=1.0.17 -# amd-quark: required for Quark quantization on ROCm +# amd-quark: required for Quark quantization on ROCm # To be consistent with test_quark.py amd-quark>=0.8.99 tilelang==0.1.10 +# Required apache-tvm-ffi matching tilelang version +apache-tvm-ffi==0.1.10 +# Required for faster safetensors model loading +fastsafetensors >= 0.3.2 diff --git a/requirements/test/cpu.txt b/requirements/test/cpu.txt new file mode 100644 index 000000000000..da2c7b772104 --- /dev/null +++ b/requirements/test/cpu.txt @@ -0,0 +1,1285 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/test/cuda.in -o requirements/test/cpu.txt --index-strategy unsafe-best-match --torch-backend cpu --python-platform x86_64-manylinux_2_28 --python-version 3.12 +absl-py==2.1.0 + # via rouge-score +accelerate==1.13.0 + # via peft +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.13.3 + # via + # -r requirements/test/../common.txt + # aiohttp-cors + # datasets + # fsspec + # gpt-oss + # lm-eval + # ray +aiohttp-cors==0.8.1 + # via ray +aiosignal==1.4.0 + # via aiohttp +albumentations==1.4.6 + # via -r requirements/test/cuda.in +alembic==1.16.4 + # via optuna +annotated-doc==0.0.4 + # via + # fastapi + # typer +annotated-types==0.7.0 + # via pydantic +anthropic==0.112.0 + # via -r requirements/test/../common.txt +anyio==4.14.1 + # via + # anthropic + # httpx + # mcp + # openai + # sse-starlette + # starlette + # watchfiles +apache-tvm-ffi==0.1.9 + # via xgrammar +arctic-inference==0.1.1 + # via -r requirements/test/cuda.in +argcomplete==3.5.1 + # via datamodel-code-generator +astor==0.8.1 + # via depyf +attrs==24.2.0 + # via + # aiohttp + # hypothesis + # jsonschema + # referencing +audioread==3.0.1 + # via librosa +av==16.1.0 + # via -r requirements/test/cuda.in +azure-core==1.38.2 + # via + # azure-identity + # azure-storage-blob +azure-identity==1.25.2 + # via runai-model-streamer-azure +azure-storage-blob==12.28.0 + # via runai-model-streamer-azure +backoff==2.2.1 + # via -r requirements/test/cuda.in +bitsandbytes==0.49.2 + # via -r requirements/test/cuda.in +black==24.10.0 + # via datamodel-code-generator +blake3==1.0.9 + # via -r requirements/test/../common.txt +blobfile==3.0.0 + # via -r requirements/test/cuda.in +bm25s==0.2.13 + # via mteb +boto3==1.35.57 + # via + # runai-model-streamer-s3 + # tensorizer +botocore==1.35.57 + # via + # boto3 + # s3transfer +bounded-pool-executor==0.0.3 + # via pqdm +buildkite-test-collector==0.1.9 + # via -r requirements/test/cuda.in +cachetools==5.5.2 + # via + # -r requirements/test/../common.txt + # google-auth +cbor2==6.1.2 + # via -r requirements/test/../common.txt +certifi==2024.8.30 + # via + # httpcore + # httpx + # requests + # sentry-sdk +cffi==2.0.0 + # via + # cryptography + # soundfile +chardet==5.2.0 + # via mbstrdecoder +charset-normalizer==3.4.0 + # via requests +chz==0.3.0 + # via gpt-oss +click==8.4.2 + # via + # black + # huggingface-hub + # jiwer + # nltk + # ray + # rich-toolkit + # schemathesis + # uvicorn +cloudpickle==3.1.2 + # via -r requirements/test/../common.txt +cohere-melody==0.9.0 + # via -r requirements/test/cuda.in +colorama==0.4.6 + # via + # perceptron + # sacrebleu +colorful==0.5.6 + # via ray +colorlog==6.10.1 + # via optuna +compressed-tensors==0.17.0 + # via -r requirements/test/../common.txt +contourpy==1.3.0 + # via matplotlib +coverage==7.10.6 + # via pytest-cov +cramjam==2.9.0 + # via fastparquet +cryptography==46.0.5 + # via + # azure-identity + # azure-storage-blob + # msal + # pyjwt +cupy-cuda12x==13.6.0 + # via ray +cycler==0.12.1 + # via matplotlib +datamodel-code-generator==0.26.3 + # via -r requirements/test/cuda.in +dataproperty==1.0.1 + # via + # pytablewriter + # tabledata +datasets==3.3.0 + # via + # -r requirements/test/cuda.in + # evaluate + # lm-eval + # mteb +decorator==5.1.1 + # via librosa +decord==0.6.0 + # via -r requirements/test/cuda.in +depyf==0.20.0 + # via -r requirements/test/../common.txt +detect-installer==0.1.0 + # via fastapi-cloud-cli +dill==0.3.8 + # via + # datasets + # depyf + # evaluate + # lm-eval + # multiprocess +diskcache==5.6.3 + # via -r requirements/test/../common.txt +distlib==0.3.9 + # via virtualenv +distro==1.9.0 + # via + # anthropic + # openai +dnspython==2.7.0 + # via email-validator +docker==7.1.0 + # via gpt-oss +docopt==0.6.2 + # via num2words +docstring-parser==0.18.0 + # via anthropic +einops==0.8.1 + # via + # -r requirements/test/../common.txt + # encodec + # vector-quantize-pytorch + # vocos +einx==0.3.0 + # via vector-quantize-pytorch +email-validator==2.2.0 + # via + # fastapi + # pydantic +encodec==0.1.1 + # via vocos +et-xmlfile==2.0.0 + # via openpyxl +evaluate==0.4.3 + # via lm-eval +fastapi==0.136.3 + # via + # -r requirements/test/../common.txt + # gpt-oss + # model-hosting-container-standards +fastapi-cli==0.0.27 + # via fastapi +fastapi-cloud-cli==0.21.0 + # via fastapi-cli +fastar==0.11.0 + # via + # fastapi + # fastapi-cloud-cli +fastparquet==2024.11.0 + # via genai-perf +fastrlock==0.8.2 + # via cupy-cuda12x +fastsafetensors==0.3.2 + # via -r requirements/test/cuda.in +filelock==3.16.1 + # via + # -r requirements/test/../common.txt + # blobfile + # datasets + # huggingface-hub + # ray + # torch + # virtualenv +fonttools==4.55.0 + # via matplotlib +frozendict==2.4.6 + # via einx +frozenlist==1.5.0 + # via + # aiohttp + # aiosignal +fsspec==2024.12.0 + # via + # datasets + # evaluate + # fastparquet + # huggingface-hub + # torch +ftfy==6.3.1 + # via open-clip-torch +genai-perf==0.0.16 + # via -r requirements/test/cuda.in +genson==1.3.0 + # via datamodel-code-generator +google-api-core==2.24.2 + # via + # google-cloud-core + # google-cloud-storage + # opencensus +google-auth==2.40.2 + # via + # google-api-core + # google-cloud-core + # google-cloud-storage + # runai-model-streamer-gcs +google-cloud-core==2.4.3 + # via google-cloud-storage +google-cloud-storage==3.4.0 + # via runai-model-streamer-gcs +google-crc32c==1.7.1 + # via + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.2 + # via google-cloud-storage +googleapis-common-protos==1.70.0 + # via + # google-api-core + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +gpt-oss==0.0.8 + # via -r requirements/test/cuda.in +graphql-core==3.2.6 + # via hypothesis-graphql +greenlet==3.2.3 + # via sqlalchemy +grpcio==1.78.0 + # via + # -r requirements/test/cuda.in + # grpcio-reflection + # opentelemetry-exporter-otlp-proto-grpc + # ray +grpcio-reflection==1.78.0 + # via -r requirements/test/cuda.in +h11==0.14.0 + # via + # httpcore + # uvicorn +h2==4.3.0 + # via httpx +harfile==0.5.0 + # via schemathesis +hf-xet==1.5.1 + # via huggingface-hub +hiredis==3.0.0 + # via tensorizer +hpack==4.1.0 + # via h2 +html2text==2025.4.15 + # via gpt-oss +httpcore==1.0.6 + # via httpx +httptools==0.8.0 + # via uvicorn +httpx==0.27.2 + # via + # -r requirements/test/cuda.in + # anthropic + # fastapi + # fastapi-cloud-cli + # huggingface-hub + # mcp + # model-hosting-container-standards + # openai + # perceptron + # schemathesis +httpx-sse==0.4.3 + # via mcp +huggingface-hub==1.22.0 + # via + # accelerate + # datasets + # evaluate + # open-clip-torch + # peft + # segmentation-models-pytorch + # sentence-transformers + # timm + # tokenizers + # transformers + # vocos +humanize==4.11.0 + # via runai-model-streamer +hyperframe==6.1.0 + # via h2 +hypothesis==6.131.0 + # via + # hypothesis-graphql + # hypothesis-jsonschema + # schemathesis +hypothesis-graphql==0.13.0 + # via schemathesis +hypothesis-jsonschema==0.23.1 + # via schemathesis +idna==3.10 + # via + # anyio + # email-validator + # httpx + # requests + # yarl +ijson==3.5.0 + # via -r requirements/test/../common.txt +imagehash==4.3.2 + # via -r requirements/test/cuda.in +imageio==2.37.0 + # via scikit-image +importlib-metadata==8.7.0 + # via opentelemetry-api +inflect==5.6.2 + # via datamodel-code-generator +iniconfig==2.0.0 + # via pytest +instanttensor==0.1.5 + # via -r requirements/test/cuda.in +interegular==0.3.3 + # via lm-format-enforcer +isodate==0.7.2 + # via azure-storage-blob +isort==5.13.2 + # via datamodel-code-generator +jinja2==3.1.6 + # via + # datamodel-code-generator + # fastapi + # genai-perf + # lm-eval + # torch +jiter==0.15.0 + # via + # anthropic + # openai +jiwer==3.0.5 + # via -r requirements/test/cuda.in +jmespath==1.0.1 + # via + # boto3 + # botocore + # model-hosting-container-standards +joblib==1.4.2 + # via + # librosa + # nltk + # scikit-learn +jsonschema==4.23.0 + # via + # -r requirements/test/../common.txt + # hypothesis-jsonschema + # mcp + # mistral-common + # ray +jsonschema-rs==0.46.5 + # via schemathesis +jsonschema-specifications==2024.10.1 + # via jsonschema +junit-xml==1.9 + # via schemathesis +kaldi-native-fbank==1.22.3 + # via -r requirements/test/cuda.in +kaleido==0.2.1 + # via genai-perf +kiwisolver==1.4.7 + # via matplotlib +lark==1.2.2 + # via -r requirements/test/../common.txt +lazy-loader==0.4 + # via + # librosa + # scikit-image +libnacl==2.1.0 + # via tensorizer +librosa==0.10.2.post1 + # via -r requirements/test/cuda.in +llguidance==1.7.6 + # via -r requirements/test/../common.txt +llvmlite==0.47.0 + # via numba +lm-eval==0.4.12 + # via -r requirements/test/cuda.in +lm-format-enforcer==0.11.3 + # via -r requirements/test/../common.txt +loguru==0.7.3 + # via compressed-tensors +lxml==5.3.0 + # via + # blobfile + # gpt-oss + # sacrebleu +mako==1.3.10 + # via alembic +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.1 + # via + # jinja2 + # mako + # werkzeug +matplotlib==3.9.2 + # via -r requirements/test/cuda.in +mbstrdecoder==1.1.3 + # via + # dataproperty + # pytablewriter + # typepy +mcp==1.28.1 + # via -r requirements/test/../common.txt +mdurl==0.1.2 + # via markdown-it-py +mistral-common==1.11.5 + # via + # -r requirements/test/../common.txt + # -r requirements/test/cuda.in +model-hosting-container-standards==0.1.16 + # via -r requirements/test/../common.txt +more-itertools==10.5.0 + # via lm-eval +mpmath==1.3.0 + # via sympy +msal==1.34.0 + # via + # azure-identity + # msal-extensions +msal-extensions==1.3.1 + # via azure-identity +msgpack==1.1.0 + # via + # librosa + # ray +msgspec==0.21.1 + # via -r requirements/test/../common.txt +mteb==2.8.3 + # via -r requirements/test/cuda.in +multidict==6.1.0 + # via + # aiohttp + # yarl +multiprocess==0.70.16 + # via + # datasets + # evaluate +mypy-extensions==1.0.0 + # via black +networkx==3.2.1 + # via + # scikit-image + # torch +ninja==1.13.0 + # via -r requirements/test/../common.txt +nltk==3.9.1 + # via rouge-score +num2words==0.5.14 + # via -r requirements/test/cuda.in +numba==0.65.0 + # via + # -r requirements/test/cuda.in + # librosa +numpy==2.2.6 + # via + # -r requirements/test/../common.txt + # accelerate + # albumentations + # bitsandbytes + # bm25s + # contourpy + # cupy-cuda12x + # datasets + # decord + # einx + # encodec + # evaluate + # fastparquet + # genai-perf + # imagehash + # imageio + # librosa + # lm-eval + # matplotlib + # mistral-common + # mteb + # numba + # opencv-python-headless + # optuna + # pandas + # patsy + # peft + # perceptron + # pywavelets + # rouge-score + # runai-model-streamer + # sacrebleu + # scikit-image + # scikit-learn + # scipy + # segmentation-models-pytorch + # soxr + # statsmodels + # tensorizer + # tifffile + # torchvision + # transformers + # tritonclient + # vocos + # xgrammar +open-clip-torch==2.32.0 + # via -r requirements/test/cuda.in +openai==2.44.0 + # via -r requirements/test/../common.txt +openai-harmony==0.0.4 + # via + # -r requirements/test/../common.txt + # gpt-oss +opencensus==0.11.4 + # via ray +opencensus-context==0.1.3 + # via opencensus +opencv-python-headless==4.13.0.90 + # via + # -r requirements/test/../common.txt + # albumentations + # mistral-common +openpyxl==3.1.5 + # via -r requirements/test/cuda.in +opentelemetry-api==1.35.0 + # via + # -r requirements/test/../common.txt + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-exporter-prometheus + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp==1.35.0 + # via -r requirements/test/../common.txt +opentelemetry-exporter-otlp-proto-common==1.35.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-grpc==1.35.0 + # via opentelemetry-exporter-otlp +opentelemetry-exporter-otlp-proto-http==1.35.0 + # via opentelemetry-exporter-otlp +opentelemetry-exporter-prometheus==0.56b0 + # via ray +opentelemetry-proto==1.35.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # ray +opentelemetry-sdk==1.35.0 + # via + # -r requirements/test/../common.txt + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-exporter-prometheus + # ray +opentelemetry-semantic-conventions==0.56b0 + # via opentelemetry-sdk +opentelemetry-semantic-conventions-ai==0.4.13 + # via -r requirements/test/../common.txt +optuna==3.6.1 + # via genai-perf +orjson==3.11.5 + # via genai-perf +outlines-core==0.2.14 + # via -r requirements/test/../common.txt +packaging==24.2 + # via + # accelerate + # bitsandbytes + # black + # datamodel-code-generator + # datasets + # evaluate + # fastparquet + # huggingface-hub + # lazy-loader + # lm-format-enforcer + # matplotlib + # optuna + # peft + # plotly + # pooch + # pytest + # pytest-rerunfailures + # ray + # scikit-image + # statsmodels + # transformers + # typepy +pandas==2.2.3 + # via + # datasets + # evaluate + # fastparquet + # genai-perf + # statsmodels +partial-json-parser==0.2.1.1.post7 + # via -r requirements/test/../common.txt +pathspec==0.12.1 + # via black +pathvalidate==3.2.1 + # via pytablewriter +patsy==1.0.1 + # via statsmodels +peft==0.19.1 + # via -r requirements/test/cuda.in +perceptron==0.1.4 + # via -r requirements/test/cuda.in +perf-analyzer==0.1.0 + # via genai-perf +pillow==10.4.0 + # via + # -r requirements/test/../common.txt + # genai-perf + # imagehash + # imageio + # matplotlib + # mistral-common + # perceptron + # scikit-image + # segmentation-models-pytorch + # torchvision +platformdirs==4.3.6 + # via + # black + # pooch + # virtualenv +plotly==5.24.1 + # via + # -r requirements/test/cuda.in + # genai-perf +pluggy==1.5.0 + # via + # pytest + # pytest-cov +polars==1.29.0 + # via mteb +pooch==1.8.2 + # via librosa +portalocker==2.10.1 + # via sacrebleu +pqdm==0.2.0 + # via -r requirements/test/cuda.in +prometheus-client==0.22.0 + # via + # -r requirements/test/../common.txt + # opentelemetry-exporter-prometheus + # prometheus-fastapi-instrumentator + # ray +prometheus-fastapi-instrumentator==8.0.2 + # via -r requirements/test/../common.txt +propcache==0.2.0 + # via + # aiohttp + # yarl +proto-plus==1.26.1 + # via google-api-core +protobuf==6.33.6 + # via + # -r requirements/test/../common.txt + # google-api-core + # googleapis-common-protos + # grpcio-reflection + # opentelemetry-proto + # proto-plus + # ray + # tensorizer +psutil==6.1.0 + # via + # -r requirements/test/../common.txt + # accelerate + # peft + # tensorizer +py==1.11.0 + # via pytest-forked +py-cpuinfo==9.0.0 + # via -r requirements/test/../common.txt +py-spy==0.4.0 + # via ray +pyarrow==23.0.0 + # via + # datasets + # genai-perf +pyasn1==0.6.1 + # via + # pyasn1-modules + # rsa +pyasn1-modules==0.4.2 + # via google-auth +pybase64==1.4.3 + # via -r requirements/test/../common.txt +pycountry==24.6.1 + # via pydantic-extra-types +pycparser==2.22 + # via cffi +pycryptodomex==3.22.0 + # via blobfile +pydantic==2.12.0 + # via + # -r requirements/test/../common.txt + # albumentations + # anthropic + # compressed-tensors + # datamodel-code-generator + # fastapi + # fastapi-cloud-cli + # gpt-oss + # lm-format-enforcer + # mcp + # mistral-common + # model-hosting-container-standards + # mteb + # openai + # openai-harmony + # pydantic-extra-types + # pydantic-settings + # ray + # xgrammar +pydantic-core==2.41.1 + # via pydantic +pydantic-extra-types==2.10.5 + # via + # fastapi + # mistral-common +pydantic-settings==2.14.2 + # via + # fastapi + # mcp +pygments==2.18.0 + # via + # pytest + # rich +pyjwt==2.11.0 + # via + # mcp + # msal +pyparsing==3.2.0 + # via matplotlib +pyrate-limiter==4.4.0 + # via schemathesis +pystemmer==3.0.0 + # via mteb +pytablewriter==1.2.0 + # via lm-eval +pytest==9.1.0 + # via + # -r requirements/test/cuda.in + # buildkite-test-collector + # genai-perf + # pytest-asyncio + # pytest-cov + # pytest-forked + # pytest-mock + # pytest-rerunfailures + # pytest-shard + # pytest-timeout + # schemathesis +pytest-asyncio==1.4.0 + # via -r requirements/test/cuda.in +pytest-cov==6.3.0 + # via -r requirements/test/cuda.in +pytest-forked==1.6.0 + # via -r requirements/test/cuda.in +pytest-mock==3.14.0 + # via genai-perf +pytest-rerunfailures==14.0 + # via -r requirements/test/cuda.in +pytest-shard==0.1.2 + # via -r requirements/test/cuda.in +pytest-timeout==2.3.1 + # via -r requirements/test/cuda.in +python-dateutil==2.9.0.post0 + # via + # botocore + # matplotlib + # pandas + # typepy +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-json-logger==4.1.0 + # via -r requirements/test/../common.txt +python-multipart==0.0.32 + # via + # fastapi + # mcp +python-rapidjson==1.20 + # via tritonclient +pytrec-eval-terrier==0.5.7 + # via mteb +pytz==2024.2 + # via + # pandas + # typepy +pywavelets==1.9.0 + # via imagehash +pyyaml==6.0.2 + # via + # -r requirements/test/../common.txt + # accelerate + # albumentations + # datamodel-code-generator + # datasets + # genai-perf + # huggingface-hub + # lm-format-enforcer + # optuna + # peft + # ray + # responses + # schemathesis + # timm + # transformers + # uvicorn + # vocos +pyzmq==27.1.0 + # via -r requirements/test/../common.txt +rapidfuzz==3.12.1 + # via jiwer +ray==2.48.0 + # via -r requirements/test/cuda.in +redis==5.2.0 + # via tensorizer +referencing==0.35.1 + # via + # jsonschema + # jsonschema-specifications +regex==2026.2.28 + # via + # -r requirements/test/../common.txt + # nltk + # open-clip-torch + # sacrebleu + # tiktoken + # transformers +requests==2.32.3 + # via + # -r requirements/test/../common.txt + # azure-core + # buildkite-test-collector + # datasets + # docker + # evaluate + # google-api-core + # google-cloud-storage + # gpt-oss + # lm-eval + # mistral-common + # msal + # mteb + # opentelemetry-exporter-otlp-proto-http + # pooch + # ray + # responses + # schemathesis + # starlette-testclient + # tiktoken +responses==0.25.3 + # via genai-perf +rich==13.9.4 + # via + # genai-perf + # mteb + # perceptron + # rich-toolkit + # schemathesis + # typer +rich-toolkit==0.20.1 + # via + # fastapi-cli + # fastapi-cloud-cli +rignore==0.7.6 + # via fastapi-cloud-cli +rouge-score==0.1.2 + # via lm-eval +rpds-py==0.20.1 + # via + # jsonschema + # referencing +rsa==4.9.1 + # via google-auth +runai-model-streamer==0.15.7 + # via -r requirements/test/cuda.in +runai-model-streamer-azure==0.15.7 + # via runai-model-streamer +runai-model-streamer-gcs==0.15.7 + # via runai-model-streamer +runai-model-streamer-s3==0.15.7 + # via runai-model-streamer +s3transfer==0.10.3 + # via boto3 +sacrebleu==2.4.3 + # via lm-eval +safetensors==0.7.0 + # via + # -r requirements/test/../common.txt + # accelerate + # open-clip-torch + # peft + # segmentation-models-pytorch + # timm + # transformers +schemathesis==4.21.6 + # via -r requirements/test/cuda.in +scikit-image==0.25.2 + # via albumentations +scikit-learn==1.5.2 + # via + # albumentations + # librosa + # lm-eval + # mteb + # sentence-transformers +scipy==1.13.1 + # via + # albumentations + # bm25s + # imagehash + # librosa + # mteb + # scikit-image + # scikit-learn + # sentence-transformers + # statsmodels + # vocos +segmentation-models-pytorch==0.5.0 + # via -r requirements/test/cuda.in +sentence-transformers==5.2.0 + # via + # -r requirements/test/cuda.in + # mteb +sentencepiece==0.2.1 + # via -r requirements/test/../common.txt +sentry-sdk==2.63.0 + # via fastapi-cloud-cli +setproctitle==1.3.7 + # via -r requirements/test/../common.txt +setuptools==77.0.3 + # via + # -r requirements/test/../common.txt + # model-hosting-container-standards + # pytablewriter + # torch +shellingham==1.5.4 + # via + # perceptron + # typer +six==1.16.0 + # via + # -r requirements/test/../common.txt + # junit-xml + # opencensus + # python-dateutil + # rouge-score +smart-open==7.1.0 + # via ray +sniffio==1.3.1 + # via + # anthropic + # httpx + # openai +sortedcontainers==2.4.0 + # via hypothesis +soundfile==0.12.1 + # via + # -r requirements/test/cuda.in + # genai-perf + # librosa + # mistral-common +soxr==0.5.0.post1 + # via + # librosa + # mistral-common +sqlalchemy==2.0.41 + # via + # alembic + # optuna +sqlitedict==2.1.0 + # via lm-eval +sse-starlette==3.4.5 + # via mcp +starlette==1.3.1 + # via + # -r requirements/test/../common.txt + # fastapi + # mcp + # model-hosting-container-standards + # prometheus-fastapi-instrumentator + # sse-starlette + # starlette-testclient +starlette-testclient==0.4.1 + # via schemathesis +statsmodels==0.14.4 + # via genai-perf +structlog==25.4.0 + # via gpt-oss +supervisor==4.3.0 + # via model-hosting-container-standards +sympy==1.13.3 + # via + # einx + # torch +tabledata==1.3.3 + # via pytablewriter +tabulate==0.9.0 + # via sacrebleu +tblib==3.1.0 + # via -r requirements/test/cuda.in +tcolorpy==0.1.6 + # via pytablewriter +tenacity==9.1.2 + # via + # gpt-oss + # lm-eval + # plotly + # schemathesis +tensorizer==2.10.1 + # via -r requirements/test/cuda.in +termcolor==3.1.0 + # via gpt-oss +threadpoolctl==3.5.0 + # via scikit-learn +tifffile==2025.3.30 + # via scikit-image +tiktoken==0.12.0 + # via + # -r requirements/test/../common.txt + # gpt-oss + # lm-eval + # mistral-common +timm==1.0.17 + # via + # -r requirements/test/cuda.in + # open-clip-torch + # segmentation-models-pytorch +tokenizers==0.22.2 + # via + # -r requirements/test/../common.txt + # -r requirements/test/cuda.in + # transformers +torch==2.11.0+cpu + # via + # -r requirements/test/cuda.in + # accelerate + # bitsandbytes + # compressed-tensors + # encodec + # instanttensor + # mteb + # open-clip-torch + # peft + # runai-model-streamer + # segmentation-models-pytorch + # sentence-transformers + # tensorizer + # timm + # torchvision + # vector-quantize-pytorch + # vocos + # xgrammar +torchaudio==2.11.0+cpu + # via + # -r requirements/test/cuda.in + # encodec + # vocos +torchcodec==0.14.0+cpu + # via -r requirements/test/cuda.in +torchvision==0.26.0+cpu + # via + # -r requirements/test/cuda.in + # open-clip-torch + # segmentation-models-pytorch + # timm +tqdm==4.67.3 + # via + # -r requirements/test/../common.txt + # datasets + # evaluate + # huggingface-hub + # lm-eval + # mteb + # nltk + # open-clip-torch + # openai + # optuna + # peft + # pqdm + # segmentation-models-pytorch + # sentence-transformers + # transformers +transformers==5.10.4 + # via + # -r requirements/test/../common.txt + # -r requirements/test/cuda.in + # compressed-tensors + # genai-perf + # peft + # sentence-transformers + # transformers-stream-generator + # xgrammar +transformers-stream-generator==0.0.5 + # via -r requirements/test/cuda.in +triton==3.6.0 + # via xgrammar +tritonclient==2.64.0 + # via -r requirements/test/cuda.in +typepy==1.3.2 + # via + # dataproperty + # pytablewriter + # tabledata +typer==0.26.8 + # via + # fastapi-cli + # fastapi-cloud-cli + # fastsafetensors + # perceptron + # transformers +typing-extensions==4.15.0 + # via + # -r requirements/test/../common.txt + # aiosignal + # albumentations + # alembic + # anthropic + # anyio + # apache-tvm-ffi + # azure-core + # azure-identity + # azure-storage-blob + # chz + # fastapi + # grpcio + # huggingface-hub + # librosa + # lm-eval + # mcp + # mistral-common + # mteb + # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pqdm + # pydantic + # pydantic-core + # pydantic-extra-types + # pytest-asyncio + # rich-toolkit + # schemathesis + # sentence-transformers + # sqlalchemy + # starlette + # torch + # typing-inspection + # xgrammar +typing-inspection==0.4.2 + # via + # fastapi + # mcp + # pydantic + # pydantic-settings +tzdata==2024.2 + # via pandas +urllib3==2.2.3 + # via + # blobfile + # botocore + # docker + # requests + # responses + # sentry-sdk + # tritonclient +uvicorn==0.35.0 + # via + # fastapi + # fastapi-cli + # fastapi-cloud-cli + # gpt-oss + # mcp +uvloop==0.22.1 + # via uvicorn +vector-quantize-pytorch==1.21.2 + # via -r requirements/test/cuda.in +virtualenv==20.31.2 + # via ray +vocos==0.1.0 + # via -r requirements/test/cuda.in +watchfiles==1.2.0 + # via + # -r requirements/test/../common.txt + # uvicorn +wcwidth==0.2.13 + # via ftfy +websockets==16.0 + # via uvicorn +werkzeug==3.1.3 + # via schemathesis +word2number==1.1 + # via lm-eval +wrapt==1.17.2 + # via smart-open +xgrammar==0.2.3 + # via -r requirements/test/../common.txt +xxhash==3.5.0 + # via + # datasets + # evaluate +yarl==1.17.1 + # via aiohttp +zipp==3.23.0 + # via importlib-metadata diff --git a/requirements/test/cuda.in b/requirements/test/cuda.in index 6c786491603b..a0061fbf78cb 100644 --- a/requirements/test/cuda.in +++ b/requirements/test/cuda.in @@ -1,3 +1,5 @@ +-r ../common.txt + # testing pytest tensorizer==2.10.1 @@ -11,14 +13,14 @@ pytest-cov # testing utils albumentations # required for Nemotron Parse in test_common.py av # required for audio_in_video tests +torchcodec >= 0.14 # required for torchcodec video backend tests backoff # required for phi4mm test blobfile # required for kimi-vl test -einops # required for MPT, qwen-vl httpx librosa # required for audio tests vector_quantize_pytorch # required for minicpmo_26 test vocos # required for minicpmo_26 test -peft>=0.18.1 # required for phi-4-mm test +peft>=0.19.1 # required for phi-4-mm test pqdm ray[cgraph,default]>=2.48.0 # Ray Compiled Graph, required by pipeline parallelism tests sentence-transformers>=5.2.0 # required for embedding tests @@ -31,16 +33,15 @@ torchaudio==2.11.0 torchvision==0.26.0 transformers_stream_generator # required for qwen-vl test matplotlib # required for qwen-vl test -mistral_common[image,audio] >= 1.11.2 # required for voxtral test +mistral_common[image,audio] >= 1.11.5 # required for voxtral test num2words # required for smolvlm test open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_common.py -opencv-python-headless >= 4.13.0 # required for video test datamodel_code_generator # required for minicpm3 test lm-eval[api]>=0.4.12 # required for model evaluation test mteb[bm25s]>=2, <3 # required for mteb test -transformers==5.5.3 +transformers==5.10.4 tokenizers==0.22.2 -schemathesis>=3.39.15 # Required for openai schema test. +schemathesis>=4.0.0 # Required for openai schema test. # quantization bitsandbytes==0.49.2 buildkite-test-collector==0.1.9 @@ -55,11 +56,9 @@ grpcio-reflection==1.78.0 arctic-inference == 0.1.1; platform_machine == "x86_64" # Required for suffix decoding test numba == 0.65.0 # Required for N-gram speculative decoding -numpy runai-model-streamer[s3,gcs,azure]==0.15.7 -fastsafetensors>=0.2.2; platform_machine == "x86_64" # 0.2.2 contains important fixes for multi-GPU mem usage +fastsafetensors>=0.3.2 instanttensor>=0.1.5; platform_machine == "x86_64" -pydantic>=2.12 # 2.11 leads to error on python 3.13 decord==0.6.0; platform_machine == "x86_64" # terratorch is temporarily disabled while PyPI has the `lightning` package # in `quarantined` status (every published terratorch version transitively @@ -73,6 +72,7 @@ gpt-oss >= 0.0.7; python_version > '3.11' perceptron # required for isaac test kaldi-native-fbank >= 1.18.7 # required for fireredasr2 test +cohere_melody>=0.9.0 # required for cohere command reasoning parser test # Newer versions of datasets require torchcoded, that makes the tests fail in CI because of a missing library. # Older versions are in conflict with teerratorch requirements. diff --git a/requirements/test/cuda.txt b/requirements/test/cuda.txt index 245a86f93beb..2f7b942e9118 100644 --- a/requirements/test/cuda.txt +++ b/requirements/test/cuda.txt @@ -9,6 +9,7 @@ aiohappyeyeballs==2.6.1 aiohttp==3.13.3 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # aiohttp-cors # datasets # fsspec @@ -24,25 +25,39 @@ albumentations==1.4.6 alembic==1.16.4 # via optuna annotated-doc==0.0.4 - # via fastapi + # via + # fastapi + # typer annotated-types==0.7.0 # via pydantic -anyio==4.6.2.post1 +anthropic==0.112.0 # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +anyio==4.14.1 + # via + # anthropic # httpx + # mcp + # openai + # sse-starlette # starlette + # watchfiles +apache-tvm-ffi==0.1.9 + # via + # -c requirements/cuda.txt + # xgrammar arctic-inference==0.1.1 # via -r requirements/test/cuda.in argcomplete==3.5.1 # via datamodel-code-generator -arrow==1.3.0 - # via isoduration +astor==0.8.1 + # via depyf attrs==24.2.0 # via # aiohttp # hypothesis # jsonschema - # pytest-subtests # referencing audioread==3.0.1 # via librosa @@ -57,13 +72,13 @@ azure-identity==1.25.2 azure-storage-blob==12.28.0 # via runai-model-streamer-azure backoff==2.2.1 - # via - # -r requirements/test/cuda.in - # schemathesis + # via -r requirements/test/cuda.in bitsandbytes==0.49.2 # via -r requirements/test/cuda.in black==24.10.0 # via datamodel-code-generator +blake3==1.0.9 + # via -r requirements/test/../common.txt blobfile==3.0.0 # via -r requirements/test/cuda.in bm25s==0.2.13 @@ -81,12 +96,17 @@ bounded-pool-executor==0.0.3 buildkite-test-collector==0.1.9 # via -r requirements/test/cuda.in cachetools==5.5.2 - # via google-auth + # via + # -r requirements/test/../common.txt + # google-auth +cbor2==6.1.2 + # via -r requirements/test/../common.txt certifi==2024.8.30 # via # httpcore # httpx # requests + # sentry-sdk cffi==2.0.0 # via # cryptography @@ -97,24 +117,32 @@ charset-normalizer==3.4.0 # via requests chz==0.3.0 # via gpt-oss -click==8.1.7 +click==8.4.2 # via # black + # huggingface-hub # jiwer # nltk # ray + # rich-toolkit # schemathesis - # typer # uvicorn +cloudpickle==3.1.2 + # via -r requirements/test/../common.txt +cohere-melody==0.9.0 + # via -r requirements/test/cuda.in colorama==0.4.6 # via # perceptron # sacrebleu - # schemathesis colorful==0.5.6 # via ray colorlog==6.10.1 # via optuna +compressed-tensors==0.17.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt contourpy==1.3.0 # via matplotlib coverage==7.10.6 @@ -153,51 +181,81 @@ decorator==5.1.1 # via librosa decord==0.6.0 # via -r requirements/test/cuda.in +depyf==0.20.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +detect-installer==0.1.0 + # via fastapi-cloud-cli dill==0.3.8 # via # datasets + # depyf # evaluate # lm-eval # multiprocess +diskcache==5.6.3 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt distlib==0.3.9 # via virtualenv +distro==1.9.0 + # via + # anthropic + # openai dnspython==2.7.0 # via email-validator docker==7.1.0 # via gpt-oss docopt==0.6.2 # via num2words +docstring-parser==0.18.0 + # via anthropic einops==0.8.1 # via - # -r requirements/test/cuda.in + # -r requirements/test/../common.txt # encodec # vector-quantize-pytorch # vocos einx==0.3.0 # via vector-quantize-pytorch email-validator==2.2.0 - # via pydantic + # via + # fastapi + # pydantic encodec==0.1.1 # via vocos et-xmlfile==2.0.0 # via openpyxl evaluate==0.4.3 # via lm-eval -fastapi==0.128.0 +fastapi==0.136.3 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # gpt-oss + # model-hosting-container-standards +fastapi-cli==0.0.27 + # via fastapi +fastapi-cloud-cli==0.21.0 + # via fastapi-cli +fastar==0.11.0 + # via + # fastapi + # fastapi-cloud-cli fastparquet==2024.11.0 # via genai-perf fastrlock==0.8.2 # via cupy-cuda12x -fastsafetensors==0.2.2 +fastsafetensors==0.3.2 # via # -c requirements/cuda.txt # -r requirements/test/cuda.in filelock==3.16.1 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # blobfile # datasets # huggingface-hub @@ -206,8 +264,6 @@ filelock==3.16.1 # virtualenv fonttools==4.55.0 # via matplotlib -fqdn==1.5.1 - # via jsonschema frozendict==2.4.6 # via einx frozenlist==1.5.0 @@ -249,7 +305,10 @@ google-crc32c==1.7.1 google-resumable-media==2.7.2 # via google-cloud-storage googleapis-common-protos==1.70.0 - # via google-api-core + # via + # google-api-core + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http gpt-oss==0.0.8 # via -r requirements/test/cuda.in graphql-core==3.2.6 @@ -260,6 +319,7 @@ grpcio==1.78.0 # via # -r requirements/test/cuda.in # grpcio-reflection + # opentelemetry-exporter-otlp-proto-grpc # ray grpcio-reflection==1.78.0 # via -r requirements/test/cuda.in @@ -269,9 +329,9 @@ h11==0.14.0 # uvicorn h2==4.3.0 # via httpx -harfile==0.3.0 +harfile==0.5.0 # via schemathesis -hf-xet==1.4.3 +hf-xet==1.5.1 # via huggingface-hub hiredis==3.0.0 # via tensorizer @@ -281,13 +341,23 @@ html2text==2025.4.15 # via gpt-oss httpcore==1.0.6 # via httpx +httptools==0.8.0 + # via uvicorn httpx==0.27.2 # via # -r requirements/test/cuda.in + # anthropic + # fastapi + # fastapi-cloud-cli # huggingface-hub + # mcp + # model-hosting-container-standards + # openai # perceptron # schemathesis -huggingface-hub==1.10.2 +httpx-sse==0.4.3 + # via mcp +huggingface-hub==1.22.0 # via # accelerate # datasets @@ -309,7 +379,7 @@ hypothesis==6.131.0 # hypothesis-graphql # hypothesis-jsonschema # schemathesis -hypothesis-graphql==0.11.1 +hypothesis-graphql==0.13.0 # via schemathesis hypothesis-jsonschema==0.23.1 # via schemathesis @@ -318,9 +388,10 @@ idna==3.10 # anyio # email-validator # httpx - # jsonschema # requests # yarl +ijson==3.5.0 + # via -r requirements/test/../common.txt imagehash==4.3.2 # via -r requirements/test/cuda.in imageio==2.37.0 @@ -333,37 +404,45 @@ iniconfig==2.0.0 # via pytest instanttensor==0.1.5 # via -r requirements/test/cuda.in +interegular==0.3.3 + # via lm-format-enforcer isodate==0.7.2 # via azure-storage-blob -isoduration==20.11.0 - # via jsonschema isort==5.13.2 # via datamodel-code-generator jinja2==3.1.6 # via # datamodel-code-generator + # fastapi # genai-perf # lm-eval # torch +jiter==0.15.0 + # via + # anthropic + # openai jiwer==3.0.5 # via -r requirements/test/cuda.in jmespath==1.0.1 # via # boto3 # botocore + # model-hosting-container-standards joblib==1.4.2 # via # librosa # nltk # scikit-learn -jsonpointer==3.0.0 - # via jsonschema jsonschema==4.23.0 # via + # -c requirements/common.txt + # -r requirements/test/../common.txt # hypothesis-jsonschema + # mcp # mistral-common # ray - # schemathesis +jsonschema-rs==0.46.5 + # via schemathesis jsonschema-specifications==2024.10.1 # via jsonschema junit-xml==1.9 @@ -374,6 +453,10 @@ kaleido==0.2.1 # via genai-perf kiwisolver==1.4.7 # via matplotlib +lark==1.2.2 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt lazy-loader==0.4 # via # librosa @@ -382,10 +465,20 @@ libnacl==2.1.0 # via tensorizer librosa==0.10.2.post1 # via -r requirements/test/cuda.in +llguidance==1.7.6 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt llvmlite==0.47.0 # via numba lm-eval==0.4.12 # via -r requirements/test/cuda.in +lm-format-enforcer==0.11.3 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +loguru==0.7.3 + # via compressed-tensors lxml==5.3.0 # via # blobfile @@ -407,12 +500,19 @@ mbstrdecoder==1.1.3 # dataproperty # pytablewriter # typepy +mcp==1.28.1 + # via -r requirements/test/../common.txt mdurl==0.1.2 # via markdown-it-py -mistral-common==1.11.2 +mistral-common==1.11.5 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # -r requirements/test/cuda.in +model-hosting-container-standards==0.1.16 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt more-itertools==10.5.0 # via lm-eval mpmath==1.3.0 @@ -427,6 +527,8 @@ msgpack==1.1.0 # via # librosa # ray +msgspec==0.21.1 + # via -r requirements/test/../common.txt mteb==2.8.3 # via -r requirements/test/cuda.in multidict==6.1.0 @@ -443,6 +545,8 @@ networkx==3.2.1 # via # scikit-image # torch +ninja==1.13.0 + # via -r requirements/test/../common.txt nltk==3.9.1 # via rouge-score num2words==0.5.14 @@ -454,7 +558,7 @@ numba==0.65.0 # librosa numpy==2.2.6 # via - # -r requirements/test/cuda.in + # -r requirements/test/../common.txt # accelerate # albumentations # bitsandbytes @@ -498,6 +602,7 @@ numpy==2.2.6 # transformers # tritonclient # vocos + # xgrammar nvidia-cublas==13.1.0.3 # via # cuda-toolkit @@ -539,9 +644,14 @@ nvidia-nvtx==13.0.85 # via cuda-toolkit open-clip-torch==2.32.0 # via -r requirements/test/cuda.in +openai==2.44.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt openai-harmony==0.0.4 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # gpt-oss opencensus==0.11.4 # via ray @@ -550,7 +660,7 @@ opencensus-context==0.1.3 opencv-python-headless==4.13.0.90 # via # -c requirements/common.txt - # -r requirements/test/cuda.in + # -r requirements/test/../common.txt # albumentations # mistral-common openpyxl==3.1.5 @@ -558,24 +668,54 @@ openpyxl==3.1.5 opentelemetry-api==1.35.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http # opentelemetry-exporter-prometheus # opentelemetry-sdk # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp==1.35.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +opentelemetry-exporter-otlp-proto-common==1.35.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-grpc==1.35.0 + # via opentelemetry-exporter-otlp +opentelemetry-exporter-otlp-proto-http==1.35.0 + # via opentelemetry-exporter-otlp opentelemetry-exporter-prometheus==0.56b0 # via ray opentelemetry-proto==1.35.0 - # via ray + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # ray opentelemetry-sdk==1.35.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http # opentelemetry-exporter-prometheus # ray opentelemetry-semantic-conventions==0.56b0 # via opentelemetry-sdk +opentelemetry-semantic-conventions-ai==0.4.13 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt optuna==3.6.1 # via genai-perf orjson==3.11.5 # via genai-perf +outlines-core==0.2.14 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt packaging==24.2 # via # accelerate @@ -587,6 +727,7 @@ packaging==24.2 # fastparquet # huggingface-hub # lazy-loader + # lm-format-enforcer # matplotlib # optuna # peft @@ -606,13 +747,15 @@ pandas==2.2.3 # fastparquet # genai-perf # statsmodels +partial-json-parser==0.2.1.1.post7 + # via -r requirements/test/../common.txt pathspec==0.12.1 # via black pathvalidate==3.2.1 # via pytablewriter patsy==1.0.1 # via statsmodels -peft==0.18.1 +peft==0.19.1 # via -r requirements/test/cuda.in perceptron==0.1.4 # via -r requirements/test/cuda.in @@ -620,6 +763,7 @@ perf-analyzer==0.1.0 # via genai-perf pillow==10.4.0 # via + # -r requirements/test/../common.txt # genai-perf # imagehash # imageio @@ -653,8 +797,14 @@ pqdm==0.2.0 prometheus-client==0.22.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # opentelemetry-exporter-prometheus + # prometheus-fastapi-instrumentator # ray +prometheus-fastapi-instrumentator==8.0.2 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt propcache==0.2.0 # via # aiohttp @@ -664,6 +814,7 @@ proto-plus==1.26.1 protobuf==6.33.6 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # google-api-core # googleapis-common-protos # grpcio-reflection @@ -673,11 +824,14 @@ protobuf==6.33.6 # tensorizer psutil==6.1.0 # via + # -r requirements/test/../common.txt # accelerate # peft # tensorizer py==1.11.0 # via pytest-forked +py-cpuinfo==9.0.0 + # via -r requirements/test/../common.txt py-spy==0.4.0 # via ray pyarrow==23.0.0 @@ -690,6 +844,8 @@ pyasn1==0.6.1 # rsa pyasn1-modules==0.4.2 # via google-auth +pybase64==1.4.3 + # via -r requirements/test/../common.txt pycountry==24.6.1 # via pydantic-extra-types pycparser==2.22 @@ -699,33 +855,52 @@ pycryptodomex==3.22.0 pydantic==2.12.0 # via # -c requirements/common.txt - # -r requirements/test/cuda.in + # -r requirements/test/../common.txt # albumentations + # anthropic + # compressed-tensors # datamodel-code-generator # fastapi + # fastapi-cloud-cli # gpt-oss + # lm-format-enforcer + # mcp # mistral-common + # model-hosting-container-standards # mteb + # openai # openai-harmony # pydantic-extra-types + # pydantic-settings # ray + # xgrammar pydantic-core==2.41.1 # via pydantic pydantic-extra-types==2.10.5 - # via mistral-common + # via + # fastapi + # mistral-common +pydantic-settings==2.14.2 + # via + # fastapi + # mcp pygments==2.18.0 - # via rich + # via + # pytest + # rich pyjwt==2.11.0 - # via msal + # via + # mcp + # msal pyparsing==3.2.0 # via matplotlib -pyrate-limiter==3.7.0 +pyrate-limiter==4.4.0 # via schemathesis pystemmer==3.0.0 # via mteb pytablewriter==1.2.0 # via lm-eval -pytest==8.3.5 +pytest==9.1.0 # via # -r requirements/test/cuda.in # buildkite-test-collector @@ -736,10 +911,9 @@ pytest==8.3.5 # pytest-mock # pytest-rerunfailures # pytest-shard - # pytest-subtests # pytest-timeout # schemathesis -pytest-asyncio==0.24.0 +pytest-asyncio==1.4.0 # via -r requirements/test/cuda.in pytest-cov==6.3.0 # via -r requirements/test/cuda.in @@ -751,17 +925,24 @@ pytest-rerunfailures==14.0 # via -r requirements/test/cuda.in pytest-shard==0.1.2 # via -r requirements/test/cuda.in -pytest-subtests==0.14.1 - # via schemathesis pytest-timeout==2.3.1 # via -r requirements/test/cuda.in python-dateutil==2.9.0.post0 # via - # arrow # botocore # matplotlib # pandas # typepy +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-json-logger==4.1.0 + # via -r requirements/test/../common.txt +python-multipart==0.0.32 + # via + # fastapi + # mcp python-rapidjson==1.20 # via tritonclient pytrec-eval-terrier==0.5.7 @@ -774,12 +955,14 @@ pywavelets==1.9.0 # via imagehash pyyaml==6.0.2 # via + # -r requirements/test/../common.txt # accelerate # albumentations # datamodel-code-generator # datasets # genai-perf # huggingface-hub + # lm-format-enforcer # optuna # peft # ray @@ -787,7 +970,12 @@ pyyaml==6.0.2 # schemathesis # timm # transformers + # uvicorn # vocos +pyzmq==27.1.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt rapidfuzz==3.12.1 # via jiwer ray==2.48.0 @@ -800,6 +988,7 @@ referencing==0.35.1 # jsonschema-specifications regex==2026.2.28 # via + # -r requirements/test/../common.txt # nltk # open-clip-torch # sacrebleu @@ -808,6 +997,7 @@ regex==2026.2.28 requests==2.32.3 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # azure-core # buildkite-test-collector # datasets @@ -820,6 +1010,7 @@ requests==2.32.3 # mistral-common # msal # mteb + # opentelemetry-exporter-otlp-proto-http # pooch # ray # responses @@ -828,16 +1019,20 @@ requests==2.32.3 # tiktoken responses==0.25.3 # via genai-perf -rfc3339-validator==0.1.4 - # via jsonschema -rfc3987==1.3.8 - # via jsonschema rich==13.9.4 # via # genai-perf # mteb # perceptron + # rich-toolkit + # schemathesis # typer +rich-toolkit==0.20.1 + # via + # fastapi-cli + # fastapi-cloud-cli +rignore==0.7.6 + # via fastapi-cloud-cli rouge-score==0.1.2 # via lm-eval rpds-py==0.20.1 @@ -861,13 +1056,14 @@ sacrebleu==2.4.3 safetensors==0.7.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # accelerate # open-clip-torch # peft # segmentation-models-pytorch # timm # transformers -schemathesis==3.39.15 +schemathesis==4.21.6 # via -r requirements/test/cuda.in scikit-image==0.25.2 # via albumentations @@ -896,9 +1092,17 @@ sentence-transformers==5.2.0 # via # -r requirements/test/cuda.in # mteb +sentencepiece==0.2.1 + # via -r requirements/test/../common.txt +sentry-sdk==2.63.0 + # via fastapi-cloud-cli +setproctitle==1.3.7 + # via -r requirements/test/../common.txt setuptools==77.0.3 # via # -c requirements/common.txt + # -r requirements/test/../common.txt + # model-hosting-container-standards # pytablewriter # torch shellingham==1.5.4 @@ -908,17 +1112,18 @@ shellingham==1.5.4 six==1.16.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # junit-xml # opencensus # python-dateutil - # rfc3339-validator # rouge-score smart-open==7.1.0 # via ray sniffio==1.3.1 # via - # anyio + # anthropic # httpx + # openai sortedcontainers==2.4.0 # via hypothesis soundfile==0.12.1 @@ -937,10 +1142,17 @@ sqlalchemy==2.0.41 # optuna sqlitedict==2.1.0 # via lm-eval -starlette==0.50.0 +sse-starlette==3.4.5 + # via mcp +starlette==1.3.1 # via + # -c requirements/common.txt + # -r requirements/test/../common.txt # fastapi - # schemathesis + # mcp + # model-hosting-container-standards + # prometheus-fastapi-instrumentator + # sse-starlette # starlette-testclient starlette-testclient==0.4.1 # via schemathesis @@ -948,6 +1160,8 @@ statsmodels==0.14.4 # via genai-perf structlog==25.4.0 # via gpt-oss +supervisor==4.3.0 + # via model-hosting-container-standards sympy==1.13.3 # via # einx @@ -965,6 +1179,7 @@ tenacity==9.1.2 # gpt-oss # lm-eval # plotly + # schemathesis tensorizer==2.10.1 # via -r requirements/test/cuda.in termcolor==3.1.0 @@ -976,6 +1191,7 @@ tifffile==2025.3.30 tiktoken==0.12.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # gpt-oss # lm-eval # mistral-common @@ -987,18 +1203,16 @@ timm==1.0.17 tokenizers==0.22.2 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # -r requirements/test/cuda.in # transformers -tomli==2.2.1 - # via schemathesis -tomli-w==1.2.0 - # via schemathesis torch==2.11.0+cu130 # via # -c requirements/cuda.txt # -r requirements/test/cuda.in # accelerate # bitsandbytes + # compressed-tensors # encodec # instanttensor # mteb @@ -1012,12 +1226,17 @@ torch==2.11.0+cu130 # torchvision # vector-quantize-pytorch # vocos + # xgrammar torchaudio==2.11.0+cu130 # via # -c requirements/cuda.txt # -r requirements/test/cuda.in # encodec # vocos +torchcodec==0.14.0+cu130 + # via + # -c requirements/cuda.txt + # -r requirements/test/cuda.in torchvision==0.26.0+cu130 # via # -c requirements/cuda.txt @@ -1027,6 +1246,7 @@ torchvision==0.26.0+cu130 # timm tqdm==4.67.3 # via + # -r requirements/test/../common.txt # datasets # evaluate # huggingface-hub @@ -1034,24 +1254,30 @@ tqdm==4.67.3 # mteb # nltk # open-clip-torch + # openai # optuna # peft # pqdm # segmentation-models-pytorch # sentence-transformers # transformers -transformers==5.5.3 +transformers==5.10.4 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # -r requirements/test/cuda.in + # compressed-tensors # genai-perf # peft # sentence-transformers # transformers-stream-generator + # xgrammar transformers-stream-generator==0.0.5 # via -r requirements/test/cuda.in triton==3.6.0 - # via torch + # via + # torch + # xgrammar tritonclient==2.64.0 # via -r requirements/test/cuda.in typepy==1.3.2 @@ -1059,20 +1285,23 @@ typepy==1.3.2 # dataproperty # pytablewriter # tabledata -typer==0.15.2 +typer==0.26.8 # via + # fastapi-cli + # fastapi-cloud-cli # fastsafetensors - # huggingface-hub # perceptron # transformers -types-python-dateutil==2.9.0.20241206 - # via arrow typing-extensions==4.15.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # aiosignal # albumentations # alembic + # anthropic + # anyio + # apache-tvm-ffi # azure-core # azure-identity # azure-storage-blob @@ -1082,27 +1311,36 @@ typing-extensions==4.15.0 # huggingface-hub # librosa # lm-eval + # mcp # mistral-common # mteb + # openai # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http # opentelemetry-sdk # opentelemetry-semantic-conventions # pqdm # pydantic # pydantic-core # pydantic-extra-types + # pytest-asyncio + # rich-toolkit + # schemathesis # sentence-transformers # sqlalchemy # starlette # torch - # typer # typing-inspection + # xgrammar typing-inspection==0.4.2 - # via pydantic + # via + # fastapi + # mcp + # pydantic + # pydantic-settings tzdata==2024.2 # via pandas -uri-template==1.3.0 - # via jsonschema urllib3==2.2.3 # via # blobfile @@ -1110,32 +1348,46 @@ urllib3==2.2.3 # docker # requests # responses + # sentry-sdk # tritonclient uvicorn==0.35.0 - # via gpt-oss + # via + # fastapi + # fastapi-cli + # fastapi-cloud-cli + # gpt-oss + # mcp +uvloop==0.22.1 + # via uvicorn vector-quantize-pytorch==1.21.2 # via -r requirements/test/cuda.in virtualenv==20.31.2 # via ray vocos==0.1.0 # via -r requirements/test/cuda.in +watchfiles==1.2.0 + # via + # -r requirements/test/../common.txt + # uvicorn wcwidth==0.2.13 # via ftfy -webcolors==24.11.1 - # via jsonschema +websockets==16.0 + # via uvicorn werkzeug==3.1.3 # via schemathesis word2number==1.1 # via lm-eval wrapt==1.17.2 # via smart-open +xgrammar==0.2.3 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt xxhash==3.5.0 # via # datasets # evaluate yarl==1.17.1 - # via - # aiohttp - # schemathesis + # via aiohttp zipp==3.23.0 # via importlib-metadata diff --git a/requirements/test/nightly-torch.txt b/requirements/test/nightly-torch.txt index 9c70aa8b90e2..826473db1b23 100644 --- a/requirements/test/nightly-torch.txt +++ b/requirements/test/nightly-torch.txt @@ -23,15 +23,15 @@ jiwer # required for audio tests timm # required for internvl test transformers_stream_generator # required for qwen-vl test matplotlib # required for qwen-vl test -mistral_common[image,audio] >= 1.11.2 # required for voxtral test +mistral_common[image,audio] >= 1.11.5 # required for voxtral test num2words # required for smolvlm test opencv-python-headless >= 4.13.0 # required for video test datamodel_code_generator # required for minicpm3 test lm-eval[api]>=0.4.12 # required for model evaluation test mteb[bm25s]>=2, <3 # required for mteb test -transformers==5.5.3 +transformers==5.10.4 tokenizers==0.22.2 -schemathesis>=3.39.15 # Required for openai schema test. +schemathesis>=4.0.0 # Required for openai schema test. # quantization bitsandbytes>=0.49.2 buildkite-test-collector==0.1.9 @@ -43,6 +43,6 @@ tritonclient>=2.51.0 numba == 0.65.0 # Required for N-gram speculative decoding numpy runai-model-streamer[s3,gcs,azure]==0.15.7 -fastsafetensors>=0.2.2 +fastsafetensors>=0.3.2 instanttensor>=0.1.5 pydantic>=2.12 # 2.11 leads to error on python 3.13 diff --git a/requirements/test/rocm.in b/requirements/test/rocm.in index 97e0658fb106..b1c9a473e2fc 100644 --- a/requirements/test/rocm.in +++ b/requirements/test/rocm.in @@ -15,12 +15,11 @@ albumentations # required for Nemotron Parse in test_common.py av # required for audio_in_video tests backoff # required for phi4mm test blobfile # required for kimi-vl test -einops # required for MPT, qwen-vl httpx librosa # required for audio tests vector_quantize_pytorch # required for minicpmo_26 test vocos # required for minicpmo_26 test -peft>=0.15.0 # required for phi-4-mm test +peft>=0.19.1 # required for phi-4-mm test pqdm ray[cgraph,default]>=2.48.0 # Ray Compiled Graph, required by pipeline parallelism tests sentence-transformers>=5.2.0 # required for embedding tests @@ -30,16 +29,15 @@ tblib # for pickling test exceptions timm>=1.0.17 # required for internvl and gemma3n-mm test transformers_stream_generator # required for qwen-vl test matplotlib # required for qwen-vl test -mistral_common[image,audio]>=1.11.2 # required for voxtral test +mistral_common[image,audio]>=1.11.5 # required for voxtral test num2words # required for smolvlm test open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_common.py -opencv-python-headless>=4.13.0 # required for video test datamodel_code_generator # required for minicpm3 test lm-eval[api]>=0.4.12 # required for model evaluation test mteb[bm25s]>=2, <3 # required for mteb test -transformers==5.5.3 +transformers==5.10.4 tokenizers==0.22.2 -schemathesis>=3.39.15 # Required for openai schema test +schemathesis>=4.0.0 # Required for openai schema test # quantization bitsandbytes==0.49.2 buildkite-test-collector==0.1.9 @@ -54,11 +52,9 @@ grpcio-reflection==1.78.0 arctic-inference==0.1.1 # Required for suffix decoding test numba==0.65.0 # Required for N-gram speculative decoding -numpy runai-model-streamer[s3,gcs,azure]==0.15.7 -fastsafetensors @ git+https://github.com/foundation-model-stack/fastsafetensors.git@0.2.2 # PyPI only ships CUDA wheels +fastsafetensors>=0.3.2 instanttensor>=0.1.5 -pydantic>=2.12 # 2.11 leads to error on python 3.13 decord==0.6.0 # Prithvi tests @@ -74,6 +70,7 @@ gpt-oss>=0.0.7; python_version > '3.11' perceptron # required for isaac test kaldi-native-fbank>=1.18.7 # required for fireredasr2 test +cohere_melody>=0.9.0 # required for cohere command reasoning parser test # Newer versions of datasets require torchcoded, that makes the tests fail in CI because of a missing library. # Older versions are in conflict with terratorch requirements. diff --git a/requirements/test/rocm.txt b/requirements/test/rocm.txt index c39f268709b5..52a0ad88c9c1 100644 --- a/requirements/test/rocm.txt +++ b/requirements/test/rocm.txt @@ -44,21 +44,19 @@ anyio==4.13.0 # watchfiles apache-tvm-ffi==0.1.10 # via + # -c requirements/rocm.txt # tilelang # xgrammar arctic-inference==0.1.1 # via -r requirements/test/rocm.in argcomplete==3.6.3 # via datamodel-code-generator -arrow==1.4.0 - # via isoduration astor==0.8.1 # via depyf attrs==26.1.0 # via # aiohttp # jsonschema - # pytest-subtests # referencing audioread==3.0.1 # via librosa @@ -73,9 +71,7 @@ azure-identity==1.25.3 azure-storage-blob==12.28.0 # via runai-model-streamer-azure backoff==2.2.1 - # via - # -r requirements/test/rocm.in - # schemathesis + # via -r requirements/test/rocm.in bitsandbytes==0.49.2 # via -r requirements/test/rocm.in black==26.3.1 @@ -120,9 +116,10 @@ choreographer==1.2.1 # via kaleido chz==0.4.0 # via gpt-oss -click==8.3.1 +click==8.4.2 # via # black + # huggingface-hub # jiwer # nltk # ray @@ -134,16 +131,17 @@ cloudpickle==3.1.2 # via # -r requirements/test/../common.txt # tilelang +cohere-melody==0.9.0 + # via -r requirements/test/rocm.in colorama==0.4.6 # via # perceptron # sacrebleu - # schemathesis colorful==0.5.8 # via ray colorlog==6.10.1 # via optuna -compressed-tensors==0.15.0.1 +compressed-tensors==0.17.0 # via # -c requirements/common.txt # -r requirements/test/../common.txt @@ -210,7 +208,6 @@ docstring-parser==0.17.0 einops==0.8.2 # via # -r requirements/test/../common.txt - # -r requirements/test/rocm.in # encodec # vector-quantize-pytorch # vocos @@ -240,8 +237,10 @@ fastar==0.10.0 # via fastapi-cloud-cli fastparquet==2026.3.0 # via genai-perf -fastsafetensors @ git+https://github.com/foundation-model-stack/fastsafetensors.git@65d80088fca7a8f567fba30415fbcc80f7d2259c - # via -r requirements/test/rocm.in +fastsafetensors==0.3.2 + # via + # -c requirements/rocm.txt + # -r requirements/test/rocm.in filelock==3.25.2 # via # -c requirements/common.txt @@ -255,8 +254,6 @@ filelock==3.25.2 # virtualenv fonttools==4.62.1 # via matplotlib -fqdn==1.5.1 - # via jsonschema frozendict==2.4.7 # via einx frozenlist==1.8.0 @@ -276,10 +273,6 @@ genai-perf==0.0.16 # via -r requirements/test/rocm.in genson==1.3.0 # via datamodel-code-generator -gguf==0.18.0 - # via - # -c requirements/common.txt - # -r requirements/test/../common.txt google-api-core==2.30.0 # via # google-cloud-core @@ -329,9 +322,9 @@ h11==0.16.0 # uvicorn h2==4.3.0 # via httpx -harfile==0.4.0 +harfile==0.5.0 # via schemathesis -hf-xet==1.4.3 +hf-xet==1.5.1 # via huggingface-hub hiredis==3.3.1 # via tensorizer @@ -357,7 +350,7 @@ httpx==0.27.2 # schemathesis httpx-sse==0.4.3 # via mcp -huggingface-hub==1.10.2 +huggingface-hub==1.22.0 # via # accelerate # datasets @@ -379,7 +372,7 @@ hypothesis==6.151.9 # hypothesis-graphql # hypothesis-jsonschema # schemathesis -hypothesis-graphql==0.12.0 +hypothesis-graphql==0.13.0 # via schemathesis hypothesis-jsonschema==0.23.1 # via schemathesis @@ -388,7 +381,6 @@ idna==3.11 # anyio # email-validator # httpx - # jsonschema # requests # yarl ijson==3.5.0 @@ -409,8 +401,6 @@ interegular==0.3.3 # via lm-format-enforcer isodate==0.7.2 # via azure-storage-blob -isoduration==20.11.0 - # via jsonschema isort==8.0.1 # via datamodel-code-generator jinja2==3.1.6 @@ -436,15 +426,16 @@ joblib==1.5.3 # librosa # nltk # scikit-learn -jsonpointer==3.1.0 - # via jsonschema jsonschema==4.26.0 # via + # -c requirements/common.txt + # -r requirements/test/../common.txt # hypothesis-jsonschema # mcp # mistral-common # ray - # schemathesis +jsonschema-rs==0.46.5 + # via schemathesis jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 @@ -510,7 +501,7 @@ mcp==1.27.0 # via -r requirements/test/../common.txt mdurl==0.1.2 # via markdown-it-py -mistral-common==1.11.2 +mistral-common==1.11.5 # via # -c requirements/common.txt # -r requirements/test/../common.txt @@ -572,7 +563,6 @@ numba==0.65.0 numpy==2.2.6 # via # -r requirements/test/../common.txt - # -r requirements/test/rocm.in # accelerate # albumentations # bitsandbytes @@ -586,7 +576,6 @@ numpy==2.2.6 # evaluate # fastparquet # genai-perf - # gguf # imagehash # imageio # librosa @@ -642,7 +631,6 @@ opencv-python-headless==4.13.0.92 # via # -c requirements/common.txt # -r requirements/test/../common.txt - # -r requirements/test/rocm.in # albumentations # mistral-common openpyxl==3.1.5 @@ -743,7 +731,7 @@ pathvalidate==3.3.1 # via pytablewriter patsy==1.0.2 # via statsmodels -peft==0.18.1 +peft==0.19.1 # via -r requirements/test/rocm.in perceptron==0.1.4 # via -r requirements/test/rocm.in @@ -792,7 +780,7 @@ prometheus-client==0.24.1 # opentelemetry-exporter-prometheus # prometheus-fastapi-instrumentator # ray -prometheus-fastapi-instrumentator==7.1.0 +prometheus-fastapi-instrumentator==8.0.0 # via # -c requirements/common.txt # -r requirements/test/../common.txt @@ -846,7 +834,6 @@ pydantic==2.12.5 # via # -c requirements/common.txt # -r requirements/test/../common.txt - # -r requirements/test/rocm.in # albumentations # anthropic # compressed-tensors @@ -876,20 +863,22 @@ pydantic-settings==2.13.1 # fastapi # mcp pygments==2.19.2 - # via rich + # via + # pytest + # rich pyjwt==2.12.1 # via # mcp # msal pyparsing==3.3.2 # via matplotlib -pyrate-limiter==3.9.0 +pyrate-limiter==4.4.0 # via schemathesis pystemmer==3.0.0 # via mteb pytablewriter==1.2.1 # via lm-eval -pytest==8.3.5 +pytest==9.1.0 # via # -r requirements/test/rocm.in # buildkite-test-collector @@ -900,10 +889,9 @@ pytest==8.3.5 # pytest-mock # pytest-rerunfailures # pytest-shard - # pytest-subtests # pytest-timeout # schemathesis -pytest-asyncio==0.24.0 +pytest-asyncio==1.4.0 # via -r requirements/test/rocm.in pytest-cov==6.3.0 # via -r requirements/test/rocm.in @@ -915,13 +903,10 @@ pytest-rerunfailures==14.0 # via -r requirements/test/rocm.in pytest-shard==0.1.2 # via -r requirements/test/rocm.in -pytest-subtests==0.14.2 - # via schemathesis pytest-timeout==2.3.1 # via -r requirements/test/rocm.in python-dateutil==2.9.0.post0 # via - # arrow # botocore # matplotlib # pandas @@ -956,7 +941,6 @@ pyyaml==6.0.3 # datamodel-code-generator # datasets # genai-perf - # gguf # huggingface-hub # lm-format-enforcer # optuna @@ -1001,7 +985,6 @@ requests==2.32.5 # datasets # docker # evaluate - # gguf # google-api-core # google-cloud-storage # gpt-oss @@ -1018,16 +1001,13 @@ requests==2.32.5 # tiktoken responses==0.26.0 # via genai-perf -rfc3339-validator==0.1.4 - # via jsonschema -rfc3987==1.3.8 - # via jsonschema rich==14.3.3 # via # genai-perf # mteb # perceptron # rich-toolkit + # schemathesis # typer rich-toolkit==0.19.7 # via @@ -1065,7 +1045,7 @@ safetensors==0.7.0 # segmentation-models-pytorch # timm # transformers -schemathesis==3.39.15 +schemathesis==4.21.6 # via -r requirements/test/rocm.in scikit-image==0.26.0 # via albumentations @@ -1122,7 +1102,6 @@ six==1.17.0 # junit-xml # opencensus # python-dateutil - # rfc3339-validator # rouge-score smart-open==7.5.1 # via ray @@ -1151,13 +1130,14 @@ sqlitedict==2.1.0 # via lm-eval sse-starlette==3.3.4 # via mcp -starlette==0.52.1 +starlette==1.3.1 # via + # -c requirements/common.txt + # -r requirements/test/../common.txt # fastapi # mcp # model-hosting-container-standards # prometheus-fastapi-instrumentator - # schemathesis # sse-starlette # starlette-testclient starlette-testclient==0.4.1 @@ -1184,6 +1164,7 @@ tenacity==9.1.4 # via # gpt-oss # lm-eval + # schemathesis tensorizer==2.10.1 # via # -c requirements/rocm.txt @@ -1217,10 +1198,6 @@ tokenizers==0.22.2 # -r requirements/test/../common.txt # -r requirements/test/rocm.in # transformers -tomli==2.4.0 - # via schemathesis -tomli-w==1.2.0 - # via schemathesis torch-c-dlpack-ext==0.1.5 # via tilelang tqdm==4.67.3 @@ -1228,7 +1205,6 @@ tqdm==4.67.3 # -r requirements/test/../common.txt # datasets # evaluate - # gguf # huggingface-hub # lm-eval # mteb @@ -1242,7 +1218,7 @@ tqdm==4.67.3 # sentence-transformers # tilelang # transformers -transformers==5.5.3 +transformers==5.10.4 # via # -c requirements/common.txt # -r requirements/test/../common.txt @@ -1269,7 +1245,6 @@ typer==0.24.1 # fastapi-cli # fastapi-cloud-cli # fastsafetensors - # huggingface-hub # perceptron # transformers typing-extensions==4.15.0 @@ -1304,8 +1279,10 @@ typing-extensions==4.15.0 # pydantic # pydantic-core # pydantic-extra-types + # pytest-asyncio # referencing # rich-toolkit + # schemathesis # sentence-transformers # sqlalchemy # starlette @@ -1320,10 +1297,6 @@ typing-inspection==0.4.2 # mcp # pydantic # pydantic-settings -tzdata==2025.3 - # via arrow -uri-template==1.3.0 - # via jsonschema urllib3==2.6.3 # via # blobfile @@ -1354,8 +1327,6 @@ watchfiles==1.1.1 # uvicorn wcwidth==0.6.0 # via ftfy -webcolors==25.10.0 - # via jsonschema websockets==16.0 # via uvicorn werkzeug==3.1.6 @@ -1364,7 +1335,7 @@ word2number==1.1 # via lm-eval wrapt==2.1.2 # via smart-open -xgrammar==0.2.0 +xgrammar==0.2.1 # via # -c requirements/common.txt # -r requirements/test/../common.txt @@ -1373,9 +1344,7 @@ xxhash==3.6.0 # datasets # evaluate yarl==1.23.0 - # via - # aiohttp - # schemathesis + # via aiohttp z3-solver==4.15.4.0 # via tilelang zipp==3.23.0 diff --git a/requirements/test/xpu.in b/requirements/test/xpu.in index 94ffc249395a..1172553c4acb 100644 --- a/requirements/test/xpu.in +++ b/requirements/test/xpu.in @@ -1,3 +1,5 @@ +-r ../common.txt + # --- Test Infrastructure --- tblib pytest @@ -13,8 +15,9 @@ pytest-shard absl-py accelerate arctic-inference -lm_eval[api] -modelscope +lm_eval[api]>=0.4.12 +modelscope<1.38 +transformers==5.10.4 # --- Audio Processing --- librosa @@ -31,7 +34,7 @@ schemathesis jiwer bm25s pystemmer -mteb[bm25s] +mteb[bm25s]>=2, <3 # required for mteb test num2words pqdm diff --git a/requirements/test/xpu.txt b/requirements/test/xpu.txt index 5581d0a079c5..6335fc90cff3 100644 --- a/requirements/test/xpu.txt +++ b/requirements/test/xpu.txt @@ -11,6 +11,7 @@ aiohappyeyeballs==2.6.1 aiohttp==3.13.4 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # fsspec # gpt-oss # lm-eval @@ -24,22 +25,36 @@ annotated-doc==0.0.4 # typer annotated-types==0.7.0 # via pydantic +anthropic==0.112.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt anyio==4.13.0 # via + # anthropic # httpx + # mcp + # openai + # sse-starlette # starlette + # watchfiles +apache-tvm-ffi==0.1.12 + # via xgrammar arctic-inference==0.1.1 # via -r requirements/test/xpu.in +astor==0.8.1 + # via depyf attrs==26.1.0 # via # aiohttp - # jsonlines # jsonschema # referencing audioread==3.0.1 # via # -r requirements/test/xpu.in # librosa +blake3==1.0.9 + # via -r requirements/test/../common.txt blobfile==3.0.0 # via -r requirements/test/xpu.in bm25s==0.2.13 @@ -48,30 +63,47 @@ bm25s==0.2.13 # mteb bounded-pool-executor==0.0.3 # via pqdm +cachetools==7.1.4 + # via -r requirements/test/../common.txt +cbor2==6.1.2 + # via -r requirements/test/../common.txt certifi==2026.2.25 # via # httpcore # httpx # requests + # sentry-sdk cffi==2.0.0 - # via soundfile + # via + # cryptography + # soundfile chardet==5.2.0 # via mbstrdecoder charset-normalizer==3.4.6 # via requests chz==0.4.0 # via gpt-oss -click==8.3.1 +click==8.4.2 # via + # huggingface-hub # jiwer # nltk + # rich-toolkit # schemathesis # typer # uvicorn +cloudpickle==3.1.2 + # via -r requirements/test/../common.txt colorama==0.4.6 # via sacrebleu +compressed-tensors==0.17.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt coverage==7.13.5 # via pytest-cov +cryptography==49.0.0 + # via pyjwt dataproperty==1.1.0 # via # pytablewriter @@ -83,16 +115,35 @@ datasets==4.8.4 # mteb decorator==5.2.1 # via librosa +depyf==0.20.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +detect-installer==0.1.0 + # via fastapi-cloud-cli dill==0.4.1 # via # datasets + # depyf # evaluate # lm-eval # multiprocess +diskcache==5.6.3 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +distro==1.9.0 + # via + # anthropic + # openai +dnspython==2.8.0 + # via email-validator docker==7.1.0 # via gpt-oss docopt==0.6.2 # via num2words +docstring-parser==0.18.0 + # via anthropic dpcpp-cpp-rt==2025.3.2 # via # onemkl-sycl-blas @@ -101,15 +152,30 @@ dpcpp-cpp-rt==2025.3.2 # onemkl-sycl-rng # onemkl-sycl-sparse # torch +einops==0.8.2 + # via -r requirements/test/../common.txt +email-validator==2.3.0 + # via + # fastapi + # pydantic evaluate==0.4.6 # via lm-eval fastapi==0.135.2 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # gpt-oss + # model-hosting-container-standards +fastapi-cli==0.0.27 + # via fastapi +fastapi-cloud-cli==0.21.0 + # via fastapi-cli +fastar==0.11.0 + # via fastapi-cloud-cli filelock==3.25.2 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # blobfile # datasets # huggingface-hub @@ -125,28 +191,44 @@ fsspec==2026.2.0 # evaluate # huggingface-hub # torch +googleapis-common-protos==1.75.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http gpt-oss==0.0.8 # via -r requirements/test/xpu.in graphql-core==3.2.8 # via hypothesis-graphql +grpcio==1.81.1 + # via opentelemetry-exporter-otlp-proto-grpc h11==0.16.0 # via # httpcore # uvicorn harfile==0.4.0 # via schemathesis -hf-xet==1.4.3 +hf-xet==1.5.1 # via huggingface-hub html2text==2025.4.15 # via gpt-oss httpcore==1.0.9 # via httpx +httptools==0.8.0 + # via uvicorn httpx==0.28.1 # via + # anthropic # datasets + # fastapi + # fastapi-cloud-cli # huggingface-hub + # mcp + # model-hosting-container-standards + # openai # schemathesis -huggingface-hub==1.10.2 +httpx-sse==0.4.3 + # via mcp +huggingface-hub==1.22.0 # via # accelerate # datasets @@ -167,9 +249,12 @@ hypothesis-jsonschema==0.23.1 idna==3.11 # via # anyio + # email-validator # httpx # requests # yarl +ijson==3.5.0 + # via -r requirements/test/../common.txt imageio==2.37.3 # via scikit-image impi-rt==2021.17.2 @@ -213,23 +298,33 @@ intel-sycl-rt==2025.3.2 # dpcpp-cpp-rt # oneccl # torch +interegular==0.3.3 + # via lm-format-enforcer jinja2==3.1.6 # via # -c requirements/xpu.txt + # fastapi # lm-eval # torch +jiter==0.15.0 + # via + # anthropic + # openai jiwer==4.0.0 # via -r requirements/test/xpu.in +jmespath==1.1.0 + # via model-hosting-container-standards joblib==1.5.3 # via # librosa # nltk # scikit-learn -jsonlines==4.0.0 - # via lm-eval jsonschema==4.26.0 # via + # -c requirements/common.txt + # -r requirements/test/../common.txt # hypothesis-jsonschema + # mcp # mistral-common # schemathesis jsonschema-rs==0.45.0 @@ -238,16 +333,30 @@ jsonschema-specifications==2025.9.1 # via jsonschema junit-xml==1.9 # via schemathesis +lark==1.2.2 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt lazy-loader==0.5 # via # librosa # scikit-image librosa==0.10.2.post1 # via -r requirements/test/xpu.in +llguidance==1.7.6 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt llvmlite==0.47.0 # via numba -lm-eval==0.4.11 +lm-eval==0.4.12 # via -r requirements/test/xpu.in +lm-format-enforcer==0.11.3 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +loguru==0.7.3 + # via compressed-tensors lxml==6.0.2 # via # blobfile @@ -264,11 +373,14 @@ mbstrdecoder==1.1.4 # dataproperty # pytablewriter # typepy +mcp==1.28.1 + # via -r requirements/test/../common.txt mdurl==0.1.2 # via markdown-it-py -mistral-common==1.11.2 +mistral-common==1.11.5 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # -r requirements/test/xpu.in mkl==2025.3.1 # via @@ -278,6 +390,10 @@ mkl==2025.3.1 # onemkl-sycl-rng # onemkl-sycl-sparse # torch +model-hosting-container-standards==0.1.16 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt modelscope==1.35.3 # via -r requirements/test/xpu.in more-itertools==10.8.0 @@ -286,6 +402,8 @@ mpmath==1.3.0 # via sympy msgpack==1.1.2 # via librosa +msgspec==0.21.1 + # via -r requirements/test/../common.txt mteb==2.12.7 # via -r requirements/test/xpu.in multidict==6.7.1 @@ -300,6 +418,8 @@ networkx==3.6.1 # via # scikit-image # torch +ninja==1.13.0 + # via -r requirements/test/../common.txt nltk==3.9.4 # via rouge-score num2words==0.5.14 @@ -310,6 +430,7 @@ numba==0.65.0 # librosa numpy==2.2.6 # via + # -r requirements/test/../common.txt # accelerate # albumentations # bm25s @@ -335,6 +456,7 @@ numpy==2.2.6 # tifffile # torchvision # transformers + # xgrammar oneccl==2021.17.2 # via # oneccl-devel @@ -358,15 +480,65 @@ onemkl-sycl-rng==2025.3.1 # via torch onemkl-sycl-sparse==2025.3.1 # via torch +openai==2.44.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt openai-harmony==0.0.8 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # gpt-oss opencv-python-headless==4.13.0.92 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # albumentations # mistral-common +opentelemetry-api==1.43.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp==1.43.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +opentelemetry-exporter-otlp-proto-common==1.43.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-grpc==1.43.0 + # via opentelemetry-exporter-otlp +opentelemetry-exporter-otlp-proto-http==1.43.0 + # via opentelemetry-exporter-otlp +opentelemetry-proto==1.43.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.43.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-semantic-conventions-ai +opentelemetry-semantic-conventions==0.64b0 + # via + # opentelemetry-sdk + # opentelemetry-semantic-conventions-ai +opentelemetry-semantic-conventions-ai==0.5.1 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt +outlines-core==0.2.14 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt packaging==26.0 # via # -c requirements/xpu.txt @@ -375,6 +547,7 @@ packaging==26.0 # evaluate # huggingface-hub # lazy-loader + # lm-format-enforcer # modelscope # pooch # pytest @@ -386,10 +559,13 @@ pandas==3.0.1 # via # datasets # evaluate +partial-json-parser==0.2.1.1.post7 + # via -r requirements/test/../common.txt pathvalidate==3.3.1 # via pytablewriter pillow==12.1.1 # via + # -r requirements/test/../common.txt # imageio # mistral-common # scikit-image @@ -412,16 +588,37 @@ portalocker==3.2.0 # via sacrebleu pqdm==0.2.0 # via -r requirements/test/xpu.in +prometheus-client==0.25.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt + # prometheus-fastapi-instrumentator +prometheus-fastapi-instrumentator==8.0.2 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt propcache==0.4.1 # via # aiohttp # yarl +protobuf==7.35.1 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt + # googleapis-common-protos + # opentelemetry-proto psutil==7.2.2 - # via accelerate + # via + # -r requirements/test/../common.txt + # accelerate py==1.11.0 # via pytest-forked +py-cpuinfo==9.0.0 + # via -r requirements/test/../common.txt pyarrow==23.0.1 # via datasets +pybase64==1.4.3 + # via -r requirements/test/../common.txt pycountry==26.2.16 # via pydantic-extra-types pycparser==3.0 @@ -431,23 +628,41 @@ pycryptodomex==3.23.0 pydantic==2.12.5 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # albumentations + # anthropic + # compressed-tensors # fastapi + # fastapi-cloud-cli # gpt-oss + # lm-format-enforcer + # mcp # mistral-common + # model-hosting-container-standards # mteb + # openai # openai-harmony # pydantic-extra-types + # pydantic-settings + # xgrammar pydantic-core==2.41.5 # via pydantic pydantic-extra-types==2.11.1 - # via mistral-common + # via + # fastapi + # mistral-common +pydantic-settings==2.14.2 + # via + # fastapi + # mcp pyelftools==0.32 # via triton-xpu pygments==2.20.0 # via # pytest # rich +pyjwt==2.13.0 + # via mcp pyrate-limiter==4.1.0 # via schemathesis pystemmer==3.0.0 @@ -482,19 +697,36 @@ python-dateutil==2.9.0.post0 # via # pandas # typepy +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-json-logger==4.1.0 + # via -r requirements/test/../common.txt +python-multipart==0.0.32 + # via + # fastapi + # mcp pytrec-eval-terrier==0.5.10 # via mteb pytz==2026.1.post1 # via typepy pyyaml==6.0.3 # via + # -r requirements/test/../common.txt # accelerate # albumentations # datasets # huggingface-hub + # lm-format-enforcer # schemathesis # timm # transformers + # uvicorn +pyzmq==27.1.0 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt rapidfuzz==3.12.1 # via # -r requirements/test/xpu.in @@ -505,6 +737,7 @@ referencing==0.37.0 # jsonschema-specifications regex==2026.3.32 # via + # -r requirements/test/../common.txt # nltk # sacrebleu # tiktoken @@ -512,6 +745,7 @@ regex==2026.3.32 requests==2.33.1 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # datasets # docker # evaluate @@ -520,6 +754,7 @@ requests==2.33.1 # mistral-common # modelscope # mteb + # opentelemetry-exporter-otlp-proto-http # pooch # schemathesis # starlette-testclient @@ -527,8 +762,15 @@ requests==2.33.1 rich==14.3.3 # via # mteb + # rich-toolkit # schemathesis # typer +rich-toolkit==0.20.1 + # via + # fastapi-cli + # fastapi-cloud-cli +rignore==0.7.6 + # via fastapi-cloud-cli rouge-score==0.1.2 # via lm-eval rpds-py==0.30.0 @@ -540,6 +782,7 @@ sacrebleu==2.6.0 safetensors==0.7.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # accelerate # timm # transformers @@ -566,10 +809,18 @@ scipy==1.17.1 # sentence-transformers sentence-transformers==5.3.0 # via mteb +sentencepiece==0.2.1 + # via -r requirements/test/../common.txt +sentry-sdk==2.63.0 + # via fastapi-cloud-cli +setproctitle==1.3.7 + # via -r requirements/test/../common.txt setuptools==80.10.2 # via # -c requirements/common.txt # -c requirements/xpu.txt + # -r requirements/test/../common.txt + # model-hosting-container-standards # modelscope # pytablewriter # torch @@ -578,9 +829,14 @@ shellingham==1.5.4 six==1.17.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # junit-xml # python-dateutil # rouge-score +sniffio==1.3.1 + # via + # anthropic + # openai sortedcontainers==2.4.0 # via hypothesis soundfile==0.13.1 @@ -595,14 +851,24 @@ soxr==0.5.0.post1 # mistral-common sqlitedict==2.1.0 # via lm-eval -starlette==1.0.0 +sse-starlette==3.4.5 + # via mcp +starlette==1.3.1 # via + # -c requirements/common.txt + # -r requirements/test/../common.txt # fastapi + # mcp + # model-hosting-container-standards + # prometheus-fastapi-instrumentator + # sse-starlette # starlette-testclient starlette-testclient==0.4.1 # via schemathesis structlog==25.5.0 # via gpt-oss +supervisor==4.3.0 + # via model-hosting-container-standards sympy==1.14.0 # via torch tabledata==1.3.4 @@ -637,6 +903,7 @@ tifffile==2026.3.3 tiktoken==0.12.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # gpt-oss # lm-eval # mistral-common @@ -645,19 +912,23 @@ timm==1.0.17 tokenizers==0.22.2 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # transformers -torch==2.11.0+xpu +torch==2.12.0+xpu # via # -c requirements/xpu.txt # accelerate + # compressed-tensors # mteb # sentence-transformers # timm # torchvision -torchvision==0.26.0+xpu + # xgrammar +torchvision==0.27.0+xpu # via timm tqdm==4.67.3 # via + # -r requirements/test/../common.txt # datasets # evaluate # huggingface-hub @@ -665,14 +936,21 @@ tqdm==4.67.3 # modelscope # mteb # nltk + # openai # pqdm # sentence-transformers # transformers -transformers==5.5.3 +transformers==5.10.4 # via # -c requirements/common.txt + # -r requirements/test/../common.txt + # -r requirements/test/xpu.in + # compressed-tensors # sentence-transformers -triton-xpu==3.7.0 + # xgrammar +triton==3.7.1 + # via xgrammar +triton-xpu==3.7.1 # via torch typepy==1.3.4 # via @@ -681,36 +959,52 @@ typepy==1.3.4 # tabledata typer==0.24.1 # via - # huggingface-hub + # fastapi-cli + # fastapi-cloud-cli # transformers typing-extensions==4.15.0 # via # -c requirements/common.txt + # -r requirements/test/../common.txt # aiosignal # albumentations + # anthropic # anyio + # apache-tvm-ffi # chz # fastapi + # grpcio # huggingface-hub # librosa # lm-eval + # mcp # mistral-common # mteb + # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pqdm # pydantic # pydantic-core # pydantic-extra-types # pytest-asyncio # referencing + # rich-toolkit # schemathesis # sentence-transformers # starlette # torch # typing-inspection + # xgrammar typing-inspection==0.4.2 # via # fastapi + # mcp # pydantic + # pydantic-settings umf==1.0.3 # via # intel-cmplr-lib-ur @@ -721,17 +1015,33 @@ urllib3==2.6.3 # docker # modelscope # requests + # sentry-sdk uvicorn==0.42.0 - # via gpt-oss + # via + # fastapi + # fastapi-cli + # fastapi-cloud-cli + # gpt-oss + # mcp +uvloop==0.22.1 + # via uvicorn +watchfiles==1.2.0 + # via + # -r requirements/test/../common.txt + # uvicorn +websockets==16.0 + # via uvicorn werkzeug==3.1.7 # via schemathesis word2number==1.1 # via lm-eval +xgrammar==0.2.3 + # via + # -c requirements/common.txt + # -r requirements/test/../common.txt xxhash==3.6.0 # via # datasets # evaluate yarl==1.23.0 # via aiohttp -zstandard==0.25.0 - # via lm-eval diff --git a/requirements/tpu.txt b/requirements/tpu.txt index 539f2320ba38..da477a68461c 100644 --- a/requirements/tpu.txt +++ b/requirements/tpu.txt @@ -12,4 +12,4 @@ ray[data] setuptools==78.1.0 setuptools-rust>=1.9.0 nixl==0.3.0 -tpu-inference==0.20.0 +tpu-inference==0.24.0 diff --git a/requirements/xpu.txt b/requirements/xpu.txt index 897e2080dafa..68b8eb130104 100644 --- a/requirements/xpu.txt +++ b/requirements/xpu.txt @@ -12,9 +12,10 @@ jinja2>=3.1.6 datasets # for benchmark scripts numba == 0.65.0 # Required for N-gram speculative decoding --extra-index-url=https://download.pytorch.org/whl/xpu -torch==2.11.0+xpu +torch==2.12.0 torchaudio torchvision +torchcodec >= 0.14 # Required for the torchcodec video decoding backend -auto_round_lib>=0.13.0 -vllm_xpu_kernels @ https://github.com/vllm-project/vllm-xpu-kernels/releases/download/v0.1.9/vllm_xpu_kernels-0.1.9-cp38-abi3-manylinux_2_28_x86_64.whl +auto_round_lib>=0.14.0 +vllm_xpu_kernels @ https://github.com/vllm-project/vllm-xpu-kernels/releases/download/v0.1.10.1/vllm_xpu_kernels-0.1.10.1-cp38-abi3-manylinux_2_28_x86_64.whl diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7639b9cc13a9..cc055505dc4e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -31,24 +31,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -144,12 +126,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "arc-swap" version = "1.9.0" @@ -159,17 +135,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -182,15 +147,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "async-io" version = "2.6.0" @@ -317,53 +273,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "av-scenechange" -version = "0.14.1" +name = "auto_enums" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +checksum = "2e4487600931c9a89f8db7ffbdf3fbdd45bb7bd85e26861f659a463cd0dff966" dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", + "derive_utils", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "av1-grain" -version = "0.2.5" +name = "auto_impl" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "avif-serialize" -version = "0.8.8" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" -dependencies = [ - "arrayvec", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" @@ -443,12 +379,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.5.3" @@ -479,27 +409,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - [[package]] name = "blake3" version = "1.8.5" @@ -534,12 +449,6 @@ dependencies = [ "serde", ] -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - [[package]] name = "bumpalo" version = "3.20.2" @@ -580,9 +489,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] @@ -620,12 +529,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.44" @@ -743,19 +646,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - [[package]] name = "console" version = "0.16.2" @@ -775,35 +665,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1027,16 +888,6 @@ dependencies = [ "serde", ] -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1099,6 +950,17 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "derive_utils" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362f47930db19fe7735f527e6595e4900316b893ebf6d48ad3d31be928d57dd6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -1263,26 +1125,6 @@ dependencies = [ "log", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1315,7 +1157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ "futures-core", - "nom 7.1.3", + "nom", "pin-project-lite", ] @@ -1329,21 +1171,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fancy-regex" version = "0.13.0" @@ -1382,13 +1209,12 @@ dependencies = [ [[package]] name = "fastokens" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "796a262ed47d1458a4b40d0ed831c927e6f54d5b9c1de2683bb4ac9b04f4c7cc" +checksum = "8728655e193e0d08d7a95d63cf1fdb9b768d282cab0a112ecb006615bae9f067" dependencies = [ "daachorse", "fancy-regex 0.17.0", - "hf-hub 0.4.3", "icu_normalizer", "memchr", "pcre2", @@ -1632,10 +1458,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -1677,9 +1501,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1754,26 +1578,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hf-hub" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" -dependencies = [ - "dirs", - "http", - "indicatif 0.17.11", - "libc", - "log", - "rand 0.9.2", - "reqwest", - "serde", - "serde_json", - "thiserror 2.0.18", - "ureq 2.12.1", - "windows-sys 0.60.2", -] - [[package]] name = "hf-hub" version = "0.5.0" @@ -1782,11 +1586,9 @@ checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" dependencies = [ "dirs", "futures", - "http", - "indicatif 0.18.4", + "indicatif", "libc", "log", - "native-tls", "num_cpus", "rand 0.9.2", "reqwest", @@ -1794,7 +1596,6 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "ureq 3.3.0", "windows-sys 0.61.2", ] @@ -1860,9 +1661,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1875,7 +1676,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1891,12 +1691,10 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.6", ] [[package]] @@ -2103,16 +1901,11 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "exr", "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", "tiff", "zune-core", "zune-jpeg", @@ -2128,12 +1921,6 @@ dependencies = [ "quick-error", ] -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "indexmap" version = "1.9.3" @@ -2157,26 +1944,13 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console 0.15.11", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - [[package]] name = "indicatif" version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ - "console 0.16.2", + "console", "portable-atomic", "unicode-width", "unit-prefix", @@ -2192,17 +1966,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -2343,12 +2106,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.183" @@ -2356,13 +2113,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] -name = "libfuzzer-sys" -version = "0.4.12" +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ - "arbitrary", - "cc", + "cfg-if", + "windows-link", ] [[package]] @@ -2409,21 +2166,27 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llm-multimodal" -version = "1.5.0" -source = "git+https://github.com/vllm-project/llm-multimodal?rev=5b558989844d1c7af3e43d0f604069ffd9c06320#5b558989844d1c7af3e43d0f604069ffd9c06320" +version = "1.7.1" +source = "git+https://github.com/smg-project/llm-multimodal?rev=7d74582aeaf0e4086a44964382655d22f1af0686#7d74582aeaf0e4086a44964382655d22f1af0686" dependencies = [ + "anyhow", "base64 0.22.1", "blake3", "bytes", "fast_image_resize", + "hf-hub", "image", + "libloading", "ndarray 0.17.2", "once_cell", "reqwest", "serde", "serde_json", + "serde_with", + "tempfile", "thiserror 2.0.18", "tokio", + "tracing", "url", ] @@ -2442,21 +2205,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -2556,16 +2304,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "memchr" version = "2.8.0" @@ -2643,9 +2381,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2737,21 +2475,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "no_std_io2" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" -dependencies = [ - "memchr", -] - [[package]] name = "nom" version = "7.1.3" @@ -2762,21 +2485,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2786,16 +2494,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -2811,17 +2509,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -2831,17 +2518,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2870,17 +2546,11 @@ dependencies = [ "libc", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2916,30 +2586,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "openai-harmony" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e77e82af451fc95deeb728a40b84db8ee82d341e136c268de415123a560b9b72" -dependencies = [ - "anyhow", - "base64 0.22.1", - "bstr", - "clap", - "fancy-regex 0.13.0", - "futures", - "image", - "regex", - "reqwest", - "rustc-hash 1.1.0", - "serde", - "serde_json", - "serde_with", - "sha1", - "sha2", - "thiserror 2.0.18", -] - [[package]] name = "openai-protocol" version = "1.6.0" @@ -2961,15 +2607,14 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3002,9 +2647,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -3019,6 +2664,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oss-harmony" +version = "0.0.11" +source = "git+https://github.com/oss-harmony/harmony?tag=v0.0.11#76e849426cc092f84509e31a17027755f67d662a" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "fancy-regex 0.13.0", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "serde_with", + "sha2", + "thiserror 2.0.18", + "zstd", +] + [[package]] name = "parking" version = "2.2.1" @@ -3054,12 +2717,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - [[package]] name = "pcre2" version = "0.2.11" @@ -3082,15 +2739,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3172,12 +2820,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.32" @@ -3337,25 +2979,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "prometheus-client" version = "0.24.0" @@ -3459,75 +3082,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] -name = "qoi" -version = "0.4.1" +name = "pyo3" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" dependencies = [ - "bytemuck", + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", ] [[package]] -name = "quick-error" -version = "2.0.1" +name = "pyo3-build-config" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] [[package]] -name = "quinn" -version = "0.11.9" +name = "pyo3-ffi" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.1", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", + "libc", + "pyo3-build-config", ] [[package]] -name = "quinn-proto" -version = "0.11.14" +name = "pyo3-macros" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash 2.1.1", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.117", ] [[package]] -name = "quinn-udp" -version = "0.5.14" +name = "pyo3-macros-backend" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.117", ] +[[package]] +name = "pythonize" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b79f670c9626c8b651c0581011b57b6ba6970bb69faf01a7c4c0cfc81c43f95" +dependencies = [ + "pyo3", + "serde", + "serde_json", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -3603,59 +3231,9 @@ dependencies = [ name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", + "getrandom 0.3.4", ] [[package]] @@ -3782,7 +3360,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", @@ -3800,9 +3377,6 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -3810,7 +3384,6 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3820,7 +3393,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.6", ] [[package]] @@ -3833,18 +3405,12 @@ dependencies = [ "futures-core", "futures-timer", "mime", - "nom 7.1.3", + "nom", "pin-project-lite", "reqwest", "thiserror 1.0.69", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - [[package]] name = "ring" version = "0.17.14" @@ -3959,34 +3525,19 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ - "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -4419,17 +3970,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - [[package]] name = "sha2" version = "0.10.9" @@ -4472,15 +4012,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - [[package]] name = "siphasher" version = "1.0.2" @@ -4520,17 +4051,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "spm_precompiled" version = "0.1.4" @@ -4538,7 +4058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" dependencies = [ "base64 0.13.1", - "nom 7.1.3", + "nom", "serde", "unicode-segmentation", ] @@ -4669,6 +4189,12 @@ dependencies = [ "libc", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "task-local" version = "0.1.1" @@ -4891,20 +4417,21 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.11.0" +name = "tls-listener" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "1461056cc1ef47003f7ee16e4cef3741068d4c7f6b627bfce49b7c00c120a530" dependencies = [ - "tinyvec_macros", + "axum", + "futures-util", + "openssl", + "pin-project-lite", + "thiserror 2.0.18", + "tokio", + "tokio-openssl", + "tracing", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokenizers" version = "0.22.2" @@ -4918,7 +4445,7 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "indicatif 0.18.4", + "indicatif", "itertools 0.14.0", "log", "macro_rules_attribute", @@ -4941,9 +4468,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4958,9 +4485,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4977,6 +4504,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-openssl" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd" +dependencies = [ + "openssl", + "openssl-sys", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5443,61 +4981,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "cookie_store", - "der", - "flate2", - "log", - "native-tls", - "percent-encoding", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "ureq-proto", - "utf8-zero", - "webpki-root-certs", - "webpki-roots 1.0.6", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -5522,12 +5005,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5551,17 +5028,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "validator" version = "0.20.0" @@ -5627,7 +5093,7 @@ dependencies = [ "llm-multimodal", "minijinja", "minijinja-contrib", - "openai-harmony", + "oss-harmony", "paste", "reqwest", "rmp-serde", @@ -5636,10 +5102,12 @@ dependencies = [ "serde_json", "serde_with", "serial_test", + "strum", "subenum", "tempfile", "thiserror 2.0.18", "thiserror-ext", + "time", "tokio", "tracing", "tracing-subscriber", @@ -5647,10 +5115,10 @@ dependencies = [ "uuid", "vllm-engine-core-client", "vllm-llm", - "vllm-reasoning-parser", + "vllm-parser", "vllm-text", "vllm-tokenizer", - "vllm-tool-parser", + "xgrammar-structural-tag", "zeromq", ] @@ -5675,6 +5143,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "vllm-chat", "vllm-engine-core-client", "vllm-managed-engine", "vllm-server", @@ -5729,6 +5198,7 @@ dependencies = [ "enum-as-inner", "expect-test", "futures", + "parking_lot", "rmp-serde", "serde", "serde_json", @@ -5759,6 +5229,7 @@ dependencies = [ name = "vllm-metrics" version = "0.1.0" dependencies = [ + "itertools 0.14.0", "prometheus-client", ] @@ -5782,11 +5253,22 @@ dependencies = [ ] [[package]] -name = "vllm-reasoning-parser" +name = "vllm-parser" version = "0.1.0" dependencies = [ + "criterion", + "easy-ext", + "expect-test", + "futures", + "openai-protocol", + "serde", + "serde_json", "thiserror 2.0.18", + "thiserror-ext", + "tool-parser", "vllm-tokenizer", + "winnow", + "xgrammar-structural-tag", ] [[package]] @@ -5796,15 +5278,21 @@ dependencies = [ "anyhow", "async-openai", "asynk-strim-attr", + "auto_enums", "axum", "bytes", "clap", + "educe", "expect-test", "futures", "http-body", + "hyper", + "hyper-util", + "indexmap 2.13.0", "itertools 0.14.0", "libc", "llm-multimodal", + "openssl", "prost", "prost-types", "rmp-serde", @@ -5813,9 +5301,14 @@ dependencies = [ "serde_json", "serde_with", "serial_test", + "sha2", "socket2", + "subtle", + "tempfile", "thiserror-ext", + "tls-listener", "tokio", + "tokio-openssl", "tokio-stream", "tokio-util", "tonic", @@ -5833,6 +5326,7 @@ dependencies = [ "vllm-llm", "vllm-metrics", "vllm-text", + "vllm-tokenizer", "zeromq", ] @@ -5846,8 +5340,9 @@ dependencies = [ "enum-as-inner", "expect-test", "futures", - "hf-hub 0.5.0", + "hf-hub", "itertools 0.14.0", + "reqwest", "serde", "serde_json", "serde_with", @@ -5870,7 +5365,8 @@ dependencies = [ "base64 0.22.1", "criterion", "fastokens", - "hf-hub 0.5.0", + "hf-hub", + "reqwest", "riptoken", "rustc-hash 1.1.0", "serde", @@ -5881,24 +5377,19 @@ dependencies = [ "thiserror-ext", "tiktoken-rs 0.9.1", "tokenizers", + "tokio", "tracing", ] [[package]] -name = "vllm-tool-parser" +name = "vllm-tool-parser-py" version = "0.1.0" dependencies = [ - "criterion", - "easy-ext", - "expect-test", - "futures", - "openai-protocol", - "serde", + "pyo3", + "pythonize", "serde_json", - "thiserror 2.0.18", "thiserror-ext", - "tool-parser", - "winnow", + "vllm-parser", ] [[package]] @@ -6070,33 +5561,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "weezl" version = "0.1.12" @@ -6221,25 +5685,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6257,31 +5703,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6290,96 +5719,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.2" @@ -6490,10 +5871,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] -name = "y4m" -version = "0.8.0" +name = "xgrammar-structural-tag" +version = "0.1.0+xgrammar.0.2.2.4d145cc" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +checksum = "2436dea2393d55a3b188588aa300c5a8afe8f45a77da52c611fb4498a6c876e6" +dependencies = [ + "auto_impl", + "serde", + "serde_json", + "strum", + "thiserror 2.0.18", +] [[package]] name = "yoke" @@ -6630,20 +6018,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] -name = "zune-core" -version = "0.5.1" +name = "zstd" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] [[package]] -name = "zune-inflate" -version = "0.2.54" +name = "zstd-safe" +version = "7.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" dependencies = [ - "simd-adler32", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9ca38d0ae790..435350c07117 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,11 +7,11 @@ members = [ "src/managed-engine", "src/metrics", "src/mock-engine", - "src/reasoning-parser", + "src/parser", + "src/parser/python", "src/server", "src/text", "src/tokenizer", - "src/tool-parser", ] resolver = "3" @@ -23,46 +23,56 @@ license = "Apache-2.0" [workspace.dependencies] anyhow = "1.0.100" arc-swap = "1.9.0" -async-openai = "0.33.1" +async-openai = { version = "0.33.1", default-features = false, features = ["native-tls"] } async-trait = "0.1.89" asynk-strim-attr = "0.1.0" +auto_enums = { version = "0.8.9", features = ["tokio1"] } axum = "0.8.8" base64 = "0.22.1" bytemuck = { version = "1.25.0", features = ["extern_crate_alloc"] } byteorder = "1.5.0" -bytes = "1.11.1" +bytes = "1.12.0" clap = { version = "4.5.38", features = ["derive", "env"] } criterion = "0.5.1" easy-ext = "1.0.3" educe = "0.6.0" enum-as-inner = "0.7.0" expect-test = "1.5.1" -fastokens = "0.2.0" +fastokens = { version = "0.2.1", default-features = false } futures = "0.3.31" half = { version = "2.7.1", features = ["bytemuck"] } hex = "0.4.3" -hf-hub = { version = "0.5.0", features = ["tokio"] } +hf-hub = { version = "0.5.0", default-features = false, features = ["tokio"] } http-body = "1.0.1" +hyper = { version = "1.10.1", features = ["http1", "server"] } +hyper-util = { version = "0.1.20", features = [ + "server-graceful", + "service", + "tokio", +] } indexmap = "2.13.0" itertools = "0.14.0" libc = "0.2.177" -llm-multimodal = { git = "https://github.com/vllm-project/llm-multimodal", rev = "5b558989844d1c7af3e43d0f604069ffd9c06320" } +llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "7d74582aeaf0e4086a44964382655d22f1af0686" } mimalloc = "0.1.52" minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] } minijinja-contrib = { version = "2.0", features = ["pycompat"] } native-tls-vendored = { package = "native-tls", version = "0.2.18", features = ["vendored"] } ndarray = { version = "0.16.1", features = ["serde"] } -openai-harmony = "0.0.8" +openai-harmony = { package = "oss-harmony", git = "https://github.com/oss-harmony/harmony", tag = "v0.0.11", default-features = false } openai-protocol = "1.6.0" +openssl = "0.10" parking_lot = "0.12.5" paste = "1.0.15" prometheus-client = "0.24.0" prometheus-client-derive-encode = "0.5.0" prost = "0.14.3" prost-types = "0.14.3" +pyo3 = "0.28.3" +pythonize = "0.28.0" rand = "0.9.2" reasoning-parser = "1.2.2" -reqwest = { version = "0.12.8", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12.8", default-features = false, features = ["native-tls"] } riptoken = { version = "0.3.0", default-features = false } rmp-serde = "1.3.1" rmpv = { version = "1.3.1", features = ["with-serde"] } @@ -75,8 +85,11 @@ serde_repr = "0.1.20" serde_tuple = "1.1.3" serde_with = "3.18.0" serial_test = { version = "3.2.0", features = ["file_locks"] } +sha2 = "0.10.9" socket2 = "0.6.3" +strum = { version = "0.27.2", features = ["derive"] } subenum = "1.1.3" +subtle = "2.6" task-local = "0.1.1" tekken = { package = "tekken-rs", version = "0.1.1", default-features = false } tempfile = "3.23.0" @@ -84,6 +97,7 @@ thiserror = "2.0.16" thiserror-ext = "0.3.0" tiktoken-rs = "0.9.1" time = { version = "0.3.47", features = ["formatting", "local-offset", "macros"] } +tls-listener = { version = "0.11.2", default-features = false, features = ["openssl", "tokio-net", "axum"] } tokenizers = "0.22.0" tokio = { version = "1.47.1", features = [ "macros", @@ -92,6 +106,7 @@ tokio = { version = "1.47.1", features = [ "sync", "time", ] } +tokio-openssl = "0.6" tokio-stream = "0.1" tokio-util = { version = "0.7.18", features = ["rt"] } tonic = "0.14.5" @@ -100,7 +115,7 @@ tonic-prost = "0.14.5" tonic-prost-build = "0.14.5" tool-parser = "1.2.0" tower = { version = "0.5.3", features = ["util"] } -tower-http = { version = "0.6.8", features = ["trace"] } +tower-http = { version = "0.6.8", features = ["cors", "trace"] } tracing = { version = "0.1.44", features = ["release_max_level_debug"] } tracing-futures = { version = "0.2.5", features = ["futures-03"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } @@ -112,12 +127,12 @@ vllm-engine-core-client = { path = "src/engine-core-client" } vllm-llm = { path = "src/llm" } vllm-managed-engine = { path = "src/managed-engine" } vllm-metrics = { path = "src/metrics" } -vllm-reasoning-parser = { path = "src/reasoning-parser" } +vllm-parser = { path = "src/parser" } vllm-server = { path = "src/server" } vllm-text = { path = "src/text" } vllm-tokenizer = { path = "src/tokenizer" } -vllm-tool-parser = { path = "src/tool-parser" } -winnow = "1.0.2" +winnow = { version = "1.0.2", features = ["simd"] } +xgrammar-structural-tag = "0.1.0" zeromq = { version = "0.6.0", default-features = false, features = [ "tokio-runtime", "all-transport", @@ -129,6 +144,13 @@ too_many_arguments = "allow" [profile.dev] panic = "abort" +# Speed up cold tokenizer construction in tests. +[profile.dev.package] +fastokens = { opt-level = 3 } +regex-automata = { opt-level = 3 } +serde_json = { opt-level = 3 } +tokenizers = { opt-level = 3 } + [profile.release] lto = "thin" panic = "abort" diff --git a/rust/README.md b/rust/README.md index 679a7f0966e2..b14aba3fae19 100644 --- a/rust/README.md +++ b/rust/README.md @@ -71,7 +71,7 @@ To build the `vllm-rs` in isolation: ```bash # from the local checkout -cargo install --path src/cmd --bin vllm-rs +./build_rust.sh ``` ### Example Request diff --git a/rust/deny.toml b/rust/deny.toml new file mode 100644 index 000000000000..25bd8e3831ad --- /dev/null +++ b/rust/deny.toml @@ -0,0 +1,15 @@ +[bans] +multiple-versions = "allow" + +deny = [ + # TLS / crypto provider + # We prefer the system's TLS (e.g. OpenSSL) over Rust implementations. + { name = "rustls" }, + { name = "ring" }, + { name = "aws-lc-rs" }, + { name = "aws-lc-sys" }, + { name = "s2n-tls" }, + { name = "s2n-tls-sys" }, + { name = "boring" }, + { name = "boring-sys" }, +] diff --git a/rust/src/chat/Cargo.toml b/rust/src/chat/Cargo.toml index 0523b9defe93..95ce5ff2e42c 100644 --- a/rust/src/chat/Cargo.toml +++ b/rust/src/chat/Cargo.toml @@ -21,19 +21,21 @@ serde.workspace = true serde-json-fmt.workspace = true serde_json.workspace = true serde_with.workspace = true +strum.workspace = true subenum.workspace = true thiserror.workspace = true thiserror-ext.workspace = true +time.workspace = true tokio.workspace = true tracing.workspace = true trait-set.workspace = true uuid.workspace = true vllm-engine-core-client.workspace = true vllm-llm.workspace = true -vllm-reasoning-parser.workspace = true +vllm-parser.workspace = true vllm-text.workspace = true vllm-tokenizer.workspace = true -vllm-tool-parser.workspace = true +xgrammar-structural-tag.workspace = true [dev-dependencies] anyhow.workspace = true @@ -48,6 +50,7 @@ tokio.workspace = true tracing-subscriber.workspace = true uuid.workspace = true vllm-engine-core-client = { workspace = true, features = ["test-util"] } +vllm-tokenizer = { workspace = true, features = ["test-utils"] } zeromq.workspace = true [lints] diff --git a/rust/src/chat/examples/external_engine_chat_qwen.rs b/rust/src/chat/examples/external_engine_chat_qwen.rs index d99d672d5eb9..457dd453d613 100644 --- a/rust/src/chat/examples/external_engine_chat_qwen.rs +++ b/rust/src/chat/examples/external_engine_chat_qwen.rs @@ -131,13 +131,13 @@ async fn main() -> Result<()> { ChatEvent::LogprobsDelta { .. } => {} ChatEvent::Done { message, - output_token_count, + usage, finish_reason: reason, .. } => { final_reasoning = message.reasoning().unwrap_or_default(); final_text = message.text(); - final_output_token_count = output_token_count; + final_output_token_count = usage.output_token_count; finish_reason = Some(reason); break; } diff --git a/rust/src/chat/src/backend/hf.rs b/rust/src/chat/src/backend/hf.rs index 6c3dddc87292..fdfe8620b20a 100644 --- a/rust/src/chat/src/backend/hf.rs +++ b/rust/src/chat/src/backend/hf.rs @@ -15,7 +15,9 @@ use crate::output::{ DefaultChatOutputProcessor, HarmonyChatOutputProcessor, validate_harmony_parser_overrides, }; use crate::renderer::hf::{HfChatRenderer, MultimodalRenderInfo}; -use crate::renderer::{DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer}; +use crate::renderer::{ + DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, HarmonyChatRenderer, +}; use crate::request::ChatRequest; use crate::{DynChatOutputProcessor, RendererSelection}; @@ -38,13 +40,17 @@ impl HfChatBackend { ) -> Result { let model_config = load_model_config(files.config_path.as_deref())?; let model_type = model_config.model_type().unwrap_or_default(); - let multimodal_model_info = MultimodalModelInfo::from_paths( - model_id.clone(), - (!model_type.is_empty()).then_some(model_type.to_string()), - files.config_path.as_deref(), - files.preprocessor_config_path.as_deref(), - tokenizer.clone(), - )?; + let multimodal_model_info = if options.language_model_only { + None + } else { + MultimodalModelInfo::from_paths( + model_id.clone(), + (!model_type.is_empty()).then_some(model_type.to_string()), + files.config_path.as_deref(), + files.preprocessor_config_path.as_deref(), + tokenizer.clone(), + )? + }; let multimodal_render_info = resolve_multimodal_render_info(multimodal_model_info.as_ref()); let renderer = options.renderer.resolve(model_type); @@ -57,6 +63,7 @@ impl HfChatBackend { )?), RendererSelection::DeepSeekV32 => Arc::new(DeepSeekV32ChatRenderer::new()), RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new()), + RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new()?), }; info!( @@ -144,13 +151,16 @@ mod tests { use std::sync::Arc; use tempfile::tempdir; + use thiserror_ext::AsReport as _; + use vllm_text::Prompt; use vllm_text::backend::hf::TokenizerSource; - use vllm_text::tokenizer::{DynTokenizer, Tokenizer}; + use vllm_text::tokenizer::DynTokenizer; + use vllm_tokenizer::test_utils::TestTokenizer; use super::HfChatBackend; - use crate::RendererSelection; - use crate::backend::{ChatBackend, LoadModelBackendsOptions}; + use crate::backend::{ChatBackend, LoadModelBackendsOptions, NewChatOutputProcessorOptions}; use crate::request::{ChatContent, ChatMessage, ChatRequest}; + use crate::{ParserSelection, RendererSelection}; fn request_with_user_text(text: &str) -> ChatRequest { ChatRequest { @@ -187,53 +197,36 @@ mod tests { } } - struct TestTokenizer; - - impl Tokenizer for TestTokenizer { - fn encode( - &self, - _text: &str, - _add_special_tokens: bool, - ) -> vllm_text::tokenizer::Result> { - Ok(Vec::new()) - } - - fn decode( - &self, - _token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_text::tokenizer::Result { - Ok(String::new()) - } - - fn token_to_id(&self, _token: &str) -> Option { - None - } - } - fn test_tokenizer() -> DynTokenizer { - Arc::new(TestTokenizer) + Arc::new(TestTokenizer::new()) } - fn render_prompt( + fn backend_for_selection( renderer: RendererSelection, config_json: &str, tokenizer_config_json: &str, - ) -> String { - let backend = HfChatBackend::from_resolved_model_files( + ) -> HfChatBackend { + HfChatBackend::from_resolved_model_files( resolved_files(config_json, tokenizer_config_json), "test-model".to_string(), LoadModelBackendsOptions { renderer, + language_model_only: false, chat_template_content_format: Default::default(), chat_template: None, default_chat_template_kwargs: HashMap::new(), }, test_tokenizer(), ) - .unwrap(); + .unwrap() + } - backend + fn render_prompt( + renderer: RendererSelection, + config_json: &str, + tokenizer_config_json: &str, + ) -> String { + backend_for_selection(renderer, config_json, tokenizer_config_json) .chat_renderer() .render(&request_with_user_text("hello")) .unwrap() @@ -267,6 +260,86 @@ mod tests { assert_eq!(prompt, "hello"); } + #[test] + fn auto_uses_harmony_renderer_and_output_processor_for_gpt_oss_model_type() { + let backend = backend_for_selection( + RendererSelection::Auto, + r#"{"model_type":"gpt_oss"}"#, + r#"{"chat_template":"{{ messages[0].content }}"}"#, + ); + + let prompt = + backend.chat_renderer().render(&request_with_user_text("hello")).unwrap().prompt; + assert!(matches!(prompt, Prompt::TokenIds(_))); + + let mut request = request_with_user_text("hello"); + let error = match backend.new_chat_output_processor( + &mut request, + NewChatOutputProcessorOptions { + tool_call_parser: &ParserSelection::Explicit("json".to_string()), + reasoning_parser: &ParserSelection::Auto, + }, + ) { + Ok(_) => panic!("gpt_oss should reject generic parser overrides"), + Err(error) => error, + }; + assert_eq!( + error.to_report_string(), + "gpt_oss uses native Harmony output parsing; generic tool parser override `json` is not supported" + ); + } + + #[test] + fn language_model_only_skips_multimodal_preprocessor_config() { + let mut files = resolved_files( + r#"{"model_type":"deepseek_v0_vl"}"#, + r#"{"chat_template":"{{ messages[0].content }}"}"#, + ); + let preprocessor_config_path = files + .config_path + .as_ref() + .unwrap() + .parent() + .unwrap() + .join("preprocessor_config.json"); + write_json(&preprocessor_config_path, r#"{"size":[672,672]}"#); + files.preprocessor_config_path = Some(preprocessor_config_path.clone()); + + let backend = HfChatBackend::from_resolved_model_files( + files.clone(), + "test-model".to_string(), + LoadModelBackendsOptions { + language_model_only: true, + chat_template_content_format: Default::default(), + chat_template: None, + default_chat_template_kwargs: HashMap::new(), + ..Default::default() + }, + test_tokenizer(), + ) + .unwrap(); + + assert!(backend.multimodal_model_info().is_none()); + + let invalid_preprocessor_config = r#"{"size":[672,672]"#; + write_json(&preprocessor_config_path, invalid_preprocessor_config); + + let error = HfChatBackend::from_resolved_model_files( + files, + "test-model".to_string(), + LoadModelBackendsOptions { + chat_template_content_format: Default::default(), + chat_template: None, + default_chat_template_kwargs: HashMap::new(), + ..Default::default() + }, + test_tokenizer(), + ) + .err() + .expect("invalid preprocessor config should fail without language_model_only"); + assert!(error.to_string().contains("failed to parse preprocessor_config.json")); + } + #[test] fn explicit_deepseek_renderer_overrides_generic_model_type() { let prompt = render_prompt( diff --git a/rust/src/chat/src/backend/mod.rs b/rust/src/chat/src/backend/mod.rs index f49ca6737047..be609ba5d9e0 100644 --- a/rust/src/chat/src/backend/mod.rs +++ b/rust/src/chat/src/backend/mod.rs @@ -60,6 +60,9 @@ pub type DynChatTextBackend = Arc; pub struct LoadModelBackendsOptions { /// Which chat renderer implementation to use. pub renderer: RendererSelection, + /// Disable frontend-side multimodal preprocessing and render the model as + /// language-only. + pub language_model_only: bool, /// How to serialize `message.content` when rendering the chat template. pub chat_template_content_format: ChatTemplateContentFormatOption, /// Optional server-default chat template override, provided either as an diff --git a/rust/src/chat/src/error.rs b/rust/src/chat/src/error.rs index 25d8d015680e..da2396c2198d 100644 --- a/rust/src/chat/src/error.rs +++ b/rust/src/chat/src/error.rs @@ -64,12 +64,27 @@ pub enum Error { StreamClosedBeforeTerminalOutput { request_id: String }, #[error("tool call stream state is inconsistent: {message}")] ToolCallStreamInvariant { message: String }, + #[error("failed to build structural tag: {message}")] + StructuralTag { message: String }, #[error(transparent)] Text(#[from] vllm_text::Error), + #[error(transparent)] + Tokenizer(#[from] vllm_tokenizer::TokenizerError), } pub type Result = std::result::Result; +impl Error { + /// Whether this error represents invalid user request parameters. + pub fn is_request_validation_error(&self) -> bool { + match self { + Self::PromptTooLong { .. } => true, + Self::Text(error) => error.is_request_validation_error(), + _ => false, + } + } +} + /// Format the available-parser suffix used in user-facing error messages. fn available_parser_hint(available_names: &[String]) -> String { if available_names.is_empty() { diff --git a/rust/src/chat/src/event.rs b/rust/src/chat/src/event.rs index 9eb8d35042b6..d6b5f8f7624f 100644 --- a/rust/src/chat/src/event.rs +++ b/rust/src/chat/src/event.rs @@ -2,6 +2,7 @@ use std::ops::Deref; use std::sync::Arc; use serde::{Deserialize, Serialize}; +use vllm_llm::TokenUsage; use vllm_text::{DecodedLogprobs, DecodedPromptLogprobs}; use crate::FinishReason; @@ -197,11 +198,7 @@ pub enum ChatEvent { /// metadata. Done { message: AssistantMessage, - /// Number of prompt tokens actually sent to the engine after chat - /// template rendering and tokenization. - prompt_token_count: usize, - /// Number of output tokens generated. - output_token_count: usize, + usage: TokenUsage, finish_reason: FinishReason, /// Connector-specific KV transfer parameters for disaggregated serving. kv_transfer_params: Option, diff --git a/rust/src/chat/src/lib.rs b/rust/src/chat/src/lib.rs index 5b6f66cf417e..8e7ed02a4036 100644 --- a/rust/src/chat/src/lib.rs +++ b/rust/src/chat/src/lib.rs @@ -29,8 +29,8 @@ pub use parser::reasoning::{ pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory}; pub use renderer::hf::ChatTemplateContentFormatOption; pub use renderer::{ - ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, RenderedPrompt, - RendererSelection, + ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, + HarmonyChatRenderer, RenderedPrompt, RendererSelection, }; pub use request::{ ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool, @@ -50,9 +50,10 @@ mod request; mod stream; use vllm_engine_core_client::EngineCoreClient; -use vllm_engine_core_client::protocol::ModelDtype; +use vllm_engine_core_client::protocol::dtype::ModelDtype; +use vllm_engine_core_client::protocol::request::ReasoningParserKwargs; use vllm_llm::Llm; -use vllm_text::{TextLlm, TextRequest}; +use vllm_text::{Prompt, TextLlm, TextRequest}; /// Validate explicit parser override names without starting request processing. pub fn validate_parser_overrides( @@ -140,6 +141,16 @@ impl ChatLlm { self } + /// Tokenizer vocabulary size. + pub fn tokenizer_vocab_size(&self) -> usize { + self.text.tokenizer_vocab_size() + } + + /// Model vocabulary size from the model config. + pub fn model_vocab_size(&self) -> usize { + self.text.model_vocab_size() + } + /// Expose the underlying text facade for raw text-generation routes such as /// `/v1/completions`. pub fn text(&self) -> &TextLlm { @@ -161,6 +172,9 @@ impl ChatLlm { pub async fn chat(&self, mut request: ChatRequest) -> Result { request.validate()?; + // Stamp before rendering so render and tokenize count toward TTFT/e2e. + let arrival_time = vllm_llm::current_unix_timestamp_secs(); + let output_processor = self.backend.new_chat_output_processor( &mut request, NewChatOutputProcessorOptions { @@ -169,6 +183,14 @@ impl ChatLlm { }, )?; let rendered = self.backend.chat_renderer().render(&request)?; + let reasoning_parser_kwargs = + request + .sampling_params + .structured_outputs + .is_some() + .then(|| ReasoningParserKwargs { + chat_template_kwargs: rendered.effective_template_kwargs.clone(), + }); let (prompt, mm_features) = multimodal::finalize_rendered_prompt( &request, @@ -189,7 +211,9 @@ impl ChatLlm { cache_salt: request.cache_salt, add_special_tokens: request.add_special_tokens, data_parallel_rank: request.data_parallel_rank, + reasoning_parser_kwargs, lora_request: request.lora_request, + arrival_time: Some(arrival_time), }; let decoded_stream = self.text.generate(text_request).await?.map_err(Error::from).boxed(); @@ -198,6 +222,39 @@ impl ChatLlm { Ok(ChatEventStream::new(request.request_id, structured_stream)) } + /// Render through the chat template and tokenize, without submitting to the engine. + /// + /// Same render → [`multimodal::finalize_rendered_prompt`] → encode pipeline as + /// [`Self::chat`], but stops after token IDs so `/tokenize` counts match what + /// generation would see. Used by `POST /tokenize` (chat form). + pub async fn tokenize_chat(&self, request: ChatRequest) -> Result> { + request.validate()?; + + let rendered = self.backend.chat_renderer().render(&request)?; + let (prompt, _mm_features) = multimodal::finalize_rendered_prompt( + &request, + rendered, + self.backend.multimodal_model_info(), + self.model_dtype, + ) + .await?; + + let tokenizer = self.text.tokenizer(); + let token_ids = match prompt { + // Rendered string from the template (usual chat path). + Prompt::Text(text) => tokenizer.encode(&text, request.add_special_tokens)?, + // Already tokenized (e.g. multimodal path); pass through unchanged. + Prompt::TokenIds(ids) => ids, + }; + Ok(token_ids) + } + + /// Abort in-flight requests by their external (user-supplied) request ids. + pub async fn abort(&self, external_ids: &[String]) -> Result<()> { + self.text.abort(external_ids).await?; + Ok(()) + } + /// Shut down the underlying LLM client and its background tasks. pub async fn shutdown(self) -> Result<()> { self.text.shutdown().await?; @@ -234,7 +291,7 @@ mod tests { ) .unwrap_err(); - expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, hermes, hy_v3, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, mistral, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string()); + expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string()); } #[test] @@ -245,6 +302,6 @@ mod tests { ) .unwrap_err(); - expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, kimi, kimi_k2, minimax_m2, nemotron_v3, qwen3, step3)"].assert_eq(&error.to_report_string()); + expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, kimi, kimi_k2, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string()); } } diff --git a/rust/src/chat/src/multimodal.rs b/rust/src/chat/src/multimodal.rs index fcfee0ccb33c..7d950d581da5 100644 --- a/rust/src/chat/src/multimodal.rs +++ b/rust/src/chat/src/multimodal.rs @@ -16,13 +16,14 @@ use std::sync::{Arc, LazyLock}; use itertools::izip; use llm_multimodal::{ - AsyncMultiModalTracker, FieldLayout, ImagePreProcessor, ImageProcessorRegistry, MediaConnector, - MediaConnectorConfig, MediaContentPart, Modality, ModelMetadata, ModelProcessorSpec, - ModelRegistry, PreProcessorConfig, PreprocessedImages, PromptReplacement, TokenResolver, - TrackedMedia, + AsyncMultiModalTracker, FieldLayout, MediaConnector, MediaConnectorConfig, MediaContentPart, + Modality, ModelMetadata, ModelProcessorSpec, ModelRegistry, PreProcessorConfig, + PreprocessedEncoderInputs as PreprocessedImages, PromptReplacement, Tokenizer as TokenResolver, + TrackedMedia, VisionPreProcessor as ImagePreProcessor, + VisionProcessorRegistry as ImageProcessorRegistry, }; use tracing::warn; -use vllm_engine_core_client::protocol::ModelDtype; +use vllm_engine_core_client::protocol::dtype::ModelDtype; use vllm_engine_core_client::protocol::multimodal::{ MmBatchedField, MmFeatureSpec, MmFeatures, MmField, MmFieldElem, MmFlatField, MmKwargsItem, MmSharedField, MmSlice, PlaceholderRange, SliceSpec, @@ -225,7 +226,7 @@ impl MultimodalModelInfo { /// /// The HF renderer uses this token while flattening image content in string /// content format. - pub(crate) fn placeholder_token(&self) -> &str { + pub fn placeholder_token(&self) -> &str { &self.spec.placeholder_token } } @@ -245,16 +246,15 @@ pub(crate) async fn finalize_rendered_prompt( return Ok((rendered.prompt, None)); } let info = info.ok_or(Error::UnsupportedMultimodalRenderer)?; - let Prompt::Text(prompt) = rendered.prompt else { - bail_multimodal!("multimodal chat renderer must return a text prompt before expansion"); + let mut prompt_token_ids = match rendered.prompt { + Prompt::Text(prompt) => info + .context + .tokenizer() + .encode(&prompt, request.add_special_tokens) + .map_err(|error| multimodal!("{error}"))?, + Prompt::TokenIds(token_ids) => token_ids, }; let media_parts = extract_media_parts(request)?; - - let mut prompt_token_ids = info - .context - .tokenizer() - .encode(&prompt, request.add_special_tokens) - .map_err(|error| multimodal!("{error}"))?; let prepared = info.prepare_multimodal(media_parts, &mut prompt_token_ids, model_dtype).await?; Ok((Prompt::TokenIds(prompt_token_ids), Some(prepared))) @@ -365,6 +365,7 @@ impl MultimodalModelInfo { let processor = self.image_processor.raw; let images = image_frames.iter().map(|frame| frame.data().clone()).collect::>(); + // TODO: is it still necessary given that we've already in a dedicated runtime? tokio::task::spawn_blocking(move || { processor.preprocess(&images, &config).map_err(|error| multimodal!("{error}")) }) @@ -555,6 +556,10 @@ impl TokenResolver for TokenizerResolver { fn id_to_token(&self, id: u32) -> Option { self.0.id_to_token(id) } + + fn encode_text(&self, text: &str) -> Option> { + self.0.encode(text, false).ok() + } } #[cfg(test)] @@ -563,7 +568,7 @@ mod tests { use llm_multimodal::TokenId; use vllm_engine_core_client::protocol::tensor::WireArrayData; - use vllm_text::tokenizer::{IncrementalDecoder, Tokenizer, TokenizerError}; + use vllm_tokenizer::test_utils::TestTokenizer; use super::*; @@ -574,60 +579,14 @@ mod tests { const LLAMA4_TILE_X_SEPARATOR_ID: u32 = 200093; const LLAMA4_TILE_Y_SEPARATOR_ID: u32 = 200094; - struct TestTokenizer; - - impl Tokenizer for TestTokenizer { - fn encode( - &self, - text: &str, - _add_special_tokens: bool, - ) -> std::result::Result, TokenizerError> { - Ok(match text { - "<|image|>" => vec![LLAMA4_IMAGE_ID], - text => text.bytes().map(u32::from).collect(), - }) - } - - fn decode( - &self, - _token_ids: &[u32], - _skip_special_tokens: bool, - ) -> std::result::Result { - Ok(String::new()) - } - - fn token_to_id(&self, token: &str) -> Option { - match token { - "<|image_start|>" => Some(LLAMA4_IMAGE_START_ID), - "<|image_end|>" => Some(LLAMA4_IMAGE_END_ID), - "<|image|>" => Some(LLAMA4_IMAGE_ID), - "<|patch|>" => Some(LLAMA4_PATCH_ID), - "<|tile_x_separator|>" => Some(LLAMA4_TILE_X_SEPARATOR_ID), - "<|tile_y_separator|>" => Some(LLAMA4_TILE_Y_SEPARATOR_ID), - _ => None, - } - } - - fn id_to_token(&self, id: u32) -> Option { - match id { - LLAMA4_IMAGE_START_ID => Some("<|image_start|>".to_string()), - LLAMA4_IMAGE_END_ID => Some("<|image_end|>".to_string()), - LLAMA4_IMAGE_ID => Some("<|image|>".to_string()), - LLAMA4_PATCH_ID => Some("<|patch|>".to_string()), - LLAMA4_TILE_X_SEPARATOR_ID => Some("<|tile_x_separator|>".to_string()), - LLAMA4_TILE_Y_SEPARATOR_ID => Some("<|tile_y_separator|>".to_string()), - _ => None, - } - } - - fn create_decode_stream( - &self, - _prompt_token_ids: &[u32], - _skip_special_tokens: bool, - _min_bytes_to_buffer: usize, - ) -> Box { - unreachable!("not used") - } + fn llama4_tokenizer() -> TestTokenizer { + TestTokenizer::new() + .with_regular_token("<|image_start|>", LLAMA4_IMAGE_START_ID) + .with_regular_token("<|image_end|>", LLAMA4_IMAGE_END_ID) + .with_regular_token("<|image|>", LLAMA4_IMAGE_ID) + .with_regular_token("<|patch|>", LLAMA4_PATCH_ID) + .with_regular_token("<|tile_x_separator|>", LLAMA4_TILE_X_SEPARATOR_ID) + .with_regular_token("<|tile_y_separator|>", LLAMA4_TILE_Y_SEPARATOR_ID) } fn test_info(model_type: &str, config: serde_json::Value) -> MultimodalModelInfo { @@ -635,7 +594,7 @@ mod tests { model_id: format!("{model_type}-test"), model_type: Some(model_type.to_string()), config, - tokenizer: TokenizerResolver(Arc::new(TestTokenizer)), + tokenizer: TokenizerResolver(Arc::new(llama4_tokenizer())), }; let spec = context .resolve_model_spec() diff --git a/rust/src/chat/src/multimodal/tensor.rs b/rust/src/chat/src/multimodal/tensor.rs index eddf8f707e95..95259f1a93fe 100644 --- a/rust/src/chat/src/multimodal/tensor.rs +++ b/rust/src/chat/src/multimodal/tensor.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use half::{bf16, f16}; -use llm_multimodal::{ModelSpecificValue, PreprocessedImages}; -use vllm_engine_core_client::protocol::ModelDtype; +use llm_multimodal::{ModelSpecificValue, PreprocessedEncoderInputs as PreprocessedImages}; +use vllm_engine_core_client::protocol::dtype::ModelDtype; use vllm_engine_core_client::protocol::multimodal::MmKwargValue as ProtocolKwargValue; use vllm_engine_core_client::protocol::tensor::{ShapeExt as _, WireTensor}; @@ -31,14 +31,14 @@ pub(super) fn collect_tensors( float_dtype: ModelDtype, ) -> Result> { let PreprocessedImages { - pixel_values, + encoder_input, model_specific, .. } = preprocessed; let pixel_values = { - let shape = pixel_values.shape().to_vec(); - let data = pixel_values.into_iter().collect(); + let shape = encoder_input.shape().to_vec(); + let data = encoder_input.into_iter().collect(); KwargValue::from_f32_tensor(data, shape, float_dtype)? }; diff --git a/rust/src/chat/src/output/default/mod.rs b/rust/src/chat/src/output/default/mod.rs index 40526a9e84ce..b42d24dcaa6a 100644 --- a/rust/src/chat/src/output/default/mod.rs +++ b/rust/src/chat/src/output/default/mod.rs @@ -1,42 +1,36 @@ //! Default output processing pipeline. -mod reasoning; -mod tool; +mod structural_tag; +mod unified; use std::sync::Once; -use futures::{Stream, StreamExt as _}; +use futures::StreamExt as _; use tracing::info; -use trait_set::trait_set; +use vllm_parser::unified::{CombinedParser, UnifiedParser}; use vllm_text::tokenizer::DynTokenizer; -use self::reasoning::reasoning_event_stream; -use self::tool::tool_event_stream; +use self::structural_tag::apply_structural_tag_constraint; +use self::unified::unified_event_stream; use super::structured::structured_chat_event_stream; use crate::error::Result; -use crate::output::{ - AssistantEvent, ChatOutputProcessor, ContentEvent, DynChatEventStream, - DynDecodedTextEventStream, -}; +use crate::output::{ChatOutputProcessor, DynChatEventStream, DynDecodedTextEventStream}; use crate::parser::ParserSelection; use crate::parser::reasoning::{ReasoningParser, ReasoningParserFactory}; use crate::parser::tool::{ToolParser, ToolParserFactory}; -use crate::request::{ChatRequest, ChatToolChoice}; +use crate::parser::unified::UnifiedParserFactory; +use crate::request::{ChatRequest, ChatTool}; use crate::{Error, Result as ChatResult}; -trait_set! { - trait ContentEventStream = Stream> + Send + 'static; -} - /// Default request-scoped output processor used by Hugging Face style chat /// backends. /// /// This implementation assumes the backend already emitted decoded text deltas, -/// then optionally layers reasoning parsing and tool-call parsing before +/// then optionally layers unified reasoning and tool-call parsing before /// assembling final structured chat events. pub struct DefaultChatOutputProcessor { - reasoning_parser: Option>, - tool_parser: Option>, + parser: Box, + parallel_tool_calls: bool, } impl DefaultChatOutputProcessor { @@ -53,27 +47,39 @@ impl DefaultChatOutputProcessor { tool_call_parser: &ParserSelection, reasoning_parser: &ParserSelection, ) -> ChatResult { - let tool_parsing_enabled = - matches!(request.tool_choice, ChatToolChoice::Auto) && !request.tools.is_empty(); - let tool_parser = if tool_parsing_enabled { - Some(Self::resolve_tool_parser( - request, + let parser = if tool_call_parser == reasoning_parser + && let Some(parser) = Self::resolve_optional_unified_parser( + &request.tools, model_id, + tokenizer.clone(), tool_call_parser, - )?) + )? { + parser } else { - None + let tool_parsing_enabled = request.tool_parsing_enabled(); + let tool_parser = if tool_parsing_enabled { + Some(Self::resolve_tool_parser( + &request.tools, + model_id, + tool_call_parser, + )?) + } else { + None + }; + let reasoning_parser = + Self::resolve_optional_reasoning_parser(model_id, tokenizer, reasoning_parser)?; + Box::new(CombinedParser::new(reasoning_parser, tool_parser)) as Box }; - let reasoning_parser = Self::resolve_optional_reasoning_parser( - request, - model_id, - tokenizer, - reasoning_parser, - )?; + + apply_structural_tag_constraint(request, parser.structural_tag_model())?; + + if parser.preserve_special_tokens() { + request.decode_options.skip_special_tokens = false; + } Ok(Self { - reasoning_parser, - tool_parser, + parser, + parallel_tool_calls: request.parallel_tool_calls, }) } @@ -84,13 +90,13 @@ impl DefaultChatOutputProcessor { /// content is treated as opaque text. pub fn plain_text_only() -> Self { Self { - reasoning_parser: None, - tool_parser: None, + parser: Box::new(CombinedParser::plain_text_only()), + parallel_tool_calls: true, } } fn resolve_tool_parser( - request: &mut ChatRequest, + tools: &[ChatTool], model_id: &str, selection: &ParserSelection, ) -> ChatResult> { @@ -106,18 +112,37 @@ impl DefaultChatOutputProcessor { ParserSelection::Explicit(name) => name.as_str(), }; - let parser = factory.create(parser_name, &request.tools)?; - - if parser.preserve_special_tokens() { - request.decode_options.skip_special_tokens = false; - } + let parser = factory.create(parser_name, tools)?; TOOL_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using tool parser")); Ok(parser) } + fn resolve_optional_unified_parser( + tools: &[ChatTool], + model_id: &str, + tokenizer: DynTokenizer, + selection: &ParserSelection, + ) -> ChatResult>> { + let factory = UnifiedParserFactory::global(); + let parser_name = match selection { + ParserSelection::Auto => factory.resolve_name_for_model(model_id), + ParserSelection::None => None, + ParserSelection::Explicit(name) if factory.contains(name) => Some(name.as_str()), + ParserSelection::Explicit(_) => None, + }; + + let Some(parser_name) = parser_name else { + return Ok(None); + }; + + let parser = factory.create(parser_name, tools, tokenizer)?; + + UNIFIED_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using unified parser")); + Ok(Some(parser)) + } + fn resolve_optional_reasoning_parser( - request: &mut ChatRequest, model_id: &str, tokenizer: DynTokenizer, selection: &ParserSelection, @@ -136,10 +161,6 @@ impl DefaultChatOutputProcessor { let parser = factory.create(parser_name, tokenizer)?; - if parser.preserve_special_tokens() { - request.decode_options.skip_special_tokens = false; - } - REASONING_PARSER_LOG_ONCE.call_once(|| info!(parser_name, "using reasoning parser")); Ok(Some(parser)) } @@ -147,20 +168,91 @@ impl DefaultChatOutputProcessor { static TOOL_PARSER_LOG_ONCE: Once = Once::new(); static REASONING_PARSER_LOG_ONCE: Once = Once::new(); +static UNIFIED_PARSER_LOG_ONCE: Once = Once::new(); impl ChatOutputProcessor for DefaultChatOutputProcessor { /// Transforms a raw generate-output token stream into structured chat - /// events through three sequential stages once text decoding has + /// events through two sequential stages once text decoding has /// already happened: /// - /// 1. [`reasoning_event_stream`] — reasoning/content separation - /// 2. [`tool_event_stream`] — tool-call parsing - /// 3. [`structured_chat_event_stream`] — final block assembly + /// 1. [`unified_event_stream`] — reasoning and tool-call parsing + /// 2. [`structured_chat_event_stream`] — final block assembly fn process(self: Box, decoded: DynDecodedTextEventStream) -> Result { - let reasoning = reasoning_event_stream(decoded, self.reasoning_parser); - let tool = tool_event_stream(reasoning, self.tool_parser); - let structured = structured_chat_event_stream(tool); + let parsed = unified_event_stream(decoded, self.parser); + let structured = structured_chat_event_stream(parsed, self.parallel_tool_calls); Ok(structured.boxed()) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vllm_tokenizer::test_utils::TestTokenizer; + + use super::DefaultChatOutputProcessor; + use crate::Error; + use crate::parser::ParserSelection; + use crate::request::ChatRequest; + + fn tokenizer() -> Arc { + Arc::new( + TestTokenizer::new() + .with_regular_token("<|channel>", 256) + .with_regular_token("", 257), + ) + } + + #[test] + fn equal_explicit_gemma4_uses_unified_parser() { + let mut request = ChatRequest::for_test(); + let selection = ParserSelection::Explicit("gemma4".to_string()); + + DefaultChatOutputProcessor::new( + &mut request, + "other-model", + tokenizer(), + &selection, + &selection, + ) + .unwrap(); + } + + #[test] + fn auto_auto_gemma4_model_uses_unified_parser() { + let mut request = ChatRequest::for_test(); + + DefaultChatOutputProcessor::new( + &mut request, + "google/gemma-4-27b-it", + tokenizer(), + &ParserSelection::Auto, + &ParserSelection::Auto, + ) + .unwrap(); + } + + #[test] + fn mixed_gemma4_selection_uses_split_dummy_error() { + let mut request = ChatRequest::for_test(); + let error = match DefaultChatOutputProcessor::new( + &mut request, + "other-model", + tokenizer(), + &ParserSelection::Auto, + &ParserSelection::Explicit("gemma4".to_string()), + ) { + Ok(_) => panic!("expected mixed Gemma4 parser selection to fail"), + Err(error) => error, + }; + + let Error::ParserInitialization { error, .. } = error else { + panic!("expected parser initialization error"); + }; + assert_eq!( + error.to_string(), + "`gemma4` only provides a unified parser; the same reasoning parser and tool parser should be specified together" + ); + } +} diff --git a/rust/src/chat/src/output/default/reasoning.rs b/rust/src/chat/src/output/default/reasoning.rs deleted file mode 100644 index b51ce41961d5..000000000000 --- a/rust/src/chat/src/output/default/reasoning.rs +++ /dev/null @@ -1,504 +0,0 @@ -//! Adapts decoded text updates into reasoning-aware assistant deltas. -//! -//! This stage sits between low-level token decoding and final block assembly. -//! It is the only place in the new pipeline that understands reasoning -//! separation: `decoded.rs` still only produces plain text deltas, while later -//! stages consume the semantic `Text` / `Reasoning` split emitted here. - -use asynk_strim_attr::{TryYielder, try_stream}; -use futures::{StreamExt as _, pin_mut}; -use thiserror_ext::AsReport; -use tracing::warn; -use vllm_text::output::DecodedTextEvent; - -use super::ContentEvent; -use crate::Result; -use crate::error::Error; -use crate::event::AssistantBlockKind; -use crate::output::DecodedTextEventStream; -use crate::parser::reasoning::{ReasoningDelta, ReasoningParser}; - -/// Per-stream reasoning parsing state. -struct ReasoningState { - /// Reasoning parser for the current model family. - parser: Box, - /// Whether reasoning parsing has already failed for this stream. - parser_failed: bool, -} - -impl ReasoningState { - /// Create one fresh reasoning-adaptation state for a new streamed response. - fn new(parser: Box) -> Self { - Self { - parser, - parser_failed: false, - } - } - - /// Convert one decoded text delta into zero or more semantic assistant - /// deltas. - fn process_delta(&mut self, delta: String) -> Vec { - // If the parser has already failed, skip parsing and return plain text deltas. - if self.parser_failed { - return vec![ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta, - }]; - } - - let mut events = Vec::new(); - - match self.parser.push(&delta) { - Ok(result) => { - push_reasoning_delta(&mut events, result); - } - Err(error) => { - if !self.parser_failed { - warn!( - error = %error.as_report(), - "reasoning parser failed; falling back to plain text deltas" - ); - self.parser_failed = true; - } - push_text_delta(&mut events, AssistantBlockKind::Text, delta); - } - } - - events - } - - /// Initialize parser state once prompt token IDs are available. - fn initialize(&mut self, prompt_token_ids: &[u32]) { - if self.parser_failed { - return; - } - - match self.parser.initialize(prompt_token_ids) { - Ok(()) => {} - Err(error) => { - warn!( - error = %error.as_report(), - "failed to initialize reasoning parser; falling back to plain text deltas" - ); - self.parser_failed = true; - } - } - } - - /// Flush any parser-held partial delimiter state at end of stream. - fn finish(&mut self) -> Vec { - if self.parser_failed { - return Vec::new(); - } - - match self.parser.finish() { - Ok(result) => { - let mut events = Vec::new(); - push_reasoning_delta(&mut events, result); - events - } - Err(error) => { - warn!(error = %error.as_report(), "failed to flush reasoning parser state"); - Vec::new() - } - } - } -} - -/// Push one semantic text delta if it is non-empty. -fn push_text_delta(events: &mut Vec, kind: AssistantBlockKind, delta: String) { - if delta.is_empty() { - return; - } - events.push(ContentEvent::TextDelta { kind, delta }); -} - -/// Convert one parsed reasoning delta into zero or more content events. -fn push_reasoning_delta(events: &mut Vec, delta: ReasoningDelta) { - if let Some(reasoning) = delta.reasoning { - push_text_delta(events, AssistantBlockKind::Reasoning, reasoning); - } - if let Some(content) = delta.content { - push_text_delta(events, AssistantBlockKind::Text, content); - } -} - -/// Wrap one decoded-text stream into the internal reasoning event stream. -#[try_stream] -pub(crate) async fn reasoning_event_stream( - decoded_stream: impl DecodedTextEventStream, - reasoning_parser: Option>, - mut y: TryYielder, -) -> Result<()> { - pin_mut!(decoded_stream); - - // Without a parser, pass through as plain text deltas. - let Some(reasoning_parser) = reasoning_parser else { - while let Some(event) = decoded_stream.next().await.transpose()? { - for next in ContentEvent::from_decoded_plain_text(event) { - y.yield_ok(next).await; - } - } - return Ok(()); - }; - - let mut state = ReasoningState::new(reasoning_parser); - - while let Some(event) = decoded_stream.next().await.transpose()? { - match event { - DecodedTextEvent::Start { - prompt_token_ids, - prompt_logprobs, - } => { - state.initialize(&prompt_token_ids); - y.yield_ok(ContentEvent::Start { - prompt_token_ids, - prompt_logprobs, - }) - .await; - } - DecodedTextEvent::TextDelta { - delta, - token_ids, - logprobs, - finished, - } => { - for next in state.process_delta(delta) { - y.yield_ok(next).await; - } - if logprobs.is_some() || !token_ids.is_empty() { - y.yield_ok(ContentEvent::LogprobsDelta { - logprobs, - token_ids, - }) - .await; - } - if let Some(finished) = finished { - for next in state.finish() { - y.yield_ok(next).await; - } - y.yield_ok(ContentEvent::Done { - prompt_token_count: finished.prompt_token_count, - output_token_count: finished.output_token_count, - finish_reason: finished.finish_reason, - kv_transfer_params: finished.kv_transfer_params, - }) - .await; - } - } - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - - use std::sync::Arc; - - use futures::{StreamExt as _, stream}; - use vllm_llm::FinishReason; - use vllm_text::output::{ - DecodedLogprobs, DecodedPositionLogprobs, DecodedTextEvent, DecodedTokenLogprob, - }; - use vllm_tokenizer::{DynTokenizer, Tokenizer}; - - use super::super::ContentEvent; - use super::reasoning_event_stream; - use crate::event::AssistantBlockKind; - use crate::parser::reasoning::{ - ReasoningDelta, ReasoningError, ReasoningParser, ReasoningParserFactory, names, - }; - - struct FakeTokenizer; - - impl Tokenizer for FakeTokenizer { - fn encode( - &self, - text: &str, - _add_special_tokens: bool, - ) -> vllm_tokenizer::Result> { - Ok(text.chars().map(u32::from).collect()) - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - Ok(token_ids - .iter() - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}')) - .collect()) - } - - fn token_to_id(&self, token: &str) -> Option { - match token { - "" => Some(1), - "" => Some(2), - _ => None, - } - } - } - - struct FailingReasoningParser { - fail_next: bool, - } - - impl ReasoningParser for FailingReasoningParser { - fn create(_tokenizer: DynTokenizer) -> Result, ReasoningError> - where - Self: Sized + 'static, - { - Ok(Box::new(Self { fail_next: true })) - } - - fn push(&mut self, _text: &str) -> Result { - if self.fail_next { - self.fail_next = false; - return Err(ReasoningError::MissingToken { - token: "".to_string(), - }); - } - Ok(ReasoningDelta::default()) - } - } - - fn test_reasoning_parser(factory: &mut ReasoningParserFactory) -> Box { - factory.register_parser::("failing"); - - factory.create("failing", Arc::new(FakeTokenizer)).unwrap() - } - - #[tokio::test] - async fn reasoning_parser_failure_falls_back_to_plain_text() { - let mut factory = ReasoningParserFactory::new(); - let events = stream::iter(vec![ - Ok(DecodedTextEvent::Start { - prompt_token_ids: vec![1, 2, 3].into(), - prompt_logprobs: None, - }), - Ok(DecodedTextEvent::TextDelta { - delta: "abc".to_string(), - token_ids: vec![], - logprobs: None, - finished: None, - }), - Ok(DecodedTextEvent::TextDelta { - delta: "def".to_string(), - token_ids: vec![], - logprobs: None, - finished: Some(vllm_text::Finished { - prompt_token_count: 3, - output_token_count: 0, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }), - }), - ]); - - let collected = reasoning_event_stream(events, Some(test_reasoning_parser(&mut factory))) - .collect::>() - .await; - - let events = collected - .into_iter() - .collect::>>() - .expect("reasoning stream should not fail"); - - assert_eq!( - events, - vec![ - ContentEvent::Start { - prompt_token_ids: vec![1, 2, 3].into(), - prompt_logprobs: None, - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "abc".to_string(), - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "def".to_string(), - }, - ContentEvent::Done { - prompt_token_count: 3, - output_token_count: 0, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }, - ] - ); - } - - #[tokio::test] - async fn reasoning_stream_preserves_logprobs_delta() { - let events = stream::iter(vec![ - Ok(DecodedTextEvent::Start { - prompt_token_ids: vec![1].into(), - prompt_logprobs: None, - }), - Ok(DecodedTextEvent::TextDelta { - delta: "abc".to_string(), - token_ids: vec![], - logprobs: Some(DecodedLogprobs { - positions: vec![DecodedPositionLogprobs { - entries: vec![DecodedTokenLogprob { - token_id: 0, - token: "a".to_string(), - logprob: -0.1, - rank: 1, - }], - }], - }), - finished: None, - }), - ]); - - let collected = reasoning_event_stream(events, None) - .collect::>() - .await - .into_iter() - .collect::>>() - .unwrap(); - - assert_eq!( - collected, - vec![ - ContentEvent::Start { - prompt_token_ids: vec![1].into(), - prompt_logprobs: None, - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "abc".to_string(), - }, - ContentEvent::LogprobsDelta { - logprobs: Some(DecodedLogprobs { - positions: vec![DecodedPositionLogprobs { - entries: vec![DecodedTokenLogprob { - token_id: 0, - token: "a".to_string(), - logprob: -0.1, - rank: 1, - }], - }], - }), - token_ids: vec![], - }, - ] - ); - } - - #[tokio::test] - async fn qwen3_parser_uses_prompt_end_marker_to_switch_to_content() { - let tokenizer = Arc::new(FakeTokenizer); - let events = stream::iter(vec![ - Ok(DecodedTextEvent::Start { - prompt_token_ids: vec![2].into(), - prompt_logprobs: None, - }), - Ok(DecodedTextEvent::TextDelta { - delta: "thought ".to_string(), - token_ids: vec![], - logprobs: None, - finished: None, - }), - Ok(DecodedTextEvent::TextDelta { - delta: "doneOK".to_string(), - token_ids: vec![], - logprobs: None, - finished: None, - }), - ]); - - let factory = ReasoningParserFactory::new(); - let collected = reasoning_event_stream( - events, - Some(factory.create(names::QWEN3, tokenizer).unwrap()), - ) - .collect::>() - .await; - - let events = collected - .into_iter() - .collect::>>() - .expect("reasoning stream should not fail"); - - assert_eq!( - events, - vec![ - ContentEvent::Start { - prompt_token_ids: vec![2].into(), - prompt_logprobs: None, - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "thought ".to_string(), - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "doneOK".to_string(), - }, - ] - ); - } - - #[tokio::test] - async fn qwen3_parser_tolerates_prompt_prefill_reasoning() { - let tokenizer = Arc::new(FakeTokenizer); - let events = stream::iter(vec![ - Ok(DecodedTextEvent::Start { - prompt_token_ids: vec![1].into(), - prompt_logprobs: None, - }), - Ok(DecodedTextEvent::TextDelta { - delta: "thought ".to_string(), - token_ids: vec![], - logprobs: None, - finished: None, - }), - Ok(DecodedTextEvent::TextDelta { - delta: "doneOK".to_string(), - token_ids: vec![], - logprobs: None, - finished: None, - }), - ]); - - let factory = ReasoningParserFactory::new(); - let collected = reasoning_event_stream( - events, - Some(factory.create(names::QWEN3, tokenizer).unwrap()), - ) - .collect::>() - .await; - - let events = collected - .into_iter() - .collect::>>() - .expect("reasoning stream should not fail"); - - assert_eq!( - events, - vec![ - ContentEvent::Start { - prompt_token_ids: vec![1].into(), - prompt_logprobs: None, - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Reasoning, - delta: "thought ".to_string(), - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Reasoning, - delta: "done".to_string(), - }, - ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "OK".to_string(), - }, - ] - ); - } -} diff --git a/rust/src/chat/src/output/default/structural_tag.rs b/rust/src/chat/src/output/default/structural_tag.rs new file mode 100644 index 000000000000..45a508ffee6a --- /dev/null +++ b/rust/src/chat/src/output/default/structural_tag.rs @@ -0,0 +1,255 @@ +//! Applies xgrammar structural-tag constraints for strict tool calling. + +use thiserror_ext::AsReport; +use vllm_engine_core_client::protocol::structured_outputs::{ + StructuredOutputBackend, StructuredOutputsParams, +}; +use vllm_parser::tool::StructuralTagModel; +use xgrammar_structural_tag::{ + FunctionDefinition, FunctionToolParam, ToolChoice as StructuralTagToolChoice, ToolParam, + build_structural_tag, +}; + +use crate::request::{ChatRequest, ChatToolChoice}; +use crate::{Error, Result as ChatResult}; + +/// Apply structural tag constraints to the request based on the tool parser's structural tag +/// support and the request's tool choice. +pub(super) fn apply_structural_tag_constraint( + request: &mut ChatRequest, + model: Option, +) -> ChatResult<()> { + let Some(model) = model else { + return Ok(()); + }; + let Some(tool_choice) = structural_tag_tool_choice(request) else { + return Ok(()); + }; + + let tools = request + .tools + .iter() + .map(|tool| { + ToolParam::Function(FunctionToolParam::new(FunctionDefinition { + name: tool.name.clone(), + description: tool.description.clone(), + parameters: Some(tool.parameters.clone()), + strict: tool.strict, + })) + }) + .collect::>(); + + let structural_tag = build_structural_tag(model, &tools, tool_choice, false) + .and_then(|tag| tag.to_json_string()) + .map_err(|error| Error::StructuralTag { + message: error.to_report_string(), + })?; + + // Overwrite any existing structured output settings with the structural tag constraint. + request.sampling_params.structured_outputs = Some(StructuredOutputsParams { + backend: StructuredOutputBackend::Xgrammar, + ..StructuredOutputsParams::structural_tag(structural_tag) + }); + + Ok(()) +} + +/// Resolve the tool choice used for [`xgrammar_structural_tag`] based on the request. +/// +/// Returns `None` if no structural tag constraints should be applied. +fn structural_tag_tool_choice(request: &ChatRequest) -> Option { + if request.tools.is_empty() { + return None; + } + + match &request.tool_choice { + // For `Auto`, only apply the structural tag if there's at least one strict tool. + ChatToolChoice::Auto if request.tools.iter().any(|tool| tool.strict == Some(true)) => { + Some(StructuralTagToolChoice::auto()) + } + ChatToolChoice::Auto | ChatToolChoice::None => None, + + ChatToolChoice::Required => Some(StructuralTagToolChoice::required()), + ChatToolChoice::Function { name } => Some(StructuralTagToolChoice::function(name.clone())), + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + use vllm_engine_core_client::protocol::structured_outputs::{ + StructuredOutputBackend, StructuredOutputsParams, + }; + use vllm_parser::tool::{Qwen3CoderToolParser, Tool, ToolParser}; + + use super::*; + + fn chat_tool(name: &str, strict: Option) -> Tool { + Tool { + name: name.to_string(), + description: None, + parameters: json!({ + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"] + }), + strict, + } + } + + fn qwen3_coder_parser(tools: &[Tool]) -> Box { + Qwen3CoderToolParser::create(tools).expect("Qwen3 Coder parser should build") + } + + fn request(tool_choice: ChatToolChoice, tools: Vec) -> ChatRequest { + ChatRequest { + tool_choice, + tools, + ..ChatRequest::for_test() + } + } + + fn structural_tag_value(request: &ChatRequest) -> Value { + let params = request + .sampling_params + .structured_outputs + .as_ref() + .expect("structured outputs should be set"); + assert_eq!(params.backend, StructuredOutputBackend::Xgrammar); + let structural_tag = params + .constraint + .as_structural_tag() + .expect("structured output constraint should be structural_tag"); + serde_json::from_str(structural_tag).expect("structural_tag should be valid JSON") + } + + fn structured_outputs(request: &ChatRequest) -> &StructuredOutputsParams { + request + .sampling_params + .structured_outputs + .as_ref() + .expect("structured outputs should be set") + } + + #[test] + fn auto_strict_tool_choice_builds_structural_tag() { + let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag should build"); + + let tag = structural_tag_value(&request); + assert_eq!(tag["type"], "structural_tag"); + assert!(tag.to_string().contains("search")); + } + + #[test] + fn auto_non_strict_tool_choice_skips_structural_tag() { + let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", None)]); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag decision should succeed"); + + assert!(request.sampling_params.structured_outputs.is_none()); + } + + #[test] + fn auto_strict_tool_choice_overwrites_existing_json_guidance() { + let mut request = request(ChatToolChoice::Auto, vec![chat_tool("search", Some(true))]); + request.sampling_params.structured_outputs = Some(StructuredOutputsParams { + backend: StructuredOutputBackend::Xgrammar, + ..StructuredOutputsParams::json(json!({"type": "object"})) + }); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag should build"); + + let params = structured_outputs(&request); + assert!(params.constraint.is_structural_tag()); + let tag = structural_tag_value(&request); + assert_eq!(tag["type"], "structural_tag"); + assert!(tag.to_string().contains("search")); + } + + #[test] + fn required_tool_choice_builds_structural_tag_without_strict_tools() { + let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag should build"); + + let tag = structural_tag_value(&request); + assert_eq!(tag["type"], "structural_tag"); + assert!(tag.to_string().contains("search")); + } + + #[test] + fn required_tool_choice_overwrites_existing_json_object_guidance() { + let mut request = request(ChatToolChoice::Required, vec![chat_tool("search", None)]); + request.sampling_params.structured_outputs = Some(StructuredOutputsParams { + backend: StructuredOutputBackend::Xgrammar, + ..StructuredOutputsParams::json_object() + }); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag should build"); + + let params = structured_outputs(&request); + assert!(params.constraint.is_structural_tag()); + let tag = structural_tag_value(&request); + assert_eq!(tag["type"], "structural_tag"); + assert!(tag.to_string().contains("search")); + } + + #[test] + fn named_tool_choice_builds_structural_tag_for_named_tool_only() { + let mut request = request( + ChatToolChoice::Function { + name: "lookup".to_string(), + }, + vec![chat_tool("search", None), chat_tool("lookup", None)], + ); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag should build"); + + let tag = structural_tag_value(&request).to_string(); + assert!(tag.contains("lookup")); + assert!(!tag.contains("search")); + } + + #[test] + fn none_tool_choice_skips_structural_tag() { + let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag decision should succeed"); + + assert!(request.sampling_params.structured_outputs.is_none()); + } + + #[test] + fn none_tool_choice_preserves_existing_json_object_guidance() { + let mut request = request(ChatToolChoice::None, vec![chat_tool("search", Some(true))]); + request.sampling_params.structured_outputs = Some(StructuredOutputsParams { + backend: StructuredOutputBackend::Xgrammar, + ..StructuredOutputsParams::json_object() + }); + let parser = qwen3_coder_parser(&request.tools); + + apply_structural_tag_constraint(&mut request, parser.structural_tag_model()) + .expect("structural tag decision should succeed"); + + let params = structured_outputs(&request); + assert!(params.constraint.is_json_object()); + } +} diff --git a/rust/src/chat/src/output/default/tool.rs b/rust/src/chat/src/output/default/tool.rs deleted file mode 100644 index 9774f6438169..000000000000 --- a/rust/src/chat/src/output/default/tool.rs +++ /dev/null @@ -1,859 +0,0 @@ -//! Adapts plain assistant text deltas into tool-call-aware assistant updates. -//! -//! This stage runs after reasoning separation and before final block assembly. -//! It only inspects normal assistant text, leaves reasoning deltas untouched, -//! and translates incremental tool parsing output into internal tool-call -//! events while preserving plain-text fallback behavior. - -use asynk_strim_attr::{TryYielder, try_stream}; -use futures::{StreamExt as _, pin_mut}; -use thiserror_ext::AsReport; -use tracing::warn; - -use super::{AssistantEvent, ContentEvent, ContentEventStream}; -use crate::Result; -use crate::error::Error; -use crate::event::AssistantBlockKind; -use crate::output::generate_tool_call_id; -use crate::parser::tool::{ToolCallDelta, ToolParser, ToolParserOutput}; - -/// Per-stream tool parsing state. -struct ToolState { - /// Parser for the current model family. - parser: Box, - /// Whether tool parsing has already failed for this stream. - parser_failed: bool, - /// The parser-local index of the currently open tool call, if any. - // NOTE: We only allow single open tool call at a time right now, since that's what all - // supported parsers currently emit. Change this to a `BTreeMap` if we need to support multiple - // interleaved calls in the future. - open_call_index: Option, -} - -impl ToolState { - /// Create one fresh tool-parsing state for a new streamed response. - fn new(parser: Box) -> Self { - Self { - parser, - parser_failed: false, - open_call_index: None, - } - } - - /// Convert one semantic assistant text delta into zero or more tool-aware - /// internal events. - fn process_text_delta( - &mut self, - kind: AssistantBlockKind, - delta: String, - ) -> Result> { - let mut events = Vec::new(); - - // Only normal assistant text is eligible for tool parsing. Reasoning - // blocks and plain-text fallback should pass through unchanged. - if kind != AssistantBlockKind::Text || self.parser_failed { - self.open_call_index = None; - events.push(AssistantEvent::TextDelta { kind, delta }); - return Ok(events); - } - - let mut output = ToolParserOutput::default(); - let parse_result = self.parser.parse_into(&delta, &mut output); - - match parse_result { - Ok(()) => self.process_parser_output(kind, output, &mut events)?, - Err(error) => { - warn!( - error = %error.as_report(), - "tool parser failed; falling back to plain text deltas" - ); - // Permanently mark this parser as failed. - // TODO: we may consider recovering from parsing errors in the future. - self.parser_failed = true; - - // On parsing failure, we still apply the partial parser output if any, but we close - // any open tool calls and emit the remaining buffered text as a plain-text delta to - // preserve as much of the output as possible. - self.process_parser_output(kind, output, &mut events)?; - self.open_call_index = None; - push_text_delta(&mut events, kind, self.parser.reset()); - } - } - - Ok(events) - } - - /// Apply one parsed tool output to the current stream state. - fn process_parser_output( - &mut self, - kind: AssistantBlockKind, - output: ToolParserOutput, - events: &mut Vec, - ) -> Result<()> { - // When we are not currently streaming a tool call, preserve plain - // text first and then surface any new tool call items. - if self.open_call_index.is_none() { - push_text_delta(events, kind, output.normal_text); - self.process_tool_items(output.calls, events)?; - } else { - // Once a tool call is open, prioritize tool deltas first. If the - // parser emits normal text again, close the tool call and resume - // plain text output. - self.process_tool_items(output.calls, events)?; - if !output.normal_text.is_empty() { - self.open_call_index = None; - push_text_delta(events, kind, output.normal_text); - } - } - Ok(()) - } - - /// Apply one batch of parsed tool-call deltas emitted by the parser. - fn process_tool_items( - &mut self, - items: Vec, - events: &mut Vec, - ) -> Result<()> { - for item in items { - if let Some(name) = item.name { - let is_new_tool = match self.open_call_index { - Some(open_call_index) => open_call_index != item.tool_index, - None => true, - }; - if is_new_tool { - let id = generate_tool_call_id(); - self.open_call_index = Some(item.tool_index); - events.push(AssistantEvent::ToolCallStart { id, name }); - } - } - - if item.arguments.is_empty() { - // No arguments delta to apply. - continue; - } - let Some(open_call_index) = self.open_call_index else { - return Err(Error::ToolCallStreamInvariant { - message: format!( - "received arguments for tool index {} before any tool-call start", - item.tool_index - ), - }); - }; - if open_call_index != item.tool_index { - return Err(Error::ToolCallStreamInvariant { - message: format!( - "received arguments for tool index {} while tool index {} is open", - item.tool_index, open_call_index - ), - }); - } - - events.push(AssistantEvent::ToolCallArgumentsDelta { - delta: item.arguments, - }); - } - Ok(()) - } - - /// Flush parser state at end-of-stream and close any remaining open calls. - fn finish(&mut self) -> Result> { - let mut events = Vec::new(); - - if self.parser_failed { - return Ok(events); - } - - match self.parser.finish() { - Ok(output) => { - self.process_parser_output(AssistantBlockKind::Text, output, &mut events)? - } - Err(error) => { - warn!( - error = %error.as_report(), - "tool parser finish failed; closing open tool calls with buffered state" - ); - self.parser_failed = true; - } - } - - Ok(events) - } -} - -/// Push one plain-text delta if it is non-empty. -fn push_text_delta(events: &mut Vec, kind: AssistantBlockKind, delta: String) { - if delta.is_empty() { - return; - } - events.push(AssistantEvent::TextDelta { kind, delta }); -} - -/// Wrap one semantic assistant stream into the internal tool-aware assistant -/// stream. -#[try_stream] -pub(crate) async fn tool_event_stream( - stream: impl ContentEventStream, - parser: Option>, - mut y: TryYielder, -) -> Result<()> { - // Without a parser, pass through the input stream unchanged. - let Some(parser) = parser else { - pin_mut!(stream); - while let Some(event) = stream.next().await.transpose()? { - y.yield_ok(event.into()).await; - } - return Ok(()); - }; - - pin_mut!(stream); - let mut state = ToolState::new(parser); - - while let Some(event) = stream.next().await.transpose()? { - match event { - ContentEvent::Start { - prompt_token_ids, - prompt_logprobs, - } => { - y.yield_ok(AssistantEvent::Start { - prompt_token_ids, - prompt_logprobs, - }) - .await; - } - ContentEvent::TextDelta { kind, delta } => { - for next in state.process_text_delta(kind, delta)? { - y.yield_ok(next).await; - } - } - ContentEvent::LogprobsDelta { - logprobs, - token_ids, - } => { - y.yield_ok(AssistantEvent::LogprobsDelta { - logprobs, - token_ids, - }) - .await; - } - ContentEvent::Done { - prompt_token_count, - output_token_count, - finish_reason, - kv_transfer_params, - } => { - for next in state.finish()? { - y.yield_ok(next).await; - } - - y.yield_ok(AssistantEvent::Done { - prompt_token_count, - output_token_count, - finish_reason, - kv_transfer_params, - }) - .await; - } - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - - use futures::{StreamExt as _, stream}; - use vllm_llm::FinishReason; - use vllm_text::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTokenLogprob}; - use vllm_tool_parser::Result; - - use super::super::{AssistantEvent, ContentEvent}; - use super::tool_event_stream; - use crate::error::Error; - use crate::event::{AssistantBlockKind, AssistantMessageExt as _}; - use crate::output::structured::structured_chat_event_stream; - use crate::parser::tool::{ - DeepSeekV4ToolParser, ToolParser, ToolParserError, ToolParserOutput, - }; - use crate::request::ChatTool; - use crate::stream::{ChatEventStream, CollectedAssistantMessage}; - - struct FailingParser { - fail_next: bool, - buffered: String, - } - - struct ScriptedParser { - push_outputs: Vec, - finish_output: ToolParserOutput, - } - - struct PartialThenFailParser { - buffered: String, - } - - impl ToolParser for FailingParser { - fn create(_tools: &[ChatTool]) -> vllm_tool_parser::Result> - where - Self: Sized + 'static, - { - Ok(Box::new(Self { - fail_next: false, - buffered: String::new(), - })) - } - - fn parse_into(&mut self, chunk: &str, _output: &mut ToolParserOutput) -> Result<()> { - self.buffered.push_str(chunk); - if self.fail_next { - self.fail_next = false; - return Err(ToolParserError::ParsingFailed { - message: "boom".to_string(), - }); - } - - self.buffered.clear(); - Ok(()) - } - - fn finish(&mut self) -> Result { - Ok(ToolParserOutput::default()) - } - - fn reset(&mut self) -> String { - std::mem::take(&mut self.buffered) - } - } - - impl ToolParser for ScriptedParser { - fn create(_tools: &[ChatTool]) -> vllm_tool_parser::Result> - where - Self: Sized + 'static, - { - Ok(Box::new(Self { - push_outputs: Vec::new(), - finish_output: ToolParserOutput::default(), - })) - } - - fn parse_into(&mut self, _chunk: &str, output: &mut ToolParserOutput) -> Result<()> { - let mut next = self.push_outputs.pop().unwrap_or_default(); - output.normal_text.push_str(&next.normal_text); - output.calls.append(&mut next.calls); - Ok(()) - } - - fn finish(&mut self) -> Result { - Ok(std::mem::take(&mut self.finish_output)) - } - - fn reset(&mut self) -> String { - String::new() - } - } - - impl ToolParser for PartialThenFailParser { - fn create(_tools: &[ChatTool]) -> vllm_tool_parser::Result> - where - Self: Sized + 'static, - { - Ok(Box::new(Self { - buffered: String::new(), - })) - } - - fn parse_into(&mut self, _chunk: &str, output: &mut ToolParserOutput) -> Result<()> { - output.calls.extend([ - crate::parser::tool::ToolCallDelta { - tool_index: 0, - name: Some("get_weather".to_string()), - arguments: String::new(), - }, - crate::parser::tool::ToolCallDelta { - tool_index: 0, - name: None, - arguments: r#"{"location":"SF"}"#.to_string(), - }, - ]); - self.buffered.push_str(" trailing text"); - Err(ToolParserError::ParsingFailed { - message: "boom".to_string(), - }) - } - - fn finish(&mut self) -> Result { - Ok(ToolParserOutput::default()) - } - - fn reset(&mut self) -> String { - std::mem::take(&mut self.buffered) - } - } - - fn deepseek_v4_test_tools() -> Vec { - vec![ - ChatTool { - name: "get_weather".to_string(), - description: None, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "location": { "type": "string" } - } - }), - strict: None, - }, - ChatTool { - name: "add".to_string(), - description: None, - parameters: serde_json::json!({ - "type": "object", - "properties": { - "x": { "type": "integer" }, - "y": { "type": "integer" } - } - }), - strict: None, - }, - ] - } - - async fn collect_deepseek_v4_message(chunks: Vec) -> CollectedAssistantMessage { - let events = chunks - .into_iter() - .map(|delta| { - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta, - }) - }) - .chain(std::iter::once(Ok(ContentEvent::Done { - prompt_token_count: 1, - output_token_count: 1, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }))); - let parser = DeepSeekV4ToolParser::create(&deepseek_v4_test_tools()).unwrap(); - let assistant_events = tool_event_stream(stream::iter(events), Some(parser)); - let chat_events = structured_chat_event_stream(assistant_events); - - ChatEventStream::new("req_deepseek_v4".to_string(), Box::pin(chat_events)) - .collect_message() - .await - .unwrap() - } - - fn message_tool_projection( - message: &CollectedAssistantMessage, - ) -> (String, Vec<(String, serde_json::Value)>) { - ( - message.message.text(), - message - .message - .tool_calls() - .map(|call| { - ( - call.name.clone(), - serde_json::from_str(&call.arguments).unwrap(), - ) - }) - .collect(), - ) - } - - #[tokio::test] - async fn tool_parser_error_preserves_partial_output_and_flushes_buffer() { - let events = stream::iter(vec![ - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "ignored".to_string(), - }), - Ok(ContentEvent::Done { - prompt_token_count: 1, - output_token_count: 1, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }), - ]); - - let events = tool_event_stream( - events, - Some(Box::new(PartialThenFailParser { - buffered: String::new(), - })), - ) - .collect::>() - .await - .into_iter() - .collect::>>() - .unwrap(); - - assert!(matches!( - &events[0], - AssistantEvent::ToolCallStart { name, .. } if name == "get_weather" - )); - assert!(matches!( - &events[1], - AssistantEvent::ToolCallArgumentsDelta { delta } if delta == r#"{"location":"SF"}"# - )); - assert_eq!( - events[2], - AssistantEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: " trailing text".to_string(), - } - ); - assert!(matches!(events[3], AssistantEvent::Done { .. })); - } - - #[tokio::test] - async fn real_buffered_parser_error_matches_streaming_and_non_streaming() { - let prefix = "I will check both.\n"; - let first_tool_call = concat!( - "<|DSML|tool_calls>\n", - "<|DSML|invoke name=\"get_weather\">\n", - "<|DSML|parameter name=\"location\" string=\"true\">Tokyo\n", - "", - ); - let malformed_second_tool_call = concat!( - "\n<|DSML|invoke name=\"add\">\n", - "not a parameter\n", - "\n", - "", - ); - let streaming_chunks = vec![ - prefix.to_string(), - first_tool_call.to_string(), - malformed_second_tool_call.to_string(), - ]; - let full_output = streaming_chunks.concat(); - - let streaming = collect_deepseek_v4_message(streaming_chunks).await; - let non_streaming = collect_deepseek_v4_message(vec![full_output]).await; - - let expected = ( - format!("{prefix}{malformed_second_tool_call}"), - vec![( - "get_weather".to_string(), - serde_json::json!({ "location": "Tokyo" }), - )], - ); - assert_eq!(message_tool_projection(&streaming), expected); - assert_eq!(message_tool_projection(&non_streaming), expected); - } - - #[tokio::test] - async fn tool_parser_failure_falls_back_to_plain_text() { - let events = stream::iter(vec![ - Ok(ContentEvent::Start { - prompt_token_ids: vec![1, 2, 3].into(), - prompt_logprobs: None, - }), - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "abc".to_string(), - }), - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "def".to_string(), - }), - Ok(ContentEvent::Done { - prompt_token_count: 3, - output_token_count: 0, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }), - ]); - - let collected = tool_event_stream( - events, - Some(Box::new(FailingParser { - fail_next: true, - buffered: String::new(), - })), - ) - .collect::>() - .await; - - let events = collected - .into_iter() - .collect::>>() - .expect("tool stream should not fail"); - - assert_eq!( - events, - vec![ - AssistantEvent::Start { - prompt_token_ids: vec![1, 2, 3].into(), - prompt_logprobs: None, - }, - AssistantEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "abc".to_string(), - }, - AssistantEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "def".to_string(), - }, - AssistantEvent::Done { - prompt_token_count: 3, - output_token_count: 0, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }, - ] - ); - - let message = ChatEventStream::new( - "req_fallback".to_string(), - Box::pin(structured_chat_event_stream(stream::iter( - events.into_iter().map(Ok), - ))), - ) - .collect_message() - .await - .expect("collect_message should succeed"); - assert_eq!(message.message.text(), "abcdef"); - assert!(message.message.tool_calls().next().is_none()); - } - - #[tokio::test] - async fn tool_stream_preserves_logprobs_delta() { - let events = stream::iter(vec![ - Ok(ContentEvent::Start { - prompt_token_ids: vec![1].into(), - prompt_logprobs: None, - }), - Ok(ContentEvent::LogprobsDelta { - logprobs: Some(DecodedLogprobs { - positions: vec![DecodedPositionLogprobs { - entries: vec![DecodedTokenLogprob { - token_id: 0, - token: "a".to_string(), - logprob: -0.2, - rank: 1, - }], - }], - }), - token_ids: vec![], - }), - Ok(ContentEvent::Done { - prompt_token_count: 1, - output_token_count: 0, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }), - ]); - let events = tool_event_stream( - events, - Some(Box::new(FailingParser { - fail_next: false, - buffered: String::new(), - })), - ) - .collect::>() - .await - .into_iter() - .collect::>>() - .unwrap(); - - assert_eq!( - events, - vec![ - AssistantEvent::Start { - prompt_token_ids: vec![1].into(), - prompt_logprobs: None, - }, - AssistantEvent::LogprobsDelta { - logprobs: Some(DecodedLogprobs { - positions: vec![DecodedPositionLogprobs { - entries: vec![DecodedTokenLogprob { - token_id: 0, - token: "a".to_string(), - logprob: -0.2, - rank: 1, - }], - }], - }), - token_ids: vec![], - }, - AssistantEvent::Done { - prompt_token_count: 1, - output_token_count: 0, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }, - ] - ); - } - - #[tokio::test] - async fn tool_stream_rejects_interleaved_tool_indices() { - let events = stream::iter(vec![ - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "ignored".to_string(), - }), - Ok(ContentEvent::Done { - prompt_token_count: 1, - output_token_count: 1, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }), - ]); - - let parser = ScriptedParser { - push_outputs: vec![ToolParserOutput { - normal_text: String::new(), - calls: vec![ - crate::parser::tool::ToolCallDelta { - tool_index: 0, - name: Some("first".to_string()), - arguments: String::new(), - }, - crate::parser::tool::ToolCallDelta { - tool_index: 1, - name: None, - arguments: "{}".to_string(), - }, - ], - }], - finish_output: ToolParserOutput::default(), - }; - - let err = tool_event_stream(events, Some(Box::new(parser))) - .collect::>() - .await - .into_iter() - .find_map(|output| output.err()) - .expect("expected invariant error"); - - assert!(matches!(err, Error::ToolCallStreamInvariant { .. })); - } - - #[tokio::test] - async fn tool_stream_resets_open_tool_when_normal_text_interrupts_it() { - let events = stream::iter(vec![ - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "start".to_string(), - }), - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "text".to_string(), - }), - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "args".to_string(), - }), - ]); - - let parser = ScriptedParser { - push_outputs: vec![ - ToolParserOutput { - normal_text: String::new(), - calls: vec![crate::parser::tool::ToolCallDelta { - tool_index: 0, - name: None, - arguments: "}".to_string(), - }], - }, - ToolParserOutput { - normal_text: "plain text".to_string(), - calls: Vec::new(), - }, - ToolParserOutput { - normal_text: String::new(), - calls: vec![crate::parser::tool::ToolCallDelta { - tool_index: 0, - name: Some("first".to_string()), - arguments: "{".to_string(), - }], - }, - ], - finish_output: ToolParserOutput::default(), - }; - - let err = tool_event_stream(events, Some(Box::new(parser))) - .collect::>() - .await - .into_iter() - .find_map(|output| output.err()) - .expect("expected invariant error"); - - assert!(matches!( - err, - Error::ToolCallStreamInvariant { message } - if message == "received arguments for tool index 0 before any tool-call start" - )); - } - - #[tokio::test] - async fn tool_stream_emits_start_and_args_for_terminal_text() { - let events = stream::iter(vec![ - Ok(ContentEvent::Start { - prompt_token_ids: vec![1].into(), - prompt_logprobs: None, - }), - Ok(ContentEvent::TextDelta { - kind: AssistantBlockKind::Text, - delta: "ignored".to_string(), - }), - Ok(ContentEvent::Done { - prompt_token_count: 1, - output_token_count: 1, - finish_reason: FinishReason::stop_eos(), - kv_transfer_params: None, - }), - ]); - - let parser = ScriptedParser { - push_outputs: vec![ToolParserOutput { - normal_text: String::new(), - calls: vec![ - crate::parser::tool::ToolCallDelta { - tool_index: 0, - name: Some("first".to_string()), - arguments: r#"{"a":1}"#.to_string(), - }, - crate::parser::tool::ToolCallDelta { - tool_index: 1, - name: Some("second".to_string()), - arguments: r#"{"b":2}"#.to_string(), - }, - ], - }], - finish_output: ToolParserOutput::default(), - }; - - let events = tool_event_stream(events, Some(Box::new(parser))) - .collect::>() - .await - .into_iter() - .collect::>>() - .unwrap(); - - assert!(matches!(events[1], AssistantEvent::ToolCallStart { .. })); - assert!(matches!( - events[2], - AssistantEvent::ToolCallArgumentsDelta { .. } - )); - assert!(matches!(events[3], AssistantEvent::ToolCallStart { .. })); - assert!(matches!( - events[4], - AssistantEvent::ToolCallArgumentsDelta { .. } - )); - let collected = ChatEventStream::new( - "req_final_only".to_string(), - Box::pin(structured_chat_event_stream(stream::iter( - events.into_iter().map(Ok), - ))), - ) - .collect_message() - .await - .unwrap(); - let tool_calls = collected.message.tool_calls().collect::>(); - assert_eq!(tool_calls.len(), 2); - assert_eq!(tool_calls[0].name, "first"); - assert_eq!(tool_calls[1].name, "second"); - } -} diff --git a/rust/src/chat/src/output/default/unified.rs b/rust/src/chat/src/output/default/unified.rs new file mode 100644 index 000000000000..e9ba676c8c8f --- /dev/null +++ b/rust/src/chat/src/output/default/unified.rs @@ -0,0 +1,678 @@ +//! Adapts decoded text updates into parsed assistant deltas. +//! +//! This stage sits between low-level token decoding and final block assembly. +//! It drives one unified parser that may emit normal text, reasoning text, or +//! tool-call deltas, then normalizes those parser events into internal +//! assistant events. + +use asynk_strim_attr::{TryYielder, try_stream}; +use futures::{StreamExt as _, pin_mut}; +use thiserror_ext::AsReport; +use tracing::warn; +use vllm_parser::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput}; +use vllm_text::output::DecodedTextEvent; + +use crate::Result; +use crate::error::Error; +use crate::event::AssistantBlockKind; +use crate::output::{AssistantEvent, DecodedTextEventStream, generate_tool_call_id}; + +/// Per-stream unified parsing state. +struct UnifiedParserState { + /// Parser for the current request stream. + parser: Box, + /// Whether unified parsing has already failed for this stream. + parser_failed: bool, + /// The parser-local index of the currently open tool call, if any. + /// + /// Supported parsers currently emit at most one active tool call at a time. + /// Change this to an indexed map if a model needs interleaved calls later. + open_call_index: Option, +} + +impl UnifiedParserState { + /// Create one fresh unified parsing state for a new streamed response. + fn new(parser: Box) -> Self { + Self { + parser, + parser_failed: false, + open_call_index: None, + } + } + + /// Initialize parser state once prompt token IDs are available. + fn initialize(&mut self, prompt_token_ids: &[u32]) { + if self.parser_failed { + return; + } + + match self.parser.initialize(prompt_token_ids) { + Ok(()) => {} + Err(error) => { + warn!( + error = %error.as_report(), + "failed to initialize unified parser; falling back to plain text deltas" + ); + self.parser_failed = true; + self.open_call_index = None; + } + } + } + + /// Convert one decoded text delta into zero or more parsed assistant events. + fn process_delta(&mut self, delta: String) -> Result> { + if self.parser_failed { + self.open_call_index = None; + return Ok(text_event(AssistantBlockKind::Text, delta).into_iter().collect()); + } + + let mut output = UnifiedParserOutput::default(); + match self.parser.parse_into(&delta, &mut output) { + Ok(()) => { + let mut events = Vec::new(); + self.process_parser_output(output, &mut events)?; + Ok(events) + } + Err(error) => { + warn!( + error = %error.as_report(), + "unified parser failed; falling back to plain text deltas" + ); + self.parser_failed = true; + + let mut events = Vec::new(); + self.process_parser_output(output, &mut events)?; + self.open_call_index = None; + + let recovered = self.parser.reset(); + if recovered.is_empty() && events.is_empty() { + push_text_delta(&mut events, AssistantBlockKind::Text, delta); + } else { + push_text_delta(&mut events, AssistantBlockKind::Text, recovered); + } + Ok(events) + } + } + } + + /// Flush parser state at end-of-stream and close any remaining open calls. + fn finish(&mut self) -> Result> { + let mut events = Vec::new(); + + if self.parser_failed { + return Ok(events); + } + + match self.parser.finish() { + Ok(output) => self.process_parser_output(output, &mut events)?, + Err(error) => { + warn!( + error = %error.as_report(), + "unified parser finish failed; closing open parser state" + ); + self.parser_failed = true; + self.open_call_index = None; + let recovered = self.parser.reset(); + push_text_delta(&mut events, AssistantBlockKind::Text, recovered); + } + } + + Ok(events) + } + + /// Apply one parsed unified output to the current stream state. + fn process_parser_output( + &mut self, + output: UnifiedParserOutput, + events: &mut Vec, + ) -> Result<()> { + for event in output.events { + match event { + UnifiedParserEvent::Text(delta) => { + self.open_call_index = None; + push_text_delta(events, AssistantBlockKind::Text, delta); + } + UnifiedParserEvent::Reasoning(delta) => { + self.open_call_index = None; + push_text_delta(events, AssistantBlockKind::Reasoning, delta); + } + UnifiedParserEvent::ToolCall(item) => { + self.process_tool_item(item, events)?; + } + } + } + + Ok(()) + } + + /// Apply one parsed tool-call delta emitted by the parser. + fn process_tool_item( + &mut self, + item: vllm_parser::tool::ToolCallDelta, + events: &mut Vec, + ) -> Result<()> { + if let Some(name) = item.name { + let is_new_tool = match self.open_call_index { + Some(open_call_index) => open_call_index != item.tool_index, + None => true, + }; + if is_new_tool { + let id = self + .parser + .tool_call_id(item.tool_index) + .map(str::to_string) + .unwrap_or_else(generate_tool_call_id); + self.open_call_index = Some(item.tool_index); + events.push(AssistantEvent::ToolCallStart { id, name }); + } + } + + if item.arguments.is_empty() { + return Ok(()); + } + let Some(open_call_index) = self.open_call_index else { + return Err(Error::ToolCallStreamInvariant { + message: format!( + "received arguments for tool index {} before any tool-call start", + item.tool_index + ), + }); + }; + if open_call_index != item.tool_index { + return Err(Error::ToolCallStreamInvariant { + message: format!( + "received arguments for tool index {} while tool index {} is open", + item.tool_index, open_call_index + ), + }); + } + + events.push(AssistantEvent::ToolCallArgumentsDelta { + delta: item.arguments, + }); + Ok(()) + } +} + +/// Build one plain text event if `delta` is non-empty. +fn text_event(kind: AssistantBlockKind, delta: String) -> Option { + if delta.is_empty() { + return None; + } + Some(AssistantEvent::TextDelta { kind, delta }) +} + +/// Push one plain text delta if it is non-empty. +fn push_text_delta(events: &mut Vec, kind: AssistantBlockKind, delta: String) { + if let Some(event) = text_event(kind, delta) { + events.push(event); + } +} + +/// Wrap one decoded-text stream into the internal unified assistant stream. +#[try_stream] +pub(crate) async fn unified_event_stream( + decoded_stream: impl DecodedTextEventStream, + parser: Box, + mut y: TryYielder, +) -> Result<()> { + pin_mut!(decoded_stream); + + let mut state = UnifiedParserState::new(parser); + + while let Some(event) = decoded_stream.next().await.transpose()? { + match event { + DecodedTextEvent::Start { + prompt_token_ids, + prompt_logprobs, + } => { + state.initialize(&prompt_token_ids); + y.yield_ok(AssistantEvent::Start { + prompt_token_ids, + prompt_logprobs, + }) + .await; + } + DecodedTextEvent::TextDelta { + delta, + token_ids, + logprobs, + finished, + } => { + for next in state.process_delta(delta)? { + y.yield_ok(next).await; + } + if logprobs.is_some() || !token_ids.is_empty() { + y.yield_ok(AssistantEvent::LogprobsDelta { + logprobs, + token_ids, + }) + .await; + } + if let Some(finished) = finished { + for next in state.finish()? { + y.yield_ok(next).await; + } + y.yield_ok(AssistantEvent::Done { + usage: finished.usage, + finish_reason: finished.finish_reason, + kv_transfer_params: finished.kv_transfer_params, + }) + .await; + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::Arc; + + use futures::{StreamExt as _, stream}; + use vllm_parser::reasoning::ReasoningError; + use vllm_parser::tool::{Tool, ToolCallDelta}; + use vllm_parser::unified::{Gemma4UnifiedParser, UnifiedParserError, UnifiedParserOutput}; + use vllm_tokenizer::test_utils::TestTokenizer; + + use super::unified_event_stream; + use crate::event::AssistantBlockKind; + use crate::output::AssistantEvent; + + enum ScriptedStep { + Output(UnifiedParserOutput), + Error { + committed: UnifiedParserOutput, + reset_text: String, + }, + } + + struct ScriptedParser { + steps: VecDeque, + reset_text: String, + tool_call_id: Option, + finish_error_reset_text: Option, + } + + impl ScriptedParser { + fn new(steps: impl IntoIterator) -> Self { + Self { + steps: steps.into_iter().collect(), + reset_text: String::new(), + tool_call_id: Some("call_test".to_string()), + finish_error_reset_text: None, + } + } + + fn with_finish_error(mut self, reset_text: &str) -> Self { + self.finish_error_reset_text = Some(reset_text.to_string()); + self + } + } + + impl vllm_parser::unified::UnifiedParser for ScriptedParser { + fn create( + _tools: &[vllm_parser::tool::Tool], + _tokenizer: vllm_tokenizer::DynTokenizer, + ) -> vllm_parser::unified::Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new([]))) + } + + fn parse_into( + &mut self, + _delta: &str, + output: &mut UnifiedParserOutput, + ) -> vllm_parser::unified::Result<()> { + match self.steps.pop_front().expect("unexpected parser call") { + ScriptedStep::Output(next) => { + output.append(next); + Ok(()) + } + ScriptedStep::Error { + committed, + reset_text, + } => { + output.append(committed); + self.reset_text = reset_text; + Err(UnifiedParserError::Reasoning( + ReasoningError::MissingToken { + token: "".to_string(), + }, + )) + } + } + } + + fn tool_call_id(&self, _tool_index: usize) -> Option<&str> { + self.tool_call_id.as_deref() + } + + fn finish(&mut self) -> vllm_parser::unified::Result { + if let Some(reset_text) = self.finish_error_reset_text.take() { + self.reset_text = reset_text; + return Err(UnifiedParserError::Reasoning( + ReasoningError::MissingToken { + token: "".to_string(), + }, + )); + } + Ok(UnifiedParserOutput::default()) + } + + fn reset(&mut self) -> String { + std::mem::take(&mut self.reset_text) + } + } + + fn decoded_delta(delta: &str) -> vllm_text::output::DecodedTextEvent { + vllm_text::output::DecodedTextEvent::TextDelta { + delta: delta.to_string(), + token_ids: Vec::new(), + logprobs: None, + finished: None, + } + } + + fn finished_delta(delta: &str) -> vllm_text::output::DecodedTextEvent { + vllm_text::output::DecodedTextEvent::TextDelta { + delta: delta.to_string(), + token_ids: Vec::new(), + logprobs: None, + finished: Some(vllm_text::output::Finished { + usage: vllm_llm::TokenUsage::default(), + finish_reason: crate::FinishReason::Stop(None), + kv_transfer_params: None, + }), + } + } + + async fn collect( + parser: ScriptedParser, + events: Vec, + ) -> Vec { + let stream = stream::iter(events.into_iter().map(Ok)); + unified_event_stream(stream, Box::new(parser)) + .collect::>() + .await + .into_iter() + .collect::>>() + .unwrap() + } + + fn text(delta: &str) -> UnifiedParserOutput { + let mut output = UnifiedParserOutput::default(); + output.push_text(delta.to_string()); + output + } + + fn reasoning(delta: &str) -> UnifiedParserOutput { + let mut output = UnifiedParserOutput::default(); + output.push_reasoning(delta.to_string()); + output + } + + fn tool_call(name: &str, arguments: &str) -> UnifiedParserOutput { + UnifiedParserOutput { + events: vec![vllm_parser::unified::UnifiedParserEvent::ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some(name.to_string()), + arguments: arguments.to_string(), + }, + )], + } + } + + fn tool_call_arguments(arguments: &str) -> UnifiedParserOutput { + UnifiedParserOutput { + events: vec![vllm_parser::unified::UnifiedParserEvent::ToolCall( + ToolCallDelta { + tool_index: 0, + name: None, + arguments: arguments.to_string(), + }, + )], + } + } + + fn combined(first: UnifiedParserOutput, second: UnifiedParserOutput) -> UnifiedParserOutput { + let mut output = first; + output.append(second); + output + } + + #[tokio::test] + async fn unified_stream_emits_reasoning_only_deltas() { + let events = collect( + ScriptedParser::new([ScriptedStep::Output(reasoning("thinking"))]), + vec![decoded_delta("raw")], + ) + .await; + + assert_eq!( + events, + vec![AssistantEvent::TextDelta { + kind: AssistantBlockKind::Reasoning, + delta: "thinking".to_string(), + }] + ); + } + + #[tokio::test] + async fn unified_stream_emits_tool_only_deltas() { + let events = collect( + ScriptedParser::new([ScriptedStep::Output(tool_call( + "get_weather", + r#"{"location":"Paris"}"#, + ))]), + vec![decoded_delta("raw")], + ) + .await; + + assert_eq!( + events, + vec![ + AssistantEvent::ToolCallStart { + id: "call_test".to_string(), + name: "get_weather".to_string(), + }, + AssistantEvent::ToolCallArgumentsDelta { + delta: r#"{"location":"Paris"}"#.to_string(), + }, + ] + ); + } + + #[tokio::test] + async fn unified_stream_emits_reasoning_followed_by_tool_call() { + let events = collect( + ScriptedParser::new([ScriptedStep::Output(combined( + reasoning("thinking"), + tool_call("get_weather", r#"{"location":"Paris"}"#), + ))]), + vec![decoded_delta("raw")], + ) + .await; + + assert_eq!( + events, + vec![ + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Reasoning, + delta: "thinking".to_string(), + }, + AssistantEvent::ToolCallStart { + id: "call_test".to_string(), + name: "get_weather".to_string(), + }, + AssistantEvent::ToolCallArgumentsDelta { + delta: r#"{"location":"Paris"}"#.to_string(), + }, + ] + ); + } + + #[tokio::test] + async fn unified_stream_emits_visible_text_followed_by_tool_call() { + let events = collect( + ScriptedParser::new([ScriptedStep::Output(combined( + text("visible "), + tool_call("get_weather", r#"{"location":"Paris"}"#), + ))]), + vec![decoded_delta("raw")], + ) + .await; + + assert_eq!( + events, + vec![ + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: "visible ".to_string(), + }, + AssistantEvent::ToolCallStart { + id: "call_test".to_string(), + name: "get_weather".to_string(), + }, + AssistantEvent::ToolCallArgumentsDelta { + delta: r#"{"location":"Paris"}"#.to_string(), + }, + ] + ); + } + + #[tokio::test] + async fn unified_stream_emits_tool_arguments_before_trailing_text() { + let events = collect( + ScriptedParser::new([ + ScriptedStep::Output(tool_call("get_weather", "")), + ScriptedStep::Output(combined( + tool_call_arguments(r#"{"location":"Paris"}"#), + text(" done"), + )), + ]), + vec![decoded_delta("start"), decoded_delta("finish")], + ) + .await; + + assert_eq!( + events, + vec![ + AssistantEvent::ToolCallStart { + id: "call_test".to_string(), + name: "get_weather".to_string(), + }, + AssistantEvent::ToolCallArgumentsDelta { + delta: r#"{"location":"Paris"}"#.to_string(), + }, + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: " done".to_string(), + }, + ] + ); + } + + #[tokio::test] + async fn unified_stream_fallback_keeps_committed_output_and_disables_later_parsing() { + let events = collect( + ScriptedParser::new([ScriptedStep::Error { + committed: text("committed"), + reset_text: "buffered".to_string(), + }]), + vec![decoded_delta("bad"), decoded_delta("later")], + ) + .await; + + assert_eq!( + events, + vec![ + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: "committed".to_string(), + }, + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: "buffered".to_string(), + }, + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: "later".to_string(), + }, + ] + ); + } + + #[tokio::test] + async fn unified_stream_finish_error_recovers_buffered_text() { + let events = collect( + ScriptedParser::new([ScriptedStep::Output(UnifiedParserOutput::default())]) + .with_finish_error("buffered"), + vec![finished_delta("")], + ) + .await; + + assert_eq!( + events, + vec![ + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: "buffered".to_string(), + }, + AssistantEvent::Done { + usage: vllm_llm::TokenUsage::default(), + finish_reason: crate::FinishReason::Stop(None), + kv_transfer_params: None, + }, + ] + ); + } + + #[tokio::test] + async fn unified_stream_recovers_incomplete_gemma4_tool_call_at_eos() { + let tokenizer = TestTokenizer::new() + .with_special_token("<|channel>", 256) + .with_special_token("", 257); + let tools = vec![Tool { + name: "write_file".to_string(), + description: None, + parameters: serde_json::json!({ "type": "object" }), + strict: None, + }]; + let parser = Gemma4UnifiedParser::new(&tools, Arc::new(tokenizer)).unwrap(); + let events = vec![ + decoded_delta("<|tool_call>"), + decoded_delta("call:write_file{"), + decoded_delta("content:<|\"|>hello "), + finished_delta("world<|\"|>"), + ]; + let stream = stream::iter(events.into_iter().map(Ok)); + let events = unified_event_stream(stream, Box::new(parser)) + .collect::>() + .await + .into_iter() + .collect::>>() + .unwrap(); + + assert_eq!( + events, + vec![ + AssistantEvent::TextDelta { + kind: AssistantBlockKind::Text, + delta: "<|tool_call>call:write_file{content:<|\"|>hello world<|\"|>" + .to_string(), + }, + AssistantEvent::Done { + usage: vllm_llm::TokenUsage::default(), + finish_reason: crate::FinishReason::Stop(None), + kv_transfer_params: None, + }, + ] + ); + } +} diff --git a/rust/src/chat/src/output/harmony/mod.rs b/rust/src/chat/src/output/harmony/mod.rs index 5dc6bc311856..597e3133795f 100644 --- a/rust/src/chat/src/output/harmony/mod.rs +++ b/rust/src/chat/src/output/harmony/mod.rs @@ -4,16 +4,10 @@ //! `DecodedTextEvent` token IDs directly and lets the official `openai-harmony` //! parser recover the structured assistant message shape at token granularity. -use std::sync::LazyLock; - -use anyhow::Context; use asynk_strim_attr::{TryYielder, try_stream}; use futures::StreamExt as _; use openai_harmony::chat::{Content as HarmonyContent, Message as HarmonyMessage, Role}; -use openai_harmony::{ - HarmonyEncoding, HarmonyEncodingName, StreamableParser, load_harmony_encoding, -}; -use thiserror_ext::AsReport; +use openai_harmony::{HarmonyEncoding, StreamableParser}; use vllm_text::output::DecodedTextEvent; use crate::Result as ChatResult; @@ -24,6 +18,7 @@ use crate::output::{ generate_tool_call_id, }; use crate::parser::ParserSelection; +use crate::renderer::harmony::encoding::harmony_encoding; use crate::request::ChatRequest; /// Request-scoped Harmony output processor used for `model_type == "gpt_oss"`. @@ -35,6 +30,7 @@ use crate::request::ChatRequest; pub struct HarmonyChatOutputProcessor { encoding: &'static HarmonyEncoding, tool_calls_enabled: bool, + parallel_tool_calls: bool, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -76,6 +72,7 @@ impl HarmonyChatOutputProcessor { Ok(Self { encoding: harmony_encoding()?, tool_calls_enabled: request.tool_parsing_enabled(), + parallel_tool_calls: request.parallel_tool_calls, }) } } @@ -110,7 +107,11 @@ impl ChatOutputProcessor for HarmonyChatOutputProcessor { fn process(self: Box, decoded: DynDecodedTextEventStream) -> Result { let assistant = harmony_assistant_event_stream(decoded, self.encoding, self.tool_calls_enabled); - Ok(crate::output::structured::structured_chat_event_stream(assistant).boxed()) + Ok(crate::output::structured::structured_chat_event_stream( + assistant, + self.parallel_tool_calls, + ) + .boxed()) } } @@ -366,8 +367,7 @@ async fn harmony_assistant_event_stream( if let Some(finished) = finished { y.yield_ok(AssistantEvent::Done { - prompt_token_count: finished.prompt_token_count, - output_token_count: finished.output_token_count, + usage: finished.usage, finish_reason: finished.finish_reason, kv_transfer_params: finished.kv_transfer_params, }) @@ -379,18 +379,6 @@ async fn harmony_assistant_event_stream( Ok(()) } -/// Lazily load the shared GPT-OSS Harmony encoding once per process. -fn harmony_encoding() -> Result<&'static HarmonyEncoding> { - static ENCODING: LazyLock> = LazyLock::new(|| { - load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss) - .context("failed to load harmony encoding for gpt-oss") - }); - - ENCODING.as_ref().map_err(|error| Error::HarmonyOutputParsing { - error: error.to_report_string().into(), - }) -} - fn harmony_output_parsing_error( error: impl Into>, ) -> Error { diff --git a/rust/src/chat/src/output/harmony/tests.rs b/rust/src/chat/src/output/harmony/tests.rs index fe42542b4736..cdb272e2cced 100644 --- a/rust/src/chat/src/output/harmony/tests.rs +++ b/rust/src/chat/src/output/harmony/tests.rs @@ -1,14 +1,8 @@ -//! Harmony output tests share the upstream `openai-harmony` tiktoken cache. -//! -//! Use a file lock for tests that load the encoding so `cargo nextest` cannot -//! start multiple processes that concurrently populate the same cache file. - use std::sync::Arc; use futures::executor::block_on; use futures::{TryStreamExt as _, stream}; use openai_harmony::chat::{Message, Role}; -use serial_test::file_serial; use vllm_text::output::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTextEvent, Finished}; use super::*; @@ -51,8 +45,11 @@ fn decoded_start() -> DecodedTextEvent { fn finished() -> Finished { Finished { - prompt_token_count: 0, - output_token_count: 0, + usage: vllm_llm::TokenUsage { + prompt_token_count: 0, + output_token_count: 0, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, } @@ -88,7 +85,6 @@ fn request_with_tools() -> ChatRequest { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn interrupted_final_message_is_preserved() { let tokens = completion_tokens(&[text_message("final", "hello")]); let events = block_on(collect_events( @@ -112,8 +108,11 @@ fn interrupted_final_message_is_preserved() { text: "hello".to_string(), }], }, - prompt_token_count: 0, - output_token_count: 0, + usage: vllm_llm::TokenUsage { + prompt_token_count: 0, + output_token_count: 0, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }) @@ -121,7 +120,6 @@ fn interrupted_final_message_is_preserved() { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn eos_flush_preserves_trailing_replacement_text() { let mut tokens = completion_tokens(&[text_message("final", "Hi")]); tokens.pop(); @@ -147,7 +145,6 @@ fn eos_flush_preserves_trailing_replacement_text() { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn interrupted_analysis_message_is_preserved() { let tokens = completion_tokens(&[text_message("analysis", "think")]); let events = block_on(collect_events( @@ -171,8 +168,11 @@ fn interrupted_analysis_message_is_preserved() { text: "think".to_string(), }], }, - prompt_token_count: 0, - output_token_count: 0, + usage: vllm_llm::TokenUsage { + prompt_token_count: 0, + output_token_count: 0, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }) @@ -180,7 +180,6 @@ fn interrupted_analysis_message_is_preserved() { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn commentary_preamble_is_visible_but_commentary_tool_payload_is_not() { let tokens = completion_tokens(&[ text_message("commentary", "Let me check."), @@ -208,7 +207,6 @@ fn commentary_preamble_is_visible_but_commentary_tool_payload_is_not() { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn multiple_messages_get_newline_separators() { let tokens = completion_tokens(&[ text_message("analysis", "first think"), @@ -240,7 +238,6 @@ fn multiple_messages_get_newline_separators() { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn tool_calls_stream_arguments_and_finish_with_local_id_shape() { let tokens = completion_tokens(&[tool_message( "get_weather", @@ -293,7 +290,6 @@ fn tool_calls_stream_arguments_and_finish_with_local_id_shape() { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn semantic_events_precede_same_update_logprobs() { let tokens = completion_tokens(&[text_message("final", "hello")]); let events = block_on(collect_events( @@ -344,7 +340,6 @@ fn rejects_generic_parser_overrides() { } #[test] -#[file_serial(harmony_tiktoken_cache)] fn allows_auto_auto_only() { validate_harmony_parser_overrides(&ParserSelection::Auto, &ParserSelection::Auto).unwrap(); let _ = HarmonyChatOutputProcessor::new(&ChatRequest::for_test()).unwrap(); diff --git a/rust/src/chat/src/output/mod.rs b/rust/src/chat/src/output/mod.rs index 81ec124fbcf2..836b199eb9b7 100644 --- a/rust/src/chat/src/output/mod.rs +++ b/rust/src/chat/src/output/mod.rs @@ -2,9 +2,9 @@ use std::pin::Pin; use std::sync::Arc; use futures::Stream; -use subenum::subenum; use trait_set::trait_set; use uuid::Uuid; +use vllm_llm::TokenUsage; use vllm_text::output::{DecodedLogprobs, DecodedPromptLogprobs, DecodedTextEvent}; use crate::FinishReason; @@ -21,23 +21,19 @@ pub(crate) use harmony::validate_harmony_parser_overrides; /// Internal assistant event before final assembly. /// -/// - [`ContentEvent`]: subenum after reasoning parsing, carries only text content. -/// - [`AssistantEvent`]: full event after tool parsing, adds tool-call variants. -#[subenum(ContentEvent)] +/// Unified parsing produces these events, and structured assembly consumes +/// them to build public chat events. #[derive(Debug, Clone, PartialEq)] pub(crate) enum AssistantEvent { - #[subenum(ContentEvent)] Start { prompt_token_ids: Arc<[u32]>, prompt_logprobs: Option, }, - #[subenum(ContentEvent)] TextDelta { kind: AssistantBlockKind, delta: String, }, /// Per-decoded-update sample metadata: logprobs and/or output token IDs. - #[subenum(ContentEvent)] LogprobsDelta { logprobs: Option, token_ids: Vec, @@ -47,61 +43,14 @@ pub(crate) enum AssistantEvent { /// A delta for the arguments of the currently open tool call. Must follow a /// `ToolCallStart`. ToolCallArgumentsDelta { delta: String }, - #[subenum(ContentEvent)] Done { - prompt_token_count: usize, - output_token_count: usize, + usage: TokenUsage, finish_reason: FinishReason, /// Connector-specific KV transfer parameters for disaggregated serving. kv_transfer_params: Option, }, } -impl ContentEvent { - /// Convert a [`DecodedTextEvent`] into one or more [`ContentEvent`] values - /// by treating all text as plain (non-reasoning) content. - fn from_decoded_plain_text(event: DecodedTextEvent) -> Vec { - match event { - DecodedTextEvent::Start { - prompt_token_ids, - prompt_logprobs, - } => vec![Self::Start { - prompt_token_ids, - prompt_logprobs, - }], - DecodedTextEvent::TextDelta { - delta, - token_ids, - logprobs, - finished, - } => { - let mut events = Vec::new(); - if !delta.is_empty() { - events.push(Self::TextDelta { - kind: AssistantBlockKind::Text, - delta, - }); - } - if logprobs.is_some() || !token_ids.is_empty() { - events.push(Self::LogprobsDelta { - logprobs, - token_ids, - }); - } - if let Some(finished) = finished { - events.push(Self::Done { - prompt_token_count: finished.prompt_token_count, - output_token_count: finished.output_token_count, - finish_reason: finished.finish_reason, - kv_transfer_params: finished.kv_transfer_params, - }); - } - events - } - } - } -} - /// Boxed stream of decoded text events coming from [`vllm_text`]. pub type DynDecodedTextEventStream = Pin> + Send>>; /// Boxed stream of structured chat events exposed by [`crate::ChatLlm`]. @@ -128,8 +77,6 @@ trait_set! { /// Generate the northbound tool-call ID using the OpenAI-style `call_` /// format. -// TODO: support other ID scheme like Kimi-K2's -// `functions.{name}:{global_index}`. pub(crate) fn generate_tool_call_id() -> String { format!("call_{}", &Uuid::new_v4().simple().to_string()[..24]) } diff --git a/rust/src/chat/src/output/structured.rs b/rust/src/chat/src/output/structured.rs index ed6e3a5130ce..4be7425d9015 100644 --- a/rust/src/chat/src/output/structured.rs +++ b/rust/src/chat/src/output/structured.rs @@ -53,16 +53,22 @@ struct StructuredEventState { open_tool_call: Option, /// Next OpenAI-compatible tool-call ordinal. next_tool_call_index: usize, + /// Whether more than one tool call may be surfaced northbound. + parallel_tool_calls: bool, + /// Whether the current tool-call parse is being suppressed. + suppressing_tool_call: bool, } impl StructuredEventState { /// Create one fresh assembly state for a new streamed response. - fn new() -> Self { + fn new(parallel_tool_calls: bool) -> Self { Self { message: AssistantMessage::default(), open_text_block: None, open_tool_call: None, next_tool_call_index: 0, + parallel_tool_calls, + suppressing_tool_call: false, } } @@ -98,6 +104,12 @@ impl StructuredEventState { let index = self.next_tool_call_index; self.next_tool_call_index += 1; + if !self.parallel_tool_calls && index >= 1 { + self.suppressing_tool_call = true; + return Ok(events); + } + + self.suppressing_tool_call = false; self.open_tool_call = Some(OpenToolCall { index, id: id.clone(), @@ -110,6 +122,10 @@ impl StructuredEventState { /// Append one incremental tool-call arguments delta. fn push_tool_call_arguments(&mut self, delta: String) -> Result> { + if self.suppressing_tool_call { + return Ok(Vec::new()); + } + let mut events = Vec::new(); let Some(open_tool_call) = self.open_tool_call.as_mut() else { return Err(Error::ToolCallStreamInvariant { @@ -127,8 +143,7 @@ impl StructuredEventState { /// Close any open block and emit the terminal `Done` event. fn finish( &mut self, - prompt_token_count: usize, - output_token_count: usize, + usage: vllm_llm::TokenUsage, finish_reason: FinishReason, kv_transfer_params: Option, ) -> Result> { @@ -137,8 +152,7 @@ impl StructuredEventState { self.close_open_tool_call(&mut events); events.push(ChatEvent::Done { message: self.message.clone(), - prompt_token_count, - output_token_count, + usage, finish_reason, kv_transfer_params, }); @@ -209,6 +223,11 @@ impl StructuredEventState { /// Finalize the currently open tool call, if present. fn close_open_tool_call(&mut self, events: &mut Vec) { + if self.suppressing_tool_call { + self.suppressing_tool_call = false; + return; + } + let Some(open_tool_call) = self.open_tool_call.take() else { return; }; @@ -231,11 +250,12 @@ impl StructuredEventState { #[try_stream] pub(crate) async fn structured_chat_event_stream( stream: impl AssistantEventStream, + parallel_tool_calls: bool, mut y: TryYielder, ) -> Result<()> { pin_mut!(stream); - let mut state = StructuredEventState::new(); + let mut state = StructuredEventState::new(parallel_tool_calls); while let Some(event) = stream.next().await.transpose()? { match event { @@ -273,17 +293,11 @@ pub(crate) async fn structured_chat_event_stream( } } AssistantEvent::Done { - prompt_token_count, - output_token_count, + usage, finish_reason, kv_transfer_params, } => { - for next in state.finish( - prompt_token_count, - output_token_count, - finish_reason, - kv_transfer_params, - )? { + for next in state.finish(usage, finish_reason, kv_transfer_params)? { y.yield_ok(next).await; } } @@ -313,14 +327,17 @@ mod tests { delta: r#"{"city":"Paris"}"#.to_string(), }), Ok(AssistantEvent::Done { - prompt_token_count: 1, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), ]); - let events = structured_chat_event_stream(events) + let events = structured_chat_event_stream(events, true) .collect::>() .await .into_iter() @@ -364,14 +381,17 @@ mod tests { delta: r#"{"b":2}"#.to_string(), }), Ok(AssistantEvent::Done { - prompt_token_count: 1, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), ]); - let events = structured_chat_event_stream(events) + let events = structured_chat_event_stream(events, true) .collect::>() .await .into_iter() @@ -412,14 +432,17 @@ mod tests { delta: r#"{"city":"Paris"}"#.to_string(), }), Ok(AssistantEvent::Done { - prompt_token_count: 1, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), ]); - let events = structured_chat_event_stream(events) + let events = structured_chat_event_stream(events, true) .collect::>() .await .into_iter() @@ -460,14 +483,17 @@ mod tests { delta: "done".to_string(), }), Ok(AssistantEvent::Done { - prompt_token_count: 1, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), ]); - let events = structured_chat_event_stream(events) + let events = structured_chat_event_stream(events, true) .collect::>() .await .into_iter() @@ -495,7 +521,7 @@ mod tests { delta: "{}".to_string(), })]); - let err = structured_chat_event_stream(events) + let err = structured_chat_event_stream(events, true) .collect::>() .await .into_iter() @@ -505,4 +531,56 @@ mod tests { assert!(matches!(err, Error::ToolCallStreamInvariant { .. })); } + + #[tokio::test] + async fn structured_stream_suppresses_later_tool_calls_when_parallel_disabled() { + let events = stream::iter(vec![ + Ok(AssistantEvent::ToolCallStart { + id: "call_1".to_string(), + name: "first".to_string(), + }), + Ok(AssistantEvent::ToolCallArgumentsDelta { + delta: r#"{"a":1}"#.to_string(), + }), + Ok(AssistantEvent::ToolCallStart { + id: "call_2".to_string(), + name: "second".to_string(), + }), + Ok(AssistantEvent::ToolCallArgumentsDelta { + delta: r#"{"b":2}"#.to_string(), + }), + Ok(AssistantEvent::Done { + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, + finish_reason: FinishReason::stop_eos(), + kv_transfer_params: None, + }), + ]); + + let events = structured_chat_event_stream(events, false) + .collect::>() + .await + .into_iter() + .collect::>>() + .unwrap(); + + assert!(matches!( + events[0], + ChatEvent::ToolCallStart { index: 0, .. } + )); + assert!(matches!( + events[1], + ChatEvent::ToolCallArgumentsDelta { index: 0, .. } + )); + assert!(matches!(events[2], ChatEvent::ToolCallEnd { index: 0, .. })); + let ChatEvent::Done { message, .. } = &events[3] else { + panic!("expected done"); + }; + let tool_calls = message.tool_calls().collect::>(); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].name, "first"); + } } diff --git a/rust/src/chat/src/parser/mod.rs b/rust/src/chat/src/parser/mod.rs index 244a87cc7a76..f56770d89daa 100644 --- a/rust/src/chat/src/parser/mod.rs +++ b/rust/src/chat/src/parser/mod.rs @@ -1,5 +1,6 @@ pub mod reasoning; pub mod tool; +pub mod unified; use std::collections::HashMap; use std::convert::Infallible; diff --git a/rust/src/chat/src/parser/reasoning/mod.rs b/rust/src/chat/src/parser/reasoning/mod.rs index 09111d7252f3..99e749d8f8af 100644 --- a/rust/src/chat/src/parser/reasoning/mod.rs +++ b/rust/src/chat/src/parser/reasoning/mod.rs @@ -1,12 +1,13 @@ //! Reasoning parser registration and selection boundary for `vllm-chat`. -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; -pub use vllm_reasoning_parser::{ +pub use vllm_parser::reasoning::{ CohereCmdReasoningParser, DeepSeekR1ReasoningParser, DeepSeekV3ReasoningParser, - DeepSeekV4ReasoningParser, Gemma4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser, - KimiReasoningParser, MiniMaxM2ReasoningParser, NemotronV3ReasoningParser, Qwen3ReasoningParser, - ReasoningDelta, ReasoningError, ReasoningParser, Step3ReasoningParser, + DeepSeekV4ReasoningParser, Glm45ReasoningParser, KimiK2ReasoningParser, KimiReasoningParser, + MiniMaxM2ReasoningParser, MiniMaxM3ReasoningParser, NemotronV3ReasoningParser, + Qwen3ReasoningParser, ReasoningDelta, ReasoningError, ReasoningParser, SeedOssReasoningParser, + Step3ReasoningParser, Step3p5ReasoningParser, }; use vllm_tokenizer::DynTokenizer; @@ -23,14 +24,18 @@ pub mod names { pub const KIMI: &str = "kimi"; pub const KIMI_K2: &str = "kimi_k2"; pub const MINIMAX_M2: &str = "minimax_m2"; + pub const MINIMAX_M3: &str = "minimax_m3"; pub const NEMOTRON_V3: &str = "nemotron_v3"; pub const QWEN3: &str = "qwen3"; + pub const SEED_OSS: &str = "seed_oss"; pub const STEP3: &str = "step3"; + pub const STEP3P5: &str = "step3p5"; } /// Constructor signature for one registered reasoning parser implementation. -type ReasoningParserCreator = - fn(DynTokenizer) -> vllm_reasoning_parser::Result>; +type ReasoningParserCreator = Arc< + dyn Fn(DynTokenizer) -> vllm_parser::reasoning::Result> + Send + Sync, +>; /// Registry and model matcher for reasoning parsers. pub type ReasoningParserFactory = ParserFactory; @@ -54,14 +59,17 @@ impl ReasoningParserFactory { .register_parser::(names::DEEPSEEK_R1) .register_parser::(names::DEEPSEEK_V3) .register_parser::(names::DEEPSEEK_V4) - .register_parser::(names::GEMMA4) + .register_unified_dummy(names::GEMMA4) .register_parser::(names::GLM45) .register_parser::(names::KIMI) .register_parser::(names::KIMI_K2) .register_parser::(names::MINIMAX_M2) + .register_parser::(names::MINIMAX_M3) .register_parser::(names::NEMOTRON_V3) .register_parser::(names::QWEN3) - .register_parser::(names::STEP3); + .register_parser::(names::SEED_OSS) + .register_parser::(names::STEP3) + .register_parser::(names::STEP3P5); factory .register_pattern("deepseek-r1", names::DEEPSEEK_R1) @@ -77,7 +85,16 @@ impl ReasoningParserFactory { .register_pattern("glm-4.5", names::GLM45) .register_pattern("kimi-k2", names::KIMI_K2) .register_pattern("kimi", names::KIMI) + // step3p5 patterns must precede `step3`: substring matching would + // otherwise route step3p5 IDs to step3. + .register_pattern("step-3p5", names::STEP3P5) + .register_pattern("step3p5", names::STEP3P5) + .register_pattern("step-3.5", names::STEP3P5) .register_pattern("step3", names::STEP3) + .register_pattern("seed-oss", names::SEED_OSS) + .register_pattern("seedoss", names::SEED_OSS) + .register_pattern("minimax-m3", names::MINIMAX_M3) + .register_pattern("mm-m3", names::MINIMAX_M3) .register_pattern("minimax", names::MINIMAX_M2) .register_pattern("mm-m2", names::MINIMAX_M2) .register_pattern("cohere", names::COHERE_CMD) @@ -93,7 +110,17 @@ impl ReasoningParserFactory { where T: ReasoningParser + 'static, { - self.register_creator(name, T::create) + self.register_creator(name, Arc::new(T::create)) + } + + /// Register one unified-only parser name in the split reasoning registry. + pub fn register_unified_dummy(&mut self, name: &str) -> &mut Self { + let name = name.to_string(); + let registered_name = name.clone(); + self.register_creator( + ®istered_name, + Arc::new(move |_| Err(ReasoningError::DummyUnifiedParser { name: name.clone() })), + ) } /// Construct a parser from an exact name. @@ -108,7 +135,7 @@ impl ReasoningParserFactory { available_names: self.list(), })?; - creator(tokenizer).map_err(|error| crate::Error::ParserInitialization { + creator.as_ref()(tokenizer).map_err(|error| crate::Error::ParserInitialization { kind: "reasoning", name: name.to_string(), error: error.into(), diff --git a/rust/src/chat/src/parser/reasoning/tests.rs b/rust/src/chat/src/parser/reasoning/tests.rs index 89b5f8e2308a..b6ae5ba9c38b 100644 --- a/rust/src/chat/src/parser/reasoning/tests.rs +++ b/rust/src/chat/src/parser/reasoning/tests.rs @@ -1,39 +1,24 @@ use std::sync::Arc; -use vllm_tokenizer::Tokenizer; +use vllm_tokenizer::test_utils::TestTokenizer; use super::{ReasoningParserFactory, names}; -struct FakeTokenizer; - -impl Tokenizer for FakeTokenizer { - fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result> { - Ok(text.chars().map(u32::from).collect()) - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - Ok(token_ids - .iter() - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}')) - .collect()) - } - - fn token_to_id(&self, _token: &str) -> Option { - None - } -} - #[test] fn factory_contains_and_lists_registered_parsers() { let factory = ReasoningParserFactory::new(); assert!(factory.contains(names::QWEN3)); assert!(factory.contains(names::DEEPSEEK_V4)); + assert!(factory.contains(names::SEED_OSS)); + assert!(factory.contains(names::STEP3P5)); + assert!(factory.contains(names::MINIMAX_M3)); + assert!(factory.contains(names::GEMMA4)); assert!(factory.list().contains(&names::QWEN3.to_string())); assert!(factory.list().contains(&names::DEEPSEEK_V4.to_string())); + assert!(factory.list().contains(&names::SEED_OSS.to_string())); + assert!(factory.list().contains(&names::STEP3P5.to_string())); + assert!(factory.list().contains(&names::MINIMAX_M3.to_string())); + assert!(factory.list().contains(&names::GEMMA4.to_string())); } #[test] @@ -49,9 +34,57 @@ fn factory_resolves_deepseek_v4_to_qwen3_alias() { ); } +#[test] +fn factory_routes_step3p5_models_to_dedicated_parser() { + let factory = ReasoningParserFactory::new(); + // step3p5 patterns must beat the bare `step3` substring. + assert_eq!( + factory.resolve_name_for_model("step-3p5-instruct"), + Some(names::STEP3P5) + ); + assert_eq!( + factory.resolve_name_for_model("step3p5"), + Some(names::STEP3P5) + ); + assert_eq!( + factory.resolve_name_for_model("step-3.5-base"), + Some(names::STEP3P5) + ); + assert_eq!( + factory.resolve_name_for_model("step3-base"), + Some(names::STEP3) + ); +} + +#[test] +fn factory_routes_seed_oss_models() { + let factory = ReasoningParserFactory::new(); + assert_eq!( + factory.resolve_name_for_model("ByteDance-Seed/Seed-OSS-36B-Instruct"), + Some(names::SEED_OSS) + ); + assert_eq!( + factory.resolve_name_for_model("seedoss-7b"), + Some(names::SEED_OSS) + ); +} + +#[test] +fn factory_resolves_minimax_m3_before_generic_minimax() { + let factory = ReasoningParserFactory::new(); + assert_eq!( + factory.resolve_name_for_model("MiniMaxAI/Minimax-M3-preview"), + Some(names::MINIMAX_M3) + ); + assert_eq!( + factory.resolve_name_for_model("mm-m3"), + Some(names::MINIMAX_M3) + ); +} + #[test] fn factory_rejects_unknown_parser_names() { - let tokenizer = Arc::new(FakeTokenizer); + let tokenizer = Arc::new(TestTokenizer::new()); let factory = ReasoningParserFactory::new(); let error = match factory.create("missing", tokenizer) { Ok(_) => panic!("expected parser lookup to fail"), diff --git a/rust/src/chat/src/parser/tool/mod.rs b/rust/src/chat/src/parser/tool/mod.rs index ad220b5a7876..a156d670248e 100644 --- a/rust/src/chat/src/parser/tool/mod.rs +++ b/rust/src/chat/src/parser/tool/mod.rs @@ -1,13 +1,13 @@ //! Tool parser registration and selection boundary for `vllm-chat`. -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock}; -pub use vllm_tool_parser::{ +pub use vllm_parser::tool::{ DeepSeekV3ToolParser, DeepSeekV4ToolParser, DeepSeekV31ToolParser, DeepSeekV32ToolParser, - Gemma4ToolParser, Glm45MoeToolParser, Glm47MoeToolParser, HermesToolParser, HyV3ToolParser, + Glm45MoeToolParser, Glm47MoeToolParser, Granite4ToolParser, HermesToolParser, HyV3ToolParser, Internlm2ToolParser, KimiK2ToolParser, Llama3JsonToolParser, MinimaxM2ToolParser, - MistralToolParser, Qwen3CoderToolParser, Qwen3XmlToolParser, ToolCallDelta, ToolParser, - ToolParserError, ToolParserOutput, + MinimaxM3ToolParser, MistralToolParser, Phi4MiniJsonToolParser, Qwen3CoderToolParser, + Qwen3XmlToolParser, ToolParser, ToolParserError, }; use crate::parser::ParserFactory; @@ -22,6 +22,7 @@ pub mod names { pub const GLM45: &str = "glm45"; pub const GLM47: &str = "glm47"; pub const GEMMA4: &str = "gemma4"; + pub const GRANITE4: &str = "granite4"; pub const HERMES: &str = "hermes"; pub const HY_V3: &str = "hy_v3"; // Matches the Python CLI name `--tool-call-parser internlm`, which Python @@ -31,13 +32,16 @@ pub mod names { pub const LLAMA3_JSON: &str = "llama3_json"; pub const LLAMA4_JSON: &str = "llama4_json"; pub const MINIMAX_M2: &str = "minimax_m2"; + pub const MINIMAX_M3: &str = "minimax_m3"; pub const MISTRAL: &str = "mistral"; + pub const PHI4_MINI_JSON: &str = "phi4_mini_json"; pub const QWEN3_CODER: &str = "qwen3_coder"; pub const QWEN3_XML: &str = "qwen3_xml"; } /// Constructor signature for one registered tool parser implementation. -type ToolParserCreator = fn(&[ChatTool]) -> vllm_tool_parser::Result>; +type ToolParserCreator = + Arc vllm_parser::tool::Result> + Send + Sync>; /// Registry and model matcher for tool parsers. pub type ToolParserFactory = ParserFactory; @@ -62,7 +66,8 @@ impl ToolParserFactory { .register_parser::(names::DEEPSEEK_V4) .register_parser::(names::GLM45) .register_parser::(names::GLM47) - .register_parser::(names::GEMMA4) + .register_unified_dummy(names::GEMMA4) + .register_parser::(names::GRANITE4) .register_parser::(names::HERMES) .register_parser::(names::HY_V3) .register_parser::(names::INTERNLM) @@ -70,7 +75,9 @@ impl ToolParserFactory { .register_parser::(names::LLAMA3_JSON) .register_parser::(names::LLAMA4_JSON) .register_parser::(names::MINIMAX_M2) + .register_parser::(names::MINIMAX_M3) .register_parser::(names::MISTRAL) + .register_parser::(names::PHI4_MINI_JSON) .register_parser::(names::QWEN3_XML) .register_parser::(names::QWEN3_CODER); @@ -105,7 +112,10 @@ impl ToolParserFactory { .register_pattern("glm-4.5", names::GLM45) .register_pattern("gemma4", names::GEMMA4) .register_pattern("gemma-4", names::GEMMA4) + .register_pattern("granite-4", names::GRANITE4) .register_pattern("kimi-k2", names::KIMI_K2) + .register_pattern("minimax-m3", names::MINIMAX_M3) + .register_pattern("mm-m3", names::MINIMAX_M3) .register_pattern("minimax", names::MINIMAX_M2) .register_pattern("mm-m2", names::MINIMAX_M2); @@ -117,7 +127,17 @@ impl ToolParserFactory { where T: ToolParser + 'static, { - self.register_creator(name, T::create) + self.register_creator(name, Arc::new(T::create)) + } + + /// Register one unified-only parser name in the split tool registry. + pub fn register_unified_dummy(&mut self, name: &str) -> &mut Self { + let name = name.to_string(); + let registered_name = name.clone(); + self.register_creator( + ®istered_name, + Arc::new(move |_| Err(ToolParserError::DummyUnifiedParser { name: name.clone() })), + ) } /// Construct a parser from an exact name. @@ -128,7 +148,7 @@ impl ToolParserFactory { available_names: self.list(), })?; - creator(tools).map_err(|error| crate::Error::ParserInitialization { + creator.as_ref()(tools).map_err(|error| crate::Error::ParserInitialization { kind: "tool", name: name.to_string(), error: error.into(), diff --git a/rust/src/chat/src/parser/tool/tests.rs b/rust/src/chat/src/parser/tool/tests.rs index 65e9f4e075b4..a630f9a951af 100644 --- a/rust/src/chat/src/parser/tool/tests.rs +++ b/rust/src/chat/src/parser/tool/tests.rs @@ -1,6 +1,6 @@ -use vllm_tool_parser::Result; +use vllm_parser::tool::{Result, ToolParserOutput}; -use super::{ToolParser, ToolParserFactory, ToolParserOutput, names}; +use super::{ToolParser, ToolParserFactory, names}; use crate::Error; use crate::request::ChatTool; @@ -145,6 +145,10 @@ fn factory_new_resolves_default_patterns() { factory.resolve_name_for_model("google/gemma-4-27b-it"), Some(names::GEMMA4) ); + assert_eq!( + factory.resolve_name_for_model("ibm-granite/granite-4.0-h-tiny"), + Some(names::GRANITE4) + ); assert_eq!( factory.resolve_name_for_model("NousResearch/Hermes-3-Llama-3.1-8B"), Some(names::HERMES) @@ -153,6 +157,14 @@ fn factory_new_resolves_default_patterns() { factory.resolve_name_for_model("tencent/Hy3-preview"), Some(names::HY_V3) ); + assert_eq!( + factory.resolve_name_for_model("MiniMax/MiniMax-M3-Text"), + Some(names::MINIMAX_M3) + ); + assert_eq!( + factory.resolve_name_for_model("org/mm-m3-base"), + Some(names::MINIMAX_M3) + ); assert_eq!( factory.resolve_name_for_model("MiniMax/MiniMax-M2-01"), Some(names::MINIMAX_M2) @@ -191,3 +203,14 @@ fn factory_new_resolves_default_patterns() { None ); } + +#[test] +fn factory_new_registers_phi4_mini_json_by_name() { + // phi-4-mini is registered by explicit name only (matching Python's + // `--tool-call-parser phi4_mini_json`); it is intentionally not mapped to + // any model-name pattern. + let factory = ToolParserFactory::new(); + + assert!(factory.contains(names::PHI4_MINI_JSON)); + factory.create(names::PHI4_MINI_JSON, &[]).unwrap(); +} diff --git a/rust/src/chat/src/parser/unified.rs b/rust/src/chat/src/parser/unified.rs new file mode 100644 index 000000000000..50f2104d6194 --- /dev/null +++ b/rust/src/chat/src/parser/unified.rs @@ -0,0 +1,99 @@ +//! Unified parser registration and selection boundary for `vllm-chat`. + +use std::sync::LazyLock; + +pub use vllm_parser::unified::{Gemma4UnifiedParser, UnifiedParser}; +use vllm_tokenizer::DynTokenizer; + +use crate::parser::ParserFactory; +use crate::request::ChatTool; + +/// Canonical public names for registered unified parsers. +pub mod names { + pub const GEMMA4: &str = "gemma4"; +} + +/// Constructor signature for one registered unified parser implementation. +type UnifiedParserCreator = + fn(&[ChatTool], DynTokenizer) -> vllm_parser::unified::Result>; + +/// Registry and model matcher for unified parsers. +pub type UnifiedParserFactory = ParserFactory; + +impl UnifiedParserFactory { + /// Get the global unified parser factory with built-in registrations and + /// model mappings. + pub fn global() -> &'static Self { + static INSTANCE: LazyLock = LazyLock::new(UnifiedParserFactory::new); + &INSTANCE + } + + /// Create the default registry with built-in parser names and model + /// mappings. + pub fn new() -> Self { + let mut factory = Self::default(); + + factory.register_parser::(names::GEMMA4); + + factory + .register_pattern("gemma-4", names::GEMMA4) + .register_pattern("gemma4", names::GEMMA4); + + factory + } + + /// Register one parser type that exposes a static `create()` constructor. + pub fn register_parser(&mut self, name: &str) -> &mut Self + where + T: UnifiedParser + 'static, + { + self.register_creator(name, T::create) + } + + /// Construct a parser from an exact name. + pub fn create( + &self, + name: &str, + tools: &[ChatTool], + tokenizer: DynTokenizer, + ) -> crate::Result> { + let creator = self.creator(name).ok_or_else(|| crate::Error::ParserUnavailableByName { + kind: "unified", + name: name.to_string(), + available_names: self.list(), + })?; + + creator(tools, tokenizer).map_err(|error| crate::Error::ParserInitialization { + kind: "unified", + name: name.to_string(), + error: error.into(), + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vllm_tokenizer::test_utils::TestTokenizer; + + use super::{UnifiedParserFactory, names}; + + fn tokenizer() -> TestTokenizer { + TestTokenizer::new() + .with_regular_token("<|channel>", 256) + .with_regular_token("", 257) + } + + #[test] + fn factory_registers_gemma4() { + let factory = UnifiedParserFactory::new(); + + assert!(factory.contains(names::GEMMA4)); + assert_eq!( + factory.resolve_name_for_model("google/gemma-4-27b-it"), + Some(names::GEMMA4) + ); + factory.create(names::GEMMA4, &[], Arc::new(tokenizer())).unwrap(); + } +} diff --git a/rust/src/chat/src/renderer/deepseek_v32/encoding.rs b/rust/src/chat/src/renderer/deepseek_v32/encoding.rs index 978255192768..2af7e4be7bcd 100644 --- a/rust/src/chat/src/renderer/deepseek_v32/encoding.rs +++ b/rust/src/chat/src/renderer/deepseek_v32/encoding.rs @@ -49,6 +49,7 @@ pub(super) fn render_request(request: &ChatRequest) -> Result { let last_user_render_index = find_last_user_render_index(request.messages.as_slice(), render_offset); let last_user_actual_index = find_last_user_actual_index(request.messages.as_slice()); + let continue_final_message = request.chat_options.continue_final_message(); let mut prompt = String::from(BOS_TOKEN); if request.tool_parsing_enabled() { @@ -66,6 +67,7 @@ pub(super) fn render_request(request: &ChatRequest) -> Result { last_user_actual_index, thinking_mode, drop_thinking, + continue_final_message, )?; } @@ -96,6 +98,7 @@ fn render_message( last_user_actual_index: usize, thinking_mode: ThinkingMode, drop_thinking: bool, + continue_final_message: bool, ) -> Result<()> { let render_index = message_index as isize + render_offset; let opens_thinking = render_index == last_user_render_index; @@ -125,9 +128,7 @@ fn render_message( thinking_mode, drop_thinking, ), - // TODO: Respect `continue_final_message` and map it to DeepSeek's - // prefix-style final-assistant continuation behavior. - false, + continue_final_message && message_index + 1 == messages.len(), ), ChatMessage::ToolResponse { content, .. } => render_tool_message( out, diff --git a/rust/src/chat/src/renderer/deepseek_v32/mod.rs b/rust/src/chat/src/renderer/deepseek_v32/mod.rs index 97225bbab09b..9da2423389e2 100644 --- a/rust/src/chat/src/renderer/deepseek_v32/mod.rs +++ b/rust/src/chat/src/renderer/deepseek_v32/mod.rs @@ -2,7 +2,7 @@ mod encoding; use vllm_text::Prompt; -use super::{ChatRenderer, RenderedPrompt}; +use super::{ChatRenderer, RenderedPrompt, request_template_kwargs}; use crate::Result; use crate::request::ChatRequest; @@ -23,6 +23,7 @@ impl ChatRenderer for DeepSeekV32ChatRenderer { Ok(RenderedPrompt { prompt: Prompt::Text(encoding::render_request(request)?), + effective_template_kwargs: request_template_kwargs(request), }) } } diff --git a/rust/src/chat/src/renderer/deepseek_v32/tests.rs b/rust/src/chat/src/renderer/deepseek_v32/tests.rs index 0b8f2b09e11c..38eddef92a2b 100644 --- a/rust/src/chat/src/renderer/deepseek_v32/tests.rs +++ b/rust/src/chat/src/renderer/deepseek_v32/tests.rs @@ -1,82 +1,18 @@ -use std::fs; use std::path::PathBuf; use expect_test::{ExpectFile, expect, expect_file}; -use serde::Deserialize; use serde_json::{Value, json}; use thiserror_ext::AsReport; use super::DeepSeekV32ChatRenderer; use crate::error::Error; use crate::event::{AssistantContentBlock, AssistantToolCall}; +use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request}; use crate::request::{ ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice, GenerationPromptMode, }; use crate::{ChatRenderer, ChatRole}; -#[derive(Debug, Deserialize)] -struct FixtureRequest { - #[serde(default)] - tools: Vec, - messages: Vec, -} - -#[derive(Debug, Deserialize)] -struct FixtureTool { - function: FixtureToolFunction, -} - -#[derive(Debug, Deserialize)] -struct FixtureToolFunction { - name: String, - description: Option, - parameters: Value, - #[serde(default)] - strict: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "role", rename_all = "snake_case")] -enum FixtureMessage { - System { - content: String, - }, - Developer { - content: String, - #[serde(default)] - tools: Vec, - }, - User { - content: String, - }, - Assistant { - #[serde(default)] - content: String, - #[serde(default)] - reasoning_content: String, - #[serde(default)] - tool_calls: Vec, - }, - Tool { - content: String, - #[serde(default)] - tool_call_id: Option, - }, -} - -#[derive(Debug, Deserialize)] -struct FixtureToolCall { - #[serde(default)] - id: Option, - function: FixtureToolCallFunction, -} - -#[derive(Debug, Deserialize)] -struct FixtureToolCallFunction { - name: String, - arguments: String, -} - fn render_request(request: &ChatRequest) -> String { DeepSeekV32ChatRenderer::new() .render(request) @@ -115,88 +51,14 @@ fn thinking_request(messages: Vec) -> ChatRequest { } fn fixture_request(input_name: &str) -> ChatRequest { - let fixture = fs::read_to_string(fixture_path(input_name)).unwrap(); - let fixture: FixtureRequest = serde_json::from_str(&fixture).unwrap(); - let mut request = ChatRequest { - request_id: "deepseek-v32-fixture".to_string(), - messages: fixture - .messages - .into_iter() - .enumerate() - .map(|(index, message)| match message { - FixtureMessage::System { content } => ChatMessage::system(content), - FixtureMessage::Developer { content, tools } => ChatMessage::developer( - content, - (!tools.is_empty()).then(|| to_chat_tools(&tools)), - ), - FixtureMessage::User { content } => ChatMessage::user(content), - FixtureMessage::Assistant { - content, - reasoning_content, - tool_calls, - } => { - let mut blocks = Vec::new(); - if !reasoning_content.is_empty() { - blocks.push(AssistantContentBlock::Reasoning { - text: reasoning_content, - }); - } - if !content.is_empty() { - blocks.push(AssistantContentBlock::Text { text: content }); - } - blocks.extend(tool_calls.into_iter().enumerate().map( - |(tool_index, tool_call)| { - AssistantContentBlock::ToolCall(AssistantToolCall { - id: tool_call.id.unwrap_or_else(|| { - format!("fixture-tool-call-{index}-{tool_index}") - }), - name: tool_call.function.name, - arguments: tool_call.function.arguments, - }) - }, - )); - ChatMessage::assistant_blocks(blocks) - } - FixtureMessage::Tool { - content, - tool_call_id, - } => ChatMessage::tool_response( - content, - tool_call_id.unwrap_or_else(|| format!("fixture-tool-response-{index}")), - ), - }) - .collect(), - tools: to_chat_tools(&fixture.tools), - tool_choice: if fixture.tools.is_empty() { - ChatToolChoice::None - } else { - ChatToolChoice::Auto - }, - ..ChatRequest::for_test() - }; - if matches!( - request.messages.last().map(ChatMessage::role), - Some(ChatRole::Assistant) - ) { - request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt; - } - request - .chat_options - .template_kwargs - .insert("thinking".to_string(), Value::Bool(true)); - request + fixture_chat_request(&fixture_path(input_name), deepseek_fixture_options()) } -fn to_chat_tools(tools: &[FixtureTool]) -> Vec { - tools - .iter() - .map(|tool| ChatTool { - name: tool.function.name.clone(), - description: tool.function.description.clone(), - parameters: tool.function.parameters.clone(), - strict: tool.function.strict, - }) - .collect() +fn deepseek_fixture_options() -> FixtureRequestOptions { + FixtureRequestOptions { + enable_thinking: true, + no_generation_prompt_when_last_assistant: true, + } } fn fixture_path(name: &str) -> PathBuf { @@ -404,6 +266,24 @@ fn assistant_after_last_user_requires_reasoning_or_tool_calls() { expect!["chat template error: invalid DeepSeek V3.2 assistant message after last user message: expected reasoning or tool calls"] .assert_eq(&error.to_report_string()); } + +#[test] +fn continue_final_assistant_omits_final_eos() { + let mut request = ChatRequest { + messages: vec![ + ChatMessage::user("write"), + ChatMessage::assistant_text("partial answer"), + ], + ..ChatRequest::for_test() + }; + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; + + let rendered = render_request(&request); + + expect!["<|begin▁of▁sentence|><|User|>write<|Assistant|>partial answer"] + .assert_eq(&rendered); +} + #[test] fn render_rejects_multimodal_input() { let request = ChatRequest { diff --git a/rust/src/chat/src/renderer/deepseek_v4/mod.rs b/rust/src/chat/src/renderer/deepseek_v4/mod.rs index 7c3f4631d20e..78047c9dbec0 100644 --- a/rust/src/chat/src/renderer/deepseek_v4/mod.rs +++ b/rust/src/chat/src/renderer/deepseek_v4/mod.rs @@ -2,7 +2,7 @@ mod encoding; use vllm_text::Prompt; -use super::{ChatRenderer, RenderedPrompt}; +use super::{ChatRenderer, RenderedPrompt, request_template_kwargs}; use crate::Result; use crate::request::ChatRequest; @@ -22,6 +22,7 @@ impl ChatRenderer for DeepSeekV4ChatRenderer { Ok(RenderedPrompt { prompt: Prompt::Text(encoding::render_request(request)?), + effective_template_kwargs: request_template_kwargs(request), }) } } diff --git a/rust/src/chat/src/renderer/deepseek_v4/tests.rs b/rust/src/chat/src/renderer/deepseek_v4/tests.rs index 78936d8e68e4..058802b8e3bf 100644 --- a/rust/src/chat/src/renderer/deepseek_v4/tests.rs +++ b/rust/src/chat/src/renderer/deepseek_v4/tests.rs @@ -1,95 +1,13 @@ -use std::fs; use std::path::PathBuf; use expect_test::{ExpectFile, expect, expect_file}; -use serde::Deserialize; use serde_json::Value; use super::DeepSeekV4ChatRenderer; +use crate::ChatRenderer; use crate::event::{AssistantContentBlock, AssistantToolCall}; -use crate::request::{ - ChatMessage, ChatRequest, ChatTool, ChatToolChoice, GenerationPromptMode, ReasoningEffort, -}; -use crate::{ChatRenderer, ChatRole}; - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum FixtureFile { - WithTools(FixtureRequest), - MessagesOnly(Vec), -} - -#[derive(Debug, Deserialize)] -struct FixtureRequest { - #[serde(default)] - tools: Vec, - messages: Vec, -} - -impl FixtureFile { - fn into_parts(self) -> (Vec, Vec) { - match self { - Self::WithTools(req) => (req.tools, req.messages), - Self::MessagesOnly(messages) => (Vec::new(), messages), - } - } -} - -#[derive(Debug, Deserialize)] -struct FixtureTool { - function: FixtureToolFunction, -} - -#[derive(Debug, Deserialize)] -struct FixtureToolFunction { - name: String, - description: Option, - parameters: Value, - #[serde(default)] - strict: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "role", rename_all = "snake_case")] -enum FixtureMessage { - System { - content: String, - }, - Developer { - content: String, - #[serde(default)] - tools: Vec, - }, - User { - content: String, - }, - Assistant { - #[serde(default)] - content: String, - #[serde(default)] - reasoning_content: String, - #[serde(default)] - tool_calls: Vec, - }, - Tool { - content: String, - #[serde(default)] - tool_call_id: Option, - }, -} - -#[derive(Debug, Deserialize)] -struct FixtureToolCall { - #[serde(default)] - id: Option, - function: FixtureToolCallFunction, -} - -#[derive(Debug, Deserialize)] -struct FixtureToolCallFunction { - name: String, - arguments: String, -} +use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request}; +use crate::request::{ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort}; fn render_request(request: &ChatRequest) -> String { DeepSeekV4ChatRenderer::new() @@ -101,88 +19,14 @@ fn render_request(request: &ChatRequest) -> String { } fn fixture_request(input_name: &str) -> ChatRequest { - let fixture = fs::read_to_string(fixture_path(input_name)).unwrap(); - let fixture: FixtureFile = serde_json::from_str(&fixture).unwrap(); - let (fixture_tools, fixture_messages) = fixture.into_parts(); - let mut request = ChatRequest { - request_id: "deepseek-v4-fixture".to_string(), - messages: fixture_messages - .into_iter() - .enumerate() - .map(|(index, message)| match message { - FixtureMessage::System { content } => ChatMessage::system(content), - FixtureMessage::Developer { content, tools } => ChatMessage::developer( - content, - (!tools.is_empty()).then(|| to_chat_tools(&tools)), - ), - FixtureMessage::User { content } => ChatMessage::user(content), - FixtureMessage::Assistant { - content, - reasoning_content, - tool_calls, - } => { - let mut blocks = Vec::new(); - if !reasoning_content.is_empty() { - blocks.push(AssistantContentBlock::Reasoning { - text: reasoning_content, - }); - } - if !content.is_empty() { - blocks.push(AssistantContentBlock::Text { text: content }); - } - blocks.extend(tool_calls.into_iter().enumerate().map( - |(tool_index, tool_call)| { - AssistantContentBlock::ToolCall(AssistantToolCall { - id: tool_call.id.unwrap_or_else(|| { - format!("fixture-tool-call-{index}-{tool_index}") - }), - name: tool_call.function.name, - arguments: tool_call.function.arguments, - }) - }, - )); - ChatMessage::assistant_blocks(blocks) - } - FixtureMessage::Tool { - content, - tool_call_id, - } => ChatMessage::tool_response( - content, - tool_call_id.unwrap_or_else(|| format!("fixture-tool-response-{index}")), - ), - }) - .collect(), - tools: to_chat_tools(&fixture_tools), - tool_choice: if fixture_tools.is_empty() { - ChatToolChoice::None - } else { - ChatToolChoice::Auto - }, - ..ChatRequest::for_test() - }; - if matches!( - request.messages.last().map(ChatMessage::role), - Some(ChatRole::Assistant) - ) { - request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt; - } - request - .chat_options - .template_kwargs - .insert("thinking".to_string(), Value::Bool(true)); - request + fixture_chat_request(&fixture_path(input_name), deepseek_fixture_options()) } -fn to_chat_tools(tools: &[FixtureTool]) -> Vec { - tools - .iter() - .map(|tool| ChatTool { - name: tool.function.name.clone(), - description: tool.function.description.clone(), - parameters: tool.function.parameters.clone(), - strict: tool.function.strict, - }) - .collect() +fn deepseek_fixture_options() -> FixtureRequestOptions { + FixtureRequestOptions { + enable_thinking: true, + no_generation_prompt_when_last_assistant: true, + } } fn fixture_path(name: &str) -> PathBuf { diff --git a/rust/src/chat/src/renderer/harmony/encoding.rs b/rust/src/chat/src/renderer/harmony/encoding.rs new file mode 100644 index 000000000000..3b8030292d60 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/encoding.rs @@ -0,0 +1,21 @@ +//! Shared Harmony encoding helper for the GPT-OSS renderer and output parser. + +use std::sync::LazyLock; + +use anyhow::Context as _; +use openai_harmony::{HarmonyEncoding, HarmonyEncodingName, load_harmony_encoding}; +use thiserror_ext::AsReport as _; + +use crate::error::{Error, Result}; + +/// Lazily load the shared GPT-OSS Harmony encoding once per process. +pub(crate) fn harmony_encoding() -> Result<&'static HarmonyEncoding> { + static ENCODING: LazyLock> = LazyLock::new(|| { + load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss) + .context("failed to load harmony encoding for gpt-oss") + }); + + ENCODING.as_ref().map_err(|error| Error::HarmonyOutputParsing { + error: error.to_report_string().into(), + }) +} diff --git a/rust/src/chat/src/renderer/harmony/fixtures/assistant_history.json b/rust/src/chat/src/renderer/harmony/fixtures/assistant_history.json new file mode 100644 index 000000000000..50edd03ee428 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/assistant_history.json @@ -0,0 +1,14 @@ +{ + "add_generation_prompt": false, + "messages": [ + { + "role": "user", + "content": "What is 2 + 2?" + }, + { + "role": "assistant", + "reasoning_content": "Need simple arithmetic.", + "content": "4" + } + ] +} diff --git a/rust/src/chat/src/renderer/harmony/fixtures/assistant_history.txt b/rust/src/chat/src/renderer/harmony/fixtures/assistant_history.txt new file mode 100644 index 000000000000..dc08897e2282 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/assistant_history.txt @@ -0,0 +1,7 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: medium + +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant<|channel|>final<|message|>4<|end|> diff --git a/rust/src/chat/src/renderer/harmony/fixtures/developer_tools.json b/rust/src/chat/src/renderer/harmony/fixtures/developer_tools.json new file mode 100644 index 000000000000..4516e3b32ba5 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/developer_tools.json @@ -0,0 +1,27 @@ +[ + { + "role": "developer", + "content": "Use tools when needed.", + "tools": [ + { + "function": { + "name": "lookup", + "description": "Lookup a record.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"] + } + } + } + ] + }, + { + "role": "user", + "content": "Find record abc." + } +] diff --git a/rust/src/chat/src/renderer/harmony/fixtures/developer_tools.txt b/rust/src/chat/src/renderer/harmony/fixtures/developer_tools.txt new file mode 100644 index 000000000000..a63b447f29c3 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/developer_tools.txt @@ -0,0 +1,23 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: medium + +# Valid channels: analysis, commentary, final. Channel must be included for every message. +Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>developer<|message|># Instructions + +Use tools when needed. + +# Tools + +## functions + +namespace functions { + +// Lookup a record. +type lookup = (_: { +id: string, +}) => any; + +} // namespace functions<|end|><|start|>user<|message|>Find record abc.<|end|><|start|>assistant diff --git a/rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.json b/rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.json new file mode 100644 index 000000000000..75fd5d8b29e3 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.json @@ -0,0 +1,15 @@ +[ + { + "role": "user", + "content": "What is 2 + 2?" + }, + { + "role": "assistant", + "reasoning_content": "This should be dropped.", + "content": "4" + }, + { + "role": "user", + "content": "What is 3 + 5?" + } +] diff --git a/rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.txt b/rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.txt new file mode 100644 index 000000000000..9e967b795640 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.txt @@ -0,0 +1,7 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: medium + +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant<|channel|>final<|message|>4<|end|><|start|>user<|message|>What is 3 + 5?<|end|><|start|>assistant diff --git a/rust/src/chat/src/renderer/harmony/fixtures/leading_system.json b/rust/src/chat/src/renderer/harmony/fixtures/leading_system.json new file mode 100644 index 000000000000..5ff190d0b853 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/leading_system.json @@ -0,0 +1,13 @@ +{ + "reasoning_effort": "high", + "messages": [ + { + "role": "system", + "content": "Answer tersely." + }, + { + "role": "user", + "content": "What is 2 + 2?" + } + ] +} diff --git a/rust/src/chat/src/renderer/harmony/fixtures/leading_system.txt b/rust/src/chat/src/renderer/harmony/fixtures/leading_system.txt new file mode 100644 index 000000000000..e656a0a0a47e --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/leading_system.txt @@ -0,0 +1,9 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: high + +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>developer<|message|># Instructions + +Answer tersely.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant diff --git a/rust/src/chat/src/renderer/harmony/fixtures/request_tools.json b/rust/src/chat/src/renderer/harmony/fixtures/request_tools.json new file mode 100644 index 000000000000..db5988182fe1 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/request_tools.json @@ -0,0 +1,26 @@ +{ + "tools": [ + { + "function": { + "name": "get_weather", + "description": "Get weather for a city.", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"] + }, + "strict": true + } + } + ], + "messages": [ + { + "role": "user", + "content": "Check Hangzhou weather." + } + ] +} diff --git a/rust/src/chat/src/renderer/harmony/fixtures/request_tools.txt b/rust/src/chat/src/renderer/harmony/fixtures/request_tools.txt new file mode 100644 index 000000000000..f31bc449bfeb --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/request_tools.txt @@ -0,0 +1,19 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: medium + +# Valid channels: analysis, commentary, final. Channel must be included for every message. +Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>developer<|message|># Tools + +## functions + +namespace functions { + +// Get weather for a city. +type get_weather = (_: { +city: string, +}) => any; + +} // namespace functions<|end|><|start|>user<|message|>Check Hangzhou weather.<|end|><|start|>assistant diff --git a/rust/src/chat/src/renderer/harmony/fixtures/simple_user.json b/rust/src/chat/src/renderer/harmony/fixtures/simple_user.json new file mode 100644 index 000000000000..b8b7f597d6f0 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/simple_user.json @@ -0,0 +1,6 @@ +[ + { + "role": "user", + "content": "Hello, who are you?" + } +] diff --git a/rust/src/chat/src/renderer/harmony/fixtures/simple_user.txt b/rust/src/chat/src/renderer/harmony/fixtures/simple_user.txt new file mode 100644 index 000000000000..7e44ca314ce6 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/simple_user.txt @@ -0,0 +1,7 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: medium + +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>Hello, who are you?<|end|><|start|>assistant diff --git a/rust/src/chat/src/renderer/harmony/fixtures/system_instructions_env.txt b/rust/src/chat/src/renderer/harmony/fixtures/system_instructions_env.txt new file mode 100644 index 000000000000..8ad0ac7d0ee3 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/system_instructions_env.txt @@ -0,0 +1,8 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Answer tersely. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: high + +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant diff --git a/rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.json b/rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.json new file mode 100644 index 000000000000..00ccae641e0f --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.json @@ -0,0 +1,43 @@ +{ + "tools": [ + { + "function": { + "name": "get_weather", + "description": "Get weather for a city.", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + }, + "required": ["city"] + } + } + } + ], + "messages": [ + { + "role": "user", + "content": "Check Hangzhou weather." + }, + { + "role": "assistant", + "reasoning_content": "Need current weather.", + "tool_calls": [ + { + "id": "call-weather", + "function": { + "name": "get_weather", + "arguments": "{\"city\":\"Hangzhou\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call-weather", + "content": "{\"temperature\":20}" + } + ] +} diff --git a/rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.txt b/rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.txt new file mode 100644 index 000000000000..0e06a4d107ec --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.txt @@ -0,0 +1,19 @@ +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. +Knowledge cutoff: 2024-06 +Current date: 2025-06-28 + +Reasoning: medium + +# Valid channels: analysis, commentary, final. Channel must be included for every message. +Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>developer<|message|># Tools + +## functions + +namespace functions { + +// Get weather for a city. +type get_weather = (_: { +city: string, +}) => any; + +} // namespace functions<|end|><|start|>user<|message|>Check Hangzhou weather.<|end|><|start|>assistant<|channel|>analysis<|message|>Need current weather.<|end|><|start|>assistant<|channel|>commentary to=functions.get_weather <|constrain|>json<|message|>{"city":"Hangzhou"}<|call|><|start|>functions.get_weather<|channel|>commentary to=assistant<|message|>{"temperature":20}<|end|><|start|>assistant diff --git a/rust/src/chat/src/renderer/harmony/mod.rs b/rust/src/chat/src/renderer/harmony/mod.rs new file mode 100644 index 000000000000..70a1bb063e37 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/mod.rs @@ -0,0 +1,487 @@ +//! Native Harmony chat renderer for `gpt_oss`. + +pub(crate) mod encoding; + +use openai_harmony::HarmonyEncoding; +use openai_harmony::chat::{ + Author, Conversation, DeveloperContent, Message, ReasoningEffort as HarmonyReasoningEffort, + Role, SystemContent, ToolDescription, +}; +use thiserror_ext::AsReport as _; +use time::macros::format_description; +use vllm_text::Prompt; + +use self::encoding::harmony_encoding; +use super::{ChatRenderer, RenderedPrompt, request_template_kwargs}; +use crate::error::{Error, Result}; +use crate::event::AssistantContentBlock; +use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatTool, GenerationPromptMode}; +use crate::{AssistantMessageExt as _, ReasoningEffort}; + +const SYSTEM_START_DATE_ENV: &str = "VLLM_SYSTEM_START_DATE"; +const HARMONY_SYSTEM_INSTRUCTIONS_ENV: &str = "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS"; + +/// GPT-OSS renderer backed by the official Harmony encoding. +pub struct HarmonyChatRenderer { + encoding: &'static HarmonyEncoding, + options: Options, +} + +struct Options { + system_start_date: String, + use_system_instructions: bool, +} + +impl HarmonyChatRenderer { + /// Create a Harmony renderer for production use. + /// + /// Environment-derived options are resolved once at construction time: + /// + /// - `VLLM_SYSTEM_START_DATE` pins the Harmony system start date. When it is + /// unset, the renderer uses the current local date with a UTC fallback. + /// - `VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS` moves leading instructions + /// into the system model identity when set to a non-zero integer. + pub fn new() -> Result { + Self::with_options( + env_system_start_date(), + env_use_harmony_system_instructions(), + ) + } + + /// Create a Harmony renderer with explicit preamble options. + /// + /// Tests use this constructor to avoid process-global environment mutation. + /// Production code should call [`Self::new`] so the renderer observes the + /// same environment contract as the Python Harmony path. + pub fn with_options( + system_start_date: impl Into, + use_system_instructions: bool, + ) -> Result { + Ok(Self { + encoding: harmony_encoding()?, + options: Options { + system_start_date: system_start_date.into(), + use_system_instructions, + }, + }) + } + + /// Render a chat request directly to Harmony token IDs. + /// + /// Harmony owns both prompt formatting and tokenization, so the Rust + /// frontend bypasses the generic HF tokenizer path for GPT-OSS input. + fn render_token_ids(&self, request: &ChatRequest) -> Result> { + if request.has_multimodal() { + return Err(Error::UnsupportedMultimodalContent("image_url")); + } + if matches!( + request.chat_options.generation_prompt_mode, + GenerationPromptMode::ContinueFinalAssistant + ) { + return Err(Error::ChatTemplate( + "Harmony renderer does not support continue_final_message".to_string(), + )); + } + + let messages = auto_drop_analysis_messages(to_harmony_messages(request, &self.options)?); + let conversation = Conversation::from_messages(messages); + // Pass `None` so oss-harmony does not apply its narrower built-in + // analysis-drop policy after the Rust-side Python-parity cleanup above. + let token_ids = match request.chat_options.generation_prompt_mode { + GenerationPromptMode::StartNewAssistant => self + .encoding + .render_conversation_for_completion(&conversation, Role::Assistant, None), + GenerationPromptMode::NoGenerationPrompt => { + self.encoding.render_conversation(&conversation, None) + } + GenerationPromptMode::ContinueFinalAssistant => unreachable!("checked above"), + } + .map_err(|error| { + Error::ChatTemplate(format!( + "failed to render Harmony prompt: {}", + error.as_report() + )) + })?; + + Ok(token_ids) + } +} + +impl ChatRenderer for HarmonyChatRenderer { + /// Render a chat request as [`Prompt::TokenIds`] with template kwargs echoed + /// for downstream accounting/debugging. + fn render(&self, request: &ChatRequest) -> Result { + Ok(RenderedPrompt { + prompt: Prompt::TokenIds(self.render_token_ids(request)?), + effective_template_kwargs: request_template_kwargs(request), + }) + } +} + +/// Convert a vLLM chat request into a full Harmony conversation. +/// +/// This adds the Harmony system/developer preamble, peels at most one leading +/// system/developer instruction message, and then lowers the remaining chat +/// history message-by-message. +fn to_harmony_messages(request: &ChatRequest, options: &Options) -> Result> { + let (instructions, leading_developer_tools, remaining_messages) = + peel_leading_instructions(&request.messages)?; + let tool_call_names = tool_call_names(&request.messages); + let mut messages = + build_harmony_preamble(request, instructions, leading_developer_tools, options)?; + + for message in remaining_messages { + messages.extend(to_harmony_message(message, &tool_call_names, options)?); + } + + Ok(messages) +} + +/// Extract the optional leading instruction message used by the Harmony preamble. +/// +/// Python only peels the first leading `system` or `developer` message. Later +/// system/developer messages stay in the conversation and are lowered normally. +#[allow(clippy::type_complexity)] +fn peel_leading_instructions( + messages: &[ChatMessage], +) -> Result<(Option, Option<&[ChatTool]>, &[ChatMessage])> { + let Some(first) = messages.first() else { + return Ok((None, None, messages)); + }; + + match first { + ChatMessage::System { content } => Ok((Some(flatten_text(content)?), None, &messages[1..])), + ChatMessage::Developer { content, tools } => Ok(( + Some(flatten_text(content)?), + tools.as_deref(), + &messages[1..], + )), + ChatMessage::User { .. } + | ChatMessage::Assistant { .. } + | ChatMessage::ToolResponse { .. } => Ok((None, None, messages)), + } +} + +/// Build the Harmony preamble for one request. +/// +/// The preamble always contains a system message with date and reasoning-effort +/// metadata. Leading instructions live either in the system model identity or in +/// a developer message depending on `use_system_instructions`; request-level and +/// leading developer tools are attached to the developer message. +fn build_harmony_preamble( + request: &ChatRequest, + instructions: Option, + leading_developer_tools: Option<&[ChatTool]>, + options: &Options, +) -> Result> { + let mut messages = vec![Message::from_role_and_content( + Role::System, + system_content( + instructions.as_deref().filter(|_| options.use_system_instructions), + request.chat_options.reasoning_effort, + &options.system_start_date, + )?, + )]; + + let mut developer = DeveloperContent::new(); + let mut has_developer_content = false; + + if !options.use_system_instructions + && let Some(instructions) = instructions.as_deref().filter(|text| !text.is_empty()) + { + developer = developer.with_instructions(instructions); + has_developer_content = true; + } + + let tool_descriptions = preamble_tool_descriptions(request, leading_developer_tools); + if !tool_descriptions.is_empty() { + developer = developer.with_function_tools(tool_descriptions); + has_developer_content = true; + } + + if has_developer_content { + messages.push(Message::from_role_and_content(Role::Developer, developer)); + } + + Ok(messages) +} + +/// Collect request-level and leading developer function tools for the preamble. +fn preamble_tool_descriptions( + request: &ChatRequest, + leading_developer_tools: Option<&[ChatTool]>, +) -> Vec { + let mut tools = Vec::new(); + if request.tool_parsing_enabled() { + tools.extend(to_tool_descriptions(&request.tools)); + } + if let Some(leading_developer_tools) = leading_developer_tools { + tools.extend(to_tool_descriptions(leading_developer_tools)); + } + tools +} + +/// Construct the Harmony system content for the request preamble. +/// +/// Harmony defaults the reasoning effort to `medium` when none is provided, so +/// this only sets an explicit effort after validating vLLM's request value. +fn system_content( + instructions: Option<&str>, + reasoning_effort: Option, + system_start_date: &str, +) -> Result { + let mut content = + SystemContent::new().with_conversation_start_date(system_start_date.to_string()); + + if let Some(reasoning_effort) = reasoning_effort { + content = content.with_reasoning_effort(to_harmony_reasoning_effort(reasoning_effort)?); + } + + if let Some(instructions) = instructions.filter(|text| !text.is_empty()) { + let model_identity = match content.model_identity.as_deref() { + Some(identity) if !identity.is_empty() => format!("{identity}\n{instructions}"), + _ => instructions.to_string(), + }; + content = content.with_model_identity(model_identity); + } + + Ok(content) +} + +/// Lower a single vLLM chat message into one or more Harmony messages. +/// +/// Assistant messages can split into separate analysis, final, commentary, and +/// tool-call messages. Tool responses require the earlier assistant tool-call ID +/// map so the Harmony tool author can include `functions.{name}`. +fn to_harmony_message( + message: &ChatMessage, + tool_call_names: &std::collections::HashMap, + options: &Options, +) -> Result> { + Ok(match message { + ChatMessage::System { content } => { + let instructions = flatten_text(content)?; + vec![system_or_developer_message( + "system", + instructions, + None, + options, + )?] + } + ChatMessage::Developer { content, tools } => { + let instructions = flatten_text(content)?; + vec![developer_message(Some(instructions), tools.as_deref())] + } + ChatMessage::User { content } => { + vec![Message::from_role_and_content( + Role::User, + flatten_text(content)?, + )] + } + ChatMessage::Assistant { content } => assistant_messages(content), + ChatMessage::ToolResponse { + content, + tool_call_id, + } => { + let name = tool_call_names.get(tool_call_id).ok_or_else(|| { + Error::ChatTemplate(format!( + "invalid Harmony tool message: unknown tool_call_id `{tool_call_id}`" + )) + })?; + vec![ + Message::from_author_and_content( + Author::new(Role::Tool, format!("functions.{name}")), + flatten_text(content)?, + ) + .with_channel("commentary") + .with_recipient("assistant"), + ] + } + }) +} + +/// Lower a non-leading system/developer message. +/// +/// Harmony treats most extra system/developer messages as developer +/// instructions. When system-instructions mode is enabled, system messages are +/// rendered as system model-identity additions to match Python. +fn system_or_developer_message( + role: &str, + instructions: String, + tools: Option<&[ChatTool]>, + options: &Options, +) -> Result { + if role == "system" && options.use_system_instructions { + return Ok(Message::from_role_and_content( + Role::System, + system_content(Some(&instructions), None, &options.system_start_date)?, + )); + } + + Ok(developer_message(Some(instructions), tools)) +} + +/// Build a Harmony developer message with optional instructions and function tools. +fn developer_message(instructions: Option, tools: Option<&[ChatTool]>) -> Message { + let mut content = DeveloperContent::new(); + if let Some(instructions) = instructions.filter(|text| !text.is_empty()) { + content = content.with_instructions(instructions); + } + if let Some(tools) = tools { + let tools = to_tool_descriptions(tools); + if !tools.is_empty() { + content = content.with_function_tools(tools); + } + } + Message::from_role_and_content(Role::Developer, content) +} + +/// Lower assistant history into Harmony channels. +/// +/// Plain assistant text goes to `final`. When the assistant has tool calls, +/// visible text goes to `commentary`, reasoning goes to `analysis`, and each +/// function call becomes a `commentary` message to `functions.{name}` with JSON +/// constrained content. +fn assistant_messages(content: &[AssistantContentBlock]) -> Vec { + let mut messages = Vec::new(); + let has_tool_calls = content.has_tool_calls(); + + if has_tool_calls { + let text = content.text(); + if !text.is_empty() { + messages.push( + Message::from_role_and_content(Role::Assistant, text).with_channel("commentary"), + ); + } + } + + if let Some(reasoning) = content.reasoning() { + messages.push( + Message::from_role_and_content(Role::Assistant, reasoning).with_channel("analysis"), + ); + } + + if has_tool_calls { + for tool_call in content.tool_calls() { + messages.push( + Message::from_role_and_content(Role::Assistant, tool_call.arguments.clone()) + .with_channel("commentary") + .with_recipient(format!("functions.{}", tool_call.name)) + .with_content_type("<|constrain|>json"), + ); + } + } else { + let text = content.text(); + if !text.is_empty() { + messages + .push(Message::from_role_and_content(Role::Assistant, text).with_channel("final")); + } + } + + messages +} + +/// Build the tool-call ID to function-name map used by later tool responses. +fn tool_call_names(messages: &[ChatMessage]) -> std::collections::HashMap { + let mut names = std::collections::HashMap::new(); + for message in messages { + let ChatMessage::Assistant { content } = message else { + continue; + }; + for tool_call in content.tool_calls() { + names.insert(tool_call.id.clone(), tool_call.name.clone()); + } + } + names +} + +/// Drop stale assistant analysis messages using vLLM Python's policy. +/// +/// Once an assistant final message exists, earlier analysis messages represent +/// chain-of-thought for completed turns and should not be replayed to the model. +fn auto_drop_analysis_messages(messages: Vec) -> Vec { + // Match vLLM Python's Harmony cleanup: once an assistant final message exists, + // previous assistant analysis messages are stale chain-of-thought and should + // be removed. oss-harmony can also drop analysis with `Some(Default::default())`, + // but that built-in path only triggers when the last assistant message is final + // and drops relative to the first final message, which misses longer multi-turn + // histories with later user/tool turns. + let Some(last_assistant_final_index) = messages.iter().rposition(|message| { + message.author.role == Role::Assistant && message.channel.as_deref() == Some("final") + }) else { + return messages; + }; + + messages + .into_iter() + .enumerate() + .filter_map(|(index, message)| { + (index >= last_assistant_final_index || message.channel.as_deref() != Some("analysis")) + .then_some(message) + }) + .collect() +} + +/// Flatten vLLM text content and reject unsupported multimodal parts. +fn flatten_text(content: &ChatContent) -> Result { + content.try_flatten_to_text() +} + +/// Convert vLLM function tool definitions to Harmony tool descriptions. +fn to_tool_descriptions(tools: &[ChatTool]) -> Vec { + tools + .iter() + .map(|tool| { + ToolDescription::new( + tool.name.clone(), + tool.description.clone().unwrap_or_default(), + Some(tool.parameters.clone()), + ) + }) + .collect() +} + +/// Map supported OpenAI reasoning-effort values onto Harmony's enum. +fn to_harmony_reasoning_effort( + reasoning_effort: ReasoningEffort, +) -> Result { + match reasoning_effort { + ReasoningEffort::Low => Ok(HarmonyReasoningEffort::Low), + ReasoningEffort::Medium => Ok(HarmonyReasoningEffort::Medium), + ReasoningEffort::High => Ok(HarmonyReasoningEffort::High), + ReasoningEffort::None + | ReasoningEffort::Minimal + | ReasoningEffort::XHigh + | ReasoningEffort::Max => Err(Error::ChatTemplate(format!( + "reasoning_effort={:?} is not supported by Harmony. Supported values are: low, medium, high.", + reasoning_effort.as_str() + ))), + } +} + +/// Resolve the system start date from the environment or the current date. +fn env_system_start_date() -> String { + std::env::var(SYSTEM_START_DATE_ENV) + .ok() + .filter(|date| !date.is_empty()) + .unwrap_or_else(current_date) +} + +/// Format today's date as `YYYY-MM-DD`, preferring local time. +fn current_date() -> String { + const DATE_FORMAT: &[time::format_description::FormatItem<'static>] = + format_description!("[year]-[month]-[day]"); + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + now.format(DATE_FORMAT).expect("static date format should be valid") +} + +/// Resolve the env flag that places leading instructions in system identity. +fn env_use_harmony_system_instructions() -> bool { + std::env::var(HARMONY_SYSTEM_INSTRUCTIONS_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value != 0) +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/chat/src/renderer/harmony/tests.rs b/rust/src/chat/src/renderer/harmony/tests.rs new file mode 100644 index 000000000000..bcbf97c86644 --- /dev/null +++ b/rust/src/chat/src/renderer/harmony/tests.rs @@ -0,0 +1,212 @@ +use std::path::PathBuf; + +use expect_test::{ExpectFile, expect, expect_file}; +use thiserror_ext::AsReport as _; + +use super::HarmonyChatRenderer; +use super::encoding::harmony_encoding; +use crate::ChatRenderer; +use crate::error::Error; +use crate::event::{AssistantContentBlock, AssistantToolCall}; +use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request}; +use crate::request::{ + ChatContentPart, ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort, +}; + +const PINNED_DATE: &str = "2025-06-28"; + +fn fixture_request(input_name: &str) -> ChatRequest { + fixture_chat_request( + &fixture_path(input_name), + FixtureRequestOptions { + enable_thinking: false, + no_generation_prompt_when_last_assistant: false, + }, + ) +} + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/renderer/harmony") + .join("fixtures") + .join(name) +} + +fn test_renderer(use_system_instructions: bool) -> HarmonyChatRenderer { + HarmonyChatRenderer::with_options(PINNED_DATE, use_system_instructions).unwrap() +} + +fn render_token_ids(request: &ChatRequest) -> Vec { + render_token_ids_with(&test_renderer(false), request) +} + +fn render_token_ids_with(renderer: &HarmonyChatRenderer, request: &ChatRequest) -> Vec { + renderer + .render(request) + .unwrap() + .prompt + .into_token_ids() + .expect("Harmony renderer returns token IDs") +} + +fn render_prompt_text(request: &ChatRequest) -> String { + render_prompt_text_with(&test_renderer(false), request) +} + +fn render_prompt_text_with(renderer: &HarmonyChatRenderer, request: &ChatRequest) -> String { + let token_ids = render_token_ids_with(renderer, request); + harmony_encoding().unwrap().tokenizer().decode_utf8(&token_ids).unwrap() +} + +fn assert_fixture(input_name: &str, expected: ExpectFile) { + let request = fixture_request(input_name); + let rendered = format!("{}\n", render_prompt_text(&request)); + expected.assert_eq(&rendered); +} + +#[test] +fn renders_token_ids() { + let request = fixture_request("simple_user.json"); + + assert!(!render_token_ids(&request).is_empty()); +} + +#[test] +fn renders_simple_user_fixture() { + assert_fixture("simple_user.json", expect_file!["fixtures/simple_user.txt"]); +} + +#[test] +fn renders_leading_system_fixture() { + assert_fixture( + "leading_system.json", + expect_file!["fixtures/leading_system.txt"], + ); +} + +#[test] +fn renders_system_instructions_env_fixture() { + let renderer = test_renderer(true); + let request = fixture_request("leading_system.json"); + let rendered = format!("{}\n", render_prompt_text_with(&renderer, &request)); + expect_file!["fixtures/system_instructions_env.txt"].assert_eq(&rendered); +} + +#[test] +fn renders_request_tools_fixture() { + assert_fixture( + "request_tools.json", + expect_file!["fixtures/request_tools.txt"], + ); +} + +#[test] +fn renders_developer_tools_fixture() { + assert_fixture( + "developer_tools.json", + expect_file!["fixtures/developer_tools.txt"], + ); +} + +#[test] +fn renders_assistant_history_fixture() { + assert_fixture( + "assistant_history.json", + expect_file!["fixtures/assistant_history.txt"], + ); +} + +#[test] +fn renders_tool_roundtrip_fixture() { + assert_fixture( + "tool_roundtrip.json", + expect_file!["fixtures/tool_roundtrip.txt"], + ); +} + +#[test] +fn drops_stale_analysis_fixture() { + assert_fixture( + "drop_analysis.json", + expect_file!["fixtures/drop_analysis.txt"], + ); +} + +#[test] +fn rejects_invalid_reasoning_effort() { + let mut request = ChatRequest::for_test(); + request.chat_options.reasoning_effort = Some(ReasoningEffort::None); + + let error = test_renderer(false).render(&request).unwrap_err(); + + expect![[r#"chat template error: reasoning_effort="none" is not supported by Harmony. Supported values are: low, medium, high."#]] + .assert_eq(&error.to_report_string()); +} + +#[test] +fn rejects_unknown_tool_response_id() { + let request = ChatRequest { + messages: vec![ + ChatMessage::assistant_blocks(vec![AssistantContentBlock::ToolCall( + AssistantToolCall { + id: "call-known".to_string(), + name: "lookup".to_string(), + arguments: "{}".to_string(), + }, + )]), + ChatMessage::tool_response("{}", "call-unknown"), + ], + ..ChatRequest::for_test() + }; + + let error = test_renderer(false).render(&request).unwrap_err(); + + expect![ + "chat template error: invalid Harmony tool message: unknown tool_call_id `call-unknown`" + ] + .assert_eq(&error.to_report_string()); +} + +#[test] +fn rejects_multimodal_input() { + let request = ChatRequest { + messages: vec![ChatMessage::user(vec![ChatContentPart::image_url( + "data:image/png;base64,test", + )])], + ..ChatRequest::for_test() + }; + + let error = test_renderer(false).render(&request).unwrap_err(); + + assert!(matches!( + error, + Error::UnsupportedMultimodalContent("image_url") + )); +} + +#[test] +fn rejects_continue_final_assistant() { + let mut request = ChatRequest { + messages: vec![ + ChatMessage::user("write"), + ChatMessage::assistant_text("partial"), + ], + ..ChatRequest::for_test() + }; + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; + + let error = test_renderer(false).render(&request).unwrap_err(); + + expect!["chat template error: Harmony renderer does not support continue_final_message"] + .assert_eq(&error.to_report_string()); +} + +#[test] +fn no_generation_prompt_omits_trailing_assistant_start() { + let mut request = fixture_request("simple_user.json"); + request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt; + + let rendered = render_prompt_text(&request); + + assert!(!rendered.ends_with("<|start|>assistant")); +} diff --git a/rust/src/chat/src/renderer/hf/format.rs b/rust/src/chat/src/renderer/hf/format.rs index 2c990fb37ba3..afb142c9bf0e 100644 --- a/rust/src/chat/src/renderer/hf/format.rs +++ b/rust/src/chat/src/renderer/hf/format.rs @@ -361,7 +361,6 @@ mod tests { expect![[r#" template_alpaca.jinja => String - template_baichuan.jinja => String template_chatglm.jinja => String template_chatglm2.jinja => String template_chatml.jinja => String @@ -386,7 +385,6 @@ mod tests { tool_chat_template_llama3.2_pythonic.jinja => String tool_chat_template_llama4_json.jinja => OpenAi tool_chat_template_llama4_pythonic.jinja => OpenAi - tool_chat_template_minimax_m1.jinja => OpenAi tool_chat_template_mistral.jinja => String tool_chat_template_mistral3.jinja => OpenAi tool_chat_template_mistral_parallel.jinja => String diff --git a/rust/src/chat/src/renderer/hf/mod.rs b/rust/src/chat/src/renderer/hf/mod.rs index 47c10c0219e6..d9031d73a4bf 100644 --- a/rust/src/chat/src/renderer/hf/mod.rs +++ b/rust/src/chat/src/renderer/hf/mod.rs @@ -14,7 +14,7 @@ use self::format::{ }; use self::template::{CompiledChatTemplate, TemplateContext}; use self::value::{TemplateValue, to_template_value}; -use super::{ChatRenderer, RenderedPrompt}; +use super::{ChatRenderer, RenderedPrompt, effective_template_kwargs}; use crate::error::Result; use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest}; use crate::{ @@ -155,11 +155,23 @@ impl HfChatRenderer { effective_template: &CompiledChatTemplate, request: &ChatRequest, ) -> Result { - let messages = to_template_messages( + let mut messages = to_template_messages( &request.messages, effective_template.content_format(), self.multimodal.as_ref(), )?; + + // Handling of `continue_final_message`: + // Append a sentinel tag to the final message content, render as usual, then + // truncate the rendered prompt at the tag so any template suffix after the + // final message content (e.g. the end-of-turn marker) is dropped. + let final_message_text = if request.chat_options.continue_final_message() { + let final_message = messages.last_mut().ok_or(Error::EmptyMessages)?; + Some(append_continue_final_message_tag(final_message)?) + } else { + None + }; + let tools = request.tool_parsing_enabled().then(|| to_template_tools(&request.tools)); trace!( message_count = messages.len(), @@ -169,8 +181,8 @@ impl HfChatRenderer { "applying chat template" ); - let mut merged_template_kwargs = self.default_template_kwargs.clone(); - merged_template_kwargs.extend(request.chat_options.template_kwargs.clone()); + let effective_template_kwargs = + effective_template_kwargs(&self.default_template_kwargs, request); let prompt = effective_template .apply(TemplateContext { messages: &messages, @@ -178,12 +190,18 @@ impl HfChatRenderer { continue_final_message: request.chat_options.continue_final_message(), tools: tools.as_deref(), documents: request.documents.as_deref(), - template_kwargs: Some(&merged_template_kwargs), + template_kwargs: Some(&effective_template_kwargs), special_tokens: self.special_tokens.as_ref(), - reasoning_effort: request.chat_options.reasoning_effort, }) .map_err(|error| Error::ChatTemplate(error.to_report_string()))?; + let prompt = match &final_message_text { + Some(final_message_text) => { + truncate_prompt_at_continue_final_message_tag(prompt, final_message_text)? + } + None => prompt, + }; + trace!( prompt_len = prompt.len(), prompt, "rendered chat template prompt" @@ -191,6 +209,7 @@ impl HfChatRenderer { Ok(RenderedPrompt { prompt: Prompt::Text(prompt), + effective_template_kwargs, }) } } @@ -429,6 +448,74 @@ fn to_template_string_content( } } +/// Sentinel appended to the final message content when `continue_final_message` +/// is requested, used to locate the truncation point in the rendered prompt. +/// +/// Same literal as `transformers`. Occurrences of this string earlier in the +/// prompt are harmless because truncation uses the rightmost match, and the +/// appended sentinel ends up last as long as the template renders messages in +/// order. +const CONTINUE_FINAL_MESSAGE_TAG: &str = "CONTINUE_FINAL_MESSAGE_TAG "; + +/// Append [`CONTINUE_FINAL_MESSAGE_TAG`] to the trailing text of the final +/// message, returning the original text for post-render validation. +// TODO: transformers v5 also allows continuing a non-`content` field (e.g. +// `reasoning_content`) by passing a field name; only the boolean form is +// supported here. +fn append_continue_final_message_tag(message: &mut TemplateMessage) -> Result { + let text = match &mut message.content { + TemplateContent::String(text) => Some(text), + // Pick the last text part in the message. + TemplateContent::OpenAi(parts) => parts.iter_mut().rev().find_map(|part| match part { + TemplateContentPart::Text { text } => Some(text), + TemplateContentPart::Image => None, + }), + }; + let text = text.ok_or_else(|| { + Error::ChatTemplate( + "continue_final_message is set but there is no text to continue \ + in the final message" + .to_string(), + ) + })?; + + let original = text.clone(); + text.push_str(CONTINUE_FINAL_MESSAGE_TAG); + Ok(original) +} + +/// Truncate the rendered prompt at [`CONTINUE_FINAL_MESSAGE_TAG`] so that it +/// ends exactly with the final message content, dropping any template suffix +/// such as end-of-turn markers. +fn truncate_prompt_at_continue_final_message_tag( + mut rendered: String, + final_message_text: &str, +) -> Result { + let tag_loc = rendered + .rfind(CONTINUE_FINAL_MESSAGE_TAG.trim_end()) + .filter(|_| rendered.contains(final_message_text.trim())); + let Some(tag_loc) = tag_loc else { + return Err(Error::ChatTemplate(format!( + "continue_final_message is set but the final message does not appear \ + in the prompt after applying the chat template! This can happen if \ + the chat template deletes portions of the final message. Final \ + message to continue: {}", + final_message_text.trim(), + ))); + }; + + if rendered[tag_loc..].starts_with(CONTINUE_FINAL_MESSAGE_TAG) { + // The template preserved spacing, so a plain cut at the tag suffices. + rendered.truncate(tag_loc); + } else { + // The template trimmed the trailing spacing of the message content, so + // apply the same trimming to the retained prefix. + rendered.truncate(tag_loc); + rendered.truncate(rendered.trim_end().len()); + } + Ok(rendered) +} + fn to_template_tools(tools: &[ChatTool]) -> Vec { tools .iter() @@ -548,28 +635,124 @@ mod tests { ChatRole::Assistant, "The capital of", )]); + let template = + "{% if continue_final_message %}continue:{% endif %}{{ messages[0].content }}"; - assert_eq!( - render( - Some("{% if continue_final_message %}continue{% else %}new{% endif %}"), - &request, - ) - .unwrap(), - "new" - ); + assert_eq!(render(Some(template), &request).unwrap(), "The capital of"); request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; assert_eq!( - render( - Some("{% if continue_final_message %}continue{% else %}new{% endif %}"), - &request, - ) - .unwrap(), - "continue" + render(Some(template), &request).unwrap(), + "continue:The capital of" ); } + #[test] + fn continue_final_message_truncates_template_suffix() { + let mut request = sample_request(vec![ + ChatMessage::text(ChatRole::User, "What is the capital of France?"), + ChatMessage::text(ChatRole::Assistant, "The capital of"), + ]); + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; + + // The Qwen3 template is unaware of `continue_final_message`; the + // end-of-turn marker it appends must still be stripped. + let rendered = render(Some(QWEN3_0_6B_TEMPLATE), &request).unwrap(); + + expect![[r#" + <|im_start|>user + What is the capital of France?<|im_end|> + <|im_start|>assistant + + + + + The capital of"#]] + .assert_eq(&rendered); + } + + #[test] + fn continue_final_message_trims_like_the_template_does() { + let mut request = sample_request(vec![ChatMessage::text(ChatRole::Assistant, "Sure, ")]); + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; + + // The template trims the trailing spacing of the message content, so + // the truncated prompt must be trimmed the same way. + let rendered = render( + Some("{{ messages[0].content.strip() }}<|im_end|>"), + &request, + ) + .unwrap(); + + assert_eq!(rendered, "Sure,"); + } + + #[test] + fn continue_final_message_appends_to_last_text_part() { + // The renderer itself is role-agnostic like transformers (the + // assistant-final restriction is enforced by request validation + // upstream), so a multimodal user message exercises the part + // selection: the sentinel must attach to the last *text* part, + // skipping the trailing image. + let mut request = sample_request(vec![ChatMessage::user(vec![ + ChatContentPart::text("Sure,"), + ChatContentPart::image_url("data:image/png;base64,test"), + ])]); + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; + + let rendered = render_mm( + "{% for item in messages[0].content %}{% if item.type == 'image' %}{% else %}{{ item.text }}{% endif %}{% endfor %}<|im_end|>", + &request, + ChatTemplateContentFormatOption::OpenAi, + ) + .unwrap() + .prompt; + + // Anything rendered after the continued text (here the image + // placeholder and the end marker) is truncated away, matching + // transformers. + assert_eq!(rendered, Prompt::Text("Sure,".to_string())); + } + + #[test] + fn continue_final_message_composes_with_aware_templates() { + // A template that reads `continue_final_message` and skips its own + // end-of-turn marker must produce the same prompt as an unaware one: + // the sentinel truncation degenerates to a cut at the very end. + let mut request = sample_request(vec![ + ChatMessage::text(ChatRole::User, "hi"), + ChatMessage::text(ChatRole::Assistant, "Sure,"), + ]); + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; + + let aware = "{% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}{% if not (loop.last and continue_final_message) %}<|im_end|>\n{% endif %}{% endfor %}"; + let unaware = "{% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n{% endfor %}"; + + let expected = "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\nSure,"; + assert_eq!(render(Some(aware), &request).unwrap(), expected); + assert_eq!(render(Some(unaware), &request).unwrap(), expected); + } + + #[test] + fn continue_final_message_errors_when_template_drops_final_message() { + let mut request = sample_request(vec![ + ChatMessage::text(ChatRole::User, "hi"), + ChatMessage::text(ChatRole::Assistant, "Sure,"), + ]); + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant; + + let error = render( + Some( + "{% for m in messages %}{% if m.role == 'user' %}{{ m.content }}{% endif %}{% endfor %}", + ), + &request, + ) + .unwrap_err(); + + assert!(matches!(error, Error::ChatTemplate(_))); + } + #[test] fn chat_template_flattens_text_parts_for_string_templates() { let request = sample_request(vec![ChatMessage::user(vec![ @@ -797,9 +980,46 @@ mod tests { ) .unwrap(); - let rendered = renderer.render(&request).unwrap().prompt; + let rendered = renderer.render(&request).unwrap(); + + assert_eq!(rendered.prompt, Prompt::Text("max".to_string())); + assert_eq!( + rendered.effective_template_kwargs.get("reasoning_effort"), + Some(&Value::String("max".to_string())) + ); + assert_eq!( + rendered.effective_template_kwargs.get("enable_thinking"), + Some(&Value::Bool(true)) + ); + } + + #[test] + fn chat_template_reasoning_effort_preserves_request_enable_thinking() { + let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]); + request.chat_options.reasoning_effort = Some(ReasoningEffort::None); + request + .chat_options + .template_kwargs + .insert("enable_thinking".to_string(), Value::Bool(true)); - assert_eq!(rendered, Prompt::Text("max".to_string())); + let renderer = HfChatRenderer::new( + Some("{{ reasoning_effort }}|{{ enable_thinking }}".to_string()), + HashMap::new(), + ChatTemplateContentFormatOption::Auto, + ) + .unwrap(); + + let rendered = renderer.render(&request).unwrap(); + + assert_eq!(rendered.prompt, Prompt::Text("none|true".to_string())); + assert_eq!( + rendered.effective_template_kwargs.get("reasoning_effort"), + Some(&Value::String("none".to_string())) + ); + assert_eq!( + rendered.effective_template_kwargs.get("enable_thinking"), + Some(&Value::Bool(true)) + ); } #[test] diff --git a/rust/src/chat/src/renderer/hf/template.rs b/rust/src/chat/src/renderer/hf/template.rs index b71efc1e53e2..c04df0165d11 100644 --- a/rust/src/chat/src/renderer/hf/template.rs +++ b/rust/src/chat/src/renderer/hf/template.rs @@ -19,7 +19,6 @@ use super::format::{ }; use super::tojson::hf_tojson_filter; use crate::renderer::hf::{TemplateMessage, TemplateTool}; -use crate::request::ReasoningEffort; type Result = std::result::Result; @@ -50,9 +49,6 @@ pub(super) struct TemplateContext<'a> { pub(super) special_tokens: Option<&'a HfSpecialTokens>, #[serde(flatten)] pub(super) template_kwargs: Option<&'a HashMap>, - // By putting top-level `reasoning_effort` after `template_kwargs`, this overrides any - // `reasoning_effort` value that might be present there. - pub(super) reasoning_effort: Option, } /// Load chat template from a file (`.jinja` or `.json` containing Jinja). diff --git a/rust/src/chat/src/renderer/mod.rs b/rust/src/chat/src/renderer/mod.rs index 07ff5d0b6ddc..f1c510a1b3d7 100644 --- a/rust/src/chat/src/renderer/mod.rs +++ b/rust/src/chat/src/renderer/mod.rs @@ -1,23 +1,33 @@ +use std::collections::HashMap; use std::sync::Arc; +use serde_json::{Value, json}; use vllm_text::Prompt; use crate::error::Result; -use crate::request::ChatRequest; +use crate::request::{ChatRequest, ReasoningEffort}; pub mod deepseek_v32; pub mod deepseek_v4; +pub mod harmony; pub mod hf; mod selection; +#[cfg(test)] +mod test_utils; pub use deepseek_v4::DeepSeekV4ChatRenderer; pub use deepseek_v32::DeepSeekV32ChatRenderer; +pub use harmony::HarmonyChatRenderer; pub use selection::RendererSelection; /// Rendered chat prompt submitted to the text backend. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct RenderedPrompt { + /// The rendered prompt, either as text or already tokenized. pub prompt: Prompt, + /// Effective chat-template kwargs visible to the renderer after applying + /// server defaults, request overrides, and typed reasoning controls. + pub effective_template_kwargs: HashMap, } /// Minimal chat-prompt renderer used by `vllm-chat`. @@ -29,3 +39,33 @@ pub trait ChatRenderer: Send + Sync { /// Shared trait-object form of [`ChatRenderer`]. pub type DynChatRenderer = Arc; + +/// Extract the effective chat-template kwargs visible to the renderer from the request, +/// using the provided defaults as the base. +pub(crate) fn effective_template_kwargs( + default_template_kwargs: &HashMap, + request: &ChatRequest, +) -> HashMap { + let mut kwargs = default_template_kwargs.clone(); + kwargs.extend(request.chat_options.template_kwargs.clone()); + + if let Some(reasoning_effort) = request.chat_options.reasoning_effort { + kwargs.insert( + "reasoning_effort".to_string(), + Value::String(reasoning_effort.as_str().to_string()), + ); + if !request.chat_options.template_kwargs.contains_key("enable_thinking") { + kwargs.insert( + "enable_thinking".to_string(), + json!(reasoning_effort != ReasoningEffort::None), + ); + } + } + + kwargs +} + +/// Extract the effective chat-template kwargs visible to the renderer from the request. +pub(crate) fn request_template_kwargs(request: &ChatRequest) -> HashMap { + effective_template_kwargs(&HashMap::new(), request) +} diff --git a/rust/src/chat/src/renderer/selection.rs b/rust/src/chat/src/renderer/selection.rs index cb22f95de0da..837ec7d69c6c 100644 --- a/rust/src/chat/src/renderer/selection.rs +++ b/rust/src/chat/src/renderer/selection.rs @@ -1,10 +1,14 @@ use std::fmt; use std::str::FromStr; +use itertools::Itertools; use serde_with::{DeserializeFromStr, SerializeDisplay}; +use strum::{EnumIter, IntoEnumIterator}; /// Specify which chat renderer implementation to use. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, DeserializeFromStr, SerializeDisplay)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, DeserializeFromStr, SerializeDisplay, EnumIter, +)] pub enum RendererSelection { /// Use model-based auto-detection. #[default] @@ -15,12 +19,16 @@ pub enum RendererSelection { DeepSeekV32, /// Force the DeepSeek V4 renderer. DeepSeekV4, + /// Force the GPT-OSS Harmony renderer. + Harmony, } impl RendererSelection { pub const AUTO_LITERAL: &str = "auto"; pub const DEEPSEEK_V32_LITERAL: &str = "deepseek_v32"; pub const DEEPSEEK_V4_LITERAL: &str = "deepseek_v4"; + pub const GPT_OSS_MODEL_TYPE: &str = "gpt_oss"; + pub const HARMONY_LITERAL: &str = "harmony"; pub const HF_LITERAL: &str = "hf"; /// Resolve the renderer selection using the given model type string, if @@ -30,6 +38,7 @@ impl RendererSelection { Self::Auto => match model_type { Self::DEEPSEEK_V32_LITERAL => Self::DeepSeekV32, Self::DEEPSEEK_V4_LITERAL => Self::DeepSeekV4, + Self::GPT_OSS_MODEL_TYPE => Self::Harmony, _ => Self::Hf, }, selection => selection, @@ -49,9 +58,12 @@ impl FromStr for RendererSelection { Ok(Self::DeepSeekV32) } else if value.eq_ignore_ascii_case(Self::DEEPSEEK_V4_LITERAL) { Ok(Self::DeepSeekV4) + } else if value.eq_ignore_ascii_case(Self::HARMONY_LITERAL) { + Ok(Self::Harmony) } else { Err(format!( - "unknown renderer `{value}` (expected one of: auto, hf, deepseek_v32, deepseek_v4)" + "unknown renderer `{value}` (expected one of: {})", + Self::iter().join(", ") )) } } @@ -64,46 +76,35 @@ impl fmt::Display for RendererSelection { Self::Hf => f.write_str(Self::HF_LITERAL), Self::DeepSeekV32 => f.write_str(Self::DEEPSEEK_V32_LITERAL), Self::DeepSeekV4 => f.write_str(Self::DEEPSEEK_V4_LITERAL), + Self::Harmony => f.write_str(Self::HARMONY_LITERAL), } } } #[cfg(test)] mod tests { - use super::RendererSelection; + use std::str::FromStr as _; - #[test] - fn renderer_selection_parses_known_values() { - assert_eq!( - "auto".parse::().unwrap(), - RendererSelection::Auto - ); - assert_eq!( - "hf".parse::().unwrap(), - RendererSelection::Hf - ); - assert_eq!( - "deepseek_v32".parse::().unwrap(), - RendererSelection::DeepSeekV32 - ); - assert_eq!( - "deepseek_v4".parse::().unwrap(), - RendererSelection::DeepSeekV4 - ); - } + use strum::IntoEnumIterator; + + use super::RendererSelection; #[test] fn renderer_selection_display_round_trips() { - for selection in [ - RendererSelection::Auto, - RendererSelection::Hf, - RendererSelection::DeepSeekV32, - RendererSelection::DeepSeekV4, - ] { + for selection in RendererSelection::iter() { assert_eq!( selection.to_string().parse::().unwrap(), selection ); } } + + #[test] + fn renderer_selection_expected_error_message() { + let err = RendererSelection::from_str("unknown").unwrap_err(); + expect_test::expect![ + "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony)" + ] + .assert_eq(&err); + } } diff --git a/rust/src/chat/src/renderer/test_utils.rs b/rust/src/chat/src/renderer/test_utils.rs new file mode 100644 index 000000000000..bf560de84272 --- /dev/null +++ b/rust/src/chat/src/renderer/test_utils.rs @@ -0,0 +1,242 @@ +use std::fs; +use std::path::Path; + +use serde::Deserialize; +use serde_json::Value; + +use crate::event::{AssistantContentBlock, AssistantToolCall}; +use crate::request::{ + ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice, + GenerationPromptMode, ReasoningEffort, +}; + +/// Options for constructing a [`ChatRequest`] from a fixture file. +#[derive(Debug, Clone, Copy)] +pub(crate) struct FixtureRequestOptions { + /// Whether to set the template kwarg `[enable_]thinking=true`. + pub enable_thinking: bool, + /// Whether fixtures ending in an assistant message should omit the + /// trailing generation prompt. + pub no_generation_prompt_when_last_assistant: bool, +} + +/// Read a fixture file from the given path and convert it into a [`ChatRequest`] +/// using the provided options. +pub(crate) fn fixture_chat_request(path: &Path, options: FixtureRequestOptions) -> ChatRequest { + let fixture = fs::read_to_string(path).unwrap(); + let fixture: FixtureFile = serde_json::from_str(&fixture).unwrap(); + fixture.into_request().into_chat_request(options) +} + +/// Fixture file format for chat-renderer tests. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum FixtureFile { + WithRequest(FixtureRequest), + MessagesOnly(Vec), +} + +#[derive(Debug, Deserialize)] +pub(crate) struct FixtureRequest { + #[serde(default)] + tools: Vec, + messages: Vec, + add_generation_prompt: Option, + reasoning_effort: Option, +} + +impl FixtureFile { + fn into_request(self) -> FixtureRequest { + match self { + Self::WithRequest(request) => request, + Self::MessagesOnly(messages) => FixtureRequest { + tools: Vec::new(), + messages, + add_generation_prompt: None, + reasoning_effort: None, + }, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "role", rename_all = "snake_case")] +pub(crate) enum FixtureMessage { + System { + content: FixtureContent, + }, + Developer { + content: FixtureContent, + #[serde(default)] + tools: Vec, + }, + User { + content: FixtureContent, + }, + Assistant { + #[serde(default)] + content: String, + #[serde(default)] + reasoning_content: String, + #[serde(default)] + tool_calls: Vec, + }, + Tool { + content: FixtureContent, + #[serde(default)] + tool_call_id: Option, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(crate) enum FixtureContent { + Text(String), + Parts(Vec), +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum FixtureContentPart { + Text { text: String }, + ImageUrl { image_url: String }, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct FixtureTool { + function: FixtureToolFunction, +} + +#[derive(Debug, Deserialize)] +struct FixtureToolFunction { + name: String, + description: Option, + parameters: Value, + #[serde(default)] + strict: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct FixtureToolCall { + #[serde(default)] + id: Option, + function: FixtureToolCallFunction, +} + +#[derive(Debug, Deserialize)] +struct FixtureToolCallFunction { + name: String, + arguments: String, +} + +impl FixtureRequest { + fn into_chat_request(self, options: FixtureRequestOptions) -> ChatRequest { + let mut request = ChatRequest { + request_id: "renderer-fixture".to_string(), + messages: self + .messages + .into_iter() + .enumerate() + .map(|(index, message)| fixture_message_to_chat_message(index, message)) + .collect(), + tools: to_chat_tools(&self.tools), + tool_choice: if self.tools.is_empty() { + ChatToolChoice::None + } else { + ChatToolChoice::Auto + }, + ..ChatRequest::for_test() + }; + + if options.no_generation_prompt_when_last_assistant + && matches!(request.messages.last(), Some(ChatMessage::Assistant { .. })) + { + request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt; + } + if self.add_generation_prompt == Some(false) { + request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt; + } + request.chat_options.reasoning_effort = self.reasoning_effort; + if options.enable_thinking { + for key in ["thinking", "enable_thinking"] { + request.chat_options.template_kwargs.insert(key.to_string(), Value::Bool(true)); + } + } + + request + } +} + +fn fixture_message_to_chat_message(index: usize, message: FixtureMessage) -> ChatMessage { + match message { + FixtureMessage::System { content } => ChatMessage::system(to_chat_content(content)), + FixtureMessage::Developer { content, tools } => ChatMessage::developer( + to_chat_content(content), + (!tools.is_empty()).then(|| to_chat_tools(&tools)), + ), + FixtureMessage::User { content } => ChatMessage::user(to_chat_content(content)), + FixtureMessage::Assistant { + content, + reasoning_content, + tool_calls, + } => { + let mut blocks = Vec::new(); + if !reasoning_content.is_empty() { + blocks.push(AssistantContentBlock::Reasoning { + text: reasoning_content, + }); + } + if !content.is_empty() { + blocks.push(AssistantContentBlock::Text { text: content }); + } + blocks.extend( + tool_calls.into_iter().enumerate().map(|(tool_index, tool_call)| { + AssistantContentBlock::ToolCall(AssistantToolCall { + id: tool_call + .id + .unwrap_or_else(|| format!("fixture-tool-call-{index}-{tool_index}")), + name: tool_call.function.name, + arguments: tool_call.function.arguments, + }) + }), + ); + ChatMessage::assistant_blocks(blocks) + } + FixtureMessage::Tool { + content, + tool_call_id, + } => ChatMessage::tool_response( + to_chat_content(content), + tool_call_id.unwrap_or_else(|| format!("fixture-tool-response-{index}")), + ), + } +} + +fn to_chat_content(content: FixtureContent) -> ChatContent { + match content { + FixtureContent::Text(text) => ChatContent::Text(text), + FixtureContent::Parts(parts) => ChatContent::Parts( + parts + .into_iter() + .map(|part| match part { + FixtureContentPart::Text { text } => ChatContentPart::text(text), + FixtureContentPart::ImageUrl { image_url } => { + ChatContentPart::image_url(image_url) + } + }) + .collect(), + ), + } +} + +fn to_chat_tools(tools: &[FixtureTool]) -> Vec { + tools + .iter() + .map(|tool| ChatTool { + name: tool.function.name.clone(), + description: tool.function.description.clone(), + parameters: tool.function.parameters.clone(), + strict: tool.function.strict, + }) + .collect() +} diff --git a/rust/src/chat/src/request.rs b/rust/src/chat/src/request.rs index 842c941a6c00..72de3d87663c 100644 --- a/rust/src/chat/src/request.rs +++ b/rust/src/chat/src/request.rs @@ -4,9 +4,9 @@ use llm_multimodal::ImageDetail; use serde::{Deserialize, Serialize}; use serde_json::Value; use vllm_engine_core_client::protocol::lora::LoraRequest; +pub use vllm_parser::tool::Tool as ChatTool; pub use vllm_text::SamplingParams; use vllm_text::TextDecodeOptions; -pub use vllm_tool_parser::Tool as ChatTool; use crate::AssistantMessageExt; use crate::error::{Error, Result}; @@ -382,12 +382,16 @@ impl ChatOptions { } /// Tool-choice semantics supported by `vllm-chat`. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ChatToolChoice { - Auto, #[default] None, + Auto, + Required, + Function { + name: String, + }, } /// One chat request ready to be rendered into a prompt and lowered into a @@ -406,6 +410,10 @@ pub struct ChatRequest { pub tools: Vec, /// Tool-choice behavior for this request. pub tool_choice: ChatToolChoice, + /// Whether the model may return more than one tool call per response. + /// + /// When `false`, only the first parsed tool call is surfaced northbound. + pub parallel_tool_calls: bool, /// Text decode options for incremental detokenization. pub decode_options: TextDecodeOptions, /// Whether to emit intermediate northbound content deltas before the @@ -442,6 +450,7 @@ impl ChatRequest { chat_options: ChatOptions::default(), tools: Vec::new(), tool_choice: ChatToolChoice::None, + parallel_tool_calls: true, decode_options: TextDecodeOptions::default(), intermediate: true, priority: 0, @@ -481,7 +490,7 @@ impl ChatRequest { /// Return true if this request should enable tool parsing based on the tool /// choice and tool list. pub(crate) fn tool_parsing_enabled(&self) -> bool { - matches!(self.tool_choice, ChatToolChoice::Auto) && !self.tools.is_empty() + !matches!(self.tool_choice, ChatToolChoice::None) && !self.tools.is_empty() } /// Return the request-level thinking toggle when explicitly requested. diff --git a/rust/src/chat/src/stream.rs b/rust/src/chat/src/stream.rs index 8a8dea46e6c0..fb5c7d3e3f07 100644 --- a/rust/src/chat/src/stream.rs +++ b/rust/src/chat/src/stream.rs @@ -14,12 +14,11 @@ use crate::event::{AssistantContentBlock, AssistantMessage, ChatEvent}; #[derive(Debug, Clone, PartialEq)] pub struct CollectedAssistantMessage { pub message: AssistantMessage, - pub prompt_token_count: usize, pub prompt_token_ids: Arc<[u32]>, pub prompt_logprobs: Option, pub logprobs: Option, pub token_ids: Vec, - pub output_token_count: usize, + pub usage: vllm_llm::TokenUsage, pub finish_reason: FinishReason, /// Connector-specific KV transfer parameters for disaggregated serving. pub kv_transfer_params: Option, @@ -75,21 +74,19 @@ impl ChatEventStream { } ChatEvent::Done { message: done, - prompt_token_count, - output_token_count, + usage, finish_reason, kv_transfer_params, } => { return Ok(CollectedAssistantMessage { message: done, - prompt_token_count, prompt_token_ids, prompt_logprobs, logprobs: (!logprob_positions.is_empty()).then_some(DecodedLogprobs { positions: logprob_positions, }), token_ids, - output_token_count, + usage, finish_reason, kv_transfer_params, }); @@ -190,8 +187,11 @@ mod tests { }), Ok(ChatEvent::Done { message: Default::default(), - prompt_token_count: 2, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 2, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), @@ -203,7 +203,6 @@ mod tests { collected, CollectedAssistantMessage { message: Default::default(), - prompt_token_count: 2, prompt_token_ids: vec![10, 11].into(), prompt_logprobs: Some(DecodedPromptLogprobs { first_token_id: 0, @@ -228,7 +227,11 @@ mod tests { }], }), token_ids: vec![], - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 2, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, } diff --git a/rust/src/chat/tests/chat.rs b/rust/src/chat/tests/chat.rs index 7c423561c855..77e93ac5c550 100644 --- a/rust/src/chat/tests/chat.rs +++ b/rust/src/chat/tests/chat.rs @@ -15,21 +15,24 @@ use vllm_chat::{ use vllm_engine_core_client::protocol::logprobs::{ Logprobs, MaybeWireLogprobs, PositionLogprobs, TokenLogprob, }; -use vllm_engine_core_client::protocol::{ - EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest, StopReason, +use vllm_engine_core_client::protocol::output::{ + EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs, StopReason, }; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task}; use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig}; use vllm_llm::Llm; -use vllm_text::tokenizer::{DynTokenizer, Tokenizer}; +use vllm_text::tokenizer::DynTokenizer; use vllm_text::{ DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedTokenLogprob, Prompt, TextBackend, }; +use vllm_tokenizer::test_utils::TestTokenizer; use zeromq::prelude::{SocketRecv, SocketSend}; use zeromq::{DealerSocket, PushSocket, ZmqMessage}; const SPECIAL_STOP_TOKEN_ID: u32 = 256; +const UNKNOWN_DECODE_TOKEN_ID: u32 = 10_000; fn request_output( request_id: &str, @@ -158,45 +161,18 @@ async fn connect_chat_llm_with_ipc( struct FakeChatBackend { has_template: bool, model_id: String, + tokenizer: DynTokenizer, } -#[derive(Debug)] -struct FakeChatTokenizer; - -impl Tokenizer for FakeChatTokenizer { - fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result> { - Ok(text.bytes().map(u32::from).collect()) - } - - fn decode( - &self, - token_ids: &[u32], - skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - let bytes = token_ids - .iter() - .filter_map(|id| { - if skip_special_tokens && *id == SPECIAL_STOP_TOKEN_ID { - None - } else { - Some(*id as u8) - } - }) - .collect::>(); - Ok(String::from_utf8_lossy(&bytes).into_owned()) - } - - fn token_to_id(&self, token: &str) -> Option { - match token { - "" => Some(0xF001), - "" => Some(0xF002), - "<|START_THINKING|>" => Some(0xF003), - "<|END_THINKING|>" => Some(0xF004), - "◁think▷" => Some(0xF005), - "◁/think▷" => Some(0xF006), - _ => None, - } - } +fn fake_chat_tokenizer() -> TestTokenizer { + TestTokenizer::new() + .with_special_token("", SPECIAL_STOP_TOKEN_ID) + .with_regular_token("", 0xF001) + .with_regular_token("", 0xF002) + .with_regular_token("<|START_THINKING|>", 0xF003) + .with_regular_token("<|END_THINKING|>", 0xF004) + .with_regular_token("◁think▷", 0xF005) + .with_regular_token("◁/think▷", 0xF006) } impl fmt::Debug for FakeChatBackend { @@ -210,6 +186,7 @@ impl FakeChatBackend { Self { has_template: true, model_id: "test-model".to_string(), + tokenizer: Arc::new(fake_chat_tokenizer()), } } @@ -217,6 +194,7 @@ impl FakeChatBackend { Self { has_template: false, model_id: "test-model".to_string(), + tokenizer: Arc::new(fake_chat_tokenizer()), } } @@ -224,13 +202,19 @@ impl FakeChatBackend { Self { has_template: true, model_id: model_id.into(), + tokenizer: Arc::new(fake_chat_tokenizer()), } } + + fn with_tokenizer(mut self, tokenizer: DynTokenizer) -> Self { + self.tokenizer = tokenizer; + self + } } impl TextBackend for FakeChatBackend { fn tokenizer(&self) -> DynTokenizer { - Arc::new(FakeChatTokenizer) + Arc::clone(&self.tokenizer) } fn model_id(&self) -> &str { @@ -277,69 +261,11 @@ impl ChatRenderer for FakeChatBackend { Ok(RenderedPrompt { prompt: Prompt::Text(prompt), + effective_template_kwargs: request.chat_options.template_kwargs.clone(), }) } } -#[derive(Clone, Debug)] -struct FailingDecodeBackend { - inner: FakeChatBackend, -} - -#[derive(Debug)] -struct FailingDecodeTokenizer; - -impl Tokenizer for FailingDecodeTokenizer { - fn encode(&self, text: &str, add_special_tokens: bool) -> vllm_tokenizer::Result> { - FakeChatTokenizer.encode(text, add_special_tokens) - } - - fn decode( - &self, - token_ids: &[u32], - skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - if token_ids.contains(&(b'i' as u32)) { - return Err(vllm_tokenizer::TokenizerError("decode failed".to_string())); - } - FakeChatTokenizer.decode(token_ids, skip_special_tokens) - } - - fn token_to_id(&self, token: &str) -> Option { - FakeChatTokenizer.token_to_id(token) - } -} - -impl TextBackend for FailingDecodeBackend { - fn tokenizer(&self) -> DynTokenizer { - Arc::new(FailingDecodeTokenizer) - } - - fn model_id(&self) -> &str { - self.inner.model_id() - } -} - -impl ChatBackend for FailingDecodeBackend { - fn chat_renderer(&self) -> DynChatRenderer { - Arc::new(self.clone()) - } - - fn new_chat_output_processor( - &self, - _request: &mut ChatRequest, - _options: NewChatOutputProcessorOptions<'_>, - ) -> vllm_chat::Result { - Ok(Box::new(DefaultChatOutputProcessor::plain_text_only())) - } -} - -impl ChatRenderer for FailingDecodeBackend { - fn render(&self, request: &ChatRequest) -> vllm_chat::Result { - self.inner.render(request) - } -} - /// Skip `LogprobsDelta` events that carry only token_ids (no logprobs), /// returning the next semantically interesting event. async fn next_semantic(stream: &mut S) -> Option> @@ -416,7 +342,7 @@ async fn chat_streams_text_events() { ); send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output("chat-1", vec![b'H' as u32], None, None), request_output( @@ -428,7 +354,8 @@ async fn chat_streams_text_events() { ], finished_requests: Some(BTreeSet::from(["chat-1".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -494,12 +421,12 @@ async fn chat_streams_text_events() { match next_semantic(&mut stream).await { Some(Ok(ChatEvent::Done { message, - output_token_count, + usage, finish_reason, .. })) => { assert_eq!(message.text(), "Hi"); - assert_eq!(output_token_count, 3); + assert_eq!(usage.output_token_count, 3); assert_eq!( finish_reason, FinishReason::Stop(Some(StopReason::TokenId(b'!' as u32))) @@ -528,7 +455,7 @@ async fn chat_stream_waits_for_complete_utf8_before_emitting() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output("chat-utf8", bytes_to_token_ids(&[0xe4]), None, None), request_output( @@ -540,7 +467,8 @@ async fn chat_stream_waits_for_complete_utf8_before_emitting() { ], finished_requests: Some(BTreeSet::from(["chat-utf8".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -590,13 +518,9 @@ async fn chat_stream_waits_for_complete_utf8_before_emitting() { ); match next_semantic(&mut stream).await { - Some(Ok(ChatEvent::Done { - message, - output_token_count, - .. - })) => { + Some(Ok(ChatEvent::Done { message, usage, .. })) => { assert_eq!(message.text(), "你"); - assert_eq!(output_token_count, 4); + assert_eq!(usage.output_token_count, 4); } other => panic!("unexpected final event: {other:?}"), } @@ -620,7 +544,7 @@ async fn chat_stream_flushes_held_text_on_finish() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output( "chat-final-flush", bytes_to_token_ids(b"ok st"), @@ -629,7 +553,8 @@ async fn chat_stream_flushes_held_text_on_finish() { )], finished_requests: Some(BTreeSet::from(["chat-final-flush".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -681,12 +606,12 @@ async fn chat_stream_flushes_held_text_on_finish() { match next_semantic(&mut stream).await { Some(Ok(ChatEvent::Done { message, - output_token_count, + usage, finish_reason, .. })) => { assert_eq!(message.text(), "ok st"); - assert_eq!(output_token_count, 5); + assert_eq!(usage.output_token_count, 5); assert_eq!(finish_reason, FinishReason::Length); } other => panic!("unexpected final event: {other:?}"), @@ -740,19 +665,24 @@ async fn chat_stream_reports_decode_failure_as_error_event() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { - outputs: vec![request_output("chat-4", vec![b'i' as u32], None, None)], + RequestBatchOutputs { + outputs: vec![request_output( + "chat-4", + vec![UNKNOWN_DECODE_TOKEN_ID], + None, + None, + )], ..Default::default() - }, + } + .into(), ) .await; }) }, ); - let backend: Arc = Arc::new(FailingDecodeBackend { - inner: FakeChatBackend::new(), - }); + let backend: Arc = + Arc::new(FakeChatBackend::new().with_tokenizer(Arc::new(TestTokenizer::new()))); let chat = connect_chat_llm_with_ipc( EngineCoreClientConfig::new_single(handshake_address), &ipc, @@ -772,7 +702,10 @@ async fn chat_stream_reports_decode_failure_as_error_event() { match timeout(Duration::from_secs(2), stream.next()).await.unwrap() { Some(Err(vllm_chat::Error::Text(vllm_text::Error::Tokenizer(message)))) => { - assert_eq!(message, "decode failed"); + assert_eq!( + message, + format!("test tokenizer cannot decode unknown token id {UNKNOWN_DECODE_TOKEN_ID}") + ); } other => panic!("unexpected event after close: {other:?}"), } @@ -796,7 +729,7 @@ async fn chat_stream_preserves_terminal_stop_token_when_requested() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output( "chat-include-stop", vec![b'H' as u32, b'i' as u32, b'!' as u32], @@ -805,7 +738,8 @@ async fn chat_stream_preserves_terminal_stop_token_when_requested() { )], finished_requests: Some(BTreeSet::from(["chat-include-stop".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -857,13 +791,9 @@ async fn chat_stream_preserves_terminal_stop_token_when_requested() { ); match next_semantic(&mut stream).await { - Some(Ok(ChatEvent::Done { - message, - output_token_count, - .. - })) => { + Some(Ok(ChatEvent::Done { message, usage, .. })) => { assert_eq!(message.text(), "Hi!"); - assert_eq!(output_token_count, 3); + assert_eq!(usage.output_token_count, 3); } other => panic!("unexpected final event: {other:?}"), } @@ -887,7 +817,7 @@ async fn chat_stream_separates_reasoning_blocks_automatically() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output( "chat-reasoning", @@ -916,7 +846,8 @@ async fn chat_stream_separates_reasoning_blocks_automatically() { ], finished_requests: Some(BTreeSet::from(["chat-reasoning".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1030,7 +961,7 @@ async fn chat_collectors_return_structured_message_and_visible_text() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output( "chat-collect", bytes_to_token_ids(b"innerouter"), @@ -1039,7 +970,8 @@ async fn chat_collectors_return_structured_message_and_visible_text() { )], finished_requests: Some(BTreeSet::from(["chat-collect".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1066,11 +998,11 @@ async fn chat_collectors_return_structured_message_and_visible_text() { assert_eq!(message.message.text(), "outer"); assert_eq!(message.finish_reason, FinishReason::Length); assert_eq!( - message.prompt_token_count, + message.usage.prompt_token_count, "system: You are terse.\nuser: Say hi\nassistant:".len() ); assert_eq!( - message.output_token_count, + message.usage.output_token_count, "innerouter".len() ); @@ -1093,7 +1025,7 @@ async fn chat_explicitly_disables_reasoning_parser() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output( "chat-reasoning-disabled", @@ -1124,7 +1056,8 @@ async fn chat_explicitly_disables_reasoning_parser() { "chat-reasoning-disabled".to_string() ])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1171,7 +1104,7 @@ async fn chat_stream_parses_tool_calls_automatically() { let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output( "chat-tool", @@ -1187,7 +1120,7 @@ async fn chat_stream_parses_tool_calls_automatically() { ), request_output( "chat-tool", - bytes_to_token_ids( + bytes_with_special_stop_token( b"\"arguments\":{\"city\":\"Paris\"}}\n", ), Some(EngineCoreFinishReason::Stop), @@ -1196,7 +1129,8 @@ async fn chat_stream_parses_tool_calls_automatically() { ], finished_requests: Some(BTreeSet::from(["chat-tool".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1279,7 +1213,7 @@ async fn chat_collect_message_preserves_tool_call_arguments_in_final_only_mode() let _ = recv_engine_message(dealer).await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output( "chat-final-only-tool", @@ -1306,7 +1240,8 @@ async fn chat_collect_message_preserves_tool_call_arguments_in_final_only_mode() "chat-final-only-tool".to_string() ])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1357,7 +1292,7 @@ async fn chat_stream_and_collect_preserve_prompt_and_sample_logprobs() { let request: EngineCoreRequest = rmp_serde::from_slice(&add[1]).unwrap(); send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output_with_logprobs( &request.request_id, @@ -1378,7 +1313,8 @@ async fn chat_stream_and_collect_preserve_prompt_and_sample_logprobs() { ], finished_requests: Some(BTreeSet::from([request.request_id])), ..Default::default() - }, + } + .into(), ) .await; } diff --git a/rust/src/chat/tests/roundtrip.rs b/rust/src/chat/tests/roundtrip.rs index ab2ca06cb376..b1670814ef62 100644 --- a/rust/src/chat/tests/roundtrip.rs +++ b/rust/src/chat/tests/roundtrip.rs @@ -1,8 +1,8 @@ -//! Text-level roundtrip tests for the real chat-template and output-processor pairing. +//! Roundtrip tests for the real chat-template and output-processor pairing. //! //! The invariant under test is that a structured assistant message rendered as history can be //! parsed from the generated assistant completion and then rendered back to the exact same -//! assistant-completion text. +//! assistant completion. use std::pin::Pin; use std::sync::Arc; @@ -18,6 +18,10 @@ use vllm_chat::{ RendererSelection, load_model_backends, }; use vllm_text::{DecodedTextEvent, Finished, Prompt}; +use vllm_tokenizer::Tokenizer; + +const TEXT_COMPLETION_CHUNK_CHARS: usize = 7; +const TOKEN_COMPLETION_CHUNK_TOKENS: usize = 1; /// One model/parser configuration used to run the fixed roundtrip fixtures. #[derive(Clone)] @@ -37,6 +41,8 @@ struct RoundtripCase { /// JSON formatting expected after this model's template has materialized /// tool-call arguments. json_fmt: JsonFmt, + /// Whether the template renders tool-call argument object keys in sorted order. + sort_json_keys: bool, } #[derive(Clone, Copy)] @@ -81,6 +87,7 @@ impl RoundtripCase { reasoning_parser: ParserSelection::Auto, thinking_behavior: ThinkingBehavior::Toggleable { default: true }, json_fmt: spaced_json_fmt(), + sort_json_keys: false, } } @@ -93,6 +100,7 @@ impl RoundtripCase { reasoning_parser: ParserSelection::Auto, thinking_behavior: ThinkingBehavior::Toggleable { default: true }, json_fmt: compact_json_fmt(), + sort_json_keys: false, } } @@ -105,6 +113,7 @@ impl RoundtripCase { reasoning_parser: ParserSelection::Auto, thinking_behavior: ThinkingBehavior::Always { value: true }, json_fmt: compact_json_fmt(), + sort_json_keys: false, } } @@ -117,6 +126,20 @@ impl RoundtripCase { reasoning_parser: ParserSelection::Auto, thinking_behavior: ThinkingBehavior::Toggleable { default: false }, json_fmt: compact_json_fmt(), + sort_json_keys: false, + } + } + + /// DeepSeek V3.2 DSML tool-call format. + fn deepseek_v32() -> Self { + Self { + model_id: "deepseek-ai/DeepSeek-V3.2-Exp", + assistant_stop_suffix: "<|end▁of▁sentence|>", + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Toggleable { default: false }, + json_fmt: compact_json_fmt(), + sort_json_keys: false, } } @@ -129,6 +152,20 @@ impl RoundtripCase { reasoning_parser: ParserSelection::Auto, thinking_behavior: ThinkingBehavior::Toggleable { default: true }, json_fmt: compact_json_fmt(), + sort_json_keys: false, + } + } + + /// Gemma4 channel reasoning with custom function-call arguments. + fn gemma4() -> Self { + Self { + model_id: "google/gemma-4-E4B-it", + assistant_stop_suffix: "<|tool_response>", + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Always { value: true }, + json_fmt: compact_json_fmt(), + sort_json_keys: true, } } @@ -142,21 +179,65 @@ impl RoundtripCase { reasoning_parser: ParserSelection::Auto, thinking_behavior: ThinkingBehavior::Toggleable { default: true }, json_fmt: spaced_json_fmt(), + sort_json_keys: false, + } + } + + /// SeedOSS with `` / `` reasoning tags. + fn seed_oss() -> Self { + Self { + model_id: "ByteDance-Seed/Seed-OSS-36B-Instruct", + assistant_stop_suffix: "", + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Always { value: true }, + json_fmt: compact_json_fmt(), + sort_json_keys: false, + } + } + + /// Step-3.5 with `` / `` reasoning tags and newline trimming. + fn step3p5() -> Self { + Self { + model_id: "stepfun-ai/Step-3.5-Flash", + assistant_stop_suffix: "<|im_end|>\n", + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Always { value: true }, + json_fmt: compact_json_fmt(), + sort_json_keys: false, + } + } + + /// GPT-OSS Harmony token-id renderer and native Harmony output processor. + fn gpt_oss() -> Self { + Self { + model_id: "openai/gpt-oss-20b", + assistant_stop_suffix: "", // not applicable for token-id cases + tool_call_parser: ParserSelection::Auto, + reasoning_parser: ParserSelection::Auto, + thinking_behavior: ThinkingBehavior::Always { value: true }, + json_fmt: compact_json_fmt(), + sort_json_keys: false, } } } macro_rules! roundtrip_tests { - ($($case:ident => [$($fixture:ident),* $(,)?]),+ $(,)?) => { + ($($case:ident => [$($(#[$fixture_attr:meta])* $fixture:ident),* $(,)?]),+ $(,)?) => { paste::paste! { $( - $( - #[tokio::test] - #[file_serial([])] - async fn []() -> Result<()> { - [](RoundtripCase::$case()).await - } - )* + #[tokio::test] + #[file_serial([])] + async fn []() -> Result<()> { + let case = RoundtripCase::$case(); + let backends = load_roundtrip_backends(&case).await?; + $( + $(#[$fixture_attr])* + [](&case, &backends).await?; + )* + Ok(()) + } )+ } }; @@ -167,26 +248,31 @@ roundtrip_tests! { qwen35 => [reasoning_and_content, tool_call_mix], minimax_m25 => [reasoning_and_content, tool_call_mix], deepseek_v4 => [reasoning_and_content, tool_call_mix], + deepseek_v32 => [tool_call_mix], glm47 => [reasoning_and_content, tool_call_mix], - - // Note: Kimi K2.5 strips the reasoning content in history. - // TODO: we don't respect model-generated tool call id now so `tool_call_mix` cannot pass. - // kimi_k25 => [tool_call_mix], + seed_oss => [reasoning_and_content], + step3p5 => [reasoning_and_content], + gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call + kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history + gpt_oss => [tool_call_mix], // Harmony strips reasoning in history if there's no tool call } /// Run the fixed reasoning+content fixture for one model/parser case. -async fn run_roundtrip_reasoning_and_content(case: RoundtripCase) -> Result<()> { +async fn run_roundtrip_reasoning_and_content( + case: &RoundtripCase, + backends: &vllm_chat::LoadedModelBackends, +) -> Result<()> { for thinking in case.thinking_behavior.fixtures() { - run_roundtrip_reasoning_and_content_inner(case.clone(), thinking).await?; + run_roundtrip_reasoning_and_content_inner(case, backends, thinking).await?; } Ok(()) } async fn run_roundtrip_reasoning_and_content_inner( - case: RoundtripCase, + case: &RoundtripCase, + backends: &vllm_chat::LoadedModelBackends, thinking: Option, ) -> Result<()> { - let backends = load_roundtrip_backends(&case).await?; let request = roundtrip_request( "roundtrip-reasoning-content", vec![ChatMessage::text(ChatRole::User, "What is 2 + 2?")], @@ -209,7 +295,7 @@ async fn run_roundtrip_reasoning_and_content_inner( }); AssistantMessage { content } }; - let result = run_roundtrip(&case, &backends, &request, assistant).await?; + let result = run_roundtrip(case, backends, &request, assistant).await?; assert_eq!( result.parsed_message.reasoning().as_deref().map(str::trim), @@ -227,8 +313,10 @@ async fn run_roundtrip_reasoning_and_content_inner( } /// Run the fixed reasoning+multiple-tools fixture for one model/parser case. -async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> { - let backends = load_roundtrip_backends(&case).await?; +async fn run_roundtrip_tool_call_mix( + case: &RoundtripCase, + backends: &vllm_chat::LoadedModelBackends, +) -> Result<()> { let request = roundtrip_request( "roundtrip-reasoning-tools", vec![ChatMessage::text( @@ -242,8 +330,8 @@ async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> { let expected_text = "I will call the tools."; let result = run_roundtrip( - &case, - &backends, + case, + backends, &request, AssistantMessage { content: vec![ @@ -287,12 +375,12 @@ async fn run_roundtrip_tool_call_mix(case: RoundtripCase) -> Result<()> { assert_eq!(tool_calls[0].name, "get_weather"); assert_eq!( tool_calls[0].arguments, - expected_arguments(&case, r#"{"location": "Shanghai"}"#)?, + expected_arguments(case, r#"{"location": "Shanghai"}"#)?, ); assert_eq!(tool_calls[1].name, "add"); assert_eq!( tool_calls[1].arguments, - expected_arguments(&case, r#"{"y": 1.0, "x": 2, "items": ["left", "right"]}"#)?, + expected_arguments(case, r#"{"y": 1.0, "x": 2, "items": ["left", "right"]}"#)?, ); assert_eq!( @@ -322,14 +410,38 @@ fn spaced_json_fmt() -> JsonFmt { /// Pass in a raw JSON string instead of a structured value to ensure the exact precision and /// formatting of numbers are preserved. fn expected_arguments(case: &RoundtripCase, raw_json: &str) -> Result { - let value: serde_json::Value = + let mut value: serde_json::Value = serde_json::from_str(raw_json).context("invalid expected tool-call arguments")?; + if case.sort_json_keys { + sort_json_value(&mut value); + } case.json_fmt .format_to_string(&value) .context("failed to format expected tool-call arguments") } +/// Sort JSON object keys recursively to match templates that render mappings with `dictsort`. +fn sort_json_value(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for value in map.values_mut() { + sort_json_value(value); + } + + let mut entries = std::mem::take(map).into_iter().collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + map.extend(entries); + } + serde_json::Value::Array(values) => { + for value in values { + sort_json_value(value); + } + } + _ => {} + } +} + /// Load the real model chat/text backend for one roundtrip case. async fn load_roundtrip_backends(case: &RoundtripCase) -> Result { load_model_backends( @@ -349,10 +461,10 @@ struct RoundtripResult { parsed_message: AssistantMessage, /// Assistant-completion suffix cut from rendering the expected assistant as /// history. - closed_completion: String, + closed_completion: Prompt, /// Assistant-completion suffix cut after rendering the parsed assistant /// back as history. - rerendered_closed_completion: String, + rerendered_closed_completion: Prompt, } /// Render, parse, and rerender one assistant turn through the production @@ -364,60 +476,59 @@ async fn run_roundtrip( assistant: AssistantMessage, ) -> Result { let renderer = backends.chat_backend.chat_renderer(); - let (prompt, closed_completion_text) = - render_closed_completion(renderer.as_ref(), request, &assistant)?; - let completion_body = closed_completion_text - .strip_suffix(case.assistant_stop_suffix) - .with_context(|| { - format!( - "closed assistant completion did not end with {:?}: {:?}", - case.assistant_stop_suffix, closed_completion_text - ) - })?; - - let parsed_message = - parse_completion(case, backends, request, &prompt, completion_body).await?; - let (_, rerendered_closed_completion) = - render_closed_completion(renderer.as_ref(), request, &parsed_message)?; + let rendered = render_closed_completion(renderer.as_ref(), request, &assistant)?; + + let parsed_message = parse_completion(case, backends, request, &rendered).await?; + let rerendered = render_closed_completion(renderer.as_ref(), request, &parsed_message)?; Ok(RoundtripResult { parsed_message, - closed_completion: closed_completion_text, - rerendered_closed_completion, + closed_completion: rendered.completion, + rerendered_closed_completion: rerendered.completion, }) } +/// Rendered prompt/completion artifacts at the renderer boundary. +struct RenderedTurn { + prompt: Prompt, + completion: Prompt, +} + /// Render `history` as a production prompt and `history + assistant` as closed /// history, then return the production prompt and assistant-completion suffix. fn render_closed_completion( renderer: &dyn vllm_chat::ChatRenderer, base_request: &ChatRequest, assistant: &AssistantMessage, -) -> Result<(String, String)> { +) -> Result { let mut prompt_request = base_request.clone(); prompt_request.chat_options.generation_prompt_mode = GenerationPromptMode::StartNewAssistant; - let prompt = render_text(renderer, &prompt_request).context("failed to render prompt")?; + let prompt = renderer.render(&prompt_request).context("failed to render prompt")?.prompt; let mut full_request = base_request.clone(); full_request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt; full_request.messages.push(ChatMessage::from(assistant.clone())); - let full = render_text(renderer, &full_request).context("failed to render full prompt")?; - - ensure!( - full.starts_with(&prompt), - "full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}" - ); - let completion = full[prompt.len()..].to_string(); - - Ok((prompt, completion)) -} + let full = renderer.render(&full_request).context("failed to render full prompt")?.prompt; + + let completion = match (&prompt, full) { + (Prompt::Text(prompt), Prompt::Text(full)) => { + ensure!( + full.starts_with(prompt), + "full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}" + ); + Prompt::Text(full[prompt.len()..].to_string()) + } + (Prompt::TokenIds(prompt), Prompt::TokenIds(full)) => { + ensure!( + full.starts_with(prompt), + "full prompt must extend production prompt\nprompt: {prompt:?}\nfull: {full:?}" + ); + Prompt::TokenIds(full[prompt.len()..].to_vec()) + } + (prompt, full) => bail!("prompt kind changed between renders: {prompt:?} vs {full:?}"), + }; -/// Render one chat request and require a text prompt. -fn render_text(renderer: &dyn vllm_chat::ChatRenderer, request: &ChatRequest) -> Result { - match renderer.render(request)?.prompt { - Prompt::Text(text) => Ok(text), - other => bail!("roundtrip tests expect text prompts, got {other:?}"), - } + Ok(RenderedTurn { prompt, completion }) } /// Feed one rendered assistant completion body into the real output processor @@ -426,13 +537,15 @@ async fn parse_completion( case: &RoundtripCase, backends: &vllm_chat::LoadedModelBackends, base_request: &ChatRequest, - prompt: &str, - completion_body: &str, + rendered: &RenderedTurn, ) -> Result { let tokenizer = backends.text_backend.tokenizer(); - let prompt_token_ids = tokenizer - .encode(prompt, base_request.add_special_tokens) - .context("failed to encode rendered prompt")?; + let prompt_token_ids = match &rendered.prompt { + Prompt::Text(prompt) => tokenizer + .encode(prompt, base_request.add_special_tokens) + .context("failed to encode rendered prompt")?, + Prompt::TokenIds(token_ids) => token_ids.clone(), + }; let mut request = base_request.clone(); let processor = backends.chat_backend.new_chat_output_processor( @@ -443,7 +556,12 @@ async fn parse_completion( }, )?; - let decoded = decoded_completion_stream(prompt_token_ids, completion_body); + let decoded = decoded_completion_stream( + tokenizer.as_ref(), + prompt_token_ids, + &rendered.completion, + case.assistant_stop_suffix, + )?; let mut events = processor.process(decoded)?; while let Some(event) = events.next().await { @@ -466,16 +584,46 @@ async fn parse_completion( /// split into small chunks to exercise streaming parser state across marker /// and JSON boundaries. fn decoded_completion_stream( + tokenizer: &dyn Tokenizer, prompt_token_ids: Vec, - completion_body: &str, -) -> Pin> + Send>> { - let prompt_token_count = prompt_token_ids.len(); + completion: &Prompt, + assistant_stop_suffix: &str, +) -> Result> + Send>>> { let mut events = vec![DecodedTextEvent::Start { - prompt_token_ids: Arc::from(prompt_token_ids.into_boxed_slice()), + prompt_token_ids: Arc::from(prompt_token_ids.clone().into_boxed_slice()), prompt_logprobs: None, }]; - let chunks = split_by_chars(completion_body, 7); + let chunks = match completion { + Prompt::Text(text) => { + let body = text.strip_suffix(assistant_stop_suffix).with_context(|| { + format!( + "closed assistant completion did not end with {:?}: {:?}", + assistant_stop_suffix, text + ) + })?; + split_by_chars(body, TEXT_COMPLETION_CHUNK_CHARS) + .into_iter() + .map(|delta| DecodedCompletionChunk { + delta, + token_ids: Vec::new(), // unused for text-level roundtrip cases + }) + .collect() + } + Prompt::TokenIds(token_ids) => { + ensure!( + assistant_stop_suffix.is_empty(), + "token-id roundtrip cases do not support text stop suffixes" + ); + incremental_decode_chunks( + tokenizer, + &prompt_token_ids, + token_ids, + TOKEN_COMPLETION_CHUNK_TOKENS, + )? + } + }; + if chunks.is_empty() { events.push({ DecodedTextEvent::TextDelta { @@ -483,8 +631,7 @@ fn decoded_completion_stream( token_ids: Vec::new(), logprobs: None, finished: Some(Finished { - prompt_token_count: 0, - output_token_count: 0, + usage: Default::default(), finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), @@ -494,21 +641,26 @@ fn decoded_completion_stream( let last_index = chunks.len() - 1; for (index, chunk) in chunks.into_iter().enumerate() { let finished = (index == last_index).then(|| Finished { - prompt_token_count, - output_token_count: completion_body.chars().count(), + usage: Default::default(), finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }); events.push(DecodedTextEvent::TextDelta { - delta: chunk, - token_ids: Vec::new(), + delta: chunk.delta, + token_ids: chunk.token_ids, logprobs: None, finished, }); } } - stream::iter(events).map(Ok).boxed() + Ok(stream::iter(events).map(Ok).boxed()) +} + +/// One decoded completion chunk fed into the output processor. +struct DecodedCompletionChunk { + delta: String, + token_ids: Vec, } /// Split text into chunks containing at most `chunk_chars` Unicode scalar @@ -534,6 +686,49 @@ fn split_by_chars(text: &str, chunk_chars: usize) -> Vec { chunks } +/// Split token ids into chunks containing at most `chunk_size` ids. +fn split_by_count(token_ids: &[u32], chunk_size: usize) -> Vec> { + token_ids.chunks(chunk_size).map(<[u32]>::to_vec).collect() +} + +/// Decode token ids incrementally using the production tokenizer stream. +fn incremental_decode_chunks( + tokenizer: &dyn Tokenizer, + prompt_token_ids: &[u32], + token_ids: &[u32], + chunk_size: usize, +) -> Result> { + let mut decoder = tokenizer.create_decode_stream(prompt_token_ids, false, 0); + let mut chunks = Vec::new(); + for chunk_token_ids in split_by_count(token_ids, chunk_size) { + let mut delta = String::new(); + for token_id in chunk_token_ids.iter().copied() { + decoder.push_token(token_id)?; + while let Some(chunk) = decoder.next_chunk() { + delta.push_str(&chunk); + } + } + chunks.push(DecodedCompletionChunk { + delta, + token_ids: chunk_token_ids, + }); + } + + let (last_chunk, _) = decoder.flush(None)?; + if let Some(last_chunk) = last_chunk { + if let Some(delta) = chunks.last_mut() { + delta.delta.push_str(&last_chunk); + } else { + chunks.push(DecodedCompletionChunk { + delta: last_chunk, + token_ids: Vec::new(), + }); + } + } + + Ok(chunks) +} + /// Build a chat request fixture with parser-enabling tool-choice semantics. fn roundtrip_request( request_id: impl Into, diff --git a/rust/src/chat/tests/templates/vllm_examples/template_baichuan.jinja b/rust/src/chat/tests/templates/vllm_examples/template_baichuan.jinja deleted file mode 100644 index 42a8d9270a4c..000000000000 --- a/rust/src/chat/tests/templates/vllm_examples/template_baichuan.jinja +++ /dev/null @@ -1,13 +0,0 @@ -{{ (messages|selectattr('role', 'equalto', 'system')|list|last).content|trim if (messages|selectattr('role', 'equalto', 'system')|list) else '' }} - -{%- for message in messages -%} - {%- if message['role'] == 'user' -%} - {{- '' + message['content'] -}} - {%- elif message['role'] == 'assistant' -%} - {{- '' + message['content'] -}} - {%- endif -%} -{%- endfor -%} - -{%- if add_generation_prompt and messages[-1]['role'] != 'assistant' -%} - {{- '' -}} -{% endif %} \ No newline at end of file diff --git a/rust/src/chat/tests/templates/vllm_examples/tool_chat_template_minimax_m1.jinja b/rust/src/chat/tests/templates/vllm_examples/tool_chat_template_minimax_m1.jinja deleted file mode 100644 index 2d5bbf4de56f..000000000000 --- a/rust/src/chat/tests/templates/vllm_examples/tool_chat_template_minimax_m1.jinja +++ /dev/null @@ -1,91 +0,0 @@ -{{ '' -}} -{%- if custom_tools is defined %} - {%- set tools = custom_tools %} -{%- endif %} -{%- if not tools is defined %} - {%- set tools = none %} -{%- endif %} - -{#- Extract system message #} -{% set ns = namespace(system_prompt='') -%} -{%- if messages[0]['role'] == 'system' %} - {%- if messages[0]['content'] is string %} - {%- set ns.system_prompt = messages[0]['content']|trim %} - {%- else %} - {%- set ns.system_prompt = messages[0]['content'][0]['text']|trim %} - {%- endif %} - {%- set messages = messages[1:] %} -{%- else %} - {%- if tools is not none %} - {%- set ns.system_prompt = "You are a helpful assistant created by Minimax based on MiniMax-M1 model." %} - {%- else %} - {%- set ns.system_prompt = "You are a helpful assistant created by Minimax based on MiniMax-M1 model." %} - {%- endif %} -{%- endif %} - -{#- System message #} -{%- if ns.system_prompt != '' %} -{{ 'system ai_setting=assistant\n' + ns.system_prompt + '\n' -}} -{%- endif %} - -{#- Tools configuration #} -{%- if tools is not none %} -{{ 'system tool_setting=tools\nYou are provided with these tools:\n\n' -}} -{%- for tool in tools %} -{{ tool | tojson ~ '\n' -}} -{%- endfor %} -{{ '\n\nIf you need to call tools, please respond with XML tags, and provide tool-name and json-object of arguments, following the format below:\n\n{"name": , "arguments": }\n...\n\n' -}} -{%- endif %} - -{#- Process messages #} -{%- for message in messages %} - {%- if not (message.role == 'ipython' or message.role == 'tool' or 'tool_calls' in message) %} - {%- if message['role'] == 'user' %} -{{ 'user name=user\n' -}} -{%- if message['content'] is string %} -{{ message['content']|trim -}} -{%- else %} -{%- for content in message['content'] %} -{%- if content['type'] == 'text' %} -{{ content['text']|trim -}} -{%- endif %} -{%- endfor %} -{%- endif %} -{{ '\n' -}} - {%- elif message['role'] == 'assistant' %} -{{ 'ai name=assistant\n' -}} -{%- if message['content'] is string %} -{{ message['content']|trim -}} -{%- else %} -{%- for content in message['content'] | selectattr('type', 'equalto', 'text') %} -{{ content['text']|trim -}} -{%- endfor %} -{%- endif %} -{{ '\n' -}} - {%- endif %} - {%- elif 'tool_calls' in message %} -{{ 'ai name=assistant\n\n' -}} -{%- for tool_call in message.tool_calls %} -{{ '{"name": "' + tool_call.function.name + '", "arguments": ' + tool_call.function.arguments | tojson + '}\n' -}} -{%- endfor %} -{{ '\n' -}} - {%- elif message.role == "tool" or message.role == "ipython" %} -{{ 'tool name=tools\n' -}} -{%- if message.content is string %} -{{ 'tool result: ' + message.content + '\n\n' -}} -{%- else %} -{%- for content in message['content'] %} -{%- if content['type'] == 'text' %} -{{ 'tool result: ' + content['text'] + '\n\n' -}} -{%- elif content.get('name') %} -{{ 'tool name: ' + content['name'] + '\ntool result: ' + content['text'] + '\n\n' -}} -{%- endif %} -{%- endfor %} -{%- endif %} -{{ '\n' -}} - {%- endif %} -{%- endfor %} - -{%- if add_generation_prompt %} -{{ 'ai name=assistant\n' -}} -{%- endif %} \ No newline at end of file diff --git a/rust/src/cmd/Cargo.toml b/rust/src/cmd/Cargo.toml index b0caa65b4e8c..030d4c6d1164 100644 --- a/rust/src/cmd/Cargo.toml +++ b/rust/src/cmd/Cargo.toml @@ -29,6 +29,7 @@ tokio-util.workspace = true tracing.workspace = true tracing-subscriber.workspace = true uuid.workspace = true +vllm-chat.workspace = true vllm-engine-core-client.workspace = true vllm-managed-engine.workspace = true vllm-server.workspace = true diff --git a/rust/src/cmd/src/cli.rs b/rust/src/cmd/src/cli.rs index ee7848fe0be5..3384fb9c67a4 100644 --- a/rust/src/cmd/src/cli.rs +++ b/rust/src/cmd/src/cli.rs @@ -13,17 +13,19 @@ use std::time::Duration; use clap::{Args, Parser, Subcommand}; use educe::Educe; -use serde::Deserialize; use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use serde_json::Value; +use serde_with::{DefaultOnNull, OneOrMany, serde_as}; use thiserror_ext::AsReport as _; use uuid::Uuid; +use vllm_chat::ReasoningParserFactory; use vllm_engine_core_client::TransportMode; use vllm_managed_engine::ManagedEngineConfig; use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args}; use vllm_server::{ - ChatTemplateContentFormatOption, Config, CoordinatorMode, HttpListenerMode, ParserSelection, - RendererSelection, + ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig, + DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, ParserSelection, RendererSelection, TlsConfig, }; use crate::cli::unsupported::UnsupportedArgs; @@ -83,7 +85,20 @@ pub enum Command { Serve(ServeArgs), } -/// Runtime arguments shared by the external-engine and managed-engine paths. +/// A JSON-encoded list of strings, matching Python's `json.loads` CLI type for +/// the CORS list arguments (e.g. `--allowed-origins '["*"]'`). Parsing the whole +/// value as one item keeps clap from treating the field as a repeated flag. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(transparent)] +pub struct JsonStringList(pub Vec); + +/// Runtime arguments shared by both paths of the Rust frontend: +/// +/// - External-engine mode: Python-supervised bootstrap, `vllm serve` -> `vllm-rs frontend`. +/// Arguments are deserialized from a single JSON object and defaults follow `serde` attrs. +/// - Managed-engine mode: Rust-managed Python engine, `vllm-rs serve`. +/// Arguments are parsed from CLI flags and defaults follow `clap` attrs. +#[serde_as] #[derive(Educe, Clone, Args, PartialEq, Eq, Deserialize)] #[educe(Debug)] pub struct SharedRuntimeArgs { @@ -105,22 +120,31 @@ pub struct SharedRuntimeArgs { /// Select the tool call parser depending on the model that you're using. /// Use `auto` to infer from the model or `none` to disable parsing. #[arg(long, default_value_t)] - #[serde(default)] + #[serde(default = "default_py_bootstrap_parser_selection")] pub tool_call_parser: ParserSelection, /// Select the reasoning parser depending on the model that you're using. /// Use `auto` to infer from the model or `none` to disable parsing. #[arg(long, default_value_t)] - #[serde(default)] + #[serde(default = "default_py_bootstrap_parser_selection")] pub reasoning_parser: ParserSelection, /// Select the chat renderer implementation. #[arg(long = "tokenizer-mode", default_value_t)] #[serde(default, rename = "tokenizer_mode")] pub renderer: RendererSelection, + /// Disable multimodal inputs and treat the model as language-only. + #[arg(long)] + #[serde(default)] + pub language_model_only: bool, /// Override the maximum model context length. When set, the frontend uses /// this value instead of the model's `max_position_embeddings` from /// `config.json`. #[arg(long)] pub max_model_len: Option, + /// Maximum number of log probabilities to return when `logprobs` is + /// specified in sampling parameters. `-1` means no cap. + #[arg(long, value_parser = clap::value_parser!(i32).range(-1..), allow_negative_numbers = true)] + #[serde(default)] + pub max_logprobs: Option, /// TCP port for the gRPC Generate service. When not set, no gRPC server is /// started. #[arg(long)] @@ -130,6 +154,11 @@ pub struct SharedRuntimeArgs { #[arg(long, default_value_t = 0)] #[serde(default)] pub shutdown_timeout: u64, + /// Maximum idle time (seconds) on a keep-alive HTTP connection before the + /// server closes it (default 5). + #[arg(long = "http-timeout-keep-alive", env = "VLLM_HTTP_TIMEOUT_KEEP_ALIVE")] + #[serde(default)] + pub http_timeout_keep_alive: Option, /// The file path to the chat template, or the template in single-line form /// for the specified model. @@ -165,6 +194,16 @@ pub struct SharedRuntimeArgs { #[serde(default)] pub enable_log_requests: bool, + /// Include prompt_tokens_details in usage when cached prompt tokens are + /// present. + #[arg( + long, + default_missing_value = "true", + num_args = 0..=1 + )] + #[serde(default)] + pub enable_prompt_tokens_details: bool, + /// If specified, API server will add X-Request-Id header to responses. #[arg( long, @@ -174,6 +213,14 @@ pub struct SharedRuntimeArgs { #[serde(default)] pub enable_request_id_headers: bool, + /// If provided, the server will require one of these keys to be presented + /// in the Authorization header. + #[educe(Debug(ignore))] + #[arg(long, env = "VLLM_API_KEY", value_delimiter = ' ')] + #[serde_as(as = "DefaultOnNull>")] + #[serde(default)] + pub api_key: Vec, + /// Disable periodic logging of engine statistics (throughput, queue depth, /// cache usage). #[arg(long)] @@ -191,6 +238,67 @@ pub struct SharedRuntimeArgs { #[serde(default)] pub served_model_name: Vec, + /// CORS allowed origins as a JSON list. `["*"]` allows any origin. + #[arg(long, value_parser = parse_json::, value_name = "JSON", default_value = r#"["*"]"#)] + #[serde(default = "default_cors_wildcard")] + pub allowed_origins: JsonStringList, + + /// CORS allowed methods as a JSON list. `["*"]` allows the standard set. + #[arg(long, value_parser = parse_json::, value_name = "JSON", default_value = r#"["*"]"#)] + #[serde(default = "default_cors_wildcard")] + pub allowed_methods: JsonStringList, + + /// CORS allowed request headers as a JSON list. `["*"]` mirrors the request. + #[arg(long, value_parser = parse_json::, value_name = "JSON", default_value = r#"["*"]"#)] + #[serde(default = "default_cors_wildcard")] + pub allowed_headers: JsonStringList, + + /// Allow CORS credentials (cookies, authorization headers). + #[arg( + long, + default_missing_value = "true", + num_args = 0..=1 + )] + #[serde(default)] + pub allow_credentials: bool, + + /// The file path to the SSL key file. When omitted, the key is read from + /// `--ssl-certfile` (combined PEM). + #[arg(long)] + #[serde(default)] + pub ssl_keyfile: Option, + + /// The file path to the SSL cert file. Enables TLS when set. + #[arg(long)] + #[serde(default)] + pub ssl_certfile: Option, + + /// The CA certificates file used to verify client certificates (mTLS). + #[arg(long)] + #[serde(default)] + pub ssl_ca_certs: Option, + + /// Whether a client certificate is required: 0 = none, 1 = optional, + /// 2 = required (mirrors Python's `ssl.CERT_*`). + #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(i32).range(0..=2))] + #[serde(default)] + pub ssl_cert_reqs: i32, + + /// OpenSSL cipher string for HTTPS (TLS 1.2 and below). + /// When unset, the linked OpenSSL's default suites are used. + #[arg(long)] + #[serde(default)] + pub ssl_ciphers: Option, + + /// Profiler configuration forwarded by the Python supervisor. + /// + /// When set with a non-null `profiler` type, the Rust frontend registers + /// the `/start_profile` and `/stop_profile` routes and forwards calls to + /// the engine via the `"profile"` utility RPC. + #[arg(long, value_parser = parse_json::, value_name = "JSON")] + #[serde(default)] + pub profiler_config: Option, + /// Unsupported Python vLLM frontend arguments recognized but not yet /// implemented in Rust. #[educe(Debug(ignore))] @@ -211,6 +319,36 @@ impl SharedRuntimeArgs { Duration::from_secs(self.shutdown_timeout) } + /// Maximum idle time on a keep-alive HTTP connection before the server + /// closes it. + pub fn keep_alive_timeout(&self) -> Duration { + self.http_timeout_keep_alive + .map_or(DEFAULT_KEEP_ALIVE_TIMEOUT, Duration::from_secs) + } + + /// Return the configured profiler mode, when profiling is enabled. + pub fn profiler(&self) -> Option { + self.profiler_config.as_ref().and_then(|c| c.profiler.clone()) + } + + /// Return the profiler config JSON for managed Python engine forwarding. + pub fn profiler_config_json(&self) -> Option { + self.profiler_config + .as_ref() + .map(serde_json::to_string) + .transpose() + .expect("profiler config serialization should not fail") + } + + /// Apply fallback logic for API key configuration from env variables. + fn apply_env_api_key_fallback(&mut self) { + if self.api_key.is_empty() + && let Ok(api_key) = std::env::var("VLLM_API_KEY") + { + self.api_key.push(api_key); + } + } + /// Build the OpenAI-server config for the Python-bootstrap worker contract. /// /// The resulting config binds the Python-supplied transport addresses and @@ -221,15 +359,22 @@ impl SharedRuntimeArgs { input_address: String, output_address: String, coordinator_address: Option, + engine_start_index: u32, engine_count: usize, ) -> Config { let ready_timeout = self.ready_timeout(); let shutdown_timeout = self.shutdown_timeout(); + let keep_alive_timeout = self.keep_alive_timeout(); + let api_server_options = self.api_server_options(); + let cors = self.cors_config(); + let tls = self.tls_config(); + let profiler = self.profiler(); Config { transport_mode: TransportMode::Bootstrapped { input_address, output_address, + engine_start_index, engine_count, ready_timeout, }, @@ -243,14 +388,20 @@ impl SharedRuntimeArgs { tool_call_parser: self.tool_call_parser, reasoning_parser: self.reasoning_parser, renderer: self.renderer, + language_model_only: self.language_model_only, chat_template: self.chat_template, default_chat_template_kwargs: self.default_chat_template_kwargs, chat_template_content_format: self.chat_template_content_format, - enable_log_requests: self.enable_log_requests, - enable_request_id_headers: self.enable_request_id_headers, + max_logprobs: self.max_logprobs, + api_server_options, + cors, + tls, + api_keys: self.api_key, disable_log_stats: self.disable_log_stats, grpc_port: self.grpc_port, shutdown_timeout, + keep_alive_timeout, + profiler, } } @@ -267,6 +418,11 @@ impl SharedRuntimeArgs { ) -> Config { let ready_timeout = self.ready_timeout(); let shutdown_timeout = self.shutdown_timeout(); + let keep_alive_timeout = self.keep_alive_timeout(); + let api_server_options = self.api_server_options(); + let cors = self.cors_config(); + let tls = self.tls_config(); + let profiler = self.profiler(); Config { transport_mode: TransportMode::HandshakeOwner { @@ -284,29 +440,97 @@ impl SharedRuntimeArgs { tool_call_parser: self.tool_call_parser, reasoning_parser: self.reasoning_parser, renderer: self.renderer, + language_model_only: self.language_model_only, chat_template: self.chat_template, default_chat_template_kwargs: self.default_chat_template_kwargs, chat_template_content_format: self.chat_template_content_format, - enable_log_requests: self.enable_log_requests, - enable_request_id_headers: self.enable_request_id_headers, + max_logprobs: self.max_logprobs, + api_server_options, + cors, + tls, + api_keys: self.api_key, disable_log_stats: self.disable_log_stats, grpc_port: self.grpc_port, shutdown_timeout, + keep_alive_timeout, + profiler, } } + + fn api_server_options(&self) -> ApiServerOptions { + ApiServerOptions { + enable_log_requests: self.enable_log_requests, + enable_prompt_tokens_details: self.enable_prompt_tokens_details, + enable_request_id_headers: self.enable_request_id_headers, + } + } + + fn cors_config(&self) -> CorsConfig { + CorsConfig { + allow_origins: self.allowed_origins.0.clone(), + allow_methods: self.allowed_methods.0.clone(), + allow_headers: self.allowed_headers.0.clone(), + allow_credentials: self.allow_credentials, + } + } + + /// Build the TLS config: `Some` when any `ssl_*` argument is set, else + /// `None` (plaintext). The combination is validated in [`Config::validate`]. + fn tls_config(&self) -> Option { + let tls_requested = self.ssl_certfile.is_some() + || self.ssl_keyfile.is_some() + || self.ssl_ca_certs.is_some() + || self.ssl_cert_reqs != 0 + || self.ssl_ciphers.is_some(); + tls_requested.then(|| TlsConfig { + cert_file: self.ssl_certfile.clone(), + key_file: self.ssl_keyfile.clone(), + ca_certs: self.ssl_ca_certs.clone(), + cert_reqs: self.ssl_cert_reqs, + ciphers: self.ssl_ciphers.clone(), + }) + } } fn default_engine_ready_timeout_secs() -> u64 { 600 } +fn default_cors_wildcard() -> JsonStringList { + JsonStringList(vec!["*".to_string()]) +} + +fn default_py_bootstrap_parser_selection() -> ParserSelection { + ParserSelection::None +} + +/// Minimal profiler configuration parsed from `--profiler-config`. +/// +/// Only the `profiler` field is inspected by the Rust frontend to decide +/// whether to register the `/start_profile` and `/stop_profile` routes. +/// All other fields are accepted but ignored — they are consumed by the +/// Python engine layer. +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProfilerConfig { + /// Profiler backend type (e.g. `"torch"`, `"cuda"`). When `null` or + /// absent, profiling is disabled. + #[serde(default)] + pub profiler: Option, + /// Additional Python profiler config fields consumed by the engine layer. + #[serde(flatten)] + pub extra: serde_json::Map, +} + fn parse_json(value: &str) -> Result { serde_json::from_str(value).map_err(|e| format!("invalid JSON object: {}", e.as_report())) } fn parse_runtime_args_json(value: &str) -> Result { - let args: SharedRuntimeArgs = serde_json::from_str(value) + let mut args: SharedRuntimeArgs = serde_json::from_str(value) .map_err(|e| format!("invalid JSON arguments: {}", e.as_report()))?; + // --args-json is parsed with serde, so clap's env support does not run for + // the Python-supervised frontend path. + args.apply_env_api_key_fallback(); args.unsupported.check()?; Ok(args) } @@ -332,6 +556,10 @@ pub struct FrontendArgs { /// `stats_update_address`. #[arg(long)] pub coordinator_address: Option, + /// First data-parallel engine rank expected to register with this + /// bootstrapped frontend. + #[arg(long, default_value_t = 0)] + pub engine_start_index: u32, /// Total number of data-parallel engines expected for this frontend. #[arg(long, default_value_t = 1)] pub engine_count: usize, @@ -349,6 +577,7 @@ impl FrontendArgs { self.input_address, self.output_address, self.coordinator_address, + self.engine_start_index, self.engine_count, ) } @@ -416,14 +645,34 @@ impl ServeArgs { /// Build the managed Python-engine spawn configuration with the given /// handshake port. pub fn to_managed_engine_config(&self, handshake_port: u16) -> ManagedEngineConfig { + let reasoning_parser = + effective_engine_reasoning_parser(&self.runtime.reasoning_parser, &self.runtime.model); + let profiler_config = self.runtime.profiler_config_json(); + self.managed_engine.clone().into_config( self.runtime.model.clone(), self.runtime.max_model_len, + self.runtime.max_logprobs, + profiler_config, + reasoning_parser.as_deref(), + self.runtime.language_model_only, + self.runtime.disable_log_stats, + self.runtime.shutdown_timeout, handshake_port, ) } } +fn effective_engine_reasoning_parser(selection: &ParserSelection, model: &str) -> Option { + match selection { + ParserSelection::Auto => ReasoningParserFactory::global() + .resolve_name_for_model(model) + .map(str::to_string), + ParserSelection::None => None, + ParserSelection::Explicit(name) => Some(name.clone()), + } +} + /// Allocate fresh IPC endpoints for one managed frontend instance. fn frontend_ipc_addresses() -> (String, String) { let preferred_base_path = std::env::var_os("VLLM_RPC_BASE_PATH") diff --git a/rust/src/cmd/src/cli/tests.rs b/rust/src/cmd/src/cli/tests.rs index ea867e4673ae..8056ee9392e6 100644 --- a/rust/src/cmd/src/cli/tests.rs +++ b/rust/src/cmd/src/cli/tests.rs @@ -34,18 +34,44 @@ fn serve_args_forward_python_flags_with_separator() { tool_call_parser: Auto, reasoning_parser: Auto, renderer: Auto, + language_model_only: false, max_model_len: Some( 512, ), + max_logprobs: None, grpc_port: None, shutdown_timeout: 0, + http_timeout_keep_alive: None, chat_template: None, default_chat_template_kwargs: None, chat_template_content_format: Auto, enable_log_requests: false, + enable_prompt_tokens_details: false, enable_request_id_headers: false, disable_log_stats: false, served_model_name: [], + allowed_origins: JsonStringList( + [ + "*", + ], + ), + allowed_methods: JsonStringList( + [ + "*", + ], + ), + allowed_headers: JsonStringList( + [ + "*", + ], + ), + allow_credentials: false, + ssl_keyfile: None, + ssl_certfile: None, + ssl_ca_certs: None, + ssl_cert_reqs: 0, + ssl_ciphers: None, + profiler_config: None, }, managed_engine: ManagedEngineArgs { python: "../vllm/.venv/bin/python", @@ -81,10 +107,13 @@ fn serve_args_auto_forward_python_flags_without_separator() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!( - args.managed_engine.python_args, - vec!["--quantization", "awq"] - ); + expect![[r#" + [ + "--quantization", + "awq", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] @@ -95,22 +124,235 @@ fn serve_args_auto_forward_enable_lora_to_python() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!(args.managed_engine.python_args, vec!["--enable-lora"]); + expect![[r#" + [ + "--enable-lora", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] -fn serve_args_auto_forward_python_multi_char_alias_without_separator() { - let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B", "-tp", "2"]).unwrap(); +fn serve_args_forward_shutdown_timeout_to_managed_engine() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--shutdown-timeout", + "60", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!(args.runtime.shutdown_timeout, 60); + + let config = args.to_managed_engine_config(5555); + expect![[r#" + [ + "--reasoning-parser", + "qwen3", + "--shutdown-timeout", + "60", + ] + "#]] + .assert_debug_eq(&config.python_args); +} + +#[test] +fn serve_args_forward_disable_log_stats_to_managed_engine() { + let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B", "--disable-log-stats"]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert!(args.runtime.disable_log_stats); + + let config = args.to_managed_engine_config(5555); + expect![[r#" + [ + "--reasoning-parser", + "qwen3", + "--disable-log-stats", + ] + "#]] + .assert_debug_eq(&config.python_args); +} + +#[test] +fn serve_args_forward_profiler_config_to_managed_engine() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--profiler-config", + r#"{"profiler":"torch","torch_profiler_dir":"/tmp/profile"}"#, + ]) + .unwrap(); let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; + assert_eq!(args.runtime.profiler().as_deref(), Some("torch")); + + let config = args.to_managed_engine_config(5555); + let profiler_flag_index = config + .python_args + .iter() + .position(|arg| arg == "--profiler-config") + .expect("profiler config flag"); + let profiler_config: serde_json::Value = + serde_json::from_str(&config.python_args[profiler_flag_index + 1]) + .expect("profiler config json"); assert_eq!( - args.managed_engine.python_args, - vec!["--tensor-parallel-size", "2"] + profiler_config, + serde_json::json!({ + "profiler": "torch", + "torch_profiler_dir": "/tmp/profile", + }) ); } +#[test] +fn serve_args_forward_max_logprobs_to_frontend_and_managed_engine() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--max-logprobs", + "-1", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!(args.runtime.max_logprobs, Some(-1)); + + let frontend_config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); + assert_eq!(frontend_config.max_logprobs, Some(-1)); + + let engine_config = args.to_managed_engine_config(5555); + expect![[r#" + [ + "--max-logprobs", + "-1", + "--reasoning-parser", + "qwen3", + ] + "#]] + .assert_debug_eq(&engine_config.python_args); +} + +#[test] +fn serve_args_resolve_auto_reasoning_parser_for_managed_engine() { + let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B"]).unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!(args.runtime.reasoning_parser, ParserSelection::Auto); + + let config = args.to_managed_engine_config(5555); + expect![[r#" + [ + "--reasoning-parser", + "qwen3", + ] + "#]] + .assert_debug_eq(&config.python_args); +} + +#[test] +fn serve_args_forward_explicit_reasoning_parser_to_managed_engine() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Unknown/Model", + "--reasoning-parser", + "deepseek_r1", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + + let config = args.to_managed_engine_config(5555); + expect![[r#" + [ + "--reasoning-parser", + "deepseek_r1", + ] + "#]] + .assert_debug_eq(&config.python_args); +} + +#[test] +fn serve_args_do_not_forward_disabled_reasoning_parser_to_managed_engine() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--reasoning-parser", + "none", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + + let config = args.to_managed_engine_config(5555); + assert!(config.python_args.is_empty()); +} + +#[test] +fn serve_args_forward_reasoning_parser_even_with_passthrough_reasoning_parser() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--", + "--reasoning-parser", + "deepseek_r1", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + + let config = args.to_managed_engine_config(5555); + expect![[r#" + [ + "--reasoning-parser", + "deepseek_r1", + "--reasoning-parser", + "qwen3", + ] + "#]] + .assert_debug_eq(&config.python_args); +} + +#[test] +fn serve_args_auto_forward_python_multi_char_alias_without_separator() { + let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B", "-tp", "2"]).unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + expect![[r#" + [ + "--tensor-parallel-size", + "2", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); +} + #[test] fn serve_args_accept_explicit_deepseek_v32_renderer() { let cli = Cli::try_parse_from([ @@ -142,7 +384,158 @@ fn serve_passes_enable_request_id_headers_into_config() { panic!("expected serve args"); }; let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); - assert!(config.enable_request_id_headers); + assert!(config.api_server_options.enable_request_id_headers); +} + +#[test] +fn serve_passes_enable_prompt_tokens_details_into_config() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--enable-prompt-tokens-details", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); + assert!(config.api_server_options.enable_prompt_tokens_details); +} + +#[test] +fn serve_passes_tls_into_config() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-certfile", + "/tmp/cert.pem", + "--ssl-keyfile", + "/tmp/key.pem", + "--ssl-ca-certs", + "/tmp/ca.pem", + "--ssl-cert-reqs", + "2", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); + let tls = config.tls.expect("tls configured"); + assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem")); + assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem")); + assert_eq!(tls.ca_certs.as_deref(), Some("/tmp/ca.pem")); + assert_eq!(tls.cert_reqs, 2); +} + +#[test] +fn serve_without_ssl_flags_has_no_tls() { + let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B"]).unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); + assert!(config.tls.is_none()); +} + +#[test] +fn serve_ssl_keyfile_without_certfile_fails_validation() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-keyfile", + "/tmp/key.pem", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); + // TLS is requested (a key was given) but there is no certificate, so + // validation fails loud rather than silently serving plaintext. + assert_eq!(config.tls.as_ref().expect("tls requested").cert_file, None); + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("--ssl-certfile is required"), "{err}"); +} + +#[test] +fn serve_mtls_without_ca_certs_fails_validation() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--ssl-certfile", + "/tmp/cert.pem", + "--ssl-cert-reqs", + "2", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); + // Client-cert verification without a CA bundle has nothing to verify + // against, so it fails loud at startup. + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("--ssl-ca-certs is required"), "{err}"); +} + +#[test] +fn frontend_args_json_passes_tls_into_config() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","ssl_certfile":"/tmp/cert.pem","ssl_keyfile":"/tmp/key.pem"}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + let config = args.into_config(); + let tls = config.tls.expect("tls configured"); + assert_eq!(tls.cert_file.as_deref(), Some("/tmp/cert.pem")); + assert_eq!(tls.key_file.as_deref(), Some("/tmp/key.pem")); +} + +#[test] +fn frontend_args_json_rejects_out_of_range_cert_reqs() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","ssl_certfile":"/tmp/cert.pem","ssl_cert_reqs":5}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + // The JSON path bypasses clap's range check, so validate() is the only guard. + let config = args.into_config(); + let err = config.validate().unwrap_err().to_string(); + assert!(err.contains("--ssl-cert-reqs"), "{err}"); } #[test] @@ -165,7 +558,77 @@ fn frontend_args_json_passes_enable_request_id_headers_into_config() { panic!("expected frontend args"); }; let config = args.into_config(); - assert!(config.enable_request_id_headers); + assert!(config.api_server_options.enable_request_id_headers); +} + +#[test] +fn serve_passes_api_keys_into_config() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--api-key", + "secret-a", + "--api-key", + "secret-b", + ]) + .unwrap(); + + let Command::Serve(args) = cli.command else { + panic!("expected serve args"); + }; + let config = args.to_frontend_config("tcp://127.0.0.1:62100".to_string()); + assert_eq!(config.api_keys, vec!["secret-a", "secret-b"]); + let debug = format!("{config:#?}"); + assert!(debug.contains("api_keys: [; 2]")); + assert!(!debug.contains("secret-a")); + assert!(!debug.contains("secret-b")); +} + +#[test] +fn frontend_args_json_accepts_api_key_string() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","api_key":"secret"}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + let config = args.into_config(); + assert_eq!(config.api_keys, vec!["secret"]); +} + +#[test] +fn frontend_args_json_accepts_api_key_list() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","api_key":["secret-a","secret-b"]}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + let config = args.into_config(); + assert_eq!(config.api_keys, vec!["secret-a", "secret-b"]); } #[test] @@ -180,7 +643,7 @@ fn serve_args_reject_unknown_renderer_value() { .unwrap_err(); expect![[r#" - error: invalid value 'definitely_missing' for '--tokenizer-mode ': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4) + error: invalid value 'definitely_missing' for '--tokenizer-mode ': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony) For more information, try '--help'. "#]] @@ -189,11 +652,17 @@ fn serve_args_reject_unknown_renderer_value() { #[test] fn serve_args_reject_unsupported_flag_arg() { - let error = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B", "--allow-credentials"]) - .unwrap_err(); + let error = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--root-path", + "/prefix", + ]) + .unwrap_err(); expect![[r#" - error: invalid value 'true' for '--allow-credentials []': argument is not implemented in Rust frontend yet + error: invalid value '/prefix' for '--root-path ': argument is not implemented in Rust frontend yet Remove this unsupported argument to continue. @@ -201,8 +670,7 @@ fn serve_args_reject_unsupported_flag_arg() { This may lead to unexpected behavior as the Rust frontend will completely ignore that argument. For more information, try '--help'. - "#]] - .assert_eq(&error.to_string()); + "#]].assert_eq(&error.to_string()); } #[test] @@ -256,23 +724,50 @@ fn frontend_args_accept_json() { coordinator_address: Some( "tcp://127.0.0.1:7000", ), + engine_start_index: 0, engine_count: 1, runtime: SharedRuntimeArgs { model: "Qwen/Qwen3-0.6B", engine_ready_timeout_secs: 600, - tool_call_parser: Auto, - reasoning_parser: Auto, + tool_call_parser: None, + reasoning_parser: None, renderer: Auto, + language_model_only: false, max_model_len: None, + max_logprobs: None, grpc_port: None, shutdown_timeout: 0, + http_timeout_keep_alive: None, chat_template: None, default_chat_template_kwargs: None, chat_template_content_format: Auto, enable_log_requests: false, + enable_prompt_tokens_details: false, enable_request_id_headers: false, disable_log_stats: false, served_model_name: [], + allowed_origins: JsonStringList( + [ + "*", + ], + ), + allowed_methods: JsonStringList( + [ + "*", + ], + ), + allowed_headers: JsonStringList( + [ + "*", + ], + ), + allow_credentials: false, + ssl_keyfile: None, + ssl_certfile: None, + ssl_ca_certs: None, + ssl_cert_reqs: 0, + ssl_ciphers: None, + profiler_config: None, }, }, ), @@ -302,10 +797,11 @@ fn frontend_args_json_applies_defaults() { }; assert_eq!(args.runtime.model, "Qwen/Qwen3-0.6B"); assert_eq!(args.runtime.engine_ready_timeout_secs, 600); - assert_eq!(args.runtime.tool_call_parser, ParserSelection::Auto); - assert_eq!(args.runtime.reasoning_parser, ParserSelection::Auto); + assert_eq!(args.runtime.tool_call_parser, ParserSelection::None); + assert_eq!(args.runtime.reasoning_parser, ParserSelection::None); assert_eq!(args.runtime.renderer, RendererSelection::Auto); assert_eq!(args.runtime.max_model_len, None); + assert_eq!(args.runtime.max_logprobs, None); assert_eq!(args.runtime.shutdown_timeout, 0); } @@ -321,7 +817,7 @@ fn frontend_args_json_accepts_supported_non_default_fields() { "--output-address", "ipc:///tmp/output.sock", "--args-json", - r#"{"model_tag":"Qwen/Qwen3-0.6B","engine_ready_timeout_secs":42,"tool_call_parser":"hermes","reasoning_parser":"qwen3_thinking","tokenizer_mode":"deepseek_v32","max_model_len":8192,"shutdown_timeout":3}"#, + r#"{"model_tag":"Qwen/Qwen3-0.6B","engine_ready_timeout_secs":42,"tool_call_parser":"hermes","reasoning_parser":"qwen3_thinking","tokenizer_mode":"deepseek_v32","language_model_only":true,"max_model_len":8192,"max_logprobs":-1,"shutdown_timeout":3}"#, ]) .unwrap(); @@ -338,7 +834,9 @@ fn frontend_args_json_accepts_supported_non_default_fields() { ParserSelection::Explicit("qwen3_thinking".to_string()) ); assert_eq!(args.runtime.renderer, RendererSelection::DeepSeekV32); + assert!(args.runtime.language_model_only); assert_eq!(args.runtime.max_model_len, Some(8192)); + assert_eq!(args.runtime.max_logprobs, Some(-1)); assert_eq!(args.runtime.shutdown_timeout, 3); } @@ -383,7 +881,7 @@ fn frontend_args_json_ignores_unknown_fields() { } #[test] -fn frontend_args_json_accepts_noop_fields() { +fn frontend_args_json_sets_prompt_tokens_details_flag() { let cli = Cli::try_parse_from([ "vllm-rs", "frontend", @@ -394,7 +892,7 @@ fn frontend_args_json_accepts_noop_fields() { "--output-address", "ipc:///tmp/output.sock", "--args-json", - r#"{"model_tag":"Qwen/Qwen3-0.6B","api_server_count":2}"#, + r#"{"model_tag":"Qwen/Qwen3-0.6B","api_server_count":2,"enable_prompt_tokens_details":true}"#, ]) .unwrap(); @@ -402,6 +900,72 @@ fn frontend_args_json_accepts_noop_fields() { panic!("expected frontend args"); }; assert_eq!(args.runtime.model, "Qwen/Qwen3-0.6B"); + assert!(args.runtime.enable_prompt_tokens_details); +} + +#[test] +fn serve_args_parse_cors_flags() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "serve", + "Qwen/Qwen3-0.6B", + "--allowed-origins", + r#"["http://a.com","http://b.com"]"#, + "--allowed-methods", + r#"["GET","POST"]"#, + "--allow-credentials", + ]) + .unwrap(); + + let Command::Serve(serve) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!( + serve.runtime.allowed_origins.0, + ["http://a.com", "http://b.com"] + ); + assert_eq!(serve.runtime.allowed_methods.0, ["GET", "POST"]); + assert!(serve.runtime.allow_credentials); + // Unspecified lists keep the permissive default. + assert_eq!(serve.runtime.allowed_headers.0, ["*"]); +} + +#[test] +fn serve_args_cors_defaults_are_permissive() { + let cli = Cli::try_parse_from(["vllm-rs", "serve", "Qwen/Qwen3-0.6B"]).unwrap(); + + let Command::Serve(serve) = cli.command else { + panic!("expected serve args"); + }; + assert_eq!(serve.runtime.allowed_origins.0, ["*"]); + assert_eq!(serve.runtime.allowed_methods.0, ["*"]); + assert_eq!(serve.runtime.allowed_headers.0, ["*"]); + assert!(!serve.runtime.allow_credentials); +} + +#[test] +fn frontend_args_json_parses_cors_fields() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","allowed_origins":["http://a.com"],"allow_credentials":true}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + assert_eq!(args.runtime.allowed_origins.0, ["http://a.com"]); + assert!(args.runtime.allow_credentials); + // Unspecified lists fall back to the permissive default via serde. + assert_eq!(args.runtime.allowed_methods.0, ["*"]); } #[test] @@ -416,14 +980,14 @@ fn frontend_args_json_rejects_unsupported_fields() { "--output-address", "ipc:///tmp/output.sock", "--args-json", - r#"{"model_tag":"Qwen/Qwen3-0.6B","allow_credentials":true}"#, + r#"{"model_tag":"Qwen/Qwen3-0.6B","root_path":"/prefix"}"#, ]) .unwrap_err(); expect![[r#" - error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","allow_credentials":true}' for '--args-json ': + error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","root_path":"/prefix"}' for '--args-json ': The following arguments are not implemented in Rust frontend yet: - - allow_credentials + - root_path Remove these arguments to continue. @@ -443,20 +1007,21 @@ fn frontend_args_json_aggregates_multiple_unsupported_fields() { "--output-address", "ipc:///tmp/output.sock", "--args-json", - r#"{"model_tag":"Qwen/Qwen3-0.6B","allow_credentials":true,"api_key":"secret"}"#, + r#"{"model_tag":"Qwen/Qwen3-0.6B","response_role":"assistant","root_path":"/prefix"}"#, ]) .unwrap_err(); + let actual = error.to_string().replace(": \n", ":\n"); expect![[r#" - error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","allow_credentials":true,"api_key":"secret"}' for '--args-json ': + error: invalid value '{"model_tag":"Qwen/Qwen3-0.6B","response_role":"assistant","root_path":"/prefix"}' for '--args-json ': The following arguments are not implemented in Rust frontend yet: - - allow_credentials - - api_key + - response_role + - root_path Remove these arguments to continue. For more information, try '--help'. - "#]].assert_eq(&error.to_string()); + "#]].assert_eq(&actual); } #[test] @@ -526,10 +1091,15 @@ fn serve_args_keep_python_passthrough_flags_after_separator() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!( - args.managed_engine.python_args, - vec!["--tensor-parallel-size", "2", "--dtype", "float16"] - ); + expect![[r#" + [ + "--tensor-parallel-size", + "2", + "--dtype", + "float16", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] @@ -551,10 +1121,15 @@ fn serve_args_keep_python_multi_char_alias_after_separator() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!( - args.managed_engine.python_args, - vec!["-tp", "2", "--dtype", "float16"] - ); + expect![[r#" + [ + "-tp", + "2", + "--dtype", + "float16", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] @@ -572,10 +1147,13 @@ fn serve_args_keep_frontend_arg_after_separator() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!( - args.managed_engine.python_args, - vec!["--uds", "/tmp/vllm.sock"] - ); + expect![[r#" + [ + "--uds", + "/tmp/vllm.sock", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] @@ -595,10 +1173,15 @@ fn serve_args_keep_python_multi_char_engine_aliases_after_separator() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!( - args.managed_engine.python_args, - vec!["-dpr", "1", "-dpl", "2"] - ); + expect![[r#" + [ + "-dpr", + "1", + "-dpl", + "2", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] @@ -608,7 +1191,13 @@ fn serve_args_auto_forward_unknown_flags_without_separator() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!(args.managed_engine.python_args, vec!["--foo", "bar"]); + expect![[r#" + [ + "--foo", + "bar", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] @@ -625,10 +1214,13 @@ fn serve_args_auto_forward_negative_value_without_separator() { let Command::Serve(args) = cli.command else { panic!("expected serve args"); }; - assert_eq!( - args.managed_engine.python_args, - vec!["--num-gpu-blocks-override", "-1"] - ); + expect![[r#" + [ + "--num-gpu-blocks-override", + "-1", + ] + "#]] + .assert_debug_eq(&args.managed_engine.python_args); } #[test] @@ -662,16 +1254,42 @@ fn serve_args_accept_handshake_aliases() { tool_call_parser: Auto, reasoning_parser: Auto, renderer: Auto, + language_model_only: false, max_model_len: None, + max_logprobs: None, grpc_port: None, shutdown_timeout: 0, + http_timeout_keep_alive: None, chat_template: None, default_chat_template_kwargs: None, chat_template_content_format: Auto, enable_log_requests: false, + enable_prompt_tokens_details: false, enable_request_id_headers: false, disable_log_stats: false, served_model_name: [], + allowed_origins: JsonStringList( + [ + "*", + ], + ), + allowed_methods: JsonStringList( + [ + "*", + ], + ), + allowed_headers: JsonStringList( + [ + "*", + ], + ), + allow_credentials: false, + ssl_keyfile: None, + ssl_certfile: None, + ssl_ca_certs: None, + ssl_cert_reqs: 0, + ssl_ciphers: None, + profiler_config: None, }, managed_engine: ManagedEngineArgs { python: "python3", @@ -783,14 +1401,35 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() { tool_call_parser: Auto, reasoning_parser: Auto, renderer: Auto, + language_model_only: false, chat_template: None, default_chat_template_kwargs: None, chat_template_content_format: Auto, - enable_log_requests: false, - enable_request_id_headers: false, + max_logprobs: None, + api_server_options: ApiServerOptions { + enable_log_requests: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + }, + cors: CorsConfig { + allow_origins: [ + "*", + ], + allow_methods: [ + "*", + ], + allow_headers: [ + "*", + ], + allow_credentials: false, + }, + tls: None, + api_keys: [], disable_log_stats: false, grpc_port: None, shutdown_timeout: 0ns, + keep_alive_timeout: 5s, + profiler: None, } "#]] .assert_debug_eq(&Config { @@ -846,14 +1485,35 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() { tool_call_parser: Auto, reasoning_parser: Auto, renderer: Auto, + language_model_only: false, chat_template: None, default_chat_template_kwargs: None, chat_template_content_format: Auto, - enable_log_requests: false, - enable_request_id_headers: false, + max_logprobs: None, + api_server_options: ApiServerOptions { + enable_log_requests: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + }, + cors: CorsConfig { + allow_origins: [ + "*", + ], + allow_methods: [ + "*", + ], + allow_headers: [ + "*", + ], + allow_credentials: false, + }, + tls: None, + api_keys: [], disable_log_stats: false, grpc_port: None, shutdown_timeout: 0ns, + keep_alive_timeout: 5s, + profiler: None, } "#]] .assert_debug_eq(&config); @@ -893,8 +1553,10 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present "ipc:///tmp/output.sock", "--coordinator-address", "tcp://127.0.0.1:7000", + "--engine-start-index", + "3", "--engine-count", - "2", + "1", "--args-json", r#"{"model_tag":"Qwen/Qwen3-0.6B"}"#, ]) @@ -910,7 +1572,8 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present transport_mode: Bootstrapped { input_address: "ipc:///tmp/input.sock", output_address: "ipc:///tmp/output.sock", - engine_count: 2, + engine_start_index: 3, + engine_count: 1, ready_timeout: 600s, }, coordinator_mode: External { @@ -921,17 +1584,38 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present listener_mode: InheritedFd { fd: 3, }, - tool_call_parser: Auto, - reasoning_parser: Auto, + tool_call_parser: None, + reasoning_parser: None, renderer: Auto, + language_model_only: false, chat_template: None, default_chat_template_kwargs: None, chat_template_content_format: Auto, - enable_log_requests: false, - enable_request_id_headers: false, + max_logprobs: None, + api_server_options: ApiServerOptions { + enable_log_requests: false, + enable_prompt_tokens_details: false, + enable_request_id_headers: false, + }, + cors: CorsConfig { + allow_origins: [ + "*", + ], + allow_methods: [ + "*", + ], + allow_headers: [ + "*", + ], + allow_credentials: false, + }, + tls: None, + api_keys: [], disable_log_stats: false, grpc_port: None, shutdown_timeout: 0ns, + keep_alive_timeout: 5s, + profiler: None, } "#]] .assert_debug_eq(&config); @@ -960,3 +1644,75 @@ fn serve_frontend_config_uses_unix_listener_when_uds_is_present() { } ); } + +#[test] +fn frontend_args_json_enables_profiling_when_profiler_config_set() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","profiler_config":{"profiler":"torch","torch_profiler_dir":"/tmp/profile"}}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + assert_eq!(args.runtime.profiler().as_deref(), Some("torch")); + let config = args.into_config(); + assert_eq!(config.profiler.as_deref(), Some("torch")); +} + +#[test] +fn frontend_args_json_disables_profiling_when_profiler_config_absent() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B"}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + assert_eq!(args.runtime.profiler(), None); + let config = args.into_config(); + assert_eq!(config.profiler, None); +} + +#[test] +fn frontend_args_json_disables_profiling_when_profiler_type_is_null() { + let cli = Cli::try_parse_from([ + "vllm-rs", + "frontend", + "--listen-fd", + "3", + "--input-address", + "ipc:///tmp/input.sock", + "--output-address", + "ipc:///tmp/output.sock", + "--args-json", + r#"{"model_tag":"Qwen/Qwen3-0.6B","profiler_config":{"profiler":null}}"#, + ]) + .unwrap(); + + let Command::Frontend(args) = cli.command else { + panic!("expected frontend args"); + }; + assert_eq!(args.runtime.profiler(), None); + let config = args.into_config(); + assert_eq!(config.profiler, None); +} diff --git a/rust/src/cmd/src/cli/unsupported.rs b/rust/src/cmd/src/cli/unsupported.rs index 8bd972ae17a3..548001412cb4 100644 --- a/rust/src/cmd/src/cli/unsupported.rs +++ b/rust/src/cmd/src/cli/unsupported.rs @@ -202,14 +202,6 @@ pub struct EngineUnsupportedArgs { #[arg(long)] pub tokenizer_revision: Option, - /// Maximum number of log probabilities to return when `logprobs` is - /// specified in `SamplingParams`. The default value comes the default for - /// the OpenAI Chat Completions API. -1 means no cap, i.e. all - /// (output_length * vocab_size) logprobs are allowed to be returned and - /// it may cause OOM. - #[arg(long)] - pub max_logprobs: Option, - /// Skip initialization of tokenizer and detokenizer. Expects valid /// `prompt_token_ids` and `None` for prompt from the input. The generated /// output will contain token ids. @@ -444,15 +436,6 @@ pub struct ServerUnsupportedArgs { #[arg(long)] pub max_log_len: Option, - /// If set to True, enable prompt_tokens_details in usage. - #[arg( - long, - visible_alias = "no-enable-prompt-tokens-details", - default_missing_value = "true", - num_args = 0..=1 - )] - pub enable_prompt_tokens_details: Option, - /// If set to True, enable tracking server_load_metrics in the app state. #[arg( long, @@ -473,13 +456,17 @@ pub struct ServerUnsupportedArgs { /// Enable the `/tokenizer_info` endpoint. May expose chat /// templates and other tokenizer configuration. + /// + /// Accepted as a no-op: the Rust frontend serves `/tokenize` and + /// `/detokenize`, but does not implement `/tokenizer_info` yet. #[arg( long, visible_alias = "no-enable-tokenizer-info-endpoint", default_missing_value = "true", - num_args = 0..=1 + num_args = 0..=1, + hide = true )] - pub enable_tokenizer_info_endpoint: Option, + pub enable_tokenizer_info_endpoint: Option, /// If set to True, log model outputs (generations). /// Requires `--enable-log-requests`. As with `--enable-log-requests`, @@ -543,44 +530,6 @@ pub struct ServerUnsupportedArgs { #[arg(long)] pub disable_access_log_for_endpoints: Option, - /// Allow credentials. - #[arg( - long, - visible_alias = "no-allow-credentials", - default_missing_value = "true", - num_args = 0..=1 - )] - pub allow_credentials: Option, - - /// Allowed origins. - #[arg(long)] - pub allowed_origins: Option, - - /// Allowed methods. - #[arg(long)] - pub allowed_methods: Option, - - /// Allowed headers. - #[arg(long)] - pub allowed_headers: Option, - - /// If provided, the server will require one of these keys to be presented - /// in the header. - #[arg(long)] - pub api_key: Option, - - /// The file path to the SSL key file. - #[arg(long)] - pub ssl_keyfile: Option, - - /// The file path to the SSL cert file. - #[arg(long)] - pub ssl_certfile: Option, - - /// The CA certificates file. - #[arg(long)] - pub ssl_ca_certs: Option, - /// Refresh SSL Context when SSL certificate files change #[arg( long, @@ -590,15 +539,6 @@ pub struct ServerUnsupportedArgs { )] pub enable_ssl_refresh: Option, - /// Whether client certificate is required (see stdlib ssl module's). - #[arg(long)] - pub ssl_cert_reqs: Option, - - /// SSL cipher suites for HTTPS (TLS 1.2 and below only). - /// Example: 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305' - #[arg(long)] - pub ssl_ciphers: Option, - /// FastAPI root_path when app is behind a path based routing proxy. #[arg(long)] pub root_path: Option, diff --git a/rust/src/engine-core-client/examples/external_engine_logprobs.rs b/rust/src/engine-core-client/examples/external_engine_logprobs.rs index 08290c69bf5b..a067ad66004b 100644 --- a/rust/src/engine-core-client/examples/external_engine_logprobs.rs +++ b/rust/src/engine-core-client/examples/external_engine_logprobs.rs @@ -5,9 +5,9 @@ use clap::Parser; use futures::StreamExt as _; use tokio::time::timeout; use tracing_subscriber::EnvFilter; -use vllm_engine_core_client::protocol::{ - EngineCoreFinishReason, EngineCoreRequest, EngineCoreSamplingParams, -}; +use vllm_engine_core_client::protocol::output::EngineCoreFinishReason; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; +use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; use vllm_engine_core_client::{ EngineCoreClient, EngineCoreClientConfig, EngineCoreStreamOutput, TransportMode, }; diff --git a/rust/src/engine-core-client/examples/external_engine_utility_call.rs b/rust/src/engine-core-client/examples/external_engine_utility_call.rs index ee2a4e57b7aa..2fff91bc20aa 100644 --- a/rust/src/engine-core-client/examples/external_engine_utility_call.rs +++ b/rust/src/engine-core-client/examples/external_engine_utility_call.rs @@ -3,6 +3,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use clap::Parser; use tracing_subscriber::EnvFilter; +use vllm_engine_core_client::protocol::utility::PauseMode; use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, TransportMode}; #[derive(Debug, Parser)] @@ -32,8 +33,8 @@ struct Args { reset_external: bool, #[arg(long, default_value_t = 1)] sleep_level: u32, - #[arg(long, default_value = "abort")] - sleep_mode: String, + #[arg(long, default_value_t = PauseMode::Abort)] + sleep_mode: PauseMode, #[arg( long, default_value_t = false, @@ -106,7 +107,7 @@ async fn main() -> Result<()> { if args.skip_sleep_wake { println!("sleep_wake=skipped"); } else { - client.sleep(args.sleep_level, &args.sleep_mode).await.with_context(|| { + client.sleep(args.sleep_level, args.sleep_mode).await.with_context(|| { format!( "failed to call sleep utility with level={} mode={}", args.sleep_level, args.sleep_mode diff --git a/rust/src/engine-core-client/src/client.rs b/rust/src/engine-core-client/src/client.rs index 2a8c3c741884..eb899e3e7843 100644 --- a/rust/src/engine-core-client/src/client.rs +++ b/rust/src/engine-core-client/src/client.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::time::Duration; use futures::future::{join_all, try_join_all}; +use itertools::Itertools; use serde::Serialize; use tokio::sync::mpsc; use tokio_util::task::AbortOnDropHandle; @@ -10,10 +11,12 @@ use tracing::{debug, info, trace}; use crate::client::imp::{ClientInner, run_abort_loop, run_output_dispatcher_loop}; use crate::coordinator::CoordinatorHandle; use crate::error::{Error, Result}; +use crate::protocol::dtype::ModelDtype; use crate::protocol::handshake::EngineCoreReadyResponse; use crate::protocol::lora::LoraRequest; -use crate::protocol::utility::EngineCoreUtilityRequest; -use crate::protocol::{EngineCoreRequest, EngineCoreRequestType, ModelDtype}; +use crate::protocol::request::{EngineCoreRequest, EngineCoreRequestType}; +use crate::protocol::utility::{EngineCoreUtilityRequest, PauseMode}; +use crate::runtime::{BackgroundShutdownRuntime, build_zmq_runtime}; use crate::transport::{self, ConnectedEngine}; pub(crate) mod imp; @@ -55,6 +58,9 @@ pub enum TransportMode { /// Output PULL socket address that engines will connect to for /// responses. output_address: String, + /// First data-parallel engine rank expected to register on this + /// transport. + engine_start_index: u32, /// Total number of engines expected to register on this transport. engine_count: usize, /// Maximum time to wait for all expected engines to register. @@ -197,6 +203,8 @@ pub struct EngineCoreClient { coordinator: Option, abort_tx: mpsc::UnboundedSender, + /// Runtime used to send messages to the engine and drive all background tasks. + runtime: BackgroundShutdownRuntime, // Background tasks output_task: AbortOnDropHandle<()>, dispatcher_task: AbortOnDropHandle<()>, @@ -245,6 +253,7 @@ impl EngineCoreClient { TransportMode::Bootstrapped { input_address, output_address, + engine_start_index, engine_count, ready_timeout, } => { @@ -255,6 +264,7 @@ impl EngineCoreClient { transport::connect_bootstrapped( input_address, output_address, + *engine_start_index, *engine_count, *ready_timeout, ) @@ -274,21 +284,22 @@ impl EngineCoreClient { let (output_tx, output_rx) = mpsc::channel(64); let (abort_tx, abort_rx) = mpsc::unbounded_channel(); let engines = connected.engines; + let runtime = build_zmq_runtime(); let inner = Arc::new(ClientInner::new( connected.input_send, + runtime.handle().clone(), config.model_name.clone(), &engines, )); - let output_task = AbortOnDropHandle::new(tokio::spawn(transport::run_output_loop( + let output_task = AbortOnDropHandle::new(runtime.spawn(transport::run_output_loop( connected.output_socket, output_tx, ))); - let dispatcher_task = AbortOnDropHandle::new(tokio::spawn(run_output_dispatcher_loop( - inner.clone(), - output_rx, - ))); + let dispatcher_task = AbortOnDropHandle::new( + runtime.spawn(run_output_dispatcher_loop(inner.clone(), output_rx)), + ); let abort_task = - AbortOnDropHandle::new(tokio::spawn(run_abort_loop(inner.clone(), abort_rx))); + AbortOnDropHandle::new(runtime.spawn(run_abort_loop(inner.clone(), abort_rx))); // If any engine reported a dp_stats_address in its ready response, use it // as the external coordinator address. @@ -301,13 +312,13 @@ impl EngineCoreClient { CoordinatorHandle::new_inproc(coordinator_transport.input_socket); let (coordinator_output_tx, coordinator_output_rx) = mpsc::channel(64); let coordinator_output_task = - AbortOnDropHandle::new(tokio::spawn(transport::run_output_loop( + AbortOnDropHandle::new(runtime.spawn(transport::run_output_loop( coordinator_transport.output_socket, coordinator_output_tx, ))); - let coordinator_task = AbortOnDropHandle::new(tokio::spawn( - runner.run(coordinator_output_rx, inner.clone()), - )); + let coordinator_task = AbortOnDropHandle::new( + runtime.spawn(runner.run(coordinator_output_rx, inner.clone())), + ); ( Some(handle), Some(coordinator_output_task), @@ -321,7 +332,7 @@ impl EngineCoreClient { { let (handle, service) = CoordinatorHandle::connect_external(address).await?; let coordinator_task = - AbortOnDropHandle::new(tokio::spawn(service.run(inner.clone()))); + AbortOnDropHandle::new(runtime.spawn(service.run(inner.clone()))); (Some(handle), None, Some(coordinator_task)) } else { (None, None, None) @@ -335,6 +346,7 @@ impl EngineCoreClient { inner, coordinator, abort_tx, + runtime, output_task, dispatcher_task, abort_task, @@ -360,6 +372,14 @@ impl EngineCoreClient { self.engines.len() } + /// Return the engine-side indices connected to this client. + pub fn engine_indices(&self) -> Vec { + self.engines + .iter() + .map(|engine| engine.engine_id.engine_index().expect("engine id must encode as u16")) + .collect() + } + /// Return the engine identities of all engines connected to this client. pub fn engine_identities(&self) -> Vec<&[u8]> { self.engines.iter().map(|engine| &*engine.engine_id).collect() @@ -408,6 +428,24 @@ impl EngineCoreClient { .expect("engine core client requires at least one engine") } + /// Return the world size (TP * PP) from the parallel config, if available. + pub fn world_size(&self) -> u64 { + self.engines + .first() + .expect("engine core client requires at least one engine") + .ready_response + .world_size + } + + /// Return the data parallel size from the parallel config, if available. + pub fn data_parallel_size(&self) -> u64 { + self.engines + .first() + .expect("engine core client requires at least one engine") + .ready_response + .data_parallel_size + } + /// Get the model name associated with this client used for metrics /// labeling. pub fn model_name(&self) -> &str { @@ -441,9 +479,10 @@ impl EngineCoreClient { ); let request_id = req.request_id.clone(); + let lora_name = req.lora_request.as_ref().map(|lora| lora.lora_name.clone()); let data_parallel_rank = req.data_parallel_rank; let (engine_id, rx) = - self.inner.register_request(request_id.clone(), data_parallel_rank)?; + self.inner.register_request(request_id.clone(), lora_name, data_parallel_rank)?; let result: Result<()> = async { if let Some(coordinator) = self.coordinator.as_ref() { @@ -473,6 +512,7 @@ impl EngineCoreClient { Ok(EngineCoreOutputStream::new( request_id, + engine_id.engine_index().unwrap_or(0), self.abort_tx.clone(), rx, )) @@ -488,6 +528,10 @@ impl EngineCoreClient { return Ok(()); } + // Finalize the consumer streams first, before the engine round-trip. + let all_request_ids: Vec = abortable.values().flatten().cloned().collect(); + self.inner.abort_requests_locally(&all_request_ids); + for (engine_id, request_ids) in abortable { self.inner.do_abort_requests(&engine_id, &request_ids).await?; } @@ -571,6 +615,27 @@ impl EngineCoreClient { try_join_all(futures).await } + /// Call a utility method on all connected engines and return the shared + /// result if every engine agrees. + pub async fn call_utility_consensus(&self, method: &str, args: A) -> Result + where + T: serde::de::DeserializeOwned + std::fmt::Debug + PartialEq, + A: serde::Serialize + std::fmt::Debug, + { + let results: Vec = self.call_utility(method, args).await?; + + if results.iter().all_equal() { + // `engine_count >= 1` is enforced during startup handshake so `results` must be + // non-empty. + Ok(results.into_iter().next().unwrap()) + } else { + Err(Error::InconsistentUtilityResults { + method: method.to_string(), + values: format!("{results:?}"), + }) + } + } + /// Execute `collective_rpc` on all engines and flatten all engine results /// into one list. pub async fn collective_rpc( @@ -599,27 +664,8 @@ impl EngineCoreClient { } /// Return whether the engine is currently sleeping at any level. - /// - /// Under data parallel, all engines should agree on the sleep state: a - /// divergence signals a control-plane bug. Returns - /// `Error::InconsistentUtilityResults` if engines disagree. pub async fn is_sleeping(&self) -> Result { - let results: Vec = self.call_utility("is_sleeping", ()).await?; - // `engine_count >= 1` is enforced during startup handshake, so `results` - // is normally non-empty; fall back to a fail-loud error rather than - // indexing in case that invariant is ever bypassed. - let first = *results.first().ok_or_else(|| Error::InconsistentUtilityResults { - method: "is_sleeping".to_string(), - values: "[]".to_string(), - })?; - if results.iter().all(|&v| v == first) { - Ok(first) - } else { - Err(Error::InconsistentUtilityResults { - method: "is_sleeping".to_string(), - values: format!("{results:?}"), - }) - } + self.call_utility_consensus("is_sleeping", ()).await } /// Reset the multi-modal cache. @@ -643,22 +689,14 @@ impl EngineCoreClient { reset_running_requests: bool, reset_connector: bool, ) -> Result { - let results: Vec = self + Ok(self .call_utility( "reset_prefix_cache", (reset_running_requests, reset_connector), ) - .await?; - // `engine_count >= 1` is enforced during startup handshake, so `results` - // is normally non-empty; fail loud rather than reporting a vacuous - // success (`[].all() == true`) in case that invariant is ever bypassed. - if results.is_empty() { - return Err(Error::InconsistentUtilityResults { - method: "reset_prefix_cache".to_string(), - values: "[]".to_string(), - }); - } - Ok(results.into_iter().all(|ok| ok)) + .await? + .into_iter() + .all(|reset| reset)) } /// Load or refresh one LoRA adapter on every connected engine. @@ -680,7 +718,7 @@ impl EngineCoreClient { } /// Put the engine to sleep. - pub async fn sleep(&self, level: u32, mode: &str) -> Result<()> { + pub async fn sleep(&self, level: u32, mode: PauseMode) -> Result<()> { self.call_utility::<(), _>("sleep", (level, mode)).await?; Ok(()) } @@ -692,11 +730,41 @@ impl EngineCoreClient { Ok(()) } + /// Pause the scheduler so generation can be halted + pub async fn pause_scheduler(&self, mode: PauseMode, clear_cache: bool) -> Result<()> { + self.call_utility::<(), _>("pause_scheduler", (mode, clear_cache)).await?; + Ok(()) + } + + /// Resume the scheduler after a pause + pub async fn resume_scheduler(&self) -> Result<()> { + self.call_utility::<(), _>("resume_scheduler", ()).await?; + Ok(()) + } + + /// Return whether the scheduler is currently in any pause state. + pub async fn is_scheduler_paused(&self) -> Result { + self.call_utility_consensus("is_scheduler_paused", ()).await + } + + /// Start profiling the engine. + pub async fn start_profile(&self, profile_prefix: Option<&str>) -> Result<()> { + self.call_utility::<(), _>("profile", (true, profile_prefix)).await?; + Ok(()) + } + + /// Stop profiling the engine. + pub async fn stop_profile(&self, profile_prefix: Option<&str>) -> Result<()> { + self.call_utility::<(), _>("profile", (false, profile_prefix)).await?; + Ok(()) + } + /// Shut down local client tasks and close transport state. pub async fn shutdown(self) -> Result<()> { let Self { inner, abort_tx, + runtime, output_task, dispatcher_task, abort_task, @@ -717,6 +785,8 @@ impl EngineCoreClient { tasks.iter().for_each(|t| t.abort()); join_all(tasks).await; + drop(inner); + drop(runtime); info!("engine-core client shut down"); Ok(()) diff --git a/rust/src/engine-core-client/src/client/imp.rs b/rust/src/engine-core-client/src/client/imp.rs index 9a66ad84cc1b..c91a93d27146 100644 --- a/rust/src/engine-core-client/src/client/imp.rs +++ b/rust/src/engine-core-client/src/client/imp.rs @@ -1,10 +1,11 @@ -use std::collections::BTreeMap; -use std::slice; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use arc_swap::ArcSwapOption; use parking_lot::Mutex; use thiserror_ext::AsReport as _; +use tokio::runtime::Handle; use tokio::sync::mpsc; use tracing::{debug, info, trace, warn}; use vllm_metrics::METRICS; @@ -14,19 +15,21 @@ use crate::client::state::{OutputReceiver, RequestRegistry, UtilityReceiver, Uti use crate::client::stream::EngineCoreStreamOutput; use crate::client::{AbortCause, AbortRequest}; use crate::error::{client_closed, dispatcher_closed, unexpected_dispatcher_output}; -use crate::metrics::record_scheduler_stats; +use crate::metrics::{LoraInfoExporter, SchedulerStatsRecorder}; +use crate::protocol::encode_msgpack; +use crate::protocol::output::{EngineCoreOutput, EngineCoreOutputs}; +use crate::protocol::request::EngineCoreRequestType; use crate::protocol::stats::SchedulerStats; use crate::protocol::utility::UtilityOutput; -use crate::protocol::{ - ClassifiedEngineCoreOutputs, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequestType, - encode_msgpack, -}; use crate::transport::{ConnectedEngine, EngineId}; use crate::{Error, Result, transport}; pub(crate) struct ClientInner { input_send: RouterSendHalf, + /// The runtime handle used for sending messages to the engine. + handle: Handle, model_name: String, + scheduler_stats_recorder: SchedulerStatsRecorder, request_reg: Mutex, utility_reg: Mutex, health_error: ArcSwapOption, @@ -37,12 +40,17 @@ impl ClientInner { /// handshake completes. pub fn new( input_send: RouterSendHalf, + handle: Handle, model_name: String, engines: &[ConnectedEngine], ) -> Self { + let scheduler_stats_recorder = + SchedulerStatsRecorder::new(&METRICS.scheduler, &model_name, engines); Self { input_send, + handle, model_name, + scheduler_stats_recorder, request_reg: Mutex::new(RequestRegistry::new(engines)), utility_reg: Mutex::new(UtilityRegistry::default()), health_error: ArcSwapOption::empty(), @@ -59,17 +67,19 @@ impl ClientInner { /// per-request output channel bound to its `request_id`. /// /// When `data_parallel_rank` is provided, the request is routed to that - /// specific engine rank, bypassing load balancing. + /// specific engine rank, bypassing load balancing. `lora_name` is the + /// request's LoRA adapter, tracked for `vllm:lora_requests_info`. pub fn register_request( &self, request_id: String, + lora_name: Option, data_parallel_rank: Option, ) -> Result<(EngineId, OutputReceiver)> { let mut registry = self.request_reg.lock(); if registry.is_closed() { return Err(self.closed_error()); } - registry.register(request_id, data_parallel_rank) + registry.register(request_id, lora_name, data_parallel_rank) } /// Allocate the next utility `call_id` and register its waiting receiver. @@ -125,6 +135,20 @@ impl ClientInner { self.request_reg.lock().finish_many(request_ids) } + /// Finalize client-initiated aborts by pushing a terminal `Abort` output + /// down each request's stream and removing it from the registry. Returns + /// the request ids that were still active. See [`RequestRegistry::abort_many`]. + pub fn abort_requests_locally<'a>( + &self, + request_ids: impl IntoIterator, + ) -> Vec { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0); + self.request_reg.lock().abort_many(request_ids, timestamp) + } + /// Apply one scheduler stats update for the given engine to the local /// routing state. Returns `false` if the engine is unknown to the /// client. @@ -132,6 +156,12 @@ impl ClientInner { self.request_reg.lock().apply_scheduler_stats(engine_index, stats) } + /// Snapshot the adapter names of tracked LoRA requests as + /// (running, waiting) sets. + pub fn lora_adapter_states(&self) -> (BTreeSet, BTreeSet) { + self.request_reg.lock().lora_adapter_states() + } + /// Close all active request streams and utility calls with the first /// persistent health error. pub fn close_registries(&self, error: Arc) { @@ -191,9 +221,19 @@ impl ClientInner { // frames instead of always producing a single msgpack frame. let payload = encode_msgpack(payload)?; let mut input_send = self.input_send.clone(); - transport::send_message(&mut input_send, engine_id, request_type.to_frame(), payload) - .await?; - Ok(()) + let engine_id = engine_id.clone(); + + self.handle + .spawn(async move { + transport::send_message( + &mut input_send, + &engine_id, + request_type.to_frame(), + payload, + ) + .await + }) + .await? } /// Handle an abort request by sending the abort message to the engine. @@ -253,33 +293,47 @@ pub(crate) async fn run_abort_loop( inner: Arc, mut abort_rx: mpsc::UnboundedReceiver, ) { - // TODO: receive and abort requests in batch - while let Some(AbortRequest { request_id, cause }) = abort_rx.recv().await { - let Some(engine_id) = inner.take_auto_abort_target(&request_id) else { - debug!(request_id, "skip auto-abort for inactive request"); - continue; - }; - - match cause { - AbortCause::DroppedStream => { - info!(request_id, "auto-aborting request due to dropped stream") - } - AbortCause::StopStringMatched => { - debug!( - request_id, - "auto-aborting request due to stop string matched" - ) + // Coalesce bursts of auto-aborts into a single Abort message per engine. + // A dropped-stream storm (e.g. many clients disconnecting at once under + // high concurrency) would otherwise issue one engine round-trip per + // request. `recv_many` returns as soon as at least one item is ready, so a + // lone abort is still forwarded promptly. + const MAX_DRAIN: usize = 1024; + let mut batch: Vec = Vec::new(); + + while abort_rx.recv_many(&mut batch, MAX_DRAIN).await > 0 { + let mut by_engine: BTreeMap> = BTreeMap::new(); + + for AbortRequest { request_id, cause } in batch.drain(..) { + let Some(engine_id) = inner.take_auto_abort_target(&request_id) else { + debug!(request_id, "skip auto-abort for inactive request"); + continue; + }; + + match cause { + AbortCause::DroppedStream => { + info!(request_id, "auto-aborting request due to dropped stream") + } + AbortCause::StopStringMatched => { + debug!( + request_id, + "auto-aborting request due to stop string matched" + ) + } } + + by_engine.entry(engine_id).or_default().push(request_id); } - if let Err(error) = inner.do_abort_requests(&engine_id, slice::from_ref(&request_id)).await - { - warn!( - request_id, - ?engine_id, - error = %error.as_report(), - "failed to auto-abort dropped request stream" - ); + for (engine_id, request_ids) in by_engine { + if let Err(error) = inner.do_abort_requests(&engine_id, &request_ids).await { + warn!( + ?engine_id, + ?request_ids, + error = %error.as_report(), + "failed to auto-abort request streams" + ); + } } } } @@ -290,6 +344,8 @@ pub(crate) async fn run_output_dispatcher_loop( inner: Arc, mut output_rx: mpsc::Receiver>, ) { + let mut lora_info = LoraInfoExporter::default(); + let result: Result<()> = async { loop { let outputs = match output_rx.recv().await { @@ -299,8 +355,8 @@ pub(crate) async fn run_output_dispatcher_loop( )), }?; - match outputs.classify() { - ClassifiedEngineCoreOutputs::RequestBatch(batch) => { + match outputs { + EngineCoreOutputs::RequestBatch(batch) => { let senders = inner.take_senders_for_outputs(&batch.outputs); for (output, sender) in batch.outputs.into_iter().zip(senders) { let request_id = output.request_id.clone(); @@ -337,15 +393,16 @@ pub(crate) async fn run_output_dispatcher_loop( "dropping scheduler stats for unknown engine" ); } - record_scheduler_stats( - &METRICS.scheduler, - inner.model_name(), - batch.engine_index, - scheduler_stats, - ); + inner.scheduler_stats_recorder.record(batch.engine_index, scheduler_stats); } + + // The engine's scheduler stats never carry adapter names; + // the gauge is derived from the registry's frontend-side + // request tracking instead. + let (running, waiting) = inner.lora_adapter_states(); + lora_info.update(&METRICS.scheduler, running, waiting); } - ClassifiedEngineCoreOutputs::Utility(utility) => { + EngineCoreOutputs::Utility(utility) => { let call_id = utility.output.call_id; if inner.resolve_utility_output(utility.output) { trace!( @@ -361,8 +418,7 @@ pub(crate) async fn run_output_dispatcher_loop( ); } } - other @ (ClassifiedEngineCoreOutputs::DpControl { .. } - | ClassifiedEngineCoreOutputs::Other(_)) => { + other => { Err::<(), _>(unexpected_dispatcher_output!( "received unexpected output on main dispatcher path: {other:?}" ))?; @@ -390,6 +446,7 @@ mod tests { let (send, _) = socket.split(); ClientInner::new( send, + Handle::current(), "test-model".to_string(), &[ConnectedEngine { engine_id: EngineId::from(b"engine-0"), diff --git a/rust/src/engine-core-client/src/client/state.rs b/rust/src/engine-core-client/src/client/state.rs index 99302e4f8cca..881035d2f98e 100644 --- a/rust/src/engine-core-client/src/client/state.rs +++ b/rust/src/engine-core-client/src/client/state.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::atomic::{AtomicU64, Ordering}; use tokio::sync::{mpsc, oneshot}; @@ -7,7 +7,7 @@ use tracing::trace; use crate::EngineId; use crate::client::stream::EngineCoreStreamOutput; use crate::error::{Error, Result}; -use crate::protocol::EngineCoreOutput; +use crate::protocol::output::{EngineCoreEventType, EngineCoreFinishReason, EngineCoreOutput}; use crate::protocol::stats::SchedulerStats; use crate::protocol::utility::UtilityOutput; use crate::transport::ConnectedEngine; @@ -21,6 +21,25 @@ pub type UtilityReceiver = oneshot::Receiver>; struct TrackedRequest { sender: OutputSender, engine_id: EngineId, + lora: Option, +} + +/// Frontend-side view of one LoRA request's scheduling phase. +/// +/// The engine's `SchedulerStats` does not carry adapter names, so +/// `vllm:lora_requests_info` must be derived from per-request lifecycle events +/// observed by this client, mirroring `LoRARequestStates` in the Python +/// frontend (`vllm/v1/engine/output_processor.py`). +#[derive(Debug)] +struct LoraRequestState { + adapter_name: String, + phase: LoraPhase, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoraPhase { + Waiting, + Running, } /// The latest real scheduler-side load snapshot observed from one engine. @@ -81,6 +100,7 @@ impl EngineRoutingState { pub struct RequestRegistry { closed: bool, requests: HashMap, + active_lora_requests: usize, routing_per_engine: BTreeMap, } @@ -89,6 +109,7 @@ impl RequestRegistry { Self { closed: false, requests: HashMap::default(), + active_lora_requests: 0, routing_per_engine: engines .iter() .map(|engine| (engine.engine_id.clone(), EngineRoutingState::default())) @@ -105,6 +126,7 @@ impl RequestRegistry { pub fn register( &mut self, request_id: String, + lora_name: Option, data_parallel_rank: Option, ) -> Result<(EngineId, OutputReceiver)> { if self.requests.contains_key(&request_id) { @@ -113,11 +135,19 @@ impl RequestRegistry { let engine_id = self.choose_engine_for_request(data_parallel_rank)?; let (tx, rx) = mpsc::unbounded_channel(); + let lora = lora_name.map(|adapter_name| LoraRequestState { + adapter_name, + phase: LoraPhase::Waiting, + }); + if lora.is_some() { + self.active_lora_requests += 1; + } self.requests.insert( request_id, TrackedRequest { sender: tx, engine_id: engine_id.clone(), + lora, }, ); @@ -171,6 +201,7 @@ impl RequestRegistry { /// Obtain the stream sender for one output. If it indicates the request is /// finished, it will be removed from the registry. pub fn sender_for_output(&mut self, output: &EngineCoreOutput) -> Option { + self.apply_lora_events(output); if output.finished() { self.remove(output.request_id.as_str()).map(|tracked| tracked.0) } else { @@ -180,6 +211,47 @@ impl RequestRegistry { } } + /// Advance the request's LoRA scheduling phase from the engine-core events + /// attached to one output, mirroring the Python frontend's + /// `LoRARequestStates.update_from_events`. + fn apply_lora_events(&mut self, output: &EngineCoreOutput) { + let Some(events) = output.events.as_ref() else { + return; + }; + let Some(lora) = self + .requests + .get_mut(output.request_id.as_str()) + .and_then(|tracked| tracked.lora.as_mut()) + else { + return; + }; + for event in events { + lora.phase = match event.r#type { + EngineCoreEventType::Queued | EngineCoreEventType::Preempted => LoraPhase::Waiting, + EngineCoreEventType::Scheduled => LoraPhase::Running, + }; + } + } + + /// Snapshot the adapter names of tracked LoRA requests as + /// (running, waiting) sets. Feeds the `vllm:lora_requests_info` gauge. + pub fn lora_adapter_states(&self) -> (BTreeSet, BTreeSet) { + if self.active_lora_requests == 0 { + return (BTreeSet::new(), BTreeSet::new()); + } + + let mut running = BTreeSet::new(); + let mut waiting = BTreeSet::new(); + for lora in self.requests.values().filter_map(|tracked| tracked.lora.as_ref()) { + let set = match lora.phase { + LoraPhase::Running => &mut running, + LoraPhase::Waiting => &mut waiting, + }; + set.insert(lora.adapter_name.clone()); + } + (running, waiting) + } + /// Obtain stream senders for a whole engine output batch under one /// registry lock. Finished outputs are removed before returning. pub fn senders_for_outputs<'a>( @@ -221,17 +293,49 @@ impl RequestRegistry { } self.closed = true; + self.active_lora_requests = 0; std::mem::take(&mut self.requests) .into_values() .map(|tracked| tracked.sender) .collect() } + /// Finalize client-initiated aborts: remove each request and push a + /// terminal output with `finish_reason = Abort` down its stream before the + /// sender drops. Returns the request ids that were still active. + pub fn abort_many<'a>( + &mut self, + request_ids: impl IntoIterator, + timestamp: f64, + ) -> Vec { + let mut aborted = Vec::new(); + for request_id in request_ids { + let Some((sender, engine_id)) = self.remove(request_id) else { + continue; + }; + let output = EngineCoreStreamOutput { + engine_index: engine_id.engine_index().unwrap_or(0), + timestamp, + output: EngineCoreOutput { + request_id: request_id.clone(), + finish_reason: Some(EngineCoreFinishReason::Abort), + ..EngineCoreOutput::default() + }, + }; + let _ = sender.send(Ok(output)); + aborted.push(request_id.clone()); + } + aborted + } + /// Remove one request from the local registry. Returns the tracked entry if /// it exists. #[must_use] pub fn remove(&mut self, request_id: &str) -> Option<(OutputSender, EngineId)> { let tracked = self.requests.remove(request_id)?; + if tracked.lora.is_some() { + self.active_lora_requests -= 1; + } self.routing_per_engine .get_mut(&tracked.engine_id) .expect("request registry must track all known engines") @@ -269,6 +373,11 @@ impl RequestRegistry { pub fn is_closed(&self) -> bool { self.closed } + + #[cfg(test)] + fn active_lora_requests(&self) -> usize { + self.active_lora_requests + } } /// Internal registry for tracking active utility calls and their waiting @@ -336,11 +445,16 @@ impl UtilityRegistry { #[cfg(test)] mod tests { - use super::{EngineRoutingState, RequestRegistry, UtilityRegistry}; + use std::collections::BTreeSet; + use crate::EngineId; - use crate::client::state::EngineLoadSnapshot; + use crate::client::state::{ + EngineLoadSnapshot, EngineRoutingState, RequestRegistry, UtilityRegistry, + }; use crate::mock_engine::default_ready_response; - use crate::protocol::{EngineCoreFinishReason, EngineCoreOutput}; + use crate::protocol::output::{ + EngineCoreEvent, EngineCoreEventType, EngineCoreFinishReason, EngineCoreOutput, + }; use crate::transport::ConnectedEngine; fn connected_engine(engine_id: EngineId) -> ConnectedEngine { @@ -350,11 +464,36 @@ mod tests { } } + fn output_with_events( + request_id: &str, + events: &[EngineCoreEventType], + finish_reason: Option, + ) -> EngineCoreOutput { + EngineCoreOutput { + request_id: request_id.to_string(), + events: Some( + events + .iter() + .map(|event_type| EngineCoreEvent { + r#type: *event_type, + timestamp: 0.0, + }) + .collect(), + ), + finish_reason, + ..Default::default() + } + } + + fn adapter_names(values: &[&str]) -> BTreeSet { + values.iter().map(|name| (*name).to_string()).collect() + } + #[test] fn registry_rejects_duplicate_request_ids() { let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); - registry.register("req-1".to_string(), None).unwrap(); - let error = registry.register("req-1".to_string(), None).unwrap_err(); + registry.register("req-1".to_string(), None, None).unwrap(); + let error = registry.register("req-1".to_string(), None, None).unwrap_err(); assert!(matches!( error, crate::error::Error::DuplicateRequestId { request_id } if request_id == "req-1" @@ -364,7 +503,7 @@ mod tests { #[test] fn registry_removes_finished_request_on_output() { let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); - registry.register("req-1".to_string(), None).unwrap(); + registry.register("req-1".to_string(), None, None).unwrap(); let sender = registry.sender_for_output(&EngineCoreOutput { request_id: "req-1".to_string(), @@ -376,11 +515,161 @@ mod tests { assert!(!registry.contains("req-1")); } + #[test] + fn registry_tracks_lora_phases_from_engine_events() { + let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); + registry + .register("req-lora".to_string(), Some("adapter-a".to_string()), None) + .unwrap(); + registry.register("req-plain".to_string(), None, None).unwrap(); + + // Registered but not yet scheduled: counted as waiting. The non-LoRA + // request never shows up. + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&[]), adapter_names(&["adapter-a"])) + ); + + // Queued then scheduled in one output: running. + drop(registry.sender_for_output(&output_with_events( + "req-lora", + &[EngineCoreEventType::Queued, EngineCoreEventType::Scheduled], + None, + ))); + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&["adapter-a"]), adapter_names(&[])) + ); + + // Preempted: back to waiting. + drop(registry.sender_for_output(&output_with_events( + "req-lora", + &[EngineCoreEventType::Preempted], + None, + ))); + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&[]), adapter_names(&["adapter-a"])) + ); + + // Finished: dropped from tracking entirely. + drop(registry.sender_for_output(&output_with_events( + "req-lora", + &[EngineCoreEventType::Scheduled], + Some(EngineCoreFinishReason::Stop), + ))); + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&[]), adapter_names(&[])) + ); + } + + #[test] + fn registry_unions_lora_adapters_across_requests() { + let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); + registry + .register("req-a1".to_string(), Some("adapter-a".to_string()), None) + .unwrap(); + registry + .register("req-a2".to_string(), Some("adapter-a".to_string()), None) + .unwrap(); + registry + .register("req-b".to_string(), Some("adapter-b".to_string()), None) + .unwrap(); + + // One of adapter-a's requests starts running while the other waits: + // the adapter appears in both sets. + drop(registry.sender_for_output(&output_with_events( + "req-a1", + &[EngineCoreEventType::Scheduled], + None, + ))); + assert_eq!( + registry.lora_adapter_states(), + ( + adapter_names(&["adapter-a"]), + adapter_names(&["adapter-a", "adapter-b"]) + ) + ); + } + + #[test] + fn registry_counts_only_active_lora_requests() { + let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); + + registry.register("req-plain".to_string(), None, None).unwrap(); + assert_eq!(registry.active_lora_requests(), 0); + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&[]), adapter_names(&[])) + ); + + registry + .register( + "req-lora-a".to_string(), + Some("adapter-a".to_string()), + None, + ) + .unwrap(); + registry + .register( + "req-lora-b".to_string(), + Some("adapter-b".to_string()), + None, + ) + .unwrap(); + assert_eq!(registry.active_lora_requests(), 2); + + drop(registry.remove("req-plain")); + assert_eq!(registry.active_lora_requests(), 2); + + drop(registry.finish_many(&["req-lora-a".to_string()])); + assert_eq!(registry.active_lora_requests(), 1); + + drop(registry.abort_many(&["req-lora-b".to_string()], 0.0)); + assert_eq!(registry.active_lora_requests(), 0); + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&[]), adapter_names(&[])) + ); + } + + #[test] + fn registry_clears_lora_count_on_close() { + let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); + registry + .register("req-lora".to_string(), Some("adapter-a".to_string()), None) + .unwrap(); + + assert_eq!(registry.active_lora_requests(), 1); + drop(registry.close()); + assert_eq!(registry.active_lora_requests(), 0); + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&[]), adapter_names(&[])) + ); + } + + #[test] + fn registry_drops_lora_tracking_on_abort() { + let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); + registry + .register("req-lora".to_string(), Some("adapter-a".to_string()), None) + .unwrap(); + + drop(registry.finish_many(&["req-lora".to_string()])); + + assert_eq!( + registry.lora_adapter_states(), + (adapter_names(&[]), adapter_names(&[])) + ); + } + #[test] fn registry_closes_all_requests_on_failure() { let mut registry = RequestRegistry::new(&[connected_engine(EngineId::from(b"engine-0"))]); - registry.register("req-1".to_string(), None).unwrap(); - registry.register("req-2".to_string(), None).unwrap(); + registry.register("req-1".to_string(), None, None).unwrap(); + registry.register("req-2".to_string(), None, None).unwrap(); let senders = registry.close(); @@ -396,9 +685,9 @@ mod tests { connected_engine(engine_0.clone()), connected_engine(engine_1.clone()), ]); - let (chosen_0, _) = registry.register("req-1".to_string(), None).unwrap(); - let (chosen_1, _) = registry.register("req-2".to_string(), None).unwrap(); - let (chosen_0_again, _) = registry.register("req-3".to_string(), None).unwrap(); + let (chosen_0, _) = registry.register("req-1".to_string(), None, None).unwrap(); + let (chosen_1, _) = registry.register("req-2".to_string(), None, None).unwrap(); + let (chosen_0_again, _) = registry.register("req-3".to_string(), None, None).unwrap(); assert_eq!(chosen_0, engine_0); assert_eq!(chosen_1, engine_1); @@ -425,9 +714,9 @@ mod tests { connected_engine(engine_1.clone()), ]); - let (chosen_0, _) = registry.register("req-1".to_string(), None).unwrap(); - let (chosen_1, _) = registry.register("req-2".to_string(), None).unwrap(); - let (chosen_0_again, _) = registry.register("req-3".to_string(), None).unwrap(); + let (chosen_0, _) = registry.register("req-1".to_string(), None, None).unwrap(); + let (chosen_1, _) = registry.register("req-2".to_string(), None, None).unwrap(); + let (chosen_0_again, _) = registry.register("req-3".to_string(), None, None).unwrap(); assert_eq!(chosen_0, engine_0); assert_eq!(chosen_1, engine_1); @@ -494,7 +783,7 @@ mod tests { } )); - let (chosen, _) = registry.register("req-stats".to_string(), None).unwrap(); + let (chosen, _) = registry.register("req-stats".to_string(), None, None).unwrap(); assert_eq!(chosen, engine_1); } @@ -510,15 +799,15 @@ mod tests { ]); // Explicitly target rank 2 (third engine). - let (chosen, _) = registry.register("req-1".to_string(), Some(2)).unwrap(); + let (chosen, _) = registry.register("req-1".to_string(), None, Some(2)).unwrap(); assert_eq!(chosen, engine_2); // Explicitly target rank 0 (first engine). - let (chosen, _) = registry.register("req-2".to_string(), Some(0)).unwrap(); + let (chosen, _) = registry.register("req-2".to_string(), None, Some(0)).unwrap(); assert_eq!(chosen, engine_0); // Explicitly target rank 1. - let (chosen, _) = registry.register("req-3".to_string(), Some(1)).unwrap(); + let (chosen, _) = registry.register("req-3".to_string(), None, Some(1)).unwrap(); assert_eq!(chosen, engine_1); } @@ -532,11 +821,11 @@ mod tests { ]); // Load-balance: first two go to engine_0 and engine_1. - registry.register("req-lb-0".to_string(), None).unwrap(); + registry.register("req-lb-0".to_string(), None, None).unwrap(); // Now engine_0 has 1 in-flight. Without dp_rank, next would go to engine_1. // But with dp_rank=0, it should still go to engine_0. - let (chosen, _) = registry.register("req-dp".to_string(), Some(0)).unwrap(); + let (chosen, _) = registry.register("req-dp".to_string(), None, Some(0)).unwrap(); assert_eq!(chosen, engine_0); } @@ -547,7 +836,7 @@ mod tests { connected_engine(EngineId::from_engine_index(1)), ]); - let error = registry.register("req-1".to_string(), Some(2)).unwrap_err(); + let error = registry.register("req-1".to_string(), None, Some(2)).unwrap_err(); assert!(matches!( error, crate::error::Error::InvalidDataParallelRank { @@ -562,10 +851,10 @@ mod tests { let engine_0 = EngineId::from_engine_index(0); let mut registry = RequestRegistry::new(&[connected_engine(engine_0.clone())]); - let (chosen, _) = registry.register("req-ok".to_string(), Some(0)).unwrap(); + let (chosen, _) = registry.register("req-ok".to_string(), None, Some(0)).unwrap(); assert_eq!(chosen, engine_0); - let error = registry.register("req-bad".to_string(), Some(1)).unwrap_err(); + let error = registry.register("req-bad".to_string(), None, Some(1)).unwrap_err(); assert!(matches!( error, crate::error::Error::InvalidDataParallelRank { diff --git a/rust/src/engine-core-client/src/client/stream.rs b/rust/src/engine-core-client/src/client/stream.rs index 3cbb215b0ef0..b0ea180795a5 100644 --- a/rust/src/engine-core-client/src/client/stream.rs +++ b/rust/src/engine-core-client/src/client/stream.rs @@ -10,7 +10,7 @@ use tracing::{debug, error, warn}; use crate::client::AbortRequest; use crate::client::state::OutputReceiver; -use crate::protocol::{EngineCoreFinishReason, EngineCoreOutput}; +use crate::protocol::output::{EngineCoreFinishReason, EngineCoreOutput}; use crate::{AbortCause, Error, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,6 +45,7 @@ impl Deref for EngineCoreStreamOutput { /// `finish_reason` is non-`None`. pub struct EngineCoreOutputStream { request_id: String, + engine_index: u32, abort_tx: mpsc::UnboundedSender, state: State, rx: OutputReceiver, @@ -53,11 +54,13 @@ pub struct EngineCoreOutputStream { impl EngineCoreOutputStream { pub(crate) fn new( request_id: String, + engine_index: u32, abort_tx: mpsc::UnboundedSender, rx: OutputReceiver, ) -> Self { Self { request_id, + engine_index, abort_tx, state: State::Running, rx, @@ -68,6 +71,11 @@ impl EngineCoreOutputStream { pub fn request_id(&self) -> &str { &self.request_id } + + /// Return the index of the engine that owns this request. + pub fn engine_index(&self) -> u32 { + self.engine_index + } } impl Stream for EngineCoreOutputStream { diff --git a/rust/src/engine-core-client/src/coordinator/handle.rs b/rust/src/engine-core-client/src/coordinator/handle.rs index dca6f70de1f8..f063b624d3b2 100644 --- a/rust/src/engine-core-client/src/coordinator/handle.rs +++ b/rust/src/engine-core-client/src/coordinator/handle.rs @@ -20,6 +20,27 @@ pub(crate) struct CoordinatorStateSnapshot { pub engines_running: bool, } +impl CoordinatorStateSnapshot { + /// Resume the engines for a `FirstRequest` and return the wave to broadcast + /// and the engine to exclude from the wakeup. + /// + /// The request may have been stamped with a `request_wave` older than + /// `current_wave` if a `WaveComplete` advanced it after the command was + /// enqueued. Such a request still needs serving, so the current wave is + /// broadcast to every engine (`exclude = None`); the wave is never rewound. + /// A non-stale request excludes the engine that already received it. Mirrors + /// the Python coordinator's front-end path. + pub(crate) fn start_wave_for_first_request( + &mut self, + request_wave: u32, + target_engine_index: u32, + ) -> (u32, Option) { + self.engines_running = true; + let exclude = (request_wave >= self.current_wave).then_some(target_engine_index); + (self.current_wave, exclude) + } +} + /// Shared in-process coordinator state. pub(crate) type CoordinatorState = Mutex; diff --git a/rust/src/engine-core-client/src/coordinator/inproc.rs b/rust/src/engine-core-client/src/coordinator/inproc.rs index 26ab0c5a0e7c..aaa6d8171bce 100644 --- a/rust/src/engine-core-client/src/coordinator/inproc.rs +++ b/rust/src/engine-core-client/src/coordinator/inproc.rs @@ -10,10 +10,9 @@ use zeromq::{XPubSocket, ZmqMessage}; use crate::client::imp::ClientInner; use crate::coordinator::handle::{CoordinatorCommand, CoordinatorState}; use crate::error::{Error, Result, bail_unexpected_coordinator_output}; -use crate::protocol::{ - ClassifiedEngineCoreOutputs, DpControlMessage, EngineCoreOutputs, EngineCoreRequestType, - encode_msgpack, -}; +use crate::protocol::encode_msgpack; +use crate::protocol::output::{DpControlMessage, DpControlOutput, EngineCoreOutputs}; +use crate::protocol::request::EngineCoreRequestType; /// Coordinator-to-engine `START_DP_WAVE` control payload encoded on the /// engine-facing coordinator socket. @@ -27,9 +26,10 @@ use crate::protocol::{ struct StartDpWaveMessage { /// DP wave number that all engines should start processing. wave: u32, - /// Engine index that already received the triggering request and should not - /// receive an extra wakeup notification. - exclude_engine_index: u32, + /// Engine index that already received the triggering request and so does not + /// need an extra wakeup. `None` wakes every engine (used when the triggering + /// request was for a stale wave). + exclude_engine_index: Option, } /// Background half of the in-process coordinator. @@ -57,7 +57,11 @@ impl InProcCoordinatorRunner { } /// Broadcast Python-compatible `START_DP_WAVE` to all connected engines. - async fn broadcast_start_wave(&mut self, wave: u32, exclude_engine_index: u32) -> Result<()> { + async fn broadcast_start_wave( + &mut self, + wave: u32, + exclude_engine_index: Option, + ) -> Result<()> { let payload = encode_msgpack(&StartDpWaveMessage { wave, exclude_engine_index, @@ -86,13 +90,17 @@ impl InProcCoordinatorRunner { engine_id: target_engine_id.to_vec(), } })?; - self.state.lock().current_wave = wave; + let (current_wave, exclude) = { + let mut state = self.state.lock(); + state.start_wave_for_first_request(wave, target_engine_index) + }; debug!( - wave, - exclude_engine_index = target_engine_index, + current_wave, + request_wave = wave, + ?exclude, "starting DP wave after first request while engines were paused" ); - self.broadcast_start_wave(wave, target_engine_index).await?; + self.broadcast_start_wave(current_wave, exclude).await?; } } Ok(()) @@ -101,19 +109,19 @@ impl InProcCoordinatorRunner { /// Apply one engine-originated control output to the coordinator state /// machine. async fn handle_outputs(&mut self, outputs: EngineCoreOutputs) -> Result<()> { - match outputs.classify() { - ClassifiedEngineCoreOutputs::RequestBatch(batch) + match outputs { + EngineCoreOutputs::RequestBatch(batch) if batch.outputs.is_empty() && batch.finished_requests.is_none() => { // Stats-only output for coordinator. // Ignore since the Rust coordinator doesn't track stats for // routing decisions. } - ClassifiedEngineCoreOutputs::DpControl { + EngineCoreOutputs::DpControl(DpControlOutput { engine_index, control, .. - } => match control { + }) => match control { // The engines signals they completed the current wave and are now paused. // Advance the current wave and mark the state as paused. DpControlMessage::WaveComplete(wave) => { @@ -150,7 +158,7 @@ impl InProcCoordinatorRunner { exclude_engine_index = engine_index, "starting DP wave after stale-wave notification from engine" ); - self.broadcast_start_wave(wave, engine_index).await?; + self.broadcast_start_wave(wave, Some(engine_index)).await?; } } }, @@ -202,3 +210,48 @@ impl InProcCoordinatorRunner { inner.close_registries(Arc::new(error)); } } + +#[cfg(test)] +mod tests { + use crate::coordinator::handle::CoordinatorStateSnapshot; + + /// A `FirstRequest` for the current wave starts that wave and excludes the + /// engine that already received the triggering request. + #[test] + fn first_request_for_current_wave_excludes_target() { + let mut state = CoordinatorStateSnapshot { + current_wave: 3, + engines_running: false, + }; + + let (wave, exclude) = state.start_wave_for_first_request(3, 2); + + assert_eq!(wave, 3); + assert_eq!(exclude, Some(2)); + assert!(state.engines_running); + assert_eq!(state.current_wave, 3); + } + + /// A `FirstRequest` whose wave was superseded by a racing `WaveComplete` + /// (`request_wave < current_wave`) must still start the request's wave: it + /// broadcasts the current wave and wakes every engine (`exclude = None`) + /// rather than rewinding the wave or dropping the request. + #[test] + fn stale_first_request_starts_current_wave_for_all_engines() { + let mut state = CoordinatorStateSnapshot { + current_wave: 4, + engines_running: false, + }; + + // Request stamped with wave 3 while the coordinator already advanced to 4. + let (wave, exclude) = state.start_wave_for_first_request(3, 2); + + assert_eq!( + wave, 4, + "must broadcast the current wave, not the stale one" + ); + assert_eq!(exclude, None, "a stale request must wake every engine"); + assert!(state.engines_running); + assert_eq!(state.current_wave, 4, "wave must not be rewound"); + } +} diff --git a/rust/src/engine-core-client/src/error.rs b/rust/src/engine-core-client/src/error.rs index 0493732b03f9..f4fc5c48237e 100644 --- a/rust/src/engine-core-client/src/error.rs +++ b/rust/src/engine-core-client/src/error.rs @@ -25,10 +25,14 @@ pub enum Error { ValueDecode(#[from] rmpv::decode::Error), #[error("messagepack ext value decode failed: {message}")] ExtValueDecode { message: String }, + #[error("invalid structured outputs params: {message}")] + InvalidStructuredOutputsParams { message: String }, #[error("io error")] Io(#[from] std::io::Error), #[error("transport error")] Transport(#[from] zeromq::ZmqError), + #[error("ZMQ runtime task failed")] + ZmqRuntimeTask(#[from] tokio::task::JoinError), #[error("engine core reported fatal failure")] EngineCoreDead, #[error("startup handshake timed out while waiting for {stage} after {timeout:?}")] diff --git a/rust/src/engine-core-client/src/lib.rs b/rust/src/engine-core-client/src/lib.rs index e39e29c4e5a1..f4ae0e19ee9a 100644 --- a/rust/src/engine-core-client/src/lib.rs +++ b/rust/src/engine-core-client/src/lib.rs @@ -4,6 +4,7 @@ mod error; mod metrics; pub mod mock_engine; pub mod protocol; +pub mod runtime; #[cfg(any(test, feature = "test-util"))] pub mod test_utils; mod transport; diff --git a/rust/src/engine-core-client/src/metrics.rs b/rust/src/engine-core-client/src/metrics.rs index 8f4593961982..05744db42dfb 100644 --- a/rust/src/engine-core-client/src/metrics.rs +++ b/rust/src/engine-core-client/src/metrics.rs @@ -1,92 +1,202 @@ -use vllm_metrics::{EngineLabels, EnginePositionLabels, SchedulerMetrics, WaitingReasonLabels}; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::time::{SystemTime, UNIX_EPOCH}; + +use vllm_metrics::{ + EngineLabels, EnginePositionLabels, F64Gauge, Family, HistogramMetric, LoraAdapterNames, + LoraInfoLabels, SchedulerLogStatsAccumulator, SchedulerMetrics, U64Counter, U64Gauge, + WaitingReasonLabels, +}; use crate::protocol::stats::SchedulerStats; +use crate::transport::ConnectedEngine; const WAITING_REASON_CAPACITY: &str = "capacity"; const WAITING_REASON_DEFERRED: &str = "deferred"; -/// Record the scheduler-stats-backed metrics for one engine at one point in -/// time. -pub(crate) fn record_scheduler_stats( +/// Cached scheduler-stats metric handles for all engines connected to one +/// frontend client. +pub(crate) struct SchedulerStatsRecorder { + engines: BTreeMap, +} + +/// Per-engine cached metric handles used while recording `SchedulerStats`. +struct SchedulerStatsHandles { + // Base labels reused for dynamic child labels. + labels: EngineLabels, + + // Scheduler state gauges. + scheduler_running: U64Gauge, + scheduler_waiting: U64Gauge, + scheduler_waiting_capacity: U64Gauge, + scheduler_waiting_deferred: U64Gauge, + kv_cache_usage: F64Gauge, + + // Prefix-cache counters, including the connector-backed external cache path. + prefix_cache_queries: U64Counter, + prefix_cache_hits: U64Counter, + external_prefix_cache_queries: U64Counter, + external_prefix_cache_hits: U64Counter, + + // Speculative decoding counters. + spec_decode_num_drafts: U64Counter, + spec_decode_num_draft_tokens: U64Counter, + spec_decode_num_accepted_tokens: U64Counter, + spec_decode_num_accepted_tokens_per_pos: Family, + + // Per-engine performance / MFU counters. + estimated_flops_per_gpu: U64Counter, + estimated_read_bytes_per_gpu: U64Counter, + estimated_write_bytes_per_gpu: U64Counter, + + // Sampled KV-cache residency histograms. + kv_block_lifetime_seconds: HistogramMetric, + kv_block_idle_before_evict_seconds: HistogramMetric, + kv_block_reuse_gap_seconds: HistogramMetric, + + // Non-Prometheus interval accumulator for periodic text-log helpers. + log_stats: SchedulerLogStatsAccumulator, +} + +impl SchedulerStatsRecorder { + /// Resolve the fixed-label metric handles for the connected engines. + pub(crate) fn new( + metrics: &SchedulerMetrics, + model_name: &str, + engines: &[ConnectedEngine], + ) -> Self { + let engines = engines + .iter() + .filter_map(|engine| { + let engine = engine.engine_id.engine_index()?; + Some(( + engine, + resolve_scheduler_stats_handles(metrics, model_name, engine), + )) + }) + .collect(); + + Self { engines } + } + + /// Record one scheduler-stats payload for the given engine index. + pub(crate) fn record(&self, engine_index: u32, stats: &SchedulerStats) { + if let Some(handles) = self.engines.get(&engine_index) { + record_scheduler_stats_with_handles(handles, stats); + } + } +} + +/// Resolve all fixed-label scheduler metrics for one engine. +fn resolve_scheduler_stats_handles( metrics: &SchedulerMetrics, - model_name: impl Into, + model_name: &str, engine: u32, - stats: &SchedulerStats, -) { - let model_name = model_name.into(); +) -> SchedulerStatsHandles { let labels = EngineLabels { - model_name: model_name.clone(), + model_name: model_name.to_string(), + engine, + }; + let capacity = WaitingReasonLabels { + model_name: model_name.to_string(), engine, + reason: WAITING_REASON_CAPACITY, }; + let deferred = WaitingReasonLabels { + model_name: model_name.to_string(), + engine, + reason: WAITING_REASON_DEFERRED, + }; + + SchedulerStatsHandles { + scheduler_running: metrics.scheduler_running.get_or_create_owned(&labels), + scheduler_waiting: metrics.scheduler_waiting.get_or_create_owned(&labels), + scheduler_waiting_capacity: metrics + .scheduler_waiting_by_reason + .get_or_create_owned(&capacity), + scheduler_waiting_deferred: metrics + .scheduler_waiting_by_reason + .get_or_create_owned(&deferred), + kv_cache_usage: metrics.kv_cache_usage.get_or_create_owned(&labels), + prefix_cache_queries: metrics.prefix_cache_queries.get_or_create_owned(&labels), + prefix_cache_hits: metrics.prefix_cache_hits.get_or_create_owned(&labels), + external_prefix_cache_queries: metrics + .external_prefix_cache_queries + .get_or_create_owned(&labels), + external_prefix_cache_hits: metrics.external_prefix_cache_hits.get_or_create_owned(&labels), + spec_decode_num_drafts: metrics.spec_decode_num_drafts.get_or_create_owned(&labels), + spec_decode_num_draft_tokens: metrics + .spec_decode_num_draft_tokens + .get_or_create_owned(&labels), + spec_decode_num_accepted_tokens: metrics + .spec_decode_num_accepted_tokens + .get_or_create_owned(&labels), + spec_decode_num_accepted_tokens_per_pos: metrics + .spec_decode_num_accepted_tokens_per_pos + .clone(), + log_stats: metrics.log_stats.get_or_create_owned(&labels), + estimated_flops_per_gpu: metrics.estimated_flops_per_gpu.get_or_create_owned(&labels), + estimated_read_bytes_per_gpu: metrics + .estimated_read_bytes_per_gpu + .get_or_create_owned(&labels), + estimated_write_bytes_per_gpu: metrics + .estimated_write_bytes_per_gpu + .get_or_create_owned(&labels), + kv_block_lifetime_seconds: metrics.kv_block_lifetime_seconds.get_or_create_owned(&labels), + kv_block_idle_before_evict_seconds: metrics + .kv_block_idle_before_evict_seconds + .get_or_create_owned(&labels), + kv_block_reuse_gap_seconds: metrics.kv_block_reuse_gap_seconds.get_or_create_owned(&labels), + labels, + } +} +/// Record scheduler-stats values through pre-resolved metric handles. +fn record_scheduler_stats_with_handles(handles: &SchedulerStatsHandles, stats: &SchedulerStats) { // Scheduler state gauges. - metrics.scheduler_running.get_or_create(&labels).set(stats.num_running_reqs); - metrics + handles.scheduler_running.set(stats.num_running_reqs); + handles .scheduler_waiting - .get_or_create(&labels) .set(stats.num_waiting_reqs + stats.num_skipped_waiting_reqs); - metrics - .scheduler_waiting_by_reason - .get_or_create(&WaitingReasonLabels { - model_name: model_name.clone(), - engine, - reason: WAITING_REASON_CAPACITY, - }) - .set(stats.num_waiting_reqs); - metrics - .scheduler_waiting_by_reason - .get_or_create(&WaitingReasonLabels { - model_name: model_name.clone(), - engine, - reason: WAITING_REASON_DEFERRED, - }) - .set(stats.num_skipped_waiting_reqs); - metrics.kv_cache_usage.get_or_create(&labels).set(stats.kv_cache_usage); + handles.scheduler_waiting_capacity.set(stats.num_waiting_reqs); + handles.scheduler_waiting_deferred.set(stats.num_skipped_waiting_reqs); + handles.kv_cache_usage.set(stats.kv_cache_usage); // Prefix-cache counters, including the connector-backed external cache path. - metrics - .prefix_cache_queries - .get_or_create(&labels) - .inc_by(stats.prefix_cache_stats.base.queries); - metrics - .prefix_cache_hits - .get_or_create(&labels) - .inc_by(stats.prefix_cache_stats.base.hits); + handles.prefix_cache_queries.inc_by(stats.prefix_cache_stats.base.queries); + handles.prefix_cache_hits.inc_by(stats.prefix_cache_stats.base.hits); if let Some(connector_prefix_cache_stats) = &stats.connector_prefix_cache_stats { - metrics + handles .external_prefix_cache_queries - .get_or_create(&labels) .inc_by(connector_prefix_cache_stats.base.queries); - metrics + handles .external_prefix_cache_hits - .get_or_create(&labels) .inc_by(connector_prefix_cache_stats.base.hits); } // Speculative decoding counters. if let Some(spec_decoding_stats) = &stats.spec_decoding_stats { - metrics - .spec_decode_num_drafts - .get_or_create(&labels) - .inc_by(spec_decoding_stats.num_drafts); - metrics + handles.spec_decode_num_drafts.inc_by(spec_decoding_stats.num_drafts); + handles .spec_decode_num_draft_tokens - .get_or_create(&labels) .inc_by(spec_decoding_stats.num_draft_tokens); - metrics + handles .spec_decode_num_accepted_tokens - .get_or_create(&labels) .inc_by(spec_decoding_stats.num_accepted_tokens); + handles.log_stats.observe_spec_decode( + spec_decoding_stats.num_drafts, + &spec_decoding_stats.num_accepted_tokens_per_pos, + ); for (position, accepted_tokens) in spec_decoding_stats.num_accepted_tokens_per_pos.iter().copied().enumerate() { - metrics + handles .spec_decode_num_accepted_tokens_per_pos .get_or_create(&EnginePositionLabels { - model_name: model_name.clone(), - engine, + model_name: handles.labels.model_name.clone(), + engine: handles.labels.engine, position: position as u32, }) .inc_by(accepted_tokens); @@ -99,33 +209,137 @@ pub(crate) fn record_scheduler_stats( || perf_stats.num_read_bytes_per_gpu != 0 || perf_stats.num_write_bytes_per_gpu != 0) { - metrics - .estimated_flops_per_gpu - .get_or_create(&labels) - .inc_by(perf_stats.num_flops_per_gpu); - metrics - .estimated_read_bytes_per_gpu - .get_or_create(&labels) - .inc_by(perf_stats.num_read_bytes_per_gpu); - metrics - .estimated_write_bytes_per_gpu - .get_or_create(&labels) - .inc_by(perf_stats.num_write_bytes_per_gpu); + handles.estimated_flops_per_gpu.inc_by(perf_stats.num_flops_per_gpu); + handles.estimated_read_bytes_per_gpu.inc_by(perf_stats.num_read_bytes_per_gpu); + handles.estimated_write_bytes_per_gpu.inc_by(perf_stats.num_write_bytes_per_gpu); + } + + if let Some(cudagraph_stats) = &stats.cudagraph_stats { + handles.log_stats.observe_cudagraph( + cudagraph_stats.num_unpadded_tokens, + cudagraph_stats.num_padded_tokens, + cudagraph_stats.num_paddings, + &cudagraph_stats.runtime_mode, + ); } // Sampled KV-cache residency histograms. if !stats.kv_cache_eviction_events.is_empty() { - let kv_block_lifetime_seconds = metrics.kv_block_lifetime_seconds.get_or_create(&labels); - let kv_block_idle_before_evict_seconds = - metrics.kv_block_idle_before_evict_seconds.get_or_create(&labels); - let kv_block_reuse_gap_seconds = metrics.kv_block_reuse_gap_seconds.get_or_create(&labels); - for event in &stats.kv_cache_eviction_events { - kv_block_lifetime_seconds.observe(event.lifetime_seconds); - kv_block_idle_before_evict_seconds.observe(event.idle_seconds); + handles.kv_block_lifetime_seconds.observe(event.lifetime_seconds); + handles.kv_block_idle_before_evict_seconds.observe(event.idle_seconds); for reuse_gap_seconds in &event.reuse_gaps_seconds { - kv_block_reuse_gap_seconds.observe(*reuse_gap_seconds); + handles.kv_block_reuse_gap_seconds.observe(*reuse_gap_seconds); } } } } + +/// Exports `vllm:lora_requests_info` as a single series covering all LoRA +/// requests tracked by this client across every engine in the replica. +/// +/// The engine's `SchedulerStats` never carries adapter names: the Python +/// frontend fills them in from per-request lifecycle events tracked by +/// `LoRARequestStates` in `vllm/v1/engine/output_processor.py`. The Rust +/// frontend mirrors that, deriving the sets from the request registry. +#[derive(Default)] +pub(crate) struct LoraInfoExporter { + current: Option, +} + +impl LoraInfoExporter { + pub(crate) fn update( + &mut self, + metrics: &SchedulerMetrics, + running: BTreeSet, + waiting: BTreeSet, + ) { + let next = (!running.is_empty() || !waiting.is_empty()).then_some(LoraInfoLabels { + running_lora_adapters: LoraAdapterNames(running), + waiting_lora_adapters: LoraAdapterNames(waiting), + }); + + if self.current != next + && let Some(prev) = &self.current + { + metrics.lora_info.remove(prev); + } + + // Python sets this gauge to the current time on every record. + if let Some(labels) = &next { + metrics.lora_info.get_or_create(labels).set(now_unix_secs()); + } + + self.current = next; + } +} + +fn now_unix_secs() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use expect_test::expect; + use vllm_metrics::Metrics; + + use crate::metrics::LoraInfoExporter; + + fn names(values: &[&str]) -> BTreeSet { + values.iter().map(|name| (*name).to_string()).collect() + } + + /// The `lora_requests_info` series with the non-deterministic timestamp + /// value replaced by ``, one line per series. + fn lora_series(rendered: &str) -> String { + rendered + .lines() + .filter(|l| l.starts_with("vllm:lora_requests_info{")) + .map(|l| match l.rsplit_once("} ") { + Some((labels, _value)) => format!("{labels}}} "), + None => l.to_string(), + }) + .collect::>() + .join("\n") + } + + #[test] + fn lora_info_emits_clears_stale_and_drains() { + let metrics = Metrics::new(); + let mut exporter = LoraInfoExporter::default(); + + // No adapters: nothing emitted. + exporter.update(&metrics.scheduler, names(&[]), names(&[])); + expect![[""]].assert_eq(&lora_series(&metrics.render().unwrap())); + + // Two running (sorted), one waiting. + exporter.update(&metrics.scheduler, names(&["b", "a"]), names(&["c"])); + expect![[ + r#"vllm:lora_requests_info{running_lora_adapters="a,b",waiting_lora_adapters="c"} "# + ]] + .assert_eq(&lora_series(&metrics.render().unwrap())); + + // "c" gets scheduled and "d" arrives: the stale series is replaced. + exporter.update(&metrics.scheduler, names(&["a", "b", "c"]), names(&["d"])); + expect![[ + r#"vllm:lora_requests_info{running_lora_adapters="a,b,c",waiting_lora_adapters="d"} "# + ]] + .assert_eq(&lora_series(&metrics.render().unwrap())); + + // Everything but "d" finishes. + exporter.update(&metrics.scheduler, names(&["d"]), names(&[])); + expect![[ + r#"vllm:lora_requests_info{running_lora_adapters="d",waiting_lora_adapters=""} "# + ]] + .assert_eq(&lora_series(&metrics.render().unwrap())); + + // All requests done: series removed entirely. + exporter.update(&metrics.scheduler, names(&[]), names(&[])); + expect![[""]].assert_eq(&lora_series(&metrics.render().unwrap())); + } +} diff --git a/rust/src/engine-core-client/src/mock_engine.rs b/rust/src/engine-core-client/src/mock_engine.rs index 32cd48c396f5..781b004c7b9f 100644 --- a/rust/src/engine-core-client/src/mock_engine.rs +++ b/rust/src/engine-core-client/src/mock_engine.rs @@ -8,13 +8,16 @@ use zeromq::{DealerSocket, PushSocket, SocketOptions, SubSocket, ZmqMessage}; use crate::EngineId; use crate::error::{Error, Result, bail_unexpected_handshake_message}; +use crate::protocol::dtype::ModelDtype; use crate::protocol::handshake::{EngineCoreReadyResponse, HandshakeInitMessage, ReadyMessage}; -use crate::protocol::{ModelDtype, decode_msgpack, encode_msgpack}; +use crate::protocol::{decode_msgpack, encode_msgpack}; /// Default model length advertised by reusable mock engine helpers. pub const DEFAULT_MOCK_MAX_MODEL_LEN: u64 = 1024 * 1024; /// Default KV block count advertised by reusable mock engine helpers. pub const DEFAULT_MOCK_NUM_GPU_BLOCKS: u64 = 0; +/// Default KV block size (tokens per block) +pub const DEFAULT_MOCK_BLOCK_SIZE: u64 = 16; /// Startup behavior for one mock engine joining a frontend. #[derive(Debug, Clone)] @@ -46,9 +49,14 @@ pub fn default_ready_response() -> EngineCoreReadyResponse { EngineCoreReadyResponse { max_model_len: DEFAULT_MOCK_MAX_MODEL_LEN, num_gpu_blocks: DEFAULT_MOCK_NUM_GPU_BLOCKS, + block_size: DEFAULT_MOCK_BLOCK_SIZE, dp_stats_address: None, dtype: ModelDtype::Float32, vllm_version: "test-vllm-version".to_string(), + world_size: 1, + data_parallel_size: 1, + kv_cache_size_tokens: None, + kv_cache_max_concurrency: None, } } diff --git a/rust/src/engine-core-client/src/protocol/classified_outputs.rs b/rust/src/engine-core-client/src/protocol/classified_outputs.rs deleted file mode 100644 index d572f8f925b3..000000000000 --- a/rust/src/engine-core-client/src/protocol/classified_outputs.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::collections::BTreeSet; - -use enum_as_inner::EnumAsInner; - -use super::utility::UtilityOutput; -use super::{EngineCoreOutput, EngineCoreOutputs}; -use crate::protocol::stats::SchedulerStats; - -/// Data-parallel control notifications multiplexed through `EngineCoreOutputs`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DpControlMessage { - WaveComplete(u32), - StartWave(u32), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct RequestBatchOutputs { - pub engine_index: u32, - pub outputs: Vec, - pub scheduler_stats: Option>, - pub timestamp: f64, - pub finished_requests: Option>, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct UtilityCallOutput { - pub engine_index: u32, - pub timestamp: f64, - pub output: UtilityOutput, -} - -/// Semantic classification of a raw `EngineCoreOutputs` message. -/// -/// Python currently uses one product-shaped wire struct for several distinct -/// output families. This enum exposes those families more explicitly without -/// changing the wire format. -#[derive(Debug, Clone, PartialEq, EnumAsInner)] -pub enum ClassifiedEngineCoreOutputs { - RequestBatch(RequestBatchOutputs), - Utility(UtilityCallOutput), - DpControl { - engine_index: u32, - timestamp: f64, - control: DpControlMessage, - }, - /// Fallback for wire-shape combinations that do not map cleanly onto the - /// current semantic families. - Other(EngineCoreOutputs), -} - -impl EngineCoreOutputs { - /// Classify the raw wire message into a more semantic Rust enum. - pub fn classify(self) -> ClassifiedEngineCoreOutputs { - let has_request_payload = !self.outputs.is_empty() - || self.scheduler_stats.is_some() - || self.finished_requests.is_some(); - - match ( - has_request_payload, - &self.utility_output, - &self.wave_complete, - &self.start_wave, - ) { - (true, None, None, None) => { - ClassifiedEngineCoreOutputs::RequestBatch(RequestBatchOutputs { - engine_index: self.engine_index, - outputs: self.outputs, - scheduler_stats: self.scheduler_stats, - timestamp: self.timestamp, - finished_requests: self.finished_requests, - }) - } - (false, Some(_), None, None) => { - ClassifiedEngineCoreOutputs::Utility(UtilityCallOutput { - engine_index: self.engine_index, - timestamp: self.timestamp, - output: self.utility_output.unwrap(), - }) - } - (false, None, Some(_), None) => ClassifiedEngineCoreOutputs::DpControl { - engine_index: self.engine_index, - timestamp: self.timestamp, - control: DpControlMessage::WaveComplete(self.wave_complete.unwrap()), - }, - (false, None, None, Some(_)) => ClassifiedEngineCoreOutputs::DpControl { - engine_index: self.engine_index, - timestamp: self.timestamp, - control: DpControlMessage::StartWave(self.start_wave.unwrap()), - }, - _ => ClassifiedEngineCoreOutputs::Other(self), - } - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use super::*; - use crate::protocol::EngineCoreOutput; - - #[test] - fn engine_core_outputs_classify_request_batch() { - let outputs = EngineCoreOutputs { - outputs: vec![EngineCoreOutput { - request_id: "req-1".to_string(), - new_token_ids: vec![7], - ..Default::default() - }], - finished_requests: Some(BTreeSet::from(["req-1".to_string()])), - ..Default::default() - }; - - expect_test::expect![[r#" - RequestBatch( - RequestBatchOutputs { - engine_index: 0, - outputs: [ - EngineCoreOutput { - request_id: "req-1", - new_token_ids: [ - 7, - ], - new_logprobs: None, - new_prompt_logprobs_tensors: None, - pooling_output: None, - finish_reason: None, - stop_reason: None, - events: None, - kv_transfer_params: None, - trace_headers: None, - prefill_stats: None, - routed_experts: None, - num_nans_in_logits: 0, - }, - ], - scheduler_stats: None, - timestamp: 0.0, - finished_requests: Some( - { - "req-1", - }, - ), - }, - ) - "#]] - .assert_debug_eq(&outputs.classify()); - } - - #[test] - fn engine_core_outputs_classify_utility() { - let outputs = EngineCoreOutputs { - utility_output: Some(UtilityOutput { - call_id: 42_u64.into(), - failure_message: None, - result: None, - }), - ..Default::default() - }; - - expect_test::expect![[r#" - Utility( - UtilityCallOutput { - engine_index: 0, - timestamp: 0.0, - output: UtilityOutput { - call_id: 42, - failure_message: None, - result: None, - }, - }, - ) - "#]] - .assert_debug_eq(&outputs.classify()); - } - - #[test] - fn engine_core_outputs_classify_control() { - let outputs = EngineCoreOutputs { - start_wave: Some(3), - ..Default::default() - }; - - expect_test::expect![[r#" - DpControl { - engine_index: 0, - timestamp: 0.0, - control: StartWave( - 3, - ), - } - "#]] - .assert_debug_eq(&outputs.classify()); - } - - #[test] - fn engine_core_outputs_classify_mixed_shape_as_raw() { - let outputs = EngineCoreOutputs { - outputs: vec![EngineCoreOutput { - request_id: "req-1".to_string(), - new_token_ids: vec![7], - ..Default::default() - }], - utility_output: Some(UtilityOutput { - call_id: 1_u64.into(), - failure_message: None, - result: None, - }), - ..Default::default() - }; - - expect_test::expect![[r#" - Other( - EngineCoreOutputs { - engine_index: 0, - outputs: [ - EngineCoreOutput { - request_id: "req-1", - new_token_ids: [ - 7, - ], - new_logprobs: None, - new_prompt_logprobs_tensors: None, - pooling_output: None, - finish_reason: None, - stop_reason: None, - events: None, - kv_transfer_params: None, - trace_headers: None, - prefill_stats: None, - routed_experts: None, - num_nans_in_logits: 0, - }, - ], - scheduler_stats: None, - timestamp: 0.0, - utility_output: Some( - UtilityOutput { - call_id: 1, - failure_message: None, - result: None, - }, - ), - finished_requests: None, - wave_complete: None, - start_wave: None, - }, - ) - "#]] - .assert_debug_eq(&outputs.classify()); - } -} diff --git a/rust/src/engine-core-client/src/protocol/handshake.rs b/rust/src/engine-core-client/src/protocol/handshake.rs index d659dc8a2446..7a295209613b 100644 --- a/rust/src/engine-core-client/src/protocol/handshake.rs +++ b/rust/src/engine-core-client/src/protocol/handshake.rs @@ -2,7 +2,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::protocol::{ModelDtype, OpaqueValue}; +use crate::protocol::OpaqueValue; +use crate::protocol::dtype::ModelDtype; /// Decoded engine startup-handshake payload sent on the handshake socket. /// @@ -28,7 +29,7 @@ pub struct ReadyMessage { /// profiling). /// /// Original Python definition: -/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EngineCoreReadyResponse { /// Engine-reported maximum model context length (auto-fitted after @@ -36,12 +37,22 @@ pub struct EngineCoreReadyResponse { pub max_model_len: u64, /// Number of GPU blocks available for KV cache on this engine. pub num_gpu_blocks: u64, + /// KV cache block size (tokens per block). + pub block_size: u64, /// DP coordinator stats publish address, if applicable. pub dp_stats_address: Option, /// Effective model dtype after Python vLLM resolves `--dtype`. pub dtype: ModelDtype, /// Python vLLM version reported by the engine process. pub vllm_version: String, + /// World size (TP * PP) from the parallel config. + pub world_size: u64, + /// Data parallelism size from the parallel config. + pub data_parallel_size: u64, + /// Total KV cache capacity in tokens, if reported. + pub kv_cache_size_tokens: Option, + /// Maximum achievable request concurrency given the KV cache, if reported. + pub kv_cache_max_concurrency: Option, } /// Frontend-owned ZMQ addresses that are sent to the engine during startup diff --git a/rust/src/engine-core-client/src/protocol/logprobs.rs b/rust/src/engine-core-client/src/protocol/logprobs.rs index 00c01df671cb..24e6ae2fee14 100644 --- a/rust/src/engine-core-client/src/protocol/logprobs.rs +++ b/rust/src/engine-core-client/src/protocol/logprobs.rs @@ -9,8 +9,7 @@ use enum_as_inner::EnumAsInner; use serde::{Deserialize, Deserializer, Serialize}; use self::wire::*; -use super::{EngineCoreOutput, EngineCoreOutputs, decode_msgpack}; -use crate::error::{Error, Result, bail_ext_value_decode, ext_value_decode}; +use crate::error::{Error, Result, bail_ext_value_decode}; use crate::protocol::tensor::{WireArrayData, WireNdArray}; /// One token candidate and its logprob metadata for a single sequence position. @@ -160,7 +159,7 @@ impl Serialize for MaybeWireLogprobs { impl MaybeWireLogprobs { /// Resolve the wire representation into decoded logprobs by looking up aux /// frames and decoding raw views as needed. - fn resolve(self, frames: &[Frame], field_prefix: &str) -> Result + pub(super) fn resolve(self, frames: &[Frame], field_prefix: &str) -> Result where Frame: AsRef<[u8]>, { @@ -171,37 +170,6 @@ impl MaybeWireLogprobs { } } -impl EngineCoreOutputs { - /// Resolve all wire-format fields in-place by looking up aux frames and - /// decoding raw-view payloads as needed. - fn resolve_in_place(&mut self, frames: &[Frame]) -> Result<()> - where - Frame: AsRef<[u8]>, - { - for output in &mut self.outputs { - output.resolve_in_place(frames)?; - } - Ok(()) - } -} - -impl EngineCoreOutput { - /// Resolve all wire-format fields in-place by looking up aux frames and - /// decoding raw-view payloads as needed. - fn resolve_in_place(&mut self, frames: &[Frame]) -> Result<()> - where - Frame: AsRef<[u8]>, - { - self.new_logprobs = (self.new_logprobs.take()) - .map(|value| value.resolve(frames, "new_logprobs")) - .transpose()?; - self.new_prompt_logprobs_tensors = (self.new_prompt_logprobs_tensors.take()) - .map(|value| value.resolve(frames, "new_prompt_logprobs_tensors")) - .transpose()?; - Ok(()) - } -} - impl WireLogprobs { /// Convert semantic per-position logprobs into the Python wire tuple shape. /// @@ -315,16 +283,3 @@ impl WireLogprobs { Ok(Logprobs { positions }) } } - -/// Decode one ordinary or multipart engine-core output message into the strong -/// typed public protocol shape. -pub fn decode_engine_core_outputs(frames: &[Frame]) -> Result -where - Frame: AsRef<[u8]>, -{ - let first_frame = frames.first().ok_or_else(|| ext_value_decode!("missing output frame"))?; - - let mut outputs: EngineCoreOutputs = decode_msgpack(first_frame.as_ref())?; - outputs.resolve_in_place(frames)?; - Ok(outputs) -} diff --git a/rust/src/engine-core-client/src/protocol/logprobs/tests.rs b/rust/src/engine-core-client/src/protocol/logprobs/tests.rs index 7408b98f50c7..17310c7a30fc 100644 --- a/rust/src/engine-core-client/src/protocol/logprobs/tests.rs +++ b/rust/src/engine-core-client/src/protocol/logprobs/tests.rs @@ -3,8 +3,8 @@ use std::collections::BTreeSet; use bytes::Bytes; use rmpv::Value; -use super::{Logprobs, PositionLogprobs, TokenLogprob, decode_engine_core_outputs}; -use crate::protocol::EngineCoreFinishReason; +use super::{Logprobs, PositionLogprobs, TokenLogprob}; +use crate::protocol::output::{EngineCoreFinishReason, decode_engine_core_outputs}; fn encode_value(value: &Value) -> Vec { let mut out = Vec::new(); @@ -183,7 +183,7 @@ fn decodes_inline_new_logprobs() { Some(inline_logprobs_value()), None, )))]; - let decoded = decode_engine_core_outputs(&frames).unwrap(); + let decoded = decode_engine_core_outputs(&frames).unwrap().into_request_batch().unwrap(); let logprobs = decoded.outputs[0].new_logprobs.clone().unwrap().into_direct().unwrap(); assert_eq!(logprobs, expected_sample_logprobs()); @@ -214,7 +214,7 @@ fn decodes_multipart_new_logprobs() { ]), Bytes::from_static(&[1, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0]), ]; - let decoded = decode_engine_core_outputs(&frames).unwrap(); + let decoded = decode_engine_core_outputs(&frames).unwrap().into_request_batch().unwrap(); let logprobs = decoded.outputs[0].new_logprobs.clone().unwrap().into_direct().unwrap(); assert_eq!(logprobs, expected_sample_logprobs()); @@ -226,7 +226,7 @@ fn decodes_inline_prompt_logprobs() { None, Some(inline_prompt_logprobs_value()), )))]; - let decoded = decode_engine_core_outputs(&frames).unwrap(); + let decoded = decode_engine_core_outputs(&frames).unwrap().into_request_batch().unwrap(); let logprobs = decoded.outputs[0] .new_prompt_logprobs_tensors @@ -252,7 +252,7 @@ fn decodes_big_endian_payloads() { ])), None, )))]; - let decoded = decode_engine_core_outputs(&frames).unwrap(); + let decoded = decode_engine_core_outputs(&frames).unwrap().into_request_batch().unwrap(); let logprobs = decoded.outputs[0].new_logprobs.clone().unwrap().into_direct().unwrap(); assert_eq!( logprobs, diff --git a/rust/src/engine-core-client/src/protocol/mod.rs b/rust/src/engine-core-client/src/protocol/mod.rs index e87bc334fd05..d434a4e3e94f 100644 --- a/rust/src/engine-core-client/src/protocol/mod.rs +++ b/rust/src/engine-core-client/src/protocol/mod.rs @@ -1,28 +1,11 @@ use std::any::type_name; -use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io::Cursor; -use bytes::Bytes; use rmpv::Value; use serde::{Deserialize, Serialize}; -use serde_default::DefaultFromSerde; -use serde_repr::{Deserialize_repr, Serialize_repr}; -use serde_tuple::{Deserialize_tuple, Serialize_tuple}; use thiserror_ext::AsReport; use crate::error::{Error, Result}; -use crate::protocol::logprobs::MaybeWireLogprobs; -use crate::protocol::multimodal::MmFeatures; -use crate::protocol::stats::{PrefillStats, SchedulerStats}; -use crate::protocol::utility::UtilityOutput; - -// TODO: This module currently mixes reusable frontend-facing semantic types -// (for example `FinishReason`, `StopReason`, `RequestOutputKind`, and future -// cleaned-up frontend sampling types) with engine-core-specific wire DTOs and -// handshake/control messages. While the Rust frontend is still evolving -// quickly, keep them co-located here for iteration speed. Once the higher-level -// API boundary stabilizes, move the truly reusable semantic types into a -// lower-level common crate and keep the engine transport/wire messages here. /// Dynamic msgpack value used for schema positions that are preserved but not /// yet strongly typed in the early-stage Rust client. @@ -36,451 +19,18 @@ fn is_false(v: &bool) -> bool { !v } -fn default_top_p() -> f32 { - 1.0 -} - -fn default_repetition_penalty() -> f32 { - 1.0 -} - -mod classified_outputs; pub mod dtype; pub mod handshake; pub mod logprobs; pub mod lora; pub mod multimodal; +pub mod output; +pub mod request; +pub mod sampling; pub mod stats; +pub mod structured_outputs; pub mod tensor; pub mod utility; -pub use classified_outputs::{ - ClassifiedEngineCoreOutputs, DpControlMessage, RequestBatchOutputs, UtilityCallOutput, -}; -pub use dtype::ModelDtype; -pub use logprobs::decode_engine_core_outputs; - -/// Request types are encoded as single-byte protocol constants so they can be -/// sent over the ZMQ socket without an extra encoding step. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum EngineCoreRequestType { - Add = 0, - Abort = 1, - StartDpWave = 2, - Utility = 3, -} - -impl EngineCoreRequestType { - /// Decode the single-byte request type frame used on the engine input - /// socket. Returns `None` for unrecognized values. - pub fn from_frame(frame: &[u8]) -> Option { - let [value] = frame else { - return None; - }; - - match value { - 0 => Some(Self::Add), - 1 => Some(Self::Abort), - 2 => Some(Self::StartDpWave), - 3 => Some(Self::Utility), - _ => None, - } - } - - /// Encode the request type as the single-byte frame used on the engine - /// input socket. - pub fn to_frame(self) -> Bytes { - Bytes::from_static(match self { - Self::Add => b"\x00", - Self::Abort => b"\x01", - Self::StartDpWave => b"\x02", - Self::Utility => b"\x03", - }) - } -} - -/// Reason a request finished: stop, length, abort, error, or repetition. -/// -/// This mirrors the Python enum and uses integer encoding for compact wire -/// representation. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum EngineCoreFinishReason { - /// A stop string was emitted. - Stop = 0, - /// `max_tokens` or `max_model_len` was reached. - Length = 1, - /// The request was aborted by the client. - Abort = 2, - /// A retryable request-level internal error occurred. - Error = 3, - /// A repetitive token pattern was detected. - Repetition = 4, -} - -/// Event types emitted by engine-core for one request. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum EngineCoreEventType { - Queued = 1, - Scheduled = 2, - Preempted = 3, -} - -/// A timestamped engine-core event associated with one request. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EngineCoreEvent { - pub r#type: EngineCoreEventType, - pub timestamp: f64, -} - -/// Controls how intermediate outputs are returned to the frontend. -/// -/// `Cumulative = 0` is intentionally not supported in Rust frontend. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum RequestOutputKind { - /// Return only token deltas in each update. - #[default] - Delta = 1, - /// Suppress intermediate updates and return only the final output. - FinalOnly = 2, -} - -/// The stop reason associated with a finished output. -/// -/// Python models this as the union-typed `stop_reason: int | str | None` -/// field on `EngineCoreOutput`; the Rust client narrows it into a tagged enum. -/// -/// Original Python field: -/// -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum StopReason { - TokenId(u32), - Text(String), -} - -/// Parameters for configuring structured outputs (guided decoding). -/// -/// Exactly one constraint field (`json`, `regex`, `choice`, `grammar`, -/// `json_object`, or `structural_tag`) should be set. The engine-core -/// backend selects the appropriate grammar compiler based on which field -/// is present. -/// -/// Original Python definition: -/// -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct StructuredOutputsParams { - /// JSON schema (as a dict/object or JSON string) constraining the output. - pub json: Option, - /// Regular expression the output must match. - pub regex: Option, - /// List of allowed output strings (the model must produce one of these). - pub choice: Option>, - /// Context-free grammar (in EBNF-like notation) the output must conform to. - pub grammar: Option, - /// When `true`, output must be valid JSON (free-form, no schema). - pub json_object: Option, - /// Disable any additional whitespace in guided JSON output. - #[serde(skip_serializing_if = "crate::protocol::is_false")] - pub disable_any_whitespace: bool, - /// Disable `additionalProperties` in JSON schema output. - #[serde(skip_serializing_if = "crate::protocol::is_false")] - pub disable_additional_properties: bool, - /// Custom whitespace pattern for guided JSON output. - pub whitespace_pattern: Option, - /// Structural tag configuration (JSON-encoded string). - pub structural_tag: Option, -} - -/// Engine-core-facing sampling parameters for text generation. -/// -/// This is the normalized southbound subset used by the Rust frontend when it -/// talks to Python engine-core over the wire. User-facing request semantics -/// such as `stop` strings, `n`, `ignore_eos`, and output aggregation mode are -/// intentionally handled by higher layers before values reach this DTO. -/// -/// Original Python definition: -/// -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct EngineCoreSamplingParams { - /// Controls randomness. Lower values are more deterministic; zero means - /// greedy sampling. - pub temperature: f32, - /// Cumulative probability threshold for nucleus sampling. - #[serde(default = "default_top_p")] - pub top_p: f32, - /// Maximum number of top tokens to consider. `0` means all tokens. - #[serde(default)] - pub top_k: u32, - /// Random seed used by the sampler when present. - pub seed: Option, - /// Maximum number of tokens to generate per output sequence. - pub max_tokens: u32, - /// Minimum number of tokens to generate before EOS or stop-token handling. - #[serde(default)] - pub min_tokens: u32, - /// Number of log probabilities to return per generated token. - /// - /// `None` disables sample logprobs. `-1` requests the full vocabulary. - pub logprobs: Option, - /// Number of log probabilities to return per prompt token. - /// - /// `None` disables prompt logprobs. `-1` requests the full vocabulary. - pub prompt_logprobs: Option, - /// Minimum probability threshold for token sampling. - #[serde(default)] - pub min_p: f32, - /// Frequency penalty applied by the sampler. - pub frequency_penalty: f32, - /// Presence penalty applied by the sampler. - pub presence_penalty: f32, - /// Repetition penalty applied by the sampler. - #[serde(default = "default_repetition_penalty")] - pub repetition_penalty: f32, - /// Token IDs that stop generation. - pub stop_token_ids: Vec, - /// Primary EOS token ID used by engine-core's dedicated EOS stop path. - /// - /// This mirrors Python's internal `_eos_token_id` field and is derived by - /// the frontend from tokenizer/model metadata rather than supplied directly - /// by end users. - #[serde(rename = "_eos_token_id")] - pub eos_token_id: Option, - /// Complete stop-token set used by engine-core for `min_tokens` masking. - /// - /// This mirrors Python's internal `_all_stop_token_ids` field and should - /// contain explicit `stop_token_ids` plus any frontend-derived EOS token - /// IDs. - #[serde(rename = "_all_stop_token_ids")] - pub all_stop_token_ids: BTreeSet, - /// Logit biases to apply during sampling. - /// Keys are token IDs - #[serde(default)] - pub logit_bias: Option>, - /// Restrict output to these token IDs only. - #[serde(default)] - pub allowed_token_ids: Option>, - /// Tokenized bad words to avoid during generation. - #[serde(default, rename = "_bad_words_token_ids")] - pub bad_words_token_ids: Option>>, - /// Parameters for configuring structured outputs (guided decoding). - #[serde(default)] - pub structured_outputs: Option, - /// Specific token IDs for which log probabilities should be returned at - /// each position. - /// - /// When set, the engine returns logprobs for exactly these tokens in - /// addition to the sampled/scored token. Mutually exclusive with the - /// `logprobs` count field in practice. - #[serde(default)] - pub logprob_token_ids: Option>, - /// If `Some(true)`, the request will not attempt to read from the prefix - /// cache; newly computed blocks may still populate the cache. `None` - /// defers to engine-core defaults. - #[serde(default)] - pub skip_reading_prefix_cache: Option, - /// Additional request parameters for custom extensions (from `vllm_xargs`). - #[serde(default)] - pub extra_args: Option>, -} - -impl EngineCoreSamplingParams { - /// Constructs a default sampling params for testing purposes only. - pub fn for_test() -> Self { - Self { - temperature: 1.0, - top_p: 1.0, - top_k: 0, - seed: None, - max_tokens: 65536, - min_tokens: 0, - logprobs: None, - prompt_logprobs: None, - min_p: 0.0, - frequency_penalty: 0.0, - presence_penalty: 0.0, - repetition_penalty: 1.0, - stop_token_ids: Vec::new(), - eos_token_id: None, - all_stop_token_ids: BTreeSet::new(), - logit_bias: None, - allowed_token_ids: None, - bad_words_token_ids: None, - structured_outputs: None, - logprob_token_ids: None, - skip_reading_prefix_cache: None, - extra_args: None, - } - } -} - -/// Engine-core add-request payload sent from frontend to engine. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple, DefaultFromSerde)] -pub struct EngineCoreRequest { - pub request_id: String, - pub prompt_token_ids: Option>, - /// Multimodal features attached to the request. - pub mm_features: Option, - pub sampling_params: Option, - /// Pooling parameters are preserved in the schema but not yet strongly - /// typed. - pub pooling_params: Option, - pub arrival_time: f64, - #[serde(default)] - pub lora_request: Option, - #[serde(default)] - pub cache_salt: Option, - #[serde(default)] - pub data_parallel_rank: Option, - /// Unsupported in the first-stage Rust client because Python uses a custom - /// tensor/aux-frame encoding path for this field. - #[serde(default)] - pub prompt_embeds: Option, - /// Per-position mask for mixed-mode inputs (e.g. chat completion with - /// `prompt_embeds` content parts). `Some(true)` means real token id; - /// `Some(false)` means the position uses a pre-computed entry from - /// `prompt_embeds`. `None` for pure-tokens and pure-embeds requests. - #[serde(default)] - pub prompt_is_token_ids: Option>, - /// Index of the client, used to ensure outputs are sent back to the same - /// client when scaling out the frontend. - #[serde(default)] - pub client_index: u32, - /// In DP mode, indicates which wave this request is expected to belong to. - #[serde(default)] - pub current_wave: u32, - #[serde(default)] - pub priority: i32, - #[serde(default)] - pub trace_headers: Option>, - #[serde(default)] - pub resumable: bool, - /// Original user-provided request ID, used for output reporting and aborts. - #[serde(default)] - pub external_req_id: Option, - #[serde(default)] - pub reasoning_ended: Option, - /// Opaque reasoning-parser kwargs forwarded from the frontend to the - /// structured-output backend. - #[serde(default)] - pub reasoning_parser_kwargs: Option, - /// If `true`, the request should be added to the scheduler's waiting queue - /// and immediately aborted, so connector-side cleanup runs via the - /// standard `request_finished` hook. - #[serde(default)] - pub abort_immediately: bool, -} - -impl EngineCoreRequest { - /// Validate fields intentionally not supported in the first-stage client. - pub fn validate(&self) -> Result<()> { - if self.prompt_embeds.is_some() { - return Err(Error::UnsupportedField { - context: "EngineCoreRequest", - field: "prompt_embeds", - }); - } - Ok(()) - } -} - -/// Engine-core output for a single request. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple, DefaultFromSerde)] -pub struct EngineCoreOutput { - pub request_id: String, - pub new_token_ids: Vec, - /// Decoded sample logprobs for the newly generated positions in this - /// output. - #[serde(default)] - pub new_logprobs: Option, - /// Decoded prompt logprobs for the scored prompt positions emitted in this - /// output. - #[serde(default)] - pub new_prompt_logprobs_tensors: Option, - #[serde(default)] - pub pooling_output: Option, - #[serde(default)] - pub finish_reason: Option, - #[serde(default)] - pub stop_reason: Option, - #[serde(default)] - pub events: Option>, - #[serde(default)] - pub kv_transfer_params: Option, - #[serde(default)] - pub trace_headers: Option, - /// Breakdown of the scheduled prefill computation, set on the first output - /// of a newly scheduled prefill and elided for subsequent decode outputs. - #[serde(default)] - pub prefill_stats: Option, - #[serde(default)] - pub routed_experts: Option, - /// Number of NaNs seen in logits. Values above zero indicate corruption. - #[serde(default)] - pub num_nans_in_logits: u32, -} - -impl EngineCoreOutput { - /// Returns whether this output is terminal for the request. - pub fn finished(&self) -> bool { - self.finish_reason.is_some() - } -} - -/// Batch of engine-core outputs returned to a frontend client. -/// -/// Original Python definition: -/// -#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple, DefaultFromSerde)] -pub struct EngineCoreOutputs { - #[serde(default)] - pub engine_index: u32, - /// Outputs grouped for this client in the current engine tick. - #[serde(default)] - pub outputs: Vec, - #[serde(default)] - pub scheduler_stats: Option>, - #[serde(default)] - pub timestamp: f64, - #[serde(default)] - pub utility_output: Option, - #[serde(default)] - pub finished_requests: Option>, - /// In DP mode, signals that the current wave finished and engines are - /// paused. - #[serde(default)] - pub wave_complete: Option, - /// In DP mode, signals that a request arrived for an old wave and the next - /// wave needs to start in other engines. - #[serde(default)] - pub start_wave: Option, -} /// Encode a Rust value into msgpack using the protocol crate's serde model. pub fn encode_msgpack(value: &T) -> Result> @@ -516,81 +66,17 @@ where }) } +/// Decode a msgpack payload into a dynamic value for diagnostics and tests. pub fn decode_value(bytes: &[u8]) -> Result { Ok(rmpv::decode::read_value(&mut Cursor::new(bytes))?) } #[cfg(test)] mod tests { - use std::collections::BTreeSet; + use std::collections::BTreeMap; use super::*; - #[test] - fn engine_core_request_serializes_as_full_array() { - let request = EngineCoreRequest { - request_id: "req-1".to_string(), - prompt_token_ids: Some(vec![1, 2, 3]), - sampling_params: Some(EngineCoreSamplingParams { - max_tokens: 8, - ..EngineCoreSamplingParams::for_test() - }), - arrival_time: 1234.5, - client_index: 7, - ..EngineCoreRequest::default() - }; - - let encoded = encode_msgpack(&request).unwrap(); - let value = decode_value(&encoded).unwrap(); - let array = match value { - Value::Array(array) => array, - other => panic!("expected array, got {other:?}"), - }; - - assert_eq!(array.len(), 20); - assert_eq!(array[0], Value::from("req-1")); - assert_eq!(array[2], Value::Nil); - assert_eq!(array[4], Value::Nil); - assert_eq!(array[10], Value::Nil); - assert_eq!(array[11], Value::from(7)); - } - - #[test] - fn engine_core_outputs_roundtrip_finished_fields() { - let outputs = EngineCoreOutputs { - outputs: vec![EngineCoreOutput { - request_id: "req-1".to_string(), - new_token_ids: vec![42], - new_logprobs: None, - new_prompt_logprobs_tensors: None, - pooling_output: None, - finish_reason: Some(EngineCoreFinishReason::Length), - stop_reason: Some(StopReason::Text("stop".to_string())), - events: None, - kv_transfer_params: None, - trace_headers: None, - prefill_stats: None, - routed_experts: None, - num_nans_in_logits: 0, - }], - finished_requests: Some(BTreeSet::from(["req-1".to_string()])), - ..Default::default() - }; - - let encoded = encode_msgpack(&outputs).unwrap(); - let decoded: EngineCoreOutputs = decode_msgpack(&encoded).unwrap(); - - assert_eq!(decoded.outputs.len(), 1); - assert_eq!( - decoded.outputs[0].finish_reason, - Some(EngineCoreFinishReason::Length) - ); - assert_eq!( - decoded.finished_requests, - Some(BTreeSet::from(["req-1".to_string()])) - ); - } - #[test] fn decode_msgpack_includes_type_name_and_value_fallback() { let error = decode_msgpack::( diff --git a/rust/src/engine-core-client/src/protocol/output.rs b/rust/src/engine-core-client/src/protocol/output.rs new file mode 100644 index 000000000000..157343c20b80 --- /dev/null +++ b/rust/src/engine-core-client/src/protocol/output.rs @@ -0,0 +1,518 @@ +use std::collections::BTreeSet; + +use enum_as_inner::EnumAsInner; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_default::DefaultFromSerde; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_tuple::{Deserialize_tuple, Serialize_tuple}; + +use super::utility::UtilityOutput; +use crate::error::{Error, Result, ext_value_decode}; +use crate::protocol::logprobs::MaybeWireLogprobs; +use crate::protocol::stats::{PrefillStats, SchedulerStats}; +use crate::protocol::{OpaqueValue, decode_msgpack}; + +/// The stop reason associated with a finished output. +/// +/// Python models this as the union-typed `stop_reason: int | str | None` +/// field on `EngineCoreOutput`; the Rust client narrows it into a tagged enum. +/// +/// Original Python field: +/// +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StopReason { + TokenId(u32), + Text(String), +} + +/// Reason a request finished: stop, length, abort, error, or repetition. +/// +/// This mirrors the Python enum and uses integer encoding for compact wire +/// representation. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum EngineCoreFinishReason { + /// A stop string was emitted. + Stop = 0, + /// `max_tokens` or `max_model_len` was reached. + Length = 1, + /// The request was aborted by the client. + Abort = 2, + /// A retryable request-level internal error occurred. + Error = 3, + /// A repetitive token pattern was detected. + Repetition = 4, +} + +/// Event types emitted by engine-core for one request. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum EngineCoreEventType { + Queued = 1, + Scheduled = 2, + Preempted = 3, +} + +/// A timestamped engine-core event associated with one request. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EngineCoreEvent { + pub r#type: EngineCoreEventType, + pub timestamp: f64, +} + +/// Engine-core output for a single request. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple, DefaultFromSerde)] +pub struct EngineCoreOutput { + pub request_id: String, + pub new_token_ids: Vec, + /// Decoded sample logprobs for the newly generated positions in this + /// output. + #[serde(default)] + pub new_logprobs: Option, + /// Decoded prompt logprobs for the scored prompt positions emitted in this + /// output. + #[serde(default)] + pub new_prompt_logprobs_tensors: Option, + #[serde(default)] + pub pooling_output: Option, + #[serde(default)] + pub finish_reason: Option, + #[serde(default)] + pub stop_reason: Option, + #[serde(default)] + pub events: Option>, + #[serde(default)] + pub kv_transfer_params: Option, + #[serde(default)] + pub trace_headers: Option, + /// Breakdown of the scheduled prefill computation, set on the first output + /// of a newly scheduled prefill and elided for subsequent decode outputs. + #[serde(default)] + pub prefill_stats: Option, + #[serde(default)] + pub routed_experts: Option, + /// Number of NaNs seen in logits. Values above zero indicate corruption. + #[serde(default)] + pub num_nans_in_logits: u32, +} + +impl EngineCoreOutput { + /// Returns whether this output is terminal for the request. + pub fn finished(&self) -> bool { + self.finish_reason.is_some() + } + + /// Resolve all wire-format fields in-place by looking up aux frames and + /// decoding raw-view payloads as needed. + fn resolve_in_place(&mut self, frames: &[Frame]) -> Result<()> + where + Frame: AsRef<[u8]>, + { + self.new_logprobs = (self.new_logprobs.take()) + .map(|value| value.resolve(frames, "new_logprobs")) + .transpose()?; + self.new_prompt_logprobs_tensors = (self.new_prompt_logprobs_tensors.take()) + .map(|value| value.resolve(frames, "new_prompt_logprobs_tensors")) + .transpose()?; + Ok(()) + } +} + +/// Raw Python/msgpack engine-core output envelope. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple, DefaultFromSerde)] +struct WireEngineCoreOutputs { + #[serde(default)] + engine_index: u32, + /// Outputs grouped for this client in the current engine tick. + #[serde(default)] + outputs: Vec, + #[serde(default)] + scheduler_stats: Option>, + #[serde(default)] + timestamp: f64, + #[serde(default)] + utility_output: Option, + #[serde(default)] + finished_requests: Option>, + /// In DP mode, signals that the current wave finished and engines are + /// paused. + #[serde(default)] + wave_complete: Option, + /// In DP mode, signals that a request arrived for an old wave and the next + /// wave needs to start in other engines. + #[serde(default)] + start_wave: Option, +} + +/// Data-parallel control notifications multiplexed through `EngineCoreOutputs`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DpControlMessage { + WaveComplete(u32), + StartWave(u32), +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RequestBatchOutputs { + pub engine_index: u32, + pub outputs: Vec, + pub scheduler_stats: Option>, + pub timestamp: f64, + pub finished_requests: Option>, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct UtilityCallOutput { + pub engine_index: u32, + pub timestamp: f64, + pub output: UtilityOutput, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DpControlOutput { + pub engine_index: u32, + pub timestamp: f64, + pub control: DpControlMessage, +} + +/// Semantic engine-core output families. +/// +/// Python currently uses one product-shaped wire struct. The Rust protocol +/// exposes the finite semantic families while preserving the same msgpack shape +/// for serialization. +#[derive(Debug, Clone, PartialEq, EnumAsInner)] +pub enum EngineCoreOutputs { + RequestBatch(RequestBatchOutputs), + Utility(UtilityCallOutput), + DpControl(DpControlOutput), +} + +impl From for EngineCoreOutputs { + fn from(outputs: RequestBatchOutputs) -> Self { + Self::RequestBatch(outputs) + } +} + +impl From for EngineCoreOutputs { + fn from(output: UtilityCallOutput) -> Self { + Self::Utility(output) + } +} + +impl From for EngineCoreOutputs { + fn from(output: DpControlOutput) -> Self { + Self::DpControl(output) + } +} + +impl EngineCoreOutputs { + /// Resolve all wire-format fields in-place by looking up aux frames and + /// decoding raw-view payloads as needed. + fn resolve_in_place(&mut self, frames: &[Frame]) -> Result<()> + where + Frame: AsRef<[u8]>, + { + if let Self::RequestBatch(batch) = self { + for output in &mut batch.outputs { + output.resolve_in_place(frames)?; + } + } + Ok(()) + } +} + +/// Classify the raw wire message into a more semantic Rust enum. +impl TryFrom for EngineCoreOutputs { + type Error = Error; + + fn try_from(value: WireEngineCoreOutputs) -> Result { + let has_request_payload = !value.outputs.is_empty() + || value.scheduler_stats.is_some() + || value.finished_requests.is_some(); + + match ( + has_request_payload, + &value.utility_output, + &value.wave_complete, + &value.start_wave, + ) { + (true, None, None, None) => Ok(RequestBatchOutputs { + engine_index: value.engine_index, + outputs: value.outputs, + scheduler_stats: value.scheduler_stats, + timestamp: value.timestamp, + finished_requests: value.finished_requests, + } + .into()), + (false, Some(_), None, None) => Ok(UtilityCallOutput { + engine_index: value.engine_index, + timestamp: value.timestamp, + output: value.utility_output.unwrap(), + } + .into()), + (false, None, Some(_), None) => Ok(DpControlOutput { + engine_index: value.engine_index, + timestamp: value.timestamp, + control: DpControlMessage::WaveComplete(value.wave_complete.unwrap()), + } + .into()), + (false, None, None, Some(_)) => Ok(DpControlOutput { + engine_index: value.engine_index, + timestamp: value.timestamp, + control: DpControlMessage::StartWave(value.start_wave.unwrap()), + } + .into()), + + _ => Err(Error::Decode { + target_type: "EngineCoreOutputs", + message: "invalid wire shape".to_string(), + }), + } + } +} + +impl From for WireEngineCoreOutputs { + fn from(value: EngineCoreOutputs) -> Self { + match value { + EngineCoreOutputs::RequestBatch(batch) => Self { + engine_index: batch.engine_index, + outputs: batch.outputs, + scheduler_stats: batch.scheduler_stats, + timestamp: batch.timestamp, + finished_requests: batch.finished_requests, + ..Default::default() + }, + EngineCoreOutputs::Utility(utility) => Self { + engine_index: utility.engine_index, + timestamp: utility.timestamp, + utility_output: Some(utility.output), + ..Default::default() + }, + EngineCoreOutputs::DpControl(control) => { + let (wave_complete, start_wave) = match control.control { + DpControlMessage::WaveComplete(wave) => (Some(wave), None), + DpControlMessage::StartWave(wave) => (None, Some(wave)), + }; + Self { + engine_index: control.engine_index, + timestamp: control.timestamp, + wave_complete, + start_wave, + ..Default::default() + } + } + } + } +} + +impl Serialize for EngineCoreOutputs { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + WireEngineCoreOutputs::from(self.clone()).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for EngineCoreOutputs { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + WireEngineCoreOutputs::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +/// Decode one ordinary or multipart engine-core output message into the strong +/// typed public protocol shape. +pub fn decode_engine_core_outputs(frames: &[Frame]) -> Result +where + Frame: AsRef<[u8]>, +{ + let first_frame = frames.first().ok_or_else(|| ext_value_decode!("missing output frame"))?; + + let mut outputs: EngineCoreOutputs = decode_msgpack(first_frame.as_ref())?; + outputs.resolve_in_place(frames)?; + Ok(outputs) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + use crate::protocol::output::EngineCoreOutput; + use crate::protocol::{decode_msgpack, encode_msgpack}; + + #[test] + fn engine_core_outputs_roundtrip_finished_fields() { + let outputs = WireEngineCoreOutputs { + outputs: vec![EngineCoreOutput { + request_id: "req-1".to_string(), + new_token_ids: vec![42], + new_logprobs: None, + new_prompt_logprobs_tensors: None, + pooling_output: None, + finish_reason: Some(EngineCoreFinishReason::Length), + stop_reason: Some(StopReason::Text("stop".to_string())), + events: None, + kv_transfer_params: None, + trace_headers: None, + prefill_stats: None, + routed_experts: None, + num_nans_in_logits: 0, + }], + finished_requests: Some(BTreeSet::from(["req-1".to_string()])), + ..Default::default() + }; + + let encoded = encode_msgpack(&outputs).unwrap(); + let decoded: WireEngineCoreOutputs = decode_msgpack(&encoded).unwrap(); + + assert_eq!(decoded.outputs.len(), 1); + assert_eq!( + decoded.outputs[0].finish_reason, + Some(EngineCoreFinishReason::Length) + ); + assert_eq!( + decoded.finished_requests, + Some(BTreeSet::from(["req-1".to_string()])) + ); + } + + #[test] + fn engine_core_outputs_classify_request_batch() { + let outputs = WireEngineCoreOutputs { + outputs: vec![EngineCoreOutput { + request_id: "req-1".to_string(), + new_token_ids: vec![7], + ..Default::default() + }], + finished_requests: Some(BTreeSet::from(["req-1".to_string()])), + ..Default::default() + }; + + expect_test::expect![[r#" + RequestBatch( + RequestBatchOutputs { + engine_index: 0, + outputs: [ + EngineCoreOutput { + request_id: "req-1", + new_token_ids: [ + 7, + ], + new_logprobs: None, + new_prompt_logprobs_tensors: None, + pooling_output: None, + finish_reason: None, + stop_reason: None, + events: None, + kv_transfer_params: None, + trace_headers: None, + prefill_stats: None, + routed_experts: None, + num_nans_in_logits: 0, + }, + ], + scheduler_stats: None, + timestamp: 0.0, + finished_requests: Some( + { + "req-1", + }, + ), + }, + ) + "#]] + .assert_debug_eq(&EngineCoreOutputs::try_from(outputs).unwrap()); + } + + #[test] + fn engine_core_outputs_classify_utility() { + let outputs = WireEngineCoreOutputs { + utility_output: Some(UtilityOutput { + call_id: 42_u64.into(), + failure_message: None, + result: None, + }), + ..Default::default() + }; + + expect_test::expect![[r#" + Utility( + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { + call_id: 42, + failure_message: None, + result: None, + }, + }, + ) + "#]] + .assert_debug_eq(&EngineCoreOutputs::try_from(outputs).unwrap()); + } + + #[test] + fn engine_core_outputs_classify_control() { + let outputs = WireEngineCoreOutputs { + start_wave: Some(3), + ..Default::default() + }; + + expect_test::expect![[r#" + DpControl( + DpControlOutput { + engine_index: 0, + timestamp: 0.0, + control: StartWave( + 3, + ), + }, + ) + "#]] + .assert_debug_eq(&EngineCoreOutputs::try_from(outputs).unwrap()); + } + + #[test] + fn engine_core_outputs_rejects_mixed_shape() { + let outputs = WireEngineCoreOutputs { + outputs: vec![EngineCoreOutput { + request_id: "req-1".to_string(), + new_token_ids: vec![7], + ..Default::default() + }], + utility_output: Some(UtilityOutput { + call_id: 1_u64.into(), + failure_message: None, + result: None, + }), + ..Default::default() + }; + + let error = EngineCoreOutputs::try_from(outputs).unwrap_err(); + expect_test::expect![[ + r#"messagepack decode failed for EngineCoreOutputs: invalid wire shape"# + ]] + .assert_eq(&error.to_string()); + } +} diff --git a/rust/src/engine-core-client/src/protocol/request.rs b/rust/src/engine-core-client/src/protocol/request.rs new file mode 100644 index 000000000000..b7993a3f7c0a --- /dev/null +++ b/rust/src/engine-core-client/src/protocol/request.rs @@ -0,0 +1,175 @@ +use std::collections::{BTreeMap, HashMap}; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use serde_default::DefaultFromSerde; +use serde_tuple::{Deserialize_tuple, Serialize_tuple}; + +use crate::protocol::multimodal::MmFeatures; +use crate::protocol::sampling::EngineCoreSamplingParams; +use crate::protocol::{OpaqueValue, lora}; +use crate::{Error, Result}; + +/// Request types are encoded as single-byte protocol constants so they can be +/// sent over the ZMQ socket without an extra encoding step. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum EngineCoreRequestType { + Add = 0, + Abort = 1, + StartDpWave = 2, + Utility = 3, +} + +impl EngineCoreRequestType { + /// Decode the single-byte request type frame used on the engine input + /// socket. Returns `None` for unrecognized values. + pub fn from_frame(frame: &[u8]) -> Option { + let [value] = frame else { + return None; + }; + + match value { + 0 => Some(Self::Add), + 1 => Some(Self::Abort), + 2 => Some(Self::StartDpWave), + 3 => Some(Self::Utility), + _ => None, + } + } + + /// Encode the request type as the single-byte frame used on the engine + /// input socket. + pub fn to_frame(self) -> Bytes { + Bytes::from_static(match self { + Self::Add => b"\x00", + Self::Abort => b"\x01", + Self::StartDpWave => b"\x02", + Self::Utility => b"\x03", + }) + } +} + +/// Extra kwargs consumed by engine-side reasoning parsers. +/// +/// Original Python construction point: +/// +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReasoningParserKwargs { + /// Effective kwargs visible to the chat template for this request. + pub chat_template_kwargs: HashMap, +} + +/// Engine-core add-request payload sent from frontend to engine. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple, DefaultFromSerde)] +pub struct EngineCoreRequest { + pub request_id: String, + pub prompt_token_ids: Option>, + /// Multimodal features attached to the request. + pub mm_features: Option, + pub sampling_params: Option, + /// Pooling parameters are preserved in the schema but not yet strongly + /// typed. + pub pooling_params: Option, + pub arrival_time: f64, + #[serde(default)] + pub lora_request: Option, + #[serde(default)] + pub cache_salt: Option, + #[serde(default)] + pub data_parallel_rank: Option, + /// Unsupported in the first-stage Rust client because Python uses a custom + /// tensor/aux-frame encoding path for this field. + #[serde(default)] + pub prompt_embeds: Option, + /// Per-position mask for mixed-mode inputs (e.g. chat completion with + /// `prompt_embeds` content parts). `Some(true)` means real token id; + /// `Some(false)` means the position uses a pre-computed entry from + /// `prompt_embeds`. `None` for pure-tokens and pure-embeds requests. + #[serde(default)] + pub prompt_is_token_ids: Option>, + /// Index of the client, used to ensure outputs are sent back to the same + /// client when scaling out the frontend. + #[serde(default)] + pub client_index: u32, + /// In DP mode, indicates which wave this request is expected to belong to. + #[serde(default)] + pub current_wave: u32, + #[serde(default)] + pub priority: i32, + #[serde(default)] + pub trace_headers: Option>, + #[serde(default)] + pub resumable: bool, + /// Original user-provided request ID, used for output reporting and aborts. + #[serde(default)] + pub external_req_id: Option, + #[serde(default)] + pub reasoning_ended: Option, + /// Reasoning-parser kwargs forwarded from the frontend to the + /// structured-output backend. + #[serde(default)] + pub reasoning_parser_kwargs: Option, + /// If `true`, the request should be added to the scheduler's waiting queue + /// and immediately aborted, so connector-side cleanup runs via the + /// standard `request_finished` hook. + #[serde(default)] + pub abort_immediately: bool, +} + +impl EngineCoreRequest { + /// Validate fields intentionally not supported in the first-stage client. + pub fn validate(&self) -> Result<()> { + if self.prompt_embeds.is_some() { + return Err(Error::UnsupportedField { + context: "EngineCoreRequest", + field: "prompt_embeds", + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use rmpv::Value; + + use super::*; + use crate::protocol::sampling::EngineCoreSamplingParams; + use crate::protocol::{decode_value, encode_msgpack}; + + #[test] + fn engine_core_request_serializes_as_full_array() { + let request = EngineCoreRequest { + request_id: "req-1".to_string(), + prompt_token_ids: Some(vec![1, 2, 3]), + sampling_params: Some(EngineCoreSamplingParams { + max_tokens: 8, + ..EngineCoreSamplingParams::for_test() + }), + arrival_time: 1234.5, + client_index: 7, + ..EngineCoreRequest::default() + }; + + let encoded = encode_msgpack(&request).unwrap(); + let value = decode_value(&encoded).unwrap(); + let array = match value { + Value::Array(array) => array, + other => panic!("expected array, got {other:?}"), + }; + + assert_eq!(array.len(), 20); + assert_eq!(array[0], Value::from("req-1")); + assert_eq!(array[2], Value::Nil); + assert_eq!(array[4], Value::Nil); + assert_eq!(array[10], Value::Nil); + assert_eq!(array[11], Value::from(7)); + } +} diff --git a/rust/src/engine-core-client/src/protocol/sampling.rs b/rust/src/engine-core-client/src/protocol/sampling.rs new file mode 100644 index 000000000000..5b52ded0651d --- /dev/null +++ b/rust/src/engine-core-client/src/protocol/sampling.rs @@ -0,0 +1,240 @@ +use std::collections::{BTreeSet, HashMap}; + +use serde::{Deserialize, Serialize}; +use serde_default::DefaultFromSerde; + +use crate::protocol::structured_outputs::StructuredOutputsParams; + +fn default_top_p() -> f32 { + 1.0 +} + +fn default_repetition_penalty() -> f32 { + 1.0 +} + +fn default_temperature() -> f32 { + 1.0 +} + +fn default_max_tokens() -> u32 { + 16 +} + +/// +/// Parameters for detecting repetitive N-gram patterns in output tokens. +/// +/// Mirrors Python's `RepetitionDetectionParams`: +/// +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RepetitionDetectionParams { + /// Maximum N-gram size to check. 0 disables detection. + pub max_pattern_size: u32, + /// Minimum N-gram size to check. Defaults to 1 when zero. + #[serde(default)] + pub min_pattern_size: u32, + /// Minimum number of repetitions to trigger detection (must be >= 2). + pub min_count: u32, +} + +impl RepetitionDetectionParams { + /// Return `true` when the params are effectively disabled (max_pattern_size + /// is 0). + pub fn is_disabled(&self) -> bool { + self.max_pattern_size == 0 + } +} + +/// Engine-core-facing sampling parameters for text generation. +/// +/// This is the normalized southbound subset used by the Rust frontend when it +/// talks to Python engine-core over the wire. User-facing request semantics +/// such as `stop` strings, `n`, `ignore_eos`, and output aggregation mode are +/// intentionally handled by higher layers before values reach this DTO. +/// +/// Original Python definition: +/// +// Python's SamplingParams is `omit_defaults=True`, so msgpack drops +// default-valued keys; default the whole struct. Per-field fns cover the +// non-zero defaults. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, DefaultFromSerde)] +#[serde(default)] +pub struct EngineCoreSamplingParams { + /// Controls randomness. Lower values are more deterministic; zero means + /// greedy sampling. + #[serde(default = "default_temperature")] + pub temperature: f32, + /// Cumulative probability threshold for nucleus sampling. + #[serde(default = "default_top_p")] + pub top_p: f32, + /// Maximum number of top tokens to consider. `0` means all tokens. + pub top_k: u32, + /// Random seed used by the sampler when present. + pub seed: Option, + /// Maximum number of tokens to generate per output sequence. + #[serde(default = "default_max_tokens")] + pub max_tokens: u32, + /// Minimum number of tokens to generate before EOS or stop-token handling. + pub min_tokens: u32, + /// Maximum number of reasoning ("thinking") tokens to emit before the + /// reasoning section is force-closed. `None` means unlimited; the + /// user-facing `-1` sentinel is normalized to `None` by the frontend before + /// reaching this DTO, so only non-negative values are sent. Enforced + /// engine-side (and only when a reasoning parser is configured). + pub thinking_token_budget: Option, + /// Number of log probabilities to return per generated token. + /// + /// `None` disables sample logprobs. `-1` requests the full vocabulary. + pub logprobs: Option, + /// Number of log probabilities to return per prompt token. + /// + /// `None` disables prompt logprobs. `-1` requests the full vocabulary. + pub prompt_logprobs: Option, + /// Minimum probability threshold for token sampling. + pub min_p: f32, + /// Frequency penalty applied by the sampler. + pub frequency_penalty: f32, + /// Presence penalty applied by the sampler. + pub presence_penalty: f32, + /// Repetition penalty applied by the sampler. + #[serde(default = "default_repetition_penalty")] + pub repetition_penalty: f32, + /// Parameters for detecting repetitive N-gram patterns. `None` disables + /// detection. + pub repetition_detection: Option, + /// Token IDs that stop generation. + pub stop_token_ids: Vec, + /// Primary EOS token ID used by engine-core's dedicated EOS stop path. + /// + /// This mirrors Python's internal `_eos_token_id` field and is derived by + /// the frontend from tokenizer/model metadata rather than supplied directly + /// by end users. + #[serde(rename = "_eos_token_id")] + pub eos_token_id: Option, + /// Complete stop-token set used by engine-core for `min_tokens` masking. + /// + /// This mirrors Python's internal `_all_stop_token_ids` field and should + /// contain explicit `stop_token_ids` plus any frontend-derived EOS token + /// IDs. + #[serde(rename = "_all_stop_token_ids")] + pub all_stop_token_ids: BTreeSet, + /// Logit biases to apply during sampling. + /// Keys are token IDs + pub logit_bias: Option>, + /// Restrict output to these token IDs only. + pub allowed_token_ids: Option>, + /// Tokenized bad words to avoid during generation. + #[serde(rename = "_bad_words_token_ids")] + pub bad_words_token_ids: Option>>, + /// Parameters for configuring structured outputs (guided decoding). + pub structured_outputs: Option, + /// Specific token IDs for which log probabilities should be returned at + /// each position. + /// + /// When set, the engine returns logprobs for exactly these tokens in + /// addition to the sampled/scored token. Mutually exclusive with the + /// `logprobs` count field in practice. + pub logprob_token_ids: Option>, + /// If `Some(true)`, the request will not attempt to read from the prefix + /// cache; newly computed blocks may still populate the cache. `None` + /// defers to engine-core defaults. + pub skip_reading_prefix_cache: Option, + /// Additional request parameters for custom extensions (from `vllm_xargs`). + pub extra_args: Option>, +} + +impl EngineCoreSamplingParams { + /// Constructs a default sampling params for testing purposes only. + pub fn for_test() -> Self { + Self { + temperature: 1.0, + top_p: 1.0, + top_k: 0, + seed: None, + max_tokens: 65536, + min_tokens: 0, + thinking_token_budget: None, + logprobs: None, + prompt_logprobs: None, + min_p: 0.0, + frequency_penalty: 0.0, + presence_penalty: 0.0, + repetition_penalty: 1.0, + repetition_detection: None, + stop_token_ids: Vec::new(), + eos_token_id: None, + all_stop_token_ids: BTreeSet::new(), + logit_bias: None, + allowed_token_ids: None, + bad_words_token_ids: None, + structured_outputs: None, + logprob_token_ids: None, + skip_reading_prefix_cache: None, + extra_args: None, + } + } +} + +#[cfg(test)] +mod tests { + use rmpv::Value; + + use crate::protocol::decode_msgpack; + use crate::protocol::request::EngineCoreRequest; + + /// A real `sampling_params` is a sparse `omit_defaults` map; absent fields + /// must fall back to defaults. `python_compat` can't catch this since Rust + /// encodes full maps (see `engine_core_request_serializes_as_full_array`). + #[test] + fn decodes_sampling_params_with_omitted_defaults() { + let sampling_params = Value::Map(vec![ + ( + Value::from("stop_token_ids"), + Value::Array(vec![Value::from(151643u32)]), + ), + (Value::from("skip_reading_prefix_cache"), Value::from(false)), + ]); + let request = Value::Array(vec![ + Value::from("req-omit-defaults"), + Value::Array(vec![ + Value::from(1u32), + Value::from(2u32), + Value::from(3u32), + ]), + Value::Nil, + sampling_params, + Value::Nil, + Value::from(1.0f64), + ]); + + let mut bytes = Vec::new(); + rmpv::encode::write_value(&mut bytes, &request).unwrap(); + + let decoded: EngineCoreRequest = decode_msgpack(&bytes) + .expect("a real omit_defaults request must decode (regression: missing field)"); + + assert_eq!(decoded.request_id, "req-omit-defaults"); + let sampling = decoded.sampling_params.expect("sampling params present"); + + assert_eq!(sampling.stop_token_ids, vec![151643]); + assert_eq!(sampling.skip_reading_prefix_cache, Some(false)); + + // Omitted fields -> Python defaults. + assert_eq!(sampling.temperature, 1.0); + assert_eq!(sampling.top_p, 1.0); + assert_eq!(sampling.top_k, 0); + assert_eq!(sampling.seed, None); + assert_eq!(sampling.max_tokens, 16); + assert_eq!(sampling.min_tokens, 0); + assert_eq!(sampling.min_p, 0.0); + assert_eq!(sampling.frequency_penalty, 0.0); + assert_eq!(sampling.presence_penalty, 0.0); + assert_eq!(sampling.repetition_penalty, 1.0); + assert_eq!(sampling.repetition_detection, None); + assert_eq!(sampling.logprobs, None); + assert_eq!(sampling.prompt_logprobs, None); + assert_eq!(sampling.eos_token_id, None); + assert!(sampling.all_stop_token_ids.is_empty()); + } +} diff --git a/rust/src/engine-core-client/src/protocol/stats.rs b/rust/src/engine-core-client/src/protocol/stats.rs index 254efc31b242..1fc9606bcdf2 100644 --- a/rust/src/engine-core-client/src/protocol/stats.rs +++ b/rust/src/engine-core-client/src/protocol/stats.rs @@ -141,7 +141,7 @@ pub struct PerfStats { /// Original Python definition: /// #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct CudagraphStat { +pub struct CudagraphStats { /// Number of real tokens in the captured batch before padding. pub num_unpadded_tokens: u64, /// Number of padded tokens in the captured batch. @@ -181,12 +181,8 @@ pub struct SchedulerStats { pub spec_decoding_stats: Option, /// Connector-specific KV transfer stats, kept opaque for now. pub kv_connector_stats: Option>, - /// Waiting request counts per LoRA adapter. - pub waiting_lora_adapters: BTreeMap, - /// Running request counts per LoRA adapter. - pub running_lora_adapters: BTreeMap, /// CUDA graph runtime stats when graph metrics are enabled. - pub cudagraph_stats: Option, + pub cudagraph_stats: Option, /// Estimated MFU/performance stats, when enabled. pub perf_stats: Option, } diff --git a/rust/src/engine-core-client/src/protocol/structured_outputs.rs b/rust/src/engine-core-client/src/protocol/structured_outputs.rs new file mode 100644 index 000000000000..ed82d3e5589e --- /dev/null +++ b/rust/src/engine-core-client/src/protocol/structured_outputs.rs @@ -0,0 +1,305 @@ +use enum_as_inner::EnumAsInner; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use crate::error::{Error, Result}; + +/// Structured-output backend selected for EngineCore grammar compilation. +/// +/// Python vLLM stores this in `StructuredOutputsParams._backend` after request +/// validation. The Rust frontend currently always lowers structured-output +/// requests to guidance, while ignoring any user-supplied `_backend` value. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum StructuredOutputBackend { + Xgrammar, + #[default] + Guidance, + Outlines, + LmFormatEnforcer, +} + +/// The single structured-output constraint selected for a request. +#[derive(Debug, Clone, PartialEq, EnumAsInner)] +pub enum StructuredOutputConstraint { + /// JSON schema (as a dict/object or JSON string) constraining the output. + Json(Value), + /// Regular expression the output must match. + Regex(String), + /// List of allowed output strings (the model must produce one of these). + Choice(Vec), + /// Context-free grammar (in EBNF-like notation) the output must conform to. + Grammar(String), + /// Output must be valid JSON (free-form, no schema). + JsonObject, + /// Structural tag configuration (JSON-encoded string). + StructuralTag(String), +} + +/// Additional structured-output options that do not select the constraint mode. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StructuredOutputOptions { + /// Disable any additional whitespace in guided JSON output. + pub disable_any_whitespace: bool, + /// Disable `additionalProperties` in JSON schema output. + pub disable_additional_properties: bool, + /// Custom whitespace pattern for guided JSON output. + pub whitespace_pattern: Option, +} + +/// Parameters for configuring structured outputs (guided decoding). +/// +/// This is the semantic Rust representation: exactly one constraint mode is +/// always selected. The Python/msgpack product-shaped representation is kept in +/// the private wire type below and used only at serde boundaries. +/// +/// Original Python definition: +/// +#[derive(Debug, Clone, PartialEq)] +pub struct StructuredOutputsParams { + pub constraint: StructuredOutputConstraint, + pub options: StructuredOutputOptions, + /// Structured-output backend, mirroring Python's internal `_backend`. + /// + /// User-supplied values are ignored during deserialization. This matches + /// Python's request boundary, where `_backend` is set by validation rather + /// than accepted as a request-level backend selector. + pub backend: StructuredOutputBackend, +} + +impl StructuredOutputsParams { + pub fn json(json: Value) -> Self { + Self::from_constraint(StructuredOutputConstraint::Json(json)) + } + + pub fn regex(regex: impl Into) -> Self { + Self::from_constraint(StructuredOutputConstraint::Regex(regex.into())) + } + + pub fn choice(choice: Vec) -> Self { + Self::from_constraint(StructuredOutputConstraint::Choice(choice)) + } + + pub fn grammar(grammar: impl Into) -> Self { + Self::from_constraint(StructuredOutputConstraint::Grammar(grammar.into())) + } + + pub fn json_object() -> Self { + Self::from_constraint(StructuredOutputConstraint::JsonObject) + } + + pub fn structural_tag(structural_tag: impl Into) -> Self { + Self::from_constraint(StructuredOutputConstraint::StructuralTag( + structural_tag.into(), + )) + } + + fn from_constraint(constraint: StructuredOutputConstraint) -> Self { + Self { + constraint, + options: StructuredOutputOptions::default(), + backend: StructuredOutputBackend::default(), + } + } +} + +/// Wire-compatible structured-output payload used by Python engine-core. +/// +/// Python models `StructuredOutputsParams` as a product-shaped dataclass with +/// several optional constraint fields, then validates that exactly one of those +/// fields is present. Rust exposes [`StructuredOutputsParams`] as an enum-backed +/// domain type instead, while using this private wire type for ser/de. +#[serde_with::skip_serializing_none] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +struct WireStructuredOutputsParams { + json: Option, + regex: Option, + choice: Option>, + grammar: Option, + json_object: Option, + #[serde(skip_serializing_if = "crate::protocol::is_false")] + disable_any_whitespace: bool, + #[serde(skip_serializing_if = "crate::protocol::is_false")] + disable_additional_properties: bool, + whitespace_pattern: Option, + structural_tag: Option, + #[serde( + default, + rename = "_backend", + deserialize_with = "serde_with::rust::deserialize_ignore_any" + )] + backend: StructuredOutputBackend, +} + +impl TryFrom for StructuredOutputsParams { + type Error = Error; + + fn try_from(raw: WireStructuredOutputsParams) -> Result { + use StructuredOutputConstraint::*; + + let mut constraint = None; + + macro_rules! insert_constraint { + ($name:literal, $value:expr) => { + if let Some(value) = $value { + if let Some((existing, _)) = constraint { + return Err(Error::InvalidStructuredOutputsParams { + message: format!( + "multiple structured output constraints specified: {existing}, {}", + $name + ), + }); + } + constraint = Some(($name, value)); + } + }; + } + + insert_constraint!("json", raw.json.map(Json)); + insert_constraint!("regex", raw.regex.map(Regex)); + insert_constraint!("choice", raw.choice.map(Choice)); + insert_constraint!("grammar", raw.grammar.map(Grammar)); + match raw.json_object { + Some(true) => { + insert_constraint!("json_object", Some(JsonObject)) + } + Some(false) => { + return Err(Error::InvalidStructuredOutputsParams { + message: "structured_outputs.json_object must be true if set; omit structured_outputs to disable structured outputs".to_string(), + }); + } + None => {} + } + insert_constraint!("structural_tag", raw.structural_tag.map(StructuralTag)); + + Ok(Self { + constraint: constraint.map(|(_, c)| c).ok_or_else(|| { + Error::InvalidStructuredOutputsParams { + message: "missing structured output constraint".to_string(), + } + })?, + options: StructuredOutputOptions { + disable_any_whitespace: raw.disable_any_whitespace, + disable_additional_properties: raw.disable_additional_properties, + whitespace_pattern: raw.whitespace_pattern, + }, + backend: raw.backend, + }) + } +} + +impl From for WireStructuredOutputsParams { + fn from(params: StructuredOutputsParams) -> Self { + let mut raw = Self { + disable_any_whitespace: params.options.disable_any_whitespace, + disable_additional_properties: params.options.disable_additional_properties, + whitespace_pattern: params.options.whitespace_pattern, + backend: params.backend, + ..Self::default() + }; + + match params.constraint { + StructuredOutputConstraint::Json(json) => raw.json = Some(json), + StructuredOutputConstraint::Regex(regex) => raw.regex = Some(regex), + StructuredOutputConstraint::Choice(choice) => raw.choice = Some(choice), + StructuredOutputConstraint::Grammar(grammar) => raw.grammar = Some(grammar), + StructuredOutputConstraint::JsonObject => raw.json_object = Some(true), + StructuredOutputConstraint::StructuralTag(structural_tag) => { + raw.structural_tag = Some(structural_tag); + } + } + + raw + } +} + +impl Serialize for StructuredOutputsParams { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + WireStructuredOutputsParams::from(self.clone()).serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for StructuredOutputsParams { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + WireStructuredOutputsParams::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn structured_outputs_backend_ignores_deserialized_value() { + let params: StructuredOutputsParams = serde_json::from_value(serde_json::json!({ + "json_object": true, + "_backend": "xgrammar", + })) + .unwrap(); + + assert_eq!(params.backend, StructuredOutputBackend::Guidance); + assert_eq!(params.constraint, StructuredOutputConstraint::JsonObject); + + let value = serde_json::to_value(params).unwrap(); + assert_eq!(value["_backend"], "guidance"); + } + + #[test] + fn structured_outputs_rejects_missing_constraint() { + let error = + serde_json::from_value::(serde_json::json!({})).unwrap_err(); + + assert!(error.to_string().contains("missing structured output constraint")); + } + + #[test] + fn structured_outputs_rejects_multiple_constraints() { + let error = serde_json::from_value::(serde_json::json!({ + "json": {"type": "object"}, + "regex": ".*", + })) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("multiple structured output constraints specified: json, regex") + ); + } + + #[test] + fn structured_outputs_rejects_json_object_false() { + let error = serde_json::from_value::(serde_json::json!({ + "json_object": false, + })) + .unwrap_err(); + + assert!(error.to_string().contains("json_object must be true")); + } + + #[test] + fn structured_outputs_serializes_through_raw_shape() { + let params = StructuredOutputsParams { + constraint: StructuredOutputConstraint::StructuralTag( + r#"{"type":"structural_tag"}"#.to_string(), + ), + options: StructuredOutputOptions::default(), + backend: StructuredOutputBackend::Xgrammar, + }; + + let value = serde_json::to_value(params).unwrap(); + + assert_eq!(value["structural_tag"], r#"{"type":"structural_tag"}"#); + assert_eq!(value["_backend"], "xgrammar"); + assert!(value.get("json").is_none()); + } +} diff --git a/rust/src/engine-core-client/src/protocol/tensor.rs b/rust/src/engine-core-client/src/protocol/tensor.rs index b67112154817..b80472129b7c 100644 --- a/rust/src/engine-core-client/src/protocol/tensor.rs +++ b/rust/src/engine-core-client/src/protocol/tensor.rs @@ -11,6 +11,21 @@ use serde_tuple::{Deserialize_tuple, Serialize_tuple}; /// const CUSTOM_TYPE_RAW_VIEW: i8 = 3; +#[derive(Serialize)] +#[serde(rename = "_ExtStruct")] +struct MsgpackExtRef<'a>((i8, ByteSlice<'a>)); + +struct ByteSlice<'a>(&'a [u8]); + +impl Serialize for ByteSlice<'_> { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_bytes(self.0) + } +} + #[easy_ext::ext(ShapeExt)] impl [usize] { /// Returned the total number of elements implied by this shape, or `None` @@ -184,7 +199,7 @@ impl Serialize for WireArrayData { match self { Self::AuxIndex(index) => serializer.serialize_u64(*index as u64), Self::RawView(bytes) => { - Value::Ext(CUSTOM_TYPE_RAW_VIEW, bytes.clone()).serialize(serializer) + MsgpackExtRef((CUSTOM_TYPE_RAW_VIEW, ByteSlice(bytes))).serialize(serializer) } } } @@ -194,6 +209,21 @@ impl Serialize for WireArrayData { mod tests { use super::*; + #[test] + fn raw_view_serializes_as_msgpack_ext() { + let bytes = vec![1, 2, 3, 4]; + let encoded = + rmp_serde::to_vec_named(&WireArrayData::RawView(bytes.clone())).expect("encode"); + let expected = rmp_serde::to_vec_named(&Value::Ext(CUSTOM_TYPE_RAW_VIEW, bytes.clone())) + .expect("encode expected"); + + assert_eq!(encoded, expected); + assert_eq!( + rmpv::decode::read_value(&mut std::io::Cursor::new(encoded)).expect("decode"), + Value::Ext(CUSTOM_TYPE_RAW_VIEW, bytes) + ); + } + #[test] fn constructors_build_raw_view_tensors() { let f32_tensor = WireNdArray::from_f32(vec![2], vec![1.0, 2.5]).unwrap(); diff --git a/rust/src/engine-core-client/src/protocol/utility.rs b/rust/src/engine-core-client/src/protocol/utility.rs index ef7e862d5171..e15ea6bea057 100644 --- a/rust/src/engine-core-client/src/protocol/utility.rs +++ b/rust/src/engine-core-client/src/protocol/utility.rs @@ -1,15 +1,64 @@ use std::any::type_name; use std::fmt; +use std::str::FromStr; use rmpv::Value; use serde::{Deserialize, Serialize}; use serde_default::DefaultFromSerde; use serde_tuple::{Deserialize_tuple, Serialize_tuple}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; use thiserror_ext::AsReport; use super::{OpaqueValue, default_opaque_value_nil}; use crate::error::{Error, Result}; +/// How pause/sleep utility calls handle in-flight requests. +/// +/// Use display/from-str serde so MessagePack utility args stay as Python +/// literal strings instead of serde enum variant tuples. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, SerializeDisplay, DeserializeFromStr)] +pub enum PauseMode { + /// Abort all in-flight requests immediately. + #[default] + Abort, + /// Wait for in-flight requests to complete. + Wait, + /// Freeze queued requests so they can resume later. + Keep, +} + +impl PauseMode { + /// Return the Python literal used on the utility-call wire. + pub fn as_str(self) -> &'static str { + match self { + Self::Abort => "abort", + Self::Wait => "wait", + Self::Keep => "keep", + } + } +} + +impl fmt::Display for PauseMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for PauseMode { + type Err = String; + + fn from_str(value: &str) -> std::result::Result { + match value { + "abort" => Ok(Self::Abort), + "wait" => Ok(Self::Wait), + "keep" => Ok(Self::Keep), + other => Err(format!( + "invalid pause mode `{other}`; expected one of: abort, wait, keep" + )), + } + } +} + /// Utility call id as carried on the engine-core MessagePack wire. /// /// Python emits utility ids as MessagePack integers, including values that may @@ -212,7 +261,7 @@ mod tests { use rmpv::Value; use serde::Serialize; - use super::{EngineCoreUtilityRequest, UtilityOutput, UtilityResultEnvelope}; + use super::{EngineCoreUtilityRequest, PauseMode, UtilityOutput, UtilityResultEnvelope}; use crate::Error; use crate::protocol::{decode_msgpack, decode_value, encode_msgpack}; @@ -241,6 +290,26 @@ mod tests { assert_eq!(array[3], Value::Array(Vec::new())); } + #[test] + fn pause_mode_serializes_as_python_literal() { + let request = + EngineCoreUtilityRequest::new(7, 42, "pause_scheduler", (PauseMode::Abort, true)) + .unwrap(); + + let encoded = encode_msgpack(&request).unwrap(); + let value = decode_value(&encoded).unwrap(); + let array = match value { + Value::Array(array) => array, + other => panic!("expected utility request array, got {other:?}"), + }; + + assert_eq!(array[2], Value::from("pause_scheduler")); + assert_eq!( + array[3], + Value::Array(vec![Value::from("abort"), Value::from(true)]) + ); + } + #[test] fn utility_output_decodes_typed_result() { let output = UtilityOutput { diff --git a/rust/src/engine-core-client/src/runtime.rs b/rust/src/engine-core-client/src/runtime.rs new file mode 100644 index 000000000000..2015bff75213 --- /dev/null +++ b/rust/src/engine-core-client/src/runtime.rs @@ -0,0 +1,75 @@ +use std::mem::ManuallyDrop; +use std::ops::{Deref, DerefMut}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use tokio::runtime::Runtime; + +/// A wrapper around [`Runtime`] that shuts down the runtime in the background when dropped. +/// +/// This can be useful in some cases, because sometimes we want to drop the runtime without +/// blocking the current thread, for example, when it's nested inside another runtime. +pub struct BackgroundShutdownRuntime(ManuallyDrop); + +impl Drop for BackgroundShutdownRuntime { + fn drop(&mut self) { + // Safety: The runtime is only dropped once here. + let runtime = unsafe { ManuallyDrop::take(&mut self.0) }; + runtime.shutdown_background(); + } +} + +impl Deref for BackgroundShutdownRuntime { + type Target = Runtime; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for BackgroundShutdownRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for BackgroundShutdownRuntime { + fn from(runtime: Runtime) -> Self { + Self(ManuallyDrop::new(runtime)) + } +} + +const ZMQ_WORKER_THREADS_ENV: &str = "VLLM_RS_ZMQ_WORKER_THREADS"; +/// The number of tasks running on the ZMQ runtime is fixed and expected to remain +/// small, and multiple engines share the same ZMQ socket. Therefore, based on +/// benchmarks, a default value of 4 is generally sufficient. +const DEFAULT_ZMQ_WORKER_THREADS: usize = 4; + +static ZMQ_RUNTIME_SEQUENCE: OnceLock = OnceLock::new(); + +/// Build a Tokio runtime for ZMQ tasks. Multiple calls to this function will +/// return multiple runtimes with distinct thread name suffixes. +pub(crate) fn build_zmq_runtime() -> BackgroundShutdownRuntime { + let sequence = ZMQ_RUNTIME_SEQUENCE + .get_or_init(|| AtomicUsize::new(0)) + .fetch_add(1, Ordering::Relaxed); + + tokio::runtime::Builder::new_multi_thread() + .worker_threads(zmq_worker_threads()) + .thread_name_fn(move || format!("vllm-zmq-{sequence}")) + .enable_all() + .build() + .expect("failed to build vLLM ZMQ runtime") + .into() +} + +/// Get the number of worker threads to use for the ZMQ runtime. If env var +/// `VLLM_RS_ZMQ_WORKER_THREADS` is set and a valid positive integer, it will be used. +/// Otherwise, the default value of `DEFAULT_ZMQ_WORKER_THREADS` will be used. +fn zmq_worker_threads() -> usize { + std::env::var(ZMQ_WORKER_THREADS_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(DEFAULT_ZMQ_WORKER_THREADS) +} diff --git a/rust/src/engine-core-client/src/test_utils.rs b/rust/src/engine-core-client/src/test_utils.rs index 06f56380ab18..0d777c912188 100644 --- a/rust/src/engine-core-client/src/test_utils.rs +++ b/rust/src/engine-core-client/src/test_utils.rs @@ -12,7 +12,7 @@ use crate::mock_engine::{ MockEngineConfig, MockEngineDataSockets, connect_to_bootstrapped_frontend, connect_to_frontend, default_ready_response, }; -use crate::protocol::handshake::HandshakeInitMessage; +use crate::protocol::handshake::{EngineCoreReadyResponse, HandshakeInitMessage}; /// Per-test IPC endpoint namespace backed by a unique temporary directory. /// @@ -62,6 +62,15 @@ fn test_mock_engine_config() -> MockEngineConfig { } } +fn test_mock_engine_config_with_ready(ready_response: EngineCoreReadyResponse) -> MockEngineConfig { + MockEngineConfig { + local: true, + headless: true, + ready_response, + ..Default::default() + } +} + /// Complete the engine-core handshake and connect mock input/output sockets /// plus optional coordinator sockets. pub async fn setup_mock_engine_sockets( @@ -147,3 +156,49 @@ where }); (shutdown_tx, engine_task) } + +/// Like [`setup_mock_engine`] but uses a custom ready response for the +/// handshake, allowing tests to control `world_size`, `data_parallel_size`, +/// etc. +async fn setup_mock_engine_with_ready( + engine_handshake: String, + engine_id: impl Into, + ready_response: EngineCoreReadyResponse, +) -> (DealerSocket, PushSocket) { + let config = test_mock_engine_config_with_ready(ready_response); + let MockEngineSockets { data_sockets, .. } = + connect_to_frontend(engine_handshake, engine_id, config) + .await + .expect("connect mock engine with custom ready response"); + let MockEngineDataSockets { dealer, push } = + data_sockets.into_iter().next().expect("mock engine data socket"); + (dealer, push) +} + +/// Like [`spawn_mock_engine_task`] but uses a custom ready response for the +/// handshake, allowing tests to set `world_size` and `data_parallel_size` to +/// non-default values. +pub fn spawn_mock_engine_task_with_ready( + engine_handshake: String, + engine_id: impl Into, + ready_response: EngineCoreReadyResponse, + run: F, +) -> (oneshot::Sender<()>, tokio::task::JoinHandle<()>) +where + F: for<'a> FnOnce( + &'a mut DealerSocket, + &'a mut PushSocket, + ) -> Pin + Send + 'a>> + + Send + + 'static, +{ + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let engine_id = engine_id.into(); + let engine_task = tokio::spawn(async move { + let (mut dealer, mut push) = + setup_mock_engine_with_ready(engine_handshake, engine_id, ready_response).await; + run(&mut dealer, &mut push).await; + let _ = shutdown_rx.await; + }); + (shutdown_tx, engine_task) +} diff --git a/rust/src/engine-core-client/src/tests/client.rs b/rust/src/engine-core-client/src/tests/client.rs index 9a92ffe447e6..b433e9060357 100644 --- a/rust/src/engine-core-client/src/tests/client.rs +++ b/rust/src/engine-core-client/src/tests/client.rs @@ -22,13 +22,15 @@ use crate::protocol::multimodal::{ MmFeatureSpec, MmField, MmFieldElem, MmFlatField, MmKwargValue, MmSlice, PlaceholderRange, SliceSpec, }; +use crate::protocol::output::{ + DpControlMessage, DpControlOutput, EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, + RequestBatchOutputs, UtilityCallOutput, decode_engine_core_outputs, +}; +use crate::protocol::request::{EngineCoreRequest, EngineCoreRequestType}; +use crate::protocol::sampling::EngineCoreSamplingParams; use crate::protocol::stats::SchedulerStats; use crate::protocol::tensor::WireTensor; use crate::protocol::utility::{UtilityOutput, UtilityResultEnvelope}; -use crate::protocol::{ - EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest, - EngineCoreRequestType, EngineCoreSamplingParams, decode_engine_core_outputs, -}; use crate::test_utils::{ IpcNamespace, setup_bootstrapped_mock_engine, setup_mock_engine_sockets, setup_mock_engine_with_init, spawn_mock_engine_task, @@ -150,6 +152,7 @@ fn sample_request_with_id(request_id: &str) -> EngineCoreRequest { top_k: 8, max_tokens: 32, min_tokens: 1, + thinking_token_budget: Some(256), stop_token_ids: vec![151643], eos_token_id: Some(151645), all_stop_token_ids: BTreeSet::from([151643, 151645]), @@ -303,6 +306,7 @@ fn bootstrapped_test_config( transport_mode: TransportMode::Bootstrapped { input_address, output_address, + engine_start_index: 0, engine_count, ready_timeout, }, @@ -312,6 +316,34 @@ fn bootstrapped_test_config( } } +fn bootstrapped_test_config_with_start_index( + input_address: String, + output_address: String, + engine_start_index: u32, + engine_count: usize, + ready_timeout: Duration, + client_index: u32, + coordinator_mode: Option, +) -> EngineCoreClientConfig { + let mut config = bootstrapped_test_config( + input_address, + output_address, + engine_count, + ready_timeout, + client_index, + coordinator_mode, + ); + let TransportMode::Bootstrapped { + engine_start_index: start, + .. + } = &mut config.transport_mode + else { + unreachable!("bootstrapped_test_config returns bootstrapped transport") + }; + *start = engine_start_index; + config +} + async fn recv_xpub_message(xpub: &mut XPubSocket) -> Vec { xpub.recv().await.unwrap().into_vec() } @@ -540,8 +572,7 @@ async fn coordinator_wave_control_tracks_pause_running_and_rebroadcasts() { send_outputs( &mut data_socket.push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( "req-1", vec![], @@ -549,17 +580,19 @@ async fn coordinator_wave_control_tracks_pause_running_and_rebroadcasts() { )], finished_requests: Some(BTreeSet::from(["req-1".to_string()])), ..Default::default() - }, + } + .into(), ) .await; send_outputs( &mut coordinator.output_push, - EngineCoreOutputs { + DpControlOutput { engine_index: 0, - wave_complete: Some(0), - ..Default::default() - }, + timestamp: 0.0, + control: DpControlMessage::WaveComplete(0), + } + .into(), ) .await; @@ -574,8 +607,7 @@ async fn coordinator_wave_control_tracks_pause_running_and_rebroadcasts() { send_outputs( &mut data_socket.push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( "req-3", vec![], @@ -583,7 +615,8 @@ async fn coordinator_wave_control_tracks_pause_running_and_rebroadcasts() { )], finished_requests: Some(BTreeSet::from(["req-3".to_string()])), ..Default::default() - }, + } + .into(), ) .await; @@ -620,7 +653,7 @@ async fn coordinator_wave_control_tracks_pause_running_and_rebroadcasts() { send_outputs( &mut data_socket.push, - EngineCoreOutputs { + RequestBatchOutputs { engine_index: 1, outputs: vec![request_output( "req-2", @@ -629,7 +662,8 @@ async fn coordinator_wave_control_tracks_pause_running_and_rebroadcasts() { )], finished_requests: Some(BTreeSet::from(["req-2".to_string()])), ..Default::default() - }, + } + .into(), ) .await; @@ -735,11 +769,12 @@ async fn coordinator_rebroadcasts_engine_start_wave_control() { send_outputs( &mut coordinator.output_push, - EngineCoreOutputs { + DpControlOutput { engine_index: 1, - start_wave: Some(4), - ..Default::default() - }, + timestamp: 0.0, + control: DpControlMessage::StartWave(4), + } + .into(), ) .await; @@ -790,15 +825,16 @@ async fn coordinator_accepts_stats_only_outputs() { send_outputs( &mut coordinator.output_push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { + outputs: Vec::new(), scheduler_stats: Some(Box::new(SchedulerStats { num_running_reqs: 1, current_wave: 0, ..Default::default() })), ..Default::default() - }, + } + .into(), ) .await; @@ -809,8 +845,7 @@ async fn coordinator_accepts_stats_only_outputs() { send_outputs( &mut data_socket.push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( "req-stats", vec![], @@ -818,7 +853,8 @@ async fn coordinator_accepts_stats_only_outputs() { )], finished_requests: Some(BTreeSet::from(["req-stats".to_string()])), ..Default::default() - }, + } + .into(), ) .await; @@ -879,30 +915,34 @@ async fn client_fail_closes_when_main_output_path_receives_dp_control() { send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + output: UtilityOutput { call_id: 1_u64.into(), failure_message: None, result: None, - }), + }, ..Default::default() - }, + } + .into(), ) .await; send_outputs( push, - EngineCoreOutputs { - start_wave: Some(3), - ..Default::default() - }, + DpControlOutput { + engine_index: 0, + timestamp: 0.0, + control: DpControlMessage::StartWave(3), + } + .into(), ) .await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output("req-1", vec![999], None)], ..Default::default() - }, + } + .into(), ) .await; @@ -954,91 +994,6 @@ async fn client_fail_closes_when_main_output_path_receives_dp_control() { client.shutdown().await.unwrap(); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn client_fail_closes_when_main_output_path_receives_mixed_shape_output() { - init_tracing(); - let ipc = IpcNamespace::new().unwrap(); - let handshake_address = ipc.handshake_endpoint(); - let engine_id = b"engine-0".to_vec(); - - let (shutdown_tx, engine_task) = spawn_mock_engine_task( - handshake_address.clone(), - engine_id.clone(), - |dealer, push| { - Box::pin(async move { - let add_1 = recv_engine_message(dealer).await; - assert_eq!(add_1[0].as_ref(), &[0x00]); - let request_1: EngineCoreRequest = rmp_serde::from_slice(&add_1[1]).unwrap(); - assert_eq!(request_1.client_index, 7); - assert_eq!(request_1.request_id, "req-1"); - - let add_2 = recv_engine_message(dealer).await; - assert_eq!(add_2[0].as_ref(), &[0x00]); - let request_2: EngineCoreRequest = rmp_serde::from_slice(&add_2[1]).unwrap(); - assert_eq!(request_2.client_index, 7); - assert_eq!(request_2.request_id, "req-2"); - - send_outputs( - push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { - call_id: 1_u64.into(), - failure_message: None, - result: None, - }), - outputs: vec![request_output("req-1", vec![999], None)], - ..Default::default() - }, - ) - .await; - - tokio::time::sleep(Duration::from_millis(50)).await; - }) - }, - ); - - let client = connect_client_with_ipc( - handshake_test_config( - handshake_address, - 1, - "test-model", - Duration::from_secs(2), - 7, - None, - ), - &ipc, - ) - .await; - assert_eq!(client.engine_identities()[0], b"engine-0"); - assert!(client.ready_responses()[0].max_model_len > 0); - - let mut stream_1 = client.call(sample_request_with_id("req-1")).await.unwrap(); - let mut stream_2 = client.call(sample_request_with_id("req-2")).await.unwrap(); - - let error_2 = timeout(Duration::from_secs(1), stream_2.next()) - .await - .unwrap() - .unwrap() - .unwrap_err(); - assert!(is_unexpected_dispatcher_output(&error_2)); - - let error_1 = timeout(Duration::from_secs(1), stream_1.next()) - .await - .unwrap() - .unwrap() - .unwrap_err(); - assert!(is_unexpected_dispatcher_output(&error_1)); - - assert!(matches!( - client.health_error().as_deref(), - Some(error) if is_unexpected_dispatcher_output(error) - )); - - let _ = shutdown_tx.send(()); - engine_task.await.unwrap(); - client.shutdown().await.unwrap(); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn duplicate_request_ids_are_rejected_without_sending_a_second_add() { init_tracing(); @@ -1060,7 +1015,7 @@ async fn duplicate_request_ids_are_rejected_without_sending_a_second_add() { send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output( "req-1", vec![], @@ -1068,7 +1023,8 @@ async fn duplicate_request_ids_are_rejected_without_sending_a_second_add() { )], finished_requests: Some(BTreeSet::from(["req-1".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1127,10 +1083,12 @@ async fn finished_requests_without_final_output_is_treated_as_unexpected_close() send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { + outputs: Vec::new(), finished_requests: Some(BTreeSet::from(["req-1".to_string()])), ..Default::default() - }, + } + .into(), ) .await; @@ -1186,10 +1144,11 @@ async fn dropping_a_live_stream_triggers_abort() { assert_eq!(add[0].as_ref(), &[0x00]); send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output("req-1", vec![99], None)], ..Default::default() - }, + } + .into(), ) .await; @@ -1225,6 +1184,93 @@ async fn dropping_a_live_stream_triggers_abort() { client.shutdown().await.unwrap(); } +#[tokio::test] +async fn dropping_multiple_live_streams_aborts_all_in_a_burst() { + init_tracing(); + let ipc = IpcNamespace::new().unwrap(); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-burst".to_vec(); + let request_ids = ["req-1", "req-2", "req-3"]; + + let (shutdown_tx, engine_task) = spawn_mock_engine_task( + handshake_address.clone(), + engine_id.clone(), + |dealer, push| { + Box::pin(async move { + for _ in 0..3 { + let add = recv_engine_message(dealer).await; + assert_eq!(add[0].as_ref(), &[0x00]); + } + send_outputs( + push, + RequestBatchOutputs { + outputs: vec![ + request_output("req-1", vec![99], None), + request_output("req-2", vec![99], None), + request_output("req-3", vec![99], None), + ], + ..Default::default() + } + .into(), + ) + .await; + + // Aborts may coalesce into one burst or split across several. + let mut aborted = BTreeSet::new(); + while aborted.len() < 3 { + let abort = + timeout(Duration::from_secs(1), recv_engine_message(dealer)).await.unwrap(); + assert_eq!(abort[0].as_ref(), &[0x01]); + let ids: Vec = rmp_serde::from_slice(&abort[1]).unwrap(); + aborted.extend(ids); + } + assert_eq!( + aborted, + BTreeSet::from([ + "req-1".to_string(), + "req-2".to_string(), + "req-3".to_string() + ]) + ); + // No spurious extra aborts. + assert!( + timeout(Duration::from_millis(100), recv_engine_message(dealer)).await.is_err() + ); + }) + }, + ); + + let client = connect_client_with_ipc( + handshake_test_config( + handshake_address, + 1, + "test-model", + Duration::from_secs(2), + 0, + None, + ), + &ipc, + ) + .await; + + // Open every request first so all three adds reach the engine before it + // emits outputs, then drain the first token from each stream. + let mut streams = Vec::new(); + for id in request_ids { + streams.push(client.call(sample_request_with_id(id)).await.unwrap()); + } + for stream in streams.iter_mut() { + let first = timeout(Duration::from_secs(1), stream.next()).await.unwrap().unwrap().unwrap(); + assert_eq!(first.new_token_ids, vec![99]); + } + // Drop the whole burst back-to-back so the abort worker can batch them. + drop(streams); + + let _ = shutdown_tx.send(()); + engine_task.await.unwrap(); + client.shutdown().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn dispatcher_failure_propagates_to_streams_and_future_calls() { init_tracing(); @@ -1319,14 +1365,16 @@ async fn is_sleeping_wrapper_sends_typed_request_and_returns_typed_response() { send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { call_id: call_id.into(), failure_message: None, result: Some(utility_result_value(true)), - }), - ..Default::default() - }, + }, + } + .into(), ) .await; }) @@ -1374,14 +1422,16 @@ async fn call_utility_failure_message_surfaces_as_error() { send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { call_id: call_id.into(), failure_message: Some("boom".to_string()), result: None, - }), - ..Default::default() - }, + }, + } + .into(), ) .await; }) @@ -1698,8 +1748,7 @@ async fn multi_engine_client_shares_transport_and_routes_by_inflight_count() { finish_req_1_rx.await.unwrap(); send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( &request_1.request_id, vec![10], @@ -1707,7 +1756,8 @@ async fn multi_engine_client_shares_transport_and_routes_by_inflight_count() { )], finished_requests: Some(BTreeSet::from([request_1.request_id.clone()])), ..Default::default() - }, + } + .into(), ) .await; @@ -1719,8 +1769,7 @@ async fn multi_engine_client_shares_transport_and_routes_by_inflight_count() { finish_req_3_rx.await.unwrap(); send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( &request_3.request_id, vec![30], @@ -1728,7 +1777,8 @@ async fn multi_engine_client_shares_transport_and_routes_by_inflight_count() { )], finished_requests: Some(BTreeSet::from([request_3.request_id.clone()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1747,7 +1797,7 @@ async fn multi_engine_client_shares_transport_and_routes_by_inflight_count() { finish_req_2_rx.await.unwrap(); send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { engine_index: 1, outputs: vec![request_output( &request_2.request_id, @@ -1756,7 +1806,8 @@ async fn multi_engine_client_shares_transport_and_routes_by_inflight_count() { )], finished_requests: Some(BTreeSet::from([request_2.request_id.clone()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1859,7 +1910,7 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { let (shutdown_tx_0, engine_task_0) = spawn_mock_engine_task( handshake_address.clone(), - b"engine-0".to_vec(), + EngineId::from_engine_index(0).into_frame().to_vec(), |dealer, push| { Box::pin(async move { let utility = recv_engine_message(dealer).await; @@ -1873,14 +1924,16 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { assert_eq!(array[2], Value::from("is_sleeping")); send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { call_id: call_id.into(), failure_message: None, result: Some(utility_result_value(true)), - }), - ..Default::default() - }, + }, + } + .into(), ) .await; @@ -1895,8 +1948,7 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { assert_eq!(aborted_ids, vec!["req-1".to_string()]); send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( "req-1", vec![], @@ -1904,7 +1956,8 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { )], finished_requests: Some(BTreeSet::from(["req-1".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -1913,7 +1966,7 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { tokio::time::sleep(Duration::from_millis(50)).await; let (shutdown_tx_1, engine_task_1) = spawn_mock_engine_task( handshake_address.clone(), - b"engine-1".to_vec(), + EngineId::from_engine_index(1).into_frame().to_vec(), |dealer, push| { Box::pin(async move { let utility = recv_engine_message(dealer).await; @@ -1927,14 +1980,16 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { assert_eq!(array[2], Value::from("is_sleeping")); send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { call_id: call_id.into(), failure_message: None, result: Some(utility_result_value(true)), - }), - ..Default::default() - }, + }, + } + .into(), ) .await; @@ -1949,7 +2004,7 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { assert_eq!(aborted_ids, vec!["req-2".to_string()]); send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { engine_index: 1, outputs: vec![request_output( "req-2", @@ -1958,7 +2013,8 @@ async fn multi_engine_abort_is_grouped_and_utility_fans_out_to_all_engines() { )], finished_requests: Some(BTreeSet::from(["req-2".to_string()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -2038,14 +2094,16 @@ async fn collective_rpc_flattens_results_from_all_engines() { send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { call_id: call_id.into(), failure_message: None, result: Some(utility_result_value(vec!["engine-0-worker"])), - }), - ..Default::default() - }, + }, + } + .into(), ) .await; }) @@ -2069,14 +2127,16 @@ async fn collective_rpc_flattens_results_from_all_engines() { send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { call_id: call_id.into(), failure_message: None, result: Some(utility_result_value(vec!["engine-1-worker"])), - }), - ..Default::default() - }, + }, + } + .into(), ) .await; }) @@ -2147,14 +2207,16 @@ fn spawn_mock_utility_engine( assert_eq!(array[3], expected_args, "unexpected utility args"); send_outputs( push, - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + engine_index: 0, + timestamp: 0.0, + output: UtilityOutput { call_id: call_id.into(), failure_message: None, result: Some(utility_result_value(result)), - }), - ..Default::default() - }, + }, + } + .into(), ) .await; }) @@ -2358,6 +2420,7 @@ fn python_msgpack_fixtures_match_rust_encoding() { let stdout = String::from_utf8(output.stdout).unwrap(); let mut lines = stdout.lines(); let request_hex = lines.next().expect("missing request fixture line"); + let defaults_request_hex = lines.next().expect("missing defaults request fixture line"); let multimodal_request_hex = lines.next().expect("missing multimodal request fixture line"); let outputs_hex = lines.next().expect("missing outputs fixture line"); let inline_logprobs_frames = lines.next().expect("missing inline logprobs fixture line"); @@ -2365,6 +2428,7 @@ fn python_msgpack_fixtures_match_rust_encoding() { let inline_prompt_frames = lines.next().expect("missing inline prompt logprobs fixture line"); let multipart_prompt_frames = lines.next().expect("missing multipart prompt logprobs fixture line"); + let ready_response_hex = lines.next().expect("missing ready response fixture line"); let request_bytes = hex::decode(request_hex).unwrap(); let multimodal_request_bytes = hex::decode(multimodal_request_hex).unwrap(); @@ -2374,6 +2438,44 @@ fn python_msgpack_fixtures_match_rust_encoding() { let expected_request = sample_request(); assert_eq!(decoded_request, expected_request); + // All-default sampling params -> empty map; must decode to Python defaults. + let defaults_request_bytes = hex::decode(defaults_request_hex).unwrap(); + let decoded_defaults: EngineCoreRequest = + rmp_serde::from_slice(&defaults_request_bytes).unwrap(); + assert_eq!(decoded_defaults.request_id, "req-defaults"); + let sampling = decoded_defaults + .sampling_params + .expect("defaults request carries sampling params"); + assert_eq!( + sampling, + EngineCoreSamplingParams { + temperature: 1.0, + top_p: 1.0, + top_k: 0, + seed: None, + max_tokens: 16, + min_tokens: 0, + thinking_token_budget: None, + logprobs: None, + prompt_logprobs: None, + min_p: 0.0, + frequency_penalty: 0.0, + presence_penalty: 0.0, + repetition_penalty: 1.0, + repetition_detection: None, + stop_token_ids: Vec::new(), + eos_token_id: None, + all_stop_token_ids: BTreeSet::new(), + logit_bias: None, + allowed_token_ids: None, + bad_words_token_ids: None, + structured_outputs: None, + logprob_token_ids: None, + skip_reading_prefix_cache: None, + extra_args: None, + }, + ); + let decoded_multimodal_request: EngineCoreRequest = rmp_serde::from_slice(&multimodal_request_bytes).unwrap(); assert_eq!(decoded_multimodal_request, sample_multimodal_request()); @@ -2396,41 +2498,40 @@ fn python_msgpack_fixtures_match_rust_encoding() { let decoded_outputs: EngineCoreOutputs = rmp_serde::from_slice(&outputs_bytes).unwrap(); expect_test::expect![[r#" - EngineCoreOutputs { - engine_index: 0, - outputs: [ - EngineCoreOutput { - request_id: "req-1", - new_token_ids: [ - 7, - 8, - ], - new_logprobs: None, - new_prompt_logprobs_tensors: None, - pooling_output: None, - finish_reason: Some( - Length, - ), - stop_reason: None, - events: None, - kv_transfer_params: None, - trace_headers: None, - prefill_stats: None, - routed_experts: None, - num_nans_in_logits: 0, - }, - ], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: Some( - { - "req-1", - }, - ), - wave_complete: None, - start_wave: None, - } + RequestBatch( + RequestBatchOutputs { + engine_index: 0, + outputs: [ + EngineCoreOutput { + request_id: "req-1", + new_token_ids: [ + 7, + 8, + ], + new_logprobs: None, + new_prompt_logprobs_tensors: None, + pooling_output: None, + finish_reason: Some( + Length, + ), + stop_reason: None, + events: None, + kv_transfer_params: None, + trace_headers: None, + prefill_stats: None, + routed_experts: None, + num_nans_in_logits: 0, + }, + ], + scheduler_stats: None, + timestamp: 0.0, + finished_requests: Some( + { + "req-1", + }, + ), + }, + ) "#]] .assert_debug_eq(&decoded_outputs); @@ -2443,7 +2544,7 @@ fn python_msgpack_fixtures_match_rust_encoding() { let inline_logprobs = decode_engine_core_outputs(&decode_frames(inline_logprobs_frames)).unwrap(); expect_sample_logprobs( - inline_logprobs.outputs[0] + inline_logprobs.as_request_batch().unwrap().outputs[0] .new_logprobs .as_ref() .expect("inline logprobs decoded"), @@ -2452,7 +2553,7 @@ fn python_msgpack_fixtures_match_rust_encoding() { let multipart_logprobs = decode_engine_core_outputs(&decode_frames(multipart_logprobs_frames)).unwrap(); expect_sample_logprobs( - multipart_logprobs.outputs[0] + multipart_logprobs.as_request_batch().unwrap().outputs[0] .new_logprobs .as_ref() .expect("multipart logprobs decoded"), @@ -2460,7 +2561,7 @@ fn python_msgpack_fixtures_match_rust_encoding() { let inline_prompt = decode_engine_core_outputs(&decode_frames(inline_prompt_frames)).unwrap(); expect_prompt_logprobs( - inline_prompt.outputs[0] + inline_prompt.as_request_batch().unwrap().outputs[0] .new_prompt_logprobs_tensors .as_ref() .expect("inline prompt logprobs decoded"), @@ -2469,11 +2570,28 @@ fn python_msgpack_fixtures_match_rust_encoding() { let multipart_prompt = decode_engine_core_outputs(&decode_frames(multipart_prompt_frames)).unwrap(); expect_prompt_logprobs( - multipart_prompt.outputs[0] + multipart_prompt.as_request_batch().unwrap().outputs[0] .new_prompt_logprobs_tensors .as_ref() .expect("multipart prompt logprobs decoded"), ); + + let map_keys = |bytes: &[u8]| -> BTreeSet { + match decode_value(bytes) { + Value::Map(entries) => entries + .into_iter() + .filter_map(|(key, _)| key.as_str().map(str::to_owned)) + .collect(), + other => panic!("ready response should encode as a map, got {other:?}"), + } + }; + let python_ready_keys = map_keys(&hex::decode(ready_response_hex).unwrap()); + let rust_ready_keys = + map_keys(&rmp_serde::to_vec_named(&crate::mock_engine::default_ready_response()).unwrap()); + assert_eq!( + rust_ready_keys, python_ready_keys, + "EngineCoreReadyResponse drifted from the Python dataclass", + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2554,6 +2672,90 @@ async fn bootstrapped_connects_with_contiguous_engine_ids() { client.shutdown().await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bootstrapped_connects_with_nonzero_engine_start_index() { + init_tracing(); + let ipc = IpcNamespace::new().unwrap(); + let input_address = ipc.input_endpoint(); + let output_address = ipc.output_endpoint(); + + let client_task = tokio::spawn({ + let input_address = input_address.clone(); + let output_address = output_address.clone(); + async move { + EngineCoreClient::connect(bootstrapped_test_config_with_start_index( + input_address, + output_address, + 3, + 1, + Duration::from_secs(2), + 0, + None, + )) + .await + .unwrap() + } + }); + + let (_dealer, _push) = + setup_bootstrapped_mock_engine(input_address, output_address, &[0x03, 0x00]).await; + let client = client_task.await.unwrap(); + + assert_eq!(client.engine_count(), 1); + let engine_ids = + client.engine_identities().into_iter().map(|id| id.to_vec()).collect::>(); + assert_eq!(engine_ids, vec![vec![0x03, 0x00]]); + + client.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bootstrapped_rejects_unexpected_engine_id_for_start_index() { + init_tracing(); + let ipc = IpcNamespace::new().unwrap(); + let input_address = ipc.input_endpoint(); + let output_address = ipc.output_endpoint(); + + let client_task = tokio::spawn({ + let input_address = input_address.clone(); + let output_address = output_address.clone(); + async move { + EngineCoreClient::connect(bootstrapped_test_config_with_start_index( + input_address, + output_address, + 3, + 1, + Duration::from_secs(2), + 0, + None, + )) + .await + } + }); + + let _ = crate::mock_engine::connect_to_bootstrapped_frontend( + input_address, + output_address, + &[0x00, 0x00], + crate::mock_engine::MockEngineConfig { + local: true, + headless: true, + ..Default::default() + }, + ) + .await; + let error = match client_task.await.unwrap() { + Ok(_) => panic!("bootstrapped connect should reject unexpected engine id"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("received input registration for unexpected engine id") + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn bootstrapped_connect_times_out_without_registration() { init_tracing(); @@ -2688,8 +2890,7 @@ async fn bootstrapped_external_coordinator_updates_wave_ignores_counts_and_sends send_outputs( &mut push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( "req-1", vec![], @@ -2697,7 +2898,8 @@ async fn bootstrapped_external_coordinator_updates_wave_ignores_counts_and_sends )], finished_requests: Some(BTreeSet::from(["req-1".to_string()])), ..Default::default() - }, + } + .into(), ) .await; @@ -2763,8 +2965,7 @@ async fn bootstrapped_external_coordinator_running_state_suppresses_wakeup() { send_outputs( &mut push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output( "req-1", vec![], @@ -2772,7 +2973,8 @@ async fn bootstrapped_external_coordinator_running_state_suppresses_wakeup() { )], finished_requests: Some(BTreeSet::from(["req-1".to_string()])), ..Default::default() - }, + } + .into(), ) .await; diff --git a/rust/src/engine-core-client/src/tests/python_compat.py b/rust/src/engine-core-client/src/tests/python_compat.py index bb81a6df1ada..a3f44ea7f068 100755 --- a/rust/src/engine-core-client/src/tests/python_compat.py +++ b/rust/src/engine-core-client/src/tests/python_compat.py @@ -10,6 +10,7 @@ # ] # /// +from dataclasses import dataclass from enum import Enum, IntEnum import msgpack @@ -30,13 +31,15 @@ class FinishReason(IntEnum): REPETITION = 4 -class EngineCoreSamplingParams(msgspec.Struct, dict=True): +# Mirror of real SamplingParams; omit_defaults makes fixtures match real maps. +class EngineCoreSamplingParams(msgspec.Struct, dict=True, omit_defaults=True): temperature: float = 1.0 top_p: float = 1.0 top_k: int = 0 seed: int | None = None - max_tokens: int = 65536 + max_tokens: int = 16 min_tokens: int = 0 + thinking_token_budget: int | None = None min_p: float = 0.0 frequency_penalty: float = 0.0 presence_penalty: float = 0.0 @@ -120,6 +123,7 @@ class EngineCoreOutputs( seed=None, max_tokens=32, min_tokens=1, + thinking_token_budget=256, min_p=0.0, frequency_penalty=0.0, presence_penalty=0.0, @@ -134,6 +138,16 @@ class EngineCoreOutputs( client_index=0, ) +# All defaults -> empty map. Regression guard for the sparse-map decode. +defaults_request = EngineCoreRequest( + request_id="req-defaults", + prompt_token_ids=[5, 6, 7], + mm_features=None, + sampling_params=EngineCoreSamplingParams(), + pooling_params=None, + arrival_time=1.0, +) + multimodal_tensor = np.array([[1.0, 2.0], [3.5, 4.25]], dtype=np.float32) multimodal_features = [ { @@ -337,7 +351,34 @@ def engine_outputs_wire(output): ) ) + +@dataclass +class EngineCoreReadyResponse: + max_model_len: int + num_gpu_blocks: int + block_size: int + dp_stats_address: str | None + dtype: str + vllm_version: str + world_size: int + data_parallel_size: int + kv_cache_size_tokens: int | None = None + kv_cache_max_concurrency: float | None = None + + +ready_response = EngineCoreReadyResponse( + max_model_len=32768, + num_gpu_blocks=1000, + block_size=16, + dp_stats_address=None, + dtype="float32", + vllm_version="0.0.0", + data_parallel_size=1, + world_size=1, +) + print(msgspec.msgpack.encode(request).hex()) +print(msgspec.msgpack.encode(defaults_request).hex()) print(msgpack.packb(multimodal_request_wire, use_bin_type=True).hex()) print(msgspec.msgpack.encode(outputs).hex()) print(" ".join(frame.hex() for frame in encode_output_frames(inline_logprobs))) @@ -354,3 +395,4 @@ def engine_outputs_wire(output): for frame in encode_output_frames(multipart_prompt_logprobs, size_threshold=1) ) ) +print(msgspec.msgpack.encode(ready_response).hex()) diff --git a/rust/src/engine-core-client/src/transport.rs b/rust/src/engine-core-client/src/transport.rs index 360f94eda127..aecf9625000e 100644 --- a/rust/src/engine-core-client/src/transport.rs +++ b/rust/src/engine-core-client/src/transport.rs @@ -18,9 +18,8 @@ use crate::error::{Error, Result, bail_unexpected_handshake_message}; use crate::protocol::handshake::{ EngineCoreReadyResponse, HandshakeAddresses, HandshakeInitMessage, ReadyMessage, }; -use crate::protocol::{ - EngineCoreOutputs, decode_engine_core_outputs, decode_msgpack, encode_msgpack, -}; +use crate::protocol::output::{EngineCoreOutputs, decode_engine_core_outputs}; +use crate::protocol::{decode_msgpack, encode_msgpack}; /// Dedicated single-frame sentinel emitted by Python `EngineCoreProc` when the /// engine dies. @@ -327,6 +326,7 @@ pub async fn connect_handshake( pub async fn connect_bootstrapped( input_address: &str, output_address: &str, + engine_start_index: u32, engine_count: usize, ready_timeout: Duration, ) -> Result { @@ -342,8 +342,8 @@ pub async fn connect_bootstrapped( let engines = wait_for_input_registrations( &mut input_socket, - // TODO: follow start rank - (0..engine_count).map(|index| EngineId::from((index as u16).to_le_bytes().to_vec())), + (0..engine_count) + .map(|offset| EngineId::from_engine_index(engine_start_index + offset as u32)), ready_timeout, ) .await?; diff --git a/rust/src/llm/Cargo.toml b/rust/src/llm/Cargo.toml index c7924b85db7e..982fd32dfdab 100644 --- a/rust/src/llm/Cargo.toml +++ b/rust/src/llm/Cargo.toml @@ -11,6 +11,7 @@ test-util = [] easy-ext.workspace = true enum-as-inner.workspace = true futures.workspace = true +parking_lot.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/rust/src/llm/examples/external_engine_smoke.rs b/rust/src/llm/examples/external_engine_smoke.rs index c2d0e6bdfa82..e5e153f33478 100644 --- a/rust/src/llm/examples/external_engine_smoke.rs +++ b/rust/src/llm/examples/external_engine_smoke.rs @@ -5,7 +5,7 @@ use clap::Parser; use futures::StreamExt as _; use tokio::time::timeout; use tracing_subscriber::EnvFilter; -use vllm_engine_core_client::protocol::EngineCoreSamplingParams; +use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, TransportMode}; use vllm_llm::{FinishReason, GenerateOutputStream, GenerateRequest, Llm}; @@ -56,7 +56,7 @@ fn build_request(request_id: String, max_tokens: u32) -> GenerateRequest { trace_headers: None, priority: 0, data_parallel_rank: None, - reasoning_ended: None, + reasoning_parser_kwargs: None, lora_request: None, } } diff --git a/rust/src/llm/src/inflight.rs b/rust/src/llm/src/inflight.rs new file mode 100644 index 000000000000..37df1441172a --- /dev/null +++ b/rust/src/llm/src/inflight.rs @@ -0,0 +1,179 @@ +//! Tracking of the external→internal request-id mapping for in-flight requests. +//! +//! When request-id randomization is enabled (the default), [`crate::Llm`] +//! rewrites the external (user-supplied) request id into a unique internal +//! engine id before reaching engine-core. Engine-core only ever knows the +//! internal id, so aborting a request by its external id requires resolving it +//! back to the internal id(s) first. + +use std::collections::HashMap; +use std::sync::{Arc, Weak}; + +use parking_lot::Mutex; + +/// external id → internal id → number of live guards holding that edge. +type InflightMap = HashMap>; + +/// Maps external (user-supplied) request ids to the set of live internal engine +/// request ids they currently expand into. +/// +/// One external id may map to multiple internal ids: duplicate external ids +/// submitted concurrently each get their own randomized internal id, and an +/// abort by the shared external id must reach all of them. Edges are +/// refcounted: with randomization disabled the same (external, internal) pair +/// can be tracked by several guards in sequence (e.g. a finished request whose +/// stream is still held alongside a fresh submission reusing the id), and the +/// edge must survive until the last guard drops. +#[derive(Default)] +pub(crate) struct InflightRequests { + map: Arc>, +} + +impl InflightRequests { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Record that `internal` is now an in-flight engine request for the + /// `external` request id, returning a guard that removes the edge when the + /// request's output stream is dropped (on clean finish or cancellation). + pub(crate) fn track(&self, external: String, internal: String) -> RequestGuard { + *self + .map + .lock() + .entry(external.clone()) + .or_default() + .entry(internal.clone()) + .or_insert(0) += 1; + RequestGuard { + map: Arc::downgrade(&self.map), + external, + internal, + } + } + + /// Resolve external request ids to the internal engine ids currently + /// in-flight for them. Unknown or already-finished ids contribute nothing. + pub(crate) fn resolve(&self, external_ids: &[String]) -> Vec { + let map = self.map.lock(); + external_ids + .iter() + .filter_map(|external| map.get(external)) + .flat_map(|internal_ids| internal_ids.keys()) + .cloned() + .collect() + } + + #[cfg(test)] + fn is_empty(&self) -> bool { + self.map.lock().is_empty() + } +} + +/// RAII guard that releases one refcount on a single external→internal edge +/// when dropped, removing the edge once no live guard holds it. +/// +/// Held by the per-request output stream, so cleanup runs whether the stream +/// terminates cleanly or is cancelled. A [`Weak`] handle is used so a stream +/// outliving its owning [`InflightRequests`] does not keep the map alive. +pub(crate) struct RequestGuard { + map: Weak>, + external: String, + internal: String, +} + +impl Drop for RequestGuard { + fn drop(&mut self) { + let Some(map) = self.map.upgrade() else { + return; + }; + let mut map = map.lock(); + if let Some(internal_ids) = map.get_mut(&self.external) { + if let Some(count) = internal_ids.get_mut(&self.internal) { + *count -= 1; + if *count == 0 { + internal_ids.remove(&self.internal); + } + } + if internal_ids.is_empty() { + map.remove(&self.external); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_external_to_internal() { + let inflight = InflightRequests::new(); + let _guard = inflight.track("ext".to_string(), "ext-abc".to_string()); + + assert_eq!( + inflight.resolve(&["ext".to_string()]), + vec!["ext-abc".to_string()] + ); + assert!(inflight.resolve(&["unknown".to_string()]).is_empty()); + } + + #[test] + fn one_external_maps_to_many_internal() { + let inflight = InflightRequests::new(); + let _g1 = inflight.track("dup".to_string(), "dup-1".to_string()); + let _g2 = inflight.track("dup".to_string(), "dup-2".to_string()); + + let mut resolved = inflight.resolve(&["dup".to_string()]); + resolved.sort(); + assert_eq!(resolved, vec!["dup-1".to_string(), "dup-2".to_string()]); + } + + #[test] + fn dropping_guard_removes_only_its_own_edge_then_cleans_empty_key() { + let inflight = InflightRequests::new(); + let g1 = inflight.track("dup".to_string(), "dup-1".to_string()); + let g2 = inflight.track("dup".to_string(), "dup-2".to_string()); + + drop(g1); + assert_eq!( + inflight.resolve(&["dup".to_string()]), + vec!["dup-2".to_string()] + ); + + drop(g2); + assert!(inflight.resolve(&["dup".to_string()]).is_empty()); + assert!( + inflight.is_empty(), + "empty external key must be removed, not left dangling" + ); + } + + #[test] + fn identical_edges_are_refcounted_across_guards() { + // With request-id randomization disabled, internal == external, so two + // tracked requests can share the exact same edge. Dropping one guard + // (e.g. a stale stream, or the error path of a rejected duplicate + // submission) must not untrack the other still-live request. + let inflight = InflightRequests::new(); + let g1 = inflight.track("x".to_string(), "x".to_string()); + let g2 = inflight.track("x".to_string(), "x".to_string()); + + drop(g1); + assert_eq!(inflight.resolve(&["x".to_string()]), vec!["x".to_string()]); + + drop(g2); + assert!(inflight.resolve(&["x".to_string()]).is_empty()); + assert!(inflight.is_empty()); + } + + #[test] + fn guard_drop_is_a_noop_after_inflight_is_gone() { + let guard = { + let inflight = InflightRequests::new(); + inflight.track("ext".to_string(), "ext-abc".to_string()) + }; + // Dropping the guard after the owning map is gone must not panic. + drop(guard); + } +} diff --git a/rust/src/llm/src/lib.rs b/rust/src/llm/src/lib.rs index d47935259b59..942bf55c288f 100644 --- a/rust/src/llm/src/lib.rs +++ b/rust/src/llm/src/lib.rs @@ -2,6 +2,7 @@ use tracing::Span; use vllm_engine_core_client::EngineCoreClient; mod error; +mod inflight; mod log_stats; mod output; mod request; @@ -10,23 +11,28 @@ mod request_metrics; pub use error::{Error, Result}; pub use output::{ CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStream, - GenerateOutputStreamExt, GeneratePromptInfo, + GenerateOutputStreamExt, GeneratePromptInfo, TokenUsage, }; pub use request::GenerateRequest; +pub use request_metrics::current_unix_timestamp_secs; pub use vllm_engine_core_client::protocol::logprobs::{Logprobs, PositionLogprobs, TokenLogprob}; +use crate::inflight::InflightRequests; use crate::log_stats::StatsLogger; use crate::request_metrics::RequestMetricsTracker; -/// Thin generate-only facade over [`EngineCoreClient`]. +/// Thin generate-and-abort facade over [`EngineCoreClient`]. /// /// This mirrors the narrow public shape of Python `AsyncLLM.generate()` and /// `abort()`, but keeps the boundary close to raw engine-core requests and -/// outputs. +/// outputs. It tracks an in-flight external→internal request-id index (see +/// [`InflightRequests`]) so that aborts issued against external (user-supplied) +/// ids can be resolved to the internal engine ids that engine-core understands. pub struct Llm { client: EngineCoreClient, randomize_request_id: bool, stats_logger: Option, + inflight: InflightRequests, } impl Llm { @@ -37,6 +43,7 @@ impl Llm { client, randomize_request_id: true, stats_logger: None, + inflight: InflightRequests::new(), } } @@ -45,7 +52,7 @@ impl Llm { if enabled { let stats_logger = StatsLogger::start( self.client.model_name().to_string(), - self.client.engine_count(), + self.client.engine_indices(), ); self.stats_logger = Some(stats_logger); } else { @@ -72,26 +79,57 @@ impl Llm { pub async fn generate(&self, req: GenerateRequest) -> Result { let prepared = req.prepare(self.randomize_request_id)?; let prompt_token_ids = prepared.prompt_token_ids().into(); + let external_request_id = prepared + .engine_request + .external_req_id + .clone() + .expect("prepare always sets external_req_id"); + let internal_request_id = prepared.engine_request.request_id.clone(); // Record internal engine-core request ID in the current tracing span. - Span::current().record("engine_request_id", &prepared.engine_request.request_id); + Span::current().record("engine_request_id", &internal_request_id); + + let arrival_time = prepared.engine_request.arrival_time; + let max_tokens_param = + (prepared.engine_request.sampling_params.as_ref()).map(|p| p.max_tokens); + let prompt_len = prepared.prompt_token_ids().len() as u32; + + let stream = self.client.call(prepared.engine_request).await?; let request_metrics = RequestMetricsTracker::new( self.client.model_name().to_string(), - prepared.engine_request.arrival_time, - prepared.prompt_token_ids().len() as u32, - (prepared.engine_request.sampling_params.as_ref()).map(|p| p.max_tokens), + stream.engine_index(), + arrival_time, + prompt_len, + max_tokens_param, 1, ); - let stream = self.client.call(prepared.engine_request).await?; + let guard = self.inflight.track(external_request_id, internal_request_id); Ok(GenerateOutputStream::new( prompt_token_ids, stream, request_metrics, + guard, )) } + /// Abort in-flight requests by their external (user-supplied) request ids. + /// + /// External ids are resolved to the internal engine ids actually known to + /// engine-core (one external id may map to several internal ids). Unknown + /// or already-finished ids resolve to nothing and are a safe no-op. The + /// tracking entries themselves are removed when the corresponding output + /// streams are dropped, not here. + pub async fn abort(&self, external_ids: &[String]) -> Result<()> { + let internal_ids = self.inflight.resolve(external_ids); + if internal_ids.is_empty() { + return Ok(()); + } + self.client.abort(&internal_ids).await?; + Ok(()) + } + /// Shut down the underlying engine-core client and its background tasks. pub async fn shutdown(self) -> Result<()> { self.client.shutdown().await?; diff --git a/rust/src/llm/src/log_stats.rs b/rust/src/llm/src/log_stats.rs index 7d3a149731b3..81c9093a108e 100644 --- a/rust/src/llm/src/log_stats.rs +++ b/rust/src/llm/src/log_stats.rs @@ -4,10 +4,12 @@ use std::time::{Duration, Instant}; use tokio_util::task::AbortOnDropHandle; use tracing::{debug, info}; use vllm_metrics::{ - EngineLabels, F64Gauge, METRICS, PromptTokenSourceLabels, U64Counter, U64Gauge, + EngineLabels, F64Gauge, METRICS, PromptTokenSourceLabels, SchedulerLogStatsAccumulator, + SchedulerLogStatsInterval, U64Counter, U64Gauge, WaitingReasonLabels, }; const LOG_STATS_INTERVAL: Duration = Duration::from_secs(10); +const WAITING_REASON_DEFERRED: &str = "deferred"; /// Cached, cloned metric handles for one engine. Each clone shares the same /// underlying `Arc` as the prometheus `Family` entry, so reads go @@ -18,20 +20,58 @@ struct EngineMetrics { generation_tokens: U64Counter, prefix_cache_queries: U64Counter, prefix_cache_hits: U64Counter, + external_prefix_cache_queries: U64Counter, + external_prefix_cache_hits: U64Counter, + num_preemptions: U64Counter, + spec_decode_num_drafts: U64Counter, + spec_decode_num_draft_tokens: U64Counter, + spec_decode_num_accepted_tokens: U64Counter, + estimated_flops_per_gpu: U64Counter, + estimated_read_bytes_per_gpu: U64Counter, + estimated_write_bytes_per_gpu: U64Counter, + log_stats: SchedulerLogStatsAccumulator, // Gauges for instantaneous scheduler state. scheduler_running: U64Gauge, scheduler_waiting: U64Gauge, + scheduler_deferred: U64Gauge, kv_cache_usage: F64Gauge, } /// Accumulated snapshot values from the last logging interval, used to compute /// deltas. +#[derive(Default)] struct CounterSnapshot { prompt_tokens: u64, generation_tokens: u64, prefix_cache_queries: u64, prefix_cache_hits: u64, + external_prefix_cache_queries: u64, + external_prefix_cache_hits: u64, + num_preemptions: u64, + spec_decode_num_drafts: u64, + spec_decode_num_draft_tokens: u64, + spec_decode_num_accepted_tokens: u64, + estimated_flops_per_gpu: u64, + estimated_read_bytes_per_gpu: u64, + estimated_write_bytes_per_gpu: u64, +} + +/// Derived spec-decoding values for one logging interval. +struct SpecDecodingLogStats { + mean_acceptance_length: f64, + accepted_throughput: f64, + draft_throughput: f64, + accepted_tokens: u64, + draft_tokens: u64, + per_position_acceptance_rates: Vec, + draft_acceptance_rate: f64, +} + +/// Derived MFU values for one logging interval. +struct MfuLogStats { + tflops_per_gpu: f64, + gbps_per_gpu: f64, } /// Periodic stats logger that mirrors Python vLLM's `LoggingStatLogger`. @@ -46,18 +86,20 @@ pub(crate) struct StatsLogger { impl StatsLogger { /// Start the background stats logging task. - pub(crate) fn start(model_name: String, engine_count: usize) -> Self { + pub(crate) fn start(model_name: String, engine_indices: Vec) -> Self { let task = AbortOnDropHandle::new(tokio::spawn(async move { - run_stats_logger(model_name, engine_count).await; + run_stats_logger(model_name, engine_indices).await; })); Self { _task: task } } } /// Resolve and clone all metric handles once so the hot path is lock-free. -fn resolve_engine_metrics(model_name: &str, engine_count: usize) -> Vec { +fn resolve_engine_metrics(model_name: &str, engine_indices: &[u32]) -> Vec { let m = &METRICS; - (0..engine_count as u32) + engine_indices + .iter() + .copied() .map(|engine| { let el = EngineLabels { model_name: model_name.to_string(), @@ -68,6 +110,11 @@ fn resolve_engine_metrics(model_name: &str, engine_count: usize) -> Vec Vec) { + let engines = resolve_engine_metrics(&model_name, &engine_indices); let mut interval = tokio::time::interval(LOG_STATS_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -109,6 +191,7 @@ async fn run_stats_logger(model_name: String, engine_count: usize) { } let curr = read_counters(&engines); + let raw_log_stats = drain_scheduler_log_stats(&engines); let prompt_throughput = curr.prompt_tokens.wrapping_sub(prev.prompt_tokens) as f64 / elapsed; @@ -121,17 +204,37 @@ async fn run_stats_logger(model_name: String, engine_count: usize) { && last_prompt_throughput == 0.0 && last_generation_throughput == 0.0; + /// Emit one stats line at DEBUG while idle and INFO while active. + macro_rules! log_stats_line { + ($($arg:tt)*) => { + if is_idle { + debug!($($arg)*); + } else { + info!($($arg)*); + } + }; + } + // Read scheduler gauges (aggregate across engines). let (num_running, num_waiting, kv_cache_usage) = read_scheduler_gauges(&engines); + let num_deferred = read_deferred_waiting(&engines); + let delta_preemptions = curr.num_preemptions.wrapping_sub(prev.num_preemptions); // Compute prefix cache hit rate over this interval. let delta_queries = curr.prefix_cache_queries.wrapping_sub(prev.prefix_cache_queries); - let prefix_cache_hit_rate = if delta_queries > 0 { - let delta_hits = curr.prefix_cache_hits.wrapping_sub(prev.prefix_cache_hits); - delta_hits as f64 / delta_queries as f64 * 100.0 - } else { - 0.0 - }; + let delta_hits = curr.prefix_cache_hits.wrapping_sub(prev.prefix_cache_hits); + let prefix_cache_hit_rate = cache_hit_rate(delta_hits, delta_queries); + + let delta_external_queries = curr + .external_prefix_cache_queries + .wrapping_sub(prev.external_prefix_cache_queries); + let delta_external_hits = + curr.external_prefix_cache_hits.wrapping_sub(prev.external_prefix_cache_hits); + let external_prefix_cache_hit_rate = + cache_hit_rate(delta_external_hits, delta_external_queries); + let spec_decoding_log_stats = + spec_decoding_log_stats(&curr, &prev, elapsed, &raw_log_stats); + let mfu_log_stats = mfu_log_stats(&curr, &prev, elapsed, engines.len()); // Build the log line. msg.clear(); @@ -140,17 +243,70 @@ async fn run_stats_logger(model_name: String, engine_count: usize) { "Avg prompt tput: {prompt_throughput:.1} toks/s, \ Avg generation tput: {generation_throughput:.1} toks/s, \ Reqs Running: {num_running}, \ - Waiting: {num_waiting}, \ - GPU KV cache used: {:.1}%, \ + Waiting: {num_waiting}" + ) + .unwrap(); + if num_deferred > 0 { + write!(msg, ", Deferred: {num_deferred} reqs").unwrap(); + } + if delta_preemptions > 0 { + write!(msg, ", Preemptions: {delta_preemptions}").unwrap(); + } + write!( + msg, + ", GPU KV cache used: {:.1}%, \ Prefix cache hit rate: {prefix_cache_hit_rate:.1}%", kv_cache_usage * 100.0, ) .unwrap(); + if delta_external_queries > 0 { + write!( + msg, + ", External prefix cache hit rate: {external_prefix_cache_hit_rate:.1}%" + ) + .unwrap(); + } - if is_idle { - debug!("{msg}"); - } else { - info!("{msg}"); + log_stats_line!("{msg}"); + + if let Some(spec_stats) = spec_decoding_log_stats { + msg.clear(); + write!( + msg, + "SpecDecoding metrics: \ + Mean acceptance length: {:.2}, \ + Accepted throughput: {:.2} tokens/s, \ + Drafted throughput: {:.2} tokens/s, \ + Accepted: {} tokens, \ + Drafted: {} tokens", + spec_stats.mean_acceptance_length, + spec_stats.accepted_throughput, + spec_stats.draft_throughput, + spec_stats.accepted_tokens, + spec_stats.draft_tokens, + ) + .unwrap(); + if !spec_stats.per_position_acceptance_rates.is_empty() { + msg.push_str(", Per-position acceptance rate: "); + format_position_rates(&mut msg, &spec_stats.per_position_acceptance_rates); + } + write!( + msg, + ", Avg Draft acceptance rate: {:.1}%", + spec_stats.draft_acceptance_rate, + ) + .unwrap(); + log_stats_line!("{msg}"); + } + + // TODO: Decide on best way to surface CUDAGraph interval samples. + + if let Some(mfu_stats) = mfu_log_stats { + log_stats_line!( + "MFU: {:.1} TF/s/GPU {:.1} GB/s/GPU", + mfu_stats.tflops_per_gpu, + mfu_stats.gbps_per_gpu, + ); } last_prompt_throughput = prompt_throughput; @@ -162,17 +318,21 @@ async fn run_stats_logger(model_name: String, engine_count: usize) { /// Read the current cumulative counter values for throughput computation. fn read_counters(engines: &[EngineMetrics]) -> CounterSnapshot { - let mut snap = CounterSnapshot { - prompt_tokens: 0, - generation_tokens: 0, - prefix_cache_queries: 0, - prefix_cache_hits: 0, - }; + let mut snap = CounterSnapshot::default(); for e in engines { snap.prompt_tokens += e.prompt_tokens_computed.get(); snap.generation_tokens += e.generation_tokens.get(); snap.prefix_cache_queries += e.prefix_cache_queries.get(); snap.prefix_cache_hits += e.prefix_cache_hits.get(); + snap.external_prefix_cache_queries += e.external_prefix_cache_queries.get(); + snap.external_prefix_cache_hits += e.external_prefix_cache_hits.get(); + snap.num_preemptions += e.num_preemptions.get(); + snap.spec_decode_num_drafts += e.spec_decode_num_drafts.get(); + snap.spec_decode_num_draft_tokens += e.spec_decode_num_draft_tokens.get(); + snap.spec_decode_num_accepted_tokens += e.spec_decode_num_accepted_tokens.get(); + snap.estimated_flops_per_gpu += e.estimated_flops_per_gpu.get(); + snap.estimated_read_bytes_per_gpu += e.estimated_read_bytes_per_gpu.get(); + snap.estimated_write_bytes_per_gpu += e.estimated_write_bytes_per_gpu.get(); } snap } @@ -197,3 +357,196 @@ fn read_scheduler_gauges(engines: &[EngineMetrics]) -> (u64, u64, f64) { (num_running, num_waiting, kv_cache_usage) } + +/// Read deferred waiting requests across all engines. +fn read_deferred_waiting(engines: &[EngineMetrics]) -> u64 { + engines.iter().map(|e| e.scheduler_deferred.get()).sum() +} + +/// Return the cache hit rate as a percentage for a counter delta. +fn cache_hit_rate(hits: u64, queries: u64) -> f64 { + if queries > 0 { + hits as f64 / queries as f64 * 100.0 + } else { + 0.0 + } +} + +/// Compute aggregate spec-decoding stats for one logging interval. +fn spec_decoding_log_stats( + curr: &CounterSnapshot, + prev: &CounterSnapshot, + elapsed: f64, + raw_log_stats: &SchedulerLogStatsInterval, +) -> Option { + let num_drafts = curr.spec_decode_num_drafts.wrapping_sub(prev.spec_decode_num_drafts); + if num_drafts == 0 { + return None; + } + + let draft_tokens = curr + .spec_decode_num_draft_tokens + .wrapping_sub(prev.spec_decode_num_draft_tokens); + let accepted_tokens = curr + .spec_decode_num_accepted_tokens + .wrapping_sub(prev.spec_decode_num_accepted_tokens); + + let (accepted_throughput, draft_throughput) = if elapsed > 0.0 { + ( + accepted_tokens as f64 / elapsed, + draft_tokens as f64 / elapsed, + ) + } else { + (0.0, 0.0) + }; + let draft_acceptance_rate = if draft_tokens > 0 { + accepted_tokens as f64 / draft_tokens as f64 * 100.0 + } else { + f64::NAN + }; + let per_position_acceptance_rates = if raw_log_stats.spec_num_drafts > 0 { + raw_log_stats + .spec_accepted_tokens_per_pos + .iter() + .map(|accepted_tokens| *accepted_tokens as f64 / raw_log_stats.spec_num_drafts as f64) + .collect() + } else { + Vec::new() + }; + + Some(SpecDecodingLogStats { + mean_acceptance_length: 1.0 + accepted_tokens as f64 / num_drafts as f64, + accepted_throughput, + draft_throughput, + accepted_tokens, + draft_tokens, + per_position_acceptance_rates, + draft_acceptance_rate, + }) +} + +/// Compute average per-GPU MFU rates for one logging interval. +fn mfu_log_stats( + curr: &CounterSnapshot, + prev: &CounterSnapshot, + elapsed: f64, + engine_count: usize, +) -> Option { + let flops = curr.estimated_flops_per_gpu.wrapping_sub(prev.estimated_flops_per_gpu); + let read_bytes = curr + .estimated_read_bytes_per_gpu + .wrapping_sub(prev.estimated_read_bytes_per_gpu); + let write_bytes = curr + .estimated_write_bytes_per_gpu + .wrapping_sub(prev.estimated_write_bytes_per_gpu); + + if flops == 0 && read_bytes == 0 && write_bytes == 0 { + return None; + } + + let denominator = elapsed * engine_count.max(1) as f64; + let (tflops_per_gpu, gbps_per_gpu) = if denominator > 0.0 { + ( + flops as f64 / denominator / 1e12, + (read_bytes as f64 + write_bytes as f64) / denominator / 1e9, + ) + } else { + (0.0, 0.0) + }; + + Some(MfuLogStats { + tflops_per_gpu, + gbps_per_gpu, + }) +} + +/// Drain raw scheduler DTO stats for the configured model and engines. +fn drain_scheduler_log_stats(engines: &[EngineMetrics]) -> SchedulerLogStatsInterval { + let mut interval = SchedulerLogStatsInterval::default(); + for engine in engines { + interval.merge(engine.log_stats.drain()); + } + interval +} + +/// Append spec-decoding per-position acceptance rates like Python's logger. +fn format_position_rates(output: &mut String, rates: &[f64]) { + for (position, rate) in rates.iter().enumerate() { + if position > 0 { + output.push_str(", "); + } + write!(output, "{rate:.3}").unwrap(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_hit_rate_returns_percent_for_non_empty_queries() { + assert_eq!(cache_hit_rate(25, 100), 25.0); + assert_eq!(cache_hit_rate(0, 0), 0.0); + } + + #[test] + fn spec_decoding_log_stats_uses_interval_deltas() { + let raw_log_stats = SchedulerLogStatsInterval { + spec_num_drafts: 4, + spec_accepted_tokens_per_pos: vec![4, 2, 1], + ..Default::default() + }; + let prev = CounterSnapshot { + spec_decode_num_drafts: 10, + spec_decode_num_draft_tokens: 100, + spec_decode_num_accepted_tokens: 40, + ..Default::default() + }; + let curr = CounterSnapshot { + spec_decode_num_drafts: 14, + spec_decode_num_draft_tokens: 120, + spec_decode_num_accepted_tokens: 52, + ..Default::default() + }; + + let stats = spec_decoding_log_stats(&curr, &prev, 2.0, &raw_log_stats).unwrap(); + + assert_eq!(stats.mean_acceptance_length, 4.0); + assert_eq!(stats.accepted_throughput, 6.0); + assert_eq!(stats.draft_throughput, 10.0); + assert_eq!(stats.accepted_tokens, 12); + assert_eq!(stats.draft_tokens, 20); + assert_eq!(stats.per_position_acceptance_rates, vec![1.0, 0.5, 0.25]); + assert_eq!(stats.draft_acceptance_rate, 60.0); + } + + #[test] + fn mfu_log_stats_averages_per_gpu_across_engines() { + let prev = CounterSnapshot { + estimated_flops_per_gpu: 10, + estimated_read_bytes_per_gpu: 10, + estimated_write_bytes_per_gpu: 10, + ..Default::default() + }; + let curr = CounterSnapshot { + estimated_flops_per_gpu: 4_000_000_000_010, + estimated_read_bytes_per_gpu: 2_000_000_010, + estimated_write_bytes_per_gpu: 2_000_000_010, + ..Default::default() + }; + + let stats = mfu_log_stats(&curr, &prev, 2.0, 2).unwrap(); + + assert_eq!(stats.tflops_per_gpu, 1.0); + assert_eq!(stats.gbps_per_gpu, 1.0); + } + + #[test] + fn format_position_rates_uses_three_decimal_places() { + let mut output = String::new(); + + format_position_rates(&mut output, &[1.0, 0.5, 0.25]); + + assert_eq!(output, "1.000, 0.500, 0.250"); + } +} diff --git a/rust/src/llm/src/output.rs b/rust/src/llm/src/output.rs index 94d9acb3fe8a..d1d7e5f46e5c 100644 --- a/rust/src/llm/src/output.rs +++ b/rust/src/llm/src/output.rs @@ -8,12 +8,24 @@ use futures::stream::FusedStream; use futures::{Stream, StreamExt as _, pin_mut}; use serde::{Deserialize, Serialize}; use vllm_engine_core_client::protocol::logprobs::Logprobs; -use vllm_engine_core_client::protocol::{EngineCoreFinishReason, StopReason}; +use vllm_engine_core_client::protocol::output::{EngineCoreFinishReason, StopReason}; use vllm_engine_core_client::{AbortCause, EngineCoreOutputStream}; use crate::error::Result; +use crate::inflight::RequestGuard; use crate::request_metrics::{RequestMetricsTracker, current_unix_timestamp_secs}; +/// Token usage metadata for one request. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TokenUsage { + /// Number of prompt tokens sent to the engine. + pub prompt_token_count: usize, + /// Number of output tokens generated. + pub output_token_count: usize, + /// Number of prompt tokens served from cache. + pub cached_token_count: usize, +} + /// Final raw token output plus terminal stream metadata. #[derive(Debug, Clone, PartialEq)] pub struct CollectedGenerateOutput { @@ -23,6 +35,7 @@ pub struct CollectedGenerateOutput { pub token_ids: Vec, pub logprobs: Option, pub finish_reason: FinishReason, + pub usage: TokenUsage, /// Connector-specific KV transfer parameters for disaggregated serving. pub kv_transfer_params: Option, } @@ -56,7 +69,7 @@ pub enum FinishReason { /// A retryable request-level internal error occurred. Error, /// A repetitive token pattern was detected. - Repetition, + Repetition(Option), } impl FinishReason { @@ -74,7 +87,7 @@ impl FinishReason { Self::Length => "length", Self::Abort => "abort", Self::Error => "error", - Self::Repetition => "repetition", + Self::Repetition(_) => "repetition", } } @@ -83,6 +96,7 @@ impl FinishReason { pub fn as_stop_reason(&self) -> Option<&StopReason> { match self { Self::Stop(stop_reason) => stop_reason.as_ref(), + Self::Repetition(stop_reason) => stop_reason.as_ref(), _ => None, } } @@ -92,6 +106,7 @@ impl FinishReason { pub fn into_stop_reason(self) -> Option { match self { Self::Stop(stop_reason) => stop_reason, + Self::Repetition(stop_reason) => stop_reason, _ => None, } } @@ -106,7 +121,7 @@ fn finish_reason_from_engine( EngineCoreFinishReason::Length => FinishReason::Length, EngineCoreFinishReason::Abort => FinishReason::Abort, EngineCoreFinishReason::Error => FinishReason::Error, - EngineCoreFinishReason::Repetition => FinishReason::Repetition, + EngineCoreFinishReason::Repetition => FinishReason::Repetition(stop_reason), }) } @@ -127,6 +142,8 @@ pub struct GenerateOutput { pub logprobs: Option, /// Terminal finish reason, when this is the final output for the request. pub finish_reason: Option, + /// Number of prompt tokens served from cache, when reported by prefill stats. + pub cached_token_count: usize, /// Connector-specific KV transfer parameters for disaggregated serving. pub kv_transfer_params: Option, } @@ -173,6 +190,7 @@ impl GenerateOutput { token_ids, logprobs: None, finish_reason, + cached_token_count: 0, kv_transfer_params: None, } } @@ -180,12 +198,17 @@ impl GenerateOutput { /// Stream of per-request generate outputs for one request. /// -/// - A normal termination of the stream represents a clean completion of the request. -/// - For errors, unexpected closes, or explicit aborts, the stream terminates with an error. +/// - A normal termination of the stream represents a clean completion of the +/// request, including a client-initiated abort, which yields a final output +/// with `finish_reason = Abort` before the stream ends. +/// - For errors or unexpected engine-side closes, the stream terminates with an error. pub struct GenerateOutputStream { pending_prompt_info: Option, raw_stream: EngineCoreOutputStream, request_metrics: RequestMetricsTracker, + /// Removes this request's external→internal tracking edge on drop. Held for + /// its `Drop` side effect only; never read directly. + _request_guard: RequestGuard, } impl GenerateOutputStream { @@ -195,6 +218,7 @@ impl GenerateOutputStream { prompt_token_ids: Arc<[u32]>, raw_stream: EngineCoreOutputStream, request_metrics: RequestMetricsTracker, + request_guard: RequestGuard, ) -> Self { Self { pending_prompt_info: Some(GeneratePromptInfo { @@ -203,6 +227,7 @@ impl GenerateOutputStream { }), raw_stream, request_metrics, + _request_guard: request_guard, } } @@ -223,12 +248,7 @@ impl Stream for GenerateOutputStream { }; let received_at = current_unix_timestamp_secs(); - self.request_metrics.observe_output( - raw.engine_index, - raw.timestamp, - received_at, - &raw.output, - ); + self.request_metrics.observe_output(raw.timestamp, received_at, &raw.output); let raw = raw.output; @@ -241,6 +261,11 @@ impl Stream for GenerateOutputStream { } let logprobs = raw.new_logprobs.map(|value| value.into_direct().unwrap()); + let cached_token_count = raw + .prefill_stats + .as_ref() + .map(|stats| stats.num_cached_tokens as usize) + .unwrap_or(0); let finish_reason = finish_reason_from_engine(raw.finish_reason, raw.stop_reason); if let Some(finish_reason) = finish_reason.as_ref() { @@ -253,6 +278,7 @@ impl Stream for GenerateOutputStream { token_ids: raw.new_token_ids, logprobs, finish_reason, + cached_token_count, kv_transfer_params: raw.kv_transfer_params, }; @@ -299,9 +325,11 @@ impl> + Send> T { pin_mut!(stream); let mut prompt_token_ids = None; let mut prompt_logprobs = None; + let mut cached_token_count = 0; let mut collected: Option = None; while let Some(output) = stream.next().await.transpose()? { + cached_token_count = cached_token_count.max(output.cached_token_count); if let Some(info) = output.prompt_info { if prompt_token_ids.is_none() { prompt_token_ids = Some(info.prompt_token_ids.to_vec()); @@ -328,6 +356,11 @@ impl> + Send> T { token_ids: output.token_ids, logprobs: output.logprobs, finish_reason: FinishReason::Error, + usage: TokenUsage { + prompt_token_count: prompt_token_ids.as_ref().map_or(0, Vec::len), + output_token_count: 0, + cached_token_count, + }, kv_transfer_params: None, }); } @@ -335,6 +368,11 @@ impl> + Send> T { if let Some(finish_reason) = output.finish_reason { let mut collected = collected.expect("terminal output must exist"); collected.finish_reason = finish_reason; + collected.usage = TokenUsage { + prompt_token_count: collected.prompt_token_ids.len(), + output_token_count: collected.token_ids.len(), + cached_token_count, + }; collected.kv_transfer_params = output.kv_transfer_params; return Ok(collected); } diff --git a/rust/src/llm/src/request.rs b/rust/src/llm/src/request.rs index af5d257774b1..45e5bd1ca64a 100644 --- a/rust/src/llm/src/request.rs +++ b/rust/src/llm/src/request.rs @@ -1,12 +1,13 @@ use std::collections::BTreeMap; -use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; use vllm_engine_core_client::protocol::lora::LoraRequest; use vllm_engine_core_client::protocol::multimodal::MmFeatures; -use vllm_engine_core_client::protocol::{EngineCoreRequest, EngineCoreSamplingParams}; +use vllm_engine_core_client::protocol::request::{EngineCoreRequest, ReasoningParserKwargs}; +use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; use crate::error::{Error, Result}; +use crate::request_metrics::current_unix_timestamp_secs; /// Tokenized decoder-only generate request accepted by [`crate::Llm`]. /// @@ -27,14 +28,25 @@ pub struct GenerateRequest { pub sampling_params: EngineCoreSamplingParams, /// Optional multimodal features already prepared by `vllm-chat`. pub mm_features: Option, - - // Fields below are currently likely unused by callers. + /// Unix timestamp, in seconds, when this request arrived at the frontend. + /// + /// Stamped at the frontend entry, before render and tokenization, to match + /// Python's renderer-entry arrival_time. When omitted, it is filled as a + /// fallback before the request is sent to engine-core. pub arrival_time: Option, + /// Optional salt used to partition prefix-cache entries for this request. pub cache_salt: Option, + /// Optional tracing headers to forward to engine-core and downstream + /// observability hooks. pub trace_headers: Option>, + /// Request scheduling priority. Lower values are scheduled earlier. pub priority: i32, + /// Optional data-parallel rank override for routing this request. pub data_parallel_rank: Option, - pub reasoning_ended: Option, + /// Optional reasoning-parser kwargs forwarded to engine-side structured + /// output logic. + pub reasoning_parser_kwargs: Option, + /// Optional LoRA adapter request applied to this generation. pub lora_request: Option, } @@ -61,7 +73,7 @@ impl GenerateRequest { trace_headers, priority, data_parallel_rank, - reasoning_ended, + reasoning_parser_kwargs, lora_request, } = self; @@ -72,7 +84,6 @@ impl GenerateRequest { } else { external_request_id.clone() }; - Ok(PreparedGenerateRequest { engine_request: EngineCoreRequest { request_id: engine_request_id, @@ -92,8 +103,10 @@ impl GenerateRequest { trace_headers, resumable: false, external_req_id: Some(external_request_id), - reasoning_ended, - reasoning_parser_kwargs: None, + // Rust parser doesn't expose this information, leave it unset and let the + // reasoning logic in engine-sided structured output manager handle it. + reasoning_ended: None, + reasoning_parser_kwargs, abort_immediately: false, }, }) @@ -110,18 +123,12 @@ impl PreparedGenerateRequest { } } -fn current_unix_timestamp_secs() -> f64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock is before unix epoch") - .as_secs_f64() -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; - use vllm_engine_core_client::protocol::EngineCoreSamplingParams; + use vllm_engine_core_client::protocol::request::ReasoningParserKwargs; + use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; use super::GenerateRequest; use crate::error::Error; @@ -140,7 +147,15 @@ mod tests { )])), priority: 3, data_parallel_rank: Some(2), - reasoning_ended: Some(true), + reasoning_parser_kwargs: Some(ReasoningParserKwargs { + chat_template_kwargs: [( + "chat_template_kwargs".to_string(), + serde_json::json!({ + "enable_thinking": true, + }), + )] + .into(), + }), lora_request: None, } } @@ -166,7 +181,16 @@ mod tests { "abc".to_string(), )])) ); - assert_eq!(request.reasoning_ended, Some(true)); + assert_eq!(request.reasoning_ended, None); + assert_eq!( + request + .reasoning_parser_kwargs + .as_ref() + .and_then(|kwargs| kwargs.chat_template_kwargs.get("chat_template_kwargs")), + Some(&serde_json::json!({ + "enable_thinking": true + })) + ); } #[test] diff --git a/rust/src/llm/src/request_metrics.rs b/rust/src/llm/src/request_metrics.rs index d28b83be816b..4f1673db154d 100644 --- a/rust/src/llm/src/request_metrics.rs +++ b/rust/src/llm/src/request_metrics.rs @@ -1,17 +1,16 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use vllm_engine_core_client::protocol::output::{ + EngineCoreEvent, EngineCoreEventType, EngineCoreOutput, +}; use vllm_engine_core_client::protocol::stats::PrefillStats; -use vllm_engine_core_client::protocol::{EngineCoreEvent, EngineCoreEventType, EngineCoreOutput}; use vllm_metrics::{ - EngineLabels, FinishedReasonLabels, METRICS, PromptTokenSourceLabels, RequestMetrics, + EngineLabels, Family, FinishedReasonLabels, HistogramMetric, METRICS, PromptTokenSourceLabels, + U64Counter, }; use crate::FinishReason; -fn metrics() -> &'static RequestMetrics { - &METRICS.request -} - const PROMPT_TOKEN_SOURCE_LOCAL_COMPUTE: &str = "local_compute"; const PROMPT_TOKEN_SOURCE_LOCAL_CACHE_HIT: &str = "local_cache_hit"; const PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER: &str = "external_kv_transfer"; @@ -27,9 +26,11 @@ const PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER: &str = "external_kv_transfer"; /// /// Original Python update flow: /// -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct RequestMetricsTracker { - model_name: String, + /// Cached request metric handles for this request's model and engine index. + handles: RequestMetricHandles, + arrival_time: f64, prompt_len: u32, max_tokens_param: Option, @@ -42,7 +43,38 @@ pub(crate) struct RequestMetricsTracker { first_token_latency: f64, num_generation_tokens: u32, latest_num_cached_tokens: u32, - last_seen_engine_index: u32, +} + +/// Cached request metric handles for one model and engine index. +#[derive(Clone)] +struct RequestMetricHandles { + labels: EngineLabels, + + // Request-derived counters. + num_preemptions: U64Counter, + prompt_tokens: U64Counter, + prompt_tokens_local_compute: U64Counter, + prompt_tokens_local_cache_hit: U64Counter, + prompt_tokens_external_kv_transfer: U64Counter, + prompt_tokens_cached: U64Counter, + generation_tokens: U64Counter, + + // Request lifecycle counters and histograms. + request_success: Family, + request_prompt_tokens: HistogramMetric, + request_generation_tokens: HistogramMetric, + request_max_num_generation_tokens: HistogramMetric, + request_params_max_tokens: HistogramMetric, + request_params_n: HistogramMetric, + request_prefill_kv_computed_tokens: HistogramMetric, + time_to_first_token_seconds: HistogramMetric, + inter_token_latency_seconds: HistogramMetric, + e2e_request_latency_seconds: HistogramMetric, + request_queue_time_seconds: HistogramMetric, + request_prefill_time_seconds: HistogramMetric, + request_decode_time_seconds: HistogramMetric, + request_inference_time_seconds: HistogramMetric, + request_time_per_output_token_seconds: HistogramMetric, } impl RequestMetricsTracker { @@ -50,13 +82,14 @@ impl RequestMetricsTracker { /// context. pub(crate) fn new( model_name: String, + engine_index: u32, arrival_time: f64, prompt_len: u32, max_tokens_param: Option, n_param: u32, ) -> Self { Self { - model_name, + handles: resolve_request_metric_handles(&model_name, engine_index), arrival_time, prompt_len, max_tokens_param, @@ -69,7 +102,6 @@ impl RequestMetricsTracker { first_token_latency: 0.0, num_generation_tokens: 0, latest_num_cached_tokens: 0, - last_seen_engine_index: 0, } } @@ -79,46 +111,41 @@ impl RequestMetricsTracker { /// pub(crate) fn observe_output( &mut self, - engine_index: u32, batch_timestamp: f64, received_at: f64, output: &EngineCoreOutput, ) { - self.last_seen_engine_index = engine_index; if let Some(prefill_stats) = &output.prefill_stats { self.latest_num_cached_tokens = prefill_stats.num_cached_tokens; } self.num_generation_tokens += output.new_token_ids.len() as u32; - metrics() - .generation_tokens - .get_or_create(&engine_labels(&self.model_name, engine_index)) - .inc_by(output.new_token_ids.len() as u64); + self.handles.generation_tokens.inc_by(output.new_token_ids.len() as u64); if let Some(events) = &output.events { - self.observe_events(engine_index, events); + self.observe_events(events); } - if self.is_prefilling { - if let Some(prefill_stats) = &output.prefill_stats { - record_prompt_tokens(&self.model_name, engine_index, prefill_stats); + // Only outputs that actually carry tokens drive token-timing metrics. + // A terminal output with no new tokens (e.g. the synthesized abort + // output) must not log a stray time-to-first-token or inter-token + // sample. + if !output.new_token_ids.is_empty() { + if self.is_prefilling { + if let Some(prefill_stats) = &output.prefill_stats { + self.record_prompt_tokens(prefill_stats); + } + self.first_token_latency = received_at - self.arrival_time; + self.handles.time_to_first_token_seconds.observe(self.first_token_latency); + self.first_token_ts = batch_timestamp; + self.is_prefilling = false; + } else if self.last_token_ts > 0.0 { + self.handles + .inter_token_latency_seconds + .observe(batch_timestamp - self.last_token_ts); } - self.first_token_latency = received_at - self.arrival_time; - observe_time_to_first_token_seconds( - &self.model_name, - engine_index, - self.first_token_latency, - ); - self.first_token_ts = batch_timestamp; - self.is_prefilling = false; - } else if self.last_token_ts > 0.0 { - observe_inter_token_latency_seconds( - &self.model_name, - engine_index, - batch_timestamp - self.last_token_ts, - ); - } - self.last_token_ts = batch_timestamp; + self.last_token_ts = batch_timestamp; + } } /// Emit the terminal request metrics once a finished output has been @@ -127,7 +154,6 @@ impl RequestMetricsTracker { /// Original Python finished-request stats: /// pub(crate) fn record_finished(&self, received_at: f64, finish_reason: FinishReason) { - let labels = engine_labels(&self.model_name, self.last_seen_engine_index); let prefill_kv_computed_tokens = self.prompt_len.saturating_sub(self.latest_num_cached_tokens); let e2e_latency_seconds = received_at - self.arrival_time; @@ -142,57 +168,47 @@ impl RequestMetricsTracker { 0.0 }; - record_request_success(&self.model_name, self.last_seen_engine_index, finish_reason); - metrics() - .request_prompt_tokens - .get_or_create(&labels) - .observe(self.prompt_len as f64); - metrics() + self.record_request_success(finish_reason); + + self.handles.request_prompt_tokens.observe(self.prompt_len as f64); + self.handles .request_generation_tokens - .get_or_create(&labels) .observe(self.num_generation_tokens as f64); - metrics() + self.handles .request_max_num_generation_tokens - .get_or_create(&labels) .observe(self.num_generation_tokens as f64); if let Some(max_tokens_param) = self.max_tokens_param { - metrics() - .request_params_max_tokens - .get_or_create(&labels) - .observe(max_tokens_param as f64); + self.handles.request_params_max_tokens.observe(max_tokens_param as f64); } - metrics().request_params_n.get_or_create(&labels).observe(self.n_param as f64); - metrics() + self.handles.request_params_n.observe(self.n_param as f64); + self.handles .request_prefill_kv_computed_tokens - .get_or_create(&labels) .observe(prefill_kv_computed_tokens as f64); - metrics() - .e2e_request_latency_seconds - .get_or_create(&labels) - .observe(e2e_latency_seconds); - metrics() - .request_queue_time_seconds - .get_or_create(&labels) - .observe(queue_time_seconds); - metrics() - .request_prefill_time_seconds - .get_or_create(&labels) - .observe(prefill_time_seconds); - metrics() - .request_decode_time_seconds - .get_or_create(&labels) - .observe(decode_time_seconds); - metrics() - .request_inference_time_seconds - .get_or_create(&labels) - .observe(inference_time_seconds); - metrics() + self.handles.e2e_request_latency_seconds.observe(e2e_latency_seconds); + self.handles.request_queue_time_seconds.observe(queue_time_seconds); + self.handles.request_prefill_time_seconds.observe(prefill_time_seconds); + self.handles.request_decode_time_seconds.observe(decode_time_seconds); + self.handles.request_inference_time_seconds.observe(inference_time_seconds); + self.handles .request_time_per_output_token_seconds - .get_or_create(&labels) .observe(time_per_output_token_seconds); } - fn observe_events(&mut self, engine_index: u32, events: &[EngineCoreEvent]) { + /// Record prompt token counters through cached metric handles. + fn record_prompt_tokens(&self, prefill_stats: &PrefillStats) { + let computed = prefill_stats.num_computed_tokens as u64; + let local_cache_hit = prefill_stats.num_local_cached_tokens as u64; + let external_kv_transfer = prefill_stats.num_external_cached_tokens as u64; + + self.handles.prompt_tokens.inc_by(prefill_stats.num_prompt_tokens as u64); + self.handles.prompt_tokens_local_compute.inc_by(computed); + self.handles.prompt_tokens_local_cache_hit.inc_by(local_cache_hit); + self.handles.prompt_tokens_external_kv_transfer.inc_by(external_kv_transfer); + self.handles.prompt_tokens_cached.inc_by(prefill_stats.num_cached_tokens as u64); + } + + /// Record request event counters through cached metric handles. + fn observe_events(&mut self, events: &[EngineCoreEvent]) { for event in events { match event.r#type { EngineCoreEventType::Queued => { @@ -204,46 +220,86 @@ impl RequestMetricsTracker { } } EngineCoreEventType::Preempted => { - metrics() - .num_preemptions - .get_or_create(&engine_labels(&self.model_name, engine_index)) - .inc(); + self.handles.num_preemptions.inc(); } } } } -} -fn engine_labels(model_name: &str, engine: u32) -> EngineLabels { - EngineLabels { - model_name: model_name.to_string(), - engine, + /// Increment the request-success counter for the terminal finish reason. + fn record_request_success(&self, finish_reason: FinishReason) { + self.handles + .request_success + .get_or_create(&FinishedReasonLabels { + model_name: self.handles.labels.model_name.clone(), + engine: self.handles.labels.engine, + finished_reason: finish_reason.as_str(), + }) + .inc(); } } -fn observe_time_to_first_token_seconds(model_name: &str, engine: u32, seconds: f64) { - metrics() - .time_to_first_token_seconds - .get_or_create(&engine_labels(model_name, engine)) - .observe(seconds); -} - -fn observe_inter_token_latency_seconds(model_name: &str, engine: u32, seconds: f64) { - metrics() - .inter_token_latency_seconds - .get_or_create(&engine_labels(model_name, engine)) - .observe(seconds); -} +/// Resolve fixed request metric handles for one model and engine index. +fn resolve_request_metric_handles(model_name: &str, engine: u32) -> RequestMetricHandles { + let metrics = &METRICS.request; + let labels = EngineLabels { + model_name: model_name.to_string(), + engine, + }; -fn record_request_success(model_name: &str, engine: u32, finish_reason: FinishReason) { - metrics() - .request_success - .get_or_create(&FinishedReasonLabels { - model_name: model_name.to_string(), - engine, - finished_reason: finish_reason.as_str(), - }) - .inc(); + RequestMetricHandles { + num_preemptions: metrics.num_preemptions.get_or_create_owned(&labels), + prompt_tokens: metrics.prompt_tokens.get_or_create_owned(&labels), + prompt_tokens_local_compute: metrics.prompt_tokens_by_source.get_or_create_owned( + &prompt_token_source_labels(model_name, engine, PROMPT_TOKEN_SOURCE_LOCAL_COMPUTE), + ), + prompt_tokens_local_cache_hit: metrics.prompt_tokens_by_source.get_or_create_owned( + &prompt_token_source_labels(model_name, engine, PROMPT_TOKEN_SOURCE_LOCAL_CACHE_HIT), + ), + prompt_tokens_external_kv_transfer: metrics.prompt_tokens_by_source.get_or_create_owned( + &prompt_token_source_labels( + model_name, + engine, + PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER, + ), + ), + prompt_tokens_cached: metrics.prompt_tokens_cached.get_or_create_owned(&labels), + generation_tokens: metrics.generation_tokens.get_or_create_owned(&labels), + request_success: metrics.request_success.clone(), + request_prompt_tokens: metrics.request_prompt_tokens.get_or_create_owned(&labels), + request_generation_tokens: metrics.request_generation_tokens.get_or_create_owned(&labels), + request_max_num_generation_tokens: metrics + .request_max_num_generation_tokens + .get_or_create_owned(&labels), + request_params_max_tokens: metrics.request_params_max_tokens.get_or_create_owned(&labels), + request_params_n: metrics.request_params_n.get_or_create_owned(&labels), + request_prefill_kv_computed_tokens: metrics + .request_prefill_kv_computed_tokens + .get_or_create_owned(&labels), + time_to_first_token_seconds: metrics + .time_to_first_token_seconds + .get_or_create_owned(&labels), + inter_token_latency_seconds: metrics + .inter_token_latency_seconds + .get_or_create_owned(&labels), + e2e_request_latency_seconds: metrics + .e2e_request_latency_seconds + .get_or_create_owned(&labels), + request_queue_time_seconds: metrics.request_queue_time_seconds.get_or_create_owned(&labels), + request_prefill_time_seconds: metrics + .request_prefill_time_seconds + .get_or_create_owned(&labels), + request_decode_time_seconds: metrics + .request_decode_time_seconds + .get_or_create_owned(&labels), + request_inference_time_seconds: metrics + .request_inference_time_seconds + .get_or_create_owned(&labels), + request_time_per_output_token_seconds: metrics + .request_time_per_output_token_seconds + .get_or_create_owned(&labels), + labels, + } } fn prompt_token_source_labels( @@ -258,45 +314,6 @@ fn prompt_token_source_labels( } } -fn record_prompt_tokens(model_name: &str, engine: u32, prefill_stats: &PrefillStats) { - let computed = prefill_stats.num_computed_tokens as u64; - let local_cache_hit = prefill_stats.num_local_cached_tokens as u64; - let external_kv_transfer = prefill_stats.num_external_cached_tokens as u64; - - metrics() - .prompt_tokens - .get_or_create(&engine_labels(model_name, engine)) - .inc_by(prefill_stats.num_prompt_tokens as u64); - metrics() - .prompt_tokens_by_source - .get_or_create(&prompt_token_source_labels( - model_name, - engine, - PROMPT_TOKEN_SOURCE_LOCAL_COMPUTE, - )) - .inc_by(computed); - metrics() - .prompt_tokens_by_source - .get_or_create(&prompt_token_source_labels( - model_name, - engine, - PROMPT_TOKEN_SOURCE_LOCAL_CACHE_HIT, - )) - .inc_by(local_cache_hit); - metrics() - .prompt_tokens_by_source - .get_or_create(&prompt_token_source_labels( - model_name, - engine, - PROMPT_TOKEN_SOURCE_EXTERNAL_KV_TRANSFER, - )) - .inc_by(external_kv_transfer); - metrics() - .prompt_tokens_cached - .get_or_create(&engine_labels(model_name, engine)) - .inc_by(prefill_stats.num_cached_tokens as u64); -} - fn diff_or_zero(end: f64, start: f64) -> f64 { if end > 0.0 && start > 0.0 && end >= start { end - start @@ -313,7 +330,7 @@ fn diff_or_zero(end: f64, start: f64) -> f64 { /// /// Original Python request timestamp source: /// -pub(crate) fn current_unix_timestamp_secs() -> f64 { +pub fn current_unix_timestamp_secs() -> f64 { SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock is before unix epoch") @@ -322,20 +339,20 @@ pub(crate) fn current_unix_timestamp_secs() -> f64 { #[cfg(test)] mod tests { + use vllm_engine_core_client::protocol::output::{EngineCoreEvent, EngineCoreEventType}; use vllm_engine_core_client::protocol::stats::PrefillStats; - use vllm_engine_core_client::protocol::{EngineCoreEvent, EngineCoreEventType}; use super::{RequestMetricsTracker, diff_or_zero}; #[test] fn tracker_updates_timing_state_across_prefill_decode_and_finish() { - let mut tracker = RequestMetricsTracker::new("model".to_string(), 100.0, 64, Some(128), 1); + let mut tracker = + RequestMetricsTracker::new("model".to_string(), 2, 100.0, 64, Some(128), 1); tracker.observe_output( - 2, 10.0, 100.2, - &vllm_engine_core_client::protocol::EngineCoreOutput { + &vllm_engine_core_client::protocol::output::EngineCoreOutput { request_id: "req-1".to_string(), new_token_ids: vec![1], finish_reason: None, @@ -360,10 +377,9 @@ mod tests { }, ); tracker.observe_output( - 2, 11.5, 100.4, - &vllm_engine_core_client::protocol::EngineCoreOutput { + &vllm_engine_core_client::protocol::output::EngineCoreOutput { request_id: "req-1".to_string(), new_token_ids: vec![2, 3], finish_reason: None, @@ -376,7 +392,7 @@ mod tests { ); assert!(!tracker.is_prefilling); - assert_eq!(tracker.last_seen_engine_index, 2); + assert_eq!(tracker.handles.labels.engine, 2); assert_eq!(tracker.num_generation_tokens, 3); assert_eq!(tracker.queued_ts, 8.0); assert_eq!(tracker.scheduled_ts, 9.0); diff --git a/rust/src/llm/tests/generate.rs b/rust/src/llm/tests/generate.rs index 8b1b98bdc485..8581b1ac08f5 100644 --- a/rust/src/llm/tests/generate.rs +++ b/rust/src/llm/tests/generate.rs @@ -9,13 +9,15 @@ use uuid::Uuid; use vllm_engine_core_client::protocol::logprobs::{ Logprobs, MaybeWireLogprobs, PositionLogprobs, TokenLogprob, }; -use vllm_engine_core_client::protocol::stats::PrefillStats; -use vllm_engine_core_client::protocol::{ +use vllm_engine_core_client::protocol::output::{ EngineCoreEvent, EngineCoreEventType, EngineCoreFinishReason, EngineCoreOutput, - EngineCoreOutputs, EngineCoreRequest, EngineCoreSamplingParams, + EngineCoreOutputs, RequestBatchOutputs, }; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; +use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; +use vllm_engine_core_client::protocol::stats::PrefillStats; use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task}; -use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig}; +use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, EngineId}; use vllm_llm::{ Error, FinishReason, GenerateOutputStreamExt as _, GeneratePromptInfo, GenerateRequest, Llm, }; @@ -179,7 +181,7 @@ fn sample_generate_request(request_id: &str, max_tokens: u32) -> GenerateRequest trace_headers: None, priority: 0, data_parallel_rank: None, - reasoning_ended: None, + reasoning_parser_kwargs: None, lora_request: None, } } @@ -249,7 +251,7 @@ async fn generate_streams_outputs() { send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![ request_output_with_logprobs( &request.request_id, @@ -268,7 +270,8 @@ async fn generate_streams_outputs() { ], finished_requests: Some(BTreeSet::from([request.request_id.clone()])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -329,16 +332,23 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() { send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![ - request_output_with_logprobs( - &request.request_id, - vec![33], - None, - Some(logprobs_for_position(33, -0.1, 1, 99, -0.2)), - Some(prompt_logprobs()), - ), + EngineCoreOutput { + prefill_stats: Some(PrefillStats { + num_prompt_tokens: 2, + num_cached_tokens: 1, + num_local_cached_tokens: 1, + ..Default::default() + }), + ..request_output_with_logprobs( + &request.request_id, + vec![33], + None, + Some(logprobs_for_position(33, -0.1, 1, 99, -0.2)), + Some(prompt_logprobs()), + ) + }, request_output_with_logprobs_and_kv( &request.request_id, vec![44], @@ -348,13 +358,9 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() { Some(serde_json::json!({"connector": "x"})), ), ], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; }) @@ -373,6 +379,7 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() { assert_eq!(collected.prompt_token_ids, vec![11, 22]); assert_eq!(collected.token_ids, vec![33, 44]); assert_eq!(collected.finish_reason, FinishReason::stop_eos()); + assert_eq!(collected.usage.cached_token_count, 1); assert_eq!(collected.prompt_logprobs, Some(prompt_logprobs())); assert_eq!( collected.logprobs.as_ref().map(|lp| lp.positions.len()), @@ -401,10 +408,12 @@ async fn generate_propagates_unexpected_close_errors() { send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { + outputs: Vec::new(), finished_requests: Some(BTreeSet::from([request.request_id])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -448,10 +457,11 @@ async fn dropping_a_live_generate_stream_triggers_abort() { send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output(&request.request_id, vec![99], None)], ..Default::default() - }, + } + .into(), ) .await; @@ -502,7 +512,7 @@ async fn duplicate_external_request_ids_are_randomized_before_reaching_engine_co send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output( &request_1.request_id, vec![], @@ -510,13 +520,14 @@ async fn duplicate_external_request_ids_are_randomized_before_reaching_engine_co )], finished_requests: Some(BTreeSet::from([request_1.request_id.clone()])), ..Default::default() - }, + } + .into(), ) .await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { outputs: vec![request_output( &request_2.request_id, vec![], @@ -524,7 +535,8 @@ async fn duplicate_external_request_ids_are_randomized_before_reaching_engine_co )], finished_requests: Some(BTreeSet::from([request_2.request_id])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -545,11 +557,149 @@ async fn duplicate_external_request_ids_are_randomized_before_reaching_engine_co llm.shutdown().await.unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn abort_resolves_external_request_id_to_internal_before_reaching_engine() { + let ipc = IpcNamespace::new().unwrap(); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-abort".to_vec(); + + let (shutdown_tx, engine_task) = spawn_mock_engine_task( + handshake_address.clone(), + engine_id.clone(), + |dealer, push| { + Box::pin(async move { + let add = recv_engine_message(dealer).await; + assert_eq!(add[0].as_ref(), &[0x00]); + let request: EngineCoreRequest = rmp_serde::from_slice(&add[1]).unwrap(); + assert_eq!(request.external_req_id.as_deref(), Some("req-abort")); + assert!(request.request_id.starts_with("req-abort-")); + assert_ne!(request.request_id, "req-abort"); + + send_outputs( + push, + RequestBatchOutputs { + outputs: vec![request_output(&request.request_id, vec![7], None)], + ..Default::default() + } + .into(), + ) + .await; + + // The abort frame must carry the internal engine id, not the + // external "req-abort" id the caller aborted by. + let abort = + timeout(Duration::from_secs(1), recv_engine_message(dealer)).await.unwrap(); + assert_eq!(abort[0].as_ref(), &[0x01]); + let aborted_ids: Vec = rmp_serde::from_slice(&abort[1]).unwrap(); + assert_eq!(aborted_ids, vec![request.request_id]); + }) + }, + ); + + let llm = connect_async_llm_with_ipc(handshake_address, 0, "test-model", &ipc).await; + let mut stream = llm.generate(sample_generate_request("req-abort", 4)).await.unwrap(); + let internal_id = stream.request_id().to_string(); + assert_ne!(internal_id, "req-abort"); + + assert_eq!(stream.next().await.unwrap().unwrap().token_ids, vec![7]); + + // Abort by the external id; engine-core only knows the internal id. + llm.abort(&["req-abort".to_string()]).await.unwrap(); + + // The consumer stream is finalized locally with a clean abort terminal + // rather than hanging or surfacing as RequestStreamClosed. The engine sends + // no final output for a client abort, so this output is synthesized. + let terminal = stream.next().await.unwrap().unwrap(); + assert_eq!(terminal.finish_reason, Some(FinishReason::Abort)); + assert!(terminal.token_ids.is_empty()); + assert!(stream.next().await.is_none()); + + let _ = shutdown_tx.send(()); + engine_task.await.unwrap(); + drop(stream); + llm.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn abort_by_external_id_aborts_all_internal_requests() { + let ipc = IpcNamespace::new().unwrap(); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-abort-many".to_vec(); + + let (shutdown_tx, engine_task) = spawn_mock_engine_task( + handshake_address.clone(), + engine_id.clone(), + |dealer, push| { + Box::pin(async move { + let add_1 = recv_engine_message(dealer).await; + assert_eq!(add_1[0].as_ref(), &[0x00]); + let request_1: EngineCoreRequest = rmp_serde::from_slice(&add_1[1]).unwrap(); + + let add_2 = recv_engine_message(dealer).await; + assert_eq!(add_2[0].as_ref(), &[0x00]); + let request_2: EngineCoreRequest = rmp_serde::from_slice(&add_2[1]).unwrap(); + + assert_eq!(request_1.external_req_id.as_deref(), Some("req-dup-abort")); + assert_eq!(request_2.external_req_id.as_deref(), Some("req-dup-abort")); + assert_ne!(request_1.request_id, request_2.request_id); + + send_outputs( + push, + RequestBatchOutputs { + outputs: vec![ + request_output(&request_1.request_id, vec![7], None), + request_output(&request_2.request_id, vec![8], None), + ], + ..Default::default() + } + .into(), + ) + .await; + + // A single abort by the shared external id must abort both + // internal engine ids it expanded into. + let abort = + timeout(Duration::from_secs(1), recv_engine_message(dealer)).await.unwrap(); + assert_eq!(abort[0].as_ref(), &[0x01]); + let mut aborted_ids: Vec = rmp_serde::from_slice(&abort[1]).unwrap(); + aborted_ids.sort(); + let mut expected = vec![request_1.request_id, request_2.request_id]; + expected.sort(); + assert_eq!(aborted_ids, expected); + }) + }, + ); + + let llm = connect_async_llm_with_ipc(handshake_address, 0, "test-model", &ipc).await; + let mut stream_1 = llm.generate(sample_generate_request("req-dup-abort", 4)).await.unwrap(); + let mut stream_2 = llm.generate(sample_generate_request("req-dup-abort", 4)).await.unwrap(); + assert_ne!(stream_1.request_id(), stream_2.request_id()); + + assert_eq!(stream_1.next().await.unwrap().unwrap().token_ids, vec![7]); + assert_eq!(stream_2.next().await.unwrap().unwrap().token_ids, vec![8]); + + llm.abort(&["req-dup-abort".to_string()]).await.unwrap(); + + // Both internal requests the external id expanded into are finalized with a + // clean abort terminal. + for stream in [&mut stream_1, &mut stream_2] { + let terminal = stream.next().await.unwrap().unwrap(); + assert_eq!(terminal.finish_reason, Some(FinishReason::Abort)); + assert!(stream.next().await.is_none()); + } + + let _ = shutdown_tx.send(()); + engine_task.await.unwrap(); + drop(stream_1); + drop(stream_2); + llm.shutdown().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn generate_records_request_metrics_in_prometheus_output() { let ipc = IpcNamespace::new().unwrap(); let handshake_address = ipc.handshake_endpoint(); - let engine_id = b"engine-metrics".to_vec(); + let engine_id = EngineId::from_engine_index(4); let model_name = request_metrics_model_name("metrics-model"); let (shutdown_tx, engine_task) = spawn_mock_engine_task( @@ -563,7 +713,7 @@ async fn generate_records_request_metrics_in_prometheus_output() { send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { engine_index: 4, timestamp: 10.0, outputs: vec![EngineCoreOutput { @@ -589,13 +739,14 @@ async fn generate_records_request_metrics_in_prometheus_output() { ) }], ..Default::default() - }, + } + .into(), ) .await; send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { engine_index: 4, timestamp: 11.5, outputs: vec![request_output_with_events( @@ -609,7 +760,8 @@ async fn generate_records_request_metrics_in_prometheus_output() { )], finished_requests: Some(BTreeSet::from([request.request_id])), ..Default::default() - }, + } + .into(), ) .await; }) @@ -680,7 +832,7 @@ async fn generate_records_request_metrics_in_prometheus_output() { async fn dropping_stream_records_abort_terminal_request_metrics() { let ipc = IpcNamespace::new().unwrap(); let handshake_address = ipc.handshake_endpoint(); - let engine_id = b"engine-metrics-drop".to_vec(); + let engine_id = EngineId::from_engine_index(5); let model_name = request_metrics_model_name("metrics-drop-model"); let (shutdown_tx, engine_task) = spawn_mock_engine_task( @@ -696,7 +848,7 @@ async fn dropping_stream_records_abort_terminal_request_metrics() { send_outputs( push, - EngineCoreOutputs { + RequestBatchOutputs { engine_index: 5, timestamp: 10.0, outputs: vec![request_output_with_events( @@ -715,7 +867,8 @@ async fn dropping_stream_records_abort_terminal_request_metrics() { ]), )], ..Default::default() - }, + } + .into(), ) .await; diff --git a/rust/src/managed-engine/src/cli.rs b/rust/src/managed-engine/src/cli.rs index 302737dbd885..9cf5e311ec19 100644 --- a/rust/src/managed-engine/src/cli.rs +++ b/rust/src/managed-engine/src/cli.rs @@ -43,6 +43,11 @@ pub struct ManagedEngineArgs { /// Arguments after an explicit `--` are forwarded verbatim. Before `--`, /// `vllm-rs serve` automatically keeps recognized frontend options on /// the Rust side and forwards everything else to Python. + /// + /// The explicit `--` passthrough is a last-resort escape hatch. Rust does + /// not interpret, validate, or de-duplicate those arguments against + /// managed-engine arguments that it appends later; if the same Python flag + /// appears more than once, Python argparse owns the final result. #[arg( last = true, allow_hyphen_values = true, @@ -71,6 +76,12 @@ impl ManagedEngineArgs { self, model: String, max_model_len: Option, + max_logprobs: Option, + profiler_config: Option, + reasoning_parser: Option<&str>, + language_model_only: bool, + disable_log_stats: bool, + shutdown_timeout: u64, handshake_port: u16, ) -> ManagedEngineConfig { let mut python_args = self.python_args; @@ -79,6 +90,30 @@ impl ManagedEngineArgs { python_args.push("--max-model-len".to_string()); python_args.push(max_model_len.to_string()); } + if let Some(max_logprobs) = max_logprobs { + python_args.push("--max-logprobs".to_string()); + python_args.push(max_logprobs.to_string()); + } + if let Some(profiler_config) = profiler_config { + python_args.push("--profiler-config".to_string()); + python_args.push(profiler_config); + } + if let Some(reasoning_parser) = reasoning_parser { + python_args.push("--reasoning-parser".to_string()); + python_args.push(reasoning_parser.to_string()); + } + if language_model_only { + python_args.push("--language-model-only".to_string()); + } + if disable_log_stats { + python_args.push("--disable-log-stats".to_string()); + } + // we must pass through shutdown_timeout to the engine, + // otherwise inflight requests get aborted on shutdown + if shutdown_timeout > 0 { + python_args.push("--shutdown-timeout".to_string()); + python_args.push(shutdown_timeout.to_string()); + } if let Some(data_parallel_size_local) = self.data_parallel_size_local { python_args.push("--data-parallel-size-local".to_string()); python_args.push(data_parallel_size_local.to_string()); diff --git a/rust/src/metrics/Cargo.toml b/rust/src/metrics/Cargo.toml index e6b579b97a47..ab1a72098b89 100644 --- a/rust/src/metrics/Cargo.toml +++ b/rust/src/metrics/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +itertools.workspace = true prometheus-client.workspace = true [lints] diff --git a/rust/src/metrics/src/lib.rs b/rust/src/metrics/src/lib.rs index 8f0db53d3ff9..ca650fbadaec 100644 --- a/rust/src/metrics/src/lib.rs +++ b/rust/src/metrics/src/lib.rs @@ -4,7 +4,7 @@ use std::sync::atomic::AtomicU64; use prometheus_client::encoding::text::encode; use prometheus_client::metrics::counter::Counter; -use prometheus_client::metrics::family::Family; +pub use prometheus_client::metrics::family::Family; use prometheus_client::metrics::gauge::Gauge; use prometheus_client::metrics::histogram::Histogram; use prometheus_client::registry::Registry; @@ -23,6 +23,8 @@ pub use scheduler::*; pub type U64Counter = Counter; pub type U64Gauge = Gauge; pub type F64Gauge = Gauge; +/// Histogram metric handle cloned out of a Prometheus family. +pub type HistogramMetric = Histogram; pub(crate) type HistogramFamily = Family Histogram>; /// Shared Prometheus registry for frontend metrics. diff --git a/rust/src/metrics/src/scheduler.rs b/rust/src/metrics/src/scheduler.rs index 0acbdf0fa753..c38cc291dc74 100644 --- a/rust/src/metrics/src/scheduler.rs +++ b/rust/src/metrics/src/scheduler.rs @@ -1,4 +1,8 @@ -use prometheus_client::encoding::EncodeLabelSet; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex}; + +use itertools::Itertools as _; +use prometheus_client::encoding::{EncodeLabelSet, EncodeLabelValue, LabelValueEncoder}; use prometheus_client::metrics::family::Family; use prometheus_client::metrics::histogram::Histogram; use prometheus_client::registry::Registry; @@ -42,6 +46,107 @@ pub struct WaitingReasonLabels { pub reason: &'static str, } +/// Adapter names encoded as a deterministic comma-joined Prometheus label value. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct LoraAdapterNames(pub BTreeSet); + +impl EncodeLabelValue for LoraAdapterNames { + fn encode(&self, encoder: &mut LabelValueEncoder) -> Result<(), std::fmt::Error> { + EncodeLabelValue::encode(&self.0.iter().join(","), encoder) + } +} + +/// Labels for `vllm:lora_requests_info`. +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +pub struct LoraInfoLabels { + pub running_lora_adapters: LoraAdapterNames, + pub waiting_lora_adapters: LoraAdapterNames, +} + +/// CUDA graph sample key used for periodic text-log aggregation. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CudagraphLogKey { + pub num_unpadded_tokens: u64, + pub num_padded_tokens: u64, + pub num_paddings: u64, + pub runtime_mode: String, +} + +/// Raw scheduler stats accumulated for one periodic text-log interval. +#[derive(Default)] +pub struct SchedulerLogStatsInterval { + pub spec_num_drafts: u64, + pub spec_accepted_tokens_per_pos: Vec, + pub cudagraph_counts: BTreeMap, +} + +impl SchedulerLogStatsInterval { + /// Merge another drained interval into this one. + pub fn merge(&mut self, other: Self) { + self.spec_num_drafts += other.spec_num_drafts; + + if self.spec_accepted_tokens_per_pos.len() < other.spec_accepted_tokens_per_pos.len() { + self.spec_accepted_tokens_per_pos + .resize(other.spec_accepted_tokens_per_pos.len(), 0); + } + for (position, accepted_tokens) in + other.spec_accepted_tokens_per_pos.into_iter().enumerate() + { + self.spec_accepted_tokens_per_pos[position] += accepted_tokens; + } + + for (key, count) in other.cudagraph_counts { + *self.cudagraph_counts.entry(key).or_default() += count; + } + } +} + +/// Internal, non-Prometheus accumulator for periodic text logs that need raw +/// scheduler DTOs. +#[derive(Clone, Default)] +pub struct SchedulerLogStatsAccumulator { + inner: Arc>, +} + +impl SchedulerLogStatsAccumulator { + /// Observe spec-decoding fields needed for per-position text-log rates. + pub fn observe_spec_decode(&self, num_drafts: u64, accepted_tokens_per_pos: &[u64]) { + let mut inner = self.inner.lock().expect("scheduler log stats accumulator poisoned"); + inner.spec_num_drafts += num_drafts; + + if inner.spec_accepted_tokens_per_pos.len() < accepted_tokens_per_pos.len() { + inner.spec_accepted_tokens_per_pos.resize(accepted_tokens_per_pos.len(), 0); + } + for (position, accepted_tokens) in accepted_tokens_per_pos.iter().copied().enumerate() { + inner.spec_accepted_tokens_per_pos[position] += accepted_tokens; + } + } + + /// Observe one CUDA graph runtime sample for the interval table. + pub fn observe_cudagraph( + &self, + num_unpadded_tokens: u64, + num_padded_tokens: u64, + num_paddings: u64, + runtime_mode: &str, + ) { + let mut inner = self.inner.lock().expect("scheduler log stats accumulator poisoned"); + let key = CudagraphLogKey { + num_unpadded_tokens, + num_padded_tokens, + num_paddings, + runtime_mode: runtime_mode.to_string(), + }; + *inner.cudagraph_counts.entry(key).or_default() += 1; + } + + /// Drain and reset the current text-log interval. + pub fn drain(&self) -> SchedulerLogStatsInterval { + let mut inner = self.inner.lock().expect("scheduler log stats accumulator poisoned"); + std::mem::take(&mut *inner) + } +} + /// Scheduler/batch-scoped Prometheus families exported from `SchedulerStats`. pub struct SchedulerMetrics { // Scheduler state gauges. @@ -50,6 +155,10 @@ pub struct SchedulerMetrics { pub scheduler_waiting_by_reason: Family, pub kv_cache_usage: Family, + /// `vllm:lora_requests_info`. Value is the emit-time unix timestamp in + /// seconds. + pub lora_info: Family, + // Prefix-cache counters, including the connector-backed external cache path. pub prefix_cache_queries: Family, pub prefix_cache_hits: Family, @@ -71,6 +180,9 @@ pub struct SchedulerMetrics { pub kv_block_lifetime_seconds: HistogramFamily, pub kv_block_idle_before_evict_seconds: HistogramFamily, pub kv_block_reuse_gap_seconds: HistogramFamily, + + /// Non-Prometheus interval accumulators for periodic text-log helpers. + pub log_stats: Family, } impl SchedulerMetrics { @@ -109,6 +221,13 @@ impl SchedulerMetrics { kv_cache_usage.clone(), ); + let lora_info = Family::default(); + registry.register( + "vllm:lora_requests_info", + "Running stats on lora requests.", + lora_info.clone(), + ); + // Prefix-cache counters, including the connector-backed external cache path. let prefix_cache_queries = Family::default(); registry.register( @@ -219,6 +338,7 @@ impl SchedulerMetrics { scheduler_waiting, scheduler_waiting_by_reason, kv_cache_usage, + lora_info, prefix_cache_queries, prefix_cache_hits, external_prefix_cache_queries, @@ -233,13 +353,14 @@ impl SchedulerMetrics { kv_block_lifetime_seconds, kv_block_idle_before_evict_seconds, kv_block_reuse_gap_seconds, + log_stats: Family::default(), } } } #[cfg(test)] mod tests { - use crate::{EngineLabels, Metrics}; + use crate::{CudagraphLogKey, EngineLabels, Metrics, SchedulerLogStatsAccumulator}; #[test] fn perf_counters_render_with_a_single_total_suffix() { @@ -269,4 +390,32 @@ mod tests { assert!(!rendered.contains("vllm:estimated_read_bytes_per_gpu_total_total")); assert!(!rendered.contains("vllm:estimated_write_bytes_per_gpu_total_total")); } + + #[test] + fn log_stats_accumulator_drains_interval_data() { + let accumulator = SchedulerLogStatsAccumulator::default(); + + accumulator.observe_spec_decode(2, &[1, 2]); + accumulator.observe_spec_decode(3, &[3, 4, 5]); + accumulator.observe_cudagraph(8, 16, 8, "FULL"); + accumulator.observe_cudagraph(8, 16, 8, "FULL"); + + let interval = accumulator.drain(); + + assert_eq!(interval.spec_num_drafts, 5); + assert_eq!(interval.spec_accepted_tokens_per_pos, vec![4, 6, 5]); + assert_eq!( + interval + .cudagraph_counts + .get(&CudagraphLogKey { + num_unpadded_tokens: 8, + num_padded_tokens: 16, + num_paddings: 8, + runtime_mode: "FULL".to_string(), + }) + .copied(), + Some(2) + ); + assert_eq!(accumulator.drain().spec_num_drafts, 0); + } } diff --git a/rust/src/mock-engine/src/engine.rs b/rust/src/mock-engine/src/engine.rs index 2aa2f7bb397c..73d60b55971e 100644 --- a/rust/src/mock-engine/src/engine.rs +++ b/rust/src/mock-engine/src/engine.rs @@ -11,12 +11,14 @@ use tokio::sync::mpsc; use tokio::task::yield_now; use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; +use vllm_engine_core_client::protocol::output::{ + EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs, + UtilityCallOutput, +}; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; use vllm_engine_core_client::protocol::utility::{ EngineCoreUtilityRequest, UtilityOutput, UtilityResultEnvelope, }; -use vllm_engine_core_client::protocol::{ - EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest, -}; use super::Opt; @@ -60,13 +62,14 @@ fn empty_finish_outputs( let output = request_output(request_id, Vec::new(), Some(finish_reason)); let finished_requests = BTreeSet::from([output.request_id.clone()]); - EngineCoreOutputs { + RequestBatchOutputs { engine_index, outputs: vec![output], timestamp: now_secs(), finished_requests: Some(finished_requests), ..Default::default() } + .into() } /// Encode a utility result into the protocol's msgpack value envelope. @@ -97,16 +100,16 @@ fn utility_response( _ => utility_envelope(Value::Nil), }?; - Ok(EngineCoreOutputs { + Ok(UtilityCallOutput { engine_index, - utility_output: Some(UtilityOutput { + timestamp: now_secs(), + output: UtilityOutput { call_id: request.call_id, failure_message: None, result: Some(result), - }), - timestamp: now_secs(), - ..Default::default() - }) + }, + } + .into()) } /// Message sent from the frontend to the mock engine task to drive the engine loop. @@ -269,13 +272,14 @@ impl Engine { } for (client_index, (client_outputs, finished_requests)) in outputs_by_client { outputs.push({ - let outputs = EngineCoreOutputs { + let outputs = RequestBatchOutputs { engine_index: self.engine_index, outputs: client_outputs, timestamp: now_secs(), finished_requests: Some(finished_requests), ..Default::default() - }; + } + .into(); EngineOutput { client_index, outputs, @@ -357,14 +361,15 @@ impl Engine { .filter_map(|(client_index, (outputs, finished_requests))| { (!outputs.is_empty()).then(|| EngineOutput { client_index, - outputs: EngineCoreOutputs { + outputs: RequestBatchOutputs { engine_index: self.engine_index, outputs, timestamp: now_secs(), finished_requests: (!finished_requests.is_empty()) .then_some(finished_requests), ..Default::default() - }, + } + .into(), }) }) .collect() diff --git a/rust/src/mock-engine/src/io.rs b/rust/src/mock-engine/src/io.rs index 28d77639c77b..77c0f14b57c4 100644 --- a/rust/src/mock-engine/src/io.rs +++ b/rust/src/mock-engine/src/io.rs @@ -4,10 +4,9 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::warn; use vllm_engine_core_client::mock_engine::MockEngineDataSockets; +use vllm_engine_core_client::protocol::request::{EngineCoreRequest, EngineCoreRequestType}; use vllm_engine_core_client::protocol::utility::EngineCoreUtilityRequest; -use vllm_engine_core_client::protocol::{ - EngineCoreRequest, EngineCoreRequestType, decode_msgpack, encode_msgpack, -}; +use vllm_engine_core_client::protocol::{decode_msgpack, encode_msgpack}; use zeromq::{DealerSocket, PushSocket, SocketRecv as _, SocketSend as _, ZmqMessage}; use crate::engine::{EngineInput, EngineOutput}; diff --git a/rust/src/mock-engine/src/tests.rs b/rust/src/mock-engine/src/tests.rs index a80aef403002..71a4e0a65759 100644 --- a/rust/src/mock-engine/src/tests.rs +++ b/rust/src/mock-engine/src/tests.rs @@ -5,9 +5,9 @@ use anyhow::Result; use futures::StreamExt as _; use tokio::time::timeout; use tokio_util::sync::CancellationToken; -use vllm_engine_core_client::protocol::{ - EngineCoreFinishReason, EngineCoreRequest, EngineCoreSamplingParams, -}; +use vllm_engine_core_client::protocol::output::EngineCoreFinishReason; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; +use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; use vllm_engine_core_client::test_utils::IpcNamespace; use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, TransportMode}; diff --git a/rust/src/tool-parser/Cargo.toml b/rust/src/parser/Cargo.toml similarity index 84% rename from rust/src/tool-parser/Cargo.toml rename to rust/src/parser/Cargo.toml index 0bc7010b75ba..3c49362c5d27 100644 --- a/rust/src/tool-parser/Cargo.toml +++ b/rust/src/parser/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "vllm-tool-parser" +name = "vllm-parser" version.workspace = true edition.workspace = true license.workspace = true @@ -13,7 +13,9 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true thiserror-ext.workspace = true +vllm-tokenizer.workspace = true winnow.workspace = true +xgrammar-structural-tag.workspace = true [dev-dependencies] criterion.workspace = true @@ -21,6 +23,7 @@ expect-test.workspace = true futures.workspace = true openai-protocol.workspace = true tool-parser.workspace = true +vllm-tokenizer = { workspace = true, features = ["test-utils"] } [[bench]] name = "deepseek_v3" @@ -72,5 +75,10 @@ name = "gemma4" harness = false required-features = ["test-util"] +[[bench]] +name = "granite4" +harness = false +required-features = ["test-util"] + [lints] workspace = true diff --git a/rust/src/tool-parser/benches/deepseek_v3.rs b/rust/src/parser/benches/deepseek_v3.rs similarity index 96% rename from rust/src/tool-parser/benches/deepseek_v3.rs rename to rust/src/parser/benches/deepseek_v3.rs index 75d2e417acee..4d1ea337a76b 100644 --- a/rust/src/tool-parser/benches/deepseek_v3.rs +++ b/rust/src/parser/benches/deepseek_v3.rs @@ -2,8 +2,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::DeepSeekParser as ExternalDeepSeekParser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{DeepSeekV3ToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{DeepSeekV3ToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; diff --git a/rust/src/tool-parser/benches/deepseek_v31.rs b/rust/src/parser/benches/deepseek_v31.rs similarity index 96% rename from rust/src/tool-parser/benches/deepseek_v31.rs rename to rust/src/parser/benches/deepseek_v31.rs index bb6d029baff9..a6f17c9f017a 100644 --- a/rust/src/tool-parser/benches/deepseek_v31.rs +++ b/rust/src/parser/benches/deepseek_v31.rs @@ -2,8 +2,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::DeepSeek31Parser as ExternalDeepSeek31Parser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{DeepSeekV31ToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{DeepSeekV31ToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; diff --git a/rust/src/tool-parser/benches/deepseek_v32.rs b/rust/src/parser/benches/deepseek_v32.rs similarity index 96% rename from rust/src/tool-parser/benches/deepseek_v32.rs rename to rust/src/parser/benches/deepseek_v32.rs index c7a8346120d7..1e770d9b136f 100644 --- a/rust/src/tool-parser/benches/deepseek_v32.rs +++ b/rust/src/parser/benches/deepseek_v32.rs @@ -1,8 +1,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{DeepSeekV32ToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{DeepSeekV32ToolParser, Tool, ToolParser}; mod utils; use utils::feed_parser; diff --git a/rust/src/tool-parser/benches/gemma4.rs b/rust/src/parser/benches/gemma4.rs similarity index 92% rename from rust/src/tool-parser/benches/gemma4.rs rename to rust/src/parser/benches/gemma4.rs index c4e8f966c2bf..fd29e77a9a22 100644 --- a/rust/src/tool-parser/benches/gemma4.rs +++ b/rust/src/parser/benches/gemma4.rs @@ -1,11 +1,12 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{Gemma4ToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{Tool, ToolParser}; +use vllm_parser::unified::Gemma4UnifiedParser; mod utils; -use utils::feed_parser; +use utils::{UnifiedToolParserAdapter, feed_parser}; const CHUNK_CHARS: usize = 7; const LONG_NORMAL_TEXT_REPEATS: usize = 2048; @@ -68,7 +69,8 @@ fn long_tool_argument_fixture() -> String { } fn parser(tools: &[Tool]) -> Box { - Gemma4ToolParser::create(tools).expect("Gemma4 parser should initialize") + UnifiedToolParserAdapter::::create(tools) + .expect("Gemma4 unified parser should initialize") } fn run_stream_group( diff --git a/rust/src/tool-parser/benches/glm45_moe.rs b/rust/src/parser/benches/glm45_moe.rs similarity index 97% rename from rust/src/tool-parser/benches/glm45_moe.rs rename to rust/src/parser/benches/glm45_moe.rs index 8486885eceb1..a55a9e83ac02 100644 --- a/rust/src/tool-parser/benches/glm45_moe.rs +++ b/rust/src/parser/benches/glm45_moe.rs @@ -2,8 +2,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::Glm4MoeParser as ExternalGlm4MoeParser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{Glm45MoeToolParser, Glm47MoeToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{Glm45MoeToolParser, Glm47MoeToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; diff --git a/rust/src/parser/benches/granite4.rs b/rust/src/parser/benches/granite4.rs new file mode 100644 index 000000000000..17ef86716057 --- /dev/null +++ b/rust/src/parser/benches/granite4.rs @@ -0,0 +1,75 @@ +use std::time::Duration; + +use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{Granite4ToolParser, Tool, ToolParser}; + +mod utils; +use utils::feed_parser; + +const CHUNK_CHARS: usize = 7; +const LONG_ARGUMENT_BYTES: usize = 64 * 1024; + +fn string_args_fixture() -> String { + let arguments = format!(r#"{{"data":"{}"}}"#, "x".repeat(LONG_ARGUMENT_BYTES)); + let encoded_arguments = serde_json::to_string(&arguments).unwrap(); + format!(r#"{{"name":"f","arguments":{encoded_arguments}}}"#) +} + +fn object_args_fixture() -> String { + format!( + r#"{{"name":"f","arguments":{{"data":"{}"}}}}"#, + "x".repeat(LONG_ARGUMENT_BYTES) + ) +} + +fn parser(tools: &[Tool]) -> Box { + Granite4ToolParser::create(tools).expect("Granite4 parser should initialize") +} + +fn run_stream_group(c: &mut Criterion, name: &str, tools: &[Tool], text: &str) { + let chunks = split_by_chars(text, CHUNK_CHARS); + + let mut group = c.benchmark_group(name); + group.sample_size(50); + group.warm_up_time(Duration::from_millis(300)); + group.measurement_time(Duration::from_secs(2)); + group.throughput(Throughput::Bytes(text.len() as u64)); + + group.bench_function("reuse_parser", |b| { + let mut parser = parser(tools); + b.iter(|| { + let result = feed_parser(&mut *parser, black_box(&chunks)); + debug_assert_eq!(result.0, ""); + debug_assert_eq!(result.1, 1); + black_box(result); + }) + }); + + group.bench_function("create_parser", |b| { + b.iter_batched( + || parser(tools), + |mut parser| { + let result = feed_parser(&mut *parser, black_box(&chunks)); + debug_assert_eq!(result.0, ""); + debug_assert_eq!(result.1, 1); + black_box(result); + }, + BatchSize::SmallInput, + ) + }); + + group.finish(); +} + +fn bench_granite4(c: &mut Criterion) { + let tools = test_tools(); + let string_args = string_args_fixture(); + let object_args = object_args_fixture(); + + run_stream_group(c, "granite4/long_string_arguments", &tools, &string_args); + run_stream_group(c, "granite4/long_object_arguments", &tools, &object_args); +} + +criterion_group!(benches, bench_granite4); +criterion_main!(benches); diff --git a/rust/src/tool-parser/benches/kimi_k2.rs b/rust/src/parser/benches/kimi_k2.rs similarity index 97% rename from rust/src/tool-parser/benches/kimi_k2.rs rename to rust/src/parser/benches/kimi_k2.rs index 5a80f6606735..ab4c98399aac 100644 --- a/rust/src/tool-parser/benches/kimi_k2.rs +++ b/rust/src/parser/benches/kimi_k2.rs @@ -2,8 +2,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::KimiK2Parser as ExternalKimiK2Parser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{KimiK2ToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{KimiK2ToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; diff --git a/rust/src/tool-parser/benches/llama3_json.rs b/rust/src/parser/benches/llama3_json.rs similarity index 96% rename from rust/src/tool-parser/benches/llama3_json.rs rename to rust/src/parser/benches/llama3_json.rs index 03b5b54ee78a..1126daf6f7d2 100644 --- a/rust/src/tool-parser/benches/llama3_json.rs +++ b/rust/src/parser/benches/llama3_json.rs @@ -2,8 +2,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::LlamaParser as ExternalLlamaParser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{Llama3JsonToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{Llama3JsonToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; diff --git a/rust/src/tool-parser/benches/minimax_m2.rs b/rust/src/parser/benches/minimax_m2.rs similarity index 97% rename from rust/src/tool-parser/benches/minimax_m2.rs rename to rust/src/parser/benches/minimax_m2.rs index 4ad20400934b..734d7437fbc3 100644 --- a/rust/src/tool-parser/benches/minimax_m2.rs +++ b/rust/src/parser/benches/minimax_m2.rs @@ -2,8 +2,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::MinimaxM2Parser as ExternalMinimaxM2Parser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{MinimaxM2ToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{MinimaxM2ToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; diff --git a/rust/src/tool-parser/benches/qwen3_coder.rs b/rust/src/parser/benches/qwen3_coder.rs similarity index 86% rename from rust/src/tool-parser/benches/qwen3_coder.rs rename to rust/src/parser/benches/qwen3_coder.rs index 850badaac527..9d70937728fa 100644 --- a/rust/src/tool-parser/benches/qwen3_coder.rs +++ b/rust/src/parser/benches/qwen3_coder.rs @@ -2,14 +2,15 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::QwenCoderParser as ExternalQwenCoderParser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{Qwen3CoderToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{Qwen3CoderToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; const CHUNK_CHARS: usize = 7; const LONG_NORMAL_TEXT_REPEATS: usize = 2048; +const LONG_TOOL_BODY_REPEATS: usize = 8192; fn mixed_fixture() -> String { concat!( @@ -39,6 +40,17 @@ fn long_normal_text_fixture() -> String { line.repeat(LONG_NORMAL_TEXT_REPEATS) } +fn long_tool_call_fixture() -> String { + let location = "x".repeat(LONG_TOOL_BODY_REPEATS); + format!( + "\n\ + \n\ + {location}\n\ + \n\ + " + ) +} + fn native_parser(tools: &[Tool]) -> Box { Qwen3CoderToolParser::create(tools).expect("Qwen Coder parser should initialize") } @@ -112,6 +124,7 @@ fn bench_qwen3_coder(c: &mut Criterion) { let tools = test_tools(); let mixed_text = mixed_fixture(); let long_normal_text = long_normal_text_fixture(); + let long_tool_call = long_tool_call_fixture(); run_stream_group( c, @@ -132,6 +145,16 @@ fn bench_qwen3_coder(c: &mut Criterion) { &long_normal_text, 0, ); + + run_stream_group( + c, + "qwen3_coder/long_tool_call_body", + &tools, + &long_tool_call, + CHUNK_CHARS, + "", + 1, + ); } criterion_group!(benches, bench_qwen3_coder); diff --git a/rust/src/tool-parser/benches/qwen3_xml.rs b/rust/src/parser/benches/qwen3_xml.rs similarity index 96% rename from rust/src/tool-parser/benches/qwen3_xml.rs rename to rust/src/parser/benches/qwen3_xml.rs index f2e37551dda7..59ea0de47dd6 100644 --- a/rust/src/tool-parser/benches/qwen3_xml.rs +++ b/rust/src/parser/benches/qwen3_xml.rs @@ -2,8 +2,8 @@ use std::time::Duration; use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main}; use tool_parser::parsers::QwenParser as ExternalQwenParser; -use vllm_tool_parser::test_utils::{split_by_chars, test_tools}; -use vllm_tool_parser::{Qwen3XmlToolParser, Tool, ToolParser}; +use vllm_parser::tool::test_utils::{split_by_chars, test_tools}; +use vllm_parser::tool::{Qwen3XmlToolParser, Tool, ToolParser}; mod utils; use utils::{feed_external_parser, feed_parser, openai_tools}; diff --git a/rust/src/parser/benches/utils/adapter.rs b/rust/src/parser/benches/utils/adapter.rs new file mode 100644 index 000000000000..20f8977441c0 --- /dev/null +++ b/rust/src/parser/benches/utils/adapter.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use vllm_parser::tool::{ + Result, StructuralTagModel, Tool, ToolParser, ToolParserError, ToolParserOutput, +}; +use vllm_parser::unified::{ + UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput, +}; +use vllm_tokenizer::Tokenizer; + +/// Tokenizer stub used by unified-parser benchmarks. +struct BenchTokenizer; + +impl Tokenizer for BenchTokenizer { + fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result> { + Ok(text.chars().map(|_| u32::MAX).collect()) + } + + fn decode( + &self, + token_ids: &[u32], + _skip_special_tokens: bool, + ) -> vllm_tokenizer::Result { + Ok("\u{FFFD}".repeat(token_ids.len())) + } + + fn token_to_id(&self, _token: &str) -> Option { + Some(u32::MAX) + } + + fn id_to_token(&self, _id: u32) -> Option { + Some("\u{FFFD}".to_string()) + } +} + +/// Bench-only adapter that exposes a unified parser through the tool-parser +/// benchmark harness. +/// +/// Returns error if the unified parser produces reasoning events. +pub struct UnifiedToolParserAdapter { + inner: Box, + _marker: std::marker::PhantomData, +} + +fn map_unified_error(error: UnifiedParserError) -> ToolParserError { + ToolParserError::ParsingFailed { + message: format!("unified parser failed: {error}"), + } +} + +fn append_unified_output( + output: UnifiedParserOutput, + tool_output: &mut ToolParserOutput, +) -> Result<()> { + for event in output.events { + match event { + UnifiedParserEvent::Text(text) => tool_output.push_text(text), + UnifiedParserEvent::ToolCall(call) => tool_output.push_call(call), + UnifiedParserEvent::Reasoning(_) => { + return Err(ToolParserError::ParsingFailed { + message: "unified parser emitted reasoning in tool-parser adapter".to_string(), + }); + } + } + } + Ok(()) +} + +impl ToolParser for UnifiedToolParserAdapter { + fn create(tools: &[Tool]) -> Result> + where + Self: Sized + 'static, + { + let inner = T::create(tools, Arc::new(BenchTokenizer)).map_err(map_unified_error)?; + Ok(Box::new(Self { + inner, + _marker: std::marker::PhantomData, + })) + } + + fn preserve_special_tokens(&self) -> bool { + self.inner.preserve_special_tokens() + } + + fn structural_tag_model(&self) -> Option { + self.inner.structural_tag_model() + } + + fn tool_call_id(&self, tool_index: usize) -> Option<&str> { + self.inner.tool_call_id(tool_index) + } + + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + let mut unified_output = UnifiedParserOutput::default(); + let result = self.inner.parse_into(chunk, &mut unified_output).map_err(map_unified_error); + append_unified_output(unified_output, output)?; + result + } + + fn finish(&mut self) -> Result { + let unified_output = self.inner.finish().map_err(map_unified_error)?; + let mut output = ToolParserOutput::default(); + append_unified_output(unified_output, &mut output)?; + Ok(output) + } + + fn reset(&mut self) -> String { + self.inner.reset() + } +} diff --git a/rust/src/tool-parser/benches/utils/mod.rs b/rust/src/parser/benches/utils/mod.rs similarity index 77% rename from rust/src/tool-parser/benches/utils/mod.rs rename to rust/src/parser/benches/utils/mod.rs index a0ad768f1154..bb674131718c 100644 --- a/rust/src/tool-parser/benches/utils/mod.rs +++ b/rust/src/parser/benches/utils/mod.rs @@ -1,10 +1,16 @@ +// This module is shared by multiple benchmark targets. +// There could be false positives for unused code or imports, and fixing them would lead to some other benchmarks failing to compile. #![allow(dead_code)] +#![allow(unused_imports)] +mod adapter; + +pub(super) use adapter::UnifiedToolParserAdapter; use futures::FutureExt as _; use openai_protocol::common::{Function as OpenAiFunction, Tool as OpenAiTool}; use tool_parser::traits::ToolParser as ExternalToolParser; -use vllm_tool_parser::test_utils::collect_stream; -use vllm_tool_parser::{Tool, ToolParser}; +use vllm_parser::tool::test_utils::collect_stream; +use vllm_parser::tool::{Tool, ToolParser}; pub(super) fn openai_tools(tools: &[Tool]) -> Vec { tools @@ -23,7 +29,7 @@ pub(super) fn openai_tools(tools: &[Tool]) -> Vec { pub(super) fn feed_parser(parser: &mut dyn ToolParser, chunks: &[&str]) -> (String, usize) { let result = collect_stream(parser, chunks); - (result.normal_text, result.calls.len()) + (result.normal_text(), result.calls().len()) } pub(super) fn feed_external_parser( diff --git a/rust/src/parser/python/Cargo.toml b/rust/src/parser/python/Cargo.toml new file mode 100644 index 000000000000..aadae5638f90 --- /dev/null +++ b/rust/src/parser/python/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "vllm-tool-parser-py" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "_rust_tool_parser" +crate-type = ["cdylib", "rlib"] + +[dependencies] +pyo3.workspace = true +pythonize = { workspace = true, features = ["serde_json"] } +serde_json.workspace = true +thiserror-ext.workspace = true +vllm-parser.workspace = true + +[lints] +workspace = true diff --git a/rust/src/parser/python/src/lib.rs b/rust/src/parser/python/src/lib.rs new file mode 100644 index 000000000000..8057930ade08 --- /dev/null +++ b/rust/src/parser/python/src/lib.rs @@ -0,0 +1,395 @@ +//! Thin PyO3 bindings for `vllm_parser::tool`. +//! +//! This crate exposes the Rust tool parser trait and data shapes to Python +//! while keeping parser state, grammar, and schema-aware argument conversion in +//! Rust. Python callers should use this module as a typed bridge and keep any +//! vLLM protocol adaptation outside the binding. + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyModule}; +use pythonize::{depythonize, pythonize}; +use serde_json::Value; +use thiserror_ext::AsReport as _; +use vllm_parser::tool::{Tool, ToolCallDelta, ToolParser, ToolParserOutput}; + +macro_rules! tool_parser_factory { + ($($parser:ident),+ $(,)?) => { + fn create_tool_parser( + name: &str, + tools: &[Tool], + ) -> PyResult> { + match name { + $( + stringify!($parser) => { + ::create(tools) + } + )+ + _ => { + return Err(PyValueError::new_err(format!( + "unsupported tool parser `{name}`" + ))); + } + } + .map_err(|error| PyValueError::new_err(error.to_report_string())) + } + }; +} + +// Export a tool parser to Python by registering it here. +tool_parser_factory! { + MinimaxM3ToolParser, + + // Below are the parsers just for testing purposes on Python side. + DeepSeekV4ToolParser, + KimiK2ToolParser, +} + +#[pyclass(name = "Tool", module = "vllm._rust_tool_parser", skip_from_py_object)] +#[derive(Clone)] +struct PyTool(Tool); + +#[pymethods] +impl PyTool { + #[new] + #[pyo3(signature = (name, description, parameters, strict=None))] + fn new( + name: String, + description: Option, + parameters: &Bound<'_, PyAny>, + strict: Option, + ) -> PyResult { + let parameters = depythonize::(parameters).map_err(|error| { + PyValueError::new_err(format!( + "failed to convert tool parameters from Python to JSON: {error}" + )) + })?; + Ok(Self(Tool { + name, + description, + parameters, + strict, + })) + } + + #[getter] + fn name(&self) -> &str { + &self.0.name + } + + #[getter] + fn description(&self) -> Option<&str> { + self.0.description.as_deref() + } + + #[getter] + fn parameters(&self, py: Python<'_>) -> PyResult> { + pythonize(py, &self.0.parameters).map(Bound::unbind).map_err(|error| { + PyValueError::new_err(format!( + "failed to convert tool parameters from JSON to Python: {error}" + )) + }) + } + + #[getter] + fn strict(&self) -> Option { + self.0.strict + } +} + +#[pyclass( + name = "ToolCallDelta", + module = "vllm._rust_tool_parser", + skip_from_py_object +)] +#[derive(Clone)] +struct PyToolCallDelta(ToolCallDelta); + +#[pymethods] +impl PyToolCallDelta { + #[new] + #[pyo3(signature = (tool_index, name, arguments))] + fn new(tool_index: usize, name: Option, arguments: String) -> Self { + Self(ToolCallDelta { + tool_index, + name, + arguments, + }) + } + + #[getter] + fn tool_index(&self) -> usize { + self.0.tool_index + } + + #[getter] + fn name(&self) -> Option<&str> { + self.0.name.as_deref() + } + + #[getter] + fn arguments(&self) -> &str { + &self.0.arguments + } +} + +#[pyclass( + name = "ToolParserOutput", + module = "vllm._rust_tool_parser", + skip_from_py_object +)] +#[derive(Clone)] +struct PyToolParserOutput(ToolParserOutput); + +#[pymethods] +impl PyToolParserOutput { + #[new] + #[pyo3(signature = (normal_text="", calls=None))] + fn new(py: Python<'_>, normal_text: &str, calls: Option>>) -> Self { + let mut output = ToolParserOutput::default(); + output.push_text(normal_text); + for call in calls.unwrap_or_default() { + output.push_call(call.borrow(py).0.clone()); + } + Self(output) + } + + #[getter] + fn normal_text(&self) -> String { + self.0.normal_text() + } + + #[getter] + fn calls(&self) -> Vec { + self.0.calls().into_iter().cloned().map(PyToolCallDelta).collect() + } + + fn append(&mut self, other: PyRef<'_, PyToolParserOutput>) { + self.0.append(other.0.clone()); + } + + fn coalesce(&self) -> Self { + Self(self.0.clone().coalesce()) + } +} + +#[pyclass(name = "ToolParser", module = "vllm._rust_tool_parser", unsendable)] +struct PyToolParser(Box); + +impl PyToolParser { + fn parse_into_output(&mut self, chunk: &str, output: &mut PyToolParserOutput) -> PyResult<()> { + self.0 + .parse_into(chunk, &mut output.0) + .map_err(|error| PyValueError::new_err(error.to_report_string())) + } +} + +#[pymethods] +impl PyToolParser { + #[new] + fn new(py: Python<'_>, parser_name: &str, tools: Vec>) -> PyResult { + let tools = tools.iter().map(|tool| tool.borrow(py).0.clone()).collect::>(); + create_tool_parser(parser_name, &tools).map(Self) + } + + fn parse_into( + &mut self, + chunk: &str, + mut output: PyRefMut<'_, PyToolParserOutput>, + ) -> PyResult<()> { + self.parse_into_output(chunk, &mut output) + } + + fn finish(&mut self) -> PyResult { + self.0 + .finish() + .map(PyToolParserOutput) + .map_err(|error| PyValueError::new_err(error.to_report_string())) + } + + fn reset(&mut self) -> String { + self.0.reset() + } + + fn preserve_special_tokens(&self) -> bool { + self.0.preserve_special_tokens() + } + + fn tool_call_id(&self, tool_index: usize) -> Option<&str> { + self.0.tool_call_id(tool_index) + } +} + +#[pymodule] +fn _rust_tool_parser(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn with_python(f: impl for<'py> FnOnce(Python<'py>) -> R) -> R { + Python::initialize(); + Python::attach(f) + } + + fn tool_schema() -> Value { + json!({ + "type": "object", + "properties": { + "user_id": {"type": "integer"}, + "shipping": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "zip": {"type": "integer"} + } + } + } + }) + } + + fn build_call() -> String { + r#"<|DSML|tool_calls> +<|DSML|invoke name="create_order"> +<|DSML|parameter name="user_id" string="false">42 +<|DSML|parameter name="shipping" string="false">{"city":"Singapore","zip":18956} + +"# + .to_owned() + } + + fn make_py_tool(py: Python<'_>) -> PyResult> { + let parameters = pythonize(py, &tool_schema()).map_err(|error| { + PyValueError::new_err(format!( + "failed to convert test schema from JSON to Python: {error}" + )) + })?; + Py::new( + py, + PyTool::new( + "create_order".to_owned(), + Some("Create an order".to_owned()), + ¶meters, + None, + )?, + ) + } + + #[test] + fn tool_round_trips_typed_fields() { + with_python(|py| { + let tool = make_py_tool(py)?; + let borrowed = tool.borrow(py); + assert_eq!(borrowed.name(), "create_order"); + assert_eq!(borrowed.description(), Some("Create an order")); + assert_eq!(borrowed.strict(), None); + + let parameters = borrowed.parameters(py)?; + let parameters = depythonize::(parameters.bind(py))?; + assert_eq!(parameters, tool_schema()); + PyResult::Ok(()) + }) + .unwrap(); + } + + #[test] + fn output_append_and_coalesce() { + with_python(|py| { + let first = Py::new( + py, + PyToolCallDelta::new(0, Some("create_order".to_owned()), "{\"a\"".to_owned()), + )?; + let second = Py::new(py, PyToolCallDelta::new(0, None, ":1}".to_owned()))?; + let mut output = PyToolParserOutput::new(py, "text", Some(vec![first])); + let other = Py::new(py, PyToolParserOutput::new(py, "", Some(vec![second])))?; + output.append(other.borrow(py)); + + let coalesced = output.coalesce(); + assert_eq!(coalesced.normal_text(), "text"); + let calls = coalesced.calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].tool_index(), 0); + assert_eq!(calls[0].name(), Some("create_order")); + assert_eq!(calls[0].arguments(), "{\"a\":1}"); + PyResult::Ok(()) + }) + .unwrap(); + } + + #[test] + fn parser_parse_finish_and_preserve_special_tokens() { + with_python(|py| { + let tool = make_py_tool(py)?; + let mut parser = PyToolParser::new(py, "DeepSeekV4ToolParser", vec![tool])?; + assert!(parser.preserve_special_tokens()); + + let mut output = PyToolParserOutput::new(py, "", None); + parser.parse_into_output(&build_call(), &mut output)?; + let finish = Py::new(py, parser.finish()?)?; + output.append(finish.borrow(py)); + let output = output.coalesce(); + + assert_eq!(output.normal_text(), ""); + let calls = output.calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name(), Some("create_order")); + assert_eq!( + serde_json::from_str::(calls[0].arguments()).unwrap(), + json!({ + "user_id": 42, + "shipping": { + "city": "Singapore", + "zip": 18956 + } + }) + ); + + assert_eq!(parser.reset(), ""); + PyResult::Ok(()) + }) + .unwrap(); + } + + #[test] + fn parser_exposes_model_emitted_tool_call_ids() { + with_python(|py| { + let tool = make_py_tool(py)?; + let mut parser = PyToolParser::new(py, "KimiK2ToolParser", vec![tool])?; + + let input = "<|tool_calls_section_begin|>\ + <|tool_call_begin|>functions.create_order:0<|tool_call_argument_begin|>\ + {\"user_id\":42}<|tool_call_end|>\ + <|tool_calls_section_end|>"; + let mut output = PyToolParserOutput::new(py, "", None); + parser.parse_into_output(input, &mut output)?; + + assert_eq!(parser.tool_call_id(0), Some("functions.create_order:0")); + assert_eq!(parser.tool_call_id(1), None); + PyResult::Ok(()) + }) + .unwrap(); + } + + #[test] + fn parser_errors_for_unknown_name() { + with_python(|py| { + let tool = make_py_tool(py)?; + let error = match PyToolParser::new(py, "missing", vec![tool]) { + Ok(_) => panic!("missing parser name unexpectedly succeeded"), + Err(error) => error, + }; + let message = format!("{error}"); + assert!(message.contains("unsupported tool parser `missing`")); + PyResult::Ok(()) + }) + .unwrap(); + } +} diff --git a/rust/src/parser/src/lib.rs b/rust/src/parser/src/lib.rs new file mode 100644 index 000000000000..0b5c2b6d7824 --- /dev/null +++ b/rust/src/parser/src/lib.rs @@ -0,0 +1,6 @@ +//! Streaming parsers for chat completions. + +pub mod reasoning; +pub mod tool; +pub mod unified; +pub(crate) mod utils; diff --git a/rust/src/reasoning-parser/src/cohere_cmd.rs b/rust/src/parser/src/reasoning/cohere_cmd.rs similarity index 100% rename from rust/src/reasoning-parser/src/cohere_cmd.rs rename to rust/src/parser/src/reasoning/cohere_cmd.rs diff --git a/rust/src/reasoning-parser/src/deepseek_r1.rs b/rust/src/parser/src/reasoning/deepseek_r1.rs similarity index 100% rename from rust/src/reasoning-parser/src/deepseek_r1.rs rename to rust/src/parser/src/reasoning/deepseek_r1.rs diff --git a/rust/src/reasoning-parser/src/delimited.rs b/rust/src/parser/src/reasoning/delimited.rs similarity index 93% rename from rust/src/reasoning-parser/src/delimited.rs rename to rust/src/parser/src/reasoning/delimited.rs index 485202e3e2ef..256e95fdde31 100644 --- a/rust/src/reasoning-parser/src/delimited.rs +++ b/rust/src/parser/src/reasoning/delimited.rs @@ -68,6 +68,11 @@ impl DelimitedReasoningParser { .unwrap_or(self.default_in_reasoning); } + /// Return whether the parser is currently inside a reasoning section. + pub(crate) fn in_reasoning(&self) -> bool { + self.current_in_reasoning + } + /// Parse one decoded text delta and return its reasoning/content split. pub(crate) fn push(&mut self, delta: &str) -> ReasoningDelta { self.buffer.push_str(delta); @@ -139,20 +144,20 @@ impl DelimitedReasoningParser { } /// Determine the reasoning state implied by the last prompt boundary, if any. -fn last_reasoning_boundary( +pub(crate) fn last_reasoning_boundary( prompt_token_ids: &[u32], start_token_id: u32, end_token_id: u32, tokenizer: &dyn Tokenizer, ) -> Option { - for token_id in prompt_token_ids.iter().rev() { - if *token_id == start_token_id { + for token_id in prompt_token_ids.iter().rev().copied() { + if token_id == start_token_id { return Some(true); } - if *token_id == end_token_id { + if token_id == end_token_id { return Some(false); } - if tokenizer.is_special_id(*token_id) { + if tokenizer.is_special_id(token_id) { return None; } } diff --git a/rust/src/reasoning-parser/src/kimi.rs b/rust/src/parser/src/reasoning/kimi.rs similarity index 100% rename from rust/src/reasoning-parser/src/kimi.rs rename to rust/src/parser/src/reasoning/kimi.rs diff --git a/rust/src/parser/src/reasoning/minimax_m3.rs b/rust/src/parser/src/reasoning/minimax_m3.rs new file mode 100644 index 000000000000..69d4e416dfa8 --- /dev/null +++ b/rust/src/parser/src/reasoning/minimax_m3.rs @@ -0,0 +1,98 @@ +use vllm_tokenizer::DynTokenizer; + +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result}; + +const M3_THINK_START: &str = ""; +const M3_THINK_END: &str = ""; + +/// Reasoning parser for MiniMax M3 style outputs. +/// +/// MiniMax M3 uses `...` delimiters. Its chat template may +/// prefill either delimiter depending on the requested thinking mode, so the +/// shared delimited parser derives the starting state from the rendered prompt. +pub struct MiniMaxM3ReasoningParser { + inner: DelimitedReasoningParser, + /// True until the first response text is classified. Only this position may + /// drop a stray `` emitted at the start of a response. + at_response_start: bool, + /// Holds an initial suffix like ` Result { + Ok(Self { + inner: DelimitedReasoningParser::new(tokenizer, M3_THINK_START, M3_THINK_END, false)?, + at_response_start: true, + leading_end_buffer: String::new(), + }) + } + + /// Drop a response-leading `` while preserving later unmatched + /// closers as ordinary content. + fn push_inner(&mut self, delta: &str) -> ReasoningDelta { + if self.at_response_start && !self.inner.in_reasoning() { + self.leading_end_buffer.push_str(delta); + let buffered = std::mem::take(&mut self.leading_end_buffer); + + if buffered.is_empty() { + return ReasoningDelta::default(); + } + if let Some(rest) = buffered.strip_prefix(M3_THINK_END) { + self.at_response_start = false; + return self.inner.push(rest); + } + if M3_THINK_END.starts_with(buffered.as_str()) { + self.leading_end_buffer = buffered; + return ReasoningDelta::default(); + } + + self.at_response_start = false; + return self.inner.push(&buffered); + } + + self.inner.push(delta) + } +} + +fn append_delta(target: &mut ReasoningDelta, delta: ReasoningDelta) { + if let Some(reasoning) = delta.reasoning { + target.push_reasoning(&reasoning); + } + if let Some(content) = delta.content { + target.push_content(&content); + } +} + +impl ReasoningParser for MiniMaxM3ReasoningParser { + fn create(tokenizer: DynTokenizer) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tokenizer)?)) + } + + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { + self.inner.initialize(prompt_token_ids); + self.at_response_start = true; + self.leading_end_buffer.clear(); + Ok(()) + } + + fn push(&mut self, delta: &str) -> Result { + Ok(self.push_inner(delta)) + } + + fn finish(&mut self) -> Result { + let mut delta = ReasoningDelta::default(); + if !self.leading_end_buffer.is_empty() { + let pending = std::mem::take(&mut self.leading_end_buffer); + self.at_response_start = false; + append_delta(&mut delta, self.inner.push(&pending)); + } + append_delta(&mut delta, self.inner.finish()); + Ok(delta) + } +} diff --git a/rust/src/reasoning-parser/src/lib.rs b/rust/src/parser/src/reasoning/mod.rs similarity index 91% rename from rust/src/reasoning-parser/src/lib.rs rename to rust/src/parser/src/reasoning/mod.rs index 084168ab2f14..fcb0f96792a8 100644 --- a/rust/src/reasoning-parser/src/lib.rs +++ b/rust/src/parser/src/reasoning/mod.rs @@ -17,19 +17,23 @@ mod cohere_cmd; mod deepseek_r1; mod delimited; -mod gemma4; mod kimi; +mod minimax_m3; mod qwen3; +mod seed_oss; +mod step3p5; use thiserror::Error; use vllm_tokenizer::DynTokenizer; pub use self::cohere_cmd::CohereCmdReasoningParser; pub use self::deepseek_r1::DeepSeekR1ReasoningParser; -pub(crate) use self::delimited::DelimitedReasoningParser; -pub use self::gemma4::Gemma4ReasoningParser; +pub(crate) use self::delimited::{DelimitedReasoningParser, last_reasoning_boundary}; pub use self::kimi::KimiReasoningParser; +pub use self::minimax_m3::MiniMaxM3ReasoningParser; pub use self::qwen3::Qwen3ReasoningParser; +pub use self::seed_oss::SeedOssReasoningParser; +pub use self::step3p5::Step3p5ReasoningParser; /// DeepSeek V3 currently shares the standard `...` parser. pub type DeepSeekV3ReasoningParser = Qwen3ReasoningParser; @@ -123,6 +127,10 @@ pub trait ReasoningParser: Send { pub enum ReasoningError { #[error("tokenizer is missing reasoning delimiter token `{token}`")] MissingToken { token: String }, + #[error( + "`{name}` only provides a unified parser; the same reasoning parser and tool parser should be specified together" + )] + DummyUnifiedParser { name: String }, } #[cfg(test)] diff --git a/rust/src/reasoning-parser/src/qwen3.rs b/rust/src/parser/src/reasoning/qwen3.rs similarity index 100% rename from rust/src/reasoning-parser/src/qwen3.rs rename to rust/src/parser/src/reasoning/qwen3.rs diff --git a/rust/src/parser/src/reasoning/seed_oss.rs b/rust/src/parser/src/reasoning/seed_oss.rs new file mode 100644 index 000000000000..61dde6537792 --- /dev/null +++ b/rust/src/parser/src/reasoning/seed_oss.rs @@ -0,0 +1,148 @@ +use vllm_tokenizer::DynTokenizer; + +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result}; + +/// Reasoning parser for SeedOSS models using ``/`` +/// delimiters. +pub struct SeedOssReasoningParser { + inner: DelimitedReasoningParser, +} + +impl SeedOssReasoningParser { + /// Create a SeedOSS parser backed by the shared delimited state machine. + pub fn new(tokenizer: DynTokenizer) -> Result { + Ok(Self { + inner: DelimitedReasoningParser::new( + tokenizer, + "", + "", + false, + )?, + }) + } +} + +impl ReasoningParser for SeedOssReasoningParser { + fn create(tokenizer: DynTokenizer) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tokenizer)?)) + } + + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { + self.inner.initialize(prompt_token_ids); + Ok(()) + } + + fn push(&mut self, delta: &str) -> Result { + Ok(self.inner.push(delta)) + } + + fn finish(&mut self) -> Result { + Ok(self.inner.finish()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::SeedOssReasoningParser; + use crate::reasoning::ReasoningParser; + use crate::reasoning::tests::{SEED_THINK_END_ID, SEED_THINK_START_ID, fake_tokenizer}; + + #[test] + fn without_prompt_markers_expects_start_token() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("implicit reasoninganswer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!( + delta.content.as_deref(), + Some("implicit reasoninganswer") + ); + } + + #[test] + fn picks_up_prompt_start_boundary() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + // Prompt prefills ``, opening reasoning before the stream. + parser.initialize(&[SEED_THINK_START_ID]).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn respects_prompt_end_boundary() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + // Prompt already closed reasoning with ``. + parser.initialize(&[SEED_THINK_END_ID]).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn handles_explicit_start_token() { + // An explicit start delimiter must not leak into reasoning text. + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn streams_explicit_start_token_across_pushes() { + // Start token, reasoning body, end token, and content arrive in separate + // streaming deltas. + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + + let mut reasoning = String::new(); + let mut content = String::new(); + for delta_str in [ + "", + "Some ", + "reasoning ", + "content", + "", + "Final ", + "answer", + ] { + let delta = parser.push(delta_str).unwrap(); + if let Some(r) = delta.reasoning { + reasoning.push_str(&r); + } + if let Some(c) = delta.content { + content.push_str(&c); + } + } + assert_eq!(reasoning, "Some reasoning content"); + assert_eq!(content, "Final answer"); + } + + #[test] + fn handles_partial_delimiters_across_pushes() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = SeedOssReasoningParser::new(tokenizer).unwrap(); + parser.initialize(&[SEED_THINK_START_ID]).unwrap(); + + // Closing delimiter `` arrives in two halves. + let first = parser.push("reasonanswer").unwrap(); + assert_eq!(second.reasoning, None); + assert_eq!(second.content.as_deref(), Some("answer")); + } +} diff --git a/rust/src/parser/src/reasoning/step3p5.rs b/rust/src/parser/src/reasoning/step3p5.rs new file mode 100644 index 000000000000..79677e682f10 --- /dev/null +++ b/rust/src/parser/src/reasoning/step3p5.rs @@ -0,0 +1,309 @@ +use vllm_tokenizer::DynTokenizer; + +use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result}; + +/// Reasoning parser for Step3p5 outputs. +/// +/// Step3p5 uses standard ``/`` delimiters but emits a `\n` +/// immediately before and/or after ``. The parser drops these framing +/// newlines on both sides of the boundary, holding a trailing `\n` from +/// reasoning across pushes until either more reasoning text or `` +/// arrives, and dropping a leading `\n` from the first content delta after +/// the boundary. +pub struct Step3p5ReasoningParser { + inner: DelimitedReasoningParser, + /// `\n` at end of last reasoning delta, held in case `` follows. + pending_reasoning_newline: bool, + /// Last push ended on `` without emitting content; the next + /// content delta's leading `\n` should be dropped. + just_ended_reasoning: bool, +} + +impl Step3p5ReasoningParser { + /// Create a Step3p5 parser backed by the shared delimited state machine. + pub fn new(tokenizer: DynTokenizer) -> Result { + Ok(Self { + inner: DelimitedReasoningParser::new(tokenizer, "", "", false)?, + pending_reasoning_newline: false, + just_ended_reasoning: false, + }) + } + + /// Drop framing newlines around `` and track held-newline state. + fn process( + &mut self, + mut inner_delta: ReasoningDelta, + was_in_reasoning: bool, + now_in_reasoning: bool, + ) -> ReasoningDelta { + // A `...` round-trip in one push still counts as a + // transition: the inner emits reasoning while ending in content mode. + let transitioned = + !now_in_reasoning && (was_in_reasoning || inner_delta.reasoning.is_some()); + + // Replay or drop a previously-held trailing reasoning newline. + if self.pending_reasoning_newline { + if let Some(reasoning) = inner_delta.reasoning.as_mut() { + reasoning.insert(0, '\n'); + self.pending_reasoning_newline = false; + } else if transitioned { + // The held `\n` was the one right before ``: drop it. + self.pending_reasoning_newline = false; + } + } + + // Hold back a trailing reasoning `\n` until we know if `` follows. + if let Some(reasoning) = inner_delta.reasoning.as_mut() + && reasoning.ends_with('\n') + { + reasoning.pop(); + if !transitioned { + self.pending_reasoning_newline = true; + } + } + + // Drop a leading `\n` of content emitted right after ``. + if let Some(content) = inner_delta.content.as_mut() + && (transitioned || self.just_ended_reasoning) + && content.starts_with('\n') + { + content.remove(0); + } + + self.just_ended_reasoning = transitioned && inner_delta.content.is_none(); + + if inner_delta.reasoning.as_deref() == Some("") { + inner_delta.reasoning = None; + } + if inner_delta.content.as_deref() == Some("") { + inner_delta.content = None; + } + + inner_delta + } +} + +impl ReasoningParser for Step3p5ReasoningParser { + fn create(tokenizer: DynTokenizer) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tokenizer)?)) + } + + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { + self.inner.initialize(prompt_token_ids); + Ok(()) + } + + fn push(&mut self, delta: &str) -> Result { + let was = self.inner.in_reasoning(); + let inner_delta = self.inner.push(delta); + let now = self.inner.in_reasoning(); + Ok(self.process(inner_delta, was, now)) + } + + fn finish(&mut self) -> Result { + let was = self.inner.in_reasoning(); + let inner_delta = self.inner.finish(); + let now = self.inner.in_reasoning(); + let mut delta = self.process(inner_delta, was, now); + + // Emit a still-held newline rather than silently dropping it. + if self.pending_reasoning_newline { + match delta.reasoning.as_mut() { + Some(existing) => existing.push('\n'), + None => delta.reasoning = Some("\n".to_string()), + } + self.pending_reasoning_newline = false; + } + + Ok(delta) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::Step3p5ReasoningParser; + use crate::reasoning::ReasoningParser; + use crate::reasoning::tests::{THINK_START_ID, fake_tokenizer}; + + #[test] + fn picks_up_prompt_start_boundary() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + // Prompt prefills ``, opening reasoning before the stream. + parser.initialize(&[THINK_START_ID]).unwrap(); + + let delta = parser.push("This is a reasoning sectionThis is the rest").unwrap(); + assert_eq!( + delta.reasoning.as_deref(), + Some("This is a reasoning section") + ); + assert_eq!(delta.content.as_deref(), Some("This is the rest")); + } + + #[test] + fn handles_unterminated_reasoning() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let pushed = parser.push("reason without end").unwrap(); + assert_eq!(pushed.reasoning.as_deref(), Some("reason without end")); + assert_eq!(pushed.content, None); + + let flushed = parser.finish().unwrap(); + assert!(flushed.is_empty()); + } + + #[test] + fn handles_empty_input() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let pushed = parser.push("").unwrap(); + assert!(pushed.is_empty()); + let flushed = parser.finish().unwrap(); + assert!(flushed.is_empty()); + } + + #[test] + fn complex_newline_pattern_trims_only_single_framing_newline_each_side() { + // Only the immediately-adjacent framing `\n` is dropped on each side of + // ``; surrounding newlines remain part of reasoning/content. + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + parser.initialize(&[THINK_START_ID]).unwrap(); + + let delta = parser + .push("\n This is a \n reasoning section\n\n\n\n\nThis is the rest") + .unwrap(); + assert_eq!( + delta.reasoning.as_deref(), + Some("\n This is a \n reasoning section\n\n") + ); + assert_eq!(delta.content.as_deref(), Some("\nThis is the rest")); + } + + #[test] + fn drops_framing_newlines_in_single_push() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reason\n\nanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn drops_framing_newlines_across_pushes() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + // The trailing `\n` from the first push is held until we know whether + // `` follows. + let first = parser.push("reason\n").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + assert_eq!(first.content, None); + + // `` arrives standalone; the held newline should be dropped. + let second = parser.push("").unwrap(); + assert!(second.is_empty()); + + // The leading newline of the first content delta is dropped. + let third = parser.push("\nanswer").unwrap(); + assert_eq!(third.reasoning, None); + assert_eq!(third.content.as_deref(), Some("answer")); + } + + #[test] + fn replays_held_newline_when_more_reasoning_follows() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let first = parser.push("reason\n").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + + let second = parser.push("more reason").unwrap(); + assert_eq!(second.reasoning.as_deref(), Some("\nmore reason")); + assert_eq!(second.content, None); + } + + #[test] + fn finish_flushes_held_newline_in_unterminated_stream() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let first = parser.push("reason\n").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + + let flushed = parser.finish().unwrap(); + assert_eq!(flushed.reasoning.as_deref(), Some("\n")); + assert_eq!(flushed.content, None); + } + + #[test] + fn preserves_inner_newlines_in_reasoning() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("line1\nline2tail").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("line1\nline2")); + assert_eq!(delta.content.as_deref(), Some("tail")); + } + + #[test] + fn trims_only_one_trailing_reasoning_newline() { + // Only the single framing newline immediately before `` is + // dropped; earlier newlines in the reasoning body are preserved. + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reason\n\nanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason\n")); + assert_eq!(delta.content.as_deref(), Some("answer")); + } + + #[test] + fn drops_only_first_content_newline_after_transition() { + // The leading-`\n` drop applies only to the first content delta after + // ``; later deltas pass through untouched. + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let first = parser.push("reason").unwrap(); + assert_eq!(first.reasoning.as_deref(), Some("reason")); + assert_eq!(first.content, None); + + let second = parser.push("\nfirst").unwrap(); + assert_eq!(second.reasoning, None); + assert_eq!(second.content.as_deref(), Some("first")); + + // A `\n` arriving in a later content delta must NOT be dropped. + let third = parser.push("\nsecond").unwrap(); + assert_eq!(third.reasoning, None); + assert_eq!(third.content.as_deref(), Some("\nsecond")); + } + + #[test] + fn passes_through_clean_boundary_without_framing_newlines() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reasontail").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("tail")); + } + + #[test] + fn handles_empty_reasoning_section() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Step3p5ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); + } +} diff --git a/rust/src/parser/src/reasoning/tests.rs b/rust/src/parser/src/reasoning/tests.rs new file mode 100644 index 000000000000..5c148079e3ab --- /dev/null +++ b/rust/src/parser/src/reasoning/tests.rs @@ -0,0 +1,217 @@ +use std::sync::Arc; + +use vllm_tokenizer::test_utils::TestTokenizer; + +use super::{ + DeepSeekR1ReasoningParser, DelimitedReasoningParser, MiniMaxM3ReasoningParser, + Qwen3ReasoningParser, ReasoningParser, +}; + +pub(crate) const THINK_START_ID: u32 = 256; +pub(crate) const THINK_END_ID: u32 = 257; +pub(crate) const START_THINKING_ID: u32 = 258; +pub(crate) const END_THINKING_ID: u32 = 259; +pub(crate) const MINIMAX_THINK_START_ID: u32 = 260; +pub(crate) const MINIMAX_THINK_END_ID: u32 = 261; +pub(crate) const SPECIAL_BOUNDARY_ID: u32 = 262; +pub(crate) const MM_THINK_START_ID: u32 = 263; +pub(crate) const MM_THINK_END_ID: u32 = 264; +pub(crate) const SEED_THINK_START_ID: u32 = 265; +pub(crate) const SEED_THINK_END_ID: u32 = 266; + +pub(crate) fn fake_tokenizer() -> TestTokenizer { + TestTokenizer::new() + .with_regular_token("", THINK_START_ID) + .with_regular_token("", THINK_END_ID) + .with_regular_token("<|START_THINKING|>", START_THINKING_ID) + .with_regular_token("<|END_THINKING|>", END_THINKING_ID) + .with_regular_token("◁think▷", MINIMAX_THINK_START_ID) + .with_regular_token("◁/think▷", MINIMAX_THINK_END_ID) + .with_special_token("", SPECIAL_BOUNDARY_ID) + .with_regular_token("", MM_THINK_START_ID) + .with_regular_token("", MM_THINK_END_ID) + .with_regular_token("", SEED_THINK_START_ID) + .with_regular_token("", SEED_THINK_END_ID) +} + +#[test] +fn delimited_content_only_stream() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = + DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); + + assert_eq!( + parser.push("plain content").content.as_deref(), + Some("plain content") + ); +} + +#[test] +fn delimited_single_chunk_with_reasoning_and_content() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = + DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); + + let delta = parser.push("reasonanswer"); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn delimited_partial_tokens_across_chunks() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = + DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); + + assert!(parser.push("reasonanswer"); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn delimited_finish_flushes_buffer() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = + DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); + parser.initialize(&[THINK_START_ID]); + + let delta = parser.push("unfinishedanswer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("reasonanswer")); +} + +#[test] +fn qwen3_prompt_end_marker_starts_in_content() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap(); + parser.initialize(&[THINK_END_ID]).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn qwen3_tolerates_old_and_new_formats() { + let tokenizer = Arc::new(fake_tokenizer()); + + let mut old_parser = Qwen3ReasoningParser::new(tokenizer.clone()).unwrap(); + let old = old_parser.push("reasonanswer").unwrap(); + assert_eq!(old.reasoning.as_deref(), Some("reason")); + assert_eq!(old.content.as_deref(), Some("answer")); + + let mut new_parser = Qwen3ReasoningParser::new(tokenizer).unwrap(); + new_parser.initialize(&[THINK_START_ID]).unwrap(); + let new = new_parser.push("reasonanswer").unwrap(); + assert_eq!(new.reasoning.as_deref(), Some("reason")); + assert_eq!(new.content.as_deref(), Some("answer")); +} + +#[test] +fn qwen3_stops_scanning_at_last_special_token() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap(); + + parser.initialize(&[THINK_START_ID, SPECIAL_BOUNDARY_ID]).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn deepseek_r1_defaults_to_reasoning_without_prompt_boundary() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = DeepSeekR1ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn deepseek_r1_stops_scanning_at_last_special_token() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = DeepSeekR1ReasoningParser::new(tokenizer).unwrap(); + + parser.initialize(&[THINK_END_ID, SPECIAL_BOUNDARY_ID]).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn minimax_m3_handles_explicit_think_delimiters() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn minimax_m3_drops_leading_end_marker() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn minimax_m3_preserves_non_leading_end_marker() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap(); + + let delta = parser.push("XXXYYY").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("XXXYYY")); +} + +#[test] +fn minimax_m3_drops_split_leading_end_marker() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap(); + + assert!(parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn minimax_m3_uses_prompt_prefilled_start_marker() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap(); + parser.initialize(&[MM_THINK_START_ID]).unwrap(); + + let delta = parser.push("reasonanswer").unwrap(); + assert_eq!(delta.reasoning.as_deref(), Some("reason")); + assert_eq!(delta.content.as_deref(), Some("answer")); +} + +#[test] +fn minimax_m3_uses_prompt_prefilled_end_marker() { + let tokenizer = Arc::new(fake_tokenizer()); + let mut parser = MiniMaxM3ReasoningParser::new(tokenizer).unwrap(); + parser.initialize(&[MM_THINK_END_ID]).unwrap(); + + let delta = parser.push("answer").unwrap(); + assert_eq!(delta.reasoning, None); + assert_eq!(delta.content.as_deref(), Some("answer")); +} diff --git a/rust/src/tool-parser/src/deepseek_dsml/deepseek_v32.rs b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs similarity index 80% rename from rust/src/tool-parser/src/deepseek_dsml/deepseek_v32.rs rename to rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs index 1bc487826e7d..e4f5c58ee0e6 100644 --- a/rust/src/tool-parser/src/deepseek_dsml/deepseek_v32.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v32.rs @@ -1,5 +1,5 @@ use super::{DeepSeekDsmlToolParser, DsmlTokens}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput}; /// Tool parser for DeepSeek V3.2 models. /// @@ -44,6 +44,10 @@ impl ToolParser for DeepSeekV32ToolParser { true } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::DeepSeekV32) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.0.parse_into(chunk, output) } @@ -63,8 +67,8 @@ mod tests { use thiserror_ext::AsReport; use super::DeepSeekV32ToolParser; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String { let params = params @@ -86,8 +90,8 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -100,11 +104,11 @@ mod tests { )) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF", "date": "2024-01-16" @@ -121,8 +125,8 @@ mod tests { ); let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -142,15 +146,15 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 5.0, "flag": true, "payload": { "nested": true }, "items": [1, 2], - "empty": null, + "empty": "null", }) ); } @@ -172,9 +176,9 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": "5.0", "flag": "true", @@ -186,7 +190,7 @@ mod tests { } #[test] - fn deepseek_v32_parse_complete_unescapes_literal_closing_tags_in_parameter_value() { + fn deepseek_v32_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let output = parser .parse_complete(&build_tool_call( @@ -202,9 +206,9 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ - "location": "Hangzhou ", + "location": "Hangzhou </|DSML|parameter></|DSML|invoke></|DSML|function_calls>", "date": "2026-05-08", }) ); @@ -224,11 +228,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -248,8 +252,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -257,8 +261,8 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &["Hello, ", "world!"]); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -274,17 +278,17 @@ mod tests { )], ); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "location": "NYC" }) ); } @@ -296,9 +300,9 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -333,11 +337,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "Beijing" }) ); } @@ -369,9 +373,9 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -389,8 +393,8 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); } #[test] @@ -417,10 +421,10 @@ mod tests { .parse_complete(&build_tool_call("get_weather", &[("location", "NYC")])) .unwrap(); - assert_eq!(first.calls.len(), 1); - assert_eq!(second.calls.len(), 1); + assert_eq!(first.calls().len(), 1); + assert_eq!(second.calls().len(), 1); assert_eq!( - serde_json::from_str::(&second.calls[0].arguments).unwrap(), + serde_json::from_str::(&second.calls()[0].arguments).unwrap(), json!({ "location": "NYC" }) ); } @@ -435,7 +439,7 @@ mod tests { let mut parser = DeepSeekV32ToolParser::new(&test_tools()); let complete = parser.parse_complete(&full_text).unwrap(); - assert_eq!(streamed.normal_text, complete.normal_text); - assert_eq!(streamed.calls, complete.calls); + assert_eq!(streamed.normal_text(), complete.normal_text()); + assert_eq!(streamed.calls(), complete.calls()); } } diff --git a/rust/src/tool-parser/src/deepseek_dsml/deepseek_v4.rs b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs similarity index 77% rename from rust/src/tool-parser/src/deepseek_dsml/deepseek_v4.rs rename to rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs index ba01b1b586a3..a04939327660 100644 --- a/rust/src/tool-parser/src/deepseek_dsml/deepseek_v4.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/deepseek_v4.rs @@ -1,5 +1,5 @@ use super::{DeepSeekDsmlToolParser, DsmlTokens}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput}; /// Tool parser for DeepSeek V4 models. /// @@ -47,6 +47,10 @@ impl ToolParser for DeepSeekV4ToolParser { true } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::DeepSeekV4) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.0.parse_into(chunk, output) } @@ -65,8 +69,8 @@ mod tests { use serde_json::{Value, json}; use super::DeepSeekV4ToolParser; - use crate::ToolParserTestExt as _; - use crate::test_utils::{collect_stream, test_tools}; + use crate::tool::test_utils::{collect_stream, test_tools}; + use crate::tool::{StructuralTagModel, ToolParser, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String { let params = params @@ -83,6 +87,16 @@ mod tests { ) } + #[test] + fn deepseek_v4_exposes_structural_tag_model() { + let parser = DeepSeekV4ToolParser::new(&test_tools()); + + assert_eq!( + parser.structural_tag_model(), + Some(StructuralTagModel::DeepSeekV4) + ); + } + #[test] fn deepseek_v4_parse_complete_reuses_dsml_parser_with_tool_calls_token() { let mut parser = DeepSeekV4ToolParser::new(&test_tools()); @@ -93,11 +107,11 @@ mod tests { )) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF", "date": "2024-01-16" @@ -123,11 +137,11 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "Beijing" }) ); } diff --git a/rust/src/tool-parser/src/deepseek_dsml/mod.rs b/rust/src/parser/src/tool/deepseek_dsml/mod.rs similarity index 84% rename from rust/src/tool-parser/src/deepseek_dsml/mod.rs rename to rust/src/parser/src/tool/deepseek_dsml/mod.rs index c332037f4519..b49fb1de8b5e 100644 --- a/rust/src/tool-parser/src/deepseek_dsml/mod.rs +++ b/rust/src/parser/src/tool/deepseek_dsml/mod.rs @@ -5,9 +5,9 @@ use winnow::stream::Partial; use winnow::token::{literal, rest, take_until}; use super::parameters::ToolSchemas; -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape}; +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker}; use super::{Result, ToolCallDelta, ToolParserOutput}; -use crate::Tool; +use crate::tool::Tool; mod deepseek_v32; mod deepseek_v4; @@ -39,10 +39,10 @@ impl DsmlTokens { }; } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum DsmlMode { Text, - ToolBlock, + ToolBlock { invoke_end_scan: MarkerScanState }, Done, } @@ -92,9 +92,13 @@ impl DeepSeekDsmlToolParser { fn apply_event(&mut self, event: DsmlEvent, output: &mut ToolParserOutput) -> Result<()> { match event { DsmlEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); + } + DsmlEvent::ToolCallsStart => { + self.mode = DsmlMode::ToolBlock { + invoke_end_scan: MarkerScanState::default(), + }; } - DsmlEvent::ToolCallsStart => self.mode = DsmlMode::ToolBlock, DsmlEvent::Invoke { name, raw_params } => { let mut arguments = serde_json::Map::with_capacity(raw_params.len()); for param in raw_params { @@ -112,7 +116,7 @@ impl DeepSeekDsmlToolParser { let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_invoke_count, name: Some(name), arguments, @@ -140,7 +144,7 @@ impl DeepSeekDsmlToolParser { self.buffer.push_str(chunk); while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { - parse_next_dsml_event(input, self.mode, self.tokens) + parse_next_dsml_event(input, &mut self.mode, self.tokens) })? { self.apply_event(event, output)?; self.buffer.drain(..consumed_len); @@ -152,9 +156,9 @@ impl DeepSeekDsmlToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match self.mode { - DsmlMode::Text => output.normal_text.push_str(&self.buffer), + DsmlMode::Text => output.push_text(&self.buffer), DsmlMode::Done => {} - DsmlMode::ToolBlock => { + DsmlMode::ToolBlock { .. } => { return Err(parsing_failed!("incomplete DeepSeek DSML tool call")); } } @@ -166,12 +170,14 @@ impl DeepSeekDsmlToolParser { /// Parse a DSML event for the current parser mode. fn parse_next_dsml_event( input: &mut DsmlInput<'_>, - mode: DsmlMode, + mode: &mut DsmlMode, tokens: DsmlTokens, ) -> ModalResult { match mode { DsmlMode::Text => parse_text_event(input, tokens), - DsmlMode::ToolBlock => parse_tool_block_event(input, tokens), + DsmlMode::ToolBlock { invoke_end_scan } => { + parse_tool_block_event(input, tokens, invoke_end_scan) + } DsmlMode::Done => ignored_rest_event(input), } } @@ -186,11 +192,16 @@ fn parse_text_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResul } /// Parse a tool-block DSML event. -fn parse_tool_block_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult { +fn parse_tool_block_event( + input: &mut DsmlInput<'_>, + tokens: DsmlTokens, + invoke_end_scan: &mut MarkerScanState, +) -> ModalResult { ws0.void().parse_next(input)?; - alt((invoke_event, |input: &mut DsmlInput<'_>| { - tool_calls_end_event(input, tokens) - })) + alt(( + |input: &mut DsmlInput<'_>| invoke_event(input, invoke_end_scan), + |input: &mut DsmlInput<'_>| tool_calls_end_event(input, tokens), + )) .parse_next(input) } @@ -217,14 +228,17 @@ fn safe_text_event(input: &mut DsmlInput<'_>, tokens: DsmlTokens) -> ModalResult } /// Parse a DSML invoke block. -fn invoke_event(input: &mut DsmlInput<'_>) -> ModalResult { +fn invoke_event( + input: &mut DsmlInput<'_>, + invoke_end_scan: &mut MarkerScanState, +) -> ModalResult { let (name, body) = seq!( _: literal(INVOKE_START), _: ws1, dsml_name_attr, _: ws0, _: ">", - take_until(0.., INVOKE_END), + take_until_marker(INVOKE_END, invoke_end_scan), _: literal(INVOKE_END), ) .parse_next(input)?; @@ -251,7 +265,7 @@ fn parse_parameter(input: &mut &str) -> ModalResult { is_string: string_attr.map(|value| value == "true"), _: ws0, _: ">", - value: take_until(0.., PARAMETER_END).map(xml_unescape).map(|value| value.into_owned()), + value: take_until(0.., PARAMETER_END).map(str::to_string), _: literal(PARAMETER_END), }} .parse_next(input) diff --git a/rust/src/tool-parser/src/deepseek_json/deepseek_v3.rs b/rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs similarity index 74% rename from rust/src/tool-parser/src/deepseek_json/deepseek_v3.rs rename to rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs index 9c8a2a5c5850..dc9859d001ca 100644 --- a/rust/src/tool-parser/src/deepseek_json/deepseek_v3.rs +++ b/rust/src/parser/src/tool/deepseek_json/deepseek_v3.rs @@ -1,5 +1,5 @@ use super::{DeepSeekJsonFormat, DeepSeekJsonToolParser}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput}; /// Tool parser for DeepSeek V3 JSON-fenced tool calls. /// @@ -32,6 +32,10 @@ impl ToolParser for DeepSeekV3ToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::DeepSeekR1) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.0.parse_into(chunk, output) } @@ -51,12 +55,12 @@ mod tests { use thiserror_ext::AsReport; use super::DeepSeekV3ToolParser; - use crate::deepseek_json::{ + use crate::tool::deepseek_json::{ TOOL_CALL_SEPARATOR, TOOL_CALL_START, TOOL_CALLS_END, TOOL_CALLS_START, V3_ARGUMENT_END, V3_JSON_START, }; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; fn v3_tool_call(function_name: &str, arguments: &str) -> String { format!( @@ -73,8 +77,8 @@ mod tests { let mut parser = DeepSeekV3ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -88,11 +92,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -103,7 +107,7 @@ mod tests { .parse_complete(&tool_section(&[v3_tool_call("get_weather", arguments)])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -128,7 +132,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -139,7 +143,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -155,9 +159,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -168,8 +172,8 @@ mod tests { let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -185,22 +189,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -231,6 +238,9 @@ mod tests { let error = parser.parse_chunk(&input).unwrap_err(); - expect!["tool parser parsing failed: "].assert_eq(&error.to_report_string()); + expect![[ + r#"tool parser parsing failed: near "tool<|tool▁sep|>get_weather\n```json\n{}": "# + ]] + .assert_eq(&error.to_report_string()); } } diff --git a/rust/src/tool-parser/src/deepseek_json/deepseek_v31.rs b/rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs similarity index 74% rename from rust/src/tool-parser/src/deepseek_json/deepseek_v31.rs rename to rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs index d16ffd7de6e7..088e6d53db9f 100644 --- a/rust/src/tool-parser/src/deepseek_json/deepseek_v31.rs +++ b/rust/src/parser/src/tool/deepseek_json/deepseek_v31.rs @@ -1,5 +1,5 @@ use super::{DeepSeekJsonFormat, DeepSeekJsonToolParser}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput}; /// Tool parser for DeepSeek V3.1 raw JSON tool calls. /// @@ -28,6 +28,10 @@ impl ToolParser for DeepSeekV31ToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::DeepSeekV31) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.0.parse_into(chunk, output) } @@ -47,11 +51,11 @@ mod tests { use thiserror_ext::AsReport; use super::DeepSeekV31ToolParser; - use crate::deepseek_json::{ + use crate::tool::deepseek_json::{ TOOL_CALL_END, TOOL_CALL_SEPARATOR, TOOL_CALL_START, TOOL_CALLS_END, TOOL_CALLS_START, }; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; fn v31_tool_call(function_name: &str, arguments: &str) -> String { format!("{TOOL_CALL_START}{function_name}{TOOL_CALL_SEPARATOR}{arguments}{TOOL_CALL_END}") @@ -66,8 +70,8 @@ mod tests { let mut parser = DeepSeekV31ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -81,11 +85,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check."); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check."); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -96,7 +100,7 @@ mod tests { .parse_complete(&tool_section(&[v31_tool_call("get_weather", arguments)])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -119,7 +123,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -130,7 +134,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -146,9 +150,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -159,8 +163,8 @@ mod tests { let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -176,22 +180,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -208,9 +215,9 @@ mod tests { let output = collect_stream(&mut parser, &[&input]); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -235,6 +242,7 @@ mod tests { let error = parser.parse_chunk(&input).unwrap_err(); - expect!["tool parser parsing failed: "].assert_eq(&error.to_report_string()); + expect![[r#"tool parser parsing failed: near "<|tool▁sep|>{}": "#]] + .assert_eq(&error.to_report_string()); } } diff --git a/rust/src/tool-parser/src/deepseek_json/mod.rs b/rust/src/parser/src/tool/deepseek_json/mod.rs similarity index 97% rename from rust/src/tool-parser/src/deepseek_json/mod.rs rename to rust/src/parser/src/tool/deepseek_json/mod.rs index 0f0d04f04288..c6fce9fec67b 100644 --- a/rust/src/tool-parser/src/deepseek_json/mod.rs +++ b/rust/src/parser/src/tool/deepseek_json/mod.rs @@ -96,7 +96,7 @@ impl DeepSeekJsonToolParser { ) -> Result<()> { match event { DeepSeekJsonEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } DeepSeekJsonEvent::ToolCallsStart => self.mode = DeepSeekJsonMode::ToolBlock, DeepSeekJsonEvent::ToolCallStart => self.mode = DeepSeekJsonMode::Header, @@ -107,7 +107,7 @@ impl DeepSeekJsonToolParser { self.mode = DeepSeekJsonMode::Arguments { json_scan: JsonObjectScanState::default(), }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -120,7 +120,7 @@ impl DeepSeekJsonToolParser { self.format.parser_name() )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -155,7 +155,7 @@ impl DeepSeekJsonToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match &self.mode { - DeepSeekJsonMode::Text => output.normal_text.push_str(&self.buffer), + DeepSeekJsonMode::Text => output.push_text(&self.buffer), DeepSeekJsonMode::ToolBlock | DeepSeekJsonMode::Done => {} DeepSeekJsonMode::Header | DeepSeekJsonMode::Arguments { .. } => { return Err(parsing_failed!( diff --git a/rust/src/tool-parser/src/error.rs b/rust/src/parser/src/tool/error.rs similarity index 61% rename from rust/src/tool-parser/src/error.rs rename to rust/src/parser/src/tool/error.rs index 0ac4a02c658c..4b2b4efcb45c 100644 --- a/rust/src/tool-parser/src/error.rs +++ b/rust/src/parser/src/tool/error.rs @@ -6,8 +6,12 @@ pub type Result = std::result::Result; /// Errors produced while creating or running tool parsers. #[derive(Debug, Error, Macro)] -#[thiserror_ext(macro(path = "crate::error"))] +#[thiserror_ext(macro(path = "crate::tool::error"))] pub enum ToolParserError { #[error("tool parser parsing failed: {message}")] ParsingFailed { message: String }, + #[error( + "`{name}` only provides a unified parser; the same reasoning parser and tool parser should be specified together" + )] + DummyUnifiedParser { name: String }, } diff --git a/rust/src/tool-parser/src/glm_xml/glm45_moe.rs b/rust/src/parser/src/tool/glm_xml/glm45_moe.rs similarity index 94% rename from rust/src/tool-parser/src/glm_xml/glm45_moe.rs rename to rust/src/parser/src/tool/glm_xml/glm45_moe.rs index 2a2d2e038133..a8d1ea0f19e4 100644 --- a/rust/src/tool-parser/src/glm_xml/glm45_moe.rs +++ b/rust/src/parser/src/tool/glm_xml/glm45_moe.rs @@ -1,5 +1,5 @@ use super::{GlmXmlToolParser, Separator}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, Tool, ToolParser, ToolParserOutput}; /// Tool parser for GLM-4.5/4.6 MoE XML-style tool calls. /// diff --git a/rust/src/tool-parser/src/glm_xml/glm47_moe.rs b/rust/src/parser/src/tool/glm_xml/glm47_moe.rs similarity index 74% rename from rust/src/tool-parser/src/glm_xml/glm47_moe.rs rename to rust/src/parser/src/tool/glm_xml/glm47_moe.rs index 3d1c38d55f7a..ac1a9d6ac6de 100644 --- a/rust/src/tool-parser/src/glm_xml/glm47_moe.rs +++ b/rust/src/parser/src/tool/glm_xml/glm47_moe.rs @@ -1,5 +1,5 @@ use super::{GlmXmlToolParser, Separator}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput}; /// Tool parser for GLM-4.7 MoE XML-style tool calls. /// @@ -22,6 +22,10 @@ impl ToolParser for Glm47MoeToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::Glm47) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.0.parse_into(chunk, output) } @@ -40,8 +44,8 @@ mod tests { use serde_json::{Value, json}; use super::Glm47MoeToolParser; - use crate::ToolParserTestExt as _; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::ToolParserTestExt as _; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; fn glm47_tool_call(function_name: &str, params: &[(&str, &str)]) -> String { let params = params @@ -65,11 +69,11 @@ mod tests { let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Let me search for that.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "Let me search for that.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({"city": "Beijing", "date": "2024-12-25"}) ); } @@ -86,12 +90,12 @@ mod tests { let chunks = split_by_chars(&output, 7); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("add")); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("add")); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({"x": 1, "y": 2}) ); } @@ -113,7 +117,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 42, "flag": true, @@ -130,10 +134,10 @@ mod tests { let output = parser.parse_complete("add").unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("add")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("add")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({}) ); } diff --git a/rust/src/tool-parser/src/glm_xml/mod.rs b/rust/src/parser/src/tool/glm_xml/mod.rs similarity index 79% rename from rust/src/tool-parser/src/glm_xml/mod.rs rename to rust/src/parser/src/tool/glm_xml/mod.rs index 6d657619ba54..cc175aeb641e 100644 --- a/rust/src/tool-parser/src/glm_xml/mod.rs +++ b/rust/src/parser/src/tool/glm_xml/mod.rs @@ -5,9 +5,9 @@ use winnow::stream::Partial; use winnow::token::{literal, rest, take_until, take_while}; use super::parameters::ToolSchemas; -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape}; +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker}; use super::{Result, ToolCallDelta, ToolParserOutput}; -use crate::Tool; +use crate::tool::Tool; mod glm45_moe; mod glm47_moe; @@ -24,10 +24,10 @@ const ARG_VALUE_END: &str = ""; type GlmInput<'i> = Partial<&'i str>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum GlmMode { Text, - ToolCall, + ToolCall { tool_call_end_scan: MarkerScanState }, AfterToolCall, } @@ -79,16 +79,20 @@ impl GlmXmlToolParser { fn apply_event(&mut self, event: GlmEvent, output: &mut ToolParserOutput) -> Result<()> { match event { GlmEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); + } + GlmEvent::ToolCallStart => { + self.mode = GlmMode::ToolCall { + tool_call_end_scan: MarkerScanState::default(), + }; } - GlmEvent::ToolCallStart => self.mode = GlmMode::ToolCall, GlmEvent::ToolCall { name, raw_params } => { self.mode = GlmMode::AfterToolCall; let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params); let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -110,7 +114,7 @@ impl GlmXmlToolParser { self.buffer.push_str(chunk); while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { - parse_next_glm_event(input, self.mode, self.separator) + parse_next_glm_event(input, &mut self.mode, self.separator) })? { self.apply_event(event, output)?; self.buffer.drain(..consumed_len); @@ -123,8 +127,10 @@ impl GlmXmlToolParser { let mut output = ToolParserOutput::default(); if !self.buffer.is_empty() { match self.mode { - GlmMode::Text => output.normal_text.push_str(&self.buffer), - GlmMode::ToolCall => return Err(parsing_failed!("incomplete GLM MoE tool call")), + GlmMode::Text => output.push_text(&self.buffer), + GlmMode::ToolCall { .. } => { + return Err(parsing_failed!("incomplete GLM MoE tool call")); + } GlmMode::AfterToolCall => {} } } @@ -136,12 +142,14 @@ impl GlmXmlToolParser { /// Parse a GLM event for the current parser mode. fn parse_next_glm_event( input: &mut GlmInput<'_>, - mode: GlmMode, + mode: &mut GlmMode, separator: Separator, ) -> ModalResult { match mode { GlmMode::Text => parse_text_event(input), - GlmMode::ToolCall => tool_call_event(input, separator), + GlmMode::ToolCall { tool_call_end_scan } => { + tool_call_event(input, separator, tool_call_end_scan) + } GlmMode::AfterToolCall => after_tool_call_event(input), } } @@ -173,9 +181,13 @@ fn ignored_rest_event(input: &mut GlmInput<'_>) -> ModalResult { } /// Parse a complete GLM tool call. -fn tool_call_event(input: &mut GlmInput<'_>, separator: Separator) -> ModalResult { +fn tool_call_event( + input: &mut GlmInput<'_>, + separator: Separator, + tool_call_end_scan: &mut MarkerScanState, +) -> ModalResult { let (body,) = seq!( - take_until(0.., TOOL_CALL_END), + take_until_marker(TOOL_CALL_END, tool_call_end_scan), _: literal(TOOL_CALL_END), ) .parse_next(input)?; @@ -238,12 +250,12 @@ fn parse_parameter(input: &mut &str) -> ModalResult<(String, String)> { _: literal(ARG_KEY_END), _: ws0, _: literal(ARG_VALUE_START), - take_until(0.., ARG_VALUE_END).map(str::trim).map(xml_unescape), + take_until(0.., ARG_VALUE_END).map(str::trim), _: literal(ARG_VALUE_END), ) .parse_next(input)?; - Ok((key.trim().to_string(), value.into_owned())) + Ok((key.trim().to_string(), value.to_string())) } #[cfg(test)] @@ -252,8 +264,8 @@ mod tests { use thiserror_ext::AsReport; use super::Glm45MoeToolParser; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserTestExt as _}; fn glm45_tool_call(function_name: &str, params: &[(&str, &str)]) -> String { let params = params @@ -271,8 +283,8 @@ mod tests { let mut parser = Glm45MoeToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -288,11 +300,11 @@ mod tests { let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Let me search for that.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "Let me search for that.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({"city": "Beijing", "date": "2024-12-25"}) ); } @@ -309,18 +321,18 @@ mod tests { let chunks = split_by_chars(&output, 11); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("add")); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("add")); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({"x": 1, "y": 2}) ); } #[test] - fn glm45_parse_complete_unescapes_literal_closing_tags_in_arg_value() { + fn glm45_parse_complete_preserves_raw_closing_tag_text_in_arg_value() { let mut parser = Glm45MoeToolParser::new(&test_tools()); let output = parser .parse_complete(&glm45_tool_call( @@ -333,9 +345,9 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ - "city": "Paris ", + "city": "Paris </arg_value></tool_call>", "date": "2026-05-08", }) ); @@ -347,8 +359,8 @@ mod tests { let output = collect_stream(&mut parser, &["hello ", "world"]); - assert_eq!(output.normal_text, "hello world"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "hello world"); + assert!(output.calls().is_empty()); } #[test] @@ -363,8 +375,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Prefix "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Prefix "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -379,9 +391,9 @@ mod tests { ], ); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -390,8 +402,8 @@ mod tests { let output = parser.parse_chunk("get_weather\ncity").unwrap(); - assert_eq!(output.normal_text, ""); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), ""); + assert!(output.calls().is_empty()); } #[test] @@ -425,7 +437,7 @@ mod tests { )], ); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); } } diff --git a/rust/src/tool-parser/src/hy_v3.rs b/rust/src/parser/src/tool/hy_v3.rs similarity index 80% rename from rust/src/tool-parser/src/hy_v3.rs rename to rust/src/parser/src/tool/hy_v3.rs index 32f0e4d9e75e..566df28d320f 100644 --- a/rust/src/tool-parser/src/hy_v3.rs +++ b/rust/src/parser/src/tool/hy_v3.rs @@ -5,9 +5,9 @@ use winnow::stream::Partial; use winnow::token::{literal, rest, take_until}; use super::parameters::ToolSchemas; -use super::utils::{parse_buffered_event, safe_text_len}; +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker}; use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput}; -use crate::Tool; +use crate::tool::{StructuralTagModel, Tool}; const TOOL_CALLS_START: &str = ""; const TOOL_CALLS_END: &str = ""; @@ -21,10 +21,10 @@ const ARG_VALUE_END: &str = ""; type HyV3Input<'i> = Partial<&'i str>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum HyV3Mode { Text, - ToolBlock, + ToolBlock { tool_call_end_scan: MarkerScanState }, Done, } @@ -79,15 +79,19 @@ impl HyV3ToolParser { fn apply_event(&mut self, event: HyV3Event, output: &mut ToolParserOutput) -> Result<()> { match event { HyV3Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); + } + HyV3Event::ToolBlockStart => { + self.mode = HyV3Mode::ToolBlock { + tool_call_end_scan: MarkerScanState::default(), + }; } - HyV3Event::ToolBlockStart => self.mode = HyV3Mode::ToolBlock, HyV3Event::ToolCall { name, raw_params } => { let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params); let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -109,11 +113,15 @@ impl ToolParser for HyV3ToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::HyV3) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.buffer.push_str(chunk); while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { - parse_next_hy_v3_event(input, self.mode) + parse_next_hy_v3_event(input, &mut self.mode) })? { self.apply_event(event, output)?; self.buffer.drain(..consumed_len); @@ -125,8 +133,8 @@ impl ToolParser for HyV3ToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match self.mode { - HyV3Mode::Text => output.normal_text.push_str(&self.buffer), - HyV3Mode::ToolBlock => return Err(parsing_failed!("incomplete HY3 tool call")), + HyV3Mode::Text => output.push_text(&self.buffer), + HyV3Mode::ToolBlock { .. } => return Err(parsing_failed!("incomplete HY3 tool call")), HyV3Mode::Done => {} } let _ = self.reset(); @@ -141,10 +149,15 @@ impl ToolParser for HyV3ToolParser { } /// Parse a HY3 event for the current parser mode. -fn parse_next_hy_v3_event(input: &mut HyV3Input<'_>, mode: HyV3Mode) -> ModalResult { +fn parse_next_hy_v3_event( + input: &mut HyV3Input<'_>, + mode: &mut HyV3Mode, +) -> ModalResult { match mode { HyV3Mode::Text => parse_text_event(input), - HyV3Mode::ToolBlock => parse_tool_block_event(input), + HyV3Mode::ToolBlock { tool_call_end_scan } => { + parse_tool_block_event(input, tool_call_end_scan) + } HyV3Mode::Done => ignored_rest_event(input), } } @@ -165,8 +178,14 @@ fn safe_text_event(input: &mut HyV3Input<'_>) -> ModalResult { } /// Parse one event inside a HY3 tool block. -fn parse_tool_block_event(input: &mut HyV3Input<'_>) -> ModalResult { - alt((tool_block_end_event, tool_call_event)).parse_next(input) +fn parse_tool_block_event( + input: &mut HyV3Input<'_>, + tool_call_end_scan: &mut MarkerScanState, +) -> ModalResult { + alt((tool_block_end_event, |input: &mut HyV3Input<'_>| { + tool_call_event(input, tool_call_end_scan) + })) + .parse_next(input) } /// Parse a HY3 tool-block end marker. @@ -175,13 +194,16 @@ fn tool_block_end_event(input: &mut HyV3Input<'_>) -> ModalResult { } /// Parse a complete HY3 tool-call block. -fn tool_call_event(input: &mut HyV3Input<'_>) -> ModalResult { +fn tool_call_event( + input: &mut HyV3Input<'_>, + tool_call_end_scan: &mut MarkerScanState, +) -> ModalResult { let (name, body) = seq!( _: ws0, _: literal(TOOL_CALL_START), take_until(0.., TOOL_SEP), _: literal(TOOL_SEP), - take_until(0.., TOOL_CALL_END), + take_until_marker(TOOL_CALL_END, tool_call_end_scan), _: literal(TOOL_CALL_END), ) .parse_next(input)?; @@ -227,8 +249,8 @@ mod tests { use thiserror_ext::AsReport; use super::{HyV3ToolParser, ToolParser}; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParserOutput, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String { let params = params @@ -244,7 +266,7 @@ mod tests { } fn parsed_arguments(output: &ToolParserOutput, index: usize) -> Value { - serde_json::from_str(&output.calls[index].arguments).unwrap() + serde_json::from_str(&output.calls()[index].arguments).unwrap() } #[test] @@ -259,8 +281,8 @@ mod tests { let mut parser = HyV3ToolParser::new(&test_tools()); let output = parser.parse_complete("This is a plain response.").unwrap(); - assert_eq!(output.normal_text, "This is a plain response."); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "This is a plain response."); + assert!(output.calls().is_empty()); } #[test] @@ -272,9 +294,9 @@ mod tests { ) .unwrap(); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); assert_eq!(parsed_arguments(&output, 0), json!({})); } @@ -287,7 +309,7 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); assert_eq!(parsed_arguments(&output, 0), json!({})); } @@ -332,8 +354,8 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Checking."); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.normal_text(), "Checking."); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); } #[test] @@ -354,22 +376,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"city\":\"Beijing\",\"date\":\"2026-03-30\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "get_weather", - ), - arguments: "{\"city\":\"Hangzhou\",\"date\":\"2026-03-30\"}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"city\":\"Beijing\",\"date\":\"2026-03-30\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "get_weather", + ), + arguments: "{\"city\":\"Hangzhou\",\"date\":\"2026-03-30\"}", + }, + ), ], } "#]] @@ -412,8 +437,8 @@ mod tests { output.append(parser.parse_chunk("response.").unwrap()); output.append(parser.finish().unwrap()); - assert_eq!(output.normal_text, "This is a plain response."); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "This is a plain response."); + assert!(output.calls().is_empty()); } #[test] @@ -430,8 +455,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); assert_eq!(parsed_arguments(&output, 0), json!({})); } @@ -453,8 +478,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( parsed_arguments(&output, 0), json!({ "city": "Beijing", "date": "2026-03-30" }) @@ -476,8 +501,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "Checking."); - assert_eq!(output.calls[0].name.as_deref(), Some("get_current_date")); + assert_eq!(output.normal_text(), "Checking."); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_current_date")); } #[test] @@ -497,7 +522,7 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 2); + assert_eq!(output.calls().len(), 2); assert_eq!(parsed_arguments(&output, 0)["city"], json!("Beijing")); assert_eq!(parsed_arguments(&output, 1)["city"], json!("Hangzhou")); } @@ -513,8 +538,8 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); assert_eq!(parsed_arguments(&output, 0), json!({ "city": "Beijing" })); } @@ -530,8 +555,8 @@ mod tests { ) .unwrap(); - assert_eq!(output.normal_text, ""); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), ""); + assert!(output.calls().is_empty()); } #[test] diff --git a/rust/src/parser/src/tool/json/granite4.rs b/rust/src/parser/src/tool/json/granite4.rs new file mode 100644 index 000000000000..0f7bb690214b --- /dev/null +++ b/rust/src/parser/src/tool/json/granite4.rs @@ -0,0 +1,554 @@ +use winnow::ascii::multispace0 as ws0; +use winnow::combinator::{alt, peek, seq}; +use winnow::error::{ContextError, ErrMode, ModalResult, StrContext}; +use winnow::prelude::*; +use winnow::token::{any, literal}; + +use super::{ + JsonToolCallConfig, JsonToolCallEvent, JsonToolCallWhitespace, JsonToolInput, + tool_call_header_event, +}; +use crate::tool::utils::{ + JsonObjectScanState, JsonStringScanState, decode_json_str, parse_buffered_event, safe_text_len, + take_json_object, take_json_string, +}; +use crate::tool::{Result, Tool, ToolCallDelta, ToolParser, ToolParserOutput}; + +const TOOL_CALL_START: &str = ""; +const TOOL_CALL_END: &str = ""; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Granite4Mode { + Text, + Header, + /// Parsing the arguments value: + /// `None` until the first byte decides object vs string; + /// `Some` while streaming the selected value shape. + Args { + args_scan: Option, + }, + /// Arguments done; consume the object's closing `}` and ``. + Close, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Granite4ArgsScan { + Object(JsonObjectScanState), + String(JsonStringScanState), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Granite4Event { + Text { + len: usize, + }, + ToolCallStart, + ToolCallHeader { + function_name: String, + }, + /// Verbatim bytes of an object-valued arguments payload; `complete` once the + /// object scan reaches its closing brace. + ObjectArgsDelta { + len: usize, + complete: bool, + }, + /// Decoded contents of a string-valued arguments payload. + StringArgs { + decoded: String, + }, + ToolCallEnd, +} + +/// Tool parser for Granite 4 `` JSON tool calls. +/// +/// Example tool call content: +/// +/// ```text +/// {"name": "get_weather", "arguments": {"city": "Boston"}} +/// ``` +/// +/// Parallel calls are repeated `` blocks with ordinary +/// content interleaved between them. This reuses the shared JSON helpers for +/// everything except one Granite 4 specific step (`args_event`): the `arguments` +/// value may be a JSON object (kept verbatim) **or** a JSON string whose decoded +/// contents are the arguments (the `# test granite behavior` case in Python). +pub struct Granite4ToolParser { + buffer: String, + mode: Granite4Mode, + active_tool_index: Option, + emitted_tool_count: usize, +} + +impl Granite4ToolParser { + /// Create a Granite 4 tool parser. + fn new(_tools: &[Tool]) -> Self { + Self { + buffer: String::new(), + mode: Granite4Mode::Text, + active_tool_index: None, + emitted_tool_count: 0, + } + } + + /// Apply one parsed Granite 4 event to parser state and output. + fn apply_event(&mut self, event: Granite4Event, output: &mut ToolParserOutput) -> Result<()> { + match event { + Granite4Event::Text { len } => output.push_text(&self.buffer[..len]), + Granite4Event::ToolCallStart => self.mode = Granite4Mode::Header, + Granite4Event::ToolCallHeader { function_name } => { + let tool_index = self.emitted_tool_count; + self.emitted_tool_count += 1; + self.active_tool_index = Some(tool_index); + self.mode = Granite4Mode::Args { args_scan: None }; + output.push_call(ToolCallDelta { + tool_index, + name: Some(function_name), + arguments: String::new(), + }); + } + Granite4Event::ObjectArgsDelta { len, complete } => { + let arguments = self.buffer[..len].to_string(); + self.push_arguments(arguments, output)?; + if complete { + self.mode = Granite4Mode::Close; + } + } + Granite4Event::StringArgs { decoded } => { + self.push_arguments(decoded, output)?; + self.mode = Granite4Mode::Close; + } + Granite4Event::ToolCallEnd => { + self.active_tool_index = None; + self.mode = Granite4Mode::Text; + } + } + Ok(()) + } + + /// Append one arguments delta to the active tool call. + fn push_arguments(&self, arguments: String, output: &mut ToolParserOutput) -> Result<()> { + let Some(tool_index) = self.active_tool_index else { + return Err(parsing_failed!( + "Granite4 arguments without an active tool call" + )); + }; + output.push_call(ToolCallDelta { + tool_index, + name: None, + arguments, + }); + Ok(()) + } + + fn reset(&mut self) -> String { + self.mode = Granite4Mode::Text; + self.active_tool_index = None; + self.emitted_tool_count = 0; + std::mem::take(&mut self.buffer) + } +} + +impl ToolParser for Granite4ToolParser { + fn create(tools: &[Tool]) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tools))) + } + + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + self.buffer.push_str(chunk); + + while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { + parse_next_granite4_event(input, &mut self.mode) + })? { + self.apply_event(event, output)?; + self.buffer.drain(..consumed_len); + } + + Ok(()) + } + + fn finish(&mut self) -> Result { + let mut output = ToolParserOutput::default(); + match &self.mode { + Granite4Mode::Text => output.push_text(&self.buffer), + Granite4Mode::Header | Granite4Mode::Args { .. } | Granite4Mode::Close => { + return Err(parsing_failed!("incomplete Granite4 tool call")); + } + } + let _ = self.reset(); + Ok(output) + } + + fn reset(&mut self) -> String { + Granite4ToolParser::reset(self) + } +} + +/// Parse a Granite 4 event for the current parser mode. +fn parse_next_granite4_event( + input: &mut JsonToolInput<'_>, + mode: &mut Granite4Mode, +) -> ModalResult { + match mode { + Granite4Mode::Text => text_event(input), + Granite4Mode::Header => header_event(input), + Granite4Mode::Args { args_scan } => args_event(input, args_scan), + Granite4Mode::Close => close_event(input), + } +} + +/// Parse content text or the start of a `` block. *(reuses `safe_text_len`)* +fn text_event(input: &mut JsonToolInput<'_>) -> ModalResult { + alt(( + |input: &mut JsonToolInput<'_>| { + seq!(_: literal(TOOL_CALL_START), _: ws0) + .value(Granite4Event::ToolCallStart) + .parse_next(input) + }, + |input: &mut JsonToolInput<'_>| { + safe_text_len(input, TOOL_CALL_START).map(|len| Granite4Event::Text { len }) + }, + )) + .parse_next(input) +} + +/// Parse the `{"name":"X","arguments":` header before the value. *(reuses `tool_call_header_event`)* +fn header_event(input: &mut JsonToolInput<'_>) -> ModalResult { + const CONFIG: JsonToolCallConfig = JsonToolCallConfig { + parser_name: "Granite4", + start_marker: "", + end_marker: "", + marker_whitespace: JsonToolCallWhitespace::Optional, + delimiter: None, + name_key: "name", + arguments_key: &["arguments"], + }; + + match tool_call_header_event(input, CONFIG)? { + JsonToolCallEvent::ToolCallHeader { function_name } => { + Ok(Granite4Event::ToolCallHeader { function_name }) + } + _ => unreachable!("tool_call_header_event only emits ToolCallHeader"), + } +} + +/// Parse one arguments-value event. +/// +/// GRANITE 4 SPECIFIC - the sole behavior that differs from the shared +/// `` JSON parsers. The value is either a JSON object (kept verbatim, +/// streamed incrementally via `take_json_object`) or an escaped JSON string +/// (decoded whole via `json_str`). The string form is why we cannot just forward +/// raw arg bytes like the sibling parsers do: an escaped string only resolves +/// once seen whole and unescaped. +fn args_event( + input: &mut JsonToolInput<'_>, + args_scan: &mut Option, +) -> ModalResult { + if let Some(scan) = args_scan { + return match scan { + Granite4ArgsScan::Object(scan) => { + let len = take_json_object(input, scan)?; + Ok(Granite4Event::ObjectArgsDelta { + len, + complete: scan.complete(), + }) + } + Granite4ArgsScan::String(scan) => string_args_event(input, scan), + }; + } + + match peek(any).parse_next(input)? { + '{' => { + let mut scan = JsonObjectScanState::default(); + let len = take_json_object(input, &mut scan)?; + let complete = scan.complete(); + *args_scan = Some(Granite4ArgsScan::Object(scan)); + Ok(Granite4Event::ObjectArgsDelta { len, complete }) + } + '"' => { + *args_scan = Some(Granite4ArgsScan::String(JsonStringScanState::default())); + let Some(Granite4ArgsScan::String(scan)) = args_scan else { + unreachable!("Granite4 string scan state was just initialized") + }; + string_args_event(input, scan) + } + _ => { + let mut error = ContextError::new(); + error.push(StrContext::Label("Granite4 arguments")); + Err(ErrMode::Cut(error)) + } + } +} + +fn string_args_event( + input: &mut JsonToolInput<'_>, + scan: &mut JsonStringScanState, +) -> ModalResult { + let text = **input; + let len = take_json_string(input, scan)?; + Ok(Granite4Event::StringArgs { + decoded: decode_json_str(&text[..len])?, + }) +} + +/// Parse the tool-call object's closing `}` and the `` end marker. +fn close_event(input: &mut JsonToolInput<'_>) -> ModalResult { + seq!(_: ws0, _: literal("}"), _: ws0, _: literal(TOOL_CALL_END)) + .value(Granite4Event::ToolCallEnd) + .parse_next(input) +} + +#[cfg(test)] +mod tests { + use expect_test::expect; + use thiserror_ext::AsReport; + + use super::Granite4ToolParser; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + + #[test] + fn granite4_parse_complete_without_tool_call_keeps_text() { + let mut parser = Granite4ToolParser::new(&test_tools()); + let output = parser.parse_complete("Hello, world!").unwrap(); + + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); + } + + #[test] + fn granite4_parse_complete_object_args() { + let mut parser = Granite4ToolParser::new(&test_tools()); + let output = parser + .parse_complete( + r#"{"name":"get_weather","arguments":{"city":"Boston"}}"#, + ) + .unwrap(); + + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, r#"{"city":"Boston"}"#); + } + + #[test] + fn granite4_parse_complete_string_args() { + // GRANITE4-SPECIFIC: `arguments` may be a pre-serialized JSON string; its + // decoded contents become the arguments. + let mut parser = Granite4ToolParser::new(&test_tools()); + let output = parser + .parse_complete( + r#"{"name":"get_weather","arguments":"{\"city\":\"Boston\"}"}"#, + ) + .unwrap(); + + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, r#"{"city":"Boston"}"#); + } + + #[test] + fn granite4_extracts_interleaved_content_and_mixed_args() { + let mut parser = Granite4ToolParser::new(&test_tools()); + let output = parser + .parse_complete( + r#"before {"name":"find_bbox","arguments":"{\"x\":1}"} middle {"name":"get_weather","arguments":{"city":"Boston"}} after"#, + ) + .unwrap(); + + expect![[r#" + ToolParserOutput { + events: [ + Text( + "before middle after", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "find_bbox", + ), + arguments: "{\"x\":1}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "get_weather", + ), + arguments: "{\"city\":\"Boston\"}", + }, + ), + ], + } + "#]] + .assert_debug_eq(&output); + } + + #[test] + fn granite4_streaming_handles_split_markers() { + let input = r#"hello {"name":"get_weather","arguments":{"city":"Tokyo"}} bye"#; + let chunks = split_by_chars(input, 5); + let mut parser = Granite4ToolParser::new(&test_tools()); + + let output = collect_stream(&mut parser, &chunks); + + assert_eq!(output.normal_text(), "hello bye"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, r#"{"city":"Tokyo"}"#); + } + + #[test] + fn granite4_streaming_emits_object_argument_deltas() { + let mut parser = Granite4ToolParser::new(&test_tools()); + let chunks = [ + r#"{"name":"get_weather","arguments":"#, + r#"{"city":"#, + r#""Beijing""#, + r#"}"#, + r#"}"#, + ]; + + let mut output = ToolParserOutput::default(); + let mut observed_arguments = Vec::new(); + for chunk in chunks { + let next = parser.parse_chunk(chunk).unwrap(); + observed_arguments.extend( + next.calls() + .iter() + .filter(|call| call.name.is_none()) + .map(|call| call.arguments.clone()), + ); + output.append(next); + } + output.append(parser.finish().unwrap()); + + assert_eq!(observed_arguments, [r#"{"city":"#, r#""Beijing""#, r#"}"#]); + assert_eq!( + output.coalesce().calls()[0].arguments, + r#"{"city":"Beijing"}"# + ); + } + + #[test] + fn granite4_string_args_split_across_chunks() { + let input = r#"{"name":"f","arguments":"{\"a\":1}"}"#; + let chunks = split_by_chars(input, 3); + let mut parser = Granite4ToolParser::new(&test_tools()); + + let output = collect_stream(&mut parser, &chunks); + + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("f")); + assert_eq!(output.calls()[0].arguments, r#"{"a":1}"#); + } + + #[test] + fn granite4_long_string_args_stream_without_reparse() { + let arguments = format!(r#"{{"data":"{}"}}"#, "x".repeat(64 * 1024)); + let encoded_arguments = serde_json::to_string(&arguments).unwrap(); + let input = + format!(r#"{{"name":"f","arguments":{encoded_arguments}}}"#); + let chunks = split_by_chars(&input, 7); + let mut parser = Granite4ToolParser::new(&test_tools()); + + let output = collect_stream(&mut parser, &chunks); + + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("f")); + assert_eq!(output.calls()[0].arguments, arguments); + } + + #[test] + fn granite4_streaming_handles_marker_and_json_whitespace() { + // Granite spaces the markers (` {…} `) and the JSON + // (`"name": …`). Since `args_event` has no leading `ws0`, this guards that + // the header consumes the whitespace before the arguments value. + let input = concat!( + "Here goes the bbox call: \n", + r#" {"name": "find_bbox", "arguments": "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}"} "#, + " Now the stock price call: \n ", + r#" {"name": "get_stock_price", "arguments": {"symbol": "AAPL", "start_date": "2021-01-01", "end_date": "2021-12-31"}} "#, + " Now another bbox call: \n ", + r#" {"name": "find_bbox", "arguments": "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}"} "#, + " See? I'm a helpful assistant.", + ); + let chunks = split_by_chars(input, 3); + let mut parser = Granite4ToolParser::new(&test_tools()); + + let output = collect_stream(&mut parser, &chunks); + + expect![[r#" + ToolParserOutput { + events: [ + Text( + "Here goes the bbox call: \n Now the stock price call: \n Now another bbox call: \n See? I'm a helpful assistant.", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "find_bbox", + ), + arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "get_stock_price", + ), + arguments: "{\"symbol\": \"AAPL\", \"start_date\": \"2021-01-01\", \"end_date\": \"2021-12-31\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 2, + name: Some( + "find_bbox", + ), + arguments: "{\"coordinates\": [[23.54, 43.1], [-12.2, 54.3], [4, 5]], \"coordinate_type\": \"latlong\"}", + }, + ), + ], + } + "#]].assert_debug_eq(&output); + } + + #[test] + fn granite4_finish_fails_incomplete_tool_call() { + let mut parser = Granite4ToolParser::new(&test_tools()); + parser + .parse_chunk(r#"{"name":"get_weather","arguments":{"city""#) + .unwrap(); + + let error = parser.finish().unwrap_err(); + + expect!["tool parser parsing failed: incomplete Granite4 tool call"] + .assert_eq(&error.to_report_string()); + } + + #[test] + fn granite4_rejects_non_object_non_string_args() { + let mut parser = Granite4ToolParser::new(&test_tools()); + let error = parser + .parse_chunk(r#"{"name":"f","arguments":42}"#) + .unwrap_err(); + + expect![[ + r#"tool parser parsing failed: near "42}": invalid Granite4 arguments"# + ]] + .assert_eq(&error.to_report_string()); + } + + #[test] + fn granite4_preserve_special_tokens_is_false() { + let parser = Granite4ToolParser::new(&test_tools()); + assert!(!parser.preserve_special_tokens()); + } +} diff --git a/rust/src/tool-parser/src/json/hermes.rs b/rust/src/parser/src/tool/json/hermes.rs similarity index 73% rename from rust/src/tool-parser/src/json/hermes.rs rename to rust/src/parser/src/tool/json/hermes.rs index f6b130ec472f..817eaee91f1c 100644 --- a/rust/src/tool-parser/src/json/hermes.rs +++ b/rust/src/parser/src/tool/json/hermes.rs @@ -1,5 +1,5 @@ use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput}; const HERMES_CONFIG: JsonToolCallConfig = JsonToolCallConfig { parser_name: "Hermes", @@ -45,6 +45,10 @@ impl ToolParser for HermesToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::Hermes) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.inner.parse_into(chunk, output) } @@ -64,8 +68,8 @@ mod tests { use thiserror_ext::AsReport; use super::HermesToolParser; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, arguments: &str) -> String { format!(r#"{{"name":"{function_name}","arguments":{arguments}}}"#) @@ -76,8 +80,8 @@ mod tests { let mut parser = HermesToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -91,11 +95,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -108,8 +112,8 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -118,7 +122,7 @@ mod tests { let arguments = r#"{"location":"Tokyo",}"#; let output = parser.parse_complete(&build_tool_call("get_weather", arguments)).unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -138,7 +142,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -148,9 +152,9 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); - assert_eq!(output.normal_text, "preface suffix"); + assert_eq!(output.normal_text(), "preface suffix"); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -166,9 +170,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -185,22 +189,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] diff --git a/rust/src/tool-parser/src/json/internlm2.rs b/rust/src/parser/src/tool/json/internlm2.rs similarity index 83% rename from rust/src/tool-parser/src/json/internlm2.rs rename to rust/src/parser/src/tool/json/internlm2.rs index 8284a4d0e1d5..25bc65911ab8 100644 --- a/rust/src/tool-parser/src/json/internlm2.rs +++ b/rust/src/parser/src/tool/json/internlm2.rs @@ -1,5 +1,5 @@ use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, Tool, ToolParser, ToolParserOutput}; const INTERNLM2_CONFIG: JsonToolCallConfig = JsonToolCallConfig { parser_name: "InternLM2", @@ -123,8 +123,8 @@ mod tests { use thiserror_ext::AsReport; use super::Internlm2ToolParser; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; const ACTION_START: &str = "<|action_start|><|plugin|>"; const ACTION_END: &str = "<|action_end|>"; @@ -140,8 +140,8 @@ mod tests { let mut parser = Internlm2ToolParser::new(&test_tools()); let result = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(result.normal_text, "Hello, world!"); - assert!(result.calls.is_empty()); + assert_eq!(result.normal_text(), "Hello, world!"); + assert!(result.calls().is_empty()); } #[test] @@ -155,11 +155,11 @@ mod tests { )) .unwrap(); - assert_eq!(result.normal_text, "Let me check.\n"); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].tool_index, 0); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.normal_text(), "Let me check.\n"); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].tool_index, 0); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -170,9 +170,9 @@ mod tests { .parse_complete(&build_tool_call("get_weather", "arguments", arguments)) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -185,8 +185,8 @@ mod tests { )) .unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); } #[test] @@ -197,7 +197,7 @@ mod tests { .parse_complete(&build_tool_call("get_weather", "parameters", arguments)) .unwrap(); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -218,7 +218,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -231,9 +231,9 @@ mod tests { observed_arguments, [r#"{"location":"#, r#""Beijing""#, r#"}"#] ); - assert_eq!(result.normal_text, "preface suffix"); + assert_eq!(result.normal_text(), "preface suffix"); assert_eq!( - result.coalesce_calls().calls[0].arguments, + result.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -249,9 +249,9 @@ mod tests { let result = collect_stream(&mut parser, &chunks); - assert_eq!(result.normal_text, "hello "); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(result.normal_text(), "hello "); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -268,22 +268,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -298,8 +301,8 @@ mod tests { let result = parser.parse_complete(&input).unwrap(); - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].arguments, arguments); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, arguments); } #[test] @@ -313,7 +316,7 @@ mod tests { let error = parser.finish().unwrap_err(); assert_eq!( - pre_finish.calls[0].name.as_deref(), + pre_finish.calls()[0].name.as_deref(), Some("get_weather"), "name delta is still emitted from parse_chunk() before truncation", ); @@ -332,7 +335,7 @@ mod tests { let error = parser.parse_chunk(&input).unwrap_err(); expect![[r#" - tool parser parsing failed: invalid InternLM2 + tool parser parsing failed: near "{\"name\":\"get_weather\",\"params\":{\"location\":\"Tokyo\"}}<|action_end|>": invalid InternLM2 expected `parameters`, `arguments`"#]] .assert_eq(&error.to_report_string()); } diff --git a/rust/src/tool-parser/src/json/llama.rs b/rust/src/parser/src/tool/json/llama.rs similarity index 83% rename from rust/src/tool-parser/src/json/llama.rs rename to rust/src/parser/src/tool/json/llama.rs index 36bc8a8347d9..9f30bbe84a6b 100644 --- a/rust/src/tool-parser/src/json/llama.rs +++ b/rust/src/parser/src/tool/json/llama.rs @@ -8,8 +8,8 @@ use super::{ JsonToolCallConfig, JsonToolCallEvent, JsonToolCallWhitespace, JsonToolInput, argument_delta_event, tool_call_header_event, }; -use crate::utils::{JsonObjectScanState, parse_buffered_event}; -use crate::{Result, Tool, ToolCallDelta, ToolParser, ToolParserOutput}; +use crate::tool::utils::{JsonObjectScanState, parse_buffered_event}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolCallDelta, ToolParser, ToolParserOutput}; #[derive(Debug, Clone, PartialEq, Eq)] enum LlamaJsonMode { @@ -87,7 +87,7 @@ impl Llama3JsonToolParser { self.mode = LlamaJsonMode::Arguments { json_scan: JsonObjectScanState::default(), }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -99,7 +99,7 @@ impl Llama3JsonToolParser { "Llama JSON arguments without an active tool call" )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -133,6 +133,10 @@ impl ToolParser for Llama3JsonToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::Llama) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.buffer.push_str(chunk); @@ -141,7 +145,7 @@ impl ToolParser for Llama3JsonToolParser { } if matches!(self.mode, LlamaJsonMode::Passthrough) { - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); self.buffer.clear(); return Ok(()); } @@ -160,7 +164,7 @@ impl ToolParser for Llama3JsonToolParser { let mut output = ToolParserOutput::default(); match &self.mode { LlamaJsonMode::Start | LlamaJsonMode::Passthrough => { - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); } LlamaJsonMode::AfterCall if self.buffer.trim().is_empty() => {} LlamaJsonMode::Header | LlamaJsonMode::Arguments { .. } => { @@ -252,8 +256,8 @@ mod tests { use thiserror_ext::AsReport; use super::Llama3JsonToolParser; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, parameters: &str) -> String { format!(r#"{{"name":"{function_name}","parameters":{parameters}}}"#) @@ -264,8 +268,8 @@ mod tests { let mut parser = Llama3JsonToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -280,10 +284,10 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!( - output.normal_text, + output.normal_text(), r#"plain text first {"name":"get_weather","parameters":{"location":"Tokyo"}}"# ); - assert!(output.calls.is_empty()); + assert!(output.calls().is_empty()); } #[test] @@ -295,8 +299,8 @@ mod tests { ); let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.normal_text, input); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), input); + assert!(output.calls().is_empty()); } #[test] @@ -308,8 +312,8 @@ mod tests { ); let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.normal_text, input); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), input); + assert!(output.calls().is_empty()); } #[test] @@ -318,10 +322,10 @@ mod tests { let arguments = r#"{ "location": "Tokyo", "days": 3 }"#; let output = parser.parse_complete(&build_tool_call("get_weather", arguments)).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -332,7 +336,7 @@ mod tests { .unwrap_err(); expect![[r#" - tool parser parsing failed: invalid Llama JSON + tool parser parsing failed: near "{\"name\":\"get_weather\",\"arguments\":{\"location\":\"Tokyo\"}}": invalid Llama JSON expected `parameters`"#]] .assert_eq(&error.to_report_string()); } @@ -349,22 +353,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -386,7 +393,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -397,7 +404,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -414,14 +421,14 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 2); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 2); assert_eq!( - output.calls[0].arguments, + output.calls()[0].arguments, r#"{"location":"Dallas","state":"TX"}"# ); - assert_eq!(output.calls[1].name.as_deref(), Some("add")); - assert_eq!(output.calls[1].arguments, r#"{"x":4,"y":5}"#); + assert_eq!(output.calls()[1].name.as_deref(), Some("add")); + assert_eq!(output.calls()[1].arguments, r#"{"x":4,"y":5}"#); } #[test] @@ -433,7 +440,7 @@ mod tests { }"#; let output = parser.parse_complete(&build_tool_call("convert", arguments)).unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -446,8 +453,8 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, ""); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), ""); + assert_eq!(output.calls().len(), 1); } #[test] @@ -467,7 +474,7 @@ mod tests { let error = parser.parse_chunk(r#"{"parameters":{},"name":"get_weather"}"#).unwrap_err(); expect![[r#" - tool parser parsing failed: invalid Llama JSON + tool parser parsing failed: near "{\"parameters\":{},\"name\":\"get_weather\"}": invalid Llama JSON expected `name`"#]] .assert_eq(&error.to_report_string()); } @@ -482,7 +489,7 @@ mod tests { )) .unwrap_err(); - expect!["tool parser parsing failed: invalid Llama JSON"] + expect![[r#"tool parser parsing failed: near " trailing": invalid Llama JSON"#]] .assert_eq(&error.to_report_string()); } } diff --git a/rust/src/tool-parser/src/json/mistral.rs b/rust/src/parser/src/tool/json/mistral.rs similarity index 74% rename from rust/src/tool-parser/src/json/mistral.rs rename to rust/src/parser/src/tool/json/mistral.rs index 9ca40fcaf97c..ab99afc7efde 100644 --- a/rust/src/tool-parser/src/json/mistral.rs +++ b/rust/src/parser/src/tool/json/mistral.rs @@ -1,5 +1,5 @@ use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, Tool, ToolParser, ToolParserOutput}; const MISTRAL_CONFIG: JsonToolCallConfig = JsonToolCallConfig { parser_name: "Mistral", @@ -61,8 +61,8 @@ mod tests { use thiserror_ext::AsReport; use super::MistralToolParser; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, arguments: &str) -> String { format!(r#"{{"name":"{function_name}","arguments":{arguments}}}"#) @@ -77,8 +77,8 @@ mod tests { let mut parser = MistralToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -92,11 +92,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -115,22 +115,28 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "I'll help.\n", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"city\": \"Tokyo\", \"units\": \"celsius\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\": 1, \"y\": 2}", - }, + events: [ + Text( + "I'll help.\n", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"city\": \"Tokyo\", \"units\": \"celsius\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\": 1, \"y\": 2}", + }, + ), ], } "#]] @@ -148,7 +154,7 @@ mod tests { )])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -168,7 +174,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -178,9 +184,9 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); - assert_eq!(output.normal_text, "preface suffix"); + assert_eq!(output.normal_text(), "preface suffix"); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -196,9 +202,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -209,8 +215,8 @@ mod tests { .parse_complete(&build_tool_calls(&[build_tool_call("echo", arguments)])) .unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -234,7 +240,7 @@ mod tests { .unwrap_err(); expect![[r#" - tool parser parsing failed: invalid Mistral + tool parser parsing failed: near "{\"arguments\":{},\"name\":\"get_weather\"}]": invalid Mistral expected `name`"#]] .assert_eq(&error.to_report_string()); } diff --git a/rust/src/tool-parser/src/json/mod.rs b/rust/src/parser/src/tool/json/mod.rs similarity index 86% rename from rust/src/tool-parser/src/json/mod.rs rename to rust/src/parser/src/tool/json/mod.rs index 5102c025c2cb..6a701de435ef 100644 --- a/rust/src/tool-parser/src/json/mod.rs +++ b/rust/src/parser/src/tool/json/mod.rs @@ -1,15 +1,19 @@ //! Shared parser core for JSON tool calls wrapped by text markers. +pub use granite4::Granite4ToolParser; pub use hermes::HermesToolParser; pub use internlm2::Internlm2ToolParser; pub use llama::Llama3JsonToolParser; pub use mistral::MistralToolParser; +pub use phi4mini::Phi4MiniJsonToolParser; pub use qwen::Qwen3XmlToolParser; +mod granite4; mod hermes; mod internlm2; mod llama; mod mistral; +mod phi4mini; mod qwen; use winnow::ascii::multispace0 as ws0; @@ -102,7 +106,7 @@ impl JsonToolCallParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match &self.mode { - JsonToolCallMode::Text => output.normal_text.push_str(&self.buffer), + JsonToolCallMode::Text => output.push_text(&self.buffer), JsonToolCallMode::Header | JsonToolCallMode::Arguments { .. } => { return Err(parsing_failed!( "incomplete {} tool call", @@ -122,7 +126,7 @@ impl JsonToolCallParser { ) -> Result<()> { match event { JsonToolCallEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } JsonToolCallEvent::ToolCallStart => self.mode = JsonToolCallMode::Header, JsonToolCallEvent::ToolCallHeader { function_name } => { @@ -132,7 +136,7 @@ impl JsonToolCallParser { self.mode = JsonToolCallMode::Arguments { json_scan: JsonObjectScanState::default(), }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -145,7 +149,7 @@ impl JsonToolCallParser { self.config.parser_name )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -370,7 +374,7 @@ mod tests { use expect_test::expect; use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace}; - use crate::ToolParserOutput; + use crate::tool::ToolParserOutput; const DELIMITED_CONFIG: JsonToolCallConfig = JsonToolCallConfig { parser_name: "Delimited JSON", @@ -396,7 +400,7 @@ mod tests { parser.parse_into(chunk, &mut output).unwrap(); } output.append(parser.finish().unwrap()); - output.coalesce_calls() + output.coalesce() } #[test] @@ -411,22 +415,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -447,22 +454,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -482,15 +492,19 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: " trailing text", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, + events: [ + Text( + " trailing text", + ), + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), ], } "#]] diff --git a/rust/src/parser/src/tool/json/phi4mini.rs b/rust/src/parser/src/tool/json/phi4mini.rs new file mode 100644 index 000000000000..3f259c2d7fe9 --- /dev/null +++ b/rust/src/parser/src/tool/json/phi4mini.rs @@ -0,0 +1,330 @@ +use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace}; +use crate::tool::{Result, Tool, ToolParser, ToolParserOutput}; + +const PHI4MINI_CONFIG: JsonToolCallConfig = JsonToolCallConfig { + parser_name: "Phi4Mini", + start_marker: "functools[", + end_marker: "]", + marker_whitespace: JsonToolCallWhitespace::Optional, + delimiter: Some(","), + name_key: "name", + // Accept both key variants emitted by Phi-4 Mini tool-call templates. + arguments_key: &["arguments", "parameters"], +}; + +/// Tool parser for phi-4-mini models. +/// +/// Example tool-call content: +/// +/// ```text +/// functools[{"name": "get_weather", "arguments": {"location": "Tokyo"}}] +/// ``` +/// +/// phi-4-mini emits an array of tool-call objects wrapped in a `functools[..]` +/// envelope. Each object names the function with `name` and carries its +/// arguments under `arguments` (preferred) or `parameters`. Arguments are +/// already OpenAI-style JSON text, so they are streamed as raw argument deltas +/// without schema conversion or JSON normalization. +pub struct Phi4MiniJsonToolParser { + inner: JsonToolCallParser, +} + +impl Phi4MiniJsonToolParser { + /// Create a phi-4-mini tool parser. + fn new(_tools: &[Tool]) -> Self { + Self { + inner: JsonToolCallParser::new(PHI4MINI_CONFIG), + } + } +} + +impl ToolParser for Phi4MiniJsonToolParser { + /// Create a boxed phi-4-mini tool parser. + fn create(tools: &[Tool]) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tools))) + } + + /// Feed one decoded text chunk through the phi-4-mini parser. + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + self.inner.parse_into(chunk, output) + } + + /// Flush any buffered partial state at end of stream. + fn finish(&mut self) -> Result { + self.inner.finish() + } + + /// Clear parser state and return currently uncommitted buffered text. + fn reset(&mut self) -> String { + self.inner.reset() + } +} + +#[cfg(test)] +mod tests { + use expect_test::expect; + use thiserror_ext::AsReport; + + use super::Phi4MiniJsonToolParser; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserTestExt as _}; + + /// Build one phi-4-mini tool-call object: `{"name":..,"":}`. + fn build_call(function_name: &str, args_key: &str, arguments: &str) -> String { + format!(r#"{{"name":"{function_name}","{args_key}":{arguments}}}"#) + } + + /// Wrap tool-call objects in the `functools[..]` envelope. + fn wrap(calls: &[String]) -> String { + format!("functools[{}]", calls.join(",")) + } + + #[test] + fn phi4mini_parse_complete_without_tool_call_keeps_text() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let result = parser.parse_complete("Hello, world!").unwrap(); + + assert_eq!(result.normal_text(), "Hello, world!"); + assert!(result.calls().is_empty()); + } + + #[test] + fn phi4mini_parse_complete_extracts_arguments_key() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let arguments = r#"{"location":"Tokyo","days":"3"}"#; + let result = parser + .parse_complete(&wrap(&[build_call("get_weather", "arguments", arguments)])) + .unwrap(); + + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].tool_index, 0); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); + } + + #[test] + fn phi4mini_parse_complete_falls_back_to_parameters_key() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let arguments = r#"{"location":"Tokyo"}"#; + let result = parser + .parse_complete(&wrap(&[build_call("get_weather", "parameters", arguments)])) + .unwrap(); + + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, arguments); + } + + #[test] + fn phi4mini_extracts_multiple_comma_delimited_calls() { + let input = wrap(&[ + build_call("get_weather", "arguments", r#"{"location":"Shanghai"}"#), + build_call("add", "arguments", r#"{"x":1,"y":2}"#), + ]); + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + + let result = parser.parse_complete(&input).unwrap(); + + expect![[r#" + ToolParserOutput { + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), + ], + } + "#]] + .assert_debug_eq(&result); + } + + /// The shared JSON core scans matched braces, so bracket-bearing argument + /// values are forwarded intact. + #[test] + fn phi4mini_array_valued_arguments_are_not_truncated() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let arguments = r#"{"items":[1,2],"flag":true}"#; + let result = parser + .parse_complete(&wrap(&[build_call("convert", "arguments", arguments)])) + .unwrap(); + + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, arguments); + } + + /// Preface text before a tool call is preserved as normal_text, consistent + /// with the other JSON parsers in this crate. + #[test] + fn phi4mini_preserves_text_before_tool_call() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let input = format!( + "Let me check.\n{}", + wrap(&[build_call( + "get_weather", + "arguments", + r#"{"location":"Tokyo"}"# + )]) + ); + + let result = parser.parse_complete(&input).unwrap(); + + assert_eq!(result.normal_text(), "Let me check.\n"); + assert_eq!(result.calls().len(), 1); + } + + #[test] + fn phi4mini_does_not_validate_or_normalize_arguments() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let arguments = r#"{"location":"Tokyo",}"#; + let result = parser + .parse_complete(&wrap(&[build_call("get_weather", "arguments", arguments)])) + .unwrap(); + + assert_eq!(result.calls()[0].arguments, arguments); + } + + /// The bundled `tool_chat_template_phi4_mini.jinja` emits objects with + /// whitespace after `:` and `,` (e.g. `{"name": "f", "arguments": {..}}`). + /// Confirm the parser handles that real model format and preserves the + /// inner argument spacing verbatim. + #[test] + fn phi4mini_accepts_real_model_whitespace_format() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let input = r#"functools[{"name": "get_weather", "arguments": {"location": "Tokyo"}}]"#; + + let result = parser.parse_complete(input).unwrap(); + + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, r#"{"location": "Tokyo"}"#); + } + + /// Argument deltas are streamed through the shared JSON core. + #[test] + fn phi4mini_streaming_emits_argument_deltas() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let chunks = [ + "preface functo", + "ols[", + r#"{"name":"get_weather","arguments":"#, + r#"{"location":"#, + r#""Beijing""#, + r#"}"#, + r#"}]"#, + " suffix", + ]; + + let result = collect_stream(&mut parser, &chunks); + + assert_eq!(result.normal_text(), "preface suffix"); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[0].arguments, r#"{"location":"Beijing"}"#); + } + + #[test] + fn phi4mini_streaming_handles_split_markers() { + let input = format!( + "hello {}", + wrap(&[build_call( + "get_weather", + "arguments", + r#"{"location":"Tokyo"}"# + )]) + ); + let chunks = split_by_chars(&input, 5); + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + + let result = collect_stream(&mut parser, &chunks); + + assert_eq!(result.normal_text(), "hello "); + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].arguments, r#"{"location":"Tokyo"}"#); + } + + #[test] + fn phi4mini_finish_errors_on_truncated_tool_call() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let _ = parser + .parse_chunk(r#"functools[{"name":"get_weather","arguments":{"location""#) + .unwrap(); + let error = parser.finish().unwrap_err(); + + assert!( + error.to_report_string().contains("incomplete Phi4Mini tool call"), + "finish() reports the truncated tool call as incomplete: {}", + error.to_report_string(), + ); + } + + #[test] + fn phi4mini_preserve_special_tokens_is_false() { + let parser = Phi4MiniJsonToolParser::new(&test_tools()); + assert!(!parser.preserve_special_tokens()); + } + + /// The brace-scanning core handles nested arrays and objects in arguments. + #[test] + fn phi4mini_parses_nested_arrays_and_objects() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let arguments = + r#"{"array_field":["a","b","c"],"object_field":{"nested":"value"},"empty_object":{}}"#; + let result = parser + .parse_complete(&wrap(&[build_call("convert", "arguments", arguments)])) + .unwrap(); + + assert_eq!(result.calls().len(), 1); + assert_eq!(result.calls()[0].name.as_deref(), Some("convert")); + assert_eq!(result.calls()[0].arguments, arguments); + } + + /// The chat template emits parallel calls as `},\n {` (comma + newline + + /// indent). Confirm the `Optional` marker whitespace and `,` delimiter + /// parse the real multi-call layout. + #[test] + fn phi4mini_parses_parallel_calls_in_template_format() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let input = concat!( + "functools[\n", + " {\"name\": \"get_weather\", \"arguments\": {\"city\": \"Tokyo\"}},\n", + " {\"name\": \"add\", \"arguments\": {\"x\": 1, \"y\": 2}}\n", + "]" + ); + + let result = parser.parse_complete(input).unwrap(); + + assert_eq!(result.calls().len(), 2); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[1].name.as_deref(), Some("add")); + } + + /// The shared core requires an object after the start marker. + #[test] + fn phi4mini_empty_array_errors() { + let mut parser = Phi4MiniJsonToolParser::new(&test_tools()); + let error = parser.parse_complete("functools[]").unwrap_err(); + + assert!( + error.to_report_string().contains("invalid Phi4Mini"), + "empty functools[] should error: {}", + error.to_report_string(), + ); + } +} diff --git a/rust/src/tool-parser/src/json/qwen.rs b/rust/src/parser/src/tool/json/qwen.rs similarity index 76% rename from rust/src/tool-parser/src/json/qwen.rs rename to rust/src/parser/src/tool/json/qwen.rs index b8caff0fefd8..a69798f785b5 100644 --- a/rust/src/tool-parser/src/json/qwen.rs +++ b/rust/src/parser/src/tool/json/qwen.rs @@ -1,5 +1,5 @@ use super::{JsonToolCallConfig, JsonToolCallParser, JsonToolCallWhitespace}; -use crate::{Result, Tool, ToolParser, ToolParserOutput}; +use crate::tool::{Result, StructuralTagModel, Tool, ToolParser, ToolParserOutput}; const QWEN_XML_CONFIG: JsonToolCallConfig = JsonToolCallConfig { parser_name: "Qwen XML", @@ -47,6 +47,10 @@ impl ToolParser for Qwen3XmlToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::Qwen3) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.inner.parse_into(chunk, output) } @@ -66,8 +70,8 @@ mod tests { use thiserror_ext::AsReport; use super::Qwen3XmlToolParser; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParser, ToolParserOutput, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, arguments: &str) -> String { format!( @@ -80,8 +84,8 @@ mod tests { let mut parser = Qwen3XmlToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -95,11 +99,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Let me check.\n"); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Let me check.\n"); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -108,7 +112,7 @@ mod tests { let arguments = r#"{"location":"Tokyo",}"#; let output = parser.parse_complete(&build_tool_call("get_weather", arguments)).unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -128,7 +132,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -139,7 +143,7 @@ mod tests { assert_eq!(observed_arguments, ["{\"location\":", "\"Beijing\"", "}"]); assert_eq!( - output.coalesce_calls().calls[0].arguments, + output.coalesce().calls()[0].arguments, r#"{"location":"Beijing"}"# ); } @@ -155,9 +159,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"Tokyo"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Tokyo"}"#); } #[test] @@ -166,8 +170,8 @@ mod tests { let arguments = r#"{"text":"literal inside"}"#; let output = parser.parse_complete(&build_tool_call("echo", arguments)).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -181,7 +185,7 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls[0].name.as_deref(), Some("say_\"hi")); + assert_eq!(output.calls()[0].name.as_deref(), Some("say_\"hi")); } #[test] @@ -192,8 +196,8 @@ mod tests { let output = parser.parse_complete(input).unwrap(); - assert_eq!(output.normal_text, input); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), input); + assert!(output.calls().is_empty()); } #[test] @@ -223,22 +227,25 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] @@ -273,7 +280,7 @@ mod tests { .unwrap_err(); expect![[r#" - tool parser parsing failed: invalid Qwen XML + tool parser parsing failed: near "{\"arguments\":{},\"name\":\"get_weather\"}\n": invalid Qwen XML expected `name`"#]] .assert_eq(&error.to_report_string()); } diff --git a/rust/src/tool-parser/src/kimi_k2.rs b/rust/src/parser/src/tool/kimi_k2.rs similarity index 80% rename from rust/src/tool-parser/src/kimi_k2.rs rename to rust/src/parser/src/tool/kimi_k2.rs index e43ff4afc6e3..e4730f96bc34 100644 --- a/rust/src/tool-parser/src/kimi_k2.rs +++ b/rust/src/parser/src/tool/kimi_k2.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use winnow::ascii::{digit1, multispace0 as ws0}; use winnow::combinator::{alt, eof, repeat, seq}; use winnow::prelude::*; @@ -6,7 +8,7 @@ use winnow::token::{literal, rest, take_until, take_while}; use super::utils::{JsonObjectScanState, parse_buffered_event, safe_text_len, take_json_object}; use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput}; -use crate::Tool; +use crate::tool::{StructuralTagModel, Tool}; const TOOL_CALLS_START: &str = "<|tool_calls_section_begin|>"; const TOOL_CALLS_END: &str = "<|tool_calls_section_end|>"; @@ -33,6 +35,7 @@ enum KimiK2Event { ToolCallsStart, ToolCallStart, ToolCallHeader { + tool_call_id: String, function_name: String, function_index: usize, }, @@ -60,6 +63,7 @@ pub struct KimiK2ToolParser { buffer: String, mode: KimiK2Mode, active_tool_index: Option, + call_ids: BTreeMap, } impl KimiK2ToolParser { @@ -69,6 +73,7 @@ impl KimiK2ToolParser { buffer: String::new(), mode: KimiK2Mode::Text, active_tool_index: None, + call_ids: BTreeMap::new(), } } @@ -76,11 +81,12 @@ impl KimiK2ToolParser { fn apply_event(&mut self, event: KimiK2Event, output: &mut ToolParserOutput) -> Result<()> { match event { KimiK2Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); } KimiK2Event::ToolCallsStart => self.mode = KimiK2Mode::ToolBlock, KimiK2Event::ToolCallStart => self.mode = KimiK2Mode::Header, KimiK2Event::ToolCallHeader { + tool_call_id, function_name, function_index, } => { @@ -89,7 +95,8 @@ impl KimiK2ToolParser { self.mode = KimiK2Mode::Arguments { json_scan: JsonObjectScanState::default(), }; - output.calls.push(ToolCallDelta { + self.call_ids.insert(tool_index, tool_call_id); + output.push_call(ToolCallDelta { tool_index, name: Some(function_name), arguments: String::new(), @@ -101,7 +108,7 @@ impl KimiK2ToolParser { "Kimi K2 arguments without an active tool call" )); }; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index, name: None, arguments: self.buffer[..consumed_len].to_string(), @@ -123,6 +130,7 @@ impl KimiK2ToolParser { fn reset(&mut self) -> String { self.mode = KimiK2Mode::Text; self.active_tool_index = None; + self.call_ids.clear(); std::mem::take(&mut self.buffer) } } @@ -139,6 +147,14 @@ impl ToolParser for KimiK2ToolParser { true } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::Kimi) + } + + fn tool_call_id(&self, tool_index: usize) -> Option<&str> { + self.call_ids.get(&tool_index).map(String::as_str) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.buffer.push_str(chunk); @@ -155,7 +171,7 @@ impl ToolParser for KimiK2ToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); match &self.mode { - KimiK2Mode::Text => output.normal_text.push_str(&self.buffer), + KimiK2Mode::Text => output.push_text(&self.buffer), KimiK2Mode::ToolBlock | KimiK2Mode::Done => {} KimiK2Mode::Header | KimiK2Mode::Arguments { .. } => { return Err(parsing_failed!("incomplete Kimi K2 tool call")); @@ -232,16 +248,18 @@ fn tool_call_end_event(input: &mut KimiK2Input<'_>) -> ModalResult /// Parse a Kimi K2 tool-call header before the argument marker. fn tool_call_header_event(input: &mut KimiK2Input<'_>) -> ModalResult { - let (header, _) = ( + let (raw_header, _) = ( take_until(1.., TOOL_CALL_ARGUMENT_START), literal(TOOL_CALL_ARGUMENT_START), ) .parse_next(input)?; - let mut header_input = header; + let tool_call_id = raw_header.trim().to_string(); + let mut header_input = raw_header; let (header, _, _) = (tool_header, ws0, eof).parse_next(&mut header_input)?; Ok(KimiK2Event::ToolCallHeader { + tool_call_id, function_name: header.function_name, function_index: header.function_index, }) @@ -321,8 +339,8 @@ mod tests { KimiK2ToolParser, TOOL_CALL_ARGUMENT_START, TOOL_CALL_END, TOOL_CALL_START, TOOL_CALLS_END, TOOL_CALLS_START, ToolParser, tool_header, }; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; - use crate::{ToolParserOutput, ToolParserTestExt as _}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParserOutput, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, index: usize, arguments: &str) -> String { format!( @@ -339,8 +357,8 @@ mod tests { let mut parser = KimiK2ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -354,11 +372,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.normal_text, "Checking. "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.normal_text(), "Checking. "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -373,7 +391,7 @@ mod tests { )])) .unwrap(); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -396,7 +414,7 @@ mod tests { for chunk in chunks { let next = parser.parse_chunk(chunk).unwrap(); observed_arguments.extend( - next.calls + next.calls() .iter() .filter(|call| call.name.is_none()) .map(|call| call.arguments.clone()), @@ -406,8 +424,8 @@ mod tests { output.append(parser.finish().unwrap()); assert_eq!(observed_arguments, ["{\"location\":", "\"Paris\"", "}"]); - let output = output.coalesce_calls(); - assert_eq!(output.calls[0].arguments, r#"{"location":"Paris"}"#); + let output = output.coalesce(); + assert_eq!(output.calls()[0].arguments, r#"{"location":"Paris"}"#); } #[test] @@ -427,9 +445,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.normal_text, "hello "); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, r#"{"location":"NYC"}"#); + assert_eq!(output.normal_text(), "hello "); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, r#"{"location":"NYC"}"#); } #[test] @@ -440,8 +458,8 @@ mod tests { let output = parser.parse_complete(&input).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].arguments, arguments); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].arguments, arguments); } #[test] @@ -460,9 +478,9 @@ mod tests { let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - output.calls[0].arguments, + output.calls()[0].arguments, r#"{"text":"literal <|tool_call_end|> inside"}"# ); } @@ -480,28 +498,50 @@ mod tests { expect![[r#" ToolParserOutput { - normal_text: "", - calls: [ - ToolCallDelta { - tool_index: 0, - name: Some( - "get_weather", - ), - arguments: "{\"location\":\"Shanghai\"}", - }, - ToolCallDelta { - tool_index: 1, - name: Some( - "add", - ), - arguments: "{\"x\":1,\"y\":2}", - }, + events: [ + ToolCall( + ToolCallDelta { + tool_index: 0, + name: Some( + "get_weather", + ), + arguments: "{\"location\":\"Shanghai\"}", + }, + ), + ToolCall( + ToolCallDelta { + tool_index: 1, + name: Some( + "add", + ), + arguments: "{\"x\":1,\"y\":2}", + }, + ), ], } "#]] .assert_debug_eq(&output); } + #[test] + fn kimi_k2_preserves_model_generated_tool_call_ids() { + let mut parser = KimiK2ToolParser::new(&test_tools()); + let input = build_tool_section(&[ + build_tool_call("get_weather", 0, r#"{"location":"Shanghai"}"#), + build_tool_call("add", 1, r#"{"x":1,"y":2}"#), + ]); + + for chunk in split_by_chars(&input, 7) { + parser.parse_chunk(chunk).unwrap(); + } + + // IDs are available after parsing but before finish(), which calls reset(). + assert_eq!(parser.tool_call_id(0), Some("functions.get_weather:0")); + assert_eq!(parser.tool_call_id(1), Some("functions.add:1")); + parser.finish().unwrap(); + assert_eq!(parser.tool_call_id(0), None); + } + #[test] fn kimi_k2_accepts_non_functions_header_prefix() { let mut parser = KimiK2ToolParser::new(&test_tools()); @@ -509,11 +549,12 @@ mod tests { "{TOOL_CALLS_START}{TOOL_CALL_START}api.tools.search:42{TOOL_CALL_ARGUMENT_START}{{}}{TOOL_CALL_END}{TOOL_CALLS_END}" ); - let output = parser.parse_complete(&input).unwrap(); + let output = parser.parse_chunk(&input).unwrap().coalesce(); - assert_eq!(output.calls[0].tool_index, 42); - assert_eq!(output.calls[0].name.as_deref(), Some("search")); - assert_eq!(output.calls[0].arguments, "{}"); + assert_eq!(output.calls()[0].tool_index, 42); + assert_eq!(parser.tool_call_id(42), Some("api.tools.search:42")); + assert_eq!(output.calls()[0].name.as_deref(), Some("search")); + assert_eq!(output.calls()[0].arguments, "{}"); } #[test] @@ -553,6 +594,9 @@ mod tests { let error = parser.parse_chunk(&input).unwrap_err(); - expect!["tool parser parsing failed: "].assert_eq(&error.to_report_string()); + expect![[ + r#"tool parser parsing failed: near "get_weather<|tool_call_argument_begin|>{}": "# + ]] + .assert_eq(&error.to_report_string()); } } diff --git a/rust/src/tool-parser/src/minimax_m2.rs b/rust/src/parser/src/tool/minimax_m2.rs similarity index 75% rename from rust/src/tool-parser/src/minimax_m2.rs rename to rust/src/parser/src/tool/minimax_m2.rs index 0e5956de9fac..c56b4bd1f601 100644 --- a/rust/src/tool-parser/src/minimax_m2.rs +++ b/rust/src/parser/src/tool/minimax_m2.rs @@ -5,9 +5,9 @@ use winnow::stream::Partial; use winnow::token::{literal, rest, take_until}; use super::parameters::ToolSchemas; -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape}; +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker}; use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput}; -use crate::Tool; +use crate::tool::{StructuralTagModel, Tool}; const TOOL_CALL_START: &str = ""; const TOOL_CALL_END: &str = ""; @@ -18,10 +18,10 @@ const PARAMETER_END: &str = ""; type MinimaxM2Input<'i> = Partial<&'i str>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum MinimaxM2Mode { Text, - ToolBlock, + ToolBlock { invoke_end_scan: MarkerScanState }, Done, } @@ -72,15 +72,19 @@ impl MinimaxM2ToolParser { fn apply_event(&mut self, event: MinimaxM2Event, output: &mut ToolParserOutput) -> Result<()> { match event { MinimaxM2Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); + } + MinimaxM2Event::ToolBlockStart => { + self.mode = MinimaxM2Mode::ToolBlock { + invoke_end_scan: MarkerScanState::default(), + }; } - MinimaxM2Event::ToolBlockStart => self.mode = MinimaxM2Mode::ToolBlock, MinimaxM2Event::Invoke { name, raw_params } => { let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params); let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -108,11 +112,15 @@ impl ToolParser for MinimaxM2ToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::Minimax) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.buffer.push_str(chunk); while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { - parse_next_minimax_m2_event(input, self.mode) + parse_next_minimax_m2_event(input, &mut self.mode) })? { self.apply_event(event, output)?; self.buffer.drain(..consumed_len); @@ -125,9 +133,9 @@ impl ToolParser for MinimaxM2ToolParser { let mut output = ToolParserOutput::default(); match self.mode { MinimaxM2Mode::Text => { - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); } - MinimaxM2Mode::ToolBlock => { + MinimaxM2Mode::ToolBlock { .. } => { return Err(parsing_failed!("incomplete MiniMax M2 tool call")); } MinimaxM2Mode::Done => {} @@ -144,11 +152,13 @@ impl ToolParser for MinimaxM2ToolParser { /// Parse a MiniMax M2 event for the current parser mode. fn parse_next_minimax_m2_event( input: &mut MinimaxM2Input<'_>, - mode: MinimaxM2Mode, + mode: &mut MinimaxM2Mode, ) -> ModalResult { match mode { MinimaxM2Mode::Text => parse_text_event(input), - MinimaxM2Mode::ToolBlock => parse_tool_block_event(input), + MinimaxM2Mode::ToolBlock { invoke_end_scan } => { + parse_tool_block_event(input, invoke_end_scan) + } MinimaxM2Mode::Done => ignored_rest_event(input), } } @@ -169,8 +179,14 @@ fn safe_text_event(input: &mut MinimaxM2Input<'_>) -> ModalResult) -> ModalResult { - alt((tool_block_end_event, invoke_event)).parse_next(input) +fn parse_tool_block_event( + input: &mut MinimaxM2Input<'_>, + invoke_end_scan: &mut MarkerScanState, +) -> ModalResult { + alt((tool_block_end_event, |input: &mut MinimaxM2Input<'_>| { + invoke_event(input, invoke_end_scan) + })) + .parse_next(input) } /// Parse a MiniMax M2 tool-block end marker. @@ -181,14 +197,17 @@ fn tool_block_end_event(input: &mut MinimaxM2Input<'_>) -> ModalResult) -> ModalResult { +fn invoke_event( + input: &mut MinimaxM2Input<'_>, + invoke_end_scan: &mut MarkerScanState, +) -> ModalResult { let (name, body) = seq!( _: ws0, _: literal(INVOKE_START), _: (ws1, literal("name=")), partial_attr_value, _: literal(">"), - take_until(0.., INVOKE_END), + take_until_marker(INVOKE_END, invoke_end_scan), _: literal(INVOKE_END), ) .parse_next(input)?; @@ -213,12 +232,12 @@ fn parameter(input: &mut &str) -> ModalResult<(String, String)> { _: (ws1, literal("name=")), attr_value, _: literal(">"), - take_until(0.., PARAMETER_END).map(xml_unescape), + take_until(0.., PARAMETER_END), _: literal(PARAMETER_END), ) .parse_next(input)?; - Ok((name.trim().to_string(), value.into_owned())) + Ok((name.trim().to_string(), value.to_string())) } /// Parse a quoted or unquoted XML attribute value. @@ -253,8 +272,8 @@ mod tests { use thiserror_ext::AsReport; use super::{MinimaxM2ToolParser, TOOL_CALL_END, TOOL_CALL_START, ToolParser}; - use crate::ToolParserTestExt as _; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::ToolParserTestExt as _; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; fn build_tool_block(invokes: &[(&str, Vec<(&str, &str)>)]) -> String { let invokes = invokes @@ -276,8 +295,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -290,11 +309,11 @@ mod tests { )])) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle", "days": 5 }) ); } @@ -308,8 +327,8 @@ mod tests { ); let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Let me check. "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -322,15 +341,15 @@ mod tests { ])) .unwrap(); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "city": "NYC" }) ); } @@ -352,7 +371,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 5.0, "flag": true, @@ -364,7 +383,24 @@ mod tests { } #[test] - fn minimax_m2_parse_complete_unescapes_literal_closing_tags_in_parameter_value() { + fn minimax_m2_parse_complete_preserves_raw_entities_in_parameter_value() { + // The MiniMax-M2 chat template renders string parameter values RAW (no + // XML escaping), so a value the user wants to be the literal text + // "Tom & Jerry <3" is emitted verbatim. The parser must preserve + // it; xml_unescape currently decodes it, corrupting the bytes. + let mut parser = MinimaxM2ToolParser::new(&test_tools()); + let output = parser + .parse_complete(&build_tool_block(&[( + "get_weather", + vec![("city", "Tom & Jerry <3")], + )])) + .unwrap(); + let args: Value = serde_json::from_str(&output.calls()[0].arguments).unwrap(); + assert_eq!(args["city"], json!("Tom & Jerry <3")); + } + + #[test] + fn minimax_m2_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = parser .parse_complete(&build_tool_block(&[( @@ -380,9 +416,9 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ - "city": "Seattle ", + "city": "Seattle </parameter></invoke></minimax:tool_call>", "days": 5, }) ); @@ -404,7 +440,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "shape": "\nrectangle\n", "dimensions": { "width": 10, "height": 20 }, @@ -426,11 +462,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Seattle" }) ); } @@ -448,8 +484,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Let me check. "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -457,8 +493,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &["Hello, ", "world!"]); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -468,8 +504,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); - assert!(output.normal_text.is_empty()); + assert_eq!(output.calls().len(), 1); + assert!(output.normal_text().is_empty()); } #[test] @@ -482,9 +518,9 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); } #[test] @@ -504,12 +540,12 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let result = collect_stream(&mut parser, &chunks); - assert_eq!(result.normal_text, "I will call the tools.\n"); - assert_eq!(result.calls.len(), 2); - assert_eq!(result.calls[0].tool_index, 0); - assert_eq!(result.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(result.calls[1].tool_index, 1); - assert_eq!(result.calls[1].name.as_deref(), Some("get_weather")); + assert_eq!(result.normal_text(), "I will call the tools.\n"); + assert_eq!(result.calls().len(), 2); + assert_eq!(result.calls()[0].tool_index, 0); + assert_eq!(result.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(result.calls()[1].tool_index, 1); + assert_eq!(result.calls()[1].name.as_deref(), Some("get_weather")); } #[test] @@ -522,8 +558,8 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); } #[test] @@ -532,8 +568,8 @@ mod tests { let output = parser.parse_chunk(r#""#).unwrap(); - assert!(output.normal_text.is_empty()); - assert!(output.calls.is_empty()); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); } #[test] @@ -557,6 +593,7 @@ mod tests { let mut parser = MinimaxM2ToolParser::new(&test_tools()); let error = parser.parse_chunk("").unwrap_err(); - expect!["tool parser parsing failed: "].assert_eq(&error.to_report_string()); + expect![[r#"tool parser parsing failed: near "": "#]] + .assert_eq(&error.to_report_string()); } } diff --git a/rust/src/parser/src/tool/minimax_m3.rs b/rust/src/parser/src/tool/minimax_m3.rs new file mode 100644 index 000000000000..e2a0cada2ce3 --- /dev/null +++ b/rust/src/parser/src/tool/minimax_m3.rs @@ -0,0 +1,931 @@ +use winnow::ascii::{multispace0 as ws0, multispace1 as ws1}; +use winnow::combinator::{alt, delimited, seq}; +use winnow::error::{ContextError, ErrMode}; +use winnow::prelude::*; +use winnow::stream::Partial; +use winnow::token::{literal, rest, take_until}; + +use super::parameters::{ParamElement, ParamInput, ToolSchemas}; +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker}; +use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput}; +use crate::tool::Tool; + +const NAMESPACE: &str = "]<]minimax[>["; +const TOOL_CALL_START: &str = "]<]minimax[>["; +const TOOL_CALL_END: &str = "]<]minimax[>["; +const INVOKE_START: &str = "]<]minimax[>[ = Partial<&'i str>; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MinimaxM3Mode { + Text, + ToolBlock { invoke_end_scan: MarkerScanState }, + Done, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MinimaxM3Event { + Text { + len: usize, + }, + ToolBlockStart, + Invoke { + name: String, + params: Vec<(String, ParamInput)>, + }, + ToolBlockEnd, + IgnoredRest, +} + +/// Tool parser for MiniMax M3 namespace-delimited XML-style tool calls. +/// +/// Example tool call content with recursive parameters: +/// +/// ```text +/// ]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[42]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[Singapore]<]minimax[>[ +/// ]<]minimax[>[018956]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[book-001]<]minimax[>[ +/// ]<]minimax[>[2]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[ +/// ]<]minimax[>[ +/// ``` +/// +/// With a schema where `shipping` is an object and `items` is an array of +/// objects, recursive parameter conversion produces: +/// +/// ```json +/// { +/// "user_id": 42, +/// "shipping": { +/// "city": "Singapore", +/// "zip": 18956 +/// }, +/// "items": [ +/// { +/// "sku": "book-001", +/// "qty": 2 +/// } +/// ] +/// } +/// ``` +/// +/// MiniMax M3 emits the namespace marker `]<]minimax[>[` before each structural +/// tag. Arguments are emitted only after a full `` block is parsed. +pub struct MinimaxM3ToolParser { + buffer: String, + mode: MinimaxM3Mode, + emitted_tool_count: usize, + tool_parameters: ToolSchemas, +} + +impl MinimaxM3ToolParser { + /// Create a MiniMax M3 tool parser. + pub fn new(tools: &[Tool]) -> Self { + Self { + buffer: String::new(), + mode: MinimaxM3Mode::Text, + emitted_tool_count: 0, + tool_parameters: ToolSchemas::from_tools(tools), + } + } + + /// Apply one parsed MiniMax M3 event to parser state and output. + fn apply_event(&mut self, event: MinimaxM3Event, output: &mut ToolParserOutput) -> Result<()> { + match event { + MinimaxM3Event::Text { len: consumed_len } => { + output.push_text(&self.buffer[..consumed_len]); + } + MinimaxM3Event::ToolBlockStart => { + self.mode = MinimaxM3Mode::ToolBlock { + invoke_end_scan: MarkerScanState::default(), + }; + } + MinimaxM3Event::Invoke { name, params } => { + let arguments = self.tool_parameters.convert_params_with_schema(&name, params); + let arguments = serde_json::to_string(&arguments) + .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; + + output.push_call(ToolCallDelta { + tool_index: self.emitted_tool_count, + name: Some(name), + arguments, + }); + self.emitted_tool_count += 1; + } + MinimaxM3Event::ToolBlockEnd => self.mode = MinimaxM3Mode::Done, + MinimaxM3Event::IgnoredRest => {} + } + Ok(()) + } +} + +impl ToolParser for MinimaxM3ToolParser { + fn create(tools: &[Tool]) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self::new(tools))) + } + + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + self.buffer.push_str(chunk); + + while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { + parse_next_minimax_m3_event(input, &mut self.mode) + })? { + self.apply_event(event, output)?; + self.buffer.drain(..consumed_len); + } + + Ok(()) + } + + fn finish(&mut self) -> Result { + let mut output = ToolParserOutput::default(); + match self.mode { + MinimaxM3Mode::Text => { + output.push_text(&self.buffer); + } + MinimaxM3Mode::ToolBlock { .. } => { + if !self.buffer.trim_start().is_empty() { + return Err(parsing_failed!("incomplete MiniMax M3 tool call")); + } + } + MinimaxM3Mode::Done => {} + } + let _ = self.reset(); + Ok(output) + } + + fn reset(&mut self) -> String { + self.mode = MinimaxM3Mode::Text; + self.emitted_tool_count = 0; + std::mem::take(&mut self.buffer) + } +} + +/// Parse a MiniMax M3 event for the current parser mode. +fn parse_next_minimax_m3_event( + input: &mut MinimaxM3Input<'_>, + mode: &mut MinimaxM3Mode, +) -> ModalResult { + match mode { + MinimaxM3Mode::Text => parse_text_event(input), + MinimaxM3Mode::ToolBlock { invoke_end_scan } => { + parse_tool_block_event(input, invoke_end_scan) + } + MinimaxM3Mode::Done => ignored_rest_event(input), + } +} + +/// Parse a text-mode MiniMax M3 event. +fn parse_text_event(input: &mut MinimaxM3Input<'_>) -> ModalResult { + alt((tool_block_start_event, safe_text_event)).parse_next(input) +} + +/// Parse a MiniMax M3 tool-block start marker. +fn tool_block_start_event(input: &mut MinimaxM3Input<'_>) -> ModalResult { + literal(TOOL_CALL_START).value(MinimaxM3Event::ToolBlockStart).parse_next(input) +} + +/// Parse a safe text run before the next MiniMax M3 marker. +fn safe_text_event(input: &mut MinimaxM3Input<'_>) -> ModalResult { + safe_text_len(input, TOOL_CALL_START).map(|len| MinimaxM3Event::Text { len }) +} + +/// Parse one event inside a MiniMax M3 tool block. +fn parse_tool_block_event( + input: &mut MinimaxM3Input<'_>, + invoke_end_scan: &mut MarkerScanState, +) -> ModalResult { + alt((tool_block_end_event, |input: &mut MinimaxM3Input<'_>| { + invoke_event(input, invoke_end_scan) + })) + .parse_next(input) +} + +/// Parse a MiniMax M3 tool-block end marker. +fn tool_block_end_event(input: &mut MinimaxM3Input<'_>) -> ModalResult { + (ws0, literal(TOOL_CALL_END)) + .value(MinimaxM3Event::ToolBlockEnd) + .parse_next(input) +} + +/// Parse a complete MiniMax M3 invoke block. +fn invoke_event( + input: &mut MinimaxM3Input<'_>, + invoke_end_scan: &mut MarkerScanState, +) -> ModalResult { + let (name, body) = seq!( + _: ws0, + _: literal(INVOKE_START), + _: (ws1, literal("name=")), + partial_attr_value, + _: literal(">"), + take_until_marker(INVOKE_END, invoke_end_scan), + _: literal(INVOKE_END), + ) + .parse_next(input)?; + let params = parse_invoke_params(body)?; + + Ok(MinimaxM3Event::Invoke { + name: name.trim().to_string(), + params, + }) +} + +/// Parse all parameter elements inside a complete MiniMax M3 invoke body. +fn parse_invoke_params(invoke_body: &str) -> ModalResult> { + let mut input = invoke_body; + let mut elements = Vec::new(); + + loop { + let _ = ws0.parse_next(&mut input)?; + if input.is_empty() { + break; + } + if input.starts_with(ELEMENT_START) { + elements.push(parameter_element(&mut input)?); + continue; + } + if input.starts_with(NAMESPACE) { + return malformed(); + } + // Be tolerant: ordinary text at an invokeparameter boundary ends this invoke. + // Keep parsed parameters and drop the remaining invoke body. + break; + } + + Ok(elements.into_iter().map(|element| (element.name, element.value)).collect()) +} + +/// Parse a MiniMax M3 parameter element. +fn parameter_element(input: &mut &str) -> ModalResult { + let name = open_element_tag(input)?.to_string(); + let value = element_body(input, &name)?; + close_element_tag(input, &name)?; + Ok(ParamElement { name, value }) +} + +/// Parse a MiniMax M3 opening element tag. +fn open_element_tag<'i>(input: &mut &'i str) -> ModalResult<&'i str> { + let name = seq!( + _: literal(ELEMENT_START), + take_until(1.., ">"), + _: literal(">"), + ) + .parse_next(input)?; + + let name = name.0; + if name.starts_with('/') || name.trim().is_empty() { + return malformed(); + } + + Ok(name) +} + +/// Parse a MiniMax M3 closing element tag. +fn close_element_tag(input: &mut &str, name: &str) -> ModalResult<()> { + literal(ELEMENT_END_START).void().parse_next(input)?; + literal(name).void().parse_next(input)?; + literal(">").void().parse_next(input) +} + +/// Parse the body of one MiniMax M3 element. +fn element_body(input: &mut &str, closing_name: &str) -> ModalResult { + let close_tag = format!("{ELEMENT_END_START}{closing_name}>"); + let mut text = String::new(); + let mut elements = Vec::new(); + + loop { + text.push_str(text_until_namespace(input)?); + + if input.starts_with(&close_tag) { + // Close tag reached, end of element body. + break; + } + if input.starts_with(ELEMENT_START) { + // Child element start reached, parse child element recursively. + elements.push(parameter_element(input)?); + continue; + } + if input.starts_with(NAMESPACE) { + // Unexpected namespace marker. + return malformed(); + } + } + + if elements.is_empty() { + Ok(ParamInput::Text(text)) + } else { + if !text.trim().is_empty() { + push_mixed_text_element(&mut elements, text); + } + Ok(ParamInput::Elements(elements)) + } +} + +/// Parse text until the next MiniMax M3 namespace marker. +fn text_until_namespace<'i>(input: &mut &'i str) -> ModalResult<&'i str> { + take_until(0.., NAMESPACE).parse_next(input) +} + +/// Preserve mixed text content under a reserved object field. +/// +/// By default, the field name is `$text`, but if that collides with an existing +/// child element name, prepend `$` until there is no collision. +fn push_mixed_text_element(elements: &mut Vec, text: String) { + let mut name = MIXED_TEXT_FIELD.to_string(); + while elements.iter().any(|element| element.name == name) { + name.insert(0, '$'); + } + elements.push(ParamElement { + name, + value: ParamInput::Text(text), + }); +} + +/// Parse a quoted or unquoted XML attribute value from partial streaming input. +fn partial_attr_value<'i>(input: &mut MinimaxM3Input<'i>) -> ModalResult<&'i str> { + alt(( + delimited(literal("\""), take_until(1.., "\""), literal("\"")), + delimited(literal("'"), take_until(1.., "'"), literal("'")), + take_until(1.., ">"), + )) + .parse_next(input) +} + +/// Parse ignored rest after the MiniMax M3 tool block ends. +fn ignored_rest_event(input: &mut MinimaxM3Input<'_>) -> ModalResult { + rest.value(MinimaxM3Event::IgnoredRest).parse_next(input) +} + +fn malformed() -> ModalResult { + Err(ErrMode::Cut(ContextError::new())) +} + +#[cfg(test)] +mod tests { + use expect_test::expect; + use serde_json::{Value, json}; + use thiserror_ext::AsReport; + + use super::{ + ELEMENT_END_START, ELEMENT_START, INVOKE_END, INVOKE_START, MinimaxM3ToolParser, + TOOL_CALL_END, TOOL_CALL_START, ToolParser, + }; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{Tool, ToolParserEvent, ToolParserTestExt as _}; + + fn element(name: &str, body: &str) -> String { + format!("{ELEMENT_START}{name}>{body}{ELEMENT_END_START}{name}>") + } + + fn invoke(function_name: &str, body: &str) -> String { + format!("{INVOKE_START} name=\"{function_name}\">{body}{INVOKE_END}") + } + + fn build_tool_block(invokes: &[(&str, String)]) -> String { + let invokes = invokes + .iter() + .map(|(function_name, body)| invoke(function_name, body)) + .collect::>() + .join("\n"); + format!("{TOOL_CALL_START}\n{invokes}\n{TOOL_CALL_END}") + } + + fn m3_test_tools() -> Vec { + let mut tools = test_tools(); + tools.push(Tool { + name: "create_order".to_string(), + description: None, + parameters: json!({ + "type": "object", + "properties": { + "user_id": { "type": "integer" }, + "urgent": { "type": "boolean" }, + "note": { "type": "string" }, + "shipping": { + "type": "object", + "properties": { + "city": { "type": "string" }, + "zip": { "type": "integer" } + } + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sku": { "type": "string" }, + "qty": { "type": "integer" } + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": { "type": "integer" } + }, + "duplicate_demo": { + "type": "object", + "properties": { + "tag": { "type": "string" } + } + }, + "schema_mismatch_array": { + "type": "array", + "items": { "type": "integer" } + } + } + }), + strict: None, + }); + tools + } + + fn order_arguments() -> String { + let shipping = element( + "shipping", + &format!( + "{}{}", + element("city", "Singapore"), + element("zip", "018956") + ), + ); + let first_item = element( + "item", + &format!("{}{}", element("sku", "book-001"), element("qty", "2")), + ); + let second_item = element( + "item", + &format!("{}{}", element("sku", "pen-007"), element("qty", "5")), + ); + let items = element("items", &format!("{first_item}{second_item}")); + let metadata = element( + "metadata", + &format!("{}{}", element("score", "42"), element("rank", "7")), + ); + let duplicate_demo = element( + "duplicate_demo", + &format!("{}{}", element("tag", "a"), element("tag", "b")), + ); + let schema_mismatch_array = element( + "schema_mismatch_array", + &format!("{}{}", element("x", "1"), element("x", "2")), + ); + + [ + element("user_id", "42"), + element("urgent", "true"), + element("note", "Please leave at front desk."), + shipping, + items, + metadata, + duplicate_demo, + schema_mismatch_array, + element( + "unknown_struct", + &format!("{}{}", element("a", "1"), element("a", "2")), + ), + ] + .join("") + } + + #[test] + fn minimax_m3_parse_complete_without_tool_call_keeps_text() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser.parse_complete("Hello, world!").unwrap(); + + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); + } + + #[test] + fn minimax_m3_parse_complete_extracts_single_tool_call() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_complete(&build_tool_block(&[( + "get_weather", + format!("{}{}", element("city", "Seattle"), element("days", "5")), + )])) + .unwrap(); + + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "city": "Seattle", "days": 5 }) + ); + } + + #[test] + fn minimax_m3_parse_complete_preserves_prefix_and_ignores_trailing_text() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = format!( + "Let me check. {} This trailing text is ignored.", + build_tool_block(&[("get_weather", element("city", "Seattle"))]) + ); + let output = parser.parse_complete(&output).unwrap(); + + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); + } + + #[test] + fn minimax_m3_parse_complete_extracts_multiple_invokes() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_complete(&build_tool_block(&[ + ("get_weather", element("city", "Seattle")), + ("get_weather", element("city", "NYC")), + ])) + .unwrap(); + + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "city": "Seattle" }) + ); + assert_eq!( + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), + json!({ "city": "NYC" }) + ); + } + + #[test] + fn minimax_m3_invoke_body_junk_drops_rest_of_invoke() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_complete(&build_tool_block(&[( + "get_weather", + [ + element("city", "Seattle"), + "I need to use the city above.".to_string(), + element("days", "5"), + ] + .join(""), + )])) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "city": "Seattle" }) + ); + } + + #[test] + fn minimax_m3_parse_complete_converts_schema_types() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_complete(&build_tool_block(&[( + "convert", + [ + element("whole", "5.0"), + element("flag", "true"), + element("payload", r#"{"nested":true}"#), + element("items", "[1,2]"), + element("empty", "42"), + ] + .join(""), + )])) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ + "whole": 5.0, + "flag": true, + "payload": { "nested": true }, + "items": [1, 2], + "empty": "42", + }) + ); + } + + #[test] + fn minimax_m3_parse_complete_converts_nested_arguments() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_complete(&build_tool_block(&[("create_order", order_arguments())])) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ + "user_id": 42, + "urgent": true, + "note": "Please leave at front desk.", + "shipping": { + "city": "Singapore", + "zip": 18956 + }, + "items": [ + { + "sku": "book-001", + "qty": 2 + }, + { + "sku": "pen-007", + "qty": 5 + } + ], + "metadata": { + "score": 42, + "rank": 7 + }, + "duplicate_demo": { + "tag": ["a", "b"] + }, + "schema_mismatch_array": [1, 2], + "unknown_struct": { + "a": ["1", "2"] + } + }) + ); + } + + #[test] + fn minimax_m3_parse_complete_handles_multiline_leaf_parameters() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_complete(&build_tool_block(&[( + "calculate_area", + [ + element("shape", "\nrectangle\n"), + element("dimensions", r#"{"width":10,"height":20}"#), + element("precision", "2"), + ] + .join(""), + )])) + .unwrap(); + + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ + "shape": "\nrectangle\n", + "dimensions": { "width": 10, "height": 20 }, + "precision": 2, + }) + ); + } + + #[test] + fn minimax_m3_streaming_extracts_single_tool_call() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream( + &mut parser, + &[ + TOOL_CALL_START, + &invoke("get_weather", &element("city", "Seattle")), + TOOL_CALL_END, + ], + ); + + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "city": "Seattle" }) + ); + } + + #[test] + fn minimax_m3_streaming_preserves_prefix_text() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream( + &mut parser, + &[ + "Let me check. ", + TOOL_CALL_START, + &invoke("get_weather", &element("city", "Seattle")), + TOOL_CALL_END, + ], + ); + + assert_eq!(output.normal_text(), "Let me check. "); + assert_eq!(output.calls().len(), 1); + } + + #[test] + fn minimax_m3_streaming_preserves_ordered_events() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream( + &mut parser, + &[ + "Let me check. ", + TOOL_CALL_START, + &invoke("get_weather", &element("city", "Seattle")), + TOOL_CALL_END, + ], + ); + + assert_eq!(output.events.len(), 2); + assert_eq!( + output.events[0], + ToolParserEvent::Text("Let me check. ".to_string()) + ); + let ToolParserEvent::ToolCall(call) = &output.events[1] else { + panic!("expected tool-call event"); + }; + assert_eq!(call.name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&call.arguments).unwrap(), + json!({ "city": "Seattle" }) + ); + } + + #[test] + fn minimax_m3_streaming_without_tool_call_emits_text_incrementally() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream(&mut parser, &["Hello, ", "world!"]); + + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); + } + + #[test] + fn minimax_m3_streaming_handles_marker_split_across_chunks() { + let text = build_tool_block(&[("get_weather", element("city", "Seattle"))]); + let chunks = split_by_chars(&text, 3); + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream(&mut parser, &chunks); + + assert_eq!(output.calls().len(), 1); + assert!(output.normal_text().is_empty()); + } + + #[test] + fn minimax_m3_streaming_extracts_multiple_invokes_in_order() { + let text = build_tool_block(&[ + ("get_weather", element("city", "Seattle")), + ("get_weather", element("city", "NYC")), + ]); + let chunks = split_by_chars(&text, 7); + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream(&mut parser, &chunks); + + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); + } + + #[test] + fn minimax_m3_streaming_does_not_emit_incomplete_tool_call() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_chunk(&format!( + "{TOOL_CALL_START}{INVOKE_START} name=\"get_weather\">" + )) + .unwrap(); + + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); + } + + #[test] + fn minimax_m3_streaming_ignores_text_after_tool_block() { + let text = format!( + "{} ignored", + build_tool_block(&[("get_weather", element("city", "Seattle"))]) + ); + let chunks = split_by_chars(&text, 5); + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = collect_stream(&mut parser, &chunks); + + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + } + + #[test] + fn minimax_m3_finish_fails_incomplete_tool_call() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + parser + .parse_chunk(&format!( + "{TOOL_CALL_START}{INVOKE_START} name=\"get_weather\">" + )) + .unwrap(); + + assert!(parser.finish().is_err()); + } + + #[test] + fn minimax_m3_finish_recovers_after_bare_tool_block_start() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + parser.parse_chunk(TOOL_CALL_START).unwrap(); + + let output = parser.finish().unwrap(); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); + } + + #[test] + fn minimax_m3_finish_recovers_completed_invoke_with_whitespace_tail() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let output = parser + .parse_complete(&format!( + "{}\n{}\n \n", + TOOL_CALL_START, + invoke("get_weather", &element("city", "Seattle")) + )) + .unwrap(); + + assert_eq!(output.calls().len(), 1); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "city": "Seattle" }) + ); + } + + #[test] + fn minimax_m3_finish_fails_partial_outer_end_marker() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + parser + .parse_chunk(&format!( + "{}\n{}\n{}", + TOOL_CALL_START, + invoke("get_weather", &element("city", "Seattle")), + &TOOL_CALL_END[..3] + )) + .unwrap(); + + assert!(parser.finish().is_err()); + } + + #[test] + fn minimax_m3_malformed_tool_call_fails_fast() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let error = parser + .parse_chunk(&format!( + "{TOOL_CALL_START}{ELEMENT_START}bad>{TOOL_CALL_END}" + )) + .unwrap_err(); + + expect![[ + r#"tool parser parsing failed: near "]<]minimax[>[]<]minimax[>[": "# + ]] + .assert_eq(&error.to_report_string()); + } + + #[test] + fn minimax_m3_mixed_content_is_preserved_as_text_field() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let body = element( + "payload", + &format!("text before {} text after", element("child", "value")), + ); + let output = parser.parse_complete(&build_tool_block(&[("convert", body)])).unwrap(); + + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ + "payload": { + "child": "value", + "$text": "text before text after" + } + }) + ); + } + + #[test] + fn minimax_m3_mixed_text_field_avoids_child_name_collision() { + let mut parser = MinimaxM3ToolParser::new(&m3_test_tools()); + let body = element( + "payload", + &format!( + "text{}{}", + element("$text", "child text"), + element("child", "value") + ), + ); + let output = parser.parse_complete(&build_tool_block(&[("convert", body)])).unwrap(); + + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ + "payload": { + "$text": "child text", + "$$text": "text", + "child": "value" + } + }) + ); + } +} diff --git a/rust/src/tool-parser/src/lib.rs b/rust/src/parser/src/tool/mod.rs similarity index 59% rename from rust/src/tool-parser/src/lib.rs rename to rust/src/parser/src/tool/mod.rs index f1dc0455843f..5a06f2311eda 100644 --- a/rust/src/tool-parser/src/lib.rs +++ b/rust/src/parser/src/tool/mod.rs @@ -1,38 +1,39 @@ //! Streaming tool parsers for chat completions. #[macro_use] -mod error; +pub(crate) mod error; mod deepseek_dsml; -mod deepseek_json; -mod gemma4; +pub(crate) mod deepseek_json; mod glm_xml; mod hy_v3; mod json; mod kimi_k2; mod minimax_m2; +mod minimax_m3; mod parameters; mod qwen_coder; #[cfg(any(test, feature = "test-util"))] pub mod test_utils; -mod utils; - use std::collections::{BTreeMap, btree_map}; pub use deepseek_dsml::{DeepSeekV4ToolParser, DeepSeekV32ToolParser}; pub use deepseek_json::{DeepSeekV3ToolParser, DeepSeekV31ToolParser}; pub use error::{Result, ToolParserError}; -pub use gemma4::Gemma4ToolParser; pub use glm_xml::{Glm45MoeToolParser, Glm47MoeToolParser}; pub use hy_v3::HyV3ToolParser; pub use json::{ - HermesToolParser, Internlm2ToolParser, Llama3JsonToolParser, MistralToolParser, - Qwen3XmlToolParser, + Granite4ToolParser, HermesToolParser, Internlm2ToolParser, Llama3JsonToolParser, + MistralToolParser, Phi4MiniJsonToolParser, Qwen3XmlToolParser, }; pub use kimi_k2::KimiK2ToolParser; pub use minimax_m2::MinimaxM2ToolParser; +pub use minimax_m3::MinimaxM3ToolParser; pub use qwen_coder::Qwen3CoderToolParser; use serde::{Deserialize, Serialize}; use serde_json::Value; +pub use xgrammar_structural_tag::Model as StructuralTagModel; + +use crate::utils; /// One function-style tool made available to the model. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -54,55 +55,115 @@ pub struct ToolCallDelta { pub arguments: String, } +/// One ordered event emitted while parsing assistant text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolParserEvent { + /// Plain assistant text that is not part of any tool call. + Text(String), + /// A tool-call update extracted from assistant text. + ToolCall(ToolCallDelta), +} + /// Result of advancing tool parsing with one assistant-text input. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ToolParserOutput { - /// Plain assistant text that is not part of any tool call. - pub normal_text: String, - /// Tool-call updates extracted from this input. - pub calls: Vec, + /// Ordered parser events committed by this input. + pub events: Vec, } impl ToolParserOutput { + /// Append one visible text event if `text` is non-empty. + pub fn push_text(&mut self, text: impl AsRef + Into) { + if text.as_ref().is_empty() { + return; + } + if let Some(ToolParserEvent::Text(last_text)) = self.events.last_mut() { + last_text.push_str(text.as_ref()); + return; + } + self.events.push(ToolParserEvent::Text(text.into())); + } + + /// Append one tool-call update event. + pub fn push_call(&mut self, call: ToolCallDelta) { + self.events.push(ToolParserEvent::ToolCall(call)); + } + + /// Return all plain assistant text committed by this output. + /// + /// Texts before and after tool calls will be concatenated into a single string. To preserve + /// the original order of the text and tool-call events, directly access `events` instead. + pub fn normal_text(&self) -> String { + self.events + .iter() + .filter_map(|event| match event { + ToolParserEvent::Text(text) => Some(text.as_str()), + ToolParserEvent::ToolCall(_) => None, + }) + .collect() + } + + /// Return all tool-call updates committed by this output. + pub fn calls(&self) -> Vec<&ToolCallDelta> { + self.events + .iter() + .filter_map(|event| match event { + ToolParserEvent::Text(_) => None, + ToolParserEvent::ToolCall(call) => Some(call), + }) + .collect() + } + /// Append another parser output onto this one. /// - /// Note that this does not attempt to merge multiple deltas for the same - /// tool call into one complete item. Call `coalesce_calls()` after if - /// that behavior is desired. - pub fn append(&mut self, mut other: Self) { - self.normal_text.push_str(&other.normal_text); - self.calls.append(&mut other.calls); + /// Note that this keeps events exactly as they arrive. Call `coalesce()` + /// after if final text and tool-call fragments should be flattened. + pub fn append(&mut self, other: Self) { + for event in other.events { + match event { + ToolParserEvent::Text(text) => self.push_text(text), + ToolParserEvent::ToolCall(call) => self.push_call(call), + } + } } - /// Merge multiple deltas for the same tool call into one complete item. + /// Flatten text and merge deltas for the same tool call. + /// + /// All text events are concatenated into one leading text event. Tool-call + /// events follow that text event in first-seen tool index order, with + /// argument fragments for the same tool call concatenated together. /// /// This is primarily used by the default `parse_complete()` implementation, /// which delegates through the incremental parser lifecycle and then /// needs to collapse streaming-style argument fragments into one final /// tool call. - pub fn coalesce_calls(mut self) -> Self { + pub fn coalesce(self) -> Self { let mut merged = BTreeMap::::new(); let mut order = Vec::new(); + let normal_text = self.normal_text(); - for call in self.calls { + for call in self.calls() { match merged.entry(call.tool_index) { btree_map::Entry::Vacant(entry) => { order.push(call.tool_index); - entry.insert(call); + entry.insert(call.clone()); } btree_map::Entry::Occupied(mut entry) => { let existing = entry.get_mut(); if existing.name.is_none() { - existing.name = call.name; + existing.name = call.name.clone(); } existing.arguments.push_str(&call.arguments); } } } - self.calls = - order.into_iter().filter_map(|tool_index| merged.remove(&tool_index)).collect(); - self + let mut output = Self::default(); + output.push_text(normal_text); + for call in order.into_iter().filter_map(|tool_index| merged.remove(&tool_index)) { + output.push_call(call); + } + output } } @@ -121,6 +182,17 @@ pub trait ToolParser: Send { false } + /// Return the xgrammar structural-tag model used for strict tool calling. + fn structural_tag_model(&self) -> Option { + None + } + + /// Return the parser-provided ID for a tool call by index, if the model + /// emitted one. + fn tool_call_id(&self, _tool_index: usize) -> Option<&str> { + None + } + /// Feed one decoded text delta into the parser, appending committed output /// into `output`. /// @@ -169,7 +241,7 @@ impl T { pub fn parse_complete(&mut self, text: &str) -> Result { let mut output = self.parse_chunk(text)?; output.append(self.finish()?); - Ok(output.coalesce_calls()) + Ok(output.coalesce()) } } diff --git a/rust/src/tool-parser/src/parameters.rs b/rust/src/parser/src/tool/parameters.rs similarity index 92% rename from rust/src/tool-parser/src/parameters.rs rename to rust/src/parser/src/tool/parameters.rs index f857c147cb64..f9abb50f8b10 100644 --- a/rust/src/tool-parser/src/parameters.rs +++ b/rust/src/parser/src/tool/parameters.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use serde_json::{Map, Number, Value}; -use crate::Tool; +use crate::tool::Tool; /// Normalized parameter schemas for all tools in one request. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -166,7 +166,13 @@ impl JsonParamType { // Typically, these types are already handled by checking the "type" field, but // we can also infer them from their characteristic fields if "type" is missing. - if schema.contains_key("enum") { + if let Some(values) = schema.get("enum").and_then(Value::as_array) { + // Enum values are treated as strings, except that a `null` member + // makes the parameter nullable (mirrors Python's enum type + // inference), so a literal "null" coerces to JSON null. + if values.iter().any(Value::is_null) { + return Some(Self::one_of(vec![Self::String, Self::Null])); + } return Some(Self::String); } if schema.contains_key("items") { @@ -277,9 +283,12 @@ impl JsonParamType { /// Convert one parameter input to a normalized JSON value. fn convert_with_optional_schema(param_type: Option<&JsonParamType>, input: &ParamInput) -> Value { - // For literal `null`, always convert to JSON null value. + // Coerce the literal text `null` to JSON null, except for `string`-typed + // params, where it must stay the string "null": a model emitting the literal + // text "null" for a string field means the string, not a missing value. if let ParamInput::Text(value) = input && value.eq_ignore_ascii_case("null") + && param_type != Some(&JsonParamType::String) { return Value::Null; } @@ -416,7 +425,7 @@ mod tests { use serde_json::{Value, json}; use super::{ParamElement, ParamInput, ToolSchema, ToolSchemas}; - use crate::Tool; + use crate::tool::Tool; fn test_tool(name: &str, parameters: serde_json::Value) -> Tool { Tool { @@ -685,21 +694,43 @@ mod tests { } #[test] - fn convert_params_preserves_null_for_known_param() { - let schemas = ToolSchemas::from_tools(&[test_tool( - "convert", - json!({ - "type": "object", - "properties": { - "value": { "type": "string" } - } - }), - )]); + fn string_param_preserves_literal_null_text() { + // A `string`-typed param whose value is the literal text "null"/"NULL" + // must stay a string (the original case is preserved), rather than being + // coerced to JSON null. Non-string types keep coercing "null" to null. + let params = ToolSchema::from_schema(&json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "count": { "type": "integer" }, + "anything": {} + } + })); + + assert_eq!(params.convert("name", text("null")), json!("null")); + assert_eq!(params.convert("name", text("NULL")), json!("NULL")); + // Non-string and schema-less params are unchanged: "null" -> null. + assert_eq!(params.convert("count", text("null")), json!(null)); + assert_eq!(params.convert("anything", text("null")), json!(null)); + } - let converted = schemas - .convert_params_with_schema("convert", vec![("value".to_string(), "NULL".to_string())]); + #[test] + fn nullable_enum_param_coerces_literal_null() { + // An enum that includes `null` admits a null value, so a literal "null" + // must coerce to JSON null (matching Python's `extract_types_from_schema`, + // which infers `null` from the enum values), while a non-null enum keeps + // "null" as a string. + let params = ToolSchema::from_schema(&json!({ + "type": "object", + "properties": { + "mode": { "enum": [null, "auto"] }, + "color": { "enum": ["red", "green"] } + } + })); - assert_eq!(converted.get("value"), Some(&json!(null))); + assert_eq!(params.convert("mode", text("null")), json!(null)); + assert_eq!(params.convert("mode", text("auto")), json!("auto")); + assert_eq!(params.convert("color", text("null")), json!("null")); } #[test] @@ -841,7 +872,7 @@ mod tests { "user_id": 42, "urgent": true, "note": "Please leave at front desk.", - "nil": null, + "nil": "NULL", "shipping": { "city": "Singapore", "zip": 18956 diff --git a/rust/src/tool-parser/src/qwen_coder.rs b/rust/src/parser/src/tool/qwen_coder.rs similarity index 68% rename from rust/src/tool-parser/src/qwen_coder.rs rename to rust/src/parser/src/tool/qwen_coder.rs index c8d21957d7a6..c2e1b0c794cf 100644 --- a/rust/src/tool-parser/src/qwen_coder.rs +++ b/rust/src/parser/src/tool/qwen_coder.rs @@ -5,9 +5,9 @@ use winnow::stream::Partial; use winnow::token::{literal, take_until}; use super::parameters::ToolSchemas; -use super::utils::{parse_buffered_event, safe_text_len, xml_unescape}; -use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput}; -use crate::Tool; +use super::utils::{MarkerScanState, parse_buffered_event, safe_text_len, take_until_marker}; +use super::{Result, StructuralTagModel, ToolCallDelta, ToolParser, ToolParserOutput}; +use crate::tool::Tool; const TOOL_CALL_START: &str = ""; const TOOL_CALL_END: &str = ""; @@ -18,10 +18,10 @@ const PARAMETER_END: &str = ""; type QwenCoderInput<'i> = Partial<&'i str>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum QwenCoderMode { Text, - ToolCall, + ToolCall { end_marker_scan: MarkerScanState }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -74,16 +74,20 @@ impl Qwen3CoderToolParser { fn apply_event(&mut self, event: QwenCoderEvent, output: &mut ToolParserOutput) -> Result<()> { match event { QwenCoderEvent::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(&self.buffer[..consumed_len]); + } + QwenCoderEvent::ToolCallStart => { + self.mode = QwenCoderMode::ToolCall { + end_marker_scan: MarkerScanState::default(), + }; } - QwenCoderEvent::ToolCallStart => self.mode = QwenCoderMode::ToolCall, QwenCoderEvent::ToolCall { name, raw_params } => { self.mode = QwenCoderMode::Text; let arguments = self.tool_parameters.convert_params_with_schema(&name, raw_params); let arguments = serde_json::to_string(&arguments) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -109,11 +113,15 @@ impl ToolParser for Qwen3CoderToolParser { Ok(Box::new(Self::new(tools))) } + fn structural_tag_model(&self) -> Option { + Some(StructuralTagModel::Qwen3Coder) + } + fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { self.buffer.push_str(chunk); while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| { - parse_next_qwen_coder_event(input, self.mode) + parse_next_qwen_coder_event(input, &mut self.mode) })? { self.apply_event(event, output)?; self.buffer.drain(..consumed_len); @@ -125,10 +133,12 @@ impl ToolParser for Qwen3CoderToolParser { fn finish(&mut self) -> Result { let mut output = ToolParserOutput::default(); if !self.buffer.is_empty() { - if self.mode == QwenCoderMode::ToolCall || self.buffer.starts_with(TOOL_CALL_START) { + if matches!(self.mode, QwenCoderMode::ToolCall { .. }) + || self.buffer.starts_with(TOOL_CALL_START) + { return Err(parsing_failed!("incomplete Qwen Coder tool call")); } - output.normal_text.push_str(&self.buffer); + output.push_text(&self.buffer); } let _ = self.reset(); Ok(output) @@ -142,11 +152,11 @@ impl ToolParser for Qwen3CoderToolParser { /// Parse a Qwen Coder event for the current parser mode. fn parse_next_qwen_coder_event( input: &mut QwenCoderInput<'_>, - mode: QwenCoderMode, + mode: &mut QwenCoderMode, ) -> ModalResult { match mode { QwenCoderMode::Text => parse_text_event(input), - QwenCoderMode::ToolCall => tool_call_event(input), + QwenCoderMode::ToolCall { end_marker_scan } => tool_call_event(input, end_marker_scan), } } @@ -166,10 +176,13 @@ fn safe_text_event(input: &mut QwenCoderInput<'_>) -> ModalResult) -> ModalResult { +fn tool_call_event( + input: &mut QwenCoderInput<'_>, + end_marker_scan: &mut MarkerScanState, +) -> ModalResult { let (body,) = seq!( _: ws0, - take_until(0.., TOOL_CALL_END), + take_until_marker(TOOL_CALL_END, end_marker_scan), _: literal(TOOL_CALL_END), ) .parse_next(input)?; @@ -201,12 +214,12 @@ fn parameter(input: &mut &str) -> ModalResult<(String, String)> { _: literal(PARAMETER_START), take_until(1.., ">"), _: ">", - take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline).map(xml_unescape), + take_until(0.., PARAMETER_END).map(trim_one_wrapping_newline), _: literal(PARAMETER_END), ) .parse_next(input)?; - Ok((name.to_string(), value.into_owned())) + Ok((name.to_string(), value.to_string())) } /// Parse a Qwen Coder tool-call body. @@ -227,9 +240,9 @@ mod tests { use serde_json::{Value, json}; use thiserror_ext::AsReport; - use super::{Qwen3CoderToolParser, ToolParser}; - use crate::ToolParserTestExt as _; - use crate::test_utils::{collect_stream, split_by_chars, test_tools}; + use super::{Qwen3CoderToolParser, StructuralTagModel, ToolParser}; + use crate::tool::test_utils::{collect_stream, split_by_chars, test_tools}; + use crate::tool::{ToolParserOutput, ToolParserTestExt as _}; fn build_tool_call(function_name: &str, params: &[(&str, &str)]) -> String { let params = params @@ -240,13 +253,23 @@ mod tests { format!("\n\n{params}\n\n") } + #[test] + fn qwen_coder_exposes_structural_tag_model() { + let parser = Qwen3CoderToolParser::new(&test_tools()); + + assert_eq!( + parser.structural_tag_model(), + Some(StructuralTagModel::Qwen3Coder) + ); + } + #[test] fn qwen_coder_parse_complete_without_tool_call_keeps_text() { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = parser.parse_complete("Hello, world!").unwrap(); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -259,11 +282,11 @@ mod tests { )) .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF", "date": "2026-04-29" @@ -280,8 +303,8 @@ mod tests { ); let output = parser.parse_complete(&output).unwrap(); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -300,9 +323,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "whole": 5.0, "flag": true, @@ -318,10 +341,10 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = parser.parse_complete(&build_tool_call("get_weather", &[])).unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({}) ); } @@ -348,10 +371,10 @@ mod tests { ) .unwrap(); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("calculate_area")); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("calculate_area")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "shape": "rectangle", "dimensions": { "width": 10, "height": 20 }, @@ -373,9 +396,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "payload": { "nested": { @@ -403,9 +426,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "html_content": r#"
Hello
"#, "xml_snippet": r#""#, @@ -414,7 +437,7 @@ mod tests { } #[test] - fn qwen_coder_parse_complete_unescapes_literal_closing_tags_in_parameter_value() { + fn qwen_coder_parse_complete_preserves_raw_closing_tag_text_in_parameter_value() { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = parser .parse_complete(&build_tool_call( @@ -429,11 +452,11 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ - "location": "杭州 ", + "location": "杭州 </parameter></function></tool_call>", "date": "2026-05-08", }) ); @@ -449,9 +472,9 @@ mod tests { )) .unwrap(); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "data": { "key": "value", "count": 42 }, }) @@ -472,11 +495,11 @@ mod tests { ], ); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } @@ -496,8 +519,8 @@ mod tests { ], ); - assert_eq!(output.normal_text, "Thinking... "); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.normal_text(), "Thinking... "); + assert_eq!(output.calls().len(), 1); } #[test] @@ -505,8 +528,8 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &["Hello, ", "world!"]); - assert_eq!(output.normal_text, "Hello, world!"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "Hello, world!"); + assert!(output.calls().is_empty()); } #[test] @@ -519,17 +542,17 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &[&text]); - assert_eq!(output.calls.len(), 2); - assert_eq!(output.calls[0].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[1].name.as_deref(), Some("get_weather")); - assert_eq!(output.calls[0].tool_index, 0); - assert_eq!(output.calls[1].tool_index, 1); + assert_eq!(output.calls().len(), 2); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[1].name.as_deref(), Some("get_weather")); + assert_eq!(output.calls()[0].tool_index, 0); + assert_eq!(output.calls()[1].tool_index, 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "location": "NYC" }) ); } @@ -546,16 +569,16 @@ mod tests { let output = collect_stream(&mut parser, &chunks); assert_eq!( - output.normal_text, + output.normal_text(), "I'll check two cities.Between calls.Done." ); - assert_eq!(output.calls.len(), 2); + assert_eq!(output.calls().len(), 2); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "city": "Dallas", "state": "TX" }) ); assert_eq!( - serde_json::from_str::(&output.calls[1].arguments).unwrap(), + serde_json::from_str::(&output.calls()[1].arguments).unwrap(), json!({ "city": "Orlando", "state": "FL" }) ); } @@ -567,13 +590,75 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let output = collect_stream(&mut parser, &chunks); - assert_eq!(output.calls.len(), 1); + assert_eq!(output.calls().len(), 1); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "SF" }) ); } + #[test] + fn qwen_coder_streaming_handles_end_token_split_across_chunks() { + let mut parser = Qwen3CoderToolParser::new(&test_tools()); + let output = parser + .parse_chunk( + "\n\ + \n\ + SF\n\ + \n\ + ").unwrap()); + output.append(parser.finish().unwrap()); + let output = output.coalesce(); + + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "location": "SF" }) + ); + } + + #[test] + fn qwen_coder_streaming_buffers_long_body_until_end_marker() { + let long_location = format!("SF-{}", "x".repeat(8192)); + let text = build_tool_call("get_weather", &[("location", &long_location)]); + let split_at = text.len() - "_call>".len(); + let (body_with_partial_end, end_suffix) = text.split_at(split_at); + let chunks = split_by_chars(body_with_partial_end, 31); + let mut parser = Qwen3CoderToolParser::new(&test_tools()); + let mut output = ToolParserOutput::default(); + + assert_eq!(end_suffix, "_call>"); + + for chunk in chunks { + let chunk_output = parser.parse_chunk(chunk).unwrap(); + assert!(chunk_output.normal_text().is_empty()); + assert!(chunk_output.calls().is_empty()); + output.append(chunk_output); + } + + output.append(parser.parse_chunk(end_suffix).unwrap()); + output.append(parser.finish().unwrap()); + let output = output.coalesce(); + + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), + json!({ "location": long_location }) + ); + } + #[test] fn qwen_coder_streaming_does_not_emit_incomplete_tool_call() { let mut parser = Qwen3CoderToolParser::new(&test_tools()); @@ -581,8 +666,8 @@ mod tests { .parse_chunk("\n\nSF") .unwrap(); - assert!(output.normal_text.is_empty()); - assert!(output.calls.is_empty()); + assert!(output.normal_text().is_empty()); + assert!(output.calls().is_empty()); } #[test] @@ -600,7 +685,8 @@ mod tests { let mut parser = Qwen3CoderToolParser::new(&test_tools()); let error = parser.parse_chunk("\n\n").unwrap_err(); - expect!["tool parser parsing failed: "].assert_eq(&error.to_report_string()); + expect![[r#"tool parser parsing failed: near "\n\n": "#]] + .assert_eq(&error.to_report_string()); } #[test] @@ -612,7 +698,7 @@ mod tests { ) .unwrap_err(); - expect!["tool parser parsing failed: "].assert_eq(&error.to_report_string()); + expect![[r#"tool parser parsing failed: near "\n\nSF\n": "#]].assert_eq(&error.to_report_string()); } #[test] @@ -625,7 +711,7 @@ mod tests { .unwrap(); assert_eq!( - serde_json::from_str::(&output.calls[0].arguments).unwrap(), + serde_json::from_str::(&output.calls()[0].arguments).unwrap(), json!({ "location": "Hangzhou" }) ); } diff --git a/rust/src/tool-parser/src/test_utils.rs b/rust/src/parser/src/tool/test_utils.rs similarity index 96% rename from rust/src/tool-parser/src/test_utils.rs rename to rust/src/parser/src/tool/test_utils.rs index 70178756e4c9..c160977479c8 100644 --- a/rust/src/tool-parser/src/test_utils.rs +++ b/rust/src/parser/src/tool/test_utils.rs @@ -1,7 +1,7 @@ use serde_json::json; use super::{ToolParser, ToolParserOutput}; -use crate::{Tool, ToolParserTestExt as _}; +use crate::tool::Tool; /// Build a reusable set of function tools for parser unit tests. pub fn test_tools() -> Vec { @@ -87,10 +87,10 @@ pub fn test_tools() -> Vec { pub fn collect_stream(parser: &mut T, chunks: &[&str]) -> ToolParserOutput { let mut output = ToolParserOutput::default(); for chunk in chunks { - output.append(parser.parse_chunk(chunk).unwrap()); + parser.parse_into(chunk, &mut output).unwrap(); } output.append(parser.finish().unwrap()); - output.coalesce_calls() + output.coalesce() } /// Split text into chunks containing at most `chunk_chars` Unicode scalar diff --git a/rust/src/parser/src/tool/tests.rs b/rust/src/parser/src/tool/tests.rs new file mode 100644 index 000000000000..5a79e7642032 --- /dev/null +++ b/rust/src/parser/src/tool/tests.rs @@ -0,0 +1,169 @@ +use super::{Result, Tool, ToolCallDelta, ToolParser, ToolParserEvent, ToolParserOutput}; +use crate::tool::ToolParserTestExt as _; + +struct DefaultParser; + +impl ToolParser for DefaultParser { + fn create(_tools: &[Tool]) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self)) + } + + fn parse_into(&mut self, _chunk: &str, _output: &mut ToolParserOutput) -> Result<()> { + Ok(()) + } + + fn finish(&mut self) -> Result { + Ok(ToolParserOutput::default()) + } + + fn reset(&mut self) -> String { + String::new() + } +} + +#[test] +fn tool_parser_does_not_preserve_special_tokens_by_default() { + let parser = DefaultParser; + + assert!(!parser.preserve_special_tokens()); +} + +#[test] +fn tool_parser_output_coalesces_adjacent_text_events() { + let mut output = ToolParserOutput::default(); + output.push_text("hello"); + output.push_text(" "); + output.push_text("world"); + output.push_call(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }); + output.push_text("!"); + + assert_eq!( + output.events, + vec![ + ToolParserEvent::Text("hello world".to_string()), + ToolParserEvent::ToolCall(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }), + ToolParserEvent::Text("!".to_string()), + ] + ); +} + +#[test] +fn tool_parser_output_append_coalesces_adjacent_text_events() { + let mut output = ToolParserOutput::default(); + output.push_text("hello"); + + let mut other = ToolParserOutput::default(); + other.push_text(" "); + other.push_text("world"); + output.append(other); + + let mut after_call = ToolParserOutput::default(); + after_call.push_call(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }); + after_call.push_text("!"); + output.append(after_call); + + assert_eq!( + output.events, + vec![ + ToolParserEvent::Text("hello world".to_string()), + ToolParserEvent::ToolCall(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }), + ToolParserEvent::Text("!".to_string()), + ] + ); +} + +#[test] +fn default_parse_complete_delegates_through_parse_chunk_and_finish() { + struct StreamingParser; + + impl ToolParser for StreamingParser { + fn create(_tools: &[Tool]) -> Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self)) + } + + fn parse_into(&mut self, _chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + output.push_text("prefix "); + for call in [ + ToolCallDelta { + tool_index: 0, + name: Some("weather".to_string()), + arguments: "{\"location\":".to_string(), + }, + ToolCallDelta { + tool_index: 0, + name: None, + arguments: "\"Paris\"".to_string(), + }, + ToolCallDelta { + tool_index: 1, + name: Some("time".to_string()), + arguments: "{\"timezone\":".to_string(), + }, + ] { + output.push_call(call); + } + Ok(()) + } + + fn finish(&mut self) -> Result { + let mut output = ToolParserOutput::default(); + output.push_text("suffix"); + output.push_call(ToolCallDelta { + tool_index: 0, + name: None, + arguments: "}".to_string(), + }); + output.push_call(ToolCallDelta { + tool_index: 1, + name: None, + arguments: "\"UTC\"}".to_string(), + }); + Ok(output) + } + + fn reset(&mut self) -> String { + String::new() + } + } + + let mut parser = StreamingParser; + let output = parser.parse_complete("ignored").unwrap(); + assert_eq!(output.normal_text(), "prefix suffix"); + assert_eq!( + output.calls().into_iter().cloned().collect::>(), + vec![ + ToolCallDelta { + tool_index: 0, + name: Some("weather".to_string()), + arguments: "{\"location\":\"Paris\"}".to_string(), + }, + ToolCallDelta { + tool_index: 1, + name: Some("time".to_string()), + arguments: "{\"timezone\":\"UTC\"}".to_string(), + }, + ] + ); +} diff --git a/rust/src/parser/src/unified/combined.rs b/rust/src/parser/src/unified/combined.rs new file mode 100644 index 000000000000..3b6abb4b9329 --- /dev/null +++ b/rust/src/parser/src/unified/combined.rs @@ -0,0 +1,320 @@ +//! Adapter that combines reasoning and tool parsers. + +use vllm_tokenizer::DynTokenizer; + +use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput}; +use crate::reasoning::ReasoningParser; +use crate::tool::{StructuralTagModel, Tool, ToolParser, ToolParserOutput}; + +/// Unified parser that composes existing reasoning and tool parsers. +pub struct CombinedParser { + reasoning: Option>, + tool: Option>, +} + +impl CombinedParser { + /// Create a combined parser from optional reasoning and tool parsers. + pub fn new( + reasoning: Option>, + tool: Option>, + ) -> Self { + Self { reasoning, tool } + } + + /// Create a text-only combined parser. + pub fn plain_text_only() -> Self { + Self { + reasoning: None, + tool: None, + } + } + + fn parse_tool(&mut self, content: &str, output: &mut UnifiedParserOutput) -> Result<()> { + let Some(tool) = self.tool.as_mut() else { + output.push_text(content); + return Ok(()); + }; + + // Preserve any tool output that was already produced before the error. + let mut tool_output = ToolParserOutput::default(); + let result = tool.parse_into(content, &mut tool_output); + output.append_tool_output(tool_output); + result?; + + Ok(()) + } + + fn flush_tool(&mut self) -> Result { + let Some(tool) = self.tool.as_mut() else { + return Ok(UnifiedParserOutput::default()); + }; + + let output = tool.finish()?; + let mut unified = UnifiedParserOutput::default(); + unified.append_tool_output(output); + Ok(unified) + } +} + +impl UnifiedParser for CombinedParser { + fn create(_tools: &[Tool], _tokenizer: DynTokenizer) -> Result> + where + Self: Sized + 'static, + { + Err(UnifiedParserError::CombinedParserConstructor) + } + + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { + if let Some(reasoning) = self.reasoning.as_mut() { + reasoning.initialize(prompt_token_ids)?; + } + Ok(()) + } + + fn preserve_special_tokens(&self) -> bool { + self.reasoning.as_ref().is_some_and(|parser| parser.preserve_special_tokens()) + || self.tool.as_ref().is_some_and(|parser| parser.preserve_special_tokens()) + } + + fn structural_tag_model(&self) -> Option { + self.tool.as_ref().and_then(|parser| parser.structural_tag_model()) + } + + fn tool_call_id(&self, tool_index: usize) -> Option<&str> { + self.tool.as_ref().and_then(|parser| parser.tool_call_id(tool_index)) + } + + fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()> { + let Some(reasoning) = self.reasoning.as_mut() else { + return self.parse_tool(delta, output); + }; + + let reasoning_delta = reasoning.push(delta)?; + if let Some(reasoning) = reasoning_delta.reasoning { + output.push_reasoning(reasoning); + } + if let Some(content) = reasoning_delta.content { + self.parse_tool(&content, output)?; + } + Ok(()) + } + + fn finish(&mut self) -> Result { + let mut output = UnifiedParserOutput::default(); + if let Some(reasoning) = self.reasoning.as_mut() { + let reasoning_delta = reasoning.finish()?; + if let Some(reasoning) = reasoning_delta.reasoning { + output.push_reasoning(reasoning); + } + if let Some(content) = reasoning_delta.content { + self.parse_tool(&content, &mut output)?; + } + } + output.append(self.flush_tool()?); + Ok(output) + } + + fn reset(&mut self) -> String { + self.tool.as_mut().map_or_else(String::new, |parser| parser.reset()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vllm_tokenizer::test_utils::TestTokenizer; + + use super::CombinedParser; + use crate::reasoning::{Qwen3ReasoningParser, ReasoningDelta, ReasoningParser}; + use crate::tool::{Qwen3XmlToolParser, Tool, ToolParser}; + use crate::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput}; + + fn tokenizer() -> TestTokenizer { + TestTokenizer::new() + .with_regular_token("", 256) + .with_regular_token("", 257) + } + + fn test_tools() -> Vec { + vec![Tool { + name: "get_weather".to_string(), + description: None, + parameters: serde_json::json!({ + "type": "object", + "properties": { + "location": { "type": "string" } + }, + }), + strict: None, + }] + } + + fn collect(parser: &mut dyn UnifiedParser, chunks: &[&str]) -> UnifiedParserOutput { + let mut output = UnifiedParserOutput::default(); + for chunk in chunks { + parser.parse_into(chunk, &mut output).unwrap(); + } + output.append(parser.finish().unwrap()); + output + } + + struct PreserveReasoningParser; + + impl ReasoningParser for PreserveReasoningParser { + fn create( + _tokenizer: vllm_tokenizer::DynTokenizer, + ) -> crate::reasoning::Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self)) + } + + fn preserve_special_tokens(&self) -> bool { + true + } + + fn push(&mut self, delta: &str) -> crate::reasoning::Result { + Ok(ReasoningDelta { + reasoning: None, + content: Some(delta.to_string()), + }) + } + } + + struct PreserveToolParser; + + impl ToolParser for PreserveToolParser { + fn create(_tools: &[Tool]) -> crate::tool::Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self)) + } + + fn preserve_special_tokens(&self) -> bool { + true + } + + fn parse_into( + &mut self, + chunk: &str, + output: &mut crate::tool::ToolParserOutput, + ) -> crate::tool::Result<()> { + output.push_text(chunk); + Ok(()) + } + + fn finish(&mut self) -> crate::tool::Result { + Ok(crate::tool::ToolParserOutput::default()) + } + + fn reset(&mut self) -> String { + String::new() + } + } + + struct PartialThenErrorToolParser; + + impl ToolParser for PartialThenErrorToolParser { + fn create(_tools: &[Tool]) -> crate::tool::Result> + where + Self: Sized + 'static, + { + Ok(Box::new(Self)) + } + + fn parse_into( + &mut self, + _chunk: &str, + output: &mut crate::tool::ToolParserOutput, + ) -> crate::tool::Result<()> { + output.push_text("committed"); + Err(crate::tool::ToolParserError::ParsingFailed { + message: "synthetic failure".to_string(), + }) + } + + fn finish(&mut self) -> crate::tool::Result { + Ok(crate::tool::ToolParserOutput::default()) + } + + fn reset(&mut self) -> String { + String::new() + } + } + + #[test] + fn combined_parser_emits_reasoning_and_text() { + let tokenizer = Arc::new(tokenizer()); + let reasoning = Qwen3ReasoningParser::create(tokenizer).unwrap(); + let mut parser = CombinedParser::new(Some(reasoning), None); + + let output = collect(&mut parser, &["workanswer"]); + + assert_eq!( + output.events, + vec![ + UnifiedParserEvent::Reasoning("work".to_string()), + UnifiedParserEvent::Text("answer".to_string()), + ] + ); + } + + #[test] + fn combined_parser_emits_tool_calls_from_visible_content() { + let tool = Qwen3XmlToolParser::create(&test_tools()).unwrap(); + let mut parser = CombinedParser::new(None, Some(tool)); + assert!(matches!( + parser.structural_tag_model(), + Some(crate::tool::StructuralTagModel::Qwen3) + )); + + let output = collect( + &mut parser, + &[r#" +{"name":"get_weather","arguments":{"location":"Paris"}} +"#], + ); + + assert_eq!( + output.events, + vec![ + UnifiedParserEvent::ToolCall(crate::tool::ToolCallDelta { + tool_index: 0, + name: Some("get_weather".to_string()), + arguments: String::new(), + }), + UnifiedParserEvent::ToolCall(crate::tool::ToolCallDelta { + tool_index: 0, + name: None, + arguments: r#"{"location":"Paris"}"#.to_string(), + }), + ] + ); + } + + #[test] + fn combined_parser_preserves_tool_output_on_parse_error() { + let mut parser = CombinedParser::new(None, Some(Box::new(PartialThenErrorToolParser))); + let mut output = UnifiedParserOutput::default(); + + let error = parser.parse_into("bad", &mut output).unwrap_err(); + + assert!(matches!(error, crate::unified::UnifiedParserError::Tool(_))); + assert_eq!( + output.events, + vec![UnifiedParserEvent::Text("committed".to_string())] + ); + } + + #[test] + fn combined_parser_preserves_special_tokens_when_either_inner_parser_needs_it() { + let mut parser = CombinedParser::new(Some(Box::new(PreserveReasoningParser)), None); + assert!(parser.preserve_special_tokens()); + + parser = CombinedParser::new(None, Some(Box::new(PreserveToolParser))); + assert!(parser.preserve_special_tokens()); + } +} diff --git a/rust/src/tool-parser/src/gemma4.rs b/rust/src/parser/src/unified/gemma4.rs similarity index 65% rename from rust/src/tool-parser/src/gemma4.rs rename to rust/src/parser/src/unified/gemma4.rs index 2fad84574c02..51a1de12c2fe 100644 --- a/rust/src/tool-parser/src/gemma4.rs +++ b/rust/src/parser/src/unified/gemma4.rs @@ -1,4 +1,5 @@ use serde_json::{Map, Number, Value}; +use vllm_tokenizer::DynTokenizer; use winnow::ascii::multispace0 as ws0; use winnow::combinator::{alt, delimited, eof, opt, separated, seq, terminated}; use winnow::error::{ContextError, ErrMode, ModalResult}; @@ -6,10 +7,15 @@ use winnow::prelude::*; use winnow::stream::{Partial, Stream}; use winnow::token::{literal, take_till, take_until}; -use super::utils::{incomplete, parse_buffered_event, partial_prefix_len, safe_text_len}; -use super::{Result, ToolCallDelta, ToolParser, ToolParserOutput}; -use crate::Tool; +use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput}; +use crate::reasoning::last_reasoning_boundary; +use crate::tool::{Tool, ToolCallDelta}; +use crate::unified::parsing_failed; +use crate::utils::{incomplete, parse_buffered_event, partial_prefix_len, safe_text_len_mul}; +const REASONING_START: &str = "<|channel>thought\n"; +const CHANNEL_START: &str = "<|channel>"; +const CHANNEL_END: &str = ""; const TOOL_CALL_START: &str = "<|tool_call>"; const TOOL_CALL_END: &str = ""; const STRING_DELIM: &str = "<|\"|>"; @@ -20,6 +26,9 @@ type Gemma4Input<'i> = Partial<&'i str>; #[derive(Debug, Clone, PartialEq)] enum Gemma4Event { Text { len: usize }, + Reasoning { len: usize }, + ReasoningStart, + ReasoningEnd, ToolCallStart, ToolCallHeader { name: String }, ToolCall { args: Map }, @@ -35,6 +44,7 @@ struct Gemma4ArgsScanState { enum Gemma4Mode { #[default] Text, + Reasoning, Header, ToolCall { name: String, @@ -42,36 +52,62 @@ enum Gemma4Mode { }, } -/// Tool parser for Google Gemma4 models. +/// Unified parser for Google Gemma4 models. /// /// Original Python implementation: -/// +/// /// -/// Handles the Gemma4 function call format: +/// Handles Gemma4 reasoning and function-call formats: +/// +/// `<|channel>thought\nreasoning` /// /// `<|tool_call>call:func_name{key:<|"|>value<|"|>}` /// /// Arguments are emitted only after a full Gemma4 tool call is parsed. -pub struct Gemma4ToolParser { +pub struct Gemma4UnifiedParser { buffer: String, mode: Gemma4Mode, emitted_tool_count: usize, + tokenizer: DynTokenizer, + channel_start_token_id: u32, + channel_end_token_id: u32, } -impl Gemma4ToolParser { - fn new(_tools: &[Tool]) -> Self { - Self { +impl Gemma4UnifiedParser { + /// Create a Gemma4 parser. + pub fn new(_tools: &[Tool], tokenizer: DynTokenizer) -> Result { + let channel_start_token_id = tokenizer.token_to_id(CHANNEL_START).ok_or_else(|| { + UnifiedParserError::MissingToken { + token: CHANNEL_START.to_string(), + } + })?; + let channel_end_token_id = + tokenizer + .token_to_id(CHANNEL_END) + .ok_or_else(|| UnifiedParserError::MissingToken { + token: CHANNEL_END.to_string(), + })?; + + Ok(Self { buffer: String::new(), mode: Gemma4Mode::default(), emitted_tool_count: 0, - } + channel_start_token_id, + channel_end_token_id, + tokenizer, + }) } - fn apply_event(&mut self, event: Gemma4Event, output: &mut ToolParserOutput) -> Result<()> { + fn apply_event(&mut self, event: Gemma4Event, output: &mut UnifiedParserOutput) -> Result<()> { match event { Gemma4Event::Text { len: consumed_len } => { - output.normal_text.push_str(&self.buffer[..consumed_len]); + output.push_text(self.buffer[..consumed_len].to_string()); + } + Gemma4Event::Reasoning { len: consumed_len } => { + output.push_reasoning(self.buffer[..consumed_len].to_string()); } + Gemma4Event::ReasoningStart => self.mode = Gemma4Mode::Reasoning, + Gemma4Event::ReasoningEnd => self.mode = Gemma4Mode::Text, Gemma4Event::ToolCallStart => self.mode = Gemma4Mode::Header, Gemma4Event::ToolCallHeader { name } => { self.mode = Gemma4Mode::ToolCall { @@ -89,7 +125,7 @@ impl Gemma4ToolParser { let arguments = serde_json::to_string(&args) .map_err(|error| parsing_failed!("failed to serialize arguments: {}", error))?; - output.calls.push(ToolCallDelta { + output.push_call(ToolCallDelta { tool_index: self.emitted_tool_count, name: Some(name), arguments, @@ -100,9 +136,24 @@ impl Gemma4ToolParser { Ok(()) } + fn initialize_mode(&mut self, prompt_token_ids: &[u32]) { + self.mode = match last_reasoning_boundary( + prompt_token_ids, + self.channel_start_token_id, + self.channel_end_token_id, + self.tokenizer.as_ref(), + ) { + Some(true) => Gemma4Mode::Reasoning, + Some(false) | None => Gemma4Mode::Text, + }; + } + fn reset(&mut self) -> String { let raw = match std::mem::replace(&mut self.mode, Gemma4Mode::Text) { Gemma4Mode::Text => std::mem::take(&mut self.buffer), + Gemma4Mode::Reasoning => { + format!("{}{}", REASONING_START, std::mem::take(&mut self.buffer)) + } Gemma4Mode::Header => { format!("{}{}", TOOL_CALL_START, std::mem::take(&mut self.buffer)) } @@ -122,19 +173,26 @@ impl Gemma4ToolParser { } } -impl ToolParser for Gemma4ToolParser { - fn create(tools: &[Tool]) -> Result> +impl UnifiedParser for Gemma4UnifiedParser { + fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result> where Self: Sized + 'static, { - Ok(Box::new(Self::new(tools))) + Self::new(tools, tokenizer).map(|parser| Box::new(parser) as Box) + } + + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { + self.buffer.clear(); + self.emitted_tool_count = 0; + self.initialize_mode(prompt_token_ids); + Ok(()) } fn preserve_special_tokens(&self) -> bool { true } - fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> { + fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> { self.buffer.push_str(chunk); while let Some((event, consumed_len)) = { @@ -149,11 +207,12 @@ impl ToolParser for Gemma4ToolParser { Ok(()) } - fn finish(&mut self) -> Result { - let mut output = ToolParserOutput::default(); + fn finish(&mut self) -> Result { + let mut output = UnifiedParserOutput::default(); match &self.mode { - Gemma4Mode::Text => output.normal_text.push_str(&self.buffer), + Gemma4Mode::Text => output.push_text(std::mem::take(&mut self.buffer)), + Gemma4Mode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)), Gemma4Mode::Header | Gemma4Mode::ToolCall { .. } => { return Err(parsing_failed!("incomplete Gemma4 tool call")); } @@ -164,7 +223,7 @@ impl ToolParser for Gemma4ToolParser { } fn reset(&mut self) -> String { - Gemma4ToolParser::reset(self) + Gemma4UnifiedParser::reset(self) } } @@ -175,6 +234,7 @@ fn parse_next_gemma4_event( ) -> ModalResult { match mode { Gemma4Mode::Text => parse_text_event(input), + Gemma4Mode::Reasoning => parse_reasoning_event(input), Gemma4Mode::Header => tool_call_header_event(input), Gemma4Mode::ToolCall { args_scan, .. } => tool_call_args_event(input, args_scan), } @@ -182,7 +242,32 @@ fn parse_next_gemma4_event( /// Parse a Gemma4 text-mode event. fn parse_text_event(input: &mut Gemma4Input<'_>) -> ModalResult { - alt((tool_call_start_event, safe_text_event)).parse_next(input) + alt(( + reasoning_start_event, + tool_call_start_event, + safe_text_event, + )) + .parse_next(input) +} + +/// Parse a Gemma4 reasoning-mode event. +fn parse_reasoning_event(input: &mut Gemma4Input<'_>) -> ModalResult { + alt(( + reasoning_end_event, + tool_call_start_event, + safe_reasoning_event, + )) + .parse_next(input) +} + +/// Parse a Gemma4 reasoning start marker. +fn reasoning_start_event(input: &mut Gemma4Input<'_>) -> ModalResult { + literal(REASONING_START).value(Gemma4Event::ReasoningStart).parse_next(input) +} + +/// Parse a Gemma4 reasoning end marker. +fn reasoning_end_event(input: &mut Gemma4Input<'_>) -> ModalResult { + literal(CHANNEL_END).value(Gemma4Event::ReasoningEnd).parse_next(input) } /// Parse a Gemma4 tool-call start marker. @@ -226,7 +311,14 @@ fn gemma4_tool_name(input: &mut Gemma4Input<'_>) -> ModalResult { /// Parse a safe text run before the next Gemma4 marker. fn safe_text_event(input: &mut Gemma4Input<'_>) -> ModalResult { - safe_text_len(input, TOOL_CALL_START).map(|len| Gemma4Event::Text { len }) + safe_text_len_mul(input, &[REASONING_START, TOOL_CALL_START]) + .map(|len| Gemma4Event::Text { len }) +} + +/// Parse a safe reasoning run before the next Gemma4 marker. +fn safe_reasoning_event(input: &mut Gemma4Input<'_>) -> ModalResult { + safe_text_len_mul(input, &[CHANNEL_END, TOOL_CALL_START]) + .map(|len| Gemma4Event::Reasoning { len }) } /// Parse raw Gemma4 arguments through the first end marker outside a Gemma string. @@ -418,17 +510,94 @@ fn parse_gemma4_scalar(value: &str) -> Value { #[cfg(test)] mod tests { + use std::sync::Arc; + use serde_json::{Value, json}; use thiserror_ext::AsReport; + use vllm_tokenizer::test_utils::TestTokenizer; use winnow::combinator::{eof, terminated}; use winnow::error::ErrMode; use winnow::prelude::*; use super::{ - Gemma4ToolParser, ToolCallDelta, ToolParser, ToolParserOutput, gemma4_array_content, - parse_gemma4_args, + CHANNEL_END, CHANNEL_START, Gemma4UnifiedParser, ToolCallDelta, UnifiedParser, + UnifiedParserError, UnifiedParserOutput, gemma4_array_content, parse_gemma4_args, }; - use crate::{Tool, ToolParserTestExt as _}; + use crate::tool::Tool; + use crate::unified::{UnifiedParserEvent, parsing_failed}; + + const CHANNEL_START_ID: u32 = 256; + const CHANNEL_END_ID: u32 = 257; + const TURN_BOUNDARY_ID: u32 = 258; + + fn tokenizer() -> TestTokenizer { + TestTokenizer::new() + .with_special_token(CHANNEL_START, CHANNEL_START_ID) + .with_special_token(CHANNEL_END, CHANNEL_END_ID) + .with_special_token("", TURN_BOUNDARY_ID) + } + + trait UnifiedParserTestExt { + fn parse_chunk(&mut self, chunk: &str) -> super::Result; + fn parse_complete(&mut self, text: &str) -> super::Result; + } + + impl UnifiedParserTestExt for Gemma4UnifiedParser { + fn parse_chunk(&mut self, chunk: &str) -> super::Result { + let mut output = UnifiedParserOutput::default(); + self.parse_into(chunk, &mut output)?; + Ok(output) + } + + fn parse_complete(&mut self, text: &str) -> super::Result { + let mut output = self.parse_chunk(text)?; + output.append(self.finish()?); + Ok(output) + } + } + + trait UnifiedOutputTestExt { + fn normal_text(&self) -> String; + fn reasoning_text(&self) -> String; + fn calls(&self) -> Vec<&ToolCallDelta>; + fn coalesce(self) -> Self; + } + + impl UnifiedOutputTestExt for UnifiedParserOutput { + fn normal_text(&self) -> String { + self.events + .iter() + .filter_map(|event| match event { + UnifiedParserEvent::Text(text) => Some(text.as_str()), + UnifiedParserEvent::Reasoning(_) | UnifiedParserEvent::ToolCall(_) => None, + }) + .collect() + } + + fn reasoning_text(&self) -> String { + self.events + .iter() + .filter_map(|event| match event { + UnifiedParserEvent::Reasoning(text) => Some(text.as_str()), + UnifiedParserEvent::Text(_) | UnifiedParserEvent::ToolCall(_) => None, + }) + .collect() + } + + fn calls(&self) -> Vec<&ToolCallDelta> { + self.events + .iter() + .filter_map(|event| match event { + UnifiedParserEvent::Text(_) | UnifiedParserEvent::Reasoning(_) => None, + UnifiedParserEvent::ToolCall(call) => Some(call), + }) + .collect() + } + + fn coalesce(self) -> Self { + self + } + } fn parse_gemma4_array(array: &str) -> super::Result> { let mut input = array; @@ -494,18 +663,35 @@ mod tests { ] } - fn collect_stream(chunks: &[&str]) -> ToolParserOutput { - let mut parser = Gemma4ToolParser::new(&test_tools()); - let mut output = ToolParserOutput::default(); + fn test_parser() -> Gemma4UnifiedParser { + Gemma4UnifiedParser::new(&test_tools(), Arc::new(tokenizer())).unwrap() + } + + #[test] + fn gemma4_create_requires_channel_start_token() { + let error = match Gemma4UnifiedParser::new(&test_tools(), Arc::new(TestTokenizer::new())) { + Ok(_) => panic!("expected missing token error"), + Err(error) => error, + }; + + assert!(matches!( + error, + UnifiedParserError::MissingToken { token } if token == CHANNEL_START + )); + } + + fn collect_stream(chunks: &[&str]) -> UnifiedParserOutput { + let mut parser = test_parser(); + let mut output = UnifiedParserOutput::default(); for chunk in chunks { output.append(parser.parse_chunk(chunk).unwrap()); } output.append(parser.finish().unwrap()); - output.coalesce_calls() + output.coalesce() } - fn first_call(output: &ToolParserOutput) -> &ToolCallDelta { - output.calls.first().expect("expected one tool call") + fn first_call(output: &UnifiedParserOutput) -> ToolCallDelta { + (*output.calls().first().expect("expected one tool call")).clone() } #[test] @@ -542,13 +728,13 @@ mod tests { #[test] fn gemma4_parse_complete_extracts_single_tool_call() { - let mut parser = Gemma4ToolParser::new(&test_tools()); + let mut parser = test_parser(); let output = parser .parse_complete("<|tool_call>call:get_weather{location:<|\"|>London<|\"|>}") .unwrap(); - assert!(output.normal_text.is_empty()); - assert_eq!(output.calls.len(), 1); + assert!(output.normal_text().is_empty()); + assert_eq!(output.calls().len(), 1); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( serde_json::from_str::(&first_call(&output).arguments).unwrap(), @@ -558,7 +744,7 @@ mod tests { #[test] fn gemma4_parse_complete_rejects_incomplete_tool_call() { - let mut parser = Gemma4ToolParser::new(&test_tools()); + let mut parser = test_parser(); let error = parser .parse_complete("<|tool_call>call:get_weather{location:<|\"|>London") .unwrap_err(); @@ -577,7 +763,7 @@ mod tests { "", ]); - assert!(output.normal_text.is_empty()); + assert!(output.normal_text().is_empty()); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( serde_json::from_str::(&first_call(&output).arguments).unwrap(), @@ -597,7 +783,7 @@ mod tests { "div>", ]); - assert_eq!(output.normal_text, "Let me check the weather.
"); + assert_eq!(output.normal_text(), "Let me check the weather.
"); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( serde_json::from_str::(&first_call(&output).arguments).unwrap(), @@ -607,8 +793,8 @@ mod tests { #[test] fn gemma4_streaming_waits_for_complete_tool_call() { - let mut parser = Gemma4ToolParser::new(&test_tools()); - let mut output = ToolParserOutput::default(); + let mut parser = test_parser(); + let mut output = UnifiedParserOutput::default(); for chunk in [ "<|tool_call>", @@ -616,11 +802,11 @@ mod tests { "location:<|\"|>Paris<|\"|>}", ] { output.append(parser.parse_chunk(chunk).unwrap()); - assert!(output.calls.is_empty()); + assert!(output.calls().is_empty()); } output.append(parser.parse_chunk("").unwrap()); - let output = output.coalesce_calls(); + let output = output.coalesce(); assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); assert_eq!( @@ -773,17 +959,112 @@ mod tests { #[test] fn gemma4_finish_flushes_partial_start_marker_as_text() { - let mut parser = Gemma4ToolParser::new(&test_tools()); + let mut parser = test_parser(); let mut output = parser.parse_chunk("<").unwrap(); output.append(parser.finish().unwrap()); - assert_eq!(output.normal_text, "<"); - assert!(output.calls.is_empty()); + assert_eq!(output.normal_text(), "<"); + assert!(output.calls().is_empty()); + } + + #[test] + fn gemma4_streaming_emits_reasoning_then_text() { + let output = collect_stream(&["<|channel>thought\nreasonanswer"]); + + assert_eq!(output.reasoning_text(), "reason"); + assert_eq!(output.normal_text(), "answer"); + assert!(output.calls().is_empty()); + } + + #[test] + fn gemma4_streaming_holds_split_reasoning_start() { + let mut parser = test_parser(); + + let first = parser.parse_chunk("<|channel>").unwrap(); + assert!(first.events.is_empty()); + + let mut output = parser.parse_chunk("thought\nrea").unwrap(); + output.append(parser.parse_chunk("sonanswer").unwrap()); + output.append(parser.finish().unwrap()); + + assert_eq!(output.reasoning_text(), "reason"); + assert_eq!(output.normal_text(), "answer"); + } + + #[test] + fn gemma4_initialize_open_channel_prompt_starts_in_reasoning() { + let mut parser = test_parser(); + parser.initialize(&[CHANNEL_START_ID, 3000, 3001]).unwrap(); + + let output = parser.parse_complete("reasonanswer").unwrap(); + + assert_eq!(output.reasoning_text(), "reason"); + assert_eq!(output.normal_text(), "answer"); + } + + #[test] + fn gemma4_initialize_turn_prompt_starts_in_text() { + let mut parser = test_parser(); + parser.initialize(&[TURN_BOUNDARY_ID, 3000, 3001]).unwrap(); + + let output = parser.parse_complete("<|channel>thought\nreasonanswer").unwrap(); + + assert_eq!(output.reasoning_text(), "reason"); + assert_eq!(output.normal_text(), "answer"); + } + + #[test] + fn gemma4_initialize_special_token_caps_boundary_scan() { + let mut parser = test_parser(); + parser.initialize(&[CHANNEL_START_ID, 3000, TURN_BOUNDARY_ID, 3001]).unwrap(); + + let output = parser.parse_complete("answer").unwrap(); + + assert!(output.reasoning_text().is_empty()); + assert_eq!(output.normal_text(), "answer"); + } + + #[test] + fn gemma4_initialize_closed_channel_prompt_starts_in_text() { + let mut parser = test_parser(); + parser.initialize(&[CHANNEL_START_ID, 3000, 3001, CHANNEL_END_ID]).unwrap(); + + let output = parser.parse_complete("answer").unwrap(); + + assert!(output.reasoning_text().is_empty()); + assert_eq!(output.normal_text(), "answer"); + } + + #[test] + fn gemma4_reasoning_tool_call_implicitly_ends_reasoning() { + let output = collect_stream(&[ + "<|channel>thought\nNeed weather.", + "<|tool_call>", + "call:get_weather{location:<|\"|>Paris<|\"|>}", + "", + ]); + + assert_eq!(output.reasoning_text(), "Need weather."); + assert!(output.normal_text().is_empty()); + assert_eq!(first_call(&output).name.as_deref(), Some("get_weather")); + assert_eq!( + serde_json::from_str::(&first_call(&output).arguments).unwrap(), + json!({ "location": "Paris" }) + ); + } + + #[test] + fn gemma4_bare_channel_start_is_plain_text() { + let output = collect_stream(&["<|channel>plain"]); + + assert_eq!(output.normal_text(), "<|channel>plain"); + assert!(output.reasoning_text().is_empty()); + assert!(output.calls().is_empty()); } #[test] fn gemma4_finish_rejects_complete_args_without_end_marker() { - let mut parser = Gemma4ToolParser::new(&test_tools()); + let mut parser = test_parser(); for chunk in ["<|tool_call>", "call:get_status{}"] { parser.parse_chunk(chunk).unwrap(); } @@ -795,7 +1076,7 @@ mod tests { #[test] fn gemma4_reset_preserves_internally_buffered_arguments() { - let mut parser = Gemma4ToolParser::new(&test_tools()); + let mut parser = test_parser(); for chunk in [ "<|tool_call>", "call:write_file{", @@ -815,7 +1096,7 @@ mod tests { #[test] fn gemma4_reset_preserves_completed_arguments_after_parse_error() { - let mut parser = Gemma4ToolParser::new(&test_tools()); + let mut parser = test_parser(); let input = "<|tool_call>call:set{broken}"; let _error = parser.parse_chunk(input).unwrap_err(); diff --git a/rust/src/parser/src/unified/mod.rs b/rust/src/parser/src/unified/mod.rs new file mode 100644 index 000000000000..0410c568461b --- /dev/null +++ b/rust/src/parser/src/unified/mod.rs @@ -0,0 +1,207 @@ +//! Unified parser interface for reasoning and tool-call deltas. + +mod combined; +mod gemma4; + +pub use combined::CombinedParser; +pub use gemma4::Gemma4UnifiedParser; +use thiserror::Error; +use thiserror_ext::Macro; +use vllm_tokenizer::DynTokenizer; + +use crate::reasoning::ReasoningError; +use crate::tool::{ + StructuralTagModel, Tool, ToolCallDelta, ToolParserError, ToolParserEvent, ToolParserOutput, +}; + +/// Result alias for unified parser operations. +pub type Result = std::result::Result; + +/// One parsed event emitted by a unified parser. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnifiedParserEvent { + /// Normal assistant-visible text. + Text(String), + /// Reasoning text hidden from the normal content stream. + Reasoning(String), + /// A tool-call update extracted from visible assistant text. + ToolCall(ToolCallDelta), +} + +/// Result of advancing unified parsing with one assistant-text input. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UnifiedParserOutput { + /// Ordered parser events committed by this input. + pub events: Vec, +} + +impl UnifiedParserOutput { + /// Append one visible text event if `delta` is non-empty. + pub fn push_text(&mut self, delta: impl AsRef + Into) { + if delta.as_ref().is_empty() { + return; + } + if let Some(UnifiedParserEvent::Text(last_text)) = self.events.last_mut() { + last_text.push_str(delta.as_ref()); + return; + } + self.events.push(UnifiedParserEvent::Text(delta.into())); + } + + /// Append one reasoning text event if `delta` is non-empty. + pub fn push_reasoning(&mut self, delta: impl AsRef + Into) { + if delta.as_ref().is_empty() { + return; + } + if let Some(UnifiedParserEvent::Reasoning(last_text)) = self.events.last_mut() { + last_text.push_str(delta.as_ref()); + return; + } + self.events.push(UnifiedParserEvent::Reasoning(delta.into())); + } + + /// Append one tool-call event. + pub fn push_call(&mut self, call: ToolCallDelta) { + self.events.push(UnifiedParserEvent::ToolCall(call)); + } + + /// Append parsed tool parser output as unified events. + pub fn append_tool_output(&mut self, output: ToolParserOutput) { + for event in output.events { + match event { + ToolParserEvent::Text(text) => self.push_text(text), + ToolParserEvent::ToolCall(call) => self.push_call(call), + } + } + } + + /// Append another parser output onto this one. + pub fn append(&mut self, other: Self) { + for event in other.events { + match event { + UnifiedParserEvent::Text(text) => self.push_text(text), + UnifiedParserEvent::Reasoning(reasoning) => self.push_reasoning(reasoning), + UnifiedParserEvent::ToolCall(call) => self.push_call(call), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{UnifiedParserEvent, UnifiedParserOutput}; + use crate::tool::ToolCallDelta; + + #[test] + fn unified_parser_output_coalesces_adjacent_text_events() { + let mut output = UnifiedParserOutput::default(); + output.push_text("hello"); + output.push_text(" "); + output.push_text("world"); + output.push_reasoning("think"); + output.push_reasoning("ing"); + output.push_call(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }); + output.push_text("!"); + + assert_eq!( + output.events, + vec![ + UnifiedParserEvent::Text("hello world".to_string()), + UnifiedParserEvent::Reasoning("thinking".to_string()), + UnifiedParserEvent::ToolCall(ToolCallDelta { + tool_index: 0, + name: Some("lookup".to_string()), + arguments: "{}".to_string(), + }), + UnifiedParserEvent::Text("!".to_string()), + ] + ); + } + + #[test] + fn unified_parser_output_append_coalesces_adjacent_events() { + let mut output = UnifiedParserOutput::default(); + output.push_text("hello"); + + let mut other = UnifiedParserOutput::default(); + other.push_text(" "); + other.push_text("world"); + other.push_reasoning("think"); + output.append(other); + + let mut after_reasoning = UnifiedParserOutput::default(); + after_reasoning.push_reasoning("ing"); + after_reasoning.push_text("!"); + output.append(after_reasoning); + + assert_eq!( + output.events, + vec![ + UnifiedParserEvent::Text("hello world".to_string()), + UnifiedParserEvent::Reasoning("thinking".to_string()), + UnifiedParserEvent::Text("!".to_string()), + ] + ); + } +} + +/// Incremental parser that extracts reasoning and tool-call events from assistant output. +pub trait UnifiedParser: Send { + /// Construct a boxed parser instance for one request stream. + fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result> + where + Self: Sized + 'static; + + /// Initialize parser state from prompt token IDs before output deltas arrive. + fn initialize(&mut self, _prompt_token_ids: &[u32]) -> Result<()> { + Ok(()) + } + + /// Return whether decoded output must preserve tokenizer special tokens. + fn preserve_special_tokens(&self) -> bool { + false + } + + /// Return the xgrammar structural-tag model used for strict tool calling. + fn structural_tag_model(&self) -> Option { + None + } + + /// Return the parser-provided ID for a tool call by index, if the model emitted one. + fn tool_call_id(&self, _tool_index: usize) -> Option<&str> { + None + } + + /// Feed one decoded text delta into the parser, appending committed output into `output`. + fn parse_into(&mut self, delta: &str, output: &mut UnifiedParserOutput) -> Result<()>; + + /// Flush any buffered parser state at end of stream. + fn finish(&mut self) -> Result { + Ok(UnifiedParserOutput::default()) + } + + /// Clear parser state and return currently uncommitted buffered text. + fn reset(&mut self) -> String { + String::new() + } +} + +/// Errors produced while creating or running unified parsers. +#[derive(Debug, Error, Macro)] +#[thiserror_ext(macro(path = "crate::unified", mangle))] +pub enum UnifiedParserError { + #[error("combined parser is constructed from split parser instances")] + CombinedParserConstructor, + #[error("tokenizer is missing unified parser token `{token}`")] + MissingToken { token: String }, + #[error("unified parser parsing failed: {message}")] + ParsingFailed { message: String }, + #[error(transparent)] + Reasoning(#[from] ReasoningError), + #[error(transparent)] + Tool(#[from] ToolParserError), +} diff --git a/rust/src/parser/src/utils.rs b/rust/src/parser/src/utils.rs new file mode 100644 index 000000000000..c692a57c889b --- /dev/null +++ b/rust/src/parser/src/utils.rs @@ -0,0 +1,859 @@ +//! Shared helpers for streaming parsers. + +use winnow::Parser; +use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue}; +use winnow::stream::{FindSlice, Offset, Partial, Stream}; + +use crate::tool::{Result, ToolParserError}; + +/// Return the byte length of the longest proper prefix of `token` that is also +/// a suffix of `buffer`. +/// +/// Streaming parsers use this to keep only the trailing fragment that might +/// still grow into a full marker after the next decoded chunk arrives. +/// +/// The returned length is always a valid UTF-8 boundary in `token`, so callers +/// can safely slice `&token[..len]` even when markers contain non-ASCII +/// characters such as DeepSeek's DSML delimiters. +pub fn partial_prefix_len(buffer: &str, token: &str) -> usize { + let Some(first_byte) = token.as_bytes().first().copied() else { + return 0; + }; + + let max_len = buffer.len().min(token.len().saturating_sub(1)); + let tail_start = buffer.len() - max_len; + let buffer_bytes = buffer.as_bytes(); + let token_bytes = token.as_bytes(); + + // Scan from the longest possible suffix to preserve overlapping prefixes. + for index in tail_start..buffer.len() { + if buffer_bytes[index] != first_byte { + continue; + } + + let len = buffer.len() - index; + if buffer.is_char_boundary(index) + && token.is_char_boundary(len) + && token_bytes[..len] == buffer_bytes[index..] + { + return len; + } + } + + 0 +} + +/// Parse a safe text run before the next marker. +/// This is the single-marker variant of [`safe_text_len_mul`]. +/// +/// Returns the text length in bytes, and advances the input. +pub fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalResult { + let text = **input; + if text.is_empty() { + return incomplete(); + } + + if let Some(start_idx) = text.find(marker) { + input.next_slice(start_idx); + return Ok(start_idx); + } + + let keep_len = partial_prefix_len(text, marker); + let emit_len = text.len().saturating_sub(keep_len); + if emit_len == 0 { + return incomplete(); + } + + input.next_slice(emit_len); + Ok(emit_len) +} + +/// Parse a safe text run before the earliest next marker. +/// This is the multi-marker variant of [`safe_text_len`]. +/// +/// Returns the text length in bytes, and advances the input. +pub fn safe_text_len_mul(input: &mut Partial<&str>, markers: &[&str]) -> ModalResult { + let text = **input; + if text.is_empty() { + return incomplete(); + } + + if let Some(start_idx) = find_slice_mul(text, markers) { + input.next_slice(start_idx); + return Ok(start_idx); + } + + let keep_len = markers.iter().map(|marker| partial_prefix_len(text, marker)).max().unwrap_or(0); + let emit_len = text.len().saturating_sub(keep_len); + if emit_len == 0 { + return incomplete(); + } + + input.next_slice(emit_len); + Ok(emit_len) +} + +#[inline(always)] +fn find_slice_mul(text: &str, markers: &[&str]) -> Option { + let range = match markers { + // Use the fast specialized `winnow::stream::FindSlice` impl for 1-3 markers. + [first] => text.find_slice(*first), + [first, second] => text.find_slice((*first, *second)), + [first, second, third] => text.find_slice((*first, *second, *third)), + // Fall back to a linear scan for 4+ markers. + _ => return markers.iter().filter_map(|marker| text.find(marker)).min(), + }; + range.map(|range| range.start) +} + +/// Streaming scan state for a buffered marker search [`take_until_marker`], +/// so that we don't have to rescan the whole buffered prefix when resuming. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MarkerScanState { + scan_start: usize, +} + +impl MarkerScanState { + pub fn reset(&mut self) { + self.scan_start = 0; + } +} + +/// Parse text until `marker`, resuming from the last safe scan checkpoint. +/// +/// This is the streaming-buffered variant of `winnow::token::take_until(0.., +/// marker)`: it returns the slice before `marker` and leaves `marker` for the +/// caller to consume. On incomplete input, it stores the earliest byte offset +/// that can still match `marker` and returns `Incomplete` without consuming +/// input, so the next parse can avoid rescanning the whole buffered prefix. +/// +/// Use this for outer parser states that keep the full buffered input across +/// chunks while waiting for a closing marker. Plain `take_until` is still a +/// better fit for one-shot parsers over a complete body, and for `1..` cases +/// where an empty slice before the marker should be rejected. +pub fn take_until_marker<'i, 'a>( + marker: &'a str, + state: &'a mut MarkerScanState, +) -> impl Parser, &'i str, ErrMode> + 'a { + move |input: &mut Partial<&'i str>| take_until_marker_(input, marker, state) +} + +fn take_until_marker_<'i>( + input: &mut Partial<&'i str>, + marker: &str, + state: &mut MarkerScanState, +) -> ModalResult<&'i str> { + debug_assert!(!marker.is_empty()); + + let text = **input; + if text.is_empty() { + return incomplete(); + } + + // Normal updates store a char boundary; this keeps stale or misused state from panicking. + let scan_start = floor_char_boundary(text, state.scan_start); + + if let Some(offset) = text[scan_start..].find(marker) { + let marker_start = scan_start + offset; + let body = &text[..marker_start]; + input.next_slice(marker_start); + state.reset(); + return Ok(body); + } + + let keep_len = partial_prefix_len(text, marker); + state.scan_start = text.len() - keep_len; + incomplete() +} + +fn floor_char_boundary(text: &str, index: usize) -> usize { + let mut index = index.min(text.len()); + while !text.is_char_boundary(index) { + index -= 1; + } + index +} + +/// Streaming lexical state for a top-level JSON object. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JsonObjectScanState { + object_depth: usize, + array_depth: usize, + in_string: bool, + escape: bool, + phase: JsonObjectScanPhase, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum JsonObjectScanPhase { + #[default] + Initial, + Scanning, + Complete, +} + +impl JsonObjectScanState { + /// Returns whether the top-level JSON object has closed. + pub const fn complete(&self) -> bool { + matches!(self.phase, JsonObjectScanPhase::Complete) + } +} + +/// Parse a raw top-level JSON object argument prefix. +/// +/// The returned length is safe to emit as raw argument text. This scans only +/// lexical boundaries from `{` through the matching `}`, preserving +/// malformed-but-balanced JSON without deserializing or normalizing it. +pub fn take_json_object( + input: &mut Partial<&str>, + state: &mut JsonObjectScanState, +) -> ModalResult { + let text = **input; + if text.is_empty() { + return incomplete(); + } + if state.complete() { + return Err(json_scan_error( + "JSON object argument", + StrContextValue::Description("active JSON object scan"), + )); + } + + let bytes = text.as_bytes(); + let just_started = matches!(state.phase, JsonObjectScanPhase::Initial); + if just_started { + if bytes[0] != b'{' { + return Err(json_scan_error( + "JSON object argument", + StrContextValue::CharLiteral('{'), + )); + } + state.phase = JsonObjectScanPhase::Scanning; + state.object_depth = 1; + } + + let mut index = usize::from(just_started); + + while index < bytes.len() { + let byte = bytes[index]; + index += 1; + + if state.in_string { + if state.escape { + state.escape = false; + } else if byte == b'\\' { + state.escape = true; + } else if byte == b'"' { + state.in_string = false; + } + continue; + } + + match byte { + b'"' => state.in_string = true, + b'{' => state.object_depth += 1, + b'}' => { + state.object_depth = state.object_depth.checked_sub(1).ok_or_else(|| { + json_scan_error( + "JSON object argument", + StrContextValue::Description("balanced object braces"), + ) + })?; + if state.object_depth == 0 && state.array_depth == 0 { + state.phase = JsonObjectScanPhase::Complete; + input.next_slice(index); + return Ok(index); + } + if state.object_depth == 0 { + return Err(json_scan_error( + "JSON object argument", + StrContextValue::Description( + "nested arrays to close before the top-level object", + ), + )); + } + } + b'[' => state.array_depth += 1, + b']' => { + state.array_depth = state.array_depth.checked_sub(1).ok_or_else(|| { + json_scan_error( + "JSON object argument", + StrContextValue::Description("balanced array brackets"), + ) + })?; + } + _ => {} + } + } + + input.next_slice(text.len()); + Ok(text.len()) +} + +/// Streaming lexical state for a JSON string literal. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JsonStringScanState { + scanned_len: usize, + escape: bool, +} + +/// Parse a raw JSON string literal, resuming from the last scanned byte. +/// +/// The returned length covers the quoted JSON string. This only scans for the +/// string boundary; callers that need the decoded value should pass the raw +/// slice to [`decode_json_str`]. +pub fn take_json_string( + input: &mut Partial<&str>, + state: &mut JsonStringScanState, +) -> ModalResult { + let text = **input; + if text.is_empty() { + return incomplete(); + } + + let bytes = text.as_bytes(); + if bytes[0] != b'"' { + return Err(json_scan_error( + "JSON string", + StrContextValue::CharLiteral('"'), + )); + } + + let mut index = if state.scanned_len == 0 { + 1 + } else if state.scanned_len <= bytes.len() { + state.scanned_len + } else { + return incomplete(); + }; + + while index < bytes.len() { + let byte = bytes[index]; + index += 1; + + if state.escape { + state.escape = false; + continue; + } + + match byte { + b'\\' => state.escape = true, + b'"' => { + input.next_slice(index); + return Ok(index); + } + _ => {} + } + } + + state.scanned_len = text.len(); + incomplete() +} + +/// Parse a JSON string literal. +pub fn json_str(input: &mut Partial<&str>) -> ModalResult { + let text = **input; + let checkpoint = input.checkpoint(); + let mut state = JsonStringScanState::default(); + let len = take_json_string(input, &mut state)?; + decode_json_str(&text[..len]).inspect_err(|_| { + input.reset(&checkpoint); + }) +} + +/// Decode a complete JSON string literal. +pub fn decode_json_str(raw: &str) -> ModalResult { + serde_json::from_str::(raw).map_err(|_| { + json_scan_error( + "JSON string", + StrContextValue::Description("valid JSON string"), + ) + }) +} + +fn json_scan_error(label: &'static str, expected: StrContextValue) -> ErrMode { + let mut error = ContextError::new(); + error.push(StrContext::Label(label)); + error.push(StrContext::Expected(expected)); + ErrMode::Cut(error) +} + +/// Parse one event from a buffered streaming input. +/// +/// Returns: +/// - `Ok(Some((event, consumed_len)))` if an event was successfully parsed, along with the number +/// of bytes consumed from the buffer. +/// - `Ok(None)` if the buffer does not contain a full event yet, and more data is needed. +/// - `Err` if a parsing error occurred. +pub fn parse_buffered_event( + buffer: &str, + parse: impl FnOnce(&mut Partial<&str>) -> ModalResult, +) -> Result> { + let mut input = Partial::new(buffer); + let checkpoint = input.checkpoint(); + let event = match parse(&mut input) { + Ok(event) => event, + Err(ErrMode::Incomplete(_)) => return Ok(None), + Err(ErrMode::Backtrack(e) | ErrMode::Cut(e)) => { + let snippet = buffer.char_indices().nth(80).map_or(buffer, |(i, _)| &buffer[..i]); + return Err(ToolParserError::ParsingFailed { + message: format!("near {snippet:?}: {e}"), + }); + } + }; + let consumed_len = input.offset_from(&checkpoint); + if consumed_len == 0 { + return Ok(None); + } + + Ok(Some((event, consumed_len))) +} + +/// Returns an error indicating that we need more data to continue parsing. +pub fn incomplete() -> ModalResult { + Err(ErrMode::Incomplete(Needed::Unknown)) +} + +#[cfg(test)] +mod tests { + + use expect_test::expect; + use winnow::Parser; + use winnow::error::ErrMode; + use winnow::stream::{Offset, Partial, Stream}; + + use super::{ + JsonObjectScanState, JsonStringScanState, MarkerScanState, json_str, parse_buffered_event, + partial_prefix_len, safe_text_len, safe_text_len_mul, take_json_object, take_json_string, + take_until_marker, + }; + + #[test] + fn partial_prefix_len_handles_ascii_markers() { + assert_eq!( + partial_prefix_len("hello<|tool", "<|tool_call>"), + "<|tool".len() + ); + assert_eq!(partial_prefix_len("hello world", "<|tool_call>"), 0); + } + + #[test] + fn partial_prefix_len_prefers_longest_overlapping_prefix() { + assert_eq!(partial_prefix_len("chunk ending in aba", "ababa"), 3); + } + + #[test] + fn partial_prefix_len_handles_unicode_markers() { + let token = "<|DSML|function_calls>"; + assert_eq!( + partial_prefix_len("prefix <|DSML|fun", token), + "<|DSML|fun".len() + ); + assert_eq!(partial_prefix_len("prefix <|DSML", token), "<|DSML".len()); + } + + #[test] + fn safe_text_len_stops_before_marker() { + let mut input = Partial::new("hello"); + let checkpoint = input.checkpoint(); + + let len = safe_text_len(&mut input, "").unwrap(); + + assert_eq!(len, "hello".len()); + assert_eq!(input.offset_from(&checkpoint), "hello".len()); + } + + #[test] + fn safe_text_len_holds_back_partial_marker() { + let mut input = Partial::new("hello").unwrap(); + + assert_eq!(len, "hello".len()); + assert_eq!(input.offset_from(&checkpoint), "hello".len()); + } + + #[test] + fn safe_text_len_reports_incomplete_for_only_partial_marker() { + let mut input = Partial::new("").unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + } + + #[test] + fn safe_text_len_mul_stops_before_earliest_marker() { + let mut input = Partial::new("hello<|tool_call>"); + let checkpoint = input.checkpoint(); + + let len = safe_text_len_mul(&mut input, &["<|tool_call>", ""]).unwrap(); + + assert_eq!(len, "hello".len()); + assert_eq!(input.offset_from(&checkpoint), "hello".len()); + assert_eq!(*input, "<|tool_call>"); + } + + #[test] + fn safe_text_len_mul_holds_back_longest_partial_marker() { + let mut input = Partial::new("hello<|tool"); + let checkpoint = input.checkpoint(); + + let len = safe_text_len_mul(&mut input, &["<|tool_call>", "<|channel>thought\n"]).unwrap(); + + assert_eq!(len, "hello".len()); + assert_eq!(input.offset_from(&checkpoint), "hello".len()); + assert_eq!(*input, "<|tool"); + } + + #[test] + fn safe_text_len_mul_skips_false_same_prefix_candidate() { + let mut input = Partial::new("hello<|tool_call>"); + let checkpoint = input.checkpoint(); + + let len = safe_text_len_mul(&mut input, &["<|tool_call>", "<|channel>thought\n"]).unwrap(); + + assert_eq!(len, "hello".len()); + assert_eq!(input.offset_from(&checkpoint), "hello".len()); + assert_eq!(*input, "<|tool_call>"); + } + + #[test] + fn safe_text_len_mul_reports_incomplete_for_only_partial_marker() { + let mut input = Partial::new("<|channel>thought"); + + let error = + safe_text_len_mul(&mut input, &["<|tool_call>", "<|channel>thought\n"]).unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + } + + #[test] + fn take_until_marker_stops_before_marker() { + let mut state = MarkerScanState::default(); + let mut input = Partial::new("bodytail"); + let checkpoint = input.checkpoint(); + + let body = take_until_marker("", &mut state).parse_next(&mut input).unwrap(); + + assert_eq!(body, "body"); + assert_eq!(input.offset_from(&checkpoint), "body".len()); + assert_eq!(*input, "tail"); + assert_eq!(state, MarkerScanState::default()); + } + + #[test] + fn take_until_marker_resumes_after_split_marker() { + let mut state = MarkerScanState::default(); + let mut input = Partial::new("body", &mut state) + .parse_next(&mut input) + .unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + assert_eq!(state.scan_start, "body".len()); + + let mut input = Partial::new("bodytail"); + let body = take_until_marker("", &mut state).parse_next(&mut input).unwrap(); + + assert_eq!(body, "body"); + assert_eq!(*input, "tail"); + assert_eq!(state, MarkerScanState::default()); + } + + #[test] + fn take_until_marker_advances_checkpoint_for_long_prefix() { + let mut state = MarkerScanState::default(); + let text = format!("{}{}", "x".repeat(1024), "", &mut state) + .parse_next(&mut input) + .unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + assert_eq!(state.scan_start, 1024); + } + + #[test] + fn take_until_marker_keeps_unicode_marker_boundaries() { + let marker = "<|DSML|function_calls>"; + let mut state = MarkerScanState::default(); + let mut input = Partial::new("prefix <|DSML|fun"); + + let error = take_until_marker(marker, &mut state).parse_next(&mut input).unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + assert_eq!(state.scan_start, "prefix ".len()); + assert!("prefix <|DSML|fun".is_char_boundary(state.scan_start)); + + let mut input = Partial::new("prefix <|DSML|function_calls>tail"); + let body = take_until_marker(marker, &mut state).parse_next(&mut input).unwrap(); + + assert_eq!(body, "prefix "); + assert_eq!(*input, "<|DSML|function_calls>tail"); + assert_eq!(state, MarkerScanState::default()); + } + + #[test] + fn take_until_marker_floors_stale_checkpoint_to_char_boundary() { + let mut state = MarkerScanState { scan_start: 1 }; + let mut input = Partial::new("é"); + + let body = take_until_marker("", &mut state).parse_next(&mut input).unwrap(); + + assert_eq!(body, "é"); + assert_eq!(*input, ""); + assert_eq!(state, MarkerScanState::default()); + } + + #[test] + fn take_until_marker_handles_overlapping_prefixes() { + let mut state = MarkerScanState::default(); + let mut input = Partial::new("xxaba"); + + let error = take_until_marker("ababa", &mut state).parse_next(&mut input).unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + assert_eq!(state.scan_start, 2); + + let mut input = Partial::new("xxababa!"); + let body = take_until_marker("ababa", &mut state).parse_next(&mut input).unwrap(); + + assert_eq!(body, "xx"); + assert_eq!(*input, "ababa!"); + } + + #[test] + fn take_json_object_consumes_simple_object() { + let mut state = JsonObjectScanState::default(); + let buffer = r#"{"location":"Paris"}"#; + let mut input = Partial::new(buffer); + let checkpoint = input.checkpoint(); + + let len = take_json_object(&mut input, &mut state).unwrap(); + + assert_eq!(len, r#"{"location":"Paris"}"#.len()); + assert_eq!(input.offset_from(&checkpoint), len); + assert!(state.complete()); + } + + #[test] + fn take_json_object_tracks_nested_values_and_strings() { + let mut state = JsonObjectScanState::default(); + let arguments = r#"{"nested":{"items":[{"text":"} <|tool_call_end|> \" \\"}]}}"#; + let buffer = format!("{arguments}"); + let mut input = Partial::new(buffer.as_str()); + + let len = take_json_object(&mut input, &mut state).unwrap(); + + assert_eq!(len, arguments.len()); + assert!(state.complete()); + } + + #[test] + fn take_json_object_rejects_leading_whitespace() { + let mut state = JsonObjectScanState::default(); + let mut input = Partial::new(" {\"x\":1}"); + + let error = take_json_object(&mut input, &mut state).unwrap_err(); + + let ErrMode::Cut(error) = error else { + panic!("expected cut error"); + }; + expect![[r#" + invalid JSON object argument + expected `{`"#]] + .assert_eq(&error.to_string()); + } + + #[test] + fn take_json_object_leaves_trailing_whitespace_to_caller() { + let mut state = JsonObjectScanState::default(); + let mut input = Partial::new("{\"x\":1}\n"); + let checkpoint = input.checkpoint(); + + let len = take_json_object(&mut input, &mut state).unwrap(); + + assert_eq!(len, "{\"x\":1}".len()); + assert_eq!(input.offset_from(&checkpoint), len); + assert!(state.complete()); + } + + #[test] + fn take_json_object_continues_across_chunks() { + let mut state = JsonObjectScanState::default(); + let chunks = [ + r#"{"text":"literal "#, + r#"<|tool_call_end|>"#, + r#" inside"}"#, + ]; + let mut collected = String::new(); + + for chunk in chunks { + let mut input = Partial::new(chunk); + let len = take_json_object(&mut input, &mut state).unwrap(); + collected.push_str(&chunk[..len]); + } + + assert_eq!(collected, r#"{"text":"literal <|tool_call_end|> inside"}"#); + assert!(state.complete()); + } + + #[test] + fn take_json_object_rejects_non_object_top_level() { + let mut state = JsonObjectScanState::default(); + let mut input = Partial::new(r#"[{"x":1}]"#); + + let error = take_json_object(&mut input, &mut state).unwrap_err(); + + let ErrMode::Cut(error) = error else { + panic!("expected cut error"); + }; + expect![[r#" + invalid JSON object argument + expected `{`"#]] + .assert_eq(&error.to_string()); + } + + #[test] + fn take_json_object_reports_unbalanced_array() { + let mut state = JsonObjectScanState::default(); + let mut input = Partial::new(r#"{"x":]}"#); + + let error = take_json_object(&mut input, &mut state).unwrap_err(); + + let ErrMode::Cut(error) = error else { + panic!("expected cut error"); + }; + expect![[r#" + invalid JSON object argument + expected balanced array brackets"#]] + .assert_eq(&error.to_string()); + } + + #[test] + fn take_json_object_reports_top_level_close_before_nested_array() { + let mut state = JsonObjectScanState::default(); + let mut input = Partial::new(r#"{"x":[}"#); + + let error = take_json_object(&mut input, &mut state).unwrap_err(); + + let ErrMode::Cut(error) = error else { + panic!("expected cut error"); + }; + expect![[r#" + invalid JSON object argument + expected nested arrays to close before the top-level object"#]] + .assert_eq(&error.to_string()); + } + + #[test] + fn take_json_string_consumes_complete_string() { + let mut state = JsonStringScanState::default(); + let mut input = Partial::new(r#""say_\"hi\u0021" rest"#); + let checkpoint = input.checkpoint(); + + let len = take_json_string(&mut input, &mut state).unwrap(); + + assert_eq!(len, r#""say_\"hi\u0021""#.len()); + assert_eq!(input.offset_from(&checkpoint), len); + assert_eq!(*input, " rest"); + } + + #[test] + fn take_json_string_resumes_after_incomplete_input() { + let mut state = JsonStringScanState::default(); + let mut input = Partial::new(r#""{\"data\":\"partial"#); + let checkpoint = input.checkpoint(); + + let error = take_json_string(&mut input, &mut state).unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + assert_eq!(input.offset_from(&checkpoint), 0); + assert_eq!(state.scanned_len, r#""{\"data\":\"partial"#.len()); + + let mut input = Partial::new(r#""{\"data\":\"partial string\"}" tail"#); + let len = take_json_string(&mut input, &mut state).unwrap(); + + assert_eq!(len, r#""{\"data\":\"partial string\"}""#.len()); + assert_eq!(*input, " tail"); + } + + #[test] + fn take_json_string_tracks_escape_across_chunks() { + let mut state = JsonStringScanState::default(); + let mut input = Partial::new(r#""abc\"#); + + let error = take_json_string(&mut input, &mut state).unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + assert!(state.escape); + + let mut input = Partial::new(r#""abc\"def" tail"#); + let len = take_json_string(&mut input, &mut state).unwrap(); + + assert_eq!(len, r#""abc\"def""#.len()); + assert_eq!(*input, " tail"); + } + + #[test] + fn take_json_string_rejects_non_string_start() { + let mut state = JsonStringScanState::default(); + let mut input = Partial::new("42"); + + let error = take_json_string(&mut input, &mut state).unwrap_err(); + + let ErrMode::Cut(error) = error else { + panic!("expected cut error"); + }; + expect![[r#" + invalid JSON string + expected `"`"#]] + .assert_eq(&error.to_string()); + } + + #[test] + fn json_str_decodes_escaped_content() { + let mut input = Partial::new(r#""say_\"hi\u0021" rest"#); + + let value = json_str(&mut input).unwrap(); + + assert_eq!(value, "say_\"hi!"); + assert_eq!(*input, " rest"); + } + + #[test] + fn json_str_reports_incomplete_escaped_string() { + let mut input = Partial::new(r#""say_\"#); + + let error = json_str(&mut input).unwrap_err(); + + assert!(matches!(error, ErrMode::Incomplete(_))); + } + + #[test] + fn parse_buffered_event_error_includes_input_snippet() { + let result = parse_buffered_event(" {\"x\":1}", |input| { + take_json_object(input, &mut JsonObjectScanState::default()) + }); + let err = result.unwrap_err().to_string(); + assert!(err.contains("near \""), "error must include snippet"); + } + + #[test] + fn parse_buffered_event_error_truncates_long_input() { + let long_input = format!(" {}", "x".repeat(100)); + let result = parse_buffered_event(&long_input, |input| { + take_json_object(input, &mut JsonObjectScanState::default()) + }); + let err = result.unwrap_err().to_string(); + assert!(err.contains("near \""), "error must include snippet"); + assert!( + !err.contains(&long_input), + "snippet must be truncated for long input" + ); + } +} diff --git a/rust/src/reasoning-parser/Cargo.toml b/rust/src/reasoning-parser/Cargo.toml deleted file mode 100644 index d6500a7b0c1d..000000000000 --- a/rust/src/reasoning-parser/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "vllm-reasoning-parser" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -thiserror.workspace = true -vllm-tokenizer.workspace = true - -[lints] -workspace = true diff --git a/rust/src/reasoning-parser/src/gemma4.rs b/rust/src/reasoning-parser/src/gemma4.rs deleted file mode 100644 index 86824f2ad407..000000000000 --- a/rust/src/reasoning-parser/src/gemma4.rs +++ /dev/null @@ -1,273 +0,0 @@ -use vllm_tokenizer::DynTokenizer; - -use super::{DelimitedReasoningParser, ReasoningDelta, ReasoningParser, Result}; - -const THOUGHT_PREFIX: &str = "thought\n"; - -/// Reasoning parser for Google Gemma4 thinking models. -/// -/// Gemma4 emits reasoning inside `<|channel> ... ` spans and adds a -/// structural `thought\n` label at the beginning of the reasoning channel. -/// This parser keeps the delimiter handling in the shared delimited parser and -/// only layers on Gemma4-specific request adjustment plus prefix stripping. -/// -/// Original Python implementation: -/// -pub struct Gemma4ReasoningParser { - inner: DelimitedReasoningParser, - reasoning_text: String, - prefix_stripped: bool, -} - -impl Gemma4ReasoningParser { - /// Create a Gemma4 parser. - pub fn new(tokenizer: DynTokenizer) -> Result { - Ok(Self { - inner: DelimitedReasoningParser::new(tokenizer, "<|channel>", "", false)?, - reasoning_text: String::new(), - prefix_stripped: false, - }) - } - - /// Apply Gemma4's `thought\n` stripping rule to one reasoning delta. - /// - /// Early reasoning text is buffered until we can decide whether it begins - /// with the structural channel label. - fn strip_thought_prefix(&mut self, reasoning: &str) -> Option { - if self.prefix_stripped { - return Some(reasoning.to_string()); - } - - self.reasoning_text.push_str(reasoning); - - if self.reasoning_text.starts_with(THOUGHT_PREFIX) { - let prefix_len = THOUGHT_PREFIX.len(); - let previous_len = self.reasoning_text.len() - reasoning.len(); - if previous_len >= prefix_len { - self.reasoning_text.clear(); - self.prefix_stripped = true; - return Some(reasoning.to_string()); - } - - let prefix_chars_in_delta = prefix_len - previous_len; - let stripped = &reasoning[prefix_chars_in_delta.min(reasoning.len())..]; - if stripped.is_empty() { - if self.reasoning_text.len() >= prefix_len { - self.reasoning_text.clear(); - self.prefix_stripped = true; - } - return None; - } - - self.reasoning_text.clear(); - self.prefix_stripped = true; - return Some(stripped.to_string()); - } - - if THOUGHT_PREFIX.starts_with(&self.reasoning_text) { - return None; - } - - self.prefix_stripped = true; - Some(std::mem::take(&mut self.reasoning_text)) - } - - /// Apply Gemma4-specific reasoning post-processing to one parsed delta. - fn post_process(&mut self, mut result: ReasoningDelta) -> ReasoningDelta { - if let Some(reasoning) = result.reasoning.take() { - result.reasoning = - self.strip_thought_prefix(&reasoning).filter(|text| !text.is_empty()); - } - result - } -} - -impl ReasoningParser for Gemma4ReasoningParser { - fn create(tokenizer: DynTokenizer) -> Result> - where - Self: Sized + 'static, - { - Ok(Box::new(Self::new(tokenizer)?)) - } - - fn preserve_special_tokens(&self) -> bool { - true - } - - fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> { - self.inner.initialize(prompt_token_ids); - self.reasoning_text.clear(); - self.prefix_stripped = false; - Ok(()) - } - - fn push(&mut self, delta: &str) -> Result { - let result = self.inner.push(delta); - Ok(self.post_process(result)) - } - - fn finish(&mut self) -> Result { - let result = self.inner.finish(); - Ok(self.post_process(result)) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use vllm_tokenizer::Tokenizer; - - use super::Gemma4ReasoningParser; - use crate::ReasoningParser; - - struct FakeTokenizer; - - impl Tokenizer for FakeTokenizer { - fn encode( - &self, - text: &str, - _add_special_tokens: bool, - ) -> vllm_tokenizer::Result> { - Ok(text.chars().map(u32::from).collect()) - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - Ok(token_ids - .iter() - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}')) - .collect()) - } - - fn token_to_id(&self, token: &str) -> Option { - match token { - "<|channel>" => Some(1000), - "" => Some(1001), - _ => None, - } - } - } - - fn run_streaming(output: &[&str]) -> (Option, Option) { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = Gemma4ReasoningParser::new(tokenizer).unwrap(); - let mut reasoning = String::new(); - let mut content = String::new(); - - for delta in output { - let result = parser.push(delta).unwrap(); - if let Some(next) = result.reasoning { - reasoning.push_str(&next); - } - if let Some(next) = result.content { - content.push_str(&next); - } - } - - let final_delta = parser.finish().unwrap(); - if let Some(next) = final_delta.reasoning { - reasoning.push_str(&next); - } - if let Some(next) = final_delta.content { - content.push_str(&next); - } - - ( - (!reasoning.is_empty()).then_some(reasoning), - (!content.is_empty()).then_some(content), - ) - } - - #[test] - fn gemma4_reasoning_streaming_handles_channel_delimited_outputs() { - let cases = [ - ( - "no_reasoning", - vec!["This is content"], - None, - Some("This is content"), - ), - ( - "reasoning_and_content", - vec!["<|channel>This is a reasoning sectionThis is the rest"], - Some("This is a reasoning section"), - Some("This is the rest"), - ), - ( - "complete_reasoning", - vec!["<|channel>This is a reasoning section"], - Some("This is a reasoning section"), - None, - ), - ( - "multiple_lines", - vec!["<|channel>This\nThatThis is the rest\nThat"], - Some("This\nThat"), - Some("This is the rest\nThat"), - ), - ( - "no_end", - vec!["<|channel>This is a reasoning section"], - Some("This is a reasoning section"), - None, - ), - ("empty", vec![""], None, None), - ( - "newline_around_reasoning", - vec!["Before\n<|channel>This is a reasoning section\nThis is the rest"], - Some("This is a reasoning section"), - Some("Before\n\nThis is the rest"), - ), - ( - "thought_prefix", - vec!["<|channel>thought\nActual reasoning hereFinal answer"], - Some("Actual reasoning here"), - Some("Final answer"), - ), - ( - "thought_prefix_only", - vec!["<|channel>thought\n"], - None, - None, - ), - ( - "thought_prefix_multiline", - vec!["<|channel>thought\nLine1\nLine2Answer"], - Some("Line1\nLine2"), - Some("Answer"), - ), - ( - "thought_prefix_diverge", - vec!["<|channel>thousand reasonsDone"], - Some("thousand reasons"), - Some("Done"), - ), - ]; - - for (name, output, expected_reasoning, expected_content) in cases { - let (reasoning, content) = run_streaming(&output); - assert_eq!(reasoning.as_deref(), expected_reasoning, "{name}"); - assert_eq!(content.as_deref(), expected_content, "{name}"); - } - } - - #[test] - fn gemma4_strips_thought_prefix_even_when_split_across_deltas() { - let (reasoning, content) = - run_streaming(&["<|channel>thou", "ght", "\nabc", "done"]); - assert_eq!(reasoning.as_deref(), Some("abc")); - assert_eq!(content.as_deref(), Some("done")); - } - - #[test] - fn gemma4_preserves_special_tokens() { - let tokenizer = Arc::new(FakeTokenizer); - let parser = Gemma4ReasoningParser::new(tokenizer).unwrap(); - - assert!(parser.preserve_special_tokens()); - } -} diff --git a/rust/src/reasoning-parser/src/tests.rs b/rust/src/reasoning-parser/src/tests.rs deleted file mode 100644 index da602d9fdddd..000000000000 --- a/rust/src/reasoning-parser/src/tests.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::sync::Arc; - -use vllm_tokenizer::Tokenizer; - -use super::{ - DeepSeekR1ReasoningParser, DelimitedReasoningParser, Qwen3ReasoningParser, ReasoningParser, -}; - -struct FakeTokenizer; - -impl Tokenizer for FakeTokenizer { - fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result> { - Ok(text.chars().map(u32::from).collect()) - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - Ok(token_ids - .iter() - .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}')) - .collect()) - } - - fn token_to_id(&self, token: &str) -> Option { - match token { - "" => Some(1), - "" => Some(2), - "<|START_THINKING|>" => Some(3), - "<|END_THINKING|>" => Some(4), - "◁think▷" => Some(5), - "◁/think▷" => Some(6), - _ => None, - } - } - - fn is_special_id(&self, token_id: u32) -> bool { - token_id == 7 - } -} - -#[test] -fn delimited_content_only_stream() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = - DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); - - assert_eq!( - parser.push("plain content").content.as_deref(), - Some("plain content") - ); -} - -#[test] -fn delimited_single_chunk_with_reasoning_and_content() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = - DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); - - let delta = parser.push("reasonanswer"); - assert_eq!(delta.reasoning.as_deref(), Some("reason")); - assert_eq!(delta.content.as_deref(), Some("answer")); -} - -#[test] -fn delimited_partial_tokens_across_chunks() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = - DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); - - assert!(parser.push("reasonanswer"); - assert_eq!(delta.reasoning.as_deref(), Some("reason")); - assert_eq!(delta.content.as_deref(), Some("answer")); -} - -#[test] -fn delimited_finish_flushes_buffer() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = - DelimitedReasoningParser::new(tokenizer, "", "", false).unwrap(); - parser.initialize(&[1]); - - let delta = parser.push("unfinishedanswer").unwrap(); - assert_eq!(delta.reasoning, None); - assert_eq!(delta.content.as_deref(), Some("reasonanswer")); -} - -#[test] -fn qwen3_prompt_end_marker_starts_in_content() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap(); - parser.initialize(&[2]).unwrap(); - - let delta = parser.push("answer").unwrap(); - assert_eq!(delta.reasoning, None); - assert_eq!(delta.content.as_deref(), Some("answer")); -} - -#[test] -fn qwen3_tolerates_old_and_new_formats() { - let tokenizer = Arc::new(FakeTokenizer); - - let mut old_parser = Qwen3ReasoningParser::new(tokenizer.clone()).unwrap(); - let old = old_parser.push("reasonanswer").unwrap(); - assert_eq!(old.reasoning.as_deref(), Some("reason")); - assert_eq!(old.content.as_deref(), Some("answer")); - - let mut new_parser = Qwen3ReasoningParser::new(tokenizer).unwrap(); - new_parser.initialize(&[1]).unwrap(); - let new = new_parser.push("reasonanswer").unwrap(); - assert_eq!(new.reasoning.as_deref(), Some("reason")); - assert_eq!(new.content.as_deref(), Some("answer")); -} - -#[test] -fn qwen3_stops_scanning_at_last_special_token() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = Qwen3ReasoningParser::new(tokenizer).unwrap(); - - parser.initialize(&[1, 7]).unwrap(); - - let delta = parser.push("answer").unwrap(); - assert_eq!(delta.reasoning, None); - assert_eq!(delta.content.as_deref(), Some("answer")); -} - -#[test] -fn deepseek_r1_defaults_to_reasoning_without_prompt_boundary() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = DeepSeekR1ReasoningParser::new(tokenizer).unwrap(); - - let delta = parser.push("reasonanswer").unwrap(); - assert_eq!(delta.reasoning.as_deref(), Some("reason")); - assert_eq!(delta.content.as_deref(), Some("answer")); -} - -#[test] -fn deepseek_r1_stops_scanning_at_last_special_token() { - let tokenizer = Arc::new(FakeTokenizer); - let mut parser = DeepSeekR1ReasoningParser::new(tokenizer).unwrap(); - - parser.initialize(&[2, 7]).unwrap(); - - let delta = parser.push("reasonanswer").unwrap(); - assert_eq!(delta.reasoning.as_deref(), Some("reason")); - assert_eq!(delta.content.as_deref(), Some("answer")); -} diff --git a/rust/src/server/Cargo.toml b/rust/src/server/Cargo.toml index 6030f972a9f2..f3e03863d495 100644 --- a/rust/src/server/Cargo.toml +++ b/rust/src/server/Cargo.toml @@ -7,25 +7,36 @@ license.workspace = true [dependencies] anyhow.workspace = true asynk-strim-attr.workspace = true +auto_enums.workspace = true axum.workspace = true +educe.workspace = true futures.workspace = true http-body.workspace = true +hyper.workspace = true +hyper-util.workspace = true +indexmap.workspace = true itertools.workspace = true libc.workspace = true llm-multimodal.workspace = true +openssl.workspace = true prost.workspace = true prost-types.workspace = true rmpv.workspace = true serde.workspace = true serde_json.workspace = true serde_with.workspace = true +sha2.workspace = true socket2.workspace = true +subtle.workspace = true thiserror-ext.workspace = true +tls-listener.workspace = true tokio.workspace = true +tokio-openssl.workspace = true tokio-stream.workspace = true tokio-util.workspace = true tonic.workspace = true tonic-prost.workspace = true +tower.workspace = true tower-http.workspace = true tracing.workspace = true tracing-futures.workspace = true @@ -49,8 +60,11 @@ clap.workspace = true expect-test.workspace = true rmp-serde.workspace = true serial_test.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["test-util"] } tower.workspace = true vllm-engine-core-client = { workspace = true, features = ["test-util"] } +vllm-tokenizer = { workspace = true, features = ["test-utils"] } zeromq.workspace = true [lints] diff --git a/rust/src/server/examples/external_engine_openai_qwen.rs b/rust/src/server/examples/external_engine_openai_qwen.rs index 50d6fc1be40f..d1c6c0fc3181 100644 --- a/rust/src/server/examples/external_engine_openai_qwen.rs +++ b/rust/src/server/examples/external_engine_openai_qwen.rs @@ -14,8 +14,8 @@ use tokio_util::sync::CancellationToken; use tracing_subscriber::EnvFilter; use vllm_engine_core_client::TransportMode; use vllm_server::{ - ChatTemplateContentFormatOption, Config, CoordinatorMode, HttpListenerMode, ParserSelection, - RendererSelection, serve, + ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig, + HttpListenerMode, ParserSelection, RendererSelection, serve, }; #[derive(Debug, Parser)] @@ -64,14 +64,20 @@ async fn main() -> Result<()> { tool_call_parser: ParserSelection::Auto, reasoning_parser: ParserSelection::Auto, renderer: RendererSelection::Auto, + language_model_only: false, chat_template: None, default_chat_template_kwargs: None, chat_template_content_format: ChatTemplateContentFormatOption::Auto, - enable_log_requests: false, - enable_request_id_headers: false, + max_logprobs: None, + api_server_options: ApiServerOptions::default(), + cors: CorsConfig::default(), + tls: None, + api_keys: Vec::new(), disable_log_stats: false, grpc_port: None, shutdown_timeout: Duration::ZERO, + keep_alive_timeout: Duration::from_secs(5), + profiler: None, }; let bind_address = format!("127.0.0.1:{port}"); diff --git a/rust/src/server/src/config.rs b/rust/src/server/src/config.rs index f1599d18793f..2ed3da2c1dde 100644 --- a/rust/src/server/src/config.rs +++ b/rust/src/server/src/config.rs @@ -1,12 +1,19 @@ use std::collections::HashMap; +use std::fmt; use std::time::Duration; -use anyhow::Result; +use anyhow::{Result, bail}; +use axum::http::{HeaderName, HeaderValue, Method}; +use educe::Educe; use serde::Serialize; use serde_json::Value; use vllm_chat::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection}; use vllm_engine_core_client::{CoordinatorMode as EngineCoreCoordinatorMode, TransportMode}; +/// Default keep-alive idle timeout (seconds); also the head-read bound +/// when keep-alive is disabled (`0`). +pub const DEFAULT_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(5); + /// How the HTTP server obtains its listening socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum HttpListenerMode { @@ -32,8 +39,121 @@ pub enum CoordinatorMode { External { address: String }, } -/// Normalized runtime configuration for the minimal OpenAI-compatible server. +/// HTTP/API-server behavior switches that affect route-layer responses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)] +pub struct ApiServerOptions { + /// Log a summary line for each completed request. + pub enable_log_requests: bool, + /// When `true`, include prompt token cache details in response usage. + pub enable_prompt_tokens_details: bool, + /// When `true`, set `X-Request-Id` on every HTTP response. + pub enable_request_id_headers: bool, +} + +/// CORS settings mirroring Python's `CORSMiddleware`; the default is permissive. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CorsConfig { + /// Allowed origins. `["*"]` allows any origin. + pub allow_origins: Vec, + /// Allowed methods. `["*"]` allows the standard method set. + pub allow_methods: Vec, + /// Allowed request headers. `["*"]` mirrors the requested headers. + pub allow_headers: Vec, + /// Whether to allow credentials (cookies, authorization headers). + pub allow_credentials: bool, +} + +impl Default for CorsConfig { + fn default() -> Self { + Self { + allow_origins: vec!["*".to_string()], + allow_methods: vec!["*".to_string()], + allow_headers: vec!["*".to_string()], + allow_credentials: false, + } + } +} + +impl CorsConfig { + /// Validate that non-wildcard values parse into HTTP types, so the CORS + /// layer can be built infallibly after startup validation has run. + pub fn validate(&self) -> Result<()> { + for origin in &self.allow_origins { + if origin != "*" { + origin.parse::().map_err(|e| { + anyhow::anyhow!("invalid --allowed-origins value {origin:?}: {e}") + })?; + } + } + for method in &self.allow_methods { + if method != "*" { + method.parse::().map_err(|e| { + anyhow::anyhow!("invalid --allowed-methods value {method:?}: {e}") + })?; + } + } + for header in &self.allow_headers { + if header != "*" { + header.parse::().map_err(|e| { + anyhow::anyhow!("invalid --allowed-headers value {header:?}: {e}") + })?; + } + } + Ok(()) + } +} + +/// TLS settings mirroring Python's uvicorn `ssl_*` arguments. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TlsConfig { + /// PEM certificate chain file. Required when TLS is configured; may also + /// hold the private key (combined PEM) when `key_file` is unset. + pub cert_file: Option, + /// PEM private key file. When `None`, the key is read from `cert_file` + /// (combined PEM). + pub key_file: Option, + /// PEM CA bundle used to verify client certificates (mTLS). Required when + /// `cert_reqs` is non-zero. + pub ca_certs: Option, + /// Client-certificate requirement, mirroring Python's `ssl.CERT_*`: + /// 0 = none, 1 = optional, 2 = required. + pub cert_reqs: i32, + /// OpenSSL cipher string for TLS 1.2 and below, mirroring Python's + /// `ssl.set_ciphers`. `None` keeps the forward-secret AEAD default. + pub ciphers: Option, +} + +impl TlsConfig { + /// Structurally validate the TLS arguments; the cert/key material is parsed + /// later, when the OpenSSL context is built. + pub fn validate(&self) -> Result<()> { + if self.cert_file.is_none() { + bail!( + "--ssl-certfile is required to enable TLS; \ + --ssl-keyfile/--ssl-ca-certs/--ssl-cert-reqs/--ssl-ciphers \ + cannot be used without it" + ); + } + if !matches!(self.cert_reqs, 0..=2) { + bail!( + "--ssl-cert-reqs must be 0 (none), 1 (optional), or 2 (required), got {}", + self.cert_reqs + ); + } + if self.cert_reqs != 0 && self.ca_certs.is_none() { + bail!( + "--ssl-ca-certs is required when --ssl-cert-reqs is {} \ + (client certificate verification)", + self.cert_reqs + ); + } + Ok(()) + } +} + +/// Normalized runtime configuration for the minimal OpenAI-compatible server. +#[derive(Educe, Clone, PartialEq, Eq, Serialize)] +#[educe(Debug)] pub struct Config { /// Frontend-to-engine transport setup. pub transport_mode: TransportMode, @@ -53,6 +173,9 @@ pub struct Config { pub reasoning_parser: ParserSelection, /// Chat renderer selection. pub renderer: RendererSelection, + /// Disable frontend-side multimodal preprocessing and render the model as + /// language-only. + pub language_model_only: bool, /// Server-default chat template override, as a file path or inline /// template. pub chat_template: Option, @@ -60,10 +183,20 @@ pub struct Config { pub default_chat_template_kwargs: Option>, /// How to serialize `message.content` for chat-template rendering. pub chat_template_content_format: ChatTemplateContentFormatOption, - /// Log a summary line for each completed request. - pub enable_log_requests: bool, - /// When `true`, set `X-Request-Id` on every HTTP response. - pub enable_request_id_headers: bool, + /// Optional maximum number of top log probabilities accepted by the + /// frontend. `None` delegates to the text layer default. + pub max_logprobs: Option, + /// HTTP/API-server behavior switches. + pub api_server_options: ApiServerOptions, + /// CORS settings applied to every HTTP response. + pub cors: CorsConfig, + /// TLS settings. `None` serves plaintext HTTP; `Some` terminates TLS at the + /// listener. + pub tls: Option, + /// API keys accepted as bearer tokens for guarded routes. + #[serde(skip_serializing)] + #[educe(Debug(method(fmt_redacted_api_keys)))] + pub api_keys: Vec, /// When `true`, suppress periodic stats logging (throughput, queue depth, /// cache usage). pub disable_log_stats: bool, @@ -72,6 +205,12 @@ pub struct Config { pub grpc_port: Option, /// Maximum time to wait for active HTTP/gRPC requests to drain on shutdown. pub shutdown_timeout: Duration, + /// Maximum idle time on a keep-alive HTTP connection before the server + /// closes it (`VLLM_HTTP_TIMEOUT_KEEP_ALIVE`, default 5s). + pub keep_alive_timeout: Duration, + /// Profiler mode that registers `/start_profile` and `/stop_profile` + /// routes when present. + pub profiler: Option, } impl Config { @@ -79,6 +218,18 @@ impl Config { /// startup. pub fn validate(&self) -> Result<()> { vllm_chat::validate_parser_overrides(&self.tool_call_parser, &self.reasoning_parser)?; + self.cors.validate()?; + if let Some(tls) = &self.tls { + tls.validate()?; + } + if let Some(max_logprobs) = self.max_logprobs + && max_logprobs < -1 + { + bail!( + "max_logprobs must be non-negative or -1, got {}", + max_logprobs + ); + } Ok(()) } @@ -111,3 +262,19 @@ impl Config { } } } + +struct RedactedApiKeys<'a>(&'a [String]); + +impl fmt::Debug for RedactedApiKeys<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + f.debug_list().finish() + } else { + write!(f, "[; {}]", self.0.len()) + } + } +} + +fn fmt_redacted_api_keys(api_keys: &[String], f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&RedactedApiKeys(api_keys), f) +} diff --git a/rust/src/server/src/error.rs b/rust/src/server/src/error.rs index cc425ca076f6..c566f0e2273d 100644 --- a/rust/src/server/src/error.rs +++ b/rust/src/server/src/error.rs @@ -1,7 +1,7 @@ use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use thiserror_ext::{Construct, Macro}; +use thiserror_ext::{AsReport as _, Construct, Macro}; use crate::routes::openai::utils::types::{ErrorDetail, ErrorResponse}; @@ -72,3 +72,145 @@ impl IntoResponse for ApiError { (self.status_code(), Json(self.to_error_response())).into_response() } } + +/// Classify a text-pipeline submit failure: request validation failures are +/// the client's fault and map to HTTP 400, mirroring the Python frontend. +/// Everything else stays an internal 500. +pub fn text_submit_error(context: &'static str, error: vllm_text::Error) -> ApiError { + if error.is_request_validation_error() { + return invalid_request!("{error}"); + } + server_error!("{}: {}", context, error.to_report_string()) +} + +/// Like [`text_submit_error`], for the chat pipeline (which both wraps the +/// text errors and raises its own prompt-length variant). +pub fn chat_submit_error(context: &'static str, error: vllm_chat::Error) -> ApiError { + if error.is_request_validation_error() { + return invalid_request!("{error}"); + } + server_error!("{}: {}", context, error.to_report_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_too_long_maps_to_invalid_request() { + let error = vllm_text::Error::PromptTooLong { + max_model_len: 8192, + prompt_len: 9000, + }; + let api_error = text_submit_error("failed to submit completion request", error); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + let response = api_error.to_error_response(); + assert_eq!(response.error.error_type, "invalid_request_error"); + assert!(response.error.message.contains("8192")); + assert!(response.error.message.contains("9000")); + } + + #[test] + fn invalid_thinking_token_budget_maps_to_invalid_request() { + let api_error = text_submit_error( + "failed to submit completion request", + vllm_text::Error::InvalidThinkingTokenBudget, + ); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + let response = api_error.to_error_response(); + assert_eq!(response.error.error_type, "invalid_request_error"); + assert!(response.error.message.contains("thinking_token_budget")); + } + + #[test] + fn min_tokens_above_max_tokens_maps_to_invalid_request() { + let api_error = text_submit_error( + "failed to submit completion request", + vllm_text::Error::MinTokensExceedsMaxTokens { + min_tokens: 5, + max_tokens: 4, + }, + ); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + let response = api_error.to_error_response(); + assert_eq!(response.error.error_type, "invalid_request_error"); + assert!(response.error.message.contains("min_tokens=5")); + assert!(response.error.message.contains("max_tokens=4")); + } + + #[test] + fn chat_wrapped_prompt_too_long_maps_to_invalid_request() { + let error = vllm_chat::Error::Text(vllm_text::Error::PromptTooLong { + max_model_len: 8192, + prompt_len: 9000, + }); + let api_error = chat_submit_error("failed to submit chat request", error); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn llm_wrapped_empty_prompt_maps_to_invalid_request() { + let error = vllm_text::Error::Llm(vllm_llm::Error::EmptyPromptTokenIds { + request_id: "req-1".to_string(), + }); + let api_error = text_submit_error("failed to submit completion request", error); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn logprobs_validation_maps_to_invalid_request() { + let error = vllm_text::Error::Logprobs(vllm_text::LogprobsError::TooManyCount { + parameter: "logprobs", + requested: 1000, + max_allowed: 20, + }); + let api_error = text_submit_error("failed to submit completion request", error); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + let response = api_error.to_error_response(); + assert_eq!(response.error.error_type, "invalid_request_error"); + assert!(response.error.message.contains("logprobs")); + } + + #[test] + fn chat_wrapped_logprobs_validation_maps_to_invalid_request() { + let error = vllm_chat::Error::Text(vllm_text::Error::Logprobs( + vllm_text::LogprobsError::TooManyCount { + parameter: "prompt_logprobs", + requested: 1000, + max_allowed: 20, + }, + )); + let api_error = chat_submit_error("failed to submit chat request", error); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn out_of_vocab_validation_maps_to_invalid_request() { + let error = vllm_text::Error::TokenIds(vllm_text::TokenIdsError::OutOfVocab { + parameter: "logprob_token_ids", + token_ids: vec![1000], + vocab_size: 1000, + }); + let api_error = text_submit_error("failed to submit completion request", error); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + } + + #[test] + fn empty_allowed_token_ids_maps_to_invalid_request() { + let error = vllm_text::Error::TokenIds(vllm_text::TokenIdsError::EmptyAllowedTokenIds); + let api_error = text_submit_error("failed to submit completion request", error); + assert_eq!(api_error.status_code(), StatusCode::BAD_REQUEST); + let response = api_error.to_error_response(); + assert_eq!(response.error.error_type, "invalid_request_error"); + assert!(response.error.message.contains("allowed_token_ids")); + } + + #[test] + fn other_submit_errors_stay_internal() { + let error = vllm_text::Error::Tokenizer("backend exploded".to_string()); + let api_error = text_submit_error("failed to submit completion request", error); + assert_eq!(api_error.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + let response = api_error.to_error_response(); + assert!(response.error.message.starts_with("failed to submit completion request:")); + } +} diff --git a/rust/src/server/src/grpc/convert.rs b/rust/src/server/src/grpc/convert.rs index 0246064b48db..4327221221da 100644 --- a/rust/src/server/src/grpc/convert.rs +++ b/rust/src/server/src/grpc/convert.rs @@ -3,7 +3,8 @@ use tonic::Status; use uuid::Uuid; -use vllm_engine_core_client::protocol::{StopReason, StructuredOutputsParams}; +use vllm_engine_core_client::protocol::output::StopReason; +use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams; use vllm_text::{ DecodedLogprobs, DecodedPromptLogprobs, FinishReason, Finished, Prompt, SamplingParams, TextDecodeOptions, TextRequest, @@ -91,7 +92,9 @@ pub fn to_text_request( cache_salt: kv.map(|k| &k.cache_salt).filter(|s| !s.is_empty()).cloned(), add_special_tokens: true, data_parallel_rank: None, + reasoning_parser_kwargs: None, lora_request: None, + arrival_time: None, }) } @@ -229,32 +232,18 @@ fn convert_structured_output( StructuredOutput::Json(schema) => { let json: serde_json::Value = serde_json::from_str(schema) .map_err(|e| Status::invalid_argument(format!("invalid json schema: {e}")))?; - StructuredOutputsParams { - json: Some(json), - ..Default::default() - } + StructuredOutputsParams::json(json) } - StructuredOutput::Regex(regex) => StructuredOutputsParams { - regex: Some(regex.clone()), - ..Default::default() - }, - StructuredOutput::Choice(choices) => StructuredOutputsParams { - choice: Some(choices.choices.clone()), - ..Default::default() - }, - StructuredOutput::Grammar(grammar) => StructuredOutputsParams { - grammar: Some(grammar.clone()), - ..Default::default() - }, - StructuredOutput::JsonObject(true) => StructuredOutputsParams { - json_object: Some(true), - ..Default::default() - }, + StructuredOutput::Regex(regex) => StructuredOutputsParams::regex(regex.clone()), + StructuredOutput::Choice(choices) => { + StructuredOutputsParams::choice(choices.choices.clone()) + } + StructuredOutput::Grammar(grammar) => StructuredOutputsParams::grammar(grammar.clone()), + StructuredOutput::JsonObject(true) => StructuredOutputsParams::json_object(), StructuredOutput::JsonObject(false) => return Ok(None), - StructuredOutput::StructuralTag(tag) => StructuredOutputsParams { - structural_tag: Some(tag.clone()), - ..Default::default() - }, + StructuredOutput::StructuralTag(tag) => { + StructuredOutputsParams::structural_tag(tag.clone()) + } }; Ok(Some(params)) } @@ -344,13 +333,13 @@ fn to_finish_info(finished: &Finished, token_ids: &[u32]) -> pb::FinishInfo { (PbFinishReason::Stop as i32, sr) } FinishReason::Length => (PbFinishReason::Length as i32, None), - FinishReason::Abort | FinishReason::Error | FinishReason::Repetition => { + FinishReason::Abort | FinishReason::Error | FinishReason::Repetition(_) => { (PbFinishReason::Aborted as i32, None) } }; pb::FinishInfo { - num_output_tokens: finished.output_token_count as u32, + num_output_tokens: finished.usage.output_token_count as u32, finish_reason, stop_reason, kv_transfer_params: finished.kv_transfer_params.as_ref().and_then(json_to_proto_struct), @@ -501,7 +490,7 @@ impl ResponseOpts { #[cfg(test)] mod tests { - use vllm_engine_core_client::protocol::StopReason; + use vllm_engine_core_client::protocol::output::StopReason; use vllm_text::{FinishReason, Finished, Prompt}; use super::pb::finish_info::{FinishReason as PbFinishReason, StopReason as PbStopReason}; @@ -590,8 +579,11 @@ mod tests { fn finished(reason: FinishReason) -> Finished { Finished { - prompt_token_count: 0, - output_token_count: 0, + usage: vllm_llm::TokenUsage { + prompt_token_count: 0, + output_token_count: 0, + cached_token_count: 0, + }, finish_reason: reason, kv_transfer_params: None, } diff --git a/rust/src/server/src/grpc/mod.rs b/rust/src/server/src/grpc/mod.rs index 2f648aa6ce0e..1fcb8674fee1 100644 --- a/rust/src/server/src/grpc/mod.rs +++ b/rust/src/server/src/grpc/mod.rs @@ -56,12 +56,9 @@ impl pb::generate_server::Generate for GenerateServiceImpl { info!(%request_id, "grpc generate (unary)"); let stream = self.state.chat.text().generate(text_request).await; - let stream = stream.map_err(|e| Status::internal(e.to_report_string()))?; + let stream = stream.map_err(text_error_to_status)?; - let collected = stream - .collect_output() - .await - .map_err(|e| Status::internal(e.to_report_string()))?; + let collected = stream.collect_output().await.map_err(text_error_to_status)?; // Build the single aggregated response. let prompt_info = convert::to_prompt_info( @@ -71,8 +68,7 @@ impl pb::generate_server::Generate for GenerateServiceImpl { ); let finish_info = vllm_text::Finished { - prompt_token_count: collected.prompt_token_ids.len(), - output_token_count: collected.token_ids.len(), + usage: collected.usage, finish_reason: collected.finish_reason, kv_transfer_params: collected.kv_transfer_params, }; @@ -105,7 +101,7 @@ impl pb::generate_server::Generate for GenerateServiceImpl { info!(%request_id, "grpc generate (stream)"); let stream = self.state.chat.text().generate(text_request).await; - let stream = stream.map_err(|e| Status::internal(e.to_report_string()))?; + let stream = stream.map_err(text_error_to_status)?; let (tx, rx) = mpsc::channel(32); @@ -113,7 +109,7 @@ impl pb::generate_server::Generate for GenerateServiceImpl { futures::pin_mut!(stream); while let Some(event) = stream.next().await { let response = match event { - Err(e) => Err(Status::internal(e.to_report_string())), + Err(e) => Err(text_error_to_status(e)), Ok(DecodedTextEvent::Start { prompt_token_ids, prompt_logprobs, @@ -156,3 +152,12 @@ impl pb::generate_server::Generate for GenerateServiceImpl { Ok(Response::new(Box::pin(response_stream))) } } + +fn text_error_to_status(error: vllm_text::Error) -> Status { + let message = error.to_report_string(); + if error.is_request_validation_error() { + Status::invalid_argument(message) + } else { + Status::internal(message) + } +} diff --git a/rust/src/server/src/grpc/tests.rs b/rust/src/server/src/grpc/tests.rs index 17361ae0e86f..83c4de440efc 100644 --- a/rust/src/server/src/grpc/tests.rs +++ b/rust/src/server/src/grpc/tests.rs @@ -1,29 +1,42 @@ use std::future::Future; +use std::io; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; +use std::time::Duration; use futures::StreamExt as _; +use hyper_util::rt::TokioIo; +use openssl::ssl::{SslConnector, SslFiletype, SslMethod}; use serial_test::serial; -use tonic::transport::Server as TonicServer; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpStream; +use tokio_openssl::SslStream; +use tonic::transport::{Channel, Endpoint, Server as TonicServer, Uri}; +use tower::service_fn; use vllm_chat::{ ChatBackend, ChatLlm, ChatRenderer, ChatRequest, ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer, NewChatOutputProcessorOptions, RenderedPrompt, }; -use vllm_engine_core_client::protocol::{ - EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest, +use vllm_engine_core_client::protocol::output::{ + EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs, }; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task}; use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, EngineId}; use vllm_llm::Llm; -use vllm_text::tokenizer::{DynTokenizer, Tokenizer}; +use vllm_text::tokenizer::DynTokenizer; use vllm_text::{Prompt, TextBackend}; +use vllm_tokenizer::test_utils::TestTokenizer; use zeromq::prelude::{SocketRecv, SocketSend}; use zeromq::{DealerSocket, PushSocket, ZmqMessage}; use super::pb::generate_client::GenerateClient; use super::{GenerateServer, GenerateServiceImpl, pb}; +use crate::listener::{Listener, MaybeTlsListener}; use crate::state::AppState; +use crate::tls; +use crate::tls_tests::{TestCerts, server_tls}; // ======================================================================================== // Helpers (mirrors the patterns in routes/tests.rs) @@ -102,19 +115,14 @@ fn engine_outputs_for_request( request_id: &str, output_specs: Vec<(Vec, Option)>, ) -> EngineCoreOutputs { - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: output_specs .into_iter() .map(|(token_ids, finish_reason)| request_output(request_id, token_ids, finish_reason)) .collect(), - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, + ..Default::default() } + .into() } fn default_stream_output_specs() -> Vec<(Vec, Option)> { @@ -144,37 +152,9 @@ fn test_llm(client: EngineCoreClient) -> Llm { #[derive(Clone, Debug)] struct FakeTextBackend; -#[derive(Debug)] -struct FakeTokenizer; - -impl Tokenizer for FakeTokenizer { - fn encode( - &self, - text: &str, - _add_special_tokens: bool, - ) -> vllm_text::tokenizer::Result> { - Ok(text.bytes().map(u32::from).collect()) - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_text::tokenizer::Result { - Ok( - String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::>()) - .into_owned(), - ) - } - - fn token_to_id(&self, token: &str) -> Option { - token.bytes().next().map(u32::from) - } -} - impl TextBackend for FakeTextBackend { fn tokenizer(&self) -> DynTokenizer { - Arc::new(FakeTokenizer) + Arc::new(TestTokenizer::new()) } fn model_id(&self) -> &str { @@ -206,21 +186,17 @@ impl ChatRenderer for FakeTextBackend { fn render(&self, _request: &ChatRequest) -> vllm_chat::Result { Ok(RenderedPrompt { prompt: Prompt::Text(String::new()), + effective_template_kwargs: Default::default(), }) } } -/// Spin up a gRPC server backed by a mock engine that serves a single request -/// with the given output specs. Returns the client, the gRPC server task, and -/// the mock engine task. -async fn grpc_test_server( +/// Build the gRPC service + mock engine that serves a single request with the +/// given output specs. Shared by the plaintext and TLS server fixtures. +async fn setup_grpc_service( engine_id: impl Into, output_specs: Vec<(Vec, Option)>, -) -> ( - GenerateClient, - tokio::task::JoinHandle<()>, - MockEngineTask, -) { +) -> (GenerateServer, MockEngineTask) { let ipc = IpcNamespace::new().expect("create ipc namespace"); let handshake_address = ipc.handshake_endpoint(); let engine_id = engine_id.into(); @@ -258,14 +234,29 @@ async fn grpc_test_server( Arc::new(FakeTextBackend) as Arc, ); let state = Arc::new(AppState::new(vec!["test-model".to_string()], chat)); - let svc = GenerateServer::new(GenerateServiceImpl::new(state)); + ( + GenerateServer::new(GenerateServiceImpl::new(state)), + engine_task, + ) +} + +/// Spin up a plaintext gRPC server backed by a mock engine. Returns the client, +/// the gRPC server task, and the mock engine task. +async fn grpc_test_server( + engine_id: impl Into, + output_specs: Vec<(Vec, Option)>, +) -> ( + GenerateClient, + tokio::task::JoinHandle<()>, + MockEngineTask, +) { + let (svc, engine_task) = setup_grpc_service(engine_id, output_specs).await; - // Bind to an OS-assigned port. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener"); let addr = listener.local_addr().expect("local addr"); let server_task = tokio::spawn(async move { - let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener); + let incoming = MaybeTlsListener::plain(Listener::Tcp(listener)); TonicServer::builder() .add_service(svc) .serve_with_incoming(incoming) @@ -273,7 +264,6 @@ async fn grpc_test_server( .expect("grpc server"); }); - // Connect the client. let grpc_client = GenerateClient::connect(format!("http://{addr}")) .await .expect("connect grpc client"); @@ -281,6 +271,158 @@ async fn grpc_test_server( (grpc_client, server_task, engine_task) } +/// Spin up a TLS gRPC server (server cert from `certs`, `cert_reqs` mTLS mode). +/// Returns the address, the server task, and the mock engine task. +async fn grpc_tls_test_server( + engine_id: impl Into, + output_specs: Vec<(Vec, Option)>, + certs: &TestCerts, + cert_reqs: i32, +) -> (String, tokio::task::JoinHandle<()>, MockEngineTask) { + let (svc, engine_task) = setup_grpc_service(engine_id, output_specs).await; + let context = tls::build_grpc_server_config(&server_tls(certs, cert_reqs)) + .expect("build grpc tls config"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener"); + let addr = listener.local_addr().expect("local addr").to_string(); + + let server_task = tokio::spawn(async move { + let incoming = MaybeTlsListener::tls(Listener::Tcp(listener), context); + TonicServer::builder() + .add_service(svc) + .serve_with_incoming(incoming) + .await + .expect("grpc tls server"); + }); + + (addr, server_task, engine_task) +} + +/// Build a tonic `Generate` client over a tokio-openssl connector, optionally +/// with a client identity for mTLS. Hand-rolled because tonic 0.14 ships no +/// OpenSSL transport. +async fn grpc_tls_client( + certs: &TestCerts, + addr: &str, + identity: Option<&str>, +) -> Result, tonic::transport::Error> { + let ca = certs.path("ca.pem"); + let identity = identity.map(|name| { + ( + certs.path(&format!("{name}.pem")), + certs.path(&format!("{name}.key")), + ) + }); + let target = addr.to_string(); + + let connector = service_fn(move |_: Uri| { + let ca = ca.clone(); + let identity = identity.clone(); + let target = target.clone(); + async move { + let tcp = TcpStream::connect(&target).await?; + let mut builder = + SslConnector::builder(SslMethod::tls_client()).map_err(io::Error::other)?; + builder.set_ca_file(&ca).map_err(io::Error::other)?; + if let Some((cert, key)) = &identity { + builder.set_certificate_chain_file(cert).map_err(io::Error::other)?; + builder.set_private_key_file(key, SslFiletype::PEM).map_err(io::Error::other)?; + } + let mut config = builder.build().configure().map_err(io::Error::other)?; + config.set_verify_hostname(false); + config.set_alpn_protos(b"\x02h2").map_err(io::Error::other)?; + let ssl = config.into_ssl("127.0.0.1").map_err(io::Error::other)?; + let mut stream = SslStream::new(ssl, tcp).map_err(io::Error::other)?; + Pin::new(&mut stream).connect().await.map_err(io::Error::other)?; + Ok::<_, io::Error>(TokioIo::new(stream)) + } + }); + + let channel = Endpoint::from_shared(format!("https://{addr}")) + .expect("grpc endpoint") + .connect_with_connector(connector) + .await?; + Ok(GenerateClient::new(channel)) +} + +/// Complete a raw TLS handshake against the gRPC port (offering ALPN `h2`) for +/// the ALPN-negotiation assertion. +async fn grpc_tls_handshake( + certs: &TestCerts, + addr: &str, +) -> io::Result>>> { + let tcp = TcpStream::connect(addr).await?; + let mut builder = SslConnector::builder(SslMethod::tls_client()).map_err(io::Error::other)?; + builder.set_ca_file(certs.path("ca.pem")).map_err(io::Error::other)?; + let mut config = builder.build().configure().map_err(io::Error::other)?; + config.set_verify_hostname(false); + config.set_alpn_protos(b"\x02h2").map_err(io::Error::other)?; + let ssl = config.into_ssl("127.0.0.1").map_err(io::Error::other)?; + let mut stream = Box::pin(SslStream::new(ssl, tcp).map_err(io::Error::other)?); + stream.as_mut().connect().await.map_err(io::Error::other)?; + Ok(stream) +} + +/// Spin up a plaintext gRPC server, optionally with HTTP/2 keepalive set to +/// `keepalive` for both the PING interval and the unanswered-PING timeout. +async fn grpc_server_with_keepalive( + engine_id: impl Into, + keepalive: Option, +) -> (String, tokio::task::JoinHandle<()>, MockEngineTask) { + let (svc, engine_task) = setup_grpc_service(engine_id, default_stream_output_specs()).await; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind grpc listener"); + let addr = listener.local_addr().expect("local addr").to_string(); + + let mut builder = TonicServer::builder(); + if let Some(interval) = keepalive { + builder = builder + .http2_keepalive_interval(Some(interval)) + .http2_keepalive_timeout(Some(interval)); + } + + let server_task = tokio::spawn(async move { + let incoming = MaybeTlsListener::plain(Listener::Tcp(listener)); + builder + .add_service(svc) + .serve_with_incoming(incoming) + .await + .expect("grpc server"); + }); + + (addr, server_task, engine_task) +} + +/// Establish an HTTP/2 connection (preface + SETTINGS exchange) then go silent, +/// ACKing the server's SETTINGS but never its keepalive PINGs. Returns whether +/// the SERVER closes the connection within `wait`. A minimal hand-rolled h2 peer +/// because a real client auto-ACKs PINGs and so can never be kept-alive-evicted. +async fn h2_unresponsive_peer_closed_within(addr: &str, wait: Duration) -> bool { + let mut tcp = TcpStream::connect(addr).await.expect("connect"); + tcp.write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n").await.expect("preface"); + tcp.write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0]).await.expect("client settings"); + + let closed = tokio::time::timeout(wait, async { + let mut header = [0u8; 9]; + while tcp.read_exact(&mut header).await.is_ok() { + let len = u32::from_be_bytes([0, header[0], header[1], header[2]]) as usize; + let frame_type = header[3]; + let flags = header[4]; + let mut payload = vec![0u8; len]; + if tcp.read_exact(&mut payload).await.is_err() { + return; + } + // ACK the server's SETTINGS so the only thing left unanswered is PINGs. + if frame_type == 0x4 && flags & 0x1 == 0 { + let _ = tcp.write_all(&[0, 0, 0, 0x4, 0x1, 0, 0, 0, 0]).await; + } + } + }) + .await; + + closed.is_ok() +} + // ======================================================================================== // Tests // ======================================================================================== @@ -424,6 +566,34 @@ async fn unary_generate_missing_prompt_returns_invalid_argument() { server_task.abort(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn unary_generate_min_tokens_above_max_tokens_returns_invalid_argument() { + let (mut client, server_task, _engine_task) = + grpc_test_server(b"engine-grpc-min-above-max", default_stream_output_specs()).await; + + let status = client + .generate(pb::GenerateRequest { + request_id: "test-min-above-max".to_string(), + model: "test-model".to_string(), + prompt: Some(pb::generate_request::Prompt::Text("hi".to_string())), + stopping: Some(pb::StoppingCriteria { + max_new_tokens: 4, + min_new_tokens: 5, + ..Default::default() + }), + ..Default::default() + }) + .await + .expect_err("should fail when min_new_tokens exceeds max_new_tokens"); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("min_tokens=5")); + assert!(status.message().contains("max_tokens=4")); + + server_task.abort(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn streaming_generate_yields_incremental_responses() { @@ -512,6 +682,37 @@ async fn streaming_generate_missing_prompt_returns_invalid_argument() { server_task.abort(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn streaming_generate_min_tokens_above_max_tokens_returns_invalid_argument() { + let (mut client, server_task, _engine_task) = grpc_test_server( + b"engine-grpc-stream-min-above-max", + default_stream_output_specs(), + ) + .await; + + let status = client + .generate_stream(pb::GenerateRequest { + request_id: "test-stream-min-above-max".to_string(), + model: "test-model".to_string(), + prompt: Some(pb::generate_request::Prompt::Text("hi".to_string())), + stopping: Some(pb::StoppingCriteria { + max_new_tokens: 4, + min_new_tokens: 5, + ..Default::default() + }), + ..Default::default() + }) + .await + .expect_err("should fail when min_new_tokens exceeds max_new_tokens"); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + assert!(status.message().contains("min_tokens=5")); + assert!(status.message().contains("max_tokens=4")); + + server_task.abort(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn unary_generate_with_sampling_params() { @@ -660,3 +861,173 @@ async fn unary_generate_output_text_defaults_to_true() { engine_task.await.expect("mock engine task"); server_task.abort(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn grpc_generate_succeeds_over_tls() { + let certs = TestCerts::generate(); + let (addr, server_task, engine_task) = grpc_tls_test_server( + b"engine-grpc-tls-unary", + default_stream_output_specs(), + &certs, + 0, + ) + .await; + + let mut client = grpc_tls_client(&certs, &addr, None).await.expect("tls client"); + let response = client + .generate(pb::GenerateRequest { + request_id: "test-tls-unary".to_string(), + model: "test-model".to_string(), + prompt: Some(pb::generate_request::Prompt::Text("hello".to_string())), + stopping: Some(pb::StoppingCriteria { + max_new_tokens: 10, + ..Default::default() + }), + response: Some(pb::ResponseOptions { + output_text: Some(true), + ..Default::default() + }), + ..Default::default() + }) + .await + .expect("unary generate over tls") + .into_inner(); + + assert_eq!(response.outputs.expect("outputs present").text, "hi"); + + engine_task.await.expect("mock engine task"); + server_task.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn grpc_tls_negotiates_h2_alpn() { + let certs = TestCerts::generate(); + let (addr, server_task, _engine_task) = grpc_tls_test_server( + b"engine-grpc-tls-alpn", + default_stream_output_specs(), + &certs, + 0, + ) + .await; + + let stream = grpc_tls_handshake(&certs, &addr).await.expect("handshake"); + assert_eq!( + stream.ssl().selected_alpn_protocol(), + Some(&b"h2"[..]), + "server must negotiate h2 ALPN" + ); + + server_task.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn grpc_mtls_required_rejects_client_without_certificate() { + let certs = TestCerts::generate(); + let (addr, server_task, _engine_task) = grpc_tls_test_server( + b"engine-grpc-tls-mtls-reject", + default_stream_output_specs(), + &certs, + 2, + ) + .await; + + // With TLS 1.3 the missing-client-cert rejection surfaces on first use, not + // at the handshake, so drive an RPC and assert the call fails. + let outcome = match grpc_tls_client(&certs, &addr, None).await { + Err(_) => Err(()), + Ok(mut client) => client + .generate(pb::GenerateRequest { + request_id: "test-tls-mtls-reject".to_string(), + model: "test-model".to_string(), + prompt: Some(pb::generate_request::Prompt::Text("hello".to_string())), + stopping: Some(pb::StoppingCriteria { + max_new_tokens: 10, + ..Default::default() + }), + ..Default::default() + }) + .await + .map(|_| ()) + .map_err(|_| ()), + }; + assert!( + outcome.is_err(), + "mTLS-required gRPC must reject a client without a certificate" + ); + + server_task.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn grpc_mtls_required_accepts_valid_client_certificate() { + let certs = TestCerts::generate(); + let (addr, server_task, engine_task) = grpc_tls_test_server( + b"engine-grpc-tls-mtls-accept", + default_stream_output_specs(), + &certs, + 2, + ) + .await; + + let mut client = grpc_tls_client(&certs, &addr, Some("client")).await.expect("mtls client"); + let response = client + .generate(pb::GenerateRequest { + request_id: "test-tls-mtls".to_string(), + model: "test-model".to_string(), + prompt: Some(pb::generate_request::Prompt::Text("hello".to_string())), + stopping: Some(pb::StoppingCriteria { + max_new_tokens: 10, + ..Default::default() + }), + response: Some(pb::ResponseOptions { + output_text: Some(true), + ..Default::default() + }), + ..Default::default() + }) + .await + .expect("mtls generate over tls") + .into_inner(); + + assert_eq!(response.outputs.expect("outputs present").text, "hi"); + + engine_task.await.expect("mock engine task"); + server_task.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn grpc_keepalive_closes_unresponsive_connection() { + let (addr, server_task, _engine_task) = + grpc_server_with_keepalive(b"engine-grpc-keepalive", Some(Duration::from_millis(150))) + .await; + + let closed = h2_unresponsive_peer_closed_within(&addr, Duration::from_secs(5)).await; + assert!( + closed, + "keepalive must close a peer that stops answering PINGs" + ); + + server_task.abort(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn grpc_without_keepalive_keeps_unresponsive_connection_open() { + // Without keepalive the same unresponsive peer is NOT + // closed, proving the close above is attributable to keepalive. + let (addr, server_task, _engine_task) = + grpc_server_with_keepalive(b"engine-grpc-no-keepalive", None).await; + + let closed = h2_unresponsive_peer_closed_within(&addr, Duration::from_secs(1)).await; + assert!( + !closed, + "without keepalive an idle h2 connection must stay open" + ); + + server_task.abort(); +} diff --git a/rust/src/server/src/lib.rs b/rust/src/server/src/lib.rs index 8d779da132f0..008a29149e62 100644 --- a/rust/src/server/src/lib.rs +++ b/rust/src/server/src/lib.rs @@ -7,21 +7,36 @@ mod listener; mod lora; mod middleware; mod routes; +mod runtime; mod server_info; mod state; +mod tls; +#[cfg(test)] +mod tls_tests; mod utils; +use std::future::Future; use std::sync::{Arc, OnceLock}; +use std::time::Duration; use anyhow::{Context as _, Result}; -use axum::{Router, serve::ListenerExt as _}; -pub use config::{Config, CoordinatorMode, HttpListenerMode}; +use axum::Router; +use axum::body::Body; +use axum::http::Request; +pub use config::{ + ApiServerOptions, Config, CoordinatorMode, CorsConfig, DEFAULT_KEEP_ALIVE_TIMEOUT, + HttpListenerMode, TlsConfig, +}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper_util::rt::{TokioIo, TokioTimer}; +use hyper_util::server::graceful::GracefulShutdown; +use hyper_util::service::TowerToHyperService; use tokio::net::TcpListener; use tokio::time::{Instant, sleep_until}; -use tokio_stream::wrappers::TcpListenerStream; -use tokio_util::either::Either; use tokio_util::sync::CancellationToken; use tonic::transport::Server as TonicServer; +use tower::ServiceExt as _; use tracing::{info, trace, warn}; use vllm_chat::{ChatLlm, LoadModelBackendsOptions, load_model_backends}; pub use vllm_chat::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection}; @@ -29,19 +44,53 @@ use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig}; use vllm_llm::Llm; use vllm_text::TextLlm; -use crate::listener::Listener; +use crate::listener::{Listener, MaybeTlsListener}; use crate::routes::build_router; use crate::server_info::ServerInfoSnapshot; use crate::state::AppState; +/// How often the server PINGs an idle gRPC connection to reap a dead peer; +/// tonic enables no keepalive by default. 2h matches the gRPC-core default. +const GRPC_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(7200); +/// How long the server waits for a keepalive PING reply before dropping the gRPC +/// connection. 20s matches the gRPC-core default. +const GRPC_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(20); + +/// Resolve the public model names accepted by the frontend. +fn effective_served_model_names(model: &str, served_model_name: &[String]) -> Vec { + if served_model_name.is_empty() { + vec![model.to_string()] + } else { + served_model_name.to_vec() + } +} + +/// Choose the gRPC listener host. It follows the HTTP TCP host when there is +/// one; otherwise (unix socket or inherited fd) it defaults to IPv4 loopback +/// rather than all interfaces, so the side-car is never accidentally +/// network-exposed. +fn grpc_bind_host(listener_mode: &HttpListenerMode) -> &str { + match listener_mode { + HttpListenerMode::BindTcp { host, .. } => host.as_str(), + HttpListenerMode::BindUnix { .. } | HttpListenerMode::InheritedFd { .. } => "127.0.0.1", + } +} + /// Build the shared application state for one configured model and one engine /// client. async fn build_state(config: &Config) -> Result> { + // If no served names are specified, fall back to the backend model path so + // that the API always has at least one valid model ID. Use the same primary + // public name for frontend-side metrics labels. + let served_model_names = effective_served_model_names(&config.model, &config.served_model_name); + let metrics_model_name = served_model_names[0].clone(); + // Load both backends from the same model metadata so they stay in sync. let loaded = load_model_backends( &config.model, LoadModelBackendsOptions { renderer: config.renderer, + language_model_only: config.language_model_only, chat_template: config.chat_template.clone(), chat_template_content_format: config.chat_template_content_format, default_chat_template_kwargs: config @@ -66,32 +115,27 @@ async fn build_state(config: &Config) -> Result> { let client = EngineCoreClient::connect(EngineCoreClientConfig { transport_mode: config.transport_mode.clone(), coordinator_mode, - model_name: config.model.clone(), + model_name: metrics_model_name, client_index: 0, }) .await .context("failed to connect to engine core")?; let llm = Llm::new(client).with_log_stats(!config.disable_log_stats); - let text = TextLlm::new(llm, text_backend); + let text = TextLlm::new(llm, text_backend).with_max_logprobs(config.max_logprobs); let chat = ChatLlm::new(text, chat_backend) .with_tool_call_parser(config.tool_call_parser.clone()) .with_reasoning_parser(config.reasoning_parser.clone()); - // If no served names are specified, fall back to the backend model path so - // that the API always has at least one valid model ID. - let served_model_names = if config.served_model_name.is_empty() { - vec![config.model.clone()] - } else { - config.served_model_name.clone() - }; - Ok(Arc::new( AppState::new(served_model_names, chat) - .with_log_requests(config.enable_log_requests) - .with_request_id_headers(config.enable_request_id_headers) - .with_server_info(ServerInfoSnapshot::from_config(config)), + .with_model_path(config.model.clone()) + .with_api_server_options(config.api_server_options) + .with_server_info(ServerInfoSnapshot::from_config(config)) + .with_api_keys(config.api_keys.clone()) + .with_cors(config.cors.clone()) + .with_profiler(config.profiler.clone()), )) } @@ -118,6 +162,15 @@ where { config.validate().context("invalid OpenAI frontend configuration")?; + // Build the TLS server config once, up front, so a bad cert/key fails fast + // before the (potentially long) engine handshake. + let tls_config = config + .tls + .as_ref() + .map(tls::build_server_config) + .transpose() + .context("invalid TLS configuration")?; + // Also check shutdown during the (potentially long) startup handshake. let state = tokio::select! { result = build_state(&config) => result?, @@ -126,43 +179,45 @@ where let listener = Listener::bind(&config.listener_mode) .await .context("failed to bind listener for OpenAI server")?; - let bind_address = listener.local_addr()?; + let bind_address = listener.local_addr_display()?; let model = state.primary_model_name().to_owned(); let app = extend_router(build_router(state.clone())); // Optionally bind the gRPC Generate server on a separate port. Bind // synchronously here so bind errors (port in use, permission denied, ...) - // surface before we start serving, rather than being deferred until - // shutdown. The gRPC listener follows the same host as the HTTP listener so - // that enabling --grpc-port does not accidentally expose the service on all - // interfaces when HTTP is intentionally local-only. + // surface before serving rather than being deferred until shutdown. let grpc_setup = if let Some(grpc_port) = config.grpc_port { - let grpc_host = match &config.listener_mode { - HttpListenerMode::BindTcp { host, .. } => host.as_str(), - HttpListenerMode::BindUnix { .. } | HttpListenerMode::InheritedFd { .. } => "0.0.0.0", - }; + let grpc_host = grpc_bind_host(&config.listener_mode); let grpc_listener = TcpListener::bind((grpc_host, grpc_port)) .await .with_context(|| format!("failed to bind gRPC listener on {grpc_host}:{grpc_port}"))?; let addr = grpc_listener.local_addr()?; + let grpc_listener = Listener::Tcp(grpc_listener); + // gRPC reuses the HTTP TLS config (same SslContext) plus ALPN h2. + let grpc_tls = config + .tls + .as_ref() + .map(tls::build_grpc_server_config) + .transpose() + .context("invalid gRPC TLS configuration")?; let svc = grpc::GenerateServer::new(grpc::GenerateServiceImpl::new(state.clone())); - info!(%addr, "starting gRPC server"); - Some((grpc_listener, svc)) + let svc = TonicServer::builder() + .http2_keepalive_interval(Some(GRPC_KEEPALIVE_INTERVAL)) + .http2_keepalive_timeout(Some(GRPC_KEEPALIVE_TIMEOUT)) + .layer(middleware::request_runtime_layer(state.clone())) + .add_service(svc); + info!(%addr, tls = grpc_tls.is_some(), "starting gRPC server"); + Some((grpc_listener, svc, grpc_tls)) } else { None }; - info!(%bind_address, %model, "starting OpenAI server"); - - // Set TCP_NODELAY on accepted connections to reduce latency. - // By `tap_io` we will do this on every accepted connection. - let listener = listener.tap_io(|io| { - if let Either::Left(tcp_stream) = io - && let Err(err) = tcp_stream.set_nodelay(true) - { - trace!(error = %err, "failed to enable TCP_NODELAY on accepted HTTP connection"); - } - }); + let scheme = if tls_config.is_some() { + "https" + } else { + "http" + }; + info!(%bind_address, %scheme, %model, "starting OpenAI server"); // Run HTTP and gRPC concurrently under a child token of the caller's shutdown // token. Caller cancellation propagates into both protocols; if either @@ -193,13 +248,28 @@ where } }); + // 0 disables keep-alive but still bounds the head read (default), so a + // silent client cannot hold the connection open. + let keep_alive_timeout = config.keep_alive_timeout; + let timeouts = ConnectionTimeouts { + header_read: if keep_alive_timeout.is_zero() { + DEFAULT_KEEP_ALIVE_TIMEOUT + } else { + keep_alive_timeout + }, + keep_alive_enabled: !keep_alive_timeout.is_zero(), + }; + let http_fut = { let shutdown = server_shutdown.child_token(); let server_shutdown = server_shutdown.clone(); let force_shutdown = force_shutdown.clone(); async move { - let server = - axum::serve(listener, app).with_graceful_shutdown(shutdown.cancelled_owned()); + let listener = match tls_config { + Some(context) => MaybeTlsListener::tls(listener, context), + None => MaybeTlsListener::plain(listener), + }; + let server = serve_connections(listener, app, shutdown.cancelled_owned(), timeouts); let result = tokio::select! { result = server => { @@ -221,16 +291,17 @@ where let server_shutdown = server_shutdown.clone(); let force_shutdown = force_shutdown.clone(); async move { - let Some((grpc_listener, svc)) = grpc_setup else { + let Some((grpc_listener, svc, grpc_tls)) = grpc_setup else { // No gRPC configured: just wait for shutdown so we do not race the // join! by resolving early and tripping the cancellation token. shutdown.cancelled().await; return Ok(()); }; - let server = TonicServer::builder().add_service(svc).serve_with_incoming_shutdown( - TcpListenerStream::new(grpc_listener), - shutdown.cancelled_owned(), - ); + let incoming = match grpc_tls { + Some(context) => MaybeTlsListener::tls(grpc_listener, context), + None => MaybeTlsListener::plain(grpc_listener), + }; + let server = svc.serve_with_incoming_shutdown(incoming, shutdown.cancelled_owned()); let result = tokio::select! { result = server => { @@ -256,3 +327,96 @@ where .unwrap_or_else(|| Instant::now() + config.shutdown_timeout); state.shutdown(shutdown_deadline).await } + +/// Per-connection timeouts applied while serving HTTP/HTTPS. +#[derive(Clone, Copy)] +pub(crate) struct ConnectionTimeouts { + /// HTTP/1 header-read timeout (bounds idle keep-alive and the head read). + pub(crate) header_read: Duration, + /// Whether HTTP/1 keep-alive is enabled; `false` closes after each response. + pub(crate) keep_alive_enabled: bool, +} + +/// Serve `app` per connection (HTTP/1) with a keep-alive idle timeout and +/// graceful drain. Hand-rolled on hyper because [`axum::serve()`] takes no config. +async fn serve_connections( + mut listener: L, + app: Router, + shutdown: impl Future + Send, + timeouts: ConnectionTimeouts, +) -> Result<()> +where + L: axum::serve::Listener, +{ + let graceful = GracefulShutdown::new(); + let mut shutdown = std::pin::pin!(shutdown); + loop { + let (io, _addr) = tokio::select! { + conn = listener.accept() => conn, + () = &mut shutdown => break, + }; + + let service = TowerToHyperService::new( + app.clone().map_request(|req: Request| req.map(Body::new)), + ); + let mut builder = http1::Builder::new(); + builder.timer(TokioTimer::new()).header_read_timeout(timeouts.header_read); + if !timeouts.keep_alive_enabled { + builder.keep_alive(false); + } + let connection = builder.serve_connection(TokioIo::new(io), service); + let connection = graceful.watch(connection); + + tokio::spawn(async move { + if let Err(err) = connection.await { + trace!(error = %err, "failed to serve connection"); + } + }); + } + + drop(listener); + graceful.shutdown().await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effective_served_model_names_falls_back_to_backend_model() { + assert_eq!( + effective_served_model_names("backend-model", &[]), + vec!["backend-model"] + ); + } + + #[test] + fn effective_served_model_names_preserves_public_names() { + let served_names = vec!["public-model".to_string(), "public-alias".to_string()]; + + assert_eq!( + effective_served_model_names("backend-model", &served_names), + served_names + ); + } + + #[test] + fn grpc_bind_host_follows_http_tcp_host() { + let mode = HttpListenerMode::BindTcp { + host: "0.0.0.0".to_string(), + port: 8000, + }; + assert_eq!(grpc_bind_host(&mode), "0.0.0.0"); + } + + #[test] + fn grpc_bind_host_defaults_to_loopback_without_tcp_host() { + let unix = HttpListenerMode::BindUnix { + path: "/tmp/vllm.sock".to_string(), + }; + let inherited = HttpListenerMode::InheritedFd { fd: 3 }; + assert_eq!(grpc_bind_host(&unix), "127.0.0.1"); + assert_eq!(grpc_bind_host(&inherited), "127.0.0.1"); + } +} diff --git a/rust/src/server/src/listener.rs b/rust/src/server/src/listener.rs index b7b715b0ebd7..e61484398ae8 100644 --- a/rust/src/server/src/listener.rs +++ b/rust/src/server/src/listener.rs @@ -1,28 +1,50 @@ -//! Unified HTTP listener wrapper for the Rust frontend. +//! Unified listener wrapper for the Rust frontend. //! //! This module hides the difference between TCP and Unix-domain listeners so //! the rest of the server can bind or inherit one socket and pass it to //! `axum::serve(...)` through a single type. use std::io::Result; -use std::net::TcpListener as StdTcpListener; +use std::net::{SocketAddr, TcpListener as StdTcpListener}; use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd}; use std::os::unix::net::UnixListener as StdUnixListener; +use std::pin::Pin; +use std::task::{Context, Poll, ready}; +use auto_enums::enum_derive; +use openssl::ssl::SslContext; use socket2::Socket; +use tls_listener::{AsyncAccept, AsyncListener}; use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream}; -use tokio_util::either::Either; +use tonic::transport::server::{Connected, TcpConnectInfo}; +use tracing::trace; -use crate::HttpListenerMode; +use crate::{HttpListenerMode, tls}; -/// Runtime listener type used by the OpenAI-compatible HTTP server, which is -/// either a TCP listener or a Unix-domain listener. +/// Runtime listener type used by the OpenAI-compatible HTTP or gRPC server, +/// which is either a TCP listener or a Unix-domain listener. #[derive(Debug)] pub enum Listener { Tcp(TcpListener), Unix(UnixListener), } +/// Runtime listener I/O type which is either a TCP stream or a Unix-domain stream. +#[derive(Debug)] +#[enum_derive(tokio1::AsyncRead, tokio1::AsyncWrite)] +pub enum ListenerIo { + Tcp(TcpStream), + Unix(UnixStream), +} + +/// Runtime listener address type which is either a TCP address or a Unix-domain address. +#[derive(Debug)] +#[allow(dead_code)] +pub enum ListenerAddr { + Tcp(SocketAddr), + Unix(tokio::net::unix::SocketAddr), +} + impl Listener { /// Bind or adopt the listener described by the frontend configuration. /// @@ -40,7 +62,7 @@ impl Listener { /// Return a log-friendly local address string for either TCP or Unix /// sockets. - pub fn local_addr(&self) -> Result { + pub fn local_addr_display(&self) -> Result { match self { Self::Tcp(listener) => Ok(listener.local_addr()?.to_string()), Self::Unix(listener) => Ok(match listener.local_addr()?.as_pathname() { @@ -70,30 +92,180 @@ impl Listener { Ok(Self::Tcp(TcpListener::from_std(std_listener)?)) } } + + fn local_addr(&self) -> Result { + match self { + Self::Tcp(listener) => listener.local_addr().map(ListenerAddr::Tcp), + Self::Unix(listener) => listener.local_addr().map(ListenerAddr::Unix), + } + } +} + +/// Allow the unified listener to plug directly into tonic's gRPC server. +impl Connected for ListenerIo { + type ConnectInfo = TcpConnectInfo; + + fn connect_info(&self) -> TcpConnectInfo { + match self { + Self::Tcp(stream) => stream.connect_info(), + Self::Unix(_) => TcpConnectInfo { + local_addr: None, + remote_addr: None, + }, + } + } +} + +/// Attempt to set `TCP_NODELAY` on the accepted TCP stream. +fn enable_tcp_nodelay(stream: TcpStream) -> TcpStream { + if let Err(err) = stream.set_nodelay(true) { + trace!(error = %err, "failed to enable TCP_NODELAY on accepted TCP connection"); + } + stream } /// Allow the unified listener to plug directly into `axum::serve(...)`. impl axum::serve::Listener for Listener { - type Addr = Either; - type Io = Either; + type Addr = ListenerAddr; + type Io = ListenerIo; async fn accept(&mut self) -> (Self::Io, Self::Addr) { match self { Self::Tcp(listener) => { - let (io, addr) = listener.accept().await; - (Either::Left(io), Either::Left(addr)) + let (io, addr) = axum::serve::Listener::accept(listener).await; + ( + ListenerIo::Tcp(enable_tcp_nodelay(io)), + ListenerAddr::Tcp(addr), + ) } Self::Unix(listener) => { - let (io, addr) = listener.accept().await; - (Either::Right(io), Either::Right(addr)) + let (io, addr) = axum::serve::Listener::accept(listener).await; + (ListenerIo::Unix(io), ListenerAddr::Unix(addr)) } } } fn local_addr(&self) -> Result { + self.local_addr() + } +} + +/// Allow the unified listener to be adaptable to `tls_listener`. +impl AsyncAccept for Listener { + type Address = ListenerAddr; + type Connection = ListenerIo; + type Error = std::io::Error; + + fn poll_accept( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Tcp(listener) => { + let (io, addr) = ready!(listener.poll_accept(cx))?; + Poll::Ready(Ok(( + ListenerIo::Tcp(enable_tcp_nodelay(io)), + ListenerAddr::Tcp(addr), + ))) + } + Self::Unix(listener) => { + let (io, addr) = ready!(listener.poll_accept(cx))?; + Poll::Ready(Ok((ListenerIo::Unix(io), ListenerAddr::Unix(addr)))) + } + } + } +} +impl AsyncListener for Listener { + fn local_addr(&self) -> Result { + self.local_addr() + } +} + +/// A listener that may be either a plain TCP/UDS listener or a TLS listener over it. +pub enum MaybeTlsListener { + Plain(Listener), + Tls(tls_listener::TlsListener), +} + +impl MaybeTlsListener { + /// Create a plain listener without TLS. + pub fn plain(listener: Listener) -> Self { + Self::Plain(listener) + } + + /// Create a TLS listener over the given plain listener. + pub fn tls(listener: Listener, context: SslContext) -> Self { + Self::Tls( + tls_listener::builder(context) + .handshake_timeout(tls::TLS_HANDSHAKE_TIMEOUT) + .listen(listener), + ) + } +} + +/// Listener I/O type that may be either a plain TCP/UDS stream or a TLS stream over it. +#[derive(Debug)] +#[enum_derive(tokio1::AsyncRead, tokio1::AsyncWrite)] +pub enum MaybeTlsStream { + Plain(ListenerIo), + Tls(tokio_openssl::SslStream), +} + +/// Allow the maybe-TLS listener to plug directly into `axum::serve(...)`. +impl axum::serve::Listener for MaybeTlsListener { + type Addr = ListenerAddr; + type Io = MaybeTlsStream; + + async fn accept(&mut self) -> (Self::Io, Self::Addr) { + match self { + Self::Plain(listener) => { + let (io, addr) = axum::serve::Listener::accept(listener).await; + (MaybeTlsStream::Plain(io), addr) + } + Self::Tls(tls_listener) => { + let (io, addr) = axum::serve::Listener::accept(tls_listener).await; + (MaybeTlsStream::Tls(io), addr) + } + } + } + + fn local_addr(&self) -> tokio::io::Result { + match self { + Self::Plain(listener) => listener.local_addr(), + Self::Tls(tls_listener) => tls_listener.local_addr(), + } + } +} + +/// Allow the maybe-TLS listener to plug directly into tonic's gRPC server. +impl Connected for MaybeTlsStream { + type ConnectInfo = TcpConnectInfo; + + fn connect_info(&self) -> TcpConnectInfo { match self { - Self::Tcp(listener) => listener.local_addr().map(Either::Left), - Self::Unix(listener) => listener.local_addr().map(Either::Right), + Self::Plain(stream) => stream.connect_info(), + Self::Tls(stream) => stream.get_ref().connect_info(), + } + } +} + +/// Allow the maybe-TLS listener to be adaptable to tonic's incoming stream shape. +impl futures::Stream for MaybeTlsListener { + type Item = std::io::Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Plain(listener) => { + let listener = Pin::new(listener); + let (io, _) = ready!(listener.poll_accept(cx))?; + Poll::Ready(Some(Ok(MaybeTlsStream::Plain(io)))) + } + Self::Tls(tls_listener) => { + let tls_listener = Pin::new(tls_listener); + let (io, _) = + ready!(tls_listener.poll_accept(cx)).map_err(std::io::Error::other)?; + Poll::Ready(Some(Ok(MaybeTlsStream::Tls(io)))) + } } } } diff --git a/rust/src/server/src/lora.rs b/rust/src/server/src/lora.rs index d58a61df862c..e92c66341940 100644 --- a/rust/src/server/src/lora.rs +++ b/rust/src/server/src/lora.rs @@ -1,6 +1,6 @@ -use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; +use indexmap::IndexMap; use tokio::sync::{Mutex, RwLock}; use vllm_engine_core_client::EngineCoreClient; use vllm_engine_core_client::protocol::lora::LoraRequest; @@ -15,8 +15,8 @@ pub(crate) struct LoraModelResolution { /// Runtime registry for dynamically loaded LoRA adapters. pub(crate) struct LoraManager { - /// Dynamically loaded LoRA adapters keyed by public model name. - requests: RwLock>, + /// Dynamically loaded LoRA adapters keyed by public model name, in load order. + requests: RwLock>, /// Monotonic adapter id allocator. LoRA ids are one-indexed. id_counter: AtomicU64, /// Serialize dynamic LoRA registry updates around engine utility calls. @@ -51,18 +51,15 @@ pub(crate) enum UnloadLoraError { impl LoraManager { pub fn new() -> Self { Self { - requests: RwLock::new(BTreeMap::new()), + requests: RwLock::new(IndexMap::new()), id_counter: AtomicU64::new(0), update_lock: Mutex::new(()), } } - /// Return base served model names plus dynamically loaded LoRA adapter - /// names. - pub async fn served_model_names(&self, base_model_names: &[String]) -> Vec { - let mut names = base_model_names.to_vec(); - names.extend(self.requests.read().await.keys().cloned()); - names + /// Snapshot loaded LoRA adapters in load order. + pub async fn served_lora_requests(&self) -> Vec { + self.requests.read().await.values().cloned().collect() } /// Resolve the requested model against one consistent LoRA registry @@ -163,6 +160,6 @@ impl LoraManager { }); } - Ok(self.requests.write().await.remove(lora_name).unwrap_or(lora_request)) + Ok(self.requests.write().await.shift_remove(lora_name).unwrap_or(lora_request)) } } diff --git a/rust/src/server/src/middleware/auth.rs b/rust/src/server/src/middleware/auth.rs new file mode 100644 index 000000000000..696e30615a87 --- /dev/null +++ b/rust/src/server/src/middleware/auth.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Request, State}; +use axum::http::header::AUTHORIZATION; +use axum::http::{HeaderValue, Method, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +use crate::state::{ApiKeyHash, AppState, hash_api_key}; + +const GUARDED_PREFIXES: &[&str] = &["/v1", "/v2", "/inference"]; + +/// Authenticate guarded HTTP routes with an OpenAI-compatible bearer token. +/// +/// Mirrors Python `AuthenticationMiddleware`: OPTIONS requests and non-guarded +/// helper endpoints such as `/health` are allowed through without a token. +pub async fn authenticate_api_key( + State(state): State>, + req: Request, + next: Next, +) -> Response { + if req.method() == Method::OPTIONS || !requires_auth(req.uri().path()) { + return next.run(req).await; + } + + if verify_token(req.headers().get(AUTHORIZATION), state.api_key_hashes()) { + return next.run(req).await; + } + + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Unauthorized" })), + ) + .into_response() +} + +fn requires_auth(path: &str) -> bool { + GUARDED_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) +} + +fn verify_token(authorization: Option<&HeaderValue>, api_key_hashes: &[ApiKeyHash]) -> bool { + let Some(authorization) = authorization else { + return false; + }; + let Ok(authorization) = authorization.to_str() else { + return false; + }; + let Some((scheme, token)) = authorization.split_once(' ') else { + return false; + }; + if !scheme.eq_ignore_ascii_case("bearer") { + return false; + } + + let token_hash = hash_api_key(token); + let mut token_match = false; + for api_key_hash in api_key_hashes { + token_match |= constant_time_eq(&token_hash, api_key_hash); + } + token_match +} + +fn constant_time_eq(left: &ApiKeyHash, right: &ApiKeyHash) -> bool { + use subtle::ConstantTimeEq; + + bool::from(left.ct_eq(right)) +} + +#[cfg(test)] +mod tests { + use super::constant_time_eq; + use crate::state::hash_api_key; + + #[test] + fn constant_time_eq_checks_sha256_digests() { + assert!(constant_time_eq( + &hash_api_key("secret"), + &hash_api_key("secret") + )); + assert!(!constant_time_eq( + &hash_api_key("secret"), + &hash_api_key("secrex") + )); + assert!(!constant_time_eq( + &hash_api_key("secret"), + &hash_api_key("secret-more") + )); + } +} diff --git a/rust/src/server/src/middleware/cors.rs b/rust/src/server/src/middleware/cors.rs new file mode 100644 index 000000000000..bd158880e199 --- /dev/null +++ b/rust/src/server/src/middleware/cors.rs @@ -0,0 +1,141 @@ +//! CORS support mirroring Python's Starlette `CORSMiddleware`. +//! +//! Built on `tower_http::cors::CorsLayer`, configured to reproduce Starlette's +//! `CORSMiddleware` behavior for the `--allowed-origins` / `--allowed-methods` / +//! `--allowed-headers` / `--allow-credentials` settings. Two intentional +//! behavioral differences remain, both invisible to real clients: +//! +//! - A rejected preflight returns `200` (empty) rather than Starlette's +//! `400 "Disallowed CORS ..."`. The browser denies the request either way +//! (the disallowed `Access-Control-Allow-*` headers are simply absent), and +//! tower-http makes the preflight reject decision inside its short-circuit, +//! so matching the `400` would mean re-implementing the layer. +//! - A bare `OPTIONS` (no `Access-Control-Request-Method`) returns `200` +//! rather than `405`. No real client sends one. + +use std::time::Duration; + +use axum::extract::Request; +use axum::http::{HeaderName, HeaderValue, Method, header}; +use axum::middleware::Next; +use axum::response::Response; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; + +use crate::config::CorsConfig; + +/// The method set that `"*"` expands to. +const ALL_METHODS: [Method; 7] = [ + Method::DELETE, + Method::GET, + Method::HEAD, + Method::OPTIONS, + Method::PATCH, + Method::POST, + Method::PUT, +]; + +/// Headers always treated as allowed (the CORS safelist). +const SAFELISTED_HEADERS: [&str; 4] = [ + "accept", + "accept-language", + "content-language", + "content-type", +]; + +fn is_wildcard(values: &[String]) -> bool { + values.iter().any(|value| value == "*") +} + +/// Build a `CorsLayer` from the resolved [`CorsConfig`]. +/// +/// Values are assumed valid: [`CorsConfig::validate`] runs at startup before +/// the router is built. +pub fn cors_layer(cfg: &CorsConfig) -> CorsLayer { + let wildcard_origins = is_wildcard(&cfg.allow_origins); + + let allow_origin = if wildcard_origins { + if cfg.allow_credentials { + // `*` with credentials is illegal, so reflect the request origin + // instead; this also avoids tower-http's wildcard+credentials panic. + AllowOrigin::mirror_request() + } else { + AllowOrigin::any() + } + } else { + AllowOrigin::list( + cfg.allow_origins + .iter() + .map(|origin| origin.parse::().expect("validated origin")) + .collect::>(), + ) + }; + + // Expand `*` to an explicit list rather than `Any`, so we emit the method + // names (not `*`) and never hit tower-http's `Any`+credentials panic. + let allow_methods = if is_wildcard(&cfg.allow_methods) { + AllowMethods::list(ALL_METHODS) + } else { + AllowMethods::list( + cfg.allow_methods + .iter() + .map(|method| method.parse::().expect("validated method")) + .collect::>(), + ) + }; + + let allow_headers = if is_wildcard(&cfg.allow_headers) { + // `*` mirrors the requested headers. + AllowHeaders::mirror_request() + } else { + // Union the safelisted headers, lowercased and sorted. + let mut names: Vec = SAFELISTED_HEADERS.iter().map(|s| s.to_string()).collect(); + names.extend(cfg.allow_headers.iter().map(|h| h.to_ascii_lowercase())); + names.sort(); + names.dedup(); + AllowHeaders::list( + names + .iter() + .map(|header| header.parse::().expect("validated header")) + .collect::>(), + ) + }; + + // Emit `Vary: Origin` only when the allow-origin is dynamic (explicit + // origins, or credentials); the wildcard + no-credentials case emits no + // `Vary` at all, and an empty list disables the header here. + let vary: Vec = if !wildcard_origins || cfg.allow_credentials { + vec![header::ORIGIN] + } else { + vec![] + }; + + CorsLayer::new() + .allow_origin(allow_origin) + .allow_methods(allow_methods) + .allow_headers(allow_headers) + .allow_credentials(cfg.allow_credentials) + .max_age(Duration::from_secs(600)) + .vary(vary) +} + +/// Strip CORS response headers when the request carried no `Origin`. +/// +/// A request without an `Origin` should carry no CORS headers, but tower-http +/// emits `Vary` and `Access-Control-Allow-*` unconditionally. Removing them on +/// no-`Origin` requests keeps non-CORS responses (e.g. `/health`, plain `curl`) +/// clean. +pub async fn strip_cors_on_no_origin(req: Request, next: Next) -> Response { + let had_origin = req.headers().contains_key(header::ORIGIN); + let mut response = next.run(req).await; + if !had_origin { + let headers = response.headers_mut(); + headers.remove(header::VARY); + headers.remove(header::ACCESS_CONTROL_ALLOW_ORIGIN); + headers.remove(header::ACCESS_CONTROL_ALLOW_CREDENTIALS); + headers.remove(header::ACCESS_CONTROL_ALLOW_METHODS); + headers.remove(header::ACCESS_CONTROL_ALLOW_HEADERS); + headers.remove(header::ACCESS_CONTROL_MAX_AGE); + headers.remove(header::ACCESS_CONTROL_EXPOSE_HEADERS); + } + response +} diff --git a/rust/src/server/src/middleware/mod.rs b/rust/src/server/src/middleware/mod.rs index 1f9647c4efa1..d61f8bc3ccef 100644 --- a/rust/src/server/src/middleware/mod.rs +++ b/rust/src/server/src/middleware/mod.rs @@ -1,7 +1,13 @@ +mod auth; +mod cors; mod load; mod metrics; +mod offload; mod request_id; +pub use auth::authenticate_api_key; +pub use cors::{cors_layer, strip_cors_on_no_origin}; pub use load::track_server_load; pub use metrics::track_http_metrics; +pub(crate) use offload::request_runtime_layer; pub use request_id::set_request_id_header; diff --git a/rust/src/server/src/middleware/offload.rs b/rust/src/server/src/middleware/offload.rs new file mode 100644 index 000000000000..28cde754c4ca --- /dev/null +++ b/rust/src/server/src/middleware/offload.rs @@ -0,0 +1,134 @@ +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::http::Request; +use axum::response::{IntoResponse, Response}; +use futures::future::BoxFuture; +use tokio_util::task::AbortOnDropHandle; +use tonic::Status; +use tower::Service; +use tower::layer::layer_fn; +use tracing::error; + +use crate::error::{ApiError, server_error}; +use crate::state::AppState; + +/// Request paths that are run on the request runtime. +/// +/// These routes can perform CPU-heavy request preparation, including JSON +/// extraction, validation, chat-template rendering, tokenization, request +/// lowering, and engine submission. Lightweight operational routes stay on the +/// HTTP runtime. +const OFFLOADED_PATHS: &[&str] = &[ + // HTTP routes: + "/v1/chat/completions", + "/v1/completions", + "/tokenize", + "/detokenize", + "/inference/v1/generate", + // gRPC routes: + "/vllm.Generate/Generate", + "/vllm.Generate/GenerateStream", +]; + +/// Return a Tower layer that runs selected data-plane requests on the request runtime, +/// so that we can offset heavy request parsing and preprocessing from the HTTP runtime. +pub(crate) fn request_runtime_layer( + state: Arc, +) -> impl tower::Layer> + Clone { + layer_fn(move |inner| RequestRuntimeService { + inner, + state: state.clone(), + }) +} + +/// Service produced by [`request_runtime_layer`]. +#[derive(Clone)] +pub(crate) struct RequestRuntimeService { + inner: S, + state: Arc, +} + +impl Service> for RequestRuntimeService +where + S: Service> + Clone + Send + 'static, + S::Future: Send + 'static, + S::Response: RequestRuntimeErrorResponse + Send + 'static, + S::Error: Send + 'static, + B: Send + 'static, +{ + type Error = S::Error; + type Future = BoxFuture<'static, Result>; + type Response = S::Response; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + if !should_offload(req.uri().path()) { + return Box::pin(self.inner.call(req)); + } + + // Axum extractors and route handlers execute inside the inner service, + // so offloading here moves request parsing and preprocessing off the + // HTTP runtime without wrapping each handler manually. For streaming + // HTTP responses, the response body is still polled on the HTTP runtime. + let clone = self.inner.clone(); + let mut inner = std::mem::replace(&mut self.inner, clone); + let task = AbortOnDropHandle::new(self.state.request_runtime().spawn(inner.call(req))); + + Box::pin(async move { + match task.await { + Ok(result) => result, + Err(error) => { + error!(%error, "request runtime task failed"); + Ok(S::Response::request_runtime_error_response()) + } + } + }) + } +} + +trait RequestRuntimeErrorResponse { + fn request_runtime_error_response() -> Self; +} + +impl RequestRuntimeErrorResponse for Response { + fn request_runtime_error_response() -> Self { + server_error!("request runtime task failed").into_response() + } +} + +impl RequestRuntimeErrorResponse for axum::http::Response { + fn request_runtime_error_response() -> Self { + Status::internal("request runtime task failed").into_http() + } +} + +fn should_offload(path: &str) -> bool { + OFFLOADED_PATHS.contains(&path) +} + +#[cfg(test)] +mod tests { + use super::should_offload; + + #[test] + fn offloads_generation_and_tokenization_paths() { + assert!(should_offload("/v1/chat/completions")); + assert!(should_offload("/v1/completions")); + assert!(should_offload("/tokenize")); + assert!(should_offload("/detokenize")); + assert!(should_offload("/inference/v1/generate")); + assert!(should_offload("/vllm.Generate/Generate")); + assert!(should_offload("/vllm.Generate/GenerateStream")); + } + + #[test] + fn passes_through_lightweight_paths() { + assert!(!should_offload("/health")); + assert!(!should_offload("/metrics")); + assert!(!should_offload("/v1/models")); + } +} diff --git a/rust/src/server/src/routes.rs b/rust/src/server/src/routes.rs index a0473c783a03..11918c4fbc78 100644 --- a/rust/src/server/src/routes.rs +++ b/rust/src/server/src/routes.rs @@ -1,3 +1,4 @@ +mod abort_requests; mod cache; mod collective_rpc; mod health; @@ -6,20 +7,28 @@ mod load; mod lora; mod metrics; pub(crate) mod openai; +mod pause; +mod profile; mod server_info; mod sleep; +mod tokenize; mod version; +mod world_size; use std::sync::Arc; use axum::Router; +use axum::extract::DefaultBodyLimit; use axum::middleware::{from_fn, from_fn_with_state}; use axum::routing::{get, post}; use tower_http::trace::TraceLayer; +use tracing::warn; use crate::middleware; use crate::state::AppState; +const DEFAULT_JSON_BODY_LIMIT_BYTES: usize = 32 * 1024 * 1024; + fn server_dev_mode_enabled() -> bool { std::env::var("VLLM_SERVER_DEV_MODE") .ok() @@ -71,7 +80,9 @@ fn build_router_with_options( .route("/v1/models", get(openai::list_models)) .route("/v1/completions", post(openai::completions)) .route("/v1/chat/completions", post(openai::chat_completions)) - // vLLM specific inference endpoints + // vLLM specific endpoints + .route("/tokenize", post(tokenize::tokenize)) + .route("/detokenize", post(tokenize::detokenize)) .route("/inference/v1/generate", post(inference::generate)); if runtime_lora_updating_enabled { @@ -87,18 +98,52 @@ fn build_router_with_options( .route("/reset_mm_cache", post(cache::reset_mm_cache)) .route("/reset_encoder_cache", post(cache::reset_encoder_cache)) .route("/collective_rpc", post(collective_rpc::collective_rpc)) + .route("/abort_requests", post(abort_requests::abort_requests)) .route("/sleep", post(sleep::sleep)) .route("/wake_up", post(sleep::wake_up)) .route("/is_sleeping", get(sleep::is_sleeping)) + .route("/pause", post(pause::pause)) + .route("/resume", post(pause::resume)) + .route("/is_paused", get(pause::is_paused)) .route("/server_info", get(server_info::server_info)) + .route("/get_world_size", get(world_size::get_world_size)) + } + + if let Some(profiler) = &state.profiler { + warn!( + mode = profiler, + "profiler is enabled in the API server; \ + this should only be used for local development", + ); + router = router + .route("/start_profile", post(profile::start_profile)) + .route("/stop_profile", post(profile::stop_profile)); } - let enable_request_id_headers = state.enable_request_id_headers; + let enable_request_id_headers = state.api_server_options.enable_request_id_headers; + let enable_api_key_auth = state.has_api_keys(); let mut router = router .with_state(state.clone()) - .layer(from_fn_with_state(state, middleware::track_server_load)) + .layer(DefaultBodyLimit::max(DEFAULT_JSON_BODY_LIMIT_BYTES)) + .layer(middleware::request_runtime_layer(state.clone())) + .layer(from_fn_with_state( + state.clone(), + middleware::track_server_load, + )) .layer(from_fn(middleware::track_http_metrics)) - .layer(TraceLayer::new_for_http()); + .layer(middleware::cors_layer(&state.cors)) + .layer(from_fn(middleware::strip_cors_on_no_origin)); + + if enable_api_key_auth { + router = router.layer(from_fn_with_state( + state.clone(), + middleware::authenticate_api_key, + )); + } + + // Later layers wrap earlier ones. Keep tracing outside auth so rejected + // requests are visible, while metrics/load only see authenticated traffic. + router = router.layer(TraceLayer::new_for_http()); if enable_request_id_headers { router = router.layer(from_fn(middleware::set_request_id_header)); diff --git a/rust/src/server/src/routes/abort_requests.rs b/rust/src/server/src/routes/abort_requests.rs new file mode 100644 index 000000000000..34fb041c800e --- /dev/null +++ b/rust/src/server/src/routes/abort_requests.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use axum::extract::rejection::JsonRejection; +use axum::http::StatusCode; +use serde::Deserialize; + +use crate::error::ApiError; +use crate::state::AppState; +use crate::utils::utility_call_error; + +#[derive(Debug, Deserialize)] +pub(crate) struct AbortRequestsRequest { + request_ids: Option>, +} + +pub async fn abort_requests( + State(state): State>, + body: Result, JsonRejection>, +) -> Result { + let Json(body) = body.map_err(|error| ApiError::json_parse_error(error.body_text()))?; + let request_ids = body.request_ids.ok_or_else(|| { + ApiError::invalid_request( + "Missing 'request_ids' in request body".to_string(), + Some("request_ids"), + ) + })?; + + state + .chat + .abort(&request_ids) + .await + .map_err(|error| utility_call_error("abort_requests", error))?; + + Ok(StatusCode::OK) +} diff --git a/rust/src/server/src/routes/cache.rs b/rust/src/server/src/routes/cache.rs index 580b91d41314..0626d2ffff3c 100644 --- a/rust/src/server/src/routes/cache.rs +++ b/rust/src/server/src/routes/cache.rs @@ -1,8 +1,9 @@ use std::sync::Arc; +use axum::Json; use axum::extract::{Query, State}; use axum::http::StatusCode; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::error::ApiError; use crate::state::AppState; @@ -16,19 +17,24 @@ pub(crate) struct ResetPrefixCacheParams { reset_external: bool, } +#[derive(Debug, Serialize)] +pub(crate) struct ResetPrefixCacheResponse { + success: bool, +} + /// Reset the local prefix cache and optionally the connector-managed external /// cache. pub async fn reset_prefix_cache( State(state): State>, Query(params): Query, -) -> Result { - state +) -> Result, ApiError> { + let success = state .engine_core_client() .reset_prefix_cache(params.reset_running_requests, params.reset_external) .await .map_err(|error| utility_call_error("reset_prefix_cache", error))?; - Ok(StatusCode::OK) + Ok(Json(ResetPrefixCacheResponse { success })) } /// Reset the multi-modal cache. diff --git a/rust/src/server/src/routes/http_client_tests.rs b/rust/src/server/src/routes/http_client_tests.rs index 8055ada9794f..6d479854a0b7 100644 --- a/rust/src/server/src/routes/http_client_tests.rs +++ b/rust/src/server/src/routes/http_client_tests.rs @@ -18,14 +18,16 @@ use vllm_chat::{ ChatBackend, ChatLlm, ChatRenderer, ChatRequest, ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer, NewChatOutputProcessorOptions, RenderedPrompt, }; -use vllm_engine_core_client::protocol::{ - EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest, +use vllm_engine_core_client::protocol::output::{ + EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs, }; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task}; use vllm_engine_core_client::{EngineCoreClient, EngineCoreClientConfig, EngineId}; use vllm_llm::Llm; -use vllm_text::tokenizer::{DynTokenizer, Tokenizer}; +use vllm_text::tokenizer::DynTokenizer; use vllm_text::{Prompt, TextBackend}; +use vllm_tokenizer::test_utils::TestTokenizer; use zeromq::prelude::{SocketRecv, SocketSend}; use zeromq::{DealerSocket, PushSocket, ZmqMessage}; @@ -109,19 +111,14 @@ fn engine_outputs_for_request( request_id: &str, output_specs: Vec<(Vec, Option)>, ) -> EngineCoreOutputs { - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: output_specs .into_iter() .map(|(token_ids, finish_reason)| request_output(request_id, token_ids, finish_reason)) .collect(), - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, + ..Default::default() } + .into() } fn default_stream_output_specs() -> Vec<(Vec, Option)> { @@ -151,37 +148,9 @@ fn test_llm(client: EngineCoreClient) -> Llm { #[derive(Clone, Debug)] struct FakeChatBackend; -#[derive(Debug)] -struct FakeChatTokenizer; - -impl Tokenizer for FakeChatTokenizer { - fn encode( - &self, - text: &str, - _add_special_tokens: bool, - ) -> vllm_text::tokenizer::Result> { - Ok(text.bytes().map(u32::from).collect()) - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_text::tokenizer::Result { - Ok( - String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::>()) - .into_owned(), - ) - } - - fn token_to_id(&self, token: &str) -> Option { - token.bytes().next().map(u32::from) - } -} - impl TextBackend for FakeChatBackend { fn tokenizer(&self) -> DynTokenizer { - Arc::new(FakeChatTokenizer) + Arc::new(TestTokenizer::new()) } fn model_id(&self) -> &str { @@ -223,6 +192,7 @@ impl ChatRenderer for FakeChatBackend { } Ok(RenderedPrompt { prompt: Prompt::Text(prompt), + effective_template_kwargs: Default::default(), }) } } diff --git a/rust/src/server/src/routes/inference/generate.rs b/rust/src/server/src/routes/inference/generate.rs index f15f757c09a3..c11e4c79ca53 100644 --- a/rust/src/server/src/routes/inference/generate.rs +++ b/rust/src/server/src/routes/inference/generate.rs @@ -19,15 +19,16 @@ use tracing::{error, info, trace}; use tracing_futures::Instrument as _; use vllm_engine_core_client::protocol::logprobs::{Logprobs, PositionLogprobs}; use vllm_llm::{ - CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStreamExt as _, + CollectedGenerateOutput, FinishReason, GenerateOutput, GenerateOutputStreamExt as _, TokenUsage, }; -use self::convert::prepare_generate_request; +use self::convert::{ResponseOptions, prepare_generate_request}; use self::types::{ GenerateLogprob, GenerateRequest, GenerateResponse, GenerateResponseChoice, GenerateResponseStreamChoice, GenerateStreamResponse, }; -use crate::error::{ApiError, bail_server_error, server_error}; +use crate::config::ApiServerOptions; +use crate::error::{ApiError, bail_server_error, server_error, text_submit_error}; use crate::routes::openai::utils::logprobs::clamp_logprob; use crate::routes::openai::utils::types::{ChatLogProbs, ChatLogProbsContent, TopLogProb, Usage}; use crate::routes::openai::utils::validated_json::ValidatedJson; @@ -53,11 +54,8 @@ pub async fn generate( engine_request_id = tracing::field::Empty, ); - let log_request = state.enable_log_requests; - let include_logprobs = prepared.include_logprobs; - let include_prompt_logprobs = prepared.include_prompt_logprobs; + let api_server_options = state.api_server_options; let stream = prepared.stream; - let raw_stream = match state .chat .text() @@ -67,11 +65,8 @@ pub async fn generate( { Ok(stream) => stream, Err(error) => { - return server_error!( - "failed to submit raw generate request: {}", - error.to_report_string() - ) - .into_response(); + return text_submit_error("failed to submit raw generate request", error) + .into_response(); } }; @@ -79,10 +74,8 @@ pub async fn generate( let chunk_stream = generate_chunk_stream( raw_stream, prepared.request_id, - log_request, - prepared.include_usage, - prepared.include_continuous_usage, - include_logprobs, + api_server_options, + prepared.options, ); let sse_stream = generate_sse_stream(chunk_stream).instrument(request_span); @@ -100,21 +93,11 @@ pub async fn generate( } }; - if log_request { - info!( - parent: &request_span, - prompt_tokens = collected.prompt_token_ids.len(), - output_tokens = collected.token_ids.len(), - finish_reason = collected.finish_reason.as_str(), - "generate finished" - ); - } - let response = match collect_generate( collected, prepared.request_id, - include_logprobs, - include_prompt_logprobs, + api_server_options, + prepared.options, ) { Ok(response) => response, Err(error) => return error.into_response(), @@ -127,27 +110,36 @@ pub async fn generate( async fn generate_chunk_stream( stream: impl Stream>, request_id: String, - log_request: bool, - include_usage: bool, - include_continuous_usage: bool, - include_logprobs: bool, + ApiServerOptions { + enable_log_requests, + enable_prompt_tokens_details, + .. + }: ApiServerOptions, + ResponseOptions { + include_usage, + include_continuous_usage, + include_logprobs, + // Ignored: raw generate streaming has no prompt-logprobs wire shape. + include_prompt_logprobs: _, + }: ResponseOptions, mut y: TryYielder, ) -> Result<(), ApiError> { pin_mut!(stream); - let mut prompt_tokens: Option = None; - let mut output_tokens = 0_u32; + let mut prompt_tokens = None; + let mut usage = TokenUsage::default(); while let Some(next) = stream.next().await { match next { Ok(output) => { if prompt_tokens.is_none() { prompt_tokens = - output.prompt_info.as_ref().map(|info| info.prompt_token_ids.len() as u32); + output.prompt_info.as_ref().map(|info| info.prompt_token_ids.len()); } - let usage_prompt_tokens = prompt_tokens.unwrap_or_default(); + usage.prompt_token_count = prompt_tokens.unwrap_or_default(); + usage.cached_token_count = usage.cached_token_count.max(output.cached_token_count); let token_ids = output.token_ids; - output_tokens = output_tokens.saturating_add(token_ids.len() as u32); + usage.output_token_count = usage.output_token_count.saturating_add(token_ids.len()); let finish_reason = output.finish_reason; if matches!(finish_reason.as_ref(), Some(FinishReason::Error)) { @@ -155,12 +147,12 @@ async fn generate_chunk_stream( } if let Some(finish_reason) = finish_reason.as_ref() - && log_request + && enable_log_requests { info!( stream = true, - prompt_tokens = usage_prompt_tokens, - output_tokens, + prompt_tokens = usage.prompt_token_count, + output_tokens = usage.output_token_count, finish_reason = finish_reason.as_str(), "generate finished" ); @@ -190,7 +182,7 @@ async fn generate_chunk_stream( token_ids, }], usage: include_continuous_usage - .then(|| Usage::from_counts(usage_prompt_tokens, output_tokens)), + .then(|| Usage::from_token_usage(usage, enable_prompt_tokens_details)), }) .await; } @@ -208,10 +200,7 @@ async fn generate_chunk_stream( y.yield_ok(GenerateStreamResponse { request_id, choices: Vec::new(), - usage: Some(Usage::from_counts( - prompt_tokens.unwrap_or_default(), - output_tokens, - )), + usage: Some(Usage::from_token_usage(usage, enable_prompt_tokens_details)), }) .await; } @@ -222,8 +211,18 @@ async fn generate_chunk_stream( fn collect_generate( collected: CollectedGenerateOutput, request_id: String, - include_logprobs: bool, - include_prompt_logprobs: bool, + ApiServerOptions { + enable_log_requests, + .. + }: ApiServerOptions, + ResponseOptions { + // Ignored: non-streaming raw generate responses do not include usage. + include_usage: _, + // Ignored: continuous usage is a streaming-only option. + include_continuous_usage: _, + include_logprobs, + include_prompt_logprobs, + }: ResponseOptions, ) -> Result { let logprobs = if include_logprobs { let logprobs = collected.logprobs.as_ref().ok_or_else(|| { @@ -246,13 +245,23 @@ fn collect_generate( } else { None }; + let finish_reason = collected.finish_reason.as_str().to_string(); + + if enable_log_requests { + info!( + prompt_tokens = collected.prompt_token_ids.len(), + output_tokens = collected.token_ids.len(), + %finish_reason, + "generate finished" + ); + } Ok(GenerateResponse { request_id, choices: vec![GenerateResponseChoice { index: 0, logprobs, - finish_reason: Some(collected.finish_reason.as_str().to_string()), + finish_reason: Some(finish_reason), token_ids: collected.token_ids, }], prompt_logprobs, @@ -393,6 +402,7 @@ mod tests { token_ids: Vec::new(), logprobs: None, finish_reason: None, + cached_token_count: 0, kv_transfer_params: None, }), Ok(GenerateOutput { @@ -404,24 +414,56 @@ mod tests { token_ids: vec![33], logprobs: None, finish_reason: Some(FinishReason::stop_eos()), + cached_token_count: 2, kv_transfer_params: None, }), ]); - let chunks: Vec<_> = - generate_chunk_stream(stream, "raw-stream".to_string(), false, true, true, false) - .try_collect() - .await - .expect("collect chunks"); + let chunks: Vec<_> = generate_chunk_stream( + stream, + "raw-stream".to_string(), + ApiServerOptions { + enable_prompt_tokens_details: true, + ..Default::default() + }, + ResponseOptions { + include_usage: true, + include_continuous_usage: true, + ..Default::default() + }, + ) + .try_collect() + .await + .expect("collect chunks"); assert_eq!(chunks.len(), 2); assert_eq!( chunks[0].usage.as_ref().expect("chunk usage").prompt_tokens, 2 ); + assert_eq!( + chunks[0] + .usage + .as_ref() + .expect("chunk usage") + .prompt_tokens_details + .as_ref() + .map(|details| details.cached_tokens), + Some(2) + ); assert_eq!( chunks[1].usage.as_ref().expect("final usage").prompt_tokens, 2 ); + assert_eq!( + chunks[1] + .usage + .as_ref() + .expect("final usage") + .prompt_tokens_details + .as_ref() + .map(|details| details.cached_tokens), + Some(2) + ); } } diff --git a/rust/src/server/src/routes/inference/generate/convert.rs b/rust/src/server/src/routes/inference/generate/convert.rs index f87ff403a7bc..965155b5825f 100644 --- a/rust/src/server/src/routes/inference/generate/convert.rs +++ b/rust/src/server/src/routes/inference/generate/convert.rs @@ -8,19 +8,29 @@ use crate::utils::{ResolvedRequestContext, merge_kv_transfer_params}; /// Lowered generate request plus the response request ID. #[derive(Debug, Clone, PartialEq)] -pub struct PreparedRequest { +pub(super) struct PreparedRequest { pub request_id: String, pub text_request: TextRequest, pub stream: bool, + /// Public response rendering options for route-layer helpers. + pub options: ResponseOptions, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub(super) struct ResponseOptions { + /// Whether the caller asked for the final streamed usage chunk. pub include_usage: bool, + /// Whether the caller asked for usage on every streamed chunk. pub include_continuous_usage: bool, + /// Whether the caller requested output logprobs on generate choices. pub include_logprobs: bool, + /// Whether the caller requested top-level prompt logprobs. pub include_prompt_logprobs: bool, } /// Validate and lower one raw generate request into the internal /// text-generation format. -pub fn prepare_generate_request( +pub(super) fn prepare_generate_request( request: GenerateRequest, lora_resolution: &LoraModelResolution, ctx: ResolvedRequestContext, @@ -58,17 +68,21 @@ pub fn prepare_generate_request( cache_salt: request.cache_salt, add_special_tokens: false, data_parallel_rank: ctx.data_parallel_rank, + reasoning_parser_kwargs: None, lora_request: lora_resolution.lora_request.clone(), + arrival_time: None, }; Ok(PreparedRequest { request_id: ctx.request_id, text_request, stream, - include_usage, - include_continuous_usage, - include_logprobs, - include_prompt_logprobs, + options: ResponseOptions { + include_usage, + include_continuous_usage, + include_logprobs, + include_prompt_logprobs, + }, }) } @@ -138,6 +152,33 @@ mod tests { ); } + #[test] + fn prepare_generate_request_forwards_thinking_token_budget() { + let request: GenerateRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "token_ids": [11, 22, 33], + "sampling_params": { + "thinking_token_budget": 64 + } + })) + .expect("parse request"); + + let prepared = prepare_generate_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("prepare"); + + // The raw inference route shares `vllm_text::SamplingParams`, so the + // field is carried through to lowering exactly like the OpenAI routes + // (normalization/validation then happens in `lower_sampling_params`). + assert_eq!( + prepared.text_request.sampling_params.thinking_token_budget, + Some(64) + ); + } + #[test] fn prepare_generate_request_gates_continuous_usage_on_include_usage() { let request: GenerateRequest = serde_json::from_value(json!({ @@ -158,7 +199,7 @@ mod tests { ) .expect("prepare"); - assert!(!prepared.include_usage); - assert!(!prepared.include_continuous_usage); + assert!(!prepared.options.include_usage); + assert!(!prepared.options.include_continuous_usage); } } diff --git a/rust/src/server/src/routes/inference/generate/types.rs b/rust/src/server/src/routes/inference/generate/types.rs index d4567c44aa6d..28855968df05 100644 --- a/rust/src/server/src/routes/inference/generate/types.rs +++ b/rust/src/server/src/routes/inference/generate/types.rs @@ -29,7 +29,9 @@ pub struct GenerateRequest { impl Normalizable for GenerateRequest {} /// Mirrors the Python vLLM `GenerateResponseChoice` class. -#[serde_with::skip_serializing_none] +/// +/// Do not skip serializing `None` fields here: non-streaming response types +/// should serialize `None` as explicit `null`. #[derive(Debug, Clone, Serialize)] pub(super) struct GenerateResponseChoice { pub index: u32, @@ -58,7 +60,6 @@ pub(super) struct GenerateStreamResponse { } /// Mirrors the Python vLLM `GenerateResponse` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub(super) struct GenerateResponse { pub request_id: String, @@ -68,7 +69,6 @@ pub(super) struct GenerateResponse { } /// Mirrors the Python vLLM `Logprob` class used in prompt-logprobs payloads. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub(super) struct GenerateLogprob { pub logprob: f32, diff --git a/rust/src/server/src/routes/inference/generate/validate.rs b/rust/src/server/src/routes/inference/generate/validate.rs index 43347c60b574..3b925f3af210 100644 --- a/rust/src/server/src/routes/inference/generate/validate.rs +++ b/rust/src/server/src/routes/inference/generate/validate.rs @@ -34,14 +34,20 @@ pub(super) fn validate_request_compat( ); } - if let Some(prompt_logprobs) = request.sampling_params.prompt_logprobs - && prompt_logprobs < 0 - && prompt_logprobs != -1 - { - bail_invalid_request!( - param = "sampling_params", - "`prompt_logprobs` must be a non-negative value or -1." - ); + if let Some(prompt_logprobs) = request.sampling_params.prompt_logprobs { + if prompt_logprobs < 0 && prompt_logprobs != -1 { + bail_invalid_request!( + param = "sampling_params", + "`prompt_logprobs` must be a non-negative value or -1." + ); + } + + if request.stream { + bail_invalid_request!( + param = "sampling_params", + "`prompt_logprobs` are not available when `stream=true`." + ); + } } Ok(()) @@ -97,4 +103,54 @@ mod tests { }; assert!(validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()); } + + #[test] + fn validate_request_compat_rejects_streaming_prompt_logprobs() { + let request: GenerateRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "token_ids": [11, 22], + "stream": true, + "sampling_params": { + "prompt_logprobs": 0 + } + })) + .expect("parse request"); + assert!(validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()); + + let request: GenerateRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "token_ids": [11, 22], + "stream": true, + "sampling_params": { + "prompt_logprobs": 1 + } + })) + .expect("parse request"); + assert!(validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()); + + let request: GenerateRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "token_ids": [11, 22], + "stream": true, + "sampling_params": { + "prompt_logprobs": -1 + } + })) + .expect("parse request"); + assert!(validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()); + } + + #[test] + fn validate_request_compat_accepts_non_stream_prompt_logprobs() { + let request: GenerateRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "token_ids": [11, 22], + "stream": false, + "sampling_params": { + "prompt_logprobs": 1 + } + })) + .expect("parse request"); + assert!(validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_ok()); + } } diff --git a/rust/src/server/src/routes/openai/chat_completions.rs b/rust/src/server/src/routes/openai/chat_completions.rs index 543a7e806c4b..f0368c5614a7 100644 --- a/rust/src/server/src/routes/openai/chat_completions.rs +++ b/rust/src/server/src/routes/openai/chat_completions.rs @@ -1,4 +1,4 @@ -pub mod convert; +pub(crate) mod convert; mod types; mod validate; @@ -21,10 +21,11 @@ use vllm_chat::{ AssistantBlockKind, AssistantMessageExt as _, ChatEvent, ChatEventStream, ChatEventStreamTrait, CollectedAssistantMessage, FinishReason, }; -use vllm_engine_core_client::protocol::StopReason; +use vllm_engine_core_client::protocol::output::StopReason; -use crate::error::{ApiError, bail_server_error, server_error}; -use crate::routes::openai::chat_completions::convert::prepare_chat_request; +use self::convert::{ResponseOptions, prepare_chat_request}; +use crate::config::ApiServerOptions; +use crate::error::{ApiError, bail_server_error, chat_submit_error, server_error}; use crate::routes::openai::chat_completions::types::{ AssistantRole, ChatCompletionChoice, ChatCompletionMessage, ChatCompletionRequest, ChatCompletionResponse, ChatCompletionStreamChoice, ChatCompletionStreamResponse, @@ -36,6 +37,7 @@ use crate::routes::openai::utils::logprobs::{ use crate::routes::openai::utils::types::{ ChatLogProbs, FunctionCallDelta, FunctionCallResponse, ToolCall, ToolCallDelta, Usage, }; +use crate::routes::openai::utils::usage::ContinuousUsage; use crate::routes::openai::utils::validated_json::ValidatedJson; use crate::state::AppState; use crate::utils::{resolve_request_context, unix_timestamp}; @@ -62,17 +64,13 @@ pub async fn chat_completions( ); let created = unix_timestamp(); - let log_request = state.enable_log_requests; + let api_server_options = state.api_server_options; let chat_stream = match state.chat.chat(prepared.chat_request).instrument(request_span.clone()).await { Ok(stream) => stream, Err(error) => { - return server_error!( - "failed to submit chat request: {}", - error.to_report_string() - ) - .into_response(); + return chat_submit_error("failed to submit chat request", error).into_response(); } }; @@ -82,12 +80,8 @@ pub async fn chat_completions( prepared.request_id, prepared.response_model, created, - log_request, - prepared.include_usage, - prepared.requested_logprobs, - prepared.echo, - prepared.return_token_ids, - prepared.return_tokens_as_token_ids, + api_server_options, + prepared.options, ); let sse_stream = chat_completion_sse_stream(chunk_stream).instrument(request_span); @@ -98,11 +92,8 @@ pub async fn chat_completions( prepared.request_id, prepared.response_model, created, - prepared.requested_logprobs, - prepared.include_prompt_logprobs, - prepared.echo, - prepared.return_token_ids, - prepared.return_tokens_as_token_ids, + api_server_options, + prepared.options, ) .instrument(request_span.clone()) .await @@ -111,18 +102,6 @@ pub async fn chat_completions( Err(error) => return error.into_response(), }; - if log_request { - let usage = response.usage.as_ref(); - info!( - parent: &request_span, - model = %response.model, - prompt_tokens = usage.map_or(0, |u| u.prompt_tokens), - output_tokens = usage.and_then(|u| u.completion_tokens).unwrap_or(0), - finish_reason = response.choices.first().and_then(|c| c.finish_reason.as_deref()).unwrap_or("unknown"), - "chat completion finished" - ); - } - Json(response).into_response() } } @@ -132,11 +111,23 @@ async fn collect_chat_completion( request_id: String, response_model: String, created: u64, - requested_logprobs: bool, - include_prompt_logprobs: bool, - echo: Option, - return_token_ids: bool, - return_tokens_as_token_ids: bool, + ApiServerOptions { + enable_log_requests, + enable_prompt_tokens_details, + .. + }: ApiServerOptions, + ResponseOptions { + // Ignored: non-streaming responses always include usage. + include_usage: _, + // Ignored: non-streaming responses are collected before usage is attached. + include_continuous_usage: _, + requested_logprobs, + include_prompt_logprobs, + include_reasoning, + echo, + return_token_ids, + return_tokens_as_token_ids, + }: ResponseOptions, ) -> Result { let collected = stream.collect_message().await.map_err(|error| { server_error!( @@ -146,17 +137,21 @@ async fn collect_chat_completion( })?; let CollectedAssistantMessage { message, - prompt_token_count, prompt_token_ids, prompt_logprobs, logprobs, token_ids, - output_token_count, + usage, finish_reason, kv_transfer_params, } = collected; let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json); let saw_tool_calls = message.tool_calls().next().is_some(); + let reasoning = message.reasoning(); + // Output logprobs and token IDs cover the complete generated token stream. + // When reasoning is hidden, omit them rather than leaking hidden reasoning + // tokens through per-token metadata. + let include_output_metadata = include_reasoning || reasoning.is_none(); let finish_reason = chat_finish_reason_to_openai(&finish_reason, saw_tool_calls)?.to_string(); let tool_calls = message .tool_calls() @@ -169,7 +164,7 @@ async fn collect_chat_completion( }, }) .collect::>(); - let logprobs = if requested_logprobs { + let logprobs = if requested_logprobs && include_output_metadata { Some(decoded_logprobs_to_openai_chat( logprobs.as_ref().ok_or_else(|| { server_error!("chat response requested logprobs but generation returned none") @@ -191,7 +186,17 @@ async fn collect_chat_completion( } else { None }; - let usage = Usage::from_counts(prompt_token_count as u32, output_token_count as u32); + let usage = Usage::from_token_usage(usage, enable_prompt_tokens_details); + + if enable_log_requests { + info!( + model = %response_model, + prompt_tokens = usage.prompt_tokens, + output_tokens = usage.completion_tokens.unwrap_or(0), + finish_reason = %finish_reason, + "chat completion finished" + ); + } Ok(ChatCompletionResponse { id: request_id, @@ -206,13 +211,13 @@ async fn collect_chat_completion( Some(prefix) => Some(format!("{prefix}{}", message.text())), None => Some(message.text()).filter(|t| !t.is_empty()), }, - tool_calls: Some(tool_calls).filter(|calls| !calls.is_empty()), - reasoning: message.reasoning(), + tool_calls, + reasoning: if include_reasoning { reasoning } else { None }, }, logprobs, finish_reason: Some(finish_reason), stop_reason, - token_ids: return_token_ids.then_some(token_ids), + token_ids: (return_token_ids && include_output_metadata).then_some(token_ids), }], usage: Some(usage), system_fingerprint: None, @@ -229,91 +234,143 @@ async fn chat_completion_chunk_stream( request_id: String, response_model: String, created: u64, - log_request: bool, - include_usage: bool, - requested_logprobs: bool, - echo: Option, - return_token_ids: bool, - return_tokens_as_token_ids: bool, + ApiServerOptions { + enable_log_requests, + enable_prompt_tokens_details, + .. + }: ApiServerOptions, + ResponseOptions { + include_usage, + include_continuous_usage, + requested_logprobs, + // Ignored: chat streaming prompt logprobs are rejected for Python parity. + include_prompt_logprobs: _, + include_reasoning, + echo, + return_token_ids, + return_tokens_as_token_ids, + }: ResponseOptions, mut y: TryYielder, ) -> Result<(), ApiError> { let mut saw_tool_calls = false; + // `LogprobsDelta` is emitted after all chat events for one decoded update. + // If that update contains hidden reasoning, including delimiter-only block + // starts or ends, omit its token metadata as well as its visible delta. + let mut inside_hidden_reasoning = false; + let mut suppress_current_update_metadata = false; + let mut continuous_usage = ContinuousUsage::default(); + + /// Yield a chunk with optional continuous usage attached. + macro_rules! yield_chunk { + ($chunk:expr) => {{ + let mut chunk = $chunk; + if include_continuous_usage { + chunk.usage = Some(continuous_usage.to_usage()); + } + y.yield_ok(chunk).await; + }}; + } // If the client requested logprobs or token_ids, we need to buffer chunks until // we receive the separate `LogprobsDelta` event, so that we can emit one // combined chunk with both the semantic delta and its per-update metadata. - let mut pending_chunk = - (requested_logprobs || return_token_ids).then(PendingChatChunk::default); + // Continuous usage also buffers so the token count from `LogprobsDelta` can + // be attached to the matching semantic chunk. + let mut pending_chunk = (requested_logprobs || return_token_ids || include_continuous_usage) + .then(PendingChatChunk::default); while let Some(next) = stream.next().await { match next { Ok(ChatEvent::Start { prompt_token_ids, .. }) => { + continuous_usage.set_prompt_tokens(prompt_token_ids.len()); let mut chunk = start_chunk(&request_id, &response_model, created); if return_token_ids { chunk.prompt_token_ids = Some(prompt_token_ids.to_vec()); } - y.yield_ok(chunk).await; + yield_chunk!(chunk); // When echo=true, emit the last assistant message content as a delta chunk. if let Some(echo_text) = &echo { - y.yield_ok(block_delta_chunk( + yield_chunk!(block_delta_chunk( &request_id, &response_model, created, AssistantBlockKind::Text, echo_text.clone(), - )) - .await; + )); } } Ok(ChatEvent::BlockDelta { kind, delta, .. }) => { - if let Some(pending_chunk) = pending_chunk.as_mut() { - pending_chunk.push_block_delta(kind, delta); + let include_delta = + include_reasoning || !matches!(kind, AssistantBlockKind::Reasoning); + if include_delta { + if let Some(pending_chunk) = pending_chunk.as_mut() { + pending_chunk.push_block_delta(kind, delta); + } else { + yield_chunk!(block_delta_chunk( + &request_id, + &response_model, + created, + kind, + delta, + )); + } } else { - y.yield_ok(block_delta_chunk( - &request_id, - &response_model, - created, - kind, - delta, - )) - .await; + suppress_current_update_metadata = true; } } Ok(ChatEvent::LogprobsDelta { logprobs, token_ids, }) => { - let openai_logprobs = logprobs - .as_ref() - .map(|lp| decoded_logprobs_to_openai_chat(lp, return_tokens_as_token_ids)) - .transpose()?; - let openai_token_ids = - return_token_ids.then_some(token_ids).filter(|t| !t.is_empty()); + let delta_token_count = token_ids.len(); + continuous_usage.add_output_tokens(delta_token_count); + let include_metadata = + !suppress_current_update_metadata && !inside_hidden_reasoning; + suppress_current_update_metadata = false; + let openai_logprobs = if include_metadata { + logprobs + .as_ref() + .map(|lp| decoded_logprobs_to_openai_chat(lp, return_tokens_as_token_ids)) + .transpose()? + } else { + None + }; + let openai_token_ids = include_metadata + .then_some(token_ids) + .and_then(|token_ids| return_token_ids.then_some(token_ids)) + .filter(|t| !t.is_empty()); if let Some(pending_chunk) = pending_chunk.as_mut() { pending_chunk.logprobs = openai_logprobs; pending_chunk.token_ids = openai_token_ids; if let Some(chunk) = pending_chunk.take_chunk(&request_id, &response_model, created) { - y.yield_ok(chunk).await; + yield_chunk!(chunk); } } else if let Some(logprobs) = openai_logprobs { - y.yield_ok(logprobs_only_chunk( + yield_chunk!(logprobs_only_chunk( &request_id, &response_model, created, logprobs, - )) - .await; + )); } } Ok(ChatEvent::BlockStart { kind, .. }) => { debug!(?kind, "starting new block"); + if !include_reasoning && matches!(kind, AssistantBlockKind::Reasoning) { + inside_hidden_reasoning = true; + suppress_current_update_metadata = true; + } } Ok(ChatEvent::BlockEnd { .. }) => { debug!("ending current block"); + if inside_hidden_reasoning { + inside_hidden_reasoning = false; + suppress_current_update_metadata = true; + } } Ok(ChatEvent::ToolCallStart { index, id, name }) => { let tool_index = index as u32; @@ -326,15 +383,14 @@ async fn chat_completion_chunk_stream( if let Some(pending_chunk) = pending_chunk.as_mut() { pending_chunk.push_tool_call_start(tool_index, id, name); } else { - y.yield_ok(tool_call_start_chunk( + yield_chunk!(tool_call_start_chunk( &request_id, &response_model, created, tool_index, id, name, - )) - .await; + )); } } Ok(ChatEvent::ToolCallArgumentsDelta { index, delta }) => { @@ -342,41 +398,44 @@ async fn chat_completion_chunk_stream( if let Some(pending_chunk) = pending_chunk.as_mut() { pending_chunk.push_tool_call_arguments(tool_index, delta); } else { - y.yield_ok(tool_call_arguments_chunk( + yield_chunk!(tool_call_arguments_chunk( &request_id, &response_model, created, tool_index, delta, - )) - .await; + )); } } Ok(ChatEvent::ToolCallEnd { .. }) => { debug!("ending current tool call"); } Ok(ChatEvent::Done { - prompt_token_count, + usage: final_usage, finish_reason, - output_token_count, .. }) => { - if log_request { + if enable_log_requests { info!( stream = true, model = %response_model, - prompt_tokens = prompt_token_count, - output_tokens = output_token_count, + prompt_tokens = final_usage.prompt_token_count, + output_tokens = final_usage.output_token_count, finish_reason = finish_reason.as_str(), "chat completion finished" ); } + continuous_usage.set_final_counts( + final_usage.prompt_token_count, + final_usage.output_token_count, + ); + if let Some(pending_chunk) = pending_chunk.as_mut() && let Some(chunk) = pending_chunk.take_chunk(&request_id, &response_model, created) { - y.yield_ok(chunk).await; + yield_chunk!(chunk); } match final_chunk( @@ -386,7 +445,7 @@ async fn chat_completion_chunk_stream( finish_reason, saw_tool_calls, ) { - Ok(chunk) => y.yield_ok(chunk).await, + Ok(chunk) => yield_chunk!(chunk), Err(error) => { error!( error = %error.to_error_response().error.message, @@ -401,7 +460,7 @@ async fn chat_completion_chunk_stream( &request_id, &response_model, created, - Usage::from_counts(prompt_token_count as u32, output_token_count as u32), + Usage::from_token_usage(final_usage, enable_prompt_tokens_details), )) .await; } @@ -746,7 +805,7 @@ fn chat_finish_reason_to_openai( FinishReason::Stop(_) => Ok("stop"), FinishReason::Length => Ok("length"), FinishReason::Abort => Ok("abort"), - FinishReason::Repetition => Ok("stop"), + FinishReason::Repetition(_) => Ok("repetition"), FinishReason::Error => { bail_server_error!("Internal server error"); } @@ -763,11 +822,16 @@ fn stop_reason_to_json(stop_reason: &StopReason) -> Value { mod tests { use futures::{StreamExt as _, stream}; use serde_json::json; - use vllm_chat::{AssistantBlockKind, AssistantToolCall, ChatEvent, FinishReason}; - use vllm_engine_core_client::protocol::StopReason; + use vllm_chat::{ + AssistantBlockKind, AssistantContentBlock, AssistantToolCall, ChatEvent, FinishReason, + }; + use vllm_engine_core_client::protocol::output::StopReason; use vllm_text::{DecodedLogprobs, DecodedPositionLogprobs, DecodedTokenLogprob}; - use super::{block_delta_chunk, chat_completion_chunk_stream, final_chunk}; + use super::{ + ApiServerOptions, ResponseOptions, block_delta_chunk, chat_completion_chunk_stream, + final_chunk, + }; #[test] fn text_chunk_uses_content_only_delta() { @@ -880,8 +944,11 @@ mod tests { }), Ok(ChatEvent::Done { message: Default::default(), - prompt_token_count: 1, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 1, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), @@ -892,12 +959,16 @@ mod tests { "chatcmpl-1".to_string(), "model".to_string(), 1, - false, - false, - true, - None, - false, - false, + ApiServerOptions { + enable_prompt_tokens_details: true, + ..Default::default() + }, + ResponseOptions { + include_usage: true, + requested_logprobs: true, + include_reasoning: true, + ..Default::default() + }, ) .collect::>() .await @@ -905,11 +976,21 @@ mod tests { .collect::, _>>() .expect("stream chunks"); - assert_eq!(chunks.len(), 3); + assert_eq!(chunks.len(), 4); assert_eq!(chunks[1].choices[0].delta.content.as_deref(), Some("hi")); let logprobs = chunks[1].choices[0].logprobs.as_ref().expect("logprobs"); let content = logprobs.content.as_ref().expect("logprobs content"); assert_eq!(content[0].token, "hi"); + assert_eq!( + chunks[3] + .usage + .as_ref() + .expect("usage") + .prompt_tokens_details + .as_ref() + .map(|details| details.cached_tokens), + Some(1) + ); } #[tokio::test] @@ -943,8 +1024,11 @@ mod tests { }), Ok(ChatEvent::Done { message: Default::default(), - prompt_token_count: 1, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), @@ -955,12 +1039,12 @@ mod tests { "chatcmpl-1".to_string(), "model".to_string(), 1, - false, - false, - true, - None, - false, - false, + ApiServerOptions::default(), + ResponseOptions { + requested_logprobs: true, + include_reasoning: true, + ..Default::default() + }, ) .collect::>() .await @@ -976,6 +1060,294 @@ mod tests { assert!(chunks[1].choices[0].logprobs.is_some()); } + #[tokio::test] + async fn chunk_stream_omits_reasoning_delta_when_disabled() { + let stream = stream::iter(vec![ + Ok(ChatEvent::Start { + prompt_token_ids: vec![].into(), + prompt_logprobs: None, + }), + Ok(ChatEvent::BlockDelta { + index: 0, + kind: AssistantBlockKind::Reasoning, + delta: "think".to_string(), + }), + Ok(ChatEvent::BlockDelta { + index: 1, + kind: AssistantBlockKind::Text, + delta: "answer".to_string(), + }), + Ok(ChatEvent::Done { + message: Default::default(), + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 2, + cached_token_count: 0, + }, + finish_reason: FinishReason::stop_eos(), + kv_transfer_params: None, + }), + ]); + + let chunks = chat_completion_chunk_stream( + stream, + "chatcmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions::default(), + ) + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("stream chunks"); + + assert_eq!(chunks.len(), 3); + assert_eq!( + chunks[1].choices[0].delta.content.as_deref(), + Some("answer") + ); + assert!( + chunks + .iter() + .all(|chunk| chunk.choices.iter().all(|choice| choice.delta.reasoning.is_none())) + ); + } + + #[tokio::test] + async fn chunk_stream_omits_logprobs_for_suppressed_reasoning() { + let stream = stream::iter(vec![ + Ok(ChatEvent::Start { + prompt_token_ids: vec![].into(), + prompt_logprobs: None, + }), + Ok(ChatEvent::BlockDelta { + index: 0, + kind: AssistantBlockKind::Reasoning, + delta: "think".to_string(), + }), + Ok(ChatEvent::LogprobsDelta { + logprobs: Some(DecodedLogprobs { + positions: vec![DecodedPositionLogprobs { + entries: vec![DecodedTokenLogprob { + token_id: 11, + token: "think".to_string(), + logprob: -0.1, + rank: 1, + }], + }], + }), + token_ids: vec![11], + }), + Ok(ChatEvent::BlockDelta { + index: 1, + kind: AssistantBlockKind::Text, + delta: "answer".to_string(), + }), + Ok(ChatEvent::LogprobsDelta { + logprobs: Some(DecodedLogprobs { + positions: vec![DecodedPositionLogprobs { + entries: vec![DecodedTokenLogprob { + token_id: 22, + token: "answer".to_string(), + logprob: -0.2, + rank: 1, + }], + }], + }), + token_ids: vec![22], + }), + Ok(ChatEvent::Done { + message: Default::default(), + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 2, + cached_token_count: 0, + }, + finish_reason: FinishReason::stop_eos(), + kv_transfer_params: None, + }), + ]); + + let chunks = chat_completion_chunk_stream( + stream, + "chatcmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions { + requested_logprobs: true, + return_token_ids: true, + ..Default::default() + }, + ) + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("stream chunks"); + + assert_eq!(chunks.len(), 3); + let choice = &chunks[1].choices[0]; + assert_eq!(choice.delta.content.as_deref(), Some("answer")); + assert_eq!(choice.token_ids.as_deref(), Some(&[22][..])); + let logprobs = choice.logprobs.as_ref().expect("answer logprobs"); + let content = logprobs.content.as_ref().expect("logprobs content"); + assert_eq!(content[0].token, "answer"); + assert!(chunks.iter().all(|chunk| { + chunk.choices.iter().all(|choice| { + choice.delta.reasoning.is_none() + && choice.token_ids.as_deref() != Some(&[11][..]) + && choice + .logprobs + .as_ref() + .and_then(|logprobs| logprobs.content.as_ref()) + .is_none_or(|content| content.iter().all(|entry| entry.token != "think")) + }) + })); + } + + #[tokio::test] + async fn chunk_stream_omits_logprobs_for_hidden_reasoning_delimiters() { + let stream = stream::iter(vec![ + Ok(ChatEvent::Start { + prompt_token_ids: vec![].into(), + prompt_logprobs: None, + }), + Ok(ChatEvent::BlockStart { + index: 0, + kind: AssistantBlockKind::Reasoning, + }), + Ok(ChatEvent::LogprobsDelta { + logprobs: Some(DecodedLogprobs { + positions: vec![DecodedPositionLogprobs { + entries: vec![DecodedTokenLogprob { + token_id: 11, + token: "".to_string(), + logprob: -0.1, + rank: 1, + }], + }], + }), + token_ids: vec![11], + }), + Ok(ChatEvent::BlockDelta { + index: 0, + kind: AssistantBlockKind::Reasoning, + delta: "think".to_string(), + }), + Ok(ChatEvent::LogprobsDelta { + logprobs: Some(DecodedLogprobs { + positions: vec![DecodedPositionLogprobs { + entries: vec![DecodedTokenLogprob { + token_id: 12, + token: "think".to_string(), + logprob: -0.2, + rank: 1, + }], + }], + }), + token_ids: vec![12], + }), + Ok(ChatEvent::BlockEnd { + index: 0, + block: AssistantContentBlock::Reasoning { + text: "think".to_string(), + }, + }), + Ok(ChatEvent::LogprobsDelta { + logprobs: Some(DecodedLogprobs { + positions: vec![DecodedPositionLogprobs { + entries: vec![DecodedTokenLogprob { + token_id: 13, + token: "".to_string(), + logprob: -0.3, + rank: 1, + }], + }], + }), + token_ids: vec![13], + }), + Ok(ChatEvent::BlockStart { + index: 1, + kind: AssistantBlockKind::Text, + }), + Ok(ChatEvent::BlockDelta { + index: 1, + kind: AssistantBlockKind::Text, + delta: "answer".to_string(), + }), + Ok(ChatEvent::LogprobsDelta { + logprobs: Some(DecodedLogprobs { + positions: vec![DecodedPositionLogprobs { + entries: vec![DecodedTokenLogprob { + token_id: 22, + token: "answer".to_string(), + logprob: -0.4, + rank: 1, + }], + }], + }), + token_ids: vec![22], + }), + Ok(ChatEvent::Done { + message: Default::default(), + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 4, + cached_token_count: 0, + }, + finish_reason: FinishReason::stop_eos(), + kv_transfer_params: None, + }), + ]); + + let chunks = chat_completion_chunk_stream( + stream, + "chatcmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions { + requested_logprobs: true, + return_token_ids: true, + ..Default::default() + }, + ) + .collect::>() + .await + .into_iter() + .collect::, _>>() + .expect("stream chunks"); + + assert_eq!(chunks.len(), 3); + let choice = &chunks[1].choices[0]; + assert_eq!(choice.delta.content.as_deref(), Some("answer")); + assert_eq!(choice.token_ids.as_deref(), Some(&[22][..])); + let logprobs = choice.logprobs.as_ref().expect("answer logprobs"); + let content = logprobs.content.as_ref().expect("logprobs content"); + assert_eq!(content[0].token, "answer"); + assert!(chunks.iter().all(|chunk| { + chunk.choices.iter().all(|choice| { + choice.delta.reasoning.is_none() + && !choice + .token_ids + .as_ref() + .is_some_and(|ids| matches!(ids.as_slice(), [11] | [12] | [13])) + && choice + .logprobs + .as_ref() + .and_then(|logprobs| logprobs.content.as_ref()) + .is_none_or(|content| { + content.iter().all(|entry| { + !matches!(entry.token.as_str(), "" | "think" | "") + }) + }) + }) + })); + } + #[tokio::test] async fn chunk_stream_preserves_tool_call_index_and_omits_id_from_arguments_delta() { let stream = stream::iter(vec![ @@ -1002,8 +1374,11 @@ mod tests { }), Ok(ChatEvent::Done { message: Default::default(), - prompt_token_count: 1, - output_token_count: 1, + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), @@ -1014,12 +1389,11 @@ mod tests { "chatcmpl-1".to_string(), "model".to_string(), 1, - false, - false, - false, - None, - false, - false, + ApiServerOptions::default(), + ResponseOptions { + include_reasoning: true, + ..Default::default() + }, ) .collect::>() .await diff --git a/rust/src/server/src/routes/openai/chat_completions/convert.rs b/rust/src/server/src/routes/openai/chat_completions/convert.rs index 2701bef809c8..a284b2197fca 100644 --- a/rust/src/server/src/routes/openai/chat_completions/convert.rs +++ b/rust/src/server/src/routes/openai/chat_completions/convert.rs @@ -18,19 +18,29 @@ use crate::utils::{ResolvedRequestContext, convert_logit_bias, merge_kv_transfer /// Lowered chat request plus the public response metadata carried by every SSE /// chunk. #[derive(Debug, Clone, PartialEq)] -pub struct PreparedRequest { +pub(super) struct PreparedRequest { /// Stable OpenAI-style request ID, reused as the external chat request ID. pub request_id: String, /// Public model ID echoed back to the client. pub response_model: String, + /// Public response rendering options for route-layer helpers. + pub options: ResponseOptions, + /// Lowered chat request for `vllm-chat`. + pub chat_request: ChatRequest, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub(super) struct ResponseOptions { /// Whether the caller asked for the final streamed usage chunk. pub include_usage: bool, + /// Whether every streamed chunk should carry cumulative usage. + pub include_continuous_usage: bool, /// Whether the caller requested output logprobs on chat choices. pub requested_logprobs: bool, /// Whether the caller requested top-level prompt logprobs. pub include_prompt_logprobs: bool, - /// Lowered chat request for `vllm-chat`. - pub chat_request: ChatRequest, + /// Whether to include reasoning content in OpenAI responses. + pub include_reasoning: bool, /// Last assistant-role message content to echo back when `echo=true`. pub echo: Option, /// Whether to include token IDs alongside generated text. @@ -44,7 +54,7 @@ pub struct PreparedRequest { /// /// `lora_resolution.model_names` must be non-empty; the first entry is used as /// the base `model` field in responses when no LoRA adapter is selected. -pub(crate) fn prepare_chat_request( +pub(super) fn prepare_chat_request( request: ChatCompletionRequest, lora_resolution: &LoraModelResolution, ctx: ResolvedRequestContext, @@ -57,6 +67,7 @@ pub(crate) fn prepare_chat_request( .as_ref() .map(|request| request.lora_name.clone()) .unwrap_or_else(|| lora_resolution.model_names.first().cloned().unwrap_or_default()); + let include_reasoning = request.include_reasoning; let echo = request .echo .then(|| extract_last_assistant_content(&request.messages)) @@ -73,6 +84,12 @@ pub(crate) fn prepare_chat_request( let include_usage = (request.stream_options.as_ref()) .and_then(|options| options.include_usage) .unwrap_or(false); + let include_continuous_usage = include_usage + && request + .stream_options + .as_ref() + .and_then(|options| options.continuous_usage_stats) + .unwrap_or(false); let requested_logprobs = request.logprobs; // Auto-enable prompt logprobs for non-streaming echo, matching Python vLLM's @@ -98,12 +115,14 @@ pub(crate) fn prepare_chat_request( seed: request.seed, max_tokens: request.max_completion_tokens, min_tokens: request.min_tokens, + thinking_token_budget: request.thinking_token_budget, logprobs: request.logprobs.then_some(top_logprobs), prompt_logprobs, min_p: request.min_p, frequency_penalty: request.frequency_penalty, presence_penalty: request.presence_penalty, repetition_penalty: request.repetition_penalty, + repetition_detection: request.repetition_detection, stop_token_ids: request.stop_token_ids, ignore_eos: request.ignore_eos, logit_bias: convert_logit_bias(request.logit_bias)?, @@ -125,6 +144,7 @@ pub(crate) fn prepare_chat_request( }, tools: convert_tools(request.tools)?, tool_choice: convert_tool_choice(request.tool_choice.as_ref())?, + parallel_tool_calls: request.parallel_tool_calls.unwrap_or(true), decode_options: vllm_text::output::TextDecodeOptions { skip_special_tokens: request.skip_special_tokens, include_stop_str_in_output: request.include_stop_str_in_output, @@ -143,17 +163,20 @@ pub(crate) fn prepare_chat_request( Ok(PreparedRequest { request_id, response_model, - include_usage, - requested_logprobs, - include_prompt_logprobs, + options: ResponseOptions { + include_usage, + include_continuous_usage, + requested_logprobs, + include_prompt_logprobs, + include_reasoning, + echo, + return_token_ids: request.return_token_ids.unwrap_or(false), + return_tokens_as_token_ids: request.return_tokens_as_token_ids.unwrap_or(false), + }, chat_request, - echo, - return_token_ids: request.return_token_ids.unwrap_or(false), - return_tokens_as_token_ids: request.return_tokens_as_token_ids.unwrap_or(false), }) } - -fn normalize_generation_prompt_mode( +pub(crate) fn normalize_generation_prompt_mode( add_generation_prompt: Option, continue_final_message: bool, messages: &[VllmChatMessage], @@ -200,7 +223,7 @@ fn extract_last_assistant_content(messages: &[ChatMessage]) -> Option { } /// Lower one OpenAI chat message into the `vllm-chat` message shape. -fn convert_message(message: ChatMessage) -> Result { +pub(crate) fn convert_message(message: ChatMessage) -> Result { match message { ChatMessage::System { content, .. } => { Ok(VllmChatMessage::system(convert_content(content)?)) @@ -312,7 +335,7 @@ fn convert_assistant_tool_calls( .collect() } -fn convert_tools(tools: Option>) -> Result, ApiError> { +pub(crate) fn convert_tools(tools: Option>) -> Result, ApiError> { tools .unwrap_or_default() .into_iter() @@ -339,6 +362,13 @@ fn convert_tool_choice(tool_choice: Option<&ToolChoice>) -> Result Ok(ChatToolChoice::Auto), Some(ToolChoice::Value(ToolChoiceValue::None)) => Ok(ChatToolChoice::None), + Some(ToolChoice::Value(ToolChoiceValue::Required)) => Ok(ChatToolChoice::Required), + Some(ToolChoice::Function { + tool_type, + function, + }) if tool_type == "function" => Ok(ChatToolChoice::Function { + name: function.name.clone(), + }), _ => bail_invalid_request!("tool_choice={:?} is not supported yet.", tool_choice), } } @@ -364,8 +394,8 @@ mod tests { AssistantRole, ChatCompletionMessage, ChatCompletionRequest, }; use crate::routes::openai::utils::types::{ - ChatMessage, ContentPart, Function, FunctionCallResponse, ImageUrl, MessageContent, Tool, - ToolCall, ToolChoice, ToolChoiceValue, VideoUrl, + ChatMessage, ContentPart, Function, FunctionCallResponse, ImageUrl, MessageContent, + StreamOptions, Tool, ToolCall, ToolChoice, ToolChoiceValue, VideoUrl, }; use crate::utils::{ResolvedRequestContext, resolve_request_context}; @@ -392,6 +422,33 @@ mod tests { } } + #[test] + fn prepare_chat_request_maps_parallel_tool_calls() { + let mut request = base_request(); + request.parallel_tool_calls = Some(false); + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert!(!prepared.chat_request.parallel_tool_calls); + } + + #[test] + fn prepare_chat_request_defaults_parallel_tool_calls_to_true() { + let prepared = prepare_chat_request( + base_request(), + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert!(prepared.chat_request.parallel_tool_calls); + } + #[test] fn prepare_chat_request_maps_text_parts() { let mut request = base_request(); @@ -445,6 +502,46 @@ mod tests { assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Auto); } + #[test] + fn prepare_chat_request_maps_stream_usage_and_token_format_options() { + let mut request = base_request(); + request.return_tokens_as_token_ids = Some(true); + request.stream_options = Some(StreamOptions { + include_usage: Some(true), + continuous_usage_stats: Some(true), + }); + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert!(prepared.options.include_usage); + assert!(prepared.options.include_continuous_usage); + assert!(prepared.options.return_tokens_as_token_ids); + } + + #[test] + fn prepare_chat_request_gates_continuous_usage_on_include_usage() { + let mut request = base_request(); + request.stream_options = Some(StreamOptions { + include_usage: None, + continuous_usage_stats: Some(true), + }); + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert!(!prepared.options.include_usage); + assert!(!prepared.options.include_continuous_usage); + } + #[test] fn prepare_chat_request_keeps_optional_sampling_fields_unset() { let prepared = prepare_chat_request( @@ -480,6 +577,23 @@ mod tests { assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Auto); } + #[test] + fn prepare_chat_request_preserves_include_reasoning_false() { + let request = ChatCompletionRequest { + include_reasoning: false, + ..base_request() + }; + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert!(!prepared.options.include_reasoning); + } + #[test] fn prepare_chat_request_preserves_sampling_passthrough_fields() { let request = ChatCompletionRequest { @@ -508,6 +622,31 @@ mod tests { assert_eq!(prepared.chat_request.sampling_params, expected); } + #[test] + fn prepare_chat_request_passes_through_thinking_token_budget() { + let prepare = |budget: Option| { + prepare_chat_request( + ChatCompletionRequest { + thinking_token_budget: budget, + ..base_request() + }, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid") + .chat_request + .sampling_params + .thinking_token_budget + }; + + // The convert layer forwards the raw value verbatim (including the `-1` + // "unlimited" sentinel); normalization/validation happens during + // lowering (see `vllm_text::lower`). + assert_eq!(prepare(Some(64)), Some(64)); + assert_eq!(prepare(Some(-1)), Some(-1)); + assert_eq!(prepare(None), None); + } + #[test] fn prepare_chat_request_accepts_developer_messages() { let request = ChatCompletionRequest { @@ -691,7 +830,7 @@ mod tests { let message = ChatCompletionMessage { role: AssistantRole, content: Some("answer".to_string()), - tool_calls: None, + tool_calls: Vec::new(), reasoning: Some("inner".to_string()), }; let message_json = serde_json::to_value(message).expect("message serializes"); @@ -831,6 +970,74 @@ mod tests { assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::None); } + #[test] + fn prepare_chat_request_lowers_required_tool_choice() { + let request = ChatCompletionRequest { + tools: Some(vec![Tool { + tool_type: "function".to_string(), + function: Function { + name: "get_weather".to_string(), + description: Some("Get weather".to_string()), + parameters: json!({ + "type": "object", + "properties": {"city": {"type": "string"}}, + }), + strict: None, + }, + }]), + tool_choice: Some(ToolChoice::Value(ToolChoiceValue::Required)), + ..base_request() + }; + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert_eq!(prepared.chat_request.tool_choice, ChatToolChoice::Required); + } + + #[test] + fn prepare_chat_request_lowers_named_function_tool_choice() { + let request = ChatCompletionRequest { + tools: Some(vec![Tool { + tool_type: "function".to_string(), + function: Function { + name: "get_weather".to_string(), + description: Some("Get weather".to_string()), + parameters: json!({ + "type": "object", + "properties": {"city": {"type": "string"}}, + }), + strict: None, + }, + }]), + tool_choice: Some(ToolChoice::Function { + tool_type: "function".to_string(), + function: crate::routes::openai::utils::types::FunctionChoice { + name: "get_weather".to_string(), + }, + }), + ..base_request() + }; + + let prepared = prepare_chat_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + ) + .expect("request is valid"); + + assert_eq!( + prepared.chat_request.tool_choice, + ChatToolChoice::Function { + name: "get_weather".to_string(), + } + ); + } + #[test] fn prepare_chat_request_lowers_logprobs_fields() { let request = ChatCompletionRequest { @@ -847,8 +1054,8 @@ mod tests { ) .expect("request is valid"); - assert!(prepared.requested_logprobs); - assert!(prepared.include_prompt_logprobs); + assert!(prepared.options.requested_logprobs); + assert!(prepared.options.include_prompt_logprobs); assert_eq!(prepared.chat_request.sampling_params.logprobs, Some(0)); assert_eq!( prepared.chat_request.sampling_params.prompt_logprobs, @@ -874,7 +1081,7 @@ mod tests { assert_eq!(prepared.chat_request.sampling_params.logprobs, Some(3)); assert_eq!(prepared.chat_request.sampling_params.prompt_logprobs, None); - assert!(!prepared.include_prompt_logprobs); + assert!(!prepared.options.include_prompt_logprobs); } #[test] diff --git a/rust/src/server/src/routes/openai/chat_completions/types.rs b/rust/src/server/src/routes/openai/chat_completions/types.rs index 00557ad53d24..f3a4dd241318 100644 --- a/rust/src/server/src/routes/openai/chat_completions/types.rs +++ b/rust/src/server/src/routes/openai/chat_completions/types.rs @@ -6,12 +6,13 @@ use serde_json::Value; use serde_with::SerializeDisplay; use validator::Validate; use vllm_chat::ReasoningEffort; +use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams; use crate::routes::openai::utils::structured_outputs::ResponseFormat; use crate::routes::openai::utils::types::{ - ChatLogProbs, ChatMessage, MessageContent, Normalizable, StreamOptions, StringOrArray, Tool, - ToolCall, ToolCallDelta, ToolChoice, ToolChoiceValue, ToolReference, UNKNOWN_MODEL_ID, Usage, - default_true, validate_stop, validate_top_p_value, + ChatLogProbs, ChatMessage, Normalizable, StreamOptions, StringOrArray, Tool, ToolCall, + ToolCallDelta, ToolChoice, ToolChoiceValue, ToolReference, UNKNOWN_MODEL_ID, Usage, + default_true, validate_messages, validate_stop, validate_top_p_value, }; /// vLLM-compatible request type for the Chat Completions API. @@ -165,8 +166,10 @@ pub struct ChatCompletionRequest { pub bad_words: Option>, // -------- Extra vLLM Parameters -------- - /// Token budget for reasoning/thinking - pub thinking_token_budget: Option, + /// Token budget for reasoning/thinking. Accepts a non-negative integer, or + /// `-1` for unlimited (mirroring the Python frontend, which normalizes `-1` + /// to "no budget"). + pub thinking_token_budget: Option, /// Whether to include reasoning content in the response #[serde(default = "default_true")] @@ -234,7 +237,7 @@ pub struct ChatCompletionRequest { pub vllm_xargs: Option>, /// Parameters for detecting repetitive N-gram patterns in output tokens - pub repetition_detection: Option, + pub repetition_detection: Option, } impl Default for ChatCompletionRequest { @@ -328,7 +331,9 @@ impl Normalizable for ChatCompletionRequest { } /// Mirrors the Python vLLM `ChatCompletionResponse` class. -#[serde_with::skip_serializing_none] +/// +/// Do not skip serializing `None` fields here: non-streaming response types +/// should serialize `None` as explicit `null`. #[derive(Debug, Clone, Serialize)] pub(super) struct ChatCompletionResponse { pub id: String, @@ -344,7 +349,6 @@ pub(super) struct ChatCompletionResponse { } /// Mirrors the Python vLLM `ChatCompletionResponseChoice` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub(super) struct ChatCompletionChoice { pub index: u32, @@ -367,12 +371,12 @@ impl fmt::Display for AssistantRole { } /// Mirrors the Python vLLM response `ChatMessage` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub(super) struct ChatCompletionMessage { pub role: AssistantRole, pub content: Option, - pub tool_calls: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, pub reasoning: Option, } @@ -430,32 +434,6 @@ fn default_model() -> String { UNKNOWN_MODEL_ID.to_string() } -/// Validates messages array is not empty and has valid content -fn validate_messages(messages: &[ChatMessage]) -> Result<(), validator::ValidationError> { - if messages.is_empty() { - return Err(validator::ValidationError::new("messages cannot be empty")); - } - - for msg in messages { - if let ChatMessage::User { content, .. } = msg { - match content { - MessageContent::Text(text) if text.is_empty() => { - return Err(validator::ValidationError::new( - "message content cannot be empty", - )); - } - MessageContent::Parts(parts) if parts.is_empty() => { - return Err(validator::ValidationError::new( - "message content parts cannot be empty", - )); - } - _ => {} - } - } - } - Ok(()) -} - /// Schema-level validation for cross-field dependencies fn validate_chat_cross_parameters( req: &ChatCompletionRequest, diff --git a/rust/src/server/src/routes/openai/chat_completions/validate.rs b/rust/src/server/src/routes/openai/chat_completions/validate.rs index fbd10eea0cb0..2859b0227477 100644 --- a/rust/src/server/src/routes/openai/chat_completions/validate.rs +++ b/rust/src/server/src/routes/openai/chat_completions/validate.rs @@ -1,6 +1,6 @@ use super::types::ChatCompletionRequest; use crate::error::{ApiError, bail_invalid_request}; -use crate::routes::openai::utils::types::{ChatMessage, Tool, ToolChoice, ToolChoiceValue}; +use crate::routes::openai::utils::types::{ChatMessage, Tool}; /// Enforce the minimal compatibility contract for the Rust OpenAI server. pub(super) fn validate_request_compat( @@ -58,30 +58,6 @@ pub(super) fn validate_request_compat( } } - if let Some(tool_choice) = &request.tool_choice { - match tool_choice { - ToolChoice::Value(ToolChoiceValue::Auto | ToolChoiceValue::None) => {} - ToolChoice::Value(ToolChoiceValue::Required) => { - bail_invalid_request!( - param = "tool_choice", - "tool_choice=required is not supported yet." - ); - } - ToolChoice::Function { .. } => { - bail_invalid_request!( - param = "tool_choice", - "Named function tool_choice is not supported yet." - ); - } - ToolChoice::AllowedTools { .. } => { - bail_invalid_request!( - param = "tool_choice", - "allowed_tools tool_choice is not supported yet." - ); - } - } - } - if request.use_beam_search { bail_invalid_request!( param = "use_beam_search", @@ -92,13 +68,6 @@ pub(super) fn validate_request_compat( // ---- Reject parameters that are accepted for deserialization but not yet // implemented ---- - if request.parallel_tool_calls.is_some() { - bail_invalid_request!( - param = "parallel_tool_calls", - "parallel_tool_calls is not supported." - ); - } - reject_non_default( request.length_penalty.as_ref(), "length_penalty", @@ -115,17 +84,6 @@ pub(super) fn validate_request_compat( "truncate_prompt_tokens", "truncate_prompt_tokens is not supported.", )?; - reject_non_default( - request.thinking_token_budget.as_ref(), - "thinking_token_budget", - "thinking_token_budget is not supported.", - )?; - if !request.include_reasoning { - bail_invalid_request!( - param = "include_reasoning", - "include_reasoning is not supported." - ); - } reject_non_default( request.media_io_kwargs.as_ref(), "media_io_kwargs", @@ -136,20 +94,6 @@ pub(super) fn validate_request_compat( "mm_processor_kwargs", "mm_processor_kwargs is not supported.", )?; - reject_non_default( - request.repetition_detection.as_ref(), - "repetition_detection", - "repetition_detection is not supported.", - )?; - - if let Some(options) = &request.stream_options - && options.continuous_usage_stats.is_some() - { - bail_invalid_request!( - param = "stream_options", - "continuous_usage_stats is not supported." - ); - } Ok(()) } @@ -186,8 +130,7 @@ mod tests { use crate::routes::openai::chat_completions::types::ChatCompletionRequest; use crate::routes::openai::utils::structured_outputs::ResponseFormat; use crate::routes::openai::utils::types::{ - ChatMessage, Function, FunctionChoice, MessageContent, StringOrArray, Tool, ToolChoice, - ToolChoiceValue, ToolReference, + ChatMessage, Function, MessageContent, StringOrArray, Tool, ToolChoice, ToolChoiceValue, }; fn served(names: &[&str]) -> Vec { @@ -312,6 +255,17 @@ mod tests { .expect("reasoning_effort should be accepted"); } + #[test] + fn validate_request_compat_accepts_include_reasoning_false() { + let request = ChatCompletionRequest { + include_reasoning: false, + ..base_request() + }; + + validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])) + .expect("include_reasoning=false should be accepted"); + } + #[test] fn validate_request_compat_rejects_top_logprobs_without_logprobs() { let request = ChatCompletionRequest { @@ -373,38 +327,4 @@ mod tests { validate_request_compat(&request, &served(&["Qwen/Qwen1.5-0.5B-Chat"])) .expect("tool_choice=none is ok"); } - - #[test] - fn validate_request_compat_rejects_required_and_named_tool_choices() { - let required = ChatCompletionRequest { - tool_choice: Some(ToolChoice::Value(ToolChoiceValue::Required)), - ..base_request() - }; - assert!(validate_request_compat(&required, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()); - - let named = ChatCompletionRequest { - tool_choice: Some(ToolChoice::Function { - tool_type: "function".to_string(), - function: FunctionChoice { - name: "tool".to_string(), - }, - }), - ..base_request() - }; - assert!(validate_request_compat(&named, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err()); - - let allowed_tools = ChatCompletionRequest { - tool_choice: Some(ToolChoice::AllowedTools { - tool_type: "allowed_tools".to_string(), - mode: "auto".to_string(), - tools: vec![ToolReference::Function { - name: "tool".to_string(), - }], - }), - ..base_request() - }; - assert!( - validate_request_compat(&allowed_tools, &served(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err() - ); - } } diff --git a/rust/src/server/src/routes/openai/completions.rs b/rust/src/server/src/routes/openai/completions.rs index 9eda8b9d2a51..0dd0eadf33d3 100644 --- a/rust/src/server/src/routes/openai/completions.rs +++ b/rust/src/server/src/routes/openai/completions.rs @@ -2,6 +2,7 @@ mod convert; mod types; mod validate; +use std::collections::HashMap; use std::convert::Infallible; use std::result::Result; use std::sync::Arc; @@ -13,23 +14,30 @@ use axum::http::HeaderMap; use axum::response::sse::{Event, Sse}; use axum::response::{IntoResponse, Response}; use futures::{Stream, StreamExt as _, pin_mut}; +use serde_json::Value; use thiserror_ext::AsReport as _; use tracing::{debug, error, info, trace}; use tracing_futures::Instrument as _; -use vllm_text::{DecodedTextEvent, FinishReason, TextOutputStream, TextOutputStreamExt as _}; +use vllm_engine_core_client::protocol::output::StopReason; +use vllm_text::{ + DecodedPromptLogprobs, DecodedTextEvent, FinishReason, TextOutputStream, + TextOutputStreamExt as _, +}; +use self::convert::{ResponseOptions, prepare_completion_request}; use super::utils::logprobs::{ collected_logprobs_to_openai, decoded_logprobs_to_openai, decoded_prompt_logprobs_to_maps, - text_len, + decoded_prompt_logprobs_to_openai, text_len, }; use super::utils::types::Usage; -use crate::error::{ApiError, bail_server_error, server_error}; -use crate::routes::openai::completions::convert::prepare_completion_request; +use crate::config::ApiServerOptions; +use crate::error::{ApiError, bail_server_error, server_error, text_submit_error}; use crate::routes::openai::completions::types::{ CompletionChoice, CompletionRequest, CompletionResponse, CompletionSseChunk, CompletionStreamChoice, CompletionStreamResponse, }; use crate::routes::openai::utils::types::LogProbs; +use crate::routes::openai::utils::usage::ContinuousUsage; use crate::routes::openai::utils::validated_json::ValidatedJson; use crate::state::AppState; use crate::utils::{resolve_request_context, unix_timestamp}; @@ -42,11 +50,16 @@ pub async fn completions( ValidatedJson(body): ValidatedJson, ) -> Response { let stream = body.stream; - let logprobs = body.logprobs; let request_context = resolve_request_context(&headers, body.request_id.as_deref()); let lora_resolution = state.resolve_model_with_loras(Some(&body.model)).await; - let prepared = match prepare_completion_request(body, &lora_resolution, request_context) { + let tokenizer = state.chat.text().tokenizer(); + let prepared = match prepare_completion_request( + body, + &lora_resolution, + request_context, + tokenizer.as_ref(), + ) { Ok(prepared) => prepared, Err(error) => return error.into_response(), }; @@ -57,9 +70,7 @@ pub async fn completions( ); let created = unix_timestamp(); - let include_prompt_logprobs = prepared.text_request.sampling_params.prompt_logprobs.is_some(); - let log_request = state.enable_log_requests; - + let api_server_options = state.api_server_options; let text_stream = match state .chat .text() @@ -69,11 +80,7 @@ pub async fn completions( { Ok(stream) => stream, Err(error) => { - return server_error!( - "failed to submit completion request: {}", - error.to_report_string() - ) - .into_response(); + return text_submit_error("failed to submit completion request", error).into_response(); } }; @@ -83,12 +90,8 @@ pub async fn completions( prepared.request_id, prepared.response_model, created, - log_request, - prepared.include_usage, - prepared.echo, - logprobs, - prepared.return_token_ids, - prepared.return_tokens_as_token_ids, + api_server_options, + prepared.options, ); let sse_stream = completion_sse_stream(chunk_stream).instrument(request_span); @@ -99,11 +102,8 @@ pub async fn completions( prepared.request_id, prepared.response_model, created, - prepared.echo, - logprobs, - include_prompt_logprobs, - prepared.return_token_ids, - prepared.return_tokens_as_token_ids, + api_server_options, + prepared.options, ) .instrument(request_span.clone()) .await @@ -112,18 +112,6 @@ pub async fn completions( Err(error) => return error.into_response(), }; - if log_request { - let usage = response.usage.as_ref(); - info!( - parent: &request_span, - model = %response.model, - prompt_tokens = usage.map_or(0, |u| u.prompt_tokens), - output_tokens = usage.and_then(|u| u.completion_tokens).unwrap_or(0), - finish_reason = response.choices.first().and_then(|c| c.finish_reason.as_deref()).unwrap_or("unknown"), - "completion finished" - ); - } - Json(response).into_response() } } @@ -133,33 +121,43 @@ async fn collect_completion( request_id: String, response_model: String, created: u64, - echo: Option, - requested_logprobs: Option, - include_prompt_logprobs: bool, - return_token_ids: bool, - return_tokens_as_token_ids: bool, + ApiServerOptions { + enable_log_requests, + enable_prompt_tokens_details, + .. + }: ApiServerOptions, + ResponseOptions { + // Ignored: non-streaming responses always include usage. + include_usage: _, + // Ignored: non-streaming responses are collected before usage is attached. + include_continuous_usage: _, + prompt_only, + echo, + requested_logprobs, + include_prompt_logprobs, + return_token_ids, + return_tokens_as_token_ids, + }: ResponseOptions, ) -> Result { let collected = stream .collect_output() .await .map_err(|error| server_error!("completion stream failed: {}", error.to_report_string()))?; let finish_reason = collected.finish_reason.clone(); - let stop_reason = finish_reason - .as_stop_reason() - .map(|sr| serde_json::to_value(sr).expect("StopReason must serialize to JSON")); + let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json); let prompt_char_count = echo.as_ref().map(|prompt| text_len(prompt)).unwrap_or_default(); - let prompt_logprobs = if include_prompt_logprobs { - let prompt_logprobs = collected.prompt_logprobs.as_ref().ok_or_else(|| { - server_error!( - "completion response requested prompt_logprobs but generation returned none" - ) + let logprobs = if requested_logprobs.is_some() && prompt_only { + let prompt = echo.as_deref().ok_or_else(|| { + server_error!("prompt-only completion response missing echoed prompt") })?; - Some(prompt_logprobs) - } else { - None - }; - let logprobs = if requested_logprobs.is_some() { + Some(prompt_only_logprobs_to_openai( + collected.prompt_logprobs.as_ref(), + prompt, + collected.prompt_token_ids.as_ref(), + return_tokens_as_token_ids, + )?) + } else if requested_logprobs.is_some() { Some(collected_logprobs_to_openai( &collected, echo.is_some(), @@ -169,12 +167,32 @@ async fn collect_completion( } else { None }; - let prompt_logprobs = - prompt_logprobs.map(|lp| decoded_prompt_logprobs_to_maps(lp, return_tokens_as_token_ids)); + let prompt_logprobs = if include_prompt_logprobs { + Some(prompt_logprobs_to_maps( + collected.prompt_logprobs.as_ref(), + collected.prompt_token_ids.as_ref(), + return_tokens_as_token_ids, + )?) + } else { + None + }; let text = match &echo { None => collected.text, + Some(prompt) if prompt_only => prompt.clone(), Some(prompt) => format!("{prompt}{}", collected.text), }; + let finish_reason = completion_finish_reason_to_openai(&finish_reason)?.to_string(); + let usage = Usage::from_token_usage(collected.usage, enable_prompt_tokens_details); + + if enable_log_requests { + info!( + model = %response_model, + prompt_tokens = usage.prompt_tokens, + output_tokens = usage.completion_tokens.unwrap_or(0), + %finish_reason, + "completion finished" + ); + } Ok(CompletionResponse { id: request_id, @@ -185,16 +203,13 @@ async fn collect_completion( index: 0, text, logprobs, - finish_reason: Some(completion_finish_reason_to_openai(finish_reason)?.into()), + finish_reason: Some(finish_reason), stop_reason, prompt_logprobs, token_ids: return_token_ids.then(|| collected.token_ids.clone()), prompt_token_ids: return_token_ids.then(|| collected.prompt_token_ids.to_vec()), }], - usage: Some(Usage::from_counts( - collected.prompt_token_ids.len() as u32, - collected.token_ids.len() as u32, - )), + usage: Some(usage), system_fingerprint: None, kv_transfer_params: collected.kv_transfer_params, }) @@ -207,35 +222,74 @@ async fn completion_chunk_stream( request_id: String, response_model: String, created: u64, - log_request: bool, - include_usage: bool, - echo: Option, - requested_logprobs: Option, - return_token_ids: bool, - return_tokens_as_token_ids: bool, + ApiServerOptions { + enable_log_requests, + enable_prompt_tokens_details, + .. + }: ApiServerOptions, + ResponseOptions { + include_usage, + include_continuous_usage, + prompt_only, + echo, + requested_logprobs, + // Ignored: streaming prompt logprobs are rejected for Python parity. + include_prompt_logprobs: _, + return_token_ids, + return_tokens_as_token_ids, + }: ResponseOptions, mut y: TryYielder, ) -> Result<(), ApiError> { pin_mut!(stream); let mut visible_text_len = 0_u32; let mut first_chunk = true; + let mut continuous_usage = ContinuousUsage::default(); + + /// Yield a chunk with optional continuous usage attached. + macro_rules! yield_chunk { + ($chunk:expr) => {{ + let mut chunk = $chunk; + if include_continuous_usage { + chunk.usage = Some(continuous_usage.to_usage()); + } + y.yield_ok(CompletionSseChunk::Chunk(chunk)).await; + }}; + } while let Some(next) = stream.next().await { match next { Ok(DecodedTextEvent::Start { - prompt_token_ids, .. + prompt_token_ids, + prompt_logprobs, }) => { debug!("completion stream started"); + continuous_usage.set_prompt_tokens(prompt_token_ids.len()); if let Some(prompt) = echo.as_ref() { visible_text_len = text_len(prompt); - let mut chunk = - delta_chunk(&request_id, &response_model, created, prompt.clone(), None); + let logprobs = if prompt_only && requested_logprobs.is_some() { + Some(prompt_only_logprobs_to_openai( + prompt_logprobs.as_ref(), + prompt, + prompt_token_ids.as_ref(), + return_tokens_as_token_ids, + )?) + } else { + None + }; + let mut chunk = delta_chunk( + &request_id, + &response_model, + created, + prompt.clone(), + logprobs, + ); if return_token_ids && first_chunk { if let Some(choice) = chunk.choices.first_mut() { choice.prompt_token_ids = Some(prompt_token_ids.to_vec()); } first_chunk = false; } - y.yield_ok(CompletionSseChunk::Chunk(chunk)).await; + yield_chunk!(chunk); } else if return_token_ids { // Emit a chunk with prompt_token_ids in the first streaming response let mut chunk = @@ -244,7 +298,7 @@ async fn completion_chunk_stream( choice.prompt_token_ids = Some(prompt_token_ids.to_vec()); } first_chunk = false; - y.yield_ok(CompletionSseChunk::Chunk(chunk)).await; + yield_chunk!(chunk); } } Ok(DecodedTextEvent::TextDelta { @@ -253,6 +307,48 @@ async fn completion_chunk_stream( logprobs, finished, }) => { + // Prompt-only streaming already emitted the echoed prompt in the Start chunk. + // The one generated token is only used to drive the engine to a finished event, + // so hide its delta and forward only the terminal finish/usage metadata. + if prompt_only { + if let Some(finished) = finished { + if enable_log_requests { + info!( + stream = true, + model = %response_model, + prompt_tokens = finished.usage.prompt_token_count, + output_tokens = finished.usage.output_token_count, + finish_reason = finished.finish_reason.as_str(), + "completion finished" + ); + } + continuous_usage.set_final_counts( + finished.usage.prompt_token_count, + finished.usage.output_token_count, + ); + let final_chunk = final_chunk( + &request_id, + &response_model, + created, + finished.finish_reason, + )?; + yield_chunk!(final_chunk); + + if include_usage { + y.yield_ok(CompletionSseChunk::Usage(usage_chunk( + &request_id, + &response_model, + created, + Usage::from_token_usage( + finished.usage, + enable_prompt_tokens_details, + ), + ))) + .await; + } + } + continue; + } let delta_text_len = text_len(&delta); let logprobs = if requested_logprobs.is_some() { let decoded_logprobs = logprobs.as_ref().ok_or_else(|| { @@ -269,40 +365,43 @@ async fn completion_chunk_stream( None }; let mut chunk = delta_chunk(&request_id, &response_model, created, delta, logprobs); + let delta_token_count = token_ids.len(); + continuous_usage.add_output_tokens(delta_token_count); if return_token_ids && let Some(choice) = chunk.choices.first_mut() { choice.token_ids = Some(token_ids); } - y.yield_ok(CompletionSseChunk::Chunk(chunk)).await; + yield_chunk!(chunk); visible_text_len = visible_text_len.saturating_add(delta_text_len); if let Some(finished) = finished { - if log_request { + if enable_log_requests { info!( stream = true, model = %response_model, - prompt_tokens = finished.prompt_token_count, - output_tokens = finished.output_token_count, + prompt_tokens = finished.usage.prompt_token_count, + output_tokens = finished.usage.output_token_count, finish_reason = finished.finish_reason.as_str(), "completion finished" ); } - y.yield_ok(CompletionSseChunk::Chunk(final_chunk( + continuous_usage.set_final_counts( + finished.usage.prompt_token_count, + finished.usage.output_token_count, + ); + let final_chunk = final_chunk( &request_id, &response_model, created, finished.finish_reason, - )?)) - .await; + )?; + yield_chunk!(final_chunk); if include_usage { y.yield_ok(CompletionSseChunk::Usage(usage_chunk( &request_id, &response_model, created, - Usage::from_counts( - finished.prompt_token_count as u32, - finished.output_token_count as u32, - ), + Usage::from_token_usage(finished.usage, enable_prompt_tokens_details), ))) .await; } @@ -342,21 +441,24 @@ fn final_chunk( created: u64, finish_reason: FinishReason, ) -> Result { - let finish_reason = completion_finish_reason_to_openai(finish_reason)?; + let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json); + let finish_reason = completion_finish_reason_to_openai(&finish_reason)?; let mut chunk = CompletionStreamResponse::new(request_id, response_model, created); chunk.choices.push(CompletionStreamChoice { finish_reason: Some(finish_reason.to_string()), + stop_reason, ..Default::default() }); Ok(chunk) } fn completion_finish_reason_to_openai( - finish_reason: FinishReason, + finish_reason: &FinishReason, ) -> Result<&'static str, ApiError> { match finish_reason { - FinishReason::Stop(_) | FinishReason::Repetition => Ok("stop"), + FinishReason::Stop(_) => Ok("stop"), + FinishReason::Repetition(_) => Ok("repetition"), FinishReason::Length => Ok("length"), FinishReason::Abort => Ok("abort"), FinishReason::Error => { @@ -365,6 +467,61 @@ fn completion_finish_reason_to_openai( } } +fn stop_reason_to_json(stop_reason: &StopReason) -> Value { + serde_json::to_value(stop_reason).expect("StopReason must serialize to JSON") +} + +fn prompt_only_logprobs_to_openai( + prompt_logprobs: Option<&DecodedPromptLogprobs>, + prompt: &str, + prompt_token_ids: &[u32], + return_tokens_as_token_ids: bool, +) -> Result { + if let Some(prompt_logprobs) = prompt_logprobs { + return decoded_prompt_logprobs_to_openai(prompt_logprobs, 0, return_tokens_as_token_ids); + } + + if let [token_id] = prompt_token_ids { + let token = if return_tokens_as_token_ids { + format!("token_id:{token_id}") + } else { + prompt.to_string() + }; + + return Ok(LogProbs { + tokens: vec![token], + token_logprobs: vec![None], + top_logprobs: vec![None], + text_offset: vec![0], + }); + } + + Err(server_error!( + "prompt-only completion requested logprobs but generation returned none" + )) +} + +fn prompt_logprobs_to_maps( + prompt_logprobs: Option<&DecodedPromptLogprobs>, + prompt_token_ids: &[u32], + return_tokens_as_token_ids: bool, +) -> Result>>, ApiError> { + if let Some(prompt_logprobs) = prompt_logprobs { + return Ok(decoded_prompt_logprobs_to_maps( + prompt_logprobs, + return_tokens_as_token_ids, + )); + } + + if let [_token_id] = prompt_token_ids { + return Ok(vec![None]); + } + + Err(server_error!( + "completion response requested prompt_logprobs but generation returned none" + )) +} + fn usage_chunk( request_id: &str, response_model: &str, @@ -427,12 +584,15 @@ fn done_sse_event() -> Event { mod tests { use futures::{StreamExt as _, stream}; use itertools::Itertools as _; + use vllm_engine_core_client::protocol::output::StopReason; use vllm_text::{ - DecodedLogprobs, DecodedPositionLogprobs, DecodedTextEvent, DecodedTokenLogprob, - FinishReason, Finished, + DecodedLogprobs, DecodedPositionLogprobs, DecodedPromptLogprobs, DecodedTextEvent, + DecodedTokenLogprob, FinishReason, Finished, }; - use super::{CompletionSseChunk, completion_chunk_stream, final_chunk}; + use super::{ + ApiServerOptions, CompletionSseChunk, ResponseOptions, completion_chunk_stream, final_chunk, + }; #[test] fn final_chunk_maps_stop_finish_reason() { @@ -513,9 +673,14 @@ mod tests { }], }), finished: Some(Finished { - prompt_token_count: 5, - output_token_count: 2, - finish_reason: FinishReason::stop_eos(), + usage: vllm_llm::TokenUsage { + prompt_token_count: 5, + output_token_count: 2, + cached_token_count: 3, + }, + finish_reason: FinishReason::Repetition(Some(StopReason::Text( + "repetition_detected".to_string(), + ))), kv_transfer_params: None, }), }), @@ -526,12 +691,15 @@ mod tests { "cmpl-1".to_string(), "model".to_string(), 1, - false, - false, - None, - Some(1), - false, - false, + ApiServerOptions { + enable_prompt_tokens_details: true, + ..Default::default() + }, + ResponseOptions { + include_usage: true, + requested_logprobs: Some(1), + ..Default::default() + }, ) .collect::>() .await; @@ -567,5 +735,345 @@ mod tests { } CompletionSseChunk::Usage(_) => panic!("expected regular chunk"), } + + match &chunks[2] { + CompletionSseChunk::Chunk(chunk) => { + assert_eq!( + chunk.choices[0].finish_reason.as_deref(), + Some("repetition") + ); + assert_eq!( + chunk.choices[0].stop_reason, + Some(serde_json::json!("repetition_detected")) + ); + } + CompletionSseChunk::Usage(_) => panic!("expected regular chunk"), + } + + match &chunks[3] { + CompletionSseChunk::Usage(chunk) => { + assert_eq!( + chunk + .usage + .as_ref() + .expect("usage") + .prompt_tokens_details + .as_ref() + .map(|details| details.cached_tokens), + Some(3) + ); + } + CompletionSseChunk::Chunk(_) => panic!("expected usage chunk"), + } + } + + #[tokio::test] + async fn collect_completion_hides_internal_prompt_only_token() { + let stream = stream::iter(vec![ + Ok(DecodedTextEvent::Start { + prompt_token_ids: vec![1, 2].into(), + prompt_logprobs: None, + }), + Ok(DecodedTextEvent::TextDelta { + delta: " leaked".to_string(), + token_ids: vec![3], + logprobs: None, + finished: Some(Finished { + usage: vllm_llm::TokenUsage { + prompt_token_count: 2, + output_token_count: 1, + cached_token_count: 0, + }, + finish_reason: FinishReason::Length, + kv_transfer_params: None, + }), + }), + ]); + + let response = super::collect_completion( + stream, + "cmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions { + prompt_only: true, + echo: Some("hello".to_string()), + return_token_ids: true, + ..Default::default() + }, + ) + .await + .expect("collect completion"); + + assert_eq!(response.choices[0].text, "hello"); + assert_eq!(response.choices[0].token_ids.as_deref(), Some(&[3][..])); + assert_eq!( + response.choices[0].prompt_token_ids.as_deref(), + Some(&[1, 2][..]) + ); + let usage = response.usage.expect("usage"); + assert_eq!(usage.prompt_tokens, 2); + assert_eq!(usage.completion_tokens, Some(1)); + assert_eq!(usage.total_tokens, 3); + } + + #[tokio::test] + async fn collect_completion_maps_prompt_logprobs_for_single_token_prompt() { + let stream = stream::iter(vec![ + Ok(DecodedTextEvent::Start { + prompt_token_ids: vec![9707].into(), + prompt_logprobs: None, + }), + Ok(DecodedTextEvent::TextDelta { + delta: " leaked".to_string(), + token_ids: vec![3], + logprobs: None, + finished: Some(Finished { + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, + finish_reason: FinishReason::Length, + kv_transfer_params: None, + }), + }), + ]); + + let response = super::collect_completion( + stream, + "cmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions { + prompt_only: true, + echo: Some("Hello".to_string()), + requested_logprobs: Some(1), + include_prompt_logprobs: true, + ..Default::default() + }, + ) + .await + .expect("collect completion"); + + let choice = &response.choices[0]; + assert_eq!(choice.text, "Hello"); + assert_eq!(choice.prompt_logprobs, Some(vec![None])); + let logprobs = choice.logprobs.as_ref().expect("logprobs"); + assert_eq!(logprobs.tokens, vec!["Hello".to_string()]); + assert_eq!(logprobs.token_logprobs, vec![None]); + assert_eq!(logprobs.top_logprobs, vec![None]); + assert_eq!(logprobs.text_offset, vec![0]); + let usage = response.usage.expect("usage"); + assert_eq!(usage.prompt_tokens, 1); + assert_eq!(usage.completion_tokens, Some(1)); + assert_eq!(usage.total_tokens, 2); + } + + #[tokio::test] + async fn completion_chunk_stream_hides_internal_prompt_only_token() { + let stream = stream::iter(vec![ + Ok(DecodedTextEvent::Start { + prompt_token_ids: vec![1, 2].into(), + prompt_logprobs: None, + }), + Ok(DecodedTextEvent::TextDelta { + delta: " leaked".to_string(), + token_ids: vec![3], + logprobs: None, + finished: Some(Finished { + usage: vllm_llm::TokenUsage { + prompt_token_count: 2, + output_token_count: 1, + cached_token_count: 0, + }, + finish_reason: FinishReason::Length, + kv_transfer_params: None, + }), + }), + ]); + + let chunks = completion_chunk_stream( + stream, + "cmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions { + include_usage: true, + prompt_only: true, + echo: Some("hello".to_string()), + return_token_ids: true, + ..Default::default() + }, + ) + .collect::>() + .await; + + let chunks: Vec<_> = chunks.into_iter().try_collect().expect("stream should succeed"); + assert_eq!(chunks.len(), 3); + + match &chunks[0] { + CompletionSseChunk::Chunk(chunk) => { + assert_eq!(chunk.choices[0].text, "hello"); + assert_eq!( + chunk.choices[0].prompt_token_ids.as_deref(), + Some(&[1, 2][..]) + ); + } + CompletionSseChunk::Usage(_) => panic!("expected prompt chunk"), + } + match &chunks[1] { + CompletionSseChunk::Chunk(chunk) => { + assert_eq!(chunk.choices[0].text, ""); + assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("length")); + } + CompletionSseChunk::Usage(_) => panic!("expected final chunk"), + } + match &chunks[2] { + CompletionSseChunk::Usage(chunk) => { + let usage = chunk.usage.as_ref().expect("usage"); + assert_eq!(usage.prompt_tokens, 2); + assert_eq!(usage.completion_tokens, Some(1)); + assert_eq!(usage.total_tokens, 3); + } + CompletionSseChunk::Chunk(_) => panic!("expected usage chunk"), + } + } + + #[tokio::test] + async fn completion_chunk_stream_maps_prompt_logprobs_for_single_token_prompt() { + let stream = stream::iter(vec![ + Ok(DecodedTextEvent::Start { + prompt_token_ids: vec![9707].into(), + prompt_logprobs: None, + }), + Ok(DecodedTextEvent::TextDelta { + delta: " leaked".to_string(), + token_ids: vec![3], + logprobs: None, + finished: Some(Finished { + usage: vllm_llm::TokenUsage { + prompt_token_count: 1, + output_token_count: 1, + cached_token_count: 0, + }, + finish_reason: FinishReason::Length, + kv_transfer_params: None, + }), + }), + ]); + + let chunks = completion_chunk_stream( + stream, + "cmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions { + prompt_only: true, + echo: Some("Hello".to_string()), + requested_logprobs: Some(1), + ..Default::default() + }, + ) + .collect::>() + .await; + + let chunks: Vec<_> = chunks.into_iter().try_collect().expect("stream should succeed"); + assert_eq!(chunks.len(), 2); + + match &chunks[0] { + CompletionSseChunk::Chunk(chunk) => { + assert_eq!(chunk.choices[0].text, "Hello"); + let logprobs = chunk.choices[0].logprobs.as_ref().expect("logprobs"); + assert_eq!(logprobs.tokens, vec!["Hello".to_string()]); + assert_eq!(logprobs.token_logprobs, vec![None]); + assert_eq!(logprobs.top_logprobs, vec![None]); + assert_eq!(logprobs.text_offset, vec![0]); + } + CompletionSseChunk::Usage(_) => panic!("expected prompt chunk"), + } + match &chunks[1] { + CompletionSseChunk::Chunk(chunk) => { + assert_eq!(chunk.choices[0].text, ""); + assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("length")); + } + CompletionSseChunk::Usage(_) => panic!("expected final chunk"), + } + } + + #[tokio::test] + async fn completion_chunk_stream_maps_prompt_only_logprobs() { + let stream = stream::iter(vec![ + Ok(DecodedTextEvent::Start { + prompt_token_ids: vec![1, 2].into(), + prompt_logprobs: Some(DecodedPromptLogprobs { + first_token_id: 1, + first_token: "he".to_string(), + scored_positions: vec![DecodedPositionLogprobs { + entries: vec![DecodedTokenLogprob { + token_id: 2, + token: "llo".to_string(), + logprob: -0.2, + rank: 1, + }], + }], + }), + }), + Ok(DecodedTextEvent::TextDelta { + delta: " leaked".to_string(), + token_ids: vec![3], + logprobs: None, + finished: Some(Finished { + usage: vllm_llm::TokenUsage { + prompt_token_count: 2, + output_token_count: 1, + cached_token_count: 0, + }, + finish_reason: FinishReason::Length, + kv_transfer_params: None, + }), + }), + ]); + + let chunks = completion_chunk_stream( + stream, + "cmpl-1".to_string(), + "model".to_string(), + 1, + ApiServerOptions::default(), + ResponseOptions { + prompt_only: true, + echo: Some("hello".to_string()), + requested_logprobs: Some(1), + ..Default::default() + }, + ) + .collect::>() + .await; + + let chunks: Vec<_> = chunks.into_iter().try_collect().expect("stream should succeed"); + assert_eq!(chunks.len(), 2); + + match &chunks[0] { + CompletionSseChunk::Chunk(chunk) => { + assert_eq!(chunk.choices[0].text, "hello"); + let logprobs = chunk.choices[0].logprobs.as_ref().expect("logprobs"); + assert_eq!(logprobs.tokens, vec!["he".to_string(), "llo".to_string()]); + assert_eq!(logprobs.token_logprobs, vec![None, Some(-0.2)]); + assert_eq!(logprobs.text_offset, vec![0, 2]); + } + CompletionSseChunk::Usage(_) => panic!("expected prompt chunk"), + } + match &chunks[1] { + CompletionSseChunk::Chunk(chunk) => { + assert_eq!(chunk.choices[0].text, ""); + assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("length")); + } + CompletionSseChunk::Usage(_) => panic!("expected final chunk"), + } } } diff --git a/rust/src/server/src/routes/openai/completions/convert.rs b/rust/src/server/src/routes/openai/completions/convert.rs index 2d4ff089397c..1355481b49b7 100644 --- a/rust/src/server/src/routes/openai/completions/convert.rs +++ b/rust/src/server/src/routes/openai/completions/convert.rs @@ -1,4 +1,6 @@ -use vllm_text::{SamplingParams, TextDecodeOptions, TextRequest}; +use thiserror_ext::AsReport as _; +use vllm_text::tokenizer::Tokenizer; +use vllm_text::{Prompt, SamplingParams, TextDecodeOptions, TextRequest}; use super::types::CompletionRequest; use crate::error::ApiError; @@ -10,18 +12,31 @@ use crate::utils::{ResolvedRequestContext, convert_logit_bias, merge_kv_transfer /// Lowered completion request plus the public response metadata carried by /// every SSE chunk. #[derive(Debug, Clone, PartialEq)] -pub struct PreparedRequest { +pub(super) struct PreparedRequest { /// Stable OpenAI-style request ID, reused as the external text request ID. pub request_id: String, /// Public model ID echoed back to the client. pub response_model: String, - /// Whether the caller asked for the final streamed usage chunk. - pub include_usage: bool, + /// Public response rendering options for route-layer helpers. + pub options: ResponseOptions, /// Lowered text request for the shared `vllm-text` facade. pub text_request: TextRequest, - /// Original text prompt that should be echoed back northbound when - /// `echo=true`. +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub(super) struct ResponseOptions { + /// Whether the caller asked for the final streamed usage chunk. + pub include_usage: bool, + /// Whether every streamed chunk should carry cumulative usage. + pub include_continuous_usage: bool, + /// Whether the caller requested prompt-only echo via `max_tokens=0`. + pub prompt_only: bool, + /// Prompt text that should be echoed back northbound when `echo=true`. pub echo: Option, + /// Whether the caller requested output logprobs on completion choices. + pub requested_logprobs: Option, + /// Whether the caller requested choice-level prompt logprobs. + pub include_prompt_logprobs: bool, /// Whether to include token IDs alongside generated text. pub return_token_ids: bool, /// Whether to format logprob tokens as `token_id:{id}`. @@ -33,10 +48,11 @@ pub struct PreparedRequest { /// /// `lora_resolution.model_names` must be non-empty; the first entry is used as /// the base `model` field in responses when no LoRA adapter is selected. -pub(crate) fn prepare_completion_request( +pub(super) fn prepare_completion_request( request: CompletionRequest, lora_resolution: &LoraModelResolution, ctx: ResolvedRequestContext, + tokenizer: &dyn Tokenizer, ) -> Result { validate::validate_request_compat(&request, &lora_resolution.model_names)?; @@ -56,15 +72,29 @@ pub(crate) fn prepare_completion_request( })?), None => None, }; - let prompt_logprobs = request.prompt_logprobs.or(if request.echo && !request.stream { - logprobs - } else { - None - }); + let prompt_only = request.echo && request.max_tokens == Some(0); + let prompt_logprobs = + request.prompt_logprobs.or(if request.echo && (!request.stream || prompt_only) { + logprobs + } else { + None + }); let include_usage = (request.stream_options.as_ref()) .and_then(|options| options.include_usage) .unwrap_or(false); - let echo = request.echo.then(|| request.prompt.as_text().cloned()).flatten(); + let include_continuous_usage = include_usage + && request + .stream_options + .as_ref() + .and_then(|options| options.continuous_usage_stats) + .unwrap_or(false); + let include_prompt_logprobs = prompt_logprobs.is_some(); + let max_tokens = if prompt_only { + Some(1) + } else { + request.max_tokens + }; + let echo = completion_echo_text(&request, tokenizer)?; let structured_outputs = convert_from_response_format_value(&request.response_format, &request.structured_outputs)?; @@ -78,14 +108,16 @@ pub(crate) fn prepare_completion_request( top_p: request.top_p, top_k: request.top_k, seed: request.seed, - max_tokens: request.max_tokens, + max_tokens, min_tokens: request.min_tokens, + thinking_token_budget: request.thinking_token_budget, logprobs, prompt_logprobs, min_p: request.min_p, frequency_penalty: request.frequency_penalty, presence_penalty: request.presence_penalty, repetition_penalty: request.repetition_penalty, + repetition_detection: request.repetition_detection, stop_token_ids: request.stop_token_ids, ignore_eos: request.ignore_eos, logit_bias: convert_logit_bias(request.logit_bias)?, @@ -110,29 +142,66 @@ pub(crate) fn prepare_completion_request( cache_salt: request.cache_salt, add_special_tokens: request.add_special_tokens, data_parallel_rank: ctx.data_parallel_rank, + reasoning_parser_kwargs: None, lora_request: lora_resolution.lora_request.clone(), + arrival_time: None, }; Ok(PreparedRequest { request_id, response_model, - include_usage, + options: ResponseOptions { + include_usage, + include_continuous_usage, + prompt_only, + echo, + requested_logprobs: request.logprobs, + include_prompt_logprobs, + return_token_ids: request.return_token_ids.unwrap_or(false), + return_tokens_as_token_ids: request.return_tokens_as_token_ids.unwrap_or(false), + }, text_request, - echo, - return_token_ids: request.return_token_ids.unwrap_or(false), - return_tokens_as_token_ids: request.return_tokens_as_token_ids.unwrap_or(false), }) } +fn completion_echo_text( + request: &CompletionRequest, + tokenizer: &dyn Tokenizer, +) -> Result, ApiError> { + if !request.echo { + return Ok(None); + } + + match &request.prompt { + Prompt::Text(prompt) => Ok(Some(prompt.clone())), + Prompt::TokenIds(token_ids) if request.return_token_ids.unwrap_or(false) => { + Ok(Some(String::new())) + } + Prompt::TokenIds(token_ids) => { + tokenizer.decode(token_ids, false).map(Some).map_err(|error| { + ApiError::invalid_request( + format!( + "Failed to decode token-ID prompt for echo: {}", + error.to_report_string() + ), + Some("prompt"), + ) + }) + } + } +} + #[cfg(test)] mod tests { use axum::http::HeaderMap; use serde_json::json; use vllm_text::Prompt; + use vllm_tokenizer::test_utils::TestTokenizer; use super::prepare_completion_request; use crate::lora::LoraModelResolution; use crate::routes::openai::completions::types::CompletionRequest; + use crate::routes::openai::utils::types::Normalizable; use crate::utils::{ResolvedRequestContext, resolve_request_context}; fn request_context(headers: &HeaderMap, request_id: Option<&str>) -> ResolvedRequestContext { @@ -146,6 +215,10 @@ mod tests { } } + fn test_tokenizer() -> TestTokenizer { + TestTokenizer::new() + } + fn base_request_json() -> serde_json::Value { json!({ "model": "Qwen/Qwen1.5-0.5B-Chat", @@ -179,6 +252,28 @@ mod tests { assert!(request.ignore_eos); } + #[test] + fn normalize_coerces_null_max_tokens_to_default() { + // An absent `max_tokens` already gets the serde default. + let absent: CompletionRequest = + serde_json::from_value(base_request_json()).expect("parse request"); + assert_eq!(absent.max_tokens, Some(16)); + + // An explicit `null` deserializes to `None`, bypassing the default; + // `normalize` must coerce it back to match Python vLLM. + let mut request: CompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": true, + "max_tokens": null + })) + .expect("parse request"); + assert_eq!(request.max_tokens, None); + + request.normalize(); + assert_eq!(request.max_tokens, Some(16)); + } + #[test] fn prepare_completion_request_maps_sampling_fields() { let request: CompletionRequest = serde_json::from_value(json!({ @@ -203,10 +298,11 @@ mod tests { request, &served(&["Qwen/Qwen1.5-0.5B-Chat"]), ResolvedRequestContext::default(), + &test_tokenizer(), ) .expect("prepare"); - assert!(prepared.include_usage); + assert!(prepared.options.include_usage); assert_eq!( prepared.text_request.prompt, Prompt::TokenIds(vec![11, 22, 33]) @@ -232,6 +328,86 @@ mod tests { assert!(!prepared.text_request.decode_options.skip_special_tokens); } + #[test] + fn prepare_completion_request_passes_through_thinking_token_budget() { + let prepare = |budget: serde_json::Value| { + let request: CompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "thinking_token_budget": budget, + })) + .expect("parse request"); + prepare_completion_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + &test_tokenizer(), + ) + .expect("prepare") + .text_request + .sampling_params + .thinking_token_budget + }; + + // The convert layer forwards the raw value verbatim (including the `-1` + // "unlimited" sentinel); normalization/validation happens during + // lowering (see `vllm_text::lower`). + assert_eq!(prepare(json!(64)), Some(64)); + assert_eq!(prepare(json!(-1)), Some(-1)); + assert_eq!(prepare(json!(null)), None); + } + + #[test] + fn prepare_completion_request_maps_stream_usage_and_token_format_options() { + let request: CompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": true, + "stream_options": { + "include_usage": true, + "continuous_usage_stats": true + }, + "return_tokens_as_token_ids": true + })) + .expect("parse request"); + + let prepared = prepare_completion_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + &test_tokenizer(), + ) + .expect("prepare"); + + assert!(prepared.options.include_usage); + assert!(prepared.options.include_continuous_usage); + assert!(prepared.options.return_tokens_as_token_ids); + } + + #[test] + fn prepare_completion_request_gates_continuous_usage_on_include_usage() { + let request: CompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": true, + "stream_options": { + "continuous_usage_stats": true + } + })) + .expect("parse request"); + + let prepared = prepare_completion_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + &test_tokenizer(), + ) + .expect("prepare"); + + assert!(!prepared.options.include_usage); + assert!(!prepared.options.include_continuous_usage); + } + #[test] fn prepare_completion_request_accepts_text_echo() { let request: CompletionRequest = serde_json::from_value(json!({ @@ -247,11 +423,65 @@ mod tests { request, &served(&["Qwen/Qwen1.5-0.5B-Chat"]), ResolvedRequestContext::default(), + &test_tokenizer(), ) .expect("prepare"); - assert_eq!(prepared.echo, Some("hello".to_string())); + assert_eq!(prepared.options.echo, Some("hello".to_string())); assert_eq!(prepared.text_request.sampling_params.max_tokens, Some(7)); + assert!(!prepared.options.prompt_only); + } + + #[test] + fn prepare_completion_request_lowers_prompt_only_echo_as_one_internal_token() { + let request: CompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": false, + "echo": true, + "max_tokens": 0 + })) + .expect("parse request"); + + let prepared = prepare_completion_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + &test_tokenizer(), + ) + .expect("prepare"); + + assert!(prepared.options.prompt_only); + assert_eq!(prepared.options.echo, Some("hello".to_string())); + assert_eq!(prepared.text_request.sampling_params.max_tokens, Some(1)); + } + + #[test] + fn prepare_completion_request_enables_prompt_logprobs_for_stream_prompt_only_echo() { + let request: CompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "echo": true, + "stream": true, + "max_tokens": 0, + "logprobs": 3 + })) + .expect("parse request"); + + let prepared = prepare_completion_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + &test_tokenizer(), + ) + .expect("prepare"); + + assert!(prepared.options.prompt_only); + assert_eq!(prepared.text_request.sampling_params.logprobs, Some(3)); + assert_eq!( + prepared.text_request.sampling_params.prompt_logprobs, + Some(3) + ); } #[test] @@ -269,6 +499,7 @@ mod tests { request, &served(&["Qwen/Qwen1.5-0.5B-Chat"]), ResolvedRequestContext::default(), + &test_tokenizer(), ) .expect("prepare"); @@ -280,22 +511,27 @@ mod tests { } #[test] - fn prepare_completion_request_rejects_token_id_prompt_echo() { + fn prepare_completion_request_decodes_token_id_prompt_echo() { let request: CompletionRequest = serde_json::from_value(json!({ "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": [11, 22, 33], + "prompt": [104, 101, 108, 108, 111], "stream": true, "echo": true })) .expect("parse request"); - assert!( - prepare_completion_request( - request, - &served(&["Qwen/Qwen1.5-0.5B-Chat"]), - ResolvedRequestContext::default(), - ) - .is_err() + let prepared = prepare_completion_request( + request, + &served(&["Qwen/Qwen1.5-0.5B-Chat"]), + ResolvedRequestContext::default(), + &test_tokenizer(), + ) + .expect("prepare"); + + assert_eq!(prepared.options.echo, Some("hello".to_string())); + assert_eq!( + prepared.text_request.prompt, + Prompt::TokenIds(vec![104, 101, 108, 108, 111]) ); } @@ -314,6 +550,7 @@ mod tests { request, &served(&["Qwen/Qwen1.5-0.5B-Chat"]), ResolvedRequestContext::default(), + &test_tokenizer(), ) .expect("prepare"); assert_eq!(prepared.text_request.sampling_params.logprobs, Some(1)); @@ -338,6 +575,7 @@ mod tests { request, &served(&["Qwen/Qwen1.5-0.5B-Chat"]), request_context(&headers, None), + &test_tokenizer(), ) .expect("prepare"); assert_eq!(prepared.text_request.data_parallel_rank, Some(3)); @@ -356,6 +594,7 @@ mod tests { request, &served(&["Qwen/Qwen1.5-0.5B-Chat"]), ResolvedRequestContext::default(), + &test_tokenizer(), ) .expect("prepare"); assert_eq!(prepared.text_request.data_parallel_rank, None); diff --git a/rust/src/server/src/routes/openai/completions/types.rs b/rust/src/server/src/routes/openai/completions/types.rs index adc8a7ba7cbd..32542b8b3513 100644 --- a/rust/src/server/src/routes/openai/completions/types.rs +++ b/rust/src/server/src/routes/openai/completions/types.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; +use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams; use vllm_text::Prompt; use crate::routes::openai::utils::types::{ @@ -100,6 +101,9 @@ pub struct CompletionRequest { /// Repetition penalty for reducing repetitive text pub repetition_penalty: Option, + /// Parameters for detecting repetitive N-gram patterns in output tokens + pub repetition_detection: Option, + /// Length penalty for beam search pub length_penalty: Option, @@ -146,6 +150,11 @@ pub struct CompletionRequest { /// Additional kwargs for structured outputs pub structured_outputs: Option, + /// Token budget for reasoning/thinking. Accepts a non-negative integer, or + /// `-1` for unlimited (mirroring the Python frontend, which normalizes `-1` + /// to "no budget"). + pub thinking_token_budget: Option, + /// Request scheduling priority (lower means earlier; default 0) pub priority: Option, @@ -174,10 +183,22 @@ pub struct CompletionRequest { pub other: Map, } -impl Normalizable for CompletionRequest {} +impl Normalizable for CompletionRequest { + /// Normalize the request by applying defaults. + fn normalize(&mut self) { + // An explicit `"max_tokens": null` deserializes to `None`, bypassing the + // serde field default. Coerce it back to the default so it behaves like + // an absent field, matching Python vLLM's `normalize_null_max_tokens`. + if self.max_tokens.is_none() { + self.max_tokens = default_completion_max_tokens(); + } + } +} /// Mirrors the Python vLLM `CompletionResponse` class. -#[serde_with::skip_serializing_none] +/// +/// Do not skip serializing `None` fields here: non-streaming response types +/// should serialize `None` as explicit `null`. #[derive(Debug, Clone, Serialize)] pub(super) struct CompletionResponse { pub id: String, @@ -191,7 +212,6 @@ pub(super) struct CompletionResponse { } /// Mirrors the Python vLLM `CompletionResponseChoice` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub(super) struct CompletionChoice { pub index: u32, diff --git a/rust/src/server/src/routes/openai/completions/validate.rs b/rust/src/server/src/routes/openai/completions/validate.rs index a53609234b60..7db8a5878b3c 100644 --- a/rust/src/server/src/routes/openai/completions/validate.rs +++ b/rust/src/server/src/routes/openai/completions/validate.rs @@ -1,5 +1,3 @@ -use vllm_text::Prompt; - use super::types::CompletionRequest; use crate::error::{ApiError, bail_invalid_request}; @@ -26,14 +24,10 @@ pub(super) fn validate_request_compat( bail_invalid_request!(param = "n", "Only n=1 is supported."); } - if request.max_tokens == Some(0) { - bail_invalid_request!(param = "max_tokens", "max_tokens must be greater than 0."); - } - - if request.echo && matches!(request.prompt, Prompt::TokenIds(_)) { + if request.max_tokens == Some(0) && !request.echo { bail_invalid_request!( - param = "echo", - "echo is not supported with token-ID prompts." + param = "max_tokens", + "max_tokens=0 is only supported when echo=true." ); } @@ -92,15 +86,6 @@ pub(super) fn validate_request_compat( ); } - if let Some(options) = &request.stream_options - && options.continuous_usage_stats.is_some() - { - bail_invalid_request!( - param = "stream_options", - "continuous_usage_stats is not supported." - ); - } - Ok(()) } @@ -175,4 +160,45 @@ mod tests { validate_request_compat(&request, &served_names(&["Qwen/Qwen1.5-0.5B-Chat"])).is_ok() ); } + + #[test] + fn validate_request_compat_accepts_prompt_only_echo() { + let request = CompletionRequest { + stream: false, + echo: true, + max_tokens: Some(0), + ..base_request() + }; + assert!( + validate_request_compat(&request, &served_names(&["Qwen/Qwen1.5-0.5B-Chat"])).is_ok() + ); + } + + #[test] + fn validate_request_compat_accepts_token_id_prompt_echo() { + let request: CompletionRequest = serde_json::from_value(json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": [104, 101, 108, 108, 111], + "stream": true, + "echo": true, + })) + .expect("parse request"); + + assert!( + validate_request_compat(&request, &served_names(&["Qwen/Qwen1.5-0.5B-Chat"])).is_ok() + ); + } + + #[test] + fn validate_request_compat_rejects_prompt_only_without_echo() { + let request = CompletionRequest { + stream: false, + echo: false, + max_tokens: Some(0), + ..base_request() + }; + assert!( + validate_request_compat(&request, &served_names(&["Qwen/Qwen1.5-0.5B-Chat"])).is_err() + ); + } } diff --git a/rust/src/server/src/routes/openai/models.rs b/rust/src/server/src/routes/openai/models.rs index 42efd259e1b0..b06e2dc693f9 100644 --- a/rust/src/server/src/routes/openai/models.rs +++ b/rust/src/server/src/routes/openai/models.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use axum::Json; use axum::extract::State; @@ -6,19 +7,39 @@ use axum::extract::State; use crate::routes::openai::utils::types::{ListModelsResponse, ModelObject}; use crate::state::AppState; -/// Return all configured served model names in OpenAI `list models` format. +// Frontend marker; Python uses "vllm". +const OWNED_BY: &str = "vllm-frontend-rs"; + +/// Base cards carry `max_model_len` and `root` = model path; LoRA cards carry +/// `root` = adapter path and `parent` = base model. LoRA cards follow load order. pub async fn list_models(State(state): State>) -> Json { - let model_names = state.served_model_names_with_loras().await; + let created = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64; + let max_model_len = state.chat.engine_core_client().max_model_len(); + let model_path = state.model_path().map(str::to_string); + + let base_cards = state.served_model_names().iter().map(|name| ModelObject { + id: name.clone(), + object: "model".to_string(), + created, + owned_by: OWNED_BY.to_string(), + root: Some(model_path.clone().unwrap_or_else(|| name.clone())), + parent: None, + max_model_len: Some(max_model_len), + }); + + let primary = state.primary_model_name().to_string(); + let lora_cards = state.served_lora_requests().await.into_iter().map(|lora| ModelObject { + id: lora.lora_name, + object: "model".to_string(), + created, + owned_by: OWNED_BY.to_string(), + root: Some(lora.lora_path), + parent: Some(lora.base_model_name.unwrap_or_else(|| primary.clone())), + max_model_len: None, + }); + Json(ListModelsResponse { object: "list".to_string(), - data: model_names - .into_iter() - .map(|name| ModelObject { - id: name, - object: "model".to_string(), - created: 0, - owned_by: "vllm-frontend-rs".to_string(), - }) - .collect(), + data: base_cards.chain(lora_cards).collect(), }) } diff --git a/rust/src/server/src/routes/openai/utils/mod.rs b/rust/src/server/src/routes/openai/utils/mod.rs index 57b1d99690d4..70e9d1466ded 100644 --- a/rust/src/server/src/routes/openai/utils/mod.rs +++ b/rust/src/server/src/routes/openai/utils/mod.rs @@ -1,4 +1,5 @@ pub mod logprobs; pub mod structured_outputs; pub mod types; +pub mod usage; pub mod validated_json; diff --git a/rust/src/server/src/routes/openai/utils/structured_outputs.rs b/rust/src/server/src/routes/openai/utils/structured_outputs.rs index e974c836bb5e..e717765c51ac 100644 --- a/rust/src/server/src/routes/openai/utils/structured_outputs.rs +++ b/rust/src/server/src/routes/openai/utils/structured_outputs.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use vllm_engine_core_client::protocol::StructuredOutputsParams; +use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams; use crate::error::ApiError; @@ -37,7 +37,7 @@ pub enum ResponseFormat { }, /// vLLM-specific structural tag format. The entire object (including the /// `type` field) is JSON-serialized and passed as - /// `StructuredOutputsParams.structural_tag`. + /// `StructuredOutputConstraint::StructuralTag`. /// /// We capture the payload as a catch-all map so both the legacy /// (`structures`/`triggers`) and current (`format`) shapes are @@ -79,14 +79,10 @@ pub fn convert_from_response_format( }; match fmt { ResponseFormat::Text => Ok(None), - ResponseFormat::JsonObject => Ok(Some(StructuredOutputsParams { - json_object: Some(true), - ..Default::default() - })), - ResponseFormat::JsonSchema { json_schema } => Ok(Some(StructuredOutputsParams { - json: Some(json_schema.schema.clone()), - ..Default::default() - })), + ResponseFormat::JsonObject => Ok(Some(StructuredOutputsParams::json_object())), + ResponseFormat::JsonSchema { json_schema } => Ok(Some(StructuredOutputsParams::json( + json_schema.schema.clone(), + ))), ResponseFormat::StructuralTag { .. } => { // The Python frontend dumps the entire response_format object (including the // `type` field) as a JSON string for the engine-core backend. @@ -96,10 +92,7 @@ pub fn convert_from_response_format( Some("response_format"), ) })?; - Ok(Some(StructuredOutputsParams { - structural_tag: Some(tag_json), - ..Default::default() - })) + Ok(Some(StructuredOutputsParams::structural_tag(tag_json))) } } } diff --git a/rust/src/server/src/routes/openai/utils/types.rs b/rust/src/server/src/routes/openai/utils/types.rs index ff747a5daf55..98eb10f937ae 100644 --- a/rust/src/server/src/routes/openai/utils/types.rs +++ b/rust/src/server/src/routes/openai/utils/types.rs @@ -4,6 +4,7 @@ use std::slice; use llm_multimodal::ImageDetail; use serde::{Deserialize, Serialize}; use serde_json::Value; +use vllm_llm::TokenUsage; // ============================================================================ // Constants @@ -310,32 +311,87 @@ pub enum MessageContent { // ============================================================================ /// Mirrors the Python vLLM `UsageInfo` class. -#[serde_with::skip_serializing_none] +/// +/// Do not skip serializing `None` fields here: non-streaming response types +/// should serialize `None` as explicit `null`. #[derive(Debug, Clone, Serialize)] pub struct Usage { - pub prompt_tokens: u32, - pub total_tokens: u32, - pub completion_tokens: Option, + pub prompt_tokens: usize, + pub total_tokens: usize, + pub completion_tokens: Option, pub prompt_tokens_details: Option, } impl Usage { - /// Create a Usage from prompt and completion token counts. - pub fn from_counts(prompt_tokens: u32, completion_tokens: u32) -> Self { + /// Create a Usage with prompt-token cache details. + pub fn from_counts( + prompt_tokens: usize, + completion_tokens: usize, + cached_tokens: Option, + ) -> Self { Self { prompt_tokens, total_tokens: prompt_tokens + completion_tokens, completion_tokens: Some(completion_tokens), - prompt_tokens_details: None, + prompt_tokens_details: cached_tokens + .filter(|&c| c > 0) + .map(|c| PromptTokenUsageInfo { cached_tokens: c }), } } + + pub fn from_token_usage(usage: TokenUsage, enable_prompt_tokens_details: bool) -> Self { + Self::from_counts( + usage.prompt_token_count, + usage.output_token_count, + enable_prompt_tokens_details.then_some(usage.cached_token_count), + ) + } } /// Mirrors the Python vLLM `PromptTokenUsageInfo` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub struct PromptTokenUsageInfo { - pub cached_tokens: Option, + pub cached_tokens: usize, +} + +#[cfg(test)] +mod usage_tests { + use vllm_llm::TokenUsage; + + use super::Usage; + + #[test] + fn token_usage_hides_prompt_token_details_by_default() { + let usage = Usage::from_token_usage( + TokenUsage { + prompt_token_count: 5, + output_token_count: 2, + cached_token_count: 3, + }, + false, + ); + + assert_eq!(usage.prompt_tokens, 5); + assert_eq!(usage.completion_tokens, Some(2)); + assert!(usage.prompt_tokens_details.is_none()); + } + + #[test] + fn token_usage_includes_prompt_token_details_when_enabled() { + let usage = Usage::from_token_usage( + TokenUsage { + prompt_token_count: 5, + output_token_count: 2, + cached_token_count: 3, + }, + true, + ); + + assert_eq!( + usage.prompt_tokens_details.as_ref().map(|details| details.cached_tokens), + Some(3) + ); + } } /// OpenAI completions-style logprobs. @@ -348,14 +404,12 @@ pub struct LogProbs { } /// Mirrors the Python vLLM `ChatCompletionLogProbs` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub struct ChatLogProbs { pub content: Option>, } /// Mirrors the Python vLLM `ChatCompletionLogProbsContent` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub struct ChatLogProbsContent { pub token: String, @@ -365,7 +419,6 @@ pub struct ChatLogProbsContent { } /// Mirrors the Python vLLM `ChatCompletionLogProb` class. -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize)] pub struct TopLogProb { pub token: String, @@ -382,7 +435,6 @@ pub struct ErrorResponse { pub error: ErrorDetail, } -#[serde_with::skip_serializing_none] #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ErrorDetail { pub message: String, @@ -403,6 +455,12 @@ pub struct ModelObject { pub object: String, pub created: i64, pub owned_by: String, + /// Backend model path (base cards) or adapter path (LoRA cards). + pub root: Option, + /// Base model a LoRA adapter derives from; `null` for base models. + pub parent: Option, + /// Maximum context length; `null` for LoRA adapter cards. + pub max_model_len: Option, } /// Response body for `GET /v1/models`. @@ -412,6 +470,41 @@ pub struct ListModelsResponse { pub data: Vec, } +// ============================================================================ +// Shared validation helpers +// ============================================================================ + +/// Validates a messages array is non-empty and has valid user-message content. +/// +/// Used by both `POST /v1/chat/completions` and `POST /tokenize` (chat form) +/// so validation behaviour stays in lockstep. +pub(crate) fn validate_messages( + messages: &[ChatMessage], +) -> Result<(), validator::ValidationError> { + if messages.is_empty() { + return Err(validator::ValidationError::new("messages cannot be empty")); + } + + for msg in messages { + if let ChatMessage::User { content, .. } = msg { + match content { + MessageContent::Text(text) if text.is_empty() => { + return Err(validator::ValidationError::new( + "message content cannot be empty", + )); + } + MessageContent::Parts(parts) if parts.is_empty() => { + return Err(validator::ValidationError::new( + "message content parts cannot be empty", + )); + } + _ => {} + } + } + } + Ok(()) +} + // ============================================================================ // Normalizable trait // ============================================================================ diff --git a/rust/src/server/src/routes/openai/utils/usage.rs b/rust/src/server/src/routes/openai/utils/usage.rs new file mode 100644 index 000000000000..c8c9d1e7262f --- /dev/null +++ b/rust/src/server/src/routes/openai/utils/usage.rs @@ -0,0 +1,35 @@ +use super::types::Usage; + +/// Tracks cumulative token counts for OpenAI streaming chunks. +/// +/// This helper is intentionally only a counter. Callers decide whether to +/// attach `counts()` to each streamed data chunk, while final usage-only chunks +/// should still be built from the authoritative terminal `TokenUsage`. +#[derive(Debug, Clone, Default)] +pub(crate) struct ContinuousUsage { + prompt_tokens: usize, + output_tokens: usize, +} + +impl ContinuousUsage { + /// Record the prompt-token count reported when a stream starts. + pub(crate) fn set_prompt_tokens(&mut self, prompt_tokens: usize) { + self.prompt_tokens = prompt_tokens; + } + + /// Add newly decoded output tokens to the running completion count. + pub(crate) fn add_output_tokens(&mut self, output_tokens: usize) { + self.output_tokens = self.output_tokens.saturating_add(output_tokens); + } + + /// Replace the running counts with the final counts reported by generation. + pub(crate) fn set_final_counts(&mut self, prompt_tokens: usize, output_tokens: usize) { + self.prompt_tokens = prompt_tokens; + self.output_tokens = output_tokens; + } + + /// Build a streaming usage snapshot without prompt cache details. + pub(crate) fn to_usage(&self) -> Usage { + Usage::from_counts(self.prompt_tokens, self.output_tokens, None) + } +} diff --git a/rust/src/server/src/routes/pause.rs b/rust/src/server/src/routes/pause.rs new file mode 100644 index 000000000000..934846054eb3 --- /dev/null +++ b/rust/src/server/src/routes/pause.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::rejection::QueryRejection; +use axum::extract::{Query, State}; +use serde::{Deserialize, Serialize}; +use vllm_engine_core_client::protocol::utility::PauseMode; + +use crate::error::ApiError; +use crate::state::AppState; +use crate::utils::utility_call_error; + +#[derive(Debug, Deserialize)] +pub(crate) struct PauseParams { + #[serde(default)] + mode: PauseMode, + #[serde(default = "default_clear_cache")] + clear_cache: bool, +} + +#[derive(Serialize)] +pub(crate) struct StatusResponse { + status: &'static str, +} + +#[derive(Serialize)] +pub(crate) struct IsPausedResponse { + is_paused: bool, +} + +const fn default_clear_cache() -> bool { + true +} + +fn invalid_query(error: QueryRejection) -> ApiError { + ApiError::invalid_request(error.body_text(), Some("mode")) +} + +// TODO: the Python frontend also accepts the deprecated +// `wait_for_inflight_requests` flag (equivalent to `mode="wait"`); it is +// intentionally omitted here in favor of the `mode` parameter. + +/// Pause the scheduler so generation can be halted (e.g. for weight updates). +pub async fn pause( + State(state): State>, + params: Result, QueryRejection>, +) -> Result, ApiError> { + let Query(params) = params.map_err(invalid_query)?; + + state + .engine_core_client() + .pause_scheduler(params.mode, params.clear_cache) + .await + .map_err(|error| utility_call_error("pause", error))?; + + Ok(Json(StatusResponse { status: "paused" })) +} + +/// Resume the scheduler after a pause. +pub async fn resume(State(state): State>) -> Result, ApiError> { + state + .engine_core_client() + .resume_scheduler() + .await + .map_err(|error| utility_call_error("resume", error))?; + + Ok(Json(StatusResponse { status: "resumed" })) +} + +/// Return whether the scheduler is currently paused. +pub async fn is_paused( + State(state): State>, +) -> Result, ApiError> { + let is_paused = state + .engine_core_client() + .is_scheduler_paused() + .await + .map_err(|error| utility_call_error("is_paused", error))?; + + Ok(Json(IsPausedResponse { is_paused })) +} diff --git a/rust/src/server/src/routes/profile.rs b/rust/src/server/src/routes/profile.rs new file mode 100644 index 000000000000..d70db30d73c3 --- /dev/null +++ b/rust/src/server/src/routes/profile.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use tracing::info; + +use crate::error::ApiError; +use crate::state::AppState; +use crate::utils::utility_call_error; + +/// Start profiling the engine. +pub async fn start_profile(State(state): State>) -> Result { + info!("starting profiler"); + state + .engine_core_client() + .start_profile(None) + .await + .map_err(|error| utility_call_error("start_profile", error))?; + info!("profiler started"); + Ok(StatusCode::OK) +} + +/// Stop profiling the engine. +pub async fn stop_profile(State(state): State>) -> Result { + info!("stopping profiler"); + state + .engine_core_client() + .stop_profile(None) + .await + .map_err(|error| utility_call_error("stop_profile", error))?; + info!("profiler stopped"); + Ok(StatusCode::OK) +} diff --git a/rust/src/server/src/routes/sleep.rs b/rust/src/server/src/routes/sleep.rs index d7b279699b30..9df2e31e7671 100644 --- a/rust/src/server/src/routes/sleep.rs +++ b/rust/src/server/src/routes/sleep.rs @@ -1,9 +1,11 @@ use std::sync::Arc; use axum::Json; +use axum::extract::rejection::QueryRejection; use axum::extract::{Query, State}; use axum::http::StatusCode; use serde::{Deserialize, Serialize}; +use vllm_engine_core_client::protocol::utility::PauseMode; use crate::error::ApiError; use crate::state::AppState; @@ -18,8 +20,8 @@ pub(crate) struct IsSleepingResponse { pub(crate) struct SleepParams { #[serde(default = "default_sleep_level")] level: u32, - #[serde(default = "default_sleep_mode")] - mode: String, + #[serde(default)] + mode: PauseMode, } #[derive(Debug, Default, Deserialize)] @@ -32,18 +34,20 @@ const fn default_sleep_level() -> u32 { 1 } -fn default_sleep_mode() -> String { - "abort".to_string() +fn invalid_query(error: QueryRejection) -> ApiError { + ApiError::invalid_request(error.body_text(), Some("mode")) } /// Put the engine to sleep. pub async fn sleep( State(state): State>, - Query(params): Query, + params: Result, QueryRejection>, ) -> Result { + let Query(params) = params.map_err(invalid_query)?; + state .engine_core_client() - .sleep(params.level, ¶ms.mode) + .sleep(params.level, params.mode) .await .map_err(|error| utility_call_error("sleep", error))?; diff --git a/rust/src/server/src/routes/tests.rs b/rust/src/server/src/routes/tests.rs index a3e437e04802..adf7103d050b 100644 --- a/rust/src/server/src/routes/tests.rs +++ b/rust/src/server/src/routes/tests.rs @@ -14,38 +14,42 @@ use std::{fmt, fs}; use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode}; use bytes::Bytes; -use futures::StreamExt as _; use rmpv::Value; use serde_json::json; use serial_test::serial; use tower::{Service as _, ServiceExt as _}; use vllm_chat::{ - ChatBackend, ChatContent, ChatContentPart, ChatEvent, ChatLlm, ChatMessage, ChatRenderer, - ChatRequest, ChatRole, ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, - DynChatRenderer, NewChatOutputProcessorOptions, SamplingParams, + ChatBackend, ChatContent, ChatContentPart, ChatLlm, ChatMessage, ChatRenderer, ChatRequest, + ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer, + NewChatOutputProcessorOptions, }; +use vllm_engine_core_client::mock_engine::default_ready_response; +use vllm_engine_core_client::protocol::decode_value; use vllm_engine_core_client::protocol::logprobs::{ Logprobs, MaybeWireLogprobs, PositionLogprobs, TokenLogprob, }; +use vllm_engine_core_client::protocol::output::{ + EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, RequestBatchOutputs, StopReason, + UtilityCallOutput, +}; +use vllm_engine_core_client::protocol::request::EngineCoreRequest; use vllm_engine_core_client::protocol::utility::{UtilityOutput, UtilityResultEnvelope}; -use vllm_engine_core_client::protocol::{ - EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs, EngineCoreRequest, StopReason, - decode_value, +use vllm_engine_core_client::test_utils::{ + IpcNamespace, spawn_mock_engine_task, spawn_mock_engine_task_with_ready, }; -use vllm_engine_core_client::test_utils::{IpcNamespace, spawn_mock_engine_task}; use vllm_engine_core_client::{ ENGINE_CORE_DEAD_SENTINEL, EngineCoreClient, EngineCoreClientConfig, EngineId, }; use vllm_llm::Llm; use vllm_metrics::METRICS; -use vllm_text::tokenizer::{DynTokenizer, Tokenizer}; +use vllm_text::tokenizer::DynTokenizer; use vllm_text::{Prompt, TextBackend}; +use vllm_tokenizer::test_utils::TestTokenizer; use zeromq::prelude::{SocketRecv, SocketSend}; use zeromq::{DealerSocket, PushSocket, ZmqMessage}; use super::{build_router, build_router_with_dev_mode, build_router_with_dev_mode_and_lora}; -use crate::lora::LoraModelResolution; -use crate::routes::openai::chat_completions::convert::prepare_chat_request; +use crate::config::{ApiServerOptions, CorsConfig}; use crate::state::AppState; fn request_output( @@ -153,6 +157,14 @@ fn sse_data_payloads(text: &str) -> Vec<&str> { text.lines().filter_map(|line| line.strip_prefix("data: ")).collect() } +fn sse_json_payloads(text: &str) -> Vec { + sse_data_payloads(text) + .into_iter() + .filter(|payload| *payload != "[DONE]") + .map(|payload| serde_json::from_str(payload).expect("sse json payload")) + .collect() +} + type TestFuture<'a> = Pin + Send + 'a>>; fn boxed_test_future<'a>(future: impl Future + Send + 'a) -> TestFuture<'a> { @@ -221,19 +233,14 @@ fn engine_outputs_for_request( request_id: &str, output_specs: Vec<(Vec, Option)>, ) -> EngineCoreOutputs { - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: output_specs .into_iter() .map(|(token_ids, finish_reason)| request_output(request_id, token_ids, finish_reason)) .collect(), - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, + ..Default::default() } + .into() } fn test_llm(client: EngineCoreClient) -> Llm { @@ -379,14 +386,15 @@ fn utility_none_result() -> UtilityResultEnvelope { } fn utility_outputs(call_id: u64, result: UtilityResultEnvelope) -> EngineCoreOutputs { - EngineCoreOutputs { - utility_output: Some(UtilityOutput { + UtilityCallOutput { + output: UtilityOutput { call_id: call_id.into(), failure_message: None, result: Some(result), - }), + }, ..Default::default() } + .into() } async fn send_outputs(push: &mut PushSocket, outputs: EngineCoreOutputs) { @@ -407,70 +415,21 @@ struct FakeChatBackend { multimodal_model_info: Option, } -#[derive(Debug)] -struct FakeChatTokenizer; - -impl Tokenizer for FakeChatTokenizer { - fn encode( - &self, - text: &str, - _add_special_tokens: bool, - ) -> vllm_text::tokenizer::Result> { - let mut token_ids = Vec::new(); - let mut rest = text; - while !rest.is_empty() { - if let Some(stripped) = rest.strip_prefix("") { - token_ids.push(999); - rest = stripped; - continue; - } - - let ch = rest.chars().next().expect("rest is not empty"); - let mut buf = [0; 4]; - token_ids.extend(ch.encode_utf8(&mut buf).bytes().map(u32::from)); - rest = &rest[ch.len_utf8()..]; - } - Ok(token_ids) - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_text::tokenizer::Result { - Ok( - String::from_utf8_lossy(&token_ids.iter().map(|id| *id as u8).collect::>()) - .into_owned(), - ) - } - - fn token_to_id(&self, token: &str) -> Option { - match token { - "" => Some(999), - "<|image_pad|>" => Some(151655), - "" => Some(0xF001), - "" => Some(0xF002), - "<|START_THINKING|>" => Some(0xF003), - "<|END_THINKING|>" => Some(0xF004), - "◁think▷" => Some(0xF005), - "◁/think▷" => Some(0xF006), - _ => None, - } - } - - fn id_to_token(&self, id: u32) -> Option { - match id { - 999 => Some("".to_string()), - 151655 => Some("<|image_pad|>".to_string()), - 0xF001 => Some("".to_string()), - 0xF002 => Some("".to_string()), - 0xF003 => Some("<|START_THINKING|>".to_string()), - 0xF004 => Some("<|END_THINKING|>".to_string()), - 0xF005 => Some("◁think▷".to_string()), - 0xF006 => Some("◁/think▷".to_string()), - _ => None, - } - } +/// Synthetic BOS id used when `add_special_tokens` is true in tests. +const FAKE_BOS_TOKEN_ID: u32 = 256; +const UNKNOWN_DECODE_TOKEN_ID: u32 = 10_000; + +fn fake_chat_tokenizer() -> TestTokenizer { + TestTokenizer::new() + .with_bos_token("", FAKE_BOS_TOKEN_ID) + .with_regular_token("", 999) + .with_regular_token("<|image_pad|>", 151655) + .with_regular_token("", 0xF001) + .with_regular_token("", 0xF002) + .with_regular_token("<|START_THINKING|>", 0xF003) + .with_regular_token("<|END_THINKING|>", 0xF004) + .with_regular_token("◁think▷", 0xF005) + .with_regular_token("◁/think▷", 0xF006) } impl FakeChatBackend { @@ -508,7 +467,7 @@ impl fmt::Debug for FakeChatBackend { impl TextBackend for FakeChatBackend { fn tokenizer(&self) -> DynTokenizer { - Arc::new(FakeChatTokenizer) + Arc::new(fake_chat_tokenizer()) } fn model_id(&self) -> &str { @@ -542,11 +501,16 @@ impl ChatBackend for FakeChatBackend { impl ChatRenderer for FakeChatBackend { fn render(&self, request: &ChatRequest) -> vllm_chat::Result { + let placeholder = self + .multimodal_model_info + .as_ref() + .map(|info| info.placeholder_token()) + .unwrap_or(""); let mut prompt = String::new(); for message in &request.messages { prompt.push_str(message.role().as_str()); prompt.push_str(": "); - prompt.push_str(&render_fake_message_content(message)?); + prompt.push_str(&render_fake_message_content(message, placeholder)?); prompt.push('\n'); } if request.chat_options.add_generation_prompt() { @@ -554,21 +518,25 @@ impl ChatRenderer for FakeChatBackend { } Ok(vllm_chat::RenderedPrompt { prompt: Prompt::Text(prompt), + effective_template_kwargs: Default::default(), }) } } -fn render_fake_message_content(message: &ChatMessage) -> vllm_chat::Result { +fn render_fake_message_content( + message: &ChatMessage, + placeholder: &str, +) -> vllm_chat::Result { match message { ChatMessage::System { content } | ChatMessage::Developer { content, .. } | ChatMessage::User { content } - | ChatMessage::ToolResponse { content, .. } => render_fake_content(content), + | ChatMessage::ToolResponse { content, .. } => render_fake_content(content, placeholder), ChatMessage::Assistant { .. } => message.text_content(), } } -fn render_fake_content(content: &ChatContent) -> vllm_chat::Result { +fn render_fake_content(content: &ChatContent, placeholder: &str) -> vllm_chat::Result { Ok(match content { ChatContent::Text(text) => text.clone(), ChatContent::Parts(parts) => { @@ -576,7 +544,7 @@ fn render_fake_content(content: &ChatContent) -> vllm_chat::Result { for part in parts { match part { ChatContentPart::Text { text } => out.push_str(text), - ChatContentPart::ImageUrl { .. } => out.push_str(""), + ChatContentPart::ImageUrl { .. } => out.push_str(placeholder), } } out @@ -591,7 +559,7 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo { )); fs::write( &config_path, - r#"{"model_type":"qwen2_vl","vision_token_id":151655}"#, + r#"{"model_type":"qwen2_vl","image_token_id":151655}"#, ) .expect("write qwen test config"); let info = vllm_chat::multimodal::MultimodalModelInfo::from_paths( @@ -599,7 +567,7 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo { Some("qwen2_vl".to_string()), Some(&config_path), None, - Arc::new(FakeChatTokenizer), + Arc::new(fake_chat_tokenizer()), ) .expect("load multimodal info") .expect("qwen multimodal info is registered"); @@ -607,70 +575,6 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo { info } -#[derive(Clone, Debug)] -struct FailingDecodeChatBackend; - -#[derive(Debug)] -struct FailingDecodeTokenizer; - -impl Tokenizer for FailingDecodeTokenizer { - fn encode( - &self, - text: &str, - add_special_tokens: bool, - ) -> vllm_text::tokenizer::Result> { - FakeChatTokenizer.encode(text, add_special_tokens) - } - - fn decode( - &self, - token_ids: &[u32], - skip_special_tokens: bool, - ) -> vllm_text::tokenizer::Result { - if token_ids.contains(&(b'i' as u32)) { - return Err(vllm_text::tokenizer::TokenizerError( - "forced decode failure for streaming test".to_string(), - )); - } - - FakeChatTokenizer.decode(token_ids, skip_special_tokens) - } - - fn token_to_id(&self, token: &str) -> Option { - FakeChatTokenizer.token_to_id(token) - } -} - -impl TextBackend for FailingDecodeChatBackend { - fn tokenizer(&self) -> DynTokenizer { - Arc::new(FailingDecodeTokenizer) - } - - fn model_id(&self) -> &str { - "test-model" - } -} - -impl ChatBackend for FailingDecodeChatBackend { - fn chat_renderer(&self) -> DynChatRenderer { - Arc::new(self.clone()) - } - - fn new_chat_output_processor( - &self, - _request: &mut ChatRequest, - _options: NewChatOutputProcessorOptions<'_>, - ) -> vllm_chat::Result { - Ok(Box::new(DefaultChatOutputProcessor::plain_text_only())) - } -} - -impl ChatRenderer for FailingDecodeChatBackend { - fn render(&self, request: &ChatRequest) -> vllm_chat::Result { - FakeChatBackend::new().render(request) - } -} - async fn test_models_with_engine_outputs_and_backend_inner( engine_id: impl Into, output_specs: Vec<(Vec, Option)>, @@ -761,6 +665,45 @@ async fn test_app_with_dev_mode(dev_mode_enabled: bool) -> axum::Router { ) } +/// Build a dev-mode router backed by a mock engine using a custom ready +/// response, returning the router and the engine task handle so the engine +/// stays alive for the duration of the test. +async fn test_dev_mode_app_with_ready( + ready_response: vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse, +) -> (axum::Router, MockEngineTask) { + let ipc = IpcNamespace::new().expect("create ipc namespace"); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-world-size".to_vec(); + + let engine_task = MockEngineTask::new(spawn_mock_engine_task_with_ready( + handshake_address.clone(), + engine_id.clone(), + ready_response, + |_dealer, _push| boxed_test_future(async {}), + )); + + let client = EngineCoreClient::connect( + EngineCoreClientConfig::new_single(handshake_address) + .with_model_name("test-model") + .with_local_input_output_addresses( + Some(ipc.input_endpoint()), + Some(ipc.output_endpoint()), + ), + ) + .await + .expect("connect client"); + + let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new())); + let app = build_router_with_dev_mode( + Arc::new(AppState::new( + vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], + chat, + )), + true, + ); + (app, engine_task) +} + async fn test_app_with_request_id_headers() -> (axum::Router, MockEngineTask) { let (chat, engine_task) = test_models_with_engine_outputs_and_backend( b"engine-openai-request-id", @@ -768,13 +711,59 @@ async fn test_app_with_request_id_headers() -> (axum::Router, MockEngineTask) { Arc::new(FakeChatBackend::new()), ) .await; + let app = build_router(Arc::new( + AppState::new(vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], chat).with_api_server_options( + ApiServerOptions { + enable_request_id_headers: true, + ..Default::default() + }, + ), + )); + (app, engine_task) +} + +async fn test_app_with_api_keys(api_keys: Vec) -> (axum::Router, MockEngineTask) { + let (chat, engine_task) = test_models_with_engine_outputs_and_backend( + b"engine-openai-api-key", + default_stream_output_specs(), + Arc::new(FakeChatBackend::new()), + ) + .await; + let app = build_router(Arc::new( + AppState::new(vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], chat).with_api_keys(api_keys), + )); + (app, engine_task) +} + +async fn test_app_with_cors_and_keys( + cors: CorsConfig, + api_keys: Vec, +) -> (axum::Router, MockEngineTask) { + let (chat, engine_task) = test_models_with_engine_outputs_and_backend( + b"engine-openai-cors", + default_stream_output_specs(), + Arc::new(FakeChatBackend::new()), + ) + .await; let app = build_router(Arc::new( AppState::new(vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], chat) - .with_request_id_headers(true), + .with_cors(cors) + .with_api_keys(api_keys), )); (app, engine_task) } +async fn test_app_with_cors(cors: CorsConfig) -> (axum::Router, MockEngineTask) { + test_app_with_cors_and_keys(cors, vec![]).await +} + +fn header_value<'a>(response: &'a axum::response::Response, name: &str) -> Option<&'a str> { + response + .headers() + .get(name) + .map(|value| value.to_str().expect("header is valid utf-8")) +} + async fn test_health_app_with_engine_script( script: F, ) -> (axum::Router, Arc, MockEngineTask) @@ -1034,6 +1023,93 @@ async fn list_models_returns_configured_model() { let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); assert_eq!(json["data"][0]["id"], "Qwen/Qwen1.5-0.5B-Chat"); + // No model path configured: `root` falls back to the served name. + assert_eq!(json["data"][0]["root"], "Qwen/Qwen1.5-0.5B-Chat"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn list_models_base_card_includes_metadata() { + let (chat, _engine_task) = test_models_with_engine_outputs_and_backend( + b"engine-openai-models-meta", + default_stream_output_specs(), + Arc::new(FakeChatBackend::new()), + ) + .await; + // `id` is the served alias; `root` is the underlying model path. + let mut app = build_router(Arc::new( + AppState::new(vec!["public-alias".to_string()], chat) + .with_model_path("org/backend-model".to_string()), + )); + + let response = app + .call(Request::builder().uri("/v1/models").body(Body::empty()).expect("build request")) + .await + .expect("call app"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + let card = json["data"][0].as_object().expect("card object"); + assert_eq!(card["id"], "public-alias"); + assert_eq!(card["owned_by"], "vllm-frontend-rs"); + assert_eq!(card["root"], "org/backend-model"); + assert!(card["max_model_len"].as_u64().expect("max_model_len") > 0); + assert!(card["created"].as_i64().expect("created") > 0); + // `parent` must be emitted as null, not omitted. + assert!(card.contains_key("parent") && card["parent"].is_null()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn list_models_lists_loras_in_load_order() { + // Load out of lexicographic order; the list must preserve load order, not sort. + let (mut app, _engine_task) = test_admin_app_with_engine_script(|dealer, push| { + boxed_test_future(async move { + for _ in 0..2 { + let utility = recv_engine_message(dealer).await; + let payload = decode_value(&utility[1]).expect("decode utility payload"); + let call_id = + payload.as_array().expect("utility array")[1].as_u64().expect("call id"); + send_outputs(push, utility_outputs(call_id, utility_result_value(true))).await; + } + }) + }) + .await; + + for name in ["zebra", "alpha"] { + let path = format!("org/{name}"); + let response = app + .call( + Request::builder() + .method("POST") + .uri("/v1/load_lora_adapter") + .header("content-type", "application/json") + .body(Body::from( + json!({ "lora_name": name, "lora_path": path }).to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + assert_eq!(response.status(), StatusCode::OK); + } + + let models = app + .call(Request::builder().uri("/v1/models").body(Body::empty()).expect("build request")) + .await + .expect("call app"); + let body = to_bytes(models.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + assert_eq!(json["data"][0]["id"], "Qwen/Qwen1.5-0.5B-Chat"); + assert_eq!(json["data"][1]["id"], "zebra"); + assert_eq!(json["data"][2]["id"], "alpha"); + // `max_model_len` must be emitted as null on LoRA cards, not omitted. + let lora_card = json["data"][1].as_object().expect("lora card object"); + assert_eq!(lora_card["root"], "org/zebra"); + assert_eq!(lora_card["parent"], "Qwen/Qwen1.5-0.5B-Chat"); + assert!(lora_card.contains_key("max_model_len") && lora_card["max_model_len"].is_null()); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1075,83 +1151,445 @@ async fn request_id_header_echoes_incoming_header_when_enabled() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn version_returns_engine_vllm_version() { - let mut app = test_app().await; +async fn api_key_auth_rejects_missing_token_on_guarded_route() { + let (mut app, _engine_task) = test_app_with_api_keys(vec!["secret".to_string()]).await; let response = app - .call(Request::builder().uri("/version").body(Body::empty()).expect("build request")) + .call( + Request::builder() + .method("GET") + .uri("/v1/models") + .body(Body::empty()) + .expect("build request"), + ) .await .expect("call app"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); - let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); - assert_eq!( - json, - json!({ - "version": "test-vllm-version", - "rust_frontend_version": env!("CARGO_PKG_VERSION"), - }) - ); + let json: serde_json::Value = serde_json::from_slice(&body).expect("json body"); + assert_eq!(json, json!({ "error": "Unauthorized" })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn server_info_endpoint_is_dev_mode_only() { - let mut app = test_app().await; +async fn api_key_auth_rejects_wrong_token_on_guarded_route() { + let (mut app, _engine_task) = test_app_with_api_keys(vec!["secret".to_string()]).await; let response = app .call( Request::builder() - .uri("/server_info") + .method("GET") + .uri("/v1/models") + .header("authorization", "Bearer wrong") .body(Body::empty()) .expect("build request"), ) .await .expect("call app"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn load_lora_adapter_registers_model_and_forwards_lora_request() { - let (mut app, engine_task) = test_admin_app_with_engine_script(|dealer, push| { - boxed_test_future(async move { - let utility = recv_engine_message(dealer).await; - assert_eq!(utility[0].as_ref(), &[0x03]); - - let payload = decode_value(&utility[1]).expect("decode utility payload"); - let array = payload.as_array().expect("utility payload array"); - let call_id = array[1].as_u64().expect("call id"); - assert_eq!(array[2], Value::from("add_lora")); +async fn api_key_auth_accepts_matching_bearer_token_on_guarded_route() { + let (mut app, _engine_task) = test_app_with_api_keys(vec!["secret".to_string()]).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", "Bearer secret") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); - let args = array[3].as_array().expect("utility args"); - let lora = args[0].as_array().expect("lora request tuple"); - assert_eq!(lora[0], Value::from("adapter-a")); - assert_eq!(lora[1], Value::from(1)); - assert_eq!(lora[2], Value::from("org/adapter-a")); + assert_eq!(response.status(), StatusCode::OK); +} - send_outputs(push, utility_outputs(call_id, utility_result_value(true))).await; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn api_key_auth_allows_options_without_token() { + let (mut app, _engine_task) = test_app_with_api_keys(vec!["secret".to_string()]).await; + let response = app + .call( + Request::builder() + .method("OPTIONS") + .uri("/v1/models") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); - let add = recv_engine_message(dealer).await; - assert_eq!(add[0].as_ref(), &[0x00]); - let request: EngineCoreRequest = - rmp_serde::from_slice(&add[1]).expect("decode engine request"); - assert_adapter_a_lora_request(&request); + assert_ne!(response.status(), StatusCode::UNAUTHORIZED); +} - send_outputs( - push, - engine_outputs_for_request(&request.request_id, default_stream_output_specs()), - ) - .await; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn api_key_auth_allows_unguarded_route_without_token() { + let (mut app, _engine_task) = test_app_with_api_keys(vec!["secret".to_string()]).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/health") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); - let add = recv_engine_message(dealer).await; - assert_eq!(add[0].as_ref(), &[0x00]); - let request: EngineCoreRequest = - rmp_serde::from_slice(&add[1]).expect("decode engine request"); - assert_adapter_a_lora_request(&request); + assert_eq!(response.status(), StatusCode::OK); +} - send_outputs( - push, +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_default_simple_request_allows_any_origin() { + let (mut app, _engine_task) = test_app_with_cors(CorsConfig::default()).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("origin", "http://example.com") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + header_value(&response, "access-control-allow-origin"), + Some("*") + ); + // Wildcard origins without credentials emit no `Vary` (Starlette parity). + assert_eq!(header_value(&response, "vary"), None); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_default_preflight_returns_explicit_methods_and_max_age() { + let (mut app, _engine_task) = test_app_with_cors(CorsConfig::default()).await; + let response = app + .call( + Request::builder() + .method("OPTIONS") + .uri("/v1/chat/completions") + .header("origin", "http://example.com") + .header("access-control-request-method", "POST") + .header("access-control-request-headers", "content-type") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + // `*` methods expand to the explicit method list, matching Starlette + // (never the literal `*`). + assert_eq!( + header_value(&response, "access-control-allow-methods"), + Some("DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT") + ); + assert_eq!( + header_value(&response, "access-control-max-age"), + Some("600") + ); + // `*` headers mirror the requested headers. + assert_eq!( + header_value(&response, "access-control-allow-headers"), + Some("content-type") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_no_origin_request_has_no_cors_headers() { + let (mut app, _engine_task) = test_app_with_cors(CorsConfig::default()).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/health") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(header_value(&response, "access-control-allow-origin"), None); + assert_eq!(header_value(&response, "vary"), None); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_explicit_origin_allowed_reflects_origin_with_vary() { + let cors = CorsConfig { + allow_origins: vec!["http://allowed.com".to_string()], + ..CorsConfig::default() + }; + let (mut app, _engine_task) = test_app_with_cors(cors).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("origin", "http://allowed.com") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!( + header_value(&response, "access-control-allow-origin"), + Some("http://allowed.com") + ); + assert_eq!(header_value(&response, "vary"), Some("origin")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_explicit_origin_disallowed_omits_allow_origin() { + let cors = CorsConfig { + allow_origins: vec!["http://allowed.com".to_string()], + ..CorsConfig::default() + }; + let (mut app, _engine_task) = test_app_with_cors(cors).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("origin", "http://evil.com") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(header_value(&response, "access-control-allow-origin"), None); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_wildcard_with_credentials_reflects_origin_without_panic() { + let cors = CorsConfig { + allow_credentials: true, + ..CorsConfig::default() + }; + let (mut app, _engine_task) = test_app_with_cors(cors).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("origin", "http://example.com") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + // `*` + credentials reflects the request origin instead of `*` (Starlette + // parity, and avoids tower-http's wildcard+credentials panic). + assert_eq!( + header_value(&response, "access-control-allow-origin"), + Some("http://example.com") + ); + assert_eq!( + header_value(&response, "access-control-allow-credentials"), + Some("true") + ); + assert_eq!(header_value(&response, "vary"), Some("origin")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_unauthorized_response_has_no_cors_headers() { + let (mut app, _engine_task) = + test_app_with_cors_and_keys(CorsConfig::default(), vec!["secret".to_string()]).await; + let response = app + .call( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("origin", "http://example.com") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + // Auth sits outside CORS, so a 401 carries no CORS headers. + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(header_value(&response, "access-control-allow-origin"), None); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_preflight_bypasses_auth_and_returns_cors_headers() { + let (mut app, _engine_task) = + test_app_with_cors_and_keys(CorsConfig::default(), vec!["secret".to_string()]).await; + let response = app + .call( + Request::builder() + .method("OPTIONS") + .uri("/v1/chat/completions") + .header("origin", "http://example.com") + .header("access-control-request-method", "POST") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_ne!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + header_value(&response, "access-control-allow-origin"), + Some("*") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_explicit_methods_preflight_returns_that_list() { + let cors = CorsConfig { + allow_methods: vec!["GET".to_string(), "POST".to_string()], + ..CorsConfig::default() + }; + let (mut app, _engine_task) = test_app_with_cors(cors).await; + let response = app + .call( + Request::builder() + .method("OPTIONS") + .uri("/v1/chat/completions") + .header("origin", "http://example.com") + .header("access-control-request-method", "POST") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + // Explicit methods are emitted verbatim, not expanded and not `*`. + assert_eq!( + header_value(&response, "access-control-allow-methods"), + Some("GET,POST") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn cors_explicit_headers_union_safelisted_headers() { + let cors = CorsConfig { + allow_headers: vec!["X-Custom".to_string()], + ..CorsConfig::default() + }; + let (mut app, _engine_task) = test_app_with_cors(cors).await; + let response = app + .call( + Request::builder() + .method("OPTIONS") + .uri("/v1/chat/completions") + .header("origin", "http://example.com") + .header("access-control-request-method", "POST") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + // Explicit headers are unioned with the safelisted set, lowercased + sorted. + assert_eq!( + header_value(&response, "access-control-allow-headers"), + Some("accept,accept-language,content-language,content-type,x-custom") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn version_returns_engine_vllm_version() { + let mut app = test_app().await; + let response = app + .call(Request::builder().uri("/version").body(Body::empty()).expect("build request")) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + assert_eq!( + json, + json!({ + "version": "test-vllm-version", + "rust_frontend_version": env!("CARGO_PKG_VERSION"), + }) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn server_info_endpoint_is_dev_mode_only() { + let mut app = test_app().await; + let response = app + .call( + Request::builder() + .uri("/server_info") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn load_lora_adapter_registers_model_and_forwards_lora_request() { + let (mut app, engine_task) = test_admin_app_with_engine_script(|dealer, push| { + boxed_test_future(async move { + let utility = recv_engine_message(dealer).await; + assert_eq!(utility[0].as_ref(), &[0x03]); + + let payload = decode_value(&utility[1]).expect("decode utility payload"); + let array = payload.as_array().expect("utility payload array"); + let call_id = array[1].as_u64().expect("call id"); + assert_eq!(array[2], Value::from("add_lora")); + + let args = array[3].as_array().expect("utility args"); + let lora = args[0].as_array().expect("lora request tuple"); + assert_eq!(lora[0], Value::from("adapter-a")); + assert_eq!(lora[1], Value::from(1)); + assert_eq!(lora[2], Value::from("org/adapter-a")); + + send_outputs(push, utility_outputs(call_id, utility_result_value(true))).await; + + let add = recv_engine_message(dealer).await; + assert_eq!(add[0].as_ref(), &[0x00]); + let request: EngineCoreRequest = + rmp_serde::from_slice(&add[1]).expect("decode engine request"); + assert_adapter_a_lora_request(&request); + + send_outputs( + push, + engine_outputs_for_request(&request.request_id, default_stream_output_specs()), + ) + .await; + + let add = recv_engine_message(dealer).await; + assert_eq!(add[0].as_ref(), &[0x00]); + let request: EngineCoreRequest = + rmp_serde::from_slice(&add[1]).expect("decode engine request"); + assert_adapter_a_lora_request(&request); + + send_outputs( + push, engine_outputs_for_request(&request.request_id, default_stream_output_specs()), ) .await; @@ -1540,14 +1978,93 @@ async fn http_metrics_record_list_models_requests() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn wrong_model_returns_not_found() { - let mut app = test_app().await; - let response = app - .call( - Request::builder() - .method("POST") - .uri("/v1/chat/completions") - .header("content-type", "application/json") +async fn request_metrics_use_served_model_name_label() { + let ipc = IpcNamespace::new().expect("create ipc namespace"); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-openai-served-model-metrics".to_vec(); + + let engine_task = MockEngineTask::new(spawn_mock_engine_task( + handshake_address.clone(), + engine_id.clone(), + |dealer, push| { + boxed_test_future(async move { + let add = recv_engine_message(dealer).await; + let request: EngineCoreRequest = + rmp_serde::from_slice(&add[1]).expect("decode request"); + send_outputs( + push, + engine_outputs_for_request(&request.request_id, default_stream_output_specs()), + ) + .await; + }) + }, + )); + + let client = EngineCoreClient::connect( + EngineCoreClientConfig::new_single(handshake_address) + .with_model_name("served-model-metrics") + .with_local_input_output_addresses( + Some(ipc.input_endpoint()), + Some(ipc.output_endpoint()), + ), + ) + .await + .expect("connect client"); + let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new())); + let mut app = build_router(Arc::new(AppState::new( + vec![ + "served-model-metrics".to_string(), + "served-model-alias".to_string(), + ], + chat, + ))); + let before = METRICS.render().unwrap(); + + let response = app + .call( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "served-model-alias", + "stream": false, + "messages": [{"role": "user", "content": "hello"}] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + let _ = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + + let after = METRICS.render().unwrap(); + assert_eq!( + metric_delta( + &before, + &after, + "vllm:request_success_total", + Some("model_name=\"served-model-metrics\",engine=\"0\",finished_reason=\"stop\""), + ), + 1.0 + ); + engine_task.await.expect("mock engine task"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn wrong_model_returns_not_found() { + let mut app = test_app().await; + let response = app + .call( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") .body(Body::from( json!({ "model": "wrong-model", @@ -1594,6 +2111,42 @@ async fn invalid_request_returns_openai_error() { assert_eq!(json["error"]["type"], "invalid_request_error"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn chat_completions_empty_allowed_token_ids_returns_openai_error() { + let mut app = test_app().await; + let response = app + .call( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "stream": false, + "messages": [{"role": "user", "content": "hello"}], + "allowed_token_ids": [] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert!( + json["error"]["message"] + .as_str() + .expect("message string") + .contains("allowed_token_ids should not be empty") + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn non_stream_chat_returns_json_response() { @@ -1638,6 +2191,28 @@ async fn non_stream_chat_returns_json_response() { assert_eq!(json["usage"]["prompt_tokens"], 22); assert_eq!(json["usage"]["completion_tokens"], 3); assert_eq!(json["usage"]["total_tokens"], 25); + + // Unset optional fields are serialized as explicit `null` on + // non-streaming responses... + let response_object = json.as_object().expect("response object"); + let choice = json["choices"][0].as_object().expect("choice object"); + let message = choice["message"].as_object().expect("message object"); + for (object, key) in [ + (response_object, "system_fingerprint"), + (response_object, "prompt_token_ids"), + (response_object, "kv_transfer_params"), + (choice, "logprobs"), + (choice, "stop_reason"), + (choice, "token_ids"), + (message, "reasoning"), + ] { + assert!( + object.contains_key(key) && object[key].is_null(), + "expected explicit null `{key}`: {json}" + ); + } + // ...except `tool_calls`, which Python pops from the payload when empty. + assert!(!message.contains_key("tool_calls"), "{json}"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1725,8 +2300,7 @@ async fn non_stream_chat_includes_logprobs_and_prompt_logprobs() { send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output_with_logprobs( &request.request_id, bytes_to_token_ids(b"hi"), @@ -1735,13 +2309,9 @@ async fn non_stream_chat_includes_logprobs_and_prompt_logprobs() { Some(sample_logprobs_for_tokens(&bytes_to_token_ids(b"hi"))), Some(prompt_logprobs_for_tokens(&prompt_token_ids)), )], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; }) @@ -2075,8 +2645,8 @@ async fn load_endpoint_resets_when_stream_response_is_dropped() { #[serial] async fn stream_error_is_returned_as_openai_error_sse() { let (app, engine_task) = test_app_with_backend_and_stream_output_specs( - Arc::new(FailingDecodeChatBackend), - default_stream_output_specs(), + Arc::new(FakeChatBackend::new()), + vec![(vec![UNKNOWN_DECODE_TOKEN_ID], None)], ) .await; let response = app @@ -2109,7 +2679,9 @@ async fn stream_error_is_returned_as_openai_error_sse() { assert!(text.contains("\"role\":\"assistant\""), "{text}"); assert!(text.contains("\"type\":\"server_error\""), "{text}"); assert!( - text.contains("forced decode failure for streaming test"), + text.contains(&format!( + "test tokenizer cannot decode unknown token id {UNKNOWN_DECODE_TOKEN_ID}" + )), "{text}" ); assert!(!text.contains("\"usage\":"), "{text}"); @@ -2210,6 +2782,60 @@ async fn include_usage_adds_final_usage_chunk_before_done() { assert_eq!(usage_chunk["usage"]["total_tokens"], 25); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn stream_continuous_usage_stats_adds_usage_to_chat_chunks() { + let (app, engine_task) = test_app_with_stream_output_specs(default_stream_output_specs()).await; + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "stream": true, + "stream_options": { + "include_usage": true, + "continuous_usage_stats": true + }, + "messages": [{"role": "user", "content": "hello"}] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + let payloads = sse_json_payloads(&text); + + assert!( + payloads.iter().all(|payload| payload.get("usage").is_some()), + "{text}" + ); + assert!( + payloads.iter().any(|payload| { + payload["choices"].as_array().is_some_and(|choices| !choices.is_empty()) + && payload["usage"]["completion_tokens"] == json!(1) + }), + "{text}" + ); + let usage_chunk = payloads + .iter() + .find(|payload| payload["choices"] == json!([])) + .expect("final usage chunk"); + assert_eq!(usage_chunk["usage"]["prompt_tokens"], 22); + assert_eq!(usage_chunk["usage"]["completion_tokens"], 3); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn stream_without_include_usage_keeps_existing_shape() { @@ -2275,6 +2901,42 @@ async fn completions_invalid_request_returns_openai_error() { assert_eq!(json["error"]["type"], "invalid_request_error"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn completions_empty_allowed_token_ids_returns_openai_error() { + let mut app = test_app().await; + let response = app + .call( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": false, + "allowed_token_ids": [] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert!( + json["error"]["message"] + .as_str() + .expect("message string") + .contains("allowed_token_ids should not be empty") + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn non_stream_completions_return_json_response() { @@ -2316,6 +2978,25 @@ async fn non_stream_completions_return_json_response() { assert_eq!(json["choices"][0]["text"], "hi"); assert_eq!(json["choices"][0]["finish_reason"], "stop"); assert_eq!(json["usage"]["completion_tokens"], 3); + + // Unset optional fields are serialized as explicit `null` on + // non-streaming responses. + let response_object = json.as_object().expect("response object"); + let choice = json["choices"][0].as_object().expect("choice object"); + for (object, key) in [ + (response_object, "system_fingerprint"), + (response_object, "kv_transfer_params"), + (choice, "logprobs"), + (choice, "stop_reason"), + (choice, "prompt_logprobs"), + (choice, "token_ids"), + (choice, "prompt_token_ids"), + ] { + assert!( + object.contains_key(key) && object[key].is_null(), + "expected explicit null `{key}`: {json}" + ); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2334,7 +3015,8 @@ async fn non_stream_completions_echo_prepends_prompt_text() { "model": "Qwen/Qwen1.5-0.5B-Chat", "prompt": "hello", "echo": true, - "stream": false + "stream": false, + "add_special_tokens": false }) .to_string(), )) @@ -2356,70 +3038,167 @@ async fn non_stream_completions_echo_prepends_prompt_text() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn non_stream_completions_include_logprobs() { - let ipc = IpcNamespace::new().expect("create ipc namespace"); - let handshake_address = ipc.handshake_endpoint(); - let engine_id = b"engine-openai-completion-logprobs".to_vec(); - - let engine_task = MockEngineTask::new(spawn_mock_engine_task( - handshake_address.clone(), - engine_id.clone(), - |dealer, push| { - boxed_test_future(async move { - let add = recv_engine_message(dealer).await; - let request: EngineCoreRequest = - rmp_serde::from_slice(&add[1]).expect("decode request"); - send_outputs( - push, - EngineCoreOutputs { - engine_index: 0, - outputs: vec![ - request_output_with_logprobs( - &request.request_id, - vec![b'h' as u32], - None, - None, - Some(sample_logprobs_for_token(b'h' as u32, b'H' as u32)), - None, - ), - request_output_with_logprobs( - &request.request_id, - vec![b'i' as u32], - Some(EngineCoreFinishReason::Stop), - None, - Some(sample_logprobs_for_token(b'i' as u32, b'I' as u32)), - None, - ), - ], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: Some(BTreeSet::from([request.request_id.clone()])), - wave_complete: None, - start_wave: None, - }, - ) - .await; - }) +async fn non_stream_completions_echo_decodes_token_id_prompt_text() { + let prompt_token_ids = bytes_to_token_ids(b"hello"); + let expected_prompt_token_ids = prompt_token_ids.clone(); + let (app, engine_task) = test_app_with_backend_and_engine_request_check( + Arc::new(FakeChatBackend::new()), + move |request| { + assert_eq!( + request.prompt_token_ids.as_deref(), + Some(expected_prompt_token_ids.as_slice()) + ); }, - )); - - let client = EngineCoreClient::connect( - EngineCoreClientConfig::new_single(handshake_address) - .with_model_name("test-model") - .with_local_input_output_addresses( - Some(ipc.input_endpoint()), - Some(ipc.output_endpoint()), - ), ) - .await - .expect("connect client"); - let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new())); - let mut app = build_router(Arc::new(AppState::new( - vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], - chat, - ))); - + .await; + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": prompt_token_ids, + "echo": true, + "stream": false + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + assert_eq!(json["choices"][0]["text"], "hellohi"); + assert_eq!(json["usage"]["prompt_tokens"], 5); + assert_eq!(json["usage"]["completion_tokens"], 3); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn non_stream_completions_token_id_echo_return_token_ids_keeps_prompt_ids_separate() { + let prompt_token_ids = bytes_to_token_ids(b"hello"); + let expected_prompt_token_ids = prompt_token_ids.clone(); + let (app, engine_task) = test_app_with_backend_and_engine_request_check( + Arc::new(FakeChatBackend::new()), + move |request| { + assert_eq!( + request.prompt_token_ids.as_deref(), + Some(expected_prompt_token_ids.as_slice()) + ); + }, + ) + .await; + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": prompt_token_ids, + "echo": true, + "return_token_ids": true, + "stream": false + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + assert_eq!(json["choices"][0]["text"], "hi"); + assert_eq!( + json["choices"][0]["prompt_token_ids"], + json!(bytes_to_token_ids(b"hello")) + ); + assert_eq!( + json["choices"][0]["token_ids"], + json!(bytes_to_token_ids(b"hi!")) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn non_stream_completions_include_logprobs() { + let ipc = IpcNamespace::new().expect("create ipc namespace"); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-openai-completion-logprobs".to_vec(); + + let engine_task = MockEngineTask::new(spawn_mock_engine_task( + handshake_address.clone(), + engine_id.clone(), + |dealer, push| { + boxed_test_future(async move { + let add = recv_engine_message(dealer).await; + let request: EngineCoreRequest = + rmp_serde::from_slice(&add[1]).expect("decode request"); + send_outputs( + push, + RequestBatchOutputs { + outputs: vec![ + request_output_with_logprobs( + &request.request_id, + vec![b'h' as u32], + None, + None, + Some(sample_logprobs_for_token(b'h' as u32, b'H' as u32)), + None, + ), + request_output_with_logprobs( + &request.request_id, + vec![b'i' as u32], + Some(EngineCoreFinishReason::Stop), + None, + Some(sample_logprobs_for_token(b'i' as u32, b'I' as u32)), + None, + ), + ], + finished_requests: Some(BTreeSet::from([request.request_id.clone()])), + ..Default::default() + } + .into(), + ) + .await; + }) + }, + )); + + let client = EngineCoreClient::connect( + EngineCoreClientConfig::new_single(handshake_address) + .with_model_name("test-model") + .with_local_input_output_addresses( + Some(ipc.input_endpoint()), + Some(ipc.output_endpoint()), + ), + ) + .await + .expect("connect client"); + let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new())); + let mut app = build_router(Arc::new(AppState::new( + vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], + chat, + ))); + let response = app .call( Request::builder() @@ -2474,8 +3253,7 @@ async fn non_stream_completions_include_prompt_logprobs() { rmp_serde::from_slice(&add[1]).expect("decode request"); send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output_with_logprobs( &request.request_id, vec![b'h' as u32, b'i' as u32, b'!' as u32], @@ -2496,13 +3274,9 @@ async fn non_stream_completions_include_prompt_logprobs() { }), Some(prompt_logprobs_for_hello()), )], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; }) @@ -2537,7 +3311,8 @@ async fn non_stream_completions_include_prompt_logprobs() { "prompt": "hello", "stream": false, "echo": true, - "logprobs": 1 + "logprobs": 1, + "add_special_tokens": false }) .to_string(), )) @@ -2637,6 +3412,46 @@ async fn non_stream_chat_completions_still_succeed() { engine_task.await.expect("mock engine task"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn chat_completions_accepts_request_body_larger_than_axum_default() { + let (chat, engine_task) = test_chat_with_engine_outputs( + b"engine-openai-chat-large-body", + default_stream_output_specs(), + ) + .await; + let mut app = build_router(Arc::new(AppState::new( + vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], + chat, + ))); + + let large_template_arg = "a".repeat(2 * 1024 * 1024); + let response = app + .call( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "stream": false, + "messages": [{"role": "user", "content": "hello"}], + "chat_template_kwargs": {"large": large_template_arg} + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + engine_task.await.expect("mock engine task"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn non_stream_completions_still_succeed() { @@ -2797,8 +3612,7 @@ async fn non_stream_raw_generate_returns_token_output_envelope() { send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![ request_output_with_logprobs( &request.request_id, @@ -2818,13 +3632,9 @@ async fn non_stream_raw_generate_returns_token_output_envelope() { Some(json!({"connector": "x"})), ), ], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; }) @@ -2917,8 +3727,7 @@ async fn stream_raw_generate_returns_sse_chunks_and_usage() { send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![ request_output_with_logprobs( &request.request_id, @@ -2937,13 +3746,9 @@ async fn stream_raw_generate_returns_sse_chunks_and_usage() { None, ), ], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; }) @@ -3216,6 +4021,45 @@ async fn raw_generate_rejects_empty_token_ids() { assert_eq!(json["error"]["param"], "token_ids"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn raw_generate_rejects_streaming_prompt_logprobs() { + let mut app = test_app().await; + + for prompt_logprobs in [0, 1] { + let response = app + .call( + Request::builder() + .method("POST") + .uri("/inference/v1/generate") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "token_ids": [11, 22], + "stream": true, + "sampling_params": { + "prompt_logprobs": prompt_logprobs + } + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + assert_eq!(json["error"]["param"], "sampling_params"); + assert_eq!( + json["error"]["message"], + "`prompt_logprobs` are not available when `stream=true`." + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn raw_generate_rejects_wrong_model() { @@ -3301,6 +4145,59 @@ async fn completions_happy_path_returns_sse_stream() { assert_eq!(usage_chunk["usage"]["completion_tokens"], 3); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn completions_stream_continuous_usage_stats_adds_usage_to_chunks() { + let (app, engine_task) = test_app_with_stream_output_specs(default_stream_output_specs()).await; + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": true, + "stream_options": { + "include_usage": true, + "continuous_usage_stats": true + } + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + let payloads = sse_json_payloads(&text); + + assert!( + payloads.iter().all(|payload| payload.get("usage").is_some()), + "{text}" + ); + assert!( + payloads.iter().any(|payload| { + payload["choices"].as_array().is_some_and(|choices| !choices.is_empty()) + && payload["usage"]["completion_tokens"] == json!(1) + }), + "{text}" + ); + let usage_chunk = payloads + .iter() + .find(|payload| payload["choices"] == json!([])) + .expect("final usage chunk"); + assert_eq!(usage_chunk["usage"]["completion_tokens"], 3); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn completions_echo_stream_emits_separate_prompt_chunk() { @@ -3318,7 +4215,8 @@ async fn completions_echo_stream_emits_separate_prompt_chunk() { "prompt": "hello", "echo": true, "stream": true, - "stream_options": {"include_usage": true} + "stream_options": {"include_usage": true}, + "add_special_tokens": false }) .to_string(), )) @@ -3361,88 +4259,67 @@ async fn completions_echo_stream_emits_separate_prompt_chunk() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn chat_harness_streams_text_events() { - let (chat, engine_task) = test_chat_with_engine_handle().await; - let mut stream = chat - .chat(ChatRequest { - messages: vec![ChatMessage::text(ChatRole::User, "hello")], - sampling_params: SamplingParams { - max_tokens: Some(8), - ..Default::default() - }, - request_id: "chat-harness".to_string(), - ..ChatRequest::for_test() - }) - .await - .expect("submit chat request"); - - let mut saw_text = false; - let mut saw_done = false; - while let Some(event) = stream.next().await { - match event.expect("chat event") { - ChatEvent::BlockDelta { .. } => saw_text = true, - ChatEvent::Done { .. } => { - saw_done = true; - break; - } - ChatEvent::Start { .. } - | ChatEvent::LogprobsDelta { .. } - | ChatEvent::BlockStart { .. } - | ChatEvent::BlockEnd { .. } - | ChatEvent::ToolCallStart { .. } - | ChatEvent::ToolCallArgumentsDelta { .. } - | ChatEvent::ToolCallEnd { .. } => {} - } - } - engine_task.await.expect("mock engine task"); - - assert!(saw_text); - assert!(saw_done); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[serial] -async fn prepared_openai_request_streams_text_events() { - let (chat, engine_task) = test_chat_with_engine_handle().await; - let prepared = prepare_chat_request( - serde_json::from_value(json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "stream": true, - "messages": [{"role": "user", "content": "hello"}] - })) - .expect("decode request"), - &LoraModelResolution { - model_names: vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], - lora_request: None, +async fn completions_echo_stream_decodes_token_id_prompt_chunk() { + let prompt_token_ids = bytes_to_token_ids(b"hello"); + let expected_prompt_token_ids = prompt_token_ids.clone(); + let (app, engine_task) = test_app_with_backend_and_engine_request_check( + Arc::new(FakeChatBackend::new()), + move |request| { + assert_eq!( + request.prompt_token_ids.as_deref(), + Some(expected_prompt_token_ids.as_slice()) + ); }, - crate::utils::ResolvedRequestContext::default(), ) - .expect("prepare request"); - - let mut stream = chat.chat(prepared.chat_request).await.expect("submit chat request"); - - let mut saw_text = false; - let mut saw_done = false; - while let Some(event) = stream.next().await { - match event.expect("chat event") { - ChatEvent::BlockDelta { .. } => saw_text = true, - ChatEvent::Done { .. } => { - saw_done = true; - break; - } - ChatEvent::Start { .. } - | ChatEvent::LogprobsDelta { .. } - | ChatEvent::BlockStart { .. } - | ChatEvent::BlockEnd { .. } - | ChatEvent::ToolCallStart { .. } - | ChatEvent::ToolCallArgumentsDelta { .. } - | ChatEvent::ToolCallEnd { .. } => {} - } - } - engine_task.await.expect("mock engine task"); - - assert!(saw_text); - assert!(saw_done); + .await; + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": prompt_token_ids, + "echo": true, + "stream": true, + "stream_options": {"include_usage": true} + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + let payloads = sse_data_payloads(&text); + let hello_index = payloads + .iter() + .position(|payload| payload.contains("\"text\":\"hello\"")) + .expect("prompt echo chunk"); + let h_index = payloads + .iter() + .position(|payload| payload.contains("\"text\":\"h\"")) + .expect("first generation chunk"); + + assert!(hello_index < h_index, "{text}"); + + let usage_chunk: serde_json::Value = serde_json::from_str( + payloads + .iter() + .find(|payload| payload.contains("\"usage\":")) + .expect("usage chunk"), + ) + .expect("usage chunk json"); + assert_eq!(usage_chunk["usage"]["prompt_tokens"], 5); + assert_eq!(usage_chunk["usage"]["completion_tokens"], 3); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -3492,6 +4369,165 @@ async fn reasoning_blocks_are_mapped_to_reasoning_sse_chunks() { assert!(text.contains("\"content\":\"answer\""), "{text}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn include_reasoning_false_suppresses_reasoning_in_non_stream_chat() { + let (app, engine_task) = test_app_with_backend_and_stream_output_specs( + Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B")), + vec![ + (bytes_to_token_ids(b"think"), None), + ( + bytes_to_token_ids(b"answer"), + Some(EngineCoreFinishReason::Length), + ), + ], + ) + .await; + + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "stream": false, + "include_reasoning": false, + "messages": [{"role": "user", "content": "hello"}] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + let json: serde_json::Value = serde_json::from_str(&text).expect("decode json"); + + assert_eq!(json["choices"][0]["message"]["content"], "answer"); + // Suppressed fields are serialized as explicit `null` on non-streaming + // responses. + assert!( + json["choices"][0]["message"]["reasoning"].is_null(), + "{text}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn include_reasoning_false_suppresses_non_stream_output_metadata() { + let ipc = IpcNamespace::new().expect("create ipc namespace"); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-openai-hidden-reasoning-logprobs".to_vec(); + + let engine_task = MockEngineTask::new(spawn_mock_engine_task( + handshake_address.clone(), + engine_id.clone(), + |dealer, push| { + boxed_test_future(async move { + let add = recv_engine_message(dealer).await; + let request: EngineCoreRequest = + rmp_serde::from_slice(&add[1]).expect("decode request"); + let reasoning_token_ids = bytes_to_token_ids(b"think"); + let answer_token_ids = bytes_to_token_ids(b"answer"); + + send_outputs( + push, + RequestBatchOutputs { + outputs: vec![ + request_output_with_logprobs( + &request.request_id, + reasoning_token_ids.clone(), + None, + None, + Some(sample_logprobs_for_tokens(&reasoning_token_ids)), + None, + ), + request_output_with_logprobs( + &request.request_id, + answer_token_ids.clone(), + Some(EngineCoreFinishReason::Length), + None, + Some(sample_logprobs_for_tokens(&answer_token_ids)), + None, + ), + ], + ..Default::default() + } + .into(), + ) + .await; + }) + }, + )); + + let client = EngineCoreClient::connect( + EngineCoreClientConfig::new_single(handshake_address) + .with_model_name("test-model") + .with_local_input_output_addresses( + Some(ipc.input_endpoint()), + Some(ipc.output_endpoint()), + ), + ) + .await + .expect("connect client"); + let chat = ChatLlm::from_shared_backend( + test_llm(client), + Arc::new(FakeChatBackend::with_model_id("Qwen/Qwen3-0.6B")), + ); + let mut app = build_router(Arc::new(AppState::new( + vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], + chat, + ))); + + let response = app + .call( + Request::builder() + .method("POST") + .uri("/v1/chat/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "stream": false, + "include_reasoning": false, + "logprobs": true, + "return_token_ids": true, + "messages": [{"role": "user", "content": "hello"}] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + let json: serde_json::Value = serde_json::from_str(&text).expect("decode json"); + let choice = json["choices"][0].as_object().expect("choice object"); + + assert_eq!(json["choices"][0]["message"]["content"], "answer"); + // Suppressed fields are serialized as explicit `null` on non-streaming + // responses. + assert!( + json["choices"][0]["message"]["reasoning"].is_null(), + "{text}" + ); + assert!(choice["logprobs"].is_null(), "{text}"); + assert!(choice["token_ids"].is_null(), "{text}"); + assert!(json["prompt_token_ids"].is_array(), "{text}"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] async fn tool_calls_are_mapped_to_tool_call_sse_chunks() { @@ -3574,8 +4610,7 @@ async fn tool_call_sse_chunks_can_carry_logprobs() { send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output_with_logprobs( &request.request_id, bytes_to_token_ids(b"Need tool."), @@ -3586,19 +4621,14 @@ async fn tool_call_sse_chunks_can_carry_logprobs() { ))), None, )], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output_with_logprobs( &request.request_id, bytes_to_token_ids(b"\n{\"name\":\"get_weather\", "), @@ -3609,19 +4639,14 @@ async fn tool_call_sse_chunks_can_carry_logprobs() { ))), None, )], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, - finished_requests: None, - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; send_outputs( push, - EngineCoreOutputs { - engine_index: 0, + RequestBatchOutputs { outputs: vec![request_output_with_logprobs( &request.request_id, bytes_to_token_ids( @@ -3634,13 +4659,10 @@ async fn tool_call_sse_chunks_can_carry_logprobs() { ))), None, )], - scheduler_stats: None, - timestamp: 0.0, - utility_output: None, finished_requests: Some(BTreeSet::from([request.request_id.clone()])), - wave_complete: None, - start_wave: None, - }, + ..Default::default() + } + .into(), ) .await; }) @@ -3774,7 +4796,10 @@ async fn reset_prefix_cache_route_sends_expected_utility_call() { let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); - assert!(body.is_empty()); + assert_eq!( + serde_json::from_slice::(&body).expect("json body"), + json!({"success": true}) + ); engine_task.await.expect("mock engine task"); } @@ -4066,371 +5091,1236 @@ async fn is_sleeping_route_returns_json_payload() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn admin_routes_are_hidden_when_dev_mode_is_disabled() { - let (chat, engine_task) = test_chat_with_engine_handle().await; - let app = build_router_with_dev_mode( - Arc::new(AppState::new( - vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], - chat, - )), - false, - ); - - for (method, uri) in [ - ("GET", "/is_sleeping"), - ("POST", "/sleep"), - ("POST", "/wake_up"), - ("POST", "/collective_rpc"), - ("POST", "/reset_prefix_cache"), - ("POST", "/reset_mm_cache"), - ("POST", "/reset_encoder_cache"), - ] { - let response = app - .clone() - .call( - Request::builder() - .method(method) - .uri(uri) - .body(Body::empty()) - .expect("build request"), - ) - .await - .expect("call app"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {uri}"); - } +async fn pause_route_uses_python_compatible_default_query_values() { + let (app, engine_task) = test_admin_app_with_engine_script(|dealer, push| { + boxed_test_future(async move { + let utility = recv_engine_message(dealer).await; + assert_eq!(utility[0].as_ref(), &[0x03]); - engine_task.abort_and_join().await; -} + let payload = decode_value(&utility[1]).expect("decode utility payload"); + let array = payload.as_array().expect("utility payload array"); + let call_id = array[1].as_u64().expect("call id"); -// ========================= Stop string tests ========================= + assert_eq!(array[2], Value::from("pause_scheduler")); + assert_eq!( + array[3], + Value::Array(vec![Value::from("abort"), Value::from(true)]) + ); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[serial] -async fn non_stream_completions_stop_string_excluded_from_output() { - // Engine generates "say world" but stop string "wor" truncates output to "say - // ". - let output_specs = vec![ - (bytes_to_token_ids(b"say"), None), - ( - bytes_to_token_ids(b" world"), - Some(EngineCoreFinishReason::Length), - ), - ]; - let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; + send_outputs(push, utility_outputs(call_id, utility_none_result())).await; + }) + }) + .await; let response = app .clone() - .oneshot( + .call( Request::builder() .method("POST") - .uri("/v1/completions") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": "hello", - "stream": false, - "stop": ["wor"] - }) - .to_string(), - )) + .uri("/pause") + .body(Body::empty()) .expect("build request"), ) .await .expect("call app"); assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); engine_task.await.expect("mock engine task"); - let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); - assert_eq!(json["choices"][0]["text"], "say "); - assert_eq!(json["choices"][0]["finish_reason"], "stop"); - assert_eq!(json["choices"][0]["stop_reason"], "wor"); + assert_eq!( + serde_json::from_slice::(&body).expect("decode json"), + json!({ "status": "paused" }) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn non_stream_completions_stop_string_included_in_output() { - // Same tokens but include_stop_str_in_output=true includes the stop string in - // the output. - let output_specs = vec![ - (bytes_to_token_ids(b"say"), None), - ( - bytes_to_token_ids(b" world"), - Some(EngineCoreFinishReason::Length), - ), - ]; - let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; +async fn pause_route_rejects_invalid_mode() { + let (app, engine_task) = + test_admin_app_with_engine_script(|_dealer, _push| boxed_test_future(async move {})).await; let response = app .clone() - .oneshot( + .call( Request::builder() .method("POST") - .uri("/v1/completions") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": "hello", - "stream": false, - "stop": ["wor"], - "include_stop_str_in_output": true - }) - .to_string(), - )) + .uri("/pause?mode=banana") + .body(Body::empty()) .expect("build request"), ) .await .expect("call app"); - assert_eq!(response.status(), StatusCode::OK); - + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); - engine_task.await.expect("mock engine task"); let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); - - assert_eq!(json["choices"][0]["text"], "say wor"); - assert_eq!(json["choices"][0]["finish_reason"], "stop"); - assert_eq!(json["choices"][0]["stop_reason"], "wor"); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert_eq!(json["error"]["param"], "mode"); + engine_task.abort_and_join().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn stream_completions_stop_string_excluded_from_output() { - let output_specs = vec![ - (bytes_to_token_ids(b"say"), None), - ( - bytes_to_token_ids(b" world"), - Some(EngineCoreFinishReason::Length), - ), - ]; - let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; +async fn resume_route_sends_no_args() { + let (app, engine_task) = test_admin_app_with_engine_script(|dealer, push| { + boxed_test_future(async move { + let utility = recv_engine_message(dealer).await; + assert_eq!(utility[0].as_ref(), &[0x03]); + + let payload = decode_value(&utility[1]).expect("decode utility payload"); + let array = payload.as_array().expect("utility payload array"); + let call_id = array[1].as_u64().expect("call id"); + + assert_eq!(array[2], Value::from("resume_scheduler")); + assert_eq!(array[3], Value::Array(Vec::new())); + + send_outputs(push, utility_outputs(call_id, utility_none_result())).await; + }) + }) + .await; let response = app .clone() - .oneshot( + .call( Request::builder() .method("POST") - .uri("/v1/completions") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": "hello", - "stream": true, - "stop": ["wor"] - }) - .to_string(), - )) + .uri("/resume") + .body(Body::empty()) .expect("build request"), ) .await .expect("call app"); assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); engine_task.await.expect("mock engine task"); - let text = String::from_utf8(body.to_vec()).expect("utf8 body"); - let payloads = sse_data_payloads(&text); - - // Collect all text deltas from the SSE chunks. - let mut full_text = String::new(); - for payload in &payloads { - if *payload == "[DONE]" { - continue; - } - let chunk: serde_json::Value = serde_json::from_str(payload).expect("json chunk"); - if let Some(text) = chunk["choices"][0]["text"].as_str() { - full_text.push_str(text); - } - } - // The concatenated text deltas should equal "say " (stop string excluded). - assert_eq!(full_text, "say ", "full streamed text: {text}"); - - // The final chunk should have finish_reason "stop". - assert!( - payloads.iter().any(|p| p.contains("\"finish_reason\":\"stop\"")), - "{text}" + assert_eq!( + serde_json::from_slice::(&body).expect("decode json"), + json!({ "status": "resumed" }) ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn stream_completions_stop_string_included_in_output() { - let output_specs = vec![ - (bytes_to_token_ids(b"say"), None), - ( - bytes_to_token_ids(b" world"), - Some(EngineCoreFinishReason::Length), - ), - ]; - let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; +async fn is_paused_route_returns_json_payload() { + let (app, engine_task) = test_admin_app_with_engine_script(|dealer, push| { + boxed_test_future(async move { + let utility = recv_engine_message(dealer).await; + assert_eq!(utility[0].as_ref(), &[0x03]); + + let payload = decode_value(&utility[1]).expect("decode utility payload"); + let array = payload.as_array().expect("utility payload array"); + let call_id = array[1].as_u64().expect("call id"); + + assert_eq!(array[2], Value::from("is_scheduler_paused")); + assert_eq!(array[3], Value::Array(Vec::new())); + + send_outputs(push, utility_outputs(call_id, utility_result_value(true))).await; + }) + }) + .await; let response = app .clone() - .oneshot( + .call( Request::builder() - .method("POST") - .uri("/v1/completions") - .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": "hello", - "stream": true, - "stop": ["wor"], - "include_stop_str_in_output": true - }) - .to_string(), - )) + .method("GET") + .uri("/is_paused") + .body(Body::empty()) .expect("build request"), ) .await .expect("call app"); assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); engine_task.await.expect("mock engine task"); - let text = String::from_utf8(body.to_vec()).expect("utf8 body"); - let payloads = sse_data_payloads(&text); - - let mut full_text = String::new(); - for payload in &payloads { - if *payload == "[DONE]" { - continue; - } - let chunk: serde_json::Value = serde_json::from_str(payload).expect("json chunk"); - if let Some(text) = chunk["choices"][0]["text"].as_str() { - full_text.push_str(text); - } - } - // With include_stop_str_in_output, the stop string "wor" should be included. - assert_eq!(full_text, "say wor", "full streamed text: {text}"); - - assert!( - payloads.iter().any(|p| p.contains("\"finish_reason\":\"stop\"")), - "{text}" + assert_eq!( + serde_json::from_slice::(&body).expect("decode json"), + json!({ "is_paused": true }) ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn non_stream_completions_no_stop_string_match_preserves_original_finish_reason() { - // Stop string "xyz" does not appear in "hi!" so the original finish reason is - // preserved. - let (app, engine_task) = test_app_with_engine_handle().await; +async fn abort_requests_route_returns_ok_for_well_formed_body() { + let (app, engine_task) = + test_admin_app_with_engine_script(|_dealer, _push| boxed_test_future(async move {})).await; let response = app .clone() - .oneshot( + .call( Request::builder() .method("POST") - .uri("/v1/completions") + .uri("/abort_requests") .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": "hello", - "stream": false, - "stop": ["xyz"] - }) - .to_string(), - )) + .body(Body::from(r#"{"request_ids":["req-1","req-2"]}"#)) .expect("build request"), ) .await .expect("call app"); - assert_eq!(response.status(), StatusCode::OK); - + let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); - engine_task.await.expect("mock engine task"); - let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); - - // Default output is "hi" (stop token '!' suppressed), finish_reason remains - // "stop" from EOS. - assert_eq!(json["choices"][0]["text"], "hi"); - assert_eq!(json["choices"][0]["finish_reason"], "stop"); - // No text stop string matched — stop_reason should be absent. - assert!(json["choices"][0]["stop_reason"].is_null()); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + assert!(body.is_empty()); + engine_task.abort_and_join().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn non_stream_completions_stop_string_array_matches_first_occurrence() { - // Multiple stop strings: "rl" appears in "world" but " wo" appears earlier. - let output_specs = vec![( - bytes_to_token_ids(b"say world"), - Some(EngineCoreFinishReason::Length), - )]; - let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; +async fn abort_requests_route_rejects_missing_request_ids() { + let (app, engine_task) = + test_admin_app_with_engine_script(|_dealer, _push| boxed_test_future(async move {})).await; let response = app .clone() - .oneshot( + .call( Request::builder() .method("POST") - .uri("/v1/completions") + .uri("/abort_requests") .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": "hello", - "stream": false, - "stop": [" wo", "rl"] - }) - .to_string(), - )) + .body(Body::from(r#"{}"#)) .expect("build request"), ) .await .expect("call app"); - assert_eq!(response.status(), StatusCode::OK); - + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); - engine_task.await.expect("mock engine task"); let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); - - // " wo" is detected first (at byte 3), so output is truncated to "say". - assert_eq!(json["choices"][0]["text"], "say"); - assert_eq!(json["choices"][0]["finish_reason"], "stop"); - assert_eq!(json["choices"][0]["stop_reason"], " wo"); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert_eq!(json["error"]["param"], "request_ids"); + engine_task.abort_and_join().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial] -async fn completions_empty_stop_string_returns_validation_error() { - let (app, _engine_task) = test_app_with_engine_handle().await; +async fn abort_requests_route_rejects_malformed_json() { + let (app, engine_task) = + test_admin_app_with_engine_script(|_dealer, _push| boxed_test_future(async move {})).await; let response = app .clone() - .oneshot( + .call( Request::builder() .method("POST") - .uri("/v1/completions") + .uri("/abort_requests") .header("content-type", "application/json") - .body(Body::from( - json!({ - "model": "Qwen/Qwen1.5-0.5B-Chat", - "prompt": "hello", - "stream": false, - "stop": [""] - }) - .to_string(), - )) + .body(Body::from(r#"{"request_ids": "#)) .expect("build request"), ) .await .expect("call app"); assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + assert_eq!(json["error"]["type"], "invalid_request_error"); + engine_task.abort_and_join().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn abort_requests_route_accepts_empty_id_list() { + let (app, engine_task) = + test_admin_app_with_engine_script(|_dealer, _push| boxed_test_future(async move {})).await; + + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/abort_requests") + .header("content-type", "application/json") + .body(Body::from(r#"{"request_ids":[]}"#)) + .expect("build request"), + ) + .await + .expect("call app"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + assert!(body.is_empty()); + engine_task.abort_and_join().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn admin_routes_are_hidden_when_dev_mode_is_disabled() { + let (chat, engine_task) = test_chat_with_engine_handle().await; + let app = build_router_with_dev_mode( + Arc::new(AppState::new( + vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()], + chat, + )), + false, + ); + + for (method, uri) in [ + ("GET", "/is_sleeping"), + ("POST", "/sleep"), + ("POST", "/wake_up"), + ("GET", "/is_paused"), + ("POST", "/pause"), + ("POST", "/resume"), + ("POST", "/collective_rpc"), + ("POST", "/abort_requests"), + ("POST", "/reset_prefix_cache"), + ("POST", "/reset_mm_cache"), + ("POST", "/reset_encoder_cache"), + ] { + let response = app + .clone() + .call( + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {uri}"); + } + + engine_task.abort_and_join().await; +} + +// ========================= Stop string tests ========================= + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn non_stream_completions_stop_string_excluded_from_output() { + // Engine generates "say world" but stop string "wor" truncates output to "say + // ". + let output_specs = vec![ + (bytes_to_token_ids(b"say"), None), + ( + bytes_to_token_ids(b" world"), + Some(EngineCoreFinishReason::Length), + ), + ]; + let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": false, + "stop": ["wor"] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + assert_eq!(json["choices"][0]["text"], "say "); + assert_eq!(json["choices"][0]["finish_reason"], "stop"); + assert_eq!(json["choices"][0]["stop_reason"], "wor"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn non_stream_completions_stop_string_included_in_output() { + // Same tokens but include_stop_str_in_output=true includes the stop string in + // the output. + let output_specs = vec![ + (bytes_to_token_ids(b"say"), None), + ( + bytes_to_token_ids(b" world"), + Some(EngineCoreFinishReason::Length), + ), + ]; + let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": false, + "stop": ["wor"], + "include_stop_str_in_output": true + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + assert_eq!(json["choices"][0]["text"], "say wor"); + assert_eq!(json["choices"][0]["finish_reason"], "stop"); + assert_eq!(json["choices"][0]["stop_reason"], "wor"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn stream_completions_stop_string_excluded_from_output() { + let output_specs = vec![ + (bytes_to_token_ids(b"say"), None), + ( + bytes_to_token_ids(b" world"), + Some(EngineCoreFinishReason::Length), + ), + ]; + let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": true, + "stop": ["wor"] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + let payloads = sse_data_payloads(&text); + + // Collect all text deltas from the SSE chunks. + let mut full_text = String::new(); + for payload in &payloads { + if *payload == "[DONE]" { + continue; + } + let chunk: serde_json::Value = serde_json::from_str(payload).expect("json chunk"); + if let Some(text) = chunk["choices"][0]["text"].as_str() { + full_text.push_str(text); + } + } + + // The concatenated text deltas should equal "say " (stop string excluded). + assert_eq!(full_text, "say ", "full streamed text: {text}"); + + // The final chunk should have finish_reason "stop". + assert!( + payloads.iter().any(|p| p.contains("\"finish_reason\":\"stop\"")), + "{text}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn stream_completions_stop_string_included_in_output() { + let output_specs = vec![ + (bytes_to_token_ids(b"say"), None), + ( + bytes_to_token_ids(b" world"), + Some(EngineCoreFinishReason::Length), + ), + ]; + let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": true, + "stop": ["wor"], + "include_stop_str_in_output": true + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let text = String::from_utf8(body.to_vec()).expect("utf8 body"); + let payloads = sse_data_payloads(&text); + + let mut full_text = String::new(); + for payload in &payloads { + if *payload == "[DONE]" { + continue; + } + let chunk: serde_json::Value = serde_json::from_str(payload).expect("json chunk"); + if let Some(text) = chunk["choices"][0]["text"].as_str() { + full_text.push_str(text); + } + } + + // With include_stop_str_in_output, the stop string "wor" should be included. + assert_eq!(full_text, "say wor", "full streamed text: {text}"); + + assert!( + payloads.iter().any(|p| p.contains("\"finish_reason\":\"stop\"")), + "{text}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn non_stream_completions_no_stop_string_match_preserves_original_finish_reason() { + // Stop string "xyz" does not appear in "hi!" so the original finish reason is + // preserved. + let (app, engine_task) = test_app_with_engine_handle().await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": false, + "stop": ["xyz"] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + // Default output is "hi" (stop token '!' suppressed), finish_reason remains + // "stop" from EOS. + assert_eq!(json["choices"][0]["text"], "hi"); + assert_eq!(json["choices"][0]["finish_reason"], "stop"); + // No text stop string matched — stop_reason should be absent. + assert!(json["choices"][0]["stop_reason"].is_null()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn non_stream_completions_stop_string_array_matches_first_occurrence() { + // Multiple stop strings: "rl" appears in "world" but " wo" appears earlier. + let output_specs = vec![( + bytes_to_token_ids(b"say world"), + Some(EngineCoreFinishReason::Length), + )]; + let (app, engine_task) = test_app_with_stream_output_specs(output_specs).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": false, + "stop": [" wo", "rl"] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + engine_task.await.expect("mock engine task"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + + // " wo" is detected first (at byte 3), so output is truncated to "say". + assert_eq!(json["choices"][0]["text"], "say"); + assert_eq!(json["choices"][0]["finish_reason"], "stop"); + assert_eq!(json["choices"][0]["stop_reason"], " wo"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn completions_empty_stop_string_returns_validation_error() { + let (app, _engine_task) = test_app_with_engine_handle().await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/completions") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "stream": false, + "stop": [""] + }) + .to_string(), + )) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +async fn post_json( + app: &mut axum::Router, + uri: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let response = app + .call( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("build request"), + ) + .await + .expect("call app"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| json!({ "raw": String::from_utf8_lossy(&bytes) })); + (status, json) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_completion_round_trips_through_detokenize() { + let mut app = test_app().await; + let prompt = "Hello world"; + + let (_, tokenize_json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": prompt, + "add_special_tokens": false, + }), + ) + .await; + let tokens = tokenize_json["tokens"] + .as_array() + .expect("tokens array") + .iter() + .map(|v| v.as_u64().expect("token id") as u32) + .collect::>(); + + let (status, detokenize_json) = post_json( + &mut app, + "/detokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "tokens": tokens, + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(detokenize_json["prompt"], prompt); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_completion_add_special_tokens_changes_ids() { + let mut app = test_app().await; + + let (_, with_special) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hi", + "add_special_tokens": true, + }), + ) + .await; + let (_, without_special) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hi", + "add_special_tokens": false, + }), + ) + .await; + + let with_ids: Vec = with_special["tokens"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as u32) + .collect(); + let without_ids: Vec = without_special["tokens"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as u32) + .collect(); + + assert_ne!(with_ids, without_ids); + assert_eq!(with_ids.first().copied(), Some(FAKE_BOS_TOKEN_ID)); + assert_eq!(without_ids.first().copied(), Some(b'h' as u32)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_completion_return_token_strs_matches_tokens() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hi", + "add_special_tokens": false, + "return_token_strs": true, + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + let tokens = json["tokens"].as_array().expect("tokens"); + let token_strs = json["token_strs"].as_array().expect("token_strs"); + assert_eq!(tokens.len(), token_strs.len()); + assert_eq!(token_strs.len(), json["count"].as_u64().unwrap() as usize); + assert!(!token_strs[0].as_str().unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_completion_count_and_max_model_len() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "prompt": "hello", + "add_special_tokens": false, + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!( + json["count"].as_u64().unwrap() as usize, + json["tokens"].as_array().unwrap().len() + ); + assert!(json["max_model_len"].as_u64().unwrap() > 0); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_chat_includes_generation_prompt_in_token_count() { + let mut app = test_app().await; + let messages = json!([{"role": "user", "content": "hi"}]); + + let (_, with_prompt) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": messages, + "add_generation_prompt": true, + "add_special_tokens": false, + }), + ) + .await; + let (_, without_prompt) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": messages, + "add_generation_prompt": false, + "add_special_tokens": false, + }), + ) + .await; + + let with_len = with_prompt["tokens"].as_array().unwrap().len(); + let without_len = without_prompt["tokens"].as_array().unwrap().len(); + assert!(with_len > without_len); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_chat_conflicting_generation_flags_returns_400() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": [{"role": "user", "content": "hi"}], + "add_generation_prompt": true, + "continue_final_message": true, + }), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["type"], "invalid_request_error"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_chat_empty_messages_returns_400() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": [], + }), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["type"], "invalid_request_error"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_chat_empty_message_content_returns_400() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": [{"role": "user", "content": ""}], + }), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["type"], "invalid_request_error"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_unknown_model_returns_404() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "does-not-exist", + "prompt": "hello", + }), + ) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert_eq!(json["error"]["code"], "model_not_found"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn detokenize_unknown_model_returns_404() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/detokenize", + json!({ + "model": "does-not-exist", + "tokens": [72, 101, 108, 108, 111], + }), + ) + .await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(json["error"]["type"], "invalid_request_error"); + assert_eq!(json["error"]["code"], "model_not_found"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn detokenize_empty_tokens_returns_empty_prompt() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/detokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "tokens": [], + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["prompt"], ""); +} + +/// Decode an explicit token sequence — pins `/detokenize` independently of +/// `/tokenize` (the round-trip test alone would pass even if encode and decode +/// were both wrong in mirrored ways). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn detokenize_decodes_known_token_ids() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/detokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "tokens": [72, 101, 108, 108, 111], + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["prompt"], "Hello"); +} + +/// `continue_final_message` without a trailing assistant message must 400. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_chat_continue_without_assistant_returns_400() { + let mut app = test_app().await; + + let (status, json) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": [{"role": "user", "content": "hi"}], + "add_generation_prompt": false, + "continue_final_message": true, + }), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(json["error"]["type"], "invalid_request_error"); +} + +/// `continue_final_message` must not append a new generation suffix vs `add_generation_prompt`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn tokenize_chat_continue_final_vs_new_assistant_differs() { + let mut app = test_app().await; + let messages = json!([ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "partial,"} + ]); + + let (_, continue_final) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": messages, + "add_generation_prompt": false, + "continue_final_message": true, + "add_special_tokens": false, + }), + ) + .await; + let (_, new_assistant) = post_json( + &mut app, + "/tokenize", + json!({ + "model": "Qwen/Qwen1.5-0.5B-Chat", + "messages": messages, + "add_generation_prompt": true, + "continue_final_message": false, + "add_special_tokens": false, + }), + ) + .await; + + let continue_len = continue_final["tokens"].as_array().unwrap().len(); + let new_len = new_assistant["tokens"].as_array().unwrap().len(); + assert!(new_len > continue_len); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn world_size_endpoint_is_dev_mode_only() { + let mut app = test_app().await; + let response = app + .call( + Request::builder() + .uri("/get_world_size") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn world_size_includes_data_parallelism_by_default() { + let ready = vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse { + world_size: 2, + data_parallel_size: 4, + ..default_ready_response() + }; + let (mut app, _engine_task) = test_dev_mode_app_with_ready(ready).await; + + let response = app + .call( + Request::builder() + .uri("/get_world_size") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + assert_eq!(json, json!({"world_size": 8})); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn world_size_excludes_data_parallelism_when_include_dp_false() { + let ready = vllm_engine_core_client::protocol::handshake::EngineCoreReadyResponse { + world_size: 2, + data_parallel_size: 4, + ..default_ready_response() + }; + let (mut app, _engine_task) = test_dev_mode_app_with_ready(ready).await; + + let response = app + .call( + Request::builder() + .uri("/get_world_size?include_dp=false") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json"); + assert_eq!(json, json!({"world_size": 2})); +} + +// ========================= Profiler route tests ========================= + +async fn test_profiling_app_with_engine_script(script: F) -> (axum::Router, MockEngineTask) +where + F: for<'a> FnOnce(&'a mut DealerSocket, &'a mut PushSocket) -> TestFuture<'a> + Send + 'static, +{ + let ipc = IpcNamespace::new().expect("create ipc namespace"); + let handshake_address = ipc.handshake_endpoint(); + let engine_id = b"engine-openai-profiler".to_vec(); + + let engine_task = MockEngineTask::new(spawn_mock_engine_task( + handshake_address.clone(), + engine_id.clone(), + move |dealer, push| script(dealer, push), + )); + + let client = EngineCoreClient::connect( + EngineCoreClientConfig::new_single(handshake_address) + .with_model_name("test-model") + .with_local_input_output_addresses( + Some(ipc.input_endpoint()), + Some(ipc.output_endpoint()), + ), + ) + .await + .expect("connect client"); + + let chat = ChatLlm::from_shared_backend(test_llm(client), Arc::new(FakeChatBackend::new())); + ( + build_router(Arc::new( + AppState::new(vec!["test-model".to_string()], chat) + .with_profiler(Some("torch".to_string())), + )), + engine_task, + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn start_profile_route_sends_expected_utility_call() { + let (app, engine_task) = test_profiling_app_with_engine_script(|dealer, push| { + boxed_test_future(async move { + let utility = recv_engine_message(dealer).await; + assert_eq!(utility[0].as_ref(), &[0x03]); + + let payload = decode_value(&utility[1]).expect("decode utility payload"); + let array = payload.as_array().expect("utility payload array"); + let call_id = array[1].as_u64().expect("call id"); + + assert_eq!(array[2], Value::from("profile")); + assert_eq!(array[3], Value::Array(vec![Value::from(true), Value::Nil])); + + send_outputs(push, utility_outputs(call_id, utility_none_result())).await; + }) + }) + .await; + + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/start_profile") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + assert!(body.is_empty()); + engine_task.await.expect("mock engine task"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn stop_profile_route_sends_expected_utility_call() { + let (app, engine_task) = test_profiling_app_with_engine_script(|dealer, push| { + boxed_test_future(async move { + let utility = recv_engine_message(dealer).await; + assert_eq!(utility[0].as_ref(), &[0x03]); + + let payload = decode_value(&utility[1]).expect("decode utility payload"); + let array = payload.as_array().expect("utility payload array"); + let call_id = array[1].as_u64().expect("call id"); + + assert_eq!(array[2], Value::from("profile")); + assert_eq!(array[3], Value::Array(vec![Value::from(false), Value::Nil])); + + send_outputs(push, utility_outputs(call_id, utility_none_result())).await; + }) + }) + .await; + + let response = app + .clone() + .call( + Request::builder() + .method("POST") + .uri("/stop_profile") + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body"); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + assert!(body.is_empty()); + engine_task.await.expect("mock engine task"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn profile_routes_are_hidden_when_profiling_is_disabled() { + let (chat, engine_task) = test_chat_with_engine_handle().await; + let app = build_router(Arc::new(AppState::new( + vec!["test-model".to_string()], + chat, + ))); + + for (method, uri) in [("POST", "/start_profile"), ("POST", "/stop_profile")] { + let response = app + .clone() + .call( + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("call app"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {uri}"); + } + + engine_task.abort_and_join().await; } diff --git a/rust/src/server/src/routes/tokenize.rs b/rust/src/server/src/routes/tokenize.rs new file mode 100644 index 000000000000..3fb4f149e6dc --- /dev/null +++ b/rust/src/server/src/routes/tokenize.rs @@ -0,0 +1,148 @@ +//! `POST /tokenize` and `POST /detokenize` (root paths, matching Python). +//! +//! Encode/decode runs entirely in-process via [`DynTokenizer`]; the inference +//! engine is not involved. + +mod types; + +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use thiserror_ext::AsReport as _; + +use crate::error::{ApiError, server_error}; +use crate::routes::openai::utils::validated_json::ValidatedJson; +use crate::routes::tokenize::types::{ + DetokenizeRequest, DetokenizeResponse, TokenizeChatRequest, TokenizeCompletionRequest, + TokenizeRequest, TokenizeResponse, +}; +use crate::state::AppState; +use crate::utils::resolve_base_request_id; + +/// Match Python `tokenize-{base}` where base is `X-Request-Id` or a new UUID. +fn tokenize_request_id(headers: &HeaderMap) -> String { + let base = resolve_base_request_id( + headers.get("X-Request-Id").and_then(|value| value.to_str().ok()), + None, + ); + format!("tokenize-{base}") +} + +/// Reject an unknown model name, matching the other handlers. +fn check_model(state: &AppState, model: Option<&str>) -> Result<(), ApiError> { + if let Some(model) = model + && !state.served_model_names().iter().any(|n| n == model) + { + return Err(ApiError::model_not_found(model.to_string())); + } + Ok(()) +} + +/// Build the `token_strs` vector when requested, via the tokenizer vocab. +fn token_strs(tokenizer: &vllm_text::tokenizer::DynTokenizer, ids: &[u32]) -> Vec { + // Unknown IDs yield "" — intentional; matches Python's convert_ids_to_tokens behaviour. + ids.iter().map(|&id| tokenizer.id_to_token(id).unwrap_or_default()).collect() +} + +pub async fn tokenize( + State(state): State>, + headers: HeaderMap, + ValidatedJson(body): ValidatedJson, +) -> Response { + let request_id = tokenize_request_id(&headers); + let tokenizer = state.chat.text().tokenizer(); + let max_model_len = state.chat.engine_core_client().max_model_len(); + + let result = match body { + // Completion form: encode the raw `prompt` string (no chat template). + TokenizeRequest::Completion(req) => tokenize_completion(&state, &tokenizer, req), + // Chat form: render `messages` through the template, then encode (see `tokenize_chat`). + TokenizeRequest::Chat(req) => tokenize_chat(&state, &request_id, req).await, + }; + + match result { + Ok((tokens, want_strs)) => { + let token_strs = want_strs.then(|| token_strs(&tokenizer, &tokens)); + Json(TokenizeResponse { + count: tokens.len(), + max_model_len, + tokens, + token_strs, + }) + .into_response() + } + Err(error) => error.into_response(), + } +} + +fn tokenize_completion( + state: &AppState, + tokenizer: &vllm_text::tokenizer::DynTokenizer, + req: TokenizeCompletionRequest, +) -> Result<(Vec, bool), ApiError> { + check_model(state, req.model.as_deref())?; + let tokens = tokenizer + .encode(&req.prompt, req.add_special_tokens) + .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?; + Ok((tokens, req.return_token_strs)) +} + +/// HTTP adapter for the chat-shaped `/tokenize` body. +/// +/// Not [`vllm_chat::ChatLlm::tokenize_chat`]: this checks the model name and maps +/// errors to [`ApiError`]; the chat-crate method does render → finalize → encode. +async fn tokenize_chat( + state: &AppState, + request_id: &str, + req: TokenizeChatRequest, +) -> Result<(Vec, bool), ApiError> { + check_model(state, req.model.as_deref())?; + let return_token_strs = req.return_token_strs; + // `continue_final_message` / `add_generation_prompt` mutual exclusion is + // enforced in `normalize_generation_prompt_mode` inside `into_chat_request`. + let tokens = state + .chat + .tokenize_chat(req.into_chat_request(request_id.to_string())?) + .await + .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?; + Ok((tokens, return_token_strs)) +} + +pub async fn detokenize( + State(state): State>, + ValidatedJson(body): ValidatedJson, +) -> Response { + if let Err(error) = check_model(&state, body.model.as_deref()) { + return error.into_response(); + } + let tokenizer = state.chat.text().tokenizer(); + match tokenizer.decode(&body.tokens, /* skip_special_tokens = */ false) { + Ok(prompt) => Json(DetokenizeResponse { prompt }).into_response(), + Err(e) => server_error!("detokenize failed: {}", e.to_report_string()).into_response(), + } +} + +#[cfg(test)] +mod tests { + use axum::http::{HeaderMap, HeaderValue}; + + use super::tokenize_request_id; + + #[test] + fn tokenize_request_id_prefers_x_request_id_header() { + let mut headers = HeaderMap::new(); + headers.insert("X-Request-Id", HeaderValue::from_static("client-req-1")); + assert_eq!(tokenize_request_id(&headers), "tokenize-client-req-1"); + } + + #[test] + fn tokenize_request_id_generates_uuid_when_header_missing() { + let headers = HeaderMap::new(); + let id = tokenize_request_id(&headers); + assert!(id.starts_with("tokenize-")); + assert_ne!(id, "tokenize-"); + } +} diff --git a/rust/src/server/src/routes/tokenize/types.rs b/rust/src/server/src/routes/tokenize/types.rs new file mode 100644 index 000000000000..f8c3e1cee858 --- /dev/null +++ b/rust/src/server/src/routes/tokenize/types.rs @@ -0,0 +1,212 @@ +use std::collections::HashMap; + +use itertools::Itertools as _; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use validator::{Validate, ValidationErrors}; +use vllm_chat::{ChatOptions, ChatRequest, ChatToolChoice, SamplingParams}; +use vllm_text::output::TextDecodeOptions; + +use crate::error::ApiError; +use crate::routes::openai::chat_completions::convert::{ + convert_message, convert_tools, normalize_generation_prompt_mode, +}; +use crate::routes::openai::utils::types::{ + ChatMessage, Normalizable, Tool, default_true, validate_messages, +}; + +/// `POST /tokenize` body. Untagged: a JSON object with `messages` parses as the +/// chat variant; one with `prompt` parses as the completion variant. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum TokenizeRequest { + Chat(TokenizeChatRequest), + Completion(TokenizeCompletionRequest), +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TokenizeCompletionRequest { + pub model: Option, + pub prompt: String, + #[serde(default = "default_true")] + pub add_special_tokens: bool, + #[serde(default)] + pub return_token_strs: bool, +} + +#[derive(Debug, Clone, Deserialize, Validate)] +pub struct TokenizeChatRequest { + pub model: Option, + #[validate(custom(function = "validate_messages"))] + pub messages: Vec, + #[serde(default = "default_true")] + pub add_generation_prompt: bool, + #[serde(default)] + pub continue_final_message: bool, + #[serde(default)] // chat default is FALSE (template adds specials) + pub add_special_tokens: bool, + #[serde(default)] + pub return_token_strs: bool, + #[serde(default)] + pub chat_template: Option, + #[serde(default)] + pub chat_template_kwargs: Option>, + #[serde(default)] + pub tools: Option>, +} + +impl TokenizeChatRequest { + /// Lower this tokenize body into a [`ChatRequest`] for template rendering. + /// + /// Reuses [`convert_message`] and [`normalize_generation_prompt_mode`] from + /// `chat_completions/convert` so message lowering and generation-prompt + /// rules match chat completions. Only fields that affect rendering are set; + /// `sampling_params`, `decode_options`, etc. stay at default because + /// tokenize never generates. + pub fn into_chat_request(self, request_id: String) -> Result { + let messages: Vec<_> = self.messages.into_iter().map(convert_message).try_collect()?; + let generation_prompt_mode = normalize_generation_prompt_mode( + Some(self.add_generation_prompt), + self.continue_final_message, + &messages, + )?; + + Ok(ChatRequest { + request_id, + messages, + sampling_params: SamplingParams::default(), + chat_options: ChatOptions { + generation_prompt_mode, + chat_template: self.chat_template, + reasoning_effort: None, + template_kwargs: self.chat_template_kwargs.unwrap_or_default(), + }, + tools: convert_tools(self.tools)?, + tool_choice: ChatToolChoice::Auto, + parallel_tool_calls: true, + decode_options: TextDecodeOptions::default(), + intermediate: false, + priority: 0, + documents: None, + cache_salt: None, + add_special_tokens: self.add_special_tokens, + data_parallel_rank: None, + lora_request: None, + }) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DetokenizeRequest { + pub model: Option, + pub tokens: Vec, +} + +/// Do not skip serializing `None` fields here: non-streaming response types +/// should serialize `None` as explicit `null`. +#[derive(Debug, Clone, Serialize)] +pub struct TokenizeResponse { + pub count: usize, + pub max_model_len: u32, + pub tokens: Vec, + pub token_strs: Option>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DetokenizeResponse { + pub prompt: String, +} + +// ---- trait impls required by ValidatedJson ---- +impl Validate for TokenizeRequest { + fn validate(&self) -> Result<(), ValidationErrors> { + if let Self::Chat(req) = self { + req.validate()?; + } + Ok(()) + } +} +impl Validate for DetokenizeRequest { + fn validate(&self) -> Result<(), ValidationErrors> { + Ok(()) + } +} +impl Normalizable for TokenizeRequest {} // default no-op normalize() +impl Normalizable for DetokenizeRequest {} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vllm_chat::ChatTool; + + use super::*; + use crate::routes::openai::utils::types::{ChatMessage, MessageContent}; + + #[test] + fn tokenize_request_converts_openai_tools() { + // The untagged `TokenizeRequest` must resolve a messages+tools body to + // the chat variant and accept standard OpenAI tool objects + // (`{"type":"function",...}`), then convert them to `ChatTool`. + let request: TokenizeRequest = serde_json::from_value(json!({ + "messages": [{"role": "user", "content": "hi"}], + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + }], + })) + .expect("OpenAI tool JSON deserializes to the chat variant"); + + let TokenizeRequest::Chat(req) = request else { + panic!("messages+tools body should parse as the chat variant"); + }; + + let chat_request = + req.into_chat_request("tokenize-test".to_string()).expect("request is valid"); + + assert_eq!( + chat_request.tools, + vec![ChatTool { + name: "get_weather".to_string(), + description: Some("Get weather".to_string()), + parameters: json!({ + "type": "object", + "properties": {"city": {"type": "string"}}, + }), + strict: None, + }] + ); + } + + #[test] + fn into_chat_request_rejects_conflicting_generation_flags() { + let req = TokenizeChatRequest { + model: None, + messages: vec![ChatMessage::User { + content: MessageContent::Text("hi".to_string()), + name: None, + }], + add_generation_prompt: true, + continue_final_message: true, + add_special_tokens: false, + return_token_strs: false, + chat_template: None, + chat_template_kwargs: None, + tools: None, + }; + + let error = req + .into_chat_request("tokenize-test".to_string()) + .expect_err("conflicting flags"); + assert_eq!( + error.to_error_response().error.message, + "Cannot set both `continue_final_message` and `add_generation_prompt` to True." + ); + } +} diff --git a/rust/src/server/src/routes/world_size.rs b/rust/src/server/src/routes/world_size.rs new file mode 100644 index 000000000000..da15757e8aa5 --- /dev/null +++ b/rust/src/server/src/routes/world_size.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Query, State}; +use serde::{Deserialize, Serialize}; + +use crate::error::ApiError; +use crate::state::AppState; + +#[derive(Debug, Deserialize)] +pub(crate) struct WorldSizeParams { + /// If true (default), returns the world size including data parallelism + /// (TP * PP * DP). If false, returns the world size without data + /// parallelism (TP * PP). + #[serde(default = "default_true")] + include_dp: bool, +} + +const fn default_true() -> bool { + true +} + +#[derive(Serialize)] +pub(crate) struct WorldSizeResponse { + world_size: u64, +} + +/// Get the world size from the parallel config. +/// +/// Currently reads static values captured during the engine startup handshake. +/// +/// TODO: If the world size can change at runtime (e.g. elastic EP scaling, +/// DP rank recovery), this should be switched to either: +/// - A `call_utility("get_world_size", (include_dp,))` RPC to the Python +/// engine for live values (simple, adds one ZMQ round-trip per request), or +/// - A push-based approach where the engine sends config updates via the +/// output stream into shared state (zero per-request overhead, more complex). +pub async fn get_world_size( + State(state): State>, + Query(params): Query, +) -> Result, ApiError> { + let client = state.engine_core_client(); + + let ws = client.world_size(); + + let world_size = if params.include_dp { + let dp = client.data_parallel_size(); + ws * dp + } else { + ws + }; + + Ok(Json(WorldSizeResponse { world_size })) +} diff --git a/rust/src/server/src/runtime.rs b/rust/src/server/src/runtime.rs new file mode 100644 index 000000000000..4a8711a6afc3 --- /dev/null +++ b/rust/src/server/src/runtime.rs @@ -0,0 +1,54 @@ +use tokio::runtime::Builder; +use tracing::{info, warn}; +use vllm_engine_core_client::runtime::BackgroundShutdownRuntime; + +const REQUEST_WORKER_THREADS_ENV: &str = "VLLM_RS_REQUEST_WORKER_THREADS"; +const DEFAULT_MAX_REQUEST_WORKER_THREADS: usize = 32; + +/// Build a Tokio runtime for heavyweight request paths outside the HTTP runtime. +/// +/// The server middleware uses this runtime for inference and tokenization +/// routes so CPU-heavy request preparation does not monopolize the HTTP +/// runtime's worker queue. Dropping the wrapper shuts the runtime down in the +/// background. +pub(crate) fn build_request_runtime() -> BackgroundShutdownRuntime { + Builder::new_multi_thread() + .enable_all() + .thread_name("vllm-request") + .worker_threads(request_worker_threads()) + .build() + .expect("failed to build request runtime") + .into() +} + +/// Get the number of worker threads to use for the request runtime. +/// +/// If `VLLM_RS_REQUEST_WORKER_THREADS` is set to a valid positive integer, it is +/// used directly. Otherwise, the runtime uses available parallelism capped by +/// `DEFAULT_MAX_REQUEST_WORKER_THREADS`. +fn request_worker_threads() -> usize { + if let Some(value) = std::env::var_os(REQUEST_WORKER_THREADS_ENV) { + match value.to_string_lossy().parse::() { + Ok(worker_threads) if worker_threads > 0 => return worker_threads, + _ => warn!( + value = %value.to_string_lossy(), + "ignoring invalid {REQUEST_WORKER_THREADS_ENV}" + ), + } + } + + std::thread::available_parallelism() + .map(|parallelism| { + let available = parallelism.get(); + let worker_threads = available.min(DEFAULT_MAX_REQUEST_WORKER_THREADS); + if worker_threads < available { + info!( + available_parallelism = available, + capped_worker_threads = worker_threads, + "capping request runtime worker threads, set {REQUEST_WORKER_THREADS_ENV} to override" + ); + } + worker_threads + }) + .unwrap_or(DEFAULT_MAX_REQUEST_WORKER_THREADS) +} diff --git a/rust/src/server/src/state.rs b/rust/src/server/src/state.rs index c73ca04c5d62..bdfd83e5764d 100644 --- a/rust/src/server/src/state.rs +++ b/rust/src/server/src/state.rs @@ -1,19 +1,29 @@ -use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; use serde_json::Value; +use sha2::{Digest, Sha256}; +use tokio::runtime::Runtime; use tokio::time::{Duration, Instant, sleep_until}; use tracing::warn; use vllm_chat::ChatLlm; use vllm_engine_core_client::EngineCoreClient; use vllm_engine_core_client::protocol::lora::LoraRequest; +use vllm_engine_core_client::runtime::BackgroundShutdownRuntime; +use crate::config::{ApiServerOptions, CorsConfig}; use crate::lora::{LoadLoraError, LoraManager, LoraModelResolution, UnloadLoraError}; - +use crate::runtime::build_request_runtime; use crate::server_info::{ServerInfoConfigFormat, ServerInfoSnapshot}; const SHUTDOWN_REFCOUNT_POLL_INTERVAL: Duration = Duration::from_millis(100); +pub(crate) type ApiKeyHash = [u8; 32]; + +pub(crate) fn hash_api_key(api_key: &str) -> ApiKeyHash { + Sha256::digest(api_key.as_bytes()).into() +} + /// Shared router state for the minimal single-model OpenAI server. pub struct AppState { /// All public model IDs served by this frontend. The first entry is the @@ -21,16 +31,25 @@ pub struct AppState { served_model_names: Vec, /// Shared chat facade used by all requests. pub chat: ChatLlm, - /// Whether to log a summary line for each completed request. - pub enable_log_requests: bool, - /// Whether to set X-Request-Id on every HTTP response. - pub enable_request_id_headers: bool, + /// HTTP/API-server behavior switches. + pub api_server_options: ApiServerOptions, + /// CORS settings applied to every HTTP response. + pub cors: CorsConfig, /// Runtime server information returned by `/server_info`, when available. server_info: Option, + /// SHA-256 hashes of API keys accepted as bearer tokens for guarded routes. + api_key_hashes: Vec, /// Number of in-flight inference requests currently owned by this frontend. server_load: AtomicU64, /// Dynamic LoRA adapter registry. lora_manager: LoraManager, + /// Backend model path reported as `root` for base-model cards. + model_path: Option, + /// Lazily initialized runtime for heavyweight request paths. + request_runtime: OnceLock, + /// Profiler mode that registers `/start_profile` and `/stop_profile` + /// routes when present. + pub profiler: Option, } impl AppState { @@ -50,23 +69,39 @@ impl AppState { Self { served_model_names, chat, - enable_log_requests: false, - enable_request_id_headers: false, + api_server_options: ApiServerOptions::default(), + cors: CorsConfig::default(), server_info: None, + api_key_hashes: Vec::new(), server_load: AtomicU64::new(0), lora_manager: LoraManager::new(), + model_path: None, + request_runtime: OnceLock::new(), + profiler: None, } } - /// Enable per-request completion logging. - pub fn with_log_requests(mut self, enabled: bool) -> Self { - self.enable_log_requests = enabled; + /// Set HTTP/API-server behavior switches. + pub fn with_api_server_options(mut self, options: ApiServerOptions) -> Self { + self.api_server_options = options; self } - /// Enable X-Request-Id response headers. - pub fn with_request_id_headers(mut self, enabled: bool) -> Self { - self.enable_request_id_headers = enabled; + /// Set the CORS settings applied to every HTTP response. + pub fn with_cors(mut self, cors: CorsConfig) -> Self { + self.cors = cors; + self + } + + /// Set the backend model path reported as `root` for base-model cards. + pub fn with_model_path(mut self, model_path: String) -> Self { + self.model_path = Some(model_path); + self + } + + /// Set the profiler mode that enables `/start_profile` and `/stop_profile`. + pub fn with_profiler(mut self, profiler: Option) -> Self { + self.profiler = profiler; self } @@ -84,6 +119,24 @@ impl AppState { self.server_info.as_ref().map(|server_info| server_info.response(config_format)) } + /// Configure API keys accepted by guarded HTTP routes. + pub fn with_api_keys(mut self, api_keys: Vec) -> Self { + self.api_key_hashes = api_keys + .into_iter() + .filter(|key| !key.is_empty()) + .map(|key| hash_api_key(&key)) + .collect(); + self + } + + pub(crate) fn has_api_keys(&self) -> bool { + !self.api_key_hashes.is_empty() + } + + pub(crate) fn api_key_hashes(&self) -> &[ApiKeyHash] { + &self.api_key_hashes + } + /// The primary model name echoed back in API responses (the first served /// name). pub fn primary_model_name(&self) -> &str { @@ -95,10 +148,14 @@ impl AppState { &self.served_model_names } - /// Return base served model names plus dynamically loaded LoRA adapter - /// names. - pub async fn served_model_names_with_loras(&self) -> Vec { - self.lora_manager.served_model_names(&self.served_model_names).await + /// Backend model path reported as `root` for base-model cards, if known. + pub fn model_path(&self) -> Option<&str> { + self.model_path.as_deref() + } + + /// Snapshot the loaded LoRA adapters in load order, for `/v1/models` cards. + pub async fn served_lora_requests(&self) -> Vec { + self.lora_manager.served_lora_requests().await } /// Resolve the requested model against one dynamic LoRA registry snapshot. @@ -144,6 +201,12 @@ impl AppState { self.chat.engine_core_client() } + /// Runtime used by middleware to isolate heavyweight request handlers from + /// the HTTP reactor. + pub(crate) fn request_runtime(&self) -> &Runtime { + self.request_runtime.get_or_init(build_request_runtime) + } + /// Return the current in-flight inference request count for the `/load` /// endpoint. pub fn server_load(&self) -> u64 { @@ -173,6 +236,7 @@ impl AppState { match Arc::try_unwrap(self) { Ok(state) => { state.chat.shutdown().await?; + drop(state.request_runtime); // shutdown in background return Ok(()); } Err(state) => self = state, diff --git a/rust/src/server/src/tls.rs b/rust/src/server/src/tls.rs new file mode 100644 index 000000000000..aa77e8191ed4 --- /dev/null +++ b/rust/src/server/src/tls.rs @@ -0,0 +1,119 @@ +//! OpenSSL server-config construction for TLS termination. +//! +//! Builds an OpenSSL [`SslContext`] from the uvicorn-style `ssl_*` arguments +//! (certificate chain, private key, mTLS client verifier, optional cipher list). +//! The `tls-listener` crate drives the handshake on each accepted connection. +//! +//! Crypto runs through whichever OpenSSL the binary links (system by default, +//! vendored when built with that feature). + +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use openssl::ssl::{ + AlpnError, SslAcceptor, SslAcceptorBuilder, SslContext, SslContextBuilder, SslFiletype, + SslMethod, SslOptions, SslVerifyMode, select_next_proto, +}; + +use crate::config::TlsConfig; + +/// Time a client has to complete the TLS handshake before the connection is dropped. +pub(crate) const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60); + +/// ALPN wire bytes for HTTP/2 (length-prefixed). +const ALPN_H2: &[u8] = b"\x02h2"; + +/// Build the shared OpenSSL acceptor from validated [`TlsConfig`]: the full +/// certificate chain, the private key (`key_file`, or the certificate file when +/// unset), the mTLS client verifier, and an optional cipher list. +/// +/// Starts from the Mozilla intermediate baseline (forward-secret AEAD suites, +/// TLS 1.2 floor, server cipher preference, no compression), a slightly +/// stricter subset of the Python frontend's default suites; `--ssl-ciphers` +/// overrides it. +fn build_server_builder(tls: &TlsConfig) -> Result { + let cert_file = tls.cert_file.as_deref().context("--ssl-certfile is required to enable TLS")?; + + let mut builder = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls_server()) + .context("failed to initialize TLS")?; + builder.set_options(SslOptions::CIPHER_SERVER_PREFERENCE); + + // Load the whole chain (leaf + intermediates), not just the leaf, so + // deployments behind an intermediate CA serve a complete chain. + ensure_exists(cert_file, "--ssl-certfile")?; + builder.set_certificate_chain_file(cert_file).with_context(|| { + format!("failed to parse certificate chain in --ssl-certfile {cert_file:?}") + })?; + + // When `key_file` is unset the key is read from the certificate file + // (combined PEM). + let key_file = tls.key_file.as_deref().unwrap_or(cert_file); + ensure_exists(key_file, "private key file")?; + builder + .set_private_key_file(key_file, SslFiletype::PEM) + .with_context(|| format!("failed to parse private key in {key_file:?}"))?; + builder + .check_private_key() + .context("the certificate and private key do not match")?; + + configure_client_auth(&mut builder, tls)?; + + if let Some(ciphers) = tls.ciphers.as_deref().filter(|c| !c.is_empty()) { + builder + .set_cipher_list(ciphers) + .with_context(|| format!("invalid --ssl-ciphers {ciphers:?}"))?; + } + + Ok(builder) +} + +/// Build the HTTP [`SslContext`] (HTTP/1.1; no ALPN, matching uvicorn). +pub(crate) fn build_server_config(tls: &TlsConfig) -> Result { + Ok(build_server_builder(tls)?.build().into_context()) +} + +/// Build the gRPC [`SslContext`]: identical to [`build_server_config`] but +/// negotiates ALPN `h2`, which HTTP/2 over TLS requires. +pub(crate) fn build_grpc_server_config(tls: &TlsConfig) -> Result { + let mut builder = build_server_builder(tls)?; + builder.set_alpn_select_callback(|_ssl, client| { + select_next_proto(ALPN_H2, client).ok_or(AlpnError::NOACK) + }); + Ok(builder.build().into_context()) +} + +/// Fail loudly with a flag-named message when a configured file is missing, +/// distinguishing it from a malformed-PEM error raised later by OpenSSL (whose +/// `ErrorStack` does not name the offending file). +fn ensure_exists(path: &str, what: &str) -> Result<()> { + std::fs::metadata(Path::new(path)) + .map(drop) + .with_context(|| format!("failed to read {what} {path:?}")) +} + +/// Apply the `cert_reqs` client-certificate policy: 0 = none, 1 = optional +/// (verify if presented, allow anonymous), 2 = required. `PEER` without a custom +/// verify callback still rejects a presented-but-untrusted certificate. +fn configure_client_auth(builder: &mut SslContextBuilder, tls: &TlsConfig) -> Result<()> { + if tls.cert_reqs == 0 { + builder.set_verify(SslVerifyMode::NONE); + return Ok(()); + } + + let ca_file = tls + .ca_certs + .as_deref() + .context("--ssl-ca-certs is required for client certificate verification")?; + ensure_exists(ca_file, "--ssl-ca-certs")?; + builder + .set_ca_file(ca_file) + .with_context(|| format!("failed to parse --ssl-ca-certs {ca_file:?}"))?; + + let mut mode = SslVerifyMode::PEER; + if tls.cert_reqs == 2 { + mode |= SslVerifyMode::FAIL_IF_NO_PEER_CERT; + } + builder.set_verify(mode); + Ok(()) +} diff --git a/rust/src/server/src/tls_tests.rs b/rust/src/server/src/tls_tests.rs new file mode 100644 index 000000000000..c9bd063d77d7 --- /dev/null +++ b/rust/src/server/src/tls_tests.rs @@ -0,0 +1,677 @@ +//! TLS tests: `build_server_config` unit checks plus end-to-end OpenSSL handshakes +//! through the production listener/connection path, with a trivial router since TLS +//! terminates below the app. + +use std::pin::Pin; +use std::time::Duration; + +use axum::Router; +use axum::routing::get; +use openssl::asn1::Asn1Time; +use openssl::bn::{BigNum, MsbOption}; +use openssl::ec::{EcGroup, EcKey}; +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkey::{PKey, Private}; +use openssl::ssl::{SslConnector, SslFiletype, SslMethod, SslVersion}; +use openssl::x509::extension::{BasicConstraints, KeyUsage, SubjectAlternativeName}; +use openssl::x509::{X509, X509NameBuilder}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpStream; +use tokio_openssl::SslStream; +use tokio_util::sync::CancellationToken; + +use crate::config::{HttpListenerMode, TlsConfig}; +use crate::listener::{Listener, MaybeTlsListener}; +use crate::{ConnectionTimeouts, serve_connections, tls}; + +// ============================================================================ +// Test infrastructure +// ============================================================================ + +/// A throwaway CA + server/client/untrusted/chain cert set as PEM files in a +/// temp dir; dropping it deletes them. +pub(crate) struct TestCerts { + dir: TempDir, +} + +impl TestCerts { + pub(crate) fn generate() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + + let (ca, ca_key) = build_ca(); + let (server, server_key) = build_leaf("server", &["127.0.0.1", "localhost"], &ca, &ca_key); + let (client, client_key) = build_leaf("client", &[], &ca, &ca_key); + let (untrusted, untrusted_key) = build_self_signed("untrusted client"); + + // Leaf signed by an intermediate (itself signed by the root); the cert + // file holds leaf + intermediate, for the chain-serving test. + let (intermediate, intermediate_key) = build_intermediate(&ca, &ca_key); + let (chain_leaf, chain_leaf_key) = build_leaf( + "chain", + &["127.0.0.1", "localhost"], + &intermediate, + &intermediate_key, + ); + + let server_pem = pem(&server); + let server_key_pem = key_pem(&server_key); + let files = [ + ("ca.pem", pem(&ca)), + ("server.pem", server_pem.clone()), + ("server.key", server_key_pem.clone()), + ("client.pem", pem(&client)), + ("client.key", key_pem(&client_key)), + ("untrusted_client.pem", pem(&untrusted)), + ("untrusted_client.key", key_pem(&untrusted_key)), + ( + "server_combined.pem", + format!("{server_pem}{server_key_pem}"), + ), + ( + "server_chain.pem", + format!("{}{}", pem(&chain_leaf), pem(&intermediate)), + ), + ("server_chain.key", key_pem(&chain_leaf_key)), + ]; + for (name, contents) in files { + std::fs::write(dir.path().join(name), contents).expect("write fixture"); + } + Self { dir } + } + + /// Absolute path to a fixture by name; the file need not exist. + pub(crate) fn path(&self, name: &str) -> String { + self.dir.path().join(name).to_str().expect("utf-8 path").to_string() + } +} + +fn gen_key() -> PKey { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).expect("ec group"); + let ec = EcKey::generate(&group).expect("ec key"); + PKey::from_ec_key(ec).expect("pkey") +} + +fn serial() -> openssl::asn1::Asn1Integer { + let mut bn = BigNum::new().expect("bignum"); + bn.rand(159, MsbOption::MAYBE_ZERO, false).expect("rand serial"); + bn.to_asn1_integer().expect("asn1 serial") +} + +fn x509_name(cn: &str) -> openssl::x509::X509Name { + let mut builder = X509NameBuilder::new().expect("name builder"); + builder.append_entry_by_text("CN", cn).expect("cn"); + builder.build() +} + +fn pem(cert: &X509) -> String { + String::from_utf8(cert.to_pem().expect("cert pem")).expect("utf-8 cert") +} + +fn key_pem(key: &PKey) -> String { + String::from_utf8(key.private_key_to_pem_pkcs8().expect("key pem")).expect("utf-8 key") +} + +/// A self-signed CA used to sign the server/client leaf certs. +fn build_ca() -> (X509, PKey) { + let key = gen_key(); + let name = x509_name("vLLM Test CA"); + let mut builder = X509::builder().expect("x509 builder"); + builder.set_version(2).expect("version"); + builder.set_serial_number(&serial()).expect("serial"); + builder.set_subject_name(&name).expect("subject"); + builder.set_issuer_name(&name).expect("issuer"); + builder.set_pubkey(&key).expect("pubkey"); + builder + .set_not_before(&Asn1Time::days_from_now(0).expect("nb")) + .expect("set nb"); + builder + .set_not_after(&Asn1Time::days_from_now(3650).expect("na")) + .expect("set na"); + builder + .append_extension(BasicConstraints::new().critical().ca().build().expect("bc")) + .expect("ext bc"); + builder + .append_extension( + KeyUsage::new().critical().key_cert_sign().crl_sign().build().expect("ku"), + ) + .expect("ext ku"); + builder.sign(&key, MessageDigest::sha256()).expect("sign ca"); + (builder.build(), key) +} + +/// A CA-signed leaf cert with optional subject-alternative names (IP or DNS). +fn build_leaf(cn: &str, sans: &[&str], ca: &X509, ca_key: &PKey) -> (X509, PKey) { + let key = gen_key(); + let mut builder = X509::builder().expect("x509 builder"); + builder.set_version(2).expect("version"); + builder.set_serial_number(&serial()).expect("serial"); + builder.set_subject_name(&x509_name(cn)).expect("subject"); + builder.set_issuer_name(ca.subject_name()).expect("issuer"); + builder.set_pubkey(&key).expect("pubkey"); + builder + .set_not_before(&Asn1Time::days_from_now(0).expect("nb")) + .expect("set nb"); + builder + .set_not_after(&Asn1Time::days_from_now(3650).expect("na")) + .expect("set na"); + builder + .append_extension(BasicConstraints::new().build().expect("bc")) + .expect("ext bc"); + if !sans.is_empty() { + let mut san = SubjectAlternativeName::new(); + for entry in sans { + if entry.parse::().is_ok() { + san.ip(entry); + } else { + san.dns(entry); + } + } + let ext = san.build(&builder.x509v3_context(Some(ca), None)).expect("san"); + builder.append_extension(ext).expect("ext san"); + } + builder.sign(ca_key, MessageDigest::sha256()).expect("sign leaf"); + (builder.build(), key) +} + +/// A self-signed leaf not chained to the CA, for the untrusted-client test. +fn build_self_signed(cn: &str) -> (X509, PKey) { + let key = gen_key(); + let name = x509_name(cn); + let mut builder = X509::builder().expect("x509 builder"); + builder.set_version(2).expect("version"); + builder.set_serial_number(&serial()).expect("serial"); + builder.set_subject_name(&name).expect("subject"); + builder.set_issuer_name(&name).expect("issuer"); + builder.set_pubkey(&key).expect("pubkey"); + builder + .set_not_before(&Asn1Time::days_from_now(0).expect("nb")) + .expect("set nb"); + builder + .set_not_after(&Asn1Time::days_from_now(3650).expect("na")) + .expect("set na"); + builder + .append_extension(BasicConstraints::new().build().expect("bc")) + .expect("ext bc"); + builder.sign(&key, MessageDigest::sha256()).expect("sign self"); + (builder.build(), key) +} + +/// A CA-capable intermediate signed by the root, for the full-chain test. +fn build_intermediate(ca: &X509, ca_key: &PKey) -> (X509, PKey) { + let key = gen_key(); + let mut builder = X509::builder().expect("x509 builder"); + builder.set_version(2).expect("version"); + builder.set_serial_number(&serial()).expect("serial"); + builder + .set_subject_name(&x509_name("vLLM Test Intermediate CA")) + .expect("subject"); + builder.set_issuer_name(ca.subject_name()).expect("issuer"); + builder.set_pubkey(&key).expect("pubkey"); + builder + .set_not_before(&Asn1Time::days_from_now(0).expect("nb")) + .expect("set nb"); + builder + .set_not_after(&Asn1Time::days_from_now(3650).expect("na")) + .expect("set na"); + builder + .append_extension(BasicConstraints::new().critical().ca().build().expect("bc")) + .expect("ext bc"); + builder + .append_extension( + KeyUsage::new().critical().key_cert_sign().crl_sign().build().expect("ku"), + ) + .expect("ext ku"); + builder.sign(ca_key, MessageDigest::sha256()).expect("sign intermediate"); + (builder.build(), key) +} + +pub(crate) fn server_tls(certs: &TestCerts, cert_reqs: i32) -> TlsConfig { + TlsConfig { + cert_file: Some(certs.path("server.pem")), + key_file: Some(certs.path("server.key")), + ca_certs: (cert_reqs != 0).then(|| certs.path("ca.pem")), + cert_reqs, + ciphers: None, + } +} + +/// A plaintext-listener TLS config for `build_server_config` checks (`cert_reqs` +/// 0, no client auth), with the cert/key files chosen by the caller. +fn build_tls(certs: &TestCerts, cert: &str, key: Option<&str>) -> TlsConfig { + TlsConfig { + cert_file: Some(certs.path(cert)), + key_file: key.map(|k| certs.path(k)), + ca_certs: None, + cert_reqs: 0, + ciphers: None, + } +} + +/// Generous per-connection timeouts that never fire during the fast tests. +const TEST_TIMEOUTS: ConnectionTimeouts = ConnectionTimeouts { + header_read: Duration::from_secs(5), + keep_alive_enabled: true, +}; + +async fn spawn_server(tls_config: Option) -> (String, CancellationToken) { + spawn_server_with_timeouts(tls_config, TEST_TIMEOUTS).await +} + +/// Bind an ephemeral listener and serve a trivial router via the production +/// listener/connection path. The listener is bound (and thus accepting into the +/// backlog) before returning, so a client may connect immediately without a sleep. +async fn spawn_server_with_timeouts( + tls_config: Option, + timeouts: ConnectionTimeouts, +) -> (String, CancellationToken) { + let listener = Listener::bind(&HttpListenerMode::BindTcp { + host: "127.0.0.1".to_string(), + port: 0, + }) + .await + .expect("bind listener"); + let addr = listener.local_addr_display().expect("local addr"); + + let server_config = + tls_config.map(|cfg| tls::build_server_config(&cfg).expect("build server config")); + let app = Router::new().route("/health", get(|| async { "ok" })); + let shutdown = CancellationToken::new(); + let server_shutdown = shutdown.clone(); + tokio::spawn(async move { + let listener = match server_config { + Some(context) => MaybeTlsListener::tls(listener, context), + None => MaybeTlsListener::plain(listener), + }; + let _ = serve_connections(listener, app, server_shutdown.cancelled_owned(), timeouts).await; + }); + (addr, shutdown) +} + +/// Open a TLS connection trusting the test CA and finish the handshake, +/// optionally presenting a client identity (`.pem` + `.key`) for +/// mTLS. Hostname verification is disabled (the IP-SAN match is not under test); +/// chain verification stays on, so an untrusted server cert is still rejected. +async fn connect_tls( + certs: &TestCerts, + addr: &str, + identity: Option<&str>, +) -> std::io::Result>>> { + let tcp = TcpStream::connect(addr).await?; + + let mut builder = SslConnector::builder(SslMethod::tls_client()).expect("connector builder"); + builder.set_ca_file(certs.path("ca.pem")).expect("trust ca"); + if let Some(name) = identity { + builder + .set_certificate_chain_file(certs.path(&format!("{name}.pem"))) + .expect("client cert"); + builder + .set_private_key_file(certs.path(&format!("{name}.key")), SslFiletype::PEM) + .expect("client key"); + } + let connector = builder.build(); + let mut config = connector.configure().expect("configure"); + config.set_verify_hostname(false); + let ssl = config.into_ssl("127.0.0.1").expect("ssl"); + + let mut stream = Box::pin(SslStream::new(ssl, tcp).expect("client ssl stream")); + stream.as_mut().connect().await.map_err(std::io::Error::other)?; + Ok(stream) +} + +/// Issue an HTTPS GET (with `Connection: close`), optionally with an mTLS identity. +async fn https_get( + certs: &TestCerts, + addr: &str, + identity: Option<&str>, +) -> std::io::Result { + let mut stream = connect_tls(certs, addr, identity).await?; + stream + .write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .await?; + let mut response = String::new(); + stream.read_to_string(&mut response).await?; + Ok(response) +} + +/// Attempt a handshake offering only a legacy CBC+SHA1 suite over TLS 1.2, +/// capping the version so TLS 1.3 cannot rescue the negotiation. +async fn legacy_suite_handshake(certs: &TestCerts, addr: &str) -> std::io::Result<()> { + let tcp = TcpStream::connect(addr).await?; + + let mut builder = SslConnector::builder(SslMethod::tls_client()).expect("connector builder"); + builder.set_ca_file(certs.path("ca.pem")).expect("trust ca"); + builder.set_max_proto_version(Some(SslVersion::TLS1_2)).expect("cap tls1.2"); + builder + .set_cipher_list("ECDHE-ECDSA-AES256-SHA:@SECLEVEL=0") + .expect("legacy cipher"); + let connector = builder.build(); + let mut config = connector.configure().expect("configure"); + config.set_verify_hostname(false); + let ssl = config.into_ssl("127.0.0.1").expect("ssl"); + + let stream = SslStream::new(ssl, tcp).expect("client ssl stream"); + tokio::pin!(stream); + stream.as_mut().connect().await.map_err(std::io::Error::other) +} + +async fn plain_get(addr: &str) -> std::io::Result { + let mut tcp = TcpStream::connect(addr).await?; + tcp.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .await?; + let mut response = String::new(); + tcp.read_to_string(&mut response).await?; + Ok(response) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[test] +fn builds_from_combined_pem() { + // Key omitted: it is read from the combined cert+key file. + let certs = TestCerts::generate(); + assert!(tls::build_server_config(&build_tls(&certs, "server_combined.pem", None)).is_ok()); +} + +#[test] +fn rejects_missing_cert_file() { + let certs = TestCerts::generate(); + assert!(tls::build_server_config(&build_tls(&certs, "does_not_exist.pem", None)).is_err()); +} + +#[test] +fn accepts_valid_cipher_list() { + let certs = TestCerts::generate(); + let mut cfg = build_tls(&certs, "server.pem", Some("server.key")); + cfg.ciphers = Some("ECDHE-ECDSA-AES256-GCM-SHA384".to_string()); + assert!(tls::build_server_config(&cfg).is_ok()); +} + +#[test] +fn rejects_invalid_cipher_list() { + let certs = TestCerts::generate(); + let mut cfg = build_tls(&certs, "server.pem", Some("server.key")); + cfg.ciphers = Some("THIS-IS-NOT-A-CIPHER".to_string()); + assert!(tls::build_server_config(&cfg).is_err()); +} + +#[test] +fn rejects_mismatched_cert_and_key() { + // check_private_key must reject a key that does not match the certificate. + let certs = TestCerts::generate(); + let tls = build_tls(&certs, "client.pem", Some("server.key")); + assert!(tls::build_server_config(&tls).is_err()); +} + +#[tokio::test] +async fn https_request_succeeds_over_tls() { + let certs = TestCerts::generate(); + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 0))).await; + let response = https_get(&certs, &addr, None).await.expect("https request"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + shutdown.cancel(); +} + +#[tokio::test] +async fn serves_full_certificate_chain() { + // Cert file holds leaf + intermediate; a client trusting only the root can + // verify only if the server sends the intermediate, guarding against a + // leaf-only load. + let certs = TestCerts::generate(); + let tls = TlsConfig { + cert_file: Some(certs.path("server_chain.pem")), + key_file: Some(certs.path("server_chain.key")), + ca_certs: None, + cert_reqs: 0, + ciphers: None, + }; + let (addr, shutdown) = spawn_server(Some(tls)).await; + let response = https_get(&certs, &addr, None).await.expect("chained https request"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + shutdown.cancel(); +} + +#[tokio::test] +async fn rejects_legacy_cipher_only_client() { + let certs = TestCerts::generate(); + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 0))).await; + let result = legacy_suite_handshake(&certs, &addr).await; + assert!(result.is_err(), "legacy-only client must be rejected"); + shutdown.cancel(); +} + +#[tokio::test] +async fn ssl_ciphers_override_widens_past_preset() { + // Counterpart to rejects_legacy_cipher_only_client: --ssl-ciphers set to that + // same legacy suite lets the client through, proving the override beats the preset. + let certs = TestCerts::generate(); + let mut tls = server_tls(&certs, 0); + tls.ciphers = Some("ECDHE-ECDSA-AES256-SHA:@SECLEVEL=0".to_string()); + let (addr, shutdown) = spawn_server(Some(tls)).await; + let result = legacy_suite_handshake(&certs, &addr).await; + assert!( + result.is_ok(), + "override must allow the legacy suite: {result:?}" + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn mtls_required_rejects_client_without_certificate() { + let certs = TestCerts::generate(); + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 2))).await; + let result = https_get(&certs, &addr, None).await; + assert!( + result.is_err(), + "handshake must fail without a client certificate" + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn mtls_required_accepts_valid_client_certificate() { + let certs = TestCerts::generate(); + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 2))).await; + let response = https_get(&certs, &addr, Some("client")).await.expect("mtls request"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + shutdown.cancel(); +} + +#[tokio::test] +async fn mtls_optional_allows_anonymous_and_authenticated() { + let certs = TestCerts::generate(); + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 1))).await; + let anonymous = https_get(&certs, &addr, None).await.expect("anonymous request"); + assert!(anonymous.starts_with("HTTP/1.1 200"), "{anonymous}"); + let authenticated = + https_get(&certs, &addr, Some("client")).await.expect("authenticated request"); + assert!(authenticated.starts_with("HTTP/1.1 200"), "{authenticated}"); + shutdown.cancel(); +} + +#[tokio::test] +async fn mtls_rejects_untrusted_client_certificate() { + // Optional (1) still verifies a presented cert, so a self-signed cert not + // chained to the CA is rejected in both modes, not just required (2). + let certs = TestCerts::generate(); + for cert_reqs in [1, 2] { + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, cert_reqs))).await; + let result = https_get(&certs, &addr, Some("untrusted_client")).await; + assert!( + result.is_err(), + "cert_reqs={cert_reqs}: untrusted client cert must be rejected" + ); + shutdown.cancel(); + } +} + +#[tokio::test] +async fn plain_http_serves_when_tls_is_disabled() { + let (addr, shutdown) = spawn_server(None).await; + let response = plain_get(&addr).await.expect("http request"); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + shutdown.cancel(); +} + +#[tokio::test(start_paused = true)] +async fn tls_handshake_timeout_drops_silent_client() { + // Silent client (no ClientHello) must be dropped at the handshake deadline. + let certs = TestCerts::generate(); + let (addr, shutdown) = spawn_server(Some(server_tls(&certs, 0))).await; + + let mut tcp = TcpStream::connect(&addr).await.expect("connect"); + tokio::task::yield_now().await; + tokio::time::advance(tls::TLS_HANDSHAKE_TIMEOUT + Duration::from_millis(1)).await; + tokio::task::yield_now().await; + + let mut buf = [0u8; 1]; + let read = tokio::time::timeout(Duration::from_secs(1), tcp.read(&mut buf)).await; + assert!( + matches!(read, Ok(Ok(0)) | Ok(Err(_))), + "server must drop a stalled TLS handshake (expected close, got {read:?})" + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn keep_alive_timeout_closes_idle_connection() { + // Idle keep-alive connection must be closed at the deadline. + let timeouts = ConnectionTimeouts { + header_read: Duration::from_millis(150), + keep_alive_enabled: true, + }; + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await; + + let mut tcp = TcpStream::connect(&addr).await.expect("connect"); + // No `Connection: close`, so it stays alive until the idle deadline. + tcp.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .await + .expect("write request"); + + let drained = tokio::time::timeout(Duration::from_secs(5), async { + let mut buf = [0u8; 1024]; + loop { + match tcp.read(&mut buf).await { + Ok(0) => return Ok(()), + Ok(_) => continue, + Err(err) => return Err(err), + } + } + }) + .await; + assert!( + matches!(drained, Ok(Ok(()))), + "server must close an idle keep-alive connection (got {drained:?})" + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn keep_alive_timeout_closes_idle_tls_connection() { + // The keep-alive idle bound lives in serve_connections, below TLS; assert it + // still fires through tls-listener's post-handshake SslStream, not just plaintext. + let certs = TestCerts::generate(); + let timeouts = ConnectionTimeouts { + header_read: Duration::from_millis(150), + keep_alive_enabled: true, + }; + let (addr, shutdown) = spawn_server_with_timeouts(Some(server_tls(&certs, 0)), timeouts).await; + + let mut stream = connect_tls(&certs, &addr, None).await.expect("handshake"); + // No `Connection: close`, so the connection stays alive until the idle deadline. + stream + .write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .await + .expect("write request"); + + let closed = tokio::time::timeout(Duration::from_secs(5), async { + let mut buf = [0u8; 1024]; + loop { + // A clean close_notify (Ok(0)) or an abrupt TLS EOF both mean the + // server closed; only the outer timeout (still open) is a failure. + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(_) => continue, + } + } + }) + .await; + assert!( + closed.is_ok(), + "server must close an idle keep-alive TLS connection at the deadline" + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn idle_timeout_closes_silent_client() { + // Silent client closed by the header-read timeout (http1-only arms it from byte 0). + let timeouts = ConnectionTimeouts { + header_read: Duration::from_millis(150), + keep_alive_enabled: true, + }; + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await; + + let mut tcp = TcpStream::connect(&addr).await.expect("connect"); + let mut buf = [0u8; 1]; + let read = tokio::time::timeout(Duration::from_secs(5), tcp.read(&mut buf)).await; + assert!( + matches!(read, Ok(Ok(0)) | Ok(Err(_))), + "server must close a silent client (expected close, got {read:?})" + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn keep_alive_zero_disables_keep_alive() { + // 0 disables keep-alive (serve, then close), like uvicorn's timeout_keep_alive=0. + let timeouts = ConnectionTimeouts { + header_read: Duration::from_secs(5), + keep_alive_enabled: false, + }; + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await; + + let mut tcp = TcpStream::connect(&addr).await.expect("connect"); + tcp.write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .await + .expect("write request"); + + let mut response = String::new(); + let read = + tokio::time::timeout(Duration::from_secs(5), tcp.read_to_string(&mut response)).await; + assert!( + read.is_ok(), + "server must close after one response, not hang" + ); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + // Assert `Connection: close`, not just 200: a 0 header-read timeout would also + // serve an immediate request, so 200 alone wouldn't prove keep-alive is off. + assert!( + response.to_ascii_lowercase().contains("connection: close"), + "keep-alive must be disabled (expected Connection: close): {response}" + ); + shutdown.cancel(); +} + +#[tokio::test] +async fn disabled_keep_alive_still_closes_silent_client() { + // Even with keep-alive off, the head read stays bounded, so a silent client + // is dropped rather than held open. + let timeouts = ConnectionTimeouts { + header_read: Duration::from_millis(150), + keep_alive_enabled: false, + }; + let (addr, shutdown) = spawn_server_with_timeouts(None, timeouts).await; + + let mut tcp = TcpStream::connect(&addr).await.expect("connect"); + let mut buf = [0u8; 1]; + let read = tokio::time::timeout(Duration::from_secs(5), tcp.read(&mut buf)).await; + assert!( + matches!(read, Ok(Ok(0)) | Ok(Err(_))), + "disabled keep-alive must still close a silent client (got {read:?})" + ); + shutdown.cancel(); +} diff --git a/rust/src/text/Cargo.toml b/rust/src/text/Cargo.toml index 8be9ee78764e..7bda7f976e1c 100644 --- a/rust/src/text/Cargo.toml +++ b/rust/src/text/Cargo.toml @@ -12,6 +12,7 @@ enum-as-inner.workspace = true futures.workspace = true hf-hub.workspace = true itertools.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true serde_with.workspace = true @@ -30,6 +31,7 @@ serial_test.workspace = true tempfile.workspace = true tokio.workspace = true vllm-llm = { workspace = true, features = ["test-util"] } +vllm-tokenizer = { workspace = true, features = ["test-utils"] } [lints] workspace = true diff --git a/rust/src/text/src/backend/hf/config.rs b/rust/src/text/src/backend/hf/config.rs index 5f2ecf8ba603..fbf796b5a7fb 100644 --- a/rust/src/text/src/backend/hf/config.rs +++ b/rust/src/text/src/backend/hf/config.rs @@ -91,8 +91,7 @@ impl HfSpecialTokens { #[serde(default)] pub struct ModelConfig { model_type: Option, - max_position_embeddings: Option, - num_attention_heads: Option, + vocab_size: Option, num_experts: Option, moe_num_experts: Option, n_routed_experts: Option, @@ -179,22 +178,18 @@ impl ModelConfig { self.model_type.as_deref().or_else(|| self.text_config.as_deref()?.model_type()) } - /// Reject partially nested `text_config` payloads that are unlikely to be - /// valid LLM configs for our current use. - /// - /// This keeps the simplified Rust-side parsing honest: if a model declares - /// `text_config`, it must at least look like a real text model config. - fn validate_text_config_selection(&self) -> Result<()> { - if let Some(text_config) = self.text_config.as_deref() - && text_config.num_attention_heads.is_none() - { - return Err(Error::Tokenizer( - "the text config extracted from the model config does not have `num_attention_heads`" - .to_string(), - )); + /// Return the effective model vocabulary size, following the same + /// simplified text-config selection as `model_type`. + pub fn vocab_size(&self) -> Result { + if let Some(vocab_size) = self.vocab_size { + Ok(vocab_size) + } else if let Some(text_config) = self.text_config.as_deref() { + text_config.vocab_size() + } else { + Err(Error::Tokenizer( + "the model config does not define `vocab_size`".to_string(), + )) } - - Ok(()) } /// Match Python's current expert-count priority on the selected text @@ -237,10 +232,6 @@ impl ModelConfig { pub(super) fn is_moe(&self) -> bool { self.num_experts() > 0 } - - pub(super) fn max_position_embeddings(&self) -> Option { - self.effective_text_config().max_position_embeddings - } } /// Load the tokenizer-side EOS metadata if a config file is present. @@ -255,9 +246,7 @@ pub(super) fn load_generation_config(path: Option<&Path>) -> Result) -> Result { - let config: ModelConfig = read_json_file(path)?; - config.validate_text_config_selection()?; - Ok(config) + read_json_file(path) } fn read_json_file(path: Option<&Path>) -> Result @@ -335,12 +324,9 @@ mod tests { r#"{ "model_type": "top_level", "num_experts": 64, - "max_position_embeddings": 8192, "text_config": { "model_type": "nested", - "num_attention_heads": 32, - "num_local_experts": 8, - "max_position_embeddings": 4096 + "num_local_experts": 8 } }"#, ) @@ -348,26 +334,36 @@ mod tests { assert_eq!(config.num_experts(), 8); assert_eq!(config.model_type(), Some("top_level")); - assert_eq!(config.max_position_embeddings(), Some(4096)); assert!(config.is_moe()); } #[test] - fn model_config_defaults_to_non_moe_when_no_expert_metadata_exists() { - let config: ModelConfig = - serde_json::from_str(r#"{"max_position_embeddings":4096}"#).unwrap(); + fn model_config_uses_nested_vocab_size_when_top_level_is_absent() { + let config: ModelConfig = serde_json::from_str( + r#"{ + "text_config": { + "vocab_size": 151936 + } + }"#, + ) + .unwrap(); - assert_eq!(config.num_experts(), 0); - assert!(!config.is_moe()); - assert_eq!(config.max_position_embeddings(), Some(4096)); + assert_eq!(config.vocab_size().unwrap(), 151936); } #[test] - fn model_config_rejects_nested_text_config_without_attention_heads() { - let config: ModelConfig = - serde_json::from_str(r#"{"text_config":{"max_position_embeddings":4096}}"#).unwrap(); + fn model_config_rejects_missing_vocab_size() { + let config: ModelConfig = serde_json::from_str(r#"{}"#).unwrap(); + + let error = config.vocab_size().unwrap_err(); + assert!(error.to_string().contains("does not define `vocab_size`")); + } - let error = config.validate_text_config_selection().unwrap_err(); - assert!(error.to_string().contains("does not have `num_attention_heads`"),); + #[test] + fn model_config_defaults_to_non_moe_when_no_expert_metadata_exists() { + let config: ModelConfig = serde_json::from_str(r#"{}"#).unwrap(); + + assert_eq!(config.num_experts(), 0); + assert!(!config.is_moe()); } } diff --git a/rust/src/text/src/backend/hf/mod.rs b/rust/src/text/src/backend/hf/mod.rs index a5d07dd8fc0b..0e8a9bd3c027 100644 --- a/rust/src/text/src/backend/hf/mod.rs +++ b/rust/src/text/src/backend/hf/mod.rs @@ -36,6 +36,8 @@ pub struct HfTextBackend { /// Generation-config for sampling defaults that may be inherited when the /// user does not explicitly override them. generation_config: GenerationConfig, + /// Model vocabulary size from the selected text config. + model_vocab_size: usize, /// Model config (`config.json`). model_config: ModelConfig, } @@ -58,6 +60,7 @@ impl HfTextBackend { .and_then(|token| tokenizer.token_to_id(token.as_str())); let model_config = load_model_config(files.config_path.as_deref())?; + let model_vocab_size = model_config.vocab_size()? as usize; let generation_config = load_generation_config(files.generation_config_path.as_deref())?; let mut extra_eos_token_ids = generation_config .eos_token_id @@ -80,6 +83,7 @@ impl HfTextBackend { primary_eos_token_id, extra_eos_token_ids, generation_config, + model_vocab_size, model_config, }) } @@ -100,6 +104,10 @@ impl TextBackend for HfTextBackend { self.model_config.is_moe() } + fn model_vocab_size(&self) -> usize { + self.model_vocab_size + } + fn model_id(&self) -> &str { &self.model_id } @@ -114,7 +122,6 @@ impl TextBackend for HfTextBackend { default_min_p: self.generation_config.min_p, default_repetition_penalty: self.generation_config.repetition_penalty, default_max_tokens: self.generation_config.max_new_tokens, - max_model_len: self.model_config.max_position_embeddings(), }) } } diff --git a/rust/src/text/src/backend/hf/model_files.rs b/rust/src/text/src/backend/hf/model_files.rs index 7f84c90f39c2..d8ddb0139c9c 100644 --- a/rust/src/text/src/backend/hf/model_files.rs +++ b/rust/src/text/src/backend/hf/model_files.rs @@ -378,7 +378,7 @@ mod tests { fs::write(dir.path().join("tokenizer.json"), "{}").expect("write tokenizer"); fs::write( dir.path().join("tokenizer_config.json"), - r#"{"tokenizer_class":"PreTrainedTokenizerFast"}"#, + r#"{"tokenizer_class":"TokenizersBackend"}"#, ) .expect("write tokenizer config"); fs::write(dir.path().join("config.json"), "{}").expect("write config"); diff --git a/rust/src/text/src/backend/mod.rs b/rust/src/text/src/backend/mod.rs index 4f2d7093a757..8bc834aeae2c 100644 --- a/rust/src/text/src/backend/mod.rs +++ b/rust/src/text/src/backend/mod.rs @@ -6,8 +6,8 @@ use vllm_tokenizer::DynTokenizer; use crate::error::Result; -/// Tokenizer/model-derived hints used to enrich text-generation requests before -/// they are lowered into engine-core. +/// Tokenizer/model-derived defaults used to enrich text-generation requests +/// before they are lowered into engine-core. #[derive(Debug, Clone, Default, PartialEq)] pub struct SamplingHints { pub primary_eos_token_id: Option, @@ -18,9 +18,38 @@ pub struct SamplingHints { pub default_min_p: Option, pub default_repetition_penalty: Option, pub default_max_tokens: Option, - /// Model context window size (`max_position_embeddings` from - /// `config.json`). - pub max_model_len: Option, +} + +/// Effective bounds used to validate and lower sampling requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SamplingLimits { + /// Runtime context window size reported by the engine startup handshake. + pub max_model_len: u32, + /// Maximum number of top log probabilities accepted by this frontend. + /// + /// `-1` means allowing requests up to the model vocabulary size. + pub max_logprobs: i32, + + /// Model vocabulary size from the model config, used to bound generated + /// token IDs and logits-domain sampling controls. + pub model_vocab_size: usize, + /// Tokenizer vocabulary size, used to bound `allowed_token_ids` and + /// token-ID prompts. + pub tokenizer_vocab_size: usize, +} + +impl SamplingLimits { + /// Original Python definition: + /// + pub const DEFAULT_MAX_LOGPROBS: i32 = 20; + /// Original Python definition: + /// + pub const MAX_LOGPROB_TOKEN_IDS: usize = 128; + + /// Return the union bound used to validate token-ID prompts. + pub fn prompt_token_vocab_size(&self) -> usize { + self.tokenizer_vocab_size.max(self.model_vocab_size) + } } /// Minimal text-processing backend needed by `vllm-text`. @@ -41,6 +70,20 @@ pub trait TextBackend: Send + Sync { fn sampling_hints(&self) -> Result { Ok(SamplingHints::default()) } + + /// Return the model vocabulary size from the model config. + /// + /// The permissive default exists for lightweight test backends. Production + /// backends should override it with the resolved model config value. + fn model_vocab_size(&self) -> usize { + usize::MAX + } + + /// Return the full tokenizer vocabulary size (Python `len(tokenizer)`). + /// Used to range-check `allowed_token_ids` and token-id prompts. + fn tokenizer_vocab_size(&self) -> usize { + self.tokenizer().vocab_size() + } } /// Shared trait-object form of [`TextBackend`]. diff --git a/rust/src/text/src/error.rs b/rust/src/text/src/error.rs index 62e8e2ae98a5..76827ae58104 100644 --- a/rust/src/text/src/error.rs +++ b/rust/src/text/src/error.rs @@ -2,6 +2,9 @@ use thiserror::Error; use vllm_engine_core_client::Error as EngineCoreError; use vllm_llm::Error as LlmError; +pub use crate::lower::logprobs::LogprobsError; +pub use crate::lower::token_ids::TokenIdsError; + #[derive(Debug, Error)] pub enum Error { #[error("tokenizer error: {0}")] @@ -13,6 +16,19 @@ pub enum Error { but the prompt contains {prompt_len} input tokens" )] PromptTooLong { max_model_len: u32, prompt_len: u32 }, + #[error(transparent)] + Logprobs(#[from] LogprobsError), + #[error(transparent)] + TokenIds(#[from] TokenIdsError), + #[error( + "`min_tokens` must be less than or equal to `max_tokens`, \ + got min_tokens={min_tokens}, max_tokens={max_tokens}" + )] + MinTokensExceedsMaxTokens { min_tokens: u32, max_tokens: u32 }, + #[error("`thinking_token_budget` must be a non-negative integer or -1 for unlimited.")] + InvalidThinkingTokenBudget, + #[error("invalid repetition detection params: {message}")] + InvalidRepetitionDetection { message: String }, #[error("text request stream `{request_id}` closed before terminal output")] StreamClosedBeforeTerminalOutput { request_id: String }, #[error(transparent)] @@ -23,6 +39,25 @@ pub enum Error { pub type Result = std::result::Result; +impl Error { + /// Whether this error represents invalid user request parameters. + pub fn is_request_validation_error(&self) -> bool { + match self { + Self::PromptTooLong { .. } + | Self::EmptyPromptTokenIds { .. } + | Self::Logprobs(_) + | Self::TokenIds(_) + | Self::MinTokensExceedsMaxTokens { .. } + | Self::InvalidThinkingTokenBudget + | Self::InvalidRepetitionDetection { .. } + // An empty tokenized prompt detected later, at request prepare + // time, surfaces through the transparent Llm wrapper. + | Self::Llm(LlmError::EmptyPromptTokenIds { .. }) => true, + _ => false, + } + } +} + impl From for Error { fn from(error: vllm_tokenizer::TokenizerError) -> Self { Self::Tokenizer(error.0) diff --git a/rust/src/text/src/lib.rs b/rust/src/text/src/lib.rs index 48828045a2db..130eaec7f425 100644 --- a/rust/src/text/src/lib.rs +++ b/rust/src/text/src/lib.rs @@ -6,8 +6,8 @@ use std::mem::take; -pub use backend::{DynTextBackend, SamplingHints, TextBackend}; -pub use error::{Error, Result}; +pub use backend::{DynTextBackend, SamplingHints, SamplingLimits, TextBackend}; +pub use error::{Error, LogprobsError, Result, TokenIdsError}; use futures::Stream; pub use lower::{ PreparedTextRequest, lower_sampling_params, lower_text_request, resolve_max_tokens, @@ -45,33 +45,33 @@ pub struct TextLlm { /// Tokenizer/model metadata backend responsible for prompt encode/decode /// and sampling hints. backend: DynTextBackend, - /// Context window size reported by the engine startup handshake, with - /// optional override from config. + /// Runtime context window size reported by the engine startup handshake. max_model_len: u32, + /// Maximum number of top log probabilities accepted by this text facade. + max_logprobs: i32, } impl TextLlm { /// Create a new text-generation facade from a shared LLM client plus a text /// backend. pub fn new(llm: Llm, backend: DynTextBackend) -> Self { - // Prefer the engine-reported max_model_len because it reflects the - // post-profiling, auto-fitted KV cache limit rather than static - // frontend metadata. + // The engine-reported value reflects the post-profiling, auto-fitted + // KV cache limit used at runtime. let max_model_len = llm.engine_core_client().max_model_len(); Self { llm, backend, max_model_len, + max_logprobs: SamplingLimits::DEFAULT_MAX_LOGPROBS, } } - /// Override the maximum model context length explicitly. - /// - /// This takes priority over both the engine-reported default and any - /// tokenizer/model metadata exposed by the backend. - pub fn with_max_model_len(mut self, max_model_len: u32) -> Self { - self.max_model_len = max_model_len; + /// Override the maximum accepted logprobs count. + pub fn with_max_logprobs(mut self, max_logprobs: Option) -> Self { + if let Some(max_logprobs) = max_logprobs { + self.max_logprobs = max_logprobs; + } self } @@ -91,6 +91,18 @@ impl TextLlm { self.backend.tokenizer() } + /// Tokenizer vocabulary size (the number of tokens the tokenizer knows), + /// used to bound `allowed_token_ids` like the Python frontend `len(tokenizer)`. + pub fn tokenizer_vocab_size(&self) -> usize { + self.backend.tokenizer_vocab_size() + } + + /// Model vocabulary size from the model config, used to bound generated + /// token IDs and logits-domain sampling controls. + pub fn model_vocab_size(&self) -> usize { + self.backend.model_vocab_size() + } + /// Tokenize if needed, lower to a generate request, and return the raw /// token stream. pub async fn generate_raw(&self, request: TextRequest) -> Result { @@ -120,6 +132,10 @@ impl TextLlm { ) -> Result<(TextRequest, GenerateOutputStream)> { request.validate()?; + if request.arrival_time.is_none() { + request.arrival_time = Some(vllm_llm::current_unix_timestamp_secs()); + } + let tokenizer = self.backend.tokenizer(); let prompt_token_ids = match take(&mut request.prompt) { Prompt::Text(text) => tokenizer.encode(&text, request.add_special_tokens)?, @@ -128,17 +144,35 @@ impl TextLlm { Prompt::TokenIds(token_ids) => token_ids, }; - let mut sampling_hints = self.backend.sampling_hints()?; - sampling_hints.max_model_len = Some(self.max_model_len); + let sampling_hints = self.backend.sampling_hints()?; + let sampling_limits = SamplingLimits { + max_model_len: self.max_model_len, + max_logprobs: self.max_logprobs, + model_vocab_size: self.backend.model_vocab_size(), + tokenizer_vocab_size: self.backend.tokenizer_vocab_size(), + }; + let PreparedTextRequest { text_request, generate_request, - } = lower_text_request(request, prompt_token_ids, sampling_hints, &*tokenizer)?; + } = lower_text_request( + request, + prompt_token_ids, + sampling_hints, + sampling_limits, + &*tokenizer, + )?; let raw_stream = self.llm.generate(generate_request).await?; Ok((text_request, raw_stream)) } + /// Abort in-flight requests by their external (user-supplied) request ids. + pub async fn abort(&self, external_ids: &[String]) -> Result<()> { + self.llm.abort(external_ids).await?; + Ok(()) + } + /// Shut down the underlying LLM client and its background tasks. pub async fn shutdown(self) -> Result<()> { self.llm.shutdown().await?; diff --git a/rust/src/text/src/lower.rs b/rust/src/text/src/lower.rs index d661c99606b8..164d2a3db06c 100644 --- a/rust/src/text/src/lower.rs +++ b/rust/src/text/src/lower.rs @@ -1,17 +1,24 @@ use std::collections::BTreeSet; -use vllm_engine_core_client::protocol::EngineCoreSamplingParams; +pub(crate) mod logprobs; +pub(crate) mod token_ids; + +use logprobs::validate_logprobs; +use token_ids::{validate_prompt_token_ids, validate_vocab_range}; +use vllm_engine_core_client::protocol::sampling::{ + EngineCoreSamplingParams, RepetitionDetectionParams, +}; use vllm_llm::GenerateRequest; use vllm_tokenizer::Tokenizer; -use crate::backend::SamplingHints; +use crate::backend::{SamplingHints, SamplingLimits}; use crate::error::{Error, Result}; use crate::request::{SamplingParams, TextRequest}; /// One text request after it has been lowered into the raw generate boundary. #[derive(Debug)] pub struct PreparedTextRequest { - /// The original high-level request, preserved for response-side metadata + /// The high-level request fields still needed for response-side metadata /// and decoding options. pub text_request: TextRequest, /// The southbound request ready to be sent to `vllm-llm`. @@ -21,30 +28,36 @@ pub struct PreparedTextRequest { /// Convert a high-level [`TextRequest`] into one lower-level /// [`GenerateRequest`] ready for the `llm` crate. pub fn lower_text_request( - request: TextRequest, + mut request: TextRequest, prompt_token_ids: Vec, sampling_hints: SamplingHints, + sampling_limits: SamplingLimits, tokenizer: &dyn Tokenizer, ) -> Result { let prompt_len = prompt_token_ids.len() as u32; + validate_prompt_token_ids(&prompt_token_ids, &sampling_limits)?; + let generate_request = GenerateRequest { request_id: request.request_id.clone(), prompt_token_ids, - mm_features: request.mm_features.clone(), + // Align with Python's response path: decoded output state does not retain + // `mm_features`; move them to the engine request to avoid cloning large + // multimodal tensor payloads. + mm_features: request.mm_features.take(), sampling_params: lower_sampling_params( request.sampling_params.clone(), sampling_hints, + sampling_limits, prompt_len, tokenizer, )?, cache_salt: request.cache_salt.clone(), priority: request.priority, data_parallel_rank: request.data_parallel_rank, + reasoning_parser_kwargs: request.reasoning_parser_kwargs.clone(), lora_request: request.lora_request.clone(), - // Fields below are currently placeholders. - arrival_time: None, + arrival_time: request.arrival_time, trace_headers: None, - reasoning_ended: None, }; Ok(PreparedTextRequest { @@ -66,8 +79,8 @@ pub fn lower_sampling_params( default_min_p, default_repetition_penalty, default_max_tokens, - max_model_len, }: SamplingHints, + sampling_limits: SamplingLimits, prompt_len: u32, tokenizer: &dyn Tokenizer, ) -> Result { @@ -78,12 +91,14 @@ pub fn lower_sampling_params( seed, max_tokens, min_tokens, + thinking_token_budget, logprobs, prompt_logprobs, min_p, frequency_penalty, presence_penalty, repetition_penalty, + repetition_detection, stop_token_ids, ignore_eos, logit_bias, @@ -95,6 +110,14 @@ pub fn lower_sampling_params( vllm_xargs, } = sampling_params; + validate_logprobs( + logprobs, + prompt_logprobs, + logprob_token_ids.as_deref(), + sampling_limits, + )?; + validate_repetition_detection(repetition_detection.as_ref())?; + // Mirrors the model-generation-config inheritance used by vLLM's OpenAI chat // path: https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/entrypoints/openai/chat_completion/protocol.py#L424-L450 // If neither the caller nor the model provides a value, fall back to 1.0 — the @@ -105,8 +128,20 @@ pub fn lower_sampling_params( let top_k = top_k.or(default_top_k).unwrap_or(0); let min_p = min_p.or(default_min_p).unwrap_or(0.0); let repetition_penalty = repetition_penalty.or(default_repetition_penalty).unwrap_or(1.0); - let max_tokens = resolve_max_tokens(max_tokens, default_max_tokens, max_model_len, prompt_len)?; + let max_tokens = resolve_max_tokens( + max_tokens, + default_max_tokens, + sampling_limits.max_model_len, + prompt_len, + )?; let min_tokens = min_tokens.unwrap_or(0); + if min_tokens > max_tokens { + return Err(Error::MinTokensExceedsMaxTokens { + min_tokens, + max_tokens, + }); + } + let thinking_token_budget = normalize_thinking_token_budget(thinking_token_budget)?; let frequency_penalty = frequency_penalty.unwrap_or(0.0); let presence_penalty = presence_penalty.unwrap_or(0.0); @@ -121,30 +156,77 @@ pub fn lower_sampling_params( merge_unique_token_ids(&mut stop_token_ids, extra_eos_token_ids.iter().copied()); } - Ok(EngineCoreSamplingParams { + let params = EngineCoreSamplingParams { temperature, top_p, top_k, seed, max_tokens, min_tokens, + thinking_token_budget, logprobs, prompt_logprobs, min_p, frequency_penalty, presence_penalty, repetition_penalty, + repetition_detection: repetition_detection.filter(|p| !p.is_disabled()), stop_token_ids, eos_token_id: (!ignore_eos).then_some(primary_eos_token_id).flatten(), all_stop_token_ids, logit_bias, allowed_token_ids, bad_words_token_ids: tokenize_bad_words(bad_words.as_deref(), tokenizer)?, + // TODO: Validate structured-output schemas and regexes before submitting requests to engine-core. structured_outputs, logprob_token_ids, skip_reading_prefix_cache, extra_args: vllm_xargs, - }) + }; + validate_vocab_range(¶ms, &sampling_limits)?; + Ok(params) +} + +/// Normalize the user-facing `thinking_token_budget` into the engine value. +/// +/// Mirrors Python's `validate_thinking_token_budget` +/// (): +/// `None` and the `-1` "unlimited" sentinel both map to `None`; any other +/// negative value is rejected; non-negative values pass through unchanged. Like +/// Python's `int`, no upper bound is imposed. +fn normalize_thinking_token_budget(value: Option) -> Result> { + match value { + None | Some(-1) => Ok(None), + Some(budget) if budget >= 0 => Ok(Some(budget as u64)), + Some(_) => Err(Error::InvalidThinkingTokenBudget), + } +} + +fn validate_repetition_detection(params: Option<&RepetitionDetectionParams>) -> Result<()> { + let Some(params) = params else { + return Ok(()); + }; + + if params.min_pattern_size > params.max_pattern_size { + return Err(Error::InvalidRepetitionDetection { + message: format!( + "`min_pattern_size` must be less than or equal to \ + `max_pattern_size`, got min_pattern_size={}, \ + max_pattern_size={}", + params.min_pattern_size, params.max_pattern_size + ), + }); + } + if params.max_pattern_size > 0 && params.min_count < 2 { + return Err(Error::InvalidRepetitionDetection { + message: format!( + "`min_count` must be at least 2, got min_count={}", + params.min_count + ), + }); + } + + Ok(()) } /// Convert bad-word strings into token-ID sequences, following the Python vLLM @@ -189,33 +271,25 @@ fn tokenize_bad_words( /// Resolve the effective `max_tokens` for generation, mirroring vLLM Python's /// `get_max_tokens()` in `vllm/entrypoints/utils.py`. /// -/// Takes the minimum of all available limits (user-specified, generation-config -/// default, and `max_model_len - prompt_len`). When nothing is known, falls -/// back to `u32::MAX` so the engine-core can apply its own context-window -/// limit. +/// Takes the minimum of all available limits: user-specified, generation-config +/// default, and `max_model_len - prompt_len`. pub fn resolve_max_tokens( user_max_tokens: Option, default_max_tokens: Option, - max_model_len: Option, + max_model_len: u32, prompt_len: u32, ) -> Result { - let model_max_tokens = match max_model_len { - Some(max_model_len) if prompt_len >= max_model_len => { - return Err(Error::PromptTooLong { - max_model_len, - prompt_len, - }); - } - Some(max_model_len) => Some(max_model_len - prompt_len), - None => None, + let model_max_tokens = if prompt_len >= max_model_len { + return Err(Error::PromptTooLong { + max_model_len, + prompt_len, + }); + } else { + max_model_len - prompt_len }; - let fallback_max_tokens = user_max_tokens.or(default_max_tokens); - Ok([fallback_max_tokens, model_max_tokens] - .into_iter() - .flatten() - .min() - .unwrap_or(u32::MAX /* TODO: a reasonable fallback? */)) + let request_max_tokens = user_max_tokens.or(default_max_tokens); + Ok(request_max_tokens.map_or(model_max_tokens, |n| n.min(model_max_tokens))) } fn merge_unique_token_ids( @@ -233,43 +307,20 @@ fn merge_unique_token_ids( #[cfg(test)] mod tests { - use std::collections::BTreeSet; + use std::collections::{BTreeSet, HashMap}; use serial_test::file_serial; + use vllm_engine_core_client::protocol::multimodal::{MmFeatureSpec, PlaceholderRange}; + use vllm_tokenizer::test_utils::TestTokenizer; use super::*; use crate::backend::hf::HfTextBackend; use crate::backend::{SamplingHints, TextBackend as _}; + use crate::error::{LogprobsError, TokenIdsError}; use crate::request::{Prompt, TextRequest}; - /// Stub tokenizer that returns empty token IDs — sufficient for tests that - /// don't exercise bad-words tokenization. - struct StubTokenizer; - - impl Tokenizer for StubTokenizer { - fn encode( - &self, - _text: &str, - _add_special_tokens: bool, - ) -> vllm_tokenizer::Result> { - Ok(vec![]) - } - - fn decode( - &self, - _token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - Ok(String::new()) - } - - fn token_to_id(&self, _token: &str) -> Option { - None - } - } - - fn stub_tokenizer() -> StubTokenizer { - StubTokenizer + fn stub_tokenizer() -> TestTokenizer { + TestTokenizer::new() } fn sample_request() -> TextRequest { @@ -290,16 +341,151 @@ mod tests { default_min_p: None, default_repetition_penalty: None, default_max_tokens: None, - max_model_len: None, } } + fn sample_sampling_limits() -> SamplingLimits { + SamplingLimits { + max_model_len: 1_000_000, + max_logprobs: SamplingLimits::DEFAULT_MAX_LOGPROBS, + model_vocab_size: 1000, + tokenizer_vocab_size: 2000, + } + } + + fn lower_sampling_params_with_limits( + sampling_params: SamplingParams, + sampling_limits: SamplingLimits, + ) -> Result { + lower_sampling_params( + sampling_params, + SamplingHints { + primary_eos_token_id: None, + extra_eos_token_ids: BTreeSet::new(), + default_temperature: None, + default_top_p: None, + default_top_k: None, + default_min_p: None, + default_repetition_penalty: None, + default_max_tokens: None, + }, + sampling_limits, + 3, + &stub_tokenizer(), + ) + } + + #[test] + fn lower_sampling_params_normalizes_thinking_token_budget() { + let lower = |budget: Option| { + lower_sampling_params_with_limits( + SamplingParams { + thinking_token_budget: budget, + ..SamplingParams::default() + }, + sample_sampling_limits(), + ) + }; + + // Non-negative budgets (including 0) pass through unchanged. + assert_eq!(lower(Some(256)).unwrap().thinking_token_budget, Some(256)); + assert_eq!(lower(Some(0)).unwrap().thinking_token_budget, Some(0)); + // `None` and the `-1` "unlimited" sentinel both disable the budget. + assert_eq!(lower(None).unwrap().thinking_token_budget, None); + assert_eq!(lower(Some(-1)).unwrap().thinking_token_budget, None); + // No upper bound is imposed, matching Python's `int`. + assert_eq!( + lower(Some(i64::from(u32::MAX) + 1)).unwrap().thinking_token_budget, + Some(u64::from(u32::MAX) + 1) + ); + // Other negatives are rejected. + assert!(matches!( + lower(Some(-2)), + Err(Error::InvalidThinkingTokenBudget) + )); + } + + #[test] + fn lower_sampling_params_rejects_min_tokens_above_resolved_max_tokens() { + let error = lower_sampling_params_with_limits( + SamplingParams { + max_tokens: Some(4), + min_tokens: Some(5), + ..SamplingParams::default() + }, + sample_sampling_limits(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::MinTokensExceedsMaxTokens { + min_tokens: 5, + max_tokens: 4, + } + )); + } + + #[test] + fn lower_sampling_params_validates_repetition_detection() { + let lower = |repetition_detection| { + lower_sampling_params_with_limits( + SamplingParams { + repetition_detection, + ..SamplingParams::default() + }, + sample_sampling_limits(), + ) + }; + + let enabled = RepetitionDetectionParams { + max_pattern_size: 4, + min_pattern_size: 2, + min_count: 2, + }; + assert_eq!( + lower(Some(enabled.clone())).unwrap().repetition_detection, + Some(enabled) + ); + + let disabled = RepetitionDetectionParams { + max_pattern_size: 0, + min_pattern_size: 0, + min_count: 0, + }; + assert_eq!(lower(Some(disabled)).unwrap().repetition_detection, None); + + let error = lower(Some(RepetitionDetectionParams { + max_pattern_size: 1, + min_pattern_size: 2, + min_count: 2, + })) + .unwrap_err(); + let Error::InvalidRepetitionDetection { message } = error else { + panic!("expected repetition_detection validation error"); + }; + assert!(message.contains("min_pattern_size=2")); + assert!(message.contains("max_pattern_size=1")); + + let error = lower(Some(RepetitionDetectionParams { + max_pattern_size: 1, + min_pattern_size: 1, + min_count: 1, + })) + .unwrap_err(); + let Error::InvalidRepetitionDetection { message } = error else { + panic!("expected repetition_detection validation error"); + }; + assert!(message.contains("min_count=1")); + } + #[test] fn lower_text_request_applies_python_style_eos_hints() { let prepared = lower_text_request( sample_request(), vec![1, 2, 3], sample_sampling_hints(), + sample_sampling_limits(), &stub_tokenizer(), ) .unwrap(); @@ -311,14 +497,16 @@ mod tests { top_p: 1.0, top_k: 0, seed: None, - max_tokens: 4294967295, + max_tokens: 999997, min_tokens: 0, + thinking_token_budget: None, logprobs: None, prompt_logprobs: None, min_p: 0.0, frequency_penalty: 0.0, presence_penalty: 0.0, repetition_penalty: 1.0, + repetition_detection: None, stop_token_ids: [ 77, ], @@ -350,6 +538,7 @@ mod tests { request, vec![1, 2, 3], sample_sampling_hints(), + sample_sampling_limits(), &stub_tokenizer(), ) .unwrap(); @@ -361,14 +550,16 @@ mod tests { top_p: 1.0, top_k: 0, seed: None, - max_tokens: 4294967295, + max_tokens: 999997, min_tokens: 0, + thinking_token_budget: None, logprobs: None, prompt_logprobs: None, min_p: 0.0, frequency_penalty: 0.0, presence_penalty: 0.0, repetition_penalty: 1.0, + repetition_detection: None, stop_token_ids: [], eos_token_id: None, all_stop_token_ids: { @@ -387,6 +578,86 @@ mod tests { .assert_debug_eq(¶ms); } + #[test] + fn lower_text_request_moves_multimodal_features_to_generate_request() { + let features = vec![MmFeatureSpec { + data: None, + modality: "image".to_string(), + identifier: "image-1".to_string(), + mm_position: PlaceholderRange { + offset: 2, + length: 4, + is_embed: None, + }, + mm_hash: Some("hash-1".to_string()), + }]; + let mut request = sample_request(); + request.mm_features = Some(features.clone()); + + let prepared = lower_text_request( + request, + vec![1, 2, 3], + sample_sampling_hints(), + sample_sampling_limits(), + &stub_tokenizer(), + ) + .unwrap(); + + assert_eq!(prepared.generate_request.mm_features, Some(features)); + assert_eq!(prepared.text_request.mm_features, None); + } + + #[test] + fn lower_text_request_uses_union_vocab_for_prompt_token_ids() { + lower_text_request( + sample_request(), + vec![1500], + sample_sampling_hints(), + SamplingLimits { + model_vocab_size: 2000, + tokenizer_vocab_size: 1000, + ..sample_sampling_limits() + }, + &stub_tokenizer(), + ) + .expect("model vocab extends prompt token range"); + + lower_text_request( + sample_request(), + vec![1500], + sample_sampling_hints(), + SamplingLimits { + model_vocab_size: 1000, + tokenizer_vocab_size: 2000, + ..sample_sampling_limits() + }, + &stub_tokenizer(), + ) + .expect("tokenizer vocab extends prompt token range"); + + let error = lower_text_request( + sample_request(), + vec![2000], + sample_sampling_hints(), + SamplingLimits { + model_vocab_size: 1000, + tokenizer_vocab_size: 2000, + ..sample_sampling_limits() + }, + &stub_tokenizer(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::TokenIds(TokenIdsError::OutOfVocab { + parameter: "prompt", + token_ids, + vocab_size: 2000, + }) if token_ids == vec![2000] + )); + } + #[tokio::test] #[file_serial(hf_qwen3)] async fn lower_text_request_uses_real_qwen_generation_defaults() { @@ -415,16 +686,23 @@ mod tests { default_min_p: None, default_repetition_penalty: None, default_max_tokens: None, - max_model_len: Some( - 40960, - ), } "#]] .assert_debug_eq(&hints); - let prepared = - lower_text_request(sample_request(), vec![1, 2, 3], hints, &stub_tokenizer()) - .expect("lower request"); + let prepared = lower_text_request( + sample_request(), + vec![1, 2, 3], + hints, + SamplingLimits { + max_model_len: 40960, + max_logprobs: SamplingLimits::DEFAULT_MAX_LOGPROBS, + model_vocab_size: backend.model_vocab_size(), + tokenizer_vocab_size: backend.tokenizer_vocab_size(), + }, + &stub_tokenizer(), + ) + .expect("lower request"); let params = prepared.generate_request.sampling_params; expect_test::expect![[r#" @@ -435,12 +713,14 @@ mod tests { seed: None, max_tokens: 40957, min_tokens: 0, + thinking_token_budget: None, logprobs: None, prompt_logprobs: None, min_p: 0.0, frequency_penalty: 0.0, presence_penalty: 0.0, repetition_penalty: 1.0, + repetition_detection: None, stop_token_ids: [ 151643, ], @@ -481,8 +761,8 @@ mod tests { default_min_p: None, default_repetition_penalty: None, default_max_tokens: None, - max_model_len: None, }, + sample_sampling_limits(), 3, &stub_tokenizer(), ) @@ -494,14 +774,16 @@ mod tests { top_p: 1.0, top_k: 0, seed: None, - max_tokens: 4294967295, + max_tokens: 999997, min_tokens: 0, + thinking_token_budget: None, logprobs: None, prompt_logprobs: None, min_p: 0.0, frequency_penalty: 0.0, presence_penalty: 0.0, repetition_penalty: 1.0, + repetition_detection: None, stop_token_ids: [ 11, 77, @@ -550,8 +832,8 @@ mod tests { default_min_p: Some(0.1), default_repetition_penalty: Some(1.2), default_max_tokens: Some(128), - max_model_len: None, }, + sample_sampling_limits(), 3, &stub_tokenizer(), ) @@ -565,12 +847,14 @@ mod tests { seed: None, max_tokens: 32, min_tokens: 2, + thinking_token_budget: None, logprobs: None, prompt_logprobs: None, min_p: 0.1, frequency_penalty: 0.0, presence_penalty: 0.0, repetition_penalty: 1.2, + repetition_detection: None, stop_token_ids: [], eos_token_id: None, all_stop_token_ids: {}, @@ -605,7 +889,10 @@ mod tests { default_min_p: None, default_repetition_penalty: None, default_max_tokens: None, - max_model_len: None, + }, + SamplingLimits { + max_logprobs: -1, + ..sample_sampling_limits() }, 3, &stub_tokenizer(), @@ -616,6 +903,171 @@ mod tests { assert_eq!(params.prompt_logprobs, Some(-1)); } + #[test] + fn lower_sampling_params_rejects_full_vocab_logprobs_over_default_cap() { + let error = lower_sampling_params_with_limits( + SamplingParams { + logprobs: Some(-1), + ..Default::default() + }, + sample_sampling_limits(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::Logprobs(LogprobsError::TooManyCount { + parameter: "logprobs", + requested: 1000, + max_allowed: 20, + }) + )); + } + + #[test] + fn lower_sampling_params_expands_full_vocab_logprobs_from_model_vocab() { + let params = lower_sampling_params_with_limits( + SamplingParams { + logprobs: Some(-1), + ..Default::default() + }, + SamplingLimits { + max_logprobs: 1500, + ..sample_sampling_limits() + }, + ) + .unwrap(); + + assert_eq!(params.logprobs, Some(-1)); + } + + #[test] + fn lower_sampling_params_rejects_invalid_logprob_token_ids() { + let error = lower_sampling_params_with_limits( + SamplingParams { + logprobs: Some(1), + logprob_token_ids: Some(vec![1000]), + ..Default::default() + }, + sample_sampling_limits(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::TokenIds(TokenIdsError::OutOfVocab { + parameter: "logprob_token_ids", + token_ids, + vocab_size: 1000, + }) if token_ids == vec![1000] + )); + } + + #[test] + fn lower_sampling_params_rejects_out_of_vocab_stop_token_ids() { + let error = lower_sampling_params_with_limits( + SamplingParams { + stop_token_ids: Some(vec![999, 1000]), + ..Default::default() + }, + sample_sampling_limits(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::TokenIds(TokenIdsError::OutOfVocab { + parameter: "stop_token_ids", + token_ids, + vocab_size: 1000, + }) if token_ids == vec![1000] + )); + } + + #[test] + fn lower_sampling_params_rejects_out_of_vocab_allowed_token_ids() { + let error = lower_sampling_params_with_limits( + SamplingParams { + allowed_token_ids: Some(vec![1999, 2000]), + ..Default::default() + }, + sample_sampling_limits(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::TokenIds(TokenIdsError::OutOfVocab { + parameter: "allowed_token_ids", + token_ids, + vocab_size: 2000, + }) if token_ids == vec![2000] + )); + } + + #[test] + fn lower_sampling_params_rejects_empty_allowed_token_ids() { + let error = lower_sampling_params_with_limits( + SamplingParams { + allowed_token_ids: Some(vec![]), + ..Default::default() + }, + sample_sampling_limits(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::TokenIds(TokenIdsError::EmptyAllowedTokenIds) + )); + } + + #[test] + fn lower_sampling_params_rejects_out_of_vocab_bad_words() { + let tokenizer = TestTokenizer::new().with_regular_token("blocked", 2000); + let error = lower_sampling_params( + SamplingParams { + bad_words: Some(vec!["blocked".to_string()]), + ..Default::default() + }, + SamplingHints::default(), + sample_sampling_limits(), + 3, + &tokenizer, + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::TokenIds(TokenIdsError::OutOfVocab { + parameter: "bad_words", + token_ids, + vocab_size: 2000, + }) if token_ids == vec![2000] + )); + } + + #[test] + fn lower_sampling_params_rejects_out_of_vocab_logit_bias() { + let error = lower_sampling_params_with_limits( + SamplingParams { + logit_bias: Some(HashMap::from([(1000, 1.0)])), + ..Default::default() + }, + sample_sampling_limits(), + ) + .unwrap_err(); + + assert!(matches!( + error, + Error::TokenIds(TokenIdsError::OutOfVocab { + parameter: "logit_bias", + token_ids, + vocab_size: 1000, + }) if token_ids == vec![1000] + )); + } + #[test] fn lower_sampling_params_uses_generation_defaults_when_user_omits_values() { let params = lower_sampling_params( @@ -629,8 +1081,8 @@ mod tests { default_min_p: Some(0.1), default_repetition_penalty: Some(1.2), default_max_tokens: Some(128), - max_model_len: None, }, + sample_sampling_limits(), 3, &stub_tokenizer(), ) @@ -644,12 +1096,14 @@ mod tests { seed: None, max_tokens: 128, min_tokens: 0, + thinking_token_budget: None, logprobs: None, prompt_logprobs: None, min_p: 0.1, frequency_penalty: 0.0, presence_penalty: 0.0, repetition_penalty: 1.2, + repetition_detection: None, stop_token_ids: [], eos_token_id: None, all_stop_token_ids: {}, @@ -667,7 +1121,7 @@ mod tests { #[test] fn resolve_max_tokens_caps_by_model_len() { - let result = resolve_max_tokens(Some(150), None, Some(200), 100); + let result = resolve_max_tokens(Some(150), None, 200, 100); assert_eq!(result.unwrap(), 100); } @@ -680,6 +1134,7 @@ mod tests { request, vec![1, 2, 3], sample_sampling_hints(), + sample_sampling_limits(), &stub_tokenizer(), ) .unwrap(); @@ -688,39 +1143,71 @@ mod tests { assert_eq!(prepared.generate_request.request_id, "text-1"); } + #[test] + fn lower_text_request_passes_arrival_time_through() { + let request = TextRequest { + arrival_time: Some(42.5), + ..sample_request() + }; + + let prepared = lower_text_request( + request, + vec![1, 2, 3], + sample_sampling_hints(), + sample_sampling_limits(), + &stub_tokenizer(), + ) + .unwrap(); + + assert_eq!(prepared.generate_request.arrival_time, Some(42.5)); + } + + #[test] + fn lower_text_request_leaves_arrival_time_unset_when_absent() { + let request = TextRequest { + arrival_time: None, + ..sample_request() + }; + + let prepared = lower_text_request( + request, + vec![1, 2, 3], + sample_sampling_hints(), + sample_sampling_limits(), + &stub_tokenizer(), + ) + .unwrap(); + + assert_eq!(prepared.generate_request.arrival_time, None); + } + #[test] fn resolve_max_tokens_user_smaller_than_model_limit() { - let result = resolve_max_tokens(Some(50), None, Some(200), 100); + let result = resolve_max_tokens(Some(50), None, 200, 100); assert_eq!(result.unwrap(), 50); } #[test] fn resolve_max_tokens_uses_default_when_user_omits() { - let result = resolve_max_tokens(None, Some(64), Some(200), 100); + let result = resolve_max_tokens(None, Some(64), 200, 100); assert_eq!(result.unwrap(), 64); } #[test] fn resolve_max_tokens_default_capped_by_model_len() { - let result = resolve_max_tokens(None, Some(256), Some(200), 100); + let result = resolve_max_tokens(None, Some(256), 200, 100); assert_eq!(result.unwrap(), 100); } #[test] - fn resolve_max_tokens_no_model_len_falls_back() { - let result = resolve_max_tokens(Some(9999), None, None, 100); - assert_eq!(result.unwrap(), 9999); - } - - #[test] - fn resolve_max_tokens_no_limits_known_falls_back_to_u32_max() { - let result = resolve_max_tokens(None, None, None, 100); - assert_eq!(result.unwrap(), u32::MAX); + fn resolve_max_tokens_uses_model_limit_when_user_omits() { + let result = resolve_max_tokens(None, None, 200, 100); + assert_eq!(result.unwrap(), 100); } #[test] fn resolve_max_tokens_prompt_too_long() { - let result = resolve_max_tokens(Some(10), None, Some(100), 100); + let result = resolve_max_tokens(Some(10), None, 100, 100); assert!(matches!( result, Err(Error::PromptTooLong { @@ -732,7 +1219,7 @@ mod tests { #[test] fn resolve_max_tokens_prompt_exceeds_model_len() { - let result = resolve_max_tokens(Some(10), None, Some(100), 200); + let result = resolve_max_tokens(Some(10), None, 100, 200); assert!(matches!( result, Err(Error::PromptTooLong { diff --git a/rust/src/text/src/lower/logprobs.rs b/rust/src/text/src/lower/logprobs.rs new file mode 100644 index 000000000000..116835560ce4 --- /dev/null +++ b/rust/src/text/src/lower/logprobs.rs @@ -0,0 +1,113 @@ +//! Python-compatible validation for logprobs sampling params. +//! +//! `-1` is expanded only for bounds checks. The original request values are +//! passed through to engine-core. + +use thiserror::Error; + +use crate::backend::SamplingLimits; + +#[derive(Debug, Error)] +pub enum LogprobsError { + #[error("{parameter} must be non-negative or -1, got {value}")] + InvalidCount { parameter: &'static str, value: i32 }, + #[error( + "requested {parameter} of {requested}, which is greater than max allowed: {max_allowed}" + )] + TooManyCount { + parameter: &'static str, + requested: usize, + max_allowed: usize, + }, + #[error( + "requested logprob_token_ids of length {requested}, \ + which is greater than max allowed: {max_allowed}" + )] + TooManyTokenIds { + requested: usize, + max_allowed: usize, + }, + #[error( + "when both logprobs and logprob_token_ids are set, logprobs must equal \ + len(logprob_token_ids). Got logprobs={logprobs}, len(logprob_token_ids)={num_token_ids}." + )] + TokenIdsMismatch { logprobs: i32, num_token_ids: usize }, +} + +/// Validate logprobs count sampling parameters. +pub(super) fn validate_logprobs( + logprobs: Option, + prompt_logprobs: Option, + logprob_token_ids: Option<&[u32]>, + sampling_limits: SamplingLimits, +) -> Result<(), LogprobsError> { + let vocab_size = sampling_limits.model_vocab_size; + let max_logprobs = + normalize_logprobs_count(sampling_limits.max_logprobs, vocab_size, "max_logprobs")?; + + validate_logprobs_count(logprobs, max_logprobs, vocab_size, "logprobs")?; + validate_logprobs_count(prompt_logprobs, max_logprobs, vocab_size, "prompt_logprobs")?; + validate_logprob_token_ids(logprobs, logprob_token_ids) +} + +fn validate_logprobs_count( + requested: Option, + max_logprobs: usize, + vocab_size: usize, + parameter: &'static str, +) -> Result<(), LogprobsError> { + let Some(requested) = requested else { + return Ok(()); + }; + + let requested = normalize_logprobs_count(requested, vocab_size, parameter)?; + if requested > max_logprobs { + return Err(LogprobsError::TooManyCount { + parameter, + requested, + max_allowed: max_logprobs, + }); + } + + Ok(()) +} + +pub(super) fn validate_logprob_token_ids( + logprobs: Option, + logprob_token_ids: Option<&[u32]>, +) -> Result<(), LogprobsError> { + let Some(logprob_token_ids) = logprob_token_ids else { + return Ok(()); + }; + + let n = logprob_token_ids.len(); + if n > SamplingLimits::MAX_LOGPROB_TOKEN_IDS { + return Err(LogprobsError::TooManyTokenIds { + requested: n, + max_allowed: SamplingLimits::MAX_LOGPROB_TOKEN_IDS, + }); + } + + if let Some(logprobs) = logprobs + && logprobs != n as i32 + { + return Err(LogprobsError::TokenIdsMismatch { + logprobs, + num_token_ids: n, + }); + } + + Ok(()) +} + +fn normalize_logprobs_count( + value: i32, + vocab_size: usize, + parameter: &'static str, +) -> Result { + match value { + -1 => Ok(vocab_size), + value if value < 0 => Err(LogprobsError::InvalidCount { parameter, value }), + value => Ok(value as usize), + } +} diff --git a/rust/src/text/src/lower/token_ids.rs b/rust/src/text/src/lower/token_ids.rs new file mode 100644 index 000000000000..ad37d864904e --- /dev/null +++ b/rust/src/text/src/lower/token_ids.rs @@ -0,0 +1,123 @@ +use std::result::Result; + +use thiserror::Error; +use vllm_engine_core_client::protocol::sampling::EngineCoreSamplingParams; + +use crate::SamplingLimits; + +#[derive(Debug, Error)] +pub enum TokenIdsError { + #[error("allowed_token_ids should not be empty")] + EmptyAllowedTokenIds, + #[error( + "token_id(s) {token_ids:?} in {parameter} are out of vocabulary. \ + Vocabulary size: {vocab_size}" + )] + OutOfVocab { + parameter: &'static str, + token_ids: Vec, + vocab_size: usize, + }, +} + +fn validate_param( + parameter: &'static str, + token_ids: impl IntoIterator, + vocab_size: usize, +) -> Result<(), TokenIdsError> { + let invalid_token_ids: Vec<_> = token_ids + .into_iter() + .filter(|&token_id| token_id as usize >= vocab_size) + .collect(); + if invalid_token_ids.is_empty() { + return Ok(()); + } + + Err(TokenIdsError::OutOfVocab { + parameter, + token_ids: invalid_token_ids, + vocab_size, + }) +} + +/// Validate that pre-tokenized prompt IDs are within the engine-visible prompt +/// vocabulary range. +pub(crate) fn validate_prompt_token_ids( + prompt_token_ids: &[u32], + limits: &SamplingLimits, +) -> Result<(), TokenIdsError> { + validate_param( + "prompt", + prompt_token_ids.iter().copied(), + limits.prompt_token_vocab_size(), + ) +} + +/// Validate that token IDs in text sampling parameters are within their +/// parameter-specific vocabulary ranges. +pub(crate) fn validate_vocab_range( + params: &EngineCoreSamplingParams, + limits: &SamplingLimits, +) -> Result<(), TokenIdsError> { + validate_param( + "stop_token_ids", + params.stop_token_ids.iter().copied(), + limits.model_vocab_size, + )?; + + if let Some(token_ids) = params.allowed_token_ids.as_deref() { + if token_ids.is_empty() { + return Err(TokenIdsError::EmptyAllowedTokenIds); + } + validate_param( + "allowed_token_ids", + token_ids.iter().copied(), + limits.tokenizer_vocab_size, + )?; + } + + if let Some(logit_bias) = params.logit_bias.as_ref() { + validate_param( + "logit_bias", + logit_bias.keys().copied(), + limits.model_vocab_size, + )?; + } + + if let Some(token_ids) = params.logprob_token_ids.as_deref() { + validate_param( + "logprob_token_ids", + token_ids.iter().copied(), + limits.model_vocab_size, + )?; + } + + if let Some(bad_words_token_ids) = params.bad_words_token_ids.as_deref() { + validate_param( + "bad_words", + bad_words_token_ids.iter().flatten().copied(), + limits.tokenizer_vocab_size, + )?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_vocab_range_rejects_out_of_vocab_ids() { + let error = validate_param("logprob_token_ids", [5_u32, 1000, 1001], 1000).unwrap_err(); + + assert!(matches!( + error, + TokenIdsError::OutOfVocab { + parameter: "logprob_token_ids", + token_ids, + vocab_size: 1000, + } if token_ids == vec![1000, 1001] + )); + } +} diff --git a/rust/src/text/src/output/decoded.rs b/rust/src/text/src/output/decoded.rs index 2ebc6f385321..a9444e459c4c 100644 --- a/rust/src/text/src/output/decoded.rs +++ b/rust/src/text/src/output/decoded.rs @@ -5,8 +5,8 @@ use futures::{Stream, StreamExt}; use serde::{Deserialize, Serialize}; use tracing::{Level, debug, trace}; use vllm_engine_core_client::AbortCause; -use vllm_engine_core_client::protocol::StopReason; -use vllm_llm::{FinishReason, GenerateOutput}; +use vllm_engine_core_client::protocol::output::StopReason; +use vllm_llm::{FinishReason, GenerateOutput, TokenUsage}; use vllm_tokenizer::{DynTokenizer, IncrementalDecoder}; use super::logprobs::{ @@ -40,8 +40,7 @@ impl Default for TextDecodeOptions { /// Terminal metadata carried on the final [`DecodedTextEvent`]. #[derive(Debug, Clone, PartialEq)] pub struct Finished { - pub prompt_token_count: usize, - pub output_token_count: usize, + pub usage: TokenUsage, pub finish_reason: FinishReason, /// Connector-specific KV transfer parameters for disaggregated serving. pub kv_transfer_params: Option, @@ -98,12 +97,14 @@ pub async fn decoded_text_event_stream( ) -> crate::Result<()> { let mut decoder: Option> = None; let mut prompt_token_count = 0_usize; + let mut cached_token_count = 0_usize; let mut token_ids = Vec::new(); let mut output_token_count: usize = 0; let mut logprobs: Option = None; while let Some(next) = raw_stream.next().await { let output = next?; + cached_token_count = cached_token_count.max(output.cached_token_count); // If it's the first output, init states and yield `Start` event. if decoder.is_none() { @@ -267,8 +268,11 @@ pub async fn decoded_text_event_stream( token_ids, logprobs, finished: Some(Finished { - prompt_token_count, - output_token_count, + usage: TokenUsage { + prompt_token_count, + output_token_count, + cached_token_count, + }, finish_reason: reason, kv_transfer_params, }), @@ -305,7 +309,7 @@ fn matches_stop_string(stops: &[String], output: &str, new_bytes: usize) -> Opti .find_map(|(ss_idx, (ss, len, start_off))| { output[start_off..] .windows(len) - .rposition(|w| w == ss) + .position(|w| w == ss) .map(|pos| (ss_idx, start_off + pos)) }) } @@ -319,37 +323,11 @@ mod tests { use futures::{Stream, stream}; use vllm_engine_core_client::AbortCause; use vllm_llm::GenerateOutput; - use vllm_tokenizer::Tokenizer; + use vllm_tokenizer::test_utils::TestTokenizer; use super::*; use crate::output::TextOutputStreamExt as _; - /// Backend that treats each token ID as a raw byte, producing lossy UTF-8. - struct ByteTokenizer; - - impl Tokenizer for ByteTokenizer { - fn encode( - &self, - _text: &str, - _add_special_tokens: bool, - ) -> vllm_tokenizer::Result> { - unreachable!() - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - let bytes = token_ids.iter().map(|id| *id as u8).collect::>(); - Ok(String::from_utf8_lossy(&bytes).into_owned()) - } - - fn token_to_id(&self, _token: &str) -> Option { - unreachable!() - } - } - /// Helper: run `decoded_text_event_stream` to completion and return the /// collected output. async fn run_to_completion( @@ -362,7 +340,7 @@ mod tests { token_ids, Some(FinishReason::Length), ))]); - let tokenizer: DynTokenizer = Arc::new(ByteTokenizer); + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new()); decoded_text_event_stream("test".into(), tokenizer, raw_stream, decode_options, false) .collect_output() .await @@ -415,7 +393,7 @@ mod tests { ))), dropped_cause: Arc::clone(&dropped_cause), }; - let tokenizer: DynTokenizer = Arc::new(ByteTokenizer); + let tokenizer: DynTokenizer = Arc::new(TestTokenizer::new()); let output = decoded_text_event_stream( "test".into(), @@ -558,6 +536,13 @@ mod tests { assert_eq!(result, Some((0, 4))); } + #[test] + fn stop_string_matches_leftmost_with_multiple_new_bytes() { + let stops = vec!["\n".to_string()]; + let result = matches_stop_string(&stops, "Answer\n\n", 2); + assert_eq!(result, Some((0, 6))); + } + #[test] fn stop_string_matches_at_beginning() { let stops = vec!["say".to_string()]; diff --git a/rust/src/text/src/output/logprobs.rs b/rust/src/text/src/output/logprobs.rs index 7024c52b779d..069a8cba2725 100644 --- a/rust/src/text/src/output/logprobs.rs +++ b/rust/src/text/src/output/logprobs.rs @@ -129,40 +129,13 @@ fn decode_position_logprobs( #[cfg(test)] mod tests { use vllm_llm::{Logprobs, PositionLogprobs, TokenLogprob}; + use vllm_tokenizer::test_utils::TestTokenizer; use super::*; - #[derive(Debug)] - struct ByteTokenizer; - - impl vllm_tokenizer::Tokenizer for ByteTokenizer { - fn encode( - &self, - _text: &str, - _add_special_tokens: bool, - ) -> vllm_tokenizer::Result> { - unreachable!() - } - - fn decode( - &self, - token_ids: &[u32], - _skip_special_tokens: bool, - ) -> vllm_tokenizer::Result { - Ok(String::from_utf8_lossy( - &token_ids.iter().map(|token_id| *token_id as u8).collect::>(), - ) - .into_owned()) - } - - fn token_to_id(&self, _token: &str) -> Option { - unreachable!() - } - } - #[test] fn decode_logprobs_decodes_every_candidate_token() { - let tokenizer = ByteTokenizer; + let tokenizer = TestTokenizer::new(); let logprobs = Logprobs { positions: vec![PositionLogprobs { entries: vec![ @@ -205,7 +178,7 @@ mod tests { #[test] fn decode_prompt_logprobs_separates_first_prompt_token() { - let tokenizer = ByteTokenizer; + let tokenizer = TestTokenizer::new(); let logprobs = Logprobs { positions: vec![PositionLogprobs { entries: vec![TokenLogprob { diff --git a/rust/src/text/src/output/mod.rs b/rust/src/text/src/output/mod.rs index 064b820d57f1..f64d1689f380 100644 --- a/rust/src/text/src/output/mod.rs +++ b/rust/src/text/src/output/mod.rs @@ -23,6 +23,7 @@ pub struct CollectedTextOutput { pub logprobs: Option, pub token_ids: Vec, pub finish_reason: FinishReason, + pub usage: vllm_llm::TokenUsage, /// Connector-specific KV transfer parameters for disaggregated serving. pub kv_transfer_params: Option, } @@ -74,6 +75,7 @@ impl T { logprobs: delta_logprobs, token_ids: delta_token_ids, finish_reason: FinishReason::Error, + usage: vllm_llm::TokenUsage::default(), kv_transfer_params: None, }) }; @@ -81,6 +83,7 @@ impl T { if let Some(finished) = finished { let mut collected = collected.unwrap(); collected.finish_reason = finished.finish_reason; + collected.usage = finished.usage; collected.kv_transfer_params = finished.kv_transfer_params; return Ok(collected); } @@ -146,8 +149,11 @@ mod tests { ], }), finished: Some(Finished { - prompt_token_count: 2, - output_token_count: 2, + usage: vllm_llm::TokenUsage { + prompt_token_count: 2, + output_token_count: 2, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), @@ -260,8 +266,11 @@ mod tests { ], }), finished: Some(Finished { - prompt_token_count: 2, - output_token_count: 5, + usage: vllm_llm::TokenUsage { + prompt_token_count: 2, + output_token_count: 5, + cached_token_count: 0, + }, finish_reason: FinishReason::stop_eos(), kv_transfer_params: None, }), diff --git a/rust/src/text/src/request.rs b/rust/src/text/src/request.rs index 1ca8f8a924a7..09522868872e 100644 --- a/rust/src/text/src/request.rs +++ b/rust/src/text/src/request.rs @@ -3,9 +3,11 @@ use std::collections::HashMap; use enum_as_inner::EnumAsInner; use serde::{Deserialize, Serialize}; use serde_json::Value; -use vllm_engine_core_client::protocol::StructuredOutputsParams; use vllm_engine_core_client::protocol::lora::LoraRequest; use vllm_engine_core_client::protocol::multimodal::MmFeatures; +use vllm_engine_core_client::protocol::request::ReasoningParserKwargs; +use vllm_engine_core_client::protocol::sampling::RepetitionDetectionParams; +use vllm_engine_core_client::protocol::structured_outputs::StructuredOutputsParams; use crate::error::{Error, Result}; use crate::output::TextDecodeOptions; @@ -56,6 +58,12 @@ pub struct SamplingParams { pub max_tokens: Option, /// Minimum number of tokens to generate before EOS or stop-token handling. pub min_tokens: Option, + /// Maximum number of reasoning ("thinking") tokens to emit before the + /// reasoning section is force-closed. `None` or the user-facing `-1` + /// "unlimited" sentinel both disable the budget. The raw value is carried + /// here; `-1` is normalized to `None` (and other negatives rejected) during + /// lowering (see `lower_sampling_params`). + pub thinking_token_budget: Option, /// Number of log probabilities to return per generated token. /// /// `None` disables sample logprobs. `-1` requests the full vocabulary. @@ -76,6 +84,9 @@ pub struct SamplingParams { /// Repetition penalty applied by the sampler. `None` means no explicit user /// override. pub repetition_penalty: Option, + /// Parameters for detecting repetitive N-gram patterns. `None` means no + /// explicit user override. + pub repetition_detection: Option, /// Explicit stop token IDs provided by the caller. `None` means no explicit /// user override. pub stop_token_ids: Option>, @@ -116,12 +127,14 @@ impl Default for SamplingParams { seed: None, max_tokens: None, min_tokens: None, + thinking_token_budget: None, logprobs: None, prompt_logprobs: None, min_p: None, frequency_penalty: None, presence_penalty: None, repetition_penalty: None, + repetition_detection: None, stop_token_ids: None, ignore_eos: false, logit_bias: None, @@ -167,9 +180,19 @@ pub struct TextRequest { /// Override data parallel rank. #[serde(default)] pub data_parallel_rank: Option, + /// Optional reasoning-parser kwargs forwarded to engine-side structured + /// output logic. + #[serde(default)] + pub reasoning_parser_kwargs: Option, /// LoRA adapter selected for this request. #[serde(default)] pub lora_request: Option, + /// Wall-clock unix timestamp (seconds) when this request arrived at the + /// frontend, stamped before render/tokenize to match Python's + /// renderer-entry arrival_time. When unset, it is stamped before + /// tokenization. + #[serde(default)] + pub arrival_time: Option, } impl TextRequest { @@ -186,7 +209,9 @@ impl TextRequest { cache_salt: None, add_special_tokens: false, data_parallel_rank: None, + reasoning_parser_kwargs: None, lora_request: None, + arrival_time: None, } } diff --git a/rust/src/tokenizer/Cargo.toml b/rust/src/tokenizer/Cargo.toml index 786c46f40316..d18b66b23162 100644 --- a/rust/src/tokenizer/Cargo.toml +++ b/rust/src/tokenizer/Cargo.toml @@ -4,6 +4,9 @@ version.workspace = true edition.workspace = true license.workspace = true +[features] +test-utils = [] + [dependencies] base64.workspace = true fastokens.workspace = true @@ -21,7 +24,9 @@ tracing.workspace = true [dev-dependencies] criterion.workspace = true hf-hub.workspace = true +reqwest.workspace = true tempfile.workspace = true +tokio.workspace = true [[bench]] name = "hf" diff --git a/rust/src/tokenizer/benches/hf.rs b/rust/src/tokenizer/benches/hf.rs index 9bf37778089c..950c4784a744 100644 --- a/rust/src/tokenizer/benches/hf.rs +++ b/rust/src/tokenizer/benches/hf.rs @@ -1,5 +1,6 @@ use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main}; -use hf_hub::api::sync::ApiBuilder; +use hf_hub::api::tokio::ApiBuilder; +use tokio::runtime::Runtime; use vllm_tokenizer::{HuggingFaceTokenizer, Tokenizer}; const MODEL_ID: &str = "Qwen/Qwen3.5-0.8B"; @@ -55,13 +56,16 @@ impl BenchFixture { } fn tokenizer_json() -> std::path::PathBuf { - ApiBuilder::from_env() - .with_progress(false) - .build() - .expect("build hf-hub api") - .model(MODEL_ID.to_string()) - .get("tokenizer.json") - .expect("fetch tokenizer.json from hf-hub") + Runtime::new().expect("build tokio runtime").block_on(async { + ApiBuilder::from_env() + .with_progress(false) + .build() + .expect("build hf-hub api") + .model(MODEL_ID.to_string()) + .get("tokenizer.json") + .await + .expect("fetch tokenizer.json from hf-hub") + }) } fn bench_encode(c: &mut Criterion) { diff --git a/rust/src/tokenizer/benches/tiktoken.rs b/rust/src/tokenizer/benches/tiktoken.rs index 54b9805f01a6..6540adf486de 100644 --- a/rust/src/tokenizer/benches/tiktoken.rs +++ b/rust/src/tokenizer/benches/tiktoken.rs @@ -1,5 +1,6 @@ use criterion::{Criterion, Throughput, black_box, criterion_group, criterion_main}; -use hf_hub::api::sync::ApiBuilder; +use hf_hub::api::tokio::ApiBuilder; +use tokio::runtime::Runtime; use vllm_tokenizer::{TiktokenTokenizer, Tokenizer}; const MODEL_ID: &str = "moonshotai/Kimi-K2.5"; @@ -52,15 +53,18 @@ impl BenchFixture { } fn tiktoken_model() -> std::path::PathBuf { - let repo = ApiBuilder::from_env() - .with_progress(false) - .build() - .expect("build hf-hub api") - .model(MODEL_ID.to_string()); - repo.get("config.json").expect("fetch config.json from hf-hub"); - repo.get("tokenizer_config.json") - .expect("fetch tokenizer_config.json from hf-hub"); - repo.get("tiktoken.model").expect("fetch tiktoken.model from hf-hub") + Runtime::new().expect("build tokio runtime").block_on(async { + let repo = ApiBuilder::from_env() + .with_progress(false) + .build() + .expect("build hf-hub api") + .model(MODEL_ID.to_string()); + repo.get("config.json").await.expect("fetch config.json from hf-hub"); + repo.get("tokenizer_config.json") + .await + .expect("fetch tokenizer_config.json from hf-hub"); + repo.get("tiktoken.model").await.expect("fetch tiktoken.model from hf-hub") + }) } fn bench_encode(c: &mut Criterion) { diff --git a/rust/src/tokenizer/src/hf.rs b/rust/src/tokenizer/src/hf.rs index 93b48545a249..2982f8c4aa4e 100644 --- a/rust/src/tokenizer/src/hf.rs +++ b/rust/src/tokenizer/src/hf.rs @@ -8,8 +8,11 @@ use tokenizers::Tokenizer as HfTokenizer; use tracing::{info, warn}; use crate::byte_level_decode::decode_byte_level; +use crate::hf::added_tokens::load_tokenizer_json_with_extra_tokens; use crate::{Result, Tokenizer}; +mod added_tokens; + enum Backend { Hf(Box), Fastokens(Box), @@ -104,7 +107,8 @@ impl HuggingFaceTokenizer { /// Load from `tokenizer.json` with `fastokens`. pub fn new_fastokens(path: &Path) -> Result { info!(path = %path.display(), "loading tokenizer with fastokens"); - let t = FastokensTokenizer::from_file(path) + let tokenizer_json = load_tokenizer_json_with_extra_tokens(path)?; + let t = FastokensTokenizer::from_json(tokenizer_json) .map_err(|error| tokenizer_error!("failed to load tokenizer: {}", error.as_report()))?; Ok(Self::from_fastokens_backend(t)) } @@ -112,7 +116,8 @@ impl HuggingFaceTokenizer { /// Load from `tokenizer.json` with Hugging Face `tokenizers`. pub fn new_hf(path: &Path) -> Result { info!(path = %path.display(), "loading tokenizer with huggingface tokenizers"); - let t = HfTokenizer::from_file(path) + let tokenizer_json = load_tokenizer_json_with_extra_tokens(path)?; + let t = serde_json::from_value::(tokenizer_json) .map_err(|error| tokenizer_error!("failed to load tokenizer: {}", error.as_report()))?; Ok(Self::from_hf_backend(t)) } @@ -169,6 +174,13 @@ impl Tokenizer for HuggingFaceTokenizer { } } + fn vocab_size(&self) -> usize { + match &self.backend { + Backend::Hf(t) => t.get_vocab_size(true), + Backend::Fastokens(t) | Backend::FastokensByteLevel(t) => t.vocab_size(), + } + } + fn id_to_token(&self, id: u32) -> Option { match &self.backend { Backend::Hf(t) => t.id_to_token(id), @@ -250,6 +262,37 @@ mod tests { assert!(wrapper.is_special_id(special_id)); } + #[test] + fn constructors_merge_extra_added_tokens_from_tokenizer_config() { + let tokenizer = tiny_bpe_tokenizer(); + + let dir = tempdir().expect("create temp dir"); + let path = dir.path().join("tokenizer.json"); + tokenizer.save(&path, false).expect("save tokenizer json"); + std::fs::write( + dir.path().join("tokenizer_config.json"), + r#"{ + "added_tokens_decoder": { + "9": { + "content": "<|image_pad|>", + "special": true, + "normalized": false + } + } + }"#, + ) + .expect("write tokenizer config"); + + for wrapper in [ + HuggingFaceTokenizer::new_fastokens(&path).expect("load fastokens wrapper"), + HuggingFaceTokenizer::new_hf(&path).expect("load hf wrapper"), + ] { + assert_eq!(wrapper.token_to_id("<|image_pad|>"), Some(9)); + assert_eq!(wrapper.id_to_token(9).as_deref(), Some("<|image_pad|>")); + assert!(wrapper.is_special_id(9)); + } + } + /// BPE tokenizer that round-trips through fastokens with a genuine /// `ByteLevel` decoder; vocab covers both GPT-2 (Ġ U+0120) and non-GPT-2 /// (| U+FF5C) codepoints. diff --git a/rust/src/tokenizer/src/hf/added_tokens.rs b/rust/src/tokenizer/src/hf/added_tokens.rs new file mode 100644 index 000000000000..ab0c01555982 --- /dev/null +++ b/rust/src/tokenizer/src/hf/added_tokens.rs @@ -0,0 +1,159 @@ +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use thiserror_ext::AsReport as _; +use tracing::warn; + +use crate::Result; + +/// Minimal `tokenizer.json` projection used to patch `added_tokens` while +/// preserving the rest of the tokenizer definition verbatim. +#[derive(Debug, Deserialize, Serialize)] +struct TokenizerJson { + #[serde(default)] + added_tokens: Vec, + #[serde(flatten)] + extra: serde_json::Map, +} + +/// Minimal `tokenizer_config.json` projection for Hugging Face's +/// `added_tokens_decoder` map. Other config keys are intentionally ignored. +#[derive(Debug, Deserialize)] +struct TokenizerConfigJson { + #[serde(default)] + added_tokens_decoder: std::collections::HashMap, +} + +/// Hugging Face added-token payload. `tokenizer.json` stores `id` inside each +/// item, while `tokenizer_config.json` stores it as the map key. +#[derive(Clone, Debug, Deserialize, Serialize)] +struct AddedTokenConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + content: String, + #[serde(default)] + single_word: bool, + #[serde(default)] + lstrip: bool, + #[serde(default)] + rstrip: bool, + #[serde(default)] + normalized: bool, + #[serde(default)] + special: bool, +} + +impl AddedTokenConfig { + /// Return this added-token payload in `tokenizer.json` shape by filling the + /// numeric token id that came from `added_tokens_decoder`'s string key. + fn with_id(mut self, id: u32) -> Self { + self.id = Some(id); + self + } +} + +/// Read `tokenizer.json`, then merge in extra added tokens from `tokenizer_config.json`. +pub(super) fn load_tokenizer_json_with_extra_tokens(path: &Path) -> Result { + let tokenizer_json = fs::read_to_string(path) + .map_err(|error| tokenizer_error!("failed to read {}: {}", path.display(), error))?; + let mut tokenizer_json: TokenizerJson = serde_json::from_str(&tokenizer_json) + .map_err(|error| tokenizer_error!("failed to parse {}: {}", path.display(), error))?; + + if let Some(parent) = path.parent() { + let config_path = parent.join("tokenizer_config.json"); + if config_path.exists() { + match load_tokenizer_config_json(&config_path) { + Ok(config_json) => merge_added_tokens_from_config(&mut tokenizer_json, config_json), + Err(error) => { + warn!( + path = %config_path.display(), + error = %error.as_report(), + "failed to load tokenizer_config.json; skipping extra added tokens" + ); + } + } + } + } + + serde_json::to_value(tokenizer_json) + .map_err(|error| tokenizer_error!("failed to serialize tokenizer json: {}", error)) +} + +/// Read and parse a sibling `tokenizer_config.json`. +fn load_tokenizer_config_json(path: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| tokenizer_error!("failed to read {}: {}", path.display(), error))?; + serde_json::from_str(&text) + .map_err(|error| tokenizer_error!("failed to parse {}: {}", path.display(), error)) +} + +/// Merge added_tokens in `tokenizer.json` and `tokenizer_config.json`. +fn merge_added_tokens_from_config( + tokenizer_json: &mut TokenizerJson, + config_json: TokenizerConfigJson, +) { + use std::collections::HashSet; + + let mut existing_ids: HashSet = + tokenizer_json.added_tokens.iter().filter_map(|token| token.id).collect(); + + let mut extra_tokens = Vec::with_capacity(config_json.added_tokens_decoder.len()); + for (id_str, token) in config_json.added_tokens_decoder { + let id = match id_str.parse::() { + Ok(id) => id, + Err(_) => continue, + }; + extra_tokens.push((id, token)); + } + extra_tokens.sort_unstable_by_key(|(id, _)| *id); + + for (id, token) in extra_tokens { + if existing_ids.contains(&id) { + continue; + } + + // Convert from decoder format to added_tokens array format by adding the "id" field. + tokenizer_json.added_tokens.push(token.with_id(id)); + existing_ids.insert(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_added_tokens_from_config_preserves_unmodeled_fields() { + let mut tokenizer_json: TokenizerJson = serde_json::from_value(serde_json::json!({ + "version": "1.0", + "added_tokens": [ + {"id": 0, "content": "", "special": true} + ], + "model": {"type": "WordLevel"} + })) + .expect("parse tokenizer json"); + + let config_json: TokenizerConfigJson = serde_json::from_value(serde_json::json!({ + "chat_template": "{{ messages }}", + "added_tokens_decoder": { + "1": { + "content": "<|image_pad|>", + "special": true, + "normalized": false + } + } + })) + .expect("parse tokenizer config"); + + merge_added_tokens_from_config(&mut tokenizer_json, config_json); + let merged = serde_json::to_value(tokenizer_json).expect("serialize tokenizer json"); + + assert_eq!(merged["version"], "1.0"); + assert_eq!(merged["model"]["type"], "WordLevel"); + assert_eq!(merged["added_tokens"][1]["id"], 1); + assert_eq!(merged["added_tokens"][1]["content"], "<|image_pad|>"); + assert_eq!(merged["added_tokens"][1]["special"], true); + assert_eq!(merged["added_tokens"][1]["normalized"], false); + } +} diff --git a/rust/src/tokenizer/src/incremental.rs b/rust/src/tokenizer/src/incremental.rs index 7a025d35e5cd..e4ed4ab26523 100644 --- a/rust/src/tokenizer/src/incremental.rs +++ b/rust/src/tokenizer/src/incremental.rs @@ -32,6 +32,7 @@ pub(crate) struct DecodeStream<'a, T: Tokenizer + ?Sized> { ids: Vec, prefix: String, prefix_index: usize, + prefix_seeded: bool, cumulative_output: String, output_index: usize, } @@ -50,6 +51,7 @@ impl<'a, T: Tokenizer + ?Sized> DecodeStream<'a, T> { ids: prompt_token_ids.to_vec(), prefix: String::new(), prefix_index: 0, + prefix_seeded: prompt_token_ids.is_empty(), cumulative_output: String::new(), output_index: 0, } @@ -63,27 +65,54 @@ const SAFE_SUFFIX_MIN: usize = 4; const SAFE_SUFFIX_MAX: usize = 6; impl DecodeStream<'_, T> { - /// Seed `self.prefix` from the shortest trailing suffix whose decoded text - /// has no U+FFFD — a clean decode means the suffix starts and ends at - /// valid UTF-8/token boundaries, so priming from it is equivalent to - /// priming from the full prompt. + /// Decode prompt-only context for prefix seeding. + /// + /// Prompt ids may come from the model vocabulary rather than the local + /// tokenizer vocabulary. For this seeding path, ids that cannot be mapped + /// back to raw token text are dropped before retrying strict decode. This + /// tolerance is intentionally limited to prompt context; generated ids are + /// decoded later through the normal strict path. + fn decode_prompt_context(&self, ids: &[u32]) -> Result<(String, Vec)> { + match self.tokenizer.decode(ids, self.skip_special_tokens) { + Ok(decoded) => Ok((decoded, ids.to_vec())), + Err(error) => { + let filtered = ids + .iter() + .copied() + .filter(|&id| self.tokenizer.id_to_token(id).is_some()) + .collect::>(); + if filtered.len() == ids.len() { + return Err(error); + } + self.tokenizer + .decode(&filtered, self.skip_special_tokens) + .map(|decoded| (decoded, filtered)) + } + } + } + + /// Seed `self.prefix` from the shortest trailing prompt suffix whose + /// filtered context is still long enough and whose decoded text has no + /// U+FFFD. A clean decode means the suffix starts and ends at valid + /// UTF-8/token boundaries, so priming from it is equivalent to priming from + /// the full prompt. fn seed_prefix(&mut self) -> Result<()> { let prompt_len = self.ids.len(); if prompt_len > SAFE_SUFFIX_MIN { let max_try = SAFE_SUFFIX_MAX.min(prompt_len - 1); for suffix_len in SAFE_SUFFIX_MIN..=max_try { let start = prompt_len - suffix_len; - let decoded = - self.tokenizer.decode(&self.ids[start..], self.skip_special_tokens)?; - if !decoded.contains('\u{FFFD}') { + let (decoded, context_ids) = self.decode_prompt_context(&self.ids[start..])?; + if !decoded.contains('\u{FFFD}') && context_ids.len() >= SAFE_SUFFIX_MIN { self.prefix = decoded; - self.ids.drain(..start); + self.ids = context_ids; self.prefix_index = self.ids.len(); return Ok(()); } } } - let decoded = self.tokenizer.decode(&self.ids, self.skip_special_tokens)?; + let (decoded, context_ids) = self.decode_prompt_context(&self.ids)?; + self.ids = context_ids; if !decoded.ends_with('\u{FFFD}') { self.prefix = decoded; self.prefix_index = self.ids.len(); @@ -94,8 +123,9 @@ impl DecodeStream<'_, T> { impl IncrementalDecoder for DecodeStream<'_, T> { fn push_token(&mut self, token_id: u32) -> Result { - if self.prefix.is_empty() && !self.ids.is_empty() { + if !self.prefix_seeded && !self.ids.is_empty() { self.seed_prefix()?; + self.prefix_seeded = true; } self.ids.push(token_id); @@ -115,6 +145,8 @@ impl IncrementalDecoder for DecodeStream<'_, T> { fn next_chunk(&mut self) -> Option { let cutoff = self.cumulative_output.len().saturating_sub(self.min_bytes_to_buffer); + // Ensure we split at a utf-8 char boundary. + let cutoff = self.cumulative_output.floor_char_boundary(cutoff); (cutoff > self.output_index).then(|| { let chunk = self.cumulative_output[self.output_index..cutoff].to_string(); self.output_index = cutoff; @@ -129,6 +161,7 @@ impl IncrementalDecoder for DecodeStream<'_, T> { self.ids.clear(); self.prefix.clear(); self.prefix_index = 0; + self.prefix_seeded = true; // Ensure we split at a utf-8 char boundary. self.cumulative_output .push_str(&string[string.floor_char_boundary(prefix_len)..]); @@ -150,6 +183,7 @@ impl IncrementalDecoder for DecodeStream<'_, T> { #[cfg(test)] mod tests { use super::*; + use crate::test_utils::TestTokenizer; /// Backend that treats each token ID as a raw byte, producing lossy UTF-8. #[derive(Debug)] @@ -168,6 +202,10 @@ mod tests { fn token_to_id(&self, _token: &str) -> Option { unreachable!() } + + fn id_to_token(&self, _id: u32) -> Option { + unreachable!() + } } #[test] @@ -246,6 +284,10 @@ mod tests { fn token_to_id(&self, _token: &str) -> Option { unreachable!() } + + fn id_to_token(&self, _id: u32) -> Option { + unreachable!() + } } #[test] @@ -271,6 +313,67 @@ mod tests { assert_eq!(decoder.output(), "!"); } + #[test] + fn prompt_context_filters_unknown_ids() { + let tokenizer = TestTokenizer::new(); + assert_eq!(tokenizer.id_to_token(10_000), None); + + let cases: &[(&str, &[u32], u32, &str)] = &[ + ( + "suffix seed", + &[ + b'a' as u32, + b'b' as u32, + b'c' as u32, + 10_000, + b'H' as u32, + b'i' as u32, + ], + b'!' as u32, + "!", + ), + ("all unknown", &[10_000], b'!' as u32, "!"), + ( + "unknown before incomplete utf-8", + &[10_000, 0xe4, 0xbd], + 0xa0, + "你", + ), + ( + "incomplete utf-8 before filtered suffix", + &[0xe4, 0xbd, 10_000, 10_001, 10_002, 10_003, 10_004, 10_005], + 0xa0, + "你", + ), + ]; + + for &(name, prompt, token_id, output) in cases { + let mut decoder = tokenizer.create_decode_stream(prompt, false, 0); + assert_eq!( + decoder.push_token(token_id).unwrap(), + output.len(), + "{name}" + ); + assert_eq!(decoder.output(), output, "{name}"); + } + } + + #[test] + fn generated_unknown_ids_still_return_decode_error() { + let tokenizer = TestTokenizer::new(); + assert_eq!(tokenizer.id_to_token(10_000), None); + + let prompt = &[10_000, b'H' as u32, b'i' as u32]; + let mut decoder = tokenizer.create_decode_stream(prompt, false, 0); + + let error = decoder.push_token(10_000).unwrap_err(); + assert!( + error + .to_string() + .contains("test tokenizer cannot decode unknown token id 10000") + ); + } + #[test] fn chunks_concatenate_to_full_text() { let backend = Utf8Backend; @@ -318,6 +421,10 @@ mod tests { fn token_to_id(&self, _token: &str) -> Option { unreachable!() } + + fn id_to_token(&self, _id: u32) -> Option { + unreachable!() + } } /// Without the char-boundary fix, this panics slicing mid-emoji. @@ -356,4 +463,27 @@ mod tests { assert_eq!(last_chunk.as_deref(), Some("lo!")); assert_eq!(full_text, "Hello!"); } + + #[test] + fn next_chunk_cutoff_respects_char_boundary() { + // Regression: next_chunk's cutoff (len - min_bytes_to_buffer) must be + // aligned to a UTF-8 char boundary like push_token/flush; otherwise + // streaming multi-byte output (CJK/emoji) with a hold-back buffer (set + // by a stop string) panics slicing cumulative_output mid-character. + let backend = Utf8Backend; + let mut decoder = backend.create_decode_stream(&[], false, 2); + let mut out = String::new(); + for byte in "你好A".bytes() { + decoder.push_token(u32::from(byte)).unwrap(); + if let Some(chunk) = decoder.next_chunk() { + out.push_str(&chunk); + } + } + let (last_chunk, full_text) = decoder.flush(None).unwrap(); + if let Some(chunk) = last_chunk { + out.push_str(&chunk); + } + assert_eq!(full_text, "你好A"); + assert_eq!(out, "你好A"); + } } diff --git a/rust/src/tokenizer/src/lib.rs b/rust/src/tokenizer/src/lib.rs index 6a512a5a620e..4f459450c613 100644 --- a/rust/src/tokenizer/src/lib.rs +++ b/rust/src/tokenizer/src/lib.rs @@ -8,6 +8,8 @@ mod error; mod hf; mod incremental; mod tekken; +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; mod tiktoken; pub use error::{Result, TokenizerError}; @@ -28,10 +30,12 @@ pub trait Tokenizer: Send + Sync { fn token_to_id(&self, token: &str) -> Option; /// Convert one token ID into the tokenizer's raw token string. - fn id_to_token(&self, _id: u32) -> Option { - // TODO: remove default impl and require this to be implemented by all - // tokenizers - None + fn id_to_token(&self, id: u32) -> Option; + + /// Return the vocabulary size. Backends that cannot report it fall back to + /// `usize::MAX`, an effectively unbounded value used only by test stubs. + fn vocab_size(&self) -> usize { + usize::MAX } /// Return whether the given token ID is special. diff --git a/rust/src/tokenizer/src/tekken.rs b/rust/src/tokenizer/src/tekken.rs index e8560c65a30b..50981efdde7b 100644 --- a/rust/src/tokenizer/src/tekken.rs +++ b/rust/src/tokenizer/src/tekken.rs @@ -56,6 +56,10 @@ impl Tokenizer for TekkenTokenizer { self.inner.id_to_piece(id).ok() } + fn vocab_size(&self) -> usize { + self.inner.vocab_size() + } + fn is_special_id(&self, token_id: u32) -> bool { self.inner.is_special_token(token_id) } diff --git a/rust/src/tokenizer/src/test_utils.rs b/rust/src/tokenizer/src/test_utils.rs new file mode 100644 index 000000000000..efc4c1171725 --- /dev/null +++ b/rust/src/tokenizer/src/test_utils.rs @@ -0,0 +1,434 @@ +use std::collections::BTreeMap; + +use crate::{Result, Tokenizer, TokenizerError}; + +const FIRST_CONFIGURED_TOKEN_ID: u32 = 256; + +/// Whether a configured test token should be treated as special. +/// +/// Special tokens are skipped by [`Tokenizer::decode`] when +/// `skip_special_tokens` is set. Regular configured tokens are always emitted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestTokenKind { + /// Token is skipped when `skip_special_tokens = true`. + Special, + /// Token is emitted regardless of `skip_special_tokens`. + Regular, +} + +impl TestTokenKind { + fn is_special(self) -> bool { + matches!(self, Self::Special) + } +} + +/// Decode behavior for token ids that are neither configured tokens nor byte ids. +/// +/// The default is [`UnknownDecode::Error`] so tests notice missing tokenizer +/// fixtures instead of silently accepting impossible ids. Individual tests can +/// opt into empty or replacement output when they are explicitly modeling a +/// lenient detokenization path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnknownDecode { + /// Return a tokenizer error on the first unknown id. + Error, + /// Drop unknown ids from decoded output. + Empty, + /// Emit U+FFFD for each unknown id. + Replacement, +} + +#[derive(Debug, Clone)] +struct TestToken { + text: String, + kind: TestTokenKind, +} + +/// Configurable tokenizer for Rust frontend tests. +/// +/// `TestTokenizer` is intentionally small, but its methods obey the same basic +/// contract as production tokenizers: +/// +/// - ordinary text encodes as UTF-8 byte ids; +/// - configured token ids start at 256, leaving `0..=255` for byte fallback; +/// - configured token ids and token text are unique; +/// - configured tokens are matched before ordinary bytes, using longest-prefix matching so +/// multi-character markers such as `` work naturally; +/// - `token_to_id` and `id_to_token` are consistent for configured tokens; +/// - `decode` is strict by default for ids outside the byte range and the configured token table; +/// - `vocab_size` is an exclusive upper bound covering byte ids and configured token ids unless a +/// test sets it explicitly. +/// +/// Prefer this helper over ad-hoc fake tokenizers for tests that rely on +/// tokenizer semantics. Keep dedicated tiny fakes for error injection or for +/// tests that deliberately need a degenerate tokenizer. +#[derive(Debug, Clone)] +pub struct TestTokenizer { + token_to_id: BTreeMap, + id_to_token: BTreeMap, + unknown_decode: UnknownDecode, + vocab_size: Option, + bos_token_id: Option, +} + +impl Default for TestTokenizer { + fn default() -> Self { + Self::new() + } +} + +impl TestTokenizer { + /// Create a byte-level test tokenizer with strict unknown-id decode. + pub fn new() -> Self { + Self { + token_to_id: BTreeMap::new(), + id_to_token: BTreeMap::new(), + unknown_decode: UnknownDecode::Error, + vocab_size: None, + bos_token_id: None, + } + } + + /// Add a configured token and return the updated tokenizer. + /// + /// Configured tokens must use ids outside the byte range and may be marked + /// special or regular. + pub fn with_token(mut self, token: impl Into, id: u32, kind: TestTokenKind) -> Self { + self.insert_token(token, id, kind); + self + } + + /// Add a special configured token and return the updated tokenizer. + pub fn with_special_token(self, token: impl Into, id: u32) -> Self { + self.with_token(token, id, TestTokenKind::Special) + } + + /// Add a regular configured token and return the updated tokenizer. + pub fn with_regular_token(self, token: impl Into, id: u32) -> Self { + self.with_token(token, id, TestTokenKind::Regular) + } + + /// Add a special BOS token inserted by `encode(..., true)`. + /// + /// This also registers the token in the normal token/id maps so + /// `token_to_id`, `id_to_token`, `decode`, and `is_special_id` stay + /// consistent for the inserted id. + pub fn with_bos_token(mut self, token: impl Into, id: u32) -> Self { + self.insert_token(token, id, TestTokenKind::Special); + self.bos_token_id = Some(id); + self + } + + /// Set decode behavior for unknown non-byte ids. + pub fn with_unknown_decode(mut self, behavior: UnknownDecode) -> Self { + self.unknown_decode = behavior; + self + } + + /// Set an explicit vocabulary size. + /// + /// Use this when a test needs a model-like vocabulary bound that differs + /// from the highest configured token id plus one. + pub fn with_vocab_size(mut self, vocab_size: usize) -> Self { + self.vocab_size = Some(vocab_size); + self + } + + fn insert_token(&mut self, token: impl Into, id: u32, kind: TestTokenKind) { + let token = token.into(); + assert!( + !token.is_empty(), + "configured test token text must be non-empty" + ); + assert!( + id >= FIRST_CONFIGURED_TOKEN_ID, + "configured test token id {id} overlaps byte fallback range 0..=255" + ); + assert!( + token.len() > 1, + "configured test token text {token:?} overlaps byte fallback token text" + ); + if self.token_to_id.insert(token.clone(), id).is_some() { + panic!("configured test token text {token:?} was registered more than once"); + } + if self.id_to_token.insert(id, TestToken { text: token, kind }).is_some() { + panic!("configured test token id {id} was registered more than once"); + } + } + + fn byte_to_token(id: u32) -> Option { + u8::try_from(id).ok().map(|byte| String::from_utf8_lossy(&[byte]).into_owned()) + } + + fn flush_bytes(bytes: &mut Vec, output: &mut String) { + if !bytes.is_empty() { + output.push_str(&String::from_utf8_lossy(bytes)); + bytes.clear(); + } + } + + fn configured_token_prefix(&self, text: &str) -> Option<(&str, u32)> { + self.token_to_id + .iter() + .filter_map(|(token, &id)| text.starts_with(token).then_some((token.as_str(), id))) + .max_by_key(|(token, _)| token.len()) + } + + fn inferred_vocab_size(&self) -> usize { + let max_configured = + self.id_to_token.last_key_value().map(|(&id, _)| id as usize + 1).unwrap_or(0); + 256.max(max_configured) + } +} + +impl Tokenizer for TestTokenizer { + fn encode(&self, text: &str, add_special_tokens: bool) -> Result> { + let mut ids = Vec::new(); + if add_special_tokens && let Some(bos_token_id) = self.bos_token_id { + ids.push(bos_token_id); + } + + let mut rest = text; + while !rest.is_empty() { + if let Some((token, id)) = self.configured_token_prefix(rest) { + ids.push(id); + rest = &rest[token.len()..]; + continue; + } + + let ch = rest.chars().next().expect("rest is not empty"); + let mut buf = [0_u8; 4]; + ids.extend(ch.encode_utf8(&mut buf).bytes().map(u32::from)); + rest = &rest[ch.len_utf8()..]; + } + + Ok(ids) + } + + fn decode(&self, token_ids: &[u32], skip_special_tokens: bool) -> Result { + let mut output = String::new(); + let mut pending_bytes = Vec::new(); + for &id in token_ids { + if let Some(token) = self.id_to_token.get(&id) { + Self::flush_bytes(&mut pending_bytes, &mut output); + if !(skip_special_tokens && token.kind.is_special()) { + output.push_str(&token.text); + } + } else if let Ok(byte) = u8::try_from(id) { + pending_bytes.push(byte); + } else { + Self::flush_bytes(&mut pending_bytes, &mut output); + match self.unknown_decode { + UnknownDecode::Error => { + return Err(TokenizerError(format!( + "test tokenizer cannot decode unknown token id {id}" + ))); + } + UnknownDecode::Empty => {} + UnknownDecode::Replacement => output.push('\u{FFFD}'), + } + } + } + Self::flush_bytes(&mut pending_bytes, &mut output); + Ok(output) + } + + fn token_to_id(&self, token: &str) -> Option { + self.token_to_id.get(token).copied().or_else(|| { + let bytes = token.as_bytes(); + (bytes.len() == 1).then(|| u32::from(bytes[0])) + }) + } + + fn id_to_token(&self, id: u32) -> Option { + self.id_to_token + .get(&id) + .map(|token| token.text.clone()) + .or_else(|| Self::byte_to_token(id)) + } + + fn vocab_size(&self) -> usize { + self.vocab_size.unwrap_or_else(|| self.inferred_vocab_size()) + } + + fn is_special_id(&self, token_id: u32) -> bool { + self.id_to_token.get(&token_id).is_some_and(|token| token.kind.is_special()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn byte_text_roundtrips_and_reports_byte_ids() { + let tokenizer = TestTokenizer::new(); + + let ids = tokenizer.encode("hi", false).unwrap(); + assert_eq!(ids, vec![b'h' as u32, b'i' as u32]); + assert_eq!(tokenizer.decode(&ids, false).unwrap(), "hi"); + assert_eq!(tokenizer.token_to_id("h"), Some(b'h' as u32)); + assert_eq!(tokenizer.id_to_token(b'h' as u32).as_deref(), Some("h")); + assert_eq!(tokenizer.vocab_size(), 256); + } + + #[test] + fn configured_tokens_use_longest_prefix_matching() { + let tokenizer = TestTokenizer::new() + .with_regular_token("", 999) + .with_regular_token("", 1000); + + assert_eq!( + tokenizer.encode("ab", false).unwrap(), + vec![b'a' as u32, 1000, b'b' as u32,] + ); + assert_eq!( + tokenizer.decode(&[b'a' as u32, 1000, b'b' as u32], false).unwrap(), + "ab" + ); + assert_eq!(tokenizer.token_to_id(""), Some(999)); + assert_eq!( + tokenizer.id_to_token(1000).as_deref(), + Some("") + ); + assert_eq!(tokenizer.vocab_size(), 1001); + } + + #[test] + fn non_ascii_text_roundtrips_through_buffered_byte_decode() { + let tokenizer = TestTokenizer::new(); + let text = "你好, café, 🚀"; + + let ids = tokenizer.encode(text, false).unwrap(); + assert_eq!( + ids, + text.as_bytes().iter().copied().map(u32::from).collect::>() + ); + assert_eq!(tokenizer.decode(&ids, false).unwrap(), text); + } + + #[test] + fn buffered_byte_decode_flushes_around_configured_tokens() { + let tokenizer = TestTokenizer::new() + .with_regular_token("", 999) + .with_special_token("", 1000); + + assert_eq!( + tokenizer.encode("你🚀", false).unwrap(), + vec![228, 189, 160, 999, 229, 165, 189, 1000, 240, 159, 154, 128] + ); + assert_eq!( + tokenizer + .decode( + &[228, 189, 160, 999, 229, 165, 189, 1000, 240, 159, 154, 128], + false + ) + .unwrap(), + "你🚀" + ); + assert_eq!( + tokenizer + .decode( + &[228, 189, 160, 999, 229, 165, 189, 1000, 240, 159, 154, 128], + true + ) + .unwrap(), + "你好🚀" + ); + } + + #[test] + fn invalid_utf8_bytes_decode_lossily_as_a_sequence() { + let tokenizer = TestTokenizer::new(); + + assert_eq!(tokenizer.decode(&[0xE4, 0xBD], false).unwrap(), "\u{FFFD}"); + assert_eq!( + tokenizer.decode(&[0xFF, b'a' as u32], false).unwrap(), + "\u{FFFD}a" + ); + } + + #[test] + fn special_tokens_respect_skip_special_tokens() { + let tokenizer = TestTokenizer::new() + .with_bos_token("", 256) + .with_special_token("", 0xF001) + .with_regular_token("", 0xF002); + + assert_eq!( + tokenizer.encode("x", true).unwrap(), + vec![256, 0xF001, b'x' as u32, 0xF002,] + ); + assert_eq!( + tokenizer.decode(&[256, 0xF001, b'x' as u32, 0xF002], false).unwrap(), + "x" + ); + assert_eq!( + tokenizer.decode(&[256, 0xF001, b'x' as u32, 0xF002], true).unwrap(), + "x" + ); + assert!(tokenizer.is_special_id(0xF001)); + assert!(!tokenizer.is_special_id(0xF002)); + } + + #[test] + #[should_panic(expected = "configured test token id 255 overlaps byte fallback range 0..=255")] + fn configured_token_id_must_stay_outside_byte_range() { + let _ = TestTokenizer::new().with_regular_token("", 255); + } + + #[test] + #[should_panic(expected = "configured test token text \"a\" overlaps byte fallback token text")] + fn configured_token_text_must_not_shadow_byte_tokens() { + let _ = TestTokenizer::new().with_regular_token("a", 256); + } + + #[test] + #[should_panic( + expected = "configured test token text \"\" was registered more than once" + )] + fn configured_token_text_must_be_unique() { + let _ = TestTokenizer::new() + .with_regular_token("", 256) + .with_regular_token("", 257); + } + + #[test] + #[should_panic(expected = "configured test token id 256 was registered more than once")] + fn configured_token_id_must_be_unique() { + let _ = TestTokenizer::new() + .with_regular_token("", 256) + .with_regular_token("", 256); + } + + #[test] + fn unknown_decode_is_strict_by_default_and_configurable() { + let strict = TestTokenizer::new(); + assert!(strict.decode(&[300], false).is_err()); + assert_eq!( + TestTokenizer::new() + .with_unknown_decode(UnknownDecode::Empty) + .decode(&[b'a' as u32, 300, b'b' as u32], false) + .unwrap(), + "ab" + ); + assert_eq!( + TestTokenizer::new() + .with_unknown_decode(UnknownDecode::Replacement) + .decode(&[300], false) + .unwrap(), + "\u{FFFD}" + ); + assert_eq!(strict.id_to_token(300), None); + } + + #[test] + fn explicit_vocab_size_overrides_inferred_bound() { + let tokenizer = TestTokenizer::new() + .with_regular_token("", 10_000) + .with_vocab_size(20_000); + + assert_eq!(tokenizer.vocab_size(), 20_000); + assert_eq!(tokenizer.id_to_token(10_000).as_deref(), Some("")); + } +} diff --git a/rust/src/tokenizer/src/tiktoken.rs b/rust/src/tokenizer/src/tiktoken.rs index 0c57ff5f6b69..9b4c17a855e0 100644 --- a/rust/src/tokenizer/src/tiktoken.rs +++ b/rust/src/tokenizer/src/tiktoken.rs @@ -503,6 +503,13 @@ impl Tokenizer for TiktokenTokenizer { fn is_special_id(&self, token_id: u32) -> bool { self.metadata.is_special_id(token_id) } + + fn vocab_size(&self) -> usize { + // Exclusive upper bound on token ids the tokenizer can decode (BPE base + // tokens plus the registered special/reserved slots), used to range-check + // `allowed_token_ids` so tiktoken models are not exempt from validation. + self.metadata.vocab_upper_bound as usize + } } /// Select the BPE regex pattern for a tiktoken model based on `config.json`. @@ -614,6 +621,17 @@ mod tests { } } + #[test] + fn tiktoken_vocab_size_reports_upper_bound() { + // The synthetic BPE file has 256 base tokens (bytes 0..=255) and ships no + // sibling config, so the constructor uses the 256-slot reserved fallback, + // giving a vocab upper bound of 512. + let (backends, _dir) = tiktoken_backends(); + for backend in backends { + assert_eq!(backend.vocab_size(), 512); + } + } + /// When `config.json` exposes a `vocab_size`, the reserved-token range must /// be sized to it rather than to the 256-slot fallback. This is the /// general (non-Kimi-specific) path: any tiktoken model whose own diff --git a/rust/src/tool-parser/src/tests.rs b/rust/src/tool-parser/src/tests.rs deleted file mode 100644 index fb9c8e62bf30..000000000000 --- a/rust/src/tool-parser/src/tests.rs +++ /dev/null @@ -1,109 +0,0 @@ -use super::{Result, Tool, ToolCallDelta, ToolParser, ToolParserOutput}; -use crate::ToolParserTestExt as _; - -struct DefaultParser; - -impl ToolParser for DefaultParser { - fn create(_tools: &[Tool]) -> Result> - where - Self: Sized + 'static, - { - Ok(Box::new(Self)) - } - - fn parse_into(&mut self, _chunk: &str, _output: &mut ToolParserOutput) -> Result<()> { - Ok(()) - } - - fn finish(&mut self) -> Result { - Ok(ToolParserOutput::default()) - } - - fn reset(&mut self) -> String { - String::new() - } -} - -#[test] -fn tool_parser_does_not_preserve_special_tokens_by_default() { - let parser = DefaultParser; - - assert!(!parser.preserve_special_tokens()); -} - -#[test] -fn default_parse_complete_delegates_through_parse_chunk_and_finish() { - struct StreamingParser; - - impl ToolParser for StreamingParser { - fn create(_tools: &[Tool]) -> Result> - where - Self: Sized + 'static, - { - Ok(Box::new(Self)) - } - - fn parse_into(&mut self, _chunk: &str, output: &mut ToolParserOutput) -> Result<()> { - output.normal_text.push_str("prefix "); - output.calls.extend([ - ToolCallDelta { - tool_index: 0, - name: Some("weather".to_string()), - arguments: "{\"location\":".to_string(), - }, - ToolCallDelta { - tool_index: 0, - name: None, - arguments: "\"Paris\"".to_string(), - }, - ToolCallDelta { - tool_index: 1, - name: Some("time".to_string()), - arguments: "{\"timezone\":".to_string(), - }, - ]); - Ok(()) - } - - fn finish(&mut self) -> Result { - Ok(ToolParserOutput { - normal_text: "suffix".to_string(), - calls: vec![ - ToolCallDelta { - tool_index: 0, - name: None, - arguments: "}".to_string(), - }, - ToolCallDelta { - tool_index: 1, - name: None, - arguments: "\"UTC\"}".to_string(), - }, - ], - }) - } - - fn reset(&mut self) -> String { - String::new() - } - } - - let mut parser = StreamingParser; - let output = parser.parse_complete("ignored").unwrap(); - assert_eq!(output.normal_text, "prefix suffix"); - assert_eq!( - output.calls, - vec![ - ToolCallDelta { - tool_index: 0, - name: Some("weather".to_string()), - arguments: "{\"location\":\"Paris\"}".to_string(), - }, - ToolCallDelta { - tool_index: 1, - name: Some("time".to_string()), - arguments: "{\"timezone\":\"UTC\"}".to_string(), - }, - ] - ); -} diff --git a/rust/src/tool-parser/src/utils.rs b/rust/src/tool-parser/src/utils.rs deleted file mode 100644 index 171c1af0eeca..000000000000 --- a/rust/src/tool-parser/src/utils.rs +++ /dev/null @@ -1,581 +0,0 @@ -//! Shared helpers for tool parsers. - -use std::borrow::Cow; - -use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue}; -use winnow::stream::{Offset, Partial, Stream}; - -use super::Result; - -/// Return the byte length of the longest proper prefix of `token` that is also -/// a suffix of `buffer`. -/// -/// Streaming parsers use this to keep only the trailing fragment that might -/// still grow into a full marker after the next decoded chunk arrives. -/// -/// The returned length is always a valid UTF-8 boundary in `token`, so callers -/// can safely slice `&token[..len]` even when markers contain non-ASCII -/// characters such as DeepSeek's DSML delimiters. -pub(super) fn partial_prefix_len(buffer: &str, token: &str) -> usize { - let Some(first_byte) = token.as_bytes().first().copied() else { - return 0; - }; - - let max_len = buffer.len().min(token.len().saturating_sub(1)); - let tail_start = buffer.len() - max_len; - let buffer_bytes = buffer.as_bytes(); - let token_bytes = token.as_bytes(); - - // Scan from the longest possible suffix to preserve overlapping prefixes. - for index in tail_start..buffer.len() { - if buffer_bytes[index] != first_byte { - continue; - } - - let len = buffer.len() - index; - if buffer.is_char_boundary(index) - && token.is_char_boundary(len) - && token_bytes[..len] == buffer_bytes[index..] - { - return len; - } - } - - 0 -} - -/// Parse a safe text run before the next marker. -/// -/// Returns the text length in bytes, and advances the input. -pub(super) fn safe_text_len(input: &mut Partial<&str>, marker: &str) -> ModalResult { - let text = **input; - if text.is_empty() { - return incomplete(); - } - - if let Some(start_idx) = text.find(marker) { - input.next_slice(start_idx); - return Ok(start_idx); - } - - let keep_len = partial_prefix_len(text, marker); - let emit_len = text.len().saturating_sub(keep_len); - if emit_len == 0 { - return incomplete(); - } - - input.next_slice(emit_len); - Ok(emit_len) -} - -/// Decode XML/HTML entities in XML-style parameter values. -pub(super) fn xml_unescape(value: &str) -> Cow<'_, str> { - if !value.as_bytes().contains(&b'&') { - return Cow::Borrowed(value); - } - - let mut output: Option = None; - let mut copied_len = 0; - let mut rest = value; - - while let Some(ampersand) = rest.find('&') { - let before_ampersand = &rest[..ampersand]; - let after_ampersand = &rest[ampersand + '&'.len_utf8()..]; - if let Some(semicolon) = after_ampersand.find(';') { - let entity = &after_ampersand[..semicolon]; - if let Some(decoded) = decode_xml_entity(entity) { - match &mut output { - Some(output) => output.push_str(before_ampersand), - None => { - let mut new_output = String::with_capacity(value.len()); - new_output.push_str(&value[..copied_len + ampersand]); - output = Some(new_output); - } - } - let output = output.as_mut().expect("output is initialized above"); - output.push(decoded); - let consumed_len = ampersand + '&'.len_utf8() + semicolon + ';'.len_utf8(); - copied_len += consumed_len; - rest = &rest[consumed_len..]; - continue; - } - } - - if let Some(output) = &mut output { - output.push_str(before_ampersand); - output.push('&'); - } - let consumed_len = ampersand + '&'.len_utf8(); - copied_len += consumed_len; - rest = after_ampersand; - } - - if let Some(mut output) = output { - output.push_str(rest); - Cow::Owned(output) - } else { - Cow::Borrowed(value) - } -} - -fn decode_xml_entity(entity: &str) -> Option { - match entity { - "amp" => Some('&'), - "lt" => Some('<'), - "gt" => Some('>'), - "quot" => Some('"'), - "apos" => Some('\''), - entity if entity.starts_with("#x") || entity.starts_with("#X") => { - u32::from_str_radix(&entity[2..], 16).ok().and_then(char::from_u32) - } - entity if entity.starts_with('#') => { - entity[1..].parse::().ok().and_then(char::from_u32) - } - _ => None, - } -} - -/// Streaming lexical state for a top-level JSON object. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(super) struct JsonObjectScanState { - object_depth: usize, - array_depth: usize, - in_string: bool, - escape: bool, - phase: JsonObjectScanPhase, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -enum JsonObjectScanPhase { - #[default] - Initial, - Scanning, - Complete, -} - -impl JsonObjectScanState { - /// Returns whether the top-level JSON object has closed. - pub(super) const fn complete(&self) -> bool { - matches!(self.phase, JsonObjectScanPhase::Complete) - } -} - -/// Parse a raw top-level JSON object argument prefix. -/// -/// The returned length is safe to emit as raw argument text. This scans only -/// lexical boundaries from `{` through the matching `}`, preserving -/// malformed-but-balanced JSON without deserializing or normalizing it. -pub(super) fn take_json_object( - input: &mut Partial<&str>, - state: &mut JsonObjectScanState, -) -> ModalResult { - let text = **input; - if text.is_empty() { - return incomplete(); - } - if state.complete() { - return Err(json_scan_error( - "JSON object argument", - StrContextValue::Description("active JSON object scan"), - )); - } - - let bytes = text.as_bytes(); - let just_started = matches!(state.phase, JsonObjectScanPhase::Initial); - if just_started { - if bytes[0] != b'{' { - return Err(json_scan_error( - "JSON object argument", - StrContextValue::CharLiteral('{'), - )); - } - state.phase = JsonObjectScanPhase::Scanning; - state.object_depth = 1; - } - - let mut index = usize::from(just_started); - - while index < bytes.len() { - let byte = bytes[index]; - index += 1; - - if state.in_string { - if state.escape { - state.escape = false; - } else if byte == b'\\' { - state.escape = true; - } else if byte == b'"' { - state.in_string = false; - } - continue; - } - - match byte { - b'"' => state.in_string = true, - b'{' => state.object_depth += 1, - b'}' => { - state.object_depth = state.object_depth.checked_sub(1).ok_or_else(|| { - json_scan_error( - "JSON object argument", - StrContextValue::Description("balanced object braces"), - ) - })?; - if state.object_depth == 0 && state.array_depth == 0 { - state.phase = JsonObjectScanPhase::Complete; - input.next_slice(index); - return Ok(index); - } - if state.object_depth == 0 { - return Err(json_scan_error( - "JSON object argument", - StrContextValue::Description( - "nested arrays to close before the top-level object", - ), - )); - } - } - b'[' => state.array_depth += 1, - b']' => { - state.array_depth = state.array_depth.checked_sub(1).ok_or_else(|| { - json_scan_error( - "JSON object argument", - StrContextValue::Description("balanced array brackets"), - ) - })?; - } - _ => {} - } - } - - input.next_slice(text.len()); - Ok(text.len()) -} - -/// Parse a JSON string literal. -pub(super) fn json_str(input: &mut Partial<&str>) -> ModalResult { - let text = **input; - if text.is_empty() { - return incomplete(); - } - - let bytes = text.as_bytes(); - if bytes[0] != b'"' { - return Err(json_scan_error( - "JSON string", - StrContextValue::CharLiteral('"'), - )); - } - - let mut escape = false; - let mut index = 1; - while index < bytes.len() { - let byte = bytes[index]; - index += 1; - - if escape { - escape = false; - continue; - } - - match byte { - b'\\' => escape = true, - b'"' => { - let raw = &text[..index]; - let value = serde_json::from_str::(raw).map_err(|_| { - json_scan_error( - "JSON string", - StrContextValue::Description("valid JSON string"), - ) - })?; - input.next_slice(index); - return Ok(value); - } - _ => {} - } - } - - incomplete() -} - -fn json_scan_error(label: &'static str, expected: StrContextValue) -> ErrMode { - let mut error = ContextError::new(); - error.push(StrContext::Label(label)); - error.push(StrContext::Expected(expected)); - ErrMode::Cut(error) -} - -/// Parse one event from a buffered streaming input. -/// -/// Returns: -/// - `Ok(Some((event, consumed_len)))` if an event was successfully parsed, along with the number -/// of bytes consumed from the buffer. -/// - `Ok(None)` if the buffer does not contain a full event yet, and more data is needed. -/// - `Err` if a parsing error occurred. -pub(super) fn parse_buffered_event( - buffer: &str, - parse: impl FnOnce(&mut Partial<&str>) -> ModalResult, -) -> Result> { - let mut input = Partial::new(buffer); - let checkpoint = input.checkpoint(); - let event = match parse(&mut input) { - Ok(event) => event, - Err(ErrMode::Incomplete(_)) => return Ok(None), - Err(ErrMode::Backtrack(e) | ErrMode::Cut(e)) => { - // TODO: enrich context for error reporting - return Err(parsing_failed!("{}", e)); - } - }; - let consumed_len = input.offset_from(&checkpoint); - if consumed_len == 0 { - return Ok(None); - } - - Ok(Some((event, consumed_len))) -} - -/// Returns an error indicating that we need more data to continue parsing. -pub(super) fn incomplete() -> ModalResult { - Err(ErrMode::Incomplete(Needed::Unknown)) -} - -#[cfg(test)] -mod tests { - use std::borrow::Cow; - - use expect_test::expect; - use winnow::error::ErrMode; - use winnow::stream::{Offset, Partial, Stream}; - - use super::{ - JsonObjectScanState, json_str, partial_prefix_len, safe_text_len, take_json_object, - xml_unescape, - }; - - #[test] - fn partial_prefix_len_handles_ascii_markers() { - assert_eq!( - partial_prefix_len("hello<|tool", "<|tool_call>"), - "<|tool".len() - ); - assert_eq!(partial_prefix_len("hello world", "<|tool_call>"), 0); - } - - #[test] - fn partial_prefix_len_prefers_longest_overlapping_prefix() { - assert_eq!(partial_prefix_len("chunk ending in aba", "ababa"), 3); - } - - #[test] - fn partial_prefix_len_handles_unicode_markers() { - let token = "<|DSML|function_calls>"; - assert_eq!( - partial_prefix_len("prefix <|DSML|fun", token), - "<|DSML|fun".len() - ); - assert_eq!(partial_prefix_len("prefix <|DSML", token), "<|DSML".len()); - } - - #[test] - fn safe_text_len_stops_before_marker() { - let mut input = Partial::new("hello"); - let checkpoint = input.checkpoint(); - - let len = safe_text_len(&mut input, "").unwrap(); - - assert_eq!(len, "hello".len()); - assert_eq!(input.offset_from(&checkpoint), "hello".len()); - } - - #[test] - fn safe_text_len_holds_back_partial_marker() { - let mut input = Partial::new("hello").unwrap(); - - assert_eq!(len, "hello".len()); - assert_eq!(input.offset_from(&checkpoint), "hello".len()); - } - - #[test] - fn safe_text_len_reports_incomplete_for_only_partial_marker() { - let mut input = Partial::new("").unwrap_err(); - - assert!(matches!(error, ErrMode::Incomplete(_))); - } - - #[test] - fn xml_unescape_decodes_common_entities() { - assert_eq!( - xml_unescape("<tag attr="value">Tom & Jerry's</tag>"), - r#"Tom & Jerry's"# - ); - } - - #[test] - fn xml_unescape_decodes_numeric_entities() { - assert_eq!(xml_unescape("<tag>😀"), "😀"); - } - - #[test] - fn xml_unescape_preserves_unknown_and_incomplete_entities() { - let output = xml_unescape("Tom & Jerry &unknown; &"); - - assert!(matches!(output, Cow::Borrowed(_))); - assert_eq!(output, "Tom & Jerry &unknown; &"); - } - - #[test] - fn xml_unescape_borrows_when_no_entity_is_present() { - let input = "plain text"; - let output = xml_unescape(input); - - assert!(matches!(output, Cow::Borrowed(_))); - assert_eq!(output, input); - } - - #[test] - fn take_json_object_consumes_simple_object() { - let mut state = JsonObjectScanState::default(); - let buffer = r#"{"location":"Paris"}"#; - let mut input = Partial::new(buffer); - let checkpoint = input.checkpoint(); - - let len = take_json_object(&mut input, &mut state).unwrap(); - - assert_eq!(len, r#"{"location":"Paris"}"#.len()); - assert_eq!(input.offset_from(&checkpoint), len); - assert!(state.complete()); - } - - #[test] - fn take_json_object_tracks_nested_values_and_strings() { - let mut state = JsonObjectScanState::default(); - let arguments = r#"{"nested":{"items":[{"text":"} <|tool_call_end|> \" \\"}]}}"#; - let buffer = format!("{arguments}"); - let mut input = Partial::new(buffer.as_str()); - - let len = take_json_object(&mut input, &mut state).unwrap(); - - assert_eq!(len, arguments.len()); - assert!(state.complete()); - } - - #[test] - fn take_json_object_rejects_leading_whitespace() { - let mut state = JsonObjectScanState::default(); - let mut input = Partial::new(" {\"x\":1}"); - - let error = take_json_object(&mut input, &mut state).unwrap_err(); - - let ErrMode::Cut(error) = error else { - panic!("expected cut error"); - }; - expect![[r#" - invalid JSON object argument - expected `{`"#]] - .assert_eq(&error.to_string()); - } - - #[test] - fn take_json_object_leaves_trailing_whitespace_to_caller() { - let mut state = JsonObjectScanState::default(); - let mut input = Partial::new("{\"x\":1}\n"); - let checkpoint = input.checkpoint(); - - let len = take_json_object(&mut input, &mut state).unwrap(); - - assert_eq!(len, "{\"x\":1}".len()); - assert_eq!(input.offset_from(&checkpoint), len); - assert!(state.complete()); - } - - #[test] - fn take_json_object_continues_across_chunks() { - let mut state = JsonObjectScanState::default(); - let chunks = [ - r#"{"text":"literal "#, - r#"<|tool_call_end|>"#, - r#" inside"}"#, - ]; - let mut collected = String::new(); - - for chunk in chunks { - let mut input = Partial::new(chunk); - let len = take_json_object(&mut input, &mut state).unwrap(); - collected.push_str(&chunk[..len]); - } - - assert_eq!(collected, r#"{"text":"literal <|tool_call_end|> inside"}"#); - assert!(state.complete()); - } - - #[test] - fn take_json_object_rejects_non_object_top_level() { - let mut state = JsonObjectScanState::default(); - let mut input = Partial::new(r#"[{"x":1}]"#); - - let error = take_json_object(&mut input, &mut state).unwrap_err(); - - let ErrMode::Cut(error) = error else { - panic!("expected cut error"); - }; - expect![[r#" - invalid JSON object argument - expected `{`"#]] - .assert_eq(&error.to_string()); - } - - #[test] - fn take_json_object_reports_unbalanced_array() { - let mut state = JsonObjectScanState::default(); - let mut input = Partial::new(r#"{"x":]}"#); - - let error = take_json_object(&mut input, &mut state).unwrap_err(); - - let ErrMode::Cut(error) = error else { - panic!("expected cut error"); - }; - expect![[r#" - invalid JSON object argument - expected balanced array brackets"#]] - .assert_eq(&error.to_string()); - } - - #[test] - fn take_json_object_reports_top_level_close_before_nested_array() { - let mut state = JsonObjectScanState::default(); - let mut input = Partial::new(r#"{"x":[}"#); - - let error = take_json_object(&mut input, &mut state).unwrap_err(); - - let ErrMode::Cut(error) = error else { - panic!("expected cut error"); - }; - expect![[r#" - invalid JSON object argument - expected nested arrays to close before the top-level object"#]] - .assert_eq(&error.to_string()); - } - - #[test] - fn json_str_decodes_escaped_content() { - let mut input = Partial::new(r#""say_\"hi\u0021" rest"#); - - let value = json_str(&mut input).unwrap(); - - assert_eq!(value, "say_\"hi!"); - assert_eq!(*input, " rest"); - } - - #[test] - fn json_str_reports_incomplete_escaped_string() { - let mut input = Partial::new(r#""say_\"#); - - let error = json_str(&mut input).unwrap_err(); - - assert!(matches!(error, ErrMode::Incomplete(_))); - } -} diff --git a/setup.py b/setup.py index 07374807bee6..b305fb1b00f2 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,6 @@ from packaging.version import Version, parse from setuptools import Extension, setup from setuptools.command.build_ext import build_ext -from setuptools_rust import Binding, RustExtension from setuptools_rust.build import build_rust from setuptools_scm import get_version from torch.utils.cpp_extension import CUDA_HOME, ROCM_HOME @@ -36,10 +35,16 @@ def load_module_from_path(module_name, path): logger = logging.getLogger(__name__) PRECOMPILED_RUST_FRONTEND_PATH = ROOT_DIR / "vllm" / "vllm-rs" +# setuptools-rust installs PyO3 artifacts as `.`, where the +# suffix ends with `.so` on Linux and macOS alike (e.g. `_rust_foo.abi3.so`). +PRECOMPILED_RUST_EXTENSION_MEMBER_REGEX = re.compile(r"vllm/_rust_[^/]*\.so$") # cannot import envs directly because it depends on vllm, # which is not installed yet envs = load_module_from_path("envs", os.path.join(ROOT_DIR, "vllm", "envs.py")) +rust_build = load_module_from_path( + "rust_build", os.path.join(ROOT_DIR, "tools", "build_rust.py") +) VLLM_TARGET_DEVICE = envs.VLLM_TARGET_DEVICE USE_PRECOMPILED_EXTENSIONS = envs.VLLM_USE_PRECOMPILED @@ -54,6 +59,25 @@ def should_require_rust_frontend() -> bool: return value.lower() not in ("", "0", "false", "no") +def get_precompiled_rust_extension_paths() -> list[Path]: + return sorted((ROOT_DIR / "vllm").glob("_rust_*.so")) + + +def get_missing_precompiled_rust_extension_modules() -> list[str]: + present = { + path.name.split(".", 1)[0] for path in get_precompiled_rust_extension_paths() + } + return [ + module_name + for module_name in rust_build.rust_py_extension_module_names() + if module_name not in present + ] + + +def has_precompiled_rust_extensions() -> bool: + return not get_missing_precompiled_rust_extension_modules() + + if sys.platform.startswith("darwin") and VLLM_TARGET_DEVICE != "cpu": logger.warning("VLLM_TARGET_DEVICE automatically set to `cpu` due to macOS") VLLM_TARGET_DEVICE = "cpu" @@ -408,6 +432,19 @@ def run(self): dirs_exist_ok=True, ) + # copy vendored fmha_sm100 package from build_lib to source tree + # for editable installs + fmha_sm100_build = os.path.join( + self.build_lib, "vllm", "third_party", "fmha_sm100" + ) + if os.path.exists(fmha_sm100_build): + print(f"Copying {fmha_sm100_build} to vllm/third_party/fmha_sm100") + shutil.copytree( + fmha_sm100_build, + "vllm/third_party/fmha_sm100", + dirs_exist_ok=True, + ) + class precompiled_build_ext(build_ext): """Disables extension building when using precompiled binaries.""" @@ -421,19 +458,31 @@ def build_extensions(self) -> None: class precompiled_build_rust(build_rust): - """Skips local Rust builds when the precompiled wheel already ships vllm-rs.""" + """Skips local Rust builds when all precompiled Rust artifacts are present.""" def run(self) -> None: - if PRECOMPILED_RUST_FRONTEND_PATH.exists(): + missing = [] + if not PRECOMPILED_RUST_FRONTEND_PATH.exists(): + missing.append(str(PRECOMPILED_RUST_FRONTEND_PATH)) + missing_rust_extensions = get_missing_precompiled_rust_extension_modules() + if missing_rust_extensions: + missing.extend( + str(ROOT_DIR / "vllm" / f"{module_name}*.so") + for module_name in missing_rust_extensions + ) + + if not missing: logger.info( - "Skipping local Rust build: using precompiled %s", + "Skipping local Rust build: using precompiled %s and %s", PRECOMPILED_RUST_FRONTEND_PATH, + get_precompiled_rust_extension_paths(), ) return logger.warning( - "Precompiled wheel did not provide %s; falling back to local Rust build.", - PRECOMPILED_RUST_FRONTEND_PATH, + "Precompiled wheel did not provide all Rust artifacts (%s); " + "falling back to local Rust build.", + ", ".join(missing), ) super().run() @@ -719,7 +768,8 @@ def extract_precompiled_and_patch_package( { "vllm/_C.abi3.so", "vllm/_C_stable_libtorch.abi3.so", - "vllm/_moe_C.abi3.so", + "vllm/_moe_C_stable_libtorch.abi3.so", + "vllm/_qutlass_C.abi3.so", "vllm/_flashmla_C.abi3.so", "vllm/_flashmla_extension_C.abi3.so", "vllm/_sparse_flashmla_C.abi3.so", @@ -727,6 +777,7 @@ def extract_precompiled_and_patch_package( "vllm/vllm_flash_attn/_vllm_fa3_C.abi3.so", "vllm/cumem_allocator.abi3.so", "vllm/spinloop.abi3.so", + "vllm/fs_io_C.abi3.so", # ROCm-specific libraries "vllm/_rocm_C.abi3.so", } @@ -751,11 +802,20 @@ def extract_precompiled_and_patch_package( ) # DeepGEMM: extract all files (.py, .so, .cuh, .h, .hpp, etc.) deep_gemm_regex = re.compile(r"vllm/third_party/deep_gemm/.*") + fmha_sm100_regex = re.compile(r"vllm/third_party/fmha_sm100/.*") file_members = [] for member in wheel.filelist: if member.filename in exact_members: file_members.append(member) continue + if ( + extract_rust_frontend + and PRECOMPILED_RUST_EXTENSION_MEMBER_REGEX.match( + member.filename + ) + ): + file_members.append(member) + continue if not extract_extensions: continue @@ -768,6 +828,7 @@ def extract_precompiled_and_patch_package( or triton_kernels_regex.match(member.filename) or flashmla_regex.match(member.filename) or deep_gemm_regex.match(member.filename) + or fmha_sm100_regex.match(member.filename) ): file_members.append(member) @@ -1037,13 +1098,14 @@ def _read_requirements(filename: str) -> list[str]: ext_modules = [] if _is_cuda() or _is_hip(): - ext_modules.append(CMakeExtension(name="vllm._moe_C")) ext_modules.append(CMakeExtension(name="vllm.cumem_allocator")) # Optional since this doesn't get built (produce an .so file). This is just # copying the relevant .py files from the source repository. ext_modules.append(CMakeExtension(name="vllm.triton_kernels", optional=True)) -ext_modules.append(CMakeExtension(name="vllm.spinloop")) +if sys.version_info >= (3, 11): + ext_modules.append(CMakeExtension(name="vllm.spinloop")) + ext_modules.append(CMakeExtension(name="vllm.fs_io_C")) if _is_hip(): ext_modules.append(CMakeExtension(name="vllm._rocm_C")) @@ -1076,6 +1138,9 @@ def _read_requirements(filename: str) -> list[str]: # DeepGEMM requires CUDA 12.3+ (SM90/SM100) # Optional since it won't build on unsupported architectures ext_modules.append(CMakeExtension(name="vllm._deep_gemm_C", optional=True)) + ext_modules.append(CMakeExtension(name="vllm._qutlass_C", optional=True)) + # fmha_sm100 is a Python/CuTe-DSL package installed into vllm.third_party. + ext_modules.append(CMakeExtension(name="vllm.fmha_sm100", optional=True)) if _is_cpu(): import platform @@ -1088,9 +1153,11 @@ def _read_requirements(filename: str) -> list[str]: ext_modules.append(CMakeExtension(name="vllm._C")) if _build_custom_ops(): - ext_modules.append(CMakeExtension(name="vllm._C")) + if _is_hip(): + ext_modules.append(CMakeExtension(name="vllm._C")) if _is_cuda() or _is_hip(): ext_modules.append(CMakeExtension(name="vllm._C_stable_libtorch")) + ext_modules.append(CMakeExtension(name="vllm._moe_C_stable_libtorch")) package_data = { "vllm": [ @@ -1105,10 +1172,26 @@ def _read_requirements(filename: str) -> list[str]: "third_party/deep_gemm/include/**/*.cuh", "third_party/deep_gemm/include/**/*.h", "third_party/deep_gemm/include/**/*.hpp", + # fmha_sm100 sparse CuTe-DSL helper kernels (vendored via cmake) + "third_party/fmha_sm100/csrc/**/*.cu", + "third_party/fmha_sm100/csrc/**/*.h", + "third_party/fmha_sm100/csrc/**/*.jinja", + "third_party/fmha_sm100/csrc/**/*.cu.jinja", + "third_party/fmha_sm100/cute/**/*.cu", + "third_party/fmha_sm100/cutlass/include/**/*.h", + "third_party/fmha_sm100/cutlass/include/**/*.hpp", + "third_party/fmha_sm100/cutlass/tools/util/include/**/*.h", + "third_party/fmha_sm100/cutlass/tools/util/include/**/*.hpp", ] } +def add_vllm_package_data(filename: str) -> None: + vllm_files = package_data.setdefault("vllm", []) + if filename not in vllm_files: + vllm_files.append(filename) + + # If using precompiled artifacts, extract and patch package_data in advance. if USE_PRECOMPILED_RUST_FRONTEND: wheel_url, download_filename = precompiled_wheel_utils.determine_wheel_url() @@ -1124,9 +1207,9 @@ def _read_requirements(filename: str) -> list[str]: # If the rust frontend binary is already present in the source tree (e.g., # pre-built in a separate Docker build stage), ship it as-is. if PRECOMPILED_RUST_FRONTEND_PATH.exists(): - vllm_files = package_data.setdefault("vllm", []) - if "vllm-rs" not in vllm_files: - vllm_files.append("vllm-rs") + add_vllm_package_data("vllm-rs") +for rust_extension_path in get_precompiled_rust_extension_paths(): + add_vllm_package_data(rust_extension_path.name) if _no_device(): ext_modules = [] @@ -1139,23 +1222,18 @@ def _read_requirements(filename: str) -> list[str]: if USE_PRECOMPILED_EXTENSIONS else cmake_build_ext, } -if USE_PRECOMPILED_RUST_FRONTEND or PRECOMPILED_RUST_FRONTEND_PATH.exists(): +if ( + USE_PRECOMPILED_RUST_FRONTEND + or PRECOMPILED_RUST_FRONTEND_PATH.exists() + or has_precompiled_rust_extensions() +): cmdclass["build_rust"] = precompiled_build_rust -# Rust frontend binary, built via setuptools-rust and installed into the -# package directory alongside the Python modules. -# TODO: we may use `RustBin` to directly install it into `bin` directory, but this -# requires extra work on using precompiled binaries. -rust_extensions = [ - RustExtension( - target="vllm.vllm-rs", - path="rust/src/cmd/Cargo.toml", - args=["--bin", "vllm-rs"], - features=["native-tls-vendored"], - binding=Binding.Exec, - optional=not should_require_rust_frontend(), - ), -] +# Rust artifacts, built via setuptools-rust and installed into the package +# directory alongside the Python modules. +rust_extensions = rust_build.rust_extensions( + optional=not should_require_rust_frontend() +) setup( # static metadata should rather go in pyproject.toml @@ -1168,13 +1246,14 @@ def _read_requirements(filename: str) -> list[str]: "zen": ["zentorch==2.11.0.0"], "bench": ["pandas", "matplotlib", "seaborn", "datasets", "scipy", "plotly"], "tensorizer": ["tensorizer==2.10.1"], - "fastsafetensors": ["fastsafetensors >= 0.2.2"], + "fastsafetensors": ["fastsafetensors >= 0.3.2"], "instanttensor": ["instanttensor >= 0.1.5"], "runai": ["runai-model-streamer[s3,gcs,azure] >= 0.15.7"], "audio": [ "av", "scipy", "soundfile", + "soxr", "mistral_common[audio]", ], # Required for audio processing "video": [], # Kept for backwards compatibility @@ -1183,7 +1262,7 @@ def _read_requirements(filename: str) -> list[str]: # NOTE: When updating helion version, also update CI files: # - .buildkite/test_areas/kernels.yaml # - .buildkite/test-amd.yaml - "helion": ["helion==1.0.0"], + "helion": ["helion==1.1.0"], # Optional deps for gRPC server (vllm serve --grpc) "grpc": ["smg-grpc-servicer[vllm] >= 0.5.2"], # Optional deps for OpenTelemetry tracing @@ -1193,6 +1272,8 @@ def _read_requirements(filename: str) -> list[str]: "opentelemetry-exporter-otlp>=1.26.0", "opentelemetry-semantic-conventions-ai>=0.4.1", ], + # extra quantization plugin + "extra-quant": ["vllm-gguf-plugin>=0.0.2"], }, cmdclass=cmdclass, package_data=package_data, diff --git a/tests/basic_correctness/test_basic_correctness.py b/tests/basic_correctness/test_basic_correctness.py index 1a07ac6da6b9..810a3a0aeed3 100644 --- a/tests/basic_correctness/test_basic_correctness.py +++ b/tests/basic_correctness/test_basic_correctness.py @@ -29,7 +29,53 @@ "meta-llama/Llama-3.2-1B-Instruct", ] -TARGET_TEST_SUITE = os.environ.get("TARGET_TEST_SUITE", "L4") +TARGET_TEST_SUITE_ENV = "VLLM_TARGET_TEST_SUITE" +LEGACY_TARGET_TEST_SUITE_ENV = "TARGET_TEST_SUITE" + +GENERIC_DISTRIBUTED_TEST_SUITES = ("L4", "MI250", "MI300", "MI325", "MI355") +ALL_DISTRIBUTED_TEST_SUITES = (*GENERIC_DISTRIBUTED_TEST_SUITES, "A100") + + +def _default_target_test_suite() -> str: + if not current_platform.is_rocm(): + return "L4" + + try: + device_name = current_platform.get_device_name().upper() + except Exception: + device_name = "" + + if "MI355" in device_name: + return "MI355" + if "MI300" in device_name: + return "MI300" + if "MI325" in device_name: + return "MI325" + if "MI250" in device_name: + return "MI250" + + try: + from vllm.platforms import rocm as rocm_platform + + if rocm_platform.on_gfx950(): + return "MI355" + if rocm_platform.on_gfx942(): + return "MI300" + except Exception: + pass + + return "MI250" + + +def _resolve_target_test_suite() -> str: + for env_name in (TARGET_TEST_SUITE_ENV, LEGACY_TARGET_TEST_SUITE_ENV): + value = os.environ.get(env_name, "").strip().upper() + if value: + return value + return _default_target_test_suite() + + +TARGET_TEST_SUITE = _resolve_target_test_suite() def test_vllm_gc_ed(): @@ -131,14 +177,27 @@ def test_models( @multi_gpu_test(num_gpus=2) @pytest.mark.parametrize( - "model, distributed_executor_backend, attention_backend, test_suite, extra_env", + ( + "model, distributed_executor_backend, attention_backend, " + "target_test_suites, extra_env" + ), [ - ("facebook/opt-125m", "ray", "", "L4", {}), - ("facebook/opt-125m", "mp", "", "L4", {}), - ("meta-llama/Llama-3.2-1B-Instruct", "ray", "", "L4", {}), - ("meta-llama/Llama-3.2-1B-Instruct", "mp", "", "L4", {}), - ("facebook/opt-125m", "ray", "", "A100", {}), - ("facebook/opt-125m", "mp", "", "A100", {}), + ("facebook/opt-125m", "ray", "", ALL_DISTRIBUTED_TEST_SUITES, {}), + ("facebook/opt-125m", "mp", "", ALL_DISTRIBUTED_TEST_SUITES, {}), + ( + "meta-llama/Llama-3.2-1B-Instruct", + "ray", + "", + GENERIC_DISTRIBUTED_TEST_SUITES, + {}, + ), + ( + "meta-llama/Llama-3.2-1B-Instruct", + "mp", + "", + GENERIC_DISTRIBUTED_TEST_SUITES, + {}, + ), ], ) @pytest.mark.parametrize("enable_prompt_embeds", [True, False]) @@ -150,19 +209,19 @@ def test_models_distributed( model: str, distributed_executor_backend: str, attention_backend: str, - test_suite: str, + target_test_suites: tuple[str, ...], extra_env: dict[str, str], enable_prompt_embeds: bool, ) -> None: - if test_suite != TARGET_TEST_SUITE: - pytest.skip(f"Skip test for {test_suite}") + if TARGET_TEST_SUITE and TARGET_TEST_SUITE not in target_test_suites: + pytest.skip(f"Skip test for {TARGET_TEST_SUITE}") with monkeypatch.context() as monkeypatch_context: if ( model == "meta-llama/Llama-3.2-1B-Instruct" and distributed_executor_backend == "ray" and attention_backend == "" - and test_suite == "L4" + and TARGET_TEST_SUITE == "L4" and enable_prompt_embeds ): # noqa pytest.skip("enable_prompt_embeds does not work with ray compiled dag.") diff --git a/tests/basic_correctness/test_cumem.py b/tests/basic_correctness/test_mem.py similarity index 85% rename from tests/basic_correctness/test_cumem.py rename to tests/basic_correctness/test_mem.py index 8d8f87f0a3c6..c0f8a5922239 100644 --- a/tests/basic_correctness/test_cumem.py +++ b/tests/basic_correctness/test_mem.py @@ -7,7 +7,7 @@ import torch from vllm import LLM, AsyncEngineArgs, AsyncLLMEngine, SamplingParams -from vllm.device_allocator.cumem import CuMemAllocator +from vllm.device_allocator import get_mem_allocator_instance from vllm.platforms import current_platform from vllm.utils.mem_constants import GiB_bytes @@ -16,14 +16,14 @@ DEVICE_TYPE = current_platform.device_type -@create_new_process_for_each_test("fork" if not current_platform.is_rocm() else "spawn") +@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn") def test_python_error(): """ Test if Python error occurs when there's low-level error happening from the C++ side. """ - allocator = CuMemAllocator.get_instance() - total_bytes = torch.cuda.mem_get_info()[1] + allocator = get_mem_allocator_instance() + total_bytes = torch.accelerator.get_memory_info()[1] alloc_bytes = int(total_bytes * 0.7) tensors = [] with allocator.use_memory_pool(): @@ -42,7 +42,7 @@ def test_python_error(): allocator.wake_up() -@create_new_process_for_each_test("fork" if not current_platform.is_rocm() else "spawn") +@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn") def test_basic_cumem(): # some tensors from default memory pool shape = (1024, 1024) @@ -50,7 +50,7 @@ def test_basic_cumem(): x.zero_() # some tensors from custom memory pool - allocator = CuMemAllocator.get_instance() + allocator = get_mem_allocator_instance() with allocator.use_memory_pool(): # custom memory pool y = torch.empty(shape, device=DEVICE_TYPE) @@ -64,9 +64,9 @@ def test_basic_cumem(): output = x + y + z assert torch.allclose(output, torch.ones_like(output) * 3) - free_bytes = torch.cuda.mem_get_info()[0] + free_bytes = torch.accelerator.get_memory_info()[0] allocator.sleep() - free_bytes_after_sleep = torch.cuda.mem_get_info()[0] + free_bytes_after_sleep = torch.accelerator.get_memory_info()[0] assert free_bytes_after_sleep > free_bytes allocator.wake_up() @@ -75,9 +75,10 @@ def test_basic_cumem(): assert torch.allclose(output, torch.ones_like(output) * 3) -@create_new_process_for_each_test("fork" if not current_platform.is_rocm() else "spawn") +@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn") +@pytest.mark.skipif(current_platform.is_xpu(), reason="CUDA graph not supported on XPU") def test_cumem_with_cudagraph(): - allocator = CuMemAllocator.get_instance() + allocator = get_mem_allocator_instance() with allocator.use_memory_pool(): weight = torch.eye(1024, device=DEVICE_TYPE) with allocator.use_memory_pool(tag="discard"): @@ -98,9 +99,9 @@ def model(x): with torch.cuda.graph(model_graph): y = model(x) - free_bytes = torch.cuda.mem_get_info()[0] + free_bytes = torch.accelerator.get_memory_info()[0] allocator.sleep() - free_bytes_after_sleep = torch.cuda.mem_get_info()[0] + free_bytes_after_sleep = torch.accelerator.get_memory_info()[0] assert free_bytes_after_sleep > free_bytes allocator.wake_up() @@ -120,7 +121,7 @@ def model(x): assert torch.allclose(y, x + 1) -@create_new_process_for_each_test("fork" if not current_platform.is_rocm() else "spawn") +@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn") @pytest.mark.parametrize( "model", [ @@ -131,7 +132,7 @@ def model(x): ], ) def test_end_to_end(model: str): - free, total = torch.cuda.mem_get_info() + free, total = torch.accelerator.get_memory_info() used_bytes_baseline = total - free # in case other process is running llm = LLM(model, enable_sleep_mode=True) prompt = "How are you?" @@ -143,7 +144,7 @@ def test_end_to_end(model: str): # test sleep level 1 here. llm.sleep(level=1) - free_gpu_bytes_after_sleep, total = torch.cuda.mem_get_info() + free_gpu_bytes_after_sleep, total = torch.accelerator.get_memory_info() used_bytes = total - free_gpu_bytes_after_sleep - used_bytes_baseline # now the memory usage is mostly cudagraph memory pool, # and it should be less than the model weights (1B model, 2GiB weights) @@ -163,7 +164,7 @@ def test_end_to_end(model: str): llm.sleep(level=1) llm.wake_up(tags=["weights"]) - free_gpu_bytes_wake_up_w, total = torch.cuda.mem_get_info() + free_gpu_bytes_wake_up_w, total = torch.accelerator.get_memory_info() used_bytes = total - free_gpu_bytes_wake_up_w - used_bytes_baseline # should just reallocate memory for weights (1B model, ~2GiB weights) @@ -180,7 +181,7 @@ def test_end_to_end(model: str): @create_new_process_for_each_test() def test_deep_sleep(): model = "hmellor/tiny-random-LlamaForCausalLM" - free, total = torch.cuda.mem_get_info() + free, total = torch.accelerator.get_memory_info() used_bytes_baseline = total - free # in case other process is running llm = LLM(model, enable_sleep_mode=True) prompt = "How are you?" @@ -190,13 +191,13 @@ def test_deep_sleep(): # Put the engine to deep sleep llm.sleep(level=2) - free_gpu_bytes_after_sleep, total = torch.cuda.mem_get_info() + free_gpu_bytes_after_sleep, total = torch.accelerator.get_memory_info() used_bytes = total - free_gpu_bytes_after_sleep - used_bytes_baseline assert used_bytes < 3 * GiB_bytes llm.wake_up(tags=["weights"]) llm.collective_rpc("reload_weights") - free_gpu_bytes_wake_up_w, total = torch.cuda.mem_get_info() + free_gpu_bytes_wake_up_w, total = torch.accelerator.get_memory_info() used_bytes = total - free_gpu_bytes_wake_up_w - used_bytes_baseline assert used_bytes < 4 * GiB_bytes @@ -212,7 +213,7 @@ def test_deep_sleep(): def test_deep_sleep_async(): async def test(): model = "hmellor/tiny-random-LlamaForCausalLM" - free, total = torch.cuda.mem_get_info() + free, total = torch.accelerator.get_memory_info() used_bytes_baseline = total - free # in case other process is running engine_args = AsyncEngineArgs( model=model, @@ -231,7 +232,7 @@ async def test(): await llm.wake_up(tags=["weights"]) await llm.collective_rpc("reload_weights") - free_gpu_bytes_wake_up_w, total = torch.cuda.mem_get_info() + free_gpu_bytes_wake_up_w, total = torch.accelerator.get_memory_info() used_bytes = total - free_gpu_bytes_wake_up_w - used_bytes_baseline assert used_bytes < 4 * GiB_bytes diff --git a/tests/benchmarks/test_audio_dataset.py b/tests/benchmarks/test_audio_dataset.py new file mode 100644 index 000000000000..d0cb3c4d99c5 --- /dev/null +++ b/tests/benchmarks/test_audio_dataset.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import asyncio +from pathlib import Path +from typing import Protocol, cast + +import numpy as np +import pytest +import soundfile as sf + +import vllm.benchmarks.datasets.datasets as datasets_module +import vllm.benchmarks.lib.endpoint_request_func as request_func_module +from vllm.benchmarks.lib.endpoint_request_func import RequestFuncInput + +pytestmark = pytest.mark.skip_global_cleanup + + +class _ReadableBinary(Protocol): + def read(self, size: int = -1) -> bytes: ... + + +class _TokenizedPrompt: + def __init__(self, prompt: str) -> None: + self.input_ids = prompt.split() + + +class _Tokenizer: + def __init__(self, name_or_path: str = "openai/whisper-large-v3") -> None: + self.name_or_path = name_or_path + + def __call__(self, prompt: str) -> _TokenizedPrompt: + return _TokenizedPrompt(prompt) + + +class CohereAsrTokenizer(_Tokenizer): + def __init__(self, name_or_path: str = "/models/cohere-transcribe") -> None: + super().__init__(name_or_path) + + +class _CohereNameOnlyTokenizer(_Tokenizer): + def __init__(self) -> None: + super().__init__("cohere/some-local-checkpoint") + + +def _write_wav(path: Path, duration_s: float = 0.1, sample_rate: int = 16_000) -> None: + num_samples = int(duration_s * sample_rate) + sf.write(path, np.zeros(num_samples, dtype=np.float32), sample_rate) + + +class _FakeFormData: + def __init__(self) -> None: + self.fields: list[tuple[str, object, dict[str, str]]] = [] + + def add_field(self, name: str, value: object, **kwargs: str) -> None: + self.fields.append((name, value, kwargs)) + + +class _FakeContent: + async def iter_any(self): + yield b'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n' + yield b'data: {"usage":{"completion_tokens":1}}\n\n' + yield b"data: [DONE]\n\n" + + +class _FakeResponse: + def __init__(self) -> None: + self.status = 200 + self.reason = "OK" + self.content = _FakeContent() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class _FakeSession: + def __init__(self) -> None: + self.uploaded_bytes: bytes | None = None + self.upload_filename: str | None = None + self.fields: list[tuple[str, object, dict[str, str]]] | None = None + + def post(self, *, url: str, data: _FakeFormData, headers: dict[str, str]): + del url, headers + self.fields = list(data.fields) + _, file_obj, file_kwargs = self.fields[0] + file_obj = cast(_ReadableBinary, file_obj) + self.uploaded_bytes = file_obj.read() + self.upload_filename = file_kwargs.get("filename") + return _FakeResponse() + + +def test_asr_dataset_sample_handles_local_audio_paths(tmp_path: Path) -> None: + audio_path = tmp_path / "earnings.wav" + _write_wav(audio_path, duration_s=0.1) + + dataset = object.__new__(datasets_module.ASRDataset) + dataset.data = [ + { + "audio": { + "path": str(audio_path), + "bytes": None, + }, + "text": "quarterly earnings call", + } + ] + + samples = dataset.sample( + tokenizer=_Tokenizer(), + num_requests=1, + output_len=32, + asr_min_audio_len_sec=0.0, + asr_max_audio_len_sec=1.0, + ) + + assert len(samples) == 1 + assert samples[0].multi_modal_data == {"audio_path": str(audio_path)} + assert ( + samples[0].prompt == "<|startoftranscript|><|en|><|transcribe|><|notimestamps|>" + ) + + +@pytest.mark.parametrize("has_filepath", [True, False]) +def test_asr_dataset_sample_handles_embedded_audio_bytes( + tmp_path: Path, has_filepath: bool +) -> None: + audio_path = tmp_path / "earnings.wav" + _write_wav(audio_path, duration_s=0.1) + + test_path = None + if has_filepath: + test_path = audio_path + + dataset = object.__new__(datasets_module.ASRDataset) + dataset.data = [ + { + "audio": { + "path": test_path, + "bytes": audio_path.read_bytes(), + }, + "text": "quarterly earnings call", + } + ] + + samples = dataset.sample( + tokenizer=_Tokenizer(), + num_requests=1, + output_len=32, + asr_min_audio_len_sec=0.0, + asr_max_audio_len_sec=1.0, + ) + + assert len(samples) == 1 + assert isinstance(samples[0].multi_modal_data, dict) + audio, sample_rate = samples[0].multi_modal_data["audio"] + assert sample_rate == 16_000 + assert isinstance(audio, np.ndarray) + assert audio.size > 0 + + +def test_async_request_openai_audio_handles_local_audio_paths( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + audio_path = tmp_path / "earnings.wav" + _write_wav(audio_path, duration_s=0.25) + + monkeypatch.setattr(request_func_module.aiohttp, "FormData", _FakeFormData) + session = _FakeSession() + request_input = RequestFuncInput( + prompt="", + api_url="http://localhost:8000/v1/audio/transcriptions", + prompt_len=1, + output_len=32, + model="openai/whisper-large-v3", + multi_modal_content={"audio_path": str(audio_path)}, + ) + + output = asyncio.run( + request_func_module.async_request_openai_audio(request_input, session) + ) + + assert session.upload_filename == audio_path.name + assert session.uploaded_bytes == audio_path.read_bytes() + assert output.success is True + assert output.generated_text == "hello" + assert output.output_tokens == 1 + assert output.input_audio_duration == pytest.approx(0.25, abs=1e-2) + + +def test_async_request_openai_audio_handles_decoded_audio_arrays( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(request_func_module.aiohttp, "FormData", _FakeFormData) + session = _FakeSession() + request_input = RequestFuncInput( + prompt="", + api_url="http://localhost:8000/v1/audio/transcriptions", + prompt_len=1, + output_len=32, + model="openai/whisper-large-v3", + multi_modal_content={ + "audio": (np.zeros(1_600, dtype=np.float32), 16_000), + }, + ) + + output = asyncio.run( + request_func_module.async_request_openai_audio(request_input, session) + ) + + assert session.upload_filename == "audio.wav" + assert session.uploaded_bytes is not None + assert output.success is True + assert output.generated_text == "hello" + + +_COHERE_ASR_PROMPT = ( + "<|startofcontext|><|startoftranscript|>" + "<|emo:undefined|><|en|><|en|><|pnc|><|noitn|>" + "<|notimestamp|><|nodiarize|>" +) + + +def _make_asr_dataset(tmp_path: Path) -> datasets_module.ASRDataset: + audio_path = tmp_path / "sample.wav" + _write_wav(audio_path, duration_s=0.1) + dataset = object.__new__(datasets_module.ASRDataset) + dataset.data = [ + { + "audio": {"path": str(audio_path), "bytes": None}, + "text": "hello world", + } + ] + return dataset + + +def test_asr_dataset_cohere_class_name_gets_decoder_prompt(tmp_path: Path) -> None: + dataset = _make_asr_dataset(tmp_path) + samples = dataset.sample( + tokenizer=CohereAsrTokenizer(), + num_requests=1, + output_len=32, + asr_min_audio_len_sec=0.0, + asr_max_audio_len_sec=1.0, + ) + assert len(samples) == 1 + assert samples[0].prompt == _COHERE_ASR_PROMPT + + +def test_asr_dataset_cohere_name_or_path_fallback_gets_decoder_prompt( + tmp_path: Path, +) -> None: + dataset = _make_asr_dataset(tmp_path) + samples = dataset.sample( + tokenizer=_CohereNameOnlyTokenizer(), + num_requests=1, + output_len=32, + asr_min_audio_len_sec=0.0, + asr_max_audio_len_sec=1.0, + ) + assert len(samples) == 1 + assert samples[0].prompt == _COHERE_ASR_PROMPT + + +def test_asr_dataset_unknown_tokenizer_gets_empty_prompt(tmp_path: Path) -> None: + dataset = _make_asr_dataset(tmp_path) + samples = dataset.sample( + tokenizer=_Tokenizer(name_or_path="some-other/asr-model"), + num_requests=1, + output_len=32, + asr_min_audio_len_sec=0.0, + asr_max_audio_len_sec=1.0, + ) + assert len(samples) == 1 + assert samples[0].prompt == "" diff --git a/tests/benchmarks/test_bfcl_dataset.py b/tests/benchmarks/test_bfcl_dataset.py new file mode 100644 index 000000000000..d19192231979 --- /dev/null +++ b/tests/benchmarks/test_bfcl_dataset.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import argparse +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from transformers import AutoTokenizer, PreTrainedTokenizerBase + +from vllm.benchmarks.datasets import BFCLDataset, get_samples + + +def _patch_hf_api(side_effect): + """Return a patch context that swaps `hf_api()` to a stub whose + `.hf_hub_download` attribute uses `side_effect`.""" + fake_api = MagicMock() + fake_api.hf_hub_download.side_effect = side_effect + return patch("vllm.benchmarks.datasets.datasets.hf_api", return_value=fake_api) + + +@pytest.fixture(scope="session") +def hf_tokenizer() -> PreTrainedTokenizerBase: + return AutoTokenizer.from_pretrained("openai-community/gpt2") + + +_FAKE_ROWS = { + "simple": [ + { + "id": "simple_0", + "question": [ + [ + { + "role": "user", + "content": "What is 2+2?", + } + ] + ], + "function": [ + { + "name": "add", + "description": "Add two numbers.", + "parameters": { + "type": "dict", + "properties": { + "a": {"type": "integer", "description": "first"}, + "b": {"type": "float", "description": "second"}, + }, + "required": ["a", "b"], + }, + } + ], + }, + ], + "live_simple": [ + { + "id": "live_simple_0", + "question": [[{"role": "user", "content": "Tell me the weather."}]], + "function": [ + { + "name": "get_weather", + "description": "Get weather.", + "parameters": { + "type": "dict", + "properties": { + "city": {"type": "any", "description": "city"}, + "coords": {"type": "tuple", "description": "coords"}, + }, + "required": ["city"], + }, + } + ], + }, + ], +} + + +def _write_fake_files(tmp_path: Path) -> dict[str, Path]: + """Write fake BFCL JSONL files mimicking the HF repo layout.""" + paths = {} + for category, rows in _FAKE_ROWS.items(): + p = tmp_path / f"BFCL_v3_{category}.json" + with p.open("w") as f: + for row in rows: + f.write(json.dumps(row) + "\n") + paths[category] = p + return paths + + +def _args_for_bfcl(categories: list[str] | None) -> argparse.Namespace: + return argparse.Namespace( + dataset_name="hf", + dataset_path="gorilla-llm/Berkeley-Function-Calling-Leaderboard", + hf_name=None, + hf_subset=None, + hf_split=None, + hf_output_len=64, + disable_shuffle=True, + num_prompts=2, + no_oversample=False, + no_stream=True, + seed=0, + request_id_prefix="", + trust_remote_code=False, + skip_chat_template=False, + enable_multimodal_chat=False, + backend="openai-chat", + bfcl_categories=categories, + ) + + +@pytest.mark.benchmark +def test_bfcl_dataset_translates_schema_and_attaches_tools( + hf_tokenizer: PreTrainedTokenizerBase, tmp_path: Path +) -> None: + """BFCLDataset should translate schemas to OpenAI tool format, set + `messages` directly on SampleRequest, and attach tools/tool_choice via + request_overrides.""" + paths = _write_fake_files(tmp_path) + + def fake_download(_repo, filename, **_kwargs): + category = filename.removeprefix("BFCL_v3_").removesuffix(".json") + return str(paths[category]) + + args = _args_for_bfcl(categories=["simple", "live_simple"]) + + with _patch_hf_api(fake_download): + samples = get_samples(args, hf_tokenizer) + + assert len(samples) == 2 + for s in samples: + assert s.chat_messages is not None + assert isinstance(s.chat_messages, list) + assert s.chat_messages[0]["role"] == "user" + assert s.request_overrides is not None + assert "tools" in s.request_overrides + assert s.request_overrides["tool_choice"] == "auto" + # messages must NOT leak into request_overrides — it has its own + # typed field on SampleRequest. + assert "messages" not in s.request_overrides + tools = s.request_overrides["tools"] + assert len(tools) == 1 + tool = tools[0] + assert tool["type"] == "function" + # Translated schema: dict -> object, float -> number, + # any -> string, tuple -> array. + params = tool["function"]["parameters"] + assert params["type"] == "object" + for prop in params["properties"].values(): + assert prop["type"] in {"integer", "number", "string", "array"} + + +@pytest.mark.benchmark +def test_bfcl_dataset_requires_openai_chat_backend( + hf_tokenizer: PreTrainedTokenizerBase, +) -> None: + args = _args_for_bfcl(categories=["simple"]) + args.backend = "openai" + + with pytest.raises(ValueError, match="openai-chat"): + get_samples(args, hf_tokenizer) + + +@pytest.mark.benchmark +def test_bfcl_dataset_missing_category_raises_clear_error( + hf_tokenizer: PreTrainedTokenizerBase, +) -> None: + """A typo'd category should produce an actionable ValueError, not an + opaque huggingface_hub exception.""" + from huggingface_hub.errors import EntryNotFoundError + + args = _args_for_bfcl(categories=["simpl"]) # typo + + def raise_missing(_repo, filename, **_kwargs): + raise EntryNotFoundError(f"404 Not Found: {filename}") + + with ( + _patch_hf_api(raise_missing), + pytest.raises(ValueError, match=r"BFCL category 'simpl' not found"), + ): + get_samples(args, hf_tokenizer) + + +@pytest.mark.benchmark +def test_chat_backend_uses_messages_field_when_set() -> None: + """When RequestFuncInput.chat_messages is set, the chat backend must use + it verbatim and skip default content construction from `prompt`.""" + import asyncio + + from vllm.benchmarks.lib.endpoint_request_func import ( + RequestFuncInput, + async_request_openai_chat_completions, + ) + + captured: dict = {} + + class _FakeResp: + status = 500 + reason = "stop-after-capture" + content = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + class _FakeSession: + def post(self, url, json, headers): # noqa: A002 + captured["url"] = url + captured["payload"] = json + return _FakeResp() + + messages = [ + {"role": "system", "content": "you are helpful"}, + {"role": "user", "content": "call add(3, 4)"}, + ] + req = RequestFuncInput( + prompt="IGNORED", + api_url="http://localhost:0/v1/chat/completions", + prompt_len=10, + output_len=16, + model="test-model", + chat_messages=messages, + extra_body={"tools": [{"type": "function", "function": {"name": "add"}}]}, + ) + + asyncio.run( + async_request_openai_chat_completions( + request_func_input=req, session=_FakeSession() + ) + ) + + payload = captured["payload"] + assert payload["messages"] is messages, ( + "chat backend must forward RequestFuncInput.chat_messages verbatim " + "instead of constructing a default user message from `prompt`" + ) + # extra_body still merges in as before (shallow, per-request wins). + assert payload["tools"][0]["function"]["name"] == "add" + + +@pytest.mark.benchmark +def test_bfcl_prompt_len_includes_tools(tmp_path: Path) -> None: + """prompt_len must reflect tokens from both messages *and* tool schemas, + so percentile buckets and input-distribution summaries aren't biased + low for tool-heavy traffic.""" + paths = _write_fake_files(tmp_path) + + def fake_download(_repo, filename, **_kwargs): + category = filename.removeprefix("BFCL_v3_").removesuffix(".json") + return str(paths[category]) + + captured: dict = {} + + class _FakeTokenizer: + def apply_chat_template( + self, messages, tools=None, tokenize=False, add_generation_prompt=True + ): + captured["tools"] = tools + base = " ".join(m.get("content", "") for m in messages) + tool_text = json.dumps(tools) if tools else "" + return base + " " + tool_text + + def __call__(self, text): + # 1 "token" per whitespace-separated word. + return type("Enc", (), {"input_ids": text.split()})() + + fake = _FakeTokenizer() + args = _args_for_bfcl(categories=["simple"]) + args.num_prompts = 1 + + with _patch_hf_api(fake_download): + samples = get_samples(args, fake) + + assert len(samples) == 1 + assert captured["tools"] is not None, ( + "apply_chat_template must be called with tools= so the schema " + "contributes to the prompt-length estimate" + ) + assert len(captured["tools"]) == 1 + assert captured["tools"][0]["function"]["name"] == "add" + + # Sanity: prompt_len exceeds a messages-only estimate. The fake row's + # user message is "What is 2+2?" (3 whitespace-separated tokens). + assert samples[0].prompt_len > 3 + + +@pytest.mark.benchmark +def test_bfcl_prompt_len_falls_back_when_tokenizer_rejects_tools( + tmp_path: Path, +) -> None: + """Older tokenizers don't accept tools=; fallback must still produce a + non-zero prompt_len without crashing.""" + paths = _write_fake_files(tmp_path) + + def fake_download(_repo, filename, **_kwargs): + category = filename.removeprefix("BFCL_v3_").removesuffix(".json") + return str(paths[category]) + + class _LegacyTokenizer: + def apply_chat_template(self, messages, **kwargs): + if "tools" in kwargs: + raise TypeError("unexpected keyword argument 'tools'") + return " ".join(m.get("content", "") for m in messages) + + def __call__(self, text): + return type("Enc", (), {"input_ids": text.split()})() + + args = _args_for_bfcl(categories=["simple"]) + args.num_prompts = 1 + + with _patch_hf_api(fake_download): + samples = get_samples(args, _LegacyTokenizer()) + + assert len(samples) == 1 + assert samples[0].prompt_len > 0 + + +@pytest.mark.benchmark +def test_bfcl_schema_translation_is_recursive() -> None: + """_translate_schema must recurse into nested properties.""" + input_schema = { + "type": "dict", + "properties": { + "nested": { + "type": "dict", + "properties": { + "value": {"type": "float"}, + "tags": {"type": "tuple", "items": {"type": "any"}}, + }, + } + }, + } + out = BFCLDataset._translate_schema(input_schema) + assert out["type"] == "object" + assert out["properties"]["nested"]["type"] == "object" + assert out["properties"]["nested"]["properties"]["value"]["type"] == "number" + assert out["properties"]["nested"]["properties"]["tags"]["type"] == "array" + nested_props = out["properties"]["nested"]["properties"] + assert nested_props["tags"]["items"]["type"] == "string" diff --git a/tests/benchmarks/test_custom_dataset_seed.py b/tests/benchmarks/test_custom_dataset_seed.py index dac87e6e6d98..d23ce40b53e7 100644 --- a/tests/benchmarks/test_custom_dataset_seed.py +++ b/tests/benchmarks/test_custom_dataset_seed.py @@ -12,7 +12,7 @@ @pytest.fixture(scope="session") def hf_tokenizer() -> PreTrainedTokenizerBase: - return AutoTokenizer.from_pretrained("gpt2") + return AutoTokenizer.from_pretrained("openai-community/gpt2") def _write_jsonl(path: Path, n_rows: int) -> None: diff --git a/tests/benchmarks/test_random_dataset.py b/tests/benchmarks/test_random_dataset.py index 57f689306182..ff691ae15d0f 100644 --- a/tests/benchmarks/test_random_dataset.py +++ b/tests/benchmarks/test_random_dataset.py @@ -17,7 +17,7 @@ @pytest.fixture(scope="session") def hf_tokenizer() -> PreTrainedTokenizerBase: # Use a small, commonly available tokenizer - return AutoTokenizer.from_pretrained("gpt2") + return AutoTokenizer.from_pretrained("openai-community/gpt2") class Params(NamedTuple): diff --git a/tests/benchmarks/test_random_multimodal_dataset_video.py b/tests/benchmarks/test_random_multimodal_dataset_video.py index bd37a520d016..b394ea2c0d75 100644 --- a/tests/benchmarks/test_random_multimodal_dataset_video.py +++ b/tests/benchmarks/test_random_multimodal_dataset_video.py @@ -16,7 +16,7 @@ @pytest.fixture(scope="session") def hf_tokenizer() -> PreTrainedTokenizerBase: """Use a small, commonly available tokenizer.""" - return AutoTokenizer.from_pretrained("gpt2") + return AutoTokenizer.from_pretrained("openai-community/gpt2") @pytest.fixture diff --git a/tests/benchmarks/test_throughput_cli.py b/tests/benchmarks/test_throughput_cli.py index a579b59e8af4..87a8cecd5eb8 100644 --- a/tests/benchmarks/test_throughput_cli.py +++ b/tests/benchmarks/test_throughput_cli.py @@ -4,6 +4,13 @@ import pytest +from vllm.benchmarks.datasets import SampleRequest +from vllm.benchmarks.throughput import ( + _run_vllm_chat_requests, + add_cli_args, +) +from vllm.utils.argparse_utils import FlexibleArgumentParser + MODEL_NAME = "meta-llama/Llama-3.2-1B-Instruct" @@ -28,3 +35,70 @@ def test_bench_throughput(): print(result.stderr) assert result.returncode == 0, f"Benchmark failed: {result.stderr}" + + +def test_bench_throughput_accepts_custom_audio_args(): + parser = FlexibleArgumentParser() + add_cli_args(parser) + + args = parser.parse_args( + [ + "--dataset-name", + "custom_audio", + "--dataset-path", + "audio.jsonl", + "--no-oversample", + "--custom-output-len", + "32", + "--enable-multimodal-chat", + ] + ) + + assert args.dataset_name == "custom_audio" + assert args.no_oversample + assert args.custom_output_len == 32 + assert args.enable_multimodal_chat + + +def test_vllm_chat_requests_include_multimodal_content(): + class FakeLLM: + def __init__(self): + self.prompts = None + + def chat(self, prompts, sampling_params, use_tqdm): + del sampling_params, use_tqdm + self.prompts = prompts + return [] + + llm = FakeLLM() + audio_content = { + "type": "input_audio", + "input_audio": {"data": "abc", "format": "wav"}, + } + request = SampleRequest( + prompt="Transcribe this audio.", + prompt_len=1, + expected_output_len=8, + multi_modal_data=audio_content, + ) + + _run_vllm_chat_requests( + llm, + [request], + n=1, + disable_detokenize=False, + do_profile=False, + prequeue_requests=False, + ) + + assert llm.prompts == [ + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Transcribe this audio."}, + audio_content, + ], + } + ] + ] diff --git a/tests/benchmarks/test_txt_slices_dataset.py b/tests/benchmarks/test_txt_slices_dataset.py index 7821e9a925a2..8741805d0d58 100644 --- a/tests/benchmarks/test_txt_slices_dataset.py +++ b/tests/benchmarks/test_txt_slices_dataset.py @@ -13,7 +13,7 @@ @pytest.fixture(scope="session") def hf_tokenizer() -> PreTrainedTokenizerBase: # Use a small, commonly available tokenizer - return AutoTokenizer.from_pretrained("gpt2") + return AutoTokenizer.from_pretrained("openai-community/gpt2") text_content = """ @@ -39,7 +39,7 @@ def test_create_txt_slices_jsonl( create_txt_slices_jsonl( input_path=str(txt_path), output_path=str(jsonl_path), - tokenizer_name="gpt2", + tokenizer_name="openai-community/gpt2", num_prompts=10, input_len=10, output_len=10, diff --git a/tests/compile/conftest.py b/tests/compile/conftest.py index 1263cce04c6c..7d15b5c47e55 100644 --- a/tests/compile/conftest.py +++ b/tests/compile/conftest.py @@ -24,6 +24,7 @@ def test_something(mock_cuda_platform): def _mock_platform(is_cuda: bool = True, capability: tuple[int, int] | None = None): mock_platform = MagicMock() mock_platform.is_cuda.return_value = is_cuda + mock_platform.is_xpu.return_value = False device_capability = ( DeviceCapability(*capability) if capability is not None else None ) @@ -46,3 +47,25 @@ def is_device_capability_family( yield mock_platform return _mock_platform + + +@pytest.fixture +def mock_xpu_platform(): + """ + Fixture that returns a factory for creating mocked XPU platforms. + + Usage: + def test_something(mock_xpu_platform): + with mock_xpu_platform(): + # test code + """ + + @contextmanager + def _mock_platform(): + mock_platform = MagicMock() + mock_platform.is_cuda.return_value = False + mock_platform.is_xpu.return_value = True + with patch("vllm.platforms.current_platform", mock_platform): + yield mock_platform + + return _mock_platform diff --git a/tests/compile/correctness_e2e/test_async_tp.py b/tests/compile/correctness_e2e/test_async_tp.py index 28c7eb6fbc25..e2d597bc7a31 100644 --- a/tests/compile/correctness_e2e/test_async_tp.py +++ b/tests/compile/correctness_e2e/test_async_tp.py @@ -102,7 +102,7 @@ def test_async_tp_pass_correctness( @create_new_process_for_each_test() -def test_async_tp_pass_nvfp4_correctness(num_gpus_available: int, monkeypatch): +def test_async_tp_pass_nvfp4_correctness(num_gpus_available: int): if ( not current_platform.is_cuda() or not current_platform.is_device_capability_family(100) @@ -111,8 +111,6 @@ def test_async_tp_pass_nvfp4_correctness(num_gpus_available: int, monkeypatch): if not has_flashinfer(): pytest.skip("FlashInfer is required for the NVFP4 AsyncTP path") - monkeypatch.setenv("VLLM_NVFP4_GEMM_BACKEND", "flashinfer-cutlass") - tp_size = 2 if num_gpus_available < tp_size: pytest.skip(f"Need at least {tp_size} GPUs") @@ -126,6 +124,8 @@ def test_async_tp_pass_nvfp4_correctness(num_gpus_available: int, monkeypatch): "8", "--load-format", "dummy", + "--linear-backend", + "flashinfer_cutlass", "--hf-overrides", json.dumps(NVFP4_HF_OVERRIDES), ] diff --git a/tests/compile/fullgraph/test_full_graph.py b/tests/compile/fullgraph/test_full_graph.py index ed4c92d90ff7..cc138454802b 100644 --- a/tests/compile/fullgraph/test_full_graph.py +++ b/tests/compile/fullgraph/test_full_graph.py @@ -39,12 +39,6 @@ def models_list(*, all: bool = True, keywords: list[str] | None = None): ] ) - # TODO: figure out why this fails. - if False and is_quant_method_supported("gguf"): # noqa: SIM223 - TEST_MODELS.append( - ("TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF", {"quantization": "gguf"}) - ) - if is_quant_method_supported("gptq"): TEST_MODELS.append( ("TheBloke/TinyLlama-1.1B-Chat-v0.3-GPTQ", {"quantization": "gptq"}) diff --git a/tests/compile/fusions_e2e/conftest.py b/tests/compile/fusions_e2e/conftest.py index 9f34d25c46d3..a4ed63ffe7b5 100644 --- a/tests/compile/fusions_e2e/conftest.py +++ b/tests/compile/fusions_e2e/conftest.py @@ -79,6 +79,7 @@ def run( ): monkeypatch.setenv("VLLM_USE_DEEP_GEMM", "1" if use_deepgemm else "0") monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1" if use_aiter else "0") + monkeypatch.setenv("VLLM_ROCM_USE_AITER_CUSTOM_AR", "1" if use_aiter else "0") from vllm._aiter_ops import rocm_aiter_ops rocm_aiter_ops.refresh_env_variables() diff --git a/tests/compile/h100/test_startup.py b/tests/compile/h100/test_startup.py index 78554a3e93da..075fc8e24972 100644 --- a/tests/compile/h100/test_startup.py +++ b/tests/compile/h100/test_startup.py @@ -138,10 +138,10 @@ class ModelStartupSpec(NamedTuple): ModelStartupSpec( model="deepseek-ai/DeepSeek-V3.2", hf_overrides=_SMALL_MOE_OVERRIDES, - cold_artifacts_saved=4, + cold_artifacts_saved=9, # https://github.com/vllm-project/vllm/issues/38051 - warm_artifacts_saved=0 if is_torch_equal_or_newer("2.12.0") else 4, - warm_artifacts_loaded=4 if is_torch_equal_or_newer("2.12.0") else 0, + warm_artifacts_saved=0 if is_torch_equal_or_newer("2.12.0") else 9, + warm_artifacts_loaded=9 if is_torch_equal_or_newer("2.12.0") else 0, ), id="deepseek_v3.2", ), diff --git a/tests/compile/passes/distributed/test_fusion_all_reduce.py b/tests/compile/passes/distributed/test_fusion_all_reduce.py index 1a175b8dd335..b86018a75559 100644 --- a/tests/compile/passes/distributed/test_fusion_all_reduce.py +++ b/tests/compile/passes/distributed/test_fusion_all_reduce.py @@ -13,7 +13,9 @@ from vllm.compilation.passes.fusion.allreduce_rms_fusion import ( AllReduceFusionPass, RocmAiterAllReduceFusionPass, + _select_flashinfer_allreduce_use_oneshot, ) +from vllm.compilation.passes.fx_utils import find_op_nodes from vllm.compilation.passes.utility.fix_functionalization import ( FixFunctionalizationPass, ) @@ -29,11 +31,14 @@ set_current_vllm_config, ) from vllm.distributed import tensor_model_parallel_all_reduce +from vllm.distributed.device_communicators.aiter_custom_all_reduce import ( + AiterCustomAllreduce, +) from vllm.distributed.parallel_state import ( init_distributed_environment, initialize_model_parallel, ) -from vllm.model_executor.layers.layernorm import RMSNorm +from vllm.model_executor.layers.layernorm import GemmaRMSNorm, RMSNorm from vllm.model_executor.layers.quantization.utils.quant_utils import ( kFp8StaticTensorSym, ) @@ -44,6 +49,35 @@ DEVICE_TYPE = current_platform.device_type +@pytest.mark.parametrize( + ("workspace_backend", "device_capability", "world_size", "tensor_size", "expected"), + [ + ("mnnvl", 103, 8, 2 * 1024 * 1024, None), + ("trtllm", 103, 8, 2 * 1024 * 1024, True), + ("trtllm", 103, 8, 2 * 1024 * 1024 + 1, False), + ("trtllm", 100, 4, 4 * 1024 * 1024, True), + ("trtllm", 100, 4, 4 * 1024 * 1024 + 1, False), + ("trtllm", None, 8, 128 * 1024 * 1024, True), + ], +) +def test_select_flashinfer_allreduce_use_oneshot( + workspace_backend: str, + device_capability: int | None, + world_size: int, + tensor_size: int, + expected: bool | None, +): + assert ( + _select_flashinfer_allreduce_use_oneshot( + workspace_backend, + device_capability, + world_size, + tensor_size, + ) + is expected + ) + + class TestAllReduceRMSNormModel(torch.nn.Module): def __init__( self, @@ -91,6 +125,49 @@ def ops_in_model_after(self): return [torch.ops.vllm.flashinfer_trtllm_fused_allreduce_norm.default] +class TestAllReduceGemmaRMSNormModel(torch.nn.Module): + def __init__( + self, + hidden_size=16, + token_num=16, + eps=1e-6, + dtype: torch.dtype = torch.float16, + ): + super().__init__() + self.hidden_size = hidden_size + self.eps = eps + self.norm = [GemmaRMSNorm(hidden_size, eps) for _ in range(4)] + # Non-trivial weight (~Gemma range) so (1 + w) exercises the scale path. + for n in self.norm: + n.weight.data.normal_(mean=0.0, std=0.1) + self.w = [torch.rand(hidden_size, hidden_size) for _ in range(3)] + + def forward(self, x): + # avoid having graph input be an arg to a pattern directly + z = torch.relu(x) + x = resid = tensor_model_parallel_all_reduce(z) + y = self.norm[0](x) + + z2 = torch.mm(y, self.w[0]) + x2 = tensor_model_parallel_all_reduce(z2) + y2, resid = self.norm[1](x2, resid) + + z3 = torch.mm(y2, self.w[1]) + x3 = tensor_model_parallel_all_reduce(z3) + y3, resid = self.norm[2](x3, resid) + + z4 = torch.mm(y3, self.w[2]) + x4 = tensor_model_parallel_all_reduce(z4) + y4, resid = self.norm[3](x4, resid) + return y4 + + def ops_in_model_before(self): + return [torch.ops.vllm.all_reduce.default] + + def ops_in_model_after(self): + return [torch.ops.vllm.flashinfer_trtllm_fused_allreduce_norm.default] + + class TestAllReduceRMSNormStaticQuantFP8Model(torch.nn.Module): quant_key = kFp8StaticTensorSym @@ -145,6 +222,118 @@ def ops_in_model_before(self): ] +class TestAiterAllReduceRMSNormGroupQuantFP8Model(torch.nn.Module): + """Exercises the new ROCm AITER AR+RMS+per-group-FP8-quant patterns. + + Four ``rms_norm`` sites that together hit every pattern registered by + ``RocmAiterAllReduceFusionPass`` for the per-group FP8 quant path: + + * ``norm[0]``: ``all_reduce -> rms_norm -> group_fp8_quant`` (no residual) + -> ``AiterAllreduceFusedRMSNormGroupQuantFP8Pattern`` + * ``norm[1]``: ``all_reduce -> fused_add_rms_norm -> group_fp8_quant`` + (single ``rms`` consumer) + -> ``AiterAllreduceFusedAddRMSNormGroupQuantFP8Pattern`` + * ``norm[2..3]``: ``all_reduce -> fused_add_rms_norm + -> (group_fp8_quant + rocm_unquantized_gemm)`` (two ``rms`` consumers, + modeling the DSv3.2 indexer fan-out) + -> ``AiterAllreduceFusedAddRMSNormGroupQuantWithIndexerPattern`` + + The chain feeds the next AllReduce by dequantizing the FP8 output (FP8 + cast back to bf16 multiplied by the per-group scale), which is enough to + keep the matmul chain bf16 without depending on a real FP8 block-scaled + GEMM kernel. + """ + + quant_group_size = 128 + indexer_out_dim = 8 + + def __init__( + self, + hidden_size=128, + token_num=16, + eps=1e-6, + dtype: torch.dtype = torch.bfloat16, + use_triton_quant: bool = False, + ): + super().__init__() + self.hidden_size = hidden_size + self.eps = eps + self.use_triton_quant = use_triton_quant + assert hidden_size % self.quant_group_size == 0, ( + f"hidden_size ({hidden_size}) must be a multiple of " + f"quant_group_size ({self.quant_group_size}) for per-group FP8 quant" + ) + self.norm = [RMSNorm(hidden_size, eps) for _ in range(4)] + self.w = [torch.rand(hidden_size, hidden_size, dtype=dtype) for _ in range(3)] + self.indexer_w = [ + torch.rand(self.indexer_out_dim, hidden_size, dtype=dtype) for _ in range(2) + ] + + def _group_quant(self, rms: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + if self.use_triton_quant: + return torch.ops.vllm.triton_per_token_group_quant_fp8( + rms, self.quant_group_size + ) + return torch.ops.vllm.rocm_aiter_group_fp8_quant.default( + rms, self.quant_group_size + ) + + def _dequantize_to_bf16( + self, q: torch.Tensor, s: torch.Tensor, ref: torch.Tensor + ) -> torch.Tensor: + # Broadcast the per-group scale across each group of `quant_group_size` + # so we can chain the FP8 output back into a bf16 matmul. This avoids + # depending on a real FP8 block-scaled GEMM kernel in the test. + s_full = s.repeat_interleave(self.quant_group_size, dim=-1).to(ref.dtype) + return q.to(ref.dtype) * s_full + + def forward(self, hidden_states): + z = torch.relu(hidden_states) + x = resid = tensor_model_parallel_all_reduce(z) + rms = self.norm[0](x) + q0, s0 = self._group_quant(rms) + y = self._dequantize_to_bf16(q0, s0, rms) + + z2 = torch.mm(y, self.w[0]) + x2 = tensor_model_parallel_all_reduce(z2) + rms2, resid = self.norm[1](x2, resid) + q1, s1 = self._group_quant(rms2) + y2 = self._dequantize_to_bf16(q1, s1, rms2) + + z3 = torch.mm(y2, self.w[1]) + x3 = tensor_model_parallel_all_reduce(z3) + rms3, resid = self.norm[2](x3, resid) + q2, s2 = self._group_quant(rms3) + # Second consumer of ``rms3``: forces the with-indexer pattern. + idx2 = torch.ops.vllm.rocm_unquantized_gemm(rms3, self.indexer_w[0], None) + y3 = self._dequantize_to_bf16(q2, s2, rms3) + + z4 = torch.mm(y3, self.w[2]) + x4 = tensor_model_parallel_all_reduce(z4) + rms4, resid = self.norm[3](x4, resid) + q3, s3 = self._group_quant(rms4) + # Second consumer of ``rms4``: forces the with-indexer pattern. + idx3 = torch.ops.vllm.rocm_unquantized_gemm(rms4, self.indexer_w[1], None) + y4 = self._dequantize_to_bf16(q3, s3, rms4) + return y4, idx2, idx3 + + def ops_in_model_before(self): + return [ + torch.ops.vllm.all_reduce.default, + ( + torch.ops.vllm.triton_per_token_group_quant_fp8.default + if self.use_triton_quant + else torch.ops.vllm.rocm_aiter_group_fp8_quant.default + ), + ] + + def ops_in_model_after(self): + return [ + rocm_aiter_ops.get_fused_allreduce_rmsnorm_quant_per_group_op(), + rocm_aiter_ops.get_fused_allreduce_rmsnorm_quant_per_group_with_bf16_norm_op(), # noqa: E501 + ] + + class TestAllReduceFusedAddRMSNormStaticQuantFP4Model(torch.nn.Module): def __init__( self, hidden_size=16, token_num=16, eps=1e-6, dtype: torch.dtype = torch.float16 @@ -209,6 +398,15 @@ def ops_in_model_before(self): "test_model, enable_quant_fp8_custom_op, use_aiter", [ (TestAllReduceRMSNormModel, False, IS_AITER_FOUND), + pytest.param( + TestAllReduceGemmaRMSNormModel, + False, + False, + marks=pytest.mark.skipif( + current_platform.is_rocm(), + reason="Not supported on ROCm platform", + ), + ), pytest.param( TestAllReduceRMSNormStaticQuantFP8Model, True, @@ -339,8 +537,12 @@ def all_reduce_fusion_pass_on_test_model( "MASTER_ADDR": "localhost", "MASTER_PORT": "12345", "VLLM_FLASHINFER_ALLREDUCE_BACKEND": flashinfer_allreduce_backend, + "VLLM_ROCM_USE_AITER": str(int(use_aiter)), + "VLLM_ROCM_USE_AITER_CUSTOM_AR": str(int(use_aiter)), } ) + if use_aiter: + rocm_aiter_ops.refresh_env_variables() init_distributed_environment() @@ -399,6 +601,176 @@ def all_reduce_fusion_pass_on_test_model( results_fused = compiled_model(hidden_states) torch.testing.assert_close(results_unfused, results_fused, atol=1e-2, rtol=1e-2) + assert all_reduce_fusion_pass.matched_count == 4, ( + f"{all_reduce_fusion_pass.matched_count=}" + ) + backend.check_before_ops(model.ops_in_model_before(), fully_replaced=False) + backend.check_after_ops(model.ops_in_model_after()) + if test_model_cls is TestAllReduceGemmaRMSNormModel: + fused_op = torch.ops.vllm.flashinfer_trtllm_fused_allreduce_norm.default + fused_nodes = list(find_op_nodes(fused_op, backend.graph_post_pass)) + assert fused_nodes + assert all(n.kwargs.get("weight_bias") == 1.0 for n in fused_nodes) + del all_reduce_fusion_pass + + +@multi_gpu_test(num_gpus=2) +@pytest.mark.parametrize("use_triton_quant", [True, False]) +@pytest.mark.parametrize("batch_size", [8]) +@pytest.mark.parametrize("seq_len", [8]) +@pytest.mark.parametrize("hidden_size", [128]) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@pytest.mark.parametrize("enable_rms_norm_custom_op", [True, False]) +@pytest.mark.skipif( + not current_platform.is_rocm(), + reason="ROCm AITER AR+RMS+per-group-FP8-quant fusion is ROCm-only", +) +@pytest.mark.skipif(not IS_AITER_FOUND, reason="aiter is not found") +def test_rocm_aiter_all_reduce_rmsnorm_group_quant_fp8_fusion_pass_replace( + batch_size: int, + seq_len: int, + hidden_size: int, + dtype: torch.dtype, + enable_rms_norm_custom_op: bool, + use_triton_quant: bool, + monkeypatch: pytest.MonkeyPatch, +): + """Sibling of ``test_all_reduce_fusion_pass_replace`` for the new + ROCm AITER AR+RMS+per-group-FP8-quant fusion patterns. + + Validates the three new ``VllmPatternReplacement`` patterns added to + ``RocmAiterAllReduceFusionPass``: + + * ``AiterAllreduceFusedRMSNormGroupQuantFP8Pattern`` (no-residual) + * ``AiterAllreduceFusedAddRMSNormGroupQuantFP8Pattern`` (with-residual, + single ``rms`` consumer) + * ``AiterAllreduceFusedAddRMSNormGroupQuantWithIndexerPattern`` (with- + residual, DSv3.2 indexer fan-out; parametrized over both + ``triton_per_token_group_quant_fp8`` and ``rocm_aiter_group_fp8_quant`` + producers). + """ + with monkeypatch.context() as m: + m.setenv("VLLM_ROCM_USE_AITER", "1") + rocm_aiter_ops.refresh_env_variables() + + if not AiterCustomAllreduce.build_supports_per_group_quant(): + pytest.skip( + "aiter build is missing 'fused_ar_rms_per_group_quant' (needs " + "ROCm/aiter PR #2823); the new patterns aren't registered." + ) + + num_processes = 2 + + def run_torch_spawn(fn, nprocs): + torch.multiprocessing.spawn( + fn, + args=( + num_processes, + TestAiterAllReduceRMSNormGroupQuantFP8Model, + batch_size, + seq_len, + hidden_size, + dtype, + enable_rms_norm_custom_op, + use_triton_quant, + monkeypatch, + ), + nprocs=nprocs, + ) + + run_torch_spawn(rocm_aiter_group_quant_fusion_pass_on_test_model, num_processes) + + +def rocm_aiter_group_quant_fusion_pass_on_test_model( + local_rank: int, + world_size: int, + test_model_cls: torch.nn.Module, + batch_size: int, + seq_len: int, + hidden_size: int, + dtype: torch.dtype, + enable_rms_norm_custom_op: bool, + use_triton_quant: bool, + monkeypatch: pytest.MonkeyPatch, +): + set_random_seed(0) + + device = torch.device(f"{DEVICE_TYPE}:{local_rank}") + torch.accelerator.set_device_index(device) + torch.set_default_device(device) + torch.set_default_dtype(dtype) + + update_environment_variables( + { + "RANK": str(local_rank), + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": "12345", + "VLLM_ROCM_USE_AITER": "1", + "VLLM_ROCM_USE_AITER_CUSTOM_AR": "1", + } + ) + rocm_aiter_ops.refresh_env_variables() + + init_distributed_environment() + + custom_ops = [] + if enable_rms_norm_custom_op: + custom_ops.append("+rms_norm") + # ``triton_per_token_group_quant_fp8`` is emitted by ``QuantFP8.forward_hip`` + # only when QuantFP8 is enabled as a custom op (and ``use_triton=True`` at + # the call site). The patterns in this PR are robust to both Triton and + # rocm_aiter forms; we always enable +quant_fp8 so the matcher's example + # trace finds the same form the test model uses. + custom_ops.append("+quant_fp8") + + vllm_config = VllmConfig( + compilation_config=CompilationConfig( + mode=CompilationMode.VLLM_COMPILE, custom_ops=custom_ops + ) + ) + vllm_config.compilation_config.pass_config = PassConfig( + fuse_allreduce_rms=True, eliminate_noops=True + ) + vllm_config.device_config = DeviceConfig(device=torch.device(DEVICE_TYPE)) + vllm_config.parallel_config.rank = local_rank + + model_name = "RedHatAI/Llama-3.2-1B-Instruct-FP8" + vllm_config.model_config = ModelConfig( + model=model_name, trust_remote_code=True, dtype=dtype, seed=42 + ) + with set_current_vllm_config(vllm_config): + initialize_model_parallel(tensor_model_parallel_size=world_size) + all_reduce_fusion_pass = RocmAiterAllReduceFusionPass(vllm_config) + noop_pass = NoOpEliminationPass(vllm_config) + func_pass = FixFunctionalizationPass(vllm_config) + cleanup_pass = PostCleanupPass(vllm_config) + + backend = TestBackend( + noop_pass, all_reduce_fusion_pass, func_pass, cleanup_pass + ) + + token_num = batch_size * seq_len + model = test_model_cls( + hidden_size, token_num, dtype=dtype, use_triton_quant=use_triton_quant + ) + + hidden_states = torch.randn((token_num, hidden_size), requires_grad=False) + + compiled_model = torch.compile(model, backend=backend) + compiled_model(hidden_states) + + results_unfused = model(hidden_states) + results_fused = compiled_model(hidden_states) + # The fused per-group AR+RMS+QUANT op is bit-equivalent to the unfused + # chain modulo the small AllReduce + RMSNorm reordering inside aiter. + # Per-group FP8 quant introduces step noise <=1 per group; use the + # same tolerance as the sibling FP8 static test. + torch.testing.assert_close(results_unfused, results_fused, atol=1e-2, rtol=1e-2) + + # Four pattern firings: norm[0] (no-add quant), norm[1] (add quant, + # single ``rms`` consumer), norm[2..3] (add quant + indexer fan-out). assert all_reduce_fusion_pass.matched_count == 4, ( f"{all_reduce_fusion_pass.matched_count=}" ) diff --git a/tests/compile/passes/ir/test_clone_cleanup.py b/tests/compile/passes/ir/test_clone_cleanup.py index 9fedb5fc9177..b6626a5cb70a 100644 --- a/tests/compile/passes/ir/test_clone_cleanup.py +++ b/tests/compile/passes/ir/test_clone_cleanup.py @@ -132,6 +132,25 @@ def f(x: torch.Tensor) -> torch.Tensor: assert count_clones(graph_module.graph) == 0 torch.testing.assert_close(actual, expected) + def test_keep_clone_that_changes_layout(self, clone_cleanup_pass): + """Clone must be kept when it materializes a compact slice layout.""" + + def f(x: torch.Tensor) -> torch.Tensor: + return x[:, :3].contiguous() + + inp = torch.randn(4, 5) + graph_module = make_fx(f)(inp) + assert count_clones(graph_module.graph) == 1 + + expected = graph_module(inp) + clone_cleanup_pass(graph_module.graph) + graph_module.recompile() + actual = graph_module(inp) + + assert count_clones(graph_module.graph) == 1 + assert actual.stride() == expected.stride() == (3, 1) + torch.testing.assert_close(actual, expected) + def test_multiple_clones_of_same_input(self, clone_cleanup_pass): """Test multiple independent clones of the same input.""" diff --git a/tests/compile/passes/test_double_aiter_rms_quant_fusion.py b/tests/compile/passes/test_double_aiter_rms_quant_fusion.py index 161c956548a7..6a620d11a492 100644 --- a/tests/compile/passes/test_double_aiter_rms_quant_fusion.py +++ b/tests/compile/passes/test_double_aiter_rms_quant_fusion.py @@ -22,7 +22,7 @@ import vllm.config from tests.compile.backend import TestBackend -from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops +from vllm._aiter_ops import rocm_aiter_ops from vllm.compilation.passes.utility.noop_elimination import NoOpEliminationPass from vllm.compilation.passes.utility.post_cleanup import PostCleanupPass from vllm.config import ( @@ -83,9 +83,8 @@ def forward( [_NoViewDoubleQuantModel, _ViewDoubleQuantModel], ids=["no_view", "with_view"], ) -@pytest.mark.skipif( - not is_aiter_found_and_supported(), - reason="Only test on ROCm with AITER installed and supported", +@pytest.mark.skip( + reason="Skipping for now because pytorch compiler removes one the two quant ops" ) def test_double_aiter_rms_fp8_group_quant_fusion( model_cls: type[torch.nn.Module], diff --git a/tests/compile/passes/test_fuse_mla_dual_rms_norm.py b/tests/compile/passes/test_fuse_mla_dual_rms_norm.py index 080417c98966..6f20d4e15874 100644 --- a/tests/compile/passes/test_fuse_mla_dual_rms_norm.py +++ b/tests/compile/passes/test_fuse_mla_dual_rms_norm.py @@ -12,7 +12,10 @@ import vllm.config from tests.compile.backend import TestBackend -from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops +from vllm._aiter_ops import ( + is_aiter_found_and_supported, + rocm_aiter_ops, +) from vllm.compilation.passes.utility.noop_elimination import NoOpEliminationPass from vllm.compilation.passes.utility.post_cleanup import PostCleanupPass from vllm.config import ( @@ -23,6 +26,7 @@ VllmConfig, ) from vllm.model_executor.layers.layernorm import RMSNorm +from vllm.platforms import current_platform # MLA attention geometry for DeepSeek-V3 / Kimi-K2 Q_DIM = 1536 @@ -30,6 +34,8 @@ K_PE_DIM = 64 EPS = 1e-6 +FP8_DTYPE = current_platform.fp8_dtype() + class MLADualRMSNormTestModel(torch.nn.Module): """ @@ -146,3 +152,139 @@ def test_fuse_mla_dual_rms_norm( backend.check_before_ops(model.ops_in_model_before()) backend.check_after_ops(model.ops_in_model_after()) + + +class MLADualRMSNormFp8PerTokenTestModel(torch.nn.Module): + """ + Minimal model reproducing the FP8 MLA attention path with *per-token* quant: + linear -> split([q_dim, kv_dim]) + +-- q_c (getitem 0) -> rocm_aiter_rmsnorm_fused_dynamic_quant -> dequant + +-- kv_lora (getitem 1) -> split([kv_c_dim, k_pe_dim]) + +-- kv_c (getitem 0) -> rms_norm (bf16) + +-- k_pe + """ + + def __init__( + self, + hidden_size: int, + q_dim: int = Q_DIM, + kv_c_dim: int = KV_C_DIM, + k_pe_dim: int = K_PE_DIM, + eps: float = EPS, + ): + super().__init__() + self.q_dim = q_dim + self.kv_dim = kv_c_dim + k_pe_dim + self.kv_c_dim = kv_c_dim + self.k_pe_dim = k_pe_dim + self.eps = eps + + self.proj = torch.nn.Linear(hidden_size, q_dim + self.kv_dim, bias=False) + self.q_weight = torch.nn.Parameter(torch.ones(q_dim)) + self.kv_norm = RMSNorm(kv_c_dim, eps=eps) + + def _dequant(self, x_fp8: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: + # Per-token: a single (M, 1) scale broadcast across the row. + return (x_fp8.to(torch.float32) * scale).to(torch.bfloat16) + + def forward(self, x: torch.Tensor): + # Avoid graph input being a direct arg to a matched pattern node + x = torch.relu(x) + + projected = self.proj(x) + + q_c, kv_lora = projected.split([self.q_dim, self.kv_dim], dim=-1) + kv_c, k_pe = kv_lora.split([self.kv_c_dim, self.k_pe_dim], dim=-1) + + q_fp8, q_scale = torch.ops.vllm.rocm_aiter_rmsnorm_fused_dynamic_quant( + q_c, self.q_weight, self.eps, FP8_DTYPE + ) + kv_normed = self.kv_norm(kv_c) + + return self._dequant(q_fp8, q_scale), kv_normed, k_pe + + def ops_in_model_before(self): + return [ + torch.ops.vllm.rocm_aiter_rmsnorm_fused_dynamic_quant.default, + torch.ops.vllm_ir.rms_norm.default, + ] + + def ops_in_model_after(self): + return [torch.ops.vllm.fused_mla_dual_rms_norm_per_token_quant.default] + + +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@pytest.mark.parametrize("hidden_size", [7168]) +@pytest.mark.skipif( + not is_aiter_found_and_supported(), + reason="Only test on ROCm with AITER installed and supported", +) +def test_fuse_mla_dual_rms_norm_fp8_per_token( + dtype: torch.dtype, + hidden_size: int, + monkeypatch: pytest.MonkeyPatch, +): + torch._dynamo.reset() + + vllm_config = VllmConfig( + model_config=ModelConfig(dtype=dtype), + compilation_config=CompilationConfig( + mode=CompilationMode.VLLM_COMPILE, + custom_ops=["+rms_norm"], + pass_config=PassConfig( + fuse_mla_dual_rms_norm=True, + eliminate_noops=True, + ), + ), + ) + + with vllm.config.set_current_vllm_config(vllm_config), monkeypatch.context() as m: + from vllm.compilation.passes.fusion.rocm_aiter_fusion import ( + MLADualRMSNormFusionPass, + ) + + torch.set_default_device("cuda") + torch.set_default_dtype(dtype) + torch.manual_seed(42) + + m.setenv("VLLM_ROCM_USE_AITER", "1") + rocm_aiter_ops.refresh_env_variables() + + fusion_pass = MLADualRMSNormFusionPass(vllm_config) + passes = [ + NoOpEliminationPass(vllm_config), + fusion_pass, + PostCleanupPass(vllm_config), + ] + backend = TestBackend(*passes) + model = MLADualRMSNormFp8PerTokenTestModel(hidden_size) + + x = torch.randn(4, hidden_size) + torch._dynamo.mark_dynamic(x, 0) + + with torch.inference_mode(): + outputs_unfused = model(x) + + model_fused = torch.compile(model, backend=backend) + outputs_fused = model_fused(x) + + q_deq_u, kv_normed_u, k_pe_u = outputs_unfused + q_deq_f, kv_normed_f, k_pe_f = outputs_fused + + torch.testing.assert_close(k_pe_u, k_pe_f, atol=0, rtol=0) + + torch.testing.assert_close(kv_normed_u, kv_normed_f, atol=1e-2, rtol=1e-2) + + E4M3_STEP = 0.125 + exact_frac = (q_deq_u == q_deq_f).float().mean().item() + assert exact_frac > 0.99, ( + f"q: only {exact_frac:.4f} of elements bit-exact; scales likely differ" + ) + torch.testing.assert_close(q_deq_u, q_deq_f, atol=1e-2, rtol=E4M3_STEP) + + assert fusion_pass.matched_count == 1, ( + f"Expected 1 fused pair, got {fusion_pass.matched_count}" + ) + + backend.check_before_ops(model.ops_in_model_before()) + backend.check_after_ops(model.ops_in_model_after()) diff --git a/tests/compile/passes/test_qk_norm_rope_fusion.py b/tests/compile/passes/test_qk_norm_rope_fusion.py index 25b8ea56fe25..def025ad39ee 100644 --- a/tests/compile/passes/test_qk_norm_rope_fusion.py +++ b/tests/compile/passes/test_qk_norm_rope_fusion.py @@ -122,7 +122,7 @@ def ops_in_model_after(self) -> list[OpOverload | OpOverloadPacket]: @pytest.mark.parametrize("enable_rope_custom_op", [True]) @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) @pytest.mark.skipif( - not current_platform.is_cuda_alike(), + not (current_platform.is_cuda_alike() or current_platform.is_xpu()), reason="Only test on cuda and rocm platform", ) def test_qk_norm_rope_fusion( @@ -136,7 +136,7 @@ def test_qk_norm_rope_fusion( if not hasattr(torch.ops._C, "fused_qk_norm_rope"): pytest.skip("fused_qk_norm_rope custom op not available") - torch.set_default_device("cuda") + torch.set_default_device(current_platform.device_type) torch.set_default_dtype(dtype) torch.manual_seed(0) diff --git a/tests/compile/passes/test_rope_kvcache_fusion.py b/tests/compile/passes/test_rope_kvcache_fusion.py index b27adfc46f51..709490f1972b 100644 --- a/tests/compile/passes/test_rope_kvcache_fusion.py +++ b/tests/compile/passes/test_rope_kvcache_fusion.py @@ -3,11 +3,13 @@ import pytest import torch +from torch._higher_order_ops import auto_functionalized import vllm.config from tests.compile.backend import TestBackend from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops +from vllm.compilation.passes.fusion import rope_kvcache_fusion from vllm.compilation.passes.fusion.matcher_utils import ROTARY_OP from vllm.compilation.passes.fusion.rope_kvcache_fusion import RopeKVCacheFusionPass from vllm.compilation.passes.utility.noop_elimination import NoOpEliminationPass @@ -24,6 +26,7 @@ PassConfig, VllmConfig, ) +from vllm.config.utils import Range from vllm.forward_context import get_forward_context, set_forward_context from vllm.model_executor.layers.attention import Attention from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding @@ -40,6 +43,20 @@ FP8_DTYPE = current_platform.fp8_dtype() +def test_rope_kvcache_fusion_default_keeps_large_ranges_unfused(): + vllm_config = VllmConfig( + compilation_config=CompilationConfig( + mode=CompilationMode.VLLM_COMPILE, + pass_config=PassConfig(fuse_rope_kvcache=True), + ), + ) + fusion_pass = RopeKVCacheFusionPass(vllm_config) + + assert fusion_pass.is_applicable_for_range(Range(1, 256)) + assert not fusion_pass.is_applicable_for_range(Range(257, 11650)) + assert not fusion_pass.is_applicable_for_range(Range(11651, 16384)) + + class QKRoPEKVCacheTestModel(torch.nn.Module): def __init__( self, @@ -184,6 +201,51 @@ def ops_in_model_after(self) -> list[torch._ops.OpOverload]: return [torch.ops.vllm.fused_rope_and_unified_kv_cache_update.default] +class QKRoPEStaticQKVCacheTestModel(QKRoPEKVCacheTestModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.q_scale = torch.ones((), dtype=torch.float32, device=self.device) + + def forward( + self, qkv: torch.Tensor, positions: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + # Create copy so inplace ops do not modify the original tensors + qkv = qkv.clone() + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1) + q, k = self.rotary_emb(positions, q, k) + + q_fp8 = torch.empty(q.shape, device=q.device, dtype=FP8_DTYPE) + _, q_fp8 = auto_functionalized( + torch.ops._C.static_scaled_fp8_quant.default, + result=q_fp8, + input=q, + scale=self.q_scale, + group_shape=(-1, -1), + ) + q = q_fp8.view(-1, self.num_heads, self.head_size) + k = k.view(-1, self.num_kv_heads, self.head_size) + v = v.view(-1, self.num_kv_heads, self.head_size) + kv_cache_dummy_dep = torch.ops.vllm.unified_kv_cache_update( + k, v, _encode_layer_name(self.layer_name) + ) + return q, k, v, kv_cache_dummy_dep + + def ops_in_model_before(self) -> list[torch._ops.OpOverload]: + ops = [] + if self.enable_rope_custom_op: + if rocm_aiter_ops.is_triton_rotary_embed_enabled(): + ops.append(torch.ops.vllm.rocm_aiter_triton_rotary_embedding.default) + else: + ops.append(ROTARY_OP) + else: + ops.append(INDEX_SELECT_OP) + ops.append(torch.ops.vllm.unified_kv_cache_update.default) + return ops + + def ops_in_model_after(self) -> list[torch._ops.OpOverload]: + return [torch.ops.vllm.fused_rope_and_unified_kv_cache_update.default] + + @pytest.mark.parametrize( "attn_backend", [ @@ -320,9 +382,207 @@ def test_rope_kvcache_fusion( torch.testing.assert_close(k_unfused, k_fused, atol=ATOL, rtol=RTOL) torch.testing.assert_close(v_unfused, v_fused, atol=ATOL, rtol=RTOL) # Cannot compare fp8_* directly here, cast to model dtype instead + # TODO(charlifu): switch back to ATOL, RTOL after aiter fix is merged. + torch.testing.assert_close( + kv_cache_unfused.to(dtype), + kv_cache_fused.to(dtype), + atol=1e-1, + rtol=1e-1, + ) + + +@pytest.mark.parametrize( + "attn_backend", + [AttentionBackendEnum.ROCM_AITER_UNIFIED_ATTN], +) +@pytest.mark.parametrize("enable_rope_custom_op", [True]) +@pytest.mark.parametrize("enable_aiter_triton_rope", [True, False]) +@pytest.mark.parametrize("num_heads", [64]) +@pytest.mark.parametrize("num_kv_heads", [8]) +@pytest.mark.parametrize("head_size", [64]) +@pytest.mark.parametrize("block_size", [16]) +@pytest.mark.parametrize("is_neox", [True, False]) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"]) +@pytest.mark.skipif( + not is_aiter_found_and_supported(), + reason="Only test on ROCm with AITER installed and supported", +) +@pytest.mark.skipif( + not hasattr(torch.ops._C, "static_scaled_fp8_quant"), + reason="static fp8 quant op not available on this build", +) +def test_rope_static_qquant_kvcache_fusion( + attn_backend: AttentionBackendEnum, + enable_rope_custom_op: bool, + enable_aiter_triton_rope: bool, + num_heads: int, + num_kv_heads: int, + head_size: int, + block_size: int, + is_neox: bool, + dtype: torch.dtype, + kv_cache_dtype: str, + monkeypatch: pytest.MonkeyPatch, +): + torch.set_default_device("cuda") + torch.set_default_dtype(dtype) + torch.manual_seed(0) + + custom_ops: list[str] = [] + if enable_rope_custom_op: + custom_ops.append("+rotary_embedding") + + vllm_config = VllmConfig( + model_config=ModelConfig(dtype=dtype), + cache_config=CacheConfig( + block_size=block_size, + cache_dtype=kv_cache_dtype, + ), + compilation_config=CompilationConfig( + mode=CompilationMode.VLLM_COMPILE, + custom_ops=custom_ops, + pass_config=PassConfig( + fuse_rope_kvcache=True, + eliminate_noops=True, + ), + ), + ) + + with vllm.config.set_current_vllm_config(vllm_config), monkeypatch.context() as m: + m.setenv("VLLM_ROCM_USE_AITER", "1") + m.setenv( + "VLLM_ROCM_USE_AITER_TRITON_ROPE", "1" if enable_aiter_triton_rope else "0" + ) + rocm_aiter_ops.refresh_env_variables() + + model = QKRoPEStaticQKVCacheTestModel( + vllm_config=vllm_config, + attn_backend=attn_backend, + num_heads=num_heads, + num_kv_heads=num_kv_heads, + head_size=head_size, + is_neox=is_neox, + dtype=dtype, + device=torch.get_default_device(), + ) + + fusion_pass = RopeKVCacheFusionPass(vllm_config) + passes = [ + NoOpEliminationPass(vllm_config), + SplitCoalescingPass(vllm_config), + ScatterSplitReplacementPass(vllm_config), + fusion_pass, + PostCleanupPass(vllm_config), + ] + backend = TestBackend(*passes) + + T = 5 + qkv = torch.randn( + T, num_heads * head_size + 2 * num_kv_heads * head_size, dtype=dtype + ) + pos = torch.arange(T, dtype=torch.long) + + qkv_unfused = qkv.clone() + pos_unfused = pos.clone() + + with set_forward_context(None, vllm_config): + forward_context = get_forward_context() + attn_metadata = model.build_attn_metadata(T) + forward_context.slot_mapping = { + model.layer_name: attn_metadata.slot_mapping + } + q_unfused, k_unfused, v_unfused, dummy = model(qkv_unfused, pos_unfused) + attn_layer = forward_context.no_compile_layers[model.layer_name] + kv_cache_unfused = attn_layer.kv_cache + del dummy + + torch._dynamo.mark_dynamic(qkv, 0) + torch._dynamo.mark_dynamic(pos, 0) + with set_forward_context(None, vllm_config): + model_fused = torch.compile(model, backend=backend) + forward_context = get_forward_context() + attn_metadata = model_fused.build_attn_metadata(T) + forward_context.slot_mapping = { + model.layer_name: attn_metadata.slot_mapping + } + q_fused, k_fused, v_fused, dummy = model_fused(qkv, pos) + attn_layer = forward_context.no_compile_layers[model.layer_name] + kv_cache_fused = attn_layer.kv_cache + del dummy + + assert fusion_pass.matched_count == 1 + backend.check_before_ops(model.ops_in_model_before()) + backend.check_after_ops(model.ops_in_model_after()) + static_quant_pre = backend.op_count( + torch.ops._C.static_scaled_fp8_quant.default, before=True + ) + static_quant_post = backend.op_count( + torch.ops._C.static_scaled_fp8_quant.default + ) + assert static_quant_pre > 0 + # The replacement still emits static quant, so count is expected to + # remain non-zero after fusion. + assert static_quant_post > 0 + + # Negative control: without the static-Q pattern, the generic RoPE+KV + # pattern cannot match this rope -> static-quant -> kv graph, so the + # fusion above is attributable solely to RopeStaticQQuantKVCachePattern. + # This is a structural property independent of the rope/dtype/neox axes, + # so run the (extra compile) check only once on a representative combo. + if is_neox and enable_aiter_triton_rope and kv_cache_dtype == "auto": + m.setattr( + rope_kvcache_fusion, + "_supports_static_q_fp8_quant_fusion", + lambda: False, + ) + generic_pass = RopeKVCacheFusionPass(vllm_config) + generic_backend = TestBackend( + NoOpEliminationPass(vllm_config), + SplitCoalescingPass(vllm_config), + ScatterSplitReplacementPass(vllm_config), + generic_pass, + PostCleanupPass(vllm_config), + ) + # Reset dynamo so the model is recompiled through generic_backend + # instead of reusing the cached compilation from above. + torch._dynamo.reset() + with set_forward_context(None, vllm_config): + model_generic = torch.compile(model, backend=generic_backend) + forward_context = get_forward_context() + attn_metadata = model_generic.build_attn_metadata(T) + forward_context.slot_mapping = { + model.layer_name: attn_metadata.slot_mapping + } + model_generic(qkv, pos) + # op_count reads the post-pass graph, so it also confirms the pass ran + # (a no-op pass would raise instead of silently passing on count 0). + assert generic_pass.matched_count == 0 + assert ( + generic_backend.op_count( + torch.ops.vllm.fused_rope_and_unified_kv_cache_update.default + ) + == 0 + ) + + if dtype == torch.float16: + ATOL, RTOL = (2e-3, 2e-3) + else: + ATOL, RTOL = (1e-2, 1e-2) + + # TODO(charlifu): switch back to ATOL, RTOL after aiter fix is merged. + torch.testing.assert_close( + q_unfused.to(torch.float32), + q_fused.to(torch.float32), + atol=1e-1, + rtol=1e-1, + ) + torch.testing.assert_close(k_unfused, k_fused, atol=ATOL, rtol=RTOL) + torch.testing.assert_close(v_unfused, v_fused, atol=ATOL, rtol=RTOL) + # TODO(charlifu): switch back to ATOL, RTOL after aiter fix is merged. torch.testing.assert_close( - kv_cache_unfused.view(dtype), - kv_cache_fused.view(dtype), - atol=ATOL, - rtol=RTOL, + kv_cache_unfused.to(dtype), + kv_cache_fused.to(dtype), + atol=1e-1, + rtol=1e-1, ) diff --git a/tests/compile/test_aot_compile.py b/tests/compile/test_aot_compile.py index 5ff0fac6c822..a7f32483a70b 100644 --- a/tests/compile/test_aot_compile.py +++ b/tests/compile/test_aot_compile.py @@ -502,7 +502,7 @@ def _snap(self): m.setenv("VLLM_USE_AOT_COMPILE", "1") # First compilation - initialize model and generate llm_model = LLM( - model="gpt2", + model="openai-community/gpt2", compilation_config=CompilationConfig( mode=CompilationMode.VLLM_COMPILE, ), @@ -519,7 +519,7 @@ def _snap(self): # Second compilation - should hit cache m.setenv("VLLM_FORCE_AOT_LOAD", "1") llm_model = LLM( - model="gpt2", + model="openai-community/gpt2", compilation_config=CompilationConfig( mode=CompilationMode.VLLM_COMPILE, ), diff --git a/tests/compile/test_dynamic_shapes_compilation.py b/tests/compile/test_dynamic_shapes_compilation.py index b8c18fa6cdc0..96c3f49aba3e 100644 --- a/tests/compile/test_dynamic_shapes_compilation.py +++ b/tests/compile/test_dynamic_shapes_compilation.py @@ -24,7 +24,7 @@ def get_test_models(): """Get list of models to test based on PyTorch version""" models = [ - "gpt2", + "openai-community/gpt2", "Qwen/Qwen2-7B-Instruct", "meta-llama/Llama-3.1-8B", ] diff --git a/tests/compile/test_graph_partition.py b/tests/compile/test_graph_partition.py index 4cb199b5897d..8e20b704facc 100644 --- a/tests/compile/test_graph_partition.py +++ b/tests/compile/test_graph_partition.py @@ -565,6 +565,8 @@ def model_fn(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: torch._dynamo.mark_dynamic(x, 0) torch._dynamo.mark_dynamic(y, 0) torch.compile(model_fn, backend=capturing_backend)(x, y) + assert captured_graph is not None, "Graph should be captured by backend" + assert captured_inputs is not None, "Example inputs should be captured by backend" split_gm, split_items = split_graph(captured_graph, ["aten::sigmoid"]) diff --git a/tests/compile/test_sequence_parallelism_threshold.py b/tests/compile/test_sequence_parallelism_threshold.py index 42e374cd95d7..090b77b330ab 100644 --- a/tests/compile/test_sequence_parallelism_threshold.py +++ b/tests/compile/test_sequence_parallelism_threshold.py @@ -108,3 +108,85 @@ def test_hidden_size_boundary(self, mock_cuda_platform): element_size=2, ) assert result is not None + + +# XPU-specific constants (must match sequence_parallelism.py values) +_XPU_MIN_HIDDEN_SIZE = 4096 +_XPU_MIN_PER_GPU_SIZE_MB = 8.0 + + +class TestGetSequenceParallelismThresholdXPU: + """Tests for get_sequence_parallelism_threshold on XPU platform.""" + + def test_xpu_small_hidden_size_returns_none(self, mock_xpu_platform): + """XPU with hidden_size below threshold should return None.""" + with mock_xpu_platform(): + result = get_sequence_parallelism_threshold( + hidden_size=_XPU_MIN_HIDDEN_SIZE - 1, + tp_size=2, + element_size=2, + ) + assert result is None + + def test_xpu_large_model_returns_threshold(self, mock_xpu_platform): + """XPU with hidden_size >= threshold should return calculated value.""" + with mock_xpu_platform(): + hidden_size = _XPU_MIN_HIDDEN_SIZE + tp_size = 2 + element_size = 2 + result = get_sequence_parallelism_threshold( + hidden_size=hidden_size, + tp_size=tp_size, + element_size=element_size, + ) + # (8 * 2 * 1024 * 1024) // (4096 * 2) = 2048 + MiB = 1024 * 1024 + expected = int( + (_XPU_MIN_PER_GPU_SIZE_MB * tp_size * MiB) // (hidden_size * element_size) + ) + assert result == expected + assert result == 2048 + + @pytest.mark.parametrize( + "hidden_size,tp_size,element_size,expected", + [ + # (8 * 1 * 1024 * 1024) // (4096 * 2) = 1024 + (4096, 1, 2, 1024), + # (8 * 4 * 1024 * 1024) // (4096 * 2) = 4096 + (4096, 4, 2, 4096), + # (8 * 2 * 1024 * 1024) // (8192 * 2) = 1024 + (8192, 2, 2, 1024), + # (8 * 2 * 1024 * 1024) // (4096 * 4) = 1024 + (4096, 2, 4, 1024), + ], + ) + def test_xpu_threshold_calculation_variations( + self, mock_xpu_platform, hidden_size, tp_size, element_size, expected + ): + """Test XPU threshold calculation with various parameter combinations.""" + with mock_xpu_platform(): + result = get_sequence_parallelism_threshold( + hidden_size=hidden_size, + tp_size=tp_size, + element_size=element_size, + ) + assert result == expected + + def test_xpu_hidden_size_boundary(self, mock_xpu_platform): + """Test behavior at the exact XPU hidden_size boundary.""" + with mock_xpu_platform(): + # Just below threshold + result = get_sequence_parallelism_threshold( + hidden_size=_XPU_MIN_HIDDEN_SIZE - 1, + tp_size=2, + element_size=2, + ) + assert result is None + + # Exactly at threshold + result = get_sequence_parallelism_threshold( + hidden_size=_XPU_MIN_HIDDEN_SIZE, + tp_size=2, + element_size=2, + ) + assert result is not None diff --git a/tests/config/test_bailing_mtp_config.py b/tests/config/test_bailing_mtp_config.py new file mode 100644 index 000000000000..8fae29959f23 --- /dev/null +++ b/tests/config/test_bailing_mtp_config.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from transformers import PretrainedConfig + +from vllm.config.speculative import MTPModelTypes, SpeculativeConfig +from vllm.transformers_utils.model_arch_config_convertor import ( + BailingHybridMTPModelArchConfigConvertor, +) + + +def _bailing_config() -> PretrainedConfig: + config = PretrainedConfig( + architectures=["BailingMoeV2_5ForCausalLM"], + hidden_size=4096, + kv_lora_rank=512, + num_attention_heads=32, + num_experts=256, + num_hidden_layers=32, + num_key_value_heads=32, + num_nextn_predict_layers=1, + qk_rope_head_dim=64, + vocab_size=157184, + ) + config.model_type = "bailing_hybrid" + return config + + +def test_bailing_hybrid_mtp_hf_config_override(): + config = _bailing_config() + + overridden = SpeculativeConfig.hf_config_override(config) + + assert overridden.model_type == "bailing_hybrid_mtp" + assert overridden.architectures == ["BailingMoeV25MTPModel"] + assert overridden.n_predict == 1 + assert "bailing_hybrid_mtp" in MTPModelTypes.__args__ + + +def test_bailing_hybrid_mtp_model_arch_config(): + config = _bailing_config() + config.model_type = "bailing_hybrid_mtp" + config.architectures = ["BailingMoeV25MTPModel"] + + model_arch_config = BailingHybridMTPModelArchConfigConvertor( + config, config + ).convert() + + assert model_arch_config.model_type == "bailing_hybrid_mtp" + assert model_arch_config.architectures == ["BailingMoeV25MTPModel"] + assert model_arch_config.total_num_hidden_layers == 1 + assert model_arch_config.is_deepseek_mla diff --git a/tests/config/test_config_utils.py b/tests/config/test_config_utils.py index 23451c475ea9..3cc26e6e4761 100644 --- a/tests/config/test_config_utils.py +++ b/tests/config/test_config_utils.py @@ -6,6 +6,7 @@ import pytest +from vllm.config.cache import CacheConfig from vllm.config.utils import get_hash_factors, hash_factors, normalize_value # Helpers @@ -201,3 +202,15 @@ def get_hash_in_subprocess(): "compile_factors hash differs between fresh initializations - " "dynamic env vars may not be properly ignored" ) + + +def test_cache_config_hash_ignores_kv_cache_sizing_knobs(): + """kv_cache_memory_bytes only sizes the KV cache allocation (like + gpu_memory_utilization, which is already ignored); it does not affect + the compiled computation graph. If it leaks into the hash, setting the + documented fast-boot knob silently invalidates the torch.compile cache + and forces a full recompile. + """ + base_hash = CacheConfig().compute_hash() + assert CacheConfig(kv_cache_memory_bytes=1 << 30).compute_hash() == base_hash + assert CacheConfig(gpu_memory_utilization=0.5).compute_hash() == base_hash diff --git a/tests/config/test_model_arch_config.py b/tests/config/test_model_arch_config.py index e172983b54f4..46790be6e4e1 100644 --- a/tests/config/test_model_arch_config.py +++ b/tests/config/test_model_arch_config.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from transformers import PretrainedConfig from vllm.config import ModelConfig, ParallelConfig, SpeculativeConfig from vllm.transformers_utils.model_arch_config_convertor import ( @@ -114,6 +115,22 @@ def _assert_model_config_methods( assert model_config.get_head_size() == expected["head_size"] +def test_head_size_falls_back_when_head_dim_is_zero(): + """Regression test for configs that materialize missing head_dim as 0.""" + hf_config = PretrainedConfig( + model_type="deepseek_vl_v2", + hidden_size=1280, + num_attention_heads=10, + num_key_value_heads=10, + head_dim=0, + kv_lora_rank=None, + ) + + convertor = ModelArchConfigConvertorBase(hf_config, hf_config) + + assert convertor.get_head_size() == 128 + + @pytest.mark.parametrize("model", BASE_MODELS_TO_TEST) def test_base_model_arch_config(model: str): """Test model architecture config for base models.""" diff --git a/tests/config/test_speculative_draft_hf_overrides.py b/tests/config/test_speculative_draft_hf_overrides.py new file mode 100644 index 000000000000..ddb8752a80d6 --- /dev/null +++ b/tests/config/test_speculative_draft_hf_overrides.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for SpeculativeConfig.compose_draft_hf_overrides. + +Callable ``hf_overrides`` on the target model config (e.g. the +``dummy_hf_overrides`` shrink used by ``tests/models/test_initialization.py``) +must also be applied when building the draft ``ModelConfig``. Otherwise a +draft belonging to a large target model is instantiated at full size even +when the target itself is shrunk — which is what kept spec-decode archs like +``EagleMistralLarge3ForCausalLM`` stuck at ``is_available_online=False`` +("TODO: revert once figuring out OOM in CI"). +""" + +import functools + +import pytest +from transformers import PretrainedConfig + +from vllm.config.speculative import SpeculativeConfig + + +def _make_hf_config(**kwargs) -> PretrainedConfig: + defaults = dict( + architectures=["LlamaForCausalLM"], + model_type="llama", + num_hidden_layers=64, + ) + defaults.update(kwargs) + return PretrainedConfig(**defaults) + + +@pytest.mark.cpu_test +def test_dict_overrides_are_not_forwarded_to_draft(): + """Dict overrides are target-specific key patches; the draft must get + only the architecture-mapping override.""" + composed = SpeculativeConfig.compose_draft_hf_overrides( + {"max_position_embeddings": 1234} + ) + assert composed is SpeculativeConfig.hf_config_override + + +@pytest.mark.cpu_test +def test_none_overrides_fall_back_to_arch_mapping(): + composed = SpeculativeConfig.compose_draft_hf_overrides(None) + assert composed is SpeculativeConfig.hf_config_override + + +@pytest.mark.cpu_test +def test_callable_overrides_reach_the_draft_config(): + """A callable override (config-to-config transform) composes with the + architecture-mapping override and is applied to the draft config.""" + + def shrink(hf_config: PretrainedConfig) -> PretrainedConfig: + hf_config.num_hidden_layers = 1 + return hf_config + + composed = SpeculativeConfig.compose_draft_hf_overrides(shrink) + assert composed is not SpeculativeConfig.hf_config_override + + out = composed(_make_hf_config()) + # The shrink transform must have been applied to the draft config. + assert out.num_hidden_layers == 1 + + +@pytest.mark.cpu_test +def test_arch_mapping_applies_before_callable_override(): + """The static arch-mapping override runs first, so the user callable + observes (and may adjust) the post-mapping config.""" + seen_architectures: list[str] = [] + + def record(hf_config: PretrainedConfig) -> PretrainedConfig: + seen_architectures.append(hf_config.architectures[0]) + return hf_config + + composed = SpeculativeConfig.compose_draft_hf_overrides(record) + + # MiMo is one of the arch-mapped model types: hf_config_override + # rewrites architectures to ["MiMoMTPModel"]. + mimo = _make_hf_config( + architectures=["MiMoForCausalLM"], + model_type="mimo", + num_nextn_predict_layers=1, + ) + composed(mimo) + assert seen_architectures == ["MiMoMTPModel"] + + +def _module_level_shrink(hf_config: PretrainedConfig) -> PretrainedConfig: + hf_config.num_hidden_layers = 1 + return hf_config + + +@pytest.mark.cpu_test +def test_composed_override_is_picklable(): + """The draft ``ModelConfig`` is sent to spawned engine-core processes, so + the composed override must be picklable. A nested local closure is not + (it raised ``Can't get local object`` on DFlashDraftModel); a + ``functools.partial`` over a module-referenceable static method is. + Guard against regressing to a closure.""" + composed = SpeculativeConfig.compose_draft_hf_overrides(_module_level_shrink) + + assert isinstance(composed, functools.partial) + assert composed.func is SpeculativeConfig._apply_composed_hf_override + + out = composed(_make_hf_config()) + assert out.num_hidden_layers == 1 diff --git a/tests/conftest.py b/tests/conftest.py index 3eaebc38bc63..94f7a83dd243 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,7 @@ from vllm.assets.audio import AudioAsset from vllm.assets.image import ImageAsset from vllm.assets.video import VideoAsset +from vllm.config.cache import CacheConfig from vllm.config.model import ConvertOption, RunnerOption, _get_and_verify_dtype from vllm.connections import global_http_connection from vllm.distributed import ( @@ -63,6 +64,7 @@ from vllm.multimodal.media import MediaWithBytes from vllm.multimodal.utils import fetch_image from vllm.outputs import RequestOutput +from vllm.platforms import current_platform from vllm.sampling_params import BeamSearchParams from vllm.transformers_utils.utils import maybe_model_redirect from vllm.utils.collection_utils import is_list_of @@ -72,7 +74,7 @@ if TYPE_CHECKING: - from transformers import PreTrainedTokenizer, PreTrainedTokenizerFast + from transformers import PythonBackend, TokenizersBackend from transformers.generation.utils import GenerateOutput @@ -497,7 +499,7 @@ def _init( self.model = model if not skip_tokenizer_init: - self.tokenizer: "PreTrainedTokenizer | PreTrainedTokenizerFast" = ( + self.tokenizer: "PythonBackend | TokenizersBackend" = ( AutoTokenizer.from_pretrained( tokenizer_name or model_name, trust_remote_code=trust_remote_code, @@ -851,11 +853,38 @@ def predict(self, prompts: list[list[str]], *args, **kwargs) -> torch.Tensor: return self.model.predict(prompts, *args, convert_to_tensor=True, **kwargs) def __enter__(self): + if current_platform.is_rocm(): + # Record starting memory usage stats on ROCm so that we can wait for + # memory to roughly settle back below these levels on shutdown. This is + # helpful in cases where the HfRunner is initialized after significant GPU + # memory is already occupied, e.g. in + # tests/basic_correctness/test_basic_correctness.py::test_models_distributed + from tests.utils import ( + get_physical_device_indices, + record_gpu_memory_usage_stats, + ) + + if (device_count := current_platform.device_count()) > 0: + devices = get_physical_device_indices(devices=list(range(device_count))) + mem_usage_stats = record_gpu_memory_usage_stats(devices=devices) + self.threshold_ratios = { + device: 0.05 + mem_used / mem_tot + for device, (mem_used, mem_tot) in mem_usage_stats.items() + } return self def __exit__(self, exc_type, exc_value, traceback): + from tests.utils import wait_for_rocm_memory_to_settle + del self.model cleanup_dist_env_and_memory() + # ROCm frees VRAM lazily; wait so a runner started right after this HF + # model exits does not OOM on its startup memory guard. + wait_for_rocm_memory_to_settle( + threshold_ratio=getattr(self, "threshold_ratios", None) + ) + if hasattr(self, "threshold_ratios"): + del self.threshold_ratios @pytest.fixture(scope="session") @@ -919,6 +948,20 @@ def __init__( num_speculative_tokens + 1 ) + from vllm.platforms import current_platform + + if current_platform.is_rocm(): + gpu_memory_utilization = kwargs.get( + "gpu_memory_utilization", + CacheConfig.gpu_memory_utilization, + ) + # V1 startup requires free_memory >= total * gpu_memory_utilization. + # ROCm CI can hand a test a device that is still lazily releasing + # VRAM from a previous process, so wait before constructing LLM. + from tests.utils import wait_for_rocm_memory_to_settle + + wait_for_rocm_memory_to_settle(threshold_ratio=1.0 - gpu_memory_utilization) + with init_ctx: self.llm = LLM( model=model_name, @@ -1221,10 +1264,6 @@ def token_classify(self, prompts: list[str]) -> list[list[float]]: req_outputs = self.llm.encode(prompts, pooling_task="token_classify") return [req_output.outputs.data for req_output in req_outputs] - def reward(self, prompts: list[str]) -> list[list[float]]: - req_outputs = self.llm.encode(prompts, pooling_task="token_classify") - return [req_output.outputs.data for req_output in req_outputs] - def score( self, text_1: list[str] | str, @@ -1248,25 +1287,13 @@ def __enter__(self): return self def _wait_for_rocm_memory_release(self, gpu_memory_utilization: float) -> None: - from tests.utils import wait_for_gpu_memory_to_clear - from vllm.platforms import current_platform - - if not current_platform.is_rocm(): - return - - num_gpus = torch.accelerator.device_count() - if num_gpus == 0: - return + from tests.utils import wait_for_rocm_memory_to_settle # V1 startup requires free_memory >= total * gpu_memory_utilization. # Wait for the complementary used-memory ratio so the next runner does - # not fail the startup guard immediately after this runner exits. Bound - # the wait so cleanup failures fail this test instead of hanging. - wait_for_gpu_memory_to_clear( - devices=list(range(num_gpus)), - threshold_ratio=1.0 - gpu_memory_utilization, - timeout_s=120, - ) + # not fail the startup guard immediately after this runner exits. The + # wait is bounded so cleanup failures fail this test instead of hanging. + wait_for_rocm_memory_to_settle(threshold_ratio=1.0 - gpu_memory_utilization) def __exit__(self, exc_type, exc_value, traceback): # Explicitly shutdown the engine core to release GPU resources @@ -1276,12 +1303,21 @@ def __exit__(self, exc_type, exc_value, traceback): gpu_memory_utilization = ( self.llm.llm_engine.vllm_config.cache_config.gpu_memory_utilization ) + from vllm.platforms import current_platform + try: - self.llm.llm_engine.engine_core.shutdown() + # Give the engine core time to run its own graceful shutdown + # (model_executor teardown + empty_cache + process-group destroy) + # before the process manager SIGKILLs it at the default 5s. On ROCm + # a hard kill leaves the whole allocation for the driver's slow async + # VRAM reclamation, which starves the next test's startup. + shutdown_timeout = 60.0 if current_platform.is_rocm() else None + self.llm.llm_engine.engine_core.shutdown(timeout=shutdown_timeout) except Exception: # Ignore shutdown errors as cleanup will still proceed pass del self.llm + torch._dynamo.reset() cleanup_dist_env_and_memory() self._wait_for_rocm_memory_release(gpu_memory_utilization) @@ -1551,7 +1587,13 @@ def do_GET(self): self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.end_headers() - self.wfile.write(data) + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError) as e: + logger.debug( + "Client disconnected while serving test asset %s: %r", filename, e + ) + self.close_connection = True def _find_free_port() -> int: diff --git a/tests/cuda/test_cuda_context.py b/tests/cuda/test_cuda_context.py index 6336f2112c66..16d2f16c2d8d 100644 --- a/tests/cuda/test_cuda_context.py +++ b/tests/cuda/test_cuda_context.py @@ -77,5 +77,43 @@ def test_set_cuda_context_invalid_device_type(self): current_platform.set_device(torch.device("cpu")) +def test_get_device_capability_uses_visible_device_ordinal(monkeypatch): + import vllm.platforms.interface as platform_interface + from vllm.platforms.cuda import NvmlCudaPlatform, pynvml + + seen_indices: list[int] = [] + + def record_handle(index: int) -> str: + seen_indices.append(index) + return f"handle-{index}" + + monkeypatch.setattr(platform_interface, "_assigned_physical_gpu_ids", [1]) + monkeypatch.setenv(NvmlCudaPlatform.device_control_env_var, "0,1") + monkeypatch.setattr( + NvmlCudaPlatform, + "device_control_id_to_physical_device_id", + classmethod(lambda _cls, device_id: int(device_id)), + ) + monkeypatch.setattr(pynvml, "nvmlInit", lambda: None) + monkeypatch.setattr(pynvml, "nvmlShutdown", lambda: None) + monkeypatch.setattr( + pynvml, + "nvmlDeviceGetHandleByIndex", + record_handle, + ) + monkeypatch.setattr( + pynvml, + "nvmlDeviceGetCudaComputeCapability", + lambda _handle: (9, 0), + ) + NvmlCudaPlatform.get_device_capability.cache_clear() + + capability = NvmlCudaPlatform.get_device_capability(device_id=1) + + assert capability is not None + assert capability.to_int() == 90 + assert seen_indices == [1] + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tests/distributed/conftest.py b/tests/distributed/conftest.py index da661c5e13ba..48df856f5e75 100644 --- a/tests/distributed/conftest.py +++ b/tests/distributed/conftest.py @@ -97,11 +97,12 @@ def __init__( for endpoint in pub_endpoints: self.sub.connect(endpoint) - # Set up replay sockets if provided + # Set up replay sockets if provided. + # DEALER allows receiving multiple replies per request. self.replay_sockets = [] if replay_endpoints: for replay_endpoint in replay_endpoints: - replay = self.ctx.socket(zmq.REQ) + replay = self.ctx.socket(zmq.DEALER) replay.connect(replay_endpoint) self.replay_sockets.append(replay) @@ -132,7 +133,9 @@ def request_replay(self, start_seq: int, socket_idx: int = 0) -> None: if socket_idx >= len(self.replay_sockets): raise ValueError(f"Invalid socket index {socket_idx}") - self.replay_sockets[socket_idx].send(start_seq.to_bytes(8, "big")) + self.replay_sockets[socket_idx].send_multipart( + [b"", start_seq.to_bytes(8, "big")] + ) def receive_replay(self, socket_idx: int = 0) -> list[tuple[int, SampleBatch]]: """Receive replayed messages from a specific replay socket""" @@ -148,12 +151,16 @@ def receive_replay(self, socket_idx: int = 0) -> list[tuple[int, SampleBatch]]: if not replay_socket.poll(1000): break + # DEALER receives [empty_delim, topic, seq, payload] frames = replay_socket.recv_multipart() - if not frames or not frames[-1]: + if frames and frames[0] == b"": + frames = frames[1:] + if len(frames) != 3 or not frames[-1]: # End of replay marker break - seq_bytes, payload = frames + topic, seq_bytes, payload = frames + assert topic == self.topic_bytes seq = int.from_bytes(seq_bytes, "big") data = self.decoder.decode(payload) replayed.append((seq, data)) diff --git a/tests/distributed/test_context_parallel.py b/tests/distributed/test_context_parallel.py index a28630921771..484d29c5b536 100644 --- a/tests/distributed/test_context_parallel.py +++ b/tests/distributed/test_context_parallel.py @@ -13,13 +13,14 @@ from dataclasses import dataclass from typing import Literal, NamedTuple +import lm_eval import pytest import torch -from tests.evals.gsm8k.gsm8k_eval import evaluate_gsm8k from tests.utils import RemoteOpenAIServer, create_new_process_for_each_test from vllm.config.model import RunnerOption from vllm.logger import init_logger +from vllm.platforms import current_platform from ..models.registry import HF_EXAMPLE_MODELS @@ -35,8 +36,10 @@ ] # GSM8K eval configuration -NUM_QUESTIONS = 256 # Fast eval for CI NUM_SHOTS = 5 # Few-shot examples +TASK = "gsm8k" +FILTER = "exact_match,strict-match" +NUM_CONCURRENT = 128 # tp accuracy with 2% buffer MIN_ACCURACY = { # .buildkite/lm-eval-harness/configs/DeepSeek-V2-Lite-Chat.yaml @@ -121,24 +124,34 @@ def iter_params(self, model_id: str): ) -CP_TEXT_GENERATION_MODELS = { - "deepseek-ai/DeepSeek-V2-Lite-Chat": [ - CPTestSettings.detailed(dcp_multipliers=[1]), - CPTestSettings.detailed( - dcp_multipliers=[0.5], - cp_kv_cache_interleave_size=64, - attn_backend="FLASHMLA", - ), - ], - "Qwen/Qwen2.5-1.5B-Instruct": [ - CPTestSettings.detailed( - cp_kv_cache_interleave_size=16, attn_backend="FLASH_ATTN" - ), - CPTestSettings.detailed( - cp_kv_cache_interleave_size=16, attn_backend="FLASHINFER" - ), - ], -} +if current_platform.is_rocm(): + CP_TEXT_GENERATION_MODELS = { + "deepseek-ai/DeepSeek-V2-Lite-Chat": [ + CPTestSettings.detailed(dcp_multipliers=[1]), + ], + "Qwen/Qwen2.5-1.5B-Instruct": [ + CPTestSettings.detailed(dcp_multipliers=[1]), + ], + } +else: + CP_TEXT_GENERATION_MODELS = { + "deepseek-ai/DeepSeek-V2-Lite-Chat": [ + CPTestSettings.detailed(dcp_multipliers=[1]), + CPTestSettings.detailed( + dcp_multipliers=[0.5], + cp_kv_cache_interleave_size=64, + attn_backend="FLASHMLA", + ), + ], + "Qwen/Qwen2.5-1.5B-Instruct": [ + CPTestSettings.detailed( + cp_kv_cache_interleave_size=16, attn_backend="FLASH_ATTN" + ), + CPTestSettings.detailed( + cp_kv_cache_interleave_size=16, attn_backend="FLASHINFER" + ), + ], + } def _test_cp_gsm8k( @@ -227,19 +240,23 @@ def _test_cp_gsm8k( server_args, max_wait_seconds=720, ) as remote_server: - host = f"http://{remote_server.host}" - port = remote_server.port - - # Run GSM8K evaluation - results = evaluate_gsm8k( - num_questions=NUM_QUESTIONS, - num_shots=NUM_SHOTS, - host=host, - port=port, + url = f"{remote_server.url_for('v1')}/completions" + + model_args = ( + f"model={model_id}," + f"base_url={url}," + f"num_concurrent={NUM_CONCURRENT},tokenized_requests=False" + ) + + results = lm_eval.simple_evaluate( + model="local-completions", + model_args=model_args, + tasks=TASK, + num_fewshot=NUM_SHOTS, ) # Validate accuracy is reasonable - accuracy = results["accuracy"] + accuracy = results["results"][TASK][FILTER] min_accuracy = MIN_ACCURACY[model_id] assert accuracy >= min_accuracy, ( f"TP+DCP accuracy too low: {accuracy:.3f} < {min_accuracy:.3f}" diff --git a/tests/distributed/test_dcp_a2a.py b/tests/distributed/test_dcp_a2a.py index d80ed36be650..5ab0f3de97b5 100644 --- a/tests/distributed/test_dcp_a2a.py +++ b/tests/distributed/test_dcp_a2a.py @@ -15,6 +15,7 @@ import torch import torch.distributed as dist +import vllm.envs as envs from vllm.config.parallel import ParallelConfig from vllm.utils.network_utils import get_open_port from vllm.utils.system_utils import update_environment_variables @@ -379,7 +380,13 @@ def _distributed_packed_a2a_worker(env: dict[str, str]) -> None: update_environment_variables(env) local_rank = int(env["LOCAL_RANK"]) torch.accelerator.set_device_index(local_rank) - dist.init_process_group(backend="nccl") + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + dist.init_process_group( + backend="cpu:gloo,cuda:nccl", + device_id=torch.device(f"cuda:{local_rank}"), + ) + else: + dist.init_process_group(backend="nccl") use_workspace = env.get("USE_WORKSPACE") == "1" if use_workspace: from vllm.v1.worker.workspace import init_workspace_manager diff --git a/tests/distributed/test_distributed_oot.py b/tests/distributed/test_distributed_oot.py index 9bd7603e731b..5f7f3ffa8a93 100644 --- a/tests/distributed/test_distributed_oot.py +++ b/tests/distributed/test_distributed_oot.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from tests.entrypoints.openai.chat_completion.test_oot_registration import ( +from tests.plugins_tests.test_oot_registration_online import ( run_and_test_dummy_opt_api_server, ) diff --git a/tests/distributed/test_elastic_ep.py b/tests/distributed/test_elastic_ep.py index 1d0f615d6ea9..4ce7497598ad 100644 --- a/tests/distributed/test_elastic_ep.py +++ b/tests/distributed/test_elastic_ep.py @@ -59,9 +59,8 @@ def _run_gsm8k_eval(server: RemoteOpenAIServer, stage: str) -> float: return accuracy -@multi_gpu_test(num_gpus=4) -def test_elastic_ep_scaling(): - vllm_serve_args = [ +def _base_serve_args(use_async_eplb: bool = False) -> list[str]: + args = [ "--trust-remote-code", "--tensor-parallel-size", "1", @@ -78,6 +77,12 @@ def test_elastic_ep_scaling(): "--enable-eplb", "--eplb-config.num_redundant_experts", "0", + "--eplb-config.use_async", + "true" if use_async_eplb else "false", + "--eplb-config.step_interval", + "10", + "--eplb-config.window_size", + "5", "--data-parallel-backend", "ray", "--data-parallel-size", @@ -88,7 +93,23 @@ def test_elastic_ep_scaling(): leader_address = os.environ.get("LEADER_ADDRESS") if leader_address: - vllm_serve_args.extend(["--data-parallel-address", leader_address]) + args.extend(["--data-parallel-address", leader_address]) + + return args + + +@pytest.mark.parametrize( + "use_async_eplb", [False, True], ids=["sync_eplb", "async_eplb"] +) +@multi_gpu_test(num_gpus=4) +def test_elastic_ep_scaling(use_async_eplb: bool): + if use_async_eplb: + from vllm.distributed.eplb.eplb_communicator import has_nixl + + if not has_nixl(): + pytest.skip("Async EPLB with elastic EP requires NIXL (not installed)") + + vllm_serve_args = _base_serve_args(use_async_eplb) with RemoteOpenAIServer( MODEL_NAME, vllm_serve_args, env_dict={}, max_wait_seconds=1200 @@ -126,42 +147,24 @@ def test_elastic_ep_scaling(): print(f" Tolerance: {ACCURACY_TOL:.3f}") +@pytest.mark.parametrize( + "use_async_eplb", [False, True], ids=["sync_eplb", "async_eplb"] +) @multi_gpu_test(num_gpus=4) -def test_elastic_ep_scaling_uneven(): +def test_elastic_ep_scaling_uneven(use_async_eplb: bool): """Test scale up with uneven worker distribution. This tests the case where num_new_workers % old_dp_size != 0, specifically 2 -> 3 where remainder = 1 % 2 = 1. This exercises the remainder handling in sender-receiver pairing. """ - vllm_serve_args = [ - "--trust-remote-code", - "--tensor-parallel-size", - "1", - "--gpu-memory-utilization", - "0.8", - "--max-model-len", - "4096", - "--max-num-seqs", - str(MAX_NUM_SEQS), - "--enable-expert-parallel", - "--all2all-backend", - "allgather_reducescatter", - "--enable-elastic-ep", - "--enable-eplb", - "--eplb-config.num_redundant_experts", - "0", - "--data-parallel-backend", - "ray", - "--data-parallel-size", - "2", - "--api-server-count", - "1", - ] + if use_async_eplb: + from vllm.distributed.eplb.eplb_communicator import has_nixl - leader_address = os.environ.get("LEADER_ADDRESS") - if leader_address: - vllm_serve_args.extend(["--data-parallel-address", leader_address]) + if not has_nixl(): + pytest.skip("Async EPLB with elastic EP requires NIXL (not installed)") + + vllm_serve_args = _base_serve_args(use_async_eplb) with RemoteOpenAIServer( MODEL_NAME, vllm_serve_args, env_dict={}, max_wait_seconds=1200 diff --git a/tests/distributed/test_eplb_execute.py b/tests/distributed/test_eplb_execute.py index 0b87477950fc..4c9b98b62cf5 100644 --- a/tests/distributed/test_eplb_execute.py +++ b/tests/distributed/test_eplb_execute.py @@ -644,9 +644,7 @@ def _test_rearrange_expert_weights_no_change(env, world_size) -> None: (2, 2, 2, 3), ], ) -@pytest.mark.parametrize( - "eplb_communicator", ["torch_nccl", "torch_gloo", "pynccl", "nixl"] -) +@pytest.mark.parametrize("eplb_communicator", ["torch_gloo", "nixl"]) def test_async_transfer_layer_without_mtp( world_size: int, num_layers: int, @@ -784,3 +782,125 @@ def test_rearrange_expert_weights_profile_mode(world_size): _test_rearrange_expert_weights_profile_mode, world_size, ) + + +def _test_nixl_deferred_init_worker( + env, + world_size: int, + num_layers: int, + num_local_experts: int, + num_logical_experts: int, +) -> None: + """Exercise NixlEplbCommunicator with defer_remote_setup=True (elastic EP path).""" + from vllm.distributed.eplb.eplb_communicator import NixlEplbCommunicator + + set_env_vars_and_device(env) + + vllm_config = VllmConfig() + vllm_config.parallel_config.tensor_parallel_size = world_size + + with set_current_vllm_config(vllm_config): + ensure_model_parallel_initialized( + tensor_model_parallel_size=world_size, pipeline_model_parallel_size=1 + ) + + ep_group_coordinator = get_tp_group() + ep_group = ep_group_coordinator.cpu_group + ep_rank = torch.distributed.get_rank() + device = torch.device(f"cuda:{ep_rank}") + + total_physical_experts = world_size * num_local_experts + hidden_sizes = [32, 64] + + redundancy_config = create_redundancy_config( + num_logical_experts, total_physical_experts + ) + old_indices = create_expert_indices_with_redundancy( + num_layers, + num_logical_experts, + total_physical_experts, + redundancy_config, + ) + + new_redundancy_config = create_redundancy_config( + num_logical_experts, total_physical_experts + ) + new_indices = create_expert_indices_with_redundancy( + num_layers, + num_logical_experts, + total_physical_experts, + new_redundancy_config, + ) + + expert_weights = create_expert_weights( + num_layers, num_local_experts, hidden_sizes, ep_rank, device, old_indices + ) + + expert_buffer = [torch.empty_like(w) for w in expert_weights[0]] + + communicator = NixlEplbCommunicator( + cpu_group=ep_group_coordinator.cpu_group, + all_expert_weights=expert_weights, + expert_buffer=expert_buffer, + defer_remote_setup=True, + ) + assert not communicator._remote_state_initialized + + rearrange_expert_weights_inplace( + old_indices, + new_indices, + expert_weights, + expert_buffer, + ep_group, + communicator, + ) + + assert communicator._remote_state_initialized + + local_ok = verify_expert_weights_after_shuffle( + expert_weights, + new_indices, + hidden_sizes, + ep_rank, + num_local_experts, + ) + + local_ok = ( + verify_redundant_experts_have_same_weights( + expert_weights, + new_indices, + hidden_sizes, + ep_rank, + world_size, + num_local_experts, + ) + and local_ok + ) + assert_verification_synced( + local_ok, + "Deferred NIXL init verification failed on at least one rank.", + ) + + +@pytest.mark.skipif(not has_nixl(), reason="NIXL is not available") +@pytest.mark.parametrize( + "world_size,num_layers,num_local_experts,num_logical_experts", + [(2, 2, 3, 4)], +) +def test_nixl_deferred_init( + world_size, + num_layers, + num_local_experts, + num_logical_experts, +): + """Test NixlEplbCommunicator with defer_remote_setup=True (elastic EP path).""" + + if torch.accelerator.device_count() < world_size: + pytest.skip(f"Need at least {world_size} GPUs to run the test") + distributed_run( + _test_nixl_deferred_init_worker, + world_size, + num_layers, + num_local_experts, + num_logical_experts, + ) diff --git a/tests/distributed/test_eplb_fused_moe_layer.py b/tests/distributed/test_eplb_fused_moe_layer.py index 87ed4485d3d8..7d5e58b26ef8 100644 --- a/tests/distributed/test_eplb_fused_moe_layer.py +++ b/tests/distributed/test_eplb_fused_moe_layer.py @@ -77,9 +77,9 @@ def make_fused_moe_layer( intermediate_size=test_config.intermediate_size, prefix=f"dummy_layer_{layer_idx}", activation="silu", - is_act_and_mul=True, params_dtype=test_config.weight_dtype, ) + re = fml.routed_experts device = torch.device(f"cuda:{rank}") @@ -92,12 +92,12 @@ def make_fused_moe_layer( tensor_device=device, ) - assert isinstance(fml.w13_weight.data, torch.Tensor) - assert isinstance(fml.w2_weight.data, torch.Tensor) - fml.w13_weight.data = fml.w13_weight.data.to(device=device) - fml.w2_weight.data = fml.w2_weight.data.to(device=device) - w13_weight = fml.w13_weight.data - w2_weight = fml.w2_weight.data + assert isinstance(re.w13_weight.data, torch.Tensor) + assert isinstance(re.w2_weight.data, torch.Tensor) + re.w13_weight.data = re.w13_weight.data.to(device=device) + re.w2_weight.data = re.w2_weight.data.to(device=device) + w13_weight = re.w13_weight.data + w2_weight = re.w2_weight.data assert w13_weight.size(0) == test_config.num_local_experts for i in range(test_config.num_local_experts): g_i = rank * test_config.num_local_experts + i @@ -172,10 +172,10 @@ def block_quant_scales_shape( assert not w2_weight_scale_inv.is_contiguous() # Add scales to the parameter list - fml.w13_weight_scale_inv = torch.nn.Parameter( + re.w13_weight_scale_inv = torch.nn.Parameter( w13_weight_scale_inv, requires_grad=False ) - fml.w2_weight_scale_inv = torch.nn.Parameter( + re.w2_weight_scale_inv = torch.nn.Parameter( w2_weight_scale_inv, requires_grad=False ) diff --git a/tests/distributed/test_eplb_fused_moe_layer_dep_nvfp4.py b/tests/distributed/test_eplb_fused_moe_layer_dep_nvfp4.py index 4818f8a2c8c0..e2d54821ce9c 100644 --- a/tests/distributed/test_eplb_fused_moe_layer_dep_nvfp4.py +++ b/tests/distributed/test_eplb_fused_moe_layer_dep_nvfp4.py @@ -37,6 +37,7 @@ class TestConfig: hidden_size: int intermediate_size: int num_tokens: int + moe_backend: str def make_fused_moe_layer( @@ -61,7 +62,6 @@ def make_fused_moe_layer( intermediate_size=test_config.intermediate_size, prefix=f"dummy_layer_{layer_idx}", activation="silu", - is_act_and_mul=True, params_dtype=torch.bfloat16, quant_config=quant_config, ) @@ -77,6 +77,7 @@ def make_fused_moe_layer( ) fml = fml.to(device) + re = fml.routed_experts w1_q, w2_q, quant_config = make_test_quant_config( test_config.num_local_experts, test_config.intermediate_size, @@ -87,21 +88,21 @@ def make_fused_moe_layer( per_act_token_quant=False, ) - fml.w13_weight.data = w1_q - fml.w2_weight.data = w2_q + re.w13_weight.data = w1_q + re.w2_weight.data = w2_q - fml.w2_input_scale.data = torch.randn_like(fml.w2_input_scale.data) / 5 - fml.w13_input_scale.data = torch.randn_like(fml.w13_input_scale.data) / 5 - fml.w2_weight_scale_2.data = torch.randn_like(fml.w2_weight_scale_2.data) / 5 - fml.w13_weight_scale_2.data = torch.randn_like(fml.w13_weight_scale_2.data) / 5 - fml.w2_weight_scale.data = ( - torch.randn(fml.w2_weight_scale.data.shape, device=device) / 5 - ).to(fml.w2_weight_scale.data.dtype) - fml.w13_weight_scale.data = ( - torch.randn(fml.w13_weight_scale.data.shape, device=device) / 5 - ).to(fml.w13_weight_scale.data.dtype) + re.w2_input_scale.data = torch.randn_like(re.w2_input_scale.data) / 5 + re.w13_input_scale.data = torch.randn_like(re.w13_input_scale.data) / 5 + re.w2_weight_scale_2.data = torch.randn_like(re.w2_weight_scale_2.data) / 5 + re.w13_weight_scale_2.data = torch.randn_like(re.w13_weight_scale_2.data) / 5 + re.w2_weight_scale.data = ( + torch.randn(re.w2_weight_scale.data.shape, device=device) / 5 + ).to(re.w2_weight_scale.data.dtype) + re.w13_weight_scale.data = ( + torch.randn(re.w13_weight_scale.data.shape, device=device) / 5 + ).to(re.w13_weight_scale.data.dtype) - nvfp4_fused_moe.process_weights_after_loading(fml) + nvfp4_fused_moe.process_weights_after_loading(re) fml.maybe_init_modular_kernel() @@ -114,6 +115,7 @@ def _test_eplb_fml(env, world_size: int, test_config: TestConfig): vllm_config = VllmConfig() vllm_config.parallel_config.data_parallel_size = world_size vllm_config.parallel_config.enable_expert_parallel = True + vllm_config.kernel_config.moe_backend = test_config.moe_backend with set_current_vllm_config(vllm_config): ensure_model_parallel_initialized( @@ -223,6 +225,12 @@ def _test_eplb_fml(env, world_size: int, test_config: TestConfig): logical_to_physical_map, logical_replica_count, ) + fml.router.eplb_state.should_record_tensor = torch.ones( + (), dtype=torch.bool, device=device + ) + fml.router.eplb_state.num_unpadded_tokens_tensors = [ + torch.tensor(0, dtype=torch.int32, device=device) + ] out_after_shuffle = [] with set_forward_context( @@ -250,7 +258,7 @@ def _test_eplb_fml(env, world_size: int, test_config: TestConfig): @pytest.mark.parametrize("hidden_size", [256]) @pytest.mark.parametrize("intermediate_size", [256]) @pytest.mark.parametrize("num_tokens", [256]) -@pytest.mark.parametrize("backend", ["latency", "throughput"]) +@pytest.mark.parametrize("moe_backend", ["flashinfer_trtllm", "flashinfer_cutlass"]) def test_eplb_fml( world_size: int, num_layers: int, @@ -258,12 +266,8 @@ def test_eplb_fml( hidden_size: int, intermediate_size: int, num_tokens: int, - backend: str, - monkeypatch, + moe_backend: str, ): - monkeypatch.setenv("VLLM_USE_FLASHINFER_MOE_FP4", "1") - monkeypatch.setenv("VLLM_FLASHINFER_MOE_BACKEND", backend) - if torch.accelerator.device_count() < world_size: pytest.skip(f"Need at least {world_size} GPUs to run the test") @@ -278,6 +282,7 @@ def test_eplb_fml( hidden_size=hidden_size, intermediate_size=intermediate_size, num_tokens=num_tokens, + moe_backend=moe_backend, ) distributed_run( diff --git a/tests/distributed/test_events.py b/tests/distributed/test_events.py index f17b7997c588..9b5601ad1d93 100644 --- a/tests/distributed/test_events.py +++ b/tests/distributed/test_events.py @@ -80,20 +80,38 @@ def test_replay_mechanism(publisher, subscriber): batch = create_test_events(1) publisher.publish(batch) - time.sleep(0.5) # Need publisher to process above requests - subscriber.request_replay(10) + # Drain live events to ensure publisher has buffered them. + for _ in range(19): + assert subscriber.receive_one(timeout=1000) is not None - batch = create_test_events(1) - publisher.publish(batch) # 20th message + subscriber.request_replay(10) replayed = subscriber.receive_replay() - assert len(replayed) > 0, "No replayed messages received" - seqs = [seq for seq, _ in replayed] - assert all(seq >= 10 for seq in seqs), "Replayed messages not in order" - assert seqs == list(range(min(seqs), max(seqs) + 1)), ( - "Replayed messages not consecutive" + assert len(replayed) == 9, ( + f"Expected 9 replayed messages (seq 10-18), got {len(replayed)}" ) + seqs = [seq for seq, _ in replayed] + assert seqs == list(range(10, 19)), "Replayed sequences should be 10-18" + + +def test_replay_includes_topic(publisher, subscriber, publisher_config): + """Test that replay responses include the topic, matching PUB format""" + for _ in range(5): + publisher.publish(create_test_events(1)) + + # Drain live events to ensure publisher has processed them. + for _ in range(5): + assert subscriber.receive_one(timeout=1000) is not None + + subscriber.request_replay(0) + + # receive_replay unpacks (topic, seq, payload) and asserts + # topic == publisher topic for each message. + replayed = subscriber.receive_replay() + assert len(replayed) == 5, f"Expected 5 replayed messages, got {len(replayed)}" + seqs = [seq for seq, _ in replayed] + assert seqs == list(range(5)), "Replayed sequences should be 0-4" def test_buffer_limit(publisher, subscriber, publisher_config): @@ -108,15 +126,16 @@ def test_buffer_limit(publisher, subscriber, publisher_config): time.sleep(0.5) # Need publisher to process above requests subscriber.request_replay(0) - batch = create_test_events(1) - publisher.publish(batch) - replayed = subscriber.receive_replay() - assert len(replayed) <= buffer_size, "Can't replay more than buffer size" + assert len(replayed) == buffer_size, ( + f"Expected {buffer_size} replayed messages, got {len(replayed)}" + ) - oldest_seq = min(seq for seq, _ in replayed) - assert oldest_seq >= 10, "The oldest sequence should be at least 10" + seqs = [seq for seq, _ in replayed] + assert seqs == list(range(10, buffer_size + 10)), ( + "Should replay seq 11 through buffer_size+10" + ) def test_topic_filtering(publisher_config): diff --git a/tests/distributed/test_mnnvl_alltoall.py b/tests/distributed/test_mnnvl_alltoall.py index 875b65ff084c..95c905fc0803 100644 --- a/tests/distributed/test_mnnvl_alltoall.py +++ b/tests/distributed/test_mnnvl_alltoall.py @@ -19,6 +19,7 @@ has_flashinfer_nvlink_one_sided, has_flashinfer_nvlink_two_sided, ) +from vllm.utils.import_utils import has_deep_ep_v2 from vllm.utils.network_utils import get_open_port from ..utils import init_test_distributed_environment @@ -194,6 +195,10 @@ class _AttnMeta: not _has_sys_ptrace(), reason="SYS_PTRACE required (docker run --cap-add=SYS_PTRACE)", ) +requires_deep_ep_v2 = pytest.mark.skipif( + not has_deep_ep_v2(), + reason="DeepEP v2 (ElasticBuffer) not available or NCCL < 2.30.4", +) # NOTE: No module-level pytestmark here. The FlashInfer lifecycle tests have # their own @requires_two_sided / @requires_one_sided decorators, and @@ -742,6 +747,11 @@ def _one_sided_data_worker(rank, world_size): top_k=experts_per_token, num_experts=num_experts, hidden_size=hidden_size, + # Account for the fp8 block-scale payload (a1q_scale: hidden//16 bytes + # per token) that is dispatched alongside the nvfp4 hidden states. + # Without this the dispatch region is under-reserved and the combine + # payload overflows the per-rank workspace. + dispatch_scale_bytes_per_token=hidden_size // 16, ) assert manager.initialized assert manager.moe_alltoall is not None @@ -856,3 +866,76 @@ def _one_sided_data_worker(rank, world_size): def test_one_sided_dispatch_combine(world_size): """Test FlashInfer one-sided dispatch/combine with actual data flow.""" _spawn_workers(_one_sided_data_worker, world_size, dp_size=world_size) + + +# --------------------------------------------------------------------------- +# Test 6: DeepEP v2 (ElasticBuffer) manager lifecycle +# --------------------------------------------------------------------------- +# +# Tests DeepEPV2All2AllManager which wraps DeepEP's ElasticBuffer API using +# the NCCL GIN backend. Requires DeepEP >= 2.0 and NCCL >= 2.30.4. +# +# Uses EP group because the DeepEP v2 manager is constructed with an +# EP-scoped communicator in production. With tp=world_size the EP group +# spans all ranks. +# --------------------------------------------------------------------------- + + +def _deepep_v2_lifecycle_worker(rank, world_size): + from vllm.distributed.device_communicators.all2all import ( + DeepEPV2All2AllManager, + ) + + cpu_group = get_ep_group().cpu_group + manager = DeepEPV2All2AllManager(cpu_group) + + assert manager.rank == rank + assert manager.world_size == world_size + assert manager._num_sms is None + + hidden_size = 7168 + num_experts = world_size * 32 + num_topk = 8 + max_tokens = 256 + + handle_kwargs = dict( + num_max_tokens_per_rank=max_tokens, + hidden=hidden_size, + num_topk=num_topk, + num_experts=num_experts, + use_fp8_dispatch=False, + ) + + handle = manager.get_handle(handle_kwargs) + assert handle is not None + assert manager._num_sms is not None + assert manager._num_sms > 0 + + torch.distributed.barrier() + + # get_handle again with same args should return cached handle + handle2 = manager.get_handle(dict(handle_kwargs)) + assert handle2 is handle + + torch.distributed.barrier() + + # Destroy clears the cache + manager.destroy() + assert len(manager.handle_cache._cache) == 0 + + torch.distributed.barrier() + + # Re-create after destroy + handle3 = manager.get_handle(dict(handle_kwargs)) + assert handle3 is not None + + torch.distributed.barrier() + manager.destroy() + + +@requires_multi_gpu +@requires_deep_ep_v2 +@pytest.mark.parametrize("world_size", [2]) +def test_deepep_v2_manager_lifecycle(world_size): + """Test DeepEP v2 ElasticBuffer manager init, caching, and destroy.""" + _spawn_workers(_deepep_v2_lifecycle_worker, world_size) diff --git a/tests/distributed/test_multiproc_executor.py b/tests/distributed/test_multiproc_executor.py index 20dd4f36393e..cbdc02527064 100644 --- a/tests/distributed/test_multiproc_executor.py +++ b/tests/distributed/test_multiproc_executor.py @@ -283,9 +283,14 @@ def test_multiproc_executor_pipeline_parallel(): output_rank = executor._get_output_rank() assert output_rank == 2, "Output rank should be 2 (first rank of last PP stage)" - # Verify max_concurrent_batches for pipeline parallel - assert vllm_config.max_concurrent_batches == 2, ( - "Max concurrent batches should equal PP size" + # V2 model runner uses one extra batch to overlap async scheduling. + expected_concurrent_batches = 2 + int( + vllm_config.scheduler_config.async_scheduling + and vllm_config.use_v2_model_runner + ) + assert vllm_config.max_concurrent_batches == expected_concurrent_batches, ( + "Max concurrent batches should follow the configured PP/async " + "scheduling policy" ) finally: diff --git a/tests/distributed/test_nccl_symm_mem.py b/tests/distributed/test_nccl_symm_mem.py new file mode 100644 index 000000000000..bd0270fc4849 --- /dev/null +++ b/tests/distributed/test_nccl_symm_mem.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import random +import typing + +import pytest +import torch +import torch.distributed as dist +import torch.multiprocessing as mp + +import vllm.envs as envs +from tests.utils import ensure_current_vllm_config +from vllm.distributed import cleanup_dist_env_and_memory +from vllm.distributed.device_communicators.cuda_communicator import CudaCommunicator +from vllm.distributed.device_communicators.pynccl import register_nccl_symmetric_ops +from vllm.distributed.device_communicators.pynccl_allocator import ( + get_nccl_mem_pool, + is_symmetric_memory_enabled, +) +from vllm.distributed.parallel_state import ( + get_tp_group, + init_distributed_environment, + initialize_model_parallel, +) +from vllm.platforms import current_platform +from vllm.utils.system_utils import update_environment_variables + +torch.manual_seed(42) +random.seed(44) + +test_size_elements = 4 * 1024 * 1024 + + +def nccl_symm_mem_allreduce_worker(local_rank: int, world_size: int): + monkeypatch = pytest.MonkeyPatch() + with monkeypatch.context() as m: + m.delenv("CUDA_VISIBLE_DEVICES", raising=False) + dtype = torch.bfloat16 + device = torch.device(f"cuda:{local_rank}") + torch.accelerator.set_device_index(device) + torch.set_default_device(device) + torch.set_default_dtype(dtype) + update_environment_variables( + { + "RANK": str(local_rank), + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": "12345", + } + ) + + init_distributed_environment() + with ensure_current_vllm_config(): + initialize_model_parallel(tensor_model_parallel_size=world_size) + + cuda_communicator = typing.cast( + CudaCommunicator, get_tp_group().device_communicator + ) + pynccl_comm = cuda_communicator.pynccl_comm + if get_nccl_mem_pool() is None: + pytest.skip( + "NCCL allocator compilation failed (probably missing NCCL headers)." + ) + if not is_symmetric_memory_enabled(): + pytest.skip("NCCL symmetric memory allreduce is disabled.") + + register_nccl_symmetric_ops(pynccl_comm) + input = torch.randint(1, 23, (test_size_elements,), dtype=dtype, device=device) + input_clone = input.clone() + output = torch.ops.vllm.all_reduce_symmetric_with_copy(input) + assert output is not None + + group = get_tp_group().device_group + dist.all_reduce(input_clone, group=group) + torch.testing.assert_close(output, input_clone, atol=2.5, rtol=0.1) + + +@pytest.mark.skipif( + not current_platform.is_cuda(), + reason="NCCLSymmMemAllreduce is only available for CUDA platforms.", +) +@pytest.mark.parametrize("world_size", [2]) +@pytest.mark.skipif(envs.VLLM_TARGET_DEVICE not in ["cuda"], reason="Only test on CUDA") +def test_nccl_symm_mem_allreduce(monkeypatch: pytest.MonkeyPatch, world_size): + if world_size > torch.accelerator.device_count(): + pytest.skip("Not enough GPUs to run the test.") + + # Enable SymmMemCommunicator + monkeypatch.setenv("VLLM_USE_NCCL_SYMM_MEM", "1") + monkeypatch.setenv("NCCL_NVLS_ENABLE", "1") + monkeypatch.setenv("NCCL_CUMEM_ENABLE", "1") + + mp.spawn(nccl_symm_mem_allreduce_worker, args=(world_size,), nprocs=world_size) + cleanup_dist_env_and_memory() + + +def nccl_symm_mem_allgather_worker(local_rank: int, world_size: int): + monkeypatch = pytest.MonkeyPatch() + with monkeypatch.context() as m: + m.delenv("CUDA_VISIBLE_DEVICES", raising=False) + dtype = torch.bfloat16 + device = torch.device(f"cuda:{local_rank}") + torch.accelerator.set_device_index(device) + torch.set_default_device(device) + torch.set_default_dtype(dtype) + update_environment_variables( + { + "RANK": str(local_rank), + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": "12346", + } + ) + + init_distributed_environment() + with ensure_current_vllm_config(): + initialize_model_parallel(tensor_model_parallel_size=world_size) + + cuda_communicator = typing.cast( + CudaCommunicator, get_tp_group().device_communicator + ) + if get_nccl_mem_pool() is None: + pytest.skip( + "NCCL allocator compilation failed (probably missing NCCL headers)." + ) + if not is_symmetric_memory_enabled(): + pytest.skip("NCCL symmetric memory is disabled.") + + per_rank_size = test_size_elements // world_size + input_tensor = torch.randint( + 1, 23, (per_rank_size,), dtype=dtype, device=device + ) + output = cuda_communicator.all_gatherv(input_tensor, dim=0) + + group = get_tp_group().device_group + expected = torch.empty(test_size_elements, dtype=dtype, device=device) + dist.all_gather_into_tensor(expected, input_tensor, group=group) + torch.testing.assert_close(output, expected, atol=0.0, rtol=0.0) + + +@pytest.mark.skipif( + not current_platform.is_cuda(), + reason="NCCL symmetric memory is only available for CUDA platforms.", +) +@pytest.mark.parametrize("world_size", [2]) +@pytest.mark.skipif(envs.VLLM_TARGET_DEVICE not in ["cuda"], reason="Only test on CUDA") +def test_nccl_symm_mem_allgather(monkeypatch: pytest.MonkeyPatch, world_size): + if world_size > torch.accelerator.device_count(): + pytest.skip("Not enough GPUs to run the test.") + + monkeypatch.setenv("VLLM_USE_NCCL_SYMM_MEM", "1") + monkeypatch.setenv("NCCL_NVLS_ENABLE", "1") + monkeypatch.setenv("NCCL_CUMEM_ENABLE", "1") + + mp.spawn(nccl_symm_mem_allgather_worker, args=(world_size,), nprocs=world_size) + cleanup_dist_env_and_memory() + + +def nccl_symm_mem_reduce_scatter_worker(local_rank: int, world_size: int): + monkeypatch = pytest.MonkeyPatch() + with monkeypatch.context() as m: + m.delenv("CUDA_VISIBLE_DEVICES", raising=False) + dtype = torch.bfloat16 + device = torch.device(f"cuda:{local_rank}") + torch.accelerator.set_device_index(device) + torch.set_default_device(device) + torch.set_default_dtype(dtype) + update_environment_variables( + { + "RANK": str(local_rank), + "LOCAL_RANK": str(local_rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": "12347", + } + ) + + init_distributed_environment() + with ensure_current_vllm_config(): + initialize_model_parallel(tensor_model_parallel_size=world_size) + + cuda_communicator = typing.cast( + CudaCommunicator, get_tp_group().device_communicator + ) + if get_nccl_mem_pool() is None: + pytest.skip( + "NCCL allocator compilation failed (probably missing NCCL headers)." + ) + if not is_symmetric_memory_enabled(): + pytest.skip("NCCL symmetric memory is disabled.") + + per_rank_size = test_size_elements // world_size + input_tensor = torch.randint( + 1, 23, (test_size_elements,), dtype=dtype, device=device + ) + input_clone = input_tensor.clone() + output = cuda_communicator.reduce_scatter(input_tensor, dim=0) + + group = get_tp_group().device_group + expected = torch.empty(per_rank_size, dtype=dtype, device=device) + dist.reduce_scatter_tensor(expected, input_clone, group=group) + torch.testing.assert_close(output, expected, atol=2.5, rtol=0.1) + + +@pytest.mark.skipif( + not current_platform.is_cuda(), + reason="NCCL symmetric memory is only available for CUDA platforms.", +) +@pytest.mark.parametrize("world_size", [2]) +@pytest.mark.skipif(envs.VLLM_TARGET_DEVICE not in ["cuda"], reason="Only test on CUDA") +def test_nccl_symm_mem_reduce_scatter(monkeypatch: pytest.MonkeyPatch, world_size): + if world_size > torch.accelerator.device_count(): + pytest.skip("Not enough GPUs to run the test.") + + monkeypatch.setenv("VLLM_USE_NCCL_SYMM_MEM", "1") + monkeypatch.setenv("NCCL_NVLS_ENABLE", "1") + monkeypatch.setenv("NCCL_CUMEM_ENABLE", "1") + + mp.spawn(nccl_symm_mem_reduce_scatter_worker, args=(world_size,), nprocs=world_size) + cleanup_dist_env_and_memory() diff --git a/tests/distributed/test_nccl_symm_mem_allreduce.py b/tests/distributed/test_nccl_symm_mem_allreduce.py deleted file mode 100644 index 420bf631d73c..000000000000 --- a/tests/distributed/test_nccl_symm_mem_allreduce.py +++ /dev/null @@ -1,96 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import random -import typing - -import pytest -import torch -import torch.distributed as dist -import torch.multiprocessing as mp - -import vllm.envs as envs -from tests.utils import ensure_current_vllm_config -from vllm.distributed import cleanup_dist_env_and_memory -from vllm.distributed.device_communicators.cuda_communicator import CudaCommunicator -from vllm.distributed.device_communicators.pynccl import register_nccl_symmetric_ops -from vllm.distributed.device_communicators.pynccl_allocator import ( - get_nccl_mem_pool, - is_symmetric_memory_enabled, -) -from vllm.distributed.parallel_state import ( - get_tp_group, - init_distributed_environment, - initialize_model_parallel, -) -from vllm.platforms import current_platform -from vllm.utils.system_utils import update_environment_variables - -torch.manual_seed(42) -random.seed(44) - -test_size_elements = 4 * 1024 * 1024 - - -def nccl_symm_mem_allreduce_worker(local_rank: int, world_size: int): - monkeypatch = pytest.MonkeyPatch() - with monkeypatch.context() as m: - m.delenv("CUDA_VISIBLE_DEVICES", raising=False) - dtype = torch.bfloat16 - device = torch.device(f"cuda:{local_rank}") - torch.accelerator.set_device_index(device) - torch.set_default_device(device) - torch.set_default_dtype(dtype) - update_environment_variables( - { - "RANK": str(local_rank), - "LOCAL_RANK": str(local_rank), - "WORLD_SIZE": str(world_size), - "MASTER_ADDR": "localhost", - "MASTER_PORT": "12345", - } - ) - - init_distributed_environment() - with ensure_current_vllm_config(): - initialize_model_parallel(tensor_model_parallel_size=world_size) - - cuda_communicator = typing.cast( - CudaCommunicator, get_tp_group().device_communicator - ) - pynccl_comm = cuda_communicator.pynccl_comm - if get_nccl_mem_pool() is None: - pytest.skip( - "NCCL allocator compilation failed (probably missing NCCL headers)." - ) - if not is_symmetric_memory_enabled(): - pytest.skip("NCCL symmetric memory allreduce is disabled.") - - register_nccl_symmetric_ops(pynccl_comm) - input = torch.randint(1, 23, (test_size_elements,), dtype=dtype, device=device) - input_clone = input.clone() - output = torch.ops.vllm.all_reduce_symmetric_with_copy(input) - assert output is not None - - group = get_tp_group().device_group - dist.all_reduce(input_clone, group=group) - torch.testing.assert_close(output, input_clone, atol=2.5, rtol=0.1) - - -@pytest.mark.skipif( - not current_platform.is_cuda(), - reason="NCCLSymmMemAllreduce is only available for CUDA platforms.", -) -@pytest.mark.parametrize("world_size", [2]) -@pytest.mark.skipif(envs.VLLM_TARGET_DEVICE not in ["cuda"], reason="Only test on CUDA") -def test_nccl_symm_mem_allreduce(monkeypatch: pytest.MonkeyPatch, world_size): - if world_size > torch.accelerator.device_count(): - pytest.skip("Not enough GPUs to run the test.") - - # Enable SymmMemCommunicator - monkeypatch.setenv("VLLM_USE_NCCL_SYMM_MEM", "1") - monkeypatch.setenv("NCCL_NVLS_ENABLE", "1") - monkeypatch.setenv("NCCL_CUMEM_ENABLE", "1") - - mp.spawn(nccl_symm_mem_allreduce_worker, args=(world_size,), nprocs=world_size) - cleanup_dist_env_and_memory() diff --git a/tests/distributed/test_pipeline_parallel.py b/tests/distributed/test_pipeline_parallel.py index c2dda1b51cfb..fd4a1d0f570b 100644 --- a/tests/distributed/test_pipeline_parallel.py +++ b/tests/distributed/test_pipeline_parallel.py @@ -102,11 +102,7 @@ def iter_params(self, model_id: str): TEXT_GENERATION_MODELS = { # [Decoder-only] - # Uses Llama - # "BAAI/AquilaChat-7B": PPTestSettings.fast(), "Snowflake/snowflake-arctic-instruct": PPTestSettings.fast(load_format="dummy"), - "baichuan-inc/Baichuan-7B": PPTestSettings.fast(), - "baichuan-inc/Baichuan2-13B-Chat": PPTestSettings.fast(), "bigscience/bloomz-1b1": PPTestSettings.fast(), "zai-org/chatglm3-6b": PPTestSettings.fast(), "CohereLabs/c4ai-command-r-v01": PPTestSettings.fast(load_format="dummy"), @@ -118,14 +114,11 @@ def iter_params(self, model_id: str): "tiiuae/falcon-7b": PPTestSettings.fast(), "google/gemma-1.1-2b-it": PPTestSettings.fast(), "google/gemma-2-9b": PPTestSettings.fast(), - "gpt2": PPTestSettings.fast(), - "bigcode/starcoder": PPTestSettings.fast(), + "openai-community/gpt2": PPTestSettings.fast(), "EleutherAI/gpt-j-6b": PPTestSettings.fast(), "EleutherAI/pythia-1.4b": PPTestSettings.fast(), "ibm/PowerLM-3b": PPTestSettings.fast(), "ibm/PowerMoE-3b": PPTestSettings.fast(), - # Uses Llama - # "internlm/internlm-chat-7b": PPTestSettings.fast(), "internlm/internlm2-chat-7b": PPTestSettings.fast(), "ai21labs/Jamba-tiny-dev": PPTestSettings.fast(), "pfnet/plamo-2-1b": PPTestSettings.fast(), @@ -146,21 +139,16 @@ def iter_params(self, model_id: str): "allenai/OLMoE-1B-7B-0924-Instruct": PPTestSettings.fast(), "facebook/opt-iml-max-1.3b": PPTestSettings.fast(), "OrionStarAI/Orion-14B-Chat": PPTestSettings.fast(), - "adept/persimmon-8b-chat": PPTestSettings.fast(), "microsoft/phi-2": PPTestSettings.fast(), "microsoft/Phi-3-small-8k-instruct": PPTestSettings.fast(), "microsoft/Phi-3.5-MoE-instruct": PPTestSettings.detailed( multi_node_only=True, load_format="dummy" ), - "Qwen/Qwen-7B-Chat": PPTestSettings.fast(), "Qwen/Qwen2.5-0.5B-Instruct": PPTestSettings.fast(), "Qwen/Qwen1.5-MoE-A2.7B-Chat": PPTestSettings.fast(), "stabilityai/stablelm-3b-4e1t": PPTestSettings.fast(), "bigcode/starcoder2-3b": PPTestSettings.fast(), "upstage/solar-pro-preview-instruct": PPTestSettings.fast(load_format="dummy"), - # FIXME: Cannot load tokenizer in latest transformers version. - # Need to use tokenizer from `meta-llama/Llama-2-7b-chat-hf` - # "xverse/XVERSE-7B-Chat": PPTestSettings.fast(), # [Encoder-only] # TODO: Implement PP # "facebook/bart-base": PPTestSettings.fast(), @@ -179,9 +167,8 @@ def iter_params(self, model_id: str): # [Decoder-only] "Salesforce/blip2-opt-6.7b": PPTestSettings.fast(), "facebook/chameleon-7b": PPTestSettings.fast(), - "adept/fuyu-8b": PPTestSettings.fast(), "zai-org/glm-4v-9b": PPTestSettings.fast(), - "OpenGVLab/InternVL2-1B": PPTestSettings.fast(), + "OpenGVLab/InternVL3-1B": PPTestSettings.fast(), "llava-hf/llava-1.5-7b-hf": PPTestSettings.fast(), "llava-hf/llava-v1.6-mistral-7b-hf": PPTestSettings.fast(), "llava-hf/LLaVA-NeXT-Video-7B-hf": PPTestSettings.fast(), @@ -192,7 +179,6 @@ def iter_params(self, model_id: str): "AIDC-AI/Ovis2.5-2B": PPTestSettings.fast(), "microsoft/Phi-3.5-vision-instruct": PPTestSettings.fast(), "mistralai/Pixtral-12B-2409": PPTestSettings.fast(load_format="dummy"), - "Qwen/Qwen-VL-Chat": PPTestSettings.fast(), "Qwen/Qwen2-Audio-7B-Instruct": PPTestSettings.fast(), "Qwen/Qwen2-VL-2B-Instruct": PPTestSettings.fast(), "fixie-ai/ultravox-v0_5-llama-3_2-1b": PPTestSettings.fast(), @@ -210,7 +196,7 @@ def iter_params(self, model_id: str): "intfloat/e5-mistral-7b-instruct", "BAAI/bge-multilingual-gemma2", # [MULTIMODAL GENERATION] - "OpenGVLab/InternVL2-1B", + "OpenGVLab/InternVL3-1B", "microsoft/Phi-3.5-vision-instruct", "fixie-ai/ultravox-v0_5-llama-3_2-1b", # [LANGUAGE GENERATION - HYBRID ARCH] diff --git a/tests/distributed/test_pp_cudagraph.py b/tests/distributed/test_pp_cudagraph.py index 34ae305c2d2c..2f0fc9a1b5d2 100644 --- a/tests/distributed/test_pp_cudagraph.py +++ b/tests/distributed/test_pp_cudagraph.py @@ -1,7 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import pytest -from typing_extensions import LiteralString + +from vllm.platforms import current_platform from ..utils import compare_two_settings, create_new_process_for_each_test @@ -14,15 +15,13 @@ ) @pytest.mark.parametrize( "ATTN_BACKEND", - [ - "FLASH_ATTN", - ], + [None] if current_platform.is_rocm() else ["FLASH_ATTN"], ) @create_new_process_for_each_test() def test_pp_cudagraph( PP_SIZE: int, MODEL_NAME: str, - ATTN_BACKEND: LiteralString, + ATTN_BACKEND: str | None, ): cudagraph_args = [ # use half precision for speed and memory savings in CI environment @@ -32,8 +31,10 @@ def test_pp_cudagraph( str(PP_SIZE), "--distributed-executor-backend", "mp", - f"--attention-backend={ATTN_BACKEND}", ] + # On ROCm, defer to the platform attention selector instead of forcing a backend. + if ATTN_BACKEND is not None: + cudagraph_args.append(f"--attention-backend={ATTN_BACKEND}") eager_args = cudagraph_args + ["--enforce-eager"] diff --git a/tests/distributed/test_pynccl.py b/tests/distributed/test_pynccl.py index a1d5355d4466..d7b04f68091d 100644 --- a/tests/distributed/test_pynccl.py +++ b/tests/distributed/test_pynccl.py @@ -9,6 +9,7 @@ import torch import torch.distributed +import vllm.envs as envs from tests.utils import ensure_current_vllm_config from vllm.distributed.communication_op import tensor_model_parallel_all_reduce # noqa from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator @@ -82,11 +83,18 @@ def test_pynccl(): @worker_fn_wrapper def multiple_allreduce_worker_fn(): device = torch.device(f"cuda:{torch.distributed.get_rank()}") - groups = [ - torch.distributed.new_group(ranks=[0, 1], backend="gloo"), - torch.distributed.new_group(ranks=[2, 3], backend="gloo"), - ] - group = groups[0] if torch.distributed.get_rank() in [0, 1] else groups[1] + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + # Eager-init path: parent PG has bound_device_id + a CPU backend, + # so split_group is supported. + group = torch.distributed.split_group( + split_ranks=[[0, 1], [2, 3]], backend="cpu:gloo,cuda:nccl" + ) + else: + groups = [ + torch.distributed.new_group(ranks=[0, 1], backend="gloo"), + torch.distributed.new_group(ranks=[2, 3], backend="gloo"), + ] + group = groups[0] if torch.distributed.get_rank() in [0, 1] else groups[1] pynccl_comm = PyNcclCommunicator(group=group, device=device) tensor = torch.ones(16, 1024, 1024, dtype=torch.float32, device=device) # two groups can communicate independently @@ -339,11 +347,16 @@ def test_pynccl_send_recv(): @worker_fn_wrapper def multiple_send_recv_worker_fn(): device = torch.device(f"cuda:{torch.distributed.get_rank()}") - groups = [ - torch.distributed.new_group(ranks=[0, 2], backend="gloo"), - torch.distributed.new_group(ranks=[1, 3], backend="gloo"), - ] - group = groups[0] if torch.distributed.get_rank() in [0, 2] else groups[1] + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + group = torch.distributed.split_group( + split_ranks=[[0, 2], [1, 3]], backend="cpu:gloo,cuda:nccl" + ) + else: + groups = [ + torch.distributed.new_group(ranks=[0, 2], backend="gloo"), + torch.distributed.new_group(ranks=[1, 3], backend="gloo"), + ] + group = groups[0] if torch.distributed.get_rank() in [0, 2] else groups[1] pynccl_comm = PyNcclCommunicator(group=group, device=device) if torch.distributed.get_rank() == 0: tensor = torch.ones(16, 1024, 1024, dtype=torch.float32, device=device) diff --git a/tests/distributed/test_quick_all_reduce.py b/tests/distributed/test_quick_all_reduce.py index a9591f96a78f..bfa28cc5c444 100644 --- a/tests/distributed/test_quick_all_reduce.py +++ b/tests/distributed/test_quick_all_reduce.py @@ -9,6 +9,7 @@ import torch import torch.distributed as dist +import vllm.envs as envs from vllm import _custom_ops as ops from vllm.distributed.communication_op import tensor_model_parallel_all_reduce # noqa from vllm.distributed.device_communicators.quick_all_reduce import ( @@ -349,7 +350,7 @@ def bf16_cast_quickreduce( @pytest.mark.skipif( not current_platform.is_rocm(), reason="only test quick allreduce for rocm" ) -@pytest.mark.parametrize("quant_mode", ["FP", "INT8", "INT6", "INT4"]) +@pytest.mark.parametrize("quant_mode", ["FP", "INT8", "INT6", "INT4", "INT3"]) @pytest.mark.parametrize("tp_size", [2]) @pytest.mark.parametrize("pipeline_parallel_size", [1, 2]) @pytest.mark.parametrize("test_target", [graph_quickreduce, eager_quickreduce]) @@ -397,13 +398,27 @@ def qr_variable_input(rank, world_size): ranks = [] for i in range(world_size): ranks.append(i) - dist.init_process_group( - backend="nccl", - init_method="tcp://127.0.0.1:29500", - rank=rank, - world_size=world_size, - ) - cpu_group = torch.distributed.new_group(ranks, backend="nccl") + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + dist.init_process_group( + backend="cpu:gloo,cuda:nccl", + init_method="tcp://127.0.0.1:29500", + rank=rank, + world_size=world_size, + device_id=device, + ) + else: + dist.init_process_group( + backend="nccl", + init_method="tcp://127.0.0.1:29500", + rank=rank, + world_size=world_size, + ) + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + cpu_group = torch.distributed.split_group( + split_ranks=[ranks], backend="cpu:gloo,cuda:nccl" + ) + else: + cpu_group = torch.distributed.new_group(ranks, backend="nccl") handle = ops.qr_get_handle(_ptr) world_size = dist.get_world_size(group=cpu_group) @@ -423,7 +438,7 @@ def qr_variable_input(rank, world_size): s2 = 2048 inp1 = torch.ones((s1, s2), dtype=dtype, device=device_idx) result = torch.empty_like(inp1) - # FP = 0 INT8 = 1 INT6 = 2 INT4 = 3 NONE = 4 + # FP = 0 INT8 = 1 INT6 = 2 INT4 = 3 INT3 = 4 ops.qr_all_reduce(_ptr, inp1, result, 3, cast_bf2half=True) try: if inp1[0, 0] == 0: diff --git a/tests/distributed/test_ray_v2_executor.py b/tests/distributed/test_ray_v2_executor.py index 398ee30c068d..a709f4b88049 100644 --- a/tests/distributed/test_ray_v2_executor.py +++ b/tests/distributed/test_ray_v2_executor.py @@ -17,6 +17,7 @@ from vllm import LLM from vllm.config import VllmConfig from vllm.engine.arg_utils import EngineArgs +from vllm.v1.executor import ray_executor_v2 from vllm.v1.executor.ray_executor_v2 import RayExecutorV2 pytestmark = pytest.mark.usefixtures("enable_ray_v2_backend") @@ -83,7 +84,13 @@ def assert_executor(executor, tp_size, pp_size): assert executor._get_output_rank() == expected_output_rank if pp_size > 1: - assert executor.vllm_config.max_concurrent_batches == pp_size + expected_concurrent_batches = pp_size + int( + executor.vllm_config.scheduler_config.async_scheduling + and executor.vllm_config.use_v2_model_runner + ) + assert ( + executor.vllm_config.max_concurrent_batches == expected_concurrent_batches + ) executor.check_health() assert not executor.is_failed @@ -95,6 +102,43 @@ def assert_executor(executor, tp_size, pp_size): assert handle.node_id is not None +def test_select_tcpstore_port_seeds_disjoint_windows(monkeypatch): + """Co-located DP engines scan distinct, adjacent port windows, so two + engines on a node cannot pick the same TCPStore port.""" + requested = [] + + def fake_get_open_port(start_port, max_attempts): + requested.append((start_port, max_attempts)) + return start_port + + monkeypatch.setattr(ray_executor_v2, "_get_open_port", fake_get_open_port) + + ports = [ + RayExecutorV2._select_tcpstore_port(rank, master_port=29500) + for rank in range(4) + ] + + assert requested == [(29600, 32), (29632, 32), (29664, 32), (29696, 32)] + assert len(set(ports)) == 4 + + +def test_select_tcpstore_port_non_dp_uses_random(monkeypatch): + """A non-DP engine has no local rank and uses a random port.""" + monkeypatch.setattr(ray_executor_v2, "get_open_port", lambda: 54321) + assert RayExecutorV2._select_tcpstore_port(None, master_port=29500) == 54321 + + +def test_select_tcpstore_port_full_window_uses_random(monkeypatch): + """A fully occupied window falls back to a random port.""" + + def raise_full(start_port, max_attempts): + raise RuntimeError("no open port") + + monkeypatch.setattr(ray_executor_v2, "_get_open_port", raise_full) + monkeypatch.setattr(ray_executor_v2, "get_open_port", lambda: 54321) + assert RayExecutorV2._select_tcpstore_port(0, master_port=29500) == 54321 + + @pytest.mark.parametrize("tp_size, pp_size", [(1, 1), (2, 1), (4, 1), (2, 2)]) def test_ray_v2_executor(tp_size, pp_size): """Validate RayExecutorV2 with various TP/PP configs.""" diff --git a/tests/distributed/test_rocm_aiter_custom_ar.py b/tests/distributed/test_rocm_aiter_custom_ar.py new file mode 100644 index 000000000000..0b85f36410d7 --- /dev/null +++ b/tests/distributed/test_rocm_aiter_custom_ar.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import ray +import torch +import torch.distributed as dist + +from vllm._aiter_ops import is_aiter_found, rocm_aiter_ops +from vllm.distributed.communication_op import tensor_model_parallel_all_reduce # noqa +from vllm.distributed.parallel_state import get_tp_group, graph_capture +from vllm.envs import disable_envs_cache +from vllm.platforms import current_platform + +from ..utils import ( + assert_rocm_custom_allreduce_backend_state, + ensure_model_parallel_initialized, + init_test_distributed_environment, + multi_gpu_test, + multi_process_parallel, +) + +pytestmark = pytest.mark.skipif( + not current_platform.is_rocm(), + reason="ROCm-only AITER custom allreduce tests", +) + +test_cases = [ + ((2, 7168), torch.float16), + ((2, 7168), torch.bfloat16), + ((128, 8192), torch.float16), + ((128, 8192), torch.bfloat16), +] + + +def _configure_aiter_custom_ar_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False) + monkeypatch.delenv("HIP_VISIBLE_DEVICES", raising=False) + monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1") + monkeypatch.setenv("VLLM_ROCM_USE_AITER_CUSTOM_AR", "1") + monkeypatch.setenv("VLLM_ROCM_QUICK_REDUCE_QUANTIZATION", "NONE") + disable_envs_cache() + rocm_aiter_ops.refresh_env_variables() + + +def _assert_aiter_handles_input(inp: torch.Tensor) -> None: + aiter_ar_comm = get_tp_group().device_communicator.aiter_ar_comm + assert aiter_ar_comm is not None + assert aiter_ar_comm.should_custom_ar(inp), ( + f"AITER CustomAllreduce does not support input shape {inp.shape}." + ) + + +@ray.remote(num_gpus=1, max_calls=1) +def graph_allreduce( + monkeypatch: pytest.MonkeyPatch, + tp_size, + pp_size, + rank, + distributed_init_port, +) -> None: + with monkeypatch.context() as m: + _configure_aiter_custom_ar_env(m) + + device = torch.device(f"cuda:{rank}") + torch.accelerator.set_device_index(device) + init_test_distributed_environment(tp_size, pp_size, rank, distributed_init_port) + ensure_model_parallel_initialized(tp_size, pp_size) + assert_rocm_custom_allreduce_backend_state(True, "NONE") + group = get_tp_group().device_group + + # A small all_reduce for warmup. + # this is needed because device communicators might be created lazily + # (e.g. NCCL). This will ensure that the communicator is initialized + # before any communication happens, so that this group can be used for + # graph capture immediately. + data = torch.zeros(1) + data = data.to(device=device) + dist.all_reduce(data, group=group) + torch.accelerator.synchronize() + del data + + for shape, dtype in test_cases: + with graph_capture(device=device) as graph_capture_context: + inp = torch.ones(shape, dtype=dtype, device=device) + _assert_aiter_handles_input(inp) + expected = inp * tp_size + + torch.accelerator.synchronize() + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph, stream=graph_capture_context.stream): + out = tensor_model_parallel_all_reduce(inp) + + graph.replay() + torch.testing.assert_close(out, expected) + + +@ray.remote(num_gpus=1, max_calls=1) +def eager_allreduce( + monkeypatch: pytest.MonkeyPatch, + tp_size, + pp_size, + rank, + distributed_init_port, +) -> None: + with monkeypatch.context() as m: + _configure_aiter_custom_ar_env(m) + + device = torch.device(f"cuda:{rank}") + torch.accelerator.set_device_index(device) + init_test_distributed_environment(tp_size, pp_size, rank, distributed_init_port) + ensure_model_parallel_initialized(tp_size, pp_size) + assert_rocm_custom_allreduce_backend_state(True, "NONE") + + for shape, dtype in test_cases: + inp = torch.ones(shape, dtype=dtype, device=device) + _assert_aiter_handles_input(inp) + expected = inp * tp_size + out = tensor_model_parallel_all_reduce(inp) + torch.testing.assert_close(out, expected) + + +@pytest.mark.skipif(not is_aiter_found(), reason="AITER is not installed") +@multi_gpu_test(num_gpus=2) +@pytest.mark.parametrize("tp_size", [2]) +@pytest.mark.parametrize("pipeline_parallel_size", [1]) +@pytest.mark.parametrize("test_target", [eager_allreduce, graph_allreduce]) +def test_rocm_aiter_custom_allreduce( + monkeypatch: pytest.MonkeyPatch, + tp_size, + pipeline_parallel_size, + test_target, +): + multi_process_parallel(monkeypatch, tp_size, pipeline_parallel_size, test_target) diff --git a/tests/distributed/test_split_group.py b/tests/distributed/test_split_group.py new file mode 100644 index 000000000000..54586c9e370a --- /dev/null +++ b/tests/distributed/test_split_group.py @@ -0,0 +1,233 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for split_group in GroupCoordinator. + +These tests verify that: +1. split_group is used for both device and CPU group creation. +2. Multiple subgroups work correctly with split_group. +3. Both GPU and CPU all-reduce work on split groups. +""" + +import os +from typing import Any + +import multiprocess as mp +import pytest +import torch +import torch.distributed + +import vllm.envs as envs +from vllm.distributed.parallel_state import ( + GroupCoordinator, + init_distributed_environment, +) +from vllm.utils.system_utils import update_environment_variables + +# The whole module exercises the split_group code path, which is opt-in +# behind VLLM_DISTRIBUTED_USE_SPLIT_GROUP=1. +pytestmark = pytest.mark.skipif( + not envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP, + reason=("VLLM_DISTRIBUTED_USE_SPLIT_GROUP=1 not set; split_group path is opt-in."), +) + +mp.set_start_method("spawn", force=True) + + +def distributed_run(fn, world_size): + number_of_processes = world_size + processes: list[mp.Process] = [] + for i in range(number_of_processes): + env: dict[str, str] = {} + env["RANK"] = str(i) + env["LOCAL_RANK"] = str(i) + env["WORLD_SIZE"] = str(number_of_processes) + env["LOCAL_WORLD_SIZE"] = str(number_of_processes) + env["MASTER_ADDR"] = "localhost" + env["MASTER_PORT"] = "12346" + # propagate the opt-in flag to the spawned child workers + env["VLLM_DISTRIBUTED_USE_SPLIT_GROUP"] = "1" + p = mp.Process(target=fn, args=(env,)) + processes.append(p) + p.start() + + for p in processes: + p.join() + + for p in processes: + assert p.exitcode == 0 + + +def worker_fn_wrapper(fn): + def wrapped_fn(env): + update_environment_variables(env) + local_rank = os.environ["LOCAL_RANK"] + device = torch.device(f"cuda:{local_rank}") + torch.accelerator.set_device_index(device) + init_distributed_environment() + fn() + + return wrapped_fn + + +def _verify_device_group(coordinator: GroupCoordinator): + """Verify device group works via all-reduce.""" + local_rank = torch.distributed.get_rank() + device = torch.device(f"cuda:{local_rank}") + tensor = torch.ones(16, 16, dtype=torch.float32, device=device) + torch.distributed.all_reduce(tensor, group=coordinator.device_group) + torch.accelerator.synchronize() + expected = coordinator.world_size + assert torch.all(tensor == expected).cpu().item(), ( + f"Device group all-reduce failed: expected {expected}, " + f"got {tensor.flatten()[0].item()}" + ) + + +def _verify_cpu_group(coordinator: GroupCoordinator): + """Verify CPU group works via all-reduce.""" + tensor = torch.ones(16, dtype=torch.float32) + torch.distributed.all_reduce(tensor, group=coordinator.cpu_group) + expected = coordinator.world_size + assert torch.all(tensor == expected).cpu().item(), ( + f"CPU group all-reduce failed: expected {expected}, " + f"got {tensor.flatten()[0].item()}" + ) + + +# --------------------------------------------------------------------------- +# Test 1: Basic split_group path with 2 GPUs +# --------------------------------------------------------------------------- +@worker_fn_wrapper +def split_group_basic_worker(): + rank = torch.distributed.get_rank() + world_size = torch.distributed.get_world_size() + group_ranks = [list(range(world_size))] + + coordinator = GroupCoordinator( + group_ranks=group_ranks, + local_rank=rank, + torch_distributed_backend="nccl", + use_device_communicator=False, + group_name="test_split_basic", + ) + + _verify_device_group(coordinator) + _verify_cpu_group(coordinator) + + +@pytest.mark.skipif( + torch.accelerator.device_count() < 2, + reason="Need at least 2 GPUs to run the test.", +) +def test_split_group_basic(): + """Test basic GroupCoordinator creation with split_group.""" + distributed_run(split_group_basic_worker, 2) + + +# --------------------------------------------------------------------------- +# Test 2: Multiple subgroups with split_group (4 GPUs) +# --------------------------------------------------------------------------- +@worker_fn_wrapper +def split_group_multiple_subgroups_worker(): + rank = torch.distributed.get_rank() + group_ranks = [[0, 1], [2, 3]] + + coordinator = GroupCoordinator( + group_ranks=group_ranks, + local_rank=rank, + torch_distributed_backend="nccl", + use_device_communicator=False, + group_name="test_split_multi", + ) + + assert coordinator.world_size == 2 + + _verify_device_group(coordinator) + _verify_cpu_group(coordinator) + + if rank in [0, 1]: + assert coordinator.ranks == [0, 1] + else: + assert coordinator.ranks == [2, 3] + + +@pytest.mark.skipif( + torch.accelerator.device_count() < 4, + reason="Need at least 4 GPUs to run the test.", +) +def test_split_group_multiple_subgroups(): + """Test GroupCoordinator with multiple independent subgroups.""" + distributed_run(split_group_multiple_subgroups_worker, 4) + + +# --------------------------------------------------------------------------- +# Test 3: split_group contract — every parent rank must enter with the same +# ``split_ranks``. NCCL happens to produce +# correct subgroups for disjoint partitions because the wrapper hashes +# ``my_group`` to derive the comm-split color, but the contract violation is +# real and would break under non-partition / non-NCCL backends. This test +# captures the actual ``split_ranks`` argument passed on every rank and +# asserts they match. +# --------------------------------------------------------------------------- +@worker_fn_wrapper +def split_group_contract_worker(): + rank = torch.distributed.get_rank() + group_ranks = [[0, 1], [2, 3]] + + captured: list[list[list[int]]] = [] + original_split_group = torch.distributed.split_group + + def capturing_split_group(*args, split_ranks=None, **kwargs): + captured.append([list(g) for g in split_ranks]) + return original_split_group(*args, split_ranks=split_ranks, **kwargs) + + torch.distributed.split_group = capturing_split_group + try: + GroupCoordinator( + group_ranks=group_ranks, + local_rank=rank, + torch_distributed_backend="nccl", + use_device_communicator=False, + group_name="test_split_contract", + ) + finally: + torch.distributed.split_group = original_split_group + + # GroupCoordinator builds two subgroups (device + cpu) per coordinator, + # so every rank must have made exactly two split_group calls. + if len(captured) != 2: + raise AssertionError( + f"rank {rank} expected 2 split_group calls (device + cpu), " + f"got {len(captured)}: {captured}" + ) + + world_size = torch.distributed.get_world_size() + for call_idx in range(2): + gathered: list[Any] = [None] * world_size + torch.distributed.all_gather_object(gathered, captured[call_idx]) + # Normalize for stable comparison (sort each subgroup and the outer list). + norm = [ + sorted([sorted(sg) for sg in per_rank_args]) for per_rank_args in gathered + ] + reference = norm[0] + for r, args in enumerate(norm): + if args != reference: + raise AssertionError( + f"split_group contract violation on call #{call_idx}: " + f"rank {r} passed split_ranks={gathered[r]}, but rank 0 " + f"passed split_ranks={gathered[0]}. PyTorch requires every " + "parent rank to enter split_group with the same split_ranks." + ) + + +@pytest.mark.skipif( + torch.accelerator.device_count() < 4, + reason="Need at least 4 GPUs to run the test.", +) +def test_split_group_contract_same_split_ranks_on_all_ranks(): + """All parent ranks must call torch.distributed.split_group with the same + ``split_ranks`` argument. This catches the bug where each rank passed + only its own subgroup (``split_ranks=[ranks]``), which NCCL forgives for + disjoint partitions but is a documented contract violation. + """ + distributed_run(split_group_contract_worker, 4) diff --git a/tests/distributed/test_torchrun_example.py b/tests/distributed/test_torchrun_example.py index e72f00bc91e0..670df2759b0a 100644 --- a/tests/distributed/test_torchrun_example.py +++ b/tests/distributed/test_torchrun_example.py @@ -5,13 +5,26 @@ import os import random +import torch import torch.distributed as dist +import vllm.envs as envs from vllm import LLM, SamplingParams from vllm.distributed.parallel_state import get_world_group -# Let PyTorch choose the WORLD backend for the current device type. -dist.init_process_group() +# By default, let PyTorch choose the WORLD backend for the current device +# type (legacy lazy-init path). When VLLM_DISTRIBUTED_USE_SPLIT_GROUP=1, +# use the explicit eager-init pattern required by `split_group` (mixed +# cpu:gloo,cuda:nccl backend + device_id binding). +if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + local_rank = int(os.environ["LOCAL_RANK"]) + torch.accelerator.set_device_index(local_rank) + dist.init_process_group( + backend="cpu:gloo,cuda:nccl", + device_id=torch.device(f"cuda:{local_rank}"), + ) +else: + dist.init_process_group() # Create prompts prompts = [ diff --git a/tests/distributed/test_torchrun_example_moe.py b/tests/distributed/test_torchrun_example_moe.py index 969b5e92e3fc..6f0957ed0263 100644 --- a/tests/distributed/test_torchrun_example_moe.py +++ b/tests/distributed/test_torchrun_example_moe.py @@ -5,13 +5,26 @@ import os import random +import torch import torch.distributed as dist +import vllm.envs as envs from vllm import LLM, SamplingParams from vllm.distributed.parallel_state import get_tp_group, get_world_group -# Let PyTorch choose the WORLD backend for the current device type. -dist.init_process_group() +# By default, let PyTorch choose the WORLD backend for the current device +# type (legacy lazy-init path). When VLLM_DISTRIBUTED_USE_SPLIT_GROUP=1, +# use the explicit eager-init pattern required by `split_group` (mixed +# cpu:gloo,cuda:nccl backend + device_id binding). +if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + local_rank = int(os.environ["LOCAL_RANK"]) + torch.accelerator.set_device_index(local_rank) + dist.init_process_group( + backend="cpu:gloo,cuda:nccl", + device_id=torch.device(f"cuda:{local_rank}"), + ) +else: + dist.init_process_group() # Create prompts prompts = [ diff --git a/tests/distributed/test_weight_transfer.py b/tests/distributed/test_weight_transfer.py index 2df0d9e71c36..f3423745ca59 100644 --- a/tests/distributed/test_weight_transfer.py +++ b/tests/distributed/test_weight_transfer.py @@ -18,7 +18,6 @@ from vllm.config.parallel import ParallelConfig from vllm.config.weight_transfer import WeightTransferConfig from vllm.distributed.weight_transfer import WeightTransferEngineFactory -from vllm.distributed.weight_transfer.base import SparseWeightPatch from vllm.distributed.weight_transfer.ipc_engine import ( IPCWeightTransferEngine, IPCWeightTransferInitInfo, @@ -29,9 +28,43 @@ NCCLWeightTransferInitInfo, NCCLWeightTransferUpdateInfo, ) +from vllm.distributed.weight_transfer.sparse_nccl_engine import ( + SparseNCCLWeightTransferEngine, + SparseNCCLWeightTransferUpdateInfo, + SparseWeightPatch, +) +from vllm.platforms import current_platform from vllm.utils.network_utils import get_open_port +def _init_ray_for_weight_transfer() -> None: + if ray.is_initialized(): + return + ray.init( + ignore_reinit_error=True, + runtime_env={ + "env_vars": { + "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES": "1", + "RAY_EXPERIMENTAL_NOSET_HIP_VISIBLE_DEVICES": "1", + "RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES": "1", + } + }, + ) + + +def _get_ray_assigned_device() -> torch.device: + gpu_ids = ray.get_gpu_ids() + if not gpu_ids: + return torch.device("cuda:0") + return torch.device(f"cuda:{int(gpu_ids[0])}") + + +def _set_ray_assigned_device() -> torch.device: + device = _get_ray_assigned_device() + current_platform.set_device(device) + return device + + def create_mock_parallel_config( rank: int = 0, world_size: int = 1, @@ -46,6 +79,18 @@ def create_mock_parallel_config( return config +def create_mock_vllm_config( + rank: int = 0, + world_size: int = 1, + dp_rank: int = 0, +) -> MagicMock: + """Create a mock VllmConfig exposing parallel_config and model_config.""" + vllm_config = MagicMock() + vllm_config.parallel_config = create_mock_parallel_config(rank, world_size, dp_rank) + vllm_config.model_config = MagicMock() + return vllm_config + + # --- Unit Tests: NCCLWeightTransferUpdateInfo Validation --- @@ -53,7 +98,6 @@ class TestNCCLWeightTransferUpdateInfoValidation: """Test NCCLWeightTransferUpdateInfo dataclass validation.""" def test_valid_update_info(self): - """Test creating valid NCCLWeightTransferUpdateInfo.""" info = NCCLWeightTransferUpdateInfo( names=["layer.weight", "layer.bias"], dtype_names=["float32", "float32"], @@ -64,7 +108,6 @@ def test_valid_update_info(self): assert info.shapes == [[10, 10], [10]] def test_mismatched_dtype_names_raises(self): - """Test that mismatched dtype_names length raises ValueError.""" with pytest.raises(ValueError, match="dtype_names"): NCCLWeightTransferUpdateInfo( names=["layer.weight", "layer.bias"], @@ -73,7 +116,6 @@ def test_mismatched_dtype_names_raises(self): ) def test_mismatched_shapes_raises(self): - """Test that mismatched shapes length raises ValueError.""" with pytest.raises(ValueError, match="shapes"): NCCLWeightTransferUpdateInfo( names=["layer.weight", "layer.bias"], @@ -82,73 +124,59 @@ def test_mismatched_shapes_raises(self): ) def test_empty_lists_valid(self): - """Test that empty lists are valid.""" - info = NCCLWeightTransferUpdateInfo( - names=[], - dtype_names=[], - shapes=[], - ) + info = NCCLWeightTransferUpdateInfo(names=[], dtype_names=[], shapes=[]) assert len(info.names) == 0 + +# --- Unit Tests: SparseNCCLWeightTransferUpdateInfo Validation --- + + +class TestSparseNCCLWeightTransferUpdateInfoValidation: + """Test SparseNCCLWeightTransferUpdateInfo dataclass validation.""" + def test_valid_sparse_update_info(self): - """Test creating valid sparse NCCL update info.""" - info = NCCLWeightTransferUpdateInfo( + info = SparseNCCLWeightTransferUpdateInfo( names=["layer.weight", "layer.bias"], dtype_names=["float32", "bfloat16"], shapes=[[10, 10], [10]], num_updates_list=[4, 2], - update_kind="sparse_flat", ) - assert info.update_kind == "sparse_flat" assert info.num_updates_list == [4, 2] - def test_sparse_update_requires_num_updates_list(self): - with pytest.raises(ValueError, match="`num_updates_list` is required"): - NCCLWeightTransferUpdateInfo( - names=["layer.weight"], + def test_mismatched_dtype_names_raises(self): + with pytest.raises(ValueError, match="dtype_names"): + SparseNCCLWeightTransferUpdateInfo( + names=["layer.weight", "layer.bias"], dtype_names=["float32"], - shapes=[[10, 10]], - update_kind="sparse_flat", + shapes=[[10, 10], [10]], + num_updates_list=[4, 2], ) - def test_sparse_update_rejects_empty_num_updates_list(self): + def test_rejects_empty_num_updates_list(self): with pytest.raises(ValueError, match="cannot be empty"): - NCCLWeightTransferUpdateInfo( + SparseNCCLWeightTransferUpdateInfo( names=[], dtype_names=[], shapes=[], num_updates_list=[], - update_kind="sparse_flat", - ) - - def test_sparse_update_rejects_packed(self): - with pytest.raises(ValueError, match="cannot be combined with `packed=True`"): - NCCLWeightTransferUpdateInfo( - names=["layer.weight"], - dtype_names=["float32"], - shapes=[[10, 10]], - num_updates_list=[3], - update_kind="sparse_flat", - packed=True, ) - def test_sparse_update_rejects_mismatched_num_updates(self): + def test_rejects_mismatched_num_updates(self): with pytest.raises(ValueError, match="`num_updates_list`"): - NCCLWeightTransferUpdateInfo( + SparseNCCLWeightTransferUpdateInfo( names=["layer.weight", "layer.bias"], dtype_names=["float32", "float32"], shapes=[[10, 10], [10]], num_updates_list=[3], - update_kind="sparse_flat", ) - def test_dense_update_rejects_sparse_metadata(self): - with pytest.raises(ValueError, match="Sparse metadata"): - NCCLWeightTransferUpdateInfo( + def test_rejects_negative_num_updates(self): + with pytest.raises(ValueError, match="non-negative"): + SparseNCCLWeightTransferUpdateInfo( names=["layer.weight"], dtype_names=["float32"], shapes=[[10, 10]], - num_updates_list=[3], + num_updates_list=[-1], ) @@ -158,14 +186,17 @@ def test_dense_update_rejects_sparse_metadata(self): class TestNCCLEngineParsing: """Test NCCLWeightTransferEngine parsing methods.""" - def test_parse_init_info_valid(self): - """Test parsing valid init info dict.""" + def _make_engine(self): config = WeightTransferConfig(backend="nccl") - parallel_config = create_mock_parallel_config() - engine = NCCLWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + return NCCLWeightTransferEngine( + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), ) + def test_parse_init_info_valid(self): + engine = self._make_engine() init_info = engine.parse_init_info( { "master_address": "127.0.0.1", @@ -174,7 +205,6 @@ def test_parse_init_info_valid(self): "world_size": 3, } ) - assert isinstance(init_info, NCCLWeightTransferInitInfo) assert init_info.master_address == "127.0.0.1" assert init_info.master_port == 12345 @@ -182,29 +212,12 @@ def test_parse_init_info_valid(self): assert init_info.world_size == 3 def test_parse_init_info_missing_field_raises(self): - """Test parsing init info with missing required field.""" - config = WeightTransferConfig(backend="nccl") - parallel_config = create_mock_parallel_config() - engine = NCCLWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) - ) - + engine = self._make_engine() with pytest.raises(ValueError, match="Invalid init_info"): - engine.parse_init_info( - { - "master_address": "127.0.0.1", - # Missing master_port, rank_offset, world_size - } - ) + engine.parse_init_info({"master_address": "127.0.0.1"}) def test_parse_update_info_valid(self): - """Test parsing valid update info dict.""" - config = WeightTransferConfig(backend="nccl") - parallel_config = create_mock_parallel_config() - engine = NCCLWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) - ) - + engine = self._make_engine() update_info = engine.parse_update_info( { "names": ["w1", "w2"], @@ -212,7 +225,6 @@ def test_parse_update_info_valid(self): "shapes": [[100, 100], [50]], } ) - assert isinstance(update_info, NCCLWeightTransferUpdateInfo) assert update_info.names == ["w1", "w2"] assert update_info.dtype_names == ["float32", "bfloat16"] @@ -226,40 +238,149 @@ class TestEngineRegistry: """Test weight transfer engine registry.""" def test_create_engine_nccl(self): - """Test factory creates NCCL engine.""" config = WeightTransferConfig(backend="nccl") - parallel_config = create_mock_parallel_config() engine = WeightTransferEngineFactory.create_engine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), ) assert isinstance(engine, NCCLWeightTransferEngine) def test_create_engine_ipc(self): - """Test factory creates IPC engine.""" config = WeightTransferConfig(backend="ipc") - parallel_config = create_mock_parallel_config() engine = WeightTransferEngineFactory.create_engine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), ) assert isinstance(engine, IPCWeightTransferEngine) + def test_create_engine_sparse_nccl(self): + config = WeightTransferConfig(backend="sparse_nccl") + engine = WeightTransferEngineFactory.create_engine( + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), + ) + assert isinstance(engine, SparseNCCLWeightTransferEngine) + def test_create_engine_invalid_backend(self): - """Test factory raises for invalid backend.""" config = WeightTransferConfig(backend="invalid") - parallel_config = create_mock_parallel_config() with pytest.raises(ValueError, match="Invalid weight transfer backend"): WeightTransferEngineFactory.create_engine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), ) def test_register_duplicate_raises(self): - """Test registering duplicate engine name raises.""" with pytest.raises(ValueError, match="already registered"): WeightTransferEngineFactory.register_engine( "nccl", NCCLWeightTransferEngine ) +# --- Unit Tests: Sparse patch application (CPU) --- + + +class TestSparseNCCLPatchApplication: + """Test SparseNCCLWeightTransferEngine._apply_patch on a real param.""" + + def _make_engine(self, model): + config = WeightTransferConfig(backend="sparse_nccl") + return SparseNCCLWeightTransferEngine( + config, create_mock_vllm_config(), torch.device("cpu"), model + ) + + def _make_model(self, numel: int = 8): + model = torch.nn.Module() + model.register_parameter( + "w", torch.nn.Parameter(torch.zeros(numel), requires_grad=False) + ) + + def get_parameter(name): + assert name == "w" + return model.w + + model.get_parameter = get_parameter + return model + + def test_apply_patch_updates_only_selected_entries(self): + model = self._make_model(8) + engine = self._make_engine(model) + engine._apply_patch( + SparseWeightPatch( + name="w", + indices=torch.tensor([1, 3], dtype=torch.int32), + values=torch.tensor([5.0, 7.0], dtype=torch.float32), + ) + ) + expected = torch.zeros(8) + expected[1] = 5.0 + expected[3] = 7.0 + assert torch.equal(model.w.data, expected) + + def test_apply_patch_rejects_mismatched_lengths(self): + model = self._make_model(8) + engine = self._make_engine(model) + with pytest.raises(ValueError, match="matching lengths"): + engine._apply_patch( + SparseWeightPatch( + name="w", + indices=torch.tensor([1, 3], dtype=torch.int32), + values=torch.tensor([5.0], dtype=torch.float32), + ) + ) + + def test_apply_patch_rejects_non_int32_indices(self): + model = self._make_model(8) + engine = self._make_engine(model) + with pytest.raises(ValueError, match="int32 indices"): + engine._apply_patch( + SparseWeightPatch( + name="w", + indices=torch.tensor([1], dtype=torch.int64), + values=torch.tensor([5.0], dtype=torch.float32), + ) + ) + + def test_apply_patch_rejects_dtype_mismatch(self): + model = self._make_model(8) + engine = self._make_engine(model) + with pytest.raises(ValueError, match="does not match"): + engine._apply_patch( + SparseWeightPatch( + name="w", + indices=torch.tensor([1], dtype=torch.int32), + values=torch.tensor([5.0], dtype=torch.bfloat16), + ) + ) + + def test_apply_patch_rejects_non_contiguous_param(self): + model = torch.nn.Module() + model.register_parameter( + "w", + torch.nn.Parameter( + torch.arange(12, dtype=torch.float32).view(3, 4).t(), + requires_grad=False, + ), + ) + model.get_parameter = lambda name: model.w + engine = self._make_engine(model) + with pytest.raises(NotImplementedError, match="contiguous params"): + engine._apply_patch( + SparseWeightPatch( + name="w", + indices=torch.tensor([1], dtype=torch.int32), + values=torch.tensor([1.0], dtype=torch.float32), + ) + ) + + # --- Test receive_weights without init raises --- @@ -269,42 +390,43 @@ def test_nccl_receive_weights_without_init_raises(): pytest.skip("Need at least 1 GPU for this test") config = WeightTransferConfig(backend="nccl") - parallel_config = create_mock_parallel_config() engine = NCCLWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), ) update_info = NCCLWeightTransferUpdateInfo( - names=["w"], - dtype_names=["float32"], - shapes=[[10]], + names=["w"], dtype_names=["float32"], shapes=[[10]] ) with pytest.raises(RuntimeError, match="not initialized"): - engine.receive_weights(update_info, lambda x: None) + engine.receive_weights(update_info) -def test_nccl_receive_sparse_weights_without_init_raises(): +def test_sparse_nccl_receive_weights_without_init_raises(): """Test that sparse receive raises if init_transfer_engine wasn't called.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") - config = WeightTransferConfig(backend="nccl") - parallel_config = create_mock_parallel_config() - engine = NCCLWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config = WeightTransferConfig(backend="sparse_nccl") + engine = SparseNCCLWeightTransferEngine( + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), ) - update_info = NCCLWeightTransferUpdateInfo( + update_info = SparseNCCLWeightTransferUpdateInfo( names=["w"], dtype_names=["float32"], shapes=[[10]], num_updates_list=[2], - update_kind="sparse_flat", ) with pytest.raises(RuntimeError, match="not initialized"): - engine.receive_sparse_weights(update_info, lambda x: None) + engine.receive_weights(update_info) # --- Integration Test: NCCL Weight Transfer Between Ray Tasks --- @@ -321,6 +443,8 @@ def trainer_broadcast_tensor( """Trainer task that broadcasts a tensor via NCCL.""" import torch + device = _set_ray_assigned_device() + from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator from vllm.distributed.utils import StatelessProcessGroup @@ -331,12 +455,11 @@ def trainer_broadcast_tensor( rank=0, world_size=world_size, ) - # Ray sets CUDA_VISIBLE_DEVICES, so device 0 is the assigned GPU - comm = PyNcclCommunicator(pg, device=0) + comm = PyNcclCommunicator(pg, device=device.index) # Create and broadcast the tensor dtype = getattr(torch, tensor_dtype) - tensor_to_send = torch.ones(tensor_shape, dtype=dtype, device="cuda:0") + tensor_to_send = torch.ones(tensor_shape, dtype=dtype, device=device) comm.broadcast(tensor_to_send, src=0, stream=torch.cuda.current_stream()) torch.accelerator.synchronize() @@ -352,10 +475,13 @@ def inference_receive_tensor( tensor_dtype: str, ) -> dict: """Inference task that receives tensor via NCCLWeightTransferEngine.""" + import contextlib from unittest.mock import MagicMock import torch + _set_ray_assigned_device() + from vllm.config.parallel import ParallelConfig from vllm.config.weight_transfer import WeightTransferConfig from vllm.distributed.weight_transfer.nccl_engine import ( @@ -364,17 +490,34 @@ def inference_receive_tensor( NCCLWeightTransferUpdateInfo, ) - # Create engine with mock parallel config + class Recorder(torch.nn.Module): + def __init__(self): + super().__init__() + self.received = [] + + def load_weights(self, weights): + for name, tensor in weights: + self.received.append((name, tensor.clone())) + config = WeightTransferConfig(backend="nccl") + vllm_config = MagicMock() parallel_config = MagicMock(spec=ParallelConfig) parallel_config.rank = 0 parallel_config.world_size = 1 parallel_config.data_parallel_rank = 0 parallel_config.data_parallel_index = 0 + vllm_config.parallel_config = parallel_config + vllm_config.model_config = MagicMock() + recorder = Recorder() engine = NCCLWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config, vllm_config, torch.device("cuda"), recorder ) + # Transport-only test: bypass the set_current_vllm_config context that + # receive_weights enters, since vllm_config here is a mock. + import vllm.config as _vllm_config_mod + + _vllm_config_mod.set_current_vllm_config = lambda cfg: contextlib.nullcontext() # Initialize the engine (joins as rank 1) init_info = NCCLWeightTransferInitInfo( @@ -385,20 +528,12 @@ def inference_receive_tensor( ) engine.init_transfer_engine(init_info) - # Receive weights with a no-op load_weights that captures the tensor - received_tensors = [] - - def noop_load_weights(weights: list[tuple[str, torch.Tensor]]): - for name, tensor in weights: - # Clone tensor to keep it after engine cleans up - received_tensors.append((name, tensor.clone())) - update_info = NCCLWeightTransferUpdateInfo( names=["test.weight"], dtype_names=[tensor_dtype], shapes=[tensor_shape], ) - engine.receive_weights(update_info, noop_load_weights) + engine.receive_weights(update_info) torch.accelerator.synchronize() # Verify we received the tensor @@ -406,11 +541,10 @@ def noop_load_weights(weights: list[tuple[str, torch.Tensor]]): received_shape = None received_sum = None - if len(received_tensors) == 1: - name, tensor = received_tensors[0] + if len(recorder.received) == 1: + name, tensor = recorder.received[0] received_shape = list(tensor.shape) received_sum = tensor.sum().item() - # Check shape matches and values are all 1s (trainer sends ones) if received_shape == tensor_shape: expected_sum = 1.0 * torch.tensor(tensor_shape).prod().item() if abs(received_sum - expected_sum) < 0.01: @@ -435,17 +569,15 @@ def test_nccl_weight_transfer_between_processes(): This test verifies that the NCCLWeightTransferEngine can receive tensors broadcast by a trainer process via NCCL. """ - ray.init(ignore_reinit_error=True) + _init_ray_for_weight_transfer() master_address = "127.0.0.1" master_port = get_open_port() world_size = 2 # 1 trainer + 1 inference worker - # Tensor to transfer: 100x100 ones tensor_shape = [100, 100] tensor_dtype = "float32" - # Start both tasks concurrently - Ray assigns GPUs automatically inference_future = inference_receive_tensor.remote( master_address, master_port, world_size, tensor_shape, tensor_dtype ) @@ -453,7 +585,6 @@ def test_nccl_weight_transfer_between_processes(): master_address, master_port, world_size, tensor_shape, tensor_dtype ) - # Wait for both to complete trainer_result, result = ray.get([trainer_future, inference_future]) assert trainer_result, "Trainer should complete successfully" @@ -473,12 +604,16 @@ def trainer_broadcast_sparse_tensor( """Trainer task that broadcasts sparse patches via NCCL.""" import torch + device = _set_ray_assigned_device() + from vllm.distributed.device_communicators.pynccl import PyNcclCommunicator from vllm.distributed.utils import StatelessProcessGroup - from vllm.distributed.weight_transfer.base import SparseWeightPatch from vllm.distributed.weight_transfer.nccl_engine import ( NCCLTrainerSendWeightsArgs, - NCCLWeightTransferEngine, + ) + from vllm.distributed.weight_transfer.sparse_nccl_engine import ( + SparseNCCLWeightTransferEngine, + SparseWeightPatch, ) pg = StatelessProcessGroup.create( @@ -487,14 +622,14 @@ def trainer_broadcast_sparse_tensor( rank=0, world_size=world_size, ) - comm = PyNcclCommunicator(pg, device=0) + comm = PyNcclCommunicator(pg, device=device.index) patch = SparseWeightPatch( name="test.weight", - indices=torch.tensor([1, 7, 25], dtype=torch.int32, device="cuda:0"), - values=torch.tensor([10.0, 20.0, 30.0], dtype=torch.float32, device="cuda:0"), + indices=torch.tensor([1, 7, 25], dtype=torch.int32, device=device), + values=torch.tensor([10.0, 20.0, 30.0], dtype=torch.float32, device=device), ) - NCCLWeightTransferEngine.trainer_send_sparse_weights( + SparseNCCLWeightTransferEngine.trainer_send_weights( iter([patch]), NCCLTrainerSendWeightsArgs(group=comm), ) @@ -508,29 +643,51 @@ def inference_receive_sparse_tensor( master_port: int, world_size: int, ) -> dict: - """Inference task that receives sparse patches via NCCLWeightTransferEngine.""" + """Inference task that receives sparse patches via the sparse engine.""" from unittest.mock import MagicMock import torch + device = _set_ray_assigned_device() + from vllm.config.parallel import ParallelConfig from vllm.config.weight_transfer import WeightTransferConfig - from vllm.distributed.weight_transfer.nccl_engine import ( - NCCLWeightTransferEngine, - NCCLWeightTransferInitInfo, - NCCLWeightTransferUpdateInfo, + from vllm.distributed.weight_transfer.sparse_nccl_engine import ( + SparseNCCLWeightTransferEngine, + SparseNCCLWeightTransferUpdateInfo, ) - config = WeightTransferConfig(backend="nccl") + config = WeightTransferConfig(backend="sparse_nccl") + vllm_config = MagicMock() parallel_config = MagicMock(spec=ParallelConfig) parallel_config.rank = 0 parallel_config.world_size = 1 parallel_config.data_parallel_rank = 0 parallel_config.data_parallel_index = 0 + vllm_config.parallel_config = parallel_config + vllm_config.model_config = MagicMock() - engine = NCCLWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + # Real module holding the target parameter the patch will modify. + model = torch.nn.Module() + model.register_parameter( + "w", torch.nn.Parameter(torch.zeros(30, device="cuda"), requires_grad=False) ) + model.get_parameter = lambda name: model.w + + update_info = SparseNCCLWeightTransferUpdateInfo( + names=["w"], + dtype_names=["float32"], + shapes=[[30]], + num_updates_list=[3], + ) + + engine = SparseNCCLWeightTransferEngine( + config, vllm_config, torch.device("cuda"), model + ) + from vllm.distributed.weight_transfer.nccl_common import ( + NCCLWeightTransferInitInfo, + ) + engine.init_transfer_engine( NCCLWeightTransferInitInfo( master_address=master_address, @@ -539,32 +696,18 @@ def inference_receive_sparse_tensor( world_size=world_size, ) ) - - target = torch.zeros(30, dtype=torch.float32, device="cuda") - - def apply_sparse_patches(patches: list[SparseWeightPatch]): - for patch in patches: - target.index_copy_(0, patch.indices.to(torch.long), patch.values) - - update_info = NCCLWeightTransferUpdateInfo( - names=["test.weight"], - dtype_names=["float32"], - shapes=[[30]], - num_updates_list=[3], - update_kind="sparse_flat", - ) - engine.receive_sparse_weights(update_info, apply_sparse_patches) + engine.receive_weights(update_info) torch.accelerator.synchronize() - expected = torch.zeros(30, dtype=torch.float32, device="cuda") + expected = torch.zeros(30, dtype=torch.float32, device=device) expected[[1, 7, 25]] = torch.tensor( - [10.0, 20.0, 30.0], dtype=torch.float32, device="cuda" + [10.0, 20.0, 30.0], dtype=torch.float32, device=device ) - success = torch.equal(target, expected) + success = torch.equal(model.w.data, expected) engine.shutdown() return { "success": success, - "selected_values": target[[1, 7, 25]].cpu().tolist(), + "selected_values": model.w.data[[1, 7, 25]].cpu().tolist(), } @@ -574,7 +717,7 @@ def apply_sparse_patches(patches: list[SparseWeightPatch]): ) def test_nccl_sparse_weight_transfer_between_processes(): """Test NCCL sparse weight transfer from trainer to inference process.""" - ray.init(ignore_reinit_error=True) + _init_ray_for_weight_transfer() master_address = "127.0.0.1" master_port = get_open_port() @@ -603,11 +746,9 @@ class TestIPCWeightTransferUpdateInfoValidation: """Test IPCWeightTransferUpdateInfo dataclass validation.""" def test_valid_update_info(self): - """Test creating valid IPCWeightTransferUpdateInfo.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") - # Create a dummy tensor and IPC handle dummy_tensor = torch.ones(10, 10, device="cuda:0") _, ipc_handle = reduce_tensor(dummy_tensor) gpu_uuid = str(torch.cuda.get_device_properties(0).uuid) @@ -625,7 +766,6 @@ def test_valid_update_info(self): assert len(info.ipc_handles) == 1 def test_mismatched_dtype_names_raises(self): - """Test that mismatched dtype_names length raises ValueError.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") @@ -643,7 +783,6 @@ def test_mismatched_dtype_names_raises(self): ) def test_mismatched_shapes_raises(self): - """Test that mismatched shapes length raises ValueError.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") @@ -661,7 +800,6 @@ def test_mismatched_shapes_raises(self): ) def test_mismatched_ipc_handles_raises(self): - """Test that mismatched ipc_handles length raises ValueError.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") @@ -678,44 +816,7 @@ def test_mismatched_ipc_handles_raises(self): ipc_handles=ipc_handles, ) - def test_sparse_update_kind_rejected(self): - """Test that IPC backend rejects sparse update metadata.""" - if torch.accelerator.device_count() < 1: - pytest.skip("Need at least 1 GPU for this test") - - dummy_tensor = torch.ones(10, 10, device="cuda:0") - ipc_handle = reduce_tensor(dummy_tensor) - gpu_uuid = str(torch.cuda.get_device_properties(0).uuid) - ipc_handles = [{gpu_uuid: ipc_handle}] - - with pytest.raises(NotImplementedError, match="dense updates"): - IPCWeightTransferUpdateInfo( - names=["layer.weight"], - dtype_names=["float32"], - shapes=[[10, 10]], - num_updates_list=[1], - ipc_handles=ipc_handles, - update_kind="sparse_flat", - ) - - def test_sparse_methods_not_supported(self): - """Test that IPC engine inherits sparse rejection from the base class.""" - config = WeightTransferConfig(backend="ipc") - parallel_config = create_mock_parallel_config() - engine = IPCWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) - ) - - with pytest.raises(NotImplementedError, match="(?i)sparse weight updates"): - engine.receive_sparse_weights(MagicMock(), lambda _: None) - with pytest.raises(NotImplementedError, match="(?i)sparse weight updates"): - engine.trainer_send_sparse_weights( - iter([]), - {"mode": "http", "url": "http://localhost:8000"}, - ) - def test_valid_update_info_from_pickled(self, monkeypatch): - """Test creating IPCWeightTransferUpdateInfo from pickled handles.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") @@ -738,7 +839,6 @@ def test_valid_update_info_from_pickled(self, monkeypatch): assert info.ipc_handles_pickled is None def test_pickled_requires_insecure_serialization_flag(self, monkeypatch): - """Test that pickled handles are rejected unless env flag is enabled.""" monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "0") with pytest.raises(ValueError, match="VLLM_ALLOW_INSECURE_SERIALIZATION=1"): @@ -750,7 +850,6 @@ def test_pickled_requires_insecure_serialization_flag(self, monkeypatch): ) def test_both_handles_and_pickled_raises(self): - """Test that providing both ipc_handles and ipc_handles_pickled raises.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") @@ -771,7 +870,6 @@ def test_both_handles_and_pickled_raises(self): ) def test_neither_handles_nor_pickled_raises(self): - """Test that providing neither ipc_handles nor ipc_handles_pickled raises.""" with pytest.raises(ValueError, match="must be provided"): IPCWeightTransferUpdateInfo( names=["layer.weight"], @@ -780,7 +878,6 @@ def test_neither_handles_nor_pickled_raises(self): ) def test_empty_lists_valid(self): - """Test that empty lists are valid.""" info = IPCWeightTransferUpdateInfo( names=[], dtype_names=[], @@ -796,18 +893,21 @@ def test_empty_lists_valid(self): class TestIPCEngineParsing: """Test IPCWeightTransferEngine parsing methods.""" + def _make_engine(self): + config = WeightTransferConfig(backend="ipc") + return IPCWeightTransferEngine( + config, + create_mock_vllm_config(), + torch.device("cuda"), + MagicMock(spec=torch.nn.Module), + ) + def test_parse_update_info_valid(self): - """Test parsing valid update info dict.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") - config = WeightTransferConfig(backend="ipc") - parallel_config = create_mock_parallel_config() - engine = IPCWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) - ) + engine = self._make_engine() - # Create dummy IPC handles dummy_tensor1 = torch.ones(100, 100, device="cuda:0") dummy_tensor2 = torch.ones(50, device="cuda:0") _, ipc_args1 = reduce_tensor(dummy_tensor1) @@ -831,17 +931,12 @@ def test_parse_update_info_valid(self): assert len(update_info.ipc_handles) == 2 def test_parse_update_info_pickled(self, monkeypatch): - """Test parsing update info with pickled IPC handles (HTTP path).""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1") - config = WeightTransferConfig(backend="ipc") - parallel_config = create_mock_parallel_config() - engine = IPCWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) - ) + engine = self._make_engine() dummy_tensor1 = torch.ones(100, 100, device="cuda:0") dummy_tensor2 = torch.ones(50, device="cuda:0") @@ -868,12 +963,7 @@ def test_parse_update_info_pickled(self, monkeypatch): assert gpu_uuid in update_info.ipc_handles[1] def test_parse_update_info_ignores_none_pickled_handles(self): - """Test Ray/asdict payloads with a null pickled field use ipc_handles.""" - config = WeightTransferConfig(backend="ipc") - parallel_config = create_mock_parallel_config() - engine = IPCWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) - ) + engine = self._make_engine() ipc_handles = [{"gpu-uuid": ("ipc-args",)}] update_info = engine.parse_update_info( @@ -890,15 +980,10 @@ def test_parse_update_info_ignores_none_pickled_handles(self): assert update_info.ipc_handles == ipc_handles def test_parse_update_info_both_handles_and_pickled_raises(self): - """Test that providing both ipc_handles and ipc_handles_pickled raises.""" if torch.accelerator.device_count() < 1: pytest.skip("Need at least 1 GPU for this test") - config = WeightTransferConfig(backend="ipc") - parallel_config = create_mock_parallel_config() - engine = IPCWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) - ) + engine = self._make_engine() dummy_tensor = torch.ones(10, 10, device="cuda:0") _, ipc_handle = reduce_tensor(dummy_tensor) @@ -933,16 +1018,15 @@ class TrainerActor: """Trainer actor that creates and holds CUDA IPC handles.""" def __init__(self, tensor_shape: list[int], tensor_dtype: str): + device = _set_ray_assigned_device() + # Create tensor on GPU and keep it alive dtype = getattr(torch, tensor_dtype) - self.tensor = torch.ones(tensor_shape, dtype=dtype, device="cuda:0") + self.tensor = torch.ones(tensor_shape, dtype=dtype, device=device) self.tensor.fill_(42.0) # Fill with 42 to verify correct transfer - # Create IPC handle (tensor must stay alive for IPC to work) - # reduce_tensor returns (rebuild_func, args); we only send args - # since the receiver imports rebuild_cuda_tensor directly. _, ipc_args = reduce_tensor(self.tensor) - gpu_uuid = get_physical_gpu_id(0) + gpu_uuid = get_physical_gpu_id(device.index) torch.accelerator.synchronize() @@ -964,6 +1048,7 @@ def inference_receive_ipc_tensor( mode: str = "ray", ) -> dict: """Inference task that receives tensor via IPCWeightTransferEngine.""" + import contextlib import os # Worker-side: ipc_handles_pickled is deserialized via pickle. @@ -974,36 +1059,46 @@ def inference_receive_ipc_tensor( import torch + _set_ray_assigned_device() + from vllm.config.parallel import ParallelConfig from vllm.config.weight_transfer import WeightTransferConfig from vllm.distributed.weight_transfer.ipc_engine import ( IPCWeightTransferEngine, ) - # Create engine with mock parallel config + class Recorder(torch.nn.Module): + def __init__(self): + super().__init__() + self.received = [] + + def load_weights(self, weights): + for name, tensor in weights: + self.received.append((name, tensor.clone())) + config = WeightTransferConfig(backend="ipc") + vllm_config = MagicMock() parallel_config = MagicMock(spec=ParallelConfig) parallel_config.rank = 0 parallel_config.world_size = 1 parallel_config.data_parallel_rank = 0 parallel_config.data_parallel_index = 0 + vllm_config.parallel_config = parallel_config + vllm_config.model_config = MagicMock() + recorder = Recorder() engine = IPCWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config, vllm_config, _get_ray_assigned_device(), recorder ) + # Transport-only test: bypass the set_current_vllm_config context that + # receive_weights enters, since vllm_config here is a mock. + import vllm.config as _vllm_config_mod + + _vllm_config_mod.set_current_vllm_config = lambda cfg: contextlib.nullcontext() - # Initialize the engine (no-op for IPC) init_info = IPCWeightTransferInitInfo() engine.init_transfer_engine(init_info) - # Receive weights with a no-op load_weights that captures the tensor - received_tensors = [] - - def noop_load_weights(weights: list[tuple[str, torch.Tensor]]): - for name, tensor in weights: - # Clone tensor to keep it after engine cleans up - received_tensors.append((name, tensor.clone())) - ipc_handles = [{ipc_handle_dict["gpu_uuid"]: ipc_handle_dict["ipc_handle"]}] if mode == "ray": @@ -1014,7 +1109,6 @@ def noop_load_weights(weights: list[tuple[str, torch.Tensor]]): "ipc_handles": ipc_handles, } elif mode == "http": - # Simulate HTTP transport: pickle + base64 encode handles pickled = base64.b64encode(pickle.dumps(ipc_handles)).decode("utf-8") update_dict = { "names": ["test.weight"], @@ -1026,19 +1120,17 @@ def noop_load_weights(weights: list[tuple[str, torch.Tensor]]): raise ValueError(f"Unknown mode: {mode}") update_info = engine.parse_update_info(update_dict) - engine.receive_weights(update_info, noop_load_weights) + engine.receive_weights(update_info) torch.accelerator.synchronize() - # Verify we received the tensor success = False received_shape = None received_sum = None - if len(received_tensors) == 1: - name, tensor = received_tensors[0] + if len(recorder.received) == 1: + name, tensor = recorder.received[0] received_shape = list(tensor.shape) received_sum = tensor.sum().item() - # Check shape matches and values are all 42s (trainer sends 42s) if received_shape == ipc_handle_dict["shape"]: expected_sum = 42.0 * torch.tensor(ipc_handle_dict["shape"]).prod().item() if abs(received_sum - expected_sum) < 0.01: @@ -1059,23 +1151,12 @@ def noop_load_weights(weights: list[tuple[str, torch.Tensor]]): ) @pytest.mark.parametrize("mode", ["ray", "http"]) def test_ipc_weight_transfer_between_processes(mode: str): - """Test IPC weight transfer from trainer to inference process using Ray. - - Parametrized over transport modes: - - 'ray': ipc_handles passed directly. - - 'http': ipc_handles pickled + base64-encoded, deserialized in - parse_update_info before constructing the dataclass. - - IPC requires same-GPU access, so we use a placement group to co-locate - the trainer actor and inference task on the same GPU. - """ + """Test IPC weight transfer from trainer to inference process using Ray.""" from ray.util.placement_group import placement_group from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy - ray.init(ignore_reinit_error=True) + _init_ray_for_weight_transfer() - # Create a placement group to ensure both processes are on the same GPU - # Use fractional GPUs so both tasks can share the same GPU bundle pg = placement_group([{"GPU": 1, "CPU": 2}]) ray.get(pg.ready()) @@ -1084,20 +1165,15 @@ def test_ipc_weight_transfer_between_processes(mode: str): placement_group_capture_child_tasks=True, ) - # Tensor to transfer: 100x100 filled with 42s tensor_shape = [100, 100] tensor_dtype = "float32" - # Create trainer actor that holds the tensor and IPC handle (stays alive) trainer_actor = TrainerActor.options( # type: ignore[attr-defined] scheduling_strategy=scheduling_strategy ).remote(tensor_shape, tensor_dtype) - # Get IPC handle dict (tensor stays alive in trainer actor) ipc_handle_dict = ray.get(trainer_actor.get_ipc_handle_dict.remote()) - # Receive tensor in inference process using IPC handles (on same GPU) - # Trainer actor stays alive during this operation inference_result = ray.get( inference_receive_ipc_tensor.options( scheduling_strategy=scheduling_strategy @@ -1117,12 +1193,13 @@ def test_ipc_receive_weights_missing_gpu_uuid_raises(): pytest.skip("Need at least 1 GPU for this test") config = WeightTransferConfig(backend="ipc") - parallel_config = create_mock_parallel_config() engine = IPCWeightTransferEngine( - config, parallel_config, MagicMock(spec=torch.nn.Module) + config, + create_mock_vllm_config(), + torch.device("cuda:0"), + MagicMock(spec=torch.nn.Module), ) - # Create IPC handle with wrong GPU UUID dummy_tensor = torch.ones(10, 10, device="cuda:0") _, ipc_handle = reduce_tensor(dummy_tensor) wrong_uuid = "wrong-uuid-12345" @@ -1136,4 +1213,4 @@ def test_ipc_receive_weights_missing_gpu_uuid_raises(): ) with pytest.raises(ValueError, match="IPC handle not found"): - engine.receive_weights(update_info, lambda x: None) + engine.receive_weights(update_info) diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py index 9b21f3eebc10..2feb9f7a039d 100644 --- a/tests/engine/test_arg_utils.py +++ b/tests/engine/test_arg_utils.py @@ -206,6 +206,25 @@ def test_get_kwargs(): assert kwargs["nested_config"]["type"]('{"field": 2}') == NestedConfig(2) # type: ignore[call-arg] +def test_jit_monitor_verbose_arg(): + parser = EngineArgs.add_cli_args(FlexibleArgumentParser()) + args = parser.parse_args(["--jit-monitor-verbose"]) + + assert args.jit_monitor_verbose + assert EngineArgs(model="test", jit_monitor_verbose=True).jit_monitor_verbose + + +@pytest.mark.parametrize("mode", ["warn", "error"]) +def test_jit_monitor_mode_arg(mode): + parser = EngineArgs.add_cli_args(FlexibleArgumentParser()) + args = parser.parse_args(["--jit-monitor-mode", mode]) + + assert args.jit_monitor_mode == mode + engine_args = EngineArgs(model="test", jit_monitor_mode=mode) + assert engine_args.jit_monitor_mode == mode + assert engine_args.create_observability_config().jit_monitor_mode == mode + + def test_hf_token_get_kwargs(): kwargs = get_kwargs(ModelConfig)["hf_token"] @@ -546,6 +565,40 @@ def test_human_readable_model_len(): parser.parse_args(["--max-model-len", invalid]) +def test_human_readable_other_args(): + # Test human-readable parsing for other integer args + # that were added to use human_readable_int parser + parser = EngineArgs.add_cli_args(FlexibleArgumentParser(exit_on_error=False)) + + # Test max_num_scheduled_tokens + args = parser.parse_args(["--max-num-scheduled-tokens", "1024"]) + assert args.max_num_scheduled_tokens == 1024 + args = parser.parse_args(["--max-num-scheduled-tokens", "2k"]) + assert args.max_num_scheduled_tokens == 2_000 + args = parser.parse_args(["--max-num-scheduled-tokens", "4K"]) + assert args.max_num_scheduled_tokens == 2**10 * 4 + args = parser.parse_args(["--max-num-scheduled-tokens", "10.5k"]) + assert args.max_num_scheduled_tokens == 10500 + + # Test kv_cache_memory_bytes (existing human-readable arg) + args = parser.parse_args(["--kv-cache-memory-bytes", "100000"]) + assert args.kv_cache_memory_bytes == 100000 + args = parser.parse_args(["--kv-cache-memory-bytes", "100k"]) + assert args.kv_cache_memory_bytes == 100_000 + args = parser.parse_args(["--kv-cache-memory-bytes", "1M"]) + assert args.kv_cache_memory_bytes == 2**20 + args = parser.parse_args(["--kv-cache-memory-bytes", "1m"]) + assert args.kv_cache_memory_bytes == 1_000_000 + + # Test max_num_batched_tokens (existing human-readable arg) + args = parser.parse_args(["--max-num-batched-tokens", "1024"]) + assert args.max_num_batched_tokens == 1024 + args = parser.parse_args(["--max-num-batched-tokens", "2k"]) + assert args.max_num_batched_tokens == 2_000 + args = parser.parse_args(["--max-num-batched-tokens", "4K"]) + assert args.max_num_batched_tokens == 2**10 * 4 + + def test_numa_bind_args(): parser = EngineArgs.add_cli_args(FlexibleArgumentParser()) args = parser.parse_args( @@ -641,3 +694,196 @@ def test_cloud_storage_tokenizer_skips_get_model_path(monkeypatch): args = EngineArgs(model="s3://bucket/model", tokenizer="s3://bucket/tokenizer") assert args.model == "s3://bucket/model" assert args.tokenizer == "s3://bucket/tokenizer" + + +class TestDeviceIds: + def test_device_ids_with_cvd_out_of_range(self, monkeypatch): + """--device-ids index beyond the CVD set raises ValueError.""" + from vllm.platforms import current_platform + + key = current_platform.device_control_env_var + monkeypatch.setenv(key, "4,5") + args = EngineArgs(model="m", device_ids=[0, 2]) + with pytest.raises(ValueError, match="out of range"): + args._resolve_device_ids() + + def test_device_ids_with_cvd_resolve_to_physical_ids(self, monkeypatch): + """--device-ids are CVD-local indices resolved to physical ids.""" + from vllm.platforms import current_platform + + key = current_platform.device_control_env_var + monkeypatch.setenv(key, "4,5") + args = EngineArgs(model="m", device_ids=[0, 1]) + assert args._resolve_device_ids() == [4, 5] + + def test_device_ids_with_uuid_cvd_resolve_to_physical_ids(self, monkeypatch): + """--device-ids support UUID CVD values resolved by the platform.""" + from vllm.platforms import current_platform + + key = current_platform.device_control_env_var + monkeypatch.setenv(key, "GPU-abcd1234,GPU-ef567890") + monkeypatch.setattr( + type(current_platform), + "device_control_id_to_physical_device_id", + classmethod( + lambda cls, device_id: {"GPU-abcd1234": 4, "GPU-ef567890": 5}[device_id] + ), + ) + + args = EngineArgs(model="m", device_ids=[0, 1]) + assert args._resolve_device_ids() == [4, 5] + + def test_device_ids_with_uuid_args_resolve_to_physical_ids(self, monkeypatch): + """UUID --device-ids are resolved to physical IDs immediately.""" + from vllm.platforms import current_platform + + monkeypatch.setattr( + type(current_platform), + "device_control_id_to_physical_device_id", + classmethod(lambda cls, device_id: {"GPU-abcd1234": 4}[device_id]), + ) + + args = EngineArgs(model="m", device_ids=["GPU-abcd1234"]) + assert args._resolve_device_ids() == [4] + + def test_device_ids_reject_mixed_integer_and_uuid_args(self): + """--device-ids must not mix CVD indices and UUIDs.""" + args = EngineArgs(model="m", device_ids=[0, "GPU-abcd1234"]) + with pytest.raises(ValueError, match="must not mix"): + args._resolve_device_ids() + + def test_no_device_ids(self): + """No --device-ids returns None.""" + args = EngineArgs(model="m") + assert args._resolve_device_ids() is None + + def test_cli_parsing(self): + """--device-ids parses comma-separated string from CLI.""" + parser = FlexibleArgumentParser() + EngineArgs.add_cli_args(parser) + parsed = parser.parse_args(["--model", "m", "--device-ids", "0,2,4"]) + assert parsed.device_ids == [0, 2, 4] + + def test_cli_parsing_uuid(self): + """--device-ids parses comma-separated UUID strings from CLI.""" + parser = FlexibleArgumentParser() + EngineArgs.add_cli_args(parser) + parsed = parser.parse_args( + ["--model", "m", "--device-ids", "GPU-abcd1234,GPU-ef567890"] + ) + assert parsed.device_ids == ["GPU-abcd1234", "GPU-ef567890"] + + def test_assigned_physical_gpu_ids_are_physical_with_cvd(self, monkeypatch): + """assigned_physical_gpu_ids are already physical and not composed with CVD.""" + import vllm.platforms.interface as platform_interface + from vllm.platforms import current_platform + + monkeypatch.setattr(platform_interface, "_assigned_physical_gpu_ids", [4, 5]) + monkeypatch.setenv(current_platform.device_control_env_var, "4,5") + + assert current_platform.device_id_to_physical_device_id(0) == 4 + assert current_platform.device_id_to_physical_device_id(1) == 5 + assert current_platform.logical_device_id_to_visible_device_id(0) == 0 + assert current_platform.logical_device_id_to_visible_device_id(1) == 1 + + def test_assigned_physical_gpu_ids_map_to_visible_uuid_cvd(self, monkeypatch): + """Physical IDs map back to visible ordinals when CVD uses UUIDs.""" + import vllm.platforms.interface as platform_interface + from vllm.platforms import current_platform + + monkeypatch.setattr(platform_interface, "_assigned_physical_gpu_ids", [5]) + monkeypatch.setenv( + current_platform.device_control_env_var, + "GPU-abcd1234,GPU-ef567890", + ) + monkeypatch.setattr( + type(current_platform), + "device_control_id_to_physical_device_id", + classmethod( + lambda cls, device_id: {"GPU-abcd1234": 4, "GPU-ef567890": 5}[device_id] + ), + ) + + assert current_platform.logical_device_id_to_visible_device_id(0) == 1 + + def test_device_ids_reject_duplicates(self): + """--device-ids must not contain duplicate entries.""" + args = EngineArgs(model="m", device_ids=[2, 2]) + with pytest.raises(ValueError, match="duplicates"): + args._resolve_device_ids() + + def test_cli_parsing_strips_whitespace(self): + """--device-ids tolerates whitespace around commas.""" + parser = FlexibleArgumentParser() + EngineArgs.add_cli_args(parser) + parsed = parser.parse_args(["--model", "m", "--device-ids", "0, 2, 4"]) + assert parsed.device_ids == [0, 2, 4] + + def test_visible_ordinal_to_physical_ignores_assigned_ids(self, monkeypatch): + """visible_device_id_to_physical_device_id maps torch device ordinals, + independent of the logical-to-physical mapping. + + Regression test: CustomAllreduce passes device.index (a visible + ordinal) and must not index into assigned_physical_gpu_ids, which + raised IndexError for non-identity --device-ids like [2, 3]. + """ + import vllm.platforms.interface as platform_interface + from vllm.platforms import current_platform + + monkeypatch.setattr(platform_interface, "_assigned_physical_gpu_ids", [2, 3]) + monkeypatch.delenv(current_platform.device_control_env_var, raising=False) + + # CVD unset: visible ordinal == physical ID, even beyond the + # assigned list's length. + assert current_platform.visible_device_id_to_physical_device_id(2) == 2 + assert current_platform.visible_device_id_to_physical_device_id(3) == 3 + + monkeypatch.setenv(current_platform.device_control_env_var, "4,5") + assert current_platform.visible_device_id_to_physical_device_id(1) == 5 + with pytest.raises(IndexError, match="out of range"): + current_platform.visible_device_id_to_physical_device_id(2) + + +class TestDpDeviceIdSharding: + def test_dp_supervisor_device_ids_stay_env_relative(self): + """Regression test: the DP supervisor must pass env-relative indices, + not physical IDs, because each child re-resolves --device-ids + against its inherited device-control env var.""" + import argparse + + from vllm.entrypoints.openai.dp_supervisor import _build_device_ids + + args = argparse.Namespace( + tensor_parallel_size=2, pipeline_parallel_size=1, device_ids=None + ) + assert _build_device_ids(args, local_rank=0) == [0, 1] + assert _build_device_ids(args, local_rank=1) == [2, 3] + + def test_dp_supervisor_shards_user_device_ids(self): + """User-provided --device-ids are sharded across DP children.""" + import argparse + + from vllm.entrypoints.openai.dp_supervisor import _build_device_ids + + args = argparse.Namespace( + tensor_parallel_size=2, pipeline_parallel_size=1, device_ids=[4, 5, 6, 7] + ) + assert _build_device_ids(args, local_rank=0) == [4, 5] + assert _build_device_ids(args, local_rank=1) == [6, 7] + with pytest.raises(ValueError, match="needs devices"): + _build_device_ids(args, local_rank=2) + + def test_dp_rank_shards_user_assigned_gpu_ids(self): + """get_physical_gpu_ids_for_local_dp_rank slices the user-provided + --device-ids list instead of recomputing from the env var.""" + from vllm.platforms import current_platform + from vllm.v1.engine.utils import get_physical_gpu_ids_for_local_dp_rank + + evar = current_platform.device_control_env_var + assert get_physical_gpu_ids_for_local_dp_rank( + evar, local_dp_rank=1, world_size=2, user_assigned_gpu_ids=[4, 5, 6, 7] + ) == [6, 7] + with pytest.raises(ValueError, match="needs devices"): + get_physical_gpu_ids_for_local_dp_rank( + evar, local_dp_rank=2, world_size=2, user_assigned_gpu_ids=[4, 5, 6, 7] + ) diff --git a/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py b/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py index ad9fed1d355a..f89d12553b92 100644 --- a/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py +++ b/tests/entrypoints/anthropic/test_anthropic_messages_conversion.py @@ -6,13 +6,39 @@ AnthropicServingMessages._convert_anthropic_to_openai_request(). Also covers extended-thinking edge cases such as ``redacted_thinking`` -blocks echoed back by Anthropic clients. +blocks echoed back by Anthropic clients, and streaming conversion in +``message_stream_converter``. + +Also covers cache usage computation in ``_build_anthropic_usage``. """ +import json +from unittest.mock import MagicMock + +import pytest + from vllm.entrypoints.anthropic.protocol import ( AnthropicMessagesRequest, ) -from vllm.entrypoints.anthropic.serving import AnthropicServingMessages +from vllm.entrypoints.anthropic.serving import ( + AnthropicServingMessages, + _build_anthropic_usage, + _get_cached_tokens, +) +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionResponse, + ChatCompletionResponseChoice, + ChatCompletionResponseStreamChoice, + ChatCompletionStreamResponse, + ChatMessage, +) +from vllm.entrypoints.openai.engine.protocol import ( + DeltaFunctionCall, + DeltaMessage, + DeltaToolCall, + PromptTokenUsageInfo, + UsageInfo, +) _convert = AnthropicServingMessages._convert_anthropic_to_openai_request _img_url = AnthropicServingMessages._convert_image_source_to_url @@ -637,12 +663,115 @@ def test_redacted_thinking_block_is_accepted(self): assert asst.get("content") == "Hi!" +# ====================================================================== +# Cache usage computation +# ====================================================================== + + +class TestGetCachedTokens: + """Tests for _get_cached_tokens helper.""" + + def test_none_usage(self): + assert _get_cached_tokens(None) is None + + def test_no_prompt_tokens_details(self): + usage = UsageInfo(prompt_tokens=100, completion_tokens=10) + assert _get_cached_tokens(usage) is None + + def test_cached_tokens_present(self): + usage = UsageInfo( + prompt_tokens=100, + completion_tokens=10, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=80), + ) + assert _get_cached_tokens(usage) == 80 + + def test_cached_tokens_zero(self): + """Zero cached tokens should return 0, not None.""" + usage = UsageInfo( + prompt_tokens=100, + completion_tokens=10, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=0), + ) + assert _get_cached_tokens(usage) == 0 + + def test_cached_tokens_none_in_details(self): + usage = UsageInfo( + prompt_tokens=100, + completion_tokens=10, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=None), + ) + assert _get_cached_tokens(usage) is None + + +class TestBuildAnthropicUsage: + """Tests for _build_anthropic_usage helper. + + Anthropic defines: total_input = input_tokens + cache_read + cache_creation + vLLM's prompt_tokens is the total. + """ + + def test_no_cache_info(self): + """When cache info is unavailable, return raw prompt_tokens.""" + result = _build_anthropic_usage(100, 10, None) + assert result.input_tokens == 100 + assert result.output_tokens == 10 + assert result.cache_read_input_tokens is None + assert result.cache_creation_input_tokens is None + + def test_cache_hit(self): + """When cache is hit, input_tokens excludes cached tokens.""" + usage = UsageInfo( + prompt_tokens=100, + completion_tokens=10, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=80), + ) + result = _build_anthropic_usage(100, 10, usage) + assert result.input_tokens == 20 # 100 - 80 + assert result.output_tokens == 10 + assert result.cache_read_input_tokens == 80 + assert result.cache_creation_input_tokens == 0 + + def test_zero_cached_tokens(self): + """Zero cached tokens should still set cache_creation to 0.""" + usage = UsageInfo( + prompt_tokens=100, + completion_tokens=10, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=0), + ) + result = _build_anthropic_usage(100, 10, usage) + assert result.input_tokens == 100 # 100 - 0 + assert result.cache_read_input_tokens == 0 + assert result.cache_creation_input_tokens == 0 + + def test_all_tokens_cached(self): + """When all tokens are cached, input_tokens should be 0.""" + usage = UsageInfo( + prompt_tokens=100, + completion_tokens=10, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=100), + ) + result = _build_anthropic_usage(100, 10, usage) + assert result.input_tokens == 0 + assert result.cache_read_input_tokens == 100 + assert result.cache_creation_input_tokens == 0 + + def test_no_prompt_tokens_details(self): + """UsageInfo without prompt_tokens_details returns no cache info.""" + usage = UsageInfo(prompt_tokens=100, completion_tokens=10) + result = _build_anthropic_usage(100, 10, usage) + assert result.input_tokens == 100 + assert result.cache_read_input_tokens is None + assert result.cache_creation_input_tokens is None + + class TestInlineSystemMessageInMessagesArray: """Verify that ``role: system`` messages embedded inside the ``messages`` - array are accepted and merged with the top-level ``system`` prompt. + array are preserved in their original position. - This handles clients that place system messages inside the messages array - instead of the Anthropic-standard top-level ``system`` field. + Unlike the previous approach that merged all system messages into a single + leading system message (breaking prefix caching), this preserves the + conversation structure so KV-cache hits remain intact. """ def test_inline_system_merged_with_top_level_system(self): @@ -690,17 +819,15 @@ def test_inline_system_merged_with_top_level_system(self): result = _convert(request) - # First message should be the merged system prompt. + # First message: top-level system prompt (billing header stripped). assert result.messages[0]["role"] == "system" - # Billing header stripped, inline system appended. assert ( result.messages[0]["content"] == "You are Claude Code, Anthropic's official CLI for Claude." "...." - "....." ) - # Second message should be the user message, content preserved. + # Second message: user message, content preserved at original position. assert result.messages[1]["role"] == "user" user_content = result.messages[1]["content"] assert len(user_content) == 2 @@ -713,6 +840,11 @@ def test_inline_system_merged_with_top_level_system(self): "text": "help?", } + # Third message: inline system stays in original position + # (after user, not merged into leading system). + assert result.messages[2]["role"] == "system" + assert result.messages[2]["content"] == "....." + def test_inline_system_string_only(self): """Only an inline system string, no top-level system.""" request = _make_request( @@ -723,9 +855,11 @@ def test_inline_system_string_only(self): ) result = _convert(request) - assert result.messages[0]["role"] == "system" - assert result.messages[0]["content"] == "Be concise." - assert result.messages[1]["role"] == "user" + # Inline system stays in its original position. + assert result.messages[0]["role"] == "user" + assert result.messages[0]["content"] == "Hello" + assert result.messages[1]["role"] == "system" + assert result.messages[1]["content"] == "Be concise." def test_inline_system_list_content(self): """Inline system with list content blocks.""" @@ -743,11 +877,15 @@ def test_inline_system_list_content(self): ) result = _convert(request) - assert result.messages[0]["role"] == "system" - assert result.messages[0]["content"] == "Part one. Part two." + # Inline system stays in its original position; + # text blocks are concatenated (same as top-level system). + assert result.messages[0]["role"] == "user" + assert result.messages[0]["content"] == "Hi" + assert result.messages[1]["role"] == "system" + assert result.messages[1]["content"] == "Part one. Part two." def test_multiple_inline_system_messages(self): - """Multiple inline system messages should all be merged.""" + """Multiple inline system messages each stay in their position.""" request = _make_request( [ {"role": "system", "content": "First system."}, @@ -757,9 +895,13 @@ def test_multiple_inline_system_messages(self): ) result = _convert(request) + # Each system message stays in its original position. assert result.messages[0]["role"] == "system" - assert result.messages[0]["content"] == "First system.Second system." + assert result.messages[0]["content"] == "First system." assert result.messages[1]["role"] == "user" + assert result.messages[1]["content"] == "Hello" + assert result.messages[2]["role"] == "system" + assert result.messages[2]["content"] == "Second system." def test_inline_system_with_top_level_string(self): """Top-level system is a string, inline system is also present.""" @@ -772,6 +914,509 @@ def test_inline_system_with_top_level_string(self): ) result = _convert(request) + # Top-level system goes first; inline system stays in position. assert result.messages[0]["role"] == "system" - assert result.messages[0]["content"] == "Top-level prompt.Inline hint." + assert result.messages[0]["content"] == "Top-level prompt." assert result.messages[1]["role"] == "user" + assert result.messages[1]["content"] == "Hello" + assert result.messages[2]["role"] == "system" + assert result.messages[2]["content"] == "Inline hint." + + def test_inline_system_billing_header_stripped(self): + """Inline system that is only a billing header is omitted.""" + request = _make_request( + [ + {"role": "user", "content": "Hello"}, + { + "role": "system", + "content": "x-anthropic-billing-header: cc_version=2.1.160", + }, + {"role": "assistant", "content": "Hi there"}, + ] + ) + result = _convert(request) + + # Billing-header-only system message should be dropped entirely. + assert len(result.messages) == 2 + assert result.messages[0]["role"] == "user" + assert result.messages[1]["role"] == "assistant" + + def test_inline_system_billing_header_mixed_with_content(self): + """Inline system with billing header block + real content.""" + request = _make_request( + [ + {"role": "user", "content": "Hello"}, + { + "role": "system", + "content": [ + { + "type": "text", + "text": "x-anthropic-billing-header: " + "cc_version=2.1.160.bca; cch=d1d48;", + }, + {"type": "text", "text": "Real system content."}, + ], + }, + ] + ) + result = _convert(request) + + # Billing header stripped, real content preserved in position. + assert len(result.messages) == 2 + assert result.messages[0]["role"] == "user" + assert result.messages[0]["content"] == "Hello" + assert result.messages[1]["role"] == "system" + assert result.messages[1]["content"] == "Real system content." + + +# ====================================================================== +# Streaming conversion: message_stream_converter +# ====================================================================== + + +def _make_stream_converter(): + obj = MagicMock(spec=AnthropicServingMessages) + obj.stop_reason_map = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + } + obj.message_stream_converter = ( + AnthropicServingMessages.message_stream_converter.__get__(obj) + ) + return obj + + +def _parse_sse_events(raw_events: list[str]) -> list[tuple[str, dict]]: + results = [] + for raw in raw_events: + headers = dict( + line.split(": ", 1) for line in raw.strip().split("\n") if ": " in line + ) + if "event" in headers and "data" in headers: + results.append((headers["event"], json.loads(headers["data"]))) + return results + + +def _make_stream_chunk( + *, + delta: DeltaMessage | None = None, + finish_reason: str | None = None, + choices: list[ChatCompletionResponseStreamChoice] | None = None, + usage: UsageInfo | None = None, +) -> str: + if choices is None: + choices = [ + ChatCompletionResponseStreamChoice( + index=0, + delta=delta or DeltaMessage(), + finish_reason=finish_reason, + ) + ] + chunk = ChatCompletionStreamResponse( + id="chatcmpl-test", + created=0, + model="test-model", + choices=choices, + usage=usage, + ) + return f"data: {chunk.model_dump_json()}" + + +def _tc(*, args, id=None, name=None): + return DeltaToolCall( + index=0, + id=id, + function=DeltaFunctionCall(name=name, arguments=args), + ) + + +class TestMessageStreamConverterToolUseContentBuffering: + """Regression test for tool_use arguments being silently dropped. + + With speculative decoding or multi-token prediction, a single delta + can carry both the final tool_call argument fragment and trailing + content. + """ + + @pytest.mark.asyncio + async def test_tool_use_args_not_dropped_when_content_in_same_chunk( + self, + ): + async def sse_input(): + yield _make_stream_chunk( + delta=DeltaMessage(role="assistant"), + usage=UsageInfo(prompt_tokens=10, total_tokens=10), + ) + yield _make_stream_chunk( + delta=DeltaMessage( + tool_calls=[ + _tc(id="call_abc123", name="read_file", args=""), + ] + ) + ) + yield _make_stream_chunk( + delta=DeltaMessage( + tool_calls=[ + _tc(args='{"path":"/tmp/f"'), + ] + ) + ) + # BUG TRIGGER: final tool_call args and trailing content in + # one delta, as happens with spec decoding / multi-token + # prediction where multiple tokens land in a single chunk. + yield _make_stream_chunk( + delta=DeltaMessage( + content="\nOkay", + tool_calls=[_tc(args="}")], + ) + ) + yield _make_stream_chunk(finish_reason="tool_calls") + yield _make_stream_chunk( + choices=[], + usage=UsageInfo( + prompt_tokens=10, + total_tokens=30, + completion_tokens=20, + ), + ) + yield "data: [DONE]" + + converter = _make_stream_converter() + output = [] + async for event in converter.message_stream_converter(sse_input()): + output.append(event) + + events = _parse_sse_events(output) + + assert events[0][0] == "message_start" + + arg_fragments = [ + data["delta"]["partial_json"] + for _, data in events + if data.get("delta", {}).get("type") == "input_json_delta" + ] + full_args = "".join(arg_fragments) + assert full_args == '{"path":"/tmp/f"}' + + text_deltas = [ + data["delta"]["text"] + for _, data in events + if data.get("delta", {}).get("type") == "text_delta" + ] + assert text_deltas == ["\nOkay"] + + block_starts = [ + (data["content_block"]["type"], data.get("index")) + for ev_type, data in events + if ev_type == "content_block_start" + ] + assert block_starts[0] == ("tool_use", 0) + assert block_starts[1] == ("text", 1) + + msg_deltas = [data for ev_type, data in events if ev_type == "message_delta"] + assert msg_deltas[0]["delta"]["stop_reason"] == "tool_use" + + assert events[-1][0] == "message_stop" + + @pytest.mark.asyncio + async def test_buffered_content_flushed_on_done_without_usage_chunk(self): + """Content buffered during tool_use must be emitted even if the + stream jumps straight from finish_reason to [DONE], skipping the + empty-choices usage chunk.""" + + async def sse_input(): + yield _make_stream_chunk( + delta=DeltaMessage(role="assistant"), + usage=UsageInfo(prompt_tokens=10, total_tokens=10), + ) + yield _make_stream_chunk( + delta=DeltaMessage( + tool_calls=[ + _tc(id="call_xyz", name="get_weather", args=""), + ] + ) + ) + yield _make_stream_chunk( + delta=DeltaMessage( + tool_calls=[_tc(args='{"city":"NYC"}')], + ) + ) + yield _make_stream_chunk( + delta=DeltaMessage(content="\nDone"), + finish_reason="tool_calls", + ) + # No empty-choices usage chunk — go straight to [DONE]. + yield "data: [DONE]" + + converter = _make_stream_converter() + output = [] + async for event in converter.message_stream_converter(sse_input()): + output.append(event) + + events = _parse_sse_events(output) + + text_deltas = [ + data["delta"]["text"] + for _, data in events + if data.get("delta", {}).get("type") == "text_delta" + ] + assert text_deltas == ["\nDone"] + + block_starts = [ + data["content_block"]["type"] + for ev_type, data in events + if ev_type == "content_block_start" + ] + assert "tool_use" in block_starts + assert "text" in block_starts + + assert events[-1][0] == "message_stop" + + +class TestMessageStartIncludesTypeAndRole: + """Regression test for issue #45367: the streaming message_start event is + serialized with exclude_unset=True, which silently dropped the + default-valued ``type``/``role`` fields of the nested message object. + Strict Anthropic SDK clients (e.g. Claude Code) validate + ``message_start.message.type``/``role`` and reject the whole stream when + they are missing. + """ + + @pytest.mark.asyncio + async def test_message_start_contains_message_type_and_role(self): + async def sse_input(): + yield _make_stream_chunk( + delta=DeltaMessage(content="Hello"), + usage=UsageInfo( + prompt_tokens=20, + total_tokens=20, + completion_tokens=0, + ), + ) + yield _make_stream_chunk(finish_reason="stop") + yield "data: [DONE]" + + converter = _make_stream_converter() + output = [] + async for event in converter.message_stream_converter(sse_input()): + output.append(event) + + events = _parse_sse_events(output) + + assert events[0][0] == "message_start" + message = events[0][1]["message"] + assert message["type"] == "message" + assert message["role"] == "assistant" + + +class TestStreamingCacheUsageSemantics: + """Locks in the documented streaming behavior of cache usage fields. + + vLLM's OpenAI chat completion streaming only attaches + ``prompt_tokens_details`` to the terminal usage chunk. The Anthropic layer + mirrors that contract: cache fields are omitted on ``message_start`` (key + absence signals "unknown") and populated on ``message_delta`` (the final + cumulative count). This is intentionally consistent with vLLM's OpenAI + behavior, even though Anthropic's upstream API populates cache fields on + ``message_start``; closing that gap requires plumbing cache info into the + first chunk at the OpenAI layer, which is out of scope here. + """ + + @pytest.mark.asyncio + async def test_streaming_cache_fields_absent_then_populated(self): + """First chunk lacks prompt_tokens_details (vLLM contract); + message_start omits cache fields. The final chunk carries + prompt_tokens_details, so message_delta carries resolved values.""" + + async def sse_input(): + yield _make_stream_chunk( + delta=DeltaMessage(role="assistant", content="hi"), + usage=UsageInfo(prompt_tokens=100, total_tokens=100), + ) + yield _make_stream_chunk(finish_reason="stop") + yield _make_stream_chunk( + choices=[], + usage=UsageInfo( + prompt_tokens=100, + completion_tokens=5, + total_tokens=105, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=80), + ), + ) + yield "data: [DONE]" + + converter = _make_stream_converter() + output = [] + async for event in converter.message_stream_converter(sse_input()): + output.append(event) + events = _parse_sse_events(output) + + # message_start: cache fields unknown → omitted from JSON entirely. + start_usage = events[0][1]["message"]["usage"] + assert events[0][0] == "message_start" + assert start_usage["input_tokens"] == 100 + assert "cache_read_input_tokens" not in start_usage + assert "cache_creation_input_tokens" not in start_usage + + # message_delta: authoritative usage with cache fields populated. + delta_usage = next( + data["usage"] for ev, data in events if ev == "message_delta" + ) + assert delta_usage["input_tokens"] == 20 # 100 - 80 + assert delta_usage["cache_read_input_tokens"] == 80 + assert delta_usage["cache_creation_input_tokens"] == 0 + + @pytest.mark.asyncio + async def test_streaming_no_cache_hit(self): + """When the final chunk reports cached_tokens=0, message_delta carries + cache fields = 0 (cache miss); message_start still omits them.""" + + async def sse_input(): + yield _make_stream_chunk( + delta=DeltaMessage(role="assistant"), + usage=UsageInfo(prompt_tokens=50, total_tokens=50), + ) + yield _make_stream_chunk(finish_reason="stop") + yield _make_stream_chunk( + choices=[], + usage=UsageInfo( + prompt_tokens=50, + completion_tokens=5, + total_tokens=55, + prompt_tokens_details=PromptTokenUsageInfo(cached_tokens=0), + ), + ) + yield "data: [DONE]" + + converter = _make_stream_converter() + output = [] + async for event in converter.message_stream_converter(sse_input()): + output.append(event) + events = _parse_sse_events(output) + + start_usage = events[0][1]["message"]["usage"] + delta_usage = next( + data["usage"] for ev, data in events if ev == "message_delta" + ) + assert start_usage["input_tokens"] == 50 + assert "cache_read_input_tokens" not in start_usage + assert "cache_creation_input_tokens" not in start_usage + assert delta_usage["input_tokens"] == 50 # 50 - 0 + assert delta_usage["cache_read_input_tokens"] == 0 + assert delta_usage["cache_creation_input_tokens"] == 0 + + @pytest.mark.asyncio + async def test_streaming_no_prompt_tokens_details_at_all(self): + """If --enable-prompt-tokens-details is off, no chunk carries cache + info; both message_start and message_delta omit cache fields.""" + + async def sse_input(): + yield _make_stream_chunk( + delta=DeltaMessage(role="assistant"), + usage=UsageInfo(prompt_tokens=30, total_tokens=30), + ) + yield _make_stream_chunk(finish_reason="stop") + yield _make_stream_chunk( + choices=[], + usage=UsageInfo(prompt_tokens=30, completion_tokens=2, total_tokens=32), + ) + yield "data: [DONE]" + + converter = _make_stream_converter() + output = [] + async for event in converter.message_stream_converter(sse_input()): + output.append(event) + events = _parse_sse_events(output) + + start_usage = events[0][1]["message"]["usage"] + delta_usage = next( + data["usage"] for ev, data in events if ev == "message_delta" + ) + assert "cache_read_input_tokens" not in start_usage + assert "cache_creation_input_tokens" not in start_usage + assert "cache_read_input_tokens" not in delta_usage + assert "cache_creation_input_tokens" not in delta_usage + + +# ====================================================================== +# Auto-detection of system-first template requirement +# ====================================================================== + + +Q35_TEMPLATE = ( + "{%- for message in messages %}" + "{%- if message.role == 'system' %}" + "{%- if not loop.first %}" + "{{- raise_exception('System message must be at the beginning.') }}" + "{%- endif %}" + "{%- endif %}" + "{%- endfor %}" +) + + +class TestDetectMergeInlineSystem: + """Verify _detect_merge_inline_system auto-detection. + + Tests three scenarios: + 1. Template with system-first guard (e.g. Qwen) → merge needed + 2. Template without restrictions → no merge, cache-friendly + 3. No template provided → safe default: merge + """ + + def test_qwen_template_requires_merge(self): + """Template with loop.first guard rejects mid-conversation system.""" + assert ( + AnthropicServingMessages._detect_merge_inline_system(Q35_TEMPLATE) is True + ) + + def test_no_restriction_no_merge(self): + """Template without restriction accepts mid-conversation system.""" + assert ( + AnthropicServingMessages._detect_merge_inline_system( + "{%- for message in messages %}" + "{{- message.role }}: {{ message.content }}\n" + "{%- endfor %}" + ) + is False + ) + + def test_no_template_defaults_merge(self): + """No chat_template → conservative default: merge.""" + assert AnthropicServingMessages._detect_merge_inline_system(None) is True + + +# ====================================================================== +# Full (non-streaming) response conversion: messages_full_converter +# ====================================================================== + + +def _make_full_converter(): + obj = MagicMock(spec=AnthropicServingMessages) + obj.messages_full_converter = ( + AnthropicServingMessages.messages_full_converter.__get__(obj) + ) + return obj + + +class TestMessagesFullConverter: + def test_empty_completion_emits_one_text_block(self): + """An empty completion still yields exactly one (empty) text block.""" + generator = ChatCompletionResponse( + id="chatcmpl-empty", + model="test-model", + choices=[ + ChatCompletionResponseChoice( + index=0, + message=ChatMessage(role="assistant", content=None), + finish_reason="stop", + ) + ], + usage=UsageInfo(prompt_tokens=10, completion_tokens=0, total_tokens=10), + ) + + result = _make_full_converter().messages_full_converter(generator) + + assert len(result.content) == 1 + assert result.content[0].type == "text" + assert result.content[0].text == "" diff --git a/tests/entrypoints/anthropic/test_protocol_exports.py b/tests/entrypoints/anthropic/test_protocol_exports.py new file mode 100644 index 000000000000..466f40e3ccf5 --- /dev/null +++ b/tests/entrypoints/anthropic/test_protocol_exports.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression tests for Anthropic protocol exports used by serving. + +Guards against Docker/nightly images shipping a stale protocol module that is +missing symbols imported by ``vllm.entrypoints.anthropic.serving`` (issue #44759). +""" + +import pytest + +from vllm.entrypoints.anthropic.protocol import ( + AnthropicContentBlock, + AnthropicContextManagement, + AnthropicCountTokensRequest, + AnthropicCountTokensResponse, + AnthropicDelta, + AnthropicError, + AnthropicMessagesRequest, + AnthropicMessagesResponse, + AnthropicOutputConfig, + AnthropicStreamEvent, + AnthropicUsage, +) + +pytestmark = pytest.mark.skip_global_cleanup + +SERVING_PROTOCOL_EXPORTS = ( + AnthropicContentBlock, + AnthropicContextManagement, + AnthropicCountTokensRequest, + AnthropicCountTokensResponse, + AnthropicDelta, + AnthropicError, + AnthropicMessagesRequest, + AnthropicMessagesResponse, + AnthropicOutputConfig, + AnthropicStreamEvent, + AnthropicUsage, +) + + +def test_serving_protocol_exports_are_importable(): + for export in SERVING_PROTOCOL_EXPORTS: + assert export is not None + + +def test_anthropic_output_config_instantiation(): + config = AnthropicOutputConfig() + assert config.effort is None + assert config.format is None diff --git a/tests/entrypoints/generate/generative_scoring/test_generative_scoring.py b/tests/entrypoints/generate/generative_scoring/test_generative_scoring.py index d80082992297..12dae6dfd523 100644 --- a/tests/entrypoints/generate/generative_scoring/test_generative_scoring.py +++ b/tests/entrypoints/generate/generative_scoring/test_generative_scoring.py @@ -78,7 +78,6 @@ def _create_mock_engine(): mock_engine.model_config = MockModelConfig() mock_engine.input_processor = MagicMock() - # renderer is accessed by OpenAIServing.__init__ and serving.py mock_renderer = MagicMock() mock_renderer.tokenizer = get_tokenizer(MODEL_NAME) mock_engine.renderer = mock_renderer diff --git a/tests/entrypoints/sagemaker/__init__.py b/tests/entrypoints/llm/offline_mode/__init__.py similarity index 100% rename from tests/entrypoints/sagemaker/__init__.py rename to tests/entrypoints/llm/offline_mode/__init__.py diff --git a/tests/entrypoints/offline_mode/test_offline_mode.py b/tests/entrypoints/llm/offline_mode/test_offline_mode.py similarity index 100% rename from tests/entrypoints/offline_mode/test_offline_mode.py rename to tests/entrypoints/llm/offline_mode/test_offline_mode.py diff --git a/tests/entrypoints/llm/test_chat.py b/tests/entrypoints/llm/test_chat.py index 7d8a09852799..61cdbd3eee21 100644 --- a/tests/entrypoints/llm/test_chat.py +++ b/tests/entrypoints/llm/test_chat.py @@ -4,7 +4,6 @@ import pytest -from tests.entrypoints.openai.chat_completion.test_vision import TEST_IMAGE_ASSETS from vllm import LLM from vllm.distributed import cleanup_dist_env_and_memory from vllm.sampling_params import SamplingParams @@ -76,47 +75,6 @@ def test_multi_chat(text_llm): assert len(outputs) == 2 -@pytest.fixture(scope="function") -def vision_llm(): - # pytest caches the fixture so we use weakref.proxy to - # enable garbage collection - llm = LLM( - model="microsoft/Phi-3.5-vision-instruct", - max_model_len=4096, - max_num_seqs=5, - enforce_eager=True, - trust_remote_code=True, - limit_mm_per_prompt={"image": 2}, - seed=0, - ) - - yield weakref.proxy(llm) - - del llm - - cleanup_dist_env_and_memory() - - -@pytest.mark.parametrize( - "image_urls", [[TEST_IMAGE_ASSETS[0], TEST_IMAGE_ASSETS[1]]], indirect=True -) -def test_chat_multi_image(vision_llm, image_urls: list[str]): - messages = [ - { - "role": "user", - "content": [ - *( - {"type": "image_url", "image_url": {"url": image_url}} - for image_url in image_urls - ), - {"type": "text", "text": "What's in this image?"}, - ], - } - ] - outputs = vision_llm.chat(messages) - assert len(outputs) >= 0 - - def test_llm_chat_tokenization_no_double_bos(text_llm): """ LLM.chat() should not add special tokens when using chat templates. diff --git a/tests/entrypoints/serve/disagg/__init__.py b/tests/entrypoints/multimodal/__init__.py similarity index 100% rename from tests/entrypoints/serve/disagg/__init__.py rename to tests/entrypoints/multimodal/__init__.py diff --git a/tests/entrypoints/multimodal/conftest.py b/tests/entrypoints/multimodal/conftest.py new file mode 100644 index 000000000000..8003f1bf7dcb --- /dev/null +++ b/tests/entrypoints/multimodal/conftest.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any + +import pytest + +# Test different image extensions (JPG/PNG) and formats (gray/RGB/RGBA) +TEST_IMAGE_ASSETS = [ + "2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg" + "Grayscale_8bits_palette_sample_image.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/Grayscale_8bits_palette_sample_image.png", + "1280px-Venn_diagram_rgb.svg.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/1280px-Venn_diagram_rgb.svg.png", + "RGBA_comp.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/RGBA_comp.png", +] + + +def _shutdown_llm(llm: Any, gpu_memory_utilization: float) -> None: + from vllm.distributed import cleanup_dist_env_and_memory + from vllm.platforms import current_platform + + try: + shutdown_timeout = 60.0 if current_platform.is_rocm() else None + llm.llm_engine.engine_core.shutdown(timeout=shutdown_timeout) + except Exception: + pass + + del llm + + try: + import torch + + torch._dynamo.reset() + except Exception: + pass + + cleanup_dist_env_and_memory() + + if current_platform.is_rocm(): + from tests.utils import wait_for_rocm_memory_to_settle + + wait_for_rocm_memory_to_settle(threshold_ratio=1.0 - gpu_memory_utilization) + + +@contextmanager +def managed_llm(*args: Any, **kwargs: Any) -> Iterator[Any]: + from vllm import LLM + + llm = LLM(*args, **kwargs) + gpu_memory_utilization = ( + llm.llm_engine.vllm_config.cache_config.gpu_memory_utilization + ) + try: + yield llm + finally: + _shutdown_llm(llm, gpu_memory_utilization) + + +def _make_managed_llm_factory() -> Iterator[Callable[..., Any]]: + from vllm import LLM + + llms: list[tuple[Any, float]] = [] + + def make_llm(*args: Any, **kwargs: Any) -> Any: + llm = LLM(*args, **kwargs) + gpu_memory_utilization = ( + llm.llm_engine.vllm_config.cache_config.gpu_memory_utilization + ) + llms.append((llm, gpu_memory_utilization)) + return llm + + try: + yield make_llm + finally: + while llms: + llm, gpu_memory_utilization = llms.pop() + _shutdown_llm(llm, gpu_memory_utilization) + + +@pytest.fixture +def multimodal_llm_factory() -> Iterator[Callable[..., Any]]: + yield from _make_managed_llm_factory() diff --git a/tests/entrypoints/serve/render/__init__.py b/tests/entrypoints/multimodal/llm/__init__.py similarity index 100% rename from tests/entrypoints/serve/render/__init__.py rename to tests/entrypoints/multimodal/llm/__init__.py diff --git a/tests/entrypoints/multimodal/llm/test_chat.py b/tests/entrypoints/multimodal/llm/test_chat.py new file mode 100644 index 000000000000..4de1f5cb80a0 --- /dev/null +++ b/tests/entrypoints/multimodal/llm/test_chat.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import pytest + +from tests.entrypoints.multimodal.conftest import TEST_IMAGE_ASSETS + + +@pytest.fixture(scope="function") +def vision_llm(multimodal_llm_factory): + return multimodal_llm_factory( + model="microsoft/Phi-3.5-vision-instruct", + max_model_len=4096, + max_num_seqs=5, + enforce_eager=True, + trust_remote_code=True, + limit_mm_per_prompt={"image": 2}, + seed=0, + ) + + +@pytest.mark.parametrize( + "image_urls", [[TEST_IMAGE_ASSETS[0], TEST_IMAGE_ASSETS[1]]], indirect=True +) +def test_chat_multi_image(vision_llm, image_urls: list[str]): + messages = [ + { + "role": "user", + "content": [ + *( + {"type": "image_url", "image_url": {"url": image_url}} + for image_url in image_urls + ), + {"type": "text", "text": "What's in this image?"}, + ], + } + ] + outputs = vision_llm.chat(messages) + assert len(outputs) >= 0 diff --git a/tests/entrypoints/llm/test_mm_cache_external_injection.py b/tests/entrypoints/multimodal/llm/test_mm_cache_external_injection.py similarity index 97% rename from tests/entrypoints/llm/test_mm_cache_external_injection.py rename to tests/entrypoints/multimodal/llm/test_mm_cache_external_injection.py index 3023457c5fed..076a381f6cd4 100644 --- a/tests/entrypoints/llm/test_mm_cache_external_injection.py +++ b/tests/entrypoints/multimodal/llm/test_mm_cache_external_injection.py @@ -15,7 +15,7 @@ import pytest import regex as re -from tests.entrypoints.openai.chat_completion.test_vision import TEST_IMAGE_ASSETS +from tests.entrypoints.multimodal.conftest import TEST_IMAGE_ASSETS from vllm import LLM, SamplingParams from vllm.renderers.params import ChatParams from vllm.v1.metrics import loggers as stat_loggers @@ -69,6 +69,7 @@ def test_inject_into_mm_cache( image_urls, mm_processor_cache_type, caplog_vllm, + multimodal_llm_factory, ): """Test that inject_into_mm_cache() injects pre-processed mm_kwargs into the processor cache and MM cache hit metrics are updated correctly. @@ -78,7 +79,7 @@ def test_inject_into_mm_cache( 2. Extract cached kwargs, call inject_into_mm_cache with a new hash, then generate with a pre-rendered input -> verifies injection works """ - llm = LLM( + llm = multimodal_llm_factory( model="llava-hf/llava-1.5-7b-hf", max_model_len=4096, max_num_seqs=5, @@ -145,11 +146,12 @@ def test_inject_into_mm_cache( def test_inject_into_mm_cache_without_cache( num_gpus_available, image_urls, + multimodal_llm_factory, ): """Test that inject_into_mm_cache works gracefully when processor cache is disabled (mm_processor_cache_gb=0). Should not crash. """ - llm = LLM( + llm = multimodal_llm_factory( model="llava-hf/llava-1.5-7b-hf", max_model_len=4096, max_num_seqs=5, diff --git a/tests/entrypoints/llm/test_mm_cache_stats.py b/tests/entrypoints/multimodal/llm/test_mm_cache_stats.py similarity index 95% rename from tests/entrypoints/llm/test_mm_cache_stats.py rename to tests/entrypoints/multimodal/llm/test_mm_cache_stats.py index 62c6aa9f7a21..dbea37f64eea 100644 --- a/tests/entrypoints/llm/test_mm_cache_stats.py +++ b/tests/entrypoints/multimodal/llm/test_mm_cache_stats.py @@ -6,7 +6,7 @@ import pytest import regex as re -from tests.entrypoints.openai.chat_completion.test_vision import TEST_IMAGE_ASSETS +from tests.entrypoints.multimodal.conftest import TEST_IMAGE_ASSETS from vllm import LLM from vllm.entrypoints.chat_utils import ChatCompletionMessageParam from vllm.v1.metrics import loggers as stat_loggers @@ -61,8 +61,9 @@ def test_mm_cache_stats( image_urls, mm_processor_cache_type, caplog_vllm, + multimodal_llm_factory, ): - llm = LLM( + llm = multimodal_llm_factory( model="llava-hf/llava-1.5-7b-hf", max_model_len=4096, max_num_seqs=5, diff --git a/tests/entrypoints/llm/test_mm_embeds_only.py b/tests/entrypoints/multimodal/llm/test_mm_embeds_only.py similarity index 91% rename from tests/entrypoints/llm/test_mm_embeds_only.py rename to tests/entrypoints/multimodal/llm/test_mm_embeds_only.py index 13d0fd58b139..57bec9c1188a 100644 --- a/tests/entrypoints/llm/test_mm_embeds_only.py +++ b/tests/entrypoints/multimodal/llm/test_mm_embeds_only.py @@ -1,13 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import weakref - import pytest +from tests.entrypoints.multimodal.conftest import managed_llm from vllm import LLM, SamplingParams from vllm.assets.image import ImageAsset -from vllm.distributed import cleanup_dist_env_and_memory MODEL = "llava-hf/llava-1.5-7b-hf" PROMPT = "USER: \nDescribe this image briefly.\nASSISTANT:" @@ -17,20 +15,15 @@ @pytest.fixture(scope="module") def llm(): """LLM with enable_mm_embeds=True and all modality limits zeroed out.""" - llm = LLM( + with managed_llm( model=MODEL, max_model_len=2048, enforce_eager=True, gpu_memory_utilization=0.8, enable_mm_embeds=True, limit_mm_per_prompt={"image": 0}, - ) - - yield weakref.proxy(llm) - - del llm - - cleanup_dist_env_and_memory() + ) as llm: + yield llm @pytest.mark.skip_global_cleanup diff --git a/tests/entrypoints/llm/test_mm_processor_kwargs.py b/tests/entrypoints/multimodal/llm/test_mm_processor_kwargs.py similarity index 100% rename from tests/entrypoints/llm/test_mm_processor_kwargs.py rename to tests/entrypoints/multimodal/llm/test_mm_processor_kwargs.py diff --git a/tests/plugins/lora_resolvers/__init__.py b/tests/entrypoints/multimodal/openai/__init__.py similarity index 100% rename from tests/plugins/lora_resolvers/__init__.py rename to tests/entrypoints/multimodal/openai/__init__.py diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/p2p/__init__.py b/tests/entrypoints/multimodal/openai/chat_completion/__init__.py similarity index 100% rename from vllm/distributed/kv_transfer/kv_connector/v1/p2p/__init__.py rename to tests/entrypoints/multimodal/openai/chat_completion/__init__.py diff --git a/tests/entrypoints/openai/chat_completion/test_audio.py b/tests/entrypoints/multimodal/openai/chat_completion/test_audio.py similarity index 100% rename from tests/entrypoints/openai/chat_completion/test_audio.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_audio.py diff --git a/tests/entrypoints/openai/chat_completion/test_audio_in_video.py b/tests/entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py similarity index 100% rename from tests/entrypoints/openai/chat_completion/test_audio_in_video.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py diff --git a/tests/entrypoints/openai/chat_completion/test_completion_with_image_embeds.py b/tests/entrypoints/multimodal/openai/chat_completion/test_chat_completion_with_image_embeds.py similarity index 98% rename from tests/entrypoints/openai/chat_completion/test_completion_with_image_embeds.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_chat_completion_with_image_embeds.py index b30556fbc81f..4d4aedfb3599 100644 --- a/tests/entrypoints/openai/chat_completion/test_completion_with_image_embeds.py +++ b/tests/entrypoints/multimodal/openai/chat_completion/test_chat_completion_with_image_embeds.py @@ -52,7 +52,7 @@ async def client_with_image_embeds(server_with_image_embeds): @pytest.mark.asyncio @pytest.mark.parametrize("model_name", [MODEL_NAME]) @pytest.mark.parametrize("dtype", [torch.half, torch.float16, torch.float32]) -async def test_completions_with_image_embeds( +async def test_chat_completions_with_image_embeds( client_with_image_embeds: openai.AsyncOpenAI, model_name: str, image_assets: ImageTestAssets, diff --git a/tests/entrypoints/openai/chat_completion/test_chat_completion_with_mixed_audio_embeds.py b/tests/entrypoints/multimodal/openai/chat_completion/test_chat_completion_with_mixed_audio_embeds.py similarity index 100% rename from tests/entrypoints/openai/chat_completion/test_chat_completion_with_mixed_audio_embeds.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_chat_completion_with_mixed_audio_embeds.py diff --git a/tests/entrypoints/openai/chat_completion/test_chat_completion_with_mixed_image_embeds.py b/tests/entrypoints/multimodal/openai/chat_completion/test_chat_completion_with_mixed_image_embeds.py similarity index 100% rename from tests/entrypoints/openai/chat_completion/test_chat_completion_with_mixed_image_embeds.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_chat_completion_with_mixed_image_embeds.py diff --git a/tests/entrypoints/openai/chat_completion/test_default_mm_loras.py b/tests/entrypoints/multimodal/openai/chat_completion/test_default_mm_loras.py similarity index 100% rename from tests/entrypoints/openai/chat_completion/test_default_mm_loras.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_default_mm_loras.py diff --git a/tests/entrypoints/openai/chat_completion/test_video.py b/tests/entrypoints/multimodal/openai/chat_completion/test_video.py similarity index 100% rename from tests/entrypoints/openai/chat_completion/test_video.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_video.py diff --git a/tests/entrypoints/openai/chat_completion/test_vision.py b/tests/entrypoints/multimodal/openai/chat_completion/test_vision.py similarity index 96% rename from tests/entrypoints/openai/chat_completion/test_vision.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_vision.py index 6cb8433423b8..b33311f8af9d 100644 --- a/tests/entrypoints/openai/chat_completion/test_vision.py +++ b/tests/entrypoints/multimodal/openai/chat_completion/test_vision.py @@ -8,6 +8,7 @@ import pytest_asyncio from transformers import AutoProcessor +from tests.entrypoints.multimodal.conftest import TEST_IMAGE_ASSETS from tests.utils import ROCM_ENV_OVERRIDES, ROCM_EXTRA_ARGS, RemoteOpenAIServer from vllm.multimodal.media import MediaWithBytes from vllm.multimodal.utils import encode_image_url, fetch_image @@ -16,14 +17,6 @@ MODEL_NAME = "microsoft/Phi-3.5-vision-instruct" MAXIMUM_IMAGES = 2 -# Test different image extensions (JPG/PNG) and formats (gray/RGB/RGBA) -TEST_IMAGE_ASSETS = [ - "2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg" - "Grayscale_8bits_palette_sample_image.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/Grayscale_8bits_palette_sample_image.png", - "1280px-Venn_diagram_rgb.svg.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/1280px-Venn_diagram_rgb.svg.png", - "RGBA_comp.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/RGBA_comp.png", -] - # Required terms for beam search validation # Each entry is a list of term groups - ALL groups must match # Each group is a list of alternatives - at least ONE term in the group must appear diff --git a/tests/entrypoints/openai/chat_completion/test_vision_embeds.py b/tests/entrypoints/multimodal/openai/chat_completion/test_vision_embeds.py similarity index 100% rename from tests/entrypoints/openai/chat_completion/test_vision_embeds.py rename to tests/entrypoints/multimodal/openai/chat_completion/test_vision_embeds.py diff --git a/vllm/entrypoints/serve/disagg/__init__.py b/tests/entrypoints/multimodal/openai/responses/__init__.py similarity index 100% rename from vllm/entrypoints/serve/disagg/__init__.py rename to tests/entrypoints/multimodal/openai/responses/__init__.py diff --git a/tests/entrypoints/openai/responses/test_image.py b/tests/entrypoints/multimodal/openai/responses/test_image.py similarity index 86% rename from tests/entrypoints/openai/responses/test_image.py rename to tests/entrypoints/multimodal/openai/responses/test_image.py index 644d8ce00686..36ebdde810c6 100644 --- a/tests/entrypoints/openai/responses/test_image.py +++ b/tests/entrypoints/multimodal/openai/responses/test_image.py @@ -7,19 +7,13 @@ import pytest import pytest_asyncio +from tests.entrypoints.multimodal.conftest import TEST_IMAGE_ASSETS from tests.utils import RemoteOpenAIServer from vllm.multimodal.utils import encode_image_url # Use a small vision model for testing MODEL_NAME = "Qwen/Qwen2.5-VL-3B-Instruct" MAXIMUM_IMAGES = 2 -# Test different image extensions (JPG/PNG) and formats (gray/RGB/RGBA) -TEST_IMAGE_ASSETS = [ - "2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg" - "Grayscale_8bits_palette_sample_image.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/Grayscale_8bits_palette_sample_image.png", - "1280px-Venn_diagram_rgb.svg.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/1280px-Venn_diagram_rgb.svg.png", - "RGBA_comp.png", # "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/RGBA_comp.png", -] @pytest.fixture(scope="module") diff --git a/tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py b/tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py index c3a8d0b2bdec..87a206b566a2 100644 --- a/tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py +++ b/tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py @@ -111,3 +111,79 @@ async def test_batched_chat_completions_with_json_schema( parsed = json.loads(choice["message"]["content"]) assert "answer" in parsed assert parsed["answer"] in ("yes", "no") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_name", + [MODEL_NAME], +) +async def test_batched_chat_completions_logprobs_not_token_id_placeholders( + server: RemoteOpenAIServer, model_name: str +) -> None: + # Regression test: requesting `return_token_ids` alongside logprobs must not + # corrupt the logprob `token` fields into "token_id:{id}" placeholders. That + # placeholder rendering is controlled by `return_tokens_as_token_ids`, which + # this request leaves unset. + conversations = [ + [{"role": "user", "content": "Reply with exactly the word: alpha"}], + ] + + async with httpx.AsyncClient() as http_client: + response = await http_client.post( + f"{server.url_for('v1/chat/completions/batch')}", + json={ + "model": model_name, + "messages": conversations, + "logprobs": True, + "top_logprobs": 1, + "return_token_ids": True, + }, + timeout=60, + ) + + assert response.status_code == 200, response.text + data = response.json() + + content = data["choices"][0]["logprobs"]["content"] + assert content + for entry in content: + assert not entry["token"].startswith("token_id:") + for top in entry["top_logprobs"]: + assert not top["token"].startswith("token_id:") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_name", + [MODEL_NAME], +) +async def test_batched_chat_completions_return_tokens_as_token_ids( + server: RemoteOpenAIServer, model_name: str +) -> None: + # Complementary check: when `return_tokens_as_token_ids` is explicitly set, + # the logprob tokens *should* be rendered as "token_id:{id}" placeholders, + # proving the new field is actually wired through. + conversations = [ + [{"role": "user", "content": "Reply with exactly the word: alpha"}], + ] + + async with httpx.AsyncClient() as http_client: + response = await http_client.post( + f"{server.url_for('v1/chat/completions/batch')}", + json={ + "model": model_name, + "messages": conversations, + "logprobs": True, + "top_logprobs": 1, + "return_tokens_as_token_ids": True, + }, + timeout=60, + ) + + assert response.status_code == 200, response.text + data = response.json() + + content = data["choices"][0]["logprobs"]["content"] + assert content + assert all(entry["token"].startswith("token_id:") for entry in content) diff --git a/tests/entrypoints/openai/chat_completion/test_chat.py b/tests/entrypoints/openai/chat_completion/test_chat.py index 6703095aec4a..dbfb48f2351e 100644 --- a/tests/entrypoints/openai/chat_completion/test_chat.py +++ b/tests/entrypoints/openai/chat_completion/test_chat.py @@ -808,17 +808,26 @@ async def test_invocations(server: RemoteOpenAIServer, client: openai.AsyncOpenA "logprobs": False, } - chat_completion = await client.chat.completions.create(**request_args) + # Use raw HTTP for both endpoints so we compare server responses + # directly, without the openai SDK injecting extra fields + # (e.g. `moderation` added in newer SDK versions). + chat_response = requests.post( + server.url_for("v1/chat/completions"), json=request_args + ) + chat_response.raise_for_status() invocation_response = requests.post( server.url_for("invocations"), json=request_args ) invocation_response.raise_for_status() - chat_output = chat_completion.model_dump() + chat_output = chat_response.json() invocation_output = invocation_response.json() - assert chat_output.keys() == invocation_output.keys() + extra_keys = invocation_output.keys() - chat_output.keys() + missing_keys = chat_output.keys() - invocation_output.keys() + assert missing_keys == set() + assert extra_keys <= {"moderation"} assert chat_output["choices"] == invocation_output["choices"] diff --git a/tests/entrypoints/openai/chat_completion/test_chat_completion_with_prompt_embeds.py b/tests/entrypoints/openai/chat_completion/test_chat_completion_with_prompt_embeds.py index 1813d74798de..3ab5185fe5eb 100644 --- a/tests/entrypoints/openai/chat_completion/test_chat_completion_with_prompt_embeds.py +++ b/tests/entrypoints/openai/chat_completion/test_chat_completion_with_prompt_embeds.py @@ -14,6 +14,7 @@ from openai import BadRequestError from tests.utils import VLLM_PATH, RemoteOpenAIServer +from vllm.platforms import current_platform MODEL_NAME = "facebook/opt-125m" CHAT_TEMPLATE = VLLM_PATH / "examples/template_chatml.jinja" @@ -41,7 +42,11 @@ def server_args() -> list[str]: @pytest.fixture(scope="module") -def server(server_args): +def server(server_args, request): + if current_platform.is_rocm(): + # Materialize HF embeddings before the server reserves ROCm VRAM. + request.getfixturevalue("prompt_embeds_b64") + request.getfixturevalue("aligned_content_and_embeds_b64") with RemoteOpenAIServer(MODEL_NAME, server_args) as remote_server: yield remote_server diff --git a/tests/entrypoints/openai/chat_completion/test_chat_error.py b/tests/entrypoints/openai/chat_completion/test_chat_error.py index e099c282f423..4b6be87ae5c2 100644 --- a/tests/entrypoints/openai/chat_completion/test_chat_error.py +++ b/tests/entrypoints/openai/chat_completion/test_chat_error.py @@ -17,9 +17,10 @@ from vllm.entrypoints.openai.engine.protocol import GenerationError from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.entrypoints.openai.models.serving import OpenAIServingModels -from vllm.entrypoints.serve.render.serving import OpenAIServingRender +from vllm.entrypoints.scale_out.render.serving import ServingRender from vllm.outputs import CompletionOutput, RequestOutput from vllm.renderers.hf import HfRenderer +from vllm.renderers.online_renderer import OnlineRenderer from vllm.tokenizers.registry import cached_tokenizer_from_config from vllm.v1.engine.async_llm import AsyncLLM @@ -88,19 +89,19 @@ def _build_serving_chat(engine: AsyncLLM) -> OpenAIServingChat: engine_client=engine, base_model_paths=BASE_MODEL_PATHS, ) - serving_render = OpenAIServingRender( + online_renderer = OnlineRenderer( model_config=engine.model_config, renderer=engine.renderer, - model_registry=models.registry, request_logger=None, chat_template=None, chat_template_content_format="auto", ) + serving_chat = OpenAIServingChat( engine, models, response_role="assistant", - openai_serving_render=serving_render, + online_renderer=online_renderer, request_logger=None, chat_template=None, chat_template_content_format="auto", @@ -113,7 +114,7 @@ async def _fake_preprocess_chat(*args, **kwargs): [{"prompt_token_ids": [1, 2, 3]}], ) - serving_chat.openai_serving_render.preprocess_chat = AsyncMock( + serving_chat.online_renderer.preprocess_chat = AsyncMock( side_effect=_fake_preprocess_chat ) return serving_chat @@ -187,13 +188,39 @@ async def test_openai_chat_keeps_mm_cache_for_engine_execution(): assert isinstance(result, tuple) assert ( - serving_chat.openai_serving_render.preprocess_chat.call_args.kwargs[ - "skip_mm_cache" - ] + serving_chat.online_renderer.preprocess_chat.call_args.kwargs["skip_mm_cache"] is False ) +def _build_serving_render(engine: AsyncLLM) -> ServingRender: + models = OpenAIServingModels( + engine_client=engine, + base_model_paths=BASE_MODEL_PATHS, + ) + online_renderer = OnlineRenderer( + model_config=engine.model_config, + renderer=engine.renderer, + request_logger=None, + chat_template=None, + chat_template_content_format="auto", + ) + + serving_render = ServingRender(models, online_renderer) + + async def _fake_preprocess_chat(*args, **kwargs): + # return conversation, engine_inputs + return ( + [{"role": "user", "content": "Test"}], + [{"prompt_token_ids": [1, 2, 3]}], + ) + + serving_render.online_renderer.preprocess_chat = AsyncMock( + side_effect=_fake_preprocess_chat + ) + return serving_render + + @pytest.mark.asyncio async def test_renderer_only_chat_request_skips_mm_cache(): mock_engine = MagicMock(spec=AsyncLLM) @@ -202,20 +229,18 @@ async def test_renderer_only_chat_request_skips_mm_cache(): mock_engine.input_processor = MagicMock() mock_engine.renderer = _build_renderer(mock_engine.model_config) - serving_chat = _build_serving_chat(mock_engine) + serving_render = _build_serving_render(mock_engine) request = ChatCompletionRequest( model=MODEL_NAME, messages=[{"role": "user", "content": "Test prompt"}], ) - result = await serving_chat.openai_serving_render.render_chat_request(request) + result = await serving_render.render_chat_request(request) assert result.token_ids == [1, 2, 3] assert ( - serving_chat.openai_serving_render.preprocess_chat.call_args.kwargs[ - "skip_mm_cache" - ] + serving_render.online_renderer.preprocess_chat.call_args.kwargs["skip_mm_cache"] is True ) diff --git a/tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py b/tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py index 22e17a14dcd9..b415fa116dac 100644 --- a/tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py +++ b/tests/entrypoints/openai/chat_completion/test_chat_logit_bias_validation.py @@ -76,3 +76,60 @@ async def test_chat_logit_bias_invalid(client): assert error.status_code == 400 assert str(invalid_token_id) in error_message assert str(vocab_size) in error_message + + +@pytest.mark.asyncio +async def test_chat_logit_bias_non_integer_key(client): + """Test that a non-integer logit_bias key is rejected with a clean, + informative error instead of a raw 'invalid literal for int()' message.""" + with pytest.raises(openai.BadRequestError) as excinfo: + await client.chat.completions.create( + model=MODEL_NAME, + messages=[{"role": "user", "content": "Testing invalid logit bias key"}], + max_tokens=5, + logit_bias={"not_a_token_id": 50}, + ) + + error = excinfo.value + error_message = str(error) + + assert error.status_code == 400 + assert "not_a_token_id" in error_message + assert "logit_bias" in error_message + + +@pytest.mark.asyncio +async def test_chat_logit_bias_non_numeric_value(client): + """Test that a non-numeric logit_bias value is rejected with a message + that names the specific offending token, not just a generic TypeError.""" + with pytest.raises(openai.BadRequestError) as excinfo: + await client.chat.completions.create( + model=MODEL_NAME, + messages=[{"role": "user", "content": "Testing invalid logit bias value"}], + max_tokens=5, + logit_bias={"1": "not_a_number"}, + ) + + error = excinfo.value + error_message = str(error) + + assert error.status_code == 400 + assert "logit_bias" in error_message + + +@pytest.mark.asyncio +async def test_chat_logit_bias_multiple_non_integer_keys(client): + """Test that ALL invalid logit_bias keys are reported together, + not just the first one encountered.""" + with pytest.raises(openai.BadRequestError) as excinfo: + await client.chat.completions.create( + model=MODEL_NAME, + messages=[{"role": "user", "content": "Testing multiple bad keys"}], + max_tokens=5, + logit_bias={"bad1": 50.0, "bad2": 20.0}, + ) + + error_message = str(excinfo.value) + assert excinfo.value.status_code == 400 + assert "bad1" in error_message + assert "bad2" in error_message diff --git a/tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py b/tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py index 839793fde856..33cb576f3512 100644 --- a/tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py +++ b/tests/entrypoints/openai/chat_completion/test_completion_with_function_calling.py @@ -24,6 +24,7 @@ "description": "Get the current weather in a given location", "parameters": { "type": "object", + "strict": True, "properties": { "city": { "type": "string", @@ -215,79 +216,6 @@ async def test_function_tool_use( assert len(reasoning) > 0 -@pytest.fixture(scope="module") -def k2_server(): - args = [ - # use half precision for speed and memory savings in CI environment - "--dtype", - "half", - "--enable-auto-tool-choice", - "--structured-outputs-config.backend", - "xgrammar", - "--tool-call-parser", - "hermes", - "--reasoning-parser", - "qwen3", - "--gpu-memory-utilization", - "0.4", - ] + ROCM_EXTRA_ARGS - # Test kimi_k2 tool use tool_id format by overriding model_type. - # is_deepseek_mla safely returns False via getattr when kv_lora_rank - # is absent from the underlying config. - with RemoteOpenAIServer( - MODEL_NAME, - args, - env_dict=ROCM_ENV_OVERRIDES, - override_hf_configs={"model_type": "kimi_k2"}, - ) as remote_server: - yield remote_server - - -@pytest_asyncio.fixture -async def k2_client(k2_server): - async with k2_server.get_async_client() as async_client: - yield async_client - - -@pytest.mark.asyncio -@pytest.mark.parametrize("model_name", [MODEL_NAME]) -@pytest.mark.parametrize("stream", [True, False]) -@pytest.mark.parametrize("tool_choice", ["required"]) -async def test_tool_id_kimi_k2( - k2_client: openai.AsyncOpenAI, model_name: str, stream: bool, tool_choice: str -): - if not stream: - # Non-streaming test - chat_completion = await k2_client.chat.completions.create( - messages=messages, model=model_name, tools=tools, tool_choice=tool_choice - ) - assert chat_completion.choices[0].message.tool_calls is not None - assert len(chat_completion.choices[0].message.tool_calls) > 0 - assert chat_completion.choices[0].message.tool_calls[0].id in [ - "functions.get_current_weather:0", - "functions.get_forecast:1", - ] - else: - # Streaming test - output_stream = await k2_client.chat.completions.create( - messages=messages, - model=model_name, - tools=tools, - tool_choice=tool_choice, - stream=True, - ) - - output = [] - async for chunk in output_stream: - if chunk.choices and chunk.choices[0].delta.tool_calls: - output.extend(chunk.choices[0].delta.tool_calls) - for o in output: - assert o.id is None or o.id in [ - "functions.get_current_weather:0", - "functions.get_forecast:1", - ] - - @pytest.mark.asyncio @pytest.mark.parametrize("model_name", [MODEL_NAME]) @pytest.mark.parametrize("arguments", ["{}", ""]) @@ -442,7 +370,7 @@ async def test_named_tool_use( if delta.role: assert delta.role == "assistant" assert delta.content is None or len(delta.content) == 0 - if delta.tool_calls: + if delta.tool_calls and delta.tool_calls[0].function.arguments: output.append(delta.tool_calls[0].function.arguments) if chunk.choices[0].finish_reason is not None: finish_reason_count += 1 @@ -539,8 +467,8 @@ async def test_max_tokens_with_tool_choice_required( tool_choice=tool_choice, ) # When `tool_choice="required"` and the tokens of `tools` exceed `max_tokens`, - # both `tool_calls` and `content` should be empty. + # `tool_calls` should be absent and `content` should be empty. # This behavior should be consistent with OpenAI. choice = chat_completion.choices[0] assert choice.finish_reason == "length" - assert len(choice.message.tool_calls) == 0 + assert choice.message.tool_calls is None diff --git a/tests/entrypoints/openai/chat_completion/test_serving_chat.py b/tests/entrypoints/openai/chat_completion/test_serving_chat.py index 7c0a46a4e634..25a9451bc2b1 100644 --- a/tests/entrypoints/openai/chat_completion/test_serving_chat.py +++ b/tests/entrypoints/openai/chat_completion/test_serving_chat.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import asyncio import json +from collections.abc import AsyncIterator from contextlib import suppress from dataclasses import dataclass, field from typing import Any @@ -19,11 +20,16 @@ from tests.utils import RemoteOpenAIServer from vllm._aiter_ops import is_aiter_found_and_supported from vllm.config import MultiModalConfig +from vllm.entrypoints.generate.base.serving import build_per_request_timing_metrics from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ChatCompletionResponse, ) -from vllm.entrypoints.openai.chat_completion.serving import OpenAIServingChat +from vllm.entrypoints.openai.chat_completion.serving import ( + OpenAIServingChat, + _get_mm_token_counts, + _make_prompt_tokens_details, +) from vllm.entrypoints.openai.engine.protocol import ( ErrorResponse, RequestResponseMetadata, @@ -34,20 +40,29 @@ OpenAIServingModels, ) from vllm.entrypoints.openai.parser.harmony_utils import get_encoding -from vllm.entrypoints.serve.render.serving import OpenAIServingRender from vllm.exceptions import VLLMValidationError from vllm.inputs import TokensPrompt +from vllm.multimodal.inputs import PlaceholderRange from vllm.outputs import CompletionOutput, RequestOutput +from vllm.parser import HarmonyParser from vllm.renderers.hf import HfRenderer from vllm.renderers.mistral import MistralRenderer +from vllm.renderers.online_renderer import OnlineRenderer from vllm.tokenizers import get_tokenizer from vllm.tokenizers.mistral import MistralTokenizer from vllm.tokenizers.registry import cached_tokenizer_from_config -from vllm.tool_parsers import ToolParserManager from vllm.v1.engine.async_llm import AsyncLLM +from vllm.v1.metrics.stats import RequestStateStats GPT_OSS_MODEL_NAME = "openai/gpt-oss-20b" GPT_OSS_SPECULATOR_NAME = "RedHatAI/gpt-oss-20b-speculator.eagle3" +_PER_REQUEST_STATS = RequestStateStats( + queued_ts=1.0, + scheduled_ts=1.5, + first_token_ts=2.0, + last_token_ts=3.0, + num_generation_tokens=2, +) @pytest.fixture(scope="module") @@ -460,7 +475,7 @@ async def test_gpt_oss_tool_choice_none( ) msg = tool_choice_none.choices[0].message - assert len(msg.tool_calls) == 0 + assert msg.tool_calls is None class TestGPTOSSSpeculativeChat: @@ -562,39 +577,210 @@ def _build_renderer(model_config: MockModelConfig): ) -def _build_serving_render( +def _build_online_renderer( engine, model_registry: OpenAIModelRegistry -) -> OpenAIServingRender: - return OpenAIServingRender( +) -> OnlineRenderer: + return OnlineRenderer( model_config=engine.model_config, renderer=engine.renderer, - model_registry=model_registry, request_logger=None, chat_template=CHAT_TEMPLATE, chat_template_content_format="auto", ) -def _build_serving_chat(engine: AsyncLLM) -> OpenAIServingChat: +def _build_serving_chat( + engine: AsyncLLM, + *, + reasoning_parser: str = "", + tool_parser: str | None = None, + enable_auto_tools: bool = False, +) -> OpenAIServingChat: models = OpenAIServingModels( engine_client=engine, base_model_paths=BASE_MODEL_PATHS, ) - openai_serving_render = _build_serving_render(engine, models.registry) + online_renderer = _build_online_renderer(engine, models.registry) serving_chat = OpenAIServingChat( engine, models, response_role="assistant", - openai_serving_render=openai_serving_render, + online_renderer=online_renderer, chat_template=CHAT_TEMPLATE, chat_template_content_format="auto", request_logger=None, + reasoning_parser=reasoning_parser, + tool_parser=tool_parser, + enable_auto_tools=enable_auto_tools, ) return serving_chat +def _build_minimal_metrics_serving_chat( + enable_per_request_metrics: bool, + enable_force_include_usage: bool = False, +) -> OpenAIServingChat: + serving = OpenAIServingChat.__new__(OpenAIServingChat) + serving.response_role = "assistant" + serving.parser_cls = None + serving.enable_auto_tools = False + serving.enable_prompt_tokens_details = False + serving.enable_log_outputs = False + serving.enable_log_deltas = False + serving.enable_force_include_usage = enable_force_include_usage + serving.request_logger = None + serving.system_fingerprint = None + serving.enable_per_request_metrics = enable_per_request_metrics + return serving + + +def _make_metrics_request_output( + metrics: RequestStateStats | None = _PER_REQUEST_STATS, + token_ids: tuple[int, ...] = (100, 101), +) -> RequestOutput: + return RequestOutput( + request_id="test-id", + prompt="Test prompt", + prompt_token_ids=[1, 2, 3], + prompt_logprobs=None, + outputs=[ + CompletionOutput( + index=0, + text="Hello", + token_ids=list(token_ids), + cumulative_logprob=None, + logprobs=None, + finish_reason="stop", + ) + ], + finished=True, + metrics=metrics, + ) + + +async def _single_request_output( + request_output: RequestOutput, +) -> AsyncIterator[RequestOutput]: + yield request_output + + +async def _collect_metrics_stream_chunks( + serving: OpenAIServingChat, + request: ChatCompletionRequest, +) -> list[dict[str, Any]]: + chunks: list[dict[str, Any]] = [] + async for line in serving.chat_completion_stream_generator( + request, + _single_request_output(_make_metrics_request_output()), + "chatcmpl-test-id", + "test-model", + conversation=[{"role": "user", "content": "Test"}], + tokenizer=MagicMock(), + request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"), + ): + line = line.strip() + if not line.startswith("data: "): + continue + payload = line[len("data: ") :] + if payload != "[DONE]": + chunks.append(json.loads(payload)) + return chunks + + +def test_build_per_request_timing_metrics_valid_timestamps(): + metrics = build_per_request_timing_metrics( + _PER_REQUEST_STATS, num_generation_tokens=10 + ) + + assert metrics.time_to_first_token_ms == pytest.approx(500.0) + assert metrics.generation_time_ms == pytest.approx(1000.0) + assert metrics.queue_time_ms == pytest.approx(500.0) + assert metrics.mean_itl_ms == pytest.approx(1000.0 / 9, rel=1e-4) + assert metrics.tokens_per_second == pytest.approx(10.0 / 1.5, rel=1e-4) + + +@pytest.mark.asyncio +async def test_chat_per_request_metrics_follow_server_flag(): + request = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "Test prompt"}], + max_tokens=10, + stream=False, + ) + request_output = _make_metrics_request_output() + + disabled_serving = _build_minimal_metrics_serving_chat( + enable_per_request_metrics=False + ) + disabled_response = await disabled_serving.chat_completion_full_generator( + request, + _single_request_output(request_output), + "chatcmpl-test-id", + "test-model", + conversation=[{"role": "user", "content": "Test"}], + tokenizer=MagicMock(), + request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"), + ) + assert disabled_response.metrics is None + + enabled_serving = _build_minimal_metrics_serving_chat( + enable_per_request_metrics=True + ) + enabled_response = await enabled_serving.chat_completion_full_generator( + request, + _single_request_output(request_output), + "chatcmpl-test-id", + "test-model", + conversation=[{"role": "user", "content": "Test"}], + tokenizer=MagicMock(), + request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"), + ) + assert enabled_response.metrics is not None + assert enabled_response.metrics.time_to_first_token_ms == pytest.approx(500.0) + + +@pytest.mark.asyncio +async def test_chat_per_request_metrics_suppressed_for_n_greater_than_one(): + serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=True) + response = await serving.chat_completion_full_generator( + ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "Test prompt"}], + max_tokens=10, + stream=False, + n=2, + ), + _single_request_output(_make_metrics_request_output()), + "chatcmpl-test-id", + "test-model", + conversation=[{"role": "user", "content": "Test"}], + tokenizer=MagicMock(), + request_metadata=RequestResponseMetadata(request_id="chatcmpl-test-id"), + ) + assert response.metrics is None + + +@pytest.mark.asyncio +async def test_chat_streaming_metrics_ride_on_usage_chunk(): + serving = _build_minimal_metrics_serving_chat(enable_per_request_metrics=True) + chunks = await _collect_metrics_stream_chunks( + serving, + ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "Test prompt"}], + max_tokens=10, + stream=True, + stream_options={"include_usage": True}, + ), + ) + + usage_chunks = [chunk for chunk in chunks if chunk.get("usage")] + assert usage_chunks + assert usage_chunks[-1]["metrics"]["time_to_first_token_ms"] == pytest.approx(500.0) + + @dataclass class MockEngine: model_config: MockModelConfig = field(default_factory=MockModelConfig) @@ -607,13 +793,13 @@ async def _async_serving_chat_init(): engine = MockEngine() models = OpenAIServingModels(engine, BASE_MODEL_PATHS) - openai_serving_render = _build_serving_render(engine, models.registry) + online_renderer = _build_online_renderer(engine, models.registry) serving_completion = OpenAIServingChat( engine, models, response_role="assistant", - openai_serving_render=openai_serving_render, + online_renderer=online_renderer, chat_template=CHAT_TEMPLATE, chat_template_content_format="auto", request_logger=None, @@ -626,6 +812,37 @@ def test_async_serving_chat_init(): assert serving_completion.chat_template == CHAT_TEMPLATE +def test_mm_prompt_tokens_details(): + # Text-only input has no multimodal placeholders. + assert _get_mm_token_counts({"type": "tokens"}) == {} + + # Per-modality counts sum each modality's placeholder ranges. + counts = _get_mm_token_counts( + { + "mm_placeholders": { + "image": [ + PlaceholderRange(offset=0, length=576), + PlaceholderRange(offset=600, length=24), + ], + "video": [PlaceholderRange(offset=700, length=1200)], + } + } + ) + assert counts == {"image": 600, "video": 1200} + + # Gated off, or nothing to report -> no details. + assert _make_prompt_tokens_details(False, 5, counts) is None + assert _make_prompt_tokens_details(True, None, None) is None + + # Zero cached_tokens is still reported (not None), matching the cached-only + # behavior; multimodal counts ride alongside even when cached_tokens is None. + assert _make_prompt_tokens_details(True, 0, None).cached_tokens == 0 + details = _make_prompt_tokens_details(True, None, counts) + assert details.cached_tokens is None + assert details.multimodal_tokens == {"image": 600, "video": 1200} + assert _make_prompt_tokens_details(True, 3, counts).cached_tokens == 3 + + @pytest.mark.asyncio async def test_serving_chat_returns_correct_model_name(): mock_engine = MagicMock(spec=AsyncLLM) @@ -637,7 +854,7 @@ async def test_serving_chat_returns_correct_model_name(): serving_chat = _build_serving_chat(mock_engine) messages = [{"role": "user", "content": "what is 1+1?"}] - async def return_model_name(*args): + async def return_model_name(*args, **kwargs): return args[3] serving_chat.chat_completion_full_generator = return_model_name @@ -1210,15 +1427,21 @@ def mock_engine(self) -> AsyncLLM: mock_engine = MagicMock(spec=AsyncLLM) mock_engine.errored = False mock_engine.model_config = MockModelConfig() + mock_engine.model_config.hf_config = MockHFConfig(model_type="gpt_oss") + mock_engine.model_config.hf_text_config = MockHFConfig(model_type="gpt_oss") mock_engine.input_processor = MagicMock() mock_engine.renderer = _build_renderer(mock_engine.model_config) return mock_engine @pytest.fixture() def serving_chat(self, mock_engine) -> OpenAIServingChat: - chat = _build_serving_chat(mock_engine) - chat.use_harmony = True - chat.tool_parser = ToolParserManager.get_tool_parser("openai") + chat = _build_serving_chat( + mock_engine, + reasoning_parser="openai_gptoss", + tool_parser="openai", + enable_auto_tools=True, + ) + assert chat.parser_cls is HarmonyParser return chat def mock_request_output_from_req_and_token_ids( @@ -1277,6 +1500,7 @@ async def generate_response_from_harmony_str( stream: bool = False, ) -> ChatCompletionResponse: harmony_token_ids = get_encoding().encode(harmony_str, allowed_special="all") + tokenizer = get_tokenizer(GPT_OSS_MODEL_NAME) async def result_generator(): if stream: @@ -1298,17 +1522,33 @@ async def result_generator(): else serving_chat.chat_completion_full_generator ) + chat_template_kwargs = serving_chat._effective_chat_template_kwargs(req) + if stream: + extra_kwargs: dict[str, Any] = { + "chat_template_kwargs": chat_template_kwargs, + } + else: + parser = None + if serving_chat.parser_cls is not None: + parser = serving_chat.parser_cls( + tokenizer, + req.tools, + chat_template_kwargs=chat_template_kwargs, + ) + extra_kwargs = {"parser": parser} + result = generator_func( request=req, result_generator=result_generator(), request_id=req.request_id, model_name=req.model, conversation=[], - tokenizer=get_tokenizer(req.model), + tokenizer=tokenizer, request_metadata=RequestResponseMetadata( request_id=req.request_id, model_name=req.model, ), + **extra_kwargs, ) if stream: @@ -1316,14 +1556,19 @@ async def result_generator(): return await result @pytest.mark.asyncio - async def test_simple_chat(self, serving_chat, stream): + @pytest.mark.parametrize( + "include_reasoning", [True, False], ids=["with_reasoning", "no_reasoning"] + ) + async def test_simple_chat(self, serving_chat, stream, include_reasoning): messages = [{"role": "user", "content": "what is 1+1?"}] # Test the Harmony messages for the first turn's input - req = ChatCompletionRequest(model=MODEL_NAME, messages=messages) - input_messages, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req) + req = ChatCompletionRequest( + model=MODEL_NAME, + messages=messages, + include_reasoning=include_reasoning, ) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) verify_harmony_messages( input_messages, [ @@ -1342,7 +1587,11 @@ async def test_simple_chat(self, serving_chat, stream): response = await self.generate_response_from_harmony_str( serving_chat, req, response_str, stream=stream ) - verify_chat_response(response, content=final_str, reasoning=reasoning_str) + verify_chat_response( + response, + content=final_str, + reasoning=reasoning_str if include_reasoning else None, + ) # Add the output messages from the first turn as input to the second turn for choice in response.choices: @@ -1350,17 +1599,72 @@ async def test_simple_chat(self, serving_chat, stream): # Test the Harmony messages for the second turn's input req_2 = ChatCompletionRequest(model=MODEL_NAME, messages=messages) - input_messages_2, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req_2) + input_messages_2, _ = serving_chat.online_renderer._make_request_with_harmony( + req_2 + ) + expected_input_messages_2 = [ + {"role": "system"}, + {"role": "user"}, + ] + if include_reasoning: + expected_input_messages_2.append( + { + "role": "assistant", + "channel": "analysis", + } + ) + expected_input_messages_2.append( + {"role": "assistant", "channel": "final", "content": final_str} ) verify_harmony_messages( input_messages_2, + expected_input_messages_2, + ) + + @pytest.mark.asyncio + async def test_system_message_without_tools(self, serving_chat, stream): + """Leading system message produces a developer message with + DeveloperContent (# Instructions header).""" + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"}, + ] + req = ChatCompletionRequest(model=MODEL_NAME, messages=messages) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) + verify_harmony_messages( + input_messages, [ {"role": "system"}, - {"role": "user"}, - # The analysis message should be dropped on subsequent inputs because - # of the subsequent assistant message to the final channel. - {"role": "assistant", "channel": "final", "content": final_str}, + { + "role": "developer", + "instructions": "You are a helpful assistant.", + }, + {"role": "user", "content": "Hello"}, + ], + ) + + @pytest.mark.asyncio + async def test_system_message_with_tools(self, serving_chat, stream, weather_tools): + """Leading system message is folded into the developer message + alongside tool definitions.""" + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What's the weather?"}, + ] + req = ChatCompletionRequest( + model=MODEL_NAME, messages=messages, tools=weather_tools + ) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) + verify_harmony_messages( + input_messages, + [ + {"role": "system"}, + { + "role": "developer", + "instructions": "You are a helpful assistant.", + "tool_definitions": ["get_weather"], + }, + {"role": "user", "content": "What's the weather?"}, ], ) @@ -1373,9 +1677,7 @@ async def test_tool_call_response_with_content( # Test the Harmony messages for the first turn's input req = ChatCompletionRequest(model=MODEL_NAME, messages=messages, tools=tools) - input_messages, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req) - ) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) verify_harmony_messages( input_messages, [ @@ -1419,8 +1721,8 @@ async def test_tool_call_response_with_content( # Test the Harmony messages for the second turn's input req_2 = ChatCompletionRequest(model=MODEL_NAME, messages=messages, tools=tools) - input_messages_2, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req_2) + input_messages_2, _ = serving_chat.online_renderer._make_request_with_harmony( + req_2 ) verify_harmony_messages( input_messages_2, @@ -1458,9 +1760,7 @@ async def test_multi_turn_tools_and_reasoning( # Test the Harmony messages for the first turn's input req = ChatCompletionRequest(model=MODEL_NAME, messages=messages, tools=tools) - input_messages, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req) - ) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) verify_harmony_messages( input_messages, [ @@ -1504,8 +1804,8 @@ async def test_multi_turn_tools_and_reasoning( # Test the Harmony messages for the second turn's input req_2 = ChatCompletionRequest(model=MODEL_NAME, messages=messages, tools=tools) - input_messages_2, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req_2) + input_messages_2, _ = serving_chat.online_renderer._make_request_with_harmony( + req_2 ) verify_harmony_messages( input_messages_2, @@ -1516,7 +1816,6 @@ async def test_multi_turn_tools_and_reasoning( { "role": "assistant", "channel": "analysis", - "content": reasoning_str, }, { "role": "assistant", @@ -1556,8 +1855,8 @@ async def test_multi_turn_tools_and_reasoning( # Test the Harmony messages for the third turn's input req_3 = ChatCompletionRequest(model=MODEL_NAME, messages=messages, tools=tools) - input_messages_3, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req_3) + input_messages_3, _ = serving_chat.online_renderer._make_request_with_harmony( + req_3 ) verify_harmony_messages( input_messages_3, @@ -1565,6 +1864,11 @@ async def test_multi_turn_tools_and_reasoning( {"role": "system"}, {"role": "developer"}, {"role": "user"}, + { + "role": "assistant", + "channel": "analysis", + "content": reasoning_str, + }, { "role": "assistant", "channel": "commentary", @@ -1621,8 +1925,8 @@ async def test_multi_turn_tools_and_reasoning( # Test the Harmony messages for the fourth turn's input req_4 = ChatCompletionRequest(model=MODEL_NAME, messages=messages, tools=tools) - input_messages_4, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req_4) + input_messages_4, _ = serving_chat.online_renderer._make_request_with_harmony( + req_4 ) verify_harmony_messages( input_messages_4, @@ -1630,6 +1934,10 @@ async def test_multi_turn_tools_and_reasoning( {"role": "system"}, {"role": "developer"}, {"role": "user"}, + { + "role": "assistant", + "channel": "analysis", + }, {"role": "assistant"}, {"role": "tool"}, { @@ -1672,17 +1980,17 @@ async def test_non_tool_reasoning(self, serving_chat): }, ] req = ChatCompletionRequest(model=MODEL_NAME, messages=messages) - input_messages, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req) - ) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) verify_harmony_messages( input_messages, [ {"role": "system"}, {"role": "user", "content": messages[0]["content"]}, - # The reasoning that would have resulted in an analysis message is - # dropped because of a later assistant message to the final channel. + { + "role": "assistant", + "channel": "analysis", + }, { "role": "assistant", "channel": "final", @@ -1705,9 +2013,7 @@ async def test_non_tool_reasoning_empty_content(self, serving_chat): }, ] req = ChatCompletionRequest(model=MODEL_NAME, messages=messages) - input_messages, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req) - ) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) verify_harmony_messages( input_messages, @@ -1736,9 +2042,7 @@ async def test_non_tool_reasoning_empty_content_list(self, serving_chat): }, ] req = ChatCompletionRequest(model=MODEL_NAME, messages=messages) - input_messages, _ = ( - serving_chat.openai_serving_render._make_request_with_harmony(req) - ) + input_messages, _ = serving_chat.online_renderer._make_request_with_harmony(req) verify_harmony_messages( input_messages, @@ -1768,14 +2072,14 @@ async def test_tool_choice_validation_without_parser(): engine_client=mock_engine, base_model_paths=BASE_MODEL_PATHS, ) - openai_serving_render = _build_serving_render(mock_engine, models.registry) + online_renderer = _build_online_renderer(mock_engine, models.registry) # Create serving_chat without tool_parser (enable_auto_tools=False) serving_chat = OpenAIServingChat( mock_engine, models, response_role="assistant", - openai_serving_render=openai_serving_render, + online_renderer=online_renderer, chat_template=CHAT_TEMPLATE, chat_template_content_format="auto", request_logger=None, @@ -1820,6 +2124,13 @@ async def test_tool_choice_validation_without_parser(): assert isinstance(response_named, ErrorResponse) assert "tool_choice" in response_named.error.message assert "--tool-call-parser" in response_named.error.message + # The function name should appear in a clean, readable form - + # guards against leaking Pydantic's internal repr of the + # ChatCompletionNamedToolChoiceParam/ChatCompletionNamedFunction + # objects directly into the client-facing error message. + assert "get_weather" in response_named.error.message + assert "ChatCompletionNamedFunction" not in response_named.error.message + assert "ChatCompletionNamedToolChoiceParam" not in response_named.error.message @pytest.mark.asyncio @@ -1837,13 +2148,13 @@ async def test_streaming_n_gt1_independent_tool_parsers(): engine_client=mock_engine, base_model_paths=BASE_MODEL_PATHS, ) - openai_serving_render = _build_serving_render(mock_engine, models.registry) + online_renderer = _build_online_renderer(mock_engine, models.registry) serving_chat = OpenAIServingChat( mock_engine, models, response_role="assistant", - openai_serving_render=openai_serving_render, + online_renderer=online_renderer, chat_template=CHAT_TEMPLATE, chat_template_content_format="auto", request_logger=None, diff --git a/tests/entrypoints/openai/chat_completion/test_serving_chat_stream_harmony.py b/tests/entrypoints/openai/chat_completion/test_serving_chat_stream_harmony.py deleted file mode 100644 index 1c058adaf0af..000000000000 --- a/tests/entrypoints/openai/chat_completion/test_serving_chat_stream_harmony.py +++ /dev/null @@ -1,471 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -Unit tests for harmony streaming delta extraction. -""" - -from dataclasses import dataclass, field -from unittest.mock import patch - -import pytest - -from vllm.entrypoints.openai.chat_completion.stream_harmony import ( - TokenState, - extract_harmony_streaming_delta, -) - - -@dataclass -class MockMessage: - """Mock message object for testing.""" - - channel: str | None = None - recipient: str | None = None - - -@dataclass -class MockStreamableParser: - """Mock StreamableParser for testing without openai_harmony dependency.""" - - messages: list[MockMessage] = field(default_factory=list) - - -class TestExtractHarmonyStreamingDelta: - """Tests for extract_harmony_streaming_delta function.""" - - @pytest.mark.parametrize( - "delta_text,expected_content", - [ - ("Hello, world!", "Hello, world!"), - ("", ""), - ], - ) - def test_final_channel_returns_content_delta(self, delta_text, expected_content): - """Test that final channel returns a DeltaMessage with content.""" - parser = MockStreamableParser() - - # Updated to use TokenState list - token_states = [TokenState(channel="final", recipient=None, text=delta_text)] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=False, - ) - - assert delta_message is not None - assert delta_message.content == expected_content - assert tools_streamed is False - - @pytest.mark.parametrize( - "include_reasoning,expected_has_message", - [ - (True, True), - (False, False), - ], - ) - def test_analysis_channel_reasoning(self, include_reasoning, expected_has_message): - """Test analysis channel respects include_reasoning flag.""" - parser = MockStreamableParser() - text = "Let me think..." - token_states = [TokenState(channel="analysis", recipient=None, text=text)] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=include_reasoning, - ) - - if expected_has_message: - assert delta_message is not None - assert delta_message.reasoning == text - else: - assert delta_message is None - assert tools_streamed is False - - @pytest.mark.parametrize("channel", ["commentary", "analysis"]) - @patch("vllm.entrypoints.openai.chat_completion.stream_harmony.make_tool_call_id") - def test_new_tool_call(self, mock_make_tool_call_id, channel): - """Test new tool call creation when recipient changes.""" - mock_make_tool_call_id.return_value = "call_test123" - parser = MockStreamableParser() - - token_states = [ - TokenState(channel=channel, recipient="functions.get_weather", text="") - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=False, - ) - - assert delta_message is not None - assert len(delta_message.tool_calls) == 1 - tool_call = delta_message.tool_calls[0] - assert tool_call.id == "call_test123" - assert tool_call.type == "function" - assert tool_call.function.name == "get_weather" - assert tool_call.function.arguments == "" - assert tool_call.index == 0 - assert tools_streamed is True - - @pytest.mark.parametrize("channel", ["commentary", "analysis"]) - def test_tool_call_argument_streaming(self, channel): - """Test streaming tool call arguments (same recipient).""" - parser = MockStreamableParser() - args_text = '{"location": "Paris"}' - - token_states = [ - TokenState( - channel=channel, - recipient="functions.get_weather", - text=args_text, - ) - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient="functions.get_weather", - include_reasoning=False, - ) - - assert delta_message is not None - tool_call = delta_message.tool_calls[0] - assert tool_call.id is None - assert tool_call.function.arguments == args_text - assert tool_call.index == 0 - assert tools_streamed is True - - @pytest.mark.parametrize("channel", ["commentary", "analysis"]) - def test_tool_call_empty_arguments_returns_none(self, channel): - """Test empty delta_text with same recipient returns None.""" - parser = MockStreamableParser() - - token_states = [ - TokenState(channel=channel, recipient="functions.get_weather", text="") - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient="functions.get_weather", - include_reasoning=False, - ) - - assert delta_message is None - assert tools_streamed is False - - def test_tool_call_index_from_previous_messages(self): - """Test tool call index accounts for previous function messages.""" - messages = [ - MockMessage(channel="analysis", recipient=None), # Not counted - MockMessage(channel="commentary", recipient="functions.tool1"), # Counted - MockMessage(channel="final", recipient=None), # Not counted - ] - parser = MockStreamableParser(messages=messages) - - token_states = [ - TokenState( - channel="commentary", - recipient="functions.tool2", - text="args", - ) - ] - - delta_message, _ = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient="functions.tool2", - include_reasoning=False, - ) - - assert delta_message.tool_calls[0].index == 1 - - def test_returns_preambles_as_content(self): - """Test that commentary with no recipient (preamble) is user content.""" - parser = MockStreamableParser() - delta_text = "some text" - - token_states = [ - TokenState(channel="commentary", recipient=None, text=delta_text) - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=True, - ) - - assert delta_message.content == delta_text - assert tools_streamed is False - - @pytest.mark.parametrize("channel", ["commentary", "analysis"]) - @patch("vllm.entrypoints.openai.chat_completion.stream_harmony.make_tool_call_id") - def test_new_tool_call_dotted_function_name(self, mock_make_tool_call_id, channel): - mock_make_tool_call_id.return_value = "call_dotted123" - parser = MockStreamableParser() - - token_states = [TokenState(channel=channel, recipient="math.sum", text="")] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=False, - ) - - assert delta_message is not None - assert len(delta_message.tool_calls) == 1 - tool_call = delta_message.tool_calls[0] - assert tool_call.id == "call_dotted123" - assert tool_call.type == "function" - assert tool_call.function.name == "math.sum" - assert tool_call.function.arguments == "" - assert tool_call.index == 0 - assert tools_streamed is True - - @pytest.mark.parametrize( - "channel,recipient", - [ - (None, None), - ("unknown_channel", None), - ("commentary", "browser.search"), - ("commentary", "assistant"), - ], - ) - def test_returns_none_for_invalid_inputs(self, channel, recipient): - """Test that invalid channel/recipient combinations return None.""" - parser = MockStreamableParser() - - token_states = [ - TokenState(channel=channel, recipient=recipient, text="some text") - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=True, - ) - - assert delta_message is None - assert tools_streamed is False - - def test_consecutive_token_grouping(self): - """ - Test that consecutive tokens with the same channel/recipient - are merged into a single processing group. - """ - parser = MockStreamableParser() - token_states = [ - TokenState("final", None, "H"), - TokenState("final", None, "el"), - TokenState("final", None, "lo"), - TokenState("final", None, ","), - TokenState("final", None, " World"), - ] - - delta_message, _ = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=False, - ) - - assert delta_message is not None - assert delta_message.content == "Hello, World" - - @patch("vllm.entrypoints.openai.chat_completion.stream_harmony.make_tool_call_id") - def test_complex_batch_permutation(self, mock_make_id): - """ - Test a complex permutation: Reasoning -> Tool Call -> Content. - This verifies that multiple distinct actions in one batch - are all captured in the single DeltaMessage. - """ - mock_make_id.return_value = "call_batch_test" - parser = MockStreamableParser() - - token_states = [ - # 1. Reasoning - TokenState("analysis", None, "Reasoning about query..."), - # 2. Tool Calling - TokenState("commentary", "functions.search", '{"query":'), - TokenState("commentary", "functions.search", ' "vllm"}'), - # 3. Final Content - TokenState("final", None, "."), - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=True, - ) - - assert delta_message is not None - - assert delta_message.reasoning == "Reasoning about query..." - - # We expect 2 objects for 1 logical tool call: - # 1. The definition (id, name, type) - # 2. The arguments payload - assert len(delta_message.tool_calls) == 2 - - header = delta_message.tool_calls[0] - payload = delta_message.tool_calls[1] - - assert header.function.name == "search" - assert header.id == "call_batch_test" - assert header.index == 0 - - assert payload.index == 0 - assert payload.function.arguments == '{"query": "vllm"}' - - assert delta_message.content == "." - assert tools_streamed is True - - @patch("vllm.entrypoints.openai.chat_completion.stream_harmony.make_tool_call_id") - def test_tool_call_index_consistency_with_ongoing_call(self, mock_make_id): - """ - Test that an ongoing tool call continuation and subsequent new calls - maintain correct indexing when interleaved with content. - """ - mock_make_id.side_effect = ["id_b", "id_c"] - - messages = [ - MockMessage(channel="commentary", recipient="functions.previous_tool") - ] - parser = MockStreamableParser(messages=messages) - - token_states = [ - TokenState("commentary", "functions.tool_a", '{"key_a": "val_a"}'), - TokenState("final", None, "Thinking..."), - TokenState("commentary", "functions.tool_b", '{"key_b": "val_b"}'), - TokenState("final", None, " Thinking again..."), - TokenState("commentary", "functions.tool_c", '{"key_c": "val_c"}'), - ] - - delta_message, _ = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient="functions.tool_a", - include_reasoning=False, - ) - - assert delta_message is not None - - tool_a_deltas = [t for t in delta_message.tool_calls if t.index == 1] - assert len(tool_a_deltas) > 0 - assert tool_a_deltas[0].id is None - assert tool_a_deltas[0].function.arguments == '{"key_a": "val_a"}' - - tool_b_header = next(t for t in delta_message.tool_calls if t.id == "id_b") - assert tool_b_header.index == 2 - tool_b_args = next( - t for t in delta_message.tool_calls if t.index == 2 and t.id is None - ) - assert tool_b_args.function.arguments == '{"key_b": "val_b"}' - - tool_c_start = next(t for t in delta_message.tool_calls if t.id == "id_c") - assert tool_c_start.index == 3 - tool_c_args = next( - t for t in delta_message.tool_calls if t.index == 3 and t.id is None - ) - assert tool_c_args.function.arguments == '{"key_c": "val_c"}' - - assert delta_message.content == "Thinking... Thinking again..." - - -class TestToolCallsOnNonStandardChannels: - """Tool calls are detected by recipient, not channel. - - Models sometimes emit tool calls on unexpected channels (e.g. ``comment`` - instead of ``commentary``). These tests verify that the streaming delta - extraction is channel-agnostic for tool call detection. - """ - - @patch("vllm.entrypoints.openai.chat_completion.stream_harmony.make_tool_call_id") - def test_prefixed_tool_call_on_comment_channel(self, mock_make_tool_call_id): - mock_make_tool_call_id.return_value = "call_comment_chan" - parser = MockStreamableParser() - - token_states = [ - TokenState(channel="comment", recipient="functions.get_weather", text="") - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=False, - ) - - assert delta_message is not None - assert len(delta_message.tool_calls) == 1 - assert delta_message.tool_calls[0].function.name == "get_weather" - assert tools_streamed is True - - @patch("vllm.entrypoints.openai.chat_completion.stream_harmony.make_tool_call_id") - def test_bare_tool_call_on_comment_channel(self, mock_make_tool_call_id): - mock_make_tool_call_id.return_value = "call_bare_comment" - parser = MockStreamableParser() - - token_states = [TokenState(channel="comment", recipient="get_weather", text="")] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient=None, - include_reasoning=False, - ) - - assert delta_message is not None - assert len(delta_message.tool_calls) == 1 - assert delta_message.tool_calls[0].function.name == "get_weather" - assert tools_streamed is True - - def test_tool_call_arguments_on_comment_channel(self): - parser = MockStreamableParser() - args_text = '{"location": "Paris"}' - - token_states = [ - TokenState( - channel="comment", recipient="functions.get_weather", text=args_text - ) - ] - - delta_message, tools_streamed = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient="functions.get_weather", - include_reasoning=False, - ) - - assert delta_message is not None - assert delta_message.tool_calls[0].function.arguments == args_text - assert tools_streamed is True - - def test_base_index_counts_tool_calls_on_comment_channel(self): - messages = [ - MockMessage(channel="comment", recipient="functions.tool1"), - ] - parser = MockStreamableParser(messages=messages) - - token_states = [ - TokenState(channel="commentary", recipient="functions.tool2", text="args") - ] - - delta_message, _ = extract_harmony_streaming_delta( - harmony_parser=parser, - token_states=token_states, - prev_recipient="functions.tool2", - include_reasoning=False, - ) - - assert delta_message.tool_calls[0].index == 1 diff --git a/tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py b/tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py index 3e9e4850f071..4c5746738173 100644 --- a/tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py +++ b/tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py @@ -183,27 +183,39 @@ async def test_thinking_token_budget_mixed_requests(client: openai.AsyncOpenAI): async def test_thinking_token_budget_limits_reasoning(client: openai.AsyncOpenAI): """Test that thinking_token_budget limits the number of reasoning tokens. - Counts non-empty streaming ``delta.reasoning`` chunks (coarse proxy; each - chunk may represent multiple decode tokens — see - ``_count_reasoning_decode_token_ids_between_markers`` and the Qwen3.5 MTP - test for id-based checks). + Counts reasoning decode tokens by id, which is robust to how tokens are + grouped into streamed chunks (a single chunk can carry several tokens under + async scheduling / stream_interval > 1). Counting chunks under-counts. """ - reasoning_token_count = 0 + tokenizer = get_tokenizer(tokenizer_name=MODEL_NAME) + start_ids = list(tokenizer.encode(REASONING_START_STR, add_special_tokens=False)) + end_ids = list(tokenizer.encode(REASONING_END_STR, add_special_tokens=False)) + + prompt_token_ids: list[int] = [] + decode_token_ids: list[int] = [] stream = await client.chat.completions.create( model=MODEL_NAME, messages=MESSAGES, max_tokens=100, stream=True, - extra_body={"thinking_token_budget": THINK_BUDGET}, + extra_body={"thinking_token_budget": THINK_BUDGET, "return_token_ids": True}, ) async for chunk in stream: - delta = chunk.choices[0].delta - if getattr(delta, "reasoning", None): - reasoning_token_count += 1 - + if not chunk.choices: + continue + if getattr(chunk, "prompt_token_ids", None): + prompt_token_ids = list(chunk.prompt_token_ids) + delta_ids = getattr(chunk.choices[0], "token_ids", None) + if delta_ids: + decode_token_ids.extend(delta_ids) + + reasoning_token_count = _count_reasoning_decode_token_ids_between_markers( + prompt_token_ids + decode_token_ids, start_ids, end_ids + ) + assert reasoning_token_count is not None, "missing reasoning start marker in ids" assert reasoning_token_count == THINK_BUDGET, ( - f"reasoning tokens ({reasoning_token_count}) exceeded " + f"reasoning tokens ({reasoning_token_count}) != " f"thinking_token_budget ({THINK_BUDGET})" ) diff --git a/tests/entrypoints/openai/completion/test_completion.py b/tests/entrypoints/openai/completion/test_completion.py index 8ca0d1604b14..7b628207ac14 100644 --- a/tests/entrypoints/openai/completion/test_completion.py +++ b/tests/entrypoints/openai/completion/test_completion.py @@ -9,6 +9,8 @@ from openai import BadRequestError from tests.utils import RemoteOpenAIServer +from vllm.entrypoints.openai.completion.protocol import CompletionRequest +from vllm.sampling_params import SamplingParams from vllm.tokenizers import get_tokenizer # any model with a chat template should work here @@ -58,9 +60,12 @@ async def test_single_completion(client: openai.AsyncOpenAI, model_name: str) -> choice = completion.choices[0] assert len(choice.text) >= 5 assert choice.finish_reason == "length" - assert completion.usage == openai.types.CompletionUsage( - completion_tokens=5, prompt_tokens=6, total_tokens=11 - ) + assert completion.usage is not None + assert completion.usage.completion_tokens == 5 + assert completion.usage.prompt_tokens == 6 + assert completion.usage.total_tokens == 11 + assert completion.usage.prompt_tokens_details is not None + assert completion.usage.prompt_tokens_details.cached_tokens == 0 # test using token IDs completion = await client.completions.create( @@ -727,3 +732,38 @@ async def test_invalid_grammar(client: openai.AsyncOpenAI, model_name: str): "structured_outputs": {"grammar": invalid_simplified_sql_grammar} }, ) + + +# Unit tests for bad_words in CompletionRequest.to_sampling_params() +def test_completion_request_bad_words_to_sampling_params(): + """bad_words should be forwarded to SamplingParams (parity with chat).""" + request = CompletionRequest( + model="test-model", + prompt="Hello", + bad_words=["foo", "bar"], + max_tokens=10, + ) + + sampling_params = request.to_sampling_params( + max_tokens=10, + default_sampling_params={}, + ) + + assert isinstance(sampling_params, SamplingParams) + assert sampling_params.bad_words == ["foo", "bar"] + + +def test_completion_request_bad_words_default_empty(): + """bad_words defaults to an empty list, matching the chat endpoint.""" + request = CompletionRequest( + model="test-model", + prompt="Hello", + max_tokens=10, + ) + + assert request.bad_words == [] + sampling_params = request.to_sampling_params( + max_tokens=10, + default_sampling_params={}, + ) + assert sampling_params.bad_words == [] diff --git a/tests/entrypoints/openai/completion/test_completion_error.py b/tests/entrypoints/openai/completion/test_completion_error.py index 71a70a4d0eb4..aa9e9c1d72e0 100644 --- a/tests/entrypoints/openai/completion/test_completion_error.py +++ b/tests/entrypoints/openai/completion/test_completion_error.py @@ -11,17 +11,29 @@ from vllm.config.multimodal import MultiModalConfig from vllm.entrypoints.openai.completion.protocol import CompletionRequest from vllm.entrypoints.openai.completion.serving import OpenAIServingCompletion -from vllm.entrypoints.openai.engine.protocol import GenerationError +from vllm.entrypoints.openai.engine.protocol import ( + GenerationError, + RequestResponseMetadata, +) from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.entrypoints.openai.models.serving import OpenAIServingModels -from vllm.entrypoints.serve.render.serving import OpenAIServingRender +from vllm.entrypoints.scale_out.render.serving import ServingRender from vllm.outputs import CompletionOutput, RequestOutput from vllm.renderers.hf import HfRenderer +from vllm.renderers.online_renderer import OnlineRenderer from vllm.tokenizers.registry import cached_tokenizer_from_config from vllm.v1.engine.async_llm import AsyncLLM +from vllm.v1.metrics.stats import RequestStateStats MODEL_NAME = "openai-community/gpt2" MODEL_NAME_SHORT = "gpt2" +_PER_REQUEST_STATS = RequestStateStats( + queued_ts=1.0, + scheduled_ts=1.5, + first_token_ts=2.0, + last_token_ts=3.0, + num_generation_tokens=2, +) BASE_MODEL_PATHS = [ BaseModelPath(name=MODEL_NAME, model_path=MODEL_NAME), BaseModelPath(name=MODEL_NAME_SHORT, model_path=MODEL_NAME_SHORT), @@ -77,10 +89,9 @@ def _build_serving_completion(engine: AsyncLLM) -> OpenAIServingCompletion: engine_client=engine, base_model_paths=BASE_MODEL_PATHS, ) - serving_render = OpenAIServingRender( + online_renderer = OnlineRenderer( model_config=engine.model_config, renderer=engine.renderer, - model_registry=models.registry, request_logger=None, chat_template=None, chat_template_content_format="auto", @@ -88,11 +99,44 @@ def _build_serving_completion(engine: AsyncLLM) -> OpenAIServingCompletion: return OpenAIServingCompletion( engine, models, - openai_serving_render=serving_render, + online_renderer=online_renderer, request_logger=None, ) +def _build_minimal_metrics_serving_completion( + enable_per_request_metrics: bool, +) -> OpenAIServingCompletion: + serving = OpenAIServingCompletion.__new__(OpenAIServingCompletion) + serving.enable_prompt_tokens_details = False + serving.system_fingerprint = None + serving.enable_per_request_metrics = enable_per_request_metrics + return serving + + +def _make_metrics_request_output( + metrics: RequestStateStats | None = _PER_REQUEST_STATS, +) -> RequestOutput: + return RequestOutput( + request_id="test-id", + prompt="Test prompt", + prompt_token_ids=[1, 2, 3], + prompt_logprobs=None, + outputs=[ + CompletionOutput( + index=0, + text="Hello", + token_ids=[100, 101], + cumulative_logprob=None, + logprobs=None, + finish_reason="stop", + ) + ], + finished=True, + metrics=metrics, + ) + + def _build_renderer(model_config: MockModelConfig): return HfRenderer( MockVllmConfig(model_config, parallel_config=MockParallelConfig()), @@ -100,6 +144,58 @@ def _build_renderer(model_config: MockModelConfig): ) +def test_completion_per_request_metrics_follow_server_flag(): + request = CompletionRequest(model=MODEL_NAME, prompt="Test prompt", max_tokens=10) + request_output = _make_metrics_request_output() + + disabled_serving = _build_minimal_metrics_serving_completion( + enable_per_request_metrics=False + ) + disabled_response = disabled_serving.request_output_to_completion_response( + [request_output], + request, + "cmpl-test-id", + 0, + MODEL_NAME, + None, + RequestResponseMetadata(request_id="cmpl-test-id"), + ) + assert disabled_response.metrics is None + + enabled_serving = _build_minimal_metrics_serving_completion( + enable_per_request_metrics=True + ) + enabled_response = enabled_serving.request_output_to_completion_response( + [request_output], + request, + "cmpl-test-id", + 0, + MODEL_NAME, + None, + RequestResponseMetadata(request_id="cmpl-test-id"), + ) + assert enabled_response.metrics is not None + assert enabled_response.metrics.time_to_first_token_ms == pytest.approx(500.0) + + +def test_completion_per_request_metrics_suppressed_for_multiple_prompts(): + serving = _build_minimal_metrics_serving_completion(enable_per_request_metrics=True) + response = serving.request_output_to_completion_response( + [_make_metrics_request_output(), _make_metrics_request_output()], + CompletionRequest( + model=MODEL_NAME, + prompt=["Test prompt", "Another prompt"], + max_tokens=10, + ), + "cmpl-test-id", + 0, + MODEL_NAME, + None, + RequestResponseMetadata(request_id="cmpl-test-id"), + ) + assert response.metrics is None + + @pytest.mark.asyncio async def test_completion_error_non_stream(): """test finish_reason='error' returns 500 InternalServerError (non-streaming)""" @@ -158,7 +254,7 @@ async def test_openai_completion_keeps_mm_cache_for_engine_execution(): mock_engine.renderer = _build_renderer(mock_engine.model_config) serving_completion = _build_serving_completion(mock_engine) - serving_completion.openai_serving_render.preprocess_completion = AsyncMock( + serving_completion.online_renderer.preprocess_completion = AsyncMock( return_value=[{"prompt_token_ids": [1, 2, 3]}] ) @@ -171,13 +267,41 @@ async def test_openai_completion_keeps_mm_cache_for_engine_execution(): assert isinstance(result, list) assert ( - serving_completion.openai_serving_render.preprocess_completion.call_args.kwargs[ + serving_completion.online_renderer.preprocess_completion.call_args.kwargs[ "skip_mm_cache" ] is False ) +def _build_serving_render(engine: AsyncLLM) -> ServingRender: + models = OpenAIServingModels( + engine_client=engine, + base_model_paths=BASE_MODEL_PATHS, + ) + online_renderer = OnlineRenderer( + model_config=engine.model_config, + renderer=engine.renderer, + request_logger=None, + chat_template=None, + chat_template_content_format="auto", + ) + + serving_render = ServingRender(models, online_renderer) + + async def _fake_preprocess_chat(*args, **kwargs): + # return conversation, engine_inputs + return ( + [{"role": "user", "content": "Test"}], + [{"prompt_token_ids": [1, 2, 3]}], + ) + + serving_render.online_renderer.preprocess_chat = AsyncMock( + side_effect=_fake_preprocess_chat + ) + return serving_render + + @pytest.mark.asyncio async def test_renderer_only_completion_request_skips_mm_cache(): mock_engine = MagicMock(spec=AsyncLLM) @@ -186,8 +310,9 @@ async def test_renderer_only_completion_request_skips_mm_cache(): mock_engine.input_processor = MagicMock() mock_engine.renderer = _build_renderer(mock_engine.model_config) - serving_completion = _build_serving_completion(mock_engine) - serving_completion.openai_serving_render.preprocess_completion = AsyncMock( + serving_render = _build_serving_render(mock_engine) + + serving_render.online_renderer.preprocess_completion = AsyncMock( return_value=[{"prompt_token_ids": [1, 2, 3]}] ) @@ -196,13 +321,11 @@ async def test_renderer_only_completion_request_skips_mm_cache(): prompt="Test prompt", ) - result = await serving_completion.openai_serving_render.render_completion_request( - request - ) + result = await serving_render.render_completion_request(request) assert isinstance(result, list) assert ( - serving_completion.openai_serving_render.preprocess_completion.call_args.kwargs[ + serving_render.online_renderer.preprocess_completion.call_args.kwargs[ "skip_mm_cache" ] is True @@ -351,3 +474,139 @@ def test_negative_prompt_token_ids_flat(): prompt=[-1], max_tokens=10, ) + + +class TestCompletionPromptListLimit: + """Regression tests for CVE: unbounded prompt list fan-out.""" + + def test_scalar_prompt_allowed(self): + request = CompletionRequest( + model=MODEL_NAME, + prompt="hello", + max_tokens=1, + ) + assert request.prompt == "hello" + + def test_single_token_list_allowed(self): + request = CompletionRequest( + model=MODEL_NAME, + prompt=[1, 2, 3], + max_tokens=1, + ) + assert request.prompt == [1, 2, 3] + + def test_bounded_text_prompt_list_allowed(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "10") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + request = CompletionRequest( + model=MODEL_NAME, + prompt=["a", "b", "c"], + max_tokens=1, + ) + assert request.prompt == ["a", "b", "c"] + + def test_bounded_token_id_prompt_list_allowed(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "10") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + request = CompletionRequest( + model=MODEL_NAME, + prompt=[[1], [2], [3]], + max_tokens=1, + ) + assert request.prompt == [[1], [2], [3]] + + def test_oversized_text_prompt_list_rejected(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + with pytest.raises( + Exception, match="prompt list length 10 exceeds the maximum" + ): + CompletionRequest( + model=MODEL_NAME, + prompt=["x"] * 10, + max_tokens=1, + ) + + def test_oversized_token_id_prompt_list_rejected(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + with pytest.raises( + Exception, match="prompt list length 10 exceeds the maximum" + ): + CompletionRequest( + model=MODEL_NAME, + prompt=[[1]] * 10, + max_tokens=1, + ) + + def test_exact_limit_allowed(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + request = CompletionRequest( + model=MODEL_NAME, + prompt=["x"] * 5, + max_tokens=1, + ) + assert len(request.prompt) == 5 + + def test_one_over_limit_rejected(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + with pytest.raises(Exception, match="prompt list length 6 exceeds the maximum"): + CompletionRequest( + model=MODEL_NAME, + prompt=["x"] * 6, + max_tokens=1, + ) + + def test_oversized_prompt_embeds_list_rejected(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + with pytest.raises(Exception, match="prompt_embeds list length 10 exceeds"): + CompletionRequest( + model=MODEL_NAME, + prompt_embeds=[b"\x00"] * 10, + max_tokens=1, + ) + + def test_bounded_prompt_embeds_list_allowed(self, monkeypatch): + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5") + from vllm import envs + + if hasattr(envs.__getattr__, "cache_clear"): + envs.__getattr__.cache_clear() + + request = CompletionRequest( + model=MODEL_NAME, + prompt_embeds=[b"\x00"] * 5, + max_tokens=1, + ) + assert len(request.prompt_embeds) == 5 diff --git a/tests/entrypoints/openai/completion/test_lora_resolvers.py b/tests/entrypoints/openai/completion/test_lora_resolvers.py index 6a0bec92516d..30c2ce322f53 100644 --- a/tests/entrypoints/openai/completion/test_lora_resolvers.py +++ b/tests/entrypoints/openai/completion/test_lora_resolvers.py @@ -14,10 +14,10 @@ from vllm.entrypoints.openai.engine.protocol import ErrorResponse from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.entrypoints.openai.models.serving import OpenAIServingModels -from vllm.entrypoints.serve.render.serving import OpenAIServingRender from vllm.lora.request import LoRARequest from vllm.lora.resolver import LoRAResolver, LoRAResolverRegistry from vllm.renderers.hf import HfRenderer +from vllm.renderers.online_renderer import OnlineRenderer from vllm.tokenizers.registry import cached_tokenizer_from_config from vllm.v1.engine.async_llm import AsyncLLM @@ -144,16 +144,15 @@ async def mock_generate(*args, **kwargs): base_model_paths=BASE_MODEL_PATHS, ) - serving_render = OpenAIServingRender( + online_renderer = OnlineRenderer( model_config=mock_engine.model_config, renderer=mock_engine.renderer, - model_registry=models.registry, request_logger=None, chat_template=None, chat_template_content_format="auto", ) serving_completion = OpenAIServingCompletion( - mock_engine, models, openai_serving_render=serving_render, request_logger=None + mock_engine, models, online_renderer=online_renderer, request_logger=None ) return mock_engine, serving_completion diff --git a/tests/entrypoints/openai/completion/test_prompt_validation.py b/tests/entrypoints/openai/completion/test_prompt_validation.py index 81204b27bc0b..87c6b6e1668b 100644 --- a/tests/entrypoints/openai/completion/test_prompt_validation.py +++ b/tests/entrypoints/openai/completion/test_prompt_validation.py @@ -18,7 +18,7 @@ @pytest.mark.asyncio async def test_empty_prompt(): - model_name = "gpt2" + model_name = "openai-community/gpt2" server_args = ["--enforce-eager"] with RemoteOpenAIServer(model_name, server_args) as remote_server: client = remote_server.get_async_client() @@ -38,7 +38,7 @@ async def test_empty_prompt(): @pytest.mark.asyncio async def test_out_of_vocab_token_ids(): - model_name = "gpt2" + model_name = "openai-community/gpt2" server_args = ["--enforce-eager"] with RemoteOpenAIServer(model_name, server_args) as remote_server: client = remote_server.get_async_client() diff --git a/tests/entrypoints/openai/correctness/test_lmeval.py b/tests/entrypoints/openai/correctness/test_lmeval.py index 5b23b4239027..aad1b5e0624e 100644 --- a/tests/entrypoints/openai/correctness/test_lmeval.py +++ b/tests/entrypoints/openai/correctness/test_lmeval.py @@ -71,8 +71,9 @@ def test_lm_eval_accuracy_v1_engine(): more_args = [] - # Limit compilation time for V1 - if current_platform.is_tpu(): + # Limit compilation time for V1 on TPU + # Avoid OOM on XPU + if current_platform.is_tpu() or current_platform.is_xpu(): more_args = ["--max-num-seqs", "64"] run_test(more_args) diff --git a/tests/entrypoints/openai/parser/test_harmony_render_parity.py b/tests/entrypoints/openai/parser/test_harmony_render_parity.py index b5ba3344990c..5cb446122361 100644 --- a/tests/entrypoints/openai/parser/test_harmony_render_parity.py +++ b/tests/entrypoints/openai/parser/test_harmony_render_parity.py @@ -23,11 +23,15 @@ from tests.entrypoints.openai.utils import verify_harmony_messages from vllm.entrypoints.openai.parser.harmony_utils import ( + get_encoding, get_system_message, parse_chat_input_to_harmony_message, render_for_completion, ) -from vllm.entrypoints.openai.responses.harmony import response_input_to_harmony +from vllm.entrypoints.openai.responses.harmony import ( + response_input_to_harmony, + response_previous_input_to_harmony, +) # Use a fixed date so the system message is deterministic across both paths. _DATE = "2025-01-01" @@ -45,6 +49,31 @@ class TestResponseInputToHarmonyRenderParity: # Single-message cases # ----------------------------------------------------------------------- + def test_developer_message(self): + """Both APIs must render developer messages identically using + DeveloperContent (with the '# Instructions' header).""" + chat_msgs = parse_chat_input_to_harmony_message( + {"role": "developer", "content": "Be concise."} + ) + resp_msgs = [ + response_input_to_harmony( + { + "type": "message", + "role": "developer", + "content": "Be concise.", + }, + prev_responses=[], + ) + ] + + expected = [{"role": "developer", "instructions": "Be concise."}] + verify_harmony_messages(chat_msgs, expected) + verify_harmony_messages(resp_msgs, expected) + + assert render_for_completion([_system()] + chat_msgs) == render_for_completion( + [_system()] + resp_msgs + ) + def test_user_message(self): chat_msgs = parse_chat_input_to_harmony_message( {"role": "user", "content": "What's the weather in Paris?"} @@ -370,6 +399,9 @@ def test_multi_turn_two_tool_calls_with_reasoning_between(self): reasoning trace. Reasoning traces in between commentary-channel tool calls must survive as analysis-channel messages in both paths. """ + first_reasoning = "I need current weather first." + second_reasoning = "Now I need the weekly forecast." + prev_call_1 = ResponseFunctionToolCall( id="fc_1", call_id="call_1", @@ -395,7 +427,7 @@ def test_multi_turn_two_tool_calls_with_reasoning_between(self): chat_msgs += parse_chat_input_to_harmony_message( { "role": "assistant", - "reasoning": "I need current weather first.", + "reasoning": first_reasoning, "tool_calls": [ { "id": "call_1", @@ -415,7 +447,7 @@ def test_multi_turn_two_tool_calls_with_reasoning_between(self): chat_msgs += parse_chat_input_to_harmony_message( { "role": "assistant", - "reasoning": "Now I need the weekly forecast.", + "reasoning": second_reasoning, "tool_calls": [ { "id": "call_2", @@ -447,9 +479,7 @@ def test_multi_turn_two_tool_calls_with_reasoning_between(self): # First reasoning + tool call { "type": "reasoning", - "content": [ - {"type": "reasoning_text", "text": "I need current weather first."} - ], + "content": [{"type": "reasoning_text", "text": first_reasoning}], }, { "type": "function_call", @@ -467,7 +497,7 @@ def test_multi_turn_two_tool_calls_with_reasoning_between(self): "content": [ { "type": "reasoning_text", - "text": "Now I need the weekly forecast.", + "text": second_reasoning, } ], }, @@ -487,6 +517,95 @@ def test_multi_turn_two_tool_calls_with_reasoning_between(self): for item in resp_input ] - assert render_for_completion([_system()] + chat_msgs) == render_for_completion( - [_system()] + resp_msgs + chat_completion_tokens = render_for_completion([_system()] + chat_msgs) + responses_tokens = render_for_completion([_system()] + resp_msgs) + + assert chat_completion_tokens == responses_tokens + + rendered_prompt = get_encoding().decode(chat_completion_tokens) + assert first_reasoning in rendered_prompt + assert second_reasoning in rendered_prompt + + def test_completed_turns_drop_reasoning(self): + """Validates that reasoning from completed turns is dropped, while + reasoning from the current in-progress tool-call turn is preserved + in both chat completions and responses previous_input_messages.""" + first_turn_reasoning = "FIRST_TURN_REASONING" + second_turn_reasoning = "SECOND_TURN_REASONING" + + chat_completion_msgs = [] + for chat_message in [ + {"role": "user", "content": "What is 2+2?"}, + { + "role": "assistant", + "reasoning": first_turn_reasoning, + "content": "The answer is 4.", + }, + {"role": "user", "content": "Now what is 3+3?"}, + { + "role": "assistant", + "reasoning": second_turn_reasoning, + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "calc", + "arguments": '{"a":3,"b":3}', + }, + } + ], + }, + ]: + chat_completion_msgs.extend( + parse_chat_input_to_harmony_message(chat_message) + ) + + responses_prev_input_msgs = [] + for responses_message in [ + { + "author": {"role": "user"}, + "content": [{"type": "text", "text": "What is 2+2?"}], + }, + { + "author": {"role": "assistant"}, + "channel": "analysis", + "content": [{"type": "text", "text": first_turn_reasoning}], + }, + { + "author": {"role": "assistant"}, + "channel": "final", + "content": [{"type": "text", "text": "The answer is 4."}], + }, + { + "author": {"role": "user"}, + "content": [{"type": "text", "text": "Now what is 3+3?"}], + }, + { + "author": {"role": "assistant"}, + "channel": "analysis", + "content": [{"type": "text", "text": second_turn_reasoning}], + }, + { + "author": {"role": "assistant"}, + "channel": "commentary", + "recipient": "functions.calc", + "content_type": "json", + "content": [{"type": "text", "text": '{"a":3,"b":3}'}], + }, + ]: + responses_prev_input_msgs.extend( + response_previous_input_to_harmony(responses_message) + ) + + chat_completion_tokens = render_for_completion( + [_system()] + chat_completion_msgs + ) + responses_tokens = render_for_completion( + [_system()] + responses_prev_input_msgs ) + + assert chat_completion_tokens == responses_tokens + + rendered_prompt = get_encoding().decode(responses_tokens) + assert first_turn_reasoning not in rendered_prompt + assert second_turn_reasoning in rendered_prompt diff --git a/tests/entrypoints/openai/parser/test_harmony_utils.py b/tests/entrypoints/openai/parser/test_harmony_utils.py index 2ec200d58377..0027c2763fa7 100644 --- a/tests/entrypoints/openai/parser/test_harmony_utils.py +++ b/tests/entrypoints/openai/parser/test_harmony_utils.py @@ -2,24 +2,77 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import pytest -from openai_harmony import Message, Role +from openai.types.responses import FunctionTool +from openai_harmony import DeveloperContent, Message, Role from tests.entrypoints.openai.utils import verify_harmony_messages +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionToolsParam from vllm.entrypoints.openai.parser.harmony_utils import ( auto_drop_analysis_messages, + create_tool_definition, extract_function_from_recipient, - get_encoding, get_system_message, has_custom_tools, is_function_recipient, parse_chat_input_to_harmony_message, - parse_chat_output, ) from vllm.entrypoints.openai.responses.harmony import ( response_input_to_harmony, response_previous_input_to_harmony, ) +_TOOL_PARAMETERS = { + "type": "object", + "properties": {"status": {"type": "string"}}, + "required": ["status"], + "additionalProperties": False, +} + + +class TestCreateToolDefinition: + def test_chat_completion_omitted_description_defaults_to_empty_string(self): + tool = ChatCompletionToolsParam( + function={ + "name": "report_status", + "parameters": _TOOL_PARAMETERS, + } + ) + + tool_definition = create_tool_definition(tool) + + assert tool_definition.name == "report_status" + assert tool_definition.description == "" + assert tool_definition.parameters == _TOOL_PARAMETERS + + def test_chat_completion_none_description_defaults_to_empty_string(self): + tool = ChatCompletionToolsParam( + function={ + "name": "report_status", + "description": None, + "parameters": _TOOL_PARAMETERS, + } + ) + + tool_definition = create_tool_definition(tool) + + assert tool_definition.name == "report_status" + assert tool_definition.description == "" + assert tool_definition.parameters == _TOOL_PARAMETERS + + def test_response_tool_none_description_defaults_to_empty_string(self): + tool = FunctionTool( + name="report_status", + description=None, + parameters=_TOOL_PARAMETERS, + type="function", + ) + + tool_definition = create_tool_definition(tool) + + assert tool_definition.name == "report_status" + assert tool_definition.description == "" + assert tool_definition.parameters == _TOOL_PARAMETERS + class TestIsFunctionRecipient: @pytest.mark.parametrize( @@ -269,7 +322,8 @@ def test_assistant_message_with_empty_tool_call_arguments(self, parse_function): assert messages[0].recipient == "functions.get_current_time" def test_system_message(self, parse_function): - """Test parsing system message.""" + """Test parsing system messages, which are parsed into developer messages + with DeveloperContent.""" chat_msg = { "role": "system", "content": "You are a helpful assistant", @@ -278,9 +332,9 @@ def test_system_message(self, parse_function): messages = parse_function(chat_msg) assert len(messages) == 1 - # System messages are converted using Message.from_dict - # which should preserve the role - assert messages[0].author.role == Role.SYSTEM + assert messages[0].author.role == Role.DEVELOPER + assert isinstance(messages[0].content[0], DeveloperContent) + assert messages[0].content[0].instructions == "You are a helpful assistant" def test_developer_message(self, parse_function): """Test parsing developer message.""" @@ -293,6 +347,8 @@ def test_developer_message(self, parse_function): assert len(messages) == 1 assert messages[0].author.role == Role.DEVELOPER + assert isinstance(messages[0].content[0], DeveloperContent) + assert messages[0].content[0].instructions == "Use concise language" def test_user_message_with_string_content(self, parse_function): """Test parsing user message with string content.""" @@ -883,110 +939,6 @@ def test_drops_non_assistant_analysis_messages(self) -> None: assert cleaned_messages == messages[1:] -class TestParseChatOutput: - def test_parse_chat_output_interrupted_first_message(self) -> None: - harmony_str = "<|channel|>final<|message|>I'm in the middle of answering" - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning is None - assert final_content == "I'm in the middle of answering" - - def test_parse_chat_output_interrupted_reasoning_first_message(self) -> None: - harmony_str = "<|channel|>analysis<|message|>I'm in the middle of thinking" - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning == "I'm in the middle of thinking" - assert final_content is None - - def test_parse_chat_output_complete_reasoning_interrupted_content(self) -> None: - harmony_str = ( - "<|channel|>analysis<|message|>I'm thinking.<|end|>" - "<|start|>assistant<|channel|>final" - "<|message|>I'm in the middle of answering" - ) - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning == "I'm thinking." - assert final_content == "I'm in the middle of answering" - - def test_parse_chat_output_complete_content(self) -> None: - harmony_str = "<|channel|>final<|message|>The answer is 4.<|end|>" - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning is None - assert final_content == "The answer is 4." - - def test_parse_chat_output_complete_commentary(self) -> None: - harmony_str = ( - "<|channel|>commentary<|message|>I need to call some tools.<|end|>" - ) - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning is None - assert final_content == "I need to call some tools." - - def test_parse_chat_output_complete_reasoning(self) -> None: - harmony_str = ( - "<|channel|>analysis<|message|>I've thought hard about this.<|end|>" - ) - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning == "I've thought hard about this." - assert final_content is None - - def test_parse_chat_output_complete_reasoning_and_content(self) -> None: - harmony_str = ( - "<|channel|>analysis<|message|>I've thought hard about this.<|end|>" - "<|start|>assistant<|channel|>final<|message|>The answer is 4.<|end|>" - ) - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning == "I've thought hard about this." - assert final_content == "The answer is 4." - - def test_parse_chat_output_commentary_with_recipient_excluded(self) -> None: - """Commentary with a recipient (tool call) should not appear in - final_content — those are handled separately by the tool parser. - - The first message is a preamble (visible), the second is a tool - call (excluded). Only the preamble should appear in final_content. - """ - harmony_str = ( - "<|channel|>commentary" - "<|message|>Let me check the weather.<|end|>" - "<|start|>assistant to=functions.get_weather" - "<|channel|>commentary" - '<|message|>{"location": "SF"}<|end|>' - ) - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning is None - assert final_content == "Let me check the weather." - - def test_parse_chat_output_interrupted_preamble(self) -> None: - """Partial/interrupted preamble (commentary without recipient) should - appear in final_content, not reasoning.""" - harmony_str = "<|channel|>commentary<|message|>I'll search for that" - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning is None - assert final_content == "I'll search for that" - - def test_parse_chat_output_preamble_then_final(self) -> None: - """Preamble followed by a final message should both appear in - final_content, joined by newline.""" - harmony_str = ( - "<|channel|>commentary" - "<|message|>Let me look that up.<|end|>" - "<|start|>assistant<|channel|>final" - "<|message|>The answer is 42.<|end|>" - ) - token_ids = get_encoding().encode(harmony_str, allowed_special="all") - reasoning, final_content, _ = parse_chat_output(token_ids) - assert reasoning is None - assert final_content == "Let me look that up.\nThe answer is 42." - - def test_has_custom_tools() -> None: assert not has_custom_tools(set()) assert not has_custom_tools({"web_search_preview", "code_interpreter", "container"}) diff --git a/tests/entrypoints/openai/responses/conftest.py b/tests/entrypoints/openai/responses/conftest.py index a1d16b123166..5bba59781f10 100644 --- a/tests/entrypoints/openai/responses/conftest.py +++ b/tests/entrypoints/openai/responses/conftest.py @@ -251,7 +251,13 @@ def _validate_field_consistency(events: list) -> None: "response.reasoning_part.added", ): _assert_item_fields(event, etype, active_item_id, active_output_index) - active_content_index = getattr(event, "content_index", None) + content_index = getattr(event, "content_index", None) + if active_content_index is None: + assert content_index == 0, ( + f"{etype} for a new item must start at content_index 0, " + f"got {content_index}" + ) + active_content_index = content_index continue # --- all other item-level events -------------------------- diff --git a/tests/entrypoints/openai/responses/test_errors.py b/tests/entrypoints/openai/responses/test_errors.py index e21f6aa2a42a..a2f1a0f3247e 100644 --- a/tests/entrypoints/openai/responses/test_errors.py +++ b/tests/entrypoints/openai/responses/test_errors.py @@ -7,20 +7,20 @@ import pytest import vllm.envs as envs -from vllm.entrypoints.openai.engine.serving import GenerationError, OpenAIServing +from vllm.entrypoints.generate.base.serving import GenerateBaseServing, GenerationError from vllm.envs import disable_envs_cache @pytest.mark.asyncio async def test_raise_if_error_raises_generation_error(): """test _raise_if_error raises GenerationError""" - # create a minimal OpenAIServing instance + # create a minimal GenerateBaseServing instance mock_engine = MagicMock() mock_engine.model_config = MagicMock() mock_engine.model_config.max_model_len = 100 mock_models = MagicMock() - serving = OpenAIServing( + serving = GenerateBaseServing( engine_client=mock_engine, models=mock_models, request_logger=None, @@ -47,7 +47,7 @@ async def test_convert_generation_error_to_streaming_response(): mock_engine.model_config.max_model_len = 100 mock_models = MagicMock() - serving = OpenAIServing( + serving = GenerateBaseServing( engine_client=mock_engine, models=mock_models, request_logger=None, @@ -77,7 +77,7 @@ def test_is_model_supported_skip_name_validation_env( mock_models = MagicMock() mock_models.is_base_model.return_value = False - serving = OpenAIServing( + serving = GenerateBaseServing( engine_client=mock_engine, models=mock_models, request_logger=None, diff --git a/tests/entrypoints/openai/responses/test_function_call_parsing.py b/tests/entrypoints/openai/responses/test_function_call_parsing.py index 8b4d7c7397a3..f90a641db7fd 100644 --- a/tests/entrypoints/openai/responses/test_function_call_parsing.py +++ b/tests/entrypoints/openai/responses/test_function_call_parsing.py @@ -5,7 +5,7 @@ import json import pytest -from openai.types.responses import ResponseFunctionToolCall +from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage from vllm.entrypoints.openai.responses.protocol import ResponsesRequest @@ -328,3 +328,52 @@ def test_validator_handles_empty_iterator(): request = ResponsesRequest(**mock_data) assert request.input == [] + + +def test_assistant_string_content_stays_easyinput(): + """EasyInput assistant message with plain string content is not + coerced into a ResponseOutputMessage.""" + request_data = { + "model": "test-model", + "input": [ + {"type": "message", "role": "assistant", "content": "hello"}, + ], + } + + request = ResponsesRequest(**request_data) + + item = request.input[0] + assert isinstance(item, dict), ( + "String-content assistant message should remain a dict (EasyInput), " + f"got {type(item)}" + ) + assert item.get("content") == "hello" + assert "id" not in item + assert "status" not in item + + +def test_assistant_output_style_content_coerced(): + """Assistant message whose content is output-message-shaped (list of + output_text items) should be coerced to ResponseOutputMessage.""" + request_data = { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "world"}], + }, + ], + } + + request = ResponsesRequest(**request_data) + + item = request.input[0] + assert isinstance(item, ResponseOutputMessage), ( + "Output-style assistant message should be coerced to " + f"ResponseOutputMessage, got {type(item)}" + ) + assert item.content[0].text == "world" + assert item.content[0].annotations == [] + assert item.status == "completed" + assert item.id.startswith("msg_") diff --git a/tests/entrypoints/openai/responses/test_harmony.py b/tests/entrypoints/openai/responses/test_harmony.py index 88dd2d38457d..2c70b06d8129 100644 --- a/tests/entrypoints/openai/responses/test_harmony.py +++ b/tests/entrypoints/openai/responses/test_harmony.py @@ -454,6 +454,7 @@ async def test_streaming(client: OpenAI, model_name: str, background: bool): if event.type == "response.output_item.added": assert event.item.id != current_item_id current_item_id = event.item.id + current_content_index = -1 elif event.type in [ "response.output_text.delta", "response.reasoning_text.delta", @@ -465,7 +466,7 @@ async def test_streaming(client: OpenAI, model_name: str, background: bool): "response.content_part.added", "response.reasoning_part.added", ]: - assert event.content_index != current_content_index + assert event.content_index == current_content_index + 1 current_content_index = event.content_index elif event.type in [ "response.output_text.delta", diff --git a/tests/entrypoints/openai/responses/test_harmony_utils.py b/tests/entrypoints/openai/responses/test_harmony_utils.py index f1434ce2bd58..bd4a46741d88 100644 --- a/tests/entrypoints/openai/responses/test_harmony_utils.py +++ b/tests/entrypoints/openai/responses/test_harmony_utils.py @@ -2,8 +2,10 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for vllm.entrypoints.openai.responses.harmony.""" +import pytest from openai.types.responses import ( ResponseFunctionToolCall, + ResponseFunctionWebSearch, ResponseOutputMessage, ResponseReasoningItem, ) @@ -12,7 +14,6 @@ from vllm.entrypoints.openai.responses.harmony import ( harmony_to_response_output, - parser_state_to_response_output, response_previous_input_to_harmony, ) @@ -95,7 +96,8 @@ def test_tool_message_with_empty_content(self): class TestHarmonyToResponseOutput: """Tests for harmony_to_response_output function.""" - def test_commentary_with_no_recipient_creates_message(self): + @pytest.mark.parametrize("incomplete", [False, True]) + def test_commentary_with_no_recipient_creates_message(self, incomplete): """Test that commentary with recipient=None (preambles) creates message items. Per Harmony format, preambles are intended to be shown to end-users, @@ -108,13 +110,15 @@ def test_commentary_with_no_recipient_creates_message(self): message = message.with_channel("commentary") # recipient is None by default, representing a preamble - output_items = harmony_to_response_output(message) + output_items = harmony_to_response_output( + message, frozenset(), incomplete=incomplete + ) assert len(output_items) == 1 assert isinstance(output_items[0], ResponseOutputMessage) assert output_items[0].type == "message" assert output_items[0].role == "assistant" - assert output_items[0].status == "completed" + assert output_items[0].status == ("incomplete" if incomplete else "completed") assert len(output_items[0].content) == 1 assert output_items[0].content[0].type == "output_text" assert ( @@ -122,81 +126,148 @@ def test_commentary_with_no_recipient_creates_message(self): == "I will now search for the weather information." ) - def test_commentary_with_function_recipient_creates_function_call(self): - """Test commentary with recipient='functions.X' creates function calls.""" - message = Message.from_role_and_content( - Role.ASSISTANT, '{"location": "San Francisco", "units": "celsius"}' + @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"]) + @pytest.mark.parametrize( + ("recipient", "fn_names", "expected_name"), + [ + ("functions.get_weather", frozenset(), "get_weather"), + ("get_weather", frozenset({"get_weather"}), "get_weather"), + ("math.sum", frozenset({"math.sum"}), "math.sum"), + ], + ) + @pytest.mark.parametrize("incomplete", [False, True]) + def test_function_recipient_creates_function_call( + self, channel, recipient, fn_names, expected_name, incomplete + ): + """Function recipients create function calls across channels.""" + content = '{"location": "San Francisco"}' + if recipient == "math.sum": + content = '{"a": 1, "b": 2}' + + message = Message.from_role_and_content(Role.ASSISTANT, content) + message = message.with_channel(channel) + message = message.with_recipient(recipient) + + output_items = harmony_to_response_output( + message, fn_names, incomplete=incomplete ) - message = message.with_channel("commentary") - message = message.with_recipient("functions.get_weather") - - output_items = harmony_to_response_output(message) assert len(output_items) == 1 assert isinstance(output_items[0], ResponseFunctionToolCall) assert output_items[0].type == "function_call" - assert output_items[0].name == "get_weather" - assert ( - output_items[0].arguments - == '{"location": "San Francisco", "units": "celsius"}' - ) + assert output_items[0].name == expected_name + assert output_items[0].arguments == content assert output_items[0].call_id.startswith("call_") assert output_items[0].id.startswith("fc_") - - def test_commentary_with_python_recipient_creates_reasoning(self): - """Test that commentary with recipient='python' creates reasoning items.""" - message = Message.from_role_and_content( - Role.ASSISTANT, "import numpy as np\nprint(np.array([1, 2, 3]))" + assert output_items[0].status == ("incomplete" if incomplete else "completed") + + @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"]) + @pytest.mark.parametrize( + ("recipient", "content"), + [ + ("python", "import numpy as np\nprint(np.array([1, 2, 3]))"), + ("browser", "Navigating to the specified URL"), + ("container", "Running command in container"), + ], + ) + @pytest.mark.parametrize("incomplete", [False, True]) + def test_builtin_recipient_creates_reasoning( + self, channel, recipient, content, incomplete + ): + """Built-in recipients create reasoning items.""" + message = Message.from_role_and_content(Role.ASSISTANT, content) + message = message.with_channel(channel) + message = message.with_recipient(recipient) + + output_items = harmony_to_response_output( + message, frozenset(), incomplete=incomplete ) - message = message.with_channel("commentary") - message = message.with_recipient("python") - - output_items = harmony_to_response_output(message) assert len(output_items) == 1 assert isinstance(output_items[0], ResponseReasoningItem) assert output_items[0].type == "reasoning" - assert ( - output_items[0].content[0].text - == "import numpy as np\nprint(np.array([1, 2, 3]))" + assert output_items[0].content[0].text == content + assert output_items[0].status is None + + @pytest.mark.parametrize("channel", ["commentary", "comment", "analysis", "final"]) + @pytest.mark.parametrize( + ("recipient", "fn_names", "content", "expected_name", "expected_server_label"), + [ + ( + "get_weather", + frozenset(), + '{"arg": "value"}', + "get_weather", + "get_weather", + ), + ( + "not_get_weather", + frozenset({"get_weather"}), + '{"arg": "value"}', + "not_get_weather", + "not_get_weather", + ), + ("repo_browser.list", frozenset(), '{"cmd": "ls"}', "list", "repo_browser"), + ], + ) + @pytest.mark.parametrize("incomplete", [False, True]) + def test_non_function_non_builtin_recipient_creates_mcp_call( + self, + channel, + recipient, + fn_names, + content, + expected_name, + expected_server_label, + incomplete, + ): + """Non-function, non-built-in recipients create MCP calls.""" + message = Message.from_role_and_content(Role.ASSISTANT, content) + message = message.with_channel(channel) + message = message.with_recipient(recipient) + + output_items = harmony_to_response_output( + message, fn_names, incomplete=incomplete ) - def test_commentary_with_browser_recipient_creates_reasoning(self): - """Test that commentary with recipient='browser' creates reasoning items.""" + assert len(output_items) == 1 + assert isinstance(output_items[0], McpCall) + assert output_items[0].type == "mcp_call" + assert output_items[0].name == expected_name + assert output_items[0].server_label == expected_server_label + assert output_items[0].arguments == content + assert output_items[0].status == ("incomplete" if incomplete else "completed") + + @pytest.mark.parametrize("incomplete", [False, True]) + def test_browser_search_recipient_respects_incomplete(self, incomplete): + """browser.search emits a web search call unless the item is incomplete.""" message = Message.from_role_and_content( - Role.ASSISTANT, "Navigating to the specified URL" + Role.ASSISTANT, '{"query": "weather in San Francisco"}' ) message = message.with_channel("commentary") - message = message.with_recipient("browser") - - output_items = harmony_to_response_output(message) - - assert len(output_items) == 1 - assert isinstance(output_items[0], ResponseReasoningItem) - assert output_items[0].type == "reasoning" - assert output_items[0].content[0].text == "Navigating to the specified URL" + message = message.with_recipient("browser.search") - def test_commentary_with_container_recipient_creates_reasoning(self): - """Test that commentary with recipient='container' creates reasoning items.""" - message = Message.from_role_and_content( - Role.ASSISTANT, "Running command in container" + output_items = harmony_to_response_output( + message, frozenset(), incomplete=incomplete ) - message = message.with_channel("commentary") - message = message.with_recipient("container") - output_items = harmony_to_response_output(message) + if incomplete: + assert output_items == [] + return assert len(output_items) == 1 - assert isinstance(output_items[0], ResponseReasoningItem) - assert output_items[0].type == "reasoning" - assert output_items[0].content[0].text == "Running command in container" + assert isinstance(output_items[0], ResponseFunctionWebSearch) + assert output_items[0].type == "web_search_call" + assert output_items[0].status == "completed" + assert output_items[0].action.type == "search" + assert output_items[0].action.query == "cursor:weather in San Francisco" def test_commentary_with_empty_content_and_no_recipient(self): """Test edge case: empty commentary with recipient=None.""" message = Message.from_role_and_content(Role.ASSISTANT, "") message = message.with_channel("commentary") - output_items = harmony_to_response_output(message) + output_items = harmony_to_response_output(message, frozenset()) assert len(output_items) == 1 assert isinstance(output_items[0], ResponseOutputMessage) @@ -211,7 +282,7 @@ def test_commentary_with_multiple_contents_and_no_recipient(self): message = Message.from_role_and_contents(Role.ASSISTANT, contents) message = message.with_channel("commentary") - output_items = harmony_to_response_output(message) + output_items = harmony_to_response_output(message, frozenset()) # _parse_final_message returns single ResponseOutputMessage with # multiple contents @@ -231,7 +302,7 @@ def test_commentary_with_multiple_function_calls(self): message = message.with_channel("commentary") message = message.with_recipient("functions.get_weather") - output_items = harmony_to_response_output(message) + output_items = harmony_to_response_output(message, frozenset()) assert len(output_items) == 2 assert all(isinstance(item, ResponseFunctionToolCall) for item in output_items) @@ -240,21 +311,6 @@ def test_commentary_with_multiple_function_calls(self): assert output_items[0].arguments == '{"location": "San Francisco"}' assert output_items[1].arguments == '{"location": "New York"}' - def test_commentary_with_unknown_recipient_creates_mcp_call(self): - """Test that commentary with unknown recipient creates MCP call.""" - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}') - message = message.with_channel("commentary") - message = message.with_recipient("custom_tool") - - fn_names = frozenset({"other_tool"}) - output_items = harmony_to_response_output(message, fn_names) - - assert len(output_items) == 1 - assert isinstance(output_items[0], McpCall) - assert output_items[0].type == "mcp_call" - assert output_items[0].name == "custom_tool" - assert output_items[0].server_label == "custom_tool" - def test_analysis_channel_creates_reasoning(self): """Test that analysis channel creates reasoning items.""" message = Message.from_role_and_content( @@ -262,7 +318,7 @@ def test_analysis_channel_creates_reasoning(self): ) message = message.with_channel("analysis") - output_items = harmony_to_response_output(message) + output_items = harmony_to_response_output(message, frozenset()) assert len(output_items) == 1 assert isinstance(output_items[0], ResponseReasoningItem) @@ -282,352 +338,6 @@ def test_non_assistant_message_returns_empty(self): "The weather is sunny, 72°F", ) - output_items = harmony_to_response_output(message) - - assert len(output_items) == 0 - - -class TestHarmonyToResponseOutputWithFunctionToolNames: - """Tests for bare function name handling with function_tool_names.""" - - def test_bare_name_creates_function_call_when_in_tool_names(self): - """Bare function name matching a known tool creates function call.""" - message = Message.from_role_and_content( - Role.ASSISTANT, '{"location": "San Francisco"}' - ) - message = message.with_channel("commentary") - message = message.with_recipient("get_weather") - - fn_names = frozenset({"get_weather"}) - output_items = harmony_to_response_output(message, fn_names) - - assert len(output_items) == 1 - assert isinstance(output_items[0], ResponseFunctionToolCall) - assert output_items[0].type == "function_call" - assert output_items[0].name == "get_weather" - assert output_items[0].arguments == '{"location": "San Francisco"}' - - def test_bare_name_creates_mcp_call_when_not_in_tool_names(self): - """Bare name not matching any known tool creates MCP call.""" - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}') - message = message.with_channel("commentary") - message = message.with_recipient("custom_tool") - - fn_names = frozenset({"get_weather"}) - output_items = harmony_to_response_output(message, fn_names) - - assert len(output_items) == 1 - assert isinstance(output_items[0], McpCall) - assert output_items[0].type == "mcp_call" - - def test_dotted_function_name_creates_function_call(self): - """Dotted function name in tool names creates function call.""" - message = Message.from_role_and_content(Role.ASSISTANT, '{"a": 1, "b": 2}') - message = message.with_channel("commentary") - message = message.with_recipient("math.sum") - - fn_names = frozenset({"math.sum"}) - output_items = harmony_to_response_output(message, fn_names) - - assert len(output_items) == 1 - assert isinstance(output_items[0], ResponseFunctionToolCall) - assert output_items[0].name == "math.sum" - - def test_empty_tool_names_defaults_to_mcp(self): - """With empty function_tool_names, bare names become MCP calls.""" - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}') - message = message.with_channel("commentary") - message = message.with_recipient("get_weather") - output_items = harmony_to_response_output(message, frozenset()) - assert len(output_items) == 1 - assert isinstance(output_items[0], McpCall) - - def test_prefixed_name_always_function_call(self): - """functions. prefix always creates function call even with empty tool names.""" - message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}') - message = message.with_channel("commentary") - message = message.with_recipient("functions.get_weather") - - output_items = harmony_to_response_output(message, frozenset()) - - assert len(output_items) == 1 - assert isinstance(output_items[0], ResponseFunctionToolCall) - assert output_items[0].name == "get_weather" - - -class TestParserStateWithFunctionToolNames: - """Tests for parser_state_to_response_output with function_tool_names.""" - - def test_bare_name_creates_function_call(self): - from unittest.mock import Mock - - parser = Mock() - parser.current_content = '{"arg": "value"}' - parser.current_role = Role.ASSISTANT - parser.current_channel = "commentary" - parser.current_recipient = "get_weather" - - fn_names = frozenset({"get_weather"}) - items = parser_state_to_response_output(parser, fn_names) - - assert len(items) == 1 - assert isinstance(items[0], ResponseFunctionToolCall) - assert items[0].name == "get_weather" - assert items[0].status == "in_progress" - - def test_bare_name_creates_mcp_when_not_in_tool_names(self): - from unittest.mock import Mock - - parser = Mock() - parser.current_content = '{"arg": "value"}' - parser.current_role = Role.ASSISTANT - parser.current_channel = "commentary" - parser.current_recipient = "unknown_tool" - - fn_names = frozenset({"get_weather"}) - items = parser_state_to_response_output(parser, fn_names) - - assert len(items) == 1 - assert isinstance(items[0], McpCall) - assert items[0].name == "unknown_tool" - - -class TestToolCallsOnNonStandardChannels: - """Tests verifying tool calls are detected regardless of channel.""" - - def test_function_call_on_comment_channel(self): - message = Message.from_role_and_content(Role.ASSISTANT, '{"query": "weather"}') - message = message.with_channel("comment") - message = message.with_recipient("functions.get_weather") - - output_items = harmony_to_response_output(message) - - assert len(output_items) == 1 - assert isinstance(output_items[0], ResponseFunctionToolCall) - assert output_items[0].type == "function_call" - assert output_items[0].name == "get_weather" - - def test_bare_function_on_comment_channel(self): - message = Message.from_role_and_content(Role.ASSISTANT, '{"query": "weather"}') - message = message.with_channel("comment") - message = message.with_recipient("get_weather") - - fn_names = frozenset({"get_weather"}) - output_items = harmony_to_response_output(message, fn_names) - - assert len(output_items) == 1 - assert isinstance(output_items[0], ResponseFunctionToolCall) - assert output_items[0].name == "get_weather" - - def test_parser_state_comment_channel_function(self): - from unittest.mock import Mock - - parser = Mock() - parser.current_content = '{"arg": "value"}' - parser.current_role = Role.ASSISTANT - parser.current_channel = "comment" - parser.current_recipient = "functions.get_weather" - - items = parser_state_to_response_output(parser) - - assert len(items) == 1 - assert isinstance(items[0], ResponseFunctionToolCall) - assert items[0].name == "get_weather" - - def test_parser_state_comment_channel_mcp(self): - from unittest.mock import Mock - - parser = Mock() - parser.current_content = '{"arg": "value"}' - parser.current_role = Role.ASSISTANT - parser.current_channel = "comment" - parser.current_recipient = "mcp.server.tool" - - fn_names: frozenset[str] = frozenset() - items = parser_state_to_response_output(parser, fn_names) - - assert len(items) == 1 - assert isinstance(items[0], McpCall) - - -def test_parse_mcp_call_basic() -> None: - """Test that MCP calls are parsed with correct type and server_label.""" - message = Message.from_role_and_content(Role.ASSISTANT, '{"path": "/tmp"}') - message = message.with_recipient("filesystem") - message = message.with_channel("commentary") - - fn_names: frozenset[str] = frozenset() - output_items = harmony_to_response_output(message, fn_names) - - assert len(output_items) == 1 - assert isinstance(output_items[0], McpCall) - assert output_items[0].type == "mcp_call" - assert output_items[0].name == "filesystem" - assert output_items[0].server_label == "filesystem" - assert output_items[0].arguments == '{"path": "/tmp"}' - assert output_items[0].status == "completed" - - -def test_parse_mcp_call_dotted_recipient() -> None: - """Test that dotted recipients extract the tool name correctly.""" - message = Message.from_role_and_content(Role.ASSISTANT, '{"cmd": "ls"}') - message = message.with_recipient("repo_browser.list") - message = message.with_channel("commentary") - - fn_names: frozenset[str] = frozenset() - output_items = harmony_to_response_output(message, fn_names) - - assert len(output_items) == 1 - assert isinstance(output_items[0], McpCall) - assert output_items[0].name == "list" - assert output_items[0].server_label == "repo_browser" - - -def test_mcp_vs_function_call() -> None: - """Test that function calls are not parsed as MCP calls.""" - func_message = Message.from_role_and_content(Role.ASSISTANT, '{"arg": "value"}') - func_message = func_message.with_recipient("functions.my_tool") - func_message = func_message.with_channel("commentary") - - func_items = harmony_to_response_output(func_message) - - assert len(func_items) == 1 - assert not isinstance(func_items[0], McpCall) - assert func_items[0].type == "function_call" - - -def test_mcp_vs_builtin_tools() -> None: - """Test that built-in tools (python, container) are not parsed as MCP calls.""" - # Test python (built-in tool) - should be reasoning, not MCP - python_message = Message.from_role_and_content(Role.ASSISTANT, "print('hello')") - python_message = python_message.with_recipient("python") - python_message = python_message.with_channel("commentary") - - python_items = harmony_to_response_output(python_message) - - assert len(python_items) == 1 - assert not isinstance(python_items[0], McpCall) - assert python_items[0].type == "reasoning" - - -def test_parser_state_to_response_output_commentary_channel() -> None: - """Test parser_state_to_response_output with commentary - channel and various recipients.""" - from unittest.mock import Mock - - # Test 1: functions.* recipient -> should return function tool call - parser_func = Mock() - parser_func.current_content = '{"arg": "value"}' - parser_func.current_role = Role.ASSISTANT - parser_func.current_channel = "commentary" - parser_func.current_recipient = "functions.my_tool" - - func_items = parser_state_to_response_output(parser_func) - - assert len(func_items) == 1 - assert not isinstance(func_items[0], McpCall) - assert func_items[0].type == "function_call" - assert func_items[0].name == "my_tool" - assert func_items[0].status == "in_progress" - - # Test 2: MCP tool (not builtin) -> should return MCP call - parser_mcp = Mock() - parser_mcp.current_content = '{"path": "/tmp"}' - parser_mcp.current_role = Role.ASSISTANT - parser_mcp.current_channel = "commentary" - parser_mcp.current_recipient = "filesystem" - - fn_names: frozenset[str] = frozenset() - mcp_items = parser_state_to_response_output(parser_mcp, fn_names) - - assert len(mcp_items) == 1 - assert isinstance(mcp_items[0], McpCall) - assert mcp_items[0].type == "mcp_call" - assert mcp_items[0].name == "filesystem" - assert mcp_items[0].server_label == "filesystem" - assert mcp_items[0].status == "in_progress" - - # Test 3: Built-in tool (python) - # should NOT return MCP call, returns reasoning (internal tool interaction) - parser_builtin = Mock() - parser_builtin.current_content = "print('hello')" - parser_builtin.current_role = Role.ASSISTANT - parser_builtin.current_channel = "commentary" - parser_builtin.current_recipient = "python" - - builtin_items = parser_state_to_response_output(parser_builtin) - - # Built-in tools explicitly return reasoning - assert len(builtin_items) == 1 - assert not isinstance(builtin_items[0], McpCall) - assert builtin_items[0].type == "reasoning" - - # Test 4: No recipient (preamble) → should return message, not reasoning - parser_preamble = Mock() - parser_preamble.current_content = "I'll search for that information now." - parser_preamble.current_role = Role.ASSISTANT - parser_preamble.current_channel = "commentary" - parser_preamble.current_recipient = None - - preamble_items = parser_state_to_response_output(parser_preamble) - - assert len(preamble_items) == 1 - assert isinstance(preamble_items[0], ResponseOutputMessage) - assert preamble_items[0].type == "message" - assert preamble_items[0].content[0].text == "I'll search for that information now." - assert preamble_items[0].status == "incomplete" # streaming - - -def test_parser_state_to_response_output_analysis_channel() -> None: - """Test parser_state_to_response_output with analysis - channel and various recipients.""" - from unittest.mock import Mock - - # Test 1: functions.* recipient -> should return function tool call - parser_func = Mock() - parser_func.current_content = '{"arg": "value"}' - parser_func.current_role = Role.ASSISTANT - parser_func.current_channel = "analysis" - parser_func.current_recipient = "functions.my_tool" - - func_items = parser_state_to_response_output(parser_func) - - assert len(func_items) == 1 - assert not isinstance(func_items[0], McpCall) - assert func_items[0].type == "function_call" - assert func_items[0].name == "my_tool" - assert func_items[0].status == "in_progress" - - # Test 2: MCP tool (not builtin) -> should return MCP call - parser_mcp = Mock() - parser_mcp.current_content = '{"query": "test"}' - parser_mcp.current_role = Role.ASSISTANT - parser_mcp.current_channel = "analysis" - parser_mcp.current_recipient = "database" - - fn_names: frozenset[str] = frozenset() - mcp_items = parser_state_to_response_output(parser_mcp, fn_names) - - assert len(mcp_items) == 1 - assert isinstance(mcp_items[0], McpCall) - assert mcp_items[0].type == "mcp_call" - assert mcp_items[0].name == "database" - assert mcp_items[0].server_label == "database" - assert mcp_items[0].status == "in_progress" - - # Test 3: Built-in tool (container) - # should NOT return MCP call, falls through to reasoning - parser_builtin = Mock() - parser_builtin.current_content = "docker run" - parser_builtin.current_role = Role.ASSISTANT - parser_builtin.current_channel = "analysis" - parser_builtin.current_recipient = "container" - - builtin_items = parser_state_to_response_output(parser_builtin) - - # Should fall through to reasoning logic - assert len(builtin_items) == 1 - assert not isinstance(builtin_items[0], McpCall) - assert builtin_items[0].type == "reasoning" + assert len(output_items) == 0 diff --git a/tests/entrypoints/openai/responses/test_namespace_tool_separator.py b/tests/entrypoints/openai/responses/test_namespace_tool_separator.py new file mode 100644 index 000000000000..c895092b45f9 --- /dev/null +++ b/tests/entrypoints/openai/responses/test_namespace_tool_separator.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json + +import openai # use the official client for correctness check +import pytest + +MODEL_NAME = "Qwen/Qwen3-1.7B" +NAMESPACE = "mcp__computer_use" +TOOL_NAME = "get_app_state" +FLAT_TOOL_NAME = f"{NAMESPACE}__{TOOL_NAME}" + +tools = [ + { + "type": "namespace", + "name": NAMESPACE, + "description": "Computer control tools.", + "tools": [ + { + "type": "function", + "name": TOOL_NAME, + "description": "Get the current state of a desktop application.", + "parameters": { + "type": "object", + "properties": { + "app": { + "type": "string", + "description": "Application name, for example Chrome.", + } + }, + "required": ["app"], + "additionalProperties": False, + }, + } + ], + } +] + +prompt = [ + { + "role": "user", + "content": "Use the computer app state tool to inspect Google Chrome.", + }, +] + + +def _assert_namespace_tool_call(tool_call) -> None: + assert tool_call.type == "function_call" + assert tool_call.name == TOOL_NAME + assert tool_call.namespace == NAMESPACE + assert tool_call.name != FLAT_TOOL_NAME + + args = json.loads(tool_call.arguments) + assert args["app"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_name", [MODEL_NAME]) +async def test_namespace_tool_separator(client: openai.AsyncOpenAI, model_name: str): + response = await client.responses.create( + model=model_name, + input=prompt, + tools=tools, + temperature=0.0, + ) + + assert len(response.output) >= 1 + tool_call = next( + (out for out in response.output if out.type == "function_call"), None + ) + assert tool_call is not None + _assert_namespace_tool_call(tool_call) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_name", [MODEL_NAME]) +async def test_namespace_tool_separator_streaming( + client: openai.AsyncOpenAI, model_name: str +): + stream = await client.responses.create( + model=model_name, + input=prompt, + tools=tools, + temperature=0.0, + stream=True, + ) + events = [event async for event in stream] + + added_call = next( + ( + event.item + for event in events + if event.type == "response.output_item.added" + and getattr(event.item, "type", None) == "function_call" + ), + None, + ) + done_call = next( + ( + event.item + for event in events + if event.type == "response.output_item.done" + and getattr(event.item, "type", None) == "function_call" + ), + None, + ) + + assert added_call is not None + assert added_call.name == TOOL_NAME + assert added_call.namespace == NAMESPACE + + assert done_call is not None + _assert_namespace_tool_call(done_call) diff --git a/tests/entrypoints/openai/test_responses_parser_unified.py b/tests/entrypoints/openai/responses/test_parsable_context_unit.py similarity index 65% rename from tests/entrypoints/openai/test_responses_parser_unified.py rename to tests/entrypoints/openai/responses/test_parsable_context_unit.py index ecc857e1aac6..2bad3032c467 100644 --- a/tests/entrypoints/openai/test_responses_parser_unified.py +++ b/tests/entrypoints/openai/responses/test_parsable_context_unit.py @@ -1,10 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for ResponsesParser with the unified Parser interface. +"""Unit tests for ParsableContext's parsing behavior. -These tests verify that ResponsesParser correctly delegates to the unified -Parser (via extract_response_outputs) instead of calling separate -ReasoningParser / ToolParser instances directly. +These tests verify that ParsableContext correctly delegates to the unified +Parser (via parse) and properly builds response output items. """ from collections.abc import Sequence @@ -18,12 +17,9 @@ FunctionCall, ToolCall, ) -from vllm.entrypoints.openai.parser.responses_parser import ( - ResponsesParser, - get_responses_parser_for_simple_context, -) +from vllm.entrypoints.openai.responses.context import ParsableContext from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -from vllm.outputs import CompletionOutput +from vllm.outputs import CompletionOutput, RequestOutput from vllm.parser.abstract_parser import DelegatingParser pytestmark = pytest.mark.skip_global_cleanup @@ -162,32 +158,50 @@ def _make_request(**overrides) -> ResponsesRequest: return ResponsesRequest.model_validate(defaults) -def _make_output( +def _make_request_output( text: str = "Hello, world!", token_ids: Sequence[int] = (1, 2, 3), finish_reason: str = "stop", -) -> CompletionOutput: - return CompletionOutput( - index=0, - text=text, - token_ids=list(token_ids), - cumulative_logprob=None, - logprobs=None, - finish_reason=finish_reason, +) -> RequestOutput: + return RequestOutput( + request_id="test", + prompt=None, + prompt_token_ids=[], + prompt_logprobs=None, + outputs=[ + CompletionOutput( + index=0, + text=text, + token_ids=list(token_ids), + cumulative_logprob=None, + logprobs=None, + finish_reason=finish_reason, + ) + ], + finished=True, ) -def _make_parser(parser_cls, **overrides): +def _make_context(parser_cls, **overrides): + # ParsableContext no longer lazily builds a parser from ``parser_cls``; + # the caller (here, the serving layer in production) must supply one. + request = overrides.get("request", _make_request()) + response_parser = overrides.pop("response_parser", None) + if response_parser is None and parser_cls is not None: + response_parser = parser_cls(MagicMock(), request.tools) + defaults = dict( tokenizer=MagicMock(), parser_cls=parser_cls, + response_parser=response_parser, response_messages=[], - request=_make_request(), + request=request, + available_tools=None, chat_template=None, chat_template_content_format="auto", ) defaults.update(overrides) - return ResponsesParser(**defaults) + return ParsableContext(**defaults) # --------------------------------------------------------------------------- @@ -197,22 +211,22 @@ def _make_parser(parser_cls, **overrides): def test_process_text_with_parser(): """Parser with no reasoning/tools returns a single message item.""" - parser = _make_parser(_NoOpParser) - parser.process(_make_output(text="Hello!")) + ctx = _make_context(_NoOpParser) + ctx.append_output(_make_request_output(text="Hello!")) - assert len(parser.response_messages) == 1 - msg = parser.response_messages[0] + assert len(ctx.response_messages) == 1 + msg = ctx.response_messages[0] assert msg.type == "message" assert msg.content[0].text == "Hello!" def test_process_text_without_parser(): """parser_cls=None falls back to plain text wrapping.""" - parser = _make_parser(None) - parser.process(_make_output(text="Hello!")) + ctx = _make_context(None) + ctx.append_output(_make_request_output(text="Hello!")) - assert len(parser.response_messages) == 1 - msg = parser.response_messages[0] + assert len(ctx.response_messages) == 1 + msg = ctx.response_messages[0] assert msg.type == "message" assert msg.content[0].text == "Hello!" @@ -224,18 +238,18 @@ def test_process_text_without_parser(): def test_process_empty_text_without_parser(): """Empty text with no parser produces no output items.""" - parser = _make_parser(None) - parser.process(_make_output(text="")) + ctx = _make_context(None) + ctx.append_output(_make_request_output(text="")) - assert len(parser.response_messages) == 0 + assert len(ctx.response_messages) == 0 def test_process_empty_text_with_parser(): """Empty text with parser produces no output items.""" - parser = _make_parser(_NoOpParser) - parser.process(_make_output(text="")) + ctx = _make_context(_NoOpParser) + ctx.append_output(_make_request_output(text="")) - assert len(parser.response_messages) == 0 + assert len(ctx.response_messages) == 0 # --------------------------------------------------------------------------- @@ -245,26 +259,28 @@ def test_process_empty_text_with_parser(): def test_process_extracts_reasoning(): """Parser that finds reasoning produces both reasoning and message items.""" - parser = _make_parser(_ReasoningOnlyParser) - parser.process(_make_output(text="Let me checkThe answer is 42")) + ctx = _make_context(_ReasoningOnlyParser) + ctx.append_output( + _make_request_output(text="Let me checkThe answer is 42") + ) - types = [m.type for m in parser.response_messages] + types = [m.type for m in ctx.response_messages] assert "reasoning" in types assert "message" in types - reasoning_item = next(m for m in parser.response_messages if m.type == "reasoning") + reasoning_item = next(m for m in ctx.response_messages if m.type == "reasoning") assert reasoning_item.content[0].text == "Let me check" - message_item = next(m for m in parser.response_messages if m.type == "message") + message_item = next(m for m in ctx.response_messages if m.type == "message") assert message_item.content[0].text == "The answer is 42" def test_process_reasoning_only_no_content(): """When reasoning consumes all text, only a reasoning item is produced.""" - parser = _make_parser(_ReasoningOnlyParser) - parser.process(_make_output(text="Just thinking")) + ctx = _make_context(_ReasoningOnlyParser) + ctx.append_output(_make_request_output(text="Just thinking")) - types = [m.type for m in parser.response_messages] + types = [m.type for m in ctx.response_messages] assert "reasoning" in types assert "message" not in types @@ -286,13 +302,13 @@ def test_process_extracts_tool_calls(): } ], ) - parser = _make_parser(_ToolCallingParser, request=request, enable_auto_tools=True) - parser.process(_make_output(text="calling tool")) + ctx = _make_context(_ToolCallingParser, request=request, enable_auto_tools=True) + ctx.append_output(_make_request_output(text="calling tool")) - types = [m.type for m in parser.response_messages] + types = [m.type for m in ctx.response_messages] assert "function_call" in types - tool_item = next(m for m in parser.response_messages if m.type == "function_call") + tool_item = next(m for m in ctx.response_messages if m.type == "function_call") assert tool_item.name == "get_weather" assert tool_item.arguments == '{"location": "Paris"}' assert tool_item.status == "completed" @@ -304,15 +320,15 @@ def test_process_extracts_tool_calls(): def test_finish_reason_tracked(): - """finish_reason from CompletionOutput is stored on the parser.""" - parser = _make_parser(_NoOpParser) - assert parser.finish_reason is None + """finish_reason from CompletionOutput is stored on the context.""" + ctx = _make_context(_NoOpParser) + assert ctx.finish_reason is None - parser.process(_make_output(finish_reason="stop")) - assert parser.finish_reason == "stop" + ctx.append_output(_make_request_output(finish_reason="stop")) + assert ctx.finish_reason == "stop" - parser.process(_make_output(finish_reason="length")) - assert parser.finish_reason == "length" + ctx.append_output(_make_request_output(finish_reason="length")) + assert ctx.finish_reason == "length" # --------------------------------------------------------------------------- @@ -321,62 +337,27 @@ def test_finish_reason_tracked(): def test_multi_turn_accumulation(): - """Multiple process() calls accumulate response_messages.""" - parser = _make_parser(_NoOpParser) + """Multiple append_output() calls accumulate response_messages.""" + ctx = _make_context(_NoOpParser) - parser.process(_make_output(text="First turn")) - parser.process(_make_output(text="Second turn")) + ctx.append_output(_make_request_output(text="First turn")) + ctx.append_output(_make_request_output(text="Second turn")) - assert len(parser.response_messages) == 2 - texts = [m.content[0].text for m in parser.response_messages] + assert len(ctx.response_messages) == 2 + texts = [m.content[0].text for m in ctx.response_messages] assert texts == ["First turn", "Second turn"] def test_num_init_messages_offset(): """Initial messages are preserved and offset works correctly.""" init_messages = [MagicMock(type="message")] - parser = _make_parser(_NoOpParser, response_messages=init_messages) + ctx = _make_context(_NoOpParser, response_messages=init_messages) - assert parser.num_init_messages == 1 + assert ctx.num_init_messages == 1 - parser.process(_make_output(text="New output")) + ctx.append_output(_make_request_output(text="New output")) - assert len(parser.response_messages) == 2 - items = parser.make_response_output_items_from_parsable_context() + assert len(ctx.response_messages) == 2 + items = ctx.make_response_output_items() assert len(items) == 1 assert items[0].type == "message" - - -# --------------------------------------------------------------------------- -# Tests: factory function -# --------------------------------------------------------------------------- - - -def test_factory_function_creates_parser(): - """get_responses_parser_for_simple_context returns a working parser.""" - rp = get_responses_parser_for_simple_context( - tokenizer=MagicMock(), - parser_cls=_NoOpParser, - response_messages=[], - request=_make_request(), - chat_template=None, - chat_template_content_format="auto", - ) - assert isinstance(rp, ResponsesParser) - - rp.process(_make_output(text="Works!")) - assert len(rp.response_messages) == 1 - - -def test_factory_function_none_parser(): - """Factory function works with parser_cls=None.""" - rp = get_responses_parser_for_simple_context( - tokenizer=MagicMock(), - parser_cls=None, - response_messages=[], - request=_make_request(), - chat_template=None, - chat_template_content_format="auto", - ) - assert isinstance(rp, ResponsesParser) - assert rp.parser_instance is None diff --git a/tests/entrypoints/openai/responses/test_response_input_to_harmony.py b/tests/entrypoints/openai/responses/test_response_input_to_harmony.py index 8efd01577328..a86a1ca4e1de 100644 --- a/tests/entrypoints/openai/responses/test_response_input_to_harmony.py +++ b/tests/entrypoints/openai/responses/test_response_input_to_harmony.py @@ -12,7 +12,7 @@ from openai.types.responses.response_reasoning_item import ( Content as ReasoningTextContent, ) -from openai_harmony import Role +from openai_harmony import DeveloperContent, Role from vllm.entrypoints.openai.responses.harmony import response_input_to_harmony @@ -65,14 +65,16 @@ def test_no_type_key_defaults_to_message_branch(self): assert msg.content[0].text == "Hello" def test_system_message(self): + """System messages carry developer instructions and must be rendered + as developer messages with DeveloperContent.""" msg = response_input_to_harmony( {"type": "message", "role": "system", "content": "Be helpful."}, prev_responses=[], ) - assert msg.author.role == Role.SYSTEM - assert msg.content[0].text == "Be helpful." - assert msg.channel is None + assert msg.author.role == Role.DEVELOPER + assert isinstance(msg.content[0], DeveloperContent) + assert msg.content[0].instructions == "Be helpful." def test_assistant_message_gets_final_channel(self): msg = response_input_to_harmony( @@ -85,14 +87,16 @@ def test_assistant_message_gets_final_channel(self): assert msg.content[0].text == "The answer is 42." def test_developer_message_gets_instructions_prefix(self): + """Developer messages must use DeveloperContent which adds the + '# Instructions' header the model was trained on.""" msg = response_input_to_harmony( {"type": "message", "role": "developer", "content": "Be concise."}, prev_responses=[], ) assert msg.author.role == Role.DEVELOPER - assert msg.content[0].text == "Instructions:\nBe concise." - assert msg.channel is None + assert isinstance(msg.content[0], DeveloperContent) + assert msg.content[0].instructions == "Be concise." def test_message_with_array_content(self): msg = response_input_to_harmony( @@ -112,7 +116,9 @@ def test_message_with_array_content(self): assert msg.content[0].text == "Part one. " assert msg.content[1].text == "Part two." - def test_developer_message_array_content_gets_prefix_on_each_part(self): + def test_developer_message_array_content_concatenated(self): + """Array content in developer messages is flattened and rendered + via DeveloperContent with the '# Instructions' header.""" msg = response_input_to_harmony( { "type": "message", @@ -125,8 +131,9 @@ def test_developer_message_array_content_gets_prefix_on_each_part(self): prev_responses=[], ) - assert msg.content[0].text == "Instructions:\nRule 1." - assert msg.content[1].text == "Instructions:\nRule 2." + assert msg.author.role == Role.DEVELOPER + assert isinstance(msg.content[0], DeveloperContent) + assert msg.content[0].instructions == "Rule 1.Rule 2." # ----------------------------------------------------------------------- # type="reasoning" diff --git a/tests/entrypoints/openai/responses/test_responses_utils.py b/tests/entrypoints/openai/responses/test_responses_utils.py index c9ba52b143e0..efbfb5c07e6a 100644 --- a/tests/entrypoints/openai/responses/test_responses_utils.py +++ b/tests/entrypoints/openai/responses/test_responses_utils.py @@ -782,6 +782,16 @@ class TestConstructChatMessagesCombinePolicy: ["call_123", "call_456"], id="reasoning-output-tool-call", ), + pytest.param( + [ + make_reasoning_item(content_text="Let me think"), + {"type": "message", "role": "assistant", "content": "Hello"}, + ], + "Hello", + "Let me think", + None, + id="reasoning-easyinput-assistant", + ), ], ) def test_assistant_side_items_merge_until_tool_output( diff --git a/tests/entrypoints/openai/responses/test_serving_responses.py b/tests/entrypoints/openai/responses/test_serving_responses.py index 25b00ff19278..48b68e96d8b2 100644 --- a/tests/entrypoints/openai/responses/test_serving_responses.py +++ b/tests/entrypoints/openai/responses/test_serving_responses.py @@ -51,6 +51,7 @@ ) from vllm.inputs import tokens_input from vllm.outputs import CompletionOutput, RequestOutput +from vllm.parser.harmony import Segment from vllm.sampling_params import SamplingParams @@ -230,7 +231,7 @@ async def serving_responses_instance(self): instance = OpenAIServingResponses( engine_client=engine_client, models=models, - openai_serving_render=MagicMock(), + online_renderer=MagicMock(), request_logger=None, chat_template=None, chat_template_content_format="auto", @@ -316,7 +317,7 @@ async def serving_responses_instance(self): instance = OpenAIServingResponses( engine_client=engine_client, models=models, - openai_serving_render=MagicMock(), + online_renderer=MagicMock(), request_logger=None, chat_template=None, chat_template_content_format="auto", @@ -379,15 +380,22 @@ def get_vocab(self): serving = OpenAIServingResponses( engine_client=engine_client, models=models, - openai_serving_render=MagicMock(), + online_renderer=MagicMock(), request_logger=None, chat_template=None, chat_template_content_format="auto", reasoning_parser="qwen3", ) + request = ResponsesRequest(input="hi", tools=[], stream=False) + response_parser = serving._make_response_parser( + request, + tokenizer, + serving._effective_chat_template_kwargs(request), + ) + # Build a SimpleContext with thinking tokens in the output. - context = SimpleContext() + context = SimpleContext(response_parser=response_parser) token_ids = [1, 10, 2, 20] # 10 20 -> reasoning token count = 1 completion = CompletionOutput( index=0, @@ -412,7 +420,6 @@ def get_vocab(self): async def dummy_result_generator(): yield None - request = ResponsesRequest(input="hi", tools=[], stream=False) sampling_params = SamplingParams(max_tokens=16) metadata = RequestResponseMetadata(request_id="req") @@ -528,13 +535,9 @@ class TestHarmonyPreambleStreaming: """Tests for preamble (commentary with no recipient) streaming events.""" @staticmethod - def _make_ctx(*, channel, recipient, delta="hello"): - """Build a lightweight mock StreamingHarmonyContext.""" - ctx = MagicMock() - ctx.last_content_delta = delta - ctx.parser.current_channel = channel - ctx.parser.current_recipient = recipient - return ctx + def _make_segment(*, channel, recipient, delta="hello"): + """Build a lightweight segment for Harmony streaming tests.""" + return Segment(channel=channel, recipient=recipient, delta=delta) @staticmethod def _make_previous_item(*, channel, recipient, text="preamble text"): @@ -553,10 +556,10 @@ def test_preamble_delta_emits_text_events(self) -> None: emit_content_delta_events, ) - ctx = self._make_ctx(channel="commentary", recipient=None) + segment = self._make_segment(channel="commentary", recipient=None) state = StreamingState() - events = emit_content_delta_events(ctx, state) + events = emit_content_delta_events(segment, state) type_names = [e.type for e in events] assert "response.output_text.delta" in type_names @@ -568,13 +571,13 @@ def test_preamble_delta_second_token_no_added(self) -> None: emit_content_delta_events, ) - ctx = self._make_ctx(channel="commentary", recipient=None, delta="w") + segment = self._make_segment(channel="commentary", recipient=None, delta="w") state = StreamingState() state.sent_output_item_added = True state.current_item_id = "msg_test" state.current_content_index = 0 - events = emit_content_delta_events(ctx, state) + events = emit_content_delta_events(segment, state) type_names = [e.type for e in events] assert "response.output_text.delta" in type_names @@ -586,13 +589,13 @@ def test_commentary_with_function_recipient_not_preamble(self) -> None: emit_content_delta_events, ) - ctx = self._make_ctx( + segment = self._make_segment( channel="commentary", recipient="functions.get_weather", ) state = StreamingState() - events = emit_content_delta_events(ctx, state) + events = emit_content_delta_events(segment, state) type_names = [e.type for e in events] assert "response.output_text.delta" not in type_names @@ -606,6 +609,7 @@ def test_preamble_done_emits_text_done_events(self) -> None: previous = self._make_previous_item(channel="commentary", recipient=None) state = StreamingState() + state.sent_output_item_added = True state.current_item_id = "msg_test" state.current_output_index = 0 state.current_content_index = 0 @@ -628,17 +632,57 @@ def test_commentary_with_recipient_no_preamble_done(self) -> None: channel="commentary", recipient="functions.get_weather" ) state = StreamingState() + state.is_first_function_call_delta = True state.current_item_id = "fc_test" + state.current_call_id = "call_test" events = emit_previous_item_done_events(previous, state) type_names = [e.type for e in events] assert "response.output_text.done" not in type_names + @pytest.mark.xfail( + reason=( + "TODO: Ensure added/in-progress events are emitted for zero-delta items." + "So we can safely emit done events for zero-delta items." + ), + strict=True, + ) + def test_zero_delta_items_should_preserve_streaming_lifecycle( + self, + ) -> None: + """Zero-delta Harmony items should still produce a coherent lifecycle.""" + from vllm.entrypoints.openai.responses.streaming_events import ( + emit_previous_item_done_events, + ) + + cases: list[tuple[str, str | None, str]] = [ + ("commentary", None, "msg_stale"), + ("analysis", None, "msg_stale"), + ("commentary", "functions.get_weather", "fc_stale"), + ("commentary", "python", "tool_stale"), + ("commentary", "repo_browser.list", "mcp_stale"), + ] + + for channel, recipient, current_item_id in cases: + previous = self._make_previous_item(channel=channel, recipient=recipient) + state = StreamingState() + state.current_item_id = current_item_id + state.current_call_id = "call_stale" + state.current_content_index = 0 + + events = emit_previous_item_done_events( + previous, state, function_tool_names=None + ) + + type_names = [e.type for e in events] + assert "response.output_item.added" in type_names + assert "response.output_item.done" in type_names + -def _make_simple_context_with_output(text, token_ids): +def _make_simple_context_with_output(text, token_ids, response_parser=None): """Create a SimpleContext with a RequestOutput containing the given text.""" - ctx = SimpleContext() + ctx = SimpleContext(response_parser=response_parser) completion = CompletionOutput( index=0, text=text, @@ -678,7 +722,7 @@ def _make_serving_instance_with_reasoning(): serving = OpenAIServingResponses( engine_client=engine_client, models=models, - openai_serving_render=MagicMock(), + online_renderer=MagicMock(), request_logger=None, chat_template=None, chat_template_content_format="auto", @@ -719,6 +763,7 @@ def mock_parse_delta(**kwargs): mock_parser_instance.parse_delta = mock_parse_delta mock_parser_instance.is_reasoning_end = MagicMock(return_value=False) serving.parser = MagicMock(return_value=mock_parser_instance) + return mock_parser_instance class TestStreamingReasoningToContentTransition: @@ -745,12 +790,12 @@ async def test_mixed_delta_reasoning_and_content_emits_reasoning_delta( DeltaMessage(reasoning=" end", content="hello"), # mixed delta DeltaMessage(content=" world"), ] - _mock_parser_with_reasoning(serving, delta_sequence) + response_parser = _mock_parser_with_reasoning(serving, delta_sequence) # Create contexts for each streaming chunk contexts = [ - _make_simple_context_with_output("chunk1", [10]), - _make_simple_context_with_output("chunk2", [20]), - _make_simple_context_with_output("chunk3", [30]), + _make_simple_context_with_output("chunk1", [10], response_parser), + _make_simple_context_with_output("chunk2", [20], response_parser), + _make_simple_context_with_output("chunk3", [30], response_parser), ] async def result_generator(): @@ -767,7 +812,7 @@ async def result_generator(): request=request, sampling_params=sampling_params, result_generator=result_generator(), - context=SimpleContext(), + context=SimpleContext(response_parser=response_parser), model_name="test-model", tokenizer=MagicMock(), request_metadata=metadata, @@ -813,11 +858,11 @@ async def test_transition_without_mixed_delta_no_extra_reasoning_event( DeltaMessage(reasoning="thinking"), DeltaMessage(content="answer"), ] - _mock_parser_with_reasoning(serving, delta_sequence) + response_parser = _mock_parser_with_reasoning(serving, delta_sequence) contexts = [ - _make_simple_context_with_output("chunk1", [10]), - _make_simple_context_with_output("chunk2", [20]), + _make_simple_context_with_output("chunk1", [10], response_parser), + _make_simple_context_with_output("chunk2", [20], response_parser), ] async def result_generator(): @@ -834,7 +879,7 @@ async def result_generator(): request=request, sampling_params=sampling_params, result_generator=result_generator(), - context=SimpleContext(), + context=SimpleContext(response_parser=response_parser), model_name="test-model", tokenizer=MagicMock(), request_metadata=metadata, @@ -875,11 +920,11 @@ async def test_reasoning_only_stream_no_content(self, monkeypatch): DeltaMessage(reasoning="step 1"), DeltaMessage(reasoning=" step 2"), ] - _mock_parser_with_reasoning(serving, delta_sequence) + response_parser = _mock_parser_with_reasoning(serving, delta_sequence) contexts = [ - _make_simple_context_with_output("chunk1", [10]), - _make_simple_context_with_output("chunk2", [20]), + _make_simple_context_with_output("chunk1", [10], response_parser), + _make_simple_context_with_output("chunk2", [20], response_parser), ] async def result_generator(): @@ -896,7 +941,7 @@ async def result_generator(): request=request, sampling_params=sampling_params, result_generator=result_generator(), - context=SimpleContext(), + context=SimpleContext(response_parser=response_parser), model_name="test-model", tokenizer=MagicMock(), request_metadata=metadata, @@ -936,10 +981,10 @@ class TestAutoToolStreaming: @staticmethod async def _collect_events(delta_sequence: list[DeltaMessage]): serving = _make_serving_instance_with_reasoning() - _mock_parser_with_reasoning(serving, delta_sequence) + response_parser = _mock_parser_with_reasoning(serving, delta_sequence) contexts = [ - _make_simple_context_with_output("chunk", [i]) + _make_simple_context_with_output("chunk", [i], response_parser) for i in range(len(delta_sequence)) ] @@ -974,7 +1019,7 @@ async def result_generator(): request=request, sampling_params=sampling_params, result_generator=result_generator(), - context=SimpleContext(), + context=SimpleContext(response_parser=response_parser), model_name="test-model", tokenizer=MagicMock(), request_metadata=metadata, diff --git a/tests/entrypoints/openai/test_cli_args.py b/tests/entrypoints/openai/test_cli_args.py index 58dd328b325a..1f764202e55e 100644 --- a/tests/entrypoints/openai/test_cli_args.py +++ b/tests/entrypoints/openai/test_cli_args.py @@ -206,6 +206,14 @@ def test_chat_template_validation_for_sad_paths(serve_parser): validate_parsed_serve_args(args) +def test_per_request_metrics_requires_log_stats(serve_parser): + args = serve_parser.parse_args( + args=["--enable-per-request-metrics", "--disable-log-stats"] + ) + with pytest.raises(ValueError): + validate_parsed_serve_args(args) + + @pytest.mark.parametrize( "cli_args, expected_middleware", [ diff --git a/tests/entrypoints/openai/test_dp_supervisor.py b/tests/entrypoints/openai/test_dp_supervisor.py index 9967e6d86d0a..abe0cac890fd 100644 --- a/tests/entrypoints/openai/test_dp_supervisor.py +++ b/tests/entrypoints/openai/test_dp_supervisor.py @@ -176,6 +176,49 @@ def test_build_multi_port_external_lb_child_args_sets_external_rank_server(): assert child_args.api_server_count == 1 +def test_run_vllm_dp_server_uses_python_server_by_default(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(dp_sup.os, "setpgrp", lambda: None) + monkeypatch.setattr(dp_sup, "set_process_title", lambda *_args: None) + monkeypatch.setattr(dp_sup, "decorate_logs", lambda *_args: None) + monkeypatch.setattr(dp_sup.envs, "VLLM_RUST_FRONTEND_PATH", None, raising=False) + monkeypatch.setattr( + dp_sup, "_run_python_vllm_dp_server", lambda _args: calls.append("python") + ) + monkeypatch.setattr( + dp_sup, "_run_rust_vllm_dp_server", lambda _args: calls.append("rust") + ) + + dp_sup._run_vllm_dp_server(_make_unit_args(data_parallel_rank=4)) + + assert calls == ["python"] + + +def test_run_vllm_dp_server_uses_rust_frontend_when_enabled(monkeypatch): + calls: list[str] = [] + + monkeypatch.setattr(dp_sup.os, "setpgrp", lambda: None) + monkeypatch.setattr(dp_sup, "set_process_title", lambda *_args: None) + monkeypatch.setattr(dp_sup, "decorate_logs", lambda *_args: None) + monkeypatch.setattr( + dp_sup.envs, + "VLLM_RUST_FRONTEND_PATH", + "/tmp/vllm-rs", + raising=False, + ) + monkeypatch.setattr( + dp_sup, "_run_python_vllm_dp_server", lambda _args: calls.append("python") + ) + monkeypatch.setattr( + dp_sup, "_run_rust_vllm_dp_server", lambda _args: calls.append("rust") + ) + + dp_sup._run_vllm_dp_server(_make_unit_args(data_parallel_rank=4)) + + assert calls == ["rust"] + + def test_validate_multi_port_external_lb_args_allows_ssl(): args = _make_unit_args( ssl_keyfile="/tmp/server.key", @@ -364,7 +407,7 @@ def _custom_handle_exit(sig: int, frame: object) -> None: await self._serve_task -def launch_mock_vllm(child_args: argparse.Namespace, env_updates: dict[str, str]): +def launch_mock_vllm(child_args: argparse.Namespace): logger.info("Launching mock vLLM on port %s", child_args.port) mock_vllm = MockVLLMServer( port=child_args.port, @@ -375,7 +418,7 @@ def launch_mock_vllm(child_args: argparse.Namespace, env_updates: dict[str, str] def launch_mock_vllm_with_drain( - child_args: argparse.Namespace, env_updates: dict[str, str] + child_args: argparse.Namespace, ): logger.info("Launching mock vLLM with 15s drain on port %s", child_args.port) mock_vllm = MockVLLMServer( diff --git a/tests/entrypoints/openai/test_openai_schema.py b/tests/entrypoints/openai/test_openai_schema.py index 56e4e9baf2e8..38ea2661c861 100644 --- a/tests/entrypoints/openai/test_openai_schema.py +++ b/tests/entrypoints/openai/test_openai_schema.py @@ -6,15 +6,22 @@ import pytest import schemathesis from hypothesis import HealthCheck, settings -from schemathesis import GenerationConfig -from schemathesis.models import Case +from schemathesis import GenerationMode +from schemathesis.config import ( + ChecksConfig, + CoveragePhaseConfig, + GenerationConfig, + PhasesConfig, + PositiveDataAcceptanceConfig, + ProjectConfig, + ProjectsConfig, + SchemathesisConfig, +) from vllm.platforms import current_platform from ...utils import RemoteOpenAIServer -schemathesis.experimental.OPEN_API_3_1.enable() - MODEL_NAME = "HuggingFaceTB/SmolVLM-256M-Instruct" MAXIMUM_IMAGES = 2 _ROCM_TIMEOUT_MULTIPLIER = 3 if current_platform.is_rocm() else 1 @@ -44,21 +51,38 @@ def server(): @pytest.fixture(scope="module") def get_schema(server): # avoid generating null (\x00) bytes in strings during test case generation - return schemathesis.openapi.from_uri( + return schemathesis.openapi.from_url( f"{server.url_root}/openapi.json", - generation_config=GenerationConfig(allow_x00=False), + config=SchemathesisConfig( + projects=ProjectsConfig( + default=ProjectConfig( + generation=GenerationConfig( + allow_x00=False, + modes=[GenerationMode.POSITIVE], + ), + checks=ChecksConfig( + positive_data_acceptance=PositiveDataAcceptanceConfig( + enabled=False, + ), + ), + phases=PhasesConfig( + coverage=CoveragePhaseConfig(enabled=False), + ), + ), + ), + ), ) -schema = schemathesis.from_pytest_fixture("get_schema") +schema = schemathesis.pytest.from_fixture("get_schema") @schemathesis.hook -def before_generate_case(context: schemathesis.hooks.HookContext, strategy): +def before_generate_case(context: schemathesis.HookContext, strategy): op = context.operation assert op is not None - def no_invalid_types(case: schemathesis.models.Case): + def no_invalid_types(case: schemathesis.Case): """ Skips tool_calls with `"type": "custom"` which schemathesis incorrectly generates instead of the valid `"type": "function"`. @@ -68,39 +92,25 @@ def no_invalid_types(case: schemathesis.models.Case): -d '{"messages": [{"role": "assistant", "tool_calls": [{"custom": {"input": "", "name": ""}, "id": "", "type": "custom"}]}]}' \ http://localhost:8000/v1/chat/completions """ # noqa: E501 - if hasattr(case, "body") and isinstance(case.body, dict): - if ( - "messages" in case.body - and isinstance(case.body["messages"], list) - and len(case.body["messages"]) > 0 - ): - for message in case.body["messages"]: - if not isinstance(message, dict): - continue - - tool_calls = message.get("tool_calls", []) - if isinstance(tool_calls, list): - for tool_call in tool_calls: - if isinstance(tool_call, dict): - if tool_call.get("type") != "function": - return False - if "custom" in tool_call: - return False - - # Sometimes structured_outputs.grammar is generated to be empty - # Causing a server error in EBNF grammar parsing - # https://github.com/vllm-project/vllm/pull/22587#issuecomment-3195253421 - structured_outputs = case.body.get("structured_outputs", {}) - grammar = ( - structured_outputs.get("grammar") - if isinstance(structured_outputs, dict) - else None - ) - - if grammar == "": - # Allow None (will be handled as no grammar) - # But skip empty strings - return False + if ( + hasattr(case, "body") + and isinstance(case.body, dict) + and "messages" in case.body + and isinstance(case.body["messages"], list) + and len(case.body["messages"]) > 0 + ): + for message in case.body["messages"]: + if not isinstance(message, dict): + continue + + tool_calls = message.get("tool_calls", []) + if isinstance(tool_calls, list): + for tool_call in tool_calls: + if isinstance(tool_call, dict): + if tool_call.get("type") != "function": + return False + if "custom" in tool_call: + return False return True @@ -108,7 +118,6 @@ def no_invalid_types(case: schemathesis.models.Case): @schema.parametrize() -@schema.override(headers={"Content-Type": "application/json"}) @settings( deadline=LONG_TIMEOUT_SECONDS * 1000, max_examples=50, @@ -122,7 +131,7 @@ def no_invalid_types(case: schemathesis.models.Case): # generating large-but-valid request bodies before vLLM is called. suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.data_too_large], ) -def test_openapi_stateless(case: Case): +def test_openapi_stateless(case: schemathesis.Case): key = ( case.operation.method.upper(), case.operation.path, @@ -151,4 +160,8 @@ def test_openapi_stateless(case: Case): }.get(key, DEFAULT_TIMEOUT_SECONDS) # No need to verify SSL certificate for localhost - case.call_and_validate(verify=False, timeout=timeout) + case.call_and_validate( + verify=False, + timeout=timeout, + headers={"Content-Type": "application/json"}, + ) diff --git a/tests/entrypoints/openai/test_render_token_offsets.py b/tests/entrypoints/openai/test_render_token_offsets.py new file mode 100644 index 000000000000..a2e66b7bd6ce --- /dev/null +++ b/tests/entrypoints/openai/test_render_token_offsets.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for the token-offsets request/response protocol wiring: +the request flag flowing into ``TokenizeParams`` and the ``GenerateRequest`` +serialization boundary. End-to-end behavior is covered by +``tests/entrypoints/scale_out/render/test_render.py``; plain Pydantic field +storage is not retested here. +""" + +from unittest.mock import Mock + +from vllm.config import ModelConfig +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.completion.protocol import CompletionRequest +from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateRequest +from vllm.sampling_params import SamplingParams + + +def _model_config() -> Mock: + model_config = Mock(spec=ModelConfig) + model_config.max_model_len = 128 + return model_config + + +def test_completion_flag_forwarded_to_tok_params(): + """build_tok_params must forward return_token_offsets, defaulting to + False (zero behavioral change for existing callers) and coercing JSON + null to False via the bool() guard.""" + cfg = _model_config() + + default = CompletionRequest(model="m", prompt="hi") + assert default.build_tok_params(cfg).return_token_offsets is False + + on = CompletionRequest(model="m", prompt="hi", return_token_offsets=True) + assert on.build_tok_params(cfg).return_token_offsets is True + + null = CompletionRequest(model="m", prompt="hi", return_token_offsets=None) + assert null.build_tok_params(cfg).return_token_offsets is False + + +def test_chat_flag_forwarded_to_tok_params(): + """Chat build_tok_params has its own (max_completion_tokens) branch, so + its return_token_offsets forwarding is verified independently.""" + cfg = _model_config() + messages = [{"role": "user", "content": "hi"}] + + default = ChatCompletionRequest(model="m", messages=messages) + assert default.build_tok_params(cfg).return_token_offsets is False + + on = ChatCompletionRequest(model="m", messages=messages, return_token_offsets=True) + assert on.build_tok_params(cfg).return_token_offsets is True + + null = ChatCompletionRequest( + model="m", messages=messages, return_token_offsets=None + ) + assert null.build_tok_params(cfg).return_token_offsets is False + + +def test_generate_request_token_offsets_default_none(): + """Defaults to None so existing /v1/.../render responses are unchanged.""" + req = GenerateRequest(token_ids=[1, 2, 3], sampling_params=SamplingParams()) + assert req.token_offsets is None + + +def test_generate_request_token_offsets_survive_json_round_trip(): + """GenerateRequest crosses the disagg serialization boundary; the + tuple[int, int] offsets must survive model_dump and re-validate.""" + req = GenerateRequest( + token_ids=[10, 20], + sampling_params=SamplingParams(), + token_offsets=[(0, 1), (1, 3)], + ) + dumped = req.model_dump() + assert dumped["token_offsets"] == [(0, 1), (1, 3)] + # Re-validate from the dumped dict (sampling_params doesn't round-trip + # cleanly via dump, so re-inject a fresh instance). + again = GenerateRequest.model_validate( + {**dumped, "sampling_params": SamplingParams()} + ) + assert again.token_offsets == [(0, 1), (1, 3)] diff --git a/tests/entrypoints/openai/test_return_tokens_as_ids.py b/tests/entrypoints/openai/test_return_tokens_as_ids.py index 2a311cc5c8d1..170bbd420cc0 100644 --- a/tests/entrypoints/openai/test_return_tokens_as_ids.py +++ b/tests/entrypoints/openai/test_return_tokens_as_ids.py @@ -127,13 +127,13 @@ def test_responses_api_logprobs_with_return_tokens_as_token_ids(): """Test that return_tokens_as_token_ids works in Responses API logprobs.""" from unittest.mock import MagicMock - from vllm.entrypoints.openai.engine.serving import OpenAIServing + from vllm.entrypoints.generate.base.serving import GenerateBaseServing from vllm.entrypoints.openai.responses.serving import OpenAIServingResponses from vllm.logprobs import Logprob as SampleLogprob serving = MagicMock(spec=OpenAIServingResponses) serving.return_tokens_as_token_ids = True - serving._get_decoded_token = OpenAIServing._get_decoded_token + serving._get_decoded_token = GenerateBaseServing._get_decoded_token tokenizer = MagicMock() tokenizer.decode = lambda token_id: "decoded" diff --git a/tests/entrypoints/openai/test_run_batch.py b/tests/entrypoints/openai/test_run_batch.py index cd1daf0bbbc2..0f7d7f5f464a 100644 --- a/tests/entrypoints/openai/test_run_batch.py +++ b/tests/entrypoints/openai/test_run_batch.py @@ -305,6 +305,7 @@ "body": { "model": SPEECH_LARGE_MODEL_NAME, "file_url": AudioAsset("mary_had_lamb").url, + "language": "en", "response_format": "json", }, } diff --git a/tests/entrypoints/openai/test_stop_token_ids.py b/tests/entrypoints/openai/test_stop_token_ids.py new file mode 100644 index 000000000000..74eba026ed99 --- /dev/null +++ b/tests/entrypoints/openai/test_stop_token_ids.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +Unit tests for stop_token_ids propagation from default_sampling_params +to SamplingParams in ChatCompletionRequest and CompletionRequest. + +Regression test for https://github.com/vllm-project/vllm/issues/22519 +where gpt-oss model stop tokens (e.g., = 200012) were loaded into +default_sampling_params at server startup but silently discarded on every +request because to_sampling_params() never fell back to defaults. +""" + +import pytest + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, +) +from vllm.entrypoints.openai.completion.protocol import ( + CompletionRequest, +) + + +class TestChatCompletionStopTokenIds: + """Test stop_token_ids merging in ChatCompletionRequest.to_sampling_params().""" + + @pytest.fixture + def minimal_chat_request(self): + return ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + ) + + def test_default_stop_token_ids_applied(self, minimal_chat_request): + """Server-default stop_token_ids are applied when client sends none.""" + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = minimal_chat_request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002} + + def test_client_stop_token_ids_merged_with_defaults(self): + """Client-specified stop_token_ids are merged with server defaults.""" + request = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + stop_token_ids=[99999], + ) + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002, 99999} + assert sampling_params.stop_token_ids == [99999, 200012, 200002] + + def test_no_stop_token_ids_anywhere(self, minimal_chat_request): + """When neither client nor server specifies stop_token_ids, result is empty.""" + sampling_params = minimal_chat_request.to_sampling_params( + max_tokens=100, + default_sampling_params={}, + ) + + assert not sampling_params.stop_token_ids + + def test_only_client_stop_token_ids(self): + """Client stop_token_ids work when no server defaults exist.""" + request = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + stop_token_ids=[42, 43], + ) + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params={}, + ) + + assert set(sampling_params.stop_token_ids) == {42, 43} + + def test_duplicate_stop_token_ids_deduplicated(self): + """Overlapping stop_token_ids between client and server are deduplicated.""" + request = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + stop_token_ids=[200012, 55555], + ) + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002, 55555} + assert sampling_params.stop_token_ids == [200012, 55555, 200002] + assert len(sampling_params.stop_token_ids) == 3 + + +class TestCompletionStopTokenIds: + """Test stop_token_ids merging in CompletionRequest.to_sampling_params().""" + + @pytest.fixture + def minimal_completion_request(self): + return CompletionRequest( + model="test-model", + prompt="hello", + ) + + def test_default_stop_token_ids_applied(self, minimal_completion_request): + """Server-default stop_token_ids are applied when client sends none.""" + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = minimal_completion_request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002} + + def test_client_stop_token_ids_merged_with_defaults(self): + """Client-specified stop_token_ids are merged with server defaults.""" + request = CompletionRequest( + model="test-model", + prompt="hello", + stop_token_ids=[99999], + ) + default_sampling_params = { + "stop_token_ids": [200012, 200002], + } + + sampling_params = request.to_sampling_params( + max_tokens=100, + default_sampling_params=default_sampling_params, + ) + + assert set(sampling_params.stop_token_ids) == {200012, 200002, 99999} + assert sampling_params.stop_token_ids == [99999, 200012, 200002] + + def test_no_stop_token_ids_anywhere(self, minimal_completion_request): + """When neither client nor server specifies stop_token_ids, result is empty.""" + sampling_params = minimal_completion_request.to_sampling_params( + max_tokens=100, + default_sampling_params={}, + ) + + assert not sampling_params.stop_token_ids diff --git a/tests/entrypoints/openai/test_tool_choice_content_none.py b/tests/entrypoints/openai/test_tool_choice_content_none.py index 75a5c578cca4..20faec5a53f5 100644 --- a/tests/entrypoints/openai/test_tool_choice_content_none.py +++ b/tests/entrypoints/openai/test_tool_choice_content_none.py @@ -2,8 +2,25 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import pytest - -from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from openai.types.chat.chat_completion import ChatCompletion as OpenAIChatCompletion +from openai.types.chat.chat_completion_chunk import ChatCompletionChunk + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ChatCompletionResponse, + ChatCompletionResponseChoice, + ChatCompletionResponseStreamChoice, + ChatCompletionStreamResponse, + ChatMessage, +) +from vllm.entrypoints.openai.engine.protocol import ( + DeltaFunctionCall, + DeltaMessage, + DeltaToolCall, + FunctionCall, + ToolCall, + UsageInfo, +) from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.parser.abstract_parser import DelegatingParser @@ -78,11 +95,109 @@ def test_responses_parser_allows_named_tool_choice_with_none_content(): ) parser = _DummyDelegatingParser(tokenizer=None) - tool_calls, content = parser._parse_tool_calls( - request=request, + tool_calls, content = parser._extract_tool_calls( content=None, + request=request, enable_auto_tools=False, ) assert content is None assert tool_calls == [] + + +def _chat_response(message: ChatMessage) -> ChatCompletionResponse: + return ChatCompletionResponse( + model="test-model", + choices=[ + ChatCompletionResponseChoice( + index=0, + message=message, + finish_reason="stop", + ) + ], + usage=UsageInfo(prompt_tokens=1, completion_tokens=1, total_tokens=2), + ) + + +def test_chat_completion_response_omits_empty_tool_calls_payload(): + response = _chat_response(ChatMessage(role="assistant", content="done")) + + payload = response.model_dump() + payload_exclude_unset = response.model_dump(exclude_unset=True) + + assert "tool_calls" not in payload["choices"][0]["message"] + assert "tool_calls" not in payload_exclude_unset["choices"][0]["message"] + parsed = OpenAIChatCompletion.model_validate(payload) + assert parsed.choices[0].message.tool_calls is None + + +def test_chat_completion_response_keeps_non_empty_tool_calls_payload(): + response = _chat_response( + ChatMessage( + role="assistant", + content="", + tool_calls=[ + ToolCall( + function=FunctionCall( + name="get_weather", + arguments='{"city": "Beijing"}', + ) + ) + ], + ) + ) + + message = response.model_dump()["choices"][0]["message"] + + assert len(message["tool_calls"]) == 1 + assert message["tool_calls"][0]["function"]["name"] == "get_weather" + + +def _stream_response(delta: DeltaMessage) -> ChatCompletionStreamResponse: + return ChatCompletionStreamResponse( + id="chatcmpl-test", + object="chat.completion.chunk", + created=1, + model="test-model", + choices=[ + ChatCompletionResponseStreamChoice( + index=0, + delta=delta, + finish_reason=None, + ) + ], + ) + + +def test_chat_completion_stream_response_omits_empty_tool_calls_payload(): + response = _stream_response(DeltaMessage(content="done")) + + payload = response.model_dump(exclude_unset=True) + payload_json = response.model_dump_json(exclude_unset=True) + + assert "tool_calls" not in payload["choices"][0]["delta"] + parsed = ChatCompletionChunk.model_validate_json(payload_json) + assert parsed.choices[0].delta.tool_calls is None + + +def test_chat_completion_stream_response_keeps_non_empty_tool_calls_payload(): + response = _stream_response( + DeltaMessage( + tool_calls=[ + DeltaToolCall( + index=0, + id="call-test", + type="function", + function=DeltaFunctionCall( + name="get_weather", + arguments='{"city": "Beijing"}', + ), + ) + ] + ) + ) + + delta = response.model_dump(exclude_unset=True)["choices"][0]["delta"] + + assert len(delta["tool_calls"]) == 1 + assert delta["tool_calls"][0]["function"]["name"] == "get_weather" diff --git a/tests/entrypoints/openai/test_uds.py b/tests/entrypoints/openai/test_uds.py index c79a4870dea3..f79e40ee4132 100644 --- a/tests/entrypoints/openai/test_uds.py +++ b/tests/entrypoints/openai/test_uds.py @@ -40,4 +40,5 @@ async def test_show_version(server: RemoteOpenAIServer): response = client.get(server.url_for("version")) response.raise_for_status() - assert response.json() == {"version": VLLM_VERSION} + # Tolerate additive fields (e.g. the Rust frontend reports its own version). + assert response.json()["version"] == VLLM_VERSION diff --git a/tests/entrypoints/openai/utils.py b/tests/entrypoints/openai/utils.py index a791cab2a0cf..36056a44d079 100644 --- a/tests/entrypoints/openai/utils.py +++ b/tests/entrypoints/openai/utils.py @@ -155,6 +155,8 @@ def verify_harmony_messages( assert msg.content[0].text == expected["content"] if "content_type" in expected: assert msg.content_type == expected["content_type"] + if "instructions" in expected: + assert msg.content[0].instructions == expected["instructions"] if "tool_definitions" in expected: # Check that the tool definitions match the expected list of tool names actual_tools = [t.name for t in msg.content[0].tools["functions"].tools] diff --git a/tests/entrypoints/pooling/classify/test_online_vision.py b/tests/entrypoints/pooling/classify/test_online_vision.py index 2776dc8d8065..ce60e01ebe38 100644 --- a/tests/entrypoints/pooling/classify/test_online_vision.py +++ b/tests/entrypoints/pooling/classify/test_online_vision.py @@ -25,7 +25,7 @@ def server(): "--runner", "pooling", "--max-model-len", - "5000", + "16384", "--enforce-eager", "--limit-mm-per-prompt", json.dumps({"video": MAXIMUM_VIDEOS}), @@ -143,4 +143,4 @@ def test_chat_video_url_request(server: RemoteOpenAIServer, model_name: str): assert output.model == model_name assert len(output.data) == 1 assert len(output.data[0].probs) == 2 - assert output.usage.prompt_tokens == 4807 + assert output.usage.prompt_tokens == 8993 diff --git a/tests/entrypoints/pooling/embed/test_io_processor.py b/tests/entrypoints/pooling/embed/test_io_processor.py index 341ccbd5f0c5..5a7a8aab2a60 100644 --- a/tests/entrypoints/pooling/embed/test_io_processor.py +++ b/tests/entrypoints/pooling/embed/test_io_processor.py @@ -3,6 +3,8 @@ """Unit tests for EmbedIOProcessor.""" import pytest +import torch +from pydantic import TypeAdapter, ValidationError from vllm import PoolingParams from vllm.entrypoints.pooling.embed.io_processor import EmbedIOProcessor @@ -10,8 +12,194 @@ CohereEmbedContent, CohereEmbedInput, CohereEmbedRequest, + EmbeddingBatchChatInputRequest, + EmbeddingBatchChatRequest, + EmbeddingChatInputRequest, + EmbeddingChatRequest, + EmbeddingCompletionRequest, + EmbeddingRequest, ) from vllm.entrypoints.pooling.typing import PoolingServeContext +from vllm.outputs import PoolingOutput, PoolingRequestOutput + + +class TestEmbeddingRequestParsing: + """Unit tests for OpenAI embedding request parsing.""" + + def test_input_messages_parses_as_chat_request(self): + request = TypeAdapter(EmbeddingRequest).validate_python( + { + "model": "test", + "input": [{"role": "user", "content": "hello"}], + "chat_template_kwargs": {"instruction": "Represent the query: "}, + } + ) + + assert isinstance(request, EmbeddingChatInputRequest) + assert request.input == [{"role": "user", "content": "hello"}] + assert request.messages == [{"role": "user", "content": "hello"}] + assert request.chat_template_kwargs == {"instruction": "Represent the query: "} + + def test_batched_input_messages_parses_as_batch_chat_input_request(self): + request = TypeAdapter(EmbeddingRequest).validate_python( + { + "model": "test", + "input": [ + [{"role": "user", "content": "hello"}], + [{"role": "user", "content": "goodbye"}], + ], + "chat_template_kwargs": {"instruction": "Represent the query: "}, + } + ) + + assert isinstance(request, EmbeddingBatchChatInputRequest) + assert request.input == [ + [{"role": "user", "content": "hello"}], + [{"role": "user", "content": "goodbye"}], + ] + assert request.messages == [ + [{"role": "user", "content": "hello"}], + [{"role": "user", "content": "goodbye"}], + ] + assert request.chat_template_kwargs == {"instruction": "Represent the query: "} + + def test_token_ids_still_parse_as_completion_request(self): + request = TypeAdapter(EmbeddingRequest).validate_python( + { + "model": "test", + "input": [[1, 2, 3], [4, 5]], + } + ) + + assert isinstance(request, EmbeddingCompletionRequest) + assert request.input == [[1, 2, 3], [4, 5]] + + def test_messages_still_parses_as_chat_request(self): + request = TypeAdapter(EmbeddingRequest).validate_python( + { + "model": "test", + "messages": [{"role": "user", "content": "hello"}], + "chat_template_kwargs": {"instruction": "Represent the query: "}, + } + ) + + assert isinstance(request, EmbeddingChatRequest) + assert request.messages == [{"role": "user", "content": "hello"}] + assert request.chat_template_kwargs == {"instruction": "Represent the query: "} + + def test_batched_messages_parses_as_batch_chat_request(self): + request = TypeAdapter(EmbeddingRequest).validate_python( + { + "model": "test", + "messages": [ + [{"role": "user", "content": "hello"}], + [{"role": "user", "content": "goodbye"}], + ], + "chat_template_kwargs": {"instruction": "Represent the query: "}, + } + ) + + assert isinstance(request, EmbeddingBatchChatRequest) + assert request.messages == [ + [{"role": "user", "content": "hello"}], + [{"role": "user", "content": "goodbye"}], + ] + assert request.chat_template_kwargs == {"instruction": "Represent the query: "} + + +class TestCohereEmbedRequestParsing: + """Unit tests for Cohere embed request parsing.""" + + @pytest.mark.parametrize( + "request_body", + [ + {"model": "test"}, + {"model": "test", "texts": ["hello"], "images": ["image-uri"]}, + { + "model": "test", + "texts": ["hello"], + "inputs": [ + {"content": [{"type": "text", "text": "hello"}]}, + ], + }, + { + "model": "test", + "images": ["image-uri"], + "inputs": [ + {"content": [{"type": "text", "text": "hello"}]}, + ], + }, + {"model": "test", "texts": []}, + {"model": "test", "images": []}, + {"model": "test", "inputs": []}, + ], + ) + def test_rejects_invalid_input_field_combinations(self, request_body): + with pytest.raises( + ValidationError, + match="Exactly one of texts, images, or inputs must be provided", + ): + CohereEmbedRequest(**request_body) + + @pytest.mark.parametrize( + "request_body", + [ + {"model": "test", "texts": ["hello"]}, + {"model": "test", "images": ["image-uri"]}, + { + "model": "test", + "inputs": [ + {"content": [{"type": "text", "text": "hello"}]}, + ], + }, + { + "model": "test", + "inputs": [ + { + "content": [ + {"type": "image_url", "image_url": {"url": "image-uri"}} + ] + }, + ], + }, + ], + ) + def test_accepts_exactly_one_non_empty_input_field(self, request_body): + request = CohereEmbedRequest(**request_body) + + assert request.model == "test" + + @pytest.mark.parametrize( + ("content", "error"), + [ + ( + {"type": "text"}, + "CohereEmbedContent with type='text' requires text", + ), + ( + {"type": "image_url"}, + "CohereEmbedContent with type='image_url' requires image_url.url", + ), + ( + {"type": "image_url", "image_url": {}}, + "CohereEmbedContent with type='image_url' requires image_url.url", + ), + ( + {"type": "image_url", "image_url": {"url": ""}}, + "CohereEmbedContent with type='image_url' requires image_url.url", + ), + ], + ) + def test_rejects_invalid_mixed_content_payloads(self, content, error): + with pytest.raises(ValidationError, match=error): + CohereEmbedRequest( + model="test", + inputs=[ + { + "content": [content], + }, + ], + ) class TestResolveTruncation: @@ -212,6 +400,96 @@ def test_error_lists_supported(self): handler._validate_input_type("z") +class TestChunkedEmbeddingProcessing: + """Unit tests for chunked embedding aggregation.""" + + class _FakeModelConfig: + max_model_len = 3 + + @classmethod + def _make_handler(cls): + handler = object.__new__(EmbedIOProcessor) + handler.model_config = cls._FakeModelConfig() + return handler + + @staticmethod + def _make_context() -> PoolingServeContext[EmbeddingCompletionRequest]: + request = TypeAdapter(EmbeddingRequest).validate_python( + { + "model": "test", + "input": [[0, 1, 2, 3, 4], [10, 11]], + } + ) + assert isinstance(request, EmbeddingCompletionRequest) + return PoolingServeContext( + request=request, + pooling_params=PoolingParams(), + model_name="test", + request_id="embd-client-prompt-999-chunk-888", + engine_inputs=[ + {"prompt_token_ids": [0, 1, 2, 3, 4]}, + {"prompt_token_ids": [10, 11]}, + ], + ) + + @staticmethod + def _make_output( + request_id: str, + prompt_token_ids: list[int], + embedding: list[float], + ) -> PoolingRequestOutput: + return PoolingRequestOutput( + request_id=request_id, + outputs=PoolingOutput(data=torch.tensor(embedding)), + prompt_token_ids=prompt_token_ids, + num_cached_tokens=0, + finished=True, + ) + + def test_aggregation_uses_metadata_not_request_id_parsing(self): + handler = self._make_handler() + ctx = self._make_context() + + handler._pre_process_chunked(ctx) + + assert ctx.prompt_request_ids == [ + "embd-client-prompt-999-chunk-888-prompt-0-chunk-0", + "embd-client-prompt-999-chunk-888-prompt-0-chunk-1", + "embd-client-prompt-999-chunk-888-prompt-1-chunk-0", + ] + assert ctx.chunked_embedding_metadata is not None + assert [ + (item.prompt_index, item.chunk_index) + for item in ctx.chunked_embedding_metadata + ] == [(0, 0), (0, 1), (1, 0)] + + ctx.final_res_batch = [ + self._make_output(ctx.prompt_request_ids[0], [0, 1, 2], [1.0, 1.0]), + self._make_output(ctx.prompt_request_ids[1], [3, 4], [4.0, 7.0]), + self._make_output(ctx.prompt_request_ids[2], [10, 11], [9.0, 9.0]), + ] + + handler._post_process_chunked(ctx) + + assert len(ctx.final_res_batch) == 2 + assert ctx.final_res_batch[0].request_id == ( + "embd-client-prompt-999-chunk-888-prompt-0" + ) + assert ctx.final_res_batch[0].prompt_token_ids == [0, 1, 2, 3, 4] + assert torch.allclose( + ctx.final_res_batch[0].outputs.data, + torch.tensor([2.2, 3.4]), + ) + assert ctx.final_res_batch[1].request_id == ( + "embd-client-prompt-999-chunk-888-prompt-1" + ) + assert ctx.final_res_batch[1].prompt_token_ids == [10, 11] + assert torch.allclose( + ctx.final_res_batch[1].outputs.data, + torch.tensor([9.0, 9.0]), + ) + + class TestPreProcessCohereOnline: """Unit tests for EmbedIOProcessor._pre_process_cohere_online.""" @@ -242,8 +520,8 @@ def preprocess_cmpl_online(request, prompt_input, prompt_embeds): handler._get_task_instruction_prefix = lambda _input_type: None handler._has_chat_template = lambda: False handler._preprocess_cmpl_online = preprocess_cmpl_online - handler._batch_render_chat = lambda *_args, **_kwargs: ( - pytest.fail("text-only request should not require chat rendering") + handler._batch_render_chat = lambda *_args, **_kwargs: pytest.fail( + "text-only request should not require chat rendering" ) handler._pre_process_cohere_online(ctx) @@ -262,8 +540,8 @@ def preprocess_cmpl(request, prompt_input, prompt_embeds): handler._get_task_instruction_prefix = lambda _input_type: "query: " handler._has_chat_template = lambda: False - handler._batch_render_chat = lambda *_args, **_kwargs: ( - pytest.fail("chat rendering should be skipped without a template") + handler._batch_render_chat = lambda *_args, **_kwargs: pytest.fail( + "chat rendering should be skipped without a template" ) handler._preprocess_cmpl_online = preprocess_cmpl @@ -299,8 +577,8 @@ def batch_render_chat( handler._get_task_instruction_prefix = lambda _input_type: "query: " handler._has_chat_template = lambda: True handler._batch_render_chat = batch_render_chat - handler._preprocess_cmpl_online = lambda *_args, **_kwargs: ( - pytest.fail("completion path should be skipped when a template exists") + handler._preprocess_cmpl_online = lambda *_args, **_kwargs: pytest.fail( + "completion path should be skipped when a template exists" ) handler._pre_process_cohere_online(ctx) @@ -324,3 +602,113 @@ def batch_render_chat( }, ) ] + + +class TestPreProcessOpenAIEmbeddingChatOnline: + """Unit tests for OpenAI embedding chat preprocessing.""" + + class _FakeModelConfig: + max_model_len = 128 + encoder_config: dict[str, object] = {} + pooler_config = None + multimodal_config = None + is_encoder_decoder = False + + class _FakeRenderer: + tokenizer = object() + + def __init__(self): + self.calls = [] + + def render_chat( + self, + all_messages, + chat_params, + tok_params, + prompt_extras=None, + ): + self.calls.append( + { + "all_messages": all_messages, + "chat_params": chat_params, + "tok_params": tok_params, + "prompt_extras": prompt_extras, + } + ) + return all_messages, [ + {"prompt_token_ids": [index]} for index, _ in enumerate(all_messages) + ] + + @classmethod + def _make_handler(cls, renderer): + handler = object.__new__(EmbedIOProcessor) + handler.renderer = renderer + handler.model_config = cls._FakeModelConfig() + handler.chat_template = "template" + handler.chat_template_content_format = "auto" + handler.trust_request_chat_template = False + handler.enable_chunked_processing = False + return handler + + @staticmethod + def _make_context( + request: ( + EmbeddingChatRequest + | EmbeddingBatchChatRequest + | EmbeddingChatInputRequest + | EmbeddingBatchChatInputRequest + ), + ) -> PoolingServeContext[ + EmbeddingChatRequest + | EmbeddingBatchChatRequest + | EmbeddingChatInputRequest + | EmbeddingBatchChatInputRequest + ]: + return PoolingServeContext( + request=request, + pooling_params=PoolingParams(), + model_name="test", + request_id="embd-test", + ) + + def test_chat_template_kwargs_forwarded_for_batched_input_messages(self): + request = TypeAdapter(EmbeddingRequest).validate_python( + { + "model": "test", + "input": [ + [{"role": "user", "content": "hello"}], + [{"role": "user", "content": "goodbye"}], + ], + "add_generation_prompt": True, + "chat_template_kwargs": {"instruction": "Represent the query: "}, + "mm_processor_kwargs": {"max_pixels": 1}, + "cache_salt": "salt", + } + ) + assert isinstance(request, EmbeddingBatchChatInputRequest) + + renderer = self._FakeRenderer() + handler = self._make_handler(renderer) + ctx = self._make_context(request) + + handler.pre_process_online(ctx) + + assert ctx.engine_inputs == [ + {"prompt_token_ids": [0]}, + {"prompt_token_ids": [1]}, + ] + assert len(renderer.calls) == 1 + + call = renderer.calls[0] + assert call["all_messages"] == request.messages + assert call["prompt_extras"] == { + "mm_processor_kwargs": {"max_pixels": 1}, + "cache_salt": "salt", + } + + chat_template_kwargs = call["chat_params"].chat_template_kwargs + assert chat_template_kwargs["instruction"] == "Represent the query: " + assert chat_template_kwargs["add_generation_prompt"] is True + assert chat_template_kwargs["continue_final_message"] is False + assert "tools" not in chat_template_kwargs + assert chat_template_kwargs["tokenize"] is False diff --git a/tests/entrypoints/pooling/embed/test_online.py b/tests/entrypoints/pooling/embed/test_online.py index d5565f25d37c..96555ee363a7 100644 --- a/tests/entrypoints/pooling/embed/test_online.py +++ b/tests/entrypoints/pooling/embed/test_online.py @@ -369,7 +369,7 @@ async def test_chat_request( assert output.object == "list" assert len(output.data) == 1 assert output.model == MODEL_NAME - assert output.usage.prompt_tokens == 34 + assert output.usage.prompt_tokens == 33 # test continue_final_message response = requests.post( @@ -401,7 +401,7 @@ async def test_chat_request( assert output.object == "list" assert len(output.data) == 1 assert output.model == MODEL_NAME - assert output.usage.prompt_tokens == 36 + assert output.usage.prompt_tokens == 35 # test continue_final_message with add_generation_prompt response = requests.post( diff --git a/tests/entrypoints/pooling/reward/test_token_reward_offline.py b/tests/entrypoints/pooling/reward/test_token_reward_offline.py index b061b5514515..50a4b54682b0 100644 --- a/tests/entrypoints/pooling/reward/test_token_reward_offline.py +++ b/tests/entrypoints/pooling/reward/test_token_reward_offline.py @@ -45,9 +45,10 @@ def test_config(llm: LLM): def test_pooling_params(llm: LLM): def get_outputs(use_activation): - outputs = llm.reward( + outputs = llm.encode( prompts, pooling_params=PoolingParams(use_activation=use_activation), + pooling_task="token_classify", use_tqdm=False, ) return torch.cat([x.outputs.data for x in outputs]) diff --git a/tests/entrypoints/pooling/scoring/test_cross_encoder_offline.py b/tests/entrypoints/pooling/scoring/test_cross_encoder_offline.py index 56e83de3f74f..df79a387afd2 100644 --- a/tests/entrypoints/pooling/scoring/test_cross_encoder_offline.py +++ b/tests/entrypoints/pooling/scoring/test_cross_encoder_offline.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import weakref +from types import SimpleNamespace import pytest import torch @@ -9,7 +10,10 @@ from tests.models.utils import softmax from vllm import LLM, PoolingParams from vllm.distributed import cleanup_dist_env_and_memory +from vllm.entrypoints.pooling.scoring.io_processor import CrossEncoderIOProcessor +from vllm.entrypoints.pooling.scoring.typing import ScoringData from vllm.platforms import current_platform +from vllm.renderers import TokenizeParams MODEL_NAME = "tomaarsen/Qwen3-Reranker-0.6B-seq-cls" PROMPT = "The chef prepared a delicious meal." @@ -141,6 +145,45 @@ def test_max_tokens_per_doc(llm: LLM): assert with_limit_tokens < no_limit_tokens +def test_token_type_ids_follow_post_tokenization(): + processor = object.__new__(CrossEncoderIOProcessor) + processor.tokenizer = SimpleNamespace(truncation_side="right", pad_token_id=-1) + processor.renderer = SimpleNamespace(process_for_engine=lambda prompt, _: prompt) + processor.model_config = None + processor.get_score_prompt = lambda **_: ( + "", + { + "prompt_token_ids": list(range(32)), + "token_type_ids": [0] * 16 + [1] * 16, + }, + ) + + engine_inputs, pooling_params = processor._pre_process( + ScoringData(data_1=["query"], data_2=["document"]), + TokenizeParams( + max_total_tokens=None, + truncate_prompt_tokens=16, + truncation_side="left", + ), + PoolingParams(task="classify", extra_kwargs={"cache_salt": "salt"}), + ) + + assert engine_inputs[0]["prompt_token_ids"] == list(range(16, 32)) + assert pooling_params[0].extra_kwargs == { + "cache_salt": "salt", + "compressed_token_type_ids": 0, + } + + engine_inputs, pooling_params = processor._pre_process( + ScoringData(data_1=["query"], data_2=["document"]), + TokenizeParams(max_total_tokens=None, pad_prompt_tokens=40), + PoolingParams(task="classify"), + ) + + assert engine_inputs[0]["prompt_token_ids"] == list(range(32)) + [-1] * 8 + assert pooling_params[0].extra_kwargs == {"compressed_token_type_ids": 16} + + def test_pooling_params(llm: LLM): def get_outputs(use_activation): outputs = llm.score( diff --git a/tests/entrypoints/pooling/scoring/test_cross_encoder_online_vision.py b/tests/entrypoints/pooling/scoring/test_cross_encoder_online_vision.py index e6b4d3f873e0..c6663dbdff00 100644 --- a/tests/entrypoints/pooling/scoring/test_cross_encoder_online_vision.py +++ b/tests/entrypoints/pooling/scoring/test_cross_encoder_online_vision.py @@ -38,12 +38,17 @@ "FLEX_ATTENTION": 0.045, # gfx950:~3.25%, gfx942:~1.10% } -# ROCm 7.2/gfx950 shows small absolute drift on the low text-vs-text -# probability even though larger scores remain well inside the relative -# tolerance. Keep the relative tolerances tight and add only a small floor. +# Some ROCm attention backends show small absolute drift on the low +# text-vs-text probability even though larger scores remain well inside the +# relative tolerance. The absolute drift is uniform across score magnitudes +# (~0.005-0.010), so it only exceeds the relative tolerance for the small +# ~0.10 text-vs-text value. Keep the relative tolerances tight and add only a +# small absolute floor for the affected backends. +# TRITON_ATTN: gfx942/ROCm 7.2 drifts ~0.008 abs on text-vs-text (~7.9% rel). BACKEND_ABS_TOL: dict[str, float] = { "default": 0.0, "ROCM_AITER_FA": 0.005, + "TRITON_ATTN": 0.009, "FLEX_ATTENTION": 0.006, } diff --git a/tests/entrypoints/sagemaker/test_sagemaker_handler_overrides.py b/tests/entrypoints/sagemaker/test_sagemaker_handler_overrides.py deleted file mode 100644 index 0d4f8e885824..000000000000 --- a/tests/entrypoints/sagemaker/test_sagemaker_handler_overrides.py +++ /dev/null @@ -1,734 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -"""Integration tests for handler override functionality. - -Tests real customer usage scenarios: -- Using @custom_ping_handler and @custom_invocation_handler decorators - to override handlers -- Setting environment variables for handler specifications -- Writing customer scripts with custom_sagemaker_ping_handler() and - custom_sagemaker_invocation_handler() functions -- Priority: env vars > decorators > customer script files > framework - defaults - -Note: These tests focus on validating server responses rather than directly calling -get_ping_handler() and get_invoke_handler() to ensure full integration testing. -""" - -import os -import tempfile - -import pytest -import requests - -from ...utils import RemoteOpenAIServer -from .conftest import ( - MODEL_NAME_SMOLLM, -) - - -class TestHandlerOverrideIntegration: - """Integration tests simulating real customer usage scenarios. - - Each test simulates a fresh server startup where customers: - - Use @custom_ping_handler and @custom_invocation_handler decorators - - Set environment variables (CUSTOM_FASTAPI_PING_HANDLER, etc.) - - Write customer scripts with custom_sagemaker_ping_handler() and - custom_sagemaker_invocation_handler() functions - """ - - def setup_method(self): - """Setup for each test - simulate fresh server startup.""" - self._clear_caches() - self._clear_env_vars() - - def teardown_method(self): - """Cleanup after each test.""" - self._clear_env_vars() - - def _clear_caches(self): - """Clear handler registry and function loader cache.""" - try: - from model_hosting_container_standards.common.handler import ( - handler_registry, - ) - from model_hosting_container_standards.sagemaker.sagemaker_loader import ( - SageMakerFunctionLoader, - ) - - handler_registry.clear() - SageMakerFunctionLoader._default_function_loader = None - except ImportError: - pytest.skip("model-hosting-container-standards not available") - - def _clear_env_vars(self): - """Clear SageMaker environment variables.""" - try: - from model_hosting_container_standards.common.fastapi.config import ( - FastAPIEnvVars, - ) - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - - # Clear SageMaker env vars - for var in [ - SageMakerEnvVars.SAGEMAKER_MODEL_PATH, - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, - ]: - os.environ.pop(var, None) - - # Clear FastAPI env vars - for var in [ - FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER, - FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER, - ]: - os.environ.pop(var, None) - except ImportError: - pass - - @pytest.mark.asyncio - async def test_customer_script_functions_auto_loaded(self): - """Test customer scenario: script functions automatically override - framework defaults.""" - try: - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - except ImportError: - pytest.skip("model-hosting-container-standards not available") - - # Customer writes a script file with ping() and invoke() functions - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ -from fastapi import Request - -async def custom_sagemaker_ping_handler(): - return { - "status": "healthy", - "source": "customer_override", - "message": "Custom ping from customer script" - } - -async def custom_sagemaker_invocation_handler(request: Request): - return { - "predictions": ["Custom response from customer script"], - "source": "customer_override" - } -""" - ) - script_path = f.name - - try: - script_dir = os.path.dirname(script_path) - script_name = os.path.basename(script_path) - - # Customer sets SageMaker environment variables to point to their script - env_vars = { - SageMakerEnvVars.SAGEMAKER_MODEL_PATH: script_dir, - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: script_name, - } - - args = [ - "--dtype", - "bfloat16", - "--max-model-len", - "2048", - "--enforce-eager", - "--max-num-seqs", - "32", - ] - - with RemoteOpenAIServer( - MODEL_NAME_SMOLLM, args, env_dict=env_vars - ) as server: - # Customer tests their server and sees their overrides work - # automatically - ping_response = requests.get(server.url_for("ping")) - assert ping_response.status_code == 200 - ping_data = ping_response.json() - - invoke_response = requests.post( - server.url_for("invocations"), - json={ - "model": MODEL_NAME_SMOLLM, - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 5, - }, - ) - assert invoke_response.status_code == 200 - invoke_data = invoke_response.json() - - # Customer sees their functions are used - assert ping_data["source"] == "customer_override" - assert ping_data["message"] == "Custom ping from customer script" - assert invoke_data["source"] == "customer_override" - assert invoke_data["predictions"] == [ - "Custom response from customer script" - ] - - finally: - os.unlink(script_path) - - @pytest.mark.asyncio - async def test_customer_decorator_usage(self): - """Test customer scenario: using @custom_ping_handler and - @custom_invocation_handler decorators.""" - try: - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - except ImportError: - pytest.skip("model-hosting-container-standards not available") - - # Customer writes a script file with decorators - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ -import model_hosting_container_standards.sagemaker as sagemaker_standards -from fastapi import Request - -@sagemaker_standards.custom_ping_handler -async def my_ping(): - return { - "type": "ping", - "source": "customer_decorator" - } - -@sagemaker_standards.custom_invocation_handler -async def my_invoke(request: Request): - return { - "type": "invoke", - "source": "customer_decorator" - } -""" - ) - script_path = f.name - - try: - script_dir = os.path.dirname(script_path) - script_name = os.path.basename(script_path) - - env_vars = { - SageMakerEnvVars.SAGEMAKER_MODEL_PATH: script_dir, - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: script_name, - } - - args = [ - "--dtype", - "bfloat16", - "--max-model-len", - "2048", - "--enforce-eager", - "--max-num-seqs", - "32", - ] - - with RemoteOpenAIServer( - MODEL_NAME_SMOLLM, args, env_dict=env_vars - ) as server: - ping_response = requests.get(server.url_for("ping")) - assert ping_response.status_code == 200 - ping_data = ping_response.json() - - invoke_response = requests.post( - server.url_for("invocations"), - json={ - "model": MODEL_NAME_SMOLLM, - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 5, - }, - ) - assert invoke_response.status_code == 200 - invoke_data = invoke_response.json() - - # Customer sees their handlers are used by the server - assert ping_data["source"] == "customer_decorator" - assert invoke_data["source"] == "customer_decorator" - - finally: - os.unlink(script_path) - - @pytest.mark.asyncio - async def test_handler_priority_order(self): - """Test priority: @custom_ping_handler/@custom_invocation_handler - decorators vs script functions.""" - try: - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - except ImportError: - pytest.skip("model-hosting-container-standards not available") - - # Customer writes a script with both decorator and regular functions - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ -import model_hosting_container_standards.sagemaker as sagemaker_standards -from fastapi import Request - -# Customer uses @custom_ping_handler decorator (higher priority than script functions) -@sagemaker_standards.custom_ping_handler -async def decorated_ping(): - return { - "status": "healthy", - "source": "ping_decorator_in_script", - "priority": "decorator" - } - -# Customer also has a regular function (lower priority than -# @custom_ping_handler decorator) -async def custom_sagemaker_ping_handler(): - return { - "status": "healthy", - "source": "script_function", - "priority": "function" - } - -# Customer has a regular invoke function -async def custom_sagemaker_invocation_handler(request: Request): - return { - "predictions": ["Script function response"], - "source": "script_invoke_function", - "priority": "function" - } -""" - ) - script_path = f.name - - try: - script_dir = os.path.dirname(script_path) - script_name = os.path.basename(script_path) - - env_vars = { - SageMakerEnvVars.SAGEMAKER_MODEL_PATH: script_dir, - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: script_name, - } - - args = [ - "--dtype", - "bfloat16", - "--max-model-len", - "2048", - "--enforce-eager", - "--max-num-seqs", - "32", - ] - - with RemoteOpenAIServer( - MODEL_NAME_SMOLLM, args, env_dict=env_vars - ) as server: - ping_response = requests.get(server.url_for("ping")) - assert ping_response.status_code == 200 - ping_data = ping_response.json() - - invoke_response = requests.post( - server.url_for("invocations"), - json={ - "model": MODEL_NAME_SMOLLM, - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 5, - }, - ) - assert invoke_response.status_code == 200 - invoke_data = invoke_response.json() - - # @custom_ping_handler decorator has higher priority than - # script function - assert ping_data["source"] == "ping_decorator_in_script" - assert ping_data["priority"] == "decorator" - - # Script function is used for invoke - assert invoke_data["source"] == "script_invoke_function" - assert invoke_data["priority"] == "function" - - finally: - os.unlink(script_path) - - @pytest.mark.asyncio - async def test_environment_variable_script_loading(self): - """Test that environment variables correctly specify script location - and loading.""" - try: - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - except ImportError: - pytest.skip("model-hosting-container-standards not available") - - # Customer writes a script in a specific directory - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ -from fastapi import Request - -async def custom_sagemaker_ping_handler(): - return { - "status": "healthy", - "source": "env_loaded_script", - "method": "environment_variable_loading" - } - -async def custom_sagemaker_invocation_handler(request: Request): - return { - "predictions": ["Loaded via environment variables"], - "source": "env_loaded_script", - "method": "environment_variable_loading" - } -""" - ) - script_path = f.name - - try: - script_dir = os.path.dirname(script_path) - script_name = os.path.basename(script_path) - - # Test environment variable script loading - env_vars = { - SageMakerEnvVars.SAGEMAKER_MODEL_PATH: script_dir, - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: script_name, - } - - args = [ - "--dtype", - "bfloat16", - "--max-model-len", - "2048", - "--enforce-eager", - "--max-num-seqs", - "32", - ] - - with RemoteOpenAIServer( - MODEL_NAME_SMOLLM, args, env_dict=env_vars - ) as server: - ping_response = requests.get(server.url_for("ping")) - assert ping_response.status_code == 200 - ping_data = ping_response.json() - - invoke_response = requests.post( - server.url_for("invocations"), - json={ - "model": MODEL_NAME_SMOLLM, - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 5, - }, - ) - assert invoke_response.status_code == 200 - invoke_data = invoke_response.json() - - # Verify that the script was loaded via environment variables - assert ping_data["source"] == "env_loaded_script" - assert ping_data["method"] == "environment_variable_loading" - assert invoke_data["source"] == "env_loaded_script" - assert invoke_data["method"] == "environment_variable_loading" - - finally: - os.unlink(script_path) - - @pytest.mark.asyncio - async def test_framework_default_handlers(self): - """Test that framework default handlers work when no customer - overrides exist.""" - args = [ - "--dtype", - "bfloat16", - "--max-model-len", - "2048", - "--enforce-eager", - "--max-num-seqs", - "32", - ] - - # Explicitly pass empty env_dict to ensure no SageMaker env vars are set - # This prevents pollution from previous tests - try: - from model_hosting_container_standards.common.fastapi.config import ( - FastAPIEnvVars, - ) - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - - env_dict = { - SageMakerEnvVars.SAGEMAKER_MODEL_PATH: "", - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: "", - FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER: "", - FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER: "", - } - except ImportError: - env_dict = {} - - with RemoteOpenAIServer(MODEL_NAME_SMOLLM, args, env_dict=env_dict) as server: - # Test that default ping works - ping_response = requests.get(server.url_for("ping")) - assert ping_response.status_code == 200 - - # Test that default invocations work - invoke_response = requests.post( - server.url_for("invocations"), - json={ - "model": MODEL_NAME_SMOLLM, - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 5, - }, - ) - assert invoke_response.status_code == 200 - - @pytest.mark.asyncio - async def test_handler_env_var_override(self): - """Test CUSTOM_FASTAPI_PING_HANDLER and CUSTOM_FASTAPI_INVOCATION_HANDLER - environment variable overrides.""" - try: - from model_hosting_container_standards.common.fastapi.config import ( - FastAPIEnvVars, - ) - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - except ImportError: - pytest.skip("model-hosting-container-standards not available") - - # Create a script with both env var handlers and script functions - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ -from fastapi import Request, Response -import json - -async def env_var_ping_handler(raw_request: Request) -> Response: - return Response( - content=json.dumps({ - "status": "healthy", - "source": "env_var_ping", - "method": "environment_variable" - }), - media_type="application/json" - ) - -async def env_var_invoke_handler(raw_request: Request) -> Response: - return Response( - content=json.dumps({ - "predictions": ["Environment variable response"], - "source": "env_var_invoke", - "method": "environment_variable" - }), - media_type="application/json" - ) - -async def custom_sagemaker_ping_handler(): - return { - "status": "healthy", - "source": "script_ping", - "method": "script_function" - } - -async def custom_sagemaker_invocation_handler(request: Request): - return { - "predictions": ["Script function response"], - "source": "script_invoke", - "method": "script_function" - } -""" - ) - script_path = f.name - - try: - script_dir = os.path.dirname(script_path) - script_name = os.path.basename(script_path) - - # Set environment variables to override both handlers - env_vars = { - SageMakerEnvVars.SAGEMAKER_MODEL_PATH: script_dir, - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: script_name, - FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER: ( - f"{script_name}:env_var_ping_handler" - ), - FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER: ( - f"{script_name}:env_var_invoke_handler" - ), - } - - args = [ - "--dtype", - "bfloat16", - "--max-model-len", - "2048", - "--enforce-eager", - "--max-num-seqs", - "32", - ] - - with RemoteOpenAIServer( - MODEL_NAME_SMOLLM, args, env_dict=env_vars - ) as server: - # Test ping handler override - ping_response = requests.get(server.url_for("ping")) - assert ping_response.status_code == 200 - ping_data = ping_response.json() - - # Environment variable should override script function - assert ping_data["method"] == "environment_variable" - assert ping_data["source"] == "env_var_ping" - - # Test invocation handler override - invoke_response = requests.post( - server.url_for("invocations"), - json={ - "model": MODEL_NAME_SMOLLM, - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 5, - }, - ) - assert invoke_response.status_code == 200 - invoke_data = invoke_response.json() - - # Environment variable should override script function - assert invoke_data["method"] == "environment_variable" - assert invoke_data["source"] == "env_var_invoke" - - finally: - os.unlink(script_path) - - @pytest.mark.asyncio - async def test_env_var_priority_over_decorator_and_script(self): - """Test that environment variables have highest priority over decorators - and script functions for both ping and invocation handlers.""" - try: - from model_hosting_container_standards.common.fastapi.config import ( - FastAPIEnvVars, - ) - from model_hosting_container_standards.sagemaker.config import ( - SageMakerEnvVars, - ) - except ImportError: - pytest.skip("model-hosting-container-standards not available") - - # Create a script with all three handler types for both ping and invocation - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ -import model_hosting_container_standards.sagemaker as sagemaker_standards -from fastapi import Request, Response -import json - -# Environment variable handlers (highest priority) -async def env_priority_ping(raw_request: Request) -> Response: - return Response( - content=json.dumps({ - "status": "healthy", - "source": "env_var", - "priority": "environment_variable" - }), - media_type="application/json" - ) - -async def env_priority_invoke(raw_request: Request) -> Response: - return Response( - content=json.dumps({ - "predictions": ["Environment variable response"], - "source": "env_var", - "priority": "environment_variable" - }), - media_type="application/json" - ) - -# Decorator handlers (medium priority) -@sagemaker_standards.custom_ping_handler -async def decorator_ping(raw_request: Request) -> Response: - return Response( - content=json.dumps({ - "status": "healthy", - "source": "decorator", - "priority": "decorator" - }), - media_type="application/json" - ) - -@sagemaker_standards.custom_invocation_handler -async def decorator_invoke(raw_request: Request) -> Response: - return Response( - content=json.dumps({ - "predictions": ["Decorator response"], - "source": "decorator", - "priority": "decorator" - }), - media_type="application/json" - ) - -# Script functions (lowest priority) -async def custom_sagemaker_ping_handler(): - return { - "status": "healthy", - "source": "script", - "priority": "script_function" - } - -async def custom_sagemaker_invocation_handler(request: Request): - return { - "predictions": ["Script function response"], - "source": "script", - "priority": "script_function" - } -""" - ) - script_path = f.name - - try: - script_dir = os.path.dirname(script_path) - script_name = os.path.basename(script_path) - - # Set environment variables to specify highest priority handlers - env_vars = { - SageMakerEnvVars.SAGEMAKER_MODEL_PATH: script_dir, - SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: script_name, - FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER: ( - f"{script_name}:env_priority_ping" - ), - FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER: ( - f"{script_name}:env_priority_invoke" - ), - } - - args = [ - "--dtype", - "bfloat16", - "--max-model-len", - "2048", - "--enforce-eager", - "--max-num-seqs", - "32", - ] - - with RemoteOpenAIServer( - MODEL_NAME_SMOLLM, args, env_dict=env_vars - ) as server: - # Test ping handler priority - ping_response = requests.get(server.url_for("ping")) - assert ping_response.status_code == 200 - ping_data = ping_response.json() - - # Environment variable has highest priority and should be used - assert ping_data["priority"] == "environment_variable" - assert ping_data["source"] == "env_var" - - # Test invocation handler priority - invoke_response = requests.post( - server.url_for("invocations"), - json={ - "model": MODEL_NAME_SMOLLM, - "messages": [{"role": "user", "content": "Hello"}], - "max_tokens": 5, - }, - ) - assert invoke_response.status_code == 200 - invoke_data = invoke_response.json() - - # Environment variable has highest priority and should be used - assert invoke_data["priority"] == "environment_variable" - assert invoke_data["source"] == "env_var" - - finally: - os.unlink(script_path) diff --git a/vllm/v1/kv_offload/worker/__init__.py b/tests/entrypoints/scale_out/__init__.py similarity index 100% rename from vllm/v1/kv_offload/worker/__init__.py rename to tests/entrypoints/scale_out/__init__.py diff --git a/tests/entrypoints/scale_out/derender/__init__.py b/tests/entrypoints/scale_out/derender/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/entrypoints/scale_out/derender/test_derender.py b/tests/entrypoints/scale_out/derender/test_derender.py new file mode 100644 index 000000000000..3167e00b96e9 --- /dev/null +++ b/tests/entrypoints/scale_out/derender/test_derender.py @@ -0,0 +1,1120 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Tests for the /derender endpoints (postprocessing counterpart to /render).""" + +import httpx +import pytest +import pytest_asyncio + +from tests.utils import RemoteLaunchRenderServer +from vllm.tokenizers import get_tokenizer + +MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM" + + +@pytest.fixture(scope="module") +def server(): + with RemoteLaunchRenderServer(MODEL_NAME, []) as remote_server: + yield remote_server + + +@pytest_asyncio.fixture +async def client(server): + async with httpx.AsyncClient( + base_url=server.url_for(""), timeout=30.0 + ) as http_client: + yield http_client + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _render_chat(client: httpx.AsyncClient) -> dict: + """Render a minimal chat request and return the GenerateRequest dict.""" + resp = await client.post( + "/v1/chat/completions/render", + json={ + "model": MODEL_NAME, + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + assert resp.status_code == 200 + return resp.json() + + +def _make_generate_response( + token_ids: list[int] | None, + request_id: str = "chatcmpl-test-id", + finish_reason: str = "stop", + logprobs: dict | None = None, + prompt_logprobs: list | None = None, + kv_transfer_params: dict | None = None, +) -> dict: + choice: dict = { + "index": 0, + "token_ids": token_ids, + "finish_reason": finish_reason, + "logprobs": logprobs, + } + return { + "request_id": request_id, + "choices": [choice], + "prompt_logprobs": prompt_logprobs, + "kv_transfer_params": kv_transfer_params, + } + + +def _make_logprobs_with_placeholders(token_id: int = 1234) -> dict: + entry = { + "token": f"token_id:{token_id}", + "logprob": -1.0, + "bytes": None, + "top_logprobs": [ + {"token": f"token_id:{token_id + 1}", "logprob": -2.0, "bytes": None} + ], + } + return {"content": [entry]} + + +# --------------------------------------------------------------------------- +# Chat derender tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_derender_chat_roundtrip(client): + """Render then derender: decoded content should be a non-empty string.""" + gen_req = await _render_chat(client) + # Use the first 5 rendered token IDs as synthetic "generated" tokens. + synthetic_ids = gen_req["token_ids"][:5] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response(synthetic_ids), + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["object"] == "chat.completion" + assert len(data["choices"]) == 1 + assert data["choices"][0]["message"]["content"] + assert data["choices"][0]["message"]["role"] == "assistant" + + +@pytest.mark.asyncio +async def test_derender_chat_usage(client): + """Supplied prompt_tokens flows through into usage correctly.""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response(synthetic_ids), + "prompt_tokens": 10, + }, + ) + assert response.status_code == 200 + usage = response.json()["usage"] + assert usage["prompt_tokens"] == 10 + assert usage["completion_tokens"] == len(synthetic_ids) + assert usage["total_tokens"] == 10 + len(synthetic_ids) + + +@pytest.mark.asyncio +async def test_derender_chat_usage_default(client): + """Omitting prompt_tokens gives usage.prompt_tokens == 0.""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response(synthetic_ids), + }, + ) + assert response.status_code == 200 + usage = response.json()["usage"] + assert usage["prompt_tokens"] == 0 + + +@pytest.mark.asyncio +async def test_derender_chat_logprobs(client): + """token_id:N placeholders in content.token are resolved to real strings.""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + token_id = synthetic_ids[0] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response( + synthetic_ids, + logprobs=_make_logprobs_with_placeholders(token_id), + ), + }, + ) + assert response.status_code == 200 + data = response.json() + logprobs = data["choices"][0]["logprobs"] + assert logprobs is not None + content = logprobs["content"] + assert content is not None and len(content) == 1 + token_str = content[0]["token"] + assert not token_str.startswith("token_id:"), ( + f"Placeholder was not resolved: {token_str!r}" + ) + + +@pytest.mark.asyncio +async def test_derender_chat_logprobs_bytes(client): + """Resolved logprob entries have bytes populated as list[int].""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + token_id = synthetic_ids[0] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response( + synthetic_ids, + logprobs=_make_logprobs_with_placeholders(token_id), + ), + }, + ) + assert response.status_code == 200 + content = response.json()["choices"][0]["logprobs"]["content"] + bytes_field = content[0]["bytes"] + assert isinstance(bytes_field, list) + assert len(bytes_field) > 0 + assert all(isinstance(b, int) for b in bytes_field) + + +@pytest.mark.asyncio +async def test_derender_chat_top_logprobs(client): + """top_logprobs entries also have their placeholders resolved.""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + token_id = synthetic_ids[0] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response( + synthetic_ids, + logprobs=_make_logprobs_with_placeholders(token_id), + ), + }, + ) + assert response.status_code == 200 + content = response.json()["choices"][0]["logprobs"]["content"] + top = content[0]["top_logprobs"] + assert len(top) == 1 + assert not top[0]["token"].startswith("token_id:"), ( + f"top_logprobs placeholder not resolved: {top[0]['token']!r}" + ) + + +@pytest.mark.asyncio +async def test_derender_chat_prompt_logprobs_passthrough(client): + """prompt_logprobs on GenerateResponse passes through unchanged.""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + # prompt_logprobs is a list[dict[int, Logprob] | None]; use None entries. + prompt_logprobs = [None, None] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response( + synthetic_ids, prompt_logprobs=prompt_logprobs + ), + }, + ) + assert response.status_code == 200 + assert response.json()["prompt_logprobs"] == prompt_logprobs + + +@pytest.mark.asyncio +async def test_derender_chat_kv_transfer_params_passthrough(client): + """kv_transfer_params passes through to the ChatCompletionResponse.""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + kv = {"key": "value"} + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response( + synthetic_ids, kv_transfer_params=kv + ), + }, + ) + assert response.status_code == 200 + assert response.json()["kv_transfer_params"] == kv + + +@pytest.mark.asyncio +async def test_derender_chat_empty_token_ids(client): + """Empty token_ids list returns 400.""" + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response([]), + }, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_derender_chat_null_token_ids(client): + """Null token_ids returns 400.""" + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response(None), + }, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_derender_chat_unknown_model(client): + """Unknown model returns 404.""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:3] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": "does-not-exist", + "generate_response": _make_generate_response(synthetic_ids), + }, + ) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Completion derender tests +# --------------------------------------------------------------------------- + + +async def _render_completion(client: httpx.AsyncClient, prompt: str) -> dict: + """Render a completion prompt and return the first GenerateRequest dict.""" + resp = await client.post( + "/v1/completions/render", + json={"model": MODEL_NAME, "prompt": prompt}, + ) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) and len(data) >= 1 + return data[0] + + +def _make_completion_generate_response( + token_ids: list[int], + request_id: str, + kv_transfer_params: dict | None = None, + logprobs: dict | None = None, +) -> dict: + return { + "request_id": request_id, + "choices": [ + { + "index": 0, + "token_ids": token_ids, + "finish_reason": "stop", + "logprobs": logprobs, + } + ], + "prompt_logprobs": None, + "kv_transfer_params": kv_transfer_params, + } + + +@pytest.mark.asyncio +async def test_derender_completion_roundtrip(client): + """Two prompts rendered, two GenerateResponses → two choices with indices 0, 1.""" + gr1 = await _render_completion(client, "Hello world") + gr2 = await _render_completion(client, "Goodbye world") + + ids1 = gr1["token_ids"][:4] + ids2 = gr2["token_ids"][:4] + + response = await client.post( + "/v1/completions/derender", + json={ + "model": MODEL_NAME, + "generate_responses": [ + _make_completion_generate_response(ids1, gr1["request_id"]), + _make_completion_generate_response(ids2, gr2["request_id"]), + ], + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["object"] == "text_completion" + choices = data["choices"] + assert len(choices) == 2 + assert choices[0]["index"] == 0 + assert choices[1]["index"] == 1 + assert choices[0]["text"] + assert choices[1]["text"] + + +@pytest.mark.asyncio +async def test_derender_completion_usage_aggregation(client): + """prompt_tokens=[5, 10] is aggregated correctly into usage.""" + gr1 = await _render_completion(client, "Hello") + gr2 = await _render_completion(client, "World") + + ids1 = gr1["token_ids"][:3] + ids2 = gr2["token_ids"][:4] + + response = await client.post( + "/v1/completions/derender", + json={ + "model": MODEL_NAME, + "generate_responses": [ + _make_completion_generate_response(ids1, gr1["request_id"]), + _make_completion_generate_response(ids2, gr2["request_id"]), + ], + "prompt_tokens": [5, 10], + }, + ) + assert response.status_code == 200 + usage = response.json()["usage"] + assert usage["prompt_tokens"] == 15 + assert usage["completion_tokens"] == len(ids1) + len(ids2) + assert usage["total_tokens"] == 15 + len(ids1) + len(ids2) + + +@pytest.mark.asyncio +async def test_derender_completion_prompt_tokens_length_mismatch(client): + """len(prompt_tokens) != len(generate_responses) returns 400.""" + gr1 = await _render_completion(client, "Hello") + ids1 = gr1["token_ids"][:3] + + response = await client.post( + "/v1/completions/derender", + json={ + "model": MODEL_NAME, + "generate_responses": [ + _make_completion_generate_response(ids1, gr1["request_id"]), + ], + "prompt_tokens": [5, 10], + }, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_derender_completion_empty_generate_responses(client): + """Empty generate_responses list returns 400.""" + response = await client.post( + "/v1/completions/derender", + json={"model": MODEL_NAME, "generate_responses": []}, + ) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_derender_completion_logprobs(client): + """token_id:N placeholders in logprobs are resolved; CompletionLogProbs + flat-list structure is returned with non-empty tokens and text_offsets.""" + gr1 = await _render_completion(client, "Hello world") + ids1 = gr1["token_ids"][:3] + token_id = ids1[0] + + response = await client.post( + "/v1/completions/derender", + json={ + "model": MODEL_NAME, + "generate_responses": [ + _make_completion_generate_response( + ids1, + gr1["request_id"], + logprobs=_make_logprobs_with_placeholders(token_id), + ), + ], + }, + ) + assert response.status_code == 200 + logprobs = response.json()["choices"][0]["logprobs"] + assert logprobs is not None + tokens = logprobs["tokens"] + assert len(tokens) == 1 + assert not tokens[0].startswith("token_id:"), ( + f"Placeholder was not resolved: {tokens[0]!r}" + ) + assert len(logprobs["token_logprobs"]) == 1 + assert isinstance(logprobs["token_logprobs"][0], float) + assert len(logprobs["text_offset"]) == 1 + assert logprobs["text_offset"][0] == 0 + + +@pytest.mark.asyncio +async def test_derender_completion_kv_transfer_params_passthrough(client): + """kv_transfer_params passes through to CompletionResponse.""" + gr1 = await _render_completion(client, "Hello") + ids1 = gr1["token_ids"][:3] + kv = {"node": "abc"} + + response = await client.post( + "/v1/completions/derender", + json={ + "model": MODEL_NAME, + "generate_responses": [ + _make_completion_generate_response( + ids1, gr1["request_id"], kv_transfer_params=kv + ), + ], + }, + ) + assert response.status_code == 200 + assert response.json()["kv_transfer_params"] == kv + + +# --------------------------------------------------------------------------- +# Resource bounds regression tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_derender_chat_bounded_payload_succeeds(client): + """Normal bounded derender payload succeeds (positive control).""" + gen_req = await _render_chat(client) + synthetic_ids = gen_req["token_ids"][:5] + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response(synthetic_ids), + }, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["choices"]) == 1 + assert data["choices"][0]["message"]["content"] + + +@pytest.mark.asyncio +async def test_derender_chat_oversized_token_ids_rejected(client): + """token_ids longer than max_model_len returns 400.""" + response = await client.get("/v1/models") + assert response.status_code == 200 + + # Use a token_ids list that exceeds any reasonable max_model_len. + # The tiny-random model has max_model_len of 2048. + oversized_ids = [42] * 1_000_000 + + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response(oversized_ids), + }, + ) + assert response.status_code == 400 + assert "max_model_len" in response.json()["error"]["message"] + + +@pytest.mark.asyncio +async def test_derender_chat_too_many_choices_rejected(client): + """choices count exceeding VLLM_MAX_N_SEQUENCES returns 400.""" + # Default VLLM_MAX_N_SEQUENCES is 16384; use a larger count. + oversized_choices = [ + {"index": i, "token_ids": [42], "finish_reason": "stop"} for i in range(20_000) + ] + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": { + "request_id": "test-choices-bound", + "choices": oversized_choices, + }, + }, + ) + assert response.status_code == 400 + assert "choices count" in response.json()["error"]["message"] + + +@pytest.mark.asyncio +async def test_derender_completion_too_many_generate_responses_rejected(client): + """generate_responses count exceeding limit returns 400.""" + oversized_responses = [ + { + "request_id": f"gen-{i}", + "choices": [{"index": 0, "token_ids": [42], "finish_reason": "stop"}], + } + for i in range(20_000) + ] + response = await client.post( + "/v1/completions/derender", + json={ + "model": MODEL_NAME, + "generate_responses": oversized_responses, + }, + ) + assert response.status_code == 400 + assert "generate_responses count" in response.json()["error"]["message"] + + +@pytest.mark.asyncio +async def test_derender_chat_negative_token_ids_rejected(client): + """Negative token_ids are rejected at the protocol validation level.""" + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": _make_generate_response([-1, 42, 100]), + }, + ) + # vLLM's validation_exception_handler converts Pydantic errors to 400 + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_derender_chat_oversized_logprobs_rejected(client): + """logprobs.content longer than max_model_len returns 400.""" + oversized_logprobs: dict = { + "content": [ + {"token": "x", "logprob": -1.0, "bytes": None, "top_logprobs": []} + for _ in range(1_000_000) + ] + } + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": { + "request_id": "test-logprobs-bound", + "choices": [ + { + "index": 0, + "token_ids": [42], + "finish_reason": "stop", + "logprobs": oversized_logprobs, + } + ], + }, + }, + ) + assert response.status_code == 400 + assert "logprobs.content length" in response.json()["error"]["message"] + + +@pytest.mark.asyncio +async def test_derender_chat_oversized_top_logprobs_rejected(client): + """top_logprobs count exceeding max_logprobs (default 20) returns 400.""" + oversized_top_logprobs = { + "content": [ + { + "token": "x", + "logprob": -1.0, + "bytes": None, + "top_logprobs": [ + {"token": f"t{i}", "logprob": -float(i), "bytes": None} + for i in range(25) + ], + } + ] + } + response = await client.post( + "/v1/chat/completions/derender", + json={ + "model": MODEL_NAME, + "generate_response": { + "request_id": "test-top-logprobs-bound", + "choices": [ + { + "index": 0, + "token_ids": [42], + "finish_reason": "stop", + "logprobs": oversized_top_logprobs, + } + ], + }, + }, + ) + assert response.status_code == 400 + msg = response.json()["error"]["message"] + assert "top_logprobs count" in msg + assert "max_logprobs" in msg + + +@pytest.mark.asyncio +async def test_derender_completion_oversized_token_ids_rejected(client): + """Completion endpoint also rejects oversized token_ids.""" + oversized_ids = [42] * 1_000_000 + response = await client.post( + "/v1/completions/derender", + json={ + "model": MODEL_NAME, + "generate_responses": [ + { + "request_id": "gen-0", + "choices": [ + { + "index": 0, + "token_ids": oversized_ids, + "finish_reason": "stop", + } + ], + } + ], + }, + ) + assert response.status_code == 400 + assert "max_model_len" in response.json()["error"]["message"] + + +# --------------------------------------------------------------------------- +# E2E: render -> derender roundtrip with parser (reasoning + tool calls) +# --------------------------------------------------------------------------- + +PARSER_MODEL = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B" + +_E2E_TOOLS = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + } +] + + +@pytest.fixture(scope="module") +def parser_server(): + args = [ + "--enable-auto-tool-choice", + "--tool-call-parser", + "hermes", + "--reasoning-parser", + "deepseek_r1", + ] + with RemoteLaunchRenderServer(PARSER_MODEL, args) as remote_server: + yield remote_server + + +@pytest_asyncio.fixture +async def parser_client(parser_server): + async with httpx.AsyncClient( + base_url=parser_server.url_for(""), timeout=60.0 + ) as http_client: + yield http_client + + +@pytest.fixture(scope="module") +def parser_tokenizer(): + return get_tokenizer(PARSER_MODEL) + + +def _encode(tokenizer, text: str) -> list[int]: + return tokenizer.encode(text, add_special_tokens=False) + + +def _decoded(tokenizer, token_ids: list[int]) -> str: + return tokenizer.decode(token_ids, skip_special_tokens=True) + + +def _require_markers_survive(tokenizer, text: str, *markers: str) -> list[int]: + """Encode text and skip the test if any marker is lost in roundtrip.""" + ids = _encode(tokenizer, text) + decoded = tokenizer.decode(ids, skip_special_tokens=False) + for m in markers: + if m not in decoded: + pytest.skip(f"Marker {m!r} lost in encode->decode roundtrip") + return ids + + +async def _e2e_render_chat( + client: httpx.AsyncClient, + model: str, + messages: list[dict], +) -> dict: + resp = await client.post( + "/v1/chat/completions/render", + json={"model": model, "messages": messages}, + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +def _e2e_generate_response( + token_ids: list[int], + request_id: str = "chatcmpl-e2e-test", +) -> dict: + return { + "request_id": request_id, + "choices": [ + { + "index": 0, + "token_ids": token_ids, + "finish_reason": "stop", + } + ], + } + + +@pytest.mark.asyncio +async def test_e2e_plain_roundtrip(parser_client, parser_tokenizer): + """Plain text without reasoning markers roundtrips correctly.""" + messages = [{"role": "user", "content": "What is 2+2?"}] + gen_req = await _e2e_render_chat(parser_client, PARSER_MODEL, messages) + + answer = "The answer is four." + output_ids = _encode(parser_tokenizer, answer) + expected = _decoded(parser_tokenizer, output_ids) + + resp = await parser_client.post( + "/v1/chat/completions/derender", + json={ + "model": PARSER_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + }, + ) + assert resp.status_code == 200, resp.text + content = resp.json()["choices"][0]["message"]["content"] + assert content == expected + + +@pytest.mark.asyncio +async def test_e2e_token_identity(parser_client, parser_tokenizer): + """encode(derender(token_ids)) == token_ids (RL invariant).""" + messages = [{"role": "user", "content": "Hi"}] + gen_req = await _e2e_render_chat(parser_client, PARSER_MODEL, messages) + + answer = "Hello! How can I help?" + output_ids = _encode(parser_tokenizer, answer) + + resp = await parser_client.post( + "/v1/chat/completions/derender", + json={ + "model": PARSER_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + }, + ) + assert resp.status_code == 200 + content = resp.json()["choices"][0]["message"]["content"] + re_encoded = _encode(parser_tokenizer, content) + assert output_ids == re_encoded + + +@pytest.mark.asyncio +async def test_e2e_non_ascii_roundtrip(parser_client, parser_tokenizer): + """CJK + emoji roundtrip without U+FFFD.""" + messages = [{"role": "user", "content": "Reply in Chinese"}] + gen_req = await _e2e_render_chat(parser_client, PARSER_MODEL, messages) + + answer = "你好世界 😀" + output_ids = _encode(parser_tokenizer, answer) + + resp = await parser_client.post( + "/v1/chat/completions/derender", + json={ + "model": PARSER_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + }, + ) + assert resp.status_code == 200 + content = resp.json()["choices"][0]["message"]["content"] + assert "�" not in content + + +@pytest.mark.asyncio +async def test_e2e_parsed_reasoning(parser_client, parser_tokenizer): + """... splits into reasoning + content.""" + messages = [{"role": "user", "content": "What is 2+3?"}] + gen_req = await _e2e_render_chat(parser_client, PARSER_MODEL, messages) + + reasoning_text = "The user wants 2 plus 3. That is 5." + answer_text = "The answer is 5." + output_text = f"{reasoning_text}{answer_text}" + output_ids = _require_markers_survive(parser_tokenizer, output_text, "") + + resp = await parser_client.post( + "/v1/chat/completions/derender", + json={ + "model": PARSER_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + "chat_request": { + "model": PARSER_MODEL, + "messages": messages, + "include_reasoning": True, + }, + }, + ) + assert resp.status_code == 200, resp.text + msg = resp.json()["choices"][0]["message"] + assert msg["reasoning"] is not None + assert reasoning_text in msg["reasoning"] + assert answer_text in msg["content"] + assert "" not in msg["content"] + + +@pytest.mark.asyncio +async def test_e2e_parsed_tool_call(parser_client, parser_tokenizer): + """ extracted into tool_calls field.""" + messages = [{"role": "user", "content": "Weather in Paris?"}] + gen_req = await _e2e_render_chat(parser_client, PARSER_MODEL, messages) + + output_text = ( + "Let me check the weather." + '\n{"name": "get_weather", ' + '"arguments": {"city": "Paris"}}\n' + ) + output_ids = _require_markers_survive( + parser_tokenizer, + output_text, + "", + "", + "", + ) + + resp = await parser_client.post( + "/v1/chat/completions/derender", + json={ + "model": PARSER_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + "chat_request": { + "model": PARSER_MODEL, + "messages": messages, + "tools": _E2E_TOOLS, + "tool_choice": "auto", + }, + }, + ) + assert resp.status_code == 200, resp.text + choice = resp.json()["choices"][0] + assert choice["message"]["tool_calls"] + assert choice["message"]["tool_calls"][0]["function"]["name"] == "get_weather" + + +@pytest.mark.asyncio +async def test_e2e_parsed_reasoning_and_tool_call(parser_client, parser_tokenizer): + """Reasoning + tool call in the same output.""" + messages = [{"role": "user", "content": "Weather in Paris?"}] + gen_req = await _e2e_render_chat(parser_client, PARSER_MODEL, messages) + + reasoning_text = "I should look up the weather." + tool_text = ( + '\n{"name": "get_weather", ' + '"arguments": {"city": "Paris"}}\n' + ) + output_text = f"{reasoning_text}{tool_text}" + output_ids = _require_markers_survive( + parser_tokenizer, output_text, "", "" + ) + + resp = await parser_client.post( + "/v1/chat/completions/derender", + json={ + "model": PARSER_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + "chat_request": { + "model": PARSER_MODEL, + "messages": messages, + "tools": _E2E_TOOLS, + "tool_choice": "auto", + "include_reasoning": True, + }, + }, + ) + assert resp.status_code == 200, resp.text + choice = resp.json()["choices"][0] + assert choice["message"]["reasoning"] is not None + assert reasoning_text in choice["message"]["reasoning"] + assert choice["message"]["tool_calls"] + + +@pytest.mark.asyncio +async def test_e2e_no_chat_request_fallback(parser_client, parser_tokenizer): + """Without chat_request, derender falls back to plain detokenization.""" + messages = [{"role": "user", "content": "Hello"}] + gen_req = await _e2e_render_chat(parser_client, PARSER_MODEL, messages) + + answer = "Hi there!" + output_ids = _encode(parser_tokenizer, answer) + + resp = await parser_client.post( + "/v1/chat/completions/derender", + json={ + "model": PARSER_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + }, + ) + assert resp.status_code == 200 + content = resp.json()["choices"][0]["message"]["content"] + assert "Hi" in content + + +# --------------------------------------------------------------------------- +# E2E: HarmonyParser + GPT-OSS +# --------------------------------------------------------------------------- + +HARMONY_MODEL = "openai/gpt-oss-20b" + + +def _ensure_harmony_vocab(): + """Pre-cache the o200k_base BPE file needed by openai-harmony. + + The Rust tiktoken-rs backend downloads from Azure Blob Storage, which + may be unreachable in some environments. When the cache is cold we + fetch the file ourselves and place it in ``/tmp/tiktoken-rs-cache/`` + using the SHA-1(URL) filename that tiktoken-rs expects. + """ + import hashlib + import urllib.request + from pathlib import Path + + url = "https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken" + cache_dir = Path("/tmp/tiktoken-rs-cache") + cache_key = hashlib.sha1(url.encode()).hexdigest() + cache_file = cache_dir / cache_key + if not cache_file.exists(): + cache_dir.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(url, cache_file) + + +@pytest.fixture(scope="module") +def harmony_server(): + _ensure_harmony_vocab() + args = [ + "--trust-remote-code", + "--enable-auto-tool-choice", + "--tool-call-parser", + "openai", + "--reasoning-parser", + "openai_gptoss", + ] + with RemoteLaunchRenderServer(HARMONY_MODEL, args) as remote_server: + yield remote_server + + +@pytest_asyncio.fixture +async def harmony_client(harmony_server): + async with httpx.AsyncClient( + base_url=harmony_server.url_for(""), timeout=60.0 + ) as http_client: + yield http_client + + +@pytest.fixture(scope="module") +def harmony_tokenizer(): + return get_tokenizer(HARMONY_MODEL, trust_remote_code=True) + + +def _harmony_extract_assistant_ids( + tokenizer, assistant_msg: dict, user_content: str = "test" +) -> list[int]: + """Extract assistant token IDs via apply_chat_template diff.""" + prompt = [{"role": "user", "content": user_content}] + full = prompt + [assistant_msg] + text_prompt = tokenizer.apply_chat_template( + prompt, add_generation_prompt=True, tokenize=False + ) + text_full = tokenizer.apply_chat_template( + full, add_generation_prompt=False, tokenize=False + ) + prompt_ids = tokenizer.encode(text_prompt) + full_ids = tokenizer.encode(text_full) + assistant_ids = list(full_ids[len(prompt_ids) :]) + if not assistant_ids: + pytest.skip("Could not extract assistant tokens for Harmony") + return assistant_ids + + +@pytest.mark.asyncio +async def test_e2e_harmony_plain_roundtrip(harmony_client, harmony_tokenizer): + """GPT-OSS content-only roundtrip.""" + messages = [{"role": "user", "content": "What is 2+2?"}] + gen_req = await _e2e_render_chat(harmony_client, HARMONY_MODEL, messages) + + assistant_msg = {"role": "assistant", "content": "Four."} + output_ids = _harmony_extract_assistant_ids(harmony_tokenizer, assistant_msg) + + resp = await harmony_client.post( + "/v1/chat/completions/derender", + json={ + "model": HARMONY_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + "chat_request": { + "model": HARMONY_MODEL, + "messages": messages, + }, + }, + ) + assert resp.status_code == 200, resp.text + content = resp.json()["choices"][0]["message"]["content"] + assert content is not None and len(content) > 0 + assert "Four" in content + + +@pytest.mark.asyncio +async def test_e2e_harmony_reasoning(harmony_client, harmony_tokenizer): + """GPT-OSS reasoning: analysis channel extracted.""" + messages = [{"role": "user", "content": "Add 2 and 3."}] + gen_req = await _e2e_render_chat(harmony_client, HARMONY_MODEL, messages) + + reasoning_text = "The user wants 2 plus 3." + answer_text = "The answer is 5." + assistant_msg = { + "role": "assistant", + "thinking": reasoning_text, + "content": answer_text, + } + output_ids = _harmony_extract_assistant_ids(harmony_tokenizer, assistant_msg) + + decoded = harmony_tokenizer.decode(output_ids) + if reasoning_text not in decoded: + pytest.skip("Harmony template did not render thinking") + + resp = await harmony_client.post( + "/v1/chat/completions/derender", + json={ + "model": HARMONY_MODEL, + "generate_response": _e2e_generate_response(output_ids), + "prompt_tokens": len(gen_req["token_ids"]), + "chat_request": { + "model": HARMONY_MODEL, + "messages": messages, + "include_reasoning": True, + }, + }, + ) + assert resp.status_code == 200, resp.text + msg = resp.json()["choices"][0]["message"] + assert msg["reasoning"] is not None + assert reasoning_text in msg["reasoning"] + assert answer_text in (msg["content"] or "") diff --git a/tests/entrypoints/scale_out/render/__init__.py b/tests/entrypoints/scale_out/render/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/entrypoints/serve/render/test_launch_render.py b/tests/entrypoints/scale_out/render/test_launch_render.py similarity index 100% rename from tests/entrypoints/serve/render/test_launch_render.py rename to tests/entrypoints/scale_out/render/test_launch_render.py diff --git a/tests/entrypoints/serve/render/test_render.py b/tests/entrypoints/scale_out/render/test_render.py similarity index 51% rename from tests/entrypoints/serve/render/test_render.py rename to tests/entrypoints/scale_out/render/test_render.py index 7aacf4564e3e..ffd7f9f30ae1 100644 --- a/tests/entrypoints/serve/render/test_render.py +++ b/tests/entrypoints/scale_out/render/test_render.py @@ -14,7 +14,7 @@ @pytest.fixture(scope="module") def server(): - args: list[str] = [] + args: list[str] = ["--trust-request-chat-template"] with RemoteLaunchRenderServer(MODEL_NAME, args) as remote_server: yield remote_server @@ -263,3 +263,233 @@ async def test_chat_completion_render_with_sampling_params(client): # Check that internal fields are not present assert "_all_stop_token_ids" not in sampling_params + + +@pytest.mark.asyncio +async def test_completion_render_emits_token_offsets(client): + """With return_token_offsets, /v1/completions/render returns per-token + (start, end) char offsets aligned with token_ids.""" + prompt = "Hello, world." + response = await client.post( + "/v1/completions/render", + json={ + "model": MODEL_NAME, + "prompt": prompt, + "return_token_offsets": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + offsets = data[0]["token_offsets"] + assert offsets is not None + assert len(offsets) == len(data[0]["token_ids"]) + for start, end in offsets: + assert isinstance(start, int) and isinstance(end, int) + assert 0 <= start <= end <= len(prompt) + + +@pytest.mark.asyncio +async def test_completion_render_default_no_token_offsets(client): + """Without the flag, token_offsets must be null (existing responses + unchanged).""" + response = await client.post( + "/v1/completions/render", + json={ + "model": MODEL_NAME, + "prompt": "Hello, world.", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data[0]["token_offsets"] is None + + +@pytest.mark.asyncio +async def test_chat_render_emits_token_offsets(client): + """With return_token_offsets, /v1/chat/completions/render returns + per-token offsets relative to the templated prompt string.""" + response = await client.post( + "/v1/chat/completions/render", + json={ + "model": MODEL_NAME, + "messages": [{"role": "user", "content": "Hello, world."}], + "return_token_offsets": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + offsets = data["token_offsets"] + assert offsets is not None + assert len(offsets) == len(data["token_ids"]) + for start, end in offsets: + assert isinstance(start, int) and isinstance(end, int) + assert 0 <= start <= end + + +@pytest.mark.asyncio +async def test_chat_render_default_no_token_offsets(client): + """Without the flag, chat render token_offsets must be null.""" + response = await client.post( + "/v1/chat/completions/render", + json={ + "model": MODEL_NAME, + "messages": [{"role": "user", "content": "Hello, world."}], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["token_offsets"] is None + + +@pytest.mark.asyncio +async def test_completion_render_multiple_prompts_token_offsets(client): + """Each prompt in a batch gets its own offsets aligned with its tokens.""" + prompts = ["Hello, world.", "Goodbye, world."] + response = await client.post( + "/v1/completions/render", + json={ + "model": MODEL_NAME, + "prompt": prompts, + "return_token_offsets": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == len(prompts) + for item, prompt in zip(data, prompts): + offsets = item["token_offsets"] + assert offsets is not None + assert len(offsets) == len(item["token_ids"]) + for start, end in offsets: + assert 0 <= start <= end <= len(prompt) + + +@pytest.mark.asyncio +async def test_chat_completion_render_assistant_tokens_mask_default(client): + """Without return_assistant_tokens_mask, assistant_tokens_mask should be null.""" + response = await client.post( + "/v1/chat/completions/render", + json={ + "model": MODEL_NAME, + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "How are you?"}, + ], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data.get("assistant_tokens_mask") is None + + +@pytest.mark.asyncio +async def test_chat_completion_render_assistant_tokens_mask_false(client): + """Explicitly setting return_assistant_tokens_mask=false gives null.""" + response = await client.post( + "/v1/chat/completions/render", + json={ + "model": MODEL_NAME, + "messages": [ + {"role": "user", "content": "Hello"}, + ], + "return_assistant_tokens_mask": False, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data.get("assistant_tokens_mask") is None + + +@pytest.mark.asyncio +async def test_chat_render_assistant_tokens_mask_null_without_gen_tags( + client, +): + """The tiny test model lacks ``{% generation %}`` tags, so the mask is null.""" + response = await client.post( + "/v1/chat/completions/render", + json={ + "model": MODEL_NAME, + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + ], + "return_assistant_tokens_mask": True, + }, + ) + + assert response.status_code == 200 + assert response.json().get("assistant_tokens_mask") is None + + +# A minimal chat template with {% generation %} tags so we can test that +# the mask correctly marks assistant tokens. +_TEMPLATE_WITH_GENERATION = ( + "{% for m in messages %}" + "{% if m['role'] == 'user' %}User: {{ m['content'] }}\n" + "{% elif m['role'] == 'assistant' %}" + "{% generation %}Assistant: {{ m['content'] }}\n{% endgeneration %}" + "{% endif %}" + "{% endfor %}" +) + + +@pytest.mark.asyncio +async def test_chat_completion_render_assistant_tokens_mask_with_generation_tags( + client, +): + """With a ``{% generation %}``-enabled template, the mask marks assistant + tokens and the masked tokens decode to the assistant content.""" + response = await client.post( + "/v1/chat/completions/render", + json={ + "model": MODEL_NAME, + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "Bye"}, + ], + "chat_template": _TEMPLATE_WITH_GENERATION, + "return_assistant_tokens_mask": True, + }, + ) + + assert response.status_code == 200 + data = response.json() + + mask = data["assistant_tokens_mask"] + token_ids = data["token_ids"] + assert mask is not None + assert isinstance(mask, list) + assert len(mask) == len(token_ids) + assert all(v in (0, 1) for v in mask) + assert sum(mask) > 0, "mask should mark at least one assistant token" + + # Detokenize masked (assistant) and unmasked (non-assistant) tokens + # separately to verify the mask is correct, not just non-empty. + masked_ids = [t for t, m in zip(token_ids, mask, strict=True) if m] + unmasked_ids = [t for t, m in zip(token_ids, mask, strict=True) if not m] + + detok = await client.post( + "/detokenize", + json={"model": MODEL_NAME, "tokens": masked_ids}, + ) + assert detok.status_code == 200 + assert "Hi!" in detok.json()["prompt"] + + detok_rest = await client.post( + "/detokenize", + json={"model": MODEL_NAME, "tokens": unmasked_ids}, + ) + assert detok_rest.status_code == 200 + assert "Hi!" not in detok_rest.json()["prompt"] + assert "Bye" in detok_rest.json()["prompt"] diff --git a/tests/entrypoints/serve/render/test_render_multimodal.py b/tests/entrypoints/scale_out/render/test_render_multimodal.py similarity index 100% rename from tests/entrypoints/serve/render/test_render_multimodal.py rename to tests/entrypoints/scale_out/render/test_render_multimodal.py diff --git a/tests/entrypoints/scale_out/token_in_token_out/__init__.py b/tests/entrypoints/scale_out/token_in_token_out/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/entrypoints/serve/disagg/test_generate_stream.py b/tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py similarity index 89% rename from tests/entrypoints/serve/disagg/test_generate_stream.py rename to tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py index ac5b8bcd9158..ce3100f196cb 100644 --- a/tests/entrypoints/serve/disagg/test_generate_stream.py +++ b/tests/entrypoints/scale_out/token_in_token_out/test_generate_stream.py @@ -12,15 +12,15 @@ from vllm.entrypoints.openai.engine.protocol import StreamOptions from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.entrypoints.openai.models.serving import OpenAIServingModels -from vllm.entrypoints.serve.disagg.protocol import ( +from vllm.entrypoints.scale_out.token_in_token_out.protocol import ( GenerateRequest, GenerateResponse, ) -from vllm.entrypoints.serve.disagg.serving import ServingTokens -from vllm.entrypoints.serve.render.serving import OpenAIServingRender +from vllm.entrypoints.scale_out.token_in_token_out.serving import ServingTokens from vllm.logprobs import Logprob from vllm.outputs import CompletionOutput, RequestOutput from vllm.renderers import renderer_from_config +from vllm.renderers.online_renderer import OnlineRenderer from vllm.sampling_params import SamplingParams from vllm.v1.engine.async_llm import AsyncLLM @@ -92,10 +92,9 @@ def _build_serving_tokens(engine: AsyncLLM, **kwargs) -> ServingTokens: engine_client=engine, base_model_paths=BASE_MODEL_PATHS, ) - serving_render = OpenAIServingRender( + online_renderer = OnlineRenderer( model_config=engine.model_config, renderer=engine.renderer, - model_registry=models.registry, request_logger=None, chat_template=None, chat_template_content_format="auto", @@ -103,7 +102,7 @@ def _build_serving_tokens(engine: AsyncLLM, **kwargs) -> ServingTokens: serving = ServingTokens( engine, models, - openai_serving_render=serving_render, + online_renderer=online_renderer, request_logger=None, **kwargs, ) @@ -111,7 +110,7 @@ def _build_serving_tokens(engine: AsyncLLM, **kwargs) -> ServingTokens: async def _fake_preprocess(*args, **kwargs): return [{"prompt_token_ids": [1, 2, 3]}] - serving.openai_serving_render.preprocess_completion = AsyncMock( + serving.online_renderer.preprocess_completion = AsyncMock( side_effect=_fake_preprocess ) return serving @@ -199,9 +198,7 @@ async def mock_generate(*args, **kwargs): assert isinstance(response, GenerateResponse) assert ( - serving.openai_serving_render.preprocess_completion.call_args.kwargs[ - "skip_mm_cache" - ] + serving.online_renderer.preprocess_completion.call_args.kwargs["skip_mm_cache"] is True ) @@ -512,3 +509,46 @@ async def mock_generate(*args, **kwargs): usage_chunk = parsed[-2] assert usage_chunk["choices"] == [] assert usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"] == 2 + + +@pytest.mark.asyncio +async def test_stream_prompt_tokens_details_zero_cached(): + """enable_prompt_tokens_details includes cached_tokens=0 in final usage. + + Regression test for https://github.com/vllm-project/vllm/issues/44377: + zero cached tokens must not be treated as falsy and omitted. + """ + engine = _mock_engine() + + async def mock_generate(*args, **kwargs): + yield _make_request_output( + "req-1", + token_ids=[10], + finish_reason="stop", + finished=True, + num_cached_tokens=0, + ) + + engine.generate = MagicMock(side_effect=mock_generate) + serving = _build_serving_tokens(engine, enable_prompt_tokens_details=True) + + request = GenerateRequest( + token_ids=[1, 2, 3], + sampling_params=SamplingParams(max_tokens=10), + model=MODEL_NAME, + stream=True, + stream_options=StreamOptions(include_usage=True), + ) + + response = await serving.serve_tokens(request) + chunks = [] + async for chunk in response: + chunks.append(chunk) + + parsed = _parse_sse_chunks(chunks) + # Usage-only chunk (before [DONE]) + usage_chunk = parsed[-2] + assert usage_chunk["choices"] == [] + # Zero cached tokens must be present, not omitted + assert usage_chunk["usage"]["prompt_tokens_details"] is not None + assert usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"] == 0 diff --git a/tests/entrypoints/openai/test_mm_serde.py b/tests/entrypoints/scale_out/token_in_token_out/test_mm_serde.py similarity index 94% rename from tests/entrypoints/openai/test_mm_serde.py rename to tests/entrypoints/scale_out/token_in_token_out/test_mm_serde.py index c568d822e1c0..d24436bbd4bd 100644 --- a/tests/entrypoints/openai/test_mm_serde.py +++ b/tests/entrypoints/scale_out/token_in_token_out/test_mm_serde.py @@ -1,14 +1,17 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Roundtrip tests for multimodal serde used by the disagg generate endpoint.""" +""" +Roundtrip tests for multimodal serde used by the +token_in_token_out generate endpoint. +""" import torch -from vllm.entrypoints.serve.disagg.mm_serde import ( +from vllm.entrypoints.scale_out.token_in_token_out.mm_serde import ( decode_mm_kwargs_item, encode_mm_kwargs_item, ) -from vllm.entrypoints.serve.disagg.protocol import ( +from vllm.entrypoints.scale_out.token_in_token_out.protocol import ( MultiModalFeatures, PlaceholderRangeInfo, ) diff --git a/tests/entrypoints/serve/disagg/test_protocol.py b/tests/entrypoints/scale_out/token_in_token_out/test_protocol.py similarity index 95% rename from tests/entrypoints/serve/disagg/test_protocol.py rename to tests/entrypoints/scale_out/token_in_token_out/test_protocol.py index 414fc2a26125..674ce18b7f30 100644 --- a/tests/entrypoints/serve/disagg/test_protocol.py +++ b/tests/entrypoints/scale_out/token_in_token_out/test_protocol.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for the disagg request/response protocol. +"""Unit tests for the token_in_token_out request/response protocol. These tests intentionally avoid spinning up a server — they exercise the pydantic validators on ``GenerateRequest`` directly so they run fast and @@ -9,7 +9,7 @@ import json -from vllm.entrypoints.serve.disagg.protocol import GenerateRequest +from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateRequest from vllm.sampling_params import SamplingParams diff --git a/tests/entrypoints/serve/disagg/test_return_routed_experts.py b/tests/entrypoints/scale_out/token_in_token_out/test_return_routed_experts.py similarity index 100% rename from tests/entrypoints/serve/disagg/test_return_routed_experts.py rename to tests/entrypoints/scale_out/token_in_token_out/test_return_routed_experts.py diff --git a/tests/entrypoints/serve/disagg/test_serving_multimodal_tokens.py b/tests/entrypoints/scale_out/token_in_token_out/test_serving_multimodal_tokens.py similarity index 100% rename from tests/entrypoints/serve/disagg/test_serving_multimodal_tokens.py rename to tests/entrypoints/scale_out/token_in_token_out/test_serving_multimodal_tokens.py diff --git a/tests/entrypoints/serve/disagg/test_serving_tokens.py b/tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py similarity index 100% rename from tests/entrypoints/serve/disagg/test_serving_tokens.py rename to tests/entrypoints/scale_out/token_in_token_out/test_serving_tokens.py diff --git a/tests/entrypoints/serve/disagg/test_tokens_logprobs.py b/tests/entrypoints/scale_out/token_in_token_out/test_tokens_logprobs.py similarity index 92% rename from tests/entrypoints/serve/disagg/test_tokens_logprobs.py rename to tests/entrypoints/scale_out/token_in_token_out/test_tokens_logprobs.py index 844dd24d5418..80f08078da27 100644 --- a/tests/entrypoints/serve/disagg/test_tokens_logprobs.py +++ b/tests/entrypoints/scale_out/token_in_token_out/test_tokens_logprobs.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from vllm.entrypoints.serve.disagg.serving import ServingTokens +from vllm.entrypoints.scale_out.token_in_token_out.serving import ServingTokens from vllm.logprobs import Logprob diff --git a/tests/entrypoints/serve/instrumentator/test_basic.py b/tests/entrypoints/serve/instrumentator/test_basic.py index 1ab963dc1801..5b00d2e578e5 100644 --- a/tests/entrypoints/serve/instrumentator/test_basic.py +++ b/tests/entrypoints/serve/instrumentator/test_basic.py @@ -83,7 +83,8 @@ async def test_show_version(server: RemoteOpenAIServer): response = requests.get(server.url_for("version")) response.raise_for_status() - assert response.json() == {"version": VLLM_VERSION} + # Tolerate additive fields (e.g. the Rust frontend reports its own version). + assert response.json()["version"] == VLLM_VERSION @pytest.mark.asyncio diff --git a/tests/entrypoints/serve/instrumentator/test_http_status_metrics.py b/tests/entrypoints/serve/instrumentator/test_http_status_metrics.py new file mode 100644 index 000000000000..0f96bf161d27 --- /dev/null +++ b/tests/entrypoints/serve/instrumentator/test_http_status_metrics.py @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Test that http_requests_total metric records correct status codes. + +Regression test for: Prometheus http_requests_total records 4xx exceptions +(ValueError, TypeError, etc.) as 5xx because they propagate through the +PrometheusInstrumentatorMiddleware before being caught by ServerErrorMiddleware. +""" + +from argparse import Namespace +from http import HTTPStatus + +import httpx +import pytest +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from prometheus_client import CollectorRegistry +from prometheus_fastapi_instrumentator import Instrumentator + +from vllm.entrypoints.serve.utils.server_utils import exception_handler +from vllm.exceptions import VLLMNotFoundError, VLLMValidationError + + +@pytest.fixture +def registry(): + """Create a fresh Prometheus registry for each test.""" + return CollectorRegistry() + + +@pytest.fixture +def app(registry): + """Create a minimal FastAPI app that mirrors vLLM's exception handler + and Prometheus middleware setup.""" + + app = FastAPI() + + # Mock app state that exception_handler needs + app.state.args = Namespace(log_error_stack=False) + + # Register exception handlers exactly as vLLM does in build_app() + app.exception_handler(HTTPException)(_http_exception_handler) + app.exception_handler(RequestValidationError)(_validation_exception_handler) + app.exception_handler(ValueError)(exception_handler) + app.exception_handler(TypeError)(exception_handler) + app.exception_handler(OverflowError)(exception_handler) + app.exception_handler(NotImplementedError)(exception_handler) + app.exception_handler(VLLMValidationError)(exception_handler) + app.exception_handler(VLLMNotFoundError)(exception_handler) + app.exception_handler(Exception)(exception_handler) + + # Instrument with Prometheus (same as vLLM's attach_router) + Instrumentator( + excluded_handlers=["/metrics"], + registry=registry, + ).add().instrument(app) + + # Test routes that raise different exception types + @app.get("/raise_value_error") + async def raise_value_error(): + raise ValueError("invalid input value") + + @app.get("/raise_type_error") + async def raise_type_error(): + raise TypeError("wrong type") + + @app.get("/raise_overflow_error") + async def raise_overflow_error(): + raise OverflowError("number too large") + + @app.get("/raise_not_implemented_error") + async def raise_not_implemented_error(): + raise NotImplementedError("feature not supported") + + @app.get("/raise_vllm_validation_error") + async def raise_vllm_validation_error(): + raise VLLMValidationError("bad parameter", parameter="temperature") + + @app.get("/raise_vllm_not_found_error") + async def raise_vllm_not_found_error(): + raise VLLMNotFoundError("model not found") + + @app.get("/raise_http_exception_400") + async def raise_http_exception_400(): + raise HTTPException(status_code=400, detail="bad request") + + @app.get("/raise_http_exception_404") + async def raise_http_exception_404(): + raise HTTPException(status_code=404, detail="not found") + + @app.get("/raise_runtime_error") + async def raise_runtime_error(): + raise RuntimeError("unexpected server error") + + @app.get("/success") + async def success(): + return {"status": "ok"} + + return app + + +async def _http_exception_handler(req: Request, exc: HTTPException): + return JSONResponse({"error": exc.detail}, status_code=exc.status_code) + + +async def _validation_exception_handler(req: Request, exc: RequestValidationError): + return JSONResponse({"error": str(exc)}, status_code=HTTPStatus.BAD_REQUEST) + + +def _get_http_requests_total(registry, method: str, handler: str): + """Extract the http_requests_total metric values grouped by status. + + Returns a dict like {"2xx": 1.0, "5xx": 1.0} for the given handler. + """ + results: dict[str, float] = {} + for metric in registry.collect(): + if metric.name == "http_requests": + for sample in metric.samples: + if ( + sample.name == "http_requests_total" + and sample.labels.get("method") == method + and sample.labels.get("handler") == handler + ): + status = sample.labels.get("status") + results[status] = results.get(status, 0) + sample.value + return results + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "endpoint,expected_status_group,expected_http_code", + [ + # These should record as 4xx in Prometheus + ("/raise_value_error", "4xx", 400), + ("/raise_type_error", "4xx", 400), + ("/raise_overflow_error", "4xx", 400), + ("/raise_vllm_validation_error", "4xx", 400), + ("/raise_vllm_not_found_error", "4xx", 404), + ("/raise_http_exception_400", "4xx", 400), + ("/raise_http_exception_404", "4xx", 404), + # NotImplementedError returns 501 which is still 5xx group + ("/raise_not_implemented_error", "5xx", 501), + # These should record as 5xx in Prometheus (genuine server errors) + ("/raise_runtime_error", "5xx", 500), + # Successful requests should record as 2xx + ("/success", "2xx", 200), + ], + ids=[ + "ValueError->4xx", + "TypeError->4xx", + "OverflowError->4xx", + "VLLMValidationError->4xx", + "VLLMNotFoundError->4xx", + "HTTPException(400)->4xx", + "HTTPException(404)->4xx", + "NotImplementedError->5xx", + "RuntimeError->5xx", + "success->2xx", + ], +) +async def test_http_requests_total_records_correct_status( + app, + registry, + endpoint, + expected_status_group, + expected_http_code, +): + """Verify that http_requests_total records the correct status group. + + The Prometheus metric should reflect the actual HTTP status code returned + to the client, not a default 500 for all exceptions. + """ + # raise_app_exceptions=False allows the full ASGI middleware stack + # (including ServerErrorMiddleware) to handle exceptions and generate + # proper HTTP responses, just like a real server would. + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + response = await client.get(endpoint) + + # Verify the HTTP response code returned to the client is correct + assert response.status_code == expected_http_code, ( + f"Expected HTTP {expected_http_code} for {endpoint}, got {response.status_code}" + ) + + # Verify Prometheus recorded the correct status group + metrics = _get_http_requests_total(registry, "GET", endpoint) + assert expected_status_group in metrics, ( + f"Expected Prometheus to record '{expected_status_group}' for " + f"{endpoint}, but got: {metrics}" + ) + assert metrics[expected_status_group] == 1.0, ( + f"Expected 1 request recorded as '{expected_status_group}' for " + f"{endpoint}, but got {metrics[expected_status_group]}" + ) + + # For endpoints that should be recorded as 4xx, verify they are NOT + # incorrectly recorded as 5xx + if expected_status_group == "4xx": + assert "5xx" not in metrics, ( + f"Expected NO '5xx' recording for {endpoint} " + f"(should be '4xx'), but found: {metrics}" + ) diff --git a/tests/entrypoints/serve/instrumentator/test_metrics.py b/tests/entrypoints/serve/instrumentator/test_metrics.py index 9095f80e20f2..8e6fdb704524 100644 --- a/tests/entrypoints/serve/instrumentator/test_metrics.py +++ b/tests/entrypoints/serve/instrumentator/test_metrics.py @@ -289,6 +289,17 @@ async def test_metrics_exist( continue assert metric in response.text + cache_config_samples = [ + sample + for family in text_string_to_metric_families(response.text) + if family.name == "vllm:cache_config_info" + for sample in family.samples + ] + assert cache_config_samples + for sample in cache_config_samples: + assert sample.labels.get("kv_cache_size_tokens") not in (None, "None", "") + assert sample.labels.get("kv_cache_max_concurrency") not in (None, "None", "") + @pytest.mark.asyncio async def test_abort_metrics_reset( diff --git a/tests/entrypoints/serve/lora/test_serving_models.py b/tests/entrypoints/serve/lora/test_serving_models.py index 0cab3fd42cff..658d004580a2 100644 --- a/tests/entrypoints/serve/lora/test_serving_models.py +++ b/tests/entrypoints/serve/lora/test_serving_models.py @@ -14,7 +14,7 @@ ) from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.entrypoints.openai.models.serving import OpenAIServingModels -from vllm.entrypoints.pooling.base.serving import PoolingServingBase +from vllm.entrypoints.pooling.base.serving import PoolingBaseServing from vllm.entrypoints.pooling.typing import PoolingServeContext from vllm.entrypoints.serve.lora.protocol import ( LoadLoRAAdapterRequest, @@ -136,7 +136,7 @@ async def test_unload_lora_adapter_not_found(): assert response.error.code == HTTPStatus.NOT_FOUND -class _ConcretePoolingServing(PoolingServingBase): +class _ConcretePoolingServing(PoolingBaseServing): """Minimal concrete subclass used only in these unit tests.""" request_id_prefix = "test" @@ -178,7 +178,7 @@ def test_pooling_maybe_get_adapters_lora_name_sets_lora_request(): serving = _make_pooling_serving(lora_name) ctx = _make_pooling_ctx(lora_name) - serving._maybe_get_adapters(ctx) + ctx.lora_request = serving._maybe_get_adapters(ctx.request) assert ctx.lora_request is not None assert ctx.lora_request.lora_name == lora_name @@ -190,4 +190,4 @@ def test_pooling_maybe_get_adapters_unknown_model_raises(): ctx = _make_pooling_ctx("unknown-model") with pytest.raises(VLLMNotFoundError): - serving._maybe_get_adapters(ctx) + serving._maybe_get_adapters(ctx.request) diff --git a/tests/entrypoints/serve/sagemaker/__init__.py b/tests/entrypoints/serve/sagemaker/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/entrypoints/sagemaker/conftest.py b/tests/entrypoints/serve/sagemaker/conftest.py similarity index 97% rename from tests/entrypoints/sagemaker/conftest.py rename to tests/entrypoints/serve/sagemaker/conftest.py index 1c34d738fa7a..d36c20ccd9af 100644 --- a/tests/entrypoints/sagemaker/conftest.py +++ b/tests/entrypoints/serve/sagemaker/conftest.py @@ -6,7 +6,7 @@ import pytest import pytest_asyncio -from ...utils import RemoteOpenAIServer +from tests.utils import RemoteOpenAIServer # Model name constants used across tests MODEL_NAME_SMOLLM = "HuggingFaceTB/SmolLM2-135M-Instruct" diff --git a/tests/entrypoints/serve/sagemaker/test_sagemaker_handler_overrides.py b/tests/entrypoints/serve/sagemaker/test_sagemaker_handler_overrides.py new file mode 100644 index 000000000000..20b917400b3c --- /dev/null +++ b/tests/entrypoints/serve/sagemaker/test_sagemaker_handler_overrides.py @@ -0,0 +1,629 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Integration tests for handler override functionality. + +Tests real customer usage scenarios: +- Using @custom_ping_handler and @custom_invocation_handler decorators + to override handlers +- Setting environment variables for handler specifications +- Writing customer scripts with custom_sagemaker_ping_handler() and + custom_sagemaker_invocation_handler() functions +- Priority: env vars > decorators > customer script files > framework + defaults + +The handler-override scenarios exercise the real vLLM SageMaker router and +bootstrap path via an in-process FastAPI ``TestClient`` instead of launching a +model server. These scenarios fully replace the ``/ping`` and ``/invocations`` +endpoints with customer handlers, so no inference engine is required to +validate override behavior. Avoiding the model server also keeps the tests +fast and deterministic rather than depending on the FastAPI version resolved +into the test environment at runtime. +""" + +import os + +import pytest +import requests +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from tests.utils import RemoteOpenAIServer + +from .conftest import ( + MODEL_NAME_SMOLLM, +) + + +def _build_sagemaker_test_client() -> TestClient: + """Build a TestClient over the real SageMaker router and bootstrap path. + + ``attach_router`` is called with empty supported tasks because the override + tests replace the endpoints with customer handlers, so no framework + invocation handler (and therefore no engine) is exercised. + """ + from vllm.entrypoints.serve.sagemaker.api_router import ( + attach_router, + sagemaker_standards_bootstrap, + ) + + app = FastAPI() + attach_router(app, ()) + return TestClient(sagemaker_standards_bootstrap(app)) + + +class TestHandlerOverrideIntegration: + """Integration tests simulating real customer usage scenarios. + + Each test simulates a fresh server startup where customers: + - Use @custom_ping_handler and @custom_invocation_handler decorators + - Set environment variables (CUSTOM_FASTAPI_PING_HANDLER, etc.) + - Write customer scripts with custom_sagemaker_ping_handler() and + custom_sagemaker_invocation_handler() functions + """ + + def setup_method(self): + """Setup for each test - simulate fresh server startup.""" + self._clear_caches() + self._clear_env_vars() + + def teardown_method(self): + """Cleanup after each test.""" + self._clear_env_vars() + + def _clear_caches(self): + """Clear handler registry and function loader cache.""" + try: + from model_hosting_container_standards.common.handler import ( + handler_registry, + ) + from model_hosting_container_standards.sagemaker.sagemaker_loader import ( + SageMakerFunctionLoader, + ) + + handler_registry.clear() + SageMakerFunctionLoader._default_function_loader = None + except ImportError: + pytest.skip("model-hosting-container-standards not available") + + def _clear_env_vars(self): + """Clear SageMaker environment variables.""" + try: + from model_hosting_container_standards.common.fastapi.config import ( + FastAPIEnvVars, + ) + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + + # Clear SageMaker env vars + for var in [ + SageMakerEnvVars.SAGEMAKER_MODEL_PATH, + SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, + ]: + os.environ.pop(var, None) + + # Clear FastAPI env vars + for var in [ + FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER, + FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER, + ]: + os.environ.pop(var, None) + except ImportError: + pass + + def test_customer_script_functions_auto_loaded(self, monkeypatch, tmp_path): + """Test customer scenario: script functions automatically override + framework defaults.""" + try: + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + except ImportError: + pytest.skip("model-hosting-container-standards not available") + + # Customer writes a script file with ping() and invoke() functions + script_path = tmp_path / "model.py" + script_path.write_text( + """ +from fastapi import Request + +async def custom_sagemaker_ping_handler(): + return { + "status": "healthy", + "source": "customer_override", + "message": "Custom ping from customer script" + } + +async def custom_sagemaker_invocation_handler(request: Request): + return { + "predictions": ["Custom response from customer script"], + "source": "customer_override" + } +""" + ) + + # Customer sets SageMaker environment variables to point to their script + monkeypatch.setenv(SageMakerEnvVars.SAGEMAKER_MODEL_PATH, str(tmp_path)) + monkeypatch.setenv(SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, script_path.name) + + with _build_sagemaker_test_client() as client: + # Customer tests their server and sees their overrides work + # automatically + ping_response = client.get("/ping") + assert ping_response.status_code == 200 + ping_data = ping_response.json() + + invoke_response = client.post( + "/invocations", + json={ + "model": MODEL_NAME_SMOLLM, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 5, + }, + ) + assert invoke_response.status_code == 200 + invoke_data = invoke_response.json() + + # Customer sees their functions are used + assert ping_data["source"] == "customer_override" + assert ping_data["message"] == "Custom ping from customer script" + assert invoke_data["source"] == "customer_override" + assert invoke_data["predictions"] == [ + "Custom response from customer script" + ] + + def test_customer_decorator_usage(self, monkeypatch, tmp_path): + """Test customer scenario: using @custom_ping_handler and + @custom_invocation_handler decorators.""" + try: + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + except ImportError: + pytest.skip("model-hosting-container-standards not available") + + # Customer writes a script file with decorators + script_path = tmp_path / "model.py" + script_path.write_text( + """ +import model_hosting_container_standards.sagemaker as sagemaker_standards +from fastapi import Request + +@sagemaker_standards.custom_ping_handler +async def my_ping(): + return { + "type": "ping", + "source": "customer_decorator" + } + +@sagemaker_standards.custom_invocation_handler +async def my_invoke(request: Request): + return { + "type": "invoke", + "source": "customer_decorator" + } +""" + ) + + monkeypatch.setenv(SageMakerEnvVars.SAGEMAKER_MODEL_PATH, str(tmp_path)) + monkeypatch.setenv(SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, script_path.name) + + with _build_sagemaker_test_client() as client: + ping_response = client.get("/ping") + assert ping_response.status_code == 200 + ping_data = ping_response.json() + + invoke_response = client.post( + "/invocations", + json={ + "model": MODEL_NAME_SMOLLM, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 5, + }, + ) + assert invoke_response.status_code == 200 + invoke_data = invoke_response.json() + + # Customer sees their handlers are used by the server + assert ping_data["source"] == "customer_decorator" + assert invoke_data["source"] == "customer_decorator" + + def test_handler_priority_order(self, monkeypatch, tmp_path): + """Test priority: @custom_ping_handler/@custom_invocation_handler + decorators vs script functions.""" + try: + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + except ImportError: + pytest.skip("model-hosting-container-standards not available") + + # Customer writes a script with both decorator and regular functions + script_path = tmp_path / "model.py" + script_path.write_text( + """ +import model_hosting_container_standards.sagemaker as sagemaker_standards +from fastapi import Request + +# Customer uses @custom_ping_handler decorator (higher priority than script functions) +@sagemaker_standards.custom_ping_handler +async def decorated_ping(): + return { + "status": "healthy", + "source": "ping_decorator_in_script", + "priority": "decorator" + } + +# Customer also has a regular function (lower priority than +# @custom_ping_handler decorator) +async def custom_sagemaker_ping_handler(): + return { + "status": "healthy", + "source": "script_function", + "priority": "function" + } + +# Customer has a regular invoke function +async def custom_sagemaker_invocation_handler(request: Request): + return { + "predictions": ["Script function response"], + "source": "script_invoke_function", + "priority": "function" + } +""" + ) + + monkeypatch.setenv(SageMakerEnvVars.SAGEMAKER_MODEL_PATH, str(tmp_path)) + monkeypatch.setenv(SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, script_path.name) + + with _build_sagemaker_test_client() as client: + ping_response = client.get("/ping") + assert ping_response.status_code == 200 + ping_data = ping_response.json() + + invoke_response = client.post( + "/invocations", + json={ + "model": MODEL_NAME_SMOLLM, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 5, + }, + ) + assert invoke_response.status_code == 200 + invoke_data = invoke_response.json() + + # @custom_ping_handler decorator has higher priority than + # script function + assert ping_data["source"] == "ping_decorator_in_script" + assert ping_data["priority"] == "decorator" + + # Script function is used for invoke + assert invoke_data["source"] == "script_invoke_function" + assert invoke_data["priority"] == "function" + + def test_environment_variable_script_loading(self, monkeypatch, tmp_path): + """Test that environment variables correctly specify script location + and loading.""" + try: + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + except ImportError: + pytest.skip("model-hosting-container-standards not available") + + # Customer writes a script in a specific directory + script_path = tmp_path / "model.py" + script_path.write_text( + """ +from fastapi import Request + +async def custom_sagemaker_ping_handler(): + return { + "status": "healthy", + "source": "env_loaded_script", + "method": "environment_variable_loading" + } + +async def custom_sagemaker_invocation_handler(request: Request): + return { + "predictions": ["Loaded via environment variables"], + "source": "env_loaded_script", + "method": "environment_variable_loading" + } +""" + ) + + # Test environment variable script loading + monkeypatch.setenv(SageMakerEnvVars.SAGEMAKER_MODEL_PATH, str(tmp_path)) + monkeypatch.setenv(SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, script_path.name) + + with _build_sagemaker_test_client() as client: + ping_response = client.get("/ping") + assert ping_response.status_code == 200 + ping_data = ping_response.json() + + invoke_response = client.post( + "/invocations", + json={ + "model": MODEL_NAME_SMOLLM, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 5, + }, + ) + assert invoke_response.status_code == 200 + invoke_data = invoke_response.json() + + # Verify that the script was loaded via environment variables + assert ping_data["source"] == "env_loaded_script" + assert ping_data["method"] == "environment_variable_loading" + assert invoke_data["source"] == "env_loaded_script" + assert invoke_data["method"] == "environment_variable_loading" + + @pytest.mark.asyncio + async def test_framework_default_handlers(self): + """Test that framework default handlers work when no customer + overrides exist. + + This scenario exercises the real inference path (default + ``/invocations``), so it keeps using a live model server rather than + the in-process TestClient. + """ + args = [ + "--dtype", + "bfloat16", + "--max-model-len", + "2048", + "--enforce-eager", + "--max-num-seqs", + "32", + ] + + # Explicitly pass empty env_dict to ensure no SageMaker env vars are set + # This prevents pollution from previous tests + try: + from model_hosting_container_standards.common.fastapi.config import ( + FastAPIEnvVars, + ) + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + + env_dict = { + SageMakerEnvVars.SAGEMAKER_MODEL_PATH: "", + SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME: "", + FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER: "", + FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER: "", + } + except ImportError: + env_dict = {} + + with RemoteOpenAIServer(MODEL_NAME_SMOLLM, args, env_dict=env_dict) as server: + # Test that default ping works + ping_response = requests.get(server.url_for("ping")) + assert ping_response.status_code == 200 + + # Test that default invocations work + invoke_response = requests.post( + server.url_for("invocations"), + json={ + "model": MODEL_NAME_SMOLLM, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 5, + }, + ) + assert invoke_response.status_code == 200 + + def test_handler_env_var_override(self, monkeypatch, tmp_path): + """Test CUSTOM_FASTAPI_PING_HANDLER and CUSTOM_FASTAPI_INVOCATION_HANDLER + environment variable overrides.""" + try: + from model_hosting_container_standards.common.fastapi.config import ( + FastAPIEnvVars, + ) + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + except ImportError: + pytest.skip("model-hosting-container-standards not available") + + # Create a script with both env var handlers and script functions + script_path = tmp_path / "model.py" + script_path.write_text( + """ +from fastapi import Request, Response +import json + +async def env_var_ping_handler(raw_request: Request) -> Response: + return Response( + content=json.dumps({ + "status": "healthy", + "source": "env_var_ping", + "method": "environment_variable" + }), + media_type="application/json" + ) + +async def env_var_invoke_handler(raw_request: Request) -> Response: + return Response( + content=json.dumps({ + "predictions": ["Environment variable response"], + "source": "env_var_invoke", + "method": "environment_variable" + }), + media_type="application/json" + ) + +async def custom_sagemaker_ping_handler(): + return { + "status": "healthy", + "source": "script_ping", + "method": "script_function" + } + +async def custom_sagemaker_invocation_handler(request: Request): + return { + "predictions": ["Script function response"], + "source": "script_invoke", + "method": "script_function" + } +""" + ) + + # Set environment variables to override both handlers + monkeypatch.setenv(SageMakerEnvVars.SAGEMAKER_MODEL_PATH, str(tmp_path)) + monkeypatch.setenv(SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, script_path.name) + monkeypatch.setenv( + FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER, + f"{script_path.name}:env_var_ping_handler", + ) + monkeypatch.setenv( + FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER, + f"{script_path.name}:env_var_invoke_handler", + ) + + with _build_sagemaker_test_client() as client: + # Test ping handler override + ping_response = client.get("/ping") + assert ping_response.status_code == 200 + ping_data = ping_response.json() + + # Environment variable should override script function + assert ping_data["method"] == "environment_variable" + assert ping_data["source"] == "env_var_ping" + + # Test invocation handler override + invoke_response = client.post( + "/invocations", + json={ + "model": MODEL_NAME_SMOLLM, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 5, + }, + ) + assert invoke_response.status_code == 200 + invoke_data = invoke_response.json() + + # Environment variable should override script function + assert invoke_data["method"] == "environment_variable" + assert invoke_data["source"] == "env_var_invoke" + + def test_env_var_priority_over_decorator_and_script(self, monkeypatch, tmp_path): + """Test that environment variables have highest priority over decorators + and script functions for both ping and invocation handlers.""" + try: + from model_hosting_container_standards.common.fastapi.config import ( + FastAPIEnvVars, + ) + from model_hosting_container_standards.sagemaker.config import ( + SageMakerEnvVars, + ) + except ImportError: + pytest.skip("model-hosting-container-standards not available") + + # Create a script with all three handler types for both ping and invocation + script_path = tmp_path / "model.py" + script_path.write_text( + """ +import model_hosting_container_standards.sagemaker as sagemaker_standards +from fastapi import Request, Response +import json + +# Environment variable handlers (highest priority) +async def env_priority_ping(raw_request: Request) -> Response: + return Response( + content=json.dumps({ + "status": "healthy", + "source": "env_var", + "priority": "environment_variable" + }), + media_type="application/json" + ) + +async def env_priority_invoke(raw_request: Request) -> Response: + return Response( + content=json.dumps({ + "predictions": ["Environment variable response"], + "source": "env_var", + "priority": "environment_variable" + }), + media_type="application/json" + ) + +# Decorator handlers (medium priority) +@sagemaker_standards.custom_ping_handler +async def decorator_ping(raw_request: Request) -> Response: + return Response( + content=json.dumps({ + "status": "healthy", + "source": "decorator", + "priority": "decorator" + }), + media_type="application/json" + ) + +@sagemaker_standards.custom_invocation_handler +async def decorator_invoke(raw_request: Request) -> Response: + return Response( + content=json.dumps({ + "predictions": ["Decorator response"], + "source": "decorator", + "priority": "decorator" + }), + media_type="application/json" + ) + +# Script functions (lowest priority) +async def custom_sagemaker_ping_handler(): + return { + "status": "healthy", + "source": "script", + "priority": "script_function" + } + +async def custom_sagemaker_invocation_handler(request: Request): + return { + "predictions": ["Script function response"], + "source": "script", + "priority": "script_function" + } +""" + ) + + # Set environment variables to specify highest priority handlers + monkeypatch.setenv(SageMakerEnvVars.SAGEMAKER_MODEL_PATH, str(tmp_path)) + monkeypatch.setenv(SageMakerEnvVars.CUSTOM_SCRIPT_FILENAME, script_path.name) + monkeypatch.setenv( + FastAPIEnvVars.CUSTOM_FASTAPI_PING_HANDLER, + f"{script_path.name}:env_priority_ping", + ) + monkeypatch.setenv( + FastAPIEnvVars.CUSTOM_FASTAPI_INVOCATION_HANDLER, + f"{script_path.name}:env_priority_invoke", + ) + + with _build_sagemaker_test_client() as client: + # Test ping handler priority + ping_response = client.get("/ping") + assert ping_response.status_code == 200 + ping_data = ping_response.json() + + # Environment variable has highest priority and should be used + assert ping_data["priority"] == "environment_variable" + assert ping_data["source"] == "env_var" + + # Test invocation handler priority + invoke_response = client.post( + "/invocations", + json={ + "model": MODEL_NAME_SMOLLM, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 5, + }, + ) + assert invoke_response.status_code == 200 + invoke_data = invoke_response.json() + + # Environment variable has highest priority and should be used + assert invoke_data["priority"] == "environment_variable" + assert invoke_data["source"] == "env_var" diff --git a/tests/entrypoints/sagemaker/test_sagemaker_lora_adapters.py b/tests/entrypoints/serve/sagemaker/test_sagemaker_lora_adapters.py similarity index 99% rename from tests/entrypoints/sagemaker/test_sagemaker_lora_adapters.py rename to tests/entrypoints/serve/sagemaker/test_sagemaker_lora_adapters.py index 01b3e6502222..4a7d8640366c 100644 --- a/tests/entrypoints/sagemaker/test_sagemaker_lora_adapters.py +++ b/tests/entrypoints/serve/sagemaker/test_sagemaker_lora_adapters.py @@ -4,7 +4,8 @@ import pytest import requests -from ...utils import RemoteOpenAIServer +from tests.utils import RemoteOpenAIServer + from .conftest import MODEL_NAME_SMOLLM diff --git a/tests/entrypoints/sagemaker/test_sagemaker_middleware_integration.py b/tests/entrypoints/serve/sagemaker/test_sagemaker_middleware_integration.py similarity index 99% rename from tests/entrypoints/sagemaker/test_sagemaker_middleware_integration.py rename to tests/entrypoints/serve/sagemaker/test_sagemaker_middleware_integration.py index f1ed0c7e2897..bc7574d6503c 100644 --- a/tests/entrypoints/sagemaker/test_sagemaker_middleware_integration.py +++ b/tests/entrypoints/serve/sagemaker/test_sagemaker_middleware_integration.py @@ -12,7 +12,8 @@ import pytest import requests -from ...utils import RemoteOpenAIServer +from tests.utils import RemoteOpenAIServer + from .conftest import ( MODEL_NAME_SMOLLM, ) diff --git a/tests/entrypoints/sagemaker/test_sagemaker_stateful_sessions.py b/tests/entrypoints/serve/sagemaker/test_sagemaker_stateful_sessions.py similarity index 99% rename from tests/entrypoints/sagemaker/test_sagemaker_stateful_sessions.py rename to tests/entrypoints/serve/sagemaker/test_sagemaker_stateful_sessions.py index 6206000385bd..7267b4265cc8 100644 --- a/tests/entrypoints/sagemaker/test_sagemaker_stateful_sessions.py +++ b/tests/entrypoints/serve/sagemaker/test_sagemaker_stateful_sessions.py @@ -6,7 +6,8 @@ import pytest import requests -from ...utils import RemoteOpenAIServer +from tests.utils import RemoteOpenAIServer + from .conftest import ( HEADER_SAGEMAKER_CLOSED_SESSION_ID, HEADER_SAGEMAKER_NEW_SESSION_ID, diff --git a/tests/entrypoints/serve/tokenize/test_serving_tokenization.py b/tests/entrypoints/serve/tokenize/test_serving_tokenization.py index ba9d7989a865..99267d857557 100644 --- a/tests/entrypoints/serve/tokenize/test_serving_tokenization.py +++ b/tests/entrypoints/serve/tokenize/test_serving_tokenization.py @@ -10,12 +10,12 @@ from vllm.config.multimodal import MultiModalConfig from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.entrypoints.openai.models.serving import OpenAIServingModels -from vllm.entrypoints.serve.render.serving import OpenAIServingRender from vllm.entrypoints.serve.tokenize.protocol import ( TokenizeChatRequest, TokenizeCompletionRequest, ) -from vllm.entrypoints.serve.tokenize.serving import OpenAIServingTokenization +from vllm.entrypoints.serve.tokenize.serving import ServingTokenization +from vllm.renderers.online_renderer import OnlineRenderer from vllm.v1.engine.async_llm import AsyncLLM MODEL_NAME = "openai-community/gpt2" @@ -58,24 +58,21 @@ def get_diff_sampling_param(self): return self.diff_sampling_param or {} -def _build_serving_tokenization(engine: AsyncLLM) -> OpenAIServingTokenization: +def _build_serving_tokenization(engine: AsyncLLM) -> ServingTokenization: models = OpenAIServingModels( engine_client=engine, base_model_paths=BASE_MODEL_PATHS, ) - serving_render = OpenAIServingRender( + online_renderer = OnlineRenderer( model_config=engine.model_config, renderer=engine.renderer, - model_registry=models.registry, request_logger=None, chat_template=None, chat_template_content_format="auto", ) - return OpenAIServingTokenization( - engine, + return ServingTokenization( models, - openai_serving_render=serving_render, - request_logger=None, + online_renderer=online_renderer, chat_template=None, chat_template_content_format="auto", ) @@ -90,7 +87,7 @@ async def test_tokenize_chat_skips_mm_cache_for_renderer_only_path(): mock_engine.renderer = MagicMock() serving = _build_serving_tokenization(mock_engine) - serving.openai_serving_render.preprocess_chat = AsyncMock( + serving.online_renderer.preprocess_chat = AsyncMock( return_value=( [{"role": "user", "content": "Test"}], [{"prompt_token_ids": [1, 2, 3]}], @@ -106,7 +103,7 @@ async def test_tokenize_chat_skips_mm_cache_for_renderer_only_path(): assert response.tokens == [1, 2, 3] assert ( - serving.openai_serving_render.preprocess_chat.call_args.kwargs["skip_mm_cache"] + serving.online_renderer.preprocess_chat.call_args.kwargs["skip_mm_cache"] is True ) @@ -120,7 +117,7 @@ async def test_tokenize_completion_skips_mm_cache_for_renderer_only_path(): mock_engine.renderer = MagicMock() serving = _build_serving_tokenization(mock_engine) - serving.openai_serving_render.preprocess_completion = AsyncMock( + serving.online_renderer.preprocess_completion = AsyncMock( return_value=[{"prompt_token_ids": [1, 2, 3]}] ) @@ -133,8 +130,6 @@ async def test_tokenize_completion_skips_mm_cache_for_renderer_only_path(): assert response.tokens == [1, 2, 3] assert ( - serving.openai_serving_render.preprocess_completion.call_args.kwargs[ - "skip_mm_cache" - ] + serving.online_renderer.preprocess_completion.call_args.kwargs["skip_mm_cache"] is True ) diff --git a/tests/entrypoints/serve/utils/__init__.py b/tests/entrypoints/serve/utils/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/entrypoints/test_utils.py b/tests/entrypoints/serve/utils/test_api_utils.py similarity index 67% rename from tests/entrypoints/test_utils.py rename to tests/entrypoints/serve/utils/test_api_utils.py index ff65066ffd2e..2429da27e1e1 100644 --- a/tests/entrypoints/test_utils.py +++ b/tests/entrypoints/serve/utils/test_api_utils.py @@ -4,7 +4,7 @@ import pytest from vllm.entrypoints.openai.engine.protocol import StreamOptions -from vllm.entrypoints.utils import ( +from vllm.entrypoints.serve.utils.api_utils import ( get_max_tokens, sanitize_message, should_include_usage, @@ -118,3 +118,49 @@ def test_input_length_exceeds_max_model_len(self): input_length=150, default_sampling_params={"max_tokens": 2048}, ) + + +class TestSanitizeMessageFilePaths: + """sanitize_message should also strip file paths and traceback + frames, not just memory addresses - see #31683.""" + + def test_strips_traceback_style_frame(self): + msg = ( + "1 validation error:\n" + " {'type': 'list_type', 'loc': ('body', 'messages')}\n" + '\n File "/usr/local/lib/python3.12/dist-packages/vllm/' + 'entrypoints/serve/utils/api_utils.py", line 40, ' + "in create_chat_completion\n" + " POST /v1/chat/completions" + ) + result = sanitize_message(msg) + assert "/usr/local/" not in result + assert "api_utils.py" not in result + assert "list_type" in result + + def test_strips_arbitrary_absolute_path(self): + result = sanitize_message("Error in /home/user/project/vllm/server.py") + assert "/home/user" not in result + + def test_strips_single_parent_container_path(self): + """Regression: /app/server.py and /workspace/server.py (common in + container deployments) were missed by the original {2,} quantifier.""" + assert "/app/" not in sanitize_message("Error in /app/server.py") + assert "/workspace/" not in sanitize_message("Error in /workspace/server.py") + + def test_preserves_api_endpoint_paths(self): + msg = "POST /v1/chat/completions failed" + assert "/v1/chat/completions" in sanitize_message(msg) + + def test_preserves_short_field_references(self): + msg = "Invalid value for field 'body.messages'" + assert sanitize_message(msg) == msg + + def test_strips_both_address_and_path(self): + msg = ( + " failed at " + "/usr/local/lib/python3.12/dist-packages/vllm/server.py" + ) + result = sanitize_message(msg) + assert "0x" not in result + assert "/usr/local/" not in result diff --git a/tests/entrypoints/serve/utils/test_error_sanitization.py b/tests/entrypoints/serve/utils/test_error_sanitization.py new file mode 100644 index 000000000000..c871dffb406f --- /dev/null +++ b/tests/entrypoints/serve/utils/test_error_sanitization.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests that error messages in Anthropic and speech-to-text entrypoints +are sanitized to prevent memory address leakage. + +Verifies the fix for the incomplete CVE-2026-22778 remediation where +PIL repr addresses leaked via the Anthropic API router and the +speech-to-text WebSocket paths. +""" + +import pytest + +from vllm.entrypoints.serve.utils.api_utils import sanitize_message + + +class TestSanitizeMessageCoversLeakPatterns: + """Ensure sanitize_message strips addresses from realistic exceptions.""" + + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ( + "cannot identify image file <_io.BytesIO object at 0x7a95e299e750>", + "cannot identify image file <_io.BytesIO object>", + ), + ( + "cannot identify image file <_io.BytesIO object at 0x7f3c1a2b4d90>", + "cannot identify image file <_io.BytesIO object>", + ), + ( + "", + "", + ), + ( + "Error processing <_io.BytesIO object at 0xdeadbeef>: invalid header", + "Error processing <_io.BytesIO object>: invalid header", + ), + ], + ids=[ + "bytesio-standard", + "bytesio-different-addr", + "pil-image-repr", + "mid-string-repr", + ], + ) + def test_address_stripped(self, raw: str, expected: str): + assert sanitize_message(raw) == expected + + def test_safe_message_unchanged(self): + msg = "Invalid request: missing 'messages' field" + assert sanitize_message(msg) == msg + + def test_multiple_addresses_stripped(self): + raw = " and " + result = sanitize_message(raw) + assert "0x" not in result + + +class TestAffectedModulesUseSanitize: + """Verify that affected modules call sanitize_message (source-level).""" + + @pytest.mark.parametrize( + "module", + [ + "vllm.entrypoints.anthropic.api_router", + "vllm.entrypoints.anthropic.serving", + "vllm.entrypoints.speech_to_text.realtime.connection", + ], + ) + def test_module_calls_sanitize_message(self, module: str): + import importlib.util + from pathlib import Path + + spec = importlib.util.find_spec(module) + assert spec is not None and spec.origin is not None, ( + f"Cannot locate module {module}" + ) + source = Path(spec.origin).read_text() + assert "sanitize_message" in source, f"{module} does not call sanitize_message" + assert "import" in source and "sanitize_message" in source diff --git a/tests/entrypoints/openai/test_fingerprint.py b/tests/entrypoints/serve/utils/test_fingerprint.py similarity index 97% rename from tests/entrypoints/openai/test_fingerprint.py rename to tests/entrypoints/serve/utils/test_fingerprint.py index b78ed38636c5..46ec6255f4e7 100644 --- a/tests/entrypoints/openai/test_fingerprint.py +++ b/tests/entrypoints/serve/utils/test_fingerprint.py @@ -6,7 +6,7 @@ import pytest -from vllm.entrypoints.openai import fingerprint as fp +from vllm.entrypoints.serve.utils import fingerprint as fp def _cfg(tp=1, pp=1, dp=1, ep=False, digest="a3b21f94deadbeef"): diff --git a/tests/entrypoints/serve/utils/test_request_logger.py b/tests/entrypoints/serve/utils/test_request_logger.py new file mode 100644 index 000000000000..c17f2471e48a --- /dev/null +++ b/tests/entrypoints/serve/utils/test_request_logger.py @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from unittest.mock import MagicMock, patch + +from vllm.entrypoints.serve.utils.request_logger import RequestLogger + + +def test_request_logger_log_outputs(): + """Test the new log_outputs functionality.""" + # Create a mock logger to capture log calls + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + request_logger = RequestLogger(max_log_len=None) + + # Test basic output logging + request_logger.log_outputs( + request_id="test-123", + outputs="Hello, world!", + output_token_ids=[1, 2, 3, 4], + finish_reason="stop", + is_streaming=False, + delta=False, + ) + + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args.args + assert "Generated response %s%s" in call_args[0] + assert call_args[1] == "test-123" + assert call_args[3] == "Hello, world!" + assert call_args[4] == [1, 2, 3, 4] + assert call_args[5] == "stop" + + +def test_request_logger_log_outputs_streaming_delta(): + """Test log_outputs with streaming delta mode.""" + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + request_logger = RequestLogger(max_log_len=None) + + # Test streaming delta logging + request_logger.log_outputs( + request_id="test-456", + outputs="Hello", + output_token_ids=[1], + finish_reason=None, + is_streaming=True, + delta=True, + ) + + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args.args + assert "Generated response %s%s" in call_args[0] + assert call_args[1] == "test-456" + assert call_args[2] == " (streaming delta)" + assert call_args[3] == "Hello" + assert call_args[4] == [1] + assert call_args[5] is None + + +def test_request_logger_log_outputs_streaming_complete(): + """Test log_outputs with streaming complete mode.""" + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + request_logger = RequestLogger(max_log_len=None) + + # Test streaming complete logging + request_logger.log_outputs( + request_id="test-789", + outputs="Complete response", + output_token_ids=[1, 2, 3], + finish_reason="length", + is_streaming=True, + delta=False, + ) + + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args.args + assert "Generated response %s%s" in call_args[0] + assert call_args[1] == "test-789" + assert call_args[2] == " (streaming complete)" + assert call_args[3] == "Complete response" + assert call_args[4] == [1, 2, 3] + assert call_args[5] == "length" + + +def test_request_logger_log_outputs_with_truncation(): + """Test log_outputs respects max_log_len setting.""" + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + # Set max_log_len to 10 + request_logger = RequestLogger(max_log_len=10) + + # Test output truncation + long_output = "This is a very long output that should be truncated" + long_token_ids = list(range(20)) # 20 tokens + + request_logger.log_outputs( + request_id="test-truncate", + outputs=long_output, + output_token_ids=long_token_ids, + finish_reason="stop", + is_streaming=False, + delta=False, + ) + + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args + + # Check that output was truncated to first 10 characters + logged_output = call_args[0][3] + assert logged_output == "This is a " + assert len(logged_output) == 10 + + # Check that token IDs were truncated to first 10 tokens + logged_token_ids = call_args[0][4] + assert logged_token_ids == list(range(10)) + assert len(logged_token_ids) == 10 + + +def test_request_logger_log_outputs_none_values(): + """Test log_outputs handles None values correctly.""" + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + request_logger = RequestLogger(max_log_len=None) + + # Test with None output_token_ids + request_logger.log_outputs( + request_id="test-none", + outputs="Test output", + output_token_ids=None, + finish_reason="stop", + is_streaming=False, + delta=False, + ) + + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args.args + assert "Generated response %s%s" in call_args[0] + assert call_args[1] == "test-none" + assert call_args[3] == "Test output" + assert call_args[4] is None + assert call_args[5] == "stop" + + +def test_request_logger_log_outputs_empty_output(): + """Test log_outputs handles empty output correctly.""" + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + request_logger = RequestLogger(max_log_len=5) + + # Test with empty output + request_logger.log_outputs( + request_id="test-empty", + outputs="", + output_token_ids=[], + finish_reason="stop", + is_streaming=False, + delta=False, + ) + + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args.args + assert "Generated response %s%s" in call_args[0] + assert call_args[1] == "test-empty" + assert call_args[3] == "" + assert call_args[4] == [] + assert call_args[5] == "stop" + + +def test_request_logger_log_outputs_integration(): + """Test that log_outputs can be called alongside log_inputs.""" + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + request_logger = RequestLogger(max_log_len=None) + + # Test that both methods can be called without interference + request_logger.log_inputs( + request_id="test-integration", + prompt="Test prompt", + prompt_token_ids=[1, 2, 3], + prompt_embeds=None, + params=None, + lora_request=None, + ) + + request_logger.log_outputs( + request_id="test-integration", + outputs="Test output", + output_token_ids=[4, 5, 6], + finish_reason="stop", + is_streaming=False, + delta=False, + ) + + # Should have been called twice - once for inputs, once for outputs + assert mock_logger.info.call_count == 2 + + # Check that the calls were made with correct patterns + input_call = mock_logger.info.call_args_list[0][0] + output_call = mock_logger.info.call_args_list[1][0] + + assert "Received request %s" in input_call[0] + assert input_call[1] == "test-integration" + + assert "Generated response %s%s" in output_call[0] + assert output_call[1] == "test-integration" + + +def test_streaming_complete_logs_full_text_content(): + """Test that streaming complete logging includes + full accumulated text, not just token count.""" + mock_logger = MagicMock() + + with patch("vllm.entrypoints.serve.utils.request_logger.logger", mock_logger): + request_logger = RequestLogger(max_log_len=None) + + # Test with actual content instead of token count format + full_response = "This is a complete response from streaming" + request_logger.log_outputs( + request_id="test-streaming-full-text", + outputs=full_response, + output_token_ids=None, + finish_reason="streaming_complete", + is_streaming=True, + delta=False, + ) + + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args.args + + # Verify the logged output is the full text, not a token count format + logged_output = call_args[3] + assert logged_output == full_response + assert "tokens>" not in logged_output + assert "streaming_complete" not in logged_output + + # Verify other parameters + assert call_args[1] == "test-streaming-full-text" + assert call_args[2] == " (streaming complete)" + assert call_args[5] == "streaming_complete" diff --git a/tests/entrypoints/serve/utils/test_server_utils.py b/tests/entrypoints/serve/utils/test_server_utils.py new file mode 100644 index 000000000000..b135d1c97c70 --- /dev/null +++ b/tests/entrypoints/serve/utils/test_server_utils.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests that validation_exception_handler populates the `param` field +in its error response using the Pydantic error's `loc`, even when no +custom VLLMValidationError context is present. + +Previously, `param` was only populated for errors carrying a custom +VLLMValidationError in their Pydantic `ctx`. Plain validation failures +(missing fields, wrong types) left `param` as None, even though the +field name was readily available from `error['loc']`. +""" + +import json +from types import SimpleNamespace + +import pytest +from fastapi.exceptions import RequestValidationError + +from vllm.entrypoints.serve.utils.server_utils import ( + clean_loc_for_param, + validation_exception_handler, +) + + +def _fake_request(log_error_stack: bool = False) -> SimpleNamespace: + """Minimal stand-in for a FastAPI Request - just enough for the + handler to read req.app.state.args.log_error_stack.""" + return SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace(args=SimpleNamespace(log_error_stack=log_error_stack)) + ), + state=SimpleNamespace(), # no request_metadata -> hasattr(...) is False + ) + + +class TestValidationErrorParamFallback: + """Ensure `param` falls back to the Pydantic error's `loc` when no + custom VLLMValidationError context is present.""" + + @pytest.mark.parametrize( + ("error_type", "msg"), + [ + ("missing", "Field required"), + ("list_type", "Input should be a valid list"), + ], + ids=["missing-field", "wrong-type"], + ) + @pytest.mark.asyncio + async def test_param_falls_back_to_loc(self, error_type: str, msg: str): + errors = [{"type": error_type, "loc": ("body", "messages"), "msg": msg}] + exc = RequestValidationError(errors) + + response = await validation_exception_handler(_fake_request(), exc) + body = json.loads(response.body) + + assert body["error"]["param"] == "body.messages" + + @pytest.mark.asyncio + async def test_param_fallback_does_not_crash_on_non_dict_error(self): + """Schemathesis fuzzing found that errors[0] isn't always a dict. + The fallback must not crash in that case - it should just leave + param as None instead of raising.""" + exc = RequestValidationError(["some unexpected non-dict error"]) + + response = await validation_exception_handler(_fake_request(), exc) + body = json.loads(response.body) + + assert body["error"]["param"] is None + + +class TestCleanLocForParam: + """Guards against PR #1's naive dot-joined `loc` fallback leaking + Pydantic-internal wrapper/union-branch markers into `param`, e.g. + 'body.function-wrap[__log_extra_fields__()].prompt' instead of the + clean 'body.prompt' an API consumer would recognize. + """ + + @pytest.mark.parametrize( + "loc,expected", + [ + (("body", "prompt"), "body.prompt"), + (("body", "messages", 2, "content"), "body.messages.2.content"), + ( + ("body", "function-wrap[__log_extra_fields__()]", "prompt"), + "body.prompt", + ), + (("body", "stop", "str"), "body.stop"), + (("body", "stop", "list[str]"), "body.stop"), + ( + ("body", "prompt", "list[constrained-int]"), + "body.prompt", + ), + ], + ) + def test_strips_internal_markers(self, loc, expected): + assert clean_loc_for_param(loc) == expected + + def test_all_internal_falls_back_to_raw_join(self): + """If every loc segment looks internal, fall back to the raw + dot-join rather than returning an empty string.""" + loc = ("function-wrap[__log_extra_fields__()]",) + assert clean_loc_for_param(loc) == "function-wrap[__log_extra_fields__()]" + + +class TestValidationErrorDoesNotLeakServerPaths: + """FastAPI sets endpoint_file/line/function/path on the exception + during routing, not in the constructor - so we set them manually + here to reproduce the real leak. See #31683.""" + + @pytest.mark.asyncio + async def test_handler_strips_endpoint_file_context(self): + errors = [ + { + "type": "list_type", + "loc": ("body", "messages"), + "msg": "Input should be a valid list", + "input": "not-a-list", + } + ] + exc = RequestValidationError(errors) + exc.endpoint_file = ( + "/usr/local/lib/python3.12/dist-packages/vllm/" + "entrypoints/serve/utils/api_utils.py" + ) + exc.endpoint_line = 40 + exc.endpoint_function = "create_chat_completion" + exc.endpoint_path = "POST /v1/chat/completions" + + response = await validation_exception_handler(_fake_request(), exc) + body = json.loads(response.body) + message = body["error"]["message"] + + assert "/usr/local/" not in message + assert "api_utils.py" not in message + assert "create_chat_completion" not in message + assert "list_type" in message + assert "Input should be a valid list" in message + + @pytest.mark.asyncio + async def test_handler_strips_endpoint_path_only_variant(self): + """Covers the branch where only endpoint_path is set.""" + errors = [ + {"type": "missing", "loc": ("body", "messages"), "msg": "Field required"} + ] + exc = RequestValidationError(errors) + exc.endpoint_path = "POST /v1/chat/completions" + + response = await validation_exception_handler(_fake_request(), exc) + body = json.loads(response.body) + message = body["error"]["message"] + + assert "missing" in message + assert "Field required" in message diff --git a/tests/entrypoints/test_ssl_cert_refresher.py b/tests/entrypoints/serve/utils/test_ssl_cert_refresher.py similarity index 55% rename from tests/entrypoints/test_ssl_cert_refresher.py rename to tests/entrypoints/serve/utils/test_ssl_cert_refresher.py index b56fbd9fee7e..8f5251374a6b 100644 --- a/tests/entrypoints/test_ssl_cert_refresher.py +++ b/tests/entrypoints/serve/utils/test_ssl_cert_refresher.py @@ -7,7 +7,7 @@ import pytest -from vllm.entrypoints.ssl import SSLCertRefresher +from vllm.entrypoints.serve.utils.ssl import SSLCertRefresher class MockSSLContext(SSLContext): @@ -41,6 +41,28 @@ def touch_file(path: str) -> None: Path(path).touch() +async def wait_for_counts( + ssl_context: MockSSLContext, + *, + cert_chain_count: int, + ca_count: int, + timeout: float = 5.0, +) -> None: + deadline = asyncio.get_running_loop().time() + timeout + while True: + if ( + ssl_context.load_cert_chain_count >= cert_chain_count + and ssl_context.load_ca_count >= ca_count + ): + return + + if asyncio.get_running_loop().time() >= deadline: + assert ssl_context.load_cert_chain_count >= cert_chain_count + assert ssl_context.load_ca_count >= ca_count + + await asyncio.sleep(0.05) + + @pytest.mark.asyncio async def test_ssl_refresher(): ssl_context = MockSSLContext() @@ -53,20 +75,28 @@ async def test_ssl_refresher(): assert ssl_context.load_ca_count == 0 touch_file(key_path) - await asyncio.sleep(1) - assert ssl_context.load_cert_chain_count == 1 + await wait_for_counts( + ssl_context, + cert_chain_count=1, + ca_count=0, + ) assert ssl_context.load_ca_count == 0 touch_file(cert_path) touch_file(ca_path) - await asyncio.sleep(1) - assert ssl_context.load_cert_chain_count == 2 - assert ssl_context.load_ca_count == 1 + await wait_for_counts( + ssl_context, + cert_chain_count=2, + ca_count=1, + ) ssl_refresher.stop() + await asyncio.sleep(0) + cert_chain_count = ssl_context.load_cert_chain_count + ca_count = ssl_context.load_ca_count touch_file(cert_path) touch_file(ca_path) await asyncio.sleep(1) - assert ssl_context.load_cert_chain_count == 2 - assert ssl_context.load_ca_count == 1 + assert ssl_context.load_cert_chain_count == cert_chain_count + assert ssl_context.load_ca_count == ca_count diff --git a/tests/entrypoints/speech_to_text/correctness/test_transcription_api_correctness.py b/tests/entrypoints/speech_to_text/correctness/test_transcription_api_correctness.py index fedbd74795b5..af61ebc52648 100644 --- a/tests/entrypoints/speech_to_text/correctness/test_transcription_api_correctness.py +++ b/tests/entrypoints/speech_to_text/correctness/test_transcription_api_correctness.py @@ -16,10 +16,11 @@ import pytest import soundfile import torch -from datasets import load_dataset +from datasets import Audio, load_dataset from evaluate import load from transformers.models.whisper.english_normalizer import EnglishTextNormalizer +from vllm.benchmarks.datasets.datasets import ASRDataset from vllm.multimodal.audio import get_audio_duration from vllm.tokenizers import get_tokenizer @@ -38,6 +39,20 @@ def to_bytes(y, sr): return buffer +def load_audio_sample(audio): + # Avoid torchcodec in CI by decoding dataset audio with soundfile. + if "array" in audio and "sampling_rate" in audio: + return audio["array"], audio["sampling_rate"] + + if audio.get("path"): + return soundfile.read(audio["path"], dtype="float32") + + if audio.get("bytes") is not None: + return soundfile.read(io.BytesIO(audio["bytes"]), dtype="float32") + + raise ValueError("Audio sample did not contain array, path, or bytes data") + + # not all models have a normalizer so use the one from whisper as a standard option normalizer_model_info = HF_EXAMPLE_MODELS.find_hf_info("openai/whisper-large-v3") normalizer_tokenizer = get_tokenizer( @@ -48,7 +63,7 @@ def to_bytes(y, sr): normalizer = EnglishTextNormalizer(normalizer_tokenizer.english_spelling_normalizer) -async def transcribe_audio(client, tokenizer, y, sr): +async def transcribe_audio(client, tokenizer, y, sr, extra_body=None): # Send loaded audio directly instead of loading from disk, # don't account for that time though with to_bytes(y, sr) as f: @@ -58,6 +73,7 @@ async def transcribe_audio(client, tokenizer, y, sr): model=tokenizer.name_or_path, language="en", temperature=0.0, + extra_body=extra_body, ) end_time = time.perf_counter() # NOTE there's no streaming in transcriptions, can't measure ttft @@ -68,17 +84,21 @@ async def transcribe_audio(client, tokenizer, y, sr): return latency, num_output_tokens, transcription.text -async def bound_transcribe(sem, client, tokenizer, audio, reference): +async def bound_transcribe( + sem, client, tokenizer, audio, sr, reference, extra_body=None +): # Use semaphore to limit concurrent requests. async with sem: - result = await transcribe_audio(client, tokenizer, *audio) + result = await transcribe_audio( + client, tokenizer, audio, sr, extra_body=extra_body + ) # Normalize *english* output/reference for evaluation. out = normalizer(result[2]) ref = normalizer(reference) return result[:2] + (out, ref) -async def process_dataset(model, client, data, concurrent_request): +async def process_dataset(model, client, data, concurrent_request, extra_body=None): sem = asyncio.Semaphore(concurrent_request) model_info = HF_EXAMPLE_MODELS.find_hf_info(model) @@ -89,14 +109,16 @@ async def process_dataset(model, client, data, concurrent_request): ) # Warmup call as the first `load_audio` server-side is quite slow. - audio, sr = data[0]["audio"]["array"], data[0]["audio"]["sampling_rate"] - _ = await bound_transcribe(sem, client, tokenizer, (audio, sr), "") + audio, sr = load_audio_sample(data[0]["audio"]) + _ = await bound_transcribe(sem, client, tokenizer, audio, sr, "", extra_body) tasks: list[asyncio.Task] = [] for sample in data: - audio, sr = sample["audio"]["array"], sample["audio"]["sampling_rate"] + audio, sr = load_audio_sample(sample["audio"]) task = asyncio.create_task( - bound_transcribe(sem, client, tokenizer, (audio, sr), sample["text"]) + bound_transcribe( + sem, client, tokenizer, audio, sr, sample["text"], extra_body + ) ) tasks.append(task) return await asyncio.gather(*tasks) @@ -121,19 +143,36 @@ def print_performance_metrics(results, total_time): def add_duration(sample): - y, sr = sample["audio"]["array"], sample["audio"]["sampling_rate"] + y, sr = load_audio_sample(sample["audio"]) sample["duration_ms"] = get_audio_duration(y=y, sr=sr) * 1000 return sample -def load_hf_dataset(dataset_repo: str, split="validation", **hf_kwargs): - ## Load and filter the dataset - dataset = load_dataset(dataset_repo, split=split, **hf_kwargs) - if "duration_ms" not in dataset[0]: - # compute duration to filter +def load_asr_dataset_rows(dataset_repo: str, split="validation", **hf_kwargs): + if dataset_repo in ASRDataset.SUPPORTED_DATASET_PATHS: + asr_dataset_kwargs = { + "dataset_path": dataset_repo, + "dataset_split": split, + "disable_shuffle": True, + "no_stream": True, + } + for key in ("dataset_subset", "hf_name", "trust_remote_code"): + if key in hf_kwargs: + asr_dataset_kwargs[key] = hf_kwargs[key] + return ASRDataset(**asr_dataset_kwargs).data + + return load_dataset(dataset_repo, split=split, **hf_kwargs) + + +def load_shortform_eval_dataset(dataset_repo: str, split="validation", **hf_kwargs): + ## Load and filter the dataset. + dataset = load_asr_dataset_rows(dataset_repo, split=split, **hf_kwargs) + dataset = dataset.cast_column("audio", Audio(decode=False)) + if "duration_ms" not in dataset.column_names: + # Compute duration to filter. dataset = dataset.map(add_duration) - # Whisper max supported duration + # Whisper max supported duration. dataset = dataset.filter(lambda example: example["duration_ms"] < 30000) return dataset @@ -145,11 +184,16 @@ def run_evaluation( max_concurrent_reqs: int, n_examples: int = -1, print_metrics: bool = True, + extra_body=None, ): if n_examples > 0: dataset = dataset.select(range(n_examples)) start = time.perf_counter() - results = asyncio.run(process_dataset(model, client, dataset, max_concurrent_reqs)) + results = asyncio.run( + process_dataset( + model, client, dataset, max_concurrent_reqs, extra_body=extra_body + ) + ) end = time.perf_counter() total_time = end - start print(f"Total Test Time: {total_time:.4f} seconds") @@ -164,6 +208,106 @@ def run_evaluation( return wer_score +LONGFORM_DATASET_REPO = ASRDataset.EARNINGS22_CLEANED_DATASET +LONGFORM_DATASET_SPLIT = "test" +LONGFORM_NUM_SAMPLES = 6 + + +def load_longform_dataset(): + dataset = load_asr_dataset_rows( + LONGFORM_DATASET_REPO, + split=LONGFORM_DATASET_SPLIT, + ) + assert len(dataset) >= LONGFORM_NUM_SAMPLES + return dataset.select(range(LONGFORM_NUM_SAMPLES)) + + +async def transcribe_audio_path(client, tokenizer, audio_path: str, extra_body=None): + with open(audio_path, "rb") as f: + start_time = time.perf_counter() + transcription = await client.audio.transcriptions.create( + file=f, + model=tokenizer.name_or_path, + language="en", + temperature=0.0, + extra_body=extra_body, + ) + end_time = time.perf_counter() + + latency = end_time - start_time + num_output_tokens = len( + tokenizer(transcription.text, add_special_tokens=False).input_ids + ) + return latency, num_output_tokens, transcription.text + + +async def bound_transcribe_path( + sem, client, tokenizer, audio_path, reference, extra_body=None +): + async with sem: + result = await transcribe_audio_path( + client, tokenizer, audio_path, extra_body=extra_body + ) + out = normalizer(result[2]) + ref = normalizer(reference) + return result[:2] + (out, ref) + + +async def process_longform_dataset( + model, client, data, concurrent_request, extra_body=None +): + sem = asyncio.Semaphore(concurrent_request) + + model_info = HF_EXAMPLE_MODELS.find_hf_info(model) + tokenizer = get_tokenizer( + model, + tokenizer_mode=model_info.tokenizer_mode, + trust_remote_code=model_info.trust_remote_code, + ) + + warmup_path = data[0]["audio"]["path"] + _ = await bound_transcribe_path(sem, client, tokenizer, warmup_path, "", extra_body) + + tasks: list[asyncio.Task] = [] + for sample in data: + audio_path = sample["audio"]["path"] + task = asyncio.create_task( + bound_transcribe_path( + sem, client, tokenizer, audio_path, sample["text"], extra_body + ) + ) + tasks.append(task) + return await asyncio.gather(*tasks) + + +def run_longform_evaluation( + model: str, + client, + dataset, + max_concurrent_reqs: int, + print_metrics: bool = True, + extra_body=None, +): + start = time.perf_counter() + results = asyncio.run( + process_longform_dataset( + model, client, dataset, max_concurrent_reqs, extra_body=extra_body + ) + ) + end = time.perf_counter() + total_time = end - start + print(f"Total Test Time: {total_time:.4f} seconds") + if print_metrics: + print_performance_metrics(results, total_time) + + predictions = [res[2] for res in results] + references = [res[3] for res in results] + wer = load("wer") + wer_score = 100 * wer.compute(references=references, predictions=predictions) + print("WER:", wer_score) + return wer_score + + # alternatives "openai/whisper-large-v2", "openai/whisper-large-v3-turbo".. # NOTE: Expected WER measured with equivalent hf.transformers args: # whisper-large-v3 + esb-datasets-earnings22-validation-tiny-filtered. @@ -184,7 +328,6 @@ def test_wer_correctness( ): model_name, expected_wer = model_config model_info = HF_EXAMPLE_MODELS.find_hf_info(model_name) - # TODO refactor to use `ASRDataset` server_args = [ "--enforce-eager", f"--tokenizer_mode={model_info.tokenizer_mode}", @@ -197,7 +340,7 @@ def test_wer_correctness( model_name, server_args, ) as remote_server: - dataset = load_hf_dataset(dataset_repo) + dataset = load_shortform_eval_dataset(dataset_repo) if not max_concurrent_request: # No max concurrency @@ -216,3 +359,42 @@ def test_wer_correctness( if expected_wer: torch.testing.assert_close(wer, expected_wer, atol=1e-1, rtol=1e-2) + + +# 14-22mins of 6 audio samples of total ~115 mins and just 37MB. +# checks for long audio transcription correctness and RMS split. +@pytest.mark.parametrize( + "model_config", + [("openai/whisper-large-v3", 9.5)], +) +def test_long_audio_wer_correctness(model_config): + model_name, expected_wer = model_config + model_info = HF_EXAMPLE_MODELS.find_hf_info(model_name) + server_args = [ + f"--tokenizer_mode={model_info.tokenizer_mode}", + ] + + if model_info.trust_remote_code: + server_args.append("--trust-remote-code") + + # 1800 seconds is 30 minutes + env_dict = { + "VLLM_MAX_AUDIO_DECODE_DURATION_S": "1800", + } + + with RemoteOpenAIServer( + model_name, + server_args, + env_dict=env_dict, + ) as remote_server: + dataset = load_longform_dataset() + client = remote_server.get_async_client() + wer = run_longform_evaluation( + model=model_name, + client=client, + dataset=dataset, + max_concurrent_reqs=LONGFORM_NUM_SAMPLES, + ) + + print(f"Expected WER: {expected_wer}, Actual WER: {wer}") + torch.testing.assert_close(wer, expected_wer, atol=1e-1, rtol=1e-2) diff --git a/tests/entrypoints/speech_to_text/test_speech_to_text_cancellation.py b/tests/entrypoints/speech_to_text/test_speech_to_text_cancellation.py index 08553c641103..040fc1a48ff1 100644 --- a/tests/entrypoints/speech_to_text/test_speech_to_text_cancellation.py +++ b/tests/entrypoints/speech_to_text/test_speech_to_text_cancellation.py @@ -7,7 +7,7 @@ import pytest -from vllm.entrypoints.speech_to_text.base.serving import OpenAISpeechToText +from vllm.entrypoints.speech_to_text.base.serving import SpeechToTextBaseServing from vllm.entrypoints.speech_to_text.transcription.protocol import TranscriptionResponse @@ -43,7 +43,7 @@ async def test_non_streaming_cancel_aborts_engine_requests( is_tracing_enabled=AsyncMock(return_value=False), ) - server = OpenAISpeechToText.__new__(OpenAISpeechToText) + server = SpeechToTextBaseServing.__new__(SpeechToTextBaseServing) server.engine_client = engine_client server.task_type = "transcribe" server.models = SimpleNamespace(model_name=lambda: "audio") @@ -99,8 +99,8 @@ async def test_non_streaming_cancel_advances_all_chunk_generators(): engine_client = SimpleNamespace( errored=False, generate=Mock( - side_effect=lambda *_args, **_kwargs: ( - _records_start_then_never_finishes(started_request_ids, _args[2]) + side_effect=lambda *_args, **_kwargs: _records_start_then_never_finishes( + started_request_ids, _args[2] ) ), abort=AsyncMock(), @@ -112,7 +112,7 @@ async def test_non_streaming_cancel_advances_all_chunk_generators(): {"prompt": "chunk-1"}, {"prompt": "chunk-2"}, ] - server = OpenAISpeechToText.__new__(OpenAISpeechToText) + server = SpeechToTextBaseServing.__new__(SpeechToTextBaseServing) server.engine_client = engine_client server.task_type = "transcribe" server.models = SimpleNamespace(model_name=lambda: "audio") @@ -170,7 +170,7 @@ async def test_language_detection_cancel_aborts_engine_request(): abort=AsyncMock(), ) - server = OpenAISpeechToText.__new__(OpenAISpeechToText) + server = SpeechToTextBaseServing.__new__(SpeechToTextBaseServing) server.engine_client = engine_client server.asr_config = SimpleNamespace() server.tokenizer = Mock() diff --git a/tests/entrypoints/speech_to_text/test_upload_size_limit.py b/tests/entrypoints/speech_to_text/test_upload_size_limit.py new file mode 100644 index 000000000000..5d38e7691948 --- /dev/null +++ b/tests/entrypoints/speech_to_text/test_upload_size_limit.py @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression tests for the speech-to-text upload size pre-check. + +These tests verify that over-limit audio uploads are rejected *before* +the full file is materialized into memory, closing the vulnerability +where vLLM would allocate memory proportional to an oversized upload +before enforcing the VLLM_MAX_AUDIO_CLIP_FILESIZE_MB limit. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from vllm.entrypoints.speech_to_text.base.utils import read_upload_with_limit +from vllm.exceptions import VLLMValidationError + + +def _make_upload_file(data: bytes, *, size: int | None = None) -> AsyncMock: + """Create a mock UploadFile that yields data in chunks.""" + mock = AsyncMock() + mock.size = size + + offset = 0 + + async def _read(n: int = -1): + nonlocal offset + if n <= 0: + chunk = data[offset:] + offset = len(data) + return chunk + chunk = data[offset : offset + n] + offset += len(chunk) + return chunk + + mock.read = AsyncMock(side_effect=_read) + return mock + + +@pytest.mark.asyncio +async def test_rejects_oversized_upload_via_content_length(): + """File is rejected early when file.size exceeds the limit.""" + max_mb = 1 + oversized_bytes = max_mb * 1024 * 1024 + 1 + + upload = _make_upload_file(b"", size=oversized_bytes) + + with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"): + await read_upload_with_limit(upload, max_size_mb=max_mb) + + upload.read.assert_not_called() + + +@pytest.mark.asyncio +async def test_rejects_oversized_upload_via_chunked_read(): + """File is rejected mid-read without materializing the full content.""" + max_mb = 1 + max_bytes = max_mb * 1024 * 1024 + oversized_data = b"\x00" * (max_bytes + 1024) + + upload = _make_upload_file(oversized_data, size=None) + + with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"): + await read_upload_with_limit(upload, max_size_mb=max_mb) + + +@pytest.mark.asyncio +async def test_accepts_file_within_limit(): + """File within the limit is read successfully.""" + max_mb = 1 + data = b"\x00" * (512 * 1024) # 512 KiB, well under 1 MB + + upload = _make_upload_file(data, size=len(data)) + result = await read_upload_with_limit(upload, max_size_mb=max_mb) + + assert result == data + + +@pytest.mark.asyncio +async def test_accepts_file_at_exact_limit(): + """File exactly at the limit boundary is accepted.""" + max_mb = 1 + max_bytes = max_mb * 1024 * 1024 + data = b"\x00" * max_bytes + + upload = _make_upload_file(data, size=len(data)) + result = await read_upload_with_limit(upload, max_size_mb=max_mb) + + assert result == data + + +@pytest.mark.asyncio +async def test_rejects_at_one_byte_over_limit(): + """File one byte over the limit is rejected.""" + max_mb = 1 + max_bytes = max_mb * 1024 * 1024 + data = b"\x00" * (max_bytes + 1) + + upload = _make_upload_file(data, size=None) + + with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"): + await read_upload_with_limit(upload, max_size_mb=max_mb) + + +@pytest.mark.asyncio +async def test_uses_env_default_when_no_limit_specified(): + """Uses VLLM_MAX_AUDIO_CLIP_FILESIZE_MB when max_size_mb is not given.""" + with patch("vllm.entrypoints.speech_to_text.base.utils.envs") as mock_envs: + mock_envs.VLLM_MAX_AUDIO_CLIP_FILESIZE_MB = 2 + max_bytes = 2 * 1024 * 1024 + oversized_data = b"\x00" * (max_bytes + 1) + + upload = _make_upload_file(oversized_data, size=None) + + with pytest.raises(VLLMValidationError, match="Maximum file size exceeded"): + await read_upload_with_limit(upload) + + +@pytest.mark.asyncio +async def test_chunked_read_does_not_fully_materialize(): + """Verify that for large oversized files, we stop reading early. + + The function reads in 64 KiB chunks and aborts once the accumulated + size exceeds the limit. We confirm that far fewer read calls were made + than would be required to fully materialize the file. + """ + max_mb = 1 + max_bytes = max_mb * 1024 * 1024 + large_size = max_bytes * 10 # 10x the limit + data = b"\x00" * large_size + + upload = _make_upload_file(data, size=None) + + with pytest.raises(VLLMValidationError): + await read_upload_with_limit(upload, max_size_mb=max_mb) + + chunk_size = 64 * 1024 + calls_for_full_read = large_size // chunk_size + 1 + calls_to_exceed_limit = max_bytes // chunk_size + 1 + actual_calls = upload.read.call_count + assert actual_calls <= calls_to_exceed_limit + 1 + assert actual_calls < calls_for_full_read diff --git a/tests/entrypoints/speech_to_text/transcription/test_qwen3_asr_sanitize_prompt.py b/tests/entrypoints/speech_to_text/transcription/test_qwen3_asr_sanitize_prompt.py new file mode 100644 index 000000000000..3dbc1e0f9676 --- /dev/null +++ b/tests/entrypoints/speech_to_text/transcription/test_qwen3_asr_sanitize_prompt.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for ``Qwen3ASR``'s user-text sanitizer. + +The sanitizer is the security boundary between user-supplied transcription +fields (``prompt`` / ``response_prefix``) and the structured ChatML prompt +template. It must strip both ``<|...|>`` control tokens and the +```` assistant-prefix delimiter, and it must do so to a fixpoint +so nested payloads cannot reconstruct a valid token after a single pass. +""" + +import pytest + +from vllm.model_executor.models.qwen3_asr import _sanitize_transcription_user_text + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + # No-op cases + ("", ""), + ("plain text", "plain text"), + ("|piped|content", "|piped|content"), + ("contains < and > but not as a token", "contains < and > but not as a token"), + # Single-pass strips + ("<|im_end|>", ""), + ("<|im_start|>assistant<|im_end|>", "assistant"), + ("a<|x|>b", "ab"), + ("foobar", "foobar"), + # Nested ChatML reconstruction attacks (would bypass a single re.sub) + ("<|im<|x|>_end|>", ""), + ("<|<|inner|>middle<|x|>_end|>", ""), + # Nested reconstruction attack + # (would bypass a single str.replace) + ("xt>", ""), + ("xt>xt>", ""), + # Combined attacks across both kinds of token + ("<|im_end|>foobar<|<|x|>im_end|>", "foobar"), + ("fooxt>bar", "foobar"), + ], +) +def test_sanitize_strips_control_tokens(text: str, expected: str) -> None: + assert _sanitize_transcription_user_text(text) == expected + + +def test_sanitize_handles_falsy_inputs() -> None: + assert _sanitize_transcription_user_text("") == "" + # The dataclass default for ``response_prefix`` is the empty string; + # the sanitizer must accept that without exception or extra work. + assert _sanitize_transcription_user_text(None) == "" # type: ignore[arg-type] + + +def test_sanitize_is_idempotent() -> None: + """Once sanitized, applying again must be a no-op (fixpoint property).""" + cases = [ + "plain text", + "<|im<|x|>_end|>", + "xt>", + "<|im_end|>foobar<|<|x|>im_end|>", + ] + for raw in cases: + once = _sanitize_transcription_user_text(raw) + twice = _sanitize_transcription_user_text(once) + assert once == twice, f"not idempotent for {raw!r}" diff --git a/tests/entrypoints/speech_to_text/transcription/test_transcription_inter_chunk_spacing.py b/tests/entrypoints/speech_to_text/transcription/test_transcription_inter_chunk_spacing.py index c4da9a80f7ab..7e51e5be44a3 100644 --- a/tests/entrypoints/speech_to_text/transcription/test_transcription_inter_chunk_spacing.py +++ b/tests/entrypoints/speech_to_text/transcription/test_transcription_inter_chunk_spacing.py @@ -25,14 +25,18 @@ ) from vllm.entrypoints.openai.models.serving import OpenAIServingModels from vllm.entrypoints.speech_to_text.base.serving import ( - OpenAISpeechToText, + SpeechToTextBaseServing, asr_inter_chunk_separator, ) from vllm.entrypoints.speech_to_text.transcription.protocol import TranscriptionRequest from vllm.entrypoints.speech_to_text.transcription.serving import ( OpenAIServingTranscription, ) -from vllm.model_executor.models.interfaces import SupportsTranscription +from vllm.model_executor.models.interfaces import ( + StreamingTranscriptionPostProcessor, + SupportsTranscription, +) +from vllm.model_executor.models.qwen3_asr import Qwen3ASRForConditionalGeneration from vllm.outputs import CompletionOutput, RequestOutput # --- Unit: helper + protocol ------------------------------------------------- @@ -58,6 +62,63 @@ def test_asr_inter_chunk_separator_matches_protocol(language, expected_sep): assert sep == expected_sep +def test_qwen3_asr_stream_processor_passes_plain_text_without_prefix(): + post_processor = ( + Qwen3ASRForConditionalGeneration.get_streaming_post_processor_cls()() + ) + + assert post_processor.process_delta("Hello", False) == "Hello" + assert post_processor.process_delta(" world", True) == " world" + + +def test_qwen3_asr_stream_processor_buffers_prefix_with_leading_space(): + post_processor = ( + Qwen3ASRForConditionalGeneration.get_streaming_post_processor_cls()() + ) + + assert post_processor.process_delta(" language Eng", False) == "" + assert post_processor.process_delta("lishHello", True) == "Hello" + + +def test_qwen3_asr_stream_processor_keeps_independent_state(): + processor_cls = Qwen3ASRForConditionalGeneration.get_streaming_post_processor_cls() + first_processor = processor_cls() + second_processor = processor_cls() + + assert first_processor.process_delta(" language Eng", False) == "" + assert second_processor.process_delta("plain text", True) == "plain text" + assert first_processor.process_delta("lishHello", True) == "Hello" + + +def test_qwen3_asr_stream_processor_emits_finished_incomplete_prefix(): + post_processor = ( + Qwen3ASRForConditionalGeneration.get_streaming_post_processor_cls()() + ) + + assert ( + post_processor.process_delta(" language English", True) == " language English" + ) + + +def test_qwen3_asr_stream_processor_stops_buffering_long_plain_prefix(): + post_processor = ( + Qwen3ASRForConditionalGeneration.get_streaming_post_processor_cls()() + ) + text = " language " + ("x" * 50) + + assert post_processor.process_delta(text, False) == text + + +def test_qwen3_asr_stream_processor_stops_buffering_prefix_with_newline(): + post_processor = ( + Qwen3ASRForConditionalGeneration.get_streaming_post_processor_cls()() + ) + text = " language English\nhello" + + assert post_processor.process_delta(text, False) == text + + def test_joined_chunks_english_has_space_between(): sep = asr_inter_chunk_separator("en", SupportsTranscription.no_space_languages) assert sep.join(["hello", "world"]) == "hello world" @@ -90,8 +151,14 @@ def get_speech_to_text_config( def post_process_output(cls, text: str) -> str: return text + @classmethod + def get_streaming_post_processor_cls( + cls, + ) -> type[StreamingTranscriptionPostProcessor]: + return StreamingTranscriptionPostProcessor -def _request_output(text: str) -> RequestOutput: + +def _request_output(text: str, finish_reason: str | None = "stop") -> RequestOutput: return RequestOutput( request_id="rid", prompt=None, @@ -104,7 +171,7 @@ def _request_output(text: str) -> RequestOutput: token_ids=(1, 2, 3), cumulative_logprob=None, logprobs=None, - finish_reason="stop", + finish_reason=finish_reason, ) ], finished=True, @@ -141,6 +208,9 @@ async def gen_world() -> AsyncGenerator[RequestOutput, None]: serving = OpenAIServingTranscription.__new__(OpenAIServingTranscription) serving.enable_force_include_usage = False serving.model_cls = _StubTranscriptionModel + serving.streaming_post_processor_cls = ( + _StubTranscriptionModel.get_streaming_post_processor_cls() + ) serving.task_type = "transcribe" request = SimpleNamespace( model="stub-model", @@ -178,6 +248,9 @@ async def gen_b() -> AsyncGenerator[RequestOutput, None]: serving = OpenAIServingTranscription.__new__(OpenAIServingTranscription) serving.enable_force_include_usage = False serving.model_cls = _StubTranscriptionModel + serving.streaming_post_processor_cls = ( + _StubTranscriptionModel.get_streaming_post_processor_cls() + ) serving.task_type = "transcribe" request = SimpleNamespace( model="stub-model", @@ -203,6 +276,48 @@ async def gen_b() -> AsyncGenerator[RequestOutput, None]: assert combined == "你好世界" +@pytest.mark.asyncio +async def test_transcription_stream_generator_strips_qwen3_asr_prefix_per_chunk(): + async def gen_hello() -> AsyncGenerator[RequestOutput, None]: + yield _request_output("language Eng", finish_reason=None) + yield _request_output("lishHello", finish_reason=None) + yield _request_output("") + + async def gen_world() -> AsyncGenerator[RequestOutput, None]: + yield _request_output(" language Eng", finish_reason=None) + yield _request_output("lishworld") + + serving = OpenAIServingTranscription.__new__(OpenAIServingTranscription) + serving.enable_force_include_usage = False + serving.model_cls = Qwen3ASRForConditionalGeneration + serving.streaming_post_processor_cls = ( + Qwen3ASRForConditionalGeneration.get_streaming_post_processor_cls() + ) + serving.task_type = "transcribe" + request = SimpleNamespace( + model="stub-qwen3-asr", + stream_include_usage=False, + stream_continuous_usage_stats=False, + ) + + out_lines: list[str] = [] + agen = OpenAIServingTranscription.transcription_stream_generator( + serving, + request=request, + result_generator=[gen_hello(), gen_world()], + request_id="test-qwen3-asr", + request_metadata=RequestResponseMetadata(request_id="test-qwen3-asr"), + audio_duration_s=1.0, + separator=" ", + ) + async for line in agen: + out_lines.append(line) + + combined = "".join(_sse_delta_contents("".join(out_lines))) + assert combined == "Hello world" + + @pytest.mark.asyncio async def test_create_transcription_non_streaming_joins_chunks_by_language(): """``create_transcription`` uses the same separator logic as the helper.""" @@ -234,7 +349,9 @@ async def gen_world() -> AsyncGenerator[RequestOutput, None]: "vllm.model_executor.model_loader.get_model_cls", return_value=_StubTranscriptionModel, ), - patch.object(OpenAISpeechToText, "_preprocess_speech_to_text", preprocess_mock), + patch.object( + SpeechToTextBaseServing, "_preprocess_speech_to_text", preprocess_mock + ), ): serving = OpenAIServingTranscription(engine_client, models, request_logger=None) diff --git a/tests/entrypoints/speech_to_text/transcription/test_transcription_validation_whisper.py b/tests/entrypoints/speech_to_text/transcription/test_transcription_validation_whisper.py index 511179f7fcb1..bbfde877b998 100644 --- a/tests/entrypoints/speech_to_text/transcription/test_transcription_validation_whisper.py +++ b/tests/entrypoints/speech_to_text/transcription/test_transcription_validation_whisper.py @@ -68,7 +68,8 @@ def server(request): if request.param is not None: args += ["--attention-backend", request.param] if "AITER" in request.param: - env_dict = _AITER_ENV + # TODO: re-enable once AITER reenables fp16 unified attention. + pytest.skip("ROCM_AITER_UNIFIED_ATTN does not support fp16") with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server: yield remote_server diff --git a/tests/entrypoints/tool_parsers/__init__.py b/tests/entrypoints/tool_parsers/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/entrypoints/openai/tool_parsers/test_granite4_tool_parser.py b/tests/entrypoints/tool_parsers/test_granite4_tool_parser.py similarity index 99% rename from tests/entrypoints/openai/tool_parsers/test_granite4_tool_parser.py rename to tests/entrypoints/tool_parsers/test_granite4_tool_parser.py index 0397613c095c..71fe7637bf4a 100644 --- a/tests/entrypoints/openai/tool_parsers/test_granite4_tool_parser.py +++ b/tests/entrypoints/tool_parsers/test_granite4_tool_parser.py @@ -5,7 +5,7 @@ import openai import pytest -from ....utils import RemoteOpenAIServer +from tests.utils import RemoteOpenAIServer MODEL = "ibm-granite/granite-4.0-h-tiny" diff --git a/tests/entrypoints/openai/tool_parsers/test_hermes_tool_parser.py b/tests/entrypoints/tool_parsers/test_hermes_tool_parser.py similarity index 99% rename from tests/entrypoints/openai/tool_parsers/test_hermes_tool_parser.py rename to tests/entrypoints/tool_parsers/test_hermes_tool_parser.py index 9ef988300904..5d769c0fd885 100644 --- a/tests/entrypoints/openai/tool_parsers/test_hermes_tool_parser.py +++ b/tests/entrypoints/tool_parsers/test_hermes_tool_parser.py @@ -9,12 +9,11 @@ from huggingface_hub import snapshot_download from typing_extensions import TypedDict +from tests.utils import RemoteOpenAIServer from vllm.tool_parsers.abstract_tool_parser import ToolParser from vllm.tool_parsers.granite4_tool_parser import Granite4ToolParser from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser -from ....utils import RemoteOpenAIServer - LORA_MODEL = "minpeter/LoRA-Llama-3.2-1B-tool-vllm-ci" TOOLS = [ diff --git a/tests/entrypoints/openai/tool_parsers/test_openai_tool_parser.py b/tests/entrypoints/tool_parsers/test_openai_tool_parser.py similarity index 99% rename from tests/entrypoints/openai/tool_parsers/test_openai_tool_parser.py rename to tests/entrypoints/tool_parsers/test_openai_tool_parser.py index cedec72fe49f..d99b66d9ac6b 100644 --- a/tests/entrypoints/openai/tool_parsers/test_openai_tool_parser.py +++ b/tests/entrypoints/tool_parsers/test_openai_tool_parser.py @@ -9,7 +9,7 @@ import pytest_asyncio from rapidfuzz import fuzz -from ....utils import RemoteOpenAIServer +from tests.utils import RemoteOpenAIServer MODEL_NAME = "openai/gpt-oss-20b" diff --git a/tests/entrypoints/unit_tests/__init__.py b/tests/entrypoints/unit_tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/entrypoints/test_api_server_process_manager.py b/tests/entrypoints/unit_tests/test_api_server_process_manager.py similarity index 100% rename from tests/entrypoints/test_api_server_process_manager.py rename to tests/entrypoints/unit_tests/test_api_server_process_manager.py diff --git a/tests/entrypoints/test_chat_utils.py b/tests/entrypoints/unit_tests/test_chat_utils.py similarity index 100% rename from tests/entrypoints/test_chat_utils.py rename to tests/entrypoints/unit_tests/test_chat_utils.py diff --git a/tests/entrypoints/test_context.py b/tests/entrypoints/unit_tests/test_context.py similarity index 83% rename from tests/entrypoints/test_context.py rename to tests/entrypoints/unit_tests/test_context.py index b1c8df4fac34..1c1f6ed23593 100644 --- a/tests/entrypoints/test_context.py +++ b/tests/entrypoints/unit_tests/test_context.py @@ -1,18 +1,18 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from openai_harmony import Author, Message, Role, StreamState, TextContent +from openai_harmony import Author, Message, Role, TextContent from vllm.entrypoints.openai.responses.context import ( HarmonyContext, SimpleContext, - StreamingHarmonyContext, TurnMetrics, ) from vllm.outputs import CompletionOutput, RequestOutput +from vllm.parser.harmony import ChunkResult, HarmonyParser, Segment def create_mock_request_output( @@ -68,25 +68,59 @@ async def generate_mock_outputs( ) -@pytest.fixture -def mock_parser(): - """Set up a mock parser for tests.""" - with patch( - "vllm.entrypoints.openai.responses.context.get_streamable_parser_for_assistant" - ) as mock_parser_factory: - # Create a mock parser object - parser = MagicMock() - parser.messages = [] - parser.current_channel = None - parser.state = StreamState.EXPECT_START - mock_parser_factory.return_value = parser - yield parser +class FakeHarmonyParser(HarmonyParser): + def __init__(self): + # Skip HarmonyParser initialization and script outputs directly. + self.reasoning_parser = None + self.tool_parser = None + self._chunk_results: list[ChunkResult] = [] + self._flush_results: list[list[Segment]] = [] + self.processed_chunks: list[list[int]] = [] + + def enqueue_chunk_result( + self, + segments: list[Segment] | None = None, + reasoning_token_count: int = 0, + ) -> None: + self._chunk_results.append( + ChunkResult( + segments=[] if segments is None else segments, + reasoning_token_count=reasoning_token_count, + ) + ) + + def enqueue_flush_result(self, segment: list[Segment]) -> None: + self._flush_results.append(segment) + + def process_chunk(self, token_ids) -> ChunkResult: + self.processed_chunks.append(list(token_ids)) + if self._chunk_results: + return self._chunk_results.pop(0) + return ChunkResult(segments=[], reasoning_token_count=0) + + def flush(self) -> list[Segment]: + if self._flush_results: + return self._flush_results.pop(0) + return [] + + +def make_harmony_context( + messages=None, available_tools=None, function_tool_names=None +) -> tuple[HarmonyContext, FakeHarmonyParser]: + fake_parser = FakeHarmonyParser() + context = HarmonyContext( + messages=[] if messages is None else messages, + available_tools=[] if available_tools is None else available_tools, + function_tool_names=function_tool_names, + response_parser=fake_parser, + ) + return context, fake_parser def test_single_turn_token_counting(): """Test token counting behavior for a single turn.""" # Create a context - context = HarmonyContext(messages=[], available_tools=[]) + context, _ = make_harmony_context() # Create a mock RequestOutput with specific token counts mock_output = create_mock_request_output( @@ -118,7 +152,7 @@ def test_single_turn_token_counting(): async def test_multi_turn_token_counting(): """Test token counting behavior across multiple turns with tool output.""" # Create a context - context = HarmonyContext(messages=[], available_tools=["browser"]) + context, _ = make_harmony_context(available_tools=["browser"]) # Simulate a conversation with 3 turns # Turn 1: prefill 5, decode 3, tool 7 @@ -177,7 +211,7 @@ async def test_multi_turn_token_counting(): def test_empty_output_tokens(): """Test behavior when RequestOutput has empty output tokens.""" - context = HarmonyContext(messages=[], available_tools=[]) + context, _ = make_harmony_context() # Create a RequestOutput with empty output tokens mock_output = create_mock_request_output( @@ -197,7 +231,7 @@ def test_empty_output_tokens(): def test_missing_prompt_token_ids(): """Test behavior when RequestOutput has None prompt_token_ids.""" - context = HarmonyContext(messages=[], available_tools=[]) + context, _ = make_harmony_context() mock_output = create_mock_request_output( prompt_token_ids=None, # No prompt token IDs @@ -216,12 +250,10 @@ def test_missing_prompt_token_ids(): assert context.num_tool_output_tokens == 0 -def test_reasoning_tokens_counting(mock_parser): +def test_reasoning_tokens_counting(): """Test that reasoning tokens are counted correctly.""" - context = HarmonyContext(messages=[], available_tools=[]) - - # Mock parser to simulate reasoning channel - mock_parser.current_channel = "analysis" # Reasoning channel + context, parser = make_harmony_context() + parser.enqueue_chunk_result(reasoning_token_count=4) mock_output = create_mock_request_output( prompt_token_ids=[1, 2, 3], @@ -236,13 +268,11 @@ def test_reasoning_tokens_counting(mock_parser): assert context.num_output_tokens == 4 -def test_preamble_tokens_not_counted_as_reasoning(mock_parser): +def test_preamble_tokens_not_counted_as_reasoning(): """Preambles (commentary with no recipient) are visible user text, not hidden reasoning. They must NOT inflate num_reasoning_tokens.""" - context = HarmonyContext(messages=[], available_tools=[]) - - mock_parser.current_channel = "commentary" - mock_parser.current_recipient = None # preamble + context, parser = make_harmony_context() + parser.enqueue_chunk_result(reasoning_token_count=0) mock_output = create_mock_request_output( prompt_token_ids=[1, 2, 3], @@ -255,13 +285,11 @@ def test_preamble_tokens_not_counted_as_reasoning(mock_parser): assert context.num_output_tokens == 3 -def test_commentary_with_recipient_counted_as_reasoning(mock_parser): +def test_commentary_with_recipient_counted_as_reasoning(): """Commentary directed at a tool (recipient != None) is hidden from the user, so it should still count as reasoning tokens.""" - context = HarmonyContext(messages=[], available_tools=[]) - - mock_parser.current_channel = "commentary" - mock_parser.current_recipient = "python" + context, parser = make_harmony_context() + parser.enqueue_chunk_result(reasoning_token_count=3) mock_output = create_mock_request_output( prompt_token_ids=[1, 2, 3], @@ -276,7 +304,7 @@ def test_commentary_with_recipient_counted_as_reasoning(mock_parser): def test_zero_tokens_edge_case(): """Test behavior with all zero token counts.""" - context = HarmonyContext(messages=[], available_tools=[]) + context, _ = make_harmony_context() # Create a request with empty lists (not None) for both prompt and # output tokens @@ -299,10 +327,7 @@ def test_zero_tokens_edge_case(): @pytest.mark.asyncio async def test_single_turn_no_tool_output(): """Test that first turn never generates tool output tokens.""" - context = HarmonyContext( - messages=[], - available_tools=["browser"], # Tools available - ) + context, _ = make_harmony_context(available_tools=["browser"]) # Even with large prompt in first turn, no tool tokens should be counted mock_output = create_mock_request_output( @@ -324,7 +349,7 @@ async def test_negative_tool_tokens_edge_case(): tokens. We should log an error and clamp the value to 0.""" # Use patch to check if logger.error was called with patch("vllm.entrypoints.openai.responses.context.logger.error") as mock_log: - context = HarmonyContext(messages=[], available_tools=["browser"]) + context, _ = make_harmony_context(available_tools=["browser"]) # First turn mock_output1 = create_mock_request_output( @@ -360,15 +385,15 @@ async def test_negative_tool_tokens_edge_case(): @pytest.mark.asyncio -async def test_streaming_multi_turn_token_counting(mock_parser): +async def test_streaming_multi_turn_token_counting(): """Test token counting for streaming multi-turn conversations. - This test focuses on how StreamingHarmonyContext counts tokens in a + This test focuses on how HarmonyContext counts tokens in a multi-turn conversation with streaming (token-by-token) outputs and message boundaries. """ # Create a streaming context - context = StreamingHarmonyContext(messages=[], available_tools=["browser"]) + context, parser = make_harmony_context(available_tools=["browser"]) num_prompt_tokens = [3, 8, 13] num_output_tokens = [3, 3, 2] @@ -413,10 +438,8 @@ async def test_streaming_multi_turn_token_counting(mock_parser): assert context.num_tool_output_tokens == 0 # No tool output in first turn assert context.first_tok_of_message is True # Ready for next message - # Second turn: reasoning tokens in analysis channel - mock_parser.current_channel = "analysis" # Set to reasoning channel - # First token of second turn + parser.enqueue_chunk_result(reasoning_token_count=1) context.append_output( create_mock_request_output( prompt_token_ids=[ @@ -436,6 +459,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser): ) # More tokens in reasoning channel + parser.enqueue_chunk_result(reasoning_token_count=1) context.append_output( create_mock_request_output( output_token_ids=[202], @@ -443,6 +467,7 @@ async def test_streaming_multi_turn_token_counting(mock_parser): ) ) + parser.enqueue_chunk_result(reasoning_token_count=1) context.append_output( create_mock_request_output( output_token_ids=[203], @@ -460,9 +485,6 @@ async def test_streaming_multi_turn_token_counting(mock_parser): expected_tool_tokens = 8 - 3 - 3 # = 2 assert context.num_tool_output_tokens == expected_tool_tokens - # Third turn: regular output channel - mock_parser.current_channel = "final" # Switch back to regular channel - # Third turn (with more cached tokens) context.append_output( create_mock_request_output( @@ -520,13 +542,8 @@ async def test_streaming_multi_turn_token_counting(mock_parser): @pytest.mark.asyncio -async def test_streaming_message_synchronization(mock_parser): - """Test message synchronization logic from lines 413-417 in context.py. - - This test verifies that when parser.messages contains more messages than - the context's _messages (minus initial messages), the context properly - extends its message list with the new parser messages. - """ +async def test_streaming_message_synchronization(): + """Completed messages from append-local and flush segments sync into context.""" # Create a streaming context with some initial messages initial_messages = [ @@ -536,23 +553,30 @@ async def test_streaming_message_synchronization(mock_parser): recipient=Role.ASSISTANT, ) ] - context = StreamingHarmonyContext(messages=initial_messages, available_tools=[]) + context, parser = make_harmony_context(messages=initial_messages) # Verify initial state assert len(context._messages) == 1 assert context.num_init_messages == 1 - # Mock parser to have more messages than context - # Simulate parser having processed 3 new messages - mock_parser.messages = [ - Message( - author=Author(role=Role.ASSISTANT, name="assistant"), - content=[TextContent(text="Response 1")], - recipient=Role.USER, - ), - ] + response_text = "First response" + message = Message( + author=Author(role=Role.ASSISTANT, name="assistant"), + content=[TextContent(text=response_text)], + recipient=Role.USER, + ) + parser.enqueue_chunk_result( + segments=[ + Segment( + channel="commentary", + recipient=None, + delta="", + completed_message=message, + ) + ] + ) - # This should trigger the message synchronization logic + # This should sync the completed message from the latest append context.append_output( create_mock_request_output( prompt_token_ids=[1, 2, 3], output_token_ids=[101], finished=False @@ -563,36 +587,48 @@ async def test_streaming_message_synchronization(mock_parser): assert len(context._messages) == 2 # Verify the new messages were added correctly - assert context._messages[1].content[0].text == "Response 1" + assert context._messages[1].content[0].text == response_text - # Test the specific condition from line 413-414: - # len(self._messages) - self.num_init_messages < len(self.parser.messages) messages_minus_init = len(context._messages) - context.num_init_messages - parser_messages_count = len(mock_parser.messages) - - # After synchronization, they should be equal (no longer less than) - assert messages_minus_init == parser_messages_count + assert messages_minus_init == 1 + + response_text = "Second response" + message = Message( + author=Author(role=Role.ASSISTANT, name="assistant"), + content=[TextContent(text=response_text)], + recipient=Role.USER, + ) + flush_segments = [ + Segment( + channel="final", + recipient=None, + delta=response_text, + completed_message=None, + ), + Segment( + channel="final", + recipient=None, + delta="", + completed_message=message, + ), + ] + parser.enqueue_flush_result(flush_segments) - # Test edge case: add one more parser message - mock_parser.messages.append( - Message( - author=Author(role=Role.ASSISTANT, name="assistant"), - content=[TextContent(text="Response 4")], - recipient=Role.USER, + # Create another output to trigger synchronization via flush() + context.append_output( + create_mock_request_output( + prompt_token_ids=[1, 2, 3], output_token_ids=[102], finished=True ) ) - # Create another output to trigger synchronization again - mock_output2 = create_mock_request_output( - prompt_token_ids=[1, 2, 3], output_token_ids=[102], finished=True - ) - - context.append_output(mock_output2) - - # Verify the fourth message was added, num_init_messages is still 1 + # Verify the flushed response was added, num_init_messages is still 1 assert len(context._messages) == 3 assert context.num_init_messages == 1 - assert context._messages[2].content[0].text == "Response 4" + assert context._messages[2].content[0].text == response_text + assert context.last_append_flush_status is True + assert len(context.last_append_segments) == 2 + assert context.last_append_segments[-2].delta == response_text + assert context.last_append_segments[-1].completed_message is message def test_turn_metrics_copy_and_reset(): diff --git a/tests/entrypoints/test_grpc_health.py b/tests/entrypoints/unit_tests/test_grpc_health.py similarity index 100% rename from tests/entrypoints/test_grpc_health.py rename to tests/entrypoints/unit_tests/test_grpc_health.py diff --git a/tests/entrypoints/test_launch_cli.py b/tests/entrypoints/unit_tests/test_launch_cli.py similarity index 100% rename from tests/entrypoints/test_launch_cli.py rename to tests/entrypoints/unit_tests/test_launch_cli.py diff --git a/tests/entrypoints/weight_transfer/test_weight_transfer_llm.py b/tests/entrypoints/weight_transfer/test_weight_transfer_llm.py index 1dd89afcf80c..9088b3c5e8db 100644 --- a/tests/entrypoints/weight_transfer/test_weight_transfer_llm.py +++ b/tests/entrypoints/weight_transfer/test_weight_transfer_llm.py @@ -8,7 +8,6 @@ """ import os -from collections.abc import Callable from dataclasses import dataclass from unittest.mock import patch @@ -48,7 +47,6 @@ class MockUpdateInfo(WeightTransferUpdateInfo): names: list[str] | None = None dtype_names: list[str] | None = None shapes: list[list[int]] | None = None - num_updates_list: list[int] | None = None class MockWeightTransferEngine(WeightTransferEngine[MockInitInfo, MockUpdateInfo]): @@ -59,16 +57,20 @@ class MockWeightTransferEngine(WeightTransferEngine[MockInitInfo, MockUpdateInfo # Class-level tracking for verification across processes init_transfer_engine_called: bool = False + start_called: bool = False receive_weights_called: bool = False + finish_called: bool = False shutdown_called: bool = False last_init_info: MockInitInfo | None = None last_update_info: MockUpdateInfo | None = None - def __init__(self, config, parallel_config, model): - super().__init__(config, parallel_config, model) + def __init__(self, config, vllm_config, device, model): + super().__init__(config, vllm_config, device, model) # Reset tracking on init MockWeightTransferEngine.init_transfer_engine_called = False + MockWeightTransferEngine.start_called = False MockWeightTransferEngine.receive_weights_called = False + MockWeightTransferEngine.finish_called = False MockWeightTransferEngine.shutdown_called = False MockWeightTransferEngine.last_init_info = None MockWeightTransferEngine.last_update_info = None @@ -77,37 +79,28 @@ def init_transfer_engine(self, init_info: MockInitInfo) -> None: MockWeightTransferEngine.init_transfer_engine_called = True MockWeightTransferEngine.last_init_info = init_info - def receive_weights( - self, - update_info: MockUpdateInfo, - load_weights: Callable[[list[tuple[str, torch.Tensor]]], None], - ) -> None: - MockWeightTransferEngine.receive_weights_called = True - MockWeightTransferEngine.last_update_info = update_info - # Simulate loading weights by calling load_weights with empty list - # (In real implementation, this would receive and load actual weights) - load_weights([]) - - def receive_sparse_weights( - self, - update_info: MockUpdateInfo, - apply_patches: Callable[[list], None], - ) -> None: + def start_weight_update(self) -> None: + MockWeightTransferEngine.start_called = True + + def finish_weight_update(self) -> None: + MockWeightTransferEngine.finish_called = True + + def receive_weights(self, update_info: MockUpdateInfo) -> None: MockWeightTransferEngine.receive_weights_called = True MockWeightTransferEngine.last_update_info = update_info - apply_patches([]) def shutdown(self) -> None: MockWeightTransferEngine.shutdown_called = True - def trainer_send_weights(self, *args, **kwargs): + @staticmethod + def trainer_send_weights(*args, **kwargs): """Mock method to simulate trainer sending weights.""" pass -def mock_create_engine(config, parallel_config, model): +def mock_create_engine(config, vllm_config, device, model): """Mock factory function that returns our mock engine.""" - return MockWeightTransferEngine(config, parallel_config, model) + return MockWeightTransferEngine(config, vllm_config, device, model) # --- Tests --- @@ -208,7 +201,7 @@ def test_update_weights_calls_engine(): llm.init_weight_transfer_engine( WeightTransferInitRequest(init_info={"test_param": "init"}) ) - llm.start_weight_update(is_checkpoint_format=True) + llm.start_weight_update() # Call update_weights test_names = ["layer.weight", "layer.bias"] @@ -243,61 +236,6 @@ def check_update_called(self): llm.finish_weight_update() -@create_new_process_for_each_test() -def test_update_weights_passes_sparse_metadata(): - """Test sparse update metadata is forwarded unchanged to the engine.""" - if torch.accelerator.device_count() < 1: - pytest.skip("Need at least 1 GPU for this test") - - os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0" - os.environ["VLLM_ALLOW_INSECURE_SERIALIZATION"] = "1" - - with patch( - "vllm.v1.worker.gpu_worker.WeightTransferEngineFactory.create_engine", - mock_create_engine, - ): - llm = LLM( - model=MODEL_NAME, - enforce_eager=True, - load_format="dummy", - tensor_parallel_size=1, - weight_transfer_config=WeightTransferConfig(backend="nccl"), - ) - - llm.init_weight_transfer_engine( - WeightTransferInitRequest(init_info={"test_param": "init"}) - ) - llm.start_weight_update(is_checkpoint_format=False) - - llm.update_weights( - WeightTransferUpdateRequest( - update_info={ - "names": ["layer.weight"], - "dtype_names": ["bfloat16"], - "shapes": [[100]], - "num_updates_list": [3], - "update_kind": "sparse_flat", - } - ) - ) - - def check_sparse_update_called(self): - engine = self.weight_transfer_engine - if not engine.receive_weights_called: - return None - info = engine.last_update_info - return ( - info.update_kind, - info.num_updates_list, - ) - - results = llm.collective_rpc(check_sparse_update_called) - for result in results: - assert result == ("sparse_flat", [3]) - - llm.finish_weight_update() - - @create_new_process_for_each_test() def test_full_weight_transfer_flow(): """Test the complete weight transfer flow: init -> start -> update -> finish.""" @@ -327,7 +265,7 @@ def test_full_weight_transfer_flow(): ) # Step 2: Start weight update - llm.start_weight_update(is_checkpoint_format=True) + llm.start_weight_update() # Step 3: Update weights llm.update_weights( diff --git a/tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16.yaml b/tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16-cutlass.yaml similarity index 68% rename from tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16.yaml rename to tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16-cutlass.yaml index 952f7e870357..992cb3dfa49b 100644 --- a/tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16.yaml +++ b/tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16-cutlass.yaml @@ -3,6 +3,4 @@ model_name: "openai/gpt-oss-20b" metric_threshold: 0.568 reasoning_effort: "low" -server_args: "--tensor-parallel-size 2" -env: - VLLM_USE_FLASHINFER_MOE_MXFP4_BF16: "1" +server_args: "--tensor-parallel-size 2 --moe-backend flashinfer_cutlass" diff --git a/tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16-trtllm.yaml b/tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16-trtllm.yaml new file mode 100644 index 000000000000..39b689308584 --- /dev/null +++ b/tests/evals/gpt_oss/configs/gpt-oss-20b-flashinfer-mxfp4-bf16-trtllm.yaml @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +model_name: "openai/gpt-oss-20b" +metric_threshold: 0.568 +reasoning_effort: "low" +server_args: "--tensor-parallel-size 2 --moe-backend flashinfer_trtllm" diff --git a/tests/evals/gpt_oss/configs/gpt-oss-20b-marlin.yaml b/tests/evals/gpt_oss/configs/gpt-oss-20b-marlin.yaml index 97e97fd19a6b..99f10f4f31cf 100644 --- a/tests/evals/gpt_oss/configs/gpt-oss-20b-marlin.yaml +++ b/tests/evals/gpt_oss/configs/gpt-oss-20b-marlin.yaml @@ -3,6 +3,4 @@ model_name: "openai/gpt-oss-20b" metric_threshold: 0.568 reasoning_effort: "low" -server_args: "--tensor-parallel-size 2" -env: - VLLM_MXFP4_USE_MARLIN: "1" +server_args: "--tensor-parallel-size 2 --moe-backend marlin --linear-backend marlin" diff --git a/tests/evals/gpt_oss/configs/gpt-oss-20b-sm120.yaml b/tests/evals/gpt_oss/configs/gpt-oss-20b-sm120.yaml new file mode 100644 index 000000000000..934f9f499474 --- /dev/null +++ b/tests/evals/gpt_oss/configs/gpt-oss-20b-sm120.yaml @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +model_name: "openai/gpt-oss-20b" +metric_threshold: 0.568 +reasoning_effort: "low" diff --git a/tests/evals/gpt_oss/configs/models-b200.txt b/tests/evals/gpt_oss/configs/models-b200.txt index 8519109e192a..4a7e80949ac0 100644 --- a/tests/evals/gpt_oss/configs/models-b200.txt +++ b/tests/evals/gpt_oss/configs/models-b200.txt @@ -1,5 +1,5 @@ # B200 model configurations for GPQA evaluation # Tests different environment variable combinations -gpt-oss-20b-flashinfer-mxfp4-bf16.yaml +gpt-oss-20b-flashinfer-mxfp4-bf16-trtllm.yaml gpt-oss-20b-flashinfer-mxfp4-mxfp8-cutlass.yaml gpt-oss-20b-sm100-fi-mxfp4-mxfp8-trtllm.yaml \ No newline at end of file diff --git a/tests/evals/gpt_oss/configs/models-h100.txt b/tests/evals/gpt_oss/configs/models-h100.txt index 9577bac5f1d4..05a35fdd8f15 100644 --- a/tests/evals/gpt_oss/configs/models-h100.txt +++ b/tests/evals/gpt_oss/configs/models-h100.txt @@ -1,5 +1,5 @@ # H100 model configurations for GPQA evaluation # Tests different environment variable combinations gpt-oss-20b-baseline.yaml -gpt-oss-20b-flashinfer-mxfp4-bf16.yaml +gpt-oss-20b-flashinfer-mxfp4-bf16-cutlass.yaml gpt-oss-20b-marlin.yaml diff --git a/tests/evals/gpt_oss/configs/models-spark.txt b/tests/evals/gpt_oss/configs/models-spark.txt new file mode 100644 index 000000000000..d1efb254c8ae --- /dev/null +++ b/tests/evals/gpt_oss/configs/models-spark.txt @@ -0,0 +1,2 @@ +# DGX Spark model configurations for GPQA evaluation +gpt-oss-20b-sm120.yaml diff --git a/tests/evals/gsm8k/README.md b/tests/evals/gsm8k/README.md index dcbfd85bfeee..db37d2e22438 100644 --- a/tests/evals/gsm8k/README.md +++ b/tests/evals/gsm8k/README.md @@ -30,9 +30,9 @@ model_name: "Qwen/Qwen2.5-1.5B-Instruct" accuracy_threshold: 0.54 # Minimum expected accuracy num_questions: 1319 # Number of questions (default: full test set) num_fewshot: 5 # Few-shot examples from train set -server_args: "--max-model-len 4096 --tensor-parallel-size 2" # Server arguments +server_args: "--max-model-len 4096 --tensor-parallel-size 2 --moe-backend flashinfer_cutlass" # Server arguments env: # Environment variables (optional) - VLLM_USE_FLASHINFER_MOE_FP4: "1" + VLLM_LOGGING_LEVEL: "DEBUG" ``` The `server_args` field accepts any arguments that can be passed to `vllm serve`. diff --git a/tests/evals/gsm8k/configs/DeepSeek-V2-Lite-Instruct-FP8.yaml b/tests/evals/gsm8k/configs/DeepSeek-V2-Lite-Instruct-FP8.yaml index 72fa7e8a38c7..dde67727bc62 100644 --- a/tests/evals/gsm8k/configs/DeepSeek-V2-Lite-Instruct-FP8.yaml +++ b/tests/evals/gsm8k/configs/DeepSeek-V2-Lite-Instruct-FP8.yaml @@ -2,4 +2,5 @@ model_name: "RedHatAI/DeepSeek-Coder-V2-Lite-Instruct-FP8" accuracy_threshold: 0.72 num_questions: 1319 num_fewshot: 5 +rocm_request_timeout_seconds: 1800 server_args: "--enforce-eager --max-model-len 4096" diff --git a/tests/evals/gsm8k/configs/DiffusionGemma-26B-A4B-it-FP8-dynamic.yaml b/tests/evals/gsm8k/configs/DiffusionGemma-26B-A4B-it-FP8-dynamic.yaml new file mode 100644 index 000000000000..060c85068166 --- /dev/null +++ b/tests/evals/gsm8k/configs/DiffusionGemma-26B-A4B-it-FP8-dynamic.yaml @@ -0,0 +1,15 @@ +model_name: "RedHatAI/diffusiongemma-26B-A4B-it-FP8-dynamic" +accuracy_threshold: 0.84 +num_questions: 1319 +num_fewshot: 5 +startup_max_wait_seconds: 1200 +use_chat_completions: true +# Diffusion models use a fixed internal temperature schedule and do not +# support per-request temperature or seed overrides. +temperature: 1.0 +seed: null +server_args: >- + --enforce-eager + --max-model-len 4096 + --tensor-parallel-size 2 + --attention-backend TRITON_ATTN diff --git a/tests/evals/gsm8k/configs/Qwen1.5-MoE-W4A16-CT.yaml b/tests/evals/gsm8k/configs/Qwen1.5-MoE-W4A16-CT.yaml index 4a1b1948acac..027b4ba56229 100644 --- a/tests/evals/gsm8k/configs/Qwen1.5-MoE-W4A16-CT.yaml +++ b/tests/evals/gsm8k/configs/Qwen1.5-MoE-W4A16-CT.yaml @@ -2,4 +2,5 @@ model_name: "nm-testing/Qwen1.5-MoE-A2.7B-Chat-quantized.w4a16" accuracy_threshold: 0.45 num_questions: 1319 num_fewshot: 5 +rocm_request_timeout_seconds: 1800 server_args: "--enforce-eager --max-model-len 4096" diff --git a/tests/evals/gsm8k/configs/Qwen3-30B-A3B-Thinking-2507-FP8.yaml b/tests/evals/gsm8k/configs/Qwen3-30B-A3B-Thinking-2507-FP8.yaml new file mode 100644 index 000000000000..7ec5b825c2b1 --- /dev/null +++ b/tests/evals/gsm8k/configs/Qwen3-30B-A3B-Thinking-2507-FP8.yaml @@ -0,0 +1,6 @@ +model_name: "amd/Qwen3-30B-A3B-Thinking-2507-FP8" +accuracy_threshold: 0.81 +num_questions: 1319 +num_fewshot: 5 +max_tokens: 1024 +server_args: "--max-model-len 4096 --gpu-memory-utilization 0.85" diff --git a/tests/evals/gsm8k/configs/Qwen3-30B-A3B-Thinking-2507-PTPC-FP8.yaml b/tests/evals/gsm8k/configs/Qwen3-30B-A3B-Thinking-2507-PTPC-FP8.yaml new file mode 100644 index 000000000000..6095cef535c3 --- /dev/null +++ b/tests/evals/gsm8k/configs/Qwen3-30B-A3B-Thinking-2507-PTPC-FP8.yaml @@ -0,0 +1,6 @@ +model_name: "amd/Qwen3-30B-A3B-Thinking-2507-PTPC-FP8" +accuracy_threshold: 0.81 +num_questions: 1319 +num_fewshot: 5 +max_tokens: 1024 +server_args: "--max-model-len 4096 --gpu-memory-utilization 0.85" diff --git a/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-DEP2.yaml b/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-DEP2.yaml index 55a134ad9bd0..6c2dcad0e60b 100644 --- a/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-DEP2.yaml +++ b/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-DEP2.yaml @@ -7,3 +7,4 @@ server_args: >- --max-model-len 4096 --data-parallel-size 2 --enable-expert-parallel + --no-enable-flashinfer-autotune \ No newline at end of file diff --git a/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-AITER-TP2.yaml b/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-AITER-TP2.yaml index 657251a66038..ca5cc450c07d 100644 --- a/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-AITER-TP2.yaml +++ b/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-AITER-TP2.yaml @@ -10,3 +10,4 @@ server_args: >- --moe-backend aiter env: VLLM_ROCM_USE_AITER: "1" + ENABLE_CK: "0" # Avoid AITER CK-based MHA JIT compilation to save time diff --git a/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-EMU-TP2.yaml b/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-EMU-TP2.yaml index ad5ca701258e..70217d2651ea 100644 --- a/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-EMU-TP2.yaml +++ b/tests/evals/gsm8k/configs/Qwen3.5-35B-A3B-MXFP4-EMU-TP2.yaml @@ -3,6 +3,7 @@ accuracy_threshold: 0.89 tolerance: 0.03 num_questions: 1319 num_fewshot: 5 +startup_max_wait_seconds: 1800 server_args: >- --max-model-len 4096 --tensor-parallel-size 2 diff --git a/tests/evals/gsm8k/configs/Qwen3.5-397B-A17B-NVFP4-DEP2-MTP.yaml b/tests/evals/gsm8k/configs/Qwen3.5-397B-A17B-NVFP4-DEP2-MTP.yaml new file mode 100644 index 000000000000..d247515a0f0e --- /dev/null +++ b/tests/evals/gsm8k/configs/Qwen3.5-397B-A17B-NVFP4-DEP2-MTP.yaml @@ -0,0 +1,12 @@ +model_name: "nvidia/Qwen3.5-397B-A17B-NVFP4" +accuracy_threshold: 0.88 +tolerance: 0.03 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --max-model-len 4096 + --data-parallel-size 2 + --enable-expert-parallel + --max-num-seqs 384 + --spec-method mtp + --spec-tokens 3 diff --git a/tests/evals/gsm8k/configs/gemma-4-E4B-it-qat-mobile-ct.yaml b/tests/evals/gsm8k/configs/gemma-4-E4B-it-qat-mobile-ct.yaml new file mode 100644 index 000000000000..80d0c98311b7 --- /dev/null +++ b/tests/evals/gsm8k/configs/gemma-4-E4B-it-qat-mobile-ct.yaml @@ -0,0 +1,5 @@ +model_name: "google/gemma-4-E4B-it-qat-mobile-ct" +accuracy_threshold: 0.50 +num_questions: 1319 +num_fewshot: 5 +server_args: "--enforce-eager --max-model-len 4096" diff --git a/tests/evals/gsm8k/configs/humming/Qwen2-1.5B-Instruct-FP8W8-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen2-1.5B-Instruct-FP8W8-humming-act-fp8.yaml new file mode 100644 index 000000000000..ba292eb9724a --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen2-1.5B-Instruct-FP8W8-humming-act-fp8.yaml @@ -0,0 +1,10 @@ +model_name: "nm-testing/Qwen2-1.5B-Instruct-FP8W8" +accuracy_threshold: 0.55 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen2-1.5B-Instruct-FP8W8-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen2-1.5B-Instruct-FP8W8-humming.yaml new file mode 100644 index 000000000000..3179c3251b2f --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen2-1.5B-Instruct-FP8W8-humming.yaml @@ -0,0 +1,8 @@ +model_name: "nm-testing/Qwen2-1.5B-Instruct-FP8W8" +accuracy_threshold: 0.55 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-0.6B-MXFP8-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-0.6B-MXFP8-humming-act-fp8.yaml new file mode 100644 index 000000000000..66888312ef07 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-0.6B-MXFP8-humming-act-fp8.yaml @@ -0,0 +1,10 @@ +model_name: "mgoin/Qwen3-0.6B-MXFP8" +accuracy_threshold: 0.39 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-0.6B-MXFP8-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-0.6B-MXFP8-humming.yaml new file mode 100644 index 000000000000..b83d9e6a9e94 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-0.6B-MXFP8-humming.yaml @@ -0,0 +1,8 @@ +model_name: "mgoin/Qwen3-0.6B-MXFP8" +accuracy_threshold: 0.39 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming-act-fp8.yaml new file mode 100644 index 000000000000..d3de5b3792ff --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming-act-fp8.yaml @@ -0,0 +1,13 @@ +model_name: "QuixiAI/Qwen3-30B-A3B-AWQ" +accuracy_threshold: 0.90 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming + --dtype bfloat16 +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming-act-int8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming-act-int8.yaml new file mode 100644 index 000000000000..6b76efc4e22c --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming-act-int8.yaml @@ -0,0 +1,13 @@ +model_name: "QuixiAI/Qwen3-30B-A3B-AWQ" +accuracy_threshold: 0.90 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming + --dtype bfloat16 +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"int8"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming.yaml new file mode 100644 index 000000000000..310255f6bf41 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-AWQ-humming.yaml @@ -0,0 +1,11 @@ +model_name: "QuixiAI/Qwen3-30B-A3B-AWQ" +accuracy_threshold: 0.90 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming + --dtype bfloat16 diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-FP8-block-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-FP8-block-humming-act-fp8.yaml new file mode 100644 index 000000000000..6d2702d8f4cb --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-FP8-block-humming-act-fp8.yaml @@ -0,0 +1,11 @@ +model_name: "nm-testing/Qwen3-30B-A3B-FP8-block" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-FP8-block-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-FP8-block-humming.yaml new file mode 100644 index 000000000000..8ca4777ce5e2 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-FP8-block-humming.yaml @@ -0,0 +1,9 @@ +model_name: "nm-testing/Qwen3-30B-A3B-FP8-block" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Fp8-v1-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Fp8-v1-humming-act-fp8.yaml new file mode 100644 index 000000000000..618e7fdcc351 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Fp8-v1-humming-act-fp8.yaml @@ -0,0 +1,11 @@ +model_name: "nm-testing/Qwen3-30B-A3B-Fp8-v1" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Fp8-v1-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Fp8-v1-humming.yaml new file mode 100644 index 000000000000..71aabcef99dd --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Fp8-v1-humming.yaml @@ -0,0 +1,9 @@ +model_name: "nm-testing/Qwen3-30B-A3B-Fp8-v1" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming-act-fp8.yaml new file mode 100644 index 000000000000..251fb252cf81 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming-act-fp8.yaml @@ -0,0 +1,11 @@ +model_name: "Qwen/Qwen3-30B-A3B-GPTQ-Int4" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming-act-int8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming-act-int8.yaml new file mode 100644 index 000000000000..d813e05d6f90 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming-act-int8.yaml @@ -0,0 +1,11 @@ +model_name: "Qwen/Qwen3-30B-A3B-GPTQ-Int4" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"int8"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming.yaml new file mode 100644 index 000000000000..ab89ebf5154e --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-GPTQ-Int4-humming.yaml @@ -0,0 +1,9 @@ +model_name: "Qwen/Qwen3-30B-A3B-GPTQ-Int4" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming-act-int8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming-act-int8.yaml new file mode 100644 index 000000000000..f77ee1173e16 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming-act-int8.yaml @@ -0,0 +1,11 @@ +model_name: "RedHatAI/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"int8"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming.yaml new file mode 100644 index 000000000000..7b4f82c84580 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming.yaml @@ -0,0 +1,9 @@ +model_name: "RedHatAI/Qwen3-30B-A3B-Instruct-2507-quantized.w8a8" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-MXFP4A16-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-MXFP4A16-humming-act-fp8.yaml new file mode 100644 index 000000000000..d98f91e1f998 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-MXFP4A16-humming-act-fp8.yaml @@ -0,0 +1,11 @@ +model_name: "nm-testing/Qwen3-30B-A3B-MXFP4A16" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --quantization humming + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-MXFP4A16-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-MXFP4A16-humming.yaml new file mode 100644 index 000000000000..67725fce64ae --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-MXFP4A16-humming.yaml @@ -0,0 +1,9 @@ +model_name: "nm-testing/Qwen3-30B-A3B-MXFP4A16" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --quantization humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-NVFP4-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-NVFP4-humming.yaml new file mode 100644 index 000000000000..a20c14331036 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-NVFP4-humming.yaml @@ -0,0 +1,9 @@ +model_name: "nvidia/Qwen3-30B-A3B-NVFP4" +accuracy_threshold: 0.86 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming-act-fp8.yaml new file mode 100644 index 000000000000..b58c0d710e4b --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming-act-fp8.yaml @@ -0,0 +1,13 @@ +model_name: "Qwen/Qwen3-30B-A3B" +accuracy_threshold: 0.80 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --tensor-parallel-size 1 + --quantization humming + --kernel-config.enable_flashinfer_autotune=False +env: + VLLM_HUMMING_ONLINE_QUANT_CONFIG: '{"dtype":"int5","hadamard_block_size":-1}' + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming-act-int8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming-act-int8.yaml new file mode 100644 index 000000000000..c932091dbe35 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming-act-int8.yaml @@ -0,0 +1,13 @@ +model_name: "Qwen/Qwen3-30B-A3B" +accuracy_threshold: 0.80 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --tensor-parallel-size 1 + --quantization humming + --kernel-config.enable_flashinfer_autotune=False +env: + VLLM_HUMMING_ONLINE_QUANT_CONFIG: '{"dtype":"int5","hadamard_block_size":-1}' + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"int8"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming.yaml new file mode 100644 index 000000000000..fa5095cc8824 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3-30B-A3B-int5wc-hadamard-humming.yaml @@ -0,0 +1,12 @@ +model_name: "Qwen/Qwen3-30B-A3B" +accuracy_threshold: 0.80 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --tensor-parallel-size 1 + --quantization humming + --kernel-config.enable_flashinfer_autotune=False +env: + VLLM_HUMMING_ONLINE_QUANT_CONFIG: '{"dtype":"int5","hadamard_block_size":-1}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-FP8-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-FP8-humming-act-fp8.yaml new file mode 100644 index 000000000000..3af5c03a245f --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-FP8-humming-act-fp8.yaml @@ -0,0 +1,12 @@ +model_name: "Qwen/Qwen3.5-35B-A3B-FP8" +accuracy_threshold: 0.90 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-FP8-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-FP8-humming.yaml new file mode 100644 index 000000000000..85fce244200e --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-FP8-humming.yaml @@ -0,0 +1,10 @@ +model_name: "Qwen/Qwen3.5-35B-A3B-FP8" +accuracy_threshold: 0.90 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-experts-int8-humming-act-int8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-experts-int8-humming-act-int8.yaml new file mode 100644 index 000000000000..6a85ab384f22 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-experts-int8-humming-act-int8.yaml @@ -0,0 +1,12 @@ +model_name: "Qwen/Qwen3.5-35B-A3B" +accuracy_threshold: 0.90 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --quantization experts_int8 +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"int8"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-experts-int8-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-experts-int8-humming.yaml new file mode 100644 index 000000000000..d282fdc70196 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.5-35B-A3B-experts-int8-humming.yaml @@ -0,0 +1,10 @@ +model_name: "Qwen/Qwen3.5-35B-A3B" +accuracy_threshold: 0.90 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --quantization experts_int8 diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming-act-fp8.yaml new file mode 100644 index 000000000000..2a118098fc06 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming-act-fp8.yaml @@ -0,0 +1,10 @@ +model_name: "RedHatAI/Qwen3.5-4B-quantized.w4a16" +accuracy_threshold: 0.82 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming-act-int8.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming-act-int8.yaml new file mode 100644 index 000000000000..34f17a4b055f --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming-act-int8.yaml @@ -0,0 +1,11 @@ +model_name: "RedHatAI/Qwen3.5-4B-quantized.w4a16" +accuracy_threshold: 0.82 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --linear-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"int8"}' diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming.yaml new file mode 100644 index 000000000000..b617c61eb3ed --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.5-4B-quantized.w4a16-humming.yaml @@ -0,0 +1,9 @@ +model_name: "RedHatAI/Qwen3.5-4B-quantized.w4a16" +accuracy_threshold: 0.82 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/Qwen3.6-35B-A3B-NVFP4-humming.yaml b/tests/evals/gsm8k/configs/humming/Qwen3.6-35B-A3B-NVFP4-humming.yaml new file mode 100644 index 000000000000..502ab776f40a --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/Qwen3.6-35B-A3B-NVFP4-humming.yaml @@ -0,0 +1,10 @@ +model_name: "RedHatAI/Qwen3.6-35B-A3B-NVFP4" +accuracy_threshold: 0.91 +num_questions: 1319 +num_fewshot: 5 +gen_prefix: " \n\n\n" +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming + --linear-backend humming diff --git a/tests/evals/gsm8k/configs/humming/config-act-fp8.txt b/tests/evals/gsm8k/configs/humming/config-act-fp8.txt new file mode 100644 index 000000000000..42ff6be00ef6 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/config-act-fp8.txt @@ -0,0 +1,9 @@ +gpt-oss-20b-humming-act-fp8.yaml +Qwen3-30B-A3B-MXFP4A16-humming-act-fp8.yaml +Qwen2-1.5B-Instruct-FP8W8-humming-act-fp8.yaml +Qwen3-0.6B-MXFP8-humming-act-fp8.yaml +Qwen3-30B-A3B-Fp8-v1-humming-act-fp8.yaml +Qwen3-30B-A3B-FP8-block-humming-act-fp8.yaml +Qwen3-30B-A3B-GPTQ-Int4-humming-act-fp8.yaml +Qwen3-30B-A3B-AWQ-humming.yaml +Qwen3.5-35B-A3B-FP8-humming-act-fp8.yaml diff --git a/tests/evals/gsm8k/configs/humming/config-act-int8.txt b/tests/evals/gsm8k/configs/humming/config-act-int8.txt new file mode 100644 index 000000000000..b018b35cbfdc --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/config-act-int8.txt @@ -0,0 +1,4 @@ +Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming-act-int8.yaml +Qwen3-30B-A3B-GPTQ-Int4-humming-act-int8.yaml +Qwen3-30B-A3B-AWQ-humming.yaml +Qwen3.5-35B-A3B-experts-int8-humming-act-int8.yaml diff --git a/tests/evals/gsm8k/configs/humming/config-int5wc-hadamard.txt b/tests/evals/gsm8k/configs/humming/config-int5wc-hadamard.txt new file mode 100644 index 000000000000..2c10777a095a --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/config-int5wc-hadamard.txt @@ -0,0 +1,3 @@ +Qwen3-30B-A3B-int5wc-hadamard-humming.yaml +Qwen3-30B-A3B-int5wc-hadamard-humming-act-fp8.yaml +Qwen3-30B-A3B-int5wc-hadamard-humming-act-int8.yaml diff --git a/tests/evals/gsm8k/configs/humming/config.txt b/tests/evals/gsm8k/configs/humming/config.txt new file mode 100644 index 000000000000..144ed9599350 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/config.txt @@ -0,0 +1,13 @@ +gpt-oss-20b-humming.yaml +Qwen3-30B-A3B-MXFP4A16-humming.yaml +Qwen2-1.5B-Instruct-FP8W8-humming.yaml +Qwen3-0.6B-MXFP8-humming.yaml +Qwen3.6-35B-A3B-NVFP4-humming.yaml +Qwen3-30B-A3B-Fp8-v1-humming.yaml +Qwen3-30B-A3B-FP8-block-humming.yaml +Qwen3-30B-A3B-GPTQ-Int4-humming.yaml +Qwen3-30B-A3B-Instruct-2507-quantized.w8a8-humming.yaml +Qwen3-30B-A3B-NVFP4-humming.yaml +Qwen3-30B-A3B-AWQ-humming.yaml +Qwen3.5-35B-A3B-FP8-humming.yaml +Qwen3.5-35B-A3B-experts-int8-humming.yaml diff --git a/tests/evals/gsm8k/configs/humming/gpt-oss-20b-humming-act-fp8.yaml b/tests/evals/gsm8k/configs/humming/gpt-oss-20b-humming-act-fp8.yaml new file mode 100644 index 000000000000..8e0d9535030e --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/gpt-oss-20b-humming-act-fp8.yaml @@ -0,0 +1,10 @@ +model_name: "openai/gpt-oss-20b" +accuracy_threshold: 0.30 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming +env: + VLLM_HUMMING_INPUT_QUANT_CONFIG: '{"dtype":"float8e4m3"}' diff --git a/tests/evals/gsm8k/configs/humming/gpt-oss-20b-humming.yaml b/tests/evals/gsm8k/configs/humming/gpt-oss-20b-humming.yaml new file mode 100644 index 000000000000..7e9b6508a206 --- /dev/null +++ b/tests/evals/gsm8k/configs/humming/gpt-oss-20b-humming.yaml @@ -0,0 +1,8 @@ +model_name: "openai/gpt-oss-20b" +accuracy_threshold: 0.30 +num_questions: 1319 +num_fewshot: 5 +server_args: >- + --enforce-eager + --max-model-len 8192 + --moe-backend humming diff --git a/tests/evals/gsm8k/configs/models-mi3xx-fp8-and-mixed.txt b/tests/evals/gsm8k/configs/models-mi3xx-fp8-and-mixed.txt index f1122008f597..bcd00044bc01 100644 --- a/tests/evals/gsm8k/configs/models-mi3xx-fp8-and-mixed.txt +++ b/tests/evals/gsm8k/configs/models-mi3xx-fp8-and-mixed.txt @@ -3,3 +3,5 @@ Qwen2.5-VL-3B-Instruct-FP8-dynamic.yaml Qwen1.5-MoE-W4A16-CT.yaml DeepSeek-V2-Lite-Instruct-FP8.yaml Qwen3-Next-FP8-EP2_MI355.yaml +Qwen3-30B-A3B-Thinking-2507-FP8.yaml +Qwen3-30B-A3B-Thinking-2507-PTPC-FP8.yaml diff --git a/tests/evals/gsm8k/configs/models-qwen35-blackwell.txt b/tests/evals/gsm8k/configs/models-qwen35-blackwell.txt index 908ada3a22cf..aef2b9dbe65a 100644 --- a/tests/evals/gsm8k/configs/models-qwen35-blackwell.txt +++ b/tests/evals/gsm8k/configs/models-qwen35-blackwell.txt @@ -1,3 +1,4 @@ Qwen3.5-35B-A3B-DEP2.yaml Qwen3.5-35B-A3B-FP8-DEP2.yaml -Qwen3.5-397B-A17B-NVFP4-DEP2.yaml \ No newline at end of file +Qwen3.5-397B-A17B-NVFP4-DEP2.yaml +Qwen3.5-397B-A17B-NVFP4-DEP2-MTP.yaml \ No newline at end of file diff --git a/tests/evals/gsm8k/configs/models-small-tp.txt b/tests/evals/gsm8k/configs/models-small-tp.txt new file mode 100644 index 000000000000..63bba5bcd1d6 --- /dev/null +++ b/tests/evals/gsm8k/configs/models-small-tp.txt @@ -0,0 +1 @@ +DiffusionGemma-26B-A4B-it-FP8-dynamic.yaml diff --git a/tests/evals/gsm8k/configs/models-small.txt b/tests/evals/gsm8k/configs/models-small.txt index a6a2f6c64f5f..ce5fe25d123a 100644 --- a/tests/evals/gsm8k/configs/models-small.txt +++ b/tests/evals/gsm8k/configs/models-small.txt @@ -4,4 +4,5 @@ Llama-3-8B-Instruct-nonuniform-CT.yaml Qwen2.5-VL-3B-Instruct-FP8-dynamic.yaml Qwen1.5-MoE-W4A16-CT.yaml DeepSeek-V2-Lite-Instruct-FP8.yaml -Qwen3-30B-A3B-MXFP4A16.yaml \ No newline at end of file +Qwen3-30B-A3B-MXFP4A16.yaml +gemma-4-E4B-it-qat-mobile-ct.yaml \ No newline at end of file diff --git a/tests/evals/gsm8k/gsm8k_eval.py b/tests/evals/gsm8k/gsm8k_eval.py index 647c149ef5fd..45f2a13fdd74 100644 --- a/tests/evals/gsm8k/gsm8k_eval.py +++ b/tests/evals/gsm8k/gsm8k_eval.py @@ -10,6 +10,7 @@ import asyncio import json import os +import tempfile import time from collections.abc import Generator @@ -25,7 +26,7 @@ def download_and_cache_file(url: str, filename: str | None = None) -> str: """Download and cache a file from a URL.""" if filename is None: - filename = os.path.join("/tmp", url.split("/")[-1]) + filename = os.path.join(tempfile.gettempdir(), url.split("/")[-1]) if os.path.exists(filename): return filename @@ -106,13 +107,47 @@ async def call_vllm_api( completion_tokens = result.get("usage", {}).get("completion_tokens", 0) return text, completion_tokens except Exception as e: - print(f"Error calling vLLM API: {e}") + print(f"Error calling vLLM API ({type(e).__name__}): {e}") + return "", 0 + + +async def call_vllm_chat_api( + session: aiohttp.ClientSession, + model: str, + prompt: str, + temperature: float, + max_tokens: int, + stop: list[str] | None = None, + url: str | None = None, + seed: int | None = None, +) -> tuple[str, int]: + """Call vLLM's OpenAI-compatible chat completions endpoint.""" + data = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": temperature, + "max_tokens": max_tokens, + "stop": stop, + } + if seed is not None: + data["seed"] = seed + + try: + async with session.post(f"{url}/v1/chat/completions", json=data) as response: + response.raise_for_status() + result = await response.json() + text = result["choices"][0]["message"]["content"] or "" + completion_tokens = result.get("usage", {}).get("completion_tokens", 0) + return text, completion_tokens + except Exception as e: + print(f"Error calling vLLM chat API ({type(e).__name__}): {e}") return "", 0 def _build_gsm8k_prompts( num_questions: int = 1319, num_shots: int = 5, + gen_prefix: str = "", ) -> tuple[list[str], list[int]]: """Build few-shot GSM8K completion prompts and ground-truth labels.""" if num_questions == 0: @@ -124,14 +159,15 @@ def _build_gsm8k_prompts( for i in range(num_shots): few_shot_examples += ( f"Question: {train_data[i]['question']}\n" - f"Answer: {train_data[i]['answer']}\n\n" + f"Answer:{gen_prefix} {train_data[i]['answer']}\n\n" ) prompts = [] labels = [] for i in range(num_questions): prompts.append( - few_shot_examples + f"Question: {test_data[i]['question']}\nAnswer:" + few_shot_examples + + f"Question: {test_data[i]['question']}\nAnswer:{gen_prefix}" ) labels.append(get_answer_value(test_data[i]["answer"])) @@ -173,10 +209,14 @@ def evaluate_gsm8k( num_questions: int = 1319, num_shots: int = 5, max_tokens: int = 256, + model: str | None = None, + use_chat_completions: bool = False, host: str = "http://127.0.0.1", port: int = 8000, temperature: float = 0.0, seed: int | None = 42, + request_timeout_seconds: float = 600, + gen_prefix: str = "", ) -> dict[str, float | int]: """ Evaluate GSM8K accuracy using vLLM serve endpoint. @@ -184,7 +224,7 @@ def evaluate_gsm8k( Returns dict with accuracy, invalid_rate, latency, etc. """ base_url = f"{host}:{port}" - prompts, labels = _build_gsm8k_prompts(num_questions, num_shots) + prompts, labels = _build_gsm8k_prompts(num_questions, num_shots, gen_prefix) num_questions = len(prompts) async def run_async_evaluation(): @@ -192,22 +232,36 @@ async def run_async_evaluation(): output_tokens: list[int] = [0] * num_questions async def get_answer(session: aiohttp.ClientSession, i: int) -> tuple[str, int]: - answer, tokens = await call_vllm_api( - session=session, - prompt=prompts[i], - temperature=temperature, - max_tokens=max_tokens, - stop=["Question", "Assistant:", "<|separator|>"], - url=base_url, - seed=seed, - ) + stop = ["Question", "Assistant:", "<|separator|>"] + if use_chat_completions: + if model is None: + raise ValueError("model is required for chat completions") + answer, tokens = await call_vllm_chat_api( + session=session, + model=model, + prompt=prompts[i], + temperature=temperature, + max_tokens=max_tokens, + stop=stop, + url=base_url, + seed=seed, + ) + else: + answer, tokens = await call_vllm_api( + session=session, + prompt=prompts[i], + temperature=temperature, + max_tokens=max_tokens, + stop=stop, + url=base_url, + seed=seed, + ) states[i] = answer output_tokens[i] = tokens return answer, tokens - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=600) - ) as session: + timeout = aiohttp.ClientTimeout(total=request_timeout_seconds) + async with aiohttp.ClientSession(timeout=timeout) as session: tasks = [get_answer(session, i) for i in range(num_questions)] await tqdm.gather(*tasks, desc="Evaluating") @@ -228,6 +282,7 @@ def evaluate_gsm8k_offline( num_shots: int = 5, max_tokens: int = 256, temperature: float = 0.0, + gen_prefix: str = "", ) -> dict[str, float | int]: """Evaluate GSM8K accuracy using an offline vllm.LLM object. @@ -236,7 +291,7 @@ def evaluate_gsm8k_offline( """ from vllm import SamplingParams - prompts, labels = _build_gsm8k_prompts(num_questions, num_shots) + prompts, labels = _build_gsm8k_prompts(num_questions, num_shots, gen_prefix) sampling_params = SamplingParams( temperature=temperature, diff --git a/tests/evals/gsm8k/test_gsm8k_correctness.py b/tests/evals/gsm8k/test_gsm8k_correctness.py index 57513e18aba3..0a7fd254eef3 100644 --- a/tests/evals/gsm8k/test_gsm8k_correctness.py +++ b/tests/evals/gsm8k/test_gsm8k_correctness.py @@ -9,16 +9,32 @@ --config-list-file=configs/models-small.txt """ +import importlib.metadata import shlex +from importlib.util import find_spec import pytest +import torch import yaml +from packaging import version from tests.utils import RemoteOpenAIServer from vllm.platforms import current_platform from .gsm8k_eval import evaluate_gsm8k +# MXFP4 via quark requires amd-quark >= 0.12 on torch >= 2.11. +# Earlier torch releases work with older quark versions. See +# https://github.com/amd/Quark/issues/34 +# TODO: Remove once amd-quark>=0.12.0 +QUARK_MXFP4_TORCH_COMPATIBLE = find_spec("quark") is not None and ( + version.parse(importlib.metadata.version("amd-quark")) >= version.parse("0.12.0") + if version.parse(torch.__version__.split("+")[0]) >= version.parse("2.11") + else True +) + +DEFAULT_STARTUP_MAX_WAIT_SECONDS = 1200 + def run_gsm8k_eval(eval_config: dict, server_url: str) -> dict: """Run GSM8K evaluation using our isolated script.""" @@ -39,11 +55,24 @@ def run_gsm8k_eval(eval_config: dict, server_url: str) -> dict: host = f"http://{host}" # Run GSM8K evaluation + request_timeout_seconds = eval_config.get("request_timeout_seconds", 600) + if current_platform.is_rocm(): + request_timeout_seconds = eval_config.get( + "rocm_request_timeout_seconds", request_timeout_seconds + ) + results = evaluate_gsm8k( num_questions=eval_config["num_questions"], num_shots=eval_config["num_fewshot"], + max_tokens=eval_config.get("max_tokens", 256), + model=eval_config["model_name"], + use_chat_completions=eval_config.get("use_chat_completions", False), host=host, port=port, + temperature=eval_config.get("temperature", 0.0), + seed=eval_config.get("seed", 42), + request_timeout_seconds=request_timeout_seconds, + gen_prefix=eval_config.get("gen_prefix", ""), ) return results @@ -62,6 +91,15 @@ def test_gsm8k_correctness(config_filename): "Marlin kernels are not supported." ) + if ( + not current_platform.is_cuda() + and "gemma-4-E4B-it-qat-mobile-ct" in eval_config["model_name"] + ): + pytest.skip( + "Skipping gemma-4-E4B-it-qat-mobile-ct on non-CUDA platforms. " + "Its W2A16 (uint2b2) scheme has no kernel outside CUDA." + ) + # TODO(akaratza): Enable DeepSeek-V3.2 and DeepSeek-R1 on ROCm platforms if current_platform.is_rocm() and ( "deepseek-ai/DeepSeek-V3.2" in eval_config["model_name"] @@ -71,7 +109,19 @@ def test_gsm8k_correctness(config_filename): "Skipping DeepSeek-V3.2 and DeepSeek-R1 on ROCm platforms " "due to agent pool disk space issues and pod evictions." ) - + if current_platform.is_rocm() and ("Qwen3.5-35B-A3B-MXFP4" in config_filename.name): + from vllm.platforms.rocm import on_gfx950 + + if not on_gfx950() and "AITER-TP2" in config_filename.name: + pytest.skip( + "Skipping Qwen3.5-35B-A3B-MXFP4-AITER-TP2 on non-GFX950 platforms. " + "The quantization scheme is not supported on non-GFX950 platforms." + ) + if not QUARK_MXFP4_TORCH_COMPATIBLE: + pytest.skip( + "Skipping Qwen3.5-35B-A3B-MXFP4: amd-quark >= 0.12 is required " + "on torch >= 2.11." + ) # Parse server arguments from config (use shlex to handle quoted strings) server_args_str = eval_config.get("server_args", "") server_args = shlex.split(server_args_str) if server_args_str else [] @@ -84,12 +134,23 @@ def test_gsm8k_correctness(config_filename): ] ) - env_dict = eval_config.get("env", None) + startup_max_wait_seconds = eval_config.get( + "startup_max_wait_seconds", DEFAULT_STARTUP_MAX_WAIT_SECONDS + ) + env_dict = dict(eval_config.get("env") or {}) + env_dict["VLLM_ENGINE_READY_TIMEOUT_S"] = str(int(startup_max_wait_seconds)) print(f"Starting GSM8K evaluation for model: {eval_config['model_name']}") print(f"Expected metric threshold: {eval_config['accuracy_threshold']}") print(f"Number of questions: {eval_config['num_questions']}") print(f"Number of few-shot examples: {eval_config['num_fewshot']}") + request_timeout_seconds = eval_config.get("request_timeout_seconds", 600) + if current_platform.is_rocm(): + request_timeout_seconds = eval_config.get( + "rocm_request_timeout_seconds", request_timeout_seconds + ) + print(f"Request timeout: {request_timeout_seconds}s") + print(f"Startup max wait: {startup_max_wait_seconds}s") print(f"Server args: {' '.join(server_args)}") print(f"Environment variables: {env_dict}") @@ -98,7 +159,7 @@ def test_gsm8k_correctness(config_filename): eval_config["model_name"], server_args, env_dict=env_dict, - max_wait_seconds=eval_config.get("startup_max_wait_seconds", 600), + max_wait_seconds=startup_max_wait_seconds, ) as remote_server: server_url = remote_server.url_for("v1") print(f"Server started at: {server_url}") diff --git a/tests/evals/gsm8k/test_gsm8k_offloading.py b/tests/evals/gsm8k/test_gsm8k_offloading.py new file mode 100644 index 000000000000..f652dcaf1bb3 --- /dev/null +++ b/tests/evals/gsm8k/test_gsm8k_offloading.py @@ -0,0 +1,269 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +GSM8K correctness test for CPU KV offloading connectors. + +Regression guard for stride computation bugs in the offloading worker +(e.g. https://github.com/vllm-project/vllm/pull/46888) and silent KV +cache data corruption during CPU offloading. Runs GSM8K twice, dropping +the GPU prefix cache (but not the CPU cache) between runs so the second +run reloads offloaded KV data from CPU. The reset only succeeds once +in-flight offload transfers have released their GPU blocks, so retrying +it until success also waits for offloading to complete. + +Correctness is enforced via GSM8K accuracy on both runs: corrupt reloaded +KV drops accuracy below threshold. The reload itself is not asserted, so +a silently skipped reload (e.g. offloading disabled) would not be flagged. + +Covers both KV offloading connectors (OffloadingConnector and +SimpleCPUOffloadConnector) across four architecture families: + - Hybrid Mamba (NemotronH: attention + Mamba) + - Heterogeneous head dim (Gemma 4) + - Hybrid GDN (Qwen 3.5: attention + GatedDeltaNet) + - Compressed attention (DeepSeek-V4-Flash: CSA) + +Usage: + pytest -s -v evals/gsm8k/test_gsm8k_offloading.py +""" + +import json +import time +from dataclasses import dataclass, field + +import pytest +import requests + +from tests.utils import RemoteOpenAIServer +from vllm.platforms import current_platform + +from .gsm8k_eval import evaluate_gsm8k + +if not current_platform.is_cuda_alike(): + pytest.skip("Requires CUDA or ROCm", allow_module_level=True) + +NUM_QUESTIONS = 200 +NUM_FEWSHOT = 5 + +_OFFLOAD_SYNC_TIMEOUT = 60 + + +def _kv_transfer_config(connector: str, cpu_gib: int = 4) -> str: + if connector == "OffloadingConnector": + return json.dumps( + { + "kv_connector": "OffloadingConnector", + "kv_role": "kv_both", + "kv_connector_extra_config": { + "spec_name": "CPUOffloadingSpec", + "cpu_bytes_to_use": cpu_gib << 30, + "eviction_policy": "lru", + }, + } + ) + elif connector == "SimpleCPUOffloadConnector": + return json.dumps( + { + "kv_connector": "SimpleCPUOffloadConnector", + "kv_role": "kv_both", + "kv_connector_extra_config": { + "cpu_bytes_to_use": cpu_gib << 30, + }, + } + ) + else: + raise ValueError(f"Unknown connector: {connector}") + + +def _force_engine_step(base_url: str) -> None: + """Force an engine step so completed offload transfers get processed.""" + requests.post( + f"{base_url}/v1/completions", + json={"prompt": "0", "max_tokens": 1, "temperature": 0}, + timeout=60, + ).raise_for_status() + + +def _reset_gpu_prefix_cache(base_url: str) -> None: + """Drop the GPU prefix cache while keeping the CPU (connector) cache, so + the next run must reload KV data through the connector. + + The reset fails while asynchronous offload transfers still hold GPU + blocks, so retry until it succeeds. Requires VLLM_SERVER_DEV_MODE=1. + """ + deadline = time.monotonic() + _OFFLOAD_SYNC_TIMEOUT + while True: + resp = requests.post( + f"{base_url}/reset_prefix_cache", + params={"reset_external": "false"}, + timeout=30, + ) + resp.raise_for_status() + if resp.json().get("success"): + return + assert time.monotonic() < deadline, ( + f"prefix cache reset did not succeed within {_OFFLOAD_SYNC_TIMEOUT}s; " + "async offload may be stuck" + ) + _force_engine_step(base_url) + + +@dataclass +class OffloadingModelConfig: + id: str + model: str + connector: str + accuracy_threshold: float + tolerance: float = 0.05 + extra_server_args: list[str] = field(default_factory=list) + cpu_offload_gib: int = 4 + startup_timeout: int = 600 + + +MODELS = [ + # ── OffloadingConnector ────────────────────────────────────────── + OffloadingModelConfig( + id="offloading-nemotron-h-8b", + model="nvidia/Nemotron-H-8B-Base-8K", + connector="OffloadingConnector", + # Baseline ~0.49 on 200 questions (measured on GB200). + accuracy_threshold=0.39, + ), + OffloadingModelConfig( + id="offloading-gemma-4-e4b-it", + model="google/gemma-4-E4B-it", + connector="OffloadingConnector", + # Baseline ~0.64 on 200 questions (measured on GB200). + accuracy_threshold=0.55, + ), + OffloadingModelConfig( + id="offloading-qwen3.5-35b", + model="Qwen/Qwen3.5-35B-A3B", + connector="OffloadingConnector", + accuracy_threshold=0.75, + extra_server_args=[ + "--tensor-parallel-size", + "2", + "--enable-expert-parallel", + ], + startup_timeout=1200, + ), + OffloadingModelConfig( + id="offloading-deepseek-v4-flash", + model="deepseek-ai/DeepSeek-V4-Flash", + connector="OffloadingConnector", + # Baseline ~0.97 on 200 questions (measured on GB200). + accuracy_threshold=0.90, + extra_server_args=[ + "--tensor-parallel-size", + "4", + "--enable-expert-parallel", + "--kv-cache-dtype", + "fp8", + "--block-size", + "256", + ], + cpu_offload_gib=16, + startup_timeout=1200, + ), + # ── SimpleCPUOffloadConnector ──────────────────────────────────── + OffloadingModelConfig( + id="simple-nemotron-h-8b", + model="nvidia/Nemotron-H-8B-Base-8K", + connector="SimpleCPUOffloadConnector", + accuracy_threshold=0.45, + ), + OffloadingModelConfig( + id="simple-gemma-4-e4b-it", + model="google/gemma-4-E4B-it", + connector="SimpleCPUOffloadConnector", + accuracy_threshold=0.55, + ), + OffloadingModelConfig( + id="simple-qwen3.5-35b", + model="Qwen/Qwen3.5-35B-A3B", + connector="SimpleCPUOffloadConnector", + accuracy_threshold=0.75, + extra_server_args=[ + "--tensor-parallel-size", + "2", + "--enable-expert-parallel", + ], + startup_timeout=1200, + ), + OffloadingModelConfig( + id="simple-deepseek-v4-flash", + model="deepseek-ai/DeepSeek-V4-Flash", + connector="SimpleCPUOffloadConnector", + accuracy_threshold=0.90, + extra_server_args=[ + "--tensor-parallel-size", + "4", + "--enable-expert-parallel", + "--kv-cache-dtype", + "fp8", + "--block-size", + "256", + ], + cpu_offload_gib=16, + startup_timeout=1200, + ), +] + + +@pytest.mark.parametrize("cfg", MODELS, ids=lambda c: c.id) +def test_gsm8k_offloading_correctness(cfg: OffloadingModelConfig): + if "--tensor-parallel-size" in cfg.extra_server_args: + tp_size = int( + cfg.extra_server_args[ + cfg.extra_server_args.index("--tensor-parallel-size") + 1 + ] + ) + if current_platform.device_count() < tp_size: + pytest.skip(f"Requires {tp_size} GPUs") + + # Prefix caching must be explicitly enabled: SimpleCPUOffloadConnector requires it. + server_args = [ + "--enforce-eager", + "--max-model-len", + "4096", + "--enable-prefix-caching", + "--no-disable-hybrid-kv-cache-manager", + "--kv-transfer-config", + _kv_transfer_config(cfg.connector, cfg.cpu_offload_gib), + "--trust-remote-code", + "--disable-uvicorn-access-log", + *cfg.extra_server_args, + ] + + with RemoteOpenAIServer( + cfg.model, + server_args, + # /reset_prefix_cache requires dev mode. + env_dict={"VLLM_SERVER_DEV_MODE": "1"}, + max_wait_seconds=cfg.startup_timeout, + ) as server: + base_url = f"http://{server.host}:{server.port}" + + for run_idx in range(1, 3): + results = evaluate_gsm8k( + num_questions=NUM_QUESTIONS, + num_shots=NUM_FEWSHOT, + host=f"http://{server.host}", + port=server.port, + ) + + print( + f"GSM8K run {run_idx}/2 + {cfg.connector} ({cfg.id}): " + f"accuracy={results['accuracy']:.4f}, " + f"invalid_rate={results['invalid_rate']:.3f}, " + f"latency={results['latency']:.1f}s" + ) + + assert results["accuracy"] >= (cfg.accuracy_threshold - cfg.tolerance), ( + f"GSM8K run {run_idx}/2 accuracy " + f"{results['accuracy']:.4f} below " + f"{cfg.accuracy_threshold - cfg.tolerance:.4f}" + ) + + if run_idx == 1: + _reset_gpu_prefix_cache(base_url) diff --git a/vllm/entrypoints/serve/render/__init__.py b/tests/fusion/__init__.py similarity index 100% rename from vllm/entrypoints/serve/render/__init__.py rename to tests/fusion/__init__.py diff --git a/tests/fusion/test_quant_activation_contract.py b/tests/fusion/test_quant_activation_contract.py new file mode 100644 index 000000000000..48d492b8d2ee --- /dev/null +++ b/tests/fusion/test_quant_activation_contract.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Contract tests for the QuantizedActivation linear-kernel integration.""" + +import pytest +import torch + +from vllm.model_executor.kernels.linear import ( + _POSSIBLE_FP8_BLOCK_KERNELS, + _POSSIBLE_FP8_KERNELS, + _POSSIBLE_INT8_KERNELS, + _POSSIBLE_NVFP4_KERNELS, +) +from vllm.model_executor.kernels.linear.nvfp4.base import ( + NvFp4LinearKernel, + NvFp4LinearLayerConfig, +) +from vllm.model_executor.kernels.linear.nvfp4.flashinfer import ( + FlashInferCutlassNvFp4LinearKernel, + FlashInferTrtllmNvFp4LinearKernel, +) +from vllm.model_executor.kernels.linear.scaled_mm.cutlass import ( + CutlassFP8ScaledMMLinearKernel, +) +from vllm.model_executor.kernels.linear.scaled_mm.flashinfer import ( + FlashInferFP8ScaledMMLinearKernel, +) +from vllm.model_executor.kernels.linear.scaled_mm.ScaledMMLinearKernel import ( + FP8ScaledMMLinearLayerConfig, + Int8ScaledMMLinearKernel, + Int8ScaledMMLinearLayerConfig, +) +from vllm.model_executor.layers.fusion.quant_activation import ( + QuantizedActivation, + as_quantized_activation, + expose_input_quant_key, +) +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + kFp8StaticTensorSym, + kNvfp4Dynamic, +) +from vllm.platforms import current_platform + +# The only backends that consume a pre-quantized activation. +SUPPORTING = { + CutlassFP8ScaledMMLinearKernel, + FlashInferFP8ScaledMMLinearKernel, + FlashInferCutlassNvFp4LinearKernel, +} + + +def _all_kernel_classes() -> list[type]: + seen: dict[type, None] = {} + for registry in ( + _POSSIBLE_FP8_KERNELS, + _POSSIBLE_FP8_BLOCK_KERNELS, + _POSSIBLE_INT8_KERNELS, + _POSSIBLE_NVFP4_KERNELS, + ): + for kernels in registry.values(): + for cls in kernels: + seen.setdefault(cls, None) + return list(seen) + + +def _probe(cls: type): + """A bare kernel instance with a plausible config, so input_quant_key() + can be queried without the hardware-gated constructor.""" + obj = cls.__new__(cls) # type: ignore[call-overload] + if issubclass(cls, NvFp4LinearKernel): + obj.config = NvFp4LinearLayerConfig() + elif issubclass(cls, Int8ScaledMMLinearKernel): + obj.config = Int8ScaledMMLinearLayerConfig( + is_static_input_scheme=True, is_channelwise=False, input_symmetric=True + ) + else: + obj.config = FP8ScaledMMLinearLayerConfig( + weight_quant_key=kFp8StaticTensorSym, + activation_quant_key=kFp8StaticTensorSym, + weight_shape=(16, 16), + input_dtype=torch.bfloat16, + out_dtype=torch.bfloat16, + ) + return obj + + +def _resolved_apply_weights(cls: type): + for base in cls.__mro__: + if "apply_weights" in base.__dict__: + return base.__dict__["apply_weights"] + raise AssertionError(f"{cls.__name__} has no apply_weights in its MRO") + + +def test_only_known_backends_support_prequantized_input(): + declarers = {c for c in _all_kernel_classes() if _probe(c).input_quant_key()} + assert declarers == SUPPORTING + + +def test_supporting_backend_declares_consume_via_helper(): + for cls in SUPPORTING: + fn = _resolved_apply_weights(cls) + assert "as_quantized_activation" in fn.__code__.co_names, cls.__name__ + + +def test_bridge_marks_supporting_and_skips_others(): + supported = _probe(FlashInferCutlassNvFp4LinearKernel) + layer = torch.nn.Module() + expose_input_quant_key(layer, supported) + assert layer.input_quant_key == kNvfp4Dynamic + + unsupported = _probe(FlashInferTrtllmNvFp4LinearKernel) + assert unsupported.input_quant_key() is None + layer = torch.nn.Module() + expose_input_quant_key(layer, unsupported) + assert not hasattr(layer, "input_quant_key") + + +def test_as_quantized_activation_validates_key(): + qa = QuantizedActivation( + data=torch.zeros(2, 4, dtype=current_platform.fp8_dtype()), + scale=torch.tensor(1.0), + orig_dtype=torch.bfloat16, + orig_shape=torch.Size([2, 4]), + quant_key=kFp8StaticTensorSym, + ) + with pytest.raises(AssertionError): + as_quantized_activation(qa, kNvfp4Dynamic) + with pytest.raises(AssertionError): + as_quantized_activation(qa, None) + assert as_quantized_activation(torch.zeros(2, 4), kFp8StaticTensorSym) is None + assert as_quantized_activation(qa, kFp8StaticTensorSym) is qa diff --git a/tests/kernels/attention/test_attention.py b/tests/kernels/attention/test_attention.py index 9ddceef8fb38..662e14a9bb87 100644 --- a/tests/kernels/attention/test_attention.py +++ b/tests/kernels/attention/test_attention.py @@ -21,16 +21,14 @@ # There may not be enough gpu memory due to large NUM_BLOCKS. # Reduce NUM_BLOCKS when it happens. NUM_BLOCKS = 4321 # Arbitrary values for testing -PARTITION_SIZE = 512 PARTITION_SIZE_ROCM = 256 DTYPES = [torch.bfloat16] NUM_GEN_SEQS = [7] # Arbitrary values for testing NUM_PREFILL_SEQS = [3] # Arbitrary values for testing -NUM_HEADS = [(40, 40), (64, 8)] # Arbitrary values for testing +NUM_HEADS = [(32, 8), (40, 40), (64, 8)] # Arbitrary values for testing -# This should be sync with get_supported_head_sizes() in -# vllm.v1.attention.ops.paged_attn.PagedAttention -HEAD_SIZES = [32, 80, 128, 256] +# Head sizes supported by the ROCm paged attention kernel. +HEAD_SIZES = [64, 128] BLOCK_SIZES = [16, 32] USE_ALIBI = [False, True] @@ -111,8 +109,8 @@ def ref_single_query_cached_kv_attention( output[i].copy_(out, non_blocking=True) -@pytest.mark.parametrize( - "version", ["v1", "v2"] if not current_platform.is_rocm() else ["v1", "v2", "rocm"] +@pytest.mark.skipif( + not current_platform.is_rocm(), reason="ROCm-only paged attention kernel" ) @pytest.mark.parametrize("num_seqs", NUM_GEN_SEQS) @pytest.mark.parametrize("num_heads", NUM_HEADS) @@ -125,7 +123,6 @@ def ref_single_query_cached_kv_attention( @pytest.mark.parametrize("device", CUDA_DEVICES) def test_paged_attention( kv_cache_factory, - version: str, num_seqs: int, num_heads: tuple[int, int], head_size: int, @@ -136,22 +133,11 @@ def test_paged_attention( seed: int, device: str, ) -> None: - if (kv_cache_dtype == "fp8" and head_size % 16) or ( - version == "rocm" and head_size not in (64, 128) + if current_platform.is_navi() and ( + kv_cache_dtype == "fp8" or head_size != 128 or block_size != 16 or use_alibi ): pytest.skip() - if ( - version == "rocm" - and current_platform.is_navi() - and ( - kv_cache_dtype == "fp8" or head_size != 128 or block_size != 16 or use_alibi - ) - ): - pytest.skip() - - global PARTITION_SIZE - set_random_seed(seed) torch.set_default_device(device) scale = float(1.0 / (head_size**0.5)) @@ -200,9 +186,46 @@ def test_paged_attention( # Call the paged attention kernel. output = torch.empty_like(query) - if version == "v1": - ops.paged_attention_v1( + num_partitions = (max_seq_len + PARTITION_SIZE_ROCM - 1) // PARTITION_SIZE_ROCM + assert PARTITION_SIZE_ROCM % block_size == 0 + num_seqs, num_heads, head_size = output.shape + tmp_output = torch.empty( + size=(num_seqs, num_heads, num_partitions, head_size), + dtype=output.dtype, + ) + exp_sums = torch.empty( + size=(num_seqs, num_heads, num_partitions), + dtype=torch.float32, + ) + max_logits = torch.empty_like(exp_sums) + ops.paged_attention_rocm( + output, + exp_sums, + max_logits, + tmp_output, + query, + key_cache, + value_cache, + num_kv_heads, + scale, + block_tables, + seq_lens, + None, + block_size, + max_seq_len, + alibi_slopes, + kv_cache_dtype, + k_scale, + v_scale, + ) + + opcheck( + torch.ops._rocm_C.paged_attention, + ( output, + exp_sums, + max_logits, + tmp_output, query, key_cache, value_cache, @@ -210,155 +233,18 @@ def test_paged_attention( scale, block_tables, seq_lens, + None, block_size, max_seq_len, alibi_slopes, kv_cache_dtype, k_scale, v_scale, - ) - - opcheck( - torch.ops._C.paged_attention_v1, - ( - output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - 0, - 0, - 0, - 64, - 0, - ), - cond=(head_size == HEAD_SIZES[0] and block_size == BLOCK_SIZES[0]), - ) - - elif version in ("v2", "rocm"): - if current_platform.is_rocm() and version == "rocm": - PARTITION_SIZE = PARTITION_SIZE_ROCM - - num_partitions = (max_seq_len + PARTITION_SIZE - 1) // PARTITION_SIZE - assert PARTITION_SIZE % block_size == 0 - num_seqs, num_heads, head_size = output.shape - tmp_output = torch.empty( - size=(num_seqs, num_heads, num_partitions, head_size), - dtype=output.dtype, - ) - exp_sums = torch.empty( - size=(num_seqs, num_heads, num_partitions), - dtype=torch.float32, - ) - max_logits = torch.empty_like(exp_sums) - if version == "v2": - ops.paged_attention_v2( - output, - exp_sums, - max_logits, - tmp_output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - ) - - opcheck( - torch.ops._C.paged_attention_v2, - ( - output, - exp_sums, - max_logits, - tmp_output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - 0, - 0, - 0, - 64, - 0, - ), - cond=(head_size == HEAD_SIZES[0] and block_size == BLOCK_SIZES[0]), - ) - - else: - ops.paged_attention_rocm( - output, - exp_sums, - max_logits, - tmp_output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - None, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - ) - - opcheck( - torch.ops._rocm_C.paged_attention, - ( - output, - exp_sums, - max_logits, - tmp_output, - query, - key_cache, - value_cache, - num_kv_heads, - scale, - block_tables, - seq_lens, - None, - block_size, - max_seq_len, - alibi_slopes, - kv_cache_dtype, - k_scale, - v_scale, - ), - cond=(head_size == HEAD_SIZES[0] and block_size == BLOCK_SIZES[0]), - ) - - else: - raise AssertionError(f"Unknown version: {version}") + None, + "f16", + ), + cond=(head_size == 64 and block_size == BLOCK_SIZES[0]), + ) # Run the reference implementation. if kv_cache_dtype == "fp8": diff --git a/tests/kernels/attention/test_attention_selector.py b/tests/kernels/attention/test_attention_selector.py index db4dcc8a636e..1e85f76b64c3 100644 --- a/tests/kernels/attention/test_attention_selector.py +++ b/tests/kernels/attention/test_attention_selector.py @@ -15,16 +15,14 @@ from vllm.platforms import current_platform from vllm.platforms.cpu import CpuPlatform -# CudaPlatform and RocmPlatform import their respective compiled C extensions -# at module level, raising ModuleNotFoundError on incompatible builds. -try: +if current_platform.is_cuda(): from vllm.platforms.cuda import CudaPlatform -except (ImportError, ModuleNotFoundError): +else: CudaPlatform = None -try: +if current_platform.is_rocm(): from vllm.platforms.rocm import RocmPlatform -except (ImportError, ModuleNotFoundError): +else: RocmPlatform = None from vllm.v1.attention.backends.registry import AttentionBackendEnum @@ -434,9 +432,15 @@ def test_per_head_quant_scales_backend_selection( [ ("FLASH_ATTN", True, True), # FlashAttn supports non-causal ("FLASH_ATTN", False, True), # FlashAttn also works with causal - ("FLASHINFER", True, False), # FlashInfer does not support non-causal - ("FLASHINFER", False, True), # FlashInfer works with causal - ], + ] + + ( + [ + ("FLASHINFER", True, True), # FlashInfer supports non-causal + ("FLASHINFER", False, True), # FlashInfer works with causal + ] + if CudaPlatform is not None + else [] + ), ) def test_non_causal_backend_selection( backend_name: str, use_non_causal: bool, should_succeed: bool @@ -459,11 +463,12 @@ def test_non_causal_backend_selection( attention_config=attention_config, cache_config=cache_config ) - if CudaPlatform is None: - pytest.skip("CudaPlatform not available") + platform = CudaPlatform or RocmPlatform + if platform is None: + pytest.skip("CudaPlatform and RocmPlatform are not available") with ( set_current_vllm_config(vllm_config), - patch("vllm.platforms.current_platform", CudaPlatform()), + patch("vllm.platforms.current_platform", platform()), ): if should_succeed: backend = get_attn_backend( diff --git a/tests/kernels/attention/test_cache.py b/tests/kernels/attention/test_cache.py index 9b022a042c81..7558da1c6008 100644 --- a/tests/kernels/attention/test_cache.py +++ b/tests/kernels/attention/test_cache.py @@ -428,6 +428,43 @@ def dequant_nvfp4_cache_nhd(data_cache, scale_cache, global_scale): torch.testing.assert_close(value_cache_compact, cloned_value_cache) +@pytest.mark.parametrize("dtype", DTYPES) +@pytest.mark.parametrize("kv_cache_dtype", KV_CACHE_DTYPE) +@pytest.mark.parametrize("kv_cache_layout", CACHE_LAYOUTS) +@pytest.mark.parametrize("implementation", RESHAPE_FLASH_IMPLEMENTATIONS) +@torch.inference_mode() +def test_reshape_and_cache_flash_unaligned_rows( + kv_cache_factory_flashinfer, + dtype: torch.dtype, + kv_cache_dtype: str, + kv_cache_layout: str, + implementation: str, +) -> None: + """Regression test for https://github.com/vllm-project/vllm/issues/41257. + + head_size=46 with num_heads=13 places KV-cache rows at byte offsets + that are not a multiple of the vector width (NHD row pitch + 13*46*itemsize, HND head pitch 46*itemsize), unlike HEAD_SIZES above + which are all 16-byte multiples. The CUDA kernel used to issue + vectorized stores to those rows -> CUDA misaligned address. + """ + test_reshape_and_cache_flash( + kv_cache_factory_flashinfer, + num_tokens=42, + num_heads=13, + head_size=46, + block_size=16, + num_blocks=128, + dtype=dtype, + seed=0, + device=CUDA_DEVICES[0], + kv_cache_dtype=kv_cache_dtype, + kv_cache_layout=kv_cache_layout, + kv_scale_type="tensor", + implementation=implementation, + ) + + @pytest.mark.parametrize("direction", COPYING_DIRECTION) @pytest.mark.parametrize("num_mappings", NUM_MAPPINGS) @pytest.mark.parametrize("num_heads", NUM_HEADS) @@ -978,6 +1015,78 @@ def test_gather_and_maybe_dequant_cache_mla( torch.testing.assert_close(dst, expected) +@pytest.mark.parametrize("kv_lora_rank", [512]) +@pytest.mark.parametrize("qk_rope_head_dim", [64]) +@pytest.mark.parametrize("block_size", [16]) +@pytest.mark.parametrize("num_blocks", [128]) +@pytest.mark.parametrize("dtype", [torch.float32]) +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"]) +@pytest.mark.parametrize("device", CUDA_DEVICES) +@torch.inference_mode() +def test_gather_and_maybe_dequant_cache_mla_with_seq_starts( + kv_lora_rank, + qk_rope_head_dim, + block_size, + num_blocks, + dtype, + kv_cache_dtype, + device, +): + entry_size = kv_lora_rank + qk_rope_head_dim + scale = torch.tensor(0.1, dtype=torch.float32, device=device) + src_cache = _create_mla_cache( + num_blocks, block_size, entry_size, dtype, kv_cache_dtype, device + ) + _fill_mla_cache(src_cache, kv_cache_dtype=kv_cache_dtype) + + seq_starts = torch.tensor([3, 17, 5], dtype=torch.int32, device=device) + seq_lens = torch.tensor([20, 10, 16], dtype=torch.int32, device=device) + batch_size = seq_lens.shape[0] + total_tokens = seq_lens.sum().item() + cu_seq_lens = torch.empty((batch_size + 1), dtype=torch.int32, device=device) + cu_seq_lens[0] = 0 + cu_seq_lens[1:] = seq_lens.cumsum(dim=0) + token_to_seq = torch.repeat_interleave( + torch.arange(batch_size, dtype=torch.int32, device=device), seq_lens + ) + + block_table = torch.empty( + (batch_size, num_blocks), dtype=torch.int32, device=device + ) + for b in range(batch_size): + block_table[b, :] = torch.randperm(num_blocks, device=device) + + if kv_cache_dtype == "fp8": + dequant_src_cache = torch.empty_like(src_cache, dtype=dtype) + ops.convert_fp8(dequant_src_cache, src_cache, scale.item()) + else: + dequant_src_cache = src_cache + + expected_rows = [] + for b in range(batch_size): + start = seq_starts[b].item() + length = seq_lens[b].item() + for offset in range(start, start + length): + block_id = block_table[b, offset // block_size] + slot = offset % block_size + expected_rows.append(dequant_src_cache[block_id, slot]) + expected = torch.stack(expected_rows) + + dst = torch.zeros((total_tokens, entry_size), dtype=dtype, device=device) + ops.gather_and_maybe_dequant_cache( + src_cache, + dst, + block_table, + cu_seq_lens, + token_to_seq, + total_tokens, + kv_cache_dtype, + scale, + seq_starts, + ) + torch.testing.assert_close(dst, expected) + + @pytest.mark.parametrize("kv_lora_rank", [512]) @pytest.mark.parametrize("qk_rope_head_dim", [64]) @pytest.mark.parametrize("block_size", [16]) diff --git a/tests/kernels/attention/test_cpu_attn.py b/tests/kernels/attention/test_cpu_attn.py index c39395025516..e296c226d709 100644 --- a/tests/kernels/attention/test_cpu_attn.py +++ b/tests/kernels/attention/test_cpu_attn.py @@ -25,7 +25,6 @@ if torch.cpu._is_amx_tile_supported(): torch.cpu._init_amx() - NUM_HEADS = [ (4, 4), (8, 2), @@ -43,6 +42,11 @@ [(2345, 2345), (5, 5), (3, 16), (134, 5131)], # prefill batch [(992, 2456), (1, 1234), (98, 1145), (1, 4162), (2345, 2345)], # mixed batch ] +_FP8_ATOL = {"fp8_e4m3": 0.2, "fp8_e5m2": 0.3} +_FP8_RTOL = 0.1 +ENCODER_SEQ_LENS = [ + [1, 678, 2367, 145, 4162, 36, 7812], +] def get_attn_isa( @@ -61,10 +65,7 @@ def get_attn_isa( # rand number generation takes too much time, cache rand tensors @functools.lru_cache(maxsize=128, typed=False) -def tensor_cache( - elem_num: int, - dtype: torch.dtype, -) -> torch.Tensor: +def tensor_cache(elem_num: int, dtype: torch.dtype, tag: str = "none") -> torch.Tensor: tensor = torch.randn(elem_num, dtype=dtype) return tensor @@ -106,6 +107,7 @@ def ref_paged_attn( soft_cap: float | None = None, alibi_slopes: torch.Tensor | None = None, s_aux: torch.Tensor | None = None, + dynamic_causal: list[bool] | None = None, ) -> torch.Tensor: num_seqs = len(query_lens) block_tables = block_tables.cpu().numpy() @@ -141,17 +143,30 @@ def ref_paged_attn( v = torch.repeat_interleave(v, q.shape[1] // v.shape[1], dim=1) attn = torch.einsum("qhd,khd->hqk", q, k).float() empty_mask = torch.ones(query_len, kv_len) - mask = torch.triu(empty_mask, diagonal=kv_len - query_len + 1).bool() - if sliding_window is not None: - sliding_window_mask = ( - torch.triu( - empty_mask, diagonal=kv_len - (query_len + sliding_window) + 1 + if dynamic_causal is None or dynamic_causal[i]: + mask = torch.triu(empty_mask, diagonal=kv_len - query_len + 1).bool() + if sliding_window is not None: + sliding_window_mask = ( + torch.triu( + empty_mask, diagonal=kv_len - (query_len + sliding_window) + 1 + ) + .bool() + .logical_not() ) - .bool() - .logical_not() - ) - mask |= sliding_window_mask + mask |= sliding_window_mask + else: + if sliding_window is not None: + mask = ( + torch.triu( + empty_mask, diagonal=1 - sliding_window + kv_len - query_len + ).bool() + ^ torch.triu( + empty_mask, diagonal=sliding_window + kv_len - query_len + ).bool() + ).logical_not() + else: + mask = empty_mask.logical_not() if soft_cap is not None: attn = soft_cap * torch.tanh(attn / soft_cap) @@ -183,8 +198,217 @@ def ref_paged_attn( return torch.cat(outputs, dim=0) -_FP8_ATOL = {"fp8_e4m3": 0.2, "fp8_e5m2": 0.3} -_FP8_RTOL = 0.1 +def ref_varlen_encoder_attn( + query: torch.Tensor, # [token, q_head_num, head_dim] + key: torch.Tensor, # [token, kv_head_num, head_dim] + value: torch.Tensor, + seq_lens: list[int], + scale: float, + sliding_window: int | None = None, +) -> torch.Tensor: + num_seqs = len(seq_lens) + dtype = query.dtype + + output = torch.empty_like(query) + + start_idx = 0 + for i in range(num_seqs): + seq_len = seq_lens[i] + q = query[start_idx : start_idx + seq_len].float() + k = key[start_idx : start_idx + seq_len].float() + v = value[start_idx : start_idx + seq_len].float() + q *= scale + + if q.shape[1] != k.shape[1]: + k = torch.repeat_interleave(k, q.shape[1] // k.shape[1], dim=1) + v = torch.repeat_interleave(v, q.shape[1] // v.shape[1], dim=1) + attn = torch.einsum("qhd,khd->hqk", q, k).float() + empty_mask = torch.ones(seq_len, seq_len) + if sliding_window is not None: + mask = ( + torch.triu(empty_mask, diagonal=1 - sliding_window).bool() + ^ torch.triu(empty_mask, diagonal=sliding_window).bool() + ).logical_not() + else: + mask = empty_mask.logical_not() + + attn.masked_fill_(mask, float("-inf")) + attn = torch.softmax(attn, dim=-1) + out = torch.einsum("hqk,khd->qhd", attn, v).to(dtype=dtype) + output[start_idx : start_idx + seq_len].copy_(out) + + start_idx += seq_len + + return output + + +@torch.inference_mode() +def varlen_encoder_attention( + seq_lens: list[int], + num_heads: tuple[int, int], + head_size: int, + sliding_window: int | None, + dtype: torch.dtype, + block_size: int, + isa: str, +) -> None: + set_random_seed(0) + num_seqs = len(seq_lens) + num_query_heads = num_heads[0] + num_kv_heads = num_heads[1] + assert num_query_heads % num_kv_heads == 0 + scale = head_size**-0.5 + token_num = sum(seq_lens) + + seq_lens_tensor = torch.tensor(seq_lens, dtype=torch.int32) + query_start_loc = torch.zeros(num_seqs, dtype=torch.int32) + torch.cumsum(seq_lens_tensor[:-1], 0, out=query_start_loc[1:]) + block_nums = (seq_lens_tensor + block_size - 1) // block_size + start_block_ids = torch.zeros_like(seq_lens_tensor) + torch.cumsum(block_nums[:-1], 0, out=start_block_ids[1:]) + total_block_num: int = block_nums.sum().item() + max_block_num = block_nums.max().item() + block_offsets = torch.arange(0, max_block_num, dtype=torch.int32) + encoder_block_table = start_block_ids[:, None] + block_offsets[None, :] + slot_mapping_list = [] + slot_start_idx = 0 + for i in range(num_seqs): + block_num = block_nums[i].item() + seq_len = seq_lens[i] + slot_mapping_list.append(torch.arange(slot_start_idx, slot_start_idx + seq_len)) + slot_start_idx += block_num * block_size + slot_mapping = torch.cat(slot_mapping_list) + + query = tensor_cache( + elem_num=token_num * num_query_heads * head_size, + dtype=dtype, + tag="query", + ) + query = query.view( + token_num, + num_query_heads, + head_size, + ) + + key_value = tensor_cache( + elem_num=2 * token_num * num_kv_heads * head_size, + dtype=dtype, + tag="kv", + ) + key_value = key_value.view( + 2, + token_num, + num_kv_heads, + head_size, + ) + key, value = key_value.unbind(0) + + # KV cache for CPU attention + packed_key_value_cache = torch.zeros( + total_block_num, num_kv_heads, block_size, head_size * 2, dtype=dtype + ) + packed_key_value_cache = packed_key_value_cache.view( + (total_block_num, num_kv_heads, block_size * 2, -1) + ) + packed_key_cache, packed_value_cache = packed_key_value_cache.chunk(2, dim=2) + + cu_query_lens = torch.tensor([0] + seq_lens, dtype=torch.int32).cumsum( + dim=0, dtype=torch.int32 + ) + kv_lens_tensor = torch.tensor(seq_lens, dtype=torch.int32) + + # use reshape_and_cache to pack key_cache and value_cache + cpu_attn_reshape_and_cache( + key=key.view(-1, num_kv_heads, head_size), + value=value.view(-1, num_kv_heads, head_size), + key_cache=packed_key_cache, + value_cache=packed_value_cache, + slot_mapping=slot_mapping, + isa=isa, + ) + + metadata = cpu_attn_get_scheduler_metadata( + num_reqs=num_seqs, + num_heads=num_query_heads, + num_kv_heads=num_kv_heads, + head_dim=head_size, + seq_lens=kv_lens_tensor, + dtype=dtype, + query_start_loc=cu_query_lens, + causal=False, + sliding_window_size=sliding_window if sliding_window is not None else -1, + isa=isa, + enable_kv_split=False, + ) + + out_without_split = torch.empty_like(query) + cpu_attention_with_kv_cache( + query=query, + key_cache=packed_key_cache, + value_cache=packed_value_cache, + output=out_without_split, + query_start_loc=cu_query_lens, + seq_lens=kv_lens_tensor, + scale=scale, + causal=False, + alibi_slopes=None, + sliding_window=sliding_window if sliding_window is not None else -1, + block_table=encoder_block_table, + softcap=0, + scheduler_metadata=metadata, + s_aux=None, + ) + + metadata = cpu_attn_get_scheduler_metadata( + num_reqs=num_seqs, + num_heads=num_query_heads, + num_kv_heads=num_kv_heads, + head_dim=head_size, + seq_lens=kv_lens_tensor, + dtype=dtype, + query_start_loc=cu_query_lens, + causal=False, + sliding_window_size=sliding_window if sliding_window is not None else -1, + isa=isa, + enable_kv_split=True, + ) + + out_with_split = torch.empty_like(query) + cpu_attention_with_kv_cache( + query=query, + key_cache=packed_key_cache, + value_cache=packed_value_cache, + output=out_with_split, + query_start_loc=cu_query_lens, + seq_lens=kv_lens_tensor, + scale=scale, + causal=False, + alibi_slopes=None, + sliding_window=sliding_window if sliding_window is not None else -1, + block_table=encoder_block_table, + softcap=0, + scheduler_metadata=metadata, + s_aux=None, + ) + + ref_output = ref_varlen_encoder_attn( + query=query, + key=key, + value=value, + seq_lens=seq_lens, + scale=scale, + sliding_window=sliding_window, + ) + atol, rtol = 1.5e-2, 1e-2 + + ( + torch.testing.assert_close(out_with_split, ref_output, atol=atol, rtol=rtol), + f"{torch.max(torch.abs(out_with_split - ref_output))}", + ) + ( + torch.testing.assert_close(out_without_split, ref_output, atol=atol, rtol=rtol), + f"{torch.max(torch.abs(out_without_split - ref_output))}", + ) @torch.inference_mode() @@ -203,6 +427,7 @@ def varlen_with_paged_kv( kv_cache_dtype: str = "auto", k_scale: float = 1.0, v_scale: float = 1.0, + dynamic_causal: list[bool] | None = None, ) -> None: set_random_seed(0) num_seqs = len(seq_lens) @@ -212,9 +437,13 @@ def varlen_with_paged_kv( num_kv_heads = num_heads[1] assert num_query_heads % num_kv_heads == 0 max_kv_len = max(kv_lens) - window_size = (sliding_window - 1, 0) if sliding_window is not None else (-1, -1) scale = head_size**-0.5 token_num = sum(query_lens) + dynamic_causal_tensor = ( + torch.tensor(dynamic_causal, dtype=torch.bool) + if dynamic_causal is not None + else None + ) # for n heads the set of slopes is the geometric sequence that starts # 2^(-8/n) @@ -300,10 +529,11 @@ def varlen_with_paged_kv( seq_lens=kv_lens_tensor, dtype=dtype, query_start_loc=cu_query_lens, - causal=True, + causal=dynamic_causal is None, sliding_window_size=sliding_window if sliding_window is not None else -1, isa=isa, enable_kv_split=False, + dynamic_causal=dynamic_causal_tensor, ) out_without_split = torch.empty_like(query) @@ -315,13 +545,14 @@ def varlen_with_paged_kv( query_start_loc=cu_query_lens, seq_lens=kv_lens_tensor, scale=scale, - causal=True, + causal=dynamic_causal is None, alibi_slopes=alibi_slopes, - sliding_window=window_size, + sliding_window=sliding_window if sliding_window is not None else -1, block_table=block_tables, softcap=soft_cap if soft_cap is not None else 0, scheduler_metadata=metadata, s_aux=s_aux, + dynamic_causal=dynamic_causal_tensor, **fp8_kwargs, ) @@ -333,10 +564,11 @@ def varlen_with_paged_kv( seq_lens=kv_lens_tensor, dtype=dtype, query_start_loc=cu_query_lens, - causal=True, + causal=dynamic_causal is None, sliding_window_size=sliding_window if sliding_window is not None else -1, isa=isa, enable_kv_split=True, + dynamic_causal=dynamic_causal_tensor, ) out_with_split = torch.empty_like(query) @@ -348,13 +580,14 @@ def varlen_with_paged_kv( query_start_loc=cu_query_lens, seq_lens=kv_lens_tensor, scale=scale, - causal=True, + causal=dynamic_causal is None, alibi_slopes=alibi_slopes, - sliding_window=window_size, + sliding_window=sliding_window if sliding_window is not None else -1, block_table=block_tables, softcap=soft_cap if soft_cap is not None else 0, scheduler_metadata=metadata, s_aux=s_aux, + dynamic_causal=dynamic_causal_tensor, **fp8_kwargs, ) @@ -382,13 +615,14 @@ def varlen_with_paged_kv( query_start_loc=cu_query_lens, seq_lens=kv_lens_tensor, scale=scale, - causal=True, + causal=dynamic_causal is None, alibi_slopes=alibi_slopes, - sliding_window=window_size, + sliding_window=sliding_window if sliding_window is not None else -1, block_table=block_tables, softcap=soft_cap if soft_cap is not None else 0, scheduler_metadata=metadata, s_aux=s_aux, + dynamic_causal=dynamic_causal_tensor, ) atol = _FP8_ATOL[kv_cache_dtype] rtol = _FP8_RTOL @@ -405,6 +639,7 @@ def varlen_with_paged_kv( soft_cap=soft_cap, alibi_slopes=alibi_slopes, s_aux=s_aux, + dynamic_causal=dynamic_causal, ) atol, rtol = 1.5e-2, 1e-2 @@ -418,6 +653,71 @@ def varlen_with_paged_kv( ) +@pytest.mark.parametrize("seq_lens", ENCODER_SEQ_LENS) +@pytest.mark.parametrize("num_heads", NUM_HEADS) +@pytest.mark.parametrize("head_size", HEAD_SIZES) +@pytest.mark.parametrize( + "block_size", + [ + 128, + ], +) +@pytest.mark.parametrize("sliding_window", SLIDING_WINDOWS) +@pytest.mark.parametrize("dtype", QTYPES) +@pytest.mark.parametrize("isa", ["vec"]) +def test_varlen_encoder_attention_vec( + seq_lens: list[int], + num_heads: tuple[int, int], + head_size: int, + sliding_window: int | None, + dtype: torch.dtype, + block_size: int, + isa: str, +) -> None: + varlen_encoder_attention( + seq_lens=seq_lens, + num_heads=num_heads, + head_size=head_size, + sliding_window=sliding_window, + dtype=dtype, + block_size=block_size, + isa=isa, + ) + + +@pytest.mark.parametrize("seq_lens", ENCODER_SEQ_LENS) +@pytest.mark.parametrize("num_heads", NUM_HEADS) +@pytest.mark.parametrize("head_size", HEAD_SIZES) +@pytest.mark.parametrize( + "block_size", + [ + 128, + ], +) +@pytest.mark.parametrize("sliding_window", SLIDING_WINDOWS) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@pytest.mark.parametrize("isa", ["amx"]) +@pytest.mark.skipif(not torch.cpu._is_amx_tile_supported(), reason="no AMX support.") +def test_varlen_encoder_attention_amx( + seq_lens: list[int], + num_heads: tuple[int, int], + head_size: int, + sliding_window: int | None, + dtype: torch.dtype, + block_size: int, + isa: str, +) -> None: + varlen_encoder_attention( + seq_lens=seq_lens, + num_heads=num_heads, + head_size=head_size, + sliding_window=sliding_window, + dtype=dtype, + block_size=block_size, + isa=isa, + ) + + @pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8_e4m3", "fp8_e5m2"]) @pytest.mark.parametrize("seq_lens", SEQ_LENS) @pytest.mark.parametrize("num_heads", NUM_HEADS) @@ -755,3 +1055,58 @@ def test_varlen_with_paged_kv_sink( isa=isa, kv_cache_dtype=kv_cache_dtype, ) + + +@pytest.mark.parametrize( + "kv_cache_dtype", + [ + "auto", + ], +) +@pytest.mark.parametrize("seq_lens", SEQ_LENS) +@pytest.mark.parametrize("num_heads", NUM_HEADS) +@pytest.mark.parametrize( + "head_size", + [ + 128, + ], +) +@pytest.mark.parametrize("block_size", [96, 128]) +@pytest.mark.parametrize("sliding_window", SLIDING_WINDOWS) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@pytest.mark.parametrize("soft_cap", [None]) +@pytest.mark.parametrize("num_blocks", NUM_BLOCKS) +@pytest.mark.parametrize("use_alibi", [False]) +@pytest.mark.parametrize("use_sink", [False]) +@pytest.mark.parametrize("isa", ["amx"]) +@pytest.mark.skipif(not torch.cpu._is_amx_tile_supported(), reason="no AMX support.") +def test_varlen_with_paged_kv_dynamic_causal( + seq_lens: list[tuple[int, int]], + num_heads: tuple[int, int], + head_size: int, + sliding_window: int | None, + dtype: torch.dtype, + block_size: int, + soft_cap: float | None, + num_blocks: int, + use_alibi: bool, + use_sink: bool, + isa: str, + kv_cache_dtype: str, +) -> None: + dynamic_causal = [bool(i % 2) for i in range(len(seq_lens))] + varlen_with_paged_kv( + seq_lens=seq_lens, + num_heads=num_heads, + head_size=head_size, + sliding_window=sliding_window, + dtype=dtype, + block_size=block_size, + soft_cap=soft_cap, + num_blocks=num_blocks, + use_alibi=use_alibi, + use_sink=use_sink, + isa=isa, + kv_cache_dtype=kv_cache_dtype, + dynamic_causal=dynamic_causal, + ) diff --git a/tests/kernels/attention/test_cutlass_mla_decode.py b/tests/kernels/attention/test_cutlass_mla_decode.py index 33bd3605863a..c0e319a27ad2 100644 --- a/tests/kernels/attention/test_cutlass_mla_decode.py +++ b/tests/kernels/attention/test_cutlass_mla_decode.py @@ -212,3 +212,69 @@ def ref_mla(): print( f"{t:.3f} ms, {FLOPS / 10**9 / t:.0f} TFLOPS,", f"{bytes / 10**6 / t:.0f} GB/s" ) + + +@pytest.mark.skipif( + not current_platform.has_device_capability(100), + reason=CUTLASS_MLA_UNSUPPORTED_REASON, +) +@torch.inference_mode() +def test_cutlass_mla_decode_cross_layer_view(): + """The kernel must read the cache's page-dim stride instead of assuming + pages are packed back-to-back. A per-layer view into a cross-layer + (block-major) cache has stride(0) inflated by num_layers; outputs must + match a contiguous cache holding the same data exactly.""" + device = torch.device("cuda:0") + torch.set_default_dtype(torch.bfloat16) + torch.set_default_device(device) + torch.manual_seed(42) + + b, mean_sk, d, dv, block_size = 4, 512, 576, 512, 64 + num_layers, layer_idx = 3, 1 + scale = math.sqrt(d) ** (-1) + + num_pages = b * (mean_sk // block_size) + cache_seqlens = torch.full((b,), mean_sk, dtype=torch.int32) + block_table = torch.arange(num_pages, dtype=torch.int32).view( + b, mean_sk // block_size + ) + + kv_contig = torch.randn(num_pages, block_size, d) + # Neighbor layers hold random data so packed-pages addressing reads + # garbage rather than zeros. + kv_cross_layer = torch.randn(num_pages, num_layers, block_size, d) + kv_view = kv_cross_layer[:, layer_idx] + kv_view.copy_(kv_contig) + assert kv_view.stride(0) == num_layers * block_size * d + + q_nope = torch.randn(b, 128, dv) + q_pe = torch.randn(b, 128, d - dv) + sm_count = num_compute_units(device.index) + workspace_size = ops.sm100_cutlass_mla_get_workspace_size( + mean_sk, b, sm_count, num_kv_splits=1 + ) + workspace = torch.empty(workspace_size, dtype=torch.uint8) + + def run(cache): + out = torch.empty(b, 128, dv) + lse = torch.empty(b, 128, dtype=torch.float32) + ops.sm100_cutlass_mla_decode( + out, + lse, + q_nope, + q_pe, + cache, + cache_seqlens, + block_table, + workspace, + scale, + 1, + ) + return out, lse + + out_contig, lse_contig = run(kv_contig) + out_view, lse_view = run(kv_view) + + # Same data and same compute order; only addressing differs. + assert torch.equal(out_contig, out_view) + assert torch.equal(lse_contig, lse_view) diff --git a/tests/kernels/attention/test_flashinfer_mla_decode.py b/tests/kernels/attention/test_flashinfer_mla_decode.py index d183f67d3919..d1bd55eebedd 100644 --- a/tests/kernels/attention/test_flashinfer_mla_decode.py +++ b/tests/kernels/attention/test_flashinfer_mla_decode.py @@ -17,6 +17,43 @@ else: from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla +# Deepseek R1 MLA config. +NUM_HEADS = 128 +KV_LORA_RANK = 512 +QK_NOPE_HEAD_DIM = 128 +QK_ROPE_HEAD_DIM = 64 +QK_HEAD_DIM = KV_LORA_RANK + QK_ROPE_HEAD_DIM +SCALE = (QK_NOPE_HEAD_DIM + QK_ROPE_HEAD_DIM) ** -0.5 + + +def _make_decode_inputs(bs: int, block_size: int, dtype: torch.dtype): + """Build valid trtllm MLA decode inputs on the current CUDA device.""" + max_seq_len_cap = 1024 + seq_lens = [torch.randint(2, max_seq_len_cap, (1,)).item() for _ in range(bs)] + seq_lens[-1] = max_seq_len_cap + max_seq_len = max(seq_lens) + seq_lens_tensor = torch.tensor(seq_lens, dtype=torch.int32) + + # Generate block tables with random but unique block IDs + # From https://github.com/flashinfer-ai/flashinfer/pull/1222 + blocks_per_seq = (seq_lens_tensor + block_size - 1) // block_size + max_num_blocks_per_seq = max(blocks_per_seq.max().item(), 4) + total_blocks_needed = int(sum(blocks_per_seq)) + all_block_ids = torch.randperm(total_blocks_needed) + + block_tables = torch.zeros((bs, max_num_blocks_per_seq), dtype=torch.int32) + block_id = 0 + for i in range(bs): + num_blocks_needed = blocks_per_seq[i] + block_tables[i, :num_blocks_needed] = all_block_ids[ + block_id : block_id + num_blocks_needed + ] + block_id += num_blocks_needed + + kv_cache = torch.randn(block_tables.numel(), block_size, QK_HEAD_DIM).to(dtype) + q = torch.randn(bs, NUM_HEADS, QK_HEAD_DIM).to(dtype) + return q, kv_cache, block_tables, seq_lens_tensor, max_seq_len + def ref_mla( out: Tensor, # (bs, num_heads, v_head_dim) @@ -49,49 +86,12 @@ def test_flashinfer_mla_decode(dtype: torch.dtype, bs: int, block_size: int): torch.set_default_device("cuda") torch.manual_seed(42) - # Deepseek R1 config - num_heads = 128 - kv_lora_rank = 512 - qk_nope_head_dim = 128 - qk_rope_head_dim = 64 - qk_head_dim = kv_lora_rank + qk_rope_head_dim - scale = (qk_nope_head_dim + qk_rope_head_dim) ** -0.5 - - MAX_SEQ_LEN = 1024 - - seq_lens = [torch.randint(2, MAX_SEQ_LEN, (1,)).item() for _ in range(bs)] - seq_lens[-1] = MAX_SEQ_LEN - max_seq_len = max(seq_lens) - seq_lens_tensor = torch.tensor(seq_lens, dtype=torch.int32) - - # Generate block tables with random but unique block IDs - # From https://github.com/flashinfer-ai/flashinfer/pull/1222 - blocks_per_seq = (seq_lens_tensor + block_size - 1) // block_size - max_num_blocks_per_seq = max(blocks_per_seq.max().item(), 4) - total_blocks_needed = sum(blocks_per_seq) - # Get random unique IDs for all blocks - all_block_ids = torch.randperm(total_blocks_needed) - - block_id = 0 - block_tables = torch.zeros( - (bs, max_num_blocks_per_seq), - dtype=torch.int32, + q, kv_cache, block_tables, seq_lens_tensor, max_seq_len = _make_decode_inputs( + bs, block_size, dtype ) - # Populate block tables and track block assignments - block_id = 0 - for i in range(bs): - num_blocks_needed = blocks_per_seq[i] - block_tables[i, :num_blocks_needed] = all_block_ids[ - block_id : block_id + num_blocks_needed - ] - block_id += num_blocks_needed - - kv_cache = torch.randn(block_tables.numel(), block_size, qk_head_dim).to(dtype) - q = torch.randn(bs, num_heads, qk_head_dim).to(dtype) - - out_ref = q.new_zeros(bs, num_heads, kv_lora_rank) - ref_mla(out_ref, q, kv_cache, scale, block_tables, seq_lens_tensor) + out_ref = q.new_zeros(bs, NUM_HEADS, KV_LORA_RANK) + ref_mla(out_ref, q, kv_cache, SCALE, block_tables, seq_lens_tensor) workspace_buffer = torch.zeros( FLASHINFER_WORKSPACE_BUFFER_SIZE, @@ -107,13 +107,55 @@ def test_flashinfer_mla_decode(dtype: torch.dtype, bs: int, block_size: int): query=q, kv_cache=kv_cache.unsqueeze(1), workspace_buffer=workspace_buffer, - qk_nope_head_dim=qk_nope_head_dim, - kv_lora_rank=kv_lora_rank, - qk_rope_head_dim=qk_rope_head_dim, + qk_nope_head_dim=QK_NOPE_HEAD_DIM, + kv_lora_rank=KV_LORA_RANK, + qk_rope_head_dim=QK_ROPE_HEAD_DIM, block_tables=block_tables, seq_lens=seq_lens_tensor, max_seq_len=max_seq_len, - bmm1_scale=scale, + bmm1_scale=SCALE, ) out_ans = out_ans.squeeze(1) torch.testing.assert_close(out_ans, out_ref, atol=1e-2, rtol=1e-2) + + +def test_flashinfer_mla_decode_workspace_supports_autotune(): + """vLLM's FlashInfer MLA decode workspace must be int8 for autotuning. + + Model Runner V2's warmup autotunes ``trtllm_batch_decode_mla``, which makes + the FlashInfer autotuner enumerate the CuteDSL tactic. That tactic asserts + ``workspace_buffer.dtype == torch.int8``; the trtllm-gen path (used for + normal, non-autotuned inference) instead views the buffer as uint8, so a + uint8 workspace only fails once the autotuner tries CuteDSL. That regressed + every DeepSeek MLA test on Blackwell under V2 with + ``workspace_buffer must be torch.int8`` (vllm-project/vllm#46646). + """ + from flashinfer.autotuner import autotune + + from vllm.v1.attention.backends.mla.flashinfer_mla import _get_workspace_buffer + + torch.set_default_device("cuda") + torch.manual_seed(0) + + workspace_buffer = _get_workspace_buffer(return_lse=False) + assert workspace_buffer.dtype == torch.int8 + + q, kv_cache, block_tables, seq_lens_tensor, max_seq_len = _make_decode_inputs( + bs=1, block_size=64, dtype=torch.bfloat16 + ) + + # Under the autotuner the CuteDSL tactic is instantiated with our workspace; + # a uint8 buffer raises AssertionError here, an int8 buffer succeeds. + with torch.inference_mode(), autotune(True): + trtllm_batch_decode_with_kv_cache_mla( + query=q.unsqueeze(1), + kv_cache=kv_cache.unsqueeze(1), + workspace_buffer=workspace_buffer, + qk_nope_head_dim=QK_NOPE_HEAD_DIM, + kv_lora_rank=KV_LORA_RANK, + qk_rope_head_dim=QK_ROPE_HEAD_DIM, + block_tables=block_tables, + seq_lens=seq_lens_tensor, + max_seq_len=max_seq_len, + bmm1_scale=SCALE, + ) diff --git a/tests/kernels/attention/test_flashmla_sparse.py b/tests/kernels/attention/test_flashmla_sparse.py index 9e4e7c2ec9a6..010c44797665 100644 --- a/tests/kernels/attention/test_flashmla_sparse.py +++ b/tests/kernels/attention/test_flashmla_sparse.py @@ -29,8 +29,10 @@ def test_sparse_flashmla_metadata_smoke(): topk=topk, is_fp8_kvcache=True, ) - assert tile_md.dtype == torch.int32 - assert num_splits.dtype == torch.int32 + assert isinstance(tile_md, fm.FlashMLASchedMeta) + assert tile_md.tile_scheduler_metadata is None + assert tile_md.num_splits is None + assert num_splits is None def test_sparse_flashmla_decode_smoke(): @@ -116,7 +118,175 @@ def test_sparse_flashmla_prefill_smoke(): kv = torch.zeros((s_kv, h_kv, d_qk), dtype=torch.bfloat16, device=device) indices = torch.zeros((s_q, h_kv, topk), dtype=torch.int32, device=device) - out, max_logits, lse = fm.flash_mla_sparse_prefill(q, kv, indices, 1.0, d_v) + out, max_logits, lse = fm.flash_mla_sparse_fwd(q, kv, indices, 1.0, d_v) assert out.shape == (s_q, h_q, d_v) assert max_logits.shape == (s_q, h_q) assert lse.shape == (s_q, h_q) + + +def test_deepseek_v4_prefill_chunk_planning_expands_for_short_sequences(): + from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata + + metadata = DeepseekSparseSWAMetadata( + block_table=torch.empty(0, dtype=torch.int32), + slot_mapping=torch.empty(0, dtype=torch.int32), + block_size=64, + num_prefills=5, + prefill_seq_lens_cpu=torch.tensor([80, 96, 112, 128, 144], dtype=torch.int32), + prefill_query_lens_cpu=torch.tensor([4, 4, 4, 4, 4], dtype=torch.int32), + prefill_window_size=64, + prefill_max_model_len=1024, + prefill_max_num_batched_tokens=128, + ) + + chunk_plan = metadata.get_prefill_chunk_plan(compress_ratio=4, prefill_chunk_size=4) + + # the adaptive plan keeps all 5 in one chunk + assert chunk_plan == [(0, 5, 36, 103)] + + +def test_flashinfer_sparse_indices_cache(monkeypatch): + from vllm.models.deepseek_v4.nvidia import flashinfer_sparse as flashinfer_mod + from vllm.models.deepseek_v4.sparse_mla import DeepseekV4FlashMLAMetadata + from vllm.v1.attention.backends.mla.sparse_swa import DeepseekSparseSWAMetadata + + builder_calls = 0 + + def fake_build(*args, **kwargs): + nonlocal builder_calls + builder_calls += 1 + return ( + torch.tensor([[builder_calls]], dtype=torch.int32), + torch.tensor([builder_calls], dtype=torch.int32), + ) + + monkeypatch.setattr( + flashinfer_mod, "build_flashinfer_mixed_sparse_indices", fake_build + ) + + def make_attn(compress_ratio: int, topk_width: int): + attn = object.__new__(flashinfer_mod.DeepseekV4FlashInferMLAAttention) + attn.compress_ratio = compress_ratio + attn.window_size = 4 + attn.topk_indices_buffer = torch.tensor( + [[0, 1], [2, 3], [4, 5]], dtype=torch.int32 + )[:, :topk_width] + return attn + + def make_swa_metadata(): + return DeepseekSparseSWAMetadata( + block_table=torch.tensor([[0, 1], [2, 3]], dtype=torch.int32), + slot_mapping=torch.tensor([0, 1], dtype=torch.int64), + block_size=64, + seq_lens=torch.tensor([8, 10], dtype=torch.int32), + query_start_loc=torch.tensor([0, 1, 3], dtype=torch.int32), + query_start_loc_cpu=torch.tensor([0, 1, 3], dtype=torch.int32), + token_to_req_indices=torch.tensor([0, 1, 1], dtype=torch.int32), + decode_swa_indices=torch.tensor([[5, 6, -1, -1]], dtype=torch.int32), + decode_swa_lens=torch.tensor([2], dtype=torch.int32), + is_valid_token=torch.tensor([True], dtype=torch.bool), + num_decodes=1, + num_prefills=1, + num_decode_tokens=1, + num_prefill_tokens=2, + ) + + def make_flashmla_metadata(): + return DeepseekV4FlashMLAMetadata( + num_reqs=2, + max_query_len=2, + max_seq_len=10, + num_actual_tokens=3, + query_start_loc=torch.tensor([0, 1, 3], dtype=torch.int32), + slot_mapping=torch.tensor([0, 1, 2], dtype=torch.int64), + block_table=torch.tensor([[0, 1], [2, 3]], dtype=torch.int32), + req_id_per_token=torch.tensor([0, 1, 1], dtype=torch.int32), + block_size=256, + topk_tokens=2, + c128a_global_decode_topk_indices=torch.tensor( + [[[9, 10]]], dtype=torch.int32 + ), + c128a_decode_topk_lens=torch.tensor([2], dtype=torch.int32), + c128a_prefill_topk_indices=torch.tensor( + [[0, 1], [1, 2]], dtype=torch.int32 + ), + ) + + swa_attn = make_attn(1, 0) + swa_metadata = make_swa_metadata() + _, _, sparse_indices_first, sparse_lens_first = ( + swa_attn._build_sparse_index_metadata( + kv_cache=None, + swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16), + swa_metadata=swa_metadata, + attn_metadata=None, + swa_only=True, + ) + ) + _, _, sparse_indices_second, sparse_lens_second = ( + swa_attn._build_sparse_index_metadata( + kv_cache=None, + swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16), + swa_metadata=swa_metadata, + attn_metadata=None, + swa_only=True, + ) + ) + assert builder_calls == 1 + assert sparse_indices_first is sparse_indices_second + assert sparse_lens_first is sparse_lens_second + + c128a_attn = make_attn(128, 2) + c128a_metadata = make_swa_metadata() + c128a_flashmla_md = make_flashmla_metadata() + _, _, sparse_indices_first, sparse_lens_first = ( + c128a_attn._build_sparse_index_metadata( + kv_cache=torch.empty((1, 2, 512), dtype=torch.bfloat16), + swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16), + swa_metadata=c128a_metadata, + attn_metadata=c128a_flashmla_md, + swa_only=False, + ) + ) + _, _, sparse_indices_second, sparse_lens_second = ( + c128a_attn._build_sparse_index_metadata( + kv_cache=torch.empty((1, 2, 512), dtype=torch.bfloat16), + swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16), + swa_metadata=c128a_metadata, + attn_metadata=c128a_flashmla_md, + swa_only=False, + ) + ) + + assert builder_calls == 2 + assert sparse_indices_first is sparse_indices_second + assert sparse_lens_first is sparse_lens_second + + c4a_attn = make_attn(4, 2) + c4a_metadata = make_swa_metadata() + c4a_flashmla_md = make_flashmla_metadata() + c4a_flashmla_md.c128a_global_decode_topk_indices = None + c4a_flashmla_md.c128a_decode_topk_lens = None + c4a_flashmla_md.c128a_prefill_topk_indices = None + _, _, sparse_indices_third, sparse_lens_third = ( + c4a_attn._build_sparse_index_metadata( + kv_cache=torch.empty((1, 2, 512), dtype=torch.bfloat16), + swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16), + swa_metadata=c4a_metadata, + attn_metadata=c4a_flashmla_md, + swa_only=False, + ) + ) + _, _, sparse_indices_fourth, sparse_lens_fourth = ( + c4a_attn._build_sparse_index_metadata( + kv_cache=torch.empty((1, 2, 512), dtype=torch.bfloat16), + swa_k_cache=torch.empty((1, 64, 512), dtype=torch.bfloat16), + swa_metadata=c4a_metadata, + attn_metadata=c4a_flashmla_md, + swa_only=False, + ) + ) + + assert builder_calls == 4 + assert sparse_indices_third is not sparse_indices_fourth + assert sparse_lens_third is not sparse_lens_fourth diff --git a/tests/kernels/attention/test_lightning_attn.py b/tests/kernels/attention/test_lightning_attn.py index 46757cc10b6a..61e13166808c 100644 --- a/tests/kernels/attention/test_lightning_attn.py +++ b/tests/kernels/attention/test_lightning_attn.py @@ -5,8 +5,16 @@ import torch from vllm.model_executor.layers.lightning_attn import linear_decode_forward_triton +from vllm.platforms import current_platform from vllm.utils.torch_utils import set_random_seed +DEVICE = current_platform.device_type + +pytestmark = pytest.mark.skipif( + not (current_platform.is_cuda_alike() or current_platform.is_xpu()), + reason="Lightning attention Triton kernels require CUDA/ROCm or XPU.", +) + NUM_HEADS = [4, 8] HEAD_SIZES = [64] BATCH_SIZES = [1, 2] @@ -121,7 +129,7 @@ def test_linear_decode_forward_triton( head_size: int, dtype: torch.dtype, ): - torch.set_default_device("cuda") + torch.set_default_device(DEVICE) set_random_seed(42) base = 0.01 q = base * torch.randn(batch_size, num_heads, 1, head_size, dtype=dtype) @@ -129,16 +137,16 @@ def test_linear_decode_forward_triton( v = base * torch.randn(batch_size, num_heads, 1, head_size, dtype=dtype) kv_caches = base * torch.randn( - batch_size, num_heads, head_size, head_size, dtype=dtype, device="cuda" + batch_size, num_heads, head_size, head_size, dtype=dtype, device=DEVICE ) kv_caches_copy = kv_caches.clone() - slope_rate = torch.zeros(num_heads, device="cuda") + slope_rate = torch.zeros(num_heads, device=DEVICE) for h in range(num_heads): slope_rate[h] = 0.1 * (h + 1) - slot_idx = torch.arange(batch_size, device="cuda") + slot_idx = torch.arange(batch_size, device=DEVICE) triton_output = linear_decode_forward_triton( q, k, v, kv_caches, slope_rate, slot_idx @@ -162,7 +170,7 @@ def test_linear_decode_forward_triton_with_padding( head_size: int, dtype: torch.dtype, ): - torch.set_default_device("cuda") + torch.set_default_device(DEVICE) set_random_seed(42) batch_size = 4 @@ -172,16 +180,16 @@ def test_linear_decode_forward_triton_with_padding( v = base * torch.randn(batch_size, num_heads, 1, head_size, dtype=dtype) kv_caches = base * torch.randn( - batch_size, num_heads, head_size, head_size, dtype=dtype, device="cuda" + batch_size, num_heads, head_size, head_size, dtype=dtype, device=DEVICE ) kv_caches_copy = kv_caches.clone() - slope_rate = torch.zeros(num_heads, device="cuda") + slope_rate = torch.zeros(num_heads, device=DEVICE) for h in range(num_heads): slope_rate[h] = 0.1 * (h + 1) - slot_idx = torch.tensor([0, 1, -1, 2], device="cuda") + slot_idx = torch.tensor([0, 1, -1, 2], device=DEVICE) triton_output = linear_decode_forward_triton( q, k, v, kv_caches, slope_rate, slot_idx @@ -224,7 +232,7 @@ def test_lightning_attention_reference( seq_len: int, dtype: torch.dtype, ): - torch.set_default_device("cuda") + torch.set_default_device(DEVICE) set_random_seed(42) base = 0.01 @@ -232,12 +240,12 @@ def test_lightning_attention_reference( k = base * torch.randn(batch_size, num_heads, seq_len, head_size, dtype=dtype) v = base * torch.randn(batch_size, num_heads, seq_len, head_size, dtype=dtype) - ed = torch.zeros(num_heads, device="cuda") + ed = torch.zeros(num_heads, device=DEVICE) for h in range(num_heads): ed[h] = 0.1 * (h + 1) kv_history = base * torch.randn( - batch_size, num_heads, head_size, head_size, dtype=dtype, device="cuda" + batch_size, num_heads, head_size, head_size, dtype=dtype, device=DEVICE ) kv_history_clone = kv_history.clone() diff --git a/tests/kernels/attention/test_minimax_m3.py b/tests/kernels/attention/test_minimax_m3.py new file mode 100644 index 000000000000..01405a0e5d12 --- /dev/null +++ b/tests/kernels/attention/test_minimax_m3.py @@ -0,0 +1,1226 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Correctness tests for MiniMax M3 sparse prefill attention kernels.""" + +import pytest +import torch + +from vllm import _custom_ops as ops +from vllm.models.minimax_m3.common.indexer import ( + MiniMaxM3IndexerBackend, +) +from vllm.models.minimax_m3.common.ops.index_topk import ( + minimax_m3_index_decode, + minimax_m3_index_score, + minimax_m3_index_topk, +) +from vllm.models.minimax_m3.common.ops.sparse_attn import ( + _FP8_DTYPES, + minimax_m3_sparse_attn, + minimax_m3_sparse_attn_decode, +) +from vllm.models.minimax_m3.common.sparse_attention import ( + MiniMaxM3SparseBackend, + MiniMaxM3SparseTritonImpl, +) +from vllm.platforms import current_platform +from vllm.v1.attention.backends.utils import set_kv_cache_layout +from vllm.v1.kv_cache_interface import FullAttentionSpec, MLAAttentionSpec +from vllm.v1.worker.gpu.attn_utils import _reshape_kv_cache +from vllm.v1.worker.utils import AttentionGroup + +if not (current_platform.is_cuda() or current_platform.is_rocm()): + pytest.skip( + "MiniMax M3 attention kernels require CUDA or ROCm.", + allow_module_level=True, + ) + + +@pytest.fixture +def kv_layout(request): + """Set the global KV cache layout for one test and restore it after.""" + set_kv_cache_layout(request.param) + try: + yield request.param + finally: + set_kv_cache_layout(None) + + +def _stride_order_for(backend: type[MiniMaxM3SparseBackend], ndim: int) -> tuple: + """Mirror the allocator's stride-order resolution (identity fallback).""" + try: + stride_order = backend.get_kv_cache_stride_order() + assert len(stride_order) == ndim + except (AttributeError, NotImplementedError): + stride_order = tuple(range(ndim)) + return stride_order + + +def _allocate_main_kv_via_contract( + num_pages: int, device: torch.device | str = "cuda" +) -> torch.Tensor: + """Build the main KV cache exactly as the production allocator does for the + currently active layout: allocate the physical (permuted) tensor, then + expose the inverse-permuted logical-NHD view the backend sees.""" + logical_shape = MiniMaxM3SparseBackend.get_kv_cache_shape( + num_pages, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM + ) + stride_order = _stride_order_for(MiniMaxM3SparseBackend, len(logical_shape)) + physical_shape = tuple(logical_shape[i] for i in stride_order) + inv_order = [stride_order.index(i) for i in range(len(stride_order))] + raw = torch.randn(physical_shape, device=device, dtype=DTYPE) + return raw.permute(*inv_order) + + +NUM_Q_HEADS = 32 +NUM_KV_HEADS = 2 +HEAD_DIM = 128 +BLOCK_SIZE = 128 +DTYPE = torch.bfloat16 +SM_SCALE = HEAD_DIM**-0.5 +TOPK = 16 + + +@pytest.mark.parametrize( + ("kv_cache_dtype", "expected_dtype"), + [ + ("fp8", current_platform.fp8_dtype()), + ("fp8_e4m3", current_platform.fp8_dtype()), + ( + "fp8_e5m2", + torch.float8_e5m2fnuz + if current_platform.is_fp8_fnuz() + else torch.float8_e5m2, + ), + ], +) +def test_sparse_impl_uses_platform_fp8_dtype( + kv_cache_dtype: str, + expected_dtype: torch.dtype, +): + impl = MiniMaxM3SparseTritonImpl( + num_heads=NUM_Q_HEADS, + head_size=HEAD_DIM, + scale=SM_SCALE, + num_kv_heads=NUM_KV_HEADS, + kv_cache_dtype=kv_cache_dtype, + topk_blocks=TOPK, + sparse_block_size=BLOCK_SIZE, + ) + assert impl.kv_cache_fp8_dtype == expected_dtype + + +@pytest.mark.parametrize( + "dtype", + [ + torch.float8_e4m3fn, + torch.float8_e4m3fnuz, + torch.float8_e5m2, + torch.float8_e5m2fnuz, + ], +) +def test_sparse_kernels_recognize_fp8_dtypes(dtype: torch.dtype): + assert dtype in _FP8_DTYPES + + +# Index top-k kernels. +def _reference_index_topk( + idx_q: torch.Tensor, + index_kv_cache: torch.Tensor, + block_table: torch.Tensor, + q_lens: torch.Tensor, + seq_lens: torch.Tensor, + prefix_lens: torch.Tensor, + topk: int, + init_blocks: int, + local_blocks: int, + sm_scale: float = 1.0, +) -> torch.Tensor: + total_q, num_idx_heads, _ = idx_q.shape + out = torch.full( + (num_idx_heads, total_q, topk), -1, device=idx_q.device, dtype=torch.int32 + ) + + q_start = 0 + for req_id, (q_len, seq_len, prefix_len) in enumerate( + zip(q_lens.tolist(), seq_lens.tolist(), prefix_lens.tolist()) + ): + q_end = q_start + q_len + q = idx_q[q_start:q_end] + num_blocks = (seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE + pages = block_table[req_id, :num_blocks] + k = index_kv_cache[pages].reshape(num_blocks * BLOCK_SIZE, -1) + score = sm_scale * torch.einsum("qhd,kd->hqk", q.float(), k.float()) + + q_pos = prefix_len + torch.arange(q_len, device=idx_q.device) + k_pos = torch.arange(k.shape[0], device=idx_q.device) + score.masked_fill_(k_pos[None, :] > q_pos[:, None], -float("inf")) + score = score.reshape(num_idx_heads, q_len, num_blocks, BLOCK_SIZE) + score_tensor = score.max(dim=3).values + + valid_blocks = (q_pos + BLOCK_SIZE) // BLOCK_SIZE + for local_q, num_valid_blocks in enumerate(valid_blocks.tolist()): + end = min(init_blocks, num_valid_blocks) + score_tensor[:, local_q, :end] = 1e30 + start = max(0, num_valid_blocks - local_blocks) + score_tensor[:, local_q, start:num_valid_blocks] = 1e29 + + k = min(topk, num_valid_blocks) + topk_idx = score_tensor[:, local_q].topk(k, dim=1).indices + out[:, q_start + local_q, :k] = topk_idx + q_start = q_end + + return out + + +def _assert_topk_indices_equal_unordered( + actual: torch.Tensor, + expected: torch.Tensor, +) -> None: + """Compare selected sparse blocks without requiring a deterministic order.""" + assert actual.shape == expected.shape + actual_flat = actual.cpu().reshape(-1, actual.shape[-1]).tolist() + expected_flat = expected.cpu().reshape(-1, expected.shape[-1]).tolist() + for actual_row, expected_row in zip(actual_flat, expected_flat): + assert set(actual_row) == set(expected_row) + + +def test_prefill_index_topk_correctness(): + topk = 6 + init_blocks = 0 + local_blocks = 1 + num_idx_heads = 2 + head_dim = 16 + q_lens = torch.tensor((4, 3), device="cuda", dtype=torch.int32) + prefix_lens = torch.tensor((0, 1024), device="cuda", dtype=torch.int32) + seq_lens = prefix_lens + q_lens + batch = q_lens.numel() + max_seq_len = seq_lens.max().item() + max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE + num_pages = batch * max_blocks + + cu_seqlens = torch.zeros(batch + 1, device="cuda", dtype=torch.int32) + cu_seqlens[1:] = q_lens.cumsum(0) + block_table = torch.randperm(num_pages, device="cuda", dtype=torch.int32).reshape( + batch, max_blocks + ) + idx_q = torch.ones(q_lens.sum().item(), num_idx_heads, head_dim, device="cuda") + index_kv_cache = torch.empty(num_pages, BLOCK_SIZE, head_dim, device="cuda") + for req_id in range(batch): + for block_id in range(max_blocks): + page = block_table[req_id, block_id] + index_kv_cache[page].fill_(block_id + 1) + + score = minimax_m3_index_score( + idx_q, + index_kv_cache, + block_table, + cu_seqlens, + seq_lens, + prefix_lens, + max_query_len=q_lens.max().item(), + max_seq_len=max_seq_len, + num_kv_heads=num_idx_heads, + ) + actual = minimax_m3_index_topk( + score, + cu_seqlens, + prefix_lens, + max_query_len=q_lens.max().item(), + topk=topk, + init_blocks=init_blocks, + local_blocks=local_blocks, + ) + expected = _reference_index_topk( + idx_q, + index_kv_cache, + block_table, + q_lens, + seq_lens, + prefix_lens, + topk, + init_blocks, + local_blocks, + ) + _assert_topk_indices_equal_unordered(actual, expected) + + +# MSA indexer (SM100): fmha_sm100 OnlyScore for the per-block scores, then the +# Triton minimax_m3_index_topk for selection (no sparse_topk_select). Uses a +# deterministic construction (idx_q == 1, distinct e4m3-exact per-block values) +# so scores are strictly monotonic in the block id -> exact top-k agreement. +def _fmha_indexer_topk( + idx_q: torch.Tensor, # [total_q, H, 128] bf16/e4m3 + index_cache: torch.Tensor, # [num_pages, 128, 128] bf16/e4m3 + block_table: torch.Tensor, + q_lens: torch.Tensor, + seq_lens: torch.Tensor, + prefix_lens: torch.Tensor, + sm_scale: float, + topk: int, +) -> torch.Tensor: + """Replicate MiniMaxM3IndexerMSAImpl's score path (single decode/prefill side).""" + from vllm.third_party.fmha_sm100.api import _fmha_sm100, _fmha_sm100_plan + + num_idx_heads, head_dim = idx_q.shape[1], idx_q.shape[2] + nvp = [(s + 127) // 128 for s in seq_lens.tolist()] + kv_indices = torch.cat([block_table[r, : nvp[r]] for r in range(len(nvp))]).to( + torch.int32 + ) + + qo = q_lens.cpu().to(torch.int32) + kv = seq_lens.cpu().to(torch.int32) + plan = _fmha_sm100_plan( + qo, + kv, + num_idx_heads, + num_kv_heads=1, + qo_offset=kv - qo, + page_size=128, + output_maxscore=True, + causal=True, + num_kv_splits=1, + ) + k_pages = index_cache.view(index_cache.shape[0], 1, 128, head_dim) + _, max_score = _fmha_sm100( + idx_q, + k_pages, + k_pages, + plan, + kv_indices=kv_indices, + output_o=False, + output_maxscore=True, + sm_scale=sm_scale, + ) + + batch = q_lens.numel() + cu = torch.zeros(batch + 1, dtype=torch.int32, device=idx_q.device) + cu[1:] = q_lens.to(torch.int32).cumsum(0) + # max_score [H, k_tiles, total_q] -> transpose to [H, total_q, k_tiles]. + return minimax_m3_index_topk( + max_score.transpose(1, 2), + cu, + prefix_lens.to(torch.int32), + int(q_lens.max()), + topk, + 0, # init_blocks + 0, # local_blocks + ) + + +# e4m3-exact, strictly-increasing per-block values: with idx_q == 1 (also exact) +# the per-block scores are exact and distinct in BOTH bf16 and e4m3, so the fp8 +# score path selects the same top-k as the reference (no quantization ties). +_E4M3_EXACT_VALUES = [ + *range(1, 17), # 1..16 (step 1) + *range(18, 33, 2), # 18..32 (step 2) + *range(36, 65, 4), # 36..64 (step 4) + *range(72, 129, 8), # 72..128 (step 8) +] + + +@pytest.mark.skipif( + not current_platform.is_device_capability_family(100), + reason="fmha_sm100 indexer requires SM100 (Blackwell).", +) +@pytest.mark.parametrize("index_dtype", [torch.bfloat16, torch.float8_e4m3fn]) +@pytest.mark.parametrize( + ("q_lens", "prefix_lens"), + [ + ((4, 3), (2048, 2560)), # prefill: every token sees >= 16 causal blocks + ((1, 1, 1), (2048, 3000, 4096)), # decode: one query token per request + ], +) +def test_fmha_sm100_indexer_matches_reference(q_lens, prefix_lens, index_dtype): + torch.manual_seed(0) + num_idx_heads, head_dim = 4, HEAD_DIM + device = "cuda" + + q_lens_t = torch.tensor(q_lens, device=device, dtype=torch.int32) + prefix_lens_t = torch.tensor(prefix_lens, device=device, dtype=torch.int32) + seq_lens = prefix_lens_t + q_lens_t + batch = len(q_lens) + max_blocks = (int(seq_lens.max()) + BLOCK_SIZE - 1) // BLOCK_SIZE + assert max_blocks <= len(_E4M3_EXACT_VALUES) + num_pages = batch * max_blocks + block_table = torch.randperm(num_pages, device=device, dtype=torch.int32).reshape( + batch, max_blocks + ) + + idx_q = torch.ones( + int(q_lens_t.sum()), num_idx_heads, head_dim, device=device, dtype=index_dtype + ) + index_cache = torch.empty( + num_pages, BLOCK_SIZE, head_dim, device=device, dtype=index_dtype + ) + for r in range(batch): + for b in range(max_blocks): + index_cache[block_table[r, b]] = float(_E4M3_EXACT_VALUES[b]) + + sm_scale = head_dim**-0.5 + actual = _fmha_indexer_topk( + idx_q, + index_cache, + block_table, + q_lens_t, + seq_lens, + prefix_lens_t, + sm_scale, + TOPK, + ) + expected = _reference_index_topk( + idx_q, + index_cache, + block_table, + q_lens_t, + seq_lens, + prefix_lens_t, + TOPK, + init_blocks=0, + local_blocks=0, + sm_scale=sm_scale, + ) + _assert_topk_indices_equal_unordered(actual, expected) + + +# Full impl-level parity: drive both MiniMaxM3IndexerMSAImpl (fmha_sm100 score + +# Triton top-k) and MiniMaxM3IndexerTritonImpl through their real metadata +# builders on the SAME CommonAttentionMetadata + index cache, and assert the +# selected blocks agree. This exercises all the metadata the impl/kernels consume +# (decode/prefill split, cu_seqlens_q rebasing, prefix_lens, kv_indices gather, +# decode_pages split) -- a metadata bug on either side shifts the causal window +# or the block->page mapping and breaks the comparison. +@pytest.mark.skipif( + not current_platform.is_device_capability_family(100), + reason="fmha_sm100 indexer requires SM100 (Blackwell).", +) +@pytest.mark.parametrize("topk", [8, 16]) +def test_msa_indexer_impl_matches_triton(topk, monkeypatch): + import vllm.models.minimax_m3.common.indexer as indexer_mod + from tests.v1.attention.utils import ( + BatchSpec, + create_common_attn_metadata, + create_vllm_config, + ) + from vllm.config import set_current_vllm_config + from vllm.forward_context import set_forward_context + from vllm.models.minimax_m3.common.indexer import ( + MiniMaxM3IndexerTritonImpl, + MiniMaxM3IndexerTritonMetadataBuilder, + ) + from vllm.models.minimax_m3.nvidia.indexer_msa import ( + MiniMaxM3IndexerMSAImpl, + MiniMaxM3IndexerMSAMetadataBuilder, + ) + + torch.manual_seed(0) + device = torch.device("cuda") + num_idx_heads, head_dim = 4, HEAD_DIM + # TP=1: avoid requiring an initialized distributed group in a unit test. + monkeypatch.setattr(indexer_mod, "get_tensor_model_parallel_world_size", lambda: 1) + + vllm_config = create_vllm_config( + block_size=BLOCK_SIZE, max_model_len=8192, max_num_batched_tokens=8192 + ) + vllm_config.model_config.hf_config.sparse_attention_config = { + "sparse_num_index_heads": num_idx_heads + } + + # Decode-first mixed batch: 2 decode reqs (q_len 1) then 2 prefill reqs. Long + # prefixes so every token sees > TOPK causal blocks (non-trivial selection). + batch = BatchSpec(seq_lens=[2305, 2561, 2624, 2720], query_lens=[1, 1, 64, 96]) + common = create_common_attn_metadata( + batch, BLOCK_SIZE, device, arange_block_indices=True + ) + num_tokens = batch.compute_num_tokens() + + # Deterministic index cache: distinct, monotonic per-logical-block values so + # the top-k is unambiguous (both kernels pick the same blocks, no fp ties). + block_table = common.block_table_tensor + num_pages = int(block_table.max().item()) + 1 + index_cache = torch.zeros( + num_pages, BLOCK_SIZE, head_dim, device=device, dtype=DTYPE + ) + for r, seq_len in enumerate(batch.seq_lens): + for b in range((seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE): + index_cache[block_table[r, b]] = float(b + 1) + index_q = torch.ones( + num_tokens, num_idx_heads * head_dim, device=device, dtype=DTYPE + ) + + spec = MLAAttentionSpec( + block_size=BLOCK_SIZE, num_kv_heads=1, head_size=head_dim, dtype=DTYPE + ) + impl_kwargs = dict( + num_kv_heads=num_idx_heads, + scale=head_dim**-0.5, + topk_blocks=topk, + sparse_block_size=BLOCK_SIZE, + num_index_heads=num_idx_heads, + index_head_dim=head_dim, + init_blocks=0, + local_blocks=0, + ) + + with set_current_vllm_config(vllm_config): + msa_impl = MiniMaxM3IndexerMSAImpl(prefix="idx_msa", **impl_kwargs) + triton_impl = MiniMaxM3IndexerTritonImpl(prefix="idx_triton", **impl_kwargs) + msa_builder = MiniMaxM3IndexerMSAMetadataBuilder( + spec, [msa_impl.index_cache.prefix], vllm_config, device + ) + triton_builder = MiniMaxM3IndexerTritonMetadataBuilder( + spec, [triton_impl.index_cache.prefix], vllm_config, device + ) + + # Both impls score against the same index keys. + msa_impl.index_cache.kv_cache = index_cache + triton_impl.index_cache.kv_cache = index_cache + + # Exercise the shared persistent top-k buffer for BOTH impls: each must write + # decode ([:, :nd]) and prefill ([:, nd:]) into its buffer and return views. + # Separate buffers so the two forwards don't clobber each other. + nd = sum(q for q in batch.query_lens if q <= 1) + msa_impl.topk_indices_buffer = torch.full( + (num_idx_heads, num_tokens, topk), -2, dtype=torch.int32, device=device + ) + triton_impl.topk_indices_buffer = torch.full( + (num_idx_heads, num_tokens, topk), -2, dtype=torch.int32, device=device + ) + + attn_metadata = { + msa_impl.index_cache.prefix: msa_builder.build(0, common), + triton_impl.index_cache.prefix: triton_builder.build(0, common), + } + with set_forward_context(attn_metadata, vllm_config): + msa_decode, msa_prefill = msa_impl(index_q) + tri_decode, tri_prefill = triton_impl(index_q) + + assert msa_decode is not None and tri_decode is not None + assert msa_prefill is not None and tri_prefill is not None + _assert_topk_indices_equal_unordered(msa_decode, tri_decode) + _assert_topk_indices_equal_unordered(msa_prefill, tri_prefill) + # decode/prefill outputs are views into each impl's persistent buffer. + for impl, dec, pre in ( + (msa_impl, msa_decode, msa_prefill), + (triton_impl, tri_decode, tri_prefill), + ): + buf = impl.topk_indices_buffer + assert dec.data_ptr() == buf[:, :nd, :].data_ptr() + assert pre.data_ptr() == buf[:, nd:, :].data_ptr() + + +@pytest.mark.parametrize( + ("decode_query_len", "max_decode_query_len"), + [ + (1, 1), + (1, 4), + (4, 4), + ], +) +@pytest.mark.parametrize("num_padded_reqs", [0, 2]) +def test_decode_index_topk_correctness( + decode_query_len: int, + max_decode_query_len: int, + num_padded_reqs: int, +): + topk = 6 + init_blocks = 0 + local_blocks = 1 + num_idx_heads = 2 + head_dim = 16 + active_seq_lens = torch.tensor((7, 129, 1025), device="cuda", dtype=torch.int32) + q_lens = torch.full_like(active_seq_lens, decode_query_len) + prefix_lens = active_seq_lens - decode_query_len + active_batch = active_seq_lens.numel() + batch = active_batch + num_padded_reqs + seq_lens = torch.cat( + [ + active_seq_lens, + torch.zeros(num_padded_reqs, device="cuda", dtype=torch.int32), + ] + ) + max_seq_len = active_seq_lens.max().item() + max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE + num_pages = active_batch * max_blocks + + active_block_table = torch.randperm( + num_pages, device="cuda", dtype=torch.int32 + ).reshape(active_batch, max_blocks) + block_table = torch.zeros(batch, max_blocks, device="cuda", dtype=torch.int32) + block_table[:active_batch] = active_block_table + idx_q = torch.randn( + batch * decode_query_len, num_idx_heads, head_dim, device="cuda" + ) + index_kv_cache = torch.randn(num_pages, BLOCK_SIZE, head_dim, device="cuda") + + actual = minimax_m3_index_decode( + idx_q, + index_kv_cache, + block_table, + seq_lens, + max_seq_len=max_seq_len, + topk=topk, + init_blocks=init_blocks, + local_blocks=local_blocks, + num_kv_heads=num_idx_heads, + decode_query_len=decode_query_len, + max_decode_query_len=max_decode_query_len, + ) + expected = torch.full_like(actual, -1) + active_tokens = active_batch * decode_query_len + expected[:, :active_tokens] = _reference_index_topk( + idx_q[:active_tokens], + index_kv_cache, + block_table[:active_batch], + q_lens, + active_seq_lens, + prefix_lens, + topk, + init_blocks, + local_blocks, + ) + _assert_topk_indices_equal_unordered(actual, expected) + + +@pytest.mark.skipif( + not current_platform.is_device_capability_family(100), + reason="fp8 e4m3 indexer cache is the SM100 (MSA) path.", +) +@pytest.mark.parametrize("num_idx_heads", [1, 4]) +def test_decode_index_topk_fp8(num_idx_heads: int): + """The fp8 (e4m3) indexer cache feeds the Triton decode kernel on the MSA + path. The kernel must score in fp32 (no scaling) so its top-k matches a + reference computed from the dequantized fp8 values.""" + torch.manual_seed(0) + topk, init_blocks, local_blocks, head_dim = 8, 0, 1, 128 + decode_query_len = 1 + active_seq_lens = torch.tensor((129, 1025, 4097), device="cuda", dtype=torch.int32) + q_lens = torch.full_like(active_seq_lens, decode_query_len) + prefix_lens = active_seq_lens - decode_query_len + batch = active_seq_lens.numel() + max_seq_len = int(active_seq_lens.max()) + max_blocks = (max_seq_len + BLOCK_SIZE - 1) // BLOCK_SIZE + num_pages = batch * max_blocks + block_table = torch.randperm(num_pages, device="cuda", dtype=torch.int32).reshape( + batch, max_blocks + ) + idx_q = torch.randn( + batch * decode_query_len, num_idx_heads, head_dim, device="cuda" + ).to(torch.float8_e4m3fn) + index_kv_cache = torch.randn(num_pages, BLOCK_SIZE, head_dim, device="cuda").to( + torch.float8_e4m3fn + ) + + actual = minimax_m3_index_decode( + idx_q, + index_kv_cache, + block_table, + active_seq_lens, + max_seq_len=max_seq_len, + topk=topk, + init_blocks=init_blocks, + local_blocks=local_blocks, + num_kv_heads=num_idx_heads, + decode_query_len=decode_query_len, + max_decode_query_len=decode_query_len, + ) + # Reference from the DEQUANTIZED fp8 values (the kernel computes the fp8 QK + # in fp32 with no scaling, so it must match an unscaled fp32 matmul of the + # same e4m3 values). + expected = _reference_index_topk( + idx_q.float(), + index_kv_cache.float(), + block_table, + q_lens, + active_seq_lens, + prefix_lens, + topk, + init_blocks, + local_blocks, + ) + _assert_topk_indices_equal_unordered(actual, expected) + + +# Sparse attention kernels. +def _reference_sparse_attn( + q: torch.Tensor, + kv_cache: torch.Tensor, + topk_idx: torch.Tensor, + block_table: torch.Tensor, + q_lens: torch.Tensor, + seq_lens: torch.Tensor, + prefix_lens: torch.Tensor, +) -> torch.Tensor: + out = torch.empty_like(q, dtype=torch.float32) + gqa_group_size = NUM_Q_HEADS // NUM_KV_HEADS + q_start = 0 + for req_id, (q_len, seq_len, prefix_len) in enumerate( + zip(q_lens.tolist(), seq_lens.tolist(), prefix_lens.tolist()) + ): + q_end = q_start + q_len + q_req = q[q_start:q_end] + positions = torch.arange(seq_len, device="cuda") + pages = block_table[req_id, positions // BLOCK_SIZE] + rows = positions % BLOCK_SIZE + k_req = kv_cache[pages, 0, rows] + v_req = kv_cache[pages, 1, rows].float() + + q_pos = prefix_len + torch.arange(q_len, device="cuda") + key_blocks = positions // BLOCK_SIZE + causal_mask = positions.unsqueeze(0) <= q_pos.unsqueeze(1) + + for kv_head in range(NUM_KV_HEADS): + selected = topk_idx[kv_head, q_start:q_end] + selected_mask = (key_blocks[None, :, None] == selected[:, None, :]).any(-1) + mask = causal_mask & selected_mask + head_start = kv_head * gqa_group_size + head_end = head_start + gqa_group_size + + q_heads = q_req[:, head_start:head_end].transpose(0, 1) + k_head = k_req[:, kv_head].T.expand(gqa_group_size, -1, -1) + scores = torch.bmm(q_heads, k_head, out_dtype=torch.float32) + scores = scores.transpose(0, 1) * SM_SCALE + probs = torch.softmax( + scores.masked_fill(~mask[:, None, :], -float("inf")), -1 + ) + out[q_start:q_end, head_start:head_end] = torch.einsum( + "qhk,kd->qhd", probs, v_req[:, kv_head] + ) + q_start += q_len + return out.to(q.dtype) + + +@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True) +@pytest.mark.parametrize( + ("q_lens", "kv_lens"), + [ + ((129, 257), (129, 257)), + ((65, 129, 257), (129, 257, 385)), + ], +) +def test_prefill_sparse_attention_correctness( + kv_layout: str, + q_lens: tuple[int, ...], + kv_lens: tuple[int, ...], +): + assert len(q_lens) == len(kv_lens) + assert all(kv_len >= q_len for q_len, kv_len in zip(q_lens, kv_lens)) + + # Build paged-KV metadata, including a non-identity page order. + batch = len(q_lens) + pages_per_req = [(kv_len + BLOCK_SIZE - 1) // BLOCK_SIZE for kv_len in kv_lens] + max_blocks = max(pages_per_req) + num_pages = sum(pages_per_req) + physical_pages = torch.randperm(num_pages, device="cuda", dtype=torch.int32) + block_table = torch.zeros(batch, max_blocks, device="cuda", dtype=torch.int32) + base_page = 0 + for req_id, num_req_pages in enumerate(pages_per_req): + block_table[req_id, :num_req_pages] = physical_pages[ + base_page : base_page + num_req_pages + ] + base_page += num_req_pages + + q_lens_t = torch.tensor(q_lens, device="cuda", dtype=torch.int32) + seq_lens = torch.tensor(kv_lens, device="cuda", dtype=torch.int32) + prefix_lens = seq_lens - q_lens_t + cu_seqlens = torch.zeros(batch + 1, device="cuda", dtype=torch.int32) + cu_seqlens[1:] = q_lens_t.cumsum(0) + total_q = sum(q_lens) + max_seqlen_q = max(q_lens) + + q_shape = (total_q, NUM_Q_HEADS, HEAD_DIM) + q = torch.randn(q_shape, device="cuda", dtype=DTYPE) + # Allocate the main KV cache through the backend layout contract so the + # physical storage matches the active layout (contiguous NHD or strided + # HND), while the kernels and reference see the logical-NHD view. + kv_cache = _allocate_main_kv_via_contract(num_pages) + + # Build sparse block indices with the same contract as the real M3 indexer: + # one forced local block, then score-selected older causal blocks. + topk_shape = (NUM_KV_HEADS, total_q, TOPK) + topk_idx = torch.full(topk_shape, -1, device="cuda", dtype=torch.int32) + q_start = 0 + for q_len, prefix_len in zip(q_lens_t.tolist(), prefix_lens.tolist()): + for local_q in range(q_len): + current_block = (prefix_len + local_q) // BLOCK_SIZE + older_blocks = torch.randperm( + current_block, device="cuda", dtype=torch.int32 + ) + selected = torch.cat( + [ + torch.tensor([current_block], device="cuda", dtype=torch.int32), + older_blocks[: TOPK - 1], + ] + ) + topk_idx[:, q_start + local_q, : selected.numel()] = selected + q_start += q_len + + actual = torch.empty_like(q) + minimax_m3_sparse_attn( + q, + kv_cache, + topk_idx, + block_table, + cu_seqlens, + seq_lens, + prefix_lens, + max_seqlen_q, + NUM_KV_HEADS, + SM_SCALE, + actual, + ) + + expected = _reference_sparse_attn( + q, + kv_cache, + topk_idx, + block_table, + q_lens_t, + seq_lens, + prefix_lens, + ) + torch.accelerator.synchronize() + + error = (actual.float() - expected.float()).abs() + assert error.mean().item() < 2.5e-4 + assert error.max().item() < 1.7e-2 + + +def test_main_backend_layout_contract(): + """The main sparse backend exposes the logical-NHD shape and the + flash_attn-style stride order for each layout.""" + nb, bs, h, d = 7, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM + logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d) + assert logical == (nb, 2, bs, h, d) + # The old HND-ordered shape is no longer the logical shape. + assert logical != (nb, 2, h, bs, d) + + try: + set_kv_cache_layout("HND") + assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 3, 2, 4) + set_kv_cache_layout("NHD") + assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3, 4) + finally: + set_kv_cache_layout(None) + + for layout in ("NHD", "HND"): + try: + set_kv_cache_layout(layout) + order = MiniMaxM3SparseBackend.get_kv_cache_stride_order() + finally: + set_kv_cache_layout(None) + # Valid permutation: no duplicates, covers every axis. + assert set(order) == set(range(len(order))) + + # M3 has no cross-layer KV blocks. + with pytest.raises(NotImplementedError): + MiniMaxM3SparseBackend.get_kv_cache_stride_order( + include_num_layers_dimension=True + ) + + +def test_main_backend_unknown_layout_raises(monkeypatch): + """An unrecognized layout (injected past env-var validation) is rejected.""" + import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod + + monkeypatch.setattr(sparse_attn_mod, "get_kv_cache_layout", lambda: "BOGUS") + with pytest.raises(ValueError, match="Unknown cache layout format"): + MiniMaxM3SparseBackend.get_kv_cache_stride_order() + + +def test_indexer_backend_stride_order_is_identity(): + """The 3-dim indexer cache must not inherit the parent's 5-element stride + order; it overrides to the 3-element identity so the allocator keeps the + contiguous layout.""" + assert MiniMaxM3IndexerBackend.get_kv_cache_stride_order() == (0, 1, 2) + + # Cross-layer (per-layer-stacked) KV blocks are not supported. + with pytest.raises(NotImplementedError): + MiniMaxM3IndexerBackend.get_kv_cache_stride_order( + include_num_layers_dimension=True + ) + + # The stride order matches the 3-dim indexer shape rank. + indexer_shape = MiniMaxM3IndexerBackend.get_kv_cache_shape( + 5, BLOCK_SIZE, 1, HEAD_DIM + ) + assert len(indexer_shape) == 3 + assert _stride_order_for(MiniMaxM3IndexerBackend, len(indexer_shape)) == (0, 1, 2) + + +def test_hnd_allocation_is_byte_identical_to_transpose(): + """Under HND the backend-visible logical view is byte-identical to the + pre-change allocate-HND-then-transpose(2, 3) workaround.""" + nb, bs, h, d = 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM + logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d) + try: + set_kv_cache_layout("HND") + stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order() + finally: + set_kv_cache_layout(None) + + physical_shape = tuple(logical[i] for i in stride_order) + # The physical (permuted) shape equals the old hardcoded HND shape. + assert physical_shape == (nb, 2, h, bs, d) + + inv_order = [stride_order.index(i) for i in range(len(stride_order))] + raw = torch.empty(physical_shape, device="cuda", dtype=DTYPE) + view = raw.permute(*inv_order) + expected = raw.view((nb, 2, h, bs, d)).transpose(2, 3) + + assert view.shape == expected.shape + assert view.stride() == expected.stride() + assert view.storage_offset() == expected.storage_offset() + + # Negative: the identity (wrong) stride order under HND does not reproduce + # the transpose view. + wrong_view = raw.view(logical) + assert wrong_view.stride() != expected.stride() + + +def test_main_cache_is_block_first_and_unpadded(): + """The allocator's contiguous-view branch (not the padded-strided branch) + is used for the main GQA cache: its spec is unpadded and the physical + layout keeps num_blocks as the first dimension under both layouts.""" + from vllm.v1.kv_cache_interface import FullAttentionSpec + + spec = FullAttentionSpec( + block_size=BLOCK_SIZE, + num_kv_heads=NUM_KV_HEADS, + head_size=HEAD_DIM, + head_size_v=HEAD_DIM, + dtype=DTYPE, + ) + # Unpadded -> allocator uses kv_tensor.view(...) rather than as_strided(). + assert spec.page_size_padded is None + + logical = MiniMaxM3SparseBackend.get_kv_cache_shape( + 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM + ) + for layout in ("NHD", "HND"): + try: + set_kv_cache_layout(layout) + order = MiniMaxM3SparseBackend.get_kv_cache_stride_order() + finally: + set_kv_cache_layout(None) + inv_order = [order.index(i) for i in range(len(order))] + # Physical first dim is num_blocks (block-first); required by the + # padded-strided branch's block-first assumption if it were ever taken. + assert inv_order[0] == 0 + assert logical[order[0]] == logical[0] + + +def _build_decode_inputs( + seq_lens_list: tuple[int, ...], + decode_query_len: int = 1, + num_padded_reqs: int = 0, +): + """Shared decode setup: uniform query tokens per request, a non-identity + block table, and topk indices selecting the current block plus older causal + blocks for each query token.""" + active_batch = len(seq_lens_list) + batch = active_batch + num_padded_reqs + pages_per_req = [(s + BLOCK_SIZE - 1) // BLOCK_SIZE for s in seq_lens_list] + max_blocks = max(pages_per_req) + num_pages = sum(pages_per_req) + physical_pages = torch.randperm(num_pages, device="cuda", dtype=torch.int32) + block_table = torch.zeros(batch, max_blocks, device="cuda", dtype=torch.int32) + base_page = 0 + for req_id, num_req_pages in enumerate(pages_per_req): + block_table[req_id, :num_req_pages] = physical_pages[ + base_page : base_page + num_req_pages + ] + base_page += num_req_pages + + seq_lens = torch.tensor( + (*seq_lens_list, *([0] * num_padded_reqs)), + device="cuda", + dtype=torch.int32, + ) + q = torch.randn( + batch * decode_query_len, NUM_Q_HEADS, HEAD_DIM, device="cuda", dtype=DTYPE + ) + + topk_idx = torch.full( + (NUM_KV_HEADS, batch * decode_query_len, TOPK), + -1, + device="cuda", + dtype=torch.int32, + ) + token_id = 0 + for req_id, seq_len in enumerate(seq_lens_list): + for local_q in range(decode_query_len): + query_pos = seq_len - decode_query_len + local_q + current_block = query_pos // BLOCK_SIZE + older_blocks = torch.randperm( + current_block, device="cuda", dtype=torch.int32 + ) + selected = torch.cat( + [ + torch.tensor([current_block], device="cuda", dtype=torch.int32), + older_blocks[: TOPK - 1], + ] + ) + topk_idx[:, token_id, : selected.numel()] = selected + token_id += 1 + + return q, block_table, seq_lens, topk_idx, num_pages + + +@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True) +@pytest.mark.parametrize( + "seq_lens_list", + [(130, 257), (129, 200, 384)], +) +@pytest.mark.parametrize("decode_query_len", [1, 4]) +@pytest.mark.parametrize("num_padded_reqs", [0, 2]) +def test_decode_sparse_attention_correctness( + kv_layout: str, + seq_lens_list: tuple[int, ...], + decode_query_len: int, + num_padded_reqs: int, +): + """Decode (split-K) parity under both layouts: this is the only coverage of + the decode-site cache feed, and the strided HND case fails if the kernel + ignores the cache strides.""" + torch.manual_seed(0) + q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs( + seq_lens_list, decode_query_len, num_padded_reqs + ) + kv_cache = _allocate_main_kv_via_contract(num_pages) + + actual = torch.empty_like(q) + minimax_m3_sparse_attn_decode( + q, + kv_cache, + topk_idx, + block_table, + seq_lens, + NUM_KV_HEADS, + SM_SCALE, + actual, + decode_query_len, + ) + + # Reuse the prefill reference: decode is a uniform query chunk ending at + # seq_len - 1 for each request. + active_batch = len(seq_lens_list) + active_tokens = active_batch * decode_query_len + q_lens_t = torch.full( + (len(seq_lens_list),), decode_query_len, device="cuda", dtype=torch.int32 + ) + active_seq_lens = seq_lens[:active_batch] + prefix_lens = active_seq_lens - q_lens_t + expected = _reference_sparse_attn( + q[:active_tokens], + kv_cache, + topk_idx[:, :active_tokens], + block_table[:active_batch], + q_lens_t, + active_seq_lens, + prefix_lens, + ) + torch.accelerator.synchronize() + + error = (actual[:active_tokens].float() - expected.float()).abs() + assert error.mean().item() < 2.5e-4 + assert error.max().item() < 1.7e-2 + + +def test_decode_wrong_layout_breaks_parity(): + """Negative (AC-3/AC-5): consuming the physical HND buffer as if it were + already contiguous-NHD (i.e. skipping the allocator's inverse permute) + reorders the K/V content, so the decode output no longer matches the + reference computed on the correct logical view. The mislabeled tensor keeps + the same shape as the correct view, so the kernel stays in bounds.""" + torch.manual_seed(0) + seq_lens_list = (130, 257) + q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs(seq_lens_list) + + # Physical HND storage [blocks, 2, heads, block, dim]. + phys = torch.randn( + (num_pages, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM), device="cuda", dtype=DTYPE + ) + # Correct logical-NHD view (strided) vs. the same bytes mislabeled as a + # contiguous-NHD cache — same shape, different content mapping. + correct = phys.permute(0, 1, 3, 2, 4) + wrong = phys.reshape(num_pages, 2, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM) + + q_lens_t = torch.ones(len(seq_lens_list), device="cuda", dtype=torch.int32) + prefix_lens = seq_lens - q_lens_t + expected = _reference_sparse_attn( + q, correct, topk_idx, block_table, q_lens_t, seq_lens, prefix_lens + ) + + actual = torch.empty_like(q) + minimax_m3_sparse_attn_decode( + q, wrong, topk_idx, block_table, seq_lens, NUM_KV_HEADS, SM_SCALE, actual, 1 + ) + torch.accelerator.synchronize() + assert (actual.float() - expected.float()).abs().max().item() > 1.7e-2 + + +def _make_attn_group(backend, spec): + return AttentionGroup( + backend=backend, + layer_names=["main"], + kv_cache_spec=spec, + kv_cache_group_id=0, + ) + + +def test_main_cache_byte_identical_through_production_allocator(): + """AC-2: drive the real allocator (`_reshape_kv_cache`) for the M3 main + `FullAttentionSpec` under HND and assert the backend-visible view has the + same shape, stride, and storage offset as the pre-change + allocate-HND-then-transpose path; the indexer `MLAAttentionSpec` allocates + through the same path to its 3-dim shape.""" + nb = 4 + spec = FullAttentionSpec( + block_size=BLOCK_SIZE, + num_kv_heads=NUM_KV_HEADS, + head_size=HEAD_DIM, + head_size_v=HEAD_DIM, + dtype=DTYPE, + ) + raw = torch.zeros(nb * spec.page_size_bytes, dtype=torch.int8) + group = _make_attn_group(MiniMaxM3SparseBackend, spec) + try: + set_kv_cache_layout("HND") + kv_caches = _reshape_kv_cache([group], {"main": raw}, "auto", [BLOCK_SIZE], {}) + finally: + set_kv_cache_layout(None) + view = kv_caches["main"] + + oracle = raw.view(DTYPE).view((nb, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM)) + oracle = oracle.transpose(2, 3) + assert tuple(view.shape) == tuple(oracle.shape) + assert view.stride() == oracle.stride() + assert view.storage_offset() == oracle.storage_offset() + + # Indexer cache allocates through the same path under both layouts. + ispec = MLAAttentionSpec( + block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE + ) + for layout in ("NHD", "HND"): + iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8) + igroup = AttentionGroup( + backend=MiniMaxM3IndexerBackend, + layer_names=["idx"], + kv_cache_spec=ispec, + kv_cache_group_id=0, + ) + try: + set_kv_cache_layout(layout) + iout = _reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {}) + finally: + set_kv_cache_layout(None) + assert tuple(iout["idx"].shape) == (nb, BLOCK_SIZE, HEAD_DIM) + + +def test_indexer_inherited_stride_order_trips_allocator_assert(): + """AC-4 negative: without the indexer override, the inherited 5-element + stride order trips the allocator's `len(stride_order) == len(shape)` assert + for the 3-dim indexer shape; the `AssertionError` is NOT swallowed by the + allocator's `(AttributeError, NotImplementedError)` fallback.""" + + class _BrokenIndexerBackend(MiniMaxM3IndexerBackend): + # Simulate inheriting the parent's 5-element stride order. + get_kv_cache_stride_order = staticmethod( + MiniMaxM3SparseBackend.get_kv_cache_stride_order + ) + + nb = 4 + ispec = MLAAttentionSpec( + block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE + ) + iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8) + igroup = AttentionGroup( + backend=_BrokenIndexerBackend, + layer_names=["idx"], + kv_cache_spec=ispec, + kv_cache_group_id=0, + ) + try: + set_kv_cache_layout("HND") + with pytest.raises(AssertionError): + _reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {}) + finally: + set_kv_cache_layout(None) + + +def test_padded_main_cache_is_flagged(): + """AC-2.1 negative: the M3 main cache relies on the allocator's + contiguous-view branch (`page_size_padded is None`). A spec that sets + `page_size_padded` is explicitly flagged rather than silently wrong-strided.""" + + def _require_unpadded_block_first(spec, stride_order): + inv_order = [stride_order.index(i) for i in range(len(stride_order))] + assert spec.page_size_padded is None, ( + "main GQA cache must be unpadded to use the contiguous-view " + "allocator branch" + ) + assert inv_order[0] == 0, "main GQA cache must remain block-first" + + try: + set_kv_cache_layout("HND") + stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order() + finally: + set_kv_cache_layout(None) + + good = FullAttentionSpec( + block_size=BLOCK_SIZE, + num_kv_heads=NUM_KV_HEADS, + head_size=HEAD_DIM, + head_size_v=HEAD_DIM, + dtype=DTYPE, + ) + _require_unpadded_block_first(good, stride_order) # passes + + padded = FullAttentionSpec( + block_size=BLOCK_SIZE, + num_kv_heads=NUM_KV_HEADS, + head_size=HEAD_DIM, + head_size_v=HEAD_DIM, + dtype=DTYPE, + page_size_padded=good.page_size_bytes + 128, + ) + with pytest.raises(AssertionError): + _require_unpadded_block_first(padded, stride_order) + + +@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True) +def test_reshape_and_cache_flash_write_persists(kv_layout: str): + """AC-5 write path: the `reshape_and_cache_flash` write site now consumes + `self.kv_cache.unbind(1)` directly. Writing through those views must persist + into the bound storage (read back through an independent logical view) under + both layouts — a `.contiguous()` copy of the unbind slice would leave the + bound storage unchanged.""" + torch.manual_seed(0) + num_pages = 4 + kv_cache = _allocate_main_kv_via_contract(num_pages) + with torch.no_grad(): + kv_cache.zero_() + + # Exactly the production write-site code under test. + key_cache, value_cache = kv_cache.unbind(1) + + num_tokens = 12 + slot_mapping = torch.randperm(num_pages * BLOCK_SIZE, device="cuda")[ + :num_tokens + ].to(torch.int64) + key = torch.randn(num_tokens, NUM_KV_HEADS, HEAD_DIM, device="cuda", dtype=DTYPE) + value = torch.randn(num_tokens, NUM_KV_HEADS, HEAD_DIM, device="cuda", dtype=DTYPE) + scale = torch.ones((), device="cuda") + ops.reshape_and_cache_flash( + key, value, key_cache, value_cache, slot_mapping, "auto", scale, scale + ) + torch.accelerator.synchronize() + + # Read back through the independent logical view; proves the writes landed + # in the engine-bound storage, not a detached copy. + for t in range(num_tokens): + slot = int(slot_mapping[t].item()) + blk, intra = divmod(slot, BLOCK_SIZE) + torch.testing.assert_close(kv_cache[blk, 0, intra], key[t]) + torch.testing.assert_close(kv_cache[blk, 1, intra], value[t]) diff --git a/tests/kernels/attention/test_mixed_causal_attn.py b/tests/kernels/attention/test_mixed_causal_attn.py new file mode 100644 index 000000000000..5343f701f283 --- /dev/null +++ b/tests/kernels/attention/test_mixed_causal_attn.py @@ -0,0 +1,318 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for per-request causal/non-causal attention (mixed batches). + +Validates that both triton and flash-attention backends correctly handle +batches where some sequences use causal masking and others use non-causal +(bidirectional) masking — needed by DiffusionGemma. +""" + +import pytest +import torch + +from vllm.platforms import current_platform +from vllm.utils.torch_utils import set_random_seed + +# Mixed causal/non-causal attention is only validated on a subset of GPUs: +# the Triton path on Hopper (SM90) and B200 (SM100); the FA4 path on Hopper +# (SM90) only. +_device_capability = current_platform.get_device_capability() +_major = _device_capability.major if _device_capability is not None else None + +NUM_HEADS = [(4, 4), (8, 2)] +HEAD_SIZES = [128] +BLOCK_SIZES = [16] +DTYPES = [torch.bfloat16] + + +def ref_paged_attn( + query: torch.Tensor, + key_cache: torch.Tensor, + value_cache: torch.Tensor, + query_lens: list[int], + kv_lens: list[int], + block_tables: torch.Tensor, + scale: float, + per_seq_causal: list[bool], + sliding_window: int | None = None, +) -> torch.Tensor: + num_seqs = len(query_lens) + block_tables_np = block_tables.cpu().numpy() + _, block_size, num_kv_heads, head_size = key_cache.shape + + outputs: list[torch.Tensor] = [] + start_idx = 0 + for i in range(num_seqs): + query_len = query_lens[i] + kv_len = kv_lens[i] + q = query[start_idx : start_idx + query_len] + q = q * scale + + num_kv_blocks = (kv_len + block_size - 1) // block_size + block_indices = block_tables_np[i, :num_kv_blocks] + k = key_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len] + v = value_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len] + + if q.shape[1] != k.shape[1]: + k = torch.repeat_interleave(k, q.shape[1] // k.shape[1], dim=1) + v = torch.repeat_interleave(v, q.shape[1] // v.shape[1], dim=1) + + attn = torch.einsum("qhd,khd->hqk", q, k).float() + + if per_seq_causal[i]: + mask = torch.triu( + torch.ones(query_len, kv_len, device=attn.device), + diagonal=kv_len - query_len + 1, + ).bool() + else: + mask = torch.zeros(query_len, kv_len, device=attn.device).bool() + + if sliding_window is not None: + sw_mask = ( + torch.triu( + torch.ones(query_len, kv_len, device=attn.device), + diagonal=kv_len - (query_len + sliding_window) + 1, + ) + .bool() + .logical_not() + ) + mask |= sw_mask + + attn.masked_fill_(mask, float("-inf")) + attn = torch.softmax(attn, dim=-1).to(v.dtype) + out = torch.einsum("hqk,khd->qhd", attn, v) + outputs.append(out) + start_idx += query_len + + return torch.cat(outputs, dim=0) + + +# ---- Triton backend test ---- + + +@pytest.mark.skipif( + _major not in (9, 10), + reason="Triton mixed causal attention requires Hopper (SM90) or B200 (SM100).", +) +@pytest.mark.parametrize( + "seq_lens", + [[(1, 128), (5, 64), (1, 256)]], +) +@pytest.mark.parametrize( + "per_seq_causal", + [[True, False, True], [False, True, False], [True, True, False]], +) +@pytest.mark.parametrize("num_heads", NUM_HEADS) +@pytest.mark.parametrize("head_size", HEAD_SIZES) +@pytest.mark.parametrize("block_size", BLOCK_SIZES) +@pytest.mark.parametrize("dtype", DTYPES) +@torch.inference_mode() +def test_triton_mixed_causal( + seq_lens: list[tuple[int, int]], + per_seq_causal: list[bool], + num_heads: tuple[int, int], + head_size: int, + dtype: torch.dtype, + block_size: int, +): + if not current_platform.is_cuda(): + pytest.skip("Triton attention requires CUDA") + + from vllm.v1.attention.ops.triton_unified_attention import unified_attention + + set_random_seed(42) + device = "cuda" + + num_query_heads, num_kv_heads = num_heads + assert len(seq_lens) == len(per_seq_causal) + + query_lens = [s[0] for s in seq_lens] + kv_lens = [s[1] for s in seq_lens] + num_seqs = len(seq_lens) + + num_query_tokens = sum(query_lens) + max_kv_len = max(kv_lens) + max_num_blocks = (max_kv_len + block_size - 1) // block_size + num_blocks = max_num_blocks * num_seqs + 10 + + scale = head_size**-0.5 + query = torch.randn( + num_query_tokens, num_query_heads, head_size, dtype=dtype, device=device + ) + key_cache = torch.randn( + num_blocks, block_size, num_kv_heads, head_size, dtype=dtype, device=device + ) + value_cache = torch.randn( + num_blocks, block_size, num_kv_heads, head_size, dtype=dtype, device=device + ) + + block_tables_list = [] + for i in range(num_seqs): + n_blocks = (kv_lens[i] + block_size - 1) // block_size + blocks = list(range(i * max_num_blocks, i * max_num_blocks + n_blocks)) + blocks += [0] * (max_num_blocks - n_blocks) + block_tables_list.append(blocks) + block_tables = torch.tensor(block_tables_list, dtype=torch.int32, device=device) + + cu_seqlens_q = torch.zeros(num_seqs + 1, dtype=torch.int32, device=device) + for i, ql in enumerate(query_lens): + cu_seqlens_q[i + 1] = cu_seqlens_q[i] + ql + + seqused_k = torch.tensor(kv_lens, dtype=torch.int32, device=device) + max_seqlen_q = max(query_lens) + max_seqlen_k = max(kv_lens) + + causal_tensor = torch.tensor(per_seq_causal, dtype=torch.bool, device=device) + + output = torch.empty_like(query) + unified_attention( + q=query, + k=key_cache, + v=value_cache, + out=output, + cu_seqlens_q=cu_seqlens_q, + max_seqlen_q=max_seqlen_q, + seqused_k=seqused_k, + max_seqlen_k=max_seqlen_k, + softmax_scale=scale, + causal=causal_tensor, + window_size=(-1, -1), + block_table=block_tables, + softcap=0.0, + q_descale=None, + k_descale=1.0, + v_descale=1.0, + ) + + ref_output = ref_paged_attn( + query, + key_cache, + value_cache, + query_lens, + kv_lens, + block_tables, + scale, + per_seq_causal, + ) + + torch.testing.assert_close(output, ref_output, atol=1e-2, rtol=1e-2) + + +# ---- Flash Attention 4 backend test (native per_seq_causal) ---- + + +@pytest.mark.skipif( + _major != 9, + reason="FA4 mixed causal attention requires Hopper (SM90).", +) +@pytest.mark.parametrize( + "seq_lens", + [[(1, 128), (5, 64), (1, 256)]], +) +@pytest.mark.parametrize( + "per_seq_causal", + [[True, False, True], [False, True, False]], +) +@pytest.mark.parametrize("num_heads", NUM_HEADS) +@pytest.mark.parametrize("head_size", HEAD_SIZES) +@pytest.mark.parametrize("block_size", BLOCK_SIZES) +@pytest.mark.parametrize("dtype", DTYPES) +@torch.inference_mode() +def test_flash_attn4_mixed_causal( + seq_lens: list[tuple[int, int]], + per_seq_causal: list[bool], + num_heads: tuple[int, int], + head_size: int, + dtype: torch.dtype, + block_size: int, +): + if not current_platform.is_cuda(): + pytest.skip("Flash attention requires CUDA") + + try: + from vllm.vllm_flash_attn import ( + fa_version_unsupported_reason, + flash_attn_varlen_func, + is_fa_version_supported, + ) + except ImportError: + pytest.skip("vllm_flash_attn not available") + + if not is_fa_version_supported(4): + reason = fa_version_unsupported_reason(4) + pytest.skip(f"FA4 not supported: {reason}") + + set_random_seed(42) + device = "cuda" + + num_query_heads, num_kv_heads = num_heads + assert len(seq_lens) == len(per_seq_causal) + + query_lens = [s[0] for s in seq_lens] + kv_lens = [s[1] for s in seq_lens] + num_seqs = len(seq_lens) + + num_query_tokens = sum(query_lens) + max_kv_len = max(kv_lens) + max_num_blocks = (max_kv_len + block_size - 1) // block_size + num_blocks = max_num_blocks * num_seqs + 10 + + scale = head_size**-0.5 + query = torch.randn( + num_query_tokens, num_query_heads, head_size, dtype=dtype, device=device + ) + key_cache = torch.randn( + num_blocks, block_size, num_kv_heads, head_size, dtype=dtype, device=device + ) + value_cache = torch.randn( + num_blocks, block_size, num_kv_heads, head_size, dtype=dtype, device=device + ) + + block_tables_list = [] + for i in range(num_seqs): + n_blocks = (kv_lens[i] + block_size - 1) // block_size + blocks = list(range(i * max_num_blocks, i * max_num_blocks + n_blocks)) + blocks += [0] * (max_num_blocks - n_blocks) + block_tables_list.append(blocks) + block_tables = torch.tensor(block_tables_list, dtype=torch.int32, device=device) + + cu_seqlens_q = torch.zeros(num_seqs + 1, dtype=torch.int32, device=device) + for i, ql in enumerate(query_lens): + cu_seqlens_q[i + 1] = cu_seqlens_q[i] + ql + + seqused_k = torch.tensor(kv_lens, dtype=torch.int32, device=device) + per_seq_causal_tensor = torch.tensor( + per_seq_causal, dtype=torch.int32, device=device + ) + + ref_output = ref_paged_attn( + query, + key_cache, + value_cache, + query_lens, + kv_lens, + block_tables, + scale, + per_seq_causal, + ) + + output = torch.empty_like(query) + flash_attn_varlen_func( + q=query, + k=key_cache, + v=value_cache, + out=output, + cu_seqlens_q=cu_seqlens_q, + max_seqlen_q=max(query_lens), + seqused_k=seqused_k, + max_seqlen_k=max(kv_lens), + softmax_scale=scale, + # The kernel must be compiled causal for `dynamic_causal` to take effect. + causal=True, + block_table=block_tables, + softcap=0.0, + dynamic_causal=per_seq_causal_tensor, + fa_version=4, + ) + + torch.testing.assert_close(output, ref_output, atol=1e-2, rtol=1e-2) diff --git a/tests/kernels/attention/test_mla_cross_layer_kernel_equivalence.py b/tests/kernels/attention/test_mla_cross_layer_kernel_equivalence.py new file mode 100644 index 000000000000..48a236a01579 --- /dev/null +++ b/tests/kernels/attention/test_mla_cross_layer_kernel_equivalence.py @@ -0,0 +1,566 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Bit-exact kernel equivalence for MLA decode/write kernels on the +cross-layer (block-major) KV cache layout. + +The cross-layer layout carves each layer's per-block page out of a single +unified slot, so the per-layer view has an inflated ``stride(0)`` (the full +unified slot) and a non-zero storage offset. These tests confirm the MLA +kernels behind the backends that opt in to the layout (FlashMLA dense, +FlashInfer MLA dense, FlashMLA fp8 sparse, plus the ``concat_and_cache_mla`` +write) honor that strided view bit-identically to a contiguous per-layer +cache, and that writes do not bleed into neighbouring layers' segments. +""" + +import pytest +import torch + +pytestmark = pytest.mark.skipif( + not torch.cuda.is_available(), reason="MLA cache kernels require CUDA" +) + + +def test_concat_and_cache_mla_into_unified_slot_view(): + """concat_and_cache_mla must write correctly into a per-layer view whose + block stride is the full unified slot (block-major), with zero bleed into + the other layers' segments of the same slot.""" + from vllm import _custom_ops as ops + + torch.manual_seed(0) + dev = "cuda" + kv_lora_rank = 512 + pe = 64 + entry = kv_lora_rank + pe + page = 64 + num_blocks = 32 + ntok = 200 + + kv_c = torch.randn(ntok, kv_lora_rank, device=dev, dtype=torch.bfloat16) + k_pe = torch.randn(ntok, pe, device=dev, dtype=torch.bfloat16) + slot = torch.randperm(num_blocks * page, device=dev, dtype=torch.int64)[:ntok] + scale = torch.tensor(1.0, device=dev) + + def write(cache): + ops.concat_and_cache_mla(kv_c, k_pe, cache, slot, "auto", scale) + + # Contiguous per-layer reference: (num_blocks, page, entry). + ref = torch.zeros(num_blocks, page, entry, device=dev, dtype=torch.bfloat16) + write(ref) + + # Unified slot holding three layer pages per block. Carve the middle + # layer's view (non-zero offset, block stride == full unified slot). + layer_page_elems = page * entry + n_layers = 3 + unified_slot_elems = n_layers * layer_page_elems + big = torch.zeros(num_blocks, unified_slot_elems, device=dev, dtype=torch.bfloat16) + flat = big.view(-1) + offset = layer_page_elems # middle layer + view = torch.as_strided( + flat, + size=(num_blocks, page, entry), + stride=(unified_slot_elems, entry, 1), + storage_offset=offset, + ) + assert not view.is_contiguous() + assert view.stride(0) == unified_slot_elems + write(view) + + # Bit-exact equivalence and zero bleed into the neighbour segments. + max_diff = (ref.float() - view.float()).abs().max().item() + assert max_diff == 0.0, f"max|Δ| = {max_diff}" + + neighbour_lo = torch.as_strided( + flat, (num_blocks, layer_page_elems), (unified_slot_elems, 1), 0 + ) + neighbour_hi = torch.as_strided( + flat, + (num_blocks, layer_page_elems), + (unified_slot_elems, 1), + 2 * layer_page_elems, + ) + assert neighbour_lo.abs().max().item() == 0.0 + assert neighbour_hi.abs().max().item() == 0.0 + + +def test_flashmla_dense_decode_unified_slot_view(): + """FlashMLA dense decode (FLASHMLA backend, e.g. Kimi-K2-style dense MLA + on Hopper) must read a unified-slot block-major view bit-identically to a + contiguous per-layer cache.""" + import vllm.v1.attention.ops.flashmla as fm + + ok, reason = fm.is_flashmla_dense_supported() + if not ok: + pytest.skip(reason) + + torch.manual_seed(0) + dev = "cuda" + dt = torch.bfloat16 + head_dim = 576 + hdv = 512 + h_q = 128 + page = 64 + num_blocks = 64 + bs = 4 + n_layers = 3 + layer = 1 + + q = torch.randn(bs, 1, h_q, head_dim, device=dev, dtype=dt) * 0.1 + kv_data = torch.randn(num_blocks, page, 1, head_dim, device=dev, dtype=dt) * 0.1 + + # (A) contiguous per-layer reference. + cache_contiguous = kv_data.clone().contiguous() + + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset. + unified = ( + torch.randn(num_blocks, n_layers, page, 1, head_dim, device=dev, dtype=dt) * 0.1 + ) + unified[:, layer].copy_(kv_data) + cache_view = unified[:, layer] + assert not cache_view.is_contiguous() + assert cache_view.stride(0) == n_layers * page * 1 * head_dim + + max_blk = num_blocks // bs + block_table = torch.arange(num_blocks, device=dev, dtype=torch.int32).view( + bs, max_blk + ) + cache_seqlens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32) + + def run(kc): + meta, num_splits = fm.get_mla_metadata() + out, _ = fm.flash_mla_with_kvcache( + q=q, + k_cache=kc, + block_table=block_table, + cache_seqlens=cache_seqlens, + head_dim_v=hdv, + tile_scheduler_metadata=meta, + num_splits=num_splits, + softmax_scale=head_dim**-0.5, + causal=True, + ) + return out.clone().float() + + out_ref = run(cache_contiguous) + out_view = run(cache_view) + assert torch.isfinite(out_ref).all() + assert out_ref.abs().max().item() > 0.0 + assert (out_ref - out_view).abs().max().item() == 0.0 + + +def test_flashinfer_mla_dense_decode_unified_slot_view(): + """FlashInfer MLA dense decode must read a unified-slot block-major view + (inflated stride(0), non-zero storage offset) bit-identically to a + contiguous per-layer cache.""" + try: + from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla + except ImportError: + pytest.skip("flashinfer is not available") + from vllm.platforms import current_platform + + if not current_platform.is_device_capability_family(100): + pytest.skip("FlashInfer trtllm-gen MLA requires sm100") + + torch.manual_seed(0) + dev = "cuda" + dt = torch.bfloat16 + kv_lora_rank = 512 + qk_rope_head_dim = 64 + qk_nope_head_dim = 128 + head_dim = kv_lora_rank + qk_rope_head_dim # 576 + num_qo_heads = 128 + page = 64 + num_blocks = 64 + bs = 4 + n_layers = 3 # >1 so the per-layer view's block stride is inflated. + layer = 1 + + q = torch.randn(bs, 1, num_qo_heads, head_dim, device=dev, dtype=dt) + kv_data = torch.randn(num_blocks, 1, page, head_dim, device=dev, dtype=dt) + + # (A) contiguous per-layer reference. + kv_contiguous = kv_data.clone().contiguous() + + # (B) unified slot: block b of every layer packed together; view one layer + # -> stride(0) is n_layers x larger and storage offset is non-zero. + unified = torch.randn(num_blocks, n_layers, 1, page, head_dim, device=dev, dtype=dt) + unified[:, layer].copy_(kv_data) + kv_view = unified[:, layer] + assert not kv_view.is_contiguous() + assert kv_view.stride(0) == n_layers * 1 * page * head_dim + + max_blk = num_blocks // bs + block_tables = torch.arange(num_blocks, device=dev, dtype=torch.int32).view( + bs, max_blk + ) + seq_lens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32) + ws = torch.empty(128 * 1024 * 1024, dtype=torch.int8, device=dev) + scale = head_dim**-0.5 + + def run(kv): + return trtllm_batch_decode_with_kv_cache_mla( + query=q, + kv_cache=kv, + workspace_buffer=ws, + qk_nope_head_dim=qk_nope_head_dim, + kv_lora_rank=kv_lora_rank, + qk_rope_head_dim=qk_rope_head_dim, + block_tables=block_tables, + seq_lens=seq_lens, + max_seq_len=int(seq_lens.max().item()), + bmm1_scale=scale, + bmm2_scale=1.0, + ).clone() + + out_ref = run(kv_contiguous).float() + out_view = run(kv_view).float() + assert torch.isfinite(out_ref).all() + assert (out_ref - out_view).abs().max().item() == 0.0 + + +def test_flashmla_fp8_sparse_decode_unified_slot_view(): + """FlashMLA fp8 sparse decode (DeepSeek V3.2/V4 DSA path) must read a + unified-slot block-major view bit-identically to a contiguous fp8_ds_mla + cache, with finite nonzero output.""" + import vllm.v1.attention.ops.flashmla as fm + + ok, reason = fm.is_flashmla_sparse_supported() + if not ok: + pytest.skip(reason) + + torch.manual_seed(0) + dev = "cuda" + entry = 656 # fp8_ds_mla bytes per token + page = 64 + num_blocks = 32 + h_q = 128 + head_dim = 576 + hdv = 512 + batch = 2 + topk = 128 + n_layers = 3 + layer = 1 + + q = torch.randn(batch, 1, h_q, head_dim, device=dev, dtype=torch.bfloat16) * 0.1 + + # Structurally valid fp8 ds_mla payload: 512B fp8 + 16B f32 scales + 128B + # bf16 rope (random bytes corrupt the scale region and yield NaNs). + nope = (torch.randn(num_blocks, page, 1, 512, device=dev) * 0.1).to( + torch.float8_e4m3fn + ) + scales = torch.ones(num_blocks, page, 1, 4, device=dev, dtype=torch.float32) + rope = (torch.randn(num_blocks, page, 1, 64, device=dev) * 0.1).to(torch.bfloat16) + payload = torch.cat( + [ + nope.view(torch.uint8).view(num_blocks, page, 1, 512), + scales.view(torch.uint8).view(num_blocks, page, 1, 16), + rope.view(torch.uint8).view(num_blocks, page, 1, 128), + ], + dim=-1, + ).contiguous() + assert payload.shape[-1] == entry and payload.dtype == torch.uint8 + + # (A) contiguous reference. + cache_contiguous = payload.clone().contiguous() + + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset. + unified = torch.randint( + 0, 256, (num_blocks, n_layers, page, 1, entry), device=dev, dtype=torch.uint8 + ) + unified[:, layer].copy_(payload) + cache_view = unified[:, layer] + assert not cache_view.is_contiguous() + assert cache_view.stride(0) == n_layers * page * 1 * entry + + # Sparse indices: each batch uses its own disjoint blocks. + blocks_per_batch = num_blocks // batch + idx = torch.full((batch, 1, topk), -1, device=dev, dtype=torch.int32) + for b in range(batch): + slots: list[int] = [] + for blk in range(b * blocks_per_batch, (b + 1) * blocks_per_batch): + slots.extend(blk * page + off for off in range(page)) + slots_t = torch.tensor(slots[:topk], device=dev, dtype=torch.int32) + idx[b, 0, : slots_t.numel()] = slots_t + + def run(kc): + meta, num_splits = fm.get_mla_metadata() + out, _ = fm.flash_mla_with_kvcache( + q=q, + k_cache=kc, + block_table=None, + cache_seqlens=None, + head_dim_v=hdv, + tile_scheduler_metadata=meta, + is_fp8_kvcache=True, + indices=idx, + softmax_scale=head_dim**-0.5, + ) + return out.clone().float() + + out_ref = run(cache_contiguous) + out_view = run(cache_view) + assert torch.isfinite(out_ref).all() + assert out_ref.abs().max().item() > 0.0 + assert (out_ref - out_view).abs().max().item() == 0.0 + + +def test_indexer_k_quant_and_cache_into_unified_slot_view(): + """indexer_k_quant_and_cache (DeepSeek V3.2/V4 DSA indexer K write) must + write correctly into a per-layer view whose block stride is the full + unified slot, with zero bleed into the other layers' segments.""" + from vllm import _custom_ops as ops + + torch.manual_seed(0) + dev = "cuda" + head_dim = 128 + quant_block_size = 128 + block_size = 64 + num_blocks = 16 + ntok = 100 + # Indexer cache layout per token: head_dim fp8 bytes followed by + # head_dim * 4 / quant_block_size scale bytes. + cache_stride = head_dim + head_dim * 4 // quant_block_size + + k = torch.randn(ntok, head_dim, device=dev, dtype=torch.bfloat16) + slot = torch.randperm(num_blocks * block_size, device=dev, dtype=torch.int64)[:ntok] + + def write(cache): + ops.indexer_k_quant_and_cache(k, cache, slot, quant_block_size, "ue8m0") + + # Contiguous per-layer reference. + ref = torch.zeros( + num_blocks, block_size, cache_stride, device=dev, dtype=torch.uint8 + ) + write(ref) + + # Unified slot holding three layer pages per block; carve the middle one. + n_layers = 3 + layer = 1 + unified = torch.zeros( + num_blocks, n_layers, block_size, cache_stride, device=dev, dtype=torch.uint8 + ) + view = unified[:, layer] + assert not view.is_contiguous() + assert view.stride(0) == n_layers * block_size * cache_stride + write(view) + + assert torch.equal(ref, view.contiguous()) + # Zero bleed into the neighbour layers' segments. + assert unified[:, 0].abs().max().item() == 0 + assert unified[:, 2].abs().max().item() == 0 + + +def test_flashattn_mla_dense_decode_unified_slot_view(): + """FA3 decode (FLASH_ATTN_MLA backend) must read a unified-slot + block-major view bit-identically to a contiguous per-layer cache.""" + try: + from vllm.vllm_flash_attn import flash_attn_varlen_func + except ImportError: + pytest.skip("vllm_flash_attn is not available") + from vllm.v1.attention.backends.fa_utils import flash_attn_supports_mla + + if not flash_attn_supports_mla(): + pytest.skip("FA3 MLA requires a Hopper device") + + torch.manual_seed(0) + dev = "cuda" + dt = torch.bfloat16 + kv_lora_rank = 512 + rope_dim = 64 + entry = kv_lora_rank + rope_dim # 576 + h_q = 16 + page = 64 + num_blocks = 64 + bs = 4 + n_layers = 3 + layer = 1 + + q_pe = torch.randn(bs, h_q, rope_dim, device=dev, dtype=dt) * 0.1 + q_nope = torch.randn(bs, h_q, kv_lora_rank, device=dev, dtype=dt) * 0.1 + kv_data = torch.randn(num_blocks, page, entry, device=dev, dtype=dt) * 0.1 + + # (A) contiguous per-layer reference. + cache_contiguous = kv_data.clone().contiguous() + + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset. + unified = torch.randn(num_blocks, n_layers, page, entry, device=dev, dtype=dt) * 0.1 + unified[:, layer].copy_(kv_data) + cache_view = unified[:, layer] + assert not cache_view.is_contiguous() + assert cache_view.stride(0) == n_layers * page * entry + + max_blk = num_blocks // bs + block_table = torch.arange(num_blocks, device=dev, dtype=torch.int32).view( + bs, max_blk + ) + seq_lens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32) + cu_seqlens_q = torch.arange(bs + 1, device=dev, dtype=torch.int32) + + def run(cache): + kv_c_cache = cache[..., :kv_lora_rank] + k_pe_cache = cache[..., kv_lora_rank:] + out = flash_attn_varlen_func( + q=q_pe, + k=k_pe_cache.unsqueeze(-2), # Add head dim of 1 + v=kv_c_cache.unsqueeze(-2), # Add head dim of 1 + q_v=q_nope, + max_seqlen_q=1, + cu_seqlens_q=cu_seqlens_q, + max_seqlen_k=int(seq_lens.max().item()), + seqused_k=seq_lens, + block_table=block_table, + softmax_scale=entry**-0.5, + causal=True, + fa_version=3, + ) + return out.clone().float() + + out_ref = run(cache_contiguous) + out_view = run(cache_view) + assert torch.isfinite(out_ref).all() + assert out_ref.abs().max().item() > 0.0 + assert (out_ref - out_view).abs().max().item() == 0.0 + + +def test_flashmla_dense_fp8_decode_unified_slot_view(): + """FlashMLA dense fp8 decode (FLASHMLA backend with quantized KV cache) + must read a unified-slot block-major view bit-identically to a contiguous + per-layer fp8 cache.""" + import vllm.v1.attention.ops.flashmla as fm + + ok, reason = fm.is_flashmla_dense_supported() + if not ok: + pytest.skip(reason) + + torch.manual_seed(0) + dev = "cuda" + head_dim = 576 + hdv = 512 + h_q = 128 + page = 64 + num_blocks = 64 + bs = 4 + n_layers = 3 + layer = 1 + + q = torch.randn(bs, 1, h_q, head_dim, device=dev, dtype=torch.bfloat16) * 0.1 + kv_data = (torch.randn(num_blocks, page, head_dim, device=dev) * 0.1).to( + torch.float8_e4m3fn + ) + + # (A) contiguous per-layer reference. + cache_contiguous = kv_data.clone().contiguous() + + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset. + unified = (torch.randn(num_blocks, n_layers, page, head_dim, device=dev) * 0.1).to( + torch.float8_e4m3fn + ) + unified[:, layer].copy_(kv_data) + cache_view = unified[:, layer] + assert not cache_view.is_contiguous() + assert cache_view.stride(0) == n_layers * page * head_dim + + max_blk = num_blocks // bs + block_table = torch.arange(num_blocks, device=dev, dtype=torch.int32).view( + bs, max_blk + ) + cache_seqlens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32) + descale = torch.ones(1, device=dev, dtype=torch.float32) + + def run(kc): + tile_md, num_splits = fm.get_mla_metadata_dense_fp8(cache_seqlens, h_q, 1) + out, _ = fm.flash_mla_with_kvcache_fp8( + q=q, + k_cache=kc.unsqueeze(-2), # Add head dim of 1 + block_table=block_table, + cache_seqlens=cache_seqlens, + head_dim_v=hdv, + tile_scheduler_metadata=tile_md, + num_splits=num_splits, + softmax_scale=head_dim**-0.5, + causal=True, + descale_q=descale, + descale_k=descale, + ) + return out.clone().float() + + out_ref = run(cache_contiguous) + out_view = run(cache_view) + assert torch.isfinite(out_ref).all() + assert out_ref.abs().max().item() > 0.0 + assert (out_ref - out_view).abs().max().item() == 0.0 + + +def test_flashinfer_mla_dense_fp8_decode_unified_slot_view(): + """FlashInfer MLA dense decode with an fp8 KV cache must read a + unified-slot block-major view bit-identically to a contiguous per-layer + cache.""" + try: + from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla + except ImportError: + pytest.skip("flashinfer is not available") + from vllm.platforms import current_platform + + if not current_platform.is_device_capability_family(100): + pytest.skip("FlashInfer trtllm-gen MLA requires sm100") + + torch.manual_seed(0) + dev = "cuda" + kv_lora_rank = 512 + qk_rope_head_dim = 64 + qk_nope_head_dim = 128 + head_dim = kv_lora_rank + qk_rope_head_dim # 576 + num_qo_heads = 128 + page = 64 + num_blocks = 64 + bs = 4 + n_layers = 3 + layer = 1 + + # With a quantized KV cache the decode query is quantized to fp8 as well + # (trtllm-gen has no bf16-query x fp8-cache decode kernel). + q = (torch.randn(bs, 1, num_qo_heads, head_dim, device=dev) * 0.1).to( + torch.float8_e4m3fn + ) + kv_data = (torch.randn(num_blocks, 1, page, head_dim, device=dev) * 0.1).to( + torch.float8_e4m3fn + ) + + # (A) contiguous per-layer reference. + kv_contiguous = kv_data.clone().contiguous() + + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset. + unified = ( + torch.randn(num_blocks, n_layers, 1, page, head_dim, device=dev) * 0.1 + ).to(torch.float8_e4m3fn) + unified[:, layer].copy_(kv_data) + kv_view = unified[:, layer] + assert not kv_view.is_contiguous() + assert kv_view.stride(0) == n_layers * 1 * page * head_dim + + max_blk = num_blocks // bs + block_tables = torch.arange(num_blocks, device=dev, dtype=torch.int32).view( + bs, max_blk + ) + seq_lens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32) + ws = torch.empty(128 * 1024 * 1024, dtype=torch.int8, device=dev) + scale = head_dim**-0.5 + + def run(kv): + return trtllm_batch_decode_with_kv_cache_mla( + query=q, + kv_cache=kv, + workspace_buffer=ws, + qk_nope_head_dim=qk_nope_head_dim, + kv_lora_rank=kv_lora_rank, + qk_rope_head_dim=qk_rope_head_dim, + block_tables=block_tables, + seq_lens=seq_lens, + max_seq_len=int(seq_lens.max().item()), + bmm1_scale=scale, + bmm2_scale=1.0, + ).clone() + + out_ref = run(kv_contiguous).float() + out_view = run(kv_view).float() + assert torch.isfinite(out_ref).all() + assert (out_ref - out_view).abs().max().item() == 0.0 diff --git a/tests/kernels/attention/test_prefix_prefill.py b/tests/kernels/attention/test_prefix_prefill.py index de63b4548f2d..f1c591fb671b 100644 --- a/tests/kernels/attention/test_prefix_prefill.py +++ b/tests/kernels/attention/test_prefix_prefill.py @@ -5,10 +5,12 @@ import random import time from collections.abc import Callable +from contextlib import nullcontext import pytest import torch import torch.nn.functional as F +from torch.nn.attention import SDPBackend, sdpa_kernel from vllm.platforms import current_platform from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE, set_random_seed @@ -557,15 +559,21 @@ def _get_alibi_slopes(total_num_heads: int) -> torch.Tensor: query_len, seq_len, alibi_slopes, device, dtype ) - # Compute attention - out = F.scaled_dot_product_attention( - q_sdpa, - k_sdpa, - v_sdpa, - attn_mask=alibi_mask, - dropout_p=0.0, - scale=scale, - ) + # Compute attention. On ROCm we force use of the Math SDPA backend rather than + # the Flash or Mem-Efficient backends for increased numerical accuracy + if current_platform.is_rocm(): + sdpa_context = sdpa_kernel(SDPBackend.MATH) + else: + sdpa_context = nullcontext() + with sdpa_context: + out = F.scaled_dot_product_attention( + q_sdpa, + k_sdpa, + v_sdpa, + attn_mask=alibi_mask, + dropout_p=0.0, + scale=scale, + ) # Reshape output back to [query_len, num_heads, head_size] out = out.view(num_heads, query_len, head_size).permute(1, 0, 2) diff --git a/tests/kernels/attention/test_rocm_aiter_mla_decode_metadata.py b/tests/kernels/attention/test_rocm_aiter_mla_decode_metadata.py new file mode 100644 index 000000000000..2b9a4e823c08 --- /dev/null +++ b/tests/kernels/attention/test_rocm_aiter_mla_decode_metadata.py @@ -0,0 +1,202 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression test for AITER MLA persistent decode metadata dtypes. + +For the gfx950 fp8/fp8 nhead=32 qlen=1 fold path, the split/reduce metadata +layout depends on the q/kv element size. The builder must forward dtype_q/dtype_kv +to ``get_mla_metadata_v1``; omitting them lays out the work for the wrong dtype +and corrupts decode output. The test pins the builder's metadata to a golden +recomputed at runtime with the explicit correct dtypes. +""" + +import types +from unittest.mock import patch + +import pytest +import torch + +from vllm._aiter_ops import is_aiter_found +from vllm.platforms import current_platform + + +def _on_gfx950() -> bool: + if not (current_platform.is_rocm() and is_aiter_found()): + return False + from vllm.platforms.rocm import on_gfx950 + + return on_gfx950() + + +pytestmark = pytest.mark.skipif( + not _on_gfx950(), + reason="AITER MLA fp8 persistent decode metadata is gfx950-only", +) + +# The fold path that the bug corrupted: fp8 query + fp8 KV-cache, 32 query +# heads, single-token decode, batch 128, context 8192, page_size 1. +NUM_QUERY_HEADS = 32 +DECODE_QLEN = 1 +BATCH_SIZE = 128 +CONTEXT_LEN = 8192 +PAGE_SIZE = 1 + +# Expected dtypes for this fold path: bf16 model dtype -> bf16 query; fp8 +# KV-cache -> fp8_e4m3 kv. +EXPECTED_Q_DTYPE = torch.bfloat16 +EXPECTED_KV_DTYPE = torch.float8_e4m3fn + +# The split/reduce content tensors filled by get_mla_metadata_v1. work_meta_data +# is excluded: it holds raw device pointers, never equal across allocations. +_CONTENT_METADATA_FIELDS = ( + "work_indptr", + "work_info_set", + "reduce_indptr", + "reduce_final_map", + "reduce_partial_map", +) + +# The builder's get_mla_metadata_v1 call passes 6 input args then 6 output +# buffers (see AiterMLAMetadataBuilder._build_decode). Output order -> field. +_NUM_INPUT_ARGS = 6 +_OUTPUT_ARG_FIELDS = ( + "work_meta_data", + "work_info_set", + "work_indptr", + "reduce_indptr", + "reduce_final_map", + "reduce_partial_map", +) + + +def _build_decode_metadata(): + """Build AITER MLA decode metadata for the fp8/fp8 nhead=32 fold path. + + Returns ``(metadata, captured)`` where ``captured`` records the positional + args/kwargs the builder passed to ``get_mla_metadata_v1``, so the golden can + be recomputed from the identical inputs. + """ + from tests.v1.attention.utils import ( + BatchSpec, + create_common_attn_metadata, + create_vllm_config, + ) + from vllm.config.vllm import set_current_vllm_config + from vllm.v1.attention.backends.registry import AttentionBackendEnum + from vllm.v1.kv_cache_interface import MLAAttentionSpec + from vllm.v1.worker.workspace import init_workspace_manager + + device = torch.device("cuda:0") + + vllm_config = create_vllm_config( + model_name="deepseek-ai/DeepSeek-R1", + max_model_len=CONTEXT_LEN, + # One flat page per token (page_size=1); +buffer for the null block. + num_gpu_blocks=BATCH_SIZE * CONTEXT_LEN + 200, + block_size=PAGE_SIZE, + max_num_seqs=BATCH_SIZE, + max_num_batched_tokens=8192, + hf_config_override={"num_attention_heads": NUM_QUERY_HEADS}, + ) + vllm_config.cache_config.cache_dtype = "fp8" + + spec = MLAAttentionSpec( + block_size=PAGE_SIZE, + num_kv_heads=1, + head_size=vllm_config.model_config.get_head_size(), + dtype=vllm_config.model_config.dtype, + cache_dtype_str="fp8", + ) + + builder_cls = AttentionBackendEnum.ROCM_AITER_MLA.get_class().get_builder_cls() + + # The builder reads layer.prefill_backend from static_forward_context; a + # stub with the attribute is enough for metadata construction. + layer_name = "placeholder" + vllm_config.compilation_config.static_forward_context[layer_name] = ( + types.SimpleNamespace(prefill_backend=torch.empty((1,))) + ) + + init_workspace_manager(device) + + batch_spec = BatchSpec( + seq_lens=[CONTEXT_LEN] * BATCH_SIZE, + query_lens=[DECODE_QLEN] * BATCH_SIZE, + ) + + captured: dict = {} + + with set_current_vllm_config(vllm_config): + builder = builder_cls(spec, [layer_name], vllm_config, device) + common_attn_metadata = create_common_attn_metadata( + batch_spec, PAGE_SIZE, device, arange_block_indices=True + ) + + import aiter + + real_get_mla_metadata_v1 = aiter.get_mla_metadata_v1 + + def spy(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = dict(kwargs) + return real_get_mla_metadata_v1(*args, **kwargs) + + with patch("aiter.get_mla_metadata_v1", spy): + metadata = builder.build( + common_prefix_len=0, + common_attn_metadata=common_attn_metadata, + ) + + return metadata, captured + + +def _compute_golden_metadata(captured: dict) -> dict[str, torch.Tensor]: + """Recompute the persistent metadata with explicit fp8/bf16 dtypes. + + Replays ``get_mla_metadata_v1`` on the builder's exact input tensors with + fresh output buffers and the explicitly-correct dtypes. This reference must + match the builder's output when the fix is in place. + """ + import aiter + + args = captured["args"] + inputs = args[:_NUM_INPUT_ARGS] + # Fresh copies so the golden does not alias the builder's persistent buffers. + fresh_outputs = [arg.clone() for arg in args[_NUM_INPUT_ARGS:]] + + golden_kwargs = dict(captured["kwargs"]) + golden_kwargs["dtype_q"] = EXPECTED_Q_DTYPE + golden_kwargs["dtype_kv"] = EXPECTED_KV_DTYPE + + aiter.get_mla_metadata_v1(*inputs, *fresh_outputs, **golden_kwargs) + + return dict(zip(_OUTPUT_ARG_FIELDS, fresh_outputs)) + + +def test_persistent_decode_metadata_matches_fp8_golden(): + """The builder's metadata must match the dtype-correct golden. + + Regression guard: the fixed builder forwards fp8/bf16 dtypes so its + split/reduce metadata matches the golden recomputed with those explicit + dtypes. Dropping the dtypes (the original bug) produces a different layout + and fails this test. + """ + metadata, captured = _build_decode_metadata() + + # qlen=1 must take the persistent-metadata path for this to be meaningful. + assert metadata.decode is not None + assert metadata.decode.has_persistent_metadata + assert metadata.work_meta_data is not None + + golden = _compute_golden_metadata(captured) + + mismatched = [ + name + for name in _CONTENT_METADATA_FIELDS + if getattr(metadata, name).shape != golden[name].shape + or not torch.equal(getattr(metadata, name), golden[name]) + ] + assert not mismatched, ( + "AITER MLA persistent decode metadata does not match the fp8/bf16 " + f"golden for fields {mismatched}; the builder must forward " + "dtype_q/dtype_kv to get_mla_metadata_v1." + ) diff --git a/tests/kernels/attention/test_rocm_aiter_unified_attn.py b/tests/kernels/attention/test_rocm_aiter_unified_attn.py new file mode 100644 index 000000000000..9e33f24ea280 --- /dev/null +++ b/tests/kernels/attention/test_rocm_aiter_unified_attn.py @@ -0,0 +1,339 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""ROCm kernel correctness tests for AITER unified attention. + +Compares ``aiter.ops.triton.unified_attention`` against ``ref_paged_attn`` under +decode, prefill, and mixed batches with varied shapes. +""" + +from typing import Any, Literal + +import pytest +import torch + +from tests.kernels.attention.test_triton_unified_attention import ref_paged_attn +from vllm.platforms import current_platform +from vllm.utils.torch_utils import set_random_seed + +_SKIP_NON_MI3XX = True +if current_platform.is_rocm(): + from vllm.platforms.rocm import on_mi3xx + + _SKIP_NON_MI3XX = not on_mi3xx() + +pytestmark = [ + pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm-specific tests"), + pytest.mark.skipif(_SKIP_NON_MI3XX, reason="MI300/MI350 ROCm only"), +] + +NUM_Q_HEADS = 8 +NUM_KV_HEADS = 8 +HEAD_SIZES = [128, 256] +BLOCK_SIZES = [16, 64] +DTYPES = [torch.bfloat16, torch.float16] +FP8_DTYPE = current_platform.fp8_dtype() + +# (query_len, kv_len) per sequence +MIXED_SEQ_LENS = [ + [(1, 128), (5, 18), (129, 463)], + [(10, 256), (5, 64), (32, 128)], + [(1, 1024), (5, 18), (129, 1328)], +] +DECODE_SEQ_LENS = [ + [(1, 128), (1, 256), (1, 384), (1, 512)], + [(1, 1024), (1, 1536), (1, 2048)], +] +PREFILL_SEQ_LENS = [ + [(256, 256), (128, 512)], + [(64, 128), (32, 256), (16, 512)], + [(256, 1024), (128, 2048)], +] + +DEFAULT_ATOL, DEFAULT_RTOL = 1.5e-2, 1e-2 +FP8_ATOL, FP8_RTOL = 1.5e-1, 1.5e-1 +# Non-unity scale so q_descale handling is exercised explicitly. +Q_SCALE = 0.75 +K_SCALE, V_SCALE = 0.5, 0.25 + +Fp8Variant = Literal["fp8_kv", "fp8_query", "fp8_query_kv"] + +FP8_VARIANTS = [ + pytest.param("fp8_kv", id="fp8_kv"), + pytest.param("fp8_query", id="fp8_query"), + pytest.param("fp8_query_kv", id="fp8_query_kv"), +] + +FP8_SEQ_LENS = [ + MIXED_SEQ_LENS[0], + DECODE_SEQ_LENS[0], + DECODE_SEQ_LENS[1], + PREFILL_SEQ_LENS[0], + PREFILL_SEQ_LENS[2], +] + + +def _require_aiter() -> None: + from vllm._aiter_ops import is_aiter_found_and_supported + + if not is_aiter_found_and_supported(): + pytest.skip("aiter is required on supported ROCm hardware for this test") + + +def _make_case( + *, + seq_lens: list[tuple[int, int]], + head_size: int, + block_size: int, + dtype: torch.dtype, + num_blocks: int = 2048, + kv_cache_dtype: torch.dtype | None = None, + k_scale: float = 1.0, + v_scale: float = 1.0, + q_dtype: torch.dtype | None = None, + q_scale: float = Q_SCALE, +) -> dict[str, Any]: + torch.set_default_device("cuda") + + query_lens = [q for q, _ in seq_lens] + kv_lens = [k for _, k in seq_lens] + num_seqs = len(seq_lens) + max_query_len = max(query_lens) + max_kv_len = max(kv_lens) + scale = head_size**-0.5 + + query = torch.randn(sum(query_lens), NUM_Q_HEADS, head_size, dtype=dtype) + if kv_cache_dtype is None: + key_cache = torch.randn( + num_blocks, block_size, NUM_KV_HEADS, head_size, dtype=dtype + ) + value_cache = torch.randn_like(key_cache) + else: + key_cache = torch.clamp( + torch.randn(num_blocks, block_size, NUM_KV_HEADS, head_size), + -1.0, + 1.0, + ).to(kv_cache_dtype) + value_cache = torch.clamp( + torch.randn(num_blocks, block_size, NUM_KV_HEADS, head_size), + -1.0, + 1.0, + ).to(kv_cache_dtype) + + cu_seqlens_q = torch.tensor([0] + query_lens, dtype=torch.int32).cumsum( + dim=0, dtype=torch.int32 + ) + seq_lens_tensor = torch.tensor(kv_lens, dtype=torch.int32) + + max_num_blocks = (max_kv_len + block_size - 1) // block_size + block_tables = torch.randint( + 0, num_blocks, (num_seqs, max_num_blocks), dtype=torch.int32 + ) + + descale_shape = (num_seqs, NUM_KV_HEADS) + k_descale = torch.full(descale_shape, k_scale, dtype=torch.float32, device="cuda") + v_descale = torch.full(descale_shape, v_scale, dtype=torch.float32, device="cuda") + + kernel_query = query + q_descale = None + if q_dtype is not None: + q_descale = torch.tensor(q_scale, dtype=torch.float32, device="cuda") + kernel_query = (query / q_scale).to(q_dtype) + + return { + "query": query, + "kernel_query": kernel_query, + "key_cache": key_cache, + "value_cache": value_cache, + "block_tables": block_tables, + "query_lens": query_lens, + "kv_lens": kv_lens, + "seq_lens_tensor": seq_lens_tensor, + "cu_seqlens_q": cu_seqlens_q, + "q_descale": q_descale, + "k_descale": k_descale, + "v_descale": v_descale, + "scale": scale, + "max_query_len": max_query_len, + "max_kv_len": max_kv_len, + "query_dtype": dtype, + "k_scale": k_scale, + "v_scale": v_scale, + } + + +def _make_fp8_case( + *, + seq_lens: list[tuple[int, int]], + head_size: int, + block_size: int, + variant: Fp8Variant, +) -> dict[str, Any]: + use_fp8_kv = variant in ("fp8_kv", "fp8_query_kv") + use_fp8_query = variant in ("fp8_query", "fp8_query_kv") + return _make_case( + seq_lens=seq_lens, + head_size=head_size, + block_size=block_size, + dtype=torch.bfloat16, + kv_cache_dtype=FP8_DTYPE if use_fp8_kv else None, + k_scale=K_SCALE if use_fp8_kv else 1.0, + v_scale=V_SCALE if use_fp8_kv else 1.0, + q_dtype=FP8_DTYPE if use_fp8_query else None, + ) + + +def _run_aiter_unified_attention(case: dict[str, Any]) -> torch.Tensor: + from aiter.ops.triton.unified_attention import unified_attention + + kernel_query = case["kernel_query"] + # Kernel writes high-precision output even when Q is FP8 (matches vLLM usage). + output = torch.empty_like(case["query"]) + unified_attention( + q=kernel_query, + k=case["key_cache"], + v=case["value_cache"], + out=output, + cu_seqlens_q=case["cu_seqlens_q"], + max_seqlen_q=case["max_query_len"], + seqused_k=case["seq_lens_tensor"], + max_seqlen_k=case["max_kv_len"], + softmax_scale=case["scale"], + causal=True, + alibi_slopes=None, + window_size=(-1, -1), + block_table=case["block_tables"], + softcap=0, + q_descale=case["q_descale"], + k_descale=case["k_descale"], + v_descale=case["v_descale"], + sinks=None, + output_scale=None, + ) + return output + + +def _ref_output(case: dict[str, Any]) -> torch.Tensor: + key_cache = case["key_cache"] + value_cache = case["value_cache"] + if key_cache.dtype != case["query_dtype"]: + key_cache = key_cache.to(case["query_dtype"]) * case["k_scale"] + value_cache = value_cache.to(case["query_dtype"]) * case["v_scale"] + + return ref_paged_attn( + query=case["query"], + key_cache=key_cache, + value_cache=value_cache, + query_lens=case["query_lens"], + kv_lens=case["kv_lens"], + block_tables=case["block_tables"], + scale=case["scale"], + ) + + +def _assert_matches_reference( + case: dict[str, Any], + *, + atol: float = DEFAULT_ATOL, + rtol: float = DEFAULT_RTOL, +) -> None: + output = _run_aiter_unified_attention(case) + output_ref = _ref_output(case) + torch.testing.assert_close(output, output_ref, atol=atol, rtol=rtol) + + +@pytest.mark.parametrize("seq_lens", MIXED_SEQ_LENS) +@pytest.mark.parametrize("head_size", HEAD_SIZES) +@pytest.mark.parametrize("block_size", BLOCK_SIZES) +@pytest.mark.parametrize("dtype", DTYPES) +@torch.inference_mode() +def test_aiter_unified_attn_mixed_batch( + seq_lens: list[tuple[int, int]], + head_size: int, + block_size: int, + dtype: torch.dtype, +) -> None: + """Decode + prefill sequences in one batch (native dtypes).""" + _require_aiter() + set_random_seed(0) + + case = _make_case( + seq_lens=seq_lens, + head_size=head_size, + block_size=block_size, + dtype=dtype, + ) + _assert_matches_reference(case) + + +@pytest.mark.parametrize("seq_lens", DECODE_SEQ_LENS) +@pytest.mark.parametrize("head_size", HEAD_SIZES) +@pytest.mark.parametrize("block_size", BLOCK_SIZES) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@torch.inference_mode() +def test_aiter_unified_attn_decode( + seq_lens: list[tuple[int, int]], + head_size: int, + block_size: int, + dtype: torch.dtype, +) -> None: + """Single-token decode (native dtypes).""" + _require_aiter() + set_random_seed(0) + + case = _make_case( + seq_lens=seq_lens, + head_size=head_size, + block_size=block_size, + dtype=dtype, + ) + _assert_matches_reference(case) + + +@pytest.mark.parametrize("seq_lens", PREFILL_SEQ_LENS) +@pytest.mark.parametrize("head_size", [128]) +@pytest.mark.parametrize("block_size", [16]) +@torch.inference_mode() +def test_aiter_unified_attn_prefill( + seq_lens: list[tuple[int, int]], + head_size: int, + block_size: int, +) -> None: + """Prefill-only batches with query_len > 1 (native dtypes).""" + _require_aiter() + set_random_seed(0) + + case = _make_case( + seq_lens=seq_lens, + head_size=head_size, + block_size=block_size, + dtype=torch.bfloat16, + ) + _assert_matches_reference(case) + + +@pytest.mark.skipif( + not current_platform.supports_fp8(), + reason="FP8 not supported on this hardware", +) +@pytest.mark.parametrize("variant", FP8_VARIANTS) +@pytest.mark.parametrize("seq_lens", FP8_SEQ_LENS) +@pytest.mark.parametrize("head_size", [128]) +@pytest.mark.parametrize("block_size", [16, 64]) +@torch.inference_mode() +def test_aiter_unified_attn_fp8( + variant: Fp8Variant, + seq_lens: list[tuple[int, int]], + head_size: int, + block_size: int, +) -> None: + """FP8 KV cache, FP8 query, or both; compared at bf16 reference precision.""" + _require_aiter() + set_random_seed(0) + + case = _make_fp8_case( + seq_lens=seq_lens, + head_size=head_size, + block_size=block_size, + variant=variant, + ) + _assert_matches_reference(case, atol=FP8_ATOL, rtol=FP8_RTOL) diff --git a/tests/kernels/attention/test_rocm_triton_attn_dsv4.py b/tests/kernels/attention/test_rocm_triton_attn_dsv4.py index d4fa9697cb7f..e00726f64d80 100644 --- a/tests/kernels/attention/test_rocm_triton_attn_dsv4.py +++ b/tests/kernels/attention/test_rocm_triton_attn_dsv4.py @@ -10,6 +10,25 @@ not current_platform.is_rocm(), reason="Only used by ROCm" ) + +def _on_gfx950() -> bool: + if not current_platform.is_rocm(): + return False + try: + from vllm.platforms.rocm import _ON_GFX950 + + return bool(_ON_GFX950) + except Exception: + return False + + +# The flash-decode split-K decode path is only tuned for AMD gfx950; other +# architectures take the fallback decode kernel, so its tests are skipped there. +requires_gfx950 = pytest.mark.skipif( + not _on_gfx950(), + reason="split-K decode kernel is only tuned for AMD gfx950", +) + NOPE_HEAD_DIM = 448 ROPE_HEAD_DIM = 64 HEAD_DIM = NOPE_HEAD_DIM + ROPE_HEAD_DIM @@ -71,7 +90,9 @@ def _ref_sparse_prefill_ragged( return out.to(torch.bfloat16) -def _pack_fp8_ds_mla_cache(kv: torch.Tensor, block_size: int) -> torch.Tensor: +def _pack_fp8_ds_mla_cache( + kv: torch.Tensor, block_size: int, is_extra: bool = False +) -> torch.Tensor: assert kv.shape[-1] == HEAD_DIM num_tokens = kv.shape[0] num_blocks = (num_tokens + block_size - 1) // block_size @@ -82,7 +103,9 @@ def _pack_fp8_ds_mla_cache(kv: torch.Tensor, block_size: int) -> torch.Tensor: ) cache_flat = cache.view(torch.uint8).flatten() kv_nope_fp8 = ( - kv[:, :NOPE_HEAD_DIM].to(current_platform.fp8_dtype()).view(torch.uint8) + kv[:, :NOPE_HEAD_DIM] + .to(torch.float8_e4m3fn if is_extra else current_platform.fp8_dtype()) + .view(torch.uint8) ) kv_rope_u8 = kv[:, NOPE_HEAD_DIM:].contiguous().view(torch.uint8) @@ -101,7 +124,7 @@ def _pack_fp8_ds_mla_cache(kv: torch.Tensor, block_size: int) -> torch.Tensor: def _read_fp8_ds_mla_cache( - cache: torch.Tensor, slot: int, block_size: int + cache: torch.Tensor, slot: int, block_size: int, is_extra: bool = False ) -> torch.Tensor: cache_flat = cache.view(torch.uint8).flatten() block_idx = slot // block_size @@ -110,7 +133,9 @@ def _read_fp8_ds_mla_cache( token_base = block_base + pos * 576 nope_u8 = cache_flat[token_base : token_base + NOPE_HEAD_DIM] - nope = nope_u8.view(current_platform.fp8_dtype()).to(torch.float32) + nope = nope_u8.view( + torch.float8_e4m3fn if is_extra else current_platform.fp8_dtype() + ).to(torch.float32) rope_u8 = cache_flat[ token_base + NOPE_HEAD_DIM : token_base + NOPE_HEAD_DIM + ROPE_HEAD_DIM * 2 ] @@ -138,7 +163,9 @@ def _ref_sparse_decode_ragged( ] if extra_cache is not None and extra_rows is not None: row_kv.extend( - _read_fp8_ds_mla_cache(extra_cache, int(slot), block_size) + _read_fp8_ds_mla_cache( + extra_cache, int(slot), block_size, is_extra=True + ) for slot in extra_rows[query_idx] ) @@ -156,6 +183,20 @@ def _ref_sparse_decode_ragged( return out.to(torch.bfloat16) +def _ragged_from_rows( + rows: list[list[int]], device: torch.device +) -> tuple[torch.Tensor, torch.Tensor]: + """Flatten per-query slot lists into ragged (indices, indptr) tensors.""" + flat = [slot for row in rows for slot in row] + indptr = [0] + for row in rows: + indptr.append(indptr[-1] + len(row)) + return ( + torch.tensor(flat, dtype=torch.int32, device=device), + torch.tensor(indptr, dtype=torch.int32, device=device), + ) + + def _ref_combine_topk_swa_ragged( device: torch.device, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: @@ -293,7 +334,7 @@ def test_sparse_attn_decode_ragged_kernel() -> None: main_kv = torch.randn(6, HEAD_DIM, dtype=torch.bfloat16, device=device) * 0.125 extra_kv = torch.randn(5, HEAD_DIM, dtype=torch.bfloat16, device=device) * 0.125 main_cache = _pack_fp8_ds_mla_cache(main_kv, block_size) - extra_cache = _pack_fp8_ds_mla_cache(extra_kv, block_size) + extra_cache = _pack_fp8_ds_mla_cache(extra_kv, block_size, is_extra=True) main_indices = torch.tensor([0, 2, 4, 1], dtype=torch.int32, device=device) main_indptr = torch.tensor([0, 2, 4], dtype=torch.int32, device=device) extra_indices = torch.tensor([1, 3, 0], dtype=torch.int32, device=device) @@ -375,3 +416,325 @@ def test_combine_topk_swa_indices_ragged() -> None: ) torch.testing.assert_close(actual_indptr, expected_indptr) torch.testing.assert_close(actual_lens, expected_lens) + + +@requires_gfx950 +@torch.inference_mode() +def test_decode_num_splits_heuristic(monkeypatch) -> None: + """Split-count heuristic added with the flash-decode split-K decode path.""" + from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod + + # Pin the CU count so the heuristic is deterministic off-device. + monkeypatch.setattr(mod, "_decode_cu_count", lambda: 256) + + # A batch that already fills the device should not be split. + assert mod._decode_num_splits(256, 1, avg_main_len=128.0, avg_extra_len=0.0) == 1 + # A tiny batch on a large device should split to add parallelism. + assert mod._decode_num_splits(2, 1, avg_main_len=256.0, avg_extra_len=0.0) > 1 + + # The chosen count always stays within the searched [1, 16] range, and a + # zero-length workload never splits (no work to parallelize). + for num_queries in (1, 4, 24, 224, 1024): + splits = mod._decode_num_splits( + num_queries, 1, avg_main_len=512.0, avg_extra_len=128.0 + ) + assert 1 <= splits <= 16 + assert mod._decode_num_splits(2, 1, avg_main_len=0.0, avg_extra_len=0.0) >= 1 + + +@requires_gfx950 +@pytest.mark.parametrize("num_splits", [1, 2, 3, 4, 8]) +@pytest.mark.parametrize("with_extra", [True, False]) +@pytest.mark.parametrize("with_sink", [True, False]) +@torch.inference_mode() +def test_sparse_attn_decode_split_k_kernel( + monkeypatch, num_splits: int, with_extra: bool, with_sink: bool +) -> None: + """Flash-decode split-K decode path (partial + reduce kernels). + + This path is the gfx950 production path (``_ON_GFX950``), so the test only + runs on gfx950. The split count is pinned so the partial/reduce kernels are + exercised across split counts. ``num_splits=8`` drives splits past the + shortest segment length, covering the empty-split edge case handled by the + reduce kernel. + """ + from vllm.v1.attention.ops import rocm_aiter_mla_sparse as mod + + device = torch.device("cuda") + torch.manual_seed(7) + block_size = 4 + num_heads = 3 + + main_rows = [[0, 2, 4, 6, 1, 3, 7, 5], [4, 1, 6, 0, 2]] + num_queries = len(main_rows) + q = ( + torch.randn( + num_queries, num_heads, HEAD_DIM, dtype=torch.bfloat16, device=device + ) + * 0.125 + ) + main_kv = torch.randn(8, HEAD_DIM, dtype=torch.bfloat16, device=device) * 0.125 + main_cache = _pack_fp8_ds_mla_cache(main_kv, block_size) + main_indices, main_indptr = _ragged_from_rows(main_rows, device) + + extra_rows: list[list[int]] | None = None + extra_cache: torch.Tensor | None = None + extra_indices: torch.Tensor | None = None + extra_indptr: torch.Tensor | None = None + if with_extra: + rows = [[1, 3, 0, 5, 2, 4], [3, 0, 6]] + extra_kv = torch.randn(7, HEAD_DIM, dtype=torch.bfloat16, device=device) * 0.125 + extra_rows = rows + extra_cache = _pack_fp8_ds_mla_cache(extra_kv, block_size, is_extra=True) + extra_indices, extra_indptr = _ragged_from_rows(rows, device) + + attn_sink = ( + torch.tensor([-0.1, 0.0, 0.1], dtype=torch.float32, device=device) + if with_sink + else None + ) + scale = HEAD_DIM**-0.5 + + # Pin the split count so each parametrized value is exercised deterministically. + monkeypatch.setattr(mod, "_decode_num_splits", lambda *args, **kwargs: num_splits) + + actual = mod._rocm_sparse_attn_decode_ragged_triton( + q=q, + main_cache=main_cache, + main_indices=main_indices, + main_indptr=main_indptr, + scale=scale, + attn_sink=attn_sink, + nope_head_dim=NOPE_HEAD_DIM, + rope_head_dim=ROPE_HEAD_DIM, + extra_cache=extra_cache, + extra_indices=extra_indices, + extra_indptr=extra_indptr, + ) + expected = _ref_sparse_decode_ragged( + q=q, + main_cache=main_cache, + main_rows=main_rows, + scale=scale, + attn_sink=attn_sink, + block_size=block_size, + extra_cache=extra_cache, + extra_rows=extra_rows, + ) + + torch.testing.assert_close(actual, expected, atol=2e-2, rtol=2e-2) + + +# --------------------------------------------------------------------------- +# o-projection: fused inverse-RoPE + cached bf16 wo_a (rocm_inv_rope_einsum) +# --------------------------------------------------------------------------- + + +# Cache rows = max_position_embeddings * scaling_factor. +_ROTARY_MAX_POS = 1024 +_ROTARY_SCALING_FACTOR = 4.0 +_ROTARY_CACHE_LEN = int(_ROTARY_MAX_POS * _ROTARY_SCALING_FACTOR) + + +def _make_dsv4_rotary(device: torch.device): + """The official DSv4 rotary embedding, sized down for unit tests.""" + from vllm.model_executor.layers.rotary_embedding.deepseek_scaling_rope import ( + DeepseekV4ScalingRotaryEmbedding, + ) + + # The model loader constructs layers under a default-device context; + # mirror that so the fp32 cos_sin_cache lands on the GPU. + with torch.device(device): + rotary_emb = DeepseekV4ScalingRotaryEmbedding( + head_size=ROPE_HEAD_DIM, + rotary_dim=ROPE_HEAD_DIM, + max_position_embeddings=_ROTARY_MAX_POS, + base=10000, + is_neox_style=False, + scaling_factor=_ROTARY_SCALING_FACTOR, + dtype=torch.bfloat16, + mscale=1.0, + mscale_all_dim=1.0, + ) + rotary_emb = rotary_emb.to(device) + assert rotary_emb.cos_sin_cache.shape == (_ROTARY_CACHE_LEN, ROPE_HEAD_DIM) + return rotary_emb + + +def _inv_rope_via_rotary_native( + rotary_emb: torch.nn.Module, + o: torch.Tensor, + positions: torch.Tensor, +) -> torch.Tensor: + """Reference: the official ``forward_native(inverse=True)`` path.""" + expected, _ = rotary_emb.forward_native(positions, o.clone(), None, inverse=True) + return expected.to(torch.bfloat16) + + +class _FakeWoA(torch.nn.Module): + """Stand-in for the wo_a linear layer holding the (optionally fp8) weight.""" + + def __init__( + self, weight: torch.Tensor, weight_scale_inv: torch.Tensor | None = None + ) -> None: + super().__init__() + self.weight = weight + if weight_scale_inv is not None: + self.weight_scale_inv = weight_scale_inv + + +@pytest.mark.parametrize("num_tokens", [1, 7, 64]) +@pytest.mark.parametrize("num_heads", [1, 8]) +@pytest.mark.parametrize("pos_dtype", [torch.int32, torch.int64]) +@torch.inference_mode() +def test_fused_inverse_rope_gptj_matches_rotary_native( + num_tokens: int, num_heads: int, pos_dtype: torch.dtype, default_vllm_config +) -> None: + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import _fused_inverse_rope_gptj + + device = torch.device("cuda") + torch.manual_seed(0) + rotary_emb = _make_dsv4_rotary(device) + o = torch.randn( + num_tokens, num_heads, HEAD_DIM, dtype=torch.bfloat16, device=device + ) + positions = torch.randint( + 0, _ROTARY_CACHE_LEN, (num_tokens,), dtype=pos_dtype, device=device + ) + + actual = _fused_inverse_rope_gptj( + o, positions, rotary_emb.cos_sin_cache, ROPE_HEAD_DIM + ) + expected = _inv_rope_via_rotary_native(rotary_emb, o, positions) + + assert actual.dtype == torch.bfloat16 + assert actual.shape == o.shape + # NoPE lanes are a pure bf16 passthrough -> must be bit-exact. + assert torch.equal(actual[..., :NOPE_HEAD_DIM], expected[..., :NOPE_HEAD_DIM]) + # RoPE lanes: tolerate at most ~1 bf16 ulp from fp32 fma ordering. + torch.testing.assert_close(actual, expected, atol=2e-2, rtol=2e-2) + + +@torch.inference_mode() +def test_fused_inverse_rope_gptj_empty(default_vllm_config) -> None: + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import _fused_inverse_rope_gptj + + device = torch.device("cuda") + rotary_emb = _make_dsv4_rotary(device) + o = torch.empty(0, 8, HEAD_DIM, dtype=torch.bfloat16, device=device) + positions = torch.empty(0, dtype=torch.int32, device=device) + + out = _fused_inverse_rope_gptj( + o, positions, rotary_emb.cos_sin_cache, ROPE_HEAD_DIM + ) + assert out.shape == (0, 8, HEAD_DIM) + assert out.dtype == torch.bfloat16 + + +@torch.inference_mode() +def test_rocm_inv_rope_einsum_matches_rotary_native(default_vllm_config) -> None: + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import rocm_inv_rope_einsum + + device = torch.device("cuda") + torch.manual_seed(2) + num_tokens, num_heads = 5, 8 + n_local_groups = num_heads + o_lora_rank = 16 + hidden_dim = num_heads * HEAD_DIM // n_local_groups # 512 + + rotary_emb = _make_dsv4_rotary(device) + o = ( + torch.randn( + num_tokens, num_heads, HEAD_DIM, dtype=torch.bfloat16, device=device + ) + * 0.125 + ) + positions = torch.randint( + 0, _ROTARY_CACHE_LEN, (num_tokens,), dtype=torch.int32, device=device + ) + weight = ( + torch.randn(n_local_groups * o_lora_rank, hidden_dim, device=device) * 0.125 + ).to(torch.bfloat16) + wo_a = _FakeWoA(weight) + + actual = rocm_inv_rope_einsum( + rotary_emb, o, positions, ROPE_HEAD_DIM, n_local_groups, o_lora_rank, wo_a + ) + + o_ref = _inv_rope_via_rotary_native(rotary_emb, o, positions) + o_ref = o_ref.view(num_tokens, n_local_groups, -1) + wo_a_ref = weight.view(n_local_groups, o_lora_rank, hidden_dim).to(torch.bfloat16) + expected = torch.einsum("tgd,grd->tgr", o_ref, wo_a_ref) + + assert actual.shape == (num_tokens, n_local_groups, o_lora_rank) + torch.testing.assert_close(actual, expected, atol=2e-2, rtol=2e-2) + + +@torch.inference_mode() +def test_get_cached_wo_a_bf16_plain_caches() -> None: + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import _get_cached_wo_a_bf16 + + device = torch.device("cuda") + torch.manual_seed(4) + n_local_groups, o_lora_rank, hidden_dim = 2, 4, 8 + weight = torch.randn( + n_local_groups * o_lora_rank, hidden_dim, dtype=torch.bfloat16, device=device + ) + wo_a = _FakeWoA(weight) + + out1 = _get_cached_wo_a_bf16(wo_a, n_local_groups, o_lora_rank, hidden_dim) + expected = weight.view(n_local_groups, o_lora_rank, hidden_dim).to(torch.bfloat16) + assert out1.shape == (n_local_groups, o_lora_rank, hidden_dim) + torch.testing.assert_close(out1, expected, atol=0, rtol=0) + assert hasattr(wo_a, "_dsv4_wo_a_bf16") + + # Mutate the source weight: the cached tensor must be returned unchanged + # (proving the dequant is not recomputed per call). + wo_a.weight.zero_() + out2 = _get_cached_wo_a_bf16(wo_a, n_local_groups, o_lora_rank, hidden_dim) + assert out2 is out1 + torch.testing.assert_close(out2, expected, atol=0, rtol=0) + + +@torch.inference_mode() +def test_get_cached_wo_a_bf16_fp8_blockscale_caches() -> None: + from vllm.v1.attention.ops.rocm_aiter_mla_sparse import _get_cached_wo_a_bf16 + + device = torch.device("cuda") + torch.manual_seed(5) + n_local_groups, o_lora_rank, hidden_dim = 2, 4, 8 + row_block, col_block = 2, 2 + row_blocks = o_lora_rank // row_block + col_blocks = hidden_dim // col_block + + fp8_dtype = current_platform.fp8_dtype() + weight_f32 = ( + torch.randn( + n_local_groups, o_lora_rank, hidden_dim, dtype=torch.float32, device=device + ) + * 0.1 + ) + weight_fp8 = weight_f32.to(fp8_dtype) + scale = ( + torch.rand( + n_local_groups, row_blocks, col_blocks, dtype=torch.float32, device=device + ) + * 0.5 + + 0.5 + ) + wo_a = _FakeWoA( + weight_fp8.reshape(n_local_groups * o_lora_rank, hidden_dim), + weight_scale_inv=scale.reshape(n_local_groups * row_blocks, col_blocks), + ) + + out = _get_cached_wo_a_bf16(wo_a, n_local_groups, o_lora_rank, hidden_dim) + + scale_full = scale.repeat_interleave(row_block, dim=-2).repeat_interleave( + col_block, dim=-1 + ) + expected = (weight_fp8.to(torch.float32) * scale_full).to(torch.bfloat16) + assert out.shape == (n_local_groups, o_lora_rank, hidden_dim) + torch.testing.assert_close(out, expected, atol=0, rtol=0) + + # Second call returns the same cached object. + assert _get_cached_wo_a_bf16(wo_a, n_local_groups, o_lora_rank, hidden_dim) is out diff --git a/tests/kernels/attention/test_triton_decode_attention.py b/tests/kernels/attention/test_triton_decode_attention.py index 81e8bb17e7bc..b4b17d9b5ce8 100644 --- a/tests/kernels/attention/test_triton_decode_attention.py +++ b/tests/kernels/attention/test_triton_decode_attention.py @@ -231,3 +231,95 @@ def test_decode_attention_fp8(B, L, H_Q, H_KV, D_QK, D_V, CACHE_SIZE, PAGE_SIZE) # FP8 tolerances match test_mla_backends.py test_backend_correctness. torch.testing.assert_close(o_ref, o_fp8, atol=5e-1, rtol=1e-2) + + +@pytest.mark.parametrize( + "H_Q,H_KV,D_QK,D_V,is_mla", + [ + (16, 1, 576, 512, True), # MLA path (grouped kernel, v = trans(k)) + (32, 8, 128, 128, False), # GQA path (grouped kernel) + (32, 32, 128, 128, False), # MHA path (normal kernel) + ], +) +@pytest.mark.parametrize("PAGE_SIZE", [16]) +def test_decode_attention_cross_layer_view(H_Q, H_KV, D_QK, D_V, is_mla, PAGE_SIZE): + """The kernel must honor the cache's page-dim stride, not assume pages are + packed back-to-back. A per-layer view into a cross-layer (block-major) + cache has stride(0) inflated by num_layers; outputs must match a + contiguous cache holding the same data exactly.""" + B = 3 + seq_len = 1027 + CACHE_SIZE = 16384 + NUM_LAYERS = 3 + LAYER_IDX = 1 + dtype = torch.bfloat16 + sm_scale = 1.0 / (D_QK**0.5) + num_kv_splits = 8 + num_pages = CACHE_SIZE // PAGE_SIZE + + num_pages_per_batch = cdiv(seq_len, PAGE_SIZE) + req_to_page = torch.randint( + 0, num_pages, (B, num_pages_per_batch), device=DEVICE_TYPE + ) + + q = torch.randn(B, H_Q, D_QK, dtype=dtype, device=DEVICE_TYPE) + b_seq_len = torch.full((B,), seq_len, device=DEVICE_TYPE) + + # Reference: contiguous paged cache. + k_ref = torch.randn( + num_pages, PAGE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE + ) + if is_mla: + v_ref = k_ref[..., :D_V] + else: + v_ref = torch.randn( + num_pages, PAGE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE + ) + + # Cross-layer cache: all layers' pages for a block are adjacent. The + # per-layer view has the same shape as the contiguous cache but + # stride(0) is NUM_LAYERS x larger. Neighbor layers hold random data so + # any packed-pages addressing reads garbage rather than zeros. + k_xl = torch.randn( + num_pages, NUM_LAYERS, PAGE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE + ) + k_view = k_xl[:, LAYER_IDX] + k_view.copy_(k_ref) + assert k_view.stride(0) == NUM_LAYERS * PAGE_SIZE * H_KV * D_QK + if is_mla: + v_view = k_view[..., :D_V] + else: + v_xl = torch.randn( + num_pages, NUM_LAYERS, PAGE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE + ) + v_view = v_xl[:, LAYER_IDX] + v_view.copy_(v_ref) + + def run(k_buffer, v_buffer): + o = torch.zeros(B, H_Q, D_V, dtype=dtype, device=DEVICE_TYPE) + lse = torch.zeros(B, H_Q, dtype=dtype, device=DEVICE_TYPE) + attn_logits = torch.empty( + (B, H_Q, num_kv_splits, D_V + 1), dtype=torch.float32, device=DEVICE_TYPE + ) + decode_attention_fwd( + q, + k_buffer, + v_buffer, + o, + lse, + req_to_page, + b_seq_len, + attn_logits, + num_kv_splits, + sm_scale, + PAGE_SIZE, + is_mla=is_mla, + ) + return o, lse + + o_ref, lse_ref = run(k_ref, v_ref) + o_xl, lse_xl = run(k_view, v_view) + + # Same data and same compute order; only addressing differs. + assert torch.equal(o_ref, o_xl) + assert torch.equal(lse_ref, lse_xl) diff --git a/tests/kernels/attention/test_triton_unified_attention.py b/tests/kernels/attention/test_triton_unified_attention.py index 6440ba3156e3..d3435ea665db 100644 --- a/tests/kernels/attention/test_triton_unified_attention.py +++ b/tests/kernels/attention/test_triton_unified_attention.py @@ -18,11 +18,7 @@ BLOCK_SIZES = [16] DTYPES = [torch.bfloat16] -QDTYPES = ( - [None, torch.float8_e4m3fn] - if not current_platform.is_rocm() - else [None, torch.float8_e4m3fnuz] -) +QDTYPES = [None, current_platform.fp8_dtype()] FP8_DTYPE = current_platform.fp8_dtype() # one value large enough to test overflow in index calculation. diff --git a/tests/kernels/attention/test_triton_unified_attention_diffkv.py b/tests/kernels/attention/test_triton_unified_attention_diffkv.py new file mode 100644 index 000000000000..1a19cf34379c --- /dev/null +++ b/tests/kernels/attention/test_triton_unified_attention_diffkv.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Unit tests for the Triton DiffKV unified-attention kernel. +""" + +import pytest +import torch + +from vllm.platforms import current_platform +from vllm.utils.math_utils import next_power_of_2 +from vllm.utils.torch_utils import ( + canonicalize_singleton_dim_strides, + set_random_seed, +) +from vllm.v1.attention.backends.fa_utils import ( + get_flash_attn_version, + is_flash_attn_varlen_func_available, +) +from vllm.v1.attention.ops.triton_unified_attention_diffkv import ( + unified_attention_diffkv, +) + +DEVICE_TYPE = current_platform.device_type + +# (num_query_heads, num_kv_heads): MHA, GQA, and the num_kv_heads==1 +# (degenerate-stride) case. +NUM_HEADS = [(4, 4), (8, 2), (5, 1)] +# (head_size_qk, head_size_v). (192, 128) is the canonical asymmetric +# DiffKV shape; FA4 on Blackwell only supports head_size>128 when it is +# 192, and FA3 on Hopper supports it too -- so this pair is runnable on +# both. (128, 128) keeps the equal-dim path covered through the DiffKV +# kernel. +HEAD_SIZES = [(128, 128), (192, 128)] +BLOCK_SIZES = [16] +DTYPES = [torch.bfloat16] + +NUM_BLOCKS = 2048 + +# 0: 2D decode kernel; 8: 3D (split-KV) decode kernel. +SEQ_THRESHOLD_3D_VALUES = [0, 8] + +NUM_PAR_SOFTMAX_SEGMENTS = 16 + + +def _alloc_segm_buffers(seq_threshold_3D: int, num_query_heads: int, head_size_v: int): + """Allocate the split-KV softmax scratch (last dim == head_size_v).""" + head_size_v_padded = next_power_of_2(head_size_v) + segm_output = torch.empty( + ( + seq_threshold_3D, + num_query_heads, + NUM_PAR_SOFTMAX_SEGMENTS, + head_size_v_padded, + ), + dtype=torch.float32, + ) + segm_max = torch.empty( + (seq_threshold_3D, num_query_heads, NUM_PAR_SOFTMAX_SEGMENTS), + dtype=torch.float32, + ) + segm_expsum = torch.empty( + (seq_threshold_3D, num_query_heads, NUM_PAR_SOFTMAX_SEGMENTS), + dtype=torch.float32, + ) + return segm_output, segm_max, segm_expsum + + +@pytest.mark.parametrize( + "seq_lens", + [ + [(1, 1328), (5, 18), (129, 463)], # mixed prefill + decode + [(1, 523), (1, 37), (1, 2011)], # decode-only (exercises 3D path) + ], +) +@pytest.mark.parametrize("num_heads", NUM_HEADS) +@pytest.mark.parametrize("head_sizes", HEAD_SIZES) +@pytest.mark.parametrize("block_size", BLOCK_SIZES) +@pytest.mark.parametrize("sliding_window", [None, 128]) +@pytest.mark.parametrize("soft_cap", [None, 50.0]) +@pytest.mark.parametrize("dtype", DTYPES) +@pytest.mark.parametrize("seq_threshold_3D", SEQ_THRESHOLD_3D_VALUES) +@torch.inference_mode() +def test_triton_unified_attn_diffkv_vs_fa( + seq_lens: list[tuple[int, int]], + num_heads: tuple[int, int], + head_sizes: tuple[int, int], + sliding_window: int | None, + soft_cap: float | None, + dtype: torch.dtype, + block_size: int, + seq_threshold_3D: int, +) -> None: + head_size_qk, head_size_v = head_sizes + + # DiffKV requires FA3 (Hopper) / FA4 (Blackwell) as the reference. + fa_version = get_flash_attn_version(head_size=head_size_qk, head_size_v=head_size_v) + if not is_flash_attn_varlen_func_available() or fa_version not in (3, 4): + pytest.skip(f"FA DiffKV needs FA3/FA4 (got version {fa_version}).") + + from vllm.v1.attention.backends.fa_utils import flash_attn_varlen_func + + torch.set_default_device(DEVICE_TYPE) + set_random_seed(0) + + num_seqs = len(seq_lens) + query_lens = [x[0] for x in seq_lens] + kv_lens = [x[1] for x in seq_lens] + num_query_heads, num_kv_heads = num_heads + assert num_query_heads % num_kv_heads == 0 + max_query_len = max(query_lens) + max_kv_len = max(kv_lens) + window_size = (sliding_window - 1, 0) if sliding_window is not None else (-1, -1) + scale = head_size_qk**-0.5 + + query = torch.randn(sum(query_lens), num_query_heads, head_size_qk, dtype=dtype) + # Packed KV cache: [num_blocks, block_size, num_kv_heads, hqk + hv]. + kv_cache = torch.randn( + NUM_BLOCKS, + block_size, + num_kv_heads, + head_size_qk + head_size_v, + dtype=dtype, + ) + key_cache = kv_cache[..., :head_size_qk] + value_cache = kv_cache[..., head_size_qk:] + + cu_query_lens = torch.tensor([0] + query_lens, dtype=torch.int32).cumsum( + dim=0, dtype=torch.int32 + ) + kv_lens_t = torch.tensor(kv_lens, dtype=torch.int32) + + max_num_blocks_per_seq = (max_kv_len + block_size - 1) // block_size + block_tables = torch.randint( + 0, NUM_BLOCKS, (num_seqs, max_num_blocks_per_seq), dtype=torch.int32 + ) + + # ---- FlashAttention DiffKV (ground truth) --------------------------- + # Mirror the backend: fix degenerate strides on size-1 dims so FA's + # TMA path sees ≥16-byte-aligned strides (matters for num_kv_heads==1). + fa_k = canonicalize_singleton_dim_strides(key_cache) + fa_v = canonicalize_singleton_dim_strides(value_cache) + fa_out = torch.empty(sum(query_lens), num_query_heads, head_size_v, dtype=dtype) + flash_attn_varlen_func( + q=query, + k=fa_k, + v=fa_v, + out=fa_out, + cu_seqlens_q=cu_query_lens, + max_seqlen_q=max_query_len, + seqused_k=kv_lens_t, + max_seqlen_k=max_kv_len, + softmax_scale=scale, + causal=True, + window_size=list(window_size), + block_table=block_tables, + softcap=soft_cap if soft_cap is not None else 0, + fa_version=fa_version, + ) + + # ---- Triton DiffKV -------------------------------------------------- + segm_output, segm_max, segm_expsum = _alloc_segm_buffers( + seq_threshold_3D, num_query_heads, head_size_v + ) + triton_out = torch.empty(sum(query_lens), num_query_heads, head_size_v, dtype=dtype) + unified_attention_diffkv( + q=query, + k=key_cache, + v=value_cache, + out=triton_out, + cu_seqlens_q=cu_query_lens, + seqused_k=kv_lens_t, + softmax_scale=scale, + causal=True, + window_size=window_size, + block_table=block_tables, + softcap=soft_cap if soft_cap is not None else 0, + max_seqlen_q=max_query_len, + seq_threshold_3D=seq_threshold_3D, + num_par_softmax_segments=NUM_PAR_SOFTMAX_SEGMENTS, + softmax_segm_output=segm_output, + softmax_segm_max=segm_max, + softmax_segm_expsum=segm_expsum, + ) + + ( + torch.testing.assert_close(triton_out, fa_out, atol=2e-2, rtol=2e-2), + f"triton vs FA max abs diff: {torch.max(torch.abs(triton_out - fa_out))}", + ) diff --git a/tests/kernels/attention/test_use_trtllm_attention.py b/tests/kernels/attention/test_use_trtllm_attention.py index fba18fe46e3d..136774bc74f7 100644 --- a/tests/kernels/attention/test_use_trtllm_attention.py +++ b/tests/kernels/attention/test_use_trtllm_attention.py @@ -6,12 +6,19 @@ import pytest import torch +from vllm.platforms import current_platform from vllm.utils.flashinfer import ( can_use_trtllm_attention, supports_trtllm_attention, use_trtllm_attention, ) +if not current_platform.is_cuda(): + pytest.skip( + "TRTLLM attention is only supported on CUDA platforms.", + allow_module_level=True, + ) + MODEL_CONFIGS = { "Llama-3-70B": dict(num_qo_heads=64, num_kv_heads=8), "Llama-3-8B": dict(num_qo_heads=32, num_kv_heads=8), @@ -71,14 +78,29 @@ def test_supports_sm100_with_artifactory(_art, _cap): @patch("vllm.envs.VLLM_BATCH_INVARIANT", False) +@patch( + "vllm.utils.flashinfer.current_platform.is_device_capability", return_value=False +) @patch( "vllm.utils.flashinfer.current_platform.is_device_capability_family", return_value=False, ) -def test_supports_non_sm100_platform(_cap): +def test_supports_unsupported_platform(_family, _cap): assert supports_trtllm_attention() is False +@patch("vllm.envs.VLLM_BATCH_INVARIANT", False) +@patch("vllm.utils.flashinfer.current_platform.is_device_capability", return_value=True) +@patch( + "vllm.utils.flashinfer.current_platform.is_device_capability_family", + return_value=False, +) +@patch("vllm.utils.flashinfer.has_nvidia_artifactory", return_value=True) +def test_supports_sm90_decode_only(_art, _family, _cap): + assert supports_trtllm_attention(is_prefill=False) is True + assert supports_trtllm_attention(is_prefill=True) is False + + @patch("vllm.envs.VLLM_BATCH_INVARIANT", False) @patch( "vllm.utils.flashinfer.current_platform.is_device_capability_family", diff --git a/tests/kernels/core/test_batched_weight_rms_norm.py b/tests/kernels/core/test_batched_weight_rms_norm.py new file mode 100644 index 000000000000..42711fdf09e6 --- /dev/null +++ b/tests/kernels/core/test_batched_weight_rms_norm.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the batched-weight RMS norm kernel (vllm._custom_ops.rms_norm). + +``rms_norm`` can use the outermost input batch index to select the corresponding +weight row. The result must match that of looping ``rms_norm`` over that dimension. +""" + +import pytest +import torch + +from vllm import _custom_ops as ops +from vllm.platforms import current_platform +from vllm.utils.torch_utils import set_random_seed + +pytestmark = pytest.mark.skipif( + not current_platform.is_cuda_alike(), + reason="rms_norm requires a CUDA/ROCm device", +) + + +@pytest.mark.parametrize( + "shape", + [ + (28, 17, 128), # 3D: [num_rows, tokens, hidden] + (1, 5, 2, 128), # 4D: single row (edge case) + (28, 13, 8, 128), # 4D: [L, num_ctx, nkv, hd] (DFlash K-norm) + (6, 3, 4, 769), # 4D: non-power-of-two hidden size + ], +) +@pytest.mark.parametrize("dtype", [torch.half, torch.bfloat16, torch.float]) +@pytest.mark.parametrize("seed", [42]) +@torch.inference_mode() +def test_rms_norm_matches_loop( + shape: tuple[int, ...], dtype: torch.dtype, seed: int +) -> None: + set_random_seed(seed) + torch.set_default_device("cuda") + + num_rows, hidden = shape[0], shape[-1] + eps = 1e-6 + + x = torch.randn(*shape, dtype=dtype) * 0.1 + # Distinct weight per row so that a wrong row index would be caught. + weight = torch.randn(num_rows, hidden, dtype=dtype) * 0.1 + 1.0 + + # Reference batched-weight rms norm. + out_ref = torch.empty_like(x) + for i in range(x.shape[0]): + ops.rms_norm(out_ref[i], x[i], weight[i], eps) + + out = torch.empty_like(x) + ops.rms_norm(out, x, weight, eps) + + # Expect bitwise-identical results. + torch.testing.assert_close(out, out_ref, atol=0, rtol=0) + + +@torch.inference_mode() +def test_rms_norm_validates_shapes() -> None: + torch.set_default_device("cuda") + + x = torch.randn(4, 8, 128, dtype=torch.float) + out = torch.empty_like(x) + # Expect num rows mismatch. + with pytest.raises(RuntimeError): + ops.rms_norm(out, x, torch.randn(3, 128), 1e-6) + # Expect hidden size mismatch. + with pytest.raises(RuntimeError): + ops.rms_norm(out, x, torch.randn(4, 64), 1e-6) diff --git a/tests/kernels/core/test_cpu_activation.py b/tests/kernels/core/test_cpu_activation.py index 40b5f0454683..110c92042e0c 100644 --- a/tests/kernels/core/test_cpu_activation.py +++ b/tests/kernels/core/test_cpu_activation.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project + import pytest import torch @@ -109,3 +110,103 @@ def test_cpu_unary_activation( if not (activation_cls is GELU and dtype != torch.bfloat16): raw_out = torch.empty_like(x) opcheck(fn, (raw_out, x, *op_args)) + + +@pytest.mark.parametrize("dtype", DTYPES) +@torch.inference_mode() +def test_cpu_gelu_tanh_and_mul( + default_vllm_config, + dtype: torch.dtype, +) -> None: + gate = torch.tensor( + [ + [ + -12.0, + -10.0, + -9.01, + -5.0, + -2.0, + -1.0, + -0.0, + 0.0, + 0.5, + 1.0, + 2.0, + 5.0, + 9.01, + 10.0, + 12.0, + 11.0, + ], + [ + -7.5, + -4.5, + -3.0, + -1.5, + -0.75, + -0.25, + 0.25, + 0.75, + 1.5, + 3.0, + 4.5, + 7.5, + -11.0, + 11.0, + 8.75, + -8.75, + ], + ], + dtype=dtype, + ) + val = torch.tensor( + [ + [ + 0.25, + -0.5, + 0.75, + -1.0, + 1.25, + -1.5, + 1.75, + -2.0, + 2.25, + -2.5, + 2.75, + -3.0, + 3.25, + -3.5, + 3.75, + -4.0, + ], + [ + -0.4, + 0.6, + -0.8, + 1.0, + -1.2, + 1.4, + -1.6, + 1.8, + -2.0, + 2.2, + -2.4, + 2.6, + -2.8, + 3.0, + -3.2, + 3.4, + ], + ], + dtype=dtype, + ) + + x = torch.cat((val, gate), dim=-1).contiguous() + kernel_out = torch.empty_like(val) + torch.ops._C.gelu_tanh_and_mul(kernel_out, x) + + torch_ref = torch.nn.functional.gelu(val, approximate="tanh") * gate + + atol = get_default_atol(kernel_out) + rtol = get_default_rtol(kernel_out) + torch.testing.assert_close(kernel_out, torch_ref, atol=atol, rtol=rtol) diff --git a/tests/kernels/core/test_fused_allreduce_gemma_rms_norm.py b/tests/kernels/core/test_fused_allreduce_gemma_rms_norm.py new file mode 100644 index 000000000000..cb936ce33ad1 --- /dev/null +++ b/tests/kernels/core/test_fused_allreduce_gemma_rms_norm.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the manual AllReduce + GemmaRMSNorm fusion used by MiniMax M3. + +``fused_allreduce_gemma_rms_norm`` must match the unfused model path, i.e. +``GemmaRMSNorm(all_reduce(partial), residual)``, both on the flashinfer fast +path (TP>1 with flashinfer + NVSwitch) and on the eager fallback (TP==1, or when +flashinfer is unavailable / the GPU has no NVSwitch). +""" + +import pytest +import torch +from torch.multiprocessing import spawn + +from tests.utils import ensure_current_vllm_config, init_test_distributed_environment +from vllm.distributed import cleanup_dist_env_and_memory +from vllm.distributed.communication_op import tensor_model_parallel_all_reduce +from vllm.model_executor.layers.fused_allreduce_gemma_rms_norm import ( + fused_allreduce_gemma_rms_norm, +) +from vllm.model_executor.layers.layernorm import GemmaRMSNorm +from vllm.platforms import current_platform +from vllm.utils.network_utils import get_open_port +from vllm.utils.torch_utils import set_random_seed + + +@ensure_current_vllm_config() +def _worker_fused_ar_norm( + local_rank, + world_size, + port, + num_tokens, + hidden_size, + dtype, + seed, + eps, +): + """Per-rank worker: compare the fused helper vs all_reduce + GemmaRMSNorm.""" + device = torch.device(f"cuda:{local_rank}") + torch.accelerator.set_device_index(device) + init_test_distributed_environment( + world_size, 1, local_rank, port, local_rank=local_rank + ) + + # Norm weights are identical across ranks (replicated GemmaRMSNorm). + set_random_seed(seed) + norm = GemmaRMSNorm(hidden_size, eps=eps).cuda().to(dtype) + with torch.no_grad(): + norm.weight.normal_(mean=0.0, std=0.1) + + # Residual is shared across ranks; the partial o_proj output differs per rank + # (each rank holds a partial sum that all_reduce combines). + torch.manual_seed(seed + 7) + residual = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device) + torch.manual_seed(seed + 1000 + local_rank) + partial = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device) + + # Reference: the unfused model path. + reduced = tensor_model_parallel_all_reduce(partial.clone()) + ref_out, ref_res = norm(reduced, residual.clone()) + + # Fused helper (flashinfer fast path when available, else fallback). + out, res = fused_allreduce_gemma_rms_norm(partial.clone(), residual.clone(), norm) + torch.accelerator.synchronize() + + torch.testing.assert_close(out, ref_out, atol=2e-2, rtol=2e-2) + torch.testing.assert_close(res, ref_res, atol=2e-2, rtol=2e-2) + + cleanup_dist_env_and_memory() + + +@pytest.mark.skipif( + not current_platform.is_cuda(), + reason="CUDA required", +) +# world_size=1 exercises the TP==1 identity branch on a single GPU; >1 exercises +# the all_reduce + GemmaRMSNorm equivalence (flashinfer kernel or fallback). +@pytest.mark.parametrize("world_size", [1, 2, 4]) +@pytest.mark.parametrize("num_tokens", [1, 128, 333]) +@pytest.mark.parametrize("hidden_size", [2048, 4096]) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@pytest.mark.parametrize("eps", [1e-6]) +@pytest.mark.parametrize("seed", [42]) +def test_fused_allreduce_gemma_rms_norm( + world_size, + num_tokens, + hidden_size, + dtype, + eps, + seed, +): + num_gpus = current_platform.device_count() + if num_gpus < world_size: + pytest.skip(f"Need >= {world_size} GPUs, have {num_gpus}") + port = str(get_open_port()) + spawn( + _worker_fused_ar_norm, + args=( + world_size, + port, + num_tokens, + hidden_size, + dtype, + seed, + eps, + ), + nprocs=world_size, + join=True, + ) diff --git a/tests/kernels/core/test_fused_quant_layernorm.py b/tests/kernels/core/test_fused_quant_layernorm.py index 07d15e3b1dfe..255833c48dc8 100644 --- a/tests/kernels/core/test_fused_quant_layernorm.py +++ b/tests/kernels/core/test_fused_quant_layernorm.py @@ -8,7 +8,7 @@ import torch import vllm._custom_ops as ops -from tests.kernels.utils import opcheck +from tests.kernels.utils import fp8_ulp_distance, opcheck from vllm.model_executor.layers.layernorm import RMSNorm from vllm.model_executor.layers.quantization.utils.fp8_utils import ( per_token_group_quant_fp8, @@ -250,30 +250,54 @@ def test_rms_norm( assert ref_out.dtype == quant_dtype assert ops_out.dtype == quant_dtype + + # Per-block bf16 scales: allow a small relative tolerance for a few groups + # whose abs-max flips by one ULP between the fused and reference paths. The + # per-token and fp32 paths stay strict. + relax_block_rocm = ( + group_size is not None + and dtype == torch.bfloat16 + and current_platform.is_rocm() + ) + + def scales_close(rtol: float, atol: float) -> bool: + if torch.allclose(ref_scales, ops_scales, rtol=rtol, atol=atol): + return True + return relax_block_rocm and torch.allclose( + ref_scales, ops_scales, rtol=1e-2, atol=atol + ) + if quant_dtype == torch.int8: - assert torch.allclose(ref_scales, ops_scales, atol=1e-6) + assert scales_close(rtol=1e-5, atol=1e-6) # big atol to account for round-off errors. assert torch.allclose(ref_out, ops_out, atol=1) else: - assert torch.allclose(ref_scales, ops_scales) + assert scales_close(rtol=1e-5, atol=1e-8) a = ref_out.to(dtype=torch.float32) b = ops_out.to(dtype=torch.float32) ok = torch.allclose(a, b, atol=1e-6) if not ok: - # fallback: compare dequantized values with relaxed tolerance - if group_size is None: - a_deq = a * ref_scales.view(-1, 1) - b_deq = b * ops_scales.view(-1, 1) + if relax_block_rocm: + # ULP-flipped group scale can cross an E4M3 tie; tolerate a + # bounded count of isolated fp8 outliers. + ulp = fp8_ulp_distance(ref_out, ops_out) + max_outliers = ulp.numel() // 100_000 + 8 + ok = int((ulp > 0).sum().item()) <= max_outliers else: - a_deq = a * ref_scales.repeat_interleave(group_size[1], dim=1) - b_deq = b * ops_scales.repeat_interleave(group_size[1], dim=1) - # NOTE: It is possible that some future test cases trigger this - # max diff due to precision issues. If such an error is - # encountered, it's recommended to inspect the differences between - # all corresponding elements from each tensor (e.g. by looping over - # them) and checking how many the max diff error shows up on (just - # a few bad elements should still be considered acceptable). - ok = torch.allclose(a_deq, b_deq, rtol=5e-2, atol=5e-2) + # CUDA (& non-bf16): compare dequantized values with relaxed tolerance. + if group_size is None: + a_deq = a * ref_scales.view(-1, 1) + b_deq = b * ops_scales.view(-1, 1) + else: + a_deq = a * ref_scales.repeat_interleave(group_size[1], dim=1) + b_deq = b * ops_scales.repeat_interleave(group_size[1], dim=1) + # NOTE: It is possible that some future test cases trigger this + # max diff due to precision issues. If such an error is + # encountered, it's recommended to inspect the differences between + # all corresponding elements from each tensor (e.g. by looping over + # them) and checking how many the max diff error shows up on (just + # a few bad elements should still be considered acceptable). + ok = torch.allclose(a_deq, b_deq, rtol=5e-2, atol=5e-2) assert ok if add_residual: assert torch.allclose(ref_residual, ops_residual) diff --git a/tests/kernels/core/test_layernorm.py b/tests/kernels/core/test_layernorm.py index c39d42c75930..6e546f154c2e 100644 --- a/tests/kernels/core/test_layernorm.py +++ b/tests/kernels/core/test_layernorm.py @@ -5,7 +5,8 @@ import torch from tests.kernels.quant_utils import FP8_DTYPE -from tests.kernels.utils import opcheck +from tests.kernels.utils import fp8_ulp_distance, opcheck +from vllm import ir from vllm.model_executor.layers.layernorm import GemmaRMSNorm, RMSNorm from vllm.platforms import current_platform from vllm.utils.torch_utils import set_random_seed @@ -27,6 +28,10 @@ ] +def _rms_norm_tolerance(dtype: torch.dtype) -> dict[str, float]: + return ir.ops.rms_norm.get_tolerance(dtype) + + @pytest.mark.parametrize("num_tokens", NUM_TOKENS) @pytest.mark.parametrize("hidden_size", HIDDEN_SIZES) @pytest.mark.parametrize("add_residual", ADD_RESIDUAL) @@ -81,6 +86,49 @@ def test_rms_norm( ) +@pytest.mark.parametrize("num_tokens", NUM_TOKENS) +@pytest.mark.parametrize("hidden_size", HIDDEN_SIZES) +@pytest.mark.parametrize("add_residual", ADD_RESIDUAL) +@pytest.mark.parametrize("dtype", DTYPES) +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("device", CUDA_DEVICES) +@torch.inference_mode() +def test_rms_norm_weightless( + default_vllm_config, + num_tokens: int, + hidden_size: int, + add_residual: bool, + dtype: torch.dtype, + seed: int, + device: str, +) -> None: + set_random_seed(seed) + torch.set_default_device(device) + layer = RMSNorm(hidden_size, has_weight=False).to(dtype=dtype) + x = torch.randn(num_tokens, hidden_size, dtype=dtype) + residual = torch.randn_like(x) if add_residual else None + + ref_out = layer.forward_native(x, residual) + out = layer(x, residual) + tol = _rms_norm_tolerance(dtype) + if add_residual: + torch.testing.assert_close(out[0], ref_out[0], **tol) + torch.testing.assert_close(out[1], ref_out[1], **tol) + else: + torch.testing.assert_close(out, ref_out, **tol) + + if residual is not None: + opcheck( + torch.ops._C.fused_add_rms_norm, + (x, residual, None, layer.variance_epsilon), + ) + else: + opcheck( + torch.ops._C.rms_norm, + (out, x, None, layer.variance_epsilon), + ) + + @pytest.mark.parametrize("num_tokens", NUM_TOKENS) @pytest.mark.parametrize("hidden_size", HIDDEN_SIZES) @pytest.mark.parametrize("add_residual", ADD_RESIDUAL) @@ -156,12 +204,22 @@ def test_fused_rms_norm_quant( (out_quant_fused, x, weight, quant_scale_t, 1e-6), ) - torch.testing.assert_close( - out_quant.to(dtype=torch.float32), - out_quant_fused.to(dtype=torch.float32), - atol=1e-3, - rtol=1e-3, - ) + if current_platform.is_rocm(): + # Fused and unfused FP8 paths can land on opposite sides of an E4M3 tie; + # tolerate a tiny number of isolated fp8 outliers on ROCm. + ulp = fp8_ulp_distance(out_quant, out_quant_fused) + max_outliers = ulp.numel() // 100_000 + 8 + num_outliers = int((ulp > 0).sum().item()) + assert num_outliers <= max_outliers, ( + f"FP8 quant mismatch: {num_outliers} fp8 outliers (allowed {max_outliers})" + ) + else: + torch.testing.assert_close( + out_quant.to(dtype=torch.float32), + out_quant_fused.to(dtype=torch.float32), + atol=1e-3, + rtol=1e-3, + ) @torch.inference_mode() diff --git a/tests/kernels/core/test_minimax_reduce_rms.py b/tests/kernels/core/test_minimax_reduce_rms.py index de9fc2bbb4f7..b9c591bb93fa 100644 --- a/tests/kernels/core/test_minimax_reduce_rms.py +++ b/tests/kernels/core/test_minimax_reduce_rms.py @@ -10,8 +10,12 @@ from tests.kernels.utils import opcheck from tests.utils import ensure_current_vllm_config, init_test_distributed_environment from vllm.distributed import cleanup_dist_env_and_memory -from vllm.model_executor.layers.minimax_rms_norm import MiniMaxText01RMSNormTP +from vllm.model_executor.layers.minimax_rms_norm import ( + MiniMaxText01RMSNormTP, + rms_norm_tp, +) from vllm.platforms import current_platform +from vllm.triton_utils import HAS_TRITON from vllm.utils.network_utils import get_open_port from vllm.utils.torch_utils import set_random_seed @@ -54,8 +58,19 @@ def _worker_forward_qk( torch.manual_seed(seed + 1000 + local_rank) qkv = torch.randn(num_tokens, hq + hk + hk, dtype=dtype, device="cuda") - q_ref, k_ref, v_ref = qkv.clone().split([hq, hk, hk], dim=-1) - ref_q, ref_k = MiniMaxText01RMSNormTP.forward_qk(q_norm, k_norm, q_ref, k_ref) + # Reference: eager all-reduce path. ``forward_qk`` no longer all-reduces + # the variance (it is the tp==1 / already-reduced building block), so the + # multi-rank reference must use the eager path that performs the global + # variance all-reduce, matching the fused kernel below. + ref_q, ref_k = rms_norm_tp._minimax_qk_norm_tp_eager( + qkv.clone(), + q_norm.weight, + k_norm.weight, + hq, + hk, + world_size, + eps, + ) # Set up Lamport workspace. from vllm.distributed.parallel_state import get_tp_group @@ -150,3 +165,44 @@ def test_minimax_reduce_rms_qk( nprocs=world_size, join=True, ) + + +@pytest.mark.skipif( + not current_platform.is_cuda() or not HAS_TRITON, + reason="CUDA and Triton required", +) +@pytest.mark.parametrize("num_tokens", [1, 7, 128, 333, 2049]) +@pytest.mark.parametrize("hidden_dims", [(3072, 512), (768, 256), (3000, 500)]) +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) +@pytest.mark.parametrize("tp_world", [1, 4, 8]) +@pytest.mark.parametrize("eps", [1e-6]) +@pytest.mark.parametrize("seed", [42]) +def test_minimax_qk_norm_triton_fallback( + monkeypatch, num_tokens, hidden_dims, dtype, tp_world, eps, seed +): + """Single-GPU check: Triton fallback kernels vs the pure-torch reference. + + The all-reduce is a TP communication barrier, so it is monkeypatched to + identity here; both the Triton path and the reference see the same + (patched) reduction. This validates the kernel math and the folded + ``/ tp_world`` scaling without needing multiple ranks -- ``hidden_dims`` + are the per-rank q/k segment widths. + """ + monkeypatch.setattr(rms_norm_tp, "_all_reduce_variance", lambda v: v) + + q_size, kv_size = hidden_dims + device = "cuda" + torch.manual_seed(seed) + qkv = torch.randn(num_tokens, q_size + 2 * kv_size, dtype=dtype, device=device) + q_weight = torch.randn(q_size, dtype=dtype, device=device) + k_weight = torch.randn(kv_size, dtype=dtype, device=device) + + q_triton, k_triton = rms_norm_tp._minimax_qk_norm_tp_fallback( + qkv, q_weight, k_weight, q_size, kv_size, 0, tp_world, eps + ) + q_ref, k_ref = rms_norm_tp._minimax_qk_norm_tp_eager( + qkv, q_weight, k_weight, q_size, kv_size, tp_world, eps + ) + + torch.testing.assert_close(q_triton, q_ref, atol=3e-2, rtol=3e-2) + torch.testing.assert_close(k_triton, k_ref, atol=3e-2, rtol=3e-2) diff --git a/tests/kernels/core/test_rotary_embedding_mla_cache_fused.py b/tests/kernels/core/test_rotary_embedding_mla_cache_fused.py index 181f10f314e9..289267b6a4c4 100644 --- a/tests/kernels/core/test_rotary_embedding_mla_cache_fused.py +++ b/tests/kernels/core/test_rotary_embedding_mla_cache_fused.py @@ -17,6 +17,36 @@ from vllm.utils.torch_utils import set_random_seed +@pytest.fixture +def default_vllm_config(monkeypatch): + """Enable the AITER triton rope on ROCm for fp16-consistent numerics. + + The fused CUDA kernel runs native fp16 while forward_native upcasts to + fp32, so on ROCm we route through the AITER triton rope (+rotary_embedding) + to match. Its env gates are cached at import, hence refresh_env_variables(). + """ + from vllm._aiter_ops import rocm_aiter_ops + from vllm.config import CompilationConfig, VllmConfig, set_current_vllm_config + + is_rocm = current_platform.is_rocm() + if is_rocm: + config = VllmConfig( + compilation_config=CompilationConfig(custom_ops=["+rotary_embedding"]) + ) + else: + config = VllmConfig() + try: + with monkeypatch.context() as m, set_current_vllm_config(config): + if is_rocm: + m.setenv("VLLM_ROCM_USE_AITER", "1") + m.setenv("VLLM_ROCM_USE_AITER_TRITON_ROPE", "1") + rocm_aiter_ops.refresh_env_variables() + yield config + finally: + if is_rocm: + rocm_aiter_ops.refresh_env_variables() + + @pytest.mark.parametrize("dtype", [torch.half, torch.bfloat16, torch.float]) @pytest.mark.parametrize("is_neox_style", [False, True]) @pytest.mark.parametrize("seq_len", [11, 42]) @@ -151,6 +181,10 @@ def test_concat_and_cache_mla_rope_fused( kv_cache_scale, ) + # ROCm neox-style Triton FMA diverges slightly from the fused kernel, so + # relax the affected tolerance: rtol for fp8 (one e4m3 ULP ~12.5%) and atol + # otherwise (bounded ~6e-4). Other paths use the CUDA defaults. + rocm_neox = current_platform.is_rocm() and is_neox_style if kv_cache_dtype == "fp8": result_temp = torch.empty_like(kv_cache, dtype=torch.float16) ops.convert_fp8( @@ -163,7 +197,11 @@ def test_concat_and_cache_mla_rope_fused( ops.convert_fp8( expected_temp, ref_kv_cache, kv_cache_scale.item(), kv_dtype=kv_cache_dtype ) - torch.testing.assert_close(result_temp, expected_temp, atol=0.001, rtol=0.1) + torch.testing.assert_close( + result_temp, expected_temp, atol=0.001, rtol=0.15 if rocm_neox else 0.1 + ) + elif rocm_neox: + torch.testing.assert_close(kv_cache, ref_kv_cache, atol=1e-3, rtol=1e-3) else: torch.testing.assert_close(kv_cache, ref_kv_cache) diff --git a/tests/kernels/core/test_vit_fp8_quant.py b/tests/kernels/core/test_vit_fp8_quant.py index 0c63d0069f16..772f06ce156e 100644 --- a/tests/kernels/core/test_vit_fp8_quant.py +++ b/tests/kernels/core/test_vit_fp8_quant.py @@ -5,6 +5,9 @@ import pytest import torch +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + get_fp8_min_max, +) from vllm.platforms import current_platform from vllm.triton_utils import HAS_TRITON @@ -24,8 +27,7 @@ def _naive_fp8_quantize( ) -> torch.Tensor: """Reference FP8 quantization in PyTorch.""" fp8_dtype = current_platform.fp8_dtype() - fp8_max = torch.finfo(fp8_dtype).max - fp8_min = -fp8_max + fp8_min, fp8_max = get_fp8_min_max() x = tensor.float() if not skip_scale: diff --git a/tests/kernels/helion/test_dynamic_per_token_scaled_fp8_quant.py b/tests/kernels/helion/test_dynamic_per_token_scaled_fp8_quant.py new file mode 100644 index 000000000000..50fd9b70d256 --- /dev/null +++ b/tests/kernels/helion/test_dynamic_per_token_scaled_fp8_quant.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the dynamic_per_token_scaled_fp8_quant helion kernel + +Run `pytest tests/kernels/helion/test_dynamic_per_token_scaled_fp8_quant.py`. +""" + +from typing import Any + +import pytest +import torch +from torch._subclasses.fake_tensor import FakeTensorMode + +from tests.kernels.helion.utils import skip_if_platform_unsupported +from tests.kernels.quant_utils import FP8_DTYPE +from vllm.kernels.helion.case_key import CaseKey +from vllm.kernels.helion.config_manager import ConfigManager +from vllm.kernels.helion.ops.dynamic_per_token_scaled_fp8_quant import ( + _pick_cache, + baseline, + dynamic_per_token_scaled_fp8_quant, + pick_config, +) +from vllm.platforms import current_platform +from vllm.utils.import_utils import has_helion +from vllm.utils.torch_utils import set_random_seed + +if not has_helion(): + pytest.skip( + "Helion is not installed. Install with: pip install vllm[helion]", + allow_module_level=True, + ) + + +def _generate_fake_input(num_tokens: int, hidden_size: int) -> tuple[Any, ...]: + with FakeTensorMode(): + input = torch.randn( + num_tokens, hidden_size, device="cuda", dtype=torch.bfloat16 + ) + result = torch.empty( + input.shape, device=input.device, dtype=current_platform.fp8_dtype() + ) + scale = torch.empty((num_tokens, 1), device=input.device, dtype=torch.float32) + scale_ub = torch.mean(input).to(torch.float32) + args = (result, input, scale, scale_ub) + return args + + +@pytest.fixture(autouse=True) +def reset_config_manager_singleton(): + ConfigManager.reset_instance() + ConfigManager() + yield + ConfigManager.reset_instance() + + +class TestDynamicPerTokenScaledFp8QuantConfigPicker: + def setup_method(self): + _pick_cache.clear() + + def test_config_picker_exact_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "num_tokens": 16}), + ] + + args = _generate_fake_input(16, 4096) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey({"hidden_size": 4096, "num_tokens": 16}) + + def test_config_picker_closest_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "num_tokens": 32}), + ] + + args = _generate_fake_input(20, 3000) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey({"hidden_size": 2048, "num_tokens": 32}) + + def test_config_picker_no_configs(self): + config_keys: list[dict] = [] + + args = _generate_fake_input(16, 4096) + selected_key = pick_config(args, config_keys) + assert selected_key is None + + def test_config_picker_fallback_to_largest(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "num_tokens": 16}), + ] + + args = _generate_fake_input(32, 8192) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey({"hidden_size": 4096, "num_tokens": 16}) + + +class TestDynamicPerTokenScaledFp8QuantCorrectness: + @pytest.mark.parametrize("num_tokens", [1, 7, 4096]) + @pytest.mark.parametrize("hidden_size", [17, 1024, 1025, 1026, 5137, 8193]) + @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float]) + @pytest.mark.parametrize("has_scale_ub", [True, False]) + @pytest.mark.parametrize("seed", [0]) + def test_dynamic_per_token_fp8_quant( + self, + num_tokens: int, + hidden_size: int, + dtype: torch.dtype, + has_scale_ub: bool, + seed: int, + ) -> None: + skip_if_platform_unsupported("dynamic_per_token_scaled_fp8_quant") + set_random_seed(seed) + + x = ( + torch.rand(num_tokens, hidden_size, dtype=dtype, device="cuda") + 1e-6 + ) # avoid nans + + scale_ub = ( + torch.mean(x).to(dtype=torch.float32, device="cuda") + if has_scale_ub + else None + ) + + ref_out = torch.empty(x.shape, device="cuda", dtype=FP8_DTYPE) + ref_scales = torch.empty((x.shape[0], 1), device="cuda", dtype=torch.float32) + baseline(ref_out, x, ref_scales, scale_ub) + + ops_out = torch.empty(x.shape, device="cuda", dtype=FP8_DTYPE) + ops_scales = torch.empty((x.shape[0], 1), device="cuda", dtype=torch.float32) + dynamic_per_token_scaled_fp8_quant(ops_out, x, ops_scales, scale_ub) + + torch.testing.assert_close(ref_scales, ops_scales) + # allow 1 ULP difference + assert ( + ref_out.view(torch.uint8).to(torch.int16) + - ops_out.view(torch.uint8).to(torch.int16) + ).abs().max() <= 1 + + +class TestDynamicPerTokenScaledFp8QuantIntegration: + def test_kernel_registration_integration(self): + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + assert "dynamic_per_token_scaled_fp8_quant" in registered_kernels + + kernel_wrapper = registered_kernels["dynamic_per_token_scaled_fp8_quant"] + assert kernel_wrapper.op_name == "dynamic_per_token_scaled_fp8_quant" + assert kernel_wrapper._config_picker is not None + assert kernel_wrapper._mutates_args == ["result", "scale"] + + def test_fake_impl_functionality(self): + skip_if_platform_unsupported("dynamic_per_token_scaled_fp8_quant") + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + kernel_wrapper = registered_kernels["dynamic_per_token_scaled_fp8_quant"] + fake_impl = kernel_wrapper._fake_impl + + args = _generate_fake_input(16, 4096) + assert fake_impl(*args) is None diff --git a/tests/kernels/helion/test_fused_qk_norm_rope.py b/tests/kernels/helion/test_fused_qk_norm_rope.py new file mode 100644 index 000000000000..19d2fc9b5a6f --- /dev/null +++ b/tests/kernels/helion/test_fused_qk_norm_rope.py @@ -0,0 +1,261 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the fused_qk_norm_rope helion kernel + +Run `pytest tests/kernels/helion/test_fused_qk_norm_rope.py`. +""" + +from typing import Any + +import pytest +import torch +from torch._subclasses.fake_tensor import FakeTensorMode + +from tests.kernels.helion.utils import skip_if_platform_unsupported +from vllm.benchmarks.lib.utils import default_vllm_config +from vllm.kernels.helion.case_key import CaseKey +from vllm.kernels.helion.config_manager import ConfigManager +from vllm.kernels.helion.ops.fused_qk_norm_rope import ( + _pick_cache, + baseline, + fused_qk_norm_rope, + pick_config, +) +from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding +from vllm.utils.import_utils import has_helion + +if not has_helion(): + pytest.skip( + "Helion is not installed. Install with: pip install vllm[helion]", + allow_module_level=True, + ) + + +@default_vllm_config() +def _generate_fake_input( + num_tokens: int, num_q_heads: int, num_kv_heads: int +) -> tuple[Any, ...]: + with FakeTensorMode(): + head_dim = 128 + eps = 1e-6 + is_neox = True + rotary_ratio = 1.0 + device = "cuda" + dtype = torch.bfloat16 + total_dim = (num_q_heads + 2 * num_kv_heads) * head_dim + qkv = torch.randn(num_tokens, total_dim, dtype=dtype, device=device) + positions = torch.arange(num_tokens, dtype=torch.long, device=device) + q_weight = torch.normal( + mean=1.0, + std=1.0, + size=(head_dim,), + dtype=qkv.dtype, + device=device, + ) + k_weight = torch.normal( + mean=1.0, + std=1.0, + size=(head_dim,), + dtype=qkv.dtype, + device=device, + ) + rotary_dim = int(head_dim * rotary_ratio) + rope = RotaryEmbedding( + head_size=head_dim, + rotary_dim=rotary_dim, + max_position_embeddings=4096, + base=10000.0, + is_neox_style=is_neox, + dtype=dtype, + ).to(device) + args = ( + qkv, + num_q_heads, + num_kv_heads, + num_kv_heads, + head_dim, + eps, + q_weight, + k_weight, + rope.cos_sin_cache, + is_neox, + positions.view(-1), + ) + return args + + +@pytest.fixture(autouse=True) +def reset_config_manager_singleton(): + ConfigManager.reset_instance() + ConfigManager() + yield + ConfigManager.reset_instance() + + +class TestFusedQkNormRopeConfigPicker: + def setup_method(self): + _pick_cache.clear() + + def test_config_picker_exact_match(self): + config_keys = [ + CaseKey({"q_heads": 2048, "kv_heads": 64, "num_tokens": 16}), + CaseKey({"q_heads": 4096, "kv_heads": 128, "num_tokens": 16}), + ] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"q_heads": 4096, "kv_heads": 128, "num_tokens": 16} + ) + + def test_config_picker_closest_match(self): + config_keys = [ + CaseKey({"q_heads": 2048, "kv_heads": 64, "num_tokens": 16}), + CaseKey({"q_heads": 2048, "kv_heads": 64, "num_tokens": 32}), + CaseKey({"q_heads": 2048, "kv_heads": 128, "num_tokens": 16}), + CaseKey({"q_heads": 2048, "kv_heads": 128, "num_tokens": 32}), + CaseKey({"q_heads": 4096, "kv_heads": 64, "num_tokens": 16}), + CaseKey({"q_heads": 4096, "kv_heads": 64, "num_tokens": 32}), + CaseKey({"q_heads": 4096, "kv_heads": 128, "num_tokens": 16}), + CaseKey({"q_heads": 4096, "kv_heads": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(20, 3000, 70) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"q_heads": 2048, "kv_heads": 64, "num_tokens": 32} + ) + + def test_config_picker_no_configs(self): + config_keys: list[dict] = [] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key is None + + def test_config_picker_fallback_to_largest(self): + config_keys = [ + CaseKey({"q_heads": 2048, "kv_heads": 64, "num_tokens": 16}), + CaseKey({"q_heads": 2048, "kv_heads": 64, "num_tokens": 32}), + CaseKey({"q_heads": 2048, "kv_heads": 128, "num_tokens": 16}), + CaseKey({"q_heads": 2048, "kv_heads": 128, "num_tokens": 32}), + CaseKey({"q_heads": 4096, "kv_heads": 64, "num_tokens": 16}), + CaseKey({"q_heads": 4096, "kv_heads": 64, "num_tokens": 32}), + CaseKey({"q_heads": 4096, "kv_heads": 128, "num_tokens": 16}), + CaseKey({"q_heads": 4096, "kv_heads": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(64, 8192, 256) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"q_heads": 4096, "kv_heads": 128, "num_tokens": 32} + ) + + +class TestFusedQkNormRopeCorrectness: + @pytest.mark.parametrize( + "num_heads, num_kv_heads, head_dim", [(16, 4, 128), (64, 8, 128)] + ) + @pytest.mark.parametrize("num_tokens", [1, 7, 1024, 1025]) + @pytest.mark.parametrize("is_neox", [False, True]) + @pytest.mark.parametrize("rotary_ratio", [1.0, 0.5, 0.25]) + @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) + @default_vllm_config() + def test_fused_qk_norm_rope( + self, + num_heads: int, + num_kv_heads: int, + head_dim: int, + num_tokens: int, + is_neox: bool, + rotary_ratio: float, + dtype: torch.dtype, + ): + skip_if_platform_unsupported("fused_qk_norm_rope") + + torch.manual_seed(42) + eps = 1e-6 + device = "cuda" + total_dim = (num_heads + 2 * num_kv_heads) * head_dim + ref_qkv = torch.empty( + num_tokens, total_dim, dtype=dtype, device=device + ).uniform_(-0.1, 0.1) + ops_qkv = ref_qkv.clone() + positions = torch.arange(num_tokens, dtype=torch.long, device=device) + q_weight = torch.empty(head_dim, dtype=dtype, device=device).uniform_(0.8, 1.2) + k_weight = torch.empty(head_dim, dtype=dtype, device=device).uniform_(0.8, 1.2) + rotary_dim = int(head_dim * rotary_ratio) + rope = RotaryEmbedding( + head_size=head_dim, + rotary_dim=rotary_dim, + max_position_embeddings=40960, + base=10000.0, + is_neox_style=is_neox, + dtype=dtype, + ).to(device) + + baseline( + ref_qkv, + num_heads, + num_kv_heads, + num_kv_heads, + head_dim, + eps, + q_weight, + k_weight, + rope.cos_sin_cache, + is_neox, + positions.view(-1), + ) + + fused_qk_norm_rope( + ops_qkv, + num_heads, + num_kv_heads, + num_kv_heads, + head_dim, + eps, + q_weight, + k_weight, + rope.cos_sin_cache, + is_neox, + positions.view(-1), + ) + + if dtype == torch.bfloat16: + atol = 5e-2 + rtol = 5e-2 + else: + atol = 1e-2 + rtol = 1e-2 + + torch.testing.assert_close( + ref_qkv, + ops_qkv, + atol=atol, + rtol=rtol, + ) + + +class TestFusedQkNormRopeIntegration: + def test_kernel_registration_integration(self): + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + assert "fused_qk_norm_rope" in registered_kernels + + kernel_wrapper = registered_kernels["fused_qk_norm_rope"] + assert kernel_wrapper.op_name == "fused_qk_norm_rope" + assert kernel_wrapper._config_picker is not None + assert kernel_wrapper._mutates_args == ["qkv"] + + def test_fake_impl_functionality(self): + skip_if_platform_unsupported("fused_qk_norm_rope") + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + kernel_wrapper = registered_kernels["fused_qk_norm_rope"] + fake_impl = kernel_wrapper._fake_impl + + args = _generate_fake_input(16, 4096, 128) + assert fake_impl(*args) is None diff --git a/tests/kernels/helion/test_per_token_group_fp8_quant.py b/tests/kernels/helion/test_per_token_group_fp8_quant.py new file mode 100644 index 000000000000..304734c77e54 --- /dev/null +++ b/tests/kernels/helion/test_per_token_group_fp8_quant.py @@ -0,0 +1,243 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the per_token_group_fp8_quant helion kernel + +Run `pytest tests/kernels/helion/test_per_token_group_fp8_quant.py`. +""" + +from typing import Any + +import pytest +import torch +from torch._subclasses.fake_tensor import FakeTensorMode + +from tests.kernels.helion.utils import skip_if_platform_unsupported +from tests.kernels.quant_utils import FP8_DTYPE +from vllm.kernels.helion.case_key import CaseKey +from vllm.kernels.helion.config_manager import ConfigManager +from vllm.kernels.helion.ops.per_token_group_fp8_quant import ( + _pick_cache, + baseline, + per_token_group_fp8_quant, + pick_config, +) +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + get_fp8_min_max, +) +from vllm.utils.import_utils import has_helion + +if not has_helion(): + pytest.skip( + "Helion is not installed. Install with: pip install vllm[helion]", + allow_module_level=True, + ) + + +def _generate_fake_input( + num_tokens: int, hidden_size: int, group_size: int +) -> tuple[Any, ...]: + with FakeTensorMode(): + input = torch.randn( + (num_tokens, hidden_size), device="cuda", dtype=torch.bfloat16 + ) + output_q = torch.empty(input.shape, device=input.device, dtype=FP8_DTYPE) + output_s = torch.empty( + (num_tokens, hidden_size // group_size), + device=input.device, + dtype=torch.float32, + ) + use_ue8m0 = False + column_major = False + fp8_min, fp8_max = get_fp8_min_max() + eps = 1e-10 + args = ( + input, + output_q, + output_s, + group_size, + eps, + fp8_min, + fp8_max, + use_ue8m0, + column_major, + ) + return args + + +@pytest.fixture(autouse=True) +def reset_config_manager_singleton(): + ConfigManager.reset_instance() + ConfigManager() + yield + ConfigManager.reset_instance() + + +class TestPerTokenGroupFp8QuantConfigPicker: + def setup_method(self): + _pick_cache.clear() + + def test_config_picker_exact_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 16}), + ] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"hidden_size": 4096, "group_size": 128, "num_tokens": 16} + ) + + def test_config_picker_closest_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(20, 3000, 70) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"hidden_size": 2048, "group_size": 64, "num_tokens": 32} + ) + + def test_config_picker_no_configs(self): + config_keys: list[dict] = [] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key is None + + def test_config_picker_fallback_to_largest(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(64, 8192, 256) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"hidden_size": 4096, "group_size": 128, "num_tokens": 32} + ) + + +class TestPerTokenGroupFp8QuantCorrectness: + @pytest.mark.parametrize( + "shape", [(31, 128), (32, 128), (63, 256), (64, 256), (16, 512), (2048, 5120)] + ) + @pytest.mark.parametrize("column_major", [False, True]) + @pytest.mark.parametrize("tma_aligned", [False, True]) + @pytest.mark.parametrize("scale_ue8m0", [False, True]) + @pytest.mark.parametrize("group_size", [64, 128]) + def test_per_token_group_fp8_quant( + self, + shape, + column_major: bool, + tma_aligned: bool, + scale_ue8m0: bool, + group_size: int, + ): + skip_if_platform_unsupported("per_token_group_fp8_quant") + + torch.manual_seed(42) + num_tokens, hidden_size = shape + fp8_min, fp8_max = get_fp8_min_max() + eps = 1e-10 + input = ( + torch.randn((num_tokens, hidden_size), device="cuda", dtype=torch.bfloat16) + * 8 + ) + ref_q = torch.empty(input.shape, device=input.device, dtype=FP8_DTYPE) + ops_q = ref_q.clone() + + groups_per_row = hidden_size // group_size + if column_major: + if tma_aligned: + tma_alignment = 4 + tma_aligned_m = ( + (num_tokens + tma_alignment - 1) // tma_alignment * tma_alignment + ) + shape = (num_tokens, groups_per_row) + stride = (1, tma_aligned_m) + ref_s = torch.empty_strided( + shape, stride, device=input.device, dtype=torch.float32 + ) + else: + ref_s = torch.empty( + (groups_per_row, num_tokens), + device=input.device, + dtype=torch.float32, + ).transpose(0, 1) + else: + ref_s = torch.empty( + (num_tokens, groups_per_row), device=input.device, dtype=torch.float32 + ) + + ops_s = ref_s.clone() + + baseline( + input, + ref_q, + ref_s, + group_size, + eps, + fp8_min, + fp8_max, + scale_ue8m0, + column_major, + tma_aligned, + ) + per_token_group_fp8_quant( + input, + ops_q, + ops_s, + group_size, + eps, + fp8_min, + fp8_max, + scale_ue8m0, + column_major, + tma_aligned, + ) + + assert torch.allclose(ref_s, ops_s) + # allow 1 ULP difference + assert ( + ref_q.view(torch.uint8).to(torch.int16) + - ops_q.view(torch.uint8).to(torch.int16) + ).abs().max() <= 1 + + +class TestPerTokenGroupFp8QuantIntegration: + def test_kernel_registration_integration(self): + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + assert "per_token_group_fp8_quant" in registered_kernels + + kernel_wrapper = registered_kernels["per_token_group_fp8_quant"] + assert kernel_wrapper.op_name == "per_token_group_fp8_quant" + assert kernel_wrapper._config_picker is not None + assert kernel_wrapper._mutates_args == ["output_q", "output_s"] + + def test_fake_impl_functionality(self): + skip_if_platform_unsupported("per_token_group_fp8_quant") + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + kernel_wrapper = registered_kernels["per_token_group_fp8_quant"] + fake_impl = kernel_wrapper._fake_impl + + args = _generate_fake_input(16, 4096, 128) + assert fake_impl(*args) is None diff --git a/tests/kernels/helion/test_register.py b/tests/kernels/helion/test_register.py index c82c3c8358ed..9876135056b2 100644 --- a/tests/kernels/helion/test_register.py +++ b/tests/kernels/helion/test_register.py @@ -713,6 +713,7 @@ def default_picker(args, config_keys): new_op = Mock() registered_ops: dict[str, Mock] = {} + mutates_args = ["y"] class MockNamespace: def __getattr__(self, name): @@ -748,6 +749,7 @@ def register_side_effect(op_name, op_func, **kwargs): raw_kernel_func=sample_kernel, op_name="test_kernel", fake_impl=fake_impl, + mutates_args=mutates_args, config_picker=default_picker, ) result = wrapper._get_or_register_custom_op() @@ -755,6 +757,7 @@ def register_side_effect(op_name, op_func, **kwargs): mock_register.assert_called_once() assert result is new_op assert mock_register.call_args[1]["op_func"] is mock_decorated + assert mock_register.call_args[1]["mutates_args"] is mutates_args class TestKernelRegistry: diff --git a/tests/kernels/helion/test_rms_norm_dynamic_per_token_quant.py b/tests/kernels/helion/test_rms_norm_dynamic_per_token_quant.py new file mode 100644 index 000000000000..3842419562c4 --- /dev/null +++ b/tests/kernels/helion/test_rms_norm_dynamic_per_token_quant.py @@ -0,0 +1,207 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the rms_norm_dynamic_per_token_quant helion kernel + +Run `pytest tests/kernels/helion/test_rms_norm_dynamic_per_token_quant.py`. +""" + +from typing import Any + +import pytest +import torch +from torch._subclasses.fake_tensor import FakeTensorMode + +from tests.kernels.helion.utils import skip_if_platform_unsupported +from vllm.kernels.helion.case_key import CaseKey +from vllm.kernels.helion.config_manager import ConfigManager +from vllm.kernels.helion.ops.rms_norm_dynamic_per_token_quant import ( + _pick_cache, + baseline, + pick_config, + rms_norm_dynamic_per_token_quant, +) +from vllm.platforms import current_platform +from vllm.utils.import_utils import has_helion +from vllm.utils.torch_utils import set_random_seed + +if not has_helion(): + pytest.skip( + "Helion is not installed. Install with: pip install vllm[helion]", + allow_module_level=True, + ) + + +def _generate_fake_input(num_tokens: int, hidden_size: int) -> tuple[Any, ...]: + with FakeTensorMode(): + input = torch.randn( + num_tokens, hidden_size, device="cuda", dtype=torch.bfloat16 + ) + result = torch.empty( + input.shape, device=input.device, dtype=current_platform.fp8_dtype() + ) + scale = torch.empty((num_tokens, 1), device=input.device, dtype=torch.float32) + scale_ub = torch.mean(input).to(torch.float32) + residual = torch.randn_like(input) + weight = torch.normal( + mean=1.0, + std=1.0, + size=(hidden_size,), + dtype=input.dtype, + device=input.device, + ) + epsilon = 1e-6 + args = (result, input, weight, scale, epsilon, scale_ub, residual) + return args + + +@pytest.fixture(autouse=True) +def reset_config_manager_singleton(): + ConfigManager.reset_instance() + ConfigManager() + yield + ConfigManager.reset_instance() + + +class TestRmsNormDynamicPerTokenQuantConfigPicker: + def setup_method(self): + _pick_cache.clear() + + def test_config_picker_exact_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "num_tokens": 16}), + ] + + args = _generate_fake_input(16, 4096) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey({"hidden_size": 4096, "num_tokens": 16}) + + def test_config_picker_closest_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "num_tokens": 32}), + ] + + args = _generate_fake_input(20, 3000) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey({"hidden_size": 2048, "num_tokens": 32}) + + def test_config_picker_no_configs(self): + config_keys: list[dict] = [] + + args = _generate_fake_input(16, 4096) + selected_key = pick_config(args, config_keys) + assert selected_key is None + + def test_config_picker_fallback_to_largest(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "num_tokens": 16}), + ] + + args = _generate_fake_input(32, 8192) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey({"hidden_size": 4096, "num_tokens": 16}) + + +DTYPES = [torch.bfloat16, torch.float] +QUANT_DTYPES = [torch.int8, current_platform.fp8_dtype()] +VEC_HIDDEN_SIZES = [1024, 1025, 1027, 1029] +# Avoid combinatorial explosion with full Cartesian product +NUM_TOKENS_HIDDEN_SIZES = [ + *[(1, i) for i in [1, 64, *VEC_HIDDEN_SIZES, 5120, 5137]], + *[(2048, i) for i in [1, 64, *VEC_HIDDEN_SIZES, 5137]], + *[(4096, i) for i in [1, 64, 5137]], +] + +ADD_RESIDUAL = [False, True] +SCALE_UBS = [True, False] +SEEDS = [0] + +EPS = 1e-6 + + +class TestRmsNormDynamicPerTokenQuantCorrectness: + @pytest.mark.parametrize("num_tokens, hidden_size", NUM_TOKENS_HIDDEN_SIZES) + @pytest.mark.parametrize("add_residual", ADD_RESIDUAL) + @pytest.mark.parametrize("has_scale_ub", SCALE_UBS) + @pytest.mark.parametrize("dtype", DTYPES) + @pytest.mark.parametrize("quant_dtype", QUANT_DTYPES) + @pytest.mark.parametrize("seed", SEEDS) + def test_rms_norm_dynamic_per_token_quant( + self, + num_tokens: int, + hidden_size: int, + add_residual: bool, + has_scale_ub: bool, + dtype: torch.dtype, + quant_dtype: torch.dtype, + seed: int, + ) -> None: + skip_if_platform_unsupported("rms_norm_dynamic_per_token_quant") + + set_random_seed(seed) + + if has_scale_ub and quant_dtype != current_platform.fp8_dtype(): + # skip + return + + scale = 1 / (hidden_size) + x = torch.randn(num_tokens, hidden_size, dtype=dtype, device="cuda") * scale + weight = torch.normal( + mean=1.0, std=1.0, size=(hidden_size,), dtype=dtype, device=x.device + ) + residual = torch.randn_like(x) * scale if add_residual else None + scale_ub = ( + torch.mean(x).to(dtype=torch.float32, device="cuda") + if has_scale_ub + else None + ) + + ref_out = torch.empty(x.shape, device=x.device, dtype=quant_dtype) + ref_scales = torch.empty((x.shape[0], 1), device=x.device, dtype=torch.float32) + ref_residual = residual.clone() if residual is not None else None + baseline(ref_out, x, weight, ref_scales, EPS, scale_ub, ref_residual) + + ops_out = torch.empty(x.shape, device=x.device, dtype=quant_dtype) + ops_scales = torch.empty((x.shape[0], 1), device=x.device, dtype=torch.float32) + ops_residual = residual.clone() if residual is not None else None + rms_norm_dynamic_per_token_quant( + ops_out, x, weight, ops_scales, EPS, scale_ub, ops_residual + ) + + torch.testing.assert_close(ref_scales, ops_scales) + # allow 1 ULP difference + assert ( + ref_out.view(torch.uint8).to(torch.int16) + - ops_out.view(torch.uint8).to(torch.int16) + ).abs().max() <= 1 + + if add_residual: + torch.testing.assert_close(ref_residual, ops_residual) + + +class TestRmsNormDynamicPerTokenQuantIntegration: + def test_kernel_registration_integration(self): + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + assert "rms_norm_dynamic_per_token_quant" in registered_kernels + + kernel_wrapper = registered_kernels["rms_norm_dynamic_per_token_quant"] + assert kernel_wrapper.op_name == "rms_norm_dynamic_per_token_quant" + assert kernel_wrapper._config_picker is not None + assert kernel_wrapper._mutates_args == ["result", "scale", "residual"] + + def test_fake_impl_functionality(self): + skip_if_platform_unsupported("rms_norm_dynamic_per_token_quant") + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + kernel_wrapper = registered_kernels["rms_norm_dynamic_per_token_quant"] + fake_impl = kernel_wrapper._fake_impl + + args = _generate_fake_input(16, 4096) + assert fake_impl(*args) is None diff --git a/tests/kernels/helion/test_rms_norm_per_block_quant.py b/tests/kernels/helion/test_rms_norm_per_block_quant.py new file mode 100644 index 000000000000..4cb18d775985 --- /dev/null +++ b/tests/kernels/helion/test_rms_norm_per_block_quant.py @@ -0,0 +1,298 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the rms_norm_per_block_quant helion kernel + +Run `pytest tests/kernels/helion/test_rms_norm_per_block_quant.py`. +""" + +import itertools +from typing import Any + +import pytest +import torch +from torch._subclasses.fake_tensor import FakeTensorMode + +from tests.kernels.helion.utils import skip_if_platform_unsupported +from tests.kernels.quant_utils import FP8_DTYPE +from vllm.kernels.helion.case_key import CaseKey +from vllm.kernels.helion.config_manager import ConfigManager +from vllm.kernels.helion.ops.rms_norm_per_block_quant import ( + _pick_cache, + baseline, + pick_config, + rms_norm_per_block_quant, +) +from vllm.utils.import_utils import has_helion +from vllm.utils.torch_utils import set_random_seed + +if not has_helion(): + pytest.skip( + "Helion is not installed. Install with: pip install vllm[helion]", + allow_module_level=True, + ) + + +def _generate_fake_input( + num_tokens: int, hidden_size: int, group_size: int +) -> tuple[Any, ...]: + with FakeTensorMode(): + input = torch.randn( + (num_tokens, hidden_size), device="cuda", dtype=torch.bfloat16 + ) + result = torch.empty(input.shape, device=input.device, dtype=FP8_DTYPE) + scale = torch.empty( + (num_tokens, hidden_size // group_size), + device=input.device, + dtype=torch.float32, + ) + scale_ub = torch.mean(input).to(scale.dtype) + residual = torch.randn_like(input) + weight = torch.normal( + mean=1.0, + std=1.0, + size=(hidden_size,), + dtype=input.dtype, + device=input.device, + ) + epsilon = 1e-6 + args = ( + result, + input, + weight, + scale, + epsilon, + scale_ub, + residual, + group_size, + False, + ) + return args + + +@pytest.fixture(autouse=True) +def reset_config_manager_singleton(): + ConfigManager.reset_instance() + ConfigManager() + yield + ConfigManager.reset_instance() + + +class TestRmsNormPerBlockQuantConfigPicker: + def setup_method(self): + _pick_cache.clear() + + def test_config_picker_exact_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 16}), + ] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"hidden_size": 4096, "group_size": 128, "num_tokens": 16} + ) + + def test_config_picker_closest_match(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(20, 3000, 70) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"hidden_size": 2048, "group_size": 64, "num_tokens": 32} + ) + + def test_config_picker_no_configs(self): + config_keys: list[dict] = [] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key is None + + def test_config_picker_fallback_to_largest(self): + config_keys = [ + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 2048, "group_size": 128, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 64, "num_tokens": 32}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 16}), + CaseKey({"hidden_size": 4096, "group_size": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(64, 8192, 256) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"hidden_size": 4096, "group_size": 128, "num_tokens": 32} + ) + + +DTYPES = [torch.bfloat16, torch.float] +QUANT_DTYPES = [torch.int8, FP8_DTYPE] +VEC_HIDDEN_SIZES = [64, 1024] +# Avoid combinatorial explosion with full Cartesian product +NUM_TOKENS_HIDDEN_SIZES = [ + *[(1, i) for i in [64, 128, 1024, 5120]], + *[(2048, i) for i in [64, 1024]], + *[(4096, i) for i in [64]], +] + +ADD_RESIDUAL = [False, True] +SCALE_UBS = [True, False] +GROUP_SIZES = [64, 128] +TMA_ALIGNMENTS = [0, 4] +SEEDS = [0] +EPS = 1e-6 + + +class TestRmsNormPerBlockQuantCorrectness: + @pytest.mark.parametrize("num_tokens, hidden_size", NUM_TOKENS_HIDDEN_SIZES) + @pytest.mark.parametrize("add_residual", ADD_RESIDUAL) + @pytest.mark.parametrize("has_scale_ub", SCALE_UBS) + @pytest.mark.parametrize("dtype", DTYPES) + @pytest.mark.parametrize("quant_dtype", QUANT_DTYPES) + @pytest.mark.parametrize("is_scale_transposed", [False, True]) + @pytest.mark.parametrize( + "group_size, tma_alignment", + [*itertools.product(GROUP_SIZES, TMA_ALIGNMENTS)], + ) + @pytest.mark.parametrize("seed", SEEDS) + def test_rms_norm_per_block_quant( + self, + num_tokens: int, + hidden_size: int, + add_residual: bool, + has_scale_ub: bool, + dtype: torch.dtype, + quant_dtype: torch.dtype, + is_scale_transposed: bool, + group_size: int, + tma_alignment: int, + seed: int, + ) -> None: + skip_if_platform_unsupported("rms_norm_per_block_quant") + + set_random_seed(seed) + + if hidden_size % group_size != 0: + # skip + return + + if tma_alignment != 0 and hidden_size // group_size % tma_alignment == 0: + # Skip tests where TMA alignment doesn't create extra padding to save time + return + + if has_scale_ub and quant_dtype != FP8_DTYPE: + # skip + return + + scale = 1 / (hidden_size) + input = torch.randn(num_tokens, hidden_size, dtype=dtype, device="cuda") * scale + weight = torch.normal( + mean=1.0, std=1.0, size=(hidden_size,), dtype=dtype, device=input.device + ) + residual = torch.randn_like(input) * scale if add_residual else None + scale_ub = ( + torch.mean(input).to(dtype=torch.float32, device="cuda") + if has_scale_ub + else None + ) + groups_per_row = hidden_size // group_size + + ref_residual = residual.clone() if residual is not None else None + ops_residual = residual.clone() if residual is not None else None + ref_out = torch.empty(input.shape, device=input.device, dtype=quant_dtype) + ops_out = ref_out.clone() + + if is_scale_transposed: + if tma_alignment == 0: + ref_scales = torch.empty( + (groups_per_row, num_tokens), + device=input.device, + dtype=torch.float32, + ).transpose(0, 1) + else: + tma_aligned_m = ( + (num_tokens + tma_alignment - 1) // tma_alignment * tma_alignment + ) + shape = (num_tokens, groups_per_row) + stride = (1, tma_aligned_m) + ref_scales = torch.empty_strided( + shape, stride, device=input.device, dtype=torch.float32 + ) + else: + ref_scales = torch.empty( + (num_tokens, groups_per_row), device=input.device, dtype=torch.float32 + ) + + ops_scales = ref_scales.clone() + + baseline( + ref_out, + input, + weight, + ref_scales, + EPS, + scale_ub, + ref_residual, + group_size, + is_scale_transposed, + ) + ref_scales = ref_scales.contiguous() + + rms_norm_per_block_quant( + ops_out, + input, + weight, + ops_scales, + EPS, + scale_ub, + ops_residual, + group_size, + is_scale_transposed, + ) + ops_scales = ops_scales.contiguous() + + torch.testing.assert_close(ref_scales, ops_scales) + # allow 1 ULP difference + assert ( + ref_out.view(torch.uint8).to(torch.int16) + - ops_out.view(torch.uint8).to(torch.int16) + ).abs().max() <= 1 + + if add_residual: + torch.testing.assert_close(ref_residual, ops_residual) + + +class TestRmsNormPerBlockQuantIntegration: + def test_kernel_registration_integration(self): + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + assert "rms_norm_per_block_quant" in registered_kernels + + kernel_wrapper = registered_kernels["rms_norm_per_block_quant"] + assert kernel_wrapper.op_name == "rms_norm_per_block_quant" + assert kernel_wrapper._config_picker is not None + assert kernel_wrapper._mutates_args == ["result", "scale", "residual"] + + def test_fake_impl_functionality(self): + skip_if_platform_unsupported("rms_norm_per_block_quant") + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + kernel_wrapper = registered_kernels["rms_norm_per_block_quant"] + fake_impl = kernel_wrapper._fake_impl + + args = _generate_fake_input(16, 4096, 128) + assert fake_impl(*args) is None diff --git a/tests/kernels/helion/test_silu_and_mul_per_block_quant.py b/tests/kernels/helion/test_silu_and_mul_per_block_quant.py new file mode 100644 index 000000000000..b8fcd9c8a676 --- /dev/null +++ b/tests/kernels/helion/test_silu_and_mul_per_block_quant.py @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the silu_and_mul_per_block_quant helion kernel +Run `pytest tests/kernels/helion/test_silu_and_mul_per_block_quant.py`. +""" + +from typing import Any + +import pytest +import torch +from torch._subclasses.fake_tensor import FakeTensorMode + +from tests.kernels.helion.utils import skip_if_platform_unsupported +from tests.kernels.quant_utils import FP8_DTYPE +from vllm.kernels.helion.case_key import CaseKey +from vllm.kernels.helion.config_manager import ConfigManager +from vllm.kernels.helion.ops.silu_and_mul_per_block_quant import ( + _pick_cache, + baseline, + pick_config, + silu_and_mul_per_block_quant, +) +from vllm.platforms import current_platform +from vllm.utils.import_utils import has_helion +from vllm.utils.torch_utils import set_random_seed + +if not has_helion(): + pytest.skip( + "Helion is not installed. Install with: pip install vllm[helion]", + allow_module_level=True, + ) + + +def _generate_fake_input( + num_tokens: int, intermediate_size: int, group_size: int +) -> tuple[Any, ...]: + with FakeTensorMode(): + in_dtype: torch.dtype = torch.bfloat16 + out_dtype: torch.dtype = current_platform.fp8_dtype() + scale_dtype: torch.dtype = torch.float32 + input = torch.randn( + num_tokens, 2 * intermediate_size, device="cuda", dtype=in_dtype + ) + result = torch.empty( + num_tokens, intermediate_size, device=input.device, dtype=out_dtype + ) + scale = torch.empty( + (num_tokens, intermediate_size // group_size), + device=input.device, + dtype=scale_dtype, + ) + scale_ub = torch.mean(input).to(scale_dtype) + args = ( + result, + input, + scale, + group_size, + scale_ub, + False, + ) + return args + + +class TestSiluAndMulPerBlockQuantConfigPicker: + def setup_method(self): + _pick_cache.clear() + + def test_config_picker_exact_match(self): + config_keys = [ + CaseKey({"intermediate_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"intermediate_size": 4096, "group_size": 128, "num_tokens": 16}), + ] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"intermediate_size": 4096, "group_size": 128, "num_tokens": 16} + ) + + def test_config_picker_closest_match(self): + config_keys = [ + CaseKey({"intermediate_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"intermediate_size": 2048, "group_size": 64, "num_tokens": 32}), + CaseKey({"intermediate_size": 2048, "group_size": 128, "num_tokens": 16}), + CaseKey({"intermediate_size": 2048, "group_size": 128, "num_tokens": 32}), + CaseKey({"intermediate_size": 4096, "group_size": 64, "num_tokens": 16}), + CaseKey({"intermediate_size": 4096, "group_size": 64, "num_tokens": 32}), + CaseKey({"intermediate_size": 4096, "group_size": 128, "num_tokens": 16}), + CaseKey({"intermediate_size": 4096, "group_size": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(20, 3000, 70) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"intermediate_size": 2048, "group_size": 64, "num_tokens": 32} + ) + + def test_config_picker_no_configs(self): + config_keys: list[dict] = [] + + args = _generate_fake_input(16, 4096, 128) + selected_key = pick_config(args, config_keys) + assert selected_key is None + + def test_config_picker_fallback_to_largest(self): + config_keys = [ + CaseKey({"intermediate_size": 2048, "group_size": 64, "num_tokens": 16}), + CaseKey({"intermediate_size": 2048, "group_size": 64, "num_tokens": 32}), + CaseKey({"intermediate_size": 2048, "group_size": 128, "num_tokens": 16}), + CaseKey({"intermediate_size": 2048, "group_size": 128, "num_tokens": 32}), + CaseKey({"intermediate_size": 4096, "group_size": 64, "num_tokens": 16}), + CaseKey({"intermediate_size": 4096, "group_size": 64, "num_tokens": 32}), + CaseKey({"intermediate_size": 4096, "group_size": 128, "num_tokens": 16}), + CaseKey({"intermediate_size": 4096, "group_size": 128, "num_tokens": 32}), + ] + + args = _generate_fake_input(64, 8192, 256) + selected_key = pick_config(args, config_keys) + assert selected_key == CaseKey( + {"intermediate_size": 4096, "group_size": 128, "num_tokens": 32} + ) + + +@pytest.fixture(autouse=True) +def reset_config_manager_singleton(): + ConfigManager.reset_instance() + ConfigManager() + yield + ConfigManager.reset_instance() + + +class TestSiluAndMulPerBlockQuantCorrectness: + @pytest.mark.parametrize("num_tokens", [1, 7, 4096]) + @pytest.mark.parametrize("hidden_size", [1024, 2048, 5120]) + @pytest.mark.parametrize("group_size", [64, 128]) + @pytest.mark.parametrize("is_scale_transposed", [False, True]) + @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) + @pytest.mark.parametrize("quant_dtype", [current_platform.fp8_dtype(), torch.int8]) + @pytest.mark.parametrize("has_scale_ub", [True, False]) + @pytest.mark.parametrize("seed", [0]) + def test_silu_and_mul_per_block_quant( + self, + num_tokens: int, + hidden_size: int, + group_size: int, + is_scale_transposed: bool, + dtype: torch.dtype, + quant_dtype: torch.dtype, + has_scale_ub: bool, + seed: int, + ) -> None: + skip_if_platform_unsupported("silu_and_mul_per_block_quant") + set_random_seed(seed) + + if hidden_size % group_size != 0: + return + + if has_scale_ub and quant_dtype != FP8_DTYPE: + # skip + return + + scale = 1 / hidden_size + x = torch.randn(num_tokens, 2 * hidden_size, dtype=dtype, device="cuda") * scale + + if has_scale_ub: + act = torch.nn.functional.silu(x[:, :hidden_size]) * x[:, hidden_size:] + act_abs = act.abs().float() + scale_ub = 0.5 * (act_abs.mean() + act_abs.amax()) + else: + scale_ub = None + + ref_out = torch.empty(num_tokens, hidden_size, device="cuda", dtype=quant_dtype) + + if is_scale_transposed: + ref_scales = torch.empty( + (hidden_size // group_size, x.shape[0]), + device="cuda", + dtype=torch.float32, + ).t() + else: + ref_scales = torch.empty( + (x.shape[0], hidden_size // group_size), + device="cuda", + dtype=torch.float32, + ) + + ops_out = ref_out.clone() + ops_scales = ref_scales.clone() + + baseline(ref_out, x, ref_scales, group_size, scale_ub, is_scale_transposed) + silu_and_mul_per_block_quant( + ops_out, x, ops_scales, group_size, scale_ub, is_scale_transposed + ) + + torch.testing.assert_close(ref_scales, ops_scales) + # allow 1 ULP difference + assert ( + ref_out.view(torch.uint8).to(torch.int16) + - ops_out.view(torch.uint8).to(torch.int16) + ).abs().max() <= 1 + + +class TestSiluAndMulPerBlockQuantIntegration: + def test_kernel_registration_integration(self): + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + assert "silu_and_mul_per_block_quant" in registered_kernels + + kernel_wrapper = registered_kernels["silu_and_mul_per_block_quant"] + assert kernel_wrapper.op_name == "silu_and_mul_per_block_quant" + assert kernel_wrapper._config_picker is not None + assert kernel_wrapper._mutates_args == ["out", "scales"] + + def test_fake_impl_functionality(self): + skip_if_platform_unsupported("silu_and_mul_per_block_quant") + from vllm.kernels.helion.register import get_registered_kernels + + registered_kernels = get_registered_kernels() + kernel_wrapper = registered_kernels["silu_and_mul_per_block_quant"] + fake_impl = kernel_wrapper._fake_impl + + args = _generate_fake_input(16, 4096, 128) + assert fake_impl(*args) is None diff --git a/tests/kernels/helion/test_utils.py b/tests/kernels/helion/test_utils.py index 540cc4f8bc71..f357fbf64733 100644 --- a/tests/kernels/helion/test_utils.py +++ b/tests/kernels/helion/test_utils.py @@ -17,6 +17,7 @@ ("NVIDIA H100 SXM5", "nvidia_h100"), ("NVIDIA GeForce RTX 4090", "nvidia_geforce_rtx_4090"), ("AMD Instinct MI300X", "amd_instinct_mi300x"), + ("AMD Instinct MI250X / MI250", "amd_instinct_mi250x_mi250"), ("Tesla V100-SXM2-32GB", "tesla_v100"), ], ) diff --git a/tests/kernels/helion/utils.py b/tests/kernels/helion/utils.py new file mode 100644 index 000000000000..38893fc8fecb --- /dev/null +++ b/tests/kernels/helion/utils.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Helion Kernel test utils""" + +import pytest +import torch + +from vllm.kernels.helion.config_manager import ConfigManager + + +def skip_if_platform_unsupported(op_name: str): + try: + from vllm.kernels.helion.utils import get_canonical_gpu_name + + if not torch.cuda.is_available(): + pytest.skip("CUDA not available") + + platform = get_canonical_gpu_name() + + try: + config_manager = ConfigManager.get_instance() + except RuntimeError: + config_manager = ConfigManager() + + configs = config_manager.get_platform_configs(op_name, platform) + if len(configs) == 0: + pytest.skip(f"Current GPU platform not supported for {op_name} kernel") + + except (ImportError, RuntimeError, KeyError): + pytest.skip(f"Error detecting platform support for {op_name} kernel") diff --git a/tests/kernels/mamba/cpu/test_cpu_gdn_ops.py b/tests/kernels/mamba/cpu/test_cpu_gdn_ops.py index 4b800b192b26..bd30bc4f1ce0 100644 --- a/tests/kernels/mamba/cpu/test_cpu_gdn_ops.py +++ b/tests/kernels/mamba/cpu/test_cpu_gdn_ops.py @@ -25,6 +25,8 @@ (64, 32), ] CHUNK_SIZE = 64 +CONV_DIM = 128 +CONV_KERNEL = 4 PREFILL_SEQ_LENS = [ [1], [1, 2, 3], @@ -312,3 +314,225 @@ def test_chunk_gated_delta_rule_cpu( atol=1e-2, rtol=1e-2, ) + + +# (total_tokens, split) pairs mimicking where chunked prefill breaks a sequence +# across two scheduler steps: chunk-aligned and non-aligned splits. +TWO_CALL_SPLITS = [ + (2 * CHUNK_SIZE, CHUNK_SIZE), + (2 * CHUNK_SIZE + 17, CHUNK_SIZE), + (2 * CHUNK_SIZE + 17, CHUNK_SIZE + 9), + (4 * CHUNK_SIZE + 17, 2 * CHUNK_SIZE), + (3 * CHUNK_SIZE, CHUNK_SIZE + 1), +] + + +@pytest.mark.parametrize("total_tokens, split", TWO_CALL_SPLITS) +@pytest.mark.parametrize("num_heads", NUM_HEADS) +@pytest.mark.parametrize("head_dims", HEAD_DIMS) +@torch.inference_mode() +def test_chunk_gated_delta_rule_cpu_two_call_split( + total_tokens: int, + split: int, + num_heads: tuple[int, int], + head_dims: tuple[int, int], +) -> None: + """A prefill split into two calls (the second seeded with the first's + ``final_state`` and a rebased ``cu_seqlens``) must match the single-call + result, mimicking the cross-scheduler-step handoff in + ``cpu_gdn_attention_core``. + """ + q, k, v, a, b, A_log, dt_bias = gdn_inputs( + num_tokens=total_tokens, + num_heads=num_heads, + head_dims=head_dims, + ) + _, num_v_heads = num_heads + head_dim, v_head_dim = head_dims + + g, beta = ref_gdn_gating(A_log, a, b, dt_bias) + g = g.unsqueeze(0) # [1, T, HV] + beta = beta.unsqueeze(0) + + zero_state = torch.zeros(1, num_v_heads, head_dim, v_head_dim, dtype=torch.float32) + + # Reference: whole sequence in one call, no initial state. + out_full, final_full = ops.chunk_gated_delta_rule_cpu( + query=q, + key=k, + value=v, + g=g, + beta=beta, + initial_state=zero_state, + output_final_state=True, + cu_seqlens=torch.tensor([0, total_tokens], dtype=torch.int32), + head_first=False, + use_qk_l2norm_in_kernel=True, + ) + + # Call 1: tokens [0:split], no initial state, capture final state. + out1, state1 = ops.chunk_gated_delta_rule_cpu( + query=q[:, :split], + key=k[:, :split], + value=v[:, :split], + g=g[:, :split], + beta=beta[:, :split], + initial_state=zero_state, + output_final_state=True, + cu_seqlens=torch.tensor([0, split], dtype=torch.int32), + head_first=False, + use_qk_l2norm_in_kernel=True, + ) + # Call 2: tokens [split:T] seeded with call 1's final state and a cu_seqlens + # rebased to start at 0, as cpu_gdn_attention_core continues a prefill chunk. + tail = total_tokens - split + out2, state2 = ops.chunk_gated_delta_rule_cpu( + query=q[:, split:], + key=k[:, split:], + value=v[:, split:], + g=g[:, split:], + beta=beta[:, split:], + initial_state=state1.to(torch.float32), + output_final_state=True, + cu_seqlens=torch.tensor([0, tail], dtype=torch.int32), + head_first=False, + use_qk_l2norm_in_kernel=True, + ) + + out_split = torch.cat([out1, out2], dim=1) + + # State must be near-exact; output allows a looser bound for the bf16 round-trip. + torch.testing.assert_close(state2, final_full, atol=1e-3, rtol=1e-3) + torch.testing.assert_close(out_split, out_full, atol=2e-2, rtol=2e-2) + + +def _conv_inputs(total_tokens: int): + x = tensor_cache(total_tokens * CONV_DIM, torch.bfloat16).view( + total_tokens, CONV_DIM + ) + weight = tensor_cache(CONV_DIM * CONV_KERNEL, torch.bfloat16).view( + CONV_DIM, CONV_KERNEL + ) + bias = tensor_cache(CONV_DIM, torch.bfloat16) + return x, weight, bias + + +@pytest.mark.parametrize("total_tokens, split", TWO_CALL_SPLITS) +@torch.inference_mode() +def test_causal_conv1d_torch_two_call_split(total_tokens: int, split: int) -> None: + """Non-AMX conv-state handoff: a two-call split (the second seeded via + ``has_initial_state=True`` from the conv_states the first wrote back) must + match the single-call result. + """ + from vllm.model_executor.layers.mamba.ops.cpu.causal_conv1d import ( + causal_conv1d_torch, + ) + + x, weight, bias = _conv_inputs(total_tokens) + state_len = CONV_KERNEL - 1 + # [num_slots, conv_dim, state_len]; slot 0 used here. + conv_states_full = torch.zeros(1, CONV_DIM, state_len, dtype=x.dtype) + conv_states_split = torch.zeros(1, CONV_DIM, state_len, dtype=x.dtype) + + # x is [conv_dim, T] for causal_conv1d_torch. + xt = x.transpose(0, 1).contiguous() + + out_full = causal_conv1d_torch( + x=xt, + weight=weight, + bias=bias, + conv_states=conv_states_full, + query_start_loc=torch.tensor([0, total_tokens], dtype=torch.int32), + cache_indices=torch.tensor([0], dtype=torch.int32), + has_initial_state=torch.tensor([False]), + activation="silu", + ) + + out1 = causal_conv1d_torch( + x=xt[:, :split], + weight=weight, + bias=bias, + conv_states=conv_states_split, + query_start_loc=torch.tensor([0, split], dtype=torch.int32), + cache_indices=torch.tensor([0], dtype=torch.int32), + has_initial_state=torch.tensor([False]), + activation="silu", + ) + out2 = causal_conv1d_torch( + x=xt[:, split:], + weight=weight, + bias=bias, + conv_states=conv_states_split, + query_start_loc=torch.tensor([0, total_tokens - split], dtype=torch.int32), + cache_indices=torch.tensor([0], dtype=torch.int32), + has_initial_state=torch.tensor([True]), + activation="silu", + ) + out_split = torch.cat([out1, out2], dim=1) + + torch.testing.assert_close(out_split, out_full, atol=1e-2, rtol=1e-2) + + +@pytest.mark.skipif( + not torch.cpu._is_amx_tile_supported(), + reason="causal_conv1d_fwd_cpu requires AMX/AVX512", +) +@pytest.mark.parametrize("total_tokens, split", TWO_CALL_SPLITS) +@torch.inference_mode() +def test_causal_conv1d_fwd_cpu_two_call_split(total_tokens: int, split: int) -> None: + """AMX prefill conv op must honor ``has_initial_state`` so a two-call split + matches the single-call result. + + Regression test for ``causal_conv1d_fwd_varlen_kernel_impl`` (``conv.cpp``) + ignoring the carried conv state on continued chunks. + """ + state_len = CONV_KERNEL - 1 + x, weight, bias = _conv_inputs(total_tokens) + + def amx(x_seg, conv_states, has_init): + seq = x_seg.shape[0] + return ops.causal_conv1d_fwd_cpu( + x=x_seg.transpose(0, 1), # [dim, seq]; stride(-2)==1 (view of [seq,dim]) + weight=weight, + bias=bias, + conv_states=conv_states, + query_start_loc=torch.tensor([0, seq], dtype=torch.int32), + cache_indices=torch.tensor([0], dtype=torch.int32), + has_initial_state=torch.tensor([has_init]), + silu_activation=True, + is_vnni=False, + ).contiguous() + + # conv_state layout passed by the AMX branch: [num_slots, dim, state_len]. + cs_full = torch.zeros(1, CONV_DIM, state_len, dtype=x.dtype) + out_full = amx(x, cs_full, False) + + cs_split = torch.zeros(1, CONV_DIM, state_len, dtype=x.dtype) + out1 = amx(x[:split], cs_split, False) + out2 = amx(x[split:], cs_split, True) + out_split = torch.cat([out1, out2], dim=1) + + torch.testing.assert_close(out_split, out_full, atol=1e-2, rtol=1e-2) + + +@torch.inference_mode() +def test_batch_memcpy_cpu_fallback() -> None: + """The ctypes batch_memcpy fallback (used when triton-cpu is absent) must + copy each src into its dst, validating the (src_ptrs, dst_ptrs, sizes) + argument order against ctypes.memmove(dst, src, size). + """ + from vllm.utils.cpu_triton_utils import batch_memcpy_kernel + + # Varied byte sizes, including a non-power-of-two run. + sizes_bytes = [256, 1024, 17 * 4, 4096] + srcs = [torch.rand(n // 4, dtype=torch.float32) for n in sizes_bytes] + dsts = [torch.zeros_like(s) for s in srcs] + + src_ptrs = torch.tensor([s.data_ptr() for s in srcs], dtype=torch.uint64) + dst_ptrs = torch.tensor([d.data_ptr() for d in dsts], dtype=torch.uint64) + sizes = torch.tensor(sizes_bytes, dtype=torch.int32) + + batch_memcpy_kernel[(len(srcs),)](src_ptrs, dst_ptrs, sizes, BLOCK_SIZE=1024) + + for src, dst in zip(srcs, dsts): + torch.testing.assert_close(dst, src) diff --git a/tests/kernels/mamba/test_causal_conv1d.py b/tests/kernels/mamba/test_causal_conv1d.py index 0ebc527d54d3..c6554f131fed 100644 --- a/tests/kernels/mamba/test_causal_conv1d.py +++ b/tests/kernels/mamba/test_causal_conv1d.py @@ -11,9 +11,17 @@ causal_conv1d_fn, causal_conv1d_update, ) +from vllm.platforms import current_platform from vllm.utils.torch_utils import set_random_seed from vllm.v1.attention.backends.utils import NULL_BLOCK_ID +DEVICE = current_platform.device_type + +pytestmark = pytest.mark.skipif( + not (current_platform.is_cuda_alike() or current_platform.is_xpu()), + reason="causal_conv1d Triton kernels require CUDA-alike or XPU", +) + def causal_conv1d_ref( x: torch.Tensor, @@ -149,7 +157,7 @@ def causal_conv1d_opcheck_fn( @pytest.mark.parametrize("width", [4]) @pytest.mark.parametrize("dim", [2048, 2048 + 16, 4096]) def test_causal_conv1d_update(dim, width, seqlen, has_bias, silu_activation, itype): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (3e-3, 5e-3) if itype == torch.bfloat16: rtol, atol = 1e-2, 5e-2 @@ -196,7 +204,7 @@ def test_causal_conv1d_update(dim, width, seqlen, has_bias, silu_activation, ity def test_causal_conv1d_update_with_batch_gather( batch_size, with_padding, dim, width, seqlen, has_bias, silu_activation, itype ): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (3e-3, 5e-3) if itype == torch.bfloat16: rtol, atol = 1e-2, 5e-2 @@ -275,7 +283,7 @@ def test_causal_conv1d_update_with_batch_gather( def test_causal_conv1d_varlen( batch, with_padding, dim, seqlen, width, has_bias, silu_activation, itype ): - device = "cuda" + device = DEVICE torch.accelerator.empty_cache() rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (3e-3, 5e-3) if itype == torch.bfloat16: @@ -341,7 +349,7 @@ def test_causal_conv1d_varlen( weight, bias=bias, conv_states=final_states, - query_start_loc=cumsum.cuda(), + query_start_loc=cumsum.to(device), cache_indices=padded_state_indices, has_initial_state=has_initial_states, activation=activation, diff --git a/tests/kernels/mamba/test_cpu_short_conv.py b/tests/kernels/mamba/test_cpu_short_conv.py new file mode 100644 index 000000000000..c8e85a455115 --- /dev/null +++ b/tests/kernels/mamba/test_cpu_short_conv.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +import torch + +from vllm.config import CompilationConfig, VllmConfig +from vllm.forward_context import set_forward_context +from vllm.model_executor.layers.mamba.short_conv import ShortConv +from vllm.model_executor.layers.utils import dispatch_cpu_unquantized_gemm +from vllm.platforms import current_platform +from vllm.v1.attention.backends.short_conv_attn import ShortConvAttentionMetadata + +if not current_platform.is_cpu(): + pytest.skip("skipping CPU-only tests", allow_module_level=True) + + +@pytest.fixture(autouse=True) +def mock_dist(): + with ( + patch( + "vllm.model_executor.layers.linear.get_tensor_model_parallel_rank", + return_value=0, + ), + patch( + "vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", + return_value=1, + ), + patch( + "vllm.distributed.parallel_state.model_parallel_is_initialized", + return_value=True, + ), + patch( + "vllm.distributed.parallel_state.get_tp_group", + return_value=MagicMock(rank_in_group=0), + ), + ): + yield + + +@pytest.fixture +def vllm_config(): + # ShortConv only needs compilation_config from the current vLLM config, so a + # minimal config (model_config=None) avoids mocking ModelConfig and the + # associated VllmConfig validation churn. + return VllmConfig(compilation_config=CompilationConfig()) + + +def test_short_conv_forward_native_prefill(vllm_config): + prefix = "test_layer" + config = SimpleNamespace(conv_L_cache=4, conv_bias=True) + dim = 16 + + from vllm.config import set_current_vllm_config + + with set_current_vllm_config(vllm_config): + layer = ShortConv(config=config, dim=dim, layer_idx=0, prefix=prefix) + + layer.to("cpu") + # vLLM Linear layers allocate weights with torch.empty (uninitialized). + # On ARM these come back as zero-filled pages, so in_proj output is zero and + # the prefill state stays zero. Seed + init to make the test platform-safe. + torch.manual_seed(0) + for p in layer.parameters(): + torch.nn.init.normal_(p) + dispatch_cpu_unquantized_gemm(layer.in_proj, remove_weight=False) + dispatch_cpu_unquantized_gemm(layer.out_proj, remove_weight=False) + + # Mock AttentionMetadata + num_prefills = 1 + num_prefill_tokens = 5 + query_start_loc_p = torch.tensor([0, 5], dtype=torch.int32) + state_indices_tensor_p = torch.tensor([0], dtype=torch.int32) + + # ShortConvAttentionMetadata + attn_metadata = ShortConvAttentionMetadata( + num_prefills=num_prefills, + num_prefill_tokens=num_prefill_tokens, + num_decodes=0, + num_decode_tokens=0, + num_reqs=1, + query_start_loc_p=query_start_loc_p, + has_initial_states_p=torch.tensor([False]), + state_indices_tensor_p=state_indices_tensor_p, + state_indices_tensor_d=torch.empty((0, 1), dtype=torch.int32), + num_accepted_tokens=None, + query_start_loc_d=None, + block_idx_last_scheduled_token=None, + block_idx_first_scheduled_token_p=None, + block_idx_last_computed_token=None, + block_idx_last_scheduled_token_prev_step=None, + num_computed_tokens_p=None, + seq_lens=torch.tensor([5]), + ) + + # Mock KV cache + # conv_state shape (num_blocks, L_cache - 1, dim) + conv_state = torch.zeros((1, config.conv_L_cache - 1, dim)) + layer.kv_cache = (conv_state,) + + hidden_states = torch.randn((num_prefill_tokens, dim)) + output = torch.zeros_like(hidden_states) + + attn_metadata_dict = {prefix: attn_metadata} + with set_forward_context(attn_metadata=attn_metadata_dict, vllm_config=vllm_config): + layer.forward_native(hidden_states, output) + + # Check if KV cache was updated + assert not torch.allclose(conv_state, torch.zeros_like(conv_state)) + + +def test_short_conv_forward_native_decode(vllm_config): + prefix = "test_layer_decode" + config = SimpleNamespace(conv_L_cache=4, conv_bias=True) + dim = 16 + + from vllm.config import set_current_vllm_config + + with set_current_vllm_config(vllm_config): + layer = ShortConv(config=config, dim=dim, layer_idx=0, prefix=prefix) + + layer.to("cpu") + torch.manual_seed(0) + for p in layer.parameters(): + torch.nn.init.normal_(p) + dispatch_cpu_unquantized_gemm(layer.in_proj, remove_weight=False) + dispatch_cpu_unquantized_gemm(layer.out_proj, remove_weight=False) + + # Mock AttentionMetadata for 2 decode requests + num_decodes = 2 + state_indices_tensor_d = torch.tensor([0, 1], dtype=torch.int32) + + attn_metadata = ShortConvAttentionMetadata( + num_prefills=0, + num_prefill_tokens=0, + num_decodes=num_decodes, + num_decode_tokens=num_decodes, + num_reqs=num_decodes, + query_start_loc_p=None, + has_initial_states_p=None, + state_indices_tensor_p=torch.empty((0,), dtype=torch.int32), + state_indices_tensor_d=state_indices_tensor_d, + num_accepted_tokens=None, + query_start_loc_d=torch.tensor([0, 1, 2], dtype=torch.int32), + block_idx_last_scheduled_token=None, + block_idx_first_scheduled_token_p=None, + block_idx_last_computed_token=None, + block_idx_last_scheduled_token_prev_step=None, + num_computed_tokens_p=None, + seq_lens=torch.tensor([1, 1]), + ) + + # Mock KV cache (2 blocks for 2 requests) + conv_state = torch.randn((2, config.conv_L_cache - 1, dim)) + layer.kv_cache = (conv_state,) + + hidden_states = torch.randn((num_decodes, dim)) + output = torch.zeros_like(hidden_states) + + old_conv_state = conv_state.clone() + + attn_metadata_dict = {prefix: attn_metadata} + with set_forward_context(attn_metadata=attn_metadata_dict, vllm_config=vllm_config): + layer.forward_native(hidden_states, output) + + # Check if KV cache was updated + assert not torch.allclose(conv_state, old_conv_state) + + +def test_dispatch_cpu_unquantized_gemm_conv_layer(): + # Convolution layers have >2D weights; dispatch should skip them gracefully. + # Shape/dtype are AMX-pack safe (bf16, width==4, dim % block_size == 0) so + # the AMX prepack branch does not raise on AMX-capable CPUs. + class MockConvLayer(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter( + torch.randn(32, 1, 4, dtype=torch.bfloat16) + ) + self.bias = torch.nn.Parameter(torch.randn(32, dtype=torch.bfloat16)) + + layer = MockConvLayer() + # The ndim != 2 guard returns early without raising. + dispatch_cpu_unquantized_gemm(layer, remove_weight=False) + # No cpu_linear set — conv layers are handled elsewhere. + assert not hasattr(layer, "cpu_linear") diff --git a/tests/kernels/mamba/test_gdn_forward_core_split.py b/tests/kernels/mamba/test_gdn_forward_core_split.py new file mode 100644 index 000000000000..f2bfc30abdb6 --- /dev/null +++ b/tests/kernels/mamba/test_gdn_forward_core_split.py @@ -0,0 +1,296 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Integration test for the non-spec decode split in +``GatedDeltaNet._forward_core``. + +On a pure non-spec batch that mixes prefills with 1-token decodes, the layer +peels the decodes (the contiguous decode-first front slice) off to +``fused_sigmoid_gating_delta_rule_update`` -- the same recurrent update kernel +the spec-decode path uses -- and runs only the prefill tail through +``chunk_gated_delta_rule``. This must produce the same core-attention output and +the same ssm-state pool update as running *everything* through +``chunk_gated_delta_rule`` (the previous behavior). + +Both paths are exercised through the REAL ``_forward_core``: + +* ``meta_split`` is built by the real ``GDNAttentionMetadataBuilder`` for a + mixed batch, so ``num_decodes > 0`` triggers the peel (and the builder rebases + ``chunk_indices``/``chunk_offsets`` to the prefill-only tail). +* ``meta_unified`` is the same metadata with the decodes reclassified as + prefills and full-batch chunk metadata, which forces ``_forward_core`` through + the existing chunk-only path on identical inputs (the conv is unified over all + non-spec tokens in both paths, so it cancels out and only the recurrent split + is compared). + +The Triton/FLA chunk backend is forced so the prefill-only ``chunk_indices`` +must stay consistent with the rebased ``cu_seqlens`` (a stringent, backend +portable check of the split wiring). +""" + +from __future__ import annotations + +import dataclasses +import types +from unittest.mock import patch + +import pytest +import torch + +from vllm.platforms import current_platform + +if not ( + current_platform.is_cuda() and current_platform.is_device_capability_family(100) +): + pytest.skip( + reason="GDN _forward_core split test uses the CuteDSL prefill backend " + "(requires CUDA SM10x).", + allow_module_level=True, + ) + +from tests.v1.attention.utils import ( # noqa: E402 + BatchSpec, + create_common_attn_metadata, + create_vllm_config, +) +from vllm.config import set_current_vllm_config # noqa: E402 +from vllm.model_executor.layers.fla.ops.index import ( # noqa: E402 + prepare_chunk_indices, + prepare_chunk_offsets, +) +from vllm.model_executor.layers.fla.ops.utils import FLA_CHUNK_SIZE # noqa: E402 +from vllm.model_executor.layers.mamba.gdn import qwen_gdn_linear_attn # noqa: E402 +from vllm.model_executor.layers.mamba.gdn.qwen_gdn_linear_attn import ( # noqa: E402 + ChunkGatedDeltaRule, + QwenGatedDeltaNetAttention, +) +from vllm.model_executor.layers.mamba.mamba_utils import ( # noqa: E402 + MambaStateShapeCalculator, +) +from vllm.v1.attention.backends.gdn_attn import ( # noqa: E402 + GDNAttentionMetadataBuilder, +) +from vllm.v1.kv_cache_interface import MambaSpec # noqa: E402 + +# Small GDN dims; head_k_dim/head_v_dim=128 keeps the chunk/update kernels happy. +H = 4 # num key heads +HV = 8 # num value heads +K = 128 # head_k_dim +V = 128 # head_v_dim +CONV_KERNEL = 4 +KEY_DIM = H * K +VALUE_DIM = HV * V +CONV_DIM = 2 * KEY_DIM + VALUE_DIM +BLOCK_SIZE = 16 +PREFIX = "model.layers.0.linear_attn" + + +def _make_vllm_config(): + # A small, ungated GDN model whose config is cached locally; only the config + # (scheduler/cache/compilation/hf) is used here, never the weights. Inject + # linear_key_head_dim=128 and request the CuteDSL prefill backend -- the + # supported GDN chunk kernel on Blackwell (the Triton/FLA chunk kernel is + # unsupported on SM10x). CuteDSL consumes chunk_indices/chunk_offsets, so + # this also exercises the prefill-only chunk-metadata wiring. + cfg = create_vllm_config( + model_name="Qwen/Qwen3.5-0.8B", + block_size=BLOCK_SIZE, + hf_config_override={"linear_key_head_dim": K}, + ) + cfg.additional_config = {"gdn_prefill_backend": "cutedsl"} + return cfg + + +def _build_layer( + vllm_config, conv_state, ssm_state, A_log, dt_bias, conv_weight, conv_bias +): + """A minimal object that runs the real ``_forward_core`` bound to it.""" + layer = types.SimpleNamespace() + layer.prefix = PREFIX + layer.enable_packed_recurrent_decode = False + layer.tp_size = 1 + layer.num_k_heads = H + layer.num_v_heads = HV + layer.head_k_dim = K + layer.head_v_dim = V + layer.key_dim = KEY_DIM + layer.value_dim = VALUE_DIM + layer.activation = "silu" + layer.A_log = A_log + layer.dt_bias = dt_bias + layer.conv1d = types.SimpleNamespace(weight=conv_weight, bias=conv_bias) + layer.kv_cache = (conv_state, ssm_state) + with set_current_vllm_config(vllm_config): + layer.chunk_gated_delta_rule = ChunkGatedDeltaRule() + for name in ( + "rearrange_mixed_qkv", + "_forward_core", + ): + setattr( + layer, + name, + types.MethodType(getattr(QwenGatedDeltaNetAttention, name), layer), + ) + return layer + + +def _run_forward_core(layer, meta, mixed_qkv, b, a, num_tokens): + core_attn_out = torch.zeros( + num_tokens, HV, V, dtype=mixed_qkv.dtype, device=mixed_qkv.device + ) + ctx = types.SimpleNamespace(attn_metadata={PREFIX: meta}) + with patch.object(qwen_gdn_linear_attn, "get_forward_context", return_value=ctx): + layer._forward_core( + mixed_qkv=mixed_qkv.clone(), + b=b.clone(), + a=a.clone(), + core_attn_out=core_attn_out, + ) + return core_attn_out + + +@pytest.mark.parametrize("state_dtype", [torch.bfloat16, torch.float32]) +@pytest.mark.parametrize("num_decodes,prefill_lens", [(3, [512, 300]), (4, [64, 5])]) +@pytest.mark.parametrize("fresh_prefill", [False, True]) +def test_forward_core_split_matches_unified( + state_dtype: torch.dtype, + num_decodes: int, + prefill_lens: list[int], + fresh_prefill: bool, +) -> None: + torch.manual_seed(0) + device = torch.device("cuda") + vllm_config = _make_vllm_config() + + # Decode-first batch: D 1-token decodes (with context), then the prefills. + decode_seq_lens = [64] * num_decodes + prefill_seq_lens = [ + pl if (fresh_prefill and i == 0) else pl + 37 + for i, pl in enumerate(prefill_lens) + ] + seq_lens = decode_seq_lens + prefill_seq_lens + query_lens = [1] * num_decodes + list(prefill_lens) + batch = BatchSpec(seq_lens=seq_lens, query_lens=query_lens) + + builder = GDNAttentionMetadataBuilder( + kv_cache_spec=MambaSpec( + block_size=BLOCK_SIZE, shapes=((16, 64),), dtypes=(torch.float16,) + ), + layer_names=[PREFIX], + vllm_config=vllm_config, + device=device, + ) + common = create_common_attn_metadata( + batch, BLOCK_SIZE, device, arange_block_indices=True + ) + with set_current_vllm_config(vllm_config): + meta_split = builder.build(common_prefix_len=0, common_attn_metadata=common) + + assert meta_split.spec_sequence_masks is None + assert meta_split.num_decodes == num_decodes + assert meta_split.num_prefills == len(prefill_lens) + assert meta_split.num_decode_tokens == num_decodes + assert builder.gdn_prefill_backend == "cutedsl" + + num_tokens = sum(query_lens) + + # Full-batch chunk metadata for the unified reference path, built the same + # way the builder would for a non-split batch (backend-matched). + cu_full = meta_split.non_spec_query_start_loc + if builder.gdn_prefill_backend == "cutedsl": + from vllm.model_executor.layers.mamba.ops.gdn_chunk_cutedsl import ( + prepare_metadata_cutedsl, + ) + + full_ci, full_co = prepare_metadata_cutedsl( + cu_full, int(cu_full[-1].item()), FLA_CHUNK_SIZE + ) + else: + cu_full_cpu = cu_full.cpu() + full_ci = prepare_chunk_indices(cu_full_cpu, FLA_CHUNK_SIZE).to(device) + full_co = prepare_chunk_offsets(cu_full_cpu, FLA_CHUNK_SIZE).to(device) + meta_unified = dataclasses.replace( + meta_split, + num_decodes=0, + num_decode_tokens=0, + num_prefills=meta_split.num_decodes + meta_split.num_prefills, + num_prefill_tokens=( + meta_split.num_decode_tokens + meta_split.num_prefill_tokens + ), + chunk_indices=full_ci, + chunk_offsets=full_co, + # Unified path: the chunk kernel processes the full non-spec batch. + prefill_query_start_loc=meta_split.non_spec_query_start_loc, + prefill_state_indices=meta_split.non_spec_state_indices_tensor, + prefill_has_initial_state=meta_split.has_initial_state, + ) + + # Size the state pools from the indices the builder actually produced. + pool_size = int(meta_split.non_spec_state_indices_tensor.max().item()) + 1 + conv_state_shape, temporal_state_shape = ( + MambaStateShapeCalculator.gated_delta_net_state_shape( + 1, H, HV, K, V, CONV_KERNEL, num_spec=0 + ) + ) + conv_state0 = ( + torch.randn(pool_size, *conv_state_shape, dtype=torch.bfloat16, device=device) + * 0.05 + ) + ssm_state0 = ( + torch.randn(pool_size, *temporal_state_shape, dtype=state_dtype, device=device) + * 0.05 + ) + + A_log = torch.randn(HV, dtype=torch.float32, device=device) * 0.1 + dt_bias = torch.randn(HV, dtype=torch.float32, device=device) * 0.1 + conv_weight = ( + torch.randn(CONV_DIM, 1, CONV_KERNEL, dtype=torch.bfloat16, device=device) * 0.1 + ) + conv_bias = torch.randn(CONV_DIM, dtype=torch.bfloat16, device=device) * 0.1 + + mixed_qkv = ( + torch.randn(num_tokens, CONV_DIM, dtype=torch.bfloat16, device=device) * 0.1 + ) + a = torch.randn(num_tokens, HV, dtype=torch.bfloat16, device=device) * 0.1 + b = torch.randn(num_tokens, HV, dtype=torch.bfloat16, device=device) * 0.1 + + # ---- Split path (real _forward_core, meta_split) ---- + conv_state_split = conv_state0.clone() + ssm_state_split = ssm_state0.clone() + layer_split = _build_layer( + vllm_config, + conv_state_split, + ssm_state_split, + A_log, + dt_bias, + conv_weight, + conv_bias, + ) + out_split = _run_forward_core(layer_split, meta_split, mixed_qkv, b, a, num_tokens) + + # ---- Unified path (real _forward_core, meta_unified) ---- + conv_state_unified = conv_state0.clone() + ssm_state_unified = ssm_state0.clone() + layer_unified = _build_layer( + vllm_config, + conv_state_unified, + ssm_state_unified, + A_log, + dt_bias, + conv_weight, + conv_bias, + ) + out_unified = _run_forward_core( + layer_unified, meta_unified, mixed_qkv, b, a, num_tokens + ) + + # Conv is unified in both paths, so the conv-state update must be identical. + torch.testing.assert_close(conv_state_split, conv_state_unified, atol=0, rtol=0) + + # Chunk vs. recurrent update accumulate in different orders; mirror the + # tolerances used by the kernel-level parity test. + if state_dtype == torch.float32: + atol = rtol = 2e-2 + else: + atol = rtol = 6e-2 + torch.testing.assert_close(out_split, out_unified, atol=atol, rtol=rtol) + torch.testing.assert_close(ssm_state_split, ssm_state_unified, atol=atol, rtol=rtol) diff --git a/tests/kernels/mamba/test_mamba_ssm.py b/tests/kernels/mamba/test_mamba_ssm.py index d812242cba96..7350b6465231 100644 --- a/tests/kernels/mamba/test_mamba_ssm.py +++ b/tests/kernels/mamba/test_mamba_ssm.py @@ -17,6 +17,21 @@ from vllm.utils.torch_utils import set_random_seed from vllm.v1.attention.backends.utils import NULL_BLOCK_ID +DEVICE = current_platform.device_type + +pytestmark = pytest.mark.skipif( + not (current_platform.is_cuda_alike() or current_platform.is_xpu()), + reason="mamba_ssm kernels require CUDA-alike or XPU", +) + +# selective_scan_fn is backed by the CUDA-only `ops.selective_scan_fwd` C++ op, +# so tests exercising it must be skipped on XPU. selective_state_update is +# pure Triton and runs on both CUDA-alike and XPU. +skip_unless_cuda_alike = pytest.mark.skipif( + not current_platform.is_cuda_alike(), + reason="selective_scan_fn uses CUDA-only custom op", +) + def selective_scan_ref( u, @@ -181,6 +196,7 @@ def selective_scan_opcheck_fn( @pytest.mark.parametrize("is_variable_C", [True]) @pytest.mark.parametrize("is_variable_B", [True]) @pytest.mark.parametrize("scan_chunks", [1, 3]) +@skip_unless_cuda_alike def test_selective_scan( is_variable_B, is_variable_C, @@ -327,11 +343,11 @@ def test_selective_scan( @pytest.mark.parametrize("dstate", [16, 64]) @pytest.mark.parametrize("dim", [2048, 2048 + 16, 4096]) def test_selective_state_update(dim, dstate, has_z, itype): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (5e-3, 1e-2) if itype == torch.bfloat16: rtol, atol = 1e-2, 5e-2 - if torch.version.hip: + if current_platform.is_rocm() or current_platform.is_xpu(): atol *= 2 # set seed set_random_seed(0) @@ -370,7 +386,7 @@ def test_selective_state_update(dim, dstate, has_z, itype): " on compute capability 10.0 CUDA devices.", ) def test_selective_state_update_stochastic_rounding(dim, dstate, has_z, philox_rounds): - device = "cuda" + device = DEVICE rtol, atol = 5e-3, 1e-1 # set seed set_random_seed(0) @@ -417,11 +433,11 @@ def test_selective_state_update_stochastic_rounding(dim, dstate, has_z, philox_r @pytest.mark.parametrize("dim", [2048, 2048 + 16, 4096]) @pytest.mark.parametrize("max_seq_len", [1, 2, 4]) def test_selective_state_update_varlen(dim, dstate, has_z, itype, max_seq_len): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (5e-3, 1e-2) if itype == torch.bfloat16: rtol, atol = 5e-2, 1.5e-1 - if torch.version.hip: + if current_platform.is_rocm() or current_platform.is_xpu(): atol *= 2 # set seed set_random_seed(0) @@ -498,6 +514,7 @@ def test_selective_state_update_varlen(dim, dstate, has_z, itype, max_seq_len): @pytest.mark.parametrize("is_variable_B", [True]) # tests correctness in case subset of the sequences are padded @pytest.mark.parametrize("with_padding", [False, True]) +@skip_unless_cuda_alike def test_selective_scan_varlen( with_padding, is_variable_B, @@ -679,11 +696,11 @@ def test_selective_scan_varlen( def test_selective_state_update_with_batch_indices( with_padding, dim, dstate, has_z, itype ): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (5e-3, 1e-2) if itype == torch.bfloat16: rtol, atol = 1e-1, 1e-1 - if torch.version.hip: + if current_platform.is_rocm() or current_platform.is_xpu(): atol *= 2 # set seed torch.random.manual_seed(0) @@ -771,7 +788,7 @@ def test_selective_state_update_with_batch_indices( def test_selective_state_update_with_heads_with_batch_indices( dim, dstate, ngroups, has_z, tie_hdim, itype ): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (5e-3, 3e-2) if itype == torch.bfloat16: rtol, atol = 1e-1, 1e-1 @@ -844,11 +861,11 @@ def test_selective_state_update_with_heads_with_batch_indices( def test_selective_state_update_with_num_accepted_tokens( dim, dstate, has_z, itype, max_seq_len ): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (5e-3, 1e-2) if itype == torch.bfloat16: rtol, atol = 5e-2, 1.5e-1 - if torch.version.hip: + if current_platform.is_rocm() or current_platform.is_xpu(): atol *= 2 set_random_seed(0) @@ -970,11 +987,11 @@ def test_selective_state_update_with_num_accepted_tokens( def test_selective_state_update_varlen_with_num_accepted( dim, dstate, has_z, itype, max_seq_len ): - device = "cuda" + device = DEVICE rtol, atol = (3e-4, 1e-3) if itype == torch.float32 else (5e-3, 1e-2) if itype == torch.bfloat16: rtol, atol = 5e-2, 1.5e-1 - if torch.version.hip: + if current_platform.is_rocm() or current_platform.is_xpu(): atol *= 2 set_random_seed(0) diff --git a/tests/kernels/mamba/test_mamba_ssm_ssd.py b/tests/kernels/mamba/test_mamba_ssm_ssd.py index 40aa3d017d78..1de25780eac4 100644 --- a/tests/kernels/mamba/test_mamba_ssm_ssd.py +++ b/tests/kernels/mamba/test_mamba_ssm_ssd.py @@ -9,9 +9,19 @@ from vllm.model_executor.layers.mamba.ops.ssd_combined import ( mamba_chunk_scan_combined_varlen, ) +from vllm.platforms import current_platform from vllm.utils.torch_utils import set_random_seed from vllm.v1.attention.backends.mamba2_attn import compute_varlen_chunk_metadata +# All kernels exercised here are pure Triton, so they run on any backend +# that the vLLM platform layer treats as a CUDA-alike device or as XPU. +DEVICE = current_platform.device_type + +pytestmark = pytest.mark.skipif( + not (current_platform.is_cuda_alike() or current_platform.is_xpu()), + reason="Mamba2 SSD Triton kernels require a CUDA-alike or XPU device.", +) + # Added by the IBM Team, 2024 # Adapted from https://github.com/state-spaces/mamba/blob/v2.2.4/mamba_ssm/modules/ssd_minimal.py @@ -81,7 +91,7 @@ def ssd_minimal_discrete(X, A, B, C, block_len, initial_states=None): return Y, final_state -def generate_random_inputs(batch_size, seqlen, n_heads, d_head, itype, device="cuda"): +def generate_random_inputs(batch_size, seqlen, n_heads, d_head, itype, device=DEVICE): set_random_seed(0) A = -torch.exp(torch.rand(n_heads, dtype=itype, device=device)) dt = F.softplus( @@ -103,7 +113,7 @@ def generate_continuous_batched_examples( n_heads, d_head, itype, - device="cuda", + device=DEVICE, return_naive_ref=True, ): # this function generates a random examples of certain length @@ -215,7 +225,7 @@ def test_mamba_chunk_scan_single_example(d_head, n_heads, seq_len_chunk_size, it X * dt.unsqueeze(-1), A * dt, B, C, chunk_size ) - cu_seqlens = torch.tensor((0, seqlen), device="cuda").cumsum(dim=0) + cu_seqlens = torch.tensor((0, seqlen), device=DEVICE).cumsum(dim=0) cu_chunk_seqlens, last_chunk_indices, seq_idx_chunks = ( compute_varlen_chunk_metadata(cu_seqlens, chunk_size) ) diff --git a/tests/kernels/mamba/test_precopy_mamba_align.py b/tests/kernels/mamba/test_precopy_mamba_align.py new file mode 100644 index 000000000000..be1e45594860 --- /dev/null +++ b/tests/kernels/mamba/test_precopy_mamba_align.py @@ -0,0 +1,180 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Equivalence test for ``precopy_mamba_align_fused_kernel``. + +The V2 "align" pre-copy must migrate mamba state across block boundaries with +byte-identical semantics to the V1 copy specs (``get_conv_copy_spec`` / +``get_temporal_copy_spec``): + +* conv state (SD layout, conv_width > 0): shift the sliding window by + ``token_bias`` tokens -- ``state[bt[src_col], token_bias:]`` -> + ``state[bt[dst_col], :conv_width - token_bias]``. +* temporal state (conv_width == 0): ``token_bias`` selects the accepted + speculative column -- ``state[bt[src_col + token_bias]]`` -> + ``state[bt[dst_col]]``. + +The kernel must also no-op when ``src_col < 0`` (fresh request) or +``src_col == dst_col`` (no boundary crossed). +""" + +from __future__ import annotations + +import torch + +from vllm.platforms import current_platform +from vllm.v1.worker.mamba_utils import precopy_mamba_align_fused_kernel + +try: + import pytest + + pytestmark = pytest.mark.skipif( + not current_platform.is_cuda(), + reason="precopy_mamba_align_fused_kernel needs CUDA/Triton", + ) + _parametrize = pytest.mark.parametrize +except ModuleNotFoundError: # allow running directly as ``python `` + pytest = None + + def _parametrize(_name, _values): + def _deco(fn): + return fn + + return _deco + + +NUM_LAYERS = 3 +CONV_WIDTH = 4 # conv_kernel - 1 + num_spec +CONV_DIM = 96 +SSM_SHAPE = (4, 16, 16) +MAX_COLS = 8 + + +def _build_state(num_blocks, device): + """Per-layer (conv SD [nb, width, dim] bf16, ssm [nb, *shape] fp32) pools.""" + convs, ssms = [], [] + for _ in range(NUM_LAYERS): + convs.append( + torch.randn( + num_blocks, CONV_WIDTH, CONV_DIM, dtype=torch.bfloat16, device=device + ) + ) + ssms.append( + torch.randn(num_blocks, *SSM_SHAPE, dtype=torch.float32, device=device) + ) + return convs, ssms + + +def _build_meta(convs, ssms, device): + """Flattened per-(layer, state-type) metadata, ordered conv, ssm per layer.""" + n = NUM_LAYERS * 2 + base = torch.zeros(n, dtype=torch.int64, device=device) + blk_stride = torch.zeros(n, dtype=torch.int64, device=device) + elem = torch.zeros(n, dtype=torch.int32, device=device) + inner = torch.zeros(n, dtype=torch.int64, device=device) + width = torch.zeros(n, dtype=torch.int32, device=device) + group = torch.zeros(n, dtype=torch.int32, device=device) + drc = torch.zeros(n, dtype=torch.int32, device=device) # DS rows (unused, SD) + drs = torch.zeros(n, dtype=torch.int64, device=device) + i = 0 + for layer in range(NUM_LAYERS): + conv, ssm = convs[layer], ssms[layer] + # conv (SD): width = size(1), inner = stride(1) + base[i] = conv.data_ptr() + blk_stride[i] = conv.stride(0) * conv.element_size() + elem[i] = conv.element_size() + width[i] = conv.size(1) + inner[i] = conv.stride(1) + i += 1 + # ssm (temporal): width = 0, inner = elems per block + base[i] = ssm.data_ptr() + blk_stride[i] = ssm.stride(0) * ssm.element_size() + elem[i] = ssm.element_size() + width[i] = 0 + inner[i] = ssm[0].numel() + i += 1 + return base, blk_stride, elem, inner, width, group, drc, drs + + +def _reference(convs, ssms, bt, src_col, dst_col, bias, num_reqs): + """Apply the V1 copy semantics on clones, reading from the pre-copy state.""" + conv_pre = [c.clone() for c in convs] + ssm_pre = [s.clone() for s in ssms] + conv_ref = [c.clone() for c in convs] + ssm_ref = [s.clone() for s in ssms] + for r in range(num_reqs): + sc, dc, tb = int(src_col[r]), int(dst_col[r]), int(bias[r]) + if sc < 0 or sc == dc: + continue + sblk, dblk = int(bt[r, sc]), int(bt[r, dc]) + tblk = int(bt[r, sc + tb]) # temporal src column shifted by bias + for layer in range(NUM_LAYERS): + conv_ref[layer][dblk, : CONV_WIDTH - tb] = conv_pre[layer][sblk, tb:] + ssm_ref[layer][dblk] = ssm_pre[layer][tblk] + return conv_ref, ssm_ref + + +@_parametrize("num_reqs", [1, 4, 16]) +@_parametrize("token_bias", [0, 1, 2]) +def test_precopy_matches_v1_copy_specs(num_reqs, token_bias): + device = torch.device("cuda") + torch.manual_seed(0) + # Distinct physical block per (req, col) so copies never alias. + num_blocks = num_reqs * MAX_COLS + 1 + bt = torch.empty(num_reqs, MAX_COLS, dtype=torch.int32, device=device) + for r in range(num_reqs): + bt[r] = torch.arange( + 1 + r * MAX_COLS, 1 + (r + 1) * MAX_COLS, dtype=torch.int32, device=device + ) + + # Per-req columns: req 0 fresh (src=-1, skip), req 1 same block (skip), + # the rest cross from col 1 -> col 0 with the given spec token bias. + src_col = torch.full((num_reqs,), 1, dtype=torch.int32, device=device) + dst_col = torch.zeros(num_reqs, dtype=torch.int32, device=device) + bias = torch.full((num_reqs,), token_bias, dtype=torch.int32, device=device) + if num_reqs >= 1: + src_col[0] = -1 # fresh -> no copy + if num_reqs >= 2: + dst_col[1] = 1 # src_col == dst_col -> no copy + + convs, ssms = _build_state(num_blocks, device) + conv_ref, ssm_ref = _reference( + convs, ssms, bt.cpu(), src_col.cpu(), dst_col.cpu(), bias.cpu(), num_reqs + ) + + base, blk_stride, elem, inner, width, group, drc, drs = _build_meta( + convs, ssms, device + ) + bt_ptrs = torch.tensor([bt.data_ptr()], dtype=torch.int64, device=device) + idx_mapping = torch.arange(num_reqs, dtype=torch.int32, device=device) + grid = (num_reqs, NUM_LAYERS * 2) + precopy_mamba_align_fused_kernel[grid]( + dst_col, + src_col, + bias, + bt_ptrs, + bt.stride(0), + base, + blk_stride, + elem, + inner, + width, + group, + drc, + drs, + idx_mapping, + num_reqs, + COPY_BLOCK_SIZE=1024, + CONV_STATE_DIM_FIRST=False, + ) + torch.accelerator.synchronize() + + for layer in range(NUM_LAYERS): + torch.testing.assert_close(convs[layer], conv_ref[layer], rtol=0, atol=0) + torch.testing.assert_close(ssms[layer], ssm_ref[layer], rtol=0, atol=0) + + +if __name__ == "__main__": + for nr in (1, 4, 16): + for tb in (0, 1, 2): + test_precopy_matches_v1_copy_specs(nr, tb) + print(f"OK num_reqs={nr} token_bias={tb}") diff --git a/tests/kernels/moe/modular_kernel_tools/common.py b/tests/kernels/moe/modular_kernel_tools/common.py index fdd00cfa27a2..8041db68d752 100644 --- a/tests/kernels/moe/modular_kernel_tools/common.py +++ b/tests/kernels/moe/modular_kernel_tools/common.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from dataclasses import dataclass +from types import SimpleNamespace from typing import Any import torch @@ -43,6 +44,7 @@ from vllm.utils.import_utils import ( has_aiter, has_deep_ep, + has_deep_ep_v2, has_deep_gemm, has_mori, ) @@ -150,6 +152,10 @@ def make_env_data(self) -> tuple[VllmConfig, dict[Any, Any]]: make env data for vllm launch. """ vllm_config = VllmConfig() + vllm_config.model_config = SimpleNamespace( + enforce_eager=True, + is_moe=True, + ) vllm_config.parallel_config.data_parallel_size = self.world_size vllm_config.parallel_config.enable_expert_parallel = True @@ -239,6 +245,10 @@ def needs_deep_ep(self): or info.backend == "deepep_low_latency" ) + def needs_deep_ep_v2(self): + info = prepare_finalize_info(self.prepare_finalize_type) + return info.backend == "deepep_v2" + def needs_aiter(self): info = expert_info(self.fused_experts_type) return info.needs_aiter @@ -315,8 +325,13 @@ def is_valid(self) -> tuple[bool, str | None]: # Check dependencies (turn into asserts?) if self.needs_deep_ep() and not has_deep_ep(): return False, "Needs DeepEP, but DeepEP not available." + if self.needs_deep_ep_v2() and not has_deep_ep_v2(): + return False, "Needs DeepEP v2, but DeepEP v2 not available." if self.needs_deep_gemm() and not has_deep_gemm(): - return False, "Needs DeepGEMM, but DeepGEMM not available." + return ( + False, + "Needs DeepGEMM, but the current vLLM environment does not provide it.", + ) if self.needs_aiter() and not has_aiter(): # noqa: SIM103 return False, "Needs Aiter, but Aiter not available." if self.needs_mori() and not has_mori(): # noqa: SIM103 @@ -623,7 +638,7 @@ def make_modular_kernel( num_experts=config.E, experts_per_token=config.topk, hidden_dim=config.K, - intermediate_size_per_partition=config.N, + intermediate_size=config.N, num_local_experts=config.num_local_experts, num_logical_experts=config.E, moe_parallel_config=moe_parallel_config, @@ -657,6 +672,58 @@ def make_modular_kernel( return modular_kernel +def _maybe_convert_weights_for_experts( + config: Config, + rank_weights: WeightTensors, +) -> WeightTensors: + """Convert weights to expert-specific format (e.g., TrtLLM BlockMajorK).""" + from vllm.model_executor.layers.fused_moe.oracle.fp8 import ( + Fp8MoeBackend, + convert_to_fp8_moe_kernel_format, + ) + + fe_type = config.fused_experts_type + fe_name = getattr(fe_type, "__name__", "") + + backend: Fp8MoeBackend | None = None + if fe_name == "TrtLlmFp8ExpertsModular": + backend = Fp8MoeBackend.FLASHINFER_TRTLLM + elif fe_name == "FlashInferExperts": + backend = Fp8MoeBackend.FLASHINFER_CUTLASS + + if backend is None or not rank_weights.is_quantized(): + return rank_weights + + mock_layer = SimpleNamespace( + weight_block_size=config.quant_block_shape, + moe_config=SimpleNamespace( + is_act_and_mul=True, + intermediate_size_per_partition=config.N, + ), + activation=SimpleNamespace(is_gated=True), + ) + + w1, w2, w1_scale, w2_scale = convert_to_fp8_moe_kernel_format( + fp8_backend=backend, + layer=mock_layer, + w13=rank_weights.w1, + w2=rank_weights.w2, + w13_scale=rank_weights.w1_scale, + w2_scale=rank_weights.w2_scale, + w13_input_scale=None, + w2_input_scale=None, + ) + + return WeightTensors( + w1=w1, + w2=w2, + w1_scale=w1_scale, + w2_scale=w2_scale, + w1_gs=rank_weights.w1_gs, + w2_gs=rank_weights.w2_gs, + ) + + def run_modular_kernel( pgi: ProcessGroupInfo, vllm_config: VllmConfig, @@ -669,6 +736,7 @@ def run_modular_kernel( # weights for rank rank_weights = weights.slice_weights(pgi.rank, config.num_local_experts) + rank_weights = _maybe_convert_weights_for_experts(config, rank_weights) if config.quant_dtype == "nvfp4": gscale = _make_gscale(config.num_local_experts) @@ -714,6 +782,8 @@ def run_modular_kernel( [num_tokens] * config.world_size, device="cuda", dtype=torch.int ) + torch.distributed.barrier() + with set_forward_context( None, vllm_config, diff --git a/tests/kernels/moe/modular_kernel_tools/mk_objects.py b/tests/kernels/moe/modular_kernel_tools/mk_objects.py index 78ee8084d907..7f1924bac5ab 100644 --- a/tests/kernels/moe/modular_kernel_tools/mk_objects.py +++ b/tests/kernels/moe/modular_kernel_tools/mk_objects.py @@ -36,10 +36,12 @@ from vllm.utils.flashinfer import ( has_flashinfer_cutlass_fused_moe, has_flashinfer_nvlink_one_sided, + has_flashinfer_trtllm_fused_moe, ) from vllm.utils.import_utils import ( has_aiter, has_deep_ep, + has_deep_ep_v2, has_deep_gemm, has_mori, ) @@ -216,6 +218,19 @@ def expert_info(kind) -> ExpertInfo: backend="deepep_low_latency", ) +if has_deep_ep_v2() and current_platform.has_device_capability(100): + from vllm.model_executor.layers.fused_moe.prepare_finalize.deepep_v2 import ( + DeepEPV2PrepareAndFinalize, + ) + + register_prepare_and_finalize( + DeepEPV2PrepareAndFinalize, + standard_format, + common_float_types, + blocked_quantization_support=True, + backend="deepep_v2", + ) + if has_mori(): from vllm.model_executor.layers.fused_moe.prepare_finalize.mori import ( MoriPrepareAndFinalize, @@ -289,6 +304,18 @@ def expert_info(kind) -> ExpertInfo: blocked_quantization_support=False, ) +if has_flashinfer_trtllm_fused_moe() and current_platform.has_device_capability(100): + from vllm.model_executor.layers.fused_moe.experts.trtllm_fp8_moe import ( + TrtLlmFp8ExpertsModular, + ) + + register_experts( + TrtLlmFp8ExpertsModular, + standard_format, + fp8_types, + blocked_quantization_support=True, + ) + if has_aiter(): from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import ( AiterExperts, diff --git a/tests/kernels/moe/modular_kernel_tools/parallel_utils.py b/tests/kernels/moe/modular_kernel_tools/parallel_utils.py index 07f244451b45..d7394d72cb3f 100644 --- a/tests/kernels/moe/modular_kernel_tools/parallel_utils.py +++ b/tests/kernels/moe/modular_kernel_tools/parallel_utils.py @@ -10,6 +10,7 @@ from torch.multiprocessing import spawn # pyright: ignore[reportPrivateImportUsage] from typing_extensions import ParamSpec +import vllm.envs as envs from vllm.config import VllmConfig, set_current_vllm_config from vllm.distributed import ( cleanup_dist_env_and_memory, @@ -60,7 +61,15 @@ def _set_vllm_config( tensor_model_parallel_size=vllm_config.parallel_config.tensor_parallel_size, pipeline_model_parallel_size=vllm_config.parallel_config.pipeline_parallel_size, ) - cpu_group = torch.distributed.new_group(list(range(world_size)), backend="gloo") + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + cpu_group = torch.distributed.split_group( + split_ranks=[list(range(world_size))], + group_desc="moe_test_cpu", + ) + else: + cpu_group = torch.distributed.new_group( + list(range(world_size)), backend="gloo" + ) return cpu_group @@ -79,6 +88,7 @@ def _worker_parallel_launch( rank = node_rank * world_local_size + local_rank device = torch.device("cuda", local_rank) torch.accelerator.set_device_index(device) + torch.set_default_device(device) torch.distributed.init_process_group( backend="cpu:gloo,cuda:nccl", init_method=init_method, @@ -96,7 +106,7 @@ def _worker_parallel_launch( if vllm_config is not None: cpu_group = _set_vllm_config(vllm_config, world_size, rank, local_rank) - try: + def _run_worker(): worker( ProcessGroupInfo( world_size=world_size, @@ -111,11 +121,19 @@ def _worker_parallel_launch( *args, **worker_kwargs, ) + + try: + if vllm_config is not None: + with set_current_vllm_config(vllm_config): + _run_worker() + else: + _run_worker() except Exception as ex: print(ex) traceback.print_exc() raise finally: + torch.accelerator.synchronize() if vllm_config is not None: cleanup_dist_env_and_memory() else: diff --git a/tests/kernels/moe/modular_kernel_tools/profile_modular_kernel.py b/tests/kernels/moe/modular_kernel_tools/profile_modular_kernel.py index 04e9c2aa4593..301aa94e02eb 100644 --- a/tests/kernels/moe/modular_kernel_tools/profile_modular_kernel.py +++ b/tests/kernels/moe/modular_kernel_tools/profile_modular_kernel.py @@ -9,9 +9,19 @@ import torch from vllm.config import VllmConfig +from vllm.forward_context import set_forward_context +from vllm.model_executor.layers.fused_moe.activation import MoEActivation +from vllm.model_executor.layers.fused_moe.config import FusedMoEQuantConfig from vllm.utils.torch_utils import set_random_seed - -from .common import Config, RankTensors, WeightTensors, make_modular_kernel +from vllm.v1.worker.workspace import init_workspace_manager + +from .common import ( + Config, + RankTensors, + WeightTensors, + _make_gscale, + make_modular_kernel, +) from .parallel_utils import ProcessGroupInfo, parallel_launch_with_config @@ -35,7 +45,7 @@ def do_profile( ) as tprof: fn(**fn_kwargs) device = torch.accelerator.current_device_index() - torch.accelerator.synchronize(device=device) + torch.accelerator.synchronize(device) # TODO (varun): Add a descriptive trace file name tprof.export_chrome_trace( @@ -56,24 +66,60 @@ def profile_modular_kernel( # weights for rank rank_weights = weights.slice_weights(pgi.rank, config.num_local_experts) + if config.quant_dtype == "nvfp4": + gscale = _make_gscale(config.num_local_experts) + else: + gscale = None + + quant_config = FusedMoEQuantConfig.make( + config.quant_dtype, + w1_scale=rank_weights.w1_scale, + w2_scale=rank_weights.w2_scale, + a1_scale=rank_tensors.hidden_states_scale, + g1_alphas=(1 / rank_weights.w1_gs) if rank_weights.w1_gs is not None else None, + g2_alphas=(1 / rank_weights.w2_gs) if rank_weights.w2_gs is not None else None, + a1_gscale=gscale, + a2_gscale=gscale, + block_shape=config.quant_block_shape, + per_act_token_quant=config.is_per_act_token_quant, + per_out_ch_quant=config.is_per_out_ch_quant, + ) + # make modular kernel - mk = make_modular_kernel(config, vllm_config, weights) + mk = make_modular_kernel(config, vllm_config, quant_config) + + topk_ids = rank_tensors.topk_ids.to( + mk.prepare_finalize.topk_indices_dtype() or rank_tensors.topk_ids.dtype + ) + + # impls might update the tensor in place + hidden_states = rank_tensors.hidden_states.clone() mk_kwargs = { - "hidden_states": rank_tensors.hidden_states, + "hidden_states": hidden_states, "w1": rank_weights.w1, "w2": rank_weights.w2, "topk_weights": rank_tensors.topk_weights, - "topk_ids": rank_tensors.topk_ids, + "topk_ids": topk_ids, + "activation": MoEActivation.SILU, "expert_map": rank_tensors.expert_map, - "w1_scale": rank_weights.w1_scale, - "w2_scale": rank_weights.w2_scale, - "a1_scale": rank_tensors.hidden_states_scale, "global_num_experts": config.E, - "apply_router_weight_on_input": config.topk == 1, + "apply_router_weight_on_input": config.topk == 1 + and config.supports_apply_weight_on_input(), } - do_profile(mk.apply, mk_kwargs, pgi, config) + num_tokens = hidden_states.shape[0] + num_tokens_across_dp = torch.tensor( + [num_tokens] * config.world_size, device="cpu", dtype=torch.int + ) + + with set_forward_context( + None, + vllm_config, + num_tokens=num_tokens, + num_tokens_across_dp=num_tokens_across_dp, + ): + do_profile(mk.apply, mk_kwargs, pgi, config) def rank_worker( @@ -85,6 +131,10 @@ def rank_worker( ): set_random_seed(pgi.rank) + # workspace manager is normally initialized by GPUModelRunner; we initialize + # it here for the standalone benchmark process. + init_workspace_manager(torch.device(f"cuda:{pgi.local_rank}")) + # get weights to this device weights.to_current_device() diff --git a/tests/kernels/moe/parallel_utils.py b/tests/kernels/moe/parallel_utils.py index 1663e562966e..bb2f9efc7c48 100644 --- a/tests/kernels/moe/parallel_utils.py +++ b/tests/kernels/moe/parallel_utils.py @@ -15,7 +15,7 @@ from torch.multiprocessing import spawn # pyright: ignore[reportPrivateImportUsage] from typing_extensions import ParamSpec -from vllm.utils.import_utils import has_deep_ep +from vllm.utils.import_utils import has_deep_ep, has_deep_ep_v2 from vllm.utils.network_utils import get_open_port if has_deep_ep(): @@ -26,6 +26,11 @@ DeepEPLLPrepareAndFinalize, ) +if has_deep_ep_v2(): + from vllm.model_executor.layers.fused_moe.prepare_finalize.deepep_v2 import ( + DeepEPV2PrepareAndFinalize, + ) + ## Parallel Processes Utils P = ParamSpec("P") @@ -55,11 +60,10 @@ def _worker_parallel_launch( torch.accelerator.set_device_index(local_rank) device = torch.device("cuda", local_rank) torch.distributed.init_process_group( - backend="cpu:gloo,cuda:nccl", + backend="nccl", init_method=init_method, rank=rank, world_size=world_size, - device_id=device, ) barrier = torch.tensor([rank], device=device) torch.distributed.all_reduce(barrier) @@ -200,3 +204,42 @@ def make_deepep_a2a( assert deepep_ll_args is not None return make_deepep_ll_a2a(pg, pgi, deepep_ll_args, q_dtype, block_shape) + + +@dataclasses.dataclass +class DeepEPV2Args: + num_local_experts: int + num_experts: int + num_topk: int + hidden_size: int + max_tokens_per_rank: int + use_fp8_dispatch: bool + + +def make_deepep_v2_a2a( + pg: ProcessGroup, + pgi: ProcessGroupInfo, + dp_size: int, + v2_args: DeepEPV2Args, + use_cudagraph: bool = False, +): + import deep_ep + + buffer = deep_ep.ElasticBuffer( + group=pg, + num_max_tokens_per_rank=v2_args.max_tokens_per_rank, + hidden=v2_args.hidden_size, + num_topk=v2_args.num_topk, + use_fp8_dispatch=v2_args.use_fp8_dispatch, + explicitly_destroy=True, + ) + return DeepEPV2PrepareAndFinalize( + buffer=buffer, + num_dispatchers=pgi.world_size, + dp_size=dp_size, + rank_expert_offset=pgi.rank * v2_args.num_local_experts, + num_experts=v2_args.num_experts, + num_topk=v2_args.num_topk, + use_fp8_dispatch=v2_args.use_fp8_dispatch, + use_cudagraph=use_cudagraph, + ) diff --git a/tests/kernels/moe/test_cpu_fused_moe.py b/tests/kernels/moe/test_cpu_fused_moe.py index ca25b8c2e9fa..41ae9be51730 100644 --- a/tests/kernels/moe/test_cpu_fused_moe.py +++ b/tests/kernels/moe/test_cpu_fused_moe.py @@ -5,10 +5,13 @@ import torch from tests.kernels.allclose_default import get_default_atol, get_default_rtol -from vllm._custom_ops import cpu_fused_moe, cpu_prepack_moe_weight +from vllm._custom_ops import ( + cpu_fused_moe, + cpu_prepack_moe_weight, +) from vllm.model_executor.layers.fused_moe.activation import MoEActivation from vllm.model_executor.layers.fused_moe.cpu_fused_moe import _CPU_MOE_ACT_FN -from vllm.platforms import current_platform +from vllm.platforms import CpuArchEnum, current_platform from vllm.utils.torch_utils import set_random_seed if not current_platform.is_cpu(): @@ -26,8 +29,13 @@ MoEActivation.GELU, MoEActivation.GELU_TANH, ] -USE_BIAS = [True, False] -ISA = ["amx", "vec"] if torch.cpu._is_amx_tile_supported() else ["vec"] +USE_BIAS = [False, True] +ISA = ["vec"] +if current_platform.get_cpu_architecture() == CpuArchEnum.ARM: + ISA.append("neon") +if torch.cpu._is_amx_tile_supported(): + ISA.append("amx") + DTYPE = [torch.bfloat16] diff --git a/tests/kernels/moe/test_cpu_int4_moe.py b/tests/kernels/moe/test_cpu_int4_moe.py index 05694eb08b22..04931e386f14 100644 --- a/tests/kernels/moe/test_cpu_int4_moe.py +++ b/tests/kernels/moe/test_cpu_int4_moe.py @@ -8,19 +8,21 @@ import torch import torch.nn.functional as F -from vllm.platforms import current_platform +from vllm.model_executor.layers.fused_moe.activation import MoEActivation +from vllm.model_executor.layers.fused_moe.experts.cpu_int4_moe import ( + CPUExpertsInt4, +) +from vllm.model_executor.layers.fused_moe.oracle.w4a8_int8 import ( + convert_to_w4a8_int8_moe_format, +) +from vllm.platforms import CpuArchEnum, current_platform from vllm.utils.torch_utils import set_random_seed -if not current_platform.is_cpu(): - pytest.skip("skipping CPU-only tests", allow_module_level=True) - -# Check if the dynamic_4bit_int_moe op is available -if not hasattr(torch.ops._C, "dynamic_4bit_int_moe"): - pytest.skip("dynamic_4bit_int_moe op not available", allow_module_level=True) - -# Check if KleidiAI ops are available -if not hasattr(torch.ops.aten, "_dyn_quant_pack_4bit_weight"): - pytest.skip("KleidiAI 4-bit ops not available", allow_module_level=True) +if ( + not current_platform.is_cpu() + or current_platform.get_cpu_architecture() != CpuArchEnum.ARM +): + pytest.skip("skipping Arm CPU-only tests", allow_module_level=True) # Tolerance for INT4 W4A8 @@ -34,49 +36,6 @@ def _silu_and_mul(x: torch.Tensor) -> torch.Tensor: return F.silu(x[..., :d]) * x[..., d:] -def _pack_int4_weight_to_kleidi( - int4_as_int8: torch.Tensor, - scales: torch.Tensor, - bias: torch.Tensor | None, - group_size: int, - in_features: int, - out_features: int, -) -> torch.Tensor: - """Pack INT4 weights (stored as int8 in [-8,7]) to KleidiAI format. - - Args: - int4_as_int8: [out, in] int8 tensor with values in [-8, 7] - scales: [out, in//group_size] or [out, 1] for channel-wise - bias: [out] optional bias - group_size: Quantization group size (-1 for channel-wise) - in_features: Input dimension - out_features: Output dimension - - Returns: - Packed weight tensor in KleidiAI format - """ - # Shift to unsigned nibble [0, 15] - tmp = int4_as_int8.add(8) - # Pack pairs along input dimension - uint8_nibbles = ((tmp[:, 1::2] << 4) | tmp[:, ::2]).to(torch.uint8) - - # Determine scale dtype based on group_size - scale_dtype = torch.float32 if group_size == -1 else torch.bfloat16 - scales_typed = scales.to(scale_dtype) - bias_typed = None if bias is None else bias.to(torch.float32) - - # Pack using KleidiAI op - actual_group_size = in_features if group_size == -1 else group_size - return torch.ops.aten._dyn_quant_pack_4bit_weight( - uint8_nibbles, - scales_typed, - bias_typed, - actual_group_size, - in_features, - out_features, - ) - - def _make_int4_moe_weights( E: int, N: int, @@ -124,59 +83,29 @@ def _n_scale_cols(in_features: int) -> int: w13_bias = torch.randn(E, 2 * N, dtype=torch.float32) * 0.01 w2_bias = torch.randn(E, K, dtype=torch.float32) * 0.01 - # Pack weights for each expert - w13_packed_list = [] - w2_packed_list = [] - - for e in range(E): - w13_packed_list.append( - _pack_int4_weight_to_kleidi( - w13_int4[e], - w13_scales[e], - w13_bias[e] if (has_bias and w13_bias is not None) else None, - group_size, - K, - 2 * N, - ) - ) - w2_packed_list.append( - _pack_int4_weight_to_kleidi( - w2_int4[e], - w2_scales[e], - w2_bias[e] if (has_bias and w2_bias is not None) else None, - group_size, - N, - K, - ) - ) - - w13_packed = torch.stack(w13_packed_list, dim=0) - w2_packed = torch.stack(w2_packed_list, dim=0) - - # Create reference dequantized weights - w13_ref = torch.zeros(E, 2 * N, K, dtype=torch.float32) - w2_ref = torch.zeros(E, K, N, dtype=torch.float32) - - for e in range(E): - # Dequantize w13 - for i in range(2 * N): - for j in range(K): - group_idx = 0 if group_size == -1 else (j // group_size) - w13_ref[e, i, j] = ( - w13_int4[e, i, j].float() * w13_scales[e, i, group_idx].float() - ) - if has_bias and w13_bias is not None: - w13_ref[e, i, j] += w13_bias[e, i].float() - - # Dequantize w2 - for i in range(K): - for j in range(N): - group_idx = 0 if group_size == -1 else (j // group_size) - w2_ref[e, i, j] = ( - w2_int4[e, i, j].float() * w2_scales[e, i, group_idx].float() - ) - if has_bias and w2_bias is not None: - w2_ref[e, i, j] += w2_bias[e, i].float() + w13_packed, w2_packed, *_ = convert_to_w4a8_int8_moe_format( + w13_weight=w13_int4, + w2_weight=w2_int4, + w13_weight_scale=w13_scales, + w2_weight_scale=w2_scales, + group_size=group_size, + w13_bias=w13_bias if has_bias else None, + w2_bias=w2_bias if has_bias else None, + ) + + if group_size == -1: + w13_scale = w13_scales.float() + w2_scale = w2_scales.float() + else: + w13_scale = w13_scales.float().repeat_interleave(group_size, dim=-1) + w2_scale = w2_scales.float().repeat_interleave(group_size, dim=-1) + + w13_ref = w13_int4.float() * w13_scale + w2_ref = w2_int4.float() * w2_scale + if has_bias and w13_bias is not None: + w13_ref = w13_ref + w13_bias.float().unsqueeze(-1) + if has_bias and w2_bias is not None: + w2_ref = w2_ref + w2_bias.float().unsqueeze(-1) return w13_packed, w2_packed, w13_ref, w2_ref, w13_bias, w2_bias @@ -233,17 +162,20 @@ def ref_int4_moe( (768, 2048, 16, 4, 64), ] SEEDS = [0, 42] +ACTIVATION_DTYPES = [torch.float32, torch.bfloat16, torch.float16] @pytest.mark.parametrize("M", NUM_TOKENS) @pytest.mark.parametrize("N,K,E,topk,group_size", MoE_CONFIGS) @pytest.mark.parametrize("seed", SEEDS) -def test_cpu_int4_moe_kernel(M, N, K, E, topk, group_size, seed): +@pytest.mark.parametrize("activation_dtype", ACTIVATION_DTYPES) +def test_cpu_int4_moe_kernel(M, N, K, E, topk, group_size, seed, activation_dtype): """Test dynamic_4bit_int_moe kernel against dequantized torch reference.""" set_random_seed(seed) + activation = MoEActivation.SILU # Generate input activations - a = torch.randn(M, K, dtype=torch.bfloat16) / (K**0.5) + a = torch.randn(M, K, dtype=activation_dtype) / (K**0.5) # Generate INT4 weights w13_packed, w2_packed, w13_ref, w2_ref, w13_bias, w2_bias = _make_int4_moe_weights( @@ -266,8 +198,6 @@ def test_cpu_int4_moe_kernel(M, N, K, E, topk, group_size, seed): ) # Test dynamic_4bit_int_moe kernel - # Activation kind: 1 = SwiGLU_Ug (SiLU(u)*g) for OAI-style - activation_kind = 1 apply_router_weight_on_input = False out = torch.ops._C.dynamic_4bit_int_moe( @@ -278,14 +208,14 @@ def test_cpu_int4_moe_kernel(M, N, K, E, topk, group_size, seed): w2_packed, K, # H (hidden_size / w2_out_features) N, # I (intermediate_size / w2_in_features) - 2 * N, # I2 (2*intermediate_size / w13_out_features) group_size, apply_router_weight_on_input, - activation_kind, + CPUExpertsInt4._activation_kind(activation), ) + assert out.dtype == activation_dtype torch.testing.assert_close( - ref_out.bfloat16(), + ref_out, out, atol=INT4_W4A8_ATOL, rtol=INT4_W4A8_RTOL, diff --git a/tests/kernels/moe/test_cpu_quant_fused_moe.py b/tests/kernels/moe/test_cpu_quant_fused_moe.py index f8967b199226..e0e0203c6b23 100644 --- a/tests/kernels/moe/test_cpu_quant_fused_moe.py +++ b/tests/kernels/moe/test_cpu_quant_fused_moe.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Tests for CPU quantized fused MoE kernels (FP8 W8A16 and MXFP4 W4A16).""" +"""Tests for CPU quantized fused MoE kernels.""" import math import sys @@ -31,7 +31,10 @@ def _prepack_experts(w: torch.Tensor) -> torch.Tensor: return torch.ops._C.convert_weight_packed(w) -# FP8 W8A16 block-scaled fused MoE +# =========================================================================== +# FP8 W8A16 MoE +# =========================================================================== + BLOCK_SIZE = [128, 128] # [block_n, block_k] @@ -216,7 +219,9 @@ def test_w8a16_block_fp8_cpu_fused_moe(M, N, K, E, topk, seed): torch.testing.assert_close(out_inplace, out, atol=0, rtol=0) -# MXFP4 W4A16 fused MoE +# =========================================================================== +# MXFP4 W4A16 MoE +# =========================================================================== class MXFP4QuantizeUtil: @@ -496,5 +501,388 @@ def test_mxfp4_cpu_fused_moe_bias_swiglu(M, N, K, E, topk, seed): torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2) +# =========================================================================== +# INT4 W4A16 MoE +# =========================================================================== + + +def _pack_int4_gptq(w_int4: torch.Tensor) -> torch.Tensor: + """Pack INT4 values [N, K] → [N, K//8] int32 along K dim (GPTQ format).""" + N, K = w_int4.shape + assert K % 8 == 0 + w = w_int4.to(torch.int32) + w_packed = torch.zeros(N, K // 8, dtype=torch.int32) + for j in range(8): + w_packed |= (w[:, j::8] & 0xF) << (j * 4) + return w_packed + + +def _pack_int4_awq(w_int4: torch.Tensor) -> torch.Tensor: + """Pack INT4 values [..., N] → [..., N//8] int32 along last dim (AWQ format).""" + # AWQ packing bitshifts: indices {0,4,1,5,2,6,3,7} * 4 bits each + _AWQ_BITSHIFTS = [0, 16, 4, 20, 8, 24, 12, 28] + + N = w_int4.shape[-1] + assert N % 8 == 0 + w = w_int4.to(torch.int32) + w_packed = torch.zeros(*w.shape[:-1], N // 8, dtype=torch.int32) + for j, shift in enumerate(_AWQ_BITSHIFTS): + w_packed |= (w[..., j::8] & 0xF) << shift + return w_packed + + +def _ref_int4_moe( + a: torch.Tensor, + w1_int4: torch.Tensor, + w2_int4: torch.Tensor, + w1_zeros: torch.Tensor | None, + w2_zeros: torch.Tensor | None, + w1_s: torch.Tensor, + w2_s: torch.Tensor, + topk_weight: torch.Tensor, + topk_ids: torch.Tensor, + group_size: int, +) -> torch.Tensor: + """Reference INT4 W4A16 group-quantized fused MoE in pure torch.""" + B = a.shape[0] + topk = topk_ids.size(1) + K_out = a.shape[1] + + out = torch.zeros(B, topk, K_out, dtype=torch.float32) + for b in range(B): + for t in range(topk): + eid = topk_ids[b, t].item() + x = a[b : b + 1].float() + + # Dequantize w1: [K, 2*N], groups along K (input dim) + K_dim = w1_int4.shape[1] + w1_dq = torch.zeros(K_dim, w1_int4.shape[2], dtype=torch.float32) + for g in range(w1_s.shape[1]): + k_start = g * group_size + k_end = min((g + 1) * group_size, K_dim) + zp = w1_zeros[eid, g, :].float() if w1_zeros is not None else 8.0 + w1_dq[k_start:k_end, :] = ( + w1_int4[eid, k_start:k_end, :].float() - zp + ) * w1_s[eid, g, :].float() + + ic = torch.matmul(x, w1_dq) # [1, K] @ [K, 2*N] → [1, 2*N] + ic = _silu_and_mul(ic) # [1, N] + + # Dequantize w2: [N, K], groups along N (input dim) + N_dim = w2_int4.shape[1] + w2_dq = torch.zeros(N_dim, w2_int4.shape[2], dtype=torch.float32) + for g in range(w2_s.shape[1]): + n_start = g * group_size + n_end = min((g + 1) * group_size, N_dim) + zp = w2_zeros[eid, g, :].float() if w2_zeros is not None else 8.0 + w2_dq[n_start:n_end, :] = ( + w2_int4[eid, n_start:n_end, :].float() - zp + ) * w2_s[eid, g, :].float() + + oc = torch.matmul(ic, w2_dq) # [1, N] @ [N, K] → [1, K] + out[b, t] = oc.squeeze(0) + + return (out * topk_weight.unsqueeze(-1)).sum(dim=1).to(a.dtype) + + +def _make_int4_moe_weights(E, N, K, group_size, quant_algo): + """Create INT4 MoE weights in GPTQ or AWQ packed format. + + Canonical layout (input × output): + w1_int4: [E, K, 2*N] w2_int4: [E, N, K] + + GPTQ packed (pack transposed weight along input/K dim): + w1_packed: [E, K//8, 2*N] w2_packed: [E, N//8, K] + zeros: actual int4 zero points, same packing as weights + + AWQ packed (pack along output/N dim): + w1_packed: [E, K, 2*N//8] w2_packed: [E, N, K//8] + zeros: actual int4 zero points, same packing as weights + + Returns: + w1_int4, w2_int4, + w1_packed, w2_packed, + w1_zeros, w2_zeros, + w1_zeros_packed, w2_zeros_packed, + w1_s, w2_s + """ + w1_int4 = torch.randint(0, 16, (E, K, 2 * N), dtype=torch.int32) + w2_int4 = torch.randint(0, 16, (E, N, K), dtype=torch.int32) + + num_groups_w1 = K // group_size + num_groups_w2 = N // group_size + w1_s = ( + torch.randn(E, num_groups_w1, 2 * N, dtype=torch.bfloat16) * 0.01 + ).abs() + 0.001 + w2_s = (torch.randn(E, num_groups_w2, K, dtype=torch.bfloat16) * 0.01).abs() + 0.001 + + if quant_algo == ops.CPUQuantAlgo.GPTQ: + # Pack: canonical [E, K, 2*N] → transpose [E, 2*N, K] → GPTQ pack + # [E, 2*N, K//8] → transpose [E, K//8, 2*N] + w1_t = w1_int4.transpose(1, 2).contiguous() # [E, 2*N, K] + w1_packed = ( + torch.stack([_pack_int4_gptq(w1_t[e]) for e in range(E)]) + .transpose(1, 2) + .contiguous() + ) # [E, K//8, 2*N] + w2_t = w2_int4.transpose(1, 2).contiguous() # [E, K, N] + w2_packed = ( + torch.stack([_pack_int4_gptq(w2_t[e]) for e in range(E)]) + .transpose(1, 2) + .contiguous() + ) # [E, N//8, K] + w1_zeros = w2_zeros = None + w1_zeros_packed = torch.full( + (E, num_groups_w1, 2 * N // 8), 0x77777777, dtype=torch.int32 + ) + w2_zeros_packed = torch.full( + (E, num_groups_w2, K // 8), 0x77777777, dtype=torch.int32 + ) + else: # AWQ + # Asymmetric: actual zero points, packed along output dim. + w1_zeros = torch.randint(1, 15, (E, num_groups_w1, 2 * N), dtype=torch.int32) + w2_zeros = torch.randint(1, 15, (E, num_groups_w2, K), dtype=torch.int32) + w1_packed = torch.stack( + [_pack_int4_awq(w1_int4[e]) for e in range(E)] + ) # [E, K, 2*N//8] + w2_packed = torch.stack( + [_pack_int4_awq(w2_int4[e]) for e in range(E)] + ) # [E, N, K//8] + w1_zeros_packed = torch.stack( + [_pack_int4_awq(w1_zeros[e]) for e in range(E)] + ) # [E, K//gs, 2*N//8] + w2_zeros_packed = torch.stack( + [_pack_int4_awq(w2_zeros[e]) for e in range(E)] + ) # [E, N//gs, K//8] + + return ( + w1_int4, + w2_int4, + w1_packed, + w2_packed, + w1_zeros, + w2_zeros, + w1_zeros_packed, + w2_zeros_packed, + w1_s, + w2_s, + ) + + +INT4_MOE_CONFIGS = [ + # (N, K, E, topk, group_size) + (256, 512, 8, 2, 128), + (512, 256, 8, 2, 128), + (512, 512, 8, 4, 128), + (768, 2048, 8, 2, 128), +] + + +@pytest.mark.parametrize("M", [1, 2, 64, 121]) +@pytest.mark.parametrize("N,K,E,topk,group_size", INT4_MOE_CONFIGS) +@pytest.mark.parametrize("quant_algo", [ops.CPUQuantAlgo.GPTQ, ops.CPUQuantAlgo.AWQ]) +@pytest.mark.parametrize("seed", [0]) +def test_int4_w4a16_cpu_fused_moe(M, N, K, E, topk, group_size, quant_algo, seed): + """Test fused_experts_cpu INT4 W4A16 for both GPTQ and AWQ quant formats.""" + set_random_seed(seed) + + a = torch.randn(M, K, dtype=torch.bfloat16) / (0.5 * K**0.5) + ( + w1_int4, + w2_int4, + w1_packed, + w2_packed, + w1_zeros, + w2_zeros, + w1_zeros_packed, + w2_zeros_packed, + w1_s, + w2_s, + ) = _make_int4_moe_weights(E, N, K, group_size, quant_algo) + + score = torch.randn(M, E, dtype=torch.bfloat16) + score = torch.softmax(score, dim=-1, dtype=torch.float32) + topk_weight, topk_ids = torch.topk(score, topk) + topk_ids = topk_ids.to(torch.int32) + + ref_out = _ref_int4_moe( + a, + w1_int4, + w2_int4, + w1_zeros, + w2_zeros, + w1_s, + w2_s, + topk_weight, + topk_ids, + group_size, + ) + + from vllm.model_executor.layers.fused_moe.experts.cpu_moe import ( + prepare_int4_moe_layer_for_cpu, + ) + + (blocked_w1, blocked_w2, blocked_s1, blocked_s2, blocked_z1, blocked_z2) = ( + prepare_int4_moe_layer_for_cpu( + w1_packed, + w2_packed, + w1_s, + w2_s, + quant_algo=quant_algo, + w13_zeros=w1_zeros_packed, + w2_zeros=w2_zeros_packed, + ) + ) + + out = ops.fused_experts_cpu( + a.clone(), + blocked_w1, + blocked_w2, + topk_weight, + topk_ids, + False, # inplace + ops.CPUQuantMethod.INT4_W4A8, + blocked_s1, + blocked_s2, + blocked_z1, + blocked_z2, + None, # block_size + None, # w1_bias + None, # w2_bias + None, # alpha + None, # limit + True, # is_vnni + ) + torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2) + + +# =========================================================================== +# INT8 W8A8 MoE +# =========================================================================== + + +def _quantize_per_channel(w): + """Symmetric per-channel INT8 quantisation. w: [N, K] -> (int8, scale).""" + amax = w.abs().amax(dim=-1, keepdim=True).clamp(min=1e-12) + scale = amax / 127.0 + w_q = (w / scale).round().clamp(-128, 127).to(torch.int8) + return w_q, scale.float() + + +def _quantize_per_token(x): + """Symmetric per-token INT8 quantisation. x: [M, K] -> (int8, scale).""" + amax = x.abs().amax(dim=-1, keepdim=True).clamp(min=1e-12) + scale = amax / 127.0 + x_q = (x / scale).round().clamp(-128, 127).to(torch.int8) + return x_q, scale.float() + + +def _ref_int8_moe(a, w1, w2, w1_s, w2_s, topk_weight, topk_ids): + """Reference INT8 W8A8 per-channel fused MoE in pure torch.""" + B, D = a.shape + topk = topk_ids.size(1) + + out = torch.zeros(B, topk, w2.shape[1], dtype=torch.float32) + for b in range(B): + for t in range(topk): + eid = topk_ids[b, t].item() + + x = a[b : b + 1].float() + x_q, x_s = _quantize_per_token(x) + ic = torch.matmul(x_q.float(), w1[eid].float().t()) + ic = ic * x_s * w1_s[eid].view(1, -1) + ic = _silu_and_mul(ic) + + ic_q, ic_s = _quantize_per_token(ic) + oc = torch.matmul(ic_q.float(), w2[eid].float().t()) + oc = oc * ic_s * w2_s[eid].view(1, -1) + out[b, t] = oc.squeeze(0) + + result = (out * topk_weight.unsqueeze(-1)).sum(dim=1) + return result.to(a.dtype) + + +def _make_int8_moe_weights(E, N, K): + factor = 1e-2 + w1_f = (torch.randn(E, 2 * N, K) - 0.5) * 2 + w2_f = (torch.randn(E, K, N) - 0.5) * 2 + + w1_q_list, w1_s_list = [], [] + w2_q_list, w2_s_list = [], [] + for e in range(E): + q, s = _quantize_per_channel(w1_f[e]) + w1_q_list.append(q) + w1_s_list.append(s) + q, s = _quantize_per_channel(w2_f[e]) + w2_q_list.append(q) + w2_s_list.append(s) + + return ( + torch.stack(w1_q_list), + torch.stack(w2_q_list), + torch.stack(w1_s_list) * factor, + torch.stack(w2_s_list) * factor, + ) + + +INT8_NUM_TOKENS = [1, 2, 64, 121] +INT8_MOE_CONFIGS = [ + # (N, K, E, topk) + (256, 512, 8, 2), + (512, 256, 8, 2), + (512, 512, 8, 4), + (768, 2048, 8, 2), +] + + +@pytest.mark.parametrize("M", INT8_NUM_TOKENS) +@pytest.mark.parametrize("N,K,E,topk", INT8_MOE_CONFIGS) +@pytest.mark.parametrize("seed", [0]) +@pytest.mark.parametrize("is_vnni", [False, True]) +@pytest.mark.parametrize("inplace", [False, True]) +def test_int8_w8a8_cpu_fused_moe(M, N, K, E, topk, seed, is_vnni, inplace): + """Test fused_experts_cpu INT8 W8A8 against torch reference.""" + set_random_seed(seed) + + a = torch.randn(M, K, dtype=torch.bfloat16) / (0.5 * K**0.5) + w1_q, w2_q, w1_s, w2_s = _make_int8_moe_weights(E, N, K) + + score = torch.randn(M, E, dtype=torch.bfloat16) + score = torch.softmax(score, dim=-1, dtype=torch.float32) + topk_weight, topk_ids = torch.topk(score, topk) + topk_ids = topk_ids.to(torch.int32) + + ref_out = _ref_int8_moe(a, w1_q, w2_q, w1_s, w2_s, topk_weight, topk_ids) + + w1 = _prepack_experts(w1_q) if is_vnni else w1_q + w2 = _prepack_experts(w2_q) if is_vnni else w2_q + + out = ops.fused_experts_cpu( + a.clone(), + w1, + w2, + topk_weight, + topk_ids, + inplace, + ops.CPUQuantMethod.INT8_W8A8, + w1_s, + w2_s, + None, # w1_zero + None, # w2_zero + None, # block_size + None, # w1_bias + None, # w2_bias + None, # alpha + None, # limit + is_vnni, + ) + torch.testing.assert_close( + ref_out.bfloat16(), + out, + atol=2e-1, + rtol=2e-1, + ) + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) diff --git a/tests/kernels/moe/test_cutlass_moe.py b/tests/kernels/moe/test_cutlass_moe.py index 1380281bb2e2..fa4351de7e29 100644 --- a/tests/kernels/moe/test_cutlass_moe.py +++ b/tests/kernels/moe/test_cutlass_moe.py @@ -205,9 +205,12 @@ def slice_experts(): w2 = kwargs["w2"] a = kwargs["hidden_states"] moe_config = make_dummy_moe_config( - num_experts=w2.shape[0], + max_num_tokens=kwargs.get("hidden_states").shape[0], + experts_per_token=kwargs.get("topk_ids").shape[1], + num_experts=num_experts, + num_local_experts=num_local_experts, hidden_dim=w2.shape[1], - intermediate_size_per_partition=w2.shape[2], + intermediate_size=w2.shape[2], in_dtype=a.dtype, ) kernel = mk.FusedMoEKernel( @@ -258,25 +261,29 @@ def run_8_bit( a1_scale=None, ) + num_experts = moe_tensors.w1.size(0) # type: ignore[attr-defined] + with_ep = num_local_experts is not None or num_local_experts == num_experts + kwargs = { "hidden_states": moe_tensors.a, "w1": moe_tensors.w1_q, # type: ignore[union-attr] "w2": moe_tensors.w2_q, # type: ignore[union-attr] "topk_weights": topk_weights, "topk_ids": topk_ids, - "global_num_experts": moe_tensors.w1_q.shape[0], # type: ignore[union-attr] + "global_num_experts": num_experts, "activation": MoEActivation.SILU, "expert_map": None, "apply_router_weight_on_input": False, } - num_experts = moe_tensors.w1.size(0) # type: ignore[attr-defined] - with_ep = num_local_experts is not None or num_local_experts == num_experts if not with_ep: moe_config = make_dummy_moe_config( - num_experts=moe_tensors.w2_q.shape[0], # type: ignore[union-attr] + max_num_tokens=moe_tensors.a.shape[0], + experts_per_token=topk_ids.shape[1], + num_experts=num_experts, + num_local_experts=num_local_experts, hidden_dim=moe_tensors.w2_q.shape[1], # type: ignore[union-attr] - intermediate_size_per_partition=moe_tensors.w2_q.shape[2], # type: ignore[union-attr] + intermediate_size=moe_tensors.w2_q.shape[2], # type: ignore[union-attr] in_dtype=moe_tensors.a.dtype, ) kernel = mk.FusedMoEKernel( @@ -581,6 +588,7 @@ def test_run_cutlass_moe_fp8( per_out_channel, False, topk_weights, + None, ) workspace13.random_() diff --git a/tests/kernels/moe/test_cutlass_mxfp8_grouped_mm.py b/tests/kernels/moe/test_cutlass_mxfp8_grouped_mm.py deleted file mode 100644 index 3a154fbb84cd..000000000000 --- a/tests/kernels/moe/test_cutlass_mxfp8_grouped_mm.py +++ /dev/null @@ -1,237 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -# Adapted from SGLang: -# https://github.com/sgl-project/sglang/blob/ded068a76e00878881d52d5bfb791e0f60d7311b/sgl-kernel/tests/test_es_fp8_blockwise_moe.py - -"""Tests for SM100 CUTLASS MXFP8 grouped MoE kernels.""" - -import random - -import pytest -import torch - -from tests.kernels.utils import torch_moe_single -from vllm import _custom_ops as ops -from vllm.platforms import current_platform -from vllm.utils.torch_utils import set_random_seed - -random.seed(42) -set_random_seed(42) - - -def align(val: int, alignment: int = 128) -> int: - return int((val + alignment - 1) // alignment * alignment) - - -# Copy from: https://github.com/deepseek-ai/DeepGEMM/blob/main/deep_gemm/utils.py -def calc_diff(x, y): - x, y = x.double(), y.double() - denominator = (x * x + y * y).sum() - sim = 2 * (x * y).sum() / denominator - return 1 - sim - - -def is_sm100_supported() -> bool: - return current_platform.is_cuda() and current_platform.is_device_capability_family( - 100 - ) - - -def compute_ref_output( - input_tensor: torch.Tensor, - weight_list: list[torch.Tensor], - expert_offsets: list[int], - expert_offset: int, - num_experts: int, -) -> torch.Tensor: - # Build a top-1 routing score so each token maps to its owning expert. - score = torch.full( - (expert_offset, num_experts), - -1e9, - device=input_tensor.device, - dtype=torch.float32, - ) - for g in range(num_experts): - start = expert_offsets[g] - end = expert_offsets[g + 1] if g + 1 < num_experts else expert_offset - score[start:end, g] = 0.0 - - return torch_moe_single( - input_tensor, torch.stack(weight_list, dim=0), score, topk=1 - ) - - -def compute_kernel_output( - input_tensor: torch.Tensor, - weight_tensor: torch.Tensor, - problem_sizes: list[list[int]], - aux_problem_sizes: list[list[int]], - expert_offsets: list[int], - aux_expert_offsets: list[int], - input_blockscale_offsets: list[int], - weight_blockscale_offsets: list[int], - input_blockscale_offset: int, - n_g: int, - k_g: int, - num_experts: int, - expert_offset: int, - out_dtype: torch.dtype, -) -> torch.Tensor: - device = input_tensor.device - _problem_sizes = torch.tensor(problem_sizes).to(device=device, dtype=torch.int32) - _aux_problem_sizes = torch.tensor(aux_problem_sizes).to( - device=device, dtype=torch.int32 - ) - _expert_offsets = torch.tensor(expert_offsets).to(device=device, dtype=torch.int32) - _aux_expert_offsets = torch.tensor(aux_expert_offsets).to( - device=device, dtype=torch.int32 - ) - _input_blockscale_offsets = torch.tensor(input_blockscale_offsets).to( - device=device, dtype=torch.int32 - ) - _weight_blockscale_offsets = torch.tensor(weight_blockscale_offsets).to( - device=device, dtype=torch.int32 - ) - - input_quant = torch.zeros_like( - input_tensor, dtype=torch.float8_e4m3fn, device=device - ) - input_scale_factor = torch.zeros( - (input_blockscale_offset, k_g // 32), dtype=torch.uint8, device=device - ) - - weight_quant = torch.zeros_like( - weight_tensor, dtype=torch.float8_e4m3fn, device=device - ) - weight_scale_factor = torch.zeros( - (num_experts, n_g, k_g // 32), dtype=torch.uint8, device=device - ) - - ops.mxfp8_experts_quant( - input_tensor, - _problem_sizes, - _expert_offsets, - _input_blockscale_offsets, - input_quant, - input_scale_factor, - ) - - ops.mxfp8_experts_quant( - weight_tensor, - _aux_problem_sizes, - _aux_expert_offsets, - _weight_blockscale_offsets, - weight_quant, - weight_scale_factor, - ) - weight_quant = weight_quant.view(num_experts, n_g, k_g).transpose(1, 2) - weight_scale_factor = weight_scale_factor.view( - num_experts, n_g, k_g // 32 - ).transpose(1, 2) - - output = torch.empty((expert_offset, n_g), device=device, dtype=out_dtype) - ops.cutlass_mxfp8_grouped_mm( - input_quant, - weight_quant, - input_scale_factor, - weight_scale_factor, - output, - _problem_sizes, - _expert_offsets, - _input_blockscale_offsets, - ) - return output - - -@pytest.mark.skipif( - not is_sm100_supported(), - reason=( - "cutlass_mxfp8_grouped_mm and mxfp8_experts_quant " - "are only supported on CUDA SM100" - ), -) -@pytest.mark.parametrize("num_experts", [8, 16, 32, 64]) -@pytest.mark.parametrize("out_dtype", [torch.half, torch.bfloat16]) -def test_cutlass_mxfp8_grouped_mm(num_experts, out_dtype): - device = "cuda" - alignment = 128 - n_g = random.randint(1, 64) * alignment - k_g = random.randint(1, 64) * alignment - - expert_offset = 0 - expert_offsets = [] - aux_expert_offset = 0 - aux_expert_offsets = [] - input_blockscale_offset = 0 - input_blockscale_offsets = [] - weight_blockscale_offset = 0 - weight_blockscale_offsets = [] - problem_sizes = [] - aux_problem_sizes = [] - input_list = [] - weight_list = [] - - for g in range(num_experts): - m_g = random.randint(1, 512) - expert_offsets.append(expert_offset) - expert_offset += m_g - aux_expert_offsets.append(aux_expert_offset) - aux_expert_offset += n_g - input_blockscale_offsets.append(input_blockscale_offset) - input_blockscale_offset += align(m_g, 128) - weight_blockscale_offsets.append(weight_blockscale_offset) - weight_blockscale_offset += n_g # n_g already align to 128 - problem_sizes.append([m_g, n_g, k_g]) - aux_problem_sizes.append([n_g, m_g, k_g]) - - input_tensor = torch.normal( - 0.0, std=1.0, size=(m_g, k_g), device=device, dtype=out_dtype - ) # (M, K):(K, 1) - weight_tensor = torch.normal( - 0.0, std=1.0, size=(n_g, k_g), device=device, dtype=out_dtype - ) # (N, K):(K, 1) - - input_list.append(input_tensor) - weight_list.append(weight_tensor) - input_tensor = torch.concat(input_list, dim=0) - weight_tensor = torch.concat(weight_list, dim=0) - - ref_output = compute_ref_output( - input_tensor=input_tensor, - weight_list=weight_list, - expert_offsets=expert_offsets, - expert_offset=expert_offset, - num_experts=num_experts, - ) - output = compute_kernel_output( - input_tensor=input_tensor, - weight_tensor=weight_tensor, - problem_sizes=problem_sizes, - aux_problem_sizes=aux_problem_sizes, - expert_offsets=expert_offsets, - aux_expert_offsets=aux_expert_offsets, - input_blockscale_offsets=input_blockscale_offsets, - weight_blockscale_offsets=weight_blockscale_offsets, - input_blockscale_offset=input_blockscale_offset, - n_g=n_g, - k_g=k_g, - num_experts=num_experts, - expert_offset=expert_offset, - out_dtype=out_dtype, - ) - - for g in range(num_experts): - baseline = ref_output[ - expert_offsets[g] : (expert_offsets[g] + problem_sizes[g][0]) - ] - actual = output[expert_offsets[g] : (expert_offsets[g] + problem_sizes[g][0])] - diff = calc_diff(actual, baseline) - assert diff < 0.001 - print( - f"m_g={baseline.shape[0]} n_g={n_g} k_g={k_g} num_experts={num_experts}, " - f"out_dtype={out_dtype}, diff={diff:.5f}: OK" - ) - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/kernels/moe/test_deepep_deepgemm_moe.py b/tests/kernels/moe/test_deepep_deepgemm_moe.py index 452bf64ed989..efb1e2f2969d 100644 --- a/tests/kernels/moe/test_deepep_deepgemm_moe.py +++ b/tests/kernels/moe/test_deepep_deepgemm_moe.py @@ -14,6 +14,7 @@ from torch.distributed import ProcessGroup from typing_extensions import ParamSpec +import vllm.envs as envs from vllm.config import VllmConfig, set_current_vllm_config from vllm.forward_context import set_forward_context from vllm.model_executor.layers.fused_moe.activation import MoEActivation @@ -375,7 +376,13 @@ def _test_deepep_deepgemm_moe( w1_scale = w1_scale.to(device=device) w2_scale = w2_scale.to(device=device) - pg = torch.distributed.new_group(list(range(pgi.world_size))) + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + pg = torch.distributed.split_group( + split_ranks=[list(range(pgi.world_size))], + group_desc="deepep_deepgemm_test", + ) + else: + pg = torch.distributed.new_group(list(range(pgi.world_size))) test_tensors = TestTensors.make(config, pgi.rank) block_shape = [w1.size(1) // w1_scale.size(1), w1.size(2) // w1_scale.size(2)] diff --git a/tests/kernels/moe/test_deepep_moe.py b/tests/kernels/moe/test_deepep_moe.py index 5e0303c3df72..8d12e2888d0d 100644 --- a/tests/kernels/moe/test_deepep_moe.py +++ b/tests/kernels/moe/test_deepep_moe.py @@ -10,7 +10,8 @@ import torch.distributed from torch.distributed import ProcessGroup -from tests.kernels.moe.utils import make_dummy_moe_config +import vllm.envs as envs +from tests.kernels.moe.utils import check_accuracy, make_dummy_moe_config from vllm import _custom_ops as ops from vllm.config import VllmConfig, set_current_vllm_config from vllm.model_executor.layers.activation import SiluAndMul @@ -26,6 +27,7 @@ from vllm.model_executor.layers.quantization.utils.fp8_utils import ( per_token_group_quant_fp8, ) +from vllm.platforms import current_platform from vllm.utils.import_utils import has_deep_ep from vllm.utils.torch_utils import set_random_seed from vllm.v1.worker.workspace import init_workspace_manager @@ -63,7 +65,7 @@ def make_weights( return w1, w2, None, None # per-out-channel weight quantization - assert dtype == torch.float8_e4m3fn + assert dtype == current_platform.fp8_dtype() w1 = torch.empty((e, 2 * n, k), device="cuda", dtype=torch.float16) w2 = torch.empty((e, k, n), device="cuda", dtype=torch.float16) @@ -104,9 +106,11 @@ class TestTensors: @staticmethod def make(config: TestConfig, low_latency_mode: bool) -> "TestTensors": # TODO (varun) - check that float16 works ? - assert config.dtype in [torch.bfloat16, torch.float8_e4m3fn] + assert config.dtype in [torch.bfloat16, current_platform.fp8_dtype()] token_dtype = ( - torch.bfloat16 if config.dtype == torch.float8_e4m3fn else config.dtype + torch.bfloat16 + if config.dtype == current_platform.fp8_dtype() + else config.dtype ) rank_tokens = ( torch.randn((config.m, config.k), device="cuda", dtype=token_dtype) / 10 @@ -215,47 +219,52 @@ def build_expert_map(): return expert_map.to(device=device, dtype=torch.int32) hidden_size = test_tensors.rank_tokens.size(1) - is_quantized = w1.dtype == torch.float8_e4m3fn + is_quantized = w1.dtype == current_platform.fp8_dtype() q_dtype = None if is_quantized: - q_dtype = torch.float8_e4m3fn + q_dtype = current_platform.fp8_dtype() out_hidden_states = torch.empty_like(test_tensors.rank_tokens) total_num_tokens = test_tensors.rank_tokens.size(0) + quant_config = FusedMoEQuantConfig.make( + q_dtype, + w1_scale=w1_scale, + w2_scale=w2_scale, + per_act_token_quant=per_act_token_quant, + a1_scale=test_tensors.rank_token_scales, + ) + + # Build the kernel (and its DeepEP buffer) once and reuse it across chunks. + # Re-creating it per chunk re-inits rocSHMEM, which only allows one + # allocation per process on ROCm. The buffer is sized by max_tokens_per_rank + # so it is valid for every chunk (mirrors production's cached all2all handle). + mk: FusedMoEKernel = make_modular_kernel( + pg, + pgi, + low_latency_mode, + hidden_size, + dp_size, + num_experts, + num_local_experts, + q_dtype, + use_fp8_dispatch, + quant_config, + ) + def process_chunk(chunk_start, chunk_end, skip_result_store=False): rank_tokens_chunk = test_tensors.rank_tokens[chunk_start:chunk_end] topk_weights_chunk = test_tensors.topk_weights[chunk_start:chunk_end] topk_chunk = test_tensors.topk[chunk_start:chunk_end] - rank_token_scales_chunk = test_tensors.rank_token_scales - if ( - rank_token_scales_chunk is not None - and rank_token_scales_chunk.size(0) == total_num_tokens - ): - # per act token - rank_token_scales_chunk = rank_token_scales_chunk[chunk_start:chunk_end] - - quant_config = FusedMoEQuantConfig.make( - q_dtype, - w1_scale=w1_scale, - w2_scale=w2_scale, - per_act_token_quant=per_act_token_quant, - a1_scale=rank_token_scales_chunk, - ) - # Make modular kernel - mk: FusedMoEKernel = make_modular_kernel( - pg, - pgi, - low_latency_mode, - hidden_size, - dp_size, - num_experts, - num_local_experts, - q_dtype, - use_fp8_dispatch, - quant_config, - ) + if low_latency_mode: + # Reusing one buffer leaves it dirty; the low-latency kernels need + # the zero-initialized regions reset before each dispatch. + mk.prepare_finalize.buffer.clean_low_latency_buffer( + MAX_TOKENS_PER_RANK, + hidden_size, + num_experts, + ) out = mk.apply( hidden_states=rank_tokens_chunk, @@ -317,7 +326,7 @@ def torch_moe_impl( .to(a.dtype) ) - is_quantized = w1.dtype == torch.float8_e4m3fn + is_quantized = w1.dtype == current_platform.fp8_dtype() a_dtype = a.dtype if is_quantized: w1 = w1.to(dtype=torch.float32) * w1_scale @@ -346,6 +355,28 @@ def torch_moe_impl( return out +def assert_deepep_close( + expected: torch.Tensor, + actual: torch.Tensor, + k: int, + use_fp8_dispatch: bool, +) -> None: + if use_fp8_dispatch and current_platform.is_fp8_fnuz(): + # ROCm e4m3fnuz rounds differently than the reference quant, + # so DeepEP's fp8 dispatch can yield a few outliers even with + # a correct kernel; allow a small fraction of mismatches here. + atol = rtol = 1.5e-1 + check_accuracy(expected, actual, atol=atol, rtol=rtol, percent=0.95) + return + + torch.testing.assert_close( + expected, + actual, + atol=6e-2, + rtol=6e-2, + ) + + def _deep_ep_moe( pgi: ProcessGroupInfo, low_latency_mode: bool, @@ -358,6 +389,9 @@ def _deep_ep_moe( use_fp8_dispatch: bool, per_act_token_quant: bool, ): + # Set seed in worker process for deterministic tensor generation. + set_random_seed(7) + device = torch.device(f"cuda:{pgi.local_rank}") init_workspace_manager(device) @@ -366,7 +400,7 @@ def _deep_ep_moe( "FP8 dispatch interface is available only in low-latency mode" ) - is_quantized = w1.dtype == torch.float8_e4m3fn + is_quantized = w1.dtype == current_platform.fp8_dtype() device_idx = torch.accelerator.current_device_index() w1 = w1.to(device=device_idx) w2 = w2.to(device=device_idx) @@ -375,7 +409,13 @@ def _deep_ep_moe( w1_scale = w1_scale.to(device=device_idx) w2_scale = w2_scale.to(device=device_idx) - pg = torch.distributed.new_group(list(range(pgi.world_size))) + if envs.VLLM_DISTRIBUTED_USE_SPLIT_GROUP: + pg = torch.distributed.split_group( + split_ranks=[list(range(pgi.world_size))], + group_desc="deepep_test", + ) + else: + pg = torch.distributed.new_group(list(range(pgi.world_size))) test_tensors = TestTensors.make(config, low_latency_mode) with set_current_vllm_config(VllmConfig()): @@ -416,12 +456,7 @@ def _deep_ep_moe( per_act_token_quant, ) - torch.testing.assert_close( - torch_combined, - deepep_combined, - atol=6e-2, - rtol=6e-2, - ) + assert_deepep_close(torch_combined, deepep_combined, config.k, use_fp8_dispatch) MNKs = [ @@ -434,7 +469,7 @@ def _deep_ep_moe( (222, 1024, 2048), ] -DTYPES = [torch.bfloat16, torch.float8_e4m3fn] +DTYPES = [torch.bfloat16, current_platform.fp8_dtype()] @pytest.mark.parametrize("dtype", DTYPES) @@ -489,7 +524,7 @@ def test_deep_ep_moe( (64, 1024, 2560), (222, 1024, 2560), ] -DTYPES = [torch.float8_e4m3fn, torch.bfloat16] +DTYPES = [current_platform.fp8_dtype(), torch.bfloat16] USE_FP8_DISPATCH = [True, False] diff --git a/tests/kernels/moe/test_deepep_v2_moe.py b/tests/kernels/moe/test_deepep_v2_moe.py new file mode 100644 index 000000000000..93b7c136605b --- /dev/null +++ b/tests/kernels/moe/test_deepep_v2_moe.py @@ -0,0 +1,542 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Test DeepEP v2 (ElasticBuffer) dispatch-combine logic. +Compares against a pure-PyTorch reference MoE implementation. +""" + +import dataclasses + +import pytest +import torch.distributed +from torch.distributed import ProcessGroup + +from tests.kernels.moe.utils import make_dummy_moe_config, make_test_weights +from tests.kernels.utils import torch_experts +from vllm.config import VllmConfig, set_current_vllm_config +from vllm.model_executor.layers.fused_moe import TritonExperts +from vllm.model_executor.layers.fused_moe.activation import MoEActivation +from vllm.model_executor.layers.fused_moe.config import ( + FusedMoEQuantConfig, +) +from vllm.model_executor.layers.fused_moe.modular_kernel import FusedMoEKernel +from vllm.utils.import_utils import has_deep_ep_v2 +from vllm.utils.torch_utils import set_random_seed +from vllm.v1.worker.workspace import init_workspace_manager + +from ...utils import multi_gpu_test +from .parallel_utils import ProcessGroupInfo, parallel_launch + +if has_deep_ep_v2(): + from .parallel_utils import DeepEPV2Args, make_deepep_v2_a2a + +requires_deep_ep_v2 = pytest.mark.skipif( + not has_deep_ep_v2(), + reason="Requires DeepEP v2 (ElasticBuffer)", +) + + +@dataclasses.dataclass +class TestConfig: + dtype: torch.dtype + topk: int + m: int + k: int + n: int + num_experts: int + + +@dataclasses.dataclass +class TestTensors: + rank_tokens: torch.Tensor + rank_token_scales: torch.Tensor | None + topk: torch.Tensor + topk_weights: torch.Tensor + config: TestConfig + + @staticmethod + def make(config: TestConfig) -> "TestTensors": + assert config.dtype in [torch.bfloat16, torch.float8_e4m3fn] + token_dtype = ( + torch.bfloat16 if config.dtype == torch.float8_e4m3fn else config.dtype + ) + rank_tokens = ( + torch.randn((config.m, config.k), device="cuda", dtype=token_dtype) / 10 + ) + + topk = torch.stack( + [ + torch.randperm(config.num_experts, device="cuda")[: config.topk] + for _ in range(config.m) + ] + ).to(dtype=torch.int64) + topk_weights = torch.randn(topk.shape, dtype=torch.float32, device="cuda") + return TestTensors( + rank_tokens=rank_tokens, + rank_token_scales=None, + topk=topk, + topk_weights=topk_weights, + config=config, + ) + + +def make_modular_kernel( + pg: ProcessGroup, + pgi: ProcessGroupInfo, + dp_size: int, + hidden_size: int, + num_experts: int, + num_local_experts: int, + topk: int, + q_dtype: torch.dtype | None, + use_fp8_dispatch: bool, + quant_config: FusedMoEQuantConfig, + use_cudagraph: bool = False, +) -> FusedMoEKernel: + v2_args = DeepEPV2Args( + num_local_experts=num_local_experts, + num_experts=num_experts, + num_topk=topk, + hidden_size=hidden_size, + max_tokens_per_rank=8192, + use_fp8_dispatch=use_fp8_dispatch, + ) + + a2a = make_deepep_v2_a2a( + pg=pg, + pgi=pgi, + dp_size=dp_size, + v2_args=v2_args, + use_cudagraph=use_cudagraph, + ) + + moe_config = make_dummy_moe_config( + num_experts=num_local_experts, + experts_per_token=topk, + hidden_dim=hidden_size, + ) + + fused_experts = TritonExperts( + moe_config=moe_config, + quant_config=quant_config, + ) + + mk = FusedMoEKernel( + prepare_finalize=a2a, + fused_experts=fused_experts, + inplace=False, + ) + return mk + + +def deepep_v2_moe_impl( + pg: ProcessGroup, + pgi: ProcessGroupInfo, + dp_size: int, + test_tensors: TestTensors, + w1: torch.Tensor, + w2: torch.Tensor, + w1_scale: torch.Tensor | None, + w2_scale: torch.Tensor | None, + num_experts: int, + topk: int, + use_fp8_dispatch: bool, + per_act_token_quant: bool, +) -> torch.Tensor: + num_local_experts = w1.size(0) + + def build_expert_map(): + expert_map = torch.full((num_experts,), fill_value=-1, dtype=torch.int32) + s = pgi.rank * num_local_experts + e = s + num_local_experts + expert_map[s:e] = torch.tensor(list(range(num_local_experts))) + device = torch.accelerator.current_device_index() + return expert_map.to(device=device, dtype=torch.int32) + + is_quantized = w1.dtype == torch.float8_e4m3fn + q_dtype = torch.float8_e4m3fn if is_quantized else None + + quant_config = FusedMoEQuantConfig.make( + q_dtype, + w1_scale=w1_scale, + w2_scale=w2_scale, + per_act_token_quant=per_act_token_quant, + a1_scale=test_tensors.rank_token_scales, + ) + + hidden_size = test_tensors.rank_tokens.size(1) + + mk: FusedMoEKernel = make_modular_kernel( + pg, + pgi, + dp_size, + hidden_size, + num_experts, + num_local_experts, + topk, + q_dtype, + use_fp8_dispatch, + quant_config, + ) + + out = mk.apply( + hidden_states=test_tensors.rank_tokens, + w1=w1, + w2=w2, + topk_weights=test_tensors.topk_weights, + topk_ids=test_tensors.topk, + activation=MoEActivation.SILU, + global_num_experts=num_experts, + expert_map=build_expert_map(), + apply_router_weight_on_input=False, + ) + + return out + + +def _deep_ep_v2_moe( + pgi: ProcessGroupInfo, + dp_size: int, + config: TestConfig, + w1: torch.Tensor, + w2: torch.Tensor, + w1_scale: torch.Tensor | None, + w2_scale: torch.Tensor | None, + use_fp8_dispatch: bool, + per_act_token_quant: bool, +): + device = torch.device(f"cuda:{pgi.local_rank}") + init_workspace_manager(device) + + is_quantized = w1.dtype == torch.float8_e4m3fn + device_idx = torch.accelerator.current_device_index() + w1 = w1.to(device=device_idx) + w2 = w2.to(device=device_idx) + if is_quantized: + assert w1_scale is not None and w2_scale is not None + w1_scale = w1_scale.to(device=device_idx) + w2_scale = w2_scale.to(device=device_idx) + + pg = torch.distributed.new_group(list(range(pgi.world_size))) + test_tensors = TestTensors.make(config) + + with set_current_vllm_config(VllmConfig()): + # Reference + q_dtype = torch.float8_e4m3fn if is_quantized else None + torch_combined = torch_experts( + test_tensors.rank_tokens, + w1, + w2, + test_tensors.topk_weights, + test_tensors.topk, + w1_scale=w1_scale, + w2_scale=w2_scale, + quant_dtype=q_dtype, + per_act_token_quant=per_act_token_quant, + ) + + # Splice experts for this rank + num_local_experts = config.num_experts // pgi.world_size + e_start = num_local_experts * pgi.rank + e_end = e_start + num_local_experts + w1_ep = w1[e_start:e_end] + w2_ep = w2[e_start:e_end] + + w1_scale_ep, w2_scale_ep = None, None + if is_quantized: + w1_scale_ep = w1_scale[e_start:e_end] # type: ignore + w2_scale_ep = w2_scale[e_start:e_end] # type: ignore + + deepep_combined = deepep_v2_moe_impl( + pg, + pgi, + dp_size, + test_tensors, + w1_ep, + w2_ep, + w1_scale_ep, + w2_scale_ep, + config.num_experts, + config.topk, + use_fp8_dispatch, + per_act_token_quant, + ) + + torch.testing.assert_close( + torch_combined, + deepep_combined, + atol=6e-2, + rtol=6e-2, + ) + + +MNKs = [ + (1, 256, 256), + (2, 256, 512), + (3, 1024, 2048), + (32, 256, 1024), + (45, 512, 2048), + (64, 1024, 1024), + (222, 1024, 2048), +] + +DTYPES = [torch.bfloat16, torch.float8_e4m3fn] + + +@pytest.mark.parametrize("dtype", DTYPES) +@pytest.mark.parametrize("m,n,k", MNKs) +@pytest.mark.parametrize("num_experts", [32]) +@pytest.mark.parametrize("topk", [6]) +@pytest.mark.parametrize("world_dp_size", [(2, 1)]) +@multi_gpu_test(num_gpus=2) +@requires_deep_ep_v2 +def test_deep_ep_v2_moe( + dtype: torch.dtype, + m: int, + n: int, + k: int, + num_experts: int, + topk: int, + world_dp_size: tuple[int, int], + workspace_init, +): + per_act_token_quant = False + use_fp8_dispatch = False + + set_random_seed(7) + world_size, dp_size = world_dp_size + config = TestConfig(dtype=dtype, topk=topk, m=m, k=k, n=n, num_experts=num_experts) + + quant_dtype = dtype if dtype == torch.float8_e4m3fn else None + (_, w1, w1_scale, _), (_, w2, w2_scale, _) = make_test_weights( + num_experts, + n, + k, + quant_dtype=quant_dtype, + per_out_ch_quant=True, + ) + + parallel_launch( + world_size, + _deep_ep_v2_moe, + dp_size, + config, + w1, + w2, + w1_scale, + w2_scale, + use_fp8_dispatch, + per_act_token_quant, + ) + + +def _deep_ep_v2_moe_cudagraph( + pgi: ProcessGroupInfo, + dp_size: int, + config: TestConfig, + w1: torch.Tensor, + w2: torch.Tensor, + w1_scale: torch.Tensor | None, + w2_scale: torch.Tensor | None, +): + """Worker function: verify DeepEP v2 + TrtLLM FP8 with do_expand=False.""" + import tempfile + + from vllm.distributed import ( + init_distributed_environment, + initialize_model_parallel, + ) + + device = torch.device(f"cuda:{pgi.local_rank}") + init_workspace_manager(device) + + pg = torch.distributed.new_group(list(range(pgi.world_size))) + test_tensors = TestTensors.make(config) + num_local_experts = config.num_experts // pgi.world_size + hidden_size = config.k + + # Create FP8 weights directly, then dequantize for bf16 reference. + w1_fp8 = torch.randn( + (config.num_experts, 2 * config.n, config.k), + device="cuda", + dtype=torch.bfloat16, + ).to(torch.float8_e4m3fn) + w2_fp8 = torch.randn( + (config.num_experts, config.k, config.n), + device="cuda", + dtype=torch.bfloat16, + ).to(torch.float8_e4m3fn) + w1_ref = w1_fp8.to(torch.bfloat16) + w2_ref = w2_fp8.to(torch.bfloat16) + + from vllm.config import KernelConfig + + vllm_cfg = VllmConfig() + vllm_cfg.kernel_config = KernelConfig(moe_backend="flashinfer_trtllm") + + with set_current_vllm_config(vllm_cfg): + # Initialize vLLM parallel state (needed by FusedMoE layer) + temp_file = tempfile.mktemp() + init_distributed_environment( + world_size=pgi.world_size, + rank=pgi.rank, + distributed_init_method=f"file://{temp_file}", + local_rank=pgi.local_rank, + backend="nccl", + ) + initialize_model_parallel(tensor_model_parallel_size=1) + # Reference MoE using dequantized bf16 weights + torch_combined = torch_experts( + test_tensors.rank_tokens, + w1_ref, + w2_ref, + test_tensors.topk_weights, + test_tensors.topk, + ) + + # Use the production pipeline: make_fused_moe_layer creates + # a FusedMoE layer, quantizes weights, runs + # process_weights_after_loading (TrtLLM W31 swap + BlockMajorK + # shuffle), and selects the kernel. + # Quantize weights using production helper, EP-slice, then + # convert to TrtLLM format. + from tests.kernels.moe.test_moe_layer import _quantize_fp8_halves + from vllm.model_executor.layers.fused_moe.experts.trtllm_fp8_moe import ( + TrtLlmFp8ExpertsModular, + ) + from vllm.model_executor.layers.fused_moe.oracle.fp8 import ( + Fp8MoeBackend, + convert_to_fp8_moe_kernel_format, + ) + + block_shape = [128, 128] + qw = _quantize_fp8_halves(w1_ref, w2_ref, block_shape) + + # EP-slice before format conversion + e_start = num_local_experts * pgi.rank + e_end = e_start + num_local_experts + w1_ep = qw.w13_weight[e_start:e_end] + w2_ep = qw.w2_weight[e_start:e_end] + assert qw.w13_weight_scale is not None + assert qw.w2_weight_scale is not None + w1_scale_ep = qw.w13_weight_scale[e_start:e_end] + w2_scale_ep = qw.w2_weight_scale[e_start:e_end] + + # Convert to TrtLLM format (W31 swap + BlockMajorK shuffle) + class _MockLayer: + weight_block_size = block_shape + + class moe_config: + is_act_and_mul = True + intermediate_size_per_partition = config.n + + class activation: + is_gated = True + + w1_ep, w2_ep, w1_scale_ep, w2_scale_ep = convert_to_fp8_moe_kernel_format( + fp8_backend=Fp8MoeBackend.FLASHINFER_TRTLLM, + layer=_MockLayer(), + w13=w1_ep, + w2=w2_ep, + w13_scale=w1_scale_ep, + w2_scale=w2_scale_ep, + w13_input_scale=None, + w2_input_scale=None, + ) + + # Build TrtLLM expert with correct EP params + quant_config = FusedMoEQuantConfig.make( + torch.float8_e4m3fn, + block_shape=block_shape, + w1_scale=w1_scale_ep, + w2_scale=w2_scale_ep, + ) + moe_config = make_dummy_moe_config( + num_experts=num_local_experts, + experts_per_token=config.topk, + hidden_dim=hidden_size, + intermediate_size=config.n, + ) + fused_experts = TrtLlmFp8ExpertsModular( + moe_config=moe_config, + quant_config=quant_config, + ) + + v2_args = DeepEPV2Args( + num_local_experts=num_local_experts, + num_experts=config.num_experts, + num_topk=config.topk, + hidden_size=hidden_size, + max_tokens_per_rank=8192, + use_fp8_dispatch=False, + ) + a2a = make_deepep_v2_a2a( + pg=pg, + pgi=pgi, + dp_size=dp_size, + v2_args=v2_args, + use_cudagraph=True, + ) + mk_kernel = FusedMoEKernel( + prepare_finalize=a2a, + fused_experts=fused_experts, + inplace=False, + ) + + for _ in range(3): + out = mk_kernel.apply( + hidden_states=test_tensors.rank_tokens, + w1=w1_ep, + w2=w2_ep, + topk_weights=test_tensors.topk_weights, + topk_ids=test_tensors.topk, + activation=MoEActivation.SILU, + global_num_experts=config.num_experts, + expert_map=None, + apply_router_weight_on_input=False, + ) + + torch.testing.assert_close( + torch_combined, + out, + atol=6e-2, + rtol=6e-2, + ) + + +@pytest.mark.parametrize("m,n,k", [(32, 256, 1024)]) +@pytest.mark.parametrize("num_experts", [32]) +@pytest.mark.parametrize("topk", [6]) +@pytest.mark.parametrize("world_dp_size", [(2, 1)]) +@multi_gpu_test(num_gpus=2) +@requires_deep_ep_v2 +def test_deep_ep_v2_moe_cudagraph( + m: int, + n: int, + k: int, + num_experts: int, + topk: int, + world_dp_size: tuple[int, int], + workspace_init, +): + set_random_seed(7) + world_size, dp_size = world_dp_size + config = TestConfig( + dtype=torch.float8_e4m3fn, + topk=topk, + m=m, + k=k, + n=n, + num_experts=num_experts, + ) + + parallel_launch( + world_size, + _deep_ep_v2_moe_cudagraph, + dp_size, + config, + None, # weights created inside worker + None, + None, + None, + ) diff --git a/tests/kernels/moe/test_flashinfer.py b/tests/kernels/moe/test_flashinfer.py index c8dc02927fa3..b1cfa511903c 100644 --- a/tests/kernels/moe/test_flashinfer.py +++ b/tests/kernels/moe/test_flashinfer.py @@ -166,12 +166,11 @@ def make_moe_tensors_8bit( num_experts=e, experts_per_token=topk, hidden_dim=k, - intermediate_size_per_partition=n, + intermediate_size=n, num_local_experts=e, num_logical_experts=e, moe_parallel_config=layer.moe_parallel_config, in_dtype=hidden_states.dtype, - is_act_and_mul=is_gated, routing_method=layer.routing_method_type, activation=activation, device=w13_quantized.device, @@ -339,14 +338,13 @@ def get_fused_moe_quant_config(n: torch.nn.Module) -> FusedMoEQuantConfig: num_experts=e, experts_per_token=topk, hidden_dim=k, - intermediate_size_per_partition=n, + intermediate_size=n, num_local_experts=e, num_logical_experts=e, activation=activation, device="cuda", moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), in_dtype=torch.bfloat16, - is_act_and_mul=activation.is_gated, routing_method=RoutingMethodType.TopK, max_num_tokens=next_power_of_2(m), ) diff --git a/tests/kernels/moe/test_flashinfer_b12x_moe.py b/tests/kernels/moe/test_flashinfer_b12x_moe.py index 85d0bbe06d75..b15cbcdd8129 100644 --- a/tests/kernels/moe/test_flashinfer_b12x_moe.py +++ b/tests/kernels/moe/test_flashinfer_b12x_moe.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from types import SimpleNamespace + import pytest import torch @@ -8,8 +10,7 @@ if not current_platform.is_device_capability_family(120): pytest.skip( - reason="FlashInfer CuteDSL SM12x MoE requires SM120 " - "(RTX Pro 6000 / DGX Spark).", + reason="FlashInfer B12x MoE requires SM120 (RTX Pro 6000 / DGX Spark).", allow_module_level=True, ) @@ -18,8 +19,8 @@ if not has_flashinfer_b12x_moe(): pytest.skip( reason=( - "FlashInfer cute_dsl_fused_moe_nvfp4 / convert_sf_to_mma_layout " - "not available in installed FlashInfer (needs PRs #3051 and #3066)." + "FlashInfer B12xMoEWrapper not available in installed " + "FlashInfer (needs PR #3080)." ), allow_module_level=True, ) @@ -40,7 +41,6 @@ from vllm.model_executor.layers.fused_moe.experts.flashinfer_b12x_moe import ( FlashInferB12xExperts, ) -from vllm.utils.flashinfer import flashinfer_convert_sf_to_mma_layout from vllm.utils.torch_utils import set_random_seed # Dimensions chosen to satisfy FP4 alignment requirements (k multiple of 256, @@ -59,7 +59,7 @@ def _reorder_gate_up_to_up_gate( ) -> tuple[torch.Tensor, torch.Tensor]: """Swap gate and up-projection halves along dim=1 to [up, gate] order. - The SM12x kernel expects weights in [up (w3), gate (w1)] order while the + The B12x kernel expects weights in [up (w3), gate (w1)] order while the BF16 reference uses [gate (w1), up (w3)]. This replicates the reordering done at model-load time by ``prepare_nvfp4_moe_layer_for_fi_or_cutlass``. """ @@ -70,6 +70,22 @@ def _reorder_gate_up_to_up_gate( ) +def _process_b12x_weights( + experts: FlashInferB12xExperts, + w1_scale: torch.Tensor, + w2_scale: torch.Tensor, + w1_scale_2: torch.Tensor, + w2_scale_2: torch.Tensor, +) -> None: + layer = SimpleNamespace( + w13_weight_scale=w1_scale, + w13_weight_scale_2=w1_scale_2, + w2_weight_scale=w2_scale, + w2_weight_scale_2=w2_scale_2, + ) + experts.process_weights_after_loading(layer) + + @pytest.mark.parametrize("m,n,k", MNK_FACTORS) @pytest.mark.parametrize("e", [8, 16]) @pytest.mark.parametrize("topk", [1, 2, 4]) @@ -166,7 +182,7 @@ def test_flashinfer_b12x_moe( num_experts=e, experts_per_token=topk, hidden_dim=k, - intermediate_size_per_partition=n, + intermediate_size=n, in_dtype=dtype, ) @@ -174,22 +190,12 @@ def test_flashinfer_b12x_moe( moe_config=moe_config, quant_config=quant_config, ) - # In production, process_weights_after_loading computes these after - # normalizing block scales. In the test the scales are already in final - # form (global_scale=1.0), so we compute the MMA layouts directly. - num_experts_w1, m1, k1_sf = w1_blockscale.shape - experts.w1_sf_mma = flashinfer_convert_sf_to_mma_layout( - w1_blockscale.reshape(num_experts_w1 * m1, k1_sf), - m=m1, - k=k1_sf * 16, - num_groups=num_experts_w1, - ) - num_experts_w2, m2, k2_sf = w2_blockscale.shape - experts.w2_sf_mma = flashinfer_convert_sf_to_mma_layout( - w2_blockscale.reshape(num_experts_w2 * m2, k2_sf), - m=m2, - k=k2_sf * 16, - num_groups=num_experts_w2, + _process_b12x_weights( + experts, + w1_blockscale, + w2_blockscale, + ones_e, + ones_e, ) kernel = mk.FusedMoEKernel( @@ -224,5 +230,134 @@ def test_flashinfer_b12x_moe( torch.testing.assert_close(sm12x_output, torch_output, atol=2e-1, rtol=2e-1) +@pytest.mark.parametrize("m,n,k", MNK_FACTORS) +@pytest.mark.parametrize("e", [8, 16]) +@pytest.mark.parametrize("topk", [1, 2, 4]) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@torch.inference_mode() +def test_flashinfer_b12x_moe_relu2( + m: int, + n: int, + k: int, + e: int, + topk: int, + dtype: torch.dtype, + workspace_init, +): + """Test FlashInferB12xExperts with ReLU2 (non-gated) activation. + + ReLU2 is used by Nemotron-H style models. Unlike the gated SiLU + path, w1 has shape [E, N, K] (not [E, 2N, K]) and the activation + is relu(x)^2 without a gate/up split. + """ + set_random_seed(7) + with set_current_vllm_config( + VllmConfig(parallel_config=ParallelConfig(pipeline_parallel_size=1)) + ): + a = torch.randn((m, k), device="cuda", dtype=dtype) / 10 + + # Non-gated: w1 shape is (e, n, k), not (e, 2n, k). + w1_bf16 = torch.randn((e, n, k), device="cuda", dtype=dtype) / 15 + w2_bf16 = torch.randn((e, k, n), device="cuda", dtype=dtype) / 15 + + gs = torch.ones(1, device="cuda", dtype=torch.float32) + sf_vec_size = 16 + + # W1: no gate/up reordering for non-gated. + w1_flat = w1_bf16.reshape(e * n, k) + w1_q_flat, w1_sf_flat = fp4_quantize( + w1_flat, + global_scale=gs, + sf_vec_size=sf_vec_size, + is_sf_swizzled_layout=True, + ) + w1_q = w1_q_flat.view(e, n, k // 2) + w1_blockscale = w1_sf_flat.view(e, n, w1_sf_flat.shape[1]) + + w2_flat = w2_bf16.reshape(e * k, n) + w2_q_flat, w2_sf_flat = fp4_quantize( + w2_flat, + global_scale=gs, + sf_vec_size=sf_vec_size, + is_sf_swizzled_layout=True, + ) + w2_q = w2_q_flat.view(e, k, n // 2) + w2_blockscale = w2_sf_flat.view(e, k, w2_sf_flat.shape[1]) + + ones_e = torch.ones(e, device="cuda", dtype=torch.float32) + + quant_config = nvfp4_moe_quant_config( + g1_alphas=ones_e, + g2_alphas=ones_e, + a1_gscale=ones_e, + a2_gscale=ones_e, + w1_scale=w1_blockscale, + w2_scale=w2_blockscale, + ) + + moe_config = make_dummy_moe_config( + num_experts=e, + experts_per_token=topk, + hidden_dim=k, + intermediate_size=n, + in_dtype=dtype, + activation=MoEActivation.RELU2_NO_MUL, + ) + + experts = FlashInferB12xExperts( + moe_config=moe_config, + quant_config=quant_config, + ) + _process_b12x_weights( + experts, + w1_blockscale, + w2_blockscale, + ones_e, + ones_e, + ) + + kernel = mk.FusedMoEKernel( + maybe_make_prepare_finalize( + moe=moe_config, + quant_config=quant_config, + allow_new_interface=True, + use_monolithic=False, + ), + experts, + inplace=False, + ) + + score = torch.randn((m, e), device="cuda", dtype=dtype) + topk_weights, topk_ids, _ = fused_topk(a, score, topk, renormalize=False) + + b12x_output = kernel.apply( + hidden_states=a, + w1=w1_q, + w2=w2_q, + topk_weights=topk_weights, + topk_ids=topk_ids, + global_num_experts=e, + activation=MoEActivation.RELU2_NO_MUL, + apply_router_weight_on_input=False, + expert_map=None, + ) + + torch_output = torch_moe( + a, + w1_bf16, + w2_bf16, + score, + topk, + activation=MoEActivation.RELU2_NO_MUL, + ) + + torch.testing.assert_close( + b12x_output, + torch_output, + atol=2e-1, + rtol=2e-1, + ) + + if __name__ == "__main__": test_flashinfer_b12x_moe(16, 128, 256, 8, 2, torch.bfloat16) diff --git a/tests/kernels/moe/test_flashinfer_moe.py b/tests/kernels/moe/test_flashinfer_moe.py index 2cec0bad1cb4..822f0f7d9424 100644 --- a/tests/kernels/moe/test_flashinfer_moe.py +++ b/tests/kernels/moe/test_flashinfer_moe.py @@ -97,14 +97,13 @@ def test_flashinfer_fp4_moe_no_graph( num_experts=e, experts_per_token=topk, hidden_dim=k, - intermediate_size_per_partition=n, + intermediate_size=n, num_local_experts=e, num_logical_experts=e, activation=activation, device="cuda", moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), in_dtype=dtype, - is_act_and_mul=is_gated_act, routing_method=RoutingMethodType.TopK, max_num_tokens=next_power_of_2(m), ) diff --git a/tests/kernels/moe/test_flydsl_moe.py b/tests/kernels/moe/test_flydsl_moe.py new file mode 100644 index 000000000000..7c51c3691311 --- /dev/null +++ b/tests/kernels/moe/test_flydsl_moe.py @@ -0,0 +1,179 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Copyright (c) 2025 FlyDSL Project Contributors + + +import importlib.util + +import pytest +import torch + +from vllm.model_executor.layers.fused_moe import fused_experts +from vllm.model_executor.layers.fused_moe.activation import MoEActivation +from vllm.model_executor.layers.fused_moe.config import ( + int4_w4a16_moe_quant_config, +) +from vllm.platforms import current_platform +from vllm.platforms.rocm import on_gfx950 + +if not (current_platform.is_rocm() and on_gfx950()): + pytest.skip("This test can only run on ROCm and gfx950.", allow_module_level=True) + +aiter_available = importlib.util.find_spec("aiter") is not None + +if not aiter_available: + pytest.skip("These tests require AITER to run.", allow_module_level=True) + +from vllm.model_executor.layers.fused_moe.fused_flydsl_moe import ( # noqa: E402 + fused_flydsl_moe, +) +from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe import ( # noqa: E402, E501 + compressed_tensors_moe_w4a16_flydsl, +) + +RoutingBuffers = tuple[ + torch.Tensor, # sorted_token_ids + torch.Tensor, # sorted_weights + torch.Tensor, # sorted_expert_ids + torch.Tensor, # num_valid_ids (shape [1], i32) + int, # sorted_size + int, # blocks +] + + +@pytest.mark.parametrize( + "num_tokens", [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384] +) +@pytest.mark.parametrize("inter_dim", [256, 512]) +def test_flydsl_moe(num_tokens: int, inter_dim: int): + device = "cuda" + topk = 8 + num_experts = 384 + hidden_size = 7168 + packed_factor = 8 + w13_num_shards = 2 + params_dtype = torch.bfloat16 + group_size = 32 + w2_scales_size = inter_dim + scale_factor = 0.01 + + num_groups_w2 = w2_scales_size // group_size + num_groups_w13 = hidden_size // group_size + + w13_weight = torch.randint( + 0, + 255, + (num_experts, hidden_size // packed_factor, w13_num_shards * inter_dim), + dtype=torch.int32, + device=device, + ) + + w2_weight = torch.randint( + 0, + 255, + (num_experts, inter_dim // packed_factor, hidden_size), + dtype=torch.int32, + device=device, + ) + w13_scale = scale_factor * torch.randn( + num_experts, + num_groups_w13, + w13_num_shards * inter_dim, + dtype=params_dtype, + device=device, + ) + w2_scale = scale_factor * torch.randn( + num_experts, num_groups_w2, hidden_size, dtype=params_dtype, device=device + ) + + w13_weight_packed = w13_weight.transpose(1, 2).contiguous().view(torch.uint8) + w2_weight_packed = w2_weight.transpose(1, 2).contiguous().view(torch.uint8) + w13_weight_scale = w13_scale.transpose(1, 2).contiguous() + w2_weight_scale = w2_scale.transpose(1, 2).contiguous() + + moe_quant_config = int4_w4a16_moe_quant_config( + w1_scale=w13_weight_scale, + w2_scale=w2_weight_scale, + w1_zp=None, + w2_zp=None, + block_shape=[0, group_size], + ) + score = torch.rand((num_tokens, num_experts), device=device, dtype=torch.float32) + topk_vals, topk_ids = torch.topk(score, k=topk, dim=1) + topk_weights = torch.softmax(topk_vals, dim=1).to(torch.float32) + x = torch.randn((num_tokens, hidden_size), dtype=torch.bfloat16, device=device) + out_ref = fused_experts( + x, + w13_weight_packed, + w2_weight_packed, + topk_weights=topk_weights, + topk_ids=topk_ids, + activation=MoEActivation.SILU, + apply_router_weight_on_input=False, + global_num_experts=num_experts, + expert_map=None, + quant_config=moe_quant_config, + ) + + w13 = w13_weight + w13 = compressed_tensors_moe_w4a16_flydsl._gptq_int32_to_flydsl_packed(w13) + w13 = w13.view(-1).contiguous() + + w2 = w2_weight + w2 = compressed_tensors_moe_w4a16_flydsl._gptq_int32_to_flydsl_packed(w2) + w2 = w2.view(-1).contiguous() + + w13_scale_flydsl = w13_scale + w2_scale_flydsl = w2_scale + + if group_size > 0 and w13_scale.dim() == 3 and w13_scale.shape[1] > 1: + E, G, N = w13_scale.shape + w13_scale_flydsl = ( + w13_scale_flydsl.view(E, G // 2, 2, N) + .permute(0, 1, 3, 2) + .contiguous() + .view(-1) + .contiguous() + ) + elif w13_scale.dim() == 3 and w13_scale.shape[1] == 1: + w13_scale_flydsl = w13_scale_flydsl.squeeze(1) + + if group_size > 0 and w2_scale.dim() == 3 and w2_scale.shape[1] > 1: + E, G, N = w2_scale.shape + w2_scale_flydsl = ( + w2_scale_flydsl.view(E, G // 2, 2, N) + .permute(0, 1, 3, 2) + .contiguous() + .view(-1) + .contiguous() + ) + elif w2_scale.dim() == 3 and w2_scale.shape[1] == 1: + w2_scale_flydsl = w2_scale_flydsl.squeeze(1) + + w13_scale_flydsl = w13_scale_flydsl.contiguous() + w2_scale_flydsl = w2_scale_flydsl.contiguous() + + w13.is_shuffled = True + w2.is_shuffled = True + + out = fused_flydsl_moe( + x, + w13, + w2, + num_experts, + inter_dim, + topk_weights, + topk_ids, + w1_scale=w13_scale_flydsl, + w2_scale=w2_scale_flydsl, + topk=topk_weights.shape[-1], + group_size=group_size, + doweight_stage1=False, + scale_is_bf16=True, + ) + + assert torch.allclose(out, out_ref, atol=0.5, rtol=0.1) + + +if __name__ == "__main__": + test_flydsl_moe(512, 256) diff --git a/tests/kernels/moe/test_marlin_vs_trtllm_mxint4.py b/tests/kernels/moe/test_marlin_vs_trtllm_mxint4.py index eaeca6a8a5dc..0f80ca5c55a9 100644 --- a/tests/kernels/moe/test_marlin_vs_trtllm_mxint4.py +++ b/tests/kernels/moe/test_marlin_vs_trtllm_mxint4.py @@ -5,9 +5,13 @@ import pytest import torch +from vllm.model_executor.layers.fused_moe.activation import MoEActivation from vllm.model_executor.layers.fused_moe.experts.marlin_moe import ( fused_marlin_moe, ) +from vllm.model_executor.layers.fused_moe.experts.trtllm_mxint4_moe import ( + TrtLlmMxint4ExpertsMonolithic, +) from vllm.model_executor.layers.fused_moe.router.grouped_topk_router import ( grouped_topk, ) @@ -77,6 +81,14 @@ def mxint4_quantize_moe_weights( ] +def test_trtllm_mxint4_activation_supports_vllm_gated_silu(): + assert TrtLlmMxint4ExpertsMonolithic._supports_activation(MoEActivation.SILU) + assert TrtLlmMxint4ExpertsMonolithic._supports_activation(MoEActivation.SWIGLUOAI) + assert not TrtLlmMxint4ExpertsMonolithic._supports_activation( + MoEActivation.RELU2_NO_MUL + ) + + def marlin_quantize_moe_weights( weights_bf16: torch.Tensor, group_size: int = 32 ) -> tuple[torch.Tensor, torch.Tensor]: diff --git a/tests/kernels/moe/test_moe.py b/tests/kernels/moe/test_moe.py index 781b5e383e06..69c50cbb11fa 100644 --- a/tests/kernels/moe/test_moe.py +++ b/tests/kernels/moe/test_moe.py @@ -406,7 +406,7 @@ def test_fused_moe_int64_overflow(workspace_init): Reproduces the scenario from PR #34279. """ # ~12 GB GPU memory needed for intermediate caches - free_mem = torch.cuda.mem_get_info()[0] + free_mem = torch.accelerator.get_memory_info()[0] if free_mem < 12 * 1024**3: pytest.skip("Insufficient GPU memory for overflow test") @@ -1243,15 +1243,27 @@ def test_batched_moe_align_block_size_opcheck(): ) +# topk=8 covers topk > 4; k=511 covers the non-vectorized scalar path. The +# layouts exercise contiguous input plus the two non-contiguous cases: a +# transpose (strided hidden -> scalar gather) and a topk-slice (hidden still +# contiguous -> vectorized). @pytest.mark.parametrize("m", [1, 33, 222]) -@pytest.mark.parametrize("topk", TOP_KS) +@pytest.mark.parametrize("topk", [*TOP_KS, 8]) @pytest.mark.parametrize("k", [128, 511, 1024]) @pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) -def test_moe_sum(m: int, topk: int, k: int, dtype: torch.dtype): - input = torch.randn((m, topk, k), device="cuda", dtype=dtype) +@pytest.mark.parametrize("layout", ["contig", "transpose", "slice"]) +def test_moe_sum(m: int, topk: int, k: int, dtype: torch.dtype, layout: str): + if layout == "transpose": + input = torch.randn((m, k, topk), device="cuda", dtype=dtype).transpose(1, 2) + elif layout == "slice": + input = torch.randn((m, 2 * topk, k), device="cuda", dtype=dtype)[:, ::2, :] + else: + input = torch.randn((m, topk, k), device="cuda", dtype=dtype) + assert input.is_contiguous() == (layout == "contig") actual = torch.empty((m, k), device="cuda", dtype=dtype) - expected = input.sum(dim=1) + # Reduction accumulates in fp32. + expected = input.float().sum(dim=1).to(dtype) torch.ops._moe_C.moe_sum(input, actual) torch.testing.assert_close(actual, expected, atol=2e-2, rtol=0) @@ -1585,7 +1597,6 @@ def test_unquantized_bf16_flashinfer_trtllm_backend( e: int, topk: int, dtype: torch.dtype, - monkeypatch, workspace_init, ): """ @@ -1593,8 +1604,6 @@ def test_unquantized_bf16_flashinfer_trtllm_backend( """ set_random_seed(7) - monkeypatch.setenv("VLLM_USE_FLASHINFER_MOE_FP16", "1") - from vllm.model_executor.layers.fused_moe.config import ( FusedMoEConfig, FusedMoEParallelConfig, @@ -1617,16 +1626,16 @@ def test_unquantized_bf16_flashinfer_trtllm_backend( num_experts=e, experts_per_token=topk, hidden_dim=k, - intermediate_size_per_partition=n, + intermediate_size=n, num_local_experts=e, num_logical_experts=e, activation=MoEActivation.SILU, device="cuda", moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), in_dtype=dtype, - is_act_and_mul=True, routing_method=RoutingMethodType.Renormalize, max_num_tokens=next_power_of_2(m), + moe_backend="flashinfer_trtllm", ) with set_current_vllm_config(vllm_config): diff --git a/tests/kernels/moe/test_moe_kernel_oracle.py b/tests/kernels/moe/test_moe_kernel_oracle.py new file mode 100644 index 000000000000..fbdf804a3b78 --- /dev/null +++ b/tests/kernels/moe/test_moe_kernel_oracle.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the MoEKernelOracle ABC introduced in PR series for #37753. + +This file contains a single canonical demonstration that +`UnquantizedMoEKernelOracle` methods delegate one-to-one to the +existing module-level functions in `oracle/unquantized.py`. Each method +on `UnquantizedMoEKernelOracle` follows the same `return module_fn(args)` +pattern, so verifying delegation for one method (`make_kernel`) gives +high confidence in the rest. +""" + +from unittest.mock import patch + +from vllm.model_executor.layers.fused_moe.experts.triton_moe import TritonExperts +from vllm.model_executor.layers.fused_moe.oracle import UnquantizedMoEKernelOracle +from vllm.model_executor.layers.fused_moe.oracle.unquantized import ( + UnquantizedMoeBackend, +) + + +class TestUnquantizedDelegation: + """UnquantizedMoEKernelOracle methods must delegate to the existing + module-level functions; behaviour is bit-identical.""" + + def test_make_kernel_delegates(self) -> None: + quant_config = object() + moe_config = object() + experts_cls = TritonExperts + sentinel_kernel = object() + + with patch( + "vllm.model_executor.layers.fused_moe.oracle.unquantized." + "make_unquantized_moe_kernel", + return_value=sentinel_kernel, + ) as mocked: + out = UnquantizedMoEKernelOracle().make_kernel( + quant_config, + moe_config, + UnquantizedMoeBackend.TRITON, + experts_cls, + ) + + mocked.assert_called_once_with( + quant_config, + moe_config, + UnquantizedMoeBackend.TRITON, + experts_cls, + None, # routing_tables default + ) + assert out is sentinel_kernel diff --git a/tests/kernels/moe/test_moe_layer.py b/tests/kernels/moe/test_moe_layer.py index 188f44481379..cc8d9c36dc0a 100644 --- a/tests/kernels/moe/test_moe_layer.py +++ b/tests/kernels/moe/test_moe_layer.py @@ -36,10 +36,13 @@ get_eplb_group, tensor_model_parallel_all_gather, ) +from vllm.distributed.device_communicators.all_reduce_utils import ( + gpu_p2p_access_check, +) from vllm.distributed.eplb.eplb_communicator import create_eplb_communicator from vllm.distributed.eplb.rebalance_execute import rearrange_expert_weights_inplace from vllm.forward_context import set_forward_context -from vllm.model_executor.layers.fused_moe import FusedMoE, fused_experts +from vllm.model_executor.layers.fused_moe import FusedMoE, MoERunner, fused_experts from vllm.model_executor.layers.fused_moe.activation import MoEActivation from vllm.model_executor.layers.fused_moe.config import FusedMoEQuantConfig from vllm.model_executor.layers.fused_moe.router.router_factory import ( @@ -106,6 +109,8 @@ if has_nixl_ep(): BACKENDS += ["nixl_ep"] +DEEPEP_BACKENDS = {"deepep_high_throughput", "deepep_low_latency"} + QUANT_METHODS = [ None, "fp8", @@ -171,7 +176,7 @@ def override_normalize_e4m3fn_to_e4m3fnuz(): def sp_wrapper( - fn: Callable | FusedMoE, is_sequence_parallel: bool | None = None + fn: Callable | MoERunner, is_sequence_parallel: bool | None = None ) -> Callable: """Wrapper to handle sequence parallelism chunking and gathering. @@ -182,9 +187,9 @@ def sp_wrapper( - tensor_model_parallel_all_gather() uses get_tp_group() - Both should work correctly even when EP is enabled """ - if isinstance(fn, FusedMoE): + if isinstance(fn, MoERunner): assert is_sequence_parallel is None - is_sequence_parallel = fn.is_sequence_parallel + is_sequence_parallel = fn.moe_config.moe_parallel_config.is_sequence_parallel else: assert is_sequence_parallel is not None @@ -322,7 +327,7 @@ class MoETestConfig: def is_sequence_parallel(self) -> bool: # Sequence parallelism: EP enabled + TP dimension used for sequence splitting # In test config: ep_size represents total expert parallel size - # tp_size represents the original TP dimension (becomes sp_size in FusedMoE) + # tp_size represents the original TP dimension (becomes sp_size in MoERunner) # dp_size represents data parallel size # For SP: we need EP enabled (ep_size > 1) and sequence splitting (tp_size > 1) return self.ep_size > 1 and self.tp_size > 1 @@ -416,6 +421,22 @@ def generate_valid_test_configs( return configs +@functools.cache +def visible_devices_have_peer_access(world_size: int) -> bool: + if not current_platform.is_cuda(): + return True + + try: + return all( + gpu_p2p_access_check(src, dst) + for src in range(world_size) + for dst in range(world_size) + if src != dst + ) + except RuntimeError: + return False + + # TODO: break this up into sections def is_valid_config(config: MoETestConfig) -> tuple[bool, str | None]: # routed_input_transform only makes sense with shared_experts (latent MoE) @@ -988,7 +1009,7 @@ def make_fused_moe_layer( routed_output_transform: torch.nn.Module | None = None, pcp_size: int | None = 1, is_sequence_parallel: bool = False, -) -> FusedMoE: +) -> MoERunner: quant_config, qw = make_quant_config(quantization, w1, w2, global_num_experts) kwargs = dict() @@ -1014,7 +1035,6 @@ def make_fused_moe_layer( topk_group=topk_group, quant_config=quant_config, tp_size=tp_size, - ep_size=ep_size, dp_size=dp_size, pcp_size=pcp_size, prefix="from_forward_context", @@ -1031,7 +1051,9 @@ def make_fused_moe_layer( **kwargs, ) - weight_scale_name = getattr(layer.quant_method, "weight_scale_name", "weight_scale") + weight_scale_name = getattr( + layer._quant_method, "weight_scale_name", "weight_scale" + ) for name, value in [ ("w13_weight", qw.w13_weight), @@ -1044,11 +1066,11 @@ def make_fused_moe_layer( ("w2_input_scale", qw.w2_input_scale), ]: if value is not None: - layer.register_parameter( + layer.routed_experts.register_parameter( name, torch.nn.Parameter(value, requires_grad=False) ) - layer.quant_method.process_weights_after_loading(layer) + layer._quant_method.process_weights_after_loading(layer.routed_experts) return layer @@ -1076,6 +1098,7 @@ def make_fake_moe_layer( expert_load_view: torch.Tensor | None = None, logical_to_physical_map: torch.Tensor | None = None, logical_replica_count: torch.Tensor | None = None, + num_redundant_experts: int = 0, gate: torch.nn.Module | None = None, routed_input_transform: torch.nn.Module | None = None, routed_output_transform: torch.nn.Module | None = None, @@ -1100,9 +1123,6 @@ def make_fake_moe_layer( routed_scaling_factor=routed_scaling_factor, e_score_correction_bias=e_score_correction_bias, num_fused_shared_experts=0, # TODO - # TODO(bnell): once we can construct the MK at init time, we - # can make this a value. - indices_type_getter=lambda: indices_type, ) if quant_dtype is not None: @@ -1143,6 +1163,7 @@ def _moe( topk_weights, topk_ids = router.select_experts( hidden_states=hidden_states, router_logits=router_logits, + topk_indices_dtype=indices_type, ) # Shared experts use original (untransformed) hidden_states @@ -1184,7 +1205,7 @@ def _moe( def _test_body_regular( - moe_layer: FusedMoE, + moe_layer: MoERunner, hidden_states: torch.Tensor, router_logits: torch.Tensor, vllm_config: VllmConfig, @@ -1207,7 +1228,7 @@ def _test_body_regular( def _test_body_eplb( - moe_layer: FusedMoE, + moe_layer: MoERunner, hidden_states: torch.Tensor, router_logits: torch.Tensor, vllm_config: VllmConfig, @@ -1234,7 +1255,7 @@ def _test_body_eplb( ) -> tuple[torch.Tensor, torch.Tensor]: device = torch.accelerator.current_accelerator() - is_sequence_parallel = moe_layer.is_sequence_parallel + is_sequence_parallel = moe_layer.moe_config.moe_parallel_config.is_sequence_parallel """EPLB test body: compare output before and after expert weight rearrangement.""" # Get "before" output with original weight arrangement @@ -1278,9 +1299,6 @@ def _test_body_eplb( is_sequence_parallel=is_sequence_parallel, ) - if eplb_moe_layer._expert_map is not None: - eplb_moe_layer._expert_map = eplb_moe_layer._expert_map.to(device) - # All ranks must generate the same permutation initial_indices = torch.arange(num_experts, dtype=torch.long) shuffled_indices = initial_indices[torch.randperm(num_experts)] @@ -1288,6 +1306,9 @@ def _test_body_eplb( expert_weights = [list(eplb_moe_layer.get_expert_weights())] expert_buffer = [torch.empty_like(w) for w in expert_weights[0]] + assert vllm_config.parallel_config.eplb_config.communicator is not None, ( + "EPLB communicator backend must be set by ParallelConfig" + ) communicator = create_eplb_communicator( group_coordinator=get_eplb_group(), backend=vllm_config.parallel_config.eplb_config.communicator, @@ -1329,9 +1350,12 @@ def _test_body_eplb( ), ) - eplb_moe_layer.eplb_state.should_record_tensor = torch.ones( + eplb_moe_layer.router.eplb_state.should_record_tensor = torch.ones( (), dtype=torch.bool, device=device ) + eplb_moe_layer.router.eplb_state.num_unpadded_tokens_tensors = [ + torch.tensor(0, dtype=torch.int32, device=device) + ] # Get "after" output with rearranged weights and EPLB routing with set_forward_context( @@ -1381,193 +1405,194 @@ def _run_one_config( * Weights are chunked by ep_size (experts) but NOT by tp_size * Input sequences are chunked by tp_size (via sp_wrapper) """ - set_random_seed(7) - - use_ep = ep_size > 1 + try: + set_random_seed(7) - assert vllm_config.parallel_config.enable_expert_parallel == use_ep + use_ep = ep_size > 1 - in_dtype = torch.bfloat16 - device = torch.accelerator.current_accelerator() + assert vllm_config.parallel_config.enable_expert_parallel == use_ep - if not is_workspace_manager_initialized(): - init_workspace_manager(device) + in_dtype = torch.bfloat16 + device = torch.accelerator.current_accelerator() - # Create test data and transforms - test_data = setup_moe_test_data( - m=m, - k=k, - n=n, - num_experts=num_experts, - in_dtype=in_dtype, - use_shared_experts=use_shared_experts, - use_gate=use_gate, - use_routed_input_transform=use_routed_input_transform, - backend=backend, - device=device, - ) + if not is_workspace_manager_initialized(): + init_workspace_manager(device) - # Extract data from test_data - hidden_states = test_data.hidden_states - router_logits = test_data.router_logits - w1 = test_data.w1 - w2 = test_data.w2 - shared_experts_config = test_data.shared_experts_config - gate = test_data.gate - routed_input_transform = test_data.routed_input_transform - routed_output_transform = test_data.routed_output_transform - activation = "silu" - - # Create baseline layer with FULL weights (no EP chunking) - # Baseline represents the expected output using full model - baseline_layer = make_fake_moe_layer( - w1=w1, - w2=w2, - top_k=top_k, - global_num_experts=num_experts, - in_dtype=in_dtype, - quantization=quantization, - renormalize=False, - shared_experts_config=shared_experts_config, - gate=gate, - routed_input_transform=routed_input_transform, - routed_output_transform=routed_output_transform, - use_ep=use_ep, - tp_size=tp_size, - ep_size=ep_size, - dp_size=dp_size, - activation=activation, - is_sequence_parallel=is_sequence_parallel, - ) - - with set_current_vllm_config(vllm_config): - # Compute baseline output with SP wrapper if needed - # sp_wrapper handles sequence chunking/gathering for SP - baseline_output = sp_wrapper(baseline_layer, is_sequence_parallel)( - hidden_states, router_logits - ) - - del baseline_layer - torch.accelerator.empty_cache() - - with set_current_vllm_config(vllm_config): - # Chunk weights for EP BEFORE creating FusedMoE - # FusedMoE uses EP-chunked weights and handles reductions internally - if ep_size > 1: - # Split experts across ranks (dimension 0 is the expert dimension) - # When EP is enabled, use EP group rank and ep_size for chunking - ep_rank = get_ep_group().rank_in_group - w1 = chunk_by_rank(w1, ep_rank, ep_size, dim=0, device=device) - w2 = chunk_by_rank(w2, ep_rank, ep_size, dim=0, device=device) - - # Chunk weights for TP (only if NOT doing sequence parallelism) - # Sequence parallelism splits tokens/sequences, not weight tensors - if tp_size > 1 and not is_sequence_parallel: - w1 = tp_chunk_gate_up(w1, tp_rank, tp_size, dim=1, device=device) - w2 = chunk_by_rank(w2, tp_rank, tp_size, dim=2, device=device) - - # Setup shared experts if needed - # In SP mode, shared experts should NOT be TP-chunked (same as routed experts) - # tp_size is used for sequence splitting, not weight splitting - shared_experts = create_shared_experts_from_config( - shared_experts_config, - in_dtype, - tp_size, - tp_rank, - is_sequence_parallel, - device, + # Create test data and transforms + test_data = setup_moe_test_data( + m=m, + k=k, + n=n, + num_experts=num_experts, + in_dtype=in_dtype, + use_shared_experts=use_shared_experts, + use_gate=use_gate, + use_routed_input_transform=use_routed_input_transform, + backend=backend, + device=device, ) - # Determine hidden size for MoE layer - # When using routed_input_transform, experts operate in latent space - hidden_size_for_layer = k // 2 if routed_input_transform is not None else k - - # Create initial MoE layer - moe_layer = make_fused_moe_layer( - quantization=quantization, - use_ep=use_ep, - hidden_size=hidden_size_for_layer, - intermediate_size=n, - in_dtype=in_dtype, - tp_size=tp_size, - ep_size=ep_size, - dp_size=dp_size, + # Extract data from test_data + hidden_states = test_data.hidden_states + router_logits = test_data.router_logits + w1 = test_data.w1 + w2 = test_data.w2 + shared_experts_config = test_data.shared_experts_config + gate = test_data.gate + routed_input_transform = test_data.routed_input_transform + routed_output_transform = test_data.routed_output_transform + activation = "silu" + + # Create baseline layer with FULL weights (no EP chunking) + # Baseline represents the expected output using full model + baseline_layer = make_fake_moe_layer( w1=w1, w2=w2, top_k=top_k, global_num_experts=num_experts, - shared_experts=shared_experts, + in_dtype=in_dtype, + quantization=quantization, + renormalize=False, + shared_experts_config=shared_experts_config, gate=gate, routed_input_transform=routed_input_transform, routed_output_transform=routed_output_transform, + use_ep=use_ep, + tp_size=tp_size, + ep_size=ep_size, + dp_size=dp_size, activation=activation, is_sequence_parallel=is_sequence_parallel, ) - if moe_layer._expert_map is not None: - moe_layer._expert_map = moe_layer._expert_map.to(device) + with set_current_vllm_config(vllm_config): + # Compute baseline output with SP wrapper if needed + # sp_wrapper handles sequence chunking/gathering for SP + baseline_output = sp_wrapper(baseline_layer, is_sequence_parallel)( + hidden_states, router_logits + ) - num_tokens = m - # num_tokens_across_dp should have one entry per DP group, not per total rank - # When EP is enabled, dp_size represents the number of DP groups - num_tokens_across_dp = torch.tensor( - [num_tokens] * dp_size, - device=device, - dtype=torch.int, - ) + del baseline_layer + torch.accelerator.empty_cache() - # Call the test body function with all necessary context - expected, actual = test_body_fn( - moe_layer=moe_layer, - hidden_states=hidden_states, - router_logits=router_logits, - vllm_config=vllm_config, - num_tokens=num_tokens, - num_tokens_across_dp=num_tokens_across_dp, - in_dtype=in_dtype, - quantization=quantization, - use_ep=use_ep, - tp_size=tp_size, - ep_size=ep_size, - dp_size=dp_size, - w1=w1, - w2=w2, - num_experts=num_experts, - k=k, - n=n, - m=m, - top_k=top_k, - shared_experts=shared_experts, - gate=gate, - routed_input_transform=routed_input_transform, - routed_output_transform=routed_output_transform, - baseline_output=baseline_output, - **kwargs, - ) + with set_current_vllm_config(vllm_config): + # Chunk weights for EP BEFORE creating FusedMoE + # FusedMoE uses EP-chunked weights and handles reductions internally + if ep_size > 1: + # Split experts across ranks (dimension 0 is the expert dimension) + # When EP is enabled, use EP group rank and ep_size for chunking + ep_rank = get_ep_group().rank_in_group + w1 = chunk_by_rank(w1, ep_rank, ep_size, dim=0, device=device) + w2 = chunk_by_rank(w2, ep_rank, ep_size, dim=0, device=device) + + # Chunk weights for TP (only if NOT doing sequence parallelism) + # Sequence parallelism splits tokens/sequences, not weight tensors + if tp_size > 1 and not is_sequence_parallel: + w1 = tp_chunk_gate_up(w1, tp_rank, tp_size, dim=1, device=device) + w2 = chunk_by_rank(w2, tp_rank, tp_size, dim=2, device=device) + + # Setup shared experts if needed + # In SP mode, shared experts should NOT be TP-chunked (same as routed + # experts). + # tp_size is used for sequence splitting, not weight splitting + shared_experts = create_shared_experts_from_config( + shared_experts_config, + in_dtype, + tp_size, + tp_rank, + is_sequence_parallel, + device, + ) - # Common tolerance logic - # TODO: consider associating tolerances with quant methods. - if quantization is None: - if k >= 2048: - atol, rtol = 7.6e-2, 7.6e-2 - else: - atol, rtol = 3.5e-2, 3.5e-2 - elif quantization in ("fp8", "fp8_blocked", "modelopt_fp8"): - atol, rtol = 6.5e-2, 6.5e-2 - elif quantization == "modelopt_fp4": - if k >= 2048: - atol = rtol = 1e-1 + (k * 1e-4) - else: - atol = rtol = 1e-1 + # Determine hidden size for MoE layer + # When using routed_input_transform, experts operate in latent space + hidden_size_for_layer = k // 2 if routed_input_transform is not None else k + + # Create initial MoE layer + moe_layer = make_fused_moe_layer( + quantization=quantization, + use_ep=use_ep, + hidden_size=hidden_size_for_layer, + intermediate_size=n, + in_dtype=in_dtype, + tp_size=tp_size, + ep_size=ep_size, + dp_size=dp_size, + w1=w1, + w2=w2, + top_k=top_k, + global_num_experts=num_experts, + shared_experts=shared_experts, + gate=gate, + routed_input_transform=routed_input_transform, + routed_output_transform=routed_output_transform, + activation=activation, + is_sequence_parallel=is_sequence_parallel, + ) - if backend == "allgather_reducescatter" and tp_size > 1: - atol += 2e-1 - rtol += 2e-1 - else: - atol, rtol = 6e-2, 6e-2 + num_tokens = m + # num_tokens_across_dp should have one entry per DP group, not per + # total rank. + # When EP is enabled, dp_size represents the number of DP groups + num_tokens_across_dp = torch.tensor( + [num_tokens] * dp_size, + device=device, + dtype=torch.int, + ) + + # Call the test body function with all necessary context + expected, actual = test_body_fn( + moe_layer=moe_layer, + hidden_states=hidden_states, + router_logits=router_logits, + vllm_config=vllm_config, + num_tokens=num_tokens, + num_tokens_across_dp=num_tokens_across_dp, + in_dtype=in_dtype, + quantization=quantization, + use_ep=use_ep, + tp_size=tp_size, + ep_size=ep_size, + dp_size=dp_size, + w1=w1, + w2=w2, + num_experts=num_experts, + k=k, + n=n, + m=m, + top_k=top_k, + shared_experts=shared_experts, + gate=gate, + routed_input_transform=routed_input_transform, + routed_output_transform=routed_output_transform, + baseline_output=baseline_output, + **kwargs, + ) + + # Common tolerance logic + # TODO: consider associating tolerances with quant methods. + if quantization is None: + if k >= 2048: + atol, rtol = 7.6e-2, 7.6e-2 + else: + atol, rtol = 3.5e-2, 3.5e-2 + elif quantization in ("fp8", "fp8_blocked", "modelopt_fp8"): + atol, rtol = 6.5e-2, 6.5e-2 + elif quantization == "modelopt_fp4": + if k >= 2048: + atol = rtol = 1e-1 + (k * 1e-4) + else: + atol = rtol = 1e-1 + + if backend == "allgather_reducescatter" and tp_size > 1: + atol += 2e-1 + rtol += 2e-1 + else: + atol, rtol = 6e-2, 6e-2 - torch.accelerator.synchronize() # TODO: Is this needed? - torch.testing.assert_close(expected, actual, atol=atol, rtol=rtol) + torch.testing.assert_close(expected, actual, atol=atol, rtol=rtol) + finally: + torch.accelerator.synchronize() # Test for non-parallel cases (world_size == 1) - backend doesn't matter @@ -1798,17 +1823,17 @@ def test_moe_layer( if enable_eplb and not use_ep: pytest.skip("EPLB requires EP.") + if backend in DEEPEP_BACKENDS and not visible_devices_have_peer_access(world_size): + pytest.skip("DeepEP backends require peer access between visible GPUs.") + verbosity = pytestconfig.getoption("verbose") if os.environ.get("VLLM_LOGGING_LEVEL") is None: monkeypatch.setenv("VLLM_LOGGING_LEVEL", "ERROR") - # TODO - # VLLM_FLASHINFER_MOE_BACKEND=latency - # VLLM_USE_FLASHINFER_MOE_FP16=1 - # VLLM_USE_FLASHINFER_MOE_FP8 - # VLLM_USE_FLASHINFER_MOE_FP4 - # VLLM_USE_FLASHINFER_MOE_INT4 + # TODO: cover FlashInfer MoE backends via moe_backend, e.g. + # moe_backend=flashinfer_trtllm / flashinfer_cutlass / flashinfer_cutedsl + # (BF16, FP8 and NVFP4 paths), and VLLM_USE_FLASHINFER_MOE_INT4=1. parallel_config = ParallelConfig( pipeline_parallel_size=1, diff --git a/tests/kernels/moe/test_moe_weight_loading_padded.py b/tests/kernels/moe/test_moe_weight_loading_padded.py index abe473879f1d..2fd4e0fed5e1 100644 --- a/tests/kernels/moe/test_moe_weight_loading_padded.py +++ b/tests/kernels/moe/test_moe_weight_loading_padded.py @@ -12,7 +12,7 @@ import pytest import torch -from vllm.model_executor.layers.fused_moe.layer import FusedMoE +from vllm.model_executor.layers.fused_moe.routed_experts import RoutedExperts class TestGetHiddenDim: @@ -20,45 +20,45 @@ class TestGetHiddenDim: def test_2d_non_transposed_w2(self): # w2: shard_dim=1 (intermediate), hidden=0 - assert FusedMoE._get_hidden_dim(shard_dim=1, ndim=2) == 0 + assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2) == 0 def test_2d_non_transposed_w13(self): # w1/w3: shard_dim=0 (intermediate), hidden=1 - assert FusedMoE._get_hidden_dim(shard_dim=0, ndim=2) == 1 + assert RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2) == 1 def test_2d_transposed_w2(self): # transposed w2: shard_dim=0, hidden=1 - assert FusedMoE._get_hidden_dim(shard_dim=0, ndim=2) == 1 + assert RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2) == 1 def test_2d_transposed_w13(self): # transposed w1/w3: shard_dim=1, hidden=0 - assert FusedMoE._get_hidden_dim(shard_dim=1, ndim=2) == 0 + assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2) == 0 def test_3d_non_transposed_w2(self): # 3D w2: shard_dim=2, hidden=1 - assert FusedMoE._get_hidden_dim(shard_dim=2, ndim=3) == 1 + assert RoutedExperts._get_hidden_dim(shard_dim=2, ndim=3) == 1 def test_3d_non_transposed_w13(self): # 3D w1/w3: shard_dim=1, hidden=2 - assert FusedMoE._get_hidden_dim(shard_dim=1, ndim=3) == 2 + assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=3) == 2 def test_3d_transposed_w2(self): # transposed 3D w2: shard_dim=1, hidden=2 - assert FusedMoE._get_hidden_dim(shard_dim=1, ndim=3) == 2 + assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=3) == 2 def test_3d_transposed_w13(self): # transposed 3D w1/w3: shard_dim=2, hidden=1 - assert FusedMoE._get_hidden_dim(shard_dim=2, ndim=3) == 1 + assert RoutedExperts._get_hidden_dim(shard_dim=2, ndim=3) == 1 def test_1d_returns_zero(self): # 1D per-channel scales: always returns 0 - assert FusedMoE._get_hidden_dim(shard_dim=0, ndim=1) == 0 - assert FusedMoE._get_hidden_dim(shard_dim=1, ndim=1) == 0 + assert RoutedExperts._get_hidden_dim(shard_dim=0, ndim=1) == 0 + assert RoutedExperts._get_hidden_dim(shard_dim=1, ndim=1) == 0 def test_invalid_shard_dim_raises(self): # shard_dim outside the data dimensions should raise with pytest.raises(ValueError, match="not a valid data dimension"): - FusedMoE._get_hidden_dim(shard_dim=0, ndim=3) + RoutedExperts._get_hidden_dim(shard_dim=0, ndim=3) class TestNarrowExpertDataForPadding: @@ -67,7 +67,7 @@ class TestNarrowExpertDataForPadding: def test_no_narrowing_when_shapes_match(self): expert_data = torch.zeros(1024, 1024) loaded_weight = torch.randn(1024, 1024) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=0 ) assert result.shape == loaded_weight.shape @@ -77,7 +77,7 @@ def test_narrow_w2_hidden_dim(self): # w2: (hidden_size, intermediate_size) - hidden_size padded at dim 0 expert_data = torch.zeros(3072, 1024) loaded_weight = torch.randn(2688, 1024) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=0 ) assert result.shape == (2688, 1024) @@ -86,7 +86,7 @@ def test_narrow_w13_hidden_dim(self): # w1/w3: (intermediate_size, hidden_size) - hidden_size padded at dim 1 expert_data = torch.zeros(2048, 3072) loaded_weight = torch.randn(2048, 2688) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=1 ) assert result.shape == (2048, 2688) @@ -95,8 +95,8 @@ def test_narrow_transposed_w2(self): # transposed w2: (intermediate_size, hidden_size) - hidden at dim 1 expert_data = torch.zeros(1024, 3072) loaded_weight = torch.randn(1024, 2688) - hidden_dim = FusedMoE._get_hidden_dim(shard_dim=0, ndim=2) - result = FusedMoE._narrow_expert_data_for_padding( + hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2) + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=hidden_dim ) assert result.shape == (1024, 2688) @@ -105,7 +105,7 @@ def test_narrow_3d_full_load(self): # 3D tensor for full_load path: w2 (num_experts, hidden_size, intermediate) expert_data = torch.zeros(8, 3072, 1024) loaded_weight = torch.randn(8, 2688, 1024) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=1 ) assert result.shape == (8, 2688, 1024) @@ -114,7 +114,7 @@ def test_narrow_1d_scale(self): # 1D scale tensor: per-channel w2 scale (hidden_size,) expert_data = torch.zeros(3072) loaded_weight = torch.randn(2688) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=0 ) assert result.shape == (2688,) @@ -123,7 +123,7 @@ def test_scalar_weight_no_op(self): # 0-dim tensor should be a no-op expert_data = torch.zeros(3072) loaded_weight = torch.tensor(1.0) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=0 ) # ndim == 0, so no narrowing @@ -133,7 +133,7 @@ def test_no_narrowing_when_loaded_weight_larger(self): # Guard: don't narrow if loaded_weight is larger than expert_data expert_data = torch.zeros(2688, 1024) loaded_weight = torch.randn(3072, 1024) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=0 ) assert result.shape == (2688, 1024) @@ -143,7 +143,7 @@ def test_negative_hidden_dim_is_noop(self): # Negative hidden_dim should be a safe no-op (0 <= check) expert_data = torch.zeros(3072, 1024) loaded_weight = torch.randn(2688, 1024) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=-1 ) # -1 fails the 0 <= check, so no narrowing @@ -155,7 +155,7 @@ def test_only_narrows_hidden_dim(self): # even when other dimensions also differ expert_data = torch.zeros(3072, 2048) loaded_weight = torch.randn(2688, 1024) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=0 ) # Only dim 0 (hidden) should be narrowed; dim 1 stays at 2048 @@ -165,7 +165,7 @@ def test_narrowed_data_shares_storage(self): # Verify narrowing returns a view (writes go to original tensor) expert_data = torch.zeros(3072, 1024) loaded_weight = torch.randn(2688, 1024) - result = FusedMoE._narrow_expert_data_for_padding( + result = RoutedExperts._narrow_expert_data_for_padding( expert_data, loaded_weight, hidden_dim=0 ) result.copy_(loaded_weight) @@ -188,8 +188,8 @@ def test_load_w2_with_padding(self): loaded_weight = torch.randn(original_hidden, intermediate) # w2 non-transposed: shard_dim=1, hidden_dim=0 - hidden_dim = FusedMoE._get_hidden_dim(shard_dim=1, ndim=2) - expert_data = FusedMoE._narrow_expert_data_for_padding( + hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2) + expert_data = RoutedExperts._narrow_expert_data_for_padding( expert_data_full, loaded_weight, hidden_dim=hidden_dim ) expert_data.copy_(loaded_weight) @@ -211,8 +211,8 @@ def test_load_w13_with_padding(self): loaded_weight = torch.randn(intermediate, original_hidden) # w1 non-transposed: shard_dim=0, hidden_dim=1 - hidden_dim = FusedMoE._get_hidden_dim(shard_dim=0, ndim=2) - expert_data = FusedMoE._narrow_expert_data_for_padding( + hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2) + expert_data = RoutedExperts._narrow_expert_data_for_padding( expert_data_full, loaded_weight, hidden_dim=hidden_dim ) expert_data.copy_(loaded_weight) @@ -233,8 +233,8 @@ def test_load_transposed_w2_with_padding(self): expert_data_full = torch.zeros(intermediate, padded_hidden) loaded_weight = torch.randn(intermediate, original_hidden) - hidden_dim = FusedMoE._get_hidden_dim(shard_dim=0, ndim=2) - expert_data = FusedMoE._narrow_expert_data_for_padding( + hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=0, ndim=2) + expert_data = RoutedExperts._narrow_expert_data_for_padding( expert_data_full, loaded_weight, hidden_dim=hidden_dim ) expert_data.copy_(loaded_weight) @@ -249,8 +249,8 @@ def test_no_padding_is_noop(self): expert_data_full = torch.zeros(hidden, intermediate) loaded_weight = torch.randn(hidden, intermediate) - hidden_dim = FusedMoE._get_hidden_dim(shard_dim=1, ndim=2) - expert_data = FusedMoE._narrow_expert_data_for_padding( + hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=1, ndim=2) + expert_data = RoutedExperts._narrow_expert_data_for_padding( expert_data_full, loaded_weight, hidden_dim=hidden_dim ) expert_data.copy_(loaded_weight) @@ -270,8 +270,8 @@ def test_narrow_shard_dim(self): loaded_weight = torch.randn(original_hidden, original_intermediate) shard_dim = 1 - hidden_dim = FusedMoE._get_hidden_dim(shard_dim=shard_dim, ndim=2) - expert_data = FusedMoE._narrow_expert_data_for_padding( + hidden_dim = RoutedExperts._get_hidden_dim(shard_dim=shard_dim, ndim=2) + expert_data = RoutedExperts._narrow_expert_data_for_padding( expert_data_full, loaded_weight, hidden_dim=hidden_dim, @@ -307,8 +307,8 @@ def test_bnb_shape_mismatch_raises(self): loaded_weight = torch.randint(0, 255, (original_packed, 1), dtype=torch.uint8) - # Minimal FusedMoE mock so weight_loader reaches the BnB path. - moe = MagicMock(spec=FusedMoE) + # Minimal RoutedExperts mock so weight_loader reaches the BnB path. + moe = MagicMock(spec=RoutedExperts) moe.quant_config = None moe.quant_method = MagicMock() moe.quant_method.__class__.__name__ = "BitsAndBytesMethod" @@ -317,7 +317,7 @@ def test_bnb_shape_mismatch_raises(self): # Call the real weight_loader (unbound) with our mock as self. with pytest.raises(ValueError, match="BitsAndBytes"): - FusedMoE.weight_loader( + RoutedExperts.weight_loader( moe, param, loaded_weight, @@ -325,3 +325,24 @@ def test_bnb_shape_mismatch_raises(self): shard_id="w2", expert_id=0, ) + + +class TestPerTensorScaleCoercion: + """Regression test for shape-(1,) per-tensor scales (issue #43297). + + llm-compressor NVFP4 emits per-tensor weight and input scales as + shape-(1,) tensors. `_to_scalar` collapses them to a 0-D scalar so the + scalar-slot assignments in the weight loader neither broadcast nor raise. + """ + + def test_collapses_to_scalar(self): + # shape-(1,) and 0-D both reduce to a 0-D scalar. + for loaded_weight in (torch.tensor([0.5]), torch.tensor(0.5)): + scalar = RoutedExperts._to_scalar(loaded_weight) + assert scalar.shape == () + assert scalar.item() == pytest.approx(0.5) + + def test_rejects_non_scalar(self): + # numel > 1 must fail loudly instead of silently picking an element. + with pytest.raises(RuntimeError): + RoutedExperts._to_scalar(torch.tensor([0.1, 0.2])) diff --git a/tests/kernels/moe/test_mxfp4_moe.py b/tests/kernels/moe/test_mxfp4_moe.py index 11fd853f54f3..16b233b935e0 100644 --- a/tests/kernels/moe/test_mxfp4_moe.py +++ b/tests/kernels/moe/test_mxfp4_moe.py @@ -244,5 +244,224 @@ def test_mxfp4_experts_quant_basic(): print("PASSED") +def untile_cutlass_scale(scale_raw: torch.Tensor, rows: int, K: int) -> torch.Tensor: + """Convert CUTLASS tiled scale back to flat [M, K//32] layout. + + CUTLASS tiled layout: [numMTiles, numKTiles, 32(outerM), 4(innerM), 4(innerK)] + Produced by: padded.reshape(numMTiles, 4, 32, numKTiles, 4).permute(0,3,2,1,4) + To undo: tiled.permute(0, 3, 2, 1, 4).reshape(padded_M, padded_sK) + """ + num_scale_cols = K // MXFP4_BLOCK_SIZE + num_m_tiles = (rows + 127) // 128 + num_k_tiles = (num_scale_cols + 3) // 4 + padded_M = num_m_tiles * 128 + padded_sK = num_k_tiles * 4 + + scale_bytes = scale_raw.view(torch.uint8).flatten() + total_bytes = padded_M * padded_sK + tiled = scale_bytes[:total_bytes].reshape(num_m_tiles, num_k_tiles, 32, 4, 4) + undone = tiled.permute(0, 3, 2, 1, 4).contiguous() + return undone.reshape(padded_M, padded_sK)[:rows, :num_scale_cols] + + +def compute_reference_e8m0_scale(block_max: float) -> int: + """Compute the expected OCP MX spec E8M0 scale for a given block max. + + The CUTLASS kernel uses round-to-nearest on the mantissa: + rounded_bits = (float_bits + (1 << 21)) & 0xFF800000 + biased_exp = (rounded_bits >> 23) & 0xFF + scale_exp = max(biased_exp - 2, 0) + + This ensures max_val / scale <= 6.0 for most inputs. + """ + import struct + + if block_max <= 0: + return 0 + # Replicate the kernel's rounding logic in Python + float_bytes = struct.pack("f", block_max) + max_bits = struct.unpack("I", float_bytes)[0] + rounded_bits = (max_bits + (1 << 21)) & 0xFF800000 + biased_exp = (rounded_bits >> 23) & 0xFF + scale_exp = max(int(biased_exp) - 2, 0) + scale_exp = min(scale_exp, 254) + return scale_exp + + +@pytest.mark.skipif( + not is_sm100_supported(), + reason="mxfp4_experts_quant requires CUDA SM100", +) +@pytest.mark.parametrize("k", [256, 7168]) +@pytest.mark.parametrize("m", [16, 64]) +def test_mxfp4_experts_quant_e8m0_scale_correctness(m, k): + """ + Test that mxfp4_experts_quant computes E8M0 block scales correctly + per OCP MX spec (not the NVFP4 formula). + + The old buggy kernel used: floor(log2(max/6)) + 127 + The fixed kernel uses: round_nearest_exp(max) - 2 + + This test verifies: + 1. Scales match the expected OCP MX formula for all blocks + 2. No block max exceeds the representable range (no unexpected saturation) + 3. Reconstruction error is within expected bounds for MXFP4 + """ + device = "cuda" + + # Generate input with controlled range + input_tensor = torch.randn(m, k, device=device, dtype=torch.bfloat16) * 0.5 + + # Quantize + num_experts = 1 + expert_offsets = torch.tensor([0, m], device=device, dtype=torch.int32) + num_k_tiles = (k // MXFP4_BLOCK_SIZE + 3) // 4 + blockscale_offsets = torch.tensor( + [0, align(m, 128) * num_k_tiles], device=device, dtype=torch.int32 + ) + + output_fp4, output_sf = ops.mxfp4_experts_quant( + input_tensor, expert_offsets, blockscale_offsets, num_experts, topk=1 + ) + + # Untile scale to flat layout for verification + scale_flat = untile_cutlass_scale(output_sf, m, k) + assert scale_flat.shape == (m, k // MXFP4_BLOCK_SIZE) + + # Verify each block's scale matches the OCP MX spec formula + num_blocks = k // MXFP4_BLOCK_SIZE + mismatches = 0 + buggy_pattern = 0 # count blocks where scale is 1-2 lower than expected + + for row in range(m): + for blk in range(num_blocks): + block_start = blk * MXFP4_BLOCK_SIZE + block_end = block_start + MXFP4_BLOCK_SIZE + block_max = ( + input_tensor[row, block_start:block_end].float().abs().max().item() + ) + + actual_scale = scale_flat[row, blk].item() + expected_scale = compute_reference_e8m0_scale(block_max) + + if actual_scale != expected_scale: + mismatches += 1 + if actual_scale < expected_scale: + buggy_pattern += 1 + + total_blocks = m * num_blocks + match_rate = (total_blocks - mismatches) / total_blocks + + print( + f" m={m}, k={k}: scale match rate = {match_rate * 100:.2f}% " + f"({mismatches}/{total_blocks} mismatches)" + ) + + # The fixed kernel should match the reference formula exactly + assert match_rate > 0.99, ( + f"E8M0 scale match rate too low: {match_rate * 100:.2f}%. " + f"Buggy pattern (scale too low): {buggy_pattern}/{mismatches}. " + f"This suggests the NVFP4 formula bug is present." + ) + + # Extra check: if most mismatches show scale < expected, it's the old bug + if mismatches > 0: + assert buggy_pattern / mismatches < 0.5, ( + f"Most scale mismatches show scale too LOW ({buggy_pattern}/{mismatches}). " + "This is the signature of the NVFP4 formula bug in nvfp4_utils.cuh." + ) + + # Verify reconstruction error is within MXFP4 expected bounds + # Dequantize and check cosine similarity + fp4_lut = torch.tensor( + [0, 0.5, 1, 1.5, 2, 3, 4, 6, 0, -0.5, -1, -1.5, -2, -3, -4, -6], + device=device, + dtype=torch.float32, + ) + lo = (output_fp4 & 0x0F).long() + hi = ((output_fp4 >> 4) & 0x0F).long() + unpacked = torch.stack([lo, hi], dim=-1).reshape(m, k) + fp4_vals = fp4_lut[unpacked] + + scales_expanded = 2.0 ** (scale_flat.float() - 127.0) + scales_expanded = scales_expanded.unsqueeze(-1).expand(-1, -1, MXFP4_BLOCK_SIZE) + scales_expanded = scales_expanded.reshape(m, k) + recon = (fp4_vals * scales_expanded).bfloat16() + + # Cosine similarity should be > 0.99 for well-behaved MXFP4 quantization + cos_sim = torch.nn.functional.cosine_similarity( + recon.float().flatten().unsqueeze(0), + input_tensor.float().flatten().unsqueeze(0), + ).item() + max_abs_diff = (recon.float() - input_tensor.float()).abs().max().item() + + print( + f" Reconstruction: cosine_sim={cos_sim:.6f}, max_abs_diff={max_abs_diff:.4f}" + ) + + assert cos_sim > 0.99, ( + f"Reconstruction cosine similarity too low: {cos_sim:.6f}. " + f"Expected > 0.99 for correct MXFP4 quantization." + ) + # With correct E8M0, max abs diff should be bounded by scale * 6 + # (worst case: value just below threshold rounds to wrong FP4 code) + assert max_abs_diff < 1.0, ( + f"Max reconstruction error too large: {max_abs_diff:.4f}. " + "Likely caused by incorrect E8M0 scale (values saturating to ±6)." + ) + + +@pytest.mark.skipif( + not is_sm100_supported(), + reason="mxfp4_experts_quant requires CUDA SM100", +) +def test_mxfp4_experts_quant_no_saturation(): + """ + Test that the E8M0 scale is large enough to avoid unexpected saturation. + + With the buggy NVFP4 formula, the scale was too small causing most values + to saturate to ±6 in FP4. The fixed OCP MX formula should ensure that + block_max / scale <= 6.0 (the max E2M1 value) in almost all cases. + """ + device = "cuda" + + m, k = 128, 1024 + # Use inputs with known range to make saturation detectable + input_tensor = torch.randn(m, k, device=device, dtype=torch.bfloat16) * 0.5 + + num_experts = 1 + expert_offsets = torch.tensor([0, m], device=device, dtype=torch.int32) + num_k_tiles = (k // MXFP4_BLOCK_SIZE + 3) // 4 + blockscale_offsets = torch.tensor( + [0, align(m, 128) * num_k_tiles], device=device, dtype=torch.int32 + ) + + output_fp4, output_sf = ops.mxfp4_experts_quant( + input_tensor, expert_offsets, blockscale_offsets, num_experts, topk=1 + ) + + # Check saturation rate: count FP4 values that are ±6 (codes 7 and 15) + lo = output_fp4 & 0x0F + hi = (output_fp4 >> 4) & 0x0F + # Code 7 = +6.0, code 15 = -6.0 + saturated = ((lo == 7) | (lo == 15) | (hi == 7) | (hi == 15)).sum().item() + total_values = m * k + saturation_rate = saturated / total_values + + print( + f" Saturation rate: {saturation_rate * 100:.2f}% " + f"({saturated}/{total_values} values at ±6)" + ) + + # For Gaussian input with std=0.5, saturation should be very rare + # (±6 * scale is far from the typical range). + # The buggy kernel had ~30-50% saturation; fixed should be < 5%. + assert saturation_rate < 0.05, ( + f"FP4 saturation rate too high: {saturation_rate * 100:.2f}%. " + "This suggests the E8M0 scale is too small (NVFP4 formula bug). " + "Expected < 5% for Gaussian(0, 0.5) input with correct OCP MX scale." + ) + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) diff --git a/tests/kernels/moe/test_mxfp8_aiter_backend_selection.py b/tests/kernels/moe/test_mxfp8_aiter_backend_selection.py new file mode 100644 index 000000000000..7c2fdbabe294 --- /dev/null +++ b/tests/kernels/moe/test_mxfp8_aiter_backend_selection.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""MXFP8 MoE backend selection for the AITER FlyDSL kernel (gfx950). + +GPU-free: mocks the platform (gfx950) and the ``flydsl`` package check, then +exercises the oracle so the FlyDSL backend is auto-picked when usable (including +under expert parallelism, since apply() forwards the expert_map as aiter's +expert_mask) and skipped (native fallback) when the device/package is missing. +""" + +import dataclasses +from unittest.mock import patch + +import pytest + +from vllm.platforms import current_platform + +if not current_platform.is_rocm(): + pytest.skip("This test can only run on ROCm.", allow_module_level=True) + +from tests.kernels.moe.utils import make_dummy_moe_config # noqa: E402 +from vllm.model_executor.layers.fused_moe.experts.aiter_mxfp8_moe import ( # noqa: E402 + AiterMxfp8Experts, +) +from vllm.model_executor.layers.fused_moe.modular_kernel import ( # noqa: E402 + FusedMoEActivationFormat, +) +from vllm.model_executor.layers.fused_moe.oracle.fp8 import ( # noqa: E402 + Fp8MoeBackend, +) +from vllm.model_executor.layers.fused_moe.oracle.mxfp8 import ( # noqa: E402 + _BACKEND_NAME_MAP, + _SUPPORTED_BACKENDS, + _mxfp8_backend_to_kernel_cls, + _select_kernel_cls, +) +from vllm.model_executor.layers.quantization.utils.quant_utils import ( # noqa: E402 + kMxfp8Dynamic, + kMxfp8Static, +) + +_AITER_MOD = "vllm.model_executor.layers.fused_moe.experts.aiter_mxfp8_moe" + + +def _config(ep_size: int = 1): + cfg = make_dummy_moe_config(num_experts=128, experts_per_token=4, hidden_dim=6144) + if ep_size != 1: + cfg = dataclasses.replace( + cfg, + moe_parallel_config=dataclasses.replace( + cfg.moe_parallel_config, ep_size=ep_size, use_ep=True + ), + ) + return cfg + + +def _gfx950(): + """Patch the platform so the device gate (gfx950 / MX) passes off-ROCm.""" + return patch.multiple( + f"{_AITER_MOD}.current_platform", + is_rocm=lambda: True, + supports_mx=lambda: True, + ) + + +def _flydsl_installed(present: bool): + return patch(f"{_AITER_MOD}.is_aiter_mxfp8_moe_available", return_value=present) + + +def test_aiter_mxfp8_registered(): + """The FlyDSL backend is auto-selectable and reachable via --moe-backend aiter.""" + assert Fp8MoeBackend.AITER_MXFP8 in _SUPPORTED_BACKENDS + assert _BACKEND_NAME_MAP["aiter"] is Fp8MoeBackend.AITER_MXFP8 + assert _mxfp8_backend_to_kernel_cls(Fp8MoeBackend.AITER_MXFP8) == [ + AiterMxfp8Experts + ] + + +def test_triton_selectable(): + assert _BACKEND_NAME_MAP["triton"] is Fp8MoeBackend.TRITON_MXFP8 + # Not auto-selected (only reachable explicitly), so FlyDSL still wins auto. + assert Fp8MoeBackend.TRITON_MXFP8 not in _SUPPORTED_BACKENDS + + +@pytest.mark.parametrize("ep_size", [1, 2]) +def test_ep_supported(ep_size): + """FlyDSL accepts both TP and EP: apply() forwards expert_map as expert_mask.""" + assert ( + AiterMxfp8Experts._supports_parallel_config( + _config(ep_size).moe_parallel_config + ) + is True + ) + + +@pytest.mark.parametrize( + "present,ep_size,supported,reason_substr", + [ + (True, 1, True, None), # gfx950 + flydsl + TP -> selectable + (True, 2, True, None), # gfx950 + flydsl + EP -> selectable (expert_mask) + (False, 1, False, "flydsl package"), # package missing -> clear reason + ], +) +def test_is_supported_config(present, ep_size, supported, reason_substr): + with _gfx950(), _flydsl_installed(present): + ok, reason = AiterMxfp8Experts.is_supported_config( + AiterMxfp8Experts, + _config(ep_size), + kMxfp8Static, + kMxfp8Dynamic, + FusedMoEActivationFormat.Standard, + ) + assert ok is supported + if reason_substr is not None: + assert reason_substr in reason + + +def test_explicit_moe_backend_aiter(): + """--moe-backend aiter: returns FlyDSL when usable (TP or EP), else a clear + ValueError when the flydsl package is missing.""" + with _gfx950(), _flydsl_installed(True): + assert ( + _select_kernel_cls(Fp8MoeBackend.AITER_MXFP8, _config(1)) + is AiterMxfp8Experts + ) + assert ( + _select_kernel_cls(Fp8MoeBackend.AITER_MXFP8, _config(2)) + is AiterMxfp8Experts + ) + with ( + _gfx950(), + _flydsl_installed(False), + pytest.raises(ValueError, match="flydsl package"), + ): + _select_kernel_cls(Fp8MoeBackend.AITER_MXFP8, _config(1)) diff --git a/tests/kernels/moe/test_ocp_mx_moe.py b/tests/kernels/moe/test_ocp_mx_moe.py index 8ed7757f6553..d6eb488a6433 100644 --- a/tests/kernels/moe/test_ocp_mx_moe.py +++ b/tests/kernels/moe/test_ocp_mx_moe.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import importlib.metadata +import types from dataclasses import dataclass from importlib.util import find_spec @@ -9,12 +10,20 @@ import torch from packaging import version +from tests.kernels.moe.utils import check_accuracy +from vllm._aiter_ops import is_aiter_found, rocm_aiter_ops from vllm.platforms import current_platform from vllm.utils.flashinfer import has_flashinfer -QUARK_MXFP4_AVAILABLE = find_spec("quark") is not None and version.parse( - importlib.metadata.version("amd-quark") -) >= version.parse("0.8.99") +# MXFP4 via quark requires amd-quark >= 0.12 on torch >= 2.11. +# Earlier torch releases work with older quark versions. See +# https://github.com/amd/Quark/issues/34 +# TODO: Remove once amd-quark>=0.12.0 +QUARK_MXFP4_TORCH_COMPATIBLE = find_spec("quark") is not None and ( + version.parse(importlib.metadata.version("amd-quark")) >= version.parse("0.12.0") + if version.parse(torch.__version__.split("+")[0]) >= version.parse("2.11") + else True +) TRTLLM_GEN_MXFP4_AVAILABLE = ( current_platform.is_cuda() and current_platform.is_device_capability_family(100) @@ -31,17 +40,15 @@ # ROCm platform and dependencies ROCM_AVAILABLE = current_platform.is_rocm() ROCM_TRITON_KERNELS_AVAILABLE = False -ROCM_AITER_AVAILABLE = False +ROCM_AITER_AVAILABLE = is_aiter_found() ROCM_GFX950 = False if ROCM_AVAILABLE: - from vllm._aiter_ops import rocm_aiter_ops from vllm.platforms.rocm import on_gfx950 from vllm.utils.import_utils import has_triton_kernels ROCM_TRITON_KERNELS_AVAILABLE = has_triton_kernels() ROCM_GFX950 = on_gfx950() - ROCM_AITER_AVAILABLE = rocm_aiter_ops.is_enabled() if ROCM_AITER_AVAILABLE: from aiter.ops.triton.moe.quant_moe import upcast_from_mxfp @@ -83,12 +90,15 @@ def enable_pickle(monkeypatch): [ ModelCase("fxmarty/qwen_1.5-moe-a2.7b-mxfp4", tp=2), ModelCase("fxmarty/deepseek_r1_3_layers_mxfp4", tp=8), - ModelCase("fxmarty/Llama-4-Scout-17B-16E-Instruct-2-layers-mxfp4", tp=1), + ModelCase("mawong-amd/Llama-4-Scout-17B-16E-Instruct-2-layers-mxfp4", tp=1), ModelCase("fxmarty/Llama-3.1-70B-Instruct-2-layers-mxfp6", tp=1), ModelCase("fxmarty/Llama-3.1-70B-Instruct-2-layers-mxfp6", tp=4), ], ) -@pytest.mark.skipif(not QUARK_MXFP4_AVAILABLE, reason="amd-quark>=0.9 is not available") +@pytest.mark.skipif( + not QUARK_MXFP4_TORCH_COMPATIBLE, + reason="MXFP4 via quark requires amd-quark >= 0.12 on torch >= 2.11.", +) def test_mxfp4_loading_and_execution_moe(vllm_runner, model_case: ModelCase): if torch.accelerator.device_count() < model_case.tp: pytest.skip( @@ -102,6 +112,7 @@ def test_mxfp4_loading_and_execution_moe(vllm_runner, model_case: ModelCase): tensor_parallel_size=model_case.tp, load_format="dummy", compilation_config={"cudagraph_capture_sizes": [16]}, + gpu_memory_utilization=0.8, # mxfp6 models use more scratch space ) as llm: # Disabled as check_model is broken: https://github.com/vllm-project/vllm/pull/18465#issuecomment-3329880562 # def check_model(model): @@ -506,29 +517,6 @@ def tg_mxfp4_moe( return tg_result -def check_accuracy(a, b, atol, rtol, percent): - """Allow a mismatch percentage of 1 - percent.""" - if torch.any(torch.isnan(a)): - raise Exception("NaN in reference output") - if torch.any(torch.isnan(b)): - raise Exception("NaN in actual output") - if torch.any(torch.isinf(a)): - raise Exception("Inf in reference output") - if torch.any(torch.isinf(b)): - raise Exception("Inf in actual output") - assert a.shape == b.shape, f"Shape mismatch: {a.shape} vs {b.shape}" - - left = torch.abs(a - b) - right = atol + rtol * torch.abs(b) - count = torch.sum(left > right) - mismatch_percent = count / a.numel() - if mismatch_percent > 1 - percent: - raise Exception( - f"Mismatch percentage is {mismatch_percent:.4f} for rtol {rtol} " - f"(threshold: {1 - percent:.4f})" - ) - - @pytest.mark.parametrize("topk", [1, 4]) @pytest.mark.parametrize("num_experts", [32, 128]) @pytest.mark.parametrize("num_tokens", [1, 128, 1024]) @@ -672,19 +660,6 @@ def test_trtllm_gen_mxfp4_fused_moe( check_accuracy(ref_result, tg_result, atol=0, rtol=0.3, percent=0.8) -def _interleave_scales_lastdim_by4(scales: torch.Tensor) -> torch.Tensor: - """Interleave scales on the last dimension by groups of 4, matching - the transformation in mxfp4.py's BF16 (Hopper) path.""" - s = scales.to(torch.uint8) - s_shape = s.shape - assert s_shape[-1] % 4 == 0 - s = s.reshape(*s_shape[:-1], s_shape[-1] // 4, 4) - # Move the 4-group dimension before the row dimension - permuted = s.permute(0, 2, 1, 3) - # Merge the row dim with the 4-group dim - return permuted.reshape(s_shape[0], s_shape[-1] // 4, s_shape[1] * 4) - - @pytest.mark.parametrize("topk", [1, 4]) @pytest.mark.parametrize("num_experts", [32]) @pytest.mark.parametrize("num_tokens", [1, 128]) @@ -784,13 +759,25 @@ def test_flashinfer_cutlass_mxfp4_fused_moe( w1_w, w3_w = torch.chunk(w13_q, 2, dim=1) w13_q_swapped = torch.cat([w3_w, w1_w], dim=1) + # SM90 mixed-input GEMM expects weights/scales in an interleaved layout; + # without it the FP4->BF16 LUT reads bytes from wrong positions for K>128. + from flashinfer.fused_moe import ( + interleave_moe_scales_for_sm90_mixed_gemm, + interleave_moe_weights_for_sm90_mixed_gemm, + ) + + w13_q_swapped = interleave_moe_weights_for_sm90_mixed_gemm( + w13_q_swapped, quant_type="fp4" + ) + w2_q = interleave_moe_weights_for_sm90_mixed_gemm(w2_q, quant_type="fp4") + b1, b3 = torch.chunk(bias13.to(torch.float32), 2, dim=-1) w13_b = torch.cat([b3, b1], dim=-1).to(torch.bfloat16) w1_s, w3_s = torch.chunk(w13_scale, 2, dim=1) w13_s = torch.cat([w3_s, w1_s], dim=1) - w13_s_inter = _interleave_scales_lastdim_by4(w13_s) - w2_s_inter = _interleave_scales_lastdim_by4(w2_scale) + w13_s_inter = interleave_moe_scales_for_sm90_mixed_gemm(w13_s) + w2_s_inter = interleave_moe_scales_for_sm90_mixed_gemm(w2_scale) routing_weights = torch.nn.functional.softmax( router_logits, dim=1, dtype=torch.float32 @@ -1261,13 +1248,14 @@ def test_rocm_mxfp4_moe_oracle( num_tokens: int, hidden_size: int, intermediate_size: int, + monkeypatch: pytest.MonkeyPatch, ): """ Test ROCm MXFP4 MoE using oracle functions. This test validates that the oracle functions work end-to-end: - select_mxfp4_moe_backend() selects a valid backend - - convert_to_mxfp4_moe_kernel_format() converts weights without error + - convert_gpt_oss_weight_to_mxfp4_moe_kernel_format() converts weights without error - make_mxfp4_moe_quant_config() builds a valid quant config - make_mxfp4_moe_kernel() creates a kernel that runs without error - The kernel output is within accuracy tolerance of reference @@ -1282,12 +1270,13 @@ def test_rocm_mxfp4_moe_oracle( if config["requires_gfx950"] and not ROCM_GFX950: pytest.skip(f"Backend {backend_name} requires GFX950") + import vllm.distributed.parallel_state as ps from vllm.config import VllmConfig, set_current_vllm_config from vllm.model_executor.layers.fused_moe.activation import MoEActivation from vllm.model_executor.layers.fused_moe.oracle.mxfp4 import ( Mxfp4MoeBackend, backend_to_kernel_cls, - convert_to_mxfp4_moe_kernel_format, + convert_gpt_oss_weight_to_mxfp4_moe_kernel_format, make_mxfp4_moe_kernel, make_mxfp4_moe_quant_config, ) @@ -1296,6 +1285,12 @@ def test_rocm_mxfp4_moe_oracle( # Initialize workspace manager (needed for modular kernels) init_workspace_manager(torch.accelerator.current_device_index()) + # Set up the TP Group to prevent failure on should_use_cdna4_mx_scale_swizzle check + monkeypatch.setattr(ps, "_TP", types.SimpleNamespace(world_size=1)) + + # AITER must be enabled or aiter_mxfp4_w4a8_moe asserts before dispatch. + monkeypatch.setattr(rocm_aiter_ops, "_AITER_ENABLED", True) + # Map string to enum backend = Mxfp4MoeBackend[backend_name] @@ -1322,7 +1317,7 @@ def test_rocm_mxfp4_moe_oracle( num_experts=num_experts, experts_per_token=topk, hidden_dim=hidden_size, - intermediate_size_per_partition=intermediate_size, + intermediate_size=intermediate_size, num_local_experts=num_experts, num_logical_experts=num_experts, moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), @@ -1387,7 +1382,7 @@ class MockLayer: # Convert weights using oracle w13_conv, w2_conv, w13_scale_conv, w2_scale_conv, w13_bias_conv, w2_bias_conv = ( - convert_to_mxfp4_moe_kernel_format( + convert_gpt_oss_weight_to_mxfp4_moe_kernel_format( mxfp4_backend=backend, layer=layer, # type: ignore[arg-type] w13_weight=w13_quant, @@ -1423,7 +1418,7 @@ class MockLayer: mxfp4_backend=backend, experts_cls=experts_cls, routing_tables=None, - shared_experts=None, + layer=None, ) # Create inputs diff --git a/tests/kernels/moe/test_profile_modular_kernel.py b/tests/kernels/moe/test_profile_modular_kernel.py new file mode 100644 index 000000000000..de201057f36d --- /dev/null +++ b/tests/kernels/moe/test_profile_modular_kernel.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +from vllm.model_executor.layers.fused_moe import TritonExperts +from vllm.model_executor.layers.fused_moe.prepare_finalize import ( + MoEPrepareAndFinalizeNoDPEPModular, +) + +from .modular_kernel_tools.common import Config +from .modular_kernel_tools.profile_modular_kernel import run + + +@pytest.mark.skipif( + not torch.cuda.is_available(), + reason="profile_modular_kernel requires a CUDA device", +) +def test_profile_modular_kernel_smoke(tmp_path): + config = Config( + Ms=[16], + K=128, + N=256, + E=4, + topks=[2], + dtype=torch.bfloat16, + quant_config=None, + prepare_finalize_type=MoEPrepareAndFinalizeNoDPEPModular, + fused_experts_type=TritonExperts, + world_size=1, + torch_trace_dir_path=str(tmp_path), + ) + + run(config) + + traces = list(tmp_path.glob("m*_*_trace.json")) + assert traces, "profile_modular_kernel.run did not emit any chrome traces" diff --git a/tests/kernels/moe/test_routing.py b/tests/kernels/moe/test_routing.py index 41dea8121938..62a4968a0d1f 100644 --- a/tests/kernels/moe/test_routing.py +++ b/tests/kernels/moe/test_routing.py @@ -61,12 +61,14 @@ def setup_eplb_state( global_num_experts, dtype=torch.int64, device="cuda" ) should_record_tensor = torch.ones((), dtype=torch.bool, device="cuda") + num_unpadded_tokens_tensors = [torch.tensor(0, dtype=torch.int32, device="cuda")] return EplbLayerState( expert_load_view=expert_load_view, logical_to_physical_map=logical_to_physical_map, logical_replica_count=logical_replica_count, should_record_tensor=should_record_tensor, + num_unpadded_tokens_tensors=num_unpadded_tokens_tensors, ) @@ -782,3 +784,67 @@ def test_eplb_map_with_redundancy( torch.testing.assert_close(load, exp_load) else: assert load.sum().item() == 0 + + +@pytest.mark.parametrize( + "l2p_map, replica_count, num_physical, topk_ids, " + "num_unpadded, expected_out, expected_load", + [ + pytest.param( + [[0], [1], [2], [3]], + [1, 1, 1, 1], + 4, + [[0, 1], [2, 3], [0, 2], [1, 3]], + 2, + [[0, 1], [2, 3], [0, 2], [1, 3]], + # only rows 0,1 counted: expert 0→1, 1→1, 2→1, 3→1 + [1, 1, 1, 1], + id="half_padded", + ), + pytest.param( + # record everything (None = no padding info) + [[0], [1], [2], [3]], + [1, 1, 1, 1], + 4, + [[0, 1], [2, 3], [0, 2], [1, 3]], + None, + [[0, 1], [2, 3], [0, 2], [1, 3]], + [2, 2, 2, 2], + id="no_padding_info", + ), + ], +) +def test_eplb_map_num_unpadded_tokens( + l2p_map, + replica_count, + num_physical, + topk_ids, + num_unpadded, + expected_out, + expected_load, +): + l2p = torch.tensor(l2p_map, dtype=torch.int64, device="cuda") + rc = torch.tensor(replica_count, dtype=torch.int64, device="cuda") + load = torch.zeros(num_physical, dtype=torch.int32, device="cuda") + rec = torch.tensor(True, dtype=torch.bool, device="cuda") + ids = torch.tensor(topk_ids, dtype=torch.int32, device="cuda") + num_unpadded_t = ( + torch.tensor(num_unpadded, dtype=torch.int32, device="cuda") + if num_unpadded is not None + else None + ) + + out = eplb_map_to_physical_and_record( + topk_ids=ids, + expert_load_view=load, + logical_to_physical_map=l2p, + logical_replica_count=rc, + record_enabled=rec, + num_unpadded_tokens=num_unpadded_t, + ) + + exp_out = torch.tensor(expected_out, dtype=out.dtype, device="cuda") + torch.testing.assert_close(out, exp_out) + + exp_load = torch.tensor(expected_load, dtype=torch.int32, device="cuda") + torch.testing.assert_close(load, exp_load) diff --git a/tests/kernels/moe/test_trtllm_bf16_moe.py b/tests/kernels/moe/test_trtllm_bf16_moe.py new file mode 100644 index 000000000000..22dff237cbc5 --- /dev/null +++ b/tests/kernels/moe/test_trtllm_bf16_moe.py @@ -0,0 +1,145 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Tests for the FlashInfer TRTLLM BF16 MoE backend +(`TrtLlmBf16ExpertsModular`). + +This mirrors the TRTLLM NvFP4 modular test shape: construct the modular +expert wrapper directly, pass production-format BlockMajorK weights, and +compare against a torch MoE reference using the original BF16 weights. +""" + +import pytest +import torch + +import vllm.model_executor.layers.fused_moe.modular_kernel as mk +from tests.kernels.utils import torch_moe +from vllm.config import ParallelConfig, VllmConfig, set_current_vllm_config +from vllm.model_executor.layers.fused_moe.activation import MoEActivation +from vllm.model_executor.layers.fused_moe.all2all_utils import ( + maybe_make_prepare_finalize, +) +from vllm.model_executor.layers.fused_moe.config import ( + FUSED_MOE_UNQUANTIZED_CONFIG, + FusedMoEConfig, + FusedMoEParallelConfig, + RoutingMethodType, +) +from vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe import ( + TrtLlmBf16ExpertsModular, +) +from vllm.model_executor.layers.quantization.utils.flashinfer_utils import ( + convert_moe_weights_to_flashinfer_trtllm_block_layout, +) +from vllm.platforms import current_platform +from vllm.utils.flashinfer import has_flashinfer_trtllm_fused_moe +from vllm.utils.math_utils import next_power_of_2 +from vllm.utils.torch_utils import set_random_seed + +if pytest and ( + not has_flashinfer_trtllm_fused_moe() + or not current_platform.has_device_capability(100) +): + pytest.skip( + "Requires flashinfer TRTLLM fused MoE BF16 backend (SM100)", + allow_module_level=True, + ) + +# (m, n, k) = (tokens, intermediate_size_per_partition, hidden_dim). +# Covers larger NvFP4-like shapes while keeping BF16's FlashInfer TRTLLM +# intermediate-size multiple-of-128 requirement. +MNK_FACTORS = [ + (2, 1024, 1024), + (64, 2048, 1536), + (64, 1024, 4096), +] + + +@pytest.mark.parametrize("m,n,k", MNK_FACTORS) +@pytest.mark.parametrize("e", [128]) +@pytest.mark.parametrize("topk", [8]) +@pytest.mark.parametrize("dtype", [torch.bfloat16]) +@torch.inference_mode() +def test_trtllm_bf16_moe_modular_no_graph( + m: int, + n: int, + k: int, + e: int, + topk: int, + dtype: torch.dtype, + workspace_init, +): + set_random_seed(7) + with set_current_vllm_config( + VllmConfig(parallel_config=ParallelConfig(pipeline_parallel_size=1)) + ): + a = torch.randn((m, k), device="cuda", dtype=dtype) / 10 + w1 = torch.randn((e, 2 * n, k), device="cuda", dtype=dtype) / 10 + w2 = torch.randn((e, k, n), device="cuda", dtype=dtype) / 10 + score = torch.randn((m, e), device="cuda", dtype=dtype) + scores = torch.softmax(score, dim=-1, dtype=torch.float32) + topk_weights, topk_ids = torch.topk(scores, topk) + topk_weights = topk_weights.contiguous() + topk_ids = topk_ids.to(torch.int32).contiguous() + + moe_config = FusedMoEConfig( + num_experts=e, + experts_per_token=topk, + hidden_dim=k, + intermediate_size=n, + num_local_experts=e, + num_logical_experts=e, + activation=MoEActivation.SILU, + device="cuda", + moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), + in_dtype=dtype, + routing_method=RoutingMethodType.TopK, + max_num_tokens=next_power_of_2(m), + ) + + trtllm_w1, trtllm_w2 = convert_moe_weights_to_flashinfer_trtllm_block_layout( + {}, + w1, + w2, + ) + + trtllm_experts = mk.FusedMoEKernel( + maybe_make_prepare_finalize( + moe=moe_config, + quant_config=FUSED_MOE_UNQUANTIZED_CONFIG, + allow_new_interface=True, + use_monolithic=False, + ), + TrtLlmBf16ExpertsModular( + moe_config=moe_config, + quant_config=FUSED_MOE_UNQUANTIZED_CONFIG, + ), + ) + + trtllm_output = trtllm_experts.apply( + hidden_states=a, + w1=trtllm_w1, + w2=trtllm_w2, + topk_weights=topk_weights, + topk_ids=topk_ids, + activation=MoEActivation.SILU, + global_num_experts=e, + expert_map=None, + apply_router_weight_on_input=False, + ) + + torch_output = torch_moe( + a, + w1, + w2, + score, + topk, + activation=MoEActivation.SILU, + ) + + torch.testing.assert_close( + torch_output, + trtllm_output, + atol=1e-1, + rtol=2e-1, + ) diff --git a/tests/kernels/moe/test_trtllm_nvfp4_moe.py b/tests/kernels/moe/test_trtllm_nvfp4_moe.py index 4b4c3e712be5..2653b711d9fc 100644 --- a/tests/kernels/moe/test_trtllm_nvfp4_moe.py +++ b/tests/kernels/moe/test_trtllm_nvfp4_moe.py @@ -164,14 +164,13 @@ def test_trtllm_fp4_moe_no_graph( num_experts=e, experts_per_token=topk, hidden_dim=k, - intermediate_size_per_partition=n, + intermediate_size=n, num_local_experts=e, num_logical_experts=e, activation=activation, device="cuda", moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), in_dtype=dtype, - is_act_and_mul=is_gated_act, routing_method=RoutingMethodType.TopK, max_num_tokens=next_power_of_2(m), ) diff --git a/tests/kernels/moe/test_unquantized_backend_selection.py b/tests/kernels/moe/test_unquantized_backend_selection.py index bc322aed3903..cdff3667ad32 100644 --- a/tests/kernels/moe/test_unquantized_backend_selection.py +++ b/tests/kernels/moe/test_unquantized_backend_selection.py @@ -5,6 +5,7 @@ import pytest from tests.kernels.moe.utils import make_dummy_moe_config +from vllm.model_executor.layers.fused_moe.config import RoutingMethodType from vllm.model_executor.layers.fused_moe.oracle.unquantized import ( UnquantizedMoeBackend, select_unquantized_moe_backend, @@ -117,13 +118,15 @@ def test_select_rocm_aiter_backend(mock_aiter_enabled, mock_has_flashinfer): @patch( - "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16Experts.is_supported_config", + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsMonolithic.is_supported_config", return_value=(True, None), ) @pytest.mark.skipif( not current_platform.is_cuda(), reason="Only supported on NVIDIA platforms." ) -def test_select_cuda_flashinfer_trtllm_backend(mock_is_supported_trtllm, monkeypatch): +def test_select_cuda_flashinfer_trtllm_backend( + mock_is_supported_trtllm_monolithic, +): """Test CUDA backend selection when FlashInfer TRTLLM is available and enabled.""" with ( patch.object(current_platform, "is_cuda", return_value=True), @@ -134,9 +137,8 @@ def test_select_cuda_flashinfer_trtllm_backend(mock_is_supported_trtllm, monkeyp patch.object(current_platform, "is_out_of_tree", return_value=False), patch.object(current_platform, "has_device_capability", return_value=True), ): - monkeypatch.setenv("VLLM_USE_FLASHINFER_MOE_FP16", "1") - moe_config = make_dummy_moe_config() + moe_config.moe_backend = "flashinfer_trtllm" # TRTLLM requires EP and does not support DP moe_config.moe_parallel_config.use_ep = True moe_config.moe_parallel_config.use_dp = False @@ -147,6 +149,162 @@ def test_select_cuda_flashinfer_trtllm_backend(mock_is_supported_trtllm, monkeyp assert selected_backend == UnquantizedMoeBackend.FLASHINFER_TRTLLM assert experts_cls is not None + assert experts_cls.__name__ == "TrtLlmBf16ExpertsMonolithic" + + +@patch( + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsMonolithic.is_supported_config", + return_value=(False, "monolithic unsupported"), +) +@patch( + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsModular.is_supported_config", + return_value=(True, None), +) +@pytest.mark.skipif( + not current_platform.is_cuda(), reason="Only supported on NVIDIA platforms." +) +def test_select_cuda_flashinfer_trtllm_modular_backend( + mock_is_supported_trtllm_modular, + mock_is_supported_trtllm_monolithic, +): + """Test CUDA backend selection falls back to FlashInfer TRTLLM modular.""" + with ( + patch.object(current_platform, "is_cuda", return_value=True), + patch.object(current_platform, "is_rocm", return_value=False), + patch.object(current_platform, "is_cpu", return_value=False), + patch.object(current_platform, "is_xpu", return_value=False), + patch.object(current_platform, "is_tpu", return_value=False), + patch.object(current_platform, "is_out_of_tree", return_value=False), + patch.object(current_platform, "has_device_capability", return_value=True), + ): + moe_config = make_dummy_moe_config() + moe_config.moe_backend = "flashinfer_trtllm" + moe_config.moe_parallel_config.use_ep = True + moe_config.moe_parallel_config.use_dp = False + + selected_backend, experts_cls = select_unquantized_moe_backend( + moe_config=moe_config + ) + + assert selected_backend == UnquantizedMoeBackend.FLASHINFER_TRTLLM + assert experts_cls is not None + assert experts_cls.__name__ == "TrtLlmBf16ExpertsModular" + + +@patch( + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsBase._supports_current_device", + return_value=True, +) +@pytest.mark.parametrize( + "all2all_backend", + [ + "mori_high_throughput", + "mori_low_latency", + "flashinfer_nvlink_two_sided", + "flashinfer_nvlink_one_sided", + ], +) +def test_select_cuda_flashinfer_trtllm_modular_for_standard_all2all( + mock_supports_current_device, + all2all_backend, +): + """Test non-AG/RS standard-format all2all backends select modular BF16.""" + with ( + patch.object(current_platform, "is_cuda", return_value=True), + patch.object(current_platform, "is_rocm", return_value=False), + patch.object(current_platform, "is_cpu", return_value=False), + patch.object(current_platform, "is_xpu", return_value=False), + patch.object(current_platform, "is_tpu", return_value=False), + patch.object(current_platform, "is_out_of_tree", return_value=False), + patch.object( + current_platform, "is_device_capability_family", return_value=False + ), + ): + moe_config = make_dummy_moe_config(num_experts=4, num_local_experts=2) + moe_config.moe_backend = "flashinfer_trtllm" + moe_config.moe_parallel_config.use_ep = True + moe_config.moe_parallel_config.dp_size = 2 + moe_config.moe_parallel_config.ep_size = 2 + moe_config.moe_parallel_config.all2all_backend = all2all_backend + + selected_backend, experts_cls = select_unquantized_moe_backend( + moe_config=moe_config + ) + + assert selected_backend == UnquantizedMoeBackend.FLASHINFER_TRTLLM + assert experts_cls is not None + assert experts_cls.__name__ == "TrtLlmBf16ExpertsModular" + + +@patch( + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsBase._supports_current_device", + return_value=True, +) +def test_select_cuda_deepep_ht_falls_back_from_trtllm( + mock_supports_current_device, +): + """Test DeepEP HT avoids the unsupported BF16 TRTLLM modular path.""" + with ( + patch.object(current_platform, "is_cuda", return_value=True), + patch.object(current_platform, "is_rocm", return_value=False), + patch.object(current_platform, "is_cpu", return_value=False), + patch.object(current_platform, "is_xpu", return_value=False), + patch.object(current_platform, "is_tpu", return_value=False), + patch.object(current_platform, "is_out_of_tree", return_value=False), + patch.object( + current_platform, "is_device_capability_family", return_value=False + ), + ): + moe_config = make_dummy_moe_config(num_experts=4, num_local_experts=2) + moe_config.moe_backend = "auto" + moe_config.moe_parallel_config.use_ep = True + moe_config.moe_parallel_config.dp_size = 2 + moe_config.moe_parallel_config.ep_size = 2 + moe_config.moe_parallel_config.all2all_backend = "deepep_high_throughput" + + selected_backend, experts_cls = select_unquantized_moe_backend( + moe_config=moe_config + ) + + assert selected_backend == UnquantizedMoeBackend.TRITON + assert experts_cls is not None + assert experts_cls.__name__ == "TritonExperts" + + +@patch( + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsBase._supports_current_device", + return_value=True, +) +def test_select_cuda_flashinfer_trtllm_ag_rs_uses_monolithic( + mock_supports_current_device, +): + """Test AG/RS stays on BF16 TRTLLM monolithic when TRTLLM is supported.""" + with ( + patch.object(current_platform, "is_cuda", return_value=True), + patch.object(current_platform, "is_rocm", return_value=False), + patch.object(current_platform, "is_cpu", return_value=False), + patch.object(current_platform, "is_xpu", return_value=False), + patch.object(current_platform, "is_tpu", return_value=False), + patch.object(current_platform, "is_out_of_tree", return_value=False), + patch.object( + current_platform, "is_device_capability_family", return_value=False + ), + ): + moe_config = make_dummy_moe_config(num_experts=4, num_local_experts=2) + moe_config.moe_backend = "flashinfer_trtllm" + moe_config.routing_method = RoutingMethodType.Renormalize + moe_config.moe_parallel_config.use_ep = True + moe_config.moe_parallel_config.dp_size = 2 + moe_config.moe_parallel_config.ep_size = 2 + moe_config.moe_parallel_config.all2all_backend = "allgather_reducescatter" + + selected_backend, experts_cls = select_unquantized_moe_backend( + moe_config=moe_config + ) + + assert selected_backend == UnquantizedMoeBackend.FLASHINFER_TRTLLM + assert experts_cls is not None + assert experts_cls.__name__ == "TrtLlmBf16ExpertsMonolithic" @patch( @@ -154,7 +312,11 @@ def test_select_cuda_flashinfer_trtllm_backend(mock_is_supported_trtllm, monkeyp return_value=True, ) @patch( - "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16Experts.is_supported_config", + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsMonolithic.is_supported_config", + return_value=(False, None), +) +@patch( + "vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe.TrtLlmBf16ExpertsModular.is_supported_config", return_value=(False, None), ) @patch( @@ -165,10 +327,10 @@ def test_select_cuda_flashinfer_trtllm_backend(mock_is_supported_trtllm, monkeyp not current_platform.is_cuda(), reason="Only supported on NVIDIA platforms." ) def test_select_cuda_flashinfer_cutlass_backend( - mock_has_flashinfer, - mock_is_supported_trtllm, mock_is_supported_cutlass, - monkeypatch, + mock_is_supported_trtllm_modular, + mock_is_supported_trtllm_monolithic, + mock_has_flashinfer, ): """Test CUDA backend selection when FlashInfer TRTLLM is not available and FlashInfer CUTLASS is available.""" @@ -181,10 +343,9 @@ def test_select_cuda_flashinfer_cutlass_backend( patch.object(current_platform, "is_out_of_tree", return_value=False), patch.object(current_platform, "has_device_capability", return_value=True), ): - # Enable FlashInfer via env var - monkeypatch.setenv("VLLM_USE_FLASHINFER_MOE_FP16", "1") - moe_config = make_dummy_moe_config() + # Select FlashInfer CUTLASS explicitly + moe_config.moe_backend = "flashinfer_cutlass" # CUTLASS requires EP and does not support DP moe_config.moe_parallel_config.use_ep = True moe_config.moe_parallel_config.use_dp = False @@ -241,37 +402,3 @@ def test_select_explicit_triton_backend(is_lora_enabled): assert selected_backend == UnquantizedMoeBackend.TRITON assert experts_cls is not None - - -@skipif_not_cuda_rocm -def test_select_explicit_triton_ignores_flashinfer_env(monkeypatch): - """Explicit triton backend should override FlashInfer env selection.""" - monkeypatch.setenv("VLLM_USE_FLASHINFER_MOE_FP16", "1") - monkeypatch.setenv("VLLM_FLASHINFER_MOE_BACKEND", "throughput") - - moe_config = make_dummy_moe_config() - moe_config.is_lora_enabled = False - moe_config.moe_backend = "triton" - - selected_backend, experts_cls = select_unquantized_moe_backend( - moe_config=moe_config - ) - - assert selected_backend == UnquantizedMoeBackend.TRITON - assert experts_cls is not None - - -@skipif_not_cuda_rocm -def test_select_lora_ignores_flashinfer_env(monkeypatch): - """LoRA path should still choose Triton even if FlashInfer env is on.""" - monkeypatch.setenv("VLLM_USE_FLASHINFER_MOE_FP16", "1") - monkeypatch.setenv("VLLM_FLASHINFER_MOE_BACKEND", "throughput") - - moe_config = make_dummy_moe_config() - moe_config.is_lora_enabled = True - selected_backend, experts_cls = select_unquantized_moe_backend( - moe_config=moe_config - ) - - assert selected_backend == UnquantizedMoeBackend.TRITON - assert experts_cls is not None diff --git a/tests/kernels/moe/test_zero_expert_moe.py b/tests/kernels/moe/test_zero_expert_moe.py index f10459aa5192..71e33b7dfacc 100644 --- a/tests/kernels/moe/test_zero_expert_moe.py +++ b/tests/kernels/moe/test_zero_expert_moe.py @@ -59,7 +59,7 @@ def zero_expert_moe(dist_init, default_vllm_config): scoring_func="softmax", ).cuda() - layer.quant_method.process_weights_after_loading(layer) + layer._quant_method.process_weights_after_loading(layer.routed_experts) yield layer, vllm_config @@ -73,12 +73,12 @@ def test_zero_expert_moe_router_is_zero_expert_router(zero_expert_moe, num_token ) -@pytest.mark.parametrize("num_tokens", [1, 32]) -def test_zero_expert_moe_no_custom_routing_fn(zero_expert_moe, num_tokens): - """Verify that custom_routing_function is not set (routing is handled - by ZeroExpertRouter, not a memoizing closure).""" - layer, _ = zero_expert_moe - assert layer.custom_routing_function is None +# @pytest.mark.parametrize("num_tokens", [1, 32]) +# def test_zero_expert_moe_no_custom_routing_fn(zero_expert_moe, num_tokens): +# """Verify that custom_routing_function is not set (routing is handled +# by ZeroExpertRouter, not a memoizing closure).""" +# layer, _ = zero_expert_moe +# #assert layer.custom_routing_function is None @pytest.mark.parametrize("num_tokens", [1, 32]) @@ -86,7 +86,7 @@ def test_zero_expert_moe_forward(zero_expert_moe, num_tokens): """Run a forward pass through FusedMoE with zero experts and verify output shape.""" layer, vllm_config = zero_expert_moe - hidden_size = layer.hidden_size + hidden_size = layer.routed_experts.hidden_size num_experts = 4 zero_expert_num = 1 total_experts = num_experts + zero_expert_num @@ -135,7 +135,10 @@ def test_zero_expert_moe_output_decomposition(zero_expert_moe, num_tokens): total_experts = num_experts + zero_expert_num hidden_states = torch.randn( - num_tokens, layer.hidden_size, dtype=torch.bfloat16, device="cuda" + num_tokens, + layer.routed_experts.hidden_size, + dtype=torch.bfloat16, + device="cuda", ) router_logits = torch.randn( num_tokens, total_experts, dtype=torch.float32, device="cuda" @@ -153,20 +156,26 @@ def test_zero_expert_moe_output_decomposition(zero_expert_moe, num_tokens): # experts. Use a separate prefix to avoid collision. plain_layer = FusedMoE( num_experts=num_experts, - top_k=layer.top_k, - hidden_size=layer.hidden_size, - intermediate_size=layer.intermediate_size_per_partition, + top_k=layer.routed_experts.top_k, + hidden_size=layer.routed_experts.hidden_size, + intermediate_size=layer.routed_experts.intermediate_size_per_partition, params_dtype=torch.bfloat16, prefix="test_zero_expert_moe_plain", renormalize=False, scoring_func="softmax", - e_score_correction_bias=layer.e_score_correction_bias, + e_score_correction_bias=layer.routed_experts.e_score_correction_bias, ).cuda() # Share weights from the zero expert layer. - plain_layer.w13_weight.data.copy_(layer.w13_weight.data) - plain_layer.w2_weight.data.copy_(layer.w2_weight.data) - plain_layer.quant_method.process_weights_after_loading(plain_layer) + plain_layer.routed_experts.w13_weight.data.copy_( + layer.routed_experts.w13_weight.data + ) + plain_layer.routed_experts.w2_weight.data.copy_( + layer.routed_experts.w2_weight.data + ) + plain_layer._quant_method.process_weights_after_loading( + plain_layer.routed_experts + ) # Compute routing via the ZeroExpertRouter. This produces masked # topk_weights/topk_ids (zero expert entries have weight=0, id=0) @@ -178,8 +187,8 @@ def test_zero_expert_moe_output_decomposition(zero_expert_moe, num_tokens): # Compute real expert output using the plain layer with the masked # routing from the ZeroExpertRouter. - real_output = plain_layer.quant_method.apply( - layer=plain_layer, + real_output = plain_layer._quant_method.apply( + layer=plain_layer.routed_experts, x=hidden_states, topk_weights=topk_weights, topk_ids=topk_ids, @@ -199,8 +208,8 @@ def test_zero_expert_moe_output_decomposition(zero_expert_moe, num_tokens): torch.testing.assert_close( full_output, expected, - atol=0, - rtol=0, + atol=4e-3, + rtol=4e-3, msg="FusedMoE output should equal plain FusedMoE output " "plus zero expert contribution", ) @@ -221,7 +230,10 @@ def test_zero_expert_moe_zero_expert_is_identity(zero_expert_moe, num_tokens): total_experts = num_experts + zero_expert_num hidden_states = torch.randn( - num_tokens, layer.hidden_size, dtype=torch.bfloat16, device="cuda" + num_tokens, + layer.routed_experts.hidden_size, + dtype=torch.bfloat16, + device="cuda", ) # Strongly bias toward the zero expert (index 4). router_logits = torch.full( @@ -246,7 +258,7 @@ def test_zero_expert_moe_zero_expert_is_identity(zero_expert_moe, num_tokens): hidden_states=hidden_states, gating_output=router_logits, e_score_correction_bias=layer.router.e_score_correction_bias.data, - topk=layer.top_k, + topk=layer.routed_experts.top_k, renormalize=layer.router.renormalize, scoring_func=layer.router.scoring_func, ) diff --git a/tests/kernels/moe/utils.py b/tests/kernels/moe/utils.py index 3503ce4cdeba..5fdcb8682f57 100644 --- a/tests/kernels/moe/utils.py +++ b/tests/kernels/moe/utils.py @@ -49,10 +49,13 @@ def shuffle_weight(w: torch.Tensor) -> torch.Tensor: def make_dummy_moe_config( num_experts: int = 1, + num_local_experts: int | None = None, experts_per_token: int = 1, hidden_dim: int = 1, - intermediate_size_per_partition: int = 1, + intermediate_size: int = 1, in_dtype: torch.dtype = torch.bfloat16, + max_num_tokens: int = 512, + activation: MoEActivation = MoEActivation.SILU, ) -> FusedMoEConfig: """ This is a dummy config for the mk constructor interface @@ -65,15 +68,17 @@ def make_dummy_moe_config( num_experts=num_experts, experts_per_token=experts_per_token, hidden_dim=hidden_dim, - intermediate_size_per_partition=intermediate_size_per_partition, - num_local_experts=num_experts, + intermediate_size=intermediate_size, + num_local_experts=num_local_experts + if num_local_experts is not None + else num_experts, num_logical_experts=num_experts, moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), - activation=MoEActivation.SILU, + activation=activation, in_dtype=in_dtype, device="cuda", routing_method=RoutingMethodType.TopK, - max_num_tokens=512, + max_num_tokens=max_num_tokens, ) @@ -649,3 +654,26 @@ def make_shared_experts( return make_shared_experts_with_weights( N, K, in_dtype, w1, w2, w1_s=w1_s, w2_s=w2_s, quant_dtype=quant_dtype ) + + +def check_accuracy(a, b, atol, rtol, percent): + """Allow a mismatch percentage of 1 - percent.""" + if torch.any(torch.isnan(a)): + raise Exception("NaN in reference output") + if torch.any(torch.isnan(b)): + raise Exception("NaN in actual output") + if torch.any(torch.isinf(a)): + raise Exception("Inf in reference output") + if torch.any(torch.isinf(b)): + raise Exception("Inf in actual output") + assert a.shape == b.shape, f"Shape mismatch: {a.shape} vs {b.shape}" + + left = torch.abs(a - b) + right = atol + rtol * torch.abs(b) + count = torch.sum(left > right) + mismatch_percent = count / a.numel() + if mismatch_percent > 1 - percent: + raise Exception( + f"Mismatch percentage is {mismatch_percent:.4f} for rtol {rtol} " + f"(threshold: {1 - percent:.4f})" + ) diff --git a/tests/kernels/quantization/test_awq.py b/tests/kernels/quantization/test_awq.py index 3bf59dea3097..a8977958023a 100644 --- a/tests/kernels/quantization/test_awq.py +++ b/tests/kernels/quantization/test_awq.py @@ -27,23 +27,3 @@ def test_awq_dequantize_opcheck(monkeypatch: pytest.MonkeyPatch): torch.ops._C.awq_dequantize, (qweight, scales, zeros, split_k_iters, thx, thy), ) - - -@pytest.mark.skip(reason="Not working; needs investigation.") -@pytest.mark.skipif( - not hasattr(torch.ops._C, "awq_gemm"), - reason="AWQ is not supported on this GPU type.", -) -def test_awq_gemm_opcheck(monkeypatch: pytest.MonkeyPatch): - with monkeypatch.context() as m: - m.setenv("VLLM_USE_TRITON_AWQ", "0") - input = torch.rand((2, 8192), device="cuda", dtype=torch.float16) - qweight = torch.randint( - -2000000000, 2000000000, (8192, 256), device="cuda", dtype=torch.int32 - ) - scales = torch.empty((64, 2048), device="cuda", dtype=torch.float16) - qzeros = torch.randint( - -2000000000, 2000000000, (64, 256), device="cuda", dtype=torch.int32 - ) - split_k_iters = 8 - opcheck(torch.ops._C.awq_gemm, (input, qweight, scales, qzeros, split_k_iters)) diff --git a/tests/kernels/quantization/test_awq_triton.py b/tests/kernels/quantization/test_awq_triton.py index 337bc177e6df..6572a7efd22a 100644 --- a/tests/kernels/quantization/test_awq_triton.py +++ b/tests/kernels/quantization/test_awq_triton.py @@ -13,9 +13,15 @@ awq_dequantize_triton, awq_gemm_triton, ) +from vllm.platforms import current_platform from vllm.utils.torch_utils import set_random_seed -device = "cuda" +pytestmark = pytest.mark.skipif( + not (current_platform.is_cuda_alike() or current_platform.is_xpu()), + reason="AWQ Triton kernels require CUDA/ROCm or XPU.", +) + +device = current_platform.device_type def reverse_awq_order(t: torch.Tensor): diff --git a/tests/kernels/quantization/test_block_fp8.py b/tests/kernels/quantization/test_block_fp8.py index 4cb638e47af0..c5eaa2f93218 100644 --- a/tests/kernels/quantization/test_block_fp8.py +++ b/tests/kernels/quantization/test_block_fp8.py @@ -11,6 +11,7 @@ native_per_token_group_quant_fp8, native_w8a8_block_matmul, ) +from tests.kernels.utils import fp8_ulp_distance from vllm.config import VllmConfig from vllm.model_executor.kernels.linear.scaled_mm.cutlass import cutlass_scaled_mm from vllm.model_executor.layers.quantization.utils.fp8_utils import ( @@ -93,7 +94,24 @@ def test_per_token_group_quant_fp8( tma_aligned_scales=tma_aligned_scales, ) - assert torch.allclose(out.to(torch.float32), ref_out.to(torch.float32), rtol=0.15) + if current_platform.is_rocm(): + # On gfx950 the Triton and PyTorch FP8 kernels can round in opposite + # directions when an element lands at the midpoint between two adjacent + # e4m3fn values (1-ULP tie-breaking). Verify: (1) no element is more + # than 1 FP8 ULP away, and (2) fewer than 0.05% of elements have any + # mismatch. Observed worst case across all parameter combos: 0.049%, + # max ULP = 1. + ulp = fp8_ulp_distance(out, ref_out) + assert (ulp <= 1).all(), ( + f"FP8 mismatch > 1 ULP: {int((ulp > 1).sum())} elements" + ) + assert float((ulp > 0).float().mean()) < 5e-4, ( + f"Too many 1-ULP mismatches: {int((ulp > 0).sum())}/{ulp.numel()}" + ) + else: + assert torch.allclose( + out.to(torch.float32), ref_out.to(torch.float32), rtol=0.15 + ) assert torch.allclose(scale, ref_scale) if column_major_scales: diff --git a/tests/kernels/quantization/test_cutlass_scaled_mm.py b/tests/kernels/quantization/test_cutlass_scaled_mm.py index a937c30fed74..25893311afca 100644 --- a/tests/kernels/quantization/test_cutlass_scaled_mm.py +++ b/tests/kernels/quantization/test_cutlass_scaled_mm.py @@ -245,8 +245,6 @@ def test_cutlass_fp8_blockwise_scale_gemm( return if m % a_scale_group_shape[0] != 0 or k % a_scale_group_shape[1] != 0: return - if m % 4 != 0 and current_platform.has_device_capability(100): - return cutlass_fp8_gemm_helper(m, n, k, a_scale_group_shape, b_scale_group_shape, use_bias) diff --git a/tests/kernels/quantization/test_flashinfer_nvfp4_scaled_mm.py b/tests/kernels/quantization/test_flashinfer_nvfp4_scaled_mm.py index 698c679a201c..a1e76d73a147 100644 --- a/tests/kernels/quantization/test_flashinfer_nvfp4_scaled_mm.py +++ b/tests/kernels/quantization/test_flashinfer_nvfp4_scaled_mm.py @@ -75,7 +75,7 @@ def get_ref_results( @pytest.mark.parametrize("shape", SHAPES) @pytest.mark.parametrize("seed", SEEDS) @pytest.mark.parametrize("device", CUDA_DEVICES) -@pytest.mark.parametrize("backend", ["cutlass", "cudnn", "trtllm", "b12x"]) +@pytest.mark.parametrize("backend", ["cute-dsl", "cutlass", "cudnn", "trtllm", "b12x"]) @pytest.mark.parametrize("autotune", [False, True]) @torch.inference_mode() def test_flashinfer_nvfp4_gemm( @@ -88,6 +88,8 @@ def test_flashinfer_nvfp4_gemm( ) -> None: if "trtllm" in backend and dtype == torch.float16: pytest.skip("Only torch.bfloat16 is supported for TRTLLM FP4 GEMM operations") + if backend == "cute-dsl" and not current_platform.is_device_capability_family(100): + pytest.skip("FlashInfer cutedsl backend is only supported on SM10x") if backend == "b12x" and not current_platform.has_device_capability(120): pytest.skip("b12x FP4 GEMM requires SM120+ (CC 12.0+)") if backend == "b12x" and not has_flashinfer_b12x_gemm(): diff --git a/tests/kernels/quantization/test_ggml.py b/tests/kernels/quantization/test_ggml.py deleted file mode 100644 index 0dc24187f2b3..000000000000 --- a/tests/kernels/quantization/test_ggml.py +++ /dev/null @@ -1,54 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import gguf -import pytest -import torch - -from tests.kernels.utils import opcheck -from vllm import _custom_ops as ops # noqa: F401 - - -@pytest.mark.parametrize("quant_type", [12]) -def test_ggml_opcheck(quant_type): - block_size, type_size = gguf.GGML_QUANT_SIZES[quant_type] - shape = [256, 1152] - qweight = torch.randint(0, 100, shape, device="cuda", dtype=torch.uint8) - m = qweight.shape[0] - n = qweight.shape[1] // type_size * block_size - opcheck(torch.ops._C.ggml_dequantize, (qweight, quant_type, m, n, torch.float16)) - - x = torch.rand((m, 512), device="cuda", dtype=torch.float16) - opcheck(torch.ops._C.ggml_mul_mat_a8, (qweight, x, quant_type, qweight.shape[0])) - opcheck( - torch.ops._C.ggml_mul_mat_vec_a8, (qweight, x, quant_type, qweight.shape[0]) - ) - - shape = [256, 1024, 336] - qweight = torch.randint(0, 100, shape, device="cuda", dtype=torch.uint8) - x = torch.rand((1, 1024), device="cuda", dtype=torch.float16) - sorted_token_ids = torch.arange(776, device="cuda") - expert_ids = torch.randint(0, 256, (194,), device="cuda") - num_tokens_post_padded = torch.tensor([1], dtype=torch.int64, device="cuda") - - opcheck( - torch.ops._C.ggml_moe_a8, - ( - x, - qweight, - sorted_token_ids, - expert_ids, - num_tokens_post_padded, - quant_type, - qweight.shape[0], - 1, - x.shape[0], - ), - ) - - topk_ids = torch.zeros((1, 1), device="cuda", dtype=torch.int32) - - opcheck( - torch.ops._C.ggml_moe_a8_vec, - (x, qweight, topk_ids, 1, quant_type, qweight.shape[0], x.shape[0]), - ) diff --git a/tests/kernels/quantization/test_gguf.py b/tests/kernels/quantization/test_gguf.py deleted file mode 100644 index 912d5fee4e59..000000000000 --- a/tests/kernels/quantization/test_gguf.py +++ /dev/null @@ -1,207 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from pathlib import Path - -import pytest -import torch -from gguf import GGMLQuantizationType, GGUFReader, ReaderTensor, dequantize -from huggingface_hub import snapshot_download - -import vllm._custom_ops as ops -from vllm.model_executor.layers.fused_moe import fused_experts -from vllm.model_executor.layers.quantization.gguf import _fused_moe_gguf -from vllm.utils.torch_utils import set_random_seed - -GGUF_SAMPLE = snapshot_download("Isotr0py/test-gguf-sample") -GGUF_SAMPLE_MOE = snapshot_download("SzymonOzog/test-gguf-moe-sample") - - -def get_gguf_sample_tensors( - hidden_size: int, quant_type: GGMLQuantizationType -) -> list[ReaderTensor]: - sample_dir = GGUF_SAMPLE - filename = f"Quant_{quant_type.name}_{hidden_size}.gguf" - sample_file = Path(sample_dir) / filename - return GGUFReader(sample_file).tensors - - -def get_gguf_MoE_tensors( - hidden_size: int, quant_type: GGMLQuantizationType -) -> list[ReaderTensor]: - sample_dir = GGUF_SAMPLE_MOE - filename = f"Quant_{quant_type.name}_{hidden_size}.gguf" - sample_file = Path(sample_dir) / filename - return GGUFReader(sample_file).tensors - - -DTYPES = [torch.bfloat16] # [torch.half, torch.bfloat16, torch.float32] -# Hidden_size for testing, must match the sample file in HF repo, -# we have `hidden_size = 256, 1024` for test in HF repo currently. -HIDDEN_SIZES = [256, 1024] -NUM_TOKENS = [7, 2050] # Arbitrary values for testing -SEEDS = [0] -QUANT_TYPES = [ - # i-matrix - GGMLQuantizationType.IQ1_M, - GGMLQuantizationType.IQ1_S, - GGMLQuantizationType.IQ2_S, - GGMLQuantizationType.IQ2_XS, - GGMLQuantizationType.IQ3_S, - GGMLQuantizationType.IQ3_XXS, - GGMLQuantizationType.IQ4_NL, - GGMLQuantizationType.IQ4_XS, - # k-quants - GGMLQuantizationType.Q2_K, - GGMLQuantizationType.Q3_K, - GGMLQuantizationType.Q4_K, - GGMLQuantizationType.Q5_K, - GGMLQuantizationType.Q6_K, - # standard quantization - GGMLQuantizationType.Q4_0, - GGMLQuantizationType.Q5_0, - GGMLQuantizationType.Q8_0, -] - - -@pytest.mark.parametrize("hidden_size", HIDDEN_SIZES) -@pytest.mark.parametrize("dtype", DTYPES) -@pytest.mark.parametrize("quant_type", QUANT_TYPES) -@torch.inference_mode() -def test_dequantize( - hidden_size: int, dtype: torch.dtype, quant_type: GGMLQuantizationType -): - tensors = get_gguf_sample_tensors(hidden_size, quant_type) - for tensor in tensors: - shape_str = tensor.name.split("_")[-1] - shape = map(int, shape_str.split("x")) - - ref_output = torch.tensor( - dequantize(tensor.data, quant_type), device="cuda" - ).to(dtype) - output = ops.ggml_dequantize( - torch.tensor(tensor.data, device="cuda"), quant_type, *list(shape), dtype - ) - - torch.testing.assert_close(output, ref_output, atol=1e-2, rtol=4e-2) - - -@pytest.mark.parametrize("hidden_size", HIDDEN_SIZES) -@pytest.mark.parametrize("dtype", DTYPES) -@pytest.mark.parametrize("quant_type", QUANT_TYPES) -@torch.inference_mode() -def test_mmvq(hidden_size: int, dtype: torch.dtype, quant_type: GGMLQuantizationType): - set_random_seed(0) - - tensors = get_gguf_sample_tensors(hidden_size, quant_type) - x = torch.rand((1, hidden_size), dtype=dtype, device="cuda") - for tensor in tensors: - weight = torch.tensor(dequantize(tensor.data, quant_type), device="cuda").to( - dtype - ) - ref_output = x @ weight.T - - qweight = torch.tensor(tensor.data, device="cuda") - output = ops.ggml_mul_mat_vec_a8(qweight, x, quant_type, qweight.shape[0]).to( - dtype - ) - - torch.testing.assert_close(output, ref_output, atol=1, rtol=1e-1) - - -@pytest.mark.parametrize("num_tokens", NUM_TOKENS) -@pytest.mark.parametrize("hidden_size", HIDDEN_SIZES) -@pytest.mark.parametrize("dtype", DTYPES) -@pytest.mark.parametrize( - "quant_type", - [ - # k-quants - GGMLQuantizationType.Q2_K, - GGMLQuantizationType.Q3_K, - GGMLQuantizationType.Q4_K, - GGMLQuantizationType.Q5_K, - GGMLQuantizationType.Q6_K, - # standard quants - GGMLQuantizationType.Q4_0, - GGMLQuantizationType.Q5_0, - GGMLQuantizationType.Q8_0, - ], -) -@torch.inference_mode() -def test_mmq( - num_tokens: int, - hidden_size: int, - dtype: torch.dtype, - quant_type: GGMLQuantizationType, -): - set_random_seed(0) - - tensors = get_gguf_sample_tensors(hidden_size, quant_type) - x = torch.rand((num_tokens, hidden_size), dtype=dtype, device="cuda") - for tensor in tensors: - weight = torch.tensor(dequantize(tensor.data, quant_type), device="cuda").to( - dtype - ) - ref_output = x @ weight.T - - qweight = torch.tensor(tensor.data, device="cuda") - output = ops.ggml_mul_mat_a8(qweight, x, quant_type, qweight.shape[0]) - atols = {torch.half: 1, torch.bfloat16: 1.5, torch.float: 1.2} - # test matrix has inputs centered around 0 and lower precision from - # bfloat16 tends to accumulate and can greatly inflate rtol - # since outputs are also very close to 0 - rtols = {torch.half: 1e-1, torch.bfloat16: 1e4, torch.float: 2e1} - torch.testing.assert_close( - output, ref_output, atol=atols[dtype], rtol=rtols[dtype] - ) - - -@pytest.mark.parametrize("num_tokens", NUM_TOKENS) -@pytest.mark.parametrize("hidden_size", [512]) -@pytest.mark.parametrize("top_k", [4, 8]) -@pytest.mark.parametrize("dtype", DTYPES) -@pytest.mark.parametrize("quant_type", QUANT_TYPES) -@torch.inference_mode() -def test_moe( - num_tokens: int, - hidden_size: int, - dtype: torch.dtype, - quant_type: GGMLQuantizationType, - top_k: int, -): - set_random_seed(0) - H, E = 1024, 256 - - x = torch.rand((num_tokens, H), dtype=dtype, device="cuda") - - topk_weights = torch.rand(num_tokens, top_k, device="cuda", dtype=dtype) - topk_ids = torch.randint( - 0, E, (num_tokens, top_k), device="cuda", dtype=torch.int32 - ) - - tensors = get_gguf_MoE_tensors(hidden_size, quant_type) - - w13 = tensors[0] - w2 = tensors[1] - - w13_dequant = torch.tensor(dequantize(w13.data, quant_type), device="cuda").to( - dtype - ) - - w2_dequant = torch.tensor(dequantize(w2.data, quant_type), device="cuda").to(dtype) - - output = _fused_moe_gguf( - x, - torch.tensor(w13.data, device="cuda"), - torch.tensor(w2.data, device="cuda"), - topk_weights, - topk_ids, - quant_type, - quant_type, - "silu", - ) - - ref_output = fused_experts( - x, w13_dequant, w2_dequant, topk_weights, topk_ids - ).reshape(output.shape) - torch.testing.assert_close(output, ref_output, atol=1, rtol=1e-1) diff --git a/tests/kernels/quantization/test_marlin_tile_padding.py b/tests/kernels/quantization/test_marlin_tile_padding.py new file mode 100644 index 000000000000..be987fda6daf --- /dev/null +++ b/tests/kernels/quantization/test_marlin_tile_padding.py @@ -0,0 +1,863 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for Marlin thread-tile padding of TP-sharded weight shapes. + +Run `pytest tests/kernels/quantization/test_marlin_tile_padding.py`. +""" + +from types import SimpleNamespace + +import pytest +import torch + +from vllm import _custom_ops as ops +from vllm.model_executor.layers.quantization.utils.marlin_utils import ( + GPTQ_MARLIN_TILE, + apply_gptq_marlin_linear, + marlin_make_empty_g_idx, + marlin_make_workspace_new, + marlin_moe_padded_intermediate, + marlin_pad_qweight, + marlin_pad_scales, + marlin_padded_nk, + marlin_permute_scales, + marlin_repacked_nk, + marlin_zero_points, +) +from vllm.model_executor.layers.quantization.utils.marlin_utils_fp4 import ( + apply_fp4_marlin_linear, + is_fp4_marlin_supported, + prepare_fp4_layer_for_marlin, +) +from vllm.model_executor.layers.quantization.utils.marlin_utils_fp8 import ( + apply_fp8_marlin_linear, + apply_mxfp8_marlin_linear, + is_fp8_marlin_supported, + prepare_fp8_layer_for_marlin, + prepare_mxfp8_layer_for_marlin, +) +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + gptq_pack, + gptq_quantize_weights, + quantize_weights, +) +from vllm.platforms import current_platform +from vllm.scalar_type import scalar_types + +# (size_n, size_k) rank-local shapes that violate Marlin tile alignment, +# e.g. produced by TP-sharding dims that are valid at TP=1. +ODD_SHAPES = [ + (200, 288), # N padded + (256, 208), # K padded + (200, 208), # both padded + (4640, 512), # Nemotron-Super-120B q_proj shard at TP=4 +] +ALIGNED_SHAPES = [(64, 128), (128, 64), (256, 256), (4608, 4096)] + + +def _is_tile_aligned(size_n: int, size_k: int) -> bool: + return (size_n % 64 == 0 and size_k % 128 == 0) or ( + size_n % 128 == 0 and size_k % 64 == 0 + ) + + +@pytest.mark.parametrize("shape", ODD_SHAPES + ALIGNED_SHAPES) +@pytest.mark.parametrize("group_size", [-1, 16, 32, 64, 128]) +def test_marlin_padded_nk(shape, group_size): + size_n, size_k = shape + padded_n, padded_k = marlin_padded_nk(size_n, size_k, group_size) + + assert padded_n >= size_n and padded_k >= size_k + assert _is_tile_aligned(padded_n, padded_k) + if group_size > 0: + assert padded_k % group_size == 0 + + # Aligned shapes must pass through unchanged (zero hot-path cost). + if _is_tile_aligned(size_n, size_k) and ( + group_size <= 0 or size_k % group_size == 0 + ): + assert (padded_n, padded_k) == (size_n, size_k) + + # Minimal: no valid shape with a smaller padded area exists. + area = padded_n * padded_k + for cand_n in range(size_n, padded_n + 1): + for cand_k in range(size_k, padded_k + 1): + if ( + _is_tile_aligned(cand_n, cand_k) + and (group_size <= 0 or cand_k % group_size == 0) + and cand_n * cand_k < area + ): + pytest.fail(f"({cand_n}, {cand_k}) beats ({padded_n}, {padded_k})") + + # Apply-time derivation from the repacked-tensor shape must round-trip. + for num_bits in (4, 8): + pack_factor = 32 // num_bits + repacked_shape = ( + padded_k // GPTQ_MARLIN_TILE, + padded_n * GPTQ_MARLIN_TILE // pack_factor, + ) + repacked = torch.empty(repacked_shape, device="meta") + assert marlin_repacked_nk(repacked, num_bits) == (padded_n, padded_k) + + +def test_marlin_pad_helpers_shapes(): + size_n, size_k, group_size = 200, 208, 16 + padded_n, padded_k = marlin_padded_nk(size_n, size_k, group_size) + + qweight = torch.zeros(size_k // 8, size_n, dtype=torch.int32) + padded = marlin_pad_qweight(qweight, size_n, size_k, padded_n, padded_k) + assert padded.shape == (padded_k // 8, padded_n) + + scales = torch.ones(size_k // group_size, size_n) + padded = marlin_pad_scales(scales, size_n, size_k, padded_n, padded_k, group_size) + assert padded.shape == (padded_k // group_size, padded_n) + assert padded[:, size_n:].abs().sum() == 0 + + channelwise = torch.ones(1, size_n) + padded = marlin_pad_scales(channelwise, size_n, size_k, padded_n, padded_k, -1) + assert padded.shape == (1, padded_n) + + +# Rank-local MoE intermediate sizes. group<=0 / 32 with a non-multiple-of-64 +# size is where tile padding triggers; 64/128 are already tile-aligned. +MOE_INTERMEDIATE_SIZES = [64, 96, 100, 176, 192, 256, 2816] + + +@pytest.mark.parametrize("intermediate", MOE_INTERMEDIATE_SIZES) +@pytest.mark.parametrize("group_size", [-1, 32, 64, 128]) +def test_marlin_moe_padded_intermediate(intermediate, group_size): + # The MoE gate only admits shapes where the group does not straddle the + # boundary, i.e. group divides the intermediate size. + if group_size > 0 and intermediate % group_size != 0: + pytest.skip("group straddles the boundary; rejected by the MoE gate") + + padded = marlin_moe_padded_intermediate(intermediate, group_size) + assert padded >= intermediate + # Valid MoE thread tile: gate-up n = 2*intermediate % 128, down k % 64. + assert (2 * padded) % 128 == 0 + assert padded % 64 == 0 + if group_size > 0: + assert padded % group_size == 0 + + # Minimal: no smaller valid intermediate exists. + for cand in range(intermediate, padded): + if ( + (2 * cand) % 128 == 0 + and cand % 64 == 0 + and (group_size <= 0 or cand % group_size == 0) + ): + pytest.fail(f"{cand} beats {padded}") + + # Already-tile-aligned sizes pass through unchanged (zero hot-path cost). + if intermediate % 64 == 0: + assert padded == intermediate + + +def test_marlin_moe_pad_helpers_shapes(): + from vllm.model_executor.layers.fused_moe.oracle.int_wna16 import ( + _pad_rows, + _pad_w13_bias, + _pad_w13_shard_cols, + ) + + E, rows, N, padded_N = 2, 8, 96, 128 + + # w13 stores the two gate/up shards along the last dim; padding each shard + # must preserve the loaded values and zero the padded columns. + w13 = torch.arange(E * rows * 2 * N).reshape(E, rows, 2 * N).float() + padded = _pad_w13_shard_cols(w13, N, padded_N) + assert padded.shape == (E, rows, 2 * padded_N) + shards = padded.view(E, rows, 2, padded_N) + orig = w13.view(E, rows, 2, N) + assert torch.equal(shards[..., :N], orig) + assert shards[..., N:].abs().sum() == 0 + + # w2 stores the intermediate dim in the rows. + w2 = torch.ones(E, N // 32, 16) + padded = _pad_rows(w2, padded_N // 32) + assert padded.shape == (E, padded_N // 32, 16) + assert padded[:, N // 32 :, :].abs().sum() == 0 + + bias = torch.arange(E * 2 * N).reshape(E, 2 * N).float() + padded = _pad_w13_bias(bias, N, padded_N) + assert padded.shape == (E, 2 * padded_N) + bias_shards = padded.view(E, 2, padded_N) + assert torch.equal(bias_shards[..., :N], bias.view(E, 2, N)) + assert bias_shards[..., N:].abs().sum() == 0 + + +def _gpu_marlin_unsupported() -> bool: + return not ( + current_platform.is_cuda() and current_platform.has_device_capability(80) + ) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported() or not is_fp8_marlin_supported(), + reason="FP8 Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("shape", ODD_SHAPES) +@pytest.mark.parametrize("use_bias", [False, True]) +def test_fp8_marlin_padded_round_trip(shape, use_bias): + size_n, size_k = shape + dtype = torch.float16 + layer = torch.nn.Module() + layer.output_size_per_partition = size_n + layer.input_size_per_partition = size_k + layer.orig_dtype = dtype + + weight = torch.randn(size_k, size_n, dtype=dtype, device="cuda") / size_k**0.5 + scale = weight.abs().max() / 448 + weight_fp8 = (weight / scale).to(torch.float8_e4m3fn) + layer.weight = torch.nn.Parameter(weight_fp8, requires_grad=False) + layer.weight_scale = torch.nn.Parameter( + scale.to(torch.float32), requires_grad=False + ) + bias = None + if use_bias: + bias = torch.randn(size_n, dtype=dtype, device="cuda") + layer.bias = torch.nn.Parameter(bias.clone(), requires_grad=False) + + prepare_fp8_layer_for_marlin(layer, size_k_first=True) + + x = torch.randn(8, size_k, dtype=dtype, device="cuda") + output = apply_fp8_marlin_linear( + input=x, + weight=layer.weight, + weight_scale=layer.weight_scale, + workspace=layer.workspace, + size_n=size_n, + size_k=size_k, + bias=layer.bias if use_bias else None, + ) + ref = x @ (weight_fp8.to(dtype) * scale.to(dtype)) + if use_bias: + ref = ref + bias + + assert output.shape == (8, size_n) + torch.testing.assert_close(output, ref, rtol=2e-2, atol=2e-2) + + +def _dequant_fp4(packed: torch.Tensor, dtype: torch.dtype) -> torch.Tensor: + """Dequantize packed e2m1 nibbles (N, K // 2) -> (N, K) in dtype.""" + lo = (packed & 0b10000000) | ((packed & 0b01110000) >> 2) + lo = lo.view(torch.float8_e4m3fn).to(dtype) * (2**6) + hi_bits = packed << 4 + hi = (hi_bits & 0b10000000) | ((hi_bits & 0b01110000) >> 2) + hi = hi.view(torch.float8_e4m3fn).to(dtype) * (2**6) + return torch.cat([hi.unsqueeze(2), lo.unsqueeze(2)], 2).view(packed.size(0), -1) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported() or not is_fp4_marlin_supported(), + reason="FP4 Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("shape", ODD_SHAPES) +def test_nvfp4_marlin_padded_round_trip(shape): + size_n, size_k = shape + group_size = 16 + dtype = torch.float16 + layer = torch.nn.Module() + layer.output_size_per_partition = size_n + layer.input_size_per_partition = size_k + layer.params_dtype = dtype + + packed = torch.randint( + 0, 256, (size_n, size_k // 2), dtype=torch.uint8, device="cuda" + ) + scales = (torch.rand(size_n, size_k // group_size, device="cuda") + 0.25).to( + torch.float8_e4m3fn + ) + global_scale = torch.tensor([0.002], dtype=torch.float32, device="cuda") + + ref_weight = ( + _dequant_fp4(packed, dtype) + * scales.to(dtype).repeat_interleave(group_size, 1) + * global_scale.to(dtype) + ) + + layer.weight = torch.nn.Parameter(packed, requires_grad=False) + layer.weight_scale = torch.nn.Parameter(scales, requires_grad=False) + layer.weight_global_scale = torch.nn.Parameter(global_scale, requires_grad=False) + + prepare_fp4_layer_for_marlin(layer) + + x = torch.randn(8, size_k, dtype=dtype, device="cuda") / size_k**0.5 + output = apply_fp4_marlin_linear( + input=x, + weight=layer.weight, + weight_scale=layer.weight_scale, + weight_global_scale=layer.weight_global_scale, + workspace=layer.workspace, + size_n=size_n, + size_k=size_k, + ) + ref = x @ ref_weight.T + + assert output.shape == (8, size_n) + torch.testing.assert_close(output, ref, rtol=2e-2, atol=2e-2) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported(), + reason="Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("shape", ODD_SHAPES) +@pytest.mark.parametrize("group_size", [-1, 128]) +def test_gptq_marlin_padded_round_trip(shape, group_size): + """Pad-then-repack a GPTQ int4 weight the way MarlinLinearKernel does and + check the GEMM against the dequantized reference. + + Symmetric int4's quantized zero decodes to -8, so this exercises the + zero-padded-scales cancellation, not just zero weights. + """ + size_n, size_k = shape + if group_size > 0 and size_k % group_size != 0: + pytest.skip("group must divide the rank-local K (not fixable by padding)") + dtype = torch.float16 + quant_type = scalar_types.uint4b8 + device = torch.device("cuda") + + weight = torch.randn(size_k, size_n, dtype=dtype, device=device) / size_k**0.5 + w_ref, q_w, s, _, _ = gptq_quantize_weights( + weight, quant_type, group_size, act_order=False + ) + qweight = gptq_pack(q_w, quant_type.size_bits, size_k, size_n) + + padded_n, padded_k = marlin_padded_nk(size_n, size_k, group_size) + qweight = marlin_pad_qweight(qweight, size_n, size_k, padded_n, padded_k) + marlin_qweight = ops.gptq_marlin_repack( + b_q_weight=qweight, + perm=torch.empty(0, dtype=torch.int, device=device), + size_k=padded_k, + size_n=padded_n, + num_bits=quant_type.size_bits, + ) + s = marlin_pad_scales(s, size_n, size_k, padded_n, padded_k, group_size) + marlin_s = marlin_permute_scales( + s, size_k=padded_k, size_n=padded_n, group_size=group_size + ) + + x = torch.randn(8, size_k, dtype=dtype, device=device) + output = apply_gptq_marlin_linear( + input=x, + weight=marlin_qweight, + weight_scale=marlin_s, + weight_zp=marlin_make_empty_g_idx(device), + g_idx=marlin_make_empty_g_idx(device), + g_idx_sort_indices=marlin_make_empty_g_idx(device), + workspace=marlin_make_workspace_new(device), + wtype=quant_type, + output_size_per_partition=size_n, + input_size_per_partition=size_k, + is_k_full=True, + ) + ref = x @ w_ref + + assert output.shape == (8, size_n) + torch.testing.assert_close(output, ref, rtol=2e-2, atol=2e-2) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported() or not is_fp8_marlin_supported(), + reason="FP8 Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("shape", [(200, 512), (4640, 512)]) +def test_fp8_block_marlin_padded_round_trip(shape): + """Block-quantized FP8 (e.g. Nemotron NVFP4 checkpoints' FP8 layers): + group_size=128 exercises the lcm K-alignment in marlin_padded_nk and the + weight_scale_inv group-wise scale padding.""" + size_n, size_k = shape + block = 128 + dtype = torch.float16 + layer = torch.nn.Module() + layer.output_size_per_partition = size_n + layer.input_size_per_partition = size_k + layer.orig_dtype = dtype + layer.weight_block_size = [block, block] + + weight = torch.randn(size_n, size_k, dtype=dtype, device="cuda") / size_k**0.5 + n_blocks, k_blocks = (size_n + block - 1) // block, size_k // block + padded = torch.zeros(n_blocks * block, size_k, dtype=dtype, device="cuda") + padded[:size_n] = weight + scales = padded.view(n_blocks, block, k_blocks, block).abs().amax(dim=(1, 3)) / 448 + scales_expanded = scales.repeat_interleave(block, 0)[:size_n].repeat_interleave( + block, 1 + ) + weight_fp8 = (weight / scales_expanded).to(torch.float8_e4m3fn) + + layer.weight = torch.nn.Parameter(weight_fp8, requires_grad=False) + layer.weight_scale_inv = torch.nn.Parameter( + scales.to(torch.float32), requires_grad=False + ) + + prepare_fp8_layer_for_marlin(layer, size_k_first=False) + + x = torch.randn(8, size_k, dtype=dtype, device="cuda") + output = apply_fp8_marlin_linear( + input=x, + weight=layer.weight, + weight_scale=layer.weight_scale_inv, + workspace=layer.workspace, + size_n=size_n, + size_k=size_k, + bias=None, + ) + ref = x @ (weight_fp8.to(dtype) * scales_expanded.to(dtype)).T + + assert output.shape == (8, size_n) + torch.testing.assert_close(output, ref, rtol=2e-2, atol=2e-2) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported() or not is_fp8_marlin_supported(), + reason="FP8 Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("shape", [(200, 288), (4640, 512)]) +def test_mxfp8_marlin_padded_round_trip(shape): + """MXFP8 exercises the e8m0 scale path, where padded 0.0 scales clamp to + 2^-127 instead of zero and must still contribute nothing.""" + size_n, size_k = shape + group_size = 32 + # The e8m0-scale Marlin kernels are only instantiated for bf16 activations. + dtype = torch.bfloat16 + layer = torch.nn.Module() + layer.output_size_per_partition = size_n + layer.input_size_per_partition = size_k + + weight_fp8 = (torch.randn(size_n, size_k, dtype=dtype, device="cuda") / 4).to( + torch.float8_e4m3fn + ) + # e8m0 exponents around 1.0 (127): scales in [2^-6, 2^0] + scales = torch.randint( + 121, 128, (size_n, size_k // group_size), dtype=torch.uint8, device="cuda" + ) + ref_weight = weight_fp8.to(dtype) * ( + 2.0 ** (scales.to(dtype) - 127) + ).repeat_interleave(group_size, 1) + + layer.weight = torch.nn.Parameter(weight_fp8, requires_grad=False) + layer.weight_scale = torch.nn.Parameter(scales, requires_grad=False) + + prepare_mxfp8_layer_for_marlin(layer) + + x = torch.randn(8, size_k, dtype=dtype, device="cuda") / size_k**0.5 + output = apply_mxfp8_marlin_linear( + input=x, + weight=layer.weight, + weight_scale=layer.weight_scale, + workspace=layer.workspace, + size_n=size_n, + size_k=size_k, + ) + ref = x @ ref_weight.T + + assert output.shape == (8, size_n) + torch.testing.assert_close(output, ref, rtol=2e-2, atol=2e-2) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported(), + reason="Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("shape", [(200, 512), (4640, 512)]) +def test_awq_zp_marlin_padded_round_trip(shape): + """AWQ-style uint4 with runtime zero-points, padded the way + MarlinLinearKernel does: padded columns rely on (q=0 - zp=0) * scale=0.""" + size_n, size_k = shape + group_size = 128 + dtype = torch.float16 + quant_type = scalar_types.uint4 + device = torch.device("cuda") + + weight = torch.randn(size_k, size_n, dtype=dtype, device=device) / size_k**0.5 + w_ref, q_w, s, zp = quantize_weights( + weight, quant_type, group_size, zero_points=True + ) + qweight = gptq_pack(q_w, quant_type.size_bits, size_k, size_n) + + padded_n, padded_k = marlin_padded_nk(size_n, size_k, group_size) + qweight = marlin_pad_qweight(qweight, size_n, size_k, padded_n, padded_k) + marlin_qweight = ops.gptq_marlin_repack( + b_q_weight=qweight, + perm=torch.empty(0, dtype=torch.int, device=device), + size_k=padded_k, + size_n=padded_n, + num_bits=quant_type.size_bits, + ) + s = marlin_pad_scales(s, size_n, size_k, padded_n, padded_k, group_size) + marlin_s = marlin_permute_scales( + s, size_k=padded_k, size_n=padded_n, group_size=group_size + ) + zp = marlin_pad_scales(zp, size_n, size_k, padded_n, padded_k, group_size) + marlin_zp = marlin_zero_points( + zp, + size_k=padded_k // group_size, + size_n=padded_n, + num_bits=quant_type.size_bits, + ) + + x = torch.randn(8, size_k, dtype=dtype, device=device) + output = apply_gptq_marlin_linear( + input=x, + weight=marlin_qweight, + weight_scale=marlin_s, + weight_zp=marlin_zp, + g_idx=marlin_make_empty_g_idx(device), + g_idx_sort_indices=marlin_make_empty_g_idx(device), + workspace=marlin_make_workspace_new(device), + wtype=quant_type, + output_size_per_partition=size_n, + input_size_per_partition=size_k, + is_k_full=True, + ) + ref = x @ w_ref + + assert output.shape == (8, size_n) + torch.testing.assert_close(output, ref, rtol=2e-2, atol=2e-2) + + +class _FakeLinear: + def __init__(self, size_n, size_k, input_size=None): + self.output_size_per_partition = size_n + self.input_size_per_partition = size_k + self.output_size = size_n + self.input_size = input_size if input_size is not None else size_k + + +def test_check_marlin_supports_layer_allow_tile_padding(): + from vllm.model_executor.layers.quantization.utils.marlin_utils import ( + check_marlin_supports_layer, + ) + + # Tile-misaligned but group-aligned: rejected strictly, allowed w/ padding + layer = _FakeLinear(4640, 512, input_size=2048) + assert not check_marlin_supports_layer(layer, 128) + assert check_marlin_supports_layer(layer, 128, allow_tile_padding=True) + assert check_marlin_supports_layer(layer, -1, allow_tile_padding=True) + + # A group straddling the TP shard cannot be fixed by padding + layer = _FakeLinear(4608, 4672, input_size=18688) + assert not check_marlin_supports_layer(layer, 128, allow_tile_padding=True) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported(), + reason="Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("group_size", [-1, 32]) +@pytest.mark.parametrize("shape", [(96, 256, 8), (160, 512, 4)]) +def test_gptq_marlin_moe_padded_round_trip(shape, group_size): + """Pad a tile-misaligned MoE intermediate the way the WNA16 Marlin MoE prep + does, run the real repack + fused_marlin_moe, and check against the + dequantized reference. Symmetric int4's quantized zero decodes to -8, so the + padded region only stays out of the output via the zero-padded scales. + """ + from tests.kernels.utils import torch_experts + from vllm.config import VllmConfig, set_current_vllm_config + from vllm.model_executor.layers.fused_moe import fused_topk + from vllm.model_executor.layers.fused_moe.experts.marlin_moe import ( + fused_marlin_moe, + ) + from vllm.model_executor.layers.fused_moe.oracle.int_wna16 import ( + _pad_rows, + _pad_w13_shard_cols, + ) + from vllm.model_executor.layers.quantization.utils.marlin_utils import ( + marlin_moe_padded_intermediate, + marlin_moe_permute_scales, + ) + + n, k, e = shape + topk, m = 2, 33 + padded_n = marlin_moe_padded_intermediate(n, group_size) + assert padded_n != n, "test should exercise padding" + + dtype = torch.float16 + device = torch.device("cuda") + quant_type = scalar_types.uint4b8 + bits = quant_type.size_bits + pack = 32 // bits + + a = torch.randn((m, k), device=device, dtype=dtype) / 10 + w1 = torch.randn((e, 2 * n, k), device=device, dtype=dtype) / k**0.5 + w2 = torch.randn((e, k, n), device=device, dtype=dtype) / n**0.5 + + def quant(w, size_k, size_n): + # w is (size_n, size_k); gptq expects (size_k, size_n). + ref, q_w, s, _, _ = gptq_quantize_weights( + w.T, quant_type, group_size, act_order=False + ) + return ref, gptq_pack(q_w, bits, size_k, size_n), s + + w13_qw, w13_s, w13_ref = [], [], [] + w2_qw, w2_s, w2_ref = [], [], [] + for i in range(e): + ref, qw, s = quant(w1[i], k, 2 * n) + w13_ref.append(ref.T) # (2n, k) + w13_qw.append(qw) + w13_s.append(s) + ref, qw, s = quant(w2[i], n, k) + w2_ref.append(ref.T) # (k, n) + w2_qw.append(qw) + w2_s.append(s) + + w13_qweight = torch.stack(w13_qw) + w2_qweight = torch.stack(w2_qw) + w13_scales = torch.stack(w13_s) + w2_scales = torch.stack(w2_s) + w1_ref = torch.stack(w13_ref) # (e, 2n, k) + w2_ref = torch.stack(w2_ref) # (e, k, n) + + # Pad the intermediate via the production helpers. + w13_qweight = _pad_w13_shard_cols(w13_qweight, n, padded_n) + w2_qweight = _pad_rows(w2_qweight, padded_n // pack) + w13_scales = _pad_w13_shard_cols(w13_scales, n, padded_n) + if group_size > 0: + w2_scales = _pad_rows(w2_scales, padded_n // group_size) + + sort_idx = torch.empty((e, 0), dtype=torch.int32, device=device) + marlin_w13 = ops.gptq_marlin_moe_repack( + w13_qweight, sort_idx, w13_qweight.shape[1] * pack, w13_qweight.shape[2], bits + ) + marlin_w2 = ops.gptq_marlin_moe_repack( + w2_qweight, sort_idx, w2_qweight.shape[1] * pack, w2_qweight.shape[2], bits + ) + group_or_pack = group_size if group_size != -1 else pack + marlin_w13_s = marlin_moe_permute_scales( + s=w13_scales, size_k=n, size_n=w13_scales.shape[2], group_size=group_size + ) + marlin_w2_s = marlin_moe_permute_scales( + s=w2_scales, + size_k=w2_scales.shape[1] * group_or_pack, + size_n=w2_scales.shape[2], + group_size=group_size, + ) + + score = torch.randn((m, e), device=device, dtype=dtype) + topk_weights, topk_ids, _ = fused_topk(a, score, topk, False) + + marlin_out = fused_marlin_moe( + a, + marlin_w13, + marlin_w2, + None, + None, + marlin_w13_s, + marlin_w2_s, + topk_weights, + topk_ids, + quant_type_id=quant_type.id, + global_num_experts=e, + is_k_full=True, + ) + with set_current_vllm_config(VllmConfig()): + ref = torch_experts( + a, + w1_ref, + w2_ref, + topk_weight=topk_weights, + topk_ids=topk_ids, + global_num_experts=e, + ) + + torch.testing.assert_close(marlin_out, ref, atol=5e-2, rtol=0) + + +@pytest.mark.skipif( + current_platform.is_rocm(), + reason="MoE Marlin is not selected on ROCm.", +) +def test_check_moe_marlin_supports_layer_padding(): + from vllm.model_executor.layers.quantization.utils.marlin_utils import ( + check_moe_marlin_supports_layer, + ) + + def make_layer(hidden, intermediate): + layer = SimpleNamespace() + layer.hidden_size = hidden + layer.apply_router_weight_on_input = False + layer.moe_config = SimpleNamespace( + intermediate_size_per_partition_unpadded=intermediate + ) + return layer + + # group=32 with intermediate % 64 != 0: rejected strictly, accepted w/ padding + layer = make_layer(4096, 96) + assert not check_moe_marlin_supports_layer(layer, 32) + assert check_moe_marlin_supports_layer(layer, 32, allow_tile_padding=True) + # channelwise misaligned intermediate is paddable + assert check_moe_marlin_supports_layer(layer, -1, allow_tile_padding=True) + + # A group straddling the boundary cannot be fixed by padding + layer = make_layer(4096, 176) + assert not check_moe_marlin_supports_layer(layer, 128, allow_tile_padding=True) + + # hidden_size is the MoE I/O extent and is never padded + layer = make_layer(4090, 128) + assert not check_moe_marlin_supports_layer(layer, 64, allow_tile_padding=True) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported() or not is_fp8_marlin_supported(), + reason="FP8 Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("quant", ["channel", "tensor"]) +@pytest.mark.parametrize("shape", [(96, 256, 8), (160, 512, 4)]) +def test_fp8_marlin_moe_padded_round_trip(shape, quant): + """FP8 weight-only MoE: pad a tile-misaligned intermediate and check the + real prepare + fused_marlin_moe against the dequantized reference.""" + from tests.kernels.utils import torch_experts + from vllm.config import VllmConfig, set_current_vllm_config + from vllm.model_executor.layers.fused_moe import fused_topk + from vllm.model_executor.layers.fused_moe.experts.marlin_moe import ( + fused_marlin_moe, + ) + from vllm.model_executor.layers.quantization.utils.marlin_utils import ( + marlin_moe_intermediate_size, + marlin_moe_padded_intermediate, + ) + from vllm.model_executor.layers.quantization.utils.marlin_utils_fp8 import ( + prepare_fp8_moe_layer_for_marlin, + ) + + n, k, e = shape + topk, m = 2, 33 + fp8 = torch.float8_e4m3fn + dtype = torch.bfloat16 + device = torch.device("cuda") + padded_n = marlin_moe_padded_intermediate(n, -1) + assert padded_n != n + + def q(w): # (out, in) -> fp8 weight, scale, dequant reference + dim = None if quant == "tensor" else 1 + s = (w.abs().amax(dim, keepdim=dim is not None) / 448.0).clamp(min=1e-8) + wq = (w / s).clamp(-448, 448).to(fp8) + ref = wq.to(dtype) * s.to(dtype) + s = s.reshape(1) if quant == "tensor" else s.squeeze(1) + return wq, s, ref + + a = torch.randn((m, k), device=device, dtype=dtype) / 10 + w1 = torch.randn((e, 2 * n, k), device=device, dtype=dtype) / k**0.5 + w2 = torch.randn((e, k, n), device=device, dtype=dtype) / n**0.5 + w13_q, w13_s, w1_ref = zip(*(q(w1[i]) for i in range(e))) + w2_q, w2_s, w2_ref = zip(*(q(w2[i]) for i in range(e))) + + w13_weight, w2_weight = torch.stack(w13_q), torch.stack(w2_q) + layer = SimpleNamespace( + num_experts=e, + hidden_size=k, + intermediate_size_per_partition=n, + orig_dtype=dtype, + w13_weight=w13_weight, + ) + pw13, pw2, ps13, ps2 = prepare_fp8_moe_layer_for_marlin( + layer, w13_weight, w2_weight, torch.stack(w13_s), torch.stack(w2_s) + ) + assert marlin_moe_intermediate_size(pw13, pw2) == padded_n + + score = torch.randn((m, e), device=device, dtype=dtype) + topk_weights, topk_ids, _ = fused_topk(a, score, topk, False) + out = fused_marlin_moe( + a, + pw13, + pw2, + None, + None, + ps13, + ps2, + topk_weights, + topk_ids, + quant_type_id=scalar_types.float8_e4m3fn.id, + global_num_experts=e, + is_k_full=True, + workspace=layer.workspace, + ) + with set_current_vllm_config(VllmConfig()): + ref = torch_experts( + a, + torch.stack(w1_ref), + torch.stack(w2_ref), + topk_weight=topk_weights, + topk_ids=topk_ids, + global_num_experts=e, + ) + torch.testing.assert_close(out, ref, atol=8e-2, rtol=0) + + +@pytest.mark.skipif( + _gpu_marlin_unsupported() or not is_fp8_marlin_supported(), + reason="FP8 Marlin is not supported on this GPU type.", +) +@pytest.mark.parametrize("shape", [(96, 256, 8), (160, 512, 4)]) +def test_mxfp8_marlin_moe_padded_round_trip(shape): + """MXFP8 weight-only MoE round-trip at a tile-misaligned intermediate, with + unit e8m0 scales so the reference is the exact fp8 dequant.""" + from tests.kernels.utils import torch_experts + from vllm.config import VllmConfig, set_current_vllm_config + from vllm.model_executor.layers.fused_moe import fused_topk + from vllm.model_executor.layers.fused_moe.experts.marlin_moe import ( + fused_marlin_moe, + ) + from vllm.model_executor.layers.quantization.utils.marlin_utils import ( + marlin_moe_intermediate_size, + marlin_moe_padded_intermediate, + ) + from vllm.model_executor.layers.quantization.utils.marlin_utils_fp8 import ( + prepare_mxfp8_moe_layer_for_marlin, + ) + + n, k, e = shape + topk, m, gs, e8m0_one = 2, 33, 32, 127 + fp8 = torch.float8_e4m3fn + dtype = torch.bfloat16 + device = torch.device("cuda") + padded_n = marlin_moe_padded_intermediate(n, gs) + assert padded_n != n + + a = torch.randn((m, k), device=device, dtype=dtype) / 10 + w13_weight = torch.randn((e, 2 * n, k), device=device, dtype=dtype) / k**0.5 + w2_weight = torch.randn((e, k, n), device=device, dtype=dtype) / n**0.5 + w13_weight = w13_weight.clamp(-448, 448).to(fp8) + w2_weight = w2_weight.clamp(-448, 448).to(fp8) + w13_scale = torch.full( + (e, 2 * n, k // gs), e8m0_one, dtype=torch.uint8, device=device + ) + w2_scale = torch.full((e, k, n // gs), e8m0_one, dtype=torch.uint8, device=device) + + layer = SimpleNamespace( + num_experts=e, hidden_size=k, intermediate_size_per_partition=n + ) + with set_current_vllm_config(VllmConfig()): + pw13, pw2, ps13, ps2 = prepare_mxfp8_moe_layer_for_marlin( + layer, w13_weight, w2_weight, w13_scale, w2_scale + ) + assert marlin_moe_intermediate_size(pw13, pw2) == padded_n + + score = torch.randn((m, e), device=device, dtype=dtype) + topk_weights, topk_ids, _ = fused_topk(a, score, topk, False) + out = fused_marlin_moe( + a, + pw13, + pw2, + None, + None, + ps13, + ps2, + topk_weights, + topk_ids, + quant_type_id=scalar_types.float8_e4m3fn.id, + global_num_experts=e, + is_k_full=True, + workspace=layer.workspace, + ) + with set_current_vllm_config(VllmConfig()): + ref = torch_experts( + a, + w13_weight.to(dtype), + w2_weight.to(dtype), + topk_weight=topk_weights, + topk_ids=topk_ids, + global_num_experts=e, + ) + torch.testing.assert_close(out, ref, atol=8e-2, rtol=0) diff --git a/tests/kernels/quantization/test_nvfp4_emulation.py b/tests/kernels/quantization/test_nvfp4_emulation.py index 71072d9e9fff..d2056fe01ebb 100644 --- a/tests/kernels/quantization/test_nvfp4_emulation.py +++ b/tests/kernels/quantization/test_nvfp4_emulation.py @@ -1,10 +1,25 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from typing import cast + import huggingface_hub import pytest import torch from safetensors import safe_open +import vllm.model_executor.layers.fused_moe.modular_kernel as mk +from vllm.model_executor.layers.fused_moe.activation import MoEActivation +from vllm.model_executor.layers.fused_moe.config import ( + FusedMoEConfig, + FusedMoEParallelConfig, + FusedMoEQuantConfig, + RoutingMethodType, + nvfp4_moe_quant_config, +) +from vllm.model_executor.layers.fused_moe.experts.nvfp4_emulation_moe import ( + Nvfp4QuantizationEmulationTritonExperts, +) +from vllm.model_executor.layers.fused_moe.experts.triton_moe import TritonExperts from vllm.model_executor.layers.quantization.utils import ( nvfp4_emulation_utils, ) @@ -12,25 +27,201 @@ dequantize_to_dtype, ref_nvfp4_quant_dequant, ) +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + QuantKey, + kNvfp4Dynamic, + kNvfp4Static, +) from vllm.platforms import current_platform from vllm.triton_utils import triton +if current_platform.is_rocm(): + from vllm.platforms.rocm import on_gfx950 +else: + + def on_gfx950() -> bool: + return False + + +MOE_MODEL_CONFIGS = { + "nvidia/Qwen3-30B-A3B-NVFP4": { + "shards": ["model-00001-of-00004.safetensors"], + "expert_prefix": "model.layers.9.mlp.experts.", + # Position of the expert index in the dot-split key. + "expert_idx_pos": 5, + } +} + + +@pytest.fixture(scope="module") +def loaded_model_files(): + return { + model_id: huggingface_hub.snapshot_download( + repo_id=model_id, allow_patterns=config["shards"] + ) + for model_id, config in MOE_MODEL_CONFIGS.items() + } + + +class Nvfp4QuantizationEmulationTritonExpertsReference(TritonExperts): + """ + Extension of TritonExperts to support emulated NVFP4 MoE experts. + + It may be used for NVFP4 models when the device does not have + native support for this dtype. + """ + + def __init__( + self, + moe_config: FusedMoEConfig, + quant_config: FusedMoEQuantConfig, + ): + super().__init__(moe_config, quant_config) + + # `TritonExperts.apply` expects pre-dequantized weights, + # which we handle in `apply` below. + self.w1_scale_val = self.quant_config.w1_scale + self.w2_scale_val = self.quant_config.w2_scale + + self.quant_config._w1.scale = None + self.quant_config._w2.scale = None + + self.quantization_emulation = True + + @property + def quant_dtype(self) -> torch.dtype | str | None: + return "nvfp4" + + @property + def a1_scale(self) -> torch.Tensor | None: + return self.quant_config.a1_gscale + + @property + def expects_unquantized_inputs(self) -> bool: + return True + + @staticmethod + def _supports_quant_scheme( + weight_key: QuantKey | None, + activation_key: QuantKey | None, + ) -> bool: + return (weight_key, activation_key) == (kNvfp4Static, kNvfp4Dynamic) + + def apply( + self, + output: torch.Tensor, + hidden_states: torch.Tensor, + w1: torch.Tensor, + w2: torch.Tensor, + topk_weights: torch.Tensor, + topk_ids: torch.Tensor, + activation: MoEActivation, + global_num_experts: int, + expert_map: torch.Tensor | None, + a1q_scale: torch.Tensor | None, + a2_scale: torch.Tensor | None, + workspace13: torch.Tensor, + workspace2: torch.Tensor, + expert_tokens_meta: mk.ExpertTokensMetadata | None, + apply_router_weight_on_input: bool, + ): + assert w1.dtype == torch.uint8 + assert w2.dtype == torch.uint8 + + # Dequantize w1 from packed NVFP4 to fp16/bf16 + w13_global_scale = self.quant_config.g1_alphas + + w1_dequant = dequantize_to_dtype( + tensor_fp4=w1, + tensor_sf=self.w1_scale_val, + global_scale=w13_global_scale, + dtype=hidden_states.dtype, + block_size=16, + swizzle=False, + ) + + # Dequantize w2 from packed NVFP4 to fp16/bf16 + w2_global_scale = self.quant_config.g2_alphas + + w2_dequant = dequantize_to_dtype( + tensor_fp4=w2, + tensor_sf=self.w2_scale_val, + global_scale=w2_global_scale, + dtype=hidden_states.dtype, + block_size=16, + swizzle=False, + ) + + super().apply( + output=output, + hidden_states=hidden_states, + w1=w1_dequant, + w2=w2_dequant, + topk_weights=topk_weights, + topk_ids=topk_ids, + activation=activation, + global_num_experts=global_num_experts, + expert_map=expert_map, + a1q_scale=None, + a2_scale=self.quant_config.a2_gscale, + workspace13=workspace13, + workspace2=workspace2, + expert_tokens_meta=expert_tokens_meta, + apply_router_weight_on_input=apply_router_weight_on_input, + ) + + +@pytest.mark.parametrize( + ("config_kwargs", "expected_reason"), + [ + ({"has_bias": True}, "kernel does not support bias"), + ({"is_lora_enabled": True}, "kernel does not support LoRA"), + ], +) +def test_nvfp4_emulation_support_check_rejects_bias_and_lora( + config_kwargs: dict[str, bool], + expected_reason: str, +) -> None: + moe_config = FusedMoEConfig( + num_experts=2, + experts_per_token=1, + hidden_dim=16, + intermediate_size=16, + num_local_experts=2, + num_logical_experts=2, + moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), + activation=MoEActivation.SILU, + in_dtype=torch.bfloat16, + device="cuda", + routing_method=RoutingMethodType.TopK, + **config_kwargs, + ) + + supported, reason = Nvfp4QuantizationEmulationTritonExperts.is_supported_config( + Nvfp4QuantizationEmulationTritonExperts, + moe_config, + kNvfp4Static, + kNvfp4Dynamic, + mk.FusedMoEActivationFormat.Standard, + ) + + assert not supported + assert reason == expected_reason + @pytest.mark.skipif( not current_platform.is_cuda_alike(), reason="Triton NVFP4 kernel requires CUDA.", ) -def test_triton_dequantize_nvfp4(monkeypatch) -> None: +def test_triton_dequantize_nvfp4(monkeypatch, loaded_model_files) -> None: """Test the Triton dequantization kernel against the CPU reference using real NVFP4 weights from a checkpoint. Tests both 2D (attention projection) and 3D (stacked MoE experts). """ - checkpoint_path = huggingface_hub.snapshot_download( - "nvidia/Qwen3-30B-A3B-NVFP4", - allow_patterns=["model-00001-of-00004.safetensors"], - ) - shard_path = f"{checkpoint_path}/model-00001-of-00004.safetensors" + checkpoint_path = loaded_model_files["nvidia/Qwen3-30B-A3B-NVFP4"] + shards = cast(list[str], MOE_MODEL_CONFIGS["nvidia/Qwen3-30B-A3B-NVFP4"]["shards"]) + shard_path = f"{checkpoint_path}/{shards[0]}" block_size = 16 with safe_open(shard_path, framework="pt", device="cpu") as f: @@ -306,3 +497,276 @@ def _reference_bench( f"min={ref_min:.3f}ms, max={ref_max:.3f}ms" ) print(f" speedup: {speedup:.2f}x") + + +def _load_nvfp4_moe_weights( + model_files: dict[str, str], + model_id: str, + tensor_parallel_size: int, + max_experts: int | None = None, +): + """Load and stack NVFP4 MoE weights from checkpoint shards. + + Returns (w1, w1_scale, w1_gscale, w2, w2_scale, w2_gscale, + a1_gscale, a2_gscale, num_experts, hidden_dim, + intermediate_size). + + When max_experts is set, only the first max_experts experts are loaded. + + When tensor_parallel_size > 1, the N dimension of w1 and the K + dimension of w2 are narrowed to the first TP shard (simulating + column-parallel on w1 / row-parallel on w2). + """ + cfg = MOE_MODEL_CONFIGS[model_id] + shards = cast(list[str], cfg["shards"]) + checkpoint_path = model_files[model_id] + + expert_prefix = cfg["expert_prefix"] + idx_pos = cast(int, cfg["expert_idx_pos"]) + + # Collect all tensors across shards into a flat dict — an expert's + # tensors may be split across multiple shard files. + all_tensors: dict[str, torch.Tensor] = {} + for shard_name in shards: + shard_path = f"{checkpoint_path}/{shard_name}" + with safe_open(shard_path, framework="pt", device="cpu") as f: + for key in f.keys(): # noqa: SIM118 + if key.startswith(expert_prefix): + all_tensors[key] = f.get_tensor(key) + + expert_indices = sorted( + { + int(key.split(".")[idx_pos]) + for key in all_tensors + if key.endswith(".gate_proj.weight") + } + ) + if max_experts is not None: + expert_indices = expert_indices[:max_experts] + num_experts = len(expert_indices) + + gate_weights, up_weights, down_weights = [], [], [] + gate_scales, up_scales, down_scales = [], [], [] + gate_gscales, up_gscales, down_gscales = [], [], [] + a1_scales, a2_scales = [], [] + + for idx in expert_indices: + prefix = f"{expert_prefix}{idx}" + gate_weights.append(all_tensors[f"{prefix}.gate_proj.weight"]) + gate_scales.append(all_tensors[f"{prefix}.gate_proj.weight_scale"]) + gate_gscales.append(all_tensors[f"{prefix}.gate_proj.weight_scale_2"]) + up_weights.append(all_tensors[f"{prefix}.up_proj.weight"]) + up_scales.append(all_tensors[f"{prefix}.up_proj.weight_scale"]) + up_gscales.append(all_tensors[f"{prefix}.up_proj.weight_scale_2"]) + down_weights.append(all_tensors[f"{prefix}.down_proj.weight"]) + down_scales.append(all_tensors[f"{prefix}.down_proj.weight_scale"]) + down_gscales.append(all_tensors[f"{prefix}.down_proj.weight_scale_2"]) + a1_scales.append(all_tensors[f"{prefix}.gate_proj.input_scale"]) + a2_scales.append(all_tensors[f"{prefix}.down_proj.input_scale"]) + + # Stack into MoE format. + # w1 = [E, 2*intermediate, hidden//2] (gate + up concatenated) + w1 = torch.stack( + [torch.cat([g, u], dim=0) for g, u in zip(gate_weights, up_weights)] + ).cuda() + w1_scale = torch.stack( + [torch.cat([g, u], dim=0) for g, u in zip(gate_scales, up_scales)] + ).cuda() + w1_gscale = torch.stack(gate_gscales).cuda() + + # w2 = [E, hidden, intermediate//2] + w2 = torch.stack(down_weights).cuda() + w2_scale = torch.stack(down_scales).cuda() + w2_gscale = torch.stack(down_gscales).cuda() + + a13_scale_raw = torch.stack(a1_scales).cuda() + a2_scale_raw = torch.stack(a2_scales).cuda() + + # Apply EMULATION transforms (matches oracle/nvfp4.py). + nvfp4_emulation_utils.kE2M1ToFloat_handle.val = ( + nvfp4_emulation_utils.kE2M1ToFloat_handle.val.cuda() + ) + a1_gscale = 1.0 / a13_scale_raw.max().to(torch.float32) + a2_gscale = 1.0 / a2_scale_raw.max().to(torch.float32) + + # ── Simulate TP sharding ── + # w1 (gate_up): column-parallel → shard the N dimension (dim 1). + # w2 (down): row-parallel → shard the K dimension (dim 2, + # which is the packed K//2 dim). + # Scales follow the same sharding on the corresponding dimension. + tp = tensor_parallel_size + if tp > 1: + n1 = w1.size(1) // tp + w1 = w1[:, :n1, :].contiguous() + w1_scale = w1_scale[:, :n1, :].contiguous() + + k2_packed = w2.size(2) // tp + k2_scale = w2_scale.size(2) // tp + w2 = w2[:, :, :k2_packed].contiguous() + w2_scale = w2_scale[:, :, :k2_scale].contiguous() + + hidden_dim = w1.size(2) * 2 + intermediate_size = w1.size(1) // 2 + + return ( + w1, + w1_scale, + w1_gscale, + w2, + w2_scale, + w2_gscale, + a1_gscale, + a2_gscale, + num_experts, + hidden_dim, + intermediate_size, + ) + + +@pytest.mark.skipif( + not current_platform.is_cuda_alike(), + reason="Triton NVFP4 kernel requires CUDA.", +) +@pytest.mark.parametrize("num_tokens", [1, 2, 4, 1024]) +@pytest.mark.parametrize("top_k", [4]) +@pytest.mark.parametrize("model_id", list(MOE_MODEL_CONFIGS.keys())) +@pytest.mark.parametrize( + "tensor_parallel_size", + [pytest.param(val, id=f"tensor_parallel_size:{val}") for val in [1, 2, 4, 8]], +) +def test_nvfp4_moe_correctness( + loaded_model_files, + num_tokens: int, + top_k: int, + model_id: str, + tensor_parallel_size: int, +) -> None: + """Compare Nvfp4QuantizationEmulationTritonExperts (fused weight dequant + compute) + against the unfused reference Nvfp4QuantizationEmulationTritonExpertsReference. + + Both must produce bit-identical results. + """ + num_test_experts = max(8, top_k) + ( + w1, + w1_scale, + w1_gscale, + w2, + w2_scale, + w2_gscale, + a1_gscale, + a2_gscale, + num_experts, + hidden_dim, + intermediate_size, + ) = _load_nvfp4_moe_weights( + loaded_model_files, + model_id, + tensor_parallel_size, + max_experts=num_test_experts, + ) + + moe_config = FusedMoEConfig( + num_experts=num_experts, + experts_per_token=top_k, + hidden_dim=hidden_dim, + intermediate_size=intermediate_size, + num_local_experts=num_experts, + num_logical_experts=num_experts, + moe_parallel_config=FusedMoEParallelConfig.make_no_parallel(), + activation=MoEActivation.SILU, + in_dtype=torch.bfloat16, + device="cuda", + routing_method=RoutingMethodType.TopK, + max_num_tokens=512, + ) + + def _make_quant_config(): + return nvfp4_moe_quant_config( + g1_alphas=w1_gscale.clone(), + g2_alphas=w2_gscale.clone(), + a1_gscale=a1_gscale.clone(), + a2_gscale=a2_gscale.clone(), + w1_scale=w1_scale.clone(), + w2_scale=w2_scale.clone(), + ) + + ref_experts = Nvfp4QuantizationEmulationTritonExpertsReference( + moe_config=moe_config, + quant_config=_make_quant_config(), + ) + fused_experts = Nvfp4QuantizationEmulationTritonExperts( + moe_config=moe_config, + quant_config=_make_quant_config(), + ) + + torch.manual_seed(42) + hidden_states = torch.randn( + num_tokens, hidden_dim, dtype=torch.bfloat16, device="cuda" + ) + + topk_weights = torch.randn( + num_tokens, top_k, dtype=torch.float32, device="cuda" + ).softmax(dim=-1) + topk_ids = torch.stack( + [torch.randperm(num_experts, device="cuda")[:top_k] for _ in range(num_tokens)] + ).to(torch.int32) + + N = w1.size(1) # 2 * intermediate + K = hidden_dim + + ws13_size = num_tokens * top_k * max(intermediate_size, K) + ws2_size = num_tokens * top_k * max(N, K) + + workspace13_ref = torch.zeros(ws13_size, dtype=torch.bfloat16, device="cuda") + workspace2_ref = torch.zeros(ws2_size, dtype=torch.bfloat16, device="cuda") + output_ref = torch.zeros(num_tokens, K, dtype=torch.bfloat16, device="cuda") + + workspace13_fused = torch.zeros_like(workspace13_ref) + workspace2_fused = torch.zeros_like(workspace2_ref) + output_fused = torch.zeros_like(output_ref) + + apply_kwargs = dict( + hidden_states=hidden_states, + w1=w1, + w2=w2, + topk_weights=topk_weights, + topk_ids=topk_ids, + activation=MoEActivation.SILU, + global_num_experts=num_experts, + expert_map=None, + a1q_scale=None, + a2_scale=None, + expert_tokens_meta=None, + apply_router_weight_on_input=False, + ) + + # Unfused reference. + ref_experts.apply( + output=output_ref, + workspace13=workspace13_ref, + workspace2=workspace2_ref, + **apply_kwargs, + ) + + # Fused implementation. + fused_experts.apply( + output=output_fused, + workspace13=workspace13_fused, + workspace2=workspace2_fused, + **apply_kwargs, + ) + + # Not strict equality on H100, MI325, MI300 (< 0.1% elements). + # The fused on-the-fly dequant path can lower to a slightly + # different Triton/MMA tiling than the pre-dequantized + # reference; experiments with reference-like tiling/masking + # reduced some diffs were not kept because they regress + # the fused kernel speed. + # Strict equality validated on MI355. + torch.testing.assert_close( + output_fused, + output_ref, + atol=0.0 if on_gfx950() else 0.02, + rtol=0, + ) diff --git a/tests/kernels/quantization/test_per_token_group_quant.py b/tests/kernels/quantization/test_per_token_group_quant.py index d957cefed4df..0d9b6c0c3e8f 100644 --- a/tests/kernels/quantization/test_per_token_group_quant.py +++ b/tests/kernels/quantization/test_per_token_group_quant.py @@ -345,6 +345,63 @@ def test_per_token_group_quant_fp8_packed_zero_fills_padded_output_q( ) +@pytest.mark.skipif( + not current_platform.is_cuda_alike(), + reason="packed FP8 per-token-group quant kernel requires a CUDA-alike GPU", +) +def test_per_token_group_quant_fp8_packed_large_mn(): + """Regression test for https://github.com/vllm-project/vllm/issues/45099. + + Some background: gridDim.x and gridDim.y have different limits of 2^31 - 1 and + 2^16 - 1, respectively. + Prior code introduced a bug where it incorrectly assumed grid.x and y both have + 2^31 - 1 limits and mixed them up, which doesn't surface until the kernel is + launched with a large mn that exceeds grid.y limit (2^16 - 1). + + This issue doesn't surface often because each forward pass only processes a + bounded token batch, not the full context. + Quantizing tensors with more rows than that will fail at launch with + "CUDA error: invalid argument". + This is a differential test that compares fp8 output against Triton output + reference when token size sits just above the gridDim.y 2^16 - 1 limit. + """ + + device = "cuda" + group_size = 128 + # hidden 2048 -> 2048/128 = 16 groups per row -> kx=16, ry=1: one grid row per mn + # row, so any mn > 65535 overflowed grid.y before the fix. + num_tokens, hidden_dim = 65537, 2048 + torch.manual_seed(42) + x = torch.randn((num_tokens, hidden_dim), device=device, dtype=torch.bfloat16) * 8 + + out_q, out_s_packed = fp8_utils.per_token_group_quant_fp8_packed_for_deepgemm( + x, + group_size=group_size, + use_ue8m0=True, + ) + + with patch("vllm.platforms.current_platform.is_cuda_alike", return_value=False): + ref_q, ref_s = fp8_utils.per_token_group_quant_fp8( + x, group_size, use_ue8m0=True + ) + + assert torch.equal(out_q, ref_q), "Quantized output mismatch" + + # Vectorized packed-scale check; the per-element loop used by the smaller + # tests is too slow at this size. groups_per_row is a multiple of 4 here, + # so there is no K padding and the packed view lines up. + mn = num_tokens + groups_per_row = hidden_dim // group_size + k_num_packed = (groups_per_row + 3) // 4 + assert groups_per_row % 4 == 0 + ref_exponents = (ref_s.reshape(mn, groups_per_row).view(torch.int32) >> 23) & 0xFF + exp = ref_exponents.view(mn, k_num_packed, 4) + expected = ( + exp[..., 0] | (exp[..., 1] << 8) | (exp[..., 2] << 16) | (exp[..., 3] << 24) + ) + assert torch.equal(out_s_packed.cpu(), expected.cpu()), "Packed scale mismatch" + + @pytest.mark.parametrize("shape", [(32, 128), (64, 256), (16, 512)]) @pytest.mark.parametrize("group_size", [64, 128]) @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available") diff --git a/tests/kernels/quantization/test_quantized_embedding.py b/tests/kernels/quantization/test_quantized_embedding.py new file mode 100644 index 000000000000..0e4af0a0c1a6 --- /dev/null +++ b/tests/kernels/quantization/test_quantized_embedding.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the Triton dequant-gather kernel used by +``CompressedTensorsEmbeddingWNA16Int`` (quantized embedding lookup).""" + +import pytest +import torch +from compressed_tensors.compressors.pack_quantized.helpers import unpack_from_int32 + +from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_embedding import ( # noqa: E501 + _dequant_gather_triton, +) +from vllm.platforms import current_platform + + +def _dequant_gather_torch( + ids: torch.Tensor, + weight_packed: torch.Tensor, + weight_scale: torch.Tensor, + hidden: int, + num_bits: int, +) -> torch.Tensor: + """Reference: gather packed rows by id, unpack int32-packed INT, dequant.""" + n = ids.shape[0] + int8 = unpack_from_int32(weight_packed[ids], num_bits, torch.Size([n, hidden])) + scale_rows = weight_scale[ids] + w = int8.to(scale_rows.dtype) + if scale_rows.shape[1] == 1: + return w * scale_rows + ng = scale_rows.shape[1] + return (w.view(n, ng, hidden // ng) * scale_rows.unsqueeze(-1)).view(n, hidden) + + +@pytest.mark.skipif( + not current_platform.is_cuda(), reason="Triton dequant kernel requires CUDA" +) +@pytest.mark.parametrize("num_bits", [2, 4, 8]) +@pytest.mark.parametrize("group_size", [0, 256]) # 0 -> channel +@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16]) +@pytest.mark.parametrize("num_ids", [1, 17, 4096]) +def test_dequant_gather(num_bits, group_size, dtype, num_ids): + torch.manual_seed(0) + device = "cuda" + vocab, hidden = 1000, 2048 + pack_factor = 32 // num_bits + + # Random full-range int32 packed weights (covers the sign bit -> exercises the + # arithmetic-shift + mask unpack path). + weight_packed = torch.randint( + -(2**31), + 2**31, + (vocab, hidden // pack_factor), + dtype=torch.int32, + device=device, + ) + + num_groups = 1 if group_size == 0 else hidden // group_size + weight_scale = torch.rand(vocab, num_groups, dtype=dtype, device=device) + 0.01 + + ids = torch.randint(0, vocab, (num_ids,), dtype=torch.long, device=device) + + out = _dequant_gather_triton(ids, weight_packed, weight_scale, hidden, num_bits) + ref = _dequant_gather_torch(ids, weight_packed, weight_scale, hidden, num_bits) + + assert out.shape == (num_ids, hidden) + assert out.dtype == dtype + torch.testing.assert_close(out, ref) diff --git a/tests/kernels/quantization/test_rdna3_compile_guards.py b/tests/kernels/quantization/test_rdna3_compile_guards.py new file mode 100644 index 000000000000..c307bfc3aed8 --- /dev/null +++ b/tests/kernels/quantization/test_rdna3_compile_guards.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Compile-guard tests for the ROCm RDNA3 W4A16 kernels (dense + MoE). + +Verifies that the gfx1100 compilation and dispatch guards are hermetic: + - On gfx1100: all ops exist, dispatch selects RDNA3 kernels. + - On CDNA (gfx942/gfx950) or other non-gfx1100: ops must NOT exist, + dispatch must fall through to Triton/Marlin, and no RDNA3 code + path is reachable. + +The negative (non-gfx1100) tests verify at three layers: + 1. Compile-level: on non-gfx1100 hardware, the RDNA3 ops are absent + from the compiled _rocm_C extension — real binary verification. + 2. Static source analysis: parses CMakeLists.txt and torch_bindings.cpp + to verify that all RDNA3 .cu files and op registrations are inside + gfx1100-only guards. + 3. Runtime mock: patches on_gfx1100() to False and verifies that the + Python dispatch chain rejects the RDNA3 path. + +Run `pytest tests/kernels/quantization/test_rdna3_compile_guards.py`. +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest +import regex as re +import torch + +import vllm +from vllm.platforms import current_platform + +if not current_platform.is_rocm(): + pytest.skip("RDNA3 compile-guard tests are ROCm-only", allow_module_level=True) + +from vllm.platforms.rocm import on_gfx1100 # noqa: E402 + +gfx1100_only = pytest.mark.skipif( + not on_gfx1100(), + reason="Requires gfx1100 hardware", +) + +not_gfx1100 = pytest.mark.skipif( + on_gfx1100(), + reason="This test verifies non-gfx1100 builds — skip on gfx1100", +) + +RDNA3_OPS = ["gptq_gemm_rdna3", "gptq_gemm_rdna3_wmma", "moe_gptq_gemm_rdna3"] +RDNA3_CU_FILES = [ + "q_gemm_rdna3.cu", + "q_gemm_rdna3_wmma.cu", + "moe_q_gemm_rdna3.cu", +] + + +def _find_repo_root() -> Path | None: + """Walk up from this file to find the repo root (has CMakeLists.txt).""" + for parent in [Path(__file__).resolve(), *Path(__file__).resolve().parents]: + if (parent / "CMakeLists.txt").exists() and (parent / "csrc").is_dir(): + return parent + return None + + +REPO_ROOT = _find_repo_root() + +# Directory of the *installed* vllm python package. The .py guard checks read +# from here so they verify the code that is actually imported at runtime — this +# works even on CI images that ship the wheel instead of the python source tree +# (where only csrc/ + CMakeLists.txt are checked out for building). +VLLM_PKG_DIR: Path | None = ( + Path(vllm.__file__).parent if getattr(vllm, "__file__", None) else None +) + +needs_source = pytest.mark.skipif( + REPO_ROOT is None, + reason="C/CMake source tree not available (installed package only)", +) + + +def _read_source_or_skip(*relparts: str) -> str: + """Read a C/CMake source file from the repo tree, or skip if absent. + + Used for csrc/ and CMakeLists.txt — these only exist in a source checkout, + not in the installed wheel. + """ + assert REPO_ROOT is not None # callers are gated by @needs_source + path = REPO_ROOT.joinpath(*relparts) + if not path.exists(): + pytest.skip(f"{path} not present in this source tree") + return path.read_text() + + +def _read_pkg_source_or_skip(*relparts: str) -> str: + """Read a python source file from the installed vllm package. + + Reflects the code actually loaded at runtime, so these guard checks run in + CI against the wheel — no source checkout required. Only skips for an + exotic install layout (namespace/zipimport) where __file__ is unavailable. + """ + if VLLM_PKG_DIR is None: + pytest.skip("vllm package directory not resolvable (zip/namespace?)") + assert VLLM_PKG_DIR is not None # narrow for mypy (skip above is NoReturn) + path = VLLM_PKG_DIR.joinpath(*relparts) + if not path.exists(): + pytest.skip(f"{path} not present in installed vllm package") + return path.read_text() + + +# ============================================================================ +# Part A: POSITIVE — on gfx1100, ops exist and dispatch works +# ============================================================================ + + +@gfx1100_only +@pytest.mark.parametrize("op_name", RDNA3_OPS) +def test_op_registered_on_gfx1100(op_name): + """On gfx1100, all RDNA3 ops must be registered in _rocm_C.""" + assert hasattr(torch.ops, "_rocm_C"), "_rocm_C module not loaded" + assert hasattr(torch.ops._rocm_C, op_name), ( + f"_rocm_C.{op_name} not registered — " + "check CMakeLists.txt VLLM_ROCM_HAS_GFX1100 " + "and torch_bindings.cpp #ifdef VLLM_ROCM_GFX1100" + ) + + +@gfx1100_only +def test_all_ops_present_or_all_absent(): + """The 3 RDNA3 ops are behind the same #ifdef — all present or all absent. + + Catches someone accidentally moving an op outside the guard. + """ + has_rocm_c = hasattr(torch.ops, "_rocm_C") + if not has_rocm_c: + pytest.skip("_rocm_C not loaded") + + present = {op: hasattr(torch.ops._rocm_C, op) for op in RDNA3_OPS} + values = set(present.values()) + assert len(values) == 1, ( + f"Guard inconsistency — some RDNA3 ops registered, others not: " + f"{present}. Check torch_bindings.cpp #ifdef VLLM_ROCM_GFX1100 block." + ) + + +# ============================================================================ +# Part B: NEGATIVE — compile-level verification on non-gfx1100 +# ============================================================================ + + +@not_gfx1100 +@pytest.mark.parametrize("op_name", RDNA3_OPS) +def test_op_absent_on_non_gfx1100(op_name): + """On non-gfx1100 (CDNA), RDNA3 ops must NOT exist in _rocm_C. + + This is the real compile-level check: the binary was built without + gfx1100 support, so the ops should not have been compiled or registered. + """ + if not hasattr(torch.ops, "_rocm_C"): + return + assert not hasattr(torch.ops._rocm_C, op_name), ( + f"_rocm_C.{op_name} is registered on non-gfx1100 hardware — " + "compile guard is broken: check CMakeLists.txt " + "VLLM_ROCM_HAS_GFX1100 and torch_bindings.cpp #ifdef" + ) + + +@not_gfx1100 +def test_rocm_moe_not_supported_on_non_gfx1100(): + """rocm_moe_rdna.is_supported() must return False on non-gfx1100 hardware.""" + from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe import ( # noqa: E501 + rocm_moe_rdna, + ) + + wq = type("WQ", (), {"num_bits": 4})() + assert rocm_moe_rdna.is_supported(wq) is False, ( + "rocm_moe_rdna.is_supported() returned True on non-gfx1100 — " + "dispatch guard is broken" + ) + + +@not_gfx1100 +def test_dense_kernel_rejects_on_non_gfx1100(): + """RDNA3W4A16LinearKernel.can_implement must reject on non-gfx1100.""" + from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import ( # noqa: E501 + MPLinearLayerConfig, + ) + from vllm.model_executor.kernels.linear.mixed_precision.rdna3_w4a16 import ( # noqa: E501 + RDNA3W4A16LinearKernel, + ) + from vllm.scalar_type import scalar_types + + config = MPLinearLayerConfig( + full_weight_shape=(1024, 256), + partition_weight_shape=(1024, 256), + weight_type=scalar_types.uint4b8, + act_type=torch.float16, + group_size=128, + zero_points=False, + has_g_idx=False, + ) + ok, reason = RDNA3W4A16LinearKernel.can_implement(config) + assert ok is False, f"RDNA3 dense kernel accepted on non-gfx1100: {reason}" + + +# ============================================================================ +# Part C: Static source analysis (build-level guards) +# ============================================================================ + + +@needs_source +class TestCMakeGuards: + """Verify CMakeLists.txt only compiles RDNA3 .cu files for gfx1100.""" + + @staticmethod + def _read_cmake(): + return _read_source_or_skip("CMakeLists.txt") + + def test_rdna3_cu_files_inside_gfx1100_conditional(self): + """All RDNA3 .cu files must be listed inside the + ``if(VLLM_GPU_ARCHES MATCHES "gfx1100")`` block, not unconditionally. + """ + cmake = self._read_cmake() + for cu_file in RDNA3_CU_FILES: + assert cu_file in cmake, f"{cu_file} not found in CMakeLists.txt" + + lines = cmake.splitlines() + in_gfx1100_block = False + for line in lines: + if 'VLLM_GPU_ARCHES MATCHES "gfx1100"' in line: + in_gfx1100_block = True + if in_gfx1100_block and "endif()" in line: + in_gfx1100_block = False + if cu_file in line: + assert in_gfx1100_block, ( + f"{cu_file} is listed OUTSIDE the gfx1100 " + f"conditional in CMakeLists.txt — CDNA builds " + f"would compile RDNA3 code. Line: {line.strip()}" + ) + + def test_compile_definition_only_for_gfx1100(self): + """VLLM_ROCM_GFX1100 compile definition must be conditional.""" + cmake = self._read_cmake() + lines = cmake.splitlines() + in_gfx1100_block = False + for line in lines: + if "VLLM_ROCM_HAS_GFX1100)" in line: + in_gfx1100_block = True + if in_gfx1100_block and "endif()" in line: + in_gfx1100_block = False + if "VLLM_ROCM_GFX1100" in line and "target_compile_definitions" in line: + assert in_gfx1100_block, ( + "VLLM_ROCM_GFX1100 compile definition is set outside " + "the VLLM_ROCM_HAS_GFX1100 conditional — CDNA builds " + f"would define it. Line: {line.strip()}" + ) + + +@needs_source +class TestTorchBindingsGuards: + """Verify torch_bindings.cpp gates all RDNA3 ops behind #ifdef.""" + + @staticmethod + def _read_bindings(): + return _read_source_or_skip("csrc", "rocm", "torch_bindings.cpp") + + def test_all_rdna3_ops_inside_ifdef(self): + """Every rdna3 op def/impl must be between #ifdef VLLM_ROCM_GFX1100 + and #endif. If any is outside, a CDNA build would try to register + the op and link a symbol that doesn't exist. + """ + src = self._read_bindings() + lines = src.splitlines() + + inside_guard = False + rdna3_lines_outside = [] + + for i, line in enumerate(lines, 1): + if "#ifdef VLLM_ROCM_GFX1100" in line: + inside_guard = True + elif line.strip() == "#endif" and inside_guard: + inside_guard = False + + if ( + "rdna3" in line.lower() + and not line.strip().startswith("//") + and not inside_guard + ): + rdna3_lines_outside.append((i, line.strip())) + + assert not rdna3_lines_outside, ( + "RDNA3 op references found OUTSIDE #ifdef VLLM_ROCM_GFX1100 " + "in torch_bindings.cpp — these would break CDNA builds:\n" + + "\n".join(f" L{n}: {s}" for n, s in rdna3_lines_outside) + ) + + def test_no_unconditional_rdna3_includes(self): + """No #include of RDNA3-specific headers outside the guard.""" + src = self._read_bindings() + lines = src.splitlines() + + inside_guard = False + for i, line in enumerate(lines, 1): + if "#ifdef VLLM_ROCM_GFX1100" in line: + inside_guard = True + elif line.strip() == "#endif" and inside_guard: + inside_guard = False + + if "#include" in line and "rdna3" in line.lower(): + assert inside_guard, ( + f"L{i}: RDNA3 include outside gfx1100 guard: {line.strip()}" + ) + + +class TestCustomOpsGuards: + """Verify _custom_ops.py gates register_fake behind hasattr checks.""" + + @staticmethod + def _read_custom_ops(): + return _read_pkg_source_or_skip("_custom_ops.py") + + def test_register_fake_guarded_by_hasattr(self): + """Every register_fake for an RDNA3 op must be preceded by a hasattr + check — otherwise it would crash on import on CDNA where the ops + don't exist. + """ + src = self._read_custom_ops() + for op in RDNA3_OPS: + pattern = rf'register_fake\(\s*"_rocm_C::{op}"\s*\)' + match = re.search(pattern, src) + if match is None: + continue + preceding = src[: match.start()] + last_hasattr = preceding.rfind(f'hasattr(torch.ops._rocm_C, "{op}")') + assert last_hasattr != -1, ( + f'register_fake("_rocm_C::{op}") is not preceded by a ' + f"hasattr check — would crash on CDNA import" + ) + gap = preceding[last_hasattr:].count("\n") + assert gap <= 5, ( + f"hasattr guard for {op} is {gap} lines before " + f"register_fake — suspiciously far; verify it's the " + f"actual guard and not a coincidence" + ) + + def test_no_toplevel_rocm_c_import(self): + """No top-level ``from vllm._rocm_C import`` — would crash on CDNA.""" + src = self._read_custom_ops() + for line in src.splitlines(): + stripped = line.strip() + if stripped.startswith("#") or stripped.startswith("//"): + continue + assert "from vllm._rocm_C import" not in stripped, ( + f"Top-level import of _rocm_C in _custom_ops.py would " + f"crash on CDNA: {stripped}" + ) + + +# ============================================================================ +# Part D: Runtime mock (simulate CDNA on gfx1100 hardware) +# ============================================================================ + + +class _FakeWeightQuant: + """Minimal stand-in for a weight quantization config.""" + + def __init__(self, num_bits): + self.num_bits = num_bits + + +class TestMoEDispatchMocked: + """Mock on_gfx1100() to False and verify RDNA3 MoE is unreachable.""" + + def test_is_supported_false_when_mocked_cdna(self): + """rocm_moe_rdna.is_supported() must return False when not on gfx1100.""" + from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe import ( # noqa: E501 + rocm_moe_rdna, + ) + + with patch("vllm.platforms.rocm.on_gfx1100", return_value=False): + assert rocm_moe_rdna.is_supported(_FakeWeightQuant(num_bits=4)) is False + + @pytest.mark.parametrize("num_bits", [2, 3, 8, 16]) + def test_is_supported_rejects_non_w4(self, num_bits): + """is_supported() rejects non-4-bit even before checking arch.""" + from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe import ( # noqa: E501 + rocm_moe_rdna, + ) + + assert rocm_moe_rdna.is_supported(_FakeWeightQuant(num_bits=num_bits)) is False + + def test_is_supported_false_when_op_missing(self): + """is_supported() returns False when the C++ op doesn't exist.""" + from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe import ( # noqa: E501 + rocm_moe_rdna, + ) + + fake_rocm_c = type("FakeRocmC", (), {"gptq_gemm_rdna3": None})() + with patch.object(torch, "ops", create=True) as mock_ops: + mock_ops._rocm_C = fake_rocm_c + assert rocm_moe_rdna.is_supported(_FakeWeightQuant(num_bits=4)) is False + + def test_is_supported_false_when_rocm_c_absent(self): + """is_supported() returns False when _rocm_C doesn't exist at all.""" + from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors_moe import ( # noqa: E501 + rocm_moe_rdna, + ) + + fake_ops = type("FakeOps", (), {})() + with patch.object(torch, "ops", fake_ops): + assert rocm_moe_rdna.is_supported(_FakeWeightQuant(num_bits=4)) is False + + +class TestDenseKernelSelectionMocked: + """Mock on_gfx1100() and verify dense RDNA3 kernel is not selected.""" + + @gfx1100_only + def test_can_implement_rejects_when_mocked_cdna(self): + """RDNA3W4A16LinearKernel.can_implement must reject on mocked CDNA.""" + from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import ( # noqa: E501 + MPLinearLayerConfig, + ) + from vllm.model_executor.kernels.linear.mixed_precision.rdna3_w4a16 import ( # noqa: E501 + RDNA3W4A16LinearKernel, + ) + from vllm.scalar_type import scalar_types + + config = MPLinearLayerConfig( + full_weight_shape=(1024, 256), + partition_weight_shape=(1024, 256), + weight_type=scalar_types.uint4b8, + act_type=torch.float16, + group_size=128, + zero_points=False, + has_g_idx=False, + ) + ok, _ = RDNA3W4A16LinearKernel.can_implement(config) + assert ok is True + + with ( + patch("vllm.platforms.rocm.on_gfx1100", return_value=False), + patch("vllm.platforms.rocm._ON_GFX1100", False), + ): + ok, reason = RDNA3W4A16LinearKernel.can_implement(config) + assert ok is False, f"RDNA3 kernel accepted on simulated CDNA: {reason}" + + @gfx1100_only + def test_chooser_skips_rdna3_when_mocked_cdna(self): + """choose_mp_linear_kernel must NOT return RDNA3 on mocked CDNA.""" + from vllm.model_executor.kernels.linear import ( + choose_mp_linear_kernel, + ) + from vllm.model_executor.kernels.linear.mixed_precision.MPLinearKernel import ( # noqa: E501 + MPLinearLayerConfig, + ) + from vllm.scalar_type import scalar_types + + config = MPLinearLayerConfig( + full_weight_shape=(1024, 256), + partition_weight_shape=(1024, 256), + weight_type=scalar_types.uint4b8, + act_type=torch.float16, + group_size=128, + zero_points=False, + has_g_idx=False, + ) + with ( + patch("vllm.platforms.rocm.on_gfx1100", return_value=False), + patch("vllm.platforms.rocm._ON_GFX1100", False), + ): + chosen = choose_mp_linear_kernel(config) + assert chosen.__name__ != "RDNA3W4A16LinearKernel", ( + "RDNA3 kernel was selected on simulated CDNA — " + "choose_mp_linear_kernel guard is broken" + ) + + +class TestCompressedTensorsMoEDispatchGuard: + """Verify compressed_tensors_moe.py only enters rocm_moe_rdna under is_rocm().""" + + def test_rocm_guard_in_dispatch_source(self): + """The rocm_moe_rdna import and call must be inside an is_rocm() check.""" + src = _read_pkg_source_or_skip( + "model_executor", + "layers", + "quantization", + "compressed_tensors", + "compressed_tensors_moe", + "compressed_tensors_moe.py", + ) + lines = src.splitlines() + + for i, line in enumerate(lines, 1): + stripped = line.strip() + if "rocm_moe" in stripped and not stripped.startswith("#"): + found_guard = False + for j in range(i - 1, max(0, i - 15), -1): + if "is_rocm()" in lines[j - 1]: + found_guard = True + break + assert found_guard, ( + f"L{i}: rocm_moe_rdna reference not protected by " + f"is_rocm() guard: {stripped}" + ) diff --git a/tests/kernels/quantization/test_rdna3_moe_w4a16.py b/tests/kernels/quantization/test_rdna3_moe_w4a16.py new file mode 100644 index 000000000000..42482516355e --- /dev/null +++ b/tests/kernels/quantization/test_rdna3_moe_w4a16.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Correctness tests for the ROCm RDNA3 fused MoE W4A16 HIP kernel (gfx1100). + +Tests ``moe_gptq_gemm_rdna3`` against the dense ``gptq_gemm_rdna3`` as +reference: builds RDNA3-format weights (shuffled int32, synthesized qzeros), +runs the fused MoE kernel, and compares per-expert results. + +Model parameters taken from: + - cyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit + (hidden=2048, inter=768, E=128, top_k=8, G=32) + - Qwen3.6-35B-A3B-GPTQ-W4A16-G32 + (hidden=2048, inter=512, E=256, top_k=8, G=32) + +Run `pytest tests/kernels/quantization/test_rdna3_moe_w4a16.py`. +""" + +import pytest +import torch + +from vllm.platforms import current_platform + +if not current_platform.is_rocm(): + pytest.skip("RDNA3 MoE W4A16 kernel is ROCm-only", allow_module_level=True) + +from vllm import _custom_ops as ops # noqa: E402 +from vllm.model_executor.layers.fused_moe.activation import ( # noqa: E402 + MoEActivation, + apply_moe_activation, +) +from vllm.model_executor.layers.fused_moe.moe_align_block_size import ( # noqa: E402 + moe_align_block_size, +) +from vllm.model_executor.layers.quantization.utils.quant_utils import ( # noqa: E402 + pack_quantized_values_into_int32, +) +from vllm.platforms.rocm import on_gfx1100 # noqa: E402 +from vllm.scalar_type import scalar_types # noqa: E402 + +device = "cuda" + +gfx1100_only = pytest.mark.skipif( + not ( + on_gfx1100() + and hasattr(torch.ops, "_rocm_C") + and hasattr(torch.ops._rocm_C, "moe_gptq_gemm_rdna3") + ), + reason="Requires gfx1100 with moe_gptq_gemm_rdna3 op", +) + +# Model configurations: real K/N/top_k/group_size dims, E capped at 16 to +# fit in test GPU memory (full E=128/256 would need >20GB for weights alone). +# Kernel behavior is E-independent (per-expert tiling), so E=16 is sufficient. +MODEL_CONFIGS = [ + # cyankiwi/Qwen3-30B-A3B-Instruct-2507-AWQ-4bit dims (E capped) + pytest.param(16, 2048, 768, 8, 32, id="Qwen3-30B-A3B"), + # Qwen3.6-35B-A3B-GPTQ-W4A16-G32 dims (E capped) + pytest.param(16, 2048, 512, 8, 32, id="Qwen3.6-35B-A3B"), +] + +# Token counts: decode (1), small batch (4), medium (16), prefill (64) +NUM_TOKENS = [1, 4, 16, 64, 256, 512] + + +def _make_packed_weights(E, K, N): + """Create random 4-bit packed weights [E, K/8, N] int32 + shuffle.""" + w = torch.randint(0, 16, (E, K, N), dtype=torch.int32, device=device) + packed = torch.zeros(E, K // 8, N, dtype=torch.int32, device=device) + for i in range(8): + packed |= (w[:, i::8, :] & 0xF) << (i * 4) + g_idx = torch.empty(0, dtype=torch.int32, device=device) + for e in range(E): + we = packed[e].contiguous() + ops.gptq_shuffle(we, g_idx, 4) + packed[e] = we + return packed + + +def _make_scales(E, groups, N, dtype): + return torch.rand(E, groups, N, dtype=dtype, device=device) * 0.1 + + +def _make_qzeros(E, groups, N): + zeros = torch.full( + (groups, N), + scalar_types.uint4b8.bias - 1, + dtype=torch.int32, + device=device, + ) + qz = pack_quantized_values_into_int32( + zeros, + scalar_types.uint4b8, + packed_dim=1, + ) + return qz.unsqueeze(0).expand(E, -1, -1).contiguous() + + +@gfx1100_only +@pytest.mark.parametrize("E, K, N_inter, top_k, group_size", MODEL_CONFIGS) +@pytest.mark.parametrize("M", NUM_TOKENS) +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) +@pytest.mark.parametrize("block_size_m", [1, 4]) +def test_fused_moe_w1_matches_dense( + E, K, N_inter, top_k, group_size, M, dtype, block_size_m +): + """w1 GEMM via fused kernel matches per-expert dense kernel.""" + N_gate_up = N_inter * 2 + groups = K // group_size + + torch.manual_seed(42) + x = torch.randn(M, K, dtype=dtype, device=device) + w13 = _make_packed_weights(E, K, N_gate_up) + w13_s = _make_scales(E, groups, N_gate_up, dtype) + w13_z = _make_qzeros(E, groups, N_gate_up) + g_idx = torch.empty(0, dtype=torch.int32, device=device) + + topk_ids = torch.randint(0, E, (M, top_k), device=device, dtype=torch.int32) + si, ei, ntp = moe_align_block_size(topk_ids, block_size_m, E) + + # Fused kernel + fused_out = torch.zeros(M * top_k, N_gate_up, dtype=dtype, device=device) + ops.moe_gptq_gemm_rdna3( + x, + fused_out, + w13, + w13_s, + w13_z, + torch.empty(0, device=device), + si, + ei, + ntp, + top_k, + block_size_m, + False, + 0, + ) + + # Per-expert dense reference + ref_out = torch.zeros(M * top_k, N_gate_up, dtype=dtype, device=device) + for m in range(M): + for k in range(top_k): + e = topk_ids[m, k].item() + flat = m * top_k + k + ref = ops.gptq_gemm_rdna3( + x[m : m + 1], + w13[e], + w13_z[e], + w13_s[e], + g_idx, + False, + ) + ref_out[flat] = ref.squeeze() + + # Split-K atomics can cause minor fp16/bf16 rounding differences + # at large K (e.g. K=2048 → 8 K-blocks). Use allclose, not equal. + atol = 0.5 if dtype == torch.bfloat16 else 0.1 + assert torch.allclose(fused_out, ref_out, atol=atol, rtol=0.01), ( + f"max diff: {(fused_out - ref_out).abs().max().item()}" + ) + + +@gfx1100_only +@pytest.mark.parametrize("E, K, N_inter, top_k, group_size", MODEL_CONFIGS) +@pytest.mark.parametrize("M", NUM_TOKENS) +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) +def test_fused_moe_output_topk_reduces(E, K, N_inter, top_k, group_size, M, dtype): + """output_topk fuses moe_sum: multiple experts write to same output row.""" + groups = K // group_size + + torch.manual_seed(123) + x = torch.randn(M * top_k, K, dtype=dtype, device=device) + w = _make_packed_weights(E, K, N_inter) + ws = _make_scales(E, groups, N_inter, dtype) + wz = _make_qzeros(E, groups, N_inter) + + topk_ids = torch.randint(0, E, (M, top_k), device=device, dtype=torch.int32) + topk_w = torch.softmax( + torch.randn(M, top_k, device=device), + dim=-1, + ).float() + + si, ei, ntp = moe_align_block_size(topk_ids, 1, E) + + # Without output_topk: write to [M*top_k, N] then moe_sum + flat_out = torch.zeros(M * top_k, N_inter, dtype=dtype, device=device) + ops.moe_gptq_gemm_rdna3( + x, + flat_out, + w, + ws, + wz, + topk_w.view(-1), + si, + ei, + ntp, + 1, + 1, + True, + 0, + ) + ref = torch.zeros(M, N_inter, dtype=dtype, device=device) + ops.moe_sum(flat_out.view(M, top_k, N_inter), ref) + + # With output_topk: write directly to [M, N] + fused = torch.zeros(M, N_inter, dtype=dtype, device=device) + ops.moe_gptq_gemm_rdna3( + x, + fused, + w, + ws, + wz, + topk_w.view(-1), + si, + ei, + ntp, + 1, + 1, + True, + top_k, + ) + + atol = 1.0 if dtype == torch.bfloat16 else 0.1 + assert torch.allclose(fused, ref, atol=atol, rtol=0.01), ( + f"max diff: {(fused - ref).abs().max().item()}" + ) + + +@gfx1100_only +@pytest.mark.parametrize("E, K, N_inter, top_k, group_size", MODEL_CONFIGS) +@pytest.mark.parametrize("M", NUM_TOKENS) +@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16]) +def test_full_moe_e2e(E, K, N_inter, top_k, group_size, M, dtype): + """Full MoE forward: w1 + silu_and_mul + w2 with output_topk reduce.""" + N_gate_up = N_inter * 2 + hidden = K + + torch.manual_seed(7) + x = torch.randn(M, K, dtype=dtype, device=device) + w13 = _make_packed_weights(E, K, N_gate_up) + w13_s = _make_scales(E, K // group_size, N_gate_up, dtype) + w13_z = _make_qzeros(E, K // group_size, N_gate_up) + w2 = _make_packed_weights(E, N_inter, hidden) + w2_s = _make_scales(E, N_inter // group_size, hidden, dtype) + w2_z = _make_qzeros(E, N_inter // group_size, hidden) + g_idx = torch.empty(0, dtype=torch.int32, device=device) + + topk_ids = torch.randint(0, E, (M, top_k), device=device, dtype=torch.int32) + topk_w = torch.softmax( + torch.randn(M, top_k, device=device), + dim=-1, + ).float() + + si, ei, ntp = moe_align_block_size(topk_ids, 1, E) + + # Fused path (what apply() does) + w1_out = torch.zeros(M * top_k, N_gate_up, dtype=dtype, device=device) + ops.moe_gptq_gemm_rdna3( + x, + w1_out, + w13, + w13_s, + w13_z, + torch.empty(0, device=device), + si, + ei, + ntp, + top_k, + 1, + False, + 0, + ) + act_out = torch.empty(M * top_k, N_inter, dtype=dtype, device=device) + apply_moe_activation(MoEActivation.SILU, act_out, w1_out) + fused = torch.zeros(M, hidden, dtype=dtype, device=device) + ops.moe_gptq_gemm_rdna3( + act_out, + fused, + w2, + w2_s, + w2_z, + topk_w.view(-1), + si, + ei, + ntp, + 1, + 1, + True, + top_k, + ) + + # Per-expert reference + ref = torch.zeros(M, hidden, dtype=dtype, device=device) + for m_idx in range(M): + for k_idx in range(top_k): + e = topk_ids[m_idx, k_idx].item() + w = topk_w[m_idx, k_idx].item() + r1 = ops.gptq_gemm_rdna3( + x[m_idx : m_idx + 1], + w13[e], + w13_z[e], + w13_s[e], + g_idx, + False, + ) + a = torch.empty(1, N_inter, dtype=dtype, device=device) + apply_moe_activation(MoEActivation.SILU, a, r1) + r2 = ops.gptq_gemm_rdna3( + a, + w2[e], + w2_z[e], + w2_s[e], + g_idx, + False, + ) + ref[m_idx] += r2.squeeze() * w + + # E2E chains w1 + activation + w2 + topk_w + output_topk reduce. + # Each step accumulates rounding error (split-K atomics, topk_w + # multiply order). Use relative L2 norm like the dense kernel test. + diff_l2 = torch.norm(fused.float() - ref.float()) + ref_l2 = torch.norm(ref.float()) + rel_l2 = (diff_l2 / ref_l2).item() if ref_l2 > 0 else 0.0 + threshold = 0.05 if dtype == torch.float16 else 0.10 + assert rel_l2 < threshold, ( + f"rel L2 = {rel_l2:.4f} (threshold {threshold}), " + f"max abs diff: {(fused - ref).abs().max().item()}" + ) + + +@gfx1100_only +def test_expert_id_minus_one(): + """Kernel handles expert_id == -1 (expert parallelism) without crash.""" + # Qwen3-30B-A3B dims (E capped for memory) + E, K, N = 16, 2048, 768 + groups = K // 32 + + w = _make_packed_weights(E, K, N) + ws = _make_scales(E, groups, N, torch.bfloat16) + wz = _make_qzeros(E, groups, N) + x = torch.randn(1, K, dtype=torch.bfloat16, device=device) + + # Manually create sorted_token_ids/expert_ids with -1 + sorted_ids = torch.tensor([0], dtype=torch.int32, device=device) + expert_ids = torch.tensor([-1], dtype=torch.int32, device=device) + ntp = torch.tensor([1], dtype=torch.int32, device=device) + + out = torch.zeros(1, N, dtype=torch.bfloat16, device=device) + ops.moe_gptq_gemm_rdna3( + x, + out, + w, + ws, + wz, + torch.empty(0, device=device), + sorted_ids, + expert_ids, + ntp, + 1, + 1, + False, + 0, + ) + current_platform.synchronize() + + # Output should remain zero (expert skipped) + assert torch.equal(out, torch.zeros_like(out)) diff --git a/tests/kernels/test_compressor_kv_cache.py b/tests/kernels/test_compressor_kv_cache.py index c6daab2d86be..74dc01472a8e 100644 --- a/tests/kernels/test_compressor_kv_cache.py +++ b/tests/kernels/test_compressor_kv_cache.py @@ -468,6 +468,7 @@ def _reference_kv_compress_norm_rope( use_fp4: bool = False, rms_eps: float = 1e-6, fp8_max: float = 448.0, + return_full_cache: bool = False, ): """Compress → RMSNorm → GPT-J RoPE → quantize. @@ -521,6 +522,12 @@ def _reference_kv_compress_norm_rope( results.append(torch.cat([nope, rope]).to(state_cache.dtype)) result = torch.stack(results) + if return_full_cache: + # Contiguous 512-wide bf16 row (nope unrotated + rope rotated), matching + # the FlashInfer full-cache layout before any per-tensor fp8 quant. The + # kernel rounds the fp32 result to bf16 once at the store. + return result.to(torch.bfloat16) + if use_fp4: return quantize_to_mxfp4(result) else: @@ -667,3 +674,145 @@ def test_fused_kv_insert_indexer(num_tokens: int, kv_block_size: int, use_fp4: b assert torch.equal(actual_scale, scale[i : i + 1]), ( f"token {i}: scale {actual_scale.item()} != {scale[i].item()}" ) + + +@pytest.mark.parametrize("compress_ratio", [4, 128]) +@pytest.mark.parametrize("store_fp8", [False, True]) +def test_cutedsl_full_cache_store(compress_ratio: int, store_fp8: bool): + """CuTeDSL compressor full-cache (FlashInfer) store parity for head=512. + + Exercises the contiguous bf16 / per-tensor fp8 store branch of both the C4 + fused kernel and the C128 split kernel against the PyTorch reference. + """ + cutedsl = pytest.importorskip("cutlass") # noqa: F841 + from vllm.models.deepseek_v4.nvidia.ops.sparse_attn_compress_cutedsl import ( + fused_kv_compress_norm_rope_insert_sparse_attn_cutedsl, + split_kv_compress_norm_rope_insert_sparse_attn_cutedsl, + ) + + HEAD_DIM = 512 + ROPE_DIM = 64 + RMS_EPS = 1e-6 + FP8_MAX = 448.0 + # C128 compress (Block8 kernel) requires state-cache block_size=8; C4 uses 16. + BLOCK_SIZE = 8 if compress_ratio == 128 else 16 + KV_BLOCK_SIZE = 64 + device = "cuda" + torch.manual_seed(7) + + overlap = 1 if compress_ratio == 4 else 0 + coff = 1 + overlap + num_tokens = 8 + + num_pages = (compress_ratio * num_tokens - 1) // BLOCK_SIZE + 2 + # The production CompressorStateCache is fp32. + state_cache = torch.randn( + num_pages, BLOCK_SIZE, 2 * coff * HEAD_DIM, dtype=torch.float32, device=device + ) + block_table = torch.arange(num_pages, dtype=torch.int32, device=device).unsqueeze(0) + token_to_req = torch.zeros(num_tokens, dtype=torch.int32, device=device) + slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device) + positions = torch.arange( + compress_ratio - 1, + compress_ratio * num_tokens, + compress_ratio, + dtype=torch.int64, + device=device, + ) + rms_weight = torch.randn(HEAD_DIM, dtype=torch.bfloat16, device=device) + cos_sin_cache = torch.randn( + compress_ratio * num_tokens, ROPE_DIM, dtype=torch.float32, device=device + ) + + dtype = torch.float8_e4m3fn if store_fp8 else torch.bfloat16 + kv_n_blocks = (num_tokens + KV_BLOCK_SIZE - 1) // KV_BLOCK_SIZE + 1 + k_cache = torch.zeros( + kv_n_blocks, KV_BLOCK_SIZE, HEAD_DIM, dtype=dtype, device=device + ) + fp8_scale = torch.tensor( + [0.5 if store_fp8 else 1.0], dtype=torch.float32, device=device + ) + + if compress_ratio == 4: + fused_kv_compress_norm_rope_insert_sparse_attn_cutedsl( + state_cache, + token_to_req, + positions, + slot_mapping, + block_table, + BLOCK_SIZE, + rms_weight, + RMS_EPS, + cos_sin_cache, + k_cache, + slot_mapping, + KV_BLOCK_SIZE, + k_cache.stride(0), + head_size=HEAD_DIM, + state_width=coff * HEAD_DIM, + rope_head_dim=ROPE_DIM, + fp8_max=FP8_MAX, + quant_block=64, + token_stride=576, + scale_dim=8, + compress_ratio=compress_ratio, + overlap=True, + store_full_kv=True, + store_full_fp8=store_fp8, + fp8_scale=fp8_scale, + ) + else: + compressed_kv = torch.empty( + (num_tokens, HEAD_DIM), dtype=torch.float32, device=device + ) + split_kv_compress_norm_rope_insert_sparse_attn_cutedsl( + state_cache, + token_to_req, + positions, + slot_mapping, + block_table, + BLOCK_SIZE, + compressed_kv, + rms_weight, + RMS_EPS, + cos_sin_cache, + k_cache, + slot_mapping, + KV_BLOCK_SIZE, + k_cache.stride(0), + head_size=HEAD_DIM, + state_width=coff * HEAD_DIM, + rope_head_dim=ROPE_DIM, + fp8_max=FP8_MAX, + quant_block=64, + token_stride=576, + scale_dim=8, + compress_ratio=compress_ratio, + overlap=bool(overlap), + store_full_kv=True, + store_full_fp8=store_fp8, + fp8_scale=fp8_scale, + ) + + ref = _reference_kv_compress_norm_rope( + state_cache, + block_table, + positions, + rms_weight, + cos_sin_cache, + compress_ratio, + overlap, + rms_eps=RMS_EPS, + return_full_cache=True, + ) # [num_tokens, HEAD_DIM] bf16 + + actual = torch.stack( + [k_cache[i // KV_BLOCK_SIZE, i % KV_BLOCK_SIZE] for i in range(num_tokens)] + ) + if store_fp8: + ref_fp8 = torch.clamp(ref.float() / fp8_scale, -FP8_MAX, FP8_MAX).to( + torch.float8_e4m3fn + ) + torch.testing.assert_close(actual.float(), ref_fp8.float(), rtol=0.0, atol=0.3) + else: + torch.testing.assert_close(actual.float(), ref.float(), rtol=3e-2, atol=3e-2) diff --git a/tests/kernels/test_flex_attention.py b/tests/kernels/test_flex_attention.py index 41d298134762..86f26cfe8cab 100644 --- a/tests/kernels/test_flex_attention.py +++ b/tests/kernels/test_flex_attention.py @@ -13,6 +13,7 @@ create_standard_kv_cache_spec, create_vllm_config, ) +from vllm.model_executor.layers.attention import Attention from vllm.v1.attention.backends.flex_attention import ( BlockSparsityHint, FlexAttentionMetadataBuilder, @@ -79,6 +80,72 @@ def test_flex_attention_full_cudagraphs(vllm_runner): ) +def windowed_causal_mask_mod(b, h, q_idx, kv_idx): + return (kv_idx <= q_idx) & (q_idx - kv_idx < 4) + + +@pytest.mark.skipif( + not torch.cuda.is_available() or TORCH_VERSION < MINIMUM_TORCH_VERSION, + reason="CUDA not available or PyTorch version < 2.7", +) +def test_flex_attention_custom_mask_full_cudagraphs(vllm_runner, monkeypatch): + monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0") + monkeypatch.setattr( + Attention, + "logical_mask_mod", + staticmethod(windowed_causal_mask_mod), + raising=False, + ) + + model_name = "Qwen/Qwen2.5-1.5B-Instruct" + seed = 42 + max_tokens = 24 + num_logprobs = 5 + prompts = [ + "Hello, my name is", + "The president of the United States is", + "The capital of France is", + ] + + set_random_seed(seed) + with vllm_runner( + model_name, + runner="generate", + tensor_parallel_size=1, + num_gpu_blocks_override=128, + enforce_eager=True, + attention_config={"backend": "FLEX_ATTENTION"}, + ) as llm_eager: + output_eager = llm_eager.generate_greedy_logprobs( + prompts, max_tokens, num_logprobs + ) + + set_random_seed(seed) + with vllm_runner( + model_name, + runner="generate", + tensor_parallel_size=1, + num_gpu_blocks_override=128, + enforce_eager=False, + gpu_memory_utilization=0.85, + compilation_config={ + "cudagraph_mode": "FULL", + "cudagraph_capture_sizes": [4], + }, + attention_config={"backend": "FLEX_ATTENTION"}, + ) as llm_cudagraph: + output_cudagraph = llm_cudagraph.generate_greedy_logprobs( + prompts, max_tokens, num_logprobs + ) + + check_logprobs_close( + outputs_0_lst=output_eager, + outputs_1_lst=output_cudagraph, + name_0="eager", + name_1="cudagraph", + ) + + @pytest.mark.skipif( not torch.cuda.is_available() or TORCH_VERSION < MINIMUM_TORCH_VERSION, reason="CUDA not available or PyTorch version < 2.7", diff --git a/tests/kernels/test_fp32_router_gemm.py b/tests/kernels/test_fp32_router_gemm.py index f855eb7aa171..0673a438c546 100644 --- a/tests/kernels/test_fp32_router_gemm.py +++ b/tests/kernels/test_fp32_router_gemm.py @@ -1,6 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Tests for fp32_router_gemm kernel: activation×weight→fp32, H=3072, E=256. +"""Tests for fp32_router_gemm kernel: activation×weight→fp32. + +Supported (hidden_size, num_experts) pairs: + (3072, 256) -> MiniMax-M2/M2.5, (6144, 128) -> MiniMax-M3 Correctness baseline: torch.matmul in float64. """ @@ -10,8 +13,8 @@ from vllm._custom_ops import fp32_router_gemm -NUM_EXPERTS = 256 -HIDDEN_DIM = 3072 +# (hidden_size, num_experts) +SHAPES = [(3072, 256), (6144, 128)] # Absolute tolerance for fp32 kernel vs float64 reference ATOL_FP32 = 2e-4 ATOL_BF16 = 2e-2 # bf16 activation has lower precision @@ -30,49 +33,52 @@ def _ref(mat_a: torch.Tensor, mat_b: torch.Tensor) -> torch.Tensor: return torch.nn.functional.linear(mat_a.float(), mat_b.float()) +@pytest.mark.parametrize("hidden_dim,num_experts", SHAPES) @pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 32]) -def test_fp32_activation(num_tokens: int): +def test_fp32_activation(num_tokens: int, hidden_dim: int, num_experts: int): """fp32 activation → fp32 output should match reference closely.""" _requires_sm90() torch.manual_seed(42) device = torch.device("cuda") - mat_a = torch.randn(num_tokens, HIDDEN_DIM, dtype=torch.float32, device=device) - mat_b = torch.randn(NUM_EXPERTS, HIDDEN_DIM, dtype=torch.float32, device=device) + mat_a = torch.randn(num_tokens, hidden_dim, dtype=torch.float32, device=device) + mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device) out = fp32_router_gemm(mat_a, mat_b) ref = _ref(mat_a, mat_b) - assert out.shape == (num_tokens, NUM_EXPERTS) + assert out.shape == (num_tokens, num_experts) assert out.dtype == torch.float32 torch.testing.assert_close(out, ref, atol=ATOL_FP32, rtol=0) +@pytest.mark.parametrize("hidden_dim,num_experts", SHAPES) @pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 32]) -def test_bf16_activation(num_tokens: int): +def test_bf16_activation(num_tokens: int, hidden_dim: int, num_experts: int): """bf16 activation → fp32 output should match reference within bf16 error.""" _requires_sm90() torch.manual_seed(42) device = torch.device("cuda") mat_a_bf16 = torch.randn( - num_tokens, HIDDEN_DIM, dtype=torch.bfloat16, device=device + num_tokens, hidden_dim, dtype=torch.bfloat16, device=device ) - mat_b = torch.randn(NUM_EXPERTS, HIDDEN_DIM, dtype=torch.float32, device=device) + mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device) out = fp32_router_gemm(mat_a_bf16, mat_b) ref = _ref(mat_a_bf16, mat_b).to(device) - assert out.shape == (num_tokens, NUM_EXPERTS) + assert out.shape == (num_tokens, num_experts) assert out.dtype == torch.float32 torch.testing.assert_close(out, ref, atol=ATOL_BF16, rtol=0) -def test_output_shape_and_dtype(): +@pytest.mark.parametrize("hidden_dim,num_experts", SHAPES) +def test_output_shape_and_dtype(hidden_dim: int, num_experts: int): """Basic shape and dtype checks.""" _requires_sm90() device = torch.device("cuda") - mat_a = torch.randn(4, HIDDEN_DIM, dtype=torch.float32, device=device) - mat_b = torch.randn(NUM_EXPERTS, HIDDEN_DIM, dtype=torch.float32, device=device) + mat_a = torch.randn(4, hidden_dim, dtype=torch.float32, device=device) + mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device) out = fp32_router_gemm(mat_a, mat_b) - assert out.shape == (4, NUM_EXPERTS) + assert out.shape == (4, num_experts) assert out.dtype == torch.float32 assert out.device.type == "cuda" diff --git a/tests/kernels/test_fused_deepseek_v32_norm_rope.py b/tests/kernels/test_fused_deepseek_v32_norm_rope.py new file mode 100644 index 000000000000..a27e67b62459 --- /dev/null +++ b/tests/kernels/test_fused_deepseek_v32_norm_rope.py @@ -0,0 +1,562 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for the horizontally-fused deepseek_v32 (NVIDIA SM100) Triton +kernels used by the specialized DSA model: + + fused_norm_rope + - q : q_lora RMSNorm + - kv : kv_lora RMSNorm + (interleaved) RoPE on k_pe + MLA cache insert + (bf16 or per-tensor fp8) + - idx: indexer-K LayerNorm + RoPE (interleaved or NeoX) + UE8M0 fp8 quant + + packed indexer cache insert; plus the top-k buffer (-1) fill + fused_q + - mqa: ql_nope + (interleaved) RoPE'd q_pe, concat-quantized to the fp8 MQA + query + - idx: indexer-Q RoPE (interleaved or NeoX) + UE8M0 fp8 quant + folded + index weights + fused_eh_norm (MTP): zero-at-pos-0 + enorm RMSNorm(embeds) + hnorm + RMSNorm(prev), concatenated side-by-side + +Each kernel is compared against a PyTorch reference. The kernel keeps the whole +pipeline in fp32 and rounds once, so it can land on the opposite side of a +round-to-nearest tie from the reference for a few elements: deterministic fp8 +outputs are checked within 1 representable-step (ULP); bf16 norm/RoPE outputs use +rtol/atol=1e-2 (the tolerance the sibling deepseek_v4 fused-kernel test uses). +""" + +import pytest +import torch + +from vllm.models.deepseek_v32.nvidia import kernels as K +from vllm.platforms import current_platform + +FP8 = torch.float8_e4m3fn +FP8_MAX = 448.0 + +# GLM-5.2 / DeepSeek-V3.2 shapes (TP8 local heads). +Q_LORA = 2048 +KV_LORA = 512 +ROPE_DIM = 64 +NUM_HEADS = 8 +INDEX_HEADS = 32 +INDEX_HEAD_DIM = 128 +HIDDEN = 6144 +EPS = 1e-6 + +pytestmark = pytest.mark.skipif( + not current_platform.is_cuda() or not current_platform.has_device_capability(89), + reason="deepseek_v32 fused kernels require CUDA with fp8 (SM89+)", +) + + +# ── reference helpers ──────────────────────────────────────────────────────── + + +def make_cos_sin(max_pos: int, rot_dim: int, device) -> torch.Tensor: + """cos||sin cache: row[pos] = [cos(theta)(rot/2), sin(theta)(rot/2)].""" + half = rot_dim // 2 + inv_freq = 1.0 / ( + 10000.0 ** (torch.arange(0, half, dtype=torch.float32, device=device) / half) + ) + t = torch.arange(max_pos, dtype=torch.float32, device=device) + freqs = torch.einsum("i,j->ij", t, inv_freq) + return torch.cat([freqs.cos(), freqs.sin()], dim=-1) + + +def rms_norm(x: torch.Tensor, w: torch.Tensor) -> torch.Tensor: + """RMSNorm matching kernels._rms_norm (fp32, eps inside rsqrt). Returns fp32.""" + xf = x.float() + ms = xf.pow(2).mean(dim=-1, keepdim=True) + return xf * torch.rsqrt(ms + EPS) * w.float() + + +def layer_norm(x: torch.Tensor, w: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + xf = x.float() + mean = xf.mean(dim=-1, keepdim=True) + var = (xf - mean).pow(2).mean(dim=-1, keepdim=True) + return (xf - mean) * torch.rsqrt(var + EPS) * w.float() + b.float() + + +def rope( + x: torch.Tensor, pos: torch.Tensor, cos_sin: torch.Tensor, interleave: bool +) -> torch.Tensor: + """Apply RoPE to the first ``rot_dim`` elements of x's last dim. + + x: [..., head_dim] fp32. ``cos_sin`` is [max_pos, rot_dim]. ``interleave`` + selects adjacent-pair (GLM) vs split-half NeoX (DeepSeek-V3.2) layout. + """ + rot = cos_sin.shape[-1] + half = rot // 2 + cs = cos_sin[pos.long()] + cos, sin = cs[..., :half], cs[..., half:] + out = x.float().clone() + r = out[..., :rot] + if interleave: + x1, x2 = r[..., 0::2].clone(), r[..., 1::2].clone() + r[..., 0::2] = x1 * cos - x2 * sin + r[..., 1::2] = x2 * cos + x1 * sin + else: + x1, x2 = r[..., :half].clone(), r[..., half:].clone() + r[..., :half] = x1 * cos - x2 * sin + r[..., half:] = x2 * cos + x1 * sin + return out + + +def ue8m0_quant(vals: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Per-row (last dim) UE8M0 fp8 quant matching kernels._fp8_ue8m0_quantize.""" + amax = vals.float().abs().amax(dim=-1, keepdim=True) + scale = torch.clamp(amax, min=1e-4) / FP8_MAX + scale = torch.exp2(torch.ceil(torch.log2(scale))) + q = (vals.float() / scale).to(FP8) + return q, scale.squeeze(-1) + + +def _bf16_ulp(a: torch.Tensor, b: torch.Tensor) -> int: + def key(t): + u = t.contiguous().view(torch.int16).to(torch.int64) & 0xFFFF + return torch.where(u >= 0x8000, 0xFFFF - u, u + 0x8000) + + return int((key(a) - key(b)).abs().max().item()) + + +def _fp8_ulp(a: torch.Tensor, b: torch.Tensor) -> int: + def key(t): + u = t.contiguous().view(torch.uint8).to(torch.int64) + return torch.where(u >= 0x80, 0xFF - u, u + 0x80) + + return int((key(a) - key(b)).abs().max().item()) + + +def assert_bf16(got: torch.Tensor, ref_fp32: torch.Tensor, msg: str): + # Kernel keeps RMSNorm/RoPE in fp32 and rounds to bf16 once; the fp32 + # reduction/FMA order differs from torch, so a few elements land on the + # opposite side of a round-to-nearest tie. Use the same tolerance the + # sibling deepseek_v4 fused-kernel test uses for this bf16 norm+rope class. + torch.testing.assert_close( + got.float(), ref_fp32.float(), rtol=1e-2, atol=1e-2, msg=lambda m: f"{msg}: {m}" + ) + + +def assert_fp8(got: torch.Tensor, ref: torch.Tensor, msg: str): + assert _fp8_ulp(got, ref) <= 1, f"{msg}: >1 fp8 ULP" + + +# ── fused_norm_rope ────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512, 4096]) +@pytest.mark.parametrize("index_interleave", [True, False]) +@pytest.mark.parametrize("mla_fp8", [False, True]) +def test_fused_norm_rope(num_tokens: int, index_interleave: bool, mla_fp8: bool): + torch.manual_seed(0) + dev = "cuda" + max_pos = 8192 + pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) % max_pos + + q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16) + kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16) + k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16) + qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16) + kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16) + ik = torch.randn(num_tokens, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16) + ikw = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32) + ikb = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32) + + mla_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) # MLA k_pe: interleaved + idx_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) + + bs = max_pos # single block covering all tokens + mla_dim = KV_LORA + ROPE_DIM + if mla_fp8: + mla_cache = torch.zeros(1, bs, mla_dim, device=dev, dtype=torch.uint8) + mla_dtype = "fp8" + mla_k_scale = torch.tensor([0.3], device=dev, dtype=torch.float32) + else: + mla_cache = torch.zeros(1, bs, mla_dim, device=dev, dtype=torch.bfloat16) + mla_dtype = "auto" + mla_k_scale = None + idx_row = INDEX_HEAD_DIM + INDEX_HEAD_DIM // 128 * 4 # 132 + idx_cache = torch.zeros(1, bs, idx_row, device=dev, dtype=torch.uint8) + slot = torch.arange(num_tokens, device=dev, dtype=torch.int64) + topk = torch.full((num_tokens, 2048), 7, device=dev, dtype=torch.int32) + + q_out = K.fused_norm_rope( + pos, + q_c, + qw, + EPS, + kv_c, + kvw, + EPS, + k_pe, + mla_cos_sin, + ik, + ikw, + ikb, + EPS, + idx_cos_sin, + topk, + slot_mapping=slot, + indexer_k_cache=idx_cache, + mla_kv_cache=mla_cache, + mla_kv_cache_dtype=mla_dtype, + mla_k_scale=mla_k_scale, + has_indexer=True, + index_rope_interleave=index_interleave, + ) + + # q_lora RMSNorm + assert_bf16(q_out, rms_norm(q_c, qw), "q_c rmsnorm") + + # MLA cache: [kv_c_normed | k_pe_roped(interleaved)] + kv_ref = rms_norm(kv_c, kvw) + kpe_ref = rope(k_pe.float(), pos, mla_cos_sin, interleave=True) + if mla_fp8: + cache = mla_cache.view(FP8)[0, :num_tokens] + s = mla_k_scale.item() + assert_fp8(cache[:, :KV_LORA], (kv_ref / s).to(FP8), "MLA kv fp8") + assert_fp8(cache[:, KV_LORA:], (kpe_ref / s).to(FP8), "MLA k_pe fp8") + else: + cache = mla_cache[0, :num_tokens] + assert_bf16(cache[:, :KV_LORA], kv_ref, "MLA kv bf16") + assert_bf16(cache[:, KV_LORA:], kpe_ref, "MLA k_pe bf16") + + # Indexer-K cache (packed [bs*head_dim fp8 | bs*4 fp32 scale]). + ik_ref = layer_norm(ik, ikw, ikb) + ik_ref = rope(ik_ref, pos, idx_cos_sin, interleave=index_interleave) + q_ref, s_ref = ue8m0_quant(ik_ref) + flat = idx_cache[0].reshape(-1) + vals = flat[: bs * INDEX_HEAD_DIM].view(FP8).reshape(bs, INDEX_HEAD_DIM) + scales = flat[bs * INDEX_HEAD_DIM :].view(torch.float32) + assert_fp8(vals[:num_tokens], q_ref, "indexer-K fp8") + torch.testing.assert_close(scales[:num_tokens], s_ref, rtol=0, atol=0) + + # Top-k buffer cleared to -1 on indexer layers. + assert (topk == -1).all(), "topk buffer not cleared on indexer layer" + + +@pytest.mark.parametrize("num_tokens", [1, 17, 512]) +def test_fused_norm_rope_no_indexer(num_tokens: int): + """Shared (no-indexer) layer: q + kv/MLA only; top-k buffer untouched.""" + torch.manual_seed(1) + dev = "cuda" + max_pos = 8192 + pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) + + q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16) + kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16) + k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16) + qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16) + kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16) + mla_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) + + bs = max_pos + mla_cache = torch.zeros(1, bs, KV_LORA + ROPE_DIM, device=dev, dtype=torch.bfloat16) + slot = torch.arange(num_tokens, device=dev, dtype=torch.int64) + topk = torch.full((num_tokens, 2048), 7, device=dev, dtype=torch.int32) + + q_out = K.fused_norm_rope( + pos, + q_c, + qw, + EPS, + kv_c, + kvw, + EPS, + k_pe, + mla_cos_sin, + None, + None, + None, + EPS, + None, + topk, + slot_mapping=slot, + indexer_k_cache=None, + mla_kv_cache=mla_cache, + mla_kv_cache_dtype="auto", + mla_k_scale=None, + has_indexer=False, + index_rope_interleave=False, + ) + + assert_bf16(q_out, rms_norm(q_c, qw), "q_c rmsnorm (no-indexer)") + cache = mla_cache[0, :num_tokens] + assert_bf16(cache[:, :KV_LORA], rms_norm(kv_c, kvw), "MLA kv (no-indexer)") + assert_bf16( + cache[:, KV_LORA:], + rope(k_pe.float(), pos, mla_cos_sin, interleave=True), + "MLA k_pe (no-indexer)", + ) + # Shared layers reuse the previous indexer's top-k: buffer must be untouched. + assert (topk == 7).all(), "topk buffer should be untouched on shared layer" + + +@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512]) +def test_fused_norm_rope_ds_mla(num_tokens: int): + """fp8_ds_mla MLA cache layout (FlashMLA sparse, bf16-query path; SM90/SM100). + + Per-token 656-byte entry: 512 fp8 NoPE (4 per-128 tiles, dynamic float32 + scale) | 4 float32 scales | 64 bf16 (unquantized) RoPE. + """ + torch.manual_seed(5) + dev = "cuda" + max_pos = 8192 + pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) % max_pos + + q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16) + kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16) + k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16) + qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16) + kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16) + mla_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) + + bs = max_pos + mla_cache = torch.zeros(1, bs, 656, device=dev, dtype=torch.uint8) + slot = torch.arange(num_tokens, device=dev, dtype=torch.int64) + topk = torch.full((num_tokens, 2048), 7, device=dev, dtype=torch.int32) + + q_out = K.fused_norm_rope( + pos, + q_c, + qw, + EPS, + kv_c, + kvw, + EPS, + k_pe, + mla_cos_sin, + None, + None, + None, + EPS, + None, + topk, + slot_mapping=slot, + indexer_k_cache=None, + mla_kv_cache=mla_cache, + mla_kv_cache_dtype="fp8_ds_mla", + mla_k_scale=None, + has_indexer=False, + index_rope_interleave=False, + ) + + assert_bf16(q_out, rms_norm(q_c, qw), "q_c rmsnorm (ds_mla)") + + kv_ref = rms_norm(kv_c, kvw) # [N, 512] fp32 + kpe_ref = rope(k_pe.float(), pos, mla_cos_sin, interleave=True) # [N, 64] + tiles = kv_ref.view(num_tokens, 4, 128) + ref_scale = torch.clamp(tiles.abs().amax(dim=-1) / FP8_MAX, min=1.1754944e-38) + ref_nope = (tiles / ref_scale[..., None]).reshape(num_tokens, KV_LORA).to(FP8) + + cache = mla_cache[0, :num_tokens] # [N, 656] uint8 + nope = cache[:, :KV_LORA].view(FP8) + scales = cache.view(torch.float32)[:, KV_LORA // 4 : KV_LORA // 4 + 4] + rope_off = KV_LORA // 2 + 8 + rope_vals = cache.view(torch.bfloat16)[:, rope_off : rope_off + ROPE_DIM] + + torch.testing.assert_close(scales, ref_scale, rtol=1e-2, atol=1e-6) + assert_fp8(nope, ref_nope, "ds_mla NoPE fp8") + assert_bf16(rope_vals, kpe_ref, "ds_mla RoPE bf16") + # No indexer on this call: top-k buffer must be untouched. + assert (topk == 7).all(), "topk buffer should be untouched (no indexer)" + + +# ── fused_q ────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512, 4096]) +@pytest.mark.parametrize("index_interleave", [True, False]) +def test_fused_q(num_tokens: int, index_interleave: bool): + torch.manual_seed(2) + dev = "cuda" + max_pos = 8192 + pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) % max_pos + + q_pe = torch.randn( + num_tokens, NUM_HEADS, ROPE_DIM, device=dev, dtype=torch.bfloat16 + ) + ql_nope = torch.randn( + num_tokens, NUM_HEADS, KV_LORA, device=dev, dtype=torch.bfloat16 + ) + index_q = torch.randn( + num_tokens, INDEX_HEADS, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16 + ) + index_w = torch.randn(num_tokens, INDEX_HEADS, device=dev, dtype=torch.float32) + q_scale = torch.tensor([0.37], device=dev, dtype=torch.float32) + softmax_scale = INDEX_HEAD_DIM**-0.5 + head_scale = INDEX_HEADS**-0.5 + q_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) # q_pe: interleaved + idx_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) + + iq_fp8, iw_out, mqa = K.fused_q( + pos, + q_pe, + q_cos_sin, + index_q, + idx_cos_sin, + ql_nope, + q_scale, + index_w, + softmax_scale, + head_scale, + has_indexer=True, + index_rope_interleave=index_interleave, + ) + + s = q_scale.item() + # MQA query: [ql_nope | q_pe RoPE'd (interleaved)], per-tensor fp8. + mqa_nope_ref = (ql_nope.float() / s).to(FP8) + qpe_ref = rope( + q_pe.float(), + pos.unsqueeze(-1).expand(num_tokens, NUM_HEADS), + q_cos_sin, + interleave=True, + ) + mqa_pe_ref = (qpe_ref / s).to(FP8) + assert_fp8(mqa[:, :, :KV_LORA], mqa_nope_ref, "mqa ql_nope") + assert_fp8(mqa[:, :, KV_LORA:], mqa_pe_ref, "mqa q_pe") + + # Indexer-Q: RoPE + UE8M0 fp8 quant; index weights fold in q-scale. + iq_ref = rope( + index_q.float(), + pos.unsqueeze(-1).expand(num_tokens, INDEX_HEADS), + idx_cos_sin, + interleave=index_interleave, + ) + q_ref, scale_ref = ue8m0_quant(iq_ref) + assert_fp8(iq_fp8, q_ref, "indexer-Q fp8") + iw_ref = index_w * scale_ref * softmax_scale * head_scale + torch.testing.assert_close(iw_out, iw_ref, rtol=1e-3, atol=1e-3) + + +@pytest.mark.parametrize("num_tokens", [1, 17, 512]) +def test_fused_q_no_indexer(num_tokens: int): + torch.manual_seed(3) + dev = "cuda" + max_pos = 8192 + pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) + q_pe = torch.randn( + num_tokens, NUM_HEADS, ROPE_DIM, device=dev, dtype=torch.bfloat16 + ) + ql_nope = torch.randn( + num_tokens, NUM_HEADS, KV_LORA, device=dev, dtype=torch.bfloat16 + ) + q_scale = torch.tensor([0.5], device=dev, dtype=torch.float32) + q_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) + + _, _, mqa = K.fused_q( + pos, + q_pe, + q_cos_sin, + None, + None, + ql_nope, + q_scale, + None, + 0.0, + 0.0, + has_indexer=False, + index_rope_interleave=False, + ) + s = q_scale.item() + assert_fp8(mqa[:, :, :KV_LORA], (ql_nope.float() / s).to(FP8), "mqa ql_nope") + qpe_ref = rope( + q_pe.float(), + pos.unsqueeze(-1).expand(num_tokens, NUM_HEADS), + q_cos_sin, + interleave=True, + ) + assert_fp8(mqa[:, :, KV_LORA:], (qpe_ref / s).to(FP8), "mqa q_pe") + + +@pytest.mark.parametrize("num_tokens", [1, 17, 512]) +@pytest.mark.parametrize("has_indexer", [True, False]) +def test_fused_q_bf16_query(num_tokens: int, has_indexer: bool): + """bf16-query path (FlashMLA sparse, SM90/SM100): only the RoPE'd q_pe is + produced (bf16, unquantized); ql_nope is consumed directly by the caller.""" + torch.manual_seed(6) + dev = "cuda" + max_pos = 8192 + pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) % max_pos + + q_pe = torch.randn( + num_tokens, NUM_HEADS, ROPE_DIM, device=dev, dtype=torch.bfloat16 + ) + ql_nope = torch.randn( + num_tokens, NUM_HEADS, KV_LORA, device=dev, dtype=torch.bfloat16 + ) + q_scale = torch.tensor([0.37], device=dev, dtype=torch.float32) + q_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) + + index_q = index_w = idx_cos_sin = None + if has_indexer: + index_q = torch.randn( + num_tokens, INDEX_HEADS, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16 + ) + index_w = torch.randn(num_tokens, INDEX_HEADS, device=dev, dtype=torch.float32) + idx_cos_sin = make_cos_sin(max_pos, ROPE_DIM, dev) + + iq_fp8, iw_out, q_pe_out = K.fused_q( + pos, + q_pe, + q_cos_sin, + index_q, + idx_cos_sin, + ql_nope, + q_scale, + index_w, + INDEX_HEAD_DIM**-0.5, + INDEX_HEADS**-0.5, + has_indexer=has_indexer, + index_rope_interleave=False, + quantize_mqa=False, + ) + + # MQA query: only the RoPE'd q_pe, bf16, unquantized. + assert q_pe_out.dtype == torch.bfloat16 + assert q_pe_out.shape == (num_tokens, NUM_HEADS, ROPE_DIM) + qpe_ref = rope( + q_pe.float(), + pos.unsqueeze(-1).expand(num_tokens, NUM_HEADS), + q_cos_sin, + interleave=True, + ) + assert_bf16(q_pe_out, qpe_ref, "bf16 q_pe RoPE") + + # Indexer-Q is unchanged on this path (still UE8M0 fp8 + folded weights). + if has_indexer: + assert index_q is not None + iq_ref = rope( + index_q.float(), + pos.unsqueeze(-1).expand(num_tokens, INDEX_HEADS), + idx_cos_sin, + interleave=False, + ) + q_ref, scale_ref = ue8m0_quant(iq_ref) + assert_fp8(iq_fp8, q_ref, "indexer-Q fp8 (bf16-query path)") + iw_ref = index_w * scale_ref * (INDEX_HEAD_DIM**-0.5) * (INDEX_HEADS**-0.5) + torch.testing.assert_close(iw_out, iw_ref, rtol=1e-3, atol=1e-3) + + +# ── fused_eh_norm (MTP) ────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("num_tokens", [1, 4, 17, 512]) +def test_fused_eh_norm(num_tokens: int): + torch.manual_seed(4) + dev = "cuda" + # Mix in a position-0 token to exercise the embeds-zeroing branch. + pos = torch.arange(num_tokens, device=dev, dtype=torch.int64) + pos[0] = 0 + embeds = torch.randn(num_tokens, HIDDEN, device=dev, dtype=torch.bfloat16) + prev = torch.randn(num_tokens, HIDDEN, device=dev, dtype=torch.bfloat16) + ew = torch.randn(HIDDEN, device=dev, dtype=torch.bfloat16) + hw = torch.randn(HIDDEN, device=dev, dtype=torch.bfloat16) + + out = K.fused_eh_norm(pos, embeds, prev, ew, hw, EPS) + + masked = torch.where(pos.unsqueeze(-1) == 0, torch.zeros_like(embeds), embeds) + ref = torch.cat([rms_norm(masked, ew), rms_norm(prev, hw)], dim=-1) + assert out.shape == (num_tokens, 2 * HIDDEN) + assert_bf16(out, ref, "eh_norm") diff --git a/tests/kernels/test_fused_deepseek_v4_qnorm_rope_kv_insert.py b/tests/kernels/test_fused_deepseek_v4_qnorm_rope_kv_insert.py index a49ea498e5e0..ed163a0472ad 100644 --- a/tests/kernels/test_fused_deepseek_v4_qnorm_rope_kv_insert.py +++ b/tests/kernels/test_fused_deepseek_v4_qnorm_rope_kv_insert.py @@ -19,17 +19,29 @@ import pytest import torch +from tests.kernels.utils import bf16_ulp_distance, fp8_ulp_distance +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + get_fp8_min_max, +) from vllm.models.deepseek_v4.common.ops import ( dequantize_and_gather_k_cache, quantize_and_insert_k_cache, ) +from vllm.platforms import current_platform # ── Constants matching the kernel ──────────────────────────────────────────── HEAD_DIM = 512 ROPE_DIM = 64 NOPE_DIM = HEAD_DIM - ROPE_DIM # 448 QUANT_BLOCK = 64 -FP8_MAX = 448.0 +# Match the C++ SWA-K encoder: FNUZ on gfx942, OCP elsewhere. +USE_FNUZ = current_platform.is_fp8_fnuz() +_, FP8_MAX = get_fp8_min_max() +# The kernel emits FNUZ-encoded fp8 bytes on gfx942 (rocm_cvt_float_to_fp8_e4m3) +# but stores them into float8_e4m3fn-typed tensors, matching vLLM's ROCm cache +# convention. References must encode under the same scheme and the kernel's +# e4m3fn-typed outputs must be reinterpreted under it before decoding. +FP8_STORE_DTYPE = torch.float8_e4m3fnuz if USE_FNUZ else torch.float8_e4m3fn HEAD_BYTES = NOPE_DIM + ROPE_DIM * 2 + 8 # 448 + 128 + 8 = 584 @@ -67,7 +79,7 @@ def apply_rope_gptj_last_k( head_dim = x.shape[-1] nope_dim = head_dim - rope_dim - cs = cos_sin_cache[positions].to(torch.float32) + cs = cos_sin_cache[positions.long()].to(torch.float32) cos = cs[..., :half] sin = cs[..., half:] @@ -81,10 +93,11 @@ def apply_rope_gptj_last_k( cos = cos.unsqueeze(1) sin = sin.unsqueeze(1) - # Use addcmul (compiles to FMA on CUDA) for the 2x2 rotation. nvcc lowers - # the kernel's `e*c - o*s` to fma(e, c, -o*s); matching that here keeps - # near-cancellation pairs on the same bf16 grid as the kernel output and - # avoids spurious 1-ULP boundary flips at high num_tokens. + # Use addcmul (an FMA) for the 2x2 rotation to mirror the kernel's + # `e*c - o*s` fused form. This keeps the reference close to the kernel, but + # the fp32 reference and the fp32 GPU kernel can still round to bf16 on + # opposite sides of a round-to-nearest tie for a tiny number of elements at + # high positions, so callers compare the RoPE region within 1 bf16 ULP. new_even = torch.addcmul(-odd * sin, even, cos) new_odd = torch.addcmul(odd * cos, even, sin) rope_rotated = torch.stack((new_even, new_odd), dim=-1).reshape(shape) @@ -114,6 +127,18 @@ def _op_available() -> bool: return hasattr(torch.ops._C, "fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert") +def _full_cache_fp8_op_available() -> bool: + return hasattr( + torch.ops._C, "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert" + ) + + +def _full_cache_bf16_op_available() -> bool: + return hasattr( + torch.ops._C, "fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert" + ) + + pytestmark = pytest.mark.skipif( not torch.cuda.is_available() or not _op_available(), reason="CUDA not available or fused DeepseekV4 op not built in", @@ -136,6 +161,57 @@ def _call_fused( ) +def _as_stored_fp8(t: torch.Tensor) -> torch.Tensor: + """Reinterpret a float8_e4m3fn-typed kernel output under the real (FNUZ on + gfx942) encoding the kernel actually wrote, without touching the bytes.""" + return t.contiguous().view(torch.uint8).view(FP8_STORE_DTYPE) + + +def _dequant_cache(k_cache_2d, num_tokens, num_blocks, block_size): + """Round-trip a [num_blocks, block_size*HEAD_BYTES] K-cache back to bf16.""" + device = k_cache_2d.device + out = torch.zeros(1, num_tokens, HEAD_DIM, dtype=torch.bfloat16, device=device) + seq_lens = torch.tensor([num_tokens], dtype=torch.int32, device=device) + block_table = torch.arange(num_blocks, dtype=torch.int32, device=device).unsqueeze( + 0 + ) + k_cache_3d = k_cache_2d.view(num_blocks, block_size, HEAD_BYTES) + dequantize_and_gather_k_cache( + out, + k_cache_3d, + seq_lens, + None, + block_table, + block_size, + offset=0, + use_fnuz=USE_FNUZ, + ) + return out[0, :num_tokens] + + +def _assert_kv_cache_parity( + k_cache_fused, k_cache_ref, num_tokens, num_blocks, block_size +): + """Assert the fused and reference K-caches agree after decoding. + + The NoPE region is deterministic UE8M0 FP8, so its round-trip must be + bit-identical. The RoPE region is stored as bf16 after an fp32 rotation: + the GPU kernel and the PyTorch reference can fall on opposite sides of a + round-to-nearest tie and differ by at most one bf16 ULP. (Spot checks show + the kernel value is the correctly-rounded one; the fp32 torch reference is + the one that lands on the wrong side near a midpoint.) Allow <=1 ULP there. + """ + rec_fused = _dequant_cache(k_cache_fused, num_tokens, num_blocks, block_size) + rec_ref = _dequant_cache(k_cache_ref, num_tokens, num_blocks, block_size) + torch.testing.assert_close( + rec_fused[:, :NOPE_DIM], rec_ref[:, :NOPE_DIM], rtol=0, atol=0 + ) + max_ulp = int( + bf16_ulp_distance(rec_fused[:, NOPE_DIM:], rec_ref[:, NOPE_DIM:]).max().item() + ) + assert max_ulp <= 1, f"RoPE bf16 region differs by {max_ulp} ULP (>1)" + + # ── Test 1: Q path numerical parity ────────────────────────────────────────── @@ -229,7 +305,7 @@ def test_kv_path_matches_reference(num_tokens: int, block_size: int): num_blocks, block_size * HEAD_BYTES, dtype=torch.uint8, device=device ) quantize_and_insert_k_cache( - kv_ref, k_cache_ref, slot_mapping, block_size=block_size + kv_ref, k_cache_ref, slot_mapping, block_size=block_size, use_fnuz=USE_FNUZ ) # ── Fused path (dummy q, padded to FlashMLA's min head count 64) ─────── @@ -261,7 +337,14 @@ def _dequant(k_cache_2d): # gather_lens arg is None (use seq_lens) k_cache_3d = k_cache_2d.view(num_blocks, block_size, HEAD_BYTES) dequantize_and_gather_k_cache( - out, k_cache_3d, seq_lens, None, block_table, block_size, offset=0 + out, + k_cache_3d, + seq_lens, + None, + block_table, + block_size, + offset=0, + use_fnuz=USE_FNUZ, ) return out[0, :num_tokens] @@ -285,12 +368,10 @@ def _dequant(k_cache_2d): f"fused NoPE token {t} diff {diff_fused} > {max_allowed}" ) - # RoPE region: bf16 stored exactly → zero diff. - rope_diff = (recovered_fused[:, NOPE_DIM:] - kv_ref[:, NOPE_DIM:]).abs().max() - assert rope_diff.item() == 0.0, f"RoPE portion not exact: {rope_diff.item()}" - - # Exact byte equality of the two cache buffers — strong parity. - torch.testing.assert_close(k_cache_fused, k_cache_ref, rtol=0, atol=0) + # Strong parity: NoPE FP8 round-trip bit-identical, RoPE bf16 within 1 ULP. + _assert_kv_cache_parity( + k_cache_fused, k_cache_ref, num_tokens, num_blocks, block_size + ) # ── Test 2b: DP padding (slot_mapping shorter than q/kv) ───────────────────── @@ -324,7 +405,7 @@ def test_kv_path_with_dp_padding(num_tokens: int, pad: int, block_size: int): num_blocks, block_size * HEAD_BYTES, dtype=torch.uint8, device=device ) quantize_and_insert_k_cache( - kv_ref, k_cache_ref, slot_mapping, block_size=block_size + kv_ref, k_cache_ref, slot_mapping, block_size=block_size, use_fnuz=USE_FNUZ ) # Fused: pass full-sized q/kv/positions, shorter slot_mapping. @@ -342,7 +423,9 @@ def test_kv_path_with_dp_padding(num_tokens: int, pad: int, block_size: int): block_size, ) - torch.testing.assert_close(k_cache_fused, k_cache_ref, rtol=0, atol=0) + _assert_kv_cache_parity( + k_cache_fused, k_cache_ref, num_tokens, num_blocks, block_size + ) # ── Test 3: combined single-call Q + KV parity ─────────────────────────────── @@ -391,7 +474,7 @@ def test_combined_q_and_kv( num_blocks, block_size * HEAD_BYTES, dtype=torch.uint8, device=device ) quantize_and_insert_k_cache( - kv_ref, k_cache_ref, slot_mapping, block_size=block_size + kv_ref, k_cache_ref, slot_mapping, block_size=block_size, use_fnuz=USE_FNUZ ) # Fused single call. @@ -414,4 +497,263 @@ def test_combined_q_and_kv( assert pad_region.abs().max().item() == 0.0, ( "padded head slots must be exact zero" ) + _assert_kv_cache_parity( + k_cache_fused, k_cache_ref, num_tokens, num_blocks, block_size + ) + + +# ── Full-cache (FlashInfer) path parity ────────────────────────────────────── + + +def _call_full_cache_fp8_fused( + q, + kv, + q_fp8, + k_cache, + slot_mapping, + positions, + cos_sin_cache, + fp8_scale, + q_fp8_scale_inv, + eps, + bs, +): + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert( + q, + kv, + q_fp8, + k_cache, + slot_mapping, + positions.long(), + cos_sin_cache, + fp8_scale, + q_fp8_scale_inv, + eps, + bs, + ) + + +def _call_full_cache_bf16_fused( + q, + kv, + k_cache, + slot_mapping, + positions, + cos_sin_cache, + eps, + bs, +): + torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert( + q, + kv, + k_cache, + slot_mapping, + positions.long(), + cos_sin_cache, + eps, + bs, + ) + + +def _fp8_full_cache_reference( + q, + kv, + k_cache, + q_fp8, + slot_mapping, + positions, + cos_sin_cache, + eps, + block_size, + fp8_scale, + q_fp8_scale_inv, +): + q_ref = rmsnorm_no_weight(q, eps) + q_ref = apply_rope_gptj_last_k(q_ref, positions, cos_sin_cache) + q_fp8.copy_( + torch.clamp(q_ref.float() * q_fp8_scale_inv, -FP8_MAX, FP8_MAX).to( + FP8_STORE_DTYPE + ) + ) + + kv_ref = apply_rope_gptj_last_k(kv, positions, cos_sin_cache) + valid = slot_mapping >= 0 + slots = slot_mapping[valid] + block_idx = slots // block_size + pos_in_block = slots % block_size + k_cache[block_idx, pos_in_block] = torch.clamp( + kv_ref[valid].float() / fp8_scale, -FP8_MAX, FP8_MAX + ).to(FP8_STORE_DTYPE) + + +def _bf16_full_cache_reference( + q, + kv, + k_cache, + slot_mapping, + positions, + cos_sin_cache, + eps, + block_size, +): + q_ref = rmsnorm_no_weight(q, eps) + # Kernel keeps RMSNorm+RoPE in fp32 and rounds to bf16 once at the store. + q_ref = apply_rope_gptj_last_k(q_ref, positions, cos_sin_cache).to(q.dtype) + + kv_ref = apply_rope_gptj_last_k(kv, positions, cos_sin_cache) + valid = slot_mapping >= 0 + slots = slot_mapping[valid] + block_idx = slots // block_size + pos_in_block = slots % block_size + k_cache[block_idx, pos_in_block] = kv_ref[valid] + return q_ref + + +@pytest.mark.skipif( + not _full_cache_fp8_op_available(), + reason="full-cache per-tensor FP8 DeepseekV4 op not built in", +) +@pytest.mark.parametrize("num_tokens", [4, 17]) +@pytest.mark.parametrize("n_heads", [8, 17]) +@pytest.mark.parametrize("positions_dtype", [torch.int32, torch.int64]) +def test_full_cache_per_tensor_fp8_matches_reference( + num_tokens: int, + n_heads: int, + positions_dtype: torch.dtype, +): + torch.manual_seed(4) + device = "cuda" + dtype = torch.bfloat16 + eps = 1e-6 + block_size = 16 + max_pos = 4096 + + q = torch.randn(num_tokens, n_heads, HEAD_DIM, dtype=dtype, device=device) + kv = torch.randn(num_tokens, HEAD_DIM, dtype=dtype, device=device) + positions = torch.arange(num_tokens, dtype=positions_dtype, device=device) + cos_sin_cache = make_cos_sin_cache(max_pos, ROPE_DIM, torch.float32, device) + + num_blocks = (num_tokens + block_size - 1) // block_size + 1 + slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device) + fp8_scale = torch.tensor([1.0], dtype=torch.float32, device=device) + q_fp8_scale_inv = torch.tensor([1.0], dtype=torch.float32, device=device) + + # References are encoded under the scheme the kernel actually writes + # (FNUZ on gfx942); the kernel's own outputs must stay float8_e4m3fn-typed + # because the op asserts that dtype. + q_fp8_ref = torch.empty_like(q, dtype=FP8_STORE_DTYPE) + q_fp8_fused = torch.empty_like(q, dtype=torch.float8_e4m3fn) + k_cache_ref = torch.zeros( + num_blocks, block_size, HEAD_DIM, dtype=FP8_STORE_DTYPE, device=device + ) + k_cache_fused = torch.zeros( + num_blocks, block_size, HEAD_DIM, dtype=torch.float8_e4m3fn, device=device + ) + + _fp8_full_cache_reference( + q, + kv, + k_cache_ref, + q_fp8_ref, + slot_mapping, + positions, + cos_sin_cache, + eps, + block_size, + fp8_scale, + q_fp8_scale_inv, + ) + _call_full_cache_fp8_fused( + q.clone(), + kv, + q_fp8_fused, + k_cache_fused, + slot_mapping, + positions, + cos_sin_cache, + fp8_scale, + q_fp8_scale_inv, + eps, + block_size, + ) + + # Q is RMSNorm(no-weight)+RoPE in fp32 before fp8 quant; the RMSNorm + # reduction and RoPE rotation can land the kernel and the torch reference on + # opposite sides of an fp8 round-to-nearest tie, so allow <=1 fp8 ULP. + q_fused = _as_stored_fp8(q_fp8_fused) + q_max_ulp = int(fp8_ulp_distance(q_fused, q_fp8_ref).max().item()) + assert q_max_ulp <= 1, f"Q fp8 differs by {q_max_ulp} ULP (>1)" + + # K-cache NoPE region [0, NOPE_DIM) is a deterministic per-tensor fp8 quant + # of the (un-rotated) KV input, so it must be bit-identical. The RoPE region + # [NOPE_DIM, HEAD_DIM) is rotated in fp32 and may differ by <=1 fp8 ULP. + k_fused = _as_stored_fp8(k_cache_fused) + torch.testing.assert_close( + k_fused[..., :NOPE_DIM].float(), + k_cache_ref[..., :NOPE_DIM].float(), + rtol=0, + atol=0, + ) + k_max_ulp = int( + fp8_ulp_distance(k_fused[..., NOPE_DIM:], k_cache_ref[..., NOPE_DIM:]) + .max() + .item() + ) + assert k_max_ulp <= 1, f"K-cache RoPE fp8 differs by {k_max_ulp} ULP (>1)" + + +@pytest.mark.skipif( + not _full_cache_bf16_op_available(), + reason="full-cache BF16 DeepseekV4 op not built in", +) +@pytest.mark.parametrize("num_tokens", [4, 17]) +@pytest.mark.parametrize("n_heads", [8, 17]) +@pytest.mark.parametrize("positions_dtype", [torch.int32, torch.int64]) +def test_full_cache_bf16_matches_reference( + num_tokens: int, + n_heads: int, + positions_dtype: torch.dtype, +): + torch.manual_seed(5) + device = "cuda" + dtype = torch.bfloat16 + eps = 1e-6 + block_size = 16 + max_pos = 4096 + + q = torch.randn(num_tokens, n_heads, HEAD_DIM, dtype=dtype, device=device) + kv = torch.randn(num_tokens, HEAD_DIM, dtype=dtype, device=device) + positions = torch.arange(num_tokens, dtype=positions_dtype, device=device) + cos_sin_cache = make_cos_sin_cache(max_pos, ROPE_DIM, torch.float32, device) + + num_blocks = (num_tokens + block_size - 1) // block_size + 1 + slot_mapping = torch.arange(num_tokens, dtype=torch.int64, device=device) + + q_fused = q.clone() + k_cache_ref = torch.zeros( + num_blocks, block_size, HEAD_DIM, dtype=torch.bfloat16, device=device + ) + k_cache_fused = torch.zeros_like(k_cache_ref) + q_ref = _bf16_full_cache_reference( + q, + kv, + k_cache_ref, + slot_mapping, + positions, + cos_sin_cache, + eps, + block_size, + ) + _call_full_cache_bf16_fused( + q_fused, + kv, + k_cache_fused, + slot_mapping, + positions, + cos_sin_cache, + eps, + block_size, + ) + + torch.testing.assert_close(q_fused, q_ref, rtol=1e-2, atol=1e-2) torch.testing.assert_close(k_cache_fused, k_cache_ref, rtol=0, atol=0) diff --git a/tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py b/tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py new file mode 100644 index 000000000000..3f79d4f5db0c --- /dev/null +++ b/tests/kernels/test_fused_minimax_m3_qknorm_rope_kv_insert.py @@ -0,0 +1,376 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit test for the horizontally-fused MiniMax-M3 attention pre-processing +kernel: + + fused_minimax_m3_qknorm_rope_kv_insert + - q / k / index_q / index_k: Gemma RMSNorm + partial NeoX RoPE (in place) + - sparse (insert) mode: scatter k/v into the paged bf16 KV cache and the + index key into the index cache by its own slot mapping. + +Reference: PyTorch Gemma RMSNorm with the same dtype materialization boundary +as the unfused path, followed by vLLM CUDA rotary_embedding-style NeoX RoPE. +""" + +import pytest +import torch + +import vllm._custom_ops as ops + +HEAD_DIM = 128 +ROTARY_DIM = 64 + + +def _op_available() -> bool: + return hasattr(torch.ops._C, "fused_minimax_m3_qknorm_rope_kv_insert") + + +pytestmark = pytest.mark.skipif( + not torch.cuda.is_available() or not _op_available(), + reason="CUDA not available or fused MiniMax-M3 op not built in", +) + + +def make_cos_sin_cache(max_pos, rotary_dim, base, dtype, device): + inv_freq = 1.0 / ( + base + ** ( + torch.arange(0, rotary_dim, 2, dtype=torch.float32, device=device) + / rotary_dim + ) + ) + t = torch.arange(max_pos, dtype=torch.float32, device=device) + freqs = torch.einsum("i,j->ij", t, inv_freq) # [max_pos, rotary_dim/2] + cache = torch.cat((freqs.cos(), freqs.sin()), dim=-1) # [max_pos, rotary_dim] + return cache.to(dtype) + + +def gemma_rmsnorm(x, weight, eps): + """x: [..., 128]; weight: [128]. Returns original dtype.""" + xf = x.float() + var = xf.pow(2).mean(dim=-1, keepdim=True) + out = xf * torch.rsqrt(var + eps) + out = out * (1.0 + weight.float()) + return out.to(x.dtype) + + +def apply_rope_neox_partial(x, positions, cos_sin_cache, rotary_dim): + """NeoX-style RoPE on the leading rotary_dim dims; rest pass through. + + x: [num_tokens, num_heads, head_dim] + cos_sin_cache: [max_pos, rotary_dim] (cos||sin), read as float (matches the + kernel, which loads the bf16 cache and converts to fp32). + """ + half = rotary_dim // 2 + cs = cos_sin_cache[positions].float() # [num_tokens, rotary_dim] + cos = cs[..., :half].unsqueeze(1) # [nt, 1, half] + sin = cs[..., half:].unsqueeze(1) + + rot = x[..., :rotary_dim].float() + x1 = rot[..., :half] + x2 = rot[..., half:] + o1 = x1 * cos - x2 * sin + o2 = x2 * cos + x1 * sin + out = x.clone() + out[..., :half] = o1 + out[..., half:rotary_dim] = o2 + return out.to(x.dtype) + + +def norm_rope_ref(x, weight, positions, cos_sin_cache, eps): + """[nt, nheads, 128] -> Gemma norm + neox partial rope.""" + normed = gemma_rmsnorm(x, weight, eps) + roped = apply_rope_neox_partial(normed, positions, cos_sin_cache, ROTARY_DIM) + return roped + + +# ── Test 1: dense mode (norm+rope only, no index, no insert) ───────────────── + + +@pytest.mark.parametrize("num_tokens", [1, 7, 64, 513]) +@pytest.mark.parametrize("num_heads,num_kv_heads", [(8, 2), (16, 4), (64, 4)]) +def test_dense_norm_rope(num_tokens, num_heads, num_kv_heads): + torch.manual_seed(0) + device, dtype, eps = "cuda", torch.bfloat16, 1e-6 + base, max_pos = 5_000_000.0, 4096 + + q_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + k_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device) + positions = torch.randint( + 0, max_pos, (num_tokens,), dtype=torch.int64, device=device + ) + + qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM + qkv = torch.randn(num_tokens, qsz + 2 * kvsz, dtype=dtype, device=device) + qkv_orig = qkv.clone() + + ops.fused_minimax_m3_qknorm_rope_kv_insert( + qkv, + q_w, + k_w, + cos_sin, + positions, + num_heads, + num_kv_heads, + ROTARY_DIM, + eps, + kv_cache_dtype="auto", + ) + q_out, k_out, v_out = qkv.split([qsz, kvsz, kvsz], dim=-1) + + q_in, k_in, v_in = qkv_orig.split([qsz, kvsz, kvsz], dim=-1) + q_ref = norm_rope_ref( + q_in.view(num_tokens, num_heads, HEAD_DIM), q_w, positions, cos_sin, eps + ).view(num_tokens, qsz) + k_ref = norm_rope_ref( + k_in.view(num_tokens, num_kv_heads, HEAD_DIM), + k_w, + positions, + cos_sin, + eps, + ).view(num_tokens, kvsz) + + torch.testing.assert_close(q_out, q_ref, rtol=1e-2, atol=1e-2) + torch.testing.assert_close(k_out, k_ref, rtol=1e-2, atol=1e-2) + # V is untouched. + torch.testing.assert_close(v_out, v_in, rtol=0, atol=0) + + +# ── Test 2: sparse mode (full: index branch + cache inserts) ───────────────── + + +@pytest.mark.parametrize("num_tokens", [1, 7, 64, 513]) +@pytest.mark.parametrize("block_size", [16, 64]) +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"]) +def test_sparse_full(num_tokens, block_size, kv_cache_dtype): + torch.manual_seed(1) + device, dtype, eps = "cuda", torch.bfloat16, 1e-6 + base, max_pos = 5_000_000.0, 4096 + num_heads, num_kv_heads, num_idx_heads = 16, 4, 4 + + q_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + k_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + iq_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + ik_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device) + positions = torch.randint( + 0, max_pos, (num_tokens,), dtype=torch.int64, device=device + ) + + qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM + iqsz, iksz = num_idx_heads * HEAD_DIM, HEAD_DIM + # Single fused tensor packing [q | k | v | index_q | index_k]. + qkv = torch.randn( + num_tokens, qsz + 2 * kvsz + iqsz + iksz, dtype=dtype, device=device + ) + qkv_orig = qkv.clone() + splits = [qsz, kvsz, kvsz, iqsz, iksz] + + num_blocks = (num_tokens + block_size - 1) // block_size + 1 + kv_cache_storage_dtype = torch.uint8 if kv_cache_dtype == "fp8" else dtype + kv_cache = torch.zeros( + num_blocks, + 2, + block_size, + num_kv_heads, + HEAD_DIM, + dtype=kv_cache_storage_dtype, + device=device, + ) + index_cache = torch.zeros( + num_blocks, block_size, HEAD_DIM, dtype=dtype, device=device + ) + slot_mapping = torch.randperm( + num_blocks * block_size, dtype=torch.int64, device=device + )[:num_tokens] + index_slot_mapping = torch.roll(slot_mapping, shifts=1) + + # Contiguous gather targets: the kernel writes the normed/roped q and + # index_q here (de-interleaved from the packed qkv); k/v/index_k stay in + # place inside qkv and are scatter-inserted into the caches. + q_out = torch.empty(num_tokens, qsz, dtype=dtype, device=device) + index_q = torch.empty(num_tokens, iqsz, dtype=dtype, device=device) + + ops.fused_minimax_m3_qknorm_rope_kv_insert( + qkv, + q_w, + k_w, + cos_sin, + positions, + num_heads, + num_kv_heads, + ROTARY_DIM, + eps, + iq_w, + ik_w, + num_idx_heads, + slot_mapping, + index_slot_mapping, + kv_cache, + index_cache, + block_size, + q_out, + index_q, + kv_cache_dtype, + ) + + # ── norm+rope parity. q/index_q land in their gather buffers; k/index_k are + # rewritten in place inside qkv. ── + _, k_out, v_out, _, index_k = qkv.split(splits, dim=-1) + q_in, k_in, v_in, iq_orig, ik_orig = qkv_orig.split(splits, dim=-1) + q_ref = norm_rope_ref( + q_in.view(num_tokens, num_heads, HEAD_DIM), q_w, positions, cos_sin, eps + ).view(num_tokens, qsz) + k_ref = norm_rope_ref( + k_in.view(num_tokens, num_kv_heads, HEAD_DIM), + k_w, + positions, + cos_sin, + eps, + ).view(num_tokens, kvsz) + iq_ref = norm_rope_ref( + iq_orig.view(num_tokens, num_idx_heads, HEAD_DIM), + iq_w, + positions, + cos_sin, + eps, + ).view(num_tokens, num_idx_heads * HEAD_DIM) + ik_ref = norm_rope_ref( + ik_orig.view(num_tokens, 1, HEAD_DIM), ik_w, positions, cos_sin, eps + ).view(num_tokens, HEAD_DIM) + + torch.testing.assert_close(q_out, q_ref, rtol=1e-2, atol=1e-2) + torch.testing.assert_close(k_out, k_ref, rtol=1e-2, atol=1e-2) + torch.testing.assert_close(index_q, iq_ref, rtol=1e-2, atol=1e-2) + torch.testing.assert_close(index_k, ik_ref, rtol=1e-2, atol=1e-2) + + # ── Cache inserts. ── + # Main cache layout is [num_blocks, 2, block_size, num_kv_heads, head_dim] + # (the K/V axis sits *before* block_size); index cache is [nb, bs, head_dim]. + k_ref_h = k_ref.view(num_tokens, num_kv_heads, HEAD_DIM) + v_ref_h = v_in.view(num_tokens, num_kv_heads, HEAD_DIM) # v is raw (no norm/rope) + if kv_cache_dtype == "fp8": + expected_kv_cache = torch.zeros_like(kv_cache) + scale = torch.ones((), device=device) + ops.reshape_and_cache_flash( + k_out.view(num_tokens, num_kv_heads, HEAD_DIM), + v_out.view(num_tokens, num_kv_heads, HEAD_DIM), + expected_kv_cache[:, 0], + expected_kv_cache[:, 1], + slot_mapping, + kv_cache_dtype, + scale, + scale, + ) + torch.testing.assert_close(kv_cache, expected_kv_cache, rtol=0, atol=0) + else: + for t in range(num_tokens): + s = slot_mapping[t].item() + b, pos = s // block_size, s % block_size + torch.testing.assert_close( + kv_cache[b, 0, pos], k_ref_h[t], rtol=1e-2, atol=1e-2 + ) + torch.testing.assert_close(kv_cache[b, 1, pos], v_ref_h[t], rtol=0, atol=0) + + expected_index_cache = torch.zeros_like(index_cache).view(-1, HEAD_DIM) + expected_index_cache[index_slot_mapping] = index_k + torch.testing.assert_close( + index_cache.view(-1, HEAD_DIM), expected_index_cache, rtol=0, atol=0 + ) + + +# ── Test 3: fp8 (e4m3) index outputs ───────────────────────────────────────── +# The fp8 score path stores index_q and the index-K cache as e4m3 while q/k/v + +# q_out stay bf16. Asserts: (1) q/k/v/q_out are bit-identical to the bf16 run +# (the index dtype must not perturb the main branch), and (2) the e4m3 index +# outputs dequantize close to the bf16 reference. + + +@pytest.mark.skipif( + not torch.cuda.is_available() or torch.cuda.get_device_capability() < (8, 9), + reason="e4m3 conversion requires CUDA SM89+.", +) +@pytest.mark.parametrize("num_tokens", [1, 7, 64, 513]) +@pytest.mark.parametrize("block_size", [16, 64]) +def test_sparse_full_fp8_index(num_tokens, block_size): + torch.manual_seed(1) + device, dtype, eps = "cuda", torch.bfloat16, 1e-6 + base, max_pos = 5_000_000.0, 4096 + num_heads, num_kv_heads, num_idx_heads = 16, 4, 4 + + q_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + k_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + iq_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + ik_w = torch.randn(HEAD_DIM, dtype=dtype, device=device) * 0.1 + cos_sin = make_cos_sin_cache(max_pos, ROTARY_DIM, base, dtype, device) + positions = torch.randint( + 0, max_pos, (num_tokens,), dtype=torch.int64, device=device + ) + + qsz, kvsz = num_heads * HEAD_DIM, num_kv_heads * HEAD_DIM + iqsz, iksz = num_idx_heads * HEAD_DIM, HEAD_DIM + qkv0 = torch.randn( + num_tokens, qsz + 2 * kvsz + iqsz + iksz, dtype=dtype, device=device + ) + + num_blocks = (num_tokens + block_size - 1) // block_size + 1 + slot_mapping = torch.randperm( + num_blocks * block_size, dtype=torch.int64, device=device + )[:num_tokens] + index_slot_mapping = torch.roll(slot_mapping, shifts=1) + + def run(index_dtype): + qkv = qkv0.clone() + kv_cache = torch.zeros( + num_blocks, + 2, + block_size, + num_kv_heads, + HEAD_DIM, + dtype=dtype, + device=device, + ) + index_cache = torch.zeros( + num_blocks, block_size, HEAD_DIM, dtype=index_dtype, device=device + ) + q_out = torch.empty(num_tokens, qsz, dtype=dtype, device=device) + index_q = torch.empty(num_tokens, iqsz, dtype=index_dtype, device=device) + ops.fused_minimax_m3_qknorm_rope_kv_insert( + qkv, + q_w, + k_w, + cos_sin, + positions, + num_heads, + num_kv_heads, + ROTARY_DIM, + eps, + iq_w, + ik_w, + num_idx_heads, + slot_mapping, + index_slot_mapping, + kv_cache, + index_cache, + block_size, + q_out, + index_q, + ) + return qkv, kv_cache, index_cache, q_out, index_q + + qkv_bf, kvc_bf, idxc_bf, qo_bf, iq_bf = run(torch.bfloat16) + qkv_fp, kvc_fp, idxc_fp, qo_fp, iq_fp = run(torch.float8_e4m3fn) + + assert iq_fp.dtype == torch.float8_e4m3fn + assert idxc_fp.dtype == torch.float8_e4m3fn + + # (1) The main branch (q/k/v in qkv, q_out, kv cache) must be bit-identical: + # the index output dtype must not perturb anything else. + torch.testing.assert_close(qo_fp, qo_bf, rtol=0, atol=0) + torch.testing.assert_close(qkv_fp, qkv_bf, rtol=0, atol=0) + torch.testing.assert_close(kvc_fp, kvc_bf, rtol=0, atol=0) + + # (2) Dequantized e4m3 index outputs match the bf16 reference within fp8 ulp. + torch.testing.assert_close(iq_fp.float(), iq_bf.float(), rtol=0.13, atol=0.05) + torch.testing.assert_close(idxc_fp.float(), idxc_bf.float(), rtol=0.13, atol=0.05) diff --git a/tests/kernels/test_fused_qk_norm_rope_gate.py b/tests/kernels/test_fused_qk_norm_rope_gate.py new file mode 100644 index 000000000000..09ec90d4884b --- /dev/null +++ b/tests/kernels/test_fused_qk_norm_rope_gate.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +from vllm.model_executor.layers.fused_qk_norm_rope import fused_qk_rmsnorm_rope_gate +from vllm.model_executor.layers.rotary_embedding import RotaryEmbedding +from vllm.platforms import current_platform +from vllm.utils.torch_utils import set_random_seed + +# Qwen/Qwen3.6-27B config (huggingface.co/Qwen/Qwen3.6-27B), TP=1 shapes. +NUM_Q_HEADS = 24 +NUM_KV_HEADS = 4 +HEAD_DIM = 256 +PARTIAL_ROTARY_FACTOR = 0.25 +ROTARY_DIM = int(HEAD_DIM * PARTIAL_ROTARY_FACTOR) # 64 +RMS_NORM_EPS = 1e-6 +MAX_POSITION_EMBEDDINGS = 262144 +ROPE_THETA = 10000000.0 + +DTYPES = [torch.bfloat16] +SEEDS = [13] +NUM_TOKENS = [1, 4, 37] + + +def _ref_qk_rmsnorm_rope_gate( + q_gate: torch.Tensor, + k: torch.Tensor, + q_gamma: torch.Tensor, + k_gamma: torch.Tensor, + cos_sin_cache: torch.Tensor, + positions: torch.Tensor, + eps: float, + num_q_heads: int, + num_kv_heads: int, + head_dim: int, + rotary_dim: int, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """PyTorch reference: split + RMSNorm + partial NeoX RoPE + gate extraction. + + Matches ``fused_qk_rmsnorm_rope_gate``'s contract: ``q_gamma`` / ``k_gamma`` + are the already-adjusted effective gammas (for GemmaRMSNorm the caller + has done ``weight + 1`` before passing them in). + """ + n_tokens = q_gate.shape[0] + half = rotary_dim // 2 + + # Per head the q projection is laid out as [q | gate]. + q_gate = q_gate.view(n_tokens, num_q_heads, 2 * head_dim) + q = q_gate[..., :head_dim] + gate = q_gate[..., head_dim:].reshape(n_tokens, num_q_heads * head_dim) + k = k.view(n_tokens, num_kv_heads, head_dim) + + def rms_norm(x: torch.Tensor, gamma: torch.Tensor) -> torch.Tensor: + orig_dtype = x.dtype + x = x.float() + var = x.pow(2).mean(dim=-1, keepdim=True) + x = x * torch.rsqrt(var + eps) + return (x * gamma.float()).to(orig_dtype) + + q = rms_norm(q, q_gamma) + k = rms_norm(k, k_gamma) + + # Partial NeoX RoPE on the first ``rotary_dim`` elements of each head; + # cos_sin_cache row is packed as [cos(half) | sin(half)]. + pos = positions.view(-1) + cos = cos_sin_cache[pos, :half].float()[:, None, :] + sin = cos_sin_cache[pos, half:rotary_dim].float()[:, None, :] + + def rope(x: torch.Tensor) -> torch.Tensor: + x_rot, x_pass = x[..., :rotary_dim], x[..., rotary_dim:] + x1 = x_rot[..., :half].float() + x2 = x_rot[..., half:].float() + o1 = x1 * cos - x2 * sin + o2 = x2 * cos + x1 * sin + rotated = torch.cat([o1, o2], dim=-1).to(x.dtype) + return torch.cat([rotated, x_pass], dim=-1) + + q = rope(q).reshape(n_tokens, num_q_heads * head_dim) + k = rope(k).reshape(n_tokens, num_kv_heads * head_dim) + return q, k, gate + + +@pytest.mark.skipif( + not current_platform.is_cuda_alike(), + reason="fused_qk_rmsnorm_rope_gate Triton kernel requires CUDA/ROCm", +) +@pytest.mark.parametrize("dtype", DTYPES) +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("num_tokens", NUM_TOKENS) +@torch.inference_mode() +def test_fused_qk_norm_rope_gate_matches_reference( + default_vllm_config, + dtype: torch.dtype, + seed: int, + num_tokens: int, +): + device = torch.device("cuda", torch.accelerator.current_device_index()) + torch.set_default_device(device) + set_random_seed(seed) + + q_gate = torch.randn( + num_tokens, NUM_Q_HEADS * 2 * HEAD_DIM, dtype=dtype, device=device + ) + k = torch.randn(num_tokens, NUM_KV_HEADS * HEAD_DIM, dtype=dtype, device=device) + # GemmaRMSNorm-style: the kernel takes the effective gamma (weight + 1). + q_gamma = ( + torch.empty(HEAD_DIM, dtype=dtype, device=device).normal_(mean=0.0, std=0.1) + + 1.0 + ) + k_gamma = ( + torch.empty(HEAD_DIM, dtype=dtype, device=device).normal_(mean=0.0, std=0.1) + + 1.0 + ) + + # fused_qk_rmsnorm_rope_gate only handles NeoX-style RoPE. + rope = RotaryEmbedding( + head_size=HEAD_DIM, + rotary_dim=ROTARY_DIM, + max_position_embeddings=MAX_POSITION_EMBEDDINGS, + base=ROPE_THETA, + is_neox_style=True, + dtype=dtype, + ).to(device) + positions = torch.arange(num_tokens, dtype=torch.long, device=device) + + q_ref, k_ref, gate_ref = _ref_qk_rmsnorm_rope_gate( + q_gate, + k, + q_gamma, + k_gamma, + rope.cos_sin_cache, + positions, + RMS_NORM_EPS, + NUM_Q_HEADS, + NUM_KV_HEADS, + HEAD_DIM, + ROTARY_DIM, + ) + q_out, k_out, gate_out = fused_qk_rmsnorm_rope_gate( + q_gate, + k, + q_gamma, + k_gamma, + rope.cos_sin_cache, + positions, + RMS_NORM_EPS, + NUM_Q_HEADS, + NUM_KV_HEADS, + HEAD_DIM, + ROTARY_DIM, + ) + + atol, rtol = 2e-3, 5e-3 + torch.testing.assert_close(q_out, q_ref, atol=atol, rtol=rtol) + torch.testing.assert_close(k_out, k_ref, atol=atol, rtol=rtol) + # gate is a verbatim copy of the source slice — must match bit-exactly. + torch.testing.assert_close(gate_out, gate_ref, atol=0, rtol=0) diff --git a/tests/kernels/test_mhc_kernels.py b/tests/kernels/test_mhc_kernels.py index 0e0e3769f497..2bdce9f9c14d 100644 --- a/tests/kernels/test_mhc_kernels.py +++ b/tests/kernels/test_mhc_kernels.py @@ -8,8 +8,8 @@ _tilelang_hc_prenorm_gemm, _torch_hc_prenorm_gemm, ) +from vllm.model_executor.layers.mhc import HAS_TILELANG_MHC from vllm.platforms import current_platform -from vllm.utils.import_utils import has_tilelang from vllm.utils.torch_utils import set_random_seed DEVICE = current_platform.device_type @@ -97,8 +97,8 @@ def hc_head_ref( @pytest.mark.skipif( - not (current_platform.is_cuda_alike() and has_tilelang()), - reason="CUDA or ROCm and tilelang required", + not HAS_TILELANG_MHC, + reason="TileLang MHC support required", ) @pytest.mark.parametrize("num_tokens", [1, 4, 8, 128]) @pytest.mark.parametrize("hidden_size", [4096, 7168]) @@ -150,8 +150,8 @@ def test_mhc_pre_tilelang(num_tokens, hidden_size, hc_mult): @pytest.mark.skipif( - not (current_platform.is_cuda_alike() and has_tilelang()), - reason="CUDA or ROCm and tilelang required", + not HAS_TILELANG_MHC, + reason="TileLang MHC support required", ) @pytest.mark.parametrize( ("num_tokens", "hidden_size"), @@ -190,8 +190,8 @@ def test_hc_prenorm_gemm_tilelang(num_tokens, hidden_size): @pytest.mark.skipif( - not (current_platform.is_cuda_alike() and has_tilelang()), - reason="CUDA or ROCm and tilelang required", + not HAS_TILELANG_MHC, + reason="TileLang MHC support required", ) @pytest.mark.parametrize("num_tokens", [1, 4, 8, 128]) @pytest.mark.parametrize("hidden_size", [4096, 7168]) @@ -217,8 +217,8 @@ def test_mhc_post_tilelang(num_tokens, hidden_size, hc_mult): @pytest.mark.skipif( - not (current_platform.is_cuda_alike() and has_tilelang()), - reason="CUDA or ROCm and tilelang required", + not HAS_TILELANG_MHC, + reason="TileLang MHC support required", ) @pytest.mark.parametrize("num_tokens", [1, 4, 8, 128]) @pytest.mark.parametrize("hidden_size", [4096, 7168]) @@ -324,8 +324,8 @@ def test_hc_head_triton(num_tokens, hidden_size, hc_mult): @pytest.mark.skipif( - not (current_platform.is_cuda_alike() and has_tilelang()), - reason="CUDA or ROCm and tilelang required", + not HAS_TILELANG_MHC, + reason="TileLang MHC support required", ) @pytest.mark.parametrize("num_tokens", [1, 4, 8, 128]) @pytest.mark.parametrize("hidden_size", [4096, 7168]) diff --git a/tests/kernels/test_minimax_m3_amd_ops.py b/tests/kernels/test_minimax_m3_amd_ops.py new file mode 100644 index 000000000000..60fbf53d4ad9 --- /dev/null +++ b/tests/kernels/test_minimax_m3_amd_ops.py @@ -0,0 +1,518 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Reference-vs-optimized unit tests for the MiniMax-M3 AMD/ROCm fused kernels. + +Each optimized kernel added for the ROCm port has a slow PyTorch reference; the +tests assert the two agree within tolerance: + + * Gemma RMSNorm (plain + fused-add-residual) -> fp32 PyTorch normalize + * SwiGLU-OAI (split layout) -> fp32 PyTorch elementwise + * Fused MXFP8 activation quant (Triton) -> _mxfp8_e4m3_quantize_torch + * Native MXFP8 linear (dot_scaled) -> dequant-to-bf16 @ matmul + * Native MXFP8 MoE (dot_scaled grouped GEMM) -> dequant-to-bf16 MoE math + +The native MXFP8 GEMMs also guard the ``dot_scaled`` rhs-scale orientation: the +scale is loaded ``[N, K//32]`` and passed WITHOUT transpose; a stray ``.T`` +makes the shape ``[K//32, N]`` and Triton raises before producing output, so any +regression there fails these tests loudly. + +Hardware scope: the whole module is ROCm-only (these are the AMD path; NVIDIA +uses the FlashInfer kernels). The norm/activation/quant kernels run on any ROCm +arch; the native MXFP8 ``dot_scaled`` linear/MoE tests are additionally gated to +CDNA4 gfx95x (``@requires_gfx950``) since gfx942 uses the BF16 emulation path. + +Run: pytest tests/kernels/test_minimax_m3_amd_ops.py -v +""" + +import pytest +import torch + +from vllm.platforms import current_platform + +if not current_platform.is_rocm(): + pytest.skip("MiniMax-M3 AMD fused ops require ROCm.", allow_module_level=True) +if not torch.cuda.is_available(): + pytest.skip("Requires a GPU.", allow_module_level=True) + +from vllm.model_executor.layers.quantization.utils.mxfp8_utils import ( # noqa: E402 + _mxfp8_e4m3_quantize_torch, + _mxfp8_e4m3_quantize_triton, + dequant_mxfp8_to_bf16, +) +from vllm.models.minimax_m3.amd.ops import ( # noqa: E402 + gemma_fused_add_rmsnorm, + gemma_rmsnorm, + swiglu_oai_split, +) +from vllm.models.minimax_m3.amd.ops.gemma_rmsnorm import _num_warps # noqa: E402 + +DEVICE = "cuda" +EPS = 1e-6 + + +def _gcn_arch() -> str: + try: + return torch.cuda.get_device_properties(0).gcnArchName + except Exception: # pragma: no cover - no device / non-AMD + return "" + + +# The pure-Triton norm/activation/quant kernels run on any ROCm arch (CDNA3 +# gfx942 and CDNA4 gfx950). The native MXFP8 ``dot_scaled`` GEMMs (linear + MoE) +# use CDNA4 hardware microscaling and are gated to gfx95x in the source +# (``RocmDotScaledMxfp8LinearKernel.is_supported``; the MoE oracle routes gfx942 +# to the BF16 emulation path instead) — so those tests are gfx950-only. +requires_gfx950 = pytest.mark.skipif( + "gfx95" not in _gcn_arch(), + reason="native MXFP8 dot_scaled is a CDNA4 (gfx95x) feature; " + "gfx942 uses the BF16 emulation path instead.", +) + + +def _relerr(a: torch.Tensor, b: torch.Tensor) -> float: + a = a.float() + b = b.float() + return ((a - b).norm() / (b.norm() + 1e-8)).item() + + +# --------------------------------------------------------------------------- # +# Gemma RMSNorm +# --------------------------------------------------------------------------- # +def _ref_gemma_rmsnorm(x, w, eps, residual=None): + orig_dtype = x.dtype + xf = x.float() + res_out = None + if residual is not None: + xf = xf + residual.float() + res_out = xf.to(orig_dtype) + xf = xf * torch.rsqrt(xf.pow(2).mean(dim=-1, keepdim=True) + eps) + xf = xf * (1.0 + w.float()) + out = xf.to(orig_dtype) + return out if residual is None else (out, res_out) + + +@pytest.mark.parametrize("shape", [(1, 4096), (37, 6144), (128, 2048)]) +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) +@pytest.mark.parametrize("seed", [0, 1234]) +@torch.inference_mode() +def test_gemma_rmsnorm(shape, dtype, seed): + torch.manual_seed(seed) + x = torch.randn(*shape, device=DEVICE, dtype=dtype) + w = torch.randn(shape[-1], device=DEVICE, dtype=dtype) * 0.1 + got = gemma_rmsnorm(x, w, EPS) + ref = _ref_gemma_rmsnorm(x, w, EPS) + assert got.shape == x.shape + assert _relerr(got, ref) < 5e-3 + + +@pytest.mark.parametrize("shape", [(1, 6144), (64, 4096)]) +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) +@torch.inference_mode() +def test_gemma_fused_add_rmsnorm(shape, dtype): + torch.manual_seed(0) + x = torch.randn(*shape, device=DEVICE, dtype=dtype) + res = torch.randn(*shape, device=DEVICE, dtype=dtype) + w = torch.randn(shape[-1], device=DEVICE, dtype=dtype) * 0.1 + got_out, got_res = gemma_fused_add_rmsnorm(x, res, w, EPS) + ref_out, ref_res = _ref_gemma_rmsnorm(x, w, EPS, residual=res) + assert _relerr(got_out, ref_out) < 5e-3 + # residual_out is the pre-norm sum (x + res): bit-for-bit identical cast. + assert torch.equal(got_res, ref_res) + + +@torch.inference_mode() +def test_gemma_rmsnorm_per_head_strided(): + """q_norm/k_norm normalize a non-contiguous ``qkv.split`` slice over head_dim.""" + torch.manual_seed(0) + T, H, D, kv = 7, 48, 128, 8 + total = (H + 2 * kv) * D + qkv = torch.randn(T, total, device=DEVICE, dtype=torch.bfloat16) + q = qkv[..., : H * D] # non-contiguous view (row stride == total) + q_by_head = q.view(T, H, D) + assert not q_by_head.is_contiguous() + w = torch.randn(D, device=DEVICE, dtype=torch.bfloat16) * 0.1 + got = gemma_rmsnorm(q_by_head, w, EPS) + ref = _ref_gemma_rmsnorm(q_by_head, w, EPS) + assert got.shape == q_by_head.shape + assert _relerr(got, ref) < 5e-3 + + +def test_num_warps_monotonic(): + assert _num_warps(128) <= _num_warps(2048) <= _num_warps(8192) + + +# --------------------------------------------------------------------------- # +# SwiGLU-OAI (split layout) +# --------------------------------------------------------------------------- # +def _ref_swiglu(gate_up, alpha, beta, limit): + d = gate_up.shape[-1] // 2 + gate = gate_up[..., :d].float() + up = gate_up[..., d:].float() + if limit is not None: + gate = gate.clamp(max=limit) + up = up.clamp(min=-limit, max=limit) + return (gate * torch.sigmoid(alpha * gate) * (up + beta)).to(gate_up.dtype) + + +@pytest.mark.parametrize("m,inter", [(1, 768), (64, 1536), (128, 1024)]) +@pytest.mark.parametrize("limit", [7.0, None]) +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) +@torch.inference_mode() +def test_swiglu_oai_split(m, inter, limit, dtype): + torch.manual_seed(0) + gate_up = torch.randn(m, 2 * inter, device=DEVICE, dtype=dtype) + got = swiglu_oai_split(gate_up, alpha=1.702, beta=1.0, limit=limit) + ref = _ref_swiglu(gate_up, 1.702, 1.0, limit) + assert got.shape == (m, inter) + assert _relerr(got, ref) < 5e-3 + + +# --------------------------------------------------------------------------- # +# Fused MXFP8 activation quant (Triton vs torch reference) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("shape", [(64, 4096), (1, 6144), (333, 2048)]) +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16]) +@torch.inference_mode() +def test_mxfp8_quant_triton_matches_torch(shape, dtype): + torch.manual_seed(0) + x = torch.randn(*shape, device=DEVICE, dtype=dtype) + xq_t, s_t = _mxfp8_e4m3_quantize_torch(x, is_sf_swizzled_layout=False) + xq_k, s_k = _mxfp8_e4m3_quantize_triton(x) + assert s_k.shape == s_t.shape == (shape[0], shape[1] // 32) + # E8M0 block exponents share the floor(log2(amax))+127 algorithm; allow at + # most a 1-step difference at exact powers of two. + assert (s_k.int() - s_t.int()).abs().max().item() <= 1 + # Dequantized values agree to fp8 granularity. + deq_t = dequant_mxfp8_to_bf16(xq_t, s_t) + deq_k = dequant_mxfp8_to_bf16(xq_k, s_k) + assert _relerr(deq_k, deq_t) < 1e-2 + + +# --------------------------------------------------------------------------- # +# Native MXFP8 linear (dot_scaled) vs dequant-to-bf16 matmul +# --------------------------------------------------------------------------- # +@requires_gfx950 +@pytest.mark.parametrize("m,n,k", [(64, 256, 128), (37, 512, 256), (1, 6144, 4096)]) +@torch.inference_mode() +def test_mxfp8_native_linear(m, n, k): + from vllm.model_executor.kernels.linear.mxfp8.rocm_native import ( + _mxfp8_dot_scaled_linear, + ) + + torch.manual_seed(0) + w_bf16 = torch.randn(n, k, device=DEVICE, dtype=torch.bfloat16) * 0.1 + w_fp8, w_scale = _mxfp8_e4m3_quantize_torch(w_bf16, is_sf_swizzled_layout=False) + x = torch.randn(m, k, device=DEVICE, dtype=torch.bfloat16) * 0.5 + + got = _mxfp8_dot_scaled_linear(x, w_fp8, w_scale) + # Reference: consume the SAME quantized weights (isolates activation-quant + # noise) -> dequant to bf16, plain matmul. + w_deq = dequant_mxfp8_to_bf16(w_fp8, w_scale) + ref = torch.nn.functional.linear(x, w_deq).to(x.dtype) + assert got.shape == (m, n) + # Only the activation is re-quantized inside the kernel -> small MX noise. + assert _relerr(got, ref) < 5e-2 + + +# --------------------------------------------------------------------------- # +# Native MXFP8 MoE (dot_scaled grouped GEMM) vs dequant-to-bf16 MoE math +# --------------------------------------------------------------------------- # +def _ref_moe(x, w13, w2, topk_weights, topk_ids, alpha, beta, limit): + T, H = x.shape + inter = w2.shape[-1] + top_k = topk_ids.shape[1] + out = torch.zeros(T, H, device=x.device, dtype=torch.float32) + for t in range(T): + for j in range(top_k): + e = int(topk_ids[t, j].item()) + g1 = x[t].float() @ w13[e].float().T # [2I] + gate = g1[:inter] + up = g1[inter:] + if limit is not None: + gate = gate.clamp(max=limit) + up = up.clamp(min=-limit, max=limit) + act = gate * torch.sigmoid(alpha * gate) * (up + beta) + g2 = act @ w2[e].float().T # [H] + out[t] += topk_weights[t, j].float() * g2 + return out.to(x.dtype) + + +@requires_gfx950 +@pytest.mark.parametrize( + "T,H,inter,E,top_k", [(8, 256, 512, 8, 2), (1, 512, 256, 16, 4)] +) +@torch.inference_mode() +def test_mxfp8_native_moe(T, H, inter, E, top_k): + from vllm.model_executor.layers.fused_moe.experts.mxfp8_native_moe import ( + fused_moe_mxfp8_native, + ) + + torch.manual_seed(0) + alpha, beta, limit = 1.702, 1.0, 7.0 + w13_bf16 = torch.randn(E, 2 * inter, H, device=DEVICE, dtype=torch.bfloat16) * 0.1 + w2_bf16 = torch.randn(E, H, inter, device=DEVICE, dtype=torch.bfloat16) * 0.1 + w13_fp8, w13_scale = _mxfp8_e4m3_quantize_torch( + w13_bf16, is_sf_swizzled_layout=False + ) + w2_fp8, w2_scale = _mxfp8_e4m3_quantize_torch(w2_bf16, is_sf_swizzled_layout=False) + + x = torch.randn(T, H, device=DEVICE, dtype=torch.bfloat16) * 0.5 + logits = torch.randn(T, E, device=DEVICE, dtype=torch.float32) + topk_weights, topk_ids = logits.softmax(dim=-1).topk(top_k, dim=-1) + topk_weights = topk_weights.to(torch.float32) + topk_ids = topk_ids.to(torch.int32) + + got = fused_moe_mxfp8_native( + x, + w13_fp8, + w13_scale, + w2_fp8, + w2_scale, + topk_weights, + topk_ids, + alpha=alpha, + beta=beta, + limit=limit, + global_num_experts=E, + expert_map=None, + ) + # Reference consumes the dequantized weights (same bits the kernel reads). + w13_deq = dequant_mxfp8_to_bf16(w13_fp8, w13_scale) + w2_deq = dequant_mxfp8_to_bf16(w2_fp8, w2_scale) + ref = _ref_moe(x, w13_deq, w2_deq, topk_weights, topk_ids, alpha, beta, limit) + assert got.shape == (T, H) + assert _relerr(got, ref) < 5e-2 + + +# --------------------------------------------------------------------------- # +# Native MXFP8 grouped GEMM (dot_scaled) vs pure-PyTorch grouped matmul +# --------------------------------------------------------------------------- # +def _ref_grouped_gemm(a_deq, w_deq, topk_ids, a_div, num_valid, mul_weight=None): + """Pure-PyTorch reference for ``_grouped_gemm_mxfp8``. + + For each routed (expanded) token ``tid in [0, num_valid)`` the kernel writes + ``out[tid] = a[tid // a_div] @ w[expert(tid)].T`` (fp32 accumulate), optionally + scaled by ``mul_weight[tid]``. The expert for ``tid`` is ``topk_ids.flatten() + [tid]`` (row-major expansion: ``tid = token*top_k + slot``). This is computed + here with plain ``torch.matmul`` on the dequantized operands — independent of + the Triton ``dot_scaled`` path and of the (separate) aiter backend. + """ + eids = topk_ids.reshape(-1) + n = w_deq.shape[1] + out = torch.empty(num_valid, n, dtype=torch.float32, device=a_deq.device) + for tid in range(num_valid): + e = int(eids[tid].item()) + out[tid] = a_deq[tid // a_div].float() @ w_deq[e].float().T + if mul_weight is not None: + out[tid] *= float(mul_weight[tid].item()) + return out + + +@requires_gfx950 +@pytest.mark.parametrize("T,N,K,E,top_k", [(8, 256, 128, 8, 2), (5, 512, 256, 16, 4)]) +@pytest.mark.parametrize("weighted", [False, True]) +@torch.inference_mode() +def test_mxfp8_grouped_gemm_native(T, N, K, E, top_k, weighted): + """Directly exercise ``_grouped_gemm_mxfp8`` against a non-Triton reference. + + Covers both call modes used by ``fused_moe_mxfp8_native``: + * ``weighted=False`` -> g1: ``a_div=top_k`` (a-row shared across the top_k + expansions of a token), no per-token weight. + * ``weighted=True`` -> g2: ``a_div=1`` (one a-row per expansion), output + scaled by ``topk_weights``. + """ + from vllm.model_executor.layers.fused_moe.experts.mxfp8_native_moe import ( + _grouped_gemm_mxfp8, + ) + from vllm.model_executor.layers.fused_moe.moe_align_block_size import ( + moe_align_block_size, + ) + + torch.manual_seed(0) + block_m = 64 + a_div = 1 if weighted else top_k + m_routed = T * top_k + # a-rows: g1 reads one row per token (a_div=top_k); g2 one per expansion. + a_rows = m_routed if weighted else T + a_bf16 = torch.randn(a_rows, K, device=DEVICE, dtype=torch.bfloat16) * 0.5 + w_bf16 = torch.randn(E, N, K, device=DEVICE, dtype=torch.bfloat16) * 0.1 + a_fp8, a_scale = _mxfp8_e4m3_quantize_torch(a_bf16, is_sf_swizzled_layout=False) + w_fp8, w_scale = _mxfp8_e4m3_quantize_torch(w_bf16, is_sf_swizzled_layout=False) + + logits = torch.randn(T, E, device=DEVICE, dtype=torch.float32) + topk_weights, topk_ids = logits.softmax(dim=-1).topk(top_k, dim=-1) + topk_weights = topk_weights.to(torch.float32) + topk_ids = topk_ids.to(torch.int32) + mul = topk_weights.reshape(-1) if weighted else None + + sorted_ids, expert_ids, num_post = moe_align_block_size( + topk_ids, block_m, E, None, ignore_invalid_experts=False + ) + got = _grouped_gemm_mxfp8( + a_fp8, + a_scale, + w_fp8, + w_scale, + sorted_ids, + expert_ids, + num_post, + m_routed, + top_k, + block_m, + torch.bfloat16, + a_div=a_div, + mul_weight_by=mul, + ) + # Reference: dequant the SAME bits the kernel reads, plain torch matmul. + a_deq = dequant_mxfp8_to_bf16(a_fp8, a_scale) + w_deq = dequant_mxfp8_to_bf16(w_fp8, w_scale) + ref = _ref_grouped_gemm(a_deq, w_deq, topk_ids, a_div, m_routed, mul) + assert got.shape == (m_routed, N) + assert _relerr(got, ref) < 5e-2 + + +# --------------------------------------------------------------------------- # +# MXFP8 linear emulation: BF16-at-load (default) vs per-step dequant + switch +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("shape", [(512, 2048), (1, 6144)]) +@pytest.mark.parametrize("act_dtype", [torch.bfloat16, torch.float16]) +@pytest.mark.parametrize("dequant_at_load", [True, False]) +@torch.inference_mode() +def test_mxfp8_linear_emulation_bf16_at_load( + shape, act_dtype, dequant_at_load, monkeypatch +): + """EmulationMxfp8LinearKernel load-time BF16 dequant (default) and the + ``VLLM_MXFP8_EMULATION_DEQUANT_AT_LOAD=0`` per-step fallback must produce the + same result; the dtype-match (BF16/FP16 activations) must also hold.""" + from vllm.model_executor.kernels.linear.mxfp8.emulation import ( + EmulationMxfp8LinearKernel, + ) + from vllm.model_executor.kernels.linear.mxfp8.Mxfp8LinearKernel import ( + Mxfp8LinearLayerConfig, + ) + + monkeypatch.setenv( + "VLLM_MXFP8_EMULATION_DEQUANT_AT_LOAD", "1" if dequant_at_load else "0" + ) + N, K = shape + torch.manual_seed(0) + w_bf16 = torch.randn(N, K, device=DEVICE, dtype=torch.bfloat16) + w_fp8, w_scale = _mxfp8_e4m3_quantize_torch(w_bf16, is_sf_swizzled_layout=False) + assert w_scale.shape == (N, K // 32) + + # Reference: dequant once, plain linear in the activation dtype. + w_ref = dequant_mxfp8_to_bf16(w_fp8, w_scale).to(act_dtype) + x = torch.randn(7, K, device=DEVICE, dtype=act_dtype) + out_ref = torch.nn.functional.linear(x, w_ref) + + layer = torch.nn.Module() + layer.weight = torch.nn.Parameter(w_fp8.clone(), requires_grad=False) + layer.weight_scale = torch.nn.Parameter(w_scale.clone(), requires_grad=False) + + kernel = EmulationMxfp8LinearKernel(Mxfp8LinearLayerConfig()) + kernel.process_weights_after_loading(layer) + + if dequant_at_load: + # weights converted to BF16 at load (>= 2-byte) + assert layer.weight.element_size() >= 2 + else: + # opt-out: weights stay 1-byte MXFP8, dequant happens per-step + assert layer.weight.element_size() == 1 + + out = kernel.apply_weights(layer, x) + assert out.dtype == act_dtype # dtype-match preserved (no tl.dot/F.linear crash) + assert _relerr(out.float(), out_ref.float()) < 2e-2 + + +# ── EP expert_mask handling for the FlyDSL (AITER_MXFP8) MoE ──────────────── +# Regression for the EP + aiter-master-switch interaction: under expert +# parallelism ``RoutedExperts.expert_map`` hands the experts either the 0/1 +# ``expert_mask`` (aiter master ON, ``rocm_aiter_fmoe_enabled``) or vLLM's -1 +# index map (master OFF). ``AiterMxfp8Experts.apply`` must forward the right 0/1 +# mask to aiter in BOTH cases. The old code always rebuilt the mask via +# ``(expert_map >= 0)``; on the already-0/1 mask that collapses to all-ones (no +# experts masked out) and EP output becomes garbage (no accuracy). +def _capture_expert_mask(expert_map, *, rocm_aiter_fmoe_enabled, global_num_experts): + """Drive the real ``AiterMxfp8Experts.apply`` mask branch and capture the + ``expert_mask`` it forwards to ``rocm_aiter_ops.fused_moe``.""" + from types import SimpleNamespace + from unittest import mock + + from vllm._aiter_ops import rocm_aiter_ops + from vllm.model_executor.layers.fused_moe.experts.aiter_mxfp8_moe import ( + AiterMxfp8Experts, + ) + + experts = object.__new__(AiterMxfp8Experts) # bypass heavy __init__ + experts.moe_config = SimpleNamespace( + rocm_aiter_fmoe_enabled=rocm_aiter_fmoe_enabled + ) + experts.quant_config = SimpleNamespace(gemm1_clamp_limit=None) + experts.w1_scale_val = None + experts.w2_scale_val = None + + captured = {} + + def _fake_fused_moe(hidden_states, w1, w2, tw, ti, *, expert_mask, **kw): + captured["expert_mask"] = expert_mask + return torch.zeros_like(hidden_states) + + w1 = torch.zeros(1, device=DEVICE) + w2 = torch.zeros(1, device=DEVICE) + out = torch.zeros(4, 8, device=DEVICE, dtype=torch.bfloat16) + hidden = torch.zeros(4, 8, device=DEVICE, dtype=torch.bfloat16) + tw = torch.ones(4, 2, device=DEVICE) + ti = torch.zeros(4, 2, dtype=torch.int32, device=DEVICE) + + with mock.patch.object(rocm_aiter_ops, "fused_moe", side_effect=_fake_fused_moe): + experts.apply( + output=out, + hidden_states=hidden, + w1=w1, + w2=w2, + topk_weights=tw, + topk_ids=ti, + activation=None, + global_num_experts=global_num_experts, + expert_map=expert_map, + a1q_scale=None, + a2_scale=None, + workspace13=None, + workspace2=None, + expert_tokens_meta=None, + apply_router_weight_on_input=False, + ) + return captured["expert_mask"] + + +@pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm only") +def test_aiter_mxfp8_ep_expert_mask_both_master_modes(): + """Both aiter-master forms must yield the SAME correct 0/1 aiter mask; + guards the EP+master regression (mask must not collapse to all-ones).""" + from vllm.model_executor.layers.fused_moe.expert_map_manager import ( + determine_expert_map, + ) + + E, ep_size, ep_rank = 8, 2, 0 # rank owns global experts 0..3 + # master OFF: vLLM's -1 index map + _, idx_map, _ = determine_expert_map(ep_size, ep_rank, E, return_expert_mask=False) + # master ON: 0/1 mask (+ trailing sentinel) that RoutedExperts forwards + _, _, ep_mask = determine_expert_map(ep_size, ep_rank, E, return_expert_mask=True) + + idx_map = idx_map.to(DEVICE) + ep_mask = ep_mask.to(DEVICE) + + # Expected aiter expert_mask: 0/1 over global ids + trailing sentinel slot. + expected = torch.tensor([1, 1, 1, 1, 0, 0, 0, 0, 0], dtype=torch.int32) + + got_off = _capture_expert_mask( + idx_map, rocm_aiter_fmoe_enabled=False, global_num_experts=E + ) + got_on = _capture_expert_mask( + ep_mask, rocm_aiter_fmoe_enabled=True, global_num_experts=E + ) + + assert torch.equal(got_off.cpu().to(torch.int32), expected) + # master ON forwards the prebuilt mask unchanged (NOT collapsed to all-ones) + assert torch.equal(got_on.cpu().to(torch.int32), ep_mask.cpu().to(torch.int32)) + assert got_on.sum().item() == 4 # exactly the 4 local experts, not all 9 diff --git a/tests/kernels/test_top_k_per_row.py b/tests/kernels/test_top_k_per_row.py index 7b9c11495e8b..3a1ad0f0d23c 100644 --- a/tests/kernels/test_top_k_per_row.py +++ b/tests/kernels/test_top_k_per_row.py @@ -14,6 +14,70 @@ BATCH_SIZE = [1, 2, 2048] NEXT_N = [1, 8] DATA_GENERATION = ["random", "10LSBits"] +RADIX_TOPK_WORKSPACE_SIZE = 1024 * 1024 + + +def _has_device_capability(major: int) -> bool: + return current_platform.is_cuda() and current_platform.has_device_capability(major) + + +COOPERATIVE_TOPK_BACKEND = pytest.param( + "cooperative_topk", + marks=pytest.mark.skipif( + not _has_device_capability(90), + reason="cooperative_topk requires SM90+", + ), +) +WORKSPACE_TOPK_BACKENDS = ["persistent_topk", COOPERATIVE_TOPK_BACKEND] +TOPK_BACKENDS = ["top_k_per_row_decode", *WORKSPACE_TOPK_BACKENDS] + + +def _run_topk_backend( + backend: str, + logits: torch.Tensor, + lengths: torch.Tensor, + indices: torch.Tensor, + top_k: int, + max_seq_len: int, + next_n: int = 1, +) -> None: + if backend == "top_k_per_row_decode": + torch.ops._C.top_k_per_row_decode( + logits, + next_n, + lengths, + indices, + indices.shape[0], + logits.stride(0), + logits.stride(1), + top_k, + ) + elif backend == "persistent_topk": + workspace = torch.empty( + RADIX_TOPK_WORKSPACE_SIZE, dtype=torch.uint8, device="cuda" + ) + torch.ops._C.persistent_topk( + logits, lengths, indices, workspace, top_k, max_seq_len + ) + elif backend == "cooperative_topk": + if indices.shape[0] > 32: + pytest.skip( + "cooperative_topk supports <=32 rows; " + "persistent_topk covers larger batches" + ) + if logits.stride(0) % 4 != 0: + pytest.skip( + "cooperative_topk requires row stride divisible by 4; " + "persistent_topk covers unaligned strides" + ) + workspace = torch.empty( + RADIX_TOPK_WORKSPACE_SIZE, dtype=torch.uint8, device="cuda" + ) + torch.ops._C.cooperative_topk( + logits, lengths, indices, workspace, top_k, max_seq_len + ) + else: + raise ValueError(f"Unknown top-k backend: {backend}") def create_random_logits( @@ -322,16 +386,19 @@ def test_top_k_per_row_decode_large_vocab_size(clean_logits: bool) -> None: @pytest.mark.parametrize("clean_logits", [True, False]) @pytest.mark.parametrize("top_k", [2048]) @pytest.mark.parametrize("next_n", [1, 4]) +@pytest.mark.parametrize("backend", WORKSPACE_TOPK_BACKENDS) @torch.inference_mode() -def test_deepseek_persistent_topk( +def test_deepseek_workspace_topk( seq_len_range: tuple[int, int], test_id: str, clean_logits: bool, top_k: int, next_n: int, + backend: str, ) -> None: """ - Test persistent_topk with varying sequence lengths and speculative decoding. + Test workspace top-k backends with varying sequence lengths and speculative + decoding. Supports speculative decoding with next_n > 1. """ set_random_seed(42 if test_id == "short_sequences" else 43) @@ -347,6 +414,7 @@ def test_deepseek_persistent_topk( dtype=torch.int32, device="cuda", ) + seq_lens = (seq_lens + 3) & ~3 # align to 4 for TMA # Compute row boundaries for speculative decoding row_starts = torch.zeros(num_rows, dtype=torch.int32, device="cuda") @@ -366,14 +434,11 @@ def test_deepseek_persistent_topk( offsets = torch.arange(next_n, device=logits.device, dtype=torch.int32) lengths = (seq_lens.unsqueeze(1) - next_n + 1 + offsets).flatten() - workspace = torch.empty(1024 * 1024, dtype=torch.uint8, device="cuda") max_seq_len = int(seq_lens.max().item()) - torch.ops._C.persistent_topk( - logits, lengths, indices, workspace, top_k, max_seq_len - ) + _run_topk_backend(backend, logits, lengths, indices, top_k, max_seq_len, next_n) validate_topk_against_reference( - logits, indices, row_starts, row_ends, top_k, f"persistent_topk ({test_id})" + logits, indices, row_starts, row_ends, top_k, f"{backend} ({test_id})" ) @@ -383,9 +448,10 @@ def run_large_context_topk_test( top_k: int, data_type: str = "random", seed: int = 42, + backend: str = "cooperative_topk", ) -> None: """ - Helper to run persistent_topk kernel test with given parameters. + Helper to run a top-k backend test with given parameters. Args: batch_size: Number of rows/sequences @@ -393,6 +459,7 @@ def run_large_context_topk_test( top_k: Number of top elements to select data_type: Type of test data to generate seed: Random seed for reproducibility + backend: Top-k backend to test """ torch.set_default_device("cuda:0") set_random_seed(seed) @@ -449,11 +516,8 @@ def run_large_context_topk_test( # Create output tensor indices = torch.empty((num_rows, top_k), dtype=torch.int32, device="cuda") - workspace = torch.empty(1024 * 1024, dtype=torch.uint8, device="cuda") max_seq_len = max(seq_lens) - torch.ops._C.persistent_topk( - logits, lengths, indices, workspace, top_k, max_seq_len - ) + _run_topk_backend(backend, logits, lengths, indices, top_k, max_seq_len) torch.accelerator.synchronize() @@ -605,8 +669,9 @@ def run_large_context_topk_test( ), ], ) +@pytest.mark.parametrize("backend", WORKSPACE_TOPK_BACKENDS) @torch.inference_mode() -def test_persistent_topk_correctness(test_config: dict) -> None: +def test_workspace_topk_correctness(test_config: dict, backend: str) -> None: """ Comprehensive correctness tests covering: - Sequence length edge cases (trivial, boundary, varied) @@ -620,6 +685,7 @@ def test_persistent_topk_correctness(test_config: dict) -> None: seq_lens=test_config["seq_lens"], top_k=test_config["top_k"], data_type=test_config.get("data_type", "random"), + backend=backend, ) @@ -668,8 +734,9 @@ def test_persistent_topk_correctness(test_config: dict) -> None: ), ], ) +@pytest.mark.parametrize("backend", WORKSPACE_TOPK_BACKENDS) @torch.inference_mode() -def test_persistent_topk_algorithm_paths(test_config: dict) -> None: +def test_workspace_topk_algorithm_paths(test_config: dict, backend: str) -> None: """ Test different algorithm execution paths (capped at 163840 for DeepSeek V3.2): - Batch size scalability (1, 4, 32, 256) @@ -680,12 +747,14 @@ def test_persistent_topk_algorithm_paths(test_config: dict) -> None: batch_size=test_config["batch_size"], seq_lens=[test_config["seq_len"]] * test_config["batch_size"], top_k=test_config["top_k"], + backend=backend, ) @pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +@pytest.mark.parametrize("backend", WORKSPACE_TOPK_BACKENDS) @torch.inference_mode() -def test_persistent_topk_stress() -> None: +def test_workspace_topk_stress(backend: str) -> None: """ Stress test with random configurations to catch edge cases. Capped at 163840 (DeepSeek V3.2 max context) for realistic testing. @@ -700,16 +769,73 @@ def test_persistent_topk_stress() -> None: batch_size = torch.randint(1, 32, (1,)).item() # Random sequence lengths capped at DeepSeek V3.2 max context - seq_lens = torch.randint(100, 163840, (batch_size,)).tolist() + seq_lens_tensor = torch.randint(100, 163840, (batch_size,)) + if backend == "cooperative_topk": + seq_lens = ((seq_lens_tensor + 3) & ~3).tolist() + else: + seq_lens = seq_lens_tensor.tolist() run_large_context_topk_test( batch_size=batch_size, seq_lens=seq_lens, top_k=top_k, seed=seed, + backend=backend, ) +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +@pytest.mark.parametrize("backend", TOPK_BACKENDS) +@pytest.mark.parametrize("top_k", [512, 1024, 2048]) +@torch.inference_mode() +def test_deepseek_topk_backends_no_error_and_reference( + backend: str, + top_k: int, +) -> None: + """Exercise every production top-k backend on the same inputs.""" + run_large_context_topk_test( + batch_size=4, + seq_lens=[2049, 4097, 8191, 12000], + top_k=top_k, + data_type="random", + seed=123, + backend=backend, + ) + + +@pytest.mark.skipif(not _has_device_capability(90), reason="This test requires SM90+") +@torch.inference_mode() +def test_cooperative_topk_512_tie_workspace_is_per_row() -> None: + """Regression test for TopK=512 tie workspace row overlap.""" + torch.set_default_device("cuda:0") + + top_k = 512 + num_rows = 2 + stride = 65536 + lengths = torch.tensor([40960, 65536], dtype=torch.int32, device="cuda") + logits = torch.full( + (num_rows, stride), float("-inf"), dtype=torch.float32, device="cuda" + ) + + # Row 0 must never select these low indices: many better row-0 ties exist. + logits[0, :2048] = -10.0 + logits[0, 2048 : lengths[0]] = 1.0 + # Row 1 has higher exact tie scores. With the old row * TopK tie_ws stride, + # these row-1 ties could overwrite row 0's TopK=512 refinement workspace. + logits[1, : lengths[1]] = 2.0 + + indices = torch.empty((num_rows, top_k), dtype=torch.int32, device="cuda") + workspace = torch.empty(RADIX_TOPK_WORKSPACE_SIZE, dtype=torch.uint8, device="cuda") + torch.ops._C.cooperative_topk(logits, lengths, indices, workspace, top_k, stride) + torch.accelerator.synchronize() + + row0 = indices[0].cpu() + assert torch.all(row0 >= 2048), ( + "cooperative_topk TopK=512 selected row-0 low-score indices, likely " + "from overlapping tie_ws rows" + ) + + @pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") @pytest.mark.parametrize( "test_config", @@ -774,10 +900,11 @@ def test_persistent_topk_stress() -> None: ], ) @pytest.mark.parametrize("top_k", [512, 2048]) +@pytest.mark.parametrize("backend", WORKSPACE_TOPK_BACKENDS) @torch.inference_mode() -def test_persistent_topk(test_config: dict, top_k: int) -> None: +def test_workspace_topk(test_config: dict, top_k: int, backend: str) -> None: """ - Tests specific to the persistent_topk kernel: + Tests specific to workspace top-k backends: - Mixed medium/large rows in the same batch (dynamic per-row dispatch) - Boundary around LARGE_THRESHOLD (32K) - Trivial + medium + large rows in a single batch @@ -787,15 +914,17 @@ def test_persistent_topk(test_config: dict, top_k: int) -> None: seq_lens=test_config["seq_lens"], top_k=top_k, data_type=test_config.get("data_type", "random"), + backend=backend, ) @pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") @pytest.mark.parametrize("top_k", [512, 2048]) +@pytest.mark.parametrize("backend", WORKSPACE_TOPK_BACKENDS) @torch.inference_mode() -def test_persistent_topk_padded_stride(top_k: int) -> None: +def test_workspace_topk_padded_stride(top_k: int, backend: str) -> None: """ - Test persistent_topk with padded logits (large stride, small seq_len) + Test workspace top-k backends with padded logits (large stride, small seq_len) to simulate the e2e CUDAGraph scenario where fp8_paged_mqa_logits returns [B, max_model_len] with max_model_len=163840. """ @@ -818,11 +947,7 @@ def test_persistent_topk_padded_stride(top_k: int) -> None: lengths = torch.tensor(actual_seq_lens, dtype=torch.int32, device="cuda") indices = torch.empty((batch_size, top_k), dtype=torch.int32, device="cuda") - workspace = torch.empty(1024 * 1024, dtype=torch.uint8, device="cuda") - - torch.ops._C.persistent_topk( - logits, lengths, indices, workspace, top_k, max(actual_seq_lens) - ) + _run_topk_backend(backend, logits, lengths, indices, top_k, max(actual_seq_lens)) torch.accelerator.synchronize() # Validate against torch.topk @@ -840,6 +965,6 @@ def test_persistent_topk_padded_stride(top_k: int) -> None: expected_vals = logits[i, expected].cpu().sort(descending=True)[0] actual_vals = logits[i, actual].cpu().sort(descending=True)[0] assert torch.allclose(expected_vals, actual_vals, rtol=1e-4, atol=1e-4), ( - f"Row {i}: persistent_topk with padded stride doesn't match. " + f"Row {i}: {backend} with padded stride doesn't match. " f"seq_len={sl}, stride={padded_stride}" ) diff --git a/tests/kernels/utils.py b/tests/kernels/utils.py index 12ff3830c21f..cc1d1bbf88d4 100644 --- a/tests/kernels/utils.py +++ b/tests/kernels/utils.py @@ -809,6 +809,35 @@ def fp8_allclose( ) +def bf16_ulp_distance(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + """Representable-step distance between two bf16 tensors. + + Reinterprets the bf16 bit patterns under the IEEE-754 total ordering so + that adjacent representable values differ by exactly 1. + """ + + def key(t: torch.Tensor) -> torch.Tensor: + u = t.contiguous().view(torch.int16).to(torch.int64) & 0xFFFF + return torch.where(u >= 0x8000, 0xFFFF - u, u + 0x8000) + + return (key(a) - key(b)).abs() + + +def fp8_ulp_distance(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: + """Representable-step distance between two 8-bit fp8 tensors. + + Reinterprets the fp8 bytes under a sign-magnitude total ordering so that + adjacent representable values differ by exactly 1. Inputs must already share + the same fp8 encoding (e.g. both FP8_STORE_DTYPE). + """ + + def key(t: torch.Tensor) -> torch.Tensor: + u = t.contiguous().view(torch.uint8).to(torch.int64) + return torch.where(u >= 0x80, 0xFF - u, u + 0x80) + + return (key(a) - key(b)).abs() + + # Marlin MoE test utils diff --git a/tests/lora/test_chatglm3_tp.py b/tests/lora/test_chatglm3_tp.py index ace4fb5f50ef..8df4ccf7b560 100644 --- a/tests/lora/test_chatglm3_tp.py +++ b/tests/lora/test_chatglm3_tp.py @@ -115,6 +115,7 @@ def test_chatglm3_lora_tp4_fully_sharded_loras(chatglm3_lora_files): enable_lora=True, max_loras=2, max_lora_rank=64, + max_num_seqs=16, tensor_parallel_size=4, trust_remote_code=True, fully_sharded_loras=True, diff --git a/tests/lora/test_default_mm_loras.py b/tests/lora/test_default_mm_loras.py index 673e8e85555a..19c910e2453d 100644 --- a/tests/lora/test_default_mm_loras.py +++ b/tests/lora/test_default_mm_loras.py @@ -42,7 +42,11 @@ } -def run_test(vllm_runner, audio_assets, lora_request, expected_suffix, **kwargs): +def run_test( + vllm_runner, audio_assets, monkeypatch, lora_request, expected_suffix, **kwargs +): + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn") + inputs = [([AUDIO_PROMPT], [audio_assets[0].audio_and_sample_rate[0]])] # Apply any additional kwargs as overrides to the base kwargs @@ -66,11 +70,13 @@ def run_test(vllm_runner, audio_assets, lora_request, expected_suffix, **kwargs) def test_active_default_mm_lora( vllm_runner: type[VllmRunner], audio_assets: AudioTestAssets, + monkeypatch: pytest.MonkeyPatch, ): """Ensure that we can use the default audio lora.""" run_test( vllm_runner, audio_assets, + monkeypatch, lora_request=None, default_mm_loras={"audio": AUDIO_LORA_PATH}, expected_suffix=RESPONSE_SUFFIX_WITH_LORA, @@ -84,12 +90,14 @@ def test_active_default_mm_lora( def test_inactive_default_mm_lora( vllm_runner: type[VllmRunner], audio_assets: AudioTestAssets, + monkeypatch: pytest.MonkeyPatch, ): """Ensure that modalities are filtered properly.""" # Default image lora won't be active since we only pass audio run_test( vllm_runner, audio_assets, + monkeypatch, lora_request=None, default_mm_loras={"image": IMAGE_LORA_PATH}, expected_suffix=RESPONSE_SUFFIX_WITHOUT_LORA, @@ -103,11 +111,13 @@ def test_inactive_default_mm_lora( def test_default_mm_lora_succeeds_with_redundant_lora_request( vllm_runner: type[VllmRunner], audio_assets: AudioTestAssets, + monkeypatch: pytest.MonkeyPatch, ): """Ensure that redundantly providing the lora works.""" run_test( vllm_runner, audio_assets, + monkeypatch, lora_request=LoRARequest("audio", 1, AUDIO_LORA_PATH), default_mm_loras={"audio": AUDIO_LORA_PATH}, expected_suffix=RESPONSE_SUFFIX_WITH_LORA, @@ -121,12 +131,14 @@ def test_default_mm_lora_succeeds_with_redundant_lora_request( def test_default_mm_lora_fails_with_overridden_lora_request( vllm_runner: type[VllmRunner], audio_assets: AudioTestAssets, + monkeypatch: pytest.MonkeyPatch, ): """Ensure that if the lora_request conflicts with default_mm_loras, we use the lora_request.""" run_test( vllm_runner, audio_assets, + monkeypatch, lora_request=LoRARequest("speech", 2, AUDIO_LORA_PATH), default_mm_loras={"audio": IMAGE_LORA_PATH}, expected_suffix=RESPONSE_SUFFIX_WITH_LORA, @@ -134,7 +146,10 @@ def test_default_mm_lora_fails_with_overridden_lora_request( @create_new_process_for_each_test() -def test_default_mm_lora_does_not_expand_string_reqs(vllm_runner): +def test_default_mm_lora_does_not_expand_string_reqs(vllm_runner, monkeypatch): + # See run_test: force spawn to avoid the forked-child CUDA re-init crash. + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn") + class MockEngineException(Exception): pass diff --git a/tests/lora/test_gptoss_tp.py b/tests/lora/test_gptoss_tp.py index 648660734655..0cf778f3b022 100644 --- a/tests/lora/test_gptoss_tp.py +++ b/tests/lora/test_gptoss_tp.py @@ -1,7 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import importlib.metadata +from importlib.util import find_spec + import pytest +import torch +from packaging import version import vllm from vllm.lora.request import LoRARequest @@ -9,6 +14,22 @@ from ..utils import multi_gpu_test +# Require amd-quark >= 0.12 on torch >= 2.11. +# Earlier torch releases work with older quark versions. See +# https://github.com/amd/Quark/issues/34 +# TODO: Remove once amd-quark>=0.12.0 +QUARK_TORCH_COMPATIBLE = find_spec("quark") is not None and ( + version.parse(importlib.metadata.version("amd-quark")) >= version.parse("0.12.0") + if version.parse(torch.__version__.split("+")[0]) >= version.parse("2.11") + else True +) + +if current_platform.is_rocm() and not QUARK_TORCH_COMPATIBLE: + pytest.skip( + "This test requires amd-quark >= 0.12 on torch >= 2.11.", + allow_module_level=True, + ) + MODEL_PATH = "openai/gpt-oss-20b" PROMPT_TEMPLATE = """<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. @@ -70,70 +91,82 @@ def generate_and_test(llm: vllm.LLM, lora_path: str, lora_id: int) -> None: assert generated_texts[i].startswith(EXPECTED_LORA_OUTPUT[i]) -@pytest.mark.skipif( - not current_platform.is_cuda(), - reason=( - "Mxfp4 LoRA on ROCm is blocked by a spawn compatibility issue. " - "The fused_moe_lora Triton kernel crashes in spawned subprocesses, " - "and vLLM forces spawn mode when HIP is initialized before " - "multiprocessing. Fixing this requires either making the LoRA " - "Triton kernel spawn-safe or pre-warming the kernel cache." - ), +# TODO: make the Mxfp4MoeBackend.TRITON spawn-safe. +# For now just use TRITON_UNFUSED kernel +@pytest.mark.parametrize( + "mxfp4_use_marlin", + [ + False, + pytest.param( + True, + marks=pytest.mark.skipif( + current_platform.is_rocm(), reason="marlin not supported" + ), + ), + ], ) -@pytest.mark.parametrize("mxfp4_use_marlin", [True, False]) @pytest.mark.parametrize("specialize_active_lora", [True, False]) def test_gpt_oss_lora( - monkeypatch: pytest.MonkeyPatch, gptoss20b_lora_files, mxfp4_use_marlin, specialize_active_lora, ): - with monkeypatch.context() as m: - m.setenv("VLLM_MXFP4_USE_MARLIN", "1" if mxfp4_use_marlin else "0") - llm = vllm.LLM( - MODEL_PATH, - max_model_len=1024, - enable_lora=True, - max_loras=4, - max_lora_rank=8, - max_num_seqs=2, - max_num_batched_tokens=2048, - specialize_active_lora=specialize_active_lora, - compilation_config=vllm.config.CompilationConfig( # Avoid OOM - cudagraph_specialize_lora=False, - ), - ) + llm = vllm.LLM( + MODEL_PATH, + max_model_len=1024, + enable_lora=True, + max_loras=4, + max_lora_rank=8, + max_num_seqs=2, + max_num_batched_tokens=2048, + specialize_active_lora=specialize_active_lora, + moe_backend="marlin" if mxfp4_use_marlin else "auto", + linear_backend="marlin" if mxfp4_use_marlin else "auto", + compilation_config=vllm.config.CompilationConfig( # Avoid OOM + cudagraph_specialize_lora=False, + ), + ) - generate_and_test(llm, gptoss20b_lora_files, lora_id=1) - generate_and_test(llm, gptoss20b_lora_files, lora_id=2) + generate_and_test(llm, gptoss20b_lora_files, lora_id=1) + generate_and_test(llm, gptoss20b_lora_files, lora_id=2) @multi_gpu_test(num_gpus=2) @pytest.mark.parametrize("fully_sharded_loras", [False, True]) -@pytest.mark.parametrize("mxfp4_use_marlin", [True, False]) +@pytest.mark.parametrize( + "mxfp4_use_marlin", + [ + False, + pytest.param( + True, + marks=pytest.mark.skipif( + current_platform.is_rocm(), reason="marlin not supported" + ), + ), + ], +) def test_gpt_oss_lora_tp2( - monkeypatch: pytest.MonkeyPatch, gptoss20b_lora_files, fully_sharded_loras, mxfp4_use_marlin, ): - with monkeypatch.context() as m: - m.setenv("VLLM_MXFP4_USE_MARLIN", "1" if mxfp4_use_marlin else "0") - llm = vllm.LLM( - MODEL_PATH, - max_model_len=1024, - enable_lora=True, - max_loras=2, - max_num_seqs=2, - max_num_batched_tokens=2048, - tensor_parallel_size=2, - gpu_memory_utilization=0.8, - fully_sharded_loras=fully_sharded_loras, - enable_expert_parallel=not fully_sharded_loras, - compilation_config=vllm.config.CompilationConfig( # Avoid OOM - cudagraph_specialize_lora=False, - ), - ) + llm = vllm.LLM( + MODEL_PATH, + max_model_len=1024, + enable_lora=True, + max_loras=2, + max_num_seqs=2, + max_num_batched_tokens=2048, + tensor_parallel_size=2, + gpu_memory_utilization=0.8, + fully_sharded_loras=fully_sharded_loras, + enable_expert_parallel=not fully_sharded_loras, + moe_backend="marlin" if mxfp4_use_marlin else "auto", + linear_backend="marlin" if mxfp4_use_marlin else "auto", + compilation_config=vllm.config.CompilationConfig( + cudagraph_specialize_lora=False, + ), + ) - generate_and_test(llm, gptoss20b_lora_files, lora_id=1) - generate_and_test(llm, gptoss20b_lora_files, lora_id=2) + generate_and_test(llm, gptoss20b_lora_files, lora_id=1) + generate_and_test(llm, gptoss20b_lora_files, lora_id=2) diff --git a/tests/lora/test_lora_checkpoints.py b/tests/lora/test_lora_checkpoints.py index 7c263e2a2276..8db529223b24 100644 --- a/tests/lora/test_lora_checkpoints.py +++ b/tests/lora/test_lora_checkpoints.py @@ -6,7 +6,6 @@ from vllm.lora.lora_model import LoRAModel from vllm.lora.peft_helper import PEFTHelper from vllm.lora.utils import parse_fine_tuned_lora_name -from vllm.model_executor.models.baichuan import BaiChuanBaseForCausalLM from vllm.model_executor.models.gemma4 import Gemma4ForCausalLM from vllm.model_executor.models.utils import WeightsMapper @@ -18,6 +17,14 @@ "down_proj", ] +MOCK_PACKED_MAPPING = { + "W_pack": ["W_pack"], + "gate_up_proj": [ + "gate_proj", + "up_proj", + ], +} + @pytest.mark.parametrize("lora_name", lora_lst) def test_load_checkpoints( @@ -27,12 +34,10 @@ def test_load_checkpoints( baichuan_regex_lora_files, chatglm3_lora_files, ): - packed_modules_mapping = BaiChuanBaseForCausalLM.packed_modules_mapping - expected_lora_lst: list[str] = [] for module in BAICHUAN_LORA_MODULES: - if module in packed_modules_mapping: - expected_lora_lst.extend(packed_modules_mapping[module]) + if module in MOCK_PACKED_MAPPING: + expected_lora_lst.extend(MOCK_PACKED_MAPPING[module]) else: expected_lora_lst.append(module) expected_lora_modules = set(expected_lora_lst) @@ -98,12 +103,10 @@ def test_load_checkpoints( def test_lora_weights_mapping(baichuan_lora_files): - packed_modules_mapping = BaiChuanBaseForCausalLM.packed_modules_mapping - expected_lora_lst: list[str] = [] for module in BAICHUAN_LORA_MODULES: - if module in packed_modules_mapping: - expected_lora_lst.extend(packed_modules_mapping[module]) + if module in MOCK_PACKED_MAPPING: + expected_lora_lst.extend(MOCK_PACKED_MAPPING[module]) else: expected_lora_lst.append(module) expected_lora_modules = set(expected_lora_lst) diff --git a/tests/lora/test_lora_manager.py b/tests/lora/test_lora_manager.py index 49436d662431..1db05b4b09d5 100644 --- a/tests/lora/test_lora_manager.py +++ b/tests/lora/test_lora_manager.py @@ -124,6 +124,7 @@ def test_replace_submodules(default_vllm_config, dist_init, dummy_model): max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE ), torch.device(DEVICES[0]), + default_vllm_config, ) model = manager.model assert isinstance(model.get_submodule("dense1"), ColumnParallelLinearWithLoRA) @@ -152,6 +153,7 @@ class CustomReplicatedLinear(ReplicatedLinear): max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE ), torch.device(DEVICES[0]), + default_vllm_config, ) assert isinstance( @@ -172,6 +174,7 @@ def test_wrap_gate_linear(default_vllm_config, dist_init, dummy_model): max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE ), torch.device(DEVICES[0]), + default_vllm_config, ) assert isinstance( @@ -219,6 +222,7 @@ def __init__(self, g): max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE ), torch.device(DEVICES[0]), + default_vllm_config, ) canonical = manager.model.get_submodule("moe.gate") @@ -263,6 +267,7 @@ def test_lm_head_exempt_from_dedup(default_vllm_config, dist_init, dummy_model): max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE ), torch.device(DEVICES[0]), + default_vllm_config, ) # lm_head's special handling still ran: logits_processor got wrapped @@ -293,6 +298,7 @@ def __init__(self): max_lora_rank=8, max_cpu_loras=8, max_loras=8, lora_dtype=DEFAULT_DTYPE ), torch.device(DEVICES[0]), + default_vllm_config, ) # Should not crash and should keep unsupported matched modules unchanged. @@ -325,6 +331,7 @@ def __init__(self): target_modules=["dense1"], ), torch.device(DEVICES[0]), + default_vllm_config, ) @@ -374,6 +381,7 @@ def test_lora_model_manager(default_vllm_config, dist_init, dummy_model, device) max_lora_rank=8, max_cpu_loras=3, max_loras=2, lora_dtype=DEFAULT_DTYPE ), device=device, + vllm_config=default_vllm_config, ) assert all(x is None for x in manager.lora_index_to_id) assert manager.add_adapter(model_lora1) @@ -442,6 +450,7 @@ def test_lora_lru_cache_model_manager( max_lora_rank=8, max_cpu_loras=3, max_loras=2, lora_dtype=DEFAULT_DTYPE ), device=device, + vllm_config=default_vllm_config, ) assert all(x is None for x in manager.lora_index_to_id) assert manager.add_adapter(model_lora1) @@ -535,6 +544,7 @@ def test_lru_lora_model_manager(default_vllm_config, dist_init, dummy_model, dev max_lora_rank=8, max_cpu_loras=2, max_loras=2, lora_dtype=DEFAULT_DTYPE ), device=device, + vllm_config=default_vllm_config, ) assert all(x is None for x in manager.lora_index_to_id) @@ -642,9 +652,56 @@ def test_lru_lora_model_manager(default_vllm_config, dist_init, dummy_model, dev @pytest.mark.parametrize("device", DEVICES) -def test_lru_cache_worker_adapter_manager( - default_vllm_config, dist_init, dummy_model, device, tmp_path +def test_set_adapter_mapping_refreshes_after_slot_reassignment( + default_vllm_config, dist_init, dummy_model, device ): + # An out-of-band add_lora() can LRU-evict and reassign GPU slots while the + # running batch -- and therefore its LoRAMapping -- is unchanged. The + # punica metadata must still be re-derived, otherwise in-flight requests + # are routed to the evicted layout and decode with the wrong adapter. + model = dummy_model + model_lora1 = create_lora(1, model, ["dense1", "dense2", "lm_head"], device=device) + model_lora2 = create_lora(2, model, ["dense1", "dense2", "lm_head"], device=device) + model_lora3 = create_lora(3, model, ["dense1", "dense2", "lm_head"], device=device) + manager = LRUCacheLoRAModelManager( + model, + 2, + 2, + 2, + LoRAConfig( + max_lora_rank=8, max_cpu_loras=3, max_loras=2, lora_dtype=DEFAULT_DTYPE + ), + device=device, + vllm_config=default_vllm_config, + ) + punica_wrapper = manager.punica_wrapper_mapping[DEFAULT_LANGUAGE_WRAPPER_KEY] + + assert manager.add_adapter(model_lora1) + assert manager.activate_adapter(1) + assert manager.add_adapter(model_lora2) + assert manager.activate_adapter(2) + assert manager.lora_index_to_id == [1, 2] + + # Two in-flight requests, one token each, on adapters 1 and 2. + manager.set_adapter_mapping(LoRAMapping((1, 2), (1, 2))) + assert punica_wrapper.token_lora_indices.tolist() == [0, 1] + + # Out-of-band add_lora() with both slots held by the running batch: + # activating 3 evicts 1; re-activating the batch's adapters lands them + # in swapped slots while the batch itself is unchanged. + assert manager.add_adapter(model_lora3) + assert manager.activate_adapter(3) + assert manager.activate_adapter(1) + assert manager.activate_adapter(2) + assert manager.lora_index_to_id == [2, 1] + + # Identical mapping, but the metadata must follow the new slot layout. + manager.set_adapter_mapping(LoRAMapping((1, 2), (1, 2))) + assert punica_wrapper.token_lora_indices.tolist() == [1, 0] + + +@pytest.mark.parametrize("device", DEVICES) +def test_lru_cache_worker_adapter_manager(dist_init, dummy_model, device, tmp_path): lora_config = LoRAConfig( max_lora_rank=8, max_cpu_loras=4, max_loras=4, lora_dtype=DEFAULT_DTYPE ) @@ -670,7 +727,7 @@ def test_lru_cache_worker_adapter_manager( worker_adapter_manager.max_num_seqs = 4 worker_adapter_manager.max_num_batched_tokens = 2 - worker_adapter_manager.create_lora_manager(dummy_model) + worker_adapter_manager.create_lora_manager(dummy_model, vllm_config) mapping = LoRAMapping([], []) worker_adapter_manager.set_active_adapters( @@ -758,9 +815,7 @@ def test_lru_cache_worker_adapter_manager( @pytest.mark.parametrize("device", DEVICES) -def test_worker_adapter_manager( - default_vllm_config, dist_init, dummy_model_gate_up, device, tmp_path -): +def test_worker_adapter_manager(dist_init, dummy_model_gate_up, device, tmp_path): # Should remove every LoRA not specified in the request. lora_config = LoRAConfig( max_lora_rank=8, max_cpu_loras=4, max_loras=4, lora_dtype=DEFAULT_DTYPE @@ -774,7 +829,7 @@ def test_worker_adapter_manager( worker_adapter_manager = WorkerLoRAManager(vllm_config, device, EMBEDDING_MODULES) worker_adapter_manager.vocab_size = dummy_model_gate_up.unpadded_vocab_size - worker_adapter_manager.create_lora_manager(dummy_model_gate_up) + worker_adapter_manager.create_lora_manager(dummy_model_gate_up, vllm_config) dummy_lora_files = f"{tmp_path}/lora_adapter" os.makedirs(dummy_lora_files, exist_ok=True) @@ -894,6 +949,7 @@ def test_packed_loras(default_vllm_config, dist_init, dummy_model_gate_up, devic max_lora_rank=8, max_cpu_loras=2, max_loras=2, lora_dtype=DEFAULT_DTYPE ), device=device, + vllm_config=default_vllm_config, ) model = manager.model @@ -944,6 +1000,7 @@ def _test_target_modules( device: str, expected_lora: list[tuple[str, type]], expected_no_lora: list[tuple[str, type]], + vllm_config, ): """Create a LoRAModelManager and assert which modules have LoRA applied.""" LoRAModelManager( @@ -959,6 +1016,7 @@ def _test_target_modules( target_modules=target_modules, ), device=device, + vllm_config=vllm_config, ) for module_path, lora_cls in expected_lora: assert isinstance(model.get_submodule(module_path), lora_cls) @@ -981,6 +1039,7 @@ def test_target_modules_config(default_vllm_config, dist_init, dummy_model, devi ("dense2", RowParallelLinearWithLoRA), ("layer1.dense2", RowParallelLinearWithLoRA), ], + vllm_config=default_vllm_config, ) @@ -998,6 +1057,7 @@ def test_target_modules_multiple(default_vllm_config, dist_init, dummy_model, de ("layer1.dense2", RowParallelLinearWithLoRA), ], expected_no_lora=[], + vllm_config=default_vllm_config, ) @@ -1017,6 +1077,7 @@ def test_target_modules_none_uses_all( ("layer1.dense2", RowParallelLinearWithLoRA), ], expected_no_lora=[], + vllm_config=default_vllm_config, ) @@ -1036,4 +1097,5 @@ def test_target_modules_match_packed_runtime_modules( ("layer1.dense1", ColumnParallelLinearWithLoRA), ("layer1.dense2", RowParallelLinearWithLoRA), ], + vllm_config=default_vllm_config, ) diff --git a/tests/lora/test_punica_ops.py b/tests/lora/test_punica_ops.py index 7706d0e2aab7..f94b54d9fb17 100644 --- a/tests/lora/test_punica_ops.py +++ b/tests/lora/test_punica_ops.py @@ -5,7 +5,6 @@ import pytest import torch -import vllm.lora.ops.torch_ops as torch_ops import vllm.lora.ops.triton_ops as triton_ops from vllm.lora.ops.triton_ops import LoRAKernelMeta from vllm.lora.ops.triton_ops.utils import _LORA_A_PTR_DICT, _LORA_B_PTR_DICT @@ -22,6 +21,59 @@ def reset_device(reset_default_device): pass +@pytest.fixture(autouse=True) +def cleanup_fixture(): + """Override conftest's cleanup_fixture— not needed for punica tests.""" + yield + + +@pytest.fixture(autouse=True) +def dynamo_reset(): + """Override conftest's dynamo_reset — not needed for punica tests.""" + yield + + +def _cpu_bgmv_shrink( + inputs, lora_weight, output, seq_len_tensor, lora_indices, scaling=1.0 +): + """Memory-efficient shrink reference: per-LoRA matmul loop on CPU. + output[mask] = scaling * inputs[mask] @ weight.T""" + exploded = torch.repeat_interleave(lora_indices, seq_len_tensor) + for lid in exploded.unique(): + if lid < 0: + continue + mask = exploded == lid + inp = inputs[mask].to(output.dtype) + w = lora_weight[lid].to(output.dtype) + output[mask] = scaling * (inp @ w.T) + + +def _cpu_bgmv_expand( + inputs, + lora_weight, + output, + seq_len_tensor, + lora_indices, + offset=0, + add_inputs=False, +): + """Memory-efficient expand reference: per-LoRA matmul loop on CPU. + output[mask, offset:offset+n] (+)= inputs[mask] @ weight.T""" + exploded = torch.repeat_interleave(lora_indices, seq_len_tensor) + for lid in exploded.unique(): + if lid < 0: + continue + mask = exploded == lid + inp = inputs[mask].to(output.dtype) + w = lora_weight[lid].to(output.dtype) + n = w.shape[0] + result = inp @ w.T + if add_inputs: + output[mask, offset : offset + n] += result + else: + output[mask, offset : offset + n] = result + + # Utility shrink and expand operations used as reference implementations. def sgmv_shrink_for_nslices( nslices: int, @@ -36,22 +88,21 @@ def sgmv_shrink_for_nslices( num_tokens: int, scaling: float, ): - """ - Wrapper around torch_ops.sgmv_shrink that handles any nslices. - """ + """CPU reference for sgmv_shrink using per-LoRA matmul loop.""" + inp_cpu = inputs_tensor.cpu() + seq_cpu = seq_len_tensor.cpu() + idx_cpu = prompt_lora_mapping.cpu() + out_cpu = out_tensor.cpu() for index in range(nslices): - torch_ops.sgmv_shrink( - inputs_tensor, - lora_weights_lst[index], - out_tensor[index], - b_seq_start_loc, - seq_len_tensor, - prompt_lora_mapping, - batches, - max_seq_length, - num_tokens, - scaling, + _cpu_bgmv_shrink( + inp_cpu, + lora_weights_lst[index].cpu(), + out_cpu[index], + seq_cpu, + idx_cpu, + scaling=scaling, ) + out_tensor.copy_(out_cpu) def sgmv_expand_for_nslices( @@ -68,42 +119,21 @@ def sgmv_expand_for_nslices( num_tokens: int, add_inputs: bool, ) -> None: - """ - Wrapper around torch_ops.sgmv_expand that handles any nslices. - """ - if nslices == 1: - # Verify the torch's sgmv_expand op - torch_ops.sgmv_expand( - inputs_tensor[0], - lora_weights_lst[0], - out_tensor, - b_seq_start_loc, - seq_len_tensor, - prompt_lora_mapping, - batches, - max_seq_length, - num_tokens, + """CPU reference for sgmv_expand using per-LoRA matmul loop.""" + seq_cpu = seq_len_tensor.cpu() + idx_cpu = prompt_lora_mapping.cpu() + out_cpu = out_tensor.cpu() + for index in range(nslices): + _cpu_bgmv_expand( + inputs_tensor[index].cpu(), + lora_weights_lst[index].cpu(), + out_cpu, + seq_cpu, + idx_cpu, + offset=hidden_size * index, add_inputs=add_inputs, ) - else: - slice_offset = 0 - for index in range(nslices): - lora_weights = lora_weights_lst[index] - torch_ops.sgmv_expand_slice( - inputs_tensor[index], - lora_weights, - out_tensor, - b_seq_start_loc, - seq_len_tensor, - prompt_lora_mapping, - batches, - max_seq_length, - num_tokens, - slice_offset, - hidden_size, - add_inputs=add_inputs, - ) - slice_offset += hidden_size + out_tensor.copy_(out_cpu) _dict_lock = Lock() @@ -482,3 +512,127 @@ def test_kernels_hidden_size( seq_length=128, add_inputs=True, ) + + +@pytest.mark.parametrize("device", DEVICES) +def test_add_lora_fused_moe_early_exit(device): + """ + Ensures add_lora_fused_moe does not invoke the LoRA kernel or + modify the output tensor when no_lora_flag_cpu is True + """ + from types import SimpleNamespace + + from vllm.lora.punica_wrapper.punica_gpu import PunicaWrapperGPU + + torch.set_default_device(device) + torch.accelerator.set_device_index(device) + + max_loras, num_tokens = 4, 16 + num_experts, top_k, max_lora_rank = 8, 2, 16 + K, N = 256, 128 + + # build PunicaWrapperGPU with minimal lora_config mock + lora_config = SimpleNamespace( + max_loras=max_loras, + specialize_active_lora=False, + ) + wrapper = PunicaWrapperGPU( + max_num_batched_tokens=num_tokens, + max_batches=num_tokens, + device=device, + lora_config=lora_config, + ) + + # simulate a prior LoRA batch so the internal mapping is + # populated with stale LoRA IDs + lora_mapping = torch.zeros( + num_tokens, + dtype=torch.int32, + device=device, + ) + lora_mapping[:8] = 1 + lora_mapping[8:] = 2 + wrapper.token_mapping_meta.prepare_tensors(lora_mapping) + + # simulate a base-model batch (all -1) + base_mapping = torch.full( + (num_tokens,), + -1, + dtype=torch.int32, + device=device, + ) + wrapper.token_mapping_meta.prepare_tensors(base_mapping) + + assert wrapper.token_mapping_meta.no_lora_flag_cpu[0].item() is True + + # dummy tensors for add_lora_fused_moe + y = torch.rand(num_tokens, top_k, N, dtype=torch.bfloat16, device=device) + y_snapshot = y.clone() + x = torch.rand(num_tokens, K, dtype=torch.bfloat16, device=device) + + lora_a_stacked = ( + torch.rand( + max_loras, + num_experts, + max_lora_rank, + K, + dtype=torch.bfloat16, + device=device, + ), + ) + lora_b_stacked = ( + torch.rand( + max_loras, + num_experts, + N, + max_lora_rank, + dtype=torch.bfloat16, + device=device, + ), + ) + topk_weights = torch.ones( + num_tokens, + top_k, + dtype=torch.float32, + device=device, + ) + adapter_enabled = torch.ones( + max_loras + 1, + dtype=torch.int32, + device=device, + ) + shrink_config = expand_config = { + "BLOCK_SIZE_M": 16, + "BLOCK_SIZE_N": 32, + "BLOCK_SIZE_K": 64, + "GROUP_SIZE_M": 1, + "NUM_WARPS": 4, + "NUM_STAGES": 3, + "SPLIT_K": 1, + } + + # call add_lora_fused_moe - the early exit should prevent any + # modification to the output + wrapper.add_lora_fused_moe( + y=y, + x=x, + lora_a_stacked=lora_a_stacked, + lora_b_stacked=lora_b_stacked, + topk_weights=topk_weights, + sorted_token_ids=None, + expert_ids=torch.zeros( + num_tokens * top_k, + dtype=torch.int32, + device=device, + ), + num_tokens_post_padded=None, + max_lora_rank=max_lora_rank, + top_k_num=top_k, + shrink_config=shrink_config, + expand_config=expand_config, + adapter_enabled=adapter_enabled, + ) + + assert torch.equal(y, y_snapshot), ( + "add_lora_fused_moe modified output tensor despite no_lora_flag_cpu=True" + ) diff --git a/tests/lora/test_qwen3_with_multi_loras.py b/tests/lora/test_qwen3_with_multi_loras.py index 56bac026b491..0cc8884abafb 100644 --- a/tests/lora/test_qwen3_with_multi_loras.py +++ b/tests/lora/test_qwen3_with_multi_loras.py @@ -6,6 +6,8 @@ 2. test multi loras request """ +import os + import pytest from tests.utils import multi_gpu_test @@ -39,6 +41,18 @@ def format_chatml_messages( ] +@pytest.fixture(autouse=True) +def set_mrv2_env(): + original = os.environ.get("VLLM_USE_V2_MODEL_RUNNER", "0") + os.environ["VLLM_USE_V2_MODEL_RUNNER"] = "1" + yield + + if original is None: + os.environ.pop("VLLM_USE_V2_MODEL_RUNNER", None) + else: + os.environ["VLLM_USE_V2_MODEL_RUNNER"] = original + + def make_add_lora_request(name: str, path: str): global INCREASE_LORA_ID, LORA_NAME_ID_MAP @@ -61,7 +75,6 @@ def test_multi_loras_with_tp_sync(): max_lora_rank=LORA_RANK, max_model_len=512, gpu_memory_utilization=0.5, - enforce_eager=True, tensor_parallel_size=2, # ensure tp >= 2 max_cpu_loras=4, # ensure max_cpu_loras >= 2 ) @@ -167,7 +180,6 @@ def test_multiple_lora_requests(): max_lora_rank=LORA_RANK, max_model_len=512, gpu_memory_utilization=0.5, - enforce_eager=True, ) PROMPTS = ["Hello, my name is"] * 2 LORA_NAME = "Alice" @@ -203,7 +215,6 @@ def test_load_inplace_offline_reload( max_lora_rank=LORA_RANK, max_model_len=512, gpu_memory_utilization=0.5, - enforce_eager=True, ) adapter_id = 1 messages = format_chatml_messages( @@ -254,7 +265,6 @@ def test_load_inplace_false_no_reload( max_lora_rank=LORA_RANK, max_model_len=512, gpu_memory_utilization=0.5, - enforce_eager=True, ) adapter_id = 2 messages = format_chatml_messages( diff --git a/tests/lora/test_whisper.py b/tests/lora/test_whisper.py index ea8179a9c661..6f1a894cf916 100644 --- a/tests/lora/test_whisper.py +++ b/tests/lora/test_whisper.py @@ -12,6 +12,7 @@ import vllm from vllm.assets.audio import AudioAsset from vllm.lora.request import LoRARequest +from vllm.platforms import current_platform from ..utils import create_new_process_for_each_test @@ -30,7 +31,9 @@ def use_spawn_for_whisper(monkeypatch): monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn") -def create_whisper_llm(enable_lora: bool = True, max_loras: int = 2): +def create_whisper_llm( + enable_lora: bool = True, max_loras: int = 2, attn_backend: str | None = None +): """Create a Whisper LLM instance with optional LoRA support.""" return vllm.LLM( model=WHISPER_MODEL, @@ -40,6 +43,7 @@ def create_whisper_llm(enable_lora: bool = True, max_loras: int = 2): max_model_len=448, dtype="half", enforce_eager=True, # For stability in tests + attention_config={"backend": attn_backend}, ) @@ -109,7 +113,11 @@ def test_whisper_multi_lora(whisper_lora_files): This test verifies that the same LoRA adapter can be loaded with different IDs and produce consistent results. """ - llm = create_whisper_llm(enable_lora=True, max_loras=4) + llm = create_whisper_llm( + enable_lora=True, + max_loras=4, + attn_backend="TRITON_ATTN" if current_platform.is_rocm() else None, + ) # Test with different LoRA IDs using the same adapter outputs_lora1 = run_whisper_inference(llm, lora_path=whisper_lora_files, lora_id=1) diff --git a/tests/model_executor/layers/test_pooler_heads.py b/tests/model_executor/layers/test_pooler_heads.py new file mode 100644 index 000000000000..99097636f947 --- /dev/null +++ b/tests/model_executor/layers/test_pooler_heads.py @@ -0,0 +1,481 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for sequence and token pooler head classes.""" + +import torch +import torch.nn as nn + +from vllm.model_executor.layers.pooler.activations import PoolerNormalize +from vllm.model_executor.layers.pooler.seqwise.heads import ( + ClassifierPoolerHead, + EmbeddingPoolerHead, +) +from vllm.model_executor.layers.pooler.tokwise.heads import ( + TokenClassifierPoolerHead, + TokenEmbeddingPoolerHead, +) +from vllm.pooling_params import PoolingParams +from vllm.v1.pool.metadata import PoolingMetadata, PoolingStates + +_HIDDEN = 16 +_BATCH = 3 + + +def _make_params( + n: int, + *, + task: str = "embed", + dimensions: int | None = None, + use_activation: bool | None = None, +) -> list[PoolingParams]: + return [ + PoolingParams(task=task, dimensions=dimensions, use_activation=use_activation) + for _ in range(n) + ] + + +def _make_metadata(pooling_params: list[PoolingParams]) -> PoolingMetadata: + n = len(pooling_params) + return PoolingMetadata( + prompt_lens=torch.ones(n, dtype=torch.long), + prompt_token_ids=None, + prompt_token_ids_cpu=None, + pooling_params=pooling_params, + pooling_states=[PoolingStates() for _ in range(n)], + ) + + +def _linear(in_f: int, out_f: int) -> nn.Linear: + torch.manual_seed(42) + return nn.Linear(in_f, out_f, bias=False) + + +# --------------------------------------------------------------------------- +# EmbeddingPoolerHead +# --------------------------------------------------------------------------- +class TestEmbeddingPoolerHead: + def test_supported_tasks(self): + head = EmbeddingPoolerHead() + assert head.get_supported_tasks() == {"embed"} + + def test_passthrough(self): + head = EmbeddingPoolerHead() + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH)) + out = head(x, meta) + assert torch.equal(out, x) + + def test_head_dtype(self): + head = EmbeddingPoolerHead(head_dtype=torch.float16) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH)) + out = head(x, meta) + assert out.dtype == torch.float16 + + def test_projector(self): + proj = _linear(_HIDDEN, 8) + head = EmbeddingPoolerHead(projector=proj) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH)) + out = head(x, meta) + assert out.shape == (_BATCH, 8) + assert torch.allclose(out, proj(x)) + + def test_matryoshka_uniform(self): + head = EmbeddingPoolerHead() + x = torch.randn(_BATCH, _HIDDEN) + params = _make_params(_BATCH, dimensions=4) + meta = _make_metadata(params) + out = head(x, meta) + assert out.shape == (_BATCH, 4) + assert torch.equal(out, x[..., :4]) + + def test_matryoshka_mixed(self): + head = EmbeddingPoolerHead() + x = torch.randn(2, _HIDDEN) + params = [ + PoolingParams(task="embed", dimensions=4), + PoolingParams(task="embed", dimensions=8), + ] + meta = _make_metadata(params) + out = head(x, meta) + assert isinstance(out, list) + assert len(out) == 2 + assert out[0].shape[-1] == 4 + assert out[1].shape[-1] == 8 + + def test_matryoshka_mixed_with_none(self): + head = EmbeddingPoolerHead() + x = torch.randn(2, _HIDDEN) + params = [ + PoolingParams(task="embed", dimensions=4), + PoolingParams(task="embed", dimensions=None), + ] + meta = _make_metadata(params) + out = head(x, meta) + assert isinstance(out, list) + assert out[0].shape[-1] == 4 + assert torch.equal(out[1], x[1]) + + def test_activation_uniform_true(self): + head = EmbeddingPoolerHead(activation=PoolerNormalize()) + x = torch.randn(_BATCH, _HIDDEN) + params = _make_params(_BATCH, use_activation=True) + meta = _make_metadata(params) + out = head(x, meta) + norms = torch.linalg.norm(out, dim=-1) + assert torch.allclose(norms, torch.ones(_BATCH), atol=1e-5) + + def test_activation_uniform_false(self): + head = EmbeddingPoolerHead(activation=PoolerNormalize()) + x = torch.randn(_BATCH, _HIDDEN) + params = _make_params(_BATCH, use_activation=False) + meta = _make_metadata(params) + out = head(x, meta) + assert torch.equal(out, x) + + def test_activation_mixed_flags(self): + head = EmbeddingPoolerHead(activation=PoolerNormalize()) + x = torch.randn(2, _HIDDEN) + params = [ + PoolingParams(task="embed", use_activation=True), + PoolingParams(task="embed", use_activation=False), + ] + meta = _make_metadata(params) + out = head(x, meta) + assert isinstance(out, list) + norm_0 = torch.linalg.norm(out[0], dim=-1) + assert torch.allclose(norm_0, torch.ones(1), atol=1e-5) + assert torch.equal(out[1], x[1]) + + def test_list_input_gets_stacked(self): + head = EmbeddingPoolerHead() + tensors = [torch.randn(_HIDDEN) for _ in range(_BATCH)] + meta = _make_metadata(_make_params(_BATCH)) + out = head(tensors, meta) + assert out.shape == (_BATCH, _HIDDEN) + expected = torch.stack(tensors) + assert torch.equal(out, expected) + + def test_projector_then_matryoshka(self): + proj = _linear(_HIDDEN, 8) + head = EmbeddingPoolerHead(projector=proj) + x = torch.randn(_BATCH, _HIDDEN) + params = _make_params(_BATCH, dimensions=4) + meta = _make_metadata(params) + out = head(x, meta) + assert out.shape == (_BATCH, 4) + assert torch.equal(out, proj(x)[..., :4]) + + def test_matryoshka_then_activation(self): + head = EmbeddingPoolerHead(activation=PoolerNormalize()) + x = torch.randn(_BATCH, _HIDDEN) + params = _make_params(_BATCH, dimensions=4, use_activation=True) + meta = _make_metadata(params) + out = head(x, meta) + assert out.shape == (_BATCH, 4) + norms = torch.linalg.norm(out, dim=-1) + assert torch.allclose(norms, torch.ones(_BATCH), atol=1e-5) + + def test_empty_batch(self): + head = EmbeddingPoolerHead() + x = torch.randn(0, _HIDDEN) + meta = _make_metadata([]) + out = head(x, meta) + assert out.shape == (0, _HIDDEN) + + +# --------------------------------------------------------------------------- +# ClassifierPoolerHead +# --------------------------------------------------------------------------- +class TestClassifierPoolerHead: + def test_supported_tasks(self): + head = ClassifierPoolerHead() + assert head.get_supported_tasks() == {"classify"} + + def test_passthrough(self): + head = ClassifierPoolerHead() + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(x, meta) + assert torch.equal(out, x) + + def test_head_dtype(self): + head = ClassifierPoolerHead(head_dtype=torch.float16) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(x, meta) + assert out.dtype == torch.float16 + + def test_classifier(self): + clf = _linear(_HIDDEN, 3) + head = ClassifierPoolerHead(classifier=clf) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(x, meta) + assert out.shape == (_BATCH, 3) + assert torch.allclose(out, clf(x)) + + def test_logit_mean(self): + head = ClassifierPoolerHead(logit_mean=2.0) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(x, meta) + assert torch.allclose(out, x - 2.0) + + def test_logit_sigma(self): + head = ClassifierPoolerHead(logit_sigma=0.5) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(x, meta) + assert torch.allclose(out, x / 0.5) + + def test_platt_scaling_combined(self): + head = ClassifierPoolerHead(logit_mean=1.0, logit_sigma=2.0) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(x, meta) + assert torch.allclose(out, (x - 1.0) / 2.0) + + def test_activation_uniform_true(self): + head = ClassifierPoolerHead(activation=PoolerNormalize()) + x = torch.randn(_BATCH, _HIDDEN) + params = _make_params(_BATCH, task="classify", use_activation=True) + meta = _make_metadata(params) + out = head(x, meta) + norms = torch.linalg.norm(out, dim=-1) + assert torch.allclose(norms, torch.ones(_BATCH), atol=1e-5) + + def test_activation_uniform_false(self): + head = ClassifierPoolerHead(activation=PoolerNormalize()) + x = torch.randn(_BATCH, _HIDDEN) + params = _make_params(_BATCH, task="classify", use_activation=False) + meta = _make_metadata(params) + out = head(x, meta) + assert torch.equal(out, x) + + def test_activation_mixed_flags(self): + head = ClassifierPoolerHead(activation=PoolerNormalize()) + x = torch.randn(2, _HIDDEN) + params = [ + PoolingParams(task="classify", use_activation=True), + PoolingParams(task="classify", use_activation=False), + ] + meta = _make_metadata(params) + out = head(x, meta) + assert isinstance(out, list) + norm_0 = torch.linalg.norm(out[0], dim=-1) + assert torch.allclose(norm_0, torch.ones(1), atol=1e-5) + assert torch.equal(out[1], x[1]) + + def test_list_input_gets_stacked(self): + head = ClassifierPoolerHead() + tensors = [torch.randn(_HIDDEN) for _ in range(_BATCH)] + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(tensors, meta) + assert out.shape == (_BATCH, _HIDDEN) + expected = torch.stack(tensors) + assert torch.equal(out, expected) + + def test_classifier_then_platt_scaling(self): + clf = _linear(_HIDDEN, 3) + head = ClassifierPoolerHead(classifier=clf, logit_mean=1.0, logit_sigma=2.0) + x = torch.randn(_BATCH, _HIDDEN) + meta = _make_metadata(_make_params(_BATCH, task="classify")) + out = head(x, meta) + expected = (clf(x) - 1.0) / 2.0 + assert torch.allclose(out, expected) + + def test_empty_batch(self): + head = ClassifierPoolerHead() + x = torch.randn(0, _HIDDEN) + meta = _make_metadata([]) + out = head(x, meta) + assert out.shape == (0, _HIDDEN) + + +# --------------------------------------------------------------------------- +# TokenEmbeddingPoolerHead +# --------------------------------------------------------------------------- +class TestTokenEmbeddingPoolerHead: + def test_supported_tasks(self): + head = TokenEmbeddingPoolerHead() + assert head.get_supported_tasks() == {"token_embed"} + + def test_passthrough(self): + head = TokenEmbeddingPoolerHead() + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed") + out = head.forward_chunk(x, param) + assert torch.equal(out, x) + + def test_none_chunked_prefill(self): + head = TokenEmbeddingPoolerHead() + param = PoolingParams(task="token_embed") + out = head.forward_chunk(None, param) + assert out is None + + def test_head_dtype(self): + head = TokenEmbeddingPoolerHead(head_dtype=torch.float16) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed") + out = head.forward_chunk(x, param) + assert out.dtype == torch.float16 + + def test_projector(self): + proj = _linear(_HIDDEN, 8) + head = TokenEmbeddingPoolerHead(projector=proj) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed") + out = head.forward_chunk(x, param) + assert out.shape == (5, 8) + assert torch.allclose(out, proj(x)) + + def test_matryoshka_truncation(self): + head = TokenEmbeddingPoolerHead() + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed", dimensions=4) + out = head.forward_chunk(x, param) + assert out.shape == (5, 4) + assert torch.equal(out, x[..., :4]) + + def test_activation_true(self): + head = TokenEmbeddingPoolerHead(activation=PoolerNormalize()) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed", use_activation=True) + out = head.forward_chunk(x, param) + norms = torch.linalg.norm(out, dim=-1) + assert torch.allclose(norms, torch.ones(5), atol=1e-5) + + def test_activation_false(self): + head = TokenEmbeddingPoolerHead(activation=PoolerNormalize()) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed", use_activation=False) + out = head.forward_chunk(x, param) + assert torch.equal(out, x) + + def test_projector_then_matryoshka(self): + proj = _linear(_HIDDEN, 8) + head = TokenEmbeddingPoolerHead(projector=proj) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed", dimensions=4) + out = head.forward_chunk(x, param) + assert out.shape == (5, 4) + assert torch.equal(out, proj(x)[..., :4]) + + def test_matryoshka_then_activation(self): + head = TokenEmbeddingPoolerHead(activation=PoolerNormalize()) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_embed", dimensions=4, use_activation=True) + out = head.forward_chunk(x, param) + assert out.shape == (5, 4) + norms = torch.linalg.norm(out, dim=-1) + assert torch.allclose(norms, torch.ones(5), atol=1e-5) + + def test_forward_mixed_batch_chunked_prefill(self): + head = TokenEmbeddingPoolerHead() + pooled_data = [torch.randn(5, _HIDDEN), None, torch.randn(3, _HIDDEN)] + params = _make_params(3, task="token_embed") + meta = _make_metadata(params) + out = head(pooled_data, meta) + assert len(out) == 3 + assert torch.equal(out[0], pooled_data[0]) + assert out[1] is None + assert torch.equal(out[2], pooled_data[2]) + + def test_forward_empty_batch(self): + head = TokenEmbeddingPoolerHead() + meta = _make_metadata([]) + out = head([], meta) + assert out == [] + + +# --------------------------------------------------------------------------- +# TokenClassifierPoolerHead +# --------------------------------------------------------------------------- +class TestTokenClassifierPoolerHead: + def test_supported_tasks(self): + head = TokenClassifierPoolerHead() + assert head.get_supported_tasks() == {"token_classify"} + + def test_passthrough(self): + head = TokenClassifierPoolerHead() + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify") + out = head.forward_chunk(x, param) + assert torch.equal(out, x) + + def test_none_chunked_prefill(self): + head = TokenClassifierPoolerHead() + param = PoolingParams(task="token_classify") + out = head.forward_chunk(None, param) + assert out is None + + def test_head_dtype(self): + head = TokenClassifierPoolerHead(head_dtype=torch.float16) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify") + out = head.forward_chunk(x, param) + assert out.dtype == torch.float16 + + def test_classifier(self): + clf = _linear(_HIDDEN, 3) + head = TokenClassifierPoolerHead(classifier=clf) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify") + out = head.forward_chunk(x, param) + assert out.shape == (5, 3) + assert torch.allclose(out, clf(x)) + + def test_logit_mean(self): + head = TokenClassifierPoolerHead(logit_mean=2.0) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify") + out = head.forward_chunk(x, param) + assert torch.allclose(out, x - 2.0) + + def test_logit_sigma(self): + head = TokenClassifierPoolerHead(logit_sigma=0.5) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify") + out = head.forward_chunk(x, param) + assert torch.allclose(out, x / 0.5) + + def test_platt_scaling_combined(self): + head = TokenClassifierPoolerHead(logit_mean=1.0, logit_sigma=2.0) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify") + out = head.forward_chunk(x, param) + assert torch.allclose(out, (x - 1.0) / 2.0) + + def test_activation_true(self): + head = TokenClassifierPoolerHead(activation=PoolerNormalize()) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify", use_activation=True) + out = head.forward_chunk(x, param) + norms = torch.linalg.norm(out, dim=-1) + assert torch.allclose(norms, torch.ones(5), atol=1e-5) + + def test_activation_false(self): + head = TokenClassifierPoolerHead(activation=PoolerNormalize()) + x = torch.randn(5, _HIDDEN) + param = PoolingParams(task="token_classify", use_activation=False) + out = head.forward_chunk(x, param) + assert torch.equal(out, x) + + def test_forward_mixed_batch_chunked_prefill(self): + head = TokenClassifierPoolerHead() + pooled_data = [torch.randn(5, _HIDDEN), None, torch.randn(3, _HIDDEN)] + params = _make_params(3, task="token_classify") + meta = _make_metadata(params) + out = head(pooled_data, meta) + assert len(out) == 3 + assert torch.equal(out[0], pooled_data[0]) + assert out[1] is None + assert torch.equal(out[2], pooled_data[2]) + + def test_forward_empty_batch(self): + head = TokenClassifierPoolerHead() + meta = _make_metadata([]) + out = head([], meta) + assert out == [] diff --git a/tests/model_executor/layers/test_pooler_methods.py b/tests/model_executor/layers/test_pooler_methods.py index cb8533cacb84..28b2fc7a78e5 100644 --- a/tests/model_executor/layers/test_pooler_methods.py +++ b/tests/model_executor/layers/test_pooler_methods.py @@ -119,7 +119,7 @@ def test_rejects_partial_prefill(self): hidden = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) metadata = _make_metadata([3], num_scheduled_tokens=[2]) pooler = CLSPool() - with pytest.raises(AssertionError, match="partial prefill"): + with pytest.raises(RuntimeError, match="partial prefill"): pooler(hidden, metadata) @@ -202,7 +202,7 @@ def test_rejects_partial_prefill(self): hidden = torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtype=torch.float32) metadata = _make_metadata([3], num_scheduled_tokens=[2]) pooler = MeanPool() - with pytest.raises(AssertionError, match="partial prefill"): + with pytest.raises(RuntimeError, match="partial prefill"): pooler(hidden, metadata) def test_chunked_accumulation(self): diff --git a/tests/model_executor/model_loader/fastsafetensors_loader/test_weight_utils.py b/tests/model_executor/model_loader/fastsafetensors_loader/test_weight_utils.py index 1975eb61b25d..da974131f65a 100644 --- a/tests/model_executor/model_loader/fastsafetensors_loader/test_weight_utils.py +++ b/tests/model_executor/model_loader/fastsafetensors_loader/test_weight_utils.py @@ -20,7 +20,9 @@ not current_platform.is_cuda_alike(), reason="fastsafetensors requires NVIDIA/AMD GPUs", ) -def test_fastsafetensors_model_loader(): +@pytest.mark.parametrize("queue_size", [0, 1]) +def test_fastsafetensors_model_loader(monkeypatch, queue_size): + monkeypatch.setenv("VLLM_FASTSAFETENSORS_QUEUE_SIZE", str(queue_size)) with tempfile.TemporaryDirectory() as tmpdir: huggingface_hub.constants.HF_HUB_OFFLINE = False download_weights_from_hf( @@ -45,7 +47,3 @@ def test_fastsafetensors_model_loader(): assert fastsafetensors_tensor.dtype == hf_safetensors_tensors[name].dtype assert fastsafetensors_tensor.shape == hf_safetensors_tensors[name].shape assert torch.all(fastsafetensors_tensor.eq(hf_safetensors_tensors[name])) - - -if __name__ == "__main__": - test_fastsafetensors_model_loader() diff --git a/tests/model_executor/model_loader/runai_streamer_loader/test_runai_model_streamer_loader.py b/tests/model_executor/model_loader/runai_streamer_loader/test_runai_model_streamer_loader.py index c7158dae537a..9164b8e4bea9 100644 --- a/tests/model_executor/model_loader/runai_streamer_loader/test_runai_model_streamer_loader.py +++ b/tests/model_executor/model_loader/runai_streamer_loader/test_runai_model_streamer_loader.py @@ -1,11 +1,16 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import os +import types +from unittest.mock import patch + import pytest from vllm import SamplingParams from vllm.config.load import LoadConfig from vllm.model_executor.model_loader import get_model_loader +from vllm.model_executor.model_loader import runai_streamer_loader as rsl load_format = "runai_streamer" test_model = "openai-community/gpt2" @@ -53,3 +58,67 @@ def test_runai_model_loader_download_files_gcs( with vllm_runner(test_gcs_model, load_format=load_format) as llm: deserialized_outputs = llm.generate(prompts, sampling_params) assert deserialized_outputs + + +def test_runai_passes_revision_by_name(): + # revision must reach download_safetensors_index_file_from_hf as the + # ``revision`` keyword, not the positional ``subfolder`` slot. + fake_self = types.SimpleNamespace( + load_config=types.SimpleNamespace(download_dir="/cache", ignore_patterns=[]) + ) + with ( + patch.object(rsl, "is_runai_obj_uri", return_value=False), + patch.object(rsl, "download_weights_from_hf", return_value="/folder"), + patch.object( + rsl, "list_safetensors", return_value=["/folder/model.safetensors"] + ), + patch.object(rsl, "download_safetensors_index_file_from_hf") as mock_idx, + ): + rsl.RunaiModelStreamerLoader._prepare_weights(fake_self, "org/model", "myrev") + + mock_idx.assert_called_once() + assert mock_idx.call_args.kwargs.get("revision") == "myrev" + assert "myrev" not in mock_idx.call_args.args + + +def _runai_loader(extra): + return rsl.RunaiModelStreamerLoader( + LoadConfig(load_format="runai_streamer", model_loader_extra_config=extra) + ) + + +@pytest.mark.parametrize( + "extra, match", + [ + ({"typo_key": 1}, "Unexpected extra config"), + ({"distributed": "yes"}, "distributed must be a bool"), + ({"concurrency": "16"}, "concurrency must be a positive integer"), + ({"concurrency": -1}, "concurrency must be a positive integer"), + ], +) +def test_runai_rejects_invalid_extra_config(extra, match): + # The loader used to silently drop unknown keys / wrong types / negatives. + with pytest.raises(ValueError, match=match): + _runai_loader(extra) + + +def test_runai_accepts_valid_extra_config(): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("RUNAI_STREAMER_CONCURRENCY", None) + os.environ.pop("RUNAI_STREAMER_MEMORY_LIMIT", None) + loader = _runai_loader( + {"distributed": True, "concurrency": 16, "memory_limit": 1024} + ) + assert loader._is_distributed is True + assert os.environ["RUNAI_STREAMER_CONCURRENCY"] == "16" + assert os.environ["RUNAI_STREAMER_MEMORY_LIMIT"] == "1024" + + +def test_runai_invalid_extra_config_leaves_environ_untouched(): + # A later invalid key must not leave an earlier valid key applied to + # os.environ (all values are validated before any global mutation). + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("RUNAI_STREAMER_CONCURRENCY", None) + with pytest.raises(ValueError, match="memory_limit must be an integer >= -1"): + _runai_loader({"concurrency": 16, "memory_limit": -5}) + assert "RUNAI_STREAMER_CONCURRENCY" not in os.environ diff --git a/tests/model_executor/model_loader/test_registry.py b/tests/model_executor/model_loader/test_registry.py index 020988ccac13..95b797bb5146 100644 --- a/tests/model_executor/model_loader/test_registry.py +++ b/tests/model_executor/model_loader/test_registry.py @@ -8,6 +8,7 @@ from vllm.config.load import LoadConfig from vllm.model_executor.model_loader import get_model_loader, register_model_loader from vllm.model_executor.model_loader.base_loader import BaseModelLoader +from vllm.model_executor.model_loader.default_loader import DefaultModelLoader @register_model_loader("custom_load_format") @@ -33,3 +34,57 @@ def test_invalid_model_loader(): @register_model_loader("invalid_load_format") class InValidModelLoader: pass + + +def test_default_loader_rejects_zero_num_threads(): + # num_threads=0 used to fail late in ThreadPoolExecutor ("max_workers must be > 0"). + with pytest.raises(ValueError, match="num_threads"): + DefaultModelLoader( + LoadConfig( + model_loader_extra_config={ + "enable_multithread_load": True, + "num_threads": 0, + } + ) + ) + + +def test_default_loader_rejects_multithread_with_non_lazy_strategy(): + # The multi-thread loader ignores safetensors_load_strategy; reject the + # combination instead of silently dropping the requested strategy. + with pytest.raises(ValueError, match="does not support"): + DefaultModelLoader( + LoadConfig( + safetensors_load_strategy="torchao", + model_loader_extra_config={"enable_multithread_load": True}, + ) + ) + + +def test_default_loader_explicit_safetensors_does_not_misread_pt(tmp_path): + # Explicit safetensors must not fall back to a .pt and open it as safetensors. + (tmp_path / "model.pt").write_bytes(b"\x00\x00\x00\x00") + loader = DefaultModelLoader(LoadConfig(load_format="safetensors")) + with pytest.raises(RuntimeError, match="Cannot find any model weights"): + loader._prepare_weights( + str(tmp_path), + None, + None, + fall_back_to_pt=True, + allow_patterns_overrides=None, + ) + + +def test_default_loader_hf_still_falls_back_to_pt(tmp_path): + # Control: load_format="hf" still picks up .pt weights via fallback. + (tmp_path / "model.pt").write_bytes(b"\x00\x00\x00\x00") + loader = DefaultModelLoader(LoadConfig(load_format="hf")) + _, files, use_safetensors = loader._prepare_weights( + str(tmp_path), + None, + None, + fall_back_to_pt=True, + allow_patterns_overrides=None, + ) + assert use_safetensors is False + assert any(f.endswith("model.pt") for f in files) diff --git a/tests/model_executor/model_loader/test_reload.py b/tests/model_executor/model_loader/test_reload.py index 0a290a00a83b..b3ed0c11bbd1 100644 --- a/tests/model_executor/model_loader/test_reload.py +++ b/tests/model_executor/model_loader/test_reload.py @@ -25,6 +25,10 @@ ) from vllm.model_executor.model_loader.reload.types import LayerReloadingInfo from vllm.model_executor.model_loader.reload.utils import get_layer_tensors +from vllm.model_executor.model_loader.weight_utils import ( + composed_weight_loader, + default_weight_loader, +) from vllm.platforms import current_platform @@ -178,6 +182,83 @@ def complex_weight_loader(param, loaded_weight): assert ret == "value" +def test_get_numel_loaded_caps_at_param_size(): + # composed_weight_loader copies into the param twice (the load and the + # in-place post-load transform), but only param.numel() distinct elements + # are loaded. get_numel_loaded must not double-count, otherwise a layer's + # loaded-element total can be reached early and trailing params get dropped. + param = torch.empty(10) + loaded_weight = torch.ones(10) + loader = composed_weight_loader(default_weight_loader, lambda x: x + 1) + + args = inspect.signature(loader).bind(param, loaded_weight) + num_loaded, _ = get_numel_loaded(loader, args) + assert num_loaded == 10 + + +class _ComposedLoaderLayer(torch.nn.Module): + """Mimics a Mamba2 mixer's equal-numel direct params (A, D, dt_bias). + + ``A`` uses ``composed_weight_loader`` (an extra in-place transform copy), + matching ``MambaMixer2`` where ``A`` is loaded as ``-exp(A_log)``. + """ + + def __init__(self): + super().__init__() + self.A = torch.nn.Parameter(torch.empty(4, dtype=torch.float32)) + self.D = torch.nn.Parameter(torch.ones(4)) + self.dt_bias = torch.nn.Parameter(torch.ones(4)) + self.A.weight_loader = composed_weight_loader( + default_weight_loader, lambda x: -torch.exp(x.float()) + ) + self.D.weight_loader = default_weight_loader + self.dt_bias.weight_loader = default_weight_loader + + +def test_layerwise_reload_composed_loader_does_not_drop_params(monkeypatch): + # Regression test: a composed_weight_loader param (A) used to double-count + # its elements, finalizing the layer before the trailing param (D) was + # loaded and leaving it as uninitialized materialized memory. + layer = _ComposedLoaderLayer() + model = torch.nn.Sequential(layer) + + def materialize_with_sentinel(meta_tensor): + tensor = torch.empty_strided( + size=tuple(meta_tensor.size()), + stride=tuple(meta_tensor.stride()), + dtype=meta_tensor.dtype, + requires_grad=False, + ) + tensor.fill_(float("nan")) + tensor.__class__ = meta_tensor.__class__ + tensor.__dict__ = meta_tensor.__dict__.copy() + return tensor + + monkeypatch.setattr( + reload_meta, "materialize_meta_tensor", materialize_with_sentinel + ) + + loaded = { + "A": torch.full((4,), 0.5), + "dt_bias": torch.full((4,), 3.0), + "D": torch.full((4,), 7.0), + } + + record_metadata_for_reloading(model) + initialize_layerwise_reload(model) + # Mimic real load_weights: resolve params once, then load in checkpoint + # order with D last (the param that was dropped). + params = dict(layer.named_parameters()) + for name in ("A", "dt_bias", "D"): + param = params[name] + param.weight_loader(param, loaded[name]) + finalize_layerwise_reload(model, model_config=None) + + assert torch.equal(layer.A, -torch.exp(loaded["A"])) + assert torch.equal(layer.dt_bias, loaded["dt_bias"]) + assert torch.equal(layer.D, loaded["D"]) + + def test_layerwise_reload_skips_non_persistent_parameter_alias_buffers(monkeypatch): layer = _AliasedBufferLayer() model = torch.nn.Sequential(layer) diff --git a/tests/model_executor/model_loader/test_sharded_state_loader.py b/tests/model_executor/model_loader/test_sharded_state_loader.py index 78134ae38333..a0b5a2a4aeca 100644 --- a/tests/model_executor/model_loader/test_sharded_state_loader.py +++ b/tests/model_executor/model_loader/test_sharded_state_loader.py @@ -97,7 +97,7 @@ def test_sharded_state_loader( ctx = mp.get_context("spawn") platform_args = {} - if current_platform.is_rocm(): + if current_platform.is_rocm() or current_platform.is_xpu(): platform_args["max_num_seqs"] = 1 # Run in separate processes for memory & CUDA isolation diff --git a/tests/model_executor/test_cpu_unquantized_gemm_dispatch.py b/tests/model_executor/test_cpu_unquantized_gemm_dispatch.py index 322897c02468..f18780cf6b57 100644 --- a/tests/model_executor/test_cpu_unquantized_gemm_dispatch.py +++ b/tests/model_executor/test_cpu_unquantized_gemm_dispatch.py @@ -66,3 +66,26 @@ def test_dispatch_cpu_unquantized_gemm_zen_remove_weight(monkeypatch): utils.dispatch_cpu_unquantized_gemm(layer, remove_weight=True) assert layer.weight.numel() == 0 + + +@pytest.mark.usefixtures("_mock_zentorch_linear_unary") +def test_dispatch_cpu_unquantized_gemm_logs_zentorch_dispatch(monkeypatch): + monkeypatch.setattr(current_platform, "is_zen_cpu", lambda: True) + expected_prepacked = bool(utils.envs.VLLM_ZENTORCH_WEIGHT_PREPACK) and hasattr( + torch.ops.zentorch, "zentorch_weight_prepack_for_linear" + ) + + log_calls = [] + monkeypatch.setattr( + utils.logger, "debug_once", lambda *args: log_calls.append(args) + ) + + layer = torch.nn.Linear(16, 8, bias=True) + utils.dispatch_cpu_unquantized_gemm(layer, remove_weight=False) + + assert log_calls == [ + ( + "CPU unquantized GEMM dispatch: using zentorch_linear_unary (prepacked=%s)", + expected_prepacked, + ) + ] diff --git a/tests/model_executor/test_eagle_quantization.py b/tests/model_executor/test_eagle_quantization.py index 481715da9cd7..72c189d83313 100644 --- a/tests/model_executor/test_eagle_quantization.py +++ b/tests/model_executor/test_eagle_quantization.py @@ -100,32 +100,6 @@ def test_fc_layer_quant_config_usage(default_vllm_config, dist_init, device) -> assert output.shape == (2, output_size) -def test_kv_cache_scale_name_handling(): - # Mock a quant config that supports cache scales - mock_quant_config = Mock() - mock_quant_config.get_cache_scale = Mock(return_value="layers.0.self_attn.kv_scale") - - # Condition check in load_weights - name = "layers.0.self_attn.k_proj.weight" - scale_name = mock_quant_config.get_cache_scale(name) - - # Check if get_cache_scale is called and returns expected value - mock_quant_config.get_cache_scale.assert_called_once_with(name) - assert scale_name == "layers.0.self_attn.kv_scale" - - -def test_kv_cache_scale_name_no_scale(): - # Mock a quant config that returns None for get_cache_scale - mock_quant_config = Mock() - mock_quant_config.get_cache_scale = Mock(return_value=None) - - name = "layers.0.mlp.gate_proj.weight" - scale_name = mock_quant_config.get_cache_scale(name) - - # Should return None for weights that don't have cache scales - assert scale_name is None - - def test_maybe_remap_kv_scale_name(): from vllm.model_executor.model_loader.weight_utils import maybe_remap_kv_scale_name @@ -183,33 +157,3 @@ def test_eagle3_lm_head_receives_quant_config(): assert call_kwargs["quant_config"] is mock_quant_config, ( "ParallelLMHead must receive the draft model's quant_config" ) - - -def test_load_weights_kv_scale_handling(): - kv_scale_param = Mock() - kv_scale_param.weight_loader = Mock() - - params_dict = { - "layers.0.self_attn.kv_scale": kv_scale_param, - } - - mock_quant_config = Mock() - mock_quant_config.get_cache_scale = Mock(return_value="layers.0.self_attn.kv_scale") - - # Load_weights logic for KV cache scales - name = "layers.0.self_attn.k_proj.weight" - loaded_weight_tensor = torch.tensor([1.0, 2.0]) - - if mock_quant_config is not None: - scale_name = mock_quant_config.get_cache_scale(name) - if scale_name: - param = params_dict[scale_name] - assert param is kv_scale_param - weight_to_load = ( - loaded_weight_tensor - if loaded_weight_tensor.dim() == 0 - else loaded_weight_tensor[0] - ) - - assert scale_name == "layers.0.self_attn.kv_scale" - assert weight_to_load == loaded_weight_tensor[0] diff --git a/tests/model_executor/test_flashinfer_autotune_cache.py b/tests/model_executor/test_flashinfer_autotune_cache.py deleted file mode 100644 index 7e6a83bb4d14..000000000000 --- a/tests/model_executor/test_flashinfer_autotune_cache.py +++ /dev/null @@ -1,60 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import sys -from hashlib import sha256 -from pathlib import Path -from types import SimpleNamespace - -from vllm.model_executor.warmup import kernel_warmup - - -def test_resolve_flashinfer_autotune_file_default_layout( - monkeypatch, tmp_path: Path -) -> None: - fake_jit = SimpleNamespace( - env=SimpleNamespace( - FLASHINFER_WORKSPACE_DIR=Path("/flashinfer-cache/0.6.11.post2/103a") - ) - ) - fake_flashinfer = SimpleNamespace(jit=fake_jit) - monkeypatch.setitem(sys.modules, "flashinfer", fake_flashinfer) - monkeypatch.setitem(sys.modules, "flashinfer.jit", fake_jit) - monkeypatch.setattr( - kernel_warmup, "aot_compile_hash_factors", lambda _: ["env-hash", "config-hash"] - ) - monkeypatch.setattr(kernel_warmup.envs, "VLLM_CACHE_ROOT", str(tmp_path)) - monkeypatch.setattr(kernel_warmup.envs, "VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR", None) - - runner = SimpleNamespace(vllm_config=SimpleNamespace()) - cache_hash = sha256(str(["env-hash", "config-hash"]).encode()).hexdigest() - - path = kernel_warmup._resolve_flashinfer_autotune_file(runner) - - assert path == ( - tmp_path - / "flashinfer_autotune_cache" - / "0.6.11.post2" - / "103a" - / cache_hash - / "autotune_configs.json" - ) - assert path.parent.is_dir() - - -def test_resolve_flashinfer_autotune_file_uses_override_dir( - monkeypatch, tmp_path: Path -) -> None: - monkeypatch.setattr( - kernel_warmup.envs, "VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR", str(tmp_path) - ) - monkeypatch.setattr( - kernel_warmup, "aot_compile_hash_factors", lambda _: ["env-hash", "config-hash"] - ) - - runner = SimpleNamespace(vllm_config=SimpleNamespace()) - cache_hash = sha256(str(["env-hash", "config-hash"]).encode()).hexdigest() - - path = kernel_warmup._resolve_flashinfer_autotune_file(runner) - - assert path == tmp_path / cache_hash / "autotune_configs.json" diff --git a/tests/model_executor/test_mistral_large_3_eagle.py b/tests/model_executor/test_mistral_large_3_eagle.py new file mode 100644 index 000000000000..d8ef109af984 --- /dev/null +++ b/tests/model_executor/test_mistral_large_3_eagle.py @@ -0,0 +1,146 @@ +# 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.config.compilation import CompilationMode +from vllm.model_executor.models import deepseek_v2 as deepseek_mod +from vllm.model_executor.models import mistral_large_3_eagle as eagle_mod + + +class DummyPPGroup: + world_size = 1 + is_first_rank = True + is_last_rank = True + + +class DummyEmbedding(nn.Module): + def __init__(self, vocab_size, hidden_size, *args, **kwargs): + super().__init__() + self.hidden_size = hidden_size + + def forward(self, input_ids): + return torch.zeros( + (*input_ids.shape, self.hidden_size), + dtype=torch.float32, + device=input_ids.device, + ) + + +class DummyLinear(nn.Module): + def __init__(self, in_features, out_features, *args, **kwargs): + super().__init__() + self.out_features = out_features + + def forward(self, x): + return torch.zeros( + (*x.shape[:-1], self.out_features), + dtype=x.dtype, + device=x.device, + ) + + +class DummyNorm(nn.Module): + def __init__(self, *args, **kwargs): + super().__init__() + + def forward(self, hidden_states, residual=None): + return hidden_states, residual + + +class DummyDecoderLayer(nn.Module): + def __init__(self, *args, **kwargs): + super().__init__() + + def forward(self, positions, hidden_states, residual, llama_4_scaling=None): + return hidden_states, residual + + +def make_vllm_config( + *, model_type="mistral3", qk_nope_head_dim=128, qk_rope_head_dim=64 +): + hf_config = SimpleNamespace( + model_type=model_type, + first_k_dense_replace=0, + vocab_size=32000, + hidden_size=16, + num_hidden_layers=1, + rms_norm_eps=1e-5, + qk_nope_head_dim=qk_nope_head_dim, + qk_rope_head_dim=qk_rope_head_dim, + ) + + return SimpleNamespace( + model_config=SimpleNamespace(hf_config=hf_config), + quant_config=None, + parallel_config=SimpleNamespace( + eplb_config=SimpleNamespace(num_redundant_experts=0), + ), + scheduler_config=SimpleNamespace(max_num_batched_tokens=8), + cache_config=None, + compilation_config=SimpleNamespace(mode=CompilationMode.NONE), + ) + + +@pytest.fixture(autouse=True) +def patch_heavy_modules(monkeypatch): + monkeypatch.setattr(eagle_mod, "get_pp_group", lambda: DummyPPGroup()) + monkeypatch.setattr(deepseek_mod, "get_pp_group", lambda: DummyPPGroup()) + + monkeypatch.setattr(eagle_mod, "VocabParallelEmbedding", DummyEmbedding) + monkeypatch.setattr(eagle_mod, "RowParallelLinear", DummyLinear) + monkeypatch.setattr(eagle_mod, "RMSNorm", DummyNorm) + monkeypatch.setattr(eagle_mod, "DeepseekV2DecoderLayer", DummyDecoderLayer) + + +@pytest.mark.cpu_test +@pytest.mark.parametrize( + ("model_type", "qk_nope_head_dim", "qk_rope_head_dim", "expected_use_mha"), + [ + # MLA-style config: should not use MHA. + ("mistral3", 128, 64, False), + # No MLA dims: should use MHA, matching DeepseekV2Model.__init__ logic. + ("mistral3", 0, 0, True), + # DeepSeek model type always uses MHA by the parent logic. + ("deepseek", 128, 64, True), + ], +) +def test_eagle_mistral_large3_initializes_deepseek_runtime_attrs( + model_type, + qk_nope_head_dim, + qk_rope_head_dim, + expected_use_mha, +): + vllm_config = make_vllm_config( + model_type=model_type, + qk_nope_head_dim=qk_nope_head_dim, + qk_rope_head_dim=qk_rope_head_dim, + ) + + model = eagle_mod.EagleMistralLarge3Model(vllm_config=vllm_config) + + assert model.aux_hidden_state_layers == () + assert model.use_mha is expected_use_mha + + # Add this if your fix also copies num_redundant_experts from + # DeepseekV2Model.__init__. + assert model.num_redundant_experts == 0 + + +@pytest.mark.cpu_test +def test_eagle_mistral_large3_forward_reuses_deepseek_parent_forward(): + vllm_config = make_vllm_config() + model = eagle_mod.EagleMistralLarge3Model(vllm_config=vllm_config) + + input_ids = torch.tensor([[1, 2, 3]]) + positions = torch.tensor([[0, 1, 2]]) + hidden_states = torch.zeros((1, 3, 16)) + + output = model(input_ids, positions, hidden_states) + + assert isinstance(output, torch.Tensor) + assert output.shape == hidden_states.shape diff --git a/tests/model_executor/test_routed_experts_capture.py b/tests/model_executor/test_routed_experts_capture.py index 152feac9e3ae..9efee9eec822 100644 --- a/tests/model_executor/test_routed_experts_capture.py +++ b/tests/model_executor/test_routed_experts_capture.py @@ -63,7 +63,6 @@ def _make_router(eplb_state: EplbLayerState | None = None) -> DummyRouter: top_k=2, global_num_experts=16, eplb_state=eplb_state, - indices_type_getter=None, ) @@ -92,6 +91,7 @@ def test_base_router_capture_with_eplb_enabled(): eplb_state.logical_to_physical_map = torch.arange(32).view(32, 1) eplb_state.logical_replica_count = torch.ones(32, dtype=torch.int64) eplb_state.should_record_tensor = torch.ones((), dtype=torch.bool) + eplb_state.num_unpadded_tokens_tensors = [torch.tensor(0, dtype=torch.int32)] router = _make_router(eplb_state=eplb_state) captured = [] @@ -115,6 +115,9 @@ def capture_fn(ids): def test_gpu_model_runner_binds_router_capture(monkeypatch): from vllm.v1.worker import gpu_model_runner as gmr + class _DummyRouter: + _routing_replay_out: torch.Tensor | None = None + class DummyFusedMoE: def __init__(self): self.layer_id = 7 @@ -132,7 +135,7 @@ def capture(self, layer_id, topk_ids): # Patch the runtime import inside _bind_routed_experts_capturer. import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer - monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE) + monkeypatch.setattr(fused_moe_layer, "MoERunner", DummyFusedMoE) dummy_self = types.SimpleNamespace( compilation_config=types.SimpleNamespace( @@ -171,7 +174,7 @@ def capture(self, layer_id, topk_ids): import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer - monkeypatch.setattr(fused_moe_layer, "FusedMoE", DummyFusedMoE) + monkeypatch.setattr(fused_moe_layer, "MoERunner", DummyFusedMoE) dummy_self = types.SimpleNamespace( compilation_config=types.SimpleNamespace( diff --git a/tests/model_executor/test_weight_utils.py b/tests/model_executor/test_weight_utils.py index 260ebdcefb3b..9e67609b78e4 100644 --- a/tests/model_executor/test_weight_utils.py +++ b/tests/model_executor/test_weight_utils.py @@ -160,5 +160,126 @@ def test_missing_target_returns_none(self): assert result is None +class TestKvCacheScaleMapper: + """The `WeightsMapper` returned by `get_cache_scale_mapper` replaces the + per-model `maybe_remap_kv_scale_name` calls. It must remap the same set of + checkpoint formats (the non-`params_dict`-dependent ones) and be idempotent + so it composes safely with a model's own qkv/gate_up `hf_to_vllm_mapper`.""" + + def _mapper(self): + # `get_cache_scale_mapper` does not use `self`; call it on the base + # class to get the default (non-config-specific) mapper. + from vllm.model_executor.layers.quantization.base_config import ( + QuantizationConfig, + ) + + return QuantizationConfig.get_cache_scale_mapper() + + def _map(self, name: str) -> str | None: + return self._mapper()._map_name(name) + + @pytest.mark.parametrize( + "name,expected", + [ + # Qwen3-MoE / llm-compressor fused qkv_proj + ( + "model.layers.0.self_attn.qkv_proj.k_scale", + "model.layers.0.self_attn.attn.k_scale", + ), + ( + "model.layers.0.self_attn.qkv_proj.v_scale", + "model.layers.0.self_attn.attn.v_scale", + ), + # ModelOpt / NVFP4 k_proj/v_proj + ( + "model.layers.0.self_attn.k_proj.k_scale", + "model.layers.0.self_attn.attn.k_scale", + ), + ( + "model.layers.0.self_attn.v_proj.v_scale", + "model.layers.0.self_attn.attn.v_scale", + ), + # deprecated fused kv_scale and bare scales + ( + "model.layers.0.self_attn.kv_scale", + "model.layers.0.self_attn.attn.k_scale", + ), + ( + "model.layers.0.self_attn.k_scale", + "model.layers.0.self_attn.attn.k_scale", + ), + # NemotronH mixer + ( + "model.layers.0.mixer.k_proj.k_scale", + "model.layers.0.mixer.attn.k_scale", + ), + # already in vLLM form -> unchanged (idempotent) + ( + "model.layers.0.self_attn.attn.k_scale", + "model.layers.0.self_attn.attn.k_scale", + ), + # non-kv scales must not be touched + ( + "model.layers.0.self_attn.k_proj.weight_scale", + "model.layers.0.self_attn.k_proj.weight_scale", + ), + ( + "model.layers.0.self_attn.k_proj.input_scale", + "model.layers.0.self_attn.k_proj.input_scale", + ), + # regular weights untouched + ( + "model.layers.0.self_attn.q_proj.weight", + "model.layers.0.self_attn.q_proj.weight", + ), + ], + ) + def test_remap(self, name, expected): + assert self._map(name) == expected + + @pytest.mark.parametrize( + "name", + [ + "model.layers.0.self_attn.k_scale", + "model.layers.0.self_attn.k_proj.k_scale", + "model.layers.0.self_attn.qkv_proj.v_scale", + "model.layers.0.mixer.k_proj.k_scale", + ], + ) + def test_idempotent(self, name): + once = self._map(name) + assert once is not None + assert self._map(once) == once + + def test_composes_with_qkv_mapper(self): + """Applied together with a model's qkv/gate_up mapper, the regex scale + rules run before the substr rename, so scales are normalized to `.attn.` + and regular projections are still fused correctly.""" + from vllm.model_executor.models.utils import WeightsMapper + + model_mapper = WeightsMapper( + orig_to_new_substr={ + ".q_proj": ".qkv_proj.q", + ".k_proj": ".qkv_proj.k", + ".v_proj": ".qkv_proj.v", + } + ) + # AutoWeightsLoader does `mapper |= cache_scale_mapper` + combined = model_mapper | self._mapper() + + assert ( + combined._map_name("model.layers.0.self_attn.q_proj.weight") + == "model.layers.0.self_attn.qkv_proj.q.weight" + ) + assert ( + combined._map_name("model.layers.0.self_attn.k_proj.k_scale") + == "model.layers.0.self_attn.attn.k_scale" + ) + assert ( + combined._map_name("model.layers.0.self_attn.k_scale") + == "model.layers.0.self_attn.attn.k_scale" + ) + + if __name__ == "__main__": test_download_weights_from_hf() diff --git a/tests/models/language/generation/test_common.py b/tests/models/language/generation/test_common.py index 2a693603f023..50c87d7729e1 100644 --- a/tests/models/language/generation/test_common.py +++ b/tests/models/language/generation/test_common.py @@ -25,7 +25,6 @@ AITER_MODEL_LIST = [ "meta-llama/Llama-3.2-1B-Instruct", "openbmb/MiniCPM3-4B", - "Qwen/Qwen-7B-Chat", "Qwen/Qwen2.5-0.5B-Instruct", "TitanML/tiny-mixtral", "Qwen/Qwen3-8B", @@ -82,9 +81,6 @@ "microsoft/phi-2", # phi marks=[pytest.mark.core_model, pytest.mark.slow_test], ), - pytest.param( - "Qwen/Qwen-7B-Chat", # qwen (text-only) - ), pytest.param( "Qwen/Qwen2.5-0.5B-Instruct", # qwen2 marks=[ @@ -134,8 +130,12 @@ def test_models( monkeypatch.setenv("VLLM_ROCM_USE_AITER", "1") if model == "TitanML/tiny-mixtral": # Untrained model: near-uniform logits make argmax sensitive to - # AITER's bfloat16 rounding error in plain rms_norm. + # AITER's bfloat16 rounding error. Route the plain rms_norm and the + # fused MoE (whose near-uniform router logits flip expert selection + # under ~1 ULP drift) through the native kernels for this model. + # See ROCm/aiter#3806 for the tracking issue and minimal repro. monkeypatch.setenv("VLLM_ROCM_USE_AITER_RMSNORM", "0") + monkeypatch.setenv("VLLM_ROCM_USE_AITER_MOE", "0") elif use_rocm_aiter and model not in AITER_MODEL_LIST: # Skip model that are not using AITER tests. # When more AITER kernels are added, this list will not be @@ -152,7 +152,11 @@ def test_models( "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - " ) - with hf_runner(model) as hf_model: + with hf_runner( + model, + revision=model_info.revision, + trust_remote_code=model_info.trust_remote_code, + ) as hf_model: hf_outputs = hf_model.generate_greedy_logprobs_limit( example_prompts, max_tokens, num_logprobs ) @@ -188,6 +192,7 @@ def test_models( model, tokenizer_name=model_info.tokenizer or model, tokenizer_mode=model_info.tokenizer_mode, + revision=model_info.revision, trust_remote_code=model_info.trust_remote_code, # Remove the effects of batch variance on ROCm since batch invariance # is not yet supported. diff --git a/tests/models/language/generation/test_grok.py b/tests/models/language/generation/test_grok.py deleted file mode 100644 index a2f1e8b4413d..000000000000 --- a/tests/models/language/generation/test_grok.py +++ /dev/null @@ -1,43 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import pytest - -from ...utils import dummy_hf_overrides - -MODELS = ["xai-org/grok-2"] - - -def _grok2_dummy_overrides(hf_config): - hf_config = dummy_hf_overrides(hf_config, model_arch="Grok1ForCausalLM") - text_config = hf_config.get_text_config() - text_config.update( - { - "hidden_size": 256, - "intermediate_size": 512, - "moe_intermediate_size": 256, - "num_attention_heads": 4, - "num_key_value_heads": 2, - "head_dim": 64, - } - ) - return hf_config - - -@pytest.mark.parametrize("model", MODELS) -def test_dummy_generate(vllm_runner, monkeypatch, model: str) -> None: - with monkeypatch.context() as m: - m.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1") - with vllm_runner( - model, - load_format="dummy", - max_model_len=128, - hf_overrides=_grok2_dummy_overrides, - enforce_eager=True, - ) as llm: - prompt = "Hello from Grok-2" - tokenizer = llm.get_llm().get_tokenizer() - prompt_len = len(tokenizer.encode(prompt)) - outputs = llm.generate_greedy([prompt], max_tokens=1) - output_ids, output_str = outputs[0] - assert len(output_ids) > prompt_len - assert output_str is not None diff --git a/tests/models/language/generation/test_hybrid.py b/tests/models/language/generation/test_hybrid.py index e410daf2fcdd..0f19c1038ec7 100644 --- a/tests/models/language/generation/test_hybrid.py +++ b/tests/models/language/generation/test_hybrid.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from collections.abc import Callable +from contextlib import contextmanager, nullcontext import pytest @@ -36,7 +37,6 @@ "ai21labs/Jamba-tiny-dev", "pfnet/plamo-2-1b", "Zyphra/Zamba2-1.2B-instruct", - "hmellor/tiny-random-BambaForCausalLM", "ibm-granite/granite-4.0-tiny-preview", "tiiuae/Falcon-H1-0.5B-Base", "LiquidAI/LFM2-1.2B", @@ -404,6 +404,12 @@ def _get_vllm_runner_params( } +@contextmanager +def _owned_vllm_runner(vllm_runner, kwargs): + with vllm_runner(**kwargs) as runner: + yield runner + + def _get_vLLM_output( vllm_runner, kwargs, @@ -413,22 +419,26 @@ def _get_vLLM_output( num_repetitions=1, vllm_model=None, ): - outs = [] - if vllm_model is None: - vllm_model = vllm_runner(**kwargs) - for _ in range(num_repetitions): - if num_logprobs < 0: - vllm_output = vllm_model.generate_greedy(prompts, max_tokens) - else: - vllm_output = vllm_model.generate_greedy_logprobs( - prompts, max_tokens, num_logprobs - ) - outs.append(vllm_output) + runner_context = ( + _owned_vllm_runner(vllm_runner, kwargs) + if vllm_model is None + else nullcontext(vllm_model) + ) + with runner_context as runner: + outs = [] + for _ in range(num_repetitions): + if num_logprobs < 0: + vllm_output = runner.generate_greedy(prompts, max_tokens) + else: + vllm_output = runner.generate_greedy_logprobs( + prompts, max_tokens, num_logprobs + ) + outs.append(vllm_output) return outs, vllm_model -@pytest.mark.parametrize("model", [HYBRID_MODELS[0], HYBRID_MODELS[3]]) +@pytest.mark.parametrize("model", [HYBRID_MODELS[0]]) @pytest.mark.parametrize("max_tokens", [64]) @pytest.mark.parametrize("n_repetitions", [2]) # If num_logprobs is set to -1, then the stringent version @@ -492,7 +502,7 @@ def test_apc_single_prompt( ) -@pytest.mark.parametrize("model", [HYBRID_MODELS[0], HYBRID_MODELS[3]]) +@pytest.mark.parametrize("model", [HYBRID_MODELS[0]]) @pytest.mark.parametrize("max_tokens", [64]) @pytest.mark.parametrize("n_repetitions", [2]) # If num_logprobs is set to -1, then the stringent version @@ -573,7 +583,7 @@ def test_apc_single_prompt_block_align_alignment( ) -@pytest.mark.parametrize("model", [HYBRID_MODELS[0], HYBRID_MODELS[3]]) +@pytest.mark.parametrize("model", [HYBRID_MODELS[0]]) @pytest.mark.parametrize("max_tokens", [64]) @pytest.mark.parametrize("n_repetitions", [2]) # If num_logprobs is set to -1, then the stringent version @@ -642,7 +652,7 @@ def test_apc_multiple_prompts_all_cached_outputs( ) -@pytest.mark.parametrize("model", [HYBRID_MODELS[0], HYBRID_MODELS[3]]) +@pytest.mark.parametrize("model", [HYBRID_MODELS[0]]) @pytest.mark.parametrize("max_tokens", [64]) @pytest.mark.parametrize("n_repetitions", [2]) # If num_logprobs is set to -1, then the stringent version @@ -727,7 +737,7 @@ def test_apc_multiple_prompts_block_align_alignment( ) -@pytest.mark.parametrize("model", [HYBRID_MODELS[0], HYBRID_MODELS[3]]) +@pytest.mark.parametrize("model", [HYBRID_MODELS[0]]) @pytest.mark.parametrize("max_tokens", [64]) @pytest.mark.parametrize("n_repetitions", [2]) # If num_logprobs is set to -1, then the stringent version @@ -772,38 +782,44 @@ def test_apc_multiple_prompts_partial_cached_outputs( # Cache only part of all the prompts vllm_runner_kwargs["enable_prefix_caching"] = True - vllm_outputs_partial_cache, vllm_model = _get_vLLM_output( - vllm_runner, vllm_runner_kwargs, generated_prompts[:3], max_tokens, num_logprobs - ) - - compare_operator( - outputs_0_lst=vllm_outputs_no_cache[0][:3], - outputs_1_lst=vllm_outputs_partial_cache[0], - name_0="vllm_no_cache", - name_1="vllm_partial_cache", - ) - - vllm_outputs_cache_rep, _ = _get_vLLM_output( - vllm_runner, - vllm_runner_kwargs, - generated_prompts, - max_tokens, - num_logprobs, - n_repetitions, - vllm_model=vllm_model, - ) - - for r_idx, vllm_outputs_cache_itn in enumerate(vllm_outputs_cache_rep): - # In the first repetition, the caches are filled - # In the second repetition, these caches are reused + with _owned_vllm_runner(vllm_runner, vllm_runner_kwargs) as vllm_model: + vllm_outputs_partial_cache, _ = _get_vLLM_output( + vllm_runner, + vllm_runner_kwargs, + generated_prompts[:3], + max_tokens, + num_logprobs, + vllm_model=vllm_model, + ) compare_operator( - outputs_0_lst=vllm_outputs_no_cache[0], - outputs_1_lst=vllm_outputs_cache_itn, + outputs_0_lst=vllm_outputs_no_cache[0][:3], + outputs_1_lst=vllm_outputs_partial_cache[0], name_0="vllm_no_cache", - name_1=f"vllm_cache_it_{r_idx + 1}", + name_1="vllm_partial_cache", ) + vllm_outputs_cache_rep, _ = _get_vLLM_output( + vllm_runner, + vllm_runner_kwargs, + generated_prompts, + max_tokens, + num_logprobs, + n_repetitions, + vllm_model=vllm_model, + ) + + for r_idx, vllm_outputs_cache_itn in enumerate(vllm_outputs_cache_rep): + # In the first repetition, the caches are filled + # In the second repetition, these caches are reused + + compare_operator( + outputs_0_lst=vllm_outputs_no_cache[0], + outputs_1_lst=vllm_outputs_cache_itn, + name_0="vllm_no_cache", + name_1=f"vllm_cache_it_{r_idx + 1}", + ) + # Test that outputs match whether prefix caching is enabled or not for mamba. @pytest.mark.parametrize("model", ["tiiuae/falcon-mamba-7b"]) diff --git a/tests/models/language/generation_ppl_test/ppl_utils.py b/tests/models/language/generation_ppl_test/ppl_utils.py index 59740505e827..2b5449bddcba 100644 --- a/tests/models/language/generation_ppl_test/ppl_utils.py +++ b/tests/models/language/generation_ppl_test/ppl_utils.py @@ -30,7 +30,7 @@ def wikitext_ppl_test( ): vllm_extra_kwargs = get_vllm_extra_kwargs(model_info, vllm_extra_kwargs) - dataset = load_dataset("wikitext", "wikitext-2-raw-v1", split="test") + dataset = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="test") with vllm_runner( model_info.name, diff --git a/tests/models/language/pooling/test_classification.py b/tests/models/language/pooling/test_classification.py index 8cf84d05db6e..e7128197bfc7 100644 --- a/tests/models/language/pooling/test_classification.py +++ b/tests/models/language/pooling/test_classification.py @@ -18,7 +18,6 @@ pytest.mark.slow_test, ], ), - pytest.param("Forrest20231206/ernie-3.0-base-zh-cls"), ], ) @pytest.mark.parametrize("dtype", ["half"] if current_platform.is_rocm() else ["float"]) @@ -48,6 +47,5 @@ def test_models( assert torch.allclose( hf_output, vllm_output, - atol=1e-3 if dtype == "float" else 1e-2, rtol=2e-3 if dtype == "float" else 1e-2, ) diff --git a/tests/models/language/pooling/test_colbert.py b/tests/models/language/pooling/test_colbert.py index 10c229fe063b..6c82ad8a9ca0 100644 --- a/tests/models/language/pooling/test_colbert.py +++ b/tests/models/language/pooling/test_colbert.py @@ -6,9 +6,13 @@ generic ColBERT support works with different encoder architectures. """ +from contextlib import contextmanager + import pytest import torch +from tests.utils import wait_for_rocm_memory_to_settle +from vllm.distributed import cleanup_dist_env_and_memory from vllm.entrypoints.pooling.scoring.utils import compute_maxsim_score # ----------------------------------------------------------------------- @@ -145,6 +149,24 @@ def _compute_hf_colbert_embeddings(model, tokenizer, linear_weight, texts, devic return embeddings +@contextmanager +def _hf_colbert_model(model_name: str, hf_spec: dict, device: torch.device): + """Load the HF backbone + ColBERT projection, freeing the GPU on exit. + + These live outside any runner context, so without explicit cleanup ROCm + keeps the VRAM resident and the next backend parametrization (or test) + OOMs on startup. + """ + hf_model = _load_hf_model(model_name, hf_spec, device) + linear_weight = _load_projection_weight(model_name, hf_spec, device) + try: + yield hf_model, linear_weight + finally: + del hf_model, linear_weight + cleanup_dist_env_and_memory() + wait_for_rocm_memory_to_settle() + + def _assert_embeddings_close(vllm_outputs, hf_embeddings): """Assert that vLLM and HuggingFace embeddings match.""" for i, (hf_emb, vllm_out) in enumerate(zip(hf_embeddings, vllm_outputs)): @@ -363,9 +385,11 @@ def test_colbert_hf_comparison(vllm_runner, backend): spec = COLBERT_MODELS[backend] hf_spec = spec["hf_comparison"] + extra_kwargs = spec["extra_kwargs"] model_name = spec["model"] assert isinstance(model_name, str) assert isinstance(hf_spec, dict) + assert isinstance(extra_kwargs, dict) test_texts = [TEXTS_1[0], TEXTS_2[0]] with vllm_runner( @@ -374,7 +398,7 @@ def test_colbert_hf_comparison(vllm_runner, backend): dtype="float32", max_model_len=spec["max_model_len"], enforce_eager=True, - **spec["extra_kwargs"], + **extra_kwargs, ) as vllm_model: vllm_outputs = vllm_model.token_embed(test_texts) @@ -384,15 +408,13 @@ def test_colbert_hf_comparison(vllm_runner, backend): model_name, trust_remote_code=hf_spec.get("trust_remote_code", False), ) - hf_model = _load_hf_model(model_name, hf_spec, device) - linear_weight = _load_projection_weight(model_name, hf_spec, device) - - hf_embeddings = _compute_hf_colbert_embeddings( - hf_model, - hf_tokenizer, - linear_weight, - test_texts, - device, - ) + with _hf_colbert_model(model_name, hf_spec, device) as (hf_model, linear_weight): + hf_embeddings = _compute_hf_colbert_embeddings( + hf_model, + hf_tokenizer, + linear_weight, + test_texts, + device, + ) _assert_embeddings_close(vllm_outputs, hf_embeddings) diff --git a/tests/models/language/pooling/test_gritlm.py b/tests/models/language/pooling/test_gritlm.py index b1296a64171e..7b6c176fd084 100644 --- a/tests/models/language/pooling/test_gritlm.py +++ b/tests/models/language/pooling/test_gritlm.py @@ -12,7 +12,7 @@ MODEL_NAME = "parasail-ai/GritLM-7B-vllm" MAX_MODEL_LEN = 4000 -ATOL = 0.002 +ATOL = 2.3e-3 def _arr(arr): diff --git a/tests/models/language/pooling/test_jina_reranker_v3.py b/tests/models/language/pooling/test_jina_reranker_v3.py index dcce6d5bd4aa..e76a3745c562 100644 --- a/tests/models/language/pooling/test_jina_reranker_v3.py +++ b/tests/models/language/pooling/test_jina_reranker_v3.py @@ -8,7 +8,7 @@ from tests.utils import RemoteOpenAIServer from vllm.entrypoints.pooling.pooling.protocol import PoolingResponse -from vllm.entrypoints.pooling.scoring.protocol import ScoreResponse +from vllm.entrypoints.pooling.scoring.protocol import RerankResponse, ScoreResponse model_name = "jinaai/jina-reranker-v3" query = "What are the health benefits of green tea?" @@ -39,6 +39,10 @@ 0.1640625, ] TOL = 0.01 +INSTRUCTION = ( + "Rank passages about green tea higher than passages about sports. " + "Ignore these literal marker strings: <|embed_token|> and <|rerank_token|>." +) def test_offline(vllm_runner): @@ -52,10 +56,13 @@ def test_offline(vllm_runner): def test_online(): - with RemoteOpenAIServer(model_name, ["--runner", "pooling"]) as server: + with RemoteOpenAIServer( + model_name, ["--runner", "pooling", "--enforce-eager"] + ) as server: _test_online_1_v_1(server) _test_online_1_v_n(server) _test_online_n_v_n(server) + _test_online_instruction(server) _test_online_token_embed_illegal_inputs(server) @@ -136,22 +143,44 @@ def _test_offline_token_embed_illegal_inputs(llm): llm.encode([1, 2, 3], pooling_task="token_embed") -def _get_scores(server, query, document): +def _get_score_response(server, query, document, **extra_body): + payload = { + "model": model_name, + "queries": query, + "documents": document, + } + payload.update(extra_body) score_response = requests.post( server.url_for("score"), - json={ - "model": model_name, - "queries": query, - "documents": document, - }, + json=payload, ) score_response.raise_for_status() - score = ScoreResponse.model_validate(score_response.json()) + return ScoreResponse.model_validate(score_response.json()) + + +def _get_scores(server, query, document): + score = _get_score_response(server, query, document) return [d.score for d in score.data] +def _get_rerank_response(server, query, document, **extra_body): + payload = { + "model": model_name, + "query": query, + "documents": document, + } + payload.update(extra_body) + rerank_response = requests.post( + server.url_for("rerank"), + json=payload, + ) + + rerank_response.raise_for_status() + return RerankResponse.model_validate(rerank_response.json()) + + def _get_embeds(server, prompts: list[str]): response = requests.post( server.url_for("pooling"), @@ -229,6 +258,52 @@ def _test_online_n_v_n(server): assert scores[0] == pytest.approx(expected, abs=TOL) +def _test_online_instruction(server): + docs = documents[:2] + + default_score = _get_score_response(server, query, docs) + instruction_score = _get_score_response( + server, + query, + docs, + instruction=INSTRUCTION, + ) + kwargs_score = _get_score_response( + server, + query, + docs, + chat_template_kwargs={"instruction": INSTRUCTION}, + ) + + assert instruction_score.usage.prompt_tokens > default_score.usage.prompt_tokens + assert kwargs_score.usage.prompt_tokens == instruction_score.usage.prompt_tokens + assert len(instruction_score.data) == len(default_score.data) + assert [d.score for d in kwargs_score.data] == pytest.approx( + [d.score for d in instruction_score.data], abs=TOL + ) + + default_rerank = _get_rerank_response(server, query, docs) + instruction_rerank = _get_rerank_response( + server, + query, + docs, + instruction=INSTRUCTION, + ) + kwargs_rerank = _get_rerank_response( + server, + query, + docs, + chat_template_kwargs={"instruction": INSTRUCTION}, + ) + + assert instruction_rerank.usage.prompt_tokens > default_rerank.usage.prompt_tokens + assert kwargs_rerank.usage.prompt_tokens == instruction_rerank.usage.prompt_tokens + assert len(instruction_rerank.results) == len(default_rerank.results) + assert [r.relevance_score for r in kwargs_rerank.results] == pytest.approx( + [r.relevance_score for r in instruction_rerank.results], abs=TOL + ) + + def _test_online_token_embed_illegal_inputs(server): response = requests.post( server.url_for("pooling"), diff --git a/tests/models/language/pooling/test_pooler_config_init_behaviour.py b/tests/models/language/pooling/test_pooler_config_init_behaviour.py index 2f6fb9c873f8..f462e9673a9a 100644 --- a/tests/models/language/pooling/test_pooler_config_init_behaviour.py +++ b/tests/models/language/pooling/test_pooler_config_init_behaviour.py @@ -106,7 +106,7 @@ def test_reward_models_using_activation( dtype=dtype, pooler_config=PoolerConfig(use_activation=False), ) as vllm_model: - wo_activation = vllm_model.reward(example_prompts) + wo_activation = vllm_model.token_classify(example_prompts) with vllm_runner( model, @@ -114,7 +114,7 @@ def test_reward_models_using_activation( dtype=dtype, pooler_config=PoolerConfig(use_activation=True), ) as vllm_model: - w_activation = vllm_model.reward(example_prompts) + w_activation = vllm_model.token_classify(example_prompts) for wo, w in zip(wo_activation, w_activation): wo = torch.tensor(wo) diff --git a/tests/models/language/pooling/test_reward.py b/tests/models/language/pooling/test_reward.py index 22e0539a9890..1872ca4ae09b 100644 --- a/tests/models/language/pooling/test_reward.py +++ b/tests/models/language/pooling/test_reward.py @@ -107,7 +107,7 @@ def test_prm_models( pytest.skip("CPU only supports V1") with vllm_runner(model, max_model_len=1024, dtype=dtype) as vllm_model: - vllm_outputs = vllm_model.reward(math_step_prompts) + vllm_outputs = vllm_model.token_classify(math_step_prompts) with hf_runner(model, dtype=dtype, auto_cls=AutoModel) as hf_model: hf_model = step_reward_patch_hf_model(hf_model) @@ -146,7 +146,7 @@ def test_prm_models_with_golden_outputs( pytest.skip(f"No available golden outputs for {model}.") with vllm_runner(model, max_model_len=1024, dtype=dtype) as vllm_model: - vllm_outputs = vllm_model.reward(math_step_prompts) + vllm_outputs = vllm_model.token_classify(math_step_prompts) golden_outputs = load_reward_outputs(FIXTURE_REWARD_RESULT[model]) diff --git a/tests/models/language/pooling/test_token_classification.py b/tests/models/language/pooling/test_token_classification.py index be71f7918ec4..8dc38cf62a04 100644 --- a/tests/models/language/pooling/test_token_classification.py +++ b/tests/models/language/pooling/test_token_classification.py @@ -5,6 +5,7 @@ import torch from transformers import AutoModelForTokenClassification +from tests.models.registry import HF_EXAMPLE_MODELS from tests.models.utils import softmax from vllm.platforms import current_platform from vllm.utils.torch_utils import set_random_seed @@ -24,13 +25,12 @@ def seed_everything(): "model", [ "boltuix/NeuroBERT-NER", - "gyr66/Ernie-3.0-base-chinese-finetuned-ner", ], ) # The float32 is required for this tiny model to pass the test. @pytest.mark.parametrize("dtype", ["float"]) @torch.inference_mode -def test_bert_like_models( +def test_bert_models( hf_runner, vllm_runner, example_prompts, @@ -117,6 +117,56 @@ def test_modernbert_models( torch.testing.assert_close(hf_output, vllm_output, atol=3.2e-2, rtol=1e-3) +PRIVACY_FILTER_PROMPTS = [ + "My name is Harry Potter.", + "Email me at harry.potter@hogwarts.edu.", + "Call me on +44 20 7946 0958 tomorrow.", + "My account number is 12345678 and the API key is sk-live-abc123def456.", + "I live at 4 Privet Drive, Little Whinging.", + "Visit https://example.com/profile/harry for more info.", + "We met on 12 January 2024.", +] + + +@pytest.mark.parametrize("model", ["openai/privacy-filter"]) +@pytest.mark.parametrize("dtype", ["bfloat16"]) +@torch.inference_mode +def test_openai_privacy_filter( + hf_runner, + vllm_runner, + model: str, + dtype: str, +) -> None: + model_info = HF_EXAMPLE_MODELS.find_hf_info(model) + model_info.check_transformers_version(on_fail="skip") + + with vllm_runner(model, max_model_len=None, dtype=dtype) as vllm_model: + vllm_outputs = vllm_model.token_classify(PRIVACY_FILTER_PROMPTS) + + hf_model_kwargs = {} + if current_platform.is_rocm(): + hf_model_kwargs["attn_implementation"] = "eager" + + with hf_runner( + model, + dtype=dtype, + auto_cls=AutoModelForTokenClassification, + model_kwargs=hf_model_kwargs, + ) as hf_model: + tokenizer = hf_model.tokenizer + hf_outputs = [] + for prompt in PRIVACY_FILTER_PROMPTS: + inputs = tokenizer([prompt], return_tensors="pt") + inputs = hf_model.wrap_device(inputs) + output = hf_model.model(**inputs) + hf_outputs.append(softmax(output.logits[0])) + + for hf_output, vllm_output in zip(hf_outputs, vllm_outputs): + hf_output = hf_output.detach().clone().cpu().float() + vllm_output = vllm_output.detach().clone().cpu().float() + torch.testing.assert_close(hf_output, vllm_output, atol=0.1, rtol=1e-2) + + @pytest.mark.parametrize("model", ["bd2lcco/Qwen3-0.6B-finetuned"]) @pytest.mark.parametrize("dtype", ["float"]) @torch.inference_mode diff --git a/tests/models/language/pooling_mteb_test/test_ernie.py b/tests/models/language/pooling_mteb_test/test_ernie.py deleted file mode 100644 index 62a542ab78ab..000000000000 --- a/tests/models/language/pooling_mteb_test/test_ernie.py +++ /dev/null @@ -1,45 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import pytest - -from tests.models.language.pooling.embed_utils import correctness_test_embed_models -from tests.models.utils import EmbedModelInfo - -from .mteb_embed_utils import mteb_test_embed_models - -MODELS = [ - EmbedModelInfo( - "shibing624/text2vec-base-chinese-sentence", - architecture="ErnieModel", - mteb_score=0.536523112, - seq_pooling_type="MEAN", - attn_type="encoder_only", - is_prefix_caching_supported=False, - is_chunked_prefill_supported=False, - enable_test=True, - ), -] - - -@pytest.mark.parametrize("model_info", MODELS) -def test_embed_models_mteb(hf_runner, vllm_runner, model_info: EmbedModelInfo) -> None: - mteb_test_embed_models( - hf_runner, - vllm_runner, - model_info, - vllm_extra_kwargs={"gpu_memory_utilization": 0.2}, - ) - - -@pytest.mark.parametrize("model_info", MODELS) -def test_embed_models_correctness( - hf_runner, vllm_runner, model_info: EmbedModelInfo, example_prompts -) -> None: - correctness_test_embed_models( - hf_runner, - vllm_runner, - model_info, - example_prompts, - vllm_extra_kwargs={"gpu_memory_utilization": 0.2}, - ) diff --git a/tests/models/multimodal/conftest.py b/tests/models/multimodal/conftest.py index 9283556d3024..d00c3df786dc 100644 --- a/tests/models/multimodal/conftest.py +++ b/tests/models/multimodal/conftest.py @@ -5,21 +5,11 @@ import os import warnings -import pytest import torch -from tests.utils import prewarm_hf_cache from vllm.platforms import current_platform -@pytest.fixture(scope="session", autouse=True) -def _prewarm_hf_cache(): - # tokenization_qwen.py downloads SimSun.ttf from - # qianwen-res.oss-cn-beijing.aliyuncs.com; both Qwen/Qwen-VL and - # Qwen/Qwen-VL-Chat look it up from the Chat repo. - prewarm_hf_cache([("Qwen/Qwen-VL-Chat", "SimSun.ttf")]) - - def pytest_configure(config): """Early ROCm configuration that must happen before test collection.""" if not current_platform.is_rocm(): diff --git a/tests/models/multimodal/generation/test_common.py b/tests/models/multimodal/generation/test_common.py index 9ac0d4ab4463..fb5c518038f0 100644 --- a/tests/models/multimodal/generation/test_common.py +++ b/tests/models/multimodal/generation/test_common.py @@ -326,39 +326,6 @@ def _granite4_vision_vllm_to_hf_output(vllm_output, model): large_gpu_mark(min_gb=64), ], ), - "aya_vision": VLMTestInfo( - models=["CohereLabs/aya-vision-8b"], - test_type=(VLMTestType.IMAGE), - prompt_formatter=lambda img_prompt: f"<|START_OF_TURN_TOKEN|><|USER_TOKEN|>{img_prompt}<|END_OF_TURN_TOKEN|><|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>", # noqa: E501 - single_image_prompts=IMAGE_ASSETS.prompts( - { - "stop_sign": "What's the content in the center of the image?", - "cherry_blossom": "What is the season?", - } - ), - multi_image_prompt="Describe the two images in detail.", - max_model_len=4096, - max_num_seqs=2, - auto_cls=AutoModelForImageTextToText, - vllm_runner_kwargs={"mm_processor_kwargs": {"crop_to_patches": True}}, - ), - "aya_vision-multi_image": VLMTestInfo( - models=["CohereLabs/aya-vision-8b"], - test_type=(VLMTestType.MULTI_IMAGE), - prompt_formatter=lambda img_prompt: f"<|START_OF_TURN_TOKEN|><|USER_TOKEN|>{img_prompt}<|END_OF_TURN_TOKEN|><|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>", # noqa: E501 - single_image_prompts=IMAGE_ASSETS.prompts( - { - "stop_sign": "What's the content in the center of the image?", - "cherry_blossom": "What is the season?", - } - ), - multi_image_prompt="Describe the two images in detail.", - max_model_len=4096, - max_num_seqs=2, - auto_cls=AutoModelForImageTextToText, - vllm_runner_kwargs={"mm_processor_kwargs": {"crop_to_patches": True}}, - marks=[large_gpu_mark(min_gb=32)], - ), "blip2": VLMTestInfo( models=["Salesforce/blip2-opt-2.7b"], test_type=VLMTestType.IMAGE, @@ -421,20 +388,6 @@ def _granite4_vision_vllm_to_hf_output(vllm_output, model): stop_str=["<|end▁of▁sentence|>", "<|begin▁of▁sentence|>"], image_size_factors=[(1.0,), (1.0, 1.0, 1.0), (0.1, 0.5, 1.0)], ), - "fuyu": VLMTestInfo( - models=["adept/fuyu-8b"], - test_type=VLMTestType.IMAGE, - prompt_formatter=lambda img_prompt: f"{img_prompt}\n", - img_idx_to_prompt=lambda idx: "", - max_model_len=2048, - max_num_seqs=2, - auto_cls=AutoModelForImageTextToText, - use_tokenizer_eos=True, - vllm_output_post_proc=model_utils.fuyu_vllm_to_hf_output, - num_logprobs=10, - image_size_factors=[(0.25,), (0.25, 0.25, 0.25), (0.25, 0.2, 0.15)], - marks=[large_gpu_mark(min_gb=32)], - ), "gemma3": VLMTestInfo( models=["google/gemma-3-4b-it"], test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE), @@ -604,8 +557,6 @@ def _granite4_vision_vllm_to_hf_output(vllm_output, model): models=[ "OpenGVLab/InternVL2-1B", "OpenGVLab/InternVL2-2B", - # FIXME: Config cannot be loaded in transformers 4.52 - # "OpenGVLab/Mono-InternVL-2B", ], test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE), prompt_formatter=lambda img_prompt: f"<|im_start|>User\n{img_prompt}<|im_end|>\n<|im_start|>Assistant\n", # noqa: E501 @@ -765,16 +716,6 @@ def _granite4_vision_vllm_to_hf_output(vllm_output, model): auto_cls=AutoModelForImageTextToText, vllm_output_post_proc=model_utils.llava_video_vllm_to_hf_output, ), - "mantis": VLMTestInfo( - models=["TIGER-Lab/Mantis-8B-siglip-llama3"], - test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE), - prompt_formatter=lambda img_prompt: f"<|start_header_id|>user<|end_header_id|>\n\n{img_prompt}<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n", # noqa: E501 - max_model_len=4096, - get_stop_token_ids=lambda tok: [128009], - auto_cls=AutoModelForImageTextToText, - vllm_output_post_proc=model_utils.mantis_vllm_to_hf_output, - patch_hf_runner=model_utils.mantis_patch_hf_runner, - ), "minicpmv_25": VLMTestInfo( models=["openbmb/MiniCPM-Llama3-V-2_5"], test_type=VLMTestType.IMAGE, @@ -812,29 +753,6 @@ def _granite4_vision_vllm_to_hf_output(vllm_output, model): hf_output_post_proc=model_utils.minicpmv_trunc_hf_output, patch_hf_runner=model_utils.minicpmv_26_patch_hf_runner, ), - "minimax_vl_01": VLMTestInfo( - models=["MiniMaxAI/MiniMax-VL-01"], - prompt_formatter=lambda img_prompt: f"user: {img_prompt} assistant:", # noqa: E501 - img_idx_to_prompt=lambda _: "", - test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE), - max_model_len=8192, - max_num_seqs=4, - dtype="bfloat16", - hf_output_post_proc=model_utils.minimax_vl_01_hf_output, - patch_hf_runner=model_utils.minimax_vl_01_patch_hf_runner, - auto_cls=AutoModelForImageTextToText, - marks=[ - large_gpu_mark(min_gb=80), - # TODO: [ROCm] Fix pickle issue with ROCm spawn and tp>1 - pytest.mark.skipif( - current_platform.is_rocm(), - reason=( - "ROCm: Model too large for single GPU; " - "multi-GPU blocked by HF _LazyConfigMapping pickle issue with spawn" - ), - ), - ], - ), "molmo": VLMTestInfo( models=["allenai/Molmo-7B-D-0924"], test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE), @@ -974,17 +892,6 @@ def _granite4_vision_vllm_to_hf_output(vllm_output, model): max_model_len=4096, use_tokenizer_eos=True, auto_cls=AutoModelForImageTextToText, - hf_model_kwargs=model_utils.qianfan_ocr_hf_model_kwargs("baidu/Qianfan-OCR"), - ), - "qwen_vl": VLMTestInfo( - models=["Qwen/Qwen-VL"], - test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE), - prompt_formatter=identity, - img_idx_to_prompt=lambda idx: f"Picture {idx}: \n", - max_model_len=1024, - max_num_seqs=2, - vllm_output_post_proc=model_utils.qwen_vllm_to_hf_output, - prompt_path_encoder=model_utils.qwen_prompt_path_encoder, ), "qwen2_vl": VLMTestInfo( models=["Qwen/Qwen2-VL-2B-Instruct"], @@ -1028,31 +935,6 @@ def _granite4_vision_vllm_to_hf_output(vllm_output, model): hf_output_post_proc=model_utils.smolvlm_trunc_hf_output, num_logprobs=10, ), - "tarsier": VLMTestInfo( - models=["omni-research/Tarsier-7b"], - test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE), - prompt_formatter=lambda img_prompt: f"USER: {img_prompt} ASSISTANT:", - max_model_len=4096, - max_num_seqs=2, - auto_cls=AutoModelForImageTextToText, - patch_hf_runner=model_utils.tarsier_patch_hf_runner, - ), - "tarsier2": VLMTestInfo( - models=["omni-research/Tarsier2-Recap-7b"], - test_type=( - VLMTestType.IMAGE, - VLMTestType.MULTI_IMAGE, - VLMTestType.VIDEO, - ), - prompt_formatter=lambda img_prompt: f"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\n{img_prompt}<|im_end|>\n<|im_start|>assistant\n", # noqa: E501 - img_idx_to_prompt=lambda idx: "<|vision_start|><|image_pad|><|vision_end|>", - video_idx_to_prompt=lambda idx: "<|vision_start|><|video_pad|><|vision_end|>", - max_model_len=4096, - max_num_seqs=2, - auto_cls=AutoModelForImageTextToText, - image_size_factors=[(0.25,), (0.25, 0.25, 0.25), (0.25, 0.2, 0.15)], - marks=[pytest.mark.skip("Model initialization hangs")], - ), ### Tensor parallel / multi-gpu broadcast tests "chameleon-broadcast": VLMTestInfo( models=["facebook/chameleon-7b"], diff --git a/tests/models/multimodal/generation/test_granite_speech.py b/tests/models/multimodal/generation/test_granite_speech.py index 038a15d057c1..3019f5f22d4b 100644 --- a/tests/models/multimodal/generation/test_granite_speech.py +++ b/tests/models/multimodal/generation/test_granite_speech.py @@ -30,11 +30,14 @@ def vllm_to_hf_output( MODEL_NAME = "ibm-granite/granite-speech-3.3-2b" MODEL_NAME_4_0 = "ibm-granite/granite-4.0-1b-speech" +# "plus" variant of granite speech (uses GraniteSpeechPlusForConditionalGeneration). +MODEL_NAME_4_1_PLUS = "ibm-granite/granite-speech-4.1-2b-plus" # Audio lora co-exists directly in the 3.3 model directory, -# the 4.0 model has adapters merged into the weights. +# the 4.0 and 4.1-plus models have adapters merged into the weights. models: dict[str, str | None] = { MODEL_NAME: MODEL_NAME, MODEL_NAME_4_0: None, + MODEL_NAME_4_1_PLUS: None, } diff --git a/tests/models/multimodal/generation/test_memory_leak.py b/tests/models/multimodal/generation/test_memory_leak.py index 743a71f928fa..45eac5b80ab7 100644 --- a/tests/models/multimodal/generation/test_memory_leak.py +++ b/tests/models/multimodal/generation/test_memory_leak.py @@ -25,7 +25,7 @@ ] MAX_MODEL_LEN = 8192 REQUESTS_PER_ROUND = 4 -WARMUP_ROUNDS = 1 +WARMUP_ROUNDS = 2 MEASURED_ROUNDS = 16 GPU_GROWTH_THRESHOLD_MIB = 0 CPU_PEAK_GROWTH_THRESHOLD_MIB = 0 @@ -83,7 +83,7 @@ def _ru_maxrss_bytes() -> int | None: def _gpu_used_bytes() -> int: torch.accelerator.synchronize() - free_bytes, total_bytes = current_platform.mem_get_info() + free_bytes, total_bytes = torch.accelerator.get_memory_info() return int(total_bytes - free_bytes) diff --git a/tests/models/multimodal/generation/test_mm_prefix_lm.py b/tests/models/multimodal/generation/test_mm_prefix_lm.py new file mode 100644 index 000000000000..8d3f5b77b715 --- /dev/null +++ b/tests/models/multimodal/generation/test_mm_prefix_lm.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from typing import Any + +import pytest +import torch +from transformers import AutoModelForImageTextToText + +from vllm.platforms import current_platform + +from ....conftest import HfRunner, ImageTestAssets, VllmRunner +from .vlm_utils import model_utils + +MODEL = "google/gemma-3-4b-it" +PROMPT = ( + "user\n" + "What is the content in the center of the image?" + "\nmodel\n" +) + + +def _install_prefill_hidden_capture(model): + model = getattr(model, "module", model) + model._prefill_hidden = None + + language_model = model.language_model.model + original_forward = language_model.forward + + def forward(*args, **kwargs): + hidden_states = original_forward(*args, **kwargs) + if model._prefill_hidden is None and torch.is_tensor(hidden_states): + model._prefill_hidden = hidden_states.detach().float().cpu() + return hidden_states + + language_model.forward = forward + + +def _get_prefill_hidden(model): + model = getattr(model, "module", model) + hidden = getattr(model, "_prefill_hidden", None) + assert hidden is not None + return hidden + + +def _get_hf_prefill_hidden(hf_model: HfRunner, image: Any): + inputs = hf_model.get_inputs([PROMPT], images=[image])[0] + with torch.no_grad(): + outputs = hf_model.model.model( + **hf_model.wrap_device(inputs), + use_cache=False, + ) + return outputs.last_hidden_state[0].detach().float().cpu() + + +def _get_vllm_prefill_hidden( + vllm_runner: type[VllmRunner], + image: Any, + vllm_runner_kwargs: dict[str, Any], +): + with vllm_runner( + MODEL, + max_model_len=4096, + max_num_seqs=2, + enforce_eager=True, + limit_mm_per_prompt={"image": 1}, + **vllm_runner_kwargs, + ) as vllm_model: + vllm_model.apply_model(_install_prefill_hidden_capture) + vllm_model.generate_greedy([PROMPT], max_tokens=1, images=[image]) + return vllm_model.apply_model(_get_prefill_hidden)[0] + + +@pytest.mark.core_model +@pytest.mark.skipif( + current_platform.is_rocm(), reason="ROCm attention has accuracy issue for this test" +) +def test_mm_prefix_lm_e2e( + hf_runner: type[HfRunner], + vllm_runner: type[VllmRunner], + image_assets: ImageTestAssets, + monkeypatch: pytest.MonkeyPatch, +): + """Regression: Gemma3 native prefill must apply image prefix-LM mask.""" + monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1") + image = image_assets[0].pil_image + + vllm_runner_kwargs: dict[str, Any] = { + "mm_processor_cache_gb": 0, + "mm_processor_kwargs": {"do_pan_and_scan": True}, + } + vllm_hidden = _get_vllm_prefill_hidden(vllm_runner, image, vllm_runner_kwargs) + + hf_model = hf_runner( + MODEL, + auto_cls=AutoModelForImageTextToText, + ) + hf_model = model_utils.gemma3_patch_hf_runner(hf_model) + + with hf_model: + hf_hidden = _get_hf_prefill_hidden(hf_model, image) + + assert vllm_hidden.shape == hf_hidden.shape + + full_cos = torch.nn.functional.cosine_similarity( + vllm_hidden.flatten(), hf_hidden.flatten(), dim=0 + ) + image_cos = torch.nn.functional.cosine_similarity( + vllm_hidden[1:769].flatten(), hf_hidden[1:769].flatten(), dim=0 + ) + + assert full_cos > 0.9, ( + "Gemma3 mm-prefix-LM full prefill hidden states should be close to HF; " + f"got {full_cos=}" + ) + assert image_cos > 0.9, ( + "Gemma3 mm-prefix-LM image prefill hidden states should be close to HF; " + f"got {image_cos=}" + ) diff --git a/tests/models/multimodal/generation/test_moss_audio.py b/tests/models/multimodal/generation/test_moss_audio.py new file mode 100644 index 000000000000..a9da471b2ca3 --- /dev/null +++ b/tests/models/multimodal/generation/test_moss_audio.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +from vllm.assets.audio import AudioAsset +from vllm.model_executor.models.moss_audio import MOSS_AUDIO_PLACEHOLDER +from vllm.platforms import current_platform + +from ...registry import HF_EXAMPLE_MODELS +from ...utils import check_logprobs_close + +CORE_MODEL = pytest.param( + "OpenMOSS-Team/MOSS-Audio-4B-Instruct", + marks=pytest.mark.core_model, + id="4b-instruct", +) + +EXTENDED_MODELS = [ + "OpenMOSS-Team/MOSS-Audio-4B-Thinking", + "OpenMOSS-Team/MOSS-Audio-8B-Instruct", + "OpenMOSS-Team/MOSS-Audio-8B-Thinking", +] + +ACCURACY_MODELS = [CORE_MODEL, *EXTENDED_MODELS] + +PARALLEL_SMOKE_CASES = [ + pytest.param({"tensor_parallel_size": 2}, id="tp2"), + pytest.param({"pipeline_parallel_size": 2}, id="pp2"), + pytest.param( + {"tensor_parallel_size": 2, "pipeline_parallel_size": 2}, + id="tp2_pp2", + ), +] + +HF_ACCURACY_SKIP_REASON = ( + "HF AutoModelForCausalLM cannot load remote MOSS-Audio configs; " + "vLLM generation coverage is provided by the smoke tests below." +) + + +@pytest.mark.core_model +def test_moss_audio_generation_smoke(vllm_runner) -> None: + model = "OpenMOSS-Team/MOSS-Audio-4B-Instruct" + model_info = HF_EXAMPLE_MODELS.find_hf_info(model) + model_info.check_available_online(on_fail="skip") + model_info.check_transformers_version(on_fail="skip") + + prompts = [f"{MOSS_AUDIO_PLACEHOLDER}\nBriefly describe this audio."] + audios = [[AudioAsset("mary_had_lamb").audio_and_sample_rate[0]]] + + with vllm_runner( + model, + dtype="half", + enforce_eager=True, + max_model_len=1024, + limit_mm_per_prompt={"audio": 1}, + trust_remote_code=True, + ) as vllm_model: + outputs = vllm_model.generate_greedy( + prompts, + max_tokens=4, + audios=audios, + ) + + assert len(outputs) == 1 + assert len(outputs[0][1]) > 0 + + +@pytest.mark.skip(reason=HF_ACCURACY_SKIP_REASON) +@pytest.mark.parametrize("model", ACCURACY_MODELS) +@pytest.mark.parametrize("dtype", ["half"]) +@pytest.mark.parametrize("max_tokens", [8]) +@pytest.mark.parametrize("num_logprobs", [5]) +def test_moss_audio_hf_vllm_accuracy( + hf_runner, + vllm_runner, + model: str, + dtype: str, + max_tokens: int, + num_logprobs: int, +) -> None: + model_info = HF_EXAMPLE_MODELS.find_hf_info(model) + model_info.check_available_online(on_fail="skip") + model_info.check_transformers_version(on_fail="skip") + + prompts = [f"{MOSS_AUDIO_PLACEHOLDER}\nTranscribe this audio."] + audios = [[AudioAsset("mary_had_lamb").audio_and_sample_rate[0]]] + + with vllm_runner( + model, + dtype=dtype, + enforce_eager=True, + max_model_len=1024, + limit_mm_per_prompt={"audio": 1}, + trust_remote_code=True, + ) as vllm_model: + vllm_outputs = vllm_model.generate_greedy_logprobs( + prompts, + max_tokens, + num_logprobs=num_logprobs, + audios=audios, + ) + + with hf_runner(model, dtype=dtype, trust_remote_code=True) as hf_model: + hf_outputs = hf_model.generate_greedy_logprobs_limit( + prompts, + max_tokens, + num_logprobs=num_logprobs, + audios=audios, + ) + + check_logprobs_close( + outputs_0_lst=hf_outputs, + outputs_1_lst=vllm_outputs, + name_0="hf", + name_1="vllm", + ) + + +@pytest.mark.core_model +@pytest.mark.parametrize("parallel_kwargs", PARALLEL_SMOKE_CASES) +def test_moss_audio_parallel_smoke(vllm_runner, parallel_kwargs) -> None: + model = "OpenMOSS-Team/MOSS-Audio-4B-Instruct" + required_gpus = parallel_kwargs.get( + "tensor_parallel_size", 1 + ) * parallel_kwargs.get("pipeline_parallel_size", 1) + if current_platform.device_count() < required_gpus: + # TP/PP integration smoke runs on local or multi-GPU CI only. + pytest.skip(f"Requires at least {required_gpus} GPUs") + + model_info = HF_EXAMPLE_MODELS.find_hf_info(model) + model_info.check_available_online(on_fail="skip") + model_info.check_transformers_version(on_fail="skip") + + prompts = [f"{MOSS_AUDIO_PLACEHOLDER}\nBriefly describe this audio."] + audios = [[AudioAsset("mary_had_lamb").audio_and_sample_rate[0]]] + + with vllm_runner( + model, + dtype="half", + enforce_eager=True, + max_model_len=1024, + limit_mm_per_prompt={"audio": 1}, + trust_remote_code=True, + **parallel_kwargs, + ) as vllm_model: + outputs = vllm_model.generate_greedy( + prompts, + max_tokens=4, + audios=audios, + ) + + assert len(outputs) == 1 + assert len(outputs[0][1]) > 0 diff --git a/tests/models/multimodal/generation/test_musicflamingo.py b/tests/models/multimodal/generation/test_musicflamingo.py deleted file mode 100644 index c87c46a7c3b4..000000000000 --- a/tests/models/multimodal/generation/test_musicflamingo.py +++ /dev/null @@ -1,146 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import json -import os - -import pytest - -from tests.models.registry import HF_EXAMPLE_MODELS -from vllm import LLM, SamplingParams - -MODEL_NAME = "nvidia/music-flamingo-2601-hf" -SINGLE_CONVERSATION = [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Describe this track in full detail - tell me the " - "genre, tempo, and key, then dive into the instruments, " - "production style, and overall mood it creates.", - }, - { - "type": "audio_url", - "audio_url": { - "url": "https://huggingface.co/datasets/nvidia/AudioSkills/" - "resolve/main/assets/song_1.mp3", - }, - }, - ], - } -] -BATCHED_CONVERSATIONS = [ - SINGLE_CONVERSATION, - [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "Generate a structured lyric sheet from the input music.", - }, - { - "type": "audio_url", - "audio_url": { - "url": "https://huggingface.co/datasets/nvidia/" - "AudioSkills/resolve/main/assets/song_2.mp3", - }, - }, - ], - } - ], -] - - -def get_fixture_path(filename): - return os.path.join( - os.path.dirname(__file__), "../../fixtures/musicflamingo", filename - ) - - -def assert_output_matches(output, expected_text, expected_token_ids): - generated = output.outputs[0] - assert generated.text == expected_text - actual_token_ids = list(generated.token_ids) - assert ( - actual_token_ids == expected_token_ids - or actual_token_ids == expected_token_ids[:-1] - or actual_token_ids[:-1] == expected_token_ids - ) - - -@pytest.fixture(scope="module") -def llm(): - model_info = HF_EXAMPLE_MODELS.get_hf_info("MusicFlamingoForConditionalGeneration") - model_info.check_transformers_version(on_fail="skip") - - try: - return LLM( - model=MODEL_NAME, - dtype="bfloat16", - enforce_eager=True, - max_model_len=8192, - limit_mm_per_prompt={"audio": 1}, - ) - except Exception as e: - pytest.skip(f"Failed to load model {MODEL_NAME}: {e}") - - -def test_single_generation(llm): - fixture_path = get_fixture_path("expected_results_single.json") - if not os.path.exists(fixture_path): - pytest.skip(f"Fixture not found: {fixture_path}") - - with open(fixture_path) as f: - expected = json.load(f) - - outputs = llm.chat( - messages=SINGLE_CONVERSATION, - sampling_params=SamplingParams(temperature=0.0, max_tokens=50), - ) - - assert_output_matches( - outputs[0], - expected["transcriptions"][0], - expected["token_ids"][0], - ) - - -def test_batched_generation(llm): - fixture_path = get_fixture_path("expected_results_batched.json") - if not os.path.exists(fixture_path): - pytest.skip(f"Fixture not found: {fixture_path}") - - with open(fixture_path) as f: - expected = json.load(f) - - outputs = llm.chat( - messages=BATCHED_CONVERSATIONS, - sampling_params=SamplingParams(temperature=0.0, max_tokens=50), - ) - - for i, output in enumerate(outputs): - assert_output_matches( - output, - expected["transcriptions"][i], - expected["token_ids"][i], - ) - - -def test_single_and_batched_generation_match(llm): - sampling_params = SamplingParams(temperature=0.0, max_tokens=50) - - single_output = llm.chat( - messages=SINGLE_CONVERSATION, - sampling_params=sampling_params, - )[0] - batched_output = llm.chat( - messages=BATCHED_CONVERSATIONS, - sampling_params=sampling_params, - )[0] - - assert single_output.outputs[0].text == batched_output.outputs[0].text - assert list(single_output.outputs[0].token_ids) == list( - batched_output.outputs[0].token_ids - ) diff --git a/tests/models/multimodal/generation/test_vit_cudagraph.py b/tests/models/multimodal/generation/test_vit_cudagraph.py index cbdc5e878aed..954bbdbb9b83 100644 --- a/tests/models/multimodal/generation/test_vit_cudagraph.py +++ b/tests/models/multimodal/generation/test_vit_cudagraph.py @@ -29,6 +29,7 @@ class VitCudagraphTestConfig: vllm_runner_kwargs: dict = field(default_factory=dict) compilation_config_overrides: dict = field(default_factory=dict) marks: list = field(default_factory=list) + skip: bool = False def params_with_marks( @@ -47,6 +48,13 @@ def internvl_chat_template(content: str) -> str: return f"<|im_start|>user\n{content}<|im_end|>\n<|im_start|>assistant\n" +def kimi_vl_chat_template(content: str) -> str: + return ( + f"<|im_user|>user<|im_middle|>{content}<|im_end|>" + "<|im_assistant|>assistant<|im_middle|>" + ) + + def step3_vl_chat_template(content: str) -> str: return ( "<|begin▁of▁sentence|> You are a helpful assistant.<|BOT|>user\n " @@ -54,16 +62,51 @@ def step3_vl_chat_template(content: str) -> str: ) +def gemma3_chat_template(content: str) -> str: + return f"user\n{content}\nmodel\n" + + MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = { - "internvl": VitCudagraphTestConfig( - model="OpenGVLab/InternVL3-1B", - num_video_frames=8, - image_prompt=internvl_chat_template("\nWhat is in this image?"), - video_prompt=internvl_chat_template( - "", "").strip() + print(f"Generated JSON: {generated!r}") + parsed = json.loads(generated) + jsonschema.validate(instance=parsed, schema=json_schema) diff --git a/tests/samplers/test_beam_search_online.py b/tests/samplers/test_beam_search_online.py new file mode 100644 index 000000000000..14481f79c68a --- /dev/null +++ b/tests/samplers/test_beam_search_online.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +from vllm import CompletionOutput, RequestOutput +from vllm.entrypoints.generate.beam_search.online import BeamSearchOnlineMixin +from vllm.logprobs import Logprob +from vllm.sampling_params import BeamSearchParams + + +class _Tokenizer: + eos_token_id = 0 + + def decode(self, token_ids: list[int]) -> str: + return " ".join(str(token_id) for token_id in token_ids) + + +class _Renderer: + def get_tokenizer(self) -> _Tokenizer: + return _Tokenizer() + + +class _EngineClient: + async def generate(self, prompt, *args, **kwargs): + yield RequestOutput( + request_id=kwargs.get("request_id", "test-request"), + prompt=prompt.get("prompt"), + prompt_token_ids=prompt["prompt_token_ids"], + prompt_logprobs=None, + outputs=[ + CompletionOutput( + index=0, + text="", + token_ids=[], + cumulative_logprob=None, + logprobs=[ + { + 11: Logprob(logprob=-1.0), + 12: Logprob(logprob=-2.0), + 13: Logprob(logprob=-3.0), + 14: Logprob(logprob=-4.0), + _Tokenizer.eos_token_id: Logprob(logprob=-0.1), + } + ], + finish_reason=None, + ) + ], + finished=True, + ) + + +class _Serving(BeamSearchOnlineMixin): + renderer = _Renderer() + engine_client = _EngineClient() + + +@pytest.mark.asyncio +async def test_beam_search_handles_extra_logprob_candidates() -> None: + prompt = { + "type": "token", + "prompt": "prompt", + "prompt_token_ids": [1], + } + params = BeamSearchParams(beam_width=2, max_tokens=1) + + outputs = [ + output async for output in _Serving().beam_search(prompt, "request", params) + ] + + assert len(outputs) == 1 + assert outputs[0].outputs[0].finish_reason == "stop" + assert outputs[0].outputs[0].token_ids == [] + assert outputs[0].outputs[0].cumulative_logprob == pytest.approx(-0.1) diff --git a/tests/samplers/test_non_finite_params.py b/tests/samplers/test_non_finite_params.py new file mode 100644 index 000000000000..57fe90f314c3 --- /dev/null +++ b/tests/samplers/test_non_finite_params.py @@ -0,0 +1,51 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests that non-finite float values (NaN, Inf) are rejected by +SamplingParams validation, preventing them from propagating to GPU kernels. + +Addresses advisory GHSA-7h4p-rffg-7823. +""" + +import math + +import pytest + +from vllm import SamplingParams +from vllm.exceptions import VLLMValidationError + + +class TestNonFiniteTemperature: + """Verify that NaN and Infinity temperature values are rejected.""" + + @pytest.mark.parametrize( + "value", + [float("nan"), float("inf"), float("-inf"), math.nan, math.inf], + ids=["nan", "inf", "-inf", "math.nan", "math.inf"], + ) + def test_non_finite_temperature_rejected(self, value: float): + with pytest.raises(VLLMValidationError, match="temperature"): + SamplingParams(temperature=value) + + def test_finite_temperature_accepted(self): + SamplingParams(temperature=0.0) + SamplingParams(temperature=0.5) + SamplingParams(temperature=1.0) + SamplingParams(temperature=2.0) + + +class TestNonFiniteRepetitionPenalty: + """Verify that NaN and Infinity repetition_penalty values are rejected.""" + + @pytest.mark.parametrize( + "value", + [float("nan"), float("inf"), float("-inf"), math.nan, math.inf], + ids=["nan", "inf", "-inf", "math.nan", "math.inf"], + ) + def test_non_finite_repetition_penalty_rejected(self, value: float): + with pytest.raises(ValueError, match="repetition_penalty"): + SamplingParams(repetition_penalty=value) + + def test_finite_repetition_penalty_accepted(self): + SamplingParams(repetition_penalty=0.5) + SamplingParams(repetition_penalty=1.0) + SamplingParams(repetition_penalty=2.0) diff --git a/tests/standalone_tests/python_only_compile.sh b/tests/standalone_tests/python_only_compile.sh index c189549d7dae..ea9d2441ca01 100644 --- a/tests/standalone_tests/python_only_compile.sh +++ b/tests/standalone_tests/python_only_compile.sh @@ -4,9 +4,26 @@ set -e -merge_base_commit=$(git merge-base HEAD origin/main) +# ROCm CI runs this script inside `run-amd-test.sh` where /vllm-workspace often has no .git +# (wheel artifact layout). The wrapper passes VLLM_STANDALONE_MERGE_BASE from the agent checkout. +merge_base_commit="" +if [[ -n "${VLLM_STANDALONE_MERGE_BASE:-}" ]]; then + merge_base_commit="${VLLM_STANDALONE_MERGE_BASE}" +elif merge_base_commit="$(git -C /vllm-workspace merge-base HEAD origin/main 2>/dev/null)"; then + : +elif merge_base_commit="$(git merge-base HEAD origin/main 2>/dev/null)"; then + : +else + echo "ERROR: need a git checkout or VLLM_STANDALONE_MERGE_BASE to resolve wheels.vllm.ai commit." >&2 + exit 1 +fi + echo "INFO: current merge base commit with main: $merge_base_commit" -git show --oneline -s "$merge_base_commit" +if git show --oneline -s "$merge_base_commit" 2>/dev/null; then + : +else + echo "INFO: git show unavailable in this environment; using SHA above for precompiled metadata." +fi # test whether the metadata.json url is valid, retry each 3 minutes up to 5 times # this avoids cumbersome error messages & manual retries in case the precompiled wheel @@ -59,7 +76,12 @@ cd /vllm-workspace/ # uninstall vllm pip3 uninstall -y vllm # restore the original files -mv src/vllm ./vllm +if [[ -d src/vllm ]]; then + mv src/vllm ./vllm +elif [[ ! -d vllm ]]; then + echo "ERROR: expected vllm package at /vllm-workspace/src/vllm or /vllm-workspace/vllm" >&2 + exit 1 +fi # remove all compilers apt remove --purge build-essential -y @@ -67,7 +89,14 @@ apt autoremove -y echo 'import os; os.system("touch /tmp/changed.file")' >> vllm/__init__.py -VLLM_PRECOMPILED_WHEEL_COMMIT=$merge_base_commit VLLM_USE_PRECOMPILED=1 pip3 install -vvv -e . +# ROCm CI uses setuptools develop for editable installs (see Dockerfile.rocm and run-amd-test.sh). +_vllm_target_lower="$(printf '%s' "${VLLM_TARGET_DEVICE:-}" | tr '[:upper:]' '[:lower:]')" +if [[ "${_vllm_target_lower}" == "rocm" ]]; then + VLLM_PRECOMPILED_WHEEL_COMMIT=$merge_base_commit VLLM_USE_PRECOMPILED=1 python3 setup.py develop +else + VLLM_PRECOMPILED_WHEEL_COMMIT=$merge_base_commit VLLM_USE_PRECOMPILED=1 pip3 install -vvv -e . +fi +unset -v _vllm_target_lower # Run the script python3 -c 'import vllm' diff --git a/tests/test_config.py b/tests/test_config.py index b78570e54fbd..1e93b610da56 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,6 +34,7 @@ OptimizationLevel, ) from vllm.platforms import current_platform +from vllm.v1.attention.backend import AttentionCGSupport DEVICE_TYPE = current_platform.device_type @@ -67,6 +68,36 @@ def test_v2_model_runner_env_tri_state(monkeypatch, env_value, expected): assert envs.VLLM_USE_V2_MODEL_RUNNER is expected +@pytest.mark.parametrize( + ("use_v2_model_runner", "expected_capture_sizes"), + [ + (False, [4, 8, 12, 16]), + (True, list(range(1, 17))), + ], +) +def test_resolve_cudagraph_mode_adjusts_spec_decode_sizes_only_for_v1( + use_v2_model_runner, + expected_capture_sizes, +): + compilation_config = CompilationConfig( + cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE, + cudagraph_capture_sizes=list(range(1, 17)), + ) + compilation_config.max_cudagraph_capture_size = 16 + compilation_config.post_init_cudagraph_sizes() + + cudagraph_mode = compilation_config.resolve_cudagraph_mode_and_sizes( + AttentionCGSupport.ALWAYS, + "FakeAttentionBackend", + uniform_decode_query_len=4, + use_v2_model_runner=use_v2_model_runner, + tensor_parallel_size=1, + ) + + assert cudagraph_mode == CUDAGraphMode.FULL_AND_PIECEWISE + assert compilation_config.cudagraph_capture_sizes == expected_capture_sizes + + @pytest.mark.parametrize( ("model_config", "expected"), [ @@ -118,12 +149,72 @@ def test_v2_model_runner_env_tri_state(monkeypatch, env_value, expected): is_moe=False, is_quantized=False, ), - False, + True, + ), + ( + SimpleNamespace( + model="google/gemma-2-2b", + architectures=["Gemma2ForCausalLM"], + runner_type="generate", + is_moe=False, + is_quantized=False, + ), + True, + ), + ( + SimpleNamespace( + model="deepseek-ai/DeepSeek-V2-Lite-Chat", + architectures=["DeepseekV2ForCausalLM"], + runner_type="generate", + is_moe=True, + is_quantized=False, + ), + True, + ), + ( + SimpleNamespace( + model="deepseek-ai/DeepSeek-V2-Chat", + architectures=["DeepseekV2ForCausalLM"], + runner_type="generate", + is_moe=True, + is_quantized=False, + ), + True, + ), + ( + SimpleNamespace( + model="Qwen/Qwen1.5-MoE-A2.7B", + architectures=["Qwen2MoeForCausalLM"], + runner_type="generate", + is_moe=True, + is_quantized=False, + ), + True, + ), + ( + SimpleNamespace( + model="Qwen/Qwen1.5-MoE-A2.7B-Chat", + architectures=["Qwen2MoeForCausalLM"], + runner_type="generate", + is_moe=True, + is_quantized=False, + ), + True, ), ( SimpleNamespace( - model="Qwen/Qwen3-30B-A3B", - architectures=["Qwen3MoeForCausalLM"], + model="ibm-research/PowerMoE-3b", + architectures=["GraniteMoeForCausalLM"], + runner_type="generate", + is_moe=True, + is_quantized=False, + ), + True, + ), + ( + SimpleNamespace( + model="mistralai/Mixtral-8x7B-Instruct-v0.1", + architectures=["MixtralForCausalLM"], runner_type="generate", is_moe=True, is_quantized=False, @@ -138,7 +229,7 @@ def test_v2_model_runner_env_tri_state(monkeypatch, env_value, expected): is_moe=False, is_quantized=True, ), - False, + True, ), ( SimpleNamespace( @@ -147,6 +238,18 @@ def test_v2_model_runner_env_tri_state(monkeypatch, env_value, expected): runner_type="generate", is_moe=False, is_quantized=False, + is_hybrid=True, + ), + False, + ), + ( + SimpleNamespace( + model="state-spaces/mamba-130m-hf", + architectures=["MambaForCausalLM"], + runner_type="generate", + is_moe=False, + is_quantized=False, + is_attention_free=True, ), False, ), @@ -546,6 +649,30 @@ def test_nested_hf_overrides(): assert model_config.hf_config.vision_config.hidden_size == 512 +def test_model_class_overrides_registers_target(): + """`model_class_overrides` redirects an architecture to a custom class.""" + from vllm.model_executor.models import ModelRegistry + + arch = "_TestModelClassOverrideArch" + target = "vllm.model_executor.models.llama:LlamaForCausalLM" + assert arch not in ModelRegistry.models + + model_config = ModelConfig( + "facebook/opt-125m", + model_class_overrides={arch: target}, + ) + try: + # Accessing `.registry` is the chokepoint that applies the overrides; + # it has already run during construction. + registered = model_config.registry.models[arch] + assert registered.module_name == "vllm.model_executor.models.llama" + assert registered.class_name == "LlamaForCausalLM" + # Idempotent: a second access does not re-register or error out. + assert model_config.registry.models[arch] is registered + finally: + ModelRegistry.models.pop(arch, None) + + @pytest.mark.skipif( current_platform.is_rocm(), reason="Encoder Decoder models not supported on ROCm." ) @@ -1242,7 +1369,9 @@ def test_vllm_config_explicit_overrides(): compilation_config=compilation_config, ) assert config.compilation_config.cudagraph_mode == CUDAGraphMode.NONE - assert config.compilation_config.pass_config.enable_qk_norm_rope_fusion is True + assert config.compilation_config.pass_config.enable_qk_norm_rope_fusion is ( + current_platform.is_cuda_alike() or current_platform.is_xpu() + ) # Mode should still use default for O2 assert config.compilation_config.mode == CompilationMode.VLLM_COMPILE @@ -1507,3 +1636,14 @@ def test_ir_op_priority_ctx(): # context restored even after exception assert ir.ops.rms_norm.get_priority() == ["vllm_c", "native"] assert ir.ops.fused_add_rms_norm.get_priority() == ["native"] + + +def test_load_config_rejects_invalid_safetensors_load_strategy(): + with pytest.raises(pydantic.ValidationError): + LoadConfig(safetensors_load_strategy="not_a_real_strategy") + + +@pytest.mark.parametrize("bad_load_format", [None, 123]) +def test_load_config_rejects_non_string_load_format(bad_load_format): + with pytest.raises(pydantic.ValidationError): + LoadConfig(load_format=bad_load_format) diff --git a/tests/test_envs.py b/tests/test_envs.py index e0211b56308f..d4d120ecee51 100644 --- a/tests/test_envs.py +++ b/tests/test_envs.py @@ -104,15 +104,32 @@ def test_is_envs_cache_enabled() -> None: def test_precompiled_install_flags_are_orthogonal() -> None: + # The Rust frontend flag is independent of the C-extension precompiled + # flag: requesting the precompiled Rust frontend must not implicitly + # enable the precompiled C extensions. + with patch.dict(os.environ, {"VLLM_USE_PRECOMPILED_RUST": "1"}, clear=True): + assert environment_variables["VLLM_USE_PRECOMPILED"]() is False + assert environment_variables["VLLM_USE_PRECOMPILED_RUST"]() is True + + # ...and the reverse: requesting precompiled C extensions (here via a + # wheel location, which enables VLLM_USE_PRECOMPILED) must not flip the + # Rust frontend flag. + with patch.dict( + os.environ, {"VLLM_PRECOMPILED_WHEEL_LOCATION": "/tmp/vllm.whl"}, clear=True + ): + assert environment_variables["VLLM_USE_PRECOMPILED"]() is True + assert environment_variables["VLLM_USE_PRECOMPILED_RUST"]() is False + + # ...and with both set together, each flag is still parsed independently. with patch.dict( os.environ, { "VLLM_PRECOMPILED_WHEEL_LOCATION": "/tmp/vllm.whl", "VLLM_USE_PRECOMPILED_RUST": "1", }, - clear=False, + clear=True, ): - assert environment_variables["VLLM_USE_PRECOMPILED"]() is False + assert environment_variables["VLLM_USE_PRECOMPILED"]() is True assert environment_variables["VLLM_USE_PRECOMPILED_RUST"]() is True diff --git a/tests/test_force_first_config.py b/tests/test_force_first_config.py new file mode 100644 index 000000000000..9db7805e1523 --- /dev/null +++ b/tests/test_force_first_config.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Targeted unit tests for VLLM_TRITON_FORCE_FIRST_CONFIG. + +These tests exercise only the patched `Autotuner.run` logic installed by +`vllm.triton_utils.force_first_config.install`. The wrapped kernel is a +plain callable so the tests run on CPU-only hosts (no GPU, no actual +kernel launch) as long as the `triton` package is importable. +""" + +from types import SimpleNamespace + +import pytest + +from vllm.triton_utils import HAS_TRITON, triton + +if not HAS_TRITON: + pytest.skip("triton not available", allow_module_level=True) + +from vllm.triton_utils import force_first_config # noqa: E402 + +OutOfResources = triton.runtime.errors.OutOfResources + + +@pytest.fixture +def patched_autotuner(monkeypatch: pytest.MonkeyPatch): + """Install the first-valid-config patch and restore after. + + The env-var gate lives in vllm.env_override; install() itself does not + read the environment, so the test calls it directly. + """ + Autotuner = triton.runtime.autotuner.Autotuner + original_run = Autotuner.run + # Reset the once-only guard so install() re-runs for each test. + monkeypatch.setattr(force_first_config, "_installed", False) + force_first_config.install() + yield Autotuner + Autotuner.run = original_run + + +def _make_fake_self(configs, fn): + """Minimal stand-in for an Autotuner instance.""" + return SimpleNamespace( + configs=configs, + keys=[], + arg_names=[], + base_fn=fn, + fn=fn, + best_config=None, + ) + + +def test_skips_invalid_first_config_and_caches_second(patched_autotuner): + bad = triton.Config({"BLOCK": 1024}) + good = triton.Config({"BLOCK": 64}) + calls = [] + + def fake_fn(*args, **kwargs): + calls.append(kwargs["BLOCK"]) + if kwargs["BLOCK"] == 1024: + raise OutOfResources(required=99999, limit=1, name="shared memory") + return "ok" + + fake_self = _make_fake_self([bad, good], fake_fn) + + # First call: walks past the invalid config, picks the second. + assert patched_autotuner.run(fake_self) == "ok" + assert calls == [1024, 64] + assert fake_self.best_config is good + + # Second call: cached index is reused, invalid config is NOT retried. + calls.clear() + assert patched_autotuner.run(fake_self) == "ok" + assert calls == [64] + + +def test_empty_configs_falls_back_to_direct_call(patched_autotuner): + def fake_fn(*args, **kwargs): + return "direct" + + fake_self = _make_fake_self([], fake_fn) + assert patched_autotuner.run(fake_self) == "direct" + + +def test_all_configs_invalid_raises_runtime_error(patched_autotuner): + cfgs = [triton.Config({"BLOCK": 1024}), triton.Config({"BLOCK": 2048})] + + def always_oor(*args, **kwargs): + raise OutOfResources(required=99999, limit=1, name="shared memory") + + fake_self = _make_fake_self(cfgs, always_oor) + with pytest.raises(RuntimeError, match="[Nn]o valid config"): + patched_autotuner.run(fake_self) diff --git a/tests/test_jit_monitor.py b/tests/test_jit_monitor.py index a463f4b5faa1..9f3285ddec0e 100644 --- a/tests/test_jit_monitor.py +++ b/tests/test_jit_monitor.py @@ -1,25 +1,38 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import inspect import os import sys -from types import SimpleNamespace +from contextlib import contextmanager +from types import ModuleType, SimpleNamespace +from typing import Any, cast from unittest import mock import pytest -from vllm.triton_utils import jit_monitor +from vllm.utils import jit_monitor @pytest.fixture(autouse=True) def _reset_monitor(): """Reset global monitor state between tests.""" jit_monitor._active = False + jit_monitor._mode = "warn" + jit_monitor._verbose = False + jit_monitor._cutedsl_hook_installed = False + jit_monitor._tilelang_hook_installed = False + jit_monitor._tilelang_jitimpl_compile_depth = 0 yield jit_monitor._active = False + jit_monitor._mode = "warn" + jit_monitor._verbose = False + jit_monitor._cutedsl_hook_installed = False + jit_monitor._tilelang_hook_installed = False + jit_monitor._tilelang_jitimpl_compile_depth = 0 # ------------------------------------------------------------------ -# Helpers — lightweight stand-ins for triton.knobs +# Helpers — lightweight stand-ins for the modules ``activate()`` patches # ------------------------------------------------------------------ @@ -30,10 +43,79 @@ def _make_fake_knobs(*, autotuning_print=False, jit_hook=None): return SimpleNamespace(autotuning=autotuning, runtime=runtime) -def _patch_triton_knobs(fake_knobs): - """Context manager that makes ``from triton import knobs`` return *fake_knobs*.""" - fake_triton = SimpleNamespace(knobs=fake_knobs) - return mock.patch.dict(sys.modules, {"triton": fake_triton}) +def _fake_cute_import_modules(compile_fn): + """Fake Python's parent package + submodule for ``import cutlass.cute``.""" + fake_cute = cast(Any, ModuleType("cutlass.cute")) + fake_cute.compile = compile_fn + fake_parent_package = cast(Any, ModuleType("cutlass")) + fake_parent_package.__path__ = [] + fake_parent_package.cute = fake_cute + return { + "cutlass": fake_parent_package, + "cutlass.cute": fake_cute, + } + + +def _fake_cute_compile(*args, **kwargs): + return "compiled" + + +def _fake_tilelang_import_modules(): + """Fake Python's TileLang modules touched by ``jit_monitor.activate``.""" + + class FakeJITKernel: + def __init__(self, *args, **kwargs): + pass + + class FakeJITImpl: + def __init__(self, func, signature): + self.func = func + self.signature = signature + self.mode = "lazy" + self._kernel_cache = {} + + def __call__(self, *args, **kwargs): + key, _ = self.func.parse_args(*args, **kwargs) + kernel = self._kernel_cache.get(key) + if kernel is None: + kernel = "compiled" + self._kernel_cache[key] = kernel + return kernel + + fake_kernel = cast(Any, ModuleType("tilelang.jit.kernel")) + fake_kernel.JITKernel = FakeJITKernel + + fake_jit = cast(Any, ModuleType("tilelang.jit")) + fake_jit.JITImpl = FakeJITImpl + fake_jit.kernel = fake_kernel + + fake_tilelang = cast(Any, ModuleType("tilelang")) + fake_tilelang.jit = fake_jit + + return { + "tilelang": fake_tilelang, + "tilelang.jit": fake_jit, + "tilelang.jit.kernel": fake_kernel, + } + + +@contextmanager +def _patch_jit_modules(fake_knobs, *, cute_compile=_fake_cute_compile): + """Patch the Triton and CuTeDSL imports touched by ``jit_monitor.activate``.""" + fake_triton = cast(Any, ModuleType("triton")) + fake_triton.knobs = fake_knobs + with ( + mock.patch.dict( + sys.modules, + { + "triton": fake_triton, + **_fake_cute_import_modules(cute_compile), + **_fake_tilelang_import_modules(), + }, + ), + mock.patch.object(jit_monitor, "HAS_TRITON", True), + ): + yield # ------------------------------------------------------------------ @@ -44,13 +126,13 @@ def _patch_triton_knobs(fake_knobs): class TestActivateBasic: def test_sets_active(self): assert not jit_monitor.is_active() - with _patch_triton_knobs(_make_fake_knobs()): + with _patch_jit_modules(_make_fake_knobs()): jit_monitor.activate() assert jit_monitor.is_active() def test_idempotent(self): fake = _make_fake_knobs() - with _patch_triton_knobs(fake): + with _patch_jit_modules(fake): jit_monitor.activate() first_hook = fake.runtime.jit_post_compile_hook jit_monitor.activate() @@ -59,17 +141,21 @@ def test_idempotent(self): def test_logs_info_on_activation(self): with ( mock.patch.object(jit_monitor.logger, "info") as m, - _patch_triton_knobs(_make_fake_knobs()), + _patch_jit_modules(_make_fake_knobs()), ): jit_monitor.activate() m.assert_called_once() assert "Kernel JIT monitor activated" in m.call_args[0][0] + def test_rejects_unknown_mode(self): + with pytest.raises(ValueError, match="Unsupported JIT monitor mode"): + jit_monitor.activate(mode="panic") # type: ignore[arg-type] + class TestAutotuningPrint: def test_enables_autotuning_print(self): fake = _make_fake_knobs(autotuning_print=False) - with _patch_triton_knobs(fake): + with _patch_jit_modules(fake): jit_monitor.activate() assert fake.autotuning.print is True @@ -77,7 +163,7 @@ def test_respects_user_opt_out(self): fake = _make_fake_knobs(autotuning_print=False) with ( mock.patch.dict(os.environ, {"TRITON_PRINT_AUTOTUNING": "0"}), - _patch_triton_knobs(fake), + _patch_jit_modules(fake), ): jit_monitor.activate() assert fake.autotuning.print is False @@ -86,29 +172,32 @@ def test_noop_when_user_already_enabled(self): fake = _make_fake_knobs(autotuning_print=True) with ( mock.patch.dict(os.environ, {"TRITON_PRINT_AUTOTUNING": "1"}), - _patch_triton_knobs(fake), + _patch_jit_modules(fake), ): jit_monitor.activate() assert fake.autotuning.print is True -class TestJitHook: +class TestTritonJitHook: def test_hook_registered(self): fake = _make_fake_knobs() assert fake.runtime.jit_post_compile_hook is None - with _patch_triton_knobs(fake): + with _patch_jit_modules(fake): jit_monitor.activate() assert fake.runtime.jit_post_compile_hook is not None def test_hook_logs_warning(self): fake = _make_fake_knobs() - with _patch_triton_knobs(fake): + with _patch_jit_modules(fake): jit_monitor.activate() hook = fake.runtime.jit_post_compile_hook mock_fn = SimpleNamespace(name="test_kernel") - with mock.patch.object(jit_monitor.logger, "warning") as m: + with ( + mock.patch.object(jit_monitor.logger, "warning_once") as m, + mock.patch.object(jit_monitor.logger, "warning") as warning, + ): hook( key="some_key", repr="some_repr", @@ -119,6 +208,7 @@ def test_hook_logs_warning(self): ) m.assert_called_once() + warning.assert_not_called() msg = m.call_args[0][0] % m.call_args[0][1:] assert "Triton kernel JIT compilation during inference" in msg assert "test_kernel" in msg @@ -126,7 +216,7 @@ def test_hook_logs_warning(self): def test_hook_chains_existing_hook(self): existing = mock.MagicMock(return_value="existing_result") fake = _make_fake_knobs(jit_hook=existing) - with _patch_triton_knobs(fake): + with _patch_jit_modules(fake): jit_monitor.activate() hook = fake.runtime.jit_post_compile_hook @@ -146,7 +236,7 @@ def test_hook_chains_existing_hook(self): def test_hook_works_without_existing_hook(self): fake = _make_fake_knobs(jit_hook=None) - with _patch_triton_knobs(fake): + with _patch_jit_modules(fake): jit_monitor.activate() hook = fake.runtime.jit_post_compile_hook @@ -161,6 +251,23 @@ def test_hook_works_without_existing_hook(self): ) assert result is None + def test_error_mode_raises(self): + fake = _make_fake_knobs() + with _patch_jit_modules(fake): + jit_monitor.activate(mode="error") + + hook = fake.runtime.jit_post_compile_hook + mock_fn = SimpleNamespace(name="error_kernel") + with pytest.raises(RuntimeError, match="Triton kernel JIT compilation"): + hook( + key="k", + repr="r", + fn=mock_fn, + compile=lambda: None, + is_manual_warmup=False, + already_compiled=False, + ) + class TestNoTritonFallback: def test_activate_without_triton(self): @@ -169,6 +276,161 @@ def test_activate_without_triton(self): assert jit_monitor.is_active() +class TestCuTeDSLHook: + def test_compile_logs_warning(self): + def compile_fn(*args, **kwargs): + return "compiled" + + with _patch_jit_modules(_make_fake_knobs(), cute_compile=compile_fn): + import cutlass.cute as cute + + jit_monitor.activate() + with mock.patch.object(jit_monitor.logger, "warning_once") as warning_once: + result = cute.compile(lambda: None, "arg", option=True) + + assert result == "compiled" + warning_once.assert_called_once() + msg = warning_once.call_args[0][0] % warning_once.call_args[0][1:] + assert "CuTeDSL JIT compilation during inference" in msg + + def test_compile_logs_verbose_warning(self): + def compile_fn(*args, **kwargs): + return "compiled" + + with _patch_jit_modules(_make_fake_knobs(), cute_compile=compile_fn): + import cutlass.cute as cute + + jit_monitor.activate(verbose=True) + with mock.patch.object(jit_monitor.logger, "warning") as warning: + result = cute.compile(lambda: None, "arg", option=True) + + assert result == "compiled" + warning.assert_called_once() + msg = warning.call_args[0][0] % warning.call_args[0][1:] + assert "CuTeDSL JIT compilation during inference" in msg + + def test_error_mode_raises(self): + def compile_fn(*args, **kwargs): + return "compiled" + + with _patch_jit_modules(_make_fake_knobs(), cute_compile=compile_fn): + import cutlass.cute as cute + + jit_monitor.activate(mode="error") + with pytest.raises(RuntimeError, match="CuTeDSL JIT compilation"): + cute.compile(lambda: None, "arg", option=True) + + +class TestTileLangHook: + def test_jit_kernel_logs_warning(self): + with _patch_jit_modules(_make_fake_knobs()): + from tilelang.jit.kernel import JITKernel + + func = SimpleNamespace(attrs={"global_symbol": "tl_kernel"}) + jit_monitor.activate() + with mock.patch.object(jit_monitor.logger, "warning_once") as warning_once: + JITKernel(func=func, out_idx=None, execution_backend="tvm_ffi") + + warning_once.assert_called_once() + msg = warning_once.call_args[0][0] % warning_once.call_args[0][1:] + assert "TileLang JIT compilation during inference" in msg + assert "tl_kernel" in msg + + def test_jit_impl_logs_warning(self): + with _patch_jit_modules(_make_fake_knobs()): + from tilelang.jit import JITImpl + + def tilelang_fn( + gemm_out_mul, + hidden_size: int, + n_splits: int = 1, + hc_mult: int = 4, + ): + return None + + class FakeFunc: + orig_func = tilelang_fn + + def parse_args(self, *args, **kwargs): + return ( + ( + "tilelang_key", + kwargs["hidden_size"], + kwargs.get("n_splits", 1), + ), + {}, + ) + + def set_mode(self, mode): + self.mode = mode + + tensor = SimpleNamespace( + shape=(2, 16, 24), + dtype="float32", + device="cuda:0", + ) + impl = JITImpl(FakeFunc(), inspect.signature(tilelang_fn)) + + jit_monitor.activate() + with ( + mock.patch.object(jit_monitor.logger, "warning_once") as warning_once, + mock.patch.object(jit_monitor.logger, "warning") as warning, + ): + impl(tensor, hidden_size=7168, n_splits=2) + + warning_once.assert_called_once() + warning.assert_not_called() + msg = warning_once.call_args[0][0] % warning_once.call_args[0][1:] + assert "TileLang JIT compilation during inference" in msg + assert "tilelang_fn" in msg + + def test_jit_impl_does_not_log_on_cache_hit(self): + with _patch_jit_modules(_make_fake_knobs()): + from tilelang.jit import JITImpl + + def tilelang_fn(gemm_out_mul, n_splits: int = 1): + return None + + class FakeFunc: + orig_func = tilelang_fn + + def parse_args(self, *args, **kwargs): + return (("tilelang_key", kwargs.get("n_splits", 1)), {}) + + def set_mode(self, mode): + self.mode = mode + + tensor = SimpleNamespace(shape=(2, 16, 24), dtype="float32") + impl = JITImpl(FakeFunc(), inspect.signature(tilelang_fn)) + + jit_monitor.activate() + with mock.patch.object(jit_monitor.logger, "warning_once") as warning_once: + impl(tensor, n_splits=2) + impl(tensor, n_splits=2) + + warning_once.assert_called_once() + + def test_from_database_does_not_log(self): + with _patch_jit_modules(_make_fake_knobs()): + from tilelang.jit.kernel import JITKernel + + func = SimpleNamespace(attrs={"global_symbol": "cached_tl_kernel"}) + jit_monitor.activate() + with mock.patch.object(jit_monitor.logger, "warning_once") as warning_once: + JITKernel(func=func, from_database=True) + + warning_once.assert_not_called() + + def test_error_mode_raises(self): + with _patch_jit_modules(_make_fake_knobs()): + from tilelang.jit.kernel import JITKernel + + func = SimpleNamespace(attrs={"global_symbol": "error_tl_kernel"}) + jit_monitor.activate(mode="error") + with pytest.raises(RuntimeError, match="TileLang JIT compilation"): + JITKernel(func=func) + + # ------------------------------------------------------------------ # Integration tests (real Triton + GPU) # ------------------------------------------------------------------ @@ -206,9 +468,9 @@ def _add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr): tl.store(out_ptr + offs, x + y, mask=mask) -def _run_add_kernel(n: int, block: int = 256) -> None: +def _run_add_kernel(n: int, block: int = 256, offset: int = 0) -> None: """Launch ``_add_kernel`` with vectors of length *n*.""" - x = torch.randn(n, device="cuda") + x = torch.randn(n + offset, device="cuda")[offset:] # affect alignment y = torch.randn(n, device="cuda") out = torch.empty(n, device="cuda") grid = ((n + block - 1) // block,) @@ -224,7 +486,7 @@ def test_no_warning_on_cached_shape(self): _run_add_kernel(1024) jit_monitor.activate() - with mock.patch.object(jit_monitor.logger, "warning") as w: + with mock.patch.object(jit_monitor.logger, "warning_once") as w: _run_add_kernel(1024) w.assert_not_called() @@ -232,9 +494,21 @@ def test_warning_on_new_constexpr(self): _run_add_kernel(1024, block=256) jit_monitor.activate() - with mock.patch.object(jit_monitor.logger, "warning") as w: + with mock.patch.object(jit_monitor.logger, "warning_once") as w: # Different BLOCK (a tl.constexpr) forces recompilation. _run_add_kernel(1024, block=512) w.assert_called() msg = w.call_args[0][0] % w.call_args[0][1:] assert "_add_kernel" in msg + + def test_verbose_warning_on_each_new_pointer_alignment(self): + _run_add_kernel(1024) + + jit_monitor.activate(verbose=True) + with ( + mock.patch.object(jit_monitor.logger, "warning") as w, + mock.patch.object(jit_monitor.logger, "warning_once") as w_once, + ): + _run_add_kernel(1024, offset=1) + assert w.called + w_once.assert_not_called() diff --git a/tests/test_logger.py b/tests/test_logger.py index b4f44f52d4df..2ff100151b29 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -10,12 +10,11 @@ from json.decoder import JSONDecodeError from tempfile import NamedTemporaryFile from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch from uuid import uuid4 import pytest -from vllm.entrypoints.logger import RequestLogger from vllm.logger import ( _DATE_FORMAT, _FORMAT, @@ -269,248 +268,6 @@ class CustomClass: assert prepare_object_to_dump(CustomClass(1, "b")) == "CustomClass(a=1, b='b')" -def test_request_logger_log_outputs(): - """Test the new log_outputs functionality.""" - # Create a mock logger to capture log calls - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - request_logger = RequestLogger(max_log_len=None) - - # Test basic output logging - request_logger.log_outputs( - request_id="test-123", - outputs="Hello, world!", - output_token_ids=[1, 2, 3, 4], - finish_reason="stop", - is_streaming=False, - delta=False, - ) - - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args.args - assert "Generated response %s%s" in call_args[0] - assert call_args[1] == "test-123" - assert call_args[3] == "Hello, world!" - assert call_args[4] == [1, 2, 3, 4] - assert call_args[5] == "stop" - - -def test_request_logger_log_outputs_streaming_delta(): - """Test log_outputs with streaming delta mode.""" - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - request_logger = RequestLogger(max_log_len=None) - - # Test streaming delta logging - request_logger.log_outputs( - request_id="test-456", - outputs="Hello", - output_token_ids=[1], - finish_reason=None, - is_streaming=True, - delta=True, - ) - - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args.args - assert "Generated response %s%s" in call_args[0] - assert call_args[1] == "test-456" - assert call_args[2] == " (streaming delta)" - assert call_args[3] == "Hello" - assert call_args[4] == [1] - assert call_args[5] is None - - -def test_request_logger_log_outputs_streaming_complete(): - """Test log_outputs with streaming complete mode.""" - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - request_logger = RequestLogger(max_log_len=None) - - # Test streaming complete logging - request_logger.log_outputs( - request_id="test-789", - outputs="Complete response", - output_token_ids=[1, 2, 3], - finish_reason="length", - is_streaming=True, - delta=False, - ) - - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args.args - assert "Generated response %s%s" in call_args[0] - assert call_args[1] == "test-789" - assert call_args[2] == " (streaming complete)" - assert call_args[3] == "Complete response" - assert call_args[4] == [1, 2, 3] - assert call_args[5] == "length" - - -def test_request_logger_log_outputs_with_truncation(): - """Test log_outputs respects max_log_len setting.""" - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - # Set max_log_len to 10 - request_logger = RequestLogger(max_log_len=10) - - # Test output truncation - long_output = "This is a very long output that should be truncated" - long_token_ids = list(range(20)) # 20 tokens - - request_logger.log_outputs( - request_id="test-truncate", - outputs=long_output, - output_token_ids=long_token_ids, - finish_reason="stop", - is_streaming=False, - delta=False, - ) - - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args - - # Check that output was truncated to first 10 characters - logged_output = call_args[0][3] - assert logged_output == "This is a " - assert len(logged_output) == 10 - - # Check that token IDs were truncated to first 10 tokens - logged_token_ids = call_args[0][4] - assert logged_token_ids == list(range(10)) - assert len(logged_token_ids) == 10 - - -def test_request_logger_log_outputs_none_values(): - """Test log_outputs handles None values correctly.""" - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - request_logger = RequestLogger(max_log_len=None) - - # Test with None output_token_ids - request_logger.log_outputs( - request_id="test-none", - outputs="Test output", - output_token_ids=None, - finish_reason="stop", - is_streaming=False, - delta=False, - ) - - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args.args - assert "Generated response %s%s" in call_args[0] - assert call_args[1] == "test-none" - assert call_args[3] == "Test output" - assert call_args[4] is None - assert call_args[5] == "stop" - - -def test_request_logger_log_outputs_empty_output(): - """Test log_outputs handles empty output correctly.""" - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - request_logger = RequestLogger(max_log_len=5) - - # Test with empty output - request_logger.log_outputs( - request_id="test-empty", - outputs="", - output_token_ids=[], - finish_reason="stop", - is_streaming=False, - delta=False, - ) - - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args.args - assert "Generated response %s%s" in call_args[0] - assert call_args[1] == "test-empty" - assert call_args[3] == "" - assert call_args[4] == [] - assert call_args[5] == "stop" - - -def test_request_logger_log_outputs_integration(): - """Test that log_outputs can be called alongside log_inputs.""" - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - request_logger = RequestLogger(max_log_len=None) - - # Test that both methods can be called without interference - request_logger.log_inputs( - request_id="test-integration", - prompt="Test prompt", - prompt_token_ids=[1, 2, 3], - prompt_embeds=None, - params=None, - lora_request=None, - ) - - request_logger.log_outputs( - request_id="test-integration", - outputs="Test output", - output_token_ids=[4, 5, 6], - finish_reason="stop", - is_streaming=False, - delta=False, - ) - - # Should have been called twice - once for inputs, once for outputs - assert mock_logger.info.call_count == 2 - - # Check that the calls were made with correct patterns - input_call = mock_logger.info.call_args_list[0][0] - output_call = mock_logger.info.call_args_list[1][0] - - assert "Received request %s" in input_call[0] - assert input_call[1] == "test-integration" - - assert "Generated response %s%s" in output_call[0] - assert output_call[1] == "test-integration" - - -def test_streaming_complete_logs_full_text_content(): - """Test that streaming complete logging includes - full accumulated text, not just token count.""" - mock_logger = MagicMock() - - with patch("vllm.entrypoints.logger.logger", mock_logger): - request_logger = RequestLogger(max_log_len=None) - - # Test with actual content instead of token count format - full_response = "This is a complete response from streaming" - request_logger.log_outputs( - request_id="test-streaming-full-text", - outputs=full_response, - output_token_ids=None, - finish_reason="streaming_complete", - is_streaming=True, - delta=False, - ) - - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args.args - - # Verify the logged output is the full text, not a token count format - logged_output = call_args[3] - assert logged_output == full_response - assert "tokens>" not in logged_output - assert "streaming_complete" not in logged_output - - # Verify other parameters - assert call_args[1] == "test-streaming-full-text" - assert call_args[2] == " (streaming complete)" - assert call_args[5] == "streaming_complete" - - # Add vllm prefix to make sure logs go through the vllm logger test_logger = init_logger("vllm.test_logger") diff --git a/tests/test_pooling_params.py b/tests/test_pooling_params.py index 6cf2a82d2ff1..6bd97db03dc1 100644 --- a/tests/test_pooling_params.py +++ b/tests/test_pooling_params.py @@ -74,6 +74,29 @@ def test_embed_dimensions(model_info: EmbedModelInfo): pooling_params.verify(model_config) +@dataclass() +class MockMatryoshkaModelConfig: + pooler_config: PoolerConfig + is_matryoshka: bool = True + matryoshka_dimensions: list[int] | None = None + served_model_name: str = "mock-matryoshka-model" + embedding_size: int = 32 + + +def test_embed_dimensions_matryoshka_without_list_upper_bound(): + task = "embed" + model_config = MockMatryoshkaModelConfig( + pooler_config=PoolerConfig(seq_pooling_type="CLS"), + matryoshka_dimensions=None, + embedding_size=32, + ) + + PoolingParams(task=task, dimensions=16).verify(model_config) + + with pytest.raises(ValueError): + PoolingParams(task=task, dimensions=64).verify(model_config) + + @pytest.mark.parametrize("task", ["classify"]) def test_classify(task): model_config = MockModelConfig(pooler_config=PoolerConfig(seq_pooling_type="CLS")) diff --git a/tests/test_sampling_params.py b/tests/test_sampling_params.py new file mode 100644 index 000000000000..e5d811fbb137 --- /dev/null +++ b/tests/test_sampling_params.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from dataclasses import dataclass + +import pytest + +from vllm import SamplingParams + + +@dataclass +class MockModelConfig: + is_diffusion: bool = False + max_logprobs: int = 20 + logits_processors: list | None = None + + def get_vocab_size(self) -> int: + return 1024 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"temperature": 0.7}, + {"temperature": 0.0}, + {"min_p": 0.1}, + {"seed": 42}, + {"min_tokens": 5}, + {"logit_bias": {0: 1.0}}, + {"bad_words": ["foo"]}, + {"allowed_token_ids": [0, 1]}, + ], +) +def test_diffusion_rejects_unsupported_params(kwargs: dict): + params = SamplingParams(**kwargs) + with pytest.raises(ValueError, match="not yet supported with diffusion"): + params.verify(MockModelConfig(is_diffusion=True), None, None, None) + + +def test_diffusion_accepts_default_params(): + SamplingParams().verify(MockModelConfig(is_diffusion=True), None, None, None) + + +def test_diffusion_accepts_top_k_top_p(): + params = SamplingParams(top_p=0.9, top_k=10) + params.verify(MockModelConfig(is_diffusion=True), None, None, None) + + +def test_non_diffusion_models_unaffected(): + params = SamplingParams(temperature=0.7, top_k=10, seed=42) + params.verify(MockModelConfig(), None, None, None) diff --git a/tests/test_seed_behavior.py b/tests/test_seed_behavior.py deleted file mode 100644 index adc8a1a4bf08..000000000000 --- a/tests/test_seed_behavior.py +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import random - -import numpy as np -import torch - -from vllm.platforms.interface import Platform - - -def test_seed_behavior(): - # Test with a specific seed - Platform.seed_everything(42) - random_value_1 = random.randint(0, 100) - np_random_value_1 = np.random.randint(0, 100) - torch_random_value_1 = torch.randint(0, 100, (1,)).item() - - Platform.seed_everything(42) - random_value_2 = random.randint(0, 100) - np_random_value_2 = np.random.randint(0, 100) - torch_random_value_2 = torch.randint(0, 100, (1,)).item() - - assert random_value_1 == random_value_2 - assert np_random_value_1 == np_random_value_2 - assert torch_random_value_1 == torch_random_value_2 diff --git a/tests/tokenizers_/conftest.py b/tests/tokenizers_/conftest.py deleted file mode 100644 index c33ab351608d..000000000000 --- a/tests/tokenizers_/conftest.py +++ /dev/null @@ -1,14 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import pytest - -from tests.utils import prewarm_hf_cache - - -@pytest.fixture(scope="session", autouse=True) -def _prewarm_hf_cache(): - # tokenization_qwen.py downloads SimSun.ttf from - # qianwen-res.oss-cn-beijing.aliyuncs.com; both Qwen/Qwen-VL and - # Qwen/Qwen-VL-Chat look it up from the Chat repo. - prewarm_hf_cache([("Qwen/Qwen-VL-Chat", "SimSun.ttf")]) diff --git a/tests/tokenizers_/test_basic.py b/tests/tokenizers_/test_basic.py index cf0d8f53c6f2..4da6381a0bc9 100644 --- a/tests/tokenizers_/test_basic.py +++ b/tests/tokenizers_/test_basic.py @@ -5,11 +5,10 @@ import pytest from transformers import ( PreTrainedTokenizerBase, - PreTrainedTokenizerFast, + TokenizersBackend, ) from vllm.tokenizers import TokenizerLike, get_tokenizer -from vllm.tokenizers.grok2 import Grok2Tokenizer from vllm.tokenizers.hf import HfTokenizer from vllm.tokenizers.mistral import MistralTokenizer @@ -24,8 +23,8 @@ def _assert_tokenizer_like(tokenizer: object): def test_tokenizer_like_protocol(): - tokenizer = get_tokenizer("gpt2", use_fast=True) - assert isinstance(tokenizer, PreTrainedTokenizerFast) + tokenizer = get_tokenizer("openai-community/gpt2", use_fast=True) + assert isinstance(tokenizer, TokenizersBackend) _assert_tokenizer_like(tokenizer) tokenizer = get_tokenizer( @@ -35,28 +34,18 @@ def test_tokenizer_like_protocol(): assert isinstance(tokenizer, MistralTokenizer) _assert_tokenizer_like(tokenizer) - tokenizer = get_tokenizer("xai-org/grok-2", tokenizer_mode="grok2") - assert isinstance(tokenizer, Grok2Tokenizer) - _assert_tokenizer_like(tokenizer) - tokenizer = get_tokenizer("deepseek-ai/DeepSeek-V3", tokenizer_mode="deepseek_v32") assert isinstance(tokenizer, HfTokenizer) # Verify it's a fast tokenizer (required for FastIncrementalDetokenizer) - assert isinstance(tokenizer, PreTrainedTokenizerFast) + assert isinstance(tokenizer, TokenizersBackend) assert "DSV32" in tokenizer.__class__.__name__ _assert_tokenizer_like(tokenizer) - tokenizer = get_tokenizer( - "Qwen/Qwen-VL", - tokenizer_mode="qwen_vl", - trust_remote_code=True, - ) - assert isinstance(tokenizer, HfTokenizer) - assert "WithoutImagePad" in tokenizer.__class__.__name__ - -@pytest.mark.parametrize("tokenizer_name", ["facebook/opt-125m", "gpt2"]) +@pytest.mark.parametrize( + "tokenizer_name", ["facebook/opt-125m", "openai-community/gpt2"] +) def test_tokenizer_revision(tokenizer_name: str): # Assume that "main" branch always exists tokenizer = get_tokenizer(tokenizer_name, revision="main") diff --git a/tests/tokenizers_/test_detokenize.py b/tests/tokenizers_/test_detokenize.py index 2f173bec80c0..23eaca9fc363 100644 --- a/tests/tokenizers_/test_detokenize.py +++ b/tests/tokenizers_/test_detokenize.py @@ -5,7 +5,7 @@ from typing import Any import pytest -from transformers import AutoTokenizer, PreTrainedTokenizer, PreTrainedTokenizerFast +from transformers import AutoTokenizer, PythonBackend, TokenizersBackend from vllm.sampling_params import SamplingParams from vllm.tokenizers.mistral import MistralTokenizer @@ -33,7 +33,7 @@ TOKENIZERS = [ "facebook/opt-125m", - "gpt2", + "openai-community/gpt2", "bigcode/tiny_starcoder_py", "EleutherAI/gpt-j-6b", "EleutherAI/pythia-70m", @@ -153,13 +153,13 @@ def test_decode_streaming( spaces_between_special_tokens, fast, ): - if fast and not isinstance(tokenizer, PreTrainedTokenizerFast): + if fast and not isinstance(tokenizer, TokenizersBackend): pytest.skip() if skip_special_tokens and not spaces_between_special_tokens: pytest.skip() - if not fast and isinstance(tokenizer, PreTrainedTokenizerFast): + if not fast and isinstance(tokenizer, TokenizersBackend): # Fix up inconsistency in fast/slow tokenizer behaviour. tokenizer.add_special_tokens( { @@ -173,7 +173,7 @@ def test_decode_streaming( extra_decode_args = ( {} - if not isinstance(tokenizer, PreTrainedTokenizer) + if not isinstance(tokenizer, PythonBackend) else {"spaces_between_special_tokens": spaces_between_special_tokens} ) @@ -225,7 +225,7 @@ def test_decode_streaming( @pytest.mark.parametrize("tokenizer_name", TOKENIZERS) @pytest.mark.parametrize("fast", (True, False)) def test_oov_decode(tokenizer, fast): - if fast and not isinstance(tokenizer, PreTrainedTokenizerFast): + if fast and not isinstance(tokenizer, TokenizersBackend): pytest.skip() decoded_text, out_ids = _run_incremental_decode( diff --git a/tests/tokenizers_/test_hf.py b/tests/tokenizers_/test_hf.py index c1238900ce0d..61c81302f071 100644 --- a/tests/tokenizers_/test_hf.py +++ b/tests/tokenizers_/test_hf.py @@ -7,10 +7,14 @@ from transformers import AutoTokenizer from vllm.tokenizers import TokenizerLike -from vllm.tokenizers.hf import get_cached_tokenizer +from vllm.tokenizers.hf import ( + ThreadSafeHFTokenizerMixin, + get_cached_tokenizer, + maybe_make_thread_pool, +) -@pytest.mark.parametrize("model_id", ["gpt2", "zai-org/chatglm3-6b"]) +@pytest.mark.parametrize("model_id", ["openai-community/gpt2", "zai-org/chatglm3-6b"]) def test_cached_tokenizer(model_id: str): reference_tokenizer = AutoTokenizer.from_pretrained( model_id, trust_remote_code=True @@ -41,3 +45,23 @@ def _check_consistency(target: TokenizerLike, expected: TokenizerLike): ) assert target.encode("prompt") == expected.encode("prompt") + + +@pytest.mark.parametrize("model_id", ["openai-community/gpt2"]) +def test_thread_pool_tokenizer_pickle(model_id: str): + """Regression test for issue #45433: the thread-pool tokenizer wrapper + reconstructs through maybe_make_thread_pool on unpickling, which used to + fall off the end and return None.""" + reference_tokenizer = AutoTokenizer.from_pretrained(model_id) + + pooled_tokenizer = maybe_make_thread_pool(deepcopy(reference_tokenizer)) + assert pooled_tokenizer is not None + assert isinstance(pooled_tokenizer, ThreadSafeHFTokenizerMixin) + + unpickled_tokenizer = pickle.loads(pickle.dumps(pooled_tokenizer)) + assert unpickled_tokenizer is not None + assert isinstance(unpickled_tokenizer, ThreadSafeHFTokenizerMixin) + assert unpickled_tokenizer.encode("prompt") == reference_tokenizer.encode("prompt") + + # Idempotence: wrapping an already-pooled tokenizer returns it unchanged. + assert maybe_make_thread_pool(pooled_tokenizer) is pooled_tokenizer diff --git a/tests/tokenizers_/test_mistral.py b/tests/tokenizers_/test_mistral.py index 2023337e8577..47abbd812898 100644 --- a/tests/tokenizers_/test_mistral.py +++ b/tests/tokenizers_/test_mistral.py @@ -797,11 +797,11 @@ def test_call(self, mistral_tokenizer: MistralTokenizer): True, ( [1, 3, 23325, 2294, 1686, 4, 23325], - [1, 3, 22177, 4304, 2662, 4, 22177, 2], + [1, 3, 22177, 4304, 2662, 4, 22177], ), ( "[INST]▁Hello▁world▁![/INST]▁Hello", - ("[INST]Hello world ![/INST]Hello"), + "[INST]Hello world ![/INST]Hello", ), ), ], diff --git a/tests/tokenizers_/test_registry.py b/tests/tokenizers_/test_registry.py index 546f38b078dd..9635e9963b5e 100644 --- a/tests/tokenizers_/test_registry.py +++ b/tests/tokenizers_/test_registry.py @@ -1,15 +1,24 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import json from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch import pytest +from transformers import AutoConfig +from transformers.models.auto.configuration_auto import CONFIG_MAPPING from vllm.tokenizers import TokenizerLike from vllm.tokenizers.registry import ( TokenizerRegistry, + cached_get_tokenizer, + cached_resolve_tokenizer_args, + cached_tokenizer_from_config, get_tokenizer, resolve_tokenizer_args, ) +from vllm.transformers_utils.configs.qwen3_5_moe import Qwen3_5MoeConfig class TestTokenizer(TokenizerLike): @@ -75,3 +84,58 @@ def test_customized_tokenizer(): assert tokenizer.bos_token_id == 0 assert tokenizer.eos_token_id == 1 assert tokenizer.pad_token_id == 2 + + +def test_cached_tokenizer_from_config_registers_local_config(tmp_path: Path): + (tmp_path / "config.json").write_text( + json.dumps({"model_type": "qwen3_5_moe"}), + encoding="utf-8", + ) + + model_config = SimpleNamespace( + skip_tokenizer_init=False, + tokenizer=str(tmp_path), + runner_type="generate", + tokenizer_mode="hf", + tokenizer_revision=None, + trust_remote_code=True, + hf_config=Qwen3_5MoeConfig(), + ) + + registered_config = CONFIG_MAPPING._extra_content.pop("qwen3_5_moe", None) + cached_get_tokenizer.cache_clear() + cached_resolve_tokenizer_args.cache_clear() + + try: + + def fake_from_pretrained(path_or_repo_id: str, *args, **kwargs): + loaded_config = AutoConfig.from_pretrained( + path_or_repo_id, + trust_remote_code=False, + ) + assert isinstance(loaded_config, Qwen3_5MoeConfig) + return SimpleNamespace(is_fast=True) + + with ( + patch( + "vllm.tokenizers.registry.logger.debug_once", + lambda *args, **kwargs: None, + ), + patch( + "vllm.tokenizers.hf.AutoTokenizer.from_pretrained", + side_effect=fake_from_pretrained, + ), + patch( + "vllm.tokenizers.hf.get_cached_tokenizer", + side_effect=lambda tokenizer: tokenizer, + ), + ): + tokenizer = cached_tokenizer_from_config(model_config) + + assert tokenizer.is_fast is True + finally: + cached_get_tokenizer.cache_clear() + cached_resolve_tokenizer_args.cache_clear() + CONFIG_MAPPING._extra_content.pop("qwen3_5_moe", None) + if registered_config is not None: + CONFIG_MAPPING._extra_content["qwen3_5_moe"] = registered_config diff --git a/tests/tool_parsers/conftest.py b/tests/tool_parsers/conftest.py index 89609b257c31..23e0eff98a24 100644 --- a/tests/tool_parsers/conftest.py +++ b/tests/tool_parsers/conftest.py @@ -9,4 +9,4 @@ @pytest.fixture(scope="module") def default_tokenizer() -> TokenizerLike: - return AutoTokenizer.from_pretrained("gpt2") + return AutoTokenizer.from_pretrained("openai-community/gpt2") diff --git a/tests/tool_parsers/test_deepseekv32_tool_parser.py b/tests/tool_parsers/test_deepseekv32_tool_parser.py index a35976b8bbf1..40ad6033d3bf 100644 --- a/tests/tool_parsers/test_deepseekv32_tool_parser.py +++ b/tests/tool_parsers/test_deepseekv32_tool_parser.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for DeepSeekV32ToolParser. +"""Unit tests for DeepSeekV32EngineToolParser. These tests use a minimal mock tokenizer so no real model weights are required. """ @@ -17,7 +17,11 @@ ChatCompletionToolsParam, FunctionDefinition, ) -from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser +from vllm.tool_parsers.deepseekv32_engine_tool_parser import ( + DeepSeekV32EngineToolParser, +) + +pytestmark = pytest.mark.skip_global_cleanup # --------------------------------------------------------------------------- # Helpers @@ -30,8 +34,8 @@ MOCK_TOKENIZER.tokenize.return_value = [] -def make_parser(tools=None) -> DeepSeekV32ToolParser: - return DeepSeekV32ToolParser(MOCK_TOKENIZER, tools=tools) +def make_parser(tools=None) -> DeepSeekV32EngineToolParser: + return DeepSeekV32EngineToolParser(MOCK_TOKENIZER, tools=tools) def make_tool_param(name: str, params: dict) -> MagicMock: @@ -167,9 +171,9 @@ def test_type_conversion_in_non_streaming(self): assert isinstance(args["enabled"], bool) assert isinstance(args["count"], int) - def test_string_attr_true_preserves_literal_despite_schema(self): - """string="true" must keep the value as a string even - if the schema says integer.""" + def test_string_attr_true_coerced_by_schema(self): + """string="true" delivers a string, but the engine's schema-aware + type fixer coerces it to the schema type (integer).""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="score", @@ -192,8 +196,8 @@ def test_string_attr_true_preserves_literal_despite_schema(self): result = parser.extract_tool_calls(model_output, None) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) - assert args == {"value": "42"} - assert isinstance(args["value"], str) + assert args == {"value": 42} + assert isinstance(args["value"], int) def test_string_attr_false_allows_schema_conversion(self): """string="false" allows the parser to convert via the tool schema.""" @@ -222,7 +226,6 @@ def test_string_attr_false_allows_schema_conversion(self): assert args == {"value": 42} assert isinstance(args["value"], int) - @pytest.mark.skip_global_cleanup def test_composed_schema_converts_object_and_array_params(self): """Composed JSON Schema types must still drive DSML type coercion.""" tool = ChatCompletionToolsParam( @@ -282,8 +285,9 @@ def test_composed_schema_converts_object_and_array_params(self): assert isinstance(args["wait"], dict) assert isinstance(args["patches"], list) - @pytest.mark.skip_global_cleanup - def test_string_attr_true_preserves_literal_for_composed_schema(self): + def test_string_attr_true_coerced_by_composed_schema(self): + """string="true" delivers a JSON string, but the engine's schema-aware + type fixer coerces it to the composed schema type (object).""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="set_timer", @@ -313,7 +317,7 @@ def test_string_attr_true_preserves_literal_for_composed_schema(self): result = parser.extract_tool_calls(model_output, None) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) - assert args == {"wait": '{"type":"for","minutes":2880}'} + assert args == {"wait": {"type": "for", "minutes": 2880}} def test_arguments_wrapper_repaired(self): """A single 'arguments' wrapper parameter must be unwrapped when it @@ -486,8 +490,9 @@ def test_multi_typed_null_value(self): args = json.loads(result.tool_calls[0].function.arguments) assert args["value"] is None - def test_null_not_coerced_without_null_in_schema(self): - """Literal 'null' must stay as a string when the schema is just 'string'.""" + def test_null_coerced_back_to_string_by_schema(self): + """string="false" with 'null' is json-parsed to None, but the + engine's schema fixer coerces it back to "null" for string schemas.""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="echo", @@ -512,8 +517,8 @@ def test_null_not_coerced_without_null_in_schema(self): assert args["text"] == "null" assert isinstance(args["text"], str) - def test_no_schema_keeps_strings(self): - """Without a tool schema, all string='false' params default to string.""" + def test_no_schema_parses_json(self): + """Without a tool schema, string='false' params are JSON-parsed.""" parser = make_parser(tools=None) model_output = ( f"{FC_START}\n" @@ -526,8 +531,8 @@ def test_no_schema_keeps_strings(self): result = parser.extract_tool_calls(model_output, None) assert result.tools_called args = json.loads(result.tool_calls[0].function.arguments) - assert args["count"] == "42" - assert args["flag"] == "true" + assert args["count"] == 42 + assert args["flag"] is True # --------------------------------------------------------------------------- @@ -648,8 +653,9 @@ def test_type_conversion_in_streaming(self): args_str = self._reconstruct_args(deltas) assert json.loads(args_str) == {"x": 3, "y": 4} - def test_string_attr_true_preserves_literal_in_streaming(self): - """Streaming: string='true' must keep the value literal despite schema.""" + def test_string_attr_true_coerced_by_schema_streaming(self): + """Streaming: string='true' delivers a string but the engine's + schema fixer coerces it to the schema type (integer).""" tool = ChatCompletionToolsParam( function=FunctionDefinition( name="score", @@ -672,10 +678,9 @@ def test_string_attr_true_preserves_literal_in_streaming(self): deltas = self._stream(parser, full_text) args_str = self._reconstruct_args(deltas) args = json.loads(args_str) - assert args == {"value": "42"} - assert isinstance(args["value"], str) + assert args == {"value": 42} + assert isinstance(args["value"], int) - @pytest.mark.skip_global_cleanup def test_composed_schema_conversion_in_streaming(self): tool = ChatCompletionToolsParam( function=FunctionDefinition( @@ -821,13 +826,13 @@ def test_multiple_tools_streaming(self, parser): assert json.loads(self._reconstruct_args(deltas, tool_index=0)) == {"p": "v1"} assert json.loads(self._reconstruct_args(deltas, tool_index=1)) == {"q": "v2"} - def test_state_reset_on_new_stream(self, parser): - """A second stream (previous_text == '') must reset state cleanly.""" + def test_state_reset_on_new_stream(self): + """A fresh parser instance must produce identical results.""" full_text = build_tool_call("fn", {"k": "v"}) # First stream - self._stream(parser, full_text) - # Second stream - should produce identical results - deltas2 = self._stream(parser, full_text) + self._stream(make_parser(), full_text) + # Second stream with fresh parser + deltas2 = self._stream(make_parser(), full_text) assert json.loads(self._reconstruct_args(deltas2)) == {"k": "v"} def test_empty_arguments_streaming(self, parser): @@ -860,26 +865,6 @@ def test_unique_tool_call_ids(self, parser): assert len(ids) == 2 assert ids[0] != ids[1] - def test_eos_after_tool_calls(self, parser): - """EOS token (empty delta_text, non-empty delta_token_ids) returns - a non-None DeltaMessage so the serving framework can finalize.""" - full_text = build_tool_call("fn", {"k": "v"}) - # Drive through the full text first - deltas = self._stream(parser, full_text) - assert any(d.tool_calls for d in deltas) - # Now simulate EOS: empty delta_text, but token ids present - prev = full_text - result = parser.extract_tool_calls_streaming( - previous_text=prev, - current_text=prev, - delta_text="", - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[2], # EOS token id - request=make_request(), - ) - assert result is not None - def test_streaming_matches_non_streaming(self, parser): """Streaming and non-streaming must produce the same result.""" full_text = build_tool_call( @@ -968,7 +953,6 @@ def test_multiple_tools_chunked(self, parser): def test_emits_arguments_before_invoke_completes(self, parser): """Argument deltas should stream before the invoke block closes.""" - # Stream only a partial invoke (no closing tag) partial_text = ( f"{FC_START}\n" f'{INV_START}fn">\n' @@ -981,7 +965,9 @@ def test_emits_arguments_before_invoke_completes(self, parser): for tc in delta.tool_calls or [] if tc.function and tc.function.arguments is not None ] - assert "".join(arg_chunks) == '{"k":"val"' + combined = "".join(arg_chunks) + assert combined # some partial args emitted + assert combined.startswith('{"k"') def test_no_marker_leak_chunked(self, parser): """Chunked streaming must NOT leak DSML start-marker fragments diff --git a/tests/tool_parsers/test_deepseekv4_tool_parser.py b/tests/tool_parsers/test_deepseekv4_tool_parser.py index ab66d6e64cd3..e7109626c0c2 100644 --- a/tests/tool_parsers/test_deepseekv4_tool_parser.py +++ b/tests/tool_parsers/test_deepseekv4_tool_parser.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for DeepSeekV4ToolParser.""" +"""Unit tests for DeepSeekV4EngineToolParser.""" import json from unittest.mock import MagicMock @@ -17,7 +17,11 @@ FunctionDefinition, ) from vllm.tool_parsers import ToolParserManager -from vllm.tool_parsers.deepseekv4_tool_parser import DeepSeekV4ToolParser +from vllm.tool_parsers.deepseekv4_engine_tool_parser import ( + DeepSeekV4EngineToolParser, +) + +pytestmark = pytest.mark.skip_global_cleanup MOCK_TOKENIZER = MagicMock() MOCK_TOKENIZER.get_vocab.return_value = {} @@ -67,8 +71,8 @@ def sample_tools() -> list[ChatCompletionToolsParam]: ] -def make_parser(tools=None) -> DeepSeekV4ToolParser: - return DeepSeekV4ToolParser(MOCK_TOKENIZER, tools=tools) +def make_parser(tools=None) -> DeepSeekV4EngineToolParser: + return DeepSeekV4EngineToolParser(MOCK_TOKENIZER, tools=tools) def make_request(tools=None) -> MagicMock: @@ -84,7 +88,7 @@ def build_tool_call(func_name: str, params: dict[str, str]) -> str: return f'{TC_START}\n{INV_START}{func_name}">\n{param_strs}{INV_END}\n{TC_END}' -def stream(parser: DeepSeekV4ToolParser, full_text: str, chunk_size: int = 7): +def stream(parser: DeepSeekV4EngineToolParser, full_text: str, chunk_size: int = 7): deltas = [] previous_text = "" for start in range(0, len(full_text), chunk_size): @@ -120,7 +124,9 @@ def reconstruct_args(deltas, tool_index: int = 0) -> str: def test_registered(): - assert ToolParserManager.get_tool_parser("deepseek_v4") is DeepSeekV4ToolParser + assert ( + ToolParserManager.get_tool_parser("deepseek_v4") is DeepSeekV4EngineToolParser + ) def test_extract_tool_calls(): @@ -216,14 +222,32 @@ def test_streaming_emits_incremental_argument_chunks(): } +def _with_strict( + tools: list[ChatCompletionToolsParam], +) -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + type=t.type, + function=FunctionDefinition( + name=t.function.name, + description=t.function.description, + parameters=t.function.parameters, + strict=True, + ), + ) + for t in tools + ] + + def test_get_vllm_registry_structural_tag_returns_structural_tag( sample_tools: list[ChatCompletionToolsParam], ) -> None: parser = make_parser() + strict_tools = _with_strict(sample_tools) req = ChatCompletionRequest( messages=[], model="m", - tools=sample_tools, + tools=strict_tools, tool_choice="auto", ) tag = parser.get_structural_tag(req) @@ -267,7 +291,7 @@ def test_extract_tool_calls_arguments_wrapper(): }, ) - parser = DeepSeekV4ToolParser(mock_tokenizer, tools=[tool]) + parser = DeepSeekV4EngineToolParser(mock_tokenizer, tools=[tool]) request = MagicMock() request.tools = [tool] @@ -285,7 +309,64 @@ def test_extract_tool_calls_arguments_wrapper(): assert args == {"location": "Beijing"} -@pytest.mark.skip_global_cleanup +_ANGLE_BRACKET_TOOL = ChatCompletionToolsParam( + function=FunctionDefinition( + name="run_command", + parameters={ + "type": "object", + "properties": { + "command": {"type": "string"}, + }, + }, + ), +) + + +@pytest.mark.parametrize( + "tools", + [[_ANGLE_BRACKET_TOOL], None], + ids=["with_tools", "without_tools"], +) +def test_no_dsml_closing_tag_leak_in_streamed_args(tools): + """Streaming must not leak into argument values. + + When a parameter value contains '>' (e.g. shell redirects like + '2>&1'), certain chunk boundaries cause the incremental lexer to + emit the closing delimiter text as part of the content token. The + partial regex then captures it as part of the value, violating the + prefix invariant and corrupting the streamed JSON. + """ + full_text = build_tool_call("run_command", {"command": "git --version 2>&1"}) + expected = {"command": "git --version 2>&1"} + + for chunk_size in range(1, len(full_text) + 1): + parser = make_parser(tools=tools) + deltas = stream(parser, full_text, chunk_size=chunk_size) + args_str = reconstruct_args(deltas) + assert args_str, f"No args emitted at chunk_size={chunk_size}" + assert "DSML" not in args_str, ( + f"DSML marker leaked into args at chunk_size={chunk_size}: {args_str!r}" + ) + parsed = json.loads(args_str) + assert parsed == expected, ( + f"Args mismatch at chunk_size={chunk_size}: " + f"got {parsed!r}, expected {expected!r}" + ) + + +def test_non_streaming_extract_with_angle_brackets(): + """Non-streaming extraction must correctly handle '>' in values.""" + parser = make_parser() + full_text = build_tool_call("run_command", {"command": "git --version 2>&1"}) + result = parser.extract_tool_calls(full_text, make_request()) + + assert result.tools_called + assert len(result.tool_calls) == 1 + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"command": "git --version 2>&1"} + assert "DSML" not in result.tool_calls[0].function.arguments + + def test_composed_schema_converts_object_and_array_params(): tool = ChatCompletionToolsParam( type="function", diff --git a/tests/tool_parsers/test_gemma4_tool_parser.py b/tests/tool_parsers/test_gemma4_tool_parser.py index 6f3709e19a45..8d74f0431934 100644 --- a/tests/tool_parsers/test_gemma4_tool_parser.py +++ b/tests/tool_parsers/test_gemma4_tool_parser.py @@ -8,31 +8,105 @@ import pytest from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest -from vllm.tool_parsers.gemma4_tool_parser import ( +from vllm.parser.gemma4 import ( TOOL_CALL_END, TOOL_CALL_START, - Gemma4ToolParser, _parse_gemma4_args, _parse_gemma4_array, ) +from vllm.tool_parsers.gemma4_engine_tool_parser import Gemma4EngineToolParser # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- +TOOL_CALL_START_ID = 48 +TOOL_CALL_END_ID = 49 +CHANNEL_START = "<|channel>" +CHANNEL_END = "" +CHANNEL_START_ID = 50 +CHANNEL_END_ID = 51 + + +def _make_tool(name, properties): + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + ) + + return ChatCompletionToolsParam( + type="function", + function={ + "name": name, + "parameters": {"type": "object", "properties": properties}, + }, + ) + + +_TOOLS = [ + _make_tool( + "set_status", + { + "is_active": {"type": "boolean"}, + "count": {"type": "integer"}, + "score": {"type": "number"}, + }, + ), + _make_tool( + "set_config", + { + "count": {"type": "integer"}, + "active": {"type": "boolean"}, + }, + ), + _make_tool( + "search", + { + "input": { + "type": "object", + "properties": {"all": {"type": "boolean"}}, + }, + }, + ), + _make_tool( + "set", + { + "flag": {"type": "boolean"}, + "count": {"type": "integer"}, + }, + ), + _make_tool( + "Edit", + { + "file_path": {"type": "string"}, + "old_string": {"type": "string"}, + "new_string": {"type": "string"}, + "replace_all": {"type": "boolean"}, + }, + ), +] + + @pytest.fixture def mock_tokenizer(): + vocab = { + TOOL_CALL_START: TOOL_CALL_START_ID, + TOOL_CALL_END: TOOL_CALL_END_ID, + CHANNEL_START: CHANNEL_START_ID, + CHANNEL_END: CHANNEL_END_ID, + } + decode_map = {v: k for k, v in vocab.items()} + tokenizer = MagicMock() tokenizer.encode.return_value = [1, 2, 3] - # Include the tool call start token in the vocab for the parser - tokenizer.get_vocab.return_value = {TOOL_CALL_START: 48, TOOL_CALL_END: 49} + tokenizer.get_vocab.return_value = vocab + tokenizer.decode.side_effect = lambda ids: decode_map.get(ids[0], f"tok{ids[0]}") return tokenizer @pytest.fixture def parser(mock_tokenizer): - return Gemma4ToolParser(mock_tokenizer) + return Gemma4EngineToolParser(mock_tokenizer, tools=_TOOLS) @pytest.fixture @@ -49,6 +123,9 @@ def mock_request(): class TestParseGemma4Args: + """Values are returned as strings; type coercion to proper JSON types + happens at the engine layer.""" + def test_empty_string(self): assert _parse_gemma4_args("") == {} @@ -71,27 +148,23 @@ def test_multiple_string_values(self): def test_integer_value(self): result = _parse_gemma4_args("count:42") - assert result == {"count": 42} + assert result == {"count": "42"} def test_float_value(self): result = _parse_gemma4_args("score:3.14") - assert result == {"score": 3.14} + assert result == {"score": "3.14"} def test_boolean_true(self): result = _parse_gemma4_args("flag:true") - assert result == {"flag": True} + assert result == {"flag": "true"} def test_boolean_false(self): result = _parse_gemma4_args("flag:false") - assert result == {"flag": False} + assert result == {"flag": "false"} def test_null_value(self): - # Bare `null` must parse as None (Python), not the string "null". - # Without this, tool_choice=auto would emit `{"param": "null"}` - # instead of `{"param": null}` for nullable tool parameters. result = _parse_gemma4_args("param:null") - assert result == {"param": None} - assert json.dumps(result) == '{"param": null}' + assert result == {"param": "null"} def test_mixed_types(self): result = _parse_gemma4_args( @@ -99,9 +172,9 @@ def test_mixed_types(self): ) assert result == { "name": "test", - "count": 42, - "active": True, - "score": 3.14, + "count": "42", + "active": "true", + "score": "3.14", } def test_nested_object(self): @@ -112,6 +185,17 @@ def test_array_of_strings(self): result = _parse_gemma4_args('items:[<|"|>a<|"|>,<|"|>b<|"|>]') assert result == {"items": ["a", "b"]} + def test_delimited_keys_stripped(self): + """Keys wrapped in <|"|> delimiters are stripped.""" + result = _parse_gemma4_args('<|"|>location<|"|>:<|"|>Paris<|"|>') + assert result == {"location": "Paris"} + + result = _parse_gemma4_args('outer:{<|"|>inner<|"|>:<|"|>val<|"|>}') + assert result == {"outer": {"inner": "val"}} + + result = _parse_gemma4_args('<|"|>name<|"|>:<|"|>Alice<|"|>,count:42') + assert result == {"name": "Alice", "count": "42"} + def test_unterminated_string(self): """Unterminated strings should take everything after the delimiter.""" result = _parse_gemma4_args('key:<|"|>unterminated') @@ -153,7 +237,7 @@ def test_trailing_dot_float_partial_withheld(self): # Non-partial mode parses trailing dot normally result = _parse_gemma4_args("left:108.,right:22.8", partial=False) - assert result == {"left": 108.0, "right": 22.8} + assert result == {"left": "108.", "right": "22.8"} @pytest.mark.timeout(5) def test_malformed_partial_array(self): @@ -172,7 +256,7 @@ def test_empty_array(self): def test_bare_values(self): result = _parse_gemma4_array("42,true,3.14") - assert result == [42, True, 3.14] + assert result == ["42", "true", "3.14"] @pytest.mark.timeout(5) def test_string_element_with_closing_bracket(self): @@ -182,7 +266,7 @@ def test_string_element_with_closing_bracket(self): @pytest.mark.timeout(5) def test_stray_closing_bracket(self): result = _parse_gemma4_array("42,]trailing") - assert result == [42] + assert result == ["42"] def test_trailing_dot_float_partial_withheld(self): """Array elements with trailing dot withheld in partial mode.""" @@ -191,7 +275,7 @@ def test_trailing_dot_float_partial_withheld(self): # Stable elements before trailing-dot element are kept result = _parse_gemma4_array("42,108.,3", partial=True) - assert result == [42] + assert result == ["42"] # --------------------------------------------------------------------------- @@ -297,9 +381,11 @@ def test_incomplete_tool_call(self, parser, mock_request): model_output = '<|tool_call>call:get_weather{location:<|"|>London' result = parser.extract_tool_calls(model_output, mock_request) - # Incomplete — no end marker, regex won't match - assert result.tools_called is False - assert result.content == model_output + assert result.tools_called is True + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].function.name == "get_weather" + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"location": "London"} def test_hyphenated_function_name(self, parser, mock_request): """Ensure function names with hyphens are parsed correctly.""" @@ -345,8 +431,15 @@ class TestStreamingExtraction: verifying that the accumulated argument deltas form valid JSON. """ + _SPECIAL_TOKEN_IDS = { + TOOL_CALL_START: TOOL_CALL_START_ID, + TOOL_CALL_END: TOOL_CALL_END_ID, + CHANNEL_START: CHANNEL_START_ID, + CHANNEL_END: CHANNEL_END_ID, + } + def _simulate_streaming( - self, parser: Gemma4ToolParser, mock_request: Any, chunks: list[str] + self, parser: Any, mock_request: Any, chunks: list[str] ) -> list[tuple[Any, str]]: """Feed chunks through the streaming parser and collect results. @@ -358,14 +451,17 @@ def _simulate_streaming( for chunk in chunks: current_text = previous_text + chunk - # Use token ID 48 for tool_call start, 49 for end, 0 otherwise - delta_token_ids: list[int] = [] - if TOOL_CALL_START in chunk: - delta_token_ids.append(48) - elif TOOL_CALL_END in chunk: - delta_token_ids.append(49) - else: - delta_token_ids.append(0) + found: list[tuple[int, int]] = [] + for token, tid in self._SPECIAL_TOKEN_IDS.items(): + pos = 0 + while True: + idx = chunk.find(token, pos) + if idx < 0: + break + found.append((idx, tid)) + pos = idx + len(token) + found.sort() + delta_token_ids: list[int] = [tid for _, tid in found] if found else [0] current_token_ids = previous_token_ids + delta_token_ids @@ -551,10 +647,10 @@ def test_streaming_numeric_args(self, parser, mock_request): results = self._simulate_streaming(parser, mock_request, chunks) args_text = self._collect_arguments(results) - if args_text: - parsed_args = json.loads(args_text) - assert parsed_args["count"] == 42 - assert parsed_args["active"] is True + assert args_text is not None + parsed_args = json.loads(args_text) + assert parsed_args["count"] == 42 + assert parsed_args["active"] is True def test_streaming_boolean_split_across_chunks(self, parser, mock_request): """Boolean value split across token boundaries must not corrupt JSON.""" @@ -643,23 +739,15 @@ def test_streaming_split_delimiter_no_invalid_json(self, parser, mock_request): ) def test_streaming_does_not_duplicate_plain_text_after_tool_call( - self, parser, mock_request, monkeypatch + self, parser, mock_request ): - """Buffered plain text after a tool call must not corrupt current_text.""" - captured_current_texts: list[str] = [] - original_extract_streaming = parser._extract_streaming - - def wrapped_extract_streaming(previous_text, current_text, delta_text): - captured_current_texts.append(current_text) - return original_extract_streaming(previous_text, current_text, delta_text) - - monkeypatch.setattr(parser, "_extract_streaming", wrapped_extract_streaming) - + """Buffered plain text after a tool call must not corrupt content.""" chunks = [ "<|tool_call>", "call:get_weather{", 'location:<|"|>Paris<|"|>}', - "<", + "", + "<", "div>", ] @@ -668,8 +756,7 @@ def wrapped_extract_streaming(previous_text, current_text, delta_text): delta.content for delta, _ in results if delta is not None and delta.content ] assert "".join(content_parts) == "
" - assert captured_current_texts[-1].endswith("
") - assert not captured_current_texts[-1].endswith("<
") + assert "<
" not in "".join(content_parts) def test_streaming_html_argument_does_not_duplicate_tag_prefixes( self, parser, mock_request @@ -702,6 +789,88 @@ def test_streaming_html_argument_does_not_duplicate_tag_prefixes( ' \n' ) + def _collect_tool_calls_by_index(self, results): + """Group streamed tool-call fragments by their ``index``. + + Returns ``{index: {"name": str | None, "arguments": str}}`` where + ``arguments`` is the concatenation of every streamed argument + fragment for that index (which should form valid JSON once complete). + """ + by_index: dict[int, dict[str, Any]] = {} + for delta, _ in results: + if not (delta and delta.tool_calls): + continue + for tc in delta.tool_calls: + entry = by_index.setdefault(tc.index, {"name": None, "arguments": ""}) + func = tc.function + if isinstance(func, dict): + name = func.get("name") + arg = func.get("arguments", "") + else: + name = getattr(func, "name", None) + arg = getattr(func, "arguments", "") or "" + if name: + entry["name"] = name + if arg: + entry["arguments"] += arg + return by_index + + def test_streaming_single_chunk_complete_tool_call(self, parser, mock_request): + """A backend may deliver a whole tool call in one streaming delta. + + The start token, ``call:name{...}`` payload and the end token all + arrive in a single chunk. The parser must still emit one + ``DeltaToolCall`` with the correct name + complete arguments JSON + (rather than swallowing it and finishing with finish_reason="stop"). + """ + chunks = [ + '<|tool_call>call:name_a_color{color_hex:<|"|>00ff11<|"|>}', + ] + + results = self._simulate_streaming(parser, mock_request, chunks) + + # Exactly one delta should carry tool_calls, and it must not be + # emitted as plain content (which would yield finish_reason="stop"). + tool_call_deltas = [ + delta for delta, _ in results if delta is not None and delta.tool_calls + ] + assert len(tool_call_deltas) == 1, ( + "Expected exactly one delta carrying the batched tool call" + ) + assert all( + delta.content is None for delta, _ in results if delta is not None + ), "Complete tool call must not leak as content" + + by_index = self._collect_tool_calls_by_index(results) + assert set(by_index) == {0} + assert by_index[0]["name"] == "name_a_color" + assert json.loads(by_index[0]["arguments"]) == {"color_hex": "00ff11"} + + def test_streaming_multi_chunk_batched_tool_calls(self, parser, mock_request): + """A single delta may batch MULTIPLE complete tool calls. + + ``<|tool_call>...<|tool_call>...`` arriving in + one chunk must emit BOTH calls (one DeltaToolCall each, with distinct + indices), not just the first. + """ + chunks = [ + '<|tool_call>call:get_weather{location:<|"|>London<|"|>}' + '<|tool_call>call:get_time{timezone:<|"|>GMT<|"|>}', + ] + + results = self._simulate_streaming(parser, mock_request, chunks) + + by_index = self._collect_tool_calls_by_index(results) + assert set(by_index) == {0, 1}, ( + f"Expected two tool calls (indices 0 and 1), got {sorted(by_index)}" + ) + + assert by_index[0]["name"] == "get_weather" + assert json.loads(by_index[0]["arguments"]) == {"location": "London"} + + assert by_index[1]["name"] == "get_time" + assert json.loads(by_index[1]["arguments"]) == {"timezone": "GMT"} + def test_streaming_trailing_bare_bool_not_duplicated(self, parser, mock_request): """Trailing bare boolean must not be streamed twice.""" chunks = [ diff --git a/tests/tool_parsers/test_gigachat3_tool_parser.py b/tests/tool_parsers/test_gigachat3_tool_parser.py index b00b410b2fa9..00a970951345 100644 --- a/tests/tool_parsers/test_gigachat3_tool_parser.py +++ b/tests/tool_parsers/test_gigachat3_tool_parser.py @@ -19,7 +19,7 @@ def default_tokenizer() -> TokenizerLike: """Override module-scoped default_tokenizer because gigachat tests mutate the tokenizer via ``add_tokens``.""" - return AutoTokenizer.from_pretrained("gpt2") + return AutoTokenizer.from_pretrained("openai-community/gpt2") MSG_SEP_TOKEN = "<|message_sep|>\n\n" diff --git a/tests/tool_parsers/test_glm47_moe_tool_parser.py b/tests/tool_parsers/test_glm47_moe_tool_parser.py index 51696c954788..224fea08d741 100644 --- a/tests/tool_parsers/test_glm47_moe_tool_parser.py +++ b/tests/tool_parsers/test_glm47_moe_tool_parser.py @@ -7,16 +7,20 @@ from unittest.mock import Mock import pytest +from openai.types.responses import ResponseFunctionToolCall from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ChatCompletionToolsParam, FunctionDefinition, ) +from vllm.entrypoints.openai.engine.protocol import FunctionCall +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.entrypoints.openai.responses.utils import build_response_output_items from vllm.tokenizers import get_tokenizer from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser -MODEL = "zai-org/GLM-4.5" +MODEL = "zai-org/GLM-4.7" @pytest.fixture(scope="module") @@ -58,7 +62,69 @@ def mock_request(sample_tools) -> ChatCompletionRequest: return request +@pytest.fixture +def namespace_tool_request() -> ResponsesRequest: + return ResponsesRequest.model_validate( + { + "input": "hi", + "tools": [ + { + "type": "namespace", + "name": "mcp__computer_use", + "description": "Computer use tools.", + "tools": [ + { + "type": "function", + "name": "get_app_state", + "description": "Get app state.", + "parameters": { + "type": "object", + "properties": { + "app": {"type": "string"}, + }, + }, + } + ], + } + ], + } + ) + + class TestGlm47ExtractToolCalls: + def test_namespace_tool_call_round_trip_to_responses_output( + self, glm47_tokenizer, namespace_tool_request + ): + parser = Glm47MoeModelToolParser( + glm47_tokenizer, tools=namespace_tool_request.tools + ) + out = ( + "mcp__computer_use__get_app_state" + "app" + "Google Chrome" + "" + ) + + result = parser.extract_tool_calls(out, request=namespace_tool_request) + + assert result.tools_called + tool_call = result.tool_calls[0].function + assert tool_call == FunctionCall( + name="mcp__computer_use__get_app_state", + arguments='{"app": "Google Chrome"}', + ) + + output_items = build_response_output_items( + reasoning=None, + content=None, + tool_calls=[tool_call], + tools=namespace_tool_request.tools, + ) + output_tool_call = output_items[0] + assert isinstance(output_tool_call, ResponseFunctionToolCall) + assert output_tool_call.name == "get_app_state" + assert output_tool_call.namespace == "mcp__computer_use" + def test_no_tool_call(self, glm47_tool_parser, mock_request): out = "This is a plain response." r = glm47_tool_parser.extract_tool_calls(out, request=mock_request) @@ -136,9 +202,10 @@ def test_no_args(self, glm47_tool_parser, mock_request): _reset(glm47_tool_parser) chunks = ["", "get_current_date", ""] current_text = "" + deltas = [] for chunk in chunks: current_text += chunk - glm47_tool_parser.extract_tool_calls_streaming( + delta = glm47_tool_parser.extract_tool_calls_streaming( previous_text="", current_text=current_text, delta_text=chunk, @@ -147,7 +214,23 @@ def test_no_args(self, glm47_tool_parser, mock_request): delta_token_ids=[], request=mock_request, ) - assert len(glm47_tool_parser.prev_tool_call_arr) >= 1 + if delta: + deltas.append(delta) + tool_calls = [ + tool_call for delta in deltas for tool_call in (delta.tool_calls or []) + ] + names = [ + tool_call.function.name + for tool_call in tool_calls + if tool_call.function and tool_call.function.name + ] + arguments = [ + tool_call.function.arguments + for tool_call in tool_calls + if tool_call.function and tool_call.function.arguments + ] + assert names == ["get_current_date"] + assert "".join(arguments) == "{}" def test_with_args(self, glm47_tool_parser, mock_request): _reset(glm47_tool_parser) @@ -161,9 +244,10 @@ def test_with_args(self, glm47_tool_parser, mock_request): "", ] current_text = "" + deltas = [] for chunk in chunks: current_text += chunk - glm47_tool_parser.extract_tool_calls_streaming( + delta = glm47_tool_parser.extract_tool_calls_streaming( previous_text="", current_text=current_text, delta_text=chunk, @@ -172,5 +256,13 @@ def test_with_args(self, glm47_tool_parser, mock_request): delta_token_ids=[], request=mock_request, ) - args = json.loads(glm47_tool_parser.prev_tool_call_arr[0]["arguments"]) + if delta: + deltas.append(delta) + arguments = [ + tool_call.function.arguments + for delta in deltas + for tool_call in (delta.tool_calls or []) + if tool_call.function and tool_call.function.arguments + ] + args = json.loads("".join(arguments)) assert args["city"] == "Beijing" diff --git a/tests/tool_parsers/test_glm4_moe_tool_parser.py b/tests/tool_parsers/test_glm4_moe_tool_parser.py index b0300297ddc4..ca110adac0d2 100644 --- a/tests/tool_parsers/test_glm4_moe_tool_parser.py +++ b/tests/tool_parsers/test_glm4_moe_tool_parser.py @@ -1,1067 +1,57 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Compatibility tests for GLM-4.5 using the shared GLM XML parser.""" import json -from unittest.mock import Mock - -import pytest -from openai.types.responses import FunctionTool +from typing import Any, TypedDict +from tests.parser.engine.replay_harness import MockTokenizer from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ChatCompletionToolsParam, FunctionDefinition, ) -from vllm.entrypoints.openai.engine.protocol import FunctionCall, ToolCall -from vllm.tokenizers import get_tokenizer -from vllm.tool_parsers.glm4_moe_tool_parser import ( - Glm4MoeModelToolParser, -) +from vllm.tool_parsers import ToolParserManager +from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser -# Use a common model that is likely to be available MODEL = "zai-org/GLM-4.5" +_GLM_VOCAB = { + "": 50, + "": 51, + "": 60, + "": 61, + "": 62, + "": 63, + "": 64, + "": 65, +} -@pytest.fixture(scope="module") -def glm4_moe_tokenizer(): - return get_tokenizer(tokenizer_name=MODEL) - - -@pytest.fixture -def sample_tools(): - return [ - ChatCompletionToolsParam( - function=FunctionDefinition( - name="get_weather", - parameters={"city": {"type": "string"}}, - ), - ), - ] - - -@pytest.fixture -def glm4_moe_tool_parser(glm4_moe_tokenizer, sample_tools): - return Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=sample_tools) - - -@pytest.fixture -def mock_request(sample_tools) -> ChatCompletionRequest: - request = Mock(spec=ChatCompletionRequest) - request.tools = sample_tools - return request - - -def assert_tool_calls( - actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall] -): - assert len(actual_tool_calls) == len(expected_tool_calls) - - for actual_tool_call, expected_tool_call in zip( - actual_tool_calls, expected_tool_calls - ): - assert isinstance(actual_tool_call.id, str) - assert len(actual_tool_call.id) > 0 - - assert actual_tool_call.type == "function" - assert actual_tool_call.function.name == expected_tool_call.function.name - # Compare arguments as JSON objects to handle formatting differences - actual_args = json.loads(actual_tool_call.function.arguments) - expected_args = json.loads(expected_tool_call.function.arguments) - assert actual_args == expected_args - - -def test_extract_tool_calls_no_tools(glm4_moe_tool_parser, mock_request): - model_output = "This is a test" - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - assert not extracted_tool_calls.tools_called - assert extracted_tool_calls.tool_calls == [] - assert extracted_tool_calls.content == model_output - - -@pytest.mark.parametrize( - ids=[ - "single_tool_call", - "multiple_tool_calls", - "tool_call_with_content_before", - "tool_call_with_mixed_args", - "tool_call_with_chinese_content", - ], - argnames=["model_output", "expected_tool_calls", "expected_content"], - argvalues=[ - ( - """get_current_weather - city - Dallas - state - TX - unit - fahrenheit - """, - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Dallas", - "state": "TX", - "unit": "fahrenheit", - } - ), - ) - ) - ], - None, - ), - ( - """get_current_weather - city - Dallas - state - TX - unit - fahrenheit - - get_current_weather - city - Orlando - state - FL - unit - fahrenheit - """, - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Dallas", - "state": "TX", - "unit": "fahrenheit", - } - ), - ) - ), - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Orlando", - "state": "FL", - "unit": "fahrenheit", - } - ), - ) - ), - ], - None, - ), - ( - """I'll help you check the weather. get_current_weather - city - Seattle - state - WA - unit - celsius - """, - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Seattle", - "state": "WA", - "unit": "celsius", - } - ), - ) - ) - ], - "I'll help you check the weather. ", - ), - ( - """get_current_weather - city - New York - state - NY - unit - celsius - """, - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "New York", - "state": "NY", - "unit": "celsius", - } - ), - ) - ) - ], - None, - ), - ( - """I will help you get the weather.get_weather - city - Beijing - date - 2025-08-01 - """, - [ - ToolCall( - function=FunctionCall( - name="get_weather", - arguments=json.dumps( - { - "city": "Beijing", - "date": "2025-08-01", - } - ), - ) - ) - ], - "I will help you get the weather.", - ), - ], -) -def test_extract_tool_calls( - glm4_moe_tool_parser, - mock_request, - model_output, - expected_tool_calls, - expected_content, -): - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - assert extracted_tool_calls.tools_called - assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls) - - assert extracted_tool_calls.content == expected_content - - -def test_extract_tool_calls_with_thinking_tags(glm4_moe_tool_parser, mock_request): - """Test tool extraction when thinking tags are present.""" - model_output = """I want to get the weather. - -I will help you get the weather. -get_weather -city -Beijing -date -2025-08-01 -""" - - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - assert len(extracted_tool_calls.tool_calls) == 1 - assert extracted_tool_calls.tool_calls[0].function.name == "get_weather" - - expected_content = """I want to get the weather. - -I will help you get the weather. -""" - assert extracted_tool_calls.content == expected_content - - -def test_extract_tool_calls_malformed_xml(glm4_moe_tool_parser, mock_request): - """Test that malformed XML is handled gracefully.""" - model_output = """get_weather -city -Seattle -incomplete_arg -value -""" - - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - # Should handle malformed XML gracefully - # The parser should either extract what it can or return no tool calls - # depending on how robust we want the parsing to be - assert isinstance(extracted_tool_calls.tools_called, bool) - assert isinstance(extracted_tool_calls.tool_calls, list) - - -def test_extract_tool_calls_empty_arguments(glm4_moe_tool_parser, mock_request): - """Test tool calls with no arguments.""" - model_output = """get_current_time -""" - - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - assert len(extracted_tool_calls.tool_calls) == 1 - assert extracted_tool_calls.tool_calls[0].function.name == "get_current_time" - # Empty arguments should result in empty JSON object - assert extracted_tool_calls.tool_calls[0].function.arguments == "{}" - - -def test_extract_tool_calls_mixed_content(glm4_moe_tool_parser, mock_request): - """Test extraction with mixed content and multiple tool calls.""" - model_output = """I will help you get the weather info. - -get_weather -city -Beijing -date -2025-08-01 - - -meaningwhile, I will also check the weather in Shanghai. - -get_weather -city -Shanghai -date -2025-08-01 -""" - - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - assert len(extracted_tool_calls.tool_calls) == 2 - - # Check first tool call - assert extracted_tool_calls.tool_calls[0].function.name == "get_weather" - args1 = json.loads(extracted_tool_calls.tool_calls[0].function.arguments) - assert args1["city"] == "Beijing" - assert args1["date"] == "2025-08-01" - - # Check second tool call - assert extracted_tool_calls.tool_calls[1].function.name == "get_weather" - args2 = json.loads(extracted_tool_calls.tool_calls[1].function.arguments) - assert args2["city"] == "Shanghai" - assert args2["date"] == "2025-08-01" - - # Content should be everything before the first tool call - assert extracted_tool_calls.content == "I will help you get the weather info.\n\n" - - -def test_streaming_basic_functionality(glm4_moe_tool_parser, mock_request): - """Test basic streaming functionality.""" - _reset_streaming_state(glm4_moe_tool_parser) - - current_text = """get_weather -city -Beijing -""" - - result = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=current_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # Should return tool call with name and arguments in one shot - assert result is not None - assert result.tool_calls is not None - assert len(result.tool_calls) >= 1 - - -def test_streaming_no_tool_calls(glm4_moe_tool_parser, mock_request): - """Test streaming when there are no tool calls.""" - _reset_streaming_state(glm4_moe_tool_parser) - - current_text = "This is just regular text without any tool calls." - - result = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=current_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # Should return content - assert result is not None - assert result.content == current_text - - -def test_streaming_with_content_before_tool_calls(glm4_moe_tool_parser, mock_request): - """Test streaming when there's content before tool calls.""" - _reset_streaming_state(glm4_moe_tool_parser) - - current_text = "I will help you get the weather." - - result = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=current_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # Should return content before the tag - assert result is not None - assert result.content == "I will help you get the weather." - - -def test_extract_tool_calls_special_characters(glm4_moe_tool_parser, mock_request): - """Test tool calls with special characters and unicode.""" - model_output = """send_message -recipient -Amy -message -It is a nice day -priority -high -""" - - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - assert len(extracted_tool_calls.tool_calls) == 1 - assert extracted_tool_calls.tool_calls[0].function.name == "send_message" - - args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments) - assert args["recipient"] == "Amy" - assert args["message"] == "It is a nice day" - assert args["priority"] == "high" - - -def test_extract_tool_calls_incomplete_tool_call(glm4_moe_tool_parser, mock_request): - """Test incomplete tool calls (missing closing tag).""" - model_output = """get_weather -city -Beijing -date -2025-08-01""" - - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - # Incomplete tool calls should not be extracted - assert not extracted_tool_calls.tools_called - assert extracted_tool_calls.tool_calls == [] - assert extracted_tool_calls.content == model_output - - -def _reset_streaming_state(parser): - """Helper to reset parser streaming state.""" - parser.current_tool_name_sent = False - parser.prev_tool_call_arr = [] - parser.current_tool_id = -1 - parser.streamed_args_for_tool = [] - parser._tool_call_ids = [] - parser._sent_content_idx = 0 - - -def test_streaming_incremental_string_value(glm4_moe_tool_parser, mock_request): - """Test incremental streaming of string argument values.""" - _reset_streaming_state(glm4_moe_tool_parser) - - # Simulate streaming a tool call chunk by chunk - chunks = [ - "", - "get_weather\n", - "city", - "", - "Bei", - "jing", - "", - "", - ] - - collected_fragments = [] - current_text = "" - for chunk in chunks: - current_text += chunk - result = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - if result is not None and result.tool_calls: - for tc in result.tool_calls: - func = tc.function - if isinstance(func, dict): - if func.get("arguments"): - collected_fragments.append(func["arguments"]) - if func.get("name"): - collected_fragments.append(f"name:{func['name']}") - else: - if func.arguments: - collected_fragments.append(func.arguments) - if func.name: - collected_fragments.append(f"name:{func.name}") - - # Verify we got incremental streaming of the argument value - assert len(collected_fragments) > 0 - # The fragments should include the tool name and argument pieces - combined = "".join(collected_fragments) - assert "get_weather" in combined or "name:get_weather" in combined - - -def test_streaming_empty_tool_call(glm4_moe_tool_parser, mock_request): - """Test that empty tool calls don't cause infinite loops.""" - _reset_streaming_state(glm4_moe_tool_parser) - - current_text = "" - result = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=current_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # Should not hang and should return something (None or content) - # The key is that this completes without hanging - assert result is None or hasattr(result, "content") or hasattr(result, "tool_calls") - - -def test_streaming_prev_tool_call_arr_updates(glm4_moe_tool_parser, mock_request): - """Test that prev_tool_call_arr is populated incrementally.""" - _reset_streaming_state(glm4_moe_tool_parser) - - chunks = [ - "get_weather\n", - "city", - "Beijing", - "", - ] - - current_text = "" - for chunk in chunks: - current_text += chunk - glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # After the tool call completes, prev_tool_call_arr should be populated - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 1 - tool_entry = glm4_moe_tool_parser.prev_tool_call_arr[0] - assert tool_entry.get("name") == "get_weather" - - # arguments is a JSON string in the re-parse approach - args_str = tool_entry.get("arguments") - assert isinstance(args_str, str), f"Expected str, got {type(args_str)}" - parsed = json.loads(args_str) - assert parsed["city"] == "Beijing" - - # streamed_args_for_tool should match prev_tool_call_arr arguments - streamed = glm4_moe_tool_parser.streamed_args_for_tool[0] - assert streamed == args_str - - -def test_streaming_multiple_tool_calls_sequential(glm4_moe_tool_parser, mock_request): - """Test streaming multiple sequential tool calls.""" - _reset_streaming_state(glm4_moe_tool_parser) - - chunks = [ - "get_weather\n", - "city", - "Beijing", - "", - "get_weather\n", - "city", - "Shanghai", - "", - ] - - current_text = "" - for chunk in chunks: - current_text += chunk - glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # Should have two tool calls in prev_tool_call_arr - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 2 - args0 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[0]["arguments"]) - args1 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[1]["arguments"]) - assert args0["city"] == "Beijing" - assert args1["city"] == "Shanghai" - - -def test_streaming_json_escape_in_string(glm4_moe_tool_parser, mock_request): - """Test that special characters in string values are properly escaped.""" - _reset_streaming_state(glm4_moe_tool_parser) - - chunks = [ - "send_message\n", - "message", - 'Hello "world"\nNew line', - "", - ] - - current_text = "" - for chunk in chunks: - current_text += chunk - glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # The streamed_args_for_tool should contain valid JSON - assert len(glm4_moe_tool_parser.streamed_args_for_tool) == 1 - args_json = glm4_moe_tool_parser.streamed_args_for_tool[0] - parsed = json.loads(args_json) - assert "message" in parsed - assert '"' in parsed["message"] or "world" in parsed["message"] - - -def test_streaming_long_content_incremental(glm4_moe_tokenizer): - """Test incremental streaming of long content (Issue #32829). - - This is the core fix: for long string values like code (4000+ chars), - the parser should stream incrementally rather than buffering until - complete. This test verifies we get many fragments, not just 1-3. - """ - - # Bubble sort example from Issue #32829 - realistic long content - bubble_sort_code = '''#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Bubble Sort Implementation -""" - -def bubble_sort(arr): - n = len(arr) - for i in range(n): - swapped = False - for j in range(0, n - i - 1): - if arr[j] > arr[j + 1]: - arr[j], arr[j + 1] = arr[j + 1], arr[j] - swapped = True - if not swapped: - break - return arr - -if __name__ == "__main__": - test_arr = [64, 34, 25, 12, 22, 11, 90] - print(f"Original: {test_arr}") - sorted_arr = bubble_sort(test_arr.copy()) - print(f"Sorted: {sorted_arr}")''' - - # Create tools with schema to enable string type detection - # This is required for incremental streaming of string values - tools = [ - ChatCompletionToolsParam( - function=FunctionDefinition( - name="write_to_file", - parameters={ - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "content": {"type": "string"}, - }, - }, - ), - ), - ] - glm4_moe_tool_parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest( - model=MODEL, - messages=[], - tools=tools, - ) - - # Simulate token-based streaming (special tags as single tokens) - chunks = [ - "", - "write_to_file\n", - "file_path", - "/tmp/bubble_sort.py", - "content", - "", - ] - # Add content line by line (realistic token streaming) - for line in bubble_sort_code.split("\n"): - chunks.append(line + "\n") - chunks.append("") - chunks.append("") - - # Count argument fragments - fragment_count = 0 - current_text = "" - for chunk in chunks: - current_text += chunk - result = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=request, - ) - if result is not None and result.tool_calls: - for tc in result.tool_calls: - func = tc.function - if isinstance(func, dict): - args = func.get("arguments") - else: - args = getattr(func, "arguments", None) - if args: - fragment_count += 1 - - # For true incremental streaming, we expect many fragments (10+) - # Old buffered implementation would give only 1-3 fragments - assert fragment_count >= 10, ( - f"Expected >=10 fragments for incremental streaming, got {fragment_count}" - ) - - # Verify final result is valid JSON - assert len(glm4_moe_tool_parser.streamed_args_for_tool) == 1 - args_json = glm4_moe_tool_parser.streamed_args_for_tool[0] - parsed = json.loads(args_json) - assert parsed["file_path"] == "/tmp/bubble_sort.py" - assert "def bubble_sort" in parsed["content"] - - -def test_extract_tool_calls_numeric_deserialization(glm4_moe_tool_parser, mock_request): - """Test that numeric arguments are deserialized as numbers, not strings.""" - model_output = """calculate -operation -add -a -42 -b -3.14 -enabled -true -""" - - extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - assert len(extracted_tool_calls.tool_calls) == 1 - - args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments) - # String should remain string - assert args["operation"] == "add" - assert isinstance(args["operation"], str) +class _CollectedToolDelta(TypedDict): + name: str | None + args_fragments: list[str] - # Integer should be deserialized as int - assert args["a"] == 42 - assert isinstance(args["a"], int) - # Float should be deserialized as float - assert args["b"] == 3.14 - assert isinstance(args["b"], float) +def _mock_tokenizer() -> MockTokenizer: + return MockTokenizer(vocab=_GLM_VOCAB, tokens=[]) - # Boolean should be deserialized as bool - assert args["enabled"] is True - assert isinstance(args["enabled"], bool) - -def test_whitespace_preserved_in_arg_values(glm4_moe_tokenizer): - """Test that string arguments preserve leading and trailing whitespace.""" - tools = [ +def _tools() -> list[ChatCompletionToolsParam]: + return [ ChatCompletionToolsParam( function=FunctionDefinition( - name="apply_diff", + name="get_current_weather", parameters={ "type": "object", "properties": { - "s": {"type": "string"}, + "city": {"type": "string"}, + "state": {"type": "string"}, + "unit": {"type": "string"}, }, - "required": ["s"], }, ), ), - ] - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - - model_output = """apply_diff -s - indented code -""" - - extracted_tool_calls = parser.extract_tool_calls(model_output, request=request) - args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments) - - assert args["s"] == " indented code " - - -def test_zero_argument_tool_call(glm4_moe_tool_parser, mock_request): - """Regression: zero-argument tool call crash (PR #32321).""" - model_output = """get_time -""" - - extracted = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted.tools_called - assert len(extracted.tool_calls) == 1 - assert extracted.tool_calls[0].function.name == "get_time" - args = json.loads(extracted.tool_calls[0].function.arguments) - assert args == {} - - -def test_malformed_tool_call_no_regex_match(glm4_moe_tool_parser, mock_request): - """Regression: malformed tool_call with no regex match (PR #32321).""" - model_output = " " - - extracted = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted.tools_called is False - assert extracted.tool_calls == [] - - -def test_delimiter_preserved_transformers_5x(glm4_moe_tool_parser): - """Regression: adjust_request sets skip_special_tokens=False (PR #31622).""" - # Tools enabled - request_with_tools = ChatCompletionRequest( - model=MODEL, - messages=[], - tools=[ - { - "type": "function", - "function": { - "name": "get_weather", - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - }, - } - ], - ) # type: ignore - adjusted = glm4_moe_tool_parser.adjust_request(request_with_tools) - assert adjusted.skip_special_tokens is False - - # tool_choice="none" - request_no_choice = ChatCompletionRequest( - model=MODEL, - messages=[], - tools=[ - { - "type": "function", - "function": { - "name": "get_weather", - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - }, - } - ], - tool_choice="none", - ) # type: ignore - adjusted_none = glm4_moe_tool_parser.adjust_request(request_no_choice) - assert adjusted_none.skip_special_tokens is True - - # No tools at all - request_no_tools = ChatCompletionRequest( - model=MODEL, - messages=[], - ) # type: ignore - adjusted_empty = glm4_moe_tool_parser.adjust_request(request_no_tools) - assert adjusted_empty.skip_special_tokens is True - - -def test_unicode_characters_preserved(glm4_moe_tool_parser, mock_request): - """Regression: Unicode chars must not be escaped to \\uXXXX (PR #30920).""" - model_output = """send_message -greeting -你好世界 -emoji -🎉 -""" - - extracted = glm4_moe_tool_parser.extract_tool_calls( - model_output, request=mock_request - ) # type: ignore[arg-type] - - assert extracted.tools_called - assert len(extracted.tool_calls) == 1 - - raw_args = extracted.tool_calls[0].function.arguments - assert "你好世界" in raw_args - assert "🎉" in raw_args - assert "\\u4f60" not in raw_args - parsed_args = json.loads(raw_args) - assert parsed_args["greeting"] == "你好世界" - assert parsed_args["emoji"] == "🎉" - - -def test_streaming_multi_token_chunks(glm4_moe_tool_parser, mock_request): - """Test that multi-token chunks (stream_interval > 1) are handled correctly. - - With stream_interval > 1 or MTP, multiple XML tags arrive in one delta. - The old buffer-based parser could only return one delta per call, losing - data on the final output. The re-parse approach handles this correctly. - """ - _reset_streaming_state(glm4_moe_tool_parser) - - # Simulate stream_interval=3: chunks contain multiple XML tags - chunks = [ - "get_weather\ncityBei", - "jing", - "", - ] - - current_text = "" - for chunk in chunks: - current_text += chunk - glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # All data should be captured despite multi-token chunks - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 1 - args = json.loads(glm4_moe_tool_parser.streamed_args_for_tool[0]) - assert args["city"] == "Beijing" - - -def test_streaming_entire_tool_call_at_once(glm4_moe_tool_parser, mock_request): - """Test that a complete tool call arriving in one delta works. - - This simulates the extreme MTP case where all tokens arrive at once. - """ - _reset_streaming_state(glm4_moe_tool_parser) - - full_text = ( - "get_weather\n" - "city" - "Beijing" - "" - ) - - result = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=full_text, - delta_text=full_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # Should emit tool call with complete arguments in one shot - assert result is not None - assert result.tool_calls is not None - - # Verify final state - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 1 - args = json.loads(glm4_moe_tool_parser.streamed_args_for_tool[0]) - assert args["city"] == "Beijing" - - -def test_streaming_content_between_tool_calls_multi_token( - glm4_moe_tool_parser, mock_request -): - """Test content between tool calls with multi-token chunks.""" - _reset_streaming_state(glm4_moe_tool_parser) - - # Deliver everything at once — worst case for the old buffer parser - full_text = ( - "I will check.\n" - "get_weather\n" - "city" - "Beijing" - "" - "\nAlso Shanghai.\n" - "get_weather\n" - "city" - "Shanghai" - "" - ) - - # First call with partial text (content only) - partial = "I will check.\n" - result1 = glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=partial, - delta_text=partial, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - assert result1 is not None - assert result1.content == "I will check.\n" - - # Second call with everything - glm4_moe_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=full_text, - delta_text=full_text[len(partial) :], - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request, - ) - - # Should have both tool calls - assert len(glm4_moe_tool_parser.prev_tool_call_arr) == 2 - args0 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[0]["arguments"]) - args1 = json.loads(glm4_moe_tool_parser.prev_tool_call_arr[1]["arguments"]) - assert args0["city"] == "Beijing" - assert args1["city"] == "Shanghai" - - -def test_streaming_multi_token_with_multiple_args(glm4_moe_tokenizer): - """Test multi-token streaming with multiple arguments of mixed types.""" - tools = [ ChatCompletionToolsParam( function=FunctionDefinition( name="calculate", @@ -1071,415 +61,168 @@ def test_streaming_multi_token_with_multiple_args(glm4_moe_tokenizer): "operation": {"type": "string"}, "a": {"type": "number"}, "b": {"type": "number"}, + "enabled": {"type": "boolean"}, }, }, ), ), - ] - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest( - model=MODEL, - messages=[], - tools=tools, - ) - - # All arguments arrive in two big chunks (simulates stream_interval=5) - chunks = [ - "calculate\noperationadda", - "42b3.14", - ] - - current_text = "" - for chunk in chunks: - current_text += chunk - parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=request, - ) - - args = json.loads(parser.streamed_args_for_tool[0]) - assert args["operation"] == "add" - assert args["a"] == 42 - assert args["b"] == 3.14 - - -def _simulate_streaming(tokenizer, parser, request, text, stream_interval=1): - """Simulate streaming with a given stream_interval. - - Tokens are batched into chunks of ``stream_interval`` tokens, - mimicking how the output processor delivers them. - Returns a list of non-None DeltaMessages. - """ - tokens = tokenizer.encode(text) - previous_text = "" - deltas = [] - for i in range(0, len(tokens), stream_interval): - chunk_ids = tokens[i : i + stream_interval] - delta_text = tokenizer.decode(chunk_ids) - current_text = previous_text + delta_text - delta = parser.extract_tool_calls_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=chunk_ids, - request=request, - ) - previous_text = current_text - if delta is not None: - deltas.append(delta) - return deltas - - -def _collect_from_deltas(deltas): - """Reconstruct tool call names/args and content from a delta stream.""" - tools: dict[int, dict] = {} - content_parts: list[str] = [] - for d in deltas: - if d.content: - content_parts.append(d.content) - if d.tool_calls: - for tc in d.tool_calls: - func = tc.function - if isinstance(func, dict): - name = func.get("name") - args = func.get("arguments") - else: - name = getattr(func, "name", None) - args = getattr(func, "arguments", None) - idx = tc.index - if idx not in tools: - tools[idx] = {"name": None, "args_fragments": []} - if name: - tools[idx]["name"] = name - if args: - tools[idx]["args_fragments"].append(args) - return content_parts, tools - - -@pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8]) -def test_stream_interval_single_tool_call(glm4_moe_tokenizer, stream_interval): - """Tool call streaming produces correct name + args at any interval.""" - tools = [ ChatCompletionToolsParam( - function=FunctionDefinition( - name="get_weather", - parameters={ - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - ), - ), - ] - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - - text = ( - "get_weather\n" - "city" - "Beijing" - "" - ) - - deltas = _simulate_streaming( - glm4_moe_tokenizer, parser, request, text, stream_interval - ) - _, tools_found = _collect_from_deltas(deltas) - - assert 0 in tools_found - assert tools_found[0]["name"] == "get_weather" - args_json = "".join(tools_found[0]["args_fragments"]) - parsed = json.loads(args_json) - assert parsed == {"city": "Beijing"} - - -@pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8]) -def test_stream_interval_multiple_tool_calls(glm4_moe_tokenizer, stream_interval): - """Multiple sequential tool calls with correct indices at any interval.""" - tools = [ - ChatCompletionToolsParam( - function=FunctionDefinition( - name="get_weather", - parameters={ - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - ), - ), - ] - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - - text = ( - "get_weather\n" - "city" - "Beijing" - "" - "get_weather\n" - "city" - "Shanghai" - "" - ) - - deltas = _simulate_streaming( - glm4_moe_tokenizer, parser, request, text, stream_interval - ) - _, tools_found = _collect_from_deltas(deltas) - - assert 0 in tools_found and 1 in tools_found - args0 = json.loads("".join(tools_found[0]["args_fragments"])) - args1 = json.loads("".join(tools_found[1]["args_fragments"])) - assert args0 == {"city": "Beijing"} - assert args1 == {"city": "Shanghai"} - - -@pytest.mark.parametrize("stream_interval", [1, 2, 3, 5, 8]) -def test_stream_interval_content_then_tool_call(glm4_moe_tokenizer, stream_interval): - """Content before a tool call is fully emitted before tool deltas.""" - tools = [ - ChatCompletionToolsParam( - function=FunctionDefinition( - name="get_weather", - parameters={ - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - ), + function=FunctionDefinition(name="get_time", parameters={}), ), ] - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - - text = ( - "I will check the weather for you.\n" - "get_weather\n" - "city" - "Beijing" - "" - ) - deltas = _simulate_streaming( - glm4_moe_tokenizer, parser, request, text, stream_interval - ) - content_parts, tools_found = _collect_from_deltas(deltas) - # Content must be present and precede tool calls - full_content = "".join(content_parts) - assert "I will check the weather" in full_content +def _request(tools: list[ChatCompletionToolsParam]) -> ChatCompletionRequest: + return ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - # Tool call must be correct - assert 0 in tools_found - assert tools_found[0]["name"] == "get_weather" - args = json.loads("".join(tools_found[0]["args_fragments"])) - assert args == {"city": "Beijing"} +def _parser(tools: list[ChatCompletionToolsParam] | None = None): + return Glm47MoeModelToolParser(_mock_tokenizer(), tools=tools) -def test_stream_interval_extreme_single_chunk(glm4_moe_tokenizer): - """Extreme MTP: entire output arrives in one chunk (interval=9999).""" - tools = [ - ChatCompletionToolsParam( - function=FunctionDefinition( - name="get_weather", - parameters={ - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - ), - ), - ] - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - text = ( - "Here is the weather.\n" - "get_weather\n" - "city" - "Beijing" - "" - ) +def _collect_tool_deltas(deltas: Any) -> dict[int, _CollectedToolDelta]: + calls: dict[int, _CollectedToolDelta] = {} + for delta in deltas: + if delta is None or not delta.tool_calls: + continue + for tool_call in delta.tool_calls: + entry = calls.setdefault( + tool_call.index, + {"name": None, "args_fragments": []}, + ) + function = tool_call.function + if function is None: + continue + if isinstance(function, dict): + name = function.get("name") + arguments = function.get("arguments") + else: + name = function.name + arguments = function.arguments + if isinstance(name, str) and name: + entry["name"] = name + if isinstance(arguments, str) and arguments: + entry["args_fragments"].append(arguments) + return calls - deltas = _simulate_streaming( - glm4_moe_tokenizer, parser, request, text, stream_interval=9999 - ) - content_parts, tools_found = _collect_from_deltas(deltas) - assert "Here is the weather" in "".join(content_parts) - assert 0 in tools_found - assert tools_found[0]["name"] == "get_weather" - args = json.loads("".join(tools_found[0]["args_fragments"])) - assert args == {"city": "Beijing"} +def test_glm45_uses_shared_glm47_parser(): + assert ToolParserManager.get_tool_parser("glm45") is Glm47MoeModelToolParser + assert ToolParserManager.get_tool_parser("glm47") is Glm47MoeModelToolParser -@pytest.mark.parametrize("stream_interval", [1, 2, 5]) -def test_stream_interval_content_between_tool_calls( - glm4_moe_tokenizer, stream_interval -): - """Content between tool calls must be emitted, not silently dropped.""" - tools = [ - ChatCompletionToolsParam( - function=FunctionDefinition( - name="get_weather", - parameters={ - "type": "object", - "properties": {"city": {"type": "string"}}, - }, - ), - ), - ] - parser = Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=tools) - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - - text = ( - "Checking Beijing.\n" - "get_weather\n" - "city" - "Beijing" - "" - "\nAlso Shanghai.\n" - "get_weather\n" - "city" - "Shanghai" - "" - ) - - deltas = _simulate_streaming( - glm4_moe_tokenizer, parser, request, text, stream_interval - ) - content_parts, tools_found = _collect_from_deltas(deltas) - - full_content = "".join(content_parts) - # Both prefix and inter-tool-call content must appear - assert "Checking Beijing" in full_content - assert "Also Shanghai" in full_content - - # Both tool calls must be correct - assert 0 in tools_found and 1 in tools_found - args0 = json.loads("".join(tools_found[0]["args_fragments"])) - args1 = json.loads("".join(tools_found[1]["args_fragments"])) - assert args0 == {"city": "Beijing"} - assert args1 == {"city": "Shanghai"} - - -# ── FunctionTool (Responses API) tests ────────────────────────────── - - -@pytest.fixture -def function_tools(): - return [ - FunctionTool( - type="function", - name="get_weather", - parameters={ - "type": "object", - "properties": { - "city": {"type": "string"}, - "unit": {"type": "string"}, - }, - }, - ), - FunctionTool( - type="function", - name="calculate", - parameters={ - "type": "object", - "properties": { - "operation": {"type": "string"}, - "a": {"type": "number"}, - "b": {"type": "number"}, - }, - }, - ), - ] - - -@pytest.fixture -def glm4_moe_parser_function_tools(glm4_moe_tokenizer, function_tools): - return Glm4MoeModelToolParser(glm4_moe_tokenizer, tools=function_tools) - - -@pytest.fixture -def mock_request_function_tools(function_tools) -> ChatCompletionRequest: - request = Mock(spec=ChatCompletionRequest) - request.tools = function_tools - return request - - -def test_extract_tool_calls_with_function_tool( - glm4_moe_parser_function_tools, mock_request_function_tools -): - model_output = """get_weather +def test_extract_tool_calls_with_glm45_newline_format(): + tools = _tools() + parser = _parser(tools) + model_output = """I'll check it. get_current_weather city Dallas +state +TX unit fahrenheit """ - extracted = glm4_moe_parser_function_tools.extract_tool_calls( - model_output, request=mock_request_function_tools - ) + extracted = parser.extract_tool_calls(model_output, request=_request(tools)) + assert extracted.tools_called + assert extracted.content == "I'll check it." assert len(extracted.tool_calls) == 1 - assert extracted.tool_calls[0].function.name == "get_weather" - args = json.loads(extracted.tool_calls[0].function.arguments) - assert args["city"] == "Dallas" - assert args["unit"] == "fahrenheit" + tool_call = extracted.tool_calls[0] + assert tool_call.function.name == "get_current_weather" + assert json.loads(tool_call.function.arguments) == { + "city": "Dallas", + "state": "TX", + "unit": "fahrenheit", + } + + +def test_extract_multiple_tool_calls_with_glm45_newline_format(): + tools = _tools() + parser = _parser(tools) + model_output = """get_current_weather +cityDallas + +get_current_weather +cityOrlando +""" + extracted = parser.extract_tool_calls(model_output, request=_request(tools)) -def test_extract_tool_calls_with_function_tool_mixed_types( - glm4_moe_parser_function_tools, mock_request_function_tools -): + assert extracted.tools_called + assert [tc.function.name for tc in extracted.tool_calls] == [ + "get_current_weather", + "get_current_weather", + ] + assert [ + json.loads(tc.function.arguments)["city"] for tc in extracted.tool_calls + ] == ["Dallas", "Orlando"] + + +def test_extract_tool_calls_coerces_schema_types(): + tools = _tools() + parser = _parser(tools) model_output = """calculate -operation -add -a -42 -b -3.14 +operationadd +a42 +b3.14 +enabledtrue """ - extracted = glm4_moe_parser_function_tools.extract_tool_calls( - model_output, request=mock_request_function_tools - ) + extracted = parser.extract_tool_calls(model_output, request=_request(tools)) + assert extracted.tools_called - args = json.loads(extracted.tool_calls[0].function.arguments) - assert args["operation"] == "add" - assert isinstance(args["a"], (int, float)) - assert isinstance(args["b"], float) + assert json.loads(extracted.tool_calls[0].function.arguments) == { + "operation": "add", + "a": 42, + "b": 3.14, + "enabled": True, + } -def test_streaming_with_function_tool( - glm4_moe_parser_function_tools, mock_request_function_tools -): - _reset_streaming_state(glm4_moe_parser_function_tools) +def test_extract_zero_argument_tool_call_with_glm45_newline_format(): + tools = _tools() + parser = _parser(tools) + extracted = parser.extract_tool_calls( + "get_time\n", + request=_request(tools), + ) + + assert extracted.tools_called + assert extracted.tool_calls[0].function.name == "get_time" + assert json.loads(extracted.tool_calls[0].function.arguments) == {} + + +def test_streaming_tool_call_with_glm45_newline_format(): + tools = _tools() + parser = _parser(tools) + request = _request(tools) chunks = [ - "get_weather\n", + "", + "get_current_weather\n", "city", "Bei", - "jing", - "", + "jing", "", ] - + deltas = [] current_text = "" + for chunk in chunks: current_text += chunk - glm4_moe_parser_function_tools.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=chunk, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=mock_request_function_tools, + deltas.append( + parser.extract_tool_calls_streaming( + previous_text="", + current_text=current_text, + delta_text=chunk, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[], + request=request, + ) ) - assert len(glm4_moe_parser_function_tools.prev_tool_call_arr) == 1 - args = json.loads(glm4_moe_parser_function_tools.prev_tool_call_arr[0]["arguments"]) - assert args["city"] == "Beijing" + calls = _collect_tool_deltas(deltas) + assert calls[0]["name"] == "get_current_weather" + assert json.loads("".join(calls[0]["args_fragments"])) == {"city": "Beijing"} diff --git a/tests/tool_parsers/test_granite_tool_parser.py b/tests/tool_parsers/test_granite_tool_parser.py index 2046c11c5d21..af3386112f50 100644 --- a/tests/tool_parsers/test_granite_tool_parser.py +++ b/tests/tool_parsers/test_granite_tool_parser.py @@ -2,13 +2,21 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import json + import pytest from tests.tool_parsers.common_tests import ( ToolParserTestConfig, ToolParserTests, ) -from tests.tool_parsers.utils import run_tool_extraction +from tests.tool_parsers.utils import ( + run_tool_extraction, + run_tool_extraction_streaming, + split_string_into_token_deltas, +) +from vllm.tokenizers import get_tokenizer +from vllm.tool_parsers.granite_tool_parser import GraniteToolParser class TestGraniteToolParser(ToolParserTests): @@ -116,3 +124,38 @@ def test_granite_string_prefix_format(self, tool_parser, streaming): f"Expected 1 tool call from string format, got {len(tool_calls)}" ) assert tool_calls[0].function.name == "get_weather" + + +# granite emits arguments before name and its own tokenizer (not gpt2) is used +# here so the token boundaries match production; get_tokenizer only fetches the +# small tokenizer files, not the model weights. +@pytest.fixture(scope="module") +def granite_tokenizer(): + return get_tokenizer(tokenizer_name="ibm-granite/granite-3.1-8b-instruct") + + +@pytest.mark.parametrize("chunk_size", [2, 3, 4, 5]) +def test_streaming_parallel_calls_batched_deltas(granite_tokenizer, chunk_size): + """A batched delta (multiple tokens) spanning the boundary between two + parallel calls must not drop the first call's name. granite streams + arguments before name, so the name only completes as the next call appears. + """ + parser = GraniteToolParser(granite_tokenizer) + model_output = ( + '<|tool_call|> [{"arguments": {"city": "Tokyo"}, "name": "get_weather"}, ' + '{"arguments": {"timezone": "Asia/Tokyo"}, "name": "get_time"}]' + ) + token_deltas = split_string_into_token_deltas(granite_tokenizer, model_output) + batched = [ + "".join(token_deltas[i : i + chunk_size]) + for i in range(0, len(token_deltas), chunk_size) + ] + reconstructor = run_tool_extraction_streaming( + parser, batched, assert_one_tool_per_delta=False + ) + names = [tc.function.name for tc in reconstructor.tool_calls] + assert names == ["get_weather", "get_time"] + # trailing args of the final call are flushed by the serving layer + assert json.loads(reconstructor.tool_calls[0].function.arguments) == { + "city": "Tokyo" + } diff --git a/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py b/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py index 90f08bb82e09..167ad6688924 100644 --- a/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py +++ b/tests/tool_parsers/test_hunyuan_a13b_tool_parser.py @@ -177,3 +177,15 @@ def test_hunyuan_a13b_tool_parser_streaming(model_deltas, expected_tool_calls): reconstructor.tool_calls[idx].id = expected_tool_calls[idx].id assert reconstructor.tool_calls == expected_tool_calls + + +def test_hunyuan_a13b_tool_parser_non_ascii(): + mock_tokenizer = MagicMock() + tool_parser: ToolParser = ToolParserManager.get_tool_parser("hunyuan_a13b")( + mock_tokenizer + ) + model_output = '[{"name": "get_weather", "arguments": {"city": "北京"}}]' + _, tool_calls = run_tool_extraction(tool_parser, model_output, streaming=False) + args = tool_calls[0].function.arguments + assert "北京" in args + assert "\\u" not in args diff --git a/tests/tool_parsers/test_minimax_m2_tool_parser.py b/tests/tool_parsers/test_minimax_m2_tool_parser.py index 963c3462ff36..029ee21ae1f8 100644 --- a/tests/tool_parsers/test_minimax_m2_tool_parser.py +++ b/tests/tool_parsers/test_minimax_m2_tool_parser.py @@ -18,7 +18,6 @@ # Token IDs matching FakeTokenizer.vocab TC_START_ID = 1 TC_END_ID = 2 -EOS_ID = 99 class FakeTokenizer: @@ -34,6 +33,10 @@ def __init__(self): def get_vocab(self): return self.vocab + def decode(self, token_ids): + id_to_token = {v: k for k, v in self.vocab.items()} + return "".join(id_to_token.get(token_id, "") for token_id in token_ids) + @pytest.fixture def parser(): @@ -121,7 +124,6 @@ def test_plain_content(self, parser): """No tool call tokens — all text is streamed as content.""" results = _feed(parser, ["Hello ", "world"]) assert _collect_content(results) == "Hello world" - assert not parser.prev_tool_call_arr def test_content_before_tool_call(self, parser): """Text before is streamed as content.""" @@ -135,7 +137,6 @@ def test_content_before_tool_call(self, parser): ], ) assert _collect_content(results) == "Let me check. " - assert len(parser.prev_tool_call_arr) == 1 def test_empty_delta_no_crash(self, parser): """Empty delta_text with no token IDs returns None.""" @@ -262,45 +263,6 @@ def test_different_functions(self, parser): assert tc[1]["name"] == "get_stock" -# --------------------------------------------------------------------------- -# Internal state: prev_tool_call_arr -# --------------------------------------------------------------------------- - - -class TestInternalState: - """Verify prev_tool_call_arr is correct.""" - - def test_prev_tool_call_arr_single(self, parser): - _feed( - parser, - [ - '' - '1' - "", - ], - ) - assert len(parser.prev_tool_call_arr) == 1 - assert parser.prev_tool_call_arr[0]["name"] == "fn" - assert parser.prev_tool_call_arr[0]["arguments"] == {"a": "1"} - - def test_prev_tool_call_arr_multiple(self, parser): - """prev_tool_call_arr records each invoke with correct arguments.""" - _feed( - parser, - [ - "", - 'hello', - 'world', - "", - ], - ) - assert len(parser.prev_tool_call_arr) == 2 - assert parser.prev_tool_call_arr[0]["name"] == "search" - assert parser.prev_tool_call_arr[0]["arguments"] == {"q": "hello"} - assert parser.prev_tool_call_arr[1]["name"] == "search" - assert parser.prev_tool_call_arr[1]["arguments"] == {"q": "world"} - - # --------------------------------------------------------------------------- # DeltaMessage structure # --------------------------------------------------------------------------- @@ -324,7 +286,7 @@ def test_tool_call_fields(self, parser): tc = tc_deltas[0] assert tc.index == 0 assert tc.type == "function" - assert tc.id is not None and tc.id.startswith("call_") + assert tc.id is not None assert tc.function.name == "fn" assert json.loads(tc.function.arguments) == {"k": "v"} @@ -344,72 +306,6 @@ def test_multi_invoke_indices(self, parser): assert indices == [0, 1] -# --------------------------------------------------------------------------- -# Phase 3: EOS handling -# --------------------------------------------------------------------------- - - -class TestEOSHandling: - """Tests for the end-of-stream phase.""" - - def test_eos_after_tool_calls(self, parser): - """EOS token (empty delta, non-special token id) returns content=''.""" - results = _feed( - parser, - [ - "", - 'v', - "", - # EOS: empty delta_text, non-special token id - ("", [EOS_ID]), - ], - ) - # Last result should be the EOS empty-content signal - assert results[-1].content == "" - - def test_end_token_ignored(self, parser): - """ special token should NOT trigger EOS.""" - results = _feed( - parser, - [ - "", - 'v', - # arrives as special token - ("", [TC_END_ID]), - ], - ) - # The tool call delta should be emitted, but no EOS signal - assert not any(r.content == "" and r.tool_calls is None for r in results) - - -# --------------------------------------------------------------------------- -# Start token detection via token IDs -# --------------------------------------------------------------------------- - - -class TestSpecialTokenDetection: - """Start token arrives as a special token (not in delta_text).""" - - def test_start_token_via_id(self, parser): - """ detected via delta_token_ids, not text.""" - results = _feed(parser, ["Hello "]) - assert _collect_content(results) == "Hello " - - # Start token as special token (empty delta_text) - previous = "Hello " - result = parser.extract_tool_calls_streaming( - previous_text=previous, - current_text=previous, - delta_text="", - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[TC_START_ID], - request=None, - ) - assert result is None # no content to emit - assert parser.is_tool_call_started is True - - # --------------------------------------------------------------------------- # Large chunks (stream_interval > 1) # --------------------------------------------------------------------------- @@ -419,7 +315,7 @@ class TestLargeChunks: """Simulate stream_interval > 1 where many tokens arrive at once.""" def test_header_and_params_in_separate_chunks(self, parser): - """Header in chunk 1, all params + close in chunk 2, then EOS.""" + """Header in chunk 1, all params + close in chunk 2.""" chunk1 = '' chunk2 = ( 'Seattle' @@ -432,7 +328,6 @@ def test_header_and_params_in_separate_chunks(self, parser): [ chunk1, chunk2, - ("", [EOS_ID]), ], ) @@ -441,12 +336,6 @@ def test_header_and_params_in_separate_chunks(self, parser): parsed = json.loads(tc[0]["arguments"]) assert parsed == {"city": "Seattle", "days": "5"} - assert len(parser.prev_tool_call_arr) == 1 - assert parser.prev_tool_call_arr[0]["arguments"] == { - "city": "Seattle", - "days": "5", - } - class TestAnyOfNullableParam: """Regression: anyOf nullable parameter parsing (PR #32342).""" diff --git a/tests/tool_parsers/test_minimax_m3_tool_parser.py b/tests/tool_parsers/test_minimax_m3_tool_parser.py new file mode 100644 index 000000000000..fd1acabde2e5 --- /dev/null +++ b/tests/tool_parsers/test_minimax_m3_tool_parser.py @@ -0,0 +1,261 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +from typing import Any + +import pytest + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionToolsParam, + FunctionDefinition, +) +from vllm.entrypoints.openai.engine.protocol import DeltaMessage +from vllm.tool_parsers import ToolParserManager +from vllm.tool_parsers.minimax_m3_tool_parser import MinimaxM3ToolParser + +pytestmark = [pytest.mark.cpu_test, pytest.mark.skip_global_cleanup] + +NS = "]<]minimax[>[" +EOS_ID = 99 + + +class FakeTokenizer: + """Minimal fake tokenizer for unit tests.""" + + def __init__(self): + self.model_tokenizer = True + self.vocab: dict[str, int] = {} + + def get_vocab(self) -> dict[str, int]: + return self.vocab + + +def sample_tools() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + function=FunctionDefinition( + name="create_order", + parameters={ + "type": "object", + "properties": { + "user_id": {"type": "integer"}, + "urgent": {"type": "boolean"}, + "note": {"type": "string"}, + "shipping": { + "type": "object", + "properties": { + "city": {"type": "string"}, + "zip": {"type": "integer"}, + }, + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sku": {"type": "string"}, + "qty": {"type": "integer"}, + }, + }, + }, + "metadata": { + "type": "object", + "additionalProperties": {"type": "string"}, + }, + "duplicate_demo": {"type": "object"}, + }, + }, + ), + ) + ] + + +@pytest.fixture +def parser() -> MinimaxM3ToolParser: + return MinimaxM3ToolParser(FakeTokenizer(), tools=sample_tools()) + + +def build_order_call() -> str: + return ( + f"{NS}\n" + f'{NS}' + f"{NS}42{NS}" + f"{NS}true{NS}" + f"{NS}Please leave at front desk.{NS}" + f"{NS}" + f"{NS}Singapore{NS}" + f"{NS}018956{NS}" + f"{NS}" + f"{NS}" + f"{NS}{NS}book-001{NS}{NS}2{NS}{NS}" + f"{NS}{NS}pen-007{NS}{NS}5{NS}{NS}" + f"{NS}" + f"{NS}" + f"{NS}mobile{NS}" + f"{NS}may-launch{NS}" + f"{NS}" + f"{NS}" + f"{NS}a{NS}" + f"{NS}b{NS}" + f"{NS}" + f"{NS}\n" + f"{NS}" + ) + + +def build_order_invocation(user_id: int) -> str: + return ( + f'{NS}' + f"{NS}{user_id}{NS}" + f"{NS}" + ) + + +def build_multiple_order_call() -> str: + return ( + f"{NS}\n" + f"{build_order_invocation(1)}\n" + f"{build_order_invocation(2)}\n" + f"{NS}" + ) + + +def _feed( + parser: MinimaxM3ToolParser, chunks: list[str | tuple[str, list[int]]] +) -> list[DeltaMessage]: + previous = "" + results: list[DeltaMessage] = [] + for chunk in chunks: + if isinstance(chunk, tuple): + delta, delta_ids = chunk + else: + delta = chunk + delta_ids = [] + + current = previous + delta + result = parser.extract_tool_calls_streaming( + previous_text=previous, + current_text=current, + delta_text=delta, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=delta_ids, + request=None, + ) + if result is not None: + results.append(result) + previous = current + return results + + +def _collect_content(results: list[DeltaMessage]) -> str: + return "".join(result.content for result in results if result.content) + + +def _collect_tool_calls(results: list[DeltaMessage]) -> dict[int, dict[str, Any]]: + tool_calls: dict[int, dict[str, Any]] = {} + for result in results: + for tool_call in result.tool_calls or []: + tool_calls.setdefault( + tool_call.index, + {"id": None, "name": "", "arguments": ""}, + ) + if tool_call.id: + tool_calls[tool_call.index]["id"] = tool_call.id + if tool_call.function: + if tool_call.function.name: + tool_calls[tool_call.index]["name"] += tool_call.function.name + if tool_call.function.arguments: + tool_calls[tool_call.index]["arguments"] += ( + tool_call.function.arguments + ) + return tool_calls + + +def test_minimax_m3_parser_registered(): + assert ToolParserManager.get_tool_parser("minimax_m3") is MinimaxM3ToolParser + + +def test_non_streaming_nested_tool_call(parser): + result = parser.extract_tool_calls( + "I will create it.\n" + build_order_call(), + request=None, + ) + + assert result.tools_called + assert result.content == "I will create it.\n" + assert len(result.tool_calls) == 1 + tool_call = result.tool_calls[0] + assert tool_call.function.name == "create_order" + assert json.loads(tool_call.function.arguments) == { + "user_id": 42, + "urgent": True, + "note": "Please leave at front desk.", + "shipping": {"city": "Singapore", "zip": 18956}, + "items": [ + {"sku": "book-001", "qty": 2}, + {"sku": "pen-007", "qty": 5}, + ], + "metadata": { + "source": "mobile", + "campaign": "may-launch", + }, + "duplicate_demo": {"tag": ["a", "b"]}, + } + + +def test_non_streaming_without_tool_call_keeps_content(parser): + result = parser.extract_tool_calls("plain response", request=None) + + assert not result.tools_called + assert result.tool_calls == [] + assert result.content == "plain response" + + +def test_non_streaming_multiple_tool_calls(parser): + result = parser.extract_tool_calls(build_multiple_order_call(), request=None) + + assert result.tools_called + assert result.content is None + assert [tool_call.function.name for tool_call in result.tool_calls] == [ + "create_order", + "create_order", + ] + assert [ + json.loads(tool_call.function.arguments)["user_id"] + for tool_call in result.tool_calls + ] == [1, 2] + + +def test_streaming_without_tool_call_emits_text(parser): + results = _feed(parser, ["plain ", "response"]) + + assert _collect_content(results) == "plain response" + assert _collect_tool_calls(results) == {} + + +def test_streaming_nested_tool_call(parser): + tool_call_text = build_order_call() + results = _feed( + parser, + [ + "I will create it.\n", + tool_call_text[:5], + tool_call_text[5:17], + tool_call_text[17:120], + tool_call_text[120:], + ("", [EOS_ID]), + ], + ) + + assert _collect_content(results) == "I will create it.\n" + tool_calls = _collect_tool_calls(results) + assert len(tool_calls) == 1 + assert tool_calls[0]["name"] == "create_order" + assert tool_calls[0]["id"] is not None + assert json.loads(tool_calls[0]["arguments"]) == json.loads( + parser.streamed_args_for_tool[0] + ) + assert json.loads(parser.prev_tool_call_arr[0]["arguments"])["items"][1]["qty"] == 5 + assert results[-1].content is None diff --git a/tests/tool_parsers/test_minimax_tool_parser.py b/tests/tool_parsers/test_minimax_tool_parser.py deleted file mode 100644 index 08b2104277b8..000000000000 --- a/tests/tool_parsers/test_minimax_tool_parser.py +++ /dev/null @@ -1,1227 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -# ruff: noqa: E501 - -import json -from typing import Any - -import pytest - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionToolsParam, -) -from vllm.entrypoints.openai.engine.protocol import ( - FunctionCall, - ToolCall, -) -from vllm.tokenizers import get_tokenizer -from vllm.tool_parsers.minimax_tool_parser import MinimaxToolParser - -# Use a common model that is likely to be available -MODEL = "MiniMaxAi/MiniMax-M1-40k" - - -@pytest.fixture(scope="module") -def minimax_tokenizer(): - return get_tokenizer(tokenizer_name=MODEL) - - -@pytest.fixture -def minimax_tool_parser(minimax_tokenizer): - return MinimaxToolParser(minimax_tokenizer) - - -@pytest.fixture -def sample_tools(): - return [ - ChatCompletionToolsParam( - type="function", - function={ - "name": "get_current_weather", - "description": "Get the current weather", - "parameters": { - "type": "object", - "properties": { - "city": {"type": "string", "description": "The city name"}, - "state": {"type": "string", "description": "The state code"}, - "unit": {"type": "string", "enum": ["fahrenheit", "celsius"]}, - }, - "required": ["city", "state"], - }, - }, - ), - ChatCompletionToolsParam( - type="function", - function={ - "name": "calculate_area", - "description": "Calculate area of a shape", - "parameters": { - "type": "object", - "properties": { - "shape": {"type": "string"}, - "dimensions": {"type": "object"}, - "precision": {"type": "integer"}, - }, - }, - }, - ), - ] - - -def assert_tool_calls( - actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall] -): - assert len(actual_tool_calls) == len(expected_tool_calls) - - for actual_tool_call, expected_tool_call in zip( - actual_tool_calls, expected_tool_calls - ): - assert isinstance(actual_tool_call.id, str) - assert len(actual_tool_call.id) > 16 - - assert actual_tool_call.type == "function" - assert actual_tool_call.function == expected_tool_call.function - - -def test_extract_tool_calls_no_tools(minimax_tool_parser): - model_output = "This is a test" - extracted_tool_calls = minimax_tool_parser.extract_tool_calls( - model_output, request=None - ) # type: ignore[arg-type] - assert not extracted_tool_calls.tools_called - assert extracted_tool_calls.tool_calls == [] - assert extracted_tool_calls.content == model_output - - -@pytest.mark.parametrize( - ids=[ - "single_tool_call", - "multiple_tool_calls", - "tool_call_with_content_before", - "tool_call_with_single_line_json", - "tool_call_incomplete_tag", - ], - argnames=["model_output", "expected_tool_calls", "expected_content"], - argvalues=[ - ( - """ -{"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}} -""", - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Dallas", - "state": "TX", - "unit": "fahrenheit", - } - ), - ) - ) - ], - None, - ), - ( - """ -{"name": "get_current_weather", "arguments": {"city": "Dallas", "state": "TX", "unit": "fahrenheit"}} -{"name": "get_current_weather", "arguments": {"city": "Orlando", "state": "FL", "unit": "fahrenheit"}} -""", - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Dallas", - "state": "TX", - "unit": "fahrenheit", - } - ), - ) - ), - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Orlando", - "state": "FL", - "unit": "fahrenheit", - } - ), - ) - ), - ], - None, - ), - ( - """I'll help you check the weather. -{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}} -""", - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Seattle", - "state": "WA", - "unit": "celsius", - } - ), - ) - ) - ], - "I'll help you check the weather.", - ), - ( - """ -{"name": "get_current_weather", "arguments": {"city": "New York", "state": "NY", "unit": "celsius"}} -""", - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "New York", - "state": "NY", - "unit": "celsius", - } - ), - ) - ) - ], - None, - ), - ( - """ -{"name": "get_current_weather", "arguments": {"city": "Boston", "state": "MA"}}""", - [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps( - { - "city": "Boston", - "state": "MA", - } - ), - ) - ) - ], - None, - ), - ], -) -def test_extract_tool_calls( - minimax_tool_parser, model_output, expected_tool_calls, expected_content -): - extracted_tool_calls = minimax_tool_parser.extract_tool_calls( - model_output, request=None - ) # type: ignore[arg-type] - assert extracted_tool_calls.tools_called - - assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls) - - assert extracted_tool_calls.content == expected_content - - -def test_preprocess_model_output_with_thinking_tags(minimax_tool_parser): - """Test that tool calls within thinking tags are removed during preprocessing.""" - model_output = """Let me think about this. -{"name": "fake_tool", "arguments": {"param": "value"}} - This should be removed. - -I'll help you with that. -{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA"}} -""" - - processed_output = minimax_tool_parser.preprocess_model_output(model_output) - - # The tool call within thinking tags should be removed - assert "fake_tool" not in processed_output - # But the thinking tag itself should remain - assert "" in processed_output - assert "" in processed_output - # The actual tool call outside thinking tags should remain - assert "get_current_weather" in processed_output - - -def test_extract_tool_calls_with_thinking_tags(minimax_tool_parser): - """Test tool extraction when thinking tags contain tool calls that should be ignored.""" - model_output = """I should use a tool. -{"name": "ignored_tool", "arguments": {"should": "ignore"}} - - -Let me help you with the weather. -{"name": "get_current_weather", "arguments": {"city": "Miami", "state": "FL", "unit": "fahrenheit"}} -""" - - extracted_tool_calls = minimax_tool_parser.extract_tool_calls( - model_output, request=None - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - assert len(extracted_tool_calls.tool_calls) == 1 - assert extracted_tool_calls.tool_calls[0].function.name == "get_current_weather" - - # Content extraction is based on the position of the first in the original model_output - # Since preprocessing removes tool calls within thinking tags, the actual first is the external one - expected_content = """I should use a tool. -{"name": "ignored_tool", "arguments": {"should": "ignore"}} - - -Let me help you with the weather.""" - assert extracted_tool_calls.content == expected_content - - -def test_extract_tool_calls_invalid_json(minimax_tool_parser): - """Test that invalid JSON in tool calls is handled gracefully.""" - model_output = """ -{"name": "valid_tool", "arguments": {"city": "Seattle"}} -{invalid json here} -{"name": "another_valid_tool", "arguments": {"param": "value"}} -""" - - extracted_tool_calls = minimax_tool_parser.extract_tool_calls( - model_output, request=None - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - # Should extract only the valid JSON tool calls - assert len(extracted_tool_calls.tool_calls) == 2 - assert extracted_tool_calls.tool_calls[0].function.name == "valid_tool" - assert extracted_tool_calls.tool_calls[1].function.name == "another_valid_tool" - - -def test_extract_tool_calls_missing_name_or_arguments(minimax_tool_parser): - """Test that tool calls missing name or arguments are filtered out.""" - model_output = """ -{"name": "valid_tool", "arguments": {"city": "Seattle"}} -{"name": "missing_args"} -{"arguments": {"city": "Portland"}} -{"name": "another_valid_tool", "arguments": {"param": "value"}} -""" - - extracted_tool_calls = minimax_tool_parser.extract_tool_calls( - model_output, request=None - ) # type: ignore[arg-type] - - assert extracted_tool_calls.tools_called - # Should extract only the valid tool calls with both name and arguments - assert len(extracted_tool_calls.tool_calls) == 2 - assert extracted_tool_calls.tool_calls[0].function.name == "valid_tool" - assert extracted_tool_calls.tool_calls[1].function.name == "another_valid_tool" - - -def test_streaming_basic_functionality(minimax_tool_parser): - """Test basic streaming functionality.""" - # Reset streaming state - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.streamed_args_for_tool = [] - - # Test with a simple tool call - current_text = """ -{"name": "get_current_weather", "arguments": {"city": "Seattle"}} -""" - - # First call should handle the initial setup - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text="", - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - # The result might be None or contain tool call information - # This depends on the internal state management - if result is not None and hasattr(result, "tool_calls") and result.tool_calls: - assert len(result.tool_calls) >= 0 - - -def test_streaming_with_content_before_tool_calls(minimax_tool_parser): - """Test streaming when there's content before tool calls.""" - # Reset streaming state - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.streamed_args_for_tool = [] - - current_text = "I'll help you with that. " - - # When there's content before tool calls, it should be returned as content - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text="I'll help you", - current_text=current_text, - delta_text=" with that. ", - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - if result is not None and hasattr(result, "content"): - # Should contain some content - assert result.content is not None - - -def test_streaming_no_tool_calls(minimax_tool_parser): - """Test streaming when there are no tool calls.""" - current_text = "This is just regular text without any tool calls." - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text="This is just regular text", - current_text=current_text, - delta_text=" without any tool calls.", - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - # Should return the delta text as content - assert result is not None - assert hasattr(result, "content") - assert result.content == " without any tool calls." - - -def test_streaming_with_thinking_tags(minimax_tool_parser): - """Test streaming with thinking tags that contain tool calls.""" - # Reset streaming state - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.streamed_args_for_tool = [] - - current_text = """{"name": "ignored", "arguments": {}}{"name": "real_tool", "arguments": {"param": "value"}}""" - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=current_text, - delta_text=current_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - # The preprocessing should remove tool calls from thinking tags - # and only process the real tool call - if result is not None and hasattr(result, "tool_calls") and result.tool_calls: - for tool_call in result.tool_calls: - assert tool_call.function.name != "ignored" - - -def test_extract_tool_calls_multiline_json_not_supported(minimax_tool_parser): - """Test that multiline JSON in tool calls is not currently supported.""" - model_output = """ -{ - "name": "get_current_weather", - "arguments": { - "city": "New York", - "state": "NY", - "unit": "celsius" - } -} -""" - - extracted_tool_calls = minimax_tool_parser.extract_tool_calls( - model_output, request=None - ) # type: ignore[arg-type] - - # Multiline JSON is currently not supported, should return no tools called - assert not extracted_tool_calls.tools_called - assert extracted_tool_calls.tool_calls == [] - assert extracted_tool_calls.content is None - - -def test_streaming_arguments_incremental_output(minimax_tool_parser): - """Test that streaming arguments are returned incrementally, not cumulatively.""" - # Reset streaming state - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.streamed_args_for_tool = [] - - # Simulate progressive tool call building - stages = [ - # Stage 1: Function name complete - '\n{"name": "get_current_weather", "arguments": ', - # Stage 2: Arguments object starts with first key - '\n{"name": "get_current_weather", "arguments": {"city": ', - # Stage 3: First parameter value added - '\n{"name": "get_current_weather", "arguments": {"city": "Seattle"', - # Stage 4: Second parameter added - '\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA"', - # Stage 5: Third parameter added, arguments complete - '\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}', - # Stage 6: Tool calls closed - '\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}\n\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}\n', - ] - - function_name_sent = False - previous_args_content = "" - - for i, current_text in enumerate(stages): - previous_text = stages[i - 1] if i > 0 else "" - delta_text = current_text[len(previous_text) :] if i > 0 else current_text - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - print(f"Stage {i}: Current text: {repr(current_text)}") - print(f"Stage {i}: Delta text: {repr(delta_text)}") - - if result is not None and hasattr(result, "tool_calls") and result.tool_calls: - tool_call = result.tool_calls[0] - - # Check if function name is sent (should happen only once) - if tool_call.function and tool_call.function.name: - assert tool_call.function.name == "get_current_weather" - function_name_sent = True - print(f"Stage {i}: Function name sent: {tool_call.function.name}") - - # Check if arguments are sent incrementally - if tool_call.function and tool_call.function.arguments: - args_fragment = tool_call.function.arguments - print(f"Stage {i}: Got arguments fragment: {repr(args_fragment)}") - - # For incremental output, each fragment should be new content only - # The fragment should not contain all previous content - if i >= 2 and previous_args_content: # After we start getting arguments - # The new fragment should not be identical to or contain all previous content - assert args_fragment != previous_args_content, ( - f"Fragment should be incremental, not cumulative: {args_fragment}" - ) - - # If this is truly incremental, the fragment should be relatively small - # compared to the complete arguments so far - if len(args_fragment) > len(previous_args_content): - print( - "Warning: Fragment seems cumulative rather than incremental" - ) - - previous_args_content = args_fragment - - # Verify function name was sent at least once - assert function_name_sent, "Function name should have been sent" - - -def test_streaming_arguments_delta_only(minimax_tool_parser): - """Test that each streaming call returns only the delta (new part) of arguments.""" - # Reset streaming state - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.streamed_args_for_tool = [] - - # Simulate two consecutive calls with growing arguments - call1_text = ( - '\n{"name": "test_tool", "arguments": {"param1": "value1"}}' - ) - call2_text = '\n{"name": "test_tool", "arguments": {"param1": "value1", "param2": "value2"}}' - - print(f"Call 1 text: {repr(call1_text)}") - print(f"Call 2 text: {repr(call2_text)}") - - # First call - should get the function name and initial arguments - result1 = minimax_tool_parser.extract_tool_calls_streaming( - previous_text="", - current_text=call1_text, - delta_text=call1_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - print(f"Result 1: {result1}") - if result1 and hasattr(result1, "tool_calls") and result1.tool_calls: - for i, tc in enumerate(result1.tool_calls): - print(f" Tool call {i}: {tc}") - - # Second call - should only get the delta (new part) of arguments - result2 = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=call1_text, - current_text=call2_text, - delta_text=', "param2": "value2"}', - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - print(f"Result 2: {result2}") - if result2 and hasattr(result2, "tool_calls") and result2.tool_calls: - for i, tc in enumerate(result2.tool_calls): - print(f" Tool call {i}: {tc}") - - # Verify the second call only returns the delta - if result2 is not None and hasattr(result2, "tool_calls") and result2.tool_calls: - tool_call = result2.tool_calls[0] - if tool_call.function and tool_call.function.arguments: - args_delta = tool_call.function.arguments - print(f"Arguments delta from second call: {repr(args_delta)}") - - # Should only contain the new part, not the full arguments - # The delta should be something like ', "param2": "value2"}' or just '"param2": "value2"' - assert ( - ', "param2": "value2"}' in args_delta - or '"param2": "value2"' in args_delta - ), f"Expected delta containing param2, got: {args_delta}" - - # Should NOT contain the previous parameter data - assert '"param1": "value1"' not in args_delta, ( - f"Arguments delta should not contain previous data: {args_delta}" - ) - - # The delta should be relatively short (incremental, not cumulative) - expected_max_length = len(', "param2": "value2"}') + 10 # Some tolerance - assert len(args_delta) <= expected_max_length, ( - f"Delta seems too long (possibly cumulative): {args_delta}" - ) - - print("✓ Delta validation passed") - else: - print("No arguments in result2 tool call") - else: - print("No tool calls in result2 or result2 is None") - # This might be acceptable if no incremental update is needed - # But let's at least verify that result1 had some content - assert result1 is not None, "At least the first call should return something" - - -def test_streaming_openai_compatibility(minimax_tool_parser): - """Test that streaming behavior with buffering works correctly.""" - # Reset streaming state - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.streamed_args_for_tool = [] - # Reset buffering state - minimax_tool_parser.pending_buffer = "" - minimax_tool_parser.in_thinking_tag = False - minimax_tool_parser.thinking_depth = 0 - - # Test scenario: simple buffering without complex tool call context - test_cases: list[dict[str, Any]] = [ - { - "stage": "Token: <", - "previous": "", - "current": "<", - "delta": "<", - "expected_content": None, # Should be buffered - }, - { - "stage": "Token: tool_calls>", - "previous": "<", - "current": "", - "delta": "tool_calls>", - "expected_content": None, # Complete tag, should not output - }, - { - "stage": "Regular content", - "previous": "Hello", - "current": "Hello world", - "delta": " world", - "expected_content": " world", # Normal content should pass through - }, - { - "stage": "Content with end tag start", - "previous": "Text", - "current": "Text content", - "delta": "calls>", - "expected_content": None, # Complete close tag, should not output - }, - ] - - for i, test_case in enumerate(test_cases): - print(f"\n--- Stage {i}: {test_case['stage']} ---") - print(f"Previous: {repr(test_case['previous'])}") - print(f"Current: {repr(test_case['current'])}") - print(f"Delta: {repr(test_case['delta'])}") - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=test_case["previous"], - current_text=test_case["current"], - delta_text=test_case["delta"], - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - print(f"Result: {result}") - - # Check expected content - if test_case["expected_content"] is None: - assert result is None or not getattr(result, "content", None), ( - f"Stage {i}: Expected no content, got {result}" - ) - print("✓ No content output as expected") - else: - assert result is not None and hasattr(result, "content"), ( - f"Stage {i}: Expected content, got {result}" - ) - assert result.content == test_case["expected_content"], ( - f"Stage {i}: Expected content {test_case['expected_content']}, got {result.content}" - ) - print(f"✓ Content matches: {repr(result.content)}") - - print("✓ Streaming test with buffering completed successfully") - - -def test_streaming_thinking_tag_buffering(minimax_tool_parser): - """Test that tool calls within thinking tags are properly handled during streaming.""" - # Reset streaming state - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.streamed_args_for_tool = [] - # Reset buffering state - minimax_tool_parser.pending_buffer = "" - minimax_tool_parser.in_thinking_tag = False - minimax_tool_parser.thinking_depth = 0 - - # Test scenario: tool calls within thinking tags should be ignored - test_cases: list[dict[str, Any]] = [ - { - "stage": "Start thinking", - "previous": "", - "current": "I need to use a tool. ", - "delta": "I need to use a tool. ", - "expected_content": "I need to use a tool. ", # Should pass through as content - }, - { - "stage": "Tool call in thinking", - "previous": "I need to use a tool. ", - "current": 'I need to use a tool. \n{"name": "ignored_tool", "arguments": {"param": "value"}}\n', - "delta": '\n{"name": "ignored_tool", "arguments": {"param": "value"}}\n', - "expected_content": '\n{"name": "ignored_tool", "arguments": {"param": "value"}}\n', # should be preserved in thinking tags - }, - { - "stage": "Real tool call after thinking", - "previous": 'I need to use a tool. \n{"name": "ignored_tool", "arguments": {"param": "value"}}\n', - "current": 'I need to use a tool. \n{"name": "ignored_tool", "arguments": {"param": "value"}}\n\n', - "delta": "\n", - "expected_content": "\n", # Should output '\n' and suppress - }, - ] - - for i, test_case in enumerate(test_cases): - print(f"\n--- Stage {i}: {test_case['stage']} ---") - print(f"Previous: {repr(test_case['previous'])}") - print(f"Current: {repr(test_case['current'])}") - print(f"Delta: {repr(test_case['delta'])}") - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=test_case["previous"], - current_text=test_case["current"], - delta_text=test_case["delta"], - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - print(f"Result: {result}") - - # Check expected content - if "expected_content" in test_case: - if test_case["expected_content"] is None: - assert result is None or not getattr(result, "content", None), ( - f"Stage {i}: Expected no content, got {result}" - ) - else: - assert result is not None and hasattr(result, "content"), ( - f"Stage {i}: Expected content, got {result}" - ) - assert result.content == test_case["expected_content"], ( - f"Stage {i}: Expected content {test_case['expected_content']}, got {result.content}" - ) - print(f"✓ Content matches: {repr(result.content)}") - - # Check tool calls - if test_case.get("expected_tool_call"): - assert ( - result is not None - and hasattr(result, "tool_calls") - and result.tool_calls - ), f"Stage {i}: Expected tool call, got {result}" - - tool_call = result.tool_calls[0] - assert tool_call.function.name == "real_tool", ( - f"Expected real_tool, got {tool_call.function.name}" - ) - print(f"✓ Real tool call detected: {tool_call.function.name}") - - print("✓ Thinking tag buffering test completed successfully") - - -def reset_streaming_state(minimax_tool_parser): - """Helper function to properly reset the streaming state for MinimaxToolParser.""" - # Reset minimax-specific state - minimax_tool_parser._reset_streaming_state() - - # Reset base class state (these should still be reset for compatibility) - minimax_tool_parser.prev_tool_call_arr = [] - minimax_tool_parser.current_tool_id = -1 - minimax_tool_parser.current_tool_name_sent = False - minimax_tool_parser.streamed_args_for_tool = [] - - -def test_streaming_complex_scenario_with_multiple_tools(minimax_tool_parser): - """Test complex streaming scenario: tools inside tags and multiple tool calls in one group.""" - # Reset streaming state - reset_streaming_state(minimax_tool_parser) - - # Complex scenario: tools inside thinking tags and multiple tools in one group - test_stages: list[dict[str, Any]] = [ - { - "stage": "Initial content", - "previous": "", - "current": "Let me help you with this task.", - "delta": "Let me help you with this task.", - "expected_content": "Let me help you with this task.", - "expected_tool_calls": 0, - }, - { - "stage": "Start thinking tag", - "previous": "Let me help you with this task.", - "current": "Let me help you with this task.I need to analyze this situation first.", - "delta": "I need to analyze this situation first.", - "expected_content": "I need to analyze this situation first.", - "expected_tool_calls": 0, - }, - { - "stage": "Tool call inside thinking tag starts", - "previous": "Let me help you with this task.I need to analyze this situation first.", - "current": "Let me help you with this task.I need to analyze this situation first.", - "delta": "", - "expected_content": "", # Inside thinking tags, tool tags should be preserved as content - "expected_tool_calls": 0, - }, - { - "stage": "Complete tool call inside thinking tag", - "previous": "Let me help you with this task.I need to analyze this situation first.", - "current": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n', - "delta": '\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n', - "expected_content": '\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n', - "expected_tool_calls": 0, # Tools inside thinking tags should be ignored - }, - { - "stage": "End thinking tag", - "previous": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n', - "current": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n', - "delta": "", - "expected_content": "", - "expected_tool_calls": 0, - }, - { - "stage": "Multiple tools group starts", - "previous": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n', - "current": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n\nNow I need to get weather information and calculate area.', - "delta": "\nNow I need to get weather information and calculate area.", - "expected_content": "\nNow I need to get weather information and calculate area.", # should be filtered - "expected_tool_calls": 0, - }, - { - "stage": "First tool in group", - "previous": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n\nNow I need to get weather information and calculate area.', - "current": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n\nNow I need to get weather information and calculate area.\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}', - "delta": '\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}', - "expected_content": None, # No content should be output when tool call is in progress - "expected_tool_calls": 1, - "expected_tool_name": "get_current_weather", - }, - { - "stage": "Second tool in group", - "previous": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n\nNow I need to get weather information and calculate area.\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}', - "current": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n\nNow I need to get weather information and calculate area.\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}\n{"name": "calculate_area", "arguments": {"shape": "rectangle", "dimensions": {"width": 10, "height": 5}}}', - "delta": '\n{"name": "calculate_area", "arguments": {"shape": "rectangle", "dimensions": {"width": 10, "height": 5}}}', - "expected_content": None, - "expected_tool_calls": 1, - "expected_tool_name": "calculate_area", - }, - { - "stage": "Complete tool calls group", - "previous": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n\nNow I need to get weather information and calculate area.\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}\n{"name": "calculate_area", "arguments": {"shape": "rectangle", "dimensions": {"width": 10, "height": 5}}}', - "current": 'Let me help you with this task.I need to analyze this situation first.\n{"name": "internal_analysis", "arguments": {"query": "analyze situation"}}\n\nNow I need to get weather information and calculate area.\n{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}}\n{"name": "calculate_area", "arguments": {"shape": "rectangle", "dimensions": {"width": 10, "height": 5}}}', - "delta": "", - "expected_content": None, - "expected_tool_calls": 0, - }, - ] - - tool_calls_count = 0 - - for i, test_case in enumerate(test_stages): - print(f"\n--- Stage {i}: {test_case['stage']} ---") - print( - f"Previous: {repr(test_case['previous'][:100])}{'...' if len(test_case['previous']) > 100 else ''}" - ) - print(f"Current: {repr(test_case['current'][-100:])}") - print(f"Delta: {repr(test_case['delta'])}") - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=test_case["previous"], - current_text=test_case["current"], - delta_text=test_case["delta"], - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - print(f"Result: {result}") - - # Check expected content - if test_case["expected_content"] is None: - assert result is None or not getattr(result, "content", None), ( - f"Stage {i}: Expected no content output, got {result}" - ) - print("✓ No content output as expected") - else: - assert result is not None and hasattr(result, "content"), ( - f"Stage {i}: Expected content output, got {result}" - ) - assert result.content == test_case["expected_content"], ( - f"Stage {i}: Expected content {repr(test_case['expected_content'])}, got {repr(result.content)}" - ) - print(f"✓ Content matches: {repr(result.content)}") - - # Check tool calls - expected_tool_calls = test_case["expected_tool_calls"] - actual_tool_calls = ( - len(result.tool_calls) - if result and hasattr(result, "tool_calls") and result.tool_calls - else 0 - ) - - if expected_tool_calls > 0: - assert actual_tool_calls >= expected_tool_calls, ( - f"Stage {i}: Expected at least {expected_tool_calls} tool calls, got {actual_tool_calls}" - ) - - if "expected_tool_name" in test_case: - # Find the tool call with the expected name - found_tool_call = None - for tool_call in result.tool_calls: - if tool_call.function.name == test_case["expected_tool_name"]: - found_tool_call = tool_call - break - - assert found_tool_call is not None, ( - f"Stage {i}: Expected tool name {test_case['expected_tool_name']} not found in tool calls: {[tc.function.name for tc in result.tool_calls]}" - ) - print(f"✓ Tool call correct: {found_tool_call.function.name}") - - # Ensure tools inside thinking tags are not called - assert found_tool_call.function.name != "internal_analysis", ( - f"Stage {i}: Tool 'internal_analysis' inside thinking tags should not be called" - ) - - tool_calls_count += actual_tool_calls - print(f"✓ Detected {actual_tool_calls} tool calls") - else: - assert actual_tool_calls == 0, ( - f"Stage {i}: Expected no tool calls, got {actual_tool_calls}" - ) - - # Verify overall results - print("\n=== Test Summary ===") - print(f"Total tool calls count: {tool_calls_count}") - assert tool_calls_count >= 2, ( - f"Expected at least 2 valid tool calls (outside thinking tags), but got {tool_calls_count}" - ) - - print("✓ Complex streaming test completed:") - print(" - ✓ Tools inside thinking tags correctly ignored") - print(" - ✓ Two tool groups outside thinking tags correctly parsed") - print(" - ✓ Content and tool call streaming correctly handled") - print(" - ✓ Buffering mechanism works correctly") - - -def test_streaming_character_by_character_output(minimax_tool_parser): - """Test character-by-character streaming output to simulate real streaming scenarios.""" - # Reset streaming state - reset_streaming_state(minimax_tool_parser) - - # Complete text that will be streamed character by character - complete_text = """I'll help you with the weather analysis. Let me think about this. -{"name": "internal_analysis", "arguments": {"type": "thinking"}} -This tool should be ignored. - -Now I'll get the weather information for you. -{"name": "get_current_weather", "arguments": {"city": "Seattle", "state": "WA", "unit": "celsius"}} -{"name": "calculate_area", "arguments": {"shape": "rectangle", "dimensions": {"width": 10, "height": 5}}} -Here are the results.""" - - print("\n=== Starting character-by-character streaming test ===") - print(f"Complete text length: {len(complete_text)} characters") - - # Track the streaming results - content_fragments = [] - tool_calls_detected = [] - - # Stream character by character - for i in range(1, len(complete_text) + 1): - current_text = complete_text[:i] - previous_text = complete_text[: i - 1] if i > 1 else "" - delta_text = complete_text[i - 1 : i] - - # Show progress every 50 characters - if i % 50 == 0 or i == len(complete_text): - print(f"Progress: {i}/{len(complete_text)} characters") - - # Call the streaming parser - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - # Collect results - if result is not None: - if hasattr(result, "content") and result.content: - content_fragments.append(result.content) - # Log important content fragments - if any( - keyword in result.content - for keyword in [ - "", - "", - "", - "", - ] - ): - print(f" Char {i}: Content fragment: {repr(result.content)}") - - if hasattr(result, "tool_calls") and result.tool_calls: - for tool_call in result.tool_calls: - tool_info = { - "character_position": i, - "function_name": tool_call.function.name - if tool_call.function - else None, - "arguments": tool_call.function.arguments - if tool_call.function - else None, - } - tool_calls_detected.append(tool_info) - print(f" Char {i}: Tool call detected: {tool_call.function.name}") - if tool_call.function.arguments: - print(f" Arguments: {repr(tool_call.function.arguments)}") - - # Verify results - print("\n=== Streaming Test Results ===") - print(f"Total content fragments: {len(content_fragments)}") - print(f"Total tool calls detected: {len(tool_calls_detected)}") - - # Reconstruct content from fragments - reconstructed_content = "".join(content_fragments) - print(f"Reconstructed content length: {len(reconstructed_content)}") - - # Verify thinking tags content is preserved - assert "" in reconstructed_content, ( - "Opening thinking tag should be preserved in content" - ) - assert "" in reconstructed_content, ( - "Closing thinking tag should be preserved in content" - ) - - # Verify that tool calls inside thinking tags are NOT extracted as actual tool calls - thinking_tool_calls = [ - tc for tc in tool_calls_detected if tc["function_name"] == "internal_analysis" - ] - assert len(thinking_tool_calls) == 0, ( - f"Tool calls inside thinking tags should be ignored, but found: {thinking_tool_calls}" - ) - - # Verify that real tool calls outside thinking tags ARE extracted - weather_tool_calls = [ - tc for tc in tool_calls_detected if tc["function_name"] == "get_current_weather" - ] - area_tool_calls = [ - tc for tc in tool_calls_detected if tc["function_name"] == "calculate_area" - ] - print(tool_calls_detected) - assert len(weather_tool_calls) > 0, ( - "get_current_weather tool call should be detected" - ) - assert len(area_tool_calls) > 0, "calculate_area tool call should be detected" - - # Verify tool call arguments are properly streamed - weather_args_found = any( - tc["arguments"] for tc in weather_tool_calls if tc["arguments"] - ) - area_args_found = any(tc["arguments"] for tc in area_tool_calls if tc["arguments"]) - - print(f"Weather tool call with arguments: {weather_args_found}") - print(f"Area tool call with arguments: {area_args_found}") - - # Verify content before and after tool calls - assert "I'll help you with the weather analysis." in reconstructed_content, ( - "Initial content should be preserved" - ) - assert "Here are the results." in reconstructed_content, ( - "Final content should be preserved" - ) - - # Verify that and tags are not included in the final content - # (they should be filtered out when not inside thinking tags) - content_outside_thinking = reconstructed_content - # Remove thinking tag content to check content outside - if "" in content_outside_thinking and "" in content_outside_thinking: - start_think = content_outside_thinking.find("") - end_think = content_outside_thinking.find("") + len("") - content_outside_thinking = ( - content_outside_thinking[:start_think] - + content_outside_thinking[end_think:] - ) - - # Outside thinking tags, tool_calls tags should be filtered - tool_calls_in_content = content_outside_thinking.count("") - assert tool_calls_in_content == 0, ( - f" tags should be filtered from content outside thinking tags, but found {tool_calls_in_content}" - ) - - print("\n=== Character-by-character streaming test completed successfully ===") - print("✓ Tool calls inside thinking tags correctly ignored") - print("✓ Tool calls outside thinking tags correctly detected") - print("✓ Content properly streamed and reconstructed") - print("✓ Tool call tags properly filtered from content") - print("✓ Character-level streaming works correctly") - - -def test_streaming_character_by_character_simple_tool_call(minimax_tool_parser): - """Test character-by-character streaming for a simple tool call scenario.""" - # Reset streaming state - reset_streaming_state(minimax_tool_parser) - - # Simple tool call text - simple_text = 'Let me check the weather. \n{"name": "get_weather", "arguments": {"city": "NYC"}}\n' - - print("\n=== Simple character-by-character test ===") - print(f"Text: {repr(simple_text)}") - - content_parts = [] - tool_name_sent = False - tool_args_sent = False - - for i in range(1, len(simple_text) + 1): - current_text = simple_text[:i] - previous_text = simple_text[: i - 1] if i > 1 else "" - delta_text = simple_text[i - 1 : i] - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - if result: - if hasattr(result, "content") and result.content: - content_parts.append(result.content) - print( - f" Char {i} ({repr(delta_text)}): Content: {repr(result.content)}" - ) - - if hasattr(result, "tool_calls") and result.tool_calls: - for tool_call in result.tool_calls: - if tool_call.function and tool_call.function.name: - tool_name_sent = True - print(f" Char {i}: Tool name: {tool_call.function.name}") - if tool_call.function and tool_call.function.arguments: - tool_args_sent = True - print( - f" Char {i}: Tool args: {repr(tool_call.function.arguments)}" - ) - - # Verify basic expectations - reconstructed_content = "".join(content_parts) - print(f"Final reconstructed content: {repr(reconstructed_content)}") - - assert tool_name_sent, "Tool name should be sent during streaming" - assert tool_args_sent, "Tool arguments should be sent during streaming" - assert "Let me check the weather." in reconstructed_content, ( - "Initial content should be preserved" - ) - - print("✓ Simple character-by-character test passed") - - -def test_streaming_character_by_character_with_buffering(minimax_tool_parser): - """Test character-by-character streaming with edge cases that trigger buffering.""" - # Reset streaming state - reset_streaming_state(minimax_tool_parser) - - # Text that includes potential buffering scenarios - buffering_text = 'Hello world\n{"name": "test"}\ndone' - - print("\n=== Buffering character-by-character test ===") - print(f"Text: {repr(buffering_text)}") - - all_content = [] - - for i in range(1, len(buffering_text) + 1): - current_text = buffering_text[:i] - previous_text = buffering_text[: i - 1] if i > 1 else "" - delta_text = buffering_text[i - 1 : i] - - result = minimax_tool_parser.extract_tool_calls_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - if result and hasattr(result, "content") and result.content: - all_content.append(result.content) - print(f" Char {i} ({repr(delta_text)}): {repr(result.content)}") - - final_content = "".join(all_content) - print(f"Final content: {repr(final_content)}") - - # The parser should handle the edge case where appears before - assert "Hello" in final_content, "Initial 'Hello' should be preserved" - assert "world" in final_content, ( - "Content after false closing tag should be preserved" - ) - assert "done" in final_content, "Final content should be preserved" - - print("✓ Buffering character-by-character test passed") diff --git a/tests/tool_parsers/test_mistral_tool_parser.py b/tests/tool_parsers/test_mistral_tool_parser.py index c9582159abbd..4f75e2f576fe 100644 --- a/tests/tool_parsers/test_mistral_tool_parser.py +++ b/tests/tool_parsers/test_mistral_tool_parser.py @@ -3,7 +3,6 @@ import json from collections.abc import Generator -from typing import Any from unittest.mock import MagicMock, patch import partial_json_parser @@ -29,22 +28,17 @@ ChatCompletionRequest, ) from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, DeltaMessage, DeltaToolCall, ExtractedToolCallInformation, StructuralTagResponseFormat, ) -from vllm.entrypoints.openai.engine.protocol import FunctionCall as VllmFunctionCall -from vllm.reasoning.mistral_reasoning_parser import MistralReasoningParser from vllm.sampling_params import StructuredOutputsParams from vllm.tokenizers import TokenizerLike, get_tokenizer from vllm.tokenizers.detokenizer_utils import detokenize_incrementally from vllm.tokenizers.mistral import MistralTokenizer from vllm.tool_parsers.mistral_tool_parser import ( _DEFAULT_JSON_SCHEMA, - MistralStreamingResult, - MistralToolCall, MistralToolParser, ) @@ -150,6 +144,7 @@ def stream_delta_message_generator( mistral_tokenizer: TokenizerLike, model_output: str | None, tools: list[tuple[str, str]] | None, + chunk_size: int = 1, ) -> Generator[DeltaMessage, None, None]: if ( isinstance(mistral_tokenizer, MistralTokenizer) @@ -188,15 +183,13 @@ def stream_delta_message_generator( previous_tokens = None prefix_offset = 0 read_offset = 0 + pending_text = "" + pending_token_ids: list[int] = [] for i, delta_token in enumerate(all_token_ids): - delta_token_ids = [delta_token] - previous_token_ids = all_token_ids[:i] - current_token_ids = all_token_ids[: i + 1] - (new_tokens, delta_text, new_prefix_offset, new_read_offset) = ( detokenize_incrementally( tokenizer=mistral_tokenizer, - all_input_ids=current_token_ids, + all_input_ids=all_token_ids[: i + 1], prev_tokens=previous_tokens, prefix_offset=prefix_offset, read_offset=read_offset, @@ -204,27 +197,39 @@ def stream_delta_message_generator( spaces_between_special_tokens=True, ) ) + previous_tokens = ( + previous_tokens + new_tokens if previous_tokens else new_tokens + ) + prefix_offset = new_prefix_offset + read_offset = new_read_offset - current_text = previous_text + delta_text + # Buffer tokens so each streamed delta can carry ``chunk_size`` tokens, + # reproducing the multi-token deltas produced by async scheduling / + # stream_interval > 1. + pending_text += delta_text + pending_token_ids.append(delta_token) + if len(pending_token_ids) < chunk_size and i != len(all_token_ids) - 1: + continue + + previous_token_ids = all_token_ids[: i + 1 - len(pending_token_ids)] + current_token_ids = all_token_ids[: i + 1] + current_text = previous_text + pending_text delta_message = mistral_tool_parser.extract_tool_calls_streaming( previous_text, current_text, - delta_text, + pending_text, previous_token_ids, current_token_ids, - delta_token_ids, + pending_token_ids, request=_DUMMY_REQUEST, ) if delta_message: yield delta_message previous_text = current_text - previous_tokens = ( - previous_tokens + new_tokens if previous_tokens else new_tokens - ) - prefix_offset = new_prefix_offset - read_offset = new_read_offset + pending_text = "" + pending_token_ids = [] @pytest.mark.parametrize( @@ -1580,380 +1585,37 @@ def test_grammar_from_tool_parser_set_by_adjust_request( assert result._grammar_from_tool_parser is True -@pytest.mark.parametrize( - "tool_calls, expected_len", - [ - (None, 0), - ([], 0), - ([VllmFunctionCall(id="abc123xyz", name="f", arguments="{}")], 1), - ([VllmFunctionCall(name="f", arguments="{}")], 1), - ( - [ - VllmFunctionCall(id="fixed1234", name="a", arguments='{"x": 1}'), - VllmFunctionCall(name="b", arguments='{"y": 2}'), - ], - 2, - ), - ], - ids=["none", "empty", "with_id", "without_id", "mixed"], -) -def test_build_non_streaming_tool_calls( - tool_calls: list[VllmFunctionCall] | None, - expected_len: int, -) -> None: - result = MistralToolParser.build_non_streaming_tool_calls(tool_calls) - assert len(result) == expected_len - - if tool_calls is None: - return - - for i, tc in enumerate(result): - assert isinstance(tc, MistralToolCall) - assert tc.type == "function" - - input_tc = tool_calls[i] - if input_tc.id: - assert tc.id == input_tc.id - else: - assert len(tc.id) == 9 - assert tc.id.isalnum() - - assert tc.function.name == input_tc.name - assert tc.function.arguments == input_tc.arguments - - -class TestExtractMaybeReasoningAndToolStreaming: - r"""Tests for `MistralToolParser.extract_maybe_reasoning_and_tool_streaming`.""" - - @pytest.fixture - def parser(self) -> MistralToolParser: - mock_tokenizer = MagicMock() - mock_tokenizer.get_vocab.return_value = {"[TOOL_CALLS]": 1} - return MistralToolParser(mock_tokenizer) - - @pytest.fixture - def request_obj(self) -> ChatCompletionRequest: - return _make_request() - - @staticmethod - def _call( - parser: MistralToolParser, - request: ChatCompletionRequest, - *, - reasoning_parser: Any = None, - previous_text: str = "", - current_text: str = "hello", - delta_text: str = "hello", - previous_token_ids: list[int] | None = None, - current_token_ids: list[int] | None = None, - output_token_ids: list[int] | None = None, - reasoning_ended: bool = False, - prompt_is_reasoning_end: bool | None = None, - ) -> MistralStreamingResult: - return parser.extract_maybe_reasoning_and_tool_streaming( - reasoning_parser=reasoning_parser, - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - previous_token_ids=previous_token_ids or [], - current_token_ids=current_token_ids or [1, 2, 3], - output_token_ids=output_token_ids or [1, 2, 3], - reasoning_ended=reasoning_ended, - prompt_is_reasoning_end=prompt_is_reasoning_end, - request=request, - ) - - def test_no_reasoning_tools_called( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - tool_delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=0, - function=DeltaFunctionCall(name="f", arguments="{}"), - ) - ] - ) - with patch.object( - parser, "extract_tool_calls_streaming", return_value=tool_delta - ): - result = self._call(parser, request_obj, reasoning_parser=None) - - assert result == MistralStreamingResult( - delta_message=tool_delta, - reasoning_ended=False, - tools_called=True, - current_text="hello", - current_token_ids=[1, 2, 3], - ) - - def test_no_reasoning_no_tools( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - content_delta = DeltaMessage(content="hello") - with patch.object( - parser, "extract_tool_calls_streaming", return_value=content_delta - ): - result = self._call(parser, request_obj, reasoning_parser=None) - - assert result == MistralStreamingResult( - delta_message=content_delta, - reasoning_ended=False, - tools_called=False, - current_text="hello", - current_token_ids=[1, 2, 3], - ) - - def test_mistral_reasoning_parser_no_think_token( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - mock_rp = MagicMock(spec=MistralReasoningParser) - mock_rp.start_token_id = 999 - content_delta = DeltaMessage(content="direct") - with patch.object( - parser, "extract_tool_calls_streaming", return_value=content_delta - ): - result = self._call( - parser, - request_obj, - reasoning_parser=mock_rp, - reasoning_ended=False, - current_token_ids=[1, 2, 3], - ) - - mock_rp.extract_reasoning_streaming.assert_not_called() - assert result == MistralStreamingResult( - delta_message=content_delta, - reasoning_ended=False, - tools_called=False, - current_text="hello", - current_token_ids=[1, 2, 3], - ) - - def test_mistral_reasoning_parser_with_think_token( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - mock_rp = MagicMock(spec=MistralReasoningParser) - mock_rp.start_token_id = 999 - mock_rp.extract_reasoning_streaming.return_value = DeltaMessage( - reasoning="thinking..." - ) - mock_rp.is_reasoning_end_streaming.return_value = False - - result = self._call( - parser, - request_obj, - reasoning_parser=mock_rp, - reasoning_ended=False, - current_token_ids=[1, 999, 3], - ) - - mock_rp.extract_reasoning_streaming.assert_called_once() - assert result == MistralStreamingResult( - delta_message=DeltaMessage(reasoning="thinking..."), - reasoning_ended=False, - tools_called=False, - current_text="hello", - current_token_ids=[1, 999, 3], - ) - - def test_non_mistral_reasoning_parser_always_expects_thinking( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - mock_rp = MagicMock() - mock_rp.start_token_id = 999 - mock_rp.extract_reasoning_streaming.return_value = DeltaMessage( - reasoning="thinking..." - ) - mock_rp.is_reasoning_end_streaming.return_value = False - - result = self._call( - parser, - request_obj, - reasoning_parser=mock_rp, - reasoning_ended=False, - current_token_ids=[1, 2, 3], - ) - - mock_rp.extract_reasoning_streaming.assert_called_once() - assert result == MistralStreamingResult( - delta_message=DeltaMessage(reasoning="thinking..."), - reasoning_ended=False, - tools_called=False, - current_text="hello", - current_token_ids=[1, 2, 3], - ) - - def test_reasoning_already_ended_no_reset( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - content_delta = DeltaMessage(content="content") - with patch.object( - parser, "extract_tool_calls_streaming", return_value=content_delta - ) as mock_extract: - result = self._call( - parser, - request_obj, - reasoning_parser=MagicMock(), - reasoning_ended=True, - previous_text="prior_tool_text", - previous_token_ids=[10, 20], - current_text="prior_tool_texthello", - current_token_ids=[10, 20, 1, 2, 3], - ) - - _, call_kwargs = mock_extract.call_args - assert call_kwargs["previous_text"] == "prior_tool_text" - assert call_kwargs["previous_token_ids"] == [10, 20] - - assert result == MistralStreamingResult( - delta_message=content_delta, - reasoning_ended=True, - tools_called=False, - current_text="prior_tool_texthello", - current_token_ids=[10, 20, 1, 2, 3], - ) - - def test_pre_v15_ignores_prompt_reasoning_end( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - mock_tokenizer = MagicMock(spec=MistralTokenizer) - mock_tokenizer.version = 13 - parser.model_tokenizer = mock_tokenizer - - mock_rp = MagicMock(spec=MistralReasoningParser) - mock_rp.start_token_id = 999 - mock_rp.extract_reasoning_streaming.return_value = DeltaMessage( - reasoning="thinking..." - ) - mock_rp.is_reasoning_end_streaming.return_value = False - - result = self._call( - parser, - request_obj, - reasoning_parser=mock_rp, - reasoning_ended=False, - prompt_is_reasoning_end=True, - current_token_ids=[999, 1, 2], - ) - - mock_rp.extract_reasoning_streaming.assert_called_once() - assert result == MistralStreamingResult( - delta_message=DeltaMessage(reasoning="thinking..."), - reasoning_ended=False, - tools_called=False, - current_text="hello", - current_token_ids=[999, 1, 2], - ) - - def test_non_pre_v15_prompt_reasoning_end( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - mock_tokenizer = MagicMock(spec=MistralTokenizer) - mock_tokenizer.version = 15 - parser.model_tokenizer = mock_tokenizer - - mock_rp = MagicMock(spec=MistralReasoningParser) - mock_rp.start_token_id = 999 - - content_delta = DeltaMessage(content="after reasoning") - with patch.object( - parser, "extract_tool_calls_streaming", return_value=content_delta - ): - result = self._call( - parser, - request_obj, - reasoning_parser=mock_rp, - reasoning_ended=False, - prompt_is_reasoning_end=True, - current_token_ids=[999, 1, 2], - output_token_ids=[10, 20, 30], - ) - - mock_rp.extract_reasoning_streaming.assert_not_called() - assert result == MistralStreamingResult( - delta_message=content_delta, - reasoning_ended=True, - tools_called=False, - current_text="hello", - current_token_ids=[10, 20, 30], - ) - - def test_reasoning_end_transition_with_content( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - """When reasoning ends and the delta has content, that content is - cleared from delta_message and used as current_text for tool parsing.""" - mock_rp = MagicMock() - mock_rp.start_token_id = 999 - mock_rp.extract_reasoning_streaming.return_value = DeltaMessage( - reasoning="think", content="leftover" - ) - mock_rp.is_reasoning_end_streaming.return_value = True - mock_rp.extract_content_ids.return_value = [50, 51] - - content_delta = DeltaMessage(content="leftover") - with patch.object( - parser, "extract_tool_calls_streaming", return_value=content_delta - ) as mock_extract: - result = self._call( - parser, - request_obj, - reasoning_parser=mock_rp, - reasoning_ended=False, - current_token_ids=[999, 1, 2], - output_token_ids=[10, 20, 30], - ) - - mock_rp.extract_content_ids.assert_called_once_with([10, 20, 30]) - _, call_kwargs = mock_extract.call_args - assert call_kwargs["previous_text"] == "" - assert call_kwargs["previous_token_ids"] == [] - assert call_kwargs["delta_text"] == "leftover" - assert call_kwargs["current_token_ids"] == [50, 51] - - assert result == MistralStreamingResult( - delta_message=content_delta, - reasoning_ended=True, - tools_called=False, - current_text="leftover", - current_token_ids=[50, 51], - ) - - def test_reasoning_end_transition_without_content( - self, parser: MistralToolParser, request_obj: ChatCompletionRequest - ) -> None: - """When reasoning ends but the delta has no content, current_text - is set to empty string.""" - mock_rp = MagicMock() - mock_rp.start_token_id = 999 - mock_rp.extract_reasoning_streaming.return_value = DeltaMessage( - reasoning="think" - ) - mock_rp.is_reasoning_end_streaming.return_value = True - mock_rp.extract_content_ids.return_value = [50, 51] - - empty_delta = DeltaMessage(content="") - with patch.object( - parser, "extract_tool_calls_streaming", return_value=empty_delta - ) as mock_extract: - result = self._call( - parser, - request_obj, - reasoning_parser=mock_rp, - reasoning_ended=False, - current_token_ids=[999, 1, 2], - output_token_ids=[10, 20, 30], - ) - - _, call_kwargs = mock_extract.call_args - assert call_kwargs["delta_text"] == "" - assert call_kwargs["current_token_ids"] == [50, 51] - - assert result == MistralStreamingResult( - delta_message=empty_delta, - reasoning_ended=True, - tools_called=False, - current_text="", - current_token_ids=[50, 51], - ) +@pytest.mark.parametrize("chunk_size", [2, 3, 4, 5]) +def test_streaming_pre_v11_parallel_calls_batched_deltas( + mistral_pre_v11_tool_parser, mistral_pre_v11_tokenizer, chunk_size +): + """A batched delta spanning the boundary between two parallel calls must + keep them on distinct indices (the bug collapsed both onto index 0).""" + model_output = ( + '[TOOL_CALLS] [{"name": "add", "arguments": {"a": 3.5, "b": 4}}, ' + '{"name": "get_current_weather", "arguments": ' + '{"city": "San Francisco", "state": "CA", "unit": "celsius"}}]' + ) + names: list[str] = [] + args: list[str] = [] + idx = -1 + for delta_message in stream_delta_message_generator( + mistral_pre_v11_tool_parser, + mistral_pre_v11_tokenizer, + model_output, + tools=None, + chunk_size=chunk_size, + ): + for tool_call in delta_message.tool_calls or []: + if tool_call.index != idx: + idx = tool_call.index + args.append("") + if tool_call.function and tool_call.function.name: + names.append(tool_call.function.name) + if tool_call.function and tool_call.function.arguments: + args[tool_call.index] += tool_call.function.arguments + + assert names == ["add", "get_current_weather"] + assert len(args) == 2 + # trailing args of the final call are flushed by the serving layer + assert json.loads(args[0]) == {"a": 3.5, "b": 4} diff --git a/tests/tool_parsers/test_openai_tool_parser.py b/tests/tool_parsers/test_openai_tool_parser.py deleted file mode 100644 index 843fbca621f7..000000000000 --- a/tests/tool_parsers/test_openai_tool_parser.py +++ /dev/null @@ -1,415 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import json - -import pytest -from openai_harmony import ( - Conversation, - DeveloperContent, - HarmonyEncodingName, - Message, - Role, - SystemContent, - load_harmony_encoding, -) - -from vllm.entrypoints.openai.engine.protocol import FunctionCall, ToolCall -from vllm.tokenizers import get_tokenizer -from vllm.tool_parsers.openai_tool_parser import OpenAIToolParser - -MODEL = "gpt2" - - -@pytest.fixture(scope="module") -def openai_tokenizer(): - # The parser does not use the tokenizer, but the constructor requires it. - return get_tokenizer(MODEL) - - -@pytest.fixture -def openai_tool_parser(openai_tokenizer): - return OpenAIToolParser(openai_tokenizer) - - -@pytest.fixture(scope="module") -def harmony_encoding(): - return load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) - - -def assert_tool_calls( - actual_tool_calls: list[ToolCall], - expected_tool_calls: list[ToolCall], -): - assert len(actual_tool_calls) == len(expected_tool_calls) - - for actual_tool_call, expected_tool_call in zip( - actual_tool_calls, expected_tool_calls - ): - assert isinstance(actual_tool_call.id, str) - assert len(actual_tool_call.id) > 16 # Default from protocol.py - assert actual_tool_call.type == "function" - assert actual_tool_call.function == expected_tool_call.function - - -def test_extract_tool_calls_no_tools(openai_tool_parser, harmony_encoding): - convo = Conversation.from_messages( - [ - Message.from_role_and_content( - Role.SYSTEM, - SystemContent.new(), - ), - Message.from_role_and_content( - Role.DEVELOPER, - DeveloperContent.new().with_instructions("Talk like a pirate!"), - ), - Message.from_role_and_content(Role.USER, "Arrr, how be you?"), - Message.from_role_and_content( - Role.ASSISTANT, "This is a test" - ).with_channel("final"), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, Role.ASSISTANT - ) - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert not extracted_info.tools_called - assert extracted_info.tool_calls == [] - assert extracted_info.content == "This is a test" - - -@pytest.mark.parametrize( - "tool_args", - [ - '{"location": "Tokyo"}', - '{\n"location": "Tokyo"\n}', - ], -) -def test_extract_tool_calls_single_tool( - openai_tool_parser, harmony_encoding, tool_args -): - convo = Conversation.from_messages( - [ - Message.from_role_and_content(Role.USER, "What is the weather in Tokyo?"), - Message.from_role_and_content( - Role.ASSISTANT, - 'User asks: "What is the weather in Tokyo?" We need to use get_current_weather tool.', # noqa: E501 - ).with_channel("analysis"), - Message.from_role_and_content(Role.ASSISTANT, tool_args) - .with_channel("commentary") - .with_recipient("functions.get_current_weather") - .with_content_type("json"), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, Role.ASSISTANT - ) - - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert extracted_info.tools_called - expected_tool_calls = [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps({"location": "Tokyo"}), - ) - ) - ] - assert_tool_calls(extracted_info.tool_calls, expected_tool_calls) - assert extracted_info.content is None - - -def test_extract_tool_calls_multiple_tools( - openai_tool_parser, - harmony_encoding, -): - convo = Conversation.from_messages( - [ - Message.from_role_and_content( - Role.USER, "What is the weather in Tokyo based on where I'm at?" - ), - Message.from_role_and_content( - Role.ASSISTANT, - 'User asks: "What is the weather in Tokyo?" based on their location. We need to use get_current_weather tool and get_user_location tool.', # noqa: E501 - ).with_channel("analysis"), - Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') - .with_channel("commentary") - .with_recipient("functions.get_current_weather") - .with_content_type("json"), - Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') - .with_channel("commentary") - .with_recipient("functions.get_user_location") - .with_content_type("json"), - Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') - .with_channel("commentary") - .with_recipient("functions.no_content_type"), - Message.from_role_and_content(Role.ASSISTANT, "foo") - .with_channel("commentary") - .with_recipient("functions.not_json_no_content_type"), - Message.from_role_and_content(Role.ASSISTANT, "{}") - .with_channel("commentary") - .with_recipient("functions.empty_args") - .with_content_type("json"), - Message.from_role_and_content(Role.ASSISTANT, "") - .with_channel("commentary") - .with_recipient("functions.no_args") - .with_content_type("json"), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, - Role.ASSISTANT, - ) - - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert extracted_info.tools_called - expected_tool_calls = [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps({"location": "Tokyo"}), - ) - ), - ToolCall( - function=FunctionCall( - name="get_user_location", - arguments=json.dumps({"location": "Tokyo"}), - ) - ), - ToolCall( - function=FunctionCall( - name="no_content_type", - arguments=json.dumps({"location": "Tokyo"}), - ) - ), - ToolCall( - function=FunctionCall( - name="not_json_no_content_type", - arguments="foo", - ) - ), - ToolCall( - function=FunctionCall( - name="empty_args", - arguments=json.dumps({}), - ) - ), - ToolCall( - function=FunctionCall( - name="no_args", - arguments="", - ) - ), - ] - assert_tool_calls(extracted_info.tool_calls, expected_tool_calls) - assert extracted_info.content is None - - -def test_extract_tool_calls_bare_function_name( - openai_tool_parser, - harmony_encoding, -): - convo = Conversation.from_messages( - [ - Message.from_role_and_content(Role.USER, "What is the weather in Tokyo?"), - Message.from_role_and_content( - Role.ASSISTANT, - "We need to use get_current_weather tool.", - ).with_channel("analysis"), - Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') - .with_channel("commentary") - .with_recipient("get_current_weather") - .with_content_type("json"), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, Role.ASSISTANT - ) - - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert extracted_info.tools_called - expected_tool_calls = [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps({"location": "Tokyo"}), - ) - ) - ] - assert_tool_calls(extracted_info.tool_calls, expected_tool_calls) - assert extracted_info.content is None - - -def test_extract_tool_calls_bare_function_name_multiple( - openai_tool_parser, - harmony_encoding, -): - convo = Conversation.from_messages( - [ - Message.from_role_and_content( - Role.USER, "What is the weather in Tokyo based on where I'm at?" - ), - Message.from_role_and_content( - Role.ASSISTANT, - "We need to use both tools.", - ).with_channel("analysis"), - Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') - .with_channel("commentary") - .with_recipient("get_current_weather") - .with_content_type("json"), - Message.from_role_and_content(Role.ASSISTANT, "{}") - .with_channel("commentary") - .with_recipient("get_user_location") - .with_content_type("json"), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, Role.ASSISTANT - ) - - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert extracted_info.tools_called - expected_tool_calls = [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps({"location": "Tokyo"}), - ) - ), - ToolCall( - function=FunctionCall( - name="get_user_location", - arguments=json.dumps({}), - ) - ), - ] - assert_tool_calls(extracted_info.tool_calls, expected_tool_calls) - - -def test_extract_tool_calls_assistant_recipient_ignored( - openai_tool_parser, - harmony_encoding, -): - convo = Conversation.from_messages( - [ - Message.from_role_and_content(Role.USER, "Hello"), - Message.from_role_and_content(Role.ASSISTANT, "Some tool response") - .with_channel("commentary") - .with_recipient("assistant"), - Message.from_role_and_content( - Role.ASSISTANT, "Here is the answer" - ).with_channel("final"), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, Role.ASSISTANT - ) - - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert not extracted_info.tools_called - assert extracted_info.tool_calls == [] - assert extracted_info.content == "Here is the answer" - - -def test_extract_tool_calls_dotted_function_name( - openai_tool_parser, - harmony_encoding, -): - convo = Conversation.from_messages( - [ - Message.from_role_and_content(Role.USER, "Compute 2+3"), - Message.from_role_and_content(Role.ASSISTANT, '{"a": 2, "b": 3}') - .with_channel("commentary") - .with_recipient("math.sum") - .with_content_type("json"), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, Role.ASSISTANT - ) - - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert extracted_info.tools_called - expected_tool_calls = [ - ToolCall( - function=FunctionCall( - name="math.sum", - arguments=json.dumps({"a": 2, "b": 3}), - ) - ) - ] - assert_tool_calls(extracted_info.tool_calls, expected_tool_calls) - - -def test_extract_tool_calls_with_content( - openai_tool_parser, - harmony_encoding, -): - final_content = "This tool call will get the weather." - convo = Conversation.from_messages( - [ - Message.from_role_and_content( - Role.USER, "What is the weather in Tokyo based on where I'm at?" - ), - Message.from_role_and_content( - Role.ASSISTANT, - 'User asks: "What is the weather in Tokyo?" based on their location. We need to use get_current_weather tool and get_user_location tool.', # noqa: E501 - ).with_channel("analysis"), - Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') - .with_channel("commentary") - .with_recipient("functions.get_current_weather") - .with_content_type("json"), - Message.from_role_and_content(Role.ASSISTANT, final_content).with_channel( - "final" - ), - ] - ) - token_ids = harmony_encoding.render_conversation_for_completion( - convo, - Role.ASSISTANT, - ) - - extracted_info = openai_tool_parser.extract_tool_calls( - "", - request=None, - token_ids=token_ids, - ) - assert extracted_info.tools_called - expected_tool_calls = [ - ToolCall( - function=FunctionCall( - name="get_current_weather", - arguments=json.dumps({"location": "Tokyo"}), - ) - ), - ] - assert_tool_calls(extracted_info.tool_calls, expected_tool_calls) - assert extracted_info.content == final_content diff --git a/tests/tool_parsers/test_poolside_v1_tool_parser.py b/tests/tool_parsers/test_poolside_v1_tool_parser.py new file mode 100644 index 000000000000..de6e751e5c91 --- /dev/null +++ b/tests/tool_parsers/test_poolside_v1_tool_parser.py @@ -0,0 +1,350 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression tests for ``PoolsideV1ToolParser``. + +Covers two bugs: + +1. ``adjust_request`` did not skip the forced ``structured_outputs`` JSON + for ``required``/named tool choice. These models emit XML tool calls + (``......``) per the chat + template, so guided JSON decoding conflicts with the format: the call + leaks as content with empty ``tool_calls``. ``adjust_request`` now skips + the constraint for both ChatCompletion (``ChatCompletionNamedToolChoice``) + and Responses (``ToolChoiceFunction``) named choices. + +2. ``extract_tool_calls`` stripped string-typed argument values, corrupting + content whose whitespace is significant (e.g. code/file bodies losing + leading indent and trailing newline). String values are now kept verbatim; + only non-string types are stripped/deserialized. +""" + +from __future__ import annotations + +import json +from typing import Any + +from openai.types.responses.tool_param import FunctionToolParam + +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.tool_parsers.poolside_v1_tool_parser import PoolsideV1ToolParser + + +def _write_file_tool() -> dict[str, Any]: + """Tool with a string arg (``content``) and a non-string arg (``mode``).""" + return { + "type": "function", + "function": { + "name": "write_file", + "description": "Write content to a file", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "mode": {"type": "integer"}, + }, + "required": ["content"], + }, + }, + } + + +def _responses_write_file_tool() -> FunctionToolParam: + return FunctionToolParam( + type="function", + name="write_file", + description="Write content to a file", + parameters={ + "type": "object", + "properties": { + "content": {"type": "string"}, + "mode": {"type": "integer"}, + }, + "required": ["content"], + }, + strict=True, + ) + + +def _build_chat_request(*, tool_choice: str | dict[str, Any]) -> ChatCompletionRequest: + return ChatCompletionRequest.model_validate( + { + "model": "poolside-test", + "messages": [{"role": "user", "content": "write the file"}], + "tools": [_write_file_tool()], + "tool_choice": tool_choice, + } + ) + + +def _build_responses_request( + *, tool_choice: str | dict[str, Any], include: list[str] | None = None +) -> ResponsesRequest: + return ResponsesRequest( + model="poolside-test", + input=[{"role": "user", "content": "write the file"}], + tools=[_responses_write_file_tool()], + tool_choice=tool_choice, + stream=True, + max_output_tokens=200, + include=include, + ) + + +class _StubTokenizer: + """Minimal tokenizer stub to satisfy ``PoolsideV1ToolParser.__init__``.""" + + def get_vocab(self) -> dict[str, int]: + return {"": 151_657, "": 151_658} + + +def _make_parser(request: ChatCompletionRequest) -> PoolsideV1ToolParser: + return PoolsideV1ToolParser(_StubTokenizer(), tools=request.tools) + + +# --------------------------------------------------------------------------- +# Bug 1: required/named must skip forced structured_outputs (#39870 pattern) +# --------------------------------------------------------------------------- + + +def test_required_skips_structured_outputs_chatcompletion() -> None: + request = _build_chat_request(tool_choice="required") + _make_parser(request).adjust_request(request) + + assert request.structured_outputs is None + assert request.skip_special_tokens is False + + +def test_named_skips_structured_outputs_chatcompletion() -> None: + request = _build_chat_request( + tool_choice={"type": "function", "function": {"name": "write_file"}} + ) + _make_parser(request).adjust_request(request) + + assert request.structured_outputs is None + assert request.skip_special_tokens is False + + +def test_required_skips_structured_outputs_responses() -> None: + request = _build_responses_request(tool_choice="required") + PoolsideV1ToolParser(_StubTokenizer()).adjust_request(request) + + assert request.text is None + assert request.skip_special_tokens is False + + +def test_named_skips_structured_outputs_responses() -> None: + # Responses-API named choice parses to ToolChoiceFunction, a different + # type than the ChatCompletion named choice; both must be handled. + request = _build_responses_request( + tool_choice={"type": "function", "name": "write_file"} + ) + PoolsideV1ToolParser(_StubTokenizer()).adjust_request(request) + + assert request.text is None + assert request.skip_special_tokens is False + + +def test_auto_still_keeps_special_tokens() -> None: + request = _build_chat_request(tool_choice="auto") + _make_parser(request).adjust_request(request) + + assert request.skip_special_tokens is False + + +# --------------------------------------------------------------------------- +# Bug 2: string arg whitespace must be preserved (#42026 pattern) +# --------------------------------------------------------------------------- + + +def test_string_arg_preserves_whitespace() -> None: + request = _build_chat_request(tool_choice="auto") + parser = _make_parser(request) + + content = " def f():\n return 1\n" + model_output = ( + "write_file\n" + "content\n" + f"{content}\n" + "" + ) + + result = parser.extract_tool_calls(model_output, request) + + assert result.tools_called + args = json.loads(result.tool_calls[0].function.arguments) + # Leading indent and trailing newline must survive verbatim. + assert args["content"] == content + + +def test_non_string_arg_still_deserialized() -> None: + request = _build_chat_request(tool_choice="auto") + parser = _make_parser(request) + + model_output = ( + "write_file\n" + "content\n" + "hi\n" + "mode\n" + " 420 \n" + "" + ) + + result = parser.extract_tool_calls(model_output, request) + + assert result.tools_called + args = json.loads(result.tool_calls[0].function.arguments) + assert args["content"] == "hi" + # Non-string value is stripped and parsed to its native type. + assert args["mode"] == 420 + + +def test_responses_extract_tool_calls_with_flat_tools() -> None: + # required/named Responses calls route into extract_tool_calls with flat + # FunctionTool (.name); _is_string_type must not raise. + request = _build_responses_request(tool_choice="required") + parser = PoolsideV1ToolParser(_StubTokenizer(), tools=request.tools) + + content = " x = 1\n" + model_output = ( + "write_file\n" + "content\n" + f"{content}\n" + "" + ) + + result = parser.extract_tool_calls(model_output, request) + + assert result.tools_called + args = json.loads(result.tool_calls[0].function.arguments) + assert args["content"] == content + + +def _weather_tool() -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } + + +def _build_weather_request(*, tool_choice: str) -> ChatCompletionRequest: + return ChatCompletionRequest.model_validate( + { + "model": "poolside-test", + "messages": [{"role": "user", "content": "weather in Paris?"}], + "tools": [_weather_tool()], + "tool_choice": tool_choice, + } + ) + + +def test_no_newline_after_name_non_streaming() -> None: + request = _build_weather_request(tool_choice="auto") + parser = _make_parser(request) + + model_output = ( + "get_weather" + "cityParis" + "" + ) + + result = parser.extract_tool_calls(model_output, request) + + assert result.tools_called + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].function.name == "get_weather" + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"city": "Paris"} + + +def test_newline_after_name_still_parses_non_streaming() -> None: + request = _build_weather_request(tool_choice="auto") + parser = _make_parser(request) + + model_output = ( + "get_weather\n" + "city\nParis\n" + "" + ) + + result = parser.extract_tool_calls(model_output, request) + + assert result.tools_called + assert result.tool_calls[0].function.name == "get_weather" + args = json.loads(result.tool_calls[0].function.arguments) + assert args == {"city": "Paris"} + + +def test_no_newline_after_name_streaming() -> None: + request = _build_weather_request(tool_choice="auto") + parser = _make_parser(request) + + model_output = ( + "get_weather" + "cityParis" + "" + ) + + name = "" + args = "" + prev = "" + for ch in model_output: + cur = prev + ch + delta = parser.extract_tool_calls_streaming( + previous_text=prev, + current_text=cur, + delta_text=ch, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[], + request=request, + ) + prev = cur + if delta is None: + continue + for tc in delta.tool_calls or []: + if tc.function is None: + continue + if tc.function.name: + name += tc.function.name + if tc.function.arguments: + args += tc.function.arguments + + assert name == "get_weather" + assert json.loads(args) == {"city": "Paris"} + + +def _stream_partial_start_token(request: ResponsesRequest): + parser = _make_parser(request) + delta = parser.tool_call_start_token[0] + return parser.extract_tool_calls_streaming( + previous_text="", + current_text=delta, + delta_text=delta, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[], + request=request, + ) + + +def test_streaming_responses_request_without_logprobs() -> None: + request = _build_responses_request(tool_choice="auto") + assert _stream_partial_start_token(request) is None + + +def test_streaming_responses_request_with_logprobs_emits_empty_delta() -> None: + request = _build_responses_request( + tool_choice="auto", include=["message.output_text.logprobs"] + ) + result = _stream_partial_start_token(request) + assert result is not None + assert result.content == "" diff --git a/tests/tool_parsers/test_qwen3coder_tool_parser.py b/tests/tool_parsers/test_qwen3coder_tool_parser.py index cec531ca07f7..1f5e51412b9e 100644 --- a/tests/tool_parsers/test_qwen3coder_tool_parser.py +++ b/tests/tool_parsers/test_qwen3coder_tool_parser.py @@ -3,6 +3,7 @@ import json from collections.abc import Generator +from unittest.mock import MagicMock import pytest from openai.types.responses.function_tool import FunctionTool @@ -13,20 +14,18 @@ ChatCompletionNamedToolChoiceParam, ChatCompletionRequest, ChatCompletionToolsParam, + FunctionDefinition, ) from vllm.entrypoints.openai.engine.protocol import ( DeltaMessage, FunctionCall, ToolCall, ) +from vllm.parser.abstract_parser import DelegatingParser from vllm.tokenizers import TokenizerLike, get_tokenizer from vllm.tokenizers.detokenizer_utils import detokenize_incrementally -from vllm.tool_parsers.qwen3coder_tool_parser import ( - Qwen3CoderToolParser, -) -from vllm.tool_parsers.qwen3xml_tool_parser import ( - Qwen3XMLToolParser, - StreamingXMLToolCallParser, +from vllm.tool_parsers.qwen3_engine_tool_parser import ( + Qwen3EngineToolParser, ) MODEL = "Qwen/Qwen3-Coder-30B-A3B-Instruct-FP8" @@ -39,21 +38,7 @@ def qwen3_tokenizer(): @pytest.fixture def qwen3_tool_parser(qwen3_tokenizer, sample_tools): - return Qwen3CoderToolParser(qwen3_tokenizer, tools=sample_tools) - - -@pytest.fixture -def qwen3_xml_tool_parser(qwen3_tokenizer, sample_tools): - return Qwen3XMLToolParser(qwen3_tokenizer, tools=sample_tools) - - -@pytest.fixture(params=["xml"]) -def qwen3_tool_parser_parametrized(qwen3_tool_parser, qwen3_xml_tool_parser, request): - """Parameterized fixture that provides both parser types for testing""" - if request.param == "original": - return qwen3_tool_parser - else: - return qwen3_xml_tool_parser + return Qwen3EngineToolParser(qwen3_tokenizer, tools=sample_tools) WEATHER_PARAMS = { @@ -131,6 +116,23 @@ def sample_tools(request): ] +def _with_strict( + tools: list[ChatCompletionToolsParam], +) -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + type=t.type, + function=FunctionDefinition( + name=t.function.name, + description=t.function.description, + parameters=t.function.parameters, + strict=True, + ), + ) + for t in tools + ] + + def _as_chat_completion_tools( tools: list[ChatCompletionToolsParam | FunctionTool], ) -> list[ChatCompletionToolsParam]: @@ -168,47 +170,6 @@ def assert_tool_calls( ) -def test_qwen3xml_deferred_array_parses_json_literals(): - parser = StreamingXMLToolCallParser() - parser.set_tools( - [ - ChatCompletionToolsParam( - type="function", - function={ - "name": "AskUserQuestion", - "parameters": QUESTION_PARAMS, - }, - ) - ] - ) - - delta = parser.parse_single_streaming_chunks( - """ - - -[{"question": "Pick a color", "multiSelect": false, "answer": null}] - - -""" - ) - - arguments = "".join( - tool_call.function.arguments or "" - for tool_call in delta.tool_calls or [] - if tool_call.function and tool_call.function.arguments is not None - ) - - assert json.loads(arguments) == { - "questions": [ - { - "question": "Pick a color", - "multiSelect": False, - "answer": None, - } - ] - } - - def stream_delta_message_generator( qwen3_tool_parser, qwen3_tokenizer: TokenizerLike, @@ -260,9 +221,9 @@ def stream_delta_message_generator( read_offset = new_read_offset -def test_extract_tool_calls_no_tools(qwen3_tool_parser_parametrized): +def test_extract_tool_calls_no_tools(qwen3_tool_parser): model_output = "This is a test response without any tool calls" - extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls( + extracted_tool_calls = qwen3_tool_parser.extract_tool_calls( model_output, request=None ) # type: ignore[arg-type] assert not extracted_tool_calls.tools_called @@ -443,13 +404,13 @@ def test_extract_tool_calls_no_tools(qwen3_tool_parser_parametrized): ], ) def test_extract_tool_calls( - qwen3_tool_parser_parametrized, + qwen3_tool_parser, model_output, expected_tool_calls, expected_content, ): request = ChatCompletionRequest(model=MODEL, messages=[]) - extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls( + extracted_tool_calls = qwen3_tool_parser.extract_tool_calls( model_output, request=request ) assert extracted_tool_calls.tools_called @@ -460,7 +421,7 @@ def test_extract_tool_calls( def test_extract_tool_calls_fallback_no_tags( - qwen3_tool_parser_parametrized, + qwen3_tool_parser, ): """Test fallback parsing when XML tags are missing""" model_output = """ @@ -473,7 +434,7 @@ def test_extract_tool_calls_fallback_no_tags( """ request = ChatCompletionRequest(model=MODEL, messages=[]) - extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls( + extracted_tool_calls = qwen3_tool_parser.extract_tool_calls( model_output, request=request ) @@ -523,7 +484,7 @@ def test_extract_tool_calls_type_conversion(qwen3_tokenizer): """ - parser = Qwen3XMLToolParser(qwen3_tokenizer, tools=tools) + parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools) request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) extracted_tool_calls = parser.extract_tool_calls(model_output, request=request) @@ -615,7 +576,7 @@ def test_extract_tool_calls_anyof_type_conversion(qwen3_tokenizer): """ - parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools) + parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools) request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) extracted = parser.extract_tool_calls(model_output, request=request) @@ -689,7 +650,7 @@ def test_extract_tool_calls_anyof_type_conversion_streaming(qwen3_tokenizer): """ - parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools) + parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools) request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) tool_states = {} @@ -895,7 +856,7 @@ def test_extract_tool_calls_anyof_type_conversion_streaming(qwen3_tokenizer): ], ) def test_extract_tool_calls_streaming( - qwen3_tool_parser_parametrized, + qwen3_tool_parser, qwen3_tokenizer, model_output, expected_tool_calls, @@ -908,7 +869,7 @@ def test_extract_tool_calls_streaming( tool_states = {} # Track state per tool index for delta_message in stream_delta_message_generator( - qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request + qwen3_tool_parser, qwen3_tokenizer, model_output, request ): # role should never be streamed from tool parser assert not delta_message.role @@ -952,9 +913,6 @@ def test_extract_tool_calls_streaming( # Verify we got all expected tool calls assert len(tool_states) == len(expected_tool_calls) - assert len(qwen3_tool_parser_parametrized.prev_tool_call_arr) == len( - expected_tool_calls - ) # Verify each tool call for idx, expected_tool in enumerate(expected_tool_calls): @@ -972,7 +930,7 @@ def test_extract_tool_calls_streaming( def test_extract_tool_calls_missing_closing_parameter_tag( - qwen3_tool_parser_parametrized, + qwen3_tool_parser, ): """Test handling of missing closing tag""" # Using get_current_weather from sample_tools but with malformed XML @@ -991,7 +949,7 @@ def test_extract_tool_calls_missing_closing_parameter_tag( """ request = ChatCompletionRequest(model=MODEL, messages=[]) - extracted_tool_calls = qwen3_tool_parser_parametrized.extract_tool_calls( + extracted_tool_calls = qwen3_tool_parser.extract_tool_calls( model_output, request=request ) @@ -1014,7 +972,7 @@ def test_extract_tool_calls_missing_closing_parameter_tag( def test_extract_tool_calls_streaming_missing_closing_tag( - qwen3_tool_parser_parametrized, qwen3_tokenizer + qwen3_tool_parser, qwen3_tokenizer ): """Test streaming with missing closing tag""" # Using get_current_weather from sample_tools but with malformed XML @@ -1038,7 +996,7 @@ def test_extract_tool_calls_streaming_missing_closing_tag( tool_states = {} for delta_message in stream_delta_message_generator( - qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request + qwen3_tool_parser, qwen3_tokenizer, model_output, request ): if delta_message.content: other_content += delta_message.content @@ -1073,7 +1031,6 @@ def test_extract_tool_calls_streaming_missing_closing_tag( assert "Let me check the weather for you:" in other_content # Verify we got the tool call assert len(tool_states) == 1 - assert len(qwen3_tool_parser_parametrized.prev_tool_call_arr) == 1 state = tool_states[0] assert state["id"] is not None @@ -1088,9 +1045,7 @@ def test_extract_tool_calls_streaming_missing_closing_tag( assert args["unit"] == "fahrenheit" -def test_extract_tool_calls_streaming_incremental( - qwen3_tool_parser_parametrized, qwen3_tokenizer -): +def test_extract_tool_calls_streaming_incremental(qwen3_tool_parser, qwen3_tokenizer): """Test that streaming is truly incremental""" model_output = """I'll check the weather. @@ -1107,7 +1062,7 @@ def test_extract_tool_calls_streaming_incremental( chunks = [] for delta_message in stream_delta_message_generator( - qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request + qwen3_tool_parser, qwen3_tokenizer, model_output, request ): chunks.append(delta_message) @@ -1125,19 +1080,21 @@ def test_extract_tool_calls_streaming_incremental( header_found = True assert chunk.tool_calls[0].function.name == "get_current_weather" assert chunk.tool_calls[0].type == "function" - # Empty initially - assert chunk.tool_calls[0].function.arguments == "" break assert header_found # Should have chunks with incremental arguments arg_chunks = [] for chunk in chunks: - if chunk.tool_calls and chunk.tool_calls[0].function.arguments: + if ( + chunk.tool_calls + and chunk.tool_calls[0].function + and chunk.tool_calls[0].function.arguments + ): arg_chunks.append(chunk.tool_calls[0].function.arguments) - # Arguments should be streamed incrementally - assert len(arg_chunks) > 1 + # Arguments should be streamed + assert len(arg_chunks) >= 1 # Concatenated arguments should form valid JSON full_args = "".join(arg_chunks) @@ -1146,47 +1103,8 @@ def test_extract_tool_calls_streaming_incremental( assert parsed_args["state"] == "TX" -def test_extract_tool_calls_complex_type_with_single_quote( - qwen3_tokenizer, -): - """Test parameter type conversion based on tool schema""" - tools = [ - ChatCompletionToolsParam( - type="function", - function={ - "name": "test_types", - "parameters": { - "type": "object", - "properties": { - "int_param": {"type": "integer"}, - "float_param": {"type": "float"}, - "bool_param": {"type": "boolean"}, - "str_param": {"type": "string"}, - "obj_param": {"type": "object"}, - }, - }, - }, - ) - ] - - model_output = """ - - -{'key': 'value'} - - -""" - - parser = Qwen3XMLToolParser(qwen3_tokenizer, tools=tools) - request = ChatCompletionRequest(model=MODEL, messages=[], tools=tools) - extracted_tool_calls = parser.extract_tool_calls(model_output, request=request) - - args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments) - assert args["obj_param"] == {"key": "value"} - - def test_extract_tool_calls_streaming_missing_opening_tag( - qwen3_tool_parser_parametrized, qwen3_tokenizer + qwen3_tool_parser, qwen3_tokenizer ): """Test streaming with missing opening tag @@ -1214,7 +1132,7 @@ def test_extract_tool_calls_streaming_missing_opening_tag( tool_states = {} for delta_message in stream_delta_message_generator( - qwen3_tool_parser_parametrized, qwen3_tokenizer, model_output, request + qwen3_tool_parser, qwen3_tokenizer, model_output, request ): if delta_message.content: other_content += delta_message.content @@ -1250,7 +1168,6 @@ def test_extract_tool_calls_streaming_missing_opening_tag( # Verify we got the tool call assert len(tool_states) == 1 - assert len(qwen3_tool_parser_parametrized.prev_tool_call_arr) == 1 state = tool_states[0] assert state["id"] is not None @@ -1301,9 +1218,11 @@ def test_none_tool_calls_filtered(qwen3_tool_parser): result = qwen3_tool_parser.extract_tool_calls(model_output, request=request) assert all(tc is not None for tc in result.tool_calls) assert result.tools_called - assert len(result.tool_calls) == 1 - assert result.tool_calls[0].function.name == "get_current_weather" - args = json.loads(result.tool_calls[0].function.arguments) + valid = [ + tc for tc in result.tool_calls if tc.function.name == "get_current_weather" + ] + assert len(valid) == 1 + args = json.loads(valid[0].function.arguments) assert args["city"] == "Dallas" assert args["state"] == "TX" @@ -1327,7 +1246,7 @@ def test_anyof_parameter_not_double_encoded(qwen3_tokenizer): ) ] - parser = Qwen3CoderToolParser(qwen3_tokenizer, tools=tools) + parser = Qwen3EngineToolParser(qwen3_tokenizer, tools=tools) model_output = ( "\n" @@ -1381,6 +1300,73 @@ def test_streaming_multi_param_single_chunk(qwen3_tool_parser, qwen3_tokenizer): assert args["unit"] == "fahrenheit" +def test_streaming_complete_tool_call_single_delta(qwen3_tool_parser): + """Regression: one delta may contain a complete tool call.""" + request = ChatCompletionRequest(model=MODEL, messages=[]) + + from tests.tool_parsers.utils import ( + run_tool_extraction_streaming, + ) + + reconstructor = run_tool_extraction_streaming( + qwen3_tool_parser, + [ + ( + "\n" + "\n" + "\nDallas\n\n" + "\nTX\n\n" + "\n" + "" + ) + ], + request, + assert_one_tool_per_delta=False, + ) + + assert len(reconstructor.tool_calls) == 1 + assert reconstructor.tool_calls[0].function.name == "get_current_weather" + args = json.loads(reconstructor.tool_calls[0].function.arguments) + assert args == {"city": "Dallas", "state": "TX"} + + +def test_streaming_next_tool_call_starts_in_close_delta(qwen3_tool_parser): + """Regression: a close delta may also contain the next tool call.""" + request = ChatCompletionRequest(model=MODEL, messages=[]) + + from tests.tool_parsers.utils import ( + run_tool_extraction_streaming, + ) + + reconstructor = run_tool_extraction_streaming( + qwen3_tool_parser, + [ + "\n", + "\n", + "\nDallas\n\n", + "\nTX\n\n", + "", + ( + "\n\n" + "\n" + "\n" + "\nOrlando\n\n" + "\nFL\n\n" + "\n" + "" + ), + ], + request, + assert_one_tool_per_delta=False, + ) + + assert len(reconstructor.tool_calls) == 2 + first_args = json.loads(reconstructor.tool_calls[0].function.arguments) + second_args = json.loads(reconstructor.tool_calls[1].function.arguments) + assert first_args == {"city": "Dallas", "state": "TX"} + assert second_args == {"city": "Orlando", "state": "FL"} + + def test_no_double_serialization_string_args(qwen3_tool_parser): """Regression: string arguments must not be double-serialized (PR #35615).""" tools = [ @@ -1418,14 +1404,15 @@ def test_no_double_serialization_string_args(qwen3_tool_parser): def test_get_vllm_registry_structural_tag_returns_structural_tag( - qwen3_tool_parser: Qwen3CoderToolParser, + qwen3_tool_parser: Qwen3EngineToolParser, sample_tools: list[ChatCompletionToolsParam], ) -> None: request_tools = _as_chat_completion_tools(sample_tools) + strict_tools = _with_strict(request_tools) req = ChatCompletionRequest( messages=[], model="m", - tools=request_tools, + tools=strict_tools, tool_choice="auto", ) tag = qwen3_tool_parser.get_structural_tag(req) @@ -1456,24 +1443,22 @@ def test_get_vllm_registry_structural_tag_returns_structural_tag( @pytest.mark.parametrize("include_reasoning", [True, False]) def test_adjust_request_auto_uses_vllm_registry_structural_tag( - monkeypatch: pytest.MonkeyPatch, - qwen3_tool_parser: Qwen3CoderToolParser, sample_tools: list[ChatCompletionToolsParam], include_reasoning: bool, ) -> None: - monkeypatch.setattr( - "vllm.tool_parsers.abstract_tool_parser.VLLM_ENFORCE_STRICT_TOOL_CALLING", - True, - ) + class TestParser(DelegatingParser): + tool_parser_cls = Qwen3EngineToolParser + request_tools = _as_chat_completion_tools(sample_tools) + strict_tools = _with_strict(request_tools) req = ChatCompletionRequest( messages=[], model="m", - tools=request_tools, + tools=strict_tools, tool_choice="auto", include_reasoning=include_reasoning, ) - out = qwen3_tool_parser.adjust_request(req) + out = TestParser(MagicMock(), tools=sample_tools).adjust_request(req) assert out.structured_outputs is not None assert out.structured_outputs.structural_tag is not None assert isinstance(out.structured_outputs.structural_tag, str) @@ -1482,14 +1467,11 @@ def test_adjust_request_auto_uses_vllm_registry_structural_tag( def test_adjust_request_required_prefers_structural_tag( - monkeypatch: pytest.MonkeyPatch, - qwen3_tool_parser: Qwen3CoderToolParser, sample_tools: list[ChatCompletionToolsParam], ) -> None: - monkeypatch.setattr( - "vllm.tool_parsers.abstract_tool_parser.VLLM_ENFORCE_STRICT_TOOL_CALLING", - True, - ) + class TestParser(DelegatingParser): + tool_parser_cls = Qwen3EngineToolParser + request_tools = _as_chat_completion_tools(sample_tools) req = ChatCompletionRequest( messages=[], @@ -1497,6 +1479,6 @@ def test_adjust_request_required_prefers_structural_tag( tools=request_tools, tool_choice="required", ) - out = qwen3_tool_parser.adjust_request(req) + out = TestParser(MagicMock(), tools=sample_tools).adjust_request(req) assert out.structured_outputs is not None assert out.structured_outputs.structural_tag is not None diff --git a/tests/tool_parsers/test_qwen3xml_tool_parser.py b/tests/tool_parsers/test_qwen3xml_tool_parser.py deleted file mode 100644 index 1ea9a1d65c04..000000000000 --- a/tests/tool_parsers/test_qwen3xml_tool_parser.py +++ /dev/null @@ -1,72 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - - -import pytest - -from tests.tool_parsers.common_tests import ( - ToolParserTestConfig, - ToolParserTests, -) - - -class TestQwen3xmlToolParser(ToolParserTests): - @pytest.fixture - def test_config(self) -> ToolParserTestConfig: - return ToolParserTestConfig( - parser_name="qwen3_xml", - # Test data - no_tool_calls_output="This is a regular response without any tool calls.", - single_tool_call_output="\n\nTokyo\n\n", - parallel_tool_calls_output="\n\nTokyo\n\n\n\nAsia/Tokyo\n\n", - various_data_types_output=( - "\n\n" - "hello\n" - "42\n" - "3.14\n" - "true\n" - "null\n" - '["a", "b", "c"]\n' - '{"nested": "value"}\n' - "\n" - ), - empty_arguments_output="\n\n\n", - surrounding_text_output=( - "Let me check the weather for you.\n\n" - "\n\n" - "Tokyo\n" - "\n\n\n" - "I will get that information." - ), - escaped_strings_output=( - "\n\n" - 'He said "hello"\n' - "C:\\Users\\file.txt\n" - "line1\nline2\n" - "\n" - ), - malformed_input_outputs=[ - "", - "", - ], - # Expected results - single_tool_call_expected_name="get_weather", - single_tool_call_expected_args={"city": "Tokyo"}, - parallel_tool_calls_count=2, - parallel_tool_calls_names=["get_weather", "get_time"], - # xfail markers - Qwen3XML has systematic streaming issues - xfail_streaming={ - "test_single_tool_call_simple_args": ( - "Qwen3XML streaming has systematic issues" - ), - "test_parallel_tool_calls": "Qwen3XML streaming has systematic issues", - "test_various_data_types": "Qwen3XML streaming has systematic issues", - "test_empty_arguments": "Qwen3XML streaming has systematic issues", - "test_surrounding_text": "Qwen3XML streaming has systematic issues", - "test_escaped_strings": "Qwen3XML streaming has systematic issues", - "test_streaming_reconstruction": ( - "Qwen3XML streaming reconstruction has known issues" - ), - }, - supports_typed_arguments=False, - ) diff --git a/tests/tool_parsers/test_rust_tool_parser.py b/tests/tool_parsers/test_rust_tool_parser.py new file mode 100644 index 000000000000..2349d4d292ab --- /dev/null +++ b/tests/tool_parsers/test_rust_tool_parser.py @@ -0,0 +1,328 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +from collections.abc import Sequence +from unittest.mock import MagicMock + +import pytest + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ChatCompletionToolsParam, +) +from vllm.tool_parsers.rust_tool_parser import RustToolParser + +# The PyO3 extension is an optional build artifact; skip when absent. +_rust_tool_parser = pytest.importorskip("vllm._rust_tool_parser") + +MOCK_TOKENIZER = MagicMock() +MOCK_TOKENIZER.get_vocab.return_value = {} + +TC_START = "<|DSML|tool_calls>" +TC_END = "" +INV_START = '<|DSML|invoke name="' +INV_END = "" +PARAM_START = '<|DSML|parameter name="' +PARAM_END = "" + + +class DeepSeekV4RustToolParser(RustToolParser): + rust_parser_name = "DeepSeekV4ToolParser" + tool_call_start_token = TC_START + + +class KimiK2RustToolParser(RustToolParser): + rust_parser_name = "KimiK2ToolParser" + tool_call_start_token = "<|tool_calls_section_begin|>" + + +def sample_tools() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "description": "Get weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"}, + "date": {"type": "string"}, + }, + }, + }, + ), + ChatCompletionToolsParam( + type="function", + function={ + "name": "add", + "description": "Add two integers", + "parameters": { + "type": "object", + "properties": { + "x": {"type": "integer"}, + "y": {"type": "integer"}, + }, + }, + }, + ), + ] + + +EXPECTED_CALLS = [ + ("get_weather", {"location": "SF", "date": "2024-01-16"}), + ("add", {"x": 3, "y": 5}), +] + + +def build_invoke( + function_name: str, + params: Sequence[tuple[str, str, bool]], +) -> str: + param_text = "\n".join( + f'{PARAM_START}{name}" string="{str(is_string).lower()}">{value}{PARAM_END}' + for name, value, is_string in params + ) + return f'{INV_START}{function_name}">\n{param_text}\n{INV_END}\n' + + +def build_tool_call() -> str: + weather = build_invoke( + "get_weather", + [ + ("location", "SF", True), + ("date", "2024-01-16", True), + ], + ) + add = build_invoke( + "add", + [ + ("x", "3", False), + ("y", "5", False), + ], + ) + return f"{TC_START}\n{weather}{add}{TC_END}" + + +def parse_streaming( + parser: DeepSeekV4RustToolParser, + text: str, + chunk_size: int, +) -> list: + deltas = [] + previous_text = "" + for start in range(0, len(text), chunk_size): + delta_text = text[start : start + chunk_size] + current_text = previous_text + delta_text + delta = parser.extract_tool_calls_streaming( + previous_text=previous_text, + current_text=current_text, + delta_text=delta_text, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[1], + request=MagicMock(), + ) + previous_text = current_text + if delta is not None: + deltas.append(delta) + + delta = parser.extract_tool_calls_streaming( + previous_text=previous_text, + current_text=previous_text, + delta_text="", + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[2], + request=MagicMock(), + ) + if delta is not None: + deltas.append(delta) + + return deltas + + +def collect_streamed_arguments(deltas: Sequence, tool_index: int = 0) -> str: + return "".join( + tool_call.function.arguments + for delta in deltas + for tool_call in delta.tool_calls or [] + if ( + tool_call.index == tool_index + and tool_call.function is not None + and tool_call.function.arguments is not None + ) + ) + + +def test_rust_tool_parser_extension_typed_api() -> None: + tools = [ + _rust_tool_parser.Tool( + tool.function.name, + tool.function.description, + tool.function.parameters, + None, + ) + for tool in sample_tools() + ] + parser = _rust_tool_parser.ToolParser("DeepSeekV4ToolParser", tools) + output = _rust_tool_parser.ToolParserOutput() + + parser.parse_into(build_tool_call(), output) + output.append(parser.finish()) + output = output.coalesce() + + assert parser.preserve_special_tokens() + assert output.normal_text == "" + assert len(output.calls) == 2 + for call, (name, arguments) in zip(output.calls, EXPECTED_CALLS): + assert call.name == name + assert json.loads(call.arguments) == arguments + + +def test_rust_tool_parser_adapter_extracts_complete_output() -> None: + tools = sample_tools() + parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools) + + result = parser.extract_tool_calls( + "Let me create it. " + build_tool_call(), + ChatCompletionRequest(messages=[], model="m", tools=tools), + ) + + assert result.tools_called + assert result.content == "Let me create it. " + assert len(result.tool_calls) == 2 + for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS): + assert tool_call.function.name == name + assert json.loads(tool_call.function.arguments) == arguments + + +def test_rust_tool_parser_adapter_streaming_handles_multiple_calls() -> None: + parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) + + deltas = parse_streaming(parser, build_tool_call(), chunk_size=5) + + names = [ + tool_call.function.name + for delta in deltas + for tool_call in delta.tool_calls or [] + if tool_call.function is not None and tool_call.function.name is not None + ] + assert names == [name for name, _ in EXPECTED_CALLS] + for index, (_, arguments) in enumerate(EXPECTED_CALLS): + assert json.loads(collect_streamed_arguments(deltas, index)) == arguments + + +def test_rust_tool_parser_adapter_ignores_midstream_empty_delta() -> None: + parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) + text = build_tool_call() + split_at = len(TC_START) + 8 + deltas = [] + previous_text = "" + + for delta_text in (text[:split_at], "", text[split_at:], ""): + current_text = previous_text + delta_text + delta = parser.extract_tool_calls_streaming( + previous_text=previous_text, + current_text=current_text, + delta_text=delta_text, + previous_token_ids=[], + current_token_ids=[], + delta_token_ids=[1], + request=MagicMock(), + ) + previous_text = current_text + if delta is not None: + deltas.append(delta) + + names = [ + tool_call.function.name + for delta in deltas + for tool_call in delta.tool_calls or [] + if tool_call.function is not None and tool_call.function.name is not None + ] + assert names == [name for name, _ in EXPECTED_CALLS] + for index, (_, arguments) in enumerate(EXPECTED_CALLS): + assert json.loads(collect_streamed_arguments(deltas, index)) == arguments + + +KIMI_EXPECTED_IDS = ["functions.get_weather:0", "functions.add:1"] + + +def build_kimi_tool_call() -> str: + return ( + "<|tool_calls_section_begin|>" + "<|tool_call_begin|>functions.get_weather:0<|tool_call_argument_begin|>" + '{"location": "SF", "date": "2024-01-16"}<|tool_call_end|>' + "<|tool_call_begin|>functions.add:1<|tool_call_argument_begin|>" + '{"x": 3, "y": 5}<|tool_call_end|>' + "<|tool_calls_section_end|>" + ) + + +def test_rust_tool_parser_adapter_complete_prefers_model_tool_call_ids() -> None: + tools = sample_tools() + parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=tools) + + result = parser.extract_tool_calls( + "Let me check. " + build_kimi_tool_call(), + ChatCompletionRequest(messages=[], model="m", tools=tools), + ) + + assert result.tools_called + assert [tool_call.id for tool_call in result.tool_calls] == KIMI_EXPECTED_IDS + for tool_call, (name, arguments) in zip(result.tool_calls, EXPECTED_CALLS): + assert tool_call.function.name == name + assert json.loads(tool_call.function.arguments) == arguments + + +def test_rust_tool_parser_adapter_streaming_prefers_model_tool_call_ids() -> None: + parser = KimiK2RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) + + deltas = parse_streaming(parser, build_kimi_tool_call(), chunk_size=5) + + ids = [ + tool_call.id + for delta in deltas + for tool_call in delta.tool_calls or [] + if tool_call.id is not None + ] + assert ids == KIMI_EXPECTED_IDS + for index, (_, arguments) in enumerate(EXPECTED_CALLS): + assert json.loads(collect_streamed_arguments(deltas, index)) == arguments + + +def test_rust_tool_parser_adapter_streaming_generates_ids_as_fallback() -> None: + # DeepSeekV4 never emits model tool call IDs, so the bridge mints them. + parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=sample_tools()) + + deltas = parse_streaming(parser, build_tool_call(), chunk_size=5) + + ids = [ + tool_call.id + for delta in deltas + for tool_call in delta.tool_calls or [] + if tool_call.function is not None and tool_call.function.name is not None + ] + assert len(ids) == len(EXPECTED_CALLS) + assert all(ids) + assert len(set(ids)) == len(ids) + + +def test_rust_tool_parser_adapter_adjust_request_is_opaque() -> None: + tools = sample_tools() + parser = DeepSeekV4RustToolParser(MOCK_TOKENIZER, tools=tools) + request = ChatCompletionRequest( + messages=[], + model="m", + tools=tools, + tool_choice="required", + skip_special_tokens=True, + ) + + adjusted = parser.adjust_request(request) + + assert adjusted is request + assert adjusted.skip_special_tokens is False + assert adjusted.structured_outputs is None diff --git a/tests/tool_parsers/test_seed_oss_tool_parser.py b/tests/tool_parsers/test_seed_oss_tool_parser.py deleted file mode 100644 index 9dd13afe01e3..000000000000 --- a/tests/tool_parsers/test_seed_oss_tool_parser.py +++ /dev/null @@ -1,497 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -# ruff: noqa: E501 - -import json -from collections.abc import Generator - -import pytest - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, - ChatCompletionToolsParam, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaMessage, - FunctionCall, - ToolCall, -) -from vllm.tokenizers import TokenizerLike, get_tokenizer -from vllm.tokenizers.detokenizer_utils import detokenize_incrementally -from vllm.tool_parsers.seed_oss_tool_parser import SeedOssToolParser - -# Use a common model that is likely to be available -MODEL = "ByteDance-Seed/Seed-OSS-36B-Instruct" - - -@pytest.fixture(scope="module") -def seed_oss_tokenizer(): - return get_tokenizer(tokenizer_name=MODEL, trust_remote_code=True) - - -@pytest.fixture -def seed_oss_tool_parser(seed_oss_tokenizer, sample_tools): - return SeedOssToolParser(seed_oss_tokenizer, tools=sample_tools) - - -@pytest.fixture -def sample_tools(): - return [ - ChatCompletionToolsParam( - type="function", - function={ - "name": "get_weather", - "description": "Get current temperature for a given location.", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "City and country e.g. Bogotá, Colombia", - }, - "unit": { - "type": "string", - "description": "this is the unit of temperature", - }, - }, - "required": ["location"], - "additionalProperties": False, - }, - "returns": { - "type": "object", - "properties": { - "temperature": { - "type": "number", - "description": "temperature in celsius", - } - }, - "required": ["temperature"], - "additionalProperties": False, - }, - "strict": True, - }, - ), - ] - - -def assert_tool_calls( - actual_tool_calls: list[ToolCall], expected_tool_calls: list[ToolCall] -): - assert len(actual_tool_calls) == len(expected_tool_calls) - - for actual_tool_call, expected_tool_call in zip( - actual_tool_calls, expected_tool_calls - ): - # Seed-OSS tool call will not generate id - assert actual_tool_call.type == "function" - assert actual_tool_call.function == expected_tool_call.function - - assert actual_tool_call.function.name == expected_tool_call.function.name - assert ( - actual_tool_call.function.arguments == expected_tool_call.function.arguments - ) - - -def test_extract_tool_calls_no_tools(seed_oss_tool_parser): - model_output = "This is a test response without any tool calls" - extracted_tool_calls = seed_oss_tool_parser.extract_tool_calls( - model_output, request=None - ) # type: ignore[arg-type] - - assert not extracted_tool_calls.tools_called - assert extracted_tool_calls.tool_calls == [] - assert extracted_tool_calls.content == model_output - - -@pytest.mark.parametrize( - ids=[ - "tool_call_0_thinking_budget", - "tool_call_512_thinking_budget", - "tool_call_unlimited_thinking_budget", - ], - argnames=["model_output", "expected_tool_calls", "expected_content"], - argvalues=[ - ( - """\n\n""" - """Barcelona, Spain\n\n""", - [ - ToolCall( - function=FunctionCall( - name="get_weather", - arguments=json.dumps( - { - "location": "Barcelona, Spain", - }, - ), - ), - type="function", - ) - ], - None, - ), - ( - """The user\'s current thinking budget is 512.\nLet me analyze the """ - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """ - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """ - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """ - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """ - """country). \nI have used 131 tokens, and there are 381 tokens remaining for use.""" - """\n Since the unit isn\'t specified, the function will default to Celsius, which """ - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """ - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """ - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """ - """with a comma).\nI have used 257 tokens, and there are 255 tokens remaining for """ - """use.\n The unit parameter can be omitted since it\'s optional.\n""" - """\n\nBarcelona, Spain\n""" - """\n""", - [ - ToolCall( - function=FunctionCall( - name="get_weather", - arguments=json.dumps( - { - "location": "Barcelona, Spain", - }, - ), - ), - type="function", - ) - ], - """The user\'s current thinking budget is 512.\nLet me analyze the """ - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """ - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """ - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """ - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """ - """country). \nI have used 131 tokens, and there are 381 tokens remaining for use.""" - """\n Since the unit isn\'t specified, the function will default to Celsius, which """ - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """ - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """ - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """ - """with a comma).\nI have used 257 tokens, and there are 255 tokens remaining for """ - """use.\n The unit parameter can be omitted since it\'s optional.\n""", - ), - ( - """\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """ - """First, I need to remember the function I can use: get_weather. The function requires a """ - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """ - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """ - """let me check the function docstring again. Oh, the function says unit is optional, and """ - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """ - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """ - """The format is \n\nBarcelona, """ - """Spain\ncelsius\n\n. """ - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """ - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """ - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """ - """call should be as above. Then wait for the result to come back and tell the user the """ - """temperature in Celsius.\n\n""" - """Barcelona, Spain\ncelsius\n\n""", - [ - ToolCall( - function=FunctionCall( - name="get_weather", - arguments=json.dumps( - { - "location": "Barcelona, Spain", - "unit": "celsius", - }, - ), - ), - type="function", - ) - ], - """\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """ - """First, I need to remember the function I can use: get_weather. The function requires a """ - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """ - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """ - """let me check the function docstring again. Oh, the function says unit is optional, and """ - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """ - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """ - """The format is \n\nBarcelona, """ - """Spain\ncelsius\n\n. """ - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """ - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """ - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """ - """call should be as above. Then wait for the result to come back and tell the user the """ - """temperature in Celsius.""", - ), - ], -) -def test_extract_tool_calls( - seed_oss_tool_parser, - sample_tools, - model_output, - expected_tool_calls, - expected_content, -): - request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools) - extracted_tool_calls = seed_oss_tool_parser.extract_tool_calls( - model_output, request=request - ) # type: ignore[arg-type] - assert extracted_tool_calls.tools_called - - assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls) - - assert extracted_tool_calls.content == expected_content - - -def test_streaming_tool_calls_no_tools(seed_oss_tool_parser): - model_output = "This is a test response without any tool calls" - - result = seed_oss_tool_parser.extract_tool_calls_streaming( - previous_text="his is a test response", - current_text=model_output, - delta_text=" without any tool calls.", - previous_token_ids=[], - current_token_ids=[], - delta_token_ids=[], - request=None, - ) - - # Should return the delta text as content - assert result is not None - assert hasattr(result, "content") - assert result.content == " without any tool calls." - - -def stream_delta_message_generator( - seed_oss_tool_parser: SeedOssToolParser, - seed_oss_tokenizer: TokenizerLike, - model_output: str, - request: ChatCompletionRequest | None = None, -) -> Generator[DeltaMessage, None, None]: - all_token_ids = seed_oss_tokenizer.encode(model_output, add_special_tokens=False) - - previous_text = "" - previous_tokens = None - prefix_offset = 0 - read_offset = 0 - for i, delta_token in enumerate(all_token_ids): - delta_token_ids = [delta_token] - previous_token_ids = all_token_ids[:i] - current_token_ids = all_token_ids[: i + 1] - - (new_tokens, delta_text, new_prefix_offset, new_read_offset) = ( - detokenize_incrementally( - tokenizer=seed_oss_tokenizer, - all_input_ids=current_token_ids, - prev_tokens=previous_tokens, - prefix_offset=prefix_offset, - read_offset=read_offset, - skip_special_tokens=False, - spaces_between_special_tokens=True, - ) - ) - - current_text = previous_text + delta_text - - delta_message = seed_oss_tool_parser.extract_tool_calls_streaming( - previous_text, - current_text, - delta_text, - previous_token_ids, - current_token_ids, - delta_token_ids, - request=request, - ) - if delta_message: - yield delta_message - - previous_text = current_text - previous_tokens = ( - previous_tokens + new_tokens if previous_tokens else new_tokens - ) - prefix_offset = new_prefix_offset - read_offset = new_read_offset - - -@pytest.mark.parametrize( - ids=[ - "tool_call_0_thinking_budget", - "tool_call_512_thinking_budget", - "tool_call_unlimited_thinking_budget", - ], - argnames=["model_output", "expected_tool_calls", "expected_content"], - argvalues=[ - ( - """\n\n\n""" - """The current thinking budget is 0, so I will directly start answering the question.\n\n""" - """\n\n""" - """Barcelona, Spain\n\n""", - [ - ToolCall( - function=FunctionCall( - name="get_weather", - arguments=json.dumps( - { - "location": "Barcelona, Spain", - }, - ), - ), - type="function", - ) - ], - """\n\n\n""" - """The current thinking budget is 0, so I will directly start answering the question.\n\n""", - ), - ( - """The user\'s current thinking budget is 512.\nLet me analyze the """ - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """ - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """ - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """ - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """ - """country). \nI have used 131 tokens, and there are 381 tokens remaining for use.""" - """\n Since the unit isn\'t specified, the function will default to Celsius, which """ - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """ - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """ - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """ - """with a comma).\nI have used 257 tokens, and there are 255 tokens remaining for """ - """use.\n The unit parameter can be omitted since it\'s optional.\n""" - """\n\nBarcelona, Spain\n""" - """\n""", - [ - ToolCall( - function=FunctionCall( - name="get_weather", - arguments=json.dumps( - { - "location": "Barcelona, Spain", - }, - ), - ), - type="function", - ) - ], - """The user\'s current thinking budget is 512.\nLet me analyze the """ - """question. The user wants to know the weather in Barcelona, Spain. Looking at the functions available, """ - """there\'s a get_weather function that can retrieve the current temperature for a given location. \n\nFirst, """ - """check the parameters required by get_weather: location is mandatory (needs city and country), and unit is """ - """optional. The user provided "Barcelona Spain" as the location, which fits the required format (city, """ - """country). \nI have used 131 tokens, and there are 381 tokens remaining for use.""" - """\n Since the unit isn\'t specified, the function will default to Celsius, which """ - """is fine. \n\nThere\'s no need to ask for more information because the location is clear. So I should call """ - """the get_weather function with location set to "Barcelona, Spain" (adding a comma for clarity, though the """ - """user\'s input has a space, but the function might accept either; to be safe, using the standard format """ - """with a comma).\nI have used 257 tokens, and there are 255 tokens remaining for """ - """use.\n The unit parameter can be omitted since it\'s optional.\n""", - ), - ( - """\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """ - """First, I need to remember the function I can use: get_weather. The function requires a """ - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """ - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """ - """let me check the function docstring again. Oh, the function says unit is optional, and """ - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """ - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """ - """The format is \n\nBarcelona, """ - """Spain\ncelsius\n\n. """ - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """ - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """ - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """ - """call should be as above. Then wait for the result to come back and tell the user the """ - """temperature in Celsius.\n\n""" - """Barcelona, Spain\ncelsius\n\n""", - [ - ToolCall( - function=FunctionCall( - name="get_weather", - arguments=json.dumps( - { - "location": "Barcelona, Spain", - "unit": "celsius", - }, - ), - ), - type="function", - ) - ], - """\nGot it, let\'s see. The user asked for the weather in Barcelona, Spain. """ - """First, I need to remember the function I can use: get_weather. The function requires a """ - """location (city and country) which is "Barcelona, Spain" here, and unit is optional. Since """ - """the user didn\'t specify the unit, the default in the function is Celsius, right? Wait, """ - """let me check the function docstring again. Oh, the function says unit is optional, and """ - """returns temperature in Celsius. So I should call get_weather with location "Barcelona, """ - """Spain" and maybe omit unit or set to Celsius. Let me format the function call correctly. """ - """The format is \n\nBarcelona, """ - """Spain\ncelsius\n\n. """ - """Wait, but does the unit parameter accept "celsius"? The docstring says unit is the unit """ - """of temperature, but the return is in Celsius anyway. Maybe even if I don\'t pass unit, """ - """it\'s okay, but to be explicit, maybe pass "celsius". Let me go with that. So the function """ - """call should be as above. Then wait for the result to come back and tell the user the """ - """temperature in Celsius.""", - ), - ], -) -def test_streaming_tool_calls( - seed_oss_tool_parser, - seed_oss_tokenizer, - sample_tools, - model_output, - expected_tool_calls, - expected_content, -): - """Test incremental streaming behavior""" - request = ChatCompletionRequest(model=MODEL, messages=[], tools=sample_tools) - - other_content = "" - tool_states = {} # Track state per tool index - - for delta_message in stream_delta_message_generator( - seed_oss_tool_parser, seed_oss_tokenizer, model_output, request - ): - # role should never be streamed from tool parser - assert not delta_message.role - - if delta_message.content: - other_content += delta_message.content - - if delta_message.tool_calls: - for tool_call in delta_message.tool_calls: - idx = tool_call.index - - # Initialize state for new tool - if idx not in tool_states: - tool_states[idx] = { - "id": None, - "name": None, - "arguments": "", - "type": None, - } - - # First chunk should have id, name, and type - if tool_call.id: - tool_states[idx]["id"] = tool_call.id - - if tool_call.type: - assert tool_call.type == "function" - tool_states[idx]["type"] = tool_call.type - - if tool_call.function: - if tool_call.function.name: - # Should only be set once - assert tool_states[idx]["name"] is None - tool_states[idx]["name"] = tool_call.function.name - - if tool_call.function.arguments is not None: - # Accumulate arguments incrementally - tool_states[idx]["arguments"] += tool_call.function.arguments - - # Verify final content - assert other_content == expected_content - - # Verify we got all expected tool calls - assert len(tool_states) == len(expected_tool_calls) - - # Verify each tool call - for idx, expected_tool in enumerate(expected_tool_calls): - state = tool_states[idx] - assert state["id"] is not None - assert state["type"] == "function" - assert state["name"] == expected_tool.function.name - - # Parse accumulated arguments - arguments_str = state["arguments"] - assert arguments_str is not None - actual_args = json.loads(arguments_str) - expected_args = json.loads(expected_tool.function.arguments) - assert actual_args == expected_args diff --git a/tests/tool_parsers/test_structural_tag_registry.py b/tests/tool_parsers/test_structural_tag_registry.py new file mode 100644 index 000000000000..354adab66976 --- /dev/null +++ b/tests/tool_parsers/test_structural_tag_registry.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from xgrammar import StructuralTag + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionNamedFunction, + ChatCompletionNamedToolChoiceParam, + ChatCompletionRequest, + ChatCompletionToolsParam, +) +from vllm.parser.abstract_parser import DelegatingParser +from vllm.tool_parsers.abstract_tool_parser import ToolParser +from vllm.tool_parsers.deepseekv3_tool_parser import DeepSeekV3ToolParser +from vllm.tool_parsers.deepseekv4_engine_tool_parser import DeepSeekV4EngineToolParser +from vllm.tool_parsers.deepseekv31_tool_parser import DeepSeekV31ToolParser +from vllm.tool_parsers.deepseekv32_engine_tool_parser import ( + DeepSeekV32EngineToolParser, +) +from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser +from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser +from vllm.tool_parsers.kimi_k2_tool_parser import KimiK2ToolParser +from vllm.tool_parsers.llama_tool_parser import Llama3JsonToolParser +from vllm.tool_parsers.minimax_m2_tool_parser import MinimaxM2ToolParser +from vllm.tool_parsers.qwen3_engine_tool_parser import Qwen3EngineToolParser +from vllm.tool_parsers.structural_tag_registry import ( + SUPPORTED_STRUCTURAL_TAG_MODELS, + VLLM_BUILTIN_STRUCTURAL_TAG_MODELS, + XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS, + _get_function_parameters, + get_model_structural_tag, +) + + +@pytest.fixture +def sample_tools() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + ) + ] + + +@pytest.fixture +def sample_tools_strict() -> list[ChatCompletionToolsParam]: + return [ + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "strict": True, + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + ) + ] + + +def test_supported_structural_tag_models_include_vllm_builtins(): + assert SUPPORTED_STRUCTURAL_TAG_MODELS == ( + XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS + ) + assert "hermes" in VLLM_BUILTIN_STRUCTURAL_TAG_MODELS + + +@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS)) +def test_get_model_structural_tag_supports_all_xgrammar_builtins( + model: str, + sample_tools_strict: list[ChatCompletionToolsParam], +): + tag = get_model_structural_tag( + model=model, + tools=sample_tools_strict, + tool_choice="auto", + reasoning=False, + ) + + assert isinstance(tag, StructuralTag) + + +def test_get_model_structural_tag_supports_vllm_hermes( + sample_tools: list[ChatCompletionToolsParam], +): + tag = get_model_structural_tag( + model="hermes", + tools=sample_tools, + tool_choice="required", + reasoning=False, + ) + + assert isinstance(tag, StructuralTag) + + # Assert the semantically meaningful structure rather than the full + # model_dump(), which gains version-specific keys across xgrammar releases + # (e.g. "any_order" was added to json_schema content in 0.2.3). + dump = tag.model_dump() + assert dump["type"] == "structural_tag" + + fmt = dump["format"] + assert fmt["type"] == "tags_with_separator" + assert fmt["separator"] == "" + assert fmt["at_least_one"] is True + assert fmt["stop_after_first"] is False + + expected_schema = { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + } + expected_tags = [ + ('\n{"name": "get_weather", "arguments": ', "}\n"), + ('{"name": "get_weather", "arguments": ', "}"), + ] + assert len(fmt["tags"]) == len(expected_tags) + for tag_dump, (begin, end) in zip(fmt["tags"], expected_tags): + assert tag_dump["type"] == "tag" + assert tag_dump["begin"] == begin + assert tag_dump["end"] == end + content = tag_dump["content"] + assert content["type"] == "json_schema" + assert content["json_schema"] == expected_schema + + +def test_hermes_required_tool_calls_use_empty_separator(): + tools = [ + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_weather", + "parameters": {"type": "object", "properties": {}}, + }, + ), + ChatCompletionToolsParam( + type="function", + function={ + "name": "get_time", + "parameters": {"type": "object", "properties": {}}, + }, + ), + ] + + tag = get_model_structural_tag( + model="hermes", + tools=tools, + tool_choice="required", + reasoning=False, + ) + + assert tag is not None + assert tag.format.separator == "" + + +@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS)) +def test_get_model_structural_tag_supports_named_tool_choice( + model: str, + sample_tools: list[ChatCompletionToolsParam], +): + tag = get_model_structural_tag( + model=model, + tools=sample_tools, + tool_choice=ChatCompletionNamedToolChoiceParam( + function=ChatCompletionNamedFunction(name="get_weather") + ), + reasoning=False, + ) + + assert isinstance(tag, StructuralTag) + + +@pytest.mark.parametrize( + ("parser_cls", "model"), + [ + (DeepSeekV3ToolParser, "deepseek_r1"), + (DeepSeekV31ToolParser, "deepseek_v3_1"), + (DeepSeekV32EngineToolParser, "deepseek_v3_2"), + (DeepSeekV4EngineToolParser, "deepseek_v4"), + (Glm47MoeModelToolParser, "glm_4_7"), + (Hermes2ProToolParser, "hermes"), + (KimiK2ToolParser, "kimi"), + (Llama3JsonToolParser, "llama"), + (MinimaxM2ToolParser, "minimax"), + (Qwen3EngineToolParser, "qwen_3_coder"), + ], +) +def test_tool_parsers_declare_matching_xgrammar_builtin_model(parser_cls, model): + assert parser_cls.structural_tag_model == model + assert not parser_cls.supports_required_and_named + + +def test_tool_parsers_without_structural_tag_support_required_and_named(): + class NonStructuralTagToolParser(ToolParser): + pass + + assert NonStructuralTagToolParser.structural_tag_model is None + assert NonStructuralTagToolParser.supports_required_and_named + + +def test_non_structural_tag_parser_uses_schema_constraints( + sample_tools: list[ChatCompletionToolsParam], +): + parser = ToolParser(MagicMock()) + request = ChatCompletionRequest( + messages=[], + model="m", + tools=sample_tools, + tool_choice="required", + ) + + out = parser.adjust_request(request) + + assert out.structured_outputs is not None + assert out.structured_outputs.json is not None + assert out.structured_outputs.structural_tag is None + + +def test_get_structural_tag_disables_reasoning( + monkeypatch: pytest.MonkeyPatch, + sample_tools_strict: list[ChatCompletionToolsParam], +): + captured: list[bool] = [] + + def fake_get_model_structural_tag(*, reasoning: bool, **kwargs): + captured.append(reasoning) + return None + + monkeypatch.setattr( + "vllm.tool_parsers.structural_tag_registry.get_model_structural_tag", + fake_get_model_structural_tag, + ) + + request = ChatCompletionRequest( + messages=[], + model="m", + tools=sample_tools_strict, + tool_choice="auto", + ) + parser = Qwen3EngineToolParser(MagicMock(), tools=sample_tools_strict) + + parser.get_structural_tag(request) + + assert captured == [False] + + +def test_unified_parser_get_structural_tag_disables_reasoning( + monkeypatch: pytest.MonkeyPatch, + sample_tools_strict: list[ChatCompletionToolsParam], +): + captured: list[bool] = [] + + def fake_get_model_structural_tag(*, reasoning: bool, **kwargs): + captured.append(reasoning) + return None + + monkeypatch.setattr( + "vllm.tool_parsers.structural_tag_registry.get_model_structural_tag", + fake_get_model_structural_tag, + ) + + class TestParser(DelegatingParser): + tool_parser_cls = Qwen3EngineToolParser + + request = ChatCompletionRequest( + messages=[], + model="m", + tools=sample_tools_strict, + tool_choice="auto", + ) + parser = TestParser(MagicMock(), tools=sample_tools_strict) + parser.reasoning_parser = MagicMock(adjust_request=lambda request: request) + + parser.adjust_request(request) + + assert captured == [False] + + +def test_xgrammar_function_parameters_are_preserved( + monkeypatch: pytest.MonkeyPatch, + sample_tools_strict: list[ChatCompletionToolsParam], +): + captured: list[list[dict]] = [] + + def fake_get_xgrammar_model_structural_tag(*, tools: list[dict], **kwargs): + captured.append(tools) + return None + + monkeypatch.setattr( + "vllm.tool_parsers.structural_tag_registry.get_xgrammar_model_structural_tag", + fake_get_xgrammar_model_structural_tag, + ) + + get_model_structural_tag( + model="llama", + tools=sample_tools_strict, + tool_choice="auto", + reasoning=False, + ) + + assert ( + captured[0][0]["function"]["parameters"] + == sample_tools_strict[0].function.parameters + ) + assert sample_tools_strict[0].function.parameters is not None + + +@pytest.mark.parametrize("model", sorted(XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS)) +def test_auto_tool_choice_skips_structural_tag_without_strict( + model: str, + sample_tools: list[ChatCompletionToolsParam], +): + tag = get_model_structural_tag( + model=model, + tools=sample_tools, + tool_choice="auto", + reasoning=False, + ) + + assert tag is None + + +def test_get_function_parameters_relaxes_function_strict_false(): + function = SimpleNamespace( + parameters={"type": "object", "properties": {}}, + strict=False, + ) + + assert _get_function_parameters(function) is True diff --git a/tests/tool_parsers/test_utils.py b/tests/tool_parsers/test_utils.py index 592ef580a2bc..3276fa9ddd25 100644 --- a/tests/tool_parsers/test_utils.py +++ b/tests/tool_parsers/test_utils.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import json + import pytest from vllm.tool_parsers.utils import ( @@ -91,6 +93,71 @@ def test_whole_float_returns_int(self): def test_invalid_number_fallback(self): assert coerce_to_schema_type("abc", "number") == "abc" + class TestNonFiniteNumbers: + """Non-finite numeric strings must not crash and must coerce to a + JSON-serializable value. + + Regression: ``int(float("inf"))`` raised an uncaught ``OverflowError`` + (only ``ValueError``/``TypeError`` were handled), and ``"1e999"`` + round-tripped through ``json.loads`` to a float ``inf`` that + ``json.dumps`` renders as invalid JSON ``Infinity``. + """ + + @pytest.mark.parametrize( + "value", ["inf", "-inf", "Infinity", "1e999", "nan", "-nan"] + ) + def test_non_finite_number_does_not_crash(self, value): + # Must not raise (previously OverflowError for inf/1e999/Infinity). + result = coerce_to_schema_type(value, "number") + # Result must serialize to valid, finite JSON and round-trip. + assert json.loads(json.dumps(result)) == result + + @pytest.mark.parametrize("value", ["inf", "-inf", "1e999"]) + def test_non_finite_number_preserved_as_string(self, value): + assert coerce_to_schema_type(value, "number") == value + + @pytest.mark.parametrize("value", ["inf", "1e999", "Infinity"]) + def test_non_finite_integer_not_float_inf(self, value): + result = coerce_to_schema_type(value, "integer") + assert isinstance(result, str) + assert result == value + + class TestNonFiniteContainers: + """Non-finite floats nested in object/array values must not produce + invalid JSON. + + Regression: the ``object``/``array`` branch returned + ``json.loads(value)`` directly, so ``"[1e999]"`` became ``[inf]`` and + ``'{"x": Infinity}'`` became ``{"x": inf}`` -- values that + ``json.dumps`` later renders as invalid JSON (``Infinity``/``NaN``). + """ + + @pytest.mark.parametrize( + "value", ["[1e999]", "[1, 2, 1e999]", "[NaN]", "[-Infinity]"] + ) + def test_array_with_non_finite_preserved_as_string(self, value): + result = coerce_to_schema_type(value, "array") + assert result == value + assert json.loads(json.dumps(result)) == result + + @pytest.mark.parametrize( + "value", ['{"x": 1e999}', '{"x": Infinity}', '{"a": [1e999, 2]}'] + ) + def test_object_with_non_finite_preserved_as_string(self, value): + result = coerce_to_schema_type(value, "object") + assert result == value + assert json.loads(json.dumps(result)) == result + + def test_finite_array_still_coerced(self): + assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3] + + def test_finite_object_still_coerced(self): + assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1} + + def test_unknown_type_non_finite_falls_back_to_string(self): + # Exercises the final json.loads fallback path. + assert coerce_to_schema_type("1e999", "unknown_type") == "1e999" + class TestBooleanType: def test_true(self): assert coerce_to_schema_type("true", "boolean") is True diff --git a/tests/tool_parsers/test_xlam_tool_parser.py b/tests/tool_parsers/test_xlam_tool_parser.py index 3853d2039a72..5d39f0b5759e 100644 --- a/tests/tool_parsers/test_xlam_tool_parser.py +++ b/tests/tool_parsers/test_xlam_tool_parser.py @@ -532,3 +532,26 @@ def test_extract_tool_calls_streaming_incremental( parsed_args = json.loads(full_args) expected_args = json.loads(expected_first_tool.function.arguments) assert parsed_args == expected_args + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_extract_tool_calls_non_ascii(xlam_tool_parser, xlam_tokenizer, streaming): + # Use parallel tool calls so the streaming path re-serializes arguments + # (the ensure_ascii fix only runs when tool_count > 1). + model_output = """[{"name": "get_current_weather", "arguments": {"city": "北京"}}, {"name": "get_current_weather", "arguments": {"city": "上海"}}]""" # noqa: E501 + + if streaming: + request = ChatCompletionRequest(model=MODEL, messages=[]) + args = "".join( + delta.tool_calls[0].function.arguments + for delta in stream_delta_message_generator( + xlam_tool_parser, xlam_tokenizer, model_output, request + ) + if delta.tool_calls and delta.tool_calls[0].function.arguments + ) + else: + extracted = xlam_tool_parser.extract_tool_calls(model_output, request=None) # type: ignore[arg-type] + args = "".join(tc.function.arguments for tc in extracted.tool_calls) + + assert "北京" in args + assert "\\u" not in args diff --git a/tests/tool_use/test_gemma4_responses_adjust_request.py b/tests/tool_use/test_gemma4_responses_adjust_request.py index e08896ee3237..937be9d016a4 100644 --- a/tests/tool_use/test_gemma4_responses_adjust_request.py +++ b/tests/tool_use/test_gemma4_responses_adjust_request.py @@ -20,6 +20,13 @@ tracked in ``__fields_set__``, which can drop the nested config from ``model_dump``. It also passed a ``description`` kwarg carrying the wrong-purpose string ``"Response format for tool calling"``. + +3. :class:`Gemma4EngineToolParser` (the engine-based parser, #45588) sets + ``supports_required_and_named=False`` but did not skip the forced + ``structured_outputs`` JSON for ``required``/named tool choice. The model + was constrained to JSON the native parser cannot read, so the call leaked + as content with empty ``tool_calls``. ``adjust_request`` now skips that + constraint so Gemma4 emits its native ``<|tool_call>`` syntax. """ from __future__ import annotations @@ -28,9 +35,12 @@ from openai.types.responses.tool_param import FunctionToolParam +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.tool_parsers.abstract_tool_parser import ToolParser -from vllm.tool_parsers.gemma4_tool_parser import Gemma4ToolParser +from vllm.tool_parsers.gemma4_engine_tool_parser import ( + Gemma4EngineToolParser as Gemma4ToolParser, +) def _get_weather_tool() -> FunctionToolParam: @@ -47,7 +57,7 @@ def _get_weather_tool() -> FunctionToolParam: ) -def _build_responses_request(*, tool_choice: str) -> ResponsesRequest: +def _build_responses_request(*, tool_choice: str | dict[str, Any]) -> ResponsesRequest: return ResponsesRequest( model="gemma4-test", input=[{"role": "user", "content": "What is the weather in Hanoi?"}], @@ -58,11 +68,56 @@ def _build_responses_request(*, tool_choice: str) -> ResponsesRequest: ) +def _build_chat_request( + *, + tool_choice: str | dict[str, Any], + chat_template_kwargs: dict[str, Any] | None = None, +) -> ChatCompletionRequest: + data: dict[str, Any] = { + "model": "gemma4-test", + "messages": [{"role": "user", "content": "What is the weather in Hanoi?"}], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather for a city", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + } + ], + "tool_choice": tool_choice, + } + if chat_template_kwargs is not None: + data["chat_template_kwargs"] = chat_template_kwargs + return ChatCompletionRequest.model_validate(data) + + class _StubTokenizer: - """Minimal tokenizer stub to satisfy ``Gemma4ToolParser.__init__``.""" + """Minimal tokenizer stub to satisfy ``Gemma4EngineToolParser.__init__``.""" + + _VOCAB: dict[str, int] = { + "<|tool_call>": 256_000, + "": 256_001, + '<|"|>': 52, + "<|channel>": 256_002, + "": 256_003, + } def get_vocab(self) -> dict[str, int]: - return {"<|tool_call>": 256_000, "": 256_001, '<|"|>': 52} + return dict(self._VOCAB) + + @property + def all_special_tokens(self) -> list[str]: + return list(self._VOCAB.keys()) + + @property + def all_special_ids(self) -> list[int]: + return list(self._VOCAB.values()) def test_gemma4_adjust_request_sets_skip_special_tokens_on_responses() -> None: @@ -74,15 +129,14 @@ def test_gemma4_adjust_request_sets_skip_special_tokens_on_responses() -> None: path, causing raw ``call:fn{...}`` text to leak via ``response.output_text.delta``. """ - parser = Gemma4ToolParser.__new__(Gemma4ToolParser) - parser.model_tokenizer = _StubTokenizer() + parser = Gemma4ToolParser(_StubTokenizer()) request = _build_responses_request(tool_choice="auto") assert request.skip_special_tokens is True, ( "Precondition: ResponsesRequest.skip_special_tokens default is True" ) - Gemma4ToolParser.adjust_request(parser, request) + parser.adjust_request(request) assert request.skip_special_tokens is False @@ -114,3 +168,93 @@ def test_tool_parser_adjust_request_builds_valid_response_text_config() -> None: # The old code passed a wrong-purpose string; valid field should now # either be absent or None (the openai-python default). assert fmt.get("description") in (None, "") + + +def test_gemma4_required_skips_structured_outputs_chatcompletion() -> None: + """required + ChatCompletion: ``Gemma4EngineToolParser`` must skip the + forced JSON ``structured_outputs`` so the model emits its native + ``<|tool_call>`` syntax. The base parser constrained output to JSON the + native parser cannot read, leaking it as content with empty + ``tool_calls`` (regression after #45588). + """ + parser = Gemma4ToolParser(_StubTokenizer()) + request = _build_chat_request(tool_choice="required") + + parser.adjust_request(request) + + assert request.structured_outputs is None + assert request.skip_special_tokens is False + + +def test_gemma4_named_skips_structured_outputs_chatcompletion() -> None: + """named + ChatCompletion: the forced single-function JSON schema must be + skipped, same as ``required``. + """ + parser = Gemma4ToolParser(_StubTokenizer()) + request = _build_chat_request( + tool_choice={"type": "function", "function": {"name": "get_weather"}} + ) + + parser.adjust_request(request) + + assert request.structured_outputs is None + assert request.skip_special_tokens is False + + +def test_gemma4_required_skips_structured_outputs_responses() -> None: + """required + Responses: the forced JSON schema (``request.text``) must be + skipped so the native delimiters reach the extractor. + """ + parser = Gemma4ToolParser(_StubTokenizer()) + request = _build_responses_request(tool_choice="required") + + parser.adjust_request(request) + + assert request.text is None + assert request.skip_special_tokens is False + + +def test_gemma4_named_skips_structured_outputs_responses() -> None: + """named (``ToolChoiceFunction``) + Responses: the forced single-function + JSON schema must be skipped. + """ + parser = Gemma4ToolParser(_StubTokenizer()) + request = _build_responses_request( + tool_choice={"type": "function", "name": "get_weather"} + ) + + parser.adjust_request(request) + + assert request.text is None + assert request.skip_special_tokens is False + + +def test_gemma4_keeps_special_tokens_with_tools_thinking_disabled() -> None: + """tools active + thinking disabled: ``skip_special_tokens`` must stay + False so ``<|tool_call>`` delimiters reach the extractor. The merged + enable_thinking early-return stripped them, breaking tool calling when + thinking is off. + """ + parser = Gemma4ToolParser(_StubTokenizer()) + request = _build_chat_request( + tool_choice="auto", chat_template_kwargs={"enable_thinking": False} + ) + + parser.adjust_request(request) + + assert request.skip_special_tokens is False + + +def test_gemma4_keeps_skip_special_tokens_false_when_nothing_to_preserve() -> None: + """No active tools + thinking disabled: ``skip_special_tokens`` stays + ``False`` because the parser engine's ``__DROP__`` terminal mechanism + strips unconfigured special tokens automatically. + """ + parser = Gemma4ToolParser(_StubTokenizer()) + request = _build_chat_request( + tool_choice="none", chat_template_kwargs={"enable_thinking": False} + ) + + parser.adjust_request(request) + + assert request.skip_special_tokens is False diff --git a/tests/tool_use/test_parallel_tool_calls.py b/tests/tool_use/test_parallel_tool_calls.py index 0f7f68931620..4cfd165f1a8e 100644 --- a/tests/tool_use/test_parallel_tool_calls.py +++ b/tests/tool_use/test_parallel_tool_calls.py @@ -115,14 +115,12 @@ async def test_parallel_tool_calls( assert not role_name or role_name == "assistant" role_name = "assistant" - # if a tool call is streamed make sure there's exactly one - # (based on the request parameters + # a chunk may carry >1 tool-call delta at a parallel-call boundary streamed_tool_calls = chunk.choices[0].delta.tool_calls - if streamed_tool_calls and len(streamed_tool_calls) > 0: - # make sure only one diff is present - correct even for parallel - assert len(streamed_tool_calls) == 1 - tool_call = streamed_tool_calls[0] + for tool_call in streamed_tool_calls or []: + # deltas arrive in non-decreasing index order + assert tool_call.index >= tool_call_idx # if a new tool is being called, set up empty arguments if tool_call.index != tool_call_idx: diff --git a/tests/tool_use/test_tool_choice_required.py b/tests/tool_use/test_tool_choice_required.py index e99165f3569a..f37a3c9681f9 100644 --- a/tests/tool_use/test_tool_choice_required.py +++ b/tests/tool_use/test_tool_choice_required.py @@ -5,13 +5,17 @@ import pytest import regex as re +from openai.types.responses import FunctionTool, WebSearchTool from pydantic import TypeAdapter from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionToolsParam, ) from vllm.tool_parsers.streaming import extract_required_tool_call_streaming -from vllm.tool_parsers.utils import get_json_schema_from_tools +from vllm.tool_parsers.utils import ( + find_tool_properties, + get_json_schema_from_tools, +) pytestmark = pytest.mark.cpu_test @@ -276,15 +280,7 @@ def test_structured_outputs_json_without_parameters( ) -@pytest.mark.parametrize("output", VALID_TOOLS) -@pytest.mark.parametrize("empty_params", [False, True]) -@pytest.mark.parametrize("delta_len", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) -def test_streaming_output_valid(output, empty_params, delta_len): - output = deepcopy(output) - if empty_params: - output = [{"name": o["name"], "parameters": {}} for o in output] - output_json = json.dumps(output) - +def _collect_required_tool_streaming_json(output_json: str, delta_len: int) -> str: previous_text = "" function_name_returned = False messages = [] @@ -323,6 +319,38 @@ def test_streaming_output_valid(output, empty_params, delta_len): else: combined_messages += message.tool_calls[0].function.arguments combined_messages += "}]" + return combined_messages + + +@pytest.mark.parametrize("output", VALID_TOOLS) +@pytest.mark.parametrize("empty_params", [False, True]) +@pytest.mark.parametrize("delta_len", [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) +def test_streaming_output_valid(output, empty_params, delta_len): + output = deepcopy(output) + if empty_params: + output = [{"name": o["name"], "parameters": {}} for o in output] + output_json = json.dumps(output) + + combined_messages = _collect_required_tool_streaming_json(output_json, delta_len) + assert json.loads(combined_messages) == output + assert json.dumps(json.loads(combined_messages)) == output_json + + +@pytest.mark.parametrize( + "city", + [ + "a { b", + "a } b", + "a }} b", + 'a " } b', + r"a \ } b", + ], +) +@pytest.mark.parametrize("delta_len", [1, 2, 3, 8, 9999]) +def test_streaming_output_valid_with_braces_in_string(city, delta_len): + output = [{"name": "get_current_weather", "parameters": {"city": city}}] + output_json = json.dumps(output) + combined_messages = _collect_required_tool_streaming_json(output_json, delta_len) assert json.loads(combined_messages) == output assert json.dumps(json.loads(combined_messages)) == output_json @@ -330,27 +358,39 @@ def test_streaming_output_valid(output, empty_params, delta_len): def test_streaming_output_valid_with_trailing_extra_data(): output = [{"name": "get_current_weather", "parameters": {"city": "Vienna"}}] output_json = json.dumps(output) + "\nDONE" + combined_messages = _collect_required_tool_streaming_json(output_json, delta_len=3) + assert json.loads(combined_messages) == output - previous_text = "" - function_name_returned = False - messages = [] - delta_len = 3 - for i in range(0, len(output_json), delta_len): - delta_text = output_json[i : i + delta_len] - current_text = previous_text + delta_text - delta_message, function_name_returned = extract_required_tool_call_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - function_name_returned=function_name_returned, - tool_call_idx=None, - tool_call_id_type="random", - ) +FUNCTION_TOOL = FunctionTool( + type="function", + name="get_weather", + parameters={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, +) +WEB_SEARCH_TOOL = WebSearchTool(type="web_search") - if delta_message: - messages.append(delta_message) - previous_text = current_text +class TestNonFunctionToolsSkipped: + """Non-function tools (web_search, etc.) must be silently skipped + by the tool-schema utilities instead of raising TypeError.""" - assert len(messages) > 0 + def test_find_tool_properties_skips_web_search(self): + tools = [WEB_SEARCH_TOOL, FUNCTION_TOOL] + props = find_tool_properties(tools, "get_weather") + assert props == {"city": {"type": "string"}} + + def test_find_tool_properties_only_non_function_tools(self): + props = find_tool_properties([WEB_SEARCH_TOOL], "get_weather") + assert props == {} + + def test_get_json_schema_with_mixed_tools(self): + tools = [WEB_SEARCH_TOOL, FUNCTION_TOOL] + schema = get_json_schema_from_tools(tools=tools, tool_choice="required") + assert isinstance(schema, dict) + any_of = schema["items"]["anyOf"] + assert len(any_of) == 1 + assert any_of[0]["properties"]["name"]["enum"] == ["get_weather"] diff --git a/tests/tools/test_docker_build_metadata_args.py b/tests/tools/test_docker_build_metadata_args.py index fa2eac558f53..72d4ad8089e1 100644 --- a/tests/tools/test_docker_build_metadata_args.py +++ b/tests/tools/test_docker_build_metadata_args.py @@ -150,3 +150,31 @@ def test_vllm_openai_image_embeds_metadata_contract() -> None: 'ai.vllm.image.tag="${VLLM_IMAGE_TAG}"', ): assert expected in dockerfile + + +def test_rocm_ci_base_bake_embeds_content_hash_label() -> None: + bake_file = (REPO_ROOT / "docker" / "docker-bake-rocm.hcl").read_text() + + for expected in ( + 'variable "CI_BASE_CONTENT_HASH"', + 'target "ci-base-rocm"', + 'target = "ci_base"', + '"vllm.ci_base.content_hash" = CI_BASE_CONTENT_HASH', + ): + assert expected in bake_file + + +def test_rocm_ci_base_metadata_inputs_cover_ci_base_files() -> None: + ci_bake = (REPO_ROOT / ".buildkite" / "scripts" / "ci-bake-rocm.sh").read_text() + + for expected in ( + "requirements/common.txt", + "requirements/rocm.txt", + "requirements/test/rocm.txt", + "docker/Dockerfile.rocm_base", + "docker/Dockerfile.rocm", + "docker/ci-rocm.hcl", + "docker/docker-bake-rocm.hcl", + ".buildkite/scripts/ci-bake-rocm.sh", + ): + assert expected in ci_bake diff --git a/tests/transformers_utils/processors/__init__.py b/tests/transformers_utils/processors/__init__.py new file mode 100644 index 000000000000..208f01a7cb5e --- /dev/null +++ b/tests/transformers_utils/processors/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project diff --git a/tests/transformers_utils/processors/test_pixtral.py b/tests/transformers_utils/processors/test_pixtral.py new file mode 100644 index 000000000000..333308868ee5 --- /dev/null +++ b/tests/transformers_utils/processors/test_pixtral.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import pytest +import transformers.image_utils +from PIL import Image + +from vllm.transformers_utils.processors.pixtral import MistralCommonImageProcessor + + +@pytest.fixture(scope="module") +def image_processor() -> MistralCommonImageProcessor: + return MistralCommonImageProcessor(mm_encoder=None) + + +def test_fetch_images_passes_through_decoded_image( + image_processor: MistralCommonImageProcessor, +): + image = Image.new("RGB", (4, 4)) + result = image_processor.fetch_images(image) + assert result is image + + +def test_fetch_images_recurses_over_list( + image_processor: MistralCommonImageProcessor, +): + a = Image.new("RGB", (4, 4)) + b = Image.new("RGB", (8, 8)) + result = image_processor.fetch_images([a, b]) + assert isinstance(result, list) + assert len(result) == 2 + assert result[0] is a + assert result[1] is b + + +def test_fetch_images_recurses_over_nested_list( + image_processor: MistralCommonImageProcessor, +): + a = Image.new("RGB", (4, 4)) + b = Image.new("RGB", (8, 8)) + result = image_processor.fetch_images([[a], [b]]) + assert result == [[a], [b]] + + +def test_fetch_images_str_delegates_to_load_image( + monkeypatch, image_processor: MistralCommonImageProcessor +): + sentinel = Image.new("RGB", (2, 2)) + received: dict[str, object] = {} + + def fake_load_image(path): + received["path"] = path + return sentinel + + monkeypatch.setattr(transformers.image_utils, "load_image", fake_load_image) + + result = image_processor.fetch_images("/tmp/fake.png") + assert result is sentinel + assert received["path"] == "/tmp/fake.png" + + +def test_fetch_images_rejects_unsupported_type( + image_processor: MistralCommonImageProcessor, +): + with pytest.raises(TypeError, match="only a single or a list"): + image_processor.fetch_images(42) diff --git a/tests/transformers_utils/processors/test_voxtral.py b/tests/transformers_utils/processors/test_voxtral.py new file mode 100644 index 000000000000..0ca8f1a94de4 --- /dev/null +++ b/tests/transformers_utils/processors/test_voxtral.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for ``MistralCommonFeatureExtractor.fetch_audio``. + +``transformers>=5.10`` adds a ``ProcessorMixin.prepare_inputs_layout`` helper +that calls ``self.feature_extractor.fetch_audio(...)`` unconditionally. The +duck-typed :class:`MistralCommonFeatureExtractor` previously did not implement +that method, so loading any voxtral model under transformers 5.10.x raised +``AttributeError: 'MistralCommonFeatureExtractor' object has no attribute +'fetch_audio'``. These tests pin the new ``fetch_audio`` method to the same +contract as ``transformers.SequenceFeatureExtractor.fetch_audio``. +""" + +import numpy as np +import pytest +import torch + +from vllm.tokenizers.mistral import MistralTokenizer +from vllm.transformers_utils.processors.voxtral import ( + MistralCommonFeatureExtractor, +) + + +@pytest.fixture(scope="module") +def feature_extractor() -> MistralCommonFeatureExtractor: + tokenizer = MistralTokenizer.from_pretrained("mistralai/Voxtral-Mini-3B-2507") + return MistralCommonFeatureExtractor(tokenizer.instruct.audio_encoder) + + +@pytest.mark.parametrize( + "audio", + [ + np.zeros(1024, dtype=np.float32), + torch.zeros(1024), + [0.0, 1.0, 2.0], + ], + ids=["numpy_array", "torch_tensor", "list_of_floats"], +) +def test_fetch_audio_passes_through( + feature_extractor: MistralCommonFeatureExtractor, audio +): + result = feature_extractor.fetch_audio(audio) + assert result is audio + + +def test_fetch_audio_recurses_over_list_of_arrays( + feature_extractor: MistralCommonFeatureExtractor, +): + a = np.zeros(8, dtype=np.float32) + b = np.ones(8, dtype=np.float32) + result = feature_extractor.fetch_audio([a, b]) + assert isinstance(result, list) + assert len(result) == 2 + assert result[0] is a + assert result[1] is b + + +def test_fetch_audio_uses_self_sampling_rate_when_none( + monkeypatch, feature_extractor: MistralCommonFeatureExtractor +): + """If ``sampling_rate`` is None, ``self.sampling_rate`` must be used. + + Verified indirectly via the recursion path: when we pass a list of arrays + without sampling_rate, recursive calls receive the resolved rate. + """ + captured: list[int | None] = [] + original = feature_extractor.fetch_audio + + def spy(audio, sampling_rate=None): + captured.append(sampling_rate) + return original(audio, sampling_rate=sampling_rate) + + monkeypatch.setattr(feature_extractor, "fetch_audio", spy) + feature_extractor.fetch_audio([np.zeros(4, dtype=np.float32)]) + # Top-level call has sampling_rate=None; inner recursive call sees the + # resolved rate from self.sampling_rate. + assert captured[0] is None + assert captured[1] == 16000 + + +def test_fetch_audio_explicit_sampling_rate_propagates( + monkeypatch, feature_extractor: MistralCommonFeatureExtractor +): + captured: list[int | None] = [] + original = feature_extractor.fetch_audio + + def spy(audio, sampling_rate=None): + captured.append(sampling_rate) + return original(audio, sampling_rate=sampling_rate) + + monkeypatch.setattr(feature_extractor, "fetch_audio", spy) + feature_extractor.fetch_audio([np.zeros(4, dtype=np.float32)], sampling_rate=8000) + assert captured[0] == 8000 + assert captured[1] == 8000 + + +def test_fetch_audio_rejects_unsupported_type( + feature_extractor: MistralCommonFeatureExtractor, +): + with pytest.raises(TypeError, match="only a numpy array"): + feature_extractor.fetch_audio(42) # type: ignore[arg-type] + + +def test_fetch_audio_str_delegates_to_load_audio( + monkeypatch, feature_extractor: MistralCommonFeatureExtractor +): + """A str input must round-trip through ``transformers.audio_utils.load_audio``. + + We monkey-patch ``load_audio`` so the test stays offline (no real URL/path + fetched) and still asserts the delegation contract. + """ + sentinel = np.array([0.5, -0.5], dtype=np.float32) + received: dict[str, object] = {} + + def fake_load_audio(path, sampling_rate=None): + received["path"] = path + received["sampling_rate"] = sampling_rate + return sentinel + + import transformers.audio_utils + + monkeypatch.setattr(transformers.audio_utils, "load_audio", fake_load_audio) + + result = feature_extractor.fetch_audio("/tmp/fake.wav") + assert result is sentinel + assert received["path"] == "/tmp/fake.wav" + assert received["sampling_rate"] == 16000 diff --git a/tests/transformers_utils/test_repo_utils.py b/tests/transformers_utils/test_repo_utils.py index 6da4256cba9a..36d0acccd6b7 100644 --- a/tests/transformers_utils/test_repo_utils.py +++ b/tests/transformers_utils/test_repo_utils.py @@ -7,9 +7,11 @@ from unittest.mock import MagicMock, call, patch import pytest +from huggingface_hub import _CACHED_NO_EXIST from vllm.transformers_utils.repo_utils import ( any_pattern_in_repo_files, + get_hf_file_to_dict, is_mistral_model_repo, list_filtered_repo_files, ) @@ -115,6 +117,33 @@ def _glob_path() -> list[str]: ) +@pytest.mark.parametrize( + ("cache_result", "should_download"), + [ + # HF Hub recorded a prior 404: don't re-probe the Hub. + (_CACHED_NO_EXIST, False), + # File not in cache and existence unknown: preserve download behavior. + (None, True), + ], +) +def test_get_hf_file_to_dict_honors_no_exist_marker( + cache_result: object, should_download: bool +): + with ( + patch( + "vllm.transformers_utils.repo_utils.try_to_load_from_cache", + MagicMock(return_value=cache_result), + ), + patch( + "vllm.transformers_utils.repo_utils._try_download_from_hf_hub", + MagicMock(return_value=None), + ) as mock_download, + ): + result = get_hf_file_to_dict("processor_config.json", "some/repo") + assert result is None + assert mock_download.call_count == int(should_download) + + @pytest.mark.parametrize( ("files", "expected_bool"), [ diff --git a/tests/transformers_utils/test_utils.py b/tests/transformers_utils/test_utils.py index 94dd014c929f..adcb02a9300a 100644 --- a/tests/transformers_utils/test_utils.py +++ b/tests/transformers_utils/test_utils.py @@ -1,15 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from pathlib import Path -from unittest.mock import patch - -import pytest - -from vllm.transformers_utils.gguf_utils import ( - is_gguf, - is_remote_gguf, - split_remote_gguf, -) from vllm.transformers_utils.utils import ( is_azure, is_cloud_storage, @@ -45,203 +35,3 @@ def test_is_cloud_storage(): assert is_cloud_storage("az://model-container/path") assert not is_cloud_storage("/unix/local/path") assert not is_cloud_storage("nfs://nfs-fqdn.local") - - -class TestIsRemoteGGUF: - """Test is_remote_gguf utility function.""" - - def test_is_remote_gguf_with_colon_and_slash(self): - """Test is_remote_gguf with repo_id:quant_type format.""" - # Valid quant types (exact GGML types) - assert is_remote_gguf("unsloth/Qwen3-0.6B-GGUF:IQ1_S") - assert is_remote_gguf("user/repo:Q2_K") - assert is_remote_gguf("repo/model:Q4_K") - assert is_remote_gguf("repo/model:Q8_0") - - # Invalid quant types should return False - assert not is_remote_gguf("repo/model:quant") - assert not is_remote_gguf("repo/model:INVALID") - assert not is_remote_gguf("repo/model:invalid_type") - - def test_is_remote_gguf_extended_quant_types(self): - """Test is_remote_gguf with extended quant type naming conventions.""" - # Extended quant types with _M, _S, _L suffixes - assert is_remote_gguf("repo/model:Q4_K_M") - assert is_remote_gguf("repo/model:Q4_K_S") - assert is_remote_gguf("repo/model:Q3_K_L") - assert is_remote_gguf("repo/model:Q5_K_M") - assert is_remote_gguf("repo/model:Q3_K_S") - - # Extended quant types with _XL, _XS, _XXS suffixes - assert is_remote_gguf("repo/model:Q5_K_XL") - assert is_remote_gguf("repo/model:IQ4_XS") - assert is_remote_gguf("repo/model:IQ3_XXS") - - # Invalid extended types (base type doesn't exist) - assert not is_remote_gguf("repo/model:INVALID_M") - assert not is_remote_gguf("repo/model:Q9_K_M") - - def test_is_remote_gguf_nonstandard_quant_type(self): - """Test is_remote_gguf with non-standard quant types containing - a known GGML type.""" - # Non-standard quant types with known GGML type after prefix - assert is_remote_gguf("unsloth/Qwen3.5-35B-A3B-GGUF:UD-Q4_K_XL") - assert is_remote_gguf("user/Model:UD-Q4_K_M") - assert is_remote_gguf("user/SomeModel:Custom-Q8_0") - - # Exact GGML type after prefix (no suffix stripping needed) - assert is_remote_gguf("user/Model-GGUF:UD-IQ4_NL") - assert is_remote_gguf("user/Model-GGUF:UD-Q8_0") - - # Completely unknown quant types should still fail - assert not is_remote_gguf("repo/model:TOTALLY-RANDOM") - assert not is_remote_gguf("user/Model:UD-INVALID") - - # No dash separator → not recognized as prefixed - assert not is_remote_gguf("repo/model:UDIQ4NL") - - def test_is_remote_gguf_without_colon(self): - """Test is_remote_gguf without colon.""" - assert not is_remote_gguf("repo/model") - assert not is_remote_gguf("unsloth/Qwen3-0.6B-GGUF") - - def test_is_remote_gguf_without_slash(self): - """Test is_remote_gguf without slash.""" - assert not is_remote_gguf("model.gguf") - # Even with valid quant_type, no slash means not remote GGUF - assert not is_remote_gguf("model:IQ1_S") - assert not is_remote_gguf("model:quant") - - def test_is_remote_gguf_local_path(self): - """Test is_remote_gguf with local file path.""" - assert not is_remote_gguf("/path/to/model.gguf") - assert not is_remote_gguf("./model.gguf") - - def test_is_remote_gguf_with_path_object(self): - """Test is_remote_gguf with Path object.""" - assert is_remote_gguf(Path("unsloth/Qwen3-0.6B-GGUF:IQ1_S")) - assert not is_remote_gguf(Path("repo/model")) - - def test_is_remote_gguf_with_http_https(self): - """Test is_remote_gguf with HTTP/HTTPS URLs.""" - # HTTP/HTTPS URLs should return False even with valid quant_type - assert not is_remote_gguf("http://example.com/repo/model:IQ1_S") - assert not is_remote_gguf("https://huggingface.co/repo/model:Q2_K") - assert not is_remote_gguf("http://repo/model:Q4_K") - assert not is_remote_gguf("https://repo/model:Q8_0") - - def test_is_remote_gguf_with_cloud_storage(self): - """Test is_remote_gguf with cloud storage paths.""" - # Cloud storage paths should return False even with valid quant_type - assert not is_remote_gguf("s3://bucket/repo/model:IQ1_S") - assert not is_remote_gguf("gs://bucket/repo/model:Q2_K") - assert not is_remote_gguf("s3://repo/model:Q4_K") - assert not is_remote_gguf("gs://repo/model:Q8_0") - - -class TestSplitRemoteGGUF: - """Test split_remote_gguf utility function.""" - - def test_split_remote_gguf_valid(self): - """Test split_remote_gguf with valid repo_id:quant_type format.""" - repo_id, quant_type = split_remote_gguf("unsloth/Qwen3-0.6B-GGUF:IQ1_S") - assert repo_id == "unsloth/Qwen3-0.6B-GGUF" - assert quant_type == "IQ1_S" - - repo_id, quant_type = split_remote_gguf("repo/model:Q2_K") - assert repo_id == "repo/model" - assert quant_type == "Q2_K" - - def test_split_remote_gguf_extended_quant_types(self): - """Test split_remote_gguf with extended quant type naming conventions.""" - repo_id, quant_type = split_remote_gguf("unsloth/Qwen3-0.6B-GGUF:Q4_K_M") - assert repo_id == "unsloth/Qwen3-0.6B-GGUF" - assert quant_type == "Q4_K_M" - - repo_id, quant_type = split_remote_gguf("repo/model:Q3_K_S") - assert repo_id == "repo/model" - assert quant_type == "Q3_K_S" - - def test_split_remote_gguf_nonstandard_quant_type(self): - """Test split_remote_gguf with non-standard quant types in GGUF repos.""" - repo_id, quant_type = split_remote_gguf( - "unsloth/Qwen3.5-35B-A3B-GGUF:UD-Q4_K_XL" - ) - assert repo_id == "unsloth/Qwen3.5-35B-A3B-GGUF" - assert quant_type == "UD-Q4_K_XL" - - def test_split_remote_gguf_with_path_object(self): - """Test split_remote_gguf with Path object.""" - repo_id, quant_type = split_remote_gguf(Path("unsloth/Qwen3-0.6B-GGUF:IQ1_S")) - assert repo_id == "unsloth/Qwen3-0.6B-GGUF" - assert quant_type == "IQ1_S" - - def test_split_remote_gguf_invalid(self): - """Test split_remote_gguf with invalid format.""" - # Invalid format (no colon) - is_remote_gguf returns False - with pytest.raises(ValueError, match="Wrong GGUF model"): - split_remote_gguf("repo/model") - - # Invalid quant type - is_remote_gguf returns False - with pytest.raises(ValueError, match="Wrong GGUF model"): - split_remote_gguf("repo/model:INVALID_TYPE") - - # HTTP URL - is_remote_gguf returns False - with pytest.raises(ValueError, match="Wrong GGUF model"): - split_remote_gguf("http://repo/model:IQ1_S") - - # Cloud storage - is_remote_gguf returns False - with pytest.raises(ValueError, match="Wrong GGUF model"): - split_remote_gguf("s3://bucket/repo/model:Q2_K") - - -class TestIsGGUF: - """Test is_gguf utility function.""" - - @patch("vllm.transformers_utils.gguf_utils.check_gguf_file", return_value=True) - def test_is_gguf_with_local_file(self, mock_check_gguf): - """Test is_gguf with local GGUF file.""" - assert is_gguf("/path/to/model.gguf") - assert is_gguf("./model.gguf") - - def test_is_gguf_with_remote_gguf(self): - """Test is_gguf with remote GGUF format.""" - # Valid remote GGUF format (repo_id:quant_type with valid quant_type) - assert is_gguf("unsloth/Qwen3-0.6B-GGUF:IQ1_S") - assert is_gguf("repo/model:Q2_K") - assert is_gguf("repo/model:Q4_K") - - # Extended quant types with suffixes - assert is_gguf("repo/model:Q4_K_M") - assert is_gguf("repo/model:Q3_K_S") - assert is_gguf("repo/model:Q5_K_L") - - # Invalid quant_type should return False - assert not is_gguf("repo/model:quant") - assert not is_gguf("repo/model:INVALID") - - @patch("vllm.transformers_utils.gguf_utils.check_gguf_file", return_value=False) - def test_is_gguf_false(self, mock_check_gguf): - """Test is_gguf returns False for non-GGUF models.""" - assert not is_gguf("unsloth/Qwen3-0.6B") - assert not is_gguf("repo/model") - assert not is_gguf("model") - - def test_is_gguf_edge_cases(self): - """Test is_gguf with edge cases.""" - # Empty string - assert not is_gguf("") - - # Only colon, no slash (even with valid quant_type) - assert not is_gguf("model:IQ1_S") - - # Only slash, no colon - assert not is_gguf("repo/model") - - # HTTP/HTTPS URLs - assert not is_gguf("http://repo/model:IQ1_S") - assert not is_gguf("https://repo/model:Q2_K") - - # Cloud storage - assert not is_gguf("s3://bucket/repo/model:IQ1_S") - assert not is_gguf("gs://bucket/repo/model:Q2_K") diff --git a/tests/utils.py b/tests/utils.py index 6a32f3e2e2d4..2a3bdb91fe0b 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -18,7 +18,7 @@ import threading import time import warnings -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable, Iterable, MutableMapping, Sequence from contextlib import ExitStack, contextmanager from multiprocessing import Process, get_context from pathlib import Path @@ -149,6 +149,46 @@ def _nvml(): if current_platform.is_rocm() else {} ) +_TILELANG_TVM_PYTHONPATH_FRAGMENT = os.path.join( + "tilelang", "3rdparty", "tvm", "python" +) + + +def _sanitize_pythonpath_value(pythonpath: str | None) -> str: + if not pythonpath: + return "" + entries = [] + for entry in pythonpath.split(os.pathsep): + normalized = entry.replace(os.sep, "/") + if _TILELANG_TVM_PYTHONPATH_FRAGMENT.replace(os.sep, "/") in normalized: + continue + entries.append(entry) + return os.pathsep.join(entries) + + +def _sanitize_pythonpath_env(env: MutableMapping[str, str]) -> None: + cleaned = _sanitize_pythonpath_value(env.get("PYTHONPATH")) + if cleaned: + env["PYTHONPATH"] = cleaned + else: + env.pop("PYTHONPATH", None) + + +def _sanitize_current_pythonpath_env() -> None: + _sanitize_pythonpath_env(os.environ) + + +@contextmanager +def _temporarily_sanitized_pythonpath_env(): + original = os.environ.get("PYTHONPATH") + _sanitize_current_pythonpath_env() + try: + yield + finally: + if original is None: + os.environ.pop("PYTHONPATH", None) + else: + os.environ["PYTHONPATH"] = original def requires_spawn_multiprocessing() -> bool: @@ -253,7 +293,8 @@ def __init__( getattr(args, "show_hidden_metrics_for_version", None) is not None ) - self._pre_download_model(model, args) + with _temporarily_sanitized_pythonpath_env(): + self._pre_download_model(model, args) self._shutdown_complete = False # Record GPU memory before server start so we know what @@ -538,11 +579,22 @@ def _get_gpu_memory_used(self) -> float | None: if current_platform.is_rocm(): with _nvml(): handles = amdsmi_get_processor_handles() - total_used = 0 - for handle in handles: + devices = get_physical_device_indices( + list(range(current_platform.device_count())) + ) + total_used_mib = 0 + for device in devices: + handle = handles[device] vram_info = amdsmi_get_gpu_vram_usage(handle) - total_used += vram_info["vram_used"] - return total_used + total_used_mib += vram_info["vram_used"] + # amdsmi reports VRAM in MiB; convert to bytes so this + # matches the CUDA/nvml branch (already bytes) and the + # byte-based target in _wait_for_gpu_memory_release. Without + # this, that wait compares MiB against a ~2e9-byte target, + # is always satisfied instantly, and returns "released to + # 0.00 GB" while the previous server's VRAM is still + # resident -- OOMing the next server's startup on ROCm. + return total_used_mib * 1024 * 1024 elif current_platform.is_cuda(): with _nvml(): total_used = 0 @@ -552,6 +604,13 @@ def _get_gpu_memory_used(self) -> float | None: mem_info = nvmlDeviceGetMemoryInfo(handle) total_used += mem_info.used return total_used + elif current_platform.is_xpu(): + total_used = 0 + device_count = current_platform.device_count() + for i in range(device_count): + free, total = torch.xpu.mem_get_info(i) + total_used += total - free + return total_used except Exception as e: print(f"[RemoteOpenAIServer] Could not query GPU memory: {e}") return None @@ -716,6 +775,7 @@ def _start_server( env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" if env_dict is not None: env.update(env_dict) + _sanitize_pythonpath_env(env) serve_cmd = ["vllm", "serve", model, *vllm_serve_args] print(f"Launching RemoteOpenAIServer with: {' '.join(serve_cmd)}") print(f"Environment variables: {env}") @@ -743,6 +803,7 @@ def _start_server( env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" if env_dict is not None: env.update(env_dict) + _sanitize_pythonpath_env(env) serve_cmd = ["vllm", "launch", "render", model, *vllm_serve_args] print(f"Launching RemoteLaunchRenderServer with: {' '.join(serve_cmd)}") self.proc: subprocess.Popen = subprocess.Popen( @@ -784,7 +845,8 @@ def _start_server( target=_run_in_new_process_group, args=(self.child_process_fxn, env_dict, model, vllm_serve_args), ) # type: ignore[assignment] - self.proc.start() + with _temporarily_sanitized_pythonpath_env(): + self.proc.start() def __init__( self, @@ -1397,6 +1459,46 @@ def multi_process_parallel( ray.shutdown() +def assert_rocm_custom_allreduce_backend_state( + use_aiter_custom_ar: bool, + quick_reduce_quantization: str, +) -> None: + from vllm.distributed.parallel_state import get_tp_group + + device_communicator = get_tp_group().device_communicator + aiter_ar_comm = device_communicator.aiter_ar_comm + if use_aiter_custom_ar: + assert aiter_ar_comm is not None, "AITER CustomAllreduce was not initialized." + assert not aiter_ar_comm.disabled, "AITER CustomAllreduce is disabled." + assert device_communicator.ca_comm is None, ( + "vLLM CustomAllreduce should not be initialized when AITER CA is used." + ) + else: + assert aiter_ar_comm is None, ( + "AITER CustomAllreduce should not be initialized when disabled." + ) + assert device_communicator.ca_comm is not None, ( + "vLLM CustomAllreduce should be initialized when AITER CA is disabled." + ) + + qr_comm = device_communicator.qr_comm + assert qr_comm is not None, "QuickReduce communicator was not initialized." + if quick_reduce_quantization == "NONE": + assert qr_comm.disabled, "QuickReduce should be disabled." + else: + assert not qr_comm.disabled, "QuickReduce should be enabled." + + +def assert_rocm_custom_allreduce_backend_state_on_worker( + _worker, + use_aiter_custom_ar: bool, + quick_reduce_quantization: str, +) -> None: + assert_rocm_custom_allreduce_backend_state( + use_aiter_custom_ar, quick_reduce_quantization + ) + + @contextmanager def error_on_warning(category: type[Warning] = Warning): """ @@ -1409,7 +1511,7 @@ def error_on_warning(category: type[Warning] = Warning): yield -def get_physical_device_indices(devices): +def get_physical_device_indices(devices: list[int]): visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES") if visible_devices is None: return devices @@ -1420,54 +1522,147 @@ def get_physical_device_indices(devices): @_nvml() +def record_gpu_memory_usage_stats( + *, + devices: list[int], +) -> dict[int, tuple[float, float]]: + output: dict[int, tuple[float, float]] = {} + for device in devices: + if current_platform.is_rocm(): + dev_handle = amdsmi_get_processor_handles()[device] + mem_info = amdsmi_get_gpu_vram_usage(dev_handle) + gb_used = mem_info["vram_used"] / 2**10 + gb_total = mem_info["vram_total"] / 2**10 + else: + dev_handle = nvmlDeviceGetHandleByIndex(device) + mem_info = nvmlDeviceGetMemoryInfo(dev_handle) + gb_used = mem_info.used / 2**30 + gb_total = mem_info.total / 2**30 + output[device] = (gb_used, gb_total) + return output + + def wait_for_gpu_memory_to_clear( *, devices: list[int], - threshold_bytes: int | None = None, - threshold_ratio: float | None = None, + threshold_bytes: int | dict[int, int] | None = None, + threshold_ratio: float | dict[int, float] | None = None, timeout_s: float = 120, + stable_duration_s: float = 0, + stable_tolerance_bytes: int = 512 * 1024**2, + poll_interval_s: float = 5, ) -> None: assert threshold_bytes is not None or threshold_ratio is not None + devices = get_physical_device_indices(devices) + if isinstance(threshold_bytes, int): + threshold_bytes = {device: threshold_bytes for device in devices} + elif isinstance(threshold_bytes, dict): + assert threshold_bytes.keys() == set(devices) + if isinstance(threshold_ratio, float): + threshold_ratio = {device: threshold_ratio for device in devices} + elif isinstance(threshold_ratio, dict): + assert threshold_ratio.keys() == set(devices) + if current_platform.is_rocm() and threshold_ratio is not None: + # ROCm can keep a small runtime/driver footprint resident even after + # all model allocations are gone. On MI300 this has been observed + # around 2.5 GiB, which is above a strict 1% idle threshold but nowhere + # near the amount of free memory needed by the next vLLM runner. + min_threshold_b = 4 * 1024**3 + if threshold_bytes is None: + threshold_bytes = {} + for device, ratio in threshold_ratio.items(): + threshold_bytes[device] = max( + threshold_bytes.get(device, 0), min_threshold_b if ratio < 0.05 else 0 + ) + # Use nvml instead of pytorch to reduce measurement error from torch cuda # context. - devices = get_physical_device_indices(devices) start_time = time.time() + stable_since: float | None = None + stable_used_bytes: dict[int, int] | None = None while True: - output: dict[int, str] = {} - output_raw: dict[int, tuple[float, float]] = {} - for device in devices: - if current_platform.is_rocm(): - dev_handle = amdsmi_get_processor_handles()[device] - mem_info = amdsmi_get_gpu_vram_usage(dev_handle) - gb_used = mem_info["vram_used"] / 2**10 - gb_total = mem_info["vram_total"] / 2**10 - else: - dev_handle = nvmlDeviceGetHandleByIndex(device) - mem_info = nvmlDeviceGetMemoryInfo(dev_handle) - gb_used = mem_info.used / 2**30 - gb_total = mem_info.total / 2**30 - output_raw[device] = (gb_used, gb_total) - output[device] = f"{gb_used:.02f}/{gb_total:.02f}" - + output_raw = record_gpu_memory_usage_stats(devices=devices) + used_bytes_by_device = { + device: int(gb_used * 2**30) for device, (gb_used, _) in output_raw.items() + } + output = { + device: f"{gb_used:.02f}/{gb_total:.02f}" + for device, (gb_used, gb_total) in output_raw.items() + } print("gpu memory used/total (GiB): ", end="") for k, v in output.items(): print(f"{k}={v}; ", end="") print("") - if threshold_bytes is not None: - is_free = lambda used, total: used <= threshold_bytes / 2**30 - threshold = f"{threshold_bytes / 2**30} GiB" + if threshold_bytes is not None and threshold_ratio is not None: + threshold_gib = { + device: threshold_b / 2**30 + for device, threshold_b in threshold_bytes.items() + } + threshold = "; ".join( + f"{device=}: max({threshold_gib[device]:.2f} GiB, " + f"{threshold_ratio[device]:.3f})" + for device in devices + ) + all_free = all( + used <= max(threshold_gib[device], total * threshold_ratio[device]) + for device, (used, total) in output_raw.items() + ) + elif threshold_bytes is not None: + threshold_gib = { + device: threshold_b / 2**30 + for device, threshold_b in threshold_bytes.items() + } + threshold = "; ".join( + f"{device=}: {threshold_gib[device]:.2f} GiB" for device in devices + ) + all_free = all( + used <= threshold_gib[device] + for device, (used, _) in output_raw.items() + ) else: - is_free = lambda used, total: used / total <= threshold_ratio - threshold = f"{threshold_ratio:.2f}" + assert threshold_ratio is not None + threshold = "; ".join( + f"{device=}: {threshold_ratio[device]:.3f}" for device in devices + ) + all_free = all( + used / total <= threshold_ratio[device] + for device, (used, total) in output_raw.items() + ) dur_s = time.time() - start_time - if all(is_free(used, total) for used, total in output_raw.values()): - print( - f"Done waiting for free GPU memory on devices {devices=} " - f"({threshold=}) {dur_s=:.02f}" - ) - break + if all_free: + if stable_duration_s <= 0: + print( + f"Done waiting for free GPU memory on devices {devices=} " + f"({threshold=}) {dur_s=:.02f}" + ) + break + + now = time.time() + if stable_used_bytes is None: + stable_since = now + stable_used_bytes = used_bytes_by_device + else: + memory_changed = any( + abs(used_bytes_by_device[device] - stable_used_bytes[device]) + > stable_tolerance_bytes + for device in devices + ) + if memory_changed: + stable_since = now + stable_used_bytes = used_bytes_by_device + elif ( + stable_since is not None and now - stable_since >= stable_duration_s + ): + print( + f"Done waiting for stable free GPU memory on devices " + f"{devices=} ({threshold=}) {dur_s=:.02f}" + ) + break + else: + stable_since = None + stable_used_bytes = None if dur_s >= timeout_s: raise ValueError( @@ -1475,7 +1670,37 @@ def wait_for_gpu_memory_to_clear( f"{dur_s=:.02f} ({threshold=})" ) - time.sleep(5) + time.sleep(poll_interval_s) + + +def wait_for_rocm_memory_to_settle( + *, + threshold_ratio: float | dict[int, float] | None = 0.1, + timeout_s: float = 240, +) -> None: + """Block until ROCm device VRAM usage drops below ``threshold_ratio``. + + ROCm reclaims GPU memory more lazily than CUDA, so back-to-back model + loads in a single test process can OOM the *next* engine/model startup + even after ``cleanup_dist_env_and_memory``. This gives the driver time to + actually release VRAM before the next allocation. No-op off ROCm. + """ + if not current_platform.is_rocm(): + return + + num_gpus = current_platform.device_count() + if num_gpus == 0: + return + if threshold_ratio is None: + threshold_ratio = 0.1 + + wait_for_gpu_memory_to_clear( + devices=list(range(num_gpus)), + threshold_ratio=threshold_ratio, + timeout_s=timeout_s, + stable_duration_s=2.0, + poll_interval_s=1.0, + ) _P = ParamSpec("_P") diff --git a/tests/utils_/test_async_utils.py b/tests/utils_/test_async_utils.py index 03d116bdfd81..cd41cdaf264a 100644 --- a/tests/utils_/test_async_utils.py +++ b/tests/utils_/test_async_utils.py @@ -40,3 +40,25 @@ async def stream_output(generator: AsyncIterator[tuple[int, str]]): print("Iterator was cancelled normally") except (Exception, asyncio.CancelledError) as e: raise AssertionError() from e + + +@pytest.mark.asyncio +async def test_merge_async_iterators_single_closes_underlying(): + # The single-iterator fast path must close the underlying generator when + # the merged generator is closed, matching the multi-iterator path. On the + # buggy fast path the underlying generator is left running. + closed = False + + async def gen(): + nonlocal closed + try: + while True: + yield "x" + await asyncio.sleep(0.01) + finally: + closed = True + + merged = merge_async_iterators(gen()) + assert await anext(merged) == (0, "x") + await merged.aclose() + assert closed diff --git a/tests/utils_/test_gpu_sync_debug.py b/tests/utils_/test_gpu_sync_debug.py new file mode 100644 index 000000000000..ea9b76f34bcb --- /dev/null +++ b/tests/utils_/test_gpu_sync_debug.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import pytest +import torch + +import vllm.utils.gpu_sync_debug as gsd +from vllm.utils.gpu_sync_debug import ( + SYNC_ERROR_MESSAGE, + gpu_sync_allowed, + with_gpu_sync_check, +) + +from ..utils import create_new_process_for_each_test + +pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA") + + +def _no_sync(): + # Pure on-GPU compute, no implicit CPU sync... + x = torch.ones(4, device="cuda") + 1 + # ...plus a sync that we explicitly allow. + with gpu_sync_allowed(): + return x.cpu() + + +def _causes_sync(): + x = torch.ones(4, device="cuda") + # An allowed sync (suppressed)... + with gpu_sync_allowed(): + x.cpu() + # ...then an un-allowed sync that should trip the check. + return x.cpu() + + +@pytest.mark.parametrize("mode", ["warn", "error"]) +@create_new_process_for_each_test() +def test_with_env_set(monkeypatch, mode): + # Env set + gate flipped on: the unguarded sync is detected. + monkeypatch.setenv("VLLM_GPU_SYNC_CHECK", mode) + monkeypatch.setattr(gsd, "_sync_check_enabled", True) + + # Guarded syncs always pass. + with_gpu_sync_check(_no_sync)() + + if mode == "error": + # "error" mode turns the stray sync into a RuntimeError. + with pytest.raises(RuntimeError, match=SYNC_ERROR_MESSAGE): + with_gpu_sync_check(_causes_sync)() + else: + # "warn" mode only warns, so the call still succeeds. + with_gpu_sync_check(_causes_sync)() + + +@create_new_process_for_each_test() +def test_without_env_set(monkeypatch): + # Env unset: the decorator is a pass-through, no sync is detected. + monkeypatch.delenv("VLLM_GPU_SYNC_CHECK", raising=False) + monkeypatch.setattr(gsd, "_sync_check_enabled", True) + + with_gpu_sync_check(_no_sync)() + with_gpu_sync_check(_causes_sync)() diff --git a/tests/utils_/test_mem_utils.py b/tests/utils_/test_mem_utils.py index 861e73c7dedd..421aec3e9b1f 100644 --- a/tests/utils_/test_mem_utils.py +++ b/tests/utils_/test_mem_utils.py @@ -36,7 +36,7 @@ def test_memory_profiling(): weights_memory = 128 * 1024 * 1024 * 4 # 512 MiB def measure_current_non_torch(): - free, total = torch.cuda.mem_get_info() + free, total = torch.accelerator.get_memory_info() current_used = total - free current_torch = torch.accelerator.memory_reserved() current_non_torch = current_used - current_torch @@ -81,8 +81,9 @@ def test_memory_snapshot_uses_psutil_on_integrated_gpu(): with ( patch("vllm.utils.mem_utils.current_platform") as mock_platform, patch("vllm.utils.mem_utils.psutil") as mock_psutil, + patch("torch.accelerator") as mock_accelerator, ): - mock_platform.mem_get_info.return_value = ( + mock_accelerator.get_memory_info.return_value = ( mock_cuda_free, mock_cuda_total, ) @@ -90,8 +91,8 @@ def test_memory_snapshot_uses_psutil_on_integrated_gpu(): mock_platform.memory_stats.return_value = { "allocated_bytes.all.peak": 0, } - mock_platform.memory_reserved.return_value = 0 - mock_platform.current_device = lambda: "cuda:0" + mock_accelerator.memory_reserved.return_value = 0 + mock_accelerator.current_device = lambda: "cuda:0" mock_vmem = MagicMock() mock_vmem.available = mock_psutil_available @@ -105,24 +106,25 @@ def test_memory_snapshot_uses_psutil_on_integrated_gpu(): def test_memory_snapshot_uses_cuda_on_discrete_gpu(): - """On discrete GPUs, free_memory should come from CUDA mem_get_info.""" + """On discrete GPUs, free_memory should come from accelerator get_memory_info.""" mock_cuda_free = 70 * 1024**3 mock_cuda_total = 80 * 1024**3 with ( patch("vllm.utils.mem_utils.current_platform") as mock_platform, patch("vllm.utils.mem_utils.psutil") as mock_psutil, + patch("torch.accelerator") as mock_accelerator, ): - mock_platform.mem_get_info.return_value = ( + mock_accelerator.get_memory_info.return_value = ( mock_cuda_free, mock_cuda_total, ) mock_platform.is_integrated_gpu.return_value = False - mock_platform.memory_stats.return_value = { + mock_accelerator.memory_stats.return_value = { "allocated_bytes.all.peak": 0, } - mock_platform.memory_reserved.return_value = 0 - mock_platform.current_device = lambda: "cuda:0" + mock_accelerator.memory_reserved.return_value = 0 + mock_accelerator.current_device = lambda: "cuda:0" snapshot = MemorySnapshot(device="cuda:0") diff --git a/tests/utils_/test_numa_utils.py b/tests/utils_/test_numa_utils.py index 0f615fb8c47e..9f718703a7c1 100644 --- a/tests/utils_/test_numa_utils.py +++ b/tests/utils_/test_numa_utils.py @@ -464,3 +464,49 @@ def test_parallel_config_validates_numa_bind_nodes(): def test_parallel_config_rejects_invalid_numa_bind_cpus(cpuset): with pytest.raises(ValueError, match="numa_bind_cpus"): ParallelConfig(numa_bind_cpus=[cpuset]) + + +def _fake_numactl_run(rejected_args): + """Fake ``numactl`` that fails when any of ``rejected_args`` is present.""" + + def run(cmd, *args, **kwargs): + arg_str = " ".join(cmd[1:-1]) + ok = not any(bad in arg_str for bad in rejected_args) + return SimpleNamespace(returncode=0 if ok else 1) + + return run + + +def test_configure_subprocess_numa_fallback(monkeypatch): + import multiprocessing + + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/numactl") + monkeypatch.setattr(numa_utils.envs, "VLLM_WORKER_MULTIPROC_METHOD", "spawn") + node_config = _make_config(numa_bind=True, numa_bind_nodes=[0]) + + monkeypatch.setattr(numa_utils.subprocess, "run", _fake_numactl_run([])) + with numa_utils.configure_subprocess(node_config, local_rank=0): + assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "--cpunodebind=0 --membind=0" + + membind_fails = _fake_numactl_run(["--membind="]) + monkeypatch.setattr(numa_utils.subprocess, "run", membind_fails) + with numa_utils.configure_subprocess(node_config, local_rank=0): + assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "--cpunodebind=0" + + cpu_config = _make_config( + numa_bind=True, + numa_bind_nodes=[0], + numa_bind_cpus=["0-3"], + ) + with numa_utils.configure_subprocess(cpu_config, local_rank=0): + assert os.environ[numa_utils._NUMACTL_ARGS_ENV] == "--physcpubind=0-3" + + before = multiprocessing.spawn.get_executable() + monkeypatch.setattr( + numa_utils.subprocess, + "run", + _fake_numactl_run(["--cpunodebind=", "--membind="]), + ) + with numa_utils.configure_subprocess(node_config, local_rank=0): + assert multiprocessing.spawn.get_executable() == before + assert numa_utils._NUMACTL_ARGS_ENV not in os.environ diff --git a/tests/v1/attention/test_attention_backends.py b/tests/v1/attention/test_attention_backends.py index 62643032edb4..d630037e9bbd 100644 --- a/tests/v1/attention/test_attention_backends.py +++ b/tests/v1/attention/test_attention_backends.py @@ -16,7 +16,7 @@ try_backend_includes_kv_cache_update, try_get_attention_backend, ) -from vllm.config import ModelConfig +from vllm.config import ModelConfig, set_current_vllm_config from vllm.platforms import current_platform from vllm.utils.math_utils import cdiv from vllm.utils.torch_utils import ( @@ -24,7 +24,11 @@ is_torch_equal_or_newer, set_random_seed, ) -from vllm.v1.attention.backend import AttentionType, CommonAttentionMetadata +from vllm.v1.attention.backend import ( + AttentionCGSupport, + AttentionType, + CommonAttentionMetadata, +) from vllm.v1.attention.backends.registry import AttentionBackendEnum from vllm.v1.attention.backends.utils import ( set_kv_cache_layout, @@ -626,6 +630,118 @@ def causal_mask_mod( ) +@pytest.mark.skipif( + AttentionBackendEnum.FLASHINFER not in BACKENDS_TO_TEST, + reason="FlashInfer is not available.", +) +def test_flashinfer_xqa_bmm1_scale_matches_decode_q_dtype(): + """XQA decode should only apply q_scale when decode Q is FP8.""" + from vllm.v1.attention.backends import flashinfer as flashinfer_backend + + class MockLayer: + _q_scale_float = 2.0 + _k_scale_float = 3.0 + + impl = object.__new__(flashinfer_backend.FlashInferImpl) + impl.scale = 0.5 + impl.kv_cache_dtype = "fp8" + + assert impl.get_xqa_bmm1_scale(MockLayer, torch.bfloat16) == 1.5 + assert impl.get_xqa_bmm1_scale(MockLayer, torch.float8_e4m3fn) == 3.0 + + +@pytest.mark.skipif( + AttentionBackendEnum.FLASHINFER not in BACKENDS_TO_TEST, + reason="FlashInfer is not available.", +) +def test_flashinfer_sm90_xqa_decode_correctness(default_vllm_config): + """FlashInfer should route Hopper decode through XQA and match SDPA.""" + if not current_platform.is_cuda() or not current_platform.is_device_capability(90): + pytest.skip("FlashInfer XQA decode requires SM90.") + + import unittest.mock + + from vllm.utils.flashinfer import can_use_trtllm_attention + from vllm.v1.attention.backends import flashinfer as flashinfer_backend + from vllm.v1.attention.backends.utils import PerLayerParameters + + def mock_get_per_layer_parameters(vllm_config, layer_names, impl_cls): + return { + "placeholder": PerLayerParameters( + window_left=-1, + logits_soft_cap=0.0, + sm_scale=1.0, + ) + } + + def causal_mask_mod( + b: torch.Tensor, + h: torch.Tensor, + q_idx: torch.Tensor, + kv_idx: torch.Tensor, + *, + context_len: int, + ): + return (q_idx + context_len) >= kv_idx + + batch_spec = BATCH_SPECS["small_decode"] + vllm_config = create_vllm_config( + model_name="meta-llama/Meta-Llama-3-8B", + max_model_len=max(batch_spec.seq_lens), + block_size=16, + ) + device = torch.device(f"{DEVICE_TYPE}:0") + kv_cache_spec = FullAttentionSpec( + block_size=vllm_config.cache_config.block_size, + num_kv_heads=vllm_config.model_config.get_num_kv_heads( + vllm_config.parallel_config + ), + head_size=vllm_config.model_config.get_head_size(), + dtype=vllm_config.model_config.dtype, + ) + + with set_current_vllm_config(vllm_config): + if not can_use_trtllm_attention( + vllm_config.model_config.get_num_attention_heads( + vllm_config.parallel_config + ), + kv_cache_spec.num_kv_heads, + is_prefill=False, + ): + pytest.skip("FlashInfer XQA decode is not available in this setup.") + + with unittest.mock.patch( + "vllm.v1.attention.backends.flashinfer.get_per_layer_parameters", + mock_get_per_layer_parameters, + ): + builder = flashinfer_backend.FlashInferMetadataBuilder( + kv_cache_spec, ["placeholder"], vllm_config, device + ) + common_attn_metadata = create_common_attn_metadata( + batch_spec, vllm_config.cache_config.block_size, device + ) + attn_metadata = builder.build(0, common_attn_metadata) + + assert ( + flashinfer_backend.FlashInferMetadataBuilder.get_cudagraph_support( + vllm_config, kv_cache_spec + ) + == AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE + ) + assert isinstance( + attn_metadata.decode, + flashinfer_backend.FlashInferTrtllmAPIDecode, + ) + assert attn_metadata.decode.kernel == flashinfer_backend.FlashInferDecodeKernel.XQA + + _test_backend_correctness( + batch_spec, + "meta-llama/Meta-Llama-3-8B", + [AttentionBackendEnum.FLASHINFER], + causal_mask_mod, + ) + + if current_platform.is_rocm(): # FLASH_ATTN is not supported on ROCm SLIDING_WINDOW_BACKENDS_TO_TEST = [ @@ -656,7 +772,7 @@ def causal_mask_mod( @pytest.mark.parametrize("model", ["microsoft/Phi-tiny-MoE-instruct"]) @pytest.mark.parametrize("tensor_parallel_size", [1, 2, 4]) def test_sliding_window_backend_correctness( - batch_spec_name: str, model: str, tensor_parallel_size: int + default_vllm_config, batch_spec_name: str, model: str, tensor_parallel_size: int ): """Test backend's correctness with sliding window attention.""" @@ -718,7 +834,7 @@ def sliding_window_mask_mod( @pytest.mark.parametrize("model", ["google/embeddinggemma-300m"]) @pytest.mark.parametrize("tensor_parallel_size", [1, 2]) def test_sliding_window_encoder_backend_correctness( - batch_spec_name: str, model: str, tensor_parallel_size: int + default_vllm_config, batch_spec_name: str, model: str, tensor_parallel_size: int ): """Test backend's correctness with sliding window attention.""" diff --git a/tests/v1/attention/test_attention_backends_selection.py b/tests/v1/attention/test_attention_backends_selection.py index 4242cc5ff2e2..8486d216a125 100644 --- a/tests/v1/attention/test_attention_backends_selection.py +++ b/tests/v1/attention/test_attention_backends_selection.py @@ -6,10 +6,12 @@ import pytest +from vllm.model_executor.layers.mamba.linear.minimax_linear_attn import ( + MiniMaxText01LinearAttention, +) from vllm.model_executor.layers.mamba.mamba_mixer import MambaMixer from vllm.model_executor.layers.mamba.mamba_mixer2 import MambaMixer2 from vllm.model_executor.layers.mamba.short_conv import ShortConv -from vllm.model_executor.models.minimax_text_01 import MiniMaxText01LinearAttention from vllm.v1.attention.backends.linear_attn import LinearAttentionBackend from vllm.v1.attention.backends.mamba1_attn import Mamba1AttentionBackend from vllm.v1.attention.backends.mamba2_attn import Mamba2AttentionBackend @@ -54,15 +56,14 @@ ( MiniMaxText01LinearAttention, dict( - hidden_size=128, - hidden_inner_size=256, - num_heads=8, - head_dim=32, - max_position=2048, - block_size=64, - num_hidden_layer=12, - layer_idx=0, - linear_layer_idx=0, + config=SimpleNamespace( + hidden_size=256, + num_attention_heads=8, + head_dim=32, + num_hidden_layers=12, + block=64, + ), + prefix="layers.0.self_attn", ), LinearAttentionBackend, MambaAttentionBackendEnum.LINEAR, @@ -88,6 +89,8 @@ def test_mamba_layers_get_attn_backend( expected_mamba_type, ): """Test that Mamba-like layers return the correct attention backend.""" + if layer_class is MiniMaxText01LinearAttention: + init_kwargs["vllm_config"] = default_vllm_config layer = layer_class(**init_kwargs) backend_class = layer.get_attn_backend() diff --git a/tests/v1/attention/test_dspark_noncausal_sparse_mla.py b/tests/v1/attention/test_dspark_noncausal_sparse_mla.py new file mode 100644 index 000000000000..ebb29c9af065 --- /dev/null +++ b/tests/v1/attention/test_dspark_noncausal_sparse_mla.py @@ -0,0 +1,529 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Correctness tests for DSpark non-causal sliding-window MLA via sparse indices. + +DSpark drafts a block of N tokens whose attention is NON-CAUSAL within the block: +every block token attends to the sliding window of context AND to all block +tokens (including ones at later positions than itself). + +We can implement this using the existing sparse-MLA pathway by expanding the window size +to include the rest of the block tokens: instead of setting topk indices to the 127 +previous tokens, we expand it to the next power of 2 (256) and include up to +swa_size + block_size - 1 topk indices, so that each query attends to the rest. The +remaining slots are filled with padding. + +The sparse-MLA decode kernels (FlashMLA on SM90/SM100, FlashInfer TRTLLM on +SM100/SM120) are index-driven: each query attends over exactly the slots in its +index list, with no causal mask (see ``flash_mla_with_kvcache(..., indices=...)`` +and ``_forward_decode``'s "attend only by generated indices"). The existing +``test_sparse_mla_backends`` suite already validates arbitrary index lists, but +only ones whose entries are <= the query's own position. This test suite specifically +ensures correctness of the non-causal attention case. + +This reuses the harness/helpers of ``test_sparse_mla_backends.py`` (same model +shapes, fp8_ds_mla round-trip, mock indexer, MockSparseMLAAttentionLayer); only +the index construction differs. +""" + +import math +from types import MethodType, SimpleNamespace + +import pytest +import torch + +from tests.v1.attention.test_mla_backends import ( + BatchSpec, + MockSparseMLAAttentionLayer, + create_and_prepopulate_kv_cache, +) +from tests.v1.attention.test_sparse_mla_backends import ( + _quantize_dequantize_fp8_ds_mla, +) +from tests.v1.attention.utils import ( + create_common_attn_metadata, + create_standard_kv_cache_spec, + create_vllm_config, +) +from vllm.config import set_current_vllm_config +from vllm.model_executor.layers.linear import ColumnParallelLinear +from vllm.platforms import current_platform + +if not current_platform.is_cuda(): + pytest.skip( + "DSpark non-causal sparse MLA tests currently only support CUDA.", + allow_module_level=True, + ) + +from vllm.utils.math_utils import cdiv +from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import ( + FlashInferMLASparseTRTLLMBackend, +) +from vllm.v1.attention.backends.mla.flashmla_sparse import FlashMLASparseBackend +from vllm.v1.attention.ops import flashmla + +DEVICE_TYPE = current_platform.device_type + +# (window, block_size, topk_width). topk_width must be a multiple of the kernel's +# B_TOPK (= padded query-head count, 64 or 128); we use 128-multiples to cover +# both. The "wide" case needs window + block > 128 -> width must grow past 128. +_DSPARK_CONFIGS = { + "small_block": (8, 4, 128), + "full_window_block": (128, 5, 256), +} + + +def _build_dspark_noncausal_indices( + seq_lens: list[int], + query_lens: list[int], + window: int, + topk_width: int, + device: torch.device, +) -> torch.Tensor: + """Per-token sparse indices for the DSpark non-causal block. + + For a request with context length ``ctx`` and a query block of ``q_len`` + tokens (block positions ``ctx .. ctx+q_len-1``), EVERY block query attends to + the same set: the trailing ``window`` context positions plus all block + positions, i.e. the contiguous range ``[max(ctx-window,0) .. ctx+q_len-1]``. + This is non-causal: an early block query's list contains later block tokens + (future-pointing). The list is padded to ``topk_width`` with ``-1``. + """ + total_query_tokens = sum(query_lens) + sparse_indices = torch.full( + (total_query_tokens, topk_width), -1, dtype=torch.int32, device=device + ) + gt = 0 + for s_len, q_len in zip(seq_lens, query_lens): + ctx_len = s_len - q_len + lo = max(ctx_len - window, 0) + hi = ctx_len + q_len # exclusive: window context + the full block + idx_list = torch.arange(lo, hi, dtype=torch.int32, device=device) + n = idx_list.numel() + assert n <= topk_width, ( + f"index list ({n}) exceeds aligned topk width ({topk_width})" + ) + for _ in range(q_len): + sparse_indices[gt, :n] = idx_list + gt += 1 + return sparse_indices + + +def _run_sparse_backend_vs_sdpa( + backend_cls, + seq_lens: list[int], + query_lens: list[int], + sparse_indices: torch.Tensor, + kv_cache_dtype: str, + block_size: int, + num_heads: int, + device: torch.device, + force_future_dominance: bool = False, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Run a sparse-MLA backend with the given per-token indices and compute a + dense per-token SDPA reference over the SAME indices. + + Mirrors ``test_sparse_mla_backends.test_sparse_backend_decode_correctness`` + but with externally-supplied (non-causal) ``sparse_indices``. + + ``num_heads`` selects the kernel's B_TOPK (= padded q-head count): 128 -> 128, + 64 -> 64. The aligned widths (128/256) are multiples of both, so num_heads=64 + exercises the head64 decode path that the SM100 alignment assert guards. + + ``force_future_dominance`` scales the LAST block token's latent KV so it + dominates the softmax for every query that attends to it. With random data the + few future block tokens carry negligible attention mass (especially with a wide + window), so causal and non-causal outputs coincide; this knob makes the + future-token contribution provably large for the differentiation test. It is + OFF for the correctness test (which needs sensitivity to all tokens). + + Returns (backend_output, noncausal_reference, causal_reference). The causal + reference restricts each query to indices <= its own absolute position. + """ + batch_spec = BatchSpec(seq_lens=seq_lens, query_lens=query_lens) + topk_tokens = sparse_indices.shape[1] + dtype = torch.bfloat16 + use_fp8_ds_mla_quantization = kv_cache_dtype == "fp8_ds_mla" + + kv_lora_rank = 512 + qk_nope_head_dim = 128 + qk_rope_head_dim = 64 + v_head_dim = 128 + head_size = kv_lora_rank + qk_rope_head_dim + + max_seqlen = max(seq_lens) + total_cache_tokens = sum(seq_lens) + + vllm_config = create_vllm_config( + model_name="deepseek-ai/DeepSeek-V2-Lite-Chat", + tensor_parallel_size=1, + max_model_len=max_seqlen, + num_gpu_blocks=max(2048, cdiv(total_cache_tokens, block_size) + 1), + block_size=block_size, + hf_config_override={ + "index_topk": topk_tokens, + "attn_module_list_cfg": [{"topk_tokens": topk_tokens}], + }, + ) + model_config = vllm_config.model_config + model_config.hf_text_config = SimpleNamespace( + q_lora_rank=None, + kv_lora_rank=kv_lora_rank, + qk_nope_head_dim=qk_nope_head_dim, + qk_rope_head_dim=qk_rope_head_dim, + v_head_dim=v_head_dim, + model_type="deepseek_v2", + ) + model_config.dtype = dtype + model_config.get_num_attention_heads = MethodType( + lambda self, parallel_config: num_heads, model_config + ) + model_config.get_num_kv_heads = MethodType( + lambda self, parallel_config: 1, model_config + ) + model_config.get_head_size = MethodType(lambda self: head_size, model_config) + model_config.get_sliding_window = MethodType(lambda self: None, model_config) + + kv_cache_spec = create_standard_kv_cache_spec(vllm_config) + + torch.manual_seed(0) + scale = 1.0 / math.sqrt(head_size) + + # Shared MLA projection weights, used by both reference and backend. + W_UK = torch.rand( + kv_lora_rank, num_heads, qk_nope_head_dim, dtype=dtype, device=device + ) + W_UV = torch.rand(kv_lora_rank, num_heads, v_head_dim, dtype=dtype, device=device) + + all_q_vllm, all_kv_c_vllm, all_k_pe_vllm = [], [], [] + kv_c_contexts, k_pe_contexts = [], [] + reference_outputs = [] + # Causal counterpart of the reference: same index lists, but each query is + # restricted to indices <= its own absolute position (drops future-pointing + # block tokens). Used to prove the non-causal result is genuinely different. + causal_reference_outputs = [] + + kv_cache_scale = torch.tensor(1.0, dtype=torch.float32, device=device) + global_token_idx = 0 + + for s_len, q_len in zip(seq_lens, query_lens): + ctx_len = s_len - q_len + + q_c = torch.rand( + q_len, + num_heads, + qk_nope_head_dim + qk_rope_head_dim, + dtype=dtype, + device=device, + ) + kv_c_full = torch.rand(s_len, kv_lora_rank, dtype=dtype, device=device) + k_pe_full = torch.rand(s_len, 1, qk_rope_head_dim, dtype=dtype, device=device) + + if force_future_dominance: + # Scale the last block token's latent KV so its key/value dominate the + # softmax for any query attending to it. 4x in the latent dot makes its + # pre-softmax score exceed the others by a wide margin, so non-causal + # queries (which include it) diverge sharply from causal ones (which, + # for all but the last query, exclude it). Applied before quantization + # so cache and reference stay consistent. + kv_c_full[s_len - 1] = kv_c_full[s_len - 1] * 4.0 + 2.0 + + if use_fp8_ds_mla_quantization: + is_sm100 = torch.cuda.get_device_capability()[0] >= 10 + kv_c_full, k_pe_squeezed = _quantize_dequantize_fp8_ds_mla( + kv_c_full, + k_pe_full.squeeze(1), + block_size=block_size, + scale=kv_cache_scale, + simulate_sm100_e8m0_scales=is_sm100, + ) + k_pe_full = k_pe_squeezed.unsqueeze(1) + + q_nope, q_pe = q_c.split([qk_nope_head_dim, qk_rope_head_dim], dim=-1) + ql_nope = torch.einsum("qnh,lnh->qnl", q_nope, W_UK) + q_mqa = torch.cat([ql_nope, q_pe], dim=-1) + + k_mqa = torch.cat([kv_c_full, k_pe_full.squeeze(1)], dim=-1) + v_mqa = kv_c_full + + # Per-token sparse SDPA reference over the supplied (non-causal) indices. + def _sparse_sdpa(idx_tensor, q_tok, k_mqa=k_mqa, v_mqa=v_mqa): + k_sparse = k_mqa[idx_tensor].unsqueeze(1).expand(-1, num_heads, -1) + v_sparse = v_mqa[idx_tensor].unsqueeze(1).expand(-1, num_heads, -1) + out = torch.nn.functional.scaled_dot_product_attention( + q_tok.unsqueeze(0).transpose(1, 2), + k_sparse.unsqueeze(0).transpose(1, 2), + v_sparse.unsqueeze(0).transpose(1, 2), + scale=scale, + ) + out = out.transpose(1, 2).squeeze(0) + out = torch.einsum("qnl,lnv->qnv", out, W_UV) + return out.flatten(start_dim=-2) + + for q_idx in range(q_len): + tok_sparse_idx = sparse_indices[global_token_idx] + valid_indices = tok_sparse_idx[tok_sparse_idx >= 0].long() + + q_tok = q_mqa[q_idx : q_idx + 1] + reference_outputs.append(_sparse_sdpa(valid_indices, q_tok)) + + # Causal: drop indices pointing past this query's own position. + abs_pos = ctx_len + q_idx + causal_indices = valid_indices[valid_indices <= abs_pos] + causal_reference_outputs.append(_sparse_sdpa(causal_indices, q_tok)) + global_token_idx += 1 + + all_q_vllm.append(q_c) + all_kv_c_vllm.append(kv_c_full[ctx_len:]) + all_k_pe_vllm.append(k_pe_full[ctx_len:]) + kv_c_contexts.append(kv_c_full[: ctx_len + 1]) + k_pe_contexts.append(k_pe_full[: ctx_len + 1]) + + query_vllm = torch.cat(all_q_vllm, dim=0) + kv_c_vllm = torch.cat(all_kv_c_vllm, dim=0) + k_pe_vllm = torch.cat(all_k_pe_vllm, dim=0) + sdpa_reference = torch.cat(reference_outputs, dim=0) + causal_reference = torch.cat(causal_reference_outputs, dim=0) + + vllm_config.cache_config.cache_dtype = kv_cache_dtype + vllm_config.model_config.hf_config.index_topk = topk_tokens + + common_attn_metadata = create_common_attn_metadata( + batch_spec, block_size, device, arange_block_indices=True + ) + kv_cache = create_and_prepopulate_kv_cache( + kv_c_contexts=kv_c_contexts, + k_pe_contexts=k_pe_contexts, + block_size=block_size, + head_size=head_size, + dtype=dtype, + device=device, + num_blocks=vllm_config.cache_config.num_gpu_blocks, + common_attn_metadata=common_attn_metadata, + randomize_blocks=False, + kv_cache_dtype=kv_cache_dtype, + scale=kv_cache_scale, + ) + + builder = backend_cls.get_builder_cls()( + kv_cache_spec, ["placeholder"], vllm_config, device + ) + metadata = builder.build( + common_prefix_len=0, common_attn_metadata=common_attn_metadata + ) + + mock_indexer = SimpleNamespace(topk_indices_buffer=sparse_indices) + + kv_b_proj_weight = torch.cat([W_UK, W_UV], dim=-1).view( + kv_lora_rank, num_heads * (qk_nope_head_dim + v_head_dim) + ) + mock_kv_b_proj = ColumnParallelLinear( + input_size=kv_lora_rank, + output_size=num_heads * (qk_nope_head_dim + v_head_dim), + bias=False, + ).to(device=device, dtype=dtype) + mock_kv_b_proj.weight = torch.nn.Parameter(kv_b_proj_weight.T.contiguous()) + + with set_current_vllm_config(vllm_config): + impl = backend_cls.get_impl_cls()( + num_heads=num_heads, + head_size=head_size, + scale=scale, + num_kv_heads=1, + alibi_slopes=None, + sliding_window=None, + kv_cache_dtype=vllm_config.cache_config.cache_dtype, + logits_soft_cap=None, + attn_type="decoder", + kv_sharing_target_layer_name=None, + q_lora_rank=None, + kv_lora_rank=kv_lora_rank, + qk_nope_head_dim=qk_nope_head_dim, + qk_rope_head_dim=qk_rope_head_dim, + qk_head_dim=qk_nope_head_dim + qk_rope_head_dim, + v_head_dim=v_head_dim, + kv_b_proj=mock_kv_b_proj, + indexer=mock_indexer, + ) + impl.process_weights_after_loading(dtype) + mock_layer = MockSparseMLAAttentionLayer( + impl=impl, + num_heads=num_heads, + qk_nope_head_dim=qk_nope_head_dim, + qk_rope_head_dim=qk_rope_head_dim, + v_head_dim=v_head_dim, + kv_lora_rank=kv_lora_rank, + device=device, + W_UK=W_UK, + W_UV=W_UV, + q_scale=1.0, + k_scale=1.0, + ) + + out_buffer = torch.empty( + metadata.num_actual_tokens, num_heads * v_head_dim, dtype=dtype, device=device + ) + with torch.inference_mode(): + backend_output = mock_layer.forward_impl( + query_vllm, kv_c_vllm, k_pe_vllm, kv_cache, metadata, out_buffer + ) + return backend_output, sdpa_reference, causal_reference + + +def _skip_if_backend_unavailable(backend_cls, kv_cache_dtype: str, block_size: int): + if kv_cache_dtype not in backend_cls.supported_kv_cache_dtypes: + pytest.skip(f"{backend_cls.get_name()} does not support {kv_cache_dtype}") + if ( + backend_cls is FlashMLASparseBackend + and kv_cache_dtype.startswith("fp8") + and kv_cache_dtype != "fp8_ds_mla" + ): + pytest.skip("FlashMLA Sparse fp8 only supports fp8_ds_mla kv-cache dtype") + if block_size not in backend_cls.get_supported_kernel_block_sizes(): + pytest.skip( + f"{backend_cls.get_name()} does not support block_size={block_size}" + ) + if backend_cls is FlashMLASparseBackend: + ok, reason = flashmla.is_flashmla_sparse_supported() + if not ok: + pytest.skip(reason) + elif backend_cls is FlashInferMLASparseTRTLLMBackend: + cap = current_platform.get_device_capability() + if cap is None or not backend_cls.supports_compute_capability(cap): + pytest.skip("FlashInferMLASparseTRTLLMBackend requires SM 10.x capability") + + +@pytest.mark.parametrize( + "backend_cls", + [FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend], + ids=["FlashMLA", "FlashInferTRTLLM"], +) +@pytest.mark.parametrize("config_name", list(_DSPARK_CONFIGS.keys())) +# Per backend, the skip logic routes fp8 to the supported flavor: FlashMLA tests +# auto + fp8_ds_mla (and skips per-tensor "fp8", which it aliases to ds_mla); +# FlashInfer TRTLLM tests auto + per-tensor "fp8" (and skips fp8_ds_mla, which it +# does not implement). So both backends get a bf16 case and an fp8 case. +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8_ds_mla", "fp8"]) +@pytest.mark.parametrize("block_size", [64]) +# h_q=128 -> B_TOPK=128; h_q=64 -> B_TOPK=64 (covers the head64 decode path the +# SM100 alignment assert specifically guards). Aligned widths (128/256) satisfy both. +@pytest.mark.parametrize("num_heads", [128, 64], ids=["h128", "h64"]) +def test_dspark_noncausal_sparse_mla_matches_sdpa( + default_vllm_config, + dist_init, + workspace_init, + backend_cls, + config_name, + kv_cache_dtype, + block_size, + num_heads, +): + """Non-causal (window ∪ block, future-pointing) per-token indices must match + a dense SDPA reference over the same indices, for both sparse-MLA backends.""" + _skip_if_backend_unavailable(backend_cls, kv_cache_dtype, block_size) + + window, block, topk_width = _DSPARK_CONFIGS[config_name] + device = torch.device(DEVICE_TYPE) + + # Decode-style batch: each request has `block` query tokens and enough + # context for a full sliding window. + seq_lens = [window + block + 123, window + block + 50] + query_lens = [block, block] + + sparse_indices = _build_dspark_noncausal_indices( + seq_lens, query_lens, window, topk_width, device + ) + + # Sanity: the construction must actually be non-causal (an early block query + # must reference a later block position than itself). + ctx0 = seq_lens[0] - query_lens[0] + first_query_valid = sparse_indices[0][sparse_indices[0] >= 0] + assert int(first_query_valid.max()) >= ctx0 + query_lens[0] - 1, ( + "expected the first block query to attend to a future block token" + ) + + backend_output, sdpa_reference, _ = _run_sparse_backend_vs_sdpa( + backend_cls, + seq_lens, + query_lens, + sparse_indices, + kv_cache_dtype, + block_size, + num_heads, + device, + ) + + assert backend_output.shape == sdpa_reference.shape + assert backend_output.dtype == sdpa_reference.dtype + assert torch.isfinite(backend_output).all() + if kv_cache_dtype.startswith("fp8"): + rtol, atol = 0.065, 0.05 + else: + rtol, atol = 0.01, 0.01 + torch.testing.assert_close(backend_output, sdpa_reference, rtol=rtol, atol=atol) + + +@pytest.mark.parametrize( + "backend_cls", + [FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend], + ids=["FlashMLA", "FlashInferTRTLLM"], +) +@pytest.mark.parametrize("config_name", list(_DSPARK_CONFIGS.keys())) +@pytest.mark.parametrize("block_size", [64]) +def test_dspark_noncausal_differs_from_causal( + default_vllm_config, + dist_init, + workspace_init, + backend_cls, + config_name, + block_size, +): + """Differentiation guard: prove the backend genuinely attends to the + future-pointing indices (not silently applying a causal mask, and not merely + coinciding with a causal result because future tokens carry little weight). + + With random data the few future block tokens are a negligible fraction of the + attended set (especially with a wide window), so causal and non-causal outputs + are numerically indistinguishable -- that is correct physics, not a backend + bug. To make the check meaningful we use ``force_future_dominance`` so the last + block token dominates the softmax: the backend must then match the non-causal + reference and diverge sharply from the causal one. bf16 (``auto``) suffices; + the property is dtype-independent and fp8 correctness is covered above. + """ + _skip_if_backend_unavailable(backend_cls, "auto", block_size) + + window, block, topk_width = _DSPARK_CONFIGS[config_name] + device = torch.device(DEVICE_TYPE) + seq_lens = [window + block + 123, window + block + 50] + query_lens = [block, block] + + sparse_indices = _build_dspark_noncausal_indices( + seq_lens, query_lens, window, topk_width, device + ) + + backend_output, sdpa_reference, causal_reference = _run_sparse_backend_vs_sdpa( + backend_cls, + seq_lens, + query_lens, + sparse_indices, + "auto", + block_size, + 128, + device, + force_future_dominance=True, + ) + + # The two references must be clearly distinguishable for the check to mean + # anything (dominance guarantees this). + ref_gap = (sdpa_reference - causal_reference).abs().max().item() + assert ref_gap > 0.1, ( + f"non-causal and causal references are too close (gap={ref_gap}); " + "force_future_dominance did not create a separable scenario" + ) + + # Backend must track the NON-causal reference, not the causal one. + torch.testing.assert_close(backend_output, sdpa_reference, rtol=0.01, atol=0.01) + causal_err = (backend_output - causal_reference).abs().max().item() + assert causal_err > 0.1, ( + f"non-causal backend output matches the causal reference " + f"(max abs diff={causal_err}); future-pointing indices are not attended to" + ) diff --git a/tests/v1/attention/test_flashinfer_sparse_mla_sm120_api.py b/tests/v1/attention/test_flashinfer_sparse_mla_sm120_api.py new file mode 100644 index 000000000000..3a7677e7511f --- /dev/null +++ b/tests/v1/attention/test_flashinfer_sparse_mla_sm120_api.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Behavior checks for FlashInfer SM120 sparse MLA backend selection.""" + +from types import SimpleNamespace + +import torch + +from vllm.config import set_current_vllm_config +from vllm.platforms.interface import DeviceCapability +from vllm.utils import flashinfer as fi_utils +from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import ( + FlashInferMLASparseSM120Backend, +) +from vllm.v1.attention.backends.registry import AttentionBackendEnum + + +def _fake_vllm_config(model_type: str) -> SimpleNamespace: + return SimpleNamespace( + model_config=SimpleNamespace( + hf_text_config=SimpleNamespace(model_type=model_type, index_topk=2048), + ), + ) + + +def test_sm120_backend_uses_dedicated_backend_name() -> None: + assert FlashInferMLASparseSM120Backend.get_name() == "FLASHINFER_MLA_SPARSE_SM120" + assert ( + AttentionBackendEnum.FLASHINFER_MLA_SPARSE_SM120.get_class() + is FlashInferMLASparseSM120Backend + ) + + +def test_v32_glm_sm120_backend_accepts_glm_block_size( + monkeypatch, +) -> None: + monkeypatch.setattr(fi_utils, "has_flashinfer_sparse_mla_sm120", lambda: True) + + with set_current_vllm_config(_fake_vllm_config("glm4_moe")): + invalid_reasons = FlashInferMLASparseSM120Backend.validate_configuration( + head_size=576, + dtype=torch.bfloat16, + kv_cache_dtype="fp8", + block_size=256, + use_mla=True, + has_sink=False, + use_sparse=True, + use_mm_prefix=False, + use_per_head_quant_scales=False, + device_capability=DeviceCapability(12, 0), + attn_type="decoder", + ) + + assert invalid_reasons == [] diff --git a/tests/v1/attention/test_gdn_metadata_builder.py b/tests/v1/attention/test_gdn_metadata_builder.py index 6576a9bf331e..221f933d8942 100644 --- a/tests/v1/attention/test_gdn_metadata_builder.py +++ b/tests/v1/attention/test_gdn_metadata_builder.py @@ -16,6 +16,7 @@ create_vllm_config, ) from vllm.config import SpeculativeConfig +from vllm.config.compilation import CUDAGraphMode from vllm.v1.attention.backends.gdn_attn import ( GDNAttentionMetadata, GDNAttentionMetadataBuilder, @@ -123,9 +124,15 @@ class GDNBuildTestCase: def _create_gdn_builder( num_speculative_tokens: int = 0, + full_cuda_graph: bool = False, ) -> GDNAttentionMetadataBuilder: """Create a GDNAttentionMetadataBuilder with minimal config.""" - vllm_config = create_vllm_config(block_size=BLOCK_SIZE) + vllm_config = create_vllm_config( + model_name="Qwen/Qwen3.5-0.8B", + block_size=BLOCK_SIZE, + ) + if full_cuda_graph: + vllm_config.compilation_config.cudagraph_mode = CUDAGraphMode.FULL_AND_PIECEWISE if num_speculative_tokens > 0: vllm_config.speculative_config = SpeculativeConfig( method="ngram", @@ -189,3 +196,28 @@ def test_has_initial_state_after_reclassification(): assert meta.has_initial_state is not None # req0 has context_lens = 65 - 1 = 64 > 0, so has_initial_state[0] = True assert meta.has_initial_state[0].item() is True + + +def test_full_cudagraph_spec_metadata_uses_request_count(): + """FULL cudagraph token padding must not pad request-indexed metadata.""" + num_speculative_tokens = 3 + builder = _create_gdn_builder( + num_speculative_tokens=num_speculative_tokens, + full_cuda_graph=True, + ) + batch = BatchSpec(seq_lens=[80, 96], query_lens=[4, 4]) + meta = _build(builder, batch, num_decode_draft_tokens=[3, 3]) + + assert meta.num_spec_decodes == batch.batch_size + assert meta.num_spec_decode_tokens == batch.compute_num_tokens() + assert meta.spec_state_indices_tensor is not None + assert meta.spec_state_indices_tensor.shape == ( + batch.batch_size, + num_speculative_tokens + 1, + ) + assert meta.spec_sequence_masks is not None + assert meta.spec_sequence_masks.shape == (batch.batch_size,) + assert meta.spec_query_start_loc is not None + assert meta.spec_query_start_loc.shape == (batch.batch_size + 1,) + assert meta.num_accepted_tokens is not None + assert meta.num_accepted_tokens.shape == (batch.batch_size,) diff --git a/tests/v1/attention/test_indexer_dcp_localize.py b/tests/v1/attention/test_indexer_dcp_localize.py new file mode 100644 index 000000000000..2809c17c4dc6 --- /dev/null +++ b/tests/v1/attention/test_indexer_dcp_localize.py @@ -0,0 +1,951 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +import vllm.model_executor.layers.sparse_attn_indexer as sparse_indexer +from vllm.platforms import current_platform +from vllm.utils.import_utils import has_cutedsl +from vllm.v1.attention.backends.mla.indexer import build_prefill_chunk_metadata +from vllm.v1.attention.backends.mla.sparse_utils import ( + triton_filter_and_convert_dcp_index, +) +from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens +from vllm.v1.attention.ops.common import CPTritonContext, correct_attn_out + + +def _local_count(length: int, rank: int, world: int, interleave: int) -> int: + return sum(1 for pos in range(length) if (pos // interleave) % world == rank) + + +def _global_to_local_indices( + global_indices: torch.Tensor, + rank: int, + world: int, + interleave: int, +) -> torch.Tensor: + valid = global_indices >= 0 + global_i64 = global_indices.to(torch.int64).clamp_min(0) + owner = (global_i64 // interleave) % world + local = (global_i64 // (world * interleave)) * interleave + global_i64 % interleave + return torch.where(valid & (owner == rank), local, -1).to(torch.int64) + + +def _local_to_global_indices( + local_indices: torch.Tensor, + rank: int, + world: int, + interleave: int, +) -> torch.Tensor: + valid = local_indices >= 0 + local = local_indices.to(torch.int64).clamp_min(0) + global_indices = ( + (local // interleave) * (world * interleave) + + rank * interleave + + local % interleave + ) + return torch.where(valid, global_indices, -1).to(torch.int64) + + +def _ref_stable_topk_from_candidates_fp64( + candidate_scores: torch.Tensor, + candidate_token_ids: torch.Tensor, + k: int, +) -> torch.Tensor: + """Pure-PyTorch reference for the CuteDSL stable-topk selector order + (score desc, then lowest global token id). Selects the same SET as the + kernel; only the set is compared in tests.""" + num_rows, num_candidates = candidate_scores.shape + device = candidate_scores.device + select_k = min(k, num_candidates) + valid = candidate_token_ids >= 0 + bits = ( + candidate_scores.to(torch.float32).view(torch.int32).to(torch.int64) + & 0xFFFFFFFF + ) + sign = (bits >> 31) & 1 + score_key = ( + torch.where(sign.bool(), bits ^ 0xFFFFFFFF, bits ^ 0x80000000) & 0xFFFFFFFF + ) + id_key = (~candidate_token_ids.to(torch.int64)) & 0xFFFFFFFF + key = (score_key << 32) | id_key + key = torch.where(valid, key, torch.zeros_like(key)) + topk_key = key ^ torch.iinfo(torch.int64).min + _, topk_pos = topk_key.topk(select_k, dim=-1) + + selected = candidate_token_ids.gather(1, topk_pos).to(torch.int32) + selected_valid = valid.gather(1, topk_pos) + selected = torch.where(selected_valid, selected, selected.new_full((), -1)) + if select_k == k: + return selected + pad = torch.full((num_rows, k - select_k), -1, dtype=torch.int32, device=device) + return torch.cat((selected, pad), dim=1) + + +def _attention_from_indices( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + indices: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + valid = indices >= 0 + safe_indices = indices.clamp_min(0) + selected_k = k[safe_indices] + selected_v = v[safe_indices] + scores = torch.einsum("td,tkd->tk", q, selected_k) + scores = scores.masked_fill(~valid, float("-inf")) + lse = torch.logsumexp(scores, dim=-1) + probs = torch.softmax(scores, dim=-1).masked_fill(~valid, 0.0) + out = torch.einsum("tk,tkd->td", probs, selected_v) + empty_rows = ~valid.any(dim=-1) + out[empty_rows] = 0 + lse[empty_rows] = float("-inf") + return out, lse + + +def _dcp_lse_merge( + local_outs: list[torch.Tensor], + local_lses: list[torch.Tensor], +) -> tuple[torch.Tensor, torch.Tensor]: + outs = torch.stack(local_outs, dim=0) + lses = torch.stack(local_lses, dim=0) + merged_lse = torch.logsumexp(lses, dim=0) + weights = torch.exp(lses - merged_lse.unsqueeze(0)) + weights = torch.where(torch.isfinite(weights), weights, torch.zeros_like(weights)) + merged_out = (outs * weights.unsqueeze(-1)).sum(dim=0) + return merged_out, merged_lse + + +class _FakeDCPGroup: + """Single-process stand-in: ``all_gather`` returns the pre-built + concatenation of every rank's packed ``(score, global_id)`` candidates, + mirroring the one packed all-gather the merge issues.""" + + def __init__(self, gathered_packed: torch.Tensor) -> None: + self.gathered_packed = gathered_packed + + def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor: + assert dim == 1 + return self.gathered_packed.clone() + + +def _run_decode_topk( + logits: torch.Tensor, + seq_lens: torch.Tensor, + next_n: int, + topk: int, +) -> torch.Tensor: + indices = torch.empty( + (logits.shape[0], topk), dtype=torch.int32, device=logits.device + ) + torch.ops._C.top_k_per_row_decode( + logits, + next_n, + seq_lens, + indices, + logits.shape[0], + logits.stride(0), + logits.stride(1), + topk, + ) + return indices + + +def _run_persistent_topk( + logits: torch.Tensor, + seq_lens: torch.Tensor, + topk: int, + max_seq_len: int, +) -> torch.Tensor: + indices = torch.empty( + (logits.shape[0], topk), dtype=torch.int32, device=logits.device + ) + workspace = torch.empty(1024 * 1024, dtype=torch.uint8, device=logits.device) + torch.ops._C.persistent_topk( + logits, + seq_lens, + indices, + workspace, + topk, + max_seq_len, + ) + return indices + + +def _dcp_attention_from_local_topks( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + local_topks: list[torch.Tensor], + world: int, + interleave: int, +) -> tuple[torch.Tensor, torch.Tensor]: + local_outs = [] + local_lses = [] + for rank, local_topk in enumerate(local_topks): + owned = [ + pos for pos in range(k.shape[0]) if (pos // interleave) % world == rank + ] + local_out, local_lse = _attention_from_indices( + q, k[owned], v[owned], local_topk.to(torch.int64) + ) + local_outs.append(local_out) + local_lses.append(local_lse) + return _dcp_lse_merge(local_outs, local_lses) + + +def _merge_local_topks_global_with_fake_dcp( + local_logits: list[torch.Tensor], + local_topks: list[torch.Tensor], + topk: int, + world: int, + interleave: int, + row_starts: list[torch.Tensor | None] | None = None, +) -> list[torch.Tensor]: + """Run ``_merge_dcp_topk_global`` for every rank against a faked + all-gather and return each rank's global-index result (all ranks should + agree). The fake pre-builds the packed candidate concatenation the merge's + single ``all_gather`` would return.""" + # The merge is now CuteDSL-only (no PyTorch fallback), so it runs the real + # Triton pack + CuteDSL selector kernels even behind the faked all-gather. + if not current_platform.is_cuda() or not has_cutedsl(): + pytest.skip("DCP merge requires CUDA and CuteDSL") + packed_per_rank = [] + for rank, (logits, indices) in enumerate(zip(local_logits, local_topks)): + score_indices = indices.clamp_min(0).to(torch.long) + if row_starts is not None: + rs = row_starts[rank] + assert rs is not None + score_indices = score_indices + rs.to(torch.long).view(-1, 1) + if logits.shape[1] == 0: + scores = torch.full_like(indices, float("-inf"), dtype=torch.float32) + else: + score_indices = score_indices.clamp_max(logits.shape[1] - 1) + scores = logits.gather(1, score_indices).masked_fill( + indices < 0, float("-inf") + ) + global_ids = _local_to_global_indices(indices, rank, world, interleave) + packed_per_rank.append( + torch.stack((scores.float(), global_ids.to(torch.float32)), dim=-1) + ) + + fake_group = _FakeDCPGroup(torch.cat(packed_per_rank, dim=1).contiguous()) + original_get_dcp_group = sparse_indexer.get_dcp_group + sparse_indexer.get_dcp_group = lambda: fake_group + try: + merged = [] + for rank, (logits, indices) in enumerate(zip(local_logits, local_topks)): + rank_indices = indices.clone() + result = sparse_indexer._merge_dcp_topk_global( + logits, + rank_indices, + topk, + rank, + world, + interleave, + row_starts=None if row_starts is None else row_starts[rank], + ) + assert result is None + merged.append(rank_indices) + return merged + finally: + sparse_indexer.get_dcp_group = original_get_dcp_group + + +@pytest.mark.parametrize("world", [1, 2, 4]) +@pytest.mark.parametrize("interleave", [1, 2, 4]) +def test_get_dcp_local_seq_lens_matches_naive(world: int, interleave: int): + seq_lens = torch.arange(0, 33, dtype=torch.int32) + + for rank in range(world): + actual = get_dcp_local_seq_lens(seq_lens, world, rank, interleave) + expected = torch.tensor( + [ + _local_count(int(seq_len), rank, world, interleave) + for seq_len in seq_lens + ], + dtype=torch.int32, + ) + torch.testing.assert_close(actual, expected) + + +def test_get_dcp_local_seq_lens_can_localize_per_token_bounds(): + seq_lens = torch.tensor([0, 1, 2, 3, 4, 7, 8, 17], dtype=torch.int32) + world = 4 + interleave = 2 + + for rank in range(world): + actual = get_dcp_local_seq_lens(seq_lens, world, rank, interleave) + expected = torch.tensor( + [ + _local_count(int(seq_len), rank, world, interleave) + for seq_len in seq_lens + ], + dtype=torch.int32, + ) + torch.testing.assert_close(actual, expected) + + +def test_get_dcp_local_seq_lens_preserves_mtp_bounds_shape(): + seq_lens = torch.tensor([[8, 9, 10], [11, 12, 13]], dtype=torch.int32) + world = 2 + rank = 1 + interleave = 1 + + actual = get_dcp_local_seq_lens(seq_lens, world, rank, interleave) + expected = torch.tensor( + [ + [_local_count(int(seq_len), rank, world, interleave) for seq_len in row] + for row in seq_lens + ], + dtype=torch.int32, + ) + + assert actual.shape == seq_lens.shape + torch.testing.assert_close(actual, expected) + + +def test_get_dcp_local_seq_lens_must_run_after_decode_expansion(): + world = 2 + rank = 1 + interleave = 1 + expanded_bounds = torch.tensor([8, 9, 10], dtype=torch.int32) + + localized_after_expansion = get_dcp_local_seq_lens( + expanded_bounds, world, rank, interleave + ) + localized_request_len_minus_offsets = get_dcp_local_seq_lens( + torch.tensor([10], dtype=torch.int32), world, rank + ) - torch.tensor([2, 1, 0], dtype=torch.int32) + + assert not torch.equal( + localized_after_expansion, localized_request_len_minus_offsets + ) + torch.testing.assert_close( + localized_after_expansion, torch.tensor([4, 4, 5], dtype=torch.int32) + ) + + +@pytest.mark.parametrize("interleave", [1, 2]) +def test_sparse_dcp_attention_matches_global_topk_attention(interleave: int): + torch.manual_seed(0) + world = 2 + topk = 3 + num_queries = 4 + max_seq_len = 13 + head_dim = 8 + + q = torch.randn(num_queries, head_dim) + k = torch.randn(max_seq_len, head_dim) + v = torch.randn(max_seq_len, head_dim) + seq_lens = torch.tensor([6, 8, 11, 13], dtype=torch.int64) + + scores = q @ k.T + global_topk = torch.full((num_queries, topk), -1, dtype=torch.int64) + for row, seq_len in enumerate(seq_lens.tolist()): + global_topk[row, :topk] = scores[row, :seq_len].topk(topk).indices + + ref_out, ref_lse = _attention_from_indices(q, k, v, global_topk) + + local_outs = [] + local_lses = [] + local_topks = [] + for rank in range(world): + owned = [ + pos for pos in range(max_seq_len) if (pos // interleave) % world == rank + ] + k_local = k[owned] + v_local = v[owned] + local_topk = _global_to_local_indices(global_topk, rank, world, interleave) + local_topks.append(local_topk) + local_out, local_lse = _attention_from_indices(q, k_local, v_local, local_topk) + local_outs.append(local_out) + local_lses.append(local_lse) + + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses) + + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5) + + gathered_global = torch.cat( + [ + _local_to_global_indices(local_topks[rank], rank, world, interleave) + for rank in range(world) + ], + dim=1, + ) + assert set(gathered_global[gathered_global >= 0].tolist()) == set( + global_topk.flatten().tolist() + ) + + +def test_local_topk_union_is_not_equivalent_to_global_topk_attention(): + world = 2 + interleave = 1 + topk = 2 + q = torch.tensor([[1.0]]) + k = torch.tensor( + [ + [1.00], + [0.90], + [0.95], + [0.85], + ] + ) + v = torch.tensor([[0.0], [1000.0], [0.0], [1000.0]]) + + scores = q @ k.T + global_topk = scores.topk(topk, dim=-1).indices + ref_out, ref_lse = _attention_from_indices(q, k, v, global_topk) + + local_topks = [] + for rank in range(world): + owned = [ + pos for pos in range(k.shape[0]) if (pos // interleave) % world == rank + ] + local_topks.append(scores[:, owned].topk(topk, dim=-1).indices) + + local_union_out, local_union_lse = _dcp_attention_from_local_topks( + q, k, v, local_topks, world, interleave + ) + + assert not torch.allclose(local_union_out, ref_out) + assert not torch.allclose(local_union_lse, ref_lse) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +def test_sparse_decode_dcp_persistent_topk_matches_non_dcp(): + torch.manual_seed(3) + device = torch.device("cuda") + world = 2 + interleave = 1 + topk = 512 + num_rows = 2 + max_seq_len = 1025 + head_dim = 16 + + q = torch.randn(num_rows, head_dim, device=device) + k = torch.randn(max_seq_len, head_dim, device=device) + v = torch.randn(max_seq_len, head_dim, device=device) + logits = q @ k.T + seq_lens = torch.tensor([[1024], [1025]], dtype=torch.int32, device=device) + + non_dcp_topk = torch.empty((num_rows, topk), dtype=torch.int64, device=device) + for row, seq_len in enumerate(seq_lens.flatten().tolist()): + non_dcp_topk[row] = logits[row, :seq_len].topk(topk).indices + ref_out, ref_lse = _attention_from_indices(q, k, v, non_dcp_topk) + + local_logits = [] + local_topks = [] + for rank in range(world): + owned = [ + pos for pos in range(max_seq_len) if (pos // interleave) % world == rank + ] + rank_logits = logits[:, owned].contiguous() + rank_seq_lens = get_dcp_local_seq_lens( + seq_lens, world, rank, interleave + ).contiguous() + local_logits.append(rank_logits) + local_topks.append( + _run_persistent_topk( + rank_logits, + rank_seq_lens, + topk, + max_seq_len=rank_logits.shape[1], + ) + ) + + merged_global_topks = _merge_local_topks_global_with_fake_dcp( + local_logits, local_topks, topk, world, interleave + ) + # The radix top-K kernel selects a deterministic SET but writes it in + # nondeterministic (atomicAdd) order; the production path is permutation- + # invariant (compaction + softmax), so all ranks must agree on the set, not + # the array order. (The fp64 fallback happens to return sorted order.) + ref_topk = merged_global_topks[0] + for rank_topk in merged_global_topks[1:]: + for row in range(rank_topk.shape[0]): + assert set(rank_topk[row].tolist()) == set(ref_topk[row].tolist()) + + local_outs = [] + local_lses = [] + for rank, global_topk in enumerate(merged_global_topks): + owned = [ + pos for pos in range(max_seq_len) if (pos // interleave) % world == rank + ] + local_topk = _global_to_local_indices( + global_topk.to(torch.int64), + rank, + world, + interleave, + ) + local_out, local_lse = _attention_from_indices( + q, k[owned], v[owned], local_topk + ) + local_outs.append(local_out) + local_lses.append(local_lse) + + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses) + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5) + + +@pytest.mark.skipif( + not current_platform.is_cuda() or not has_cutedsl(), + reason="This test requires CUDA and CuteDSL", +) +@pytest.mark.parametrize("use_row_starts", [False, True]) +def test_cutedsl_dcp_candidate_pack_and_select_matches_reference( + use_row_starts: bool, +): + from vllm.model_executor.kernels.attention.dsa.dcp_indexer_cutedsl import ( + pack_dcp_topk_candidates_cutedsl, + stable_topk_from_gathered_candidates_cutedsl, + ) + + torch.manual_seed(13) + device = torch.device("cuda") + rows = 4 + valid_width = 1024 + width = valid_width + (8 if use_row_starts else 0) + topk = 512 + world = 2 + row_starts = ( + torch.tensor([0, 2, 4, 1], device=device, dtype=torch.int32) + if use_row_starts + else None + ) + row_offsets = ( + row_starts + if row_starts is not None + else torch.zeros(rows, device=device, dtype=torch.int32) + ) + + packed_by_rank = [] + for rank in range(world): + logits = torch.randn((rows, width), device=device, dtype=torch.float32) + local_topks = [] + for row in range(rows): + start = int(row_offsets[row].item()) + local_topks.append( + logits[row, start : start + valid_width].topk(topk).indices + ) + topk_indices = torch.stack(local_topks).to(torch.int32) + + packed = torch.empty((rows, topk, 2), device=device, dtype=torch.float32) + pack_dcp_topk_candidates_cutedsl( + logits, + topk_indices, + packed, + rank, + world, + 1, + row_starts, + ) + + expected_scores = logits.gather( + 1, topk_indices.to(torch.long) + row_offsets.to(torch.long).view(-1, 1) + ) + expected_ids = (topk_indices * world + rank).to(torch.float32) + torch.testing.assert_close(packed[..., 0], expected_scores) + torch.testing.assert_close(packed[..., 1], expected_ids) + packed_by_rank.append(packed) + + gathered = torch.cat(packed_by_rank, dim=1).contiguous() + actual = torch.empty((rows, topk), device=device, dtype=torch.int32) + returned = stable_topk_from_gathered_candidates_cutedsl(gathered, topk, out=actual) + assert returned is actual + expected = _ref_stable_topk_from_candidates_fp64( + gathered[..., 0], + gathered[..., 1].to(torch.int32), + topk, + ) + + for row in range(rows): + assert set(actual[row].cpu().tolist()) == set(expected[row].cpu().tolist()) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +def test_sparse_prefill_dcp_metadata_localizes_causal_bounds(): + device = torch.device("cuda") + seq_len = 8 + + query_start_loc = torch.tensor([0, seq_len], dtype=torch.int32, device=device) + query_start_loc_cpu = torch.tensor([0, seq_len], dtype=torch.int32) + seq_lens = torch.tensor([seq_len], dtype=torch.int32, device=device) + seq_lens_cpu = torch.tensor([seq_len], dtype=torch.int32) + block_table = torch.zeros((1, 1), dtype=torch.int32, device=device) + + def build(dcp_world_size, dcp_rank, interleave=1): + chunk = build_prefill_chunk_metadata( + start_idx=0, + end_idx=1, + query_start_loc=query_start_loc, + query_start_loc_cpu=query_start_loc_cpu, + uncompressed_seq_lens=seq_lens, + compressed_seq_lens=seq_lens, + compressed_seq_lens_cpu=seq_lens_cpu, + block_table=block_table, + compress_ratio=1, + dcp_rank=dcp_rank, + dcp_world_size=dcp_world_size, + cp_kv_cache_interleave_size=interleave, + ) + assert chunk is not None + torch.accelerator.synchronize() + return chunk + + # Non-DCP: local_cu_seq_lens aliases the global cu_seq_lens, and + # cu_seqlen_ks/ke carry the global causal bounds. + chunk = build(dcp_world_size=1, dcp_rank=0) + assert chunk.local_cu_seq_lens is chunk.cu_seq_lens + torch.testing.assert_close( + chunk.cu_seqlen_ks.cpu(), + torch.zeros(seq_len, dtype=torch.int32), + ) + torch.testing.assert_close( + chunk.cu_seqlen_ke.cpu(), + torch.arange(1, seq_len + 1, dtype=torch.int32), + ) + + # DCP: cu_seqlen_ks/ke are localized in place to this rank's shard. + chunk = build(dcp_world_size=4, dcp_rank=0) + assert chunk.local_cu_seq_lens is not None + torch.testing.assert_close( + chunk.local_cu_seq_lens.cpu(), + torch.tensor([0, 2], dtype=torch.int32), + ) + torch.testing.assert_close( + chunk.cu_seqlen_ks.cpu(), + torch.zeros(seq_len, dtype=torch.int32), + ) + torch.testing.assert_close( + chunk.cu_seqlen_ke.cpu(), + torch.tensor([1, 1, 1, 1, 2, 2, 2, 2], dtype=torch.int32), + ) + + # DCP with interleave=2: per-token causal bounds localize differently from + # interleave=1 (groups of 2 consecutive tokens are owned together). For + # world=4, rank=0, K=2, per-token global len L=1..8 -> local len + # [1,2,2,2,2,2,2,2] (matches get_dcp_local_seq_lens). + chunk = build(dcp_world_size=4, dcp_rank=0, interleave=2) + assert chunk.local_cu_seq_lens is not None + torch.testing.assert_close( + chunk.cu_seqlen_ks.cpu(), + torch.zeros(seq_len, dtype=torch.int32), + ) + torch.testing.assert_close( + chunk.cu_seqlen_ke.cpu(), + torch.tensor([1, 2, 2, 2, 2, 2, 2, 2], dtype=torch.int32), + ) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +def test_dcp_filter_compacts_valid_slots_for_sparse_kernel(): + block_size = 4 + num_topk = 128 + dcp_size = 2 + req_id = torch.zeros(1, dtype=torch.int32, device="cuda") + token_indices = torch.full((1, num_topk), -1, dtype=torch.int32, device="cuda") + token_indices[0, :8] = torch.arange(8, dtype=torch.int32, device="cuda") + block_table = torch.tensor([[10]], dtype=torch.int32, device="cuda") + + out, valid_counts = triton_filter_and_convert_dcp_index( + req_id, + block_table, + token_indices, + dcp_size=dcp_size, + dcp_rank=0, + BLOCK_SIZE=block_size, + NUM_TOPK_TOKENS=num_topk, + return_valid_counts=True, + ) + + valid = int(valid_counts.item()) + assert valid == 4 + assert (out[0, :valid] >= 0).all() + assert (out[0, valid:] == -1).all() + # In-kernel compaction packs valid slots to the front; prefix order is + # unspecified, so compare as a set. + assert set(out[0, :valid].cpu().tolist()) == {40, 41, 42, 43} + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +@pytest.mark.parametrize("interleave", [1, 2]) +@pytest.mark.parametrize("dcp_rank", [0, 1]) +def test_dcp_filter_compaction_matches_reference(interleave: int, dcp_rank: int): + """In-kernel compaction (atomic slot allocator across multiple column tiles) + must, for every row, produce exactly the rank-owned physical slots packed + into [0, valid_count) with -1 in the tail -- the same SET a reference filter + + sort/gather produces. Uses wide rows (> BLOCK_N valid slots) so the + cross-tile atomic allocation is exercised, with interior -1 gaps.""" + device = torch.device("cuda") + torch.manual_seed(7) + dcp_size = 2 + block_size = 8 + num_topk = 1024 # > BLOCK_N(128) -> multiple tiles per row + num_rows = 5 + max_blocks = 64 + seq = max_blocks * block_size + + req_id = torch.randint(0, 3, (num_rows,), dtype=torch.int32, device=device) + block_table = torch.randint( + 0, 1000, (3, max_blocks), dtype=torch.int32, device=device + ) + # Each row: a dense valid prefix of distinct global token ids, then -1 pad. + token_indices = torch.full( + (num_rows, num_topk), -1, dtype=torch.int32, device=device + ) + for r in range(num_rows): + n_valid = int(torch.randint(200, 600, (1,)).item()) + perm = torch.randperm(seq, device=device)[:n_valid].to(torch.int32) + token_indices[r, :n_valid] = perm + + out, valid_counts = triton_filter_and_convert_dcp_index( + req_id, + block_table, + token_indices, + dcp_size=dcp_size, + dcp_rank=dcp_rank, + cp_kv_cache_interleave_size=interleave, + BLOCK_SIZE=block_size, + NUM_TOPK_TOKENS=num_topk, + return_valid_counts=True, + ) + + for r in range(num_rows): + toks = token_indices[r] + toks = toks[toks >= 0] + owner = (toks // interleave) % dcp_size + owned = toks[owner == dcp_rank] + local = (owned // (dcp_size * interleave)) * interleave + owned % interleave + blk = local // block_size + off = local % block_size + expected = ( + block_table[int(req_id[r].item()), blk].to(torch.int64) * block_size + off + ) + n = int(valid_counts[r].item()) + assert n == owned.numel() + assert (out[r, :n] >= 0).all() + assert (out[r, n:] == -1).all() + assert set(out[r, :n].cpu().tolist()) == set(expected.cpu().tolist()) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +@pytest.mark.parametrize("interleave", [1, 2, 4]) +def test_dcp_global_topk_physical_attention_matches_non_dcp(interleave: int): + torch.manual_seed(2) + device = torch.device("cuda") + dcp_size = 2 + block_size = 4 + num_topk = 128 + selected_k = 8 + seq_len = 16 + head_dim = 16 + num_queries = 3 + + q = torch.randn(num_queries, head_dim, device=device) + k_global = torch.randn(seq_len, head_dim, device=device) + v_global = torch.randn(seq_len, head_dim, device=device) + scores = q @ k_global.T + global_topk = torch.full( + (num_queries, num_topk), -1, dtype=torch.int32, device=device + ) + global_topk[:, :selected_k] = scores.topk(selected_k, dim=-1).indices.to( + torch.int32 + ) + + ref_out, ref_lse = _attention_from_indices( + q, k_global, v_global, global_topk[:, :selected_k].to(torch.int64) + ) + + local_outs = [] + local_lses = [] + for rank in range(dcp_size): + block_ids = torch.tensor( + [[rank * 10 + 1, rank * 10 + 2]], dtype=torch.int32, device=device + ) + num_slots = int((block_ids.max().item() + 1) * block_size) + k_cache = torch.zeros(num_slots, head_dim, device=device) + v_cache = torch.zeros(num_slots, head_dim, device=device) + for global_idx in range(seq_len): + if (global_idx // interleave) % dcp_size != rank: + continue + local_idx = ( + global_idx // (dcp_size * interleave) + ) * interleave + global_idx % interleave + block = local_idx // block_size + offset = local_idx % block_size + slot = int(block_ids[0, block].item()) * block_size + offset + k_cache[slot] = k_global[global_idx] + v_cache[slot] = v_global[global_idx] + + slots, valid_counts = triton_filter_and_convert_dcp_index( + torch.zeros(num_queries, dtype=torch.int32, device=device), + block_ids, + global_topk, + dcp_size=dcp_size, + dcp_rank=rank, + cp_kv_cache_interleave_size=interleave, + BLOCK_SIZE=block_size, + NUM_TOPK_TOKENS=num_topk, + return_valid_counts=True, + ) + row_ids = torch.arange(num_queries, device=device) + assert (slots[row_ids, valid_counts] == -1).all() + local_out, local_lse = _attention_from_indices( + q, k_cache, v_cache, slots.to(torch.int64) + ) + local_outs.append(local_out) + local_lses.append(local_lse) + + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses) + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +@pytest.mark.parametrize("is_lse_base_on_e", [True, False]) +def test_correct_attn_out_zeroes_empty_nan_partial(is_lse_base_on_e: bool): + out = torch.full((1, 1, 4), float("nan"), device="cuda") + lses = torch.tensor( + [[[0.0]], [[float("-inf")]]], + dtype=torch.float32, + device="cuda", + ) + + corrected, final_lse = correct_attn_out( + out, + lses, + cp_rank=1, + ctx=CPTritonContext(), + is_lse_base_on_e=is_lse_base_on_e, + ) + torch.accelerator.synchronize() + + torch.testing.assert_close(corrected, torch.zeros_like(corrected)) + torch.testing.assert_close(final_lse, torch.zeros_like(final_lse)) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +def test_decode_topk_pads_surplus_with_negative_one(): + """When a row's valid length < topk the top-k kernel must pad the surplus + slots with -1: the DCP merge (`topk_indices >= 0`) and + `triton_filter_and_convert_dcp_index` (`tok < 0`) both treat <0 as invalid, + so a non-(-1) pad would be silently attended. This is the common case under + DCP, where each rank's local seq_len = global / world is usually << topk. + Checked on the *set* of valid indices (the kernel may order them by score, + not position).""" + torch.manual_seed(0) + device = torch.device("cuda") + topk = 8 + next_n = 1 + seq_lens_list = [0, 3, 5, 8] + num_rows = len(seq_lens_list) + logits = torch.randn(num_rows, 16, device=device) + seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device=device).view( + num_rows, next_n + ) + idx = _run_decode_topk(logits, seq_lens, next_n, topk) + for r, sl in enumerate(seq_lens_list): + valid = idx[r][idx[r] >= 0] + # seq_len <= topk, so top-k selects exactly the whole valid range. + assert valid.numel() == min(sl, topk) + assert set(valid.tolist()) == set(range(sl)) + assert (idx[r] == -1).sum().item() == topk - min(sl, topk) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +def test_persistent_topk_pads_surplus_with_negative_one(): + """Same surplus=-1 invariant for the persistent_topk kernel (k>=512).""" + torch.manual_seed(0) + device = torch.device("cuda") + topk = 512 # persistent_topk requires k in {512, 1024, 2048} + seq_lens_list = [100, 300, 512] + num_rows = len(seq_lens_list) + max_seq_len = 600 + logits = torch.randn(num_rows, max_seq_len, device=device) + seq_lens = torch.tensor(seq_lens_list, dtype=torch.int32, device=device).view( + num_rows, 1 + ) + idx = _run_persistent_topk(logits, seq_lens, topk, max_seq_len) + for r, sl in enumerate(seq_lens_list): + valid = idx[r][idx[r] >= 0] + assert valid.numel() == min(sl, topk) + assert set(valid.tolist()) == set(range(sl)) + assert (idx[r] == -1).sum().item() == topk - min(sl, topk) + + +@pytest.mark.skipif(not current_platform.is_cuda(), reason="This test requires CUDA") +def test_sparse_decode_dcp_short_context_matches_non_dcp(): + """End-to-end DCP decode where the global seq_len < topk (so every rank's + local top-k is surplus-padded). Exercises the kernel surplus -> merge mask + -> global top-k -> physical localize -> LSE merge chain for the common + short-context decode case, vs the non-DCP reference.""" + torch.manual_seed(4) + device = torch.device("cuda") + world = 2 + interleave = 1 + topk = 512 + num_rows = 2 + max_seq_len = 300 # < topk -> surplus everywhere + head_dim = 16 + + q = torch.randn(num_rows, head_dim, device=device) + k = torch.randn(max_seq_len, head_dim, device=device) + v = torch.randn(max_seq_len, head_dim, device=device) + logits = q @ k.T + seq_lens = torch.tensor([[250], [300]], dtype=torch.int32, device=device) + + non_dcp_topk = torch.empty((num_rows, topk), dtype=torch.int64, device=device) + for row, seq_len in enumerate(seq_lens.flatten().tolist()): + sel = logits[row, :seq_len].topk(min(topk, seq_len)).indices + non_dcp_topk[row, : sel.numel()] = sel + non_dcp_topk[row, sel.numel() :] = -1 + ref_out, ref_lse = _attention_from_indices(q, k, v, non_dcp_topk) + + local_logits = [] + local_topks = [] + for rank in range(world): + owned = [p for p in range(max_seq_len) if (p // interleave) % world == rank] + rank_logits = logits[:, owned].contiguous() + rank_seq_lens = get_dcp_local_seq_lens(seq_lens, world, rank, interleave) + local_logits.append(rank_logits) + local_topks.append( + _run_persistent_topk( + rank_logits, + rank_seq_lens.contiguous(), + topk, + max_seq_len=rank_logits.shape[1], + ) + ) + + merged_global_topks = _merge_local_topks_global_with_fake_dcp( + local_logits, local_topks, topk, world, interleave + ) + # The radix top-K kernel selects a deterministic SET but writes it in + # nondeterministic (atomicAdd) order; the production path is permutation- + # invariant (compaction + softmax), so all ranks must agree on the set, not + # the array order. (The fp64 fallback happens to return sorted order.) + ref_topk = merged_global_topks[0] + for rank_topk in merged_global_topks[1:]: + for row in range(rank_topk.shape[0]): + assert set(rank_topk[row].tolist()) == set(ref_topk[row].tolist()) + + local_outs = [] + local_lses = [] + for rank, global_topk in enumerate(merged_global_topks): + owned = [p for p in range(max_seq_len) if (p // interleave) % world == rank] + local_topk = _global_to_local_indices( + global_topk.to(torch.int64), rank, world, interleave + ) + local_out, local_lse = _attention_from_indices( + q, k[owned], v[owned], local_topk + ) + local_outs.append(local_out) + local_lses.append(local_lse) + + dcp_out, dcp_lse = _dcp_lse_merge(local_outs, local_lses) + torch.testing.assert_close(dcp_out, ref_out, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(dcp_lse, ref_lse, atol=1e-5, rtol=1e-5) diff --git a/tests/v1/attention/test_linear_attention_metadata_builder.py b/tests/v1/attention/test_linear_attention_metadata_builder.py new file mode 100644 index 000000000000..3ef811b3a66b --- /dev/null +++ b/tests/v1/attention/test_linear_attention_metadata_builder.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import torch + +from tests.v1.attention.utils import ( + BatchSpec, + create_common_attn_metadata, + create_vllm_config, +) +from vllm.config import CUDAGraphMode, SpeculativeConfig +from vllm.v1.attention.backend import AttentionCGSupport +from vllm.v1.attention.backends.linear_attn import ( + BailingLinearAttentionMetadataBuilder, + LinearAttentionMetadataBuilder, +) +from vllm.v1.attention.backends.utils import PAD_SLOT_ID +from vllm.v1.kv_cache_interface import MambaSpec + +BLOCK_SIZE = 16 +DEVICE = torch.device("cpu") + + +def _create_mamba_spec(num_speculative_blocks: int = 1) -> MambaSpec: + return MambaSpec( + block_size=BLOCK_SIZE, + shapes=((16, 64),), + dtypes=(torch.float16,), + num_speculative_blocks=num_speculative_blocks, + ) + + +def test_bailing_linear_attention_reports_uniform_batch_cudagraph_support(): + vllm_config = create_vllm_config( + hf_config_override={ + "architectures": ["BailingMoeV2_5ForCausalLM"], + "model_type": "bailing_hybrid", + } + ) + + support = BailingLinearAttentionMetadataBuilder.get_cudagraph_support( + vllm_config, _create_mamba_spec() + ) + + assert support == AttentionCGSupport.UNIFORM_BATCH + + +def test_non_bailing_linear_attention_keeps_single_token_cudagraph_support(): + vllm_config = create_vllm_config( + hf_config_override={ + "architectures": ["MiniMaxText01ForCausalLM"], + "model_type": "minimax_text_01", + } + ) + + support = LinearAttentionMetadataBuilder.get_cudagraph_support( + vllm_config, _create_mamba_spec() + ) + + assert support == AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE + + +def test_linear_attention_spec_decode_full_graph_metadata_pads_cache_slots(): + vllm_config = create_vllm_config( + hf_config_override={ + "architectures": ["BailingMoeV2_5ForCausalLM"], + "model_type": "bailing_hybrid", + } + ) + vllm_config.speculative_config = SpeculativeConfig( + method="ngram", + num_speculative_tokens=1, + ) + vllm_config.compilation_config.cudagraph_mode = CUDAGraphMode.FULL_DECODE_ONLY + + builder = BailingLinearAttentionMetadataBuilder( + kv_cache_spec=_create_mamba_spec(), + layer_names=["model.layers.0.self_attn"], + vllm_config=vllm_config, + device=DEVICE, + ) + + common = create_common_attn_metadata( + BatchSpec(seq_lens=[20, 20, 0], query_lens=[2, 2, 0]), + BLOCK_SIZE, + DEVICE, + ) + common.block_table_tensor[2].fill_(-1) + + metadata = builder.build( + common_prefix_len=0, + common_attn_metadata=common, + num_accepted_tokens=torch.tensor([1, 2, 1], dtype=torch.int32), + ) + + assert metadata.num_decodes == 3 + assert metadata.num_prefills == 0 + assert metadata.num_decode_tokens == 4 + assert metadata.state_indices_tensor_d is not None + assert metadata.state_indices_tensor_d.shape == (3, 2) + assert torch.equal( + metadata.state_indices_tensor_d[2], + torch.full((2,), PAD_SLOT_ID, dtype=torch.int32), + ) + assert torch.equal( + metadata.state_indices_tensor[2], + torch.tensor(PAD_SLOT_ID, dtype=torch.int32), + ) + assert metadata.query_start_loc_d is not None + assert metadata.query_start_loc_d.tolist() == [0, 2, 4, 4] + assert metadata.num_accepted_tokens is not None + assert metadata.num_accepted_tokens.tolist() == [1, 2, 1] + + +def test_linear_attention_full_graph_metadata_uses_stable_decode_buffers(): + vllm_config = create_vllm_config( + hf_config_override={ + "architectures": ["BailingMoeV2_5ForCausalLM"], + "model_type": "bailing_hybrid", + } + ) + vllm_config.speculative_config = SpeculativeConfig( + method="ngram", + num_speculative_tokens=1, + ) + vllm_config.compilation_config.cudagraph_mode = CUDAGraphMode.FULL_DECODE_ONLY + + builder = BailingLinearAttentionMetadataBuilder( + kv_cache_spec=_create_mamba_spec(), + layer_names=["model.layers.0.self_attn"], + vllm_config=vllm_config, + device=DEVICE, + ) + + common = create_common_attn_metadata( + BatchSpec(seq_lens=[20, 20, 0], query_lens=[2, 2, 0]), + BLOCK_SIZE, + DEVICE, + arange_block_indices=True, + ) + common.block_table_tensor = torch.tensor( + [[10, 11], [12, 13], [-1, -1]], + dtype=torch.int32, + device=DEVICE, + ) + + first = builder.build( + common_prefix_len=0, + common_attn_metadata=common, + num_accepted_tokens=torch.tensor([1, 2, 1], dtype=torch.int32), + ) + assert first.state_indices_tensor_d is not None + assert first.query_start_loc_d is not None + assert first.num_accepted_tokens is not None + state_ptr = first.state_indices_tensor_d.data_ptr() + query_ptr = first.query_start_loc_d.data_ptr() + accepted_ptr = first.num_accepted_tokens.data_ptr() + + common2 = create_common_attn_metadata( + BatchSpec(seq_lens=[36, 0, 0], query_lens=[2, 0, 0]), + BLOCK_SIZE, + DEVICE, + arange_block_indices=True, + ) + common2.block_table_tensor = torch.tensor( + [[20, 21], [-1, -1], [-1, -1]], + dtype=torch.int32, + device=DEVICE, + ) + second = builder.build( + common_prefix_len=0, + common_attn_metadata=common2, + num_accepted_tokens=torch.tensor([2, 1, 1], dtype=torch.int32), + ) + + assert second.state_indices_tensor_d is not None + assert second.query_start_loc_d is not None + assert second.num_accepted_tokens is not None + assert second.state_indices_tensor_d.data_ptr() == state_ptr + assert second.query_start_loc_d.data_ptr() == query_ptr + assert second.num_accepted_tokens.data_ptr() == accepted_ptr + assert second.state_indices_tensor_d.tolist() == [ + [20, 21], + [PAD_SLOT_ID, PAD_SLOT_ID], + [PAD_SLOT_ID, PAD_SLOT_ID], + ] + assert second.query_start_loc_d.tolist() == [0, 2, 2, 2] + assert second.num_accepted_tokens.tolist() == [2, 1, 1] diff --git a/tests/v1/attention/test_mla_backends.py b/tests/v1/attention/test_mla_backends.py index 109e56cb3838..315c77de3928 100644 --- a/tests/v1/attention/test_mla_backends.py +++ b/tests/v1/attention/test_mla_backends.py @@ -19,6 +19,7 @@ ) from vllm import _custom_ops as ops from vllm.config.vllm import set_current_vllm_config +from vllm.model_executor.layers.attention import mla_attention as mla_attention_module from vllm.model_executor.layers.attention.mla_attention import ( MLAAttention, QueryLenSupport, @@ -30,6 +31,7 @@ from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE from vllm.v1.attention.backend import CommonAttentionMetadata from vllm.v1.attention.backends.fa_utils import flash_attn_supports_mla +from vllm.v1.attention.backends.mla import flashmla as flashmla_module from vllm.v1.attention.backends.mla.prefill import ( MLAPrefillBackendEnum, get_mla_prefill_backend, @@ -552,6 +554,10 @@ def forward_impl( ) else: mqa_q = (mqa_ql_nope, mqa_q_pe) + if self.impl.dcp_world_size > 1: + if isinstance(mqa_q, tuple): + mqa_q = torch.cat(mqa_q, dim=-1) + mqa_q = mla_attention_module.get_dcp_group().all_gather(mqa_q, dim=1) attn_out, _ = self.impl.forward_mqa(mqa_q, kv_cache, attn_metadata, self) @@ -569,6 +575,215 @@ def forward_impl( return output +def test_mock_mla_dcp_fp8_decode_gathers_quantized_query( + monkeypatch, default_vllm_config +): + if not torch.cuda.is_available(): + pytest.skip("CUDA is required for FP8 decode query quantization path.") + + device = torch.device(f"{DEVICE_TYPE}:0") + num_tokens = 2 + num_heads = 2 + qk_nope_head_dim = 4 + qk_rope_head_dim = 2 + v_head_dim = 3 + kv_lora_rank = 5 + + class _DummyKVProj: + def __init__(self): + # Shape expected by MockMLAAttentionLayer.__init__ + self.weight = torch.randn( + num_heads * (qk_nope_head_dim + v_head_dim), + kv_lora_rank, + device=device, + dtype=torch.float32, + ) + + class _FakeImpl: + def __init__(self): + self.kv_cache_dtype = "fp8" + self.supports_quant_query_input = True + self.dcp_world_size = 2 + self.forward_q = None + + def forward_mha(self, *args, **kwargs): + return None + + def forward_mqa(self, q, kv_cache, attn_metadata, layer): + self.forward_q = q + assert isinstance(q, torch.Tensor) + bsz, _, _ = q.shape + return ( + torch.zeros( + bsz, + num_heads, + kv_lora_rank, + device=q.device, + dtype=torch.float32, + ), + None, + ) + + class _FakeDCPGroup: + def __init__(self): + self.calls = 0 + self.input_dtype = None + self.input_shape = None + + def all_gather(self, x, dim=1): + self.calls += 1 + self.input_dtype = x.dtype + self.input_shape = tuple(x.shape) + return torch.cat([x, x], dim=dim) + + fake_group = _FakeDCPGroup() + monkeypatch.setattr(mla_attention_module, "get_dcp_group", lambda: fake_group) + + impl = _FakeImpl() + with set_current_vllm_config(default_vllm_config): + layer = MockMLAAttentionLayer( + impl=impl, + num_heads=num_heads, + qk_nope_head_dim=qk_nope_head_dim, + qk_rope_head_dim=qk_rope_head_dim, + v_head_dim=v_head_dim, + kv_lora_rank=kv_lora_rank, + device=device, + kv_b_proj=_DummyKVProj(), + q_scale=1.0, + k_scale=1.0, + ) + + q = torch.randn( + num_tokens, + num_heads, + qk_nope_head_dim + qk_rope_head_dim, + device=device, + dtype=torch.float32, + ) + kv_c = torch.randn(num_tokens, kv_lora_rank, device=device, dtype=torch.float32) + k_pe = torch.randn( + num_tokens, 1, qk_rope_head_dim, device=device, dtype=torch.float32 + ) + kv_cache = torch.empty(0, device=device, dtype=torch.float32) + output = torch.empty( + num_tokens, num_heads * v_head_dim, device=device, dtype=torch.float32 + ) + + class _AttnMeta: + num_decode_tokens = num_tokens + num_decodes = 1 + num_prefills = 0 + slot_mapping = torch.empty(0, dtype=torch.long, device=device) + + layer.forward_impl(q, kv_c, k_pe, kv_cache, _AttnMeta(), output) + + assert fake_group.calls == 1 + assert fake_group.input_dtype == current_platform.fp8_dtype() + assert fake_group.input_shape == ( + num_tokens, + num_heads, + kv_lora_rank + qk_rope_head_dim, + ) + assert isinstance(impl.forward_q, torch.Tensor) + assert tuple(impl.forward_q.shape) == ( + num_tokens, + num_heads * impl.dcp_world_size, + kv_lora_rank + qk_rope_head_dim, + ) + + +@pytest.mark.parametrize("is_fp8_kvcache", [False, True], ids=["bf16", "fp8"]) +def test_flashmla_dcp_decode_metadata_uses_gathered_query_heads( + monkeypatch, is_fp8_kvcache +): + class _FakeSchedulerMetadata: + tile_scheduler_metadata = None + num_splits = None + + base_call: tuple[torch.Tensor, int, int, bool] | None = None + fp8_call: tuple[torch.Tensor, int, int] | None = None + + def fake_get_mla_metadata( + seq_lens_device, + num_q_tokens_per_head_k, + num_heads_k, + is_fp8_kvcache=False, + ): + nonlocal base_call + base_call = ( + seq_lens_device, + num_q_tokens_per_head_k, + num_heads_k, + is_fp8_kvcache, + ) + return _FakeSchedulerMetadata(), None + + def fake_get_mla_metadata_dense_fp8( + seq_lens_device, num_q_tokens_per_head_k, num_heads_k + ): + nonlocal fp8_call + fp8_call = ( + seq_lens_device, + num_q_tokens_per_head_k, + num_heads_k, + ) + return ( + torch.empty((0, 8), dtype=torch.int32), + torch.empty((0,), dtype=torch.int32), + ) + + monkeypatch.setattr(flashmla_module, "get_mla_metadata", fake_get_mla_metadata) + monkeypatch.setattr( + flashmla_module, + "get_mla_metadata_dense_fp8", + fake_get_mla_metadata_dense_fp8, + ) + + builder = object.__new__(flashmla_module.FlashMLAMetadataBuilder) + builder.num_q_heads = 4 + builder.dcp_world_size = 2 + builder.is_fp8_kvcache = is_fp8_kvcache + builder.compilation_config = type( + "_CompilationConfig", + (), + { + "cudagraph_mode": type( + "_CudaGraphMode", + (), + {"has_full_cudagraphs": lambda self: False}, + )() + }, + )() + + seq_lens = torch.tensor([16, 24], dtype=torch.int32) + query_start_loc = torch.tensor([0, 1, 2], dtype=torch.int32) + + metadata = builder._build_decode( + block_table_tensor=torch.empty((2, 1), dtype=torch.int32), + seq_lens_device=seq_lens, + max_seq_len=24, + query_start_loc_cpu=query_start_loc, + query_start_loc_device=query_start_loc, + num_decode_tokens=2, + dcp_tot_seq_lens_device=None, + ) + + assert base_call is not None + assert base_call[0] is seq_lens + assert base_call[1:] == (8, 1, is_fp8_kvcache) + if is_fp8_kvcache: + assert metadata.scheduler_metadata.tile_scheduler_metadata is not None + assert metadata.scheduler_metadata.num_splits is not None + assert fp8_call is not None + assert fp8_call[0] is seq_lens + assert fp8_call[1:] == (8, 1) + else: + assert metadata.scheduler_metadata.tile_scheduler_metadata is None + assert metadata.scheduler_metadata.num_splits is None + assert fp8_call is None + + def run_attention_backend( backend: AttentionBackendEnum, kv_cache_spec: MLAAttentionSpec, @@ -765,7 +980,8 @@ def test_backend_correctness( if not backends_to_test: pytest.skip(f"No backends support kv_cache_dtype={kv_cache_dtype}") - # Skip prefill backends that can't satisfy capability/deps/R1 constraints. + # Skip prefill backends that can't satisfy capability/deps/dimension constraints. + from vllm.v1.attention.backends.mla.prefill.base import MLADimensions from vllm.v1.attention.backends.mla.prefill.selector import ( MLAPrefillSelectorConfig, ) @@ -773,7 +989,14 @@ def test_backend_correctness( try: prefill_invalid_reasons = prefill_backend.get_class().validate_configuration( current_platform.get_device_capability(), - MLAPrefillSelectorConfig(dtype=torch.bfloat16, is_r1_compatible=True), + MLAPrefillSelectorConfig( + dtype=torch.bfloat16, + mla_dimensions=MLADimensions( + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + ), + ), ) except ImportError: prefill_invalid_reasons = ["ImportError"] diff --git a/tests/v1/attention/test_mla_prefill_quant_output.py b/tests/v1/attention/test_mla_prefill_quant_output.py new file mode 100644 index 000000000000..d7659485aa9f --- /dev/null +++ b/tests/v1/attention/test_mla_prefill_quant_output.py @@ -0,0 +1,207 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for MLA prefill backend fused-quant-output support. + +Covers two things: + * `MLAPrefillBackend.supports_quant_output`, the capability gate that decides + whether the prefill kernel writes quantized output directly (FA4 native + fused FP8, see flash-attention#135) instead of the post-quant path. + * The numerical equivalence of that fused FP8 write versus the bf16-attention + + standalone static-FP8-quant path it replaces (GPU-only, SM100/SM110). +""" + +from unittest.mock import patch + +import pytest +import torch + +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + kFp8Dynamic128Sym, + kFp8StaticTensorSym, + kNvfp4Dynamic, +) +from vllm.platforms.interface import DeviceCapability +from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend +from vllm.v1.attention.backends.mla.prefill.flash_attn import ( + FlashAttnPrefillBackend, +) + +_FA_MODULE = "vllm.v1.attention.backends.mla.prefill.flash_attn" + + +class _DummyPrefillBackend(MLAPrefillBackend): + """Concrete backend that does NOT override supports_quant_output.""" + + @staticmethod + def get_name() -> str: + return "DUMMY" + + def run_prefill_new_tokens(self, *args, **kwargs): # pragma: no cover + raise NotImplementedError + + def run_prefill_context_chunk(self, *args, **kwargs): # pragma: no cover + raise NotImplementedError + + +@pytest.mark.parametrize( + "quant_key", [kFp8StaticTensorSym, kFp8Dynamic128Sym, kNvfp4Dynamic, None] +) +def test_base_backend_never_supports_quant_output(quant_key): + """The base default opts every backend out unless it overrides.""" + backend = object.__new__(_DummyPrefillBackend) + assert backend.supports_quant_output(quant_key) is False + + +def _make_fa_backend(version: int | None, is_vllm_fa: bool): + """Build a FlashAttnPrefillBackend without running its heavy __init__.""" + backend = object.__new__(FlashAttnPrefillBackend) + backend.vllm_flash_attn_version = version + backend._is_vllm_fa = is_vllm_fa + return backend + + +@pytest.mark.parametrize( + ("version", "is_vllm_fa", "dc_major", "quant_key", "expected"), + [ + # FA4 + vLLM-FA + Blackwell SM100/SM110 + static FP8 -> fused. + (4, True, 10, kFp8StaticTensorSym, True), + (4, True, 11, kFp8StaticTensorSym, True), + # Wrong compute capability (SM90 / SM120) -> not supported (#135). + (4, True, 9, kFp8StaticTensorSym, False), + (4, True, 12, kFp8StaticTensorSym, False), + # Not FA4. + (3, True, 10, kFp8StaticTensorSym, False), + (2, True, 10, kFp8StaticTensorSym, False), + (None, True, 10, kFp8StaticTensorSym, False), + # Upstream (ROCm) flash-attn, not vLLM-FA. + (4, False, 10, kFp8StaticTensorSym, False), + # Quant keys not wired through FA4 yet. + (4, True, 10, kFp8Dynamic128Sym, False), + (4, True, 10, kNvfp4Dynamic, False), + ], +) +def test_flash_attn_supports_quant_output( + version, is_vllm_fa, dc_major, quant_key, expected +): + backend = _make_fa_backend(version, is_vllm_fa) + with patch(f"{_FA_MODULE}.current_platform") as plat: + plat.get_device_capability.return_value = DeviceCapability( + major=dc_major, minor=0 + ) + assert backend.supports_quant_output(quant_key) is expected + + +def test_flash_attn_supports_quant_output_unknown_device(): + """A None device capability (e.g. capability probe failed) is safe.""" + backend = _make_fa_backend(version=4, is_vllm_fa=True) + with patch(f"{_FA_MODULE}.current_platform") as plat: + plat.get_device_capability.return_value = None + assert backend.supports_quant_output(kFp8StaticTensorSym) is False + + +def test_flash_attn_prefill_backend_signature_accepts_fused_kwargs(): + """run_prefill_new_tokens must accept out/output_scale so the direct + (non-**kwargs) call in forward_mha type- and runtime-checks.""" + import inspect + + params = inspect.signature( + FlashAttnPrefillBackend.run_prefill_new_tokens + ).parameters + assert "out" in params + assert "output_scale" in params + # The base contract must expose them too (Liskov / direct call site). + base_params = inspect.signature(MLAPrefillBackend.run_prefill_new_tokens).parameters + assert "out" in base_params + assert "output_scale" in base_params + + +def test_mla_impl_forward_mha_accepts_output_scale(): + """The abstract MLA impl forward_mha must carry output_scale so every + override (and the unconditional forward_impl call) stays compatible.""" + import inspect + + from vllm.v1.attention.backend import MLAAttentionImpl + + params = inspect.signature(MLAAttentionImpl.forward_mha).parameters + assert "output_scale" in params + assert params["output_scale"].default is None + + +def _fused_fp8_skip_reason() -> str | None: + """FA4 fused FP8 output needs a real Blackwell SM100/SM110 GPU.""" + if not torch.cuda.is_available(): + return "requires CUDA" + major = torch.cuda.get_device_capability()[0] + if major not in (10, 11): + return f"FA4 fused FP8 output requires SM100/SM110, got SM{major}x" + return None + + +_FUSED_FP8_SKIP = _fused_fp8_skip_reason() + + +@pytest.mark.skipif(_FUSED_FP8_SKIP is not None, reason=_FUSED_FP8_SKIP or "") +def test_fa4_fused_fp8_output_matches_post_quant(default_vllm_config): + """FA4's fused FP8 write (output_scale, flash-attention#135) must match the + bf16-attention + standalone static-FP8-quant path it replaces, since + production uses the same output_scale for both.""" + from vllm.model_executor.layers.quantization.input_quant_fp8 import QuantFP8 + from vllm.model_executor.layers.quantization.utils.quant_utils import GroupShape + from vllm.platforms import current_platform + from vllm.vllm_flash_attn import flash_attn_varlen_func + + torch.manual_seed(0) + device = torch.device("cuda") + fp8_dtype = current_platform.fp8_dtype() + + # MLA prefill head dims (post kv_b_proj): q/k = qk_nope(128)+qk_rope(64), + # v = v_head_dim(128); DeepSeek-V2-Lite has 16 query heads. + num_heads, qk_head_dim, v_head_dim, seqlen = 16, 192, 128, 512 + cu_seqlens = torch.tensor([0, seqlen], dtype=torch.int32, device=device) + q = torch.randn(seqlen, num_heads, qk_head_dim, dtype=torch.bfloat16, device=device) + k = torch.randn(seqlen, num_heads, qk_head_dim, dtype=torch.bfloat16, device=device) + v = torch.randn(seqlen, num_heads, v_head_dim, dtype=torch.bfloat16, device=device) + + fa_kwargs = dict( + cu_seqlens_q=cu_seqlens, + cu_seqlens_k=cu_seqlens, + max_seqlen_q=seqlen, + max_seqlen_k=seqlen, + causal=True, + fa_version=4, + ) + + # Reference: bf16 attention, then standalone static per-tensor FP8 quant. + out_bf16 = flash_attn_varlen_func(q=q, k=k, v=v, **fa_kwargs) + out_2d = out_bf16.reshape(seqlen, num_heads * v_head_dim) + # Scale the amax near e4m3 max so the check uses the representable range. + finfo = torch.finfo(fp8_dtype) + scale = (out_2d.abs().max() / finfo.max).to(torch.float32).reshape(1) + quant_op = QuantFP8(static=True, group_shape=GroupShape.PER_TENSOR) + ref_fp8, _ = quant_op(out_2d, scale) + + # Feature: FA4 writes e4m3 into the (tokens, heads*dim) buffer directly. + fused_fp8 = torch.empty( + seqlen, num_heads * v_head_dim, dtype=fp8_dtype, device=device + ) + flash_attn_varlen_func( + q=q, + k=k, + v=v, + out=fused_fp8.view(seqlen, num_heads, v_head_dim), + output_scale=scale, + **fa_kwargs, + ) + + # Non-degenerate (catches a no-op / all-zero write). + assert torch.isfinite(fused_fp8.float()).all() + assert fused_fp8.float().abs().any() + + # e4m3 has 3 mantissa bits, so allow ~1 mantissa step of rounding slack. + ref = ref_fp8.float() * scale + got = fused_fp8.float() * scale + torch.testing.assert_close(got, ref, rtol=0.125, atol=float(scale) * 2) + + # ...and most elements land in the exact same fp8 bucket. + exact = (fused_fp8.view(torch.uint8) == ref_fp8.view(torch.uint8)).float().mean() + assert exact > 0.9, f"only {exact:.1%} of fused FP8 outputs matched the baseline" diff --git a/tests/v1/attention/test_mla_prefill_registry.py b/tests/v1/attention/test_mla_prefill_registry.py index 4b701b8c13be..52c8d185548b 100644 --- a/tests/v1/attention/test_mla_prefill_registry.py +++ b/tests/v1/attention/test_mla_prefill_registry.py @@ -16,7 +16,6 @@ class CustomMLAPrefillBackend(MLAPrefillBackend): """Mock custom MLA prefill backend for testing.""" supported_dtypes = [torch.bfloat16, torch.float16] - requires_r1_mla_dimensions = False @staticmethod def get_name() -> str: @@ -29,6 +28,28 @@ def run_prefill_context_chunk(self, chunk_idx, q, k, v): raise NotImplementedError +def test_prefill_backend_clone_has_isolated_metadata(): + backend = CustomMLAPrefillBackend( + num_heads=4, + scale=0.5, + kv_lora_rank=8, + qk_nope_head_dim=16, + qk_rope_head_dim=8, + v_head_dim=32, + vllm_config=object(), + ) + + clone = backend.clone() + + assert isinstance(clone, CustomMLAPrefillBackend) + assert clone is not backend + assert clone.num_heads == backend.num_heads + assert clone.scale == backend.scale + backend._prefill_metadata = object() + clone._prefill_metadata = object() + assert clone._prefill_metadata is not backend._prefill_metadata + + @pytest.fixture(autouse=True) def cleanup_overrides(): """Clear any overrides after each test.""" @@ -83,7 +104,6 @@ def test_register_custom_backend_as_decorator(): @register_mla_prefill_backend(MLAPrefillBackendEnum.CUSTOM) class DecoratedPrefillBackend(MLAPrefillBackend): supported_dtypes = [torch.bfloat16] - requires_r1_mla_dimensions = False @staticmethod def get_name() -> str: @@ -135,3 +155,20 @@ def test_clear_override(): def test_unknown_backend_name_raises(): with pytest.raises(ValueError, match="Unknown MLA prefill backend"): MLAPrefillBackendEnum["NONEXISTENT"] + + +def test_rocm_aiter_fa_registered(): + """ROCM_AITER_FA is a known backend pointing at the AITER FA class.""" + assert "ROCM_AITER_FA" in MLAPrefillBackendEnum.__members__ + + path = MLAPrefillBackendEnum.ROCM_AITER_FA.get_path() + assert path == ( + "vllm.v1.attention.backends.mla.prefill.aiter_flash_attn." + "AiterFlashAttnPrefillBackend" + ) + + backend_cls = MLAPrefillBackendEnum.ROCM_AITER_FA.get_class() + assert backend_cls.get_name() == "ROCM_AITER_FA" + # The AITER FA path is the fp16/bf16 generic-varlen prefill path. + assert backend_cls.supports_dtype(torch.bfloat16) + assert backend_cls.supports_dtype(torch.float16) diff --git a/tests/v1/attention/test_mla_prefill_selector.py b/tests/v1/attention/test_mla_prefill_selector.py index d5c80c80c03e..c8932032467a 100644 --- a/tests/v1/attention/test_mla_prefill_selector.py +++ b/tests/v1/attention/test_mla_prefill_selector.py @@ -9,12 +9,13 @@ from vllm.config import AttentionConfig, ModelConfig, VllmConfig from vllm.platforms.interface import DeviceCapability +from vllm.v1.attention.backends.mla.prefill.base import MLADimensions from vllm.v1.attention.backends.mla.prefill.registry import MLAPrefillBackendEnum from vllm.v1.attention.backends.mla.prefill.selector import ( MLAPrefillSelectorConfig, _auto_select_mla_prefill_backend, + _get_mla_prefill_backend_priorities, get_mla_prefill_backend, - is_deepseek_r1_mla_compatible, ) @@ -149,11 +150,14 @@ class TestAutoSelectMLAPrefillBackend: """Tests for fallback and error paths in auto-selection.""" def test_blackwell_falls_back_to_trtllm(self): - vllm_config = _make_vllm_config() capability = DeviceCapability(major=10, minor=0) selector_config = MLAPrefillSelectorConfig( dtype=torch.bfloat16, - is_r1_compatible=is_deepseek_r1_mla_compatible(vllm_config), + mla_dimensions=MLADimensions( + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + ), ) try: @@ -163,6 +167,7 @@ def test_blackwell_falls_back_to_trtllm(self): return with ( + patch("vllm.platforms.current_platform") as mock_platform, patch.object( MLAPrefillBackendEnum.FLASH_ATTN, "get_class", @@ -170,6 +175,8 @@ def test_blackwell_falls_back_to_trtllm(self): ), patch.object(trtllm_cls, "validate_configuration", return_value=[]), ): + # Force the non-ROCm priority on the Blackwell. + mock_platform.is_rocm.return_value = False backend = _auto_select_mla_prefill_backend( capability, selector_config, @@ -177,11 +184,14 @@ def test_blackwell_falls_back_to_trtllm(self): assert backend.get_name() == "TRTLLM_RAGGED" def test_all_fail_raises_error(self): - vllm_config = _make_vllm_config() capability = DeviceCapability(major=10, minor=0) selector_config = MLAPrefillSelectorConfig( dtype=torch.bfloat16, - is_r1_compatible=is_deepseek_r1_mla_compatible(vllm_config), + mla_dimensions=MLADimensions( + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + ), ) def mock_get_class(backend_enum): # noqa: ARG001 @@ -201,28 +211,26 @@ def mock_get_class(backend_enum): # noqa: ARG001 class TestBackendValidation: """Tests for backend validation logic.""" - def test_r1_dimension_requirement(self): + def test_backend_supported_dimension_validation(self): try: from vllm.v1.attention.backends.mla.prefill.flashinfer import ( FlashInferPrefillBackend, ) + from vllm.v1.attention.backends.mla.prefill.trtllm_ragged import ( + TrtllmRaggedPrefillBackend, + ) except ImportError: - pytest.skip("FlashInfer prefill backend not available") + pytest.skip("MLA prefill backend not available") return - assert FlashInferPrefillBackend.requires_r1_mla_dimensions is True - - vllm_config = _make_vllm_config( - model_config=_make_mock_model_config( - qk_nope_head_dim=128, - qk_rope_head_dim=64, - v_head_dim=128, - ) - ) capability = DeviceCapability(major=10, minor=0) selector_config = MLAPrefillSelectorConfig( dtype=torch.bfloat16, - is_r1_compatible=is_deepseek_r1_mla_compatible(vllm_config), + mla_dimensions=MLADimensions( + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + ), ) with patch.object(FlashInferPrefillBackend, "is_available", return_value=True): @@ -232,16 +240,13 @@ def test_r1_dimension_requirement(self): ) assert len(invalid_reasons) == 0 - vllm_config_invalid = _make_vllm_config( - model_config=_make_mock_model_config( + selector_config_invalid = MLAPrefillSelectorConfig( + dtype=torch.bfloat16, + mla_dimensions=MLADimensions( qk_nope_head_dim=64, qk_rope_head_dim=64, v_head_dim=128, - ) - ) - selector_config_invalid = MLAPrefillSelectorConfig( - dtype=torch.bfloat16, - is_r1_compatible=is_deepseek_r1_mla_compatible(vllm_config_invalid), + ), ) with patch.object(FlashInferPrefillBackend, "is_available", return_value=True): @@ -250,7 +255,140 @@ def test_r1_dimension_requirement(self): selector_config_invalid, ) assert len(invalid_reasons) == 1 - assert "DeepSeek R1 MLA dimensions" in invalid_reasons[0] + assert "supported MLA dimensions" in invalid_reasons[0] + + selector_config_glm5 = MLAPrefillSelectorConfig( + dtype=torch.bfloat16, + mla_dimensions=MLADimensions( + qk_nope_head_dim=192, + qk_rope_head_dim=64, + v_head_dim=256, + ), + ) + + with patch.object( + TrtllmRaggedPrefillBackend, "is_available", return_value=True + ): + invalid_reasons = TrtllmRaggedPrefillBackend.validate_configuration( + capability, + selector_config_glm5, + ) + assert invalid_reasons == [] + + +class TestROCmAiterFAPrefillSelection: + """Tests for the ROCm AITER FlashAttention MLA prefill backend.""" + + def test_rocm_priorities_prefer_aiter_fa(self): + """On ROCm, ROCM_AITER_FA is tried first, FLASH_ATTN as fallback.""" + with patch("vllm.platforms.current_platform") as mock_platform: + mock_platform.is_rocm.return_value = True + priorities = _get_mla_prefill_backend_priorities( + DeviceCapability(major=9, minor=5) + ) + + assert priorities == [ + MLAPrefillBackendEnum.ROCM_AITER_FA, + MLAPrefillBackendEnum.FLASH_ATTN, + ] + + def test_supported_dtypes_are_fp16_bf16_only(self): + from vllm.v1.attention.backends.mla.prefill.aiter_flash_attn import ( + AiterFlashAttnPrefillBackend, + ) + + assert AiterFlashAttnPrefillBackend.supports_dtype(torch.bfloat16) + assert AiterFlashAttnPrefillBackend.supports_dtype(torch.float16) + # FP8 is served by the separate AITER ASM backend, not this one. + assert not AiterFlashAttnPrefillBackend.supports_dtype(torch.float8_e4m3fn) + + def test_supports_compute_capability_on_rocm(self): + from vllm.v1.attention.backends.mla.prefill import aiter_flash_attn as mod + + # Gating is decided by on_mi3xx(), not by capability + capability = MagicMock() + + with patch.object(mod.current_platform, "is_rocm", return_value=False): + assert not mod.AiterFlashAttnPrefillBackend.supports_compute_capability( + capability + ) + + with ( + patch.object(mod.current_platform, "is_rocm", return_value=True), + patch("vllm.platforms.rocm.on_mi3xx", return_value=False), + ): + assert not mod.AiterFlashAttnPrefillBackend.supports_compute_capability( + capability + ) + + with ( + patch.object(mod.current_platform, "is_rocm", return_value=True), + patch("vllm.platforms.rocm.on_mi3xx", return_value=True), + ): + assert mod.AiterFlashAttnPrefillBackend.supports_compute_capability( + capability + ) + + def test_is_available_delegates_to_rocm_aiter_ops(self): + from vllm._aiter_ops import rocm_aiter_ops + from vllm.v1.attention.backends.mla.prefill import aiter_flash_attn as mod + + with patch.object(rocm_aiter_ops, "is_enabled", return_value=False): + assert not mod.AiterFlashAttnPrefillBackend.is_available() + + with patch.object(rocm_aiter_ops, "is_enabled", return_value=True): + assert mod.AiterFlashAttnPrefillBackend.is_available() + + def test_auto_select_prefers_aiter_fa_on_rocm(self): + from vllm.v1.attention.backends.mla.prefill.aiter_flash_attn import ( + AiterFlashAttnPrefillBackend, + ) + + # gfx gating is simulated via the mocked validate_configuration, + # not the capability. + capability = MagicMock() + selector_config = MLAPrefillSelectorConfig(dtype=torch.bfloat16) + + with ( + patch("vllm.platforms.current_platform") as mock_platform, + patch.object( + AiterFlashAttnPrefillBackend, + "validate_configuration", + return_value=[], + ), + ): + mock_platform.is_rocm.return_value = True + backend = _auto_select_mla_prefill_backend(capability, selector_config) + assert backend.get_name() == "ROCM_AITER_FA" + + def test_auto_select_falls_back_to_flash_attn_when_aiter_invalid(self): + from vllm.v1.attention.backends.mla.prefill.aiter_flash_attn import ( + AiterFlashAttnPrefillBackend, + ) + + try: + flash_attn_cls = MLAPrefillBackendEnum.FLASH_ATTN.get_class() + except ImportError: + pytest.skip("FLASH_ATTN backend not available") + return + + # the fallback is forced by the mocked validate_configuration, + # not the capability. + capability = MagicMock() + selector_config = MLAPrefillSelectorConfig(dtype=torch.bfloat16) + + with ( + patch("vllm.platforms.current_platform") as mock_platform, + patch.object( + AiterFlashAttnPrefillBackend, + "validate_configuration", + return_value=["compute capability not supported"], + ), + patch.object(flash_attn_cls, "validate_configuration", return_value=[]), + ): + mock_platform.is_rocm.return_value = True + backend = _auto_select_mla_prefill_backend(capability, selector_config) + assert backend.get_name() == "FLASH_ATTN" class TestMLAPrefillBackendParsing: diff --git a/tests/v1/attention/test_sparse_mla_backends.py b/tests/v1/attention/test_sparse_mla_backends.py index 22acc748d24b..6e389604c800 100644 --- a/tests/v1/attention/test_sparse_mla_backends.py +++ b/tests/v1/attention/test_sparse_mla_backends.py @@ -36,7 +36,7 @@ from vllm.utils.math_utils import cdiv from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import ( - FlashInferMLASparseBackend, + FlashInferMLASparseTRTLLMBackend, ) from vllm.v1.attention.backends.mla.flashmla_sparse import ( FlashMLASparseBackend, @@ -174,8 +174,8 @@ def _quantize_dequantize_fp8_ds_mla( @pytest.mark.parametrize( "backend_cls", - [FlashMLASparseBackend, FlashInferMLASparseBackend], - ids=["FlashMLA", "FlashInfer"], + [FlashMLASparseBackend, FlashInferMLASparseTRTLLMBackend], + ids=["FlashMLA", "FlashInferTRTLLM"], ) @pytest.mark.parametrize("batch_name", list(SPARSE_BACKEND_BATCH_SPECS.keys())) @pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8", "fp8_ds_mla"]) @@ -217,9 +217,12 @@ def test_sparse_backend_decode_correctness( ok, reason = flashmla.is_flashmla_sparse_supported() if not ok: pytest.skip(reason) - elif backend_cls == FlashInferMLASparseBackend: - if not current_platform.has_device_capability(100): - pytest.skip("FlashInferMLASparseBackend requires SM 10.0 or higher") + elif backend_cls == FlashInferMLASparseTRTLLMBackend: + device_capability = current_platform.get_device_capability() + if device_capability is None or not backend_cls.supports_compute_capability( + device_capability + ): + pytest.skip("FlashInferMLASparseTRTLLMBackend requires SM 10.x capability") batch_spec = SPARSE_BACKEND_BATCH_SPECS[batch_name] use_fp8_ds_mla_quantization = kv_cache_dtype == "fp8_ds_mla" diff --git a/tests/v1/attention/test_trtllm_attention_integration.py b/tests/v1/attention/test_trtllm_attention_integration.py index 06c5844508f4..0fe9a7ecf62e 100644 --- a/tests/v1/attention/test_trtllm_attention_integration.py +++ b/tests/v1/attention/test_trtllm_attention_integration.py @@ -32,9 +32,10 @@ ) from vllm.v1.attention.backends.flashinfer import ( # noqa: E402 + FlashInferDecodeKernel, FlashInferImpl, FlashInferMetadataBuilder, - TRTLLMDecode, + FlashInferTrtllmAPIDecode, TRTLLMPrefill, ) @@ -435,9 +436,11 @@ def causal_mask_mod(b, h, q_idx, kv_idx, *, context_len): f"Expected TRTLLMPrefill, got {type(attn_metadata.prefill)}" ) if has_decodes: - assert isinstance(attn_metadata.decode, TRTLLMDecode), ( - f"Expected TRTLLMDecode, got {type(attn_metadata.decode)}" + assert isinstance(attn_metadata.decode, FlashInferTrtllmAPIDecode), ( + "Expected FlashInferTrtllmAPIDecode, got " + f"{type(attn_metadata.decode)}" ) + assert attn_metadata.decode.kernel == FlashInferDecodeKernel.TRTLLM_GEN impl = FlashInferImpl( num_heads=num_q_heads, diff --git a/tests/v1/core/prefix_cache/test_partial_prefix_cache_primitives.py b/tests/v1/core/prefix_cache/test_partial_prefix_cache_primitives.py new file mode 100644 index 000000000000..225d87e86796 --- /dev/null +++ b/tests/v1/core/prefix_cache/test_partial_prefix_cache_primitives.py @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from collections.abc import Callable + +import pytest + +import vllm.v1.core.kv_cache_utils as kv_cache_utils +from vllm.distributed.kv_events import BlockRemoved, BlockStored +from vllm.sampling_params import SamplingParams +from vllm.utils.hashing import sha256 +from vllm.v1.core.block_pool import BlockPool +from vllm.v1.core.kv_cache_utils import ( + BlockHash, + BlockHashListWithBlockSize, + KVCacheBlock, + get_request_block_hasher, + hash_block_tokens, + init_none_hash, +) +from vllm.v1.request import Request + +pytestmark = pytest.mark.cpu_test + + +@pytest.fixture(autouse=True) +def _auto_init_hash_fn(): + init_none_hash(sha256) + + +def make_request( + request_id: str, + prompt_token_ids: list[int], + hash_block_size: int, + hash_fn: Callable, +) -> Request: + sampling_params = SamplingParams(max_tokens=17) + sampling_params.update_from_generation_config({}, eos_token_id=100) + return Request( + request_id=request_id, + prompt_token_ids=prompt_token_ids, + sampling_params=sampling_params, + pooling_params=None, + block_hasher=get_request_block_hasher(hash_block_size, hash_fn), + ) + + +def boundary_hash(req: Request, hash_block_size: int, num_tokens: int) -> BlockHash: + # Every boundary at a hash_block_size multiple is just the fine-grained + # chain hash ending there. + return req.block_hashes[num_tokens // hash_block_size - 1] + + +def cache_full_block_and_partial_tail( + token_ids: list[int], + *, + enable_kv_cache_events: bool = False, +) -> tuple[BlockPool, Request, list[KVCacheBlock], BlockHash]: + hash_block_size = 2 + block_size = 6 + kv_cache_group_id = 0 + req = make_request("0", token_ids, hash_block_size, sha256) + pool = BlockPool( + num_gpu_blocks=3, + enable_caching=True, + hash_block_size=hash_block_size, + enable_kv_cache_events=enable_kv_cache_events, + ) + blocks = pool.get_new_blocks(2) + + pool.cache_full_blocks( + request=req, + blocks=blocks, + num_cached_blocks=0, + num_full_blocks=1, + block_size=block_size, + kv_cache_group_id=kv_cache_group_id, + ) + partial_hash = boundary_hash(req, hash_block_size, len(token_ids)) + assert pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=len(token_ids), + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + return pool, req, blocks, partial_hash + + +def test_boundary_hashes_reuse_fine_grained_chain(): + hash_block_size = 2 + block_size = 6 + token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4] + req = make_request("0", token_ids, hash_block_size, sha256) + + coarse = BlockHashListWithBlockSize(req.block_hashes, hash_block_size, block_size) + # The block_size=6 full-block hash is the fine hash at the 6-token boundary, + # not a concatenation of the three fine hashes inside the block. + assert coarse[0] == req.block_hashes[6 // hash_block_size - 1] + assert coarse[0] != BlockHash( + req.block_hashes[0] + req.block_hashes[1] + req.block_hashes[2] + ) + # A partial tail at 10 tokens is the fine hash at the 10-token boundary, + # which chains over the entire prefix. + tail_hash = boundary_hash(req, hash_block_size, 10) + assert tail_hash == req.block_hashes[4] + assert tail_hash == hash_block_tokens(sha256, req.block_hashes[3], token_ids[8:10]) + + +def test_cache_partial_block_kv_cache_events(): + hash_block_size = 4 + block_size = 12 + kv_cache_group_id = 2 + + pool = BlockPool( + num_gpu_blocks=2, + enable_caching=True, + hash_block_size=hash_block_size, + enable_kv_cache_events=True, + ) + req = make_request( + "req_partial_events", + prompt_token_ids=list(range(hash_block_size * 2)), + hash_block_size=hash_block_size, + hash_fn=sha256, + ) + + block = pool.get_new_blocks(1)[0] + partial_entry_hash = pool.cache_partial_block( + request=req, + block=block, + num_tokens=hash_block_size * 2, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + + events = pool.take_events() + assert len(events) == 1 + stored_event = events[0] + assert isinstance(stored_event, BlockStored) + assert partial_entry_hash is not None + assert stored_event.block_hashes == [ + kv_cache_utils.maybe_convert_block_hash(req.block_hashes[1]) + ] + assert stored_event.parent_block_hash == kv_cache_utils.maybe_convert_block_hash( + req.block_hashes[0] + ) + assert stored_event.token_ids == req.all_token_ids[hash_block_size:] + assert stored_event.block_size == 4 + assert stored_event.group_idx == kv_cache_group_id + + duplicate_entry_hash = pool.cache_partial_block( + request=req, + block=block, + num_tokens=hash_block_size * 2, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + assert duplicate_entry_hash == partial_entry_hash + assert pool.take_events() == [] + + pool.free_blocks([block]) + pool.get_new_blocks(1) + events = pool.take_events() + assert len(events) == 1 + removed_event = events[0] + assert isinstance(removed_event, BlockRemoved) + assert removed_event.block_hashes == stored_event.block_hashes + assert removed_event.group_idx == kv_cache_group_id + + +def test_partial_block_replacement_emits_remove_then_store_events(): + hash_block_size = 2 + block_size = 6 + kv_cache_group_id = 0 + req = make_request("0", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256) + pool = BlockPool( + num_gpu_blocks=3, + enable_caching=True, + hash_block_size=hash_block_size, + enable_kv_cache_events=True, + ) + blocks = pool.get_new_blocks(2) + + pool.cache_full_blocks( + request=req, + blocks=blocks, + num_cached_blocks=0, + num_full_blocks=1, + block_size=block_size, + kv_cache_group_id=kv_cache_group_id, + ) + partial_hash_8 = boundary_hash(req, hash_block_size, 8) + assert pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=8, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + assert pool.get_cached_block(partial_hash_8, [kv_cache_group_id]) == [blocks[1]] + pool.take_events() + + req.append_output_token_ids([4, 4]) + partial_hash_10 = boundary_hash(req, hash_block_size, 10) + assert pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=10, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + events = pool.take_events() + + assert len(events) == 2 + removed_event, stored_event = events + assert isinstance(removed_event, BlockRemoved) + assert removed_event.block_hashes == [ + kv_cache_utils.maybe_convert_block_hash(partial_hash_8) + ] + assert removed_event.group_idx == kv_cache_group_id + assert isinstance(stored_event, BlockStored) + assert stored_event.block_hashes == [ + kv_cache_utils.maybe_convert_block_hash(partial_hash_10) + ] + assert stored_event.parent_block_hash == kv_cache_utils.maybe_convert_block_hash( + boundary_hash(req, hash_block_size, 8) + ) + assert stored_event.token_ids == req.all_token_ids[8:10] + assert stored_event.block_size == hash_block_size + assert stored_event.group_idx == kv_cache_group_id + assert pool.get_cached_block(partial_hash_8, [kv_cache_group_id]) is None + assert pool.get_cached_block(partial_hash_10, [kv_cache_group_id]) == [blocks[1]] + + +def test_later_request_hits_cached_partial_tail(): + hash_block_size = 2 + block_size = 6 + kv_cache_group_id = 0 + cached_token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4] + req = make_request("0", cached_token_ids, hash_block_size, sha256) + pool = BlockPool( + num_gpu_blocks=3, + enable_caching=True, + hash_block_size=hash_block_size, + ) + blocks = pool.get_new_blocks(2) + + pool.cache_full_blocks( + request=req, + blocks=blocks, + num_cached_blocks=0, + num_full_blocks=1, + block_size=block_size, + kv_cache_group_id=kv_cache_group_id, + ) + partial_hash_10 = boundary_hash(req, hash_block_size, 10) + assert pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=10, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + + replay = make_request("1", cached_token_ids, hash_block_size, sha256) + replay_hash_10 = boundary_hash(replay, hash_block_size, 10) + assert replay_hash_10 == partial_hash_10 + assert pool.get_cached_block(replay_hash_10, [kv_cache_group_id]) == [blocks[1]] + + extended = make_request("2", cached_token_ids + [10], hash_block_size, sha256) + extended_hash_10 = boundary_hash(extended, hash_block_size, 10) + assert extended_hash_10 == partial_hash_10 + assert pool.get_cached_block(extended_hash_10, [kv_cache_group_id]) == [blocks[1]] + + +def test_cache_partial_block_uses_fine_grained_boundary_hash(): + hash_block_size = 2 + block_size = 6 + kv_cache_group_id = 0 + token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4] + req = make_request("0", token_ids, hash_block_size, sha256) + pool = BlockPool( + num_gpu_blocks=3, + enable_caching=True, + hash_block_size=hash_block_size, + ) + blocks = pool.get_new_blocks(2) + + pool.cache_full_blocks( + request=req, + blocks=blocks, + num_cached_blocks=0, + num_full_blocks=1, + block_size=block_size, + kv_cache_group_id=kv_cache_group_id, + ) + + partial_entry_hash = pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=10, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + # The partial entry is keyed by the fine-grained hash at the 10-token + # boundary, regardless of the owning group's block_size. + expected = boundary_hash(req, hash_block_size, 10) + assert partial_entry_hash == kv_cache_utils.make_block_hash_with_group_id( + expected, kv_cache_group_id + ) + assert pool.get_cached_block(expected, [kv_cache_group_id]) == [blocks[1]] + + +def test_cache_partial_block_requires_hash_boundary(): + hash_block_size = 2 + block_size = 4 + req = make_request("0", [0, 0, 1, 1], hash_block_size, sha256) + pool = BlockPool( + num_gpu_blocks=2, + enable_caching=True, + hash_block_size=hash_block_size, + ) + block = pool.get_new_blocks(1)[0] + + with pytest.raises(AssertionError): + pool.cache_partial_block( + request=req, + block=block, + num_tokens=3, + kv_cache_group_id=0, + block_size=block_size, + ) + + +def test_cache_partial_block_duplicate_checks_all_blocks_for_hash(): + hash_block_size = 2 + block_size = 4 + kv_cache_group_id = 0 + req = make_request("0", [0, 0, 1, 1], hash_block_size, sha256) + pool = BlockPool( + num_gpu_blocks=4, + enable_caching=True, + hash_block_size=hash_block_size, + ) + blocks = pool.get_new_blocks(2) + + first_entry_hash = pool.cache_partial_block( + request=req, + block=blocks[0], + num_tokens=2, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + second_entry_hash = pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=2, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + assert first_entry_hash == second_entry_hash + + duplicate_entry_hash = pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=2, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + assert duplicate_entry_hash == second_entry_hash + assert pool.cached_block_hashes_by_block == {} + + +def test_reset_prefix_cache_clears_partial_entry_metadata(): + pool, req, blocks, partial_hash_10 = cache_full_block_and_partial_tail( + [0, 0, 1, 1, 2, 2, 3, 3, 4, 4] + ) + full_hash = BlockHashListWithBlockSize(req.block_hashes, 2, 6)[0] + + assert pool.get_cached_block(full_hash, [0]) == [blocks[0]] + assert pool.get_cached_block(partial_hash_10, [0]) == [blocks[1]] + + pool.free_blocks(blocks) + assert pool.reset_prefix_cache() + + assert pool.get_cached_block(full_hash, [0]) is None + assert pool.get_cached_block(partial_hash_10, [0]) is None + assert pool.cached_block_hashes_by_block == {} + + +def test_evict_cached_block_removes_full_hash_and_partial_entry(): + pool, req, blocks, partial_hash_10 = cache_full_block_and_partial_tail( + [0, 0, 1, 1, 2, 2, 3, 3, 4, 4] + ) + full_hash = BlockHashListWithBlockSize(req.block_hashes, 2, 6)[0] + + assert pool.get_cached_block(full_hash, [0]) == [blocks[0]] + assert pool.get_cached_block(partial_hash_10, [0]) == [blocks[1]] + + pool.evict_blocks({blocks[0].block_id, blocks[1].block_id}) + + assert pool.get_cached_block(full_hash, [0]) is None + assert pool.get_cached_block(partial_hash_10, [0]) is None + assert pool.cached_block_hashes_by_block == {} + + +def test_partial_block_promotes_to_direct_full_block_hash(): + hash_block_size = 2 + block_size = 6 + kv_cache_group_id = 0 + token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4] + req = make_request("0", token_ids, hash_block_size, sha256) + pool = BlockPool( + num_gpu_blocks=3, + enable_caching=True, + hash_block_size=hash_block_size, + ) + blocks = pool.get_new_blocks(2) + + pool.cache_full_blocks( + request=req, + blocks=blocks, + num_cached_blocks=0, + num_full_blocks=1, + block_size=block_size, + kv_cache_group_id=kv_cache_group_id, + ) + partial_hash_10 = boundary_hash(req, hash_block_size, 10) + assert pool.cache_partial_block( + request=req, + block=blocks[1], + num_tokens=10, + kv_cache_group_id=kv_cache_group_id, + block_size=block_size, + ) + assert pool.get_cached_block(partial_hash_10, [kv_cache_group_id]) == [blocks[1]] + + req.append_output_token_ids([5, 5]) + full_hashes = BlockHashListWithBlockSize( + req.block_hashes, hash_block_size, block_size + ) + promoted_full_hash = full_hashes[1] + # The promoted full-block hash is the fine hash at the 12-token boundary, + # not a concatenation of the fine hashes inside the block. + assert promoted_full_hash == req.block_hashes[12 // hash_block_size - 1] + assert promoted_full_hash != BlockHash( + req.block_hashes[3] + req.block_hashes[4] + req.block_hashes[5] + ) + + pool.cache_full_blocks( + request=req, + blocks=blocks, + num_cached_blocks=1, + num_full_blocks=2, + block_size=block_size, + kv_cache_group_id=kv_cache_group_id, + ) + assert pool.get_cached_block(promoted_full_hash, [kv_cache_group_id]) == [blocks[1]] + assert pool.get_cached_block(partial_hash_10, [kv_cache_group_id]) is None diff --git a/tests/v1/core/test_async_scheduler.py b/tests/v1/core/test_async_scheduler.py index a77a50173f3f..9b6f64589614 100644 --- a/tests/v1/core/test_async_scheduler.py +++ b/tests/v1/core/test_async_scheduler.py @@ -272,6 +272,9 @@ def test_abort_request_when_structured_output_fsm_cannot_advance(): scheduler.connector = None scheduler.structured_output_manager = Mock() scheduler.structured_output_manager.should_advance.return_value = True + scheduler.structured_output_manager.trim_reasoning_for_advance.side_effect = ( + lambda request, new_token_ids: new_token_ids + ) scheduler.requests = {request.request_id: request} scheduler.running = [request] scheduler.waiting = Mock() @@ -284,6 +287,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance(): scheduler.vllm_config.model_config.enable_return_routed_experts = False scheduler.enable_return_routed_experts = False scheduler.recompute_kv_load_failures = False + scheduler.defer_block_free = False scheduler.make_stats = Mock(return_value=None) scheduler.max_model_len = 128 @@ -320,3 +324,48 @@ def free_request(req, delay_free_blocks=False): assert request.status == RequestStatus.FINISHED_ERROR assert request.request_id not in scheduler.requests assert not scheduler.running + + +def test_no_placeholder_underflow_on_discarded_spec_frame(): + num_spec = 5 + scheduler = create_scheduler( + async_scheduling=True, + num_speculative_tokens=num_spec, + speculative_method="ngram_gpu", + ) + req = create_requests(num_requests=1, max_tokens=20)[0] + req.num_computed_tokens = req.num_tokens + scheduler.requests[req.request_id] = req + scheduler.running.append(req) + req.status = RequestStatus.RUNNING + + req.num_output_placeholders = 1 + req.async_tokens_to_discard = num_spec + computed_before = req.num_computed_tokens + + scheduler_output = SchedulerOutput( + scheduled_new_reqs=[], + scheduled_cached_reqs=CachedRequestData.make_empty(), + num_scheduled_tokens={req.request_id: num_spec + 1}, + total_num_scheduled_tokens=num_spec + 1, + scheduled_encoder_inputs={}, + scheduled_spec_decode_tokens={req.request_id: [10] * num_spec}, + num_common_prefix_blocks=[], + finished_req_ids=set(), + free_encoder_mm_hashes=[], + ) + model_runner_output = ModelRunnerOutput( + req_ids=[req.request_id], + req_id_to_index={req.request_id: 0}, + sampled_token_ids=[[999]], + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ) + + scheduler.update_from_output(scheduler_output, model_runner_output) + + assert req.num_output_placeholders == 1 + assert req.num_computed_tokens == computed_before + assert req.async_tokens_to_discard == num_spec - 1 + assert req.status == RequestStatus.RUNNING diff --git a/tests/v1/core/test_contiguous_kv_packing.py b/tests/v1/core/test_contiguous_kv_packing.py new file mode 100644 index 000000000000..647241ce73cb --- /dev/null +++ b/tests/v1/core/test_contiguous_kv_packing.py @@ -0,0 +1,231 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for contiguous KV cache packing.""" + +from unittest.mock import MagicMock + +import pytest +import torch + +from vllm.v1.core.kv_cache_utils import ( + _get_kv_cache_config_packed, + get_kv_cache_config_from_groups, +) +from vllm.v1.kv_cache_interface import ( + FullAttentionSpec, + KVCacheGroupSpec, + KVCacheTensor, + MLAAttentionSpec, + SlidingWindowSpec, + UniformTypeKVCacheSpecs, +) + + +def _make_mla_spec(page_size: int, block_size: int = 256) -> MLAAttentionSpec: + return MLAAttentionSpec( + block_size=block_size, + num_kv_heads=1, + head_size=512, + dtype=torch.uint8, + page_size_padded=page_size, + cache_dtype_str="fp8_ds_mla", + model_version="deepseek_v4", + alignment=576, + ) + + +def _make_full_spec() -> FullAttentionSpec: + return FullAttentionSpec( + block_size=16, + num_kv_heads=2, + head_size=64, + dtype=torch.float16, + ) + + +def _make_sw_spec() -> SlidingWindowSpec: + return SlidingWindowSpec( + block_size=16, + num_kv_heads=2, + head_size=64, + dtype=torch.float16, + sliding_window=128, + ) + + +def _make_groups(n_c4, n_c128, n_swa): + PS_C4_MLA = 37440 + PS_C4_IDX = 8640 + PS_C128 = 1728 + PS_SWA = 37440 + + mla_specs = {} + for i in range(n_c4): + mla_specs[f"c4_mla.{i}"] = _make_mla_spec(PS_C4_MLA) + mla_specs[f"c4_idx.{i}"] = _make_mla_spec(PS_C4_IDX) + for i in range(n_c128): + mla_specs[f"c128_mla.{i}"] = _make_mla_spec(PS_C128) + + mla_group = KVCacheGroupSpec( + layer_names=list(mla_specs.keys()), + kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=mla_specs), + ) + + swa_specs = {} + for i in range(n_swa): + swa_specs[f"swa.{i}"] = _make_mla_spec(PS_SWA) + + swa_group = KVCacheGroupSpec( + layer_names=list(swa_specs.keys()), + kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=swa_specs), + ) + + return [mla_group, swa_group] + + +def _mock_vllm_config(kv_connector_extra_config: dict[str, str] | None = None): + config = MagicMock() + config.cache_config.num_gpu_blocks_override = None + config.kv_transfer_config = None + if kv_connector_extra_config is not None: + config.kv_transfer_config = MagicMock() + config.kv_transfer_config.kv_connector_extra_config = kv_connector_extra_config + return config + + +def _run(n_c4=3, n_c128=2, n_swa=5, mem=100 * 1024 * 1024): + groups = _make_groups(n_c4, n_c128, n_swa) + return _get_kv_cache_config_packed(_mock_vllm_config(), groups, mem) + + +def _page_sizes_by_layer( + groups: list[KVCacheGroupSpec], +) -> dict[str, int]: + page_sizes = {} + for group in groups: + specs = group.kv_cache_spec.kv_cache_specs + for layer_name in group.layer_names: + page_sizes[layer_name] = specs[layer_name].page_size_bytes + return page_sizes + + +class TestInterleavedPacking: + def test_all_tensors_have_block_stride(self): + _, tensors = _run() + for t in tensors: + assert t.block_stride > 0 + + def test_all_tensors_share_same_size(self): + _, tensors = _run() + sizes = set(t.size for t in tensors) + assert len(sizes) == 1 + assert sizes.pop() > 0 + + def test_offsets_within_one_block(self): + _, tensors = _run() + for t in tensors: + assert t.offset < t.block_stride + + def test_all_layers_accounted_for(self): + n_c4, n_c128, n_swa = 5, 4, 7 + _, tensors = _run(n_c4=n_c4, n_c128=n_c128, n_swa=n_swa) + all_names = set() + for t in tensors: + all_names.update(t.shared_by) + expected = n_c4 * 2 + n_c128 + n_swa + assert len(all_names) == expected + + def test_strided_views_are_independent(self): + groups = _make_groups(n_c4=3, n_c128=2, n_swa=5) + page_sizes = _page_sizes_by_layer(groups) + num_blocks, tensors = _get_kv_cache_config_packed( + _mock_vllm_config(), groups, 100 * 1024 * 1024 + ) + backing = torch.zeros(tensors[0].size, dtype=torch.uint8) + views = [] + for t in tensors: + page_size = page_sizes[t.shared_by[0]] + v = torch.as_strided( + backing, + size=(num_blocks, page_size), + stride=(t.block_stride, 1), + storage_offset=t.offset, + ) + views.append(v) + + for i, v in enumerate(views): + v.fill_(i + 1) + + for i, v in enumerate(views): + assert (v == i + 1).all(), f"View {i} was corrupted" + + def test_hma_attention_groups_keep_default_backing(self): + full = _make_full_spec() + sw = _make_sw_spec() + page_size = full.page_size_bytes + groups = [ + KVCacheGroupSpec(["full.0", "full.1"], full), + KVCacheGroupSpec(["sw.0", "sw.2"], sw), + KVCacheGroupSpec(["sw.1", "sw.3"], sw), + ] + + config = get_kv_cache_config_from_groups( + _mock_vllm_config(), groups, available_memory=page_size * 2 * 32 + ) + + assert config.num_blocks == 32 + assert sum(t.size for t in config.kv_cache_tensors) == page_size * 2 * 32 + assert config.kv_cache_tensors == [ + KVCacheTensor(size=page_size * 32, shared_by=["full.0", "sw.0", "sw.1"]), + KVCacheTensor(size=page_size * 32, shared_by=["full.1", "sw.2", "sw.3"]), + ] + + def test_hma_attention_groups_use_packed_backing_with_enable_cross_layers(self): + full = _make_full_spec() + sw = _make_sw_spec() + page_size = full.page_size_bytes + groups = [ + KVCacheGroupSpec(["full.0", "full.1"], full), + KVCacheGroupSpec(["sw.0", "sw.2"], sw), + KVCacheGroupSpec(["sw.1", "sw.3"], sw), + ] + + config = get_kv_cache_config_from_groups( + _mock_vllm_config({"enable_cross_layers_blocks": "True"}), + groups, + available_memory=page_size * 2 * 32, + ) + + assert config.num_blocks == 32 + assert {t.size for t in config.kv_cache_tensors} == {page_size * 2 * 32} + assert config.kv_cache_tensors == [ + KVCacheTensor( + size=page_size * 2 * 32, + shared_by=["full.0", "sw.0", "sw.1"], + offset=0, + block_stride=page_size * 2, + ), + KVCacheTensor( + size=page_size * 2 * 32, + shared_by=["full.1", "sw.2", "sw.3"], + offset=page_size, + block_stride=page_size * 2, + ), + ] + + def test_single_group_attention_keeps_unpacked_layout(self): + spec = _make_full_spec() + groups = [KVCacheGroupSpec(["full.0", "full.1"], spec)] + + config = get_kv_cache_config_from_groups( + _mock_vllm_config(), groups, available_memory=spec.page_size_bytes * 2 * 32 + ) + + assert sum(t.size for t in config.kv_cache_tensors) == ( + spec.page_size_bytes * 2 * 32 + ) + assert [t.block_stride for t in config.kv_cache_tensors] == [0, 0] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/v1/core/test_deferred_block_free.py b/tests/v1/core/test_deferred_block_free.py new file mode 100644 index 000000000000..8cab620f0e34 --- /dev/null +++ b/tests/v1/core/test_deferred_block_free.py @@ -0,0 +1,414 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for deferred block freeing under async scheduling. + +With async scheduling, a finished/preempted request's blocks may still be +written by a speculatively over-scheduled in-flight GPU step (mamba/GDN +layers rewrite the whole state block every step). If such a block is +reallocated to a request arriving via PD disaggregation, the NIC/RDMA write +of the received state races with the in-flight stale write. The scheduler +closes the race by deferring the return of blocks to the block pool until +the newest scheduled step's output has been processed. +""" + +import os +import time +from unittest.mock import PropertyMock, patch + +import pytest + +from vllm.config import VllmConfig +from vllm.v1.core.sched.output import SchedulerOutput +from vllm.v1.outputs import ModelRunnerOutput +from vllm.v1.request import RequestStatus + +from .utils import create_requests, create_scheduler, mock_kv + +pytestmark = pytest.mark.cpu_test + +# Allow overriding the model with a local path for offline environments. +MODEL = os.environ.get("VLLM_TEST_DEFER_FREE_MODEL", "facebook/opt-125m") +STOP_TOKEN_ID = 42 +NUM_PROMPT_TOKENS = 33 # 3 blocks with block_size=16 + + +def _make_model_runner_output( + scheduler_output: SchedulerOutput, + token_id: int = 0, +) -> ModelRunnerOutput: + req_ids = list(scheduler_output.num_scheduled_tokens.keys()) + return ModelRunnerOutput( + req_ids=req_ids, + req_id_to_index={req_id: i for i, req_id in enumerate(req_ids)}, + sampled_token_ids=[[token_id] for _ in req_ids], + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ) + + +def _create_deferring_scheduler(): + """Async scheduler with deferred block freeing forced on. + + The production gate additionally requires a PD KV-consumer connector; + the mechanism itself is independent of it. + """ + scheduler = create_scheduler(model=MODEL, async_scheduling=True) + scheduler.defer_block_free = True + return scheduler + + +def _setup_request_with_inflight_step(scheduler, max_tokens: int = 5): + """Schedule a request's prefill (step 1) and one speculatively + over-scheduled decode (step 2), mimicking async scheduling depth 1. + + Returns (request, out0, out1). + """ + request = create_requests( + num_requests=1, + num_tokens=NUM_PROMPT_TOKENS, + max_tokens=max_tokens, + stop_token_ids=[STOP_TOKEN_ID], + )[0] + scheduler.add_request(request) + out0 = scheduler.schedule() + assert out0.num_scheduled_tokens[request.request_id] == NUM_PROMPT_TOKENS + out1 = scheduler.schedule() + assert out1.num_scheduled_tokens[request.request_id] == 1 + return request, out0, out1 + + +def test_gate_enabled_for_async_consumer(): + # Overlapping batches + consumer-side connector enables the gate. Async + # scheduling (which would give >1 concurrent batches) is force-disabled on + # CPU, where this test runs, and PP can't be built without GPUs, so force + # max_concurrent_batches to exercise the enabled path on any platform. + with patch.object( + VllmConfig, + "max_concurrent_batches", + new_callable=PropertyMock, + return_value=2, + ): + scheduler = create_scheduler( + model=MODEL, + async_scheduling=True, + use_kv_connector=mock_kv(matched_tokens=0, is_async=False), + ) + assert scheduler.defer_block_free + + +def test_gate_disabled_without_connector(): + # Async scheduling alone (no PD connector): the gate must stay off + # and freeing must remain immediate. + scheduler = create_scheduler(model=MODEL, async_scheduling=True) + assert not scheduler.defer_block_free + + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request, out0, out1 = _setup_request_with_inflight_step(scheduler) + assert pool.get_num_free_blocks() < num_free_initially + + # Request stops early while step 2 is in flight: blocks are freed + # immediately because deferral is disabled. + scheduler.update_from_output( + out0, _make_model_runner_output(out0, token_id=STOP_TOKEN_ID) + ) + assert request.is_finished() + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_finish_defers_free_until_inflight_step_done(): + scheduler = _create_deferring_scheduler() + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request, out0, out1 = _setup_request_with_inflight_step(scheduler) + num_free_running = pool.get_num_free_blocks() + assert num_free_running < num_free_initially + + # The request stops early (stop token) while the over-scheduled step 2 + # is still in flight: its blocks must NOT return to the pool yet. + scheduler.update_from_output( + out0, _make_model_runner_output(out0, token_id=STOP_TOKEN_ID) + ) + assert request.is_finished() + assert len(scheduler.deferred_frees) == 1 + assert pool.get_num_free_blocks() == num_free_running + + # Step 2's output is processed: every GPU write of step 2 has + # completed, so the blocks can now be returned to the pool. + scheduler.update_from_output(out1, _make_model_runner_output(out1)) + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_finish_frees_immediately_when_no_inflight_step(): + scheduler = _create_deferring_scheduler() + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request = create_requests( + num_requests=1, + num_tokens=NUM_PROMPT_TOKENS, + max_tokens=5, + stop_token_ids=[STOP_TOKEN_ID], + )[0] + scheduler.add_request(request) + out0 = scheduler.schedule() + + # Synchronous-like flow: out0 is the newest scheduled step and its + # output is being processed, so no other step can still write the + # blocks and the free happens immediately. + scheduler.update_from_output( + out0, _make_model_runner_output(out0, token_id=STOP_TOKEN_ID) + ) + assert request.is_finished() + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_abort_defers_free(): + scheduler = _create_deferring_scheduler() + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request, out0, out1 = _setup_request_with_inflight_step(scheduler) + num_free_running = pool.get_num_free_blocks() + + # External abort arrives while steps 1 and 2 are both in flight. + scheduler.finish_requests(request.request_id, RequestStatus.FINISHED_ABORTED) + assert len(scheduler.deferred_frees) == 1 + assert pool.get_num_free_blocks() == num_free_running + + # Step 1's output: step 2 is still in flight, keep holding the blocks. + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + assert len(scheduler.deferred_frees) == 1 + assert pool.get_num_free_blocks() == num_free_running + + # Step 2's output: now the blocks can be freed. + scheduler.update_from_output(out1, _make_model_runner_output(out1)) + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_preempt_defers_free_and_clears_bookkeeping(): + scheduler = _create_deferring_scheduler() + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request, out0, out1 = _setup_request_with_inflight_step(scheduler) + num_free_running = pool.get_num_free_blocks() + + # Preempt the request while steps are in flight (mirrors the + # preemption path inside schedule()). + scheduler.running.remove(request) + scheduler._preempt_request(request, time.monotonic()) + assert request.status == RequestStatus.PREEMPTED + + # Blocks are withheld from the pool, but the manager bookkeeping is + # cleared immediately so the request can be rescheduled safely. + assert len(scheduler.deferred_frees) == 1 + assert pool.get_num_free_blocks() == num_free_running + for manager in scheduler.kv_cache_manager.coordinator.single_type_managers: + assert request.request_id not in manager.req_to_blocks + + # Outputs of both in-flight steps are processed: blocks return to the + # pool only after the newest one. + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + assert len(scheduler.deferred_frees) == 1 + scheduler.update_from_output(out1, _make_model_runner_output(out1)) + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_multiple_deferred_frees_drain_in_order(): + scheduler = _create_deferring_scheduler() + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + requests = create_requests( + num_requests=2, + num_tokens=NUM_PROMPT_TOKENS, + max_tokens=5, + stop_token_ids=[STOP_TOKEN_ID], + ) + for request in requests: + scheduler.add_request(request) + out0 = scheduler.schedule() + out1 = scheduler.schedule() + + # Both requests stop early at step 1's output while step 2 is in + # flight: two deferred entries with the same fence. + scheduler.update_from_output( + out0, _make_model_runner_output(out0, token_id=STOP_TOKEN_ID) + ) + assert len(scheduler.deferred_frees) == 2 + assert pool.get_num_free_blocks() < num_free_initially + + scheduler.update_from_output(out1, _make_model_runner_output(out1)) + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_fence_held_across_multiple_inflight_steps(): + """Pipeline-parallel / deep async: with several steps scheduled ahead, + a freed request's blocks must stay held until the *newest* in-flight + step's output is processed, not the first. + + Depth-1 tests only check a single intervening update; with PP the + scheduler can dispatch up to pp_size steps ahead, so the fence must + survive multiple intervening update_from_output calls. + """ + scheduler = _create_deferring_scheduler() + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request = create_requests( + num_requests=1, + num_tokens=NUM_PROMPT_TOKENS, + max_tokens=10, + )[0] + scheduler.add_request(request) + + # Schedule three steps ahead without processing any output: a prefill + # plus two speculatively over-scheduled decodes, all in flight at once. + outs = [scheduler.schedule() for _ in range(3)] + assert outs[0].num_scheduled_tokens[request.request_id] == NUM_PROMPT_TOKENS + assert outs[1].num_scheduled_tokens[request.request_id] == 1 + assert outs[2].num_scheduled_tokens[request.request_id] == 1 + assert scheduler.sched_step_seq == 3 + num_free_running = pool.get_num_free_blocks() + assert num_free_running < num_free_initially + + # Abort while all three steps are in flight: the fence is the newest + # scheduled step (3), since any of them may still write the blocks. + scheduler.finish_requests(request.request_id, RequestStatus.FINISHED_ABORTED) + assert len(scheduler.deferred_frees) == 1 + assert scheduler.deferred_frees[0][0] == 3 + assert pool.get_num_free_blocks() == num_free_running + + # Draining the two earlier in-flight steps must NOT release the blocks: + # their outputs don't fence the still-pending newest write. + for out in (outs[0], outs[1]): + scheduler.update_from_output(out, _make_model_runner_output(out)) + assert len(scheduler.deferred_frees) == 1 + assert pool.get_num_free_blocks() == num_free_running + + # Only once the newest scheduled step's output is processed do the + # blocks return to the pool. + scheduler.update_from_output(outs[2], _make_model_runner_output(outs[2])) + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_max_tokens_finish_frees_immediately_with_other_inflight(): + """A request finishing by reaching max_tokens is never over-scheduled past + its final-token step, so no in-flight step writes its blocks: it is freed + immediately even while another request's step is still in flight. + """ + scheduler = _create_deferring_scheduler() + pool = scheduler.kv_cache_manager.block_pool + + # Short request finishes at max_tokens=1; long request keeps running. + short = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, max_tokens=1, req_ids=["short"] + )[0] + long = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, max_tokens=100, req_ids=["long"] + )[0] + scheduler.add_request(short) + scheduler.add_request(long) + + out0 = scheduler.schedule() # prefill both + out1 = scheduler.schedule() # short is skipped (at max_tokens); long decodes + assert "short" not in out1.num_scheduled_tokens + assert "long" in out1.num_scheduled_tokens + + free_before = pool.get_num_free_blocks() + # Process step 0: `short` reaches max_tokens and finishes while step 1 + # (which scheduled `long`, not `short`) is still in flight. + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + + assert short.is_finished() + # A step IS globally in flight (the old global fence would have deferred), + # but the per-request gate frees `short` immediately since nothing writes + # its blocks anymore. + assert scheduler.sched_step_seq > scheduler.processed_step_seq + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() > free_before # short's blocks returned + + +def test_abort_mid_prefill_defers_free(): + """Intermediate prefill chunks don't allocate output placeholders, so the + deferral must key off is_prefill_chunk: aborting a request whose prefill + chunk is still in flight must withhold its blocks. + """ + scheduler = create_scheduler( + model=MODEL, async_scheduling=True, long_prefill_token_threshold=16 + ) + scheduler.defer_block_free = True + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, max_tokens=5 + )[0] + scheduler.add_request(request) + + out0 = scheduler.schedule() + # Partial prefill: a chunk is in flight, with no output placeholders yet. + assert out0.num_scheduled_tokens[request.request_id] == 16 + assert request.num_output_placeholders == 0 + assert request.is_prefill_chunk + num_free_running = pool.get_num_free_blocks() + assert num_free_running < num_free_initially + + # Abort while the prefill chunk is in flight: blocks must be withheld + # (keyed off is_prefill_chunk, since there are no placeholders). + scheduler.finish_requests(request.request_id, RequestStatus.FINISHED_ABORTED) + assert len(scheduler.deferred_frees) == 1 + assert pool.get_num_free_blocks() == num_free_running + + # Once the in-flight prefill step's output is processed, blocks return. + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially + + +def test_non_async_abort_defers_via_last_sched_seq(): + """Without async (e.g. PP filling the pipeline) there are no placeholders + and a full prefill isn't a partial chunk, yet an abort with a step in flight + must defer. Only the last-scheduled-step fence catches this. + + PP=2 can't be built on a single-GPU host, so force the flag and exercise the + mechanism; the gate itself is covered by test_gate_enabled_for_async_consumer. + """ + scheduler = create_scheduler(model=MODEL, async_scheduling=False) + scheduler.defer_block_free = True + pool = scheduler.kv_cache_manager.block_pool + num_free_initially = pool.get_num_free_blocks() + + request = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, max_tokens=5 + )[0] + scheduler.add_request(request) + + out0 = scheduler.schedule() + # Neither async-only signal marks this request as in flight. + assert request.num_output_placeholders == 0 + assert not request.is_prefill_chunk + # Only the last-scheduled-step fence does. + assert request.last_sched_seq > scheduler.processed_step_seq + num_free_running = pool.get_num_free_blocks() + assert num_free_running < num_free_initially + + # Abort while out0 is in flight: blocks must be withheld. + scheduler.finish_requests(request.request_id, RequestStatus.FINISHED_ABORTED) + assert len(scheduler.deferred_frees) == 1 + assert pool.get_num_free_blocks() == num_free_running + + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + assert not scheduler.deferred_frees + assert pool.get_num_free_blocks() == num_free_initially diff --git a/tests/v1/core/test_kv_cache_utils.py b/tests/v1/core/test_kv_cache_utils.py index 68ad7bc42ef0..947672c48af6 100644 --- a/tests/v1/core/test_kv_cache_utils.py +++ b/tests/v1/core/test_kv_cache_utils.py @@ -28,6 +28,7 @@ estimate_max_model_len, generate_block_hash_extra_keys, generate_scheduler_kv_cache_config, + get_kv_cache_capacity, get_kv_cache_configs, get_max_concurrency_for_kv_cache_config, get_request_block_hasher, @@ -116,6 +117,7 @@ def new_kv_cache_spec( page_size_padded=None, sliding_window=None, attention_chunk_size=None, + indexes_kv_by_block_stride=False, ): return FullAttentionSpec( block_size=block_size, @@ -125,6 +127,7 @@ def new_kv_cache_spec( page_size_padded=page_size_padded, sliding_window=sliding_window, attention_chunk_size=attention_chunk_size, + indexes_kv_by_block_stride=indexes_kv_by_block_stride, ) @@ -135,6 +138,7 @@ def new_sliding_window_spec( dtype=torch.float32, page_size_padded=None, sliding_window=1, + indexes_kv_by_block_stride=False, ): return SlidingWindowSpec( block_size=block_size, @@ -143,6 +147,7 @@ def new_sliding_window_spec( dtype=dtype, page_size_padded=page_size_padded, sliding_window=sliding_window, + indexes_kv_by_block_stride=indexes_kv_by_block_stride, ) @@ -220,7 +225,7 @@ def test_kv_cache_block(): # Test block hash setting and resetting block_hash = make_block_hash_with_group_id(BlockHash(b"abc"), 0) - block.block_hash = block_hash + block.set_block_hash(block_hash) assert block.block_hash == block_hash block.reset_hash() @@ -358,6 +363,43 @@ def test_free_kv_cache_block_queue_append_n(): ) +def test_free_kv_cache_block_queue_prepend_n(): + # Seed the queue with one block so prepend has an existing head to splice + # in front of (fake_head->b0->fake_tail). + blocks = [KVCacheBlock(block_id=i) for i in range(6)] + queue = FreeKVCacheBlockQueue(blocks[0:1]) + + # Prepend 0 blocks is a no-op. + queue.prepend_n([]) + assert queue.num_free_blocks == 1 + assert queue.fake_free_list_head.next_free_block is blocks[0] + + # Prepend 2 blocks; they land in front of the existing head, in order. + # fake_head->b4->b5->b0->fake_tail + queue.prepend_n(blocks[4:6]) + assert queue.num_free_blocks == 3 + assert queue.fake_free_list_head.next_free_block is blocks[4] + assert blocks[4].prev_free_block is queue.fake_free_list_head + assert blocks[4].next_free_block is blocks[5] + assert blocks[5].prev_free_block is blocks[4] + assert blocks[5].next_free_block is blocks[0] + assert blocks[0].prev_free_block is blocks[5] + assert blocks[0].next_free_block is queue.fake_free_list_tail + assert queue.fake_free_list_tail.prev_free_block is blocks[0] + + # A second prepend goes ahead of everything previously prepended. + # fake_head->b1->b2->b4->b5->b0->fake_tail + queue.prepend_n(blocks[1:3]) + assert queue.num_free_blocks == 5 + assert queue.fake_free_list_head.next_free_block is blocks[1] + assert blocks[1].next_free_block is blocks[2] + assert blocks[2].next_free_block is blocks[4] + + # The popleft order reflects the front-to-back queue order. + assert [queue.popleft().block_id for _ in range(5)] == [1, 2, 4, 5, 0] + assert queue.num_free_blocks == 0 + + def test_free_kv_cache_block_queue_popleft_n(): blocks = [KVCacheBlock(block_id=i) for i in range(6)] # Create an empty FreeKVCacheBlockQueue with these blocks @@ -1362,12 +1404,15 @@ def test_get_max_concurrency_for_kv_cache_config(): enable_chunked_prefill=True, max_model_len=model_config.max_model_len, is_encoder_decoder=model_config.is_encoder_decoder, + # Pin to sync: SWA per-request bounds grow with overlapping batches. + async_scheduling=False, ) vllm_config = VllmConfig( model_config=model_config, scheduler_config=scheduler_config, ) + assert vllm_config.max_concurrent_batches == 1 full_attention_spec = FullAttentionSpec( block_size=16, @@ -1422,6 +1467,11 @@ def test_get_max_concurrency_for_kv_cache_config(): vllm_config, kv_cache_config_hybrid_model ) assert max_concurrency_hybrid_model == 3 + num_tokens, max_concurrency = get_kv_cache_capacity( + vllm_config, kv_cache_config_hybrid_model + ) + assert num_tokens == max_concurrency_hybrid_model * max_model_len + assert max_concurrency == max_concurrency_hybrid_model def test_allocate_with_lookahead(): @@ -1756,16 +1806,38 @@ def test_get_kv_cache_config_one_worker(): ], ) - # different hidden size that cannot be aligned by using different block size + # different hidden size that cannot be aligned by using different block size, + # but can be aligned by padding the smaller physical page. + swa_spec = new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True) kv_cache_specs_hybrid = { - "layer_1": new_kv_cache_spec(head_size=64), - "layer_2": new_sliding_window_spec(head_size=96), + "layer_1": new_kv_cache_spec(head_size=64, indexes_kv_by_block_stride=True), + "layer_2": swa_spec, } - with pytest.raises(NotImplementedError): - get_kv_cache_configs( - vllm_config, [kv_cache_specs_hybrid], [mem_per_block_per_layer * 2 * 32] - )[0] + kv_cache_config_hybrid = get_kv_cache_configs( + vllm_config, [kv_cache_specs_hybrid], [mem_per_block_per_layer * 2 * 32] + )[0] + padded_page_size = swa_spec.page_size_bytes + assert kv_cache_config_hybrid == KVCacheConfig( + num_blocks=42, + kv_cache_tensors=[ + KVCacheTensor(size=padded_page_size * 42, shared_by=["layer_1", "layer_2"]), + ], + kv_cache_groups=[ + KVCacheGroupSpec( + ["layer_1"], + new_kv_cache_spec( + head_size=64, + page_size_padded=padded_page_size, + indexes_kv_by_block_stride=True, + ), + ), + KVCacheGroupSpec( + ["layer_2"], + new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True), + ), + ], + ) # Test num_gpu_blocks_override vllm_config.cache_config.num_gpu_blocks_override = 16 @@ -2279,6 +2351,75 @@ def test_check_enough_kv_cache_memory_respects_num_gpu_blocks_override(): get_kv_cache_configs(vllm_config, [kv_cache_specs], [large_available_memory]) +def test_unify_kv_cache_page_size_uses_padding_for_non_divisible_sizes(): + """DFlash drafters can have a smaller head size than the target model. + + For example, MiMo uses 192-dim target KV heads while its DFlash draft uses + 128-dim KV heads. The resulting page sizes are 3:2 rather than an integer + block-size multiple, so the smaller page must be padded instead. + """ + # Both layers' backends opt into the padded-page strided view (e.g. + # FlashAttention / its DiffKV subclass), so padding is allowed. + target_spec = new_kv_cache_spec( + block_size=16, + num_kv_heads=1, + head_size=192, + dtype=torch.bfloat16, + indexes_kv_by_block_stride=True, + ) + draft_spec = new_sliding_window_spec( + block_size=16, + num_kv_heads=1, + head_size=128, + dtype=torch.bfloat16, + sliding_window=1024, + indexes_kv_by_block_stride=True, + ) + + unified_specs = kv_cache_utils.unify_kv_cache_spec_page_size( + { + "target_attn": target_spec, + "draft_attn": draft_spec, + } + ) + + assert unified_specs["target_attn"] == target_spec + unified_draft_spec = unified_specs["draft_attn"] + assert unified_draft_spec.block_size == draft_spec.block_size + assert unified_draft_spec.real_page_size_bytes == draft_spec.real_page_size_bytes + assert unified_draft_spec.page_size_padded == target_spec.page_size_bytes + assert unified_draft_spec.page_size_bytes == target_spec.page_size_bytes + + +def test_unify_kv_cache_page_size_padding_requires_backend_support(): + """Padding is gated on the backend declaring ``indexes_kv_by_block_stride``. + + A backend that does not support the strided padded-page view must raise + rather than silently padding (and misreading KV at runtime). + """ + target_spec = new_kv_cache_spec( + block_size=16, + num_kv_heads=1, + head_size=192, + dtype=torch.bfloat16, + indexes_kv_by_block_stride=True, + ) + # The non-divisible draft layer needs padding but its backend does not + # support the strided padded-page view -> must raise, not silently pad. + draft_spec = new_sliding_window_spec( + block_size=16, + num_kv_heads=1, + head_size=128, + dtype=torch.bfloat16, + sliding_window=1024, + indexes_kv_by_block_stride=False, + ) + specs = {"target_attn": target_spec, "draft_attn": draft_spec} + + with pytest.raises(NotImplementedError): + kv_cache_utils.unify_kv_cache_spec_page_size(specs) + + def test_unify_hybrid_kv_cache_specs(): # 1. has_full_attention and has_sliding_window before_spec_1 = new_kv_cache_spec() @@ -2346,6 +2487,91 @@ def test_unify_hybrid_kv_cache_specs(): kv_cache_utils.unify_hybrid_kv_cache_specs(kv_cache_spec) +def test_unify_kv_cache_spec_page_size_mamba(): + """Regression test for https://github.com/vllm-project/vllm/issues/43626. + + MambaSpec's page_size_bytes is determined by its state shapes and does not + change with block_size, so unify_kv_cache_spec_page_size must pad the Mamba + page instead of scaling its block_size. This situation arises when a layer + with a page larger than the (already platform-aligned) Mamba page joins the + specs, e.g. a dense draft model with more KV heads than the hybrid main + model. + """ + # 1. Hybrid main model (Mamba + full attention, pages already aligned at + # 16KB) plus a dense draft model layer with a 2x larger page (32KB). + # Reproduces the bare AssertionError from #43626: 32768 % 16384 == 0, so + # the old code scaled the Mamba block_size, which left page_size_bytes + # unchanged at 16384. + mamba_spec = new_mamba_spec() # page_size_bytes = 16384 + main_attn_spec = new_kv_cache_spec() # page_size_bytes = 16384 + draft_attn_spec = new_kv_cache_spec(num_kv_heads=4) # page_size_bytes = 32768 + assert mamba_spec.page_size_bytes == main_attn_spec.page_size_bytes == 16384 + assert draft_attn_spec.page_size_bytes == 32768 + + unified = kv_cache_utils.unify_kv_cache_spec_page_size( + { + "mamba_layer": mamba_spec, + "main_attn_layer": main_attn_spec, + "draft_attn_layer": draft_attn_spec, + } + ) + # Mamba page is padded; block_size (caching granularity) is unchanged. + assert unified["mamba_layer"].page_size_bytes == 32768 + assert unified["mamba_layer"].page_size_padded == 32768 + assert unified["mamba_layer"].block_size == mamba_spec.block_size + # Attention layer with smaller page still unifies by scaling block_size. + assert unified["main_attn_layer"].page_size_bytes == 32768 + assert unified["main_attn_layer"].block_size == 2 * main_attn_spec.block_size + assert unified["main_attn_layer"].page_size_padded is None + # Layer already at max page size is unchanged. + assert unified["draft_attn_layer"] == draft_attn_spec + + # 2. Mamba page already padded by the platform (state smaller than the + # padded page); the padding is re-applied at the new maximum. + padded_mamba_spec = new_mamba_spec( + shapes=((2, 256), (3, 32, 32)), page_size_padded=16384 + ) + assert padded_mamba_spec.page_size_bytes == 16384 + unified = kv_cache_utils.unify_kv_cache_spec_page_size( + { + "mamba_layer": padded_mamba_spec, + "draft_attn_layer": draft_attn_spec, + } + ) + assert unified["mamba_layer"].page_size_bytes == 32768 + assert unified["mamba_layer"].page_size_padded == 32768 + + # 3. Mamba page that does not evenly divide the maximum page size is + # padded as well (the divisibility constraint only applies to block_size + # scaling). + odd_mamba_spec = new_mamba_spec(shapes=((6144,),)) + assert odd_mamba_spec.page_size_bytes == 24576 + assert 32768 % odd_mamba_spec.page_size_bytes != 0 + unified = kv_cache_utils.unify_kv_cache_spec_page_size( + { + "mamba_layer": odd_mamba_spec, + "draft_attn_layer": draft_attn_spec, + } + ) + assert unified["mamba_layer"].page_size_bytes == 32768 + + # 4. Attention layers with non-divisible page sizes still raise. + with pytest.raises(NotImplementedError): + kv_cache_utils.unify_kv_cache_spec_page_size( + { + "attn_layer": new_kv_cache_spec(block_size=24), # 24576 + "draft_attn_layer": draft_attn_spec, # 32768 + } + ) + + # 5. Uniform page sizes are returned unchanged. + specs = { + "mamba_layer": new_mamba_spec(), + "attn_layer": new_kv_cache_spec(), + } + assert kv_cache_utils.unify_kv_cache_spec_page_size(specs) == specs + + def test_hma_not_disabled_when_kv_events_enabled(): """ Test enabling KV events must not force disable_hybrid_kv_cache_manager to True. diff --git a/tests/v1/core/test_prefix_caching.py b/tests/v1/core/test_prefix_caching.py index 91c5f37b4179..59260a499ef4 100644 --- a/tests/v1/core/test_prefix_caching.py +++ b/tests/v1/core/test_prefix_caching.py @@ -5,6 +5,7 @@ import copy from collections.abc import Callable from math import lcm +from types import SimpleNamespace import pytest import torch @@ -21,7 +22,7 @@ from vllm.sampling_params import SamplingParams from vllm.utils.hashing import sha256, sha256_cbor from vllm.v1.core.block_pool import BlockHashToBlockMap, BlockPool -from vllm.v1.core.kv_cache_manager import KVCacheManager, Request +from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager, Request from vllm.v1.core.kv_cache_utils import ( BlockHash, BlockHashWithGroupId, @@ -33,12 +34,14 @@ init_none_hash, make_block_hash_with_group_id, ) +from vllm.v1.core.sched.scheduler import Scheduler from vllm.v1.kv_cache_interface import ( FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheSpecKind, MambaSpec, + MLAAttentionSpec, SlidingWindowSpec, ) @@ -288,13 +291,12 @@ def test_prefill(hash_fn): # All blocks should be available. assert free_block_queue.num_free_blocks == 10 # The order should be + # [partial without hashes from req1 and req0 (5, 4) - prepended for immediate reuse] # [unallocated (6, 7, 8, 9, 10)] - # [unique_req0 (4)] - # [unique_req1 (5)] # [common (3, 2, 1)] assert [ b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks() - ] == [6, 7, 8, 9, 10, 4, 5, 3, 2, 1] + ] == [5, 4, 6, 7, 8, 9, 10, 3, 2, 1] # Cache hit in the common prefix when the original block is already free. # Incomplete 1 block (6 tokens) @@ -308,7 +310,7 @@ def test_prefill(hash_fn): blocks = manager.allocate_slots( req2, num_new_tokens, len(computed_blocks.blocks[0]) * 16, computed_blocks ) - assert blocks is not None and blocks.get_block_ids() == ([6],) + assert blocks is not None and blocks.get_block_ids() == ([5],) # reuse partial [5] # Although we only have 6 free blocks, we have 8 blocks in # the free block queue due to lazy removal. @@ -328,7 +330,7 @@ def test_prefill(hash_fn): ) # This block ID order also checks the eviction order. assert blocks is not None and blocks.get_block_ids() == ( - [7, 8, 9, 10, 4, 5, 6, 3, 2, 1], + [5, 4, 6, 7, 8, 9, 10, 3, 2, 1], ) assert free_block_queue.num_free_blocks == 0 @@ -1022,6 +1024,118 @@ def test_prefill_hybrid_model_mamba_align(): manager.free(req0) +def test_hybrid_cache_mamba_align_shared_prefix_detection(): + """Test shared prefix detection heuristic for mamba align cache mode + + HybridKVCacheCoordinator returns num_uncached_common > 0 when a shared + uncached prefix is detected. With mamba_align cache, _mamba_block_aligned_split + enforces scheduling aligned with the common prefix. + """ + block_size = 16 + manager = make_kv_cache_manager( + _make_hybrid_kv_cache_config(block_size, 30, ["full", "mamba_align"]), + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + ) + hash_fn = sha256 + + # Request: 3 blocks + prefix = [i for i in range(3) for _ in range(block_size)] + req_0 = make_request("0", prefix, block_size, hash_fn) + computed_blocks, num_computed = manager.get_computed_blocks(req_0) + num_uncached_common = manager.coordinator.num_uncached_common_prefix_tokens + assert num_computed == 0 # nothing cached yet + assert num_uncached_common == 0 + manager.allocate_slots(req_0, 3 * block_size, 0, computed_blocks) + + # Request: 3 blocks (shared with above) + 7 different tokens + req_1 = make_request("1", prefix + [100] * 7, block_size, hash_fn) + computed_blocks, num_computed = manager.get_computed_blocks(req_1) + num_uncached_common = manager.coordinator.num_uncached_common_prefix_tokens + assert num_computed == 3 * block_size # we should observe a 3-block cache hit + assert num_uncached_common == 0 + manager.allocate_slots(req_1, 7, 3 * block_size, computed_blocks) + + # Request: 3 blocks, but only 2 blocks shared (replace the last token in 3rd block): + req_2 = make_request("2", prefix[:-1] + [101], block_size, hash_fn) + computed_blocks, num_computed = manager.get_computed_blocks(req_2) + num_uncached_common = manager.coordinator.num_uncached_common_prefix_tokens + assert num_computed == 0 # mamba_align doesn't cache intermediate blocks + assert num_uncached_common == 2 * block_size # heuristic detects a shared prefix + + # Next, validate scheduler logic for num_uncached_common_prefix_tokens > 0 + # Create minimal mock with just the needed attributes + mock = SimpleNamespace( + cache_config=SimpleNamespace(block_size=block_size), use_eagle=False + ) + num_new_tokens_adjusted = Scheduler._mamba_block_aligned_split( + self=mock, + request=req_2, + num_new_tokens=3 * block_size, + num_uncached_common_prefix_tokens=num_uncached_common, + ) + assert num_new_tokens_adjusted == 2 * block_size # adjust to the common prefix + + manager.allocate_slots(req_2, 3 * block_size, 0, computed_blocks) + # Cleanup + manager.free(req_0) + manager.free(req_1) + manager.free(req_2) + + +def test_hybrid_model_mamba_align_with_dynamic_draft_tokens(): + """Regression test for https://github.com/vllm-project/vllm/issues/39271. + + With suffix decoding enabled, the number of proposed draft token may + change dynamically each round, causing the MambaManager to crash during + allocate_slots() as it originally assumes the `num_blocks` to increase. + """ + block_size = 16 + num_blocks = 30 + + kv_cache_config = _make_hybrid_kv_cache_config( + block_size, num_blocks, ["full", "mamba_align"] + ) + manager = KVCacheManager( + kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + scheduler_block_size=block_size, + ) + + # the default hash function is sha256 + hash_fn = sha256 + + all_token_ids = [i for i in range(3) for _ in range(block_size)] + [3] * 7 + req0 = make_request("0", all_token_ids, block_size, hash_fn) + computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0) + assert num_computed_tokens == 0 + blocks = manager.allocate_slots( + req0, len(all_token_ids), num_computed_tokens, computed_blocks + ) + assert blocks is not None + + # prefill forward finished + req0.append_output_token_ids([1]) + req0.num_computed_tokens = len(all_token_ids) + + # Round1: propose 16 draft tokens, accept only one + req0.spec_token_ids = [4] * 16 + blocks = manager.allocate_slots(req0, num_new_tokens=16, num_new_computed_tokens=0) + assert blocks is not None + req0.append_output_token_ids([4]) + req0.num_computed_tokens += 1 + + # Round2: propose only one token, allocate should not crash + req0.spec_token_ids = [5] * 1 + blocks = manager.allocate_slots(req0, num_new_tokens=1, num_new_computed_tokens=0) + assert blocks is not None and all(len(group) == 0 for group in blocks.blocks) + + manager.free(req0) + + def test_prefill_plp(): """Test prefill with APC and some prompt logprobs (plp) requests. @@ -1101,13 +1215,12 @@ def test_prefill_plp(): # All blocks should be available. assert manager.block_pool.free_block_queue.num_free_blocks == 10 # The order should be + # [partial without hashes from req1 and req0 (5, 4) - prepended for immediate reuse] # [unallocated (6, 7, 8, 9, 10)] - # [unique_req0 (4)] - # [unique_req1 (5)] # [common (3, 2, 1)] assert [ b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks() - ] == [6, 7, 8, 9, 10, 4, 5, 3, 2, 1] + ] == [5, 4, 6, 7, 8, 9, 10, 3, 2, 1] # Request #2 is a prompt-logprobs request: # NO cache hit in the common prefix; duplicates request #0 cached blocks @@ -1236,11 +1349,15 @@ def test_evict(): assert manager.block_pool.free_block_queue.num_free_blocks == 1 manager.free(req0) + # partial blocks (without hash) at head, other at tail (LRU policy): + assert [ + b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks() + ] == [6, 10, 5, 4, 3, 2, 1] manager.free(req1) assert manager.block_pool.free_block_queue.num_free_blocks == 10 assert [ b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks() - ] == [10, 6, 5, 4, 3, 2, 1, 9, 8, 7] + ] == [6, 10, 5, 4, 3, 2, 1, 9, 8, 7] # Touch the first 2 blocks. req2 = make_request("2", list(range(2 * 16 + 3)), block_size, sha256) @@ -1250,7 +1367,7 @@ def test_evict(): blocks = manager.allocate_slots( req2, 3, len(computed_blocks.blocks[0]) * 16, computed_blocks ) - assert blocks is not None and blocks.get_block_ids() == ([10],) + assert blocks is not None and blocks.get_block_ids() == ([6],) assert manager.block_pool.free_block_queue.num_free_blocks == 7 @@ -1886,7 +2003,7 @@ def test_maybe_evict_cached_block(): assert len(pool.blocks) == len(block_hashes) # Manually add all blocks to cached_blocks for block, block_hash in zip(pool.blocks, block_hashes): - block.block_hash = block_hash + block.set_block_hash(block_hash) pool.cached_block_hash_to_block.insert(block_hash, block) block0, block1, block2, block3 = pool.blocks @@ -2875,6 +2992,350 @@ def test_hybrid_cache_blocks_clamped_to_lcm(): ) +def test_hybrid_local_kv_retention_interval_aligns_in_manager(monkeypatch): + """Verify fixed intervals retain sparse tails plus the latest replay tail.""" + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", "64") + block_size = 8 + kv_cache_config = KVCacheConfig( + num_blocks=100, + kv_cache_tensors=[], + kv_cache_groups=[ + KVCacheGroupSpec( + ["layer1"], + FullAttentionSpec( + block_size=4 * block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float16, + ), + ), + KVCacheGroupSpec( + ["layer2"], + SlidingWindowSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=block_size, + ), + ), + ], + ) + manager = make_kv_cache_manager( + kv_cache_config=kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + ) + + # The SWA manager uses the configured 64-token interval (a multiple of the + # 32-token lcm_block_size) as its retention segment. For this 128-token + # prompt, the retained SWA tails are the 64-token interval boundary, the + # 96-token replay boundary, and the 128-token interval boundary. + token_ids = [i for i in range(16) for _ in range(block_size)] + req = make_request("0", token_ids, block_size, sha256) + computed_blocks, _ = manager.get_computed_blocks(req) + blocks = manager.allocate_slots( + req, + len(token_ids), + len(computed_blocks.blocks[0]) * block_size, + computed_blocks, + ) + assert blocks is not None + + pool = manager.block_pool + expected_swa_cached = {7, 11, 15} + for i in range(16): + cached = pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[1]) + if i in expected_swa_cached: + assert cached is not None, f"SWA hash {i} should be cached" + else: + assert cached is None, f"SWA hash {i} should not be cached" + + +@pytest.mark.parametrize( + "interval, expected_match", + [ + # scheduler_block_size is 32 (= lcm(4*8, 8)); 33 is not a multiple of it. + ("33", "multiple of scheduler_block_size"), + # A negative multiple (-32 % 32 == 0) must still be rejected explicitly, + # otherwise it would pass the modulo check and silently degrade to dense. + ("-32", "non-negative"), + ], +) +def test_hybrid_local_kv_retention_interval_rejects_invalid( + monkeypatch, interval, expected_match +): + """A retention interval that is negative or not a multiple of + scheduler_block_size errors out at construction time.""" + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", interval) + block_size = 8 + kv_cache_config = KVCacheConfig( + num_blocks=100, + kv_cache_tensors=[], + kv_cache_groups=[ + KVCacheGroupSpec( + ["layer1"], + FullAttentionSpec( + block_size=4 * block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float16, + ), + ), + KVCacheGroupSpec( + ["layer2"], + SlidingWindowSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=block_size, + ), + ), + ], + ) + with pytest.raises(ValueError, match=expected_match): + make_kv_cache_manager( + kv_cache_config=kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + ) + + +def test_hybrid_local_kv_retention_interval_survives_recycling(monkeypatch): + """Verify retained local checkpoints are reused after block recycling.""" + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", "1024") + hash_block_size = 4 + kv_cache_config = KVCacheConfig( + num_blocks=800, + kv_cache_tensors=[], + kv_cache_groups=[ + KVCacheGroupSpec( + ["full"], + MLAAttentionSpec( + block_size=64 * hash_block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.uint8, + compress_ratio=4, + ), + ), + KVCacheGroupSpec( + ["swa"], + SlidingWindowSpec( + block_size=16 * hash_block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=512, + ), + ), + KVCacheGroupSpec( + ["c128"], + SlidingWindowSpec( + block_size=2 * hash_block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=128, + ), + ), + KVCacheGroupSpec( + ["c4"], + SlidingWindowSpec( + block_size=hash_block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=8, + ), + ), + ], + ) + manager = make_kv_cache_manager( + kv_cache_config=kv_cache_config, + max_model_len=4096, + enable_caching=True, + hash_block_size=hash_block_size, + ) + + def fill_request(request_id: str, token_offset: int) -> list[int]: + token_ids = [ + token_offset + i for i in range(1024) for _ in range(hash_block_size) + ] + fill_req = make_request(request_id, token_ids, hash_block_size, sha256) + while fill_req.num_computed_tokens < len(token_ids): + num_new_tokens = min(512, len(token_ids) - fill_req.num_computed_tokens) + blocks = manager.allocate_slots(fill_req, num_new_tokens) + assert blocks is not None + fill_req.num_computed_tokens += num_new_tokens + manager.free(fill_req) + return token_ids + + token_ids = fill_request("fill_0", 0) + replay_req = make_request("replay", token_ids[:1800], hash_block_size, sha256) + computed_blocks, num_computed_tokens = manager.get_computed_blocks(replay_req) + assert num_computed_tokens == 1024 + assert [len(blocks) for blocks in computed_blocks.blocks] == [4, 16, 128, 256] + + fill_request("fill_1", 100_000) + replay_req = make_request("replay_again", token_ids[:1800], hash_block_size, sha256) + computed_blocks, num_computed_tokens = manager.get_computed_blocks(replay_req) + assert num_computed_tokens == 1024 + assert [len(blocks) for blocks in computed_blocks.blocks] == [4, 16, 128, 256] + + +def test_hybrid_local_kv_retention_latest_only_reuses_replay_boundary(monkeypatch): + """Verify latest-only retention reuses only the replayable prompt boundary.""" + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", "0") + block_size = 8 + kv_cache_config = KVCacheConfig( + num_blocks=100, + kv_cache_tensors=[], + kv_cache_groups=[ + KVCacheGroupSpec( + ["layer1"], + FullAttentionSpec( + block_size=4 * block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float16, + ), + ), + KVCacheGroupSpec( + ["layer2"], + SlidingWindowSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=block_size, + ), + ), + ], + ) + manager = make_kv_cache_manager( + kv_cache_config=kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + ) + + token_ids = [i for i in range(16) for _ in range(block_size)] + req0 = make_request("0", token_ids, block_size, sha256) + computed_blocks, _ = manager.get_computed_blocks(req0) + blocks = manager.allocate_slots( + req0, + len(token_ids), + len(computed_blocks.blocks[0]) * block_size, + computed_blocks, + ) + assert blocks is not None + + pool = manager.block_pool + expected_swa_cached = {11} + for i in range(16): + cached = pool.get_cached_block(req0.block_hashes[i], kv_cache_group_ids=[1]) + if i in expected_swa_cached: + assert cached is not None, f"SWA hash {i} should be cached" + else: + assert cached is None, f"SWA hash {i} should not be cached" + + manager.free(req0) + retained_swa_block = pool.get_cached_block(req0.block_hashes[11], [1]) + assert retained_swa_block is not None + assert retained_swa_block[0].ref_cnt == 0 + + req1 = make_request("1", token_ids, block_size, sha256) + computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1) + # Full prompt hits intentionally recompute the final block for logits, so + # the longest usable hit is the previous LCM boundary: 96 tokens. + assert num_computed_tokens == 12 * block_size + assert len(computed_blocks.blocks[1]) == 12 + + shorter_req = make_request("2", token_ids[: 12 * block_size], block_size, sha256) + computed_blocks, num_computed_tokens = manager.get_computed_blocks(shorter_req) + assert num_computed_tokens == 0 + assert len(computed_blocks.blocks[1]) == 0 + + +def test_hybrid_local_kv_retention_mtp_reuses_latest_boundary(monkeypatch): + """Verify MTP/EAGLE SWA retention keeps the extra proof block. + + EAGLE/MTP lookup matches one additional local block after the returned + prefix and then drops it. Sparse retention must therefore cache the normal + local tail at the latest replay boundary plus one extra SWA block. + """ + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", "0") + block_size = 8 + kv_cache_config = KVCacheConfig( + num_blocks=100, + kv_cache_tensors=[], + kv_cache_groups=[ + KVCacheGroupSpec( + ["full"], + FullAttentionSpec( + block_size=4 * block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float16, + ), + ), + KVCacheGroupSpec( + ["swa_mtp"], + SlidingWindowSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=block_size, + ), + is_eagle_group=True, + ), + ], + ) + manager = make_kv_cache_manager( + kv_cache_config=kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + use_eagle=True, + ) + + # 127 tokens: latest replay boundary is floor((127 - 1) / 32) * 32 = 96. + # The EAGLE/MTP SWA lookup group must cache the local tail ending at + # 104 tokens, and that tail is two 8-token blocks wide: hashes 11 and 12. + token_ids = [i for i in range(15) for _ in range(block_size)] + [15] * 7 + req0 = make_request("0", token_ids, block_size, sha256) + computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0) + assert num_computed_tokens == 0 + blocks = manager.allocate_slots( + req0, + len(token_ids), + num_computed_tokens, + computed_blocks, + ) + assert blocks is not None + + pool = manager.block_pool + expected_swa_cached = {11, 12} + for i in range(15): + cached = pool.get_cached_block(req0.block_hashes[i], kv_cache_group_ids=[1]) + if i in expected_swa_cached: + assert cached is not None, f"SWA hash {i} should be cached" + else: + assert cached is None, f"SWA hash {i} should not be cached" + + manager.free(req0) + + req1 = make_request("1", token_ids, block_size, sha256) + computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1) + assert num_computed_tokens == 12 * block_size + assert [len(blocks) for blocks in computed_blocks.blocks] == [3, 12] + + def test_block_lookup_cache_single_block_per_key(): cache = BlockHashToBlockMap() key0 = BlockHashWithGroupId(b"hash0") @@ -2992,7 +3453,8 @@ def test_can_fit_full_sequence_swa_cap_admits_long_prompt(): manager = make_kv_cache_manager( config, max_model_len=max_model_len, - max_num_batched_tokens=max_num_batched_tokens, + # Single (sync) batch in flight, so in-flight tokens == batched tokens. + max_in_flight_tokens=max_num_batched_tokens, enable_caching=True, hash_block_size=block_size, ) @@ -3048,7 +3510,8 @@ def test_can_fit_full_sequence_full_attention_still_gates_oversized(): manager = make_kv_cache_manager( config, max_model_len=max_model_len, - max_num_batched_tokens=max_num_batched_tokens, + # Single (sync) batch in flight, so in-flight tokens == batched tokens. + max_in_flight_tokens=max_num_batched_tokens, enable_caching=True, hash_block_size=block_size, ) @@ -3058,3 +3521,427 @@ def test_can_fit_full_sequence_full_attention_still_gates_oversized(): req = make_request("oversized", list(range(prompt_len)), block_size, sha256) assert manager.allocate_slots(req, block_size, full_sequence_must_fit=True) is None + + +def test_cache_hit_local_and_external(): + # Regression test for #33775: when a request hits the local prefix cache + # in one KV cache group and needs external (connector) blocks in another, + # the external allocation of an earlier group must not evict the local + # cache-hit blocks of a later group. Otherwise the same physical block can + # be handed out twice, producing duplicate block IDs / ref_cnt corruption. + block_size = 16 + kv_cache_config = make_kv_cache_config_hybrid_model(block_size, 31, 100) + del kv_cache_config.kv_cache_groups[2:] + req_id = "test" + manager = make_kv_cache_manager( + kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + use_eagle=True, + ) + + top_blocks = [] + head = manager.block_pool.free_block_queue.fake_free_list_head + for _ in range(10): + top_blocks.append(head.next_free_block) + head = head.next_free_block + cache_hit = KVCacheBlocks((top_blocks[:5], top_blocks[5:])) + + manager.allocate_slots( + make_request(req_id, [0] * (8 * block_size), block_size, sha256), + 16, + 5 * block_size, + cache_hit, + 0, + 2 * block_size, + ) + + req_blocks = manager.get_blocks(req_id) + req_block_ids = req_blocks.get_block_ids() + all_block_ids = req_block_ids[0] + req_block_ids[1] + assert len(set(all_block_ids)) == len(all_block_ids), "Block IDs are not unique" + + +def _take_free_blocks(manager: KVCacheManager, num_blocks: int) -> list[KVCacheBlock]: + """Grab the first ``num_blocks`` blocks at the head of the free queue + without removing them. These ref_cnt==0 blocks stand in for evictable + cache-hit blocks left behind by a previous (e.g. preempted) request, and + sitting at the head guarantees a later group's external ``get_new_blocks`` + would contend for them on unpatched code (issue #33775).""" + blocks: list[KVCacheBlock] = [] + head = manager.block_pool.free_block_queue.fake_free_list_head + for _ in range(num_blocks): + head = head.next_free_block + blocks.append(head) + return blocks + + +def _assert_no_double_allocation(manager: KVCacheManager, req_id: str) -> None: + """No physical block may be handed out twice across groups, and every + block referenced by the request must have a live ref_cnt.""" + block_ids = manager.get_blocks(req_id).get_block_ids() + flat = [block_id for group in block_ids for block_id in group] + assert len(set(flat)) == len(flat), "Block IDs are not unique across groups" + null_id = manager.block_pool.null_block.block_id + for block_id in flat: + if block_id == null_id: + continue + assert manager.block_pool.blocks[block_id].ref_cnt >= 1, ( + f"block {block_id} referenced by the request has ref_cnt 0" + ) + + +def _two_phase_block_size(manager: KVCacheManager) -> int: + return manager.kv_cache_config.kv_cache_groups[0].kv_cache_spec.block_size + + +def _cross_group_cache_hit( + manager: KVCacheManager, + req_id: str, + num_groups: int, + local_blocks_per_group: int = 5, + num_external_blocks: int = 2, + num_new_blocks: int = 1, +) -> Request: + """Allocate ``req_id`` with a per-group local prefix hit plus external + (connector) computed tokens, driving the coordinator's two-phase path. + Returns the allocated request so callers can free it (e.g. to preempt).""" + block_size = _two_phase_block_size(manager) + hit_blocks = _take_free_blocks(manager, num_groups * local_blocks_per_group) + cache_hit = KVCacheBlocks( + tuple( + hit_blocks[i * local_blocks_per_group : (i + 1) * local_blocks_per_group] + for i in range(num_groups) + ) + ) + prompt_blocks = local_blocks_per_group + num_external_blocks + num_new_blocks + request = make_request( + req_id, [0] * (prompt_blocks * block_size), block_size, sha256 + ) + manager.allocate_slots( + request, + num_new_blocks * block_size, + local_blocks_per_group * block_size, + cache_hit, + 0, + num_external_blocks * block_size, + ) + return request + + +def _make_two_phase_manager(num_groups: int) -> KVCacheManager: + assert num_groups in (2, 3) + block_size = 16 + kv_cache_config = make_kv_cache_config_hybrid_model(block_size, 31, 100) + del kv_cache_config.kv_cache_groups[num_groups:] + return make_kv_cache_manager( + kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + use_eagle=True, + ) + + +def test_cache_hit_local_and_external_three_groups(): + # Scenario 1 (issue #33775): SWA + full attention with *three* KV cache + # groups (1 full + 2 sliding-window). A local prefix hit in some groups + # combined with external (connector) blocks in others must not let one + # group's external `get_new_blocks` evict another group's not-yet-touched + # cache-hit blocks, which would hand the same physical block out twice. + manager = _make_two_phase_manager(num_groups=3) + _cross_group_cache_hit(manager, "test", num_groups=3) + _assert_no_double_allocation(manager, "test") + + +def test_cache_hit_local_and_external_three_groups_preempt_and_reallocate(): + # Scenario 2: the same 3-group hybrid config, but the request is preempted + # (freed) and then reallocated. After the free, the coordinator must treat + # the request as new again so external blocks are re-allocated, and the + # two-phase ordering must still prevent cross-group double allocation when + # reallocating against the now-evictable cache-hit blocks. + manager = _make_two_phase_manager(num_groups=3) + + request = _cross_group_cache_hit(manager, "test", num_groups=3) + _assert_no_double_allocation(manager, "test") + + # Preempt: free the request; its blocks return to the pool (full ones stay + # cached/evictable) and the coordinator forgets it. + manager.free(request) + assert manager.get_blocks("test").get_block_ids() == ([], [], []) + + # Reallocate the same request id against fresh cache-hit blocks taken from + # the current free-queue head, mirroring a preempted request being + # scheduled again. Because the request is no longer known, the coordinator + # re-arms `is_new_request` and re-runs external allocation, which must still + # not double-allocate across groups. + _cross_group_cache_hit(manager, "test", num_groups=3) + _assert_no_double_allocation(manager, "test") + assert manager.get_blocks("test").get_block_ids() != ([], [], []) + + +def test_cache_hit_local_and_external_two_groups_preempt_and_reallocate(): + # Scenario 3: the minimal 2-group hybrid config (1 full + 1 sliding-window) + # exercised through the same preempt -> reallocate cycle as scenario 2. + manager = _make_two_phase_manager(num_groups=2) + + request = _cross_group_cache_hit(manager, "test", num_groups=2) + _assert_no_double_allocation(manager, "test") + + manager.free(request) + assert manager.get_blocks("test").get_block_ids() == ([], []) + + _cross_group_cache_hit(manager, "test", num_groups=2) + _assert_no_double_allocation(manager, "test") + assert manager.get_blocks("test").get_block_ids() != ([], []) + + +def test_swa_free_split_keeps_cached_tail_ahead_of_scratch(monkeypatch): + """Default path (no retention): freeing an SWA request must place its + uncached scratch blocks at the front of the free queue (recycled first) + and keep its cached checkpoint blocks at the back (retained for prefix + hits). This split is always-on, independent of the retention interval.""" + monkeypatch.delenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", raising=False) + block_size = 8 + kv_cache_config = KVCacheConfig( + num_blocks=100, + kv_cache_tensors=[], + kv_cache_groups=[ + KVCacheGroupSpec( + ["layer1"], + FullAttentionSpec( + block_size=4 * block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float16, + ), + ), + KVCacheGroupSpec( + ["layer2"], + SlidingWindowSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=block_size, + ), + ), + ], + ) + manager = make_kv_cache_manager( + kv_cache_config=kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + ) + + token_ids = [i for i in range(16) for _ in range(block_size)] + req = make_request("0", token_ids, block_size, sha256) + computed_blocks, _ = manager.get_computed_blocks(req) + blocks = manager.allocate_slots( + req, + len(token_ids), + len(computed_blocks.blocks[0]) * block_size, + computed_blocks, + ) + assert blocks is not None + + swa_manager = manager.coordinator.single_type_managers[1] + null_block = manager.block_pool.null_block + cached_ids: set[int] = set() + uncached_ids: set[int] = set() + cached_hash_indices: list[int] = [] + for i, block in enumerate(swa_manager.req_to_blocks[req.request_id]): + if block is null_block: + continue + if block.block_hash is None: + uncached_ids.add(block.block_id) + else: + cached_ids.add(block.block_id) + cached_hash_indices.append(i) + # The dense default mask caches only the per-segment tails, so a 16-block + # SWA prompt must produce a mix of retained and scratch blocks. + assert cached_ids, "expected some retained (cached) SWA tail blocks" + assert uncached_ids, "expected some scratch (uncached) SWA blocks" + + manager.free(req) + + order = [ + b.block_id for b in manager.block_pool.free_block_queue.get_all_free_blocks() + ] + pos = {bid: i for i, bid in enumerate(order)} + # Every scratch block is recycled before every retained block. + assert max(pos[bid] for bid in uncached_ids) < min(pos[bid] for bid in cached_ids) + # The retained tails survive the free and still serve a prefix-cache hit. + for i in cached_hash_indices: + assert ( + manager.block_pool.get_cached_block( + req.block_hashes[i], kv_cache_group_ids=[1] + ) + is not None + ) + + +def _make_pure_swa_manager(block_size, sliding_window, num_blocks=100, **kwargs): + """Single sliding-window group (UnitaryKVCacheCoordinator).""" + kv_cache_config = KVCacheConfig( + num_blocks=num_blocks, + kv_cache_tensors=[], + kv_cache_groups=[ + KVCacheGroupSpec( + ["layer"], + SlidingWindowSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=sliding_window, + ), + ), + ], + ) + return make_kv_cache_manager( + kv_cache_config=kv_cache_config, + max_model_len=8192, + enable_caching=True, + hash_block_size=block_size, + **kwargs, + ) + + +def test_pure_swa_retention_interval_caches_sparse_tails(monkeypatch): + """Sparse retention must work for a pure-SWA single-group model, not just + hybrid models: only the per-interval tails plus the latest replay tail are + cached, and a replay still hits the latest replayable boundary.""" + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", "64") + block_size = 16 + manager = _make_pure_swa_manager(block_size, sliding_window=block_size) + assert type(manager.coordinator).__name__ == "UnitaryKVCacheCoordinator" + + token_ids = [i for i in range(16) for _ in range(block_size)] + req = make_request("0", token_ids, block_size, sha256) + computed_blocks, _ = manager.get_computed_blocks(req) + blocks = manager.allocate_slots( + req, + len(token_ids), + len(computed_blocks.blocks[0]) * block_size, + computed_blocks, + ) + assert blocks is not None + + pool = manager.block_pool + cached = { + i + for i in range(16) + if pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[0]) + is not None + } + # per_segment = 64 / 16 = 4, need = cdiv(16-1, 16) = 1 -> segment tails at + # i%4==3 -> {3,7,11,15}; latest replay boundary (255//16*16 = 240) -> tail + # block 14. Crucially this is a strict subset of all 16 blocks: retention + # is actually sparse for pure SWA (not silently dense). + assert cached == {3, 7, 11, 14, 15} + + # A replay of the same prompt hits the latest replayable boundary (240). + replay = make_request("1", token_ids, block_size, sha256) + _, num_computed = manager.get_computed_blocks(replay) + assert num_computed == 240 + + +def test_pure_swa_retention_latest_only(monkeypatch): + """`=0` on a pure-SWA model keeps only the latest replay tail.""" + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", "0") + block_size = 16 + manager = _make_pure_swa_manager(block_size, sliding_window=block_size) + + token_ids = [i for i in range(16) for _ in range(block_size)] + req = make_request("0", token_ids, block_size, sha256) + computed_blocks, _ = manager.get_computed_blocks(req) + blocks = manager.allocate_slots( + req, + len(token_ids), + len(computed_blocks.blocks[0]) * block_size, + computed_blocks, + ) + assert blocks is not None + + pool = manager.block_pool + cached = { + i + for i in range(16) + if pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[0]) + is not None + } + # No segment tails (interval 0); only the latest replay tail (block 14). + assert cached == {14} + + replay = make_request("1", token_ids, block_size, sha256) + _, num_computed = manager.get_computed_blocks(replay) + assert num_computed == 240 + + +def test_pure_swa_retention_dense_default_caches_all(monkeypatch): + """With retention unset, a pure-SWA model must keep the dense behavior: + every block boundary is a potential hit, so all blocks are cached.""" + monkeypatch.delenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", raising=False) + block_size = 16 + manager = _make_pure_swa_manager(block_size, sliding_window=block_size) + + token_ids = [i for i in range(16) for _ in range(block_size)] + req = make_request("0", token_ids, block_size, sha256) + computed_blocks, _ = manager.get_computed_blocks(req) + blocks = manager.allocate_slots( + req, + len(token_ids), + len(computed_blocks.blocks[0]) * block_size, + computed_blocks, + ) + assert blocks is not None + + pool = manager.block_pool + cached = { + i + for i in range(16) + if pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[0]) + is not None + } + assert cached == set(range(16)) + + +def test_mamba_reachable_block_mask_sparsifies_retention(): + """Mamba state-snapshot retention: with VLLM_PREFIX_CACHE_RETENTION_INTERVAL + the manager keeps one cached state per interval-sized segment (plus the + latest replay boundary) instead of a snapshot per block, which is what + lets a small attention block_size avoid Mamba dominating the KV pool.""" + from vllm.v1.core.single_type_kv_cache_manager import MambaManager + + block_size = 16 + spec = MambaSpec( + block_size=block_size, + shapes=(1, 1), + dtypes=(torch.float32,), + mamba_cache_mode="align", + ) + + def retained(retention_interval, num_prompt_tokens=256, end_block=16): + m = MambaManager.reachable_block_mask( + start_block=0, + end_block=end_block, + alignment_tokens=block_size, + kv_cache_spec=spec, + use_eagle=False, + retention_interval=retention_interval, + num_prompt_tokens=num_prompt_tokens, + ) + return None if m is None else {i for i, v in enumerate(m) if v} + + # Dense default (None) -> no mask, every block cached (unchanged behavior). + assert retained(None) is None + # interval == block_size -> every block is a boundary -> stays dense. + assert retained(block_size) is None + # interval 64 = 4 blocks: segment tails at i%4==3 -> {3,7,11,15}; latest + # replay boundary 240//16 - 1 = 14. Sparse subset of the 16 blocks. + assert retained(64) == {3, 7, 11, 14, 15} + # interval 0 -> only the latest replay boundary (block 14). + assert retained(0) == {14} diff --git a/tests/v1/core/test_scheduler.py b/tests/v1/core/test_scheduler.py index 7fa331747c40..900f8a9b06af 100644 --- a/tests/v1/core/test_scheduler.py +++ b/tests/v1/core/test_scheduler.py @@ -6,6 +6,7 @@ import pytest import torch +import vllm.envs as envs from vllm.config import ( CacheConfig, ECTransferConfig, @@ -15,6 +16,7 @@ SpeculativeConfig, VllmConfig, ) +from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats from vllm.multimodal.inputs import ( MultiModalFeatureSpec, MultiModalKwargsItem, @@ -143,6 +145,43 @@ def test_async_scheduling_pp_allows_rescheduling_with_output_placeholders(): assert req.request_id in output.num_scheduled_tokens +def test_cached_request_data_resumed_all_token_ids_mrv1_only(): + """all_token_ids carries a resumed request's token ids to the connector + for the V1 model runner, but is skipped entirely for the V2 model runner. + """ + from vllm.v1.core.kv_cache_manager import KVCacheBlocks + + scheduler = create_scheduler(use_v2_model_runner=False) + (req,) = create_requests(num_requests=1, num_tokens=8) + req.append_output_token_ids([101, 102, 103]) + + # A resumed request was not scheduled in the previous step. + assert req.request_id not in scheduler.prev_step_scheduled_req_ids + + empty_blocks = KVCacheBlocks(blocks=((),)) + + def make_cached(): + return scheduler._make_cached_request_data( + running_reqs=[], + resumed_reqs=[req], + num_scheduled_tokens={req.request_id: 1}, + spec_decode_tokens={}, + req_to_new_blocks={req.request_id: empty_blocks}, + ) + + # V1 model runner: the full token id list is propagated. + assert not scheduler.use_v2_model_runner + cached = make_cached() + assert req.request_id in cached.resumed_req_ids + assert cached.all_token_ids[req.request_id] == list(req.all_token_ids) + + # V2 model runner: all_token_ids is skipped entirely. + scheduler.use_v2_model_runner = True + cached = make_cached() + assert req.request_id in cached.resumed_req_ids + assert cached.all_token_ids == {} + + def test_schedule_partial_requests(): """Test scheduling behavior with partial requests. @@ -206,6 +245,210 @@ def test_schedule_partial_requests(): assert requests[2].request_id not in output.num_scheduled_tokens +@pytest.mark.parametrize("has_running", [True, False]) +def test_schedule_prefills_gating(has_running: bool): + """DP prefill-balancing gate: when `throttle_prefills` is True, a new + WAITING (prefill) request is deferred ONLY if this rank has running work to + protect. With no running requests, the prefill is admitted regardless (so a + throttled step is never wasted as a dummy), and running/decode requests are + unaffected. Once the cadence allows prefills again, the request is admitted. + """ + scheduler = create_scheduler(max_num_seqs=16, max_num_batched_tokens=8192) + + if has_running: + # Establish a running (decode) request via a prefill + output step. + (running_req,) = create_requests(num_requests=1, num_tokens=8, req_ids=["run0"]) + scheduler.add_request(running_req) + output = scheduler.schedule() + assert len(output.scheduled_new_reqs) == 1 + scheduler.update_from_output( + output, + ModelRunnerOutput( + req_ids=["run0"], + req_id_to_index={"run0": 0}, + sampled_token_ids=[[0]], + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ), + ) + assert len(scheduler.running) == 1 + + # Add a new WAITING (prefill) request, with prefills gated off. + (new_req,) = create_requests(num_requests=1, num_tokens=8, req_ids=["new0"]) + scheduler.add_request(new_req) + output = scheduler.schedule(throttle_prefills=True) + + if has_running: + # There is running work to protect, so the new prefill is deferred... + assert "new0" not in output.num_scheduled_tokens + assert new_req.status == RequestStatus.WAITING + # ...while the running/decode request keeps being scheduled. + assert "run0" in output.num_scheduled_tokens + # When the cadence allows prefills again, the request is admitted. + output = scheduler.schedule() + + # No running work to protect (or cadence now open): the prefill is admitted. + assert "new0" in output.num_scheduled_tokens + assert any(r.req_id == "new0" for r in output.scheduled_new_reqs) + + +def _setup_remote_kv_resume(num_prompt_tokens: int, matched_tokens: int): + """Drive a remote-KV request `r2` to the resume point (async load complete) + while another request `r1` is already decoding, so the step is throttle- + eligible. Returns the scheduler. The connector matches `matched_tokens` of + `r2`'s prompt; the rest (if any) is local prefill. + """ + from tests.v1.kv_connector.unit.utils import create_model_runner_output + + BLOCK_SIZE = 16 + scheduler = create_scheduler( + enable_prefix_caching=True, + use_kv_connector=mock_kv(matched_tokens=matched_tokens, is_async=True), + block_size=BLOCK_SIZE, + ) + # Distinct prompts so r2 gets no local prefix cache hit from r1, only the + # connector's external async load. + r1, r2 = create_requests( + num_requests=2, + num_tokens=num_prompt_tokens, + max_tokens=20, + block_size=BLOCK_SIZE, + req_ids=["r1", "r2"], + ) + + # r1: drive through its async KV load into the running (decode) state, so + # self.running is non-empty (which makes the next step throttle-eligible). + scheduler.add_request(r1) + _step_until_kv_transfer_finished(scheduler, ["r1"]) + output = scheduler.schedule() # promote + schedule r1 + assert "r1" in output.num_scheduled_tokens + scheduler.update_from_output( + output, create_model_runner_output([r1], token_id=1000) + ) + assert scheduler.running # r1 now decoding + + # r2: a second remote-KV request; complete its async load while r1 decodes. + scheduler.add_request(r2) + output = scheduler.schedule() # r1 decodes; r2 -> WAITING_FOR_REMOTE_KVS + assert r2.status == RequestStatus.WAITING_FOR_REMOTE_KVS + scheduler.update_from_output( + output, create_model_runner_output([r1], finished_recving={"r2"}) + ) + assert "r2" in scheduler.finished_recving_kv_req_ids + return scheduler + + +def test_throttle_prefills_excludes_fully_transferred_remote_kv(): + """A remote-KV resume whose whole prompt was transferred (no local prefill + left, e.g. the decode side of P/D disaggregation) must NOT be throttled by + the DP prefill cadence -- its single-token step has no prefill compute to + defer, so delaying it would be pointless. + """ + block_size = 16 + num_prompt = block_size * 2 + # Fully matched: the whole prompt is loaded remotely. + scheduler = _setup_remote_kv_resume(num_prompt, matched_tokens=num_prompt) + + output = scheduler.schedule(throttle_prefills=True) + assert "r2" in output.num_scheduled_tokens + assert "r1" in output.num_scheduled_tokens + + +def test_throttle_prefills_defers_remote_kv_resume_with_local_prefill(): + """A remote-KV resume with local prefill still to compute (the connector + only matched part of the prompt) IS throttled by the DP prefill cadence, + like any other request doing local prefill compute this step. + """ + block_size = 16 + num_prompt = block_size * 4 + # Half matched: the remaining half is local prefill compute. + scheduler = _setup_remote_kv_resume(num_prompt, matched_tokens=num_prompt // 2) + + output = scheduler.schedule(throttle_prefills=True) + assert "r2" not in output.num_scheduled_tokens # deferred (has local prefill) + assert "r1" in output.num_scheduled_tokens + + +def test_throttle_defers_inflight_prefill_chunk(): + """DP prefill balancing throttles ALL prefill compute on a throttled step, + not just new admissions: an in-progress (chunked) prefill already in the + running queue is also deferred, so the step runs decode-only, while a + separate decode keeps being scheduled.""" + scheduler = create_scheduler( + max_num_seqs=16, max_num_batched_tokens=50, enable_chunked_prefill=True + ) + + # A short request that finishes prefill in one step -> a running decode. + (decode_req,) = create_requests(num_requests=1, num_tokens=4, req_ids=["dec0"]) + scheduler.add_request(decode_req) + output = scheduler.schedule() + scheduler.update_from_output( + output, + ModelRunnerOutput( + req_ids=["dec0"], + req_id_to_index={"dec0": 0}, + sampled_token_ids=[[0]], + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ), + ) + assert decode_req in scheduler.running and not decode_req.is_prefill_chunk + + # A long request (80 tokens, budget 50) -> prefilled in chunks. + (chunk_req,) = create_requests(num_requests=1, num_tokens=80, req_ids=["chk0"]) + scheduler.add_request(chunk_req) + output = scheduler.schedule() # first chunk of chk0 + decode of dec0 + assert output.num_scheduled_tokens["chk0"] > 0 + scheduler.update_from_output( + output, + ModelRunnerOutput( + req_ids=["dec0", "chk0"], + req_id_to_index={"dec0": 0, "chk0": 1}, + sampled_token_ids=[[0], []], # no token sampled for partial prefill + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ), + ) + assert chunk_req.is_prefill_chunk # still mid-prefill, in running + + # Throttled step: the in-flight prefill chunk is deferred, the decode runs. + output = scheduler.schedule(throttle_prefills=True) + assert "chk0" not in output.num_scheduled_tokens + assert "dec0" in output.num_scheduled_tokens + + # When the cadence opens again, the prefill chunk resumes. + output = scheduler.schedule() + assert "chk0" in output.num_scheduled_tokens + + +def test_throttle_capacity_bound_guard_admits(): + """Saturation guard: if a cadence-aligned release step cannot drain the + waiting prefill queue (it ran out of token budget), the throttle backs off on + the next step so the backlog cannot grow into a TTFT avalanche -- prefills are + admitted even though throttle_prefills is set.""" + scheduler = create_scheduler( + max_num_seqs=16, max_num_batched_tokens=200, enable_chunked_prefill=True + ) + a, b = create_requests(num_requests=2, num_tokens=200, req_ids=["a", "b"]) + scheduler.add_request(a) + scheduler.add_request(b) + + # Release step (throttle off): `a` fills the 200-token budget; `b` cannot be + # reached, so the waiting queue is not drained -> capacity-bound. + output = scheduler.schedule() + assert "a" in output.num_scheduled_tokens + assert "b" not in output.num_scheduled_tokens + assert scheduler.prefill_capacity_bound + + # Throttle. Because the previous release was capacity-bound, the guard backs + # off and `b` is admitted rather than stalling the backlog. + output = scheduler.schedule(throttle_prefills=True) + assert "b" in output.num_scheduled_tokens + + def test_no_mm_input_chunking(): # Disable multimodal input chunking. scheduler = create_scheduler( @@ -1060,6 +1303,136 @@ def test_no_spec_tokens_scheduled_for_prefill_chunks(): assert len(output.scheduled_spec_decode_tokens[req.request_id]) == num_spec_tokens +def _model_output(scheduler, output, sampled): + """Feed `sampled` (per-request list) back to the scheduler.""" + req_ids = list(output.num_scheduled_tokens.keys()) + scheduler.update_from_output( + output, + ModelRunnerOutput( + req_ids=req_ids, + req_id_to_index={r: i for i, r in enumerate(req_ids)}, + sampled_token_ids=sampled, + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ), + ) + + +def test_spec_decode_padding_first_decode_step(): + """A request taking its first decode step (whole prompt already computed via + a prefix-cache hit) is padded with placeholder (-1) spec tokens so it enters + the worker with the same 1 + num_spec_tokens shape as the other speculative + decodes, keeping the batch uniform. + """ + num_spec = 3 + scheduler = create_scheduler( + num_speculative_tokens=num_spec, + enable_prefix_caching=True, + block_size=16, + ) + # Two identical 33-token prompts: 2 full blocks (32 tokens) get cached, so a + # second identical request hits num_computed == num_prompt_tokens - 1. + r1, r2 = create_requests( + num_requests=2, num_tokens=33, same_prompt=True, max_tokens=16 + ) + + # Drive r1 through prefill so its prompt blocks are cached, then give it real + # drafts so it is a running speculative decode (1 + num_spec shape). + scheduler.add_request(r1) + out = scheduler.schedule() + assert out.num_scheduled_tokens[r1.request_id] == 33 + _model_output(scheduler, out, [[100]]) + scheduler.update_draft_token_ids(DraftTokenIds([r1.request_id], [[1, 2, 3]])) + + # r2 arrives; its whole prompt is a prefix-cache hit -> first decode step. + scheduler.add_request(r2) + out = scheduler.schedule() + + # r1 verifies its real drafts. + assert out.scheduled_spec_decode_tokens[r1.request_id] == [1, 2, 3] + # r2 is padded to the 1 + num_spec shape with placeholder (-1) drafts. + assert out.num_scheduled_tokens[r2.request_id] == 1 + num_spec + assert out.scheduled_spec_decode_tokens[r2.request_id] == [-1] * num_spec + + +def test_spec_decode_padding_skipped_for_diffusion(): + """Diffusion spec tokens are the fixed-size denoising canvas, not + rejectable drafts: a first-decode-step request must keep its 1-token span + instead of being padded to 1 + num_spec_tokens, which would overflow the + canvas. + """ + num_spec = 3 + scheduler = create_scheduler( + num_speculative_tokens=num_spec, + enable_prefix_caching=True, + block_size=16, + ) + # Diffusion schedulers initialize this to 0 (model_config.is_diffusion). + scheduler.num_sampled_tokens_per_step = 0 + r1, r2 = create_requests( + num_requests=2, num_tokens=33, same_prompt=True, max_tokens=16 + ) + + scheduler.add_request(r1) + out = scheduler.schedule() + assert out.num_scheduled_tokens[r1.request_id] == 33 + _model_output(scheduler, out, [[100]]) + scheduler.update_draft_token_ids(DraftTokenIds([r1.request_id], [[1, 2, 3]])) + + # r2 arrives; its whole prompt is a prefix-cache hit -> needs exactly + # 1 token while r1 is a running speculative decode. + scheduler.add_request(r2) + out = scheduler.schedule() + + assert out.scheduled_spec_decode_tokens[r1.request_id] == [1, 2, 3] + # r2 keeps its true 1-token span; no placeholder drafts are attached. + assert out.num_scheduled_tokens[r2.request_id] == 1 + assert r2.request_id not in out.scheduled_spec_decode_tokens + + +def test_spec_decode_padding_skipped_with_prefill_in_batch(): + """Padding is skipped when the batch contains a prefill chunk: the batch is + already mixed/non-uniform, so padding a new decode request buys nothing. + """ + num_spec = 3 + scheduler = create_scheduler( + num_speculative_tokens=num_spec, + enable_prefix_caching=True, + block_size=16, + max_num_batched_tokens=64, + ) + # r_warm + r_candidate share a prompt so r_candidate gets a full prefix hit. + r_warm, r_candidate = create_requests( + num_requests=2, num_tokens=33, same_prompt=True, max_tokens=1 + ) + # r_long has a different, long prompt that prefills over multiple chunks. + (r_long,) = create_requests(num_requests=1, num_tokens=100, max_tokens=16) + + # Warm the prefix cache with r_warm's prompt (it finishes; blocks stay cached). + scheduler.add_request(r_warm) + out = scheduler.schedule() + assert out.num_scheduled_tokens[r_warm.request_id] == 33 + _model_output(scheduler, out, [[100]]) + assert r_warm.request_id in scheduler.finished_req_ids + + # Start r_long; after one chunk it remains a prefill chunk in the running queue. + scheduler.add_request(r_long) + out = scheduler.schedule() + _model_output(scheduler, out, [[]]) # still prefilling, no sampled token + assert r_long.is_prefill_chunk + + # r_candidate arrives (prefix-cache hit -> first decode step) alongside the + # in-flight prefill chunk. + scheduler.add_request(r_candidate) + out = scheduler.schedule() + + # The batch has a prefill chunk, so r_candidate is NOT padded. + assert r_long.request_id in out.num_scheduled_tokens + assert out.num_scheduled_tokens[r_candidate.request_id] == 1 + assert r_candidate.request_id not in out.scheduled_spec_decode_tokens + + def test_scheduler_stats_waiting_queues(): """Test that scheduler stats correctly report waiting and skipped_waiting queues.""" # Create scheduler with limited capacity so we can have waiting requests @@ -1562,11 +1935,14 @@ def test_kv_connector_unable_to_allocate(use_ec_connector, ec_role): assert len(scheduler.waiting) == 0 +@pytest.mark.parametrize("use_v2_model_runner", [False, True]) @pytest.mark.parametrize("is_async", [False, True]) @pytest.mark.parametrize( "use_ec_connector, ec_role", [(False, None), (True, "ec_consumer")] ) -def test_kv_connector_handles_preemption(is_async, use_ec_connector, ec_role): +def test_kv_connector_handles_preemption( + is_async, use_ec_connector, ec_role, use_v2_model_runner +): """ Test whether scheduler with KVConnector is able to handle unable to allocate (run out of blocks in allocate_slots(). @@ -1587,6 +1963,7 @@ def test_kv_connector_handles_preemption(is_async, use_ec_connector, ec_role): # encoder connector should not affect test results use_ec_connector=use_ec_connector, ec_role=ec_role, + use_v2_model_runner=use_v2_model_runner, ) # Create two requests. @@ -1697,8 +2074,14 @@ def test_kv_connector_handles_preemption(is_async, use_ec_connector, ec_role): ) assert len(scheduler.running) == 1 assert len(scheduler.waiting) == 0 - assert output.scheduled_cached_reqs.num_reqs == 1 - assert output.scheduled_new_reqs == [] + if use_v2_model_runner: + # V2 emits a resumed (previously preempted) request as a + # NewRequestData rather than a cached request. + assert output.scheduled_cached_reqs.num_reqs == 0 + assert len(output.scheduled_new_reqs) == 1 + else: + assert output.scheduled_cached_reqs.num_reqs == 1 + assert output.scheduled_new_reqs == [] _ = scheduler.update_from_output(output, MODEL_RUNNER_OUTPUT) assert len(scheduler.running) == 1 assert len(scheduler.waiting) == 0 @@ -1818,6 +2201,7 @@ def create_scheduler_with_priority( num_speculative_tokens: int | None = None, use_ec_connector: bool = False, ec_role: str | None = None, + use_v2_model_runner: bool | None = None, ) -> Scheduler: """Create scheduler with priority policy enabled. @@ -1849,6 +2233,8 @@ def create_scheduler_with_priority( enable_chunked_prefill=True, is_encoder_decoder=model_config.is_encoder_decoder, policy="priority", # Enable priority scheduling + # Ensure admission/preemption mechanics are deterministic + watermark=0.0, ) # Cache config, optionally force APC cache_config = CacheConfig( @@ -1907,7 +2293,7 @@ def create_scheduler_with_priority( ], ) cache_config.num_gpu_blocks = num_blocks - return Scheduler( + scheduler = Scheduler( vllm_config=vllm_config, kv_cache_config=kv_cache_config, log_stats=True, @@ -1915,6 +2301,10 @@ def create_scheduler_with_priority( block_size=block_size, hash_block_size=block_size, ) + if use_v2_model_runner is None: + use_v2_model_runner = bool(envs.VLLM_USE_V2_MODEL_RUNNER) + scheduler.use_v2_model_runner = use_v2_model_runner + return scheduler _none_hash_initialized = False @@ -2556,6 +2946,9 @@ def test_abort_request_when_structured_output_fsm_cannot_advance(): scheduler.connector = None scheduler.structured_output_manager = Mock() scheduler.structured_output_manager.should_advance.return_value = True + scheduler.structured_output_manager.trim_reasoning_for_advance.side_effect = ( + lambda request, new_token_ids: new_token_ids + ) scheduler.requests = {request.request_id: request} scheduler.running = [request] scheduler.waiting = Mock() @@ -2568,6 +2961,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance(): scheduler.vllm_config.model_config.enable_return_routed_experts = False scheduler.enable_return_routed_experts = False scheduler.recompute_kv_load_failures = False + scheduler.defer_block_free = False scheduler.make_stats = Mock(return_value=None) scheduler.max_model_len = 128 @@ -2615,11 +3009,12 @@ def free_request(req: Request, delay_free_blocks: bool = False): assert engine_core_output.finish_reason == FinishReason.ERROR +@pytest.mark.parametrize("use_v2_model_runner", [False, True]) @pytest.mark.parametrize( "use_ec_connector, ec_role", [(False, None), (True, "ec_consumer")] ) def test_priority_scheduling_preemption_and_resumption_when_out_of_kv( - use_ec_connector, ec_role + use_ec_connector, ec_role, use_v2_model_runner ): """Test that priority scheduling preempts lower priority requests when out of KV cache space.""" @@ -2633,6 +3028,7 @@ def test_priority_scheduling_preemption_and_resumption_when_out_of_kv( # encoder connector should not affect test results use_ec_connector=use_ec_connector, ec_role=ec_role, + use_v2_model_runner=use_v2_model_runner, ) # Create a request and schedule it @@ -2725,20 +3121,31 @@ def test_priority_scheduling_preemption_and_resumption_when_out_of_kv( output = scheduler.schedule() scheduled_cached_reqs = output.scheduled_cached_reqs - assert len(output.scheduled_new_reqs) == 0 - assert scheduled_cached_reqs.num_reqs == 1 assert len(scheduler.waiting) == 0 assert len(scheduler.running) == 1 - # Preempted request resumed in scheduled_cached_reqs - assert len(scheduled_cached_reqs.resumed_req_ids) == 1 - assert len(scheduled_cached_reqs.all_token_ids) == 1 - assert scheduled_cached_reqs.req_ids[0] == request_low.request_id - assert request_low.request_id in scheduled_cached_reqs.resumed_req_ids - assert request_low.request_id in scheduled_cached_reqs.all_token_ids - # Resumed tokens include 30 prompt tokens and 2 decoded tokens - assert len(scheduled_cached_reqs.all_token_ids[request_low.request_id]) == 32 - assert scheduled_cached_reqs.all_token_ids[request_low.request_id][31] == 100 + if use_v2_model_runner: + # V2 emits the resumed request as a NewRequestData, carrying its full + # token ids in prefill_token_ids (instead of cached all_token_ids). + assert scheduled_cached_reqs.num_reqs == 0 + assert len(output.scheduled_new_reqs) == 1 + new_req = output.scheduled_new_reqs[0] + assert new_req.req_id == request_low.request_id + # Resumed tokens include 30 prompt tokens and 2 decoded tokens. + assert len(new_req.prefill_token_ids) == 32 + assert new_req.prefill_token_ids[31] == 100 + else: + assert len(output.scheduled_new_reqs) == 0 + assert scheduled_cached_reqs.num_reqs == 1 + # Preempted request resumed in scheduled_cached_reqs + assert len(scheduled_cached_reqs.resumed_req_ids) == 1 + assert len(scheduled_cached_reqs.all_token_ids) == 1 + assert scheduled_cached_reqs.req_ids[0] == request_low.request_id + assert request_low.request_id in scheduled_cached_reqs.resumed_req_ids + assert request_low.request_id in scheduled_cached_reqs.all_token_ids + # Resumed tokens include 30 prompt tokens and 2 decoded tokens + assert len(scheduled_cached_reqs.all_token_ids[request_low.request_id]) == 32 + assert scheduled_cached_reqs.all_token_ids[request_low.request_id][31] == 100 @pytest.mark.parametrize( @@ -3988,6 +4395,87 @@ def test_delayed_kv_connector_free_keeps_scheduler_active(): assert not scheduler.has_finished_requests() +def test_scheduler_kv_connector_stats(): + """Test worker-side, scheduler-side, and combined KV connector stats.""" + + class GenericKVConnectorStats(KVConnectorStats): + def reset(self): + self.data = {} + + def aggregate(self, other: KVConnectorStats) -> KVConnectorStats: + self.data.update(other.data) + return self + + def reduce(self) -> dict[str, int | float]: + return {} + + def is_empty(self) -> bool: + return not self.data + + test_cases = ( + ({"worker": 1}, None, {"worker": 1}), + (None, {"scheduler": 2}, {"scheduler": 2}), + ({"worker": 1}, {"scheduler": 2}, {"worker": 1, "scheduler": 2}), + ) + + for worker_data, scheduler_data, expected_data in test_cases: + scheduler = create_scheduler() + worker_stats = ( + GenericKVConnectorStats(data=worker_data) if worker_data else None + ) + scheduler_stats = ( + GenericKVConnectorStats(data=scheduler_data) if scheduler_data else None + ) + scheduler.connector = Mock() + scheduler.connector.get_kv_connector_stats.return_value = ( + scheduler_stats if worker_stats is None else None + ) + scheduler.connector.take_events.return_value = [] + + def update_connector_output( + kv_connector_output: KVConnectorOutput, + scheduler=scheduler, + scheduler_stats=scheduler_stats, + ): + scheduler.connector.get_kv_connector_stats.return_value = scheduler_stats + + scheduler.connector.update_connector_output.side_effect = ( + update_connector_output + ) + + model_output = ModelRunnerOutput( + req_ids=["req_0"], + req_id_to_index={"req_0": 0}, + sampled_token_ids=[[123]], + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[None], + kv_connector_output=KVConnectorOutput(kv_connector_stats=worker_stats) + if worker_stats + else None, + ) + scheduler_output = SchedulerOutput( + scheduled_new_reqs=[], + scheduled_cached_reqs=None, + num_scheduled_tokens={"req_0": 1}, + total_num_scheduled_tokens=1, + scheduled_spec_decode_tokens={}, + scheduled_encoder_inputs={}, + num_common_prefix_blocks=[0], + finished_req_ids=set(), + free_encoder_mm_hashes=[], + ) + + engine_core_outputs = scheduler.update_from_output( + scheduler_output, model_output + ) + + final_stats = next( + iter(engine_core_outputs.values()) + ).scheduler_stats.kv_connector_stats + assert final_stats == expected_data + + # ============================================================================== # Variable-length encoder cross-attention block allocation tests # ============================================================================== @@ -4351,6 +4839,212 @@ def test_eagle3_mm_encoder_cache_with_shift(): ) +def test_free_encoder_inputs_respects_unconfirmed_placeholders(): + """Regression test for issue #38551 (rollback path): under async + scheduling with speculative decoding, num_computed_tokens is advanced + optimistically and can be rolled back when in-flight draft tokens are + rejected. Freeing an encoder input as soon as num_computed_tokens passes + the end of its placeholder range allows a later rollback to rewind back + into the range, after which the worker's MM-embedding gather reads an + evicted entry and crashes the engine with "Encoder cache miss". The + scheduler must retain the input until the *confirmed* progress + (num_computed_tokens - num_output_placeholders) passes the range end, so + that no pending rejection can rewind into the range.""" + scheduler = create_scheduler( + model="llava-hf/llava-1.5-7b-hf", + num_speculative_tokens=3, + ) + mm_start_pos = 50 + mm_length = 100 + mm_positions = [ + [PlaceholderRange(offset=mm_start_pos, length=mm_length)], + ] + request = create_requests( + num_requests=1, + num_tokens=mm_start_pos + mm_length + 100, + mm_positions=mm_positions, + )[0] + manager = scheduler.encoder_cache_manager + manager.allocate(request, 0) + mm_end = mm_start_pos + mm_length + + # One optimistically-scheduled in-flight step advanced num_computed_tokens + # by 1 sampled + 3 draft tokens; none are confirmed yet, so all 4 are + # still output placeholders that a rejection could rewind. + request.num_output_placeholders = 4 + + # Optimistic progress reaches the end of the MM range, but the confirmed + # position (mm_end + 1 - 4) is still inside it: a rejection could rewind + # back into the range, so the entry must be retained. + request.num_computed_tokens = mm_end + 1 + scheduler._free_encoder_inputs(request) + assert manager.get_cached_input_ids(request) == {0} + + # Confirmed position still inside the range. + request.num_computed_tokens = mm_end + 3 + scheduler._free_encoder_inputs(request) + assert manager.get_cached_input_ids(request) == {0} + + # Confirmed position (mm_end + 4 - 4) now reaches the range end: even if + # every unconfirmed token is rejected, progress cannot rewind into the + # range, so the entry is freed. + request.num_computed_tokens = mm_end + 4 + scheduler._free_encoder_inputs(request) + assert manager.get_cached_input_ids(request) == set() + + +def test_free_encoder_inputs_defers_for_eagle_lookahead(): + """With EAGLE speculative decoding, the encoder input is retained one extra + position so the drafter's +1 look-ahead mm-embedding gather (which reads one + position past the target's computed range) still finds it cached. This is + the primary mechanism that prevents the drafter "Encoder cache miss"; the + worker-side token-embedding fallback is only a backstop.""" + scheduler = create_scheduler(model="llava-hf/llava-1.5-7b-hf") + # create_scheduler only builds ngram spec configs; force the eagle path that + # _free_encoder_inputs keys off (self.use_eagle). + scheduler.use_eagle = True + mm_positions = [[PlaceholderRange(offset=50, length=100)]] + request = create_requests( + num_requests=1, + num_tokens=250, + mm_positions=mm_positions, + )[0] + manager = scheduler.encoder_cache_manager + manager.allocate(request, 0) + mm_end = 150 # offset + length + + # Confirmed progress reaches the range end: without spec decode this frees + # (see test below), but the drafter's +1 look-ahead still needs it. + request.num_computed_tokens = mm_end + scheduler._free_encoder_inputs(request) + assert manager.get_cached_input_ids(request) == {0} + + # One position past the range end: the +1 look-ahead has now passed it. + request.num_computed_tokens = mm_end + 1 + scheduler._free_encoder_inputs(request) + assert manager.get_cached_input_ids(request) == set() + + +def test_free_encoder_inputs_unchanged_without_spec_decode(): + """Without speculative decoding, encoder inputs are freed as soon as + num_computed_tokens passes the placeholder range, as before.""" + scheduler = create_scheduler(model="llava-hf/llava-1.5-7b-hf") + mm_positions = [[PlaceholderRange(offset=50, length=100)]] + request = create_requests( + num_requests=1, + num_tokens=250, + mm_positions=mm_positions, + )[0] + manager = scheduler.encoder_cache_manager + manager.allocate(request, 0) + + request.num_computed_tokens = 149 + scheduler._free_encoder_inputs(request) + assert manager.get_cached_input_ids(request) == {0} + + request.num_computed_tokens = 150 + scheduler._free_encoder_inputs(request) + assert manager.get_cached_input_ids(request) == set() + + +def test_encoder_cache_retained_across_preemption_and_resume(): + """Regression guard for issue #38551 (preemption path). + + A request preempted under KV pressure resets num_computed_tokens to 0 + and drops its encoder references (scheduler._preempt_request calls + encoder_cache_manager.free). Because that only moves the entry into + `freeable` (it is not evicted), the worker still holds it: the scheduler + must NOT report the mm_hash as freed. On resume, re-requesting the + encoder input must pull the still-cached entry back out of `freeable` + without scheduling a recompute, keeping the scheduler and worker + consistent. The spec-rollback retention margin does not gate this path, + so it is covered separately here.""" + scheduler = create_scheduler( + model="llava-hf/llava-1.5-7b-hf", + num_speculative_tokens=3, + ) + mm_positions = [[PlaceholderRange(offset=50, length=100)]] + request = create_requests( + num_requests=1, + num_tokens=250, + mm_hashes_list=[["img_a"]], + mm_positions=mm_positions, + )[0] + manager = scheduler.encoder_cache_manager + mm_hash = request.mm_features[0].identifier + + # Prefill scheduled and computed the encoder input; it is pinned. + manager.allocate(request, 0) + assert manager.get_cached_input_ids(request) == {0} + + # Preemption drops the request's encoder references (scheduler.py: + # _preempt_request -> encoder_cache_manager.free) and resets progress. + manager.free(request) + request.num_computed_tokens = 0 + # The entry is now ref-free but only `freeable` (not evicted): the + # worker still holds it, so nothing must be reported as freed. + assert mm_hash in manager.cached + assert mm_hash in manager.freeable + assert manager.get_freed_mm_hashes() == [] + + # Resume re-requests the encoder output. The still-cached entry is pulled + # back out of `freeable` with no recompute and no worker-side free. + assert manager.check_and_update_cache(request, 0) is True + assert mm_hash not in manager.freeable + assert manager.get_cached_input_ids(request) == {0} + assert manager.get_freed_mm_hashes() == [] + + +def test_encoder_cache_recomputed_when_evicted_during_preemption(): + """Companion to the retention case (issue #38551, preemption path). + + If a preempted request's retained encoder entry IS evicted under memory + pressure before it resumes, the scheduler reports the mm_hash as freed + (so the worker drops it) and a resume must schedule a recompute rather + than assume the worker still holds it. check_and_update_cache must + return False so the encoder input is re-scheduled.""" + scheduler = create_scheduler( + model="llava-hf/llava-1.5-7b-hf", + num_speculative_tokens=3, + ) + mm_positions = [[PlaceholderRange(offset=50, length=100)]] + request = create_requests( + num_requests=1, + num_tokens=250, + mm_hashes_list=[["img_a"]], + mm_positions=mm_positions, + )[0] + manager = scheduler.encoder_cache_manager + mm_hash = request.mm_features[0].identifier + + manager.allocate(request, 0) + # Preemption drops references; the entry becomes freeable. + manager.free(request) + request.num_computed_tokens = 0 + assert mm_hash in manager.freeable + + # A new request with a different image hits memory pressure and evicts + # the freeable entry to make room. + other = create_requests( + num_requests=1, + num_tokens=250, + mm_hashes_list=[["img_b"]], + mm_positions=mm_positions, + req_ids=["1"], + )[0] + manager.num_free_slots = 50 # force eviction of the freeable entry + assert manager.can_allocate( + other, 0, encoder_compute_budget=10_000, num_embeds_to_schedule=0 + ) + + # The evicted entry is reported to the worker, which drops it. + assert mm_hash not in manager.cached + assert manager.get_freed_mm_hashes() == [mm_hash] + + # On resume the original request must recompute (cache miss is correct). + assert manager.check_and_update_cache(request, 0) is False + + @pytest.mark.parametrize("use_kv_connector", [False, True]) def test_ec_connector_ensure_cache_available_defers_request(use_kv_connector): """Test that ensure_cache_available() returning False defers the request. @@ -4474,3 +5168,81 @@ def test_ec_connector_pending_prefetch_only_checks_future_mm_features(): f"Expected only {HASH_FUTURE!r} from future mm feature filtering, " f"got {future_hashes!r}. Past/boundary features must be filtered out." ) + + +def test_async_load_reservation_prevents_wedge_e2e(): + """Same wedge scenario as PR #40968's lateral-preemption e2e test, but + resolved by reservation-based admission control instead of preemption. + + A (8 blocks) and B (5 blocks) both want an async KV load, sharing a 4-block + prefix, in a 10-block pool (9 usable). Admitting both loads would wedge: + once their recvs finish neither can complete its local prefill (8+5 > 9). + + Here the reservation gate refuses to admit B's load while A's full sequence + is still reserved, so B never holds blocks and A is free to complete - no + deadlock, and (unlike lateral preemption) B is never preempted. + """ + BLOCK_SIZE = 16 + A_TOKENS = BLOCK_SIZE * 8 # bigger request + B_TOKENS = BLOCK_SIZE * 5 # smaller request + MATCHED_TOKENS = BLOCK_SIZE * 4 # 4-block prefix loaded for both + NUM_BLOCKS = 10 # 9 usable; both prefixes fit, but not both full sequences + + scheduler = create_scheduler( + block_size=BLOCK_SIZE, + num_blocks=NUM_BLOCKS, + max_num_seqs=4, + max_num_batched_tokens=A_TOKENS * 2, + use_kv_connector=mock_kv(matched_tokens=MATCHED_TOKENS, is_async=True), + ) + + [a] = create_requests( + num_requests=1, num_tokens=A_TOKENS, block_size=BLOCK_SIZE, req_ids=["a"] + ) + [b] = create_requests( + num_requests=1, num_tokens=B_TOKENS, block_size=BLOCK_SIZE, req_ids=["b"] + ) + scheduler.add_request(a) + scheduler.add_request(b) + + EMPTY_OUTPUT = ModelRunnerOutput( + req_ids=[], + req_id_to_index={}, + sampled_token_ids=[], + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ) + + req_to_blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[ + 0 + ].req_to_blocks + + # Step 1: A's load is admitted; B's is held back by the reservation (B never + # holds blocks, so the wedge precondition - both holding prefixes - is gone). + out1 = scheduler.schedule() + assert a.status == RequestStatus.WAITING_FOR_REMOTE_KVS + assert a.num_computed_tokens == MATCHED_TOKENS + assert b.status == RequestStatus.WAITING + assert b.request_id not in req_to_blocks + assert len(scheduler.running) == 0 + scheduler.update_from_output(out1, EMPTY_OUTPUT) + + # Step 2: nothing changes until A's recv lands. + out2 = scheduler.schedule() + assert len(scheduler.running) == 0 + a_finished = dataclasses.replace( + EMPTY_OUTPUT, + kv_connector_output=KVConnectorOutput(finished_recving=[a.request_id]), + ) + scheduler.update_from_output(out2, a_finished) + + # Step 3: A makes forward progress straight to RUNNING - no preemption was + # needed because B never wedged it. + out3 = scheduler.schedule() + assert a.status == RequestStatus.RUNNING + assert a in scheduler.running + assert a.request_id in {req.req_id for req in out3.scheduled_new_reqs} + assert b.status == RequestStatus.WAITING + assert b.num_preemptions == 0 + assert b.request_id not in req_to_blocks diff --git a/tests/v1/core/test_single_type_kv_cache_manager.py b/tests/v1/core/test_single_type_kv_cache_manager.py index 0e3e8879359a..609c1428d196 100644 --- a/tests/v1/core/test_single_type_kv_cache_manager.py +++ b/tests/v1/core/test_single_type_kv_cache_manager.py @@ -14,9 +14,14 @@ ) from vllm.v1.core.single_type_kv_cache_manager import ( ChunkedLocalAttentionManager, + RSWAManager, SlidingWindowManager, ) -from vllm.v1.kv_cache_interface import ChunkedLocalAttentionSpec, SlidingWindowSpec +from vllm.v1.kv_cache_interface import ( + ChunkedLocalAttentionSpec, + RSWASpec, + SlidingWindowSpec, +) pytestmark = pytest.mark.cpu_test @@ -327,6 +332,51 @@ def assert_block_id(block_table: list[KVCacheBlock], ids: list[int]): assert_block_id(block_table, [null_block_id] * 4 + original_block_ids[4:]) +def test_rswa_remove_skipped_blocks_gap_range(): + block_size = 4 + rswa_spec = RSWASpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + rswa_window=8, + ) + block_pool = BlockPool(num_gpu_blocks=2000, enable_caching=True, hash_block_size=4) + manager = RSWAManager( + rswa_spec, + block_pool=block_pool, + enable_caching=True, + kv_cache_group_id=0, + scheduler_block_size=block_size, + ) + + null_block_id = block_pool.null_block.block_id + original_block_ids = list(range(1000, 1010)) + block_table = [ + KVCacheBlock(id_) if id_ != null_block_id else block_pool.null_block + for id_ in original_block_ids + ] + manager.req_to_blocks["test"] = block_table + + prefix_len = 16 + + # Without num_prompt_tokens, R-SWA does not evict gap blocks. + manager.remove_skipped_blocks("test", 28) + assert [b.block_id for b in block_table] == original_block_ids + + # Gap = block 4 only (tokens [16, 20) fall in the gap). + manager.remove_skipped_blocks("test", 28, num_prompt_tokens=prefix_len) + expected = original_block_ids.copy() + expected[4] = null_block_id + assert [b.block_id for b in block_table] == expected + + # Window moves: blocks 5 and 6 also enter the gap; block 4 is already null. + manager.remove_skipped_blocks("test", 36, num_prompt_tokens=prefix_len) + expected[5] = null_block_id + expected[6] = null_block_id + assert [b.block_id for b in block_table] == expected + + def test_get_num_blocks_to_allocate(): block_size = 2 sliding_window_spec = SlidingWindowSpec( @@ -390,7 +440,7 @@ def test_evictable_cached_blocks_not_double_allocated(): # should only allocate the truly new block. assert num_blocks_to_allocate == 2 - manager.allocate_new_computed_blocks( + manager.add_local_computed_blocks( request_id, [evictable_block], num_local_computed_tokens=block_size, diff --git a/tests/v1/core/test_swa_inflight_window_free.py b/tests/v1/core/test_swa_inflight_window_free.py new file mode 100644 index 000000000000..737796a4bf7d --- /dev/null +++ b/tests/v1/core/test_swa_inflight_window_free.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Out-of-window block frees vs in-flight GPU steps. + +With async scheduling / PP, `num_computed_tokens` optimistically includes +tokens of unprocessed steps whose attention windows still read the blocks +just below the optimistic boundary (and rejected spec tokens can roll it +back), so `allocate_slots` frees on the processed-token basis: +`num_computed_tokens - num_in_flight_tokens`. +""" + +import torch + +from vllm.v1.core.sched.output import SchedulerOutput +from vllm.v1.kv_cache_interface import ChunkedLocalAttentionSpec, SlidingWindowSpec +from vllm.v1.outputs import ModelRunnerOutput + +from .utils import create_requests, create_scheduler, mock_kv + +NUM_PROMPT_TOKENS = 100 +BLOCK_SIZE = 16 +SLIDING_WINDOW = 16 +# Tokens 0..84 are outside the window of the next token to compute +# (100 - 16 + 1 = 85), i.e. 5 full blocks. +NUM_OUT_OF_WINDOW_BLOCKS = 85 // BLOCK_SIZE +# Chunked-local skips whole chunks left of the current one: +# (100 // 32) * 32 = 96 settled tokens -> 6 full blocks. +CHUNK_SIZE = 32 +NUM_OUT_OF_CHUNK_BLOCKS = (NUM_PROMPT_TOKENS // CHUNK_SIZE) * CHUNK_SIZE // BLOCK_SIZE + + +def _make_model_runner_output( + scheduler_output: SchedulerOutput, + token_id: int = 0, +) -> ModelRunnerOutput: + req_ids = list(scheduler_output.num_scheduled_tokens.keys()) + return ModelRunnerOutput( + req_ids=req_ids, + req_id_to_index={req_id: i for i, req_id in enumerate(req_ids)}, + sampled_token_ids=[[token_id] for _ in req_ids], + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ) + + +def _create_swa_scheduler(async_scheduling: bool): + return create_scheduler( + block_size=BLOCK_SIZE, + async_scheduling=async_scheduling, + kv_cache_spec=SlidingWindowSpec( + block_size=BLOCK_SIZE, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=SLIDING_WINDOW, + ), + ) + + +def _create_chunked_scheduler(async_scheduling: bool): + return create_scheduler( + block_size=BLOCK_SIZE, + async_scheduling=async_scheduling, + kv_cache_spec=ChunkedLocalAttentionSpec( + block_size=BLOCK_SIZE, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + attention_chunk_size=CHUNK_SIZE, + ), + ) + + +def _num_null_blocks(scheduler, request_id: str) -> int: + manager = scheduler.kv_cache_manager.coordinator.single_type_managers[0] + null_block = manager._null_block + return sum(1 for b in manager.req_to_blocks[request_id] if b is null_block) + + +def test_num_in_flight_tokens_accounting(): + scheduler = create_scheduler(async_scheduling=True) + request = create_requests(num_requests=1, num_tokens=NUM_PROMPT_TOKENS)[0] + scheduler.add_request(request) + + out0 = scheduler.schedule() + assert request.num_in_flight_tokens == NUM_PROMPT_TOKENS + # Async: decode scheduled before the prefill output is processed. + out1 = scheduler.schedule() + assert request.num_in_flight_tokens == NUM_PROMPT_TOKENS + 1 + + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + assert request.num_in_flight_tokens == 1 + scheduler.update_from_output(out1, _make_model_runner_output(out1)) + assert request.num_in_flight_tokens == 0 + + +def test_swa_free_waits_for_in_flight_step(): + """Async: out-of-window blocks stay allocated until the step that still + reads them has been processed.""" + scheduler = _create_swa_scheduler(async_scheduling=True) + request = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE + )[0] + scheduler.add_request(request) + req_id = request.request_id + block_pool = scheduler.kv_cache_manager.block_pool + + out0 = scheduler.schedule() # prefill, in flight from here on + free_after_prefill = block_pool.get_num_free_blocks() + + # Decode scheduled while the prefill still reads the out-of-window blocks: + # they must not be freed yet. + out1 = scheduler.schedule() + assert _num_null_blocks(scheduler, req_id) == 0 + assert block_pool.get_num_free_blocks() == free_after_prefill + + # Prefill output processed; the next allocate frees the out-of-window + # blocks. + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + scheduler.schedule() + assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS + assert ( + block_pool.get_num_free_blocks() + == free_after_prefill + NUM_OUT_OF_WINDOW_BLOCKS + ) + # Not double-freed on the following steps. + scheduler.update_from_output(out1, _make_model_runner_output(out1)) + scheduler.schedule() + assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS + + +def test_swa_free_immediate_when_sync(): + """Sync: no in-flight step at schedule time, frees happen at the first + decode allocation as before.""" + scheduler = _create_swa_scheduler(async_scheduling=False) + request = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE + )[0] + scheduler.add_request(request) + req_id = request.request_id + + out0 = scheduler.schedule() + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + assert request.num_in_flight_tokens == 0 + + scheduler.schedule() + assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS + + +def test_swa_admission_cap_accounts_for_overlapping_batches(): + spec = SlidingWindowSpec( + block_size=16, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=1024, + ) + base = spec.max_admission_blocks_per_request( + max_in_flight_tokens=1024, max_model_len=16384 + ) + # (1024 - 1 + 1024) tokens -> 128 blocks, +1 for window misalignment. + assert base == 129 + overlapped = spec.max_admission_blocks_per_request( + max_in_flight_tokens=2 * 1024, max_model_len=16384 + ) + # One extra in-flight chunk is held back: (1024 - 1 + 2 * 1024) tokens. + assert overlapped == 193 + + +def test_chunked_local_free_waits_for_in_flight_step(): + """Chunked-local attention frees whole chunks left of the current one, and + is exposed to the same load-WAR: with async scheduling those chunks must + stay allocated until the in-flight step that still reads them settles.""" + scheduler = _create_chunked_scheduler(async_scheduling=True) + request = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE + )[0] + scheduler.add_request(request) + req_id = request.request_id + block_pool = scheduler.kv_cache_manager.block_pool + + out0 = scheduler.schedule() # prefill, in flight from here on + free_after_prefill = block_pool.get_num_free_blocks() + + # Decode scheduled while the prefill still reads the out-of-chunk blocks. + out1 = scheduler.schedule() + assert _num_null_blocks(scheduler, req_id) == 0 + assert block_pool.get_num_free_blocks() == free_after_prefill + + # Prefill output processed; the next allocate frees the out-of-chunk blocks. + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + scheduler.schedule() + assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_CHUNK_BLOCKS + assert ( + block_pool.get_num_free_blocks() == free_after_prefill + NUM_OUT_OF_CHUNK_BLOCKS + ) + # Not double-freed on the following steps. + scheduler.update_from_output(out1, _make_model_runner_output(out1)) + scheduler.schedule() + assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_CHUNK_BLOCKS + + +def test_chunked_local_admission_cap_accounts_for_overlapping_batches(): + spec = ChunkedLocalAttentionSpec( + block_size=16, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + attention_chunk_size=1024, + ) + base = spec.max_admission_blocks_per_request( + max_in_flight_tokens=1024, max_model_len=16384 + ) + # (1024 + 1024) tokens -> 128 blocks. + assert base == 128 + overlapped = spec.max_admission_blocks_per_request( + max_in_flight_tokens=2 * 1024, max_model_len=16384 + ) + # One extra in-flight chunk is held back: (1024 + 2 * 1024) tokens. + assert overlapped == 192 + + +def test_connector_finish_frees_on_settled_basis(): + """The out-of-window prune done at request finish, before the block table + is handed to a KV connector (simple CPU offload / NIXL store), must use the + same processed-token basis. Otherwise the connector reads/hands off a block + the still-in-flight step is optimistically counted as done with, which is + the load-path WAR this fix closes.""" + scheduler = create_scheduler( + block_size=BLOCK_SIZE, + async_scheduling=True, + use_kv_connector=mock_kv(matched_tokens=0, is_async=False), + kv_cache_spec=SlidingWindowSpec( + block_size=BLOCK_SIZE, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=SLIDING_WINDOW, + ), + ) + request = create_requests( + num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE + )[0] + scheduler.add_request(request) + req_id = request.request_id + + out0 = scheduler.schedule() # prefill, in flight from here on + scheduler.schedule() # decode over-scheduled: num_computed_tokens optimistic + + # Finishing now (connector store) must NOT prune out-of-window blocks the + # in-flight prefill still reads. + scheduler._connector_finished(request) + assert _num_null_blocks(scheduler, req_id) == 0 + + # Once the in-flight step settles, the same prune releases them. + scheduler.update_from_output(out0, _make_model_runner_output(out0)) + scheduler._connector_finished(request) + assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS diff --git a/tests/v1/core/test_worker_slot_overflow.py b/tests/v1/core/test_worker_slot_overflow.py new file mode 100644 index 000000000000..79366b0a2368 --- /dev/null +++ b/tests/v1/core/test_worker_slot_overflow.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression tests for the V2 model runner "No free indices" assertion. + +The V2 model runner stores per-request state in a fixed-size slab sized to +``max_num_seqs`` (``RequestState.free_indices`` in +``vllm/v1/worker/gpu/states.py``). A slot is occupied from the moment a request +appears in ``scheduled_new_reqs`` (worker ``add_requests``) until it appears in +``finished_req_ids``/``preempted_req_ids`` (worker ``finish_requests``), with a +defensive same-id ``_remove_request`` on re-add. If the scheduler ever lets the +number of slot-holding requests exceed ``max_num_seqs``, ``add_request`` trips:: + + assert len(self.free_indices) > 0, "No free indices" + +The invariant the scheduler must preserve is: *every request that still holds a +worker slot is counted against the admission limit*. These tests exercise two +ways that invariant can break, by replaying real scheduler outputs through a +faithful model of the worker's slot accounting. +""" + +import pytest + +from vllm.v1.outputs import ModelRunnerOutput +from vllm.v1.request import RequestStatus + +from .test_scheduler import create_scheduler_with_priority +from .utils import create_requests, create_scheduler + +pytestmark = pytest.mark.cpu_test + + +class WorkerSlots: + """Faithful model of the V2 model runner's request-slot accounting. + + Mirrors ``GPUModelRunner.finish_requests`` + ``add_requests`` and + ``RequestState.add_request``/``remove_request`` (the ``free_indices`` pool). + ``apply`` raises ``AssertionError("No free indices")`` exactly where the real + worker would, so a scheduler over-admission surfaces as the same failure. + """ + + def __init__(self, max_num_reqs: int): + self.max_num_reqs = max_num_reqs + self.occupied: set[str] = set() + + def apply(self, scheduler_output) -> None: + # finish_requests: free finished and preempted slots first. + freed = set(scheduler_output.finished_req_ids) + freed |= set(scheduler_output.preempted_req_ids) + self.occupied -= freed + + # update_requests: cached reqs must already own a slot, else the + # worker's req_id_to_index lookup would KeyError. + for req_id in scheduler_output.scheduled_cached_reqs.req_ids: + assert req_id in self.occupied, f"cached req {req_id!r} has no worker slot" + + # add_requests: new (and, under V2, resumed) reqs claim a slot. The + # same-id _remove_request guard frees a stale slot for the same id. + for new_req in scheduler_output.scheduled_new_reqs: + self.occupied.discard(new_req.req_id) + assert len(self.occupied) < self.max_num_reqs, "No free indices" + self.occupied.add(new_req.req_id) + + +def _model_runner_output(req_ids: list[str], sampled: list[list[int]]): + return ModelRunnerOutput( + req_ids=req_ids, + req_id_to_index={req_id: i for i, req_id in enumerate(req_ids)}, + sampled_token_ids=sampled, + logprobs=None, + prompt_logprobs_dict={}, + pooler_output=[], + ) + + +def test_streaming_pause_does_not_over_admit_worker_slot(): + """A paused resumable session keeps its worker slot; with max_num_seqs=1 a + new request must not be admitted on top of it. + + Repro of the "No free indices" crash: the session pauses into + WAITING_FOR_STREAMING_REQ (removed from ``running`` but never reported as + finished/preempted, so the worker keeps its slot), then a different request + is admitted into the single slot. + """ + STOP_TOKEN = 7 + scheduler = create_scheduler(max_num_seqs=1, use_v2_model_runner=True) + slots = WorkerSlots(max_num_reqs=1) + + # A resumable streaming session. + (session,) = create_requests( + num_requests=1, + num_tokens=4, + req_ids=["session"], + stop_token_ids=[STOP_TOKEN], + max_tokens=16, + ) + session.resumable = True + scheduler.add_request(session) + + out = scheduler.schedule() + slots.apply(out) + assert [r.req_id for r in out.scheduled_new_reqs] == ["session"] + + # The session emits its stop token and pauses, waiting for the next input + # chunk. It leaves `running` but is NOT finished/preempted, so the worker + # still holds its slot. + scheduler.update_from_output(out, _model_runner_output(["session"], [[STOP_TOKEN]])) + assert session.status == RequestStatus.WAITING_FOR_STREAMING_REQ + # A blocked waiting status lands in skipped_waiting, not the main queue. + assert session in scheduler.skipped_waiting + assert scheduler.num_waiting_for_streaming_input == 1 + + # A different request arrives while the session is paused. + (other,) = create_requests( + num_requests=1, num_tokens=4, req_ids=["other"], max_tokens=16 + ) + scheduler.add_request(other) + + out = scheduler.schedule() + # Before the fix this admits `other` as a new request while the session's + # slot is still held -> WorkerSlots.apply raises "No free indices". + slots.apply(out) + + # After the fix: `other` is deferred until the session resumes or finishes. + assert "other" not in [r.req_id for r in out.scheduled_new_reqs] + assert other in scheduler.waiting + + +def test_reset_prefix_cache_priority_does_not_over_admit_worker_slot(): + """reset_prefix_cache(reset_running_requests=True) force-preempts the running + request out-of-band; under priority scheduling a higher-priority newcomer + jumps ahead of the preempted request and would over-admit the single worker + slot unless the preemption is reported to the worker. + + The fix buffers the force-preemption and reports it in the next scheduler + output's preempted_req_ids, so the worker frees the slot before the newcomer + is admitted. + """ + scheduler = create_scheduler_with_priority( + max_num_seqs=1, enable_prefix_caching=True, use_v2_model_runner=True + ) + slots = WorkerSlots(max_num_reqs=1) + + # Low-priority request A (larger priority value == lower priority). + (req_a,) = create_requests( + num_requests=1, num_tokens=4, req_ids=["A"], ignore_eos=True, max_tokens=100 + ) + req_a.priority = 10 + scheduler.add_request(req_a) + + out = scheduler.schedule() + slots.apply(out) + assert [r.req_id for r in out.scheduled_new_reqs] == ["A"] + + scheduler.update_from_output(out, _model_runner_output(["A"], [[42]])) + assert req_a.status == RequestStatus.RUNNING + assert req_a in scheduler.running + + # Force a prefix-cache reset that preempts A out-of-band. A goes back to the + # waiting queue (PREEMPTED); the preemption is buffered for the next output. + assert scheduler.reset_prefix_cache(reset_running_requests=True) + assert req_a.status == RequestStatus.PREEMPTED + + # A higher-priority request B arrives and outranks the preempted A. + (req_b,) = create_requests( + num_requests=1, num_tokens=4, req_ids=["B"], ignore_eos=True, max_tokens=100 + ) + req_b.priority = 0 + scheduler.add_request(req_b) + + out = scheduler.schedule() + # The fix reports A's force-preemption here, so the worker frees A's slot + # before B is admitted -> no overflow. + assert "A" in out.preempted_req_ids + assert "B" in [r.req_id for r in out.scheduled_new_reqs] + slots.apply(out) + assert slots.occupied == {"B"} + + +def test_reset_prefix_cache_same_step_resume_purges_then_re_adds(): + """When a force-preempted request resumes in the SAME step (FCFS, no + competing request), it appears in both preempted_req_ids and (under V2) + scheduled_new_reqs. The worker runs finish_requests before add_requests, so + the request's slot is purged and then cleanly re-added. + """ + scheduler = create_scheduler(max_num_seqs=1, use_v2_model_runner=True) + slots = WorkerSlots(max_num_reqs=1) + + (req_a,) = create_requests( + num_requests=1, num_tokens=4, req_ids=["A"], ignore_eos=True, max_tokens=100 + ) + scheduler.add_request(req_a) + + out = scheduler.schedule() + slots.apply(out) + scheduler.update_from_output(out, _model_runner_output(["A"], [[42]])) + assert req_a in scheduler.running + + assert scheduler.reset_prefix_cache(reset_running_requests=True) + assert req_a.status == RequestStatus.PREEMPTED + # Reset invalidates A's computed tokens; it will re-prefill from scratch. + assert req_a.num_computed_tokens == 0 + + out = scheduler.schedule() + # A is force-preempted AND resumed in this one step. + assert "A" in out.preempted_req_ids + assert "A" in [r.req_id for r in out.scheduled_new_reqs] + # WorkerSlots mirrors finish-before-add: purge frees the slot, re-add fills + # it. No overflow, A still occupies its single slot afterward. + slots.apply(out) + assert slots.occupied == {"A"} diff --git a/tests/v1/core/utils.py b/tests/v1/core/utils.py index 7213a669c53e..19beba1a53dd 100644 --- a/tests/v1/core/utils.py +++ b/tests/v1/core/utils.py @@ -3,6 +3,7 @@ import torch +import vllm.envs as envs from tests.v1.kv_connector.unit.utils import MockKVConfig from vllm.config import ( CacheConfig, @@ -29,6 +30,7 @@ FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, + KVCacheSpec, ) from vllm.v1.request import Request from vllm.v1.structured_output import StructuredOutputManager @@ -53,11 +55,16 @@ def create_scheduler( block_size: int = 16, max_model_len: int | None = None, num_speculative_tokens: int | None = None, + speculative_method: str | None = None, skip_tokenizer_init: bool = False, async_scheduling: bool = False, pipeline_parallel_size: int = 1, + data_parallel_size: int = 1, + num_speculative_tokens_per_batch_size: list[tuple[int, int, int]] | None = None, use_ec_connector: bool = False, ec_role: str | None = None, + use_v2_model_runner: bool | None = None, + kv_cache_spec: KVCacheSpec | None = None, ) -> Scheduler | AsyncScheduler: """Create scheduler under test. @@ -90,6 +97,8 @@ def create_scheduler( enable_chunked_prefill=enable_chunked_prefill, async_scheduling=async_scheduling, is_encoder_decoder=model_config.is_encoder_decoder, + # Ensure admission/preemption mechanics are deterministic + watermark=0.0, ) # Cache config, optionally force APC cache_config = CacheConfig( @@ -122,9 +131,18 @@ def create_scheduler( speculative_config: SpeculativeConfig | None = None if num_speculative_tokens is not None: - speculative_config = SpeculativeConfig( + spec_kwargs: dict = dict( model="ngram", num_speculative_tokens=num_speculative_tokens ) + if num_speculative_tokens_per_batch_size is not None: + spec_kwargs["num_speculative_tokens_per_batch_size"] = ( + num_speculative_tokens_per_batch_size + ) + if speculative_method is not None: + spec_kwargs["method"] = speculative_method + spec_kwargs["prompt_lookup_max"] = num_speculative_tokens + spec_kwargs["prompt_lookup_min"] = 1 + speculative_config = SpeculativeConfig(**spec_kwargs) ec_transfer_config = ( ECTransferConfig( @@ -140,36 +158,40 @@ def create_scheduler( scheduler_config=scheduler_config, model_config=model_config, cache_config=cache_config, - parallel_config=ParallelConfig(pipeline_parallel_size=pipeline_parallel_size), + parallel_config=ParallelConfig( + pipeline_parallel_size=pipeline_parallel_size, + data_parallel_size=data_parallel_size, + ), kv_transfer_config=kv_transfer_config, speculative_config=speculative_config, ec_transfer_config=ec_transfer_config, ) + if kv_cache_spec is None: + kv_cache_spec = FullAttentionSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + ) kv_cache_config = KVCacheConfig( num_blocks=num_blocks, # A large number of blocks to hold all requests kv_cache_tensors=[], - kv_cache_groups=[ - KVCacheGroupSpec( - ["layer"], - FullAttentionSpec( - block_size=block_size, - num_kv_heads=1, - head_size=1, - dtype=torch.float32, - ), - ) - ], + kv_cache_groups=[KVCacheGroupSpec(["layer"], kv_cache_spec)], ) cache_config.num_gpu_blocks = num_blocks register_all_kvcache_specs(vllm_config) scheduler_cls = AsyncScheduler if async_scheduling else Scheduler - return scheduler_cls( + scheduler = scheduler_cls( vllm_config=vllm_config, kv_cache_config=kv_cache_config, block_size=block_size, log_stats=True, structured_output_manager=StructuredOutputManager(vllm_config), ) + if use_v2_model_runner is None: + use_v2_model_runner = bool(envs.VLLM_USE_V2_MODEL_RUNNER) + scheduler.use_v2_model_runner = use_v2_model_runner + return scheduler _none_hash_initialized = False diff --git a/tests/v1/cudagraph/test_cudagraph_dispatch.py b/tests/v1/cudagraph/test_cudagraph_dispatch.py index 97b5fd46a2eb..c10835821f58 100644 --- a/tests/v1/cudagraph/test_cudagraph_dispatch.py +++ b/tests/v1/cudagraph/test_cudagraph_dispatch.py @@ -49,6 +49,7 @@ def _create_vllm_config( ) mock_config.parallel_config = ParallelConfig() mock_config.speculative_config = None # No speculative decoding + mock_config.num_speculative_tokens = 0 if not lora_config: mock_config.lora_config = None else: diff --git a/tests/v1/cudagraph/test_encoder_cudagraph.py b/tests/v1/cudagraph/test_encoder_cudagraph.py index 61134a4f5a2a..ed816d817c15 100644 --- a/tests/v1/cudagraph/test_encoder_cudagraph.py +++ b/tests/v1/cudagraph/test_encoder_cudagraph.py @@ -108,7 +108,7 @@ def _make_manager_with_budgets(budgets: list[int]) -> EncoderCudaGraphManager: mgr.token_budgets = sorted(budgets) mgr.max_batch_size = 16 mgr.use_dp = False - mgr.budget_graphs = {} + mgr.budget_graphs = {"default": {}} mgr.graph_pool = None mgr.graph_hits = 0 mgr.graph_misses = 0 @@ -341,6 +341,7 @@ def prepare_encoder_cudagraph_capture_inputs( max_frames_per_batch: int, device: torch.device, dtype: torch.dtype, + path: str = "default", ) -> EncoderCudaGraphCaptureInputs: per_image_output = token_budget // max_batch_size grid_config = [ @@ -365,6 +366,7 @@ def prepare_encoder_cudagraph_replay_buffers( mm_kwargs: dict[str, Any], max_batch_size: int, max_frames_per_batch: int, + path: str = "default", ) -> EncoderCudaGraphReplayBuffers: grid_thw = mm_kwargs["image_grid_thw"] n_out = _count_output_tokens(grid_thw, _SPATIAL_MERGE) @@ -380,12 +382,14 @@ def prepare_encoder_cudagraph_replay_buffers( def encoder_cudagraph_forward( self, values: dict[str, torch.Tensor], + path: str = "default", ) -> torch.Tensor: return self._forward(values["pixel_values"]) def encoder_eager_forward( self, mm_kwargs: dict[str, Any], + path: str = "default", ) -> torch.Tensor: return self._forward(mm_kwargs["pixel_values"]) @@ -413,7 +417,7 @@ def _make_manager_for_gpu( max_frames_per_batch if max_frames_per_batch is not None else max_batch_size * 2 ) mgr.use_dp = False - mgr.budget_graphs = {} + mgr.budget_graphs = {"default": {}} mgr.graph_pool = None mgr.graph_hits = 0 mgr.graph_misses = 0 @@ -479,15 +483,15 @@ def setup_method(self): # --- capture --- def test_capture_creates_one_graph_per_budget(self): - assert len(self.mgr.budget_graphs) == len(_BUDGETS) - assert set(self.mgr.budget_graphs.keys()) == set(_BUDGETS) + assert len(self.mgr.budget_graphs["default"]) == len(_BUDGETS) + assert set(self.mgr.budget_graphs["default"].keys()) == set(_BUDGETS) def test_capture_uses_supplied_graph_pool(self): assert self.mgr.graph_pool is self.graph_pool def test_clear_releases_graphs_and_pool(self): self.mgr.clear() - assert self.mgr.budget_graphs == {} + assert self.mgr.budget_graphs == {"default": {}} assert self.mgr.graph_pool is None # --- output shape --- @@ -642,6 +646,7 @@ def prepare_encoder_cudagraph_capture_inputs( max_frames_per_batch: int, device: torch.device, dtype: torch.dtype, + path: str = "default", ) -> EncoderCudaGraphCaptureInputs: per_item_output = token_budget // max_batch_size frames_per_item = max_frames_per_batch // max_batch_size @@ -678,6 +683,7 @@ def prepare_encoder_cudagraph_replay_buffers( mm_kwargs: dict[str, Any], max_batch_size: int, max_frames_per_batch: int, + path: str = "default", ) -> EncoderCudaGraphReplayBuffers: n_out = _count_output_tokens(self._get_grid_thw(mm_kwargs), _SPATIAL_MERGE) p = next(self.parameters()) @@ -692,12 +698,14 @@ def prepare_encoder_cudagraph_replay_buffers( def encoder_cudagraph_forward( self, values: dict[str, torch.Tensor], + path: str = "default", ) -> torch.Tensor: return self._forward(values["pixel_values"]) def encoder_eager_forward( self, mm_kwargs: dict[str, Any], + path: str = "default", ) -> torch.Tensor: return self._forward(self._get_pixel_values(mm_kwargs)) @@ -763,8 +771,8 @@ def setup_method(self): # --- capture --- def test_capture_creates_one_graph_per_budget(self): - assert len(self.mgr.budget_graphs) == len(_BUDGETS) - assert set(self.mgr.budget_graphs.keys()) == set(_BUDGETS) + assert len(self.mgr.budget_graphs["default"]) == len(_BUDGETS) + assert set(self.mgr.budget_graphs["default"].keys()) == set(_BUDGETS) # --- output shape --- diff --git a/tests/v1/determinism/test_batch_invariance.py b/tests/v1/determinism/test_batch_invariance.py index 415c7d5f3f26..fb12ffd17063 100644 --- a/tests/v1/determinism/test_batch_invariance.py +++ b/tests/v1/determinism/test_batch_invariance.py @@ -19,6 +19,7 @@ @skip_unsupported +@pytest.mark.flaky(reruns=3) @pytest.mark.timeout(1000) @pytest.mark.parametrize( "backend", diff --git a/tests/v1/distributed/test_async_llm_dp.py b/tests/v1/distributed/test_async_llm_dp.py index 70a5136a57ce..9269f294b8bd 100644 --- a/tests/v1/distributed/test_async_llm_dp.py +++ b/tests/v1/distributed/test_async_llm_dp.py @@ -186,6 +186,67 @@ def log_engine_initialized(self): ) +@pytest.mark.parametrize("prefill_schedule_interval", [1, 4]) +@pytest.mark.asyncio +async def test_dp_prefill_schedule_interval(prefill_schedule_interval: int): + """Throttling new prefills to every Nth step (DP balancing) must not break + generation: a stream of staggered requests should still all complete with + the expected number of tokens. + + The throttle only engages in the DP MoE/EP engine-core path + (`DPEngineCoreProc`), so this uses an MoE model with expert parallel. + """ + with ExitStack() as after: + prompt = "This is a test of data parallel" + + engine_args = AsyncEngineArgs( + model="ibm-research/PowerMoE-3b", + enforce_eager=True, + tensor_parallel_size=int(os.getenv("TP_SIZE", 1)), + data_parallel_size=DP_SIZE, + data_parallel_backend="mp", + enable_expert_parallel=True, + prefill_schedule_interval=prefill_schedule_interval, + ) + engine = AsyncLLM.from_engine_args(engine_args) + after.callback(engine.shutdown) + + NUM_REQUESTS = 50 + NUM_EXPECTED_TOKENS = 10 + + request_ids = [f"request-{i}" for i in range(NUM_REQUESTS)] + + # Create requests with a small stagger so they arrive across many + # steps and (with interval > 1) accumulate in the waiting queue + # before being admitted together on cadence-aligned steps. + tasks = [] + for request_id in request_ids: + tasks.append( + asyncio.create_task( + generate( + engine, + request_id, + prompt, + RequestOutputKind.DELTA, + NUM_EXPECTED_TOKENS, + ) + ) + ) + await asyncio.sleep(0.01) + + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) + for task in pending: + task.cancel() + for task in done: + num_generated_tokens, request_id = await task + assert num_generated_tokens == NUM_EXPECTED_TOKENS, ( + f"{request_id} generated {num_generated_tokens} but " + f"expected {NUM_EXPECTED_TOKENS}" + ) + + assert not engine.output_processor.has_unfinished_requests() + + # ============================================================================= # DP Pause/Resume Tests # ============================================================================= diff --git a/tests/v1/e2e/general/test_async_scheduling.py b/tests/v1/e2e/general/test_async_scheduling.py index 22a6c799c79f..7f5a11514563 100644 --- a/tests/v1/e2e/general/test_async_scheduling.py +++ b/tests/v1/e2e/general/test_async_scheduling.py @@ -158,6 +158,10 @@ def test_with_eagle3_spec_decoding(sample_json_schema, monkeypatch: pytest.Monke @pytest.mark.flaky(reruns=2, only_on=current_platform.is_rocm()) +@pytest.mark.skipif( + current_platform.is_xpu(), + reason=("XPU matmul/attention kernels are not batch-invariant"), +) def test_with_ngram_gpu_spec_decoding(monkeypatch: pytest.MonkeyPatch): """Test ngram_gpu speculative decoding with different configurations. diff --git a/tests/v1/e2e/general/test_cascade_attention.py b/tests/v1/e2e/general/test_cascade_attention.py index be889b38690b..251746271de3 100644 --- a/tests/v1/e2e/general/test_cascade_attention.py +++ b/tests/v1/e2e/general/test_cascade_attention.py @@ -4,9 +4,16 @@ import pytest from vllm import LLM, SamplingParams +from vllm.platforms import current_platform from ....utils import create_new_process_for_each_test +if current_platform.is_rocm(): + pytest.skip( + "Cascade attention backends FLASH_ATTN and FLASHINFER are notsupported on ROCm", + allow_module_level=True, + ) + @create_new_process_for_each_test() @pytest.mark.parametrize("attn_backend", ["FLASH_ATTN", "FLASHINFER"]) diff --git a/tests/v1/e2e/general/test_context_length.py b/tests/v1/e2e/general/test_context_length.py index c9dc8354fa1a..cd0aff79de83 100644 --- a/tests/v1/e2e/general/test_context_length.py +++ b/tests/v1/e2e/general/test_context_length.py @@ -75,8 +75,9 @@ def test_auto_fit_max_model_len_rejects_oversized_input( must see this reduced value and reject prompts that exceed it, rather than accepting them and hanging.""" - # Use a tiny KV cache budget to force auto-fit to a very small - # max_model_len (e.g. ~16 tokens). + # Use a small KV cache budget to force auto-fit to a small + # max_model_len. Pin block_size=16 so the budget is independent + # of the platform's default block size. kv_cache_bytes = 1_000_000 # 1 MB with vllm_runner( @@ -84,6 +85,7 @@ def test_auto_fit_max_model_len_rejects_oversized_input( max_model_len=-1, max_num_seqs=1, enforce_eager=True, + block_size=16, kv_cache_memory_bytes=kv_cache_bytes, load_format="dummy", ) as vllm_model: diff --git a/tests/v1/e2e/general/test_kv_sharing_fast_prefill.py b/tests/v1/e2e/general/test_kv_sharing_fast_prefill.py index 4bb8d63a8a21..11f77492d2e9 100644 --- a/tests/v1/e2e/general/test_kv_sharing_fast_prefill.py +++ b/tests/v1/e2e/general/test_kv_sharing_fast_prefill.py @@ -45,7 +45,9 @@ def test_prompts(): use_fork_for_test = ( - fork_new_process_for_each_test if not current_platform.is_rocm() else lambda x: x + fork_new_process_for_each_test + if not (current_platform.is_rocm() or current_platform.is_xpu()) + else lambda x: x ) diff --git a/tests/v1/e2e/general/test_mamba_prefix_cache.py b/tests/v1/e2e/general/test_mamba_prefix_cache.py index 8cd2e89f5e98..5a7af6f22c5d 100644 --- a/tests/v1/e2e/general/test_mamba_prefix_cache.py +++ b/tests/v1/e2e/general/test_mamba_prefix_cache.py @@ -11,6 +11,7 @@ import pytest import torch +import vllm.envs as envs from tests.utils import create_new_process_for_each_test from vllm import LLM, SamplingParams, TokensPrompt from vllm.config import CacheConfig @@ -38,13 +39,18 @@ class StepAction: num_computed_tokens_start: int num_scheduled_tokens: int - kv_cache_block_ids: list[int] # [] to follow last step + kv_cache_block_ids: list[int] # per-block mask: 1=held, 0=freed/nulled preprocess_copy_idx: tuple[int, int] # -1, -1 for no copy postprocess_copy_idx: tuple[int, int] # -1, -1 for no copy num_speculative_tokens = 3 +# Whether the run under test uses async scheduling. Set by each test entrypoint +# before generation; consulted where the scheduler's optimistic token count must +# be corrected for in-flight (possibly-rejected) speculative tokens. +async_scheduling_mode = False + num_accepted_tokens = 1 prompt_token_ids: list[int] = [] MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct-FP8" @@ -180,6 +186,8 @@ def fake_allocate_slots_fn( delay_cache_blocks: bool = False, num_encoder_tokens: int = 0, full_sequence_must_fit: bool = False, + reserved_blocks: int = 0, + has_scheduled_reqs: bool = True, ): ret = original_allocate_slots_fn( self, @@ -192,6 +200,8 @@ def fake_allocate_slots_fn( delay_cache_blocks, num_encoder_tokens, full_sequence_must_fit, + reserved_blocks, + has_scheduled_reqs, ) if cur_step_action is not None: cur_block_ids = self.coordinator.single_type_managers[0].req_to_blocks[ @@ -244,7 +254,8 @@ def fake_execute_model_fn( scheduler_output.scheduled_cached_reqs.num_computed_tokens[0] ) if ( - self.num_spec_tokens + async_scheduling_mode + and self.num_spec_tokens and num_prompt_tokens is not None and num_computed_tokens > num_prompt_tokens ): @@ -398,6 +409,7 @@ def _run_ref_mamba_state_worker(): GPUModelRunner._sample = fake_sample_fn engine = LLM( model=MODEL, + load_format="dummy", block_size=BLOCK_SIZE, hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS}, seed=42, @@ -490,12 +502,10 @@ def apply_patch(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(mamba_utils, "do_mamba_copy_block", fake_copy_fn) -@create_new_process_for_each_test() -def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): - run_ref_mamba_state_in_subprocess() - apply_patch(monkeypatch) - prompt_dataset = datasets.load_dataset("heheda/a_long_article") - full_prompt = prompt_dataset["train"][0]["text"] +def get_mamba_prefix_cache_step_configs( + async_scheduling: bool = False, +) -> dict[str, TestConfig]: + a = async_scheduling tests = { "accept_1": TestConfig( num_prompt_tokens=554, @@ -503,14 +513,24 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=1, step_actions=[ StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(554, 4, [], (-1, -1), (-1, -1)), - StepAction(555, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(556, 4, [], (-1, -1), (-1, -1)), - StepAction(557, 4, [], (0, 1), (-1, -1)), - StepAction(558, 4, [], (-1, -1), (-1, -1)), - StepAction(559, 4, [], (-1, -1), (1, 0)), - StepAction(560, 4, [], (-1, -1), (-1, -1)), - StepAction(561, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 555, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) + ), + StepAction( + 556, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) + ), + StepAction(557, 4, [1, 1, 1, 1, 1], (0, 1), (-1, -1)), + StepAction(558, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction(559, 4, [1, 1, 1, 1, 1], (-1, -1), (1, 0)), + StepAction(560, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 561, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), ], ), # test case 2.1: no hit, accept 2 tokens @@ -520,11 +540,19 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=2, step_actions=[ StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(554, 4, [], (-1, -1), (-1, -1)), - StepAction(556, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(558, 4, [], (1, 1), (2, 0)), - StepAction(560, 4, [], (-1, -1), (-1, -1)), - StepAction(562, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 556, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) + ), + StepAction(558, 4, [1, 1, 1, 1, 1], (1, 1), (2, 0)), + StepAction(560, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 562, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), ], ), # test case 2.2: no hit, accept 2 tokens @@ -534,10 +562,16 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=2, step_actions=[ StepAction(0, 555, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(555, 4, [], (-1, -1), (-1, -1)), + StepAction(555, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(557, 4, [1, 1, 1, 1, 1], (1, 1), (-1, -1)), - StepAction(559, 4, [], (-1, -1), (1, 0)), - StepAction(561, 4, [], (-1, -1), (-1, -1)), + StepAction(559, 4, [1, 1, 1, 1, 1], (-1, -1), (1, 0)), + StepAction( + 561, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), StepAction(563, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), ], ), @@ -547,10 +581,18 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=3, step_actions=[ StepAction(0, 553, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(553, 4, [], (-1, -1), (-1, -1)), - StepAction(556, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(559, 4, [], (2, 1), (1, 0)), - StepAction(562, 4, [], (-1, -1), (-1, -1)), + StepAction(553, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 556, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1) + ), + StepAction(559, 4, [1, 1, 1, 1, 1], (2, 1), (1, 0)), + StepAction( + 562, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), StepAction(565, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), ], ), @@ -560,10 +602,16 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=3, step_actions=[ StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(554, 4, [], (-1, -1), (-1, -1)), + StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(557, 4, [1, 1, 1, 1, 1], (2, 1), (3, 0)), - StepAction(560, 4, [], (-1, -1), (-1, -1)), - StepAction(563, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction(560, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 563, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), ], ), "accept_3_3": TestConfig( @@ -572,9 +620,15 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=3, step_actions=[ StepAction(0, 555, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(555, 4, [], (-1, -1), (-1, -1)), + StepAction(555, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(558, 4, [1, 1, 1, 1, 1], (2, 1), (2, 0)), - StepAction(561, 4, [], (-1, -1), (-1, -1)), + StepAction( + 561, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), StepAction(564, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), ], ), @@ -584,9 +638,15 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=4, step_actions=[ StepAction(0, 553, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(553, 4, [], (-1, -1), (-1, -1)), + StepAction(553, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(557, 4, [1, 1, 1, 1, 1], (3, 1), (3, 0)), - StepAction(561, 4, [], (-1, -1), (-1, -1)), + StepAction( + 561, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), StepAction(565, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), ], ), @@ -596,9 +656,15 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=4, step_actions=[ StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(554, 4, [], (-1, -1), (-1, -1)), + StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(558, 4, [1, 1, 1, 1, 1], (3, 1), (2, 0)), - StepAction(562, 4, [], (-1, -1), (-1, -1)), + StepAction( + 562, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), StepAction(566, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), ], ), @@ -608,9 +674,15 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=4, step_actions=[ StepAction(0, 555, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(555, 4, [], (-1, -1), (-1, -1)), + StepAction(555, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(559, 4, [1, 1, 1, 1, 1], (3, 1), (1, 0)), - StepAction(563, 4, [], (-1, -1), (-1, -1)), + StepAction( + 563, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), StepAction(567, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), ], ), @@ -620,9 +692,15 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): num_accepted_tokens=4, step_actions=[ StepAction(0, 556, [1, 1, 1, 1], (-1, -1), (-1, -1)), - StepAction(556, 4, [], (-1, -1), (3, 0)), + StepAction(556, 4, [1, 1, 1, 1], (-1, -1), (3, 0)), StepAction(560, 4, [1, 1, 1, 1, 1], (0, 1), (-1, -1)), - StepAction(564, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 564, + 4, + [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), ], ), "prompt_block_size": TestConfig( @@ -641,7 +719,13 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): step_actions=[ StepAction(0, 560, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(560, 560, [1, 1, 1, 1, 1], (0, 1), (-1, -1)), - StepAction(560 * 2, 4, [0, 1, 1, 1, 1, 1], (1, 2), (-1, -1)), + StepAction( + 560 * 2, + 4, + [1, 1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1, 1], + (1, 2), + (-1, -1), + ), ], ), "prompt_2_block_size_10": TestConfig( @@ -651,7 +735,13 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): step_actions=[ StepAction(0, 560, [1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(560, 570, [1, 0, 1, 1, 1, 1], (0, 2), (-1, -1)), - StepAction(560 * 2 + 10, 4, [0, 0, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 560 * 2 + 10, + 4, + [1, 0, 1, 1, 1, 1] if a else [0, 0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), ], ), "prompt_3_block_size": TestConfig( @@ -661,7 +751,13 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): step_actions=[ StepAction(0, 560 * 2, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(560 * 2, 560, [0, 1, 1, 1, 1, 1], (1, 2), (-1, -1)), - StepAction(560 * 3, 4, [0, 0, 1, 1, 1, 1, 1], (2, 3), (-1, -1)), + StepAction( + 560 * 3, + 4, + [0, 1, 1, 1, 1, 1, 1] if a else [0, 0, 1, 1, 1, 1, 1], + (2, 3), + (-1, -1), + ), ], ), "prompt_3_block_size_10": TestConfig( @@ -671,7 +767,13 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): step_actions=[ StepAction(0, 560 * 2, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)), StepAction(560 * 2, 570, [0, 1, 0, 1, 1, 1, 1], (1, 3), (-1, -1)), - StepAction(560 * 3 + 10, 4, [0, 0, 0, 1, 1, 1, 1], (-1, -1), (-1, -1)), + StepAction( + 560 * 3 + 10, + 4, + [0, 1, 0, 1, 1, 1, 1] if a else [0, 0, 0, 1, 1, 1, 1], + (-1, -1), + (-1, -1), + ), ], ), "prompt_10_block_size": TestConfig( @@ -690,14 +792,18 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): StepAction( 560 * 9, 560, - [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1] + if a + else [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], (8, 9), (-1, -1), ), StepAction( 560 * 10, 4, - [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], + [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1] + if a + else [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], (9, 10), (-1, -1), ), @@ -719,16 +825,32 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): StepAction( 560 * 9, 560 + 10, - [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1], + [0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1] + if a + else [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1], (8, 10), (-1, -1), ), ], ), } + return tests + + +def _run_mamba_prefix_cache_mrv1( + monkeypatch: pytest.MonkeyPatch, async_scheduling: bool +): + global async_scheduling_mode + async_scheduling_mode = async_scheduling + run_ref_mamba_state_in_subprocess() + apply_patch(monkeypatch) + prompt_dataset = datasets.load_dataset("heheda/a_long_article") + full_prompt = prompt_dataset["train"][0]["text"] + tests = get_mamba_prefix_cache_step_configs(async_scheduling) engine = LLM( model=MODEL, + load_format="dummy", enable_prefix_caching=True, block_size=BLOCK_SIZE, mamba_cache_mode="align", @@ -738,6 +860,7 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): }, max_num_batched_tokens=3072, hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS}, + async_scheduling=async_scheduling, seed=42, ) global prompt_token_ids @@ -754,16 +877,6 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): ) global cur_step_action_idx cur_step_action_idx = 0 - for step_action_prev, step_action_next in zip( - test_config.step_actions[:-1], test_config.step_actions[1:] - ): - if ( - step_action_next.kv_cache_block_ids is not None - and len(step_action_next.kv_cache_block_ids) == 0 - ): - prev_block_ids = step_action_prev.kv_cache_block_ids - if prev_block_ids is not None: - step_action_next.kv_cache_block_ids = prev_block_ids.copy() global step_actions step_actions = test_config.step_actions _ = engine.generate( @@ -783,3 +896,282 @@ def test_mamba_prefix_cache(monkeypatch: pytest.MonkeyPatch): del engine torch.accelerator.empty_cache() cleanup_dist_env_and_memory() + + +@create_new_process_for_each_test() +def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch): + _run_mamba_prefix_cache_mrv1(monkeypatch, async_scheduling=False) + + +@create_new_process_for_each_test() +def test_mamba_prefix_cache_mrv1_async(monkeypatch: pytest.MonkeyPatch): + _run_mamba_prefix_cache_mrv1(monkeypatch, async_scheduling=True) + + +def _run_mamba_prefix_cache_mrv2( + monkeypatch: pytest.MonkeyPatch, async_scheduling: bool +): + global async_scheduling_mode + async_scheduling_mode = async_scheduling + monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0") + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1") + envs.disable_envs_cache() + + from vllm.v1.worker.gpu.model_runner import GPUModelRunner as MRV2GPUModelRunner + from vllm.v1.worker.gpu.model_states.mamba_hybrid import ( + MambaHybridModelState, + ) + from vllm.v1.worker.gpu.sample.output import SamplerOutput as MRV2SamplerOutput + + events: list[int] = [] + original_execute_model = MRV2GPUModelRunner.execute_model + original_sample = MRV2GPUModelRunner.sample + original_preprocess_state = MambaHybridModelState.preprocess_state + original_postprocess_state = MambaHybridModelState.postprocess_state + original_step_action_fn = InprocClient.get_output + original_allocate_slots = KVCacheManager.allocate_slots + captured: dict[str, Any] = {} + + def temporal_states(model_state, block_tables, kv_cache_config): + # Qwen3-Next keeps the temporal (ssm) state as the last Mamba cache. + forward_context = ( + model_state.vllm_config.compilation_config.static_forward_context + ) + group_ids, _ = get_mamba_groups(kv_cache_config) + for group_id in group_ids: + block_table = block_tables[group_id] + for layer_name in kv_cache_config.kv_cache_groups[group_id].layer_names: + yield forward_context[layer_name].kv_cache[-1], block_table + + def temporal_block(temporal_state, block_table, col): + return temporal_state[int(block_table[0, col].item())] + + def wrapped_preprocess_state( + self: MambaHybridModelState, + input_batch: Any, + block_tables: tuple[torch.Tensor, ...], + kv_cache_config: KVCacheConfig, + num_computed_tokens: torch.Tensor, + ) -> None: + captured["block_tables"] = block_tables + captured["kv_cache_config"] = kv_cache_config + expected = ( + None if cur_step_action is None else cur_step_action.preprocess_copy_idx + ) + snapshots = [] + if expected is not None and expected != (-1, -1): + for temporal, bt in temporal_states(self, block_tables, kv_cache_config): + snapshots.append( + (temporal, bt, temporal_block(temporal, bt, expected[0]).clone()) + ) + ret = original_preprocess_state( + self, input_batch, block_tables, kv_cache_config, num_computed_tokens + ) + if cur_step_action is not None: + req_idx = int(input_batch.idx_mapping[0].item()) + src_col = int(self._mamba_src_col_gpu[req_idx].item()) + off = int(self._mamba_src_off_gpu[req_idx].item()) + dst = int(self._mamba_state_idx_gpu[req_idx].item()) + actual = (-1, -1) if src_col < 0 or src_col == dst else (src_col + off, dst) + assert actual == expected, ( + f"V2 align preprocess copy: expected={expected}, " + f"actual={actual}, {cur_step_action=}" + ) + for temporal, bt, src_state in snapshots: + torch.testing.assert_close( + temporal_block(temporal, bt, expected[1]), src_state + ) + return ret + + def wrapped_postprocess_state( + self: MambaHybridModelState, + idx_mapping: torch.Tensor, + num_sampled: torch.Tensor | int, + num_computed_tokens: torch.Tensor | None = None, + ) -> None: + action = cur_step_action + block_tables = captured.get("block_tables") + kv_cache_config = captured.get("kv_cache_config") + # The postprocess kernel does not expose its indices, so only the copy + # case is checked, by effect: snapshot the src block, expect dst == src. + if ( + action is None + or num_computed_tokens is None + or block_tables is None + or action.postprocess_copy_idx == (-1, -1) + ): + return original_postprocess_state( + self, idx_mapping, num_sampled, num_computed_tokens + ) + expected = action.postprocess_copy_idx + snapshots = [ + (temporal, bt, temporal_block(temporal, bt, expected[0]).clone()) + for temporal, bt in temporal_states(self, block_tables, kv_cache_config) + ] + ret = original_postprocess_state( + self, idx_mapping, num_sampled, num_computed_tokens + ) + for temporal, bt, src_state in snapshots: + torch.testing.assert_close( + temporal_block(temporal, bt, expected[1]), src_state + ) + return ret + + def wrapped_execute_model( + self: MRV2GPUModelRunner, + scheduler_output: SchedulerOutput, + *args: Any, + **kwargs: Any, + ): + events.extend( + req.num_computed_tokens for req in scheduler_output.scheduled_new_reqs + ) + events.extend(scheduler_output.scheduled_cached_reqs.num_computed_tokens) + if cur_step_action is not None: + num_scheduled_tokens = next( + iter(scheduler_output.num_scheduled_tokens.values()) + ) + assert num_scheduled_tokens == cur_step_action.num_scheduled_tokens + ret = original_execute_model(self, scheduler_output, *args, **kwargs) + if cur_step_action is not None and self.execute_model_state is not None: + input_batch = self.execute_model_state.input_batch + assert ( + cur_step_action.num_computed_tokens_start + == input_batch.positions[input_batch.query_start_loc[0]].item() + ) + return ret + + def fake_sample( + self: MRV2GPUModelRunner, + hidden_states: torch.Tensor, + input_batch: Any, + grammar_output: Any, + ): + if cur_step_action is None: + return original_sample(self, hidden_states, input_batch, grammar_output) + + num_reqs = input_batch.num_reqs + sampled_token_ids = torch.ones( + (num_reqs, self.num_speculative_steps + 1), + device=hidden_states.device, + dtype=torch.int64, + ) + num_logits = torch.tensor( + input_batch.cu_num_logits_np[1 : num_reqs + 1] + - input_batch.cu_num_logits_np[:num_reqs], + device=hidden_states.device, + dtype=torch.int32, + ) + accepted = torch.full_like(num_logits, num_accepted_tokens) + num_sampled = torch.minimum(accepted, num_logits) + prefill_lens = self.req_states.prefill_len.gpu[input_batch.idx_mapping] + is_chunked_prefill = input_batch.seq_lens[:num_reqs] < prefill_lens + num_sampled = torch.where(is_chunked_prefill, 0, num_sampled) + num_rejected = torch.where(is_chunked_prefill, 0, num_logits - num_sampled) + sampler_output = MRV2SamplerOutput( + sampled_token_ids=sampled_token_ids, + logprobs_tensors=None, + num_nans=None, + num_sampled=num_sampled, + ) + return sampler_output, num_sampled, num_rejected + + monkeypatch.setattr( + InprocClient, + "get_output", + get_fake_step_action_fn(original_step_action_fn), + ) + monkeypatch.setattr( + KVCacheManager, + "allocate_slots", + get_fake_allocate_slots_fn(original_allocate_slots), + ) + monkeypatch.setattr(MRV2GPUModelRunner, "execute_model", wrapped_execute_model) + monkeypatch.setattr(MRV2GPUModelRunner, "sample", fake_sample) + monkeypatch.setattr( + MambaHybridModelState, "preprocess_state", wrapped_preprocess_state + ) + monkeypatch.setattr( + MambaHybridModelState, "postprocess_state", wrapped_postprocess_state + ) + + engine = LLM( + model=MODEL, + load_format="dummy", + enforce_eager=True, + skip_tokenizer_init=True, + enable_prefix_caching=True, + block_size=BLOCK_SIZE, + mamba_cache_mode="align", + speculative_config={ + "method": "qwen3_next_mtp", + "num_speculative_tokens": num_speculative_tokens, + }, + max_num_batched_tokens=3072, + max_model_len=BLOCK_SIZE * 12, + hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS}, + async_scheduling=async_scheduling, + seed=42, + ) + + try: + tests = get_mamba_prefix_cache_step_configs(async_scheduling) + + global step_actions + global cur_step_action_idx + global num_accepted_tokens + for test_name, test_config in tests.items(): + num_accepted_tokens = test_config.num_accepted_tokens + cur_step_action_idx = 0 + step_actions = test_config.step_actions + sampling_params = SamplingParams( + temperature=0.0, + max_tokens=test_config.num_generated_tokens, + ignore_eos=True, + ) + _ = engine.generate( + [TokensPrompt(prompt_token_ids=[1] * test_config.num_prompt_tokens)], + sampling_params=sampling_params, + ) + assert cur_step_action_idx == len(test_config.step_actions), test_name + assert ( + engine.llm_engine.engine_core.engine_core.scheduler.reset_prefix_cache() + ) + + step_actions = [] + cur_step_action_idx = 0 + num_accepted_tokens = 1 + prompt = TokensPrompt(prompt_token_ids=[1] * (BLOCK_SIZE * 2)) + sampling_params = SamplingParams( + temperature=0.0, + max_tokens=1, + ignore_eos=True, + ) + _ = engine.generate([prompt], sampling_params=sampling_params) + first_event_count = len(events) + _ = engine.generate([prompt], sampling_params=sampling_params) + second_events = events[first_event_count:] + prefix_hits = [ + num_computed_tokens + for num_computed_tokens in second_events + if num_computed_tokens >= BLOCK_SIZE + ] + assert prefix_hits, ( + "Expected the second identical prompt to hit prefix cache, " + f"got events={second_events!r}" + ) + assert engine.llm_engine.engine_core.engine_core.scheduler.reset_prefix_cache() + finally: + del engine + torch.accelerator.empty_cache() + cleanup_dist_env_and_memory() + + +@create_new_process_for_each_test() +def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch): + _run_mamba_prefix_cache_mrv2(monkeypatch, async_scheduling=False) + + +@create_new_process_for_each_test() +def test_mamba_prefix_cache_mrv2_async(monkeypatch: pytest.MonkeyPatch): + _run_mamba_prefix_cache_mrv2(monkeypatch, async_scheduling=True) diff --git a/tests/v1/e2e/general/test_rocm_aiter_custom_ar.py b/tests/v1/e2e/general/test_rocm_aiter_custom_ar.py new file mode 100644 index 000000000000..18e51584fa89 --- /dev/null +++ b/tests/v1/e2e/general/test_rocm_aiter_custom_ar.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +from vllm._aiter_ops import is_aiter_found, rocm_aiter_ops +from vllm.config import CompilationConfig, CompilationMode, CUDAGraphMode +from vllm.envs import disable_envs_cache +from vllm.platforms import current_platform + +from ....conftest import VllmRunner +from ....utils import ( + assert_rocm_custom_allreduce_backend_state_on_worker, + multi_gpu_test, +) + +PROMPTS = ["Hello, my name is", "The capital of France is"] + + +def _run_generation( + vllm_runner: type[VllmRunner], + monkeypatch: pytest.MonkeyPatch, + compilation_config: CompilationConfig, + *, + model: str, + max_tokens: int, + use_aiter_custom_ar: bool, + quick_reduce_quantization: str, +) -> list[tuple[list[int], str]]: + with monkeypatch.context() as m: + m.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1") + m.setenv("VLLM_ROCM_USE_AITER", "1") + m.setenv( + "VLLM_ROCM_USE_AITER_CUSTOM_AR", + "1" if use_aiter_custom_ar else "0", + ) + m.setenv("VLLM_ROCM_QUICK_REDUCE_QUANTIZATION", quick_reduce_quantization) + disable_envs_cache() + rocm_aiter_ops.refresh_env_variables() + + with vllm_runner( + model, + dtype="half", + tensor_parallel_size=2, + compilation_config=compilation_config, + max_model_len=256, + max_num_seqs=len(PROMPTS), + gpu_memory_utilization=0.7, + ) as llm: + llm.get_llm().collective_rpc( + assert_rocm_custom_allreduce_backend_state_on_worker, + args=(use_aiter_custom_ar, quick_reduce_quantization), + ) + + return llm.generate_greedy(PROMPTS, max_tokens) + + +@pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm-only") +@pytest.mark.skipif(not is_aiter_found(), reason="AITER is not installed") +@multi_gpu_test(num_gpus=2) +@pytest.mark.parametrize( + "quick_reduce_quantization", + [ + pytest.param("FP", id="quick-reduce-on"), + pytest.param("NONE", id="quick-reduce-off"), + ], +) +@pytest.mark.parametrize( + "cudagraph_mode", + [ + pytest.param(CUDAGraphMode.NONE, id="cudagraph-none"), + pytest.param(CUDAGraphMode.FULL, id="cudagraph-full"), + ], +) +@pytest.mark.parametrize( + "model,max_tokens", + [ + pytest.param("facebook/opt-125m", 8, id="opt-125m"), + ], +) +def test_rocm_aiter_custom_ar_e2e( + vllm_runner: type[VllmRunner], + monkeypatch: pytest.MonkeyPatch, + cudagraph_mode: CUDAGraphMode, + quick_reduce_quantization: str, + model: str, + max_tokens: int, +): + compilation_mode = ( + CompilationMode.NONE + if cudagraph_mode == CUDAGraphMode.NONE + else CompilationMode.VLLM_COMPILE + ) + compilation_config = CompilationConfig( + mode=compilation_mode, + cudagraph_mode=cudagraph_mode, + ) + + baseline_generations = _run_generation( + vllm_runner, + monkeypatch, + compilation_config, + model=model, + max_tokens=max_tokens, + use_aiter_custom_ar=False, + quick_reduce_quantization=quick_reduce_quantization, + ) + aiter_custom_ar_generations = _run_generation( + vllm_runner, + monkeypatch, + compilation_config, + model=model, + max_tokens=max_tokens, + use_aiter_custom_ar=True, + quick_reduce_quantization=quick_reduce_quantization, + ) + + assert aiter_custom_ar_generations == baseline_generations diff --git a/tests/v1/e2e/spec_decode/test_laguna_dflash.py b/tests/v1/e2e/spec_decode/test_laguna_dflash.py new file mode 100644 index 000000000000..1ba9b9749f15 --- /dev/null +++ b/tests/v1/e2e/spec_decode/test_laguna_dflash.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +import torch + +from tests.utils import large_gpu_mark +from vllm import LLM, SamplingParams +from vllm.distributed import cleanup_dist_env_and_memory + + +def _get_counter(metrics, name: str) -> float: + metric = next((m for m in metrics if m.name == name), None) + assert metric is not None, f"Missing metric: {name}" + return metric.value + + +@pytest.mark.slow_test +@large_gpu_mark(min_gb=80) +def test_laguna_dflash_hf_pair_smoke(monkeypatch): + """Smoke-test the public Laguna XS-2.1 base/DFlash checkpoint pair.""" + monkeypatch.setenv("VLLM_USE_FLASHINFER_SAMPLER", "0") + + llm = LLM( + model="poolside/Laguna-XS-2.1", + trust_remote_code=True, + speculative_config={ + "method": "dflash", + "model": "poolside/Laguna-XS-2.1-DFlash", + "num_speculative_tokens": 15, + }, + max_model_len=8192, + max_num_batched_tokens=65536, + max_num_seqs=32, + enforce_eager=True, + disable_log_stats=False, + ) + + try: + outputs = llm.generate( + [ + "What is the capital of the United Kingdom?", + "Write a Python function that returns the square of a number.", + ], + SamplingParams(temperature=0.0, max_tokens=32, ignore_eos=True), + ) + assert len(outputs) == 2 + assert all(output.outputs[0].text for output in outputs) + + metrics = llm.get_metrics() + num_drafts = _get_counter(metrics, "vllm:spec_decode_num_drafts") + num_accepted = _get_counter(metrics, "vllm:spec_decode_num_accepted_tokens") + + assert num_drafts > 0 + acceptance_len = 1 + (num_accepted / num_drafts) + assert acceptance_len > 1.0 + finally: + del llm + torch.accelerator.empty_cache() + cleanup_dist_env_and_memory() diff --git a/tests/v1/e2e/spec_decode/test_spec_decode.py b/tests/v1/e2e/spec_decode/test_spec_decode.py index a9092bb76634..2eff02ea6ed6 100644 --- a/tests/v1/e2e/spec_decode/test_spec_decode.py +++ b/tests/v1/e2e/spec_decode/test_spec_decode.py @@ -425,7 +425,7 @@ def _run_eagle_correctness( if "deepseek" in model_setup[1].lower(): m.setenv("VLLM_ROCM_USE_AITER", "1") m.delenv("VLLM_MLA_DISABLE", raising=False) - attention_config = {"backend": "TRITON_MLA"} + attention_config = {"backend": "ROCM_AITER_MLA"} else: m.setenv("VLLM_ROCM_USE_AITER", "1") @@ -718,6 +718,47 @@ def test_eagle_correctness_heavy( ) +@large_gpu_mark(min_gb=24) +def test_medusa_acceptance_rate( + sampling_config: SamplingParams, +): + """Verify a trained Medusa checkpoint achieves nonzero acceptance rate. + + Uses the canonical FasterDecoding vicuna-7b checkpoint to confirm the + speculation path actually accepts tokens — unlike test_medusa_correctness, + which uses a random head and only validates output correctness. + """ + target_model = "lmsys/vicuna-7b-v1.3" + medusa_model = "FasterDecoding/medusa-vicuna-7b-v1.3" + prompts = _build_gsm8k_prompts(num_questions=10, num_shots=1)[0] + + spec_llm = LLM( + model=target_model, + speculative_config={ + "method": "medusa", + "model": medusa_model, + "num_speculative_tokens": 3, + }, + max_model_len=1024, + enforce_eager=True, + disable_log_stats=False, + ) + spec_llm.generate(prompts, sampling_config) + metrics = spec_llm.get_metrics() + acceptance_rate = compute_acceptance_rate(metrics) + del spec_llm + torch.accelerator.empty_cache() + cleanup_dist_env_and_memory() + + min_acceptance_rate = 0.198 + print(f"Medusa acceptance rate: {acceptance_rate:.4f} (min {min_acceptance_rate})") + + # Regression guard at 90% of the measured baseline. + assert acceptance_rate >= min_acceptance_rate, ( + f"Medusa acceptance rate {acceptance_rate:.4f} below min {min_acceptance_rate}" + ) + + @pytest.mark.parametrize( ["model_setup", "mm_enabled", "expected_accuracy_threshold"], [ @@ -1300,13 +1341,18 @@ def dflash_config(): ) -def test_dflash_acceptance_rates(dflash_config): +@pytest.mark.parametrize("use_mrv2", [False, True]) +def test_dflash_acceptance_rates( + monkeypatch: pytest.MonkeyPatch, use_mrv2: bool, dflash_config +): """ E2E test for DFlash (block diffusion) speculative decoding. Runs acceptance rate validation on GSM8k, MT-Bench, and HumanEval comparing against baseline results from the paper (Table 1). See https://github.com/z-lab/dflash/blob/main/benchmark_sglang.py for methodology. """ + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if use_mrv2 else "0") + spec_llm = LLM(**dflash_config) max_prompts_per_dataset = 200 # mt-bench has 80, humaneval has 164, truncates gsm8k @@ -1366,6 +1412,67 @@ def test_dflash_acceptance_rates(dflash_config): cleanup_dist_env_and_memory() +@pytest.fixture +def dspark_config(): + target_model = "Qwen/Qwen3-4B-FP8" + draft_model = "deepseek-ai/dspark_qwen3_4b_block7" + + return dict( + model=target_model, + trust_remote_code=True, + speculative_config={ + "method": "dspark", + "model": draft_model, + "num_speculative_tokens": 7, + "attention_backend": "FLASH_ATTN", + "draft_sample_method": "probabilistic", + }, + max_model_len=4096, + disable_log_stats=False, + ) + + +@single_gpu_only +@large_gpu_mark(min_gb=24) +def test_dspark_correctness_and_acceptance_rate(dspark_config): + """ + E2E test for DSpark speculative decoding: acceptance rate/length + regression coverage plus GSM8K correctness, at temperature=1.0 to + exercise the probabilistic draft-sampling/rejection-sampling path + (not just greedy). + + Uses Qwen/Qwen3-4B-FP8 as target with the dspark_qwen3_4b_block7 draft + model. Reference: measured over 12 runs of the full GSM8K set at + temperature=1.0 (prefix caching disabled to avoid cross-run reuse): + accuracy: min=0.782 max=0.814 mean=0.801 + acceptance_rate: min=0.418 max=0.434 mean=0.428 + acceptance_len: min=3.928 max=4.037 mean=3.994 + Thresholds set conservatively to 10% to avoid flaking due to unlucky sampling + """ + spec_llm = LLM(**dspark_config) + + results = evaluate_gsm8k_offline(spec_llm, temperature=1.0) + gsm8k_accuracy = results["accuracy"] + + metrics = spec_llm.get_metrics() + acceptance_rate = compute_acceptance_rate(metrics) + acceptance_len = compute_acceptance_len(metrics) + + print( + f"DSpark acceptance_rate={acceptance_rate:.2f}, " + f"acceptance_len={acceptance_len:.2f}, " + f"gsm8k_accuracy={gsm8k_accuracy:.3f}" + ) + + assert acceptance_rate >= 0.428 * 0.9 + assert acceptance_len >= 3.994 * 0.9 + assert gsm8k_accuracy >= 0.801 * 0.9 + + del spec_llm + torch.accelerator.empty_cache() + cleanup_dist_env_and_memory() + + @single_gpu_only def test_synthetic_acceptance_rate(): """Verify that synthetic rejection sampling produces an acceptance @@ -1414,11 +1521,16 @@ def test_synthetic_acceptance_rate(): cleanup_dist_env_and_memory() -def test_dflash_correctness(dflash_config): +@pytest.mark.parametrize("use_mrv2", [False, True]) +def test_dflash_correctness( + monkeypatch: pytest.MonkeyPatch, use_mrv2: bool, dflash_config +): """ E2E test for DFlash (block diffusion) speculative decoding. Ensures output correctness on GSM8k, with cudagraphs and batching on. """ + monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1" if use_mrv2 else "0") + spec_llm = LLM(**dflash_config) # Evaluate GSM8k accuracy (Qwen3-8B ref: ~87-92% on GSM8k) diff --git a/tests/v1/e2e/test_cpu_linear_attn_chunked_prefix.py b/tests/v1/e2e/test_cpu_linear_attn_chunked_prefix.py new file mode 100644 index 000000000000..71484d3b05c0 --- /dev/null +++ b/tests/v1/e2e/test_cpu_linear_attn_chunked_prefix.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""CPU chunked-prefill / prefix-caching correctness for linear-attention models.""" + +import os + +import pytest + +from tests.models.utils import check_logprobs_close +from vllm import LLM, SamplingParams +from vllm.platforms import current_platform + +if not current_platform.is_cpu(): + pytest.skip("skipping CPU-only tests", allow_module_level=True) + +# Bound the KV cache so the run does not scale with host memory; these engines +# only need a few thousand tokens. +os.environ.setdefault("VLLM_CPU_KVCACHE_SPACE", "1") + +MODEL = "Qwen/Qwen3.5-0.8B" +CHUNK_TOKENS = 128 # max_num_batched_tokens for the chunked engine +NUM_LOGPROBS = 5 +SP = SamplingParams(max_tokens=32, temperature=0, logprobs=NUM_LOGPROBS) + + +def _long_prompt(repeat: int) -> str: + return "Solve the following arithmetic step by step. " * repeat + "What is 7*8?" + + +# Prompts long enough to span several CHUNK_TOKENS-sized chunks; a single-chunk +# prompt is bit-identical to full prefill regardless of the bug. +PROMPTS = [_long_prompt(r) for r in (40, 60, 80)] +# Spans several full cache blocks; prefix caching only reuses complete blocks. +PREFIX_PROMPT = "You are a helpful assistant. " * 230 + " Now answer: what is 2+2?" + + +def _make_llm(**overrides) -> LLM: + base = dict( + model=MODEL, + dtype="bfloat16", + max_model_len=2048, + enforce_eager=True, + trust_remote_code=True, + ) + base.update(overrides) + return LLM(**base) + + +def _tuples(outputs) -> list[tuple[list[int], str, object]]: + """(token_ids, text, sample_logprobs) per request, for check_logprobs_close.""" + return [ + (list(o.outputs[0].token_ids), o.outputs[0].text, o.outputs[0].logprobs) + for o in outputs + ] + + +@pytest.fixture(scope="module") +def full_prefill_refs(): + """Reference (ids, text, logprobs) for PROMPTS and PREFIX_PROMPT, full prefill.""" + llm = _make_llm(enable_chunked_prefill=False, enable_prefix_caching=False) + refs = _tuples(llm.generate(PROMPTS, SP)) + prefix_ref = _tuples(llm.generate([PREFIX_PROMPT], SP))[0] + del llm + return refs, prefix_ref + + +def test_chunked_prefill_matches_full_prefill(full_prefill_refs): + """Batched multi-chunk prefill must stay close to per-prompt full prefill. + + Prompts are scheduled together so the scheduler interleaves prefill chunks + across requests (the cross-request path where the accuracy gap was strongest). + """ + refs, _ = full_prefill_refs + llm = _make_llm( + enable_chunked_prefill=True, + max_num_batched_tokens=CHUNK_TOKENS, + enable_prefix_caching=False, + ) + got = _tuples(llm.generate(PROMPTS, SP)) + del llm + + check_logprobs_close( + outputs_0_lst=refs, + outputs_1_lst=got, + name_0="full_prefill", + name_1="chunked_prefill", + ) + + +def test_prefix_cache_hit_matches_cold_cache(full_prefill_refs): + """A prefix-cache hit must stay close to the cold-cache (reference) output. + + The warm run continues prefill from the restored GDN state; the + num_cached_tokens check guards against a vacuous (no-hit) pass. + """ + _, ref = full_prefill_refs + llm = _make_llm(enable_prefix_caching=True) + llm.generate([PREFIX_PROMPT], SP) # prime the cache + warm_out = llm.generate([PREFIX_PROMPT], SP)[0] + warm = _tuples([warm_out])[0] + del llm + + assert warm_out.num_cached_tokens > 0, ( + "expected a prefix-cache hit but num_cached_tokens=0; " + "PREFIX_PROMPT may be shorter than one cache block" + ) + check_logprobs_close( + outputs_0_lst=[ref], + outputs_1_lst=[warm], + name_0="cold_cache", + name_1="warm_cache", + ) diff --git a/tests/v1/engine/test_async_llm.py b/tests/v1/engine/test_async_llm.py index 92de5a7e9819..afb6e4c98b78 100644 --- a/tests/v1/engine/test_async_llm.py +++ b/tests/v1/engine/test_async_llm.py @@ -512,12 +512,11 @@ async def test_header_dp_rank_argument(): ) # Create render serving instance (required by OpenAIServingChat) - from vllm.entrypoints.serve.render.serving import OpenAIServingRender + from vllm.renderers.online_renderer import OnlineRenderer - serving_render = OpenAIServingRender( + online_renderer = OnlineRenderer( model_config=engine.model_config, renderer=engine.renderer, - model_registry=models.registry, request_logger=None, chat_template=None, chat_template_content_format="auto", @@ -528,7 +527,7 @@ async def test_header_dp_rank_argument(): engine_client=engine, models=models, response_role="assistant", - openai_serving_render=serving_render, + online_renderer=online_renderer, chat_template=None, chat_template_content_format="auto", request_logger=None, diff --git a/tests/v1/engine/test_core_engine_actor_manager.py b/tests/v1/engine/test_core_engine_actor_manager.py index f60f8c94e7e2..a986bc07a3e8 100644 --- a/tests/v1/engine/test_core_engine_actor_manager.py +++ b/tests/v1/engine/test_core_engine_actor_manager.py @@ -8,6 +8,7 @@ from pathlib import Path from types import SimpleNamespace from typing import Any +from unittest.mock import Mock import pytest import ray @@ -15,6 +16,7 @@ from vllm.utils.network_utils import make_zmq_socket, split_zmq_path from vllm.v1.engine.core import EngineCoreActorMixin +from vllm.v1.engine.core_client import BackgroundResources from vllm.v1.engine.utils import ( CoreEngineActorManager, EngineZmqAddresses, @@ -99,6 +101,17 @@ class _DummyExecutor: pass +def test_background_resources_passes_worker_shutdown_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + timeout = 7 + monkeypatch.setenv("VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS", str(timeout)) + engine_manager = Mock() + resources = BackgroundResources(ctx=None, engine_manager=engine_manager) + resources() + engine_manager.shutdown.assert_called_once_with(timeout=timeout) + + def _make_vllm_config() -> SimpleNamespace: return SimpleNamespace( parallel_config=SimpleNamespace( diff --git a/tests/v1/engine/test_dp_placement_node_allowlist.py b/tests/v1/engine/test_dp_placement_node_allowlist.py new file mode 100644 index 000000000000..1fd6f34fed45 --- /dev/null +++ b/tests/v1/engine/test_dp_placement_node_allowlist.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for VLLM_RAY_DP_PLACEMENT_NODE_IPS.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +import vllm.v1.engine.utils as utils +from vllm.v1.engine.utils import CoreEngineActorManager + + +def _vllm_config( + *, dp_size, dp_local, master_ip, world_size=1, all2all_backend="naive" +): + parallel = SimpleNamespace( + data_parallel_master_ip=master_ip, + data_parallel_size=dp_size, + data_parallel_size_local=dp_local, + world_size=world_size, + all2all_backend=all2all_backend, + ) + return SimpleNamespace(parallel_config=parallel) + + +def _resources(node_gpus: dict[str, int]): + # node_gpus: {ip: gpu_count}; plus a CPU-only head node. + res = { + f"id-{ip}": {"GPU": float(g), f"node:{ip}": 1.0} for ip, g in node_gpus.items() + } + res["id-head"] = { + "CPU": 8.0, + "node:__internal_head__": 1.0, + "node:10.9.9.9": 1.0, + } + return res + + +def _run(cfg, resources): + created = [] + + def fake_pg(name, strategy, bundles): + created.append({"name": name, "strategy": strategy, "bundles": bundles}) + return object() + + with ( + patch( + "ray._private.state.available_resources_per_node", + return_value=resources, + ), + patch.object(utils, "current_platform", SimpleNamespace(ray_device_key="GPU")), + patch("ray.util.placement_group", side_effect=fake_pg), + ): + pgs, local_ranks = CoreEngineActorManager.create_dp_placement_groups(cfg) + return pgs, local_ranks, created + + +def _pinned_ips(created): + return { + key.split(":", 1)[1] + for pg in created + for bundle in pg["bundles"] + for key in bundle + if key.startswith("node:") + } + + +def test_allowlist_confines_dp_to_listed_nodes(monkeypatch): + monkeypatch.setenv("VLLM_RAY_DP_PLACEMENT_NODE_IPS", "10.0.0.1,10.0.0.3") + resources = _resources({"10.0.0.1": 8, "10.0.0.2": 8, "10.0.0.3": 8, "10.0.0.4": 8}) + cfg = _vllm_config(dp_size=16, dp_local=8, master_ip="10.0.0.1") + + pgs, _, created = _run(cfg, resources) + + assert len(pgs) == 16 # 8 on .1 (master) + 8 on .3 + assert _pinned_ips(created) <= {"10.0.0.1", "10.0.0.3"} + + +def test_empty_allowlist_is_noop(monkeypatch): + monkeypatch.delenv("VLLM_RAY_DP_PLACEMENT_NODE_IPS", raising=False) + resources = _resources({"10.0.0.1": 8, "10.0.0.2": 8}) + cfg = _vllm_config(dp_size=16, dp_local=8, master_ip="10.0.0.1") + + pgs, _, created = _run(cfg, resources) + + assert len(pgs) == 16 + assert _pinned_ips(created) == {"10.0.0.1", "10.0.0.2"} # all nodes used + + +def test_master_auto_added_with_warning(monkeypatch): + # Allowlist omits the master; vLLM must still keep it and warn. + monkeypatch.setenv("VLLM_RAY_DP_PLACEMENT_NODE_IPS", "10.0.0.3") + resources = _resources({"10.0.0.1": 8, "10.0.0.3": 8}) + cfg = _vllm_config(dp_size=16, dp_local=8, master_ip="10.0.0.1") + _, _, created = _run(cfg, resources) + + assert _pinned_ips(created) == {"10.0.0.1", "10.0.0.3"} + + +def test_allowlist_isolates_two_engines(monkeypatch): + # Engine B is confined to .2/.4, so it can never touch engine A's master .1. + monkeypatch.setenv("VLLM_RAY_DP_PLACEMENT_NODE_IPS", "10.0.0.2,10.0.0.4") + resources = _resources({"10.0.0.1": 8, "10.0.0.2": 8, "10.0.0.3": 8, "10.0.0.4": 8}) + cfg = _vllm_config(dp_size=16, dp_local=8, master_ip="10.0.0.2") + + _, _, created = _run(cfg, resources) + + assert _pinned_ips(created) <= {"10.0.0.2", "10.0.0.4"} + + +def test_allowlist_too_small_raises(monkeypatch): + # Master alone can't hold all ranks and no other node is allowed. + monkeypatch.setenv("VLLM_RAY_DP_PLACEMENT_NODE_IPS", "10.0.0.1") + resources = _resources({"10.0.0.1": 8, "10.0.0.2": 8}) + cfg = _vllm_config(dp_size=16, dp_local=8, master_ip="10.0.0.1") + + with pytest.raises(ValueError): # not enough placement groups created + _run(cfg, resources) diff --git a/tests/v1/engine/test_engine_core_client.py b/tests/v1/engine/test_engine_core_client.py index 36dc95eea498..0b44b205cd4d 100644 --- a/tests/v1/engine/test_engine_core_client.py +++ b/tests/v1/engine/test_engine_core_client.py @@ -255,6 +255,8 @@ def test_apply_ready_response_syncs_block_size(): dp_stats_address=None, dtype="bfloat16", vllm_version="test", + world_size=1, + data_parallel_size=1, ) ) client._apply_ready_response(payload) diff --git a/tests/v1/engine/utils.py b/tests/v1/engine/utils.py index 013e73bd8e48..324c9c9ad568 100644 --- a/tests/v1/engine/utils.py +++ b/tests/v1/engine/utils.py @@ -7,14 +7,14 @@ import numpy as np import torch -from transformers import PreTrainedTokenizer, PreTrainedTokenizerFast +from transformers import PythonBackend, TokenizersBackend from vllm.engine.arg_utils import EngineArgs from vllm.v1.engine import EngineCoreOutput, FinishReason from vllm.v1.metrics.stats import PrefillStats from vllm.v1.outputs import LogprobsLists, LogprobsTensors -GeneralTokenizerType: TypeAlias = PreTrainedTokenizer | PreTrainedTokenizerFast +GeneralTokenizerType: TypeAlias = PythonBackend | TokenizersBackend # Number of sample logprobs to request when testing sample logprobs NUM_SAMPLE_LOGPROBS_UNDER_TEST = 5 @@ -193,7 +193,7 @@ def _create_random_top_token_test_matrix( def decode_token( tok_id: int, - tokenizer: PreTrainedTokenizer, + tokenizer: PythonBackend, ) -> str: """Reproduce the process of detokenizing a token for testing purposes. @@ -210,7 +210,7 @@ def decode_token( def generate_dummy_sample_logprobs( sampled_tokens_list: list, num_logprobs: int, - tokenizer: PreTrainedTokenizer, + tokenizer: PythonBackend, ) -> list[tuple[list[int], list[float], int]]: """Generate dummy sample logprobs @@ -259,7 +259,7 @@ def generate_dummy_sample_logprobs( def generate_dummy_prompt_logprobs_tensors( prompt_tokens_list: list, num_logprobs: int, - tokenizer: PreTrainedTokenizer, + tokenizer: PythonBackend, ) -> LogprobsTensors: """Generate dummy prompt logprobs tensors diff --git a/tests/v1/executor/test_executor.py b/tests/v1/executor/test_executor.py index 494e8aa67dd8..c529c3204d50 100644 --- a/tests/v1/executor/test_executor.py +++ b/tests/v1/executor/test_executor.py @@ -14,6 +14,7 @@ from vllm.sampling_params import SamplingParams from vllm.v1.engine.async_llm import AsyncLLM from vllm.v1.engine.llm_engine import LLMEngine +from vllm.v1.executor import multiproc_executor as multiproc_executor_module from vllm.v1.executor.abstract import Executor from vllm.v1.executor.multiproc_executor import MultiprocExecutor from vllm.v1.executor.uniproc_executor import ( @@ -43,6 +44,50 @@ def test_supports_async_scheduling_multiproc_executor(): assert MultiprocExecutor.supports_async_scheduling() is True +class _FakeClock: + def __init__(self) -> None: + self.now = 0.0 + + def time(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +class _FakeProcess: + def __init__(self, clock: _FakeClock, exits_at: float) -> None: + self.clock = clock + self.exits_at = exits_at + self.terminate_called = False + + def is_alive(self) -> bool: + return self.clock.time() < self.exits_at + + def terminate(self) -> None: + self.terminate_called = True + + +@pytest.mark.parametrize( + ("timeout", "exits_at", "expected_terminate"), + [ + pytest.param(6, 5, False, id="worker-exits-before-timeout"), + pytest.param(6, 7, True, id="worker-exceeds-timeout"), + ], +) +def test_multiproc_executor_worker_termination_timeout( + monkeypatch, timeout, exits_at, expected_terminate +): + monkeypatch.setenv("VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS", str(timeout)) + clock = _FakeClock() + monkeypatch.setattr(multiproc_executor_module.time, "time", clock.time) + monkeypatch.setattr(multiproc_executor_module.time, "sleep", clock.sleep) + executor = MultiprocExecutor.__new__(MultiprocExecutor) + proc = _FakeProcess(clock, exits_at=exits_at) + executor._ensure_worker_termination([proc]) + assert proc.terminate_called is expected_terminate + + class CustomMultiprocExecutor(MultiprocExecutor): def collective_rpc( self, diff --git a/tests/v1/kv_connector/extract_hidden_states_integration/test_extraction.py b/tests/v1/kv_connector/extract_hidden_states_integration/test_extraction.py index 5cc19247f515..4a84ed031495 100644 --- a/tests/v1/kv_connector/extract_hidden_states_integration/test_extraction.py +++ b/tests/v1/kv_connector/extract_hidden_states_integration/test_extraction.py @@ -1,44 +1,41 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import gc import os +import tempfile import pytest import torch -from safetensors import safe_open +from tests.utils import create_new_process_for_each_test, multi_gpu_test from vllm import LLM, ModelRegistry, SamplingParams +from vllm.distributed.kv_transfer.kv_connector.v1 import ( + example_hidden_states_connector, +) +from vllm.platforms import current_platform def get_and_check_output(output, expected_shape): assert output.kv_transfer_params is not None hidden_states_path = output.kv_transfer_params.get("hidden_states_path") assert hidden_states_path is not None - assert os.path.exists(hidden_states_path) - # Load and verify the saved tensors - with safe_open(hidden_states_path, "pt") as f: - # Check that token_ids and hidden_states are present - tensor_names = f.keys() - assert "token_ids" in tensor_names - assert "hidden_states" in tensor_names + obj = example_hidden_states_connector.load_hidden_states(hidden_states_path) + token_ids = obj["token_ids"] + hidden_states = obj["hidden_states"] - token_ids = f.get_tensor("token_ids") - hidden_states = f.get_tensor("hidden_states") + prompt_token_ids = output.prompt_token_ids + assert torch.equal(token_ids, torch.tensor(prompt_token_ids)) - prompt_token_ids = output.prompt_token_ids - assert torch.equal(token_ids, torch.tensor(prompt_token_ids)) + assert hidden_states.shape == expected_shape - assert hidden_states.shape == expected_shape - - # Verify hidden_states are not all zeros (i.e., they were actually computed) - assert not torch.allclose(hidden_states, torch.zeros_like(hidden_states)) + # Verify hidden_states are not all zeros (i.e., they were actually computed) + assert not torch.allclose(hidden_states, torch.zeros_like(hidden_states)) return token_ids, hidden_states -@pytest.fixture(scope="module") +@pytest.fixture def predictable_llama_config_path(tmp_path_factory): """Create a minimal LlamaConfig for PredictableLlamaForCausalLM.""" from transformers import LlamaConfig, LlamaTokenizerFast @@ -53,7 +50,7 @@ def predictable_llama_config_path(tmp_path_factory): num_hidden_layers=24, # Enough layers to test various layer_ids num_attention_heads=4, num_key_value_heads=4, - max_position_embeddings=128, + max_position_embeddings=1024, architectures=["PredictableLlamaForCausalLM"], ) @@ -85,24 +82,25 @@ def register_predictable_model(): def test_extract_hidden_states_with_predictable_dummy_model( predictable_llama_config_path, tmp_path, monkeypatch ): - """Comprehensive test using a predictable dummy model with synthetic weights. - - The PredictableLlamaForCausalLM outputs deterministic hidden states where - each layer produces values equal to (layer_index). This test verifies: - 1. Hidden states are correctly extracted from requested layers - 2. Values match the expected predictable pattern - 3. Layer ordering is preserved correctly (non-sequential layer IDs) - 4. Multiple prompts of different lengths produce consistent layer values + """Test hidden-state extraction with a predictable dummy model. + + Tests 3 scenarios: + + 1. **Basic extraction**: non-sequential layer ordering, multiple prompts + of varying length — verifies correct layer association and + deterministic values. + 2. **Chunked prefill**: max_num_batched_tokens=128 with ~500-token + prompts so each is split across multiple scheduler iterations — + verifies hidden states are reassembled correctly. + 3. **Per-request options**: custom hidden_states_path and + include_output_tokens — verifies per-request kv_transfer_params + plumbing. """ - # Force fork so the engine worker inherits the autouse fixture's - # ModelRegistry.register_model("PredictableLlamaForCausalLM", ...). - # Spawn (the CI default) starts a fresh Python process that wouldn't - # see the registration. monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "fork") - # Test with non-sequential layer ordering to verify correct association layer_ids = [5, 2, 10] num_layers = len(layer_ids) + max_num_batched_tokens = 128 llm = LLM( model=predictable_llama_config_path, @@ -116,16 +114,21 @@ def test_extract_hidden_states_with_predictable_dummy_model( kv_transfer_config={ "kv_connector": "ExampleHiddenStatesConnector", "kv_role": "kv_producer", - "kv_connector_extra_config": {"shared_storage_path": tmp_path}, + "kv_connector_extra_config": { + "shared_storage_path": tmp_path, + "allow_custom_save_path": True, + }, }, - max_model_len=128, + max_model_len=1024, + max_num_batched_tokens=max_num_batched_tokens, enforce_eager=True, - enable_chunked_prefill=False, trust_remote_code=True, - load_format="dummy", # Don't try to load real weights + load_format="dummy", ) - # Test with multiple prompts of different lengths + hidden_size = llm.llm_engine.model_config.get_hidden_size() + + # --- Scenario 1: basic extraction with non-sequential layers ---------- prompts = [ "Short", "Medium length", @@ -133,15 +136,10 @@ def test_extract_hidden_states_with_predictable_dummy_model( "Much longer prompt with many tokens", # repeated prompt ] sampling_params = SamplingParams(max_tokens=1, temperature=0.0) - hidden_size = llm.llm_engine.model_config.get_hidden_size() outputs = llm.generate(prompts, sampling_params) - del llm - gc.collect() assert len(outputs) == len(prompts) - for output in outputs: - # hidden_states shape is [prompt_len, num_hidden_layers, hidden_size] expected_shape = ( len(output.prompt_token_ids), num_layers, @@ -156,12 +154,100 @@ def test_extract_hidden_states_with_predictable_dummy_model( torch.full_like(layer_hidden, layer_id), atol=1e-5, ), ( - f"Layer {layer_id} at position {idx} should output {float(layer_id)}, " - f"but got mean={layer_hidden.mean():.3f}, " - f"min={layer_hidden.min():.3f}, max={layer_hidden.max():.3f}" + f"Layer {layer_id} at position {idx} should output " + f"{float(layer_id)}, but got mean=" + f"{layer_hidden.mean():.3f}, min=" + f"{layer_hidden.min():.3f}, max={layer_hidden.max():.3f}" ) + # --- Scenario 2: chunked prefill with long prompts -------------------- + long_prompt = " ".join(["word"] * 500) + chunked_prompts = [ + long_prompt, + long_prompt + " extra tokens here", + "Short", + ] + outputs = llm.generate(chunked_prompts, sampling_params) + assert len(outputs) == len(chunked_prompts) + for output in outputs: + prompt_len = len(output.prompt_token_ids) + expected_shape = (prompt_len, num_layers, hidden_size) + _token_ids, hidden_states = get_and_check_output(output, expected_shape) + + for idx, layer_id in enumerate(layer_ids): + layer_hidden = hidden_states[:, idx, :] + assert torch.allclose( + layer_hidden, + torch.full_like(layer_hidden, layer_id), + atol=1e-5, + ), ( + f"Layer {layer_id} at position {idx} should output " + f"{float(layer_id)}, but got mean=" + f"{layer_hidden.mean():.3f}, min=" + f"{layer_hidden.min():.3f}, max=" + f"{layer_hidden.max():.3f}. " + f"prompt_len={prompt_len}, " + f"max_num_batched_tokens={max_num_batched_tokens}" + ) + + # --- Scenario 3: per-request options ---------------------------------- + max_tokens = 5 + custom_path = os.path.join(tmp_path, "subdir", "custom.safetensors") + + sampling_params_list = [ + SamplingParams(max_tokens=max_tokens, temperature=0.0), + SamplingParams( + max_tokens=max_tokens, + temperature=0.0, + extra_args={ + "kv_transfer_params": { + "hidden_states_path": custom_path, + "include_output_tokens": True, + } + }, + ), + ] + per_req_prompts = ["Short", "Medium length"] + outputs = llm.generate(per_req_prompts, sampling_params_list) + + # First output: prompt-only hidden states, default path + out0 = outputs[0] + path0 = out0.kv_transfer_params["hidden_states_path"] + assert path0 != custom_path + obj0 = example_hidden_states_connector.load_hidden_states(path0) + assert torch.equal(obj0["token_ids"], torch.tensor(out0.prompt_token_ids)) + assert obj0["hidden_states"].shape == ( + len(out0.prompt_token_ids), + num_layers, + hidden_size, + ) + example_hidden_states_connector.cleanup_hidden_states(path0) + + # Second output: prompt + output tokens, custom path + out1 = outputs[1] + assert out1.kv_transfer_params["hidden_states_path"] == custom_path + obj1 = example_hidden_states_connector.load_hidden_states(custom_path) + token_ids = obj1["token_ids"] + hidden_states = obj1["hidden_states"] + # The final output token was never an input to the model, so its hidden + # state is not in the cache — hence the -1. + total_tokens = len(out1.prompt_token_ids) + len(out1.outputs[0].token_ids) - 1 + assert token_ids.shape[0] == total_tokens + assert hidden_states.shape == (total_tokens, num_layers, hidden_size) + + # Verify predictable layer values hold for all tokens (prompt + output) + for idx, layer_id in enumerate(layer_ids): + layer_hidden = hidden_states[:, idx, :] + assert torch.allclose( + layer_hidden, + torch.full_like(layer_hidden, layer_id), + atol=1e-5, + ) + example_hidden_states_connector.cleanup_hidden_states(custom_path) + + +@create_new_process_for_each_test() def test_extract_hidden_states_qwen35_hybrid_smoke(tmp_path): """Smoke test for Qwen3.5 hybrid (mamba + full-attention) models. Uses load_format="dummy" to just check shape/plumbing. @@ -185,7 +271,6 @@ def test_extract_hidden_states_qwen35_hybrid_smoke(tmp_path): }, max_model_len=256, enforce_eager=True, - enable_chunked_prefill=False, gpu_memory_utilization=0.4, load_format="dummy", ) @@ -193,19 +278,68 @@ def test_extract_hidden_states_qwen35_hybrid_smoke(tmp_path): prompts = ["Hello world", "Test prompt with several tokens"] sampling_params = SamplingParams(max_tokens=1, temperature=0.0) outputs = llm.generate(prompts, sampling_params) - del llm - gc.collect() assert len(outputs) == len(prompts) for output in outputs: assert output.kv_transfer_params is not None hidden_states_path = output.kv_transfer_params.get("hidden_states_path") assert hidden_states_path is not None - assert os.path.exists(hidden_states_path) - with safe_open(hidden_states_path, "pt") as f: - token_ids = f.get_tensor("token_ids") - hidden_states = f.get_tensor("hidden_states") + obj = example_hidden_states_connector.load_hidden_states(hidden_states_path) + token_ids = obj["token_ids"] + hidden_states = obj["hidden_states"] + + assert torch.equal(token_ids, torch.tensor(output.prompt_token_ids)) + assert hidden_states.shape == ( + len(output.prompt_token_ids), + len(layer_ids), + hidden_size, + ) + + +@pytest.mark.timeout(240 if current_platform.is_rocm() else 60) +@multi_gpu_test(num_gpus=2) +@create_new_process_for_each_test() +def test_extract_hidden_states_tp2(): + """Test that hidden states extraction works with tensor_parallel_size=2.""" + tmp_dir = tempfile.mkdtemp() + layer_ids = [5, 11, 17] + hidden_size = 1024 # Qwen/Qwen3-0.6B hidden_size + + llm = LLM( + model="Qwen/Qwen3-0.6B", + tensor_parallel_size=2, + speculative_config={ + "method": "extract_hidden_states", + "num_speculative_tokens": 1, + "draft_model_config": { + "hf_config": {"eagle_aux_hidden_state_layer_ids": layer_ids} + }, + }, + kv_transfer_config={ + "kv_connector": "ExampleHiddenStatesConnector", + "kv_role": "kv_producer", + "kv_connector_extra_config": {"shared_storage_path": tmp_dir}, + }, + max_model_len=256, + enforce_eager=True, + gpu_memory_utilization=0.4, + load_format="dummy", + ) + + prompts = ["Hello world", "Test prompt with several tokens"] + sampling_params = SamplingParams(max_tokens=1, temperature=0.0) + outputs = llm.generate(prompts, sampling_params) + + assert len(outputs) == len(prompts) + for output in outputs: + assert output.kv_transfer_params is not None + hidden_states_path = output.kv_transfer_params.get("hidden_states_path") + assert hidden_states_path is not None + + obj = example_hidden_states_connector.load_hidden_states(hidden_states_path) + token_ids = obj["token_ids"] + hidden_states = obj["hidden_states"] assert torch.equal(token_ids, torch.tensor(output.prompt_token_ids)) assert hidden_states.shape == ( diff --git a/tests/v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh b/tests/v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh index d0a56304f2a6..acc7ce312f54 100755 --- a/tests/v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh +++ b/tests/v1/kv_connector/nixl_integration/config_sweep_accuracy_test.sh @@ -13,30 +13,33 @@ tp_configs=( "GPU_MEMORY_UTILIZATION=0.6 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=2" "GPU_MEMORY_UTILIZATION=0.6 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2" "GPU_MEMORY_UTILIZATION=0.6 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=1" - "GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # MLA case + "GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" "GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" "GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=1 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" ) dp_ep_configs=( -"DP_EP=1 GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # MLA+P-TP1, D-DPEP=2 (TP=1) -"DP_EP=1 GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # MLA+P-TP2, D-DPEP=2 (TP=1) +"DP_EP=1 GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # P-TP1, D-DPEP=2 (TP=1) +"DP_EP=1 GPU_MEMORY_UTILIZATION=0.8 PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=2 MODEL_NAMES=deepseek-ai/deepseek-vl2-tiny" # P-TP2, D-DPEP=2 (TP=1) ) # We assume HMA enabled by default. hybrid_ssm_configs=( "VLLM_SSM_CONV_STATE_LAYOUT=DS GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=ibm-granite/granite-4.0-h-tiny VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192,--trust-remote-code" - # TODO: (NickLucche) Address async scheduling issue with TP>1 separately as this may impact other models. - "VLLM_SSM_CONV_STATE_LAYOUT=DS PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=2 GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=ibm-granite/granite-4.0-h-tiny VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192,--trust-remote-code,--no-async-scheduling" + "VLLM_SSM_CONV_STATE_LAYOUT=DS PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=2 GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=ibm-granite/granite-4.0-h-tiny VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192,--trust-remote-code" # GDN (Qwen3.5) "VLLM_SSM_CONV_STATE_LAYOUT=DS GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=Qwen/Qwen3.5-0.8B" - "VLLM_SSM_CONV_STATE_LAYOUT=DS PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=Qwen/Qwen3.5-0.8B VLLM_SERVE_EXTRA_ARGS=--no-async-scheduling" + "VLLM_SSM_CONV_STATE_LAYOUT=DS PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=Qwen/Qwen3.5-0.8B" + "VLLM_SSM_CONV_STATE_LAYOUT=DS ENFORCE_EAGER=0 GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=Qwen/Qwen3.5-0.8B VLLM_SERVE_EXTRA_ARGS=--spec-method,mtp,--spec-tokens,1" + # Mamba1 (Jamba) + "VLLM_SSM_CONV_STATE_LAYOUT=DS GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=ai21labs/AI21-Jamba2-3B VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192" ) sw_attn_configs=( # NOTE: gemma3 does not work with FlashInfer "GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=google/gemma-3-4b-it VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192" # SW model "GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=google/gemma-3-4b-it PREFILLER_TP_SIZE=1 DECODER_TP_SIZE=2 VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192" "GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=google/gemma-3-4b-it PREFILLER_TP_SIZE=2 DECODER_TP_SIZE=1 VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192" + # Gemma4: SW + cross-layer KV sharing + "GPU_MEMORY_UTILIZATION=0.8 MODEL_NAMES=google/gemma-4-E2B-it VLLM_SERVE_EXTRA_ARGS=--max-model-len,8192" ) - # Select config array based on DP_EP env var if [[ -n "${DP_EP:-}" ]]; then configs=("${dp_ep_configs[@]}") @@ -75,7 +78,11 @@ run_tests() { # Set backend label="default backend" cmdline_args="" -if [[ -n "${ROCM_ATTN:-}" ]]; then +if [[ -n "${ATTENTION_BACKEND:-}" ]]; then + echo "ATTENTION_BACKEND is set, running with --attention-backend ${ATTENTION_BACKEND}" + label="${ATTENTION_BACKEND} backend" + cmdline_args=" --attention-backend ${ATTENTION_BACKEND} " +elif [[ -n "${ROCM_ATTN:-}" ]]; then echo "ROCM_ATTN is set, running with --attention-backend ROCM_ATTN" label="ROCM_ATTN backend" cmdline_args=" --attention-backend ROCM_ATTN " diff --git a/tests/v1/kv_connector/nixl_integration/nixl_side_channel_probe.py b/tests/v1/kv_connector/nixl_integration/nixl_side_channel_probe.py index 24ecbd795e41..36b7deef04e9 100644 --- a/tests/v1/kv_connector/nixl_integration/nixl_side_channel_probe.py +++ b/tests/v1/kv_connector/nixl_integration/nixl_side_channel_probe.py @@ -15,6 +15,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--host", required=True) parser.add_argument("--port", required=True, type=int) + parser.add_argument("--pp-rank", default=0, type=int) parser.add_argument("--rank", default=0, type=int) parser.add_argument("--timeout-ms", default=1000, type=int) return parser.parse_args() @@ -37,7 +38,7 @@ def main() -> None: sock.setsockopt(zmq.RCVTIMEO, args.timeout_ms) try: sock.connect(make_zmq_path(args.host, args.port)) - sock.send(msgspec.msgpack.encode((GET_META_MSG, args.rank))) + sock.send(msgspec.msgpack.encode((GET_META_MSG, args.pp_rank, args.rank))) sock.recv() finally: sock.close() diff --git a/tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh b/tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh index bde246c9b661..a357128d3ce4 100755 --- a/tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh +++ b/tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh @@ -49,11 +49,16 @@ else KV_EXTRA_CONFIG='' fi -# Build the kv-transfer-config once +# Connector: default pull NixlConnector; NixlPushConnector enables PP prefill. +KV_CONNECTOR=${KV_CONNECTOR:-NixlConnector} + +# Build the kv-transfer-config for P and D if [[ "$KV_BUFFER_DEVICE" == "cuda" ]]; then - KV_CONFIG='{"kv_connector":"NixlConnector","kv_role":"kv_both"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}' + KV_CONFIG_P='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_producer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}' + KV_CONFIG_D='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_consumer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}' else - KV_CONFIG="{\"kv_connector\":\"NixlConnector\",\"kv_role\":\"kv_both\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}" + KV_CONFIG_P="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_producer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}" + KV_CONFIG_D="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_consumer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}" fi # Models to run @@ -70,10 +75,12 @@ fi NUM_PREFILL_INSTANCES=${NUM_PREFILL_INSTANCES:-1} # Default to 1 NUM_DECODE_INSTANCES=${NUM_DECODE_INSTANCES:-1} # Default to 1 PREFILLER_TP_SIZE=${PREFILLER_TP_SIZE:-1} +PREFILLER_PP_SIZE=${PREFILLER_PP_SIZE:-1} # >1 requires NixlPushConnector DECODER_TP_SIZE=${DECODER_TP_SIZE:-1} GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.2} PREFILL_BLOCK_SIZE=${PREFILL_BLOCK_SIZE:-128} DECODE_BLOCK_SIZE=${DECODE_BLOCK_SIZE:-128} +ENFORCE_EAGER=${ENFORCE_EAGER:-1} # Comma-separated extra args for vllm serve (e.g. --max-model-len,2048) VLLM_SERVE_EXTRA_ARGS=${VLLM_SERVE_EXTRA_ARGS:-} @@ -135,8 +142,9 @@ run_tests_for_model() { # Calculate GPU ID - we'll distribute across available GPUs GPU_ID=$((i % $(get_num_gpus))) NEXT_GPU=${GPU_ID} - # If PREFILLER_TP_SIZE is more than 1 - for (( j=1; j < PREFILLER_TP_SIZE; j++ )); do + # Reserve TP*PP GPUs for the prefiller (TP shards across PP stages). + PREFILLER_WORLD_SIZE=$((PREFILLER_TP_SIZE * PREFILLER_PP_SIZE)) + for (( j=1; j < PREFILLER_WORLD_SIZE; j++ )); do NEXT_GPU=$(((GPU_ID + j) % $(get_num_gpus))) GPU_ID="${GPU_ID},${NEXT_GPU}" done @@ -155,11 +163,14 @@ run_tests_for_model() { VLLM_NIXL_SIDE_CHANNEL_PORT=$SIDE_CHANNEL_PORT \ vllm serve $model_name \ --port $PORT \ - --enforce-eager \ --block-size ${PREFILL_BLOCK_SIZE} \ --gpu-memory-utilization $GPU_MEMORY_UTILIZATION \ --tensor-parallel-size $PREFILLER_TP_SIZE \ - --kv-transfer-config '$KV_CONFIG'" + --pipeline-parallel-size $PREFILLER_PP_SIZE \ + --kv-transfer-config '$KV_CONFIG_P'" + if [[ "$ENFORCE_EAGER" == "1" ]]; then + BASE_CMD="${BASE_CMD} --enforce-eager" + fi if [[ -n "$VLLM_SERVE_EXTRA_ARGS" ]]; then IFS=',' read -r -a extra_args <<< "$VLLM_SERVE_EXTRA_ARGS" for arg in "${extra_args[@]}"; do @@ -204,10 +215,12 @@ run_tests_for_model() { VLLM_NIXL_SIDE_CHANNEL_PORT=$SIDE_CHANNEL_PORT \ vllm serve $model_name \ --port $PORT \ - --enforce-eager \ --block-size ${DECODE_BLOCK_SIZE} \ --gpu-memory-utilization $GPU_MEMORY_UTILIZATION \ - --kv-transfer-config '$KV_CONFIG'" + --kv-transfer-config '$KV_CONFIG_D'" + if [[ "$ENFORCE_EAGER" == "1" ]]; then + BASE_CMD="${BASE_CMD} --enforce-eager" + fi if [[ -n "$VLLM_SERVE_EXTRA_ARGS" ]]; then IFS=',' read -r -a extra_args <<< "$VLLM_SERVE_EXTRA_ARGS" for arg in "${extra_args[@]}"; do diff --git a/tests/v1/kv_connector/nixl_integration/run_mamba_prefix_cache_test.sh b/tests/v1/kv_connector/nixl_integration/run_mamba_prefix_cache_test.sh new file mode 100755 index 000000000000..d3fee8d6ba58 --- /dev/null +++ b/tests/v1/kv_connector/nixl_integration/run_mamba_prefix_cache_test.sh @@ -0,0 +1,108 @@ +#!/bin/bash +set -xe + +# E2E test: Mamba hybrid prefix cache hits in PD disaggregation. +# Spins up a 1P1D setup with a Mamba hybrid model and verifies +# repeated prompts yield non-zero D-side prefix cache hits. + +PREFILL_GPU_ID=${PREFILL_GPU_ID:-0} +DECODE_GPU_ID=${DECODE_GPU_ID:-1} +MODEL=${MODEL:-"ibm-granite/granite-4.0-h-tiny"} +GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.8} +VLLM_SERVE_EXTRA_ARGS=${VLLM_SERVE_EXTRA_ARGS:-} +ATTENTION_BACKEND=${ATTENTION_BACKEND:-FLASHINFER} + +echo "Running Mamba prefix cache test (GPUs: P=$PREFILL_GPU_ID, D=$DECODE_GPU_ID, model=$MODEL, backend=$ATTENTION_BACKEND)" + +KV_CONFIG='{"kv_connector":"NixlConnector","kv_role":"kv_both"}' + +# Resolve repository root +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +GIT_ROOT="${GIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../../../.." && pwd -P)}" + +trap 'kill $(jobs -pr) 2>/dev/null' SIGINT SIGTERM EXIT + +wait_for_server() { + local port=$1 + timeout 600 bash -c " + until curl -s localhost:${port}/v1/completions > /dev/null; do + sleep 1 + done" && return 0 || return 1 +} + +cleanup_instances() { + echo "Cleaning up any running vLLM instances..." + pkill -f "vllm serve" || true + sleep 2 +} + +cleanup_instances + +EXTRA_ARGS=() +if [[ -n "$VLLM_SERVE_EXTRA_ARGS" ]]; then + IFS=',' read -r -a EXTRA_ARGS <<< "$VLLM_SERVE_EXTRA_ARGS" +fi +if [[ -n "$ATTENTION_BACKEND" ]]; then + EXTRA_ARGS+=(--attention-backend "$ATTENTION_BACKEND") +fi + +# Start prefill instance +PREFILL_PORT=8001 +CUDA_VISIBLE_DEVICES=$PREFILL_GPU_ID \ +VLLM_SSM_CONV_STATE_LAYOUT=DS \ +VLLM_KV_CACHE_LAYOUT=HND \ +VLLM_NIXL_SIDE_CHANNEL_PORT=5559 \ +vllm serve $MODEL \ + --port $PREFILL_PORT \ + --enforce-eager \ + --gpu-memory-utilization $GPU_MEMORY_UTILIZATION \ + --max-model-len 16384 \ + --block-size 128 \ + --trust-remote-code \ + --enable-prefix-caching \ + --mamba-cache-mode all \ + --kv-transfer-config "$KV_CONFIG" \ + "${EXTRA_ARGS[@]}" & + +# Start decode instance +DECODE_PORT=8002 +CUDA_VISIBLE_DEVICES=$DECODE_GPU_ID \ +VLLM_SSM_CONV_STATE_LAYOUT=DS \ +VLLM_KV_CACHE_LAYOUT=HND \ +VLLM_NIXL_SIDE_CHANNEL_PORT=6000 \ +vllm serve $MODEL \ + --port $DECODE_PORT \ + --enforce-eager \ + --gpu-memory-utilization $GPU_MEMORY_UTILIZATION \ + --max-model-len 16384 \ + --block-size 128 \ + --trust-remote-code \ + --enable-prefix-caching \ + --mamba-cache-mode all \ + --kv-transfer-config "$KV_CONFIG" \ + "${EXTRA_ARGS[@]}" & + +echo "Waiting for prefill instance on port $PREFILL_PORT..." +wait_for_server "$PREFILL_PORT" +echo "Waiting for decode instance on port $DECODE_PORT..." +wait_for_server "$DECODE_PORT" + +# Start proxy +PROXY_PORT=8192 +python3 "${GIT_ROOT}/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py" \ + --port $PROXY_PORT \ + --prefiller-ports $PREFILL_PORT \ + --decoder-ports $DECODE_PORT & + +sleep 5 + +echo "Running Mamba prefix cache test..." +PREFILL_PORT=$PREFILL_PORT \ +DECODE_PORT=$DECODE_PORT \ +PROXY_PORT=$PROXY_PORT \ +python3 -m pytest -s -v \ + "${GIT_ROOT}/tests/v1/kv_connector/nixl_integration/test_mamba_prefix_cache.py" + +echo "Mamba prefix cache test passed!" + +cleanup_instances diff --git a/tests/v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh b/tests/v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh index 2e71858983e9..dae632dfce84 100755 --- a/tests/v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh +++ b/tests/v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh @@ -18,6 +18,7 @@ # Environment variables: # MODEL_NAMES - model to test (default: Qwen/Qwen3-0.6B) # GPU_MEMORY_UTILIZATION - GPU memory fraction (default: 0.6) +# ATTENTION_BACKEND - optional attention backend for vllm serve # VLLM_SERVE_EXTRA_ARGS - comma-separated extra args for vllm serve # SKIP_CROSS_LAYERS - set to 1 to skip the cross-layer layout test # SKIP_NORMAL_LAYOUT - set to 1 to skip the normal layout test @@ -34,9 +35,11 @@ fi GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.6} BLOCK_SIZE=${BLOCK_SIZE:-128} +ATTENTION_BACKEND=${ATTENTION_BACKEND:-} VLLM_SERVE_EXTRA_ARGS=${VLLM_SERVE_EXTRA_ARGS:-} -GIT_ROOT=$(git rev-parse --show-toplevel) +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +GIT_ROOT="${GIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../../../.." && pwd -P)}" SMI_BIN=$(which nvidia-smi || which rocm-smi || echo "") # ── KV transfer configs ───────────────────────────────────────────────── @@ -139,6 +142,9 @@ run_tests_for_model() { BASE_CMD="${BASE_CMD} $arg" done fi + if [[ -n "$ATTENTION_BACKEND" ]]; then + BASE_CMD="${BASE_CMD} --attention-backend $ATTENTION_BACKEND" + fi eval "$BASE_CMD &" # ── Start decode instance ── @@ -161,6 +167,9 @@ run_tests_for_model() { BASE_CMD="${BASE_CMD} $arg" done fi + if [[ -n "$ATTENTION_BACKEND" ]]; then + BASE_CMD="${BASE_CMD} --attention-backend $ATTENTION_BACKEND" + fi eval "$BASE_CMD &" # ── Wait for servers ── diff --git a/tests/v1/kv_connector/nixl_integration/run_multi_connector_edge_case_test.sh b/tests/v1/kv_connector/nixl_integration/run_multi_connector_edge_case_test.sh index a80950b34136..de6c9abcc6b0 100755 --- a/tests/v1/kv_connector/nixl_integration/run_multi_connector_edge_case_test.sh +++ b/tests/v1/kv_connector/nixl_integration/run_multi_connector_edge_case_test.sh @@ -19,6 +19,7 @@ # MODEL_NAMES - model to test (default: Qwen/Qwen3-0.6B) # KV_CACHE_MEMORY_BYTES - GPU KV cache size in bytes (default: 268435456 = 256 MiB) # BLOCK_SIZE - KV cache block size (default: 128) +# ATTENTION_BACKEND - optional attention backend for vllm serve # VLLM_SERVE_EXTRA_ARGS - comma-separated extra args for vllm serve set -xe @@ -34,9 +35,11 @@ fi KV_CACHE_MEMORY_BYTES=${KV_CACHE_MEMORY_BYTES:-268435456} # 256 MiB MAX_MODEL_LEN=${MAX_MODEL_LEN:-2048} BLOCK_SIZE=${BLOCK_SIZE:-128} +ATTENTION_BACKEND=${ATTENTION_BACKEND:-} VLLM_SERVE_EXTRA_ARGS=${VLLM_SERVE_EXTRA_ARGS:-} -GIT_ROOT=$(git rev-parse --show-toplevel) +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +GIT_ROOT="${GIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../../../.." && pwd -P)}" # ── KV transfer config ────────────────────────────────────────────────── @@ -110,6 +113,9 @@ run_tests_for_model() { BASE_CMD="${BASE_CMD} $arg" done fi + if [[ -n "$ATTENTION_BACKEND" ]]; then + BASE_CMD="${BASE_CMD} --attention-backend $ATTENTION_BACKEND" + fi eval "$BASE_CMD &" # ── Start decode instance ── @@ -133,6 +139,9 @@ run_tests_for_model() { BASE_CMD="${BASE_CMD} $arg" done fi + if [[ -n "$ATTENTION_BACKEND" ]]; then + BASE_CMD="${BASE_CMD} --attention-backend $ATTENTION_BACKEND" + fi eval "$BASE_CMD &" # ── Wait for servers ── diff --git a/tests/v1/kv_connector/nixl_integration/run_xpu_disagg_accuracy_test.sh b/tests/v1/kv_connector/nixl_integration/run_xpu_disagg_accuracy_test.sh index 8340720f927c..4d4512b19c4f 100644 --- a/tests/v1/kv_connector/nixl_integration/run_xpu_disagg_accuracy_test.sh +++ b/tests/v1/kv_connector/nixl_integration/run_xpu_disagg_accuracy_test.sh @@ -21,27 +21,28 @@ DECODER_TP_SIZE=${DECODER_TP_SIZE:-1} KV_BUFFER_DEVICE=${KV_BUFFER_DEVICE:-"xpu"} GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.8} -generate_affinity_mask() { - local count=$1 - local start=${2:-0} - local mask="" - local i - - for ((i=0; i + +The argument body consists of ``VALUE`` tags. +The ``_qwen3_arg_converter`` parses these into a JSON object. +""" + +from __future__ import annotations + +import functools +import json +from typing import TYPE_CHECKING + +import regex as re + +from vllm.parser.engine.events import EventType +from vllm.parser.engine.parser_engine import ParserEngine +from vllm.parser.engine.parser_engine_config import ( + ParserEngineConfig, + ParserState, + Transition, +) + +if TYPE_CHECKING: + from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ) + from vllm.entrypoints.openai.responses.protocol import ResponsesRequest + from vllm.tokenizers import TokenizerLike + from vllm.tool_parsers.abstract_tool_parser import Tool + +THINK_START = "" +THINK_END = "" +TOOL_CALL_START = "" +TOOL_CALL_END = "" +FUNC_PREFIX = "]*)>" + r"(.*?)" + r"(?:<\s*/\s*parameter\s*>|(?=<\s*parameter\s*=))", + re.DOTALL, +) +_PARTIAL_PARAM_RE = re.compile(r"<\s*parameter\s*=\s*([^>]+)>(.*)$", re.DOTALL) + + +def _qwen3_arg_converter(raw_args: str, partial: bool) -> str: + params: dict[str, object] = {} + + for match in _PARAM_RE.finditer(raw_args): + name = match.group(1) + value = match.group(2) + params[name] = value.strip() + + if partial: + remaining = _PARAM_RE.sub("", raw_args) + m = _PARTIAL_PARAM_RE.search(remaining) + if m: + name = m.group(1) + value = m.group(2) + if name: + params[name] = value.strip() + + return json.dumps(params, ensure_ascii=False) + + +@functools.cache +def qwen3_config( + thinking: bool = True, + *, + name: str = "qwen3", + think_start: str = THINK_START, + think_end: str = THINK_END, + tool_start: str = TOOL_CALL_START, + tool_end: str = TOOL_CALL_END, +) -> ParserEngineConfig: + return ParserEngineConfig( + name=name, + initial_state=ParserState.REASONING if thinking else ParserState.CONTENT, + terminals={ + # Reasoning terminals + "THINK_START": think_start, + "THINK_END": think_end, + # Tool call terminals + "TOOL_START": tool_start, + "TOOL_END": tool_end, + "FUNC_PREFIX": FUNC_PREFIX, + "FUNC_END": FUNC_END, + "PARAM_START": PARAM_START, + "PARAM_END": PARAM_END, + "CLOSE_ANGLE": ">", + }, + token_id_terminals={ + "THINK_START": think_start, + "THINK_END": think_end, + "TOOL_START": tool_start, + "TOOL_END": tool_end, + }, + transitions={ + # -- Reasoning transitions -- + (ParserState.REASONING, "THINK_START"): Transition( + ParserState.REASONING, + (), + ), + (ParserState.REASONING, "THINK_END"): Transition( + ParserState.CONTENT, + (EventType.REASONING_END,), + ), + # Absorb duplicate — model may emit it after + # already transitioning to CONTENT; drop it silently. + (ParserState.CONTENT, "THINK_END"): Transition( + ParserState.CONTENT, + (), + ), + # Tool call directly from reasoning (implicit end) + (ParserState.REASONING, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, + (EventType.REASONING_END, EventType.TOOL_CALL_START), + ), + # -- Tool call transitions -- + (ParserState.CONTENT, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, + (EventType.REASONING_END, EventType.TOOL_CALL_START), + ), + # Fallback: + (ParserState.CONTENT, "FUNC_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), + (ParserState.TOOL_PREAMBLE, "TOOL_END"): Transition( + ParserState.CONTENT, + (EventType.TOOL_CALL_END,), + ), + (ParserState.TOOL_PREAMBLE, "FUNC_PREFIX"): Transition( + ParserState.TOOL_NAME, + (), + ), + (ParserState.TOOL_NAME, "CLOSE_ANGLE"): Transition( + ParserState.TOOL_ARGS, + (), + ), + # Malformed: while still in TOOL_NAME (no closing >) + (ParserState.TOOL_NAME, "FUNC_END"): Transition( + ParserState.TOOL_BETWEEN, + (EventType.TOOL_CALL_END,), + ), + (ParserState.TOOL_ARGS, "FUNC_END"): Transition( + ParserState.TOOL_BETWEEN, + (EventType.TOOL_CALL_END,), + ), + (ParserState.TOOL_ARGS, "PARAM_START"): Transition( + ParserState.TOOL_ARGS, + (EventType.ARG_VALUE_CHUNK,), + ), + (ParserState.TOOL_ARGS, "PARAM_END"): Transition( + ParserState.TOOL_ARGS, + (EventType.ARG_VALUE_CHUNK,), + ), + (ParserState.TOOL_BETWEEN, "TOOL_END"): Transition( + ParserState.CONTENT, + (), + ), + # Consecutive tool call without closing + (ParserState.TOOL_BETWEEN, "TOOL_START"): Transition( + ParserState.TOOL_PREAMBLE, + (EventType.TOOL_CALL_START,), + ), + (ParserState.TOOL_BETWEEN, "FUNC_PREFIX"): Transition( + ParserState.TOOL_NAME, + (EventType.TOOL_CALL_START,), + ), + }, + arg_converter=_qwen3_arg_converter, + stream_arg_deltas=True, + strip_trailing_reasoning_whitespace=False, + tool_args_json=False, + ) + + +class Qwen3Parser(ParserEngine): + """Qwen3 parser: ````/```` reasoning + + ```` XML tool calls in a single engine. + + - ```` as implicit reasoning end + - Unpaired ```` token ID detection for ``is_reasoning_end`` + + Subclasses that share the grammar but differ only in the four wrapper + token strings (reasoning + tool-call) override the class attributes + below; everything else is inherited unchanged. + """ + + CONFIG_NAME = "qwen3" + THINK_START = THINK_START + THINK_END = THINK_END + TOOL_START = TOOL_CALL_START + TOOL_END = TOOL_CALL_END + + def __init__( + self, + tokenizer: TokenizerLike, + tools: list[Tool] | None = None, + **kwargs, + ) -> None: + chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} + self.thinking_enabled = chat_kwargs.get("enable_thinking", True) + kwargs.setdefault( + "parser_engine_config", + qwen3_config( + thinking=self.thinking_enabled, + name=self.CONFIG_NAME, + think_start=self.THINK_START, + think_end=self.THINK_END, + tool_start=self.TOOL_START, + tool_end=self.TOOL_END, + ), + ) + super().__init__( + tokenizer, + tools, + **kwargs, + ) + vocab = self.vocab + self._tool_call_token_id: int | None = vocab.get(self.TOOL_START) + self._tool_call_end_token_id: int | None = vocab.get(self.TOOL_END) + + def extract_reasoning( + self, + model_output: str, + request: ChatCompletionRequest | ResponsesRequest, + ) -> tuple[str | None, str | None]: + if not self.thinking_enabled: + return None, model_output + return super().extract_reasoning(model_output, request) + + def is_reasoning_end(self, input_ids: list[int]) -> bool: + if super().is_reasoning_end(input_ids): + return True + tool_call_id = self._tool_call_token_id + tool_call_end_id = self._tool_call_end_token_id + reasoning_start_id = self._reasoning_start_token_id + if tool_call_id is not None: + for i in range(len(input_ids) - 1, -1, -1): + if ( + reasoning_start_id is not None + and input_ids[i] == reasoning_start_id + ): + return False + if input_ids[i] == tool_call_id: + if tool_call_end_id is not None and any( + input_ids[j] == tool_call_end_id + for j in range(i + 1, len(input_ids)) + ): + continue + return True + return False diff --git a/vllm/parser/seed_oss.py b/vllm/parser/seed_oss.py new file mode 100644 index 000000000000..2f709f0ad67e --- /dev/null +++ b/vllm/parser/seed_oss.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""seed_oss parser for tool calls and reasoning. + +seed_oss shares the Qwen3 XML grammar exactly; only the four wrapper +token strings differ:: + + -> + -> + -> + -> + +```` and ```` are byte-identical, so the +entire transition table and ``_qwen3_arg_converter`` are inherited from +:class:`Qwen3Parser` unchanged. +""" + +from __future__ import annotations + +from vllm.parser.qwen3 import Qwen3Parser + + +class SeedOssParser(Qwen3Parser): + CONFIG_NAME = "seed_oss" + THINK_START = "" + THINK_END = "" + TOOL_START = "" + TOOL_END = "" diff --git a/vllm/parser/utils.py b/vllm/parser/utils.py new file mode 100644 index 000000000000..51382cd29094 --- /dev/null +++ b/vllm/parser/utils.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from collections.abc import Iterable, Sequence + +from openai.types.responses import ResponseFunctionToolCall + +from vllm.entrypoints.chat_utils import ChatCompletionMessageParam +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest +from vllm.entrypoints.openai.responses.protocol import ( + ResponseInputOutputItem, + ResponsesRequest, +) + + +def count_tool_calls(tool_calls: object) -> int: + if tool_calls is None: + return 0 + if isinstance(tool_calls, (str, bytes, dict)): + return 1 + if isinstance(tool_calls, Iterable): + return sum(1 for _ in tool_calls) + return 1 + + +def count_chat_history_tool_calls( + messages: Sequence[ChatCompletionMessageParam], +) -> int: + return sum( + count_tool_calls(msg.get("tool_calls")) + for msg in messages + if isinstance(msg, dict) and msg.get("role") == "assistant" + ) + + +def count_response_history_tool_calls( + response_items: Sequence[ResponseInputOutputItem], +) -> int: + count = 0 + for item in response_items: + if isinstance(item, ResponseFunctionToolCall): + count += 1 + continue + + if isinstance(item, dict): + item_type = item.get("type") + if item_type == "function_call": + count += 1 + elif item.get("role") == "assistant": + count += count_tool_calls(item.get("tool_calls")) + + return count + + +def count_history_tool_calls( + request: ChatCompletionRequest | ResponsesRequest, +) -> int: + if isinstance(request, ChatCompletionRequest): + return count_chat_history_tool_calls(request.messages) + + request_input = request.input + if isinstance(request_input, str): + return 0 + + return count_response_history_tool_calls(request_input) diff --git a/vllm/platforms/__init__.py b/vllm/platforms/__init__.py index 645da0a1fe97..ac536aff00c5 100644 --- a/vllm/platforms/__init__.py +++ b/vllm/platforms/__init__.py @@ -9,7 +9,6 @@ from vllm import envs from vllm.plugins import PLATFORM_PLUGINS_GROUP, load_plugins_by_group from vllm.utils.import_utils import resolve_obj_by_qualname -from vllm.utils.torch_utils import supports_xccl from .interface import CpuArchEnum, Platform, PlatformEnum @@ -135,7 +134,7 @@ def xpu_platform_plugin() -> str | None: try: import torch - if supports_xccl(): + if torch.distributed.is_xccl_available(): dist_backend = "xccl" from vllm.platforms.xpu import XPUPlatform @@ -187,7 +186,7 @@ def cpu_platform_plugin() -> str | None: try: import zentorch # noqa: F401 - logger.debug( + logger.info( "AMD Zen CPU detected with zentorch installed, using ZenCpuPlatform." ) return "vllm.platforms.zen_cpu.ZenCpuPlatform" diff --git a/vllm/platforms/cpu.py b/vllm/platforms/cpu.py index cf4319ac7223..ed7cec519aa4 100644 --- a/vllm/platforms/cpu.py +++ b/vllm/platforms/cpu.py @@ -50,7 +50,7 @@ class CpuPlatform(Platform): @property def supported_dtypes(self) -> list[torch.dtype]: if self.get_cpu_architecture() == CpuArchEnum.POWERPC: - return [torch.bfloat16, torch.float32] + return [torch.bfloat16, torch.float32, torch.float16] elif self.get_cpu_architecture() == CpuArchEnum.ARM and sys.platform.startswith( "darwin" ): @@ -125,6 +125,14 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: "otherwise the performance is not optimized." ) + # AMX GDN requires float32 state + if ( + torch.cpu._is_amx_tile_supported() + and cache_config.mamba_ssm_cache_dtype != "float32" + ): + cache_config.mamba_ssm_cache_dtype = "float32" + logger.warning("Reset SSM cache type to float32 for AMX mamba attention.") + # Lagecy setting env_key = "VLLM_CPU_KVCACHE_SPACE" if env_key in os.environ and os.environ[env_key] != "": @@ -149,23 +157,9 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: parallel_config.worker_cls = "vllm.v1.worker.cpu_worker.CPUWorker" # Disable DBO if parallel_config.enable_dbo: - logger.warning("Dual-Batch Overlap is not supported on CPU, disabled.") + logger.warning_once("Dual-Batch Overlap is not supported on CPU, disabled.") parallel_config.enable_dbo = False - if torch.cpu._is_amx_tile_supported() and ( - model_config is not None - and model_config.get_num_layers_by_block_type( - parallel_config, "linear_attention" - ) - > 0 - ): - cache_config.enable_prefix_caching = False - scheduler_config.enable_chunked_prefill = False - logger.warning( - "Disabled unsupported prefix caching and chunked prefill " - "for linear attention on AMX CPU platforms." - ) - # Note: workaround for v1 gpu_model_runner from vllm.config import CompilationMode @@ -207,6 +201,18 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: and "-gelu" not in compilation_config.custom_ops ): compilation_config.custom_ops.append("+gelu") + if ( + cls.get_cpu_architecture() == CpuArchEnum.ARM + and "+gelu_tanh" not in compilation_config.custom_ops + and "-gelu_tanh" not in compilation_config.custom_ops + ): + compilation_config.custom_ops.append("+gelu_tanh") + if ( + cls.get_cpu_architecture() == CpuArchEnum.ARM + and "+gelu_and_mul" not in compilation_config.custom_ops + and "-gelu_and_mul" not in compilation_config.custom_ops + ): + compilation_config.custom_ops.append("+gelu_and_mul") vllm_config.profiler_config.torch_profiler_dump_cuda_time_total = False @@ -304,7 +310,7 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: ) if model_config is not None and model_config.use_mla: - logger.info( + logger.info_once( "MLA is enabled on a non-GPU platform; forcing chunked " "prefill and prefix caching to be disabled." ) @@ -426,13 +432,13 @@ def import_kernels(cls) -> None: try: import vllm._C # noqa: F401 except ImportError as e: - logger.warning("Failed to import from vllm._C: %r", e) + logger.warning_once("Failed to import from vllm._C: %r", e) else: try: import vllm._C_AVX512 # noqa: F401 except ImportError as e: if ignored_msg not in e.msg: - logger.warning( + logger.warning_once( "Failed to import from vllm._C_AVX512: %r", e ) else: @@ -440,12 +446,12 @@ def import_kernels(cls) -> None: import vllm._C_AVX2 # noqa: F401 except ImportError as e: if ignored_msg not in e.msg: - logger.warning("Failed to import from vllm._C_AVX2: %r", e) + logger.warning_once("Failed to import from vllm._C_AVX2: %r", e) else: try: import vllm._C # noqa: F401 except ImportError as e: - logger.warning("Failed to import from vllm._C: %r", e) + logger.warning_once("Failed to import from vllm._C: %r", e) @classmethod def pack_kv_cache( diff --git a/vllm/platforms/cuda.py b/vllm/platforms/cuda.py index 57814d29bef9..9eac95e03249 100644 --- a/vllm/platforms/cuda.py +++ b/vllm/platforms/cuda.py @@ -6,11 +6,13 @@ from __future__ import annotations +import contextlib import os +import platform from collections.abc import Callable from datetime import timedelta from functools import cache, lru_cache, wraps -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, NamedTuple, TypeVar import torch from torch.distributed import PrefixStore, ProcessGroup @@ -18,20 +20,22 @@ from typing_extensions import ParamSpec # import custom ops, trigger op registration -import vllm._C # noqa import vllm._C_stable_libtorch # noqa + +with contextlib.suppress(ImportError): + import vllm._qutlass_C # noqa import vllm.envs as envs from vllm.logger import init_logger from vllm.utils.import_utils import import_pynvml -from vllm.utils.torch_utils import is_quantized_kv_cache from vllm.v1.attention.backends.registry import AttentionBackendEnum -from .interface import DeviceCapability, Platform, PlatformEnum +from .interface import DeviceCapability, Platform, PlatformEnum, in_wsl if TYPE_CHECKING: from vllm.config import VllmConfig from vllm.config.cache import CacheDType from vllm.config.kernel import IrOpPriorityConfig + from vllm.v1.attention.backend import AttentionBackend from vllm.v1.attention.selector import AttentionSelectorConfig else: VllmConfig = None @@ -83,6 +87,8 @@ def _get_backend_priorities( kv_cache_dtype: CacheDType | None = None, ) -> list[AttentionBackendEnum]: """Get backend priorities with lazy import to avoid circular dependency.""" + from vllm.utils.torch_utils import is_quantized_kv_cache + if use_mla: if device_capability.major == 10: # Sparse MLA backend priorities @@ -120,12 +126,18 @@ def _get_backend_priorities( AttentionBackendEnum.TRITON_MLA, *sparse_backends, ] + elif device_capability.major == 12: + return [ + AttentionBackendEnum.TRITON_MLA, + AttentionBackendEnum.FLASHINFER_MLA_SPARSE_SM120, + ] else: return [ AttentionBackendEnum.FLASH_ATTN_MLA, AttentionBackendEnum.FLASHMLA, AttentionBackendEnum.FLASHINFER_MLA, AttentionBackendEnum.TRITON_MLA, + AttentionBackendEnum.FLASH_ATTN_MLA_SPARSE, AttentionBackendEnum.FLASHMLA_SPARSE, ] else: @@ -147,6 +159,21 @@ def _get_backend_priorities( ] +def _backend_cls_path(backend_cls: type[AttentionBackend]) -> str: + module, qualname = backend_cls.full_cls_name() + return f"{module}.{qualname}" + + +def _get_attn_backend_class(backend: AttentionBackendEnum) -> type[AttentionBackend]: + return backend.get_class() + + +class _BackendCandidate(NamedTuple): + backend_class: type[AttentionBackend] + backend: AttentionBackendEnum + priority: int + + def with_nvml_context(fn: Callable[_P, _R]) -> Callable[_P, _R]: @wraps(fn) def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: @@ -159,6 +186,21 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: return wrapper +@cache +def _get_wsl_kernel_version() -> tuple[int, ...] | None: + """Return the WSL2 kernel version as a tuple, or None on parse failure. + + platform.uname().release on WSL2 looks like + "5.15.167.4-microsoft-standard-WSL2"; we take the numeric prefix. + """ + try: + release = platform.uname().release + parts = release.split("-")[0].split(".") + return tuple(int(x) for x in parts[:3]) + except Exception: + return None + + class CudaPlatformBase(Platform): _enum = PlatformEnum.CUDA device_name: str = "cuda" @@ -171,6 +213,18 @@ class CudaPlatformBase(Platform): "RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES", ] + @classmethod + def import_kernels(cls) -> None: + """Import CUDA kernel extensions (_C_stable_libtorch, optional _qutlass_C).""" + try: + import vllm._C_stable_libtorch # noqa: F401 + except ImportError as e: + logger.warning_once("Failed to import from vllm._C_stable_libtorch: %r", e) + with contextlib.suppress(ImportError): + import vllm._moe_C_stable_libtorch # noqa: F401 + with contextlib.suppress(ImportError): + import vllm._qutlass_C # noqa: F401 + @property def supported_dtypes(self) -> list[torch.dtype]: if self.has_device_capability(80): @@ -224,6 +278,27 @@ def is_fully_connected(cls, device_ids: list[int]) -> bool: def log_warnings(cls): pass + @classmethod + def is_pin_memory_available(cls) -> bool: + if in_wsl(): + # WSL1 has no CUDA support, so being on the CUDA platform under + # WSL implies WSL2. Gate on kernel >= 4.19.121, the first WSL2 + # kernel with limited pinned memory support for CUDA. + version = _get_wsl_kernel_version() + if version is None or version < (4, 19, 121): + logger.warning_once( + "Using 'pin_memory=False' as WSL is detected and the " + "WSL2 kernel version is below 4.19.121. This may slow " + "down performance. Please run `wsl --update`." + ) + return False + # On compatible WSL2 kernels, pinned memory is supported but + # disabled by default. Enable it via VLLM_WSL2_ENABLE_PIN_MEMORY=1. + import vllm.envs as envs + + return envs.VLLM_WSL2_ENABLE_PIN_MEMORY + return True + @classmethod def check_and_update_config(cls, vllm_config: VllmConfig) -> None: parallel_config = vllm_config.parallel_config @@ -240,12 +315,33 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: and scheduler_config.is_multimodal_model and not scheduler_config.disable_chunked_mm_input ): - logger.warning( + logger.warning_once( "Forcing --disable_chunked_mm_input for models " "with multimodal-bidirectional attention." ) scheduler_config.disable_chunked_mm_input = True + if ( + in_wsl() + and vllm_config.offload_config.uva.cpu_offload_gb > 0 + and bool(vllm_config.compilation_config.cudagraph_mode) + ): + logger.warning_once( + "--cpu-offload-gb is enabled with CUDA graphs on WSL2. " + "This combination requires pinned (page-locked) memory " + "allocations. WARNING: Windows (WDDM) enforces a hard " + "system-wide cap of roughly 50%% of physical RAM on pinned " + "memory shared across ALL processes by default (limit can " + "changed via %%USERPROFILE%%\\.wslconfig). " + "Excessive use of page-locked memory can prevent Windows " + "from reclaiming memory under load, which can cause the " + "entire host OS to become unresponsive and may require a " + "hard reboot to recover. Proceed at your own risk. " + "To raise the WSL2 VM memory ceiling, increase the `memory` " + "setting in %%USERPROFILE%%\\.wslconfig and run " + "`wsl --shutdown`." + ) + @classmethod def get_current_memory_usage( cls, device: torch.types.Device | None = None @@ -261,7 +357,7 @@ def get_valid_backends( attn_selector_config: AttentionSelectorConfig, num_heads: int | None = None, ) -> tuple[ - list[tuple[AttentionBackendEnum, int]], + list[_BackendCandidate], dict[AttentionBackendEnum, tuple[int, list[str]]], ]: valid_backends_priorities = [] @@ -275,7 +371,7 @@ def get_valid_backends( ) for priority, backend in enumerate(backend_priorities): try: - backend_class = backend.get_class() + backend_class = _get_attn_backend_class(backend) invalid_reasons_i = backend_class.validate_configuration( device_capability=device_capability, **attn_selector_config._asdict(), @@ -285,7 +381,9 @@ def get_valid_backends( if invalid_reasons_i: invalid_reasons[backend] = (priority, invalid_reasons_i) else: - valid_backends_priorities.append((backend, priority)) + valid_backends_priorities.append( + _BackendCandidate(backend_class, backend, priority) + ) return valid_backends_priorities, invalid_reasons @@ -302,7 +400,7 @@ def get_attn_backend_cls( # First try checking just the selected backend, if there is one. if selected_backend is not None: try: - backend_class = selected_backend.get_class() + backend_class = _get_attn_backend_class(selected_backend) invalid_reasons = backend_class.validate_configuration( device_capability=device_capability, **attn_selector_config._asdict(), @@ -316,7 +414,7 @@ def get_attn_backend_cls( ) else: logger.info("Using %s backend.", selected_backend) - return selected_backend.get_path() + return _backend_cls_path(backend_class) # No selected backend or the selected backend is invalid, # so we try finding a valid backend. @@ -346,13 +444,13 @@ def get_attn_backend_cls( # We have found some valid backends. Select the one with the # highest priority. - sorted_indices = sorted( - range(len(valid_backends_priorities)), - key=lambda i: valid_backends_priorities[i][1], + selected_candidate = min( + valid_backends_priorities, + key=lambda candidate: candidate.priority, ) - selected_index = sorted_indices[0] - selected_backend = valid_backends_priorities[selected_index][0] - selected_priority = valid_backends_priorities[selected_index][1] + selected_backend_class = selected_candidate.backend_class + selected_backend = selected_candidate.backend + selected_priority = selected_candidate.priority # If the user specified --block-size (but not --attention-backend), # check whether that constraint precluded any higher-priority backends. @@ -378,10 +476,14 @@ def get_attn_backend_cls( logger.info_once( "Using %s attention backend out of potential backends: %s.", selected_backend.name, - "[" + ", ".join(f"'{b[0].name}'" for b in valid_backends_priorities) + "]", + "[" + + ", ".join( + f"'{candidate.backend.name}'" for candidate in valid_backends_priorities + ) + + "]", ) - return selected_backend.get_path() + return _backend_cls_path(selected_backend_class) @classmethod def get_supported_vit_attn_backends(cls) -> list[AttentionBackendEnum]: @@ -556,7 +658,11 @@ def support_static_graph_mode(cls) -> bool: @classmethod def support_deep_gemm(cls) -> bool: """Currently, only Hopper and Blackwell GPUs are supported.""" - return cls.is_device_capability(90) or cls.is_device_capability_family(100) + return ( + cls.is_device_capability(90) + or cls.is_device_capability_family(100) + or cls.is_device_capability_family(120) + ) @classmethod def is_integrated_gpu(cls, device_id: int = 0) -> bool: @@ -607,12 +713,21 @@ def is_arch_support_pdl(cls) -> bool: # all the related functions work on real physical device ids. # the major benefit of using NVML is that it will not initialize CUDA class NvmlCudaPlatform(CudaPlatformBase): + @classmethod + @with_nvml_context + def device_control_id_to_physical_device_id(cls, device_id: str) -> int: + try: + return int(device_id) + except ValueError: + handle = pynvml.nvmlDeviceGetHandleByUUID(device_id) + return pynvml.nvmlDeviceGetIndex(handle) + @classmethod @cache @with_nvml_context def get_device_capability(cls, device_id: int = 0) -> DeviceCapability | None: try: - physical_device_id = cls.device_id_to_physical_device_id(device_id) + physical_device_id = cls.visible_device_id_to_physical_device_id(device_id) handle = pynvml.nvmlDeviceGetHandleByIndex(physical_device_id) major, minor = pynvml.nvmlDeviceGetCudaComputeCapability(handle) return DeviceCapability(major=major, minor=minor) diff --git a/vllm/platforms/interface.py b/vllm/platforms/interface.py index b357c5798bf6..7cf009c80716 100644 --- a/vllm/platforms/interface.py +++ b/vllm/platforms/interface.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import contextlib import enum +import functools import os import platform import sys @@ -29,7 +30,35 @@ logger = init_logger(__name__) +_assigned_physical_gpu_ids: list[int] | None = None + +def set_assigned_physical_gpu_ids(ids: list[int]) -> None: + """Set the physical GPU IDs assigned to this worker process. + Called during worker init so that device_id_to_physical_device_id() + can map local_rank to the correct physical device without relying + on CUDA_VISIBLE_DEVICES. + + Idempotent: a second call with the same value is a no-op. + Raises RuntimeError if called again with a different value. + + This is expected to run during single-threaded worker initialization.""" + global _assigned_physical_gpu_ids + if _assigned_physical_gpu_ids is not None: + if _assigned_physical_gpu_ids != ids: + raise RuntimeError( + f"set_assigned_physical_gpu_ids called with conflicting values: " + f"existing={_assigned_physical_gpu_ids}, new={ids}" + ) + return + _assigned_physical_gpu_ids = ids + + +def get_assigned_physical_gpu_ids() -> list[int] | None: + return _assigned_physical_gpu_ids + + +@functools.cache def in_wsl() -> bool: # Reference: https://github.com/microsoft/WSL/issues/4071 return "microsoft" in " ".join(platform.uname()).lower() @@ -197,7 +226,7 @@ def is_sleep_mode_available(self) -> bool: # for ROCm, but currently we don't have a way to detect the # exact GPU model statelessly here. So we return True for # all ROCm platforms for now. - return self._enum in (PlatformEnum.CUDA, PlatformEnum.ROCM) + return self._enum in (PlatformEnum.CUDA, PlatformEnum.ROCM, PlatformEnum.XPU) def is_cumem_allocator_available(self) -> bool: try: @@ -231,8 +260,44 @@ def import_ir_kernels(cls) -> None: """ import vllm.kernels # noqa: F401 + @classmethod + def device_control_id_to_physical_device_id(cls, device_id: str) -> int: + """Map one device-control env entry to an integer physical device ID.""" + try: + return int(device_id) + except ValueError as e: + raise ValueError( + f"Non-integer device ID {device_id!r} is not supported by " + f"{cls.device_name}." + ) from e + + # GPU device IDs can refer to three distinct namespaces: + # - logical: vLLM-local IDs such as local ranks. These index + # assigned_physical_gpu_ids when it is set. + # - visible: torch/CUDA ordinals in the current process after applying + # the device-control env var, e.g. CUDA_VISIBLE_DEVICES. + # - physical: global GPU IDs used by topology and management APIs such as + # NVML, which are not remapped by CUDA_VISIBLE_DEVICES. + # Keep conversions explicit. In particular, torch device indices are + # visible IDs, not vLLM logical IDs. + @classmethod def device_id_to_physical_device_id(cls, device_id: int): + """Map a vLLM-local logical device ID to a physical device ID. + + The input is a logical local ID (e.g. a local rank), NOT a visible + device ordinal; for the latter use + visible_device_id_to_physical_device_id(). The two coincide only + when no logical-to-physical mapping is in effect. + """ + if _assigned_physical_gpu_ids is not None: + if device_id >= len(_assigned_physical_gpu_ids): + raise IndexError( + f"device_id {device_id} is out of range for " + f"assigned_physical_gpu_ids {_assigned_physical_gpu_ids} " + f"({len(_assigned_physical_gpu_ids)} devices assigned)" + ) + return _assigned_physical_gpu_ids[device_id] # Treat empty device control env var as unset. This is a valid # configuration in Ray setups where the engine is launched in # a CPU-only placement group located on a GPU node. @@ -242,19 +307,67 @@ def device_id_to_physical_device_id(cls, device_id: int): ): device_ids = os.environ[cls.device_control_env_var].split(",") physical_device_id = device_ids[device_id] - return int(physical_device_id) + return cls.device_control_id_to_physical_device_id(physical_device_id) else: return device_id + @classmethod + def logical_device_id_to_visible_device_id(cls, device_id: int) -> int: + """Map a vLLM-local logical device ID to the current process's + visible accelerator ordinal. + + vLLM internals use logical local IDs. Physical IDs are used only + at platform/topology boundaries. This helper performs the final + translation needed by APIs such as ``torch.device("cuda:N")``. + """ + physical_device_id = cls.device_id_to_physical_device_id(device_id) + device_control_env = os.environ.get(cls.device_control_env_var, "") + if not device_control_env: + return physical_device_id + + visible_physical_device_ids = [ + cls.device_control_id_to_physical_device_id(physical_id) + for physical_id in device_control_env.split(",") + ] + if physical_device_id not in visible_physical_device_ids: + raise RuntimeError( + f"Physical device {physical_device_id} for logical device " + f"{device_id} is not visible in {cls.device_control_env_var}=" + f"{device_control_env}" + ) + return visible_physical_device_ids.index(physical_device_id) + + @classmethod + def visible_device_id_to_physical_device_id(cls, device_id: int) -> int: + """Map a visible accelerator ordinal (e.g. ``torch.device.index``) + to a physical device ID. + + This is the inverse of the env-var translation performed by + logical_device_id_to_visible_device_id() and is independent of any + logical-to-physical mapping set via set_assigned_physical_gpu_ids(). + """ + device_control_env = os.environ.get(cls.device_control_env_var, "") + if not device_control_env: + return device_id + visible_device_ids = device_control_env.split(",") + if device_id >= len(visible_device_ids): + raise IndexError( + f"visible device ordinal {device_id} is out of range for " + f"{cls.device_control_env_var}={device_control_env}" + ) + return cls.device_control_id_to_physical_device_id( + visible_device_ids[device_id] + ) + @classmethod def import_kernels(cls) -> None: """Import any platform-specific C kernels.""" try: import vllm._C # noqa: F401 except ImportError as e: - logger.warning("Failed to import from vllm._C: %r", e) + logger.warning_once("Failed to import from vllm._C: %r", e) with contextlib.suppress(ImportError): - import vllm._moe_C # noqa: F401 + import vllm._moe_C_stable_libtorch # noqa: F401 @classmethod def get_attn_backend_cls( @@ -308,7 +421,12 @@ def get_device_capability( cls, device_id: int = 0, ) -> DeviceCapability | None: - """Stateless version of [torch.cuda.get_device_capability][].""" + """Stateless version of [torch.cuda.get_device_capability][]. + + Args: + device_id: Device index in the visible device namespace, matching + the argument accepted by torch.cuda. + """ return None @classmethod @@ -526,6 +644,123 @@ def update_block_size_for_backend(cls, vllm_config: "VllmConfig") -> None: if model_config.is_hybrid: cls._align_hybrid_block_size(vllm_config, backend_cls) + # Phase 3: Align block/page sizes when multiple KV dtypes share the + # block pool (e.g. nvfp4 primary + unquantized skip layers). + # May override the user's --block-size. + if cache_config.kv_cache_dtype_skip_layers: + cls._align_heterogeneous_kv_block_size(vllm_config, backend_cls) + + @classmethod + def _align_heterogeneous_kv_block_size( + cls, + vllm_config: "VllmConfig", + backend_cls: "type[AttentionBackend]", + ) -> None: + """Align block size when several KV dtypes share one block pool. + + A quantized primary (e.g. nvfp4) shares the block pool with one or more + higher-precision "padded specs" (skip layers today; the first/last-N + sibling in the future). A padded spec's per-token page is larger than + the primary's *and not an integer multiple of it*, so the trivial + ``unify_kv_cache_spec_page_size`` cannot reconcile them. We do it here + instead, before the specs are built: + + 1. Bump the primary ``block_size`` (kernel-aligned) until the primary + page is large enough to cover the largest padded-spec page. + 2. Record that shared page in each padded spec's ``*_page_size_padded`` + hint, so it pads up to the shared page. + + ``unify`` then sees equal pages and stays trivial. + + To add a padded-spec type: append its per-token page to ``padded_pages`` + and set its ``*_page_size_padded`` hint below. + """ + from vllm.config.vllm import set_current_vllm_config + from vllm.utils.math_utils import cdiv + from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE + from vllm.v1.attention.backend import MultipleOf + from vllm.v1.kv_cache_interface import FullAttentionSpec, get_kv_quant_mode + + cache_config = vllm_config.cache_config + model_config = vllm_config.model_config + parallel_config = vllm_config.parallel_config + if not model_config: + return + + def per_token_page_bytes(dtype: "torch.dtype", cache_dtype: str) -> int: + """Bytes one token occupies in one layer, for the given dtype.""" + return FullAttentionSpec( + block_size=1, + num_kv_heads=model_config.get_num_kv_heads(parallel_config), + head_size=model_config.get_head_size(), + dtype=dtype, + kv_quant_mode=get_kv_quant_mode(cache_dtype), + ).page_size_bytes + + primary_dtype = ( + STR_DTYPE_TO_TORCH_DTYPE[cache_config.cache_dtype] + if cache_config.cache_dtype != "auto" + else model_config.dtype + ) + primary_page = per_token_page_bytes(primary_dtype, cache_config.cache_dtype) + + # Per-token page of every higher-precision padded spec sharing the pool. + padded_pages: list[int] = [] + if cache_config.kv_cache_dtype_skip_layers: + padded_pages.append(per_token_page_bytes(model_config.dtype, "auto")) + # To add the first/last-N sibling: + # padded_pages.append(per_token_page_bytes(, "auto")) + if not padded_pages: + return + + largest_padded_page = max(padded_pages) + assert largest_padded_page >= primary_page, ( + f"padded-spec per-token page ({largest_padded_page}B) < primary " + f"({primary_page}B); a higher-precision padded spec must not be " + "smaller than the quantized primary." + ) + if largest_padded_page == primary_page: + # Pages already match per token; ``unify`` reconciles the differing + # block sizes by integer scaling, so no bump or padding is needed. + return + + # Smallest block the kernel supports, and the granularity the primary + # block is rounded up to (never below the already-chosen block_size). + with set_current_vllm_config(vllm_config): + supported = backend_cls.get_supported_kernel_block_sizes() + smallest_kernel_block = min( + s.base if isinstance(s, MultipleOf) else s for s in supported + ) + block_alignment = max(smallest_kernel_block, cache_config.block_size) + + # Bytes one padded-spec page spans at its own smallest kernel block; + # also cover any mamba page a hybrid model already padded. + required_page = max( + largest_padded_page * smallest_kernel_block, + cache_config.mamba_page_size_padded or 0, + ) + + # Smallest kernel-aligned primary block whose page covers required_page. + primary_block_size = block_alignment * cdiv( + required_page, block_alignment * primary_page + ) + if cache_config.block_size < primary_block_size: + cache_config.block_size = primary_block_size + logger.info( + "Setting attention block size to %d tokens so the quantized " + "primary KV page covers the higher-precision padded-spec page.", + primary_block_size, + ) + + # The shared page that every padded spec (and mamba) pads up to. + shared_page = cache_config.block_size * primary_page + if cache_config.kv_cache_dtype_skip_layers: + cache_config.skip_page_size_padded = shared_page + # To add the first/last-N sibling: + # cache_config.sibling_page_size_padded = shared_page + if cache_config.mamba_page_size_padded is not None: + cache_config.mamba_page_size_padded = shared_page + @classmethod def _align_hybrid_block_size( cls, @@ -752,11 +987,13 @@ def get_cpu_architecture(cls) -> CpuArchEnum: def is_pin_memory_available(cls) -> bool: """Checks whether pin memory is available on the current platform.""" if in_wsl(): - # Pinning memory in WSL is not supported. # https://docs.nvidia.com/cuda/wsl-user-guide/index.html#known-limitations-for-linux-cuda-applications - logger.warning( + # Pinned memory support under WSL depends on the vendor and driver + # version. Conservative default: return False. Platform subclasses + # that can verify support (e.g. CudaPlatformBase) override this. + logger.warning_once( "Using 'pin_memory=False' as WSL is detected. " - "This may slow down the performance." + "This may slow down performance." ) return False return True @@ -898,7 +1135,7 @@ def __getattr__(self, key: str): if attr is not None: return attr - logger.warning( + logger.warning_once( "Current platform %s does not have '%s' attribute.", self.device_type, key, diff --git a/vllm/platforms/rocm.py b/vllm/platforms/rocm.py index 89471e844d8f..6c3a0fe96ecc 100644 --- a/vllm/platforms/rocm.py +++ b/vllm/platforms/rocm.py @@ -27,8 +27,10 @@ try: from amdsmi import ( AmdSmiException, + AmdSmiMemoryType, amdsmi_get_gpu_asic_info, amdsmi_get_gpu_device_uuid, + amdsmi_get_gpu_memory_total, amdsmi_get_processor_handles, amdsmi_init, amdsmi_shut_down, @@ -117,6 +119,14 @@ def _sync_hip_cuda_env_vars(): hip_val = os.environ.get("HIP_VISIBLE_DEVICES") or None cuda_val = os.environ.get("CUDA_VISIBLE_DEVICES") or None + if cuda_val is not None: + logger.warning_once( + "Using CUDA_VISIBLE_DEVICES on ROCm is deprecated and support " + "will be removed in vLLM v0.26.0. Please use HIP_VISIBLE_DEVICES " + "instead.", + scope="process", + ) + if hip_val is not None and cuda_val is not None: if hip_val != cuda_val: raise ValueError( @@ -134,6 +144,7 @@ def _sync_hip_cuda_env_vars(): # Sync at import time - catches misconfigurations from process start. _sync_hip_cuda_env_vars() + # AMDSMI utils # Note that NVML is not affected by `{CUDA/HIP}_VISIBLE_DEVICES`, # all the related functions work on real physical device ids. @@ -166,6 +177,14 @@ def _query_gcn_arch_from_amdsmi() -> str: raise RuntimeError("amdsmi did not return valid GCN arch") +@with_amdsmi_context +def _query_total_memory_from_amdsmi(physical_device_id: int) -> int: + """Query total VRAM (bytes) from amdsmi. Raises if not available.""" + handles = amdsmi_get_processor_handles() + handle = handles[physical_device_id] + return amdsmi_get_gpu_memory_total(handle, AmdSmiMemoryType.VRAM) + + def _get_gcn_arch() -> str: """ Get GCN arch via amdsmi (no CUDA init), fallback to torch.cuda. @@ -312,6 +331,17 @@ def on_gfx950() -> bool: return _ON_GFX950 +# Enable HIP online tuning early, before hipBLASLt initializes. +# Turn on hipBLASLt online tuning if use AITER hipBLASLt GEMM. +if ( + envs.VLLM_ROCM_USE_AITER + and envs.VLLM_ROCM_USE_AITER_LINEAR + and envs.VLLM_ROCM_USE_AITER_LINEAR_HIPBMM + and on_mi3xx() +): + os.environ["HIP_ONLINE_TUNING"] = "1" + + @cache def use_rocm_custom_paged_attention( qtype: torch.dtype, @@ -428,15 +458,14 @@ class RocmPlatform(Platform): supported_quantization: list[str] = [ "awq", + "auto_awq", "awq_marlin", # will be overwritten with awq "gptq", - "gptq_marlin", "auto_gptq", "fp8", "deepseek_v4_fp8", "compressed-tensors", "fbgemm_fp8", - "gguf", "quark", "mxfp4", "mxfp8", @@ -448,6 +477,7 @@ class RocmPlatform(Platform): "modelopt_mixed", "fp8_per_tensor", "fp8_per_block", + "fp8_per_channel", "online", "gpt_oss_mxfp4", ] @@ -713,8 +743,22 @@ def get_device_uuid(cls, device_id: int = 0) -> str: @classmethod def get_device_total_memory(cls, device_id: int = 0) -> int: - device_props = torch.cuda.get_device_properties(device_id) - return device_props.total_memory + # Query total VRAM via amdsmi so we don't initialize a HIP context in + # the calling process. torch.cuda.get_device_properties() creates a + # HIP context, which makes vLLM fall back from `fork` to `spawn` for + # worker processes. Keeping this query context-free preserves `fork` + # where it is otherwise valid (e.g. out-of-tree models registered in + # the parent process). + try: + physical_device_id = cls.device_id_to_physical_device_id(device_id) + return _query_total_memory_from_amdsmi(physical_device_id) + except Exception as e: + logger.debug("Failed to get total memory via amdsmi: %s", e) + logger.warning_once( + "Failed to get total memory via amdsmi, falling back to " + "torch.cuda. This will initialize CUDA." + ) + return torch.cuda.get_device_properties(device_id).total_memory @classmethod def apply_config_platform_defaults(cls, vllm_config: "VllmConfig") -> None: diff --git a/vllm/platforms/xpu.py b/vllm/platforms/xpu.py index 5947bff9b080..cbfa579313b3 100644 --- a/vllm/platforms/xpu.py +++ b/vllm/platforms/xpu.py @@ -14,7 +14,6 @@ import vllm.envs as envs from vllm.logger import init_logger -from vllm.utils.torch_utils import supports_xpu_graph from vllm.v1.attention.backends.registry import AttentionBackendEnum from .interface import DeviceCapability, Platform, PlatformEnum @@ -29,6 +28,78 @@ logger = init_logger(__name__) +def get_mem_info_wrapper( + device: int | str | torch.device | None = None, +) -> tuple[int, int]: + """ + Get memory info for a device, compatible with torch.accelerator.get_memory_info API. + + Args: + device: Device specification. Can be: + - None: Use current XPU device + - int: Device index + - str: Device string (e.g., "xpu:0", "xpu") + - torch.device: Device object + + Returns: + Tuple[int, int]: (free_memory, total_memory) in bytes + """ + # Handle None - use current device + if device is None: + device = torch.xpu.current_device() + + # Handle torch.device objects + elif isinstance(device, torch.device): + if device.type != "xpu": + raise RuntimeError(f"Expected 'xpu' device, got '{device.type}'") + # If device index is not specified, use current device + device = ( + device.index if device.index is not None else torch.xpu.current_device() + ) + + # Handle string device specifications (e.g., "xpu:0", "xpu") + elif isinstance(device, str): + if not device.startswith("xpu"): + raise RuntimeError(f"Expected 'xpu' device string, got '{device}'") + # Parse device string + parts = device.split(":") + if len(parts) == 1: + # "xpu" -> use current device + device = torch.xpu.current_device() + elif len(parts) == 2: + # "xpu:0" -> use index 0 + try: + device = int(parts[1]) + except ValueError as err: + raise RuntimeError( + f"Invalid device index: '{device}', expected integer after ':'" + ) from err + else: + raise RuntimeError(f"Invalid device string format: '{device}'") + + # At this point, device should be an int + if isinstance(device, int): + # bounds check + device_count = torch.xpu.device_count() + if not (0 <= device < device_count): + raise ValueError( + f"Invalid device index {device}, must be in range [0, {device_count})" + ) + + elif not isinstance(device, int): + raise TypeError( + f"device must be int, str, torch.device, or None, got {type(device)}" + ) + + # Call the underlying C++ implementation + free, total = torch.ops._C_cache_ops.getMemoryInfo(device) + + return free, total + + +torch.accelerator.get_memory_info = get_mem_info_wrapper + + class XPUPlatform(Platform): _enum = PlatformEnum.XPU device_name: str = "xpu" @@ -56,7 +127,7 @@ def get_attn_backend_cls( from vllm.v1.attention.backends.utils import set_kv_cache_layout set_kv_cache_layout("NHD") - logger.info( + logger.info_once( "Setting VLLM_KV_CACHE_LAYOUT to 'NHD' for XPU; " "only NHD layout is supported by XPU attention kernels." ) @@ -77,6 +148,15 @@ def get_attn_backend_cls( if selected_backend == AttentionBackendEnum.TRITON_ATTN: logger.info_once("Using Triton backend.") return AttentionBackendEnum.TRITON_ATTN.get_path() + elif attn_selector_config.use_mm_prefix: + # Flash Attention on XPU has no FA4 kernel, so it cannot apply the + # multimodal prefix-LM bidirectional mask. Fall back to Triton + # Attention, which supports mm_prefix. + logger.warning_once( + "Flash Attention on XPU does not support multimodal prefix-LM " + "attention. Falling back to Triton Attention backend." + ) + return AttentionBackendEnum.TRITON_ATTN.get_path() elif dtype == torch.float32: logger.warning_once( "Flash Attention on XPU does not support float32 dtype. " @@ -92,7 +172,7 @@ def get_attn_backend_cls( f"with use_mla: {attn_selector_config.use_mla}" ) - logger.info("Using Flash Attention backend.") + logger.info_once("Using Flash Attention backend.") return AttentionBackendEnum.FLASH_ATTN.get_path() @classmethod @@ -178,8 +258,6 @@ def get_static_graph_wrapper_cls(cls) -> str: @classmethod def check_and_update_config(cls, vllm_config: VllmConfig) -> None: - parallel_config = vllm_config.parallel_config - # lazy import to avoid circular import from vllm.config import CUDAGraphMode @@ -190,15 +268,19 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: attention_config = vllm_config.attention_config if attention_config.backend is None: attention_config.backend = AttentionBackendEnum.FLASH_ATTN + + # lazy import to avoid circular import + from vllm.utils.torch_utils import supports_xpu_graph + if not supports_xpu_graph(): compilation_config.cudagraph_mode = CUDAGraphMode.NONE - logger.warning( + logger.warning_once( "XPU Graph is not supported in the current PyTorch version, " "disabling cudagraph_mode." ) elif not envs.VLLM_XPU_ENABLE_XPU_GRAPH: compilation_config.cudagraph_mode = CUDAGraphMode.NONE - logger.warning( + logger.warning_once( "XPU Graph is disabled by environment variable, " "please set VLLM_XPU_ENABLE_XPU_GRAPH=1 to enable it." ) @@ -208,17 +290,18 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: pass_config = compilation_config.pass_config fusion_passes_to_disable = { - "enable_sp": "Sequence parallelism", "fuse_gemm_comms": "Async TP", "fuse_allreduce_rms": "AllReduce + RMSNorm fusion", "fuse_attn_quant": "Attention + quant fusion", "fuse_act_padding": "Activation + padding fusion", "fuse_rope_kvcache": "RoPE + KV cache fusion", + "fuse_rope_kvcache_cat_mla": "RoPE + KV cache + MLA fusion", + "enable_qk_norm_rope_fusion": "QK Norm + RoPE fusion", } if compilation_config.mode != CompilationMode.NONE: for flag, feature_name in fusion_passes_to_disable.items(): if getattr(pass_config, flag): - logger.warning( + logger.warning_once( "Feature %r is not yet supported on XPU and will be disabled.", feature_name, ) @@ -243,6 +326,16 @@ def check_and_update_config(cls, vllm_config: VllmConfig) -> None: if "VLLM_WORKER_MULTIPROC_METHOD" not in os.environ: os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + # XPU requires graceful shutdown to allow oneCCL/Level Zero resources + # to be properly released. Without this, subsequent server startups on + # the same devices may hang during CCL initialization. + if vllm_config.shutdown_timeout == 0: + vllm_config.shutdown_timeout = 5 + logger.info( + "XPU platform: set server shutdown_timeout=%d.", + vllm_config.shutdown_timeout, + ) + @classmethod def update_block_size_for_backend(cls, vllm_config: "VllmConfig") -> None: super().update_block_size_for_backend(vllm_config) @@ -325,9 +418,8 @@ def is_data_center_gpu(cls) -> bool: @classmethod def get_device_communicator_cls(cls) -> str: - from vllm.utils.torch_utils import supports_xccl - - if not supports_xccl(): + if not torch.distributed.is_xccl_available(): + # Supports xccl with PyTorch versions >= 2.8.0.dev for XPU platform logger.warning( "xccl is not enabled in this torch build, communication" " is not available." diff --git a/vllm/plugins/__init__.py b/vllm/plugins/__init__.py index 89fadad7a8f7..95e895c279be 100644 --- a/vllm/plugins/__init__.py +++ b/vllm/plugins/__init__.py @@ -3,10 +3,14 @@ import logging from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any import vllm.envs as envs +if TYPE_CHECKING: + from vllm.plugins.endpoint_plugins.interface import EndpointPlugin + from vllm.tasks import SupportedTask + logger = logging.getLogger(__name__) # Default plugins group will be loaded in all processes(process0, engine core @@ -20,6 +24,10 @@ # Stat logger plugins group will be loaded in process0 only when serve vLLM with # async mode. STAT_LOGGER_PLUGINS_GROUP = "vllm.stat_logger_plugins" +# Endpoint plugins group is loaded in the API server front end process only. +# Each entry point resolves to a factory returning an `EndpointPlugin` +# (see `vllm/plugins/endpoint_plugins/interface.py`). +ENDPOINT_PLUGINS_GROUP = "vllm.endpoint_plugins" # make sure one process only loads plugins once plugins_loaded = False @@ -80,3 +88,71 @@ def load_general_plugins(): # general plugins, we only need to execute the loaded functions for func in plugins.values(): func() + + +def load_endpoint_plugins( + supported_tasks: "tuple[SupportedTask, ...] | None" = None, +) -> "list[EndpointPlugin]": + """Discover, gate and instantiate `vllm.endpoint_plugins` entry points. + + Endpoint plugins add HTTP routes to the API server, so they default to + not loading. Unlike other plugin groups, a plugin here is only + considered when it is explicitly named in `VLLM_PLUGINS`. This is a + stricter posture than `load_plugins_by_group` which "load everything unless + an allowlist says otherwise". This posture is taken to handle potentially + larger exposed network surface. + + A discovered plugin is loaded only if both hold: + - it is named in `VLLM_PLUGINS` (enforced by not calling the loader + at all when `VLLM_PLUGINS` is unset). Note that `VLLM_PLUGINS=""` + parses to `[""]`, not `None`, so it is treated as a (non strict) + allowlist that matches no plugin name, not as "unset". + - its `required_tasks` is `None` or intersects `supported_tasks`. + + Args: + supported_tasks: Tasks the server supports. `None` means no plugin + with a non `None` `required_tasks` will be loaded. + + Returns: + Instantiated plugins that passed gating in discovery order. + """ + from importlib.metadata import entry_points + + if envs.VLLM_PLUGINS is None: + discovered = entry_points(group=ENDPOINT_PLUGINS_GROUP) + if discovered: + logger.warning( + "Found endpoint plugin(s) %s but VLLM_PLUGINS is not set. " + "Endpoint plugins add HTTP routes and must be explicitly " + "allowlisted via VLLM_PLUGINS to be loaded.", + [p.name for p in discovered], + ) + return [] + + factories = load_plugins_by_group(ENDPOINT_PLUGINS_GROUP) + + endpoint_plugins: list[EndpointPlugin] = [] + for name, factory in factories.items(): + try: + plugin = factory() + except Exception: + logger.exception("Failed to instantiate endpoint plugin %s", name) + continue + + required_tasks = plugin.required_tasks + if required_tasks is not None and ( + supported_tasks is None or not set(required_tasks) & set(supported_tasks) + ): + logger.info( + "Skipping endpoint plugin %s: requires one of tasks %s, " + "server supports %s", + name, + required_tasks, + supported_tasks, + ) + continue + + logger.info("Loaded endpoint plugin %s", name) + endpoint_plugins.append(plugin) + + return endpoint_plugins diff --git a/vllm/plugins/endpoint_plugins/__init__.py b/vllm/plugins/endpoint_plugins/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/vllm/plugins/endpoint_plugins/interface.py b/vllm/plugins/endpoint_plugins/interface.py new file mode 100644 index 000000000000..99487f57b68e --- /dev/null +++ b/vllm/plugins/endpoint_plugins/interface.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Contract for `vllm.endpoint_plugins` entry points. + +An endpoint plugin adds HTTP routes to the OpenAI compatible API server. +Its scope is HTTP surface only. It registers routes and optionally +per app state used by those routes. It must not open new paths into the +engine by reaching the engine the same way an in-tree serving handler does +via `EngineClient` (e.g. `engine_client.collective_rpc(...)`). + +If a plugin also needs engine side behavior (a new worker side RPC method, +a custom stat, etc.) pair this entry point with one registered under +`vllm.general_plugins` (see `vllm/plugins/__init__.py`). The +`general_plugins` entry installs the engine side method and the +`endpoint_plugins` entry exposes it over HTTP. The two are registered and +loaded independently where neither implies the other. + +Plugins are opt-in. See `load_endpoint_plugins` in `vllm/plugins/__init__.py` +for the loading/gating rules and `docs/usage/security.md` for the security +posture of exposing plugin defined routes. + +The CPU only render server (see `build_and_serve_renderer` in +`vllm/entrypoints/openai/api_server.py`) has no `EngineClient`. A plugin +eligible for the `render` task (`required_tasks` is `None` or includes +`"render"`) still gets `attach_router` called but `init_state` receives +`engine_client=None`. Plugins that cannot function without an engine should +either exclude `"render"` from `required_tasks` or check for `None` in +`init_state`/their route handlers and degrade gracefully. +""" + +from argparse import Namespace +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +from fastapi import FastAPI +from starlette.datastructures import State + +if TYPE_CHECKING: + from vllm.engine.protocol import EngineClient + from vllm.tasks import SupportedTask + + +@runtime_checkable +class EndpointPlugin(Protocol): + """Protocol implemented by `vllm.endpoint_plugins` entry point factories. + + An entry point registered under the `vllm.endpoint_plugins` group must + resolve to a zero argument callable (a class or factory function) that + returns an object satisfying this protocol. + """ + + name: str + """Unique plugin name used in logs and for `VLLM_PLUGINS` allowlisting.""" + + required_tasks: "tuple[SupportedTask, ...] | None" + """Tasks the server must support for this plugin to be loaded. + + The plugin is loaded only if this set intersects the server's + `supported_tasks`. `None` means the plugin has no task requirement and + is always eligible (subject to the `VLLM_PLUGINS` allowlist). + """ + + def attach_router(self, app: FastAPI) -> None: + """Register this plugin's routes on `app`. + + Called once during `build_app()` after all core routers have been + attached. Routes attached here can shadow core routes with the same + path. There is currently no conflict enforcement (see RFC #46565 follow ups). + """ + ... + + async def init_state( + self, engine_client: "EngineClient | None", state: State, args: Namespace + ) -> None: + """Initialize per app state consumed by this plugin's routes. + + Called once during `init_app_state()` after core state has been + initialized. Use `engine_client` (e.g. `collective_rpc`) to reach + the engine. Do not open new engine access paths. + + `engine_client` is `None` on the CPU only render server which has + no engine. This only happens for plugins eligible for the `render` + task (`required_tasks` is `None` or includes `"render"`). Handle + `None` explicitly (e.g. skip engine dependent setup, or have route + handlers return an error) if the plugin is loadable for `render` but + cannot function without an engine. + """ + ... diff --git a/vllm/pooling_params.py b/vllm/pooling_params.py index 3cfe9b427bd5..240c999ab4b9 100644 --- a/vllm/pooling_params.py +++ b/vllm/pooling_params.py @@ -182,6 +182,11 @@ def _set_default_parameters(self, model_config: ModelConfig): ) elif self.dimensions < 1: raise ValueError("Dimensions must be greater than 0") + elif self.dimensions > model_config.embedding_size: + raise ValueError( + "Dimensions must be less than or equal to the model's " + f"embedding size ({model_config.embedding_size})" + ) elif self.task in ["classify", "token_classify"]: if self.use_activation is None: diff --git a/vllm/reasoning/__init__.py b/vllm/reasoning/__init__.py index cd51f106503a..ba5ed4718521 100644 --- a/vllm/reasoning/__init__.py +++ b/vllm/reasoning/__init__.py @@ -13,8 +13,8 @@ Example: ReasoningParserManager.register_lazy_module( name="qwen3", - module_path="vllm.reasoning.qwen3_reasoning_parser", - class_name="Qwen3ReasoningParser", + module_path="vllm.reasoning.qwen3_engine_reasoning_parser", + class_name="Qwen3ParserReasoningAdapter", ) """ @@ -29,8 +29,8 @@ "DeepSeekV3ReasoningParser", ), "deepseek_v4": ( - "deepseek_v3_reasoning_parser", - "DeepSeekV3ReasoningParser", + "deepseek_v4_engine_reasoning_parser", + "DeepSeekV4ParserReasoningAdapter", ), "poolside_v1": ( "poolside_v1_reasoning_parser", @@ -49,12 +49,16 @@ "Ernie45ReasoningParser", ), "gemma4": ( - "gemma4_reasoning_parser", - "Gemma4ReasoningParser", + "gemma4_engine_reasoning_parser", + "Gemma4ParserReasoningAdapter", ), "glm45": ( - "deepseek_v3_reasoning_parser", - "DeepSeekV3ReasoningWithThinkingParser", + "glm47_moe_reasoning_parser", + "Glm47MoeParserReasoningAdapter", + ), + "glm47": ( + "glm47_moe_reasoning_parser", + "Glm47MoeParserReasoningAdapter", ), "openai_gptoss": ( "gptoss_reasoning_parser", @@ -81,8 +85,8 @@ "KimiK2ReasoningParser", ), "mimo": ( - "qwen3_reasoning_parser", - "Qwen3ReasoningParser", + "qwen3_engine_reasoning_parser", + "Qwen3ParserReasoningAdapter", ), "minimax_m2": ( "minimax_m2_reasoning_parser", @@ -92,25 +96,29 @@ "minimax_m2_reasoning_parser", "MiniMaxM2AppendThinkReasoningParser", ), + "minimax_m3": ( + "minimax_m3_reasoning_parser", + "MiniMaxM3ReasoningParser", + ), "mistral": ( "mistral_reasoning_parser", "MistralReasoningParser", ), "nemotron_v3": ( - "nemotron_v3_reasoning_parser", - "NemotronV3ReasoningParser", + "nemotron_v3_engine_reasoning_parser", + "NemotronV3ParserReasoningAdapter", ), "olmo3": ( "olmo3_reasoning_parser", "Olmo3ReasoningParser", ), "qwen3": ( - "qwen3_reasoning_parser", - "Qwen3ReasoningParser", + "qwen3_engine_reasoning_parser", + "Qwen3ParserReasoningAdapter", ), "seed_oss": ( - "seedoss_reasoning_parser", - "SeedOSSReasoningParser", + "seed_oss_engine_reasoning_parser", + "SeedOssParserReasoningAdapter", ), "step3": ( "step3_reasoning_parser", diff --git a/vllm/reasoning/abs_reasoning_parsers.py b/vllm/reasoning/abs_reasoning_parsers.py index 8edbc5f82efd..04d6a937b3f4 100644 --- a/vllm/reasoning/abs_reasoning_parsers.py +++ b/vllm/reasoning/abs_reasoning_parsers.py @@ -31,6 +31,8 @@ class ReasoningParser: It is used to extract reasoning content from the model output. """ + engine_based_streaming: bool = False + def __init__(self, tokenizer: "TokenizerLike", *args, **kwargs): self.model_tokenizer = tokenizer # Optional vLLM ModelConfig from the server. Use get (not pop) so composite @@ -39,7 +41,7 @@ def __init__(self, tokenizer: "TokenizerLike", *args, **kwargs): @cached_property def vocab(self) -> dict[str, int]: - # NOTE: Only PreTrainedTokenizerFast is guaranteed to have .vocab + # NOTE: Only TokenizersBackend is guaranteed to have .vocab # whereas all tokenizers have .get_vocab() return self.model_tokenizer.get_vocab() @@ -57,6 +59,17 @@ def reasoning_end_str(self) -> str | None: """ return None + def has_engine_confirmed_reasoning_end(self) -> bool: + """Whether the engine has confirmed the reasoning end transition. + + Engine-based parsers may defer terminal processing when the + detokenizer holds back text. This method returns the engine's + *processed* state, not a raw token-ID check. + + Only called for parsers with ``engine_based_streaming = True``. + """ + return False + @abstractmethod def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: """ @@ -174,6 +187,18 @@ def adjust_request( """Adjust request parameters; override in subclasses as needed.""" return request + def adjust_initial_state_from_prompt(self, prompt_token_ids: Sequence[int]) -> None: + """Hook called once at the start of streaming with the prompt tokens. + + Gives parsers a chance to adjust their initial parsing state based on + the prompt — for example, when the chat template leaves the prompt + inside an open reasoning channel and the engine's default initial + state would otherwise misclassify the first generated tokens. + + Default is a no-op; override in subclasses as needed. + """ + return + def prepare_structured_tag( self, original_tag: str | None, @@ -181,9 +206,8 @@ def prepare_structured_tag( ) -> str | None: """ Instance method that is implemented for preparing the structured tag - Otherwise, None is returned """ - return None + return original_tag class ReasoningParserManager: @@ -286,8 +310,8 @@ def register_lazy_module(cls, name: str, module_path: str, class_name: str) -> N Example: ReasoningParserManager.register_lazy_module( name="qwen3", - module_path="vllm.reasoning.parsers.qwen3_reasoning_parser", - class_name="Qwen3ReasoningParser", + module_path="vllm.reasoning.qwen3_engine_reasoning_parser", + class_name="Qwen3ParserReasoningAdapter", ) """ cls.lazy_parsers[name] = (module_path, class_name) diff --git a/vllm/reasoning/cohere_command_reasoning_parser.py b/vllm/reasoning/cohere_command_reasoning_parser.py index b28a59089e73..f0e7aed0b034 100644 --- a/vllm/reasoning/cohere_command_reasoning_parser.py +++ b/vllm/reasoning/cohere_command_reasoning_parser.py @@ -20,7 +20,6 @@ ) from e -from vllm.entrypoints.mcp.tool_server import ToolServer from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ) @@ -90,7 +89,7 @@ class CohereNormalizedTool(TypedDict): tools=COMMAND_A_TOOLS_TAG, ), "Cohere2MoeForCausalLM": CohereTagStyle( - json_tags=(COMMAND_A_JSON_TAG,), + json_tags=(COMMAND_A_JSON_TAG, COMMAND_A_PLUS_JSON_TAG), tools=COMMAND_A_TOOLS_TAG, ), } @@ -415,7 +414,9 @@ def __init__( **kwargs, ): super().__init__(tokenizer, *args, **kwargs) + self.start_token_id = tokenizer.convert_tokens_to_ids("<|START_THINKING|>") self.end_token_id = tokenizer.convert_tokens_to_ids("<|END_THINKING|>") + self.chatbot_token_id = tokenizer.convert_tokens_to_ids("<|CHATBOT_TOKEN|>") self.unary_opts = unary_opts self.melody_unary = PyFilter(unary_opts) self.melody_streaming = PyFilter(streaming_opts) @@ -479,16 +480,21 @@ def extract_content_ids(self, input_ids: list[int]) -> list[int]: return content_ids def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: - return any(tid == self.end_token_id for tid in reversed(input_ids)) - - def prepare_structured_tag( - self, original_tag: str | None, tool_server: ToolServer | None - ) -> str | None: - # Responses API replaces ``structural_tag`` via the reasoning parser. - # Default ``ReasoningParser.prepare_structured_tag`` returns None, which - # would clear a Cohere tag produced in ``adjust_request`` and break - # ``StructuredOutputsParams`` validation. Preserve the existing tag. - return original_tag + chatbot = self.chatbot_token_id + start = self.start_token_id + end = self.end_token_id + has_end_token = False + + for i in reversed(range(len(input_ids))): + tid = input_ids[i] + if tid == start: + return has_end_token + if tid == chatbot: + return False + if tid == end: + has_end_token = True + + return has_end_token def adjust_request( self, request: ChatCompletionRequest | ResponsesRequest diff --git a/vllm/reasoning/deepseek_v3_reasoning_parser.py b/vllm/reasoning/deepseek_v3_reasoning_parser.py index bb79afd8dede..dbaf0b1cf897 100644 --- a/vllm/reasoning/deepseek_v3_reasoning_parser.py +++ b/vllm/reasoning/deepseek_v3_reasoning_parser.py @@ -6,7 +6,6 @@ from transformers import PreTrainedTokenizerBase -from vllm.logger import init_logger from vllm.reasoning import ReasoningParser from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser @@ -17,8 +16,6 @@ from vllm.entrypoints.openai.engine.protocol import DeltaMessage from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - class DeepSeekV3ReasoningParser(ReasoningParser): """ diff --git a/vllm/reasoning/deepseek_v4_engine_reasoning_parser.py b/vllm/reasoning/deepseek_v4_engine_reasoning_parser.py new file mode 100644 index 000000000000..6fa6444b35cd --- /dev/null +++ b/vllm/reasoning/deepseek_v4_engine_reasoning_parser.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import DeepSeekV4ParserReasoningAdapter + +__all__ = ["DeepSeekV4ParserReasoningAdapter"] diff --git a/vllm/reasoning/ernie45_reasoning_parser.py b/vllm/reasoning/ernie45_reasoning_parser.py index 593eba4ecb4a..1c868f8b2ea6 100644 --- a/vllm/reasoning/ernie45_reasoning_parser.py +++ b/vllm/reasoning/ernie45_reasoning_parser.py @@ -7,15 +7,12 @@ from transformers import PreTrainedTokenizerBase from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.logger import init_logger from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser if TYPE_CHECKING: from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - class Ernie45ReasoningParser(BaseThinkingReasoningParser): """ @@ -117,7 +114,8 @@ def extract_reasoning_streaming( content = content[:response_end_idx] elif self.response_end_token_id in delta_token_ids: response_end_idx = content.rfind(self.response_end_token) - content = content[:response_end_idx] + if response_end_idx != -1: + content = content[:response_end_idx] # remove \n after or if previous_token_ids[-1] in self.parser_token_ids and ( len(delta_token_ids) > 0 and delta_token_ids[0] == self.newline_token_id diff --git a/vllm/reasoning/gemma4_engine_reasoning_parser.py b/vllm/reasoning/gemma4_engine_reasoning_parser.py new file mode 100644 index 000000000000..e9bc46e9bfb8 --- /dev/null +++ b/vllm/reasoning/gemma4_engine_reasoning_parser.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import Gemma4ParserReasoningAdapter + +__all__ = ["Gemma4ParserReasoningAdapter"] diff --git a/vllm/reasoning/gemma4_reasoning_parser.py b/vllm/reasoning/gemma4_reasoning_parser.py deleted file mode 100644 index 6f2241603f9a..000000000000 --- a/vllm/reasoning/gemma4_reasoning_parser.py +++ /dev/null @@ -1,225 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from collections.abc import Sequence -from typing import TYPE_CHECKING - -from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser -from vllm.tokenizers import TokenizerLike - -if TYPE_CHECKING: - from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, - ) - from vllm.entrypoints.openai.responses.protocol import ResponsesRequest - -# Role label that Gemma4 emits at the start of the thinking channel. -# The model generates: <|channel>thought\n...reasoning... -# This prefix must be stripped to expose only the actual reasoning content. -_THOUGHT_PREFIX = "thought\n" - - -class Gemma4ReasoningParser(BaseThinkingReasoningParser): - """ - Reasoning parser for Google Gemma4 thinking models. - - Gemma4 uses <|channel>... tokens to delimit reasoning/thinking - content within its output. Thinking mode is activated by passing - ``enable_thinking=True`` in the chat template kwargs, which injects a - system turn containing <|think|> (token 98) to trigger chain-of-thought - reasoning. - - Output pattern when thinking is enabled:: - - <|channel>thought - ...chain of thought reasoning... - Final answer text here. - - The ``thought\\n`` role label inside the channel delimiters is a - structural artefact (analogous to ``user\\n`` in ``<|turn>user\\n...``). - This parser strips it so that downstream consumers see only the - actual reasoning text, consistent with the offline parser - (``vllm.reasoning.gemma4_utils._strip_thought_label``). - """ - - def __init__(self, tokenizer: TokenizerLike, *args, **kwargs): - super().__init__(tokenizer, *args, **kwargs) - # Instance state for streaming prefix stripping. - # Tracks only the reasoning text received from the base parser, - # independent of current_text (which may contain pre-reasoning - # content and lacks special token text due to - # skip_special_tokens=True). - self._reasoning_text: str = "" - self._prefix_stripped: bool = False - self.new_turn_token_id = self.vocab["<|turn>"] - self.tool_call_token_id = self.vocab["<|tool_call>"] - self.tool_response_token_id = self.vocab["<|tool_response>"] - - def adjust_request( - self, request: "ChatCompletionRequest | ResponsesRequest" - ) -> "ChatCompletionRequest | ResponsesRequest": - """Disable special-token stripping to preserve boundary tokens.""" - request.skip_special_tokens = False - return request - - @property - def start_token(self) -> str: - """The token that starts reasoning content.""" - return "<|channel>" - - @property - def end_token(self) -> str: - """The token that ends reasoning content.""" - return "" - - def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: - start_token_id = self.start_token_id - end_token_id = self.end_token_id - new_turn_token_id = self.new_turn_token_id - tool_call_token_id = self.tool_call_token_id - tool_response_token_id = self.tool_response_token_id - - # Search from the end of input_ids to find the last match. - for i in range(len(input_ids) - 1, -1, -1): - if input_ids[i] == start_token_id: - return False - if input_ids[i] == tool_call_token_id: - # We're generating a tool call, so reasoning must be ended. - return True - if input_ids[i] in (new_turn_token_id, tool_response_token_id): - # We found a new turn or tool response token so don't consider - # reasoning ended yet, since the model starts new reasoning - # after these tokens. - return False - if input_ids[i] == end_token_id: - return True - return False - - # ------------------------------------------------------------------ - # Non-streaming path - # ------------------------------------------------------------------ - - def extract_reasoning( - self, - model_output: str, - request: "ChatCompletionRequest | ResponsesRequest", - ) -> tuple[str | None, str | None]: - """Extract reasoning, stripping the ``thought\\n`` role label.""" - if self.start_token not in model_output and self.end_token not in model_output: - # Default to content history if no tags are present - # (or if they were stripped) - return None, model_output - - reasoning, content = super().extract_reasoning(model_output, request) - if reasoning is not None: - reasoning = _strip_thought_label(reasoning) - return reasoning, content - - # ------------------------------------------------------------------ - # Streaming path - # ------------------------------------------------------------------ - - def extract_reasoning_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - ) -> DeltaMessage | None: - """Extract streaming reasoning, stripping ``thought\\n`` from the - first reasoning delta(s). - - The ``thought\\n`` prefix may arrive as a single delta or split - across multiple deltas (e.g. ``"thought"`` then ``"\\n"``). We - buffer early reasoning tokens until we can determine whether the - prefix is present, then emit the buffered content minus the - prefix. - - Unlike the previous implementation which reconstructed accumulated - reasoning from ``current_text``, this uses instance state - (``_reasoning_text``) to track only the reasoning content returned - by the base parser. This is necessary because - ``skip_special_tokens=True`` (the vLLM default) causes the - ``<|channel>`` delimiter to be invisible in ``current_text``, - making it impossible to separate pre-reasoning content from - reasoning content via string matching. - """ - result = super().extract_reasoning_streaming( - previous_text, - current_text, - delta_text, - previous_token_ids, - current_token_ids, - delta_token_ids, - ) - if result is None: - return None - - if result.reasoning is None: - return result - - # Accumulate ONLY the reasoning text from base parser results. - # This is immune to pre-reasoning content pollution. - self._reasoning_text += result.reasoning - - # Once the prefix has been handled, all subsequent reasoning - # deltas pass through unchanged. - if self._prefix_stripped: - return result - - # ---- Prefix stripping logic ---- - - # Case 1: We've accumulated enough to confirm the prefix is - # present. Strip it and pass through the remainder. - if self._reasoning_text.startswith(_THOUGHT_PREFIX): - prefix_len = len(_THOUGHT_PREFIX) - # How much reasoning was accumulated before this delta? - prev_reasoning_len = len(self._reasoning_text) - len(result.reasoning) - if prev_reasoning_len >= prefix_len: - # Prefix was already consumed by prior deltas; this - # delta is entirely real content — pass through. - self._prefix_stripped = True - return result - else: - # Part or all of the prefix is in this delta. - chars_of_prefix_in_delta = prefix_len - prev_reasoning_len - stripped = result.reasoning[chars_of_prefix_in_delta:] - if stripped: - self._prefix_stripped = True - result.reasoning = stripped - return result - else: - if len(self._reasoning_text) >= prefix_len: - self._prefix_stripped = True - result.reasoning = "" - return result - return None - - # Case 2: Accumulated text is a strict prefix of - # _THOUGHT_PREFIX (e.g. we've only seen "thou" so far). - # Buffer by suppressing — we can't yet tell if this will - # become the full prefix or diverge. - if _THOUGHT_PREFIX.startswith(self._reasoning_text): - return None - - # Case 3: Accumulated text doesn't match the thought prefix - # at all. This means prior deltas were buffered (suppressed - # by Case 2) but the text diverged. Re-emit the full - # accumulated text to avoid data loss. - self._prefix_stripped = True - result.reasoning = self._reasoning_text - return result - - -def _strip_thought_label(text: str) -> str: - """Remove the ``thought\\n`` role label from the beginning of text. - - Mirrors ``vllm.reasoning.gemma4_utils._strip_thought_label`` from the - offline parser. - """ - if text.startswith(_THOUGHT_PREFIX): - return text[len(_THOUGHT_PREFIX) :] - return text diff --git a/vllm/reasoning/glm47_moe_reasoning_parser.py b/vllm/reasoning/glm47_moe_reasoning_parser.py new file mode 100644 index 000000000000..8e963f88b09c --- /dev/null +++ b/vllm/reasoning/glm47_moe_reasoning_parser.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import Glm47MoeParserReasoningAdapter + +__all__ = ["Glm47MoeParserReasoningAdapter"] diff --git a/vllm/reasoning/gptoss_reasoning_parser.py b/vllm/reasoning/gptoss_reasoning_parser.py index 1ba933cca31e..d7bdca829126 100644 --- a/vllm/reasoning/gptoss_reasoning_parser.py +++ b/vllm/reasoning/gptoss_reasoning_parser.py @@ -8,7 +8,6 @@ from vllm.entrypoints.mcp.tool_server import ToolServer from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.entrypoints.openai.parser.harmony_utils import parse_chat_output from vllm.logger import init_logger from vllm.reasoning import ReasoningParser @@ -132,10 +131,10 @@ def is_reasoning_end_streaming( return self.is_reasoning_end(input_ids[n - window :]) def extract_content_ids(self, input_ids: list[int]) -> list[int]: - _, content, _ = parse_chat_output(input_ids) - if content is None: - return [] - return self.model_tokenizer.encode(content) + raise NotImplementedError( + "GptOssReasoningParser only provides boundary detection. " + "Use HarmonyParser for output parsing." + ) def extract_reasoning_streaming( self, @@ -146,25 +145,10 @@ def extract_reasoning_streaming( current_token_ids: Sequence[int], delta_token_ids: Sequence[int], ) -> DeltaMessage | None: - prev_reasoning, prev_content, _ = parse_chat_output(list(previous_token_ids)) - cur_reasoning, cur_content, _ = parse_chat_output(list(current_token_ids)) - reasoning_delta = None - content_delta = None - if cur_reasoning is not None: - prev_r = prev_reasoning or "" - if cur_reasoning.startswith(prev_r): - reasoning_delta = cur_reasoning[len(prev_r) :] or None - else: - reasoning_delta = cur_reasoning - if cur_content is not None: - prev_c = prev_content or "" - if cur_content.startswith(prev_c): - content_delta = cur_content[len(prev_c) :] or None - else: - content_delta = cur_content - if reasoning_delta is None and content_delta is None: - return None - return DeltaMessage(reasoning=reasoning_delta, content=content_delta) + raise NotImplementedError( + "GptOssReasoningParser only provides boundary detection. " + "Use HarmonyParser for output parsing." + ) def extract_reasoning( self, @@ -172,7 +156,8 @@ def extract_reasoning( request: "ChatCompletionRequest | ResponsesRequest", ) -> tuple[str | None, str | None]: raise NotImplementedError( - "gpt-oss has a special branch for parsing reasoning in non-streaming mode. This method shouldn't be used." # noqa: E501 + "GptOssReasoningParser only provides boundary detection. " + "Use HarmonyParser for output parsing." ) # This function prepares the structural tag to format reasoning output diff --git a/vllm/reasoning/granite_reasoning_parser.py b/vllm/reasoning/granite_reasoning_parser.py index 2d8052f614db..c6d63fc36148 100644 --- a/vllm/reasoning/granite_reasoning_parser.py +++ b/vllm/reasoning/granite_reasoning_parser.py @@ -8,15 +8,12 @@ from transformers import PreTrainedTokenizerBase from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.logger import init_logger from vllm.reasoning import ReasoningParser if TYPE_CHECKING: from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - class GraniteReasoningParser(ReasoningParser): """ diff --git a/vllm/reasoning/hunyuan_a13b_reasoning_parser.py b/vllm/reasoning/hunyuan_a13b_reasoning_parser.py index f833f8f32f64..257dc0f95409 100644 --- a/vllm/reasoning/hunyuan_a13b_reasoning_parser.py +++ b/vllm/reasoning/hunyuan_a13b_reasoning_parser.py @@ -8,15 +8,12 @@ from transformers import PreTrainedTokenizerBase from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.logger import init_logger from vllm.reasoning import ReasoningParser if TYPE_CHECKING: from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - class HunyuanA13BReasoningParser(ReasoningParser): """ diff --git a/vllm/reasoning/hy_v3_reasoning_parser.py b/vllm/reasoning/hy_v3_reasoning_parser.py index 5beac22996dd..59631a334053 100644 --- a/vllm/reasoning/hy_v3_reasoning_parser.py +++ b/vllm/reasoning/hy_v3_reasoning_parser.py @@ -26,6 +26,8 @@ class HYV3ReasoningParser(BaseThinkingReasoningParser): """ def __init__(self, tokenizer: TokenizerLike, *args, **kwargs): + init_kwargs = getattr(tokenizer, "init_kwargs", None) or {} + self.suffix: str = init_kwargs.get("token_suffix") or "" super().__init__(tokenizer, *args, **kwargs) # First, If there is reasoning_effort in chat_kwargs, @@ -52,12 +54,12 @@ def __init__(self, tokenizer: TokenizerLike, *args, **kwargs): @property def start_token(self) -> str: """The token that starts reasoning content.""" - return "" + return f"" @property def end_token(self) -> str: """The token that ends reasoning content.""" - return "" + return f"" def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: if self._identity_parser is not None: diff --git a/vllm/reasoning/identity_reasoning_parser.py b/vllm/reasoning/identity_reasoning_parser.py index c6f117e2f983..ee35360ea6c7 100644 --- a/vllm/reasoning/identity_reasoning_parser.py +++ b/vllm/reasoning/identity_reasoning_parser.py @@ -7,15 +7,12 @@ from transformers import PreTrainedTokenizerBase from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.logger import init_logger from vllm.reasoning import ReasoningParser if TYPE_CHECKING: from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - class IdentityReasoningParser(ReasoningParser): """ diff --git a/vllm/reasoning/kimi_k2_reasoning_parser.py b/vllm/reasoning/kimi_k2_reasoning_parser.py index 0b64c5c62ea1..45f99965bc78 100644 --- a/vllm/reasoning/kimi_k2_reasoning_parser.py +++ b/vllm/reasoning/kimi_k2_reasoning_parser.py @@ -1,245 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING +from vllm.parser.engine.registered_adapters import KimiK2ParserReasoningAdapter -from transformers import PreTrainedTokenizerBase +KimiK2ReasoningParser = KimiK2ParserReasoningAdapter -from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.reasoning.abs_reasoning_parsers import ReasoningParser -from vllm.reasoning.identity_reasoning_parser import IdentityReasoningParser - -if TYPE_CHECKING: - from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest - from vllm.entrypoints.openai.responses.protocol import ResponsesRequest - - -class KimiK2ReasoningParser(ReasoningParser): - """ - Reasoning parser for Kimi K2 model. - - The Kimi K2 model uses ... tokens to denote reasoning text, - and may implicitly end reasoning by starting a tool call section using - <|tool_calls_section_begin|>. - Thinking may also begin without a token. - - Kimi's thinking mode can be disabled via chat_template_kwargs. - """ - - def __init__(self, tokenizer: PreTrainedTokenizerBase, *args, **kwargs): - super().__init__(tokenizer, *args, **kwargs) - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ReasoningParser " - "constructor during construction." - ) - - # Check if thinking is disabled via chat_template_kwargs - chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} - thinking = bool(chat_kwargs.get("thinking", True)) - - # If thinking is not enabled, use identity parser to fall through - self._identity_parser: IdentityReasoningParser | None - if not thinking: - self._identity_parser = IdentityReasoningParser(tokenizer, *args, **kwargs) - else: - self._identity_parser = None - - # Token definitions - self._start_token = "" - self._end_token = "" - self._tool_section_start_token = "<|tool_calls_section_begin|>" - - # Get token IDs - self._start_token_id = self.vocab.get(self._start_token) - self._end_token_id = self.vocab.get(self._end_token) - self._tool_section_start_token_id = self.vocab.get( - self._tool_section_start_token - ) - - if self._start_token_id is None or self._end_token_id is None: - raise RuntimeError( - "KimiK2ReasoningParser could not locate think start/end " - "tokens in the tokenizer!" - ) - - @property - def reasoning_start_str(self) -> str | None: - return self._start_token - - @property - def reasoning_end_str(self) -> str | None: - return self._end_token - - def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: - """ - Check if the reasoning content ends in the input_ids. - - Reasoning ends when we see either: - 1. The end token () - 2. The tool section start token (<|tool_calls_section_begin|>) - """ - if self._identity_parser is not None: - return self._identity_parser.is_reasoning_end(input_ids) - - start_token_id = self._start_token_id - end_token_id = self._end_token_id - tool_section_start_token_id = self._tool_section_start_token_id - - for i in range(len(input_ids) - 1, -1, -1): - if input_ids[i] == start_token_id: - return False - if input_ids[i] == end_token_id: - return True - # Implicit reasoning end via tool call section - if ( - tool_section_start_token_id is not None - and input_ids[i] == tool_section_start_token_id - ): - return True - return False - - def is_reasoning_end_streaming( - self, input_ids: Sequence[int], delta_ids: Iterable[int] - ) -> bool: - """ - Check if the reasoning content ends in the input_ids on a decode step. - """ - if self._identity_parser is not None: - return self._identity_parser.is_reasoning_end_streaming( - input_ids, delta_ids - ) - - # Materialize iterable for membership checks - delta_ids_set = set(delta_ids) - - # Check for explicit end token or implicit tool section start in delta - if self._end_token_id in delta_ids_set: - return True - return ( - self._tool_section_start_token_id is not None - and self._tool_section_start_token_id in delta_ids_set - ) - - def extract_content_ids(self, input_ids: list[int]) -> list[int]: - """ - Extract content token ids from the input_ids. - """ - if self._identity_parser is not None: - return self._identity_parser.extract_content_ids(input_ids) - - if self._end_token_id in input_ids: - end_token_index = ( - len(input_ids) - 1 - input_ids[::-1].index(self._end_token_id) - ) - - if end_token_index != -1: - return input_ids[end_token_index + 1 :] - - if ( - self._tool_section_start_token_id is not None - and self._tool_section_start_token_id in input_ids - ): - tool_section_index = ( - len(input_ids) - - 1 - - input_ids[::-1].index(self._tool_section_start_token_id) - ) - - if tool_section_index != -1: - return input_ids[tool_section_index:] - - # still reasoning (no content) - return [] - - def extract_reasoning( - self, model_output: str, request: "ChatCompletionRequest | ResponsesRequest" - ) -> tuple[str | None, str | None]: - """ - Extract reasoning content from the model output. - """ - if self._identity_parser is not None: - return self._identity_parser.extract_reasoning(model_output, request) - - # thinking does not require a think start token but consume it if present - start_token_index = model_output.find(self._start_token) - start_token_index = 0 if start_token_index != 0 else len(self._start_token) - end_token_index = model_output.find(self._end_token) - - if end_token_index != -1: - return ( - model_output[start_token_index:end_token_index], - model_output[end_token_index + len(self._end_token) :] or None, - ) - - tool_section_index = model_output.find(self._tool_section_start_token) - if tool_section_index != -1: - return ( - model_output[start_token_index:tool_section_index], - model_output[tool_section_index:] or None, - ) - - # still reasoning (no content) - return ( - model_output[start_token_index:], - None, - ) - - def extract_reasoning_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - ) -> DeltaMessage | None: - """ - Extract reasoning content from a delta message during streaming. - """ - if self._identity_parser is not None: - return self._identity_parser.extract_reasoning_streaming( - previous_text, - current_text, - delta_text, - previous_token_ids, - current_token_ids, - delta_token_ids, - ) - - # If reasoning has already ended in previous tokens, this is content - if self.is_reasoning_end(previous_token_ids): - return DeltaMessage(content=delta_text) - - # Skip single special tokens - if len(delta_token_ids) == 1 and delta_token_ids[0] in [ - self._start_token_id, - self._end_token_id, - ]: - return None - - if self._end_token_id in delta_token_ids: - if self._end_token not in delta_text: - # Token ID arrived before text was flushed (stop-sequence buffering). - # Wait for the next delta when the text becomes visible. - return None - end_index = delta_text.find(self._end_token) - reasoning = delta_text[:end_index] - content = delta_text[end_index + len(self._end_token) :] - return DeltaMessage( - reasoning=reasoning, content=content if content else None - ) - - if self._tool_section_start_token_id in delta_token_ids: - if self._tool_section_start_token not in delta_text: - # Token ID arrived before text was flushed (stop-sequence buffering). - return None - tool_index = delta_text.find(self._tool_section_start_token) - reasoning = delta_text[:tool_index] - content = delta_text[tool_index:] - return DeltaMessage(reasoning=reasoning, content=content) - - # still reasoning (no end token) - return DeltaMessage(reasoning=delta_text) +__all__ = ["KimiK2ReasoningParser"] diff --git a/vllm/reasoning/minimax_m2_reasoning_parser.py b/vllm/reasoning/minimax_m2_reasoning_parser.py index b2f3db5bbfdb..9c3a502e4f86 100644 --- a/vllm/reasoning/minimax_m2_reasoning_parser.py +++ b/vllm/reasoning/minimax_m2_reasoning_parser.py @@ -7,19 +7,16 @@ from vllm.entrypoints.openai.engine.protocol import ( DeltaMessage, ) -from vllm.logger import init_logger +from vllm.parser.engine.registered_adapters import MinimaxM2ParserReasoningAdapter from vllm.reasoning.abs_reasoning_parsers import ReasoningParser -from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser from vllm.tokenizers import TokenizerLike if TYPE_CHECKING: from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - -class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser): +class MiniMaxM2ReasoningParser(MinimaxM2ParserReasoningAdapter): # type: ignore[valid-type, misc] """ Reasoning parser for MiniMax M2 model. @@ -28,55 +25,6 @@ class MiniMaxM2ReasoningParser(BaseThinkingReasoningParser): actual response. """ - @property - def start_token(self) -> str: - """The token that starts reasoning content.""" - return "" - - @property - def end_token(self) -> str: - """The token that ends reasoning content.""" - return "" - - def extract_reasoning_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - ) -> DeltaMessage | None: - """ - Extract reasoning content from a delta message for streaming. - - MiniMax M2 models don't generate start token, so we assume - all content is reasoning until we encounter the end token. - """ - # Skip single end token - if len(delta_token_ids) == 1 and delta_token_ids[0] == self.end_token_id: - return None - - # Check if end token has already appeared in previous tokens - # meaning we're past the reasoning phase - if self.end_token_id in previous_token_ids: - # We're past the reasoning phase, this is content - return DeltaMessage(content=delta_text) - - # Check if end token is in delta tokens - if self.end_token_id in delta_token_ids: - # End token in delta, split reasoning and content - end_index = delta_text.find(self.end_token) - reasoning = delta_text[:end_index] - content = delta_text[end_index + len(self.end_token) :] - return DeltaMessage( - reasoning=reasoning if reasoning else None, - content=content if content else None, - ) - - # No end token yet, all content is reasoning - return DeltaMessage(reasoning=delta_text) - class MiniMaxM2AppendThinkReasoningParser(ReasoningParser): """ diff --git a/vllm/reasoning/minimax_m3_reasoning_parser.py b/vllm/reasoning/minimax_m3_reasoning_parser.py new file mode 100644 index 000000000000..52d2851e2a4c --- /dev/null +++ b/vllm/reasoning/minimax_m3_reasoning_parser.py @@ -0,0 +1,320 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING + +from vllm.entrypoints.openai.engine.protocol import DeltaMessage +from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser + +if TYPE_CHECKING: + from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest + from vllm.entrypoints.openai.responses.protocol import ResponsesRequest + + +class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser): + """Reasoning parser for MiniMax M3 explicit thinking blocks. + + MiniMax M3 emits reasoning as: + + reasoning textassistant content + + The M3 tokenizer exposes both markers as complete vocabulary entries, but + generated marker text may be tokenized into smaller pieces. The streaming + parser therefore uses text markers for extraction instead of relying on the + single vocabulary IDs. The chat template may also prefill the start marker + when ``thinking_mode="enabled"``, so generated text can begin directly + inside a reasoning block without emitting ```` again. + """ + + @property + def start_token(self) -> str: + return "" + + @property + def end_token(self) -> str: + return "" + + def __init__(self, tokenizer, *args, **kwargs): + super().__init__(tokenizer, *args, **kwargs) + self._start_token_ids = self._encode_marker(self.start_token) + self._end_token_ids = self._encode_marker(self.end_token) + chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} + self._initial_in_reasoning = chat_kwargs.get("thinking_mode") == "enabled" + self._reasoning_ended_streaming = False + self._reasoning_active_streaming = self._initial_in_reasoning + self._pending_marker_streaming = False + self._last_streaming_delta_token_ids: tuple[int, ...] | None = None + self._last_streaming_content_token_ids: list[int] | None = None + + def _encode_text(self, text: str) -> list[int]: + try: + return list(self.model_tokenizer.encode(text, add_special_tokens=False)) + except TypeError: + return list(self.model_tokenizer.encode(text)) + + def _encode_marker(self, marker: str) -> tuple[int, ...]: + return tuple(self._encode_text(marker)) + + def _decode_text(self, token_ids: Sequence[int]) -> str: + try: + return self.model_tokenizer.decode( + list(token_ids), skip_special_tokens=False + ) + except TypeError: + return self.model_tokenizer.decode(list(token_ids)) + + def _content_suffix_token_ids( + self, + delta_text: str, + delta_token_ids: Sequence[int], + content: str | None, + ) -> list[int]: + if content is None: + return [] + if content == delta_text: + return list(delta_token_ids) + if delta_text.endswith(content): + prefix_text = delta_text[: len(delta_text) - len(content)] + for index in range(len(delta_token_ids) + 1): + if self._decode_text(delta_token_ids[:index]) == prefix_text: + return list(delta_token_ids[index:]) + return self._encode_text(content) + + @staticmethod + def _contains_token_sequence( + token_ids: Sequence[int], marker_ids: Sequence[int] + ) -> bool: + if not marker_ids or len(marker_ids) > len(token_ids): + return False + marker_len = len(marker_ids) + return any( + tuple(token_ids[i : i + marker_len]) == tuple(marker_ids) + for i in range(len(token_ids) - marker_len + 1) + ) + + @staticmethod + def _rfind_token_sequence( + token_ids: Sequence[int], marker_ids: Sequence[int] + ) -> int: + if not marker_ids or len(marker_ids) > len(token_ids): + return -1 + marker_len = len(marker_ids) + for i in range(len(token_ids) - marker_len, -1, -1): + if tuple(token_ids[i : i + marker_len]) == tuple(marker_ids): + return i + return -1 + + @staticmethod + def _ends_with_token_sequence_prefix( + token_ids: Sequence[int], marker_ids: Sequence[int] + ) -> bool: + if not marker_ids: + return False + max_len = min(len(token_ids), len(marker_ids) - 1) + for prefix_len in range(max_len, 0, -1): + if tuple(token_ids[-prefix_len:]) == tuple(marker_ids[:prefix_len]): + return True + return False + + @staticmethod + def _strip_partial_marker_suffix(text: str, marker: str) -> str: + max_len = min(len(text), len(marker) - 1) + for suffix_len in range(max_len, 0, -1): + if marker.startswith(text[-suffix_len:]): + return text[:-suffix_len] + return text + + @staticmethod + def _visible_delta(previous: str | None, current: str | None) -> str | None: + if not current: + return None + if not previous: + return current + if current.startswith(previous): + delta = current[len(previous) :] + return delta or None + return current + + def _visible_segments(self, text: str) -> tuple[str | None, str | None]: + if not text: + return None, None + + if not self._initial_in_reasoning: + if self.end_token.startswith(text) and len(text) < len(self.end_token): + return None, None + if text.startswith(self.end_token): + text = text[len(self.end_token) :] + if not text: + return None, None + + if self._initial_in_reasoning and self.start_token not in text: + reasoning, end, content = text.partition(self.end_token) + if end: + return reasoning or None, content or None + reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token) + return reasoning or None, None + + if self.start_token not in text: + content = self._strip_partial_marker_suffix(text, self.start_token) + return None, content or None + + content_before, _, after_start = text.partition(self.start_token) + reasoning, end, content_after = after_start.partition(self.end_token) + if end: + return reasoning or None, (content_before + content_after) or None + + reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token) + return reasoning or None, content_before or None + + def extract_reasoning( + self, + model_output: str, + request: "ChatCompletionRequest | ResponsesRequest", + ) -> tuple[str | None, str | None]: + # MiniMax M3 can start a response with a stray closer. Drop that first + # token only; later unmatched closers stay visible as content. + if not self._initial_in_reasoning and model_output.startswith(self.end_token): + content = model_output[len(self.end_token) :] + return None, content or None + + if self._initial_in_reasoning and self.start_token not in model_output: + reasoning, end, content = model_output.partition(self.end_token) + if not end: + return model_output, None + return reasoning, content or None + + if self.start_token not in model_output: + return None, model_output + + content_before, _, after_start = model_output.partition(self.start_token) + reasoning, end, content_after = after_start.partition(self.end_token) + if not end: + return reasoning, content_before or None + + return reasoning, (content_before + content_after) or None + + def is_reasoning_end_streaming( + self, input_ids: Sequence[int], delta_ids: Iterable[int] + ) -> bool: + if self._reasoning_ended_streaming: + return True + + if self._reasoning_active_streaming or self._pending_marker_streaming: + return False + + delta_ids = tuple(delta_ids) + if self._contains_token_sequence(delta_ids, self._end_token_ids): + return True + if self._contains_token_sequence(input_ids, self._end_token_ids): + return True + if self._initial_in_reasoning: + return False + if self._ends_with_token_sequence_prefix(input_ids, self._start_token_ids): + return False + if self._ends_with_token_sequence_prefix(input_ids, self._end_token_ids): + return False + if not self._contains_token_sequence(input_ids, self._start_token_ids): + return bool(input_ids) + return False + + def extract_content_ids(self, input_ids: list[int]) -> list[int]: + if ( + self._last_streaming_delta_token_ids == tuple(input_ids) + and self._last_streaming_content_token_ids is not None + ): + content_ids = self._last_streaming_content_token_ids + self._last_streaming_delta_token_ids = None + self._last_streaming_content_token_ids = None + return list(content_ids) + + end_index = self._rfind_token_sequence(input_ids, self._end_token_ids) + if end_index >= 0: + return input_ids[end_index + len(self._end_token_ids) :] + + has_start = self._contains_token_sequence(input_ids, self._start_token_ids) + if self._initial_in_reasoning and not has_start: + return [] + + if not has_start: + return input_ids + return [] + + def extract_reasoning_streaming( + self, + previous_text: str, + current_text: str, + delta_text: str, + previous_token_ids: Sequence[int], + current_token_ids: Sequence[int], + delta_token_ids: Sequence[int], + ) -> DeltaMessage | None: + if not delta_text: + return None + + if not previous_text: + self._reasoning_ended_streaming = False + self._reasoning_active_streaming = self._initial_in_reasoning + self._pending_marker_streaming = False + self._last_streaming_delta_token_ids = None + self._last_streaming_content_token_ids = None + previous_reasoning, previous_content = self._visible_segments(previous_text) + current_reasoning, current_content = self._visible_segments(current_text) + if self.end_token in current_text or current_content is not None: + self._reasoning_ended_streaming = True + self._reasoning_active_streaming = False + self._pending_marker_streaming = False + else: + self._last_streaming_delta_token_ids = None + self._last_streaming_content_token_ids = None + self._reasoning_active_streaming = ( + self._initial_in_reasoning + or self.start_token in current_text + or current_reasoning is not None + ) + self._pending_marker_streaming = not self._reasoning_active_streaming and ( + self.start_token.startswith(current_text) + or self.end_token.startswith(current_text) + ) + reasoning = self._visible_delta(previous_reasoning, current_reasoning) + content = self._visible_delta(previous_content, current_content) + if self._reasoning_ended_streaming: + self._last_streaming_delta_token_ids = tuple(delta_token_ids) + self._last_streaming_content_token_ids = self._content_suffix_token_ids( + delta_text, delta_token_ids, content + ) + if reasoning is None and content is None: + return None + return DeltaMessage(reasoning=reasoning, content=content) + + def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int: + count = 0 + depth = 1 if self._initial_in_reasoning else 0 + i = 0 + while i < len(token_ids): + if tuple(token_ids[i : i + len(self._start_token_ids)]) == ( + self._start_token_ids + ): + depth += 1 + i += len(self._start_token_ids) + continue + if tuple(token_ids[i : i + len(self._end_token_ids)]) == ( + self._end_token_ids + ): + if depth > 0: + depth -= 1 + i += len(self._end_token_ids) + continue + if depth > 0: + count += 1 + i += 1 + return count + + def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: + start_index = self._rfind_token_sequence(input_ids, self._start_token_ids) + end_index = self._rfind_token_sequence(input_ids, self._end_token_ids) + if end_index < 0: + return False + if start_index < 0: + return True + return end_index > start_index diff --git a/vllm/reasoning/mistral_reasoning_parser.py b/vllm/reasoning/mistral_reasoning_parser.py index 7117716b6fea..c224c3c165c2 100644 --- a/vllm/reasoning/mistral_reasoning_parser.py +++ b/vllm/reasoning/mistral_reasoning_parser.py @@ -1,11 +1,10 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from functools import cached_property from typing import TYPE_CHECKING -from vllm.logger import init_logger from vllm.reasoning import ReasoningParser from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser from vllm.tokenizers.mistral import MistralTokenizer @@ -14,8 +13,6 @@ from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - class MistralReasoningParser(BaseThinkingReasoningParser): """ @@ -76,6 +73,15 @@ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: has_eot_token = True return False + def is_reasoning_end_streaming( + self, input_ids: Sequence[int], delta_ids: Iterable[int] + ) -> bool: + if self.end_token_id in delta_ids: + return True + # Grammar's think? is optional — if [THINK] was never generated, + # reasoning was skipped entirely. + return self.start_token_id not in input_ids + def extract_content_ids(self, input_ids: list[int]) -> list[int]: """ Extract the content diff --git a/vllm/reasoning/nemotron_v3_engine_reasoning_parser.py b/vllm/reasoning/nemotron_v3_engine_reasoning_parser.py new file mode 100644 index 000000000000..2d33df7b7425 --- /dev/null +++ b/vllm/reasoning/nemotron_v3_engine_reasoning_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import ( + NemotronV3ParserReasoningAdapter, +) + +__all__ = ["NemotronV3ParserReasoningAdapter"] diff --git a/vllm/reasoning/nemotron_v3_reasoning_parser.py b/vllm/reasoning/nemotron_v3_reasoning_parser.py deleted file mode 100644 index 7256f0f1283d..000000000000 --- a/vllm/reasoning/nemotron_v3_reasoning_parser.py +++ /dev/null @@ -1,33 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.responses.protocol import ( - ResponsesRequest, -) -from vllm.reasoning.deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser - - -class NemotronV3ReasoningParser(DeepSeekR1ReasoningParser): - """ - Reasoning parser for Nemotron V3 models. - """ - - def extract_reasoning( - self, model_output: str, request: ChatCompletionRequest | ResponsesRequest - ) -> tuple[str | None, str | None]: - reasoning, final_content = super().extract_reasoning(model_output, request) - chat_template_kwargs = getattr(request, "chat_template_kwargs", None) - - if ( - chat_template_kwargs - and ( - chat_template_kwargs.get("enable_thinking") is False - or chat_template_kwargs.get("force_nonempty_content") is True - ) - and (final_content is None or not final_content.strip()) - ): - reasoning, final_content = final_content, reasoning - - return reasoning, final_content diff --git a/vllm/reasoning/olmo3_reasoning_parser.py b/vllm/reasoning/olmo3_reasoning_parser.py index 102508b9ac18..dd323501dfb5 100644 --- a/vllm/reasoning/olmo3_reasoning_parser.py +++ b/vllm/reasoning/olmo3_reasoning_parser.py @@ -9,7 +9,6 @@ import regex as re from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.logger import init_logger from vllm.reasoning import ReasoningParser if TYPE_CHECKING: @@ -17,8 +16,6 @@ from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.tokenizers import TokenizerLike -logger = init_logger(__name__) - class Olmo3ReasoningState(enum.Enum): REASONING = 1 diff --git a/vllm/reasoning/qwen3_engine_reasoning_parser.py b/vllm/reasoning/qwen3_engine_reasoning_parser.py new file mode 100644 index 000000000000..64e71f9f08ab --- /dev/null +++ b/vllm/reasoning/qwen3_engine_reasoning_parser.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import Qwen3ParserReasoningAdapter + +__all__ = ["Qwen3ParserReasoningAdapter"] diff --git a/vllm/reasoning/qwen3_reasoning_parser.py b/vllm/reasoning/qwen3_reasoning_parser.py deleted file mode 100644 index e38b0de3d822..000000000000 --- a/vllm/reasoning/qwen3_reasoning_parser.py +++ /dev/null @@ -1,231 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING - -from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser - -if TYPE_CHECKING: - from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest - from vllm.entrypoints.openai.responses.protocol import ResponsesRequest - from vllm.tokenizers import TokenizerLike - - -class Qwen3ReasoningParser(BaseThinkingReasoningParser): - """ - Reasoning parser for the Qwen3/Qwen3.5 model family. - - The Qwen3 model family uses ... tokens to denote reasoning - text. Starting with Qwen3.5, the chat template places in the - prompt so only appears in the generated output. The model - provides a strict switch to disable reasoning output via the - 'enable_thinking=False' parameter. - - When thinking is disabled, the template places \\n\\n\\n\\n - in the prompt. The serving layer detects this via prompt_is_reasoning_end - and routes deltas as content without calling the streaming parser. - - NOTE: Models up to the 2507 release (e.g., Qwen/Qwen3-235B-A22B-Instruct-2507) - use an older chat template where the model generates itself. - This parser handles both styles: if appears in the generated output - it is stripped before extraction (non-streaming) or skipped (streaming). - - NOTE: Qwen3.5 models may emit inside the thinking block - without closing first. is treated as an implicit - end of reasoning, matching the approach in KimiK2ReasoningParser. - """ - - def __init__(self, tokenizer: "TokenizerLike", *args, **kwargs): - super().__init__(tokenizer, *args, **kwargs) - - chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {} - # Qwen3 defaults to thinking enabled; only treat output as - # pure content when the user explicitly disables it. - self.thinking_enabled = chat_kwargs.get("enable_thinking", True) - - self._tool_call_tag = "" - self._tool_call_token_id = self.vocab.get(self._tool_call_tag) - self._tool_call_end_tag = "" - self._tool_call_end_token_id = self.vocab.get(self._tool_call_end_tag) - - @property - def start_token(self) -> str: - """The token that starts reasoning content.""" - return "" - - @property - def end_token(self) -> str: - """The token that ends reasoning content.""" - return "" - - def is_reasoning_end(self, input_ids: Sequence[int]) -> bool: - start_token_id = self.start_token_id - end_token_id = self.end_token_id - tool_call_token_id = self._tool_call_token_id - tool_call_end_token_id = self._tool_call_end_token_id - - for i in range(len(input_ids) - 1, -1, -1): - token_id = input_ids[i] - if token_id == start_token_id: - # Found before or - return False - if token_id == end_token_id: - return True - if tool_call_token_id is not None and token_id == tool_call_token_id: - # Only treat as implicit reasoning end if this - # is NOT followed by . Paired occurrences are - # template examples in the prompt, not model output. - if tool_call_end_token_id is not None and any( - input_ids[j] == tool_call_end_token_id - for j in range(i + 1, len(input_ids)) - ): - continue - return True - return False - - def is_reasoning_end_streaming( - self, input_ids: Sequence[int], delta_ids: Iterable[int] - ) -> bool: - if super().is_reasoning_end_streaming(input_ids, delta_ids): - return True - if self._tool_call_token_id is not None: - return self._tool_call_token_id in delta_ids - return False - - def extract_content_ids(self, input_ids: list[int]) -> list[int]: - """ - Extract content token ids from the input_ids. - """ - result = super().extract_content_ids(input_ids) - if result: - return result - # Fall back: content starts at (implicit reasoning end). - if ( - self._tool_call_token_id is not None - and self._tool_call_token_id in input_ids - ): - tool_call_index = ( - len(input_ids) - 1 - input_ids[::-1].index(self._tool_call_token_id) - ) - return input_ids[tool_call_index:] - return [] - - def extract_reasoning( - self, model_output: str, request: "ChatCompletionRequest | ResponsesRequest" - ) -> tuple[str | None, str | None]: - """ - Extract reasoning content from the model output. - - The token is placed in the prompt by the chat template, - so typically only appears in the generated output. - If is present (e.g. from a different template), it is - stripped before extraction. - - When thinking is explicitly disabled and no appears, - returns (None, model_output) — all output is content. - Otherwise (thinking enabled, default), a missing means - the output was truncated and everything is reasoning: - returns (model_output, None). - - Returns: - tuple[Optional[str], Optional[str]]: reasoning content and content - """ - - # Strip if present in the generated output. - model_output_parts = model_output.partition(self.start_token) - model_output = ( - model_output_parts[2] if model_output_parts[1] else model_output_parts[0] - ) - - if self.end_token in model_output: - reasoning, _, content = model_output.partition(self.end_token) - return reasoning, content or None - - if not self.thinking_enabled: - # Thinking explicitly disabled — treat everything as content. - return None, model_output - - # No — check for implicit reasoning end via . - tool_call_index = model_output.find(self._tool_call_tag) - if tool_call_index != -1: - reasoning = model_output[:tool_call_index] - content = model_output[tool_call_index:] - return reasoning or None, content or None - # Thinking enabled but no : output was truncated. - # Everything generated so far is reasoning. - return model_output, None - - def extract_reasoning_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - ) -> DeltaMessage | None: - """ - Extract reasoning content from a streaming delta. - - Since is placed in the prompt by the chat template, all - generated tokens before are reasoning and tokens after - are content. - - NOTE: When thinking is disabled, no think tokens appear in the - generated output. The serving layer detects this via - prompt_is_reasoning_end and routes deltas as content without - calling this method. - """ - # Strip from delta if present (old template / edge case - # where the model generates itself). - if self.start_token_id in delta_token_ids: - start_idx = delta_text.find(self.start_token) - if start_idx >= 0: - delta_text = delta_text[start_idx + len(self.start_token) :] - - if self.end_token_id in delta_token_ids: - # End token in this delta: split reasoning from content. - end_index = delta_text.find(self.end_token) - if end_index >= 0: - reasoning = delta_text[:end_index] - content = delta_text[end_index + len(self.end_token) :] - if not reasoning and not content: - return None - return DeltaMessage( - reasoning=reasoning if reasoning else None, - content=content if content else None, - ) - # end_token_id in IDs but not in text (already stripped) - return None - - # Implicit reasoning end via . - if ( - self._tool_call_token_id is not None - and self._tool_call_token_id in delta_token_ids - ): - tool_index = delta_text.find(self._tool_call_tag) - if tool_index >= 0: - reasoning = delta_text[:tool_index] - content = delta_text[tool_index:] - return DeltaMessage( - reasoning=reasoning if reasoning else None, - content=content if content else None, - ) - - # No end token in this delta. - if not delta_text: - # Nothing left after stripping start token. - return None - elif self.end_token_id in previous_token_ids: - # End token already passed: everything is content now. - return DeltaMessage(content=delta_text) - elif ( - self._tool_call_token_id is not None - and self._tool_call_token_id in previous_token_ids - ): - return DeltaMessage(content=delta_text) - else: - # No end token yet: still in reasoning phase. - return DeltaMessage(reasoning=delta_text) diff --git a/vllm/reasoning/seed_oss_engine_reasoning_parser.py b/vllm/reasoning/seed_oss_engine_reasoning_parser.py new file mode 100644 index 000000000000..e651d411f432 --- /dev/null +++ b/vllm/reasoning/seed_oss_engine_reasoning_parser.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import SeedOssParserReasoningAdapter + +__all__ = ["SeedOssParserReasoningAdapter"] diff --git a/vllm/reasoning/seedoss_reasoning_parser.py b/vllm/reasoning/seedoss_reasoning_parser.py deleted file mode 100644 index d3d4d8ec0749..000000000000 --- a/vllm/reasoning/seedoss_reasoning_parser.py +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - - -from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser - - -class SeedOSSReasoningParser(BaseThinkingReasoningParser): - """ - Reasoning parser for SeedOSS model. - - The SeedOSS model uses ... tokens to - denote reasoning content text. This parser extracts - the reasoning content from the model output. - Similar to DeepSeek R1, it supports cases - where the model doesn't generate the start token. - """ - - @property - def start_token(self) -> str: - """The token that starts reasoning content.""" - return "" - - @property - def end_token(self) -> str: - """The token that ends reasoning content.""" - return "" diff --git a/vllm/reasoning/step3_reasoning_parser.py b/vllm/reasoning/step3_reasoning_parser.py index a50fcf02db48..bc80003edc32 100644 --- a/vllm/reasoning/step3_reasoning_parser.py +++ b/vllm/reasoning/step3_reasoning_parser.py @@ -9,15 +9,12 @@ from transformers import PreTrainedTokenizerBase from vllm.entrypoints.openai.engine.protocol import DeltaMessage -from vllm.logger import init_logger from vllm.reasoning import ReasoningParser if TYPE_CHECKING: from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -logger = init_logger(__name__) - class Step3ReasoningParser(ReasoningParser): """ diff --git a/vllm/renderers/base.py b/vllm/renderers/base.py index 9fab3aff04e4..a0f2508ccc72 100644 --- a/vllm/renderers/base.py +++ b/vllm/renderers/base.py @@ -29,6 +29,7 @@ from vllm.logger import init_logger from vllm.multimodal import MULTIMODAL_REGISTRY as mm_registry from vllm.multimodal.cache import BaseMultiModalProcessorCache +from vllm.multimodal.gpu_ipc_memory import maybe_init_mm_gpu_ipc_pool from vllm.multimodal.parse import ( MultiModalDataItems, MultiModalUUIDItems, @@ -38,10 +39,7 @@ from vllm.multimodal.processing import ProcessorInputs as MMProcessorInputs from vllm.multimodal.registry import MultiModalTimingRegistry from vllm.tokenizers import TokenizerLike -from vllm.utils.async_utils import ( - AsyncMicrobatchTokenizer, - make_async, -) +from vllm.utils.async_utils import make_async from vllm.utils.counter import AtomicCounter from vllm.utils.torch_utils import set_default_torch_num_threads from vllm.v1.metrics.stats import MultiModalCacheStats @@ -92,8 +90,14 @@ def __init__(self, config: "VllmConfig", tokenizer: _T | None) -> None: # to keep the asyncio event loop responsive under concurrent load. self._mm_executor: Executor = self._executor - # Lazy initialization since offline LLM doesn't use async - self._async_tokenizer: AsyncMicrobatchTokenizer | None = None + # Offload tokenization to the thread pool. The sync + # ``_tokenize_prompt`` already encapsulates the unified ``__call__`` + # path and char-offset extraction, so the async variant is just it + # offloaded (mirrors ``_process_multimodal_async`` below). + self._tokenize_prompt_async = make_async( + self._tokenize_prompt, executor=self._executor + ) + self._async_tokenizer_decode = make_async(self._decode, executor=self._executor) self.mm_processor: BaseMultiModalProcessor | None = None self._readonly_mm_processor: BaseMultiModalProcessor | None = None @@ -108,6 +112,16 @@ def __init__(self, config: "VllmConfig", tokenizer: _T | None) -> None: safe_load_prompt_embeds, executor=self._executor ) if mm_registry.supports_multimodal_inputs(config.model_config): + # Install the process-global GPU memory pool used to gate + # frontend GPU-side multimodal decoding (no-op when the budget + # is 0). Lives in the API-server process only. + mm_config = config.model_config.multimodal_config + if mm_config is not None: + maybe_init_mm_gpu_ipc_pool( + mm_config.mm_ipc_gpu_memory_gb, + config.parallel_config._api_process_count, + ) + mm_processor_cache = mm_registry.processor_cache_from_config(config) with set_default_torch_num_threads(): @@ -146,13 +160,8 @@ def get_tokenizer(self) -> _T: return tokenizer - def get_async_tokenizer(self) -> AsyncMicrobatchTokenizer: - if self._async_tokenizer is None: - self._async_tokenizer = AsyncMicrobatchTokenizer( - self.get_tokenizer(), executor=self._executor - ) - - return self._async_tokenizer + def _decode(self, *args, **kwargs): + return self.get_tokenizer().decode(*args, **kwargs) def get_mm_processor(self) -> "BaseMultiModalProcessor": if self.mm_processor is None: @@ -418,32 +427,64 @@ async def render_messages_async( return self.render_messages(messages, params) # Step 2: Tokenize prompts if necessary - def _tokenize_prompt( + def _can_produce_offsets(self) -> bool: + """Whether this renderer's tokenizer can emit char-level offsets. + + Defaults to False; only renderers backed by an HF fast tokenizer + (see ``HfRenderer``) can produce ``offset_mapping``. + """ + return False + + def _wants_offsets( self, - prompt: TextPrompt, - params: TokenizeParams, - ) -> TokensPrompt: - tokenizer = self.get_tokenizer() - prompt_token_ids = tokenizer.encode( - prompt["prompt"], - **params.get_encode_kwargs(), + prompt: "TextPrompt", + params: "TokenizeParams", + ) -> bool: + return ( + params.return_token_offsets + and self._can_produce_offsets() + and not prompt.get("multi_modal_data") + and not prompt.get("multi_modal_uuids") ) - return TokensPrompt(prompt_token_ids=prompt_token_ids, **prompt) + @staticmethod + def _build_tokens_prompt( + token_ids: Sequence[int], + prompt: "TextPrompt", + *, + offset_mapping: Sequence[tuple[int, int]] | None = None, + ) -> "TokensPrompt": + """Build a TokensPrompt from already-extracted token ids. - async def _tokenize_prompt_async( + ``offset_mapping`` is the per-token ``(start, end)`` sequence from + a BatchEncoding; pass it only when offsets were requested, and it + is attached as ``prompt_token_offsets``. + """ + if offset_mapping is not None: + return TokensPrompt( + prompt_token_ids=list(token_ids), + prompt_token_offsets=[(int(s), int(e)) for s, e in offset_mapping], + **prompt, + ) + return TokensPrompt(prompt_token_ids=list(token_ids), **prompt) + + def _tokenize_prompt( self, prompt: TextPrompt, params: TokenizeParams, ) -> TokensPrompt: - tokenizer = self.get_async_tokenizer() - prompt_token_ids = await tokenizer.encode( - prompt["prompt"], - **params.get_encode_kwargs(), + tokenizer = self.get_tokenizer() + want_offsets = self._wants_offsets(prompt, params) + kwargs = params.get_encode_kwargs() + if want_offsets: + kwargs = {**kwargs, "return_offsets_mapping": True} + encoding = tokenizer(prompt["prompt"], **kwargs) + return self._build_tokens_prompt( + encoding["input_ids"], + prompt, + offset_mapping=encoding["offset_mapping"] if want_offsets else None, ) - return TokensPrompt(prompt_token_ids=prompt_token_ids, **prompt) - def _detokenize_prompt(self, prompt: TokensPrompt) -> TokensPrompt: tokenizer = self.get_tokenizer() prompt["prompt"] = tokenizer.decode(prompt["prompt_token_ids"]) @@ -451,8 +492,9 @@ def _detokenize_prompt(self, prompt: TokensPrompt) -> TokensPrompt: return prompt async def _detokenize_prompt_async(self, prompt: TokensPrompt) -> TokensPrompt: - tokenizer = self.get_async_tokenizer() - prompt["prompt"] = await tokenizer.decode(prompt["prompt_token_ids"]) + prompt["prompt"] = await self._async_tokenizer_decode( + prompt["prompt_token_ids"] + ) return prompt @@ -751,6 +793,11 @@ def _process_tokens( engine_input["prompt"] = prompt_text if cache_salt := prompt.get("cache_salt"): engine_input["cache_salt"] = cache_salt + # Narrow the union — `prompt_token_offsets` is only on TokensInput. + if engine_input["type"] == "token" and ( + (offsets := prompt.get("prompt_token_offsets")) is not None + ): + engine_input["prompt_token_offsets"] = offsets return engine_input @@ -809,6 +856,11 @@ async def _process_tokens_async( engine_input["prompt"] = prompt_text if cache_salt := prompt.get("cache_salt"): engine_input["cache_salt"] = cache_salt + # Narrow the union — `prompt_token_offsets` is only on TokensInput. + if engine_input["type"] == "token" and ( + (offsets := prompt.get("prompt_token_offsets")) is not None + ): + engine_input["prompt_token_offsets"] = offsets return engine_input diff --git a/vllm/renderers/grok2.py b/vllm/renderers/grok2.py deleted file mode 100644 index 665d9a98e94f..000000000000 --- a/vllm/renderers/grok2.py +++ /dev/null @@ -1,90 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from vllm.config import VllmConfig -from vllm.entrypoints.chat_utils import ( - ChatCompletionMessageParam, - ConversationMessage, - parse_chat_messages, - parse_chat_messages_async, -) -from vllm.logger import init_logger -from vllm.tokenizers.grok2 import Grok2Tokenizer -from vllm.utils.async_utils import make_async - -from .base import BaseRenderer -from .inputs import DictPrompt -from .inputs.preprocess import parse_dec_only_prompt -from .params import ChatParams - -logger = init_logger(__name__) - - -class Grok2Renderer(BaseRenderer[Grok2Tokenizer]): - def __init__( - self, - config: VllmConfig, - tokenizer: Grok2Tokenizer | None, - ) -> None: - super().__init__(config, tokenizer) - - self._apply_chat_template_async = make_async( - self._apply_chat_template, executor=self._executor - ) - - def _apply_chat_template(self, *args, **kwargs): - return self.get_tokenizer().apply_chat_template(*args, **kwargs) - - def render_messages( - self, - messages: list[ChatCompletionMessageParam], - params: ChatParams, - ) -> tuple[list[ConversationMessage], DictPrompt]: - conversation, mm_data, mm_uuids = parse_chat_messages( - messages, - self.model_config, - content_format="string", - media_io_kwargs=params.media_io_kwargs, - mm_processor_kwargs=params.mm_processor_kwargs, - ) - - prompt_raw = self._apply_chat_template( - conversation=conversation, - messages=messages, - **params.get_apply_chat_template_kwargs(), - ) - - prompt = parse_dec_only_prompt(prompt_raw) - if mm_data is not None: - prompt["multi_modal_data"] = mm_data - if mm_uuids is not None: - prompt["multi_modal_uuids"] = mm_uuids - - return conversation, prompt - - async def render_messages_async( - self, - messages: list[ChatCompletionMessageParam], - params: ChatParams, - ) -> tuple[list[ConversationMessage], DictPrompt]: - conversation, mm_data, mm_uuids = await parse_chat_messages_async( - messages, - self.model_config, - content_format="string", - media_io_kwargs=params.media_io_kwargs, - mm_processor_kwargs=params.mm_processor_kwargs, - ) - - prompt_raw = await self._apply_chat_template_async( - conversation=conversation, - messages=messages, - **params.get_apply_chat_template_kwargs(), - ) - - prompt = parse_dec_only_prompt(prompt_raw) - if mm_data is not None: - prompt["multi_modal_data"] = mm_data - if mm_uuids is not None: - prompt["multi_modal_uuids"] = mm_uuids - - return conversation, prompt diff --git a/vllm/renderers/hf.py b/vllm/renderers/hf.py index e57d0586aa01..a7a6693154b0 100644 --- a/vllm/renderers/hf.py +++ b/vllm/renderers/hf.py @@ -7,7 +7,7 @@ import itertools import weakref from collections import defaultdict, deque -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from functools import lru_cache from typing import TYPE_CHECKING, Any, Final, Literal, cast, overload @@ -228,20 +228,12 @@ def _try_get_processor_chat_template( if cache_key in _PROCESSOR_CHAT_TEMPLATES: return _PROCESSOR_CHAT_TEMPLATES[cache_key] - from transformers import ( - PreTrainedTokenizer, - PreTrainedTokenizerFast, - ProcessorMixin, - ) + from transformers import ProcessorMixin, PythonBackend, TokenizersBackend try: processor = cached_get_processor( tokenizer.name_or_path, - processor_cls=( - PreTrainedTokenizer, - PreTrainedTokenizerFast, - ProcessorMixin, - ), + processor_cls=(PythonBackend, TokenizersBackend, ProcessorMixin), trust_remote_code=trust_remote_code, ) if ( @@ -619,15 +611,15 @@ def _resolve_chat_template_kwargs(chat_template: str) -> Set[str]: @lru_cache def _get_hf_base_chat_template_params() -> frozenset[str]: - from transformers import PreTrainedTokenizer + from transformers import PythonBackend # Get standard parameters from HuggingFace's base tokenizer class. - # This dynamically extracts parameters from PreTrainedTokenizer's + # This dynamically extracts parameters from PythonBackend's # apply_chat_template method, ensuring compatibility with tokenizers # that use **kwargs to receive standard parameters. # Read signature from HF's base class - the single source of truth - base_sig = inspect.signature(PreTrainedTokenizer.apply_chat_template) + base_sig = inspect.signature(PythonBackend.apply_chat_template) # Exclude VAR_KEYWORD (**kwargs) and VAR_POSITIONAL (*args) placeholders return frozenset( @@ -678,6 +670,7 @@ def safe_apply_chat_template( tools: list[dict[str, Any]] | None = ..., chat_template: str | None = ..., tokenize: Literal[True] = ..., + return_assistant_tokens_mask: Literal[False] = ..., **kwargs, ) -> list[int]: ... @overload @@ -689,8 +682,20 @@ def safe_apply_chat_template( tools: list[dict[str, Any]] | None = ..., chat_template: str | None = ..., tokenize: Literal[False] = ..., + return_assistant_tokens_mask: Literal[False] = ..., **kwargs, ) -> str: ... +@overload +def safe_apply_chat_template( + model_config: ModelConfig, + tokenizer: HfTokenizer, + conversation: list[ConversationMessage], + *, + tools: list[dict[str, Any]] | None = ..., + chat_template: str | None = ..., + return_assistant_tokens_mask: Literal[True], + **kwargs, +) -> tuple[list[int], list[int] | None]: ... def safe_apply_chat_template( model_config: ModelConfig, tokenizer: HfTokenizer, @@ -699,8 +704,9 @@ def safe_apply_chat_template( tools: list[dict[str, Any]] | None = None, chat_template: str | None = None, tokenize: bool = True, + return_assistant_tokens_mask: bool = False, **kwargs, -) -> str | list[int]: +) -> str | list[int] | tuple[list[int], list[int] | None]: chat_template = resolve_chat_template( tokenizer, chat_template=chat_template, @@ -728,6 +734,38 @@ def safe_apply_chat_template( chat_template_kwargs=kwargs, ) + # assistant_tokens_mask requires tokenized output — force tokenize=True. + if return_assistant_tokens_mask: + tokenize = True + + # When return_assistant_tokens_mask is requested and the template supports it, + # request assistant_tokens_mask via return_dict. + # Check for the actual Jinja tag, not just the word "generation" + # (which also appears in add_generation_prompt). + if return_assistant_tokens_mask and "{% generation %}" in chat_template: + resolved_kwargs["return_assistant_tokens_mask"] = True + resolved_kwargs["return_dict"] = True + resolved_kwargs.pop("tokenize", None) + try: + result = tokenizer.apply_chat_template( + conversation=conversation, # type: ignore[arg-type] + tools=tools, # type: ignore[arg-type] + chat_template=chat_template, + tokenize=True, + **resolved_kwargs, + ) + except (TypeError, ValueError) as exc: + logger.warning( + "apply_chat_template failed for assistant_tokens_mask: %s", exc + ) + else: + if isinstance(result, Mapping): + token_ids = list(result.get("input_ids", [])) + mask_raw = result.get("assistant_masks") + mask = list(mask_raw) if mask_raw is not None else None + return token_ids, mask + return list(result), None + # transformers v5 changed the default of `return_dict` to True, which # makes `apply_chat_template(tokenize=True)` return a `BatchEncoding` # instead of `list[int]`. Force `return_dict=False` so downstream code @@ -737,23 +775,24 @@ def safe_apply_chat_template( resolved_kwargs["return_dict"] = False try: - return tokenizer.apply_chat_template( + plain = tokenizer.apply_chat_template( conversation=conversation, # type: ignore[arg-type] tools=tools, # type: ignore[arg-type] chat_template=chat_template, tokenize=tokenize, **resolved_kwargs, ) - # External library exceptions can sometimes occur despite the framework's - # internal exception management capabilities. except Exception as e: - # Log and report any library-related exceptions for further - # investigation. logger.exception( "An error occurred in `transformers` while applying chat template" ) raise ValueError(str(e)) from e + if return_assistant_tokens_mask: + assert isinstance(plain, list), f"Expected list[int], got {type(plain)}" + return plain, None + return plain + def rebuild_mm_uuids_from_mm_data( mm_uuids: MultiModalUUIDDict, @@ -882,6 +921,11 @@ def __init__( self.tokenizer, config.model_config.renderer_num_workers + 1 ) + def _can_produce_offsets(self) -> bool: + # HF tokenizers may be slow (use_fast=False); only fast tokenizers + # expose offset_mapping. + return self.tokenizer is not None and self.tokenizer.is_fast + def render_messages( self, messages: list[ChatCompletionMessageParam], @@ -929,12 +973,22 @@ def render_messages( logger.warning_once(_TOKENIZE_OVERRIDE_WARNING) chat_template_kwargs["tokenize"] = True - prompt_raw = safe_apply_chat_template( - model_config, - tokenizer, - conversation, - **chat_template_kwargs, - ) + assistant_tokens_mask: list[int] | None = None + if params.return_assistant_tokens_mask: + prompt_raw, assistant_tokens_mask = safe_apply_chat_template( + model_config, + tokenizer, + conversation, + return_assistant_tokens_mask=True, + **chat_template_kwargs, + ) + else: + prompt_raw = safe_apply_chat_template( + model_config, + tokenizer, + conversation, + **chat_template_kwargs, + ) # NOTE: use_unified_vision_chunk is currently specific to Kimi-K2.5 # model which uses unified vision chunks for both images and videos. @@ -960,6 +1014,9 @@ def render_messages( prompt = parse_dec_only_prompt(prompt_raw) + if assistant_tokens_mask is not None: + cast(dict, prompt)["_assistant_tokens_mask"] = assistant_tokens_mask + # When `prompt_embeds` is mixed with other modality data, # `_process_tokens` runs `_process_multimodal` first (expanding # `<|AUDIO|>` / `<|IMAGE|>` placeholders) and then @@ -1033,12 +1090,30 @@ async def render_messages_async( logger.warning_once(_TOKENIZE_OVERRIDE_WARNING) chat_template_kwargs["tokenize"] = True - prompt_raw = await self._apply_chat_template_async( - model_config, - tokenizer, - conversation, - **chat_template_kwargs, - ) + assistant_tokens_mask: list[int] | None = None + if params.return_assistant_tokens_mask: + result_with_mask = cast( + tuple[list[int], list[int] | None], + await make_async( + safe_apply_chat_template, + executor=self._executor, + )( + model_config, + tokenizer, + conversation, + return_assistant_tokens_mask=True, # type: ignore[arg-type] + **chat_template_kwargs, + ), + ) + prompt_raw: str | list[int] = result_with_mask[0] + assistant_tokens_mask = result_with_mask[1] + else: + prompt_raw = await self._apply_chat_template_async( + model_config, + tokenizer, + conversation, + **chat_template_kwargs, + ) # NOTE: use_unified_vision_chunk is currently specific to Kimi-K2.5 # model which uses unified vision chunks for both images and videos. @@ -1062,6 +1137,9 @@ async def render_messages_async( prompt = parse_dec_only_prompt(prompt_raw) + if assistant_tokens_mask is not None: + cast(dict, prompt)["_assistant_tokens_mask"] = assistant_tokens_mask + # See `render_messages` for the rationale. if prompt_embeds_tensors and mm_data: assert prompt_embeds_placeholder_token_id is not None @@ -1103,6 +1181,7 @@ def _process_tokens( processor records all placeholder offsets in the final (post-expansion) coordinate space, no offset shifting needed afterwards. """ + assistant_tokens_mask = cast(dict, prompt).pop("_assistant_tokens_mask", None) prompt_embeds_info = cast(dict, prompt).pop("_prompt_embeds", None) if prompt_embeds_info is not None: tensors, placeholder_token_id = prompt_embeds_info @@ -1118,6 +1197,8 @@ def _process_tokens( tensors, mm_updates, ) + if assistant_tokens_mask is not None: + engine_input["assistant_tokens_mask"] = assistant_tokens_mask return engine_input @override @@ -1128,6 +1209,7 @@ async def _process_tokens_async( skip_mm_cache: bool = False, ) -> TokensInput | MultiModalInput: """Async equivalent of `_process_tokens`.""" + assistant_tokens_mask = cast(dict, prompt).pop("_assistant_tokens_mask", None) prompt_embeds_info = cast(dict, prompt).pop("_prompt_embeds", None) if prompt_embeds_info is not None: tensors, placeholder_token_id = prompt_embeds_info @@ -1145,6 +1227,8 @@ async def _process_tokens_async( tensors, mm_updates, ) + if assistant_tokens_mask is not None: + engine_input["assistant_tokens_mask"] = assistant_tokens_mask return engine_input @staticmethod diff --git a/vllm/renderers/online_derenderer.py b/vllm/renderers/online_derenderer.py new file mode 100644 index 000000000000..20c54eb07ffd --- /dev/null +++ b/vllm/renderers/online_derenderer.py @@ -0,0 +1,334 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from typing import Any + +from vllm.config import ModelConfig +from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption +from vllm.entrypoints.generate.base.serving import resolve_token_id_placeholder +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionLogProbs, + ChatCompletionRequest, + ChatCompletionResponseChoice, + ChatMessage, +) +from vllm.entrypoints.openai.completion.protocol import ( + CompletionLogProbs, + CompletionResponseChoice, +) +from vllm.entrypoints.openai.engine.protocol import ToolCall +from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateResponse +from vllm.entrypoints.serve.utils.request_logger import RequestLogger +from vllm.logger import init_logger +from vllm.parser import Parser, ParserManager +from vllm.renderers import BaseRenderer +from vllm.tokenizers import TokenizerLike +from vllm.utils import random_uuid + +logger = init_logger(__name__) + + +class OnlineDerenderer: + def __init__( + self, + model_config: ModelConfig, + renderer: BaseRenderer, + *, + request_logger: RequestLogger | None, + chat_template: str | None, + chat_template_content_format: ChatTemplateContentFormatOption, + trust_request_chat_template: bool = False, + enable_auto_tools: bool = False, + exclude_tools_when_tool_choice_none: bool = False, + tool_parser: str | None = None, + reasoning_parser: str | None = None, + default_chat_template_kwargs: dict[str, Any] | None = None, + log_error_stack: bool = False, + ) -> None: + self.model_config = model_config + self.renderer = renderer + self.request_logger = request_logger + + self.enable_auto_tools = enable_auto_tools + self.exclude_tools_when_tool_choice_none = exclude_tools_when_tool_choice_none + self.use_harmony = model_config.hf_config.model_type == "gpt_oss" + self.parser: type[Parser] | None = ParserManager.get_parser( + tool_parser_name=tool_parser, + reasoning_parser_name=reasoning_parser, + enable_auto_tools=enable_auto_tools, + model_name=model_config.model, + is_harmony=self.use_harmony, + ) + + self.chat_template = chat_template + self.chat_template_content_format: ChatTemplateContentFormatOption = ( + chat_template_content_format + ) + self.default_chat_template_kwargs: dict[str, Any] = ( + default_chat_template_kwargs or {} + ) + self.trust_request_chat_template = trust_request_chat_template + + self.log_error_stack = log_error_stack + self.supports_browsing = False + self.supports_code_interpreter = False + + async def derender_chat( + self, + generate_response: GenerateResponse, + chat_request: ChatCompletionRequest | None = None, + ) -> list[ChatCompletionResponseChoice]: + tokenizer = self.renderer.get_tokenizer() + choices: list[ChatCompletionResponseChoice] = [] + + for choice in generate_response.choices: + if not choice.token_ids: + raise ValueError(f"choice {choice.index} has empty or null token_ids") + + resolved_logprobs = ( + _resolve_logprobs(choice.logprobs, tokenizer) + if choice.logprobs is not None + else None + ) + + if self.parser is not None and chat_request is not None: + # Parser path: decode with special tokens preserved + # so the parser can see markers like , + # , or Harmony channel tokens. + decoded_text = tokenizer.decode( + choice.token_ids, skip_special_tokens=False + ) + + chat_template_kwargs: dict[str, Any] = {} + if not self.use_harmony: + chat_template_kwargs = ( + chat_request.build_chat_params( + self.chat_template, + self.chat_template_content_format, + ) + .with_defaults(self.default_chat_template_kwargs) + .chat_template_kwargs + ) + + parser = self.parser( + tokenizer, + chat_request.tools, + chat_template_kwargs=chat_template_kwargs, + ) + reasoning, content, tool_calls = parser.parse( + decoded_text, + chat_request, + enable_auto_tools=self.enable_auto_tools, + model_output_token_ids=choice.token_ids, + ) + + if not getattr(chat_request, "include_reasoning", True): + reasoning = None + + tc_items = ( + [ + ToolCall( + id=random_uuid(), + function=tc, + ) + for tc in tool_calls + ] + if tool_calls + else [] + ) + + message = ChatMessage( + role="assistant", + reasoning=reasoning, + content=content, + tool_calls=tc_items, + ) + else: + # No parser: plain detokenization. + decoded_text = tokenizer.decode( + choice.token_ids, skip_special_tokens=True + ) + message = ChatMessage(role="assistant", content=decoded_text) + + choices.append( + ChatCompletionResponseChoice( + index=choice.index, + message=message, + logprobs=resolved_logprobs, + finish_reason=choice.finish_reason, + ) + ) + + return choices + + async def derender_completion( + self, + generate_responses: list[GenerateResponse], + prompt_tokens: list[int] | None = None, + ) -> tuple[list[CompletionResponseChoice], int, int]: + n = len(generate_responses) + prompt_tokens_list: list[int] = ( + prompt_tokens if prompt_tokens is not None else [0] * n + ) + + tokenizer = self.renderer.get_tokenizer() + choices: list[CompletionResponseChoice] = [] + total_prompt_tokens = 0 + total_completion_tokens = 0 + index = 0 + + for gen, pt in zip(generate_responses, prompt_tokens_list): + for choice in gen.choices: + if not choice.token_ids: + raise ValueError( + f"choice {choice.index} in response {gen.request_id} " + "has empty or null token_ids" + ) + + decoded_text = tokenizer.decode( + choice.token_ids, skip_special_tokens=True + ) + completion_logprobs = None + if choice.logprobs is not None: + resolved = _resolve_logprobs(choice.logprobs, tokenizer) + completion_logprobs = _convert_chat_logprobs_to_completion_logprobs( + resolved + ) + choices.append( + CompletionResponseChoice( + index=index, + text=decoded_text, + finish_reason=choice.finish_reason, + logprobs=completion_logprobs, + ) + ) + total_completion_tokens += len(choice.token_ids) + index += 1 + total_prompt_tokens += pt + + return choices, total_prompt_tokens, total_completion_tokens + + +def _parse_token_id_placeholder(token: str) -> int | None: + """Extract token ID from a 'token_id:N' placeholder string.""" + if not token.startswith("token_id:"): + return None + try: + return int(token[len("token_id:") :]) + except ValueError: + return None + + +def _correct_decoded_token( + token_id: int, context_token_ids: list[int], tokenizer: TokenizerLike +) -> str: + """Use preceding tokens as context to fix U+FFFD from byte-fallback. + + Mirrors LogprobsProcessor._correct_decoded_token in v1/engine/logprobs.py. + """ + max_ctx = min(len(context_token_ids), 4) + + for num_ctx in range(1, max_ctx + 1): + context = context_token_ids[-num_ctx:] + full_decoded = tokenizer.decode(context + [token_id]) + + if full_decoded.endswith("�"): + continue + + clean_end = len(context) + for j in range(len(context) - 1, -1, -1): + if tokenizer.decode([context[j]]).endswith("�"): + clean_end = j + else: + break + + clean_prefix = tokenizer.decode(context[:clean_end]) if clean_end > 0 else "" + + if full_decoded.startswith(clean_prefix): + return full_decoded[len(clean_prefix) :] + + common_len = 0 + for a, b in zip(clean_prefix, full_decoded): + if a != b: + break + common_len += 1 + return full_decoded[common_len:] + + return "" + + +def _resolve_logprobs( + logprobs: ChatCompletionLogProbs, tokenizer: TokenizerLike +) -> ChatCompletionLogProbs: + """Resolve token_id:N placeholders in a ChatCompletionLogProbs object.""" + if logprobs.content is None: + return logprobs + + context_token_ids: list[int] = [] + resolved_content = [] + + for entry in logprobs.content: + token_str, token_bytes = resolve_token_id_placeholder(entry.token, tokenizer) + sampled_id = _parse_token_id_placeholder(entry.token) + + if token_str.endswith("�") and sampled_id is not None: + token_str = _correct_decoded_token(sampled_id, context_token_ids, tokenizer) + token_bytes = list(token_str.encode("utf-8")) + + resolved_top = [] + for top in entry.top_logprobs: + top_str, top_bytes = resolve_token_id_placeholder(top.token, tokenizer) + top_id = _parse_token_id_placeholder(top.token) + if top_str.endswith("�") and top_id is not None: + top_str = _correct_decoded_token(top_id, context_token_ids, tokenizer) + top_bytes = list(top_str.encode("utf-8")) + resolved_top.append( + top.model_copy(update={"token": top_str, "bytes": top_bytes}) + ) + + resolved_content.append( + entry.model_copy( + update={ + "token": token_str, + "bytes": token_bytes, + "top_logprobs": resolved_top, + } + ) + ) + + if sampled_id is not None: + context_token_ids.append(sampled_id) + + return ChatCompletionLogProbs(content=resolved_content) + + +def _convert_chat_logprobs_to_completion_logprobs( + logprobs: ChatCompletionLogProbs, +) -> CompletionLogProbs: + """Convert ChatCompletionLogProbs (per-token objects) to CompletionLogProbs + (parallel flat lists) as required by the /v1/completions response schema.""" + if logprobs.content is None: + return CompletionLogProbs() + + tokens: list[str] = [] + token_logprobs: list[float | None] = [] + top_logprobs_list: list[dict[str, float] | None] = [] + text_offset: list[int] = [] + + offset = 0 + for entry in logprobs.content: + text_offset.append(offset) + tokens.append(entry.token) + token_logprobs.append(entry.logprob) + top_logprobs_list.append( + {t.token: t.logprob for t in entry.top_logprobs} + if entry.top_logprobs + else None + ) + offset += len(entry.token) + + return CompletionLogProbs( + text_offset=text_offset, + token_logprobs=token_logprobs, + tokens=tokens, + top_logprobs=top_logprobs_list, + ) diff --git a/vllm/entrypoints/serve/render/serving.py b/vllm/renderers/online_renderer.py similarity index 58% rename from vllm/entrypoints/serve/render/serving.py rename to vllm/renderers/online_renderer.py index 782b2eaea24b..15a4023fceeb 100644 --- a/vllm/entrypoints/serve/render/serving.py +++ b/vllm/renderers/online_renderer.py @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from collections.abc import Sequence from http import HTTPStatus -from typing import Any, cast +from typing import Any from openai_harmony import Message as OpenAIMessage @@ -11,63 +11,47 @@ ChatTemplateContentFormatOption, ConversationMessage, ) -from vllm.entrypoints.logger import RequestLogger -from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest -from vllm.entrypoints.openai.completion.protocol import CompletionRequest -from vllm.entrypoints.openai.engine.protocol import ( - ErrorResponse, +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionNamedToolChoiceParam, + ChatCompletionRequest, ) -from vllm.entrypoints.openai.models.serving import OpenAIModelRegistry +from vllm.entrypoints.openai.completion.protocol import ( + CompletionRequest, +) +from vllm.entrypoints.openai.engine.protocol import ErrorResponse from vllm.entrypoints.openai.parser.harmony_utils import ( - get_developer_message, - get_system_message, + build_harmony_preamble, + extract_instructions_from_messages, parse_chat_inputs_to_harmony_messages, render_for_completion, ) from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -from vllm.entrypoints.serve.disagg.mm_serde import encode_mm_kwargs_item -from vllm.entrypoints.serve.disagg.protocol import ( - GenerateRequest, - MultiModalFeatures, - PlaceholderRangeInfo, -) -from vllm.entrypoints.utils import ( - create_error_response, - get_max_tokens, -) +from vllm.entrypoints.serve.utils.error_response import create_error_response +from vllm.entrypoints.serve.utils.request_logger import RequestLogger from vllm.inputs import ( EngineInput, - MultiModalHashes, - MultiModalInput, - MultiModalPlaceholders, PromptType, SingletonPrompt, tokens_input, ) from vllm.logger import init_logger -from vllm.parser import ParserManager -from vllm.reasoning.abs_reasoning_parsers import ReasoningParser +from vllm.parser import Parser, ParserManager from vllm.renderers import BaseRenderer, merge_kwargs from vllm.renderers.inputs.preprocess import ( - extract_prompt_components, - extract_prompt_len, parse_model_prompt, prompt_to_seq, ) -from vllm.tool_parsers import ToolParser -from vllm.utils import random_uuid from vllm.utils.mistral import is_mistral_tokenizer, is_mistral_tool_parser from vllm.utils.mistral import mt as _mt logger = init_logger(__name__) -class OpenAIServingRender: +class OnlineRenderer: def __init__( self, model_config: ModelConfig, renderer: BaseRenderer, - model_registry: OpenAIModelRegistry, *, request_logger: RequestLogger | None, chat_template: str | None, @@ -82,106 +66,32 @@ def __init__( ) -> None: self.model_config = model_config self.renderer = renderer - self.model_registry = model_registry self.request_logger = request_logger - self.chat_template = chat_template - self.chat_template_content_format: ChatTemplateContentFormatOption = ( - chat_template_content_format - ) - self.trust_request_chat_template = trust_request_chat_template + self.enable_auto_tools = enable_auto_tools self.exclude_tools_when_tool_choice_none = exclude_tools_when_tool_choice_none - self.tool_parser: type[ToolParser] | None = ParserManager.get_tool_parser( + self.use_harmony = model_config.hf_config.model_type == "gpt_oss" + self.parser: type[Parser] | None = ParserManager.get_parser( tool_parser_name=tool_parser, + reasoning_parser_name=reasoning_parser, enable_auto_tools=enable_auto_tools, model_name=model_config.model, + is_harmony=self.use_harmony, ) - self.reasoning_parser: type[ReasoningParser] | None = ( - ParserManager.get_reasoning_parser( - reasoning_parser_name=reasoning_parser, - ) + + self.chat_template = chat_template + self.chat_template_content_format: ChatTemplateContentFormatOption = ( + chat_template_content_format ) self.default_chat_template_kwargs: dict[str, Any] = ( default_chat_template_kwargs or {} ) + self.trust_request_chat_template = trust_request_chat_template + self.log_error_stack = log_error_stack - self.use_harmony = model_config.hf_config.model_type == "gpt_oss" self.supports_browsing = False self.supports_code_interpreter = False - self.default_sampling_params = model_config.get_diff_sampling_param() - mc = model_config - self.override_max_tokens = ( - self.default_sampling_params.get("max_tokens") - if mc.generation_config not in ("auto", "vllm") - else getattr(mc, "override_generation_config", {}).get("max_new_tokens") - ) - - async def render_chat_request( - self, - request: ChatCompletionRequest, - ) -> GenerateRequest | ErrorResponse: - """Validate the model and preprocess a chat completion request. - - This is the authoritative implementation used directly by the - GPU-less render server and delegated to by OpenAIServingChat. - """ - error_check_ret = await self._check_model(request) - if error_check_ret is not None: - logger.error("Error with model %s", error_check_ret) - return error_check_ret - - if request.use_beam_search: - return self.create_error_response( - "Beam search is not supported by the render endpoint" - ) - - result = await self.render_chat(request, skip_mm_cache=True) - if isinstance(result, ErrorResponse): - return result - - _, engine_inputs = result - - if len(engine_inputs) != 1: - return self.create_error_response( - f"Expected exactly 1 engine prompt, got {len(engine_inputs)}" - ) - - engine_input = engine_inputs[0] - - prompt_components = extract_prompt_components(self.model_config, engine_input) - token_ids = prompt_components.token_ids - if not token_ids: - return self.create_error_response("No token_ids rendered") - token_ids = list(token_ids) - - input_length = extract_prompt_len(self.model_config, engine_input) - max_tokens = get_max_tokens( - self.model_config.max_model_len, - request.max_completion_tokens - if request.max_completion_tokens is not None - else request.max_tokens, - input_length, - self.default_sampling_params, - self.override_max_tokens, - truncate_prompt_tokens=request.truncate_prompt_tokens, - ) - params = request.to_sampling_params(max_tokens, self.default_sampling_params) - - request_id = f"chatcmpl-{random_uuid()}" - - return GenerateRequest( - request_id=request_id, - token_ids=token_ids, - features=self._extract_mm_features(engine_input), - sampling_params=params, - model=request.model, - stream=bool(request.stream), - stream_options=(request.stream_options if request.stream else None), - cache_salt=request.cache_salt, - priority=request.priority, - ) - async def render_chat( self, request: ChatCompletionRequest, @@ -195,7 +105,7 @@ async def render_chat( """ tokenizer = self.renderer.tokenizer - tool_parser = self.tool_parser + tool_parser = self.parser.tool_parser_cls if self.parser is not None else None if is_mistral_tokenizer(tokenizer): # because of issues with pydantic we need to potentially @@ -225,8 +135,12 @@ async def render_chat( ) elif request.tool_choice != "auto": # "required" or named tool requires tool parser + if isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam): + tool_choice_desc = f'function "{request.tool_choice.function.name}"' + else: + tool_choice_desc = f'"{request.tool_choice}"' return self.create_error_response( - f'tool_choice="{request.tool_choice}" requires ' + f"tool_choice={tool_choice_desc} requires " "--tool-call-parser to be set" ) @@ -254,9 +168,8 @@ async def render_chat( default_template_content_format=self.chat_template_content_format, default_template_kwargs=self.default_chat_template_kwargs, tool_dicts=tool_dicts, - tool_parser=tool_parser, + parser=self.parser, skip_mm_cache=skip_mm_cache, - reasoning_parser=self.reasoning_parser, ) else: # For GPT-OSS. @@ -267,61 +180,47 @@ async def render_chat( return conversation, engine_inputs - async def render_completion_request( + def _make_request_with_harmony( self, - request: CompletionRequest, - ) -> list[GenerateRequest] | ErrorResponse: - """Validate the model and preprocess a completion request. + request: ChatCompletionRequest, + should_include_tools: bool = True, + ): + """Build Harmony (GPT-OSS) messages and engine prompt from a chat request.""" + messages: list[OpenAIMessage] = [] - This is the authoritative implementation used directly by the - GPU-less render server and delegated to by OpenAIServingCompletion. - """ - error_check_ret = await self._check_model(request) - if error_check_ret is not None: - return error_check_ret - result = await self.render_completion(request, skip_mm_cache=True) - if isinstance(result, ErrorResponse): - return result - generate_requests: list[GenerateRequest] = [] - for engine_input in result: - prompt_components = extract_prompt_components( - self.model_config, engine_input - ) - token_ids = prompt_components.token_ids - if not token_ids: - return self.create_error_response("No token_ids rendered") - token_ids = list(token_ids) - - input_length = extract_prompt_len(self.model_config, engine_input) - max_tokens = get_max_tokens( - self.model_config.max_model_len, - request.max_tokens, - input_length, - self.default_sampling_params, - self.override_max_tokens, - truncate_prompt_tokens=request.truncate_prompt_tokens, - ) - params = request.to_sampling_params( - max_tokens, self.default_sampling_params - ) + # because of issues with pydantic we need to potentially + # re-serialize the tool_calls field of the request + # for more info: see comment in `maybe_serialize_tool_calls` + _mt.maybe_serialize_tool_calls(request) # type: ignore[arg-type] - request_id = f"cmpl-{random_uuid()}" - - generate_requests.append( - GenerateRequest( - request_id=request_id, - token_ids=token_ids, - features=self._extract_mm_features(engine_input), - sampling_params=params, - model=request.model, - stream=bool(request.stream), - stream_options=(request.stream_options if request.stream else None), - cache_salt=request.cache_salt, - priority=request.priority, - ) + chat_messages = list(request.messages) + instructions, chat_messages = extract_instructions_from_messages(chat_messages) + + # Add system message. + # NOTE: In Chat Completion API, browsing is enabled by default + # if the model supports it. TODO: Support browsing. + assert not self.supports_browsing + assert not self.supports_code_interpreter + if (reasoning_effort := request.reasoning_effort) == "none": + raise ValueError(f"Harmony does not support {reasoning_effort=}") + tools = request.tools if should_include_tools else None + messages.extend( + build_harmony_preamble( + instructions=instructions, + tools=tools, # type: ignore[arg-type] + reasoning_effort=reasoning_effort, + with_custom_tools=should_include_tools, ) + ) + + # Add remaining conversation messages. + messages.extend(parse_chat_inputs_to_harmony_messages(chat_messages)) + + # Render prompt token ids. + prompt_token_ids = render_for_completion(messages) + engine_input = tokens_input(prompt_token_ids, cache_salt=request.cache_salt) - return generate_requests + return messages, [engine_input] async def render_completion( self, @@ -355,89 +254,6 @@ async def render_completion( return engine_inputs - @staticmethod - def _extract_mm_features( - engine_input: EngineInput, - ) -> MultiModalFeatures | None: - """Extract multimodal metadata from a rendered engine prompt. - - Returns ``None`` for text-only prompts. - """ - if engine_input.get("type") != "multimodal": - return None - - # At this point engine_input is a MultiModalInput TypedDict. - mm_engine_input = cast(MultiModalInput, engine_input) - mm_hashes: MultiModalHashes = mm_engine_input["mm_hashes"] - raw_placeholders: MultiModalPlaceholders = mm_engine_input["mm_placeholders"] - - mm_placeholders = { - modality: [ - PlaceholderRangeInfo(offset=p.offset, length=p.length) for p in ranges - ] - for modality, ranges in raw_placeholders.items() - } - - # Serialize tensor data per modality. - kwargs_data: dict[str, list[str | None]] | None = None - if raw_mm_kwargs := mm_engine_input.get("mm_kwargs"): - kwargs_data = {} - for modality, items in raw_mm_kwargs.items(): - kwargs_data[modality] = [ - encode_mm_kwargs_item(item) if item is not None else None - for item in items - ] - - return MultiModalFeatures( - mm_hashes=mm_hashes, - mm_placeholders=mm_placeholders, - kwargs_data=kwargs_data, - ) - - def _make_request_with_harmony( - self, - request: ChatCompletionRequest, - should_include_tools: bool = True, - ): - """Build Harmony (GPT-OSS) messages and engine prompt from a chat request.""" - messages: list[OpenAIMessage] = [] - - # because of issues with pydantic we need to potentially - # re-serialize the tool_calls field of the request - # for more info: see comment in `maybe_serialize_tool_calls` - _mt.maybe_serialize_tool_calls(request) # type: ignore[arg-type] - - # Add system message. - # NOTE: In Chat Completion API, browsing is enabled by default - # if the model supports it. TODO: Support browsing. - assert not self.supports_browsing - assert not self.supports_code_interpreter - if (reasoning_effort := request.reasoning_effort) == "none": - raise ValueError(f"Harmony does not support {reasoning_effort=}") - sys_msg = get_system_message( - reasoning_effort=reasoning_effort, - browser_description=None, - python_description=None, - with_custom_tools=should_include_tools, - ) - messages.append(sys_msg) - - # Add developer message. - if request.tools: - dev_msg = get_developer_message( - tools=request.tools if should_include_tools else None # type: ignore[arg-type] - ) - messages.append(dev_msg) - - # Add user message. - messages.extend(parse_chat_inputs_to_harmony_messages(request.messages)) - - # Render prompt token ids. - prompt_token_ids = render_for_completion(messages) - engine_input = tokens_input(prompt_token_ids, cache_salt=request.cache_salt) - - return messages, [engine_input] - def create_error_response( self, message: str | Exception, @@ -447,19 +263,13 @@ def create_error_response( ) -> ErrorResponse: return create_error_response(message, err_type, status_code, param) - async def _check_model( - self, - request: Any, - ) -> ErrorResponse | None: - return await self.model_registry.check_model(request.model) - def validate_chat_template( self, request_chat_template: str | None, chat_template_kwargs: dict[str, Any] | None, trust_request_chat_template: bool, ) -> ErrorResponse | None: - """Copied from OpenAIServing._validate_chat_template.""" + """Copied from GenerateBaseServing._validate_chat_template.""" if not trust_request_chat_template and ( request_chat_template is not None or ( @@ -482,7 +292,7 @@ async def preprocess_completion( *, skip_mm_cache: bool = False, ) -> list[EngineInput]: - """Copied from OpenAIServing._preprocess_completion.""" + """Copied from GenerateBaseServing._preprocess_completion.""" prompts = list[SingletonPrompt | bytes]() if prompt_embeds is not None: # embeds take higher priority prompts.extend(prompt_to_seq(prompt_embeds)) @@ -497,7 +307,7 @@ async def preprocess_cmpl( *, skip_mm_cache: bool = False, ) -> list[EngineInput]: - """Copied from OpenAIServing._preprocess_cmpl.""" + """Copied from GenerateBaseServing._preprocess_cmpl.""" renderer = self.renderer model_config = self.model_config @@ -530,12 +340,11 @@ async def preprocess_chat( default_template_content_format: ChatTemplateContentFormatOption, default_template_kwargs: dict[str, Any] | None, tool_dicts: list[dict[str, Any]] | None = None, - tool_parser: type[ToolParser] | None = None, - reasoning_parser: type[ReasoningParser] | None = None, + parser: type[Parser] | None = None, *, skip_mm_cache: bool = False, ) -> tuple[list[ConversationMessage], list[EngineInput]]: - """Copied from OpenAIServing._preprocess_chat.""" + """Copied from GenerateBaseServing._preprocess_chat.""" renderer = self.renderer mm_config = self.model_config.multimodal_config @@ -571,14 +380,6 @@ async def preprocess_chat( skip_mm_cache=skip_mm_cache, ) - if reasoning_parser is not None: - tokenizer = renderer.get_tokenizer() - request = reasoning_parser( - tokenizer, - model_config=self.model_config, - chat_template_kwargs=chat_params.chat_template_kwargs, - ).adjust_request(request=request) - # tool parsing is done only if a tool_parser has been set and if # tool_choice is not "none" (if tool_choice is "none" but a tool_parser # is set, we want to prevent parsing a tool_call hallucinated by the LLM @@ -586,15 +387,22 @@ async def preprocess_chat( # Exception: Mistral grammar-capable tokenizers always call # adjust_request — even for tool_choice="none" — so that the grammar # factory can prevent special-token leakage. - if tool_parser is not None: - tool_choice = getattr(request, "tool_choice", "none") + if parser is not None: tokenizer = renderer.get_tokenizer() + tool_parser = parser.tool_parser_cls + tool_choice = getattr(request, "tool_choice", "none") is_mistral_grammar_eligible = ( - is_mistral_tool_parser(tool_parser) + tool_parser is not None + and is_mistral_tool_parser(tool_parser) and is_mistral_tokenizer(tokenizer) and tokenizer.supports_grammar ) - if tool_choice != "none" or is_mistral_grammar_eligible: + should_adjust_request = ( + parser.reasoning_parser_cls is not None + or tool_choice != "none" + or is_mistral_grammar_eligible + ) + if should_adjust_request: if not isinstance(request, ChatCompletionRequest | ResponsesRequest): msg = ( "Tool usage is only supported " @@ -602,8 +410,13 @@ async def preprocess_chat( f"but got {type(request).__name__}" ) raise NotImplementedError(msg) - request = tool_parser(tokenizer, request.tools).adjust_request( - request=request + request = parser( + tokenizer, + request.tools, + model_config=self.model_config, + chat_template_kwargs=chat_params.chat_template_kwargs, + ).adjust_request( + request=request, ) return conversation, [engine_input] diff --git a/vllm/renderers/params.py b/vllm/renderers/params.py index d5c89abc043b..a07a49230676 100644 --- a/vllm/renderers/params.py +++ b/vllm/renderers/params.py @@ -87,6 +87,9 @@ class ChatParams: mm_processor_kwargs: dict[str, Any] | None = None """The kwargs to pass to the multi-modal processor.""" + return_assistant_tokens_mask: bool = False + """Request a per-token assistant mask from apply_chat_template.""" + def with_defaults( self, default_chat_template_kwargs: dict[str, Any] | None = None, @@ -115,6 +118,7 @@ def with_defaults( default_mm_processor_kwargs, self.mm_processor_kwargs, ), + return_assistant_tokens_mask=self.return_assistant_tokens_mask, ) def get_apply_chat_template_kwargs(self) -> dict[str, Any]: @@ -167,6 +171,11 @@ class TokenizeParams: add_special_tokens: bool = True """Whether to add special tokens.""" + return_token_offsets: bool = False + """If true, request char-level (start, end) offsets per token. Honored + only for Fast (Rust-backed) tokenizers with text input and no multimodal + data; otherwise silently ignored.""" + needs_detokenization: bool = False """ Whether the tokenized prompt needs to contain the original text. @@ -305,10 +314,11 @@ def get_encode_kwargs(self) -> dict[str, Any]: # while still failing `self._token_len_check` as expected by users max_length = self.max_input_tokens + 1 - # Explicit truncation-side overrides require the full token sequence so - # we can slice from the requested side in _token_truncation. Disable - # tokenizer-level truncation because generation tokenizers default to - # left truncation while callers may request right truncation. + # Explicit truncation-side overrides require the full token sequence + # so we can slice from the requested side in _token_truncation. + # Disable tokenizer-level truncation because its default side may + # differ from the requested side. The defense against unbounded + # tokenization lives in _text_len_check (character-level pre-trim). if self.truncation_side is not None and self.truncate_prompt_tokens is not None: return dict( truncation=False, @@ -324,15 +334,13 @@ def get_encode_kwargs(self) -> dict[str, Any]: def _text_len_check(self, tokenizer: TokenizerLike | None, text: str) -> str: """Apply length checks to prompt text if necessary.""" max_input_tokens = self.max_input_tokens - if max_input_tokens is None: + if max_input_tokens is None or tokenizer is None: return text - if self.truncate_prompt_tokens is None and tokenizer is not None: - max_input_chars = max_input_tokens * tokenizer.max_chars_per_token + max_input_chars = max_input_tokens * tokenizer.max_chars_per_token + if self.truncate_prompt_tokens is None: if len(text) > max_input_chars: - # To save resources, fail the request outright without even - # attempting tokenization raise VLLMValidationError( f"This model's maximum context length is " f"{self.max_total_tokens} tokens. However, you requested " @@ -345,6 +353,11 @@ def _text_len_check(self, tokenizer: TokenizerLike | None, text: str) -> str: parameter="input_text", value=len(text), ) + elif self.truncation_side is not None and len(text) > max_input_chars: + if self.truncation_side == "left": + text = text[-max_input_chars:] + else: + text = text[:max_input_chars] return text diff --git a/vllm/renderers/registry.py b/vllm/renderers/registry.py index 8263dd713a49..098a58e8edcd 100644 --- a/vllm/renderers/registry.py +++ b/vllm/renderers/registry.py @@ -22,11 +22,9 @@ _VLLM_RENDERERS = { "deepseek_v32": ("deepseek_v32", "DeepseekV32Renderer"), "deepseek_v4": ("deepseek_v4", "DeepseekV4Renderer"), - "grok2": ("grok2", "Grok2Renderer"), "hf": ("hf", "HfRenderer"), "kimi_audio": ("hf", "HfRenderer"), "mistral": ("mistral", "MistralRenderer"), - "qwen_vl": ("hf", "HfRenderer"), "terratorch": ("terratorch", "TerratorchRenderer"), } diff --git a/vllm/sampling_params.py b/vllm/sampling_params.py index 6beb1423ce23..f0966902d363 100644 --- a/vllm/sampling_params.py +++ b/vllm/sampling_params.py @@ -4,6 +4,7 @@ import copy import json as json_mod +import math from dataclasses import field from enum import Enum, IntEnum from functools import cached_property @@ -384,12 +385,31 @@ def from_optional( repetition_detection: RepetitionDetectionParams | None = None, ) -> "SamplingParams": if logit_bias is not None: - # Convert token_id to integer - # Clamp the bias between -100 and 100 per OpenAI API spec - logit_bias = { - int(token): min(100.0, max(-100.0, bias)) - for token, bias in logit_bias.items() - } + # Fast path uses a dict comprehension; on failure we iterate once + # to identify the exact offending entry for the error message. + try: + logit_bias = { + int(token): min(100.0, max(-100.0, bias)) + for token, bias in logit_bias.items() + } + except (ValueError, TypeError): + invalid_keys = [] + converted_logit_bias = {} + for token, bias in logit_bias.items(): + try: + token_id = int(token) + except (ValueError, TypeError): + invalid_keys.append(token) + continue + converted_logit_bias[token_id] = min(100.0, max(-100.0, bias)) + if invalid_keys: + raise VLLMValidationError( + f"logit_bias contains key(s) that cannot be " + f"converted to integer token IDs: {invalid_keys!r}", + parameter="logit_bias", + value=invalid_keys, + ) from None + logit_bias = converted_logit_bias return SamplingParams( n=1 if n is None else n, @@ -503,17 +523,34 @@ def _verify_args(self) -> None: raise ValueError( f"frequency_penalty must be in [-2, 2], got {self.frequency_penalty}." ) + if not math.isfinite(self.repetition_penalty): + raise ValueError( + "repetition_penalty must be a finite number, " + f"got {self.repetition_penalty}." + ) if self.repetition_penalty <= 0.0: raise ValueError( "repetition_penalty must be greater than zero, got " f"{self.repetition_penalty}." ) + if not math.isfinite(self.temperature): + raise VLLMValidationError( + f"temperature must be a finite number, got {self.temperature}.", + parameter="temperature", + value=self.temperature, + ) if self.temperature < 0.0: raise VLLMValidationError( f"temperature must be non-negative, got {self.temperature}.", parameter="temperature", value=self.temperature, ) + if self.temperature > 2.0: + raise VLLMValidationError( + f"temperature must be in [0, 2], got {self.temperature}.", + parameter="temperature", + value=self.temperature, + ) if not 0.0 < self.top_p <= 1.0: raise VLLMValidationError( f"top_p must be in (0, 1], got {self.top_p}.", @@ -708,7 +745,10 @@ def verify( self._validate_logits_processors(model_config) self._validate_allowed_token_ids(tokenizer) self._validate_spec_decode(speculative_config) - self._validate_structured_outputs(structured_outputs_config, tokenizer) + self._validate_diffusion(model_config) + self._validate_structured_outputs( + model_config, structured_outputs_config, tokenizer + ) def _validate_logprobs(self, model_config: ModelConfig) -> None: max_logprobs = model_config.max_logprobs @@ -839,14 +879,49 @@ def _validate_spec_decode( "are not yet supported with speculative decoding." ) + def _validate_diffusion(self, model_config: ModelConfig) -> None: + if not model_config.is_diffusion: + return + + # Diffusion models denoise a whole canvas per step with a fixed + # temperature schedule, so per-request sampling parameters are not + # supported. Penalties are ignored by the sampler with a warning. + if ( + self.temperature != 1.0 + or self.min_p > _SAMPLING_EPS + or self.seed is not None + or self.min_tokens > 0 + or self.logit_bias + or self.bad_words + or self.allowed_token_ids + ): + raise ValueError( + "The temperature, min_p, seed, min_tokens, logit_bias, " + "bad_words, and allowed_token_ids sampling parameters " + "are not yet supported with diffusion models." + ) + def _validate_structured_outputs( self, + model_config: ModelConfig, structured_outputs_config: StructuredOutputsConfig | None, tokenizer: TokenizerLike | None, ) -> None: if structured_outputs_config is None or self.structured_outputs is None: return + if model_config.is_diffusion: + # Diffusion LLMs denoise a whole canvas of tokens in parallel + # rather than sampling left-to-right, which the grammar FSM + # requires. Without this check, requests fail mid-generation + # with an FSM rejection (HTTP 500). See issue #45436. + raise ValueError( + "Structured outputs are not yet supported for diffusion " + "language models. Remove the structured output constraint " + "(e.g. `response_format`, `structured_outputs`) from the " + "request." + ) + if tokenizer is None: raise ValueError( "Structured outputs requires a tokenizer so it can't be used with 'skip_tokenizer_init'" # noqa: E501 @@ -886,6 +961,18 @@ def _validate_structured_outputs( and self.structured_outputs.grammar.strip() == "" ): raise ValueError("structured_outputs.grammar cannot be an empty string") + # Reject empty string json schema early to avoid engine-side crashes + if ( + isinstance(self.structured_outputs.json, str) + and self.structured_outputs.json.strip() == "" + ): + raise ValueError("structured_outputs.json cannot be an empty string") + # Reject json_object=False early to avoid engine-side crashes + if self.structured_outputs.json_object is False: + raise ValueError( + "structured_outputs.json_object must be True if set; omit " + "structured_outputs to disable structured outputs" + ) from vllm.v1.structured_output.backend_guidance import ( has_guidance_unsupported_json_features, @@ -1036,3 +1123,4 @@ class BeamSearchParams( temperature: float = 0.0 length_penalty: float = 1.0 include_stop_str_in_output: bool = False + structured_outputs: StructuredOutputsParams | None = None diff --git a/vllm/scalar_type.py b/vllm/scalar_type.py index 05760f3f8299..db52e93465ca 100644 --- a/vllm/scalar_type.py +++ b/vllm/scalar_type.py @@ -348,6 +348,9 @@ class scalar_types: uint2b2 = ScalarType.uint(2, 2) uint3b4 = ScalarType.uint(3, 4) uint4b8 = ScalarType.uint(4, 8) + uint5b16 = ScalarType.uint(5, 16) + uint6b32 = ScalarType.uint(6, 32) + uint7b64 = ScalarType.uint(7, 64) uint8b128 = ScalarType.uint(8, 128) # colloquial names diff --git a/vllm/tokenizers/deepseek_v32.py b/vllm/tokenizers/deepseek_v32.py index 51199de5c47e..b388f0579300 100644 --- a/vllm/tokenizers/deepseek_v32.py +++ b/vllm/tokenizers/deepseek_v32.py @@ -3,7 +3,7 @@ import copy from typing import Any -from transformers import PreTrainedTokenizerFast +from transformers import TokenizersBackend from vllm.entrypoints.chat_utils import ChatCompletionMessageParam @@ -85,5 +85,5 @@ def __reduce__(self): class DeepseekV32Tokenizer(TokenizerLike): @classmethod def from_pretrained(cls, *args, **kwargs) -> HfTokenizer: - tokenizer = PreTrainedTokenizerFast.from_pretrained(*args, **kwargs) + tokenizer = TokenizersBackend.from_pretrained(*args, **kwargs) return get_cached_tokenizer(get_deepseek_v32_tokenizer(tokenizer)) diff --git a/vllm/tokenizers/deepseek_v4.py b/vllm/tokenizers/deepseek_v4.py index 2a6aaaf73975..3897149626f8 100644 --- a/vllm/tokenizers/deepseek_v4.py +++ b/vllm/tokenizers/deepseek_v4.py @@ -3,7 +3,7 @@ import copy from typing import Any -from transformers import PreTrainedTokenizerFast +from transformers import TokenizersBackend from vllm.entrypoints.chat_utils import ChatCompletionMessageParam @@ -92,5 +92,5 @@ def __reduce__(self): class DeepseekV4Tokenizer(TokenizerLike): @classmethod def from_pretrained(cls, *args, **kwargs) -> HfTokenizer: - tokenizer = PreTrainedTokenizerFast.from_pretrained(*args, **kwargs) + tokenizer = TokenizersBackend.from_pretrained(*args, **kwargs) return get_cached_tokenizer(get_deepseek_v4_tokenizer(tokenizer)) diff --git a/vllm/tokenizers/fastokens.py b/vllm/tokenizers/fastokens.py index 5f080a549dbd..8adf1d94f2ea 100644 --- a/vllm/tokenizers/fastokens.py +++ b/vllm/tokenizers/fastokens.py @@ -7,7 +7,7 @@ fastokens shim and rebinds ``tokenizers.decoders.DecodeStream`` so the streaming detokenizer accepts the shim. The patch is process-global and idempotent, so it applies to any tokenizer mode that ends up loading an HF -fast tokenizer (`hf`, `deepseek_v32`, `deepseek_v4`, `qwen_vl`, …). +fast tokenizer (`hf`, `deepseek_v32`, `deepseek_v4`, …). """ from importlib.metadata import PackageNotFoundError, version diff --git a/vllm/tokenizers/grok2.py b/vllm/tokenizers/grok2.py deleted file mode 100644 index 612af5374082..000000000000 --- a/vllm/tokenizers/grok2.py +++ /dev/null @@ -1,452 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Tokenizer for Grok-2 .tok.json format.""" - -import functools -import json -from collections.abc import Collection, Sequence, Set -from pathlib import Path -from typing import Any, Literal, overload - -from huggingface_hub.utils import ( - EntryNotFoundError, - HfHubHTTPError, - RepositoryNotFoundError, - RevisionNotFoundError, -) -from transformers import BatchEncoding -from transformers.utils import chat_template_utils as hf_chat_utils - -from vllm.entrypoints.chat_utils import ChatCompletionMessageParam -from vllm.logger import init_logger -from vllm.transformers_utils.repo_utils import hf_api - -from .protocol import TokenizerLike - -logger = init_logger(__name__) - -PAD = "<|pad|>" -EOS = "<|eos|>" -SEP = "<|separator|>" -RESERVED_TOKEN_TEXTS = [f"<|reserved_{i}|>" for i in range(3, 128)] -CONTROL_TOKEN_TEXTS = [f"<|control{i}|>" for i in range(1, 705)] -DEFAULT_SPECIAL_TOKENS = [PAD, SEP, EOS] -DEFAULT_CONTROL_TOKENS = {"pad": PAD, "sep": SEP, "eos": EOS} -DEFAULT_CHAT_TEMPLATE = ( - "{% for message in messages %}" - "{% if message['role'] == 'user' %}" - "{{ 'Human: ' + message['content'].strip() + '<|separator|>\\n\\n' }}" - "{% elif message['role'] == 'system' %}" - "{{ 'System: ' + message['content'].strip() + '<|separator|>\\n\\n' }}" - "{% elif message['role'] == 'assistant' %}" - "{{ 'Assistant: ' + message['content'] + '<|separator|>\\n\\n' }}" - "{% endif %}" - "{% endfor %}" - "{% if add_generation_prompt %}" - "{{ 'Assistant:' }}" - "{% endif %}" -) - -# Default + separate each single digit. -PAT_STR_B = ( - r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}|""" - r""" ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""" -) - - -def _maybe_load_tokenizer_config( - model_path: Path, - *, - repo_id: str | None, - revision: str | None, - download_dir: str | None, -) -> dict[str, Any]: - config_path = model_path / "tokenizer_config.json" - if config_path.is_file(): - with config_path.open("r", encoding="utf-8") as f: - return json.load(f) - - if repo_id is None: - return {} - - try: - config_file = hf_api().hf_hub_download( - repo_id=repo_id, - filename="tokenizer_config.json", - revision=revision, - cache_dir=download_dir, - ) - except (RepositoryNotFoundError, RevisionNotFoundError, EntryNotFoundError): - # If the repo, revision, or file does not exist, fall back silently. - return {} - except HfHubHTTPError as exc: - logger.warning( - "Failed to download tokenizer_config.json from %s. " - "This may be due to a network or authentication issue. " - "The default chat template will be used. Error: %s", - repo_id, - exc, - ) - return {} - - try: - with Path(config_file).open("r", encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError as exc: - logger.warning( - "Failed to parse tokenizer_config.json. " - "The default chat template will be used. Error: %s", - exc, - ) - return {} - except OSError as exc: - logger.warning( - "Failed to open tokenizer_config.json. " - "The default chat template will be used. Error: %s", - exc, - ) - return {} - - -def _load_tiktoken_encoding( - vocab_file: Path, -) -> tuple[Any, dict[str, int]]: - try: - import tiktoken - except ImportError as exc: - raise ImportError("Grok-2 tokenizer requires the `tiktoken` package.") from exc - - with vocab_file.open("rb") as f: - xtok_dict = json.load(f) - - mergeable_ranks = { - bytes(item["bytes"]): item["token"] - for item in xtok_dict.get("regular_tokens", []) - } - special_tokens = { - bytes(item["bytes"]).decode("utf-8", errors="replace"): item["token"] - for item in xtok_dict.get("special_tokens", []) - } - - if xtok_dict.get("word_split") == "V1": - pat_str = PAT_STR_B - else: - raise ValueError(f"Unknown word_split: {xtok_dict.get('word_split')!r}") - - pat_str = xtok_dict.get("pat_str", pat_str) - - kwargs = { - "name": str(vocab_file), - "pat_str": pat_str, - "mergeable_ranks": mergeable_ranks, - "special_tokens": special_tokens, - } - - if "vocab_size" in xtok_dict: - kwargs["explicit_n_vocab"] = xtok_dict["vocab_size"] - - tokenizer = tiktoken.Encoding(**kwargs) - - default_allowed_special: set[str] | None = None - if "default_allowed_special" in xtok_dict: - default_allowed_special = { - bytes(bytes_list).decode("utf-8", errors="replace") - for bytes_list in xtok_dict["default_allowed_special"] - } - - tokenizer._default_allowed_special = default_allowed_special or set() - tokenizer._control_tokens = DEFAULT_CONTROL_TOKENS - - def encode_patched( - self, - text: str, - *, - allowed_special: Literal["all"] | Set[str] = set(), - disallowed_special: Literal["all"] | Collection[str] = "all", - ) -> list[int]: - del disallowed_special - if isinstance(allowed_special, set): - allowed_special |= self._default_allowed_special - return tiktoken.Encoding.encode( - self, - text, - allowed_special=allowed_special, - disallowed_special=(), - ) - - tokenizer.encode = functools.partial(encode_patched, tokenizer) - tokenizer._default_allowed_special |= set(DEFAULT_CONTROL_TOKENS.values()) - tokenizer._default_allowed_special |= set( - CONTROL_TOKEN_TEXTS + RESERVED_TOKEN_TEXTS - ) - - return tokenizer, special_tokens - - -class Grok2Tokenizer(TokenizerLike): - @classmethod - def from_pretrained( - cls, - path_or_repo_id: str | Path, - *args, - trust_remote_code: bool = False, - revision: str | None = None, - download_dir: str | None = None, - **kwargs, - ) -> "Grok2Tokenizer": - if args: - logger.debug_once("Ignoring extra positional args for Grok2Tokenizer.") - - path = Path(path_or_repo_id) - if path.is_file(): - vocab_file = path - model_path = path.parent - repo_id = None - elif path.is_dir(): - vocab_file = path / "tokenizer.tok.json" - model_path = path - repo_id = None - else: - vocab_file = Path( - hf_api().hf_hub_download( - repo_id=str(path_or_repo_id), - filename="tokenizer.tok.json", - revision=revision, - cache_dir=download_dir, - ) - ) - model_path = vocab_file.parent - repo_id = str(path_or_repo_id) - - if not vocab_file.is_file(): - raise FileNotFoundError(f"tokenizer.tok.json not found at {vocab_file}.") - - config = _maybe_load_tokenizer_config( - model_path, - repo_id=repo_id, - revision=revision, - download_dir=download_dir, - ) - - return cls( - vocab_file=vocab_file, - name_or_path=str(path_or_repo_id), - truncation_side=kwargs.get("truncation_side", "left"), - chat_template=config.get("chat_template"), - init_kwargs=config, - ) - - def __init__( - self, - *, - vocab_file: Path, - name_or_path: str, - truncation_side: str, - chat_template: str | None, - init_kwargs: dict[str, Any] | None = None, - ) -> None: - super().__init__() - self.name_or_path = name_or_path - self._truncation_side = truncation_side - self.init_kwargs = init_kwargs or {} - self._chat_template = chat_template or DEFAULT_CHAT_TEMPLATE - - self._tokenizer, self._special_tokens = _load_tiktoken_encoding(vocab_file) - - self._token_to_id: dict[str, int] = {} - self._id_to_token: dict[int, str] = {} - for token, token_id in self._tokenizer._mergeable_ranks.items(): - token_str = token.decode("utf-8", errors="replace") - self._token_to_id[token_str] = token_id - self._id_to_token[token_id] = token_str - - for token, token_id in self._special_tokens.items(): - self._token_to_id[token] = token_id - self._id_to_token[token_id] = token - - bos_token_id = self._special_tokens.get(SEP) - if bos_token_id is None: - bos_token_id = self._special_tokens.get(PAD) - if bos_token_id is None: - bos_token_id = self._special_tokens.get(EOS) - if bos_token_id is None: - bos_token_id = 0 - self._bos_token_id = bos_token_id - - self._eos_token_id = self._special_tokens.get(EOS, self._bos_token_id) - self._pad_token_id = self._special_tokens.get(PAD, self._eos_token_id) - self._unk_token_id = self._pad_token_id - - self._max_chars_per_token = max(len(tok) for tok in self._token_to_id) - - def num_special_tokens_to_add(self) -> int: - return 0 - - @property - def all_special_tokens(self) -> list[str]: - return list(self._special_tokens.keys()) - - @property - def all_special_ids(self) -> list[int]: - return list(self._special_tokens.values()) - - @property - def bos_token_id(self) -> int: - return self._bos_token_id - - @property - def eos_token_id(self) -> int: - return self._eos_token_id - - @property - def pad_token_id(self) -> int: - return self._pad_token_id - - @property - def is_fast(self) -> bool: - return False - - @property - def vocab_size(self) -> int: - return self._tokenizer.n_vocab - - @property - def max_token_id(self) -> int: - return self._tokenizer.n_vocab - 1 - - @property - def max_chars_per_token(self) -> int: - return self._max_chars_per_token - - @property - def truncation_side(self) -> str: - return self._truncation_side - - def get_vocab(self) -> dict[str, int]: - return dict(self._token_to_id) - - def get_added_vocab(self) -> dict[str, int]: - return dict(self._special_tokens) - - def _maybe_truncate(self, tokens: list[int], max_length: int | None) -> list[int]: - if max_length is None or len(tokens) <= max_length: - return tokens - if self.truncation_side == "left": - return tokens[-max_length:] - return tokens[:max_length] - - def encode( - self, - text: str, - truncation: bool | None = None, - max_length: int | None = None, - add_special_tokens: bool = True, - ) -> list[int]: - del add_special_tokens - tokens = self._tokenizer.encode(text) - if truncation: - tokens = self._maybe_truncate(tokens, max_length) - return tokens - - def decode( - self, ids: Sequence[int] | int, skip_special_tokens: bool = False - ) -> str: - if isinstance(ids, int): - ids = [ids] - if skip_special_tokens: - ids = [ - token_id - for token_id in ids - if token_id not in self._special_tokens.values() - ] - return self._tokenizer.decode(ids) - - @overload - def convert_tokens_to_ids(self, tokens: str) -> int: ... - - @overload - def convert_tokens_to_ids(self, tokens: list[str]) -> list[int]: ... - - def convert_tokens_to_ids(self, tokens: str | list[str]) -> int | list[int]: - if isinstance(tokens, str): - return self._token_to_id.get(tokens, self._unk_token_id) - return [self._token_to_id.get(token, self._unk_token_id) for token in tokens] - - def convert_ids_to_tokens( - self, ids: Sequence[int], skip_special_tokens: bool = False - ) -> list[str]: - tokens = [] - for token_id in ids: - if skip_special_tokens and token_id in self._special_tokens.values(): - continue - tokens.append(self._id_to_token.get(token_id, "<|unk|>")) - return tokens - - def convert_tokens_to_string(self, tokens: list[str]) -> str: - token_ids = self.convert_tokens_to_ids(tokens) - return self.decode(token_ids, skip_special_tokens=False) - - def __call__( - self, - text: str | list[str], - text_pair: str | None = None, - add_special_tokens: bool = True, - truncation: bool = False, - max_length: int | None = None, - ) -> BatchEncoding: - if text_pair is not None: - raise NotImplementedError("text_pair is not supported for Grok2Tokenizer.") - - if isinstance(text, list): - input_ids_batch: list[list[int]] = [ - self.encode( - item, - truncation=truncation, - max_length=max_length, - add_special_tokens=add_special_tokens, - ) - for item in text - ] - attention_mask_batch = [[1] * len(ids) for ids in input_ids_batch] - return BatchEncoding( - {"input_ids": input_ids_batch, "attention_mask": attention_mask_batch} - ) - - input_ids = self.encode( - text, - truncation=truncation, - max_length=max_length, - add_special_tokens=add_special_tokens, - ) - attention_mask = [1] * len(input_ids) - return BatchEncoding({"input_ids": input_ids, "attention_mask": attention_mask}) - - def get_chat_template( - self, chat_template: str | None, tools: list[dict[str, Any]] | None = None - ) -> str | None: - del tools - return chat_template or self._chat_template - - def apply_chat_template( - self, - messages: list[ChatCompletionMessageParam], - tools: list[dict[str, Any]] | None = None, - chat_template: str | None = None, - tokenize: bool = False, - **kwargs, - ) -> str | list[int]: - template = self.get_chat_template(chat_template, tools=tools) - if template is None: - raise ValueError( - "No chat template available. Provide `chat_template` explicitly." - ) - kwargs["return_dict"] = False - prompt = hf_chat_utils.apply_chat_template( - conversation=messages, - chat_template=template, - tools=tools, - **kwargs, - ) - if tokenize: - return self.encode(prompt, add_special_tokens=False) - return prompt diff --git a/vllm/tokenizers/hf.py b/vllm/tokenizers/hf.py index b4248e229a68..bdc767acd66c 100644 --- a/vllm/tokenizers/hf.py +++ b/vllm/tokenizers/hf.py @@ -6,13 +6,13 @@ from pathlib import Path from typing import TypeAlias, TypeVar -from transformers import AutoTokenizer, PreTrainedTokenizer, PreTrainedTokenizerFast +from transformers import AutoTokenizer, PythonBackend, TokenizersBackend from vllm.transformers_utils.config import get_sentence_transformer_tokenizer_config from .protocol import TokenizerLike -HfTokenizer: TypeAlias = PreTrainedTokenizer | PreTrainedTokenizerFast +HfTokenizer: TypeAlias = PythonBackend | TokenizersBackend _T = TypeVar("_T", bound=TokenizerLike) @@ -24,7 +24,7 @@ class ThreadSafeHFTokenizerMixin: def maybe_make_thread_pool(tokenizer: _T, copies: int = 1): """ - If `tokenizer` is a `PreTrainedTokenizerFast`, modify the tokenizer + If `tokenizer` is a `TokenizersBackend`, modify the tokenizer in-place to make the public interface thread-safe by routing calls through a deep-copied tokenizer pool. @@ -34,14 +34,14 @@ def maybe_make_thread_pool(tokenizer: _T, copies: int = 1): methods like ``add_special_tokens`` or ``add_tokens``. - Adjacent method calls could happen on different deep copies. """ - if not isinstance(tokenizer, PreTrainedTokenizerFast) or isinstance( + if not isinstance(tokenizer, TokenizersBackend) or isinstance( tokenizer, ThreadSafeHFTokenizerMixin ): return tokenizer og_tokenizer = copy.copy(tokenizer) - tokenizer_pool: queue.Queue[PreTrainedTokenizerFast] = queue.Queue() + tokenizer_pool: queue.Queue[TokenizersBackend] = queue.Queue() for _ in range(copies): tokenizer_pool.put(copy.deepcopy(og_tokenizer)) @@ -99,6 +99,9 @@ def __reduce__(self): TokenizerPool.__name__ = f"TokenizerPool{og_tokenizer.__class__.__name__}" tokenizer.__class__ = TokenizerPool + # Return the tokenizer: TokenizerPool.__reduce__ reconstructs through this + # function, so falling off the end would unpickle to None (issue #45433). + return tokenizer def get_cached_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer: @@ -113,6 +116,16 @@ def get_cached_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer: tokenizer_all_special_tokens = tokenizer.all_special_tokens tokenizer_vocab = tokenizer.get_vocab() tokenizer_len = len(tokenizer) + # The underlying tokenizer class could be MistralCommonBackend, + # which does not implement is_fast in Transformers + tokenizer_is_fast = getattr(tokenizer, "is_fast", True) + + # MistralCommonBackend is tekken-backed and needs byte-fallback-aware tokenization. + mistral_tekkenizer = None + if getattr(getattr(tokenizer, "tokenizer", None), "instruct_tokenizer", None): + from vllm.tokenizers.mistral import mistral_common_tekkenizer + + mistral_tekkenizer = mistral_common_tekkenizer(tokenizer) max_token_id = max(tokenizer_vocab.values()) max_chars_per_token = max(len(tok) for tok in tokenizer_vocab) @@ -142,6 +155,31 @@ def max_token_id(self) -> int: def max_chars_per_token(self) -> int: return max_chars_per_token + @property + def is_fast(self) -> bool: + return tokenizer_is_fast + + def convert_ids_to_tokens(self, ids, skip_special_tokens: bool = False): + if mistral_tekkenizer is not None: + from vllm.tokenizers.mistral import tekken_convert_ids_to_tokens + + return tekken_convert_ids_to_tokens(mistral_tekkenizer, ids) + return super().convert_ids_to_tokens( + ids, skip_special_tokens=skip_special_tokens + ) + + def convert_tokens_to_string(self, tokens: list[str]) -> str: + if mistral_tekkenizer is not None: + from vllm.tokenizers.mistral import tekken_convert_tokens_to_string + + return tekken_convert_tokens_to_string(mistral_tekkenizer, tokens) + try: + return super().convert_tokens_to_string(tokens) + except NotImplementedError: + # The underlying tokenizer class could be MistralCommonBackend, + # which does not implement convert_tokens_to_string in Transformers + return "".join(tokens) + def get_vocab(self) -> dict[str, int]: return tokenizer_vocab diff --git a/vllm/tokenizers/mistral.py b/vllm/tokenizers/mistral.py index 8fce690433ef..1164f7c41a76 100644 --- a/vllm/tokenizers/mistral.py +++ b/vllm/tokenizers/mistral.py @@ -31,21 +31,13 @@ ) from mistral_common.tokens.tokenizers.tekken import Tekkenizer from pydantic import ValidationError +from transformers.tokenization_mistral_common import MistralCommonBackend from vllm.entrypoints.chat_utils import ChatCompletionMessageParam from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest from vllm.logger import init_logger from vllm.tokenizers.protocol import TokenizerLike -try: - # Transformers v5 - from transformers.tokenization_mistral_common import MistralCommonBackend -except ImportError: - # Transformers v4 - from transformers.tokenization_mistral_common import ( - MistralCommonTokenizer as MistralCommonBackend, - ) - if TYPE_CHECKING: import llguidance from transformers import BatchEncoding @@ -196,6 +188,39 @@ def _tekken_token_to_id(tokenizer: "Tekkenizer", t: str | bytes) -> int: return tokenizer.unk_id +def mistral_common_tekkenizer(tokenizer: object) -> "Tekkenizer | None": + """Return the underlying `Tekkenizer` for a `MistralCommonBackend`.""" + mistral = getattr(tokenizer, "tokenizer", None) + instruct = getattr(mistral, "instruct_tokenizer", None) + tekken = getattr(instruct, "tokenizer", None) + return tekken if isinstance(tekken, Tekkenizer) else None + + +def tekken_convert_ids_to_tokens( + tokenizer: "Tekkenizer", ids: Sequence[int] +) -> list[str | bytes]: + """Convert ids to pieces, using raw `bytes` for byte-fallback tokens.""" + tokens: list[str | bytes] = [tokenizer.id_to_piece(i) for i in ids] + if any("�" in t for t in tokens): + tokens = [ + tokenizer.id_to_byte_piece(i, SpecialTokenPolicy.KEEP) + if i >= tokenizer.num_special_tokens + else tokenizer.decode([i], SpecialTokenPolicy.KEEP) + for i in ids + ] + return tokens + + +def tekken_convert_tokens_to_string( + tokenizer: "Tekkenizer", tokens: Sequence[str | bytes] +) -> str: + """Reassemble pieces from `tekken_convert_ids_to_tokens` into text.""" + if any(isinstance(t, bytes) for t in tokens): + ids = [_tekken_token_to_id(tokenizer, t) for t in tokens] + return tokenizer.decode(ids, SpecialTokenPolicy.KEEP) + return "".join(cast(Sequence[str], tokens)) + + class MistralTokenizer(TokenizerLike): IS_MISTRAL_TOKENIZER = True # used by vllm.utils.mistral @@ -461,14 +486,8 @@ def convert_tokens_to_string(self, tokens: list[str]) -> str: if (t in to_decode_special_tokens or t not in self._special_tokens_set) ] - if any(isinstance(t, bytes) for t in tokens): - # we need to encode and decode all tokens again - ids = [_tekken_token_to_id(self.tokenizer, t) for t in tokens] - # We filtered unwanted special tokens before - # so we can decode the rest. - decoded = self.tokenizer.decode(ids, SpecialTokenPolicy.KEEP) - else: - decoded = "".join(tokens) + # We filtered unwanted special tokens before so we can decode the rest. + decoded = tekken_convert_tokens_to_string(self.tokenizer, tokens) else: # make sure certain special tokens like Tool calls are # not decoded diff --git a/vllm/tokenizers/qwen_vl.py b/vllm/tokenizers/qwen_vl.py deleted file mode 100644 index f36a22b02545..000000000000 --- a/vllm/tokenizers/qwen_vl.py +++ /dev/null @@ -1,71 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import copy -import unicodedata -from collections.abc import Collection, Set - -from transformers import AutoTokenizer - -from .hf import HfTokenizer, get_cached_tokenizer -from .protocol import TokenizerLike - - -def get_qwen_vl_tokenizer(tokenizer: HfTokenizer) -> HfTokenizer: - """ - The logic of adding image pad tokens should only be applied in - `QwenVLProcessor`, so they are patched out here. - - The definition of the wrapped tokenizer can be found here: - https://huggingface.co/Qwen/Qwen-VL/blob/main/tokenization_qwen.py - """ - new_tokenizer = copy.copy(tokenizer) - - class TokenizerWithoutImagePad(tokenizer.__class__): # type: ignore - def tokenize( - self, - text: str, - allowed_special: Set[str] | str = "all", - disallowed_special: Collection[str] | str = (), - **kwargs, - ) -> list[bytes | str]: - text = unicodedata.normalize("NFC", text) - - return [ - self.decoder[t] - for t in self.tokenizer.encode( - text, - allowed_special=allowed_special, - disallowed_special=disallowed_special, - ) - ] - - def _decode( - self, - token_ids: int | list[int], - skip_special_tokens: bool = False, - errors: str | None = None, - **kwargs, - ) -> str: - if isinstance(token_ids, int): - token_ids = [token_ids] - - return self.tokenizer.decode( - token_ids, - errors=errors or self.errors, - ) - - TokenizerWithoutImagePad.__name__ = f"{tokenizer.__class__.__name__}WithoutImagePad" - - new_tokenizer.__class__ = TokenizerWithoutImagePad - return new_tokenizer - - -class QwenVLTokenizer(TokenizerLike): - image_start_tag: str - image_end_tag: str - image_pad_tag: str - - @classmethod - def from_pretrained(cls, *args, **kwargs) -> HfTokenizer: - tokenizer = AutoTokenizer.from_pretrained(*args, **kwargs) - return get_cached_tokenizer(get_qwen_vl_tokenizer(tokenizer)) diff --git a/vllm/tokenizers/registry.py b/vllm/tokenizers/registry.py index 7578d3b43ab2..cef2f7645fe8 100644 --- a/vllm/tokenizers/registry.py +++ b/vllm/tokenizers/registry.py @@ -11,14 +11,7 @@ import vllm.envs as envs from vllm.logger import init_logger -from vllm.transformers_utils.config import get_config -from vllm.transformers_utils.gguf_utils import ( - check_gguf_file, - get_gguf_file_path_from_hf, - is_gguf, - is_remote_gguf, - split_remote_gguf, -) +from vllm.transformers_utils.config import _maybe_register_hf_config, get_config from vllm.transformers_utils.repo_utils import ( any_pattern_in_repo_files, is_mistral_model_repo, @@ -38,16 +31,19 @@ # temporary workaround and better long term solutions are: # - Add model type to MODELS_WITH_INCORRECT_HUB_TOKENIZER_CLASS in transformers (better) # - Fix tokenizer_class on the hub for the affected models (best) -_MODEL_TYPES_WITH_INCORRECT_TOKENIZER_CLASS: set[str] = {"step3_vl", "step3p7"} +_MODEL_TYPES_WITH_INCORRECT_TOKENIZER_CLASS: set[str] = { + "internlm2", + "step3_vl", + "step3p7", + "unlimited-ocr", +} _VLLM_TOKENIZERS = { "deepseek_v32": ("deepseek_v32", "DeepseekV32Tokenizer"), "deepseek_v4": ("deepseek_v4", "DeepseekV4Tokenizer"), - "grok2": ("grok2", "Grok2Tokenizer"), "hf": ("hf", "CachedHfTokenizer"), "kimi_audio": ("kimi_audio", "KimiAudioTokenizer"), "mistral": ("mistral", "MistralTokenizer"), - "qwen_vl": ("qwen_vl", "QwenVLTokenizer"), } @@ -125,21 +121,6 @@ def resolve_tokenizer_args( ) tokenizer_name = tokenizer_path - # Separate model folder from file path for GGUF models - if is_gguf(tokenizer_name): - if check_gguf_file(tokenizer_name): - kwargs["gguf_file"] = Path(tokenizer_name).name - tokenizer_name = Path(tokenizer_name).parent - elif is_remote_gguf(tokenizer_name): - tokenizer_name, quant_type = split_remote_gguf(tokenizer_name) - # Get the HuggingFace Hub path for the GGUF file - gguf_file = get_gguf_file_path_from_hf( - tokenizer_name, - quant_type, - revision=revision, - ) - kwargs["gguf_file"] = gguf_file - if "truncation_side" not in kwargs: if runner_type == "generate" or runner_type == "draft": kwargs["truncation_side"] = "left" @@ -269,6 +250,8 @@ def cached_tokenizer_from_config(model_config: "ModelConfig", **kwargs): if model_config.skip_tokenizer_init: return None + _maybe_register_hf_config(getattr(model_config, "hf_config", None)) + return cached_get_tokenizer( model_config.tokenizer, runner_type=model_config.runner_type, diff --git a/vllm/tool_parsers/__init__.py b/vllm/tool_parsers/__init__.py index bf832f178be7..26362ebf0ed6 100644 --- a/vllm/tool_parsers/__init__.py +++ b/vllm/tool_parsers/__init__.py @@ -15,7 +15,7 @@ Example: ToolParserManager.register_lazy_module( name="kimi_k2", - module_path="vllm.tool_parsers.kimi_k2_parser", + module_path="vllm.tool_parsers.kimi_k2_tool_parser", class_name="KimiK2ToolParser", ) """ @@ -31,12 +31,12 @@ "DeepSeekV31ToolParser", ), "deepseek_v32": ( - "deepseekv32_tool_parser", - "DeepSeekV32ToolParser", + "deepseekv32_engine_tool_parser", + "DeepSeekV32EngineToolParser", ), "deepseek_v4": ( - "deepseekv4_tool_parser", - "DeepSeekV4ToolParser", + "deepseekv4_engine_tool_parser", + "DeepSeekV4EngineToolParser", ), "cohere_command3": ( "cohere_command_tool_parser", @@ -51,8 +51,8 @@ "Ernie45ToolParser", ), "glm45": ( - "glm4_moe_tool_parser", - "Glm4MoeModelToolParser", + "glm47_moe_tool_parser", + "Glm47MoeModelToolParser", ), "glm47": ( "glm47_moe_tool_parser", @@ -119,16 +119,16 @@ "LongcatFlashToolParser", ), "mimo": ( - "qwen3xml_tool_parser", - "Qwen3XMLToolParser", + "qwen3_engine_tool_parser", + "Qwen3EngineToolParser", ), "minimax_m2": ( "minimax_m2_tool_parser", "MinimaxM2ToolParser", ), - "minimax": ( - "minimax_tool_parser", - "MinimaxToolParser", + "minimax_m3": ( + "minimax_m3_tool_parser", + "MinimaxM3ToolParser", ), "minicpm5": ( "minicpm5xml_tool_parser", @@ -143,8 +143,8 @@ "Olmo3PythonicToolParser", ), "openai": ( - "openai_tool_parser", - "OpenAIToolParser", + "gptoss_tool_parser", + "GptOssToolParser", ), "phi4_mini_json": ( "phi4mini_tool_parser", @@ -155,16 +155,16 @@ "PythonicToolParser", ), "qwen3_coder": ( - "qwen3coder_tool_parser", - "Qwen3CoderToolParser", + "qwen3_engine_tool_parser", + "Qwen3EngineToolParser", ), "qwen3_xml": ( - "qwen3xml_tool_parser", - "Qwen3XMLToolParser", + "qwen3_engine_tool_parser", + "Qwen3EngineToolParser", ), "seed_oss": ( - "seed_oss_tool_parser", - "SeedOssToolParser", + "seed_oss_engine_tool_parser", + "SeedOssEngineToolParser", ), "step3": ( "step3_tool_parser", @@ -187,8 +187,8 @@ "FunctionGemmaToolParser", ), "gemma4": ( - "gemma4_tool_parser", - "Gemma4ToolParser", + "gemma4_engine_tool_parser", + "Gemma4EngineToolParser", ), "apertus": ( "apertus_tool_parser", diff --git a/vllm/tool_parsers/abstract_tool_parser.py b/vllm/tool_parsers/abstract_tool_parser.py index 94543b82350b..acb96e28f624 100644 --- a/vllm/tool_parsers/abstract_tool_parser.py +++ b/vllm/tool_parsers/abstract_tool_parser.py @@ -6,6 +6,7 @@ import os from collections.abc import Callable, Sequence from functools import cached_property +from typing import Any from openai.types.responses import ( ResponseFormatTextJSONSchemaConfig, @@ -13,8 +14,8 @@ ) from openai.types.responses.function_tool import FunctionTool +import vllm.envs as envs from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionNamedToolChoiceParam, ChatCompletionRequest, ChatCompletionToolsParam, ) @@ -25,7 +26,6 @@ from vllm.entrypoints.openai.responses.protocol import ( ResponsesRequest, ) -from vllm.envs import VLLM_ENFORCE_STRICT_TOOL_CALLING from vllm.logger import init_logger from vllm.sampling_params import ( StructuredOutputsParams, @@ -57,6 +57,18 @@ class ToolParser: # extract_tool_calls / extract_tool_calls_streaming methods for # required/named tool_choice, treating them the same as "auto". supports_required_and_named: bool = True + # xgrammar builtin structural tag model key. Subclasses set this when + # their parsed tool-call syntax matches a builtin xgrammar format. + structural_tag_model: str | None = None + engine_based_streaming: bool = False + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + if ( + cls.structural_tag_model is not None + and envs.VLLM_ENFORCE_STRICT_TOOL_CALLING + ): + cls.supports_required_and_named = False def __init__( self, @@ -100,7 +112,7 @@ def get_remaining_unstreamed_args(self) -> str: @cached_property def vocab(self) -> dict[str, int]: - # NOTE: Only PreTrainedTokenizerFast is guaranteed to have .vocab + # NOTE: Only TokenizersBackend is guaranteed to have .vocab # whereas all tokenizers have .get_vocab() return self.model_tokenizer.get_vocab() @@ -112,32 +124,16 @@ def adjust_request( if not request.tools: return request - # Step 1 (highest priority for ChatCompletionRequest): apply - # vLLM-owned structural tag support for model-specific tool formats. + # Set structured output params when tool constraints are derived from + # the tool schema. Unified parsers handle model-specific structural + # tags before calling into the tool parser. + structured_outputs = getattr(request, "structured_outputs", None) if ( - isinstance(request, ChatCompletionRequest) - and VLLM_ENFORCE_STRICT_TOOL_CALLING + structured_outputs is not None + and structured_outputs.structural_tag is not None ): - need_tool_calling = ( - request.tool_choice == "auto" - or request.tool_choice == "required" - or isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam) - ) - if need_tool_calling: - structure_tag = self.get_structural_tag(request) - if structure_tag is not None: - if request.structured_outputs is None: - request.structured_outputs = StructuredOutputsParams( - structural_tag=json.dumps(structure_tag.model_dump()), - ) - else: - request.structured_outputs.structural_tag = json.dumps( - structure_tag.model_dump() - ) - return request - - # Step 2: set structured output params when tool constraints are - # derived from the tool schema. + return request + json_schema_from_tool = get_json_schema_from_tools( tool_choice=request.tool_choice, tools=request.tools ) @@ -169,8 +165,24 @@ def adjust_request( return request - def get_structural_tag(self, request: ChatCompletionRequest): - return None + def get_structural_tag( + self, + request: ChatCompletionRequest | ResponsesRequest, + *, + reasoning: bool = False, + ): + if self.structural_tag_model is None: + return None + if not envs.VLLM_ENFORCE_STRICT_TOOL_CALLING: + return None + from vllm.tool_parsers.structural_tag_registry import get_model_structural_tag + + return get_model_structural_tag( + model=self.structural_tag_model, + tools=request.tools, + tool_choice=request.tool_choice, + reasoning=reasoning, + ) def extract_tool_calls( self, model_output: str, request: ChatCompletionRequest diff --git a/vllm/tool_parsers/cohere_command_tool_parser.py b/vllm/tool_parsers/cohere_command_tool_parser.py index 0b252ce3177a..6ce753b993c5 100644 --- a/vllm/tool_parsers/cohere_command_tool_parser.py +++ b/vllm/tool_parsers/cohere_command_tool_parser.py @@ -41,6 +41,9 @@ def __init__( super().__init__(tokenizer) self.melody_streaming = PyFilter(streaming_opts) self.melody_unary = PyFilter(unary_opts) + # Melody can emit the tool-call id before the function name. Keep it + # until the first real name delta so clients receive both together. + self._pending_streaming_tool_call_ids: dict[int, str] = {} def adjust_request( self, request: ChatCompletionRequest | ResponsesRequest @@ -65,19 +68,37 @@ def extract_tool_calls_streaming( if r.reasoning is not None: return DeltaMessage(reasoning=r.reasoning) if r.tool_calls: - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - id=tc.id, - index=tc.index, - type="function", - function=DeltaFunctionCall( - name=tc.name, arguments=tc.arguments - ), + tool_calls: list[DeltaToolCall] = [] + for tc in r.tool_calls: + if tc.id: + self._pending_streaming_tool_call_ids[tc.index] = tc.id + name = tc.name or None + arguments = tc.arguments or None + # Empty strings are placeholders in Melody's streaming output; + # omit them from OpenAI-compatible deltas instead of sending + # invalid tool names or empty argument fragments. + if name is None and arguments is None: + continue + + function_kwargs = {} + if name is not None: + function_kwargs["name"] = name + if arguments is not None: + function_kwargs["arguments"] = arguments + tool_call_kwargs = { + "index": tc.index, + "function": DeltaFunctionCall(**function_kwargs), + } + if name is not None: + tool_call_id = tc.id or self._pending_streaming_tool_call_ids.pop( + tc.index, None ) - for tc in r.tool_calls - ] - ) + if tool_call_id is not None: + tool_call_kwargs["id"] = tool_call_id + tool_call_kwargs["type"] = "function" + tool_calls.append(DeltaToolCall(**tool_call_kwargs)) + if tool_calls: + return DeltaMessage(tool_calls=tool_calls) return None def extract_tool_calls( diff --git a/vllm/tool_parsers/deepseekv31_tool_parser.py b/vllm/tool_parsers/deepseekv31_tool_parser.py index e4ade3aae989..05d337874783 100644 --- a/vllm/tool_parsers/deepseekv31_tool_parser.py +++ b/vllm/tool_parsers/deepseekv31_tool_parser.py @@ -25,6 +25,8 @@ class DeepSeekV31ToolParser(ToolParser): + structural_tag_model = "deepseek_v3_1" + def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): super().__init__(tokenizer, tools) diff --git a/vllm/tool_parsers/deepseekv32_engine_tool_parser.py b/vllm/tool_parsers/deepseekv32_engine_tool_parser.py new file mode 100644 index 000000000000..4747fd10f3e2 --- /dev/null +++ b/vllm/tool_parsers/deepseekv32_engine_tool_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import DeepSeekV32ParserToolAdapter + + +class DeepSeekV32EngineToolParser(DeepSeekV32ParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "deepseek_v3_2" diff --git a/vllm/tool_parsers/deepseekv32_tool_parser.py b/vllm/tool_parsers/deepseekv32_tool_parser.py deleted file mode 100644 index 7d5e299be881..000000000000 --- a/vllm/tool_parsers/deepseekv32_tool_parser.py +++ /dev/null @@ -1,561 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import json -import uuid -from collections.abc import Sequence -from typing import Any, Literal - -import regex as re - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import ( - coerce_to_schema_type, - extract_types_from_schema, - find_tool_properties, - partial_tag_overlap, -) - -logger = init_logger(__name__) - - -class DeepSeekV32ToolParser(ToolParser): - """ - example tool call content: - <|DSML|function_calls> - <|DSML|invoke name="get_weather"> - <|DSML|parameter name="location" string="true">杭州 - <|DSML|parameter name="date" string="true">2024-01-16 - - <|DSML|invoke name="get_weather"> - <|DSML|parameter name="location" string="true">北京 - <|DSML|parameter name="date" string="true">2024-01-16 - - - """ - - tool_call_start_token: str = "<|DSML|function_calls>" - tool_call_end_token: str = "" - - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - self.prev_tool_call_arr: list[dict] = [] - - # Streaming state - self.current_tool_index: int = 0 - self._sent_content_idx: int = 0 - self._buffer: str = "" - self._in_tool_calls: bool = False - self._active_tool_index: int | None = None - self._active_tool_name: str | None = None - self._active_param_name: str | None = None - self._active_param_string_attr: str | None = None - self._active_param_mode: str | None = None - self._active_param_parts: list[str] = [] - self._args_started: list[bool] = [] - - # Regex patterns for complete parsing - self.tool_call_complete_regex = re.compile( - re.escape(self.tool_call_start_token) - + r"(.*?)" - + re.escape(self.tool_call_end_token), - re.DOTALL, - ) - self.invoke_complete_regex = re.compile( - r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)', re.DOTALL - ) - self.parameter_complete_regex = re.compile( - r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>(.*?)', - re.DOTALL, - ) - self.invoke_start_regex = re.compile(r'<|DSML|invoke\s+name="([^"]+)"\s*>') - self.parameter_start_regex = re.compile( - r'<|DSML|parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>' - ) - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) - - logger.debug( - "vLLM Successfully import tool parser %s !", self.__class__.__name__ - ) - - def adjust_request( - self, request: ChatCompletionRequest | ResponsesRequest - ) -> ChatCompletionRequest | ResponsesRequest: - request = super().adjust_request(request) - if request.tools and request.tool_choice != "none": - # Ensure tool call tokens - # (e.g. <|DSML|function_calls>, ) - # are not skippedduring decoding. - # Even though they are not marked as special tokens, - # setting skip_special_tokens=False ensures proper handling in - # transformers 5.x where decoding behavior may have changed. - request.skip_special_tokens = False - return request - - def _generate_tool_call_id(self) -> str: - """Generate a unique tool call ID.""" - return f"call_{uuid.uuid4().hex[:24]}" - - def _parse_invoke_params(self, invoke_str: str) -> dict[str, tuple[str, str]]: - param_dict: dict[str, tuple[str, str]] = {} - for param_name, string_attr, param_val in self.parameter_complete_regex.findall( - invoke_str - ): - param_dict[param_name] = (param_val, string_attr) - return param_dict - - @staticmethod - def _repair_param_dict( - param_dict: dict[str, Any], - param_config: dict[str, Any], - ) -> dict[str, Any]: - """Unwrap single 'arguments' / 'input' wrappers when the wrapper - is not part of the requested tool schema and the wrapped object - matches the schema fields.""" - allowed = set(param_config.keys()) - for wrapper in ("arguments", "input"): - if set(param_dict.keys()) != {wrapper} or wrapper in allowed: - continue - inner = param_dict[wrapper] - if isinstance(inner, str): - try: - inner = json.loads(inner) - except json.JSONDecodeError: - return param_dict - if isinstance(inner, dict) and set(inner.keys()).issubset(allowed): - return inner - return param_dict - - def _convert_params_with_schema( - self, - function_name: str, - param_dict: dict[str, tuple[str, str]], - ) -> dict[str, Any]: - """Convert raw string param values using the tool schema types.""" - param_config = find_tool_properties(self.tools, function_name) - - converted: dict[str, Any] = {} - for name, (value, string_attr) in param_dict.items(): - if string_attr == "true": - converted[name] = value - continue - - param_types = extract_types_from_schema(param_config.get(name, {})) - converted[name] = coerce_to_schema_type(value, param_types) - return self._repair_param_dict(converted, param_config) - - def _get_param_config(self, function_name: str | None) -> dict[str, Any]: - if not function_name or not self.tools: - return {} - return find_tool_properties(self.tools, function_name) - - @staticmethod - def _json_escape_string_content(text: str) -> str: - return json.dumps(text, ensure_ascii=False)[1:-1] - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - """Extract tool calls from complete model output (non-streaming).""" - # Quick check - if self.tool_call_start_token not in model_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - try: - tool_calls = [] - - # Find all complete tool_call blocks - for tool_call_match in self.tool_call_complete_regex.findall(model_output): - # Find all invokes within this tool_call - for invoke_name, invoke_content in self.invoke_complete_regex.findall( - tool_call_match - ): - param_dict = self._parse_invoke_params(invoke_content) - params = self._convert_params_with_schema(invoke_name, param_dict) - tool_calls.append( - ToolCall( - type="function", - function=FunctionCall( - name=invoke_name, - arguments=json.dumps(params, ensure_ascii=False), - ), - ) - ) - - if not tool_calls: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - # Extract content before first tool call - first_tool_idx = model_output.find(self.tool_call_start_token) - content = model_output[:first_tool_idx] if first_tool_idx > 0 else None - - return ExtractedToolCallInformation( - tools_called=True, tool_calls=tool_calls, content=content - ) - - except Exception: - logger.exception("Error extracting tool calls") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def _reset_streaming_state(self): - """Reset all streaming state.""" - self.current_tool_index = 0 - self._sent_content_idx = 0 - self._buffer = "" - self._in_tool_calls = False - self._active_tool_index = None - self._active_tool_name = None - self._active_param_name = None - self._active_param_string_attr = None - self._active_param_mode = None - self._active_param_parts.clear() - self.prev_tool_call_arr.clear() - self.streamed_args_for_tool.clear() - self._args_started.clear() - - def _add_tool_call_delta( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - *, - call_id: str | None = None, - call_type: Literal["function"] | None = None, - name: str | None = None, - arguments: str | None = None, - ) -> None: - if arguments: - self.streamed_args_for_tool[index] += arguments - - if index not in tool_call_deltas: - tool_call_deltas[index] = DeltaToolCall( - index=index, - id=call_id, - type=call_type, - function=DeltaFunctionCall(name=name, arguments=arguments), - ) - return - - delta = tool_call_deltas[index] - if call_id is not None: - delta.id = call_id - if call_type is not None: - delta.type = call_type - if delta.function is None: - delta.function = DeltaFunctionCall() - if name is not None: - delta.function.name = name - if arguments is not None: - delta.function.arguments = (delta.function.arguments or "") + arguments - - def _begin_streaming_tool_call( - self, - name: str, - tool_call_deltas: dict[int, DeltaToolCall], - ) -> None: - index = self.current_tool_index - self.current_tool_index += 1 - self._active_tool_index = index - self._active_tool_name = name - self.prev_tool_call_arr.append({"name": name, "arguments": {}}) - self.streamed_args_for_tool.append("") - self._args_started.append(False) - self._add_tool_call_delta( - tool_call_deltas, - index, - call_id=self._generate_tool_call_id(), - call_type="function", - name=name, - arguments="", - ) - - def _append_param_prefix( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - key: str, - *, - as_string: bool, - ) -> None: - prefix = "{" if not self._args_started[index] else "," - self._args_started[index] = True - arguments = prefix + json.dumps(key, ensure_ascii=False) + ":" - if as_string: - arguments += '"' - self._add_tool_call_delta(tool_call_deltas, index, arguments=arguments) - - def _append_json_param_value( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - key: str, - value: Any, - ) -> None: - self._append_param_prefix(tool_call_deltas, index, key, as_string=False) - self._add_tool_call_delta( - tool_call_deltas, - index, - arguments=json.dumps(value, ensure_ascii=False), - ) - - def _param_types_for_name(self, name: str) -> list[str]: - param_config = self._get_param_config(self._active_tool_name) - if name in param_config and isinstance(param_config[name], dict): - return extract_types_from_schema(param_config[name]) - return ["string"] - - @staticmethod - def _can_stream_raw_param(param_types: list[str]) -> bool: - # Scalars and unions need the complete value so streaming and - # non-streaming share the same coercion fallback behavior. - return set(param_types).issubset({"object", "array"}) - - def _should_buffer_wrapper_param(self, name: str) -> bool: - if ( - self._active_tool_index is None - or self._args_started[self._active_tool_index] - ): - return False - param_config = self._get_param_config(self._active_tool_name) - return bool( - param_config and name in ("arguments", "input") and name not in param_config - ) - - def _finish_buffered_param( - self, - tool_call_deltas: dict[int, DeltaToolCall], - index: int, - ) -> None: - assert self._active_param_name is not None - assert self._active_param_string_attr is not None - raw_value = "".join(self._active_param_parts) - converted = self._convert_params_with_schema( - self._active_tool_name or "", - {self._active_param_name: (raw_value, self._active_param_string_attr)}, - ) - for key, value in converted.items(): - self._append_json_param_value(tool_call_deltas, index, key, value) - - def _close_streaming_tool_call( - self, - tool_call_deltas: dict[int, DeltaToolCall], - ) -> None: - index = self._active_tool_index - if index is None: - return - - suffix = "}" if self._args_started[index] else "{}" - self._add_tool_call_delta(tool_call_deltas, index, arguments=suffix) - try: - self.prev_tool_call_arr[index] = { - "name": self._active_tool_name, - "arguments": json.loads(self.streamed_args_for_tool[index]), - } - except (json.JSONDecodeError, IndexError): - logger.exception("Failed to finalize DeepSeek DSML streaming tool call") - - self._active_tool_index = None - self._active_tool_name = None - self._active_param_name = None - self._active_param_string_attr = None - self._active_param_mode = None - self._active_param_parts.clear() - - def _process_streaming_buffer( - self, - content_parts: list[str], - tool_call_deltas: dict[int, DeltaToolCall], - ) -> None: - parameter_end_token = "" - invoke_end_token = "" - - while True: - if not self._in_tool_calls: - start_idx = self._buffer.find(self.tool_call_start_token) - if start_idx == -1: - overlap = partial_tag_overlap( - self._buffer, self.tool_call_start_token - ) - sendable_idx = len(self._buffer) - overlap - if sendable_idx > 0: - content_parts.append(self._buffer[:sendable_idx]) - self._buffer = self._buffer[sendable_idx:] - return - - if start_idx > 0: - content_parts.append(self._buffer[:start_idx]) - self._buffer = self._buffer[start_idx:] - continue - - self._buffer = self._buffer[len(self.tool_call_start_token) :] - self._in_tool_calls = True - continue - - if self._active_tool_index is None: - stripped_len = len(self._buffer) - len(self._buffer.lstrip()) - if stripped_len: - self._buffer = self._buffer[stripped_len:] - continue - - if self._buffer.startswith(self.tool_call_end_token): - self._buffer = self._buffer[len(self.tool_call_end_token) :] - self._in_tool_calls = False - continue - - match = self.invoke_start_regex.match(self._buffer) - if match is None: - return - - self._buffer = self._buffer[match.end() :] - self._begin_streaming_tool_call(match.group(1), tool_call_deltas) - continue - - index = self._active_tool_index - - if self._active_param_mode is not None: - end_pos = self._buffer.find(parameter_end_token) - if end_pos != -1: - raw_content = self._buffer[:end_pos] - self._buffer = self._buffer[end_pos + len(parameter_end_token) :] - if self._active_param_mode in ("wrapper", "buffered"): - self._active_param_parts.append(raw_content) - self._finish_buffered_param(tool_call_deltas, index) - elif self._active_param_mode == "string": - arguments = self._json_escape_string_content(raw_content) + '"' - self._add_tool_call_delta( - tool_call_deltas, index, arguments=arguments - ) - else: - self._add_tool_call_delta( - tool_call_deltas, index, arguments=raw_content - ) - - self._active_param_name = None - self._active_param_string_attr = None - self._active_param_mode = None - self._active_param_parts.clear() - continue - - overlap = partial_tag_overlap(self._buffer, parameter_end_token) - safe_len = len(self._buffer) - overlap - if safe_len > 0: - raw_content = self._buffer[:safe_len] - self._buffer = self._buffer[safe_len:] - if self._active_param_mode in ("wrapper", "buffered"): - self._active_param_parts.append(raw_content) - elif self._active_param_mode == "string": - self._add_tool_call_delta( - tool_call_deltas, - index, - arguments=self._json_escape_string_content(raw_content), - ) - else: - self._add_tool_call_delta( - tool_call_deltas, index, arguments=raw_content - ) - return - - stripped_len = len(self._buffer) - len(self._buffer.lstrip()) - if stripped_len: - self._buffer = self._buffer[stripped_len:] - continue - - if self._buffer.startswith(invoke_end_token): - self._buffer = self._buffer[len(invoke_end_token) :] - self._close_streaming_tool_call(tool_call_deltas) - continue - - match = self.parameter_start_regex.match(self._buffer) - if match is None: - return - - self._buffer = self._buffer[match.end() :] - name = match.group(1) - string_attr = match.group(2) - self._active_param_name = name - self._active_param_string_attr = string_attr - - if self._should_buffer_wrapper_param(name): - self._active_param_mode = "wrapper" - continue - - if string_attr == "true": - self._append_param_prefix(tool_call_deltas, index, name, as_string=True) - self._active_param_mode = "string" - continue - - param_types = self._param_types_for_name(name) - if not self._can_stream_raw_param(param_types): - self._active_param_mode = "buffered" - continue - - self._append_param_prefix(tool_call_deltas, index, name, as_string=False) - self._active_param_mode = "raw" - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], # pylint: disable=unused-argument - current_token_ids: Sequence[int], # pylint: disable=unused-argument - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - """Extract tool calls from streaming model output. - - Buffers DSML markup while streaming tool-call metadata and argument - JSON fragments as soon as they are complete enough to be valid deltas. - """ - - # First chunk of a new stream — reset state from prior request. - if not previous_text: - self._reset_streaming_state() - - self._buffer += delta_text - content_parts: list[str] = [] - tool_call_deltas: dict[int, DeltaToolCall] = {} - self._process_streaming_buffer(content_parts, tool_call_deltas) - - if content_parts or tool_call_deltas: - content = "".join(content_parts) or None - return DeltaMessage( - content=content, tool_calls=list(tool_call_deltas.values()) - ) - - # Empty delta with token ids means EOS or closing tag; return - # non-None so the serving framework can finalize finish_reason. - if not delta_text and delta_token_ids and self.prev_tool_call_arr: - return DeltaMessage(content="") - - return None diff --git a/vllm/tool_parsers/deepseekv3_tool_parser.py b/vllm/tool_parsers/deepseekv3_tool_parser.py index e92af87e604d..7eaa983df7ea 100644 --- a/vllm/tool_parsers/deepseekv3_tool_parser.py +++ b/vllm/tool_parsers/deepseekv3_tool_parser.py @@ -28,6 +28,8 @@ class DeepSeekV3ToolParser(ToolParser): + structural_tag_model = "deepseek_r1" + def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): super().__init__(tokenizer, tools) diff --git a/vllm/tool_parsers/deepseekv4_engine_tool_parser.py b/vllm/tool_parsers/deepseekv4_engine_tool_parser.py new file mode 100644 index 000000000000..7e3ebf269197 --- /dev/null +++ b/vllm/tool_parsers/deepseekv4_engine_tool_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import DeepSeekV4ParserToolAdapter + + +class DeepSeekV4EngineToolParser(DeepSeekV4ParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "deepseek_v4" diff --git a/vllm/tool_parsers/deepseekv4_tool_parser.py b/vllm/tool_parsers/deepseekv4_tool_parser.py deleted file mode 100644 index e32451cd8bbd..000000000000 --- a/vllm/tool_parsers/deepseekv4_tool_parser.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.tool_parsers.deepseekv32_tool_parser import DeepSeekV32ToolParser -from vllm.tool_parsers.structural_tag_registry import ( - get_enable_structured_outputs_in_reasoning, - get_model_structural_tag, -) - - -class DeepSeekV4ToolParser(DeepSeekV32ToolParser): - """ - DeepSeek V4 DSML tool parser. - - V4 keeps the V3.2 DSML invoke/parameter grammar, but wraps tool calls in - ``<|DSML|tool_calls>`` instead of ``<|DSML|function_calls>``. - """ - - tool_call_start_token: str = "<|DSML|tool_calls>" - tool_call_end_token: str = "" - - def get_structural_tag(self, request: ChatCompletionRequest): - return get_model_structural_tag( - model="deepseek_v4", - tools=request.tools, - tool_choice=request.tool_choice, - reasoning=get_enable_structured_outputs_in_reasoning(), - ) diff --git a/vllm/tool_parsers/gemma4_engine_tool_parser.py b/vllm/tool_parsers/gemma4_engine_tool_parser.py new file mode 100644 index 000000000000..04c03ecaa20a --- /dev/null +++ b/vllm/tool_parsers/gemma4_engine_tool_parser.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from openai.types.responses import ToolChoiceFunction + +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionNamedToolChoiceParam, + ChatCompletionRequest, +) +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.parser.engine.registered_adapters import Gemma4ParserToolAdapter + + +class Gemma4EngineToolParser(Gemma4ParserToolAdapter): # type: ignore[valid-type, misc] + supports_required_and_named = False + + def adjust_request( + self, request: ChatCompletionRequest | ResponsesRequest + ) -> ChatCompletionRequest | ResponsesRequest: + """Skip structured-output JSON for required/named tool choice. + + Gemma4 emits its native ``<|tool_call>call:...`` syntax, which the + parser extracts directly. The base ``ToolParser.adjust_request`` would + set ``structured_outputs`` for required/named and force JSON via guided + decoding, conflicting with that native syntax (it leaks as content and + crashes EngineCore under speculative decoding). Skip it so the model + emits its native format (mirrors the GLM4 parser). + """ + if request.tools: + tc = request.tool_choice + if tc == "required" or isinstance( + tc, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction) + ): + request.skip_special_tokens = False + return request + return super().adjust_request(request) diff --git a/vllm/tool_parsers/gemma4_tool_parser.py b/vllm/tool_parsers/gemma4_tool_parser.py deleted file mode 100644 index 9925284273f9..000000000000 --- a/vllm/tool_parsers/gemma4_tool_parser.py +++ /dev/null @@ -1,790 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -Tool call parser for Google Gemma4 models. - -Gemma4 uses a custom serialization format (not JSON) for tool calls:: - - <|tool_call>call:func_name{key:<|"|>value<|"|>,num:42} - -Strings are delimited by ``<|"|>`` (token 52), keys are unquoted, and -multiple tool calls are concatenated without separators. - -Used when ``--enable-auto-tool-choice --tool-call-parser gemma4`` are set. - -For offline inference tool call parsing (direct ``tokenizer.decode()`` output), -see ``vllm.tool_parsers.gemma4_utils.parse_tool_calls``. -""" - -import json -from collections.abc import Sequence - -import regex as re - -from vllm.entrypoints.chat_utils import make_tool_call_id -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.entrypoints.openai.responses.protocol import ( - ResponsesRequest, -) -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser -from vllm.tool_parsers.utils import find_common_prefix - -logger = init_logger(__name__) - -# Gemma4 special tokens for tool calls -TOOL_CALL_START = "<|tool_call>" -TOOL_CALL_END = "" -STRING_DELIM = '<|"|>' - - -# --------------------------------------------------------------------------- -# Gemma4 argument parser (used by both streaming and non-streaming paths) -# --------------------------------------------------------------------------- - - -def _parse_gemma4_value(value_str: str) -> object: - """Parse a single Gemma4 value (after key:) into a Python object.""" - value_str = value_str.strip() - if not value_str: - return value_str - - # Boolean - if value_str == "true": - return True - if value_str == "false": - return False - - # Null - if value_str.lower() in ("null", "none", "nil"): - return None - - # Number (int or float) - try: - if "." in value_str: - return float(value_str) - return int(value_str) - except ValueError: - pass - - # Bare string (no <|"|> delimiters — shouldn't happen but be safe) - return value_str - - -def _parse_gemma4_args(args_str: str, *, partial: bool = False) -> dict: - """Parse Gemma4's custom key:value format into a Python dict. - - Format examples:: - - location:<|"|>Tokyo<|"|> - location:<|"|>San Francisco<|"|>,unit:<|"|>celsius<|"|> - count:42,flag:true - nested:{inner_key:<|"|>val<|"|>} - items:[<|"|>a<|"|>,<|"|>b<|"|>] - - Args: - args_str: The raw Gemma4 argument string. - partial: When True (streaming), bare values at end of string are - omitted because they may be incomplete and type-unstable - (e.g. partial boolean parsed as bare string). - - Returns a dict ready for ``json.dumps()``. - """ - if not args_str or not args_str.strip(): - return {} - - result: dict = {} - i = 0 - n = len(args_str) - - while i < n: - # Skip whitespace and commas - while i < n and args_str[i] in (" ", ",", "\n", "\t"): - i += 1 - if i >= n: - break - - # Parse key (unquoted, ends at ':') - key_start = i - while i < n and args_str[i] != ":": - i += 1 - if i >= n: - break - key = args_str[key_start:i].strip() - i += 1 # skip ':' - - # Parse value - if i >= n: - if not partial: - result[key] = "" - break - - # Skip whitespace after ':' - while i < n and args_str[i] in (" ", "\n", "\t"): - i += 1 - if i >= n: - if not partial: - result[key] = "" - break - - # String value: <|"|>...<|"|> - if args_str[i:].startswith(STRING_DELIM): - i += len(STRING_DELIM) - val_start = i - end_pos = args_str.find(STRING_DELIM, i) - if end_pos == -1: - # Unterminated string — take rest - result[key] = args_str[val_start:] - break - result[key] = args_str[val_start:end_pos] - i = end_pos + len(STRING_DELIM) - - # Nested object: {...} - elif args_str[i] == "{": - depth = 1 - obj_start = i + 1 - i += 1 - while i < n and depth > 0: - if args_str[i:].startswith(STRING_DELIM): - # Skip over string contents to avoid counting { inside strings - i += len(STRING_DELIM) - next_delim = args_str.find(STRING_DELIM, i) - i = n if next_delim == -1 else next_delim + len(STRING_DELIM) - continue - if args_str[i] == "{": - depth += 1 - elif args_str[i] == "}": - depth -= 1 - i += 1 - if depth > 0: - # Incomplete nested object — use i (not i-1) to avoid - # dropping the last char, and recurse as partial. - result[key] = _parse_gemma4_args(args_str[obj_start:i], partial=True) - else: - result[key] = _parse_gemma4_args(args_str[obj_start : i - 1]) - - # Array: [...] - elif args_str[i] == "[": - depth = 1 - arr_start = i + 1 - i += 1 - while i < n and depth > 0: - if args_str[i:].startswith(STRING_DELIM): - i += len(STRING_DELIM) - next_delim = args_str.find(STRING_DELIM, i) - i = n if next_delim == -1 else next_delim + len(STRING_DELIM) - continue - if args_str[i] == "[": - depth += 1 - elif args_str[i] == "]": - depth -= 1 - i += 1 - if depth > 0: - result[key] = _parse_gemma4_array(args_str[arr_start:i], partial=True) - else: - result[key] = _parse_gemma4_array(args_str[arr_start : i - 1]) - - # Bare value (number, boolean, etc.) - else: - val_start = i - while i < n and args_str[i] not in (",", "}", "]"): - i += 1 - if partial and i >= n: - # Value may be incomplete (e.g. partial boolean) — - # withhold to avoid type instability during streaming. - break - if i == val_start: - logger.warning( - "Gemma4 args parser made no progress at position %d; " - "aborting on malformed input.", - i, - ) - break - if partial: - raw_val = args_str[val_start:i].strip() - if raw_val.endswith("."): - # Trailing dot means decimal digits may still arrive - # (e.g. "108." may become "108.2"). Parsing now would - # yield float("108.") == 108.0, whose json repr "108.0" - # corrupts the streaming diff when the true digit lands. - break - result[key] = _parse_gemma4_value(args_str[val_start:i]) - - return result - - -def _parse_gemma4_array(arr_str: str, *, partial: bool = False) -> list: - """Parse a Gemma4 array content string into a Python list.""" - items: list = [] - i = 0 - n = len(arr_str) - - while i < n: - while i < n and arr_str[i] in (" ", ",", "\n", "\t"): - i += 1 - if i >= n: - break - - # String element - if arr_str[i:].startswith(STRING_DELIM): - i += len(STRING_DELIM) - end_pos = arr_str.find(STRING_DELIM, i) - if end_pos == -1: - items.append(arr_str[i:]) - break - items.append(arr_str[i:end_pos]) - i = end_pos + len(STRING_DELIM) - - # Nested object - elif arr_str[i] == "{": - depth = 1 - obj_start = i + 1 - i += 1 - while i < n and depth > 0: - if arr_str[i:].startswith(STRING_DELIM): - i += len(STRING_DELIM) - nd = arr_str.find(STRING_DELIM, i) - i = nd + len(STRING_DELIM) if nd != -1 else n - continue - if arr_str[i] == "{": - depth += 1 - elif arr_str[i] == "}": - depth -= 1 - i += 1 - if depth > 0: - items.append(_parse_gemma4_args(arr_str[obj_start:i], partial=True)) - else: - items.append(_parse_gemma4_args(arr_str[obj_start : i - 1])) - - # Nested array - elif arr_str[i] == "[": - depth = 1 - sub_start = i + 1 - i += 1 - while i < n and depth > 0: - if arr_str[i:].startswith(STRING_DELIM): - i += len(STRING_DELIM) - nd = arr_str.find(STRING_DELIM, i) - i = nd + len(STRING_DELIM) if nd != -1 else n - continue - if arr_str[i] == "[": - depth += 1 - elif arr_str[i] == "]": - depth -= 1 - i += 1 - if depth > 0: - items.append(_parse_gemma4_array(arr_str[sub_start:i], partial=True)) - else: - items.append(_parse_gemma4_array(arr_str[sub_start : i - 1])) - - # Bare value - else: - val_start = i - while i < n and arr_str[i] not in (",", "]"): - i += 1 - if partial and i >= n: - break - if i == val_start: - logger.warning( - "Gemma4 array parser made no progress at position %d; " - "aborting on malformed input.", - i, - ) - break - if partial: - raw_val = arr_str[val_start:i].strip() - if raw_val.endswith("."): - break - items.append(_parse_gemma4_value(arr_str[val_start:i])) - - return items - - -# --------------------------------------------------------------------------- -# Parser -# --------------------------------------------------------------------------- - - -class Gemma4ToolParser(ToolParser): - """ - Tool call parser for Google Gemma4 models. - - Handles the Gemma4 function call format:: - - <|tool_call>call:func_name{key:<|"|>value<|"|>} - - Used when ``--enable-auto-tool-choice --tool-call-parser gemma4`` - are set. - - Streaming strategy: **accumulate-then-parse-then-diff** - - Instead of trying to convert Gemma4's custom format to JSON - token-by-token (which fails because Gemma4 uses bare keys, custom - delimiters, and structural braces that differ from JSON), this parser: - - 1. Accumulates the raw Gemma4 argument string during streaming - 2. Parses it with ``_parse_gemma4_args()`` into a Python dict - 3. Converts to JSON with ``json.dumps()`` - 4. Diffs against the previously-streamed JSON string - 5. Emits only the new JSON fragment as the delta - - This follows the same pattern used by FunctionGemma, Hermes, and Llama - tool parsers. - """ - - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) - - # Token strings - self.tool_call_start_token = TOOL_CALL_START - self.tool_call_end_token = TOOL_CALL_END - - # Token IDs - self.tool_call_start_token_id = self.vocab.get(TOOL_CALL_START) - self.tool_call_end_token_id = self.vocab.get(TOOL_CALL_END) - - if self.tool_call_start_token_id is None: - raise RuntimeError( - "Gemma4 ToolParser could not locate the tool call start " - f"token '{TOOL_CALL_START}' in the tokenizer!" - ) - - # Regex for non-streaming: extract complete tool calls. - # Supports function names with letters, digits, underscores, - # hyphens, and dots (e.g. "get-weather", "module.func"). - self.tool_call_regex = re.compile( - r"<\|tool_call>call:([\w\-\.]+)\{(.*?)\}", - re.DOTALL, - ) - - # Streaming state — reset per-request via _reset_streaming_state() - self._reset_streaming_state() - - # Delta buffer for handling multi-token special sequences - self.buffered_delta_text = "" - - def _reset_streaming_state(self) -> None: - """Reset all streaming state for a new request.""" - self.current_tool_id = -1 - self.current_tool_name_sent = False - self.prev_tool_call_arr: list[dict] = [] - self.streamed_args_for_tool: list[str] = [] - - def adjust_request( - self, request: ChatCompletionRequest | ResponsesRequest - ) -> ChatCompletionRequest | ResponsesRequest: - request = super().adjust_request(request) - if request.tools and request.tool_choice != "none": - # Don't skip special tokens — <|tool_call> etc. are needed for - # the parser to detect tool calls. Apply to BOTH - # ChatCompletionRequest and ResponsesRequest (the previous - # isinstance(ChatCompletionRequest) guard caused tool-call - # delimiters to be stripped on /v1/responses, leaking raw - # `call:fn{...}` text via output_text.delta). - request.skip_special_tokens = False - return request - - # ------------------------------------------------------------------ - # Delta buffering for multi-token special sequences - # ------------------------------------------------------------------ - - def _buffer_delta_text(self, delta_text: str) -> str: - """Buffer incoming delta text to handle multi-token special sequences. - - Accumulates partial tokens that could be the start of - ``<|tool_call>`` or ```` and only flushes them - when the complete sequence is recognized or the sequence breaks. - - This prevents partial special tokens (e.g., ``<|tool``) from being - emitted prematurely as content text. - """ - combined = self.buffered_delta_text + delta_text - - # Check if combined ends with a complete special token - if combined.endswith(TOOL_CALL_START) or combined.endswith(TOOL_CALL_END): - self.buffered_delta_text = "" - return combined - - # Check if combined ends with a partial prefix of a special token - for tag in [TOOL_CALL_START, TOOL_CALL_END]: - for i in range(1, len(tag)): - if combined.endswith(tag[:i]): - self.buffered_delta_text = combined[-i:] - return combined[:-i] - - # No partial match — flush everything - self.buffered_delta_text = "" - return combined - - # ------------------------------------------------------------------ - # Non-streaming extraction - # ------------------------------------------------------------------ - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - if self.tool_call_start_token not in model_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - try: - matches = self.tool_call_regex.findall(model_output) - if not matches: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - tool_calls: list[ToolCall] = [] - for func_name, args_str in matches: - arguments = _parse_gemma4_args(args_str) - tool_calls.append( - ToolCall( - type="function", - function=FunctionCall( - name=func_name, - arguments=json.dumps(arguments, ensure_ascii=False), - ), - ) - ) - - # Content = text before first tool call (if any) - content_end = model_output.find(self.tool_call_start_token) - content = model_output[:content_end].strip() if content_end > 0 else None - - return ExtractedToolCallInformation( - tools_called=True, - tool_calls=tool_calls, - content=content if content else None, - ) - - except Exception: - logger.exception("Error extracting tool calls from Gemma4 response") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - # ------------------------------------------------------------------ - # Streaming extraction — accumulate-then-parse-then-diff - # ------------------------------------------------------------------ - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - # Buffer delta text to handle multi-token special sequences - delta_text = self._buffer_delta_text(delta_text) - # Keep current_text from the upstream stream state. The buffered delta - # is only for emission, and must not be stitched back into the - # accumulated model text or normal content like "
" can be - # duplicated into "<
" when a tool call just ended. - - # If no tool call token seen yet, emit as content - if self.tool_call_start_token not in current_text: - if delta_text: - return DeltaMessage(content=delta_text) - return None - - try: - return self._extract_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - ) - except Exception: - logger.exception("Error in Gemma4 streaming tool call extraction") - return None - - def _extract_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - ) -> DeltaMessage | None: - """Tag-counting streaming parser. - - Uses the proven approach from FunctionGemma/Hermes: count start/end - tags in previous vs current text to determine phase, then - accumulate-parse-diff for arguments. - - Format: ``<|tool_call>call:name{args}`` - """ - start_count = current_text.count(self.tool_call_start_token) - end_count = current_text.count(self.tool_call_end_token) - prev_start_count = previous_text.count(self.tool_call_start_token) - prev_end_count = previous_text.count(self.tool_call_end_token) - - # Case 1: Not inside any tool call — emit as content - if ( - start_count == end_count - and prev_end_count == end_count - and self.tool_call_end_token not in delta_text - ): - if delta_text: - return DeltaMessage(content=delta_text) - return None - - # Case 2: Starting a new tool call - if start_count > prev_start_count and start_count > end_count: - self.current_tool_id += 1 - self.current_tool_name_sent = False - self.streamed_args_for_tool.append("") - self.prev_tool_call_arr.append({}) - logger.debug("Starting new tool call %d", self.current_tool_id) - # Don't return yet — fall through to try parsing if there's - # content after <|tool_call> in this same delta - # (but usually it's just the token itself, so return None) - if len(delta_text) <= len(self.tool_call_start_token): - return None - - # Case 3: Tool call just ended - if end_count > prev_end_count: - return self._handle_tool_call_end(current_text) - - # Case 4: In the middle of a tool call — parse partial content - if start_count > end_count: - return self._handle_tool_call_middle(current_text) - - # Default: generate text outside tool calls - if delta_text: - text = delta_text.replace(self.tool_call_start_token, "") - text = text.replace(self.tool_call_end_token, "") - if text: - return DeltaMessage(content=text) - return None - - def _extract_partial_call(self, current_text: str) -> tuple[str | None, str]: - """Extract function name and raw argument string from partial text. - - Returns (func_name, raw_args_str) or (None, "") if not parseable yet. - """ - # Get the text after the last <|tool_call> token - last_start = current_text.rfind(self.tool_call_start_token) - if last_start == -1: - return None, "" - - partial_call = current_text[last_start + len(self.tool_call_start_token) :] - - # Strip end token if present - if self.tool_call_end_token in partial_call: - partial_call = partial_call.split(self.tool_call_end_token)[0] - - # Expect "call:name{args...}" or "call:name{args...}" - if not partial_call.startswith("call:"): - return None, "" - - func_part = partial_call[5:] # skip "call:" - - if "{" not in func_part: - # Still accumulating function name, not ready yet - return None, "" - - func_name, _, args_part = func_part.partition("{") - func_name = func_name.strip() - - # Strip trailing '}' if present (Gemma4 structural brace) - if args_part.endswith("}"): - args_part = args_part[:-1] - - return func_name, args_part - - def _handle_tool_call_middle(self, current_text: str) -> DeltaMessage | None: - """Handle streaming when we're inside an active tool call. - - Accumulates the raw Gemma4 arguments, parses them into JSON, and - diffs against the previously-streamed JSON to emit only the new - fragment. - """ - func_name, args_part = self._extract_partial_call(current_text) - - if func_name is None: - return None - - # Step 1: Send function name (once) - if not self.current_tool_name_sent and func_name: - self.current_tool_name_sent = True - self.prev_tool_call_arr[self.current_tool_id] = { - "name": func_name, - "arguments": {}, - } - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_id, - type="function", - id=make_tool_call_id(), - function=DeltaFunctionCall( - name=func_name, - arguments="", - ).model_dump(exclude_none=True), - ) - ] - ) - - # Step 2: Parse and diff arguments - if self.current_tool_name_sent and args_part: - return self._emit_argument_diff(args_part) - - return None - - def _handle_tool_call_end(self, current_text: str) -> DeltaMessage | None: - """Handle streaming when a tool call has just completed. - - Performs a final parse of the complete tool call and flushes - any remaining un-streamed argument fragments. - """ - if self.current_tool_id < 0 or self.current_tool_id >= len( - self.prev_tool_call_arr - ): - logger.debug( - "Tool call end detected but no active tool call (current_tool_id=%d)", - self.current_tool_id, - ) - return None - - # Parse the complete tool call using regex for accuracy - all_matches = self.tool_call_regex.findall(current_text) - if self.current_tool_id < len(all_matches): - _, args_str = all_matches[self.current_tool_id] - final_args = _parse_gemma4_args(args_str) - final_args_json = json.dumps(final_args, ensure_ascii=False) - - prev_streamed = self.streamed_args_for_tool[self.current_tool_id] - if len(final_args_json) > len(prev_streamed): - diff = final_args_json[len(prev_streamed) :] - self.streamed_args_for_tool[self.current_tool_id] = final_args_json - self.prev_tool_call_arr[self.current_tool_id]["arguments"] = final_args - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_id, - function=DeltaFunctionCall(arguments=diff).model_dump( - exclude_none=True - ), - ) - ] - ) - - return None - - def _emit_argument_diff(self, raw_args_str: str) -> DeltaMessage | None: - """Parse raw Gemma4 arguments, convert to JSON, diff, and emit. - - This is the core of the accumulate-then-parse-then-diff strategy: - 1. Parse ``raw_args_str`` with ``_parse_gemma4_args()`` - 2. Convert to JSON string with ``json.dumps()`` - 3. Withhold trailing closing characters (``"}``) that may move - as more tokens arrive - 4. Diff against previously streamed JSON and emit only new chars - - **Why withholding is necessary:** - - Gemma4's custom format produces *structurally incomplete* JSON - during streaming. For example, when ``<|"|>Paris`` arrives - without a closing delimiter, ``_parse_gemma4_args`` treats it - as a complete value and produces ``{"location": "Paris"}``. But - when ``, France<|"|>`` arrives next, the JSON becomes - ``{"location": "Paris, France"}``. If we had sent the closing - ``"}`` from the first parse, the concatenated client output - would be ``{"location": "Paris"}France"}``, which is garbage. - - The solution: **never send trailing closing chars during - streaming**. They get flushed by ``_handle_tool_call_end()`` - when the ```` end marker arrives. - - Args: - raw_args_str: The raw Gemma4 argument text accumulated so far - (without the surrounding ``{`` ``}``). - - Returns: - DeltaMessage with the argument diff, or None if no new content. - """ - try: - current_args = _parse_gemma4_args(raw_args_str, partial=True) - except Exception: - logger.debug( - "Could not parse partial Gemma4 args yet: %s", - raw_args_str[:100], - ) - return None - - if not current_args: - return None - - current_args_json = json.dumps(current_args, ensure_ascii=False) - - # Withhold trailing closing characters that may shift as more - # tokens arrive. Strip trailing '}', '"', ']' and partial - # STRING_DELIM fragments ('<', '|', '\\', '>') to get the - # "safe prefix". - safe_json = current_args_json - while safe_json and safe_json[-1] in ("}", '"', "]", "<", "|", "\\", ">"): - safe_json = safe_json[:-1] - - prev_streamed = self.streamed_args_for_tool[self.current_tool_id] - - if not safe_json or safe_json == prev_streamed: - return None - - # Use find_common_prefix to handle cases where the value changed - # structurally (e.g., a string grew). - if prev_streamed: - prefix = find_common_prefix(prev_streamed, safe_json) - sent_len = len(prev_streamed) - prefix_len = len(prefix) - - if prefix_len < sent_len: - # Structure changed — we sent too much. Truncate our - # tracking to the common prefix and wait for the final - # flush in _handle_tool_call_end. - self.streamed_args_for_tool[self.current_tool_id] = prefix - return None - - # Stream the new stable portion - diff = safe_json[sent_len:] - else: - # First emission - diff = safe_json - - if diff: - self.streamed_args_for_tool[self.current_tool_id] = safe_json - self.prev_tool_call_arr[self.current_tool_id]["arguments"] = current_args - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_id, - function=DeltaFunctionCall(arguments=diff).model_dump( - exclude_none=True - ), - ) - ] - ) - - return None diff --git a/vllm/tool_parsers/gemma4_utils.py b/vllm/tool_parsers/gemma4_utils.py index 439ad1125ce2..a72e16ea56f4 100644 --- a/vllm/tool_parsers/gemma4_utils.py +++ b/vllm/tool_parsers/gemma4_utils.py @@ -35,8 +35,6 @@ do not need a transformers dependency for output parsing. """ -import json - import regex as re # Tool call delimiter tokens as they appear in decoded text. @@ -52,42 +50,23 @@ def _parse_tool_arguments(args_str: str) -> dict[str, str]: """Parse tool call arguments from the Gemma4 compact format. - Handles the ``key:<|"|>value<|"|>`` format used by Gemma4, with fallback - to heuristic key-value extraction. Also tolerates the slightly different - ``key: "value"`` format (space + plain quotes) that some chat templates - produce. + Delegates to the native ``<|"|>``-aware parser from + ``vllm.parser.gemma4``, which handles internal quotes, nested + objects, arrays, and all Gemma4 value types correctly. Args: args_str: Raw argument string from inside ``call:name{...}``. Returns: - Dictionary of argument name → value. + Dictionary of argument name → string value. """ if not args_str or not args_str.strip(): return {} - # Replace Gemma4 escape tokens with standard quotes. - cleaned = args_str.replace(_ESCAPE_TOKEN, '"') - - # Try JSON parsing first (handles nested values, arrays, etc.). - try: - parsed = json.loads("{" + cleaned + "}") - # Ensure all values are strings for consistency. - return {k: str(v) if not isinstance(v, str) else v for k, v in parsed.items()} - except (json.JSONDecodeError, ValueError): - pass - - # Fallback: extract key:"value" pairs (allow optional space after colon). - arguments = {} - for key, value in re.findall(r'(\w+):\s*"([^"]*)"', cleaned): - arguments[key] = value - - if not arguments: - # Last resort: extract key:value pairs (unquoted). - for key, value in re.findall(r"(\w+):\s*([^,}]+)", args_str): - arguments[key] = value.strip().strip('"').replace(_ESCAPE_TOKEN, "") + from vllm.parser.gemma4 import _parse_gemma4_args - return arguments + parsed = _parse_gemma4_args(args_str) + return {k: str(v) if not isinstance(v, str) else v for k, v in parsed.items()} def parse_tool_calls(text: str, *, strict: bool = False) -> list[dict]: diff --git a/vllm/tool_parsers/glm47_moe_tool_parser.py b/vllm/tool_parsers/glm47_moe_tool_parser.py index 47b6ad2f5afe..70275a6ac03d 100644 --- a/vllm/tool_parsers/glm47_moe_tool_parser.py +++ b/vllm/tool_parsers/glm47_moe_tool_parser.py @@ -1,40 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -GLM-4.7 Tool Call Parser. -GLM-4.7 uses a slightly different tool call format compared to GLM-4.5: - - The function name may appear on the same line as ```` without - a newline separator before the first ````. - - Tool calls may have zero arguments - (e.g. ``func``). +from __future__ import annotations -This parser overrides the parent regex patterns to handle both formats. -""" +from vllm.parser.engine.registered_adapters import Glm47MoeParserToolAdapter -import regex as re -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import Tool -from vllm.tool_parsers.glm4_moe_tool_parser import Glm4MoeModelToolParser - -logger = init_logger(__name__) - - -class Glm47MoeModelToolParser(Glm4MoeModelToolParser): +class Glm47MoeModelToolParser(Glm47MoeParserToolAdapter): # type: ignore[valid-type, misc] supports_required_and_named = False - - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - # GLM-4.7 format: func_name[...]* - # The function name can be followed by a newline, whitespace, or - # directly by tags (no separator). The arg section is - # optional so that zero-argument calls are supported. - self.func_detail_regex = re.compile( - r"\s*(\S+?)\s*(.*)?", re.DOTALL - ) - self.func_arg_regex = re.compile( - r"(.*?)\s*(.*?)", - re.DOTALL, - ) + structural_tag_model = "glm_4_7" diff --git a/vllm/tool_parsers/glm4_moe_tool_parser.py b/vllm/tool_parsers/glm4_moe_tool_parser.py deleted file mode 100644 index 213a774535bc..000000000000 --- a/vllm/tool_parsers/glm4_moe_tool_parser.py +++ /dev/null @@ -1,495 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -GLM-4 Tool Call Parser with incremental string streaming support. - -This parser fixes the streaming issue reported in Issue #32829 where long string -parameters (e.g., file content with 4000+ characters of code) are buffered until -complete, causing multi-second delays before the user sees any content. - -The fix streams string values incrementally as they arrive, providing a true -streaming experience for long content. -""" - -import json -from collections.abc import Sequence -from typing import Any - -import regex as re - -from vllm.entrypoints.chat_utils import make_tool_call_id -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionNamedToolChoiceParam, - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import ( - extract_types_from_schema, - find_tool_properties, - partial_tag_overlap, - safe_literal_eval, -) - -logger = init_logger(__name__) - - -class Glm4MoeModelToolParser(ToolParser): - """Tool parser for GLM-4 models with incremental string streaming. - - On every streaming call the parser re-parses ``current_text`` to find - ```` regions, builds the JSON arguments string for each tool - call, and diffs against what was previously sent to emit only new content. - """ - - supports_required_and_named = False - - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - # Stateful streaming fields - self.current_tool_name_sent: bool = False - self.prev_tool_call_arr: list[dict[str, Any]] = [] - self.current_tool_id: int = -1 - self.streamed_args_for_tool: list[str] = [] - - self.tool_call_start_token: str = "" - self.tool_call_end_token: str = "" - self.arg_key_start: str = "" - self.arg_key_end: str = "" - self.arg_val_start: str = "" - self.arg_val_end: str = "" - - self.tool_calls_start_token = self.tool_call_start_token - - self.func_call_regex = re.compile(r".*?", re.DOTALL) - self.func_detail_regex = re.compile( - r"([^\n]*)\n(.*)", re.DOTALL - ) - self.func_arg_regex = re.compile( - r"(.*?)\s*(.*?)", re.DOTALL - ) - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) - - self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token) - self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token) - - # Pre-compiled pattern for finding the last ... - # before a partial (used in _build_args_json_so_far). - self._arg_key_pattern = re.compile( - re.escape(self.arg_key_start) + r"(.*?)" + re.escape(self.arg_key_end), - re.DOTALL, - ) - - # Streaming state for re-parse-and-diff approach - self._sent_content_idx: int = 0 - self._tool_call_ids: list[str] = [] - - @staticmethod - def _deserialize(value: str) -> Any: - try: - return json.loads(value) - except json.JSONDecodeError: - pass - - try: - return safe_literal_eval(value) - except (ValueError, SyntaxError): - pass - - return value - - @staticmethod - def _json_escape_string_content(s: str) -> str: - """JSON-escape string content for incremental streaming. - - This escapes the content that goes INSIDE a JSON string (between quotes), - not including the surrounding quotes themselves. - """ - if not s: - return "" - return json.dumps(s, ensure_ascii=False)[1:-1] - - def _is_string_type(self, tool_name: str, arg_name: str) -> bool: - tool_properties = find_tool_properties(self.tools, tool_name) - param_schema = tool_properties.get(arg_name) - if param_schema is None: - return False - param_types = extract_types_from_schema(param_schema) - return set(param_types) - {"null"} == {"string"} - - @staticmethod - def _tools_enabled(request: ChatCompletionRequest) -> bool: - """Return whether tool parsing should be applied for this request.""" - try: - tools = getattr(request, "tools", None) - tool_choice = getattr(request, "tool_choice", None) - return bool(tools) and tool_choice != "none" - except Exception: - logger.exception("Failed to determine if tools are enabled.") - return False - - def adjust_request( - self, request: ChatCompletionRequest | ResponsesRequest - ) -> ChatCompletionRequest | ResponsesRequest: - """Adjust request parameters for tool call token handling. - - For required/named tool_choice, skip setting structured_outputs - because GLM models output tool calls in XML format (per chat - template). Guided decoding would force JSON output, conflicting - with the XML format and causing parsing failures. - """ - if request.tools: - tc = request.tool_choice - if tc == "required" or isinstance(tc, ChatCompletionNamedToolChoiceParam): - # Do NOT call super().adjust_request() for required/named, - # because it would set structured_outputs and force JSON - # output via guided decoding. GLM models use XML tool-call - # syntax (defined in the chat template), so guided decoding - # must be skipped to let the model output XML freely. - # The tool_parser handles extraction from XML output. - if request.tool_choice != "none": - request.skip_special_tokens = False - return request - request = super().adjust_request(request) - if request.tools and request.tool_choice != "none": - # Ensure tool call tokens (, ) are not skipped - # during decoding. Even though they are not marked as special tokens, - # setting skip_special_tokens=False ensures proper handling in - # transformers 5.x where decoding behavior may have changed. - request.skip_special_tokens = False - return request - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - matched_tool_calls = self.func_call_regex.findall(model_output) - logger.debug("model_output: %s", model_output) - try: - tool_calls: list[ToolCall] = [] - for match in matched_tool_calls: - tc_detail = self.func_detail_regex.search(match) - if not tc_detail: - logger.warning( - "Failed to parse tool call details from: %s", - match, - ) - continue - tc_name = tc_detail.group(1).strip() - tc_args = tc_detail.group(2) - pairs = self.func_arg_regex.findall(tc_args) if tc_args else [] - arg_dct: dict[str, Any] = {} - for key, value in pairs: - arg_key = key.strip() - if self._is_string_type(tc_name, arg_key): - arg_val = value - else: - arg_val = self._deserialize(value.strip()) - logger.debug("arg_key = %s, arg_val = %s", arg_key, arg_val) - arg_dct[arg_key] = arg_val - tool_calls.append( - ToolCall( - type="function", - function=FunctionCall( - name=tc_name, - arguments=json.dumps(arg_dct, ensure_ascii=False), - ), - ) - ) - except Exception: - logger.exception("Failed to extract tool call spec") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - else: - if len(tool_calls) > 0: - content: str | None = model_output[ - : model_output.find(self.tool_calls_start_token) - ] - # Normalize empty/whitespace-only content to None - if not content or not content.strip(): - content = None - return ExtractedToolCallInformation( - tools_called=True, tool_calls=tool_calls, content=content - ) - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def _extract_content(self, current_text: str) -> str | None: - """Return unsent non-tool-call text, or None. - - Collects all text outside ``...`` regions, - including text between consecutive tool calls. Holds back any - suffix that could be a partial ```` tag. - """ - # Build the "sendable index" — the furthest point we can send - # content up to. We scan through the text collecting segments - # that are outside tool-call regions. - content_segments: list[str] = [] - pos = self._sent_content_idx - - while pos < len(current_text): - start = current_text.find(self.tool_call_start_token, pos) - if start == -1: - # No more tool calls — send up to (len - partial-tag overlap) - tail = current_text[pos:] - overlap = partial_tag_overlap(tail, self.tool_call_start_token) - sendable = tail[: len(tail) - overlap] if overlap else tail - if sendable: - content_segments.append(sendable) - pos = len(current_text) - overlap - break - - # Text before this - if start > pos: - content_segments.append(current_text[pos:start]) - - # Skip past the (or to end if incomplete) - end = current_text.find(self.tool_call_end_token, start) - if end != -1: - pos = end + len(self.tool_call_end_token) - else: - # Incomplete tool call — nothing more to send - pos = start - break - - if content_segments: - self._sent_content_idx = pos - return "".join(content_segments) - # Even if no content, advance past completed tool-call regions - if pos > self._sent_content_idx: - self._sent_content_idx = pos - return None - - def _extract_tool_call_regions(self, text: str) -> list[tuple[str, bool]]: - """Extract ``(inner_text, is_complete)`` for each ```` region.""" - results: list[tuple[str, bool]] = [] - pos = 0 - while True: - start = text.find(self.tool_call_start_token, pos) - if start == -1: - break - inner_start = start + len(self.tool_call_start_token) - end = text.find(self.tool_call_end_token, inner_start) - if end != -1: - results.append((text[inner_start:end], True)) - pos = end + len(self.tool_call_end_token) - else: - # Incomplete tool call — strip partial suffix - raw = text[inner_start:] - overlap = partial_tag_overlap(raw, self.tool_call_end_token) - if overlap: - raw = raw[:-overlap] - results.append((raw, False)) - break - return results - - def _extract_tool_name_from_region(self, inner_text: str) -> str | None: - """Extract the tool name from the beginning of a tool-call region. - - The name is everything before the first ``\\n`` or ````. - Returns ``None`` if the name hasn't fully arrived yet. - """ - nl = inner_text.find("\n") - ak = inner_text.find(self.arg_key_start) - candidates = [i for i in [nl, ak] if i != -1] - if not candidates: - return None - cut = min(candidates) - name = inner_text[:cut].strip() - return name if name else None - - def _build_args_json_so_far( - self, - tool_name: str, - inner_text: str, - is_complete: bool, - ) -> str: - """Build the JSON arguments string from the XML pairs seen so far. - - For complete ``/`` pairs the value is fully - formatted. For the last argument whose ```` has been - opened but not closed, the partial string content is included - (JSON-escaped, with an opening ``"`` but no closing ``"``). - - The closing ``}`` is only appended when ``is_complete`` is True - (i.e. the ```` tag has arrived). - """ - # Find all complete arg pairs - pairs = self.func_arg_regex.findall(inner_text) - - parts: list[str] = [] - for key, value in pairs: - key = key.strip() - key_json = json.dumps(key, ensure_ascii=False) - if self._is_string_type(tool_name, key): - # Don't strip string values — whitespace is significant - # and must match the partial-value path for diffing. - val_json = json.dumps(value, ensure_ascii=False) - else: - val_json = json.dumps( - self._deserialize(value.strip()), ensure_ascii=False - ) - parts.append(f"{key_json}: {val_json}") - - # Check for a partial (incomplete) arg value - # Find the last that isn't closed - last_val_start = inner_text.rfind(self.arg_val_start) - last_val_end = inner_text.rfind(self.arg_val_end) - has_partial_value = last_val_start != -1 and ( - last_val_end == -1 or last_val_end < last_val_start - ) - - if has_partial_value: - # Find the key for this partial value - # Look for the last ... before this - last_key_match = None - for m in self._arg_key_pattern.finditer(inner_text[:last_val_start]): - last_key_match = m - - if last_key_match: - partial_key = last_key_match.group(1).strip() - partial_content_start = last_val_start + len(self.arg_val_start) - partial_content = inner_text[partial_content_start:] - - # Hold back any partial suffix - overlap = partial_tag_overlap(partial_content, self.arg_val_end) - if overlap: - partial_content = partial_content[:-overlap] - - key_json = json.dumps(partial_key, ensure_ascii=False) - if is_complete: - # Tool call finished but is missing - # (malformed output). Treat partial as complete value - # so the diff naturally closes any open quotes. - if self._is_string_type(tool_name, partial_key): - val_json = json.dumps(partial_content, ensure_ascii=False) - else: - val_json = json.dumps( - self._deserialize(partial_content.strip()), - ensure_ascii=False, - ) - parts.append(f"{key_json}: {val_json}") - elif self._is_string_type(tool_name, partial_key): - escaped = self._json_escape_string_content(partial_content) - # Open quote but no close — more content may arrive - parts.append(f'{key_json}: "{escaped}') - else: - # Non-string partial: include raw content, no wrapping - parts.append(f"{key_json}: {partial_content}") - - if not parts: - return "{}" if is_complete else "" - - joined = "{" + ", ".join(parts) - if is_complete: - joined += "}" - return joined - - def _compute_args_diff(self, index: int, args_so_far: str) -> str | None: - """Return new argument text not yet sent for tool *index*, or None.""" - if not args_so_far or len(args_so_far) <= len( - self.streamed_args_for_tool[index] - ): - return None - diff = args_so_far[len(self.streamed_args_for_tool[index]) :] - self.streamed_args_for_tool[index] = args_so_far - self.prev_tool_call_arr[index]["arguments"] = args_so_far - return diff - - def _ensure_tool_state_for(self, index: int) -> None: - """Grow state arrays so that *index* is valid.""" - while len(self._tool_call_ids) <= index: - self._tool_call_ids.append( - make_tool_call_id(id_type="random", func_name=None, idx=None) - ) - while len(self.streamed_args_for_tool) <= index: - self.streamed_args_for_tool.append("") - while len(self.prev_tool_call_arr) <= index: - self.prev_tool_call_arr.append({}) - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - if not self._tools_enabled(request): - return DeltaMessage(content=delta_text) if delta_text else None - - content = self._extract_content(current_text) - regions = self._extract_tool_call_regions(current_text) - tool_call_deltas: list[DeltaToolCall] = [] - - for i, (inner_text, is_complete) in enumerate(regions): - self._ensure_tool_state_for(i) - - # Extract tool name - tool_name = self._extract_tool_name_from_region(inner_text) - if not tool_name: - break - - # Emit tool name (once per tool call) - if "name" not in self.prev_tool_call_arr[i]: - self.prev_tool_call_arr[i]["name"] = tool_name - tool_call_deltas.append( - DeltaToolCall( - index=i, - id=self._tool_call_ids[i], - type="function", - function=DeltaFunctionCall( - name=tool_name, - arguments="", - ).model_dump(exclude_none=True), - ) - ) - - # Build args JSON so far, diff, emit - args_so_far = self._build_args_json_so_far( - tool_name, inner_text, is_complete - ) - diff = self._compute_args_diff(i, args_so_far) - if diff: - tool_call_deltas.append( - DeltaToolCall( - index=i, - function=DeltaFunctionCall(arguments=diff).model_dump( - exclude_none=True - ), - ) - ) - - # Update current_tool_id for serving layer compatibility - if regions: - self.current_tool_id = len(regions) - 1 - - if content or tool_call_deltas: - return DeltaMessage( - content=content, - tool_calls=tool_call_deltas, - ) - return None diff --git a/vllm/tool_parsers/gptoss_tool_parser.py b/vllm/tool_parsers/gptoss_tool_parser.py new file mode 100644 index 000000000000..6857e6bbe728 --- /dev/null +++ b/vllm/tool_parsers/gptoss_tool_parser.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from vllm.entrypoints.openai.engine.protocol import ( + DeltaMessage, + ExtractedToolCallInformation, +) +from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser + +if TYPE_CHECKING: + from vllm.tokenizers import TokenizerLike + + +class GptOssToolParser(ToolParser): + """ + Stub tool parser for gpt-oss/harmony models. + + All output parsing is handled by HarmonyParser. This stub exists as a + capability declaration via HarmonyParser.tool_parser_cls. + """ + + def __init__(self, tokenizer: "TokenizerLike", tools: list[Tool] | None = None): + super().__init__(tokenizer, tools) + + def extract_tool_calls( + self, model_output, request, **kwargs + ) -> ExtractedToolCallInformation: + raise NotImplementedError( + "GptOssToolParser is a stub. Use HarmonyParser for tool parsing." + ) + + def extract_tool_calls_streaming( + self, + previous_text: str, + current_text: str, + delta_text: str, + previous_token_ids: Sequence[int], + current_token_ids: Sequence[int], + delta_token_ids: Sequence[int], + request, + ) -> DeltaMessage | None: + raise NotImplementedError( + "GptOssToolParser is a stub. Use HarmonyParser for tool parsing." + ) diff --git a/vllm/tool_parsers/granite_tool_parser.py b/vllm/tool_parsers/granite_tool_parser.py index d586db326707..174e2884277f 100644 --- a/vllm/tool_parsers/granite_tool_parser.py +++ b/vllm/tool_parsers/granite_tool_parser.py @@ -154,9 +154,11 @@ def extract_tool_calls_streaming( current_tool_call: dict = tool_call_arr[self.current_tool_id] delta = None - # case: we are starting a new tool in the array - # -> array has > 0 length AND length has moved past cursor - if len(tool_call_arr) > self.current_tool_id + 1: + # Only advance once the current tool name is streamed; granite + # emits arguments before name, so advancing early would drop it. + if len(tool_call_arr) > self.current_tool_id + 1 and ( + self.current_tool_id < 0 or self.current_tool_name_sent + ): # if we're moving on to a new call, first make sure we # haven't missed anything in the previous one that was # auto-generated due to JSON completions, but wasn't @@ -184,7 +186,7 @@ def extract_tool_calls_streaming( ) # re-set stuff pertaining to progress in the current tool - self.current_tool_id = len(tool_call_arr) - 1 + self.current_tool_id += 1 self.current_tool_name_sent = False self.streamed_args_for_tool.append("") logger.debug("starting on new tool %d", self.current_tool_id) diff --git a/vllm/tool_parsers/hermes_tool_parser.py b/vllm/tool_parsers/hermes_tool_parser.py index 546cde5cd14c..3fd819297aab 100644 --- a/vllm/tool_parsers/hermes_tool_parser.py +++ b/vllm/tool_parsers/hermes_tool_parser.py @@ -32,6 +32,7 @@ class Hermes2ProToolParser(ToolParser): + structural_tag_model = "hermes" tool_call_start_token: str = "" tool_call_end_token: str = "" tool_call_regex = re.compile( diff --git a/vllm/tool_parsers/hunyuan_a13b_tool_parser.py b/vllm/tool_parsers/hunyuan_a13b_tool_parser.py index 9723ef45d24e..f5cd9f85a0d8 100644 --- a/vllm/tool_parsers/hunyuan_a13b_tool_parser.py +++ b/vllm/tool_parsers/hunyuan_a13b_tool_parser.py @@ -144,7 +144,7 @@ def extract_tool_calls( function=FunctionCall( name=call["name"], arguments=( - json.dumps(call["arguments"]) + json.dumps(call["arguments"], ensure_ascii=False) if isinstance(call["arguments"], dict) else call["arguments"] ), diff --git a/vllm/tool_parsers/hy_v3_tool_parser.py b/vllm/tool_parsers/hy_v3_tool_parser.py index 619be5e9cc29..0ffaf92d86cb 100644 --- a/vllm/tool_parsers/hy_v3_tool_parser.py +++ b/vllm/tool_parsers/hy_v3_tool_parser.py @@ -108,6 +108,14 @@ def _get_schema_options(arg_schema: dict) -> list[dict]: Note: single ``type`` has the highest priority. """ if "type" in arg_schema: + type_val = arg_schema["type"] + # JSON Schema allows "type" to be an array to represent union types, + # e.g. "type": ["string", "object"]. + # Expand it into an anyOf-equivalent format: + # [{"type": "string"}, {"type": "object"}] + # so that _get_types / _parse_value can handle it uniformly later. + if isinstance(type_val, list): + return [{"type": t} for t in type_val] return [arg_schema] if "anyOf" in arg_schema: return arg_schema["anyOf"] @@ -261,19 +269,22 @@ def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): self._current_arg_is_string: bool = False # is current arg pure string? self._streamed_json_len: int = 0 # bytes of JSON already sent - self.tool_calls_start_token: str = "" - self.tool_calls_end_token: str = "" + init_kwargs = getattr(tokenizer, "init_kwargs", None) or {} + self.suffix: str = init_kwargs.get("token_suffix") or "" - self.tool_call_start_token: str = "" - self.tool_call_end_token: str = "" + self.tool_calls_start_token: str = f"" + self.tool_calls_end_token: str = f"" - self.tool_sep_token: str = "" + self.tool_call_start_token: str = f"" + self.tool_call_end_token: str = f"" - self.arg_key_start_token: str = "" - self.arg_key_end_token: str = "" + self.tool_sep_token: str = f"" - self.arg_value_start_token: str = "" - self.arg_value_end_token: str = "" + self.arg_key_start_token: str = f"" + self.arg_key_end_token: str = f"" + + self.arg_value_start_token: str = f"" + self.arg_value_end_token: str = f"" self.tool_call_regex = re.compile( rf"{self.tool_call_start_token}(.*?){self.tool_sep_token}" diff --git a/vllm/tool_parsers/kimi_k2_tool_parser.py b/vllm/tool_parsers/kimi_k2_tool_parser.py index 7ddd8fa7a80d..d1cc48301833 100644 --- a/vllm/tool_parsers/kimi_k2_tool_parser.py +++ b/vllm/tool_parsers/kimi_k2_tool_parser.py @@ -1,276 +1,20 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from collections.abc import Sequence - -import regex as re - from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) from vllm.entrypoints.openai.responses.protocol import ResponsesRequest -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import partial_tag_overlap - -logger = init_logger(__name__) - - -class KimiK2ToolParser(ToolParser): - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - # Streaming state - self._sent_content_idx: int = 0 - self.prev_tool_call_arr: list[dict] = [] - self.streamed_args_for_tool: list[str] = [] - - # Section marker - self.tool_calls_start_token: str = "<|tool_calls_section_begin|>" - - # Individual tool call markers - self.tool_call_start_token: str = "<|tool_call_begin|>" - self.tool_call_end_token: str = "<|tool_call_end|>" - self.tool_call_arg_token: str = "<|tool_call_argument_begin|>" +from vllm.parser.engine.registered_adapters import KimiK2ParserToolAdapter - # Regex for non-streaming extraction - self.tool_call_regex = re.compile( - r"<\|tool_call_begin\|>\s*(?P[^<]+:\d+)\s*" - r"<\|tool_call_argument_begin\|>\s*" - r"(?P(?:(?!<\|tool_call_begin\|>).)*?)\s*" - r"<\|tool_call_end\|>", - re.DOTALL, - ) - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) +class KimiK2ToolParser(KimiK2ParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "kimi" def adjust_request( - self, request: ChatCompletionRequest | ResponsesRequest + self, + request: ChatCompletionRequest | ResponsesRequest, ) -> ChatCompletionRequest | ResponsesRequest: - request = super().adjust_request(request) if request.tools and request.tool_choice != "none": - # Ensure special-token markers appear as literal text in - # current_text so we can do pure text-based parsing. request.skip_special_tokens = False return request - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - # sanity check; avoid unnecessary processing - if self.tool_calls_start_token not in model_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - else: - try: - # there are two possible captures - between tags, or between a - # tag and end-of-string so the result of - # findall is an array of tuples where one is a function call and - # the other is None - function_call_tuples = self.tool_call_regex.findall(model_output) - - logger.debug("function_call_tuples: %s", function_call_tuples) - - tool_calls = [] - for match in function_call_tuples: - function_id, function_args = match - # function_id: functions.get_weather:0 or get_weather:0 - function_name = function_id.split(":")[0].split(".")[-1] - tool_calls.append( - ToolCall( - id=function_id, - type="function", - function=FunctionCall( - name=function_name, arguments=function_args - ), - ) - ) - - content = model_output[: model_output.find(self.tool_calls_start_token)] - return ExtractedToolCallInformation( - tools_called=True, - tool_calls=tool_calls, - content=content if content else None, - ) - - except Exception: - logger.exception("Error in extracting tool call from response.") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def _extract_content(self, current_text: str) -> str | None: - """Return unsent content before the tool-calls section, or None. - - Holds back any trailing suffix that partially matches - ``<|tool_calls_section_begin|>`` to avoid leaking marker bytes. - """ - if self.tool_calls_start_token not in current_text: - overlap = partial_tag_overlap(current_text, self.tool_calls_start_token) - sendable_idx = len(current_text) - overlap - else: - sendable_idx = current_text.index(self.tool_calls_start_token) - - if sendable_idx > self._sent_content_idx: - content = current_text[self._sent_content_idx : sendable_idx] - self._sent_content_idx = sendable_idx - return content - return None - - def _extract_tool_calls(self, current_text: str) -> list[str]: - """Extract raw bodies from ``<|tool_call_begin|>…<|tool_call_end|>`` blocks.""" - if self.tool_calls_start_token not in current_text: - return [] - - results: list[str] = [] - pos = current_text.index(self.tool_calls_start_token) - while True: - start = current_text.find(self.tool_call_start_token, pos) - if start == -1: - break - tc_start = start + len(self.tool_call_start_token) - end = current_text.find(self.tool_call_end_token, tc_start) - - if end != -1: - tool_call = current_text[tc_start:end] - pos = end + len(self.tool_call_end_token) - else: - tool_call = current_text[tc_start:] - overlap = partial_tag_overlap(tool_call, self.tool_call_end_token) - if overlap: - tool_call = tool_call[:-overlap] - - results.append(tool_call) - - if end == -1: - break - return results - - @staticmethod - def _extract_tool_id_and_name( - header: str | None, - ) -> tuple[str | None, str | None]: - """Parse ``(tool_id, tool_name)`` from a header - like ``"functions.get_weather:0"``.""" - if header is None: - return None, None - match = re.match(r"(.+:\d+)", header) - if not match: - return None, None - - tool_id = match.group(1).strip() - tool_name = tool_id.split(":")[0].split(".")[-1] - return tool_id, tool_name - - def _split_tool_call(self, tool_call: str) -> tuple[str | None, str | None]: - """Split a tool-call body into ``(header, arguments)`` at the argument marker. - - Example:: - 'get_weather:0 <|tool_call_argument_begin|>{"c' - -> ("get_weather:0", '{"c') - """ - arg_pos = tool_call.find(self.tool_call_arg_token) - if arg_pos == -1: - return None, None - header = tool_call[:arg_pos].strip() - tool_args = tool_call[arg_pos + len(self.tool_call_arg_token) :] - return header, tool_args - - def _compute_args_diff(self, index: int, tool_args: str | None) -> str | None: - """Return new argument text not yet sent for tool `index`, or None.""" - if tool_args is None: - return None - prev = self.streamed_args_for_tool[index] - if len(tool_args) <= len(prev): - return None - diff = tool_args[len(prev) :] - self.streamed_args_for_tool[index] = tool_args - self.prev_tool_call_arr[index]["arguments"] = tool_args - return diff - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - try: - # Extract any content before tool calls. - content = self._extract_content(current_text) - tool_calls = self._extract_tool_calls(current_text) - tool_call_deltas: list[DeltaToolCall] = [] - - for i, tool_call in enumerate(tool_calls): - # First time seeing tool call at index i. - if i >= len(self.prev_tool_call_arr): - # Initialize streaming state. - self.prev_tool_call_arr.append({}) - self.streamed_args_for_tool.append("") - - header, tool_args = self._split_tool_call(tool_call) - - # Stream back tool name. - if "name" not in self.prev_tool_call_arr[i]: - tool_id, tool_name = self._extract_tool_id_and_name(header) - if not tool_name: - # Can't skip to tool i+1 if i isn't ready - break - self.prev_tool_call_arr[i]["name"] = tool_name - self.prev_tool_call_arr[i]["id"] = tool_id - tool_call_deltas.append( - DeltaToolCall( - index=i, - type="function", - id=tool_id, - function=DeltaFunctionCall(name=tool_name).model_dump( - exclude_none=True - ), - ) - ) - - # Stream back new tool args by diffing against what was sent. - args_diff = self._compute_args_diff(i, tool_args) - if args_diff: - tool_call_deltas.append( - DeltaToolCall( - index=i, - function=DeltaFunctionCall(arguments=args_diff).model_dump( - exclude_none=True - ), - ) - ) - - if content or tool_call_deltas: - return DeltaMessage( - content=content, - tool_calls=tool_call_deltas, - ) - return None - - except Exception: - logger.exception("Error trying to handle streaming tool call.") - return None diff --git a/vllm/tool_parsers/llama_tool_parser.py b/vllm/tool_parsers/llama_tool_parser.py index 4a041041f096..624428d992fe 100644 --- a/vllm/tool_parsers/llama_tool_parser.py +++ b/vllm/tool_parsers/llama_tool_parser.py @@ -46,6 +46,7 @@ class Llama3JsonToolParser(ToolParser): """ bot_token: str = "<|python_tag|>" + structural_tag_model = "llama" # Simple regex to find opening braces - we'll use JSON decoder for parsing # This handles arbitrary nesting depth correctly tool_call_start_regex: re.Pattern = re.compile(r"\{") diff --git a/vllm/tool_parsers/minimax_m2_tool_parser.py b/vllm/tool_parsers/minimax_m2_tool_parser.py index 5a3aae81262c..850732c555ee 100644 --- a/vllm/tool_parsers/minimax_m2_tool_parser.py +++ b/vllm/tool_parsers/minimax_m2_tool_parser.py @@ -1,282 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import json -import uuid -from collections.abc import Sequence +from vllm.parser.engine.registered_adapters import MinimaxM2ParserToolAdapter -import regex as re -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import ( - coerce_to_schema_type, - extract_types_from_schema, - find_tool_properties, -) - -logger = init_logger(__name__) - - -class MinimaxM2ToolParser(ToolParser): - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - self.prev_tool_call_arr: list[dict] = [] - - # Sentinel tokens - self.tool_call_start_token: str = "" - self.tool_call_end_token: str = "" - - # Streaming state - self.is_tool_call_started: bool = False - self.current_tool_index: int = 0 - - # Regex patterns for complete parsing - self.tool_call_complete_regex = re.compile( - r"(.*?)", re.DOTALL - ) - self.invoke_complete_regex = re.compile( - r"", re.DOTALL - ) - self.parameter_complete_regex = re.compile( - r"", re.DOTALL - ) - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) - - self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token) - self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token) - - if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None: - raise RuntimeError( - "MiniMax M2 Tool parser could not locate tool call start/end " - "tokens in the tokenizer!" - ) - - logger.debug( - "vLLM Successfully import tool parser %s !", self.__class__.__name__ - ) - - def _generate_tool_call_id(self) -> str: - """Generate a unique tool call ID.""" - return f"call_{uuid.uuid4().hex[:24]}" - - def _extract_name(self, name_str: str) -> str: - """Extract name from quoted string.""" - name_str = name_str.strip() - if (name_str.startswith('"') and name_str.endswith('"')) or ( - name_str.startswith("'") and name_str.endswith("'") - ): - return name_str[1:-1] - return name_str - - def _parse_single_invoke( - self, invoke_str: str, tools: list | None - ) -> ToolCall | None: - """Parse a single block.""" - # Extract function name - name_match = re.search(r"^([^>]+)", invoke_str) - if not name_match: - return None - - function_name = self._extract_name(name_match.group(1)) - tool_properties = find_tool_properties(tools, function_name) - - # Extract parameters - param_dict = {} - for match in self.parameter_complete_regex.findall(invoke_str): - param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL) - if param_match: - param_name = self._extract_name(param_match.group(1)) - param_value = param_match.group(2).strip() - param_types = extract_types_from_schema( - tool_properties.get(param_name, {}) - ) - param_dict[param_name] = coerce_to_schema_type(param_value, param_types) - - return ToolCall( - type="function", - function=FunctionCall( - name=function_name, - arguments=json.dumps(param_dict, ensure_ascii=False), - ), - ) - - def _extract_delta_tool_calls( - self, - current_text: str, - request: ChatCompletionRequest | None, - ) -> list[DeltaToolCall]: - """Extract DeltaToolCalls from newly completed blocks. - - Tracks progress via ``current_tool_index`` so each block is - extracted exactly once across successive streaming calls. - """ - complete_invokes = self.invoke_complete_regex.findall(current_text) - delta_tool_calls: list[DeltaToolCall] = [] - - while len(complete_invokes) > self.current_tool_index: - invoke_str = complete_invokes[self.current_tool_index] - tool_call = self._parse_single_invoke( - invoke_str, - self.tools, - ) - if not tool_call: - self.current_tool_index += 1 - continue - - args_json = tool_call.function.arguments - idx = self.current_tool_index - self.current_tool_index += 1 - - self.prev_tool_call_arr.append( - { - "name": tool_call.function.name, - "arguments": json.loads(args_json), - } - ) - self.streamed_args_for_tool.append(args_json) - delta_tool_calls.append( - DeltaToolCall( - index=idx, - id=self._generate_tool_call_id(), - function=DeltaFunctionCall( - name=tool_call.function.name, - arguments=args_json, - ), - type="function", - ) - ) - - return delta_tool_calls - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - """Extract tool calls from complete model output (non-streaming).""" - # Quick check - if self.tool_call_start_token not in model_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - try: - tool_calls = [] - - # Find all complete tool_call blocks - for tool_call_match in self.tool_call_complete_regex.findall(model_output): - # Find all invokes within this tool_call - for invoke_match in self.invoke_complete_regex.findall(tool_call_match): - tool_call = self._parse_single_invoke(invoke_match, self.tools) - if tool_call: - tool_calls.append(tool_call) - - if not tool_calls: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - # Update prev_tool_call_arr - self.prev_tool_call_arr.clear() - for tool_call in tool_calls: - self.prev_tool_call_arr.append( - { - "name": tool_call.function.name, - "arguments": tool_call.function.arguments, - } - ) - - # Extract content before first tool call - first_tool_idx = model_output.find(self.tool_call_start_token) - content = model_output[:first_tool_idx] if first_tool_idx > 0 else None - - return ExtractedToolCallInformation( - tools_called=True, tool_calls=tool_calls, content=content - ) - - except Exception: - logger.exception("Error extracting tool calls") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], # pylint: disable=unused-argument - current_token_ids: Sequence[int], # pylint: disable=unused-argument - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - """Extract tool calls from streaming model output. - - Uses a buffer-until-complete-invoke strategy: tokens are buffered - until a complete ``...`` block is available, then - parsed and emitted in one shot. - """ - - start_in_text = self.tool_call_start_token in delta_text - start_in_ids = self.tool_call_start_token_id in delta_token_ids - tool_call_starting = start_in_text or start_in_ids - # Reset state on new request (parser is reused) or new tool-call block. - if not previous_text or tool_call_starting: - self.current_tool_index = 0 - self.prev_tool_call_arr.clear() - self.streamed_args_for_tool.clear() - self.is_tool_call_started = tool_call_starting - - # Pass through content before any tool call. - if not self.is_tool_call_started: - return DeltaMessage(content=delta_text) if delta_text else None - - # Capture content before the start token. - content_before = None - if start_in_text: - before = delta_text[: delta_text.index(self.tool_call_start_token)] - content_before = before or None - - # Extract newly completed blocks as DeltaToolCalls. - delta_tool_calls = self._extract_delta_tool_calls(current_text, request) - - if delta_tool_calls or content_before: - return DeltaMessage( - content=content_before, - tool_calls=delta_tool_calls, - ) - - # EOS and both arrive as special tokens with - # no decoded text. Return non-None for EOS so the serving framework - # reaches the finish-reason handling path instead of skipping. - if ( - not delta_text - and delta_token_ids - and self.prev_tool_call_arr - and self.tool_call_end_token_id not in delta_token_ids - ): - return DeltaMessage(content="") - - return None +class MinimaxM2ToolParser(MinimaxM2ParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "minimax" diff --git a/vllm/tool_parsers/minimax_m3_tool_parser.py b/vllm/tool_parsers/minimax_m3_tool_parser.py new file mode 100644 index 000000000000..a8628448c448 --- /dev/null +++ b/vllm/tool_parsers/minimax_m3_tool_parser.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.tool_parsers.rust_tool_parser import RustToolParser + + +class MinimaxM3ToolParser(RustToolParser): + """Adapter from the Rust MiniMax M3 parser to vLLM ToolParser. + + The real M3 grammar lives in the Rust tool-parser crate. This class only + configures the generic Rust bridge with the MiniMax M3 parser name. + + M3 is not M2 with renamed tags: it prefixes each structural tag with the + MiniMax namespace marker, allows multiple ```` tags in one wrapper, + and represents nested arguments with parameter-name XML tags. + """ + + rust_parser_name = "MinimaxM3ToolParser" + tool_call_start_token = "]<]minimax[>[" diff --git a/vllm/tool_parsers/minimax_tool_parser.py b/vllm/tool_parsers/minimax_tool_parser.py deleted file mode 100644 index 2a2baa03b0e3..000000000000 --- a/vllm/tool_parsers/minimax_tool_parser.py +++ /dev/null @@ -1,852 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import json -from collections.abc import Sequence -from typing import Any - -import regex as re - -from vllm.entrypoints.chat_utils import make_tool_call_id -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import extract_intermediate_diff - -logger = init_logger(__name__) - - -class MinimaxToolParser(ToolParser): - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - # Initialize streaming state for tracking tool call progress - self.streaming_state: dict[str, Any] = { - "current_tool_index": -1, # Index of current tool being processed - "tool_ids": [], # List of tool call IDs - "sent_tools": [], # List of tools that have been sent - } - - # Define tool call tokens and patterns - self.tool_call_start_token = "" - self.tool_call_end_token = "" - self.tool_call_regex = re.compile( - r"(.*?)|(.*)", re.DOTALL - ) - self.thinking_tag_pattern = r"(.*?)" - self.tool_name_pattern = re.compile(r'"name":\s*"([^"]+)"') - self.tool_args_pattern = re.compile(r'"arguments":\s*') - - # Buffer for handling partial tool calls during streaming - self.pending_buffer = "" - self.in_thinking_tag = False - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) - - # Get token IDs for tool call start/end tokens - self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token) - self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token) - - if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None: - logger.warning( - "Minimax Tool parser could not locate tool call start/end " - "tokens in the tokenizer. Falling back to string matching." - ) - - def preprocess_model_output(self, model_output: str) -> str: - """ - Preprocess model output by removing tool calls from thinking tags. - - Args: - model_output: Raw model output string - - Returns: - Preprocessed model output with tool calls removed from thinking tags - """ - - def remove_tool_calls_from_think(match): - think_content = match.group(1) - cleaned_content = re.sub( - r".*?", "", think_content, flags=re.DOTALL - ) - return f"{cleaned_content}" - - return re.sub( - self.thinking_tag_pattern, - remove_tool_calls_from_think, - model_output, - flags=re.DOTALL, - ) - - def _clean_duplicate_braces(self, args_text: str) -> str: - """ - Clean duplicate closing braces from arguments text. - - Args: - args_text: Raw arguments text - - Returns: - Cleaned arguments text with proper JSON formatting - """ - args_text = args_text.strip() - if not args_text: - return args_text - - try: - json.loads(args_text) - return args_text - except json.JSONDecodeError: - pass - - while args_text.endswith("}}"): - candidate = args_text[:-1] - try: - json.loads(candidate) - return candidate - except json.JSONDecodeError: - args_text = candidate - - return args_text - - def _clean_delta_braces(self, delta_text: str) -> str: - """ - Clean delta text by removing excessive closing braces. - - Args: - delta_text: Delta text to clean - - Returns: - Cleaned delta text - """ - if not delta_text: - return delta_text - - delta_stripped = delta_text.strip() - - if delta_stripped and all(c in "}\n\r\t " for c in delta_stripped): - brace_count = delta_stripped.count("}") - if brace_count > 1: - return "}\n" if delta_text.endswith("\n") else "}" - - return delta_text - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - """ - Extract tool calls from model output for non-streaming mode. - - Args: - model_output: Complete model output - request: Chat completion request - - Returns: - ExtractedToolCallInformation containing tool calls and content - """ - processed_output = self.preprocess_model_output(model_output) - - if self.tool_call_start_token not in processed_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - try: - function_call_tuples = self.tool_call_regex.findall(processed_output) - - raw_function_calls = [] - for match in function_call_tuples: - tool_call_content = match[0] if match[0] else match[1] - if tool_call_content.strip(): - lines = tool_call_content.strip().split("\n") - for line in lines: - line = line.strip() - if line and line.startswith("{") and line.endswith("}"): - try: - parsed_call = json.loads(line) - raw_function_calls.append(parsed_call) - except json.JSONDecodeError: - continue - - tool_calls = [] - for function_call in raw_function_calls: - if "name" in function_call and "arguments" in function_call: - tool_calls.append( - ToolCall( - type="function", - function=FunctionCall( - name=function_call["name"], - arguments=json.dumps( - function_call["arguments"], ensure_ascii=False - ), - ), - ) - ) - - processed_pos = processed_output.find(self.tool_call_start_token) - if processed_pos != -1: - processed_content = processed_output[:processed_pos].strip() - - if processed_content: - lines = processed_content.split("\n") - for line in reversed(lines): - line = line.strip() - if line: - pos = model_output.find(line) - if pos != -1: - content = model_output[: pos + len(line)] - break - else: - content = "" - else: - content = "" - else: - content = model_output - - return ExtractedToolCallInformation( - tools_called=len(tool_calls) > 0, - tool_calls=tool_calls, - content=content.strip() if content.strip() else None, - ) - - except Exception: - logger.exception( - "An unexpected error occurred during tool call extraction." - ) - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def _update_thinking_state(self, text: str) -> None: - """ - Update the thinking tag state based on text content. - - Args: - text: Text to analyze for thinking tags - """ - open_count = text.count("") - close_count = text.count("") - self.in_thinking_tag = open_count > close_count or ( - open_count == close_count and text.endswith("") - ) - - def _is_potential_tag_start(self, text: str) -> bool: - """ - Check if text might be the start of a tool call tag. - - Args: - text: Text to check - - Returns: - True if text could be the start of a tool call tag - """ - for tag in [self.tool_call_start_token, self.tool_call_end_token]: - if any( - tag.startswith(text[-i:]) - for i in range(1, min(len(text) + 1, len(tag))) - ): - return True - return False - - def _should_buffer_content(self, delta_text: str) -> bool: - """ - Determine if content should be buffered for later processing. - - Args: - delta_text: Delta text to check - - Returns: - True if content should be buffered - """ - if self.in_thinking_tag: - return False - return bool( - self.pending_buffer - or self.tool_call_start_token in delta_text - or self.tool_call_end_token in delta_text - or delta_text.startswith("<") - ) - - def _split_content_for_buffering(self, delta_text: str) -> tuple[str, str]: - """ - Split delta text into safe content and potential tag content. - - Args: - delta_text: Delta text to split - - Returns: - Tuple of (safe_content, potential_tag_content) - """ - if self.in_thinking_tag: - return delta_text, "" - - for tag in [self.tool_call_start_token, self.tool_call_end_token]: - for i in range(1, len(tag)): - tag_prefix = tag[:i] - pos = delta_text.rfind(tag_prefix) - if pos != -1 and tag.startswith(delta_text[pos:]): - return delta_text[:pos], delta_text[pos:] - return delta_text, "" - - def _process_buffer(self, new_content: str) -> str: - """ - Process buffered content and return output content. - - Args: - new_content: New content to add to buffer - - Returns: - Processed output content - """ - self.pending_buffer += new_content - output_content = "" - - if self.in_thinking_tag: - output_content = self.pending_buffer - self.pending_buffer = "" - return output_content - - while self.pending_buffer: - start_pos = self.pending_buffer.find(self.tool_call_start_token) - end_pos = self.pending_buffer.find(self.tool_call_end_token) - - if start_pos != -1 and (end_pos == -1 or start_pos < end_pos): - tag_pos, tag_len = start_pos, len(self.tool_call_start_token) - elif end_pos != -1: - tag_pos, tag_len = end_pos, len(self.tool_call_end_token) - else: - if self._is_potential_tag_start(self.pending_buffer): - break - output_content += self.pending_buffer - self.pending_buffer = "" - break - - output_content += self.pending_buffer[:tag_pos] - self.pending_buffer = self.pending_buffer[tag_pos + tag_len :] - - return output_content - - def _reset_streaming_state(self) -> None: - """Reset the streaming state to initial values.""" - self.streaming_state = { - "current_tool_index": -1, - "tool_ids": [], - "sent_tools": [], - } - - def _advance_to_next_tool(self) -> None: - """Advance to the next tool in the streaming sequence.""" - self.streaming_state["current_tool_index"] = ( - int(self.streaming_state["current_tool_index"]) + 1 - ) - - def _set_current_tool_index(self, index: int) -> None: - """ - Set the current tool index. - - Args: - index: Tool index to set - """ - self.streaming_state["current_tool_index"] = index - - def _get_current_tool_index(self) -> int: - """ - Get the current tool index. - - Returns: - Current tool index - """ - return int(self.streaming_state["current_tool_index"]) - - def _get_next_unsent_tool_index(self, tool_count: int) -> int: - """ - Get the index of the next unsent tool. - - Args: - tool_count: Total number of tools - - Returns: - Index of next unsent tool, or -1 if all tools sent - """ - sent_tools = list(self.streaming_state["sent_tools"]) - for i in range(tool_count): - if i < len(sent_tools): - if not sent_tools[i]["sent_name"]: - return i - else: - return i - return -1 - - def _ensure_state_arrays(self, tool_count: int) -> None: - """ - Ensure state arrays have sufficient capacity for tool_count tools. - - Args: - tool_count: Number of tools to prepare for - """ - sent_tools = list(self.streaming_state["sent_tools"]) - tool_ids = list(self.streaming_state["tool_ids"]) - - while len(sent_tools) < tool_count: - sent_tools.append( - { - "sent_name": False, - "sent_arguments": "", - "id": make_tool_call_id(), - } - ) - - while len(tool_ids) < tool_count: - tool_ids.append(None) - - self.streaming_state["sent_tools"] = sent_tools - self.streaming_state["tool_ids"] = tool_ids - - def _detect_tools_in_text(self, text: str) -> int: - """ - Detect the number of tools in text by counting name patterns. - - Args: - text: Text to analyze - - Returns: - Number of tools detected - """ - matches = self.tool_name_pattern.findall(text) - return len(matches) - - def _find_tool_boundaries(self, text: str) -> list[tuple[int, int]]: - """ - Find the boundaries of tool calls in text. - - Args: - text: Text to analyze - - Returns: - List of (start, end) positions for tool calls - """ - boundaries = [] - i = 0 - while i < len(text): - if text[i] == "{": - start = i - depth = 0 - has_name = False - has_arguments = False - - while i < len(text): - if text[i] == "{": - depth += 1 - elif text[i] == "}": - depth -= 1 - if depth == 0: - end = i + 1 - segment = text[start:end] - if '"name"' in segment and '"arguments"' in segment: - boundaries.append((start, end)) - break - - if not has_name and '"name"' in text[start : i + 1]: - has_name = True - if not has_arguments and '"arguments"' in text[start : i + 1]: - has_arguments = True - - i += 1 - - if depth > 0 and has_name: - boundaries.append((start, i)) - else: - i += 1 - return boundaries - - def _extract_tool_args(self, tool_content: str, args_match: re.Match[str]) -> str: - """ - Extract tool arguments from tool content. - - Args: - tool_content: Tool call content - args_match: Regex match for arguments pattern - - Returns: - Extracted arguments as string - """ - args_start_pos = args_match.end() - remaining_content = tool_content[args_start_pos:] - - if remaining_content.strip().startswith("{"): - depth = 0 - for i, char in enumerate(remaining_content): - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return remaining_content[: i + 1] - else: - args_end = remaining_content.find("}") - if args_end > 0: - return remaining_content[:args_end].strip() - - return remaining_content.rstrip("}").strip() - - def _get_current_tool_content( - self, text: str, tool_index: int - ) -> tuple[str | None, str | None]: - """ - Get the content of a specific tool by index. - - Args: - text: Text containing tool calls - tool_index: Index of tool to extract - - Returns: - Tuple of (tool_name, tool_arguments) or (None, None) if not found - """ - boundaries = self._find_tool_boundaries(text) - - if tool_index >= len(boundaries): - return None, None - - start, end = boundaries[tool_index] - tool_content = text[start:end] - - name_match = self.tool_name_pattern.search(tool_content) - name = name_match.group(1) if name_match else None - - args_match = self.tool_args_pattern.search(tool_content) - if args_match: - try: - args_text = self._extract_tool_args(tool_content, args_match) - return name, args_text - except Exception: - remaining_content = tool_content[args_match.end() :] - args_text = remaining_content.rstrip("}").strip() - return name, args_text - - return name, None - - def _handle_tool_name_streaming( - self, tool_content: str, tool_count: int - ) -> DeltaMessage | None: - """ - Handle streaming of tool names. - - Args: - tool_content: Content containing tool calls - tool_count: Total number of tools - - Returns: - DeltaMessage with tool name or None if no tool to stream - """ - next_idx = self._get_next_unsent_tool_index(tool_count) - - if next_idx == -1: - return None - - boundaries = self._find_tool_boundaries(tool_content) - if next_idx >= len(boundaries): - return None - - tool_name, _ = self._get_current_tool_content(tool_content, next_idx) - if not tool_name: - return None - - self._set_current_tool_index(next_idx) - sent_tools = list(self.streaming_state["sent_tools"]) - tool_ids = list(self.streaming_state["tool_ids"]) - - tool_id = sent_tools[next_idx]["id"] - tool_ids[next_idx] = tool_id - sent_tools[next_idx]["sent_name"] = True - - self.streaming_state["sent_tools"] = sent_tools - self.streaming_state["tool_ids"] = tool_ids - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=next_idx, - type="function", - id=tool_id, - function=DeltaFunctionCall(name=tool_name).model_dump( - exclude_none=True - ), - ) - ] - ) - - def _handle_tool_args_streaming( - self, tool_content: str, tool_count: int - ) -> DeltaMessage | None: - """ - Handle streaming of tool arguments. - - Args: - tool_content: Content containing tool calls - tool_count: Total number of tools - - Returns: - DeltaMessage with tool arguments or None if no arguments to stream - """ - current_idx = self._get_current_tool_index() - - if current_idx < 0 or current_idx >= tool_count: - return None - - tool_name, tool_args = self._get_current_tool_content(tool_content, current_idx) - if not tool_name or tool_args is None: - return None - - sent_tools = list(self.streaming_state["sent_tools"]) - - if not sent_tools[current_idx]["sent_name"]: - return None - - clean_args = self._clean_duplicate_braces(tool_args) - sent_args = sent_tools[current_idx]["sent_arguments"] - - if clean_args != sent_args: - if sent_args and clean_args.startswith(sent_args): - args_delta = extract_intermediate_diff(clean_args, sent_args) - if args_delta: - args_delta = self._clean_delta_braces(args_delta) - sent_tools[current_idx]["sent_arguments"] = clean_args - self.streaming_state["sent_tools"] = sent_tools - - if clean_args.endswith("}"): - self._advance_to_next_tool() - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=current_idx, - function=DeltaFunctionCall( - arguments=args_delta - ).model_dump(exclude_none=True), - ) - ] - ) - elif not sent_args and clean_args: - clean_args_delta = self._clean_delta_braces(clean_args) - sent_tools[current_idx]["sent_arguments"] = clean_args - self.streaming_state["sent_tools"] = sent_tools - - if clean_args.endswith("}"): - self._advance_to_next_tool() - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=current_idx, - function=DeltaFunctionCall( - arguments=clean_args_delta - ).model_dump(exclude_none=True), - ) - ] - ) - - return None - - def _is_end_tool_calls(self, current_text: str) -> bool: - if self.tool_call_end_token not in current_text: - return False - - end_token_positions = [] - search_start = 0 - while True: - pos = current_text.find(self.tool_call_end_token, search_start) - if pos == -1: - break - end_token_positions.append(pos) - search_start = pos + 1 - - think_regions = [] - for match in re.finditer( - self.thinking_tag_pattern, current_text, flags=re.DOTALL - ): - think_regions.append((match.start(), match.end())) - - for pos in end_token_positions: - in_think = any( - pos >= t_start and pos < t_end for t_start, t_end in think_regions - ) - if not in_think: - return True - - return False - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - self._update_thinking_state(current_text) - - if self.in_thinking_tag: - return DeltaMessage(content=delta_text) - - if self._should_buffer_content(delta_text): - buffered_output = self._process_buffer(delta_text) - return DeltaMessage(content=buffered_output) if buffered_output else None - - if self._is_end_tool_calls(current_text): - return DeltaMessage(content=delta_text) - - safe_content, potential_tag = self._split_content_for_buffering(delta_text) - if potential_tag: - self.pending_buffer += potential_tag - return DeltaMessage(content=safe_content) if safe_content else None - - processed_current_text = self.preprocess_model_output(current_text) - - if self.tool_call_start_token not in processed_current_text: - if ( - self.tool_call_end_token in delta_text - and self.tool_call_start_token in current_text - ): - return None - if delta_text.strip() == "" and self.tool_call_start_token in current_text: - return None - if ( - self._get_current_tool_index() != -1 - and self.tool_call_end_token in current_text - ): - self._reset_streaming_state() - return DeltaMessage(content=delta_text) - - if ( - self.tool_call_start_token_id is not None - and self.tool_call_start_token_id in delta_token_ids - and len(delta_token_ids) == 1 - ): - return None - - original_tool_start = self._find_tool_start_outside_thinking(current_text) - if original_tool_start is None: - return None - - content_before_tools = self._extract_content_before_tools( - current_text, delta_text, original_tool_start - ) - if content_before_tools: - return DeltaMessage(content=content_before_tools) - - try: - tool_content = self._extract_tool_content(current_text, original_tool_start) - current_tools_count = self._detect_tools_in_text(tool_content) - - if current_tools_count == 0: - return None - - if self._get_current_tool_index() == -1: - self._reset_streaming_state() - - self._ensure_state_arrays(current_tools_count) - - return self._handle_tool_name_streaming( - tool_content, current_tools_count - ) or self._handle_tool_args_streaming(tool_content, current_tools_count) - - except Exception: - logger.exception( - "An unexpected error occurred ", "during streaming tool call handling." - ) - return None - - def _find_tool_start_outside_thinking(self, current_text: str) -> int | None: - """ - Find the start position of tool calls outside of thinking tags. - - Args: - current_text: Current text to search - - Returns: - Position of tool call start or None if not found - """ - search_start = 0 - while True: - pos = current_text.find(self.tool_call_start_token, search_start) - if pos == -1: - return None - - think_regions = [ - (m.start(), m.end()) - for m in re.finditer( - r"(.*?)", current_text, flags=re.DOTALL - ) - ] - in_think = any( - pos >= t_start and pos < t_end for t_start, t_end in think_regions - ) - - if not in_think: - return pos - - search_start = pos + 1 - - def _extract_content_before_tools( - self, current_text: str, delta_text: str, tool_start: int - ) -> str | None: - """ - Extract content that appears before tool calls. - - Args: - current_text: Current text - delta_text: Delta text - tool_start: Start position of tools - - Returns: - Content before tools or None - """ - if tool_start > 0: - delta_start_pos = len(current_text) - len(delta_text) - if delta_start_pos < tool_start: - content_part = delta_text - if delta_start_pos + len(delta_text) > tool_start: - content_part = delta_text[: tool_start - delta_start_pos] - return content_part if content_part else None - return None - - def _extract_tool_content(self, current_text: str, tool_start: int) -> str: - """ - Extract tool content from current text starting at tool_start. - - Args: - current_text: Current text - tool_start: Start position of tool calls - - Returns: - Extracted tool content - """ - tool_content_start = tool_start + len(self.tool_call_start_token) - tool_content = current_text[tool_content_start:] - - end_pos = tool_content.find(self.tool_call_end_token) - if end_pos != -1: - tool_content = tool_content[:end_pos] - - return tool_content diff --git a/vllm/tool_parsers/mistral_tool_parser.py b/vllm/tool_parsers/mistral_tool_parser.py index 0a057a3af468..026098a87358 100644 --- a/vllm/tool_parsers/mistral_tool_parser.py +++ b/vllm/tool_parsers/mistral_tool_parser.py @@ -5,11 +5,10 @@ import json from collections.abc import Sequence -from dataclasses import dataclass from enum import Enum, auto from random import choices from string import ascii_letters, digits -from typing import TYPE_CHECKING, Any +from typing import Any import ijson import regex as re @@ -40,19 +39,14 @@ ) from vllm.entrypoints.openai.responses.protocol import ResponsesRequest from vllm.logger import init_logger -from vllm.reasoning.mistral_reasoning_parser import MistralReasoningParser from vllm.sampling_params import StructuredOutputsParams from vllm.tokenizers import TokenizerLike -from vllm.tokenizers.mistral import MistralTokenizer from vllm.tool_parsers.abstract_tool_parser import ( Tool, ToolParser, ) from vllm.utils.mistral import is_mistral_tokenizer -if TYPE_CHECKING: - from vllm.reasoning import ReasoningParser - logger = init_logger(__name__) ALPHANUMERIC = ascii_letters + digits @@ -99,19 +93,6 @@ def _is_pre_v11_tokeniser(model_tokenizer: TokenizerLike) -> bool: return "[ARGS]" not in vocab -@dataclass -class MistralStreamingResult: - r"""Encapsulates the mutable state returned from - `MistralToolParser.extract_maybe_reasoning_and_tool_streaming`. - """ - - delta_message: DeltaMessage | None - reasoning_ended: bool - tools_called: bool - current_text: str - current_token_ids: list[int] - - class MistralToolParser(ToolParser): r"""Tool call parser for Mistral models, intended for use with either: @@ -281,148 +262,6 @@ def adjust_request( request._grammar_from_tool_parser = True return request - def extract_maybe_reasoning_and_tool_streaming( - self, - *, - reasoning_parser: ReasoningParser | None, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: list[int], - current_token_ids: list[int], - output_token_ids: Sequence[int], - reasoning_ended: bool, - prompt_is_reasoning_end: bool | None, - request: ChatCompletionRequest, - ) -> MistralStreamingResult: - r"""Streaming extraction with reasoning followed by tool-call parsing. - - This method encapsulates the combined reasoning extraction and - tool-call streaming logic so that the serving layer only needs a - thin routing branch. - - The flow is: - - 1. If a *reasoning_parser* is present and reasoning has **not** ended, - extract reasoning tokens. Pre-v15 models may have pre-filled - `[THINK]...[/THINK]` in system prompts, so we skip the - prompt-level reasoning-end check for those. - 2. Once reasoning ends (or if there is no reasoning parser), delegate - to `extract_tool_calls_streaming` and track whether tools were - called. - - Args: - reasoning_parser: Optional reasoning parser instance. - previous_text: Accumulated text from prior chunks. - current_text: Full accumulated text including current chunk. - delta_text: New text in this chunk. - previous_token_ids: Token ids from prior chunks. - current_token_ids: Full token ids including current chunk. - output_token_ids: Raw output token ids from the engine. - reasoning_ended: Whether reasoning has already ended. - prompt_is_reasoning_end: Whether the prompt itself ends reasoning. - request: The originating chat completion request. - """ - delta_message: DeltaMessage | None = None - tools_called = False - reasoning_ended_at_entry = reasoning_ended - - # For MistralReasoningParser, only enter the reasoning block when - # the model has actually emitted a [THINK] token. Other reasoning - # parsers always expect thinking to be present. - expect_thinking = ( - not isinstance(reasoning_parser, MistralReasoningParser) - or reasoning_parser.start_token_id in current_token_ids - ) - if reasoning_parser is not None and not reasoning_ended and expect_thinking: - # Pre-v15 models may have pre-filled [THINK]...[/THINK] in - # system prompts, so skip the prompt-level reasoning-end - # check and wait for the output's own end-of-think. - is_pre_v15 = ( - isinstance(self.model_tokenizer, MistralTokenizer) - and self.model_tokenizer.version < 15 - ) - - if not is_pre_v15 and prompt_is_reasoning_end: - reasoning_ended = True - current_token_ids = list(output_token_ids) - else: - delta_message = reasoning_parser.extract_reasoning_streaming( - previous_text, - current_text, - delta_text, - previous_token_ids, - current_token_ids, - output_token_ids, - ) - if reasoning_parser.is_reasoning_end_streaming( - current_token_ids, output_token_ids - ): - reasoning_ended = True - current_token_ids = reasoning_parser.extract_content_ids( - list(output_token_ids) - ) - if delta_message and delta_message.content: - current_text = delta_message.content - delta_message.content = None - else: - current_text = "" - - if not reasoning_ended: - return MistralStreamingResult( - delta_message=delta_message, - reasoning_ended=False, - tools_called=False, - current_text=current_text, - current_token_ids=current_token_ids, - ) - - delta_token_ids = list(output_token_ids) - - # On the iteration where reasoning just ended, reset the text/token - # state so the tool parser sees a clean history instead of the - # accumulated reasoning text. - if not reasoning_ended_at_entry and reasoning_ended: - previous_text = "" - previous_token_ids = [] - delta_text = current_text - delta_token_ids = current_token_ids - - delta_message = self.extract_tool_calls_streaming( - previous_text=previous_text, - current_text=current_text, - delta_text=delta_text, - previous_token_ids=previous_token_ids, - current_token_ids=current_token_ids, - delta_token_ids=delta_token_ids, - request=request, - ) - if delta_message and delta_message.tool_calls: - tools_called = True - - return MistralStreamingResult( - delta_message=delta_message, - reasoning_ended=reasoning_ended, - tools_called=tools_called, - current_text=current_text, - current_token_ids=current_token_ids, - ) - - @staticmethod - def build_non_streaming_tool_calls( - tool_calls: list[FunctionCall] | None, - ) -> list[ToolCall]: - r"""Build `MistralToolCall` items for non-streaming responses.""" - if not tool_calls: - return [] - - return [ - MistralToolCall(id=tc.id, function=tc) - if tc.id - else MistralToolCall(function=tc) - for tc in tool_calls - ] - def extract_tool_calls( self, model_output: str, @@ -536,7 +375,7 @@ def extract_tool_calls( return ExtractedToolCallInformation( tools_called=True, tool_calls=mistral_tool_calls, - content=content if len(content) > 0 else None, + content=content if content.strip() else None, ) def extract_tool_calls_streaming( @@ -694,6 +533,7 @@ def update_stream_state_pre_v11_tokenizer(self): if prefix == "item" and event == "start_map": self.streaming_state = StreamingState.WAITING_FOR_TOOL_KEY + self.starting_new_tool = True if prefix == "item" and event == "map_key" and value == "name": self.streaming_state = StreamingState.PARSING_NAME if prefix == "item.name" and event == "string": @@ -801,18 +641,10 @@ def _extract_tool_calls_streaming_pre_v11_tokenizer( # Given the parsed text and the possible streaming state change, # let's add to the tool delta - if ( - (streaming_state_before_parse != self.streaming_state) - and streaming_state_before_parse - in [StreamingState.WAITING_FOR_TOOL_START, StreamingState.TOOL_COMPLETE] - and self.streaming_state - not in [ - StreamingState.ALL_TOOLS_COMPLETE, - StreamingState.TOOL_COMPLETE, - StreamingState.WAITING_FOR_TOOL_START, - ] - ): - # starting a new tool call + # start_map is the authoritative new-tool signal and survives + # batched deltas, unlike comparing pre/post streaming states + if self.starting_new_tool: + self.starting_new_tool = False if current_tool_call_modified: if self.current_tool_mistral_id is not None: current_tool_call.id = self.current_tool_mistral_id diff --git a/vllm/tool_parsers/openai_tool_parser.py b/vllm/tool_parsers/openai_tool_parser.py deleted file mode 100644 index e5c37fbd3dfb..000000000000 --- a/vllm/tool_parsers/openai_tool_parser.py +++ /dev/null @@ -1,120 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import json -from collections.abc import Sequence -from typing import TYPE_CHECKING - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaMessage, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.entrypoints.openai.parser.harmony_utils import ( - extract_function_from_recipient, - is_function_recipient, - parse_output_into_messages, -) -from vllm.logger import init_logger -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) - -if TYPE_CHECKING: - from vllm.tokenizers import TokenizerLike -else: - TokenizerLike = object - -logger = init_logger(__name__) - - -class OpenAIToolParser(ToolParser): - def __init__(self, tokenizer: "TokenizerLike", tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - token_ids: Sequence[int] | None = None, - ) -> ExtractedToolCallInformation: - if token_ids is None: - raise NotImplementedError( - "OpenAIToolParser requires token IDs and does not support text-based extraction." # noqa: E501 - ) - - parser = parse_output_into_messages(token_ids) - tool_calls = [] - final_content = None - commentary_content = None - - if len(parser.messages) > 0: - for msg in parser.messages: - if msg.author.role != "assistant": - continue - if len(msg.content) < 1: - continue - msg_text = msg.content[0].text - if msg.recipient and is_function_recipient(msg.recipient): - # If no content-type is given assume JSON, as that's the - # most common case with gpt-oss models. - if not msg.content_type or "json" in msg.content_type: - # load and dump the JSON text to check validity and - # remove any extra newlines or other odd formatting - try: - tool_args = json.dumps(json.loads(msg_text)) - except json.JSONDecodeError: - logger.exception( - "Error decoding JSON tool call from response." - ) - tool_args = msg_text - else: - tool_args = msg_text - tool_calls.append( - ToolCall( - type="function", - function=FunctionCall( - name=extract_function_from_recipient(msg.recipient), - arguments=tool_args, - ), - ) - ) - elif msg.channel == "final": - final_content = msg_text - elif msg.channel == "commentary" and not msg.recipient: - commentary_content = msg_text - - # Extract partial content from the parser state if the generation was truncated - if parser.current_content: - if parser.current_channel == "final": - final_content = parser.current_content - elif ( - parser.current_channel == "commentary" and not parser.current_recipient - ): - commentary_content = parser.current_content - - return ExtractedToolCallInformation( - tools_called=len(tool_calls) > 0, - tool_calls=tool_calls, - # prefer final content over commentary content if both are present - # commentary content is tool call preambles meant to be shown to the user - content=final_content or commentary_content, - ) - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - raise NotImplementedError( - "Not being used, manual parsing in serving_chat.py" # noqa: E501 - ) diff --git a/vllm/tool_parsers/poolside_v1_tool_parser.py b/vllm/tool_parsers/poolside_v1_tool_parser.py index e515e1ce637d..1265bccb44e0 100644 --- a/vllm/tool_parsers/poolside_v1_tool_parser.py +++ b/vllm/tool_parsers/poolside_v1_tool_parser.py @@ -17,10 +17,12 @@ import partial_json_parser.core.complete import regex as re +from openai.types.responses import ToolChoiceFunction from partial_json_parser.core.options import Allow from vllm.entrypoints.chat_utils import make_tool_call_id from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionNamedToolChoiceParam, ChatCompletionRequest, ) from vllm.entrypoints.openai.engine.protocol import ( @@ -53,6 +55,8 @@ class PoolsideV1ToolParser(ToolParser): rather than waiting for the complete tag. """ + supports_required_and_named = False + def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): super().__init__(tokenizer, tools) # Stateful streaming fields @@ -72,7 +76,7 @@ def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): self.func_call_regex = re.compile(r".*?", re.DOTALL) self.func_detail_regex = re.compile( - r"([^\n]*)\n(.*)", re.DOTALL + r"\s*([^\n<]+?)\s*\n?\s*(.*?)?", re.DOTALL ) self.func_arg_regex = re.compile( r"(.*?)\s*(.*?)", re.DOTALL @@ -132,15 +136,15 @@ def _is_string_type( if tools is None: return False for tool in tools: - if tool.function.name != tool_name: + # ChatCompletion tools nest under .function; Responses + # FunctionTool is flat (.name/.parameters at the top level). + fn = getattr(tool, "function", tool) + if getattr(fn, "name", None) != tool_name: continue - if tool.function.parameters is None: + params = getattr(fn, "parameters", None) + if params is None: return False - arg_type = ( - tool.function.parameters.get("properties", {}) - .get(arg_name, {}) - .get("type", None) - ) + arg_type = params.get("properties", {}).get(arg_name, {}).get("type", None) return arg_type == "string" logger.debug("No tool named '%s'.", tool_name) return False @@ -159,7 +163,19 @@ def _tools_enabled(request: ChatCompletionRequest) -> bool: def adjust_request( self, request: ChatCompletionRequest | ResponsesRequest ) -> ChatCompletionRequest | ResponsesRequest: - """Adjust request parameters for tool call token handling.""" + """Adjust request parameters for tool call token handling. + + For required/named tool_choice, skip super().adjust_request() so it + does not install JSON guided decoding. These models emit XML tool + calls (per the chat template), which JSON guidance would break. + """ + if request.tools: + tc = request.tool_choice + if tc == "required" or isinstance( + tc, (ChatCompletionNamedToolChoiceParam, ToolChoiceFunction) + ): + request.skip_special_tokens = False + return request request = super().adjust_request(request) if request.tools and request.tool_choice != "none": # Ensure tool call tokens (, ) are not skipped @@ -192,9 +208,12 @@ def extract_tool_calls( arg_dct: dict[str, Any] = {} for key, value in pairs: arg_key = key.strip() - arg_val = value.strip() - if not self._is_string_type(tc_name, arg_key, request.tools): - arg_val = self._deserialize(arg_val) + # Keep string values verbatim; whitespace is significant + # (e.g. code/file content). Only strip non-string types. + if self._is_string_type(tc_name, arg_key, request.tools): + arg_val = value + else: + arg_val = self._deserialize(value.strip()) logger.debug("arg_key = %s, arg_val = %s", arg_key, arg_val) arg_dct[arg_key] = arg_val tool_calls.append( @@ -424,7 +443,11 @@ def extract_tool_calls_streaming( tool_calls = list(pending_deltas.values()) if content is None and len(tool_calls) == 0: - if request.logprobs: + wants_logprobs = getattr(request, "logprobs", None) or ( + isinstance(request, ResponsesRequest) + and request.is_include_output_logprobs() + ) + if wants_logprobs: return DeltaMessage(content="") return None return DeltaMessage(content=content, tool_calls=tool_calls) diff --git a/vllm/tool_parsers/qwen3_engine_tool_parser.py b/vllm/tool_parsers/qwen3_engine_tool_parser.py new file mode 100644 index 000000000000..2263a40b3609 --- /dev/null +++ b/vllm/tool_parsers/qwen3_engine_tool_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import Qwen3ParserToolAdapter + + +class Qwen3EngineToolParser(Qwen3ParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = "qwen_3_coder" diff --git a/vllm/tool_parsers/qwen3coder_tool_parser.py b/vllm/tool_parsers/qwen3coder_tool_parser.py deleted file mode 100644 index 7457590c5ac0..000000000000 --- a/vllm/tool_parsers/qwen3coder_tool_parser.py +++ /dev/null @@ -1,599 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import json -import uuid -from collections.abc import Sequence -from typing import Any - -import regex as re - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.envs import VLLM_ENFORCE_STRICT_TOOL_CALLING -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.structural_tag_registry import ( - get_enable_structured_outputs_in_reasoning, - get_model_structural_tag, -) -from vllm.tool_parsers.utils import ( - coerce_to_schema_type, - extract_types_from_schema, - find_tool_properties, -) - -logger = init_logger(__name__) - - -class Qwen3CoderToolParser(ToolParser): - supports_required_and_named: bool = not VLLM_ENFORCE_STRICT_TOOL_CALLING - - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - self.current_tool_name_sent: bool = False - self.prev_tool_call_arr: list[dict] = [] - # Override base class type - we use string IDs for tool calls - self.current_tool_id: str | None = None # type: ignore - self.streamed_args_for_tool: list[str] = [] - - # Sentinel tokens for streaming mode - self.tool_call_start_token: str = "" - self.tool_call_end_token: str = "" - self.tool_call_prefix: str = "(.*?)", re.DOTALL - ) - self.tool_call_regex = re.compile( - r"(.*?)|(.*?)$", re.DOTALL - ) - self.tool_call_function_regex = re.compile( - r"||(?=)|$)", - re.DOTALL, - ) - - if not self.model_tokenizer: - raise ValueError( - "The model tokenizer must be passed to the ToolParser " - "constructor during construction." - ) - - self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token) - self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token) - - if self.tool_call_start_token_id is None or self.tool_call_end_token_id is None: - raise RuntimeError( - "Qwen3 XML Tool parser could not locate tool call start/end " - "tokens in the tokenizer!" - ) - - logger.debug( - "vLLM Successfully import tool parser %s !", self.__class__.__name__ - ) - - def _generate_tool_call_id(self) -> str: - """Generate a unique tool call ID.""" - return f"call_{uuid.uuid4().hex[:24]}" - - def _reset_streaming_state(self): - """Reset all streaming state.""" - self.current_tool_index = 0 - self.is_tool_call_started = False - self.header_sent = False - self.current_tool_id = None - self.current_function_name = None - self.current_param_name = None - self.current_param_value = "" - self.param_count = 0 - self.in_param = False - self.in_function = False - self.accumulated_text = "" - self.json_started = False - self.json_closed = False - # Store accumulated parameters for type conversion - self.accumulated_params = {} - self.streaming_request = None - - def _convert_param_value( - self, param_value: str, param_name: str, param_config: dict, func_name: str - ) -> Any: - """Convert parameter value based on its type in the schema.""" - if not isinstance(param_value, str): - return param_value - param_schema = param_config.get(param_name, {}) - param_types = extract_types_from_schema(param_schema) - return coerce_to_schema_type(param_value, param_types) - - def _parse_xml_function_call(self, function_call_str: str) -> ToolCall | None: - # Extract function name - end_index = function_call_str.find(">") - # If there's no ">" character, this is not a valid xml function call - if end_index == -1: - return None - function_name = function_call_str[:end_index] - param_config = find_tool_properties(self.tools, function_name) - parameters = function_call_str[end_index + 1 :] - param_dict = {} - for match_text in self.tool_call_parameter_regex.findall(parameters): - idx = match_text.index(">") - param_name = match_text[:idx] - param_value = str(match_text[idx + 1 :]) - # Remove prefix and trailing \n - if param_value.startswith("\n"): - param_value = param_value[1:] - if param_value.endswith("\n"): - param_value = param_value[:-1] - - param_dict[param_name] = self._convert_param_value( - param_value, param_name, param_config, function_name - ) - return ToolCall( - type="function", - function=FunctionCall( - name=function_name, arguments=json.dumps(param_dict, ensure_ascii=False) - ), - ) - - def _get_function_calls(self, model_output: str) -> list[str]: - # Find all tool calls - matched_ranges = self.tool_call_regex.findall(model_output) - raw_tool_calls = [ - match[0] if match[0] else match[1] for match in matched_ranges - ] - - # Back-off strategy if no tool_call tags found - if len(raw_tool_calls) == 0: - raw_tool_calls = [model_output] - - raw_function_calls = [] - for tool_call in raw_tool_calls: - raw_function_calls.extend(self.tool_call_function_regex.findall(tool_call)) - - function_calls = [ - match[0] if match[0] else match[1] for match in raw_function_calls - ] - return function_calls - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - # Quick check to avoid unnecessary processing - if self.tool_call_prefix not in model_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - try: - function_calls = self._get_function_calls(model_output) - if len(function_calls) == 0: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - tool_calls = [ - self._parse_xml_function_call(function_call_str) - for function_call_str in function_calls - ] - # Populate prev_tool_call_arr for serving layer to set finish_reason - self.prev_tool_call_arr.clear() # Clear previous calls - for tool_call in tool_calls: - if tool_call: - self.prev_tool_call_arr.append( - { - "name": tool_call.function.name, - "arguments": tool_call.function.arguments, - } - ) - - # Extract content before tool calls - content_index = model_output.find(self.tool_call_start_token) - idx = model_output.find(self.tool_call_prefix) - content_index = content_index if content_index >= 0 else idx - content = model_output[:content_index] # .rstrip() - valid_tool_calls = [tc for tc in tool_calls if tc is not None] - return ExtractedToolCallInformation( - tools_called=(len(valid_tool_calls) > 0), - tool_calls=valid_tool_calls, - content=content if content else None, - ) - - except Exception: - logger.exception("Error in extracting tool call from response.") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - # Store request for type conversion - if not previous_text: - self._reset_streaming_state() - self.streaming_request = request - - # If no delta text, return None unless it's an EOS token after tools - if not delta_text: - # Check if this is an EOS token after all tool calls are complete - # Check for tool calls in text even if is_tool_call_started - # is False (might have been reset after processing all tools) - if delta_token_ids and self.tool_call_end_token_id not in delta_token_ids: - # Count complete tool calls - complete_calls = len( - self.tool_call_complete_regex.findall(current_text) - ) - - # If we have completed tool calls and populated - # prev_tool_call_arr - if complete_calls > 0 and len(self.prev_tool_call_arr) > 0: - # Check if all tool calls are closed - open_calls = current_text.count( - self.tool_call_start_token - ) - current_text.count(self.tool_call_end_token) - if open_calls == 0: - # Return empty delta for finish_reason processing - return DeltaMessage(content="") - elif not self.is_tool_call_started and current_text: - # This is a regular content response that's now complete - return DeltaMessage(content="") - return None - - # Update accumulated text - self.accumulated_text = current_text - - # Check if we need to advance to next tool - if self.json_closed and not self.in_function: - # Check if this tool call has ended - tool_ends = current_text.count(self.tool_call_end_token) - if tool_ends > self.current_tool_index: - # This tool has ended, advance to next - self.current_tool_index += 1 - self.header_sent = False - self.param_count = 0 - self.json_started = False - self.json_closed = False - self.accumulated_params = {} - - # Check if there are more tool calls - tool_starts = current_text.count(self.tool_call_start_token) - if self.current_tool_index >= tool_starts: - # No more tool calls - self.is_tool_call_started = False - # Continue processing next tool - return None - - # Handle normal content before tool calls - if not self.is_tool_call_started: - # Check if tool call is starting - if ( - self.tool_call_start_token_id in delta_token_ids - or self.tool_call_start_token in delta_text - ): - self.is_tool_call_started = True - # Return any content before the tool call - if self.tool_call_start_token in delta_text: - content_before = delta_text[ - : delta_text.index(self.tool_call_start_token) - ] - if content_before: - return DeltaMessage(content=content_before) - return None - else: - # Check if we're between tool calls - skip whitespace - if ( - current_text.rstrip().endswith(self.tool_call_end_token) - and delta_text.strip() == "" - ): - # We just ended a tool call, skip whitespace - return None - # Normal content, no tool call - return DeltaMessage(content=delta_text) - - # Check if we're between tool calls (waiting for next one) - # Count tool calls we've seen vs processed - tool_starts_count = current_text.count(self.tool_call_start_token) - if self.current_tool_index >= tool_starts_count: - # We're past all tool calls, shouldn't be here - return None - - # We're in a tool call, find the current tool call portion - # Need to find the correct tool call based on current_tool_index - tool_start_positions: list[int] = [] - idx = 0 - while True: - idx = current_text.find(self.tool_call_start_token, idx) - if idx == -1: - break - tool_start_positions.append(idx) - idx += len(self.tool_call_start_token) - - if self.current_tool_index >= len(tool_start_positions): - # No more tool calls to process yet - return None - - tool_start_idx = tool_start_positions[self.current_tool_index] - # Find where this tool call ends (or current position if not ended yet) - tool_end_idx = current_text.find(self.tool_call_end_token, tool_start_idx) - if tool_end_idx == -1: - tool_text = current_text[tool_start_idx:] - else: - tool_text = current_text[ - tool_start_idx : tool_end_idx + len(self.tool_call_end_token) - ] - - # Looking for function header - if not self.header_sent: - if self.tool_call_prefix in tool_text: - func_start = tool_text.find(self.tool_call_prefix) + len( - self.tool_call_prefix - ) - func_end = tool_text.find(">", func_start) - - if func_end != -1: - # Found complete function name - self.current_function_name = tool_text[func_start:func_end] - self.current_tool_id = self._generate_tool_call_id() - self.header_sent = True - self.in_function = True - - # Always append — each tool call is a separate - # invocation even if the function name is the same - # (e.g. two consecutive "read" calls). - self.prev_tool_call_arr.append( - { - "name": self.current_function_name, - "arguments": "{}", - } - ) - - # Initialize streamed args tracking for this tool. - # The serving layer reads streamed_args_for_tool to - # compute remaining arguments at stream end. Without - # this, IndexError occurs when the serving layer - # accesses streamed_args_for_tool[index]. - self.streamed_args_for_tool.append("") - - # Send header with function info - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - id=self.current_tool_id, - function=DeltaFunctionCall( - name=self.current_function_name, arguments="" - ), - type="function", - ) - ] - ) - return None - - # We've sent header, now handle function body - if self.in_function: - # Always send opening brace first, regardless of whether - # parameter_prefix is in the current delta. With speculative - # decoding, a single delta may contain both the opening brace - # and parameter data; skipping "{" here would desync - # json_started from what was actually streamed. - if not self.json_started: - self.json_started = True - self.streamed_args_for_tool[self.current_tool_index] += "{" - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall(arguments="{"), - ) - ] - ) - - # Find all parameter start positions in current tool_text - param_starts = [] - search_idx = 0 - while True: - search_idx = tool_text.find(self.parameter_prefix, search_idx) - if search_idx == -1: - break - param_starts.append(search_idx) - search_idx += len(self.parameter_prefix) - - # Process ALL complete params in a loop (spec decode fix). - # With speculative decoding a single delta can deliver - # multiple complete parameters at once. The old single-pass - # code would process one and ``return None`` if the next was - # incomplete — skipping any already-complete params that - # preceded it. Using a loop with ``break`` instead ensures - # we emit every complete parameter before yielding control. - json_fragments = [] - while not self.in_param and self.param_count < len(param_starts): - param_idx = param_starts[self.param_count] - param_start = param_idx + len(self.parameter_prefix) - remaining = tool_text[param_start:] - - if ">" not in remaining: - break - - name_end = remaining.find(">") - current_param_name = remaining[:name_end] - - value_start = param_start + name_end + 1 - value_text = tool_text[value_start:] - if value_text.startswith("\n"): - value_text = value_text[1:] - - param_end_idx = value_text.find(self.parameter_end_token) - if param_end_idx == -1: - next_param_idx = value_text.find(self.parameter_prefix) - func_end_idx = value_text.find(self.function_end_token) - - if next_param_idx != -1 and ( - func_end_idx == -1 or next_param_idx < func_end_idx - ): - param_end_idx = next_param_idx - elif func_end_idx != -1: - param_end_idx = func_end_idx - else: - # Fallback for malformed XML where - # is missing. Use as a delimiter - # if present in the value so we don't include - # the closing tag as part of the param value. - tool_end_in_value = value_text.find(self.tool_call_end_token) - if tool_end_in_value != -1: - param_end_idx = tool_end_in_value - else: - # Parameter incomplete — break so we still - # emit any fragments accumulated by earlier - # loop iterations. - break - - if param_end_idx == -1: - break - - param_value = value_text[:param_end_idx] - if param_value.endswith("\n"): - param_value = param_value[:-1] - - self.current_param_name = current_param_name - self.accumulated_params[current_param_name] = param_value - - param_config = find_tool_properties( - self.tools, self.current_function_name or "" - ) - - converted_value = self._convert_param_value( - param_value, - current_param_name, - param_config, - self.current_function_name or "", - ) - - serialized_value = json.dumps(converted_value, ensure_ascii=False) - - if self.param_count == 0: - json_fragment = f'"{current_param_name}": {serialized_value}' - else: - json_fragment = f', "{current_param_name}": {serialized_value}' - - self.param_count += 1 - json_fragments.append(json_fragment) - - if json_fragments: - combined = "".join(json_fragments) - - if self.current_tool_index < len(self.streamed_args_for_tool): - self.streamed_args_for_tool[self.current_tool_index] += combined - else: - logger.warning( - "streamed_args_for_tool out of sync: index=%d len=%d", - self.current_tool_index, - len(self.streamed_args_for_tool), - ) - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall(arguments=combined), - ) - ] - ) - - # Check for function end AFTER processing parameters. - # This ordering is critical: with speculative decoding a - # burst can deliver the final parameter value together with - # . If the close check ran first it would emit - # "}" and set in_function=False before the parameter loop - # ever ran, causing the parameter to be silently dropped. - if not self.json_closed and self.function_end_token in tool_text: - self.json_closed = True - - func_start = tool_text.find(self.tool_call_prefix) + len( - self.tool_call_prefix - ) - func_content_end = tool_text.find(self.function_end_token, func_start) - if func_content_end != -1: - func_content = tool_text[func_start:func_content_end] - try: - parsed_tool = self._parse_xml_function_call( - func_content, - ) - if parsed_tool and self.current_tool_index < len( - self.prev_tool_call_arr - ): - self.prev_tool_call_arr[self.current_tool_index][ - "arguments" - ] = parsed_tool.function.arguments - except Exception: - logger.debug( - "Failed to parse tool call during streaming: %s", - tool_text, - exc_info=True, - ) - - if self.current_tool_index < len(self.streamed_args_for_tool): - self.streamed_args_for_tool[self.current_tool_index] += "}" - else: - logger.warning( - "streamed_args_for_tool out of sync: index=%d len=%d", - self.current_tool_index, - len(self.streamed_args_for_tool), - ) - - result = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall(arguments="}"), - ) - ] - ) - - self.in_function = False - self.json_closed = True - self.accumulated_params = {} - - return result - - return None - - def get_structural_tag(self, request: ChatCompletionRequest): - return get_model_structural_tag( - model="qwen_3_5", - tools=request.tools, - tool_choice=request.tool_choice, - reasoning=get_enable_structured_outputs_in_reasoning(), - ) diff --git a/vllm/tool_parsers/qwen3xml_tool_parser.py b/vllm/tool_parsers/qwen3xml_tool_parser.py deleted file mode 100644 index e5d2b896e005..000000000000 --- a/vllm/tool_parsers/qwen3xml_tool_parser.py +++ /dev/null @@ -1,1300 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -import json -from collections.abc import Sequence -from typing import Any -from xml.parsers.expat import ParserCreate - -import regex as re - -from vllm.entrypoints.chat_utils import make_tool_call_id -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import find_tool_properties, safe_literal_eval - -logger = init_logger(__name__) - - -class StreamingXMLToolCallParser: - """ - Simplified streaming XML tool call parser - Supports streaming input, parsing, and output - """ - - def __init__(self): - self.reset_streaming_state() - - # Tool configuration information - self.tools: list[Tool] | None = None - self.tool_call_start_token: str = "" - self.tool_call_end_token: str = "" - self.function_start_token: str = " DeltaMessage: - """ - Parse single streaming XML chunk and return Delta response - This is the actual streaming interface that receives chunks - one by one and maintains internal state - - Args: - xml_chunk: Single XML chunk string - Returns: - DeltaMessage: Contains delta information generated by this chunk, - returns empty response if no complete elements - """ - # Record delta count before processing - initial_delta_count = len(self.deltas) - - self.streaming_buffer += xml_chunk - - found_elements = self._process_complete_xml_elements() - - if found_elements: - # If complete elements found, check if end events were missed - # some tags may not have been triggered - try: - new_deltas = self.deltas[initial_delta_count:] - # If this chunk contains - # but didn't generate '}', then complete it - if ( - self.current_call_id is not None - and self.function_end_token in xml_chunk - ): - # - Added '}' (non-empty parameter ending) - # - Added '{}' (empty parameter function) - has_function_close = any( - ( - td.tool_calls - and any( - ( - tc.function - and tc.id == self.current_call_id - and isinstance(tc.function.arguments, str) - and (tc.function.arguments in ("}", "{}")) - ) - for tc in td.tool_calls - ) - ) - for td in new_deltas - ) - if not has_function_close: - # Close potentially unclosed element - if self.current_param_name: - self._end_element("parameter") - if self.current_function_name: - self._end_element("function") - # If this chunk contains - # but didn't generate final empty delta, then complete it - if ( - self.current_call_id is not None - and self.tool_call_end_token in xml_chunk - ): - has_toolcall_close = any( - ( - td.tool_calls - and any( - ( - tc.type == "function" - and tc.function - and tc.function.arguments == "" - and tc.id == self.current_call_id - ) - for tc in td.tool_calls - ) - ) - for td in new_deltas - ) - if not has_toolcall_close: - # Close potentially unclosed element - if self.current_param_name: - self._end_element("parameter") - if self.current_function_name: - self._end_element("function") - self._end_element("tool_call") - except Exception as e: - logger.warning("Error with fallback parsing: %s", e) - # Merge newly generated deltas into single response - result_delta = self._merge_new_deltas_to_single_response( - initial_delta_count - ) - return result_delta - else: - # No complete elements, check if there's unoutput text content - if self.text_content_buffer and self.tool_call_index == 0: - # Has text content but no tool_call yet, output text content - text_delta = DeltaMessage(content=self.text_content_buffer) - self._emit_delta(text_delta) - # Clear buffer to avoid duplicate output - self.text_content_buffer = "" - return text_delta - - # If this chunk contains end tags but wasn't triggered by parser, - # manually complete end events - # Only execute when still on the same call as when entered, - # to prevent accidentally closing new calls - # in multi scenarios - if self.current_call_id is not None and ( - self.function_end_token in xml_chunk - or self.tool_call_end_token in xml_chunk - ): - # Close potentially unclosed element - if self.current_param_name: - self._end_element("parameter") - if self.function_end_token in xml_chunk and self.current_function_name: - self._end_element("function") - if self.tool_call_end_token in xml_chunk: - self._end_element("tool_call") - # Return the merged delta result generated by this fallback - result_delta = self._merge_new_deltas_to_single_response( - initial_delta_count - ) - return result_delta - - # No complete elements, return empty response - return DeltaMessage(content=None) - - def _escape_xml_special_chars(self, text: str) -> str: - """ - Escape XML special characters - Args: - text: Original text - Returns: - Escaped text - """ - xml_escapes = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - } - - for char, escape in xml_escapes.items(): - text = text.replace(char, escape) - - return text - - def _process_complete_xml_elements(self) -> bool: - """ - Process complete XML elements in buffer - - Returns: - bool: Whether complete elements were found and processed - """ - found_any = False - - while self.last_processed_pos < len(self.streaming_buffer): - # Find next complete xml element - element, end_pos = self._find_next_complete_element(self.last_processed_pos) - if element is None: - # No complete element found, wait for more data - break - - # Check if this element should be skipped - if self._should_skip_element(element): - self.last_processed_pos = end_pos - continue - - # Found complete XML element, process it - try: - preprocessed_element = self._preprocess_xml_chunk(element) - # Check if this is the first tool_call start - if ( - ( - preprocessed_element.strip().startswith("") - or preprocessed_element.strip().startswith("") - and self.tool_call_index > 0 - and self.current_call_id - ): - # Reset parser state but preserve generated deltas - if self.current_param_name: - self._end_element("parameter") - if self.current_function_open or self.current_function_name: - self._end_element("function") - # Output final tool_call tail delta - final_delta = DeltaMessage( - role=None, - content=None, - reasoning=None, - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments=""), - ) - ], - ) - self._emit_delta(final_delta) - # Reset XML parser and current call state - self._reset_xml_parser_after_tool_call() - # Parse preprocessed element - self.parser.Parse(preprocessed_element, False) - found_any = True - - except Exception as e: - logger.warning("Error when parsing XML elements: %s", e) - - # Update processed position - self.last_processed_pos = end_pos - - return found_any - - def _should_skip_element(self, element: str) -> bool: - """ - Determine whether an element should be skipped - - Args: - element: Element to evaluate - - Returns: - bool: True means should skip, False means should process - """ - - # If it's a tool_call XML tag, don't skip - if ( - element.startswith(self.tool_call_start_token) - or element.startswith(self.function_start_token) - or element.startswith(self.parameter_start_token) - ): - return False - - # If currently not parsing tool calls and not blank, - # collect this text instead of skipping - # Only process other XML elements after tool_call appears, - # otherwise treat as plain text - if self.current_call_id is None and element: - # Collect text content to buffer - self.text_content_buffer += element - return True # Still skip, but content has been collected - - # If currently parsing tool calls, - # this might be parameter value, don't skip - if self.current_call_id is not None: - return False - - # Skip blank content - return not element - - def _find_next_complete_element(self, start_pos: int) -> tuple[str | None, int]: - """ - Find next complete XML element from specified position - - Args: - start_pos: Position to start searching - - Returns: - (Complete element string, element end position), - returns (None, start_pos) if no complete element found - """ - buffer = self.streaming_buffer[start_pos:] - - if not buffer: - return None, start_pos - - if buffer.startswith("<"): - # Need to ensure no new < appears, - # find the nearest one between < and > - tag_end = buffer.find("<", 1) - tag_end2 = buffer.find(">", 1) - if tag_end != -1 and tag_end2 != -1: - # Next nearest is < - if tag_end < tag_end2: - return buffer[:tag_end], start_pos + tag_end - # Next nearest is >, means found XML element - else: - return buffer[: tag_end2 + 1], start_pos + tag_end2 + 1 - elif tag_end != -1: - return buffer[:tag_end], start_pos + tag_end - elif tag_end2 != -1: - return buffer[: tag_end2 + 1], start_pos + tag_end2 + 1 - else: - # If currently not parsing tool calls (entering a tool_call), - # check if starts with or - if buffer == ""[: len(buffer)]: - # Might be start of , wait for more data - return None, start_pos - elif ( - buffer.startswith(" DeltaMessage: - """ - Merge newly generated deltas from this processing - into a single DeltaMessage - - Args: - initial_count: Delta count before processing - - Returns: - Merged DeltaMessage containing all newly generated delta information - """ - if len(self.deltas) <= initial_count: - return DeltaMessage(content=None) - - # Get newly generated deltas - new_deltas = self.deltas[initial_count:] - - if len(new_deltas) == 1: - # Only one new delta, return directly - return new_deltas[0] - - # Merge multiple new deltas - merged_tool_calls: list[DeltaToolCall] = [] - merged_content: str = "" - - for delta in new_deltas: - if delta.content: - merged_content += delta.content - if delta.tool_calls: - # For tool_calls, we need to intelligently merge arguments - for tool_call in delta.tool_calls: - # Find if there's already a tool_call with the same call_id - existing_call = None - for existing in merged_tool_calls: - if existing.id == tool_call.id: - existing_call = existing - break - - if existing_call and existing_call.function: - # Merge to existing tool_call - if tool_call.function and tool_call.function.name: - existing_call.function.name = tool_call.function.name - if ( - tool_call.function - and tool_call.function.arguments is not None - ): - if existing_call.function.arguments is None: - existing_call.function.arguments = "" - - # For streaming JSON parameters, - # simply concatenate in order - new_args = tool_call.function.arguments - existing_call.function.arguments += new_args - if tool_call.type: - existing_call.type = tool_call.type - else: - # Add new tool_call - merged_tool_calls.append(tool_call) - - return DeltaMessage( - content=merged_content if merged_content else None, - tool_calls=merged_tool_calls, - ) - - def _preprocess_xml_chunk(self, chunk: str) -> str: - """ - Preprocess XML chunk, handle non-standard formats, - and escape special characters - - Args: - chunk: Original XML chunk - - Returns: - Processed XML chunk - """ - - # Check if this is a tool_call related element - is_tool_call = False - if chunk.startswith(self.tool_call_start_token) or chunk.startswith( - self.tool_call_end_token - ): - is_tool_call = True - if chunk.startswith(self.function_start_token) or chunk.startswith( - self.function_end_token - ): - is_tool_call = True - if chunk.startswith(self.parameter_start_token) or chunk.startswith( - self.parameter_end_token - ): - is_tool_call = True - # Handle format -> - processed = re.sub(r"]+)>", r'', chunk) - # Handle format -> - processed = re.sub(r"]+)>", r'', processed) - - original_chunk = chunk - # If in parameter value accumulation mode - if self._pre_inside_parameter: - # Parameter end: output accumulated raw text - # safely then return - if processed.startswith(""): - body_text = self._pre_param_buffer - # Trigger deferred parsing mode - # literal_eval+json output in end_element - self.defer_current_parameter = True - self.deferred_param_raw_value = body_text - # Clean up state - self._pre_inside_parameter = False - self._pre_param_buffer = "" - self._pre_current_param_name = None - safe_text = self._escape_xml_special_chars(body_text) - return f"{safe_text}" - else: - # If this is the first block of content after entering parameter - # evaluate if deferred parsing is needed; - # If not needed, exit accumulation mode - # and pass through directly - if self._pre_param_buffer == "": - # Get current parameter type - param_type = ( - self._get_param_type(self._pre_current_param_name) - if self._pre_current_param_name - else "string" - ) - # Only these types need deferred parsing to - # handle Python literals containing single quotes - is_object_type = param_type in ["object"] - is_complex_type = ( - param_type in ["array", "arr", "sequence"] - or param_type.startswith("dict") - or param_type.startswith("list") - ) - - # Only delay when contains container symbols - # and has single quotes and is complex type - has_container_hint = ( - ("[" in original_chunk) - or ("{" in original_chunk) - or ("(" in original_chunk) - ) - - # Determine if deferred parsing is needed - need_defer = False - if is_complex_type: - # Complex type, always need deferred parsing - need_defer = True - elif ( - is_object_type - and has_container_hint - and ("'" in original_chunk) - ): - # Object type with container symbols - # and single quotes, need deferred parsing - need_defer = True - - if not need_defer: - # No need for deferred parsing, - # exit parameter mode directly - self._pre_inside_parameter = False - return self._escape_xml_special_chars(original_chunk) - self._pre_param_buffer += original_chunk - return "" - - # Parameter start: enable accumulation - if processed.startswith("', processed) - if m: - self._pre_current_param_name = m.group(1) - self._pre_inside_parameter = True - self._pre_param_buffer = "" - return processed - - # If processed doesn't contain special_token, escape processed - # This is because XML parsing encounters special characters - # and reports errors, so escaping is needed - if not is_tool_call: - processed = self._escape_xml_special_chars(processed) - return processed - - def _emit_delta(self, delta: DeltaMessage): - """Emit Delta response (streaming output)""" - self.deltas.append(delta) - - def _auto_close_open_parameter_if_needed(self, incoming_tag: str | None = None): - """Before starting to process new elements, - if there are unclosed tags from before, - automatically complete their endings to the parser. - - If there are unclosed parameters, - it's equivalent to feeding `` - - When about to start a new function or tool_call, - if there are unclosed functions, complete ``. - - When about to start a new tool_call, - if there are unclosed tool_calls, complete ``. - """ - # First close unclosed parameters - if self.current_param_name: - self._end_element("parameter") - - # If about to start new function or tool_call, - # and there are unclosed functions, close function first - if incoming_tag in ("function", "tool_call") and self.current_function_name: - self._end_element("function") - - # If about to start new tool_call, - # and there are unclosed tool_calls, close tool_call first - if incoming_tag == "tool_call" and self.current_call_id: - self._end_element("tool_call") - - def _start_element(self, name: str, attrs: dict[str, str]): - """Handle XML start element events""" - - if name == "root": - return - - if name == "tool_call": - # Before opening new tool_call, - # automatically complete previous unclosed tags - self._auto_close_open_parameter_if_needed("tool_call") - - self.parameters = {} - self.current_call_id = make_tool_call_id() - self.current_param_is_first = True - self.tool_call_index += 1 - elif name.startswith("function") or (name == "function"): - # If missing tool_call, manually complete - if not self.current_call_id: - self._start_element("tool_call", {}) - # Before opening new function, - # automatically complete previous unclosed tags (parameter/function) - self._auto_close_open_parameter_if_needed("function") - function_name = self._extract_function_name(name, attrs) - self.current_function_name = function_name - self.current_function_open = True - if function_name: - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall( - name=function_name, arguments="" - ), - ) - ] - ) - self._emit_delta(delta) - elif name.startswith("parameter") or (name == "parameter"): - # If previous parameter hasn't ended normally, - # complete its end first, then start new parameter - self._auto_close_open_parameter_if_needed("parameter") - param_name = self._extract_parameter_name(name, attrs) - self.current_param_name = param_name - self.current_param_value = "" - self.current_param_value_converted = "" - self.start_quote_emitted = False # Reset start quote flag - - # Only output parameter name and colon, - # don't output quotes - # decide after parameter value type is determined - if param_name: - if not self.parameters: - # First parameter - # start JSON, only output parameter name and colon - json_start = f'{{"{param_name}": ' - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall( - name=None, arguments=json_start - ), - ) - ] - ) - self._emit_delta(delta) - self.current_param_is_first = True - else: - # Subsequent parameters - # add comma and parameter name, no quotes - json_continue = f', "{param_name}": ' - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall( - name=None, arguments=json_continue - ), - ) - ] - ) - self._emit_delta(delta) - self.current_param_is_first = False - - def _char_data(self, data: str): - """Handle XML character data events""" - if data and self.current_param_name: - # If preprocessing stage determines deferred parsing is needed, - # only cache character data, no streaming output - if self.defer_current_parameter: - original_data = data - if self.should_emit_end_newline: - original_data = "\n" + original_data - self.should_emit_end_newline = False - if original_data.endswith("\n"): - self.should_emit_end_newline = True - original_data = original_data[:-1] - self.current_param_value += original_data - return - - param_type = self._get_param_type(self.current_param_name) - - # Check if this is the first time receiving data for this parameter - # If this is the first packet of data and starts with \n, remove \n - if not self.current_param_value and data.startswith("\n"): - data = data[1:] - - # Output start quote for string type (if not already output) - if ( - param_type in ["string", "str", "text", "varchar", "char", "enum"] - and not self.start_quote_emitted - ): - quote_delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments='"'), - ) - ] - ) - self._emit_delta(quote_delta) - self.start_quote_emitted = True - - if not data: - return - - original_data = data - # Delay output of trailing newline - if self.should_emit_end_newline: - original_data = "\n" + original_data - self.should_emit_end_newline = False - if original_data.endswith("\n"): - self.should_emit_end_newline = True - original_data = original_data[:-1] - self.current_param_value += original_data - - # convert parameter value by param_type - converted_value = self._convert_param_value( - self.current_param_value, param_type - ) - output_data = self._convert_for_json_streaming(converted_value, param_type) - - delta_data = output_data[len(self.current_param_value_converted) :] - self.current_param_value_converted = output_data - - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments=delta_data), - ) - ] - ) - self._emit_delta(delta) - - def _end_element(self, name: str): - """Handle XML end element events""" - - if name == "root": - return - - # If function or tool_call ends and there are still unclosed parameters, - # complete parameter end first - if ( - name.startswith("function") or name == "function" or name == "tool_call" - ) and self.current_param_name: - self._auto_close_open_parameter_if_needed() - - if ( - name.startswith("parameter") or name == "parameter" - ) and self.current_param_name: - # End current parameter - param_name = self.current_param_name - param_value = self.current_param_value - - # If in deferred parsing mode, - # perform overall parsing on raw content - # accumulated in preprocessing stage and output once - if self.defer_current_parameter: - raw_text = ( - self.deferred_param_raw_value - if self.deferred_param_raw_value - else param_value - ) - parsed_value = None - output_arguments = None - try: - # If previously delayed trailing newline, - # add it back before parsing - if self.should_emit_end_newline: - raw_for_parse = raw_text + "\n" - else: - raw_for_parse = raw_text - try: - parsed_value = json.loads(raw_for_parse) - except json.JSONDecodeError: - parsed_value = safe_literal_eval(raw_for_parse) - output_arguments = json.dumps(parsed_value, ensure_ascii=False) - except Exception: - # Fallback: output as string as-is - output_arguments = json.dumps(raw_text, ensure_ascii=False) - parsed_value = raw_text - - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall( - name=None, arguments=output_arguments - ), - ) - ] - ) - self._emit_delta(delta) - - # Clean up and store - self.should_emit_end_newline = False - self.parameters[param_name] = parsed_value - self.current_param_name = None - self.current_param_value = "" - self.current_param_value_converted = "" - self.start_quote_emitted = False - self.defer_current_parameter = False - self.deferred_param_raw_value = "" - return - - param_type = self._get_param_type(param_name) - - # convert complete parameter value by param_type - converted_value = self._convert_param_value(param_value, param_type) - - # Decide whether to add end quote based on parameter type - if param_type in ["string", "str", "text", "varchar", "char", "enum"]: - # For empty string parameters, need special handling - if not param_value and not self.start_quote_emitted: - # No start quote output, - # directly output complete empty string - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments='""'), - ) - ] - ) - self._emit_delta(delta) - else: - # Non-empty parameter value, output end quote - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments='"'), - ) - ] - ) - self._emit_delta(delta) - - self.should_emit_end_newline = False - # Store converted value - self.parameters[param_name] = converted_value - self.current_param_name = None - self.current_param_value = "" - self.current_param_value_converted = "" - self.start_quote_emitted = False - - elif name.startswith("function") or name == "function": - # if there are parameters, close JSON object - if self.parameters: - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments="}"), - ) - ] - ) - self._emit_delta(delta) - # return empty object - else: - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments="{}"), - ) - ] - ) - self._emit_delta(delta) - self.current_function_open = False - - elif name == "tool_call": - # Before ending tool_call, - # ensure function is closed to complete missing right brace - if self.current_function_open: - # If there are still unclosed parameters, close them first - if self.current_param_name: - self._end_element("parameter") - # Close function, ensure output '}' or '{}' - self._end_element("function") - # Final Delta - delta = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.tool_call_index - 1, - id=self.current_call_id, - type="function", - function=DeltaFunctionCall(name=None, arguments=""), - ) - ] - ) - self._emit_delta(delta) - - # Check if there's text content to output (between tool_calls) - if self.text_content_buffer.strip(): - text_delta = DeltaMessage(content=self.text_content_buffer) - self._emit_delta(text_delta) - - self._reset_xml_parser_after_tool_call() - - def setup_parser(self): - """Set up XML parser event handlers""" - self.parser.buffer_text = True - self.parser.StartElementHandler = self._start_element - self.parser.EndElementHandler = self._end_element - self.parser.CharacterDataHandler = self._char_data - - def set_tools(self, tools: list[Tool] | None): - """Set tool configuration information""" - self.tools = tools - - def _extract_function_name(self, name: str, attrs: dict[str, str]) -> str | None: - """Extract function name from various formats""" - if attrs and "name" in attrs: - return attrs["name"] - - if "=" in name: - parts = name.split("=", 1) - if len(parts) == 2 and parts[0] == "function": - return parts[1] - - return None - - def _extract_parameter_name(self, name: str, attrs: dict[str, str]) -> str | None: - """Extract parameter name from various formats""" - if attrs and "name" in attrs: - return attrs["name"] - - if "=" in name: - parts = name.split("=", 1) - if len(parts) == 2 and parts[0] == "parameter": - return parts[1] - - return None - - def _get_param_type(self, param_name: str) -> str: - """Get parameter type based on tool configuration, defaults to string - Args: - param_name: Parameter name - - Returns: - Parameter type - """ - if not self.tools or not self.current_function_name: - return "string" - - properties = find_tool_properties(self.tools, self.current_function_name) - if param_name in properties and isinstance(properties[param_name], dict): - return self.repair_param_type( - str(properties[param_name].get("type", "string")) - ) - return "string" - - def repair_param_type(self, param_type: str) -> str: - """Repair unknown parameter types by treating them as string - Args: - param_type: Parameter type - - Returns: - Repaired parameter type - """ - if ( - param_type in ["string", "str", "text", "varchar", "char", "enum"] - or param_type.startswith("int") - or param_type.startswith("uint") - or param_type.startswith("long") - or param_type.startswith("short") - or param_type.startswith("unsigned") - or param_type.startswith("num") - or param_type.startswith("float") - or param_type in ["boolean", "bool", "binary"] - or ( - param_type in ["object", "array", "arr", "sequence"] - or param_type.startswith("dict") - or param_type.startswith("list") - ) - ): - return param_type - else: - return "string" - - def _convert_param_value(self, param_value: str, param_type: str) -> Any: - """Convert value based on parameter type - Args: - param_value: Parameter value - param_type: Parameter type - - Returns: - Converted value - """ - if param_value.lower() == "null": - return None - - param_type = param_type.strip().lower() - if param_type in ["string", "str", "text", "varchar", "char", "enum"]: - return param_value - elif ( - param_type.startswith("int") - or param_type.startswith("uint") - or param_type.startswith("long") - or param_type.startswith("short") - or param_type.startswith("unsigned") - ): - try: - return int(param_value) - except (ValueError, TypeError): - logger.warning( - "Parsed value '%s' of parameter '%s' is not an integer " - "in tool '%s', degenerating to string.", - param_value, - ) - return param_value - elif param_type.startswith("num") or param_type.startswith("float"): - try: - float_param_value: float = float(param_value) - return ( - float_param_value - if float_param_value - int(float_param_value) != 0 - else int(float_param_value) - ) - except (ValueError, TypeError): - logger.warning( - "Parsed value '%s' of parameter '%s' is not a float " - "in tool '%s', degenerating to string.", - param_value, - ) - return param_value - elif param_type in ["boolean", "bool", "binary"]: - param_value = param_value.lower() - return param_value == "true" - else: - return param_value - - def _convert_for_json_streaming(self, converted_value: Any, param_type: str) -> str: - """Convert converted_value based on - whether it's empty and if type is string - Args: - converted_value: Converted value - param_type: Parameter type - - Returns: - Converted string for streaming output - """ - # Check if value is empty, but exclude numeric 0 - if converted_value is None or converted_value == "": - return "" - - if param_type in ["string", "str", "text", "varchar", "char", "enum"]: - # String type, remove double quotes - return json.dumps(converted_value, ensure_ascii=False)[1:-1] - else: - # Non-string type, return complete JSON string - if not isinstance(converted_value, str): - return json.dumps(converted_value, ensure_ascii=False) - else: - return converted_value - - def _reset_xml_parser_after_tool_call(self): - """ - Each tool_call is treated as a separate XML document, - so we need to reset the parser after each tool_call. - """ - - # recreate XML parser - self.parser = ParserCreate() - self.setup_parser() - - # Reset current tool_call state - if self.current_call_id: - self.last_completed_call_id = self.current_call_id - self.current_call_id = None - self.current_function_name = None - self.current_function_open = False - self.parameters = {} - self.current_param_name = None - self.current_param_value = "" - self.current_param_value_converted = "" - self.current_param_is_first = False - self.should_emit_end_newline = False - self.start_quote_emitted = False - self.text_content_buffer = "" - - # Reset preprocessing and deferred parsing state - self._pre_inside_parameter = False - self._pre_param_buffer = "" - self._pre_current_param_name = None - self.defer_current_parameter = False - self.deferred_param_raw_value = "" - - -class Qwen3XMLToolParser(ToolParser): - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - self.parser = StreamingXMLToolCallParser() - - # Add missing attributes for compatibility with serving_chat.py - self.prev_tool_call_arr: list[dict] = [] - self.streamed_args_for_tool: list[str] = [] - - logger.info( - "vLLM Successfully import tool parser %s !", self.__class__.__name__ - ) - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - self.parser.reset_streaming_state() - # Reset tool call tracking arrays for new extraction - self.prev_tool_call_arr = [] - self.streamed_args_for_tool = [] - self.parser.set_tools(self.tools) - result = self.parser.parse_single_streaming_chunks(model_output) - if not result.tool_calls: - return ExtractedToolCallInformation( - tool_calls=[], - tools_called=False, - content=result.content, - ) - else: - tool_calls = [] - for tool_call in result.tool_calls: - if tool_call.function and tool_call.function.name: - tool_calls.append( - ToolCall( - id=tool_call.id, - type=tool_call.type, - function=FunctionCall( - name=tool_call.function.name, - arguments=tool_call.function.arguments, - ), - ) - ) - - # Update tool call tracking arrays for compatibility - tool_index = ( - tool_call.index - if tool_call.index is not None - else len(self.prev_tool_call_arr) - 1 - ) - - # Ensure we have enough entries in our tracking arrays - while len(self.prev_tool_call_arr) <= tool_index: - self.prev_tool_call_arr.append({"name": "", "arguments": ""}) - while len(self.streamed_args_for_tool) <= tool_index: - self.streamed_args_for_tool.append("") - - # Update tool call information - self.prev_tool_call_arr[tool_index]["name"] = ( - tool_call.function.name - ) - self.prev_tool_call_arr[tool_index]["arguments"] = ( - tool_call.function.arguments - ) - - # Update streamed arguments - if tool_call.function.arguments: - self.streamed_args_for_tool[tool_index] = ( - tool_call.function.arguments - ) - - return ExtractedToolCallInformation( - tool_calls=tool_calls, - tools_called=len(tool_calls) > 0, - content=result.content, - ) - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - if not previous_text: - self.parser.reset_streaming_state() - # Reset tool call tracking arrays for new streaming session - self.prev_tool_call_arr = [] - self.streamed_args_for_tool = [] - self.parser.set_tools(self.tools) - - # Model sometimes outputs separately causing delta_text to be empty. - # If there were tool_calls before and all current tool_calls have ended, - # return an empty tool_call for outer streaming output - # to correctly output tool_call field - if not delta_text and delta_token_ids: - open_calls = current_text.count( - self.parser.tool_call_start_token - ) - current_text.count(self.parser.tool_call_end_token) - if ( - open_calls == 0 - and self.parser.tool_call_index > 0 - or not self.parser.tool_call_index - and current_text - ): - return DeltaMessage(content="") - return None - - # Parse the delta text and get the result - delta = self.parser.parse_single_streaming_chunks(delta_text) - - # Update tool call tracking arrays based on incremental parsing results - if delta and delta.tool_calls: - for tool_call in delta.tool_calls: - if tool_call.function: - tool_index = ( - tool_call.index - if tool_call.index is not None - else len(self.prev_tool_call_arr) - 1 - ) - - # Ensure we have enough entries in our tracking arrays - while len(self.prev_tool_call_arr) <= tool_index: - self.prev_tool_call_arr.append({"name": "", "arguments": ""}) - while len(self.streamed_args_for_tool) <= tool_index: - self.streamed_args_for_tool.append("") - - # Update tool name if provided - if tool_call.function.name: - self.prev_tool_call_arr[tool_index]["name"] = ( - tool_call.function.name - ) - - # Update arguments incrementally - if tool_call.function.arguments is not None: - # Concatenate the incremental arguments - # to the existing streamed arguments - self.prev_tool_call_arr[tool_index]["arguments"] += ( - tool_call.function.arguments - ) - self.streamed_args_for_tool[tool_index] += ( - tool_call.function.arguments - ) - if delta.content is None and not delta.tool_calls and delta.reasoning is None: - # If no content and no tool calls, return None to indicate no update - return None - return delta diff --git a/vllm/tool_parsers/rust_tool_parser.py b/vllm/tool_parsers/rust_tool_parser.py new file mode 100644 index 000000000000..05f015369f8e --- /dev/null +++ b/vllm/tool_parsers/rust_tool_parser.py @@ -0,0 +1,322 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import importlib +from collections.abc import Sequence +from typing import Any + +from openai.types.responses.function_tool import FunctionTool + +from vllm.entrypoints.chat_utils import make_tool_call_id +from vllm.entrypoints.openai.chat_completion.protocol import ( + ChatCompletionRequest, + ChatCompletionToolsParam, +) +from vllm.entrypoints.openai.engine.protocol import ( + DeltaFunctionCall, + DeltaMessage, + DeltaToolCall, + ExtractedToolCallInformation, + FunctionCall, + ToolCall, +) +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.logger import init_logger +from vllm.tokenizers import TokenizerLike +from vllm.tool_parsers.abstract_tool_parser import Tool, ToolParser + +logger = init_logger(__name__) + + +def _rust_tool_parser_module() -> Any: + try: + return importlib.import_module("vllm._rust_tool_parser") + except ImportError as exc: + raise RuntimeError( + "Rust tool parsing requires the vllm._rust_tool_parser PyO3 " + "extension. Rebuild vLLM with Rust frontend/extensions enabled." + ) from exc + + +class RustToolParser(ToolParser): + """Adapter from an opaque Rust parser to the vLLM ToolParser API. + + Subclasses provide only model-specific configuration: the exact Rust parser + name and an optional tool-call start marker for fast complete-output + rejection. + + This class keeps the vLLM-specific bridge work: + - convert vLLM tool definitions into the Rust ``Tool`` shape; + - translate typed Rust parser outputs into vLLM protocol objects; and + - maintain vLLM streaming bookkeeping used by finish-reason handling. + + The parser grammar and incremental parser state stay in Rust. + """ + + # Rust-backed parsers are opaque to Python by default. Do not use vLLM's + # standard JSON required/named handling; let the Rust parser consume the + # model's native tool-call syntax. + supports_required_and_named = False + + rust_parser_name: str + tool_call_start_token: str | None = None + + def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): + super().__init__(tokenizer, tools) + self._parser: Any | None = None + self._error: Exception | None = None + + if not self.model_tokenizer: + raise ValueError( + "The model tokenizer must be passed to the ToolParser " + "constructor during construction." + ) + + logger.debug( + "vLLM successfully imported tool parser %s", self.__class__.__name__ + ) + + def adjust_request( + self, request: ChatCompletionRequest | ResponsesRequest + ) -> ChatCompletionRequest | ResponsesRequest: + """Adjust request options without installing Python-side constraints. + + Rust-backed parsers are treated as source-of-truth opaque parsers. The + bridge intentionally avoids ``super().adjust_request()`` so Python does + not install JSON schema guidance or structural-tag constraints that may + conflict with the Rust parser's native grammar. + """ + if self._get_parser().preserve_special_tokens(): + request.skip_special_tokens = False + return request + + def _rust_tools(self) -> list[Any]: + """Build Rust ``Tool`` objects from vLLM tool definitions.""" + if not self.tools: + return [] + + tools: list[Any] = [] + for tool in self.tools: + if isinstance(tool, FunctionTool): + name = tool.name + description = tool.description + parameters = tool.parameters or {} + strict = getattr(tool, "strict", None) + elif isinstance(tool, ChatCompletionToolsParam): + name = tool.function.name + description = tool.function.description + parameters = tool.function.parameters or {} + strict = getattr(tool.function, "strict", None) + else: + continue + tools.append( + _rust_tool_parser_module().Tool(name, description, parameters, strict) + ) + return tools + + def _new_parser(self) -> Any: + """Create a fresh Rust parser with the current tool schemas.""" + return _rust_tool_parser_module().ToolParser( + self.rust_parser_name, self._rust_tools() + ) + + def _get_parser(self) -> Any: + if self._parser is None: + self._parser = self._new_parser() + return self._parser + + def _reset_streaming_state(self) -> None: + """Reset parser state for a new request on a reused parser instance.""" + self._parser = self._new_parser() + self._error = None + self.prev_tool_call_arr.clear() + self.streamed_args_for_tool.clear() + self.current_tool_id = -1 + self.current_tool_name_sent = False + + def _ensure_tool_state(self, index: int) -> None: + """Grow vLLM streaming state arrays to contain ``index``.""" + while len(self.prev_tool_call_arr) <= index: + self.prev_tool_call_arr.append({}) + while len(self.streamed_args_for_tool) <= index: + self.streamed_args_for_tool.append("") + + def _record_delta( + self, index: int, name: str | None, arguments: str | None + ) -> str | None: + """Mirror a Rust parser delta into vLLM streaming bookkeeping. + + ``prev_tool_call_arr`` and ``streamed_args_for_tool`` are read later by + the chat serving layer to decide the final ``tool_calls`` finish reason + and to flush any remaining argument bytes. + """ + tool_call_id = None + self._ensure_tool_state(index) + + if name is not None: + # Prefer the model-emitted ID surfaced by the Rust parser (e.g. + # Kimi K2) over a randomly generated one. + tool_call_id = self._get_parser().tool_call_id(index) or make_tool_call_id() + self.prev_tool_call_arr[index] = {"name": name, "arguments": {}} + self.current_tool_name_sent = True + + if arguments is not None: + self.streamed_args_for_tool[index] += arguments + self.prev_tool_call_arr[index]["arguments"] = self.streamed_args_for_tool[ + index + ] + self.current_tool_id = index + + return tool_call_id + + def _delta_message_from_parser_output( + self, parser_output: Any | None + ) -> DeltaMessage | None: + """Translate one Rust parser output into a vLLM ``DeltaMessage``.""" + if parser_output is None: + return None + + normal_text = parser_output.normal_text or None + tool_calls: list[DeltaToolCall] = [] + for tool_call in parser_output.calls: + index = tool_call.tool_index + name = tool_call.name + arguments: str | None = tool_call.arguments + if name is None and arguments is None: + continue + + tool_call_id = self._record_delta(index, name, arguments) + tool_calls.append( + DeltaToolCall( + index=index, + id=tool_call_id, + type="function" if name is not None else None, + function=DeltaFunctionCall( + name=name, + arguments=arguments, + ), + ) + ) + + if normal_text is None and not tool_calls: + return None + return DeltaMessage(content=normal_text, tool_calls=tool_calls) + + def _parse_complete(self, model_output: str) -> tuple[Any, dict[int, str]] | None: + """Parse complete model output with a throwaway Rust parser instance. + + Returns the coalesced parser output along with any model-emitted tool + call IDs keyed by tool index. + """ + parser = self._new_parser() + output = _rust_tool_parser_module().ToolParserOutput() + try: + parser.parse_into(model_output, output) + # finish() clears parser state, so snapshot model-emitted IDs first. + tool_call_ids = { + call.tool_index: tool_call_id + for call in output.calls + if (tool_call_id := parser.tool_call_id(call.tool_index)) is not None + } + output.append(parser.finish()) + except Exception: + logger.exception( + "Error parsing %s tool call output.", self.rust_parser_name + ) + return None + return output.coalesce(), tool_call_ids + + def extract_tool_calls( + self, + model_output: str, + request: ChatCompletionRequest, + ) -> ExtractedToolCallInformation: + """Extract tool calls from complete model output (non-streaming).""" + if ( + self.tool_call_start_token is not None + and self.tool_call_start_token not in model_output + ): + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + parse_result = self._parse_complete(model_output) + if parse_result is None: + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + parsed, tool_call_ids = parse_result + + tool_calls: list[ToolCall] = [] + self.prev_tool_call_arr.clear() + for parsed_tool_call in parsed.calls: + name = parsed_tool_call.name + arguments = parsed_tool_call.arguments or "{}" + if name is None: + continue + tool_calls.append( + ToolCall( + id=tool_call_ids.get(parsed_tool_call.tool_index) + or make_tool_call_id(), + type="function", + function=FunctionCall(name=name, arguments=arguments), + ) + ) + self.prev_tool_call_arr.append({"name": name, "arguments": arguments}) + + if not tool_calls: + return ExtractedToolCallInformation( + tools_called=False, + tool_calls=[], + content=model_output, + ) + + content = parsed.normal_text or None + return ExtractedToolCallInformation( + tools_called=True, + tool_calls=tool_calls, + content=content, + ) + + def extract_tool_calls_streaming( + self, + previous_text: str, + current_text: str, + delta_text: str, + previous_token_ids: Sequence[int], # pylint: disable=unused-argument + current_token_ids: Sequence[int], # pylint: disable=unused-argument + delta_token_ids: Sequence[int], # pylint: disable=unused-argument + request: ChatCompletionRequest, # pylint: disable=unused-argument + ) -> DeltaMessage | None: + """Extract tool calls from streaming model output. + + The Rust parser owns the incremental buffer, so this adapter feeds only + the newest text delta and lets the serving layer handle final empty + chunks. + """ + # TODO: Add a final-chunk hook if streaming needs to call Rust finish(). + if not previous_text: + self._reset_streaming_state() + + if self._error is not None: + return None + + parser_output = _rust_tool_parser_module().ToolParserOutput() + try: + self._get_parser().parse_into(delta_text, parser_output) + except Exception as error: + self._error = error + logger.exception( + "Error parsing %s streaming tool call output.", + self.rust_parser_name, + ) + + delta_message = self._delta_message_from_parser_output(parser_output) + if delta_message is not None: + return delta_message + + return None diff --git a/vllm/tool_parsers/seed_oss_engine_tool_parser.py b/vllm/tool_parsers/seed_oss_engine_tool_parser.py new file mode 100644 index 000000000000..e708afd1710a --- /dev/null +++ b/vllm/tool_parsers/seed_oss_engine_tool_parser.py @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from vllm.parser.engine.registered_adapters import SeedOssParserToolAdapter + + +class SeedOssEngineToolParser(SeedOssParserToolAdapter): # type: ignore[valid-type, misc] + structural_tag_model = None diff --git a/vllm/tool_parsers/seed_oss_tool_parser.py b/vllm/tool_parsers/seed_oss_tool_parser.py deleted file mode 100644 index a90bdc76d9ed..000000000000 --- a/vllm/tool_parsers/seed_oss_tool_parser.py +++ /dev/null @@ -1,629 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -# Adapted from qwen3coder xml parser, All rights reserved. -# ruff: noqa: E501 - -import json -import uuid -from collections.abc import Sequence - -import regex as re - -from vllm.entrypoints.openai.chat_completion.protocol import ( - ChatCompletionRequest, -) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ExtractedToolCallInformation, - FunctionCall, - ToolCall, -) -from vllm.logger import init_logger -from vllm.tokenizers import TokenizerLike -from vllm.tool_parsers.abstract_tool_parser import ( - Tool, - ToolParser, -) -from vllm.tool_parsers.utils import ( - coerce_to_schema_type, - extract_types_from_schema, - find_tool_properties, -) - -logger = init_logger(__name__) - - -class SeedOssToolParser(ToolParser): - TOOL_CALL_START = "" - TOOL_CALL_END = "" - - def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None): - super().__init__(tokenizer, tools) - - # --- streaming state --- - self._reset_streaming_state() - self.prev_tool_call_arr: list[dict] = [] - - self.tool_call_start_token: str = self.TOOL_CALL_START - self.tool_call_end_token: str = self.TOOL_CALL_END - # Sentinel tokens for streaming mode - self.tool_call_prefix: str = " or its closing tag." - ) - - tool_start_re = re.escape(self.tool_call_start_token) - tool_end_re = re.escape(self.tool_call_end_token) - - self.tool_call_complete_regex = re.compile( - rf"{tool_start_re}(.*?){tool_end_re}", re.DOTALL - ) - self.tool_call_regex = re.compile( - rf"{tool_start_re}(.*?){tool_end_re}|{tool_start_re}(.*?)$", re.DOTALL - ) - - self.tool_call_function_regex = re.compile( - r"|| str: - """Generate a unique tool call ID.""" - return f"call_{uuid.uuid4().hex[:24]}" - - def _reset_streaming_state(self): - """Reset all streaming state.""" - self.current_tool_index = 0 - self.is_tool_call_started = False - self.header_sent = False - self.current_tool_id = -1 - self.current_function_name = None - self.current_param_name = None - self.current_param_value = "" - self.param_count = 0 - self.in_param = False - self.in_function = False - self.accumulated_text = "" - self.json_started = False - self.json_closed = False - - def _parse_xml_function_call( - self, function_call_str: str, tools: list[Tool] | None - ) -> ToolCall | None: - # Extract function name - end_index = function_call_str.index(">") - function_name = function_call_str[:end_index] - tool_properties = find_tool_properties(tools, function_name) - parameters = function_call_str[end_index + 1 :] - param_dict = {} - for match in self.tool_call_parameter_regex.findall(parameters): - match_text = match[0] if match[0] else match[1] - idx = match_text.index(">") - param_name = match_text[:idx] - param_value = str(match_text[idx + 1 :]) - # Remove prefix and trailing \n - if param_value.startswith("\n"): - param_value = param_value[1:] - if param_value.endswith("\n"): - param_value = param_value[:-1] - - param_types = extract_types_from_schema(tool_properties.get(param_name, {})) - param_dict[param_name] = coerce_to_schema_type(param_value, param_types) - return ToolCall( - type="function", - function=FunctionCall( - name=function_name, arguments=json.dumps(param_dict, ensure_ascii=False) - ), - ) - - def _get_function_calls(self, model_output: str) -> list[str]: - # Find all tool calls - matched_ranges = self.tool_call_regex.findall(model_output) - raw_tool_calls = [ - match[0] if match[0] else match[1] for match in matched_ranges - ] - - # Back-off strategy if no tool_call tags found - if len(raw_tool_calls) == 0: - raw_tool_calls = [model_output] - - raw_function_calls = [] - for tool_call in raw_tool_calls: - raw_function_calls.extend(self.tool_call_function_regex.findall(tool_call)) - - function_calls = [ - match[0] if match[0] else match[1] for match in raw_function_calls - ] - return function_calls - - def extract_tool_calls( - self, - model_output: str, - request: ChatCompletionRequest, - ) -> ExtractedToolCallInformation: - # Quick check to avoid unnecessary processing - if self.tool_call_prefix not in model_output: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - # Check if both think start and end tokens are present - if ( - self.think_start_token in model_output - and self.think_end_token in model_output - ): - # Find the position of think end token - think_end_index = model_output.find(self.think_end_token) + len( - self.think_end_token - ) - # Extract content after think end token - result_content = model_output[think_end_index:] - thinking_content = model_output[:think_end_index] - else: - thinking_content = "" - result_content = model_output - - try: - function_calls = self._get_function_calls(result_content) - if len(function_calls) == 0: - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - tool_calls = [ - self._parse_xml_function_call(function_call_str, self.tools) - for function_call_str in function_calls - ] - - # Populate prev_tool_call_arr for serving layer to set finish_reason - self.prev_tool_call_arr.clear() # Clear previous calls - for tool_call in tool_calls: - if tool_call: - self.prev_tool_call_arr.append( - { - "name": tool_call.function.name, - "arguments": tool_call.function.arguments, - } - ) - - # Extract content before tool calls - tool_call_start_index = result_content.find(self.tool_call_start_token) - tool_call_start_index = ( - tool_call_start_index - if tool_call_start_index >= 0 - else result_content.find(self.tool_call_prefix) - ) - content = thinking_content + result_content[:tool_call_start_index] - - return ExtractedToolCallInformation( - tools_called=(len(tool_calls) > 0), - tool_calls=tool_calls, - content=content if content else None, - ) - - except Exception: - logger.exception("Error in extracting tool call from response.") - return ExtractedToolCallInformation( - tools_called=False, tool_calls=[], content=model_output - ) - - def extract_tool_calls_streaming( - self, - previous_text: str, - current_text: str, - delta_text: str, - previous_token_ids: Sequence[int], - current_token_ids: Sequence[int], - delta_token_ids: Sequence[int], - request: ChatCompletionRequest, - ) -> DeltaMessage | None: - # If no delta text, return None unless - # it's an EOS token after tool calls - if not delta_text: - # Check if this is an EOS token after all tool calls are complete - # We check for tool calls in the text even if is_tool_call_started - # is False because it might have been reset after processing all tools - if delta_token_ids and self.tool_call_end_token_id not in delta_token_ids: - # Count complete tool calls - complete_calls = len( - self.tool_call_complete_regex.findall(current_text) - ) - - # If we have completed tool calls and populated prev_tool_call_arr - if complete_calls > 0 and len(self.prev_tool_call_arr) > 0: - # Check if all tool calls are closed - open_calls = current_text.count( - self.tool_call_start_token - ) - current_text.count(self.tool_call_end_token) - if open_calls == 0: - # Return empty delta message to allow finish_reason processing - return DeltaMessage(content="") - elif not self.is_tool_call_started and current_text: - # This is a regular content response that's now complete - return DeltaMessage(content="") - return None - - # Check if this is the first call (reset state if needed) - if not previous_text: - self._reset_streaming_state() - - # Update accumulated text - self.accumulated_text = current_text - - # Check if we need to advance to next tool - if self.json_closed and not self.in_function: - # Check if this tool call has ended - tool_ends = current_text.count(self.tool_call_end_token) - if tool_ends > self.current_tool_index: - # This tool has ended, advance to next - self.current_tool_index += 1 - self.header_sent = False - self.param_count = 0 - self.json_started = False - self.json_closed = False - - # Check if there are more tool calls - if self.current_tool_index >= current_text.count( - self.tool_call_start_token - ): - # No more tool calls - self.is_tool_call_started = False - # Continue processing next tool - return None - - # Check if end thinking - if not self.is_thinking_end and ( - self.think_end_token_id in delta_token_ids - or self.think_end_token in delta_text - ): - self.is_thinking_end = True - - # If thinking hasn't ended yet, don't process any tool calls - if not self.is_thinking_end: - return DeltaMessage(content=delta_text) - - # Handle normal content before tool calls - if not self.is_tool_call_started: - # Check if tool call is starting - if ( - self.tool_call_start_token_id in delta_token_ids - or self.tool_call_start_token in delta_text - ): - self.is_tool_call_started = True - # Return any content before the tool call - if self.tool_call_start_token in delta_text: - content_before = delta_text[ - : delta_text.index(self.tool_call_start_token) - ] - if content_before: - return DeltaMessage(content=content_before) - return None - else: - # Check if we're between tool calls - skip whitespace - if ( - current_text.rstrip().endswith(self.tool_call_end_token) - and delta_text.strip() == "" - ): - # We just ended a tool call, skip whitespace - return None - # Normal content, no tool call - return DeltaMessage(content=delta_text) - - # Check if we're between tool calls (waiting for next one) - # Count tool calls we've seen vs processed - tool_starts_count = current_text.count(self.tool_call_start_token) - if self.current_tool_index >= tool_starts_count: - # We're past all tool calls, shouldn't be here - return None - - # We're in a tool call, find the current tool call portion - # Need to find the correct tool call based on current_tool_index - # Only process tool calls after think_end_token - think_end_index = ( - current_text.find(self.think_end_token) + len(self.think_end_token) - if self.think_end_token in current_text - else 0 - ) - tool_starts: list[int] = [] - idx = think_end_index - while True: - idx = current_text.find(self.tool_call_start_token, idx) - if idx == -1: - break - tool_starts.append(idx) - idx += len(self.tool_call_start_token) - - if self.current_tool_index >= len(tool_starts): - # No more tool calls to process yet - return None - - tool_start_idx = tool_starts[self.current_tool_index] - # Find where this tool call ends (or current position if not ended yet) - tool_end_idx = current_text.find(self.tool_call_end_token, tool_start_idx) - if tool_end_idx == -1: - tool_text = current_text[tool_start_idx:] - else: - tool_text = current_text[ - tool_start_idx : tool_end_idx + len(self.tool_call_end_token) - ] - - # Looking for function header - if not self.header_sent: - if self.tool_call_prefix in tool_text: - func_start = tool_text.find(self.tool_call_prefix) + len( - self.tool_call_prefix - ) - func_end = tool_text.find(">", func_start) - - if func_end != -1: - # Found complete function name - self.current_function_name = tool_text[func_start:func_end] - self.current_tool_id = self._generate_tool_call_id() # type: ignore - self.header_sent = True - self.in_function = True - - # IMPORTANT: Add to prev_tool_call_arr immediately when we detect a tool call - # This ensures finish_reason="tool_calls" even if parsing isn't complete - already_added = any( - tool.get("name") == self.current_function_name - for tool in self.prev_tool_call_arr - ) - if not already_added: - self.prev_tool_call_arr.append( - { - "name": self.current_function_name, - "arguments": "{}", # Placeholder, will be updated later - } - ) - - # Send header with function info - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - id=self.current_tool_id, - function=DeltaFunctionCall( - name=self.current_function_name, arguments="" - ), - type="function", - ) - ] - ) - return None - - # We've sent header, now handle function body - if self.in_function: - # Send opening brace if not sent yet - if not self.json_started and self.parameter_prefix not in delta_text: - self.json_started = True - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall(arguments="{"), - ) - ] - ) - - # Make sure json_started is set if we're processing parameters - if not self.json_started: - self.json_started = True - - # Check for function end in accumulated text - if not self.json_closed and self.function_end_token in tool_text: - # Close JSON - self.json_closed = True - - # Extract the complete tool call to update prev_tool_call_arr with final arguments - # Find the function content - func_start = tool_text.find(self.tool_call_prefix) + len( - self.tool_call_prefix - ) - func_content_end = tool_text.find(self.function_end_token, func_start) - if func_content_end != -1: - func_content = tool_text[func_start:func_content_end] - # Parse to get the complete arguments - try: - parsed_tool = self._parse_xml_function_call( - func_content, self.tools - ) - if parsed_tool: - # Update existing entry in prev_tool_call_arr with complete arguments - for i, tool in enumerate(self.prev_tool_call_arr): - if tool.get("name") == parsed_tool.function.name: - self.prev_tool_call_arr[i]["arguments"] = ( - parsed_tool.function.arguments - ) - break - except Exception: - logger.warning( - "Failed to parse tool arguments during streaming.", - exc_info=True, - ) - - result = DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall(arguments="}"), - ) - ] - ) - - # Reset state for next tool - self.in_function = False - self.json_closed = True - - return result - - # Look for parameters - # Count how many complete parameters we have processed - complete_params = tool_text.count(self.parameter_end_token) - - # Check if we should start a new parameter - if not self.in_param and self.param_count < complete_params: - # Find the unprocessed parameter - # Count parameter starts - param_starts = [] - idx = 0 - while True: - idx = tool_text.find(self.parameter_prefix, idx) - if idx == -1: - break - param_starts.append(idx) - idx += len(self.parameter_prefix) - - if len(param_starts) > self.param_count: - # Process the next parameter - param_idx = param_starts[self.param_count] - param_start = param_idx + len(self.parameter_prefix) - remaining = tool_text[param_start:] - - if ">" in remaining: - # We have the complete parameter name - name_end = remaining.find(">") - self.current_param_name = remaining[:name_end] - - # Find the parameter value - value_start = param_start + name_end + 1 - value_text = tool_text[value_start:] - if value_text.startswith("\n"): - value_text = value_text[1:] - - # Find where this parameter ends - param_end_idx = value_text.find(self.parameter_end_token) - if param_end_idx != -1: - # Complete parameter found - param_value = value_text[:param_end_idx] - if param_value.endswith("\n"): - param_value = param_value[:-1] - - # Build complete JSON fragment for this parameter - if self.param_count == 0: - json_fragment = ( - '"' - + self.current_param_name - + '": "' - + json.dumps(param_value)[1:-1] - + '"' - ) - else: - json_fragment = ( - ', "' - + self.current_param_name - + '": "' - + json.dumps(param_value)[1:-1] - + '"' - ) - - self.param_count += 1 - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall( - arguments=json_fragment - ), - ) - ] - ) - - # Continue parameter value - if self.in_param: - if self.parameter_end_token in delta_text: - # End of parameter - end_idx = delta_text.find(self.parameter_end_token) - value_chunk = delta_text[:end_idx] - - # Skip past > if at start - if not self.current_param_value and ">" in value_chunk: - gt_idx = value_chunk.find(">") - value_chunk = value_chunk[gt_idx + 1 :] - - if not self.current_param_value and value_chunk.startswith("\n"): - value_chunk = value_chunk[1:] - - # Calculate incremental JSON - full_value = self.current_param_value + value_chunk - prev_escaped = ( - json.dumps(self.current_param_value)[1:-1] - if self.current_param_value - else "" - ) - full_escaped = json.dumps(full_value)[1:-1] - delta_escaped = full_escaped[len(prev_escaped) :] - - self.in_param = False - self.current_param_value = "" - - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall( - arguments=delta_escaped + '"' - ), - ) - ] - ) - else: - # Continue accumulating value - value_chunk = delta_text - - # Handle first chunk after param name - if not self.current_param_value and ">" in value_chunk: - gt_idx = value_chunk.find(">") - value_chunk = value_chunk[gt_idx + 1 :] - - if not self.current_param_value and value_chunk.startswith("\n"): - value_chunk = value_chunk[1:] - - if value_chunk: - # Stream the escaped delta - prev_escaped = ( - json.dumps(self.current_param_value)[1:-1] - if self.current_param_value - else "" - ) - self.current_param_value += value_chunk - full_escaped = json.dumps(self.current_param_value)[1:-1] - delta_escaped = full_escaped[len(prev_escaped) :] - - if delta_escaped: - return DeltaMessage( - tool_calls=[ - DeltaToolCall( - index=self.current_tool_index, - function=DeltaFunctionCall( - arguments=delta_escaped - ), - ) - ] - ) - - return None diff --git a/vllm/tool_parsers/streaming.py b/vllm/tool_parsers/streaming.py index 7f6638dcb94e..5ee7c6f6c291 100644 --- a/vllm/tool_parsers/streaming.py +++ b/vllm/tool_parsers/streaming.py @@ -14,7 +14,6 @@ DeltaMessage, DeltaToolCall, ) -from vllm.tool_parsers.mistral_tool_parser import MistralToolCall from vllm.tool_parsers.utils import partial_json_loads from vllm.utils.mistral import is_mistral_tokenizer @@ -24,14 +23,33 @@ TokenizerLike = object -def _bracket_level(s: str, opening: str = "{", closing: str = "}") -> int: - """Calculate the current level of nested brackets in a string.""" +def _bracket_level_state( + s: str, opening: str = "{", closing: str = "}" +) -> tuple[int, bool, bool]: level = 0 + in_string = False + escaped = False for char in s: - if char == opening: - level += 1 - elif char == closing: - level -= 1 + if escaped: + escaped = False + continue + if in_string and char == "\\": + escaped = True + continue + if char == '"': + in_string = not in_string + continue + if not in_string: + if char == opening: + level += 1 + elif char == closing: + level -= 1 + return level, in_string, escaped + + +def _bracket_level(s: str, opening: str = "{", closing: str = "}") -> int: + """Calculate the current level of nested brackets in a string.""" + level, _, _ = _bracket_level_state(s, opening, closing) return level @@ -40,11 +58,20 @@ def filter_delta_text( previous_text: str, ) -> tuple[str, bool]: """Trim trailing tool-list delimiters from required-tool streaming text.""" - bracket_level = _bracket_level(previous_text) + bracket_level, in_string, escaped = _bracket_level_state(previous_text) updated_delta = "" passed_zero = False for char in delta_text: - if char == "{": + if escaped: + escaped = False + elif in_string: + if char == "\\": + escaped = True + elif char == '"': + in_string = False + elif char == '"': + in_string = True + elif char == "{": bracket_level += 1 passed_zero = bracket_level == 0 elif char == "}": @@ -54,7 +81,7 @@ def filter_delta_text( if bracket_level != 0: updated_delta += char else: - if char == ",": + if not in_string and char == ",": break return updated_delta, passed_zero @@ -77,6 +104,9 @@ def extract_named_tool_call_streaming( ) else: if is_mistral_tokenizer(tokenizer): + # Import mistral_common only if we need it. + from vllm.tool_parsers.mistral_tool_parser import MistralToolCall + tool_call_id = MistralToolCall.generate_random_id() else: tool_call_id = make_tool_call_id( @@ -144,8 +174,12 @@ def extract_required_tool_call_streaming( param_match = re.search( r'.*"parameters":\s*(.*)', current_text, re.DOTALL ) - arguments = param_match.group(1) if param_match else "" - arguments, _ = filter_delta_text(arguments, previous_text) + if param_match: + arguments = param_match.group(1) + arguments_prefix = current_text[: param_match.start(1)] + arguments, _ = filter_delta_text(arguments, arguments_prefix) + else: + arguments = "" # if this iteration finishes a previous tool call but a # new incomplete tool is already generated, take the diff --git a/vllm/tool_parsers/structural_tag_registry.py b/vllm/tool_parsers/structural_tag_registry.py index 754cc52361c5..99c92f8f0a2e 100644 --- a/vllm/tool_parsers/structural_tag_registry.py +++ b/vllm/tool_parsers/structural_tag_registry.py @@ -1,14 +1,20 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -# Model-specific structural tag builders adapted from XGrammar's -# builtin structural tag implementations: -# https://github.com/mlc-ai/xgrammar/blob/main/python/xgrammar/builtin_structural_tag.py - -from collections.abc import Callable -from typing import Any, Literal - -from xgrammar import StructuralTag +from collections.abc import Callable, Sequence +from typing import Any, Literal, TypeAlias + +from openai.types.responses import FunctionTool +from openai.types.responses.response import ToolChoice as ResponsesToolChoice +from openai.types.responses.tool import Tool as ResponsesTool +from openai.types.responses.tool_choice_allowed import ToolChoiceAllowed +from openai.types.responses.tool_choice_function import ToolChoiceFunction +from xgrammar import StructuralTag, normalize_tool_choice +from xgrammar import get_model_structural_tag as get_xgrammar_model_structural_tag +from xgrammar.openai_tool_call_schema import ( + BuiltinToolParam, + FunctionToolParam, +) from xgrammar.structural_tag import ( AnyTextFormat, ConstStringFormat, @@ -24,307 +30,318 @@ ChatCompletionToolsParam, ) -SimplifiedToolChoice = Literal["auto", "required", "forced"] -ToolChoice = ( - Literal["none", "auto", "required"] | ChatCompletionNamedToolChoiceParam | None +ToolChoice: TypeAlias = ( + Literal["none", "auto", "required"] + | ChatCompletionNamedToolChoiceParam + | ResponsesToolChoice + | None ) -StructuralTagBuilder = Callable[ - [list[ChatCompletionToolsParam], SimplifiedToolChoice, bool], +AllowedToolRef: TypeAlias = dict[str, object] +SimplifiedToolChoice: TypeAlias = Literal["auto", "required", "forced"] +StructuralTagBuilder: TypeAlias = Callable[ + [ + list[FunctionToolParam], + list[BuiltinToolParam], + SimplifiedToolChoice, + bool, + ], StructuralTag, ] -_structural_tag_registry: dict[str, StructuralTagBuilder] = {} +# Keep this list in sync with xgrammar.builtin_structural_tag. It is used for +# vLLM-side validation and for documenting the xgrammar builtin surface that +# can be requested by tool parsers through ``structural_tag_model``. +XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset( + { + "llama", + "kimi", + "deepseek_r1", + "deepseek_v3_1", + "qwen_3_5", + "qwen_3_coder", + "qwen_3", + "harmony", + "deepseek_v3_2", + "glm_4_7", + "deepseek_v4", + } +) +VLLM_BUILTIN_STRUCTURAL_TAG_MODELS = frozenset({"hermes"}) +SUPPORTED_STRUCTURAL_TAG_MODELS = ( + XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS | VLLM_BUILTIN_STRUCTURAL_TAG_MODELS +) + +_VLLM_STRUCTURAL_TAG_REGISTRY: dict[str, StructuralTagBuilder] = {} -def register_model_structural_tag(name: str): - """Register a vLLM-owned model-specific structural tag builder.""" +def register_vllm_structural_tag(model: str): + """Register a vLLM-owned structural tag builder.""" def decorator(func: StructuralTagBuilder) -> StructuralTagBuilder: - _structural_tag_registry[name] = func + _VLLM_STRUCTURAL_TAG_REGISTRY[model] = func return func return decorator +def _any_tool_strict( + tools: Sequence[ChatCompletionToolsParam | ResponsesTool], +) -> bool: + for tool in tools: + if isinstance(tool, FunctionTool) and tool.strict is True: + return True + if isinstance(tool, ChatCompletionToolsParam) and tool.function.strict is True: + return True + return False + + def get_model_structural_tag( model: str, - tools: list[ChatCompletionToolsParam] | None, + tools: Sequence[ChatCompletionToolsParam | ResponsesTool] | None, tool_choice: ToolChoice, reasoning: bool, ) -> StructuralTag | None: - """Build a structural tag from vLLM-owned model-specific builders.""" + """Build a structural tag with xgrammar's builtin model templates.""" + + if not tools or tool_choice == "none": + return None + + if tool_choice == "auto" and not _any_tool_strict(tools): + return None - builder = _structural_tag_registry.get(model) - if builder is None: - supported = list(_structural_tag_registry.keys()) + dumped_tools = [_dump_tool_for_xgrammar(tool) for tool in tools] + dumped_tool_choice = _dump_tool_choice_for_xgrammar(tool_choice) + + if model in _VLLM_STRUCTURAL_TAG_REGISTRY: + function_tools, builtin_tools, simplified_tool_choice = normalize_tool_choice( + dumped_tools, + dumped_tool_choice, + ) + return _VLLM_STRUCTURAL_TAG_REGISTRY[model]( + function_tools, + builtin_tools, + simplified_tool_choice, + reasoning, + ) + + if model not in XGRAMMAR_BUILTIN_STRUCTURAL_TAG_MODELS: + supported = sorted(SUPPORTED_STRUCTURAL_TAG_MODELS) raise ValueError(f"Unknown format type: {model}, supported types: {supported}") - normalized_tools, simplified_tool_choice = _normalize_tool_choice( - tools=tools, - tool_choice=tool_choice, + return get_xgrammar_model_structural_tag( + model=model, + tools=dumped_tools, + tool_choice=dumped_tool_choice, + reasoning=reasoning, ) - if not normalized_tools: - return None - return builder(normalized_tools, simplified_tool_choice, reasoning) +def _dump_tool_for_xgrammar( + tool: ChatCompletionToolsParam | ResponsesTool, +) -> dict[str, Any]: + """Convert tool objects to xgrammar's Chat Completions tool protocol.""" -def _normalize_tool_choice( - tools: list[ChatCompletionToolsParam] | None, - tool_choice: ToolChoice, -) -> tuple[list[ChatCompletionToolsParam], SimplifiedToolChoice]: - """Normalize vLLM ChatCompletion tool_choice for structural tag builders.""" + if isinstance(tool, FunctionTool): + function: dict[str, Any] = {"name": tool.name} + if tool.description is not None: + function["description"] = tool.description + if tool.parameters is not None: + function["parameters"] = tool.parameters + if tool.strict is not None: + function["strict"] = tool.strict + return {"type": "function", "function": function} + dumped_tool = tool.model_dump(mode="json", exclude_none=True) + if isinstance(tool, ChatCompletionToolsParam): + return dumped_tool + return dict(dumped_tool) - if not tools: - return [], "auto" - if tool_choice is None or tool_choice == "none": - return [], "auto" +def _dump_tool_choice_for_xgrammar( + tool_choice: ToolChoice, +) -> dict[str, Any] | str | None: + """Convert tool_choice objects to xgrammar's expected protocol.""" - if tool_choice == "auto": - return tools, "auto" + if tool_choice is None: + return None - if tool_choice == "required": - return tools, "required" + if isinstance(tool_choice, str): + return tool_choice if isinstance(tool_choice, ChatCompletionNamedToolChoiceParam): - tool_name = tool_choice.function.name - filtered_tools = [tool for tool in tools if tool.function.name == tool_name] - if not filtered_tools: - raise ValueError( - f"The tool with name '{tool_name}' is not found in the tools list." - ) - return filtered_tools, "forced" + return tool_choice.model_dump(mode="json", exclude_none=True) + + if isinstance(tool_choice, ToolChoiceFunction): + return { + "type": "function", + "function": {"name": tool_choice.name}, + } + + if isinstance(tool_choice, ToolChoiceAllowed): + return { + "type": "allowed_tools", + "allowed_tools": { + "mode": tool_choice.mode, + "tools": [ + _dump_allowed_tool_ref_for_xgrammar(tool) + for tool in tool_choice.tools + ], + }, + } - raise ValueError(f"Unsupported tool_choice for structural tag: {tool_choice}") + return tool_choice.model_dump(mode="json", exclude_none=True) -def _get_function_parameters(function: Any) -> dict[str, Any] | bool: - """Return the JSON schema used for constrained tool arguments.""" +def _dump_allowed_tool_ref_for_xgrammar(tool_ref: AllowedToolRef) -> AllowedToolRef: + if ( + tool_ref.get("type") == "function" + and "function" not in tool_ref + and "name" in tool_ref + ): + return { + "type": "function", + "function": {"name": tool_ref["name"]}, + } + return tool_ref + +def _get_function_parameters(function) -> dict[str, Any] | bool: if getattr(function, "strict", None) is False: return True - if function.parameters is None: - return True - return function.parameters - - -_enable_structured_outputs_in_reasoning: bool = False - + return function.parameters if function.parameters is not None else True + + +def _hermes_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]: + arguments_field_prefix = '", "arguments": ' + formats = [ + # + # {"name": "t1", "arguments": {"q": "v"}} + # + ('\n{"name": "', "}\n"), + # {"name": "t1", "arguments": {"q": "v"}} + ('{"name": "', "}"), + ] + + return [ + TagFormat( + begin=begin + tool.function.name + arguments_field_prefix, + content=JSONSchemaFormat( + json_schema=_get_function_parameters(tool.function) + ), + end=end, + ) + for tool in tools + for begin, end in formats + ] -def set_enable_structured_outputs_in_reasoning(enabled: bool) -> None: - """Publish the engine's ``enable_in_reasoning`` flag to tool parsers. - Called once during APIServer startup so request-time parsers can read - it without going through the EngineCore-only contextvar. - """ +@register_vllm_structural_tag("hermes") +def get_hermes_structural_tag( + tools: list[FunctionToolParam], + builtin_tools: list[BuiltinToolParam], + tool_choice: SimplifiedToolChoice, + reasoning: bool, +) -> StructuralTag: + del builtin_tools, reasoning - global _enable_structured_outputs_in_reasoning - _enable_structured_outputs_in_reasoning = bool(enabled) + tool_call_trigger = "" + if tool_choice == "auto": + tags = _hermes_tool_tags(tools) + suffix_tag = ( + TriggeredTagsFormat(triggers=[tool_call_trigger], tags=tags) + if tags + else AnyTextFormat() + ) + elif tool_choice == "forced": + suffix_tag = TagsWithSeparatorFormat( + tags=_hermes_tool_tags(tools), + separator="", + at_least_one=True, + stop_after_first=True, + ) + else: + suffix_tag = TagsWithSeparatorFormat( + tags=_hermes_tool_tags(tools), + separator="", + at_least_one=True, + ) -def get_enable_structured_outputs_in_reasoning() -> bool: - """Whether structured outputs are active during the reasoning phase. + return StructuralTag(format=suffix_tag) - When ``True``, the structural tag will cover the reasoning part: - ``...`` prefix (if available); when ``False`` (default), the tag only - constrains the post-reasoning suffix. - """ - return _enable_structured_outputs_in_reasoning +def _minimax_tool_tags(tools: list[FunctionToolParam]) -> list[TagFormat]: + return [ + TagFormat( + begin=f'\n', + content=JSONSchemaFormat( + json_schema=_get_function_parameters(tool.function), + style="minimax_xml", + ), + end="\n", + ) + for tool in tools + ] -@register_model_structural_tag("deepseek_v4") -def get_deepseek_v4_structural_tag( - tools: list[ChatCompletionToolsParam], +@register_vllm_structural_tag("minimax") +def get_minimax_structural_tag( + tools: list[FunctionToolParam], + builtin_tools: list[BuiltinToolParam], tool_choice: SimplifiedToolChoice, reasoning: bool, ) -> StructuralTag: - """Build DeepSeek V4 structural tags.""" - - invoke_begin_prefix = '<|DSML|invoke name="' - invoke_begin_suffix = '">\n' - invoke_end = "\n" - tool_calls_prefix = "\n\n" - function_calls_begin = "<|DSML|tool_calls>\n" - function_calls_end = "" - function_calls_trigger = "<|DSML|tool_calls>" - think_tag_end = "" - think_exclude_tokens = ["", ""] - xml_style = "deepseek_xml" + del builtin_tools, reasoning - if tool_choice == "auto": - tags = [] - for tool in tools: - function = tool.function - parameters = _get_function_parameters(function) - tags.append( - TagFormat( - begin=invoke_begin_prefix + function.name + invoke_begin_suffix, - content=JSONSchemaFormat( - json_schema=parameters, - style=xml_style, - ), - end=invoke_end, - ) - ) + tool_call_begin = "\n" + tool_call_end = "" + tool_call_trigger = "" - if tags: - function_calling_tags = TagsWithSeparatorFormat( - tags=tags, - separator="\n", - at_least_one=True, - ) - suffix_tag = TriggeredTagsFormat( - triggers=[function_calls_trigger], + tags = _minimax_tool_tags(tools) + + if tool_choice == "auto": + suffix_tag = ( + TriggeredTagsFormat( + triggers=[tool_call_trigger], tags=[ TagFormat( - begin=function_calls_begin, - content=function_calling_tags, - end=function_calls_end, + begin=tool_call_begin, + content=TagsWithSeparatorFormat( + tags=tags, + separator="", + at_least_one=True, + ), + end=tool_call_end, ) ], - excludes=think_exclude_tokens, + excludes=["", ""], ) - else: - suffix_tag = AnyTextFormat(excludes=think_exclude_tokens) - + if tags + else AnyTextFormat(excludes=["", ""]) + ) elif tool_choice == "forced": - if not tools: - raise ValueError("Forced tool choice must resolve to exactly one tool.") - function = tools[0].function suffix_tag = SequenceFormat( elements=[ - ConstStringFormat(value=tool_calls_prefix + function_calls_begin), - TagFormat( - begin=invoke_begin_prefix + function.name + invoke_begin_suffix, - content=JSONSchemaFormat( - json_schema=_get_function_parameters(function), - style=xml_style, - ), - end=invoke_end, + ConstStringFormat(value="\n" + tool_call_begin), + TagsWithSeparatorFormat( + tags=tags, + separator="", + at_least_one=True, + stop_after_first=True, ), - ConstStringFormat(value=function_calls_end), + ConstStringFormat(value=tool_call_end), ] ) - - elif tool_choice == "required": - tags = [] - for tool in tools: - function = tool.function - parameters = _get_function_parameters(function) - tags.append( - TagFormat( - begin=invoke_begin_prefix + function.name + invoke_begin_suffix, - content=JSONSchemaFormat( - json_schema=parameters, - style=xml_style, - ), - end=invoke_end, - ) - ) - assert len(tags) > 0 + else: suffix_tag = SequenceFormat( elements=[ - ConstStringFormat(value=tool_calls_prefix + function_calls_begin), + ConstStringFormat(value="\n" + tool_call_begin), TagsWithSeparatorFormat( tags=tags, - separator="\n", + separator="", at_least_one=True, ), - ConstStringFormat(value=function_calls_end), - ] - ) - - if not reasoning: - return StructuralTag(format=suffix_tag) - - prefix_tag = TagFormat(begin="", content=AnyTextFormat(), end=think_tag_end) - return StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag])) - - -@register_model_structural_tag("qwen_3_5") -def get_qwen_3_5_structural_tag( - tools: list[ChatCompletionToolsParam], - tool_choice: SimplifiedToolChoice, - reasoning: bool, -) -> StructuralTag: - """Build Qwen XML structural tags. - - This format is used for Qwen3-Coder/Qwen3.5/Qwen3.6 and is compatible with - Qwen variants that use the same XML tool-call format. - """ - tool_call_begin_prefix = "\n", ""] - - if tool_choice == "auto": - tags = [] - for tool in tools: - function = tool.function - parameters = _get_function_parameters(function) - tags.append( - TagFormat( - begin=f"{tool_call_begin_prefix}{function.name}{tool_call_begin_suffix}", - content=JSONSchemaFormat(json_schema=parameters, style="qwen_xml"), - end=tool_call_end, - ) - ) - - if tags: - suffix_tag = TriggeredTagsFormat( - triggers=[tool_call_trigger], - tags=tags, - excludes=think_exclude_tokens, - ) - else: - suffix_tag = AnyTextFormat(excludes=think_exclude_tokens) - - elif tool_choice == "forced": - if not tools: - raise ValueError("Forced tool choice must resolve to exactly one tool.") - function = tools[0].function - suffix_tag = TagFormat( - begin=f"{tool_call_begin_prefix}{function.name}{tool_call_begin_suffix}", - content=JSONSchemaFormat( - json_schema=_get_function_parameters(function), - style="qwen_xml", - ), - end=tool_call_end, - ) - - elif tool_choice == "required": - tags = [] - for tool in tools: - function = tool.function - parameters = _get_function_parameters(function) - tags.append( - TagFormat( - begin=f"{tool_call_begin_prefix}{function.name}{tool_call_begin_suffix}", - content=JSONSchemaFormat(json_schema=parameters, style="qwen_xml"), - end=tool_call_end, - ) - ) - assert len(tags) > 0 - suffix_tag = TagsWithSeparatorFormat( - tags=tags, - separator="", - at_least_one=True, - ) - - if not reasoning: - result = StructuralTag(format=suffix_tag) - else: - prefix_tag = SequenceFormat( - elements=[ - TagFormat(begin="", content=AnyTextFormat(), end=think_tag_end), - ConstStringFormat(value=think_suffix), + ConstStringFormat(value=tool_call_end), ] ) - result = StructuralTag(format=SequenceFormat(elements=[prefix_tag, suffix_tag])) - return result + return StructuralTag(format=suffix_tag) diff --git a/vllm/tool_parsers/utils.py b/vllm/tool_parsers/utils.py index 6ee107433c54..95769bafd7f3 100644 --- a/vllm/tool_parsers/utils.py +++ b/vllm/tool_parsers/utils.py @@ -3,13 +3,16 @@ import ast import json +import math import warnings +from dataclasses import dataclass from json import JSONDecodeError, JSONDecoder from typing import Any, TypeAlias import partial_json_parser from openai.types.responses import ( FunctionTool, + NamespaceTool, ToolChoiceFunction, ) from openai.types.responses.tool import Tool as ResponsesTool @@ -145,12 +148,115 @@ def is_complete_json(input_str: str) -> bool: return False +def _is_json_finite(obj: Any) -> bool: + """Whether *obj* can be serialized to valid JSON. + + ``json.dumps(..., allow_nan=False)`` raises ``ValueError`` on any + non-finite float (``inf``/``-inf``/``nan``) anywhere in the value, so this + detects non-finite floats nested inside parsed lists/dicts too. + """ + try: + json.dumps(obj, allow_nan=False) + return True + except (ValueError, TypeError): + return False + + def consume_space(i: int, s: str) -> int: while i < len(s) and s[i].isspace(): i += 1 return i +_NAMESPACE_TOOL_SEPARATOR = "__" + + +@dataclass(frozen=True) +class ResponsesToolCallName: + name: str + namespace: str | None = None + + +def flat_namespace_tool_name(namespace: str, name: str) -> str: + return f"{namespace}{_NAMESPACE_TOOL_SEPARATOR}{name}" + + +def iter_response_function_tool_info( + tool: ResponsesTool, +) -> list[tuple[str, dict[str, Any] | None]]: + if isinstance(tool, FunctionTool): + return [(tool.name, tool.parameters)] + if not isinstance(tool, NamespaceTool): + return [] + + namespace = tool.name + return [ + ( + flat_namespace_tool_name(namespace, namespaced_tool.name), + namespaced_tool.parameters, + ) + for namespaced_tool in tool.tools + if namespaced_tool.type == "function" + ] + + +def iter_response_function_tool_dicts( + tools: list[ResponsesTool], +) -> list[dict[str, Any]]: + function_tools: list[dict[str, Any]] = [] + for tool in tools: + if isinstance(tool, NamespaceTool): + namespace = tool.name + for namespaced_tool in tool.tools: + if namespaced_tool.type != "function": + continue + tool_dict = namespaced_tool.model_dump() + tool_dict["name"] = flat_namespace_tool_name( + namespace, namespaced_tool.name + ) + function_tools.append(tool_dict) + else: + function_tools.append(tool.model_dump()) + return function_tools + + +def build_responses_tool_call_name_map( + tools: list[ResponsesTool] | None, +) -> dict[str, ResponsesToolCallName]: + if not tools: + return {} + + name_map: dict[str, ResponsesToolCallName] = {} + for tool in tools: + if not isinstance(tool, NamespaceTool): + continue + namespace = tool.name + for namespaced_tool in tool.tools: + if namespaced_tool.type != "function": + continue + flat_name = flat_namespace_tool_name(namespace, namespaced_tool.name) + name_map[flat_name] = ResponsesToolCallName( + name=namespaced_tool.name, + namespace=namespace, + ) + return name_map + + +def resolve_responses_tool_call_name( + name: str, + tools: list[ResponsesTool] | None = None, + tool_call_name_map: dict[str, ResponsesToolCallName] | None = None, +) -> ResponsesToolCallName: + name_map = tool_call_name_map + if name_map is None: + name_map = build_responses_tool_call_name_map(tools) + return name_map.get(name, ResponsesToolCallName(name=name)) + + +def _is_function_tool(tool: Tool) -> bool: + return isinstance(tool, (FunctionTool, ChatCompletionToolsParam)) + + def _extract_tool_info( tool: Tool, ) -> tuple[str, dict[str, Any] | None]: @@ -170,14 +276,43 @@ def find_tool_properties( if not tools: return {} for tool in tools: + if isinstance(tool, (FunctionTool, NamespaceTool)): + for name, params in iter_response_function_tool_info(tool): + if name == tool_name: + return (params or {}).get("properties", {}) + continue + if not _is_function_tool(tool): + continue name, params = _extract_tool_info(tool) if name == tool_name: return (params or {}).get("properties", {}) return {} -def _get_tool_schema_from_tool(tool: Tool) -> dict: - name, params = _extract_tool_info(tool) +def find_tool_name( + tools: list[Tool] | None, + tool_name: str, +) -> bool: + """Return whether a function tool with *tool_name* exists.""" + if not tools: + return False + for tool in tools: + if isinstance(tool, (FunctionTool, NamespaceTool)): + for name, _ in iter_response_function_tool_info(tool): + if name == tool_name: + return True + continue + if not _is_function_tool(tool): + continue + name, _ = _extract_tool_info(tool) + if name == tool_name: + return True + return False + + +def _get_tool_schema_from_name_and_params( + name: str, params: dict[str, Any] | None +) -> dict: params = params if params else {"type": "object", "properties": {}} return { "properties": { @@ -188,6 +323,11 @@ def _get_tool_schema_from_tool(tool: Tool) -> dict: } +def _get_tool_schema_from_tool(tool: Tool) -> dict: + name, params = _extract_tool_info(tool) + return _get_tool_schema_from_name_and_params(name, params) + + def _get_tool_schema_defs( tools: list[Tool], ) -> dict: @@ -210,15 +350,28 @@ def _get_tool_schema_defs( def _get_json_schema_from_tools( tools: list[Tool], ) -> dict: + fn_tool_schemas: list[dict[str, Any]] = [] + fn_tools: list[Tool] = [] + for tool in tools: + if isinstance(tool, (FunctionTool, NamespaceTool)): + fn_tool_schemas.extend( + _get_tool_schema_from_name_and_params(name, params) + for name, params in iter_response_function_tool_info(tool) + ) + if isinstance(tool, FunctionTool): + fn_tools.append(tool) + elif _is_function_tool(tool): + fn_tool_schemas.append(_get_tool_schema_from_tool(tool)) + fn_tools.append(tool) json_schema = { "type": "array", "minItems": 1, "items": { "type": "object", - "anyOf": [_get_tool_schema_from_tool(tool) for tool in tools], + "anyOf": fn_tool_schemas, }, } - json_schema_defs = _get_tool_schema_defs(tools) + json_schema_defs = _get_tool_schema_defs(fn_tools) if json_schema_defs: json_schema["$defs"] = json_schema_defs return json_schema @@ -236,23 +389,30 @@ def get_json_schema_from_tools( tool_choice, ToolChoiceFunction ): tool_name = tool_choice.name - tool_map = {tool.name: tool for tool in tools if isinstance(tool, FunctionTool)} - if tool_name not in tool_map: + responses_tool_map: dict[str, dict[str, Any] | None] = {} + for tool in tools: + if not isinstance(tool, (FunctionTool, NamespaceTool)): + continue + for name, params in iter_response_function_tool_info(tool): + responses_tool_map[name] = params + if "__" in name: + responses_tool_map.setdefault(name.rsplit("__", 1)[1], params) + if tool_name not in responses_tool_map: raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.") - return tool_map[tool_name].parameters + return responses_tool_map[tool_name] # tool_choice: Forced Function (ChatCompletion) if (not isinstance(tool_choice, str)) and isinstance( tool_choice, ChatCompletionNamedToolChoiceParam ): tool_name = tool_choice.function.name - tool_map = { + chat_tool_map: dict[str, ChatCompletionToolsParam] = { tool.function.name: tool for tool in tools if isinstance(tool, ChatCompletionToolsParam) } - if tool_name not in tool_map: + if tool_name not in chat_tool_map: raise ValueError(f"Tool '{tool_name}' has not been passed in `tools`.") - return tool_map[tool_name].function.parameters + return chat_tool_map[tool_name].function.parameters # tool_choice: "required" if tool_choice == "required": return _get_json_schema_from_tools(tools) @@ -578,9 +738,15 @@ def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any: if candidate_type == "number": try: val = float(value) - return val if val != int(val) else int(val) except (ValueError, TypeError): continue + if not math.isfinite(val): + # inf/-inf/nan are not valid JSON numbers. Fall through so + # the value is preserved as a string instead of crashing + # (int(float("inf")) raises OverflowError) or emitting + # invalid JSON (json.dumps(inf) -> "Infinity"). + continue + return val if val != int(val) else int(val) if candidate_type == "boolean": lower_val = value.lower().strip() if lower_val in ("true", "1"): @@ -590,14 +756,25 @@ def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any: continue if candidate_type in ("object", "array"): try: - return json.loads(value) + parsed = json.loads(value) except (json.JSONDecodeError, ValueError, TypeError): continue + if _is_json_finite(parsed): + return parsed + # Non-finite floats (e.g. "[1e999]" -> [inf]) cannot be + # serialized back to valid JSON; preserve the raw string. + continue try: - return json.loads(value) + parsed = json.loads(value) except (json.JSONDecodeError, ValueError): return value + # Reject non-finite results (e.g. json.loads("1e999") -> inf, or nested + # inf/nan inside a parsed list/dict) which json.dumps would render as + # invalid JSON (Infinity/NaN). Preserve the raw string instead. + if not _is_json_finite(parsed): + return value + return parsed def compute_tool_delta( diff --git a/vllm/tool_parsers/xlam_tool_parser.py b/vllm/tool_parsers/xlam_tool_parser.py index 61eaaf952b24..d004e83352f0 100644 --- a/vllm/tool_parsers/xlam_tool_parser.py +++ b/vllm/tool_parsers/xlam_tool_parser.py @@ -165,7 +165,7 @@ def extract_tool_calls( function=FunctionCall( name=call["name"], arguments=( - json.dumps(call["arguments"]) + json.dumps(call["arguments"], ensure_ascii=False) if isinstance(call["arguments"], dict) else call["arguments"] ), @@ -473,7 +473,9 @@ def extract_tool_calls_streaming( ): current_tool = parsed_tools[current_idx] if isinstance(current_tool.get("arguments"), dict): - args_text = json.dumps(current_tool["arguments"]) + args_text = json.dumps( + current_tool["arguments"], ensure_ascii=False + ) else: args_text = str(current_tool.get("arguments", "{}")) except (json.JSONDecodeError, KeyError, IndexError): diff --git a/vllm/transformers_utils/chat_templates/registry.py b/vllm/transformers_utils/chat_templates/registry.py index 0c3d15f4dbd0..9af4217be65c 100644 --- a/vllm/transformers_utils/chat_templates/registry.py +++ b/vllm/transformers_utils/chat_templates/registry.py @@ -13,13 +13,6 @@ ChatTemplatePath: TypeAlias = Path | Callable[[str], Path | None] -def _get_qwen_chat_template_fallback(tokenizer_name_or_path: str) -> Path | None: - if tokenizer_name_or_path.endswith("-Chat"): - return CHAT_TEMPLATES_DIR / "template_chatml.jinja" - - return CHAT_TEMPLATES_DIR / "template_basic.jinja" - - def _get_minicpmv_chat_template_fallback(tokenizer_name_or_path: str) -> Path | None: # MiniCPM-V-4.5 version uses a dedicated template if "4.5" in tokenizer_name_or_path or "4_5" in tokenizer_name_or_path: @@ -36,12 +29,11 @@ def _get_minicpmv_chat_template_fallback(tokenizer_name_or_path: str) -> Path | "colpali": CHAT_TEMPLATES_DIR / "template_basic.jinja", "deepseek_ocr": CHAT_TEMPLATES_DIR / "template_deepseek_ocr.jinja", "deepseek_ocr2": CHAT_TEMPLATES_DIR / "template_deepseek_ocr.jinja", + "unlimited-ocr": CHAT_TEMPLATES_DIR / "template_deepseek_ocr.jinja", "deepseek_vl_v2": CHAT_TEMPLATES_DIR / "template_deepseek_vl2.jinja", - "fuyu": CHAT_TEMPLATES_DIR / "template_fuyu.jinja", "minicpmv": _get_minicpmv_chat_template_fallback, "minicpmv4_6": _get_minicpmv_chat_template_fallback, "paligemma": CHAT_TEMPLATES_DIR / "template_basic.jinja", - "qwen": _get_qwen_chat_template_fallback, "siglip": CHAT_TEMPLATES_DIR / "template_basic.jinja", "siglip2": CHAT_TEMPLATES_DIR / "template_basic.jinja", } diff --git a/vllm/transformers_utils/chat_templates/template_fuyu.jinja b/vllm/transformers_utils/chat_templates/template_fuyu.jinja deleted file mode 100644 index ec337d0c6447..000000000000 --- a/vllm/transformers_utils/chat_templates/template_fuyu.jinja +++ /dev/null @@ -1,3 +0,0 @@ -{%- for message in messages -%} - {{- message['content'] + '\n' -}} -{%- endfor -%} diff --git a/vllm/transformers_utils/config.py b/vllm/transformers_utils/config.py index 8339c183c0fd..a848bbe142f9 100644 --- a/vllm/transformers_utils/config.py +++ b/vllm/transformers_utils/config.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import asdict -from functools import cache, partial +from functools import cache, partial, wraps from importlib.metadata import version from pathlib import Path from typing import Any, Literal, TypeAlias @@ -16,9 +16,9 @@ from packaging.version import Version from safetensors.torch import _TYPES as _SAFETENSORS_TO_TORCH_DTYPE from transformers import GenerationConfig, PretrainedConfig +from transformers.configuration_utils import ALLOWED_LAYER_TYPES from transformers.models.auto.image_processing_auto import get_image_processor_config from transformers.models.auto.modeling_auto import ( - MODEL_FOR_CAUSAL_LM_MAPPING_NAMES, MODEL_MAPPING_NAMES, ) from transformers.models.auto.tokenization_auto import get_tokenizer_config @@ -34,12 +34,6 @@ from vllm.utils.torch_utils import common_broadcastable_dtype from .config_parser_base import ConfigParserBase -from .gguf_utils import ( - check_gguf_file, - is_gguf, - is_remote_gguf, - split_remote_gguf, -) from .repo_utils import ( file_or_path_exists, get_hf_file_to_dict, @@ -49,15 +43,6 @@ with_retry, ) -try: - # Transformers v5 - from transformers.configuration_utils import ALLOWED_ATTENTION_LAYER_TYPES -except ImportError: - # Transformers v4 - from transformers.configuration_utils import ( - ALLOWED_LAYER_TYPES as ALLOWED_ATTENTION_LAYER_TYPES, - ) - if envs.VLLM_USE_MODELSCOPE: from modelscope import AutoConfig else: @@ -68,9 +53,8 @@ logger = init_logger(__name__) if Version(version("transformers")) < Version("5.0.0"): - logger.warning( - "Support for Transformers v4 is deprecated. The Transformers v4 codepath will " - "become unmaintained in vLLM v0.22.0 and will be removed in vLLM v0.24.0. " + raise ImportError( + "Support for Transformers v4 is deprecated and was removed in vLLM v0.24.0. " "Please upgrade to Transformers v5: pip install --upgrade transformers" ) @@ -87,6 +71,7 @@ def __getitem__(self, key): _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict( afmoe="AfmoeConfig", + arctic="ArcticConfig", bagel="BagelConfig", umm="CheersConfig", chatglm="ChatGLMConfig", @@ -96,6 +81,7 @@ def __getitem__(self, key): ops_colqwen3="OpsColQwen3Config", qwen3_vl_nemotron_embed="Qwen3VLNemotronEmbedConfig", cosmos3_omni="Cosmos3Config", + diffusion_gemma="DiffusionGemmaConfig", deepseek_vl_v2="DeepseekVLV2Config", deepseek_v32="DeepseekV3Config", deepseek_v4="DeepseekV4Config", @@ -118,7 +104,10 @@ def __getitem__(self, key): medusa="MedusaConfig", mellum="MellumConfig", midashenglm="MiDashengLMConfig", + minimax_m3_vl="MiniMaxM3Config", + minimax_m3_mtp="MiniMaxM3MTPConfig", moondream3="Moondream3Config", + moss_transcribe_diarize="MossTranscribeDiarizeConfig", eagle="EAGLEConfig", speculators="SpeculatorsConfig", nemotron="NemotronConfig", @@ -136,10 +125,12 @@ def __getitem__(self, key): qwen3_5_moe="Qwen3_5MoeConfig", laguna="LagunaConfig", lfm2_moe="Lfm2MoeConfig", - tarsier2="Tarsier2Config", + **{"unlimited-ocr": "UnlimitedOCRConfig"}, ) -_SPECULATIVE_DECODING_CONFIGS: set[str] = {"eagle", "speculators"} +_SPECULATIVE_DECODING_CONFIGS: set[str] = {"eagle", "speculators", "medusa"} + +_PATCH_HF_VALIDATE_ROPE: set[str] = {"sarvam_mla"} _CONFIG_ATTRS_MAPPING: dict[str, str] = { "llm_config": "text_config", @@ -152,12 +143,28 @@ def __getitem__(self, key): } +def _register_config_class( + model_type: str, config_class: type[PretrainedConfig] +) -> None: + config_class.model_type = model_type + AutoConfig.register(model_type, config_class, exist_ok=True) + + +def _maybe_register_hf_config(config: PretrainedConfig | None) -> None: + if config is None: + return + + model_type = getattr(config, "model_type", None) + if isinstance(model_type, str) and model_type in _CONFIG_REGISTRY: + _register_config_class(model_type, _CONFIG_REGISTRY[model_type]) + + def is_rope_parameters_nested(rope_parameters: dict[str, Any]) -> bool: """Check if rope_parameters is nested by layer types.""" # Cannot be nested if rope_parameters is empty if not rope_parameters: return False - return set(rope_parameters.keys()).issubset(ALLOWED_ATTENTION_LAYER_TYPES) + return set(rope_parameters.keys()).issubset(ALLOWED_LAYER_TYPES) @contextmanager @@ -173,6 +180,31 @@ def _mistral_patch_hf_hub_constants() -> Iterator[None]: constants.SAFETENSORS_INDEX_FILE = hf_safetensors_index_file +def _patch_hf_transformers_validate_rope(): + """Transformers v5 moved the ignore_keys option from the method signature of + validate_rope and replaced it with the ignore_keys_at_rope_validation parameter + in the PreTrainedConfig class. This is a patch to make older versions of + validate_rope() with the ignore_keys parameter work with newer versions of + hf transformers (from v5 onwards) + """ + + if hasattr(PretrainedConfig.validate_rope, "__vllm_patched__"): + return + + _original_validate_rope = PretrainedConfig.validate_rope + + @wraps(_original_validate_rope) + def patched_validate_rope(self, *args, **kwargs): + ignore_keys_param = kwargs.pop("ignore_keys", None) + original_ignore_keys = self.ignore_keys_at_rope_validation + self.ignore_keys_at_rope_validation = original_ignore_keys or ignore_keys_param + result = _original_validate_rope(self, *args, **kwargs) + return result + + patched_validate_rope.__vllm_patched__ = True # type: ignore[attr-defined] + PretrainedConfig.validate_rope = patched_validate_rope + + class HFConfigParser(ConfigParserBase): def parse( self, @@ -212,6 +244,9 @@ def parse( dummy_model_type = hf_overrides(dummy_config).model_type model_type = dummy_model_type.removeprefix("dummy_") + if model_type in _PATCH_HF_VALIDATE_ROPE: + _patch_hf_transformers_validate_rope() + if model_type in _SPECULATIVE_DECODING_CONFIGS: config_class = _CONFIG_REGISTRY[model_type] config = config_class.from_pretrained( @@ -227,8 +262,7 @@ def parse( # in future calls to `from_pretrained` (e.g. from # AutoTokenizer or AutoProcessor). config_class = _CONFIG_REGISTRY[model_type] - config_class.model_type = model_type - AutoConfig.register(model_type, config_class, exist_ok=True) + _register_config_class(model_type, config_class) # If the on-disk model_type differs from the overridden # one, register under both so AutoConfig.from_pretrained # returns the correct class regardless of what the @@ -236,8 +270,7 @@ def parse( if ( config_model_type := config_dict.get("model_type") ) and config_model_type != model_type: - config_class.model_type = config_model_type - AutoConfig.register(config_model_type, config_class, exist_ok=True) + _register_config_class(config_model_type, config_class) config_class.model_type = model_type # Now that it is registered, it is not considered remote code anymore trust_remote_code = False @@ -460,39 +493,13 @@ def patch_rope_parameters(config: PretrainedConfig) -> None: """Provide backwards compatibility for RoPE.""" from vllm.config.utils import getattr_iter - # Older custom models may use non-standard field names - # which need patching for both Transformers v4 and v5. + # Older custom models may use non-standard field names which need patching. names = ["rope_theta", "rotary_emb_base"] rope_theta = getattr_iter(config, names, None, warn=True) names = ["partial_rotary_factor", "rotary_pct", "rotary_emb_fraction"] partial_rotary_factor = getattr_iter(config, names, None, warn=True) - ompe = getattr(config, "original_max_position_embeddings", None) - - if Version(version("transformers")) < Version("5.0.0"): - # Transformers v4 installed, legacy config fields may be present. - if is_rope_parameters_nested(getattr(config, "rope_parameters", {})): - # Loading nested rope_parameters (from Transformers v5) in Transformers v4. - # Skip legacy patching since it should already be in the correct format. - pass - else: - if (rope_scaling := getattr(config, "rope_scaling", None)) is not None: - config.rope_parameters = rope_scaling - if ( - rope_theta is not None - or partial_rotary_factor is not None - or ompe is not None - ) and not getattr(config, "rope_parameters", None): - config.rope_parameters = {"rope_type": "default"} - # Patch legacy fields into rope_parameters - if rope_theta is not None: - config.rope_parameters["rope_theta"] = rope_theta - if partial_rotary_factor is not None: - config.rope_parameters["partial_rotary_factor"] = partial_rotary_factor - if ompe is not None: - config.rope_parameters["original_max_position_embeddings"] = ompe - patch_legacy_rope_type(getattr(config, "rope_parameters", None)) - elif rope_theta is not None or getattr(config, "rope_parameters", None): - # Transformers v5 installed + + if rope_theta is not None or getattr(config, "rope_parameters", None): # Patch these fields in case they used non-standard names if rope_theta is not None: config.rope_theta = rope_theta @@ -615,17 +622,9 @@ def maybe_override_with_speculators( Returns: Tuple of (resolved_model, resolved_tokenizer, speculative_config) """ - if check_gguf_file(model): - kwargs["gguf_file"] = Path(model).name - gguf_model_repo = Path(model).parent - elif is_remote_gguf(model): - repo_id, _ = split_remote_gguf(model) - gguf_model_repo = Path(repo_id) - else: - gguf_model_repo = None kwargs["local_files_only"] = huggingface_hub.constants.HF_HUB_OFFLINE config_dict, _ = PretrainedConfig.get_config_dict( - model if gguf_model_repo is None else gguf_model_repo, + model, revision=revision, token=hf_token, **without_trust_remote_code(kwargs), @@ -663,21 +662,6 @@ def get_config( hf_overrides_fn: Callable[[PretrainedConfig], PretrainedConfig] | None = None, **kwargs, ) -> PretrainedConfig: - # Separate model folder from file path for GGUF models - - _is_gguf = is_gguf(model) - _is_remote_gguf = is_remote_gguf(model) - if _is_gguf: - if check_gguf_file(model): - # Local GGUF file - kwargs["gguf_file"] = Path(model).name - model = Path(model).parent - elif _is_remote_gguf: - # Remote GGUF - extract repo_id from repo_id:quant_type format - # The actual GGUF file will be downloaded later by GGUFModelLoader - # Keep model as repo_id:quant_type for download, but use repo_id for config - model, _ = split_remote_gguf(model) - if config_format == "auto": try: # First check for Mistral to avoid defaulting to @@ -688,25 +672,8 @@ def get_config( model=model, config_name=MISTRAL_CONFIG_NAME, revision=revision ): config_format = "mistral" - elif (_is_gguf and not _is_remote_gguf) or file_or_path_exists( - model, HF_CONFIG_NAME, revision=revision - ): + elif file_or_path_exists(model, HF_CONFIG_NAME, revision=revision): config_format = "hf" - # Remote GGUF models must have config.json in repo, - # otherwise the config can't be parsed correctly. - # FIXME(Isotr0py): Support remote GGUF repos without config.json - elif _is_remote_gguf and not file_or_path_exists( - model, HF_CONFIG_NAME, revision=revision - ): - err_msg = ( - "Could not find config.json for remote GGUF model repo. " - "To load remote GGUF model through `:`, " - "ensure your model has config.json (HF format) file. " - "Otherwise please specify --hf-config-path " - "in engine args to fetch config from unquantized hf model." - ) - logger.error(err_msg) - raise ValueError(err_msg) else: raise ValueError( "Could not detect config format for no config file found. " @@ -741,34 +708,6 @@ def get_config( **kwargs, ) - # Patching defaults for GGUF models - if _is_gguf: - # Some models have different default values between GGUF and HF. - def apply_gguf_default(key: str, gguf_default: Any): - """ - Apply GGUF defaults unless explicitly configured. - - This function reads/writes external `config` and `config_dict`. - If the specified `key` is not in `config_dict` (i.e. not explicitly - configured and the default HF value is used), it updates the - corresponding `config` value to `gguf_default`. - """ - if key not in config_dict: - config.update({key: gguf_default}) - - # Apply architecture-specific GGUF defaults. - if config.model_type in {"qwen3_moe"}: - # Qwen3 MoE: norm_topk_prob is always true. - # Note that, this parameter is always false (HF default) on Qwen2 MoE. - apply_gguf_default("norm_topk_prob", True) - - # Special architecture mapping check for GGUF models - if _is_gguf: - if config.model_type not in MODEL_FOR_CAUSAL_LM_MAPPING_NAMES: - raise RuntimeError(f"Can't get gguf config for {config.model_type}.") - model_type = MODEL_FOR_CAUSAL_LM_MAPPING_NAMES[config.model_type] - config.update({"architectures": [model_type]}) - # Architecture mapping for models without explicit architectures field if not config.architectures: if config.model_type not in MODEL_MAPPING_NAMES: @@ -860,9 +799,6 @@ def get_pooling_config( A dictionary containing the pooling type and whether normalization is used, or None if no pooling configuration is found. """ - if is_remote_gguf(model): - model, _ = split_remote_gguf(model) - modules_file_name = "modules.json" modules_dict = None @@ -962,9 +898,9 @@ def get_sentence_transformer_tokenizer_config( encoder_dict = None for config_file in sentence_transformer_config_files: - if ( - try_get_local_file(model=model, file_name=config_file, revision=revision) - is not None + if isinstance( + try_get_local_file(model=model, file_name=config_file, revision=revision), + Path, ): encoder_dict = get_hf_file_to_dict(config_file, model, revision) if encoder_dict: @@ -1078,11 +1014,6 @@ def get_hf_image_processor_config( # ModelScope does not provide an interface for image_processor if envs.VLLM_USE_MODELSCOPE: return dict() - # Separate model folder from file path for GGUF models - if check_gguf_file(model): - model = Path(model).parent - elif is_remote_gguf(model): - model, _ = split_remote_gguf(model) return get_image_processor_config( model, token=hf_token, revision=revision, **kwargs ) @@ -1112,13 +1043,6 @@ def try_get_generation_config( config_format: str | ConfigFormat = "auto", hf_token: bool | str | None = None, ) -> GenerationConfig | None: - # GGUF files don't have generation_config.json - their config is embedded - # in the file header. Skip all filesystem lookups to avoid re-reading the - # memory-mapped file, which can hang in multi-process scenarios when the - # EngineCore process already has the file mapped. - if is_gguf(model): - return None - try: return GenerationConfig.from_pretrained( model, diff --git a/vllm/transformers_utils/configs/__init__.py b/vllm/transformers_utils/configs/__init__.py index 71f7723e4c80..f48b0dd6df6a 100644 --- a/vllm/transformers_utils/configs/__init__.py +++ b/vllm/transformers_utils/configs/__init__.py @@ -16,6 +16,7 @@ _CLASS_TO_MODULE: dict[str, str] = { "AfmoeConfig": "vllm.transformers_utils.configs.afmoe", + "ArcticConfig": "vllm.transformers_utils.configs.arctic", "AXK1Config": "vllm.transformers_utils.configs.AXK1", "BagelConfig": "vllm.transformers_utils.configs.bagel", "CheersConfig": "vllm.transformers_utils.configs.cheers", @@ -26,6 +27,8 @@ "OpsColQwen3Config": "vllm.transformers_utils.configs.colqwen3", "Qwen3VLNemotronEmbedConfig": "vllm.transformers_utils.configs.colqwen3", "Cosmos3Config": "vllm.transformers_utils.configs.cosmos3", + "DiffusionGemmaConfig": "vllm.transformers_utils.configs.diffusion_gemma", + "DiffusionGemmaTextConfig": "vllm.transformers_utils.configs.diffusion_gemma", "DeepseekVLV2Config": "vllm.transformers_utils.configs.deepseek_vl2", "DeepseekV4Config": "vllm.transformers_utils.configs.deepseek_v4", "DotsOCRConfig": "vllm.transformers_utils.configs.dotsocr", @@ -51,10 +54,16 @@ "MedusaConfig": "vllm.transformers_utils.configs.medusa", "MellumConfig": "vllm.transformers_utils.configs.mellum", "MiDashengLMConfig": "vllm.transformers_utils.configs.midashenglm", + "MiniMaxM3Config": "vllm.transformers_utils.configs.minimax_m3", + "MiniMaxM3MTPConfig": "vllm.transformers_utils.configs.minimax_m3", + "MiniMaxM3TextConfig": "vllm.transformers_utils.configs.minimax_m3", "MLPSpeculatorConfig": "vllm.transformers_utils.configs.mlp_speculator", "Moondream3Config": "vllm.transformers_utils.configs.moondream3", "Moondream3TextConfig": "vllm.transformers_utils.configs.moondream3", "Moondream3VisionConfig": "vllm.transformers_utils.configs.moondream3", + "MossTranscribeDiarizeConfig": ( + "vllm.transformers_utils.configs.moss_transcribe_diarize" + ), "MoonViTConfig": "vllm.transformers_utils.configs.moonvit", "KimiLinearConfig": "vllm.transformers_utils.configs.kimi_linear", "KimiVLConfig": "vllm.transformers_utils.configs.kimi_vl", @@ -68,6 +77,7 @@ "RadioConfig": "vllm.transformers_utils.configs.radio", "SpeculatorsConfig": "vllm.transformers_utils.configs.speculators", "UltravoxConfig": "vllm.transformers_utils.configs.ultravox", + "UnlimitedOCRConfig": "vllm.transformers_utils.configs.unlimited_ocr", "Step3VLConfig": "vllm.transformers_utils.configs.step3_vl", "Step3VisionEncoderConfig": "vllm.transformers_utils.configs.step3_vl", "Step3TextConfig": "vllm.transformers_utils.configs.step3_vl", @@ -80,13 +90,13 @@ "Qwen3_5TextConfig": "vllm.transformers_utils.configs.qwen3_5", "Qwen3_5MoeConfig": "vllm.transformers_utils.configs.qwen3_5_moe", "Qwen3_5MoeTextConfig": "vllm.transformers_utils.configs.qwen3_5_moe", - "Tarsier2Config": "vllm.transformers_utils.configs.tarsier2", # Special case: DeepseekV3Config is from HuggingFace Transformers "DeepseekV3Config": "transformers", } __all__ = [ "AfmoeConfig", + "ArcticConfig", "AXK1Config", "BagelConfig", "CheersConfig", @@ -97,6 +107,8 @@ "OpsColQwen3Config", "Qwen3VLNemotronEmbedConfig", "Cosmos3Config", + "DiffusionGemmaConfig", + "DiffusionGemmaTextConfig", "DeepseekVLV2Config", "DeepseekV3Config", "DeepseekV4Config", @@ -120,10 +132,14 @@ "MedusaConfig", "MellumConfig", "MiDashengLMConfig", + "MiniMaxM3Config", + "MiniMaxM3MTPConfig", + "MiniMaxM3TextConfig", "MLPSpeculatorConfig", "Moondream3Config", "Moondream3TextConfig", "Moondream3VisionConfig", + "MossTranscribeDiarizeConfig", "MoonViTConfig", "KimiLinearConfig", "KimiVLConfig", @@ -137,6 +153,7 @@ "RadioConfig", "SpeculatorsConfig", "UltravoxConfig", + "UnlimitedOCRConfig", "Step3VLConfig", "Step3VisionEncoderConfig", "Step3TextConfig", @@ -149,7 +166,6 @@ "Qwen3_5TextConfig", "Qwen3_5MoeConfig", "Qwen3_5MoeTextConfig", - "Tarsier2Config", ] diff --git a/vllm/transformers_utils/configs/deepseek_vl2.py b/vllm/transformers_utils/configs/deepseek_vl2.py index 3d3e20fea856..9345306abae3 100644 --- a/vllm/transformers_utils/configs/deepseek_vl2.py +++ b/vllm/transformers_utils/configs/deepseek_vl2.py @@ -3,6 +3,7 @@ # adapted from https://github.com/deepseek-ai/DeepSeek-VL2/blob/faf18023f24b962b32d9f0a2d89e402a8d383a78/deepseek_vl2/models/modeling_deepseek_vl_v2.py#L115-L268 +from huggingface_hub.dataclasses import strict from transformers import DeepseekV2Config, PretrainedConfig @@ -87,16 +88,9 @@ def __init__( super().__init__(**kwargs) -if hasattr(DeepseekV2Config, "validate"): - # Transformers v5 - from huggingface_hub.dataclasses import strict - - @strict - class DeepseekVLV2TextConfig(DeepseekV2Config): - kv_lora_rank: int | None = None -else: - # Transformers v4 - DeepseekVLV2TextConfig = DeepseekV2Config # type: ignore[misc] +@strict +class DeepseekVLV2TextConfig(DeepseekV2Config): + kv_lora_rank: int | None = None class DeepseekVLV2Config(PretrainedConfig): diff --git a/vllm/transformers_utils/configs/diffusion_gemma.py b/vllm/transformers_utils/configs/diffusion_gemma.py new file mode 100644 index 000000000000..246a25b32c6d --- /dev/null +++ b/vllm/transformers_utils/configs/diffusion_gemma.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from typing import Any + +from transformers import PretrainedConfig +from transformers.models.gemma4.configuration_gemma4 import Gemma4VisionConfig + + +def _init_text_config(self: PretrainedConfig, **kwargs: Any) -> None: + PretrainedConfig.__init__(self, **kwargs) + # DiffusionGemma always uses MoE and K=V sharing for full_attention + # layers. The HF reference removed these config fields entirely. + if getattr(self, "num_experts", None): + self.enable_moe_block = True + self.attention_k_eq_v = True + + +class DiffusionGemmaTextConfig(PretrainedConfig): + model_type = "diffusion_gemma_text" + + def __init__(self, **kwargs: Any): + _init_text_config(self, **kwargs) + + +class DiffusionGemmaConfig(PretrainedConfig): + model_type = "diffusion_gemma" + + def __init__( + self, + text_config: dict[str, Any] | None = None, + canvas_length: int = 256, + self_conditioning_size: int | None = None, + **kwargs: Any, + ): + self.text_config = DiffusionGemmaTextConfig(**(text_config or {})) + self.canvas_length = canvas_length + self.self_conditioning_size = self_conditioning_size + vision_config = kwargs.pop("vision_config", None) + if isinstance(vision_config, dict): + self.vision_config = Gemma4VisionConfig(**vision_config) + else: + self.vision_config = vision_config + self.audio_config = None + PretrainedConfig.__init__(self, **kwargs) diff --git a/vllm/transformers_utils/configs/minimax_m3.py b/vllm/transformers_utils/configs/minimax_m3.py new file mode 100644 index 000000000000..c340dda85a6c --- /dev/null +++ b/vllm/transformers_utils/configs/minimax_m3.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from typing import Any + +from transformers import PretrainedConfig + + +class MiniMaxM3TextConfig(PretrainedConfig): + """Config for the MiniMax M3 text backbone (MiniMaxM3SparseForCausalLM). + + Defaults mirror the ``text_config`` of the MiniMax-M3-preview checkpoint. + """ + + model_type = "minimax_m3_text" + architectures = ["MiniMaxM3SparseForCausalLM"] + + def __init__( + self, + vocab_size: int = 200064, + hidden_size: int = 6144, + intermediate_size: int = 3072, + dense_intermediate_size: int = 12288, + shared_intermediate_size: int = 3072, + num_hidden_layers: int = 60, + num_attention_heads: int = 64, + num_key_value_heads: int = 4, + head_dim: int = 128, + max_position_embeddings: int = 524288, + rms_norm_eps: float = 1e-6, + use_gemma_norm: bool = True, + attention_output_gate: bool = False, + rope_theta: float = 5000000, + rotary_dim: int = 64, + partial_rotary_factor: float = 0.5, + hidden_act: str = "swigluoai", + swiglu_alpha: float = 1.702, + # SwiGLU-OAI uses the (up + 1) bias, i.e. beta=1.0 (matches the + # reference: gate * sigmoid(gate * alpha) * (up + 1)). The checkpoint + # config omits swiglu_beta, so this default must stay 1.0. + swiglu_beta: float = 1.0, + swiglu_limit: float = 7.0, + use_qk_norm: bool = True, + qk_norm_type: str = "per_head", + num_local_experts: int = 128, + num_experts_per_tok: int = 4, + n_shared_experts: int = 1, + scoring_func: str = "sigmoid", + use_routing_bias: bool = True, + routed_scaling_factor: float = 2.0, + num_mtp_modules: int = 1, + moe_layer_freq: list[int] | None = None, + sparse_attention_config: dict[str, Any] | None = None, + tie_word_embeddings: bool = False, + **kwargs, + ): + self.vocab_size = vocab_size + self.hidden_size = hidden_size + self.intermediate_size = intermediate_size + self.dense_intermediate_size = dense_intermediate_size + self.shared_intermediate_size = shared_intermediate_size + self.num_hidden_layers = num_hidden_layers + self.num_attention_heads = num_attention_heads + self.num_key_value_heads = num_key_value_heads + self.head_dim = head_dim + self.max_position_embeddings = max_position_embeddings + self.rms_norm_eps = rms_norm_eps + self.use_gemma_norm = use_gemma_norm + self.attention_output_gate = attention_output_gate + self.rope_theta = rope_theta + self.rotary_dim = rotary_dim + self.partial_rotary_factor = partial_rotary_factor + self.hidden_act = hidden_act + self.swiglu_alpha = swiglu_alpha + self.swiglu_beta = swiglu_beta + self.swiglu_limit = swiglu_limit + self.use_qk_norm = use_qk_norm + self.qk_norm_type = qk_norm_type + self.num_local_experts = num_local_experts + self.num_experts_per_tok = num_experts_per_tok + self.n_shared_experts = n_shared_experts + self.scoring_func = scoring_func + self.use_routing_bias = use_routing_bias + self.routed_scaling_factor = routed_scaling_factor + self.num_mtp_modules = num_mtp_modules + # First 3 layers are dense; the remaining 57 are sparse MoE. + self.moe_layer_freq = ( + moe_layer_freq if moe_layer_freq is not None else [0] * 3 + [1] * 57 + ) + self.sparse_attention_config = ( + sparse_attention_config + if sparse_attention_config is not None + else { + "use_sparse_attention": True, + "sparse_index_dim": 128, + "sparse_num_index_heads": 4, + "sparse_topk_blocks": 16, + "sparse_block_size": 128, + "sparse_disable_index_value": [0] * 3 + [1] * 57, + "sparse_score_type": "max", + "sparse_init_block": 0, + "sparse_local_block": 1, + "sparse_attention_freq": [0] * 3 + [1] * 57, + } + ) + super().__init__(tie_word_embeddings=tie_word_embeddings, **kwargs) + + +class MiniMaxM3MTPConfig(MiniMaxM3TextConfig): + """Config for a standalone MiniMax M3 MTP (multi-token prediction) head. + + The MTP transformer layer is structurally a single MiniMax M3 decoder + layer, so this reuses the text backbone schema. Standalone MTP checkpoints + use ``model_type='minimax_m3_mtp'`` and a single hidden layer. + """ + + model_type = "minimax_m3_mtp" + architectures = ["MiniMaxM3MTP"] + + def __init__(self, num_hidden_layers: int = 1, **kwargs): + super().__init__(num_hidden_layers=num_hidden_layers, **kwargs) + + +class MiniMaxM3Config(PretrainedConfig): + """Top-level MiniMax M3 (VL) config. + + Holds the text backbone as ``text_config`` so that + ``config.get_text_config()`` extracts the MiniMaxM3SparseForCausalLM + backbone. Vision components are kept as a raw dict passthrough and are + not modeled here. + """ + + model_type = "minimax_m3_vl" + + def __init__( + self, + text_config: dict | MiniMaxM3TextConfig | None = None, + vision_config: dict | None = None, + **kwargs, + ): + if text_config is None: + text_config = MiniMaxM3TextConfig() + elif isinstance(text_config, dict): + text_config = MiniMaxM3TextConfig(**text_config) + self.text_config = text_config + self.vision_config = vision_config + + self.hidden_size = text_config.hidden_size + + super().__init__(**kwargs) diff --git a/vllm/transformers_utils/configs/moss_transcribe_diarize.py b/vllm/transformers_utils/configs/moss_transcribe_diarize.py new file mode 100644 index 000000000000..20abbd8feec9 --- /dev/null +++ b/vllm/transformers_utils/configs/moss_transcribe_diarize.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from typing import Any + +from transformers import PretrainedConfig, Qwen3Config +from transformers.models.whisper.configuration_whisper import WhisperConfig + + +class MossTranscribeDiarizeConfig(PretrainedConfig): + """Configuration for MOSS-Transcribe-Diarize.""" + + model_type = "moss_transcribe_diarize" + sub_configs = {"text_config": Qwen3Config, "audio_config": WhisperConfig} + keys_to_ignore_at_inference = ["past_key_values"] + + def __init__( + self, + text_config: dict[str, Any] | Qwen3Config | None = None, + audio_config: dict[str, Any] | WhisperConfig | None = None, + audio_token_id: int = 151671, + audio_merge_size: int = 4, + adaptor_input_dim: int | None = None, + tie_word_embeddings: bool = True, + **kwargs: Any, + ) -> None: + text_config_obj: Qwen3Config + if text_config is None: + text_config_obj = Qwen3Config( + vocab_size=151936, + hidden_size=1024, + intermediate_size=3072, + num_hidden_layers=28, + num_attention_heads=16, + num_key_value_heads=8, + head_dim=128, + max_position_embeddings=40960, + tie_word_embeddings=tie_word_embeddings, + rope_theta=1_000_000.0, + layer_types=["full_attention"] * 28, + ) + elif isinstance(text_config, dict): + text_config_obj = Qwen3Config(**text_config) + else: + text_config_obj = text_config + + audio_config_obj: WhisperConfig + if audio_config is None: + audio_config_obj = WhisperConfig( + num_mel_bins=80, + d_model=1024, + encoder_layers=24, + encoder_attention_heads=16, + encoder_ffn_dim=4096, + max_source_positions=1500, + dropout=0.0, + attention_dropout=0.0, + activation_dropout=0.0, + activation_function="gelu", + encoder_layerdrop=0.0, + scale_embedding=False, + ) + elif isinstance(audio_config, dict): + audio_config_obj = WhisperConfig(**audio_config) + else: + audio_config_obj = audio_config + + text_config_obj.tie_word_embeddings = tie_word_embeddings + if not getattr(text_config_obj, "layer_types", None): + text_config_obj.layer_types = [ + "full_attention" + ] * text_config_obj.num_hidden_layers + + super().__init__(tie_word_embeddings=tie_word_embeddings, **kwargs) + + self.text_config = text_config_obj + self.audio_config = audio_config_obj + self.audio_token_id = int(audio_token_id) + self.audio_merge_size = int(audio_merge_size) + self.adaptor_input_dim = ( + int(adaptor_input_dim) + if adaptor_input_dim is not None + else int(audio_config_obj.d_model) * int(audio_merge_size) + ) + + self.vocab_size = int(text_config_obj.vocab_size) + self.hidden_size = int(text_config_obj.hidden_size) + self.intermediate_size = int(text_config_obj.intermediate_size) + self.num_hidden_layers = int(text_config_obj.num_hidden_layers) + self.num_attention_heads = int(text_config_obj.num_attention_heads) + self.num_key_value_heads = int(text_config_obj.num_key_value_heads) + self.head_dim = int(text_config_obj.head_dim) + self.hidden_act = text_config_obj.hidden_act + self.max_position_embeddings = int(text_config_obj.max_position_embeddings) + self.rms_norm_eps = float(text_config_obj.rms_norm_eps) + rope_parameters = getattr(text_config_obj, "rope_parameters", None) + rope_theta = float(getattr(text_config_obj, "rope_theta", 1_000_000.0)) + if rope_parameters is None: + rope_parameters = { + "rope_type": "default", + "rope_theta": rope_theta, + } + text_config_obj.rope_parameters = rope_parameters + self.rope_parameters = rope_parameters + self.rope_theta = float(rope_parameters.get("rope_theta", rope_theta)) + self.attention_bias = bool(text_config_obj.attention_bias) + self.attention_dropout = float(text_config_obj.attention_dropout) + self.is_causal = True diff --git a/vllm/transformers_utils/configs/olmo_hybrid.py b/vllm/transformers_utils/configs/olmo_hybrid.py index 2a60f29025a0..cdca81757e70 100644 --- a/vllm/transformers_utils/configs/olmo_hybrid.py +++ b/vllm/transformers_utils/configs/olmo_hybrid.py @@ -228,15 +228,8 @@ def __init__( if "full_attention" not in layer_types: layer_types[-1] = "full_attention" - if hasattr(self, "validate_layer_type"): - # Transformers v5 - self.layer_types = layer_types - self.validate_layer_type() - else: - # Transformers v4 - from transformers.configuration_utils import layer_type_validation - - layer_type_validation(layer_types, num_hidden_layers) + self.layer_types = layer_types + self.validate_layer_type() if "linear_attention" not in layer_types: raise ValueError( "OLMoHybrid expects at least one 'linear_attention' layer." diff --git a/vllm/transformers_utils/configs/qwen3_5.py b/vllm/transformers_utils/configs/qwen3_5.py index 3192e5e9a166..d5820a5783c6 100644 --- a/vllm/transformers_utils/configs/qwen3_5.py +++ b/vllm/transformers_utils/configs/qwen3_5.py @@ -94,18 +94,11 @@ def __init__( else "full_attention" for i in range(self.num_hidden_layers) ] - if hasattr(self, "validate_layer_type"): - # Transformers v5 - kwargs["ignore_keys_at_rope_validation"] = { - "mrope_section", - "mrope_interleaved", - } - self.validate_layer_type() - else: - # Transformers v4 - from transformers.configuration_utils import layer_type_validation - - layer_type_validation(self.layer_types, self.num_hidden_layers) + kwargs["ignore_keys_at_rope_validation"] = { + "mrope_section", + "mrope_interleaved", + } + self.validate_layer_type() # linear attention part self.linear_conv_kernel_dim = linear_conv_kernel_dim diff --git a/vllm/transformers_utils/configs/qwen3_5_moe.py b/vllm/transformers_utils/configs/qwen3_5_moe.py index 9d9987ce03ee..ec229ce81426 100644 --- a/vllm/transformers_utils/configs/qwen3_5_moe.py +++ b/vllm/transformers_utils/configs/qwen3_5_moe.py @@ -100,18 +100,11 @@ def __init__( else "full_attention" for i in range(self.num_hidden_layers) ] - if hasattr(self, "validate_layer_type"): - # Transformers v5 - kwargs["ignore_keys_at_rope_validation"] = { - "mrope_section", - "mrope_interleaved", - } - self.validate_layer_type() - else: - # Transformers v4 - from transformers.configuration_utils import layer_type_validation - - layer_type_validation(self.layer_types, self.num_hidden_layers) + kwargs["ignore_keys_at_rope_validation"] = { + "mrope_section", + "mrope_interleaved", + } + self.validate_layer_type() # linear attention part self.linear_conv_kernel_dim = linear_conv_kernel_dim diff --git a/vllm/transformers_utils/configs/qwen3_next.py b/vllm/transformers_utils/configs/qwen3_next.py index 6a02476fbe1a..de579ed2cf3f 100644 --- a/vllm/transformers_utils/configs/qwen3_next.py +++ b/vllm/transformers_utils/configs/qwen3_next.py @@ -252,14 +252,7 @@ def __init__( "linear_attention" if bool((i + 1) % 4) else "full_attention" for i in range(self.num_hidden_layers) ] - if hasattr(self, "validate_layer_type"): - # Transformers v5 - self.validate_layer_type() - else: - # Transformers v4 - from transformers.configuration_utils import layer_type_validation - - layer_type_validation(self.layer_types) + self.validate_layer_type() # linear attention part self.linear_conv_kernel_dim = linear_conv_kernel_dim diff --git a/vllm/transformers_utils/configs/speculators/algos.py b/vllm/transformers_utils/configs/speculators/algos.py index 650f09c39fb0..e034cec9745a 100644 --- a/vllm/transformers_utils/configs/speculators/algos.py +++ b/vllm/transformers_utils/configs/speculators/algos.py @@ -36,7 +36,16 @@ def update_eagle3(config_dict: dict, pre_trained_config: dict) -> None: "norm_before_residual", True ) pre_trained_config["norm_before_fc"] = config_dict.get("norm_before_fc", False) - pre_trained_config["architectures"] = ["Eagle3LlamaForCausalLM"] + pre_trained_config["fc_norm"] = config_dict.get("fc_norm", False) + pre_trained_config["norm_output"] = config_dict.get("norm_output", False) + eagle3_arch_map = { + "qwen3": "Eagle3Qwen3ForCausalLM", + "llama": "Eagle3LlamaForCausalLM", + } + model_type = pre_trained_config.get("model_type", "llama") + if model_type not in eagle3_arch_map: + raise ValueError(f"Unsupported model_type {model_type} for Eagle3 speculator") + pre_trained_config["architectures"] = [eagle3_arch_map[model_type]] if config_dict.get("eagle_aux_hidden_state_layer_ids"): pre_trained_config["eagle_aux_hidden_state_layer_ids"] = config_dict[ "eagle_aux_hidden_state_layer_ids" @@ -59,7 +68,6 @@ def update_peagle(config_dict: dict, pre_trained_config: dict) -> None: - eagle_aux_hidden_state_layer_ids: Layer indices from the target model whose intermediate hidden states are used as auxiliary inputs """ - pre_trained_config["architectures"] = ["PeagleLlamaForCausalLM"] pre_trained_config["draft_vocab_size"] = config_dict.get("draft_vocab_size") if config_dict.get("target_hidden_size") is not None: pre_trained_config["target_hidden_size"] = config_dict["target_hidden_size"] @@ -67,6 +75,14 @@ def update_peagle(config_dict: dict, pre_trained_config: dict) -> None: "norm_before_residual", False ) pre_trained_config["norm_before_fc"] = config_dict.get("norm_before_fc", False) + peagle_arch_map = { + "qwen3": "PeagleQwen3ForCausalLM", + "llama": "PeagleLlamaForCausalLM", + } + model_type = pre_trained_config.get("model_type", "llama") + if model_type not in peagle_arch_map: + raise ValueError(f"Unsupported model_type {model_type} for PEagle speculator") + pre_trained_config["architectures"] = [peagle_arch_map[model_type]] pre_trained_config["pard_token"] = config_dict["mask_token_id"] if config_dict.get("eagle_aux_hidden_state_layer_ids"): pre_trained_config["eagle_aux_hidden_state_layer_ids"] = config_dict[ @@ -104,3 +120,51 @@ def update_dflash(config_dict: dict, pre_trained_config: dict) -> None: "mask_token_id": config_dict["mask_token_id"], "target_layer_ids": [i - 1 for i in aux_layer_ids], } + # Enable causal masking in SWA for vllm-project/speculators models + pre_trained_config["dflash_config"]["causal"] = not config_dict.get( + "sliding_window_non_causal", True + ) + + +@register_speculator("dspark") +def update_dspark(config_dict: dict, pre_trained_config: dict) -> None: + """ + Apply DSpark specific configuration transformations to the `dict` used to + construct the Transformers PreTrainedConfig. + + DSpark extends DFlash with a Markov logit-bias head, reusing the same + Qwen3DSparkModel loader and DSparkSpeculator runtime as the dense DSpark + checkpoints (e.g. deepseek-ai/dspark_qwen3_8b_block7). + + DSpark specific fields: + - draft_vocab_size: draft vocab size; when smaller than the target vocab the + checkpoint also ships d2t/t2d remap tables. + - mask_token_id (required): token id for parallel-drafting mask slots. + - markov_rank / markov_head_type: low-rank Markov logit-bias head. + - block_size: semi-autoregressive draft block size. + - enable_confidence_head / confidence_head_with_markov: confidence head. + - aux_hidden_state_layer_ids (required): target layer indices feeding the + drafter. Mapped to both eagle_aux_hidden_state_layer_ids and + target_layer_ids (DSpark's i-1 layer semantics). + """ + pre_trained_config["architectures"] = ["Qwen3DSparkModel"] + # Speculators DSpark uses the 1+N fill-in block (anchor is a bonus token). + pre_trained_config["dspark_bonus_anchor"] = True + + aux_layer_ids = config_dict["aux_hidden_state_layer_ids"] + pre_trained_config["eagle_aux_hidden_state_layer_ids"] = aux_layer_ids + # DSpark indexes target layers as aux_id - 1 (matches the dense configs). + pre_trained_config["target_layer_ids"] = [i - 1 for i in aux_layer_ids] + + for key in ( + "draft_vocab_size", + "target_hidden_size", + "mask_token_id", + "markov_rank", + "markov_head_type", + "block_size", + "enable_confidence_head", + "confidence_head_with_markov", + ): + if config_dict.get(key) is not None: + pre_trained_config[key] = config_dict[key] diff --git a/vllm/transformers_utils/configs/speculators/base.py b/vllm/transformers_utils/configs/speculators/base.py index f09173bcb9a0..08368d346f15 100644 --- a/vllm/transformers_utils/configs/speculators/base.py +++ b/vllm/transformers_utils/configs/speculators/base.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import os -from dataclasses import fields, is_dataclass +from dataclasses import fields from typing import Any from transformers import PretrainedConfig @@ -16,11 +16,8 @@ class SpeculatorsConfig(PretrainedConfig): model_type = "speculators" def __init__(self, **kwargs): - # Transformers v4 - super().__init__ which sets all kwargs as attributes - if not is_dataclass(PretrainedConfig): - return super().__init__(**kwargs) - # Transformers v5 - super().__init__ performs some validation before - # setting all kwargs as attributes, so we set them first to be safe + # super().__init__ performs some validation before setting all kwargs as + # attributes, so we set them first to be safe pre_trained_config_fields = {f.name for f in fields(PretrainedConfig)} super_kwargs = dict() for key, value in kwargs.items(): diff --git a/vllm/transformers_utils/configs/tarsier2.py b/vllm/transformers_utils/configs/tarsier2.py deleted file mode 100644 index 12ebb4b7f602..000000000000 --- a/vllm/transformers_utils/configs/tarsier2.py +++ /dev/null @@ -1,24 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from transformers import Qwen2VLConfig - - -class Tarsier2Config(Qwen2VLConfig): - """ - Tarsier2's config.json is written such that AutoConfig.from_pretrained will create - a deeply nested config consisting of: - - - LlavaConfig - - Qwen2VLConfig - - Qwen2VLTextConfig - - Qwen2VLVisionConfig - - Qwen2VLConfig - - Qwen2VLTextConfig - - Qwen2VLVisionConfig - - When it should really just be a single Qwen2VLConfig. - - This class is a hack to stop AutoConfig from creating the nested config structure. - """ - - model_type = "tarsier2" diff --git a/vllm/transformers_utils/configs/unlimited_ocr.py b/vllm/transformers_utils/configs/unlimited_ocr.py new file mode 100644 index 000000000000..99e50a03c7db --- /dev/null +++ b/vllm/transformers_utils/configs/unlimited_ocr.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +# Unlimited-OCR (baidu/Unlimited-OCR) reuses +# the DeepSeek-OCR multimodal layout (DeepEncoder = SAM-ViT-B + CLIP-L, a linear +# MLP projector and a DeepSeek-V2 text backbone). The only architectural +# difference is the language model, which is a DeepSeek-V2 *MoE* with plain +# multi-head attention (``use_mla=False``) instead of the dense MLA backbone. +# We therefore reuse ``DeepseekVLV2Config`` for parsing the nested config. + +from vllm.transformers_utils.configs.deepseek_vl2 import DeepseekVLV2Config + + +class UnlimitedOCRConfig(DeepseekVLV2Config): + model_type = "unlimited-ocr" + + # An explicit ``__init__`` is required: Transformers v5 processes each + # concrete config class' ``__init__`` signature to build nested sub-configs, + # and an empty subclass (only overriding ``model_type``) would skip + # ``DeepseekVLV2Config.__init__``, leaving ``text_config`` unset. + def __init__( + self, + tile_tag: str = "2D", + global_view_pos: str = "head", + candidate_resolutions: tuple[tuple[int, int]] = ((384, 384),), + rswa_window: int = 128, + **kwargs, + ): + super().__init__( + tile_tag=tile_tag, + global_view_pos=global_view_pos, + candidate_resolutions=candidate_resolutions, + **kwargs, + ) + self.rswa_window = rswa_window diff --git a/vllm/transformers_utils/gguf_utils.py b/vllm/transformers_utils/gguf_utils.py deleted file mode 100644 index 7708378ee13b..000000000000 --- a/vllm/transformers_utils/gguf_utils.py +++ /dev/null @@ -1,336 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""GGUF utility functions.""" - -from functools import cache -from os import PathLike -from pathlib import Path - -import gguf -import regex as re -from gguf.constants import Keys, VisionProjectorType -from gguf.quants import GGMLQuantizationType -from transformers import Gemma3Config, PretrainedConfig, SiglipVisionConfig - -from vllm.logger import init_logger - -from .repo_utils import list_filtered_repo_files - -logger = init_logger(__name__) - - -@cache -def check_gguf_file(model: str | PathLike) -> bool: - """Check if the file is a GGUF model.""" - model = Path(model) - if not model.is_file(): - return False - elif model.suffix == ".gguf": - return True - - try: - with model.open("rb") as f: - header = f.read(4) - - return header == b"GGUF" - except Exception as e: - logger.debug("Error reading file %s: %s", model, e) - return False - - -@cache -def is_remote_gguf(model: str | Path) -> bool: - """Check if the model is a remote GGUF model. - - Recognizes two forms: - 1. Standard: ``repo_id:quant_type`` where *quant_type* is a known - GGML quantization type (e.g. ``Q4_K_M``). - 2. Non-standard: ``repo_id:quant_type`` where *quant_type* contains - a known GGML type with extra prefixes (e.g. ``UD-Q4_K_XL``). - A warning is logged and actual file existence is validated later - during download. - """ - pattern = r"^[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*:[A-Za-z0-9_+-]+$" - model = str(model) - if re.fullmatch(pattern, model): - _, quant_type = model.rsplit(":", 1) - if is_valid_gguf_quant_type(quant_type): - return True - if is_nonstandard_gguf_quant_type(quant_type): - logger.warning( - "Non-standard GGUF quant type '%s' detected.", - quant_type, - ) - return True - return False - - -def is_nonstandard_gguf_quant_type(quant_type: str) -> bool: - """Check if a non-standard quant type contains a known GGML type. - - Splits the quant type by the last ``-`` and checks whether the - trailing part is a standard GGML type. For example:: - - UD-Q4_K_XL → rsplit → ["UD", "Q4_K_XL"] → Q4_K_XL valid ✓ - UD-IQ4_NL → rsplit → ["UD", "IQ4_NL"] → IQ4_NL valid ✓ - Custom-UD-Q4_K → rsplit → ["Custom-UD", "Q4_K"] → Q4_K valid ✓ - RANDOM → no "-" → False - """ - if "-" not in quant_type: - return False - _, remainder = quant_type.rsplit("-", 1) - return is_valid_gguf_quant_type(remainder) - - -# Common suffixes used in GGUF file naming conventions -# e.g., Q4_K_M, Q3_K_S, Q5_K_L, Q2_K_XL -_GGUF_QUANT_SUFFIXES = ("_M", "_S", "_L", "_XL", "_XS", "_XXS") - - -def is_valid_gguf_quant_type(gguf_quant_type: str) -> bool: - """Check if the quant type is a valid GGUF quant type. - - Supports both exact GGML quant types (e.g., Q4_K, IQ1_S) and - extended naming conventions (e.g., Q4_K_M, Q3_K_S, Q5_K_L). - """ - # Check for exact match first - if getattr(GGMLQuantizationType, gguf_quant_type, None) is not None: - return True - - # Check for extended naming conventions (e.g., Q4_K_M -> Q4_K) - for suffix in _GGUF_QUANT_SUFFIXES: - if gguf_quant_type.endswith(suffix): - base_type = gguf_quant_type[: -len(suffix)] - if getattr(GGMLQuantizationType, base_type, None) is not None: - return True - - return False - - -def split_remote_gguf(model: str | Path) -> tuple[str, str]: - """Split the model into repo_id and quant type.""" - model = str(model) - if is_remote_gguf(model): - parts = model.rsplit(":", 1) - return (parts[0], parts[1]) - raise ValueError( - f"Wrong GGUF model or invalid GGUF quant type: {model}.\n" - "- It should be in repo_id:quant_type format.\n" - f"- Valid base quant types: {GGMLQuantizationType._member_names_}\n" - f"- Extended suffixes also supported: {_GGUF_QUANT_SUFFIXES}\n" - "- Non-standard GGUF quant types also supported: " - "dash-separated prefixes (e.g. UD-Q4_K_XL, Custom-Q8_0)", - ) - - -def is_gguf(model: str | Path) -> bool: - """Check if the model is a GGUF model. - - Args: - model: Model name, path, or Path object to check. - - Returns: - True if the model is a GGUF model, False otherwise. - """ - model = str(model) - - # Check if it's a local GGUF file - if check_gguf_file(model): - return True - - # Check if it's a remote GGUF model (repo_id:quant_type format) - return is_remote_gguf(model) - - -def detect_gguf_multimodal(model: str) -> Path | None: - """Check if GGUF model has multimodal projector file. - - Args: - model: Model path string - - Returns: - Path to mmproj file if found, None otherwise - """ - if not model.endswith(".gguf"): - return None - - try: - model_path = Path(model) - if not model_path.is_file(): - return None - - model_dir = model_path.parent - mmproj_patterns = ["mmproj.gguf", "mmproj-*.gguf", "*mmproj*.gguf"] - for pattern in mmproj_patterns: - mmproj_files = list(model_dir.glob(pattern)) - if mmproj_files: - return mmproj_files[0] - return None - except Exception: - return None - - -def extract_vision_config_from_gguf(mmproj_path: str) -> "SiglipVisionConfig | None": - """Extract vision config parameters from mmproj.gguf metadata. - - Reads vision encoder configuration from GGUF metadata fields using - standardized GGUF constants. Automatically detects the projector type - (e.g., gemma3, llama4) and applies model-specific parameters accordingly. - - The function extracts standard CLIP vision parameters from GGUF metadata - and applies projector-type-specific customizations. For unknown projector - types, it uses safe defaults from SiglipVisionConfig. - - Args: - mmproj_path: Path to mmproj.gguf file (str or Path) - - Returns: - SiglipVisionConfig if extraction succeeds, None if any required - field is missing from the GGUF metadata - - Raises: - Exception: Exceptions from GGUF reading (file not found, corrupted - file, etc.) propagate directly from gguf.GGUFReader - """ - reader = gguf.GGUFReader(str(mmproj_path)) - - # Detect projector type to apply model-specific parameters - projector_type = None - projector_type_field = reader.get_field(Keys.Clip.PROJECTOR_TYPE) - if projector_type_field: - try: - projector_type = bytes(projector_type_field.parts[-1]).decode("utf-8") - except (AttributeError, UnicodeDecodeError) as e: - logger.warning("Failed to decode projector type from GGUF: %s", e) - - # Map GGUF field constants to SiglipVisionConfig parameters. - # Uses official GGUF constants from gguf-py for standardization. - # Format: {gguf_constant: (param_name, dtype)} - VISION_CONFIG_FIELDS = { - Keys.ClipVision.EMBEDDING_LENGTH: ("hidden_size", int), - Keys.ClipVision.FEED_FORWARD_LENGTH: ("intermediate_size", int), - Keys.ClipVision.BLOCK_COUNT: ("num_hidden_layers", int), - Keys.ClipVision.Attention.HEAD_COUNT: ("num_attention_heads", int), - Keys.ClipVision.IMAGE_SIZE: ("image_size", int), - Keys.ClipVision.PATCH_SIZE: ("patch_size", int), - Keys.ClipVision.Attention.LAYERNORM_EPS: ("layer_norm_eps", float), - } - - # Extract and validate all required fields - config_params = {} - for gguf_key, (param_name, dtype) in VISION_CONFIG_FIELDS.items(): - field = reader.get_field(gguf_key) - if field is None: - logger.warning( - "Missing required vision config field '%s' in mmproj.gguf", - gguf_key, - ) - return None - # Extract scalar value from GGUF field and convert to target type - config_params[param_name] = dtype(field.parts[-1]) - - # Apply model-specific parameters based on projector type - if projector_type == VisionProjectorType.GEMMA3: - # Gemma3 doesn't use the vision pooling head (multihead attention) - # This is a vLLM-specific parameter used in SiglipVisionTransformer - config_params["vision_use_head"] = False - logger.info("Detected Gemma3 projector, disabling vision pooling head") - # Add other projector-type-specific customizations here as needed - # elif projector_type == VisionProjectorType.LLAMA4: - # config_params["vision_use_head"] = ... - - # Create config with extracted parameters - # Note: num_channels and attention_dropout use SiglipVisionConfig defaults - # (3 and 0.0 respectively) which are correct for all models - config = SiglipVisionConfig(**config_params) - - if projector_type: - logger.info( - "Extracted vision config from mmproj.gguf (projector_type: %s)", - projector_type, - ) - else: - logger.info("Extracted vision config from mmproj.gguf metadata") - - return config - - -def maybe_patch_hf_config_from_gguf( - model: str, - hf_config: PretrainedConfig, -) -> PretrainedConfig: - """Patch HF config for GGUF models. - - Applies GGUF-specific patches to HuggingFace config: - 1. For multimodal models: patches architecture and vision config - 2. For all GGUF models: overrides vocab_size from embedding tensor - - This ensures compatibility with GGUF models that have extended - vocabularies (e.g., Unsloth) where the GGUF file contains more - tokens than the HuggingFace tokenizer config specifies. - - Args: - model: Model path string - hf_config: HuggingFace config to patch in-place - - Returns: - Updated HuggingFace config - """ - # Patch multimodal config if mmproj.gguf exists - mmproj_path = detect_gguf_multimodal(model) - if mmproj_path is not None: - vision_config = extract_vision_config_from_gguf(str(mmproj_path)) - - # Create HF config for Gemma3 multimodal - text_config = hf_config.get_text_config() - is_gemma3 = hf_config.model_type in ("gemma3", "gemma3_text") - if vision_config is not None and is_gemma3: - new_hf_config = Gemma3Config( - text_config=text_config, - vision_config=vision_config, - architectures=["Gemma3ForConditionalGeneration"], - ) - hf_config = new_hf_config - - return hf_config - - -def get_gguf_file_path_from_hf( - repo_id: str | Path, - quant_type: str, - revision: str | None = None, -) -> str: - """Get the GGUF file path from HuggingFace Hub based on repo_id and quant_type. - - Args: - repo_id: The HuggingFace repository ID (e.g., "Qwen/Qwen3-0.6B") - quant_type: The quantization type (e.g., "Q4_K_M", "F16") - revision: Optional revision/branch name - - Returns: - The path to the GGUF file on HuggingFace Hub (e.g., "filename.gguf"), - """ - repo_id = str(repo_id) - gguf_patterns = [ - f"*-{quant_type}.gguf", - f"*-{quant_type}-*.gguf", - f"*/*-{quant_type}.gguf", - f"*/*-{quant_type}-*.gguf", - ] - matching_files = list_filtered_repo_files( - repo_id, - allow_patterns=gguf_patterns, - revision=revision, - ) - - if len(matching_files) == 0: - raise ValueError( - "Could not find GGUF file for repo %s with quantization %s.", - repo_id, - quant_type, - ) - - # Sort to ensure consistent ordering (prefer non-sharded files) - matching_files.sort(key=lambda x: (x.count("-"), x)) - gguf_filename = matching_files[0] - return gguf_filename diff --git a/vllm/transformers_utils/model_arch_config_convertor.py b/vllm/transformers_utils/model_arch_config_convertor.py index d706b5057422..7ced083afb54 100644 --- a/vllm/transformers_utils/model_arch_config_convertor.py +++ b/vllm/transformers_utils/model_arch_config_convertor.py @@ -50,7 +50,7 @@ def get_head_size(self) -> int: # special case for deepseek_v4 if hasattr(self.hf_text_config, "compress_ratios"): return self.hf_text_config.head_dim - qk_rope_head_dim = getattr(self.hf_text_config, "qk_rope_head_dim", 0) + qk_rope_head_dim = self._get_qk_rope_head_dim() if not envs.VLLM_MLA_DISABLE: return self.hf_text_config.kv_lora_rank + qk_rope_head_dim else: @@ -58,9 +58,12 @@ def get_head_size(self) -> int: if qk_rope_head_dim and qk_nope_head_dim: return qk_rope_head_dim + qk_nope_head_dim - # NOTE: Some configs may set head_dim=None in the config - if getattr(self.hf_text_config, "head_dim", None) is not None: - return self.hf_text_config.head_dim + # NOTE: Some config classes may set head_dim=None or materialize a missing + # head_dim as 0 (for example, DeepseekVLV2TextConfig). + if ( + head_dim := getattr(self.hf_text_config, "head_dim", None) + ) is not None and head_dim > 0: + return head_dim # NOTE: Some models (such as PLaMo2.1) use `hidden_size_per_head` if getattr(self.hf_text_config, "hidden_size_per_head", None) is not None: @@ -71,6 +74,38 @@ def get_head_size(self) -> int: # FIXME(woosuk): This may not be true for all models. return self.get_hidden_size() // total_num_attention_heads + def _get_qk_rope_head_dim(self) -> int: + """Get qk_rope_head_dim, fixing the transformers v5.4+ attribute_map bug.""" + cfg = self.hf_text_config + qk_rope_head_dim = getattr(cfg, "qk_rope_head_dim", 0) + qk_nope_head_dim = getattr(cfg, "qk_nope_head_dim", 0) + + # In valid MLA configs, qk_rope_head_dim != qk_nope_head_dim. + if qk_rope_head_dim == 0 or qk_rope_head_dim != qk_nope_head_dim: + return qk_rope_head_dim # not corrupted + + # Read the correct value from raw config.json. + from vllm.transformers_utils.repo_utils import get_hf_file_to_dict + + model_path = self.hf_config.name_or_path + if not model_path: + return qk_rope_head_dim + raw = get_hf_file_to_dict("config.json", model_path) + if raw and "qk_rope_head_dim" in raw: + correct = raw["qk_rope_head_dim"] + if correct != qk_rope_head_dim: + logger.info( + "Fixing qk_rope_head_dim: %d -> %d " + "(transformers v5.4+ attribute_map bug)", + qk_rope_head_dim, + correct, + ) + # Patch the config so downstream model layers also get + # the correct value. + cfg.qk_rope_head_dim = correct + return correct + return qk_rope_head_dim + def get_total_num_kv_heads(self) -> int: attributes = [ # For Falcon: @@ -236,6 +271,7 @@ def is_deepseek_mla(self) -> bool: "pangu_ultra_moe", "pangu_ultra_moe_mtp", "bailing_hybrid", + "bailing_hybrid_mtp", ): # check is deepseek_v4 model if hasattr(self.hf_text_config, "compress_ratios"): @@ -275,6 +311,12 @@ def is_mm_prefix_lm(self) -> bool: return False return self.hf_config.model_type in MM_PREFIX_LM_MODELS + def rswa_window(self) -> int | None: + value = getattr(self.hf_config, "rswa_window", None) + if value is None: + return None + return int(value) + def derive_max_model_len_and_key(self) -> tuple[float, str | None]: derived_max_model_len = float("inf") possible_keys = [ @@ -304,7 +346,7 @@ def derive_max_model_len_and_key(self) -> tuple[float, str | None]: max_len_key = key derived_max_model_len = min(derived_max_model_len, max_len) - # For Command-R / Cohere, Cohere2 / Aya Vision models + # For Command-R / Cohere, Cohere2 models if tmp_max_len := getattr(self.hf_text_config, "model_max_length", None): max_len_key = "model_max_length" derived_max_model_len = tmp_max_len @@ -325,6 +367,7 @@ def convert(self) -> ModelArchitectureConfig: quantization_config=self.get_quantization_config(), is_deepseek_mla=self.is_deepseek_mla(), is_mm_prefix_lm=self.is_mm_prefix_lm(), + rswa_window=self.rswa_window(), derived_max_model_len_and_key=self.derive_max_model_len_and_key(), ) @@ -497,6 +540,11 @@ def get_num_hidden_layers(self) -> int: return getattr(self.hf_text_config, "num_nextn_predict_layers", 0) +class BailingHybridMTPModelArchConfigConvertor(ModelArchConfigConvertorBase): + def get_num_hidden_layers(self) -> int: + return getattr(self.hf_text_config, "num_nextn_predict_layers", 0) + + class Qwen3_5MTPModelArchConfigConvertor(ModelArchConfigConvertorBase): def get_num_hidden_layers(self) -> int: return getattr(self.hf_text_config, "mtp_num_hidden_layers", 0) @@ -545,11 +593,57 @@ def get_head_size(self) -> int: return max(head_dim, global_head_dim) or super().get_head_size() +class MossAudioModelArchConfigConvertor(ModelArchConfigConvertorBase): + def _language_config(self) -> PretrainedConfig: + return self.hf_config.language_config + + def get_num_hidden_layers(self) -> int: + return getattr(self._language_config(), "num_hidden_layers", 0) + + def get_total_num_attention_heads(self) -> int: + return getattr(self._language_config(), "num_attention_heads", 0) + + def get_vocab_size(self) -> int: + return getattr(self._language_config(), "vocab_size", 0) + + def get_hidden_size(self) -> int: + return getattr(self._language_config(), "hidden_size", 0) + + def get_head_size(self) -> int: + head_dim = getattr(self._language_config(), "head_dim", None) + if head_dim is not None: + return head_dim + total_num_attention_heads = self.get_total_num_attention_heads() + if total_num_attention_heads == 0: + return 0 + return self.get_hidden_size() // total_num_attention_heads + + def get_total_num_kv_heads(self) -> int: + return getattr( + self._language_config(), + "num_key_value_heads", + self.get_total_num_attention_heads(), + ) + + def derive_max_model_len_and_key(self) -> tuple[float, str | None]: + language_config = self._language_config() + max_position_embeddings = getattr( + language_config, + "max_position_embeddings", + None, + ) + if max_position_embeddings is None: + return super().derive_max_model_len_and_key() + return max_position_embeddings, "language_config.max_position_embeddings" + + # hf_config.model_type -> convertor class MODEL_ARCH_CONFIG_CONVERTORS = { + "bailing_hybrid_mtp": BailingHybridMTPModelArchConfigConvertor, "cohere_asr": CohereAsrModelArchConfigConvertor, "dbrx": DbrxModelArchConfigConvertor, "deepseek_mtp": DeepSeekMTPModelArchConfigConvertor, + "diffusion_gemma_text": Gemma4ModelArchConfigConvertor, "ernie_mtp": ErnieMTPModelArchConfigConvertor, "falcon": FalconModelArchConfigConvertor, "falcon_mamba": MambaModelArchConfigConvertor, @@ -568,6 +662,7 @@ def get_head_size(self) -> int: "mimo_v2_flash": MimoV2ModelArchConfigConvertor, "mimo_v2_mtp": MimoV2MTPModelArchConfigConvertor, "mimo_v2_omni_mtp": MimoV2MTPModelArchConfigConvertor, + "moss_audio": MossAudioModelArchConfigConvertor, "mpt": MPTModelArchConfigConvertor, "nemotron-nas": NemotronNasModelArchConfigConvertor, "pangu_ultra_moe_mtp": PanguUltraMoeMTPModelArchConfigConvertor, diff --git a/vllm/transformers_utils/processor.py b/vllm/transformers_utils/processor.py index ec01f65d7749..aa33faed916c 100644 --- a/vllm/transformers_utils/processor.py +++ b/vllm/transformers_utils/processor.py @@ -18,6 +18,7 @@ from transformers.feature_extraction_utils import FeatureExtractionMixin from transformers.image_processing_utils import BaseImageProcessor from transformers.image_utils import ImageInput +from transformers.models.auto.video_processing_auto import VIDEO_PROCESSOR_MAPPING_NAMES from transformers.processing_utils import ProcessorMixin from transformers.video_processing_utils import BaseVideoProcessor from transformers.video_utils import VideoInput @@ -25,7 +26,6 @@ from vllm.logger import init_logger from vllm.transformers_utils import processors -from vllm.transformers_utils.gguf_utils import is_gguf from vllm.transformers_utils.repo_utils import get_hf_file_to_dict from vllm.transformers_utils.utils import convert_model_repo_to_path from vllm.utils.func_utils import get_allowed_kwarg_only_overrides @@ -59,10 +59,6 @@ def _transformers_v4_compatibility_init() -> Any: This can be removed if `Molmo2ForConditionalGeneration` is upstreamed to Transformers.""" - # Transformers v4 - if hasattr(ProcessorMixin, "optional_attributes"): - return - # Transformers v5 if hasattr(ProcessorMixin.__init__, "_vllm_patched"): return @@ -85,6 +81,7 @@ def __init__(self, *args, **kwargs): _transformers_v4_compatibility_init() _P = TypeVar("_P", bound=ProcessorMixin, default=ProcessorMixin) +_I = TypeVar("_I", bound=BaseImageProcessor, default=BaseImageProcessor) _V = TypeVar("_V", bound=BaseVideoProcessor, default=BaseVideoProcessor) @@ -174,6 +171,15 @@ def get_video_processor_cls_name_from_config( config = get_hf_file_to_dict(file, processor_name, revision=revision) if config and "video_processor_type" in config: return config["video_processor_type"] + + # Some models ship no explicit ``video_processor_type`` in their + # preprocessor config. Fall back to transformers' ``model_type`` -> video + # processor mapping so these still resolve to their registered loader + # instead of the generic opencv fallback. The mapping is ``None`` for a + # given type when torchvision is unavailable; callers then use opencv. + model_config = get_hf_file_to_dict("config.json", processor_name, revision=revision) + if model_config and "model_type" in model_config: + return VIDEO_PROCESSOR_MAPPING_NAMES.get(model_config["model_type"]) return None @@ -185,17 +191,8 @@ def get_video_processor_cls_name_from_config( def get_video_processor_cls_name( model_config: "ModelConfig", ) -> str | None: - if is_gguf(model_config.model): - assert not is_gguf(model_config.tokenizer), ( - "For multimodal GGUF models, the original tokenizer " - "should be used to correctly load video processor metadata." - ) - model = model_config.tokenizer - revision = model_config.tokenizer_revision - else: - model = model_config.model - revision = model_config.revision - + model = model_config.model + revision = model_config.revision return _cached_get_video_processor_cls_name(model, revision=revision) @@ -379,20 +376,9 @@ def cached_processor_from_config( processor_cls: type[_P] | tuple[type[_P], ...] = ProcessorMixin, **kwargs: Any, ) -> _P: - if is_gguf(model_config.model): - assert not is_gguf(model_config.tokenizer), ( - "For multimodal GGUF models, the original tokenizer " - "should be used to correctly load processor." - ) - model = model_config.tokenizer - revision = model_config.tokenizer_revision - else: - model = model_config.model - revision = model_config.revision - return cached_get_processor_without_dynamic_kwargs( - model, - revision=revision, + model_config.model, + revision=model_config.revision, trust_remote_code=model_config.trust_remote_code, processor_cls=processor_cls, # type: ignore[arg-type] **_merge_mm_kwargs(model_config, processor_cls, **kwargs), @@ -455,12 +441,14 @@ def get_image_processor( *args: Any, revision: str | None = None, trust_remote_code: bool = False, + processor_cls_overrides: type[_I] | None = None, **kwargs: Any, ): """Load an image processor for the given model name via HuggingFace.""" try: processor_name = convert_model_repo_to_path(processor_name) - processor = AutoImageProcessor.from_pretrained( + processor_cls = processor_cls_overrides or AutoImageProcessor + processor = processor_cls.from_pretrained( processor_name, *args, revision=revision, @@ -493,19 +481,9 @@ def cached_image_processor_from_config( model_config: "ModelConfig", **kwargs: Any, ): - if is_gguf(model_config.model): - assert not is_gguf(model_config.tokenizer), ( - "For multimodal GGUF models, the original tokenizer " - "should be used to correctly load image processor." - ) - model = model_config.tokenizer - revision = model_config.tokenizer_revision - else: - model = model_config.model - revision = model_config.revision return cached_get_image_processor( - model, - revision=revision, + model_config.model, + revision=model_config.revision, trust_remote_code=model_config.trust_remote_code, **_merge_mm_kwargs(model_config, AutoImageProcessor, **kwargs), ) diff --git a/vllm/transformers_utils/processors/__init__.py b/vllm/transformers_utils/processors/__init__.py index b53dd87d6088..e4ece0a41972 100644 --- a/vllm/transformers_utils/processors/__init__.py +++ b/vllm/transformers_utils/processors/__init__.py @@ -31,6 +31,9 @@ "MiMoOmniProcessor", "MiniCPMOProcessor", "MiniCPMVProcessor", + "MiniMaxM3VLImageProcessor", + "MiniMaxM3VLVideoProcessor", + "MiniMaxVLProcessor", "MistralCommonPixtralProcessor", "MistralCommonVoxtralProcessor", "NanoNemotronVLProcessor", @@ -40,7 +43,6 @@ "OpenVLAProcessor", "OvisProcessor", "Ovis2_5Processor", - "QwenVLProcessor", "Qwen3ASRProcessor", "Step3VLProcessor", ] @@ -65,6 +67,9 @@ "MiMoOmniProcessor": "vllm.transformers_utils.processors.mimo_v2_omni", "MiniCPMOProcessor": "vllm.transformers_utils.processors.minicpmo", "MiniCPMVProcessor": "vllm.transformers_utils.processors.minicpmv", + "MiniMaxM3VLImageProcessor": "vllm.transformers_utils.processors.minimax_m3", + "MiniMaxM3VLVideoProcessor": "vllm.transformers_utils.processors.minimax_m3", + "MiniMaxVLProcessor": "vllm.transformers_utils.processors.minimax_m3", "MistralCommonPixtralProcessor": "vllm.transformers_utils.processors.pixtral", "MistralCommonVoxtralProcessor": "vllm.transformers_utils.processors.voxtral", "Moondream3Processor": "vllm.transformers_utils.processors.moondream3", @@ -75,7 +80,6 @@ "OpenVLAProcessor": "vllm.transformers_utils.processors.openvla", "OvisProcessor": "vllm.transformers_utils.processors.ovis", "Ovis2_5Processor": "vllm.transformers_utils.processors.ovis2_5", - "QwenVLProcessor": "vllm.transformers_utils.processors.qwen_vl", "Qwen3ASRProcessor": "vllm.transformers_utils.processors.qwen3_asr", "Step3VLProcessor": "vllm.transformers_utils.processors.step3_vl", } diff --git a/vllm/transformers_utils/processors/deepseek_ocr.py b/vllm/transformers_utils/processors/deepseek_ocr.py index 68a2b1aaaa02..618070b506f7 100644 --- a/vllm/transformers_utils/processors/deepseek_ocr.py +++ b/vllm/transformers_utils/processors/deepseek_ocr.py @@ -161,10 +161,12 @@ def __init__( image_size: int = IMAGE_SIZE, base_size: int = BASE_SIZE, strategy: Literal["v1", "v2"] = "v1", + max_crops: int = MAX_CROPS, **kwargs, ): self.image_size = image_size self.base_size = base_size + self.max_crops = max_crops # image token calculation strategy for # Deepseek-OCR and Deepseek-OCR-2 @@ -332,7 +334,7 @@ def tokenize_with_images( crop_ratio = [1, 1] elif cropping: images_crop_raw, crop_ratio = dynamic_preprocess( - image, image_size=self.image_size + image, image_size=self.image_size, max_num=self.max_crops ) else: crop_ratio = [1, 1] diff --git a/vllm/transformers_utils/processors/fireredlid.py b/vllm/transformers_utils/processors/fireredlid.py index cb041397d036..3afdca4c7bbc 100644 --- a/vllm/transformers_utils/processors/fireredlid.py +++ b/vllm/transformers_utils/processors/fireredlid.py @@ -232,7 +232,7 @@ class FireRedLIDProcessor(ProcessorMixin): """ feature_extractor_class = "FireRedLIDFeatureExtractor" - tokenizer_class = ("PreTrainedTokenizer", "PreTrainedTokenizerFast") + tokenizer_class = ("PythonBackend", "TokenizersBackend") def __init__(self, feature_extractor, tokenizer): super().__init__(feature_extractor, tokenizer) diff --git a/vllm/transformers_utils/processors/glm4v.py b/vllm/transformers_utils/processors/glm4v.py index 3ecb1bae531a..a8da23955259 100644 --- a/vllm/transformers_utils/processors/glm4v.py +++ b/vllm/transformers_utils/processors/glm4v.py @@ -3,7 +3,7 @@ # Adapted from # https://github.com/zai-org/CogAgent -from transformers import PreTrainedTokenizer +from transformers import PythonBackend from transformers.image_processing_utils_fast import BaseImageProcessorFast from transformers.image_utils import PILImageResampling from transformers.processing_utils import ProcessorMixin @@ -30,7 +30,7 @@ class GLM4VProcessor(ProcessorMixin): def __init__( self, image_processor: GLM4VImageProcessorFast, - tokenizer: PreTrainedTokenizer, + tokenizer: PythonBackend, ) -> None: self.image_processor = image_processor self.tokenizer = tokenizer diff --git a/vllm/transformers_utils/processors/hunyuan_vl.py b/vllm/transformers_utils/processors/hunyuan_vl.py deleted file mode 100644 index 2d0e4db97a6f..000000000000 --- a/vllm/transformers_utils/processors/hunyuan_vl.py +++ /dev/null @@ -1,226 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -# adapted from https://github.com/ManaEstras/transformers/blob/v4.57.1.hyvl/src/transformers/models/hunyuan_vl/processing_hunyuan_vl.py - -import numpy as np -import torch -from transformers.feature_extraction_utils import BatchFeature -from transformers.image_utils import ImageInput -from transformers.processing_utils import ProcessorMixin -from transformers.tokenization_utils_base import PreTokenizedInput, TextInput -from transformers.video_utils import VideoInput - - -class HunYuanVLProcessor(ProcessorMixin): - attributes = ["image_processor", "tokenizer"] - valid_kwargs = ["chat_template"] - image_processor_class = "AutoImageProcessor" - tokenizer_class = "AutoTokenizer" # ("AutoTokenizer", None) - - def __init__( - self, - image_processor=None, - tokenizer=None, - chat_template=None, - **kwargs, - ): - # TODO Fix the init - self.tokenizer = tokenizer - self.image_token_id = 120120 # self.tokenizer.image_token_id - self.image_token = self.tokenizer.convert_ids_to_tokens(self.image_token_id) - self.im_start_token_id = 120118 # self.tokenizer.im_start_id - self.im_start_token = self.tokenizer.convert_ids_to_tokens( - self.im_start_token_id - ) - self.im_end_token_id = 120119 # self.tokenizer.im_end_id - self.im_end_token = self.tokenizer.convert_ids_to_tokens(self.im_end_token_id) - self.placeholder_token = self.tokenizer.convert_ids_to_tokens( - self.tokenizer.vocab_size - 1 - ) - self.pad_id = 120002 # self.tokenizer.pad_token_id - - super().__init__(image_processor, tokenizer, chat_template=chat_template) - - def __call__( - self, - images: ImageInput = None, - text: TextInput - | PreTokenizedInput - | list[TextInput] - | list[PreTokenizedInput] = None, - videos: VideoInput = None, - **kwargs, - ) -> BatchFeature: - image_inputs = {} - if images is not None: - image_inputs = self.image_processor(images=images) - image_grid_thw = image_inputs["image_grid_thw"] - - if not isinstance(text, list): - text = [text] - - text = text.copy() # below lines change text in-place - - image_tokens_cumsum = [0] - if images is not None: - index = 0 - for i in range(len(text)): - while self.image_token in text[i]: - grid_h, grid_w = image_grid_thw[index][-2:] - patch_h = grid_h // self.image_processor.merge_size - patch_w = grid_w // self.image_processor.merge_size - num_image_tokens = patch_h * (patch_w + 1) + 2 - image_tokens_cumsum.append( - image_tokens_cumsum[-1] + num_image_tokens - ) - # text[i] = text[i].replace(self.image_token, self.im_start_token + self.placeholder_token * num_image_tokens + self.im_end_token, 1) # noqa: E501 - text[i] = text[i].replace( - self.image_token, self.placeholder_token * num_image_tokens, 1 - ) - index += 1 - text[i] = text[i].replace(self.placeholder_token, self.image_token) - # text[i] = self.tokenizer.bos_token + text[i] - - text_inputs = self.tokenizer(text, add_special_tokens=False, **kwargs) - self._check_special_mm_tokens(text, text_inputs, modalities=["image"]) - - input_ids = text_inputs["input_ids"] - position_ids = torch.arange(len(input_ids[0])) - position_ids_w = torch.arange(len(input_ids[0])) - position_ids_h = torch.arange(len(input_ids[0])) - position_ids_t = torch.arange(len(input_ids[0])) - - if images is not None: - image_token_pos_indices = torch.where(input_ids[0] == self.image_token_id)[ - 0 - ] - for i in range(len(image_grid_thw)): - grid_h, grid_w = image_grid_thw[i][-2:] - patch_h = grid_h // self.image_processor.merge_size - patch_w = grid_w // self.image_processor.merge_size - start_pos = image_token_pos_indices[image_tokens_cumsum[i]].item() + 1 - replace_num = (patch_w + 1) * patch_h - position_ids_w[start_pos : start_pos + replace_num] = torch.tensor( - list(range(patch_w + 1)) * patch_h, dtype=torch.int64 - ) - patch_h_list = [] - for h in range(patch_h): - patch_h_list += [h] * (patch_w + 1) - position_ids_h[start_pos : start_pos + replace_num] = torch.tensor( - patch_h_list, dtype=torch.int64 - ) - position_ids_t[start_pos : start_pos + replace_num] = 0 - - position_ids = torch.stack( - [position_ids, position_ids_w, position_ids_h, position_ids_t] - ).unsqueeze(0) - text_inputs["position_ids"] = position_ids - - attention_mask = input_ids.ne(self.pad_id) - text_inputs["attention_mask"] = attention_mask - text_inputs["imgs_pos"] = [self.get_imgs_pos(e) for e in input_ids] - # image_inputs["imgs"] = [[image_inputs["pixel_values"]]] - - return_tensors = kwargs.pop("return_tensors", None) - return BatchFeature( - data={**text_inputs, **image_inputs}, - tensor_type=return_tensors, - ) - - def batch_decode(self, *args, **kwargs): - return self.tokenizer.batch_decode(*args, **kwargs) - - def decode(self, *args, **kwargs): - return self.tokenizer.decode(*args, **kwargs) - - def post_process_image_text_to_text( - self, - generated_outputs, - skip_special_tokens=True, - clean_up_tokenization_spaces=False, - **kwargs, - ): - assert 0 - - def apply_chat_template(self, *args, **kwargs): - kwargs["return_dict"] = False - return self.tokenizer.apply_chat_template(*args, **kwargs) - - def get_imgs_pos(self, doc_ids): - doc_ids = np.array(doc_ids, dtype=np.int64) - img_begin_index = np.where(doc_ids == self.im_start_token_id)[0] - img_end_index = np.where(doc_ids == self.im_end_token_id)[0] - imgs_pos = np.concatenate( - ( - np.reshape(img_begin_index + 1, (-1, 1)), - np.reshape(img_end_index, (-1, 1)), - ), - axis=-1, - ).tolist() - return imgs_pos - - @property - def model_input_names(self): - tokenizer_input_names = self.tokenizer.model_input_names - image_processor_input_names = self.image_processor.model_input_names - return list(dict.fromkeys(tokenizer_input_names + image_processor_input_names)) - - -def split_image_into_patch_blocks( - pixel_values: torch.Tensor, # shape: [batch_size, 3, H, W] - patch_size: int = 16, # e.g. 16 - adaptor_patch_div: int = 4, # e.g. 4 --> each patch_size is cut into 4x4 small regions, i.e. patch_size // 4 # noqa: E501 -) -> torch.Tensor: - """ - Split the input image tensor (supporting batch) into large patches of size `patch_size`, - and then further divide each large patch into smaller regions of size - (patch_size // adaptor_patch_div) x (patch_size // adaptor_patch_div). - Each small region is extracted as a tensor of shape [3, patch_size, patch_size]. - The final output contains all such small region tensors. - - Args: - pixel_values: Input image tensor of shape [batch_size, 3, H, W]. - patch_size: Size of the large patch, e.g., 16. - adaptor_patch_div: Each large patch is divided into - (patch_size // adaptor_patch_div) x (patch_size // adaptor_patch_div) - smaller regions. - - Returns: - patches: A tensor of shape [N, 3, patch_size, patch_size], - where N = batch_size * (H // patch_size) * (W // patch_size) * (patch_size // adaptor_patch_div)^2. - Each element in the batch corresponds to one small image region. - """ # noqa: E501 - batch_size, channels, height, width = pixel_values.shape - assert channels == 3, "Pixel values must have 3 channels in dim=1" - assert height % patch_size == 0 and width % patch_size == 0, ( - "H and W must be divisible by patch_size" - ) - - patch_height_num = height // patch_size - patch_width_num = width // patch_size - - # Reshape to [B, 3, ph, ps, pw, ps] - img = pixel_values.reshape( - batch_size, 3, patch_height_num, patch_size, patch_width_num, patch_size - ) - - # Further split each psxps patch into (ps//aps)x(ps//aps) small regions - img = img.reshape( - batch_size, - 3, - patch_height_num, - patch_size // adaptor_patch_div, # ps // aps - adaptor_patch_div, - patch_width_num, - patch_size // adaptor_patch_div, # ps // aps - adaptor_patch_div, - ) - - # Permute to group the small regions: [B, ph, pw, ps//aps, ps//aps, 3, aps, aps] - img = img.permute(0, 2, 5, 3, 6, 1, 4, 7) - - # Reshape into [B * ph * pw * (ps//aps)^2, 3, patch_size, patch_size] - patches = img.reshape(-1, 3, patch_size, patch_size) - - return patches diff --git a/vllm/transformers_utils/processors/hunyuan_vl_image.py b/vllm/transformers_utils/processors/hunyuan_vl_image.py deleted file mode 100644 index 0b10ae249dbb..000000000000 --- a/vllm/transformers_utils/processors/hunyuan_vl_image.py +++ /dev/null @@ -1,477 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -# adapted from https://github.com/ManaEstras/transformers/blob/v4.57.1.hyvl/src/transformers/models/hunyuan_vl/image_processing_hunyuan_vl.py -"""Image processor class for HunYuanVL.""" - -# isort conflicts with ruff for transformers imports -# isort: skip_file -import math - -import numpy as np -import torchvision.transforms as transforms -from transformers import AutoImageProcessor -from transformers.image_processing_utils import BaseImageProcessor, BatchFeature -from transformers.image_transforms import ( - convert_to_rgb, -) -from transformers.image_utils import ( - OPENAI_CLIP_MEAN, - OPENAI_CLIP_STD, - ChannelDimension, - ImageInput, - PILImageResampling, - make_flat_list_of_images, - make_list_of_images, - valid_images, - validate_preprocess_arguments, -) -from transformers.utils import TensorType, logging -from transformers.video_utils import VideoInput, make_batched_videos - -logger = logging.get_logger(__name__) - - -def smart_resize( - height: int, - width: int, - factor: int = 16, - min_pixels: int = 512 * 512, - max_pixels: int = 2048 * 2048, -): - """Rescales the image so that the following conditions are met: - - 1. Both dimensions (height and width) are divisible by 'factor'. - - 2. The total number of pixels is within the range ['min_pixels', 'max_pixels']. - - 3. The aspect ratio of the image is maintained as closely as possible. - - """ - if max(height, width) / min(height, width) > 200: - raise ValueError( - "absolute aspect ratio must be smaller than 200, got " - f"{max(height, width) / min(height, width)}" - ) - h_bar = round(height / factor) * factor - w_bar = round(width / factor) * factor - if h_bar * w_bar > max_pixels: - beta = math.sqrt((height * width) / max_pixels) - h_bar = max(factor, math.floor(height / beta / factor) * factor) - w_bar = max(factor, math.floor(width / beta / factor) * factor) - elif h_bar * w_bar < min_pixels: - beta = math.sqrt(min_pixels / (height * width)) - h_bar = math.ceil(height * beta / factor) * factor - w_bar = math.ceil(width * beta / factor) * factor - return h_bar, w_bar - - -class HunYuanVLImageProcessor(BaseImageProcessor): - model_input_names = [ - "pixel_values", - "image_grid_thw", - "pixel_values_videos", - "video_grid_thw", - ] - - def __init__( - self, - do_resize: bool = True, - size: dict[str, int] | None = None, - resample: PILImageResampling = PILImageResampling.BICUBIC, - do_rescale: bool = True, - rescale_factor: int | float = 1 / 255, - do_normalize: bool = True, - image_mean: float | list[float] | None = None, - image_std: float | list[float] | None = None, - do_convert_rgb: bool = True, - min_pixels: int | None = None, - max_pixels: int | None = None, - patch_size: int = 16, - temporal_patch_size: int = 2, - merge_size: int = 2, - **kwargs, - ) -> None: - super().__init__(**kwargs) - if size is not None and ( - "shortest_edge" not in size or "longest_edge" not in size - ): - raise ValueError( - "size must contain 'shortest_edge' and 'longest_edge' keys." - ) - else: - size = {"shortest_edge": 512 * 512, "longest_edge": 2048 * 2048} - # backward compatibility: override size with min_pixels and max_pixels - # if they are provided. - if min_pixels is not None: - size["shortest_edge"] = min_pixels - if max_pixels is not None: - size["longest_edge"] = max_pixels - self.min_pixels = size["shortest_edge"] - self.max_pixels = size["longest_edge"] - self.size = size - - self.do_resize = do_resize - self.resample = resample - self.do_rescale = do_rescale - self.rescale_factor = rescale_factor - self.do_normalize = do_normalize - self.image_mean = image_mean if image_mean is not None else OPENAI_CLIP_MEAN - self.image_std = image_std if image_std is not None else OPENAI_CLIP_STD - - self.patch_size = patch_size - self.temporal_patch_size = temporal_patch_size - self.merge_size = merge_size - self.do_convert_rgb = do_convert_rgb - - # hard-code - - def _preprocess( - self, - images: ImageInput | VideoInput, - do_resize: bool | None = None, - size: dict[str, int] | None = None, - resample: PILImageResampling = None, - do_rescale: bool | None = None, - rescale_factor: float | None = None, - do_normalize: bool | None = None, - image_mean: float | list[float] | None = None, - image_std: float | list[float] | None = None, - patch_size: int = 16, - temporal_patch_size: int = 2, - merge_size: int = 2, - do_convert_rgb: bool | None = None, - data_format: ChannelDimension | None = ChannelDimension.FIRST, - input_data_format: str | ChannelDimension | None = None, - ): - """ - Preprocess an image or batch of images. Copy of the `preprocess` method from `CLIPImageProcessor`. - - Args: - images (`ImageInput`): - Image or batch of images to preprocess. Expects pixel values ranging from 0 to 255. If pixel values range from 0 to 1, set `do_rescale=False`. - do_resize (`bool`, *optional*, defaults to `self.do_resize`): - Whether to resize the image. - size (`dict[str, int]`, *optional*, defaults to `self.size`): - Size of the image after resizing. `shortest_edge` and `longest_edge` keys must be present. - resample (`PILImageResampling`, *optional*, defaults to `self.resample`): - Resampling filter to use if resizing the image. This can be one of the `PILImageResampling` enums. - do_rescale (`bool`, *optional*, defaults to `self.do_rescale`): - Whether to rescale the image. - rescale_factor (`float`, *optional*, defaults to `self.rescale_factor`): - Scale factor to use if rescaling the image. - do_normalize (`bool`, *optional*, defaults to `self.do_normalize`): - Whether to normalize the image. - image_mean (`float` or `list[float]`, *optional*, defaults to `self.image_mean`): - Mean to use if normalizing the image. Can be a float or a list of floats corresponding to the number of channels in the image. - image_std (`float` or `list[float]`, *optional*, defaults to `self.image_std`): - Standard deviation to use if normalizing the image. Can be a float or a list of floats corresponding to the number of channels in the image. - patch_size (`int`, *optional*, defaults to `self.patch_size`): - The spatial patch size of the vision encoder. - temporal_patch_size (`int`, *optional*, defaults to `self.temporal_patch_size`): - The temporal patch size of the vision encoder. - merge_size (`int`, *optional*, defaults to `self.merge_size`): - The merge size of the vision encoder to llm encoder. - do_convert_rgb (`bool`, *optional*, defaults to `self.do_convert_rgb`): - Whether to convert the image to RGB. - data_format (`ChannelDimension`, *optional*, defaults to `ChannelDimension.FIRST`): - The channel dimension format for the output image. Can be one of: - - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format. - - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format. - - Unset: Use the channel dimension format of the input image. - input_data_format (`ChannelDimension` or `str`, *optional*): - The channel dimension format for the input image. Can be one of: - - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format. - - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format. - - `"none"` or `ChannelDimension.NONE`: image in (height, width) format. - `"none"` or `ChannelDimension.NONE`: image in (height, width) format. - """ # noqa: E501 - images = make_list_of_images(images) - - if do_convert_rgb: - images = [convert_to_rgb(image) for image in images] - - width, height = images[0].width, images[0].height - resized_width, resized_height = width, height - processed_images = [] - for image in images: - if do_resize: - resized_height, resized_width = smart_resize( - height=height, - width=width, - factor=patch_size * merge_size, - min_pixels=self.min_pixels, - max_pixels=self.max_pixels, - ) - image = image.resize((resized_width, resized_height)) - - if do_normalize: - image = transforms.Compose( - [ - transforms.ToTensor(), - transforms.Normalize(self.image_mean, self.image_std), - ] - )(image) - processed_images.append(image) - - patches = np.array(processed_images) - channel = patches.shape[1] - grid_t = patches.shape[0] // temporal_patch_size - grid_h, grid_w = resized_height // patch_size, resized_width // patch_size - patches = patches.reshape( - 1, - channel, - grid_h // merge_size, - merge_size, - patch_size, - grid_w // merge_size, - merge_size, - patch_size, - ) - patches = patches.transpose(0, 2, 3, 5, 6, 1, 4, 7) - flatten_patches = patches.reshape( - 1 * grid_h * grid_w, channel * patch_size * patch_size - ) - - return flatten_patches, (grid_t, grid_h, grid_w) - - def preprocess( - self, - images: ImageInput, - videos: VideoInput = None, - do_resize: bool | None = None, - size: dict[str, int] | None = None, - min_pixels: int | None = None, - max_pixels: int | None = None, - resample: PILImageResampling = None, - do_rescale: bool | None = None, - rescale_factor: float | None = None, - do_normalize: bool | None = None, - image_mean: float | list[float] | None = None, - image_std: float | list[float] | None = None, - patch_size: int | None = None, - temporal_patch_size: int | None = None, - merge_size: int | None = None, - do_convert_rgb: bool | None = None, - return_tensors: str | TensorType | None = None, - data_format: ChannelDimension | None = ChannelDimension.FIRST, - input_data_format: str | ChannelDimension | None = None, - ): - """ - Args: - images (`ImageInput`): - Image to preprocess. Expects a single or batch of images with pixel values ranging from 0 to 255. If - passing in images with pixel values between 0 and 1, set `do_rescale=False`. - videos (`VideoInput`): - Video to preprocess. Expects a single or batch of videos with pixel values ranging from 0 to 255. If - passing in videos with pixel values between 0 and 1, set `do_rescale=False`. - do_resize (`bool`, *optional*, defaults to `self.do_resize`): - Whether to resize the image. - size (`dict[str, int]`, *optional*, defaults to `self.size`): - Size of the image after resizing. Shortest edge of the image is resized to size["shortest_edge"], with - the longest edge resized to keep the input aspect ratio. - resample (`int`, *optional*, defaults to `self.resample`): - Resampling filter to use if resizing the image. This can be one of the enum `PILImageResampling`. Only - has an effect if `do_resize` is set to `True`. - do_rescale (`bool`, *optional*, defaults to `self.do_rescale`): - Whether to rescale the image. - rescale_factor (`float`, *optional*, defaults to `self.rescale_factor`): - Rescale factor to rescale the image by if `do_rescale` is set to `True`. - do_normalize (`bool`, *optional*, defaults to `self.do_normalize`): - Whether to normalize the image. - image_mean (`float` or `list[float]`, *optional*, defaults to `self.image_mean`): - Image mean to use for normalization. Only has an effect if `do_normalize` is set to `True`. - image_std (`float` or `list[float]`, *optional*, defaults to `self.image_std`): - Image standard deviation to use for normalization. Only has an effect if `do_normalize` is set to - `True`. - min_pixels (`int`, *optional*, defaults to `self.min_pixels`): - The min pixels of the image to resize the image. - max_pixels (`int`, *optional*, defaults to `self.max_pixels`): - The max pixels of the image to resize the image. - patch_size (`int`, *optional*, defaults to `self.patch_size`): - The spatial patch size of the vision encoder. - temporal_patch_size (`int`, *optional*, defaults to `self.temporal_patch_size`): - The temporal patch size of the vision encoder. - merge_size (`int`, *optional*, defaults to `self.merge_size`): - The merge size of the vision encoder to llm encoder. - do_convert_rgb (`bool`, *optional*, defaults to `self.do_convert_rgb`): - Whether to convert the image to RGB. - return_tensors (`str` or `TensorType`, *optional*): - The type of tensors to return. Can be one of: - - Unset: Return a list of `np.ndarray`. - - `TensorType.TENSORFLOW` or `'tf'`: Return a batch of type `tf.Tensor`. - - `TensorType.PYTORCH` or `'pt'`: Return a batch of type `torch.Tensor`. - - `TensorType.NUMPY` or `'np'`: Return a batch of type `np.ndarray`. - - `TensorType.JAX` or `'jax'`: Return a batch of type `jax.numpy.ndarray`. - data_format (`ChannelDimension` or `str`, *optional*, defaults to `ChannelDimension.FIRST`): - The channel dimension format for the output image. Can be one of: - - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format. - - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format. - - Unset: Use the channel dimension format of the input image. - input_data_format (`ChannelDimension` or `str`, *optional*): - The channel dimension format for the input image. If unset, the channel dimension format is inferred - from the input image. Can be one of: - - `"channels_first"` or `ChannelDimension.FIRST`: image in (num_channels, height, width) format. - - `"channels_last"` or `ChannelDimension.LAST`: image in (height, width, num_channels) format. - - `"none"` or `ChannelDimension.NONE`: image in (height, width) format. - - """ # noqa: E501 - min_pixels = min_pixels if min_pixels is not None else self.min_pixels - max_pixels = max_pixels if max_pixels is not None else self.max_pixels - - if size is not None: - if "shortest_edge" not in size or "longest_edge" not in size: - raise ValueError( - "size must contain 'shortest_edge' and 'longest_edge' keys." - ) - min_pixels = size["shortest_edge"] - elif min_pixels is not None and max_pixels is not None: - # backward compatibility: override size with min_pixels and max_pixels - # if they are provided. - size = {"shortest_edge": min_pixels, "longest_edge": max_pixels} - else: - size = {**self.size} - - do_resize = do_resize if do_resize is not None else self.do_resize - - resample = resample if resample is not None else self.resample - do_rescale = do_rescale if do_rescale is not None else self.do_rescale - rescale_factor = ( - rescale_factor if rescale_factor is not None else self.rescale_factor - ) - do_normalize = do_normalize if do_normalize is not None else self.do_normalize - image_mean = image_mean if image_mean is not None else self.image_mean - image_std = image_std if image_std is not None else self.image_std - patch_size = patch_size if patch_size is not None else self.patch_size - temporal_patch_size = ( - temporal_patch_size - if temporal_patch_size is not None - else self.temporal_patch_size - ) - merge_size = merge_size if merge_size is not None else self.merge_size - do_convert_rgb = ( - do_convert_rgb if do_convert_rgb is not None else self.do_convert_rgb - ) - - if images is not None: - images = make_flat_list_of_images(images) - - if images is not None and not valid_images(images): - raise ValueError( - "Invalid image type. Must be of type PIL.Image.Image, numpy.ndarray, " - "torch.Tensor, tf.Tensor or jax.ndarray." - ) - - validate_preprocess_arguments( - rescale_factor=rescale_factor, - do_normalize=do_normalize, - image_mean=image_mean, - image_std=image_std, - do_resize=do_resize, - size=size, - resample=resample, - ) - - data = {} - if images is not None: - pixel_values, vision_grid_thws = [], [] - for image in images: - patches, image_grid_thw = self._preprocess( - image, - do_resize=do_resize, - size=size, - resample=resample, - do_rescale=do_rescale, - rescale_factor=rescale_factor, - do_normalize=do_normalize, - image_mean=image_mean, - image_std=image_std, - patch_size=patch_size, - temporal_patch_size=temporal_patch_size, - merge_size=merge_size, - data_format=data_format, - do_convert_rgb=do_convert_rgb, - input_data_format=input_data_format, - ) - pixel_values.extend(patches) - vision_grid_thws.append(image_grid_thw) - pixel_values = np.array(pixel_values) - vision_grid_thws = np.array(vision_grid_thws) - data.update( - {"pixel_values": pixel_values, "image_grid_thw": vision_grid_thws} - ) - - # kept for BC only and should be removed after v5.0 - if videos is not None: - logger.warning( - "`HunYuanVLV1ImageProcessor` works only with image inputs " - "and doesn't process videos anymore. " - "This is a deprecated behavior and will be removed in v5.0. " - "Your videos should be forwarded to `HunYuanVLV1VideoProcessor`. " - ) - videos = make_batched_videos(videos) - pixel_values_videos, vision_grid_thws_videos = [], [] - for images in videos: - patches, video_grid_thw = self._preprocess( - images, - do_resize=do_resize, - size=size, - resample=resample, - do_rescale=do_rescale, - rescale_factor=rescale_factor, - do_normalize=do_normalize, - image_mean=image_mean, - image_std=image_std, - patch_size=patch_size, - temporal_patch_size=temporal_patch_size, - merge_size=merge_size, - data_format=data_format, - do_convert_rgb=do_convert_rgb, - input_data_format=input_data_format, - ) - pixel_values_videos.extend(patches) - vision_grid_thws_videos.append(video_grid_thw) - data.update( - { - "pixel_values_videos": np.array(pixel_values_videos), - "video_grid_thw": np.array(vision_grid_thws_videos), - } - ) - - return BatchFeature(data=data, tensor_type=return_tensors) - - def get_number_of_image_patches(self, height: int, width: int, images_kwargs=None): - """ - A utility that returns number of image patches for a given image size. - - Args: - height (`int`): - Height of the input image. - width (`int`): - Width of the input image. - images_kwargs (`dict`, *optional*): - Any kwargs to override defaults of the image processor. - Returns: - `int`: Number of image patches per image. - """ - min_pixels = ( - images_kwargs["min_pixels"] - if "min_pixels" in images_kwargs - else self.size["shortest_edge"] - ) - max_pixels = ( - images_kwargs["max_pixels"] - if "max_pixels" in images_kwargs - else self.size["longest_edge"] - ) - patch_size = images_kwargs.get("patch_size", self.patch_size) - merge_size = images_kwargs.get("merge_size", self.merge_size) - - factor = patch_size * merge_size - resized_height, resized_width = smart_resize( - height, width, factor, min_pixels=min_pixels, max_pixels=max_pixels - ) - grid_h, grid_w = resized_height // patch_size, resized_width // patch_size - return grid_h * (grid_w + 1) + 2 - - -AutoImageProcessor.register("HunYuanVLImageProcessor", HunYuanVLImageProcessor) diff --git a/vllm/transformers_utils/processors/internvl.py b/vllm/transformers_utils/processors/internvl.py index fc582deef973..22e3f5be98ab 100644 --- a/vllm/transformers_utils/processors/internvl.py +++ b/vllm/transformers_utils/processors/internvl.py @@ -12,7 +12,12 @@ import torch import torchvision.transforms as T from PIL import Image -from transformers import BatchFeature, TensorType +from transformers import ( + BaseVideoProcessor, + BatchFeature, + ImageProcessingMixin, + TensorType, +) from transformers.processing_utils import ProcessorMixin from vllm.multimodal.image import convert_image_mode @@ -215,7 +220,7 @@ def video_to_pixel_values_internvl( return pixel_values -class InternVLImageProcessor: +class InternVLImageProcessor(ImageProcessingMixin): def __init__( self, image_size: int, @@ -312,7 +317,7 @@ def __call__( return BatchFeature(image_inputs, tensor_type=return_tensors) -class InternVLVideoProcessor: +class InternVLVideoProcessor(BaseVideoProcessor): def __init__( self, image_size: int, diff --git a/vllm/transformers_utils/processors/isaac.py b/vllm/transformers_utils/processors/isaac.py index da548a8f1e02..2d791df01673 100644 --- a/vllm/transformers_utils/processors/isaac.py +++ b/vllm/transformers_utils/processors/isaac.py @@ -7,7 +7,12 @@ import torch import torch.nn.functional as F from PIL import Image -from transformers import BatchFeature, ProcessorMixin, TensorType +from transformers import ( + BatchFeature, + ImageProcessingMixin, + ProcessorMixin, + TensorType, +) from transformers.processing_utils import ProcessingKwargs from typing_extensions import Unpack @@ -322,7 +327,7 @@ class IsaacProcessorKwargs(ProcessingKwargs, total=False): # type: ignore[call- } -class IsaacImageProcessor: +class IsaacImageProcessor(ImageProcessingMixin): model_input_names = ["pixel_values", "image_grid_thw"] def __init__( diff --git a/vllm/transformers_utils/processors/kimi_k25_vision_fused.py b/vllm/transformers_utils/processors/kimi_k25_vision_fused.py new file mode 100644 index 000000000000..63907898175c --- /dev/null +++ b/vllm/transformers_utils/processors/kimi_k25_vision_fused.py @@ -0,0 +1,352 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Optimized CPU image processor for Kimi-K2.5/K2.6 vision chunks.""" + +import io +import json +import math +from typing import Any + +import numpy as np +import pybase64 as base64 +import torch +from PIL import Image +from transformers.image_processing_utils import BaseImageProcessor, BatchFeature +from transformers.utils import TensorType + +from vllm.utils.import_utils import is_numba_available +from vllm.utils.jit_monitor import numba_workqueue_threading_layer + +if is_numba_available(): + from numba import njit, prange + + @njit(parallel=True, cache=True) + def _write_fused_patches( + frames: np.ndarray, + out: np.ndarray, + out_offset: int, + new_h: int, + new_w: int, + padded_h: int, + padded_w: int, + patch_size: int, + normalize_lut: np.ndarray, + ) -> None: + # frames: [T, new_h, new_w, 3] uint8, without padding. + # out: [total_patches, 3, patch_size, patch_size] float32. + t_size = frames.shape[0] + patch_h = padded_h // patch_size + patch_w = padded_w // patch_size + total = t_size * padded_h * padded_w * 3 + hwc = padded_h * padded_w * 3 + wc = padded_w * 3 + + for linear in prange(total): + t = linear // hwc + rem = linear - t * hwc + y = rem // wc + rem = rem - y * wc + x = rem // 3 + c = rem - x * 3 + + value = frames[t, y, x, c] if y < new_h and x < new_w else 0 + + patch_idx = ( + out_offset + + t * patch_h * patch_w + + (y // patch_size) * patch_w + + (x // patch_size) + ) + out[patch_idx, c, y % patch_size, x % patch_size] = normalize_lut[value, c] + +else: + + def _write_fused_patches(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("numba is required for fused Kimi image preprocessing") + + +def navit_resize_image( + width: int, + height: int, + patch_size: int, + merge_kernel_size: int, + in_patch_limit: int, + patch_limit_on_one_side: int, + fixed_output_tokens: int | None, +) -> dict[str, int]: + s1 = math.sqrt( + in_patch_limit + / (max(1.0, width // patch_size) * max(1.0, height // patch_size)) + ) + s2 = patch_limit_on_one_side * patch_size / width + s3 = patch_limit_on_one_side * patch_size / height + scale = min(1.0, s1, s2, s3) + new_w = min(max(1, int(width * scale)), patch_limit_on_one_side * patch_size) + new_h = min(max(1, int(height * scale)), patch_limit_on_one_side * patch_size) + + factor = merge_kernel_size * patch_size + pad_height = (factor - new_h % factor) % factor + pad_width = (factor - new_w % factor) % factor + + if fixed_output_tokens is not None: + num_tokens = fixed_output_tokens + else: + token_height = (new_h + pad_height) // factor + token_width = (new_w + pad_width) // factor + num_tokens = token_height * token_width + + return { + "num_tokens": num_tokens, + "new_width": new_w, + "new_height": new_h, + "pad_width": pad_width, + "pad_height": pad_height, + "sampled_nframes": 1, + } + + +def navit_resize_video( + width: int, + height: int, + nframes: int, + avg_fps: float, + sample_fps: float, + patch_size: int, + merge_kernel_size: int, + in_patch_limit_each_frame: int, + patch_limit_on_one_side: int, + in_patch_limit_total: int | None, + max_num_frames_each_video: int | None, + fixed_output_tokens_each_frame: int | None, +) -> dict[str, int]: + sample_fps = min(sample_fps, avg_fps) + sampled_nframes = max(round(nframes * sample_fps / avg_fps), 1) + if max_num_frames_each_video is not None: + sampled_nframes = min(sampled_nframes, max_num_frames_each_video) + + if in_patch_limit_total is not None: + in_patch_limit_each_frame = min( + round(in_patch_limit_total / sampled_nframes), + in_patch_limit_each_frame, + ) + + ret = navit_resize_image( + width, + height, + patch_size, + merge_kernel_size, + in_patch_limit_each_frame, + patch_limit_on_one_side, + fixed_output_tokens_each_frame, + ) + ret["sampled_nframes"] = sampled_nframes + return ret + + +def _to_pil(data: Any) -> Image.Image: + if hasattr(data, "media") and hasattr(data, "original_bytes"): + data = data.media + if isinstance(data, Image.Image): + return data if data.mode == "RGB" else data.convert("RGB") + if isinstance(data, str): + if data.startswith("data:"): + raw_base64 = data.split(",", 1)[1] + return Image.open(io.BytesIO(base64.b64decode(raw_base64))).convert("RGB") + return Image.open(data).convert("RGB") + if isinstance(data, bytes): + return Image.open(io.BytesIO(data)).convert("RGB") + raise ValueError(f"Unsupported data type: {type(data)}") + + +def _ensure_media_type(media: dict[str, Any]) -> dict[str, Any]: + if media["type"] == "image": + media["image"] = _to_pil(media["image"]) + return media + if media["type"] == "video_chunk": + media["video_chunk"] = [_to_pil(frame) for frame in media["video_chunk"]] + return media + raise ValueError(f"Unsupported media type: {media['type']}") + + +class KimiK25FusedVisionProcessor(BaseImageProcessor): + model_type = "kimi_k25" + + def __init__(self, media_proc_cfg: dict[str, Any], **kwargs: Any) -> None: + super().__init__(**kwargs) + media_proc_cfg = dict(media_proc_cfg) + merge_kernel_size = media_proc_cfg["merge_kernel_size"] + if isinstance(merge_kernel_size, (list, tuple)): + media_proc_cfg["merge_kernel_size"] = int(merge_kernel_size[0]) + self.media_proc_cfg = media_proc_cfg + self.num_frames_per_chunk = media_proc_cfg["temporal_merge_kernel_size"] + values = np.arange(256, dtype=np.float32)[:, None] + image_mean = np.asarray(media_proc_cfg["image_mean"], dtype=np.float32) + image_std_inv = 1.0 / np.asarray(media_proc_cfg["image_std"], dtype=np.float32) + self.normalize_lut = (values / 255.0 - image_mean[None, :]) * image_std_inv[ + None, : + ] + + def media_tokens_calculator(self, media: dict[str, Any]) -> int: + media = _ensure_media_type(media) + ret = self.get_resize_config(media) + return ret["num_tokens"] + + def get_resize_config(self, media_input: dict[str, Any]) -> dict[str, int]: + if media_input["type"] == "image": + width, height = media_input["image"].size + return navit_resize_image( + width, + height, + self.media_proc_cfg["patch_size"], + self.media_proc_cfg["merge_kernel_size"], + self.media_proc_cfg["in_patch_limit"], + self.media_proc_cfg["patch_limit_on_one_side"], + self.media_proc_cfg["fixed_output_tokens"], + ) + + if media_input["type"] == "video_chunk": + frame = media_input["video_chunk"][0] + width, height = frame.size + num_frames = len(media_input["video_chunk"]) + in_patch_limit_each_frame = self.media_proc_cfg["in_patch_limit_each_frame"] + if in_patch_limit_each_frame is None: + in_patch_limit_each_frame = self.media_proc_cfg["in_patch_limit"] + + return navit_resize_video( + width, + height, + num_frames, + 1.0, + math.inf, + self.media_proc_cfg["patch_size"], + self.media_proc_cfg["merge_kernel_size"], + in_patch_limit_each_frame, + self.media_proc_cfg["patch_limit_on_one_side"], + self.media_proc_cfg["in_patch_limit_video"], + None, + self.media_proc_cfg["fixed_output_tokens"], + ) + + raise ValueError(f"Unsupported type: {media_input['type']}") + + @staticmethod + def resize_image(image: Image.Image, new_width: int, new_height: int) -> np.ndarray: + image = image.resize((new_width, new_height), resample=Image.Resampling.BICUBIC) + return np.asarray(image) + + def preprocess( + self, + medias: list[dict[str, Any]], + return_tensors: str | TensorType | None = None, + ) -> BatchFeature: + if not isinstance(medias, list): + medias = [medias] + if not medias: + return BatchFeature(data={}, tensor_type=return_tensors) + + if njit is None: + raise RuntimeError("numba is required for fused Kimi image preprocessing") + + patch_size = int(self.media_proc_cfg["patch_size"]) + prepared = [] + grid_thws_np = np.empty((len(medias), 3), dtype=np.int64) + total_patches = 0 + + for idx, item in enumerate(medias): + item = _ensure_media_type(item) + resize_config = self.get_resize_config(item) + new_width = resize_config["new_width"] + new_height = resize_config["new_height"] + pad_width = resize_config["pad_width"] + pad_height = resize_config["pad_height"] + padded_width = new_width + pad_width + padded_height = new_height + pad_height + + if item["type"] == "image": + image_np = self.resize_image(item["image"], new_width, new_height) + frames = image_np[np.newaxis, ...] + elif item["type"] == "video_chunk": + frames = np.stack( + [ + self.resize_image(frame, new_width, new_height) + for frame in item["video_chunk"] + ], + axis=0, + ) + else: + raise ValueError(f"Unsupported type: {item['type']}") + + t_size = frames.shape[0] + grid_h = padded_height // patch_size + grid_w = padded_width // patch_size + grid_thws_np[idx, 0] = t_size + grid_thws_np[idx, 1] = grid_h + grid_thws_np[idx, 2] = grid_w + + num_patches = t_size * grid_h * grid_w + prepared.append( + ( + frames, + new_height, + new_width, + padded_height, + padded_width, + num_patches, + ) + ) + total_patches += num_patches + + pixel_values_np = np.empty( + (total_patches, 3, patch_size, patch_size), dtype=np.float32 + ) + out_offset = 0 + with numba_workqueue_threading_layer(): + for ( + frames, + new_height, + new_width, + padded_height, + padded_width, + num_patches, + ) in prepared: + _write_fused_patches( + frames, + pixel_values_np, + out_offset, + new_height, + new_width, + padded_height, + padded_width, + patch_size, + self.normalize_lut, + ) + out_offset += num_patches + + data = { + "pixel_values": torch.from_numpy(pixel_values_np), + "grid_thws": torch.from_numpy(grid_thws_np), + } + return BatchFeature(data=data, tensor_type=return_tensors) + + def __repr__(self): + return f"KimiK25FusedVisionProcessor(media_proc_cfg={self.media_proc_cfg})" + + def to_dict(self) -> dict[str, Any]: + output = super().to_dict() + output["media_proc_cfg"] = self.media_proc_cfg + if "media_processor" in output: + del output["media_processor"] + return output + + @classmethod + def from_dict(cls, config_dict: dict[str, Any], **kwargs): + config = config_dict.copy() + media_proc_cfg = config.pop("media_proc_cfg", {}) + return cls(media_proc_cfg=media_proc_cfg, **config, **kwargs) + + def to_json_string(self): + dictionary = self.to_dict() + for key, value in dictionary.items(): + if hasattr(value, "tolist"): + dictionary[key] = value.tolist() + return json.dumps(dictionary, indent=2, sort_keys=True) + "\n" diff --git a/vllm/transformers_utils/processors/minicpmo.py b/vllm/transformers_utils/processors/minicpmo.py index 3059b8bac99d..899e0402ba52 100644 --- a/vllm/transformers_utils/processors/minicpmo.py +++ b/vllm/transformers_utils/processors/minicpmo.py @@ -64,7 +64,12 @@ def __init__( pool_step=2, ): super().__init__(image_processor, feature_extractor, tokenizer) - self.version = image_processor.version + # Mirror the MiniCPMVProcessor guard: newer (transformers v5.7+) + # MiniCPM image processors may drop the legacy `version` attribute, + # so fall back to None instead of hard-crashing. `version` only + # special-cases the 2.5 tokenization path; other values take the + # default branch. + self.version = getattr(image_processor, "version", None) self.pool_step = pool_step def _safe_get_token_id(self, attr_name, default_token_str): @@ -263,7 +268,7 @@ def audio_feature_extract( def batch_decode(self, *args, **kwargs): """ This method forwards all its arguments to LlamaTokenizerFast's - [`~PreTrainedTokenizer.batch_decode`]. Please refer to the + [`~PythonBackend.batch_decode`]. Please refer to the docstring of this method for more information. """ output_ids = args[0] @@ -284,7 +289,7 @@ def batch_decode(self, *args, **kwargs): def decode(self, *args, **kwargs): """ This method forwards all its arguments to LlamaTokenizerFast's - [`~PreTrainedTokenizer.decode`]. Please refer to the docstring + [`~PythonBackend.decode`]. Please refer to the docstring of this method for more information. """ result = args[0] diff --git a/vllm/transformers_utils/processors/minicpmv.py b/vllm/transformers_utils/processors/minicpmv.py index cc0dee8dacd5..712cbbb47a01 100644 --- a/vllm/transformers_utils/processors/minicpmv.py +++ b/vllm/transformers_utils/processors/minicpmv.py @@ -56,9 +56,9 @@ class MiniCPMVProcessor(ProcessorMixin): image_processor_class = "AutoImageProcessor" tokenizer_class = "AutoTokenizer" - def __init__(self, image_processor=None, tokenizer=None): + def __init__(self, image_processor=None, tokenizer=None, version=None): super().__init__(image_processor, tokenizer) - self.version = image_processor.version + self.version = version def __call__( self, @@ -72,8 +72,8 @@ def __call__( ) -> MiniCPMVBatchFeature: """Run the vendored MiniCPMV processor on a (text, images) pair. - Only single-sample input is currently supported; batched input is - coming soon. ``images`` is forwarded to the underlying image + Batched inputs are supported following the upstream MiniCPM-V + processor flow. ``images`` is forwarded to the underlying image processor and ``text`` is tokenized with image placeholders replaced by the appropriate slice tokens. Returns a ``MiniCPMVBatchFeature`` with at minimum ``input_ids`` and (when @@ -95,7 +95,7 @@ def __call__( def batch_decode(self, *args, **kwargs): """ This method forwards all its arguments to LlamaTokenizerFast's - [`~PreTrainedTokenizer.batch_decode`]. Please refer to the + [`~PythonBackend.batch_decode`]. Please refer to the docstring of this method for more information. """ output_ids = args[0] @@ -128,7 +128,7 @@ def batch_decode(self, *args, **kwargs): def decode(self, *args, **kwargs): """ This method forwards all its arguments to LlamaTokenizerFast's - [`~PreTrainedTokenizer.decode`]. Please refer to the docstring + [`~PythonBackend.decode`]. Please refer to the docstring of this method for more information. """ result = args[0] @@ -156,7 +156,7 @@ def decode(self, *args, **kwargs): def _convert(self, input_str, max_inp_length: int | None = None): add_bos = getattr(self.tokenizer, "add_bos_token", False) - if self.version == 2.5 or add_bos: + if self.version == (2, 5) or add_bos: input_ids = self.tokenizer.encode(input_str) else: bos_id = getattr( @@ -194,7 +194,7 @@ def _convert(self, input_str, max_inp_length: int | None = None): image_end_tokens.unsqueeze(-1), ] ) - return input_ids.unsqueeze(0), image_bounds + return input_ids, image_bounds def _convert_images_texts_to_inputs( self, @@ -220,23 +220,41 @@ def _convert_images_texts_to_inputs( image_sizes = images["image_sizes"] tgt_sizes = images["tgt_sizes"] - image_tags = regex.findall(pattern, texts) - assert len(image_tags) == len(image_sizes[0]) - text_chunks = texts.split(pattern) - final_texts = "" - for i in range(len(image_tags)): - placeholder = self.image_processor.get_slice_image_placeholder( - image_sizes[0][i] - ) - final_texts = final_texts + text_chunks[i] + placeholder - final_texts += text_chunks[-1] - input_ids, image_bounds = self._convert(final_texts, max_length) + if isinstance(texts, str): + texts = [texts] + + input_ids_list = [] + image_bounds_list = [] + + for index, text in enumerate(texts): + image_tags = regex.findall(pattern, text) + assert len(image_tags) == len(image_sizes[index]) + text_chunks = text.split(pattern) + final_text = "" + for i in range(len(image_tags)): + placeholder = self.image_processor.get_slice_image_placeholder( + image_sizes[index][i] + ) + final_text = final_text + text_chunks[i] + placeholder + final_text += text_chunks[-1] + input_ids, image_bounds = self._convert(final_text, max_length) + input_ids_list.append(input_ids) + image_bounds_list.append(image_bounds) + + padded_input_ids, padding_lengths = self.pad( + input_ids_list, + padding_side="left", + ) + for i, length in enumerate(padding_lengths): + image_bounds_list[i] = image_bounds_list[i] + length + return MiniCPMVBatchFeature( data={ - "input_ids": input_ids, + "input_ids": padded_input_ids, + "attention_mask": padded_input_ids.ne(0), "pixel_values": images_val, "image_sizes": image_sizes, - "image_bound": [image_bounds], + "image_bound": image_bounds_list, "tgt_sizes": tgt_sizes, } ) @@ -249,42 +267,36 @@ def model_input_names(self): image_processor_input_names = self.image_processor.model_input_names return list(dict.fromkeys(tokenizer_input_names + image_processor_input_names)) - def pad( - self, - orig_items, - key, - max_length=None, - padding_value=0, - padding_side="left", - ): - if not orig_items: - return torch.empty(0) + # Copied from openbmb/MiniCPM-V-4_5 processing_minicpmv.py. + def pad(self, inputs, max_length=None, padding_value=0, padding_side="left"): + if not inputs: + return torch.empty(0), [] items = [] - if isinstance(orig_items[0][key], list): - assert isinstance(orig_items[0][key][0], torch.Tensor) - for it in orig_items: - for tr in it[key]: - items.append({key: tr}) + if isinstance(inputs[0], list): + assert isinstance(inputs[0][0], torch.Tensor) + for it in inputs: + for tr in it: + items.append(tr) else: - assert isinstance(orig_items[0][key], torch.Tensor) - items = orig_items + assert isinstance(inputs[0], torch.Tensor) + items = inputs batch_size = len(items) - shape = items[0][key].shape + shape = items[0].shape dim = len(shape) - assert dim <= 3 + assert dim <= 2 if max_length is None: max_length = 0 - max_length = max(max_length, max(item[key].shape[-1] for item in items)) - min_length = min(item[key].shape[-1] for item in items) - dtype = items[0][key].dtype + max_length = max(max_length, max(item.shape[-1] for item in items)) + min_length = min(item.shape[-1] for item in items) + dtype = items[0].dtype - if dim == 1: - return torch.cat([item[key] for item in items], dim=0) - elif dim == 2: + if dim == 0: + return torch.stack([item for item in items], dim=0), [0] + elif dim == 1: if max_length == min_length: - return torch.cat([item[key] for item in items], dim=0) + return torch.stack([item for item in items], dim=0), [0] * batch_size tensor = torch.zeros((batch_size, max_length), dtype=dtype) + padding_value else: tensor = ( @@ -292,23 +304,18 @@ def pad( + padding_value ) + padding_lengths = [] for i, item in enumerate(items): - tensor_to_pad = item[key] - if tensor_to_pad.shape[0] != 1: - raise ValueError( - f"Expected leading batch size of 1 for padding, " - f"but got shape {tensor_to_pad.shape}" - ) - squeezed = tensor_to_pad.squeeze(0) - if dim == 2: + if dim == 1: if padding_side == "left": - tensor[i, -squeezed.shape[0] :] = squeezed.clone() + tensor[i, -len(item) :] = item.clone() else: - tensor[i, : squeezed.shape[0]] = squeezed.clone() - elif dim == 3: + tensor[i, : len(item)] = item.clone() + elif dim == 2: if padding_side == "left": - tensor[i, -squeezed.shape[0] :, :] = squeezed.clone() + tensor[i, -len(item) :, :] = item.clone() else: - tensor[i, : squeezed.shape[0], :] = squeezed.clone() + tensor[i, : len(item), :] = item.clone() + padding_lengths.append(tensor.shape[-1] - len(item)) - return tensor + return tensor, padding_lengths diff --git a/vllm/transformers_utils/processors/minimax_m3.py b/vllm/transformers_utils/processors/minimax_m3.py new file mode 100644 index 000000000000..13dbce5368f8 --- /dev/null +++ b/vllm/transformers_utils/processors/minimax_m3.py @@ -0,0 +1,736 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""MiniMax M3 VL HuggingFace-compatible Processor / ImageProcessor / +VideoProcessor, vendored into vLLM so the model loads without +``--trust-remote-code`` (the released checkpoint only ships these classes as +remote code via ``auto_map``). + +Adapted verbatim from the ``MiniMaxAI/Minimax-M3-preview`` repository files +``image_processor.py``, ``video_processor.py`` and ``processing_minimax.py`` +(revision ``db01c0fe``). Both image and video processors use Qwen-style +``smart_resize`` (bound by total pixels). The original async frame-sampling +helpers are intentionally omitted: vLLM performs its own frame loading and +feeds decoded frames to the processor. +""" + +import math + +import regex as re +import torch +from torchvision.transforms import InterpolationMode +from transformers import AutoTokenizer, BatchFeature +from transformers.image_processing_utils_fast import ( + BaseImageProcessorFast, + group_images_by_shape, + reorder_images, +) +from transformers.image_utils import PILImageResampling, SizeDict +from transformers.processing_utils import ( + ImagesKwargs, + ProcessingKwargs, + ProcessorMixin, + Unpack, + VideosKwargs, +) +from transformers.utils import TensorType +from transformers.video_processing_utils import BaseVideoProcessor +from transformers.video_utils import group_videos_by_shape, reorder_videos + +# Maximum allowed aspect ratio before smart_resize rejects the input. +MAX_RATIO = 200 + +# Fixed (non-configurable) bounds for the long-side resize logic, per the +# MiniMax-M3 size spec. ``min_short_side_pixel`` is the floor the short edge is +# enlarged to; ``*_MAX_TOTAL_PIXELS`` is the hard area cap that, once exceeded, +# aborts processing instead of downscaling. +MIN_SHORT_SIDE_PIXEL = 112 +IMAGE_MAX_TOTAL_PIXELS = 12_845_056 # 3584 ** 2 (width * height) +VIDEO_MAX_TOTAL_PIXELS = 301_056_000 # width * height * frames + + +def round_by_factor(number: int | float, factor: int) -> int: + return round(number / factor) * factor + + +def ceil_by_factor(number: int | float, factor: int) -> int: + return math.ceil(number / factor) * factor + + +def floor_by_factor(number: int | float, factor: int) -> int: + return math.floor(number / factor) * factor + + +def _smart_resize_by_long_side( + height: int, + width: int, + factor: int, + max_long_side_pixel: int, + min_short_side_pixel: int, + max_total_pixels: int | None, +) -> tuple[int, int]: + """Long-side based resize (MiniMax-M3 size spec). + + (a) if the long side exceeds ``max_long_side_pixel`` → shrink so the long + side equals ``max_long_side_pixel``; + (b) else if the short side is below ``min_short_side_pixel`` → enlarge so the + short side equals ``min_short_side_pixel``; + (c) if the resulting area still exceeds ``max_total_pixels`` → raise. + + (a) and (b) are mutually exclusive (they branch on the *original* long side). + Both sides are then rounded to a multiple of ``factor``. For videos the + ``max_total_pixels`` cap is volumetric (width * height * frames) and is + enforced by the caller, so pass ``max_total_pixels=None`` here. + """ + long_side = max(height, width) + short_side = min(height, width) + + scaled_height: float = height + scaled_width: float = width + if long_side > max_long_side_pixel: + beta = max_long_side_pixel / long_side + scaled_height = height * beta + scaled_width = width * beta + elif short_side < min_short_side_pixel: + beta = min_short_side_pixel / short_side + scaled_height = height * beta + scaled_width = width * beta + + h_bar = max(factor, round_by_factor(scaled_height, factor)) + w_bar = max(factor, round_by_factor(scaled_width, factor)) + + if max_total_pixels is not None and h_bar * w_bar > max_total_pixels: + raise ValueError( + f"image area {h_bar * w_bar} exceeds max_total_pixels " + f"{max_total_pixels} after resizing" + ) + return h_bar, w_bar + + +def smart_resize( + height: int, + width: int, + factor: int = 28, + min_pixels: int = 4 * 28 * 28, + max_pixels: int = 451584, + max_long_side_pixel: int | None = None, + min_short_side_pixel: int = MIN_SHORT_SIDE_PIXEL, + max_total_pixels: int | None = None, +) -> tuple[int, int]: + """Rescale (height, width) so each side is a multiple of ``factor``. + + When ``max_long_side_pixel`` is set, use the MiniMax-M3 long-side resize + spec (see :func:`_smart_resize_by_long_side`). Otherwise fall back to the + Qwen-VL area bound, keeping the total area within ``[min_pixels, max_pixels]``. + """ + if max(height, width) / min(height, width) > MAX_RATIO: + raise ValueError( + f"absolute aspect ratio must be smaller than {MAX_RATIO}, " + f"got {max(height, width) / min(height, width)}" + ) + if max_long_side_pixel is not None: + return _smart_resize_by_long_side( + height, + width, + factor=factor, + max_long_side_pixel=max_long_side_pixel, + min_short_side_pixel=min_short_side_pixel, + max_total_pixels=max_total_pixels, + ) + h_bar = max(factor, round_by_factor(height, factor)) + w_bar = max(factor, round_by_factor(width, factor)) + if h_bar * w_bar > max_pixels: + beta = math.sqrt((height * width) / max_pixels) + h_bar = floor_by_factor(height / beta, factor) + w_bar = floor_by_factor(width / beta, factor) + elif h_bar * w_bar < min_pixels: + beta = math.sqrt(min_pixels / (height * width)) + h_bar = ceil_by_factor(height * beta, factor) + w_bar = ceil_by_factor(width * beta, factor) + return h_bar, w_bar + + +class MiniMaxM3VLImageProcessorKwargs(ImagesKwargs, total=False): # type: ignore[call-arg] + patch_size: int + temporal_patch_size: int + merge_size: int + max_pixels: int + max_long_side_pixel: int + + +class MiniMaxM3VLImageProcessor(BaseImageProcessorFast): + do_resize = True + resample = PILImageResampling.BICUBIC + # required by base-class validation, not used as the resize bound + size = {"height": 672, "width": 672} + default_to_square = False + do_rescale = True + rescale_factor = 1 / 255 + do_normalize = True + image_mean = [0.48145466, 0.4578275, 0.40821073] + image_std = [0.26862954, 0.26130258, 0.27577711] + do_convert_rgb = True + patch_size = 14 + temporal_patch_size = 2 + merge_size = 2 + max_pixels = 451584 # 672 * 672 + # Long-side resize spec (opt-in via ``max_long_side_pixel``). The latter two + # are fixed per the spec and are not exposed as configurable kwargs. + max_long_side_pixel = None + min_short_side_pixel = MIN_SHORT_SIDE_PIXEL + max_total_pixels = IMAGE_MAX_TOTAL_PIXELS + valid_kwargs = MiniMaxM3VLImageProcessorKwargs + model_input_names = ["pixel_values", "image_grid_thw"] + + def __init__(self, **kwargs: Unpack[MiniMaxM3VLImageProcessorKwargs]): + super().__init__(**kwargs) + + def preprocess( + self, images, **kwargs: Unpack[MiniMaxM3VLImageProcessorKwargs] + ) -> BatchFeature: + return super().preprocess(images, **kwargs) + + def _preprocess( + self, + images: list[torch.Tensor], + do_resize: bool, + size: SizeDict, + resample: "PILImageResampling | InterpolationMode | int | None", + do_rescale: bool, + rescale_factor: float, + do_normalize: bool, + image_mean: "float | list[float] | None", + image_std: "float | list[float] | None", + patch_size: int, + temporal_patch_size: int, + merge_size: int, + max_pixels: int, + max_long_side_pixel: "int | None", + disable_grouping: "bool | None", + return_tensors: "str | TensorType | None", + **kwargs, + ) -> BatchFeature: + grouped_images, grouped_images_index = group_images_by_shape( + images, disable_grouping=disable_grouping + ) + resized_images_grouped = {} + factor = patch_size * merge_size + for shape, stacked_images in grouped_images.items(): + height, width = stacked_images.shape[-2:] + if do_resize: + resized_height, resized_width = smart_resize( + height, + width, + factor=factor, + max_pixels=max_pixels, + max_long_side_pixel=max_long_side_pixel, + min_short_side_pixel=self.min_short_side_pixel, + max_total_pixels=self.max_total_pixels, + ) + stacked_images = self.resize( + stacked_images, + size=SizeDict(height=resized_height, width=resized_width), + resample=resample, + ) + resized_images_grouped[shape] = stacked_images + + resized_images = reorder_images(resized_images_grouped, grouped_images_index) + + grouped_images, grouped_images_index = group_images_by_shape( + resized_images, disable_grouping=disable_grouping + ) + processed_images_grouped = {} + processed_grids = {} + + for shape, stacked_images in grouped_images.items(): + resized_height, resized_width = stacked_images.shape[-2:] + + patches = self.rescale_and_normalize( + stacked_images, + do_rescale, + rescale_factor, + do_normalize, + image_mean, + image_std, + ) + if patches.ndim == 4: + patches = patches.unsqueeze(1) + + if patches.shape[1] % temporal_patch_size != 0: + repeats = patches[:, -1:].repeat( + 1, + temporal_patch_size - (patches.shape[1] % temporal_patch_size), + 1, + 1, + 1, + ) + patches = torch.cat([patches, repeats], dim=1) + + batch_size, grid_t, channel = patches.shape[:3] + grid_t = grid_t // temporal_patch_size + grid_h, grid_w = resized_height // patch_size, resized_width // patch_size + + patches = patches.view( + batch_size, + grid_t, + temporal_patch_size, + channel, + grid_h // merge_size, + merge_size, + patch_size, + grid_w // merge_size, + merge_size, + patch_size, + ) + patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9) + + flatten_patches = patches.reshape( + batch_size, + grid_t * grid_h * grid_w, + channel * temporal_patch_size * patch_size * patch_size, + ) + + processed_images_grouped[shape] = flatten_patches + processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size + + processed_images = reorder_images( + processed_images_grouped, grouped_images_index + ) + processed_grids = reorder_images(processed_grids, grouped_images_index) + + pixel_values = torch.cat(processed_images, dim=0) + image_grid_thw = torch.tensor(processed_grids, dtype=torch.long) + + return BatchFeature( + data={"pixel_values": pixel_values, "image_grid_thw": image_grid_thw}, + tensor_type=return_tensors, + ) + + def get_number_of_image_patches(self, height: int, width: int, images_kwargs=None): + images_kwargs = images_kwargs or {} + patch_size = images_kwargs.get("patch_size", self.patch_size) + merge_size = images_kwargs.get("merge_size", self.merge_size) + max_pixels = images_kwargs.get("max_pixels", self.max_pixels) + max_long_side_pixel = images_kwargs.get( + "max_long_side_pixel", self.max_long_side_pixel + ) + + resized_height, resized_width = smart_resize( + height, + width, + factor=patch_size * merge_size, + max_pixels=max_pixels, + max_long_side_pixel=max_long_side_pixel, + min_short_side_pixel=self.min_short_side_pixel, + max_total_pixels=self.max_total_pixels, + ) + grid_h, grid_w = resized_height // patch_size, resized_width // patch_size + return grid_h * grid_w + + +class MiniMaxM3VLVideoProcessorKwargs(VideosKwargs, total=False): # type: ignore[call-arg] + patch_size: int + temporal_patch_size: int + merge_size: int + min_pixels: int + max_pixels: int + max_long_side_pixel: int + total_pixels: int + min_frames: int + max_frames: int + fps: "float | int" + + +class MiniMaxM3VLVideoProcessor(BaseVideoProcessor): + do_resize = True + resample = PILImageResampling.BICUBIC + size = {"height": 672, "width": 672} + default_to_square = False + do_rescale = True + rescale_factor = 1 / 255 + do_normalize = True + image_mean = [0.48145466, 0.4578275, 0.40821073] + image_std = [0.26862954, 0.26130258, 0.27577711] + do_convert_rgb = True + do_sample_frames = False + patch_size = 14 + temporal_patch_size = 2 + merge_size = 2 + min_pixels = 4 * 28 * 28 + max_pixels = 768 * 28 * 28 # 602,112 + total_pixels = int(64000 * 28 * 28 * 0.9) # ~45M, ~64k tokens budget + # Long-side resize spec (opt-in via ``max_long_side_pixel``). The video + # ``max_total_pixels`` cap is volumetric (width * height * frames) and is + # enforced in ``_preprocess`` once the frame count is known. + max_long_side_pixel = None + min_short_side_pixel = MIN_SHORT_SIDE_PIXEL + max_total_pixels = VIDEO_MAX_TOTAL_PIXELS + fps = 1.0 + min_frames = 4 + max_frames = 768 + valid_kwargs = MiniMaxM3VLVideoProcessorKwargs + model_input_names = ["pixel_values_videos", "video_grid_thw"] + + def __init__(self, **kwargs: Unpack[MiniMaxM3VLVideoProcessorKwargs]): + super().__init__(**kwargs) + + def _preprocess( + self, + videos: list[torch.Tensor], + do_convert_rgb: bool, + do_resize: bool, + size: SizeDict, + resample: "PILImageResampling | InterpolationMode | int | None", + do_rescale: bool, + rescale_factor: float, + do_normalize: bool, + image_mean: "float | list[float] | None", + image_std: "float | list[float] | None", + patch_size: int, + temporal_patch_size: int, + merge_size: int, + min_pixels: int, + max_pixels: int, + max_long_side_pixel: "int | None" = None, + return_tensors: "str | TensorType | None" = None, + **kwargs, + ) -> BatchFeature: + grouped_videos, grouped_videos_index = group_videos_by_shape(videos) + resized_videos_grouped = {} + factor = patch_size * merge_size + for shape, stacked_videos in grouped_videos.items(): + batch_size, num_frames, channels, height, width = stacked_videos.shape + resized_height, resized_width = height, width + if do_resize: + resized_height, resized_width = smart_resize( + height, + width, + factor=factor, + min_pixels=min_pixels, + max_pixels=max_pixels, + max_long_side_pixel=max_long_side_pixel, + min_short_side_pixel=self.min_short_side_pixel, + # Per-frame raise disabled; the video cap is volumetric and + # is enforced below once num_frames is known. + max_total_pixels=None, + ) + if ( + max_long_side_pixel is not None + and resized_height * resized_width * num_frames + > self.max_total_pixels + ): + raise ValueError( + f"video area {resized_height * resized_width * num_frames} " + f"(width * height * frames) exceeds max_total_pixels " + f"{self.max_total_pixels} after resizing" + ) + stacked_videos = stacked_videos.view( + batch_size * num_frames, channels, height, width + ) + stacked_videos = self.resize( + stacked_videos, + size=SizeDict(height=resized_height, width=resized_width), + resample=resample, + ) + stacked_videos = stacked_videos.view( + batch_size, + num_frames, + channels, + resized_height, + resized_width, + ) + resized_videos_grouped[shape] = stacked_videos + resized_videos = reorder_videos(resized_videos_grouped, grouped_videos_index) + + grouped_videos, grouped_videos_index = group_videos_by_shape(resized_videos) + processed_videos_grouped = {} + processed_grids = {} + for shape, stacked_videos in grouped_videos.items(): + resized_height, resized_width = stacked_videos.shape[-2:] + patches = self.rescale_and_normalize( + stacked_videos, + do_rescale, + rescale_factor, + do_normalize, + image_mean, + image_std, + ) + + if pad := -patches.shape[1] % temporal_patch_size: + repeats = patches[:, -1:].expand(-1, pad, -1, -1, -1) + patches = torch.cat([patches, repeats], dim=1) + + batch_size, grid_t, channels = patches.shape[:3] + grid_t = grid_t // temporal_patch_size + grid_h, grid_w = resized_height // patch_size, resized_width // patch_size + + patches = patches.view( + batch_size, + grid_t, + temporal_patch_size, + channels, + grid_h // merge_size, + merge_size, + patch_size, + grid_w // merge_size, + merge_size, + patch_size, + ) + patches = patches.permute(0, 1, 4, 7, 5, 8, 3, 2, 6, 9) + flatten_patches = patches.reshape( + batch_size, + grid_t * grid_h * grid_w, + channels * temporal_patch_size * patch_size * patch_size, + ) + + processed_videos_grouped[shape] = flatten_patches + processed_grids[shape] = [[grid_t, grid_h, grid_w]] * batch_size + + processed_videos = reorder_videos( + processed_videos_grouped, grouped_videos_index + ) + processed_grids = reorder_videos(processed_grids, grouped_videos_index) + pixel_values_videos = torch.cat(processed_videos, dim=0) + video_grid_thw = torch.tensor(processed_grids, dtype=torch.long) + + return BatchFeature( + data={ + "pixel_values_videos": pixel_values_videos, + "video_grid_thw": video_grid_thw, + }, + tensor_type=return_tensors, + ) + + +class MiniMaxVLProcessorKwargs(ProcessingKwargs, total=False): # type: ignore[call-arg] + _defaults = { + "videos_kwargs": { + "do_resize": False, + "return_metadata": True, + }, + } + + +class MiniMaxVLProcessor(ProcessorMixin): + IMAGE_TOKEN = "]<]image[>[" + VIDEO_TOKEN = "]<]video[>[" + VISION_START_TOKEN = "]<]start of image[>[" + VISION_END_TOKEN = "]<]end of image[>[" + + @classmethod + def from_pretrained(cls, pretrained_model_name_or_path, **kwargs): + # Bypass ProcessorMixin's dynamic module lookup, which breaks in + # transformers >= 5.9 when image_processor_class is a string: the + # register() API now stores classes as {"pil": cls} dicts in + # _extra_content, but get_possibly_dynamic_module() still calls + # .__name__ on the raw value, crashing with AttributeError on dicts. + tokenizer = AutoTokenizer.from_pretrained( + pretrained_model_name_or_path, **kwargs + ) + image_processor = MiniMaxM3VLImageProcessor.from_pretrained( + pretrained_model_name_or_path, **kwargs + ) + video_processor = MiniMaxM3VLVideoProcessor.from_pretrained( + pretrained_model_name_or_path, **kwargs + ) + return cls( + image_processor=image_processor, + tokenizer=tokenizer, + video_processor=video_processor, + ) + + def __init__( + self, image_processor=None, tokenizer=None, video_processor=None, **kwargs + ): + self.image_token_id = tokenizer.convert_tokens_to_ids(self.IMAGE_TOKEN) + self.video_token_id = tokenizer.convert_tokens_to_ids(self.VIDEO_TOKEN) + super().__init__(image_processor, tokenizer, video_processor) + # Video expansion also uses image start/end tokens. Separate video + # start/end tokens exist in the tokenizer, but the original MiniMax + # serving path did not use them; keep that behavior for compatibility. + self.vision_start_token_id = tokenizer.convert_tokens_to_ids( + self.VISION_START_TOKEN + ) + self.vision_end_token_id = tokenizer.convert_tokens_to_ids( + self.VISION_END_TOKEN + ) + + def _prune_video_tokens( + self, + input_text: str, + video_segments: list[int], + video_token: str, + ) -> str: + """Prune video tokens by temporal_patch_size (e.g., 2:1). + + Expects the prompt to carry exactly sum(video_segments) video tokens + — i.e. one token per *sampled* frame — then drops tokens. + """ + # If no videos or temporal_patch_size <= 1, no pruning needed + if not video_segments or self.video_processor.temporal_patch_size <= 1: + return input_text + + # Split while keeping delimiters + special_tokens = [video_token] + pattern = "|".join(map(re.escape, special_tokens)) + parts = re.split(f"({pattern})", input_text) + + def is_timestamp(text: str) -> bool: + """Check if text ends with timestamp format like ']<]0.0 seconds[>['""" + return ( + text.endswith("seconds[>[") + or text.endswith("seconds[>[ ") + or text.endswith("seconds [>[") + or text.endswith("seconds [>[ ") + ) + + def extract_timestamp(text: str) -> str: + """Extract timestamp text from the end, starting from ']<]'""" + start_index = text.rfind("]<]") + if start_index == -1: + raise ValueError(f"Failed to extract timestamp: {text}") + return text[start_index:] + + # Build new text with pruned video tokens + final_parts = [] + current_seg_idx = 0 # Which video segment we're in + frame_in_seg = 0 # Frame index within current segment + last_timestamp_len = 0 # Length of timestamp to potentially remove + + for part in parts: + if part == video_token: + if current_seg_idx < len(video_segments): + if frame_in_seg % self.video_processor.temporal_patch_size == 0: + # Keep this video token + final_parts.append(part) + frame_in_seg += 1 + if frame_in_seg >= video_segments[current_seg_idx]: + current_seg_idx += 1 + frame_in_seg = 0 + last_timestamp_len = 0 + else: + # Skip this video token + frame_in_seg += 1 + if frame_in_seg >= video_segments[current_seg_idx]: + current_seg_idx += 1 + frame_in_seg = 0 + # Remove the timestamp that was already appended + if last_timestamp_len > 0: + assert len(final_parts) > 0 + final_parts[-1] = final_parts[-1][:-last_timestamp_len] + last_timestamp_len = 0 + else: + # No more video segments, keep as is + final_parts.append(part) + last_timestamp_len = 0 + else: + # Text part + final_parts.append(part) + # Check if this text ends with a timestamp + if is_timestamp(part): + last_timestamp_len = len(extract_timestamp(part)) + else: + last_timestamp_len = 0 + + return "".join(final_parts) + + def __call__( + self, + images=None, + text=None, + videos=None, + **kwargs: Unpack[MiniMaxVLProcessorKwargs], + ) -> BatchFeature: + output_kwargs = self._merge_kwargs( + MiniMaxVLProcessorKwargs, + tokenizer_init_kwargs=self.tokenizer.init_kwargs, + **kwargs, + ) + + if images is not None: + images_kwargs = output_kwargs["images_kwargs"] + image_inputs = self.image_processor(images=images, **images_kwargs) + image_grid_thw = image_inputs["image_grid_thw"] + else: + image_inputs = {} + image_grid_thw = None + + if videos is not None: + videos_kwargs = output_kwargs["videos_kwargs"] + video_inputs = self.video_processor(videos=videos, **videos_kwargs) + video_grid_thw = video_inputs["video_grid_thw"] + if not kwargs.get("return_metadata"): + video_metadata = video_inputs.pop("video_metadata") + else: + video_metadata = video_inputs["video_metadata"] + else: + video_inputs = {} + video_grid_thw = None + + if not isinstance(text, list): + text = [text] + text = text.copy() + + # Expand image tokens + if image_grid_thw is not None: + merge_length = self.image_processor.merge_size**2 + placeholder = "]<]placeholder[>[" + index = 0 + for i in range(len(text)): + while self.IMAGE_TOKEN in text[i]: + num_tokens = image_grid_thw[index].prod() // merge_length + text[i] = text[i].replace( + self.IMAGE_TOKEN, + self.VISION_START_TOKEN + + placeholder * num_tokens + + self.VISION_END_TOKEN, + 1, + ) + index += 1 + text[i] = text[i].replace(placeholder, self.IMAGE_TOKEN) + + # Expand video tokens + if video_grid_thw is not None: + merge_length = self.image_processor.merge_size**2 + placeholder = "]<]placeholder[>[" + index = 0 + for i in range(len(text)): + while self.VIDEO_TOKEN in text[i]: + metadata = video_metadata[index] + grid_t = video_grid_thw[index][0] + frame_seqlen = video_grid_thw[index][1:].prod() // merge_length + + video_placeholder = "" + for frame_idx in range(grid_t): + if ( + metadata.fps is not None + and metadata.frames_indices is not None + ): + ts = ( + metadata.frames_indices[ + min( + frame_idx + * self.video_processor.temporal_patch_size, + len(metadata.frames_indices) - 1, + ) + ] + / metadata.fps + ) + video_placeholder += f"]<]{ts:.1f} seconds[>[" + video_placeholder += ( + self.VISION_START_TOKEN + + placeholder * frame_seqlen + + self.VISION_END_TOKEN + ) + + text[i] = text[i].replace(self.VIDEO_TOKEN, video_placeholder, 1) + index += 1 + text[i] = text[i].replace(placeholder, self.VIDEO_TOKEN) + + # Tokenize + return_tensors = output_kwargs["text_kwargs"].pop("return_tensors", None) + text_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"]) + + return BatchFeature( + data={**text_inputs, **image_inputs, **video_inputs}, + tensor_type=return_tensors, + ) diff --git a/vllm/transformers_utils/processors/moondream3.py b/vllm/transformers_utils/processors/moondream3.py index 289c40dd175e..ae6833b20079 100644 --- a/vllm/transformers_utils/processors/moondream3.py +++ b/vllm/transformers_utils/processors/moondream3.py @@ -195,7 +195,7 @@ def from_pretrained( The moondream3 model uses a custom tokenizer from 'moondream/starmie-v1' instead of having tokenizer files in the model repo. """ - from transformers import AutoTokenizer, PreTrainedTokenizerFast + from transformers import AutoTokenizer, TokenizersBackend from transformers.utils import cached_file tokenizer = kwargs.pop("tokenizer", None) @@ -237,7 +237,7 @@ def load_tokenizer(repo_or_path): "tokenizer.json", **cached_file_kwargs, ) - return PreTrainedTokenizerFast( + return TokenizersBackend( tokenizer_file=tokenizer_file, clean_up_tokenization_spaces=False, ) diff --git a/vllm/transformers_utils/processors/nano_nemotron_vl.py b/vllm/transformers_utils/processors/nano_nemotron_vl.py index 76b73d21635c..d48a29d6b43a 100644 --- a/vllm/transformers_utils/processors/nano_nemotron_vl.py +++ b/vllm/transformers_utils/processors/nano_nemotron_vl.py @@ -44,12 +44,6 @@ # MAX_FRAMES = 16 DEFAULT_NUM_TILES = 12 -# Configure PIL to handle large images without warnings -# This prevents DecompressionBombWarning for legitimate large images -Image.MAX_IMAGE_PIXELS = None # Disable the limit entirely -# Alternative: Set a specific higher limit -# Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels - def calculate_timestamps( indices: list[int] | torch.Tensor, diff --git a/vllm/transformers_utils/processors/nemotron_vl.py b/vllm/transformers_utils/processors/nemotron_vl.py index 6163144bbb96..9c5436aa2729 100644 --- a/vllm/transformers_utils/processors/nemotron_vl.py +++ b/vllm/transformers_utils/processors/nemotron_vl.py @@ -10,12 +10,6 @@ from .internvl import InternVLImageProcessor, InternVLProcessor -# Configure PIL to handle large images without warnings -# This prevents DecompressionBombWarning for legitimate large images -Image.MAX_IMAGE_PIXELS = None # Disable the limit entirely -# Alternative: Set a specific higher limit -# Image.MAX_IMAGE_PIXELS = 300000000 # ~300M pixels - def build_transform(input_size: int): return T.Compose( diff --git a/vllm/transformers_utils/processors/openvla.py b/vllm/transformers_utils/processors/openvla.py index 162f40238309..e520f3b13981 100644 --- a/vllm/transformers_utils/processors/openvla.py +++ b/vllm/transformers_utils/processors/openvla.py @@ -7,7 +7,7 @@ import numpy as np import torch from PIL import Image -from transformers.processing_utils import ProcessorMixin +from transformers import ImageProcessingMixin, ProcessorMixin IMAGENET_MEAN = np.array([0.484375, 0.455078125, 0.40625], dtype=np.float32) IMAGENET_STD = np.array([0.228515625, 0.2236328125, 0.224609375], dtype=np.float32) @@ -65,7 +65,7 @@ def preprocess_openvla_image(image: Any, image_size: int) -> torch.Tensor: return torch.from_numpy(pixel_values) -class OpenVLAImageProcessor: +class OpenVLAImageProcessor(ImageProcessingMixin): def __init__(self, *, image_size: int) -> None: self.image_size = image_size diff --git a/vllm/transformers_utils/processors/ovis.py b/vllm/transformers_utils/processors/ovis.py index da80f24e75c0..907c68035fcf 100644 --- a/vllm/transformers_utils/processors/ovis.py +++ b/vllm/transformers_utils/processors/ovis.py @@ -417,14 +417,14 @@ def _get_best_grid(img, side): def batch_decode(self, *args, **kwargs): """ - This method forwards all its arguments to Qwen2TokenizerFast's [`~PreTrainedTokenizer.batch_decode`]. Please + This method forwards all its arguments to Qwen2TokenizerFast's [`~PythonBackend.batch_decode`]. Please refer to the docstring of this method for more information. """ return self.tokenizer.batch_decode(*args, **kwargs) def decode(self, *args, **kwargs): """ - This method forwards all its arguments to Qwen2TokenizerFast's [`~PreTrainedTokenizer.decode`]. Please refer to + This method forwards all its arguments to Qwen2TokenizerFast's [`~PythonBackend.decode`]. Please refer to the docstring of this method for more information. """ return self.tokenizer.decode(*args, **kwargs) diff --git a/vllm/transformers_utils/processors/pixtral.py b/vllm/transformers_utils/processors/pixtral.py index 63c75151fcbd..588ad6b7fad8 100644 --- a/vllm/transformers_utils/processors/pixtral.py +++ b/vllm/transformers_utils/processors/pixtral.py @@ -4,13 +4,13 @@ from mistral_common.protocol.instruct.chunk import ImageChunk from mistral_common.tokens.tokenizers.multimodal import ImageEncoder from PIL import Image -from transformers import BatchFeature, ProcessorMixin, TensorType +from transformers import BatchFeature, ImageProcessingMixin, ProcessorMixin, TensorType from transformers.image_utils import ImageInput from vllm.tokenizers.mistral import MistralTokenizer -class MistralCommonImageProcessor: +class MistralCommonImageProcessor(ImageProcessingMixin): """ Provide a HF-compatible interface for `mistral_common.tokens.tokenizers.multimodal.ImageEncoder`. @@ -46,6 +46,22 @@ def get_number_of_image_patches( ncols, nrows = self.mm_encoder._image_to_num_tokens(image) return ncols * nrows, nrows, ncols + # Copied from Transformers (Apache-2.0): + # https://github.com/huggingface/transformers/blob/d20946079fd422335fbae3eeb98b7cd88334612f/src/transformers/image_processing_base.py#L473 + def fetch_images(self, image_url_or_urls): + from transformers.image_utils import is_valid_image, load_image + + if isinstance(image_url_or_urls, (list, tuple)): + return [self.fetch_images(x) for x in image_url_or_urls] + if isinstance(image_url_or_urls, str): + return load_image(image_url_or_urls) + if is_valid_image(image_url_or_urls): + return image_url_or_urls + raise TypeError( + "only a single or a list of entries is supported but got " + f"type={type(image_url_or_urls)}" + ) + class MistralCommonPixtralProcessor(ProcessorMixin): attributes = ["image_processor", "tokenizer"] @@ -56,11 +72,6 @@ def __init__( image_processor: MistralCommonImageProcessor, ) -> None: self.tokenizer = tokenizer.transformers_tokenizer - - # Back-compatibility for Transformers v4 - if not hasattr(self.tokenizer, "init_kwargs"): - self.tokenizer.init_kwargs = {} - self.image_processor = image_processor image_special_ids = self.image_processor.mm_encoder.special_ids diff --git a/vllm/transformers_utils/processors/qwen_vl.py b/vllm/transformers_utils/processors/qwen_vl.py deleted file mode 100644 index 7de9046d93e6..000000000000 --- a/vllm/transformers_utils/processors/qwen_vl.py +++ /dev/null @@ -1,42 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -# Adapted from -# https://huggingface.co/Qwen/Qwen-VL/blob/main/modeling_qwen.py -# Copyright (c) Alibaba Cloud. -from transformers.image_processing_utils_fast import BaseImageProcessorFast -from transformers.image_utils import PILImageResampling -from transformers.processing_utils import ProcessorMixin - -from vllm.tokenizers.qwen_vl import QwenVLTokenizer - - -class QwenVLImageProcessorFast(BaseImageProcessorFast): - """ - Port of https://huggingface.co/Qwen/Qwen-VL/blob/main/visual.py#L354 - to HF Transformers. - """ - - resample = PILImageResampling.BICUBIC - image_mean = [0.48145466, 0.4578275, 0.40821073] - image_std = [0.26862954, 0.26130258, 0.27577711] - size = {"height": 448, "width": 448} - do_resize = True - do_rescale = True - do_normalize = True - - -class QwenVLProcessor(ProcessorMixin): - attributes = ["image_processor", "tokenizer"] - - def __init__( - self, - image_processor: QwenVLImageProcessorFast, - tokenizer: QwenVLTokenizer, - ) -> None: - self.image_processor = image_processor - self.tokenizer = tokenizer - - self.image_start_tag = tokenizer.image_start_tag - self.image_end_tag = tokenizer.image_end_tag - self.image_pad_tag = tokenizer.image_pad_tag diff --git a/vllm/transformers_utils/processors/step3_vl.py b/vllm/transformers_utils/processors/step3_vl.py index 71540f433fd1..8957a7c353cf 100644 --- a/vllm/transformers_utils/processors/step3_vl.py +++ b/vllm/transformers_utils/processors/step3_vl.py @@ -8,7 +8,12 @@ from PIL import Image from torchvision import transforms from torchvision.transforms.functional import InterpolationMode -from transformers import BatchFeature, ProcessorMixin, TensorType +from transformers import ( + BatchFeature, + ImageProcessingMixin, + ProcessorMixin, + TensorType, +) from vllm.tokenizers import TokenizerLike @@ -240,7 +245,7 @@ def __call__( ) -class Step3VLImageProcessor: +class Step3VLImageProcessor(ImageProcessingMixin): def __init__( self, image_size: int = 728, diff --git a/vllm/transformers_utils/processors/unlimited_ocr.py b/vllm/transformers_utils/processors/unlimited_ocr.py new file mode 100644 index 000000000000..927f19d0f930 --- /dev/null +++ b/vllm/transformers_utils/processors/unlimited_ocr.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Image processor for Unlimited-OCR (baidu/Unlimited-OCR).""" + +from PIL import Image + +from vllm.logger import init_logger +from vllm.transformers_utils.processors.deepseek_ocr import DeepseekOCRProcessor + +logger = init_logger(__name__) + + +class UnlimitedOCRProcessor(DeepseekOCRProcessor): + """DeepseekOCRProcessor variant for Unlimited-OCR. + + The only behavioural difference from the base processor is a multi-image + safeguard: when more than one image is present, crop ("gundam") mode is + disabled. + + Because the effective crop flag then depends on *how many* images are in the + request, the per-item processing output is no longer invariant of sibling + images. ``UnlimitedOCRMultiModalProcessor`` accounts for this by bypassing + the multimodal processing cache for multi-image requests (see its + ``_cached_apply_hf_processor``), so the two paths stay consistent. + + DeepSeek-OCR does *not* have this restriction because its ``max_crops=6`` is + small enough to be safe for multi-image use. + """ + + def tokenize_with_images( + self, + conversation: str, + images: list[Image.Image], + bos: bool = True, + eos: bool = True, + cropping: bool = True, + ): + if len(images) > 1 and cropping: + logger.warning_once( + "Unlimited-OCR: crop mode is not supported for multi-image " + "input. Falling back to cropping=False." + ) + cropping = False + return super().tokenize_with_images( + conversation, images, bos=bos, eos=eos, cropping=cropping + ) diff --git a/vllm/transformers_utils/processors/voxtral.py b/vllm/transformers_utils/processors/voxtral.py index 829bab2d4157..5403a7e47f99 100644 --- a/vllm/transformers_utils/processors/voxtral.py +++ b/vllm/transformers_utils/processors/voxtral.py @@ -6,13 +6,18 @@ import numpy as np import torch from mistral_common.tokens.tokenizers.audio import AudioEncoder -from transformers import BatchFeature, ProcessorMixin, TensorType +from transformers import ( + BatchFeature, + ProcessorMixin, + SequenceFeatureExtractor, + TensorType, +) from transformers.audio_utils import AudioInput from vllm.tokenizers.mistral import MistralTokenizer -class MistralCommonFeatureExtractor: +class MistralCommonFeatureExtractor(SequenceFeatureExtractor): """ Provide a HF-compatible interface for `mistral_common.tokens.tokenizers.multimodal.AudioEncoder`. @@ -53,6 +58,54 @@ def __call__( def get_num_audio_tokens(self, audio_length: int) -> int: return ceil(audio_length / (self.sampling_rate // self.frame_rate)) + def fetch_audio(self, audio_url_or_urls, sampling_rate=None): + """HF-compatible duck-typed ``fetch_audio``. + + Mirrors :meth:`transformers.SequenceFeatureExtractor.fetch_audio` so + :class:`transformers.ProcessorMixin.prepare_inputs_layout` (added in + transformers 5.10) works on this duck-typed feature extractor. Older + transformers versions never invoke this method, so the addition is a + no-op there. + + Accepts the same shapes as ``SequenceFeatureExtractor.fetch_audio``: + + * ``np.ndarray`` / ``torch.Tensor`` — returned as-is. + * ``list[float]`` — returned as-is (a single audio sample). + * ``str`` URL or path — delegated to + :func:`transformers.audio_utils.load_audio`. + * ``list`` of any of the above — recursed element-wise. + + ``ProcessorMixin.prepare_inputs_layout`` always passes already-decoded + audio (numpy array or torch tensor), so the str / list-of-str branches + exist only to keep the contract identical to the upstream method. + + The semantics of ``transformers.audio_utils.is_valid_audio`` differ + between transformers versions (5.9 only accepts ndarray/tensor; 5.10 + also accepts ``list[float]``). We detect ``list[float]`` explicitly to + keep behavior identical across versions. + """ + from transformers.audio_utils import is_valid_audio + + sampling_rate = sampling_rate if sampling_rate else self.sampling_rate + if is_valid_audio(audio_url_or_urls): + return audio_url_or_urls + if isinstance(audio_url_or_urls, (list, tuple)): + if audio_url_or_urls and isinstance(audio_url_or_urls[0], float): + # A single audio represented as ``list[float]``. + return audio_url_or_urls + return [ + self.fetch_audio(x, sampling_rate=sampling_rate) + for x in audio_url_or_urls + ] + if isinstance(audio_url_or_urls, str): + from transformers.audio_utils import load_audio + + return load_audio(audio_url_or_urls, sampling_rate=sampling_rate) + raise TypeError( + "only a numpy array, torch tensor, str URL/path, or list of those " + f"is supported but got type={type(audio_url_or_urls)}" + ) + class MistralCommonVoxtralProcessor(ProcessorMixin): attributes = ["feature_extractor", "tokenizer"] @@ -63,11 +116,6 @@ def __init__( feature_extractor: MistralCommonFeatureExtractor, ) -> None: self.tokenizer = tokenizer.transformers_tokenizer - - # Back-compatibility for Transformers v4 - if not hasattr(self.tokenizer, "init_kwargs"): - self.tokenizer.init_kwargs = {} - self.feature_extractor = feature_extractor audio_special_ids = self.feature_extractor.audio_encoder.special_ids diff --git a/vllm/transformers_utils/repo_utils.py b/vllm/transformers_utils/repo_utils.py index 8385057e9111..a758f5d535f2 100644 --- a/vllm/transformers_utils/repo_utils.py +++ b/vllm/transformers_utils/repo_utils.py @@ -9,7 +9,7 @@ from collections.abc import Callable from functools import cache from pathlib import Path -from typing import TypeVar +from typing import Any, TypeVar import huggingface_hub from huggingface_hub import HfApi, try_to_load_from_cache @@ -218,18 +218,22 @@ def file_or_path_exists( # NB: file_exists will only check for the existence of the config file on # hf_hub. This will fail in offline mode. - # Call HF to check if the file exists - return file_exists(str(model), config_name, revision=revision) + if cached_filepath is None: + # The config file is not cached - check if it exists on hf_hub + return file_exists(str(model), config_name, revision=revision) + # The config file is known to not exist in cache - we can return False + return False def get_model_path(model: str | Path, revision: str | None = None): if os.path.exists(model): return model assert huggingface_hub.constants.HF_HUB_OFFLINE - common_kwargs = { - "local_files_only": huggingface_hub.constants.HF_HUB_OFFLINE, - "revision": revision, - } + common_kwargs = dict( + local_files_only=huggingface_hub.constants.HF_HUB_OFFLINE, + ignore_patterns="*", + revision=revision, + ) if envs.VLLM_USE_MODELSCOPE: from modelscope.hub.snapshot_download import snapshot_download @@ -288,7 +292,7 @@ def get_hf_file_bytes( if file_path is None: file_path = _try_download_from_hf_hub(model, file_name, revision) - if file_path is not None and file_path.is_file(): + if isinstance(file_path, Path) and file_path.is_file(): with open(file_path, "rb") as file: return file.read() @@ -297,7 +301,20 @@ def get_hf_file_bytes( def try_get_local_file( model: str | Path, file_name: str, revision: str | None = "main" -) -> Path | None: +) -> Path | Any | None: + """ + Try to get a local file from the HuggingFace repository. + + The possible return values are: + + - A `Path` object if the local file is found + - The `huggingface_hub._CACHED_NO_EXIST` sentinel if the file is known to not exist + - `None` if the file is not found and we cannot determine if it exists or not + + Callers of this method should handle the `_CACHED_NO_EXIST` sentinel appropriately. + Checking if the return value `is not None` is not sufficient because it does not + distinguish between the file not existing and the file not being found. + """ file_path = Path(model) / file_name if file_path.is_file(): return file_path @@ -308,6 +325,7 @@ def try_get_local_file( ) if isinstance(cached_filepath, str): return Path(cached_filepath) + return cached_filepath except ValueError: ... return None @@ -335,7 +353,7 @@ def get_hf_file_to_dict( if file_path is None: file_path = _try_download_from_hf_hub(model, file_name, revision) - if file_path is not None and file_path.is_file(): + if isinstance(file_path, Path) and file_path.is_file(): with open(file_path) as file: return json.load(file) diff --git a/vllm/transformers_utils/utils.py b/vllm/transformers_utils/utils.py index 04def3e37699..cd215421a981 100644 --- a/vllm/transformers_utils/utils.py +++ b/vllm/transformers_utils/utils.py @@ -84,8 +84,11 @@ def maybe_model_redirect(model: str) -> str: """ Use model_redirect to redirect the model name to a local folder. - :param model: hf model name - :return: maybe redirect to a local folder + Args: + model: hf model name + + Returns: + maybe redirect to a local folder """ model_redirect_path = envs.VLLM_MODEL_REDIRECT_PATH diff --git a/vllm/triton_utils/force_first_config.py b/vllm/triton_utils/force_first_config.py new file mode 100644 index 000000000000..67f566d8ae04 --- /dev/null +++ b/vllm/triton_utils/force_first_config.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Skip Triton autotuning under VLLM_TRITON_FORCE_FIRST_CONFIG.""" + +from vllm.logger import init_logger +from vllm.triton_utils.importing import HAS_TRITON + +logger = init_logger(__name__) + +_installed: bool = False + + +def is_installed() -> bool: + """Return whether the first-valid-config patch is currently installed.""" + return _installed + + +def install() -> None: + """Install the Autotuner.run replacement.""" + global _installed + if _installed: + return + if not HAS_TRITON: + return + + import importlib + + autotuner_mod = importlib.import_module("triton.runtime.autotuner") + Autotuner = autotuner_mod.Autotuner + from triton.compiler.errors import CompileTimeAssertionFailure + from triton.runtime.errors import OutOfResources, PTXASError + + _invalid_config_errors = (OutOfResources, CompileTimeAssertionFailure, PTXASError) + _picked_cache: dict[tuple, int] = {} + seen_kernels: set[str] = set() + + def _run_first_valid_config(self, *args, **kwargs): + if not self.configs: + return self.fn(*args, **kwargs) + + key_vals = tuple(kwargs[name] for name in self.keys if name in kwargs) + cache_key = (id(self), key_vals) + kernel_name = getattr(self.base_fn, "__name__", repr(self.fn)) + + cached_idx = _picked_cache.get(cache_key) + candidate_indices = ( + [cached_idx] if cached_idx is not None else list(range(len(self.configs))) + ) + + last_exc: Exception | None = None + for idx in candidate_indices: + config = self.configs[idx] + if config.pre_hook is not None: + full_nargs = { + **dict(zip(self.arg_names, args)), + **kwargs, + **config.all_kwargs(), + } + config.pre_hook(full_nargs) + # Prefer self.fn.run(...) — the kernel-launch entrypoint for both + # JITFunction and Heuristics. Calling JITFunction(...) directly + # raises "Cannot call @triton.jit'd outside of the scope of a + # kernel". Fall back to plain call only if .run is missing. + launch = getattr(self.fn, "run", self.fn) + try: + result = launch(*args, **kwargs, **config.all_kwargs()) + except _invalid_config_errors as e: + last_exc = e + continue + + if cached_idx is None: + _picked_cache[cache_key] = idx + self.best_config = config + if kernel_name not in seen_kernels: + seen_kernels.add(kernel_name) + logger.info( + "[triton-autotune-disabled] kernel=%s configs=%d " + "picked_index=%d picked=%s", + kernel_name, + len(self.configs), + idx, + config, + ) + return result + + raise RuntimeError( + f"No valid config for kernel " + f"{kernel_name} key={key_vals} (tried {len(self.configs)} configs)" + ) from last_exc + + Autotuner.run = _run_first_valid_config + _installed = True diff --git a/vllm/triton_utils/importing.py b/vllm/triton_utils/importing.py index 8dea20fd3ea9..e17450f78d25 100644 --- a/vllm/triton_utils/importing.py +++ b/vllm/triton_utils/importing.py @@ -7,6 +7,7 @@ from importlib.util import find_spec from vllm.logger import init_logger +from vllm.platforms import current_platform from vllm.utils.math_utils import cdiv logger = init_logger(__name__) @@ -27,10 +28,16 @@ ] # Check if we're in a distributed environment where CUDA_VISIBLE_DEVICES - # might be temporarily empty (e.g., Ray sets it to "" during actor init) - cuda_visible_devices = os.environ.get("CUDA_VISIBLE_DEVICES") + # or HIP_VISIBLE_DEVICES might be temporarily empty (e.g., Ray sets it to "" + # during actor init) + visible_devices_env = ( + "HIP_VISIBLE_DEVICES" + if current_platform.is_rocm() + else "CUDA_VISIBLE_DEVICES" + ) + visible_devices = os.environ.get(visible_devices_env) is_distributed_env = ( - cuda_visible_devices is not None and len(cuda_visible_devices.strip()) == 0 + visible_devices is not None and len(visible_devices.strip()) == 0 ) # Apply lenient driver check for distributed environments diff --git a/vllm/triton_utils/jit_monitor.py b/vllm/triton_utils/jit_monitor.py deleted file mode 100644 index 5ee33fc51dc4..000000000000 --- a/vllm/triton_utils/jit_monitor.py +++ /dev/null @@ -1,113 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -""" -Monitor unexpected Triton kernel JIT compilation during inference. - -After server warmup completes, any Triton JIT compilation or autotuning -event indicates a cache miss or unexpected input shape that causes a -latency spike. This module registers hooks in the Triton runtime to -detect and log such events so they can be investigated. - -Currently monitors: -- Triton ``@triton.autotune`` cache misses (via ``knobs.autotuning.print``) -- Triton ``@triton.jit`` first-time compilations - (via ``knobs.runtime.jit_post_compile_hook``) -""" - -import os - -from vllm.logger import init_logger -from vllm.triton_utils.importing import HAS_TRITON - -logger = init_logger(__name__) - -_active: bool = False - - -def is_active() -> bool: - """Return whether the JIT compilation monitor is currently active.""" - return _active - - -def activate() -> None: - """Enable JIT compilation monitoring after warmup. - - Call once per worker process at the end of - :func:`compile_or_warm_up_model`. After activation every Triton - kernel compilation or autotuning benchmark that happens during - inference will be logged as a warning. - - Safe to call multiple times — subsequent calls are no-ops. - - If the user has explicitly set ``TRITON_PRINT_AUTOTUNING=0`` in - their environment, autotuning printing is left disabled; the JIT - compilation hook is still registered regardless. - """ - global _active - if _active: - return - _active = True - - _setup_triton_autotuning_print() - _setup_triton_jit_hook() - - logger.info( - "Kernel JIT monitor activated — Triton JIT compilations " - "during inference will be logged as warnings." - ) - - -# ------------------------------------------------------------------ -# Triton autotuning print -# ------------------------------------------------------------------ - - -def _setup_triton_autotuning_print() -> None: - """Enable ``TRITON_PRINT_AUTOTUNING`` unless the user opted out.""" - if not HAS_TRITON: - return - from triton import knobs # type: ignore[import-untyped] - - user_val = os.environ.get("TRITON_PRINT_AUTOTUNING") - if user_val == "0": - logger.debug( - "TRITON_PRINT_AUTOTUNING=0 set by user — " - "autotuning messages will stay suppressed." - ) - return - - knobs.autotuning.print = True - - -# ------------------------------------------------------------------ -# Triton JIT compilation hook -# ------------------------------------------------------------------ - - -def _setup_triton_jit_hook() -> None: - """Register a ``jit_post_compile_hook`` that warns on compilation.""" - if not HAS_TRITON: - return - from triton import knobs # type: ignore[import-untyped] - - existing_hook = knobs.runtime.jit_post_compile_hook - - def _on_jit_compile(**kwargs): - # `jit_post_compile_hook` is Triton internal API and its - # signature has changed across releases (kwargs added/renamed). - # Accept **kwargs so an upstream change cannot crash this hook - # with TypeError, and forward the full kwarg set to any - # pre-existing hook unchanged. - fn = kwargs.get("fn") - fn_name = getattr(fn, "name", "") - logger.warning_once( - "Triton kernel JIT compilation during inference: %s. " - "This causes a latency spike; consider extending warmup " - "to cover this shape/config.", - fn_name, - ) - if existing_hook is not None: - return existing_hook(**kwargs) - return None - - knobs.runtime.jit_post_compile_hook = _on_jit_compile diff --git a/vllm/utils/__init__.py b/vllm/utils/__init__.py index bf455c261f4f..e8287b0cd114 100644 --- a/vllm/utils/__init__.py +++ b/vllm/utils/__init__.py @@ -39,7 +39,7 @@ def length_from_prompt_token_ids_or_embeds( def is_moe_layer(module: torch.nn.Module) -> bool: # TODO(bnell): Should use isinstance but can't due to circular dependencies. def _check_bases(cls): - if cls.__name__ == "FusedMoE": + if cls.__name__ == "MoERunnerInterface": return True for b in cls.__bases__: diff --git a/vllm/utils/async_utils.py b/vllm/utils/async_utils.py index 725868c39a3d..3faf728384e6 100644 --- a/vllm/utils/async_utils.py +++ b/vllm/utils/async_utils.py @@ -14,215 +14,12 @@ from functools import partial from typing import TYPE_CHECKING, TypeVar -from transformers.tokenization_utils_base import BatchEncoding from typing_extensions import ParamSpec P = ParamSpec("P") T = TypeVar("T") -class AsyncMicrobatchTokenizer: - """Asynchronous tokenizer with micro-batching. - - Pulls pending encode/decode requests from a queue and batches them - up to reduce overhead. A single-thread ThreadPoolExecutor is used - so the event loop stays responsive. - """ - - def __init__( - self, - tokenizer, - max_batch_size: int = 32, - batch_wait_timeout_s: float = 0.002, - executor: ThreadPoolExecutor | None = None, - ) -> None: - self.tokenizer = tokenizer - self.max_batch_size = max_batch_size - self.batch_wait_timeout_s = batch_wait_timeout_s - - self._loop = asyncio.get_running_loop() - self._queues: dict[ - tuple, - asyncio.Queue[tuple[str, dict, Future] | tuple[list[int], Future]], - ] = {} - self._batcher_tasks: list[Task] = [] - - # Single-thread executor for blocking tokenizer calls. - # Accept an external executor to serialize with other tokenizer users. - self._executor = executor or ThreadPoolExecutor(max_workers=1) - - # === Public async API === - async def __call__(self, prompt, **kwargs) -> BatchEncoding: - result_future: Future = self._loop.create_future() - key = self._queue_key("encode", kwargs) - queue = self._get_queue(self._loop, key) - await queue.put((prompt, kwargs, result_future)) - return await result_future - - async def encode(self, prompt, **kwargs) -> list[int]: - return (await self(prompt, **kwargs)).input_ids - - async def decode(self, token_ids, **kwargs) -> str: - result_future: Future = self._loop.create_future() - key = self._queue_key("decode", kwargs) - queue = self._get_queue(self._loop, key) - await queue.put((token_ids, result_future)) - return await result_future - - # === Internal helpers === - def _get_queue( - self, loop: asyncio.AbstractEventLoop, key: tuple - ) -> asyncio.Queue[tuple[str, dict, Future] | tuple[list[int], Future]]: - """Get the request queue for the given operation key, creating a new - queue and batcher task if needed.""" - queue = self._queues.get(key) - if queue is None: - self._queues[key] = queue = asyncio.Queue() - if key[0] == "encode": - can_batch = key[1] != "other" - coro = self._batch_encode_loop(queue, can_batch) - else: - assert key[0] == "decode", f"Unknown operation type: {key[0]}." - coro = self._batch_decode_loop(queue) - self._batcher_tasks.append(loop.create_task(coro)) - return queue - - async def _batch_encode_loop(self, queue: asyncio.Queue, can_batch: bool): - """Batch incoming encode requests for efficiency.""" - while True: - prompt, kwargs, result_future = await queue.get() - prompts = [prompt] - kwargs_list = [kwargs] - result_futures = [result_future] - deadline = self._loop.time() + self.batch_wait_timeout_s - - while len(prompts) < self.max_batch_size: - timeout = deadline - self._loop.time() - if timeout <= 0: - break - try: - prompt, kwargs, result_future = await asyncio.wait_for( - queue.get(), timeout - ) - prompts.append(prompt) - result_futures.append(result_future) - if not can_batch: - kwargs_list.append(kwargs) - except asyncio.TimeoutError: - break - - try: - # If every request uses identical kwargs we can run a single - # batched tokenizer call for a big speed-up. - if can_batch and len(prompts) > 1: - batch_encode_fn = partial(self.tokenizer, prompts, **kwargs) - results = await self._loop.run_in_executor( - self._executor, batch_encode_fn - ) - - for i, fut in enumerate(result_futures): - if not fut.done(): - data = {k: v[i] for k, v in results.items()} - fut.set_result(BatchEncoding(data)) - else: - encode_fn = lambda prompts=prompts, kwargs=kwargs_list: [ - self.tokenizer(p, **kw) for p, kw in zip(prompts, kwargs) - ] - results = await self._loop.run_in_executor( - self._executor, encode_fn - ) - - for fut, res in zip(result_futures, results): - if not fut.done(): - fut.set_result(res) - except Exception as e: - for fut in result_futures: - if not fut.done(): - fut.set_exception(e) - - async def _batch_decode_loop(self, queue: asyncio.Queue): - """Batch incoming decode requests for efficiency.""" - while True: - token_ids, result_future = await queue.get() - token_ids_list = [token_ids] - result_futures = [result_future] - deadline = self._loop.time() + self.batch_wait_timeout_s - - while len(token_ids_list) < self.max_batch_size: - timeout = deadline - self._loop.time() - if timeout <= 0: - break - try: - token_ids, result_future = await asyncio.wait_for( - queue.get(), timeout - ) - token_ids_list.append(token_ids) - result_futures.append(result_future) - except asyncio.TimeoutError: - break - - try: - # Perform a single batched decode call for all requests - results = await self._loop.run_in_executor( - self._executor, self.tokenizer.batch_decode, token_ids_list - ) - for fut, res in zip(result_futures, results): - if not fut.done(): - fut.set_result(res) - except Exception as e: - for fut in result_futures: - if not fut.done(): - fut.set_exception(e) - - def _queue_key(self, op: str, kwargs: dict) -> tuple: - """ - Return a normalized key describing operation + kwargs. - - - `add_special_tokens`: {True/False} - - `truncation`: {True/False} - - If `truncation` is False (`max_length` is None), - returns a key for a can_batch queue. - - If `truncation` is True and `max_length` is None or equals - `tokenizer.model_max_length`, returns a key for a can_batch queue. - - Otherwise, returns a key for a cannot_batch queue. - - Examples: - - Decode: ("decode",) - - Encode typical: - ("encode", add_special_tokens, bool_truncation, max_length_label) - - Fallback: ("encode", "other") - """ - - if op == "decode": - return ("decode",) - - add_special_tokens = kwargs.get("add_special_tokens", True) - truncation = kwargs.get("truncation", False) - max_length = kwargs.get("max_length") - - if not truncation: - return "encode", add_special_tokens, False, None - - model_max = getattr(self.tokenizer, "model_max_length", None) - if max_length is None or (model_max is not None and max_length == model_max): - return "encode", add_special_tokens, True, "model_max" - - return "encode", "other" - - def __del__(self): - if ( - (tasks := getattr(self, "_batcher_tasks", None)) - and (loop := getattr(self, "_loop", None)) - and not loop.is_closed() - ): - - def cancel_tasks(): - for task in tasks: - task.cancel() - - loop.call_soon_threadsafe(cancel_tasks) - - def cancel_task_threadsafe(task: Task): if task and not task.done(): run_in_loop(task.get_loop(), task.cancel) @@ -248,6 +45,32 @@ def _async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Future[T]: return _async_wrapper +def make_async_with_semaphore( + func: Callable[P, T], + executor: ThreadPoolExecutor, +) -> Callable[P, Awaitable[T]]: + """ + Take a blocking function, and run it on in an executor thread. + + This function prevents the blocking function from blocking the + asyncio event loop. + The code in this function needs to be thread safe. + + The function is wrapped in a semaphore to limit the number of + concurrent executions making it easier to cancel tasks before they start. + """ + + semaphore = asyncio.Semaphore(executor._max_workers) + + async def _async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + loop = asyncio.get_event_loop() + p_func = partial(func, *args, **kwargs) + async with semaphore: + return await loop.run_in_executor(executor, p_func) + + return _async_wrapper + + def run_in_loop(loop: AbstractEventLoop, function: Callable, *args): if in_loop(loop): function(*args) @@ -280,8 +103,15 @@ async def merge_async_iterators( """ if len(iterators) == 1: # Fast-path single iterator case. - async for item in iterators[0]: - yield 0, item + iterator: AsyncGenerator[T, None] | None = iterators[0] + try: + async for item in iterator: # type: ignore[union-attr] + yield 0, item + iterator = None + finally: + if iterator is not None: + with contextlib.suppress(BaseException): + await iterator.aclose() return loop = asyncio.get_running_loop() diff --git a/vllm/utils/cache.py b/vllm/utils/cache.py index 4338983f9060..5f7647dce6fb 100644 --- a/vllm/utils/cache.py +++ b/vllm/utils/cache.py @@ -118,10 +118,8 @@ def stat(self, *, delta: bool = False) -> CacheInfo: return info def touch(self, key: _K) -> None: - try: + if key in self: self._LRUCache__order.move_to_end(key) # type: ignore - except KeyError: - self._LRUCache__order[key] = None # type: ignore @overload def get(self, key: _K, /) -> _V | None: ... diff --git a/vllm/utils/cpu_resource_utils.py b/vllm/utils/cpu_resource_utils.py index 6baf84266195..5543f4b6b018 100644 --- a/vllm/utils/cpu_resource_utils.py +++ b/vllm/utils/cpu_resource_utils.py @@ -50,6 +50,47 @@ class MemoryNodeInfo: available_memory: int = -1 +def _read_int_file(path: str) -> int | None: + try: + with open(path) as f: + value = f.read().strip() + if not value or value == "max": + return None + return int(value) + except (OSError, ValueError): + return None + + +@cache +def get_cgroup_memory_limit() -> tuple[int | None, int | None]: + """Return (limit, usage) in bytes from cgroup, or (None, None). + + Supports both cgroup v2 (unified) and v1. Returns (None, None) when + not running under a constrained cgroup (e.g. bare metal, or limit + reported as `max`/an unrealistically large value). + """ + if sys.platform != "linux": + return None, None + + # cgroup v2 unified hierarchy + v2_limit = _read_int_file("/sys/fs/cgroup/memory.max") + if v2_limit is not None: + v2_usage = _read_int_file("/sys/fs/cgroup/memory.current") + return v2_limit, v2_usage + + # cgroup v1 + v1_limit = _read_int_file("/sys/fs/cgroup/memory/memory.limit_in_bytes") + if v1_limit is not None: + # cgroup v1 reports a huge sentinel (close to PAGE_COUNTER_MAX) + # when unlimited. Treat absurdly large values as "no limit". + if v1_limit >= (1 << 62): + return None, None + v1_usage = _read_int_file("/sys/fs/cgroup/memory/memory.usage_in_bytes") + return v1_limit, v1_usage + + return None, None + + def get_memory_affinity(pid: int = 0) -> list[int]: pid = os.getpid() if pid == 0 else pid path = f"/proc/{pid}/status" @@ -114,6 +155,17 @@ def get_memory_node_info(node_id: int = 0) -> MemoryNodeInfo: free_memory + active_file_memory + inactive_file_memory + reclaimable_memory ) + # Honor cgroup memory limit (containers / k8s pods). NUMA meminfo + # reflects host-wide numbers; without this, gpu_memory_utilization + # would be applied to host RAM instead of the pod's limit. cgroup + # does not expose per-NUMA-node limits, so we just clamp the totals + # against the pod-wide limit here. + cgroup_limit, cgroup_usage = get_cgroup_memory_limit() + if cgroup_limit is not None and cgroup_limit < total_memory: + total_memory = cgroup_limit + cgroup_available = cgroup_limit - (cgroup_usage or 0) + available_memory = max(0, min(available_memory, cgroup_available)) + return MemoryNodeInfo( total_memory=total_memory, available_memory=available_memory, diff --git a/vllm/utils/cpu_triton_utils.py b/vllm/utils/cpu_triton_utils.py index ea0383a9d4b9..3b5012d01751 100644 --- a/vllm/utils/cpu_triton_utils.py +++ b/vllm/utils/cpu_triton_utils.py @@ -5,6 +5,7 @@ Contains replacement functions to fallback Triton usages in CPU backend """ +import ctypes from collections.abc import Callable import torch @@ -196,6 +197,133 @@ def _copy_and_expand_eagle_inputs_kernel_impl( out_positions_ptr.copy_(out_pos_i64.to(orig_pos_dtype)) +def _copy_and_expand_dflash_inputs_kernel_impl( + next_token_ids_ptr, + target_positions_ptr, + out_input_ids_ptr, + out_context_positions_ptr, + out_query_positions_ptr, + out_context_slot_mapping_ptr, + out_query_slot_mapping_ptr, + out_token_indices_ptr, + block_table_ptr, + block_table_stride, + query_start_loc_ptr, + num_rejected_tokens_ptr, + parallel_drafting_token_id, + block_size, + num_query_per_req, + num_speculative_tokens, + total_input_tokens, + BLOCK_SIZE=None, + HAS_NUM_REJECTED=False, +): + """Adapter between the DFlash Triton launch and the C++ CPU op.""" + assert block_table_stride == block_table_ptr.stride(0), ( + "block_table_stride mismatch: " + f"{block_table_stride} vs {block_table_ptr.stride(0)}" + ) + + orig_ids_dtype = out_input_ids_ptr.dtype + orig_context_positions_dtype = out_context_positions_ptr.dtype + orig_query_positions_dtype = out_query_positions_ptr.dtype + orig_context_slot_mapping_dtype = out_context_slot_mapping_ptr.dtype + orig_query_slot_mapping_dtype = out_query_slot_mapping_ptr.dtype + out_ids_i64 = _ensure_int64(out_input_ids_ptr) + out_context_positions_i64 = _ensure_int64(out_context_positions_ptr) + out_query_positions_i64 = _ensure_int64(out_query_positions_ptr) + out_context_slot_mapping_i64 = _ensure_int64(out_context_slot_mapping_ptr) + out_query_slot_mapping_i64 = _ensure_int64(out_query_slot_mapping_ptr) + rejected_i64 = _ensure_int64(num_rejected_tokens_ptr) if HAS_NUM_REJECTED else None + + if hasattr(torch.ops._C, "copy_and_expand_dflash_inputs_kernel_impl"): + torch.ops._C.copy_and_expand_dflash_inputs_kernel_impl( + _ensure_int64(next_token_ids_ptr), + _ensure_int64(target_positions_ptr), + out_ids_i64, + out_context_positions_i64, + out_query_positions_i64, + out_context_slot_mapping_i64, + out_query_slot_mapping_i64, + out_token_indices_ptr, + block_table_ptr, + query_start_loc_ptr, + rejected_i64, + parallel_drafting_token_id, + block_size, + num_query_per_req, + num_speculative_tokens, + total_input_tokens, + HAS_NUM_REJECTED, + ) + else: + next_ids_i64 = _ensure_int64(next_token_ids_ptr) + target_positions_i64 = _ensure_int64(target_positions_ptr) + block_table_stride = block_table_ptr.stride(0) + num_reqs = query_start_loc_ptr.shape[0] - 1 + + for req_idx in range(num_reqs): + ctx_start = int(query_start_loc_ptr[req_idx].item()) + ctx_end = int(query_start_loc_ptr[req_idx + 1].item()) + num_ctx = ctx_end - ctx_start + valid_ctx_end = ctx_end + if rejected_i64 is not None: + valid_ctx_end -= int(rejected_i64[req_idx].item()) + # Guard against out-of-bounds: ensure valid_ctx_end > ctx_start. + valid_ctx_end = max(valid_ctx_end, ctx_start + 1) + + last_pos = int(target_positions_i64[valid_ctx_end - 1].item()) + + for j in range(num_ctx): + ctx_idx = ctx_start + j + ctx_pos_idx = min(ctx_idx, total_input_tokens - 1) + position = int(target_positions_i64[ctx_pos_idx].item()) + block_num = min(position // block_size, block_table_stride - 1) + block_id = int(block_table_ptr[req_idx, block_num].item()) + slot = block_id * block_size + (position % block_size) + + out_context_positions_i64[ctx_idx] = position + out_context_slot_mapping_i64[ctx_idx] = slot + + for query_off in range(num_query_per_req): + query_out = req_idx * num_query_per_req + query_off + position = last_pos + 1 + query_off + block_num = min(position // block_size, block_table_stride - 1) + block_id = int(block_table_ptr[req_idx, block_num].item()) + slot = block_id * block_size + (position % block_size) + + out_query_positions_i64[query_out] = position + out_query_slot_mapping_i64[query_out] = slot + out_ids_i64[query_out] = ( + int(next_ids_i64[req_idx].item()) + if query_off == 0 + else parallel_drafting_token_id + ) + + if query_off > 0: + sample_out_idx = req_idx * num_speculative_tokens + (query_off - 1) + out_token_indices_ptr[sample_out_idx] = query_out + + if orig_ids_dtype != torch.int64: + out_input_ids_ptr.copy_(out_ids_i64.to(orig_ids_dtype)) + if orig_context_positions_dtype != torch.int64: + out_context_positions_ptr.copy_( + out_context_positions_i64.to(orig_context_positions_dtype) + ) + if orig_query_positions_dtype != torch.int64: + out_query_positions_ptr.copy_( + out_query_positions_i64.to(orig_query_positions_dtype) + ) + if orig_context_slot_mapping_dtype != torch.int64: + out_context_slot_mapping_ptr.copy_( + out_context_slot_mapping_i64.to(orig_context_slot_mapping_dtype) + ) + if orig_query_slot_mapping_dtype != torch.int64: + out_query_slot_mapping_ptr.copy_( + out_query_slot_mapping_i64.to(orig_query_slot_mapping_dtype) + ) + + def _rejection_greedy_sample_kernel_impl( output_token_ids, cu_num_draft_tokens, @@ -300,7 +428,12 @@ def _sample_recovered_tokens_kernel_impl( vocab_size, BLOCK_SIZE=None, NO_DRAFT_PROBS=False, + USE_FP64_GUMBEL=False, ): + # USE_FP64_GUMBEL only controls the gumbel-noise precision, which the caller + # has already applied to `inv_q` (fp64 vs fp32). The CPU kernel consumes + # `inv_q` directly, so the flag is accepted for interface parity and the + # value is read at its existing dtype. # C++ reads integer tensors as int64_t*; ensure correct dtype. orig_dtype = output_token_ids.dtype output_i64 = _ensure_int64(output_token_ids) @@ -310,7 +443,8 @@ def _sample_recovered_tokens_kernel_impl( _ensure_int64(draft_token_ids), draft_probs, target_probs, - inv_q, + # C++ kernel reads inv_q as float32. + inv_q.to(torch.float32), vocab_size, NO_DRAFT_PROBS, ) @@ -327,6 +461,9 @@ def _sample_recovered_tokens_kernel_impl( copy_and_expand_eagle_inputs_kernel = _FuncWrapper( _copy_and_expand_eagle_inputs_kernel_impl ) +copy_and_expand_dflash_inputs_kernel = _FuncWrapper( + _copy_and_expand_dflash_inputs_kernel_impl +) eagle_step_slot_mapping_metadata_kernel = _FuncWrapper( _eagle_step_slot_mapping_metadata_kernel_impl ) @@ -334,3 +471,12 @@ def _sample_recovered_tokens_kernel_impl( rejection_random_sample_kernel = _FuncWrapper(_rejection_random_sample_kernel_impl) expand_kernel = _FuncWrapper(_expand_kernel_impl) sample_recovered_tokens_kernel = _FuncWrapper(_sample_recovered_tokens_kernel_impl) + + +def _batch_memcpy_impl(src_ptrs, dst_ptrs, sizes, BLOCK_SIZE=None): + # BLOCK_SIZE is unused; kept for signature parity with the Triton kernel. + for src, dst, size in zip(src_ptrs.tolist(), dst_ptrs.tolist(), sizes.tolist()): + ctypes.memmove(dst, src, size) + + +batch_memcpy_kernel = _FuncWrapper(_batch_memcpy_impl) diff --git a/vllm/utils/deep_gemm.py b/vllm/utils/deep_gemm.py index 4252ce87754d..0a1644bcc5c6 100644 --- a/vllm/utils/deep_gemm.py +++ b/vllm/utils/deep_gemm.py @@ -5,6 +5,7 @@ Users of vLLM should always import **only** these wrappers. """ +import contextlib import functools import importlib import os @@ -37,7 +38,10 @@ def should_auto_disable_deep_gemm(model_type: str | None) -> bool: """ if model_type is None: return False - if not current_platform.is_device_capability_family(100): + if not ( + current_platform.is_device_capability_family(100) + or current_platform.is_device_capability_family(120) + ): return False return model_type in _DEEPGEMM_BLACKWELL_EXCLUDED_MODEL_TYPES @@ -71,7 +75,10 @@ def init_oracle_cache(cls) -> None: cls._oracle_cache = ( # type: ignore cls.UE8M0 - if current_platform.is_device_capability_family(100) + if ( + current_platform.is_device_capability_family(100) + or current_platform.is_device_capability_family(120) + ) else cls.FLOAT32_CEIL_UE8M0 ) @@ -120,8 +127,9 @@ def is_deep_gemm_e8m0_used() -> bool: def _missing(*_: Any, **__: Any) -> NoReturn: """Placeholder for unavailable DeepGEMM backend.""" raise RuntimeError( - "DeepGEMM backend is not available or outdated. Please install or " - "update the `deep_gemm` to a newer version to enable FP8 kernels." + "DeepGEMM backend is unavailable in the current vLLM environment, " + "or the available DeepGEMM package does not provide the required APIs " + "for these kernels." ) @@ -137,7 +145,15 @@ def _missing(*_: Any, **__: Any) -> NoReturn: _tf32_hc_prenorm_gemm_impl: Callable[..., Any] | None = None _get_mn_major_tma_aligned_tensor_impl: Callable[..., Any] | None = None _get_mk_alignment_for_contiguous_layout_impl: Callable[..., Any] | None = None +_get_theoretical_mk_alignment_for_contiguous_layout_impl: Callable[..., Any] | None = ( + None +) _transform_sf_into_required_layout_impl: Callable[..., Any] | None = None +_pack_ue8m0_to_int_impl: Callable[..., Any] | None = None +_get_mn_major_tma_aligned_packed_ue8m0_tensor_impl: Callable[..., Any] | None = None +_get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor_impl: ( + Callable[..., Any] | None +) = None @functools.cache @@ -156,7 +172,7 @@ def _import_deep_gemm(): logger.debug_once("Imported deep_gemm module from site-packages") return module except ImportError: - logger.debug_once( + logger.info_once( "deep_gemm not found in site-packages, " "trying vendored vllm.third_party.deep_gemm" ) @@ -167,7 +183,7 @@ def _import_deep_gemm(): logger.debug_once("Imported deep_gemm module from vllm.third_party.deep_gemm") return module except ImportError: - logger.debug_once("Vendored deep_gemm not found either") + logger.info_once("Vendored deep_gemm not found either") except Exception as e: # The vendored module may raise RuntimeError during _C.init() # if JIT include files are missing (e.g. incomplete wheel). @@ -176,6 +192,22 @@ def _import_deep_gemm(): return None +def _apply_pdl(mod, enable: bool = True) -> None: + mod_name = getattr(mod, "__name__", str(mod)) + try: + set_pdl_fn = getattr(mod, "set_pdl", None) + if set_pdl_fn is None: + return + set_pdl_fn(enable) + logger.info_once( + "DeepGEMM PDL %s on %s.", + "enabled" if enable else "disabled", + mod_name, + ) + except Exception as e: # noqa: BLE001 + logger.warning_once("Failed to set DeepGEMM PDL on %s: %s", mod_name, e) + + def _lazy_init() -> None: """Import deep_gemm and resolve symbols on first use.""" global _cublaslt_gemm_nt_impl @@ -186,7 +218,11 @@ def _lazy_init() -> None: global _tf32_hc_prenorm_gemm_impl global _get_mn_major_tma_aligned_tensor_impl global _get_mk_alignment_for_contiguous_layout_impl + global _get_theoretical_mk_alignment_for_contiguous_layout_impl global _transform_sf_into_required_layout_impl + global _pack_ue8m0_to_int_impl + global _get_mn_major_tma_aligned_packed_ue8m0_tensor_impl + global _get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor_impl # fast path if ( _cublaslt_gemm_nt_impl is not None @@ -201,6 +237,9 @@ def _lazy_init() -> None: or _tf32_hc_prenorm_gemm_impl is not None or _get_mk_alignment_for_contiguous_layout_impl is not None or _transform_sf_into_required_layout_impl is not None + or _pack_ue8m0_to_int_impl is not None + or _get_mn_major_tma_aligned_packed_ue8m0_tensor_impl is not None + or _get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor_impl is not None ): return @@ -218,6 +257,9 @@ def _lazy_init() -> None: if _dg is None: return + # Enable PDL for DeepGEMM on architectures that support it (SM90+). + if current_platform.is_arch_support_pdl(): + _apply_pdl(_dg, True) _cublaslt_gemm_nt_impl = getattr(_dg, "cublaslt_gemm_nt", None) _fp8_gemm_nt_impl = getattr(_dg, "fp8_gemm_nt", None) _fp8_einsum_impl = getattr(_dg, "fp8_einsum", None) @@ -238,9 +280,19 @@ def _lazy_init() -> None: _get_mk_alignment_for_contiguous_layout_impl = getattr( _dg, "get_mk_alignment_for_contiguous_layout", None ) + _get_theoretical_mk_alignment_for_contiguous_layout_impl = getattr( + _dg, "get_theoretical_mk_alignment_for_contiguous_layout", None + ) _transform_sf_into_required_layout_impl = getattr( _dg, "transform_sf_into_required_layout", None ) + _pack_ue8m0_to_int_impl = getattr(_dg, "pack_ue8m0_to_int", None) + _get_mn_major_tma_aligned_packed_ue8m0_tensor_impl = getattr( + _dg, "get_mn_major_tma_aligned_packed_ue8m0_tensor", None + ) + _get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor_impl = getattr( + _dg, "get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor", None + ) DeepGemmQuantScaleFMT.init_oracle_cache() @@ -260,7 +312,6 @@ def set_num_sms(num_sms: int) -> None: dg.set_num_sms(num_sms) -@functools.cache def get_mk_alignment_for_contiguous_layout() -> list[int]: _lazy_init() if _get_mk_alignment_for_contiguous_layout_impl is None: @@ -269,6 +320,70 @@ def get_mk_alignment_for_contiguous_layout() -> list[int]: return [mk_align_size, mk_align_size] +def get_theoretical_mk_alignment_for_contiguous_layout( + expected_m: int | None = None, + num_groups: int | None = None, +) -> int: + """Per-call optimal M alignment for grouped contiguous GEMMs. + + `expected_m` is the TOTAL routed tokens (sum across experts, typically + M × num_topk). `num_groups` is the number of experts on this rank. + The helper divides to recover per-expert em and picks an alignment based + on data-driven thresholds (see deep_gemm runtime.hpp comments). + + Older callers that omit `num_groups` are interpreted as passing already + per-expert em (legacy behaviour preserved for backward compat). + """ + _lazy_init() + if _get_theoretical_mk_alignment_for_contiguous_layout_impl is None: + return _missing() + if num_groups is None: + return _get_theoretical_mk_alignment_for_contiguous_layout_impl(expected_m) + if num_groups <= 0: + raise ValueError(f"num_groups must be positive, got {num_groups}") + try: + return _get_theoretical_mk_alignment_for_contiguous_layout_impl( + expected_m, num_groups + ) + except TypeError: + per_group_m = None if expected_m is None else cdiv(expected_m, num_groups) + return _get_theoretical_mk_alignment_for_contiguous_layout_impl(per_group_m) + + +def set_mk_alignment_for_contiguous_layout(value: int) -> None: + """Set DeepGEMM's BLOCK_M cap for grouped contiguous GEMMs. + + The DG heuristic constrains BLOCK_M ≤ this value when picking a kernel + layout. Use this in concert with `compute_aligned_M_and_alignment`'s + per-call alignment so the workspace's per-expert padding matches the + kernel's BLOCK_M; a mismatch leads to the scheduler reading the wrong + expert_id from `m_indices` at `m_block_idx * BLOCK_M` stride and + OOB-indexing the B-weights tensor (manifests as IMA under CUDA-graph + replay). + """ + _lazy_init() + dg = _import_deep_gemm() + if dg is None: + raise RuntimeError("DeepGEMM is not available") + dg.set_mk_alignment_for_contiguous_layout(value) + + +@contextlib.contextmanager +def mk_alignment_scope(value: int): + """Temporarily set DeepGEMM's BLOCK_M cap, restoring on exit. + + Use around a sequence of grouped-contiguous GEMM calls whose workspace + is padded to `value` (typically the per_call_align returned by + `compute_aligned_M_and_alignment`). + """ + prev = get_mk_alignment_for_contiguous_layout()[0] + set_mk_alignment_for_contiguous_layout(value) + try: + yield + finally: + set_mk_alignment_for_contiguous_layout(prev) + + def get_col_major_tma_aligned_tensor(x: torch.Tensor) -> torch.Tensor: """Wrapper for DeepGEMM's get_mn_major_tma_aligned_tensor""" _lazy_init() @@ -277,6 +392,48 @@ def get_col_major_tma_aligned_tensor(x: torch.Tensor) -> torch.Tensor: return _get_mn_major_tma_aligned_tensor_impl(x) +def pack_ue8m0_to_int(x: torch.Tensor) -> torch.Tensor: + """Pack 4 UE8M0 (uint8) scales into one int32. + + DeepGEMM's SM100/SM120 FP8/FP4 kernels accept either ``float32`` scales + (legacy format, 4 B/scale) or ``int32`` packed UE8M0 scales (1 B/scale + after 4:1 packing — 4× smaller than the legacy fp32 representation). + """ + _lazy_init() + if _pack_ue8m0_to_int_impl is None: + return _missing() + return _pack_ue8m0_to_int_impl(x) + + +def get_mn_major_tma_aligned_packed_ue8m0_tensor(x: torch.Tensor) -> torch.Tensor: + """Pack UE8M0 (uint8) → int32 with the MN-major TMA-aligned layout the + DeepGEMM kernels consume directly. 16× smaller than the fp32 legacy SF + format. Use for non-grouped 2D scale tensors. + """ + _lazy_init() + if _get_mn_major_tma_aligned_packed_ue8m0_tensor_impl is None: + return _missing() + return _get_mn_major_tma_aligned_packed_ue8m0_tensor_impl(x) + + +def get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor( + sf: torch.Tensor, + ks_tensor: torch.Tensor, + ks: list[int], + gran_k: int, +) -> torch.Tensor: + """Grouped (3D, expert-batched) variant of + ``get_mn_major_tma_aligned_packed_ue8m0_tensor``. Use for MoE weight + scale tensors of shape ``(num_experts, mn, k_scale)``. + """ + _lazy_init() + if _get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor_impl is None: + return _missing() + return _get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor_impl( + sf, ks_tensor, ks, gran_k + ) + + def cublaslt_gemm_nt(*args, **kwargs): _lazy_init() if _cublaslt_gemm_nt_impl is None: @@ -581,4 +738,8 @@ def should_use_deepgemm_for_fp8_linear( "should_use_deepgemm_for_fp8_linear", "get_col_major_tma_aligned_tensor", "get_mk_alignment_for_contiguous_layout", + "get_theoretical_mk_alignment_for_contiguous_layout", + "pack_ue8m0_to_int", + "get_mn_major_tma_aligned_packed_ue8m0_tensor", + "get_k_grouped_mn_major_tma_aligned_packed_ue8m0_tensor", ] diff --git a/vllm/utils/flashinfer.py b/vllm/utils/flashinfer.py index f7ed180a7300..998c3b63df66 100644 --- a/vllm/utils/flashinfer.py +++ b/vllm/utils/flashinfer.py @@ -72,6 +72,13 @@ def _missing(*_: Any, **__: Any) -> NoReturn: ) +def _missing_sparse_mla(*_: Any, **__: Any) -> NoReturn: + raise RuntimeError( + "FlashInfer sparse MLA decode APIs are not available. " + "Install a FlashInfer build that includes sparse MLA decode support." + ) + + def _get_submodule(module_name: str) -> Any | None: """Safely import a submodule and return it, or None if not available.""" try: @@ -141,6 +148,18 @@ def wrapper(*args, **kwargs): trtllm_fp4_block_scale_moe = _lazy_import_wrapper( "flashinfer", "trtllm_fp4_block_scale_moe" ) +flashinfer_trtllm_batch_decode_with_kv_cache_mla = _lazy_import_wrapper( + "flashinfer.decode", + "trtllm_batch_decode_with_kv_cache_mla", + fallback_fn=_missing_sparse_mla, +) +flashinfer_trtllm_batch_decode_sparse_mla_dsv4 = _lazy_import_wrapper( + "flashinfer.decode", + "trtllm_batch_decode_sparse_mla_dsv4", + fallback_fn=_missing_sparse_mla, +) + + # Special case for autotune since it returns a context manager autotune = _lazy_import_wrapper( "flashinfer.autotuner", @@ -193,6 +212,26 @@ def has_flashinfer_moe() -> bool: ) +@functools.cache +def has_flashinfer_sparse_mla_sm120() -> bool: + """Return ``True`` if FlashInfer sparse MLA decode support is available.""" + if not has_flashinfer(): + return False + try: + from flashinfer.autotuner import autotune + from flashinfer.decode import ( + trtllm_batch_decode_sparse_mla_dsv4, + trtllm_batch_decode_with_kv_cache_mla, + ) + except ImportError: + return False + return ( + callable(trtllm_batch_decode_sparse_mla_dsv4) + and callable(trtllm_batch_decode_with_kv_cache_mla) + and callable(autotune) + ) + + @functools.cache def has_flashinfer_cutedsl() -> bool: """Return ``True`` if FlashInfer cutedsl module is available.""" @@ -332,19 +371,27 @@ def has_nvidia_artifactory() -> bool: @functools.cache -def supports_trtllm_attention() -> bool: - """ - TRTLLM attention is supported if the platform is SM100, - NVIDIA artifactory is accessible, and batch-invariant mode is not enabled. +def supports_trtllm_attention(is_prefill: bool = False) -> bool: + """Return whether TRTLLM attention is available on the current platform + for the given attention phase. + + SM90 (Hopper) supports the XQA decode kernel but not TRTLLM prefill. + SM100+ supports TRTLLM for both phases. All others are unsupported. """ # Batch-invariant mode disables TRTLLM attention if envs.VLLM_BATCH_INVARIANT: return False - # Requires SM100 and NVIDIA artifactory to be accessible to download cubins - return ( - current_platform.is_device_capability_family(100) and has_nvidia_artifactory() - ) + # Requires NVIDIA artifactory to be accessible to download cubins + if not has_nvidia_artifactory(): + return False + + # SM90 has XQA decode; prefill is not supported. + if current_platform.is_device_capability(90): + return not is_prefill + + # SM100/SM103 has both prefill and decode TRTLLM kernels. + return current_platform.is_device_capability_family(100) def force_use_trtllm_attention() -> bool | None: @@ -361,12 +408,15 @@ def force_use_trtllm_attention() -> bool | None: return vllm_config.attention_config.use_trtllm_attention -def can_use_trtllm_attention(num_qo_heads: int, num_kv_heads: int) -> bool: +def can_use_trtllm_attention( + num_qo_heads: int, num_kv_heads: int, is_prefill: bool = False +) -> bool: """Check if the current configuration supports TRTLLM attention.""" if force_use_trtllm_attention() is False: return False - has_trtllm = supports_trtllm_attention() - return has_trtllm and (num_qo_heads % num_kv_heads == 0) + return supports_trtllm_attention(is_prefill=is_prefill) and ( + num_qo_heads % num_kv_heads == 0 + ) def use_trtllm_attention( @@ -398,11 +448,12 @@ def use_trtllm_attention( return False # The platform is not supported - if not supports_trtllm_attention(): + if not supports_trtllm_attention(is_prefill=is_prefill): if force_use_trtllm: logger.warning_once( - "TRTLLM attention is not supported on this platform, " - "but --attention-config.use_trtllm_attention is set to 1" + "TRTLLM attention is not supported on this platform for %s, " + "but --attention-config.use_trtllm_attention is set to 1", + "prefill" if is_prefill else "decode", ) return False @@ -437,13 +488,20 @@ def use_trtllm_attention( if is_prefill: # Prefill auto-detection use_trtllm = kv_cache_dtype == "auto" - if use_trtllm: - logger.warning_once("Using TRTLLM prefill attention (auto-detected).") + elif current_platform.is_device_capability(90) and kv_cache_dtype.startswith( + "fp8" + ): + # SM90 + FP8 KV cache: prefer the XQA decode kernel. XQA does not + # support NVFP4 KV (that is an SM100 trtllm-gen path only). + use_trtllm = True else: # Decode auto-detection use_trtllm = num_tokens <= 256 and kv_cache_dtype == "auto" - if use_trtllm: - logger.warning_once("Using TRTLLM decode attention (auto-detected).") + if use_trtllm: + logger.warning_once( + "Using TRTLLM %s attention (auto-detected).", + "prefill" if is_prefill else "decode", + ) return use_trtllm # CLI argument is set to 1 - respect it @@ -918,20 +976,27 @@ def should_use_flashinfer_for_blockscale_fp8_gemm( return should_use_flashinfer -_MIN_CUDNN_FP8 = 91701 # cuDNN >= 9.17.1 required for FP8 attention +_MIN_CUDNN_FP8 = 91701 # cuDNN >= 9.17.1 required for FP8 ViT attention @functools.cache def is_flashinfer_cudnn_fp8_prefill_attn_supported() -> bool: """Check if FP8 ViT attention is supported on this platform. - Requires native FP8 hardware support, the FlashInfer cuDNN backend, + Requires Blackwell (SM 100) or newer, the FlashInfer cuDNN backend, and cuDNN >= 9.17.1. + + cuDNN's FP8 SDPA forward path with bf16/fp16 output (used by + ``MMEncoderAttention._forward_flashinfer``) gates internally on + ``prop.major >= 10``; on Hopper it raises a misleading + ``cudnnGraphNotSupportedError: ... cuDNN version 9.13.0 and newer`` + even when the installed cuDNN is new enough. See PR #38065 for the + original Blackwell-only design intent. """ from vllm.v1.attention.backends.registry import AttentionBackendEnum - # cuDNN SDPA FP8 requires Hopper (SM 90) or newer. - if not current_platform.has_device_capability(90): + # cuDNN SDPA FP8 with bf16/fp16 output requires Blackwell (SM 100) or newer. + if not current_platform.has_device_capability(100): return False try: @@ -965,6 +1030,8 @@ def is_flashinfer_cudnn_fp8_prefill_attn_supported() -> bool: "flashinfer_b12x_fused_moe", "flashinfer_convert_sf_to_mma_layout", "trtllm_fp4_block_scale_moe", + "flashinfer_trtllm_batch_decode_with_kv_cache_mla", + "flashinfer_trtllm_batch_decode_sparse_mla_dsv4", "autotune", "has_flashinfer_moe", "has_flashinfer_comm", diff --git a/vllm/utils/gpu_sync_debug.py b/vllm/utils/gpu_sync_debug.py new file mode 100644 index 000000000000..1e2114f3b5bd --- /dev/null +++ b/vllm/utils/gpu_sync_debug.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import functools +import sys +from contextlib import contextmanager + +import torch + +import vllm.envs as envs +from vllm.platforms import current_platform + +SYNC_ERROR_MESSAGE = ( + "GPU<->CPU sync detected - avoid it or wrap with gpu_sync_allowed()" +) + +_GPU_SYNC_ALLOWED_FIRST_SEEN: set[tuple[str, int]] = set() + +# Global sync-check gate. Off during engine setup (model load, KV cache +# init, warmup/compile) so first-compile and lazy-init syncs pass through; +# flipped on by `enable_gpu_sync_check()` at the end of +# `GPUWorker.compile_or_warm_up_model`, after which `with_gpu_sync_check`- +# decorated functions activate the configured debug mode. +_sync_check_enabled: bool = False + + +def enable_gpu_sync_check() -> None: + """Flip the sync-check gate on. Call once per worker, after warmup / + first-compile is complete. No-op unless `VLLM_GPU_SYNC_CHECK` is set.""" + if envs.VLLM_GPU_SYNC_CHECK is None: + return + global _sync_check_enabled + _sync_check_enabled = True + _install_compile_time_sync_suppressors() + + +_compile_time_suppressors_installed: bool = False + + +def _install_compile_time_sync_suppressors() -> None: + """Wrap torch inductor/aot_autograd compile entry points so the + synchronizing ops those passes perform don't trip the + sync-check mode we set around `execute_model` / `sample_tokens`. + + Warmup-time compiles already run under the gate (before + `enable_gpu_sync_check`), but post-warmup compiles fire inside + `execute_model` and we want to avoid this tripping the sync check. + """ + global _compile_time_suppressors_installed + if _compile_time_suppressors_installed: + return + _compile_time_suppressors_installed = True + + try: # noqa: BLE001 + from torch._inductor.fx_passes import joint_graph as _jg + + _orig_joint = _jg.joint_graph_passes + + @functools.wraps(_orig_joint) + def _wrapped_joint(*args, **kwargs): + prev_mode = torch.cuda.get_sync_debug_mode() + if not prev_mode: + return _orig_joint(*args, **kwargs) + torch.cuda.set_sync_debug_mode(0) + try: + return _orig_joint(*args, **kwargs) + finally: + torch.cuda.set_sync_debug_mode(prev_mode) + + # `compile_fx` does `from .fx_passes.joint_graph import + # joint_graph_passes`, which binds the *function object* at import + # time. Patching just the module attribute won't update that rebind, + # so patch every already-imported reference we can find. Restrict + # the scan to torch's compile-time modules. + import sys as _sys + + setattr(_jg, "joint_graph_passes", _wrapped_joint) # noqa: B010 + for _name, _mod in list(_sys.modules.items()): + if _mod is None: + continue + if not ( + _name.startswith("torch._inductor") + or _name.startswith("torch._functorch") + or _name.startswith("torch._dynamo") + ): + continue + if getattr(_mod, "joint_graph_passes", None) is _orig_joint: + setattr(_mod, "joint_graph_passes", _wrapped_joint) # noqa: B010 + except Exception: # pragma: no cover + pass + + +@contextmanager +def _suppress_gpu_sync_check(prev_mode: int): + torch.cuda.set_sync_debug_mode(0) + try: + yield + finally: + torch.cuda.set_sync_debug_mode(prev_mode) + + +@contextmanager +def _noop_cm(): + yield + + +if current_platform.is_cuda_alike(): + + def gpu_sync_allowed(first_only: bool = False): + """Context manager that suppresses `torch.cuda.set_sync_debug_mode` for the + duration of the `with` block. + + If `first_only` is True, only the first entry from this call site + suppresses the sync check; subsequent entries from the same site are + no-ops so any further GPU syncs will be reported. The "site" is the + caller's (filename, lineno), so different + `with gpu_sync_allowed(first_only=True):` lines track independently. + """ + if envs.VLLM_GPU_SYNC_CHECK is None or torch.compiler.is_compiling(): + return _noop_cm() + prev_mode = torch.cuda.get_sync_debug_mode() + if not prev_mode: + return _noop_cm() + if first_only: + frame = sys._getframe(1) + key = (frame.f_code.co_filename, frame.f_lineno) + if key in _GPU_SYNC_ALLOWED_FIRST_SEEN: + return _noop_cm() + _GPU_SYNC_ALLOWED_FIRST_SEEN.add(key) + return _suppress_gpu_sync_check(prev_mode) + + def with_gpu_sync_check(fn): + """Decorator that enables `torch.cuda.set_sync_debug_mode` around `fn` + when `VLLM_GPU_SYNC_CHECK` is set *and* the gate has been flipped by + `enable_gpu_sync_check()`. Before the gate flips (i.e. during + engine setup / warmup) the decorated function runs as-is. + """ + mode = envs.VLLM_GPU_SYNC_CHECK + if mode is None: + return fn + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + if not _sync_check_enabled: + return fn(*args, **kwargs) + prev_mode = torch.cuda.get_sync_debug_mode() + torch.cuda.set_sync_debug_mode(mode) + try: + return fn(*args, **kwargs) + except RuntimeError as re: + if str(re) == "called a synchronizing CUDA operation": + raise RuntimeError(SYNC_ERROR_MESSAGE) from re + raise re + finally: + torch.cuda.set_sync_debug_mode(prev_mode) + + return wrapper + +else: + # No-op the methods in non-CUDA cases. + + def gpu_sync_allowed(first_only: bool = False): + return _noop_cm() + + def with_gpu_sync_check(fn): + return fn diff --git a/vllm/utils/hpc.py b/vllm/utils/hpc.py new file mode 100644 index 000000000000..abe546c91658 --- /dev/null +++ b/vllm/utils/hpc.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Compatibility wrapper for HPC API changes. + +Users of vLLM should always import **only** these wrappers. +""" + +import functools +import importlib +import importlib.util + +import torch + +from vllm.logger import init_logger + +logger = init_logger(__name__) + + +@functools.cache +def has_hpc() -> bool: + """Return `True` if hpc package is available.""" + # Use find_spec to check if the module exists without importing it + # This avoids potential CUDA initialization side effects + if importlib.util.find_spec("hpc") is None: + logger.warning_once( + "HPC attention requires the hpc module to be installed. " + "Please install it from https://github.com/Tencent/hpc-ops" + ) + return False + return True + + +# Remove 'torch._library.custom_ops': +# The output of this custom operator (1) must not also be an input to +# this custom operator and (2) may not alias any inputs to this custom +# operator or other returns. The most common way to trigger this error +# is if we have y = custom_op(x) and y and x are the same Tensor. +# Please instead return a clone of the offending output tensor(s) (e.g. +# return x.clone()) or refactor the custom operator to not return y. +# @torch.library.custom_op( +# "vllm::fuse_moe_impl", +# mutates_args=[], +# device_types="cuda", +# ) +def fuse_moe_impl( + x: torch.Tensor, + gate_up_weight: torch.Tensor, + down_weight: torch.Tensor, + gate_up_scale: torch.Tensor, + down_scale: torch.Tensor, + act_and_mul_scale: torch.Tensor, + topk_ids: torch.Tensor, + topk_scale: torch.Tensor, + rank_ep: int, + num_expert_total: int, + use_bf16_mul: bool = True, + shared_output: torch.Tensor = None, + output: torch.Tensor = None, +) -> torch.Tensor: + from hpc import fuse_moe as fuse_moe_ + + return fuse_moe_( + x, + gate_up_weight, + down_weight, + gate_up_scale, + down_scale, + act_and_mul_scale, + topk_ids, + topk_scale, + rank_ep, + num_expert_total, + use_bf16_mul, + shared_output, + output=output, + ) + + +# @torch.library.register_fake( +# "vllm::fuse_moe_impl", +# ) +def fuse_moe_impl_fake( + x: torch.Tensor, + gate_up_weight: torch.Tensor, + down_weight: torch.Tensor, + gate_up_scale: torch.Tensor, + down_scale: torch.Tensor, + act_and_mul_scale: torch.Tensor, + topk_ids: torch.Tensor, + topk_scale: torch.Tensor, + rank_ep: int, + num_expert_total: int, + use_bf16_mul: bool = True, + shared_output: torch.Tensor = None, + output: torch.Tensor = None, +) -> torch.Tensor: + return torch.empty_like(x) + + +def hpc_fuse_moe( + x: torch.Tensor, + gate_up_weight: torch.Tensor, + down_weight: torch.Tensor, + gate_up_scale: torch.Tensor, + down_scale: torch.Tensor, + act_and_mul_scale: torch.Tensor, + topk_ids: torch.Tensor, + topk_scale: torch.Tensor, + rank_ep: int, + num_expert_total: int, + use_bf16_mul: bool = True, + shared_output: torch.Tensor = None, + output: torch.Tensor = None, +) -> torch.Tensor: + return fuse_moe_impl( + x, + gate_up_weight, + down_weight, + gate_up_scale, + down_scale, + act_and_mul_scale, + topk_ids, + topk_scale, + rank_ep, + num_expert_total, + use_bf16_mul, + shared_output, + output=output, + ) + + +# @torch.library.custom_op( +# "vllm::fuse_moe_blockwise_impl", +# mutates_args=[], +# device_types="cuda", +# ) +def fuse_moe_blockwise_impl( + x: torch.Tensor, + x_scale: torch.Tensor, + gate_up_weight: torch.Tensor, + gate_up_weight_scale: torch.Tensor, + down_weight: torch.Tensor, + down_weight_scale: torch.Tensor, + topk_ids: torch.Tensor, + topk_scale: torch.Tensor, + rank_ep: int, + num_expert_total: int, + shared_output: torch.Tensor = None, + output: torch.Tensor = None, +) -> torch.Tensor: + from hpc import fuse_moe_blockwise as fuse_moe_blockwise_ + + return fuse_moe_blockwise_( + x, + x_scale, + gate_up_weight, + gate_up_weight_scale, + down_weight, + down_weight_scale, + topk_ids, + topk_scale, + rank_ep, + num_expert_total, + shared_output, + output=output, + ) + + +# @torch.library.register_fake( +# "vllm::fuse_moe_blockwise_impl", +# ) +def fuse_moe_blockwise_impl_fake( + x: torch.Tensor, + x_scale: torch.Tensor, + gate_up_weight: torch.Tensor, + gate_up_weight_scale: torch.Tensor, + down_weight: torch.Tensor, + down_weight_scale: torch.Tensor, + topk_ids: torch.Tensor, + topk_scale: torch.Tensor, + rank_ep: int, + num_expert_total: int, + shared_output: torch.Tensor = None, + output: torch.Tensor = None, +) -> torch.Tensor: + return torch.empty_like(x) + + +def hpc_fuse_moe_blockwise( + x: torch.Tensor, + x_scale: torch.Tensor, + gate_up_weight: torch.Tensor, + gate_up_weight_scale: torch.Tensor, + down_weight: torch.Tensor, + down_weight_scale: torch.Tensor, + topk_ids: torch.Tensor, + topk_scale: torch.Tensor, + rank_ep: int, + num_expert_total: int, + shared_output: torch.Tensor = None, + output: torch.Tensor = None, +) -> torch.Tensor: + return fuse_moe_blockwise_impl( + x, + x_scale, + gate_up_weight, + gate_up_weight_scale, + down_weight, + down_weight_scale, + topk_ids, + topk_scale, + rank_ep, + num_expert_total, + shared_output, + output=output, + ) + + +__all__ = [ + "has_hpc", + "hpc_fuse_moe", + "hpc_fuse_moe_blockwise", +] diff --git a/vllm/utils/humming.py b/vllm/utils/humming.py new file mode 100644 index 000000000000..bdd519bd8c51 --- /dev/null +++ b/vllm/utils/humming.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Lazy facade for the optional ``humming`` package. + +vLLM code should import humming symbols from here so that ``import humming`` +(which has import-time side effects) is deferred until first use. Add new +symbols by appending one entry to ``_EXPORTS`` as ``"module.path:attr"``, +or ``"module.path"`` for a whole-module re-export. +""" + +import importlib +from typing import Any + +_EXPORTS: dict[str, str] = { + "dtypes": "humming.dtypes", + "DataType": "humming.dtypes:DataType", + "GemmType": "humming.config:GemmType", + "WeightScaleType": "humming.config:WeightScaleType", + "HummingMethod": "humming.layer:HummingMethod", + "HummingLayerMeta": "humming.layer:HummingLayerMeta", + "BaseInputSchema": "humming.schema:BaseInputSchema", + "BaseWeightSchema": "humming.schema:BaseWeightSchema", + "HummingInputSchema": "humming.schema:HummingInputSchema", + "HummingWeightSchema": "humming.schema:HummingWeightSchema", + "quantize_weight": "humming.utils.weight:quantize_weight", + "AWQWeightSchema": "humming.schema:AWQWeightSchema", + "BitnetWeightSchema": "humming.schema:BitnetWeightSchema", + "ModeloptMxfp8WeightSchema": "humming.schema.modelopt:ModeloptMxfp8WeightSchema", + "ModeloptNvfp4InputSchema": "humming.schema.modelopt:ModeloptNvfp4InputSchema", + "ModeloptNvfp4WeightSchema": "humming.schema.modelopt:ModeloptNvfp4WeightSchema", + "CompressedTensorsInputSchema": "humming.schema:CompressedTensorsInputSchema", + "CompressedTensorsWeightSchema": "humming.schema:CompressedTensorsWeightSchema", + "Fp8InputSchema": "humming.schema:Fp8InputSchema", + "Fp8WeightSchema": "humming.schema.fp8:Fp8WeightSchema", + "Mxfp4WeightSchema": "humming.schema:Mxfp4WeightSchema", + "GptOssMxfp4WeightSchema": "humming.schema:GptOssMxfp4WeightSchema", + "GPTQWeightSchema": "humming.schema:GPTQWeightSchema", +} + + +def __getattr__(name: str) -> Any: + spec = _EXPORTS.get(name) + if spec is None: + raise AttributeError(f"module 'vllm.utils.humming' has no attribute {name!r}") + if ":" in spec: + mod_path, attr = spec.split(":", 1) + obj = getattr(importlib.import_module(mod_path), attr) + else: + obj = importlib.import_module(spec) + globals()[name] = obj + return obj + + +def __dir__() -> list[str]: + return sorted({*globals(), *_EXPORTS}) diff --git a/vllm/utils/import_utils.py b/vllm/utils/import_utils.py index c37b3b6c70c9..812adb59e6ce 100644 --- a/vllm/utils/import_utils.py +++ b/vllm/utils/import_utils.py @@ -417,6 +417,61 @@ def has_deep_ep() -> bool: return _has_module("deep_ep") +DEEPEP_V2_MIN_NCCL_VERSION_RAW = 23004 # 2.30.4 + + +def _get_runtime_nccl_version() -> int | None: + """Get the runtime NCCL version by loading the actual library. + + Returns the raw version int (e.g. 23004 for 2.30.4), or None on failure. + torch.cuda.nccl.version() is a compile-time constant from the PyTorch + wheel and does not reflect a separately installed NCCL. + """ + import ctypes + + try: + from vllm.utils.nccl import find_nccl_library + + lib = ctypes.CDLL(find_nccl_library()) + version = ctypes.c_int() + lib.ncclGetVersion(ctypes.byref(version)) + return version.value + except Exception: + return None + + +def _format_nccl_raw_version(raw: int) -> str: + s = str(raw) + return f"{s[0]}.{s[1:3].lstrip('0') or '0'}.{s[3:].lstrip('0') or '0'}" + + +def has_deep_ep_v2() -> bool: + """Whether deep_ep with ElasticBuffer (v2 API) is available. + + Requires both the ElasticBuffer class in the deep_ep module and + NCCL >= 2.30.4 (GIN backend), checked against the runtime library. + """ + if not _has_module("deep_ep"): + return False + import deep_ep # type: ignore[import-not-found] + + if not hasattr(deep_ep, "ElasticBuffer"): + return False + try: + nccl_ver = _get_runtime_nccl_version() + if nccl_ver is None or nccl_ver < DEEPEP_V2_MIN_NCCL_VERSION_RAW: + logger.info_once( + "DeepEP v2 requires NCCL >= %s but found %s. " + "deepep_v2 backend will not be available.", + _format_nccl_raw_version(DEEPEP_V2_MIN_NCCL_VERSION_RAW), + _format_nccl_raw_version(nccl_ver) if nccl_ver else "unknown", + ) + return False + except Exception: + return False + return True + + def has_deep_gemm() -> bool: """Whether the optional `deep_gemm` package is available. @@ -432,6 +487,11 @@ def has_nixl_ep() -> bool: return _has_module("nixl_ep") +def is_numba_available() -> bool: + """Whether the optional `numba` package is available.""" + return _has_module("numba") + + def has_triton_kernels() -> bool: """Whether the optional `triton_kernels` package is available.""" is_available = _has_module("triton_kernels") or _has_module( @@ -487,3 +547,26 @@ def has_fbgemm_gpu() -> bool: def has_cutedsl() -> bool: """Whether the optional `cutelass` package is available.""" return _has_module("cutlass") + + +def has_humming() -> bool: + """Whether the optional `humming` package is available.""" + return _has_module("humming") + + +def check_torchcodec_available(): + """Whether the optional `torchcodec` package is available.""" + try: + import torchcodec # noqa: F401 + except RuntimeError as e: + # torchcodec will raise RuntimeError during import instead + # of ImportError when system ffmpeg unavailable, with a + # message that can leak sensitive system information. + # Trim it down to avoid it. + marker = ( + "The following exceptions were raised as we tried to load libtorchcodec:" + ) + message = str(e) + if marker in message: + raise RuntimeError(message.split(marker, 1)[0].rstrip()) from None + raise e diff --git a/vllm/utils/jit_monitor.py b/vllm/utils/jit_monitor.py new file mode 100644 index 000000000000..8228e24c1c3b --- /dev/null +++ b/vllm/utils/jit_monitor.py @@ -0,0 +1,530 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Monitor unexpected kernel JIT compilation during inference. + +After server warmup completes, any kernel JIT compilation or autotuning event +indicates a cache miss or unexpected input shape that causes a latency spike. +This module registers hooks in supported runtimes to detect such events so +they can be investigated. + +Set ``--jit-monitor-mode=error`` to fail fast on unexpected runtime +compilation. Set ``--jit-monitor-verbose`` to log every JIT compile with +additional runtime details. Verbose logging is intentionally opt-in because it +can emit many logs and add overhead. + +Currently monitors: +- CuTeDSL cute.compile calls +- Triton ``@triton.autotune`` cache misses (via ``knobs.autotuning.print``) +- Triton ``@triton.jit`` first-time compilations + (via ``knobs.runtime.jit_post_compile_hook``) +- TileLang ``@tilelang.jit`` first-time compilations +""" + +import contextlib +import functools +import importlib +import os +from collections.abc import Iterator, Mapping +from contextlib import suppress +from typing import Any, Literal, cast + +from vllm.logger import init_logger +from vllm.triton_utils.importing import HAS_TRITON + +logger = init_logger(__name__) +JitMonitorMode = Literal["warn", "error"] + +_active: bool = False +_mode: JitMonitorMode = "warn" +_verbose: bool = False +_cutedsl_hook_installed: bool = False +_tilelang_hook_installed: bool = False +_tilelang_jitimpl_compile_depth: int = 0 + + +def is_active() -> bool: + """Return whether the JIT compilation monitor is currently active.""" + return _active + + +def activate(*, mode: JitMonitorMode = "warn", verbose: bool = False) -> None: + """Enable JIT compilation monitoring after warmup. + + Call once per worker process at the end of + :func:`compile_or_warm_up_model`. After activation every monitored kernel + compilation or autotuning benchmark that happens during inference will be + logged as a warning or raised as an error, depending on ``mode``. + + Safe to call multiple times; subsequent calls are no-ops. + + If the user has explicitly set ``TRITON_PRINT_AUTOTUNING=0`` in + their environment, autotuning printing is left disabled; the JIT + compilation hook is still registered regardless. + """ + global _active, _mode, _verbose + if _active: + return + if mode not in ("warn", "error"): + raise ValueError(f"Unsupported JIT monitor mode: {mode!r}") + _active = True + _mode = mode + _verbose = verbose + + _setup_triton_autotuning_print() + _setup_triton_jit_hook() + _setup_cutedsl_jit_hook() + _setup_tilelang_jit_hook() + + logger.info( + "Kernel JIT monitor activated; monitored JIT compilations during " + "inference will use mode=%s.", + mode, + ) + + +# ------------------------------------------------------------------ +# Triton autotuning print +# ------------------------------------------------------------------ + + +def _setup_triton_autotuning_print() -> None: + """Enable ``TRITON_PRINT_AUTOTUNING`` unless the user opted out.""" + if not HAS_TRITON: + return + from triton import knobs # type: ignore[import-untyped] + + user_val = os.environ.get("TRITON_PRINT_AUTOTUNING") + if user_val == "0": + logger.debug( + "TRITON_PRINT_AUTOTUNING=0 set by user; " + "autotuning messages will stay suppressed." + ) + return + + knobs.autotuning.print = True + + +# ------------------------------------------------------------------ +# Triton JIT compilation hook +# ------------------------------------------------------------------ + + +def _handle_jit_event( + *, + backend: str, + event: str, + fn_name: str, + detail: str | None = None, +) -> None: + message = ( + "%s %s during inference: %s%s. " + "This causes a latency spike; consider extending warmup " + "to cover this shape/config." + ) + detail_suffix = f" ({detail})" if detail else "" + args = (backend, event, fn_name, detail_suffix) + + if _mode == "error": + raise RuntimeError(message % args) + + if _verbose: + logger.warning(message, *args) + return + + logger.warning_once(message, *args) + + +def _safe_repr(value: object, *, max_len: int = 120) -> str: + try: + text = repr(value) + except Exception: + text = f"<{type(value).__name__}>" + if len(text) > max_len: + return text[: max_len - 3] + "..." + return text + + +def _get_compile_info(kwargs: Mapping[str, object]) -> dict: + compile_info = kwargs.get("compile") + if isinstance(compile_info, dict): + return compile_info + return {} + + +def _constant_name(fn: object, path: object) -> str: + jit_function = getattr(fn, "jit_function", None) + params = getattr(jit_function, "params", ()) + if isinstance(path, tuple) and path and isinstance(path[0], int): + idx = path[0] + if idx < len(params): + param_name = getattr(params[idx], "name", None) + if param_name is not None: + if len(path) == 1: + return param_name + suffix = "".join(f"[{part!r}]" for part in path[1:]) + return f"{param_name}{suffix}" + return str(path) + + +def _format_constants(fn: object, compile_info: Mapping[str, object]) -> str: + constants = compile_info.get("constants") + if not isinstance(constants, Mapping) or not constants: + return "{}" + + items = sorted( + ( + (_constant_name(fn, path), _safe_repr(value)) + for path, value in constants.items() + ), + key=lambda item: item[0], + ) + return "{" + ", ".join(f"{name}={value}" for name, value in items) + "}" + + +def _format_signature(compile_info: Mapping[str, object]) -> str: + signature = compile_info.get("signature") + if not isinstance(signature, Mapping) or not signature: + return "{}" + items = sorted((str(k), _safe_repr(v)) for k, v in signature.items()) + return "{" + ", ".join(f"{name}={value}" for name, value in items) + "}" + + +def _format_extra_compile_info(compile_info: Mapping[str, object]) -> str: + skip_keys = frozenset( + { + "constants", + "signature", + "key", + "fn", + "name", + } + ) + items = [ + f"{name}={_safe_repr(value)}" + for name, value in sorted(compile_info.items()) + if name not in skip_keys + ] + return "{" + ", ".join(items) + "}" + + +def _format_verbose_triton_compile_details(kwargs: Mapping[str, object]) -> str: + compile_info = _get_compile_info(kwargs) + fn = kwargs.get("fn") + key = compile_info.get("key") or kwargs.get("key") + return ( + f"constexprs={_format_constants(fn, compile_info)}; " + f"signature={_format_signature(compile_info)}; " + f"extra_compile_info={_format_extra_compile_info(compile_info)}; " + f"key={_safe_repr(key)}" + ) + + +def _log_triton_jit_compile(fn_name: str, kwargs) -> None: + detail = _format_verbose_triton_compile_details(kwargs) if _verbose else None + _handle_jit_event( + backend="Triton", + event="kernel JIT compilation", + fn_name=fn_name, + detail=detail, + ) + + +def _setup_triton_jit_hook() -> None: + """Register a ``jit_post_compile_hook`` that warns on compilation.""" + if not HAS_TRITON: + return + from triton import knobs # type: ignore[import-untyped] + + existing_hook = knobs.runtime.jit_post_compile_hook + + def _on_jit_compile(**kwargs): + # `jit_post_compile_hook` is Triton internal API and its + # signature has changed across releases (kwargs added/renamed). + # Accept **kwargs so an upstream change cannot crash this hook + # with TypeError, and forward the full kwarg set to any + # pre-existing hook unchanged. + fn = kwargs.get("fn") + fn_name = getattr(fn, "name", "") + _log_triton_jit_compile(fn_name, kwargs) + if existing_hook is not None: + return existing_hook(**kwargs) + return None + + knobs.runtime.jit_post_compile_hook = _on_jit_compile + + +# ------------------------------------------------------------------ +# CuTeDSL JIT compilation hook +# ------------------------------------------------------------------ + + +def _log_cutedsl_jit_compile(fn_name: str) -> None: + _handle_jit_event( + backend="CuTeDSL", + event="JIT compilation", + fn_name=fn_name, + ) + + +def _setup_cutedsl_jit_hook() -> None: + """Wrap ``cutlass.cute.compile`` to warn on compilation.""" + global _cutedsl_hook_installed + if _cutedsl_hook_installed: + return + + try: + import cutlass.cute as cute + except Exception: + logger.debug("CuTeDSL is not available; skipping CuTeDSL JIT monitor.") + return + + original_compile = cute.compile + + @functools.wraps(original_compile) + def _compile_with_monitor(*args, **kwargs): + kernel = args[0] if args else kwargs.get("function") + kernel_name = getattr(kernel, "__name__", None) + if kernel_name is None: + kernel_name = ( + kernel.__class__.__name__ if kernel is not None else "" + ) + _log_cutedsl_jit_compile(kernel_name) + return original_compile(*args, **kwargs) + + cute.compile = _compile_with_monitor + _cutedsl_hook_installed = True + + +# ------------------------------------------------------------------ +# TileLang JIT compilation hook +# ------------------------------------------------------------------ + + +def _tilelang_arg( + args: tuple[object, ...], + kwargs: Mapping[str, object], + index: int, + name: str, + default: object = None, +) -> object: + if len(args) > index: + return args[index] + return kwargs.get(name, default) + + +def _tilelang_kernel_name(func: object) -> str: + attrs = getattr(func, "attrs", None) + global_symbol = None + get_attr = getattr(attrs, "get", None) + if callable(get_attr): + try: + global_symbol = get_attr("global_symbol") + except Exception: + global_symbol = None + elif isinstance(attrs, Mapping): + global_symbol = attrs.get("global_symbol") + + if global_symbol is not None: + return str(global_symbol) + + name = getattr(func, "__name__", None) + if name is not None: + return str(name) + return func.__class__.__name__ if func is not None else "" + + +def _tilelang_call_kwargs(kwargs: Mapping[str, object]) -> dict[str, object]: + call_kwargs = dict(kwargs) + tune_params = call_kwargs.pop("__tune_params", {}) + if isinstance(tune_params, Mapping): + call_kwargs.update(tune_params) + return call_kwargs + + +def _tilelang_cache_miss_key( + jit_impl: object, + args: tuple[object, ...], + kwargs: Mapping[str, object], +) -> object | None: + if kwargs.get("__return_compile_arguments", False): + return None + + call_kwargs = _tilelang_call_kwargs(kwargs) + func = getattr(jit_impl, "func", None) + parse_args = getattr(func, "parse_args", None) + if not callable(parse_args): + return None + + try: + if getattr(jit_impl, "mode", None) == "auto": + impl = cast(Any, jit_impl) + mode = impl._infer_jit_mode(*args, **call_kwargs) + impl.mode = mode + if func is not None: + func.set_mode(mode) + key, _ = parse_args(*args, **call_kwargs) + except Exception: + return None + + cache = getattr(jit_impl, "_kernel_cache", {}) + if isinstance(cache, Mapping): + return key if key not in cache else None + + get = getattr(cache, "get", None) + if not callable(get): + return key + try: + return key if get(key) is None else None + except Exception: + return key + + +def _format_tilelang_runtime_shapes( + jit_impl: object, + args: tuple[object, ...], + kwargs: Mapping[str, object], +) -> str: + signature = getattr(jit_impl, "signature", None) + bind_partial = getattr(signature, "bind_partial", None) + if not callable(bind_partial): + return "{}" + + try: + bound = bind_partial(*args, **_tilelang_call_kwargs(kwargs)) + bound.apply_defaults() + except Exception: + return "{}" + + items = [] + for name, value in bound.arguments.items(): + if str(name).startswith("__"): + continue + if not (hasattr(value, "shape") and hasattr(value, "dtype")): + continue + shape = getattr(value, "shape", None) + if shape is not None: + with suppress(TypeError): + shape = tuple(shape) + items.append(f"{name}={_safe_repr(shape, max_len=80)}") + return "{" + ", ".join(items) + "}" + + +def _format_verbose_tilelang_compile_details( + jit_impl: object, + args: tuple[object, ...], + kwargs: Mapping[str, object], + cache_key: object, +) -> str: + return ( + f"cache_key={_safe_repr(cache_key, max_len=400)}; " + f"runtime_shapes={_format_tilelang_runtime_shapes(jit_impl, args, kwargs)}" + ) + + +def _log_tilelang_jit_compile( + fn_name: str, + detail: str | None = None, +) -> None: + _handle_jit_event( + backend="TileLang", + event="JIT compilation", + fn_name=fn_name, + detail=detail, + ) + + +def _setup_tilelang_jit_hook() -> None: + """Wrap TileLang JIT entry points to warn on compilation.""" + global _tilelang_hook_installed + if _tilelang_hook_installed: + return + + try: + tilelang_kernel = importlib.import_module("tilelang.jit.kernel") + except Exception: + logger.debug("TileLang is not available; skipping TileLang JIT monitor.") + return + + jit_kernel_cls = getattr(tilelang_kernel, "JITKernel", None) + if jit_kernel_cls is None: + logger.debug( + "TileLang JITKernel is unavailable; skipping TileLang JIT monitor." + ) + return + + try: + tilelang_jit = importlib.import_module("tilelang.jit") + except Exception: + tilelang_jit = None + jit_impl_cls = getattr(tilelang_jit, "JITImpl", None) + original_init = jit_kernel_cls.__init__ + + @functools.wraps(original_init) + def _init_with_monitor(self, *args, **kwargs): + from_database = bool(_tilelang_arg(args, kwargs, 7, "from_database", False)) + if not from_database and _tilelang_jitimpl_compile_depth == 0: + func = _tilelang_arg(args, kwargs, 0, "func") + _log_tilelang_jit_compile(_tilelang_kernel_name(func)) + return original_init(self, *args, **kwargs) + + jit_kernel_cls.__init__ = _init_with_monitor + + if jit_impl_cls is not None: + original_call = jit_impl_cls.__call__ + + @functools.wraps(original_call) + def _call_with_monitor(self, *args, **kwargs): + global _tilelang_jitimpl_compile_depth + cache_key = _tilelang_cache_miss_key(self, args, kwargs) + if cache_key is None: + return original_call(self, *args, **kwargs) + + _tilelang_jitimpl_compile_depth += 1 + try: + detail = None + if _verbose: + detail = _format_verbose_tilelang_compile_details( + self, args, kwargs, cache_key + ) + func = getattr(self, "func", None) + orig_func = getattr(func, "orig_func", None) + _log_tilelang_jit_compile( + _tilelang_kernel_name(orig_func or func), detail + ) + return original_call(self, *args, **kwargs) + finally: + _tilelang_jitimpl_compile_depth -= 1 + + jit_impl_cls.__call__ = _call_with_monitor + + _tilelang_hook_installed = True + + +@contextlib.contextmanager +def numba_workqueue_threading_layer() -> Iterator[None]: + """Force numba's fork-safe `workqueue` threading layer for this block. + + GNU OpenMP (numba's default `omp` threading layer) aborts the process + if a forked child re-enters an OpenMP-active runtime. vLLM forks the + EngineCore subprocess from a process that may already have launched + numba's parallel accelerator, so the first call to any + `@njit(parallel=True)` function must happen under `workqueue` instead. + The threading layer choice is sticky for the life of the process once + launched, so restoring the config on exit does not undo the effect. + """ + import numba + + key = "NUMBA_THREADING_LAYER" + previous_env = os.environ.get(key) + previous_config = numba.config.THREADING_LAYER + os.environ[key] = "workqueue" + numba.config.THREADING_LAYER = "workqueue" + try: + yield + finally: + if previous_env is None: + os.environ.pop(key, None) + else: + os.environ[key] = previous_env + numba.config.THREADING_LAYER = previous_config diff --git a/vllm/utils/mem_utils.py b/vllm/utils/mem_utils.py index 4efb29975af8..b0ac4b16e476 100644 --- a/vllm/utils/mem_utils.py +++ b/vllm/utils/mem_utils.py @@ -11,10 +11,13 @@ import torch import torch.types +from vllm.logger import init_logger from vllm.platforms import current_platform from .mem_constants import GiB_bytes, KiB_bytes, MiB_bytes +logger = init_logger(__name__) + def format_kib(b: int) -> str: return f"{round(b / KiB_bytes, 2)}" @@ -45,6 +48,41 @@ def get_cpu_memory() -> int: return psutil.virtual_memory().total +_UMA_PRESSURE_THRESHOLD = 0.8 +_UMA_MIN_RELEASE_BYTES = 512 * MiB_bytes + + +def release_device_memory_under_pressure(device: torch.device) -> bool: + """On integrated (UMA) GPUs, release caching-allocator memory back to the + OS when system memory pressure is high. The OS may start thrashing before + an allocation failure would trigger PyTorch's own cache release. + + Returns: + True if memory was released. + """ + if device.type != "cuda" or not current_platform.is_integrated_gpu(device.index): + return False + + releasable = torch.accelerator.memory_reserved( + device + ) - torch.accelerator.memory_allocated(device) + if releasable < _UMA_MIN_RELEASE_BYTES: + return False + + # cudaMemGetInfo underreports free memory on UMA, see MemorySnapshot.measure + mem = psutil.virtual_memory() + if mem.available > (1 - _UMA_PRESSURE_THRESHOLD) * mem.total: + return False + + torch.accelerator.synchronize(device) + torch.accelerator.empty_cache() + logger.debug( + "Released %sGiB of cached device memory under memory pressure", + format_gib(releasable), + ) + return True + + class DeviceMemoryProfiler: def __init__(self, device: torch.types.Device | None = None): self.device = device @@ -105,7 +143,7 @@ def measure(self) -> None: "allocated_bytes.all.peak", 0 ) - self.free_memory, self.total_memory = current_platform.mem_get_info(device) + self.free_memory, self.total_memory = torch.accelerator.get_memory_info(device) if current_platform.is_integrated_gpu(device.index): # On UMA (Unified Memory Architecture) platforms where CPU and # GPU share physical memory (e.g. GH200, DGX Spark, Jetson Orin), diff --git a/vllm/utils/numa_utils.py b/vllm/utils/numa_utils.py index 6e4b4b471c11..2e52935ea662 100644 --- a/vllm/utils/numa_utils.py +++ b/vllm/utils/numa_utils.py @@ -473,6 +473,37 @@ def log_current_affinity_state(label: str) -> None: _log_numactl_show(label) +def _probe_numactl_args(numactl_args: str) -> bool: + """Whether ``numactl true`` succeeds in this (parent) environment.""" + try: + result = subprocess.run( + ["numactl", *numactl_args.split(), "true"], + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def _resolve_numactl_args(numactl_args: str) -> str: + """Drop ``--membind`` if the container rejects it, keeping CPU binding.""" + cpu_only = " ".join( + t for t in numactl_args.split() if not t.startswith("--membind=") + ) + for candidate in (numactl_args, cpu_only, ""): + if _probe_numactl_args(candidate): + if candidate != numactl_args: + logger.warning( + "numactl args %r rejected; falling back to %r. Add " + "--cap-add SYS_NICE for full NUMA binding.", + numactl_args, + candidate or "no binding", + ) + return candidate + return "" + + @contextmanager def configure_subprocess( vllm_config: "VllmConfig", @@ -500,6 +531,11 @@ def configure_subprocess( ) executable, debug_str = _get_numactl_executable() + numactl_args = _resolve_numactl_args(numactl_args) + if not numactl_args: + # No NUMA binding possible here; launch without the wrapper. + yield + return python_executable = os.fsdecode(multiprocessing.spawn.get_executable()) with ( _set_numa_wrapper_env(numactl_args, python_executable), diff --git a/vllm/utils/platform_utils.py b/vllm/utils/platform_utils.py index cc69d9a241c6..5d7fed3c9904 100644 --- a/vllm/utils/platform_utils.py +++ b/vllm/utils/platform_utils.py @@ -7,6 +7,7 @@ from functools import cache from typing import Any +import regex as re import torch @@ -62,3 +63,12 @@ def num_compute_units(device_id: int = 0) -> int: from vllm.platforms import current_platform return current_platform.num_compute_units(device_id) + + +@cache +def get_device_name_as_file_name(device_id: int = 0) -> str: + from vllm.platforms import current_platform + + name = current_platform.get_device_name(device_id) + name = re.sub(r"[\s/]+", "_", name) + return name diff --git a/vllm/utils/system_utils.py b/vllm/utils/system_utils.py index 7f56f972a4fa..e2ac15a949f8 100644 --- a/vllm/utils/system_utils.py +++ b/vllm/utils/system_utils.py @@ -308,26 +308,26 @@ def set_ulimit(target_soft_limit: int = 65535): def find_loaded_library(lib_name: str) -> str | None: """ - According to according to https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html, + According to https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html, the file `/proc/self/maps` contains the memory maps of the process, which includes the shared libraries loaded by the process. We can use this file to find the path of the loaded library. """ # noqa - found_line = None + # Match the mapped file's name, not the whole line: an unrelated library + # whose name merely contains lib_name (e.g. TileLang's libcudart_stub.so + # when looking for libcudart) or a directory component containing it must + # not win. Legitimate filenames are {lib_name}.so[.*] or a name-mangled + # {lib_name}-.so[.*], and /proc/self/maps is ordered by mapping + # address rather than load order, so a substring hit is a + # nondeterministic hijack. with open("/proc/self/maps") as f: for line in f: - if lib_name in line: - found_line = line - break - if found_line is None: - # the library is not loaded in the current process - return None - # if lib_name is libcudart, we need to match a line with: - # address /path/to/libcudart-hash.so.11.0 - start = found_line.index("/") - path = found_line[start:].strip() - filename = path.split("/")[-1] - assert filename.rpartition(".so")[0].startswith(lib_name), ( - f"Unexpected filename: {filename} for library {lib_name}" - ) - return path + start = line.find("/") + if start == -1: + continue + path = line[start:].strip() + filename = path.rsplit("/", maxsplit=1)[-1] + if filename.startswith((f"{lib_name}.", f"{lib_name}-")): + return path + # the library is not loaded in the current process + return None diff --git a/vllm/utils/torch_utils.py b/vllm/utils/torch_utils.py index 12ec5b0fcc66..cf821a54baac 100644 --- a/vllm/utils/torch_utils.py +++ b/vllm/utils/torch_utils.py @@ -3,7 +3,6 @@ import contextlib import importlib.metadata import os -import platform import random import threading from collections.abc import Callable, Collection @@ -18,6 +17,7 @@ import vllm.envs as envs from vllm.logger import init_logger +from vllm.utils.platform_utils import is_pin_memory_available if TYPE_CHECKING: from vllm.config import ModelConfig @@ -39,6 +39,7 @@ "fp8_e4m3": torch.uint8, "fp8_e5m2": torch.uint8, "int8": torch.int8, + "int4_per_token_head": torch.uint8, "int8_per_token_head": torch.int8, "fp8_per_token_head": torch.uint8, "fp8_inc": torch.float8_e4m3fn, @@ -68,9 +69,7 @@ T = TypeVar("T") -# Pin memory in non-WSL case. -# Logic duplicated here for now to avoid circular import. -PIN_MEMORY = "microsoft" not in " ".join(platform.uname()).lower() +PIN_MEMORY = is_pin_memory_available() def is_quantized_kv_cache(kv_cache_dtype: str) -> bool: @@ -606,14 +605,24 @@ def create_kv_caches_with_random( def async_tensor_h2d( - data: list, - dtype: torch.dtype, + data: list | np.ndarray | torch.Tensor, device: str | torch.device, - pin_memory: bool = PIN_MEMORY, + dtype: torch.dtype | None = None, ) -> torch.Tensor: - """Asynchronously create a tensor and copy it from host to device.""" - t = torch.tensor(data, dtype=dtype, pin_memory=pin_memory, device="cpu") - return t.to(device=device, non_blocking=True) + """Copy list/numpy array/tensor async from host to device.""" + if isinstance(data, np.ndarray): + data = torch.from_numpy(data) + if isinstance(data, torch.Tensor): + t = data.pin_memory() if PIN_MEMORY else data + else: + t = torch.tensor(data, dtype=dtype, pin_memory=PIN_MEMORY, device="cpu") + assert t.is_cpu + return t.to(device=device, dtype=dtype, non_blocking=True) + + +def np_to_pinned_tensor(array: np.ndarray) -> torch.Tensor: + t = torch.from_numpy(array) + return t.pin_memory() if PIN_MEMORY else t def make_ndarray_with_pad( @@ -914,11 +923,6 @@ def _encode_layer_name(layer_name: str) -> str | LayerName: return LayerName(layer_name) if _USE_LAYERNAME else layer_name -# Supports xccl with PyTorch versions >= 2.8.0.dev for XPU platform -def supports_xccl() -> bool: - return torch.distributed.is_xccl_available() - - # Supports XPU Graph with PyTorch versions >= 2.11.0.dev for XPU platform def supports_xpu_graph() -> bool: return is_torch_equal_or_newer("2.11.0.dev") diff --git a/vllm/v1/attention/backend.py b/vllm/v1/attention/backend.py index af58bfd31a57..bfecb3c952e1 100644 --- a/vllm/v1/attention/backend.py +++ b/vllm/v1/attention/backend.py @@ -16,6 +16,7 @@ kFp8StaticTensorSym, kNvfp4Dynamic, ) +from vllm.utils.torch_utils import np_to_pinned_tensor if TYPE_CHECKING: from vllm.config import VllmConfig @@ -201,6 +202,38 @@ def get_preferred_block_size(cls, default_block_size: int) -> int: return min(s.base if isinstance(s, MultipleOf) else s for s in supported_sizes) + @classmethod + def indexes_kv_by_block_stride(cls) -> bool: + """Whether the backend reads KV pages by the runtime block stride. + + True when ``num_blocks`` is the outermost physical dimension of the KV + cache, so the backend tolerates a non-contiguous block dim. This gates + page size padding and cross-layer uniform KV layout. + + Returns: + True if the backend's physical KV layout is num-blocks-first. False + otherwise, including when the backend does not define a layered + stride order. + """ + try: + kv_cache_stride_order = cls.get_kv_cache_stride_order( + include_num_layers_dimension=False + ) + layered_kv_cache_stride_order = cls.get_kv_cache_stride_order( + include_num_layers_dimension=True + ) + except (AttributeError, NotImplementedError): + return False + + # Check that attention backend includes a layers dimension. + if len(layered_kv_cache_stride_order) != len(kv_cache_stride_order) + 1: + return False + + # stride_order[0] == 0 means num_layers stays first in physical + # layout (identity permutation), so indexing by block stride is + # not supported. + return layered_kv_cache_stride_order[0] != 0 + @classmethod def is_mla(cls) -> bool: return False @@ -267,6 +300,7 @@ def supports_combination( use_mla: bool, has_sink: bool, use_sparse: bool, + use_mm_prefix: bool, device_capability: "DeviceCapability", ) -> str | None: return None @@ -334,6 +368,7 @@ def validate_configuration( use_mla, has_sink, use_sparse, + use_mm_prefix, device_capability, ) if combination_reason is not None: @@ -385,7 +420,7 @@ class CommonAttentionMetadata: block_table_tensor: torch.Tensor slot_mapping: torch.Tensor - causal: bool = True + causal: bool | torch.Tensor = True # Needed by FastPrefillAttentionBuilder logits_indices_padded: torch.Tensor | None = None @@ -402,7 +437,7 @@ class CommonAttentionMetadata: positions: torch.Tensor | None = None """(num_actual_tokens,) token positions. Optional; set when the caller has positions available so that builders can pre-compute position-dependent - metadata (e.g. C128A topk indices for DeepSeek V4).""" + sparse metadata for DeepSeek V4 C128A layers.""" is_prefilling: torch.Tensor | None = None """(batch_size,) bool tensor: True if request is still in prefill phase @@ -415,11 +450,25 @@ class CommonAttentionMetadata: decode rows (assumes every draft was accepted). Not safe for kernels that need exact per-row context lengths on decode rows.""" + mm_req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None + """PrefixLM bidirectional ranges for multimodal tokens. Maps + request index to list of (start, end) token position ranges + where bidirectional attention should apply. None for text-only + batches or non-PrefixLM models.""" + + rswa_prefix_lens: torch.Tensor | None = None + """(batch_size,) per-request prefix length (prompt/image token count) for + Reference Sliding Window Attention (R-SWA). Tokens with logical index below + this stay globally visible; later (generated) tokens additionally see a + fixed sliding window. None disables R-SWA. The attention backend copies this + into its own persistent buffer and reads ``rswa_window`` from model config.""" + # WARNING: Deprecated fields. Will be removed in a future release (v0.15.0) _seq_lens_cpu: torch.Tensor | None = None _num_computed_tokens_cpu: torch.Tensor | None = None _num_computed_tokens_cache: torch.Tensor | None = None + _token_to_req_indices_cache: torch.Tensor | None = None def batch_size(self) -> int: return self.seq_lens.shape[0] @@ -468,6 +517,31 @@ def compute_num_computed_tokens(self) -> torch.Tensor: self._num_computed_tokens_cache = self.seq_lens - query_lens return self._num_computed_tokens_cache + def token_to_req_indices(self, buffer: torch.Tensor) -> torch.Tensor: + """Build or reuse the per-token request index mapping.""" + num_tokens = self.num_actual_tokens + if self._token_to_req_indices_cache is not None: + assert self._token_to_req_indices_cache.device == buffer.device + assert self._token_to_req_indices_cache.dtype == torch.int32 + assert self._token_to_req_indices_cache.shape[0] >= num_tokens + return self._token_to_req_indices_cache[:num_tokens] + + starts = np.asarray(self.query_start_loc_cpu, dtype=np.int32) + query_lens = np.diff(starts) + token_to_req_indices = np.repeat( + np.arange(query_lens.shape[0], dtype=np.int32), query_lens + ) + num_mapped_tokens = token_to_req_indices.shape[0] + assert buffer.shape[0] >= max(num_mapped_tokens, num_tokens) + # copy from CPU to GPU + buffer[:num_mapped_tokens].copy_( + np_to_pinned_tensor(token_to_req_indices), non_blocking=True + ) + if num_mapped_tokens < num_tokens: + buffer[num_mapped_tokens:num_tokens].zero_() + self._token_to_req_indices_cache = buffer[: max(num_mapped_tokens, num_tokens)] + return self._token_to_req_indices_cache[:num_tokens] + # TODO(lucas): remove once we have FULL-CG spec-decode support def unpadded( self, num_actual_tokens: int, num_actual_reqs: int @@ -489,7 +563,9 @@ def unpadded( max_seq_len=self.max_seq_len, block_table_tensor=self.block_table_tensor[:num_actual_reqs], slot_mapping=self.slot_mapping[:num_actual_tokens], - causal=self.causal, + causal=self.causal[:num_actual_reqs] + if isinstance(self.causal, torch.Tensor) + else self.causal, logits_indices_padded=self.logits_indices_padded, num_logits_indices=self.num_logits_indices, encoder_seq_lens=maybe_slice_reqs(self.encoder_seq_lens), @@ -497,6 +573,7 @@ def unpadded( dcp_local_seq_lens=maybe_slice_reqs(self.dcp_local_seq_lens), dcp_local_seq_lens_cpu=maybe_slice_reqs(self.dcp_local_seq_lens_cpu), is_prefilling=maybe_slice_reqs(self.is_prefilling), + rswa_prefix_lens=maybe_slice_reqs(self.rswa_prefix_lens), ) @@ -706,6 +783,17 @@ class AttentionImplBase(ABC, Generic[T]): # Some features like decode context parallelism require the softmax lse. can_return_lse_for_decode: bool = False + # Base of the logarithm used by this backend when returning softmax lse. + # True => natural log (lse = ln(sum(exp(qk)))) + # -- e.g. Triton MLA, FlashAttention, FlashMLA, Cutlass MLA + # False => base 2 (lse = log2(sum(exp(qk)))) + # -- e.g. FlashInfer trtllm-gen MLA + # The DCP combine kernel (cp_lse_ag_out_rs / dcp_a2a_lse_reduce in + # vllm/v1/attention/ops/common.py) branches on this via its IS_BASE_E + # constexpr; getting it wrong silently corrupts the cross-shard + # softmax denominator. + lse_base_on_e: bool = True + # Whether the attention impl supports Prefill Context Parallelism. supports_pcp: bool = False # Whether the attention impl(or ops) supports MTP @@ -808,14 +896,17 @@ def forward( ) -> torch.Tensor: raise NotImplementedError - def fused_output_quant_supported(self, quant_key: "QuantKey"): + def fused_output_quant_supported(self, quant_key: "QuantKey") -> bool: """ Does this attention implementation support fused output quantization. This is used by the AttnFusionPass to only fuse output quantization onto implementations that support it. - :param quant_key: QuantKey object that describes the quantization op - :return: is fusion supported for this type of quantization + Args: + quant_key: QuantKey object that describes the quantization op + + Returns: + is fusion supported for this type of quantization """ return False @@ -886,6 +977,7 @@ def forward_mha( attn_metadata: T, k_scale: torch.Tensor, output: torch.Tensor, + output_scale: torch.Tensor | None = None, ) -> None: """MHA-style prefill forward pass.""" raise NotImplementedError diff --git a/vllm/v1/attention/backends/cpu_attn.py b/vllm/v1/attention/backends/cpu_attn.py index 3519691a3c58..a5735bf313f5 100644 --- a/vllm/v1/attention/backends/cpu_attn.py +++ b/vllm/v1/attention/backends/cpu_attn.py @@ -11,7 +11,7 @@ from vllm import _custom_ops as ops from vllm import envs -from vllm.config import VllmConfig +from vllm.config import VllmConfig, get_current_vllm_config from vllm.logger import init_logger from vllm.platforms import CpuArchEnum, current_platform from vllm.utils.torch_utils import is_quantized_kv_cache @@ -26,22 +26,19 @@ ) from vllm.v1.attention.backends.utils import ( KVCacheLayoutType, - split_decodes_and_prefills, ) -from vllm.v1.kv_cache_interface import AttentionSpec, CrossAttentionSpec +from vllm.v1.kv_cache_interface import ( + AttentionSpec, + CrossAttentionSpec, + EncoderOnlyAttentionSpec, +) logger = init_logger(__name__) -_CPU_ARCH_PREFER_MIXED_BATCH = ( - CpuArchEnum.X86, - CpuArchEnum.ARM, - CpuArchEnum.S390X, - CpuArchEnum.RISCV, - CpuArchEnum.POWERPC, -) - class CPUAttentionBackend(AttentionBackend): + forward_includes_kv_cache_update: bool = False + supported_dtypes: ClassVar[list[torch.dtype]] = [ torch.float16, torch.bfloat16, @@ -66,6 +63,10 @@ def get_supported_head_sizes(cls) -> list[int]: def get_name() -> str: return "CPU_ATTN" + @classmethod + def supports_non_causal(cls) -> bool: + return True + @classmethod def supports_attn_type(cls, attn_type: str) -> bool: """CPU attention supports decoder, @@ -106,7 +107,6 @@ def use_cascade_attention(*args, **kwargs) -> bool: @dataclass class CPUAttentionMetadata: - isa: str num_actual_tokens: int # Number of tokens excluding padding. max_query_len: int query_start_loc: torch.Tensor @@ -116,6 +116,7 @@ class CPUAttentionMetadata: slot_mapping: torch.Tensor scheduler_metadata: torch.Tensor | None causal: bool = True + dynamic_causal: torch.Tensor | None = None # can be removed after deprecate sdpa use_sdpa_prefill: bool = False @@ -123,6 +124,8 @@ class CPUAttentionMetadata: sdpa_attn_masks: list[torch.Tensor | None] | None = None sdpa_start_loc: torch.Tensor | None = None + encoder_cache: torch.Tensor | None = None + class CPUAttentionMetadataBuilder(AttentionMetadataBuilder[CPUAttentionMetadata]): def __init__( @@ -134,22 +137,11 @@ def __init__( ) -> None: super().__init__(kv_cache_spec, layer_names, vllm_config, device) - self.use_sdpa_prefill = False - reorder_batch_threshold = None - if current_platform.get_cpu_architecture() not in _CPU_ARCH_PREFER_MIXED_BATCH: - # in this case, decode seqs are reordered to the front of prefill seqs - # to split decode and prefill. Then use SDPA for prefill and - # cpu_attention_with_kv_cache for decode - reorder_batch_threshold = 1 - self.use_sdpa_prefill = True - - self._init_reorder_batch_threshold(reorder_batch_threshold, False) - self.kv_cache_spec = kv_cache_spec self.vllm_config = vllm_config parallel_config = vllm_config.parallel_config - self.num_kv_heads = vllm_config.model_config.get_num_kv_heads(parallel_config) + self.num_kv_heads = kv_cache_spec.num_kv_heads self.num_heads = vllm_config.model_config.get_num_attention_heads( parallel_config ) @@ -167,6 +159,9 @@ def __init__( kv_cache_dtype_str, ) self.is_cross_attention = isinstance(kv_cache_spec, CrossAttentionSpec) + self.is_encoder_only_attention = isinstance( + kv_cache_spec, EncoderOnlyAttentionSpec + ) def build( self, @@ -182,25 +177,45 @@ def build( seq_lens = common_attn_metadata.seq_lens block_table_tensor = common_attn_metadata.block_table_tensor slot_mapping = common_attn_metadata.slot_mapping - causal = False if self.is_cross_attention else common_attn_metadata.causal - - sdpa_start_loc = query_start_loc - num_decode_tokens = 0 - if self.use_sdpa_prefill and causal: - # Decoder, need reorder and truncate - assert self.reorder_batch_threshold - (num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens) = ( - split_decodes_and_prefills( - common_attn_metadata, - decode_threshold=self.reorder_batch_threshold, - require_uniform=True, - ) + is_dynamic_casual = isinstance(common_attn_metadata.causal, torch.Tensor) + dynamic_casual = None + if is_dynamic_casual: + dynamic_casual = common_attn_metadata.causal + + causal = ( + False + if self.is_cross_attention or is_dynamic_casual + else common_attn_metadata.causal + ) + + encoder_cache_tensor = None + if self.is_encoder_only_attention: + block_nums = (seq_lens + self.block_size - 1) // self.block_size + start_block_ids = torch.zeros_like(seq_lens) + torch.cumsum(block_nums[:-1], 0, out=start_block_ids[1:]) + total_block_num: int = block_nums.sum().item() + max_block_num = block_nums.max().item() + block_offsets = torch.arange( + 0, max_block_num, dtype=block_table_tensor.dtype ) - num_reqs = num_decodes - sdpa_start_loc = sdpa_start_loc[num_decodes:] - num_decode_tokens - seq_lens = seq_lens[:num_decodes] - query_start_loc = query_start_loc[: num_decodes + 1] - block_table_tensor = block_table_tensor[:num_decodes] + encoder_block_table = start_block_ids[:, None] + block_offsets[None, :] + torch.ops._C.compute_slot_mapping_kernel_impl( + query_start_loc, + common_attn_metadata.positions, + encoder_block_table, + slot_mapping, + self.block_size, + ) + encoder_cache_tensor = torch.zeros( + ( + total_block_num, + self.num_kv_heads, + self.block_size, + 2 * self.head_dim, + ), + dtype=self.dtype, + ) + block_table_tensor = encoder_block_table scheduler_metadata = ops.cpu_attn_get_scheduler_metadata( num_reqs=num_reqs, @@ -214,10 +229,10 @@ def build( sliding_window_size=self.window_size, isa=self.isa, enable_kv_split=envs.VLLM_CPU_ATTN_SPLIT_KV, + dynamic_causal=dynamic_casual, ) attn_metadata = CPUAttentionMetadata( - isa=self.isa, num_actual_tokens=num_actual_tokens, max_query_len=max_query_len, query_start_loc=query_start_loc, @@ -227,9 +242,8 @@ def build( slot_mapping=slot_mapping, scheduler_metadata=scheduler_metadata, causal=causal, - use_sdpa_prefill=self.use_sdpa_prefill, - num_decode_tokens=num_decode_tokens, - sdpa_start_loc=sdpa_start_loc, + encoder_cache=encoder_cache_tensor, + dynamic_causal=dynamic_casual, ) return attn_metadata @@ -271,11 +285,9 @@ def __init__( alibi_slopes = torch.tensor(alibi_slopes, dtype=torch.float32) self.alibi_slopes = alibi_slopes if sliding_window is None: - self.sliding_window = (-1, -1) - elif attn_type == AttentionType.ENCODER_ONLY: - self.sliding_window = (sliding_window - 1, sliding_window - 1) + self.sliding_window = -1 else: - self.sliding_window = (sliding_window - 1, 0) + self.sliding_window = sliding_window self.kv_cache_dtype = kv_cache_dtype self.num_queries_per_kv = self.num_heads // self.num_kv_heads @@ -289,6 +301,14 @@ def __init__( "heads in the layer" ) + vllm_config = get_current_vllm_config() + self.isa = _get_attn_isa( + vllm_config.model_config.dtype, + vllm_config.cache_config.block_size, + self.head_size, + self.kv_cache_dtype, + ) + def forward( self, layer: AttentionLayer, @@ -325,22 +345,14 @@ def forward( num_actual_tokens = attn_metadata.num_actual_tokens - # Handle encoder attention differently - no KV cache needed + # For encoder attention if self.attn_type in (AttentionType.ENCODER_ONLY, AttentionType.ENCODER): # For encoder attention, - return self._run_sdpa_forward( - query[:num_actual_tokens], - key[:num_actual_tokens], - value[:num_actual_tokens], - output[:num_actual_tokens], - attn_metadata, - self.attn_type, - ) + kv_cache = attn_metadata.encoder_cache - # For decoder and cross-attention, use KV cache, size are - # [num_blocks, num_kv_heads, block_size, 2 * head_size] - # Make a view [num_blocks, num_kv_heads, block_size * 2, head_size] - # Then slice KV at dim 2 + # KV cache size are [num_blocks, num_kv_heads, block_size, + # 2 * head_size]. Make a view [num_blocks, num_kv_heads, + # block_size * 2, head_size]. Then slice KV at dim 2 num_blocks, num_kv_heads, block_size, _ = kv_cache.size() kv_cache = kv_cache.view((num_blocks, num_kv_heads, block_size * 2, -1)) key_cache, value_cache = kv_cache.chunk(2, dim=2) @@ -359,163 +371,61 @@ def forward( key_cache, value_cache, attn_metadata.slot_mapping, - attn_metadata.isa, + self.isa, k_scale=layer._k_scale_float, v_scale=layer._v_scale_float, kv_cache_dtype=self.kv_cache_dtype, ) - if attn_metadata.use_sdpa_prefill: - assert self.sinks is None, "Attention sink is unsupported in SDPA prefill" - num_decode_tokens = attn_metadata.num_decode_tokens - self._run_sdpa_forward( - query[num_decode_tokens:num_actual_tokens], - key[num_decode_tokens:num_actual_tokens], - value[num_decode_tokens:num_actual_tokens], - output[num_decode_tokens:num_actual_tokens], - attn_metadata, - self.attn_type, - ) - num_actual_tokens = num_decode_tokens - - if num_actual_tokens > 0: - ops.cpu_attention_with_kv_cache( - query=query[:num_actual_tokens], - key_cache=key_cache, - value_cache=value_cache, - output=output[:num_actual_tokens], # type: ignore - query_start_loc=attn_metadata.query_start_loc, - seq_lens=attn_metadata.seq_lens, - scale=self.scale, - causal=attn_metadata.causal, - alibi_slopes=self.alibi_slopes, # type: ignore - sliding_window=self.sliding_window, - block_table=attn_metadata.block_table, - softcap=self.logits_soft_cap, - scheduler_metadata=attn_metadata.scheduler_metadata, - s_aux=self.sinks, - k_scale=layer._k_scale_float, - v_scale=layer._v_scale_float, - kv_cache_dtype=self.kv_cache_dtype, - ) + ops.cpu_attention_with_kv_cache( + query=query[:num_actual_tokens], + key_cache=key_cache, + value_cache=value_cache, + output=output[:num_actual_tokens], # type: ignore + query_start_loc=attn_metadata.query_start_loc, + seq_lens=attn_metadata.seq_lens, + scale=self.scale, + causal=attn_metadata.causal, + alibi_slopes=self.alibi_slopes, # type: ignore + sliding_window=self.sliding_window, + block_table=attn_metadata.block_table, + softcap=self.logits_soft_cap, + scheduler_metadata=attn_metadata.scheduler_metadata, + s_aux=self.sinks, + dynamic_causal=attn_metadata.dynamic_causal, + k_scale=layer._k_scale_float, + v_scale=layer._v_scale_float, + kv_cache_dtype=self.kv_cache_dtype, + ) return output - def _run_sdpa_forward( + def do_kv_cache_update( self, - query: torch.Tensor, + layer: torch.nn.Module, key: torch.Tensor, value: torch.Tensor, - output: torch.Tensor, - attn_metadata: CPUAttentionMetadata, - attn_type: str, - ) -> torch.Tensor: - attn_masks = attn_metadata.sdpa_attn_masks - if attn_masks is None: - if self.alibi_slopes is not None: - attn_masks = _make_alibi_bias( - self.alibi_slopes, - query.dtype, - attn_metadata.sdpa_start_loc, - ) - elif self.sliding_window[0] != -1 or self.sliding_window[1] != -1: - assert attn_metadata.seq_lens is not None - attn_masks = _make_sliding_window_bias( - attn_metadata.sdpa_start_loc, - self.sliding_window[0], - self.sliding_window[1], - query.dtype, - ) - else: - attn_masks = [None] * (attn_metadata.sdpa_start_loc.size(0) - 1) # type: ignore - attn_metadata.sdpa_attn_masks = attn_masks - - query = query.movedim(0, query.dim() - 2) - key = key.movedim(0, key.dim() - 2) - value = value.movedim(0, value.dim() - 2) - - causal_attn = attn_type == AttentionType.DECODER - - sdpa_start_loc = attn_metadata.sdpa_start_loc.numpy() # type: ignore - for i in range(len(attn_masks)): - mask = attn_masks[i] - start_q = sdpa_start_loc[i] - end_q = sdpa_start_loc[i + 1] - sub_out = ( - torch.nn.functional.scaled_dot_product_attention( - query[None, :, start_q:end_q, :], - key[None, :, start_q:end_q, :], - value[None, :, start_q:end_q, :], - attn_mask=mask, - dropout_p=0.0, - is_causal=causal_attn and mask is None, - scale=self.scale, - enable_gqa=self.num_heads > self.num_kv_heads, - ) - .squeeze(0) - .movedim(query.dim() - 2, 0) - ) - output[start_q:end_q, :, :] = sub_out - return output - - -def _make_alibi_bias( - alibi_slopes: torch.Tensor, - dtype: torch.dtype, - sdpa_start_loc: torch.Tensor, -) -> list[torch.Tensor]: - attn_biases: list[torch.Tensor] = [] - seq_num = sdpa_start_loc.size(0) - 1 - sdpa_start_loc = sdpa_start_loc.numpy() # type: ignore - for i in range(seq_num): - seq_len = sdpa_start_loc[i + 1] - sdpa_start_loc[i] - bias = torch.arange(seq_len, dtype=dtype) # type: ignore - # NOTE(zhuohan): HF uses - # `bias = bias[None, :].repeat(seq_len, 1)` - # here. We find that both biases give the same results, but - # the bias below more accurately follows the original ALiBi - # paper. - bias = bias[None, :] - bias[:, None] - - num_heads = alibi_slopes.shape[0] - bias = bias[None, :].repeat((num_heads, 1, 1)) - bias.mul_(alibi_slopes[:, None, None]).unsqueeze_(0) - inf_mask = ( - torch.empty((1, seq_len, seq_len), dtype=bias.dtype) # type: ignore - .fill_(-torch.inf) - .triu_(diagonal=1) - ) - attn_biases.append((bias + inf_mask).to(dtype)) - - return attn_biases - + kv_cache: torch.Tensor, + slot_mapping: torch.Tensor, + ) -> None: + if self.attn_type in (AttentionType.ENCODER_ONLY, AttentionType.ENCODER): + return -def _make_sliding_window_bias( - sdpa_start_loc: torch.Tensor, - left_window_size: int, - right_window_size: int, - dtype: torch.dtype, -) -> list[torch.Tensor]: - attn_biases: list[torch.Tensor] = [] - seq_num = sdpa_start_loc.size(0) - 1 - sdpa_start_loc = sdpa_start_loc.numpy() # type: ignore - for i in range(seq_num): - seq_len = sdpa_start_loc[i + 1] - sdpa_start_loc[i] - mask = torch.full( # type: ignore - (1, seq_len, seq_len), # type: ignore - fill_value=1, - dtype=dtype, + num_blocks, num_kv_heads, block_size, _ = kv_cache.size() + kv_cache = kv_cache.view((num_blocks, num_kv_heads, block_size * 2, -1)) + key_cache, value_cache = kv_cache.chunk(2, dim=2) + ops.cpu_attn_reshape_and_cache( + key, + value, + key_cache, + value_cache, + slot_mapping, + self.isa, + k_scale=layer._k_scale_float, + v_scale=layer._v_scale_float, + kv_cache_dtype=self.kv_cache_dtype, ) - if right_window_size != -1: - mask = torch.tril(mask, diagonal=right_window_size) - if left_window_size != -1: - mask = torch.triu(mask, diagonal=-left_window_size) - mask = torch.log(mask) - attn_biases.append(mask) - - return attn_biases - @functools.lru_cache(maxsize=1) def _riscv_supports_rvv() -> bool: @@ -527,14 +437,24 @@ def _riscv_supports_rvv() -> bool: The RVV path is compiled whenever __riscv_v_min_vlen is defined, so we check that at least one supported zvlb is advertised. """ + # The C++ compile-time check is the ground truth: it knows which + # VLEN the binary was actually compiled for. The cpuinfo check + # below is only a fast-path shortcut. + try: + import torch + + if torch.ops._C.cpu_attn_has_isa("rvv"): + return True + except Exception: + pass + + # Fallback: check /proc/cpuinfo for zvl128b/zvl256b. try: with open("/proc/cpuinfo") as f: cpuinfo = f.read() except OSError: return False - return any(f"zvl{n}b" in cpuinfo for n in (128, 256)) and all( - f"zvl{n}b" not in cpuinfo for n in (512, 1024) - ) + return any(f"zvl{n}b" in cpuinfo for n in (128, 256)) def _get_attn_isa( diff --git a/vllm/v1/attention/backends/fa_utils.py b/vllm/v1/attention/backends/fa_utils.py index 0d6a3d298b66..6c0debab9930 100644 --- a/vllm/v1/attention/backends/fa_utils.py +++ b/vllm/v1/attention/backends/fa_utils.py @@ -1,8 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from dataclasses import dataclass from typing import Any +import torch + import vllm.envs as envs from vllm.logger import init_logger from vllm.platforms import current_platform @@ -18,6 +21,7 @@ if current_platform.is_cuda(): from vllm._custom_ops import reshape_and_cache_flash from vllm.vllm_flash_attn import ( # type: ignore[attr-defined] + compile_flash_attn_varlen_func_from_specs, flash_attn_varlen_func, get_scheduler_metadata, ) @@ -28,11 +32,14 @@ reshape_and_cache_flash = ops.reshape_and_cache_flash flash_attn_varlen_func = xpu_ops.flash_attn_varlen_func # type: ignore[assignment] + compile_flash_attn_varlen_func_from_specs = None # type: ignore[assignment] get_scheduler_metadata = xpu_ops.get_scheduler_metadata # type: ignore[assignment] elif current_platform.is_rocm(): try: from flash_attn import flash_attn_varlen_func # type: ignore[no-redef] + compile_flash_attn_varlen_func_from_specs = None # type: ignore[assignment] + # Mark that upstream flash-attn is available on ROCm _ROCM_FLASH_ATTN_AVAILABLE = True except ImportError: @@ -43,6 +50,8 @@ def flash_attn_varlen_func(*args: Any, **kwargs: Any) -> Any: # type: ignore[no "to be installed. Please install flash-attn first." ) + compile_flash_attn_varlen_func_from_specs = None # type: ignore[assignment] + # ROCm doesn't use scheduler metadata (FA3 feature), provide stub def get_scheduler_metadata(*args: Any, **kwargs: Any) -> None: # type: ignore[misc] return None @@ -53,6 +62,73 @@ def get_scheduler_metadata(*args: Any, **kwargs: Any) -> None: # type: ignore[m reshape_and_cache_flash = ops.reshape_and_cache_flash +@dataclass(frozen=True) +class FlashAttentionCuTeDSLCompileSpec: + """High-level FA4 compile-only request used by vLLM warmup. + + This is not the CuTeDSL cache key. FA4 owns the selector that maps these + serving inputs to the actual compile-static fields: tile sizes, q_stage, + Split-KV, scheduler choice, layout-presence booleans, dtype/head dims, + arch, and related fields. + """ + + q_shape: tuple[int, ...] + k_shape: tuple[int, ...] + v_shape: tuple[int, ...] + q_dtype: torch.dtype + max_seqlen_q: int + max_seqlen_k: int + softmax_scale: float + causal: bool + fa_version: int + v_stride: tuple[int, ...] | None = None + cu_seqlens_q_shape: tuple[int, ...] | None = None + cu_seqlens_k_shape: tuple[int, ...] | None = None + window_size: tuple[int, int] | None = None + return_softmax_lse: bool = False + num_splits: int = 0 + + def compile(self) -> None: + assert compile_flash_attn_varlen_func_from_specs is not None + window_size = list(self.window_size) if self.window_size is not None else None + compile_flash_attn_varlen_func_from_specs( + q_shape=self.q_shape, + k_shape=self.k_shape, + v_shape=self.v_shape, + q_dtype=self.q_dtype, + v_stride=self.v_stride, + cu_seqlens_q_shape=self.cu_seqlens_q_shape, + cu_seqlens_k_shape=self.cu_seqlens_k_shape, + max_seqlen_q=self.max_seqlen_q, + max_seqlen_k=self.max_seqlen_k, + softmax_scale=self.softmax_scale, + causal=self.causal, + window_size=window_size, + return_softmax_lse=self.return_softmax_lse, + fa_version=self.fa_version, + num_splits=self.num_splits, + ) + + def request_key(self) -> tuple[object, ...]: + return ( + self.q_shape, + self.k_shape, + self.v_shape, + self.q_dtype, + self.max_seqlen_q, + self.max_seqlen_k, + self.softmax_scale, + self.causal, + self.fa_version, + self.v_stride, + self.cu_seqlens_q_shape, + self.cu_seqlens_k_shape, + self.window_size, + self.return_softmax_lse, + self.num_splits, + ) + + def get_flash_attn_version( requires_alibi: bool = False, head_size: int | None = None, @@ -131,6 +207,12 @@ def get_flash_attn_version( and head_size != head_size_v ): upgrade_reason = "Diff-KV with sinks" + elif ( + vllm_config is not None + and vllm_config.model_config is not None + and vllm_config.model_config.is_diffusion + ): + upgrade_reason = "Per-sequence causal (dynamic_causal) requires FA4" if upgrade_reason: logger.info_once( "%s: upgrading FlashAttention 3 -> 4", diff --git a/vllm/v1/attention/backends/flash_attn.py b/vllm/v1/attention/backends/flash_attn.py index c56c4ee6e1ff..db5b0dda367e 100755 --- a/vllm/v1/attention/backends/flash_attn.py +++ b/vllm/v1/attention/backends/flash_attn.py @@ -43,7 +43,6 @@ import vllm.envs as envs from vllm.config import ( VllmConfig, - get_current_vllm_config, get_current_vllm_config_or_none, get_layers_from_vllm_config, ) @@ -75,22 +74,6 @@ class FlashAttentionBackend(AttentionBackend): @staticmethod def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: - vllm_config = get_current_vllm_config() - model_config = vllm_config.model_config - cache_config = vllm_config.cache_config - if ( - model_config - and model_config.is_hybrid - and ( - cache_config.mamba_ssm_cache_dtype == "float32" - or cache_config.mamba_cache_dtype == "float32" - ) - ): - # NOTE(tdoublep): while in principle, FA supports - # MultipleOf(16), these are the block sizes that do not - # suffer from the NaN propagation problem described here: - # https://github.com/Dao-AILab/flash-attention/issues/1974 - return [16, 32, 64] return [MultipleOf(16)] forward_includes_kv_cache_update: bool = False @@ -192,6 +175,10 @@ def supports_kv_cache_dtype(cls, kv_cache_dtype: CacheDType | None) -> bool: ) return kv_cache_dtype in ["auto", "float16", "bfloat16"] + @classmethod + def supports_mm_prefix(cls) -> bool: + return is_fa_version_supported(4) + @classmethod def supports_sink(cls) -> bool: if not is_flash_attn_varlen_func_available(): @@ -212,10 +199,20 @@ def supports_combination( use_mla: bool, has_sink: bool, use_sparse: bool, + use_mm_prefix: bool, device_capability: DeviceCapability, ) -> str | None: if has_sink and device_capability < DeviceCapability(9, 0): return "sink not supported on compute capability < 9.0" + if ( + use_mm_prefix + and get_flash_attn_version(head_size=head_size, has_sinks=has_sink) != 4 + ): + return ( + "mm_prefix (PrefixLM bidirectional attention) requires " + "FlashAttention v4, which does not resolve for this " + "head_size" + ) return None @@ -253,7 +250,23 @@ class FlashAttentionMetadata: prefix_scheduler_metadata: torch.Tensor | None = None max_num_splits: int = 0 - causal: bool = True + causal: bool | torch.Tensor = True + + sliding_window: tuple[int, int] | None = None + + # PrefixLM bidirectional ranges for multimodal tokens. + # Shape: (num_seqs, max_ranges, 2) int32, [start, end] per range. + mm_prefix_range_tensor: torch.Tensor | None = None + + # Reference Sliding Window Attention (R-SWA) fields. + # rswa_prefix_lens: per-request prompt lengths [num_reqs], int32, CUDA. + # rswa_window: sliding window size (scalar int, for logic checks). + # rswa_window_tensor: [1] int32 CUDA tensor — pre-allocated in build() so + # no CPU→CUDA copy is needed inside forward() during CUDA graph capture. + # Only populated when the model uses R-SWA (Unlimited-OCR). + rswa_prefix_lens: torch.Tensor | None = None + rswa_window: int | None = None + rswa_window_tensor: torch.Tensor | None = None def _get_sliding_window_configs( @@ -273,6 +286,20 @@ def _get_sliding_window_configs( return sliding_window_configs +def _maybe_symmetrize_window( + window: tuple[int, int] | None, + causal: bool | torch.Tensor, +) -> tuple[int, int] | None: + """Make a causal sliding window ``(w, 0)`` symmetric ``(w, w)`` when attention + is non-causal, so bidirectional queries attend in both directions. Leaves + full-attention ``(-1, -1)`` and already-symmetric windows untouched. + """ + non_causal = isinstance(causal, torch.Tensor) or causal is False + if window is not None and window[0] >= 0 and window[1] == 0 and non_causal: + return (window[0], window[0]) + return window + + class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetadata]): # FA3: # Supports full cudagraphs for all cases. @@ -294,7 +321,7 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad # https://github.com/vllm-project/vllm/issues/22945 _cudagraph_support = ( AttentionCGSupport.ALWAYS - if get_flash_attn_version() == 3 or current_platform.is_xpu() + if get_flash_attn_version() == 3 else AttentionCGSupport.UNIFORM_BATCH ) supports_update_block_table: bool = True @@ -385,6 +412,19 @@ def __init__( # populated on first build() call. self.aot_sliding_window: tuple[int, int] | None = None + # R-SWA: persistent CUDA-graph-safe buffers owned by this builder. + self.rswa_window: int | None = self.model_config.rswa_window + self.persistent_rswa_prefix_lens: torch.Tensor | None = None + self.persistent_rswa_window_tensor: torch.Tensor | None = None + if self.rswa_window is not None: + max_num_reqs = vllm_config.scheduler_config.max_num_seqs + self.persistent_rswa_prefix_lens = torch.zeros( + max_num_reqs, dtype=torch.int32, device=self.device + ) + self.persistent_rswa_window_tensor = torch.tensor( + [self.rswa_window], dtype=torch.int32, device=self.device + ) + def build( self, common_prefix_len: int, @@ -463,7 +503,9 @@ def schedule( cu_seqlens_q=cu_query_lens, page_size=self.block_size, causal=causal, - window_size=self.aot_sliding_window, + window_size=_maybe_symmetrize_window( + self.aot_sliding_window, causal + ), num_splits=max_num_splits, ) return None @@ -552,6 +594,16 @@ def schedule( self.scheduler_metadata[n:] = 0 scheduler_metadata = self.scheduler_metadata[:n] + if isinstance(causal, torch.Tensor) and causal.dtype != torch.int32: + causal = causal.to(torch.int32) + + # Symmetrize the spec's sliding_window for non-causal attention + group_sliding_window = getattr(self.kv_cache_spec, "sliding_window", None) + base_window = ( + (-1, -1) if group_sliding_window is None else (group_sliding_window - 1, 0) + ) + effective_sliding_window = _maybe_symmetrize_window(base_window, causal) + attn_metadata = FlashAttentionMetadata( num_actual_tokens=num_actual_tokens, max_query_len=max_query_len, @@ -571,7 +623,37 @@ def schedule( prefix_scheduler_metadata=prefix_scheduler_metadata, max_num_splits=max_num_splits, causal=causal, + sliding_window=effective_sliding_window, ) + + # Compute mm_prefix range tensor if the batch contains + # multimodal tokens with bidirectional ranges. + mm_ranges = common_attn_metadata.mm_req_doc_ranges + if mm_ranges is not None: + from vllm.v1.attention.backends.utils import ( + compute_mm_prefix_range_tensor, + ) + + attn_metadata.mm_prefix_range_tensor = compute_mm_prefix_range_tensor( + mm_ranges, num_reqs, seq_lens.device + ) + + # R-SWA: copy prefix lengths into persistent buffers (outside the + # compiled region) so forward() never allocates during CUDA graph + # capture. rswa_window is a static model config scalar read here. + if ( + self.rswa_window is not None + and common_attn_metadata.rswa_prefix_lens is not None + ): + assert self.persistent_rswa_prefix_lens is not None + assert self.persistent_rswa_window_tensor is not None + src = common_attn_metadata.rswa_prefix_lens + rswa_prefix_lens = self.persistent_rswa_prefix_lens[:num_reqs] + rswa_prefix_lens.copy_(src[:num_reqs], non_blocking=True) + attn_metadata.rswa_prefix_lens = rswa_prefix_lens + attn_metadata.rswa_window = self.rswa_window + attn_metadata.rswa_window_tensor = self.persistent_rswa_window_tensor + return attn_metadata def update_block_table( @@ -788,11 +870,85 @@ def forward( ) return output else: - sliding_window_size = ( - list(self.sliding_window) - if self.sliding_window is not None - else None + window = ( + attn_metadata.sliding_window + if attn_metadata.sliding_window is not None + else self.sliding_window + ) + sliding_window_size: list[int] | None = ( + list(window) if window is not None else None ) + + causal = attn_metadata.causal + is_dynamic_causal = isinstance(causal, torch.Tensor) + + mm_prefix_ranges = attn_metadata.mm_prefix_range_tensor + mm_mask_mod = None + mm_aux = None + if ( + mm_prefix_ranges is not None + and not is_dynamic_causal + and causal is True + and self.vllm_flash_attn_version == 4 + ): + max_ranges = mm_prefix_ranges.shape[1] + # Sliding window value in Triton convention + # (1 + window_size[0]). Global-attention layers + # store (-1, -1) → sw stays None / 0. + sw_val = ( + 1 + sliding_window_size[0] + if sliding_window_size is not None + and sliding_window_size[0] >= 0 + else None + ) + # Gemma4: also clamp the bidirectional block to the + # sliding window when the layer opts in + # (mm_prefix_clamp_sliding_window flag from PR #47217). + mm_clamp_sw = 0 + if ( + getattr(layer, "mm_prefix_clamp_sliding_window", False) + and sw_val is not None + ): + mm_clamp_sw = sw_val + mm_mask_mod = _make_mm_prefix_mask_mod( + max_ranges, + sliding_window=mm_clamp_sw, + sliding_window_left=sw_val, + ) + mm_aux = [mm_prefix_ranges] + + # R-SWA: use CuTE-DSL mask_mod on FA4 for exact token-level + # mask without block-size approximation. The mask_mod encodes + # "causal AND (kv < prefix_len OR q - kv < rswa_window)", which + # supersedes any FA-layer sliding_window_size parameter. + rswa_mask_mod_fn = None + rswa_aux = None + if ( + attn_metadata.rswa_prefix_lens is not None + and self.vllm_flash_attn_version == 4 + and not is_dynamic_causal + ): + rswa_mask_mod_fn = _make_rswa_mask_mod() + rswa_aux = [ + attn_metadata.rswa_prefix_lens.to(torch.int32), + attn_metadata.rswa_window_tensor, # pre-allocated CUDA tensor + ] + # mask_mod fully expresses R-SWA; disable FA's own window. + sliding_window_size = None + + dynamic_causal = None + if isinstance(causal, torch.Tensor): + if self.vllm_flash_attn_version != 4: + raise NotImplementedError( + "Per-sequence causal requires FA4. Current version: " + f"FA{self.vllm_flash_attn_version}" + ) + dynamic_causal = causal + has_window = ( + sliding_window_size is not None and sliding_window_size[1] >= 0 + ) + causal = not has_window + flash_attn_varlen_func( q=query[:num_actual_tokens], k=key_cache, @@ -803,7 +959,7 @@ def forward( seqused_k=seqused_k, max_seqlen_k=max_seqlen_k, softmax_scale=self.scale, - causal=attn_metadata.causal, + causal=causal, alibi_slopes=self.alibi_slopes, window_size=sliding_window_size, block_table=block_table, @@ -813,8 +969,11 @@ def forward( q_descale=q_descale, k_descale=k_descale, v_descale=v_descale, + dynamic_causal=dynamic_causal, num_splits=attn_metadata.max_num_splits, s_aux=self.sinks, + mask_mod=rswa_mask_mod_fn or mm_mask_mod, + aux_tensors=rswa_aux or mm_aux, ) return output @@ -1040,17 +1199,158 @@ def _forward_encoder_attention( window_size=sliding_window_size, softcap=self.logits_soft_cap, fa_version=self.vllm_flash_attn_version, - q_descale=layer._q_scale.expand(descale_shape) + q_descale=layer._q_scale.expand(descale_shape) # type: ignore[operator] if self.supports_quant_query_input else None, - k_descale=layer._k_scale.expand(descale_shape), - v_descale=layer._v_scale.expand(descale_shape), + k_descale=layer._k_scale.expand(descale_shape), # type: ignore[operator] + v_descale=layer._v_scale.expand(descale_shape), # type: ignore[operator] num_splits=1 if self.batch_invariant_enabled else 0, + s_aux=self.sinks, ) return output +def _make_mm_prefix_mask_mod( + max_ranges: int, + sliding_window: int = 0, + sliding_window_left: int | None = None, +): + """Build a CuTE-DSL mask_mod implementing + ``(causal AND sliding_window) OR mm_prefix``. + + The FA4 kernel passes *local* ``q_idx`` (0-based within the current + prefill chunk) while ``kv_idx`` is absolute (0-based over the full + KV cache). We recover the absolute Q position via + ``q_abs = q_idx + seqlen_k - seqlen_q`` (the context-length offset) + so that causal, sliding-window, and mm_prefix range comparisons all + use consistent absolute positions. This matches the Triton + reference path (``compute_kv_seq_mask``). + + ``sliding_window_left`` enforces the sliding window on the causal + term (None = full causal, no window). ``sliding_window`` clamps the + bidirectional block to the window (0 = unclamped; >0 = Gemma4 local + layers via ``mm_prefix_clamp_sliding_window``). + """ + import cutlass + import cutlass.cute as cute + from cutlass import Int32 # type: ignore[attr-defined] + + from vllm.vllm_flash_attn.cute.utils import ( # type: ignore[import-untyped] + scalar_to_ssa, + ) + + if sliding_window_left is not None: + + @cute.jit + def mm_prefix_mask_mod( + batch_idx: cute.TensorSSA, + head_idx: cute.TensorSSA, + q_idx: cute.TensorSSA, + kv_idx: cute.TensorSSA, + seqlen_info, + aux_tensors, + ): + ctx_off = scalar_to_ssa(seqlen_info.seqlen_k - seqlen_info.seqlen_q, Int32) + q_abs = q_idx + ctx_off + sw = scalar_to_ssa(Int32(sliding_window_left), Int32) + keep = (kv_idx <= q_abs) & ((q_abs - kv_idx) < sw) + ranges = aux_tensors[0] + b = batch_idx[0] + for i in cutlass.range_constexpr(max_ranges): # type: ignore[attr-defined] + r_start = scalar_to_ssa(ranges[b, i, 0], Int32) + r_end = scalar_to_ssa(ranges[b, i, 1], Int32) + valid = r_start < r_end + q_in = (q_abs >= r_start) & (q_abs <= r_end) & valid + k_in = (kv_idx >= r_start) & (kv_idx <= r_end) & valid + mm = q_in & k_in + if sliding_window > 0: + mm = mm & ((q_abs - kv_idx) < sw) + keep = keep | mm + return keep + + else: + + @cute.jit + def mm_prefix_mask_mod( + batch_idx: cute.TensorSSA, + head_idx: cute.TensorSSA, + q_idx: cute.TensorSSA, + kv_idx: cute.TensorSSA, + seqlen_info, + aux_tensors, + ): + ctx_off = scalar_to_ssa(seqlen_info.seqlen_k - seqlen_info.seqlen_q, Int32) + q_abs = q_idx + ctx_off + keep = kv_idx <= q_abs + ranges = aux_tensors[0] + b = batch_idx[0] + for i in cutlass.range_constexpr(max_ranges): # type: ignore[attr-defined] + r_start = scalar_to_ssa(ranges[b, i, 0], Int32) + r_end = scalar_to_ssa(ranges[b, i, 1], Int32) + valid = r_start < r_end + q_in = (q_abs >= r_start) & (q_abs <= r_end) & valid + k_in = (kv_idx >= r_start) & (kv_idx <= r_end) & valid + keep = keep | (q_in & k_in) + return keep + + mm_prefix_mask_mod.use_fast_sampling = True + return mm_prefix_mask_mod + + +def _make_rswa_mask_mod(): + """Build a CuTE-DSL mask_mod for Reference Sliding Window Attention (R-SWA). + + FA4 varlen + paged-KV convention (verified from cute/mask.py apply_mask): + q_idx = LOCAL query-token offset (0 .. seqlen_q - 1) within this sequence. + kv_idx = LOCAL KV-token position (0 .. seqlen_k - 1) within this sequence. + + To recover the ABSOLUTE token position (needed for causal and the sliding + window distance), use the standard offset: + abs_q = q_idx + (seqlen_k - seqlen_q) + + R-SWA keep condition: + abs_q >= kv_idx (causal: KV at or before the query) + AND (kv_idx < prefix_len (global prefix is always visible) + OR abs_q - kv_idx < window) (generated tokens: sliding window) + + aux_tensors[0]: prefix_lens [num_reqs] int32 — per-request prefill length. + aux_tensors[1]: rswa_window [1] int32 — decode sliding window size. + + use_fast_sampling=True lets FA4 skip fully-masked KV blocks (gap blocks) + without loading their data. + """ + import cutlass.cute as cute + from cutlass import Int32 # type: ignore[attr-defined] + + from vllm.vllm_flash_attn.cute.utils import ( # type: ignore[import-untyped] + scalar_to_ssa, + ) + + @cute.jit + def rswa_mask_mod( + batch_idx: cute.TensorSSA, + head_idx: cute.TensorSSA, + q_idx: cute.TensorSSA, + kv_idx: cute.TensorSSA, + seqlen_info, + aux_tensors, + ): + b = batch_idx[0] + prefix_len = scalar_to_ssa(aux_tensors[0][b], Int32) + window = scalar_to_ssa(aux_tensors[1][0], Int32) + # Convert local q offset to absolute token position. + offset = scalar_to_ssa(seqlen_info.seqlen_k - seqlen_info.seqlen_q, Int32) + abs_q = q_idx + offset + causal = kv_idx <= abs_q + in_prefix = kv_idx < prefix_len + in_window = (abs_q - kv_idx) < window + return causal & (in_prefix | in_window) + + rswa_mask_mod.use_fast_sampling = True + return rswa_mask_mod + + def use_cascade_attention( common_prefix_len: int, query_lens: np.ndarray, diff --git a/vllm/v1/attention/backends/flash_attn_diffkv.py b/vllm/v1/attention/backends/flash_attn_diffkv.py index e788b0e3496f..ff8fbfc022b7 100644 --- a/vllm/v1/attention/backends/flash_attn_diffkv.py +++ b/vllm/v1/attention/backends/flash_attn_diffkv.py @@ -41,6 +41,30 @@ class FlashAttentionDiffKVBackend(FlashAttentionBackend): def set_head_size_v(cls, head_size_v: int) -> None: cls.head_size_v = head_size_v + @classmethod + def is_supported_on_current_device( + cls, + head_size: int, + head_size_v: int, + has_sinks: bool, + ) -> bool: + """Check whether FA3/4 with this DiffKV config is usable here. + + DiffKV (hdim_qk != hdim_v) requires FA3 or FA4 + """ + if not is_flash_attn_varlen_func_available(): + return False + try: + version = get_flash_attn_version( + requires_alibi=False, + head_size=head_size, + head_size_v=head_size_v, + has_sinks=has_sinks, + ) + except Exception: + return False + return version in (3, 4) + @staticmethod def get_name() -> str: return "FLASH_ATTN_DIFFKV" @@ -49,8 +73,6 @@ def get_name() -> str: def get_impl_cls() -> type["FlashAttentionImpl"]: return FlashAttentionDiffKVImpl - # Do not modify the interface of get_kv_cache_shape, - # but consider head_size_v when returning result. @staticmethod def get_kv_cache_shape( num_blocks: int, diff --git a/vllm/v1/attention/backends/flashinfer.py b/vllm/v1/attention/backends/flashinfer.py index 73e1cce56d56..12eab21e3e13 100755 --- a/vllm/v1/attention/backends/flashinfer.py +++ b/vllm/v1/attention/backends/flashinfer.py @@ -3,6 +3,7 @@ """Attention layer with FlashInfer.""" from dataclasses import dataclass +from enum import Enum from functools import partial from typing import ClassVar @@ -19,6 +20,7 @@ from flashinfer.utils import FP4Tensor from typing_extensions import override +from vllm import _custom_ops as custom_ops from vllm import envs from vllm.config import ( CUDAGraphMode, @@ -38,11 +40,13 @@ from vllm.triton_utils import tl, triton from vllm.utils.flashinfer import ( can_use_trtllm_attention, + force_use_trtllm_attention, + supports_trtllm_attention, use_trtllm_attention, ) from vllm.utils.math_utils import cdiv -from vllm.utils.platform_utils import is_pin_memory_available from vllm.utils.torch_utils import ( + PIN_MEMORY, canonicalize_singleton_dim_strides, is_quantized_kv_cache, is_strictly_contiguous, @@ -62,6 +66,7 @@ KVCacheLayoutType, get_dcp_local_seq_lens, get_kv_cache_layout, + get_num_attention_heads_from_layers, get_per_layer_parameters, infer_global_hyperparameters, split_decodes_and_prefills, @@ -83,16 +88,16 @@ logger = init_logger(__name__) -trtllm_gen_workspace_buffer = None +trtllm_workspace_buffer = None -def _get_trtllm_gen_workspace_buffer(): - global trtllm_gen_workspace_buffer - if trtllm_gen_workspace_buffer is None: - trtllm_gen_workspace_buffer = torch.zeros( +def _get_trtllm_workspace_buffer(): + global trtllm_workspace_buffer + if trtllm_workspace_buffer is None: + trtllm_workspace_buffer = torch.zeros( envs.VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE, dtype=torch.uint8, device="cuda" ) - return trtllm_gen_workspace_buffer + return trtllm_workspace_buffer @triton.jit @@ -336,14 +341,34 @@ class FlashInferBackend(AttentionBackend): @staticmethod def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: - # Note: Not sure for all platforms, but on Blackwell, - # only support a page size of 16, 32, 64. - return [16, 32, 64] + # Page sizes >= 128 only run on the trtllm-gen dynamic kernel (GQA/MQA + # on Blackwell); advertise them only when usable so selection never + # picks a large kernel block we cannot serve. + use_large_pages = False + vllm_config = get_current_vllm_config_or_none() + if vllm_config is not None and vllm_config.model_config is not None: + pc = vllm_config.parallel_config + mc = vllm_config.model_config + num_qo_heads = mc.get_num_attention_heads(pc) + num_kv_heads = mc.get_num_kv_heads(pc) + use_large_pages = ( + num_kv_heads > 0 + and num_qo_heads // num_kv_heads > 1 + and current_platform.is_device_capability_family(100) + and can_use_trtllm_attention(num_qo_heads, num_kv_heads) + ) + if not use_large_pages: + return [16, 32, 64] + return [16, 32, 64, 128, 256, 512, 1024] @staticmethod def get_name() -> str: return "FLASHINFER" + @classmethod + def supports_non_causal(cls) -> bool: + return True + @staticmethod def get_impl_cls() -> type["FlashInferImpl"]: return FlashInferImpl @@ -398,6 +423,16 @@ def get_dtype_for_flashinfer(kv_cache_dtype: str) -> torch.dtype: else: raise ValueError(f"Unrecognized dtype: {kv_cache_dtype}") + @classmethod + def supports_kv_cache_dtype(cls, kv_cache_dtype: CacheDType | None) -> bool: + if kv_cache_dtype == "nvfp4": + return ( + current_platform.is_device_capability_family(100) + and supports_trtllm_attention(is_prefill=True) + and supports_trtllm_attention(is_prefill=False) + ) + return super().supports_kv_cache_dtype(kv_cache_dtype) + @classmethod def get_supported_head_sizes(cls) -> list[int]: # https://github.com/flashinfer-ai/flashinfer/blob/3d55c71a62052c590c130897d3a3db49b14fcc34/include/flashinfer/utils.cuh#L157 @@ -405,16 +440,20 @@ def get_supported_head_sizes(cls) -> list[int]: @classmethod def supports_compute_capability(cls, capability: DeviceCapability) -> bool: - return capability >= DeviceCapability(7, 5) and capability <= DeviceCapability( + # FlashInfer supports SM75+, but is currently broken on SM75 (Turing): + # https://github.com/flashinfer-ai/flashinfer/issues/3620 (fix: + # https://github.com/flashinfer-ai/flashinfer/pull/3621). Temporarily + # raise the floor to SM80 so it is not auto-selected on SM75 until + # that fix lands; revert to DeviceCapability(7, 5) once it does. + return capability >= DeviceCapability(8, 0) and capability <= DeviceCapability( 12, 1 ) @classmethod def supports_sink(cls) -> bool: - """FlashInfer supports sinks when TRTLLM attention is available (SM100).""" + """FlashInfer supports sinks only on the SM100 trtllm-gen path.""" from vllm.utils.flashinfer import ( force_use_trtllm_attention, - supports_trtllm_attention, ) # Respect explicit disable flag (e.g., @@ -422,8 +461,13 @@ def supports_sink(cls) -> bool: if force_use_trtllm_attention() is False: return False + if not current_platform.is_device_capability_family(100): + return False + # Check if TRTLLM is supported on this platform - return supports_trtllm_attention() + return supports_trtllm_attention( + is_prefill=False + ) and supports_trtllm_attention(is_prefill=True) @classmethod def get_required_kv_cache_layout(cls) -> KVCacheLayoutType | None: @@ -449,6 +493,13 @@ class FIDecode: wrapper: BatchDecodeWithPagedKVCacheWrapper +class FlashInferDecodeKernel(Enum): + """Decode kernels selected inside the FlashInfer backend.""" + + XQA = "xqa" + TRTLLM_GEN = "trtllm-gen" + + @dataclass class TRTLLMPrefill: """Metadata for the TRTLLM prefill pathway.""" @@ -478,8 +529,15 @@ class TRTLLMPrefill: @dataclass -class TRTLLMDecode: - """Metadata for the TRTLLM decode pathway.""" +class FlashInferTrtllmAPIDecode: + """Metadata for decode paths using FlashInfer's TRTLLM decode API. + + FlashInfer exposes both XQA (SM90) and trtllm-gen (SM100) through + ``trtllm_batch_decode_with_kv_cache``. Keep them as distinct vLLM + decode kernels because their dtype/layout/output constraints differ. + """ + + kernel: FlashInferDecodeKernel block_tables: torch.Tensor """ @@ -505,12 +563,16 @@ class FlashInferMetadata: slot_mapping: torch.Tensor """Tensor for writing K/V to the cache. Shape: [num_actual_tokens]""" - q_data_type: torch.dtype + # The data types of the query for prefill and decode. + # On SM90, these two data types may be different. + q_data_type_prefill: torch.dtype + q_data_type_decode: torch.dtype num_decodes: int num_decode_tokens: int num_prefills: int num_prefill_tokens: int + causal: bool prefill: FIPrefill | TRTLLMPrefill | None """ @@ -518,7 +580,7 @@ class FlashInferMetadata: Will be `None` if `num_prefill_tokens == 0`. """ - decode: FIDecode | TRTLLMDecode | None + decode: FIDecode | FlashInferTrtllmAPIDecode | None """ Holds the metadata for the decode portion of the batch. Will be `None` if `num_decode_tokens == 0`. @@ -553,6 +615,9 @@ def __init__( self._prefill_wrapper: ( BatchPrefillWithPagedKVCacheWrapper | BatchDCPPrefillWrapper | None ) = None # Wrapper for prefill/append + self._noncausal_prefill_wrapper: BatchPrefillWithPagedKVCacheWrapper | None = ( + None # Wrapper for non-causal prefill (DFlash) + ) self._decode_wrapper = None # Wrapper for decode (general shape) if envs.VLLM_BATCH_INVARIANT: @@ -607,9 +672,10 @@ def __init__( self.use_dcp and vllm_config.parallel_config.dcp_comm_backend == "a2a" ) - self.num_qo_heads = self.model_config.get_num_attention_heads( - self.vllm_config.parallel_config - ) + # Compatible with models with non-uniform per-layer head counts. + self.num_qo_heads = get_num_attention_heads_from_layers( + vllm_config, layer_names + ) or self.model_config.get_num_attention_heads(self.vllm_config.parallel_config) self.num_kv_heads = self.kv_cache_spec.num_kv_heads self.head_dim = self.kv_cache_spec.head_size @@ -621,12 +687,14 @@ def __init__( # storage dtype may not be the same as the op dtype (uint8 vs fp8_e4m3) self.is_kvcache_nvfp4 = self.cache_dtype == "nvfp4" if self.is_kvcache_nvfp4: - # trtllm-gen FP4 FMHA kernels only exist for sm100f (sm_100/sm_103). - # Fail fast at init rather than crashing on the first request. - if not current_platform.is_device_capability_family(100): + if ( + force_use_trtllm_attention() is False + or not supports_trtllm_attention(is_prefill=True) + or not supports_trtllm_attention(is_prefill=False) + ): raise ValueError( - "--kv-cache-dtype nvfp4 requires sm100f, " - "please try a different dtype or remove" + "--kv-cache-dtype nvfp4 requires the SM100 trtllm-gen " + "FlashInfer path." ) # For NVFP4, kv_cache_dtype stays as the string "nvfp4" # which is passed to FlashInferImpl @@ -641,55 +709,88 @@ def __init__( assert self.kv_cache_spec.dtype == self.model_config.dtype self.kv_cache_dtype = self.kv_cache_spec.dtype - # Use model dtype as q dtype when TRTLLM attn is not supported, or - # --attention-config.disable_flashinfer_q_quantization is set to 1. Otherwise, - # try to use fp8 q if kv cache is fp8, and will fall back to model dtype - # if TRTLLM attention kernel is not used when building attn metadata - can_use_trtllm = can_use_trtllm_attention(self.num_qo_heads, self.num_kv_heads) - - if ( - can_use_trtllm - and not vllm_config.attention_config.disable_flashinfer_q_quantization - ): - if self.is_kvcache_nvfp4: - # NVFP4 KV cache uses FP8 quantized queries - self.q_data_type = FlashInferBackend.get_dtype_for_flashinfer( - "fp8_e4m3" - ) - else: - self.q_data_type = self.kv_cache_dtype - else: - self.q_data_type = self.model_config.dtype - - # Prefer TRTLLM attention for decoding in all cases. - # This allows us to use AttentionCGSupport.UNIFORM_BATCH mode. - self.use_trtllm_decode_attention = can_use_trtllm - self._init_reorder_batch_threshold(1, supports_spec_as_decode=can_use_trtllm) + # Compute per-phase Q dtype. On SM90 (XQA decode), the prefill and + # decode phases require different Q dtypes when the KV cache is FP8 + # (FP8-Q for the FI native prefill, BF16/FP16-Q for XQA decode), + # so both values must be tracked independently. + self.q_data_type_prefill = self.get_q_data_type(is_prefill=True) + self.q_data_type_decode = self.get_q_data_type(is_prefill=False) + + # Prefer TRTLLM/XQA for decoding whenever supported. The decode kernel + # must be selected statically for FULL cudagraph capture. + can_use_xqa_or_trtllm_gen_decode = can_use_trtllm_attention( + self.num_qo_heads, self.num_kv_heads, is_prefill=False + ) + # Page sizes >= 128 require the trtllm-gen GQA/MQA path (guaranteed by + # get_supported_kernel_block_sizes). + assert self.page_size <= 64 or ( + current_platform.is_device_capability_family(100) + and can_use_xqa_or_trtllm_gen_decode + and self.num_qo_heads // self.num_kv_heads > 1 + ), f"Unexpected FlashInfer page size {self.page_size} without trtllm-gen GQA" + self.use_trtllm_decode_attention = can_use_xqa_or_trtllm_gen_decode + self.flashinfer_trtllm_api_decode_kernel: FlashInferDecodeKernel | None = ( + self._get_flashinfer_trtllm_api_decode_kernel() + if can_use_xqa_or_trtllm_gen_decode + else None + ) + supports_spec_as_decode = ( + self.flashinfer_trtllm_api_decode_kernel + == FlashInferDecodeKernel.TRTLLM_GEN + ) + self._init_reorder_batch_threshold( + 1, supports_spec_as_decode=supports_spec_as_decode + ) self._cascade_wrapper = None # Wrapper for cascade attention # Global hyperparameters shared by all attention layers # TODO: discard this for trtllm-gen backend - self.global_hyperparameters = infer_global_hyperparameters( - get_per_layer_parameters(vllm_config, layer_names, FlashInferImpl) + per_layer_parameters = get_per_layer_parameters( + vllm_config, layer_names, FlashInferImpl ) + if current_platform.is_device_capability(90) and any( + params.window_left != -1 for params in per_layer_parameters.values() + ): + # FlashInfer SM90 sliding-window prefill is not reliable with FP8-Q: + # https://github.com/flashinfer-ai/flashinfer/issues/3578 + raise NotImplementedError( + "FlashInfer backend on SM90 currently crashes with " + "sliding-window attention layers. Use the default attention " + "backend." + ) + self.global_hyperparameters = infer_global_hyperparameters(per_layer_parameters) self.sm_scale = self.global_hyperparameters.sm_scale self.window_left = self.global_hyperparameters.window_left self.logits_soft_cap = self.global_hyperparameters.logits_soft_cap self.has_sinks = self.global_hyperparameters.has_sinks - if self.has_sinks and not can_use_trtllm: + if self.has_sinks and not FlashInferBackend.supports_sink(): raise NotImplementedError( "FlashInfer backend currently does not support attention " "sinks, please use trtllm on blackwell or flash attention on " "earlier GPUs." ) + capability = current_platform.get_device_capability() + arch = f"sm{capability.major}{capability.minor}" if capability else "unknown" + decode_backend = ( + self.flashinfer_trtllm_api_decode_kernel.value + if self.flashinfer_trtllm_api_decode_kernel is not None + else "flashinfer-native" + ) + logger.info_once( + "FlashInfer resolved query dtypes: prefill=%s, decode=%s, " + "decode_backend=%s, kv_cache_dtype=%s, arch=%s", + self.q_data_type_prefill, + self.q_data_type_decode, + decode_backend, + self.kv_cache_dtype, + arch, + ) # Preparing persistent buffers # Since we do not have explicit synchronization in ModelRunnerV2, we do not pin # reused CPU buffers to avoid a race condition between step N async copies to # GPU and step N+1 buffer updates. - self.pin_memory = ( - not vllm_config.use_v2_model_runner and is_pin_memory_available() - ) + self.pin_memory = not vllm_config.use_v2_model_runner and PIN_MEMORY self.paged_kv_indptr = self._make_buffer(max_num_reqs + 1) self.paged_kv_indptr_cpu_buffer = torch.zeros_like( self.paged_kv_indptr.cpu, pin_memory=self.pin_memory @@ -697,6 +798,43 @@ def __init__( self.paged_kv_indices = self._make_buffer(max_num_pages) self.paged_kv_last_page_len = self._make_buffer(max_num_reqs) + # Keep SM90 prefill/decode Q dtype selection in one place. + def get_q_data_type(self, is_prefill: bool) -> torch.dtype: + # The user sets --attention-config.disable_flashinfer_q_quantization + # to 1 explicitly, use model dtype for query. + if self.vllm_config.attention_config.disable_flashinfer_q_quantization: + return self.model_config.dtype + + # self.cache_dtype is resolved per KV-cache group: it is "auto" when + # this group is unquantized (e.g. --kv-cache-dtype-skip-layers), even + # if cache_config requests a quantized dtype globally. + cache_dtype = self.cache_dtype + + # On SM90, XQA decode requires BF16/FP16-Q even with FP8 KV cache. + # FI native prefill on SM90 still uses FP8-Q in that case. + if ( + current_platform.is_device_capability(90) + and not is_prefill + and force_use_trtllm_attention() is not False + and cache_dtype.startswith("fp8") + ): + return self.model_config.dtype + + # Otherwise, match Q dtype to the KV cache dtype. + if cache_dtype.startswith("fp8"): + # FP8-Q requires an fp8 tensor-core attention path + # (FI native fa3 on SM90, trtllm-gen/XQA on SM100). + # Architectures with only fa2 (e.g. SM89, SM120) cannot + # consume FP8 queries, so keep the model dtype for Q there. + if current_platform.is_device_capability( + 90 + ) or current_platform.is_device_capability_family(100): + return FlashInferBackend.get_dtype_for_flashinfer(cache_dtype) + return self.model_config.dtype + if cache_dtype == "nvfp4": + return FlashInferBackend.get_dtype_for_flashinfer("fp8_e4m3") + return self.kv_cache_spec.dtype + def _make_buffer( self, *size: int | torch.SymInt, dtype: torch.dtype = torch.int32 ) -> CpuGpuBuffer: @@ -717,12 +855,13 @@ def get_cudagraph_support( ) -> AttentionCGSupport: """Get the cudagraph support level for FlashInfer attention. - This depends on whether we can use TRTLLM attention for decodes, since we can - only do UNIFORM_SINGLE_TOKEN_DECODE if it is unavailable. - To check this, we must call can_use_trtllm_attention with the number of KV - heads from the kv_cache_spec. We check all available KV cache specs and - only return UNIFORM_BATCH if all of them support TRTLLM attention. + The SM90 XQA integration only enables single-token decode today. Keep + specdec CUDA graphs limited to trtllm-gen until vLLM wires the XQA + specdec mask. """ + if current_platform.is_device_capability(90): + return AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE + # For UniformTypeKVCacheSpecs, check all contained specs kv_specs = ( kv_cache_spec.kv_cache_specs.values() @@ -741,6 +880,7 @@ def get_cudagraph_support( if not can_use_trtllm_attention( num_qo_heads=num_qo_heads, num_kv_heads=spec.num_kv_heads, + is_prefill=False, ): has_trtllm_support = False break @@ -763,9 +903,35 @@ def _get_workspace_buffer(self): def set_workspace_buffer(self, workspace_buffer: torch.Tensor): self._workspace_buffer = workspace_buffer + @staticmethod + def _get_flashinfer_trtllm_api_decode_kernel() -> FlashInferDecodeKernel: + if current_platform.is_device_capability(90): + return FlashInferDecodeKernel.XQA + assert current_platform.is_device_capability_family(100) + return FlashInferDecodeKernel.TRTLLM_GEN + def _get_prefill_wrapper( self, + causal: bool = True, ) -> BatchPrefillWithPagedKVCacheWrapper | BatchDCPPrefillWrapper: + if not causal: + if self.use_dcp: + raise NotImplementedError( + "FlashInfer non-causal prefill is not supported with DCP yet." + ) + if self.is_kvcache_nvfp4: + raise NotImplementedError( + "FlashInfer non-causal attention is not supported with " + "NVFP4 KV cache." + ) + if self._noncausal_prefill_wrapper is None: + self._noncausal_prefill_wrapper = BatchPrefillWithPagedKVCacheWrapper( + self._get_workspace_buffer(), + get_kv_cache_layout(), + backend="auto", + ) + return self._noncausal_prefill_wrapper + if self._prefill_wrapper is None: if self.use_dcp: self._prefill_wrapper = BatchDCPPrefillWrapper( @@ -896,13 +1062,22 @@ def build( ) -> FlashInferMetadata: num_reqs = common_attn_metadata.num_reqs num_actual_tokens = common_attn_metadata.num_actual_tokens - num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = ( - split_decodes_and_prefills( - common_attn_metadata, - decode_threshold=self.reorder_batch_threshold, - require_uniform=True, + causal = common_attn_metadata.causal + if causal: + num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = ( + split_decodes_and_prefills( + common_attn_metadata, + decode_threshold=self.reorder_batch_threshold, + require_uniform=True, + ) ) - ) + else: + # FlashInfer decode/TRTLLM paths cannot express non-causal + # query-query attention, so DFlash runs as native prefill. + num_decodes = 0 + num_prefills = num_reqs + num_decode_tokens = 0 + num_prefill_tokens = num_actual_tokens page_size = self.page_size max_seq_len = common_attn_metadata.max_seq_len @@ -914,28 +1089,42 @@ def build( # Step 1: Decide which dispatch modes to use: # - Cascade attention (distinct mode) # - Prefill (FI native or TRTLLM) - # - Decode (FI native or TRTLLM) + # - Decode (FI native, XQA, or trtllm-gen) use_cascade = common_prefix_len > 0 uses_spec_reorder = self.reorder_batch_threshold > 1 - prefill_use_trtllm = use_trtllm_attention( + # Page sizes >= 128 must use trtllm-gen; force it for prefill too. + prefill_force_trtllm = ( + True if page_size >= 128 else self.attention_config.use_trtllm_attention + ) + prefill_use_trtllm = causal and use_trtllm_attention( self.num_qo_heads, self.num_kv_heads, num_prefill_tokens, max_seq_len, self.dcp_world_size, self.cache_dtype, - self.q_data_type, + self.q_data_type_prefill, is_prefill=True, - force_use_trtllm=self.attention_config.use_trtllm_attention, + force_use_trtllm=prefill_force_trtllm, has_sinks=self.has_sinks, has_spec=uses_spec_reorder, ) - decode_use_trtllm = ( - self.use_trtllm_decode_attention and self.dcp_world_size <= 1 + decode_with_flashinfer_trtllm_api = ( + causal and self.use_trtllm_decode_attention and self.dcp_world_size <= 1 ) - all_uses_trtllm = (num_prefills == 0 or prefill_use_trtllm) and ( - num_decodes == 0 or decode_use_trtllm + if not causal and self.use_dcp: + raise NotImplementedError( + "FlashInfer non-causal prefill is not supported with DCP yet." + ) + if not causal and self.use_trtllm_decode_attention: + logger.warning_once( + "Using FlashInfer for draft model non-causal attention; TRTLLM " + "can still be used for target model causal attention." + ) + all_uses_trtllm = causal and ( + (num_prefills == 0 or prefill_use_trtllm) + and (num_decodes == 0 or decode_with_flashinfer_trtllm_api) ) if not all_uses_trtllm: @@ -959,21 +1148,19 @@ def build( "`sm_scale`." ) - # The q quantization is not supported for non-trtllm attention, - # fall back to model dtype. - self.q_data_type = self.model_config.dtype - # Step 2: Initialize the output metadata # Leave prefill/decode/cascade_wrapper empty, to be populated # case by case depending on the batch contents and backend selection. attn_metadata = FlashInferMetadata( num_actual_tokens=num_actual_tokens, slot_mapping=common_attn_metadata.slot_mapping, - q_data_type=self.q_data_type, + q_data_type_prefill=self.q_data_type_prefill, + q_data_type_decode=self.q_data_type_decode, num_decodes=num_decodes, num_decode_tokens=num_decode_tokens, num_prefills=num_prefills, num_prefill_tokens=num_prefill_tokens, + causal=causal, use_cascade=use_cascade, prefill=None, decode=None, @@ -1024,7 +1211,7 @@ def build( # Compute paged_kv_indices if necessary # paged_kv_indices is only needed for FlashInfer native paths; - # TRTLLM paths use block_tables directly on GPU. + # XQA/trtllm-gen paths use block_tables directly on GPU. needs_paged_kv_indices = use_cascade or not all_uses_trtllm if needs_paged_kv_indices: assert num_blocks_np is not None @@ -1066,6 +1253,9 @@ def build( paged_kv_last_page_len_cpu = self.paged_kv_last_page_len.cpu[:num_reqs] attn_metadata.cascade_wrapper = self._get_cascade_wrapper() + # Cascade attention must use the same q dtype for prefill and decode + # because it does not support FP8 kv-cache or FP8 query yet. + assert self.q_data_type_prefill == self.q_data_type_decode attn_metadata.cascade_wrapper.plan( qo_indptr_arr=[shared_qo_indptr_cpu, qo_indptr_cpu], paged_kv_indptr_arr=[shared_kv_page_indptr_cpu, paged_kv_indptr_cpu], @@ -1082,7 +1272,7 @@ def build( sm_scale=self.sm_scale, window_left=self.window_left, logits_soft_cap=self.logits_soft_cap, - q_data_type=self.q_data_type, + q_data_type=self.q_data_type_prefill, kv_data_type=self.kv_cache_dtype, ) return attn_metadata @@ -1131,7 +1321,7 @@ def build( max_seq_len=max_seq_len, ) else: - prefill_wrapper = self._get_prefill_wrapper() + prefill_wrapper = self._get_prefill_wrapper(causal=attn_metadata.causal) # Slicing CPU buffers that are only needed for FI native prefills paged_kv_last_page_len_prefill_cpu = self.paged_kv_last_page_len.cpu[ prefill_start:num_reqs @@ -1156,7 +1346,7 @@ def build( sm_scale=self.sm_scale, window_left=self.window_left, logits_soft_cap=self.logits_soft_cap, - q_data_type=self.q_data_type, + q_data_type=self.q_data_type_prefill, kv_cache_dtype=self.kv_cache_dtype, prefill_fixed_split_size=self.prefill_fixed_split_size, disable_split_kv=self.disable_split_kv, @@ -1181,11 +1371,11 @@ def build( num_kv_heads=self.num_kv_heads, head_dim_qk=self.head_dim, page_size=self.page_size, - causal=True, + causal=attn_metadata.causal, sm_scale=self.sm_scale, window_left=self.window_left, logits_soft_cap=self.logits_soft_cap, - q_data_type=self.q_data_type, + q_data_type=self.q_data_type_prefill, kv_data_type=self.kv_cache_dtype, o_data_type=o_dtype, fixed_split_size=self.prefill_fixed_split_size, @@ -1195,12 +1385,14 @@ def build( ## DECODE PATHWAY if num_decodes > 0: - if decode_use_trtllm: + if decode_with_flashinfer_trtllm_api: assert num_decode_tokens % num_decodes == 0, ( - "TRTLLM decode requires uniform query lengths per request. " + "XQA/trtllm-gen decode requires uniform query lengths per request. " f"Got {num_decode_tokens=} and {num_decodes=}." ) - attn_metadata.decode = TRTLLMDecode( + assert self.flashinfer_trtllm_api_decode_kernel is not None + attn_metadata.decode = FlashInferTrtllmAPIDecode( + kernel=self.flashinfer_trtllm_api_decode_kernel, block_tables=block_table_tensor[:num_decodes], seq_lens=seq_lens[:num_decodes], max_seq_len=max_seq_len, @@ -1243,7 +1435,7 @@ def build( sm_scale=self.sm_scale, window_left=self.window_left, logits_soft_cap=self.logits_soft_cap, - q_data_type=self.q_data_type, + q_data_type=self.q_data_type_decode, kv_data_type=self.kv_cache_dtype, o_data_type=o_dtype, fixed_split_size=self.decode_fixed_split_size, @@ -1319,10 +1511,17 @@ def __init__( ) self.sinks = sinks - self.support_trtllm_attn = can_use_trtllm_attention(num_heads, num_kv_heads) + self.supports_xqa_or_trtllm_gen_decode = can_use_trtllm_attention( + num_heads, num_kv_heads, is_prefill=False + ) vllm_config = get_current_vllm_config_or_none() + # Query pre-quantization needs a single dtype for the whole query tensor. + # SM90 XQA needs BF16/FP16-Q for decode and FP8 for prefill, + # so only enable this for SM100 trtllm-gen where both use FP8-Q. self.supports_quant_query_input = ( - self.support_trtllm_attn + self.supports_xqa_or_trtllm_gen_decode + and is_quantized_kv_cache(self.kv_cache_dtype) + and current_platform.is_device_capability_family(100) and vllm_config is not None and not vllm_config.attention_config.disable_flashinfer_q_quantization ) @@ -1352,9 +1551,12 @@ def __init__( self.dcp_combine = partial(cp_lse_ag_out_rs, is_lse_base_on_e=False) def fused_output_quant_supported(self, quant_key: QuantKey): + # XQA does not support FP8/NVFP4 output, so require trtllm-gen + # (SM100+) here. Without that we cannot fuse the output quant. return ( - self.support_trtllm_attn + self.supports_xqa_or_trtllm_gen_decode and is_quantized_kv_cache(self.kv_cache_dtype) + and current_platform.is_device_capability_family(100) and quant_key in (kFp8StaticTensorSym, kNvfp4Dynamic) ) @@ -1363,6 +1565,37 @@ def process_weights_after_loading(self, act_dtype: torch.dtype): if self.sinks is not None and self.sinks.dtype != torch.float32: self.sinks = self.sinks.to(torch.float32) + def get_xqa_bmm1_scale(self, layer: torch.nn.Module, q_data_type: torch.dtype): + bmm1_scale = self.scale + if is_quantized_kv_cache(self.kv_cache_dtype): + if q_data_type in (torch.float8_e4m3fn, torch.float8_e5m2): + bmm1_scale *= layer._q_scale_float + bmm1_scale *= layer._k_scale_float + return bmm1_scale + + # SM90 may need FP8-Q for native prefill and BF16/FP16-Q for XQA decode, + # so quantize only the slice whose target dtype differs. + def maybe_quant_query( + self, + query: torch.Tensor, + q_data_type: torch.dtype, + scale: torch.Tensor, + ) -> torch.Tensor: + if query.dtype != q_data_type: + assert query.dtype in [torch.float16, torch.bfloat16] + assert q_data_type in [torch.float8_e4m3fn, torch.float8_e5m2] + assert query.dim() == 3 + num_tokens = query.shape[0] + num_heads = query.shape[1] + head_size = query.shape[2] + assert query.stride(2) == 1 and query.stride(1) == head_size + query_quantized, _ = custom_ops.scaled_fp8_quant( + query.view(num_tokens, num_heads * head_size), scale=scale + ) + return query_quantized.view(num_tokens, num_heads, head_size) + + return query + def forward( self, layer: torch.nn.Module, @@ -1392,12 +1625,6 @@ def forward( # Profiling run. return output.fill_(0) - # Ensure query dtype matches the expected dtype from attention metadata - assert attn_metadata.q_data_type == query.dtype, ( - f"Query dtype mismatch: expected {attn_metadata.q_data_type}, " - f"got {query.dtype}" - ) - if self.bmm1_scale is None: self.bmm1_scale = self.scale if is_quantized_kv_cache(self.kv_cache_dtype): @@ -1409,7 +1636,14 @@ def forward( self.bmm2_scale *= layer._v_scale_float prefill_use_trtllm = isinstance(attn_metadata.prefill, TRTLLMPrefill) - decode_use_trtllm = isinstance(attn_metadata.decode, TRTLLMDecode) + decode_kernel = ( + attn_metadata.decode.kernel + if isinstance(attn_metadata.decode, FlashInferTrtllmAPIDecode) + else None + ) + decode_with_xqa = decode_kernel == FlashInferDecodeKernel.XQA + decode_with_trtllm_gen = decode_kernel == FlashInferDecodeKernel.TRTLLM_GEN + decode_with_flashinfer_trtllm_api = decode_with_xqa or decode_with_trtllm_gen # The attn+quant fusion happens when output_scale is provided. if output_scale is None: @@ -1417,12 +1651,15 @@ def forward( "output_block_scale is not supported when fusion has not happened" ) else: - assert attn_metadata.q_data_type == FP8_DTYPE, ( - "Query must be FP8 when attn+quant fusion happened." + assert attn_metadata.q_data_type_prefill == FP8_DTYPE, ( + "Query must be FP8 when attn+quant fusion happened for prefill." + ) + assert attn_metadata.q_data_type_decode == FP8_DTYPE, ( + "Query must be FP8 when attn+quant fusion happened for decode." ) assert (attn_metadata.num_prefills == 0 or prefill_use_trtllm) and ( - attn_metadata.num_decodes == 0 or decode_use_trtllm - ), "Must use TRT-LLM attn" + attn_metadata.num_decodes == 0 or decode_with_trtllm_gen + ), "Output quant fusion requires TRTLLM prefill/trtllm-gen decode" if output.dtype == FP8_DTYPE: assert output_block_scale is None, ( @@ -1520,6 +1757,13 @@ def forward( prefill_query = query[num_decode_tokens:] assert prefill_query.shape[0] == num_prefill_tokens + # Convert query to the expected dtype for prefill if needed. + prefill_query = self.maybe_quant_query( + prefill_query, + attn_metadata.q_data_type_prefill, + layer._q_scale, + ) + if not prefill_use_trtllm: assert isinstance(attn_metadata.prefill, FIPrefill) prefill_wrapper = attn_metadata.prefill.wrapper @@ -1556,7 +1800,7 @@ def forward( self.logits_soft_cap or 0.0 ) assert prefill_wrapper._sm_scale == self.scale - assert prefill_wrapper._causal + assert prefill_wrapper._causal == attn_metadata.causal if self.is_kvcache_nvfp4: kv_cache_permute = nvfp4_kv_data @@ -1578,6 +1822,7 @@ def forward( prefill_wrapper.run( prefill_query, kv_cache_permute, + q_scale=layer._q_scale_float, k_scale=layer._k_scale_float, v_scale=layer._v_scale_float, out=out_prefill, @@ -1596,7 +1841,7 @@ def forward( # degenerate strides on size=1 dims for TMA alignment. prefill_query = prefill_query.contiguous() prefill_query = canonicalize_singleton_dim_strides(prefill_query) - workspace_buffer = _get_trtllm_gen_workspace_buffer() + workspace_buffer = _get_trtllm_workspace_buffer() block_tables_prefill = attn_metadata.prefill.block_tables seq_lens_prefill = attn_metadata.prefill.seq_lens @@ -1628,7 +1873,7 @@ def forward( prefill_kv_block_scales = None if self.is_kvcache_nvfp4: # NVFP4 trtllm-gen kernel requires FP8 query. - assert attn_metadata.q_data_type == FP8_DTYPE, ( + assert attn_metadata.q_data_type_prefill == FP8_DTYPE, ( "NVFP4 KV cache requires FP8 quantized queries for " "trtllm-gen prefill. Set " "disable_flashinfer_q_quantization=False." @@ -1637,7 +1882,7 @@ def forward( mock_block_table = block_tables_prefill prefill_kv_block_scales = nvfp4_kv_block_scales elif ( - attn_metadata.q_data_type != FP8_DTYPE + attn_metadata.q_data_type_prefill != FP8_DTYPE and self.kv_cache_dtype.startswith("fp8") ): # TRTLLM prefill attention does not support BF16 Q @@ -1661,7 +1906,7 @@ def forward( block_tables_prefill, layer._k_scale, layer._v_scale, - attn_metadata.q_data_type, + attn_metadata.q_data_type_prefill, ) else: mock_kv_cache = kv_cache_permute @@ -1696,7 +1941,14 @@ def forward( decode_query = query[:num_decode_tokens] assert decode_query.shape[0] == num_decode_tokens - if not decode_use_trtllm: + # Convert query to the expected dtype for decode if needed. + decode_query = self.maybe_quant_query( + decode_query, + attn_metadata.q_data_type_decode, + layer._q_scale, + ) + + if not decode_with_flashinfer_trtllm_api: assert isinstance(attn_metadata.decode, FIDecode) decode_wrapper = attn_metadata.decode.wrapper assert decode_wrapper is not None @@ -1729,6 +1981,7 @@ def forward( decode_wrapper.run( decode_query, kv_cache_permute, + q_scale=layer._q_scale_float, k_scale=layer._k_scale_float, v_scale=layer._v_scale_float, out=output_tmp, @@ -1745,6 +1998,7 @@ def forward( decode_wrapper.run( decode_query, kv_cache_permute, + q_scale=layer._q_scale_float, k_scale=layer._k_scale_float, v_scale=layer._v_scale_float, out=out_decode, @@ -1754,19 +2008,23 @@ def forward( if needs_fp8_out: output[:num_decode_tokens].copy_(out_decode.to(output.dtype)) else: - assert isinstance(attn_metadata.decode, TRTLLMDecode) + assert isinstance(attn_metadata.decode, FlashInferTrtllmAPIDecode) # decode_query may be non-contiguous or have degenerate strides # on size=1 dims. contiguous() ensures memory layout; then # canonicalize_singleton_dim_strides fixes any remaining # degenerate strides on size=1 dims for TMA alignment. decode_query = decode_query.contiguous() decode_query = canonicalize_singleton_dim_strides(decode_query) - workspace_buffer = _get_trtllm_gen_workspace_buffer() + workspace_buffer = _get_trtllm_workspace_buffer() block_tables_decode = attn_metadata.decode.block_tables seq_lens_decode = attn_metadata.decode.seq_lens - # This path needs to be enabled with VLLM_KV_CACHE_LAYOUT = HND - assert get_kv_cache_layout() == "HND" + # trtllm-gen needs HND layout on SM100. XQA is selected + # separately on SM90 and does not use this SM100 layout gate. + if decode_with_trtllm_gen: + assert get_kv_cache_layout() == "HND" + else: + assert decode_with_xqa assert is_strictly_contiguous(decode_query) assert is_strictly_contiguous(workspace_buffer) assert is_strictly_contiguous(block_tables_decode) @@ -1805,6 +2063,19 @@ def forward( else: q_len_per_req = num_decode_tokens // attn_metadata.num_decodes + if decode_with_xqa and q_len_per_req > 1: + raise NotImplementedError( + "FlashInfer XQA speculative decode is not wired in vLLM yet." + ) + + # XQA decode can use model-dtype Q with FP8 KV, so only include + # q_scale when the decode query is actually FP8. + bmm1_scale = ( + self.get_xqa_bmm1_scale(layer, attn_metadata.q_data_type_decode) + if decode_with_xqa + else self.bmm1_scale + ) + trtllm_batch_decode_with_kv_cache( query=decode_query, kv_cache=( @@ -1814,12 +2085,14 @@ def forward( block_tables=block_tables_decode, seq_lens=seq_lens_decode, max_seq_len=attn_metadata.decode.max_seq_len, - bmm1_scale=self.bmm1_scale, + bmm1_scale=bmm1_scale, bmm2_scale=self.bmm2_scale, window_left=self.window_left, sinks=self.sinks, o_sf_scale=self.o_sf_scale, out=out, + kv_layout=get_kv_cache_layout(), + backend=attn_metadata.decode.kernel.value, q_len_per_req=q_len_per_req, kv_cache_sf=( nvfp4_kv_block_scales if self.is_kvcache_nvfp4 else None diff --git a/vllm/v1/attention/backends/flex_attention.py b/vllm/v1/attention/backends/flex_attention.py index b87014252018..c45294bfc796 100644 --- a/vllm/v1/attention/backends/flex_attention.py +++ b/vllm/v1/attention/backends/flex_attention.py @@ -22,12 +22,17 @@ ) import vllm.envs as envs -from vllm.config import VllmConfig +from vllm.config import VllmConfig, get_layers_from_vllm_config from vllm.config.cache import CacheDType from vllm.logger import init_logger +from vllm.model_executor.layers.attention import Attention from vllm.platforms import current_platform from vllm.utils.math_utils import cdiv -from vllm.utils.torch_utils import is_quantized_kv_cache, is_torch_equal_or_newer +from vllm.utils.torch_utils import ( + async_tensor_h2d, + is_quantized_kv_cache, + is_torch_equal_or_newer, +) from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -57,7 +62,7 @@ def _offsets_to_doc_ids_tensor( doc_ids = torch.repeat_interleave( torch.arange(len(counts), dtype=torch.int32), counts ) - return doc_ids.to(device, non_blocking=True) + return async_tensor_h2d(doc_ids, device=device) def pad_to_multiple(x: torch.Tensor, multiple: int, dim: int): @@ -403,6 +408,11 @@ class FlexAttentionMetadata: sliding_window: int | None = None mm_prefix_range: dict[int, list[tuple[int, int]]] | None = None block_sparsity_hint: BlockSparsityHint | None = None + # Reference Sliding Window Attention (R-SWA): per-request prefix length + # (prompt/image tokens stay globally visible) plus a sliding window over + # generated tokens. Both must be set to enable. + rswa_prefix_lens: torch.Tensor | None = None + rswa_window: int | None = None @cached_property def logical_block_ids(self): @@ -566,6 +576,52 @@ def final_mask_mod( return final_mask_mod + def get_rswa_mask_mod(self) -> _mask_mod_signature: + """Creates the Reference Sliding Window Attention (R-SWA) mask_mod. + + R-SWA keeps the whole prefix (image + prompt tokens, i.e. logical index + ``< prefix_len``) globally visible while generated tokens additionally + attend a fixed sliding window of recent tokens. This term is combined + with the base causal mask via logical AND, so it only ever *removes* + far-away generated tokens that fall outside the window and outside the + prefix. + """ + + assert self.doc_ids is not None + assert self.rswa_prefix_lens is not None + assert self.rswa_window is not None + doc_ids = self.doc_ids + prefix_lens = self.rswa_prefix_lens + window = self.rswa_window + + def rswa_mask_mod( + q_req: torch.Tensor, + logical_q_idx: torch.Tensor, + logical_kv_idx: torch.Tensor, + ) -> torch.Tensor: + prefix_len = prefix_lens[q_req] + in_prefix = logical_kv_idx < prefix_len + in_window = (logical_q_idx - logical_kv_idx) < window + return in_prefix | in_window + + def final_mask_mod( + b: torch.Tensor, + h: torch.Tensor, + q_idx: torch.Tensor, + physical_kv_idx: torch.Tensor, + ) -> torch.Tensor: + (is_valid, logical_q_idx, logical_kv_idx) = ( + self._convert_physical_to_logical(doc_ids, q_idx, physical_kv_idx) + ) + q_req = doc_ids[q_idx] + return torch.where( + is_valid, + rswa_mask_mod(q_req, logical_q_idx, logical_kv_idx), + False, + ) + + return final_mask_mod + def get_mask_mod(self): # Stage-1: initialize the base mask_mod # (causal mask for decoder or bidirectional mask for encoder) @@ -583,6 +639,10 @@ def get_mask_mod(self): # Add prefix LM mask for vision-language prefix LM attention prefix_lm_mask_mod = self.get_prefix_lm_mask_mod() mask_mod = or_masks(mask_mod, prefix_lm_mask_mod) + if self.rswa_window is not None and self.rswa_prefix_lens is not None: + # Reference Sliding Window Attention: AND with the base causal mask + # (prefix stays global, generated tokens use a sliding window). + mask_mod = and_masks(mask_mod, self.get_rswa_mask_mod()) return mask_mod def get_transformed_score_mod(self) -> _score_mod_signature | None: @@ -658,9 +718,21 @@ def _build_block_mask_direct(self) -> BlockMask: self.doc_ids, : cdiv(self.max_seq_len, self.block_size) ] + # block_table slots beyond each request's seq_len may contain garbage + # physical page ids (see physical_to_logical_mapping). With batched + # decode, max_seq_len is the batch max while shorter requests still + # index all columns up to that max unless masked here. + num_blocks = self.num_blocks_per_seq[self.doc_ids] + past_seq = self.logical_block_ids[None, :] >= num_blocks[:, None] + used_pages.masked_fill_(past_seq, 0) + custom_hint = self.block_sparsity_hint is not None + use_rswa = self.rswa_window is not None and self.rswa_prefix_lens is not None + needs_per_q_pruning = ( + self.causal or self.sliding_window or custom_hint or use_rswa + ) - if self.sliding_window or custom_hint: + if needs_per_q_pruning: device = used_pages.device assert self.doc_ids is not None token_indices = torch.arange( @@ -671,6 +743,12 @@ def _build_block_mask_direct(self) -> BlockMask: - self.query_start_loc[self.doc_ids] + self.decode_offset[self.doc_ids] ) + block_starts = self.logical_block_ids * self.block_size + block_ends = block_starts + self.block_size + + if self.causal: + future_blocks = block_starts[None, :] > logical_q_idx[:, None] + used_pages.masked_fill_(future_blocks, 0) if self.sliding_window: assert self.sliding_window is not None @@ -680,6 +758,23 @@ def _build_block_mask_direct(self) -> BlockMask: min_block_idx = min_kv_idx // self.block_size sliding_mask = self.logical_block_ids >= min_block_idx[:, None] used_pages.masked_fill_(~sliding_mask, 0) + if use_rswa: + # R-SWA keeps prefix KV globally visible and applies a sliding + # window over generated tokens. Prune blocks that fall entirely + # in the "hole" between prefix_len and the current window so + # FlexAttention does not gather invalid paged-KV slots (this + # mirrors uniform sliding-window block pruning above). + assert self.rswa_prefix_lens is not None + assert self.rswa_window is not None + prefix_len = self.rswa_prefix_lens[self.doc_ids] + min_kv_window = torch.maximum( + prefix_len, + logical_q_idx - (self.rswa_window - 1), + ) + in_gap = (block_starts[None, :] >= prefix_len[:, None]) & ( + block_ends[None, :] <= min_kv_window[:, None] + ) + used_pages.masked_fill_(in_gap, 0) if custom_hint: assert self.block_sparsity_hint is not None q_block_idx = logical_q_idx // self.block_size @@ -793,12 +888,36 @@ def __init__( self.max_num_query_groups = cdiv(max_num_batched_tokens, self.q_block_size) max_num_pages_per_seq = cdiv(self.max_model_len, self.block_size) self.max_num_kv_indices = self.q_block_size * max_num_pages_per_seq + # R-SWA uses q_block_size=1 so block lists are not merged across requests + # in a q-group (mixed-length batches otherwise gather foreign paged-KV). + self.max_num_rswa_query_groups = max_num_batched_tokens + # +1 sentinel column: the flex-attention kernel's get_offset_for_next_block + # always prefetches kv_indices[q, kv_num_blocks] (one past the last valid + # entry) to compute the jump offset for the next loop iteration. When + # kv_num_blocks[q] == W (every page of the sequence is live), that prefetch + # reads column W of the persistent buffer. Without the extra column this + # would land on stale data from a previous step (the buffer is wider than W + # but is never fully zeroed), producing an out-of-bounds K/V pointer and a + # CUDA illegal memory access. Allocating W_max+1 columns and initialising + # the whole buffer to -1 ensures the sentinel slot is always safe to read. + self.max_num_rswa_kv_indices = max_num_pages_per_seq + 1 self.persistent_kv_num_blocks = torch.empty( self.max_num_query_groups, dtype=torch.int32, device=device ) + self.persistent_rswa_kv_num_blocks = torch.empty( + self.max_num_rswa_query_groups, dtype=torch.int32, device=device + ) self.persistent_offset_tensor = torch.empty( max_num_seqs, dtype=torch.int32, device=device ) + # Persistent buffer for R-SWA per-request prefix lengths so the device + # address stays stable across steps (required for CUDA graph replay). + self.rswa_window: int | None = self.model_config.rswa_window + self.persistent_rswa_prefix_lens: torch.Tensor | None = None + if self.rswa_window is not None: + self.persistent_rswa_prefix_lens = torch.empty( + max_num_seqs, dtype=torch.int32, device=device + ) self.persistent_doc_ids = torch.empty( max_num_batched_tokens, dtype=torch.int32, device=device ) @@ -806,6 +925,14 @@ def __init__( # initialize later when we can access block_table self.persistent_physical_to_logical = None self.persistent_kv_indices = None + self.persistent_rswa_kv_indices = None + + self.custom_logical_mask_mod: _mask_mod_signature | None = None + if self._uses_full_cudagraphs(): + layers = get_layers_from_vllm_config( + vllm_config, Attention, self.layer_names + ) + self.custom_logical_mask_mod = self._maybe_get_custom_mask_mod(layers) @staticmethod def _get_block_sizes( @@ -853,6 +980,21 @@ def build_for_cudagraph_capture( common_prefix_len=0, common_attn_metadata=common_attn_metadata ) + def _maybe_get_custom_mask_mod(self, layers) -> _mask_mod_signature | None: + mask_mods = { + getattr(layer, "logical_mask_mod", None) for layer in layers.values() + } + if len(mask_mods) > 1: + raise ValueError( + f"Found differing mask mods {mask_mods}, " + "cannot use alternating mask mods w/ full CUDA graphs" + ) + return next(iter(mask_mods), None) + + def _uses_full_cudagraphs(self) -> bool: + mode = self.vllm_config.compilation_config.cudagraph_mode + return mode is not None and mode.has_full_cudagraphs() + def build( self, common_prefix_len: int, @@ -909,6 +1051,26 @@ def build( dtype=torch.int32, device=self.device, ) + if self.persistent_rswa_kv_indices is None: + # Initialise to -1 so the +1 sentinel column (see max_num_rswa_kv_indices) + # is always a safe pad value for the flex kernel's prefetch. + self.persistent_rswa_kv_indices = torch.full( + (self.max_num_rswa_query_groups, self.max_num_rswa_kv_indices), + fill_value=-1, + dtype=torch.int32, + device=self.device, + ) + + use_rswa = self.rswa_window is not None + q_block_size = 1 if use_rswa else self.q_block_size + persistent_kv_indices = ( + self.persistent_rswa_kv_indices if use_rswa else self.persistent_kv_indices + ) + persistent_kv_num_blocks = ( + self.persistent_rswa_kv_num_blocks + if use_rswa + else self.persistent_kv_num_blocks + ) inverse_block_table = copy_to_persistent( self.persistent_physical_to_logical, inverse_block_table @@ -917,6 +1079,13 @@ def build( offset_tensor = common_attn_metadata.compute_num_computed_tokens() offset_tensor = copy_to_persistent(self.persistent_offset_tensor, offset_tensor) + rswa_prefix_lens = common_attn_metadata.rswa_prefix_lens + if use_rswa and rswa_prefix_lens is not None: + assert self.persistent_rswa_prefix_lens is not None + rswa_prefix_lens = copy_to_persistent( + self.persistent_rswa_prefix_lens, rswa_prefix_lens + ) + uses_paged_kv = not isinstance(self.kv_cache_spec, EncoderOnlyAttentionSpec) logical_mask_mod = ( bidirectional_mask_mod @@ -924,9 +1093,16 @@ def build( else causal_mask_mod ) + sliding_window = None + if self._uses_full_cudagraphs(): + if self.custom_logical_mask_mod is not None: + logical_mask_mod = self.custom_logical_mask_mod + sliding_window = getattr(self.kv_cache_spec, "sliding_window", None) + out = FlexAttentionMetadata( causal=common_attn_metadata.causal, logical_mask_mod=logical_mask_mod, + sliding_window=sliding_window, num_actual_tokens=num_actual_tokens, max_query_len=max_query_len, query_start_loc=query_start_loc, @@ -952,11 +1128,14 @@ def build( # attention block mask for encoder-only models, disable it temporarily. # see: https://github.com/vllm-project/vllm/pull/27329#issuecomment-3431484053 direct_build=self.direct_build and uses_paged_kv, - q_block_size=self.q_block_size, + q_block_size=q_block_size, kv_block_size=self.kv_block_size, - persistent_kv_indices=self.persistent_kv_indices, - persistent_kv_num_blocks=self.persistent_kv_num_blocks, + persistent_kv_indices=persistent_kv_indices, + persistent_kv_num_blocks=persistent_kv_num_blocks, persistent_doc_ids=self.persistent_doc_ids, + mm_prefix_range=common_attn_metadata.mm_req_doc_ranges, + rswa_prefix_lens=rswa_prefix_lens, + rswa_window=self.rswa_window, ) # Pre-build block_mask so it is ready before CUDA graph capture. diff --git a/vllm/v1/attention/backends/gdn_attn.py b/vllm/v1/attention/backends/gdn_attn.py index 2c0ff984b41a..340a304030ea 100644 --- a/vllm/v1/attention/backends/gdn_attn.py +++ b/vllm/v1/attention/backends/gdn_attn.py @@ -8,6 +8,7 @@ import torch from vllm.config import VllmConfig +from vllm.utils.torch_utils import async_tensor_h2d from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -67,6 +68,10 @@ class GDNAttentionMetadata: # Pre-computed FLA chunk metadata (avoids GPU->CPU sync in prepare_chunk_indices) chunk_indices: torch.Tensor | None = None chunk_offsets: torch.Tensor | None = None + # Chunk-kernel inputs for prefill + prefill_query_start_loc: torch.Tensor | None = None + prefill_state_indices: torch.Tensor | None = None + prefill_has_initial_state: torch.Tensor | None = None # The following attributes are for triton implementation of causal_conv1d nums_dict: dict | None = None @@ -199,8 +204,8 @@ def build( # type: ignore[override] spec_sequence_masks = None spec_sequence_masks_cpu = None else: - spec_sequence_masks = spec_sequence_masks_cpu.to( - query_start_loc.device, non_blocking=True + spec_sequence_masks = async_tensor_h2d( + spec_sequence_masks_cpu, device=query_start_loc.device ) if spec_sequence_masks is None: @@ -322,19 +327,42 @@ def build( # type: ignore[override] chunk_indices: torch.Tensor | None = None chunk_offsets: torch.Tensor | None = None + prefill_query_start_loc: torch.Tensor | None = None + prefill_state_indices: torch.Tensor | None = None + prefill_has_initial_state: torch.Tensor | None = None if num_prefills > 0: from vllm.model_executor.layers.fla.ops.utils import FLA_CHUNK_SIZE + # In a mixed non-spec batch, decodes are peeled off to the recurrent + # kernel (decode-first front slice), so build chunk metadata from the + # rebased prefill-only cu_seqlens; otherwise use the full non-spec one. + # _forward_core keys off the same condition, so they agree. + if spec_sequence_masks is None and num_decodes > 0: + assert non_spec_query_start_loc is not None + assert non_spec_query_start_loc_cpu is not None + assert non_spec_state_indices_tensor is not None + prefill_query_start_loc = ( + non_spec_query_start_loc[num_decodes:] - num_decode_tokens + ) + prefill_query_start_loc_cpu = ( + non_spec_query_start_loc_cpu[num_decodes:] - num_decode_tokens + ) + prefill_state_indices = non_spec_state_indices_tensor[num_decodes:] + else: + prefill_query_start_loc = non_spec_query_start_loc + prefill_query_start_loc_cpu = non_spec_query_start_loc_cpu + prefill_state_indices = non_spec_state_indices_tensor + if self.gdn_prefill_backend == "cutedsl": from vllm.model_executor.layers.mamba.ops.gdn_chunk_cutedsl import ( prepare_metadata_cutedsl, ) - assert non_spec_query_start_loc is not None - assert non_spec_query_start_loc_cpu is not None - total_tokens = int(non_spec_query_start_loc_cpu[-1].item()) + assert prefill_query_start_loc is not None + assert prefill_query_start_loc_cpu is not None + total_tokens = int(prefill_query_start_loc_cpu[-1].item()) chunk_indices, chunk_offsets = prepare_metadata_cutedsl( - non_spec_query_start_loc, + prefill_query_start_loc, total_tokens, FLA_CHUNK_SIZE, ) @@ -348,13 +376,15 @@ def build( # type: ignore[override] prepare_chunk_offsets, ) - assert non_spec_query_start_loc_cpu is not None - chunk_indices = prepare_chunk_indices( - non_spec_query_start_loc_cpu, FLA_CHUNK_SIZE - ).to(device=gpu_device, non_blocking=True) - chunk_offsets = prepare_chunk_offsets( - non_spec_query_start_loc_cpu, FLA_CHUNK_SIZE - ).to(device=gpu_device, non_blocking=True) + assert prefill_query_start_loc_cpu is not None + chunk_indices = async_tensor_h2d( + prepare_chunk_indices(prefill_query_start_loc_cpu, FLA_CHUNK_SIZE), + device=gpu_device, + ) + chunk_offsets = async_tensor_h2d( + prepare_chunk_offsets(prefill_query_start_loc_cpu, FLA_CHUNK_SIZE), + device=gpu_device, + ) if num_prefills > 0: has_initial_state = context_lens_tensor > 0 @@ -367,6 +397,10 @@ def build( # type: ignore[override] device=query_start_loc.device, ) ) + if spec_sequence_masks is None and num_decodes > 0: + prefill_has_initial_state = has_initial_state[num_decodes:] + else: + prefill_has_initial_state = has_initial_state else: has_initial_state = None @@ -376,9 +410,10 @@ def build( # type: ignore[override] f"num_decodes: {num_decodes}, num_spec_decodes: {num_spec_decodes}" ) - # Prepare tensors for cudagraph - # Note: m.num_actual_tokens is already padded by the model runner for CUDAGraph - batch_size = m.num_actual_tokens + # Prepare per-request tensors for cudagraph. m.num_actual_tokens is + # token-padded for FULL graph replay, but the GDN state/query/accepted + # metadata below is indexed by request. + batch_size = m.num_reqs if ( self.use_full_cuda_graph @@ -458,6 +493,9 @@ def build( # type: ignore[override] has_initial_state=has_initial_state, chunk_indices=chunk_indices, chunk_offsets=chunk_offsets, + prefill_query_start_loc=prefill_query_start_loc, + prefill_state_indices=prefill_state_indices, + prefill_has_initial_state=prefill_has_initial_state, spec_query_start_loc=spec_query_start_loc, non_spec_query_start_loc=non_spec_query_start_loc, spec_state_indices_tensor=spec_state_indices_tensor, diff --git a/vllm/v1/attention/backends/hpc_attn.py b/vllm/v1/attention/backends/hpc_attn.py new file mode 100644 index 000000000000..8c6dcfe51682 --- /dev/null +++ b/vllm/v1/attention/backends/hpc_attn.py @@ -0,0 +1,554 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""HPC Attention Backend. + +Pure attention (prefill + decode), without RoPE or RMSNorm. +Independent metadata / builder; KV cache layout is NHD: +(num_blocks, 2, block_size, num_kv_heads, head_size). +""" + +import importlib.util +from dataclasses import dataclass +from typing import ClassVar + +import torch +from typing_extensions import override + +from vllm.config import VllmConfig +from vllm.config.cache import CacheDType +from vllm.logger import init_logger +from vllm.platforms import current_platform +from vllm.platforms.interface import DeviceCapability +from vllm.v1.attention.backend import ( + AttentionBackend, + AttentionCGSupport, + AttentionImpl, + AttentionMetadata, + AttentionMetadataBuilder, + AttentionType, + CommonAttentionMetadata, + MultipleOf, +) +from vllm.v1.attention.backends.utils import ( + KVCacheLayoutType, + split_decodes_and_prefills, +) +from vllm.v1.kv_cache_interface import AttentionSpec + +logger = init_logger(__name__) + +FP8_DTYPE = current_platform.fp8_dtype() + + +def _get_fp8_dtype_for_kv_cache(kv_cache_dtype: str) -> torch.dtype: + """Return the torch FP8 dtype for the given kv_cache_dtype string.""" + if kv_cache_dtype in ("fp8", "fp8_e4m3"): + return torch.float8_e4m3fn + elif kv_cache_dtype == "fp8_e5m2": + return torch.float8_e5m2 + else: + raise ValueError(f"Unrecognized FP8 dtype: {kv_cache_dtype}") + + +@dataclass +class HpcAttnMetadata(AttentionMetadata): + """Metadata required by the HPC attention kernel.""" + + num_actual_tokens: int + num_decodes: int + num_decode_tokens: int + num_prefills: int + num_prefill_tokens: int + max_query_len: int + + slot_mapping: torch.Tensor + """Slot mapping for KV cache writes. shape = [num_actual_tokens]""" + + seq_lens: torch.Tensor + """KV cache length per request. shape = [batch_size]""" + + block_table_tensor: torch.Tensor + """Paged KV-cache block table. + shape = [batch_size, max_num_blocks_per_seq]""" + + qo_indptr: torch.Tensor | None = None + """Cumulative query lengths for prefill requests (GPU tensor). + shape = [num_prefills + 1]. None when num_prefills == 0.""" + + # --- HPC RopeNorm pass-through fields --- + # Set by HpcRopeNorm._forward_impl(); consumed & reset by + # HpcAttentionImpl.forward(). Defaults are safe for the standard + # (non-RopeNorm) path and for profiling runs (attn_metadata=None). + hpc_kv_written: bool = False + """True when HpcRopeNorm already wrote KV cache.""" + hpc_prefill_q_scale: torch.Tensor | None = None + """FP8 per-token-per-head Q scale for prefill (from RopeNorm).""" + hpc_decode_q_scale: torch.Tensor | None = None + """FP8 per-token-per-head Q scale for decode (persistent buffer). + shape = [max_decode_tokens, num_q_heads], contiguous. + Only the first num_decode_q_scale_tokens rows are valid.""" + hpc_split_k_flag: torch.Tensor | None = None + """Split-K flag tensor for FP8 decode (persistent buffer). + shape = [max_num_seqs, num_kv_heads], int32.""" + + # --- MTP (Multi-Token Prediction) fields --- + decode_query_len: int = 1 + """Number of query tokens per decode request. + 1 for standard decoding, mtp+1 for speculative decoding (2 or 3).""" + qo_indptr_decode: torch.Tensor | None = None + """Cumulative query offsets for decode requests (GPU tensor). + shape = [num_decodes + 1]. Only set when decode_query_len > 1. + e.g. 3 requests with dql=2: [0, 2, 4, 6].""" + task_map: torch.Tensor | None = None + """Used for HPC dynamic schedule attention""" + + +class HpcAttnMetadataBuilder(AttentionMetadataBuilder[HpcAttnMetadata]): + """Build HpcAttnMetadata from CommonAttentionMetadata.""" + + _cudagraph_support = AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE + reorder_batch_threshold: int = 1 + + def __init__( + self, + kv_cache_spec: AttentionSpec, + layer_names: list[str], + vllm_config: VllmConfig, + device: torch.device, + ): + super().__init__(kv_cache_spec, layer_names, vllm_config, device) + import hpc + + self.num_kv_heads = kv_cache_spec.num_kv_heads + self.hpc_dynamic_sched_attn_min_split_len = 1024 + + # MTP constraint: HPC decode kernel only supports mtp in {0, 1, 2, 3} + spec_config = vllm_config.speculative_config + if ( + spec_config is not None + and spec_config.num_speculative_tokens is not None + and spec_config.num_speculative_tokens > 3 + ): + raise ValueError( + f"HPC attention only supports up to 3 speculative tokens " + f"(mtp ∈ {{0, 1, 2, 3}}), got " + f"num_speculative_tokens={spec_config.num_speculative_tokens}. " + f"Please reduce num_speculative_tokens or use a different " + f"attention backend." + ) + + # Dynamic decode threshold for MTP support. + # _init_reorder_batch_threshold computes: + # no spec_config → threshold=1 (unchanged) + # with spec_config → threshold=1+num_speculative_tokens + self._init_reorder_batch_threshold( + reorder_batch_threshold=1, + supports_spec_as_decode=True, + ) + + self.task_map = hpc.get_attention_decode_task_workspace( + vllm_config.scheduler_config.max_num_seqs, + vllm_config.model_config.max_model_len or 4096, + self.num_kv_heads, + min_process_len=self.hpc_dynamic_sched_attn_min_split_len, + ) + + @override # type: ignore[misc] + @classmethod + def get_cudagraph_support( + cls: type["HpcAttnMetadataBuilder"], + vllm_config: VllmConfig, + kv_cache_spec: AttentionSpec, + ) -> AttentionCGSupport: + spec_config = vllm_config.speculative_config + if ( + spec_config is not None + and spec_config.num_speculative_tokens is not None + and spec_config.num_speculative_tokens > 0 + ): + return AttentionCGSupport.UNIFORM_BATCH + return AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE + + def build( + self, + common_prefix_len: int, + common_attn_metadata: CommonAttentionMetadata, + fast_build: bool = False, + ) -> HpcAttnMetadata: + """Build HpcAttnMetadata from CommonAttentionMetadata.""" + num_actual_tokens = common_attn_metadata.num_actual_tokens + + num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = ( + split_decodes_and_prefills( + common_attn_metadata, + decode_threshold=self.reorder_batch_threshold, + # MTP requires uniform query lengths across decode requests + require_uniform=(self.reorder_batch_threshold > 1), + ) + ) + + seq_lens = common_attn_metadata.seq_lens + block_table_tensor = common_attn_metadata.block_table_tensor + slot_mapping = common_attn_metadata.slot_mapping + max_query_len = common_attn_metadata.max_query_len + + # Compute decode_query_len (tokens per decode request). + # Non-MTP: 1, MTP: mtp+1 (2 or 3). + if num_decodes > 0 and num_decode_tokens > num_decodes: + decode_query_len = num_decode_tokens // num_decodes + else: + decode_query_len = 1 + + seq_lens_decode = None + qo_indptr = None + qo_indptr_decode = None + if num_prefills > 0: + qo_indptr_cpu = common_attn_metadata.query_start_loc_cpu + prefill_start = num_decodes + qo_indptr_prefill_cpu = ( + qo_indptr_cpu[prefill_start:] - qo_indptr_cpu[prefill_start] + ) + qo_indptr = qo_indptr_prefill_cpu.to(self.device, non_blocking=True) + + if num_decodes > 0: + seq_lens_decode = seq_lens[:num_decodes] + # block_table is per-request, indexed by num_decodes (not tokens) + qo_indptr_decode = common_attn_metadata.query_start_loc[: num_decodes + 1] + import hpc + + hpc.assign_attention_decode_task( + seq_lens_decode, + self.task_map, + self.num_kv_heads, + decode_query_len, + new_kv_included=True, + min_process_len=self.hpc_dynamic_sched_attn_min_split_len, + ) + + return HpcAttnMetadata( + num_actual_tokens=num_actual_tokens, + num_decodes=num_decodes, + num_decode_tokens=num_decode_tokens, + num_prefills=num_prefills, + num_prefill_tokens=num_prefill_tokens, + max_query_len=max_query_len, + slot_mapping=slot_mapping, + seq_lens=seq_lens, + block_table_tensor=block_table_tensor, + qo_indptr=qo_indptr, + hpc_kv_written=True, + hpc_prefill_q_scale=None, + hpc_decode_q_scale=None, + hpc_split_k_flag=None, + decode_query_len=decode_query_len, + qo_indptr_decode=qo_indptr_decode, + task_map=self.task_map, + ) + + +class HpcAttentionBackend(AttentionBackend): + """HPC attention backend (pure attention, no RoPE/Norm). + + KV cache layout: NHD (num_blocks, 2, block_size, num_kv_heads, head_size). + """ + + accept_output_buffer: bool = True + supported_dtypes: ClassVar[list[torch.dtype]] = [ + torch.float16, + torch.bfloat16, + ] + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [ + "auto", + "bfloat16", + "fp8_e4m3", + ] + + # Avoid attention abstracted method call cache insert + forward_includes_kv_cache_update: bool = True + + @staticmethod + def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: + return [64] + + @staticmethod + def get_name() -> str: + return "HPC_ATTN" + + @staticmethod + def get_impl_cls() -> type["HpcAttentionImpl"]: + return HpcAttentionImpl + + @staticmethod + def get_builder_cls() -> type["HpcAttnMetadataBuilder"]: + return HpcAttnMetadataBuilder + + @staticmethod + def get_kv_cache_shape( + num_blocks: int, + block_size: int, + num_kv_heads: int, + head_size: int, + cache_dtype_str: str = "auto", + ) -> tuple[int, ...]: + return (num_blocks, 2, block_size, num_kv_heads, head_size) + + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + if include_num_layers_dimension: + return (1, 0, 2, 3, 4, 5) + return (0, 1, 2, 3, 4) + + @classmethod + def get_supported_head_sizes(cls) -> list[int]: + return [128] + + @classmethod + def supports_compute_capability(cls, capability: DeviceCapability) -> bool: + return capability >= DeviceCapability(9, 0) + + @classmethod + def supports_kv_cache_dtype(cls, kv_cache_dtype: "CacheDType | None") -> bool: + if kv_cache_dtype is None: + return True + return kv_cache_dtype in cls.supported_kv_cache_dtypes + + @classmethod + def get_required_kv_cache_layout(cls) -> KVCacheLayoutType | None: + return "NHD" + + +class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]): + """HPC pure attention implementation (no RoPE/Norm). + + Constraints: + - head_dim == 128 + - num_heads // num_kv_heads in {4, 8} + - kv_cache_dtype in {"auto", "fp8_e4m3"} + """ + + def __init__( + self, + num_heads: int, + head_size: int, + scale: float, + num_kv_heads: int, + alibi_slopes: list[float] | None = None, + sliding_window: int | None = None, + kv_cache_dtype: str = "auto", + logits_soft_cap: float | None = None, + attn_type: str = AttentionType.DECODER, + kv_sharing_target_layer_name: str | None = None, + ) -> None: + if importlib.util.find_spec("hpc") is None: + raise ImportError( + "HPC attention requires the hpc module to be installed. " + "Please install it from https://github.com/Tencent/hpc-ops" + ) + if attn_type != AttentionType.DECODER: + raise NotImplementedError("HPC attention only supports decoder attention") + if alibi_slopes is not None: + raise NotImplementedError("HPC attention does not support ALiBi") + if logits_soft_cap is not None: + raise NotImplementedError("HPC attention does not support logits_soft_cap") + + if head_size != 128: + raise ValueError( + f"HPC attention only supports head_dim=128, got {head_size}" + ) + + num_queries_per_kv = num_heads // num_kv_heads + if num_queries_per_kv not in (4, 8): + raise ValueError( + f"HPC attention only supports head_per_group in {{4, 8}}, " + f"got {num_queries_per_kv} " + f"(num_heads={num_heads}, num_kv_heads={num_kv_heads})" + ) + + if kv_cache_dtype not in ("auto", "fp8_e4m3"): + raise ValueError( + f"HPC attention only supports kv_cache_dtype 'auto' or " + f"'fp8_e4m3', got '{kv_cache_dtype}'" + ) + + self.num_heads = num_heads + self.head_size = head_size + self.scale = float(scale) + self.num_kv_heads = num_kv_heads + self.kv_cache_dtype = kv_cache_dtype + self.kv_sharing_target_layer_name = kv_sharing_target_layer_name + + self.num_queries_per_kv = num_queries_per_kv + + if sliding_window is None: + self.sliding_window = (-1, -1) + else: + self.sliding_window = (sliding_window - 1, 0) + + self.use_fp8 = kv_cache_dtype == "fp8_e4m3" + + self.supports_quant_query_input = False + self.splitk = True + + import hpc + + if self.use_fp8: + self._quant_type = hpc.QuantType.QPERTOKEN_PERHEAD_KPERTENSOR_VPERTENSOR + else: + self._quant_type = None + + def forward( + self, + layer: torch.nn.Module, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + kv_cache: torch.Tensor, + attn_metadata: HpcAttnMetadata | None, + output: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, + output_block_scale: torch.Tensor | None = None, + ) -> torch.Tensor: + """HPC attention forward (standard vLLM backend interface). + + Two modes: + 1. Standard: upstream handles RoPE/Norm; this backend writes KV + attn. + 2. HpcRopeNorm: fused op already did RoPE/Norm/KV-Write/Q-Quant; + extra params passed via attn_metadata.hpc_* fields. + """ + import hpc + + assert output is not None, "Output tensor must be provided." + assert output_scale is None, "HPC attention does not support fused output quant" + assert output_block_scale is None + + if attn_metadata is None: + return output.fill_(0) + + hpc_kv_written = attn_metadata.hpc_kv_written + hpc_prefill_q_scale = attn_metadata.hpc_prefill_q_scale + hpc_decode_q_scale = attn_metadata.hpc_decode_q_scale + hpc_split_k_flag = attn_metadata.hpc_split_k_flag + + num_actual_tokens = attn_metadata.num_actual_tokens + num_prefill_reqs = attn_metadata.num_prefills + num_decode_reqs = attn_metadata.num_decodes + num_decode_tokens = attn_metadata.num_decode_tokens + + # Write KV cache if not already done by HpcRopeNorm. + if self.kv_sharing_target_layer_name is None and not hpc_kv_written: + torch.ops._C_cache_ops.reshape_and_cache_flash( + key, + value, + kv_cache[:, 0], + kv_cache[:, 1], + attn_metadata.slot_mapping, + self.kv_cache_dtype, + layer._k_scale, + layer._v_scale, + ) + + if self.use_fp8: + torch_dtype = _get_fp8_dtype_for_kv_cache(self.kv_cache_dtype) + kv_cache = kv_cache.view(torch_dtype) + + if self.use_fp8: + if not hpc_kv_written: + raise RuntimeError( + "HpcAttentionImpl: FP8 mode requires HpcRopeNorm. " + "Ensure hpc_rope_norm is enabled or set " + "kv_cache_dtype='auto' for bf16 mode." + f" (layer={getattr(layer, 'layer_name', '?')})" + ) + k_scale = layer._k_scale.reshape(1) + v_scale = layer._v_scale.reshape(1) + + query = query[:num_actual_tokens] + key = key[:num_actual_tokens] + value = value[:num_actual_tokens] + output_padded = output + output = output[:num_actual_tokens] + + # --- Prefill --- + if num_prefill_reqs > 0: + seq_lens_prefill = attn_metadata.seq_lens[num_decode_reqs:] + cu_seqlens_prefill = attn_metadata.qo_indptr + max_seqlens = attn_metadata.max_query_len + block_table_prefill = attn_metadata.block_table_tensor[num_decode_reqs:] + + q_prefill = query[num_decode_tokens:] + output_prefill = output[num_decode_tokens:] + + if self.use_fp8: + hpc.attention_with_kvcache_prefill_fp8( + q_prefill, + kv_cache[:, 0], + kv_cache[:, 1], + hpc_prefill_q_scale, + k_scale, + v_scale, + cu_seqlens_prefill, + block_table_prefill, + seq_lens_prefill, + max_seqlens, + quant_type=self._quant_type, + output=output_prefill, + ) + else: + hpc.attention_with_kvcache_prefill_bf16( + q_prefill, + kv_cache[:, 0], + kv_cache[:, 1], + cu_seqlens_prefill, + block_table_prefill, + seq_lens_prefill, + max_seqlens, + output=output_prefill, + ) + + # --- Decode --- + if num_decode_reqs > 0: + num_seq_kvcache = attn_metadata.seq_lens[:num_decode_reqs] + block_table_decode = attn_metadata.block_table_tensor[:num_decode_reqs] + + q_decode = query[:num_decode_tokens] + output_decode = output[:num_decode_tokens] + + mtp = attn_metadata.decode_query_len - 1 + + if self.use_fp8: + hpc.attention_decode_fp8( + q_decode, + kv_cache[:, 0], + kv_cache[:, 1], + block_table_decode, + num_seq_kvcache, + hpc_decode_q_scale, + k_scale, + v_scale, + mtp=mtp, + # MTP: split_flag from rope_norm is unavailable + # when using prefill-mode kernel; let HPC decide. + # splitk=(self.splitk if mtp == 0 else True), + new_kv_included=True, + quant_type=self._quant_type, + splitk=self.splitk, + task_map=attn_metadata.task_map, + split_flag=hpc_split_k_flag, + output=output_decode, + ) + else: + hpc.attention_decode_bf16( + q_decode, + kv_cache[:, 0], + kv_cache[:, 1], + block_table_decode, + num_seq_kvcache, + mtp=mtp, + output=output_decode, + new_kv_included=True, + splitk=self.splitk, + ) + + return output_padded diff --git a/vllm/v1/attention/backends/linear_attn.py b/vllm/v1/attention/backends/linear_attn.py index b2ca151986cc..9cdcf0e30e7c 100644 --- a/vllm/v1/attention/backends/linear_attn.py +++ b/vllm/v1/attention/backends/linear_attn.py @@ -4,7 +4,7 @@ import torch -from vllm.config import VllmConfig +from vllm.config import CompilationConfig, VllmConfig from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -12,6 +12,7 @@ CommonAttentionMetadata, ) from vllm.v1.attention.backends.utils import ( + PAD_SLOT_ID, mamba_get_block_table_tensor, split_decodes_and_prefills, ) @@ -91,3 +92,213 @@ def build( state_indices_tensor=state_indices_tensor, ) return attn_metadata + + +class BailingLinearAttentionBackend(LinearAttentionBackend): + @staticmethod + def get_name() -> str: + return "BAILING_LINEAR_ATTN" + + @staticmethod + def get_builder_cls() -> type["BailingLinearAttentionMetadataBuilder"]: + return BailingLinearAttentionMetadataBuilder + + +@dataclass +class BailingLinearAttentionMetadata(LinearAttentionMetadata): + state_indices_tensor_d: torch.Tensor | None = None + state_indices_tensor_p: torch.Tensor | None = None + num_accepted_tokens: torch.Tensor | None = None + query_start_loc_d: torch.Tensor | None = None + + +class BailingLinearAttentionMetadataBuilder(LinearAttentionMetadataBuilder): + supports_spec_decode_metadata = True + supports_update_block_table: bool = False + + @classmethod + def get_cudagraph_support( + cls, + vllm_config: VllmConfig, + kv_cache_spec: AttentionSpec, + ) -> AttentionCGSupport: + return AttentionCGSupport.UNIFORM_BATCH + + def __init__( + self, + kv_cache_spec: AttentionSpec, + layer_names: list[str], + vllm_config: VllmConfig, + device: torch.device, + ): + super().__init__(kv_cache_spec, layer_names, vllm_config, device) + self.compilation_config: CompilationConfig = vllm_config.compilation_config + self.num_spec_tokens: int = vllm_config.num_speculative_tokens + self.use_spec_decode: bool = self.num_spec_tokens > 0 + self.decode_cudagraph_max_bs: int = vllm_config.scheduler_config.max_num_seqs + if self.compilation_config.max_cudagraph_capture_size is not None: + self.decode_cudagraph_max_bs = min( + self.decode_cudagraph_max_bs, + self.compilation_config.max_cudagraph_capture_size, + ) + self.decode_state_indices_tensor: torch.Tensor = torch.empty( + (self.decode_cudagraph_max_bs, 1 + self.num_spec_tokens), + dtype=torch.int32, + device=device, + ) + self.decode_legacy_state_indices_tensor: torch.Tensor = torch.empty( + (self.decode_cudagraph_max_bs,), + dtype=torch.int32, + device=device, + ) + self.decode_query_start_loc: torch.Tensor = torch.empty( + (self.decode_cudagraph_max_bs + 1,), + dtype=torch.int32, + device=device, + ) + self.decode_num_accepted_tokens: torch.Tensor = torch.empty( + (self.decode_cudagraph_max_bs,), + dtype=torch.int32, + device=device, + ) + self._init_reorder_batch_threshold(1, self.use_spec_decode) + + def build_for_cudagraph_capture( + self, + common_attn_metadata: CommonAttentionMetadata, + ) -> BailingLinearAttentionMetadata: + num_accepted_tokens = None + if self.use_spec_decode: + assert common_attn_metadata.max_query_len <= 1 + self.num_spec_tokens, ( + "Bailing linear attention only supports speculative decoding " + "with query length <= 1 + number of speculative tokens." + ) + num_accepted_tokens = torch.diff(common_attn_metadata.query_start_loc) + return self.build( + common_prefix_len=0, + common_attn_metadata=common_attn_metadata, + num_accepted_tokens=num_accepted_tokens, + ) + + def build( # type: ignore[override] + self, + common_prefix_len: int, + common_attn_metadata: CommonAttentionMetadata, + fast_build: bool = False, + *, + num_accepted_tokens: torch.Tensor | None = None, + num_decode_draft_tokens_cpu: torch.Tensor | None = None, + ) -> BailingLinearAttentionMetadata: + query_start_loc = common_attn_metadata.query_start_loc + seq_lens = common_attn_metadata.seq_lens + num_reqs = common_attn_metadata.num_reqs + use_spec_decode = self.use_spec_decode and num_accepted_tokens is not None + + state_indices_tensor = mamba_get_block_table_tensor( + common_attn_metadata.block_table_tensor, + common_attn_metadata.seq_lens, + self.kv_cache_spec, + self.vllm_config.cache_config.mamba_cache_mode, + ) + if state_indices_tensor.dim() == 1: + state_indices_tensor = state_indices_tensor.unsqueeze(-1) + + decode_threshold = self.reorder_batch_threshold if use_spec_decode else 1 + num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = ( + split_decodes_and_prefills( + common_attn_metadata, + decode_threshold=decode_threshold, + ) + ) + state_indices_tensor_d, state_indices_tensor_p = torch.split( + state_indices_tensor, + [num_decodes, num_prefills], + dim=0, + ) + state_indices_tensor_p = state_indices_tensor_p[:, 0] + + query_start_loc_d = None + if use_spec_decode: + assert num_accepted_tokens is not None + state_indices_tensor_d = state_indices_tensor_d[ + :, : 1 + self.num_spec_tokens + ] + query_start_loc_d = query_start_loc[: num_decodes + 1] + num_accepted_tokens = num_accepted_tokens[:num_decodes] + else: + state_indices_tensor_d = state_indices_tensor_d[:, 0] + num_accepted_tokens = None + + legacy_state_indices_tensor = state_indices_tensor[:, 0] + cudagraph_mode = self.compilation_config.cudagraph_mode + use_full_cudagraph = ( + cudagraph_mode is not None and cudagraph_mode.has_full_cudagraphs() + ) + if ( + num_prefills == 0 + and num_decodes <= self.decode_cudagraph_max_bs + and use_full_cudagraph + ): + padded_bs = num_reqs + is_padded_decode = seq_lens[:num_decodes] == 0 + if state_indices_tensor_d.dim() > 1: + state_indices_tensor_d = torch.where( + is_padded_decode.unsqueeze(1), + torch.full_like(state_indices_tensor_d, PAD_SLOT_ID), + state_indices_tensor_d, + ) + self.decode_state_indices_tensor[:num_decodes].copy_( + state_indices_tensor_d, + non_blocking=True, + ) + state_indices_tensor_d = self.decode_state_indices_tensor[:padded_bs] + state_indices_tensor_d[num_decodes:] = PAD_SLOT_ID + + self.decode_legacy_state_indices_tensor[:num_decodes].copy_( + torch.where( + is_padded_decode, + torch.full_like( + legacy_state_indices_tensor[:num_decodes], + PAD_SLOT_ID, + ), + legacy_state_indices_tensor[:num_decodes], + ), + non_blocking=True, + ) + legacy_state_indices_tensor = self.decode_legacy_state_indices_tensor[ + :padded_bs + ] + legacy_state_indices_tensor[num_decodes:] = PAD_SLOT_ID + if state_indices_tensor_d.dim() == 1: + state_indices_tensor_d = legacy_state_indices_tensor + + if use_spec_decode and num_accepted_tokens is not None: + assert query_start_loc_d is not None + self.decode_query_start_loc[: num_decodes + 1].copy_( + query_start_loc_d, + non_blocking=True, + ) + decode_num_query_tokens = query_start_loc_d[-1] + query_start_loc_d = self.decode_query_start_loc[: padded_bs + 1] + query_start_loc_d[num_decodes + 1 :] = decode_num_query_tokens + + self.decode_num_accepted_tokens[:num_decodes].copy_( + num_accepted_tokens, + non_blocking=True, + ) + num_accepted_tokens = self.decode_num_accepted_tokens[:padded_bs] + num_accepted_tokens[num_decodes:] = 1 + + return BailingLinearAttentionMetadata( + num_prefills=num_prefills, + num_prefill_tokens=num_prefill_tokens, + num_decodes=num_decodes, + num_decode_tokens=num_decode_tokens, + query_start_loc=query_start_loc, + seq_lens=seq_lens, + state_indices_tensor=legacy_state_indices_tensor, + state_indices_tensor_d=state_indices_tensor_d, + state_indices_tensor_p=state_indices_tensor_p, + num_accepted_tokens=num_accepted_tokens, + query_start_loc_d=query_start_loc_d, + ) diff --git a/vllm/v1/attention/backends/mamba2_attn.py b/vllm/v1/attention/backends/mamba2_attn.py index 5f25c4a79520..6b4999ab35be 100644 --- a/vllm/v1/attention/backends/mamba2_attn.py +++ b/vllm/v1/attention/backends/mamba2_attn.py @@ -7,6 +7,7 @@ import torch from vllm.config import VllmConfig +from vllm.utils.torch_utils import async_tensor_h2d from vllm.v1.attention.backend import ( AttentionBackend, CommonAttentionMetadata, @@ -68,22 +69,22 @@ def compute_varlen_chunk_metadata( # Exclusive prefix sum over logical-chunk lengths if chunk_lens: - cu_chunk_seqlens = torch.tensor( - [0] + list(itertools.accumulate(chunk_lens)), - device=device, - dtype=torch.int32, - ) - # Final boundary must equal total tokens - assert int(cu_chunk_seqlens[-1].item()) == total + cu_chunk_seqlens_list = [0] + list(itertools.accumulate(chunk_lens)) + # Final boundary must equal total tokens (check on host to avoid a sync) + assert cu_chunk_seqlens_list[-1] == total else: - cu_chunk_seqlens = torch.tensor([0], device=device, dtype=torch.int32) + cu_chunk_seqlens_list = [0] + cu_chunk_seqlens = async_tensor_h2d( + cu_chunk_seqlens_list, dtype=torch.int32, device=device + ) - last_chunk_indices_t = ( - torch.tensor(last_chunk_indices, device=device, dtype=torch.int32) - if len(starts) > 0 - else torch.empty((0,), device=device, dtype=torch.int32) + # last_chunk_indices is empty when there are no sequences (len(starts) == 0). + last_chunk_indices_t = async_tensor_h2d( + last_chunk_indices, dtype=torch.int32, device=device + ) + seq_idx_chunks_t = async_tensor_h2d( + seq_idx_chunks, dtype=torch.int32, device=device ) - seq_idx_chunks_t = torch.tensor(seq_idx_chunks, device=device, dtype=torch.int32) return cu_chunk_seqlens, last_chunk_indices_t, seq_idx_chunks_t diff --git a/vllm/v1/attention/backends/mla/cutlass_mla.py b/vllm/v1/attention/backends/mla/cutlass_mla.py index 8815bd93407b..832acbc7cec4 100644 --- a/vllm/v1/attention/backends/mla/cutlass_mla.py +++ b/vllm/v1/attention/backends/mla/cutlass_mla.py @@ -49,6 +49,14 @@ class CutlassMLABackend(MLACommonBackend): def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: return [128] + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + if include_num_layers_dimension: + return (1, 0, 2, 3) + return (0, 1, 2) + @staticmethod def get_name() -> str: return "CUTLASS_MLA" diff --git a/vllm/v1/attention/backends/mla/flashattn_mla.py b/vllm/v1/attention/backends/mla/flashattn_mla.py index bd947296e8bc..80833dfba65b 100644 --- a/vllm/v1/attention/backends/mla/flashattn_mla.py +++ b/vllm/v1/attention/backends/mla/flashattn_mla.py @@ -52,6 +52,14 @@ class FlashAttnMLABackend(MLACommonBackend): def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: return [MultipleOf(16)] + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + if include_num_layers_dimension: + return (1, 0, 2, 3) + return (0, 1, 2) + @staticmethod def get_name() -> str: return "FLASH_ATTN_MLA" @@ -82,6 +90,7 @@ def supports_combination( use_mla: bool, has_sink: bool, use_sparse: bool, + use_mm_prefix: bool, device_capability: DeviceCapability, ) -> str | None: if not flash_attn_supports_mla(): diff --git a/vllm/v1/attention/backends/mla/flashattn_mla_sparse.py b/vllm/v1/attention/backends/mla/flashattn_mla_sparse.py new file mode 100644 index 000000000000..664bd649fdf8 --- /dev/null +++ b/vllm/v1/attention/backends/mla/flashattn_mla_sparse.py @@ -0,0 +1,287 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from dataclasses import dataclass +from typing import Any, ClassVar + +import numpy as np +import torch + +from vllm.config import VllmConfig +from vllm.config.cache import CacheDType +from vllm.platforms.interface import DeviceCapability +from vllm.utils.torch_utils import np_to_pinned_tensor +from vllm.v1.attention.backend import ( + AttentionBackend, + AttentionCGSupport, + AttentionLayer, + AttentionMetadata, + AttentionMetadataBuilder, + CommonAttentionMetadata, + MultipleOf, + SparseMLAAttentionImpl, +) +from vllm.v1.attention.backends.fa_utils import flash_attn_supports_mla +from vllm.v1.attention.backends.mla.sparse_utils import ( + triton_convert_req_index_to_global_index, +) +from vllm.v1.kv_cache_interface import AttentionSpec +from vllm.vllm_flash_attn.flash_attn_interface import flash_attn_varlen_func + + +class FlashAttnMLASparseBackend(AttentionBackend): + supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16] + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [ + "auto", + "float16", + "bfloat16", + ] + + @staticmethod + def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: + return [64] + + @staticmethod + def get_name() -> str: + return "FLASH_ATTN_MLA_SPARSE" + + @staticmethod + def get_builder_cls() -> type["FlashAttnMLASparseMetadataBuilder"]: + return FlashAttnMLASparseMetadataBuilder + + @staticmethod + def get_impl_cls() -> type[SparseMLAAttentionImpl[Any]]: + return FlashAttnMLASparseImpl + + @classmethod + def get_supported_head_sizes(cls) -> list[int]: + return [] + + @classmethod + def is_mla(cls) -> bool: + return True + + @classmethod + def is_sparse(cls) -> bool: + return True + + @classmethod + def supports_compute_capability(cls, capability: DeviceCapability) -> bool: + return capability.major == 9 + + @classmethod + def supports_combination( + cls, + head_size: int, + dtype: torch.dtype, + kv_cache_dtype: CacheDType | None, + block_size: int | None, + use_mla: bool, + has_sink: bool, + use_sparse: bool, + use_mm_prefix: bool, + device_capability: DeviceCapability, + ) -> str | None: + if kv_cache_dtype not in (None, "auto", "float16", "bfloat16"): + return ( + "FlashAttention MLA Sparse currently supports only FP16/BF16 KV cache" + ) + + if not flash_attn_supports_mla(): + return "FlashAttention MLA not supported on this device" + + from vllm.config import get_current_vllm_config_or_none + + vllm_config = get_current_vllm_config_or_none() + if vllm_config is not None and vllm_config.model_config is not None: + if vllm_config.parallel_config.decode_context_parallel_size > 1: + return "FlashAttention MLA Sparse does not support DCP for now" + + hf_config = vllm_config.model_config.hf_config + if not hasattr(hf_config, "index_topk"): + return "FlashAttention MLA Sparse requires model with index_topk" + return None + + @staticmethod + def get_kv_cache_shape( + num_blocks: int, + block_size: int, + num_kv_heads: int, + head_size: int, + cache_dtype_str: str = "auto", + ) -> tuple[int, ...]: + return (num_blocks, block_size, head_size) + + +@dataclass +class FlashAttnMLASparseMetadata(AttentionMetadata): + num_reqs: int + max_query_len: int + max_seq_len: int + + num_actual_tokens: int + query_start_loc: torch.Tensor + slot_mapping: torch.Tensor + + block_table: torch.Tensor + req_id_per_token: torch.Tensor + block_size: int = 64 + topk_tokens: int = 2048 + + +class FlashAttnMLASparseMetadataBuilder( + AttentionMetadataBuilder[FlashAttnMLASparseMetadata] +): + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH + + def __init__( + self, + kv_cache_spec: AttentionSpec, + layer_names: list[str], + vllm_config: VllmConfig, + device: torch.device, + ) -> None: + self.vllm_config = vllm_config + self.layer_names = layer_names + self.kv_cache_spec = kv_cache_spec + self.model_config = vllm_config.model_config + self.device = device + + self._init_reorder_batch_threshold(1, supports_spec_as_decode=True) + + self.topk_tokens = vllm_config.model_config.hf_config.index_topk + self.req_id_per_token_buffer = torch.empty( + (vllm_config.scheduler_config.max_num_batched_tokens,), + dtype=torch.int32, + device=device, + ) + + def build( + self, + common_prefix_len: int, + common_attn_metadata: CommonAttentionMetadata, + fast_build: bool = False, + ) -> FlashAttnMLASparseMetadata: + cm = common_attn_metadata + num_tokens = cm.num_actual_tokens + starts = np.asarray(cm.query_start_loc_cpu, dtype=np.int32) + seg_lengths = np.diff(starts) + req_id_per_token = np.repeat( + np.arange(seg_lengths.shape[0], dtype=np.int32), seg_lengths + ) + + self.req_id_per_token_buffer.fill_(0) + self.req_id_per_token_buffer[: req_id_per_token.shape[0]].copy_( + np_to_pinned_tensor(req_id_per_token), non_blocking=True + ) + + return FlashAttnMLASparseMetadata( + num_reqs=cm.num_reqs, + max_query_len=cm.max_query_len, + max_seq_len=cm.max_seq_len, + num_actual_tokens=cm.num_actual_tokens, + query_start_loc=cm.query_start_loc, + slot_mapping=cm.slot_mapping, + block_table=cm.block_table_tensor, + req_id_per_token=self.req_id_per_token_buffer[:num_tokens], + block_size=self.kv_cache_spec.block_size, + topk_tokens=self.topk_tokens, + ) + + +class FlashAttnMLASparseImpl(SparseMLAAttentionImpl[FlashAttnMLASparseMetadata]): + def __init__( + self, + num_heads: int, + head_size: int, + scale: float, + num_kv_heads: int, + alibi_slopes: list[float] | None, + sliding_window: int | None, + kv_cache_dtype: str, + logits_soft_cap: float | None, + attn_type: str, + kv_sharing_target_layer_name: str | None, + topk_indices_buffer: torch.Tensor | None = None, + indexer: Any | None = None, + **mla_args: Any, + ) -> None: + unsupported_features = [alibi_slopes, sliding_window, logits_soft_cap] + if any(unsupported_features): + raise NotImplementedError( + "FlashAttnMLASparseImpl does not support alibi, sliding window, " + "or logits soft cap." + ) + if kv_cache_dtype not in ("auto", "float16", "bfloat16"): + raise NotImplementedError( + "FlashAttnMLASparseImpl currently supports only FP16/BF16 KV cache." + ) + + self.num_heads = num_heads + self.head_size = head_size + self.scale = float(scale) + self.num_kv_heads = num_kv_heads + self.kv_cache_dtype = kv_cache_dtype + self.kv_lora_rank: int = mla_args["kv_lora_rank"] + self.qk_rope_head_dim: int = mla_args["qk_rope_head_dim"] + self.topk_indices_buffer: torch.Tensor | None = ( + indexer.topk_indices_buffer if indexer is not None else topk_indices_buffer + ) + assert self.topk_indices_buffer is not None, ( + "Indexer or topk_indices_buffer required for sparse MLA" + ) + self.supports_quant_query_input = False + self.dcp_world_size = -1 + self.q_pad_num_heads = None + + def forward_mqa( + self, + q: torch.Tensor | tuple[torch.Tensor, torch.Tensor], + kv_c_and_k_pe_cache: torch.Tensor, + attn_metadata: FlashAttnMLASparseMetadata, + layer: AttentionLayer, + ) -> tuple[torch.Tensor, torch.Tensor | None]: + if not isinstance(q, tuple): + raise NotImplementedError( + "FlashAttnMLASparseImpl expects split (q_nope, q_rope) input." + ) + q_nope, q_rope = q + num_actual_toks = q_rope.shape[0] + + assert self.topk_indices_buffer is not None + topk_indices = self.topk_indices_buffer[:num_actual_toks] + topk_indices, valid_counts = triton_convert_req_index_to_global_index( + attn_metadata.req_id_per_token[:num_actual_toks], + attn_metadata.block_table, + topk_indices, + BLOCK_SIZE=attn_metadata.block_size, + NUM_TOPK_TOKENS=topk_indices.shape[1], + return_valid_counts=True, + ) + + cu_seqlens_q = torch.arange( + 0, num_actual_toks + 1, dtype=torch.int32, device=q_rope.device + ) + kv_cache = kv_c_and_k_pe_cache.view( + -1, attn_metadata.block_size, self.head_size + ) + k_cache = kv_cache[:, :, self.kv_lora_rank :].view( + -1, 1, 1, self.qk_rope_head_dim + ) + v_cache = kv_cache[:, :, : self.kv_lora_rank].view(-1, 1, 1, self.kv_lora_rank) + + out = flash_attn_varlen_func( + q=q_rope, + k=k_cache, + v=v_cache, + q_v=q_nope, + max_seqlen_q=1, + cu_seqlens_q=cu_seqlens_q, + max_seqlen_k=topk_indices.shape[1], + seqused_k=valid_counts, + block_table=topk_indices, + softmax_scale=self.scale, + causal=True, + fa_version=3, + ) + return out, None diff --git a/vllm/v1/attention/backends/mla/flashinfer_mla.py b/vllm/v1/attention/backends/mla/flashinfer_mla.py index e98bee9d79b5..0a6eb14e0c22 100644 --- a/vllm/v1/attention/backends/mla/flashinfer_mla.py +++ b/vllm/v1/attention/backends/mla/flashinfer_mla.py @@ -28,6 +28,24 @@ logger = init_logger(__name__) FLASHINFER_MLA_WORKSPACE_BUFFER_SIZE = 128 * 1024 * 1024 +FLASHINFER_MLA_LSE_WORKSPACE_BUFFER_SIZE = 256 * 1024 * 1024 + +_fi_workspace: torch.Tensor | None = None + + +def _get_workspace_buffer(return_lse: bool) -> torch.Tensor: + global _fi_workspace + + buffer_size = ( + FLASHINFER_MLA_LSE_WORKSPACE_BUFFER_SIZE + if return_lse + else FLASHINFER_MLA_WORKSPACE_BUFFER_SIZE + ) + if _fi_workspace is None or _fi_workspace.numel() < buffer_size: + # FlashInfer's CuteDSL MLA-decode tactic requires an int8 workspace; + # the trtllm-gen path views it as uint8, so int8 is safe for all backends. + _fi_workspace = torch.zeros(buffer_size, dtype=torch.int8, device="cuda") + return _fi_workspace class FlashInferMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]): @@ -49,6 +67,14 @@ class FlashInferMLABackend(MLACommonBackend): def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: return [32, 64] + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + if include_num_layers_dimension: + return (1, 0, 2, 3) + return (0, 1, 2) + @staticmethod def get_name() -> str: return "FLASHINFER_MLA" @@ -75,6 +101,7 @@ def supports_combination( use_mla: bool, has_sink: bool, use_sparse: bool, + use_mm_prefix: bool, device_capability: DeviceCapability, ) -> str | None: # FlashInfer MLA kernel requires qk_nope_head_dim in [64, 128, 192] @@ -96,14 +123,16 @@ def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None": return "HND" -g_fi_workspace = torch.zeros( - FLASHINFER_MLA_WORKSPACE_BUFFER_SIZE, - dtype=torch.uint8, - device="cuda", -) - - class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]): + can_return_lse_for_decode: bool = True + # trtllm-gen MLA decode emits LSE in log2 (per flashinfer's own + # reference at flashinfer/trace/templates/attention.py:81: + # `logsumexp / log(2.0)`). Override the AttentionImplBase default + # so MLAAttention's DCP combine branches on the correct base + # (IS_BASE_E=False uses tl.exp2/tl.log2 natively, avoiding an FP + # multiply per decode step). + lse_base_on_e: bool = False + def __init__( self, num_heads: int, @@ -148,7 +177,6 @@ def __init__( "FlashInferMLAImpl" ) - self._workspace_buffer = g_fi_workspace self.bmm1_scale: float | None = None self.bmm2_scale: float | None = None @@ -187,10 +215,12 @@ def forward_mqa( if is_quantized_kv_cache(self.kv_cache_dtype): self.bmm2_scale *= layer._k_scale_float - o = trtllm_batch_decode_with_kv_cache_mla( + return_lse = self.need_to_return_lse_for_decode + workspace_buffer = _get_workspace_buffer(return_lse) + kernel_out = trtllm_batch_decode_with_kv_cache_mla( query=q, kv_cache=kv_c_and_k_pe_cache.unsqueeze(1), - workspace_buffer=self._workspace_buffer, + workspace_buffer=workspace_buffer, qk_nope_head_dim=self.qk_nope_head_dim, kv_lora_rank=self.kv_lora_rank, qk_rope_head_dim=self.qk_rope_head_dim, @@ -199,11 +229,14 @@ def forward_mqa( max_seq_len=attn_metadata.max_seq_len, bmm1_scale=self.bmm1_scale, bmm2_scale=self.bmm2_scale, + return_lse=return_lse, ) + if return_lse: + o, lse = kernel_out + else: + o, lse = kernel_out, None # Flatten the output for consistent shape o = o.view(-1, o.shape[-2], o.shape[-1]) - # TODO: Return LSE pending support from Flashinfer API: - # https://github.com/flashinfer-ai/flashinfer/pull/1566 - return o, None + return o, lse diff --git a/vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py b/vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py index 842153f40396..b66952cc74be 100644 --- a/vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py +++ b/vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py @@ -1,24 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""FlashInfer MLA Sparse Attention Backend. - -This backend uses the FlashInfer TRT-LLM MLA kernel with sparse_mla_top_k -for models like DeepSeek-V3.2 that use index-based sparse attention. - -For sparse MLA: -- block_tables shape changes from [batch_size, max_num_blocks] (dense) - to [batch_size, q_len_per_request, sparse_mla_top_k] (sparse) -- The sparse indices represent physical cache slot positions to attend to -- sparse_mla_top_k parameter must be set to the topk value -""" +"""FlashInfer sparse MLA attention backend.""" from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar import numpy as np import torch -from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla +from vllm import envs from vllm.config import VllmConfig from vllm.config.cache import CacheDType from vllm.logger import init_logger @@ -26,7 +16,7 @@ get_mla_dims, ) from vllm.platforms.interface import DeviceCapability -from vllm.utils.torch_utils import is_quantized_kv_cache +from vllm.utils.torch_utils import is_quantized_kv_cache, np_to_pinned_tensor from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -40,8 +30,12 @@ ) from vllm.v1.attention.backends.mla.sparse_utils import ( triton_convert_req_index_to_global_index, + triton_filter_and_convert_dcp_index, +) +from vllm.v1.attention.backends.utils import ( + KVCacheLayoutType, + split_decodes_and_prefills, ) -from vllm.v1.attention.backends.utils import KVCacheLayoutType from vllm.v1.kv_cache_interface import AttentionSpec if TYPE_CHECKING: @@ -49,37 +43,14 @@ logger = init_logger(__name__) -FLASHINFER_MLA_SPARSE_WORKSPACE_BUFFER_SIZE = 128 * 1024 * 1024 - -class FlashInferMLASparseBackend(AttentionBackend): - """FlashInfer MLA backend with sparse attention support. - - This backend uses the FlashInfer TRT-LLM MLA kernel with sparse_mla_top_k - for models like DeepSeek-V3.2 that use index-based sparse attention. - """ - - supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16] - supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [ - "auto", - "float16", - "bfloat16", - "fp8", - "fp8_e4m3", - ] - - @staticmethod - def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: - return [32, 64] +class _FlashInferMLASparseBackendBase(AttentionBackend): + """Common metadata for concrete FlashInfer sparse MLA backends.""" @staticmethod def get_name() -> str: return "FLASHINFER_MLA_SPARSE" - @staticmethod - def get_impl_cls() -> type["FlashInferMLASparseImpl"]: - return FlashInferMLASparseImpl - @staticmethod def get_builder_cls() -> type["FlashInferMLASparseMetadataBuilder"]: return FlashInferMLASparseMetadataBuilder @@ -96,9 +67,29 @@ def is_mla(cls) -> bool: def is_sparse(cls) -> bool: return True + +class FlashInferMLASparseTRTLLMBackend(_FlashInferMLASparseBackendBase): + """FlashInfer sparse MLA backend using the TRTLLM-gen launcher.""" + + supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16] + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [ + "auto", + "float16", + "bfloat16", + "fp8", + "fp8_e4m3", + ] + + @staticmethod + def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: + return [32, 64] + + @staticmethod + def get_impl_cls() -> type[SparseMLAAttentionImpl]: + return FlashInferMLASparseImpl + @classmethod def supports_compute_capability(cls, capability: DeviceCapability) -> bool: - # FlashInfer sparse MLA targets Blackwell (SM 10.x) return capability.major == 10 @classmethod @@ -111,12 +102,18 @@ def supports_combination( use_mla: bool, has_sink: bool, use_sparse: bool, + use_mm_prefix: bool, device_capability: DeviceCapability, ) -> str | None: - # FlashInfer MLA sparse kernel requires qk_nope_head_dim in [128, 192] from vllm.config import get_current_vllm_config vllm_config = get_current_vllm_config() + if kv_cache_dtype == "fp8_ds_mla": + return ( + "FLASHINFER_MLA_SPARSE SM10 does not support fp8_ds_mla kv-cache dtype" + ) + + # FlashInfer MLA sparse SM10 kernel requires qk_nope_head_dim in [128, 192]. if vllm_config.model_config is not None: hf_text_config = vllm_config.model_config.hf_text_config qk_nope_head_dim = getattr(hf_text_config, "qk_nope_head_dim", 1) @@ -145,6 +142,102 @@ def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None": return "HND" +class FlashInferMLASparseSM120Backend(_FlashInferMLASparseBackendBase): + """FlashInfer sparse MLA backend for SM120.""" + + supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16] + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [ + "auto", + "fp8", + "fp8_e4m3", + "fp8_ds_mla", + ] + + @staticmethod + def get_name() -> str: + return "FLASHINFER_MLA_SPARSE_SM120" + + @staticmethod + def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: + return [64, 256] + + @staticmethod + def get_impl_cls() -> type[SparseMLAAttentionImpl]: + from vllm.v1.attention.backends.mla.flashinfer_mla_sparse_sm120 import ( + FlashInferMLASparseSM120Impl, + ) + + return FlashInferMLASparseSM120Impl + + @classmethod + def supports_compute_capability(cls, capability: DeviceCapability) -> bool: + return capability.major == 12 + + @classmethod + def supports_combination( + cls, + head_size: int, + dtype: torch.dtype, + kv_cache_dtype: CacheDType | None, + block_size: int | None, + use_mla: bool, + has_sink: bool, + use_sparse: bool, + use_mm_prefix: bool, + device_capability: DeviceCapability, + ) -> str | None: + from vllm.config import get_current_vllm_config + from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120 + + if not has_flashinfer_sparse_mla_sm120(): + return ( + "FLASHINFER_MLA_SPARSE_SM120 requires FlashInfer's " + "sparse MLA decode API" + ) + if dtype != torch.bfloat16: + return "dtype not supported" + if kv_cache_dtype not in ( + None, + "auto", + "fp8", + "fp8_e4m3", + "fp8_ds_mla", + ): + return "kv_cache_dtype not supported" + vllm_config = get_current_vllm_config() + if vllm_config.model_config is not None: + hf_text_config = vllm_config.model_config.hf_text_config + index_topk = getattr(hf_text_config, "index_topk", None) + if index_topk is None: + return ( + "FLASHINFER_MLA_SPARSE_SM120 requires a model with " + "index_topk config" + ) + if int(index_topk) != 2048: + return ( + "FLASHINFER_MLA_SPARSE_SM120 requires index_topk=2048; " + f"got {index_topk}" + ) + return None + + @staticmethod + def get_kv_cache_shape( + num_blocks: int, + block_size: int, + num_kv_heads: int, # assumed to be 1 for MLA + head_size: int, + cache_dtype_str: str = "auto", + ) -> tuple[int, ...]: + if cache_dtype_str in ("auto", "fp8", "fp8_e4m3", "fp8_ds_mla"): + # fp8_ds_mla packed layout: 512 NoPE + 16 scales + 128 RoPE. + return (num_blocks, block_size, 656) + return (num_blocks, block_size, head_size) + + @classmethod + def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None": + return None + + @dataclass class FlashInferMLASparseMetadata(AttentionMetadata): """Attention metadata for FlashInfer MLA Sparse backend.""" @@ -162,10 +255,13 @@ class FlashInferMLASparseMetadata(AttentionMetadata): # Sequence lengths for all requests (context + query) seq_lens: torch.Tensor + num_decodes: int + num_decode_tokens: int # Sparse-specific block_size: int = 64 topk_tokens: int = 2048 + cp_kv_cache_interleave_size: int = 1 class FlashInferMLASparseMetadataBuilder( @@ -191,6 +287,12 @@ def __init__( self.mla_dims = get_mla_dims(self.model_config) self.topk_tokens = vllm_config.model_config.hf_config.index_topk + self._init_reorder_batch_threshold( + 1, + supports_spec_as_decode=True, + supports_dcp_with_varlen=True, + ) + self.req_id_per_token_buffer = torch.empty( (vllm_config.scheduler_config.max_num_batched_tokens,), dtype=torch.int32, @@ -205,6 +307,12 @@ def build( ) -> FlashInferMLASparseMetadata: cm = common_attn_metadata num_tokens = cm.num_actual_tokens + assert self.reorder_batch_threshold is not None + num_decodes, _, num_decode_tokens, _ = split_decodes_and_prefills( + cm, + decode_threshold=self.reorder_batch_threshold, + treat_short_extends_as_decodes=True, + ) # Build req_id_per_token mapping starts = np.asarray(cm.query_start_loc_cpu, dtype=np.int32) @@ -216,7 +324,7 @@ def build( # Zero-fill for cudagraphs self.req_id_per_token_buffer.fill_(0) self.req_id_per_token_buffer[: req_id_per_token.shape[0]].copy_( - torch.from_numpy(req_id_per_token), non_blocking=True + np_to_pinned_tensor(req_id_per_token), non_blocking=True ) req_id_per_token_tensor = self.req_id_per_token_buffer[:num_tokens] @@ -230,8 +338,13 @@ def build( block_table=cm.block_table_tensor, req_id_per_token=req_id_per_token_tensor, seq_lens=cm.seq_lens, + num_decodes=num_decodes, + num_decode_tokens=num_decode_tokens, block_size=self.kv_cache_spec.block_size, topk_tokens=self.topk_tokens, + cp_kv_cache_interleave_size=( + self.vllm_config.parallel_config.cp_kv_cache_interleave_size + ), ) @@ -242,9 +355,11 @@ def build( def _get_workspace_buffer(device: torch.device) -> torch.Tensor: global _fi_sparse_workspace if _fi_sparse_workspace is None: + # FlashInfer's CuteDSL MLA-decode tactic requires an int8 workspace; + # the trtllm-gen path views it as uint8, so int8 is safe for all backends. _fi_sparse_workspace = torch.zeros( - FLASHINFER_MLA_SPARSE_WORKSPACE_BUFFER_SIZE, - dtype=torch.uint8, + envs.VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE, + dtype=torch.int8, device=device, ) return _fi_sparse_workspace @@ -257,6 +372,9 @@ class FlashInferMLASparseImpl(SparseMLAAttentionImpl[FlashInferMLASparseMetadata sparse attention computation. """ + can_return_lse_for_decode: bool = True + lse_base_on_e: bool = False + def __init__( self, num_heads: int, @@ -270,7 +388,7 @@ def __init__( attn_type: str, kv_sharing_target_layer_name: str | None, # MLA Specific Arguments - topk_indice_buffer: torch.Tensor | None = None, + topk_indices_buffer: torch.Tensor | None = None, indexer: "Indexer | None" = None, **mla_args, ) -> None: @@ -300,8 +418,12 @@ def __init__( self.qk_nope_head_dim: int = mla_args["qk_nope_head_dim"] self.qk_rope_head_dim: int = mla_args["qk_rope_head_dim"] - assert indexer is not None, "Indexer required for sparse MLA" - self.topk_indices_buffer: torch.Tensor | None = indexer.topk_indices_buffer + # The indexer carries the shared buffer for normal layers and tests; + # the explicitly-passed buffer covers backbone skip layers, whose + # indexer is not constructed (see deepseek_v2.py). + self.topk_indices_buffer: torch.Tensor | None = ( + indexer.topk_indices_buffer if indexer is not None else topk_indices_buffer + ) self._workspace_buffer: torch.Tensor | None = None self.bmm1_scale: float | None = None @@ -327,14 +449,27 @@ def forward_mqa( assert self.topk_indices_buffer is not None topk_indices = self.topk_indices_buffer[:num_actual_toks] - topk_indices_physical, seq_lens = triton_convert_req_index_to_global_index( - attn_metadata.req_id_per_token[:num_actual_toks], - attn_metadata.block_table, - topk_indices, - BLOCK_SIZE=attn_metadata.block_size, - NUM_TOPK_TOKENS=topk_indices.shape[1], - return_valid_counts=True, - ) + if self.dcp_world_size > 1: + topk_indices_physical, seq_lens = triton_filter_and_convert_dcp_index( + attn_metadata.req_id_per_token[:num_actual_toks], + attn_metadata.block_table, + topk_indices, + dcp_size=self.dcp_world_size, + dcp_rank=self.dcp_rank, + cp_kv_cache_interleave_size=(attn_metadata.cp_kv_cache_interleave_size), + BLOCK_SIZE=attn_metadata.block_size, + NUM_TOPK_TOKENS=topk_indices.shape[1], + return_valid_counts=True, + ) + else: + topk_indices_physical, seq_lens = triton_convert_req_index_to_global_index( + attn_metadata.req_id_per_token[:num_actual_toks], + attn_metadata.block_table, + topk_indices, + BLOCK_SIZE=attn_metadata.block_size, + NUM_TOPK_TOKENS=topk_indices.shape[1], + return_valid_counts=True, + ) if self._workspace_buffer is None: self._workspace_buffer = _get_workspace_buffer(q.device) @@ -348,18 +483,68 @@ def forward_mqa( if is_quantized_kv_cache(self.kv_cache_dtype): self.bmm2_scale *= layer._k_scale_float - o = trtllm_batch_decode_with_kv_cache_mla( - query=q.unsqueeze(1), + from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla + + # Single-token sparse decode. trtllm-gen requires the q_len_per_request + # dim, but the sparse attention mask is fully per-token (each query token + # carries its own top-k index row), so unsqueeze is sufficient and + # correct. The MTP/multi-token q_len grouping is a perf-only layout and is + # deferred until MTP is validated end-to-end for this backend. + query = q.unsqueeze(1) + block_tables = topk_indices_physical.unsqueeze(1) + seq_lens_arg = seq_lens + + kernel_out = trtllm_batch_decode_with_kv_cache_mla( + query=query, kv_cache=kv_c_and_k_pe_cache.unsqueeze(1), workspace_buffer=self._workspace_buffer, qk_nope_head_dim=self.qk_nope_head_dim, kv_lora_rank=self.kv_lora_rank, qk_rope_head_dim=self.qk_rope_head_dim, - block_tables=topk_indices_physical.unsqueeze(1), - seq_lens=seq_lens, + block_tables=block_tables, + seq_lens=seq_lens_arg, max_seq_len=attn_metadata.topk_tokens, bmm1_scale=self.bmm1_scale, bmm2_scale=self.bmm2_scale, sparse_mla_top_k=attn_metadata.topk_tokens, + return_lse=self.need_to_return_lse_for_decode, ) - return o.view(-1, o.shape[-2], o.shape[-1]), None + if self.need_to_return_lse_for_decode: + assert isinstance(kernel_out, tuple) + o, lse = kernel_out + else: + assert isinstance(kernel_out, torch.Tensor) + o = kernel_out + lse = None + + out = o.view(-1, o.shape[-2], o.shape[-1]) + if lse is not None: + lse = self._normalize_lse(lse, out.shape[0], out.shape[1]) + empty_rows = (topk_indices_physical == -1).all(dim=-1) + out.masked_fill_(empty_rows.view(-1, 1, 1), 0.0) + lse.masked_fill_(empty_rows.view(-1, 1), float("-inf")) + return out, lse + + @staticmethod + def _normalize_lse( + lse: torch.Tensor, + num_tokens: int, + num_heads: int, + ) -> torch.Tensor: + # FlashInfer returns the decode LSE either as 2D (num_tokens, num_heads) + # or 3D ((num_tokens, num_heads, 1) / (num_tokens, 1, num_heads)). + # Collapse all of these to the (num_tokens, num_heads) the shared DCP + # reducer expects. + if lse.dim() == 3: + if lse.shape[-1] == 1: + lse = lse.squeeze(-1) + elif lse.shape[1] == 1: + lse = lse.squeeze(1) + elif lse.shape[0] * lse.shape[1] == num_tokens: + lse = lse.reshape(num_tokens, lse.shape[-1]) + if lse.shape != (num_tokens, num_heads): + raise RuntimeError( + "Unexpected FlashInfer sparse MLA LSE shape: " + f"{tuple(lse.shape)}, expected ({num_tokens}, {num_heads})." + ) + return lse diff --git a/vllm/v1/attention/backends/mla/flashinfer_mla_sparse_sm120.py b/vllm/v1/attention/backends/mla/flashinfer_mla_sparse_sm120.py new file mode 100644 index 000000000000..d802f568836a --- /dev/null +++ b/vllm/v1/attention/backends/mla/flashinfer_mla_sparse_sm120.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""SM120 implementation variant for ``FLASHINFER_MLA_SPARSE_SM120``.""" + +from typing import TYPE_CHECKING, cast + +import torch + +from vllm.v1.attention.backend import ( + AttentionLayer, + AttentionType, + SparseMLAAttentionImpl, +) +from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import ( + FlashInferMLASparseMetadata, + _get_workspace_buffer, +) +from vllm.v1.attention.backends.mla.sparse_utils import ( + triton_convert_req_index_to_global_index, +) + +if TYPE_CHECKING: + from vllm.model_executor.models.deepseek_v2 import Indexer + + +def _kv_scale_format_for_model(model_type: str | None) -> str: + if model_type is not None and model_type.startswith("glm"): + return "arbitrary_fp32" + return "pow2_fp32" + + +class FlashInferMLASparseSM120Impl(SparseMLAAttentionImpl[FlashInferMLASparseMetadata]): + """SM120 FlashInfer sparse-MLA implementation.""" + + def __init__( + self, + num_heads: int, + head_size: int, + scale: float, + num_kv_heads: int, + alibi_slopes: list[float] | None, + sliding_window: int | None, + kv_cache_dtype: str, + logits_soft_cap: float | None, + attn_type: str, + kv_sharing_target_layer_name: str | None, + indexer: "Indexer | None" = None, + **mla_args, + ) -> None: + if any([alibi_slopes, sliding_window, logits_soft_cap]): + raise NotImplementedError( + "FLASHINFER_MLA_SPARSE_SM120 does not support alibi_slopes / " + "sliding_window / logits_soft_cap" + ) + if attn_type != AttentionType.DECODER: + raise NotImplementedError( + "FLASHINFER_MLA_SPARSE_SM120 only supports decoder self-attention" + ) + + self.num_heads = num_heads + self.head_size = head_size + self.scale = float(scale) + self.num_kv_heads = num_kv_heads + self.kv_cache_dtype = kv_cache_dtype + if self.kv_cache_dtype != "fp8_ds_mla": + raise NotImplementedError( + "FLASHINFER_MLA_SPARSE_SM120 requires the packed fp8_ds_mla " + f"KV cache layout; got kv_cache_dtype={kv_cache_dtype!r}." + ) + + self.kv_lora_rank: int = mla_args["kv_lora_rank"] + self.qk_nope_head_dim: int = mla_args["qk_nope_head_dim"] + self.qk_rope_head_dim: int = mla_args["qk_rope_head_dim"] + from vllm.config import get_current_vllm_config + + vllm_config = get_current_vllm_config() + model_type = None + if vllm_config.model_config is not None: + model_type = getattr( + vllm_config.model_config.hf_text_config, "model_type", None + ) + self.kv_scale_format = _kv_scale_format_for_model(model_type) + + # Skip-topk layers are built with indexer=None and get the shared + # buffer via mla_args instead (cf. FLASHMLA_SPARSE). + self.topk_indices_buffer: torch.Tensor | None = ( + indexer.topk_indices_buffer + if indexer is not None + else mla_args.get("topk_indices_buffer") + ) + from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120 + + if not has_flashinfer_sparse_mla_sm120(): + raise RuntimeError( + "FLASHINFER_MLA_SPARSE_SM120 requires FlashInfer's " + "sparse MLA decode API." + ) + assert self.topk_indices_buffer is not None + + self.supports_quant_query_input = False + self._workspace_buffer: torch.Tensor | None = None + + def forward_mqa( + self, + q: torch.Tensor | tuple[torch.Tensor, torch.Tensor], + kv_c_and_k_pe_cache: torch.Tensor, + attn_metadata: FlashInferMLASparseMetadata, + layer: AttentionLayer, + ) -> tuple[torch.Tensor, torch.Tensor | None]: + if isinstance(q, tuple): + q = torch.cat(q, dim=-1) + + num_actual_toks = q.shape[0] + + assert self.topk_indices_buffer is not None + topk_indices = self.topk_indices_buffer[:num_actual_toks] + + topk_indices_physical = cast( + torch.Tensor, + triton_convert_req_index_to_global_index( + attn_metadata.req_id_per_token[:num_actual_toks], + attn_metadata.block_table, + topk_indices, + BLOCK_SIZE=attn_metadata.block_size, + NUM_TOPK_TOKENS=topk_indices.shape[1], + ), + ) + + output = q.new_empty( + (num_actual_toks, self.num_heads, self.kv_lora_rank), + dtype=q.dtype, + ) + + if self._workspace_buffer is None: + self._workspace_buffer = _get_workspace_buffer(q.device) + + from vllm.utils.flashinfer import ( + flashinfer_trtllm_batch_decode_with_kv_cache_mla, + ) + + out = flashinfer_trtllm_batch_decode_with_kv_cache_mla( + query=q.unsqueeze(1), + kv_cache=kv_c_and_k_pe_cache.view(torch.uint8).unsqueeze(1), + workspace_buffer=self._workspace_buffer, + qk_nope_head_dim=self.qk_nope_head_dim, + kv_lora_rank=self.kv_lora_rank, + qk_rope_head_dim=self.qk_rope_head_dim, + block_tables=topk_indices_physical.unsqueeze(1), + seq_lens=None, + max_seq_len=attn_metadata.topk_tokens, + out=output.unsqueeze(1), + bmm1_scale=self.scale, + bmm2_scale=1.0, + sparse_mla_top_k=attn_metadata.topk_tokens, + kv_scale_format=self.kv_scale_format, + ) + return out.squeeze(1), None diff --git a/vllm/v1/attention/backends/mla/flashmla.py b/vllm/v1/attention/backends/mla/flashmla.py index 2f6058d69aeb..bb6efe59c8c1 100644 --- a/vllm/v1/attention/backends/mla/flashmla.py +++ b/vllm/v1/attention/backends/mla/flashmla.py @@ -58,6 +58,14 @@ class FlashMLABackend(MLACommonBackend): def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: return [64] + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + if include_num_layers_dimension: + return (1, 0, 2, 3) + return (0, 1, 2) + @staticmethod def get_name() -> str: return "FLASHMLA" @@ -84,6 +92,7 @@ def supports_combination( use_mla: bool, has_sink: bool, use_sparse: bool, + use_mm_prefix: bool, device_capability: DeviceCapability, ) -> str | None: if use_sparse: @@ -162,7 +171,10 @@ def _build_decode( query_lens_cpu = query_start_loc_cpu[1:] - query_start_loc_cpu[:-1] # we use the max but all should be the same due to uniform length requirement max_query_len = query_lens_cpu.max().item() - num_q_tokens_per_head_k = max_query_len * self.num_q_heads // 1 + num_q_heads = self.num_q_heads + if self.dcp_world_size > 1: + num_q_heads *= self.dcp_world_size + num_q_tokens_per_head_k = max_query_len * num_q_heads // 1 scheduler_metadata, _ = get_mla_metadata( seq_lens_device, num_q_tokens_per_head_k, diff --git a/vllm/v1/attention/backends/mla/flashmla_sparse.py b/vllm/v1/attention/backends/mla/flashmla_sparse.py index 9140a6fccd55..19381efd732c 100644 --- a/vllm/v1/attention/backends/mla/flashmla_sparse.py +++ b/vllm/v1/attention/backends/mla/flashmla_sparse.py @@ -15,10 +15,8 @@ ) from vllm.platforms import current_platform from vllm.platforms.interface import DeviceCapability -from vllm.triton_utils import tl, triton -from vllm.utils.math_utils import cdiv from vllm.utils.platform_utils import num_compute_units -from vllm.utils.torch_utils import is_quantized_kv_cache +from vllm.utils.torch_utils import is_quantized_kv_cache, np_to_pinned_tensor from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -29,7 +27,6 @@ MultipleOf, SparseMLAAttentionImpl, ) -from vllm.v1.attention.backends.mla.compressor_utils import get_compressed_slot_mapping from vllm.v1.attention.backends.mla.sparse_utils import ( triton_convert_req_index_to_global_index, ) @@ -118,9 +115,6 @@ def get_impl_cls() -> type[SparseMLAAttentionImpl[Any]]: @classmethod def get_supported_head_sizes(cls) -> list[int]: # DeepSeek V3.2 layout: 512 NoPE + 64 RoPE = 576. - # DeepSeek V4 uses 448 NoPE + 64 RoPE = 512 and overrides this in - # vllm/models/deepseek_v4/nvidia/flashmla.py: - # DeepseekV4FlashMLASparseBackend.get_supported_head_sizes. return [576] @classmethod @@ -223,13 +217,6 @@ class Chunk: fp8_extra_metadata: FP8SeparatePrefillDecode | FP8KernelMetadata | None = None fp8_use_mixed_batch: bool = False - # Pre-computed C128A metadata (DeepseekV4 only, compress_ratio == 128). - # Decode: global slot ids + valid-entry counts (fused from positions). - c128a_global_decode_topk_indices: torch.Tensor | None = None - c128a_decode_topk_lens: torch.Tensor | None = None - # Prefill: local topk indices (used by combine_topk_swa_indices). - c128a_prefill_topk_indices: torch.Tensor | None = None - def get_prefill_workspace_size(max_model_len: int): # NOTE(Lucas): 5 is a magic number for controlling the prefill buffer size. @@ -325,68 +312,6 @@ def __init__( device=device, ) - # DeepseekV4: has compress_ratios in hf_config. - hf_config = vllm_config.model_config.hf_config - self.is_deepseek_v4 = ( - hasattr(hf_config, "compress_ratios") and len(hf_config.compress_ratios) > 0 - ) - self.compress_ratio = 1 - if self.is_deepseek_v4: - assert hasattr(self.kv_cache_spec, "compress_ratio") - self.compress_ratio = self.kv_cache_spec.compress_ratio - # Pre-allocate compressed slot mapping buffer for CUDA graph - # address stability when compress_ratio > 1. - if self.compress_ratio > 1: - max_num_batched_tokens = ( - vllm_config.scheduler_config.max_num_batched_tokens - ) - self.compressed_slot_mapping_buffer = torch.empty( - max_num_batched_tokens, - dtype=torch.int64, - device=self.device, - ) - - # Pre-allocate C128A topk buffers for CUDA graph address stability. - if self.compress_ratio == 128: - max_num_batched_tokens = ( - vllm_config.scheduler_config.max_num_batched_tokens - ) - # Pad to B_TOPK alignment (128 covers both h_q=64 B_TOPK=64 and - # h_q=128 B_TOPK=128). FlashMLA decode asserts extra_topk % B_TOPK - # == 0; unaligned widths (e.g. 17 = ceil(2136/128)) crash the - # sm100 head64 kernel. Padded slots stay -1 and decode_lens caps - # them via topk_length, so the pad is a no-op at kernel level. - # Mirrors _SPARSE_PREFILL_TOPK_ALIGNMENT in cache_utils.py. - _C128A_TOPK_ALIGNMENT = 128 - c128a_max_compressed = cdiv( - self.model_config.max_model_len, self.compress_ratio - ) - c128a_max_compressed = ( - cdiv(c128a_max_compressed, _C128A_TOPK_ALIGNMENT) - * _C128A_TOPK_ALIGNMENT - ) - # Stored so _build_c128a_metadata passes it as the kernel's - # max_compressed_tokens, matching the buffer stride. Otherwise - # the kernel's default 8192 iterates past row width and spills - # writes into adjacent rows (present in both decode and prefill - # branches of _build_c128a_topk_metadata_kernel). - self.c128a_max_compressed = c128a_max_compressed - self.c128a_global_decode_buffer = torch.empty( - (max_num_batched_tokens, c128a_max_compressed), - dtype=torch.int32, - device=self.device, - ) - self.c128a_decode_lens_buffer = torch.empty( - max_num_batched_tokens, - dtype=torch.int32, - device=self.device, - ) - self.c128a_prefill_buffer = torch.empty( - (max_num_batched_tokens, c128a_max_compressed), - dtype=torch.int32, - device=self.device, - ) - def _build_fp8_mixed_decode_prefill( self, common_attn_metadata: CommonAttentionMetadata, @@ -578,113 +503,39 @@ def build( # Zero-fill for cudagraphs self.req_id_per_token_buffer.fill_(0) self.req_id_per_token_buffer[: req_id_per_token.shape[0]].copy_( - torch.from_numpy(req_id_per_token), non_blocking=True + np_to_pinned_tensor(req_id_per_token), non_blocking=True ) req_id_per_token = self.req_id_per_token_buffer[:num_tokens] - slot_mapping = cm.slot_mapping - if self.compress_ratio > 1: - slot_mapping = get_compressed_slot_mapping( - common_attn_metadata.num_actual_tokens, - common_attn_metadata.query_start_loc, - common_attn_metadata.seq_lens, - common_attn_metadata.block_table_tensor.clamp(min=0), - int(self.kv_cache_spec.storage_block_size), - self.compress_ratio, - out=self.compressed_slot_mapping_buffer, - ) - fp8_extra_metadata: ( FlashMLASparseMetadata.FP8SeparatePrefillDecode | FlashMLASparseMetadata.FP8KernelMetadata | None ) = None - fp8_use_mixed_batch = ( - self.num_heads < MIN_HEADS_FOR_BF16_PREFILL and not self.is_deepseek_v4 - ) - # DeepseekV4 has its own attention impl (DeepseekV4MLAAttention) that does not - # consume fp8_extra_metadata. Skipping the build here avoids a - # forced D2H sync on seq_lens that would otherwise fire on every - # prefill-bearing step, lifting GPU utilization on long-prefill - # workloads (e.g. LongBench) from ~83% to ~100%. - if self.use_fp8_kv_cache and not self.is_deepseek_v4: + fp8_use_mixed_batch = self.num_heads < MIN_HEADS_FOR_BF16_PREFILL + if self.use_fp8_kv_cache: if fp8_use_mixed_batch: fp8_extra_metadata = self._build_fp8_mixed_decode_prefill(cm) else: fp8_extra_metadata = self._build_fp8_separate_prefill_decode(cm) - # Pre-compute C128A topk indices for DeepseekV4. - c128a_fields = {} - if self.is_deepseek_v4 and self.compress_ratio == 128: - c128a_fields = self._build_c128a_metadata(cm, req_id_per_token) - metadata = FlashMLASparseMetadata( num_reqs=cm.num_reqs, max_query_len=cm.max_query_len, max_seq_len=cm.max_seq_len, num_actual_tokens=cm.num_actual_tokens, query_start_loc=cm.query_start_loc, - slot_mapping=slot_mapping, + slot_mapping=cm.slot_mapping, block_table=cm.block_table_tensor, req_id_per_token=req_id_per_token, block_size=self.kv_cache_spec.block_size, topk_tokens=self.topk_tokens, fp8_extra_metadata=fp8_extra_metadata, fp8_use_mixed_batch=fp8_use_mixed_batch, - **c128a_fields, ) return metadata - def _build_c128a_metadata( - self, - cm: CommonAttentionMetadata, - req_id_per_token: torch.Tensor, - ) -> dict[str, torch.Tensor | None]: - """Pre-compute C128A topk indices for DeepseekV4 (compress_ratio >= 128).""" - # Must match SWA's decode split (no `require_uniform=True`) so - # `c128a_global_decode_topk_indices.shape[0]` lines up with q in - # `_forward_decode`. The per-token C128A kernel handles non-uniform - # query lengths. - (num_decodes, _, num_decode_tokens, num_prefill_tokens) = ( - split_decodes_and_prefills( - cm, - decode_threshold=self.reorder_batch_threshold or 1, - ) - ) - - num_total = num_decode_tokens + num_prefill_tokens - if num_total == 0: - return {} - - assert cm.positions is not None, ( - "positions is required for C128A metadata build" - ) - block_size = self.kv_cache_spec.block_size // self.compress_ratio - global_decode, decode_lens, prefill_local = build_c128a_topk_metadata( - cm.positions[:num_total], - self.compress_ratio, - num_decode_tokens, - req_id_per_token, - cm.block_table_tensor[:num_decodes], - block_size, - cm.slot_mapping, - self.c128a_global_decode_buffer, - self.c128a_decode_lens_buffer, - self.c128a_prefill_buffer, - max_compressed_tokens=self.c128a_max_compressed, - ) - - result: dict[str, torch.Tensor | None] = {} - if num_decode_tokens > 0: - result["c128a_global_decode_topk_indices"] = global_decode.view( - num_decode_tokens, 1, -1 - ) - result["c128a_decode_topk_lens"] = decode_lens - if num_prefill_tokens > 0: - result["c128a_prefill_topk_indices"] = prefill_local - return result - class FlashMLASparseImpl(SparseMLAAttentionImpl[FlashMLASparseMetadata]): @staticmethod @@ -717,8 +568,12 @@ def __init__( self.kv_cache_dtype = kv_cache_dtype self.kv_lora_rank: int = mla_args["kv_lora_rank"] self.softmax_scale = scale - assert indexer is not None - self.topk_indices_buffer: torch.Tensor | None = indexer.topk_indices_buffer + # The indexer carries the shared buffer for normal layers and tests; + # the explicitly-passed buffer covers backbone skip layers, whose + # indexer is not constructed (see deepseek_v2.py). + self.topk_indices_buffer: torch.Tensor | None = ( + indexer.topk_indices_buffer if indexer is not None else topk_indices_buffer + ) # Prefill BF16 kernel requires 64 on Hopper, 128 on Blackwell self.prefill_padding = ( 128 if current_platform.is_device_capability_family(100) else 64 @@ -761,18 +616,20 @@ def _forward_bf16_kv( ) -> torch.Tensor: # Convert per-request indices to global slots (decode) or workspace # offsets (prefill). - topk_indices = triton_convert_req_index_to_global_index( + topk_indices, topk_length = triton_convert_req_index_to_global_index( attn_metadata.req_id_per_token, attn_metadata.block_table, topk_indices, BLOCK_SIZE=attn_metadata.block_size, NUM_TOPK_TOKENS=topk_indices.shape[1], + return_valid_counts=True, ) return self._bf16_flash_mla_kernel( q, kv_c_and_k_pe_cache, topk_indices, + topk_length, ) def _forward_fp8_kv_separate_prefill_decode( @@ -800,7 +657,7 @@ def _forward_fp8_kv_separate_prefill_decode( # For BF16 cache: always use global cache slots (no workspace) # prefill_workspace_starts has been adjusted in-place per chunk so # prefill indices automatically come out chunk-local - topk_indices = triton_convert_req_index_to_global_index( + topk_indices, topk_length = triton_convert_req_index_to_global_index( attn_metadata.req_id_per_token, attn_metadata.block_table, topk_indices, @@ -809,6 +666,7 @@ def _forward_fp8_kv_separate_prefill_decode( HAS_PREFILL_WORKSPACE=has_prefill_workspace, prefill_workspace_request_ids=prefill_request_ids, prefill_workspace_starts=prefill_workspace_starts, + return_valid_counts=True, ) fp8_metadata = attn_metadata.fp8_extra_metadata @@ -871,11 +729,13 @@ def _fp8_decode( chunk_q = q[chunk.tokens_slice] chunk_topk_indices_workspace = topk_indices[chunk.tokens_slice] + chunk_topk_length = topk_length[chunk.tokens_slice] attn_out[chunk.tokens_slice] = self._bf16_flash_mla_kernel( chunk_q, chunk_workspace, chunk_topk_indices_workspace, + chunk_topk_length, ) return attn_out @@ -963,6 +823,7 @@ def _bf16_flash_mla_kernel( q: torch.Tensor, kv_c_and_k_pe_cache: torch.Tensor, topk_indices: torch.Tensor, + topk_length: torch.Tensor | None = None, ) -> torch.Tensor: num_tokens = q.shape[0] kv_c_and_k_pe_cache = kv_c_and_k_pe_cache.view( @@ -983,7 +844,11 @@ def _bf16_flash_mla_kernel( topk_indices = topk_indices.view(num_tokens, 1, -1) output = flash_mla_sparse_fwd( - q, kv_c_and_k_pe_cache, topk_indices, self.softmax_scale + q, + kv_c_and_k_pe_cache, + topk_indices, + self.softmax_scale, + topk_length=topk_length, )[0] output = output[:, : self.num_heads, :] @@ -1027,123 +892,3 @@ def forward_mqa( ) return attn_out, None - - -def build_c128a_topk_metadata( - positions: torch.Tensor, - compress_ratio: int, - num_decode_tokens: int, - token_to_req_indices: torch.Tensor, - block_table: torch.Tensor, - block_size: int, - slot_mapping: torch.Tensor, - global_decode_buffer: torch.Tensor, - decode_lens_buffer: torch.Tensor, - prefill_buffer: torch.Tensor, - max_compressed_tokens: int = 8192, -) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """Single kernel for all C128A tokens (decode + prefill). - - Decode tokens: position → block_table lookup → global slot ids + topk_lens. - Prefill tokens: position → local indices [0, ..., n-1, -1, ...]. - - Writes into pre-allocated buffers for CUDA graph address stability. - Returns slices of the buffers. - """ - num_tokens = positions.shape[0] - num_prefill_tokens = num_tokens - num_decode_tokens - - global_decode = global_decode_buffer[:num_decode_tokens] - decode_lens = decode_lens_buffer[:num_decode_tokens] - prefill_local = prefill_buffer[:num_prefill_tokens] - - if num_tokens == 0: - return global_decode, decode_lens, prefill_local - - _build_c128a_topk_metadata_kernel[(num_tokens,)]( - global_decode_buffer, - global_decode_buffer.stride(0), - decode_lens_buffer, - prefill_buffer, - prefill_buffer.stride(0), - positions, - compress_ratio, - max_compressed_tokens, - num_decode_tokens, - token_to_req_indices, - block_table, - block_table.stride(0), - block_size, - slot_mapping, - BLOCK_SIZE=1024, - ) - return global_decode, decode_lens, prefill_local - - -@triton.jit -def _build_c128a_topk_metadata_kernel( - # Decode outputs - global_decode_ptr, - global_decode_stride, - decode_lens_ptr, - # Prefill output - prefill_local_ptr, - prefill_local_stride, - # Inputs - positions_ptr, - compress_ratio, - max_compressed_tokens, - num_decode_tokens, - token_to_req_indices_ptr, - block_table_ptr, - block_table_stride, - block_size, - slot_mapping_ptr, - BLOCK_SIZE: tl.constexpr, -): - token_idx = tl.program_id(0) - position = tl.load(positions_ptr + token_idx) - num_compressed = (position + 1) // compress_ratio - num_compressed = tl.minimum(num_compressed, max_compressed_tokens) - is_decode = token_idx < num_decode_tokens - - if is_decode: - # --- Decode: block-table lookup → global slot ids + count --- - is_valid_token = tl.load(slot_mapping_ptr + token_idx) >= 0 - req_idx = tl.load(token_to_req_indices_ptr + token_idx) - count = tl.zeros((), dtype=tl.int32) - for i in range(0, max_compressed_tokens, BLOCK_SIZE): - offset = i + tl.arange(0, BLOCK_SIZE) - mask = offset < max_compressed_tokens - is_valid = offset < num_compressed - - block_indices = offset // block_size - block_numbers = tl.load( - block_table_ptr + req_idx * block_table_stride + block_indices, - mask=mask & is_valid, - ) - block_offsets = offset % block_size - slot_ids = block_numbers * block_size + block_offsets - slot_ids = tl.where(is_valid, slot_ids, -1) - tl.store( - global_decode_ptr + token_idx * global_decode_stride + offset, - slot_ids, - mask=mask, - ) - count += tl.sum(is_valid.to(tl.int32), axis=0) - - tl.store( - decode_lens_ptr + token_idx, - tl.where(is_valid_token, count, 0), - ) - else: - # --- Prefill: write local indices --- - pfx_idx = token_idx - num_decode_tokens - for i in range(0, max_compressed_tokens, BLOCK_SIZE): - offset = i + tl.arange(0, BLOCK_SIZE) - mask = offset < max_compressed_tokens - tl.store( - prefill_local_ptr + pfx_idx * prefill_local_stride + offset, - tl.where(offset < num_compressed, offset, -1), - mask=mask, - ) diff --git a/vllm/v1/attention/backends/mla/indexer.py b/vllm/v1/attention/backends/mla/indexer.py index 2870ec9a15c0..b38445f9ff0a 100644 --- a/vllm/v1/attention/backends/mla/indexer.py +++ b/vllm/v1/attention/backends/mla/indexer.py @@ -6,6 +6,7 @@ import vllm.envs as envs from vllm.config import VllmConfig +from vllm.distributed import get_dcp_group from vllm.logger import init_logger from vllm.platforms import current_platform from vllm.triton_utils import tl, triton @@ -24,6 +25,7 @@ ) from vllm.v1.attention.backends.mla.compressor_utils import get_compressed_slot_mapping from vllm.v1.attention.backends.utils import ( + get_dcp_local_seq_lens, split_decodes_and_prefills, ) from vllm.v1.kv_cache_interface import AttentionSpec, MLAAttentionSpec @@ -168,6 +170,8 @@ def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: @dataclass class DeepseekV32IndexerPrefillChunkMetadata: block_table: torch.Tensor + # Under DCP (dcp_world_size > 1) these hold this rank's local row bounds; + # otherwise they hold the global bounds. cu_seqlen_ks: torch.Tensor cu_seqlen_ke: torch.Tensor cu_seq_lens: torch.Tensor @@ -177,6 +181,9 @@ class DeepseekV32IndexerPrefillChunkMetadata: token_end: int num_reqs: int skip_kv_gather: bool = False + local_cu_seq_lens: torch.Tensor | None = None + local_total_seq_lens: int = 0 + max_local_total_seq_lens: int = 0 @dataclass @@ -195,6 +202,7 @@ class DeepSeekV32IndexerDecodeMetadata: decode_lens: torch.Tensor requires_padding: bool schedule_metadata: torch.Tensor + global_seq_lens: torch.Tensor | None = None @dataclass @@ -231,8 +239,6 @@ def get_max_prefill_buffer_size(vllm_config: VllmConfig): class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder): reorder_batch_threshold: int = 1 - natively_supported_next_n_fp4: list[int] = [1, 2] - # TODO (matt): integrate kernel with next_n = 4 support @classmethod def get_cudagraph_support( @@ -245,6 +251,19 @@ def get_cudagraph_support( def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) scheduler_config = self.vllm_config.scheduler_config + parallel_config = self.vllm_config.parallel_config + self.dcp_world_size = parallel_config.decode_context_parallel_size + self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_world_size > 1 else 0 + self.cp_kv_cache_interleave_size = parallel_config.cp_kv_cache_interleave_size + # The DCP sparse-indexer code is parameterized by interleave size, but + # interleave > 1 is not yet validated end-to-end (gsm8k parity fails), + # so fail closed here rather than silently produce wrong output. + if self.dcp_world_size > 1 and self.cp_kv_cache_interleave_size > 1: + raise NotImplementedError( + "DCP sparse indexer currently supports only " + f"cp_kv_cache_interleave_size=1 (got " + f"{self.cp_kv_cache_interleave_size})." + ) # NOTE(Chen):an estimated max size of flattened_kv. Need to double check. self.max_prefill_buffer_size = get_max_prefill_buffer_size(self.vllm_config) self.num_speculative_tokens = ( @@ -267,15 +286,21 @@ def __init__(self, *args, **kwargs): next_n = self.num_speculative_tokens + 1 self.reorder_batch_threshold += self.num_speculative_tokens - # NOTE(zyongye) fp4 indexer cache only natively supports next_n in - # natively_supported_next_n_fp4; for other next_n values we fall back - # to the flattening path. Outside the SM100 datacenter family the FP8 - # paged MQA logits kernel has the same [1, 2] constraint (deepgemm - # smxx_fp8_fp4_paged_mqa_logits.hpp:233), so flatten there too. - self.use_flattening = ( - self.use_fp4_indexer_cache - or not current_platform.is_device_capability_family(100) - ) and next_n not in self.natively_supported_next_n_fp4 + # NOTE: SM100 datacenter GPUs support any next_n natively via the + # multi-atom paged MQA logits kernels (FP8 and FP4 indexer + # caches). Outside the SM100 family the FP8 + # paged MQA logits kernel only supports next_n in (1, 2) + # (deepgemm smxx_fp8_fp4_paged_mqa_logits.hpp:233), so flatten there. + self.use_flattening = not current_platform.is_device_capability_family( + 100 + ) and next_n not in (1, 2) + logger.info_once( + "DSA indexer decode path: use_flattening=%s " + "(next_n=%d, use_fp4_indexer_cache=%s)", + self.use_flattening, + next_n, + self.use_fp4_indexer_cache, + ) sm_count = num_compute_units(self.device.index) self.num_sms = sm_count @@ -296,6 +321,11 @@ def __init__(self, *args, **kwargs): dtype=torch.int32, device=self.device, ) + self.global_decode_seq_lens_buffer = torch.zeros( + (scheduler_config.max_num_batched_tokens,), + dtype=torch.int32, + device=self.device, + ) self.arange_buffer = torch.arange( max( scheduler_config.max_num_seqs * next_n, @@ -327,6 +357,11 @@ def __init__(self, *args, **kwargs): # Get compress_ratio for DeepseekV4 support if isinstance(self.kv_cache_spec, MLAAttentionSpec): self.compress_ratio = self.kv_cache_spec.compress_ratio + if self.dcp_world_size > 1 and self.compress_ratio > 1: + raise NotImplementedError( + "DCP is not supported with sparse indexer KV compression " + f"(compress_ratio={self.compress_ratio})." + ) # Pre-allocate buffers for CUDA graph compatibility when if self.compress_ratio > 1: @@ -344,6 +379,26 @@ def __init__(self, *args, **kwargs): device=self.device, ) + def _dcp_localize_decode_seq_lens( + self, + seq_lens: torch.Tensor, + num_decodes: int, + seq_lens_is_buffer_view: bool, + ) -> torch.Tensor: + local_seq_lens = get_dcp_local_seq_lens( + seq_lens, + self.dcp_world_size, + self.dcp_rank, + self.cp_kv_cache_interleave_size, + ) + if seq_lens_is_buffer_view: + seq_lens.copy_(local_seq_lens) + return seq_lens + + out = self.decode_seq_lens_buffer[:num_decodes] + out.copy_(local_seq_lens) + return out + def _prepare_decode_tensors( self, seq_lens: torch.Tensor, @@ -464,6 +519,34 @@ def _prepare_decode_tensors( seq_lens = seq_lens_buffer return seq_lens, block_table, decode_lens, num_decodes, requires_padding + def _prepare_global_decode_seq_lens( + self, + global_seq_lens: torch.Tensor | None, + decode_lens: torch.Tensor, + decode_lens_cpu: torch.Tensor, + query_start_loc: torch.Tensor, + num_decode_tokens: int, + use_native: bool, + max_decode_len: int, + ) -> torch.Tensor | None: + if global_seq_lens is None: + return None + if use_native or max_decode_len <= 1: + return global_seq_lens + + actual_expanded = int(decode_lens_cpu.sum().item()) + if actual_expanded > 0: + expanded_offsets = torch.repeat_interleave( + global_seq_lens - decode_lens - query_start_loc, + decode_lens, + output_size=actual_expanded, + ) + self.global_decode_seq_lens_buffer[:actual_expanded] = ( + expanded_offsets + self.arange_buffer[:actual_expanded] + 1 + ) + self.global_decode_seq_lens_buffer[actual_expanded:num_decode_tokens] = 0 + return self.global_decode_seq_lens_buffer[:num_decode_tokens] + def build( self, common_prefix_len: int, @@ -477,6 +560,7 @@ def build( seq_lens = common_attn_metadata.seq_lens slot_mapping = common_attn_metadata.slot_mapping block_table = common_attn_metadata.block_table_tensor + dcp_local_seq_lens = common_attn_metadata.dcp_local_seq_lens num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = ( split_decodes_and_prefills( @@ -545,6 +629,9 @@ def build( self.compress_ratio, query_slice=query_slice, skip_kv_gather=query_slice.start > 0, + dcp_rank=self.dcp_rank, + dcp_world_size=self.dcp_world_size, + cp_kv_cache_interleave_size=self.cp_kv_cache_interleave_size, ) # Skip when total_seq_lens is 0 (i.e., no compressed token). if metadata is not None: @@ -562,6 +649,17 @@ def build( common_attn_metadata.query_start_loc_cpu[: num_decodes + 1] ) + # Under DCP the per-token decode bounds must be localized AFTER the + # per-token expansion below, not before. Expanding from a + # request-level localized length subtracts decode offsets in local + # space and yields too-short bounds (e.g. world=2, rank=1, global + # per-token bounds [8, 9, 10] -> [3, 4, 5] instead of [4, 4, 5]), so + # the first decode token would run top-k against too short a local KV + # range and miss valid tokens. Keep the global seq_lens here and + # localize the expanded bounds further down. + global_seq_lens_for_decode: torch.Tensor | None = None + if dcp_local_seq_lens is not None: + global_seq_lens_for_decode = common_attn_metadata.seq_lens[:num_decodes] seq_lens = common_attn_metadata.seq_lens[:num_decodes] block_table = common_attn_metadata.block_table_tensor[:num_decodes, ...] @@ -569,6 +667,16 @@ def build( next_n = 1 + self.num_speculative_tokens use_native = not self.use_flattening and max_decode_len <= next_n + global_seq_lens_for_decode = self._prepare_global_decode_seq_lens( + global_seq_lens=global_seq_lens_for_decode, + decode_lens=decode_lens, + decode_lens_cpu=decode_lens_cpu, + query_start_loc=common_attn_metadata.query_start_loc[:num_decodes], + num_decode_tokens=num_decode_tokens, + use_native=use_native, + max_decode_len=max_decode_len, + ) + seq_lens, block_table, decode_lens, batch_size, requires_padding = ( self._prepare_decode_tensors( seq_lens=seq_lens, @@ -584,15 +692,22 @@ def build( ) ) + seq_lens_is_buffer_view = (use_native and next_n > 1) or ( + not use_native and max_decode_len > 1 + ) + + # DCP: localize the now-expanded per-token global bounds to this + # rank's owned KV. Done here (after expansion) so each token's global + # causal length is localized individually; see the comment above. + if dcp_local_seq_lens is not None: + seq_lens = self._dcp_localize_decode_seq_lens( + seq_lens, num_decodes, seq_lens_is_buffer_view + ) + # For DeepseekV4 (compress_ratio > 1), the indexer KV cache stores # compressed tokens. Convert uncompressed seq_lens to compressed. if self.compress_ratio > 1: - # True iff seq_lens aliases decode_seq_lens_buffer (flatten or - # native wrote it); False iff it aliases common_attn_metadata. - seq_lens_is_local_view = (use_native and next_n > 1) or ( - not use_native and max_decode_len > 1 - ) - if seq_lens_is_local_view: + if seq_lens_is_buffer_view: seq_lens //= self.compress_ratio else: # Copy to avoid mutating shared state; keeps CG address stable. @@ -622,6 +737,7 @@ def build( decode_lens=decode_lens, requires_padding=requires_padding, schedule_metadata=self.scheduler_metadata_buffer, + global_seq_lens=global_seq_lens_for_decode, ) attn_metadata = DeepseekV32IndexerMetadata( @@ -651,6 +767,9 @@ def build_prefill_chunk_metadata( compress_ratio: int, query_slice: slice | None = None, skip_kv_gather: bool = False, + dcp_rank: int = 0, + dcp_world_size: int = 1, + cp_kv_cache_interleave_size: int = 1, ) -> DeepseekV32IndexerPrefillChunkMetadata | None: total_seq_lens = compressed_seq_lens_cpu[start_idx:end_idx].sum().item() if total_seq_lens == 0: @@ -665,6 +784,25 @@ def build_prefill_chunk_metadata( cu_seq_lens[:1] = 0 torch.cumsum(compressed_seq_lens[start_idx:end_idx], dim=0, out=cu_seq_lens[1:]) + local_cu_seq_lens = cu_seq_lens + local_total_seq_lens = total_seq_lens + max_local_total_seq_lens = total_seq_lens + if dcp_world_size > 1: + # Per-rank local KV length under interleave-aware DCP sharding, shape + # [num_reqs, dcp_world_size]. Reuse the canonical CP helper so the + # sharding matches the rest of the DCP pipeline (decode/prefill). + local_seq_lens = get_dcp_local_seq_lens( + compressed_seq_lens[start_idx:end_idx], + dcp_world_size, + None, + cp_kv_cache_interleave_size, + ) + this_rank_counts = local_seq_lens[:, dcp_rank].to(torch.int32) + local_cu_seq_lens = torch.zeros(num_reqs + 1, dtype=torch.int32, device=device) + torch.cumsum(this_rank_counts, dim=0, out=local_cu_seq_lens[1:]) + local_total_seq_lens = int(local_cu_seq_lens[-1].item()) + max_local_total_seq_lens = int(local_seq_lens.sum(dim=0).max().item()) + query_start_loc = ( query_start_loc[start_idx : end_idx + 1] - query_start_loc[start_idx] ) @@ -683,15 +821,21 @@ def build_prefill_chunk_metadata( cu_seq_len_ks = torch.empty(output_query_len, dtype=torch.int32, device=device) cu_seq_len_ke = torch.empty(output_query_len, dtype=torch.int32, device=device) + # Under DCP the kernel writes this rank's local row bounds into + # cu_seq_len_ks/ke; otherwise local_cu_seq_lens aliases cu_seq_lens. _build_prefill_chunk_metadata_kernel[(num_reqs,)]( query_start_loc, uncompressed_seq_lens[start_idx:end_idx], cu_seq_lens, + local_cu_seq_lens, token_to_seq, cu_seq_len_ks, cu_seq_len_ke, qs_start, qs_stop, + dcp_rank, + dcp_world_size, + cp_kv_cache_interleave_size, BLOCK_SIZE=1024, COMPRESS_RATIO=compress_ratio, ) @@ -715,6 +859,9 @@ def build_prefill_chunk_metadata( token_end=token_end, num_reqs=num_reqs, skip_kv_gather=skip_kv_gather, + local_cu_seq_lens=local_cu_seq_lens, + local_total_seq_lens=local_total_seq_lens, + max_local_total_seq_lens=max_local_total_seq_lens, ) @@ -724,12 +871,18 @@ def _build_prefill_chunk_metadata_kernel( query_start_loc_ptr, uncompressed_seq_lens_ptr, cu_compressed_seq_lens_ptr, + # Row-start base for cu_seq_len_ks/ke: local cumulative lens under DCP, + # aliases cu_compressed_seq_lens_ptr otherwise. + row_start_cu_compressed_seq_lens_ptr, # Outputs token_to_seq_ptr, cu_compressed_seq_len_ks_ptr, cu_compressed_seq_len_ke_ptr, query_slice_start, query_slice_stop, + DCP_RANK, + DCP_WORLD, + DCP_INTERLEAVE, BLOCK_SIZE: tl.constexpr, COMPRESS_RATIO: tl.constexpr, ): @@ -743,6 +896,10 @@ def _build_prefill_chunk_metadata_kernel( seq_end = tl.load(cu_compressed_seq_lens_ptr + batch_idx + 1) compressed_seq_len = seq_end - seq_start + # Row start for the (possibly localized) cu_seq_len_ks/ke. Equals seq_start + # when DCP is disabled (the pointer aliases cu_compressed_seq_lens_ptr). + row_start = tl.load(row_start_cu_compressed_seq_lens_ptr + batch_idx) + uncompressed_seq_len = tl.load(uncompressed_seq_lens_ptr + batch_idx) start_pos = uncompressed_seq_len - query_len @@ -756,14 +913,25 @@ def _build_prefill_chunk_metadata_kernel( ) out_pos = abs_pos - query_slice_start - # Compute cu_seq_len_ks - tl.store(cu_compressed_seq_len_ks_ptr + out_pos, seq_start, mask=mask) - - # Compute cu_seq_len_ke - seq_len_per_token = (start_pos + 1 + offset) // COMPRESS_RATIO + # cu_seq_len_ks: row start in the gathered K buffer. + tl.store(cu_compressed_seq_len_ks_ptr + out_pos, row_start, mask=mask) + + # cu_seq_len_ke: row start + per-token context length. Under DCP the + # global per-token length is sharded across ranks. + global_ctx = start_pos + 1 + offset + len_per_token = global_ctx // COMPRESS_RATIO + if DCP_WORLD > 1: + # Per-rank local context length under interleave-aware DCP, matching + # get_dcp_local_seq_lens. K == 1 reduces to (len + world-1-rank)//world. + base = (len_per_token // DCP_INTERLEAVE // DCP_WORLD) * DCP_INTERLEAVE + remainder = len_per_token - base * DCP_WORLD + remainder = tl.minimum( + tl.maximum(remainder - DCP_RANK * DCP_INTERLEAVE, 0), DCP_INTERLEAVE + ) + len_per_token = base + remainder tl.store( cu_compressed_seq_len_ke_ptr + out_pos, - seq_start + seq_len_per_token, + row_start + len_per_token, mask=mask, ) diff --git a/vllm/v1/attention/backends/mla/prefill/aiter_flash_attn.py b/vllm/v1/attention/backends/mla/prefill/aiter_flash_attn.py new file mode 100644 index 000000000000..130fcf394be5 --- /dev/null +++ b/vllm/v1/attention/backends/mla/prefill/aiter_flash_attn.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""AITER FlashAttention backend for MLA prefill (ROCm). + +This backend calls ``aiter.flash_attn_varlen_func`` directly, which natively +supports different q/k and v head dims (qk headdim 192, v headdim 128) without +padding V, and dispatches to the fast ``aiter::fmha_fwd_`` kernel on +gfx942/gfx950 (fp16/bf16). +""" + +from typing import TYPE_CHECKING + +import torch + +from vllm.platforms import current_platform +from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend + +if TYPE_CHECKING: + from vllm.config import VllmConfig + from vllm.platforms.interface import DeviceCapability + + +class AiterFlashAttnPrefillBackend(MLAPrefillBackend): + """AITER FlashAttention backend for MLA prefill""" + + @staticmethod + def get_name() -> str: + return "ROCM_AITER_FA" + + @classmethod + def supports_compute_capability(cls, device_capability: "DeviceCapability") -> bool: + if not current_platform.is_rocm(): + return False + from vllm.platforms.rocm import on_mi3xx + + return on_mi3xx() + + @classmethod + def is_available(cls) -> bool: + from vllm._aiter_ops import rocm_aiter_ops + + return rocm_aiter_ops.is_enabled() + + def __init__( + self, + num_heads: int, + scale: float, + kv_lora_rank: int, + qk_nope_head_dim: int, + qk_rope_head_dim: int, + v_head_dim: int, + vllm_config: "VllmConfig", + ) -> None: + super().__init__( + num_heads=num_heads, + scale=scale, + kv_lora_rank=kv_lora_rank, + qk_nope_head_dim=qk_nope_head_dim, + qk_rope_head_dim=qk_rope_head_dim, + v_head_dim=v_head_dim, + vllm_config=vllm_config, + ) + + from aiter import flash_attn_varlen_func + + self.flash_attn_varlen_func = flash_attn_varlen_func + + def run_prefill_new_tokens( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + return_softmax_lse: bool, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, + ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + assert output_scale is None, ( + "AiterFlashAttnPrefillBackend does not support fused quantized output." + ) + result = self.flash_attn_varlen_func( + q=q, + k=k, + v=v, + cu_seqlens_q=self._prefill_metadata.query_start_loc, + cu_seqlens_k=self._prefill_metadata.query_start_loc, + max_seqlen_q=self._prefill_metadata.max_query_len, + max_seqlen_k=self._prefill_metadata.max_query_len, + softmax_scale=self.scale, + causal=True, + return_lse=return_softmax_lse, + out=out, + ) + + # aiter returns the bare output tensor when return_lse is False, and + # (out, softmax_lse) when it is True. + if return_softmax_lse: + return result[0], result[1] + return result + + def run_prefill_context_chunk( + self, + chunk_idx: int, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + assert self._prefill_metadata.chunked_context is not None + chunked = self._prefill_metadata.chunked_context + out, lse = self.flash_attn_varlen_func( + q=q, + k=k, + v=v, + cu_seqlens_q=self._prefill_metadata.query_start_loc, + cu_seqlens_k=chunked.cu_seq_lens[chunk_idx], + max_seqlen_q=self._prefill_metadata.max_query_len, + max_seqlen_k=chunked.max_seq_lens[chunk_idx], + softmax_scale=self.scale, + causal=False, + return_lse=True, + ) + return out, lse diff --git a/vllm/v1/attention/backends/mla/prefill/base.py b/vllm/v1/attention/backends/mla/prefill/base.py index 91d668826fd9..c56f3d46c788 100644 --- a/vllm/v1/attention/backends/mla/prefill/base.py +++ b/vllm/v1/attention/backends/mla/prefill/base.py @@ -3,6 +3,7 @@ """Abstract base class for MLA prefill backends.""" from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar import torch @@ -12,12 +13,27 @@ from vllm.model_executor.layers.attention.mla_attention import ( MLACommonPrefillMetadata, ) + from vllm.model_executor.layers.quantization.utils.quant_utils import QuantKey from vllm.platforms.interface import DeviceCapability from vllm.v1.attention.backends.mla.prefill.selector import ( MLAPrefillSelectorConfig, ) +@dataclass(frozen=True, kw_only=True) +class MLADimensions: + qk_nope_head_dim: int + qk_rope_head_dim: int + v_head_dim: int + + def __str__(self) -> str: + return ( + f"(qk_nope_head_dim={self.qk_nope_head_dim}, " + f"qk_rope_head_dim={self.qk_rope_head_dim}, " + f"v_head_dim={self.v_head_dim})" + ) + + class MLAPrefillBackend(ABC): """Abstract base class for MLA prefill backends.""" @@ -25,7 +41,7 @@ class MLAPrefillBackend(ABC): torch.float16, torch.bfloat16, ] - requires_r1_mla_dimensions: ClassVar[bool] = False + supported_mla_dimensions: ClassVar[list[MLADimensions]] = [] @staticmethod @abstractmethod @@ -44,6 +60,12 @@ def supports_dtype(cls, dtype: torch.dtype) -> bool: def is_available(cls) -> bool: return True + def supports_quant_output(self, quant_key: "QuantKey") -> bool: + """Whether `run_prefill_new_tokens` can write quantized output + directly (fused) for the given quant key, skipping the post-quant + pass. Overridden by backends that support it.""" + return False + @classmethod def validate_configuration( cls, @@ -64,10 +86,14 @@ def validate_configuration( if not cls.is_available(): invalid_reasons.append("required dependencies not available") - if cls.requires_r1_mla_dimensions and not selector_config.is_r1_compatible: + if ( + cls.supported_mla_dimensions + and selector_config.mla_dimensions not in cls.supported_mla_dimensions + ): + supported = ", ".join(str(dims) for dims in cls.supported_mla_dimensions) invalid_reasons.append( - "model does not have DeepSeek R1 MLA dimensions " - "(qk_nope_head_dim=128, qk_rope_head_dim=64, v_head_dim=128)" + "Model does not have supported MLA dimensions " + f"(got {selector_config.mla_dimensions}; supported: {supported})" ) return invalid_reasons @@ -90,6 +116,17 @@ def __init__( self.v_head_dim = v_head_dim self.vllm_config = vllm_config + def clone(self) -> "MLAPrefillBackend": + return self.__class__( + num_heads=self.num_heads, + scale=self.scale, + kv_lora_rank=self.kv_lora_rank, + qk_nope_head_dim=self.qk_nope_head_dim, + qk_rope_head_dim=self.qk_rope_head_dim, + v_head_dim=self.v_head_dim, + vllm_config=self.vllm_config, + ) + def prepare_metadata( # noqa: B027 self, prefill_metadata: "MLACommonPrefillMetadata", @@ -107,6 +144,8 @@ def run_prefill_new_tokens( k: torch.Tensor, v: torch.Tensor, return_softmax_lse: bool, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: raise NotImplementedError diff --git a/vllm/v1/attention/backends/mla/prefill/flash_attn.py b/vllm/v1/attention/backends/mla/prefill/flash_attn.py index 029bd8ec9560..70e1f41c8193 100644 --- a/vllm/v1/attention/backends/mla/prefill/flash_attn.py +++ b/vllm/v1/attention/backends/mla/prefill/flash_attn.py @@ -8,8 +8,20 @@ import torch import vllm.envs as envs +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + kFp8StaticTensorSym, +) +from vllm.model_executor.warmup.cutedsl_warmup import ( + CuTeDSLCompileUnit, + register_cutedsl_warmup_provider, +) +from vllm.model_executor.warmup.fa4_cutedsl_config import ( + FA4MLAPrefillCompileContext, + iter_fa4_mla_prefill_compile_requests, +) from vllm.platforms import current_platform from vllm.v1.attention.backends.fa_utils import ( + compile_flash_attn_varlen_func_from_specs, get_flash_attn_version, is_flash_attn_varlen_func_available, ) @@ -17,6 +29,7 @@ if TYPE_CHECKING: from vllm.config import VllmConfig + from vllm.model_executor.layers.quantization.utils.quant_utils import QuantKey if is_flash_attn_varlen_func_available(): from vllm.v1.attention.backends.fa_utils import flash_attn_varlen_func @@ -86,6 +99,56 @@ def __init__( # Track whether we're using vllm's FA or upstream (for ROCm) self._is_vllm_fa = current_platform.is_cuda() or current_platform.is_xpu() + if self.vllm_flash_attn_version == 4: + register_cutedsl_warmup_provider(self) + + def get_cutedsl_warmup_compile_units(self) -> tuple[CuTeDSLCompileUnit, ...]: + if self.vllm_flash_attn_version != 4: + return () + if compile_flash_attn_varlen_func_from_specs is None: + raise RuntimeError( + "FA4 compile-only API is unavailable; CuTeDSL warmup does not " + "fall back to synthetic forward passes." + ) + + dtype = self.vllm_config.model_config.dtype + if dtype not in self.supported_dtypes: + dtype = torch.bfloat16 + + qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim + ctx = FA4MLAPrefillCompileContext( + dtype=dtype, + num_heads=self.num_heads, + qk_head_dim=qk_head_dim, + v_head_dim=self.v_head_dim, + kv_nope_head_dim=self.qk_nope_head_dim + self.v_head_dim, + requires_v_padding=self.requires_v_padding, + scale=self.scale, + num_splits=1 if envs.VLLM_BATCH_INVARIANT else 0, + fa_version=self.vllm_flash_attn_version, + ) + compile_requests = tuple(iter_fa4_mla_prefill_compile_requests(ctx)) + if not compile_requests: + return () + + return tuple( + CuTeDSLCompileUnit( + name="fa4_mla_prefill", + key=request.key, + compile=request.compile, + ) + for request in compile_requests + ) + + def supports_quant_output(self, quant_key: "QuantKey") -> bool: + device_capability = current_platform.get_device_capability() + return ( + self.vllm_flash_attn_version == 4 + and self._is_vllm_fa + and device_capability is not None + and device_capability[0] in (10, 11) + and quant_key == kFp8StaticTensorSym + ) def _flash_attn_varlen_diff_headdims( self, @@ -94,6 +157,8 @@ def _flash_attn_varlen_diff_headdims( v: torch.Tensor, return_softmax_lse: bool = False, softmax_scale: float | None = None, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, **kwargs, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: maybe_padded_v = v @@ -104,10 +169,13 @@ def _flash_attn_varlen_diff_headdims( if self._is_vllm_fa: kwargs["return_softmax_lse"] = return_softmax_lse + kwargs["out"] = out + kwargs["output_scale"] = output_scale else: # ROCm leverages the upstream flash_attn, which takes a parameter # called "return_attn_probs" instead of return_softmax_lse kwargs["return_attn_probs"] = return_softmax_lse + assert out is None and output_scale is None if envs.VLLM_BATCH_INVARIANT: kwargs["num_splits"] = 1 @@ -124,10 +192,6 @@ def _flash_attn_varlen_diff_headdims( if isinstance(attn_out, tuple): attn_out, lse = attn_out[0], attn_out[1] - # Unpad output back to v_head_dim if we padded V - if self.requires_v_padding: - attn_out = attn_out[..., : v.shape[-1]] - # Remain consistent with old `flash_attn_varlen_func` where there # is only one output tensor if `return_softmax_lse` is False. if return_softmax_lse: @@ -140,6 +204,8 @@ def run_prefill_new_tokens( k: torch.Tensor, v: torch.Tensor, return_softmax_lse: bool, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: return self._flash_attn_varlen_diff_headdims( q=q, @@ -152,6 +218,8 @@ def run_prefill_new_tokens( softmax_scale=self.scale, causal=True, return_softmax_lse=return_softmax_lse, + out=out, + output_scale=output_scale, ) def run_prefill_context_chunk( diff --git a/vllm/v1/attention/backends/mla/prefill/flashinfer.py b/vllm/v1/attention/backends/mla/prefill/flashinfer.py index 0204f6ee1a02..557c16f97f01 100644 --- a/vllm/v1/attention/backends/mla/prefill/flashinfer.py +++ b/vllm/v1/attention/backends/mla/prefill/flashinfer.py @@ -2,12 +2,15 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """FlashInfer backend for MLA prefill.""" -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import torch import vllm.envs as envs -from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend +from vllm.v1.attention.backends.mla.prefill.base import ( + MLADimensions, + MLAPrefillBackend, +) from vllm.v1.attention.backends.utils import ( PerLayerParameters, get_per_layer_parameters, @@ -33,7 +36,13 @@ class FlashInferPrefillBackend(MLAPrefillBackend): """FlashInfer backend for MLA prefill.""" - requires_r1_mla_dimensions = True + supported_mla_dimensions: ClassVar[list[MLADimensions]] = [ + MLADimensions( + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + ), + ] @staticmethod def get_name() -> str: @@ -188,6 +197,8 @@ def run_prefill_new_tokens( k: torch.Tensor, v: torch.Tensor, return_softmax_lse: bool, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: assert self._prefill_main is not None diff --git a/vllm/v1/attention/backends/mla/prefill/registry.py b/vllm/v1/attention/backends/mla/prefill/registry.py index 9c83ea1b13d9..0d818a084ba6 100644 --- a/vllm/v1/attention/backends/mla/prefill/registry.py +++ b/vllm/v1/attention/backends/mla/prefill/registry.py @@ -48,6 +48,10 @@ class MLAPrefillBackendEnum(Enum, metaclass=_MLAPrefillBackendEnumMeta): "vllm.v1.attention.backends.mla.prefill.tokenspeed_mla." "TokenspeedMLAPrefillBackend" ) + ROCM_AITER_FA = ( + "vllm.v1.attention.backends.mla.prefill.aiter_flash_attn." + "AiterFlashAttnPrefillBackend" + ) # Placeholder for third-party/custom backends - must be registered before use # set to None to avoid alias with other backend, whose value is an empty string CUSTOM = None diff --git a/vllm/v1/attention/backends/mla/prefill/selector.py b/vllm/v1/attention/backends/mla/prefill/selector.py index 816f4fd4b737..e0d54eee1016 100644 --- a/vllm/v1/attention/backends/mla/prefill/selector.py +++ b/vllm/v1/attention/backends/mla/prefill/selector.py @@ -13,6 +13,7 @@ from vllm.logger import init_logger from vllm.platforms.interface import DeviceCapability +from vllm.v1.attention.backends.mla.prefill.base import MLADimensions from vllm.v1.attention.backends.mla.prefill.registry import MLAPrefillBackendEnum if TYPE_CHECKING: @@ -31,24 +32,17 @@ class MLAPrefillSelectorConfig(NamedTuple): """ dtype: torch.dtype - is_r1_compatible: bool - - -def is_deepseek_r1_mla_compatible(vllm_config: "VllmConfig") -> bool: - """Check if model has DeepSeek R1 compatible MLA dimensions. + mla_dimensions: MLADimensions = MLADimensions( + qk_nope_head_dim=0, + qk_rope_head_dim=0, + v_head_dim=0, + ) - DeepSeek R1 MLA dimensions are: - - qk_nope_head_dim = 128 - - qk_rope_head_dim = 64 - - v_head_dim = 128 - """ - if vllm_config.model_config is None: - return False - hf_text_config = vllm_config.model_config.hf_text_config - qk_nope_head_dim = getattr(hf_text_config, "qk_nope_head_dim", 1) - qk_rope_head_dim = getattr(hf_text_config, "qk_rope_head_dim", 1) - v_head_dim = getattr(hf_text_config, "v_head_dim", 1) - return qk_nope_head_dim == 128 and qk_rope_head_dim == 64 and v_head_dim == 128 + def __repr__(self): + return ( + f"MLAPrefillSelectorConfig(dtype={self.dtype}, " + f"mla_dimensions={self.mla_dimensions})" + ) def _get_mla_prefill_backend_priorities( @@ -62,6 +56,14 @@ def _get_mla_prefill_backend_priorities( Returns: List of backends in priority order (highest priority first). """ + from vllm.platforms import current_platform + + if current_platform.is_rocm(): + return [ + MLAPrefillBackendEnum.ROCM_AITER_FA, + MLAPrefillBackendEnum.FLASH_ATTN, + ] + if device_capability.major == 10: # Blackwell return [ MLAPrefillBackendEnum.FLASH_ATTN, @@ -101,10 +103,19 @@ def get_mla_prefill_backend( attention_config = vllm_config.attention_config - selector_config = MLAPrefillSelectorConfig( - dtype=vllm_config.model_config.dtype, - is_r1_compatible=is_deepseek_r1_mla_compatible(vllm_config), - ) + model_config = vllm_config.model_config + if model_config is None: + selector_config = MLAPrefillSelectorConfig(dtype=torch.get_default_dtype()) + else: + hf_text_config = model_config.hf_text_config + selector_config = MLAPrefillSelectorConfig( + dtype=model_config.dtype, + mla_dimensions=MLADimensions( + qk_nope_head_dim=getattr(hf_text_config, "qk_nope_head_dim", 0), + qk_rope_head_dim=getattr(hf_text_config, "qk_rope_head_dim", 0), + v_head_dim=getattr(hf_text_config, "v_head_dim", 0), + ), + ) if attention_config.mla_prefill_backend is not None: selected_backend = attention_config.mla_prefill_backend @@ -123,7 +134,7 @@ def get_mla_prefill_backend( f"Reason: {invalid_reasons}" ) assert backend_cls is not None - logger.info("Using %s MLA prefill backend.", selected_backend.name) + logger.info_once("Using %s MLA prefill backend.", selected_backend.name) return backend_cls return _auto_select_mla_prefill_backend( diff --git a/vllm/v1/attention/backends/mla/prefill/tokenspeed_mla.py b/vllm/v1/attention/backends/mla/prefill/tokenspeed_mla.py index d6e4fca172ad..1f041f37317f 100644 --- a/vllm/v1/attention/backends/mla/prefill/tokenspeed_mla.py +++ b/vllm/v1/attention/backends/mla/prefill/tokenspeed_mla.py @@ -2,11 +2,14 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """TokenSpeed CuTe DSL backend for MLA prefill.""" -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import torch -from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend +from vllm.v1.attention.backends.mla.prefill.base import ( + MLADimensions, + MLAPrefillBackend, +) if TYPE_CHECKING: from vllm.config import VllmConfig @@ -19,7 +22,13 @@ class TokenspeedMLAPrefillBackend(MLAPrefillBackend): """TokenSpeed CuTe DSL backend for MLA prefill.""" - requires_r1_mla_dimensions = True + supported_mla_dimensions: ClassVar[list[MLADimensions]] = [ + MLADimensions( + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + ), + ] @staticmethod def get_name() -> str: @@ -115,6 +124,8 @@ def run_prefill_new_tokens( k: torch.Tensor, v: torch.Tensor, return_softmax_lse: bool, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: from tokenspeed_mla import tokenspeed_mla_prefill diff --git a/vllm/v1/attention/backends/mla/prefill/trtllm_ragged.py b/vllm/v1/attention/backends/mla/prefill/trtllm_ragged.py index afb0444a3148..90f721272dc9 100644 --- a/vllm/v1/attention/backends/mla/prefill/trtllm_ragged.py +++ b/vllm/v1/attention/backends/mla/prefill/trtllm_ragged.py @@ -2,12 +2,15 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """TRT-LLM Ragged backend for MLA prefill.""" -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import torch import vllm.envs as envs -from vllm.v1.attention.backends.mla.prefill.base import MLAPrefillBackend +from vllm.v1.attention.backends.mla.prefill.base import ( + MLADimensions, + MLAPrefillBackend, +) from vllm.v1.worker.workspace import current_workspace_manager if TYPE_CHECKING: @@ -21,7 +24,18 @@ class TrtllmRaggedPrefillBackend(MLAPrefillBackend): """TRT-LLM Ragged backend for MLA prefill.""" - requires_r1_mla_dimensions = True + supported_mla_dimensions: ClassVar[list[MLADimensions]] = [ + MLADimensions( + qk_nope_head_dim=128, + qk_rope_head_dim=64, + v_head_dim=128, + ), + MLADimensions( + qk_nope_head_dim=192, + qk_rope_head_dim=64, + v_head_dim=256, + ), + ] @staticmethod def get_name() -> str: @@ -83,6 +97,8 @@ def run_prefill_new_tokens( k: torch.Tensor, v: torch.Tensor, return_softmax_lse: bool, + out: torch.Tensor | None = None, + output_scale: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: from flashinfer.prefill import trtllm_ragged_attention_deepseek diff --git a/vllm/v1/attention/backends/mla/rocm_aiter_mla.py b/vllm/v1/attention/backends/mla/rocm_aiter_mla.py index e0a5730f5fd8..977a42c6fef9 100644 --- a/vllm/v1/attention/backends/mla/rocm_aiter_mla.py +++ b/vllm/v1/attention/backends/mla/rocm_aiter_mla.py @@ -196,9 +196,14 @@ def __init__( kv_cache_dtype_str = getattr(vllm_config.cache_config, "cache_dtype", "auto") if kv_cache_dtype_str in ("fp8", "fp8_e4m3", "fp8_e5m2"): kv_cache_dtype_str = "fp8" + q_dtype = dtypes.fp8 else: kv_cache_dtype_str = "bf16" kv_dtype = dtypes.d_dtypes.get(kv_cache_dtype_str, dtypes.bf16) + # Persist for get_mla_metadata_v1 (decode build): omitting these causes + # wrong split/reduce metadata for the gfx950 fp8 nhead=32 fold path. + self._mla_q_dtype = q_dtype + self._mla_kv_dtype = kv_dtype ( (work_meta_data_size, work_meta_data_type), (work_indptr_size, work_indptr_type), @@ -242,7 +247,12 @@ def __init__( vllm_config.model_config.max_model_len, vllm_config.scheduler_config.max_num_batched_tokens, ) - self._init_fp8_prefill_ps_buffers(max_num_reqs, max_prefill_qlen, device) + self._init_fp8_prefill_ps_buffers( + max_num_reqs, + max_prefill_qlen, + vllm_config.scheduler_config.max_num_batched_tokens, + device, + ) if self.compilation_config.cudagraph_mode.has_full_cudagraphs(): self.paged_kv_indptr = torch.zeros( @@ -257,21 +267,29 @@ def _init_fp8_prefill_ps_buffers( self, max_num_reqs: int, max_prefill_qlen: int, + max_num_batched_tokens: int, device: torch.device, ) -> None: """Pre-allocate persistent buffers for FP8 MLA prefill PS metadata. Uses ``get_ps_metadata_info_v1`` with max values so the buffers are large enough for any batch. ``get_ps_metadata_v1`` fills them - per-batch in ``build()``. + per-batch in ``build()``. The FP8 prefill forward path also uses the + global workspace manager for per-call scratch, so reserve its maximum + shape here before the workspace manager is locked after warmup. Args: max_num_reqs: Maximum number of concurrent requests. max_prefill_qlen: Maximum Q-length for a single request in one prefill batch. Should be ``min(max_model_len, - max_num_batched_tokens)`` — the chunked-prefill scheduler - never emits more than ``max_num_batched_tokens`` new tokens - per batch. + max_num_batched_tokens)`` — a single request never exceeds + ``max_model_len`` tokens, nor the per-batch token budget. + max_num_batched_tokens: Maximum number of tokens scheduled in one + batch. The ``final_lse`` scratch is sized by ``total_q`` (the + summed Q-length over all prefill requests in the batch), which + is bounded by this budget rather than by a single request's + ``max_prefill_qlen`` — concurrent requests can sum to more than + ``max_model_len`` when ``max_model_len < max_num_batched_tokens``. device: Target device for the buffers. """ from aiter import get_ps_metadata_info_v1 @@ -279,6 +297,7 @@ def _init_fp8_prefill_ps_buffers( # After kv_b_proj decompression, K has num_heads heads (same as Q). # So gqa_ratio=1 and num_head_k=num_heads for the PS kernel. num_head_k = self.num_heads + v_head_dim = self.mla_dims.v_head_dim # gqa_ratio = 1 # qlen_granularity = _FP8_PREFILL_TILE_Q // max(gqa_ratio, 1) qlen_granularity = _FP8_PREFILL_TILE_Q @@ -318,6 +337,21 @@ def _init_fp8_prefill_ps_buffers( device=device, ) + from vllm.v1.worker.workspace import current_workspace_manager + + max_num_partial_tiles = reduce_partial_map_size + current_workspace_manager().get_simultaneous( + ( + (max_num_partial_tiles * _FP8_PREFILL_TILE_Q, num_head_k, v_head_dim), + torch.float32, + ), + ( + (max_num_partial_tiles * _FP8_PREFILL_TILE_Q, num_head_k), + torch.float32, + ), + ((max_num_batched_tokens, num_head_k), torch.float32), + ) + logger.info( "FP8 MLA prefill PS buffers allocated " "(max_batch=%d, max_qlen=%d, num_head_k=%d)", @@ -505,6 +539,8 @@ def _build_decode( max_seqlen_qo=max_qo_len, uni_seqlen_qo=max_qo_len, fast_mode=True, + dtype_q=self._mla_q_dtype, + dtype_kv=self._mla_kv_dtype, ) has_persistent_metadata = True @@ -799,6 +835,9 @@ def _mla_fp8_prefill_attn( attn_metadata.fp8_prefill_reduce_final_map, attn_metadata.fp8_prefill_reduce_partial_map, tile_q, + # num_kv_splits added by ROCm/aiter#3391; 0 selects the kernel + # default max(cu_num, 0) == cu_num, matching pre-#3391 behavior. + 0, out_3d, final_lse, ) @@ -812,6 +851,7 @@ def forward_mha( attn_metadata: MLACommonMetadata, k_scale: torch.Tensor, output: torch.Tensor, + output_scale: torch.Tensor | None = None, ) -> None: """Dispatch prefill to the FP8 ASM kernel when available. @@ -837,6 +877,7 @@ def forward_mha( attn_metadata, k_scale, output, + output_scale, ) assert attn_metadata.prefill is not None @@ -852,8 +893,13 @@ def forward_mha( attn_metadata, k_scale, output, + output_scale, ) + assert output_scale is None, ( + "fused FP8 output not supported by the AITER FP8 MLA prefill path" + ) + kv_nope = self.kv_b_proj(kv_c_normed)[0].view( -1, self.num_heads, self.qk_nope_head_dim + self.v_head_dim ) diff --git a/vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py b/vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py index a58ecf2c651f..1e4a26830637 100644 --- a/vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py +++ b/vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py @@ -9,7 +9,7 @@ from vllm import _custom_ops as ops from vllm._aiter_ops import rocm_aiter_ops -from vllm.config import VllmConfig +from vllm.config import VllmConfig, get_current_vllm_config from vllm.config.cache import CacheDType from vllm.logger import init_logger from vllm.model_executor.layers.attention.mla_attention import ( @@ -31,6 +31,7 @@ AiterMLAHelper, ) from vllm.v1.kv_cache_interface import AttentionSpec +from vllm.v1.worker.workspace import current_workspace_manager if TYPE_CHECKING: from vllm.model_executor.models.deepseek_v2 import Indexer @@ -521,19 +522,26 @@ def build( # treated as its own batch entry), so persistent metadata can always # be precomputed here. The kernel switches to the persistent # work-stealing path automatically when work_meta_data is non-None. - # The output is a deterministic function of (num_tokens, max_query_len, - # num_heads, min(seq_lens, topk_tokens)); fingerprint those CPU-side - # and skip the launch when nothing changed. + # The output is a deterministic function of the per-request query and + # context lengths (both clamped to topk_tokens, past which per-token KV + # length saturates) and num_heads; fingerprint those CPU-side and skip + # the launch when nothing changed. num_reqs = common_attn_metadata.num_reqs clamped_seq_lens = np.minimum( common_attn_metadata.seq_lens_cpu[:num_reqs].numpy(), self.topk_tokens, ) + clamped_context_lens = np.minimum( + common_attn_metadata.seq_lens_cpu[:num_reqs].numpy() - seg_lengths, + self.topk_tokens, + ) metadata_key = ( num_tokens, int(common_attn_metadata.max_query_len), self._num_attention_heads, clamped_seq_lens.tobytes(), + clamped_context_lens.tobytes(), + seg_lengths.tobytes(), ) if metadata_key != self._prev_metadata_key: from aiter import get_mla_metadata_v1 @@ -628,7 +636,7 @@ def __init__( attn_type: str, kv_sharing_target_layer_name: str | None, # MLA Specific Arguments - topk_indice_buffer: torch.Tensor | None = None, + topk_indices_buffer: torch.Tensor | None = None, indexer: "Indexer | None" = None, **mla_args, ) -> None: @@ -641,8 +649,19 @@ def __init__( self.kv_cache_dtype = kv_cache_dtype self.kv_lora_rank: int = mla_args["kv_lora_rank"] self.softmax_scale = scale - assert indexer is not None - self.topk_indices_buffer: torch.Tensor | None = indexer.topk_indices_buffer + # The indexer carries the shared buffer for normal layers and tests; + # the explicitly-passed buffer covers backbone skip layers, whose + # indexer is not constructed (see deepseek_v2.py). + self.topk_indices_buffer: torch.Tensor | None = ( + indexer.topk_indices_buffer if indexer is not None else topk_indices_buffer + ) + + vllm_config = get_current_vllm_config() + max_tokens = vllm_config.scheduler_config.max_num_batched_tokens + q_concat_shape = (max_tokens, num_heads, head_size) + (self.q_concat_buffer,) = current_workspace_manager().get_simultaneous( + (q_concat_shape, vllm_config.model_config.dtype), + ) def _forward_mla( self, @@ -701,9 +720,16 @@ def forward_mqa( # NOTE(lucas): for the sparse FlashMLA kernels the kernels want to use # MQA 576/512 approach for both prefill and decode - # Concatenate q if it's a tuple (ql_nope, q_pe) + fp8_attention = self.kv_cache_dtype.startswith("fp8") if isinstance(q, tuple): - q = torch.cat(q, dim=-1) + ql_nope, q_pe = q + if fp8_attention: + q = layer._decode_concat_quant_fp8_op( # type: ignore[attr-defined] + ql_nope, q_pe, layer._q_scale + ) + else: + q = self.q_concat_buffer[: ql_nope.shape[0]] + ops.concat_mla_q(ql_nope, q_pe, q) num_actual_toks = attn_metadata.num_actual_tokens @@ -722,12 +748,12 @@ def forward_mqa( ) # write the latent and rope to kv cache - fp8_attention = self.kv_cache_dtype.startswith("fp8") if fp8_attention: - original_q_shape = q.shape kv_c_and_k_pe_cache = kv_c_and_k_pe_cache.view(current_platform.fp8_dtype()) - q, _ = ops.scaled_fp8_quant(q.view(q.shape[0], -1), layer._q_scale) - q = q.view(original_q_shape) + if q.dtype != current_platform.fp8_dtype(): + original_q_shape = q.shape + q, _ = ops.scaled_fp8_quant(q.view(q.shape[0], -1), layer._q_scale) + q = q.view(original_q_shape) mla_padded_q = AiterMLAHelper.get_mla_padded_q(self.num_heads, q) attn_out = self._forward_mla( layer, mla_padded_q, kv_c_and_k_pe_cache, attn_metadata diff --git a/vllm/v1/attention/backends/mla/sparse_swa.py b/vllm/v1/attention/backends/mla/sparse_swa.py index f0e444e493c4..9e54b62a00b6 100644 --- a/vllm/v1/attention/backends/mla/sparse_swa.py +++ b/vllm/v1/attention/backends/mla/sparse_swa.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import ClassVar, cast import torch @@ -9,6 +9,7 @@ from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase from vllm.platforms import current_platform from vllm.triton_utils import tl, triton +from vllm.utils.math_utils import cdiv from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -22,6 +23,7 @@ KVCacheSpec, MLAAttentionSpec, SlidingWindowMLASpec, + get_kv_quant_mode, ) # DeepseekV4 decode layer types, keyed by compress_ratio. Each type has a distinct @@ -73,9 +75,14 @@ def __init__( # determines the SWA block size of 64 tokens per block. # TODO(yifan): make SWA block size automatically determined and configurable. self.block_size = 64 - assert self.dtype == torch.uint8 + # uint8: fp8_ds_mla UE8M0 paged layout. bfloat16 / float8_e4m3fn: + # contiguous full-cache layout. + assert self.dtype in (torch.uint8, torch.bfloat16, torch.float8_e4m3fn) def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec: + # fp8_ds_mla's UE8M0 paged layout needs 576B alignment; contiguous + # bf16/fp8 cache uses the natural element-size page. + uses_fp8_ds_mla_layout = self.cache_config.cache_dtype == "fp8_ds_mla" return SlidingWindowMLASpec( block_size=self.block_size, num_kv_heads=1, @@ -83,8 +90,10 @@ def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec: dtype=self.dtype, sliding_window=self.window_size, cache_dtype_str=self.cache_config.cache_dtype, - alignment=576, # NOTE: FlashMLA requires 576B alignment + # 576B for FlashMLA packing; 512B for FlashInfer sparse (#44577). + alignment=576 if uses_fp8_ds_mla_layout else 512, model_version="deepseek_v4", + kv_quant_mode=get_kv_quant_mode(self.cache_config.cache_dtype), ) def forward(self): ... @@ -158,6 +167,11 @@ class DeepseekSparseSWAMetadata: token_to_req_indices: torch.Tensor | None = None # [num_tokens] decode_swa_indices: torch.Tensor | None = None # [num_decode_tokens, window_size] decode_swa_lens: torch.Tensor | None = None # [num_decode_tokens] + # Paged-coordinate prefill SWA indices/lens (FP8 paged-direct prefill). + prefill_swa_indices: torch.Tensor | None = ( + None # [num_prefill_tokens, 1, window_size] + ) + prefill_swa_lens: torch.Tensor | None = None # [num_prefill_tokens] # Number of decode/prefill requests/tokens (batch is reordered: decodes first) num_decodes: int = 0 @@ -167,7 +181,12 @@ class DeepseekSparseSWAMetadata: # Pre-computed prefill metadata shared across all DeepseekV4 attention layers. prefill_seq_lens: torch.Tensor | None = None + prefill_seq_lens_cpu: torch.Tensor | None = None prefill_gather_lens: torch.Tensor | None = None + prefill_query_lens_cpu: torch.Tensor | None = None + prefill_window_size: int = 0 + prefill_max_model_len: int = 0 + prefill_max_num_batched_tokens: int = 0 # Per-layer-type FlashMLA tile-scheduler metadata. One FlashMLASchedMeta # per present DeepseekV4 layer type, shared across all ~60 layers of that type @@ -182,6 +201,82 @@ class DeepseekSparseSWAMetadata: tile_sched_swaonly: "FlashMLASchedMeta | None" = None tile_sched_c4a: "FlashMLASchedMeta | None" = None tile_sched_c128a: "FlashMLASchedMeta | None" = None + flashinfer_sparse_index_cache: dict[str, tuple[torch.Tensor, torch.Tensor]] = field( + default_factory=dict + ) + + def get_prefill_chunk_plan( + self, compress_ratio: int, prefill_chunk_size: int + ) -> list[tuple[int, int, int, int]]: + if self.num_prefills == 0: + return [] + + assert self.prefill_seq_lens_cpu is not None + assert self.prefill_query_lens_cpu is not None + + # query_len <= max_num_batched_tokens and + # gather_len = query_len + min(prefix_len, window_size - 1), so the + # worst-case gathered width is bounded by + # max_num_batched_tokens + window_size - 1. The compressed prefix pool + # is bounded by ceil(max_model_len / compress_ratio). + max_workspace_area = prefill_chunk_size * ( + ( + 0 + if compress_ratio <= 1 + else cdiv(self.prefill_max_model_len, compress_ratio) + ) + + self.prefill_window_size + + self.prefill_max_num_batched_tokens + ) + prefix_lens_cpu = self.prefill_seq_lens_cpu - self.prefill_query_lens_cpu + gather_lens_cpu = self.prefill_query_lens_cpu + torch.clamp( + prefix_lens_cpu, min=0, max=self.prefill_window_size - 1 + ) + compressed_lens_cpu = ( + torch.zeros_like(self.prefill_seq_lens_cpu) + if compress_ratio <= 1 + else torch.div( + self.prefill_seq_lens_cpu, + compress_ratio, + rounding_mode="floor", + ) + ) + + chunk_plan: list[tuple[int, int, int, int]] = [] + chunk_start = 0 + while chunk_start < self.num_prefills: + chunk_max_compressed = int(compressed_lens_cpu[chunk_start].item()) + chunk_max_gather = int(gather_lens_cpu[chunk_start].item()) + chunk_end = chunk_start + 1 + + while chunk_end < self.num_prefills: + candidate_max_compressed = max( + chunk_max_compressed, + int(compressed_lens_cpu[chunk_end].item()), + ) + candidate_max_gather = max( + chunk_max_gather, + int(gather_lens_cpu[chunk_end].item()), + ) + candidate_width = candidate_max_compressed + candidate_max_gather + candidate_area = (chunk_end - chunk_start + 1) * candidate_width + if candidate_area > max_workspace_area: + break + chunk_max_compressed = candidate_max_compressed + chunk_max_gather = candidate_max_gather + chunk_end += 1 + + chunk_plan.append( + ( + chunk_start, + chunk_end, + chunk_max_compressed, + chunk_max_compressed + chunk_max_gather, + ) + ) + chunk_start = chunk_end + + return chunk_plan class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder): @@ -208,18 +303,25 @@ def __init__(self, *args, **kwargs): self.head_size = mla_spec.head_size # Already considered quantization. self.compress_ratio = mla_spec.compress_ratio self.block_size = mla_spec.block_size + self.max_model_len = self.vllm_config.model_config.max_model_len + self.max_num_batched_tokens = ( + self.vllm_config.scheduler_config.max_num_batched_tokens + ) # Handle MTP: adjust decode_threshold like the indexer does + spec_config = self.vllm_config.speculative_config self.num_speculative_tokens = ( - self.vllm_config.speculative_config.num_speculative_tokens - if self.vllm_config.speculative_config - else 0 + spec_config.num_speculative_tokens if spec_config else 0 + ) + # Decode can have query_len up to + # 1 + (2 if parallel drafting else 1) * num_speculative_tokens. + # This MUST match the flashmla_sparse / indexer threshold so that + # all backends agree on the decode/prefill split. + spec_mult = ( + 2 if (spec_config is not None and spec_config.parallel_drafting) else 1 ) - # With MTP, decode can have query_len up to 1 + num_speculative_tokens. - # Must match the threshold used by the indexer and flashmla_sparse so - # that all backends agree on the decode/prefill split. self.decode_threshold = ( - self.reorder_batch_threshold + self.num_speculative_tokens + self.reorder_batch_threshold + spec_mult * self.num_speculative_tokens ) hf_config = self.vllm_config.model_config.hf_config @@ -252,12 +354,40 @@ def __init__(self, *args, **kwargs): dtype=torch.int32, device=self.device, ) + # Allocated unconditionally — consumer picks paged-direct vs dequant + # at call time. + self.prefill_swa_indices = torch.zeros( + max_tokens, + 1, + self.window_size, + dtype=torch.int32, + device=self.device, + ) + self.prefill_swa_lens = torch.zeros( + max_tokens, + dtype=torch.int32, + device=self.device, + ) self.is_valid_token = torch.zeros( max_tokens, dtype=torch.bool, device=self.device, ) + # DSpark draft: the block is non-causal (every query attends to the + # trailing window of context PLUS all query tokens, including future ones), + # so its per-token index list is wider than `window_size`. The kernel pads + # the q-head count to B_TOPK (64/128), which requires the index width to be + # a multiple of 128. + self.is_dspark = spec_config is not None and spec_config.use_dspark() + self.noncausal_index_width = ( + cdiv(self.window_size + self.num_speculative_tokens, 128) * 128 + if self.is_dspark + else 0 + ) + self.decode_swa_indices_noncausal: torch.Tensor | None = None + self._max_tokens = max_tokens + def build( self, common_prefix_len: int, @@ -272,8 +402,8 @@ def build( For prefill, we use chunked prefill to align with the indexer's chunking. """ - num_reqs = common_attn_metadata.num_reqs seq_lens = common_attn_metadata.seq_lens + seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound query_start_loc = common_attn_metadata.query_start_loc query_start_loc_cpu = common_attn_metadata.query_start_loc_cpu block_table = common_attn_metadata.block_table_tensor @@ -288,20 +418,74 @@ def build( # NOTE: Ensure all metadata tensors maintain fixed memory addresses # for CUDA graph compatibility. - query_lens = query_start_loc_cpu[1:] - query_start_loc_cpu[:-1] - x = torch.repeat_interleave(torch.arange(num_reqs), query_lens).pin_memory() - token_to_req_indices = self.token_to_req_indices[: x.shape[0]] - token_to_req_indices.copy_(x, non_blocking=True) + token_to_req_indices = common_attn_metadata.token_to_req_indices( + self.token_to_req_indices + ) is_valid_token = self.is_valid_token[: slot_mapping.shape[0]] is_valid_token.copy_(slot_mapping >= 0) + non_causal = not common_attn_metadata.causal + decode_swa_indices = self.decode_swa_indices if num_decode_tokens > 0: self.decode_swa_lens[num_decode_tokens:] = 0 - _compute_swa_indices_and_lens_kernel[(num_decode_tokens,)]( - self.decode_swa_indices, - self.decode_swa_indices.stride(0), - self.decode_swa_lens, + if non_causal: + assert self.is_dspark, ( + "Non-causal DeepseekV4 SWA is only supported for the DSpark " + "speculation mode, but causal=False was set without DSpark." + ) + if self.decode_swa_indices_noncausal is None: + self.decode_swa_indices_noncausal = torch.zeros( + self._max_tokens, + 1, + self.noncausal_index_width, + dtype=torch.int32, + device=self.device, + ) + decode_swa_indices = self.decode_swa_indices_noncausal + _compute_dspark_noncausal_swa_indices_kernel[(num_decode_tokens,)]( + decode_swa_indices, + decode_swa_indices.stride(0), + self.decode_swa_lens, + self.window_size, + self.noncausal_index_width, + query_start_loc, + seq_lens, + token_to_req_indices, + is_valid_token, + block_table, + block_table.stride(0), + self.block_size, + token_offset=0, + TRITON_BLOCK_SIZE=1024, + ) + else: + _compute_swa_indices_and_lens_kernel[(num_decode_tokens,)]( + decode_swa_indices, + decode_swa_indices.stride(0), + self.decode_swa_lens, + self.window_size, + query_start_loc, + seq_lens, + token_to_req_indices, + is_valid_token, + block_table, + block_table.stride(0), + self.block_size, + token_offset=0, + TRITON_BLOCK_SIZE=1024, + ) + + # Prefill SWA indices live in paged coordinates. `token_offset` lets + # the kernel read is_valid_token / token_to_req_indices at absolute + # prefill positions while writing output starting at index 0. + if num_prefill_tokens > 0: + prefill_swa_indices = self.prefill_swa_indices[:num_prefill_tokens] + prefill_swa_lens = self.prefill_swa_lens[:num_prefill_tokens] + _compute_swa_indices_and_lens_kernel[(num_prefill_tokens,)]( + prefill_swa_indices, + prefill_swa_indices.stride(0), + prefill_swa_lens, self.window_size, query_start_loc, seq_lens, @@ -310,6 +494,7 @@ def build( block_table, block_table.stride(0), self.block_size, + token_offset=num_decode_tokens, TRITON_BLOCK_SIZE=1024, ) @@ -318,7 +503,9 @@ def build( num_decodes, num_prefills, seq_lens, + seq_lens_cpu, query_start_loc, + query_start_loc_cpu, ) # Per-layer-type tile-scheduler plan holders. Empty FlashMLASchedMeta @@ -335,8 +522,18 @@ def build( slot_mapping=slot_mapping, is_valid_token=is_valid_token, token_to_req_indices=token_to_req_indices, - decode_swa_indices=self.decode_swa_indices[:num_decode_tokens], + decode_swa_indices=decode_swa_indices[:num_decode_tokens], decode_swa_lens=self.decode_swa_lens[:num_decode_tokens], + prefill_swa_indices=( + self.prefill_swa_indices[:num_prefill_tokens] + if num_prefill_tokens > 0 + else None + ), + prefill_swa_lens=( + self.prefill_swa_lens[:num_prefill_tokens] + if num_prefill_tokens > 0 + else None + ), block_size=self.block_size, num_decodes=num_decodes, num_prefills=num_prefills, @@ -345,7 +542,7 @@ def build( tile_sched_swaonly=tile_sched[_LAYER_TYPE_SWAONLY], tile_sched_c4a=tile_sched[_LAYER_TYPE_C4A], tile_sched_c128a=tile_sched[_LAYER_TYPE_C128A], - **deepseek_v4_fields, + **deepseek_v4_fields, # type: ignore[arg-type] ) def build_tile_scheduler( @@ -371,6 +568,7 @@ def build_tile_scheduler( num_decode_tokens == 0 or current_platform.is_rocm() or current_platform.is_xpu() + or current_platform.is_device_capability_family(120) ): return out for layer_type in self._layer_types: @@ -386,20 +584,23 @@ def _build_deepseek_v4_metadata( num_decodes: int, num_prefills: int, seq_lens: torch.Tensor, + seq_lens_cpu: torch.Tensor | None, query_start_loc: torch.Tensor, - ) -> dict[str, torch.Tensor | None]: + query_start_loc_cpu: torch.Tensor, + ) -> dict[str, torch.Tensor | int | None]: """Pre-compute DeepseekV4 prefill metadata during the metadata build phase. Returns a dict of keyword arguments to pass to the DeepseekSparseSWAMetadata constructor. - Note: C128A topk indices are computed by the FlashMLASparse builder + Note: C128A sparse metadata is computed by the FlashMLASparse builder (which owns the C128A block_table), not here. """ - result: dict[str, torch.Tensor | None] = {} + result: dict[str, torch.Tensor | int | None] = {} # --- Prefill query metadata (single Triton kernel + CPU slicing) --- if num_prefills > 0: + assert seq_lens_cpu is not None pfx_gather_lens = torch.empty( num_prefills, dtype=torch.int32, device=seq_lens.device ) @@ -414,7 +615,15 @@ def _build_deepseek_v4_metadata( ) result["prefill_seq_lens"] = seq_lens[num_decodes:] + result["prefill_seq_lens_cpu"] = seq_lens_cpu[num_decodes:] result["prefill_gather_lens"] = pfx_gather_lens + result["prefill_query_lens_cpu"] = ( + query_start_loc_cpu[num_decodes + 1 : num_decodes + num_prefills + 1] + - query_start_loc_cpu[num_decodes : num_decodes + num_prefills] + ).to(dtype=torch.int32) + result["prefill_window_size"] = self.window_size + result["prefill_max_model_len"] = self.max_model_len + result["prefill_max_num_batched_tokens"] = self.max_num_batched_tokens return result @@ -434,10 +643,14 @@ def _compute_prefill_metadata_kernel( """Compute prefill gather_lens in a single pass.""" offset = tl.arange(0, BLOCK_SIZE) mask = offset < num_prefills + # SM12x + Triton 3.6 raises IMA on out-of-bounds address arithmetic for + # masked-off lanes even though the load mask gates the actual read, so + # clamp the offset. Caller guarantees num_prefills > 0. + safe_offset = tl.minimum(offset, num_prefills - 1) - seq_len = tl.load(seq_lens_ptr + num_decodes + offset, mask=mask) - qsl_start = tl.load(query_start_loc_ptr + num_decodes + offset, mask=mask) - qsl_end = tl.load(query_start_loc_ptr + num_decodes + offset + 1, mask=mask) + seq_len = tl.load(seq_lens_ptr + num_decodes + safe_offset, mask=mask) + qsl_start = tl.load(query_start_loc_ptr + num_decodes + safe_offset, mask=mask) + qsl_end = tl.load(query_start_loc_ptr + num_decodes + safe_offset + 1, mask=mask) query_len = qsl_end - qsl_start prefix_len = seq_len - query_len @@ -446,7 +659,7 @@ def _compute_prefill_metadata_kernel( tl.store(prefill_gather_lens_ptr + offset, gather_len, mask=mask) -@triton.jit +@triton.jit(do_not_specialize=["token_offset"]) def _compute_swa_indices_and_lens_kernel( swa_indices_ptr, swa_indices_stride, @@ -459,12 +672,14 @@ def _compute_swa_indices_and_lens_kernel( block_table_ptr, block_table_stride, block_size, + token_offset, TRITON_BLOCK_SIZE: tl.constexpr, ): - token_idx = tl.program_id(0) + pid = tl.program_id(0) + token_idx = pid + token_offset is_valid = tl.load(is_valid_token_ptr + token_idx) if not is_valid: - tl.store(swa_lens_ptr + token_idx, 0) + tl.store(swa_lens_ptr + pid, 0) return req_idx = tl.load(token_to_req_indices_ptr + token_idx) @@ -481,7 +696,7 @@ def _compute_swa_indices_and_lens_kernel( end_pos = pos + 1 swa_len = end_pos - start_pos - tl.store(swa_lens_ptr + token_idx, swa_len) + tl.store(swa_lens_ptr + pid, swa_len) for i in range(0, window_size, TRITON_BLOCK_SIZE): offset = i + tl.arange(0, TRITON_BLOCK_SIZE) @@ -497,7 +712,73 @@ def _compute_swa_indices_and_lens_kernel( slot_ids = tl.where(offset < swa_len, slot_ids, -1) tl.store( - swa_indices_ptr + token_idx * swa_indices_stride + offset, + swa_indices_ptr + pid * swa_indices_stride + offset, slot_ids, mask=offset < window_size, ) + + +# TODO(ben): unify this kernel to reduce duplication +@triton.jit(do_not_specialize=["token_offset"]) +def _compute_dspark_noncausal_swa_indices_kernel( + swa_indices_ptr, + swa_indices_stride, + swa_lens_ptr, + window_size, + index_width, + query_start_loc_ptr, + seq_lens_ptr, + token_to_req_indices_ptr, + is_valid_token_ptr, + block_table_ptr, + block_table_stride, + block_size, + token_offset, + TRITON_BLOCK_SIZE: tl.constexpr, +): + """Non-causal per-token indices for the DSpark draft block. + + Here, we populate the topk indices with the trailing window of context tokens, + plus all query tokens (including future ones). + """ + pid = tl.program_id(0) + token_idx = pid + token_offset + is_valid = tl.load(is_valid_token_ptr + token_idx) + if not is_valid: + tl.store(swa_lens_ptr + pid, 0) + return + + req_idx = tl.load(token_to_req_indices_ptr + token_idx) + + query_start = tl.load(query_start_loc_ptr + req_idx) + query_end = tl.load(query_start_loc_ptr + req_idx + 1) + query_len = query_end - query_start + + seq_len = tl.load(seq_lens_ptr + req_idx) + prefix_len = seq_len - query_len + + # Block-anchored window (shared by every token in the block) + full block. + start_pos = tl.maximum(prefix_len - window_size, 0) + end_pos = seq_len + + swa_len = end_pos - start_pos + tl.store(swa_lens_ptr + pid, swa_len) + + for i in range(0, index_width, TRITON_BLOCK_SIZE): + offset = i + tl.arange(0, TRITON_BLOCK_SIZE) + + pos_offset = start_pos + offset + block_indices = pos_offset // block_size + block_numbers = tl.load( + block_table_ptr + req_idx * block_table_stride + block_indices, + mask=pos_offset < end_pos, + ) + block_offsets = pos_offset % block_size + slot_ids = block_numbers * block_size + block_offsets + + slot_ids = tl.where(offset < swa_len, slot_ids, -1) + tl.store( + swa_indices_ptr + pid * swa_indices_stride + offset, + slot_ids, + mask=offset < index_width, + ) diff --git a/vllm/v1/attention/backends/mla/sparse_utils.py b/vllm/v1/attention/backends/mla/sparse_utils.py index e4bd0cf425e1..522b52b0dcd2 100644 --- a/vllm/v1/attention/backends/mla/sparse_utils.py +++ b/vllm/v1/attention/backends/mla/sparse_utils.py @@ -23,6 +23,16 @@ def _convert_req_index_to_global_index_kernel( BLOCK_N: tl.constexpr, # tile width along columns HAS_PREFILL: tl.constexpr, COUNT_VALID: tl.constexpr, # whether to count valid indices + # When set, scatter valid slots to a contiguous prefix [0, valid_count) using + # valid_count_ptr as an atomic slot allocator (DCP filtering leaves interior + # -1 gaps; the trtllm-gen sparse kernel reads the first valid_count entries). + # Requires COUNT_VALID and an out buffer pre-filled with -1. Order within the + # prefix is unspecified (only the selected set matters). + COMPACT_TO_FRONT: tl.constexpr, + # DCP de-interleave: with DCP_SIZE == 1 these are an exact no-op + DCP_SIZE: tl.constexpr, + DCP_RANK: tl.constexpr, + DCP_INTERLEAVE: tl.constexpr, # strides (in elements) bt_stride0, bt_stride1, @@ -52,15 +62,27 @@ def _convert_req_index_to_global_index_kernel( if HAS_PREFILL: prefill_req_id = tl.load(prefill_request_id_ptr + token_id) is_prefill = prefill_req_id >= 0 + + # DCP de-interleave the global token id into this rank's local slot. + # Tokens are interleaved in groups of DCP_INTERLEAVE across ranks. With + # DCP_SIZE == 1 (and any interleave) owning_rank == 0 == DCP_RANK (never + # remote) and local_idx == tok, so this reduces to the non-DCP path; with + # DCP_INTERLEAVE == 1 it reduces to plain round-robin (tok % / // DCP_SIZE). + owning_rank = (tok // DCP_INTERLEAVE) % DCP_SIZE + is_remote = owning_rank != DCP_RANK + local_idx = ( + tok // (DCP_SIZE * DCP_INTERLEAVE) + ) * DCP_INTERLEAVE + tok % DCP_INTERLEAVE + # Compute block id and in-block offset - block_id = tok // BLOCK_SIZE - inblock_off = tok % BLOCK_SIZE + block_id = local_idx // BLOCK_SIZE + inblock_off = local_idx % BLOCK_SIZE # Guard block_table access valid_block = (block_id < max_num_blocks_per_req) & (block_id >= 0) bt_ptr = block_table_ptr + req * bt_stride0 + block_id * bt_stride1 - is_invalid_tok |= ~valid_block - base = tl.load(bt_ptr, mask=valid_block & ~is_prefill, other=0) + is_invalid_tok |= ~valid_block | is_remote + base = tl.load(bt_ptr, mask=valid_block & ~is_prefill & ~is_remote, other=0) out_val = base * BLOCK_SIZE + inblock_off # Override with prefill output if prefill is enabled @@ -72,14 +94,27 @@ def _convert_req_index_to_global_index_kernel( out_val = tl.where(is_prefill, prefill_out, out_val) out_val = tl.where(is_invalid_tok, -1, out_val) - # Store results - out_ptr_ij = out_ptr + token_id * out_stride0 + indice_id * out_stride1 - tl.store(out_ptr_ij, out_val) + if COMPACT_TO_FRONT: + # Scatter valid slots to a contiguous prefix. A per-tile exclusive prefix + # sum gives each valid lane a distinct local offset; one atomic add of the + # tile's valid count reserves a contiguous base across racing tiles. The + # out buffer is pre-filled with -1, so unwritten tail slots stay -1. + is_valid = (~is_invalid_tok).to(tl.int32) + local_offset = tl.cumsum(is_valid) - is_valid + tile_valid_count = tl.sum(is_valid) + base = tl.atomic_add(valid_count_ptr + token_id, tile_valid_count) + dest = base + local_offset + out_ptr_dest = out_ptr + token_id * out_stride0 + dest * out_stride1 + tl.store(out_ptr_dest, out_val, mask=is_valid == 1) + else: + # Store results in place (input column == output column). + out_ptr_ij = out_ptr + token_id * out_stride0 + indice_id * out_stride1 + tl.store(out_ptr_ij, out_val) - # Count valid indices in this tile and atomically add to row total - if COUNT_VALID: - tile_valid_count = tl.sum((~is_invalid_tok).to(tl.int32)) - tl.atomic_add(valid_count_ptr + token_id, tile_valid_count) + # Count valid indices in this tile and atomically add to row total + if COUNT_VALID: + tile_valid_count = tl.sum((~is_invalid_tok).to(tl.int32)) + tl.atomic_add(valid_count_ptr + token_id, tile_valid_count) def triton_convert_req_index_to_global_index( @@ -176,6 +211,11 @@ def triton_convert_req_index_to_global_index( BLOCK_N, HAS_PREFILL_WORKSPACE, return_valid_counts, + False, # COMPACT_TO_FRONT: keep input column == output column + # DCP disabled (no-op de-interleave) + 1, + 0, + 1, # strides bt_stride0, bt_stride1, @@ -189,3 +229,110 @@ def triton_convert_req_index_to_global_index( assert valid_counts is not None return out, valid_counts return out + + +def triton_filter_and_convert_dcp_index( + req_id: torch.Tensor, + block_table: torch.Tensor, + token_indices: torch.Tensor, + dcp_size: int, + dcp_rank: int, + cp_kv_cache_interleave_size: int = 1, + BLOCK_SIZE: int = 64, + NUM_TOPK_TOKENS: int = 2048, + BLOCK_N: int = 128, + return_valid_counts: bool = False, + compact_valid_to_front: bool = True, +) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + """Filter global per-request indices to this DCP rank's local slots. + + With ``compact_valid_to_front`` (default), the conversion kernel scatters + this rank's owned slots to a contiguous prefix ``[0, valid_count)`` and + leaves the rest ``-1``. DCP filtering marks non-owned slots ``-1`` and so + creates interior gaps; the trtllm-gen sparse kernel reads the first + ``valid_count`` entries of each row, so they must be a contiguous prefix. + Compaction is fused into the kernel (atomic slot allocator) rather than a + separate sort/gather pass. Prefix order is unspecified (only the set matters). + """ + assert dcp_size >= 1 + assert 0 <= dcp_rank < dcp_size + # Interleave groups must align to KV blocks (globally enforced by + # VllmConfig: block_size % cp_kv_cache_interleave_size == 0); assert the + # local invariant so local_idx // BLOCK_SIZE never straddles a group. + assert BLOCK_SIZE % cp_kv_cache_interleave_size == 0, ( + f"BLOCK_SIZE ({BLOCK_SIZE}) must be divisible by " + f"cp_kv_cache_interleave_size ({cp_kv_cache_interleave_size})." + ) + assert req_id.dtype == torch.int32 + assert block_table.dtype == torch.int32 + assert token_indices.dtype == torch.int32 + assert token_indices.shape[1] == NUM_TOPK_TOKENS + assert NUM_TOPK_TOKENS % BLOCK_N == 0 + + if dcp_size == 1: + return triton_convert_req_index_to_global_index( + req_id, + block_table, + token_indices, + BLOCK_SIZE=BLOCK_SIZE, + NUM_TOPK_TOKENS=NUM_TOPK_TOKENS, + BLOCK_N=BLOCK_N, + return_valid_counts=return_valid_counts, + ) + + num_tokens = req_id.shape[0] + max_num_blocks_per_req = block_table.shape[1] + tiles_per_row = NUM_TOPK_TOKENS // BLOCK_N + + req_id_c = req_id.contiguous() + block_table_c = block_table.contiguous() + token_indices_c = token_indices.contiguous() + + # The compaction uses the valid-count buffer as an atomic slot allocator, so + # it requires counting. Pre-fill out with -1 so the unwritten tail stays -1. + count_valid = return_valid_counts or compact_valid_to_front + if compact_valid_to_front: + out = torch.full_like(token_indices_c, -1) + else: + out = torch.empty_like(token_indices_c) + + valid_counts: torch.Tensor | None = None + if count_valid: + valid_counts = torch.zeros( + num_tokens, dtype=torch.int32, device=token_indices.device + ) + + bt_stride0, bt_stride1 = block_table_c.stride() + ti_stride0, ti_stride1 = token_indices_c.stride() + out_stride0, out_stride1 = out.stride() + + _convert_req_index_to_global_index_kernel[(num_tokens, tiles_per_row)]( + req_id_c, + block_table_c, + token_indices_c, + out, + valid_counts, + # No prefill workspace on the DCP decode path. + None, + None, + max_num_blocks_per_req, + BLOCK_SIZE, + BLOCK_N, + False, # HAS_PREFILL + count_valid, + compact_valid_to_front, + dcp_size, + dcp_rank, + cp_kv_cache_interleave_size, + bt_stride0, + bt_stride1, + ti_stride0, + ti_stride1, + out_stride0, + out_stride1, + ) + + if return_valid_counts: + assert valid_counts is not None + return out, valid_counts + return out diff --git a/vllm/v1/attention/backends/mla/tokenspeed_mla.py b/vllm/v1/attention/backends/mla/tokenspeed_mla.py index 6c8dedd77f27..0f819fe8ce0c 100644 --- a/vllm/v1/attention/backends/mla/tokenspeed_mla.py +++ b/vllm/v1/attention/backends/mla/tokenspeed_mla.py @@ -93,6 +93,7 @@ def supports_combination( use_mla: bool, has_sink: bool, use_sparse: bool, + use_mm_prefix: bool, device_capability: DeviceCapability, ) -> str | None: # Surface a clear install hint up front rather than letting a raw diff --git a/vllm/v1/attention/backends/mla/triton_mla.py b/vllm/v1/attention/backends/mla/triton_mla.py index c2aa5edccb66..acc9c9cb5010 100644 --- a/vllm/v1/attention/backends/mla/triton_mla.py +++ b/vllm/v1/attention/backends/mla/triton_mla.py @@ -25,12 +25,59 @@ MultipleOf, ) from vllm.v1.attention.ops.triton_decode_attention import decode_attention_fwd +from vllm.v1.worker.workspace import ( + current_workspace_manager, + is_workspace_manager_initialized, +) logger = init_logger(__name__) +# num_kv_splits selection (shared by forward_mqa and the workspace reservation +# so the two cannot drift). Both are hardware dependent. +_MIN_WORK_PER_SPLIT = 512 +_SPLIT_OCCUPANCY_MULTIPLIER = 2 + + +def _compute_num_kv_splits(max_seq_len: int, sm_count: int) -> int: + # Power of 2 to avoid excessive kernel instantiations, capped by an SM-based + # maximum (occupancy multiplier allows multiple blocks per SM + # for latency hiding). + ideal_splits = triton.next_power_of_2(max(1, max_seq_len // _MIN_WORK_PER_SPLIT)) + max_splits = sm_count * _SPLIT_OCCUPANCY_MULTIPLIER + return min(ideal_splits, max_splits) + class TritonMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]): - _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH + _cudagraph_support: ClassVar[AttentionCGSupport] = ( + AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE + ) + + def __init__(self, kv_cache_spec, layer_names, vllm_config, device): + super().__init__(kv_cache_spec, layer_names, vllm_config, device) + self._reserve_attn_logits_workspace() + + def _reserve_attn_logits_workspace(self) -> None: + """Pre-size the shared workspace for the decode split-KV attn logits. + + Reserving at the worst case (max_model_len -> max num_kv_splits, + max_num_seqs decode tokens) before warmup/cudagraph capture means the + per-call ``get_simultaneous`` in ``forward_mqa`` never has to grow the + buffer at runtime (which would raise once the workspace is locked). + """ + if not is_workspace_manager_initialized(): + return + # Decode reorder threshold is 1, so decode tokens <= max_num_seqs. + B = self.vllm_config.scheduler_config.max_num_seqs + # DCP all-gathers the query heads before forward_mqa. + q_num_heads = self.num_heads * self.dcp_world_size + max_splits = _compute_num_kv_splits( + self.model_config.max_model_len, + current_platform.num_compute_units(), + ) + lse_dim = self.mla_dims.kv_lora_rank + 1 + current_workspace_manager().get_simultaneous( + ((B, q_num_heads, max_splits, lse_dim), torch.float32), + ) class TritonMLABackend(MLACommonBackend): @@ -57,6 +104,14 @@ def supports_block_size(cls, block_size: int | None) -> bool: return True return block_size % 16 == 0 + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + if include_num_layers_dimension: + return (1, 0, 2, 3) + return (0, 1, 2) + @staticmethod def get_name() -> str: return "TRITON_MLA" @@ -158,35 +213,25 @@ def forward_mqa( if envs.VLLM_BATCH_INVARIANT: num_kv_splits = 1 else: - # Minimum work per split - # hardware dependent - min_work_per_split = 512 - - ideal_splits = max(1, attn_metadata.max_seq_len // min_work_per_split) - - # use power of 2 to avoid excessive kernel instantiations - ideal_splits = triton.next_power_of_2(ideal_splits) - - # Calculate SM-based maximum splits with occupancy multiplier - # 2-4x allows multiple blocks per SM for latency hiding - # hardware dependent - occupancy_multiplier = 2 - max_splits = self._sm_count * occupancy_multiplier - num_kv_splits = min(ideal_splits, max_splits) - - # TODO(lucas) Allocate ahead of time - attn_logits = torch.empty( - ( - B, - q_num_heads, - num_kv_splits, - # NOTE: the +1 stores the LogSumExp (LSE) that the stage2 - # kernel uses to merge partial attention outputs across splits. - self.kv_lora_rank + 1, - ), - dtype=torch.float32, - device=q.device, - ) + num_kv_splits = _compute_num_kv_splits( + attn_metadata.max_seq_len, self._sm_count + ) + + # NOTE: the +1 stores the LogSumExp (LSE) that the stage2 kernel uses to + # merge partial attention outputs across splits. The scratch is served + # from the shared workspace (reserved at max in the metadata builder), so + # there is no per-call allocation on the decode hot path. Fall back to a + # direct allocation when the workspace manager is not initialized (e.g. + # unit tests without a GPUModelRunner). + logits_shape = (B, q_num_heads, num_kv_splits, self.kv_lora_rank + 1) + if is_workspace_manager_initialized(): + (attn_logits,) = current_workspace_manager().get_simultaneous( + (logits_shape, torch.float32), + ) + else: + attn_logits = torch.empty( + logits_shape, dtype=torch.float32, device=q.device + ) # Add a head dim of 1 kv_c_and_k_pe_cache = kv_c_and_k_pe_cache.unsqueeze(2) diff --git a/vllm/v1/attention/backends/mla/xpu_mla_sparse.py b/vllm/v1/attention/backends/mla/xpu_mla_sparse.py index 2fa91d018388..9aad45321036 100644 --- a/vllm/v1/attention/backends/mla/xpu_mla_sparse.py +++ b/vllm/v1/attention/backends/mla/xpu_mla_sparse.py @@ -184,7 +184,7 @@ def __init__( attn_type: str, kv_sharing_target_layer_name: str | None, # MLA Specific Arguments - topk_indice_buffer: torch.Tensor | None = None, + topk_indices_buffer: torch.Tensor | None = None, indexer: Optional["Indexer"] = None, **mla_args, ) -> None: @@ -195,8 +195,12 @@ def __init__( self.kv_cache_dtype = kv_cache_dtype self.kv_lora_rank: int = mla_args["kv_lora_rank"] self.softmax_scale = scale - assert indexer is not None - self.topk_indices_buffer: torch.Tensor | None = indexer.topk_indices_buffer + # The indexer carries the shared buffer for normal layers and tests; + # the explicitly-passed buffer covers backbone skip layers, whose + # indexer is not constructed (see deepseek_v2.py). + self.topk_indices_buffer: torch.Tensor | None = ( + indexer.topk_indices_buffer if indexer is not None else topk_indices_buffer + ) def _forward_bf16_kv( self, diff --git a/vllm/v1/attention/backends/registry.py b/vllm/v1/attention/backends/registry.py index 87abb6884313..94fc24f1cac6 100644 --- a/vllm/v1/attention/backends/registry.py +++ b/vllm/v1/attention/backends/registry.py @@ -46,6 +46,9 @@ class AttentionBackendEnum(Enum, metaclass=_AttentionBackendEnumMeta): "vllm.v1.attention.backends.flash_attn_diffkv.FlashAttentionDiffKVBackend" ) TRITON_ATTN = "vllm.v1.attention.backends.triton_attn.TritonAttentionBackend" + TRITON_ATTN_DIFFKV = ( + "vllm.v1.attention.backends.triton_attn_diffkv.TritonAttentionDiffKVBackend" + ) ROCM_ATTN = "vllm.v1.attention.backends.rocm_attn.RocmAttentionBackend" ROCM_AITER_MLA = "vllm.v1.attention.backends.mla.rocm_aiter_mla.AiterMLABackend" ROCM_AITER_TRITON_MLA = ( @@ -68,7 +71,11 @@ class AttentionBackendEnum(Enum, metaclass=_AttentionBackendEnumMeta): ) FLASHINFER_MLA_SPARSE = ( "vllm.v1.attention.backends.mla.flashinfer_mla_sparse." - "FlashInferMLASparseBackend" + "FlashInferMLASparseTRTLLMBackend" + ) + FLASHINFER_MLA_SPARSE_SM120 = ( + "vllm.v1.attention.backends.mla.flashinfer_mla_sparse." + "FlashInferMLASparseSM120Backend" ) TRITON_MLA = "vllm.v1.attention.backends.mla.triton_mla.TritonMLABackend" CUTLASS_MLA = "vllm.v1.attention.backends.mla.cutlass_mla.CutlassMLABackend" @@ -76,9 +83,32 @@ class AttentionBackendEnum(Enum, metaclass=_AttentionBackendEnumMeta): FLASHMLA_SPARSE = ( "vllm.v1.attention.backends.mla.flashmla_sparse.FlashMLASparseBackend" ) + # DeepSeek V4 sparse MLA backends (model-driven; selected via the V4 layer). + FLASHMLA_SPARSE_DSV4 = ( + "vllm.models.deepseek_v4.sparse_mla.DeepseekV4FlashMLABackend" + ) + FLASHINFER_MLA_SPARSE_DSV4 = ( + "vllm.models.deepseek_v4.nvidia.flashinfer_sparse." + "DeepseekV4FlashInferMLASparseBackend" + ) + ROCM_FLASHMLA_SPARSE_DSV4 = ( + "vllm.models.deepseek_v4.amd.rocm.DeepseekV4ROCMAiterMLASparseBackend" + ) FLASH_ATTN_MLA = "vllm.v1.attention.backends.mla.flashattn_mla.FlashAttnMLABackend" + FLASH_ATTN_MLA_SPARSE = ( + "vllm.v1.attention.backends.mla.flashattn_mla_sparse.FlashAttnMLASparseBackend" + ) + MINIMAX_M3_SPARSE = ( + "vllm.models.minimax_m3.common.sparse_attention.MiniMaxM3SparseBackend" + ) NO_ATTENTION = "vllm.v1.attention.backends.no_attention.NoAttentionBackend" FLEX_ATTENTION = "vllm.v1.attention.backends.flex_attention.FlexAttentionBackend" + # HPC Attention Backend: + # powered by operators from https://github.com/Tencent/hpc-ops. + # Only supported on NVIDIA Hopper GPUs (e.g. H20, H200), + # currently limited to the Hy3 model, + # and requires a block size of 64. + HPC_ATTN = "vllm.v1.attention.backends.hpc_attn.HpcAttentionBackend" ROCM_AITER_UNIFIED_ATTN = ( "vllm.v1.attention.backends.rocm_aiter_unified_attn." "RocmAiterUnifiedAttentionBackend" diff --git a/vllm/v1/attention/backends/rocm_aiter_fa.py b/vllm/v1/attention/backends/rocm_aiter_fa.py index a9fa45debcfc..0f289649b1c9 100644 --- a/vllm/v1/attention/backends/rocm_aiter_fa.py +++ b/vllm/v1/attention/backends/rocm_aiter_fa.py @@ -602,7 +602,11 @@ def build( torch.cumsum( chunk_seq_lens, dim=1, out=cu_seq_lens_cpu[:, 1:], dtype=torch.int32 ) - max_cum_tokens = cu_seq_lens_cpu[:, -1].max().item() + # Avoid .max() on an empty tensor when there is no context + # (num_chunks == 0, e.g. Whisper encoder's first pass). + max_cum_tokens = ( + cu_seq_lens_cpu[:, -1].max().item() if num_chunks > 0 else 0 + ) range_idx = torch.arange(max_cum_tokens, dtype=torch.int32)[None, None, :] idx_to_batch_tensor = range_idx == cu_seq_lens_cpu[:, 1:][:, :, None] @@ -1408,8 +1412,6 @@ def do_kv_cache_update( assert k_scale is not None and v_scale is not None, ( "k_scale and v_scale are required for shuffled update" ) - # TODO: Add correct KV cache handling for hybrid model. KV cache - # may not be contiguous if mamba state exists. reshape_and_cache_shuffle_triton( key, value, diff --git a/vllm/v1/attention/backends/rocm_aiter_unified_attn.py b/vllm/v1/attention/backends/rocm_aiter_unified_attn.py index 984fc20ecaff..57b64cc93df7 100644 --- a/vllm/v1/attention/backends/rocm_aiter_unified_attn.py +++ b/vllm/v1/attention/backends/rocm_aiter_unified_attn.py @@ -2,10 +2,13 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Attention layer with PagedAttention and Triton prefix prefill.""" +from typing import ClassVar + import torch from vllm import _custom_ops as ops from vllm._aiter_ops import rocm_aiter_ops +from vllm.config.cache import CacheDType from vllm.logger import init_logger from vllm.model_executor.layers.quantization.utils.quant_utils import ( QuantKey, @@ -24,6 +27,16 @@ class RocmAiterUnifiedAttentionBackend(RocmAttentionBackend): + supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16] + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [ + "auto", + "float16", + "bfloat16", + "fp8", + "fp8_e4m3", + "fp8_e5m2", + ] + @staticmethod def get_supported_kernel_block_sizes() -> list[int | MultipleOf]: return [MultipleOf(16)] @@ -137,26 +150,10 @@ def __init__( def _split_kv_cache( self, kv_cache: torch.Tensor ) -> tuple[torch.Tensor, torch.Tensor]: - if self.attn_type != AttentionType.ENCODER_DECODER: - return kv_cache.unbind(1) - - # NOTE: Encoder-decoder layers can share the same raw KV allocation with - # ROCM_ATTN decoder layers, whose physical layout is K/V first. Keep - # this cross-attention path on that physical layout so block IDs do not - # alias different bytes across the shared allocation. - num_blocks, _, block_size, num_kv_heads, head_size = kv_cache.shape - block_stride = block_size * num_kv_heads * head_size - kv_cache = kv_cache.as_strided( - (2, num_blocks, block_size, num_kv_heads, head_size), - ( - num_blocks * block_stride, - block_stride, - num_kv_heads * head_size, - head_size, - 1, - ), - ) - return kv_cache.unbind(0) + # Blocks-first ``(num_blocks, 2, ...)``. The model runner normalizes any + # shared decoder/cross-attention allocation to this layout, so no + # per-backend restriding is needed here. + return kv_cache.unbind(1) def forward( self, @@ -231,27 +228,58 @@ def forward( max_seqlen_k = attn_metadata.max_seq_len block_table = attn_metadata.block_table - self.unified_attention( - q=query[:num_actual_tokens], - k=key_cache, - v=value_cache, - out=output[:num_actual_tokens], - cu_seqlens_q=cu_seqlens_q, - max_seqlen_q=max_seqlen_q, - seqused_k=seqused_k, - max_seqlen_k=max_seqlen_k, - softmax_scale=softmax_scale, - causal=True, - alibi_slopes=self.alibi_slopes, - window_size=self.sliding_window, - block_table=block_table, - softcap=self.logits_soft_cap, - q_descale=layer._q_scale if query.dtype == self.fp8_dtype else None, - k_descale=layer._k_scale, - v_descale=layer._v_scale, - sinks=self.sinks, - output_scale=output_scale, - ) + if attn_metadata.causal: + self.unified_attention( + q=query[:num_actual_tokens], + k=key_cache, + v=value_cache, + out=output[:num_actual_tokens], + cu_seqlens_q=cu_seqlens_q, + max_seqlen_q=max_seqlen_q, + seqused_k=seqused_k, + max_seqlen_k=max_seqlen_k, + softmax_scale=softmax_scale, + causal=True, + alibi_slopes=self.alibi_slopes, + window_size=self.sliding_window, + block_table=block_table, + softcap=self.logits_soft_cap, + q_descale=layer._q_scale if query.dtype == self.fp8_dtype else None, + k_descale=layer._k_scale, + v_descale=layer._v_scale, + sinks=self.sinks, + output_scale=output_scale, + ) + else: + # The aiter kernel is causal-only. Non-causal cross-attention + # (ENCODER_DECODER, e.g. Whisper) falls back to the vLLM Triton + # unified kernel, which shares this layout and honors the flag. + from vllm.v1.attention.ops.triton_unified_attention import ( + unified_attention as triton_unified_attention, + ) + + descale_shape = (cu_seqlens_q.shape[0] - 1, key_cache.shape[2]) + triton_unified_attention( + q=query[:num_actual_tokens], + k=key_cache, + v=value_cache, + out=output[:num_actual_tokens], + cu_seqlens_q=cu_seqlens_q, + max_seqlen_q=max_seqlen_q, + seqused_k=seqused_k, + max_seqlen_k=max_seqlen_k, + softmax_scale=softmax_scale, + causal=attn_metadata.causal, + alibi_slopes=self.alibi_slopes, + window_size=self.sliding_window, + block_table=block_table, + softcap=self.logits_soft_cap, + q_descale=layer._q_scale if query.dtype == self.fp8_dtype else None, + k_descale=layer._k_scale.expand(descale_shape), + v_descale=layer._v_scale.expand(descale_shape), + sinks=self.sinks, + output_scale=output_scale, + ) return output diff --git a/vllm/v1/attention/backends/rocm_attn.py b/vllm/v1/attention/backends/rocm_attn.py index 2f6c48e3df34..24f65ab31ad0 100644 --- a/vllm/v1/attention/backends/rocm_attn.py +++ b/vllm/v1/attention/backends/rocm_attn.py @@ -288,6 +288,8 @@ def __init__( self.alibi_slopes = alibi_slopes if sliding_window is None: self.sliding_window = (-1, -1) + elif attn_type in (AttentionType.ENCODER, AttentionType.ENCODER_ONLY): + self.sliding_window = (sliding_window - 1, sliding_window - 1) else: self.sliding_window = (sliding_window - 1, 0) self.kv_cache_dtype = kv_cache_dtype @@ -354,6 +356,7 @@ def _forward_encoder_attention( softmax_scale=self.scale, sliding_window_q=self.sliding_window[0], sliding_window_k=self.sliding_window[1], + sinks=self.sinks, ) return output @@ -421,9 +424,13 @@ def forward( if is_quantized_kv_cache(self.kv_cache_dtype): key_cache = key_cache.view(self.fp8_dtype) value_cache = value_cache.view(self.fp8_dtype) - assert layer._q_scale_float == 1.0, ( - "A non 1.0 q_scale is not currently supported." - ) + # q_scale only applies to an fp8 query; this path keeps the query + # in full precision, so a non-1.0 q_scale is not applicable here. + if query.dtype == self.fp8_dtype and layer._q_scale_float != 1.0: + raise NotImplementedError( + "A non 1.0 q_scale with an fp8 query is not currently " + "supported by RocmAttentionImpl." + ) cu_seqlens_q = attn_metadata.query_start_loc seqused_k = attn_metadata.seq_lens diff --git a/vllm/v1/attention/backends/triton_attn.py b/vllm/v1/attention/backends/triton_attn.py index 716d56e8176a..7b4a652939ae 100644 --- a/vllm/v1/attention/backends/triton_attn.py +++ b/vllm/v1/attention/backends/triton_attn.py @@ -19,7 +19,7 @@ from vllm.platforms import current_platform from vllm.platforms.interface import DeviceCapability from vllm.utils.math_utils import next_power_of_2 -from vllm.utils.torch_utils import async_tensor_h2d, is_quantized_kv_cache +from vllm.utils.torch_utils import is_quantized_kv_cache from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -30,7 +30,11 @@ CommonAttentionMetadata, MultipleOf, ) -from vllm.v1.attention.backends.utils import get_kv_cache_layout +from vllm.v1.attention.backends.utils import ( + compute_mm_prefix_range_tensor, + get_kv_cache_layout, + get_num_attention_heads_from_layers, +) from vllm.v1.attention.ops.triton_prefill_attention import context_attention_fwd from vllm.v1.attention.ops.triton_reshape_and_cache_flash import ( triton_reshape_and_cache_flash, @@ -76,6 +80,8 @@ class TritonAttentionMetadata: softmax_segm_max: torch.Tensor softmax_segm_expsum: torch.Tensor + causal: bool | torch.Tensor + # For cascade attention. use_cascade: bool common_prefix_len: int @@ -88,40 +94,8 @@ class TritonAttentionMetadata: prefix_scheduler_metadata: torch.Tensor | None = None mm_prefix_range: dict[int, list[tuple[int, int]]] | None = None mm_prefix_range_tensor: torch.Tensor | None = None - - @staticmethod - def compute_mm_prefix_range_tensor( - mm_prefix_range: dict[int, list[tuple[int, int]]] | None, - num_seqs: int, - device: torch.device, - ) -> torch.Tensor | None: - """Convert mm_prefix_range dict to padded tensor for Triton kernel. - - Returns shape: (num_seqs, max_ranges, 2) with 0-padding for empty ranges. - Empty ranges have start==end==0, which kernel skips via is_valid check. - """ - if mm_prefix_range is None: - return None - - # Collect ranges, using [(0,0)] for empty sequences to ensure uniform dims - range_lists = [ - mm_prefix_range.get(i, [(0, 0)]) or [(0, 0)] for i in range(num_seqs) - ] - - # Return None if all ranges are trivial (only (0,0) placeholders) - if all(r == [(0, 0)] for r in range_lists): - return None - - # Build on CPU first then move to GPU in a single H2D transfer - max_ranges = max(len(r) for r in range_lists) - # Pad all sequences to the same number of ranges - padded = [] - for r in range_lists: - padded_r = list(r) + [(0, 0)] * (max_ranges - len(r)) - padded.append(padded_r) - # Build on pinned CPU memory so the H2D transfer is non-blocking. - padded = async_tensor_h2d(padded, dtype=torch.int32, device=device) - return padded.view(num_seqs, max_ranges, 2) + rswa_prefix_lens: torch.Tensor | None = None + rswa_window: int | None = None class TritonAttentionMetadataBuilder(AttentionMetadataBuilder[TritonAttentionMetadata]): @@ -139,9 +113,10 @@ def __init__( self.block_size = kv_cache_spec.block_size model_config = vllm_config.model_config - self.num_heads_q = model_config.get_num_attention_heads( - vllm_config.parallel_config - ) + # Compatible with models with non-uniform per-layer head counts. + self.num_heads_q = get_num_attention_heads_from_layers( + vllm_config, layer_names + ) or model_config.get_num_attention_heads(vllm_config.parallel_config) self.num_heads_kv = model_config.get_num_kv_heads(vllm_config.parallel_config) self.headdim = model_config.get_head_size() @@ -198,6 +173,14 @@ def __init__( dtype=torch.float32, device=device, ) + self.rswa_window = model_config.rswa_window + self.persistent_rswa_prefix_lens: torch.Tensor | None = None + if self.rswa_window is not None: + self.persistent_rswa_prefix_lens = torch.empty( + vllm_config.scheduler_config.max_num_seqs, + dtype=torch.int32, + device=device, + ) def build_for_cudagraph_capture( self, common_attn_metadata: CommonAttentionMetadata @@ -215,6 +198,7 @@ def build( common_attn_metadata: CommonAttentionMetadata, fast_build: bool = False, ) -> TritonAttentionMetadata: + num_reqs = common_attn_metadata.num_reqs num_actual_tokens = common_attn_metadata.num_actual_tokens max_query_len = common_attn_metadata.max_query_len @@ -249,6 +233,7 @@ def build( seq_lens=seq_lens, block_table=block_table_tensor, slot_mapping=slot_mapping, + causal=common_attn_metadata.causal, use_cascade=use_cascade, common_prefix_len=common_prefix_len, cu_prefix_query_lens=cu_prefix_query_lens, @@ -261,6 +246,25 @@ def build( softmax_segm_max=self.softmax_segm_max, softmax_segm_expsum=self.softmax_segm_expsum, ) + + mm_ranges = common_attn_metadata.mm_req_doc_ranges + if mm_ranges is not None: + attn_metadata.mm_prefix_range = mm_ranges + attn_metadata.mm_prefix_range_tensor = compute_mm_prefix_range_tensor( + mm_ranges, num_reqs, seq_lens.device + ) + + rswa_prefix_lens = common_attn_metadata.rswa_prefix_lens + if self.rswa_window is not None and rswa_prefix_lens is not None: + assert self.persistent_rswa_prefix_lens is not None + rswa_prefix_lens = rswa_prefix_lens.to( + device=self.device, dtype=torch.int32, non_blocking=True + ) + persistent_prefix_lens = self.persistent_rswa_prefix_lens[:num_reqs] + persistent_prefix_lens.copy_(rswa_prefix_lens[:num_reqs]) + attn_metadata.rswa_prefix_lens = persistent_prefix_lens + attn_metadata.rswa_window = self.rswa_window + return attn_metadata @@ -277,6 +281,7 @@ class TritonAttentionBackend(AttentionBackend): "fp8", "fp8_e4m3", "fp8_e5m2", + "int4_per_token_head", "int8_per_token_head", "fp8_per_token_head", ] @@ -293,6 +298,10 @@ def supports_block_size(cls, block_size: int | None) -> bool: forward_includes_kv_cache_update: bool = False + @classmethod + def supports_non_causal(cls) -> bool: + return True + @staticmethod def get_name() -> str: return "TRITON_ATTN" @@ -316,9 +325,11 @@ def get_kv_cache_shape( if block_size % 16 != 0: raise ValueError("Block size must be a multiple of 16.") if kv_cache_uses_per_token_head_scales(cache_dtype_str): - # Pad head_size by sizeof(float32)/sizeof(cache_dtype) so - # the per-head scale fits inline. The backend extracts - # data[:head_size] and scale[head_size:] via typed views. + # Pad the head dim by sizeof(float32)/sizeof(cache_dtype) so the + # per-(token, head) scale fits inline after the quantized data; + # the backend extracts data[:head_size] and scale[head_size:] via + # typed views (see _ensure_scale_caches). INT4 packs two values + # per byte, so the data occupies only head_size // 2 bytes. from vllm.utils.torch_utils import ( STR_DTYPE_TO_TORCH_DTYPE, get_dtype_size, @@ -326,7 +337,11 @@ def get_kv_cache_shape( cache_dtype = STR_DTYPE_TO_TORCH_DTYPE[cache_dtype_str] scale_pad = get_dtype_size(torch.float32) // get_dtype_size(cache_dtype) - return (num_blocks, 2, block_size, num_kv_heads, head_size + scale_pad) + if get_kv_quant_mode(cache_dtype_str) == KVQuantMode.INT4_PER_TOKEN_HEAD: + data_head_size = head_size // 2 + else: + data_head_size = head_size + return (num_blocks, 2, block_size, num_kv_heads, data_head_size + scale_pad) return (num_blocks, 2, block_size, num_kv_heads, head_size) @staticmethod @@ -479,6 +494,29 @@ def __init__( else: self.sliding_window = (sliding_window - 1, 0) self.kv_cache_dtype = kv_cache_dtype + if current_platform.is_cuda(): + cap = current_platform.get_device_capability() + cap_str = cap.as_version_str() if cap is not None else "unknown" + dev = current_platform.get_device_name() + if self.kv_cache_dtype.startswith("fp8") and not ( + current_platform.has_device_capability(89) + ): + suggested = ( + "float16" if (cap is None or cap.to_int() < 80) else "bfloat16" + ) + raise ValueError( + f"FP8 KV cache is not supported by the Triton attention backend " + f"on {dev} (compute capability {cap_str}); native FP8 (fp8e4nv) " + f"requires SM89+. Re-run with --kv-cache-dtype {suggested}." + ) + if self.kv_cache_dtype == "bfloat16" and not ( + current_platform.has_device_capability(80) + ): + raise ValueError( + f"bfloat16 KV cache is not supported on {dev} (compute capability " + f"{cap_str}); bfloat16 requires SM80+. Re-run with " + f"--kv-cache-dtype float16." + ) if logits_soft_cap is None: # In flash-attn, setting logits_soft_cap as 0 means no soft cap. logits_soft_cap = 0 @@ -579,18 +617,14 @@ def forward( layer, ) - # Per-token-head quantized KV cache: use separate scale caches. + # Per-token-head quantized KV cache: handled by the core unified + # kernel, which dequantizes per-(token, head) inline via constexpr + # branches (INT8 / FP8) and dispatches to the packed INT4 kernel. if self._is_per_token_head_quant: - self._ensure_scale_caches(kv_cache) - key_cache, value_cache = kv_cache.unbind(1) - if key_cache.dtype == torch.uint8: - key_cache = key_cache.view(self.fp8_dtype) - value_cache = value_cache.view(self.fp8_dtype) - q_descale = None - k_descale = None - v_descale = None + key_cache, value_cache = self._pth_key_value_caches(kv_cache) k_scale_cache = self._k_scale_cache v_scale_cache = self._v_scale_cache + q_descale = k_descale = v_descale = None # FP8 per-tensor / auto path (original flow). else: key_cache, value_cache = kv_cache.unbind(1) @@ -641,7 +675,7 @@ def forward( seqused_k=seqused_k, max_seqlen_k=max_seqlen_k, softmax_scale=self.scale, - causal=True, + causal=attn_metadata.causal, alibi_slopes=self.alibi_slopes, use_alibi_sqrt=self.use_alibi_sqrt, window_size=self.sliding_window, @@ -658,15 +692,31 @@ def forward( sinks=self.sinks, output_scale=output_scale, mm_prefix_range=mm_prefix_range_tensor, + rswa_prefix_lens=attn_metadata.rswa_prefix_lens, + rswa_window=attn_metadata.rswa_window, kv_quant_mode=self._kv_quant_mode, k_scale_cache=k_scale_cache, v_scale_cache=v_scale_cache, chunk_lookback=self.chunk_lookback, use_td=self.use_td, + mm_prefix_clamp_sliding_window=getattr( + layer, "mm_prefix_clamp_sliding_window", False + ), ) return output + def _pth_key_value_caches( + self, kv_cache: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """Per-token-head K/V cache views (ensures scale caches; FP8 retyped).""" + self._ensure_scale_caches(kv_cache) + key_cache, value_cache = kv_cache.unbind(1) + if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD: + key_cache = key_cache.view(self.fp8_dtype) + value_cache = value_cache.view(self.fp8_dtype) + return key_cache, value_cache + def _forward_encoder_attention( self, query: torch.Tensor, @@ -710,6 +760,7 @@ def _forward_encoder_attention( softmax_scale=self.scale, sliding_window_q=self.sliding_window[0], sliding_window_k=self.sliding_window[1], + sinks=self.sinks, ) return output @@ -729,7 +780,9 @@ def do_kv_cache_update( if self._is_per_token_head_quant: self._ensure_scale_caches(kv_cache) key_cache, value_cache = kv_cache.unbind(1) - if key_cache.dtype == torch.uint8: + k_scale_cache = self._k_scale_cache + v_scale_cache = self._v_scale_cache + if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD: key_cache = key_cache.view(self.fp8_dtype) value_cache = value_cache.view(self.fp8_dtype) triton_reshape_and_cache_flash_per_token_head_quant( @@ -737,9 +790,10 @@ def do_kv_cache_update( value, key_cache, value_cache, - self._k_scale_cache, - self._v_scale_cache, + k_scale_cache, + v_scale_cache, slot_mapping, + kv_quant_mode=self._kv_quant_mode, ) return # For decoder and cross-attention, use KV cache as before. diff --git a/vllm/v1/attention/backends/triton_attn_diffkv.py b/vllm/v1/attention/backends/triton_attn_diffkv.py new file mode 100644 index 000000000000..3420a0eba477 --- /dev/null +++ b/vllm/v1/attention/backends/triton_attn_diffkv.py @@ -0,0 +1,261 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Triton attention backend with different K/V head dimensions (DiffKV). + +The KV cache layout is identical to ``FlashAttentionDiffKVBackend`` — K +and V are packed along the last dim: + + [num_blocks, block_size, num_kv_heads, head_size_qk + head_size_v] + +so existing helpers (``triton_reshape_and_cache_flash_diffkv``) are reused. +""" + +from typing import ClassVar + +import torch + +from vllm.config import VllmConfig +from vllm.config.cache import CacheDType +from vllm.logger import init_logger +from vllm.utils.math_utils import next_power_of_2 +from vllm.utils.torch_utils import is_quantized_kv_cache +from vllm.v1.attention.backend import AttentionLayer, AttentionType +from vllm.v1.attention.backends.triton_attn import ( + TritonAttentionBackend, + TritonAttentionImpl, + TritonAttentionMetadata, + TritonAttentionMetadataBuilder, +) +from vllm.v1.attention.backends.utils import get_kv_cache_layout +from vllm.v1.attention.ops.triton_reshape_and_cache_flash import ( + triton_reshape_and_cache_flash_diffkv, +) +from vllm.v1.attention.ops.triton_unified_attention_diffkv import ( + unified_attention_diffkv, +) +from vllm.v1.kv_cache_interface import AttentionSpec + +logger = init_logger(__name__) + + +class TritonAttentionDiffKVMetadataBuilder(TritonAttentionMetadataBuilder): + """Override the parent's softmax buffer last-dim to head_size_v. + + The parent allocates ``softmax_segm_output`` with last-dim sized to + ``next_power_of_2(head_size)`` (== Q/K head size). For DiffKV the + accumulator and per-segment partial outputs are V-shaped, so we + re-allocate with ``next_power_of_2(head_size_v)`` instead. + """ + + def __init__( + self, + kv_cache_spec: AttentionSpec, + layer_names: list[str], + vllm_config: VllmConfig, + device: torch.device, + ): + super().__init__(kv_cache_spec, layer_names, vllm_config, device) + + head_size_v = TritonAttentionDiffKVBackend.head_size_v + head_size_v_padded = next_power_of_2(head_size_v) + self.softmax_segm_output = torch.empty( + ( + self.seq_threshold_3D, + self.num_heads_q, + self.num_par_softmax_segments, + head_size_v_padded, + ), + dtype=torch.float32, + device=device, + ) + + +class TritonAttentionDiffKVBackend(TritonAttentionBackend): + # V head dim — set per layer via ``set_head_size_v`` before instantiation. + head_size_v: int = 128 + + # No FP8 / int8 KV cache for the DiffKV path yet; require fp16/bf16/fp32. + supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [ + "auto", + "bfloat16", + ] + + @classmethod + def set_head_size_v(cls, head_size_v: int) -> None: + cls.head_size_v = head_size_v + + @staticmethod + def get_name() -> str: + return "TRITON_ATTN_DIFFKV" + + @staticmethod + def get_impl_cls() -> type["TritonAttentionDiffKVImpl"]: + return TritonAttentionDiffKVImpl + + @staticmethod + def get_builder_cls() -> type["TritonAttentionDiffKVMetadataBuilder"]: + return TritonAttentionDiffKVMetadataBuilder + + @staticmethod + def get_kv_cache_shape( + num_blocks: int, + block_size: int, + num_kv_heads: int, + head_size: int, + cache_dtype_str: str = "auto", + ) -> tuple[int, ...]: + if block_size % 16 != 0: + raise ValueError("Block size must be a multiple of 16.") + return ( + num_blocks, + block_size, + num_kv_heads, + head_size + TritonAttentionDiffKVBackend.head_size_v, + ) + + @staticmethod + def get_kv_cache_stride_order( + include_num_layers_dimension: bool = False, + ) -> tuple[int, ...]: + cache_layout = get_kv_cache_layout() + if cache_layout == "NHD" and include_num_layers_dimension: + # (num_blocks, num_layers, block_size, + # num_kv_heads, head_size + head_size_v) + return (1, 0, 2, 3, 4) + elif cache_layout == "NHD": + return (0, 1, 2, 3) + elif cache_layout == "HND" and include_num_layers_dimension: + # (num_blocks, num_kv_heads, num_layers, + # block_size, head_size + head_size_v) + return (1, 3, 0, 2, 4) + elif cache_layout == "HND": + return (0, 2, 1, 3) + else: + raise ValueError(f"Unknown cache layout format {cache_layout}.") + + @classmethod + def supports_head_size(cls, head_size: int) -> bool: + # DiffKV K head sizes (e.g. 192 for MiMo-V2.5) need to be allowed. + return head_size >= 32 + + @classmethod + def supports_attn_type(cls, attn_type: str) -> bool: + # DiffKV only implements decoder self-attention. Unlike the parent + # TritonAttentionBackend (which advertises all types), encoder + # attention is not supported, so gate it here at backend selection. + return attn_type == AttentionType.DECODER + + +class TritonAttentionDiffKVImpl(TritonAttentionImpl): + """Triton attention impl for the DiffKV packed KV cache layout.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + if is_quantized_kv_cache(self.kv_cache_dtype): + raise NotImplementedError( + "TritonAttentionDiffKVBackend does not yet support quantized " + f"KV cache (got kv_cache_dtype={self.kv_cache_dtype!r})." + ) + if self._is_per_token_head_quant: + raise NotImplementedError( + "TritonAttentionDiffKVBackend does not support per-token-head " + "quantization." + ) + if self.chunk_lookback > -1: + raise NotImplementedError( + "TritonAttentionDiffKVBackend does not support chunked " + "attention with lookback." + ) + + def do_kv_cache_update( + self, + layer: AttentionLayer, + key: torch.Tensor, + value: torch.Tensor, + kv_cache: torch.Tensor, + slot_mapping: torch.Tensor, + ) -> None: + # Cache is packed [..., head_size_qk + head_size_v]; the diffkv + # reshape kernel writes K to [..., :head_size_qk] and V to + # [..., head_size_qk:hqk+hv]. + triton_reshape_and_cache_flash_diffkv( + key, + value, + kv_cache, + slot_mapping, + self.kv_cache_dtype, + layer._k_scale, + layer._v_scale, + ) + + def fused_rope_kvcache_supported(self): + # The fused rope+cache path assumes the standard 2-tensor layout. + return False + + def forward( + self, + layer: torch.nn.Module, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + kv_cache: torch.Tensor, + attn_metadata: TritonAttentionMetadata, + output: torch.Tensor, + output_scale: torch.Tensor | None = None, + output_block_scale: torch.Tensor | None = None, + ) -> torch.Tensor: + """Forward pass. + + Shapes: + query: [num_tokens, num_heads, head_size_qk] + key: [num_tokens, num_kv_heads, head_size_qk] + value: [num_tokens, num_kv_heads, head_size_v] + kv_cache: [num_blocks, block_size, num_kv_heads, + head_size_qk + head_size_v] + output: [num_tokens, num_heads, head_size_v] + """ + if output_scale is not None or output_block_scale is not None: + raise NotImplementedError( + "fused output quantization is not supported for " + "TritonAttentionDiffKVImpl" + ) + + if attn_metadata is None: + return output.fill_(0) + + assert attn_metadata.use_cascade is False, ( + "Cascade attention not supported for TritonAttentionDiffKVImpl" + ) + + num_actual_tokens = attn_metadata.num_actual_tokens + head_size_qk = self.head_size + head_size_v = TritonAttentionDiffKVBackend.head_size_v + + # Slice the packed cache into K / V views. Strides on dims 0/1/2 + # match the original cache; dim 3 stays contiguous (stride 1). + key_cache = kv_cache[..., :head_size_qk] + value_cache = kv_cache[..., head_size_qk : head_size_qk + head_size_v] + + unified_attention_diffkv( + q=query[:num_actual_tokens], + k=key_cache, + v=value_cache, + out=output[:num_actual_tokens], + cu_seqlens_q=attn_metadata.query_start_loc, + seqused_k=attn_metadata.seq_lens, + softmax_scale=self.scale, + causal=True, + alibi_slopes=self.alibi_slopes, + use_alibi_sqrt=self.use_alibi_sqrt, + window_size=self.sliding_window, + block_table=attn_metadata.block_table, + softcap=self.logits_soft_cap, + sinks=self.sinks, + max_seqlen_q=attn_metadata.max_query_len, + seq_threshold_3D=attn_metadata.seq_threshold_3D, + num_par_softmax_segments=attn_metadata.num_par_softmax_segments, + softmax_segm_output=attn_metadata.softmax_segm_output, + softmax_segm_max=attn_metadata.softmax_segm_max, + softmax_segm_expsum=attn_metadata.softmax_segm_expsum, + ) + return output diff --git a/vllm/v1/attention/backends/turboquant_attn.py b/vllm/v1/attention/backends/turboquant_attn.py index 3bf3b6b82482..af4ab007a8be 100644 --- a/vllm/v1/attention/backends/turboquant_attn.py +++ b/vllm/v1/attention/backends/turboquant_attn.py @@ -30,6 +30,7 @@ get_centroids, ) from vllm.triton_utils import triton +from vllm.utils.math_utils import round_up from vllm.v1.attention.backend import ( AttentionBackend, AttentionCGSupport, @@ -201,6 +202,44 @@ class TurboQuantMetadataBuilder(AttentionMetadataBuilder[TurboQuantMetadata]): def __init__(self, kv_cache_spec, layer_names, vllm_config, device): super().__init__(kv_cache_spec, layer_names, vllm_config, device) self._init_reorder_batch_threshold(1, supports_spec_as_decode=False) + self._reserve_workspace() + + def _reserve_workspace(self) -> None: + if not is_workspace_manager_initialized(): + return + + scheduler_config = self.vllm_config.scheduler_config + model_config = self.vllm_config.model_config + parallel_config = self.vllm_config.parallel_config + + max_num_reqs = scheduler_config.max_num_seqs + num_heads = model_config.get_num_attention_heads(parallel_config) + num_kv_heads = self.kv_cache_spec.num_kv_heads + head_size = self.kv_cache_spec.head_size + max_num_splits = ( + self.vllm_config.attention_config.tq_max_kv_splits_for_cuda_graph + ) + + current_workspace_manager().get_simultaneous( + ((max_num_reqs, num_heads, max_num_splits, head_size + 1), torch.float32), + ((max_num_reqs, num_heads, head_size), model_config.dtype), + ((max_num_reqs, num_heads), torch.float32), + ) + + reserve_continuation_prefill = ( + scheduler_config.enable_chunked_prefill + and scheduler_config.max_num_batched_tokens > _CONTINUATION_DECODE_THRESHOLD + ) + if not reserve_continuation_prefill: + return + + max_cached_len = max(0, model_config.max_model_len - 1) + alloc_len = round_up(max_cached_len, self.kv_cache_spec.block_size) + cache_buf_shape = (1, num_kv_heads, alloc_len, head_size) + current_workspace_manager().get_simultaneous( + (cache_buf_shape, torch.float16), + (cache_buf_shape, torch.float16), + ) def build_for_cudagraph_capture( self, common_attn_metadata: CommonAttentionMetadata diff --git a/vllm/v1/attention/backends/utils.py b/vllm/v1/attention/backends/utils.py index d09c01eb9059..1e12f43caacb 100644 --- a/vllm/v1/attention/backends/utils.py +++ b/vllm/v1/attention/backends/utils.py @@ -17,6 +17,7 @@ from vllm.config import VllmConfig, get_layers_from_vllm_config from vllm.utils.math_utils import cdiv +from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d, np_to_pinned_tensor from vllm.v1.kv_cache_interface import KVCacheSpec, MambaSpec if TYPE_CHECKING: @@ -45,6 +46,35 @@ NULL_BLOCK_ID = 0 +def compute_mm_prefix_range_tensor( + mm_prefix_range: dict[int, list[tuple[int, int]]] | None, + num_seqs: int, + device: torch.device, +) -> torch.Tensor | None: + """Convert mm_prefix_range dict to padded tensor for Triton kernel. + + Returns shape: (num_seqs, max_ranges, 2) with 0-padding for empty ranges. + Empty ranges have start==end==0, which kernel skips via is_valid check. + """ + if mm_prefix_range is None: + return None + + range_lists = [ + mm_prefix_range.get(i, [(0, 0)]) or [(0, 0)] for i in range(num_seqs) + ] + + if all(r == [(0, 0)] for r in range_lists): + return None + + max_ranges = max(len(r) for r in range_lists) + padded = [] + for r in range_lists: + padded_r = list(r) + [(0, 0)] * (max_ranges - len(r)) + padded.append(padded_r) + padded = async_tensor_h2d(padded, dtype=torch.int32, device=device) + return padded.view(num_seqs, max_ranges, 2) + + def is_valid_kv_cache_layout(value: str) -> bool: return value in get_args(KVCacheLayoutType) @@ -136,6 +166,32 @@ def get_per_layer_parameters( return per_layer_params +def get_num_attention_heads_from_layers( + vllm_config: VllmConfig, layer_names: list[str] +) -> int | None: + """Per-TP-rank ``num_heads`` shared by the named Attention layers. + + Use in metadata builders whose plan-time allocations depend on the + head count: the model-wide ``get_num_attention_heads()`` is wrong + for models with non-uniform per-layer head counts. All layers in + one attention group must agree on ``num_heads``; this is asserted. + Returns ``None`` when no matching Attention layer is found. + """ + attn_layers = get_layers_from_vllm_config( + vllm_config, + AttentionLayerBase, # type: ignore[type-abstract] + layer_names, + ) + if not attn_layers: + return None + heads = {layer.impl.num_heads for layer in attn_layers.values()} + assert len(heads) == 1, ( + f"All layers in one attention group must share num_heads; " + f"got {heads} for {layer_names}." + ) + return heads.pop() + + def infer_global_hyperparameters( per_layer_params: dict[str, PerLayerParameters], ) -> PerLayerParameters: @@ -334,8 +390,8 @@ def make_local_attention_virtual_batches( # tensor first, which recovers perf. # Upload the index tensors to the block_table's device up-front so that the # fancy indexing below doesn't implicitly force a synchronous H2D copy. - batch_indices_torch = torch.from_numpy(batch_indices).to(device, non_blocking=True) - block_indices_torch = torch.from_numpy(block_indices).to(device, non_blocking=True) + batch_indices_torch = async_tensor_h2d(batch_indices, device=device) + block_indices_torch = async_tensor_h2d(block_indices, device=device) # Save as a lambda so we can return this for update_block_table make_block_table = lambda block_table: block_table[ @@ -349,8 +405,8 @@ def make_local_attention_virtual_batches( return CommonAttentionMetadata( query_start_loc_cpu=query_start_loc_cpu, - query_start_loc=query_start_loc_cpu.to(device=device, non_blocking=True), - seq_lens=seq_lens_cpu.to(device=device, non_blocking=True), + query_start_loc=async_tensor_h2d(query_start_loc_cpu, device=device), + seq_lens=async_tensor_h2d(seq_lens_cpu, device=device), num_reqs=len(seq_lens_cpu), num_actual_tokens=common_attn_metadata.num_actual_tokens, max_query_len=seqlens_q_local.max(), @@ -458,15 +514,16 @@ def split_decodes_prefills_and_extends( num_reqs = common_attn_metadata.num_reqs num_tokens = common_attn_metadata.num_actual_tokens query_start_loc = common_attn_metadata.query_start_loc_cpu + + if max_query_len <= decode_threshold: + return num_reqs, 0, 0, num_tokens, 0, 0 + # Upper bound is exact for prefill rows; decode rows still satisfy # seq_len > query_len under the optimistic bound, so `seq_lens == # query_lens` identifies prefills correctly either way. assert common_attn_metadata.seq_lens_cpu_upper_bound is not None seq_lens = common_attn_metadata.seq_lens_cpu_upper_bound - if max_query_len <= decode_threshold: - return num_reqs, 0, 0, num_tokens, 0, 0 - query_lens = query_start_loc[1:] - query_start_loc[:-1] is_prefill_or_extend = query_lens > decode_threshold is_prefill = (seq_lens == query_lens) & is_prefill_or_extend @@ -777,14 +834,12 @@ def __init__(self, metadata, common_attn_metadata): def compute_causal_conv1d_metadata( - query_start_loc_p_cpu: torch.Tensor, - *, - device: torch.device, -): + query_start_loc_p_cpu: torch.Tensor, *, device: torch.device +) -> tuple[dict[int, dict[str, Any]], torch.Tensor, torch.Tensor]: # Needed for causal_conv1d. Use the CPU query_start_loc to avoid DtoH sync. assert query_start_loc_p_cpu.device.type == "cpu" seqlens = query_start_loc_p_cpu.diff() - nums_dict = {} # type: ignore + nums_dict: dict[int, dict[str, Any]] = {} batch_ptr = None token_chunk_offset_ptr = None for BLOCK_M in [8]: # cover all BLOCK_M values @@ -792,7 +847,7 @@ def compute_causal_conv1d_metadata( nums_dict[BLOCK_M] = {} nums_dict[BLOCK_M]["nums"] = nums nums_dict[BLOCK_M]["tot"] = nums.sum().item() - mlist = torch.from_numpy(np.repeat(np.arange(len(nums)), nums)) + mlist = np_to_pinned_tensor(np.repeat(np.arange(len(nums)), nums)) nums_dict[BLOCK_M]["mlist"] = mlist mlist_len = len(nums_dict[BLOCK_M]["mlist"]) nums_dict[BLOCK_M]["mlist_len"] = mlist_len @@ -800,7 +855,7 @@ def compute_causal_conv1d_metadata( offsetlist = [] # type: ignore for idx, num in enumerate(nums): offsetlist.extend(range(num)) - offsetlist = torch.tensor(offsetlist, dtype=torch.int32) + offsetlist = torch.tensor(offsetlist, dtype=torch.int32, pin_memory=PIN_MEMORY) nums_dict[BLOCK_M]["offsetlist"] = offsetlist if batch_ptr is None: @@ -814,16 +869,15 @@ def compute_causal_conv1d_metadata( else: if batch_ptr.nelement() < MAX_NUM_PROGRAMS: batch_ptr.resize_(MAX_NUM_PROGRAMS).fill_(PAD_SLOT_ID) - token_chunk_offset_ptr.resize_( # type: ignore - MAX_NUM_PROGRAMS - ).fill_(PAD_SLOT_ID) + assert token_chunk_offset_ptr is not None + token_chunk_offset_ptr.resize_(MAX_NUM_PROGRAMS).fill_(PAD_SLOT_ID) + assert batch_ptr is not None batch_ptr[0:mlist_len].copy_(mlist, non_blocking=True) - token_chunk_offset_ptr[ # type: ignore - 0:mlist_len - ].copy_(offsetlist, non_blocking=True) + assert token_chunk_offset_ptr is not None + token_chunk_offset_ptr[0:mlist_len].copy_(offsetlist, non_blocking=True) nums_dict[BLOCK_M]["batch_ptr"] = batch_ptr - nums_dict[BLOCK_M]["token_chunk_offset_ptr"] = token_chunk_offset_ptr # type: ignore + nums_dict[BLOCK_M]["token_chunk_offset_ptr"] = token_chunk_offset_ptr return nums_dict, batch_ptr, token_chunk_offset_ptr @@ -838,20 +892,20 @@ def get_dcp_local_seq_lens( use this function to calculate split decode seq_lens of each dcp rank. Only consider dcp now, we can extend the case of cp based on this. """ - num_requests = seq_lens.size(0) + seq_lens_i32 = seq_lens.to(torch.int32) if dcp_rank is None: - rank_offsets = ( - torch.arange(dcp_size, dtype=torch.int32, device=seq_lens.device) - .unsqueeze(0) - .repeat(num_requests, 1) + rank_offsets = torch.arange( + dcp_size, + dtype=torch.int32, + device=seq_lens.device, + ).view( + *((1,) * seq_lens_i32.dim()), + dcp_size, ) + seq_lens_tiled = seq_lens_i32.unsqueeze(-1) else: - rank_offsets = torch.tensor( - [[dcp_rank]], dtype=torch.int32, device=seq_lens.device - ) - seq_lens_tiled = ( - seq_lens.to(torch.int32).unsqueeze(-1).repeat(1, rank_offsets.shape[1]) - ) + rank_offsets = torch.tensor(dcp_rank, dtype=torch.int32, device=seq_lens.device) + seq_lens_tiled = seq_lens_i32 base = ( seq_lens_tiled // cp_kv_cache_interleave_size @@ -865,7 +919,7 @@ def get_dcp_local_seq_lens( cp_kv_cache_interleave_size, ) dcp_local_seq_lens = base + remainder - return dcp_local_seq_lens.squeeze(1) + return dcp_local_seq_lens def mamba_get_block_table_tensor( diff --git a/vllm/v1/attention/ops/chunked_prefill_paged_decode.py b/vllm/v1/attention/ops/chunked_prefill_paged_decode.py index 77eb3ac60b1f..73d40a0a3331 100644 --- a/vllm/v1/attention/ops/chunked_prefill_paged_decode.py +++ b/vllm/v1/attention/ops/chunked_prefill_paged_decode.py @@ -156,6 +156,11 @@ def kernel_paged_attention_2d( # Supports non-contiguous mapping # from logical blocks to physical blocks abs_token_idx = start_n + offs_n + # Slots >= seq_len are unwritten KV cache and may hold NaN/garbage + # (e.g. the tail of the last partial block). They are score-masked + # below, but 0 * NaN = NaN would still poison the output, so exclude + # them from the K/V loads too. + kv_load_mask = abs_token_idx < seq_len l_block_idx = abs_token_idx // PHYSICAL_BLOCK_SIZE # Vectorized loading of physical block IDs p_block_idx = tl.load(block_tables_ptr + block_table_offset + l_block_idx) @@ -181,7 +186,7 @@ def kernel_paged_attention_2d( # K : (HEAD_SIZE, BLOCK_SIZE) K_load = tl.load( key_cache_ptr + k_offset, - mask=dim_mask[:, None], + mask=dim_mask[:, None] & kv_load_mask[None, :], other=0.0, eviction_policy="evict_last", ) @@ -194,7 +199,7 @@ def kernel_paged_attention_2d( # V : (BLOCK_SIZE, HEAD_SIZE) V_load = tl.load( value_cache_ptr + v_offset, - mask=dim_mask[None, :], + mask=dim_mask[None, :] & kv_load_mask[:, None], other=0.0, eviction_policy="evict_last", ) diff --git a/vllm/v1/attention/ops/common.py b/vllm/v1/attention/ops/common.py index 98abc7790ea2..901d6bb30bd8 100644 --- a/vllm/v1/attention/ops/common.py +++ b/vllm/v1/attention/ops/common.py @@ -90,6 +90,7 @@ def _correct_attn_cp_out_kernel( factor = tl.exp(lse_finally) if IS_BASE_E else tl.exp2(lse_finally) output = tl.load(outputs_ptr + output_offsets) output = output * factor + output = tl.where(factor == 0.0, 0.0, output) tl.store(new_output_ptr + output_offsets, output) diff --git a/vllm/v1/attention/ops/dcp_alltoall.py b/vllm/v1/attention/ops/dcp_alltoall.py index 1469a5c754d6..e100dbc79ee2 100644 --- a/vllm/v1/attention/ops/dcp_alltoall.py +++ b/vllm/v1/attention/ops/dcp_alltoall.py @@ -26,10 +26,6 @@ import torch.distributed as dist from vllm.triton_utils import tl, triton -from vllm.v1.worker.workspace import ( - current_workspace_manager, - is_workspace_manager_initialized, -) if TYPE_CHECKING: from vllm.distributed.parallel_state import GroupCoordinator @@ -117,13 +113,16 @@ def _dcp_a2a_send_recv_buffers( device: torch.device, dtype: torch.dtype, ) -> tuple[torch.Tensor, torch.Tensor]: - if is_workspace_manager_initialized(): - send_buffer, recv_buffer = current_workspace_manager().get_simultaneous( - (shape, dtype), - (shape, dtype), - ) - return send_buffer, recv_buffer - + # Don't use the shared WorkspaceManager here. A FULL cudagraph bakes in the + # buffer address at capture, but the workspace is growable and sized only to + # the largest *captured* batch (the cudagraph capture cap). Any eager a2a + # with a bigger batch regrows it, freeing that address and poisoning every + # captured graph -> illegal memory access on replay. This bites the very + # first request: the post-capture warmup runs an eager decode at + # max_num_seqs (> the cap), so the graphs are already dangling before the + # server is ready. torch.empty buffers instead live in the graph's private + # pool and stay valid for its lifetime (as _dcp_a2a_unpack_combine and the + # AG+RS combine path already rely on). return ( torch.empty(shape, device=device, dtype=dtype), torch.empty(shape, device=device, dtype=dtype), @@ -427,6 +426,10 @@ def dcp_a2a_lse_reduce( if H % world_size != 0: raise ValueError(f"H={H} must be divisible by DCP world size {world_size}.") H_per_rank = H // world_size + # The pack kernel bit-casts the LSE as fp32; some MLA backends return it in + # the activation dtype (bf16/fp16), so enforce the documented fp32 contract. + if cp_attn_lse.dtype != torch.float32: + cp_attn_lse = cp_attn_lse.to(torch.float32) lse_pack_dim = _dcp_a2a_lse_pack_dim(cp_attn_out.dtype) send_buffer, recv_buffer = _dcp_a2a_send_recv_buffers( diff --git a/vllm/v1/attention/ops/flashmla.py b/vllm/v1/attention/ops/flashmla.py index df04f5bf2289..c84a495859ba 100644 --- a/vllm/v1/attention/ops/flashmla.py +++ b/vllm/v1/attention/ops/flashmla.py @@ -73,7 +73,7 @@ def is_flashmla_sparse_supported() -> tuple[bool, str | None]: ): return ( False, - "FlashMLA Sparse is only supported on Hopper and Blackwell devices.", + "FlashMLA Sparse is only supported on Hopper and Blackwell DC devices.", ) return True, None diff --git a/vllm/v1/attention/ops/int4_per_token_head.py b/vllm/v1/attention/ops/int4_per_token_head.py new file mode 100644 index 000000000000..322d32bb81f2 --- /dev/null +++ b/vllm/v1/attention/ops/int4_per_token_head.py @@ -0,0 +1,1155 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Sub-byte packed (INT4) per-token-head KV cache mode. + +INT4 packs two 4-bit values per cache byte, pre-rotates with a single RHT, +and hides a 4-bit zero-point in the scale's low mantissa bits — too +different from the core kernel to share it. Owns the whole mode: nibble +pack/unpack, the reshape (write) kernel, the split-dot attention (read) +kernel, the RHT transform, and the public ``reshape_and_cache_int4`` / +``unified_attention_int4`` entry points. +""" + +from __future__ import annotations + +from typing import Any + +import torch + +from vllm.platforms import current_platform +from vllm.triton_utils import tl, triton +from vllm.v1.attention.ops.triton_attention_helpers import ( + apply_alibi_to_score, + apply_softcap, + cdiv_fn, + compute_kv_seq_mask, + compute_tile_loop_bounds, + init_softmax_M, + load_qq_bias_tile, + resolve_seq_and_query_len, + softmax_step, + store_segm_reduce_scalars, +) +from vllm.v1.attention.ops.triton_unified_attention import reduce_segments + +float8_info = torch.finfo(current_platform.fp8_dtype()) + +# 2 x int4 packed per storage byte. +_INT4_PACKING_FACTOR = 2 + + +# ---------------------------------------------------------------------- +# Nibble pack / unpack (shared write+read format) +# ---------------------------------------------------------------------- + + +@triton.jit +def pack_int4_nibbles(lo, hi): + """Pack two uint8 values (each in [0, 15]) into one byte.""" + return (lo & 0xF) | ((hi & 0xF) << 4) + + +@triton.jit +def unpack_int4_nibbles(packed): + """Split one packed byte into the (low, high) nibble pair as uint8.""" + return packed & 0xF, (packed >> 4) & 0xF + + +# ---------------------------------------------------------------------- +# Write path: RHT + pack + per-(token, head) scale +# ---------------------------------------------------------------------- + + +@triton.jit +def _reshape_cache_int4_kernel( + key_ptr, + value_ptr, + key_cache_ptr, + value_cache_ptr, + k_scale_cache_ptr, + v_scale_cache_ptr, + slot_mapping_ptr, + stride_key_tok: tl.int64, + stride_key_head: tl.int64, + stride_val_tok: tl.int64, + stride_val_head: tl.int64, + stride_kc_blk: tl.int64, + stride_kc_slot: tl.int64, + stride_kc_head: tl.int64, + stride_vc_blk: tl.int64, + stride_vc_slot: tl.int64, + stride_vc_head: tl.int64, + stride_ks_blk: tl.int64, + stride_ks_slot: tl.int64, + stride_ks_head: tl.int64, + stride_vs_blk: tl.int64, + stride_vs_slot: tl.int64, + stride_vs_head: tl.int64, + block_size: tl.constexpr, + head_size: tl.constexpr, + head_size_v: tl.constexpr, + PACKED_HEAD_PADDED: tl.constexpr, +): + """INT4 asymmetric quantization with zero-point steganography.""" + tok = tl.program_id(0) + head = tl.program_id(1) + + slot = tl.load(slot_mapping_ptr + tok).to(tl.int64) + if slot < 0: + return + + blk = slot // block_size + slot_in_blk = slot % block_size + + half_offs = tl.arange(0, PACKED_HEAD_PADDED) + even_offs = half_offs * 2 + odd_offs = half_offs * 2 + 1 + + half_k = head_size // 2 + even_k_mask = even_offs < head_size + odd_k_mask = odd_offs < head_size + key_base = key_ptr + tok * stride_key_tok + head * stride_key_head + + k_even = tl.load(key_base + even_offs, mask=even_k_mask, other=0.0).to(tl.float32) + k_odd = tl.load(key_base + odd_offs, mask=odd_k_mask, other=0.0).to(tl.float32) + + k_min = tl.minimum( + tl.min(tl.where(even_k_mask, k_even, float("inf"))), + tl.min(tl.where(odd_k_mask, k_odd, float("inf"))), + ) + k_max = tl.maximum( + tl.max(tl.where(even_k_mask, k_even, float("-inf"))), + tl.max(tl.where(odd_k_mask, k_odd, float("-inf"))), + ) + k_scale = tl.maximum((k_max - k_min) / 15.0, 1e-6) + k_zp_f = tl.clamp( + tl.where( + -k_min / k_scale >= 0, + (-k_min / k_scale + 0.5).to(tl.int32), + (-k_min / k_scale - 0.5).to(tl.int32), + ).to(tl.float32), + 0.0, + 15.0, + ) + + inv_k = 1.0 / k_scale + k_even_s = k_even * inv_k + k_zp_f + k_odd_s = k_odd * inv_k + k_zp_f + k_even_q = tl.clamp( + tl.where( + k_even_s >= 0, + (k_even_s + 0.5).to(tl.int32), + (k_even_s - 0.5).to(tl.int32), + ).to(tl.float32), + 0.0, + 15.0, + ) + k_odd_q = tl.clamp( + tl.where( + k_odd_s >= 0, + (k_odd_s + 0.5).to(tl.int32), + (k_odd_s - 0.5).to(tl.int32), + ).to(tl.float32), + 0.0, + 15.0, + ) + + k_zp_int = k_zp_f.to(tl.int32) + k_scale_bits = k_scale.to(tl.int32, bitcast=True) + k_scale_packed = ((k_scale_bits & -16) | (k_zp_int & 0xF)).to( + tl.float32, bitcast=True + ) + + tl.store( + k_scale_cache_ptr + + blk * stride_ks_blk + + slot_in_blk * stride_ks_slot + + head * stride_ks_head, + k_scale_packed, + ) + + k_packed = pack_int4_nibbles(k_even_q.to(tl.uint8), k_odd_q.to(tl.uint8)) + tl.store( + key_cache_ptr + + blk * stride_kc_blk + + slot_in_blk * stride_kc_slot + + head * stride_kc_head + + half_offs, + k_packed, + mask=half_offs < half_k, + ) + + half_v = head_size_v // 2 + even_v_mask = even_offs < head_size_v + odd_v_mask = odd_offs < head_size_v + val_base = value_ptr + tok * stride_val_tok + head * stride_val_head + + v_even = tl.load(val_base + even_offs, mask=even_v_mask, other=0.0).to(tl.float32) + v_odd = tl.load(val_base + odd_offs, mask=odd_v_mask, other=0.0).to(tl.float32) + + v_min = tl.minimum( + tl.min(tl.where(even_v_mask, v_even, float("inf"))), + tl.min(tl.where(odd_v_mask, v_odd, float("inf"))), + ) + v_max = tl.maximum( + tl.max(tl.where(even_v_mask, v_even, float("-inf"))), + tl.max(tl.where(odd_v_mask, v_odd, float("-inf"))), + ) + v_scale = tl.maximum((v_max - v_min) / 15.0, 1e-6) + v_zp_f = tl.clamp( + tl.where( + -v_min / v_scale >= 0, + (-v_min / v_scale + 0.5).to(tl.int32), + (-v_min / v_scale - 0.5).to(tl.int32), + ).to(tl.float32), + 0.0, + 15.0, + ) + + inv_v = 1.0 / v_scale + v_even_s = v_even * inv_v + v_zp_f + v_odd_s = v_odd * inv_v + v_zp_f + v_even_q = tl.clamp( + tl.where( + v_even_s >= 0, + (v_even_s + 0.5).to(tl.int32), + (v_even_s - 0.5).to(tl.int32), + ).to(tl.float32), + 0.0, + 15.0, + ) + v_odd_q = tl.clamp( + tl.where( + v_odd_s >= 0, + (v_odd_s + 0.5).to(tl.int32), + (v_odd_s - 0.5).to(tl.int32), + ).to(tl.float32), + 0.0, + 15.0, + ) + + v_zp_int = v_zp_f.to(tl.int32) + v_scale_bits = v_scale.to(tl.int32, bitcast=True) + v_scale_packed = ((v_scale_bits & -16) | (v_zp_int & 0xF)).to( + tl.float32, bitcast=True + ) + + tl.store( + v_scale_cache_ptr + + blk * stride_vs_blk + + slot_in_blk * stride_vs_slot + + head * stride_vs_head, + v_scale_packed, + ) + + v_packed = pack_int4_nibbles(v_even_q.to(tl.uint8), v_odd_q.to(tl.uint8)) + tl.store( + value_cache_ptr + + blk * stride_vc_blk + + slot_in_blk * stride_vc_slot + + head * stride_vc_head + + half_offs, + v_packed, + mask=half_offs < half_v, + ) + + +def _run_reshape_kernel( + kernel, + *, + key: torch.Tensor, + value: torch.Tensor, + key_cache: torch.Tensor, + value_cache: torch.Tensor, + k_scale_cache: torch.Tensor, + v_scale_cache: torch.Tensor, + slot_mapping: torch.Tensor, + packing_factor: int, +) -> None: + """Launch the packed INT4 reshape kernel.""" + num_tokens, num_kv_heads, head_size = key.shape + head_size_v = value.shape[2] + assert head_size % packing_factor == 0 and head_size_v % packing_factor == 0 + packed_padded = triton.next_power_of_2( + max(head_size, head_size_v) // packing_factor + ) + if current_platform.is_rocm() or current_platform.is_xpu(): + num_warps = 4 + else: + num_warps = min(16, max(1, packed_padded // 32)) + + kernel[(num_tokens, num_kv_heads)]( + key_ptr=key, + value_ptr=value, + key_cache_ptr=key_cache, + value_cache_ptr=value_cache, + k_scale_cache_ptr=k_scale_cache, + v_scale_cache_ptr=v_scale_cache, + slot_mapping_ptr=slot_mapping, + stride_key_tok=key.stride(0), + stride_key_head=key.stride(1), + stride_val_tok=value.stride(0), + stride_val_head=value.stride(1), + stride_kc_blk=key_cache.stride(0), + stride_kc_slot=key_cache.stride(1), + stride_kc_head=key_cache.stride(2), + stride_vc_blk=value_cache.stride(0), + stride_vc_slot=value_cache.stride(1), + stride_vc_head=value_cache.stride(2), + stride_ks_blk=k_scale_cache.stride(0), + stride_ks_slot=k_scale_cache.stride(1), + stride_ks_head=k_scale_cache.stride(2), + stride_vs_blk=v_scale_cache.stride(0), + stride_vs_slot=v_scale_cache.stride(1), + stride_vs_head=v_scale_cache.stride(2), + block_size=key_cache.shape[1], + head_size=head_size, + head_size_v=head_size_v, + PACKED_HEAD_PADDED=packed_padded, + num_warps=num_warps, + ) + + +# ---------------------------------------------------------------------- +# Read path: split-dot attention over the packed cache +# ---------------------------------------------------------------------- + + +@triton.jit +def _attn_packed( + # Output destinations. In 2D mode the final result is written into + # ``output_ptr``; in 3D mode per-segment partials go into the three + # ``segm_*`` tensors and ``output_ptr`` is unused. + output_ptr, + segm_output_ptr, + segm_max_ptr, + segm_expsum_ptr, + query_ptr, + key_cache_ptr, + value_cache_ptr, + sink_ptr, + block_tables_ptr, + seq_lens_ptr, + alibi_slopes_ptr, + qq_bias_ptr, + scale, + out_scale, + softcap, + k_scale_cache_ptr, + v_scale_cache_ptr, + num_query_heads: tl.constexpr, + num_queries_per_kv: tl.constexpr, + block_table_stride: tl.int64, + query_stride_0: tl.int64, + query_stride_1: tl.int64, + output_stride_0: tl.int64, + output_stride_1: tl.int64, + qq_bias_stride_0: tl.int64, + BLOCK_SIZE: tl.constexpr, + TILE_SIZE: tl.constexpr, + HEAD_SIZE: tl.constexpr, + HEAD_SIZE_PADDED: tl.constexpr, + PACKED_HEAD_PADDED: tl.constexpr, # HEAD_SIZE / PACKING_FACTOR, rounded up + USE_ALIBI_SLOPES: tl.constexpr, + USE_ALIBI_SQRT: tl.constexpr, + USE_QQ_BIAS: tl.constexpr, + USE_SOFTCAP: tl.constexpr, + USE_SINKS: tl.constexpr, + SLIDING_WINDOW: tl.constexpr, + USE_MM_PREFIX: tl.constexpr, + MAX_MM_RANGES: tl.constexpr, + mm_prefix_range_ptr, + stride_k_cache_0: tl.int64, + stride_k_cache_1: tl.int64, + stride_k_cache_2: tl.int64, + stride_k_cache_3: tl.constexpr, + stride_v_cache_0: tl.int64, + stride_v_cache_1: tl.int64, + stride_v_cache_2: tl.int64, + stride_v_cache_3: tl.constexpr, + stride_ks_blk: tl.int64, + stride_ks_slot: tl.int64, + stride_ks_head: tl.int64, + stride_vs_blk: tl.int64, + stride_vs_slot: tl.int64, + stride_vs_head: tl.int64, + query_start_len_ptr, + BLOCK_Q: tl.constexpr, + num_seqs: tl.int32, + BLOCK_M: tl.constexpr, + NUM_SEGMENTS_PER_SEQ: tl.constexpr, + USE_FP8: tl.constexpr, + IS_3D: tl.constexpr, + # 2 → INT4 nibble pair (asymmetric + zp). The packed KV cache stores + # one byte per ``packed_offs``, holding PACKING_FACTOR values. + PACKING_FACTOR: tl.constexpr, + FP8_MIN: tl.constexpr = float8_info.min, + FP8_MAX: tl.constexpr = float8_info.max, +): + # Shared prologue: sequence lookup, q-block bounds, early returns. + q_block_global_idx = tl.program_id(0) + kv_head_idx = tl.program_id(1) + segm_idx = tl.program_id(2) if IS_3D else 0 + + ( + seq_idx, + q_block_local_idx, + cur_batch_in_all_start_index, + cur_batch_query_len, + seq_len, + ) = resolve_seq_and_query_len( + query_start_len_ptr, seq_lens_ptr, q_block_global_idx, num_seqs, BLOCK_Q + ) + + if q_block_local_idx * BLOCK_Q >= cur_batch_query_len: + return + + if IS_3D: + tiles_per_segment = cdiv_fn(seq_len, NUM_SEGMENTS_PER_SEQ * TILE_SIZE) + if segm_idx * tiles_per_segment * TILE_SIZE >= seq_len: + return + else: + tiles_per_segment = 0 + + offs_m = tl.arange(0, BLOCK_M) + offs_t = tl.arange(0, TILE_SIZE) + query_pos = q_block_local_idx * BLOCK_Q + offs_m // num_queries_per_kv + + query_offset_0 = cur_batch_in_all_start_index + query_pos + query_offset_1 = kv_head_idx * num_queries_per_kv + offs_m % num_queries_per_kv + + query_mask_0 = tl.where(query_pos < cur_batch_query_len, 1, 0).to(tl.int1) + query_mask_1 = tl.where(query_offset_1 < num_query_heads, 1, 0).to(tl.int1) + + # Split-Q prologue: PACKING_FACTOR interleaved streams of Q. + # INT4 uses 2 streams (even / odd). The packed KV cache stores one + # byte per ``packed_offs``, which holds PACKING_FACTOR values. + packed_offs = tl.arange(0, PACKED_HEAD_PADDED) + offs_s0 = packed_offs * PACKING_FACTOR + offs_s1 = packed_offs * PACKING_FACTOR + 1 + mask_s0 = tl.where(offs_s0 < HEAD_SIZE, 1, 0).to(tl.int1) + mask_s1 = tl.where(offs_s1 < HEAD_SIZE, 1, 0).to(tl.int1) + packed_dim_mask = tl.where(packed_offs < HEAD_SIZE // PACKING_FACTOR, 1, 0).to( + tl.int1 + ) + q_base = ( + query_offset_0[:, None] * query_stride_0 + + query_offset_1[:, None] * query_stride_1 + ) + q_mask = query_mask_0[:, None] & query_mask_1[:, None] + Q_s0 = tl.load( + query_ptr + q_base + offs_s0[None, :], + mask=mask_s0[None, :] & q_mask, + other=0.0, + ).to(tl.float32) + Q_s1 = tl.load( + query_ptr + q_base + offs_s1[None, :], + mask=mask_s1[None, :] & q_mask, + other=0.0, + ).to(tl.float32) + + # INT4 asymmetric correction needs sum(Q) per row. + Q_sum = tl.sum(Q_s0, axis=1) + tl.sum(Q_s1, axis=1) + + block_table_offset = seq_idx * block_table_stride + + # Online-softmax state + optional feature loads. + M = init_softmax_M( + sink_ptr, query_offset_1, query_mask_1, segm_idx, BLOCK_M, USE_SINKS, IS_3D + ) + L = tl.full([BLOCK_M], 1.0, dtype=tl.float32) + acc_s0 = tl.zeros([BLOCK_M, PACKED_HEAD_PADDED], dtype=tl.float32) + acc_s1 = tl.zeros([BLOCK_M, PACKED_HEAD_PADDED], dtype=tl.float32) + + context_len = seq_len - cur_batch_query_len + + if USE_ALIBI_SLOPES: + alibi_slope = tl.load( + alibi_slopes_ptr + query_offset_1, mask=query_mask_1, other=0.0 + ) + + if USE_QQ_BIAS: + qq_bias_row_ptrs = qq_bias_ptr + query_pos[:, None] * qq_bias_stride_0 + + loop_lo, loop_hi, max_seq_prefix_len = compute_tile_loop_bounds( + context_len, + seq_len, + cur_batch_query_len, + q_block_local_idx, + segm_idx, + tiles_per_segment, + TILE_SIZE, + BLOCK_M, + BLOCK_Q, + num_queries_per_kv, + SLIDING_WINDOW, + USE_MM_PREFIX, + IS_3D, + ) + + # Tile loop. Per-tile: load packed KV + scales, dequantize into + # PACKING_FACTOR streams, compute the split dot, run the shared + # softmax step, accumulate per stream. + for j in range(loop_lo, loop_hi): + seq_offset = j * TILE_SIZE + offs_t + tile_mask = seq_offset < max_seq_prefix_len + + physical_block_idx = tl.load( + block_tables_ptr + block_table_offset + seq_offset // BLOCK_SIZE + ).to(tl.int64) + + slot_in_blk = seq_offset % BLOCK_SIZE + k_off = ( + physical_block_idx[None, :] * stride_k_cache_0 + + kv_head_idx * stride_k_cache_2 + + packed_offs[:, None] * stride_k_cache_3 + + slot_in_blk[None, :] * stride_k_cache_1 + ) + K_packed = tl.load( + key_cache_ptr + k_off, + mask=packed_dim_mask[:, None] & tile_mask[None, :], + other=0, + ) + v_off = ( + physical_block_idx[:, None] * stride_v_cache_0 + + kv_head_idx * stride_v_cache_2 + + packed_offs[None, :] * stride_v_cache_3 + + slot_in_blk[:, None] * stride_v_cache_1 + ) + V_packed = tl.load( + value_cache_ptr + v_off, + mask=packed_dim_mask[None, :] & tile_mask[:, None], + other=0, + ) + # Dequantize KV. INT4 unpacks nibbles as plain uint [0..15]; + # the zero-point is applied on the score side. + K_s0_u, K_s1_u = unpack_int4_nibbles(K_packed) + K_s0 = K_s0_u.to(tl.float32) + K_s1 = K_s1_u.to(tl.float32) + V_s0_u, V_s1_u = unpack_int4_nibbles(V_packed) + V_s0 = V_s0_u.to(tl.float32) + V_s1 = V_s1_u.to(tl.float32) + + ks_idx = ( + physical_block_idx * stride_ks_blk + + slot_in_blk * stride_ks_slot + + kv_head_idx * stride_ks_head + ) + ks_raw = tl.load(k_scale_cache_ptr + ks_idx, mask=tile_mask, other=0) + vs_idx = ( + physical_block_idx * stride_vs_blk + + slot_in_blk * stride_vs_slot + + kv_head_idx * stride_vs_head + ) + vs_raw = tl.load(v_scale_cache_ptr + vs_idx, mask=tile_mask, other=0) + + # INT4 steganographs the 4-bit zero-point in the low 4 bits of + # the float32 scale's mantissa. + ks_bits = ks_raw.to(tl.int32, bitcast=True) + k_zp = (ks_bits & 0xF).to(tl.float32) + k_token_head_scales = (ks_bits & -16).to(tl.float32, bitcast=True) + vs_bits = vs_raw.to(tl.int32, bitcast=True) + v_zp = (vs_bits & 0xF).to(tl.float32) + v_token_head_scales = (vs_bits & -16).to(tl.float32, bitcast=True) + + query_abs_pos = context_len + query_pos[:, None] + seq_mask = compute_kv_seq_mask( + query_abs_pos, + seq_offset, + seq_idx, + seq_len, + mm_prefix_range_ptr, + SLIDING_WINDOW, + USE_MM_PREFIX, + MAX_MM_RANGES, + ) + + # Score: split-dot across the 2 INT4 streams; fused + # softmax_scale * per-(token, head) k_scale in one mul. INT4 + # subtracts the ``zp * sum(Q)`` correction term. + S = tl.zeros(shape=(BLOCK_M, TILE_SIZE), dtype=tl.float32) + raw_dot = tl.dot(Q_s0, K_s0) + tl.dot(Q_s1, K_s1) + S += (raw_dot - Q_sum[:, None] * k_zp[None, :]) * ( + scale * k_token_head_scales[None, :] + ) + + if USE_SOFTCAP: + S = apply_softcap(S, softcap) + + S = tl.where( + query_mask_1[:, None] & query_mask_0[:, None] & seq_mask, S, float("-inf") + ) + + if USE_ALIBI_SLOPES: + S = apply_alibi_to_score( + S, alibi_slope, seq_offset, context_len, query_pos, USE_ALIBI_SQRT + ) + + if USE_QQ_BIAS: + S += load_qq_bias_tile( + qq_bias_row_ptrs, seq_offset, context_len, qq_bias_stride_0 + ) + + M, L, P, alpha = softmax_step(S, M, L) + acc_s0 = acc_s0 * alpha[:, None] + acc_s1 = acc_s1 * alpha[:, None] + + if SLIDING_WINDOW: + qpos_lo = q_block_local_idx * BLOCK_Q + sw_mask = (context_len + qpos_lo - seq_offset) < SLIDING_WINDOW + V_s0 = tl.where(sw_mask[:, None], V_s0, 0.0) + V_s1 = tl.where(sw_mask[:, None], V_s1, 0.0) + + # Fuse v per-(token, head) scale into P. INT4 also subtracts + # the v-zero-point contribution from each stream once. + P_v = (P * v_token_head_scales[None, :]).to(tl.float32) + Pv_zp_sum = tl.sum(P_v * v_zp[None, :], axis=1) + acc_s0 += tl.dot(P_v, V_s0) - Pv_zp_sum[:, None] + acc_s1 += tl.dot(P_v, V_s1) - Pv_zp_sum[:, None] + + # Epilogue. 2D writes the final output with optional FP8 clamp; + # 3D writes the per-segment partials (output / max / expsum) for + # ``reduce_segments`` to finalize. Each stream writes its own + # stripe in the output layout. + out_mask = query_mask_0[:, None] & query_mask_1[:, None] + if IS_3D: + segm_base = ( + query_offset_0[:, None].to(tl.int64) + * (num_query_heads * NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_PADDED) + + query_offset_1[:, None] * (NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_PADDED) + + segm_idx * HEAD_SIZE_PADDED + ) + tl.store( + segm_output_ptr + segm_base + offs_s0[None, :], + acc_s0, + mask=mask_s0[None, :] & out_mask, + ) + tl.store( + segm_output_ptr + segm_base + offs_s1[None, :], + acc_s1, + mask=mask_s1[None, :] & out_mask, + ) + store_segm_reduce_scalars( + segm_max_ptr, + segm_expsum_ptr, + query_offset_0, + query_offset_1, + segm_idx, + M, + L, + query_mask_0, + query_mask_1, + num_query_heads, + NUM_SEGMENTS_PER_SEQ, + ) + else: + acc_s0 = acc_s0 / L[:, None] + acc_s1 = acc_s1 / L[:, None] + if USE_FP8: + out_s = tl.load(out_scale) + acc_s0 = tl.clamp(acc_s0 * out_s, FP8_MIN, FP8_MAX) + acc_s1 = tl.clamp(acc_s1 * out_s, FP8_MIN, FP8_MAX) + out_base = ( + query_offset_0[:, None] * output_stride_0 + + query_offset_1[:, None] * output_stride_1 + ) + tl.store( + output_ptr + out_base + offs_s0[None, :], + acc_s0, + mask=mask_s0[None, :] & out_mask, + ) + tl.store( + output_ptr + out_base + offs_s1[None, :], + acc_s1, + mask=mask_s1[None, :] & out_mask, + ) + + +def _launch_packed_attn( + *, + q, + k_cache, + v_cache, + out, + cu_seqlens_q, + max_seqlen_q, + seqused_k, + softmax_scale, + window_size, + block_table, + softcap, + sinks, + alibi_slopes, + use_alibi_sqrt, + qq_bias, + output_scale, + mm_prefix_range, + k_scale_cache, + v_scale_cache, + seq_threshold_3D, + num_par_softmax_segments, + softmax_segm_output, + softmax_segm_max, + softmax_segm_expsum, + packing_factor: int, +): + """Launch ``_attn_packed`` for one of the sub-byte modes. + + Handles 2D-vs-3D dispatch, placeholder pointers for the unused side + of that split, and the trailing ``reduce_segments`` pass. Writes + into ``out`` (directly for 2D; via the segm buffers for 3D). + """ + import vllm.envs as envs + from vllm.v1.attention.ops.triton_unified_attention import _get_tile_size + + is_batch_invariant = envs.VLLM_BATCH_INVARIANT + + use_mm_prefix = False + max_mm_ranges = 0 + if mm_prefix_range is not None: + assert mm_prefix_range.ndim == 3, ( + f"Unsupported mm_prefix_range shape: {mm_prefix_range.shape}" + ) + use_mm_prefix = True + max_mm_ranges = mm_prefix_range.shape[1] + + block_size = v_cache.shape[1] + num_seqs = len(seqused_k) + num_query_heads = q.shape[1] + num_kv_heads = k_cache.shape[2] + num_queries_per_kv = num_query_heads // num_kv_heads + head_size = q.shape[2] + + BLOCK_M = ( + 16 if num_queries_per_kv <= 16 else triton.next_power_of_2(num_queries_per_kv) + ) + BLOCK_Q = BLOCK_M // num_queries_per_kv + total_num_q_blocks = q.shape[0] // BLOCK_Q + num_seqs + sliding_window_val = 1 + window_size[0] if window_size[0] >= 0 else 0 + TILE_SIZE_PREFILL = _get_tile_size( + head_size, sliding_window_val, q.element_size(), is_prefill=True + ) + TILE_SIZE_DECODE = _get_tile_size( + head_size, sliding_window_val, q.element_size(), is_prefill=False + ) + + use_3d = not ( + seq_threshold_3D is None + or num_par_softmax_segments is None + or softmax_segm_output is None + or softmax_segm_max is None + or softmax_segm_expsum is None + or max_seqlen_q > 1 + or num_seqs > seq_threshold_3D + or is_batch_invariant + ) + + # 3D never reads ``output_ptr`` and 2D never reads the segm tensors, + # but Triton needs a non-null pointer everywhere; reuse ``out`` as + # the placeholder for the unused side. + segm_output_ptr = softmax_segm_output if use_3d else out + segm_max_ptr = softmax_segm_max if use_3d else out + segm_expsum_ptr = softmax_segm_expsum if use_3d else out + num_segments = num_par_softmax_segments if use_3d else 1 + + grid: tuple[Any, ...] + if use_3d: + grid = (total_num_q_blocks, num_kv_heads, num_par_softmax_segments) + tile_size = TILE_SIZE_DECODE + else: + grid = (total_num_q_blocks, num_kv_heads) + tile_size = TILE_SIZE_PREFILL + + _attn_packed[grid]( + output_ptr=out, + segm_output_ptr=segm_output_ptr, + segm_max_ptr=segm_max_ptr, + segm_expsum_ptr=segm_expsum_ptr, + query_ptr=q, + key_cache_ptr=k_cache, + value_cache_ptr=v_cache, + sink_ptr=sinks, + block_tables_ptr=block_table, + seq_lens_ptr=seqused_k, + alibi_slopes_ptr=alibi_slopes, + qq_bias_ptr=qq_bias, + scale=softmax_scale, + out_scale=1 / output_scale if output_scale is not None else 1.0, + softcap=softcap, + k_scale_cache_ptr=k_scale_cache, + v_scale_cache_ptr=v_scale_cache, + num_query_heads=num_query_heads, + num_queries_per_kv=num_queries_per_kv, + block_table_stride=block_table.stride(0), + query_stride_0=q.stride(0), + query_stride_1=q.stride(1), + output_stride_0=out.stride(0), + output_stride_1=out.stride(1), + qq_bias_stride_0=qq_bias.stride(0) if qq_bias is not None else 0, + BLOCK_SIZE=block_size, + TILE_SIZE=tile_size, + HEAD_SIZE=head_size, + HEAD_SIZE_PADDED=triton.next_power_of_2(head_size), + PACKED_HEAD_PADDED=triton.next_power_of_2(head_size) // packing_factor, + USE_ALIBI_SLOPES=alibi_slopes is not None, + USE_ALIBI_SQRT=use_alibi_sqrt, + USE_QQ_BIAS=qq_bias is not None, + USE_SOFTCAP=(softcap > 0), + USE_SINKS=(sinks is not None), + SLIDING_WINDOW=(1 + window_size[0]), + USE_MM_PREFIX=use_mm_prefix, + MAX_MM_RANGES=max_mm_ranges, + mm_prefix_range_ptr=mm_prefix_range, + stride_k_cache_0=k_cache.stride(0), + stride_k_cache_1=k_cache.stride(1), + stride_k_cache_2=k_cache.stride(2), + stride_k_cache_3=k_cache.stride(3), + stride_v_cache_0=v_cache.stride(0), + stride_v_cache_1=v_cache.stride(1), + stride_v_cache_2=v_cache.stride(2), + stride_v_cache_3=v_cache.stride(3), + stride_ks_blk=k_scale_cache.stride(0), + stride_ks_slot=k_scale_cache.stride(1), + stride_ks_head=k_scale_cache.stride(2), + stride_vs_blk=v_scale_cache.stride(0), + stride_vs_slot=v_scale_cache.stride(1), + stride_vs_head=v_scale_cache.stride(2), + query_start_len_ptr=cu_seqlens_q, + BLOCK_Q=BLOCK_Q, + num_seqs=num_seqs, + BLOCK_M=BLOCK_M, + NUM_SEGMENTS_PER_SEQ=num_segments, + USE_FP8=output_scale is not None, + IS_3D=use_3d, + PACKING_FACTOR=packing_factor, + ) + + if use_3d: + reduce_segments[(q.shape[0], num_query_heads)]( + output_ptr=out, + segm_output_ptr=softmax_segm_output, + segm_max_ptr=softmax_segm_max, + segm_expsum_ptr=softmax_segm_expsum, + seq_lens_ptr=seqused_k, + num_seqs=num_seqs, + num_query_heads=num_query_heads, + out_scale_inv=1 / output_scale if output_scale is not None else 1.0, + output_stride_0=out.stride(0), + output_stride_1=out.stride(1), + block_table_stride=block_table.stride(0), + TILE_SIZE=TILE_SIZE_DECODE, + HEAD_SIZE=head_size, + HEAD_SIZE_PADDED=triton.next_power_of_2(head_size), + query_start_len_ptr=cu_seqlens_q, + BLOCK_Q=BLOCK_Q, + NUM_SEGMENTS_PER_SEQ=num_par_softmax_segments, + USE_FP8=output_scale is not None, + ) + + +# ---------------------------------------------------------------------- +# Public entry points +# ---------------------------------------------------------------------- + + +def reshape_and_cache_int4( + key: torch.Tensor, + value: torch.Tensor, + key_cache: torch.Tensor, + value_cache: torch.Tensor, + slot_mapping: torch.Tensor, + *, + k_scale_cache: torch.Tensor, + v_scale_cache: torch.Tensor, +) -> None: + """Pre-rotate (RHT), pack to INT4 and write into the paged cache.""" + key = single_rht(key.float()).to(key.dtype) + value = single_rht(value.float()).to(value.dtype) + _run_reshape_kernel( + _reshape_cache_int4_kernel, + key=key, + value=value, + key_cache=key_cache, + value_cache=value_cache, + k_scale_cache=k_scale_cache, + v_scale_cache=v_scale_cache, + slot_mapping=slot_mapping, + packing_factor=_INT4_PACKING_FACTOR, + ) + + +def unified_attention_int4( + q: torch.Tensor, + k_cache: torch.Tensor, + v_cache: torch.Tensor, + out: torch.Tensor, + *, + cu_seqlens_q: torch.Tensor, + max_seqlen_q: int, + seqused_k: torch.Tensor, + max_seqlen_k: int, + softmax_scale: float, + window_size: tuple[int, int], + block_table: torch.Tensor, + softcap: float, + sinks: torch.Tensor | None, + alibi_slopes: torch.Tensor | None, + use_alibi_sqrt: bool, + qq_bias: torch.Tensor | None, + output_scale: torch.Tensor | None, + mm_prefix_range: torch.Tensor | None, + k_scale_cache: torch.Tensor, + v_scale_cache: torch.Tensor, + seq_threshold_3D: int | None = None, + num_par_softmax_segments: int | None = None, + softmax_segm_output: torch.Tensor | None = None, + softmax_segm_max: torch.Tensor | None = None, + softmax_segm_expsum: torch.Tensor | None = None, +) -> None: + """Paged attention over the INT4 packed cache, writing into *out*. + + The forward RHT has norm ``sqrt(head_size)``, so ``softmax_scale`` is + divided by ``head_size`` and the inverse RHT divides the output by + ``head_size`` as well. + """ + q_orig_dtype = q.dtype + q = single_rht(q.float()).to(q_orig_dtype) + head_size = q.shape[2] + softmax_scale = softmax_scale / head_size + + _launch_packed_attn( + q=q, + k_cache=k_cache, + v_cache=v_cache, + out=out, + cu_seqlens_q=cu_seqlens_q, + max_seqlen_q=max_seqlen_q, + seqused_k=seqused_k, + softmax_scale=softmax_scale, + window_size=window_size, + block_table=block_table, + softcap=softcap, + sinks=sinks, + alibi_slopes=alibi_slopes, + use_alibi_sqrt=use_alibi_sqrt, + qq_bias=qq_bias, + output_scale=output_scale, + mm_prefix_range=mm_prefix_range, + k_scale_cache=k_scale_cache, + v_scale_cache=v_scale_cache, + seq_threshold_3D=seq_threshold_3D, + num_par_softmax_segments=num_par_softmax_segments, + softmax_segm_output=softmax_segm_output, + softmax_segm_max=softmax_segm_max, + softmax_segm_expsum=softmax_segm_expsum, + packing_factor=_INT4_PACKING_FACTOR, + ) + + out_f = single_rht(out.float(), inverse=True) / head_size + out.copy_(out_f.to(q_orig_dtype)) + + +# ---------------------------------------------------------------------- +# Randomized Hadamard Transform (RHT) — gaussianizes K/V before INT4 +# quantization; applied on write and to Q before the read kernels. +# ---------------------------------------------------------------------- + +# Hadacore (CUDA tensor core kernel) availability check +# Hadacore's CUDA impl is only registered when built for sm_80+, but the +# schema def is unconditional — on ROCm ``hasattr`` is True yet dispatch +# would crash, so we also gate on ``is_cuda()`` and the sm_80 capability. +_HADACORE_AVAILABLE: bool | None = None + + +def _hadacore_available() -> bool: + global _HADACORE_AVAILABLE + if _HADACORE_AVAILABLE is None: + _HADACORE_AVAILABLE = ( + current_platform.is_cuda() + and current_platform.has_device_capability(80) + and hasattr(torch.ops._C, "hadacore_transform") + ) + return _HADACORE_AVAILABLE + + +# Cached Hadamard matrices (one per (size, dtype, device) tuple) +_HADAMARD_MATRIX_CACHE: dict[tuple[int, torch.dtype, str], torch.Tensor] = {} + + +def _get_hadamard_matrix( + d: int, dtype: torch.dtype, device: torch.device +) -> torch.Tensor: + key = (d, dtype, str(device)) + cached = _HADAMARD_MATRIX_CACHE.get(key) + if cached is None: + H = torch.ones(1, 1, dtype=torch.float32, device=device) + while H.shape[0] < d: + H = torch.cat( + [ + torch.cat([H, H], dim=1), + torch.cat([H, -H], dim=1), + ], + dim=0, + ) + cached = H.to(dtype).contiguous() + _HADAMARD_MATRIX_CACHE[key] = cached + return cached + + +# Triton MMA Hadamard kernel (Tier 2) +@triton.jit +def _hadamard_mma_kernel( + x_ptr, + h_ptr, + out_ptr, + n_rows, + stride_x_row: tl.int64, + stride_x_col: tl.int64, + stride_o_row: tl.int64, + stride_o_col: tl.int64, + BLOCK_M: tl.constexpr, + D: tl.constexpr, +): + pid = tl.program_id(0) + rows = pid * BLOCK_M + tl.arange(0, BLOCK_M) + cols = tl.arange(0, D) + row_mask = rows < n_rows + + x = tl.load( + x_ptr + rows[:, None] * stride_x_row + cols[None, :] * stride_x_col, + mask=row_mask[:, None], + other=0.0, + ) + H = tl.load(h_ptr + cols[:, None] * D + cols[None, :]) + + out = tl.dot(x, H, out_dtype=tl.float32).to(x.dtype) + + tl.store( + out_ptr + rows[:, None] * stride_o_row + cols[None, :] * stride_o_col, + out, + mask=row_mask[:, None], + ) + + +# H is D×D bf16 = 2·D² bytes of LDS. AMD CDNA has 64 KiB LDS, so D ≤ 128 +# (32 KiB) leaves room for input + accumulator. Larger D falls back. +_TRITON_HADAMARD_MIN_D = 16 +_TRITON_HADAMARD_MAX_D = 128 + + +def _triton_hadamard_transform(x: torch.Tensor) -> torch.Tensor: + d = x.shape[-1] + orig_shape = x.shape + orig_dtype = x.dtype + + work_dtype = torch.bfloat16 if orig_dtype == torch.float32 else orig_dtype + x2d = x.contiguous().to(work_dtype).reshape(-1, d) + out2d = torch.empty_like(x2d) + n_rows = x2d.shape[0] + H_mat = _get_hadamard_matrix(d, work_dtype, x.device) + + BLOCK_M = 16 + grid = (triton.cdiv(n_rows, BLOCK_M),) + # num_stages=1: the kernel has no loop, so default 3-stage pipelining + # would triple-buffer H and blow the AMD LDS budget. + _hadamard_mma_kernel[grid]( + x2d, + H_mat, + out2d, + n_rows, + x2d.stride(0), + x2d.stride(1), + out2d.stride(0), + out2d.stride(1), + BLOCK_M=BLOCK_M, + D=d, + num_stages=1, + num_warps=4, + ) + return out2d.reshape(orig_shape).to(orig_dtype) + + +# Public API +def fast_hadamard_transform(x: torch.Tensor) -> torch.Tensor: + """Unnormalized Walsh-Hadamard Transform along the last dimension. + + H_d × x where H_d × H_d = d × I. Last dim must be a power of 2. + + Three-tier dispatch: + 1. Hadacore CUDA Tensor Core kernel (sm_80+). + 2. Triton MMA matmul kernel (CUDA fallback + ROCm MFMA/WMMA path). + 3. PyTorch butterfly (CPU and any GPU/dtype combo Triton can't take). + """ + d = x.shape[-1] + assert d & (d - 1) == 0, f"Requires power-of-2 dim, got {d}" + + # Tier 1 — hadacore on CUDA. + if _hadacore_available() and 0 < d <= (1 << 15): + from vllm import _custom_ops as ops + + # hadacore returns x @ (H/√d); rescale to the unnormalized H × x + # convention the INT4 scale math is calibrated to. + rescale = d**0.5 + if x.dtype in (torch.float16, torch.bfloat16): + y = ops.hadacore_transform(x.contiguous().clone(), inplace=True) + return y * rescale + # fp32 → bf16 round-trip; precision loss is irrelevant before + # INT4 quantization. + orig_dtype = x.dtype + x_bf16 = x.contiguous().to(torch.bfloat16) + y_bf16 = ops.hadacore_transform(x_bf16, inplace=True) + return y_bf16.to(orig_dtype) * rescale + + # Tier 2 — Triton MMA kernel (covers ROCm via MFMA/WMMA codegen, and + # also CUDA when hadacore is unavailable). + if ( + x.is_cuda + and _TRITON_HADAMARD_MIN_D <= d <= _TRITON_HADAMARD_MAX_D + and x.dtype in (torch.float16, torch.bfloat16, torch.float32) + ): + return _triton_hadamard_transform(x) + + # Tier 3 — PyTorch butterfly (CPU / unsupported dtype / D < 16). + h = 1 + while h < d: + xv = x.view(*x.shape[:-1], d // (2 * h), 2, h) + a = xv[..., 0, :] + b = xv[..., 1, :] + x = torch.stack([a + b, a - b], dim=-2).reshape(x.shape) + h <<= 1 + return x + + +# Randomized Hadamard Transform (used by INT4) +# Deterministic ±1 signs for Randomized Hadamard Transform. +# RHT = H × D × x (sign flip + Hadamard). Breaks residual structure +# in KV vectors, improving quantization quality. +_RHT_SIGNS_CACHE: dict[tuple[int, int, str], torch.Tensor] = {} + + +def _get_rht_signs(d: int, round_idx: int, device: torch.device) -> torch.Tensor: + """Return a cached deterministic ±1 sign vector of length *d*.""" + key = (d, round_idx, str(device)) + if key not in _RHT_SIGNS_CACHE: + gen = torch.Generator(device="cpu") + gen.manual_seed(0x9E3779B9 + round_idx * 0x517CC1B7) + signs = ( + 2.0 * torch.bernoulli(torch.full((d,), 0.5, device="cpu"), generator=gen) + - 1.0 + ) + _RHT_SIGNS_CACHE[key] = signs.to(device) + return _RHT_SIGNS_CACHE[key] + + +def single_rht(x: torch.Tensor, inverse: bool = False) -> torch.Tensor: + """Single Randomized Hadamard Transform: H × D₁ × x. + + Used by INT4 per-token-head quantization to gaussianize data + before asymmetric quantization. + """ + d = x.shape[-1] + d1 = _get_rht_signs(d, 0, x.device) + if inverse: + return fast_hadamard_transform(x) * d1 + else: + return fast_hadamard_transform(x * d1) diff --git a/vllm/v1/attention/ops/merge_attn_states.py b/vllm/v1/attention/ops/merge_attn_states.py index cf4338fb180d..20c7503e9d05 100644 --- a/vllm/v1/attention/ops/merge_attn_states.py +++ b/vllm/v1/attention/ops/merge_attn_states.py @@ -46,6 +46,14 @@ def merge_attn_states( When provided, output must be FP8 dtype. """ + # Both the CUDA and Triton kernels derive the suffix head stride from + # prefix_output, so suffix_output must share the same head stride. + assert prefix_output.stride(1) == suffix_output.stride(1), ( + "merge_attn_states requires prefix_output and suffix_output to have " + f"matching head strides, got {prefix_output.stride(1)} and " + f"{suffix_output.stride(1)}" + ) + # NOTE(DefTruth): Currently, custom merge_attn_states CUDA kernel # does not support FP8 dtype for inputs, fallback to use Triton kernel. # However, when output_scale is provided, the inputs are still BF16/FP16 diff --git a/vllm/v1/attention/ops/rocm_aiter_mla_sparse.py b/vllm/v1/attention/ops/rocm_aiter_mla_sparse.py index 332350d83806..2153a460f696 100644 --- a/vllm/v1/attention/ops/rocm_aiter_mla_sparse.py +++ b/vllm/v1/attention/ops/rocm_aiter_mla_sparse.py @@ -8,6 +8,7 @@ import torch import torch.nn.functional as F +import vllm.envs as envs from vllm.compilation.breakable_cudagraph import eager_break_during_capture from vllm.forward_context import get_forward_context from vllm.platforms import current_platform @@ -15,6 +16,7 @@ from vllm.utils.torch_utils import LayerNameType from vllm.v1.attention.backends.mla.indexer import DeepseekV32IndexerMetadata from vllm.v1.attention.ops.common import pack_seq_triton, unpack_seq_triton +from vllm.v1.worker.workspace import current_workspace_manager if current_platform.is_rocm(): from vllm.platforms.rocm import _ON_GFX942, _ON_GFX950 @@ -56,7 +58,9 @@ def _indexer_k_quant_and_cache_kernel( slot_id = tl.load(slot_mapping_ptr + tid) if slot_id < 0: return - block_id = slot_id // block_size + # The packed KV layout makes per-block strides large + # enough that block_id * stride can exceed 32-bit range. + block_id = (slot_id // block_size).to(tl.int64) block_offset = slot_id % block_size tile_block_id = block_offset // BLOCK_TILE_SIZE tile_block_offset = block_offset % BLOCK_TILE_SIZE @@ -177,7 +181,9 @@ def _cp_gather_indexer_quant_cache_kernel( block_table_ptr + block_table_offset, mask=valid_block_table, other=-1 ) valid_block = valid_block_table & (block_id >= 0) & (block_id < NUM_BLOCKS) - safe_block_id = tl.where(valid_block, block_id, 0) + # The packed KV layout makes per-block strides large + # enough that block_id * stride can exceed 32-bit range. + safe_block_id = tl.where(valid_block, block_id, 0).to(tl.int64) safe_block_offset = tl.where(valid_block, block_offset, 0) tiled_block_offset = safe_block_offset % BLOCK_TILE_SIZE if LAYOUT == "SHUFFLE": @@ -408,8 +414,8 @@ def rocm_fp8_paged_mqa_logits( aiter_paged_mqa_logits_module = None # if rocm_aiter_ops.is_enabled(): - batch_size, next_n, heads, head_dim = q_fp8.shape - num_blocks, block_size, _, _ = kv_cache_fp8.shape + batch_size, next_n = q_fp8.shape[:2] + block_size = kv_cache_fp8.shape[1] if rocm_aiter_ops.is_enabled(): aiter_paged_mqa_logits_module = paged_mqa_logits_module() @@ -420,12 +426,10 @@ def rocm_fp8_paged_mqa_logits( aiter_paged_mqa_logits_module.deepgemm_fp8_paged_mqa_logits ) batch_size, next_n, heads, _ = q_fp8.shape - out_logits = torch.full( - [batch_size * next_n, max_model_len], - float("-inf"), - device="cuda", - dtype=torch.float32, + (out_logits,) = current_workspace_manager().get_simultaneous( + ((batch_size * next_n, max_model_len), torch.float32), ) + out_logits.fill_(float("-inf")) deepgemm_fp8_paged_mqa_logits( q_fp8, kv_cache_fp8, @@ -444,12 +448,10 @@ def rocm_fp8_paged_mqa_logits( aiter_paged_mqa_logits_module.deepgemm_fp8_paged_mqa_logits_stage1 ) batch_size, next_n, heads, _ = q_fp8.shape - out_qk = torch.full( - (heads, batch_size * next_n, max_model_len), - float("-inf"), - device="cuda", - dtype=torch.float32, + (out_qk,) = current_workspace_manager().get_simultaneous( + ((heads, batch_size * next_n, max_model_len), torch.float32), ) + out_qk.fill_(float("-inf")) deepgemm_fp8_paged_mqa_logits_stage1( q_fp8, kv_cache_fp8, @@ -506,7 +508,13 @@ def fp8_mqa_logits_torch( ) mask = mask_lo & mask_hi - score = torch.einsum("mhd,nd->hmn", q, k).float() * scale + # ``score`` is [H, M, N]; ``scale`` is the per-KV-token scale, which + # vLLM callers hand us as ``[N, 1]`` (a ``[N, 4]`` uint8 buffer cast + # to fp32). PyTorch right-aligns dimensions for broadcasting, so a + # naked ``score * scale`` would align ``scale``'s leading dim with + # ``score``'s M dim and raise a shape mismatch. Flatten to ``[N]`` so + # broadcasting lines up with the last dim of ``score``. + score = torch.einsum("mhd,nd->hmn", q, k).float() * scale.reshape(-1) logits = (score.relu() * weights.unsqueeze(-1).transpose(0, 1)).sum(dim=0) logits = logits.masked_fill(~mask, float("-inf")) @@ -559,13 +567,26 @@ def rocm_fp8_mqa_logits( # path after aiter merge this kernel into main from vllm._aiter_ops import rocm_aiter_ops + k_fp8, scale = kv + + # Temporarily route gfx942 to the vendored ROCm/aiter#3257 workaround. + # Remove this branch once vLLM bumps AITER to a version that includes + # ROCm/aiter#3257. + if _ON_GFX942 and rocm_aiter_ops.is_enabled(): + from vllm.v1.attention.ops.triton_fp8_mqa_logits import ( + fp8_mqa_logits_gfx942, + ) + + return fp8_mqa_logits_gfx942( + q, k_fp8, scale, weights, cu_seqlen_ks, cu_seqlen_ke + ) + aiter_mqa_logits_module = None if rocm_aiter_ops.is_enabled(): aiter_mqa_logits_module = mqa_logits_module() if aiter_mqa_logits_module is not None: fp8_mqa_logits = aiter_mqa_logits_module.fp8_mqa_logits - k_fp8, scale = kv return fp8_mqa_logits(q, k_fp8, scale, weights, cu_seqlen_ks, cu_seqlen_ke) else: return fp8_mqa_logits_torch(q, kv, weights, cu_seqlen_ks, cu_seqlen_ke) @@ -641,12 +662,48 @@ def rocm_aiter_sparse_attn_indexer( # careful! this will be None in dummy run attn_metadata = get_forward_context().attn_metadata fp8_dtype = current_platform.fp8_dtype() - from vllm import _custom_ops as ops from vllm.utils.torch_utils import _resolve_layer_name k_cache_prefix = _resolve_layer_name(k_cache_prefix) # assert isinstance(attn_metadata, dict) if not isinstance(attn_metadata, dict): + # Profiling early-exit: reserve memory to account for runtime + # allocations. Must be in the real impl, not the fake impl — + # torch.compile calls the fake impl under FakeTensor mode where + # workspace manager operations on the locked real workspace + # would corrupt PyTorch's dispatch state. + workspace_manager = current_workspace_manager() + + # Prefill k_fp8 and k_scale buffers, used by + # rocm_aiter_sparse_attn_indexer's prefill path + workspace_manager.get_simultaneous( + ((total_seq_lens, head_dim), fp8_dtype), + ((total_seq_lens, 4), torch.uint8), + ) + + # Decode logits buffer, used by rocm_fp8_paged_mqa_logits. + # batch_size * next_n <= hidden_states.shape[0] == max_num_batched_tokens + if _ON_GFX942 or _ON_GFX950: + workspace_manager.get_simultaneous( + ((hidden_states.shape[0], max_model_len), torch.float32), + ) + else: + workspace_manager.get_simultaneous( + ( + (q_fp8.shape[1], hidden_states.shape[0], max_model_len), + torch.float32, + ), + ) + # Transient logits tensor peak memory, produced by + # rocm_fp8_mqa_logits (prefill) and rocm_fp8_paged_mqa_logits + # (decode). Prefill logits are bounded by + # VLLM_SPARSE_INDEXER_MAX_LOGITS_MB via chunking in + # split_indexer_prefill_chunks; decode logits are smaller. + max_logits_elems = envs.VLLM_SPARSE_INDEXER_MAX_LOGITS_MB * 1024 * 1024 + _ = torch.empty( + max_logits_elems, dtype=torch.uint8, device=hidden_states.device + ) + return rocm_aiter_sparse_attn_indexer_fake( hidden_states, k_cache_prefix, @@ -671,7 +728,6 @@ def rocm_aiter_sparse_attn_indexer( has_decode = layer_attn_metadata.num_decodes > 0 has_prefill = layer_attn_metadata.num_prefills > 0 num_decode_tokens = layer_attn_metadata.num_decode_tokens - device = hidden_states.device if k is None else k.device # during speculative decoding, k may be padded to the CUDA graph batch # size while slot_mapping only covers actual tokens. @@ -682,56 +738,35 @@ def rocm_aiter_sparse_attn_indexer( raise ValueError("k must be provided when skip_k_cache_insert is False") if not skip_k_cache_insert: - if _ON_GFX942: - ops.indexer_k_quant_and_cache( - k, - kv_cache, - slot_mapping, - quant_block_size, - scale_fmt, - ) - else: - indexer_k_quant_and_cache_triton( - k, - kv_cache, - slot_mapping, - quant_block_size, - scale_fmt, - ) + indexer_k_quant_and_cache_triton( + k, + kv_cache, + slot_mapping, + quant_block_size, + scale_fmt, + ) topk_indices_buffer[: hidden_states.shape[0]] = -1 if has_prefill: prefill_metadata = layer_attn_metadata.prefill assert prefill_metadata is not None + + workspace_manager = current_workspace_manager() + k_fp8_full, k_scale_full = workspace_manager.get_simultaneous( + ((total_seq_lens, head_dim), fp8_dtype), + ((total_seq_lens, 4), torch.uint8), + ) for chunk in prefill_metadata.chunks: - k_fp8 = torch.empty( - [chunk.total_seq_lens, head_dim], - device=device, - dtype=fp8_dtype, - ) - k_scale = torch.empty( - [chunk.total_seq_lens, 4], - device=device, - dtype=torch.uint8, + k_fp8 = k_fp8_full[: chunk.total_seq_lens] + k_scale = k_scale_full[: chunk.total_seq_lens] + cp_gather_indexer_k_quant_cache_triton( + kv_cache, + k_fp8, + k_scale, + chunk.block_table, + chunk.cu_seq_lens, + token_to_seq=chunk.token_to_seq, ) - if _ON_GFX942: - ops.cp_gather_indexer_k_quant_cache( - kv_cache, - k_fp8, - k_scale, - chunk.block_table, - chunk.cu_seq_lens, - ) - else: - cp_gather_indexer_k_quant_cache_triton( - kv_cache, - k_fp8, - k_scale, - chunk.block_table, - chunk.cu_seq_lens, - token_to_seq=chunk.token_to_seq, - ) - logits = rocm_fp8_mqa_logits( q_fp8[chunk.token_start : chunk.token_end], (k_fp8, k_scale.view(torch.float32)), @@ -843,72 +878,113 @@ def _expand_2d_block_scales( return scale -def _apply_gptj_inv_rope_ref( - x: torch.Tensor, +@triton.jit +def _inverse_rope_gptj_kernel( + o_ptr, # [T, H, D] input + out_ptr, # [T, H, D] bf16 output + pos_ptr, # [T] positions + cos_sin_ptr, # [P, rope_dim] fp32 (cos[:half] | sin[half:]) + s_t, + s_h, # input row strides (last dim contiguous) + os_t, + os_h, # output row strides + cs_stride, # cos_sin_cache row stride + NOPE: tl.constexpr, # non-rope head dims (passed through) + HALF: tl.constexpr, # rope_dim // 2 + BLOCK_NOPE: tl.constexpr, + BLOCK_HALF: tl.constexpr, +): + """Fused inverse GPT-J RoPE on the trailing rope_dim of each (token, head). + + Mirrors ``DeepseekV4ScalingRotaryEmbedding.forward_native(inverse=True)`` + for the GPT-J (non-neox) layout, writing bf16 directly. Replaces the + clone + index_select + repeat_interleave + neg + stack + cat + cast chain + (~10 small kernels) with a single launch. + """ + t = tl.program_id(0) + h = tl.program_id(1) + in_base = t * s_t + h * s_h + out_base = t * os_t + h * os_h + + # NoPE lanes pass through unchanged (only cast to bf16). + n = tl.arange(0, BLOCK_NOPE) + nmask = n < NOPE + vals = tl.load(o_ptr + in_base + n, mask=nmask) + tl.store(out_ptr + out_base + n, vals.to(tl.bfloat16), mask=nmask) + + # RoPE lanes: out_even = a*cos + b*sin, out_odd = b*cos - a*sin + # (a = even lane, b = odd lane; sin negated for the inverse rotation). + pos = tl.load(pos_ptr + t).to(tl.int64) + k = tl.arange(0, BLOCK_HALF) + kmask = k < HALF + a = tl.load(o_ptr + in_base + NOPE + 2 * k, mask=kmask).to(tl.float32) + b = tl.load(o_ptr + in_base + NOPE + 2 * k + 1, mask=kmask).to(tl.float32) + cos = tl.load(cos_sin_ptr + pos * cs_stride + k, mask=kmask) + sin = tl.load(cos_sin_ptr + pos * cs_stride + HALF + k, mask=kmask) + out_even = a * cos + b * sin + out_odd = b * cos - a * sin + tl.store(out_ptr + out_base + NOPE + 2 * k, out_even.to(tl.bfloat16), mask=kmask) + tl.store(out_ptr + out_base + NOPE + 2 * k + 1, out_odd.to(tl.bfloat16), mask=kmask) + + +def _fused_inverse_rope_gptj( + o: torch.Tensor, positions: torch.Tensor, cos_sin_cache: torch.Tensor, - rope_dim: int, -) -> torch.Tensor: - if rope_dim == 0 or x.numel() == 0: - return x - half_rot = rope_dim // 2 - nope_dim = x.shape[-1] - rope_dim - dtype = x.dtype - x = x.to(torch.float32) - cache = cos_sin_cache.index_select(0, positions.to(torch.long)) - cos = cache[:, :half_rot].to(torch.float32) - sin = cache[:, half_rot : 2 * half_rot].to(torch.float32) - view_shape = (positions.shape[0],) + (1,) * (x.dim() - 2) + (half_rot,) - cos = cos.view(view_shape) - sin = sin.view(view_shape) - rope = x[..., nope_dim:] - y_even = rope[..., 0::2] - y_odd = rope[..., 1::2] - rope_out = torch.stack( - (y_even * cos + y_odd * sin, y_odd * cos - y_even * sin), - dim=-1, - ).flatten(-2) - x = x.clone() - x[..., nope_dim:] = rope_out - return x.to(dtype) - - -def _apply_inv_rope_ref( - rotary_emb: torch.nn.Module, - x: torch.Tensor, - positions: torch.Tensor, - rope_dim: int, + rope_head_dim: int, ) -> torch.Tensor: - if hasattr(rotary_emb, "forward_native"): - try: - query, _ = rotary_emb.forward_native( - positions, - x.clone(), - None, - inverse=True, - ) - return query - except TypeError: - pass - return _apply_gptj_inv_rope_ref(x, positions, rotary_emb.cos_sin_cache, rope_dim) + """bf16 inverse GPT-J RoPE via a single fused Triton kernel.""" + assert o.dim() == 3 and o.stride(-1) == 1, ( + "_fused_inverse_rope_gptj expects a [T, H, D] input with a contiguous last dim" + ) + assert rope_head_dim > 0 and rope_head_dim % 2 == 0, ( + f"_fused_inverse_rope_gptj expects an even rope_head_dim, got {rope_head_dim}" + ) + assert cos_sin_cache.shape[-1] == rope_head_dim, ( + "_fused_inverse_rope_gptj expects cos_sin_cache laid out as " + f"[P, {rope_head_dim}] = cos | sin, got {tuple(cos_sin_cache.shape)}" + ) + num_tokens, num_heads, head_dim = o.shape + out = torch.empty( + (num_tokens, num_heads, head_dim), dtype=torch.bfloat16, device=o.device + ) + if num_tokens == 0: + return out + _inverse_rope_gptj_kernel[(num_tokens, num_heads)]( + o, + out, + positions, + cos_sin_cache, + o.stride(0), + o.stride(1), + out.stride(0), + out.stride(1), + cos_sin_cache.stride(0), + NOPE=head_dim - rope_head_dim, + HALF=rope_head_dim // 2, + BLOCK_NOPE=triton.next_power_of_2(head_dim - rope_head_dim), + BLOCK_HALF=triton.next_power_of_2(rope_head_dim // 2), + ) + return out -def rocm_inv_rope_einsum( - rotary_emb: torch.nn.Module, - o: torch.Tensor, - positions: torch.Tensor, - rope_head_dim: int, +def _get_cached_wo_a_bf16( + wo_a: torch.nn.Module, n_local_groups: int, o_lora_rank: int, - wo_a: torch.nn.Module, + hidden_dim: int, ) -> torch.Tensor: - """Reference inverse-RoPE + WO_A einsum path used on ROCm.""" - o_ref = _apply_inv_rope_ref(rotary_emb, o, positions, rope_head_dim).to( - torch.bfloat16 - ) - o_ref = o_ref.view(o.shape[0], n_local_groups, -1) + """Dequantize wo_a to bf16 once and cache it on the module. - hidden_dim = o_ref.shape[-1] + wo_a weights are static, so the fp8 -> fp32 -> (* block scale) -> bf16 + dequant only needs to run once. Recomputing it every decode step shows up + in the profile as the largest copy/mul kernels (``direct_copy float`` ~55us + and ``MulFunctor float`` ~31us per two layers). SGLang / ATOM keep wo_a in + bf16 and feed a plain bf16 GEMM; this mirrors that. + """ + cached = getattr(wo_a, "_dsv4_wo_a_bf16", None) + if cached is not None: + return cached if hasattr(wo_a, "weight_scale_inv"): wo_a_weight = wo_a.weight.view(n_local_groups, o_lora_rank, hidden_dim).to( torch.float32 @@ -920,11 +996,37 @@ def rocm_inv_rope_einsum( o_lora_rank, hidden_dim, ) - wo_a_weight = (wo_a_weight * wo_a_scale).to(torch.bfloat16) + cached = (wo_a_weight * wo_a_scale).to(torch.bfloat16) else: - wo_a_weight = wo_a.weight.view(n_local_groups, o_lora_rank, hidden_dim).to( + cached = wo_a.weight.view(n_local_groups, o_lora_rank, hidden_dim).to( torch.bfloat16 ) + wo_a._dsv4_wo_a_bf16 = cached + return cached + + +def rocm_inv_rope_einsum( + rotary_emb: torch.nn.Module, + o: torch.Tensor, + positions: torch.Tensor, + rope_head_dim: int, + n_local_groups: int, + o_lora_rank: int, + wo_a: torch.nn.Module, +) -> torch.Tensor: + """Inverse-RoPE + WO_A bmm path used on ROCm. + + Fuses the inverse GPT-J RoPE into one Triton kernel and caches the bf16 + wo_a weight so the per-step dequant disappears. + """ + o_ref = _fused_inverse_rope_gptj( + o, positions, rotary_emb.cos_sin_cache, rope_head_dim + ) + o_ref = o_ref.view(o.shape[0], n_local_groups, -1) + + wo_a_weight = _get_cached_wo_a_bf16( + wo_a, n_local_groups, o_lora_rank, o_ref.shape[-1] + ) return torch.einsum("tgd,grd->tgr", o_ref, wo_a_weight) @@ -1170,7 +1272,10 @@ def _sparse_attn_decode_ragged_kernel( NOPE_DIM: tl.constexpr, NOPE_BLOCK: tl.constexpr, ROPE_DIM: tl.constexpr, - IS_FNUZ: tl.constexpr, + # SWA K-cache (main): C++ encoder writes FNUZ on gfx942, OCP on gfx950. + # Compressed K-cache (extra): Triton encoder writes OCP everywhere. + IS_FNUZ_MAIN: tl.constexpr, + IS_FNUZ_EXTRA: tl.constexpr, BLOCK_H: tl.constexpr, BLOCK_K: tl.constexpr, ): @@ -1227,8 +1332,8 @@ def _sparse_attn_decode_ragged_kernel( mask=valid[:, None] & nope_mask[None, :], other=0, ) - if IS_FNUZ: - x_fp8 = x_uint8.to(tl.float8e4b15, bitcast=True) + if IS_FNUZ_MAIN: + x_fp8 = x_uint8.to(tl.float8e4b8, bitcast=True) else: x_fp8 = x_uint8.to(tl.float8e4nv, bitcast=True) encoded_scales = tl.load( @@ -1295,8 +1400,8 @@ def _sparse_attn_decode_ragged_kernel( mask=valid[:, None] & nope_mask[None, :], other=0, ) - if IS_FNUZ: - x_fp8 = x_uint8.to(tl.float8e4b15, bitcast=True) + if IS_FNUZ_EXTRA: + x_fp8 = x_uint8.to(tl.float8e4b8, bitcast=True) else: x_fp8 = x_uint8.to(tl.float8e4nv, bitcast=True) encoded_scales = tl.load( @@ -1375,6 +1480,353 @@ def _sparse_attn_decode_ragged_kernel( ) +@triton.jit +def _sparse_attn_decode_partial_kernel( + q_ptr, + main_cache_ptr, + main_indices_ptr, + main_indptr_ptr, + extra_cache_ptr, + extra_indices_ptr, + extra_indptr_ptr, + part_m_ptr, + part_l_ptr, + part_acc_ptr, + q_stride0, + q_stride1, + main_cache_stride0, + extra_cache_stride0, + pm_stride0, + pm_stride_s, + pa_stride0, + pa_stride_s, + pa_stride_h, + main_num_rows, + extra_num_rows, + main_block_size, + extra_block_size, + scale, + num_heads, + HAS_EXTRA: tl.constexpr, + NOPE_DIM: tl.constexpr, + NOPE_BLOCK: tl.constexpr, + ROPE_DIM: tl.constexpr, + # `main_cache` is the SWA K-cache (written by the C++ encoder, FNUZ on + # gfx942 / OCP on gfx950). `extra_cache` is the compressed K-cache + # (Triton encoder, OCP on every platform). Reading both with the same + # `IS_FNUZ` would decode one of them with the wrong FNUZ/OCP scale ratio. + IS_FNUZ_MAIN: tl.constexpr, + IS_FNUZ_EXTRA: tl.constexpr, + BLOCK_H: tl.constexpr, + BLOCK_K: tl.constexpr, + NUM_SPLITS: tl.constexpr, + NUM_STAGES: tl.constexpr, +): + query_idx = tl.program_id(0) + split_id = tl.program_id(1) + pid_h = tl.program_id(2) + + head_offsets = pid_h * BLOCK_H + tl.arange(0, BLOCK_H) + head_mask = head_offsets < num_heads + nope_offsets = tl.arange(0, NOPE_BLOCK) + nope_mask = nope_offsets < NOPE_DIM + rope_offsets = tl.arange(0, ROPE_DIM) + + q_row_ptr = q_ptr + query_idx * q_stride0 + head_offsets[:, None] * q_stride1 + q_nope = tl.load( + q_row_ptr + nope_offsets[None, :], + mask=head_mask[:, None] & nope_mask[None, :], + other=0.0, + ) + q_rope = tl.load( + q_row_ptr + NOPE_DIM + rope_offsets[None, :], + mask=head_mask[:, None], + other=0.0, + ) + + neg_large = -3.4028234663852886e38 + m_i = tl.full((BLOCK_H,), neg_large, dtype=tl.float32) + l_i = tl.zeros((BLOCK_H,), dtype=tl.float32) + acc_nope = tl.zeros((BLOCK_H, NOPE_BLOCK), dtype=tl.float32) + acc_rope = tl.zeros((BLOCK_H, ROPE_DIM), dtype=tl.float32) + k_offsets = tl.arange(0, BLOCK_K) + + zero_nope = tl.zeros((BLOCK_K, NOPE_BLOCK), dtype=tl.bfloat16) + zero_rope = tl.zeros((BLOCK_K, ROPE_DIM), dtype=tl.bfloat16) + + # Each split processes a contiguous slice of this query's main (SWA) and + # extra (topk) segments. Slices are handled independently so a block never + # straddles the main/extra boundary. + main_start = tl.load(main_indptr_ptr + query_idx) + main_end = tl.load(main_indptr_ptr + query_idx + 1) + main_len = main_end - main_start + main_chunk = (main_len + NUM_SPLITS - 1) // NUM_SPLITS + main_lo = split_id * main_chunk + main_hi = tl.minimum(main_lo + main_chunk, main_len) + + for k_start in tl.range(main_lo, main_hi, BLOCK_K, num_stages=NUM_STAGES): + k_pos = k_start + k_offsets + in_range = k_pos < main_hi + slot = tl.load(main_indices_ptr + main_start + k_pos, mask=in_range, other=-1) + valid = in_range & (slot >= 0) & (slot < main_num_rows) + safe_slot = tl.where(valid, slot, 0) + + block_idx = safe_slot // main_block_size + pos_in_block = safe_slot % main_block_size + cache_block_ptr = main_cache_ptr + block_idx.to(tl.int64) * main_cache_stride0 + token_data_ptr = cache_block_ptr + pos_in_block * 576 + token_scale_ptr = cache_block_ptr + main_block_size * 576 + pos_in_block * 8 + + x_uint8 = tl.load( + token_data_ptr[:, None] + nope_offsets[None, :], + mask=valid[:, None] & nope_mask[None, :], + other=0, + ) + if IS_FNUZ_MAIN: + x_fp8 = x_uint8.to(tl.float8e4b8, bitcast=True) + else: + x_fp8 = x_uint8.to(tl.float8e4nv, bitcast=True) + encoded_scales = tl.load( + token_scale_ptr[:, None] + nope_offsets[None, :] // 64, + mask=valid[:, None] & nope_mask[None, :], + other=127, + ) + scales = tl.exp2(encoded_scales.to(tl.float32) - 127.0) + k_nope = x_fp8.to(tl.bfloat16) * scales.to(tl.bfloat16) + k_nope = tl.where(valid[:, None] & nope_mask[None, :], k_nope, zero_nope) + k_nope = tl.where(k_nope == k_nope, k_nope, zero_nope) + + rope_ptr = (token_data_ptr + NOPE_DIM).to(tl.pointer_type(tl.bfloat16)) + k_rope = tl.load( + rope_ptr[:, None] + rope_offsets[None, :], + mask=valid[:, None], + other=0.0, + ) + k_rope = tl.where(valid[:, None], k_rope, zero_rope) + k_rope = tl.where(k_rope == k_rope, k_rope, zero_rope) + + scores = tl.dot(q_nope, tl.trans(k_nope)) + tl.dot(q_rope, tl.trans(k_rope)) + scores *= scale + scores = tl.where(head_mask[:, None] & valid[None, :], scores, neg_large) + + m_block = tl.max(scores, axis=1) + m_new = tl.maximum(m_i, m_block) + alpha = tl.exp(m_i - m_new) + p = tl.exp(scores - m_new[:, None]) + p = tl.where(head_mask[:, None] & valid[None, :], p, 0.0) + l_new = l_i * alpha + tl.sum(p, axis=1) + + acc_nope = acc_nope * alpha[:, None] + tl.dot(p.to(k_nope.dtype), k_nope) + acc_rope = acc_rope * alpha[:, None] + tl.dot(p.to(k_rope.dtype), k_rope) + m_i = m_new + l_i = l_new + + if HAS_EXTRA: + extra_start = tl.load(extra_indptr_ptr + query_idx) + extra_end = tl.load(extra_indptr_ptr + query_idx + 1) + extra_len = extra_end - extra_start + extra_chunk = (extra_len + NUM_SPLITS - 1) // NUM_SPLITS + extra_lo = split_id * extra_chunk + extra_hi = tl.minimum(extra_lo + extra_chunk, extra_len) + + for k_start in tl.range(extra_lo, extra_hi, BLOCK_K, num_stages=NUM_STAGES): + k_pos = k_start + k_offsets + in_range = k_pos < extra_hi + slot = tl.load( + extra_indices_ptr + extra_start + k_pos, mask=in_range, other=-1 + ) + valid = in_range & (slot >= 0) & (slot < extra_num_rows) + safe_slot = tl.where(valid, slot, 0) + + block_idx = safe_slot // extra_block_size + pos_in_block = safe_slot % extra_block_size + cache_block_ptr = ( + extra_cache_ptr + block_idx.to(tl.int64) * extra_cache_stride0 + ) + token_data_ptr = cache_block_ptr + pos_in_block * 576 + token_scale_ptr = ( + cache_block_ptr + extra_block_size * 576 + pos_in_block * 8 + ) + + x_uint8 = tl.load( + token_data_ptr[:, None] + nope_offsets[None, :], + mask=valid[:, None] & nope_mask[None, :], + other=0, + ) + if IS_FNUZ_EXTRA: + x_fp8 = x_uint8.to(tl.float8e4b8, bitcast=True) + else: + x_fp8 = x_uint8.to(tl.float8e4nv, bitcast=True) + encoded_scales = tl.load( + token_scale_ptr[:, None] + nope_offsets[None, :] // 64, + mask=valid[:, None] & nope_mask[None, :], + other=127, + ) + scales = tl.exp2(encoded_scales.to(tl.float32) - 127.0) + k_nope = x_fp8.to(tl.bfloat16) * scales.to(tl.bfloat16) + k_nope = tl.where(valid[:, None] & nope_mask[None, :], k_nope, zero_nope) + k_nope = tl.where(k_nope == k_nope, k_nope, zero_nope) + + rope_ptr = (token_data_ptr + NOPE_DIM).to(tl.pointer_type(tl.bfloat16)) + k_rope = tl.load( + rope_ptr[:, None] + rope_offsets[None, :], + mask=valid[:, None], + other=0.0, + ) + k_rope = tl.where(valid[:, None], k_rope, zero_rope) + k_rope = tl.where(k_rope == k_rope, k_rope, zero_rope) + + scores = tl.dot(q_nope, tl.trans(k_nope)) + tl.dot( + q_rope, + tl.trans(k_rope), + ) + scores *= scale + scores = tl.where(head_mask[:, None] & valid[None, :], scores, neg_large) + + m_block = tl.max(scores, axis=1) + m_new = tl.maximum(m_i, m_block) + alpha = tl.exp(m_i - m_new) + p = tl.exp(scores - m_new[:, None]) + p = tl.where(head_mask[:, None] & valid[None, :], p, 0.0) + l_new = l_i * alpha + tl.sum(p, axis=1) + + acc_nope = acc_nope * alpha[:, None] + tl.dot(p.to(k_nope.dtype), k_nope) + acc_rope = acc_rope * alpha[:, None] + tl.dot(p.to(k_rope.dtype), k_rope) + m_i = m_new + l_i = l_new + + # Store raw (un-normalized) partial state for this split. Softmax sink and + # final normalization happen in the reduce kernel. + pm_base = query_idx * pm_stride0 + split_id * pm_stride_s + head_offsets + tl.store(part_m_ptr + pm_base, m_i, mask=head_mask) + tl.store(part_l_ptr + pm_base, l_i, mask=head_mask) + acc_base = ( + part_acc_ptr + + query_idx * pa_stride0 + + split_id * pa_stride_s + + head_offsets[:, None] * pa_stride_h + ) + tl.store( + acc_base + nope_offsets[None, :], + acc_nope, + mask=head_mask[:, None] & nope_mask[None, :], + ) + tl.store( + acc_base + NOPE_DIM + rope_offsets[None, :], + acc_rope, + mask=head_mask[:, None], + ) + + +@triton.jit +def _sparse_attn_decode_reduce_kernel( + part_m_ptr, + part_l_ptr, + part_acc_ptr, + attn_sink_ptr, + out_ptr, + out_stride0, + out_stride1, + pm_stride0, + pm_stride_s, + pa_stride0, + pa_stride_s, + pa_stride_h, + num_heads, + HAS_ATTN_SINK: tl.constexpr, + COMB_DIM: tl.constexpr, + BLOCK_H: tl.constexpr, + NUM_SPLITS: tl.constexpr, + SPLITS_PAD: tl.constexpr, +): + query_idx = tl.program_id(0) + pid_h = tl.program_id(1) + + head_offsets = pid_h * BLOCK_H + tl.arange(0, BLOCK_H) + head_mask = head_offsets < num_heads + comb_offsets = tl.arange(0, COMB_DIM) + # SPLITS_PAD is NUM_SPLITS rounded up to a power of two so the parallel + # split-axis load is a legal arange for any split count; padding lanes are + # masked off. + split_offsets = tl.arange(0, SPLITS_PAD) + split_mask = split_offsets < NUM_SPLITS + + neg_large = -3.4028234663852886e38 + + # Phase 1: load every split's running max/sum at once and reduce the max + # in parallel (tl.max over the split axis) instead of walking the splits + # serially. This breaks the long online-softmax dependency chain that made + # the reduce latency-bound. + load_mask = split_mask[:, None] & head_mask[None, :] + pm_split = ( + part_m_ptr + + query_idx * pm_stride0 + + split_offsets[:, None] * pm_stride_s + + head_offsets[None, :] + ) + m_all = tl.load(pm_split, mask=load_mask, other=neg_large) # [S, H] + l_all = tl.load( + part_l_ptr + + query_idx * pm_stride0 + + split_offsets[:, None] * pm_stride_s + + head_offsets[None, :], + mask=load_mask, + other=0.0, + ) + + m_comb = tl.max(m_all, axis=0) # [H] + if HAS_ATTN_SINK: + sink = tl.load( + attn_sink_ptr + head_offsets, mask=head_mask, other=neg_large + ).to(tl.float32) + m_final = tl.maximum(m_comb, sink) + else: + m_final = m_comb + + w_all = tl.exp(m_all - m_final[None, :]) # [S, H] + w_all = tl.where(load_mask, w_all, 0.0) + l_final = tl.sum(w_all * l_all, axis=0) # [H] + if HAS_ATTN_SINK: + l_final = l_final + tl.exp(sink - m_final) + denom = tl.maximum(l_final, 1.0e-30) + + # Phase 2: weighted sum of the per-split accumulators. The combine weight + # for each split only depends on the (already known) global max, so the + # acc loads carry no cross-split dependency and the compiler can pipeline + # them; only the cheap FMA into `acc` is loop-carried. + acc = tl.zeros((BLOCK_H, COMB_DIM), dtype=tl.float32) + for s in tl.static_range(NUM_SPLITS): + m_s = tl.load( + part_m_ptr + query_idx * pm_stride0 + s * pm_stride_s + head_offsets, + mask=head_mask, + other=neg_large, + ) + w_s = tl.exp(m_s - m_final) + acc_base = ( + part_acc_ptr + + query_idx * pa_stride0 + + s * pa_stride_s + + head_offsets[:, None] * pa_stride_h + ) + acc_s = tl.load( + acc_base + comb_offsets[None, :], + mask=head_mask[:, None], + other=0.0, + ) + acc += w_s[:, None] * acc_s + + out = tl.where(l_final[:, None] > 0.0, acc / denom[:, None], 0.0) + + out_row_ptr = ( + out_ptr + query_idx * out_stride0 + head_offsets[:, None] * out_stride1 + ) + tl.store( + out_row_ptr + comb_offsets[None, :], + out, + mask=head_mask[:, None], + ) + + def _rocm_sparse_attn_prefill_ragged_triton( q: torch.Tensor, kv: torch.Tensor, @@ -1471,6 +1923,101 @@ def _rocm_sparse_attn_prefill_triton( ) +@functools.lru_cache +def _decode_cu_count() -> int: + try: + return torch.cuda.get_device_properties(0).multi_processor_count + except Exception: + return 256 # For gfx950 arch, gated behind a fallback path for other archs. + + +def _decode_partial_iters( + avg_main_len: float, avg_extra_len: float, splits: int, block_k: int +) -> int: + """BLOCK_K iterations one partial workgroup walks for ``splits`` splits. + + Each split processes ``ceil(seg_len / splits)`` tokens of a segment, walked + ``BLOCK_K`` at a time, and the main/extra segments are handled separately. + """ + main_iters = ( + math.ceil(math.ceil(avg_main_len / splits) / block_k) if avg_main_len > 0 else 0 + ) + extra_iters = ( + math.ceil(math.ceil(avg_extra_len / splits) / block_k) + if avg_extra_len > 0 + else 0 + ) + return main_iters + extra_iters + + +def _decode_num_splits( + num_queries: int, + heads_blocks: int, + avg_main_len: float = 0.0, + avg_extra_len: float = 0.0, + block_k: int = 32, +) -> int: + """Pick a flash-decode split count to keep the GPU busy across batch sizes. + + Decode launches only ``num_queries * heads_blocks`` workgroups otherwise, + which severely under-fills the device for the low-concurrency regime that + dominates latency. Splitting the KV sequence adds parallelism. + + We model the relative partial-kernel latency for a given split count ``s`` + as ``waves * (1/s + mu)`` where ``waves = ceil(base * s / CU)`` and ``mu`` + is a small per-wave overhead penalty: + + - ``waves / s`` captures the partial compute: each wave walks roughly + ``total_tokens / s`` tokens and there are ``waves`` of them, so dividing + by ``s`` makes more splits cheaper *until* they spill into extra waves. + - ``mu * waves`` charges per-wave launch/tail overhead so we do not + over-split into many mostly-idle waves (e.g. batch 224 on 256 CUs is + best left at 1 split rather than 8 splits across 7 waves). + + The minimiser naturally prefers split counts that pack the device into full + waves (``base * s`` near a multiple of ``CU``) and falls back to 1 split + once the batch already fills the device. Ties favour the smaller split + count (less reduce work). + + Finally we "snap down" the chosen split count to the smallest value that + yields the same wave count *and* the same per-workgroup BLOCK_K iteration + count. Because latency tracks iteration count (not raw token count), extra + splits that do not lower the iteration count add only reduce/HBM overhead + for no parallelism gain (e.g. batch 24: s8 and s10 both walk 4 extra iters + in one wave, so s8 is strictly better). Snapping needs the average segment + lengths, which the caller derives sync-free from the ragged index sizes. + """ + base = max(1, num_queries * heads_blocks) + # Target ~1 workgroup per CU: enough to fill the device while keeping the + # reduce cost (which grows with split count) small. Tuned on gfx950. + cu = max(1, _decode_cu_count()) + # Per-wave overhead penalty: higher values discourage split counts that + # spill into extra GPU waves. Tuned on gfx950. + mu = 0.04 + best_splits = 1 + best_cost = None + # Search up to 16 splits; beyond that the reduce/HBM overhead dominates. + for splits in range(1, 17): + waves = (base * splits + cu - 1) // cu + cost = waves * (1.0 / splits + mu) + if best_cost is None or cost < best_cost - 1e-9: + best_splits = splits + best_cost = cost + + if best_splits > 1 and (avg_main_len > 0 or avg_extra_len > 0): + target_waves = (base * best_splits + cu - 1) // cu + target_iters = _decode_partial_iters( + avg_main_len, avg_extra_len, best_splits, block_k + ) + for splits in range(1, best_splits): + waves = (base * splits + cu - 1) // cu + iters = _decode_partial_iters(avg_main_len, avg_extra_len, splits, block_k) + if waves == target_waves and iters == target_iters: + best_splits = splits + break + return best_splits + + def _rocm_sparse_attn_decode_ragged_triton( q: torch.Tensor, main_cache: torch.Tensor, @@ -1544,9 +2091,71 @@ def _rocm_sparse_attn_decode_ragged_triton( extra_indptr = torch.zeros(num_queries + 1, device=q.device, dtype=torch.int32) block_h = 16 - block_k = 16 if head_dim >= 256 else 32 out = torch.empty_like(q, dtype=torch.bfloat16) - _sparse_attn_decode_ragged_kernel[(num_queries, triton.cdiv(num_heads, block_h))]( + heads_blocks = triton.cdiv(num_heads, block_h) + nope_block = triton.next_power_of_2(nope_head_dim) + comb_dim = nope_head_dim + rope_head_dim + is_fnuz = current_platform.is_fp8_fnuz() + + if not _ON_GFX950: # Fallback path for un-tuned architectures. + block_k = 16 if head_dim >= 256 else 32 + _sparse_attn_decode_ragged_kernel[(num_queries, heads_blocks)]( + q, + main_cache, + main_indices, + main_indptr, + extra_cache, + extra_indices, + extra_indptr, + attn_sink, + out, + q.stride(0), + q.stride(1), + out.stride(0), + out.stride(1), + main_cache.stride(0), + extra_cache.stride(0), + main_cache.shape[0] * main_cache.shape[1], + extra_cache.shape[0] * extra_cache.shape[1], + main_cache.shape[1], + extra_cache.shape[1], + scale, + num_heads, + HAS_ATTN_SINK=has_attn_sink, + HAS_EXTRA=has_extra, + NOPE_DIM=nope_head_dim, + NOPE_BLOCK=nope_block, + ROPE_DIM=rope_head_dim, + IS_FNUZ_MAIN=is_fnuz, + IS_FNUZ_EXTRA=False, + BLOCK_H=block_h, + BLOCK_K=block_k, + num_warps=8, + ) + return out + + block_k = 32 # KV tokens walked per split-K iteration. Tuned on gfx950. + # Average per-query segment lengths, read sync-free from the ragged index + # sizes, let the split heuristic avoid over-splitting + # main_indices/extra_indices are flat [nnz] int32. + inv_q = 1.0 / max(1, num_queries) + avg_main_len = main_indices.numel() * inv_q + avg_extra_len = (extra_indices.numel() * inv_q) if has_extra else 0.0 + num_splits = _decode_num_splits( + num_queries, heads_blocks, avg_main_len, avg_extra_len, block_k + ) + + part_m = torch.empty( + (num_queries, num_splits, num_heads), dtype=torch.float32, device=q.device + ) + part_l = torch.empty_like(part_m) + part_acc = torch.empty( + (num_queries, num_splits, num_heads, comb_dim), + dtype=torch.float32, + device=q.device, + ) + + _sparse_attn_decode_partial_kernel[(num_queries, num_splits, heads_blocks)]( q, main_cache, main_indices, @@ -1554,29 +2163,61 @@ def _rocm_sparse_attn_decode_ragged_triton( extra_cache, extra_indices, extra_indptr, - attn_sink, - out, + part_m, + part_l, + part_acc, q.stride(0), q.stride(1), - out.stride(0), - out.stride(1), main_cache.stride(0), extra_cache.stride(0), + part_m.stride(0), + part_m.stride(1), + part_acc.stride(0), + part_acc.stride(1), + part_acc.stride(2), main_cache.shape[0] * main_cache.shape[1], extra_cache.shape[0] * extra_cache.shape[1], main_cache.shape[1], extra_cache.shape[1], scale, num_heads, - HAS_ATTN_SINK=has_attn_sink, HAS_EXTRA=has_extra, NOPE_DIM=nope_head_dim, - NOPE_BLOCK=triton.next_power_of_2(nope_head_dim), + NOPE_BLOCK=nope_block, ROPE_DIM=rope_head_dim, - IS_FNUZ=current_platform.is_fp8_fnuz(), + # main_cache = swa_k_cache (C++ encoder, FNUZ on gfx942 / OCP on gfx950). + # extra_cache = compressed kv_cache (Triton encoder, OCP everywhere). + # Reading both with a single IS_FNUZ would decode one of them with the + # wrong FNUZ/OCP scale ratio (~1.87×). + IS_FNUZ_MAIN=is_fnuz, + IS_FNUZ_EXTRA=False, BLOCK_H=block_h, BLOCK_K=block_k, - num_warps=8, + NUM_SPLITS=num_splits, + NUM_STAGES=1, + num_warps=4, + ) + + _sparse_attn_decode_reduce_kernel[(num_queries, heads_blocks)]( + part_m, + part_l, + part_acc, + attn_sink, + out, + out.stride(0), + out.stride(1), + part_m.stride(0), + part_m.stride(1), + part_acc.stride(0), + part_acc.stride(1), + part_acc.stride(2), + num_heads, + HAS_ATTN_SINK=has_attn_sink, + COMB_DIM=comb_dim, + BLOCK_H=block_h, + NUM_SPLITS=num_splits, + SPLITS_PAD=triton.next_power_of_2(num_splits), + num_warps=4, ) return out diff --git a/vllm/v1/attention/ops/triton_attention_helpers.py b/vllm/v1/attention/ops/triton_attention_helpers.py index 6ed50f6a2df2..b2667935c6f7 100644 --- a/vllm/v1/attention/ops/triton_attention_helpers.py +++ b/vllm/v1/attention/ops/triton_attention_helpers.py @@ -153,6 +153,8 @@ def compute_tile_loop_bounds( SLIDING_WINDOW: tl.constexpr, USE_MM_PREFIX: tl.constexpr, IS_3D: tl.constexpr, + USE_CAUSAL: tl.constexpr = True, + USE_PER_SEQ_CAUSAL: tl.constexpr = False, CHUNK_LOOKBACK: tl.constexpr = -1, CHUNK_SIZE: tl.constexpr = -1, ): @@ -163,10 +165,11 @@ def compute_tile_loop_bounds( 1. Longest prefix spanned by any query token in this q-block. Clamped to ``seq_len`` (causal) or extended to it when - mm_prefix is active (bidirectional ranges can reach past the - causal prefix). + mm_prefix is active or non-causal sequences need the full + sequence. 2. Sliding-window pruning: narrows ``[tile_start, tile_end)`` to only tiles that can contain an allowed key under SWA. + For non-causal sequences, the window extends in both directions. 3. 3D scoping: when ``IS_3D`` is True, further narrows to the segment's slice via ``(segm_idx * tiles_per_segment, (segm_idx + 1) * tiles_per_segment)``. @@ -179,10 +182,13 @@ def compute_tile_loop_bounds( + (BLOCK_M - 1) // num_queries_per_kv + 1 ) - if USE_MM_PREFIX: - # image bidirectional attention ranges require a full range - # including q_block padding to make sure doc mask is correct - max_seq_prefix_len = tl.maximum(max_seq_prefix_len, seq_len) + if USE_MM_PREFIX or USE_PER_SEQ_CAUSAL or (not USE_CAUSAL): + # Read the full sequence but never past seq_len: the causal-style + # formula above can overshoot for non-causal sequences, and slots + # >= seq_len are unwritten KV (last-block tail) that may hold NaN + # (0 * NaN poisons the output). Per-element masking in + # compute_kv_seq_mask handles the causal/non-causal boundary. + max_seq_prefix_len = seq_len else: max_seq_prefix_len = tl.minimum(max_seq_prefix_len, seq_len) @@ -207,12 +213,17 @@ def compute_tile_loop_bounds( # [context_len + qpos_lo - SLIDING_WINDOW + 1, context_len + qpos_hi] q_abs = context_len + qpos_lo if CHUNK_LOOKBACK > -1: - # Chunked attention: align lower bound to the start of the - # lookback'th previous chunk. first_allowed_key = ((q_abs // CHUNK_SIZE) - CHUNK_LOOKBACK) * CHUNK_SIZE else: first_allowed_key = q_abs - SLIDING_WINDOW + 1 - last_allowed_key = context_len + qpos_hi + if USE_PER_SEQ_CAUSAL or (not USE_CAUSAL): + # Non-causal: keys can be AHEAD of query within the window + last_allowed_key = tl.minimum( + context_len + qpos_hi + SLIDING_WINDOW - 1, + seq_len - 1, + ) + else: + last_allowed_key = context_len + qpos_hi # Convert to tile indices and clamp tile_start = tl.maximum(0, first_allowed_key // TILE_SIZE) tile_end = tl.minimum((last_allowed_key // TILE_SIZE) + 1, num_tiles) @@ -262,12 +273,20 @@ def compute_kv_seq_mask( query_abs_pos, seq_offset, seq_idx, + seq_len, mm_prefix_range_ptr, SLIDING_WINDOW: tl.constexpr, USE_MM_PREFIX: tl.constexpr, MAX_MM_RANGES: tl.constexpr, + USE_CAUSAL: tl.constexpr = True, + USE_PER_SEQ_CAUSAL: tl.constexpr = False, + per_seq_causal_ptr=None, + rswa_prefix_lens_ptr=None, + R_SWA_WINDOW: tl.constexpr = 0, + USE_R_SWA: tl.constexpr = False, CHUNK_LOOKBACK: tl.constexpr = -1, CHUNK_SIZE: tl.constexpr = -1, + MM_PREFIX_CLAMP_SW: tl.constexpr = False, ): """Build the KV mask for one tile. @@ -279,9 +298,23 @@ def compute_kv_seq_mask( Chunked attention takes precedence over sliding window when both are non-default — the launcher zeros ``CHUNK_LOOKBACK`` whenever sliding window is disabled. + + When ``USE_PER_SEQ_CAUSAL`` is set, each sequence carries its own + causal flag via ``per_seq_causal_ptr``; non-causal sequences use a + simple ``key < seq_len`` bound instead. ``USE_CAUSAL=False`` + disables causal masking entirely. """ - # Compute attention mask: causal by default (key <= query) - seq_mask = seq_offset[None, :] <= query_abs_pos + if USE_PER_SEQ_CAUSAL: + is_causal = tl.load(per_seq_causal_ptr + seq_idx) + seq_mask = tl.where( + is_causal, + seq_offset[None, :] <= query_abs_pos, + seq_offset[None, :] < seq_len, + ) + elif USE_CAUSAL: + seq_mask = seq_offset[None, :] <= query_abs_pos + else: + seq_mask = seq_offset[None, :] < seq_len # Apply sliding window / chunked attention to base mask # BEFORE mm_prefix OR. @@ -292,11 +325,32 @@ def compute_kv_seq_mask( (query_abs_pos // CHUNK_SIZE - seq_offset[None, :] // CHUNK_SIZE) <= CHUNK_LOOKBACK ) - elif SLIDING_WINDOW > 0: - seq_mask = seq_mask & ((query_abs_pos - seq_offset) < SLIDING_WINDOW) + elif SLIDING_WINDOW > 0 and not USE_R_SWA: + sw_left = (query_abs_pos - seq_offset) < SLIDING_WINDOW + if USE_PER_SEQ_CAUSAL: + sw_right = (seq_offset[None, :] - query_abs_pos) < SLIDING_WINDOW + seq_mask = seq_mask & tl.where(is_causal, sw_left, sw_left & sw_right) + elif not USE_CAUSAL: + sw_right = (seq_offset[None, :] - query_abs_pos) < SLIDING_WINDOW + seq_mask = seq_mask & sw_left & sw_right + else: + seq_mask = seq_mask & sw_left + + if USE_R_SWA: + prefix_len = tl.load(rswa_prefix_lens_ptr + seq_idx) + in_prefix = seq_offset[None, :] < prefix_len + in_window = (query_abs_pos - seq_offset) < R_SWA_WINDOW + seq_mask = seq_mask & (in_prefix | in_window) # PrefixLM: extend mask with bidirectional ranges for multimodal tokens. - # Applied AFTER sliding window so mm_prefix ranges override SW restriction. + # Default (MM_PREFIX_CLAMP_SW=False): applied AFTER sliding window so + # mm_prefix ranges override the SW restriction -> (causal AND SW) OR mm. + # Gemma4 (MM_PREFIX_CLAMP_SW=True): the bidirectional image block must stay + # within the sliding window, matching HF's (causal OR blockwise) AND + # sliding_window. We AND each range with the SW past-bound + # (query_abs_pos - seq_offset) < SLIDING_WINDOW (== HF sliding_window_overlay + # kv > q - sw; future kv passes trivially). Inert for full-attention layers + # (SLIDING_WINDOW <= 0). if USE_MM_PREFIX: for i in range(MAX_MM_RANGES): range_start = tl.load( @@ -314,7 +368,10 @@ def compute_kv_seq_mask( & (seq_offset[None, :] <= range_end) & is_valid ) - seq_mask |= q_in_range & k_in_range + mm_mask = q_in_range & k_in_range + if MM_PREFIX_CLAMP_SW and SLIDING_WINDOW > 0: + mm_mask = mm_mask & ((query_abs_pos - seq_offset) < SLIDING_WINDOW) + seq_mask |= mm_mask return seq_mask diff --git a/vllm/v1/attention/ops/triton_decode_attention.py b/vllm/v1/attention/ops/triton_decode_attention.py index c58a7026e89b..6aec1db2e59c 100644 --- a/vllm/v1/attention/ops/triton_decode_attention.py +++ b/vllm/v1/attention/ops/triton_decode_attention.py @@ -56,6 +56,15 @@ def tanh(x): return 2 * tl.sigmoid(2 * x) - 1 +def _page_stride(buf, page_size): + # Stride between pages. 4D buffers have a page dim; 3D buffers pack pages + # along the token dim, so split it out first. Read the real stride (a + # cross-layer view has gaps), don't assume PAGE_SIZE * token stride. + if buf.ndim == 3: + buf = buf.unflatten(-3, (-1, page_size)) + return buf.stride(-4) + + @triton.jit def _fwd_kernel_stage1( Q, @@ -68,8 +77,10 @@ def _fwd_kernel_stage1( stride_req_to_tokens_b, stride_qbs, stride_qh, + stride_buf_kpbs, stride_buf_kbs, stride_buf_kh, + stride_buf_vpbs, stride_buf_vbs, stride_buf_vh, stride_mid_ob, @@ -122,10 +133,12 @@ def _fwd_kernel_stage1( + offs_n // PAGE_SIZE, mask=offs_n < split_kv_end, other=0, - ) - kv_loc = kv_page_number * PAGE_SIZE + offs_n % PAGE_SIZE + ).to(tl.int64) # page_number * page stride overflows int32 + kv_in_page = offs_n % PAGE_SIZE offs_buf_k = ( - kv_loc[:, None] * stride_buf_kbs + (kv_page_number * stride_buf_kpbs + kv_in_page * stride_buf_kbs)[ + :, None + ] + cur_kv_head * stride_buf_kh + offs_d[None, :] ) @@ -145,7 +158,9 @@ def _fwd_kernel_stage1( qk = tl.where(offs_n < split_kv_end, qk, float("-inf")) offs_buf_v = ( - kv_loc[:, None] * stride_buf_vbs + (kv_page_number * stride_buf_vpbs + kv_in_page * stride_buf_vbs)[ + :, None + ] + cur_kv_head * stride_buf_vh + offs_dv[None, :] ) @@ -235,8 +250,10 @@ def _decode_att_m_fwd( Req_to_tokens.stride(0), q.stride(0), q.stride(1), + _page_stride(k_buffer, page_size), k_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) k_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) + _page_stride(v_buffer, page_size), v_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) v_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) att_out.stride(0), @@ -270,8 +287,10 @@ def _fwd_grouped_kernel_stage1( stride_req_to_tokens_b, stride_qbs, stride_qh, + stride_buf_kpbs, stride_buf_kbs, stride_buf_kh, + stride_buf_vpbs, stride_buf_vbs, stride_buf_vh, stride_mid_ob, @@ -356,11 +375,13 @@ def _fwd_grouped_kernel_stage1( mask=offs_n < split_kv_end, other=0, cache_modifier=".ca", + ).to(tl.int64) # page_number * page stride overflows int32 + kv_off_k = ( + kv_page_number * stride_buf_kpbs + (offs_n % PAGE_SIZE) * stride_buf_kbs ) - kv_loc = kv_page_number * PAGE_SIZE + offs_n % PAGE_SIZE # explicitly facilitate overlapping load/compute - offs_buf_k = kv_loc[None, :] * stride_buf_kbs + base_offs_k + offs_buf_k = kv_off_k[None, :] + base_offs_k k = tl.load( K_Buffer + offs_buf_k, mask=(offs_n[None, :] < split_kv_end) & (mask_d[:, None]), @@ -372,7 +393,7 @@ def _fwd_grouped_kernel_stage1( k = (k.to(tl.float32) * ks).to(q.dtype) qk = tl.dot(q, k.to(q.dtype)) if BLOCK_DPE > 0: - offs_buf_kpe = kv_loc[None, :] * stride_buf_kbs + base_offs_kpe + offs_buf_kpe = kv_off_k[None, :] + base_offs_kpe kpe = tl.load( K_Buffer + offs_buf_kpe, mask=(offs_n[None, :] < split_kv_end) & (mask_dpe[:, None]), @@ -392,7 +413,11 @@ def _fwd_grouped_kernel_stage1( ) if not IS_MLA: - offs_buf_v = kv_loc[:, None] * stride_buf_vbs + base_offs_v + kv_off_v = ( + kv_page_number * stride_buf_vpbs + + (offs_n % PAGE_SIZE) * stride_buf_vbs + ) + offs_buf_v = kv_off_v[:, None] + base_offs_v v = tl.load( V_Buffer + offs_buf_v, mask=(offs_n[:, None] < split_kv_end) & (mask_dv[None, :]), @@ -517,8 +542,10 @@ def _decode_grouped_att_m_fwd( Req_to_tokens.stride(0), q.stride(0), q.stride(1), + _page_stride(k_buffer, page_size), k_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) k_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) + _page_stride(v_buffer, page_size), v_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) v_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM) att_out.stride(0), diff --git a/vllm/v1/attention/ops/triton_fp8_mqa_logits.py b/vllm/v1/attention/ops/triton_fp8_mqa_logits.py new file mode 100644 index 000000000000..619d0ec50a9d --- /dev/null +++ b/vllm/v1/attention/ops/triton_fp8_mqa_logits.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Temporary gfx942 fallback for AITER's fp8_mqa_logits kernel. + +This module vendors AITER's Triton fp8_mqa_logits kernel with the gfx942 +tile-size workaround from ROCm/aiter#3257. It is used only while vLLM's +pinned AITER version lacks that fix. + +TODO: Remove this vendored copy once vLLM pins an AITER version that includes +ROCm/aiter#3257 bugfix for gfx942. +""" + +import torch + +from vllm.triton_utils import tl, triton + +# gfx942 (MI300X) has 64 KiB of LDS per CU. We accept the default +# (BLOCK_KV=128, num_stages=2) tile only when *both* of these hold: +# +# 1. Occupancy gate. With waves_per_eu=2 and num_warps=4 we target two +# workgroups co-resident on a CU -> per-WG LDS budget = 32 KiB. Triton +# keeps Q in registers (loop-invariant) and the fp32 scores accumulator +# in VGPRs (heavy VALU), so only the double-buffered KV tile is +# expected to live in LDS. A 0.9 safety factor leaves headroom for any +# LDS overhead the compiler may add. +# +# 2. Hardware ceiling. Defensive upper bound that also counts Q and +# scores against the 64 KiB CU limit, in case a Triton version (older +# or future) decides to spill them to LDS. False positives here only +# shrink the tile; false negatives are JIT-aborts, so we lean +# conservative. +_GFX942_CU_LDS_BYTES = 64 * 1024 +_GFX942_PER_WG_LDS_BUDGET_BYTES = _GFX942_CU_LDS_BYTES * 9 // 20 # ~28.8 KiB + + +def _gfx942_default_tile_fits_lds(num_heads: int, head_size: int) -> bool: + """Return True iff (BLOCK_KV=128, num_stages=2) fits in MI300X LDS.""" + BLOCK_KV = 128 + NUM_STAGES = 2 + kv_bytes = head_size * BLOCK_KV * NUM_STAGES + scores_bytes = num_heads * BLOCK_KV * 4 + q_bytes = num_heads * head_size + fits_occupancy = kv_bytes < _GFX942_PER_WG_LDS_BUDGET_BYTES + fits_hardware = q_bytes + kv_bytes + scores_bytes <= _GFX942_CU_LDS_BYTES + return fits_occupancy and fits_hardware + + +@triton.jit +def _fp8_mqa_logits_kernel( + Q_ptr, # fp8e4m3 [seq_len, H, D] + KV_ptr, # fp8e4m3 [seq_len_kv, D] + kv_scales_ptr, # fp32 [seq_len_kv] + weights_ptr, # fp32 [seq_len, H] + cu_start_ptr, # int32 [seq_len] + cu_end_ptr, # int32 [seq_len] + logits_ptr, # fp32 [seq_len, seq_len_kv] + seq_len, + seq_len_kv, + NUM_HEADS: tl.constexpr, + HEAD_SIZE: tl.constexpr, + # strides + stride_q_s: tl.int64, + stride_q_h: tl.constexpr, + stride_q_d: tl.constexpr, + stride_kv_s: tl.int64, + stride_kv_d: tl.constexpr, + stride_w_s: tl.int64, + stride_w_h: tl.constexpr, + stride_logits_s: tl.int64, + stride_logits_k: tl.int64, + # block sizes + BLOCK_KV: tl.constexpr, +): + row_id = tl.program_id(0) + # go from larger to smaller in terms of work + # to reduce the tail effect + row_id = tl.num_programs(0) - row_id - 1 + tl.assume(row_id >= 0) + tl.assume(stride_q_s > 0) + tl.assume(stride_q_h > 0) + tl.assume(stride_q_d > 0) + tl.assume(stride_kv_s > 0) + tl.assume(stride_kv_d > 0) + tl.assume(stride_w_s > 0) + tl.assume(stride_w_h > 0) + + logits_row_ptrs = logits_ptr + row_id * stride_logits_s + + h_inds = tl.arange(0, NUM_HEADS)[:, None] + d_inds = tl.arange(0, HEAD_SIZE) + + # load Q[BLOCK_Q, NUM_HEADS, HEAD_SIZE] + q_ptrs = ( + Q_ptr + row_id * stride_q_s + h_inds * stride_q_h + d_inds[None, :] * stride_q_d + ) + + q_block = tl.load(q_ptrs, cache_modifier=".cg") + w_ptrs = weights_ptr + row_id * stride_w_s + h_inds * stride_w_h + w_block = tl.load(w_ptrs, cache_modifier=".cg").to(tl.float32) + + # Load start/end for each row in this block + start_ind = tl.load(cu_start_ptr + row_id) + end_ind = tl.load(cu_end_ptr + row_id) + + start_ind = tl.maximum(start_ind, 0) + end_ind = tl.minimum(end_ind, seq_len_kv) + shifted_end = end_ind - start_ind + shifted_unmasked_end = shifted_end // BLOCK_KV * BLOCK_KV + + kv_col_offsets = tl.arange(0, BLOCK_KV) + start_ind + kv_ptrs = ( + KV_ptr + kv_col_offsets[None, :] * stride_kv_s + d_inds[:, None] * stride_kv_d + ) + + kv_scales_ptrs = kv_scales_ptr + kv_col_offsets + + logits_ptrs = logits_row_ptrs + kv_col_offsets * stride_logits_k + + # Loop over KV tiles + for _ in tl.range(0, shifted_unmasked_end, BLOCK_KV): + kv_block = tl.load(kv_ptrs) + kv_scales = tl.load(kv_scales_ptrs) + + # [NUM_HEADS, BLOCK_KV] = [NUM_HEADS, HEAD_SIZE] x [HEAD_SIZE, BLOCK_KV] + scores = tl.dot(q_block, kv_block, input_precision="ieee") + # Multiply by kv_scales (broadcast along rows) + scores = scores * kv_scales[None, :] + # ReLU + scores = tl.maximum(scores, 0.0) + scores = scores * w_block + # [NUM_HEADS, BLOCK_KV] -> [BLOCK_KV, ] + scores = tl.sum(scores, axis=0) + tl.store(logits_ptrs, scores) + + kv_ptrs += BLOCK_KV * stride_kv_s + kv_scales_ptrs += BLOCK_KV + logits_ptrs += BLOCK_KV * stride_logits_k + kv_col_offsets += BLOCK_KV + + # masked load + kv_col_mask = kv_col_offsets < end_ind + kv_block = tl.load(kv_ptrs, mask=kv_col_mask[None, :], other=0.0) + kv_scales = tl.load(kv_scales_ptrs, mask=kv_col_mask, other=0.0) + + # [NUM_HEADS, BLOCK_KV] = [NUM_HEADS, HEAD_SIZE] x [HEAD_SIZE, BLOCK_KV] + scores = tl.dot(q_block, kv_block, input_precision="ieee") + # Multiply by kv_scales (broadcast along rows) + scores = scores * kv_scales[None, :] + # ReLU + scores = tl.maximum(scores, 0.0) + scores = scores * w_block + # [NUM_HEADS, BLOCK_KV] -> [BLOCK_KV, ] + scores = tl.sum(scores, axis=0) + # masked store + in_window = (kv_col_offsets >= start_ind) & (kv_col_offsets < end_ind) + tl.store(logits_ptrs, scores, mask=in_window) + + +def fp8_mqa_logits_gfx942( + q: torch.Tensor, + k_fp8: torch.Tensor, + kv_scales: torch.Tensor, + weights: torch.Tensor, + cu_starts: torch.Tensor, + cu_ends: torch.Tensor, +) -> torch.Tensor: + """Compute FP8 MQA logits on MI300X (gfx942) using the vendored kernel. + + Drop-in replacement for ``aiter.ops.triton.attention.fp8_mqa_logits. + fp8_mqa_logits`` on MI300X. Selects ``(BLOCK_KV, num_stages)`` based on + whether the default tile fits within the 64 KiB LDS budget of a gfx942 + CU (see module docstring). + + Args: + q: Query tensor of shape ``[M, H, D]``, FP8 dtype. + k_fp8: Key tensor of shape ``[N, D]``, FP8 dtype. + kv_scales: K scales of shape ``[N]`` (or ``[N, 1]`` -- viewed as + ``[N]``), float32. + weights: Per-head weights of shape ``[M, H]``, float32. + cu_starts: Start indices (inclusive) of shape ``[M]``, int32. + cu_ends: End indices (exclusive) of shape ``[M]``, int32. + + Returns: + Logits of shape ``[M, N]``, float32 -- positions outside + ``[cu_starts[i], cu_ends[i])`` for row ``i`` are pre-filled with + ``-inf`` so the caller can run a top-k without masking. + """ + seq_len, num_heads, head_size = q.shape + seq_len_kv = k_fp8.shape[0] + assert num_heads & (num_heads - 1) == 0, ( + f"num_heads must be a power of two (got {num_heads})" + ) + assert head_size & (head_size - 1) == 0, ( + f"head_size must be a power of two (got {head_size})" + ) + + # The kernel walks ``kv_scales`` as a 1-D contiguous array of size N + # (it indexes by ``kv_scales_ptr + kv_col_offsets``). The vLLM caller + # passes a ``[N, 4]`` uint8 view-cast-to-float32 which lands as + # ``[N, 1]`` contiguous -- byte-identical to ``[N]`` -- but flatten + # explicitly to keep the kernel's pointer arithmetic intent clear. + kv_scales_1d = kv_scales.reshape(-1) + + # Initialise with -inf so positions outside [cu_starts, cu_ends) read + # as ``-inf`` after the masked store path -- this matches AITER's + # ``fp8_mqa_logits`` semantics and is what the top-k consumer expects. + logits = torch.full( + (seq_len, seq_len_kv), + fill_value=-float("inf"), + dtype=torch.float32, + device=q.device, + ) + + if _gfx942_default_tile_fits_lds(num_heads, head_size): + block_kv = 128 + num_stages = 2 + else: + # DSv4 sparse indexer (NUM_HEADS=64, HEAD_SIZE=128) lands here: + # default tile spills past gfx942's 64 KiB LDS budget. (64, 1) + # needs ~33 KiB and clears the per-WG budget with margin. + block_kv = 64 + num_stages = 1 + + # heuristic for MFMA instruction shape, identical to AITER's choice + matrix_instr_nonkdim = 32 + if seq_len <= 1024: + matrix_instr_nonkdim = 16 + + stride_q_s, stride_q_h, stride_q_d = q.stride() + stride_kv_s, stride_kv_d = k_fp8.stride() + stride_w_s, stride_w_h = weights.stride() + stride_logits_s, stride_logits_k = logits.stride() + + _fp8_mqa_logits_kernel[(seq_len,)]( + Q_ptr=q, + KV_ptr=k_fp8, + kv_scales_ptr=kv_scales_1d, + weights_ptr=weights, + cu_start_ptr=cu_starts, + cu_end_ptr=cu_ends, + logits_ptr=logits, + seq_len=seq_len, + seq_len_kv=seq_len_kv, + NUM_HEADS=num_heads, + HEAD_SIZE=head_size, + stride_q_s=stride_q_s, + stride_q_h=stride_q_h, + stride_q_d=stride_q_d, + stride_kv_s=stride_kv_s, + stride_kv_d=stride_kv_d, + stride_w_s=stride_w_s, + stride_w_h=stride_w_h, + stride_logits_s=stride_logits_s, + stride_logits_k=stride_logits_k, + BLOCK_KV=block_kv, + num_warps=4, + num_stages=num_stages, + waves_per_eu=2, + matrix_instr_nonkdim=matrix_instr_nonkdim, + ) + + return logits diff --git a/vllm/v1/attention/ops/triton_prefill_attention.py b/vllm/v1/attention/ops/triton_prefill_attention.py index e9b123fa0d8a..aaf23c8f4a1d 100644 --- a/vllm/v1/attention/ops/triton_prefill_attention.py +++ b/vllm/v1/attention/ops/triton_prefill_attention.py @@ -38,6 +38,7 @@ def _fwd_kernel( Q, K, V, + Sinks, sm_scale, B_Start_Loc, B_Seqlen, @@ -57,6 +58,7 @@ def _fwd_kernel( IS_CAUSAL: tl.constexpr, SLIDING_WINDOW_Q: tl.constexpr, SLIDING_WINDOW_K: tl.constexpr, + USE_SINKS: tl.constexpr, Lk: tl.constexpr, ): cur_batch = tl.program_id(0) @@ -94,8 +96,13 @@ def _fwd_kernel( v_ptrs = V + off_v # initialize pointer to m and l - m_i = tl.zeros([BLOCK_M], dtype=tl.float32) - float("inf") - l_i = tl.zeros([BLOCK_M], dtype=tl.float32) + if USE_SINKS: + sink = tl.load(Sinks + cur_head) * 1.4426950408889634 + m_i = tl.full([BLOCK_M], sink, dtype=tl.float32) + l_i = tl.full([BLOCK_M], 1.0, dtype=tl.float32) + else: + m_i = tl.zeros([BLOCK_M], dtype=tl.float32) - float("inf") + l_i = tl.zeros([BLOCK_M], dtype=tl.float32) acc = tl.zeros([BLOCK_M, BLOCK_DMODEL], dtype=tl.float32) block_mask = tl.where(block_start_loc < cur_batch_seq_len, 1, 0) @@ -200,6 +207,7 @@ def context_attention_fwd( softmax_scale: float | None = None, sliding_window_q: int | None = None, sliding_window_k: int | None = None, + sinks: torch.Tensor | None = None, ): """ q, k, v: [b * s, head, head_dim] @@ -217,6 +225,8 @@ def context_attention_fwd( batch, head = b_seq_len.shape[0], q.shape[1] kv_group_num = q.shape[1] // k.shape[1] + if sinks is not None: + assert sinks.shape[0] == head, "Sinks must be num_query_heads size" grid = (batch, head, triton.cdiv(max_input_len, BLOCK)) num_warps = 4 if Lk <= 64 else 8 @@ -228,6 +238,7 @@ def context_attention_fwd( q, k, v, + sinks if sinks is not None else q, sm_scale, b_start_loc, b_seq_len, @@ -247,6 +258,7 @@ def context_attention_fwd( IS_CAUSAL=is_causal, SLIDING_WINDOW_Q=sliding_window_q, SLIDING_WINDOW_K=sliding_window_k, + USE_SINKS=sinks is not None, num_warps=num_warps, num_stages=1, Lk=Lk, diff --git a/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py b/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py index 08c6673fb589..0f0022c2cb46 100644 --- a/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py +++ b/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py @@ -10,6 +10,7 @@ from vllm.platforms import current_platform from vllm.triton_utils import tl, triton from vllm.utils.torch_utils import is_quantized_kv_cache +from vllm.v1.kv_cache_interface import KVQuantMode FP8_MIN, FP8_MAX = get_fp8_min_max() @@ -17,9 +18,16 @@ def _is_supported_kv_cache_dtype(kv_cache_dtype: str) -> bool: - return kv_cache_dtype in _NATIVE_KV_CACHE_DTYPES or is_quantized_kv_cache( - kv_cache_dtype - ) + if not ( + kv_cache_dtype in _NATIVE_KV_CACHE_DTYPES + or is_quantized_kv_cache(kv_cache_dtype) + ): + return False + if kv_cache_dtype.startswith("fp8"): + return current_platform.has_device_capability(89) or current_platform.is_xpu() + if kv_cache_dtype == "bfloat16": + return current_platform.has_device_capability(80) or current_platform.is_xpu() + return True @triton.jit @@ -174,6 +182,7 @@ def _reshape_cache_per_token_head( HEAD_SIZE_PADDED: tl.constexpr, # next_power_of_2(max(head_size, head_size_v)) QUANT_MAX: tl.constexpr = 127.0, QUANT_MIN: tl.constexpr = -128.0, + IS_INT_QUANT: tl.constexpr = False, ): tok = tl.program_id(0) head = tl.program_id(1) @@ -204,7 +213,11 @@ def _reshape_cache_per_token_head( k_scale, ) - k_q = tl.clamp(k_h * (1.0 / k_scale), QUANT_MIN, QUANT_MAX) + k_q = k_h * (1.0 / k_scale) + if IS_INT_QUANT: + # Round half away from zero before the int8 store truncates. + k_q = tl.where(k_q >= 0, k_q + 0.5, k_q - 0.5) + k_q = tl.clamp(k_q, QUANT_MIN, QUANT_MAX) tl.store( key_cache_ptr + blk * stride_kc_blk @@ -232,7 +245,11 @@ def _reshape_cache_per_token_head( v_scale, ) - v_q = tl.clamp(v_h * (1.0 / v_scale), QUANT_MIN, QUANT_MAX) + v_q = v_h * (1.0 / v_scale) + if IS_INT_QUANT: + # Round half away from zero before the int8 store truncates. + v_q = tl.where(v_q >= 0, v_q + 0.5, v_q - 0.5) + v_q = tl.clamp(v_q, QUANT_MIN, QUANT_MAX) tl.store( value_cache_ptr + blk * stride_vc_blk @@ -260,6 +277,7 @@ def triton_reshape_and_cache_flash_per_token_head_quant( k_scale_cache: torch.Tensor, # [num_blocks, block_size, num_kv_heads] float32 v_scale_cache: torch.Tensor, # [num_blocks, block_size, num_kv_heads] float32 slot_mapping: torch.Tensor, # [num_tokens] + kv_quant_mode: KVQuantMode, ): """Quantize key/value per (token, head) and write to paged cache. @@ -267,9 +285,26 @@ def triton_reshape_and_cache_flash_per_token_head_quant( quantized data in key_cache/value_cache, and stores the float32 scale in k_scale_cache/v_scale_cache. - The quantization range (QUANT_MAX, QUANT_MIN) is derived from the - cache tensor dtype so the same code path works for int8 and fp8. + INT4 needs sub-byte packing + a Hadamard rotation, so it is handled by + its own kernel; INT8 / FP8 share this kernel, with the quantization + range (QUANT_MAX, QUANT_MIN) derived from the cache tensor dtype. """ + if kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD: + from vllm.v1.attention.ops.int4_per_token_head import ( + reshape_and_cache_int4, + ) + + reshape_and_cache_int4( + key, + value, + key_cache, + value_cache, + slot_mapping, + k_scale_cache=k_scale_cache, + v_scale_cache=v_scale_cache, + ) + return + cache_dtype = key_cache.dtype quant_params = _PER_TOKEN_HEAD_QUANT_PARAMS.get(cache_dtype) if quant_params is None: @@ -320,6 +355,7 @@ def triton_reshape_and_cache_flash_per_token_head_quant( HEAD_SIZE_PADDED=head_size_padded, QUANT_MAX=quant_max, QUANT_MIN=quant_min, + IS_INT_QUANT=cache_dtype == torch.int8, num_warps=num_warps, ) @@ -359,7 +395,9 @@ def triton_reshape_and_cache_flash( page_stride = key_cache.stride()[1] assert _is_supported_kv_cache_dtype(kv_cache_dtype), ( - f"unsupported kv_cache_dtype (str), got {kv_cache_dtype}." + f"Triton reshape-and-cache cannot store kv_cache_dtype={kv_cache_dtype} " + f"on this device: an FP8 KV cache needs native fp8e4nv (SM89+). Use " + f"--kv-cache-dtype bfloat16 (or float16 on SM75)." ) kv_cache_torch_dtype = ( current_platform.fp8_dtype() @@ -374,23 +412,7 @@ def triton_reshape_and_cache_flash( # (e.g. explicit cast to fp8e4m3fnuz is not supported in triton 3.4) key_cache = key_cache.view(kv_cache_torch_dtype) value_cache = value_cache.view(kv_cache_torch_dtype) - assert kv_cache_dtype != torch.uint8, ( - "explicit fp8 cast and store to " - "uint8 is not supported by triton reshape_and_cache_flash" - ) - FP8_KV_CACHE = is_quantized_kv_cache(kv_cache_dtype) - assert (not FP8_KV_CACHE) or kv_cache_torch_dtype in [ - torch.float8_e4m3fn, - torch.float8_e5m2, - torch.uint8, - torch.float8_e4m3fnuz, - ], ( - "unsupported dtype of KV cache tensor, got " - "{kv_cache_torch_dtype}. Supported kv cache dtypes: fp8e4m3fn, " - "fp8e5m2, uint8, bfloat16, float16, float32, fp8e4m3fnuz." - ) - # heuristics instead of autotuning TILE_SIZE = min(2048, triton.next_power_of_2(n)) if current_platform.is_rocm() or current_platform.is_xpu(): @@ -537,9 +559,6 @@ def triton_reshape_and_cache_flash_diffkv( block_stride = kv_cache.stride()[0] page_stride = kv_cache.stride()[1] - assert _is_supported_kv_cache_dtype(kv_cache_dtype), ( - f"unsupported kv_cache_dtype (str), got {kv_cache_dtype}." - ) kv_cache_torch_dtype = ( current_platform.fp8_dtype() if is_quantized_kv_cache(kv_cache_dtype) @@ -550,23 +569,7 @@ def triton_reshape_and_cache_flash_diffkv( # to avoid erounous implicit cast in triton kernel (tl.store to uint8) # (e.g. explicit cast to fp8e4m3fnuz is not supported in triton 3.4) kv_cache = kv_cache.view(kv_cache_torch_dtype) - assert kv_cache_dtype != torch.uint8, ( - "explicit fp8 cast and store to " - "uint8 is not supported by triton reshape_and_cache_flash_diffkv" - ) - FP8_KV_CACHE = is_quantized_kv_cache(kv_cache_dtype) - assert (not FP8_KV_CACHE) or kv_cache_torch_dtype in [ - torch.float8_e4m3fn, - torch.float8_e5m2, - torch.uint8, - torch.float8_e4m3fnuz, - ], ( - "unsupported dtype of KV cache tensor, got " - "{kv_cache_torch_dtype}. Supported kv cache dtypes: fp8e4m3fn, " - "fp8e5m2, uint8, bfloat16, float16, float32, fp8e4m3fnuz." - ) - # heuristics instead of autotuning TILE_SIZE = max(head_size_k, head_size_v) TILE_SIZE = triton.next_power_of_2(TILE_SIZE) diff --git a/vllm/v1/attention/ops/triton_unified_attention.py b/vllm/v1/attention/ops/triton_unified_attention.py index 56f1d1c1d084..93622957b559 100644 --- a/vllm/v1/attention/ops/triton_unified_attention.py +++ b/vllm/v1/attention/ops/triton_unified_attention.py @@ -215,9 +215,15 @@ def kernel_unified_attention( USE_SOFTCAP: tl.constexpr, # bool USE_SINKS: tl.constexpr, # bool SLIDING_WINDOW: tl.constexpr, # int + USE_CAUSAL: tl.constexpr, # bool + USE_PER_SEQ_CAUSAL: tl.constexpr, # bool + per_seq_causal_ptr, # [num_seqs] bool, or None USE_MM_PREFIX: tl.constexpr, # bool MAX_MM_RANGES: tl.constexpr, # int mm_prefix_range_ptr, + rswa_prefix_lens_ptr, + R_SWA_WINDOW: tl.constexpr, # int + USE_R_SWA: tl.constexpr, # bool stride_k_cache_0: tl.int64, # int stride_k_cache_1: tl.int64, # int stride_k_cache_2: tl.int64, # int @@ -248,15 +254,23 @@ def kernel_unified_attention( # Per-(token, head) scale caches: used iff KV_QUANT_MODE in {2, 3}. k_scale_cache_ptr=None, v_scale_cache_ptr=None, - stride_ks_blk: tl.int64 = None, - stride_ks_slot: tl.int64 = None, - stride_ks_head: tl.int64 = None, - stride_vs_blk: tl.int64 = None, - stride_vs_slot: tl.int64 = None, - stride_vs_head: tl.int64 = None, + # ``tl.int64`` cannot be combined with a ``None`` default — Triton's JIT + # rejects ``Optional[tl.int64]`` / ``tl.int64 | None`` at trace time, and + # plain ``tl.int64 = None`` raises ``TypeError: 'NoneType' object cannot + # be interpreted as an integer`` when callers omit these arguments. + # ``int | None`` is the only annotation that lets the wrapper pass + # ``None`` here so Triton can skip materialising the strides when the + # ``USE_PER_TOKEN_HEAD_SCALES`` branch is dead. + stride_ks_blk: int | None = None, + stride_ks_slot: int | None = None, + stride_ks_head: int | None = None, + stride_vs_blk: int | None = None, + stride_vs_slot: int | None = None, + stride_vs_head: int | None = None, # KV cache quantization mode handled inside this kernel via constexpr # branches: NONE (0), FP8_PER_TENSOR (1), INT8_PER_TOKEN_HEAD (2), - # FP8_PER_TOKEN_HEAD (3). + # FP8_PER_TOKEN_HEAD (3). Sub-byte INT4 (4) uses its own + # int4_per_token_head kernel, not this one. KV_QUANT_MODE: tl.constexpr = 0, FP8_MIN: tl.constexpr = float8_info.min, FP8_MAX: tl.constexpr = float8_info.max, @@ -271,8 +285,15 @@ def kernel_unified_attention( USE_TD: tl.constexpr = False, USE_TD_QO: tl.constexpr = False, Q_IS_FP8: tl.constexpr = False, + # Gemma4: clamp mm_prefix bidirectional ranges by the sliding window + # instead of letting them override it. Default False preserves the + # original (causal AND SW) OR mm_prefix behavior for all other models. + MM_PREFIX_CLAMP_SW: tl.constexpr = False, ): - USE_PER_TOKEN_HEAD_SCALES: tl.constexpr = KV_QUANT_MODE >= 2 + # Per-(token, head) scale caches: used iff KV_QUANT_MODE in {2, 3}. + USE_PER_TOKEN_HEAD_SCALES: tl.constexpr = (KV_QUANT_MODE >= 2) and ( + KV_QUANT_MODE <= 3 + ) USE_FP8_Q_DESCALE: tl.constexpr = KV_QUANT_MODE == 1 and Q_IS_FP8 if USE_TD: @@ -387,8 +408,10 @@ def kernel_unified_attention( BLOCK_Q, num_queries_per_kv, SLIDING_WINDOW, - USE_MM_PREFIX, + USE_MM_PREFIX or USE_R_SWA, IS_3D, + USE_CAUSAL, + USE_PER_SEQ_CAUSAL, CHUNK_LOOKBACK, CHUNK_SIZE, ) @@ -493,12 +516,20 @@ def kernel_unified_attention( query_abs_pos, seq_offset, seq_idx, + seq_len, mm_prefix_range_ptr, SLIDING_WINDOW, USE_MM_PREFIX, MAX_MM_RANGES, + USE_CAUSAL, + USE_PER_SEQ_CAUSAL, + per_seq_causal_ptr, + rswa_prefix_lens_ptr, + R_SWA_WINDOW, + USE_R_SWA, CHUNK_LOOKBACK, CHUNK_SIZE, + MM_PREFIX_CLAMP_SW, ) # S : (BLOCK_M, TILE_SIZE) @@ -532,11 +563,19 @@ def kernel_unified_attention( if SLIDING_WINDOW: qpos_lo = q_block_local_idx * BLOCK_Q - V = tl.where( - (context_len + qpos_lo - seq_offset[:, None]) < SLIDING_WINDOW, - V, - 0.0, - ) + dist = context_len + qpos_lo - seq_offset[:, None] + if USE_PER_SEQ_CAUSAL: + is_causal_seq = tl.load(per_seq_causal_ptr + seq_idx) + sw_mask_v = tl.where( + is_causal_seq, + dist < SLIDING_WINDOW, + (dist < SLIDING_WINDOW) & (dist > -SLIDING_WINDOW), + ) + elif USE_CAUSAL: + sw_mask_v = dist < SLIDING_WINDOW + else: + sw_mask_v = (dist < SLIDING_WINDOW) & (dist > -SLIDING_WINDOW) + V = tl.where(sw_mask_v, V, 0.0) if USE_PER_TOKEN_HEAD_SCALES: # Per-token-head quant: apply v_scale to P instead of V. P_v = (P * v_token_head_scales[None, :]).to(V.dtype) @@ -789,6 +828,10 @@ def unified_attention( sinks=None, # Optional tensor for prefix lengths (PrefixLM support) mm_prefix_range=None, + # R-SWA support: prefix tokens stay globally visible, generated tokens use + # a fixed sliding window. + rswa_prefix_lens=None, + rswa_window: int | None = None, use_alibi_sqrt=False, # KV cache quantization mode and per-token-head scale caches. kv_quant_mode: KVQuantMode = KVQuantMode.NONE, @@ -801,8 +844,56 @@ def unified_attention( # The non-TD branch is dead-code-eliminated at Triton compile time so # disabling this flag costs nothing. use_td: bool = False, + # Gemma4: clamp mm_prefix bidirectional ranges by the sliding window. + # Default False keeps the original behavior for every other model. + mm_prefix_clamp_sliding_window: bool = False, ): - assert causal, "Only causal attention is supported" + # Resolve causal: bool or per-seq tensor. + use_per_seq_causal = isinstance(causal, torch.Tensor) + use_causal = bool(causal) if not use_per_seq_causal else True + per_seq_causal_ptr = causal if use_per_seq_causal else None + + # Sub-byte packed mode (INT4) needs a bespoke kernel (split-dot + + # sub-byte unpack); everything else goes through the core kernel below. + if kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD: + assert use_causal and not use_per_seq_causal, ( + "INT4_PER_TOKEN_HEAD only supports causal attention" + ) + from vllm.v1.attention.ops.int4_per_token_head import ( + unified_attention_int4, + ) + + if sinks is not None: + assert sinks.shape[0] == q.shape[1], "Sinks must be num_query_heads size" + unified_attention_int4( + q=q, + k_cache=k, + v_cache=v, + out=out, + cu_seqlens_q=cu_seqlens_q, + max_seqlen_q=max_seqlen_q, + seqused_k=seqused_k, + max_seqlen_k=max_seqlen_k, + softmax_scale=softmax_scale, + window_size=window_size, + block_table=block_table, + softcap=softcap, + sinks=sinks, + alibi_slopes=alibi_slopes, + use_alibi_sqrt=use_alibi_sqrt, + qq_bias=qq_bias, + output_scale=output_scale, + mm_prefix_range=mm_prefix_range, + k_scale_cache=k_scale_cache, + v_scale_cache=v_scale_cache, + seq_threshold_3D=seq_threshold_3D, + num_par_softmax_segments=num_par_softmax_segments, + softmax_segm_output=softmax_segm_output, + softmax_segm_max=softmax_segm_max, + softmax_segm_expsum=softmax_segm_expsum, + ) + return + if sinks is not None: assert sinks.shape[0] == q.shape[1], "Sinks must be num_query_heads size" @@ -826,6 +917,8 @@ def unified_attention( f"Unsupported mm_prefix_range shape: {mm_prefix_range.shape}" ) + use_rswa = rswa_window is not None and rswa_prefix_lens is not None + use_alibi_slopes = alibi_slopes is not None use_qq_bias = qq_bias is not None @@ -841,6 +934,26 @@ def unified_attention( ) BLOCK_Q = BLOCK_M // num_queries_per_kv + # Tuned launch parameters; ``None`` lets Triton pick its defaults. + launch_num_warps: int | None = None + launch_num_stages: int | None = None + + # head_size 256 with many query rows per sequence (e.g. diffusion-gemma + # bidirectional canvas passes) is prefill-shaped, but the decode-oriented + # defaults (BLOCK_Q=8, TILE=32, 4 warps) under-tile it. A wider KV tile + + # more query rows per block + 8 warps is ~2x faster on B200. + tuned_large_head = ( + head_size == 256 + and max_seqlen_q > 1 + and num_queries_per_kv <= 16 + and current_platform.is_device_capability_family(100) + ) + if tuned_large_head: + BLOCK_M = 32 + BLOCK_Q = BLOCK_M // num_queries_per_kv + launch_num_warps = 8 + launch_num_stages = 2 + # Ideally we would launch with kernel with: # \sum_i[ceil(query_len[i] / BLOCK_Q)] blocks. # However, it is slow to realize the query_lens on cpu. @@ -869,6 +982,11 @@ def unified_attention( head_size, sliding_window_val, q.element_size(), is_prefill=False ) + # Wider KV tile for the tuned large-head path (see above). Only the 2D + # path (used when max_seqlen_q > 1) reads TILE_SIZE_PREFILL. + if tuned_large_head: + TILE_SIZE_PREFILL = 128 + # USE_TD requires BLOCK_SIZE % TILE_SIZE == 0 (enforced by a # ``tl.static_assert`` in the kernel). The default prefill tile # size (32) is larger than a common ``block_size=16``, so clamp it @@ -933,9 +1051,9 @@ def unified_attention( # The kernel signature is the same for 2D and 3D — only the launch # grid + a handful of constexpr toggles differ. Per-token-head scale - # caches and their strides are required arguments; non-per-token-head - # modes pass dummy zeros (the code path is dead-code eliminated by - # the ``USE_PER_TOKEN_HEAD_SCALES`` constexpr branch in the kernel). + # caches and their strides are passed as ``None`` when the + # ``USE_PER_TOKEN_HEAD_SCALES`` branch is dead so Triton can skip + # materialising those arguments and the associated registers. if use_per_token_head_scales: ks_strides = k_scale_cache.stride() vs_strides = v_scale_cache.stride() @@ -944,16 +1062,15 @@ def unified_attention( k_scale_ptr = k_scale_cache v_scale_ptr = v_scale_cache else: - ks_blk = ks_slot = ks_head = 0 - vs_blk = vs_slot = vs_head = 0 - # Pass the K cache as a stand-in pointer; never dereferenced. - k_scale_ptr = k - v_scale_ptr = v - # 3D needs real segm tensors; 2D never touches them but Triton wants - # a non-null pointer. Reuse ``out`` as the placeholder. - segm_output_ptr = softmax_segm_output if use_3d else out - segm_max_ptr = softmax_segm_max if use_3d else out - segm_expsum_ptr = softmax_segm_expsum if use_3d else out + ks_blk = ks_slot = ks_head = None + vs_blk = vs_slot = vs_head = None + k_scale_ptr = None + v_scale_ptr = None + # 3D needs real segm tensors; 2D never touches them. Pass ``None`` in + # 2D mode so Triton can skip materialising these pointer arguments. + segm_output_ptr = softmax_segm_output if use_3d else None + segm_max_ptr = softmax_segm_max if use_3d else None + segm_expsum_ptr = softmax_segm_expsum if use_3d else None num_segments = num_par_softmax_segments if use_3d else 1 grid: tuple[Any, ...] @@ -964,6 +1081,12 @@ def unified_attention( grid = (total_num_q_blocks, num_kv_heads, num_par_softmax_segments) tile_size = TILE_SIZE_DECODE + launch_kwargs: dict[str, int] = {} + if launch_num_warps is not None: + launch_kwargs["num_warps"] = launch_num_warps + if launch_num_stages is not None: + launch_kwargs["num_stages"] = launch_num_stages + kernel_unified_attention[grid]( output_ptr=out, segm_output_ptr=segm_output_ptr, @@ -1002,10 +1125,16 @@ def unified_attention( USE_QQ_BIAS=use_qq_bias, USE_SOFTCAP=(softcap > 0), USE_SINKS=(sinks is not None), + SLIDING_WINDOW=(1 + window_size[0]), + USE_CAUSAL=use_causal, + USE_PER_SEQ_CAUSAL=use_per_seq_causal, + per_seq_causal_ptr=per_seq_causal_ptr, USE_MM_PREFIX=use_mm_prefix, MAX_MM_RANGES=max_mm_ranges, mm_prefix_range_ptr=mm_prefix_range, - SLIDING_WINDOW=(1 + window_size[0]), + rswa_prefix_lens_ptr=rswa_prefix_lens if use_rswa else seqused_k, + R_SWA_WINDOW=rswa_window or 0, + USE_R_SWA=use_rswa, stride_k_cache_0=k.stride(0), stride_k_cache_1=k.stride(1), stride_k_cache_2=k.stride(2), @@ -1033,6 +1162,8 @@ def unified_attention( CHUNK_SIZE=chunk_size, USE_TD=use_td, USE_TD_QO=use_td_qo, + MM_PREFIX_CLAMP_SW=mm_prefix_clamp_sliding_window, + **launch_kwargs, ) if use_3d: diff --git a/vllm/v1/attention/ops/triton_unified_attention_diffkv.py b/vllm/v1/attention/ops/triton_unified_attention_diffkv.py new file mode 100644 index 000000000000..eaf62b6bce66 --- /dev/null +++ b/vllm/v1/attention/ops/triton_unified_attention_diffkv.py @@ -0,0 +1,530 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Triton unified attention with different K/V head dimensions (DiffKV). + +This is a slimmed fork of ``triton_unified_attention.py`` for models like +MiMo-V2.5 where the V tensor's head dimension differs from K's. The KV cache +is the same packed layout used by ``FlashAttentionDiffKVBackend``: + + kv_cache: [num_blocks, block_size, num_kv_heads, head_size_qk + head_size_v] + +We slice ``key_cache = kv_cache[..., :head_size_qk]`` and +``value_cache = kv_cache[..., head_size_qk:]`` on the host, so the kernel +takes two cache pointers but with two distinct head sizes. + +Both 2D and 3D launches are supported: + - 2D: one program per (q-block, kv-head); tile-loop walks the full KV + sequence; final output written directly. Used for prefill and large + decode batches. + - 3D: one program per (q-block, kv-head, segm); each program covers a + KV slice and writes per-segment partials (max/expsum/output). A + follow-up ``kernel_reduce_segments_diffkv`` combines them. Selected + for decode-only batches whose 2D grid would under-fill the GPU. +""" + +from typing import Any + +import torch + +import vllm.envs as envs +from vllm.logger import init_logger +from vllm.triton_utils import tl, triton +from vllm.v1.attention.ops.triton_attention_helpers import ( + apply_alibi_to_score, + apply_softcap, + cdiv_fn, + compute_kv_seq_mask, + compute_tile_loop_bounds, + find_seq_idx, + init_softmax_M, + resolve_seq_and_query_len, + softmax_step, + store_segm_reduce_scalars, +) + +logger = init_logger(__name__) + +is_batch_invariant = envs.VLLM_BATCH_INVARIANT + + +@triton.jit +def kernel_unified_attention_diffkv( + # Output destinations. In 2D mode we write the final result into + # ``output_ptr``; in 3D mode we write per-segment partials into + # ``segm_*`` and ``output_ptr`` is unused (callers may pass any + # non-null pointer). + output_ptr, + segm_output_ptr, + segm_max_ptr, + segm_expsum_ptr, + query_ptr, + key_cache_ptr, # view of packed cache: [..., :head_size_qk] + value_cache_ptr, # view of packed cache: [..., head_size_qk:hqk+hv] + sink_ptr, + block_tables_ptr, + seq_lens_ptr, + alibi_slopes_ptr, + scale, + softcap, + num_query_heads: tl.constexpr, + num_queries_per_kv: tl.constexpr, + block_table_stride: tl.int64, + query_stride_0: tl.int64, + query_stride_1: tl.int64, # == HEAD_SIZE_QK + output_stride_0: tl.int64, + output_stride_1: tl.int64, # == HEAD_SIZE_V + BLOCK_SIZE: tl.constexpr, + TILE_SIZE: tl.constexpr, + HEAD_SIZE_QK: tl.constexpr, + HEAD_SIZE_QK_PADDED: tl.constexpr, + HEAD_SIZE_V: tl.constexpr, + HEAD_SIZE_V_PADDED: tl.constexpr, + USE_ALIBI_SLOPES: tl.constexpr, + USE_ALIBI_SQRT: tl.constexpr, + USE_SOFTCAP: tl.constexpr, + USE_SINKS: tl.constexpr, + SLIDING_WINDOW: tl.constexpr, + # Strides for both cache views (they share the same packed buffer, so + # dims 0/1/2 strides match; only the per-head extent differs). + stride_k_cache_0: tl.int64, + stride_k_cache_1: tl.int64, + stride_k_cache_2: tl.int64, + stride_k_cache_3: tl.constexpr, + stride_v_cache_0: tl.int64, + stride_v_cache_1: tl.int64, + stride_v_cache_2: tl.int64, + stride_v_cache_3: tl.constexpr, + query_start_len_ptr, + BLOCK_Q: tl.constexpr, + num_seqs: tl.int32, + BLOCK_M: tl.constexpr, + NUM_SEGMENTS_PER_SEQ: tl.constexpr, + # ``IS_3D`` toggles between 2D layout (one program walks the full KV + # sequence) and 3D layout (split-KV / FlashDecoding-style: per-segm + # programs write partials, finalized by ``kernel_reduce_segments_diffkv``). + IS_3D: tl.constexpr, +): + q_block_global_idx = tl.program_id(0) + kv_head_idx = tl.program_id(1) + segm_idx = tl.program_id(2) if IS_3D else 0 + + ( + seq_idx, + q_block_local_idx, + cur_batch_in_all_start_index, + cur_batch_query_len, + seq_len, + ) = resolve_seq_and_query_len( + query_start_len_ptr, seq_lens_ptr, q_block_global_idx, num_seqs, BLOCK_Q + ) + + if q_block_local_idx * BLOCK_Q >= cur_batch_query_len: + return + + if IS_3D: + tiles_per_segment = cdiv_fn(seq_len, NUM_SEGMENTS_PER_SEQ * TILE_SIZE) + if segm_idx * tiles_per_segment * TILE_SIZE >= seq_len: + return + else: + tiles_per_segment = 0 + + offs_m = tl.arange(0, BLOCK_M) + offs_d_qk = tl.arange(0, HEAD_SIZE_QK_PADDED) + offs_d_v = tl.arange(0, HEAD_SIZE_V_PADDED) + offs_t = tl.arange(0, TILE_SIZE) + query_pos = q_block_local_idx * BLOCK_Q + offs_m // num_queries_per_kv + + query_offset_0 = cur_batch_in_all_start_index + query_pos + query_offset_1 = kv_head_idx * num_queries_per_kv + offs_m % num_queries_per_kv + query_offset = ( + query_offset_0[:, None] * query_stride_0 + + query_offset_1[:, None] * query_stride_1 + + offs_d_qk[None, :] + ) + + dim_mask_qk = tl.where(offs_d_qk < HEAD_SIZE_QK, 1, 0).to(tl.int1) + dim_mask_v = tl.where(offs_d_v < HEAD_SIZE_V, 1, 0).to(tl.int1) + query_mask_0 = tl.where(query_pos < cur_batch_query_len, 1, 0).to(tl.int1) + query_mask_1 = tl.where(query_offset_1 < num_query_heads, 1, 0).to(tl.int1) + + # Q : (BLOCK_M, HEAD_SIZE_QK_PADDED) + Q = tl.load( + query_ptr + query_offset, + mask=dim_mask_qk[None, :] & query_mask_0[:, None] & query_mask_1[:, None], + other=0.0, + ) + + block_table_offset = seq_idx * block_table_stride + + M = init_softmax_M( + sink_ptr, query_offset_1, query_mask_1, segm_idx, BLOCK_M, USE_SINKS, IS_3D + ) + L = tl.full([BLOCK_M], 1.0, dtype=tl.float32) + # acc : (BLOCK_M, HEAD_SIZE_V_PADDED) + acc = tl.zeros([BLOCK_M, HEAD_SIZE_V_PADDED], dtype=tl.float32) + + context_len = seq_len - cur_batch_query_len + + if USE_ALIBI_SLOPES: + alibi_slope = tl.load( + alibi_slopes_ptr + query_offset_1, mask=query_mask_1, other=0.0 + ) + + loop_lo, loop_hi, max_seq_prefix_len = compute_tile_loop_bounds( + context_len, + seq_len, + cur_batch_query_len, + q_block_local_idx, + segm_idx, + tiles_per_segment, + TILE_SIZE, + BLOCK_M, + BLOCK_Q, + num_queries_per_kv, + SLIDING_WINDOW, + False, # USE_MM_PREFIX + IS_3D, + ) + + for j in range(loop_lo, loop_hi): + seq_offset = j * TILE_SIZE + offs_t + tile_mask = seq_offset < max_seq_prefix_len + + physical_block_idx = tl.load( + block_tables_ptr + block_table_offset + seq_offset // BLOCK_SIZE + ).to(tl.int64) + + v_offset = ( + physical_block_idx[:, None] * stride_v_cache_0 + + kv_head_idx * stride_v_cache_2 + + offs_d_v[None, :] * stride_v_cache_3 + + (seq_offset % BLOCK_SIZE)[:, None] * stride_v_cache_1 + ) + k_offset = ( + physical_block_idx[None, :] * stride_k_cache_0 + + kv_head_idx * stride_k_cache_2 + + offs_d_qk[:, None] * stride_k_cache_3 + + (seq_offset % BLOCK_SIZE)[None, :] * stride_k_cache_1 + ) + # K : (HEAD_SIZE_QK_PADDED, TILE_SIZE) + K_load = tl.load( + key_cache_ptr + k_offset, + mask=dim_mask_qk[:, None] & tile_mask[None, :], + other=0.0, + ) + K = K_load.to(Q.dtype) + # V : (TILE_SIZE, HEAD_SIZE_V_PADDED) + V_load = tl.load( + value_cache_ptr + v_offset, + mask=dim_mask_v[None, :] & tile_mask[:, None], + other=0.0, + ) + V = V_load.to(Q.dtype) + + query_abs_pos = context_len + query_pos[:, None] + seq_mask = compute_kv_seq_mask( + query_abs_pos, + seq_offset, + seq_idx, + seq_len, + None, # mm_prefix_range_ptr + SLIDING_WINDOW, + False, # USE_MM_PREFIX + 0, # MAX_MM_RANGES + ) + + # S : (BLOCK_M, TILE_SIZE) + S = tl.zeros(shape=(BLOCK_M, TILE_SIZE), dtype=tl.float32) + S += scale * tl.dot(Q, K) + + if USE_SOFTCAP: + S = apply_softcap(S, softcap) + + S = tl.where( + query_mask_1[:, None] & query_mask_0[:, None] & seq_mask, S, float("-inf") + ) + + if USE_ALIBI_SLOPES: + S = apply_alibi_to_score( + S, alibi_slope, seq_offset, context_len, query_pos, USE_ALIBI_SQRT + ) + + M, L, P, alpha = softmax_step(S, M, L) + acc = acc * alpha[:, None] + + if SLIDING_WINDOW: + qpos_lo = q_block_local_idx * BLOCK_Q + V = tl.where( + (context_len + qpos_lo - seq_offset[:, None]) < SLIDING_WINDOW, + V, + 0.0, + ) + acc += tl.dot(P.to(V.dtype), V) + + # ---- Epilogue -------------------------------------------------------- + if IS_3D: + # Store per-segment partials; finalized by reduce_segments_diffkv. + segm_output_offset = ( + query_offset_0[:, None].to(tl.int64) + * (num_query_heads * NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_V_PADDED) + + query_offset_1[:, None] * (NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_V_PADDED) + + segm_idx * HEAD_SIZE_V_PADDED + + tl.arange(0, HEAD_SIZE_V_PADDED)[None, :] + ) + tl.store( + segm_output_ptr + segm_output_offset, + acc, + mask=dim_mask_v[None, :] & query_mask_0[:, None] & query_mask_1[:, None], + ) + store_segm_reduce_scalars( + segm_max_ptr, + segm_expsum_ptr, + query_offset_0, + query_offset_1, + segm_idx, + M, + L, + query_mask_0, + query_mask_1, + num_query_heads, + NUM_SEGMENTS_PER_SEQ, + ) + else: + acc = acc / L[:, None] + output_offset = ( + query_offset_0[:, None] * output_stride_0 + + query_offset_1[:, None] * output_stride_1 + + offs_d_v[None, :] + ) + tl.store( + output_ptr + output_offset, + acc, + mask=dim_mask_v[None, :] & query_mask_0[:, None] & query_mask_1[:, None], + ) + + +@triton.jit +def kernel_reduce_segments_diffkv( + output_ptr, # [num_tokens, num_query_heads, head_size_v] + segm_output_ptr, + # [num_tokens, num_query_heads, max_num_segments, head_size_v] + segm_max_ptr, # [num_tokens, num_query_heads, max_num_segments] + segm_expsum_ptr, # [num_tokens, num_query_heads, max_num_segments] + seq_lens_ptr, # [num_seqs] + num_seqs, + num_query_heads: tl.constexpr, + output_stride_0: tl.int64, + output_stride_1: tl.int64, # == HEAD_SIZE_V + TILE_SIZE: tl.constexpr, + HEAD_SIZE_V: tl.constexpr, + HEAD_SIZE_V_PADDED: tl.constexpr, + query_start_len_ptr, # [num_seqs+1] + BLOCK_Q: tl.constexpr, + NUM_SEGMENTS_PER_SEQ: tl.constexpr, +): + """Combine per-segment partials into the final softmax output. + + Mirrors ``reduce_segments`` from triton_unified_attention.py but + indexes V's head size (``HEAD_SIZE_V``) instead of the shared one. + """ + query_token_idx = tl.program_id(0) + query_head_idx = tl.program_id(1) + + seq_idx = find_seq_idx( + query_start_len_ptr, query_token_idx, num_seqs, BLOCK_Q, False + ) + seq_len = tl.load(seq_lens_ptr + seq_idx) + + tiles_per_segment = cdiv_fn(seq_len, NUM_SEGMENTS_PER_SEQ * TILE_SIZE) + act_num_segments = cdiv_fn(seq_len, tiles_per_segment * TILE_SIZE) + segm_mask = tl.arange(0, NUM_SEGMENTS_PER_SEQ) < tl.full( + [NUM_SEGMENTS_PER_SEQ], act_num_segments, dtype=tl.int32 + ) + dim_mask = tl.where(tl.arange(0, HEAD_SIZE_V_PADDED) < HEAD_SIZE_V, 1, 0).to( + tl.int1 + ) + + segm_offset = ( + query_token_idx.to(tl.int64) * (num_query_heads * NUM_SEGMENTS_PER_SEQ) + + query_head_idx * NUM_SEGMENTS_PER_SEQ + + tl.arange(0, NUM_SEGMENTS_PER_SEQ) + ) + segm_max = tl.load(segm_max_ptr + segm_offset, mask=segm_mask, other=float("-inf")) + overall_max = tl.max(segm_max) + + segm_expsum = tl.load(segm_expsum_ptr + segm_offset, mask=segm_mask, other=0.0) + segm_expsum = segm_expsum * tl.exp(segm_max - overall_max) + overall_expsum = tl.sum(segm_expsum) + + segm_output_offset = ( + query_token_idx.to(tl.int64) + * (num_query_heads * NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_V_PADDED) + + query_head_idx * (NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_V_PADDED) + + tl.arange(0, NUM_SEGMENTS_PER_SEQ)[:, None] * HEAD_SIZE_V_PADDED + + tl.arange(0, HEAD_SIZE_V_PADDED)[None, :] + ) + segm_output = tl.load( + segm_output_ptr + segm_output_offset, + mask=segm_mask[:, None] & dim_mask[None, :], + other=0.0, + ) + segm_output *= tl.exp(segm_max - overall_max)[:, None] + acc_sum = tl.sum(segm_output, axis=0) + acc = tl.where(overall_expsum == 0.0, 0.0, acc_sum / overall_expsum) + + output_offset = ( + query_token_idx * output_stride_0 + + query_head_idx * output_stride_1 + + tl.arange(0, HEAD_SIZE_V_PADDED) + ) + tl.store(output_ptr + output_offset, acc, mask=dim_mask) + + +def unified_attention_diffkv( + q, # [num_tokens, num_query_heads, head_size_qk] + k, # view: [num_blocks, block_size, num_kv_heads, head_size_qk] + v, # view: [num_blocks, block_size, num_kv_heads, head_size_v] + out, # [num_tokens, num_query_heads, head_size_v] + cu_seqlens_q, + seqused_k, + softmax_scale, + causal, + window_size, + block_table, + softcap, + max_seqlen_q: int = 1, + alibi_slopes=None, + sinks=None, + use_alibi_sqrt=False, + # 3D / split-KV softmax buffers. When all four are provided and the + # batch is decode-only with few sequences, the 3D path is taken. + seq_threshold_3D: int | None = None, + num_par_softmax_segments: int | None = None, + softmax_segm_output: torch.Tensor | None = None, + softmax_segm_max: torch.Tensor | None = None, + softmax_segm_expsum: torch.Tensor | None = None, +): + assert causal, "Only causal attention is supported" + + if sinks is not None: + assert sinks.shape[0] == q.shape[1], "Sinks must be num_query_heads size" + + use_alibi_slopes = alibi_slopes is not None + + block_size = v.shape[1] + num_seqs = len(seqused_k) + num_query_heads = q.shape[1] + num_kv_heads = k.shape[2] + num_queries_per_kv = num_query_heads // num_kv_heads + head_size_qk = q.shape[2] + head_size_v = v.shape[3] + + BLOCK_M = ( + 16 if num_queries_per_kv <= 16 else triton.next_power_of_2(num_queries_per_kv) + ) + BLOCK_Q = BLOCK_M // num_queries_per_kv + + total_num_q_blocks = q.shape[0] // BLOCK_Q + num_seqs + + sliding_window_val = 1 + window_size[0] if window_size[0] >= 0 else 0 + + # Decide between 2D and 3D launch. Mirrors the standard launcher: + # 3D requires preallocated softmax buffers, decode-only batches, and + # a small number of sequences (otherwise 2D already saturates the SM). + use_3d = not ( + seq_threshold_3D is None + or num_par_softmax_segments is None + or softmax_segm_output is None + or softmax_segm_max is None + or softmax_segm_expsum is None + or max_seqlen_q > 1 + or num_seqs > seq_threshold_3D + or is_batch_invariant + ) + + # Tile size: 32 for prefill-class kernels. Decode (small Q) prefers + # smaller tiles to expose more parallelism along the KV dim. + tile_size = 32 if not use_3d else (16 if q.element_size() >= 2 else 32) + + grid: tuple[Any, ...] + if use_3d: + grid = (total_num_q_blocks, num_kv_heads, num_par_softmax_segments) + segm_output_ptr = softmax_segm_output + segm_max_ptr = softmax_segm_max + segm_expsum_ptr = softmax_segm_expsum + num_segments = num_par_softmax_segments + else: + grid = (total_num_q_blocks, num_kv_heads) + # 2D never touches the segm tensors but Triton wants a non-null + # pointer; reuse ``out``. + segm_output_ptr = out + segm_max_ptr = out + segm_expsum_ptr = out + num_segments = 1 + + kernel_unified_attention_diffkv[grid]( + output_ptr=out, + segm_output_ptr=segm_output_ptr, + segm_max_ptr=segm_max_ptr, + segm_expsum_ptr=segm_expsum_ptr, + query_ptr=q, + key_cache_ptr=k, + value_cache_ptr=v, + sink_ptr=sinks, + block_tables_ptr=block_table, + seq_lens_ptr=seqused_k, + alibi_slopes_ptr=alibi_slopes, + scale=softmax_scale, + softcap=softcap, + num_query_heads=num_query_heads, + num_queries_per_kv=num_queries_per_kv, + block_table_stride=block_table.stride(0), + query_stride_0=q.stride(0), + query_stride_1=q.stride(1), + output_stride_0=out.stride(0), + output_stride_1=out.stride(1), + BLOCK_SIZE=block_size, + TILE_SIZE=tile_size, + HEAD_SIZE_QK=head_size_qk, + HEAD_SIZE_QK_PADDED=triton.next_power_of_2(head_size_qk), + HEAD_SIZE_V=head_size_v, + HEAD_SIZE_V_PADDED=triton.next_power_of_2(head_size_v), + USE_ALIBI_SLOPES=use_alibi_slopes, + USE_ALIBI_SQRT=use_alibi_sqrt, + USE_SOFTCAP=(softcap > 0), + USE_SINKS=(sinks is not None), + SLIDING_WINDOW=sliding_window_val, + stride_k_cache_0=k.stride(0), + stride_k_cache_1=k.stride(1), + stride_k_cache_2=k.stride(2), + stride_k_cache_3=k.stride(3), + stride_v_cache_0=v.stride(0), + stride_v_cache_1=v.stride(1), + stride_v_cache_2=v.stride(2), + stride_v_cache_3=v.stride(3), + query_start_len_ptr=cu_seqlens_q, + BLOCK_Q=BLOCK_Q, + num_seqs=num_seqs, + BLOCK_M=BLOCK_M, + NUM_SEGMENTS_PER_SEQ=num_segments, + IS_3D=use_3d, + ) + + if use_3d: + kernel_reduce_segments_diffkv[(q.shape[0], num_query_heads)]( + output_ptr=out, + segm_output_ptr=softmax_segm_output, + segm_max_ptr=softmax_segm_max, + segm_expsum_ptr=softmax_segm_expsum, + seq_lens_ptr=seqused_k, + num_seqs=num_seqs, + num_query_heads=num_query_heads, + output_stride_0=out.stride(0), + output_stride_1=out.stride(1), + TILE_SIZE=tile_size, + HEAD_SIZE_V=head_size_v, + HEAD_SIZE_V_PADDED=triton.next_power_of_2(head_size_v), + query_start_len_ptr=cu_seqlens_q, + BLOCK_Q=BLOCK_Q, + NUM_SEGMENTS_PER_SEQ=num_par_softmax_segments, + ) diff --git a/vllm/v1/attention/ops/vit_attn_wrappers.py b/vllm/v1/attention/ops/vit_attn_wrappers.py index 4506f452cf9a..5bbcc3386e52 100644 --- a/vllm/v1/attention/ops/vit_attn_wrappers.py +++ b/vllm/v1/attention/ops/vit_attn_wrappers.py @@ -12,6 +12,8 @@ To use these ops, you must have a recent version of PyTorch installed (>= 2.4.0) """ +from typing import Any + import einops import torch import torch.nn.functional as F @@ -31,9 +33,11 @@ def flash_attn_maxseqlen_wrapper( cu_seqlens: torch.Tensor | None = None, max_seqlen: torch.Tensor | None = None, ) -> torch.Tensor: - kwargs = {} + kwargs: dict[str, Any] = {} if is_rocm_aiter: from aiter import flash_attn_varlen_func + + kwargs["window_size"] = (-1, -1) else: from vllm.v1.attention.backends.fa_utils import flash_attn_varlen_func diff --git a/vllm/v1/attention/ops/xpu_mla_sparse.py b/vllm/v1/attention/ops/xpu_mla_sparse.py index 8a4c1ffd6e0d..e73e5a2b28ee 100644 --- a/vllm/v1/attention/ops/xpu_mla_sparse.py +++ b/vllm/v1/attention/ops/xpu_mla_sparse.py @@ -180,11 +180,17 @@ def triton_bf16_mla_sparse_interface( indices: torch.Tensor, # [num_tokens, num_heads_kv, topk] sm_scale: float, d_v: int = 512, + block_dpe: int = 64, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """ out : [num_tokens, num_heads_q, d_v] max_logits : [num_tokens, num_heads_q] lse : logsumexp, [num_tokens, num_heads_q] + + Args: + block_dpe: Size of positional embedding portion of dim_qk. + Set to 0 when q/kv contain only the nope latent (e.g. DSv4 + prefill where RoPE is not split out). """ num_tokens, num_heads_q, dim_qk = q.shape _, num_heads_kv, _ = kv.shape @@ -194,8 +200,8 @@ def triton_bf16_mla_sparse_interface( _, _, index_topk = indices.shape BLOCK_H = 16 - BLOCK_DMODEL = 512 - BLOCK_DPE = 64 + BLOCK_DPE = block_dpe + BLOCK_DMODEL = dim_qk - BLOCK_DPE BLOCK_M = 32 BLOCK_N = 16 BLOCK_DV = 512 diff --git a/vllm/v1/core/block_pool.py b/vllm/v1/core/block_pool.py index 513e4bf380b9..81ac05f36589 100644 --- a/vllm/v1/core/block_pool.py +++ b/vllm/v1/core/block_pool.py @@ -72,6 +72,20 @@ def get_one_block(self, key: BlockHashWithGroupId) -> KVCacheBlock | None: self._unexpected_blocks_type(blocks) return None + def contain(self, key: BlockHashWithGroupId, block_id: int) -> bool: + """ + Checks whether the key maps to the given block ID. + """ + blocks = self._cache.get(key) + if blocks is None: + return False + if isinstance(blocks, KVCacheBlock): + return blocks.block_id == block_id + if isinstance(blocks, dict): + return block_id in blocks + self._unexpected_blocks_type(blocks) + return False + def insert(self, key: BlockHashWithGroupId, block: KVCacheBlock) -> None: """ Inserts the KVCacheBlock to the cache @@ -169,6 +183,7 @@ def __init__( # Cache for block lookup self.cached_block_hash_to_block: BlockHashToBlockMap = BlockHashToBlockMap() + self.cached_block_hashes_by_block: dict[int, set[BlockHashWithGroupId]] = {} # To represent a placeholder block with block_id=0. # The ref_cnt of null_block is not maintained, needs special care to @@ -245,7 +260,6 @@ def cache_full_blocks( if num_cached_blocks >= num_full_blocks: return new_full_blocks = blocks[num_cached_blocks:num_full_blocks] - assert len(request.block_hashes) >= num_full_blocks assert block_mask is None or len(block_mask) == len(new_full_blocks) if block_size == self.hash_block_size: # Common case. @@ -254,11 +268,10 @@ def cache_full_blocks( # block_size is a multiple of hash_block_size. This happens when # different KV cache groups have different block sizes. assert block_size % self.hash_block_size == 0 - # Recalculate block_hashes at the granularity of block_size, using - # the original block_hashes (at the granularity of hash_block_size). block_hashes = BlockHashListWithBlockSize( request.block_hashes, self.hash_block_size, block_size ) + assert len(block_hashes) >= num_full_blocks new_block_hashes = block_hashes[num_cached_blocks:] new_hashes: list[ExternalBlockHash] | None = ( @@ -270,15 +283,27 @@ def cache_full_blocks( # in align mode. We skip null blocks here. if blk.is_null or (block_mask is not None and not block_mask[i]): continue - assert blk.block_hash is None block_hash = new_block_hashes[i] + num_hash_tokens = (num_cached_blocks + i + 1) * block_size # Update and added the full block to the cache. block_hash_with_group_id = make_block_hash_with_group_id( block_hash, kv_cache_group_id ) - blk.block_hash = block_hash_with_group_id - self.cached_block_hash_to_block.insert(block_hash_with_group_id, blk) + if blk.block_hash is not None: + # The only valid case where a "new full block" already has a + # hash is partial->full promotion of the same cache block. + assert ( + blk.block_hash_num_tokens is not None + and blk.block_hash_num_tokens < num_hash_tokens + ) + removed_hashes = self._remove_cached_block_hashes(blk) + self._emit_block_removed_events(removed_hashes) + self._insert_block_hash( + block_hash_with_group_id, + blk, + num_tokens=num_hash_tokens, + ) if new_hashes is not None: new_hashes.append(maybe_convert_block_hash(block_hash)) @@ -330,6 +355,190 @@ def cache_full_blocks( ) ) + def cache_partial_block( + self, + request: Request, + block: KVCacheBlock, + num_tokens: int, + kv_cache_group_id: int, + block_size: int, + ) -> BlockHashWithGroupId | None: + """Register a partial prefix-cache entry for an existing block. + + Prefix-cache keys normally identify full cache blocks. A partial entry + makes an existing cache block reachable from a fine-grained prefix + boundary inside that block without allocating or copying a new + ``KVCacheBlock``. + + The partial entry is lookup metadata owned by ``block``. If ``block`` + has no primary hash, the key becomes its primary hash. If the block + already has a primary hash, the partial entry is tracked in + ``cached_block_hashes_by_block`` so eviction, reset, and promotion can + remove every hash key that points to the block. + + Args: + request: Request whose token IDs and block hashes define the + partial entry. + block: Existing cache block to make reachable from the partial + prefix boundary. + num_tokens: Prefix length represented by the partial entry. It + must be a positive multiple of ``self.hash_block_size`` and + cannot exceed the request's computed block hashes. + kv_cache_group_id: KV cache group that owns the partial entry. + block_size: Cache block size for the owning group. The partial + entry hash itself is always the prefix-chain hash at + ``num_tokens``; ``block_size`` is used to assert that the + entry is partial within the owning cache block. + + Returns: + The hash key with group ID if a partial entry can be registered; + otherwise ``None`` for null blocks. + """ + if block.is_null: + return None + + assert block_size > self.hash_block_size + assert block_size % self.hash_block_size == 0 + assert num_tokens % block_size != 0 + block_hash = self._get_partial_block_hash(request, num_tokens) + num_hash_blocks = num_tokens // self.hash_block_size + block_hash_with_group_id = make_block_hash_with_group_id( + block_hash, kv_cache_group_id + ) + already_cached = block.block_hash == block_hash_with_group_id or ( + self.cached_block_hash_to_block.contain( + block_hash_with_group_id, block.block_id + ) + ) + if ( + not already_cached + and block.block_hash is not None + and block.block_hash_num_tokens is not None + and block.block_hash_num_tokens < num_hash_blocks * self.hash_block_size + ): + removed_hashes = self._remove_cached_block_hashes(block) + self._emit_block_removed_events(removed_hashes) + self._insert_block_hash( + block_hash_with_group_id, + block, + num_tokens=num_hash_blocks * self.hash_block_size, + ) + if self.enable_kv_cache_events and not already_cached: + parent_hash, block_start = self._get_partial_block_parent_hash_and_start( + request, num_tokens + ) + parent_block_hash = ( + maybe_convert_block_hash(parent_hash) + if parent_hash is not None + else None + ) + block_end = num_tokens + curr_mm_idx = -1 if block_start > 0 else 0 + extra_keys, _ = generate_block_hash_extra_keys( + request, block_start, block_end, curr_mm_idx + ) + self.kv_event_queue.append( + BlockStored( + block_hashes=[maybe_convert_block_hash(block_hash)], + parent_block_hash=parent_block_hash, + token_ids=request.all_token_ids[block_start:block_end], + block_size=block_end - block_start, + lora_id=request.lora_request.adapter_id + if request.lora_request + else None, + medium=MEDIUM_GPU, + lora_name=request.lora_request.name + if request.lora_request + else None, + extra_keys=[extra_keys], + group_idx=kv_cache_group_id, + ) + ) + return block_hash_with_group_id + + def _get_partial_block_hash( + self, + request: Request, + num_tokens: int, + ) -> BlockHash: + assert num_tokens % self.hash_block_size == 0 + num_hash_blocks = num_tokens // self.hash_block_size + assert 0 < num_hash_blocks <= len(request.block_hashes) + + # Each hash_block_size hash chains over its full prefix, so the partial + # entry for any group block size is the hash at that prefix boundary. + return request.block_hashes[num_hash_blocks - 1] + + def _get_partial_block_parent_hash_and_start( + self, + request: Request, + num_tokens: int, + ) -> tuple[BlockHash | None, int]: + num_hash_blocks = num_tokens // self.hash_block_size + parent_hash = ( + request.block_hashes[num_hash_blocks - 2] if num_hash_blocks > 1 else None + ) + block_start = (num_hash_blocks - 1) * self.hash_block_size + return parent_hash, block_start + + def _remove_cached_block_hashes( + self, + block: KVCacheBlock, + ) -> list[BlockHashWithGroupId]: + block_hashes: list[BlockHashWithGroupId] = [] + if block.block_hash is not None: + block_hashes.append(block.block_hash) + block_hashes.extend(self.cached_block_hashes_by_block.pop(block.block_id, ())) + if not block_hashes: + return [] + + removed_hashes: list[BlockHashWithGroupId] = [] + for block_hash in block_hashes: + if ( + self.cached_block_hash_to_block.pop(block_hash, block.block_id) + is not None + ): + removed_hashes.append(block_hash) + block.reset_hash() + return removed_hashes + + def _emit_block_removed_events( + self, + block_hashes: list[BlockHashWithGroupId], + ) -> None: + if not self.enable_kv_cache_events: + return + for block_hash in block_hashes: + self.kv_event_queue.append( + BlockRemoved( + block_hashes=[maybe_convert_block_hash(get_block_hash(block_hash))], + medium=MEDIUM_GPU, + group_idx=get_group_id(block_hash), + ) + ) + + def _insert_block_hash( + self, + block_hash_with_group_id: BlockHashWithGroupId, + block: KVCacheBlock, + num_tokens: int | None, + ) -> None: + if block.block_hash == block_hash_with_group_id: + return + + if self.cached_block_hash_to_block.contain( + block_hash_with_group_id, block.block_id + ): + return + + if block.block_hash is None: + block.set_block_hash(block_hash_with_group_id, num_tokens=num_tokens) + else: + self.cached_block_hashes_by_block.setdefault(block.block_id, set()).add( + block_hash_with_group_id + ) + self.cached_block_hash_to_block.insert(block_hash_with_group_id, block) + def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]: """Get new blocks from the free block pool. @@ -377,26 +586,12 @@ def _maybe_evict_cached_block(self, block: KVCacheBlock) -> bool: if self.metrics_collector: self.metrics_collector.on_block_evicted(block) - block_hash = block.block_hash - if block_hash is None: + evicted_hashes = self._remove_cached_block_hashes(block) + if not evicted_hashes: # The block doesn't have hash, eviction is not needed return False - if self.cached_block_hash_to_block.pop(block_hash, block.block_id) is None: - # block not found in cached_block_hash_to_block, - # eviction is not needed - return False - - block.reset_hash() - - if self.enable_kv_cache_events: - self.kv_event_queue.append( - BlockRemoved( - block_hashes=[maybe_convert_block_hash(get_block_hash(block_hash))], - medium=MEDIUM_GPU, - group_idx=get_group_id(block_hash), - ) - ) + self._emit_block_removed_events(evicted_hashes) return True def touch(self, blocks: Sequence[KVCacheBlock]) -> None: @@ -424,13 +619,20 @@ def free_blocks(self, ordered_blocks: Iterable[KVCacheBlock]) -> None: ordered_blocks: A list of blocks to free ordered by their eviction priority. """ - # Materialize the iterable to allow multiple passes. - blocks_list = list(ordered_blocks) - for block in blocks_list: + # Identify blocks with hash (LRU cache) and without it (will never match in APC) + blocks_with_hash = [] + blocks_without_hash = [] + for block in ordered_blocks: block.ref_cnt -= 1 - self.free_block_queue.append_n( - [block for block in blocks_list if block.ref_cnt == 0 and not block.is_null] - ) + if block.ref_cnt == 0 and not block.is_null: + if block.block_hash is None: + blocks_without_hash.append(block) + else: + blocks_with_hash.append(block) + + # Blocks without hash always get evicted first - prepend them last to the tail + self.free_block_queue.prepend_n(blocks_without_hash) + self.free_block_queue.append_n(blocks_with_hash) def evict_blocks(self, block_ids: set[int]) -> None: """evict blocks from the prefix cache by their block IDs. @@ -471,6 +673,7 @@ def reset_prefix_cache(self) -> bool: # Remove all hashes so that no new blocks will hit. self.cached_block_hash_to_block = BlockHashToBlockMap() + self.cached_block_hashes_by_block.clear() # Remove all hashes from all blocks. for block in self.blocks: diff --git a/vllm/v1/core/kv_cache_coordinator.py b/vllm/v1/core/kv_cache_coordinator.py index 387f1a1e335d..4756136f03cf 100644 --- a/vllm/v1/core/kv_cache_coordinator.py +++ b/vllm/v1/core/kv_cache_coordinator.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from typing import NamedTuple +from vllm import envs from vllm.v1.core.block_pool import BlockPool from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector from vllm.v1.core.kv_cache_utils import ( @@ -21,10 +22,42 @@ FullAttentionSpec, KVCacheConfig, KVCacheSpec, + MambaSpec, + SlidingWindowSpec, ) from vllm.v1.request import Request +def _validate_prefix_cache_retention_interval( + retention_interval: int | None, + scheduler_block_size: int, + kv_cache_config: KVCacheConfig, +) -> None: + if retention_interval is None: + return + + # Retention sparsifies sliding-window and Mamba (linear-attention) + # checkpoints; full-attention and chunked-local groups cache densely and + # ignore it (their hit granularity must stay fine). + if not any( + isinstance(g.kv_cache_spec, (SlidingWindowSpec, MambaSpec)) + for g in kv_cache_config.kv_cache_groups + ): + raise ValueError( + "VLLM_PREFIX_CACHE_RETENTION_INTERVAL is set but this model has " + "no sliding-window or Mamba KV cache group, so retention has no " + "effect. Unset it (it only applies to sliding-window and Mamba " + "attention)." + ) + + if retention_interval < 0 or retention_interval % scheduler_block_size != 0: + raise ValueError( + f"VLLM_PREFIX_CACHE_RETENTION_INTERVAL ({retention_interval}) " + "must be non-negative and a multiple of scheduler_block_size " + f"({scheduler_block_size})." + ) + + class KVCacheCoordinator(ABC): """ Coordinate the KV cache of different KV cache groups. @@ -34,7 +67,7 @@ def __init__( self, kv_cache_config: KVCacheConfig, max_model_len: int, - max_num_batched_tokens: int, + max_in_flight_tokens: int, use_eagle: bool, enable_caching: bool, enable_kv_cache_events: bool, @@ -74,7 +107,7 @@ def __init__( self.single_type_managers = tuple( get_manager_for_kv_cache_spec( kv_cache_spec=kv_cache_group.kv_cache_spec, - max_num_batched_tokens=max_num_batched_tokens, + max_in_flight_tokens=max_in_flight_tokens, max_model_len=max_model_len, block_pool=self.block_pool, enable_caching=enable_caching, @@ -82,10 +115,19 @@ def __init__( dcp_world_size=dcp_world_size, pcp_world_size=pcp_world_size, scheduler_block_size=self.scheduler_block_size, + needs_kv_cache_zeroing=self.kv_cache_config.needs_kv_cache_zeroing, ) for i, kv_cache_group in enumerate(self.kv_cache_config.kv_cache_groups) ) + # A positive retention interval must be a multiple of the base hit granularity + # (``scheduler_block_size``) to land on real cache-hit boundaries. + # 0 = keep only the latest replay boundary; None = dense; + self.retention_interval = envs.VLLM_PREFIX_CACHE_RETENTION_INTERVAL + _validate_prefix_cache_retention_interval( + self.retention_interval, self.scheduler_block_size, kv_cache_config + ) + def get_num_blocks_to_allocate( self, request_id: str, @@ -161,13 +203,33 @@ def allocate_new_computed_blocks( num_local_computed_tokens: The number of local computed tokens. num_external_computed_tokens: The number of external computed tokens. """ + # A running request is already tracked in num_cached_block and won't + # have new prefix-cache hits, so this is a no-op for it. + if any( + request_id in manager.num_cached_block + for manager in self.single_type_managers + ): + assert all(len(blocks) == 0 for blocks in new_computed_blocks) + return + + # Two-phase allocation (issue #33775): first touch every group's local + # cache-hit blocks, then allocate external blocks for every group. This + # ensures an earlier group's external `get_new_blocks` cannot evict a + # later group's not-yet-touched cache-hit blocks. for i, manager in enumerate(self.single_type_managers): - manager.allocate_new_computed_blocks( + manager.add_local_computed_blocks( request_id, new_computed_blocks[i], num_local_computed_tokens, num_external_computed_tokens, ) + if num_external_computed_tokens > 0: + for manager in self.single_type_managers: + manager.allocate_external_computed_blocks( + request_id, + num_local_computed_tokens, + num_external_computed_tokens, + ) def allocate_new_blocks( self, @@ -215,7 +277,11 @@ def cache_blocks(self, request: Request, num_computed_tokens: int) -> None: (including tokens that are already cached). """ for manager in self.single_type_managers: - manager.cache_blocks(request, num_computed_tokens) + manager.cache_blocks( + request, + num_computed_tokens, + retention_interval=self.retention_interval, + ) def free(self, request_id: str) -> None: """ @@ -227,6 +293,25 @@ def free(self, request_id: str) -> None: for manager in self.single_type_managers: manager.free(request_id) + def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]: + """ + Pop the request's bookkeeping from all single-type managers and + return its blocks without returning them to the block pool. The + caller must eventually pass the returned blocks to + `block_pool.free_blocks`, freeing them in reverse order (so that + tail blocks are evicted first). + + Args: + request_id: The request ID. + + Returns: + The request's blocks in allocation order. + """ + blocks: list[KVCacheBlock] = [] + for manager in self.single_type_managers: + blocks.extend(manager.pop_blocks_for_free(request_id)) + return blocks + def get_num_common_prefix_blocks(self, running_request_id: str) -> list[int]: """ Get the number of common prefix blocks for all requests with allocated @@ -245,7 +330,10 @@ def get_num_common_prefix_blocks(self, running_request_id: str) -> list[int]: ] def remove_skipped_blocks( - self, request_id: str, total_computed_tokens: int + self, + request_id: str, + processed_computed_tokens: int, + num_prompt_tokens: int | None = None, ) -> None: """ Remove the blocks that are no longer needed from `blocks` and replace @@ -253,11 +341,16 @@ def remove_skipped_blocks( Args: request_id: The request ID. - total_computed_tokens: The total number of computed tokens, including - local computed tokens and external computed tokens. + processed_computed_tokens: Computed-token prefix length covering + fully processed and committed tokens only (safe to free). + num_prompt_tokens: Optional prompt length. R-SWA managers use this to + free gap blocks between the prefill tail and decode window; other + manager types ignore it. """ for manager in self.single_type_managers: - manager.remove_skipped_blocks(request_id, total_computed_tokens) + manager.remove_skipped_blocks( + request_id, processed_computed_tokens, num_prompt_tokens + ) def get_blocks(self, request_id: str) -> tuple[list[KVCacheBlock], ...]: """ @@ -294,7 +387,7 @@ def __init__( self, kv_cache_config: KVCacheConfig, max_model_len: int, - max_num_batched_tokens: int, + max_in_flight_tokens: int, use_eagle: bool, enable_kv_cache_events: bool, dcp_world_size: int, @@ -306,7 +399,7 @@ def __init__( super().__init__( kv_cache_config, max_model_len, - max_num_batched_tokens, + max_in_flight_tokens, use_eagle, False, enable_kv_cache_events, @@ -343,7 +436,7 @@ def __init__( self, kv_cache_config: KVCacheConfig, max_model_len: int, - max_num_batched_tokens: int, + max_in_flight_tokens: int, use_eagle: bool, enable_caching: bool, enable_kv_cache_events: bool, @@ -356,7 +449,7 @@ def __init__( super().__init__( kv_cache_config, max_model_len, - max_num_batched_tokens, + max_in_flight_tokens, use_eagle, enable_caching, enable_kv_cache_events, @@ -429,7 +522,7 @@ def __init__( self, kv_cache_config: KVCacheConfig, max_model_len: int, - max_num_batched_tokens: int, + max_in_flight_tokens: int, use_eagle: bool, enable_caching: bool, enable_kv_cache_events: bool, @@ -442,7 +535,7 @@ def __init__( super().__init__( kv_cache_config, max_model_len, - max_num_batched_tokens, + max_in_flight_tokens, use_eagle, enable_caching, enable_kv_cache_events, @@ -525,8 +618,14 @@ def cache_blocks(self, request: Request, num_computed_tokens: int) -> None: num_computed_tokens, aligned_num_computed_tokens + manager.block_size, ) + # The manager already knows the fine hit granularity + # (``scheduler_block_size``); retention is passed separately so it + # can keep both the coarse segment tails and the fine replay + # boundary (which needs the fine value). manager.cache_blocks( - request, num_tokens_to_cache, alignment_tokens=self.scheduler_block_size + request, + num_tokens_to_cache, + retention_interval=self.retention_interval, ) def find_longest_cache_hit( @@ -561,6 +660,7 @@ def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList: num_groups = len(self.kv_cache_config.kv_cache_groups) hit_length = max_cache_hit_length + longest_hit_length = 0 hit_blocks_by_group: list[list[KVCacheBlock] | None] = [None] * num_groups # Simple hybrid (1 full attn + 1 other): one iteration suffices. @@ -617,6 +717,8 @@ def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList: for group_id, blocks in zip(group_ids, hit_blocks): hit_blocks_by_group[group_id] = blocks + longest_hit_length = max(longest_hit_length, curr_hit_length) + if curr_hit_length >= hit_length: break hit_length = curr_hit_length @@ -631,15 +733,57 @@ def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList: if (blks := hit_blocks_by_group[group_id]) is not None: del blks[num_blocks:] + # Uncached shared prefix detection: If any attn. group cached a longer prefix + # than the current prefix, it is an uncached common prefix across requests: + self.num_uncached_common_prefix_tokens = longest_hit_length - hit_length return tuple( blocks if blocks is not None else [] for blocks in hit_blocks_by_group ), hit_length + def find_longest_cache_hit_per_group( + self, + block_hashes: list[BlockHash], + max_cache_hit_length: int, + ) -> tuple[tuple[list[KVCacheBlock], ...], tuple[int, ...]]: + """Like find_longest_cache_hit but evaluates each group independently. + + Returns: + (blocks_per_group, hit_lengths_per_group) + """ + + def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList: + if kv_cache_spec.block_size == self.hash_block_size: + return block_hashes + return BlockHashListWithBlockSize( + block_hashes, self.hash_block_size, kv_cache_spec.block_size + ) + + num_groups = len(self.kv_cache_config.kv_cache_groups) + hit_blocks: list[list[KVCacheBlock]] = [[] for _ in range(num_groups)] + hit_lengths: list[int] = [0] * num_groups + + for spec, group_ids, manager_cls, use_eagle in self.attention_groups: + blocks = manager_cls.find_longest_cache_hit( + block_hashes=_get_block_hashes(spec), + max_length=max_cache_hit_length, + kv_cache_group_ids=group_ids, + block_pool=self.block_pool, + kv_cache_spec=spec, + drop_eagle_block=use_eagle, + alignment_tokens=self.scheduler_block_size, + ) + group_hit = len(blocks[0]) * spec.block_size + for gid, blks in zip(group_ids, blocks): + hit_blocks[gid] = blks + hit_lengths[gid] = group_hit + + return tuple(hit_blocks), tuple(hit_lengths) + def get_kv_cache_coordinator( kv_cache_config: KVCacheConfig, max_model_len: int, - max_num_batched_tokens: int, + max_in_flight_tokens: int, use_eagle: bool, enable_caching: bool, enable_kv_cache_events: bool, @@ -653,7 +797,7 @@ def get_kv_cache_coordinator( return KVCacheCoordinatorNoPrefixCache( kv_cache_config, max_model_len, - max_num_batched_tokens, + max_in_flight_tokens, use_eagle, enable_kv_cache_events, dcp_world_size=dcp_world_size, @@ -666,7 +810,7 @@ def get_kv_cache_coordinator( return UnitaryKVCacheCoordinator( kv_cache_config, max_model_len, - max_num_batched_tokens, + max_in_flight_tokens, use_eagle, enable_caching, enable_kv_cache_events, @@ -679,7 +823,7 @@ def get_kv_cache_coordinator( return HybridKVCacheCoordinator( kv_cache_config, max_model_len, - max_num_batched_tokens, + max_in_flight_tokens, use_eagle, enable_caching, enable_kv_cache_events, diff --git a/vllm/v1/core/kv_cache_manager.py b/vllm/v1/core/kv_cache_manager.py index d98520da95fc..4b62915edeff 100644 --- a/vllm/v1/core/kv_cache_manager.py +++ b/vllm/v1/core/kv_cache_manager.py @@ -8,16 +8,20 @@ from vllm.distributed.kv_events import BlockStored, KVCacheEvent from vllm.logger import init_logger +from vllm.utils.math_utils import cdiv from vllm.v1.core.kv_cache_coordinator import get_kv_cache_coordinator from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector from vllm.v1.core.kv_cache_utils import KVCacheBlock from vllm.v1.kv_cache_interface import ( + AttentionSpec, + CrossAttentionSpec, + EncoderOnlyAttentionSpec, KVCacheConfig, get_kv_cache_spec_kind, get_kv_cache_spec_sliding_window, ) from vllm.v1.metrics.stats import PrefixCacheStats -from vllm.v1.request import Request +from vllm.v1.request import Request, RequestStatus logger = init_logger(__name__) @@ -114,7 +118,7 @@ def __init__( max_model_len: int, scheduler_block_size: int, hash_block_size: int, - max_num_batched_tokens: int | None = None, + max_in_flight_tokens: int | None = None, enable_caching: bool = True, use_eagle: bool = False, log_stats: bool = False, @@ -122,13 +126,14 @@ def __init__( dcp_world_size: int = 1, pcp_world_size: int = 1, metrics_collector: KVCacheMetricsCollector | None = None, + watermark: float = 0.0, ) -> None: self.max_model_len = max_model_len # When unset, fall back to `max_model_len` so the recycling-aware cap # collapses to the prior (uncapped) admission behavior. The scheduler # always supplies the real value at runtime. - if max_num_batched_tokens is None: - max_num_batched_tokens = max_model_len + if max_in_flight_tokens is None: + max_in_flight_tokens = max_model_len self.enable_caching = enable_caching self.use_eagle = use_eagle @@ -142,7 +147,7 @@ def __init__( self.coordinator = get_kv_cache_coordinator( kv_cache_config=kv_cache_config, max_model_len=self.max_model_len, - max_num_batched_tokens=max_num_batched_tokens, + max_in_flight_tokens=max_in_flight_tokens, use_eagle=self.use_eagle, enable_caching=self.enable_caching, enable_kv_cache_events=enable_kv_cache_events, @@ -155,6 +160,11 @@ def __init__( self.num_kv_cache_groups = len(kv_cache_config.kv_cache_groups) self.block_pool = self.coordinator.block_pool self.kv_cache_config = kv_cache_config + + # Watermark: minimum number of KV cache blocks to keep free when + # admitting waiting/preempted requests, to avoid frequent preemptions. + assert watermark >= 0.0, "watermark must be non-negative" + self.watermark_blocks = int(watermark * kv_cache_config.num_blocks) self.kv_cache_event_metadata = tuple( ( get_kv_cache_spec_kind(group.kv_cache_spec).value, @@ -246,6 +256,8 @@ def allocate_slots( delay_cache_blocks: bool = False, num_encoder_tokens: int = 0, full_sequence_must_fit: bool = False, + reserved_blocks: int = 0, + has_scheduled_reqs: bool = True, ) -> KVCacheBlocks | None: """Add slots for a request with new tokens to append. @@ -271,6 +283,13 @@ def allocate_slots( free blocks to hold the full sequence, accounting for prefix cache hits and sliding window. Used as an admission gate to prevent over-admitting requests when chunked prefill would otherwise only check the first chunk + reserved_blocks: Number of free blocks that must be left available for + other in-flight sequences to complete. The actual allocation is only + made if it fits within (free blocks - reserved_blocks). Used to gate + async KV-connector loads so their initial allocation cannot consume + blocks an already in-flight (prefilling) sequence is relying on. + has_scheduled_reqs: Whether any requests are already scheduled to run + this step, controls whether watermark is applied. Blocks layout: ``` @@ -345,6 +364,15 @@ def allocate_slots( self.max_model_len, ) + watermark_blocks = 0 + # The watermark is applied to waiting/preempted requests only, and only + # when there's at least one request already scheduled. + if has_scheduled_reqs and request.status in ( + RequestStatus.WAITING, + RequestStatus.PREEMPTED, + ): + watermark_blocks = self.watermark_blocks + if full_sequence_must_fit: # First check and fail if the full request sequence won't fit. full_num_tokens = min(request.num_tokens, self.max_model_len) @@ -358,7 +386,8 @@ def allocate_slots( num_tokens_main_model=full_num_tokens, apply_admission_cap=True, ) - if num_blocks_to_allocate > self.block_pool.get_num_free_blocks(): + required_blocks = num_blocks_to_allocate + watermark_blocks + if required_blocks > self.block_pool.get_num_free_blocks(): return None num_tokens_main_model = total_computed_tokens + num_new_tokens @@ -372,8 +401,13 @@ def allocate_slots( # insufficient free blocks. # Should call this function before allocating new blocks to reduce # the number of evicted blocks. + # Free on the processed-token basis: in-flight steps' attention windows + # still read blocks below the optimistic boundary, and rejected spec + # tokens can roll it back. self.coordinator.remove_skipped_blocks( - request.request_id, total_computed_tokens + request.request_id, + max(0, total_computed_tokens - request.num_in_flight_tokens), + num_prompt_tokens=request.num_prompt_tokens, ) num_blocks_to_allocate = self.coordinator.get_num_blocks_to_allocate( @@ -386,7 +420,11 @@ def allocate_slots( num_tokens_main_model=num_tokens_main_model, ) - if num_blocks_to_allocate > self.block_pool.get_num_free_blocks(): + # Keep `reserved_blocks` free for other in-flight sequences, and an + # additional watermark of headroom for waiting/preempted admissions. + available_blocks = self.block_pool.get_num_free_blocks() - reserved_blocks + required_blocks = num_blocks_to_allocate + watermark_blocks + if required_blocks > available_blocks: # Cannot allocate new blocks return None @@ -439,17 +477,36 @@ def free(self, request: Request) -> None: self.coordinator.free(request.request_id) def remove_skipped_blocks( - self, request_id: str, total_computed_tokens: int + self, + request_id: str, + processed_computed_tokens: int, + num_prompt_tokens: int | None = None, ) -> None: """Remove the blocks that are no longer needed from `blocks` and replace the removed blocks with null_block. Args: request_id: The request ID. - total_computed_tokens: The total number of computed tokens, including - local computed tokens and external computed tokens. + processed_computed_tokens: Computed-token prefix length covering + fully processed and committed tokens only (safe to free). + num_prompt_tokens: Optional prompt length for R-SWA gap eviction. + """ + self.coordinator.remove_skipped_blocks( + request_id, processed_computed_tokens, num_prompt_tokens + ) + + def pop_blocks_for_free(self, request: Request) -> list[KVCacheBlock]: + """Pop the request's bookkeeping and return its blocks without + returning them to the block pool. The caller must eventually free + them in reverse order (so that tail blocks are evicted first). + + Args: + request: The request to pop the blocks for. + + Returns: + The request's blocks in allocation order. """ - self.coordinator.remove_skipped_blocks(request_id, total_computed_tokens) + return self.coordinator.pop_blocks_for_free(request.request_id) def evict_blocks(self, block_ids: set[int]) -> None: """evict blocks from the prefix cache by their block IDs. @@ -543,6 +600,26 @@ def get_block_ids(self, request_id: str) -> tuple[list[int], ...]: """Get the block ids of a request.""" return self.get_blocks(request_id).get_block_ids() + def get_block_ids_for_computed_tokens( + self, + request_id: str, + num_computed_tokens: int, + ) -> tuple[list[int], ...]: + """Get block ids covering the request's computed tokens.""" + block_ids = self.get_block_ids(request_id) + clipped_block_ids: list[list[int]] = [] + for group, ids in zip(self.kv_cache_config.kv_cache_groups, block_ids): + spec = group.kv_cache_spec + if not isinstance(spec, AttentionSpec) or isinstance( + spec, (CrossAttentionSpec, EncoderOnlyAttentionSpec) + ): + clipped_block_ids.append(ids) + continue + + num_valid_blocks = cdiv(num_computed_tokens, spec.block_size) + clipped_block_ids.append(ids[:num_valid_blocks]) + return tuple(clipped_block_ids) + def cache_blocks(self, request: Request, num_computed_tokens: int) -> None: """Cache the blocks for the request, if enabled. diff --git a/vllm/v1/core/kv_cache_utils.py b/vllm/v1/core/kv_cache_utils.py index cfa79f077a16..aa42f90bb90e 100644 --- a/vllm/v1/core/kv_cache_utils.py +++ b/vllm/v1/core/kv_cache_utils.py @@ -20,6 +20,7 @@ from vllm.utils.mem_utils import format_gib from vllm.utils.torch_utils import get_dtype_size from vllm.v1.kv_cache_interface import ( + AttentionSpec, ChunkedLocalAttentionSpec, FullAttentionSpec, HiddenStateCacheSpec, @@ -124,6 +125,9 @@ class KVCacheBlock: # The hash key (block hash + group id) of the block, only available # when the block is full and cached. _block_hash: BlockHashWithGroupId | None = None + # Number of prefix tokens covered by _block_hash. For full blocks this is + # the full block boundary; partial aliases can end inside a cache block. + _block_hash_num_tokens: int | None = None # Used to construct a doubly linked list for free blocks. # These two attributes should only be manipulated by FreeKVCacheBlockQueue. @@ -137,16 +141,25 @@ class KVCacheBlock: def block_hash(self) -> BlockHashWithGroupId | None: return self._block_hash - @block_hash.setter - def block_hash(self, block_hash: BlockHashWithGroupId): - assert self.block_hash is None, ( + @property + def block_hash_num_tokens(self) -> int | None: + return self._block_hash_num_tokens + + def set_block_hash( + self, + block_hash: BlockHashWithGroupId, + num_tokens: int | None = None, + ) -> None: + assert self.block_hash is None and self._block_hash_num_tokens is None, ( "The block already has a hash. This should not happen." ) self._block_hash = block_hash + self._block_hash_num_tokens = num_tokens def reset_hash(self): """Reset the block hash when the block is evicted.""" self._block_hash = None + self._block_hash_num_tokens = None def __repr__(self) -> str: # Use block_id instead of KVCacheBlock object to avoid calling __repr__ @@ -157,6 +170,7 @@ def __repr__(self) -> str: f"KVCacheBlock(block_id={self.block_id}, " f"ref_cnt={self.ref_cnt}, " f"_block_hash={self._block_hash!r}, " + f"_block_hash_num_tokens={self._block_hash_num_tokens}, " f"prev_free_block={prev_block_id}, " f"next_free_block={next_block_id})" ) @@ -327,6 +341,27 @@ def append(self, block: KVCacheBlock) -> None: self.num_free_blocks += 1 + def prepend_n(self, blocks: list[KVCacheBlock]) -> None: + """Put a list of blocks at the front of the free list.""" + if len(blocks) == 0: + return + + first_block = self.fake_free_list_head.next_free_block + assert first_block is not None, ( + "next_free_block of fake_free_list_head should always exist" + ) + + prev_block = self.fake_free_list_head + for block in blocks: + block.prev_free_block = prev_block + prev_block.next_free_block = block + prev_block = block + + prev_block.next_free_block = first_block + first_block.prev_free_block = prev_block + + self.num_free_blocks += len(blocks) + def append_n(self, blocks: list[KVCacheBlock]) -> None: """Put a list of blocks back into the free list @@ -372,6 +407,20 @@ def get_all_free_blocks(self) -> list[KVCacheBlock]: curr_block = curr_block.next_free_block return ret + def iter_blocks_after( + self, + cursor: KVCacheBlock | None, + ) -> Iterator[KVCacheBlock]: + """Iterate free blocks in eviction order after the cursor.""" + if cursor is None: + curr_block = self.fake_free_list_head.next_free_block + else: + curr_block = cursor.next_free_block + + while curr_block is not None and curr_block is not self.fake_free_list_tail: + yield curr_block + curr_block = curr_block.next_free_block + def need_extra_keys(request: Request) -> bool: """Check whether the blocks allocated to this request need extra hash keys. @@ -636,18 +685,24 @@ def resolve_kv_cache_block_sizes( def get_request_block_hasher( - block_size: int, + hash_block_size: int, caching_hash_fn: Callable[[Any], bytes], ) -> Callable[[Request], list[BlockHash]]: """ Returns a function which computes the list of un-computed block hashes - of a request.""" + of a request. + + Hashes are computed at ``hash_block_size`` granularity and chained over the + full prefix, so each hash uniquely fingerprints the prefix ending at its + boundary. Coarser group block sizes and partial-cache boundaries reuse + these hashes directly (see ``BlockHashListWithBlockSize``). + """ def request_block_hasher(request: Request) -> list[BlockHash]: - start_token_idx = len(request.block_hashes) * block_size + start_token_idx = len(request.block_hashes) * hash_block_size num_tokens = request.num_tokens - if start_token_idx + block_size > num_tokens: + if start_token_idx + hash_block_size > num_tokens: # Early stop when there no new full blocks created. return [] @@ -664,7 +719,7 @@ def request_block_hasher(request: Request) -> list[BlockHash]: ) new_block_hashes: list[BlockHash] = [] while True: - end_token_idx = start_token_idx + block_size + end_token_idx = start_token_idx + hash_block_size if end_token_idx > num_tokens: # We only hash full blocks break @@ -681,7 +736,7 @@ def request_block_hasher(request: Request) -> list[BlockHash]: ) new_block_hashes.append(block_hash) - start_token_idx += block_size + start_token_idx += hash_block_size prev_block_hash_value = block_hash return new_block_hashes @@ -906,7 +961,9 @@ def may_override_num_blocks(vllm_config: VllmConfig, num_blocks: int) -> int: return num_blocks -def _pool_bytes_per_block(kv_cache_groups: list[KVCacheGroupSpec]) -> int: +def _pool_bytes_per_block( + vllm_config: VllmConfig, kv_cache_groups: list[KVCacheGroupSpec] +) -> int: """ Bytes consumed by one block in the worker's shared KV cache pool, mirroring the divisor used by `get_kv_cache_config_from_groups` to convert @@ -917,17 +974,10 @@ def _pool_bytes_per_block(kv_cache_groups: list[KVCacheGroupSpec]) -> int: kv_cache_groups[0].kv_cache_spec, UniformTypeKVCacheSpecs ): return kv_cache_groups[0].kv_cache_spec.page_size_bytes - if all( - isinstance(g.kv_cache_spec, UniformTypeKVCacheSpecs) for g in kv_cache_groups - ): - # DeepseekV4: shared layout sized by the largest per-page-size bucket. - full_mla_spec = cast(UniformTypeKVCacheSpecs, kv_cache_groups[0].kv_cache_spec) - layer_tuple_page_bytes = sum(full_mla_spec.get_page_sizes()) - num_layer_tuples = max( - cast(UniformTypeKVCacheSpecs, g.kv_cache_spec).get_num_layer_tuples() - for g in kv_cache_groups - ) - return layer_tuple_page_bytes * num_layer_tuples + if _use_packed_kv_cache_config(vllm_config, kv_cache_groups): + # buckets = {page_size: [[layer_names], [layer_names], ...]} + buckets = _bucket_layers_by_page_size(kv_cache_groups) + return sum(ps * len(slots) for ps, slots in buckets.items()) group_size = max(len(g.layer_names) for g in kv_cache_groups) page_size = get_uniform_page_size([g.kv_cache_spec for g in kv_cache_groups]) return page_size * group_size @@ -1016,8 +1066,14 @@ def unify_kv_cache_spec_page_size( """ Unify the page size of the given KVCacheSpec. If the page size of all layers are the same, return the original KVCacheSpec. If not same, unify the page - size by increasing the block size of layers with smaller page size. Raise - NotImplementedError if failed to unify the page size. + size by increasing the block size of layers with smaller page size. Two + cases cannot be unified by block size alone and pad their physical page to + the maximum instead: Mamba layers, whose page size comes from state shapes + and is independent of block size; and attention layers whose page does not + evenly divide the maximum and whose backend opts in via + ``AttentionSpec.indexes_kv_by_block_stride`` (the padded page is read through + a strided view, which not every backend handles). Raise NotImplementedError + if failed to unify the page size. Args: kv_cache_spec: The KVCacheSpec of each attention layer in the model @@ -1035,16 +1091,35 @@ def unify_kv_cache_spec_page_size( for layer_name, layer_spec in kv_cache_spec.items(): if layer_spec.page_size_bytes == max_page_size: new_kv_cache_spec[layer_name] = layer_spec + elif isinstance(layer_spec, MambaSpec): + # MambaSpec's page size is determined by its state shapes and does + # not scale with block_size, so pad the page instead. This is the + # same padding mechanism the platform uses to align Mamba pages + # with the main model's attention page size; it is needed here + # when another layer (e.g. from a draft model) has a larger page + # than the already-aligned Mamba page. + new_spec: KVCacheSpec = replace(layer_spec, page_size_padded=max_page_size) + assert new_spec.page_size_bytes == max_page_size + new_kv_cache_spec[layer_name] = new_spec else: layer_page_size = layer_spec.page_size_bytes - if max_page_size % layer_page_size != 0: + if max_page_size % layer_page_size == 0: + ratio = max_page_size // layer_page_size + new_block_size = layer_spec.block_size * ratio + new_spec = replace(layer_spec, block_size=new_block_size) + elif ( + isinstance(layer_spec, AttentionSpec) + and layer_spec.indexes_kv_by_block_stride + ): + new_spec = replace(layer_spec, page_size_padded=max_page_size) + else: raise NotImplementedError( - "The page size of the layer is not divisible by the " - "maximum page size. Cannot unify by adjusting block_size." + f"Layer {layer_name}: page size is not divisible by the " + "maximum page size and cannot be padded. Padding is only " + "supported for attention layers whose backend indexes KV " + "pages by the block stride (indexes_kv_by_block_stride is " + "True)." ) - ratio = max_page_size // layer_page_size - new_block_size = layer_spec.block_size * ratio - new_spec = replace(layer_spec, block_size=new_block_size) assert new_spec.page_size_bytes == max_page_size new_kv_cache_spec[layer_name] = new_spec return new_kv_cache_spec @@ -1177,59 +1252,87 @@ def _get_kv_cache_groups_uniform_page_size( return create_kv_cache_group_specs(kv_cache_spec, grouped_layers) -def _get_kv_cache_config_deepseek_v4( +def _bucket_layers_by_page_size( + kv_cache_groups: list[KVCacheGroupSpec], +) -> dict[int, list[list[str]]]: + """Bucket layers by page size: ``result[ps][slot_idx] = [layer_names]``. + + Layers from different groups at the same ``slot_idx`` share an underlying tensor + (they have independent block tables so block-id namespaces never collide). + """ + buckets: dict[int, list[list[str]]] = defaultdict(list) + for group in kv_cache_groups: + spec = group.kv_cache_spec + slot_count: dict[int, int] = defaultdict(int) + for layer_name in group.layer_names: + if isinstance(spec, UniformTypeKVCacheSpecs): + ps = spec.kv_cache_specs[layer_name].page_size_bytes + else: + ps = spec.page_size_bytes + slot_idx = slot_count[ps] + slot_count[ps] += 1 + if slot_idx == len(buckets[ps]): + buckets[ps].append([]) + buckets[ps][slot_idx].append(layer_name) + return buckets + + +def _use_packed_kv_cache_config( + vllm_config: VllmConfig, + kv_cache_groups: list[KVCacheGroupSpec], +) -> bool: + is_dsv4 = all( + isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs) + for group in kv_cache_groups + ) + kv_transfer_config = vllm_config.kv_transfer_config + extra_config = ( + kv_transfer_config.kv_connector_extra_config + if kv_transfer_config is not None + else {} + ) + # NOTE: enable_cross_layers_blocks is an experimental API and subject to change with + # https://github.com/vllm-project/vllm/issues/42082 + enable_cross_layers = ( + str(extra_config.get("enable_cross_layers_blocks", "False")).lower() == "true" + ) + return is_dsv4 or (enable_cross_layers and len(kv_cache_groups) > 1) + + +def _get_kv_cache_config_packed( vllm_config: VllmConfig, kv_cache_groups: list[KVCacheGroupSpec], available_memory: int, ) -> tuple[int, list[KVCacheTensor]]: - """DeepseekV4 KV cache tensor layout planning. - - Precondition: kv_cache_groups[0] is the full-MLA group; its page sizes - define the canonical bucket set. Non-full-MLA groups must have been - page_size-padded upstream (see _get_kv_cache_groups_uniform_groups) so - every layer's page_size matches one of the full-MLA bucket sizes. - - For each group, bucket its layers by page_size_bytes and place each - layer at tuple_idx = position-within-bucket. Emit one KVCacheTensor - per (tuple_idx, bucket) whose shared_by is the union of per-group - layers at that slot. - """ - full_mla_spec = kv_cache_groups[0].kv_cache_spec - assert isinstance(full_mla_spec, UniformTypeKVCacheSpecs) - page_sizes = sorted(full_mla_spec.get_page_sizes()) - layer_tuple_page_bytes = sum(page_sizes) - - # Pre-bucket each group's layers by page_size (registration order within - # bucket). bucketed[g_idx][page_size] = [layer_name, ...]. - bucketed: list[dict[int, list[str]]] = [] - for group in kv_cache_groups: - assert isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs) - specs = group.kv_cache_spec.kv_cache_specs - b: dict[int, list[str]] = defaultdict(list) - for name in group.layer_names: - b[specs[name].page_size_bytes].append(name) - bucketed.append(b) - - # num_layer_tuples = longest bucket list across all groups. For the - # full-MLA group this equals the count of layers in the largest - # per-page-size bucket (= get_num_layer_tuples()); for SWA sub-groups - # this equals the sub-group size (each has a single page_size). - num_layer_tuples = max(len(layers) for b in bucketed for layers in b.values()) - - num_blocks = available_memory // (layer_tuple_page_bytes * num_layer_tuples) + """Plan a packed per-block KV cache tensor layout. + + Emit one KVCacheTensor per (slot_idx, page_size). Layers from different + groups at the same slot share a tensor (they have independent block + tables so block-id namespaces never collide). Each emitted tensor aliases + one physical backing allocation, with per-block data laid out contiguously. + """ + # buckets = {page_size: [[layer_names], [layer_names], ...]} + buckets = _bucket_layers_by_page_size(kv_cache_groups) + total_num_bytes_per_block = sum(ps * len(slots) for ps, slots in buckets.items()) + + num_blocks = available_memory // total_num_bytes_per_block num_blocks = may_override_num_blocks(vllm_config, num_blocks) + total_size = total_num_bytes_per_block * num_blocks + kv_cache_tensors: list[KVCacheTensor] = [] - for tuple_idx in range(num_layer_tuples): - for ps in page_sizes: - shared_by: list[str] = [] - for b in bucketed: - bucket = b.get(ps) - if bucket is not None and tuple_idx < len(bucket): - shared_by.append(bucket[tuple_idx]) + byte_offset = 0 + for ps, slots in buckets.items(): + for slot in slots: kv_cache_tensors.append( - KVCacheTensor(size=ps * num_blocks, shared_by=shared_by) + KVCacheTensor( + size=total_size, + shared_by=slot, + offset=byte_offset, + block_stride=total_num_bytes_per_block, + ) ) + byte_offset += ps return num_blocks, kv_cache_tensors @@ -1278,13 +1381,10 @@ def get_kv_cache_config_from_groups( ) for layer_name in kv_cache_groups[0].layer_names ] - elif all( - isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs) - for group in kv_cache_groups - ): - # DeepseekV4: UniformTypeKVCacheSpecs but multiple groups. - # Delegate to the DeepseekV4-specific allocator. - num_blocks, kv_cache_tensors = _get_kv_cache_config_deepseek_v4( + elif _use_packed_kv_cache_config(vllm_config, kv_cache_groups): + # DeepSeek V4 uses the packed layout by default. Other multi-group + # layouts can opt in with --enable-cross-layers. + num_blocks, kv_cache_tensors = _get_kv_cache_config_packed( vllm_config, kv_cache_groups, available_memory ) else: @@ -1707,36 +1807,17 @@ def generate_scheduler_kv_cache_config( return cfg -def _report_kv_cache_config( +def get_kv_cache_capacity( vllm_config: VllmConfig, kv_cache_config: KVCacheConfig -) -> None: +) -> tuple[int, float]: """ - Log resolved KV cache configuration. - - Args: - vllm_config: The global VllmConfig - kv_cache_config: The resolved KV cache configuration + Get the group-aware KV cache token capacity and max concurrency. """ max_model_len = vllm_config.model_config.max_model_len max_concurrency = get_max_concurrency_for_kv_cache_config( vllm_config, kv_cache_config ) - - # GPU KV cache size in tokens = max_concurrency * max_model_len: the total - # tokens of context the pool can hold at peak utilization. Sourcing this - # from the concurrency calculation handles hybrid layouts correctly: SWA / - # chunked-local groups have a per-request block count that's capped by - # their window, so a naive `num_blocks // num_groups * block_size` formula - # underestimates capacity for these models. DCP/PCP sharding is already - # accounted for in each spec's `max_memory_usage_bytes`. - num_tokens = int(max_concurrency * max_model_len) - - logger.info_once("GPU KV cache size: %s tokens", f"{num_tokens:,}") - logger.info_once( - "Maximum concurrency for %s tokens per request: %.2fx", - f"{max_model_len:,}", - max_concurrency, - ) + return int(max_concurrency * max_model_len), max_concurrency def _max_memory_usage_bytes_from_groups( @@ -2021,7 +2102,7 @@ def get_kv_cache_configs( if not groups: adjusted_memory.append(avail_mem) continue - bytes_per_block = _pool_bytes_per_block(groups) + bytes_per_block = _pool_bytes_per_block(vllm_config, groups) logger.info( "Overriding num_gpu_blocks=%d with num_gpu_blocks_override=%d", avail_mem // bytes_per_block, @@ -2075,7 +2156,21 @@ def get_kv_cache_configs( tensor.size = tensor.size // num_blocks_old * min_num_blocks if len(kv_cache_config.kv_cache_groups) > 0: - _report_kv_cache_config(vllm_config, kv_cache_config) + max_model_len = vllm_config.model_config.max_model_len + # GPU KV cache size in tokens = max_concurrency * max_model_len: + # the total tokens of context the pool can hold at peak + # utilization. Sourcing this from the concurrency calculation + # handles hybrid layouts correctly. + num_tokens, max_concurrency = get_kv_cache_capacity( + vllm_config, kv_cache_config + ) + + logger.info_once("GPU KV cache size: %s tokens", f"{num_tokens:,}") + logger.info_once( + "Maximum concurrency for %s tokens per request: %.2fx", + f"{max_model_len:,}", + max_concurrency, + ) return kv_cache_configs @@ -2089,11 +2184,14 @@ class BlockHashListWithBlockSize: Currently, only scaling up by an integer factor is supported (i.e., `target_block_size` is a multiple of `hash_block_size`). Conversion is - performed lazily on access for efficiency, by concatenating consecutive - hashes at `hash_block_size` to form each hash at `target_block_size`. + performed lazily on access for efficiency. Each `hash_block_size` hash is + already chained over its entire prefix, so the hash at the last + `hash_block_size` boundary of a `target_block_size` block uniquely + fingerprints that block's prefix; we use it directly. Example (`hash_block_size` = 16, `target_block_size` = 32): - concatenating two 16-size hashes yields one 32-size hash: + the second 16-size hash already covers tokens 0-31, so it is the 32-size + hash: Block hashes with block_size 16: | Token Range | 0-15 | 16-31 | 32-47 | 48-63 | @@ -2103,7 +2201,7 @@ class BlockHashListWithBlockSize: Block hashes with block_size 32: | Token Range | 0-31 | 32-63 | |-------------|------|-------| - | Hash | AB | CD | + | Hash | B | D | Args: block_hashes: Block hashes to convert, computed at `hash_block_size`. @@ -2145,9 +2243,9 @@ def __iter__(self) -> Iterator[BlockHash]: yield self._get_value_at(i) def _get_value_at(self, idx: int) -> BlockHash: - base = idx * self.scale_factor - end = base + self.scale_factor - return BlockHash(b"".join(self.block_hashes[base:end])) + # The last hash_block_size hash within the target block already chains + # over the whole prefix, so it is the target block's hash. + return self.block_hashes[(idx + 1) * self.scale_factor - 1] BlockHashList = list[BlockHash] | BlockHashListWithBlockSize diff --git a/vllm/v1/core/sched/async_scheduler.py b/vllm/v1/core/sched/async_scheduler.py index 2fd22f4c0cb4..d1c652c46efa 100644 --- a/vllm/v1/core/sched/async_scheduler.py +++ b/vllm/v1/core/sched/async_scheduler.py @@ -19,6 +19,10 @@ def __init__(self, *args, **kwargs) -> None: def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None: super()._update_after_schedule(scheduler_output) spec_decode_tokens = scheduler_output.scheduled_spec_decode_tokens + # Use the latest num of scheduled draft tokens in next step as placeholder. + self._spec_token_placeholders = [ + -1 + ] * scheduler_output.num_spec_tokens_to_schedule for req_id in scheduler_output.num_scheduled_tokens: request = self.requests[req_id] if request.is_prefill_chunk: @@ -27,10 +31,14 @@ def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None: scheduler_output.pending_structured_output_tokens |= ( request.use_structured_output and request.num_output_placeholders > 0 ) - # The request will generate a new token plus num_spec_tokens - # in this scheduling step. + # The request will generate num_sampled_tokens_per_step new tokens + # plus num_spec_tokens in this scheduling step. Diffusion has no AR + # bonus token (num_sampled_tokens_per_step == 0) — only the canvas + # (spec) tokens. cur_num_spec_tokens = len(spec_decode_tokens.get(req_id, ())) - request.num_output_placeholders += 1 + cur_num_spec_tokens + request.num_output_placeholders += ( + self.num_sampled_tokens_per_step + cur_num_spec_tokens + ) # Add placeholders for the new draft/spec tokens. # We will update the actual spec token ids in the worker process. request.spec_token_ids = self._spec_token_placeholders diff --git a/vllm/v1/core/sched/interface.py b/vllm/v1/core/sched/interface.py index 264811a556d3..bc65250f991f 100644 --- a/vllm/v1/core/sched/interface.py +++ b/vllm/v1/core/sched/interface.py @@ -49,7 +49,7 @@ def __init__( raise NotImplementedError @abstractmethod - def schedule(self) -> "SchedulerOutput": + def schedule(self, throttle_prefills: bool = False) -> "SchedulerOutput": """Schedule the requests to process in this scheduling step. The scheduling decision is made at the iteration level. Each scheduling @@ -68,6 +68,12 @@ def schedule(self) -> "SchedulerOutput": or the batch as a whole. The model runner will use this information in preparing inputs to the model. + Args: + throttle_prefills: DP prefill balancing. When True (set by the DP + engine core on non-cadence-aligned steps), new prefill compute is + deferred to a later step so prefills stay aligned across DP ranks; + automatically overridden when the rank is saturated. + Returns: A SchedulerOutput object containing information about the scheduled requests. diff --git a/vllm/v1/core/sched/output.py b/vllm/v1/core/sched/output.py index b2e9dd8b1719..291e73bc64b3 100644 --- a/vllm/v1/core/sched/output.py +++ b/vllm/v1/core/sched/output.py @@ -118,8 +118,8 @@ class CachedRequestData: # NOTE(woosuk): new_token_ids is only used for pipeline parallelism. # When PP is not used, new_token_ids will be empty. new_token_ids: list[list[int]] - # For requests not scheduled in the last step, propagate the token ids to the - # connector. Won't contain requests that were scheduled in the prior step. + # MRV1-only: For requests not scheduled in the last step, propagate the token ids + # to the connector. Won't contain requests scheduled in the prior step. all_token_ids: dict[str, list[int]] new_block_ids: list[tuple[list[int], ...] | None] num_computed_tokens: list[int] @@ -240,6 +240,10 @@ class SchedulerOutput: # preventing stale NaN/data from corrupting attention or SSM computation. new_block_ids_to_zero: list[int] | None = None + # Dynamic speculative decoding: optimal K chosen by scheduler. + # Number of spec tokens to schedule for the next step. + num_spec_tokens_to_schedule: int = 0 + @classmethod def make_empty(cls) -> "SchedulerOutput": return cls( diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py index c39e80c24eb0..95071408876b 100644 --- a/vllm/v1/core/sched/scheduler.py +++ b/vllm/v1/core/sched/scheduler.py @@ -34,8 +34,10 @@ EncoderCacheManager, EncoderDecoderCacheManager, ) +from vllm.v1.core.kv_cache_coordinator import HybridKVCacheCoordinator from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector +from vllm.v1.core.kv_cache_utils import KVCacheBlock from vllm.v1.core.sched.interface import PauseState, SchedulerInterface from vllm.v1.core.sched.output import ( CachedRequestData, @@ -55,6 +57,7 @@ from vllm.v1.metrics.stats import PrefixCacheStats, SchedulerStats from vllm.v1.outputs import DraftTokenIds, KVConnectorOutput, ModelRunnerOutput from vllm.v1.request import Request, RequestStatus, StreamingUpdate +from vllm.v1.spec_decode.dynamic.utils import build_dynamic_sd_schedule_lookup from vllm.v1.spec_decode.metrics import SpecDecodingStats from vllm.v1.structured_output import StructuredOutputManager from vllm.v1.utils import record_function_or_nullcontext @@ -98,6 +101,7 @@ def __init__( self.finished_req_ids_dict: dict[int, set[str]] | None = ( defaultdict(set) if include_finished_set else None ) + # Track requests scheduled in prior step (MRV1-only). self.prev_step_scheduled_req_ids: set[str] = set() # Scheduling constraints. @@ -112,6 +116,10 @@ def __init__( self.kv_events_config is not None and self.kv_events_config.enable_kv_cache_events ) + # Diffusion models may not sample any tokens for a denoising step. + self.num_sampled_tokens_per_step = ( + 1 if not vllm_config.model_config.is_diffusion else 0 + ) # Create KVConnector for the Scheduler. Note that each Worker # will have a corresponding KVConnector with Role=WORKER. @@ -119,7 +127,9 @@ def __init__( self.connector = None self.connector_prefix_cache_stats: PrefixCacheStats | None = None self.recompute_kv_load_failures = True - if self.vllm_config.kv_transfer_config is not None: + self.defer_block_free = False + kv_transfer_config = self.vllm_config.kv_transfer_config + if kv_transfer_config is not None: assert not self.is_encoder_decoder, ( "Encoder-decoder models are not currently supported with KV connectors" ) @@ -130,11 +140,17 @@ def __init__( ) if self.log_stats: self.connector_prefix_cache_stats = PrefixCacheStats() - kv_load_failure_policy = ( - self.vllm_config.kv_transfer_config.kv_load_failure_policy - ) + kv_load_failure_policy = kv_transfer_config.kv_load_failure_policy self.recompute_kv_load_failures = kv_load_failure_policy == "recompute" + # With overlapping batches (async scheduling or PP), a step may + # still be writing a freed request's KV blocks. A consumer KV + # Connector can reallocate and fill those blocks via a load that + # isn't ordered against that write, so defer freeing them. + multiple_inflight_batches = self.vllm_config.max_concurrent_batches > 1 + if multiple_inflight_batches and kv_transfer_config.is_kv_consumer: + self.defer_block_free = True + self.kv_event_publisher = EventPublisherFactory.create( self.kv_events_config, self.parallel_config.data_parallel_index, @@ -173,6 +189,9 @@ def __init__( # This is flushed at the end of each scheduling step. self.finished_req_ids: set[str] = set() + # IDs of requests preempted since the last call to schedule(). + self.reset_preempted_req_ids: set[str] = set() + # Counter for requests waiting for streaming input. Used to calculate # number of unfinished requests self.num_waiting_for_streaming_input: int = 0 @@ -211,9 +230,16 @@ def __init__( speculative_config = vllm_config.speculative_config self.use_eagle = False - self.num_spec_tokens = self.num_lookahead_tokens = 0 - if speculative_config: - self.num_spec_tokens = speculative_config.num_speculative_tokens + self.num_spec_tokens = vllm_config.num_speculative_tokens + self.num_lookahead_tokens = 0 + self.dynamic_sd_lookup: list[int] | None = None + if speculative_config is not None: + if speculative_config.num_speculative_tokens_per_batch_size: + self.dynamic_sd_lookup = build_dynamic_sd_schedule_lookup( + speculative_config.num_speculative_tokens_per_batch_size, + vllm_max_batch_size=self.scheduler_config.max_num_seqs, + vllm_num_speculative_tokens=self.num_spec_tokens, + ) if speculative_config.use_eagle(): self.use_eagle = True self.num_lookahead_tokens = self.num_spec_tokens @@ -224,6 +250,11 @@ def __init__( # decoding instead of standard next-token sampling, so it has a query # for the last sampled token plus queries for each draft token. self.num_lookahead_tokens = self.num_spec_tokens + 1 + if speculative_config.use_dspark(): + # DSpark drafts a block of num_spec_tokens query tokens in which the + # anchor itself is the first prediction position (no separate bonus + # query), so it needs exactly num_spec_tokens lookahead slots. + self.num_lookahead_tokens = self.num_spec_tokens # Create the KV cache manager. if hash_block_size is None: @@ -231,7 +262,7 @@ def __init__( self.kv_cache_manager = KVCacheManager( kv_cache_config=kv_cache_config, max_model_len=self.max_model_len, - max_num_batched_tokens=self.scheduler_config.max_num_batched_tokens, + max_in_flight_tokens=vllm_config.max_in_flight_tokens, enable_caching=self.cache_config.enable_prefix_caching, use_eagle=self.use_eagle, log_stats=self.log_stats, @@ -241,6 +272,7 @@ def __init__( scheduler_block_size=self.block_size, hash_block_size=hash_block_size, metrics_collector=self.kv_metrics_collector, + watermark=self.scheduler_config.watermark, ) # Bind GPU block pool to the KV connector. This must happen after # kv_cache_manager is constructed so block_pool is available. @@ -252,6 +284,10 @@ def __init__( # Scheduler iteration counter. Drives the V2+PP+async decode-throttle # cadence (`next_decode_eligible_step`). self.current_step = 0 + # DP prefill balancing: Flag to track whether the last cadence-aligned + # prefill batch fully drained the waiting queue. Prefill throttling + # is disabled in this case. + self.prefill_capacity_bound = False self.scheduler_reserve_full_isl = ( self.scheduler_config.scheduler_reserve_full_isl ) @@ -261,6 +297,15 @@ def __init__( self.need_mamba_block_aligned_split = ( self.has_mamba_layers and self.cache_config.mamba_cache_mode == "align" ) + + # Counts of non-empty steps scheduled / processed. update_from_output + # is called once per scheduled step in FIFO order, so these stay in sync. + self.sched_step_seq = 0 + self.processed_step_seq = 0 + # FIFO of (fence_seq, blocks): blocks become safe to free once + # processed_step_seq >= fence_seq. + self.deferred_frees: deque[tuple[int, list[KVCacheBlock]]] = deque() + self.perf_metrics: ModelMetrics | None = None if self.log_stats and vllm_config.observability_config.enable_mfu_metrics: self.perf_metrics = ModelMetrics(vllm_config) @@ -286,16 +331,18 @@ def __init__( self._pause_state: PauseState = PauseState.UNPAUSED + # In-flight requests still prefilling (prefill chunks + in-progress + # async KV loads). Their remaining-block reservation gates async loads. + self._inflight_prefills: set[Request] = set() + def _mamba_block_aligned_split( self, request: Request, num_new_tokens: int, num_new_local_computed_tokens: int = 0, num_external_computed_tokens: int = 0, + num_uncached_common_prefix_tokens: int = 0, ) -> int: - assert num_external_computed_tokens == 0, ( - "External KV connector is not verified yet" - ) num_computed_tokens = ( request.num_computed_tokens + num_new_local_computed_tokens @@ -334,9 +381,19 @@ def _mamba_block_aligned_split( else: # prefill the last few tokens pass + + # Marconi cache admission optimization: + # cache common prefixes by scheduling num_new_tokens = common prefix length + if ( + num_uncached_common_prefix_tokens >= block_size + and num_new_tokens > num_uncached_common_prefix_tokens + ): + num_new_tokens = num_uncached_common_prefix_tokens + # keep alignment to block_size + num_new_tokens = num_new_tokens // block_size * block_size return num_new_tokens - def schedule(self) -> SchedulerOutput: + def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput: self.current_step += 1 # NOTE(woosuk) on the scheduling algorithm: # There's no "decoding phase" nor "prefill phase" in the scheduler. @@ -366,12 +423,20 @@ def schedule(self) -> SchedulerOutput: encoder_compute_budget = self.max_num_encoder_input_tokens # Spec decode-related. scheduled_spec_decode_tokens: dict[str, list[int]] = {} + # Whether the running batch contains any prefill requests. + prefill_scheduled = False # For logging. scheduled_timestamp = time.monotonic() self.kv_cache_manager.new_step_starts() + # DP prefill balancing: on a throttled (non-cadence-aligned) step, defer + # all prefill compute unless saturated. + defer_prefills = ( + throttle_prefills and not self.prefill_capacity_bound + ) and any(not r.is_prefill_chunk for r in self.running) + # First, schedule the RUNNING requests. req_index = 0 while req_index < len(self.running) and token_budget > 0: @@ -399,6 +464,12 @@ def schedule(self) -> SchedulerOutput: req_index += 1 continue + if defer_prefills and request.is_prefill_chunk: + # DP prefill balancing: defer this in-progress prefill chunk to a + # cadence-aligned step; decodes still run to fill this step. + req_index += 1 + continue + num_new_tokens = ( request.num_tokens_with_spec + request.num_output_placeholders @@ -411,7 +482,10 @@ def schedule(self) -> SchedulerOutput: # Make sure the input position does not exceed the max model len. # This is necessary when using spec decoding. num_new_tokens = min( - num_new_tokens, self.max_model_len - 1 - request.num_computed_tokens + num_new_tokens, + self.max_model_len + - request.num_computed_tokens + - self.num_sampled_tokens_per_step, ) # Schedule encoder inputs. @@ -509,6 +583,7 @@ def schedule(self) -> SchedulerOutput: # Schedule the request. scheduled_running_reqs.append(request) + prefill_scheduled |= request.is_prefill_chunk request_id = request.request_id req_to_new_blocks[request_id] = new_blocks num_scheduled_tokens[request_id] = num_new_tokens @@ -563,7 +638,10 @@ def schedule(self) -> SchedulerOutput: step_skipped_waiting = create_request_queue(self.policy) while (self.waiting or self.skipped_waiting) and token_budget > 0: - if len(self.running) == self.max_num_running_reqs: + # Paused streaming sessions (WAITING_FOR_STREAMING_REQ) are not + # in `running` but still hold a model-runner request slot. + num_running = len(self.running) + self.num_waiting_for_streaming_input + if num_running >= self.max_num_running_reqs: break request_queue = self._select_waiting_queue_for_scheduling() @@ -603,13 +681,57 @@ def schedule(self) -> SchedulerOutput: num_external_computed_tokens = 0 load_kv_async = False connector_prefix_cache_queries, connector_prefix_cache_hits = 0, 0 + num_uncached_common_prefix_tokens = 0 # Get already-cached tokens. if request.num_computed_tokens == 0: # Get locally-cached tokens. - new_computed_blocks, num_new_local_computed_tokens = ( - self.kv_cache_manager.get_computed_blocks(request) - ) + if ( + self.connector is not None + and self.has_mamba_layers + and isinstance( + self.kv_cache_manager.coordinator, + HybridKVCacheCoordinator, + ) + ): + computed, per_group_hits = ( + self.kv_cache_manager.coordinator.find_longest_cache_hit_per_group( + request.block_hashes, + request.num_tokens - 1, + ) + ) + new_computed_blocks = ( + self.kv_cache_manager.create_kv_cache_blocks(computed) + ) + # NOTE(ZhanqiuHu): For Mamba hybrid models, + # num_new_local_computed_tokens should be the FA hit + # length. This value is passed to the connector's + # get_num_new_matched_tokens which computes: + # external = total - local_computed. + # Using the FA hit skips re-transferring FA blocks + # already cached on D-side. The Mamba state (always + # the last block) is transferred unconditionally by + # _apply_prefix_caching in nixl/worker.py. + num_new_local_computed_tokens = max(per_group_hits) + if self.kv_cache_manager.log_stats: + assert self.kv_cache_manager.prefix_cache_stats is not None + self.kv_cache_manager.prefix_cache_stats.record( + num_tokens=request.num_tokens, + num_hits=num_new_local_computed_tokens, + preempted=request.num_preemptions > 0, + ) + else: + new_computed_blocks, num_new_local_computed_tokens = ( + self.kv_cache_manager.get_computed_blocks(request) + ) + + # In case of hybrid models, obtain hint for Marconi-style APC logic + if self.has_mamba_layers: + num_uncached_common_prefix_tokens = getattr( + self.kv_cache_manager.coordinator, + "num_uncached_common_prefix_tokens", + 0, + ) # Get externally-cached tokens if using a KVConnector. if self.connector is not None: @@ -670,17 +792,41 @@ def schedule(self) -> SchedulerOutput: encoder_inputs_to_schedule = None external_load_encoder_input = [] new_encoder_compute_budget = encoder_compute_budget + pad_spec_decode = False if load_kv_async: # KVTransfer: loading remote KV, do not allocate for new work. assert num_external_computed_tokens > 0 num_new_tokens = 0 + elif defer_prefills and num_computed_tokens < request.num_tokens - 1: + # DP prefill balancing: defer this step's local prefill + # compute to a cadence-aligned step. + break else: # Number of tokens to be scheduled. # We use `request.num_tokens` instead of # `request.num_prompt_tokens` to consider the resumed # requests, which have output tokens. num_new_tokens = request.num_tokens - num_computed_tokens + + # Pad new decode requests to uniform spec decoding size to + # preserve full cudagraph for this step. + # Not for diffusion where draft tokens can't be padded. + if ( + (self.num_spec_tokens > 0 and self.dynamic_sd_lookup is None) + and self.num_sampled_tokens_per_step > 0 + and num_new_tokens == 1 + and (scheduled_running_reqs and not prefill_scheduled) + ): + num_new_tokens = 1 + self.num_spec_tokens + if ( + num_new_tokens > token_budget + or num_computed_tokens + num_new_tokens > self.max_model_len + ): + # Prefer to not schedule than schedule un-padded here. + break + pad_spec_decode = True + threshold = self.scheduler_config.long_prefill_token_threshold if 0 < threshold < num_new_tokens: num_new_tokens = threshold @@ -716,22 +862,22 @@ def schedule(self) -> SchedulerOutput: # The request cannot be scheduled. break - if self.need_mamba_block_aligned_split: + # Skip block alignment when setting up async receive (no local work). + if self.need_mamba_block_aligned_split and not load_kv_async: num_new_tokens = self._mamba_block_aligned_split( request, num_new_tokens, num_new_local_computed_tokens, num_external_computed_tokens, + num_uncached_common_prefix_tokens, ) if num_new_tokens == 0: break - # Handles an edge case when P/D Disaggregation - # is used with Spec Decoding where an - # extra block gets allocated which - # creates a mismatch between the number - # of local and remote blocks. - limit_lookahead_tokens = load_kv_async and self.use_eagle + # During async KV load, no forward pass is run yet. + # Allocate speculative lookahead slots later to avoid + # mismatching local and remote block counts. + limit_lookahead_tokens = load_kv_async and self.num_lookahead_tokens > 0 effective_lookahead_tokens = ( 0 if limit_lookahead_tokens else self.num_lookahead_tokens ) @@ -748,6 +894,14 @@ def schedule(self) -> SchedulerOutput: for i in encoder_inputs_to_schedule ) + reserved_blocks = 0 + if load_kv_async: + # An async load holds its blocks for the whole transfer with + # no forward progress and isn't preemptible here. Admit it + # only if it fits in (free - other in-flight reservations), to + # avoid deadlock and predictable preemptions. + reserved_blocks = self._inflight_prefill_reserved_blocks() + new_blocks = self.kv_cache_manager.allocate_slots( request, num_new_tokens, @@ -758,6 +912,8 @@ def schedule(self) -> SchedulerOutput: delay_cache_blocks=load_kv_async, num_encoder_tokens=num_encoder_tokens, full_sequence_must_fit=self.scheduler_reserve_full_isl, + reserved_blocks=reserved_blocks, + has_scheduled_reqs=bool(self.running), ) if new_blocks is None: @@ -809,6 +965,7 @@ def schedule(self) -> SchedulerOutput: # _update_waiting_for_remote_kv will then cache # only the successfully loaded tokens. request.num_computed_tokens = num_computed_tokens + self._inflight_prefills.add(request) continue self.running.append(request) @@ -832,6 +989,13 @@ def schedule(self) -> SchedulerOutput: token_budget -= num_new_tokens request.status = RequestStatus.RUNNING request.num_computed_tokens = num_computed_tokens + if pad_spec_decode: + scheduled_spec_decode_tokens[request_id] = [ + -1 + ] * self.num_spec_tokens + # Only track requests that will still be prefilling after this chunk. + if num_computed_tokens + num_new_tokens < request.num_tokens: + self._inflight_prefills.add(request) # Encoder-related. if encoder_inputs_to_schedule: scheduled_encoder_inputs[request_id] = encoder_inputs_to_schedule @@ -852,6 +1016,11 @@ def schedule(self) -> SchedulerOutput: if step_skipped_waiting: self.skipped_waiting.prepend_requests(step_skipped_waiting) + # DP prefill balancing: on a step that admitted prefills (release), + # record whether it was capacity-bound. + if not defer_prefills: + self.prefill_capacity_bound = bool(self.waiting) + # Check if the scheduling constraints are satisfied. total_num_scheduled_tokens = sum(num_scheduled_tokens.values()) assert total_num_scheduled_tokens <= self.max_num_scheduled_tokens @@ -877,8 +1046,8 @@ def schedule(self) -> SchedulerOutput: # Construct the scheduler output. if self.use_v2_model_runner: - scheduled_new_reqs = scheduled_new_reqs + scheduled_resumed_reqs - scheduled_resumed_reqs = [] + scheduled_new_reqs.extend(scheduled_resumed_reqs) + scheduled_resumed_reqs.clear() new_reqs_data = [ NewRequestData.from_request( req, @@ -904,16 +1073,25 @@ def schedule(self) -> SchedulerOutput: req_to_new_blocks, ) - # Record the request ids that were scheduled in this step. - self.prev_step_scheduled_req_ids.clear() - self.prev_step_scheduled_req_ids.update(num_scheduled_tokens.keys()) + # Record the request ids that were scheduled in this step (MRV1-only). + if not self.use_v2_model_runner: + self.prev_step_scheduled_req_ids.clear() + self.prev_step_scheduled_req_ids.update(num_scheduled_tokens.keys()) + # Drain new attention block ids every step so the manager-side list + # does not grow unbounded; only kv-cache zeroing consumes them. + new_attn_block_ids = self.kv_cache_manager.take_new_block_ids() new_block_ids_to_zero = ( - (self.kv_cache_manager.take_new_block_ids() or None) - if self.needs_kv_cache_zeroing - else None + (new_attn_block_ids or None) if self.needs_kv_cache_zeroing else None ) + # Dynamic speculative decoding: compute optimal K + num_spec_tokens_to_schedule = self.num_spec_tokens + if self.dynamic_sd_lookup is not None and len(num_scheduled_tokens) > 0: + num_spec_tokens_to_schedule = self.dynamic_sd_lookup[ + len(num_scheduled_tokens) + ] + scheduler_output = SchedulerOutput( scheduled_new_reqs=new_reqs_data, scheduled_cached_reqs=cached_reqs_data, @@ -922,7 +1100,7 @@ def schedule(self) -> SchedulerOutput: scheduled_spec_decode_tokens=scheduled_spec_decode_tokens, scheduled_encoder_inputs=scheduled_encoder_inputs, num_common_prefix_blocks=num_common_prefix_blocks, - preempted_req_ids={req.request_id for req in preempted_reqs}, + preempted_req_ids=self.reset_preempted_req_ids, # finished_req_ids is an existing state in the scheduler, # instead of being newly scheduled in this step. # It contains the request IDs that are finished in between @@ -930,6 +1108,7 @@ def schedule(self) -> SchedulerOutput: finished_req_ids=self.finished_req_ids, free_encoder_mm_hashes=self.encoder_cache_manager.get_freed_mm_hashes(), new_block_ids_to_zero=new_block_ids_to_zero, + num_spec_tokens_to_schedule=num_spec_tokens_to_schedule, ) # NOTE(Kuntai): this function is designed for multiple purposes: @@ -947,6 +1126,11 @@ def schedule(self) -> SchedulerOutput: ) scheduler_output.ec_connector_metadata = ec_meta + # Advance the fence only for non-empty steps (those that actually + # write KV and have their output processed later in update_from_output). + if self.defer_block_free and total_num_scheduled_tokens > 0: + self.sched_step_seq += 1 + with record_function_or_nullcontext("schedule: update_after_schedule"): self._update_after_schedule(scheduler_output) return scheduler_output @@ -965,8 +1149,9 @@ def _preempt_request(self, request: Request, timestamp: float) -> None: assert request.status == RequestStatus.RUNNING, ( "Only running requests can be preempted" ) - self.kv_cache_manager.free(request) + self._free_request_blocks(request) self.encoder_cache_manager.free(request) + self._inflight_prefills.discard(request) request.status = RequestStatus.PREEMPTED request.num_computed_tokens = 0 if request.spec_token_ids: @@ -977,6 +1162,7 @@ def _preempt_request(self, request: Request, timestamp: float) -> None: # Put the request back to the waiting queue. self.waiting.prepend_request(request) + self.reset_preempted_req_ids.add(request.request_id) def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None: # Advance the number of computed tokens for the request AFTER @@ -992,12 +1178,19 @@ def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None: for req_id, num_scheduled_token in num_scheduled_tokens.items(): request = self.requests[req_id] request.num_computed_tokens += num_scheduled_token + request.num_in_flight_tokens += num_scheduled_token + if self.defer_block_free: + # Record the in-flight step, to fence deferred block freeing. + request.last_sched_seq = self.sched_step_seq request.is_prefill_chunk = request.num_computed_tokens < ( request.num_tokens + request.num_output_placeholders ) scheduler_output.has_structured_output_requests |= ( request.use_structured_output and not request.is_prefill_chunk ) + # Drop from the in-flight-prefill set once it's no longer prefilling. + if not request.is_prefill_chunk: + self._inflight_prefills.discard(request) # Snapshot block IDs for routed experts before forward starts. # A concurrent schedule() may preempt requests and free blocks @@ -1015,10 +1208,11 @@ def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None: } ) - # Clear the finished request IDs. - # NOTE: We shouldn't do self.finished_req_ids.clear() here because - # it will also affect the scheduler output. + # Clear the finished and preempted request IDs. + # NOTE: We shouldn't just clear() here because it will also affect + # the scheduler output. self.finished_req_ids = set() + self.reset_preempted_req_ids = set() def _update_request_as_session( self, session: Request, update: StreamingUpdate @@ -1099,12 +1293,11 @@ def _make_cached_request_data( req.num_computed_tokens : req.num_computed_tokens + num_tokens ] new_token_ids.append(token_ids) - scheduled_in_prev_step = req_id in self.prev_step_scheduled_req_ids if idx >= num_running_reqs: - assert not scheduled_in_prev_step resumed_req_ids.add(req_id) - if not scheduled_in_prev_step: - all_token_ids[req_id] = req.all_token_ids.copy() + if not self.use_v2_model_runner: # noqa: SIM102 + if req_id not in self.prev_step_scheduled_req_ids: + all_token_ids[req_id] = req.all_token_ids.copy() new_block_ids.append( req_to_new_blocks[req_id].get_block_ids(allow_none=True) ) @@ -1321,19 +1514,18 @@ def update_from_output( kv_connector_output = model_runner_output.kv_connector_output cudagraph_stats = model_runner_output.cudagraph_stats + # Every GPU write enqueued by this and earlier steps has completed, so it is + # safe to return deferred-free blocks to the pool. + if self.defer_block_free and scheduler_output.total_num_scheduled_tokens > 0: + self.processed_step_seq += 1 + self._drain_deferred_frees() + perf_stats: PerfStats | None = None if self.perf_metrics and self.perf_metrics.is_enabled(): perf_stats = self.perf_metrics.get_step_perf_stats_per_gpu(scheduler_output) outputs: dict[int, list[EngineCoreOutput]] = defaultdict(list) spec_decoding_stats: SpecDecodingStats | None = None - kv_connector_stats: KVConnectorStats | None = ( - kv_connector_output.kv_connector_stats if kv_connector_output else None - ) - if kv_connector_stats and self.connector: - kv_stats = self.connector.get_kv_connector_stats() - if kv_stats: - kv_connector_stats = kv_connector_stats.aggregate(kv_stats) failed_kv_load_req_ids = None if kv_connector_output and kv_connector_output.invalid_block_ids: @@ -1373,10 +1565,12 @@ def update_from_output( stopped_preempted_reqs: set[Request] = set() for req_id, num_tokens_scheduled in num_scheduled_tokens.items(): assert num_tokens_scheduled > 0 + request = self.requests.get(req_id) + if request is not None: + request.num_in_flight_tokens -= num_tokens_scheduled if failed_kv_load_req_ids and req_id in failed_kv_load_req_ids: # skip failed or rescheduled requests from KV load failure continue - request = self.requests.get(req_id) if request is None or request.is_finished(): # The request is already finished. This can happen if the # request is aborted while the model is executing it (e.g., @@ -1395,9 +1589,16 @@ def update_from_output( scheduled_spec_token_ids = ( scheduler_output.scheduled_spec_decode_tokens.get(req_id) ) - if scheduled_spec_token_ids and generated_token_ids: + # Skip a stale frame still pending discard (async_tokens_to_discard + # > 0): its pre-reset rejection count would underflow the counters. + if ( + scheduled_spec_token_ids + and (generated_token_ids or self.num_sampled_tokens_per_step == 0) + and request.async_tokens_to_discard == 0 + ): num_draft_tokens = len(scheduled_spec_token_ids) - num_accepted = len(generated_token_ids) - 1 + num_sampled = self.num_sampled_tokens_per_step + num_accepted = max(len(generated_token_ids) - num_sampled, 0) num_rejected = num_draft_tokens - num_accepted # num_computed_tokens represents the number of tokens # processed in the current step, considering scheduled @@ -1443,14 +1644,23 @@ def update_from_output( if new_token_ids and self.structured_output_manager.should_advance(request): struct_output_request = request.structured_output_request assert struct_output_request is not None - assert struct_output_request.grammar is not None - if not struct_output_request.grammar.accept_tokens( # type: ignore[union-attr] - req_id, new_token_ids + grammar = struct_output_request.grammar + assert grammar is not None + # new_token_ids can be a mixed block of reasoning content, then + # the reasoning end marker, then the start of the grammar content. + # Trim the reasoning content so the grammar only sees grammar content. + advance_token_ids = ( + self.structured_output_manager.trim_reasoning_for_advance( + request, new_token_ids + ) + ) + if advance_token_ids and not grammar.accept_tokens( + req_id, advance_token_ids ): logger.error( "Unexpected: grammar rejected tokens %s for request %s. " "Terminating request.", - new_token_ids, + advance_token_ids, req_id, ) request.status = RequestStatus.FINISHED_ERROR @@ -1577,6 +1787,23 @@ def update_from_output( if kv_connector_output: self._update_from_kv_xfer_finished(kv_connector_output) + # Worker-side KV connector stats from the model runner output. + kv_connector_stats: KVConnectorStats | None = ( + kv_connector_output.kv_connector_stats if kv_connector_output else None + ) + if self.connector: + # Scheduler-side KV connector stats collected after connector update. + scheduler_kv_connector_stats = self.connector.get_kv_connector_stats() + if ( + scheduler_kv_connector_stats is not None + and not scheduler_kv_connector_stats.is_empty() + ): + kv_connector_stats = ( + kv_connector_stats.aggregate(scheduler_kv_connector_stats) + if kv_connector_stats is not None + else scheduler_kv_connector_stats + ) + # collect KV cache events from KV cache manager events = self.kv_cache_manager.take_events() @@ -1699,6 +1926,11 @@ def _free_encoder_inputs(self, request: Request) -> None: if not cached_encoder_input_ids: return + # Defer the free by the drafter's look-ahead so an entry stays + # referenced until the drafter's +1 read has also passed it, mirroring + # the shift the encoder scheduling path applies. + spec_lookahead = 1 if self.use_eagle else 0 + # Here, we use list(set) to avoid modifying the set while iterating # over it. for input_id in list(cached_encoder_input_ids): @@ -1710,9 +1942,13 @@ def _free_encoder_inputs(self, request: Request) -> None: # we know we're done with the encoder input. Cross Attention # KVs have been calculated and cached already. self.encoder_cache_manager.free_encoder_input(request, input_id) - elif start_pos + num_tokens <= request.num_computed_tokens: - # The encoder output is already processed and stored - # in the decoder's KV cache. + elif ( + start_pos + num_tokens + spec_lookahead + <= request.num_computed_tokens - request.num_output_placeholders + ): + # Processed, stored in the decoder KV cache, and far enough past + # the placeholder range (plus the drafter's look-ahead) that no + # rejection or drafter gather can reference it. self.encoder_cache_manager.free_encoder_input(request, input_id) def update_draft_token_ids(self, draft_token_ids: DraftTokenIds) -> None: @@ -1871,6 +2107,7 @@ def _free_request( ) -> dict[str, Any] | None: assert request.is_finished() + self._inflight_prefills.discard(request) connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request) self.encoder_cache_manager.free(request) request_id = request.request_id @@ -1886,7 +2123,7 @@ def _free_request( def _free_blocks(self, request: Request): assert request.is_finished() - self.kv_cache_manager.free(request) + self._free_request_blocks(request) del self.requests[request.request_id] @property @@ -1896,6 +2133,35 @@ def pause_state(self) -> PauseState: def set_pause_state(self, pause_state: PauseState) -> None: self._pause_state = pause_state + def _free_request_blocks(self, request: Request): + """Free the request's KV blocks, deferring the return to the block + pool when an in-flight GPU step may still write them. + """ + if not self.defer_block_free or ( + # Last scheduled step already processed: no in-flight write remains + # (always the case for a normal finish), so free now. + request.last_sched_seq <= self.processed_step_seq + ): + self.kv_cache_manager.free(request) + return + blocks = self.kv_cache_manager.pop_blocks_for_free(request) + if blocks: + self.deferred_frees.append((self.sched_step_seq, blocks)) + + def _drain_deferred_frees(self): + """Return deferred blocks whose fence step has completed. + + Entries are appended with monotonically non-decreasing fences, so + stop at the first one that is still pending. + """ + while self.deferred_frees: + fence, _ = self.deferred_frees[0] + if fence > self.processed_step_seq: + break + _, blocks = self.deferred_frees.popleft() + # Free in reverse order so that the tail blocks are evicted first. + self.kv_cache_manager.block_pool.free_blocks(reversed(blocks)) + def get_num_unfinished_requests(self) -> int: if self._pause_state == PauseState.PAUSED_ALL: return 0 @@ -1920,6 +2186,19 @@ def has_finished_requests(self) -> bool: ) return len(self.requests) > num_in_queues + def has_requests(self) -> bool: + # Override the interface default to also keep the engine alive while a + # connector still has pending push work (e.g. push-mode WRITE transfers + # in flight after all "live" requests have finished). Without this hook + # the engine would quiesce before the connector can drain completions. + # TODO: replace with a more general mechanism for connectors to keep + # the scheduler alive. + return ( + self.has_unfinished_requests() + or self.has_finished_requests() + or (self.connector is not None and self.connector.has_pending_push_work()) + ) + def reset_prefix_cache( self, reset_running_requests: bool = False, reset_connector: bool = False ) -> bool: @@ -2058,13 +2337,17 @@ def make_spec_decoding_stats( return spec_decoding_stats def shutdown(self) -> None: + logger.debug_once("[shutdown] Scheduler: start") if self.kv_event_publisher: self.kv_event_publisher.shutdown() if self.connector is not None: self.connector.shutdown() + if self.ec_connector is not None: self.ec_connector.shutdown() + logger.debug_once("[shutdown] Scheduler: complete") + ######################################################################## # KV Connector Related Methods ######################################################################## @@ -2085,13 +2368,19 @@ def _connector_finished( return False, None # Free any out-of-window prefix blocks before we hand the block table to - # the connector. + # the connector, on the processed-token basis (see `allocate_slots`). self.kv_cache_manager.remove_skipped_blocks( request_id=request.request_id, - total_computed_tokens=request.num_computed_tokens, + processed_computed_tokens=max( + 0, request.num_computed_tokens - request.num_in_flight_tokens + ), + num_prompt_tokens=request.num_prompt_tokens, ) - block_ids = self.kv_cache_manager.get_block_ids(request.request_id) + block_ids = self.kv_cache_manager.get_block_ids_for_computed_tokens( + request_id=request.request_id, + num_computed_tokens=request.num_computed_tokens, + ) if not isinstance(self.connector, SupportsHMA): # NOTE(Kuntai): We should deprecate this code path after we enforce @@ -2103,6 +2392,26 @@ def _connector_finished( return self.connector.request_finished_all_groups(request, block_ids) + def _request_remaining_blocks(self, request: Request) -> int: + """Blocks `request` still needs to allocate to hold its full sequence.""" + full_num_tokens = min(request.num_tokens, self.max_model_len) + return self.kv_cache_manager.coordinator.get_num_blocks_to_allocate( + request_id=request.request_id, + num_tokens=full_num_tokens, + new_computed_blocks=self.kv_cache_manager.empty_kv_cache_blocks.blocks, + num_encoder_tokens=0, + total_computed_tokens=request.num_computed_tokens, + num_tokens_main_model=full_num_tokens, + apply_admission_cap=True, + ) + + def _inflight_prefill_reserved_blocks(self) -> int: + """Num blocks in-flight prefills still need to finish (their reservation).""" + + return sum( + self._request_remaining_blocks(req) for req in self._inflight_prefills + ) + def _update_waiting_for_remote_kv(self, request: Request) -> None: """ KV Connector: update request state after async recv is finished. diff --git a/vllm/v1/core/single_type_kv_cache_manager.py b/vllm/v1/core/single_type_kv_cache_manager.py index 281b79639db3..87c3f8feb725 100644 --- a/vllm/v1/core/single_type_kv_cache_manager.py +++ b/vllm/v1/core/single_type_kv_cache_manager.py @@ -20,6 +20,7 @@ KVCacheSpec, MambaSpec, MLAAttentionSpec, + RSWASpec, SinkFullAttentionSpec, SlidingWindowMLASpec, SlidingWindowSpec, @@ -44,6 +45,7 @@ def __init__( scheduler_block_size: int, dcp_world_size: int = 1, pcp_world_size: int = 1, + needs_kv_cache_zeroing: bool = False, max_admission_blocks_per_request: int | None = None, ) -> None: """ @@ -54,6 +56,8 @@ def __init__( kv_cache_group_id: The id of the kv cache group of this manager. scheduler_block_size: The scheduling granularity (LCM of all group block sizes); a multiple of this manager's ``block_size``. + needs_kv_cache_zeroing: Whether worker-side KV cache zeroing needs + newly allocated block IDs from this manager. max_admission_blocks_per_request: Recycling-aware per-request block cap used by `get_num_blocks_to_allocate`. Only set for spec types that recycle blocks across chunks (SWA, @@ -62,6 +66,7 @@ def __init__( block until the request finishes. """ self.scheduler_block_size = scheduler_block_size + # The block size for this manager; used for actual block allocation. self.block_size = kv_cache_spec.block_size self.dcp_world_size = dcp_world_size self.pcp_world_size = pcp_world_size @@ -71,6 +76,14 @@ def __init__( self.block_pool = block_pool self.enable_caching = enable_caching self._max_admission_blocks_per_request = max_admission_blocks_per_request + # Record newly allocated block ids only when worker-side zeroing will + # consume them and this manager holds a spec type that gets zeroed. + self._record_new_block_ids = needs_kv_cache_zeroing and type(kv_cache_spec) in ( + FullAttentionSpec, + TQFullAttentionSpec, + MLAAttentionSpec, + HiddenStateCacheSpec, + ) self.new_block_ids: list[int] = [] # Mapping from request ID to blocks to track the blocks allocated @@ -178,7 +191,7 @@ def get_num_blocks_to_allocate( ) return num_new_blocks + num_evictable_blocks - def allocate_new_computed_blocks( + def add_local_computed_blocks( self, request_id: str, new_computed_blocks: Sequence[KVCacheBlock], @@ -186,12 +199,11 @@ def allocate_new_computed_blocks( num_external_computed_tokens: int, ) -> None: """ - Add the new computed blocks to the request. This involves three steps: - 1. Touch the computed blocks to make sure they won't be evicted. - 1.5. (Optional) For sliding window, skip blocks are padded with null blocks. + Add the locally cached (prefix-hit) blocks to the request: + 1. Touch the computed blocks (paired with adding them to `req_blocks`) + so their ref_cnt exactly tracks the referencing requests. + 1.5. (Optional) For sliding window, skipped blocks are padded with nulls. 2. Add the remaining computed blocks. - 3. (Optional) For KV connectors, allocate new blocks for external computed - tokens (if any). Args: request_id: The request ID. @@ -200,14 +212,8 @@ def allocate_new_computed_blocks( num_local_computed_tokens: The number of local computed tokens. num_external_computed_tokens: The number of external computed tokens. """ - - if request_id in self.num_cached_block: - # Fast-path: a running request won't have any new prefix-cache hits. - # It should not have any new computed blocks. - assert len(new_computed_blocks) == 0 - return - - # A new request. + # The coordinator only calls this for first-time allocations (running + # requests are short-circuited there), so the request has no blocks yet. req_blocks = self.req_to_blocks[request_id] assert len(req_blocks) == 0 num_total_computed_tokens = ( @@ -219,11 +225,6 @@ def allocate_new_computed_blocks( # It is possible that all new computed blocks are skipped when # num_skipped_blocks > len(new_computed_blocks). new_computed_blocks = new_computed_blocks[num_skipped_blocks:] - # Some external computed tokens may be skipped too. - num_external_computed_tokens = min( - num_total_computed_tokens - num_skipped_tokens, - num_external_computed_tokens, - ) # Touch the computed blocks to make sure they won't be evicted. if self.enable_caching: @@ -242,18 +243,44 @@ def allocate_new_computed_blocks( # have a block_hash set. self.num_cached_block[request_id] = len(req_blocks) - if num_external_computed_tokens > 0: - # Allocate new blocks for external computed tokens. - allocated_blocks = self.block_pool.get_new_blocks( - cdiv(num_total_computed_tokens, self.block_size) - len(req_blocks) + def allocate_external_computed_blocks( + self, + request_id: str, + num_local_computed_tokens: int, + num_external_computed_tokens: int, + ) -> None: + """ + Allocate new blocks for external (KV-connector) computed tokens. + + Must run only after every group's local blocks have been touched via + `add_local_computed_blocks`, so this group's `get_new_blocks` cannot + evict another group's cache-hit blocks (issue #33775). + + Args: + request_id: The request ID. + num_local_computed_tokens: The number of local computed tokens. + num_external_computed_tokens: The number of external computed tokens. + """ + num_total_computed_tokens = ( + num_local_computed_tokens + num_external_computed_tokens + ) + num_skipped_tokens = self.get_num_skipped_tokens(num_total_computed_tokens) + if num_skipped_tokens > 0: + # Some external computed tokens may be skipped too. + num_external_computed_tokens = min( + num_total_computed_tokens - num_skipped_tokens, + num_external_computed_tokens, ) - req_blocks.extend(allocated_blocks) - if type(self.kv_cache_spec) in ( - FullAttentionSpec, - TQFullAttentionSpec, - MLAAttentionSpec, - ): - self.new_block_ids.extend(b.block_id for b in allocated_blocks) + if num_external_computed_tokens <= 0: + return + + req_blocks = self.req_to_blocks[request_id] + allocated_blocks = self.block_pool.get_new_blocks( + cdiv(num_total_computed_tokens, self.block_size) - len(req_blocks) + ) + req_blocks.extend(allocated_blocks) + if self._record_new_block_ids: + self.new_block_ids.extend(b.block_id for b in allocated_blocks) def allocate_new_blocks( self, request_id: str, num_tokens: int, num_tokens_main_model: int @@ -280,11 +307,7 @@ def allocate_new_blocks( else: new_blocks = self.block_pool.get_new_blocks(num_new_blocks) req_blocks.extend(new_blocks) - if type(self.kv_cache_spec) in ( - FullAttentionSpec, - TQFullAttentionSpec, - MLAAttentionSpec, - ): + if self._record_new_block_ids: self.new_block_ids.extend(b.block_id for b in new_blocks) return new_blocks @@ -298,7 +321,7 @@ def cache_blocks( self, request: Request, num_tokens: int, - alignment_tokens: int | None = None, + retention_interval: int | None = None, ) -> None: """ Cache the blocks for the request. @@ -307,12 +330,10 @@ def cache_blocks( request: The request. num_tokens: The total number of tokens that need to be cached (including tokens that are already cached). - alignment_tokens: The cache-hit alignment (in tokens) used by the - coordinator's ``find_longest_cache_hit``. When greater than - this group's ``block_size``, managers whose hit logic only - returns a subset of blocks per alignment-aligned segment - (SWA) skip the rest since they can never participate in a - future cache hit. + retention_interval: Sparse local-checkpoint granularity. ``None`` + keeps dense checkpointing; ``0`` keeps only the latest replay + boundary; a positive multiple of ``scheduler_block_size`` keeps + a tail once per that-sized segment. Only SWA acts on it. """ num_cached_blocks = self.num_cached_block.get(request.request_id, 0) num_full_blocks = num_tokens // self.block_size @@ -320,17 +341,15 @@ def cache_blocks( if num_cached_blocks >= num_full_blocks: return - # Fast path: when the coordinator imposes no alignment constraint - if alignment_tokens is None or alignment_tokens <= self.block_size: - block_mask = None - else: - block_mask = self.reachable_block_mask( - num_cached_blocks, - num_full_blocks, - alignment_tokens, - self.kv_cache_spec, - self.use_eagle, - ) + block_mask = self.reachable_block_mask( + start_block=num_cached_blocks, + end_block=num_full_blocks, + alignment_tokens=self.scheduler_block_size, + kv_cache_spec=self.kv_cache_spec, + use_eagle=self.use_eagle, + retention_interval=retention_interval, + num_prompt_tokens=request.num_prompt_tokens, + ) self.block_pool.cache_full_blocks( request=request, blocks=self.req_to_blocks[request.request_id], @@ -347,10 +366,12 @@ def cache_blocks( def reachable_block_mask( cls, start_block: int, - num_blocks: int, - alignment_tokens: int, + end_block: int, + alignment_tokens: int | None, kv_cache_spec: KVCacheSpec, use_eagle: bool, + retention_interval: int | None = None, + num_prompt_tokens: int | None = None, ) -> list[bool] | None: """Per-block mask for ``cache_full_blocks``. ``None`` means cache every (non-null) block — the default for full attention. @@ -361,22 +382,33 @@ def reachable_block_mask( """ return None - def free(self, request_id: str) -> None: + def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]: """ - Free the blocks for the request. + Pop the request's bookkeeping and return its blocks without yet + returning them to the block pool. The caller is responsible for + eventually passing the returned blocks to `block_pool.free_blocks`, + freeing them in reverse order (so that tail blocks are evicted first). Args: request_id: The request ID. + + Returns: + The request's blocks in allocation order. """ # Default to [] in case a request is freed (aborted) before alloc. req_blocks = self.req_to_blocks.pop(request_id, []) + self.num_cached_block.pop(request_id, None) + return req_blocks - # Free blocks in reverse order so that the tail blocks are - # freed first. - ordered_blocks = reversed(req_blocks) + def free(self, request_id: str) -> None: + """ + Free the blocks for the request. - self.block_pool.free_blocks(ordered_blocks) - self.num_cached_block.pop(request_id, None) + Args: + request_id: The request ID. + """ + # Free blocks in reverse order so that the tail blocks are freed first. + self.block_pool.free_blocks(reversed(self.pop_blocks_for_free(request_id))) @abstractmethod def get_num_common_prefix_blocks(self, running_request_id: str) -> int: @@ -446,8 +478,38 @@ def find_longest_cache_hit( raise NotImplementedError + def _remove_blocks_in_range( + self, + request_id: str, + first_block: int, + last_block: int, + ) -> None: + """Free blocks in ``[first_block, last_block)`` and replace with null_block. + + Iterates backward so newly-evictable tail blocks are reached even after + earlier blocks in the range were nulled in a prior call. + """ + if request_id not in self.req_to_blocks: + return + if first_block >= last_block: + return + blocks = self.req_to_blocks[request_id] + last_block = min(last_block, len(blocks)) + + freed: list[KVCacheBlock] = [] + for i in range(last_block - 1, first_block - 1, -1): + if blocks[i] == self._null_block: + break + freed.append(blocks[i]) + blocks[i] = self._null_block + if freed: + self.block_pool.free_blocks(freed) + def remove_skipped_blocks( - self, request_id: str, total_computed_tokens: int + self, + request_id: str, + processed_computed_tokens: int, + num_prompt_tokens: int | None = None, ) -> None: """ Remove and free the blocks that are no longer needed for attention computation. @@ -458,11 +520,15 @@ def remove_skipped_blocks( Args: request_id: The request ID. - total_computed_tokens: The total number of computed tokens, including - local computed tokens and external computed tokens. + processed_computed_tokens: Computed-token prefix length covering + fully processed and committed tokens only (safe to free). + num_prompt_tokens: Optional prompt length for attention types (e.g. + R-SWA) that evict a middle gap rather than a head prefix. Ignored + by the default implementation. """ + del num_prompt_tokens # Remove the blocks that will be skipped during attention computation. - num_skipped_tokens = self.get_num_skipped_tokens(total_computed_tokens) + num_skipped_tokens = self.get_num_skipped_tokens(processed_computed_tokens) if num_skipped_tokens <= 0: # This indicates that ALL tokens are inside attention window. # Thus we do not need to free any blocks outside attention window. @@ -476,18 +542,7 @@ def remove_skipped_blocks( # range), so we must cap to the number of blocks that currently exist for # this request. num_skipped_blocks = min(num_skipped_blocks, len(blocks)) - removed_blocks: list[KVCacheBlock] = [] - # Because the block starts from index 0, the num_skipped_block-th block - # corresponds to index num_skipped_blocks - 1. - for i in range(num_skipped_blocks - 1, -1, -1): - if blocks[i] == self._null_block: - # If the block is already a null block, the blocks before it - # should also have been set to null blocks by the previous calls - # to this function. - break - removed_blocks.append(blocks[i]) - blocks[i] = self._null_block - self.block_pool.free_blocks(removed_blocks) + self._remove_blocks_in_range(request_id, 0, num_skipped_blocks) def get_num_skipped_tokens(self, num_computed_tokens: int) -> int: """ @@ -568,6 +623,52 @@ def get_num_common_prefix_blocks(self, running_request_id: str) -> int: return num_common_blocks +class RSWAManager(FullAttentionManager): + """KV cache manager for Reference Sliding Window Attention (R-SWA). + + When ``num_prompt_tokens`` is supplied to ``remove_skipped_blocks``, frees + gap blocks between the prefill tail and the current decode window. This + bounds per-request KV memory at O(prefix_len + rswa_window) instead of + growing linearly with decode length. + """ + + def __init__(self, kv_cache_spec: RSWASpec, **kwargs) -> None: + super().__init__(kv_cache_spec, **kwargs) + self.rswa_window: int = kv_cache_spec.rswa_window + + def remove_skipped_blocks( + self, + request_id: str, + processed_computed_tokens: int, + num_prompt_tokens: int | None = None, + ) -> None: + """Free gap blocks that are no longer needed for attention. + + Gap = blocks entirely within + [ceil(prefix_len / block_size) * block_size, + max(prefix_len, processed_computed_tokens - rswa_window)) + + Freed blocks are replaced with null_block in req_to_blocks so the + block_table passed to FA4 is valid (null_block KV is all-zero; + rswa_mask_mod marks gap positions as non-visible so FA4 skips them). + """ + if num_prompt_tokens is None: + super().remove_skipped_blocks( + request_id, processed_computed_tokens, num_prompt_tokens + ) + return + + bs = self.block_size + # First block fully after the prefill boundary. + first_gap_block = cdiv(num_prompt_tokens, bs) + # Decode window start position; blocks before this are evictable. + window_start = max( + num_prompt_tokens, processed_computed_tokens - self.rswa_window + ) + last_gap_block = window_start // bs # exclusive upper bound + self._remove_blocks_in_range(request_id, first_gap_block, last_gap_block) + + class SlidingWindowManager(SingleTypeKVCacheManager): def __init__(self, kv_cache_spec: SlidingWindowSpec, **kwargs) -> None: super().__init__(kv_cache_spec, **kwargs) @@ -677,30 +778,65 @@ def find_longest_cache_hit( def reachable_block_mask( cls, start_block: int, - num_blocks: int, - alignment_tokens: int, + end_block: int, + alignment_tokens: int | None, kv_cache_spec: KVCacheSpec, use_eagle: bool, + retention_interval: int | None = None, + num_prompt_tokens: int | None = None, ) -> list[bool] | None: - assert alignment_tokens > kv_cache_spec.block_size assert isinstance(kv_cache_spec, SlidingWindowSpec) - per_segment = alignment_tokens // kv_cache_spec.block_size + if alignment_tokens is None: + # Fast path: when the coordinator imposes no alignment constraint. + return None + assert alignment_tokens % kv_cache_spec.block_size == 0 + + block_size = kv_cache_spec.block_size + # Contiguous blocks a hit needs at a boundary (incl. the EAGLE peek). need = cls._contiguous_blocks_for_hit( window_size=kv_cache_spec.sliding_window, - block_size=kv_cache_spec.block_size, + block_size=block_size, use_eagle=use_eagle, ) - if need >= per_segment: - return None # The matched run's right edge sits on the aligned boundary block when # EAGLE peeks one block past it (shift=1), otherwise on the last block - # before the boundary (shift=0). A block is reachable iff it falls in - # the ``need``-wide run ending at some boundary's right edge. + # before the boundary (shift=0). shift = 1 if use_eagle else 0 - return [ - i >= shift and (i - shift) % per_segment >= per_segment - need - for i in range(start_block, num_blocks) - ] + + mask = [False] * (end_block - start_block) + + # (1) Segment-boundary tails. ``retention_interval``: + # None -> dense (a tail at every ``alignment_tokens`` boundary); + # 0 -> no dense tails (only the replay boundary below); + # >0 -> a tail once per ``retention_interval``-sized segment. + segment_tokens = ( + alignment_tokens + if retention_interval is None + else (None if retention_interval == 0 else retention_interval) + ) + if segment_tokens is not None: + per_segment = segment_tokens // block_size + if need >= per_segment: + # Every block is reachable; cache them all. + return None + for i in range(start_block, end_block): + if i >= shift and (i - shift) % per_segment >= per_segment - need: + mask[i - start_block] = True + + # (2) Replay-boundary tail. ``get_computed_blocks`` caps hits at + # ``num_prompt - 1`` (to recompute the last token's logits), so an exact + # prompt replay can only land on the latest *fine*-aligned boundary. + # Sparse retention would otherwise skip it, so keep its tail explicitly. + if retention_interval is not None and num_prompt_tokens is not None: + latest = (num_prompt_tokens - 1) // alignment_tokens * alignment_tokens + prompt_end_block = latest // block_size + shift + for i in range( + max(start_block, prompt_end_block - need), + min(end_block, prompt_end_block), + ): + mask[i - start_block] = True + + return mask def get_num_skipped_tokens(self, num_computed_tokens: int) -> int: """ @@ -953,17 +1089,71 @@ def find_longest_cache_hit( return computed_blocks - def remove_skipped_blocks(self, request_id: str, num_computed_tokens: int) -> None: - assert isinstance(self.kv_cache_spec, MambaSpec) + @classmethod + def reachable_block_mask( + cls, + start_block: int, + end_block: int, + alignment_tokens: int | None, + kv_cache_spec: KVCacheSpec, + use_eagle: bool, + retention_interval: int | None = None, + num_prompt_tokens: int | None = None, + ) -> list[bool] | None: + """Sparse Mamba state-snapshot retention. - # NOTE (tdoublep) with async scheduling, the num_computed_tokens can contain - # draft tokens from the previous step that may or may not be rejected later. - # This can make us think we are further ahead in the sequence than we actually - # are, so let's assume that all tokens are rejected so we don't free blocks - # that we might actually need. - num_computed_tokens = max(0, num_computed_tokens - self.num_speculative_blocks) + ``retention_interval``: + + ``None`` -> dense (cache every block; default, unchanged behavior) + ``0`` -> keep only the latest replay boundary + ``> 0`` -> keep one state per ``retention_interval``-sized segment + """ + if retention_interval is None or alignment_tokens is None: + # Dense caching (default) or no alignment constraint imposed. + return None + assert isinstance(kv_cache_spec, MambaSpec) + block_size = kv_cache_spec.block_size + mask = [False] * (end_block - start_block) + + # (1) Segment-boundary states. A Mamba hit needs exactly the single + # state block ending on the boundary (no window, and draft models have + # no mamba layers, so no eagle shift). Block ``i`` ends at token + # ``(i + 1) * block_size``. + segment_tokens = None if retention_interval == 0 else retention_interval + if segment_tokens is not None: + per_segment = segment_tokens // block_size + if per_segment <= 1: + # Interval at/below the block size: every block is a boundary. + return None + first_boundary = ( + start_block + per_segment + ) // per_segment * per_segment - 1 + for i in range(first_boundary - start_block, len(mask), per_segment): + mask[i] = True + + # (2) Replay boundary. ``get_computed_blocks`` caps hits at + # ``num_prompt - 1``, so an exact prompt replay lands on the latest + # fine-aligned boundary. Sparse retention would otherwise skip its + # state, so keep it explicitly. + if num_prompt_tokens is not None: + latest = (num_prompt_tokens - 1) // alignment_tokens * alignment_tokens + boundary_block = latest // block_size - 1 + if start_block <= boundary_block < end_block: + mask[boundary_block - start_block] = True + + return mask - super().remove_skipped_blocks(request_id, num_computed_tokens) + def remove_skipped_blocks( + self, + request_id: str, + processed_computed_tokens: int, + num_prompt_tokens: int | None = None, + ) -> None: + assert isinstance(self.kv_cache_spec, MambaSpec) + + super().remove_skipped_blocks( + request_id, processed_computed_tokens, num_prompt_tokens + ) if self.mamba_cache_mode == "align": # `last_state_block_idx` refers to the block index allocated two steps ago. # The block allocated in the previous step is used to copy Mamba states @@ -976,7 +1166,7 @@ def remove_skipped_blocks(self, request_id: str, num_computed_tokens: int) -> No if ( last_state_block_idx is not None and last_state_block_idx - < cdiv(num_computed_tokens, self.block_size) - 1 + < cdiv(processed_computed_tokens, self.block_size) - 1 ): blocks = self.req_to_blocks[request_id] if blocks[last_state_block_idx] != self._null_block: @@ -1081,13 +1271,11 @@ def allocate_new_blocks( num_required_blocks = ( cdiv(num_tokens, self.block_size) + self.num_speculative_blocks ) - if num_required_blocks == len(req_blocks): + # `num_required_blocks` might be less than `len(req_blocks)` if blocks are + # over-allocated at last round. + if num_required_blocks <= len(req_blocks): return [] else: - assert num_required_blocks > len(req_blocks), ( - "num_required_blocks " - f"{num_required_blocks} < len(req_blocks) {len(req_blocks)}" - ) prev_block_len = len(req_blocks) blocks_allocated = request_id in self._allocated_block_reqs # Record the last state block @@ -1134,11 +1322,11 @@ def allocate_new_blocks( self._allocated_block_reqs.add(request_id) return req_blocks[prev_block_len:] - def free(self, request_id: str) -> None: + def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]: if self.mamba_cache_mode == "align": self._allocated_block_reqs.discard(request_id) self.last_state_block_idx.pop(request_id, None) - super().free(request_id) + return super().pop_blocks_for_free(request_id) def get_num_skipped_tokens(self, num_computed_tokens: int) -> int: """ @@ -1152,18 +1340,21 @@ def cache_blocks( self, request: Request, num_tokens: int, - alignment_tokens: int | None = None, + retention_interval: int | None = None, ) -> None: num_cached_blocks_before = self.num_cached_block.get(request.request_id, 0) - super().cache_blocks(request, num_tokens, alignment_tokens=alignment_tokens) + super().cache_blocks(request, num_tokens, retention_interval=retention_interval) num_cached_blocks_after = self.num_cached_block.get(request.request_id, 0) if num_cached_blocks_after > num_cached_blocks_before: for block in self.req_to_blocks[request.request_id][ num_cached_blocks_before:num_cached_blocks_after ]: - if block.is_null: + # Skip null blocks (align-mode skipped states) and blocks that + # were not cached this step — with sparse retention + # (reachable_block_mask) the intermediate state snapshots carry + # no hash and must not be recorded as cached-this-step. + if block.is_null or block.block_hash is None: continue - assert block.block_hash is not None self.cached_blocks_this_step.add(block.block_hash) def new_step_starts(self) -> None: @@ -1173,7 +1364,7 @@ def new_step_starts(self) -> None: class CrossAttentionManager(SingleTypeKVCacheManager): """Manager for cross-attention KV cache in encoder-decoder models.""" - def allocate_new_computed_blocks( + def add_local_computed_blocks( self, request_id: str, new_computed_blocks: Sequence[KVCacheBlock], @@ -1184,11 +1375,20 @@ def allocate_new_computed_blocks( # requests, so `new_computed_blocks` should always be empty. assert len(new_computed_blocks) == 0 + def allocate_external_computed_blocks( + self, + request_id: str, + num_local_computed_tokens: int, + num_external_computed_tokens: int, + ) -> None: + # Cross-attention does not use prefix caching / external KV loads. + return + def cache_blocks( self, request: Request, num_tokens: int, - alignment_tokens: int | None = None, + retention_interval: int | None = None, ) -> None: # We do not cache blocks for cross-attention to be shared between # requests, so this method is not relevant. @@ -1250,7 +1450,7 @@ def __init__( def get_manager_for_kv_cache_spec( kv_cache_spec: KVCacheSpec, - max_num_batched_tokens: int, + max_in_flight_tokens: int, max_model_len: int, **kwargs, ) -> SingleTypeKVCacheManager: @@ -1263,7 +1463,8 @@ def get_manager_for_kv_cache_spec( Args: kv_cache_spec: The KVCacheSpec instance - max_num_batched_tokens: The maximum number of tokens in a batch + max_in_flight_tokens: The max tokens scheduled but not yet settled + (one batch per concurrent step); see `VllmConfig.max_in_flight_tokens` max_model_len: The maximum context length the model could serve Returns: An instance of the appropriate SingleTypeKVCacheManager subclass @@ -1272,13 +1473,19 @@ def get_manager_for_kv_cache_spec( assert manager_class is not None, ( f"No manager registered for KVCacheSpec {type(kv_cache_spec)}" ) - # SlidingWindow / ChunkedLocalAttention managers recycle blocks across - # chunks; the runtime admission cap must match the recycling-aware bound - # the startup pool sizer uses (single source of truth: the spec method). - if isinstance(kv_cache_spec, (SlidingWindowSpec, ChunkedLocalAttentionSpec)): + # SlidingWindow / ChunkedLocalAttention managers recycle blocks; + # the runtime admission cap must match the recycling-aware bound the + # startup pool sizer uses (single source of truth: the spec method). + # R-SWA also recycles gap blocks but peak physical KV still fits the + # full-attention bound (prefix + window <= max_model_len), so it inherits + # FullAttentionSpec sizing without a separate admission cap. + if isinstance( + kv_cache_spec, + (SlidingWindowSpec, ChunkedLocalAttentionSpec), + ): kwargs["max_admission_blocks_per_request"] = ( kv_cache_spec.max_admission_blocks_per_request( - max_num_batched_tokens=max_num_batched_tokens, + max_in_flight_tokens=max_in_flight_tokens, max_model_len=max_model_len, ) ) @@ -1328,6 +1535,9 @@ def register_all_kvcache_specs(vllm_config): KVCacheSpecRegistry.register( MLAAttentionSpec, FullAttentionManager, uniform_type_base_spec=FullAttentionSpec ) + KVCacheSpecRegistry.register( + RSWASpec, RSWAManager, uniform_type_base_spec=FullAttentionSpec + ) # NOTE(Mengqing): HiddenStateCacheSpec won't take part in # grouping, thus the uniform_type_base_spec is just a # placeholder. diff --git a/vllm/v1/cudagraph_dispatcher.py b/vllm/v1/cudagraph_dispatcher.py index cf0c1d417728..6a48b6282d43 100644 --- a/vllm/v1/cudagraph_dispatcher.py +++ b/vllm/v1/cudagraph_dispatcher.py @@ -34,11 +34,7 @@ class CudagraphDispatcher: def __init__(self, vllm_config: VllmConfig): self.vllm_config = vllm_config self.compilation_config = vllm_config.compilation_config - self.uniform_decode_query_len = ( - 1 - if not self.vllm_config.speculative_config - else 1 + self.vllm_config.speculative_config.num_speculative_tokens - ) + self.uniform_decode_query_len = 1 + self.vllm_config.num_speculative_tokens # Dict to store valid cudagraph dispatching keys. self.cudagraph_keys: dict[CUDAGraphMode, set[BatchDescriptor]] = { diff --git a/vllm/v1/engine/__init__.py b/vllm/v1/engine/__init__.py index 848f530ce334..38ca8dc6da4d 100644 --- a/vllm/v1/engine/__init__.py +++ b/vllm/v1/engine/__init__.py @@ -78,6 +78,11 @@ class EngineCoreReadyResponse: dp_stats_address: str | None dtype: str vllm_version: str + world_size: int + data_parallel_size: int + # KV cache capacity (None for encoder-only/attention-free models). + kv_cache_size_tokens: int | None = None + kv_cache_max_concurrency: float | None = None class EngineCoreRequest( @@ -151,7 +156,7 @@ class EngineCoreEventType(enum.IntEnum): class EngineCoreEvent(msgspec.Struct): """A timestamped engine core event associated with a request. - The timestamp is a monotonic timestamps and is used for by the engine + The timestamp is a monotonic timestamp and is used by the engine frontend to calculate intervals between engine core events. These timestamps should not be compared with timestamps from other processes. """ diff --git a/vllm/v1/engine/async_llm.py b/vllm/v1/engine/async_llm.py index 419e15163a9f..61f02092bd12 100644 --- a/vllm/v1/engine/async_llm.py +++ b/vllm/v1/engine/async_llm.py @@ -1080,12 +1080,9 @@ async def init_weight_transfer_engine( "init_weight_transfer_engine", kwargs={"init_info": init_info_dict} ) - async def start_weight_update(self, is_checkpoint_format: bool = True) -> None: + async def start_weight_update(self) -> None: """Start a new weight update.""" - await self.collective_rpc( - "start_weight_update", - kwargs={"is_checkpoint_format": is_checkpoint_format}, - ) + await self.collective_rpc("start_weight_update") async def update_weights(self, request: WeightTransferUpdateRequest) -> None: """ diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py index b12aa9d0505d..f97f697dedca 100644 --- a/vllm/v1/engine/core.py +++ b/vllm/v1/engine/core.py @@ -45,6 +45,7 @@ from vllm.v1.core.kv_cache_utils import ( BlockHash, generate_scheduler_kv_cache_config, + get_kv_cache_capacity, get_kv_cache_configs, get_request_block_hasher, init_none_hash, @@ -73,7 +74,7 @@ EngineHandshakeMetadata, EngineZmqAddresses, SignalCallback, - get_device_indices, + get_physical_gpu_ids_for_local_dp_rank, ) from vllm.v1.executor import Executor from vllm.v1.kv_cache_interface import KVCacheConfig, get_kv_cache_spec_kind @@ -156,6 +157,9 @@ def __init__( hash_block_size=hash_block_size, ) self.use_spec_decode = vllm_config.speculative_config is not None + self.check_for_draft_tokens = ( + self.use_spec_decode or vllm_config.model_config.is_diffusion + ) if self.scheduler.connector is not None: # type: ignore self.model_executor.init_kv_output_aggregator(self.scheduler.connector) # type: ignore @@ -177,13 +181,13 @@ def __init__( if xfer_handshake_metadata: # xfer_handshake_metadata is list of dicts from workers - # Each dict already has structure {tp_rank: metadata} + # Each dict already has structure {(pp_rank, tp_rank): metadata} # Merge all worker dicts into a single dict - content: dict[int, Any] = {} + content: dict[tuple[int, int], Any] = {} for worker_dict in xfer_handshake_metadata: if worker_dict is not None: content.update(worker_dict) - kv_connector.set_xfer_handshake_metadata(content) + kv_connector.set_xfer_handshake_metadata_pp_aware(content) # Setup batch queue for pipeline parallelism. # Batch queue for scheduled batches. This enables us to asynchronously @@ -242,6 +246,28 @@ def _initialize_kv_caches(self, vllm_config: VllmConfig) -> KVCacheConfig: # Get all kv cache needed by the model kv_cache_specs = self.model_executor.get_kv_cache_specs() + # Some layers (e.g. Prefix LM attention) run non-causally and tag their + # KV cache spec with ``non_causal=True``. The specs are collected here in + # the engine-core process (the same process that builds the scheduler), + # so this is the multiproc-safe place to translate that layer-level + # signal into a scheduling policy: chunked prefill and prefix caching + # both assume causal attention and would corrupt non-causal prefill. + if any( + getattr(spec, "non_causal", False) + for worker_specs in kv_cache_specs + for spec in worker_specs.values() + ): + if vllm_config.scheduler_config.enable_chunked_prefill: + logger.info( + "Disabling chunked prefill: model has non-causal attention layers." + ) + vllm_config.scheduler_config.enable_chunked_prefill = False + if vllm_config.cache_config.enable_prefix_caching: + logger.info( + "Disabling prefix caching: model has non-causal attention layers." + ) + vllm_config.cache_config.enable_prefix_caching = False + has_kv_cache = any(kv_cache_spec for kv_cache_spec in kv_cache_specs) if has_kv_cache: if envs.VLLM_ELASTIC_EP_SCALE_UP_LAUNCH: @@ -283,6 +309,11 @@ def _initialize_kv_caches(self, vllm_config: VllmConfig) -> KVCacheConfig: vllm_config.cache_config.block_size = min( g.kv_cache_spec.block_size for g in kv_cache_groups ) + num_tokens, max_concurrency = get_kv_cache_capacity( + vllm_config, scheduler_kv_cache_config + ) + vllm_config.cache_config.kv_cache_size_tokens = num_tokens + vllm_config.cache_config.kv_cache_max_concurrency = max_concurrency vllm_config.validate_block_size() @@ -440,6 +471,11 @@ def log_iteration_details(self, scheduler_output: SchedulerOutput | None): ) self._iteration_index += 1 + def _should_throttle_prefills(self) -> bool: + """Whether to defer new prefills this step (DP prefill balancing). + Overridden by the DP engine core; never throttles otherwise.""" + return False + def step(self) -> tuple[dict[int, EngineCoreOutputs], bool]: """Schedule, execute, and make output. @@ -451,7 +487,7 @@ def step(self) -> tuple[dict[int, EngineCoreOutputs], bool]: # or finished and not yet removed from the batch. if not self.scheduler.has_requests(): return {}, False - scheduler_output = self.scheduler.schedule() + scheduler_output = self.scheduler.schedule(self._should_throttle_prefills()) future = self.model_executor.execute_model(scheduler_output, non_block=True) grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output) with ( @@ -475,8 +511,7 @@ def post_step(self, model_executed: bool) -> None: # When using async scheduling we can't get draft token ids in advance, # so we update draft token ids in the worker process and don't # need to update draft token ids here. - if not self.async_scheduling and self.use_spec_decode and model_executed: - # Take the draft token ids. + if self.check_for_draft_tokens and not self.async_scheduling and model_executed: draft_token_ids = self.model_executor.take_draft_token_ids() if draft_token_ids is not None: self.scheduler.update_draft_token_ids(draft_token_ids) @@ -509,7 +544,7 @@ def step_with_batch_queue( model_executed = False deferred_scheduler_output = None if self.scheduler.has_requests(): - scheduler_output = self.scheduler.schedule() + scheduler_output = self.scheduler.schedule(self._should_throttle_prefills()) with self.log_error_detail(scheduler_output): exec_future = self.model_executor.execute_model( scheduler_output, non_block=True @@ -575,18 +610,17 @@ def step_with_batch_queue( # in a field and do it immediately once step_with_batch_queue is # re-called. The latter slightly favors TTFT over TPOT/throughput. if deferred_scheduler_output: - # If we are doing speculative decoding with structured output, - # we need to get the draft token ids from the prior step before - # we can compute the grammar bitmask for the deferred request. - if self.use_spec_decode: + # When draft tokens are used with structured output, validate them + # before computing the grammar bitmask for the deferred request. + if self.check_for_draft_tokens: draft_token_ids = self.model_executor.take_draft_token_ids() - assert draft_token_ids is not None - # Update the draft token ids in the scheduler output to - # filter out the invalid spec tokens, which will be padded - # with -1 and skipped by the grammar bitmask computation. - self.scheduler.update_draft_token_ids_in_output( - draft_token_ids, deferred_scheduler_output - ) + if draft_token_ids is not None: + # Update the draft token ids in the scheduler output to + # filter out the invalid spec tokens, which will be padded + # with -1 and skipped by the grammar bitmask computation. + self.scheduler.update_draft_token_ids_in_output( + draft_token_ids, deferred_scheduler_output + ) # We now have the tokens needed to compute the bitmask for the # deferred request. Get the bitmask and call sample tokens. grammar_output = self.scheduler.get_grammar_bitmask( @@ -608,6 +642,7 @@ def _process_aborts_queue(self): self.abort_requests(request_ids) def shutdown(self): + logger.debug_once("[shutdown] EngineCore: tearing down local resources") self.structured_output_manager.clear_backend() if self.model_executor: self.model_executor.shutdown() @@ -622,6 +657,7 @@ def shutdown(self): # Tear down distributed state initialized in this EngineCore process # before it exits and release cached memory. cleanup_dist_env_and_memory() + logger.debug_once("[shutdown] EngineCore: local resource teardown complete") def profile(self, is_start: bool = True, profile_prefix: str | None = None): self.model_executor.profile(is_start, profile_prefix) @@ -773,8 +809,10 @@ def wake_up(self, tags: list[str] | None = None): if tags is None or tags: self.model_executor.wake_up(tags) - # Resume scheduling (applies to all levels) - self.resume_scheduler() + # Partial wakes intentionally keep the remaining allocations asleep. + # Resume scheduling only once all executor memory is resident again. + if not self.model_executor.is_sleeping: + self.resume_scheduler() def is_sleeping(self) -> bool: """Check if engine is sleeping at any level.""" @@ -1137,10 +1175,10 @@ def run_engine_core(*args, dp_rank: int = 0, local_dp_rank: int = 0, **kwargs): numa_utils.log_current_affinity_state(process_title) if data_parallel and vllm_config.kv_transfer_config is not None: - # modify the engine_id and append the local_dp_rank to it to ensure + # modify the engine_id and append the dp_rank to it to ensure # that the kv_transfer_config is unique for each DP rank. vllm_config.kv_transfer_config.engine_id = ( - f"{vllm_config.kv_transfer_config.engine_id}_dp{local_dp_rank}" + f"{vllm_config.kv_transfer_config.engine_id}_dp{dp_rank}" ) logger.debug( "Setting kv_transfer_config.engine_id to %s", @@ -1172,6 +1210,11 @@ def wakeup_engine(): signal_callback = SignalCallback(wakeup_engine) def signal_handler(signum, frame): + signal_name = signal.Signals(signum).name + logger.info( + "[shutdown] EngineCore: trigger received signal=%s", + signal_name, + ) engine_core.shutdown_state = EngineShutdownState.REQUESTED signal_callback.trigger() @@ -1181,7 +1224,7 @@ def signal_handler(signum, frame): engine_core.run_busy_loop() except SystemExit: - logger.debug("EngineCore exiting.") + logger.info_once("[shutdown] EngineCore: exiting busy loop") raise except Exception as e: if engine_core is None: @@ -1285,13 +1328,21 @@ def _handle_shutdown(self) -> bool: if self.shutdown_state == EngineShutdownState.REQUESTED: shutdown_timeout = self.vllm_config.shutdown_timeout + mode = "abort" if shutdown_timeout == 0 else "drain" - logger.info("Shutdown initiated (timeout=%d)", shutdown_timeout) + logger.info( + "[shutdown] EngineCore: start mode=%s timeout=%ds", + mode, + shutdown_timeout, + ) if shutdown_timeout == 0: num_requests = self.scheduler.get_num_unfinished_requests() if num_requests > 0: - logger.info("Aborting %d requests", num_requests) + logger.info( + "[shutdown] EngineCore: aborting in-flight requests count=%d", + num_requests, + ) aborted_reqs = self.scheduler.finish_requests( None, RequestStatus.FINISHED_ABORTED ) @@ -1300,7 +1351,8 @@ def _handle_shutdown(self) -> bool: num_requests = self.scheduler.get_num_unfinished_requests() if num_requests > 0: logger.info( - "Draining %d in-flight requests (timeout=%ds)", + "[shutdown] EngineCore: draining in-flight requests " + "count=%d timeout=%ds", num_requests, shutdown_timeout, ) @@ -1309,7 +1361,10 @@ def _handle_shutdown(self) -> bool: # Exit when no work remaining if not self.has_work(): - logger.info("Shutdown complete") + logger.info( + "[shutdown] EngineCore: request processing complete; " + "starting resource teardown" + ) return False return True @@ -1353,7 +1408,10 @@ def _reject_add_in_shutdown(self, request: Request) -> bool: if self.shutdown_state == EngineShutdownState.RUNNING: return False - logger.info("Rejecting request %s (server shutting down)", request.request_id) + logger.debug( + "[shutdown] EngineCore: rejecting new request request_id=%s", + request.request_id, + ) self._send_abort_outputs_to_client([request.request_id], request.client_index) return True @@ -1363,7 +1421,10 @@ def _reject_utility_in_shutdown( if self.shutdown_state == EngineShutdownState.RUNNING: return False - logger.warning("Rejecting utility call %s (server shutting down)", method_name) + logger.warning( + "[shutdown] EngineCore: rejecting utility call method=%s", + method_name, + ) output = UtilityOutput(call_id, failure_message="Server shutting down") self.output_queue.put_nowait( (client_idx, EngineCoreOutputs(utility_output=output)) @@ -1468,6 +1529,14 @@ def process_input_sockets( dp_stats_address=self.frontend_stats_publish_address, dtype=str(self.vllm_config.model_config.dtype).removeprefix("torch."), vllm_version=VLLM_VERSION, + world_size=self.vllm_config.parallel_config.world_size, + data_parallel_size=self.vllm_config.parallel_config.data_parallel_size, + kv_cache_size_tokens=( + self.vllm_config.cache_config.kv_cache_size_tokens + ), + kv_cache_max_concurrency=( + self.vllm_config.cache_config.kv_cache_max_concurrency + ), ) ready_payload = msgspec.msgpack.encode(ready_response) for input_socket in input_sockets: @@ -1691,6 +1760,9 @@ def __init__( "DPEngineCoreProc should only be used for MoE models" ) + scheduler_config = vllm_config.scheduler_config + self.prefill_schedule_interval = scheduler_config.prefill_schedule_interval + # Counts forward-passes of the model so that we can synchronize # finished with DP peers every N steps. self.step_counter = 0 @@ -1841,6 +1913,15 @@ def _maybe_publish_request_counts(self): ) self.output_queue.put_nowait((-1, EngineCoreOutputs(scheduler_stats=stats))) + def _should_throttle_prefills(self) -> bool: + # Throttle new prefills to cadence-aligned steps for DP balancing. + # step_counter is identical across DP ranks. On a fresh wave the + # counter is 0, so prefills are admitted immediately after idle. + return ( + self.prefill_schedule_interval > 1 + and self.step_counter % self.prefill_schedule_interval != 0 + ) + def run_busy_loop(self): """Core busy loop of the EngineCore for data parallel case.""" @@ -1868,10 +1949,11 @@ def run_busy_loop(self): # All engines are idle. continue - # We are in a running state and so must execute a dummy pass - # if the model didn't execute any ready requests. - with self.log_iteration_details(None): - self.execute_dummy_batch() + # Execute a dummy pass when no ready requests ran, unless the + # engine is sleeping. + elif not self.model_executor.is_sleeping: + with self.log_iteration_details(None): + self.execute_dummy_batch() # 3) All-reduce operation to determine global unfinished reqs. self.engines_running = self._has_global_unfinished_reqs( @@ -2096,23 +2178,30 @@ def _set_visible_devices(self, vllm_config: VllmConfig, local_dp_rank: int): pass else: device_control_env_var = current_platform.device_control_env_var - self._set_cuda_visible_devices( + self._set_assigned_physical_gpu_ids( vllm_config, local_dp_rank, device_control_env_var ) - def _set_cuda_visible_devices( - self, vllm_config: VllmConfig, local_dp_rank: int, device_control_env_var: str + def _set_assigned_physical_gpu_ids( + self, + vllm_config: VllmConfig, + local_dp_rank: int, + device_control_env_var: str, ): world_size = vllm_config.parallel_config.world_size - # Set CUDA_VISIBLE_DEVICES or equivalent. try: - value = get_device_indices( - device_control_env_var, local_dp_rank, world_size + physical_gpu_ids = get_physical_gpu_ids_for_local_dp_rank( + device_control_env_var, + local_dp_rank, + world_size, + user_assigned_gpu_ids=( + vllm_config.parallel_config.assigned_physical_gpu_ids + ), ) - os.environ[device_control_env_var] = value + vllm_config.parallel_config.assigned_physical_gpu_ids = physical_gpu_ids except IndexError as e: raise Exception( - f"Error setting {device_control_env_var}: " + f"Error computing assigned_physical_gpu_ids: " f"local range: [{local_dp_rank * world_size}, " f"{(local_dp_rank + 1) * world_size}) " f'base value: "{os.getenv(device_control_env_var)}"' diff --git a/vllm/v1/engine/core_client.py b/vllm/v1/engine/core_client.py index 14257b020ee2..bcb441e7564a 100644 --- a/vllm/v1/engine/core_client.py +++ b/vllm/v1/engine/core_client.py @@ -20,6 +20,7 @@ import zmq import zmq.asyncio +from vllm import envs from vllm.config import VllmConfig from vllm.envs import VLLM_ENGINE_READY_TIMEOUT_S from vllm.logger import init_logger @@ -391,9 +392,12 @@ class BackgroundResources: def __call__(self): """Clean up background resources.""" + logger.debug_once("[shutdown] MPClient: background resource cleanup start") self.engine_dead = True if self.engine_manager is not None: - self.engine_manager.shutdown() + self.engine_manager.shutdown( + timeout=envs.VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS + ) if self.coordinator is not None: self.coordinator.shutdown() @@ -445,6 +449,8 @@ def close_sockets_and_tasks(): # Send shutdown signal. shutdown_sender.send(b"") + logger.debug_once("[shutdown] MPClient: background resource cleanup complete") + def validate_alive(self, frames: Sequence[zmq.Frame]): if len(frames) == 1 and (frames[0].buffer == EngineCoreProc.ENGINE_CORE_DEAD): self.engine_dead = True @@ -645,9 +651,15 @@ def __init__( def shutdown(self, timeout: float | None = None) -> None: """Shutdown engine manager under timeout and clean up resources.""" if self._finalizer.detach() is not None: + timeout_str = "default" if timeout is None else f"{timeout}s" + logger.info("[shutdown] MPClient: start timeout=%s", timeout_str) if self.resources.engine_manager is not None: + logger.info_once("[shutdown] MPClient: stopping engine manager") self.resources.engine_manager.shutdown(timeout=timeout) + logger.info_once("[shutdown] MPClient: engine manager stopped") + logger.info_once("[shutdown] MPClient: cleaning up background resources") self.resources() + logger.info_once("[shutdown] MPClient: complete") def _format_exception(self, e: Exception) -> Exception: """If errored, use EngineDeadError so root cause is clear.""" @@ -687,6 +699,9 @@ def monitor_engine_cores(): if not _self or not _self._finalizer.alive or _self.resources.engine_dead: return _self.resources.engine_dead = True + logger.warning_once( + "[shutdown] MPClient: engine core exited unexpectedly; starting cleanup" + ) _self.shutdown() # Note: For MPClient, we don't have a failure callback mechanism # like MultiprocExecutor, but we set engine_dead flag which will @@ -708,14 +723,26 @@ def _apply_ready_response(self, payload: bytes) -> None: ) # Setup KV cache config with initialization state from - # engine core process. Sum values from all engines in DP case. + # engine core process. Sum num_gpu_blocks from all engines in DP case. num_gpu_blocks = vllm_config.cache_config.num_gpu_blocks or 0 num_gpu_blocks += response.num_gpu_blocks vllm_config.cache_config.num_gpu_blocks = num_gpu_blocks # Sync block_size: may be enlarged by _align_hybrid_block_size in the # worker for hybrid Mamba models. - vllm_config.cache_config.block_size = response.block_size + cache_config = vllm_config.cache_config + cache_config.block_size = response.block_size + # Keep these as per-engine cache_config_info values; do not sum across DP. + cache_config.kv_cache_size_tokens = ( + getattr(cache_config, "kv_cache_size_tokens", None) + if getattr(cache_config, "kv_cache_size_tokens", None) is not None + else response.kv_cache_size_tokens + ) + cache_config.kv_cache_max_concurrency = ( + getattr(cache_config, "kv_cache_max_concurrency", None) + if getattr(cache_config, "kv_cache_max_concurrency", None) is not None + else response.kv_cache_max_concurrency + ) # In external DP LB mode, the coordinator address that the # front-end procs connect to is obtained by each engine via it's @@ -1407,6 +1434,12 @@ def get_core_engine_for_request(self, request: EngineCoreRequest) -> EngineIdent # Increment local waiting count for better balancing between stats # updates from the coordinator (which happen every 100ms). current_counts[eng_index][0] += self.client_count + # Rotate the scan start so that ties (equal scores, e.g. right + # after a coordinator stats reset when engines look equally loaded) + # don't systematically favor the same engine. This removes the + # fixed tie-break bias without affecting load-aware decisions when + # scores actually differ. + self.eng_start_index = (self.eng_start_index + 1) % num_engines chosen_engine = self.core_engines[eng_index] # Record which engine is chosen for this request, to handle aborts. diff --git a/vllm/v1/engine/detokenizer.py b/vllm/v1/engine/detokenizer.py index 4700eecb59a7..50f14b9f96a3 100644 --- a/vllm/v1/engine/detokenizer.py +++ b/vllm/v1/engine/detokenizer.py @@ -6,7 +6,7 @@ import tokenizers.decoders from packaging import version from tokenizers import Tokenizer -from transformers import PreTrainedTokenizerFast +from transformers import TokenizersBackend from vllm.logger import init_logger from vllm.tokenizers import TokenizerLike @@ -57,7 +57,7 @@ def from_new_request( # No tokenizer => skipping detokenization. return IncrementalDetokenizer() - if USE_FAST_DETOKENIZER and isinstance(tokenizer, PreTrainedTokenizerFast): + if USE_FAST_DETOKENIZER and isinstance(tokenizer, TokenizersBackend): # Fast tokenizer => use tokenizers library DecodeStream. return FastIncrementalDetokenizer(tokenizer, request) @@ -165,7 +165,7 @@ def get_next_output_text(self, finished: bool, delta: bool) -> str: class FastIncrementalDetokenizer(BaseIncrementalDetokenizer): - def __init__(self, tokenizer: PreTrainedTokenizerFast, request: EngineCoreRequest): + def __init__(self, tokenizer: TokenizersBackend, request: EngineCoreRequest): super().__init__(request) sampling_params = request.sampling_params diff --git a/vllm/v1/engine/llm_engine.py b/vllm/v1/engine/llm_engine.py index f3e8a95b0d63..ff86a1dffd94 100644 --- a/vllm/v1/engine/llm_engine.py +++ b/vllm/v1/engine/llm_engine.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import time +import weakref from collections.abc import Callable, Mapping from copy import copy from typing import Any @@ -123,6 +124,14 @@ def __init__( # for v0 compatibility self.model_executor = self.engine_core.engine_core.model_executor # type: ignore + # Capture the model while reachable so the finalizer can drop the + # bytecode hooks pinning it (frees GPU memory on engine deletion). + model = self._get_driver_model_for_cleanup() + if model is not None: + self._finalizer = weakref.finalize( + self, LLMEngine._cleanup_instance_caches, model + ) + if self.external_launcher_dp: # If we use DP in external launcher mode, we reuse the # existing DP group used for data communication. @@ -419,6 +428,20 @@ def collective_rpc( def apply_model(self, func: Callable[[nn.Module], _R]) -> list[_R]: return self.collective_rpc("apply_model", args=(func,)) + def _get_driver_model_for_cleanup(self) -> nn.Module | None: + driver_worker = getattr(self.model_executor, "driver_worker", None) + model_runner = getattr(driver_worker, "model_runner", None) + return getattr(model_runner, "model", None) + + @staticmethod + def _cleanup_instance_caches(model) -> None: + """Remove the bytecode hooks that pin the compiled model.""" + from vllm.compilation.wrapper import TorchCompileWithNoGuardsWrapper + + for module in model.modules(): + if isinstance(module, TorchCompileWithNoGuardsWrapper): + module.cleanup() + def __del__(self): dp_group = getattr(self, "dp_group", None) if dp_group is not None and not self.external_launcher_dp: diff --git a/vllm/v1/engine/utils.py b/vllm/v1/engine/utils.py index 8a7269a7707a..093f065475ab 100644 --- a/vllm/v1/engine/utils.py +++ b/vllm/v1/engine/utils.py @@ -12,7 +12,6 @@ from multiprocessing.process import BaseProcess from multiprocessing.queues import Queue from typing import TYPE_CHECKING, cast -from unittest.mock import patch import msgspec import zmq @@ -101,6 +100,23 @@ def _get_bundle_node_ip(bundle: dict[str, float]) -> str: raise ValueError(f"Missing node affinity in placement bundle: {bundle}") +def _node_ip_from_resources(node_resources: dict) -> str | None: + """Return the node IP encoded in a Ray per-node resource dict, or None. + + Ray advertises each node's IP as a ``node:`` resource key. The head node + also carries ``node:__internal_head__``, and placement groups add + ``..._group_...`` keys; both are ignored. + """ + for key in node_resources: + if ( + key.startswith("node:") + and key != "node:__internal_head__" + and "_group_" not in key + ): + return key.split(":", 1)[1] + return None + + class CoreEngineProcManager: """ Utility class to handle creation, readiness, and shutdown @@ -158,38 +174,38 @@ def __init__( self.manager_stopped = threading.Event() self.failed_proc_name: str | None = None + # All ranks share this config object: capture the user-provided + # --device-ids list before the per-rank shard overwrites it. Mutating + # the config before each proc.start() works because the spawn method + # pickles process args at start() time, sequentially per rank. + user_assigned_gpu_ids = vllm_config.parallel_config.assigned_physical_gpu_ids try: for proc, local_dp_rank in zip(self.processes, local_dp_ranks): - # Adjust device control in DP for platforms that cannot rely - # on torch.accelerator.set_device_index(), and for Ray launchers. - device_control_context: contextlib.AbstractContextManager[None] = ( - contextlib.nullcontext() - ) + # Populate the logical-to-physical GPU mapping in DP for + # platforms that cannot rely on + # torch.accelerator.set_device_index(), and for Ray. needs_device_env_isolation = not ( current_platform.is_cuda_alike() or current_platform.is_xpu() ) if is_dp and ( needs_device_env_isolation or vllm_config.parallel_config.use_ray ): - device_control_context = set_device_control_env_var( - vllm_config, local_dp_rank + set_assigned_physical_gpu_ids_for_dp_rank( + vllm_config, local_dp_rank, user_assigned_gpu_ids ) - with ( - device_control_context, - numa_utils.configure_subprocess( - # EngineCore itself does not have a TP/PP-local rank. - # When DP is enabled, set_device_control_env_var() - # narrows visible devices to this DP shard first, so - # local_rank=0 means "the first local GPU in this - # shard". The actual TP/PP worker processes spawned by - # the executor are bound separately with their own - # local_rank values. - vllm_config, - local_rank=0, - dp_local_rank=local_dp_rank, - process_kind="EngineCore", - ), + with numa_utils.configure_subprocess( + # EngineCore itself does not have a TP/PP-local rank. + # When DP is enabled, set_assigned_physical_gpu_ids_for_dp_rank() + # populates the logical-to-physical mapping for this DP + # shard, so local_rank=0 means "the first local GPU in + # this shard". The actual TP/PP worker processes spawned + # by the executor are bound separately with their own + # local_rank values. + vllm_config, + local_rank=0, + dp_local_rank=local_dp_rank, + process_kind="EngineCore", ): proc.start() finally: @@ -264,55 +280,79 @@ def stop(self): self._event.set() -@contextlib.contextmanager -def set_device_control_env_var( - vllm_config: VllmConfig, local_dp_rank: int -) -> Iterator[None]: +def set_assigned_physical_gpu_ids_for_dp_rank( + vllm_config: VllmConfig, + local_dp_rank: int, + user_assigned_gpu_ids: list[int] | None = None, +) -> None: """ - Temporarily set CUDA_VISIBLE_DEVICES or equivalent - for engine subprocess. + Populate assigned_physical_gpu_ids on the config for the given DP rank. + + user_assigned_gpu_ids is the full (un-sharded) --device-ids list, if the + user provided one; this DP rank's shard is sliced from it. It is passed + explicitly rather than read from the config because callers may reuse + one config object across DP ranks, overwriting the field each time. """ world_size = vllm_config.parallel_config.world_size local_world_size = vllm_config.parallel_config.local_world_size evar = current_platform.device_control_env_var - value = get_device_indices(evar, local_dp_rank, world_size, local_world_size) - with patch.dict(os.environ, values=((evar, value),)): - yield + physical_gpu_ids = get_physical_gpu_ids_for_local_dp_rank( + evar, + local_dp_rank, + world_size, + local_world_size, + user_assigned_gpu_ids=user_assigned_gpu_ids, + ) + vllm_config.parallel_config.assigned_physical_gpu_ids = physical_gpu_ids -def get_device_indices( +def get_physical_gpu_ids_for_local_dp_rank( device_control_env_var: str, local_dp_rank: int, world_size: int, local_world_size: int | None = None, -): + user_assigned_gpu_ids: list[int] | None = None, +) -> list[int]: """ - Returns a comma-separated string of device indices for the specified + Returns list of physical GPU IDs for the specified data parallel rank. For example, if world_size=2 and local_dp_rank=1, and there are 4 devices, - this will select devices 2 and 3 for local_dp_rank=1. + this will return [2, 3] for local_dp_rank=1. + + If user_assigned_gpu_ids is provided (e.g. from --device-ids), this DP + rank's shard is sliced from it instead of being derived from the + device-control env var. """ if local_world_size is None: local_world_size = world_size + if user_assigned_gpu_ids is not None: + start = local_dp_rank * world_size + stop = start + local_world_size + if stop > len(user_assigned_gpu_ids): + raise ValueError( + f"--device-ids provides {len(user_assigned_gpu_ids)} devices, " + f"but DP rank {local_dp_rank} needs devices [{start}, {stop})" + ) + return user_assigned_gpu_ids[start:stop] try: - value = ",".join( - str(current_platform.device_id_to_physical_device_id(i)) + return [ + current_platform.device_id_to_physical_device_id(i) for i in range( local_dp_rank * world_size, local_dp_rank * world_size + local_world_size, ) - ) + ] except IndexError as e: raise Exception( - f"Error setting {device_control_env_var}: " + f"Error computing device indices for " + f"{device_control_env_var}: " f"local range: [{local_dp_rank * world_size}, " f"{(local_dp_rank + 1) * world_size}) " "base value: " f'"{os.getenv(device_control_env_var)}"' ) from e - return value def _apply_dp_identity_suffix(dp_vllm_config, dp_rank: int) -> None: @@ -436,11 +476,11 @@ def __init__( # https://github.com/ray-project/ray/blob/master/python/ray/_private/accelerators/intel_gpu.py#L56 # noqa: E501 if current_platform.is_xpu(): device_evar = current_platform.device_control_env_var - device_indices = get_device_indices( + physical_gpu_ids = get_physical_gpu_ids_for_local_dp_rank( device_evar, local_index, world_size ) actor_env_vars = self.env_vars_dict.copy() - actor_env_vars[device_evar] = device_indices + actor_env_vars[device_evar] = ",".join(str(d) for d in physical_gpu_ids) runtime_env = RuntimeEnv(env_vars=actor_env_vars) actor = ( @@ -506,6 +546,32 @@ def create_dp_placement_groups( assert dp_master_ip_key in nodes[0], ( f"The DP master node (ip: {dp_master_ip}) is missing or dead" ) + + # optionally restrict DP placement to a caller-provided node set. + requested_node_ips = { + ip.strip() + for ip in envs.VLLM_RAY_DP_PLACEMENT_NODE_IPS.split(",") + if ip.strip() + } + if requested_node_ips: + allowed_node_ips = set(requested_node_ips) + # The master node must host the local ranks, so it has to be allowed. + if dp_master_ip not in allowed_node_ips: + allowed_node_ips.add(dp_master_ip) + filtered_nodes = [ + node_resources + for node_resources in nodes + if _node_ip_from_resources(node_resources) in allowed_node_ips + ] + logger.info( + "VLLM_RAY_DP_PLACEMENT_NODE_IPS set; restricting DP placement " + "from %d to %d node(s): %s", + len(nodes), + len(filtered_nodes), + sorted(allowed_node_ips), + ) + nodes = filtered_nodes + device_str = current_platform.ray_device_key n_node_devices: list[int] = [ int(node_resources[device_str]) @@ -572,18 +638,10 @@ def create_dp_placement_groups( # for "span" pack strategy collected_bundles = [] for node_resources in nodes: - node_ip_keys = [ - key - for key in node_resources - if key != "node:__internal_head__" - and key.startswith("node:") - and "_group_" not in key - ] - assert len(node_ip_keys) == 1, ( - f"Zero or multiple node IP keys found in node resources: {node_ip_keys}" + node_ip = _node_ip_from_resources(node_resources) + assert node_ip is not None, ( + f"No node IP key found in node resources: {node_resources}" ) - node_ip_key = node_ip_keys[0] - node_ip = node_ip_key.split(":")[1] n_device_on_node = int(node_resources.get(device_str, 0)) if pack_strategy == "span" and n_device_on_node != 0: diff --git a/vllm/v1/executor/abstract.py b/vllm/v1/executor/abstract.py index 7beef598e27e..4063844d469c 100644 --- a/vllm/v1/executor/abstract.py +++ b/vllm/v1/executor/abstract.py @@ -203,7 +203,7 @@ def collective_rpc( def get_kv_connector_handshake_metadata( self, - ) -> list[dict[int, KVConnectorHandshakeMetadata]]: + ) -> list[dict[tuple[int, int], KVConnectorHandshakeMetadata]]: return self.collective_rpc("get_kv_connector_handshake_metadata") @overload diff --git a/vllm/v1/executor/multiproc_executor.py b/vllm/v1/executor/multiproc_executor.py index c5766c923c88..7633ca89cdf9 100644 --- a/vllm/v1/executor/multiproc_executor.py +++ b/vllm/v1/executor/multiproc_executor.py @@ -280,9 +280,12 @@ def monitor_workers(): logger.debug("MultiprocWorkerMonitor: shutdown already initiated") return _self.is_failed = True - proc_name = next(h.proc.name for h in workers if h.proc.sentinel == died[0]) + proc = next(h.proc for h in workers if h.proc.sentinel == died[0]) logger.error( - "Worker proc %s died unexpectedly, shutting down executor.", proc_name + "Worker proc %s died unexpectedly (exit code: %s), " + "shutting down executor.", + proc.name, + proc.exitcode, ) _self.shutdown() callback = _self.failure_callback @@ -396,9 +399,7 @@ def get_response(): return responses[0] if output_rank is not None else responses future = FutureWrapper( - self.futures_queue, - get_response=get_response, - aggregate=aggregate, + self.futures_queue, get_response=get_response, aggregate=aggregate ) return future if non_block else future.result() @@ -422,27 +423,47 @@ def wait_for_termination(procs, timeout): return False active_procs = lambda: [proc for proc in worker_procs if proc.is_alive()] + initial_count = len(active_procs()) + # Give processes time to clean themselves up properly first - logger.debug("Worker Termination: allow workers to gracefully shutdown") - if wait_for_termination(active_procs(), 4): + logger.info( + "[shutdown] Executor: waiting for worker exit count=%d", + initial_count, + ) + if wait_for_termination( + active_procs(), timeout=envs.VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS + ): + logger.info_once("[shutdown] Executor: all workers exited gracefully") return # Send SIGTERM if still running - logger.debug("Worker Termination: workers still running sending SIGTERM") - for p in active_procs(): + remaining = active_procs() + logger.warning( + "[shutdown] Executor: workers still running after grace period; " + "sending SIGTERM count=%d", + len(remaining), + ) + for p in remaining: p.terminate() if not wait_for_termination(active_procs(), 4): # Send SIGKILL if still running - logger.debug( - "Worker Termination: resorting to SIGKILL to take down workers" + remaining = active_procs() + logger.warning( + "[shutdown] Executor: workers still running after SIGTERM; " + "sending SIGKILL count=%d", + len(remaining), ) - for p in active_procs(): + for p in remaining: p.kill() def shutdown(self): """Properly shut down the executor and its workers""" if not getattr(self, "shutting_down", False): - logger.debug("Triggering shutdown of workers") + worker_count = len(getattr(self, "workers", None) or []) + logger.debug( + "[shutdown] Executor: start worker_count=%d", + worker_count, + ) self.shutting_down = True # Make sure all the worker processes are terminated first. @@ -468,6 +489,8 @@ def shutdown(self): mq.shutdown() self.response_mqs = [] + logger.debug_once("[shutdown] Executor: complete") + def check_health(self) -> None: self.collective_rpc("check_health", timeout=10) return @@ -806,6 +829,16 @@ def signal_handler(signum, frame): signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) + # Publish the logical-to-physical mapping early so topology helpers + # work before init_device (needed by set_worker_net_device below). + assigned_physical_gpu_ids = kwargs[ + "vllm_config" + ].parallel_config.assigned_physical_gpu_ids + if assigned_physical_gpu_ids is not None: + from vllm.platforms.interface import set_assigned_physical_gpu_ids + + set_assigned_physical_gpu_ids(assigned_physical_gpu_ids) + # Set net device env vars for the worker if VLLM_GPU_NIC_PCIE_MAPPING is set set_worker_net_device(kwargs.get("local_rank", 0), kwargs["vllm_config"]) @@ -867,7 +900,9 @@ def signal_handler(signum, frame): if ready_writer is not None: logger.exception("WorkerProc failed to start.") elif shutdown_requested.is_set(): - logger.info("WorkerProc shutting down.") + logger.debug_once( + "[shutdown] WorkerProc: exiting after shutdown request" + ) else: logger.exception("WorkerProc failed.") @@ -879,7 +914,12 @@ def signal_handler(signum, frame): except SystemExit as e: # SystemExit is raised on SIGTERM or SIGKILL, which usually indicates that # the graceful shutdown process did not succeed - logger.warning("WorkerProc was terminated") + if shutdown_requested.is_set(): + logger.debug_once( + "[shutdown] WorkerProc: terminated by shutdown signal" + ) + else: + logger.warning("WorkerProc was terminated") # SystemExit must never be ignored raise e @@ -902,7 +942,11 @@ def enqueue_output(self, output: Any): converted to a FAILURE response. """ if isinstance(output, AsyncModelRunnerOutput): - output = output.get_output() + try: + output = output.get_output() + except Exception as e: + logger.exception("Error getting async model runner output") + output = e if isinstance(output, Exception): result = (WorkerProc.ResponseStatus.FAILURE, str(output)) @@ -953,6 +997,9 @@ def worker_busy_loop(self): func = partial(cloudpickle.loads(method), self.worker) output = func(*args, **kwargs) + + if output_rank is None or self.rank == output_rank: + self.handle_output(output) except Exception as e: # Notes have been introduced in python 3.11 if hasattr(e, "add_note"): @@ -962,10 +1009,6 @@ def worker_busy_loop(self): # string, only for logging purpose. if output_rank is None or self.rank == output_rank: self.handle_output(e) - continue - - if output_rank is None or self.rank == output_rank: - self.handle_output(output) @staticmethod def setup_proc_title_and_log_prefix(enable_ep: bool) -> None: diff --git a/vllm/v1/executor/ray_executor.py b/vllm/v1/executor/ray_executor.py index 749e59e04c26..39749ffc257e 100644 --- a/vllm/v1/executor/ray_executor.py +++ b/vllm/v1/executor/ray_executor.py @@ -258,30 +258,35 @@ def sort_by_driver_then_worker_ip(item: RayWorkerMetaData): } self.collective_rpc("adjust_rank", args=(rerank_mapping,)) - # Get the set of GPU IDs used on each node. - worker_node_and_gpu_ids = [] + # Get the set of physical GPU IDs used on each node. + worker_node_and_physical_gpu_ids = [] for worker in [self.driver_dummy_worker] + self.workers: if worker is None: # driver_dummy_worker can be None when using ray spmd worker. continue - worker_node_and_gpu_ids.append( - ray.get(worker.get_node_and_gpu_ids.remote()) # type: ignore[attr-defined] + worker_node_and_physical_gpu_ids.append( + ray.get(worker.get_node_and_physical_gpu_ids.remote()) # type: ignore[attr-defined] ) node_workers = defaultdict(list) # node id -> list of worker ranks - node_gpus = defaultdict(list) # node id -> list of gpu ids + node_physical_gpu_ids = defaultdict(list) # node id -> physical GPU IDs - for i, (node_id, gpu_ids) in enumerate(worker_node_and_gpu_ids): + for i, (node_id, physical_gpu_ids) in enumerate( + worker_node_and_physical_gpu_ids + ): node_workers[node_id].append(i) - # `gpu_ids` can be a list of strings or integers. + # `physical_gpu_ids` can be a list of strings or integers. # convert them to integers for consistency. - # NOTE: gpu_ids can be larger than 9 (e.g. 16 GPUs), + # NOTE: physical GPU IDs can be larger than 9 (e.g. 16 GPUs), # string sorting is not sufficient. # see https://github.com/vllm-project/vllm/issues/5590 - gpu_ids = [int(x) for x in gpu_ids] - node_gpus[node_id].extend(gpu_ids) - for node_id, gpu_ids in node_gpus.items(): - node_gpus[node_id] = sorted(gpu_ids) + physical_gpu_ids = [ + current_platform.device_control_id_to_physical_device_id(str(x)) + for x in physical_gpu_ids + ] + node_physical_gpu_ids[node_id].extend(physical_gpu_ids) + for node_id, physical_gpu_ids in node_physical_gpu_ids.items(): + node_physical_gpu_ids[node_id] = sorted(physical_gpu_ids) all_ips = set(worker_ips + [driver_ip]) n_ips = len(all_ips) @@ -297,23 +302,8 @@ def sort_by_driver_then_worker_ip(item: RayWorkerMetaData): " each node." ) - # Set environment variables for the driver and workers. - # We set CUDA_VISIBLE_DEVICES to ALL GPUs on the node for each worker. - # This is needed because: - # 1. Ray's compiled DAG needs to find the allocated GPU in - # CUDA_VISIBLE_DEVICES. - # 2. vLLM's communication layer (NCCL, CustomAllreduce) needs to see - # all GPUs for P2P checks and communication setup. Though if it was - # just this reason, we could have also just kept the visible devices - # unset. - # Each worker will use local_rank to index into the visible devices. - all_args_to_update_environment_variables = [ - { - current_platform.device_control_env_var: ",".join( - map(str, node_gpus[node_id]) - ), - } - for (node_id, _) in worker_node_and_gpu_ids + all_args_to_update_environment_variables: list[dict[str, str]] = [ + {} for _ in worker_node_and_physical_gpu_ids ] # Environment variables to copy from driver to workers @@ -336,7 +326,7 @@ def sort_by_driver_then_worker_ip(item: RayWorkerMetaData): "update_environment_variables", args=(self._get_env_vars_to_be_updated(),) ) - if len(node_gpus) == 1: + if len(node_physical_gpu_ids) == 1: # in single node case, we don't need to get the IP address. # the loopback address is sufficient # NOTE: a node may have several IP addresses, one for each @@ -352,10 +342,11 @@ def sort_by_driver_then_worker_ip(item: RayWorkerMetaData): # Initialize the actual workers inside worker wrapper. all_kwargs = [] - for rank, (node_id, _) in enumerate(worker_node_and_gpu_ids): + for rank, (node_id, _) in enumerate(worker_node_and_physical_gpu_ids): local_rank = node_workers[node_id].index(rank) kwargs = dict( vllm_config=self.vllm_config, + assigned_physical_gpu_ids=sorted(node_physical_gpu_ids[node_id]), local_rank=local_rank, rank=rank, distributed_init_method=distributed_init_method, diff --git a/vllm/v1/executor/ray_executor_v2.py b/vllm/v1/executor/ray_executor_v2.py index 0665b5fc1b88..33c386515766 100644 --- a/vllm/v1/executor/ray_executor_v2.py +++ b/vllm/v1/executor/ray_executor_v2.py @@ -17,6 +17,7 @@ from vllm.logger import init_logger from vllm.platforms import current_platform from vllm.utils.network_utils import ( + _get_open_port, get_distributed_init_method, get_open_port, ) @@ -79,24 +80,25 @@ class RayWorkerProc(WorkerProc): 1. __init__: lightweight setup, stores init args (no device/model init) 2. initialize_worker: called after GPU IDs are discovered, completes the full WorkerProc initialization with the correct local_rank and - CUDA_VISIBLE_DEVICES. + logical-to-physical GPU mapping. - CUDA_VISIBLE_DEVICES setup flow: + GPU assignment flow: 1. RayExecutorV2 enables RAY_EXPERIMENTAL_NOSET_CUDA_VISIBLE_DEVICES so Ray does not set CUDA_VISIBLE_DEVICES on RayWorkerProc actors at creation time. 2. Each actor is scheduled with a placement group and bundle index; Ray resolves the physical GPU ID for that bundle at placement time. - 3. After placement, the worker discovers that GPU ID and sets - CUDA_VISIBLE_DEVICES before finishing WorkerProc initialization. + 3. After placement, the executor discovers each worker's GPU ID and passes the + node's logical-to-physical mapping (assigned_physical_gpu_ids) to + initialize_worker(); CUDA_VISIBLE_DEVICES is never modified. - There is no workaround for this unset-and-reset sequence when the placement group - is externally managed: scheduling must complete before CUDA_VISIBLE_DEVICES can - match the GPU tied to the worker's bundle. + Scheduling must complete before the mapping is known when the placement + group is externally managed: only then is the GPU tied to the worker's + bundle resolved. This sequence allows multiple vLLM instances to coexist on the same node: each instance is unaware which physical devices others hold, and the - externally managed placement group avoids CUDA_VISIBLE_DEVICES conflicts + externally managed placement group avoids device assignment conflicts by binding workers to specific placement group bundles. """ @@ -120,28 +122,33 @@ def __init__( is_driver_worker=is_driver_worker, ) - def get_node_and_gpu_ids(self) -> tuple[str, list[int]]: - """Return (node_id, gpu_ids) assigned to this actor by Ray.""" + def get_node_and_physical_gpu_ids(self) -> tuple[str, list[int]]: + """Return (node_id, physical_gpu_ids) assigned to this actor by Ray.""" node_id = ray.get_runtime_context().get_node_id() device_key = current_platform.ray_device_key if not device_key: raise RuntimeError( f"current platform {current_platform.device_name} does not support ray." ) - gpu_ids = ray.get_runtime_context().get_accelerator_ids()[device_key] - return node_id, [int(x) for x in gpu_ids] + physical_gpu_ids = ray.get_runtime_context().get_accelerator_ids()[device_key] + return node_id, [ + current_platform.device_control_id_to_physical_device_id(str(x)) + for x in physical_gpu_ids + ] def initialize_worker( self, local_rank: int, env_vars: dict[str, str], driver_env_vars: dict[str, str] | None = None, + assigned_physical_gpu_ids: list[int] | None = None, ) -> None: """Complete initialization after GPU assignment is known. *driver_env_vars* are applied with ``setdefault`` — they fill in missing vars but never overwrite node-local values. - *env_vars* (e.g. CUDA_VISIBLE_DEVICES) always overwrite. + *env_vars* always overwrite. + *assigned_physical_gpu_ids* maps local_rank to physical CUDA device ID. """ if driver_env_vars: for key, value in driver_env_vars.items(): @@ -149,6 +156,13 @@ def initialize_worker( for key, value in env_vars.items(): os.environ[key] = value + if assigned_physical_gpu_ids is not None: + vllm_config = self._init_kwargs["vllm_config"] + assert isinstance(vllm_config, VllmConfig) + vllm_config.parallel_config.assigned_physical_gpu_ids = ( + assigned_physical_gpu_ids + ) + self.local_rank = local_rank super().__init__( local_rank=local_rank, @@ -246,6 +260,25 @@ def _get_actor_resource_kwargs() -> dict[str, Any]: return {"num_gpus": num_devices} return {"num_gpus": 0, "resources": {device_key: num_devices}} + @staticmethod + def _select_tcpstore_port(local_dp_rank: int | None, master_port: int) -> int: + """Pick the torch.distributed TCPStore port for this engine. + + Co-located DP engines choosing this port with a shared random search + collide intermittently. Seeding by node-local DP rank gives each a + disjoint window. Non-DP engines and full windows fall back to a + random port. + """ + if local_dp_rank is None: + return get_open_port() + # Offset past the DP master port reserved range, one window per rank. + window = 32 + start_port = master_port + 100 + local_dp_rank * window + try: + return _get_open_port(start_port=start_port, max_attempts=window) + except RuntimeError: + return get_open_port() + def _init_executor(self) -> None: """Initialize the RayExecutorV2 executor.""" self._finalizer = weakref.finalize(self, self.shutdown) @@ -295,7 +328,12 @@ def _init_executor(self) -> None: # The TCPStore server runs on rank 0's node, so all workers # must be able to reach this address. dist_ip = bundle_assignments[0]["node_ip"] - distributed_init_method = get_distributed_init_method(dist_ip, get_open_port()) + parallel_config = self.vllm_config.parallel_config + port = self._select_tcpstore_port( + parallel_config.data_parallel_rank_local, + parallel_config.data_parallel_master_port, + ) + distributed_init_method = get_distributed_init_method(dist_ip, port) # Step 4: Create broadcast MessageQueue. # Workers on the driver node use shared memory; the rest use TCP. @@ -365,36 +403,48 @@ def _init_executor(self) -> None: ) self.ray_worker_handles.append(handle) - # Step 6: Discover GPU IDs assigned to each worker via Ray runtime context. - worker_node_and_gpu_ids = ray.get( - [h.actor.get_node_and_gpu_ids.remote() for h in self.ray_worker_handles] + # Step 6: Discover physical GPU IDs assigned to each worker via Ray + # runtime context. + worker_node_and_physical_gpu_ids = ray.get( + [ + h.actor.get_node_and_physical_gpu_ids.remote() + for h in self.ray_worker_handles + ] ) node_workers: dict[str, list[int]] = defaultdict(list) - node_gpus: dict[str, list[int]] = defaultdict(list) - for i, (node_id, gpu_ids) in enumerate(worker_node_and_gpu_ids): + node_physical_gpu_ids: dict[str, list[int]] = defaultdict(list) + for i, (node_id, physical_gpu_ids) in enumerate( + worker_node_and_physical_gpu_ids + ): node_workers[node_id].append(i) - node_gpus[node_id].extend(gpu_ids) - for node_id, gpu_ids in node_gpus.items(): - node_gpus[node_id] = sorted(gpu_ids) + node_physical_gpu_ids[node_id].extend(physical_gpu_ids) + for node_id, physical_gpu_ids in node_physical_gpu_ids.items(): + node_physical_gpu_ids[node_id] = sorted(physical_gpu_ids) - # Step 7: Initialize workers with correct local_rank and - # CUDA_VISIBLE_DEVICES. Each worker sees all GPUs assigned to - # this executor on its node; local_rank indexes into that set. + # Step 7: Initialize workers with local logical ranks and the + # logical-to-physical GPU mapping discovered from Ray placement. init_worker_refs = [] - for i, (node_id, _) in enumerate(worker_node_and_gpu_ids): + for i, (node_id, _) in enumerate(worker_node_and_physical_gpu_ids): local_rank = node_workers[node_id].index(i) - worker_env_vars = { - current_platform.device_control_env_var: ",".join( - map(str, node_gpus[node_id]) - ), - } + assigned_physical_gpu_ids = sorted(node_physical_gpu_ids[node_id]) + worker_env_vars: dict[str, str] = {} self.ray_worker_handles[i].local_rank = local_rank init_worker_refs.append( self.ray_worker_handles[i].actor.initialize_worker.remote( - local_rank, worker_env_vars, self.driver_env_vars + local_rank, + worker_env_vars, + self.driver_env_vars, + assigned_physical_gpu_ids=assigned_physical_gpu_ids, ) ) + # Also set on the executor-side config for consistency. The mapping + # is per-node, so only do this when all workers share one node. + if len(node_physical_gpu_ids) == 1: + node_id_0 = worker_node_and_physical_gpu_ids[0][0] + self.vllm_config.parallel_config.assigned_physical_gpu_ids = sorted( + node_physical_gpu_ids[node_id_0] + ) ray.get(init_worker_refs) # Step 8: Collect response MQ handles diff --git a/vllm/v1/executor/ray_utils.py b/vllm/v1/executor/ray_utils.py index 9083b9195912..cc17c39e35f3 100644 --- a/vllm/v1/executor/ray_utils.py +++ b/vllm/v1/executor/ray_utils.py @@ -93,7 +93,7 @@ def execute_method(self, method: str | bytes, *args, **kwargs): def get_node_ip(self) -> str: return get_ip() - def get_node_and_gpu_ids(self) -> tuple[str, list[int]]: + def get_node_and_physical_gpu_ids(self) -> tuple[str, list[int]]: node_id = ray.get_runtime_context().get_node_id() device_key = vllm.platforms.current_platform.ray_device_key if not device_key: @@ -101,8 +101,10 @@ def get_node_and_gpu_ids(self) -> tuple[str, list[int]]: "current platform %s does not support ray.", vllm.platforms.current_platform.device_name, ) - gpu_ids = ray.get_runtime_context().get_accelerator_ids()[device_key] - return node_id, gpu_ids + physical_gpu_ids = ray.get_runtime_context().get_accelerator_ids()[ + device_key + ] + return node_id, physical_gpu_ids def setup_device_if_necessary(self): # TODO(swang): This is needed right now because Ray CG executes diff --git a/vllm/v1/executor/uniproc_executor.py b/vllm/v1/executor/uniproc_executor.py index dd04b718d67c..3bac65bf4fdd 100644 --- a/vllm/v1/executor/uniproc_executor.py +++ b/vllm/v1/executor/uniproc_executor.py @@ -90,6 +90,8 @@ def collective_rpc( # type: ignore[override] if not non_block: result = run_method(self.driver_worker, method, args, kwargs) + if isinstance(result, AsyncModelRunnerOutput): + result = result.get_output() return result if single_value else [result] try: diff --git a/vllm/v1/kv_cache_interface.py b/vllm/v1/kv_cache_interface.py index 3bbfba1a0fe6..4204c31be58d 100644 --- a/vllm/v1/kv_cache_interface.py +++ b/vllm/v1/kv_cache_interface.py @@ -41,7 +41,8 @@ class KVQuantMode(IntEnum): FP8_PER_TENSOR = 1 # per-tensor scales (current fp8 path) INT8_PER_TOKEN_HEAD = 2 # per-token-head dynamic scales for int8 FP8_PER_TOKEN_HEAD = 3 # per-token-head dynamic scales for fp8 - NVFP4 = 4 # packed fp4 data + fp8 block scales + INT4_PER_TOKEN_HEAD = 4 # packed 2×int4/byte, RHT + asymmetric zp + NVFP4 = 5 # packed fp4 data + fp8 block scales @property def is_per_token_head(self) -> bool: @@ -49,6 +50,7 @@ def is_per_token_head(self) -> bool: return self in ( KVQuantMode.INT8_PER_TOKEN_HEAD, KVQuantMode.FP8_PER_TOKEN_HEAD, + KVQuantMode.INT4_PER_TOKEN_HEAD, ) @property @@ -59,6 +61,8 @@ def is_nvfp4(self) -> bool: def get_kv_quant_mode(kv_cache_dtype: str) -> KVQuantMode: """Map a ``kv_cache_dtype`` string to a :class:`KVQuantMode`.""" + if kv_cache_dtype == "int4_per_token_head": + return KVQuantMode.INT4_PER_TOKEN_HEAD if kv_cache_dtype == "int8_per_token_head": return KVQuantMode.INT8_PER_TOKEN_HEAD if kv_cache_dtype == "fp8_per_token_head": @@ -163,6 +167,7 @@ class AttentionSpec(KVCacheSpec): dtype: torch.dtype kv_quant_mode: KVQuantMode = KVQuantMode.NONE page_size_padded: int | None = None + indexes_kv_by_block_stride: bool = False @property def page_size_bytes(self) -> int: @@ -183,19 +188,16 @@ def page_size_bytes(self) -> int: def real_page_size_bytes(self) -> int: if self.kv_quant_mode.is_nvfp4: # Packed layout: fp4 data + fp8 block scales per head. - full_dim = nvfp4_kv_cache_full_dim(self.head_size) - return ( - 2 - * self.block_size - * self.num_kv_heads - * full_dim - * get_dtype_size(self.dtype) - ) + head_dim = nvfp4_kv_cache_full_dim(self.head_size) + elif self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD: + head_dim = self.head_size // 2 + else: + head_dim = self.head_size return ( 2 * self.block_size * self.num_kv_heads - * self.head_size + * head_dim * get_dtype_size(self.dtype) ) @@ -219,6 +221,15 @@ class FullAttentionSpec(AttentionSpec): """ attention_chunk_size: int | None = None + non_causal: bool = False + """ + Whether the layer attends non-causally (e.g. Prefix LM). Carried on the + spec so the engine core, which collects specs from all workers before the + scheduler is built, can adjust scheduling policy (chunked prefill / prefix + caching) regardless of tensor-parallel layout. It does not affect the KV + cache layout itself. + """ + def __post_init__(self): if self.head_size_v is None: object.__setattr__(self, "head_size_v", self.head_size) @@ -274,8 +285,12 @@ def merge(cls, specs: list[Self]) -> Self: dtype=specs[0].dtype, kv_quant_mode=specs[0].kv_quant_mode, page_size_padded=specs[0].page_size_padded, + indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride, sliding_window=cls.merge_window_sizes(sliding_window), attention_chunk_size=cls.merge_window_sizes(attention_chunk_size), + # If any layer in the group is non-causal, treat the group as + # non-causal so the engine core disables incompatible scheduling. + non_causal=any(spec.non_causal for spec in specs), ) for spec in specs: for f in fields(AttentionSpec): @@ -300,17 +315,12 @@ def real_page_size_bytes(self) -> int: last_dim = nvfp4_kv_cache_full_dim( self.head_size ) + nvfp4_kv_cache_full_dim(self.head_size_v) - return ( - self.block_size - * self.num_kv_heads - * last_dim - * get_dtype_size(self.dtype) - ) + elif self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD: + last_dim = self.head_size // 2 + self.head_size_v // 2 + else: + last_dim = self.head_size + self.head_size_v return ( - self.block_size - * self.num_kv_heads - * (self.head_size + self.head_size_v) - * get_dtype_size(self.dtype) + self.block_size * self.num_kv_heads * last_dim * get_dtype_size(self.dtype) ) @@ -376,10 +386,14 @@ def real_page_size_bytes(self) -> int: # V3.2 main MLA: 656-byte custom layout (kv_lora_rank=512 + # qk_rope_head_dim=64, head_size=576). See flashmla_sparse.py. return self.block_size * 656 + if self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD: + head_dim = self.head_size // 2 + else: + head_dim = self.head_size return ( self.storage_block_size * self.num_kv_heads - * self.head_size + * head_dim * get_dtype_size(self.dtype) ) @@ -391,13 +405,16 @@ def merge(cls, specs: list[Self]) -> Self: cache_dtype_str_set = set(spec.cache_dtype_str for spec in specs) compress_ratio_set = set(spec.compress_ratio for spec in specs) model_version_set = set(spec.model_version for spec in specs) + block_stride_set = set(spec.indexes_kv_by_block_stride for spec in specs) assert ( len(cache_dtype_str_set) == 1 and len(compress_ratio_set) == 1 and len(model_version_set) == 1 + and len(block_stride_set) == 1 ), ( "All attention layers in the same KV cache group must use the same " - "quantization method, compress ratio, and model version." + "quantization method, compress ratio, model version, and KV block " + "stride indexing." ) return cls( block_size=specs[0].block_size, @@ -406,6 +423,7 @@ def merge(cls, specs: list[Self]) -> Self: dtype=specs[0].dtype, kv_quant_mode=specs[0].kv_quant_mode, page_size_padded=specs[0].page_size_padded, + indexes_kv_by_block_stride=block_stride_set.pop(), cache_dtype_str=cache_dtype_str_set.pop(), compress_ratio=compress_ratio_set.pop(), model_version=model_version_set.pop(), @@ -419,30 +437,73 @@ class HiddenStateCacheSpec(MLAAttentionSpec): pass +@dataclass(frozen=True, kw_only=True) +class RSWASpec(FullAttentionSpec): + """KV cache spec for Reference Sliding Window Attention (R-SWA). + + Prefill (image + text prompt) tokens are always globally visible. + Only the last ``rswa_window`` generated tokens are kept in the KV cache; + gap blocks (between the prefill tail and the current decode window) are + evicted during each decode step to bound memory at + O(prefix_blocks + window_blocks). + """ + + rswa_window: int + + @classmethod + def merge(cls, specs: list[RSWASpec]) -> RSWASpec: + assert all(isinstance(spec, RSWASpec) for spec in specs), ( + "All attention layers in the same KV cache group must be RSWASpec." + ) + rswa_windows = {spec.rswa_window for spec in specs} + assert len(rswa_windows) == 1, ( + f"All R-SWA layers must share the same rswa_window, got {rswa_windows}" + ) + # Delegate common field merging to the parent, then reattach rswa_window. + base = FullAttentionSpec.merge(specs) # type: ignore[arg-type] + return cls( + block_size=base.block_size, + num_kv_heads=base.num_kv_heads, + head_size=base.head_size, + head_size_v=base.head_size_v, + dtype=base.dtype, + kv_quant_mode=base.kv_quant_mode, + page_size_padded=base.page_size_padded, + indexes_kv_by_block_stride=base.indexes_kv_by_block_stride, + sliding_window=base.sliding_window, + attention_chunk_size=base.attention_chunk_size, + non_causal=base.non_causal, + rswa_window=rswa_windows.pop(), + ) + + @dataclass(frozen=True, kw_only=True) class ChunkedLocalAttentionSpec(AttentionSpec): attention_chunk_size: int def max_admission_blocks_per_request( - self, max_num_batched_tokens: int, max_model_len: int + self, max_in_flight_tokens: int, max_model_len: int ) -> int: """Per-request admission cap, in blocks. Single source of truth for both startup pool sizing (`max_memory_usage_bytes`) and the runtime admission gate, so requests admitted by startup can also be admitted at runtime. + + `max_in_flight_tokens` is the max tokens scheduled but not yet settled + (one batch per concurrent step); see `VllmConfig.max_in_flight_tokens`. """ - # During chunked prefill, we hold KV for at most one chunk window. + # During chunked prefill, we hold KV for at most one chunk window plus + # the in-flight tokens, since frees happen on the processed-token basis. num_tokens = min( - self.attention_chunk_size + max_num_batched_tokens, max_model_len + self.attention_chunk_size + max_in_flight_tokens, max_model_len ) return cdiv(num_tokens, self.block_size) def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int: - max_model_len = vllm_config.model_config.max_model_len - max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens max_blocks = self.max_admission_blocks_per_request( - max_num_batched_tokens=max_num_batched_tokens, max_model_len=max_model_len + max_in_flight_tokens=vllm_config.max_in_flight_tokens, + max_model_len=vllm_config.model_config.max_model_len, ) return max_blocks * self.page_size_bytes @@ -486,7 +547,7 @@ def real_page_size_bytes(self) -> int: ) def max_admission_blocks_per_request( - self, max_num_batched_tokens: int, max_model_len: int + self, max_in_flight_tokens: int, max_model_len: int ) -> int: """Per-request admission cap, in blocks. @@ -495,13 +556,14 @@ def max_admission_blocks_per_request( real-held blocks plateau at this bound because `SlidingWindowManager.remove_skipped_blocks` runs from `allocate_slots` before each chunk's `get_num_blocks_to_allocate`. + + `max_in_flight_tokens` is the max tokens scheduled but not yet settled + (one batch per concurrent step); see `VllmConfig.max_in_flight_tokens`. """ # During chunked prefill, we hold KV for the last `sliding_window-1` - # computed tokens plus the newly scheduled tokens, and never more - # than `max_model_len`. - num_tokens = min( - self.sliding_window - 1 + max_num_batched_tokens, max_model_len - ) + # computed tokens plus the in-flight tokens (frees happen on the + # processed-token basis); never more than `max_model_len`. + num_tokens = min(self.sliding_window - 1 + max_in_flight_tokens, max_model_len) # +1 because the sliding window may not start from the beginning of # the block. E.g. block size 4 and num_token 4 needs two blocks # [XXCD][EF] to store the 6-token window [CDEF]. @@ -511,10 +573,9 @@ def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int: assert vllm_config.parallel_config.decode_context_parallel_size == 1, ( "DCP not support sliding window." ) - max_model_len = vllm_config.model_config.max_model_len - max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens max_blocks = self.max_admission_blocks_per_request( - max_num_batched_tokens=max_num_batched_tokens, max_model_len=max_model_len + max_in_flight_tokens=vllm_config.max_in_flight_tokens, + max_model_len=vllm_config.model_config.max_model_len, ) return max_blocks * self.page_size_bytes @@ -547,10 +608,12 @@ def storage_block_size(self) -> int: @property def real_page_size_bytes(self) -> int: - if self.model_version == "deepseek_v4": - # DeepseekV4: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B per token. + if self.model_version == "deepseek_v4" and self.cache_dtype_str == "fp8_ds_mla": + # DeepseekV4 FlashMLA: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B + # per token. FlashInfer's contiguous bf16/fp8 cache falls through to + # the element-size formula below. return self.storage_block_size * 584 - assert self.model_version is None, ( + assert self.model_version in (None, "deepseek_v4"), ( f"Unsupported model version: {self.model_version}" ) return ( @@ -570,15 +633,17 @@ def merge(cls, specs: list[Self]) -> Self: compress_ratio_set = set(spec.compress_ratio for spec in specs) model_version_set = set(spec.model_version for spec in specs) sliding_window_set = set(spec.sliding_window for spec in specs) + block_stride_set = set(spec.indexes_kv_by_block_stride for spec in specs) assert ( len(cache_dtype_str_set) == 1 and len(compress_ratio_set) == 1 and len(model_version_set) == 1 and len(sliding_window_set) == 1 + and len(block_stride_set) == 1 ), ( "All attention layers in the same KV cache group must use the same " - "quantization method, compress ratio, model version and sliding " - "window size." + "quantization method, compress ratio, model version, sliding " + "window size, and KV block stride indexing." ) return cls( block_size=specs[0].block_size, @@ -586,6 +651,7 @@ def merge(cls, specs: list[Self]) -> Self: head_size=specs[0].head_size, dtype=specs[0].dtype, page_size_padded=specs[0].page_size_padded, + indexes_kv_by_block_stride=block_stride_set.pop(), sliding_window=sliding_window_set.pop(), cache_dtype_str=cache_dtype_str_set.pop(), compress_ratio=compress_ratio_set.pop(), @@ -697,8 +763,10 @@ def merge(cls, specs: list[Self]) -> Self: dtype=specs[0].dtype, kv_quant_mode=specs[0].kv_quant_mode, page_size_padded=specs[0].page_size_padded, + indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride, sliding_window=cls.merge_window_sizes(sliding_window), attention_chunk_size=cls.merge_window_sizes(attention_chunk_size), + non_causal=any(spec.non_causal for spec in specs), ) for spec in specs: for f in fields(AttentionSpec): @@ -832,6 +900,8 @@ class KVCacheTensor: size: int # size of the KV cache tensor in bytes shared_by: list[str] # layer names that share the same KV cache tensor + offset: int = 0 # byte offset of this layer within a contiguous block + block_stride: int = 0 # total bytes per block in a packed layout (0 = not packed) @dataclass diff --git a/vllm/v1/kv_offload/base.py b/vllm/v1/kv_offload/base.py index 5f798f41eac8..5a2e3c184d39 100644 --- a/vllm/v1/kv_offload/base.py +++ b/vllm/v1/kv_offload/base.py @@ -5,22 +5,23 @@ """ from abc import ABC, abstractmethod -from collections.abc import Collection, Iterable, Iterator, Sequence +from collections.abc import Collection, Iterable, Sequence from dataclasses import dataclass -from enum import Enum -from typing import TYPE_CHECKING, Any, NewType +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, NamedTuple, NewType import numpy as np import torch -from typing_extensions import override from vllm.logger import init_logger from vllm.v1.core.kv_cache_utils import resolve_kv_cache_block_sizes if TYPE_CHECKING: from vllm.config import VllmConfig + from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import ( + OffloadingConnectorStats, + ) from vllm.v1.kv_cache_interface import KVCacheConfig - from vllm.v1.kv_offload.worker.worker import OffloadingHandler # `OffloadKey` identifies an offloaded block. It combines a block hash with # its KV cache group index, encoded as raw bytes to avoid tuple GC overhead. @@ -51,6 +52,15 @@ class ReqContext: kv_transfer_params: dict[str, Any] | None = None +class LookupResult(Enum): + """Result of OffloadingManager.lookup().""" + + MISS = auto() + HIT = auto() + HIT_PENDING = auto() + RETRY = auto() + + class OffloadPolicy(Enum): # Offload only newly-computed blocks as they arrive; prefix-hit # blocks (already offloaded by a prior request) are skipped. @@ -65,21 +75,21 @@ class RequestOffloadingContext: policy: OffloadPolicy = OffloadPolicy.BLOCK_LEVEL -class LoadStoreSpec(ABC): +class ScheduleEndContext(NamedTuple): + """Per-step scheduling info passed to on_schedule_end().""" + + # Request IDs scheduled for the first time this step. + new_req_ids: Collection[str] + # Request IDs preempted this step. + preempted_req_ids: Collection[str] + + +class LoadStoreSpec: """ - Abstract metadata that encapsulates information allowing a worker + Metadata that encapsulates information allowing a worker to load, and optionally also to store, blocks of KV data. """ - @staticmethod - @abstractmethod - def medium() -> str: - """ - Returns a string representation of the medium type - this store/load targets. - """ - pass - @dataclass class PrepareStoreOutput: @@ -123,9 +133,40 @@ class OffloadingEvent: """ +@dataclass(frozen=True) +class OffloadingMetricMetadata: + documentation: str + labelnames: tuple[str, ...] = () + + +@dataclass(frozen=True) +class OffloadingCounterMetadata(OffloadingMetricMetadata): + pass + + +@dataclass(frozen=True) +class OffloadingGaugeMetadata(OffloadingMetricMetadata): + pass + + +@dataclass(frozen=True) +class OffloadingHistogramMetadata(OffloadingMetricMetadata): + buckets: tuple[float, ...] | None = None + + +@dataclass(frozen=True) +class OffloadingKVEventsConfig: + # Global vLLM KV event publishing flag. When false, connector-specific + # event capture must stay inert because take_events() is not drained. + enable_kv_cache_events: bool + # OffloadingConnector opt-in for self-describing BlockStored payloads. + # Effective only when enable_kv_cache_events is true. + self_describing_kv_events: bool + + class OffloadingManager(ABC): @abstractmethod - def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: """ Checks whether a single block is offloaded and ready to be read. @@ -134,10 +175,9 @@ def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: req_context: per-request context (e.g. kv_transfer_params). Returns: - True if the block is offloaded and ready, False if not, - or None if the lookup should be retried later. - Returning None will delay the request handling by the vLLM - scheduler. + HIT if the block is offloaded and ready, MISS if not found, + HIT_PENDING if found but not yet readable, or RETRY if the + lookup should be retried later. """ pass @@ -243,6 +283,17 @@ def on_request_finished(self, req_context: ReqContext) -> None: """ Called when a request has finished. + By the time this is called, the scheduler will issue no more + submit-side calls for this request, such as prepare_store() and + prepare_load(). Completion callbacks for already-submitted transfers + (complete_store() and complete_load()) may still arrive afterward. + + This hook does NOT imply the data has been persisted. Asynchronous + transfers already submitted for this request may still be in flight. + Managers that cascade to lower tiers should delay those tiers' + on_request_finished() calls until no more lower-tier submit calls can + be issued for this request. + Args: req_context: per-request context. """ @@ -252,12 +303,16 @@ def take_events(self) -> Iterable[OffloadingEvent]: """ Take the offloading events from the manager. + A tier manager emits only events for storage state it owns. A + composing manager may aggregate child event streams, but should not + synthesize events on behalf of a child tier. + Yields: New OffloadingEvents collected since the last call. """ return () - def on_schedule_end(self) -> None: + def on_schedule_end(self, context: ScheduleEndContext) -> None: """Called once at the end of each scheduler step. Managers may override this to flush deferred work accumulated @@ -265,10 +320,22 @@ def on_schedule_end(self) -> None: """ return + def has_pending_work(self) -> bool: + """Whether this manager needs the engine to keep stepping. + + While True, on_schedule_end() and get_finished_jobs() continue + to be called even when no requests are scheduled. + """ + return False + def reset_cache(self) -> None: """Evict all tracked blocks and reset internal state.""" return + def get_stats(self) -> "OffloadingConnectorStats | None": + """Return collected metrics since last call, or None if disabled.""" + return None + def shutdown(self) -> None: """Shutdown the manager and release any resources.""" return @@ -319,11 +386,6 @@ def __init__( self.group_sizes: Sequence[int] = group_sizes self.block_indices: Sequence[int] = block_indices - @staticmethod - @override - def medium() -> str: - return "GPU" - @dataclass class CanonicalKVCacheTensor: @@ -375,9 +437,51 @@ class CanonicalKVCaches: group_data_refs: list[list[CanonicalKVCacheRef]] +@dataclass +class TransferResult: + job_id: int + success: bool + transfer_size: int | None = None + transfer_time: float | None = None + + +class OffloadingWorker(ABC): + """Runs in the worker process. Performs async KV transfers for ONE + offloaded medium (e.g. CPU). Direction is explicit via submit_store / + submit_load, so there is no (src_medium, dst_medium) routing.""" + + @abstractmethod + def submit_store( + self, job_id: int, src_spec: GPULoadStoreSpec, dst_spec: LoadStoreSpec + ) -> bool: + """Async GPU -> offloaded medium.""" + + @abstractmethod + def submit_load( + self, job_id: int, src_spec: LoadStoreSpec, dst_spec: GPULoadStoreSpec + ) -> bool: + """Async offloaded medium -> GPU.""" + + @abstractmethod + def get_finished(self) -> list[TransferResult]: ... + + @abstractmethod + def wait(self, job_ids: set[int]) -> None: ... + + def shutdown(self) -> None: + return + + class OffloadingSpec(ABC): """Spec for an offloading connector""" + @classmethod + def build_metric_definitions( + cls, extra_config: dict[str, Any] + ) -> dict[str, "OffloadingMetricMetadata"]: + """Return Prometheus metric definitions emitted by this spec.""" + return {} + def __init__(self, vllm_config: "VllmConfig", kv_cache_config: "KVCacheConfig"): logger.warning( "Initializing OffloadingSpec. This API is experimental and " @@ -389,6 +493,15 @@ def __init__(self, vllm_config: "VllmConfig", kv_cache_config: "KVCacheConfig"): kv_transfer_config = vllm_config.kv_transfer_config assert kv_transfer_config is not None self.extra_config = kv_transfer_config.kv_connector_extra_config + kv_events_config = vllm_config.kv_events_config + self.kv_events_config = OffloadingKVEventsConfig( + enable_kv_cache_events=( + kv_events_config is not None and kv_events_config.enable_kv_cache_events + ), + self_describing_kv_events=bool( + self.extra_config.get("self_describing_kv_events", False) + ), + ) # When True, only prompt (prefill) blocks are offloaded; decode-phase # blocks (KV generated after the prompt) are skipped. Useful when prior @@ -451,16 +564,14 @@ def get_manager(self) -> OffloadingManager: pass @abstractmethod - def get_handlers( - self, kv_caches: CanonicalKVCaches - ) -> Iterator[tuple[type[LoadStoreSpec], type[LoadStoreSpec], "OffloadingHandler"]]: + def get_worker(self, kv_caches: CanonicalKVCaches) -> OffloadingWorker: """ - Get offloading handlers along with their respective src and dst types. + Get an OffloadingWorker that handles async KV transfers for this spec. Args: kv_caches: Canonicalized KV caches. - Yields: - Tuples of (src_type, dst_type, offloading_handler). + Returns: + An OffloadingWorker instance for this medium. """ pass diff --git a/vllm/v1/kv_offload/cpu/common.py b/vllm/v1/kv_offload/cpu/common.py index 42f576bb7057..29f95e1b4076 100644 --- a/vllm/v1/kv_offload/cpu/common.py +++ b/vllm/v1/kv_offload/cpu/common.py @@ -1,16 +1,15 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from typing_extensions import override - from vllm.v1.kv_offload.base import BlockIDsLoadStoreSpec +class CPUOffloadingMetrics: + STORES_SKIPPED = "vllm:kv_offload_stores_skipped" + CPU_CACHE_USAGE_PERC = "vllm:kv_offload_cpu_cache_usage_perc" + CPU_ALLOCATION_SIZE = "vllm:kv_offload_cpu_allocation_size" + + class CPULoadStoreSpec(BlockIDsLoadStoreSpec): """ Spec for loading/storing a KV block to CPU memory. """ - - @staticmethod - @override - def medium() -> str: - return "CPU" diff --git a/vllm/v1/kv_offload/cpu/gpu_worker.py b/vllm/v1/kv_offload/cpu/gpu_worker.py index 4fbda71d9edf..c8b9915a1e56 100644 --- a/vllm/v1/kv_offload/cpu/gpu_worker.py +++ b/vllm/v1/kv_offload/cpu/gpu_worker.py @@ -7,29 +7,27 @@ import numpy as np import torch -from typing_extensions import override from vllm import _custom_ops as ops from vllm.logger import init_logger +from vllm.platforms import current_platform from vllm.triton_utils import HAS_TRITON, triton from vllm.utils.math_utils import cdiv -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.kv_offload.base import ( BlockIDsLoadStoreSpec, CanonicalKVCacheRef, CanonicalKVCaches, GPULoadStoreSpec, + LoadStoreSpec, + OffloadingWorker, + TransferResult, ) from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion from vllm.v1.kv_offload.cpu.swap_blocks_triton import ( THRESHOLD_BYTES, swap_blocks_batch, ) -from vllm.v1.kv_offload.worker.worker import ( - OffloadingHandler, - TransferResult, - TransferSpec, -) logger = init_logger(__name__) @@ -43,8 +41,10 @@ def _select_swap_blocks_fn( if gpu_to_cpu: return ops.swap_blocks_batch # Fall back to the C++ DMA path on platforms where Triton isn't usable - # (e.g. ROCm builds without Triton). - if not HAS_TRITON: + # (e.g. ROCm builds without Triton) or where GPU kernels cannot directly + # dereference CPU pointers (XPU lacks CUDA's unified virtual address space, + # so the Triton kernel's tl.load(cpu_ptr) is invalid on XPU). + if not HAS_TRITON or current_platform.is_xpu(): return ops.swap_blocks_batch page_sizes = [r.page_size_bytes for g in kv_cache_groups_data_refs for r in g] # Triton wins only on small, 8-byte-aligned payloads. @@ -92,7 +92,7 @@ def compute_sub_block_ptrs( Args: block_ids: array of block IDs at the tensor's native granularity. block_size_factor: number of sub-blocks per block. - output: pre-allocated int64 array to write pointers into. + output: pre-allocated pointer array to write pointers into. tensor: the source or destination tensor. skip_count: sub-blocks to skip in the first block. """ @@ -104,16 +104,16 @@ def compute_sub_block_ptrs( if block_size_factor == 1: # Fast path: 1:1 mapping, no sub-block expansion needed. - output[:] = base_ptr + block_ids[:num_sub_blocks] * row_stride + output[:] = base_ptr + block_ids.astype(np.uint64)[:num_sub_blocks] * row_stride return # Vectorized expansion for block_size_factor > 1. assert tensor.shape[1] % block_size_factor == 0 sub_block_size = tensor.shape[1] // block_size_factor - sub_offsets = np.arange(block_size_factor, dtype=np.int64) * sub_block_size + sub_offsets = np.arange(block_size_factor, dtype=np.uint64) * sub_block_size # (num_blocks, 1) + (1, block_size_factor) -> (num_blocks, block_size_factor) all_ptrs = ( - base_ptr + block_ids.astype(np.int64)[:, np.newaxis] * row_stride + base_ptr + block_ids.astype(np.uint64)[:, np.newaxis] * row_stride ) + sub_offsets[np.newaxis, :] # Flatten and apply skip_count / truncation flat = all_ptrs.ravel() @@ -122,6 +122,14 @@ def compute_sub_block_ptrs( def pin_mmap_region(region: SharedOffloadRegion) -> None: """Register the entire mmap as CUDA pinned memory via cudaHostRegister.""" + if not current_platform.is_cuda_alike(): + logger.info( + "Skipping mmap host registration on %s; cudaHostRegister is only " + "available on CUDA/ROCm.", + current_platform.device_name, + ) + return + rank = region.rank base_ptr = region._base.data_ptr() @@ -145,18 +153,19 @@ def pin_mmap_region(region: SharedOffloadRegion) -> None: def _new_descriptor_buffers( num_copy_ops: int, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - pin = is_pin_memory_available() + pin = PIN_MEMORY + # CUDA cache_kernels.cu requires int64; XPU DMA engine requires uint64. + ptr_dtype = torch.uint64 if current_platform.is_xpu() else torch.int64 return ( - torch.empty(num_copy_ops, dtype=torch.int64, pin_memory=pin), - torch.empty(num_copy_ops, dtype=torch.int64, pin_memory=pin), - torch.empty(num_copy_ops, dtype=torch.int64, pin_memory=pin), + torch.empty(num_copy_ops, dtype=ptr_dtype, pin_memory=pin), + torch.empty(num_copy_ops, dtype=ptr_dtype, pin_memory=pin), + torch.empty(num_copy_ops, dtype=ptr_dtype, pin_memory=pin), ) -class SingleDirectionOffloadingHandler(OffloadingHandler): +class SingleDirectionOffloadingHandler: """ - SingleDirectionOffloadingHandler handles transfers for a single direction, - either CPU->GPU or GPU->CPU. + Handles transfers for a single direction, either CPU->GPU or GPU->CPU. Transfers are guaranteed to be executed in order of their submission. Each transfer uses a unique CUDA stream, and its stream will start executing only after the streams of previous transfers have finished. @@ -190,7 +199,7 @@ def __init__( for gpu_tensor, cpu_tensor in zip(gpu_tensors, cpu_tensors): assert gpu_tensor.dtype == torch.int8 assert gpu_tensor.ndim == 2 - assert gpu_tensor.is_cuda + assert gpu_tensor.is_cuda or gpu_tensor.is_xpu assert cpu_tensor.dtype == torch.int8 assert cpu_tensor.ndim == 2 assert cpu_tensor.device.type == "cpu" @@ -215,7 +224,6 @@ def __init__( self.src_block_size_factor = 1 if self.gpu_to_cpu else block_size_factor self.dst_block_size_factor = block_size_factor if self.gpu_to_cpu else 1 - self.transfer_type = ("GPU", "CPU") if self.gpu_to_cpu else ("CPU", "GPU") # mmap_region to clean up on shutdown (gpu_to_cpu handler owns it) self._mmap_region = mmap_region # job_id -> event @@ -229,9 +237,9 @@ def __init__( # list of pinned descriptor buffer sets available for re-use self._buffer_pool: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = [] - @override - def transfer_async(self, job_id: int, transfer_spec: TransferSpec) -> bool: - src_spec, dst_spec = transfer_spec + def transfer_async( + self, job_id: int, src_spec: LoadStoreSpec, dst_spec: LoadStoreSpec + ) -> bool: assert isinstance(src_spec, BlockIDsLoadStoreSpec) assert isinstance(dst_spec, BlockIDsLoadStoreSpec) @@ -355,7 +363,9 @@ def transfer_async(self, job_id: int, transfer_spec: TransferSpec) -> bool: assert dst_offset == num_dst_blocks assert op_idx == num_copy_ops - stream = self._stream_pool.pop() if self._stream_pool else torch.cuda.Stream() + stream = ( + self._stream_pool.pop() if self._stream_pool else current_platform.Stream() + ) start_event = ( self._event_pool.pop() if self._event_pool @@ -369,7 +379,7 @@ def transfer_async(self, job_id: int, transfer_spec: TransferSpec) -> bool: if self.gpu_to_cpu: # wait for model computation to finish before offloading - stream.wait_stream(torch.cuda.current_stream()) + stream.wait_stream(current_platform.current_stream()) if self._transfers: last_transfer: Transfer = self._transfers[-1] last_event = last_transfer.end_event @@ -382,7 +392,7 @@ def transfer_async(self, job_id: int, transfer_spec: TransferSpec) -> bool: # writing; we must keep STREAM ordering so source reads are gated # by the transfer stream's wait_stream(compute) barrier. is_src_access_order_any = not self.gpu_to_cpu - with torch.cuda.stream(stream): + with current_platform.stream(stream): start_event.record(stream) if num_copy_ops > 0: self._swap_blocks_batch( @@ -410,7 +420,6 @@ def transfer_async(self, job_id: int, transfer_spec: TransferSpec) -> bool: # success return True - @override def get_finished(self) -> list[TransferResult]: results: list[TransferResult] = [] while self._transfers and self._transfers[0].end_event.query(): @@ -423,7 +432,6 @@ def get_finished(self) -> list[TransferResult]: success=True, transfer_size=transfer.num_bytes, transfer_time=transfer_time, - transfer_type=self.transfer_type, ) results.append(result) @@ -436,14 +444,12 @@ def get_finished(self) -> list[TransferResult]: del self._transfer_events[transfer.job_id] return results - @override def wait(self, job_ids: set[int]): for job_id in job_ids: event = self._transfer_events.get(job_id) if event is not None: event.synchronize() - @override def shutdown(self) -> None: while self._transfers: transfer = self._transfers.popleft() @@ -459,7 +465,14 @@ def shutdown(self) -> None: self._mmap_region = None -class CpuGpuOffloadingHandlers: +class CPUOffloadingWorker(OffloadingWorker): + """OffloadingWorker for CPU offloading. + + Composes two SingleDirectionOffloadingHandler instances (one for each + direction) and exposes them through the explicit submit_store / + submit_load API. + """ + def __init__( self, kv_caches: CanonicalKVCaches, @@ -467,9 +480,8 @@ def __init__( num_cpu_blocks: int, mmap_region: SharedOffloadRegion | None = None, ): - pin_memory = is_pin_memory_available() + pin_memory = PIN_MEMORY logger.info("Allocating %d CPU tensors...", len(kv_caches.tensors)) - self._mmap_region = mmap_region if mmap_region is not None and pin_memory: pin_mmap_region(mmap_region) @@ -503,7 +515,7 @@ def __init__( gpu_tensors.append(gpu_tensor) cpu_tensors.append(cpu_tensor) - self.gpu_to_cpu_handler = SingleDirectionOffloadingHandler( + self._store_handler = SingleDirectionOffloadingHandler( gpu_tensors=gpu_tensors, cpu_tensors=cpu_tensors, block_size_factor=block_size_factor, @@ -512,10 +524,33 @@ def __init__( mmap_region=mmap_region, ) - self.cpu_to_gpu_handler = SingleDirectionOffloadingHandler( + self._load_handler = SingleDirectionOffloadingHandler( gpu_tensors=gpu_tensors, cpu_tensors=cpu_tensors, block_size_factor=block_size_factor, kv_cache_groups_data_refs=kv_caches.group_data_refs, gpu_to_cpu=False, ) + + def submit_store( + self, job_id: int, src_spec: GPULoadStoreSpec, dst_spec: LoadStoreSpec + ) -> bool: + """Async GPU -> CPU.""" + return self._store_handler.transfer_async(job_id, src_spec, dst_spec) + + def submit_load( + self, job_id: int, src_spec: LoadStoreSpec, dst_spec: GPULoadStoreSpec + ) -> bool: + """Async CPU -> GPU.""" + return self._load_handler.transfer_async(job_id, src_spec, dst_spec) + + def get_finished(self) -> list[TransferResult]: + return self._store_handler.get_finished() + self._load_handler.get_finished() + + def wait(self, job_ids: set[int]) -> None: + self._store_handler.wait(job_ids) + self._load_handler.wait(job_ids) + + def shutdown(self) -> None: + self._store_handler.shutdown() + self._load_handler.shutdown() diff --git a/vllm/v1/kv_offload/cpu/manager.py b/vllm/v1/kv_offload/cpu/manager.py index a1d3a30ebb16..e7416bf63bd2 100644 --- a/vllm/v1/kv_offload/cpu/manager.py +++ b/vllm/v1/kv_offload/cpu/manager.py @@ -6,8 +6,13 @@ from typing_extensions import override +from vllm.distributed.kv_events import MEDIUM_CPU +from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import ( + OffloadingConnectorStats, +) from vllm.v1.kv_offload.base import ( LoadStoreSpec, + LookupResult, OffloadingEvent, OffloadingManager, OffloadKey, @@ -15,7 +20,10 @@ ReqContext, RequestOffloadingContext, ) -from vllm.v1.kv_offload.cpu.common import CPULoadStoreSpec +from vllm.v1.kv_offload.cpu.common import ( + CPULoadStoreSpec, + CPUOffloadingMetrics, +) from vllm.v1.kv_offload.cpu.policies.arc import ARCCachePolicy from vllm.v1.kv_offload.cpu.policies.base import BlockStatus, CachePolicy from vllm.v1.kv_offload.cpu.policies.lru import LRUCachePolicy @@ -44,7 +52,7 @@ def __init__( store_threshold: int = 1, max_tracker_size: int = 64_000, ): - self.medium: str = CPULoadStoreSpec.medium() + self.medium: str = MEDIUM_CPU self._num_blocks: int = num_blocks self._num_allocated_blocks: int = 0 self._free_list: list[int] = [] @@ -56,8 +64,13 @@ def __init__( f"Supported: {list(_CACHE_POLICIES)}" ) self._policy: CachePolicy = policy_cls(cache_capacity=num_blocks) + # Track the number of blocks in the cache that are evictable. i.e. ref_cnt 0. + self._num_evictable_cache_blocks: int = 0 + self.store_threshold: int = store_threshold self.max_tracker_size: int = max_tracker_size + self.stores_skipped_in_current_batch: int = 0 + self.allocation_sizes_in_current_batch: list[int] = [] # Number of block references. It is ordered so can evict the LRU entry in O(1). self.counts: OrderedDict[OffloadKey, int] | None = ( @@ -102,7 +115,7 @@ def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: return RequestOffloadingContext() @override - def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: if self.counts is not None: if key in self.counts: self.counts.move_to_end(key) @@ -113,10 +126,10 @@ def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: self.counts[key] = 1 block = self._policy.get(key) if block is None: - return False + return LookupResult.MISS if not block.is_ready: - return None # write in-flight; caller should retry - return True + return LookupResult.HIT_PENDING + return LookupResult.HIT @override def prepare_load( @@ -129,13 +142,17 @@ def prepare_load( block = self._policy.get(key) assert block is not None, f"Block {key!r} not found in cache" assert block.is_ready, f"Block {key!r} is not ready for reading" + if block.ref_cnt == 0: + self._policy.mark_non_evictable(key) + self._num_evictable_cache_blocks -= 1 # ref_cnt 0 -> 1 + assert self._num_evictable_cache_blocks >= 0 block.ref_cnt += 1 blocks.append(block) return self._get_load_store_spec(keys, blocks) @override def touch(self, keys: Collection[OffloadKey], req_context: ReqContext) -> None: - self._policy.touch(keys) + self._policy.touch(keys, req_context) @override def complete_load( @@ -146,6 +163,9 @@ def complete_load( assert block is not None, f"Block {key!r} not found" assert block.ref_cnt > 0, f"Block {key!r} ref_cnt is already 0" block.ref_cnt -= 1 + if block.ref_cnt == 0: + self._num_evictable_cache_blocks += 1 # ref_cnt 1 -> 0 + self._policy.mark_evictable(key) @override def prepare_store( @@ -154,7 +174,9 @@ def prepare_store( req_context: ReqContext, ) -> PrepareStoreOutput | None: if self.counts is not None: + num_keys = len(keys) keys = [k for k in keys if self.counts.get(k, 0) >= self.store_threshold] + self.stores_skipped_in_current_batch += num_keys - len(keys) # filter out blocks that are already stored keys_to_store = [k for k in keys if self._policy.get(k) is None] @@ -165,16 +187,28 @@ def prepare_store( evicted_keys=[], ) + self.allocation_sizes_in_current_batch.append(len(keys_to_store)) num_blocks_to_evict = len(keys_to_store) - self._get_num_free_blocks() to_evict: list[OffloadKey] = [] if num_blocks_to_evict > 0: + if num_blocks_to_evict > self._num_evictable_cache_blocks: + # Eviction will fail. + return None + # There is a still a chance for eviction failure as some of the + # idle blocks might be in the protected list. + # Blocks from the original input are excluded from eviction candidates: # a block that was already stored must remain in the cache after this call. protected = set(keys) evicted = self._policy.evict(num_blocks_to_evict, protected) if evicted is None: return None + + # cache-policy removes only idle blocks. + self._num_evictable_cache_blocks -= len(evicted) + assert self._num_evictable_cache_blocks >= 0 + for key, block in evicted: self._free_block(block) to_evict.append(key) @@ -219,6 +253,8 @@ def complete_store( block = self._policy.get(key) if block is not None and not block.is_ready: block.ref_cnt = 0 + self._num_evictable_cache_blocks += 1 + self._policy.mark_evictable(key) stored_keys.append(key) else: for key in keys: @@ -244,6 +280,7 @@ def reset_cache(self) -> None: # flushes in-flight load job IDs to the workers before any new stores # can begin, preventing a cross-direction data race on reused offload block IDs. self._policy.clear() + self._num_evictable_cache_blocks = 0 self._free_list.clear() self._num_allocated_blocks = 0 @@ -253,3 +290,30 @@ def take_events(self) -> Iterable[OffloadingEvent]: if self.events is not None: yield from self.events self.events.clear() + + def get_stats(self) -> OffloadingConnectorStats | None: + stats = OffloadingConnectorStats() + + # Compute cache usage. + num_used = ( + self._num_allocated_blocks + - len(self._free_list) + - self._num_evictable_cache_blocks + ) + usage = num_used / self._num_blocks if self._num_blocks > 0 else 0.0 + stats.set_gauge(CPUOffloadingMetrics.CPU_CACHE_USAGE_PERC, usage) + + for allocation_size in self.allocation_sizes_in_current_batch: + stats.observe_histogram( + CPUOffloadingMetrics.CPU_ALLOCATION_SIZE, allocation_size + ) + self.allocation_sizes_in_current_batch.clear() + + if self.store_threshold >= 2: + stats.increase_counter( + CPUOffloadingMetrics.STORES_SKIPPED, + self.stores_skipped_in_current_batch, + ) + self.stores_skipped_in_current_batch = 0 + + return stats diff --git a/vllm/v1/kv_offload/cpu/policies/arc.py b/vllm/v1/kv_offload/cpu/policies/arc.py index 7d22e5186549..f682a47e45f1 100644 --- a/vllm/v1/kv_offload/cpu/policies/arc.py +++ b/vllm/v1/kv_offload/cpu/policies/arc.py @@ -5,7 +5,7 @@ from typing_extensions import override -from vllm.v1.kv_offload.base import OffloadKey +from vllm.v1.kv_offload.base import OffloadKey, ReqContext from vllm.v1.kv_offload.cpu.policies.base import BlockStatus, CachePolicy @@ -72,7 +72,7 @@ def remove(self, key: OffloadKey) -> None: self.t2.pop(key, None) @override - def touch(self, keys: Iterable[OffloadKey]) -> None: + def touch(self, keys: Iterable[OffloadKey], req_context: ReqContext) -> None: for key in reversed(list(keys)): if key in self.t1: block = self.t1.pop(key) diff --git a/vllm/v1/kv_offload/cpu/policies/base.py b/vllm/v1/kv_offload/cpu/policies/base.py index 0febfe90d613..2b6681e49920 100644 --- a/vllm/v1/kv_offload/cpu/policies/base.py +++ b/vllm/v1/kv_offload/cpu/policies/base.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod from collections.abc import Iterable -from vllm.v1.kv_offload.base import OffloadKey +from vllm.v1.kv_offload.base import OffloadKey, ReqContext class BlockStatus(ctypes.Structure): @@ -57,8 +57,14 @@ def remove(self, key: OffloadKey) -> None: """Remove a block (used to clean up after a failed store).""" @abstractmethod - def touch(self, keys: Iterable[OffloadKey]) -> None: - """Mark blocks as recently used.""" + def touch(self, keys: Iterable[OffloadKey], req_context: ReqContext) -> None: + """ + Mark blocks as recently used. + + Args: + keys: Blocks to mark as recently used. + req_context: Per-request context for the request touching these blocks. + """ @abstractmethod def evict( @@ -82,3 +88,11 @@ def clear(self) -> None: Ghost lists and adaptive state are also reset. """ + + def mark_evictable(self, key: OffloadKey) -> None: + """Called when a block's ref_cnt transitions to 0.""" + return + + def mark_non_evictable(self, key: OffloadKey) -> None: + """Called when a block's ref_cnt transitions from 0.""" + return diff --git a/vllm/v1/kv_offload/cpu/policies/lru.py b/vllm/v1/kv_offload/cpu/policies/lru.py index 75fbc6015e11..efa24fe90333 100644 --- a/vllm/v1/kv_offload/cpu/policies/lru.py +++ b/vllm/v1/kv_offload/cpu/policies/lru.py @@ -5,16 +5,23 @@ from typing_extensions import override -from vllm.v1.kv_offload.base import OffloadKey +from vllm.v1.kv_offload.base import OffloadKey, ReqContext from vllm.v1.kv_offload.cpu.policies.base import BlockStatus, CachePolicy class LRUCachePolicy(CachePolicy): - """LRU cache policy backed by a single OrderedDict.""" + """ + LRU Caching policy that keeps a dedicated evictable list for fast eviction. + A use is indicated by, + - First time the key is added (store). + - Load job completion + - touch + """ def __init__(self, cache_capacity: int): - # cache_capacity unused by LRU but accepted for a uniform constructor - self.blocks: OrderedDict[OffloadKey, BlockStatus] = OrderedDict() + # Blocks with ref_cnt 0 (not participating in any loads/stores) ordered in LRU + self.evictable_blocks: OrderedDict[OffloadKey, None] = OrderedDict() + self.blocks: dict[OffloadKey, BlockStatus] = {} @override def get(self, key: OffloadKey) -> BlockStatus | None: @@ -23,19 +30,25 @@ def get(self, key: OffloadKey) -> BlockStatus | None: @override def insert(self, key: OffloadKey, block: BlockStatus) -> None: self.blocks[key] = block + if block.ref_cnt == 0: + self.evictable_blocks[key] = None @override def remove(self, key: OffloadKey) -> None: del self.blocks[key] + self.evictable_blocks.pop(key, None) @override - def touch(self, keys: Iterable[OffloadKey]) -> None: + def touch(self, keys: Iterable[OffloadKey], req_context: ReqContext) -> None: for key in reversed(list(keys)): - if key in self.blocks: - self.blocks.move_to_end(key) + if key in self.evictable_blocks: + self.evictable_blocks.move_to_end(key) + # active blocks are untouched as they are non-evictable now. They + # will eventually reach the end of evictable_blocks when they finish. @override def clear(self) -> None: + self.evictable_blocks.clear() self.blocks.clear() @override @@ -44,14 +57,33 @@ def evict( ) -> list[tuple[OffloadKey, BlockStatus]] | None: if n == 0: return [] + candidates: list[tuple[OffloadKey, BlockStatus]] = [] - for key, block in self.blocks.items(): - if block.ref_cnt == 0 and key not in protected: - candidates.append((key, block)) - if len(candidates) == n: - break + for key, _ in self.evictable_blocks.items(): + if key in protected: + continue + + block = self.blocks[key] + assert block.ref_cnt == 0 + candidates.append((key, block)) + if len(candidates) == n: + break + if len(candidates) < n: return None for key, _ in candidates: + del self.evictable_blocks[key] del self.blocks[key] return candidates + + @override + def mark_evictable(self, key: OffloadKey) -> None: + # blocks can become evictable when, + # store completes - i.e. ref_cnt -1 -> 0 # not in evictable list + # all loads complete - i.e ref_cnt 1 -> 0 # not in evictable list + self.evictable_blocks[key] = None + + @override + def mark_non_evictable(self, key: OffloadKey) -> None: + # key must have been in the evictable list. + del self.evictable_blocks[key] diff --git a/vllm/v1/kv_offload/cpu/shared_offload_region.py b/vllm/v1/kv_offload/cpu/shared_offload_region.py index b9b415f12d13..d5400e0ca723 100644 --- a/vllm/v1/kv_offload/cpu/shared_offload_region.py +++ b/vllm/v1/kv_offload/cpu/shared_offload_region.py @@ -7,6 +7,7 @@ import torch from vllm.logger import init_logger +from vllm.platforms import current_platform logger = init_logger(__name__) @@ -171,12 +172,15 @@ def create_kv_memoryview(self) -> memoryview: def cleanup(self) -> None: if self.is_pinned and self._base is not None: - base_ptr = self._base.data_ptr() - result = torch.cuda.cudart().cudaHostUnregister(base_ptr) - if result.value != 0: - logger.warning( - "cudaHostUnregister failed for rank=%d (code=%d)", self.rank, result - ) + if current_platform.is_cuda_alike(): + base_ptr = self._base.data_ptr() + result = torch.cuda.cudart().cudaHostUnregister(base_ptr) + if result.value != 0: + logger.warning( + "cudaHostUnregister failed for rank=%d (code=%d)", + self.rank, + result, + ) self.is_pinned = False # Release views before _base: each view holds a _base reference and a # direct StorageImpl reference. Freeing views first lets both refcounts diff --git a/vllm/v1/kv_offload/cpu/spec.py b/vllm/v1/kv_offload/cpu/spec.py index 8791ff5d391b..26ea3728191f 100644 --- a/vllm/v1/kv_offload/cpu/spec.py +++ b/vllm/v1/kv_offload/cpu/spec.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from collections.abc import Iterator +from typing import Any from typing_extensions import override @@ -10,20 +10,55 @@ from vllm.v1.kv_cache_interface import KVCacheConfig from vllm.v1.kv_offload.base import ( CanonicalKVCaches, - GPULoadStoreSpec, - LoadStoreSpec, + OffloadingCounterMetadata, + OffloadingGaugeMetadata, + OffloadingHistogramMetadata, OffloadingManager, + OffloadingMetricMetadata, OffloadingSpec, + OffloadingWorker, ) -from vllm.v1.kv_offload.cpu.common import CPULoadStoreSpec -from vllm.v1.kv_offload.cpu.gpu_worker import CpuGpuOffloadingHandlers +from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics +from vllm.v1.kv_offload.cpu.gpu_worker import CPUOffloadingWorker from vllm.v1.kv_offload.cpu.manager import CPUOffloadingManager -from vllm.v1.kv_offload.worker.worker import OffloadingHandler class CPUOffloadingSpec(OffloadingSpec): BLOCK_SIZE_ALIGNMENT = 1 + @classmethod + def build_metric_definitions( + cls, extra_config: dict[str, Any] + ) -> dict[str, OffloadingMetricMetadata]: + definitions: dict[str, OffloadingMetricMetadata] = { + CPUOffloadingMetrics.CPU_CACHE_USAGE_PERC: OffloadingGaugeMetadata( + documentation=( + "Fraction of CPU KV-cache space currently pinned by active " + "transfers (0.0 = idle, 1.0 = saturated). Sustained high " + "values indicate transfers (stores or promotions) may be " + "dropped due to insufficient capacity." + ), + ), + CPUOffloadingMetrics.CPU_ALLOCATION_SIZE: OffloadingHistogramMetadata( + documentation=( + "Histogram of the number of CPU blocks requested by each " + "KV offload prepare_store call." + ), + buckets=(1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144), + ), + } + store_threshold = int(extra_config.get("store_threshold", 0)) + if store_threshold >= 2: + definitions[CPUOffloadingMetrics.STORES_SKIPPED] = ( + OffloadingCounterMetadata( + documentation=( + "Number of KV offload stores skipped because the reuse " + "threshold was not reached." + ), + ) + ) + return definitions + def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig): super().__init__(vllm_config, kv_cache_config) @@ -39,7 +74,15 @@ def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig): self.cpu_page_size_per_worker = 0 assert kv_cache_config is not None if kv_cache_config.num_blocks > 0 and world_size > 0: - total_gpu_kv_bytes = sum(t.size for t in kv_cache_config.kv_cache_tensors) + is_packed = any(t.block_stride for t in kv_cache_config.kv_cache_tensors) + assert not is_packed or all( + t.block_stride for t in kv_cache_config.kv_cache_tensors + ) + total_gpu_kv_bytes = ( + kv_cache_config.kv_cache_tensors[0].size + if is_packed + else sum(t.size for t in kv_cache_config.kv_cache_tensors) + ) kv_bytes_per_block = ( total_gpu_kv_bytes // kv_cache_config.num_blocks ) * world_size @@ -66,18 +109,13 @@ def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig): self._manager: OffloadingManager | None = None # worker-side - self._handlers: CpuGpuOffloadingHandlers | None = None + self._worker: CPUOffloadingWorker | None = None self.eviction_policy: str = self.extra_config.get("eviction_policy", "lru") @override def get_manager(self) -> OffloadingManager: if not self._manager: - kv_events_config = self.vllm_config.kv_events_config - enable_events = ( - kv_events_config is not None and kv_events_config.enable_kv_cache_events - ) - # store_threshold: how many times a block must appear in lookup() # before it is eligible for CPU offloading. Values < 2 disable # filtering (a threshold of 1 equals no filter; 0 is the default). @@ -89,30 +127,28 @@ def get_manager(self) -> OffloadingManager: self._manager = CPUOffloadingManager( num_blocks=self.num_blocks, cache_policy=self.eviction_policy, # type: ignore[arg-type] - enable_events=enable_events, + enable_events=self.kv_events_config.enable_kv_cache_events, store_threshold=store_threshold, max_tracker_size=max_tracker_size, ) return self._manager - def create_handlers(self, kv_caches: CanonicalKVCaches) -> CpuGpuOffloadingHandlers: - return CpuGpuOffloadingHandlers( + def create_worker(self, kv_caches: CanonicalKVCaches) -> CPUOffloadingWorker: + return CPUOffloadingWorker( kv_caches=kv_caches, block_size_factor=self.block_size_factor, num_cpu_blocks=self.num_blocks, ) @override - def get_handlers( - self, kv_caches: CanonicalKVCaches - ) -> Iterator[tuple[type[LoadStoreSpec], type[LoadStoreSpec], OffloadingHandler]]: - if not self._handlers: - if not current_platform.is_cuda_alike(): + def get_worker(self, kv_caches: CanonicalKVCaches) -> OffloadingWorker: + if not self._worker: + if not (current_platform.is_cuda_alike() or current_platform.is_xpu()): raise Exception( - "CPU Offloading is currently only supported on CUDA-alike GPUs" + "CPU Offloading is currently only supported on CUDA-alike " + "and XPU GPUs" ) - self._handlers = self.create_handlers(kv_caches) + self._worker = self.create_worker(kv_caches) - assert self._handlers is not None - yield GPULoadStoreSpec, CPULoadStoreSpec, self._handlers.gpu_to_cpu_handler - yield CPULoadStoreSpec, GPULoadStoreSpec, self._handlers.cpu_to_gpu_handler + assert self._worker is not None + return self._worker diff --git a/vllm/v1/kv_offload/factory.py b/vllm/v1/kv_offload/factory.py index 8b967f771b04..abbc9c0ede79 100644 --- a/vllm/v1/kv_offload/factory.py +++ b/vllm/v1/kv_offload/factory.py @@ -30,11 +30,7 @@ def loader() -> type[OffloadingSpec]: cls._registry[name] = loader @classmethod - def create_spec( - cls, - config: "VllmConfig", - kv_cache_config: "KVCacheConfig", - ) -> OffloadingSpec: + def get_spec_cls(cls, config: "VllmConfig") -> type[OffloadingSpec]: kv_transfer_config = config.kv_transfer_config assert kv_transfer_config is not None extra_config = kv_transfer_config.kv_connector_extra_config @@ -48,6 +44,20 @@ def create_spec( spec_module = importlib.import_module(spec_module_path) spec_cls = getattr(spec_module, spec_name) assert issubclass(spec_cls, OffloadingSpec) + return spec_cls + + @classmethod + def create_spec( + cls, + config: "VllmConfig", + kv_cache_config: "KVCacheConfig", + ) -> OffloadingSpec: + kv_transfer_config = config.kv_transfer_config + assert kv_transfer_config is not None + spec_name = kv_transfer_config.kv_connector_extra_config.get( + "spec_name", "CPUOffloadingSpec" + ) + spec_cls = cls.get_spec_cls(config) logger.info("Creating offloading spec with name: %s", spec_name) return spec_cls(config, kv_cache_config) diff --git a/vllm/v1/kv_offload/file_mapper.py b/vllm/v1/kv_offload/file_mapper.py index 7184a5d1ce13..d8fadb09988e 100644 --- a/vllm/v1/kv_offload/file_mapper.py +++ b/vllm/v1/kv_offload/file_mapper.py @@ -4,6 +4,7 @@ import hashlib import json +from vllm.v1.kv_cache_interface import FullAttentionSpec, MLAAttentionSpec from vllm.v1.kv_offload.base import ( OffloadingSpec, OffloadKey, @@ -81,6 +82,18 @@ def from_offloading_spec( } for group in kv_cache_config.kv_cache_groups ] + # Only a single full-attention group is parallelism-invariant. MLA is + # excluded: its latent KV is replicated per rank, never head-sharded. + # The V2 model runner is excluded: its KV layout is not known to be + # parallelism-invariant. + groups = kv_cache_config.kv_cache_groups + spec = groups[0].kv_cache_spec if len(groups) == 1 else None + parallel_agnostic = ( + parallel_agnostic + and not vllm_config.use_v2_model_runner + and isinstance(spec, FullAttentionSpec) + and not isinstance(spec, MLAAttentionSpec) + ) return cls( root_dir=root_dir, model_name=vllm_config.model_config.model, diff --git a/vllm/v1/kv_offload/tiering/async_lookup.py b/vllm/v1/kv_offload/tiering/async_lookup.py new file mode 100644 index 000000000000..c75a9604009b --- /dev/null +++ b/vllm/v1/kv_offload/tiering/async_lookup.py @@ -0,0 +1,231 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +AsyncLookupManager: per-tier async lookup manager for secondary tier +existence checks. + +Each secondary tier that wants non-blocking lookups composes its own +AsyncLookupManager instance internally. The manager maintains lookup +state and uses a background thread to execute batch_lookup() calls. + +Locking design +-------------- +There is no explicit lock. Thread safety is achieved by ownership: + +* _lookup_state and _lookup_batch are owned exclusively by the scheduler + thread. lookup(), flush(), and cleanup() read and write them directly. + +* _lookup_queue is written by the scheduler (flush → put_nowait, one item + per step) and read by the background thread (get). queue.Queue is + thread-safe. + +* _pending_results is written by the background thread (put) and read by + the scheduler (get_nowait inside drain_results). queue.SimpleQueue is + thread-safe by design. + +lookup() accumulates new keys in _lookup_batch without touching the queue. +flush() is called once per step from the tier's on_schedule_end(), posting +the entire batch as a single queue item so the background thread sees one +batch per step. +drain_results() is called before any lookup() calls in the same step, so +lookup() is a pure OrderedDict operation. +""" + +import queue +import threading +from abc import ABC, abstractmethod +from collections.abc import Iterable +from dataclasses import dataclass, field + +from vllm.logger import init_logger +from vllm.v1.kv_offload.base import OffloadKey, ReqContext + +logger = init_logger(__name__) + + +@dataclass(slots=True) +class LookupState: + result: bool | None = None # True (found), False (not found), None + request_ids: set[str] = field(default_factory=set) # requests asking for the lookup + + +class AsyncLookupManager(ABC): + """ + Per-tier async lookup manager for secondary tier existence checks. + + Each secondary tier that wants non-blocking lookups composes its own + AsyncLookupManager instance internally. The manager maintains lookup + state (cache, queue) and uses a background thread to execute the actual + batch_lookup() calls. + + Subclasses implement only batch_lookup() — all queue management, + state tracking, and result delivery is provided by this base class. + + The owning tier delegates its lookup(), on_schedule_end(), and + on_request_finished() to this manager: + - lookup() → drain_results() + lookup state check + - on_schedule_end() → flush() + - on_request_finished() → cleanup() + """ + + def __init__( + self, + tier_type: str, + ) -> None: + self._tier_type = tier_type + + # key → LookupState; scheduler-owned, no lock needed. + self._lookup_state: dict[OffloadKey, LookupState] = {} + # req_id → keys looked up by that request (reverse index for cleanup). + self._req_keys: dict[str, set[OffloadKey]] = {} + + # Accumulates (key, req_context) pairs during lookup() calls. + # Flushed as one queue item per step by flush(). + self._lookup_batch: list[tuple[OffloadKey, ReqContext]] = [] + + # Scheduler → worker: one full step's batch per item. + # None is used as a shutdown sentinel. + self._lookup_queue: queue.SimpleQueue[ + list[tuple[OffloadKey, ReqContext]] | None + ] = queue.SimpleQueue() + + # Worker → scheduler: completed result batches. + # Each item is a list of (key, found) pairs. + # SimpleQueue is explicitly thread-safe for one writer / one reader. + self._pending_results: queue.SimpleQueue[list[tuple[OffloadKey, bool]]] = ( + queue.SimpleQueue() + ) + self._need_to_drain: bool = False + + self._thread = threading.Thread( + target=self._worker, + name=f"vllm_offloading_lookup_{tier_type}", + daemon=True, + ) + self._thread.start() + + @abstractmethod + def batch_lookup( + self, keys: list[OffloadKey], req_context: ReqContext + ) -> Iterable[bool]: + """ + Check whether a batch of blocks exist in this tier. + + Called from the worker thread — must be synchronous and must not + touch the primary tier or scheduler state. + + Returns a list parallel to keys: True if present, False if not. + """ + ... + + # ------------------------------------------------------------------ + # Scheduler-thread API + # ------------------------------------------------------------------ + + def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: + """ + Non-blocking lookup called from the scheduler thread. + + Returns: + True — block is present in this tier. + False — block is not present in this tier. + None — result not yet available; retry next step. + """ + if self._need_to_drain: + self.drain_results() + self._need_to_drain = False + req_id = req_context.req_id + state = self._lookup_state.get(key) + if state is None: + state = LookupState() + self._lookup_state[key] = state + self._lookup_batch.append((key, req_context)) + state.request_ids.add(req_id) + self._req_keys.setdefault(req_id, set()).add(key) + return state.result + + def flush(self) -> None: + """Post this step's accumulated keys to the worker thread. + + Called once per step from on_schedule_end() after all lookup() calls + are done. The worker receives the full batch and processes it during + the model-execution window, maximising time available before the next + step's drain_results(). Safe to call with an empty batch (no-op). + """ + self._need_to_drain = True + if self._lookup_batch: + self._lookup_queue.put(self._lookup_batch) + self._lookup_batch = [] + + def drain_results(self) -> None: + """Apply pending worker results to _lookup_state. + + Called from lookup() before checking state. + """ + while True: + try: + batch = self._pending_results.get_nowait() + except queue.Empty: + break + for key, result in batch: + state = self._lookup_state.get(key) + if state is not None: + state.result = result + + def cleanup(self, req_id: str) -> None: + """Remove entries no longer needed by any active request. + + Called from the tier's on_request_finished(). Uses the reverse + index to visit only keys associated with this request. + """ + for key in self._req_keys.pop(req_id, ()): + state = self._lookup_state[key] + state.request_ids.discard(req_id) + if not state.request_ids: + del self._lookup_state[key] + + def shutdown(self) -> None: + """Stop the worker thread.""" + self._lookup_queue.put(None) # unblock _worker from _lookup_queue.get() + self._thread.join() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _worker(self) -> None: + while True: + pending = self._lookup_queue.get() + if pending is None: + break + + # Group by req_id. + batches: dict[str, tuple[ReqContext, list[OffloadKey]]] = {} + for key, req_context in pending: + req_id = req_context.req_id + if req_id not in batches: + batches[req_id] = (req_context, []) + batches[req_id][1].append(key) + + if not batches: + continue + + results: list[tuple[OffloadKey, bool]] = [] + for req_context, keys in batches.values(): + try: + hits = self.batch_lookup(keys, req_context) + except Exception as exc: + logger.warning( + "batch_lookup failed on tier %s for %d keys: %s", + self._tier_type, + len(keys), + exc, + ) + hits = (False for _ in keys) + + for key, hit in zip(keys, hits): + results.append((key, hit)) + + # Post the entire batch as one item — no lock needed. + if results: + self._pending_results.put(results) diff --git a/vllm/v1/kv_offload/tiering/base.py b/vllm/v1/kv_offload/tiering/base.py index d4f0cefe5eb6..f83113e137cb 100644 --- a/vllm/v1/kv_offload/tiering/base.py +++ b/vllm/v1/kv_offload/tiering/base.py @@ -7,13 +7,24 @@ from abc import ABC, abstractmethod from collections.abc import Collection, Iterable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np -from vllm.v1.kv_offload.base import OffloadKey, ReqContext, RequestOffloadingContext +from vllm.v1.kv_offload.base import ( + LookupResult, + OffloadingEvent, + OffloadingMetricMetadata, + OffloadKey, + ReqContext, + RequestOffloadingContext, + ScheduleEndContext, +) if TYPE_CHECKING: + from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import ( + OffloadingConnectorStats, + ) from vllm.v1.kv_offload.base import OffloadingSpec # Type alias for job IDs used in async transfer tracking @@ -39,6 +50,43 @@ class JobResult: success: bool +class ParentManager(ABC): + """Interface for secondary tiers to call back into the tiering manager. + + Passed to secondary tiers via serve_external_requests() each step. + The _SecondaryTierFacingParent wrapper implements this, automatically + excluding the calling tier from fan-out operations. + + Required call sequence for each remote request: + 1. on_new_request(req_context) — set up per-request state + 2. lookup(key, req_context) — check block availability + (repeat per block) + 3. create_store_job(keys, req_context) — pin blocks and get a + job handle + 4. on_request_finished(req_context) — clean up per-request state + + Steps 2-3 may be interleaved. Step 4 must be called even if no + blocks were found, to avoid leaking async lookup state (e.g. in + the fs tier's AsyncLookupManager). + """ + + @abstractmethod + def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: ... + + @abstractmethod + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: ... + + @abstractmethod + def create_store_job( + self, + keys: Collection[OffloadKey], + req_context: ReqContext, + ) -> JobMetadata: ... + + @abstractmethod + def on_request_finished(self, req_context: ReqContext) -> None: ... + + class SecondaryTierManager(ABC): """ Abstract interface for managing a single non-primary offloading tier. @@ -71,7 +119,7 @@ def __init__( self.tier_type = tier_type @abstractmethod - def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: """ Check whether a block exists in this secondary tier. @@ -80,9 +128,9 @@ def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: req_context: per-request context (e.g. kv_transfer_params). Returns: - True if the block is present and ready, - False if not found, - or None if the block is being transferred (retry later). + HIT if the block is present and ready, + MISS if not found, + or RETRY if the block is being transferred (retry later). """ pass @@ -153,6 +201,18 @@ def get_finished_jobs(self) -> Iterable[JobResult]: """ pass + def has_pending_work(self) -> bool: + """Whether this tier needs the engine to keep stepping. + + While True, on_schedule_end() and get_finished_jobs() continue + to be called even when no requests are scheduled. + """ + return False + + def take_events(self) -> Iterable[OffloadingEvent]: + """Take KV events for storage state owned by this tier.""" + return () + def touch(self, keys: Collection[OffloadKey], req_context: ReqContext): """ Mark blocks as recently used for eviction policy. @@ -180,19 +240,63 @@ def on_request_finished(self, req_context: ReqContext) -> None: """ Called when a request has finished. + By the time this is called, all per-request calls for this request + (submit_store, submit_load, touch) have already been issued, and none + will follow. Note this does NOT imply the tier's transfers have + completed: jobs already submitted may still be in flight and will + report via get_finished_jobs(). This is the right place to release + per-request bookkeeping. + Args: req_context: per-request context. """ return - def on_schedule_end(self) -> None: + def serve_external_requests(self, parent: ParentManager) -> None: + """Process remotely-originated requests using the parent manager. + + Called once per scheduler step, BEFORE _flush_pending_promotions(). + The parent handle is valid only for the duration of this call. + Tiers that don't serve external requests leave this as a no-op. + """ + return + + def on_schedule_end(self, context: ScheduleEndContext) -> None: """Called once at the end of each scheduler step. - Secondary tiers may override this for per-step cleanup or - deferred work submission. + Args: + context: Per-step context from the scheduler. """ return + @abstractmethod + def drain_jobs(self) -> None: + """Block until every submitted load/store job has completed or failed. + + After this returns, no tier I/O is touching the primary memoryview, + and every submitted job's result is available from `get_finished_jobs()` + (yielded by a prior call or queued for the next one). Used by + `TieringOffloadingManager.reset_cache` to release primary slots + without racing with in-flight transfers. + + Implementations must not abort a mid-flight transfer: a partial copy + would corrupt either the primary memoryview or the secondary backing + store. Queued (not-yet-started) transfers may be cancelled, but their + failure result must still appear in `get_finished_jobs()`. + """ + pass + def shutdown(self) -> None: """Release resources held by this tier (threads, connections, etc.).""" return + + @classmethod + def build_metric_definitions( + cls, extra_config: dict[str, Any] + ) -> dict[str, OffloadingMetricMetadata]: + """Return Prometheus metric definitions emitted by this tier.""" + return {} + + def get_stats(self) -> "OffloadingConnectorStats | None": + """Return and reset metric observations collected by this tier.""" + return None diff --git a/vllm/v1/kv_offload/tiering/example/manager.py b/vllm/v1/kv_offload/tiering/example/manager.py index caf1d2c71b43..a9e4e4f689cb 100644 --- a/vllm/v1/kv_offload/tiering/example/manager.py +++ b/vllm/v1/kv_offload/tiering/example/manager.py @@ -15,7 +15,12 @@ from typing_extensions import override -from vllm.v1.kv_offload.base import OffloadKey, ReqContext, RequestOffloadingContext +from vllm.v1.kv_offload.base import ( + LookupResult, + OffloadKey, + ReqContext, + RequestOffloadingContext, +) from vllm.v1.kv_offload.tiering.base import ( JobMetadata, JobResult, @@ -67,7 +72,7 @@ def __init__( self.completed_jobs: list[JobResult] = [] @override - def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: """ Check whether a block exists in this secondary tier. @@ -76,9 +81,9 @@ def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: req_context: Per-request context. Returns: - True if the block is present, False if not found. + HIT if the block is present, MISS if not found. """ - return key in self.blocks + return LookupResult.HIT if key in self.blocks else LookupResult.MISS @override def submit_store(self, job_metadata: JobMetadata) -> None: @@ -142,6 +147,12 @@ def get_finished_jobs(self) -> Iterable[JobResult]: def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: return RequestOffloadingContext() + @override + def drain_jobs(self) -> None: + """Synchronous tier — submit_*() returns only after the operation + completes, so there is nothing to wait for.""" + return + def get_num_blocks(self) -> int: """Get the number of blocks currently stored in this tier.""" return len(self.blocks) diff --git a/vllm/v1/kv_offload/tiering/factory.py b/vllm/v1/kv_offload/tiering/factory.py index cbde45dfcf88..180c87d09497 100644 --- a/vllm/v1/kv_offload/tiering/factory.py +++ b/vllm/v1/kv_offload/tiering/factory.py @@ -31,25 +31,27 @@ def create_secondary_tier( primary_kv_view: memoryview, offloading_spec: "OffloadingSpec", ) -> SecondaryTierManager: + tier_cls = cls.get_tier_class(tier_config) config = tier_config.copy() + tier_type = config.pop("type") + return tier_cls( + offloading_spec=offloading_spec, + primary_kv_view=primary_kv_view, + tier_type=tier_type, + **config, + ) - tier_type = config.pop("type", None) + @classmethod + def get_tier_class(cls, tier_config: dict) -> type[SecondaryTierManager]: + tier_type = tier_config.get("type") if not tier_type: raise ValueError("Secondary tier configuration must include 'type'") - if tier_type not in cls._registry: raise ValueError( f"Unknown secondary tier type: {tier_type!r}. " f"Supported types: {list(cls._registry)}" ) - - tier_cls = cls._registry[tier_type]() - return tier_cls( - offloading_spec=offloading_spec, - primary_kv_view=primary_kv_view, - tier_type=tier_type, - **config, - ) + return cls._registry[tier_type]() SecondaryTierFactory.register_tier( @@ -63,3 +65,15 @@ def create_secondary_tier( "vllm.v1.kv_offload.tiering.fs.manager", "FileSystemTierManager", ) + +SecondaryTierFactory.register_tier( + "p2p", + "vllm.v1.kv_offload.tiering.p2p.manager", + "P2PSecondaryTierManager", +) + +SecondaryTierFactory.register_tier( + "obj", + "vllm.v1.kv_offload.tiering.obj.manager", + "ObjectStoreSecondaryTierManager", +) diff --git a/vllm/v1/kv_offload/tiering/fs/manager.py b/vllm/v1/kv_offload/tiering/fs/manager.py index a33de02f43dd..a38ac8eb4044 100644 --- a/vllm/v1/kv_offload/tiering/fs/manager.py +++ b/vllm/v1/kv_offload/tiering/fs/manager.py @@ -21,15 +21,24 @@ from collections.abc import Iterable from typing import TYPE_CHECKING +try: + from vllm.fs_io_C import batch_lookup as batch_lookup_C + + _HAS_BATCH_LOOKUP_C = True +except ImportError: + _HAS_BATCH_LOOKUP_C = False + from typing_extensions import override from vllm.logger import init_logger -from vllm.v1.kv_offload.base import OffloadKey, ReqContext +from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext from vllm.v1.kv_offload.file_mapper import FileMapper +from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager from vllm.v1.kv_offload.tiering.base import ( JobMetadata, JobResult, RequestOffloadingContext, + ScheduleEndContext, SecondaryTierManager, ) from vllm.v1.kv_offload.tiering.fs.io import load_block, store_block @@ -41,6 +50,27 @@ logger = init_logger(__name__) +class FsAsyncLookupManager(AsyncLookupManager): + """Async lookup manager for FileSystemTierManager.""" + + def __init__( + self, + tier: "FileSystemTierManager", + tier_type: str, + ) -> None: + super().__init__(tier_type=tier_type) + self._tier = tier + + def batch_lookup( + self, keys: list[OffloadKey], req_context: ReqContext + ) -> Iterable[bool]: + paths = [self._tier.file_mapper.get_file_name(k) for k in keys] + if _HAS_BATCH_LOOKUP_C: + # C extension: GIL released for the entire faccessat() batch. + return batch_lookup_C(paths) + return (os.path.exists(p) for p in paths) + + class FileSystemTierManager(SecondaryTierManager): """ Pure-Python disk-backed secondary tier. @@ -89,11 +119,12 @@ def __init__( ) self._block_size: int = primary_kv_view.strides[0] - # Create file mapper + # Opt in; FileMapper enables it only for a parallelism-invariant block. self.file_mapper = FileMapper.from_offloading_spec( root_dir=root_dir, offloading_spec=offloading_spec, gpu_blocks_per_file=offloading_spec.block_size_factor, + parallel_agnostic=True, ) # Write config file @@ -111,15 +142,18 @@ def __init__( thread_name_prefix="vllm_kv_py_fs", ) + self._lookup_manager = FsAsyncLookupManager(tier=self, tier_type=self.tier_type) + @override def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: return RequestOffloadingContext() @override - def lookup( - self, key: OffloadKey, req_context: ReqContext | None = None - ) -> bool | None: - return os.path.exists(self.file_mapper.get_file_name(key)) + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: + result = self._lookup_manager.lookup(key, req_context) + if result is None: + return LookupResult.RETRY + return LookupResult.HIT if result else LookupResult.MISS @override def submit_store(self, job_metadata: JobMetadata) -> None: @@ -159,12 +193,25 @@ def get_finished_jobs(self) -> Iterable[JobResult]: for job_id, success in self._pool.get_finished() ) + @override + def drain_jobs(self) -> None: + """Block until all in-flight transfers in the threadpool finish.""" + self._pool.wait_idle() + + def on_request_finished(self, req_context: ReqContext) -> None: + self._lookup_manager.cleanup(req_context.req_id) + + @override + def on_schedule_end(self, context: ScheduleEndContext) -> None: + self._lookup_manager.flush() + @override def shutdown(self) -> None: """ Release resources held by this tier. - Shuts down the thread pool, clearing pending tasks and waiting for - active threads to complete. + Shuts down the lookup manager and the thread pool, + clearing pending tasks and waiting for active threads to complete. """ + self._lookup_manager.shutdown() self._pool.shutdown(wait=True) diff --git a/vllm/v1/kv_offload/tiering/fs/thread_pool.py b/vllm/v1/kv_offload/tiering/fs/thread_pool.py index 49bfeee44c9f..9bf8fe508f0d 100644 --- a/vllm/v1/kv_offload/tiering/fs/thread_pool.py +++ b/vllm/v1/kv_offload/tiering/fs/thread_pool.py @@ -68,6 +68,7 @@ def __init__( self._stop = False self._threads: list[threading.Thread] = [] self._finished_q: deque[tuple[JobId, bool]] = deque() + self._inflight_jobs = 0 # guarded by _condition for i in range(n_read_threads): t = threading.Thread( @@ -98,6 +99,7 @@ def enqueue_load( """Enqueue load tasks for a job (high-priority for load-priority threads).""" state = JobState(job_id, n_tasks) with self._condition: + self._inflight_jobs += 1 for fn in tasks: self._load_q.append((fn, state)) self._condition.notify(n_tasks) @@ -111,21 +113,38 @@ def enqueue_store( """Enqueue store tasks for a job (high-priority for store-priority threads).""" state = JobState(job_id, n_tasks) with self._condition: + self._inflight_jobs += 1 for fn in tasks: self._store_q.append((fn, state)) self._condition.notify(n_tasks) def get_finished(self) -> list[tuple[JobId, bool]]: + # No lock needed: deque is thread-safe for concurrent append/popleft, + # and the manager is the sole popper. jobs = [] while self._finished_q: jobs.append(self._finished_q.popleft()) return jobs + def wait_idle(self) -> None: + """Block until there are no in-flight jobs. + + After this returns, every submitted job has had its last task + finish, so no worker thread is still copying data. Note: + completed jobs may still be sitting in ``_finished_q`` waiting + for ``get_finished()`` to drain them. + """ + with self._condition: + self._condition.wait_for(lambda: self._inflight_jobs == 0) + def shutdown(self, wait: bool = True) -> None: with self._condition: self._stop = True self._load_q.clear() self._store_q.clear() + # Cancelled tasks will not decrement _inflight_jobs; reset it so a + # subsequent wait_idle() returns instead of hanging. + self._inflight_jobs = 0 self._condition.notify_all() if wait: for t in self._threads: @@ -155,4 +174,7 @@ def _worker(self, load_priority: bool) -> None: job_finished, success = state.task_done(False) if job_finished: - self._finished_q.append((state.job_id, success)) + with self._condition: + self._finished_q.append((state.job_id, success)) + self._inflight_jobs -= 1 + self._condition.notify_all() diff --git a/vllm/v1/kv_offload/tiering/manager.py b/vllm/v1/kv_offload/tiering/manager.py index cb8de749ec74..728ac8dc86e4 100644 --- a/vllm/v1/kv_offload/tiering/manager.py +++ b/vllm/v1/kv_offload/tiering/manager.py @@ -20,16 +20,19 @@ protecting blocks from eviction until complete_read() is called """ -from collections import defaultdict from collections.abc import Collection, Iterable, Sequence from dataclasses import dataclass, field import numpy as np from typing_extensions import override +from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import ( + OffloadingConnectorStats, +) from vllm.logger import init_logger from vllm.v1.kv_offload.base import ( LoadStoreSpec, + LookupResult, OffloadingEvent, OffloadingManager, OffloadKey, @@ -37,6 +40,7 @@ PrepareStoreOutput, ReqContext, RequestOffloadingContext, + ScheduleEndContext, ) from vllm.v1.kv_offload.cpu.common import CPULoadStoreSpec from vllm.v1.kv_offload.cpu.manager import CPUOffloadingManager @@ -44,6 +48,7 @@ from vllm.v1.kv_offload.tiering.base import ( JobId, JobMetadata, + ParentManager, SecondaryTierManager, ) @@ -59,6 +64,14 @@ class PendingPromotion: block_ids: list[int] = field(default_factory=list) +@dataclass(slots=True) +class RequestState: + req_context: ReqContext + pending_primary_stores: int = 0 + is_finished: bool = False + request_level_tiers: set[SecondaryTierManager] | None = None + + class CPUPrimaryTierOffloadingManager(CPUOffloadingManager): """CPUOffloadingManager with a primary/secondary transfer interface. @@ -108,6 +121,35 @@ def shutdown(self) -> None: self._mmap_region.cleanup() +class _SecondaryTierFacingParent(ParentManager): + """Wrapper that implements ParentManager by delegating to the + TieringOffloadingManager with exclude_tier set to the origin tier.""" + + __slots__ = ("_m", "_origin") + + def __init__( + self, + manager: "TieringOffloadingManager", + tier: SecondaryTierManager, + ): + self._m = manager + self._origin = tier + + def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: + return self._m.on_new_request(req_context, exclude_tier=self._origin) + + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: + return self._m.lookup(key, req_context, exclude_tier=self._origin) + + def create_store_job( + self, keys: Collection[OffloadKey], req_context: ReqContext + ) -> JobMetadata: + return self._m.create_store_job(keys, req_context) + + def on_request_finished(self, req_context: ReqContext) -> None: + return self._m.on_request_finished(req_context, exclude_tier=self._origin) + + class TieringOffloadingManager(OffloadingManager): """ Orchestrates multi-tier KV cache offloading. @@ -128,7 +170,6 @@ def __init__( self, primary_tier: CPUPrimaryTierOffloadingManager, secondary_tiers: list[SecondaryTierManager] | None = None, - enable_events: bool = False, ): """ Initialize the TieringOffloadingManager. @@ -137,14 +178,11 @@ def __init__( primary_tier: The primary tier manager (CPU-based). secondary_tiers: List of secondary tier managers (e.g., Storage, Network). Can be None or empty list. - enable_events: Whether to track offloading events """ self.primary_tier: CPUPrimaryTierOffloadingManager = primary_tier self.secondary_tiers = secondary_tiers or [] self._job_id_counter: int = 0 - self.events: list[OffloadingEvent] | None = [] if enable_events else None - # Job tracking: maps job_id to metadata for all in-flight transfers. # JobMetadata.is_promotion distinguishes direction: # True: secondary → primary (promotion) @@ -163,12 +201,16 @@ def __init__( # Reset at the end of each step in on_schedule_end(). self._processed_jobs_this_step: bool = False - # Per-request set of secondary tiers that requested REQUEST_LEVEL - # policy. Populated in on_new_request(), - # cleaned up in on_request_finished(). - self._request_level_tiers: defaultdict[str, set[SecondaryTierManager]] = ( - defaultdict(set) - ) + # Per-request state for prepared GPU->primary stores and finalization. + # Secondary tiers are finalized only after pending primary stores reach + # complete_store(), since complete_store() can still submit cascades. + self._req_state: dict[str, RequestState] = {} + + # Cached ParentManager wrappers for each secondary tier. + self._tier_parents: dict[SecondaryTierManager, _SecondaryTierFacingParent] = { + tier: _SecondaryTierFacingParent(self, tier) + for tier in self.secondary_tiers + } def _next_job_id(self) -> JobId: """Generate a unique job ID for async transfer tracking.""" @@ -225,7 +267,13 @@ def _process_finished_jobs(self): ) @override - def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: + def lookup( + self, + key: OffloadKey, + req_context: ReqContext, + *, + exclude_tier: SecondaryTierManager | None = None, + ) -> LookupResult: """ Check whether a single block is offloaded and ready. @@ -240,33 +288,40 @@ def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None: req_context: Per-request context. Returns: - True — block is ready in the primary tier. - None — block found but not yet ready (primary in-flight, - promotion started, or a secondary tier is busy). - False — block not found in any tier, or primary is full - and cannot accept a promotion. + HIT — block is ready in the primary tier. + HIT_PENDING — block found but not yet readable (write + in-flight on the primary tier). + RETRY — promotion started or a secondary tier is busy. + MISS — block not found in any tier, or primary is full + and cannot accept a promotion. """ + # Poll first so a promotion that finished since the last call is + # already reflected as HIT (not stale HIT_PENDING/MISS) below, and + # so blocks freed by cascade or promotion completions are evictable + # in time for a promotion this lookup may initiate. self._maybe_process_finished_jobs() primary_hit = self.primary_tier.lookup(key, req_context) - if primary_hit is True: - return True - if primary_hit is None: - return None + if primary_hit is LookupResult.HIT: + return LookupResult.HIT + if primary_hit is LookupResult.HIT_PENDING: + return LookupResult.HIT_PENDING - any_none = False + any_retry = False for tier in self.secondary_tiers: + if tier is exclude_tier: + continue result = tier.lookup(key, req_context) - if result is True: + if result is LookupResult.HIT: if not self._initiate_promotion(tier, key, req_context): - return False # primary full, block unavailable - return None # promotion started, retry later - if result is None: - any_none = True + return LookupResult.MISS + return LookupResult.RETRY + if result is LookupResult.RETRY: + any_retry = True - if any_none: - return None - return False + if any_retry: + return LookupResult.RETRY + return LookupResult.MISS def _initiate_promotion( self, @@ -348,8 +403,8 @@ def prepare_load( """ Prepare blocks to be loaded from primary tier to GPU. - CRITICAL: This method calls _maybe_process_finished_jobs() FIRST to ensure - that any completed promotions have been finalized and blocks are ready. + Callers only pass keys already confirmed HIT by lookup() earlier this + step. This increments ref_cnt on the blocks in the primary tier, protecting them from eviction during the transfer. @@ -361,9 +416,6 @@ def prepare_load( Returns: LoadStoreSpec for reading from primary tier. """ - # Process completed promotions to ensure blocks are ready - self._maybe_process_finished_jobs() - return self.primary_tier.prepare_load(keys, req_context) @override @@ -416,8 +468,15 @@ def prepare_store( evicted, or None if store cannot proceed. """ # Step 1: Poll for completed async jobs FIRST - # This decrements ref_cnt on primary blocks that have been - # successfully transferred to secondary tiers. + # _process_finished_jobs() handles two kinds of completions here: + # - Cascade completions (store to a secondary tier, either a local + # cascade or a store job created for a remote requester via + # create_store_job()): decrements ref_cnt on the primary blocks + # that were read, making them evictable again once ref_cnt hits 0. + # - Promotion completions (secondary->primary loads): sets a + # not-yet-ready block's ref_cnt from -1 to 0 via complete_write(), + # making it evictable for the first time. + # Both must be accounted for before the eviction decision below. self._maybe_process_finished_jobs() # Step 2: Store to primary tier (new blocks only). @@ -429,9 +488,13 @@ def prepare_store( if primary_result is None: return None + if primary_result.keys_to_store: + state = self._req_state[req_context.req_id] + state.pending_primary_stores += 1 + # Step 3: For request-level tiers, cascade blocks already in primary - request_level_tiers = self._request_level_tiers.get(req_context.req_id) - if request_level_tiers is not None: + request_level_tiers = self._req_state[req_context.req_id].request_level_tiers + if request_level_tiers: keys_to_store_set = set(primary_result.keys_to_store) keys_already_in_primary = tuple( k for k in keys if k not in keys_to_store_set @@ -455,26 +518,15 @@ def _cascade_existing_blocks_to_request_level_tiers( """ # Filter out keys that are not ready in primary (e.g. in-flight) ready_keys = tuple( - k for k in keys if self.primary_tier.lookup(k, req_context) is True + k + for k in keys + if self.primary_tier.lookup(k, req_context) is LookupResult.HIT ) if not ready_keys: return for tier in request_level_tiers: - primary_blocks_spec = self.primary_tier.prepare_read( - ready_keys, req_context - ) - - job_id = self._next_job_id() - assert isinstance(primary_blocks_spec, CPULoadStoreSpec) - job_metadata = JobMetadata( - job_id=job_id, - keys=ready_keys, - block_ids=primary_blocks_spec.block_ids, - is_promotion=False, - req_context=req_context, - ) - self._transfer_jobs[job_id] = job_metadata + job_metadata = self.create_store_job(ready_keys, req_context) tier.submit_store(job_metadata) @override @@ -483,7 +535,7 @@ def complete_store( keys: Collection[OffloadKey], req_context: ReqContext, success: bool = True, - ): + ) -> None: """ Mark blocks as done storing from GPU to primary tier. @@ -505,90 +557,219 @@ def complete_store( # Step 1: Complete store in primary tier (makes blocks loadable) self.primary_tier.complete_store(keys, req_context, success) - if not success: - # If GPU→Primary transfer failed, don't cascade to secondary tiers - return - - # Step 2: Cascade to ALL secondary tiers - # For each secondary tier, call primary.prepare_read() to get the - # LoadStoreSpec AND to increment ref_cnt (protecting blocks from - # eviction during the async transfer). One prepare_read() call per - # secondary tier. - for tier in self.secondary_tiers: - primary_blocks_spec = self.primary_tier.prepare_read(keys, req_context) - - # Submit async store job: primary→secondary - job_id = self._next_job_id() - - # Track this store job - assert isinstance(primary_blocks_spec, CPULoadStoreSpec) - job_metadata = JobMetadata( - job_id=job_id, - keys=keys, - block_ids=primary_blocks_spec.block_ids, - is_promotion=False, - req_context=req_context, - ) - self._transfer_jobs[job_id] = job_metadata - - tier.submit_store(job_metadata) + if success: + # Step 2: Cascade to ALL secondary tiers + # For each secondary tier, call primary.prepare_read() to get the + # LoadStoreSpec AND to increment ref_cnt (protecting blocks from + # eviction during the async transfer). One prepare_read() call per + # secondary tier. + for tier in self.secondary_tiers: + job_metadata = self.create_store_job(keys, req_context) + tier.submit_store(job_metadata) # Note: The async transfers are now in flight. Their completion is # tracked via get_finished_jobs() / _maybe_process_finished_jobs(). + req_id = req_context.req_id + state = self._req_state[req_id] + assert state.pending_primary_stores > 0 + state.pending_primary_stores -= 1 + self._maybe_finalize_request(req_id) + + def create_store_job( + self, + keys: Collection[OffloadKey], + req_context: ReqContext, + ) -> JobMetadata: + """Pin blocks in the primary tier and create a tracked store job. + + Calls prepare_read() to increment ref_cnt (protecting blocks + from eviction during the async transfer), allocates a job ID, + and registers the job in _transfer_jobs. + + The caller is responsible for the actual data transfer and + reporting completion via get_finished_jobs(). + """ + primary_blocks_spec = self.primary_tier.prepare_read(keys, req_context) + assert isinstance(primary_blocks_spec, CPULoadStoreSpec) + job_id = self._next_job_id() + job_metadata = JobMetadata( + job_id=job_id, + keys=keys, + block_ids=primary_blocks_spec.block_ids, + is_promotion=False, + req_context=req_context, + ) + self._transfer_jobs[job_id] = job_metadata + return job_metadata @override - def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: + def on_new_request( + self, + req_context: ReqContext, + *, + exclude_tier: SecondaryTierManager | None = None, + ) -> RequestOffloadingContext: """ Query each secondary tier for its offload policy preference. Returns REQUEST_LEVEL if ANY secondary tier wants request-level. Only stores REQUEST_LEVEL tier decisions for use in prepare_store. """ + state = RequestState(req_context=req_context) for tier in self.secondary_tiers: + if tier is exclude_tier: + continue tier_ctx = tier.on_new_request(req_context) if tier_ctx.policy == OffloadPolicy.REQUEST_LEVEL: - self._request_level_tiers[req_context.req_id].add(tier) + if state.request_level_tiers is None: + state.request_level_tiers = set() + state.request_level_tiers.add(tier) + self._req_state[req_context.req_id] = state policy = ( OffloadPolicy.REQUEST_LEVEL - if req_context.req_id in self._request_level_tiers + if state.request_level_tiers else OffloadPolicy.BLOCK_LEVEL ) return RequestOffloadingContext(policy=policy) @override - def on_request_finished(self, req_context: ReqContext) -> None: + def on_request_finished( + self, + req_context: ReqContext, + *, + exclude_tier: SecondaryTierManager | None = None, + ) -> None: self.primary_tier.on_request_finished(req_context) + state = self._req_state[req_context.req_id] + state.is_finished = True + self._maybe_finalize_request(req_context.req_id, exclude_tier) + + def _maybe_finalize_request( + self, + req_id: str, + exclude_tier: SecondaryTierManager | None = None, + ) -> None: + """Finalize secondary tiers once no more store cascades can be submitted. + + Finalization means forwarding on_request_finished() to secondary tiers. + It is delayed until pending GPU->primary stores finish, since their + complete_store() callbacks may still submit primary->secondary stores. + """ + state = self._req_state[req_id] + if not state.is_finished: + return + if state.pending_primary_stores != 0: + return + for tier in self.secondary_tiers: - tier.on_request_finished(req_context) - self._request_level_tiers.pop(req_context.req_id, None) + if tier is exclude_tier: + continue + tier.on_request_finished(state.req_context) + del self._req_state[req_id] @override - def on_schedule_end(self) -> None: + def on_schedule_end(self, context: ScheduleEndContext) -> None: """End-of-schedule hook: process finished jobs, flush deferred promotions, and reset the per-step gate. Called once per scheduler step from OffloadingConnectorScheduler.build_connector_meta(). """ + # Catch-all poll: guarantees jobs are processed even on steps where + # lookup()/prepare_store() were never called (e.g. no requests + # scheduled but a tier still has_pending_work()). self._maybe_process_finished_jobs() + + for tier in self.secondary_tiers: + tier.serve_external_requests(self._tier_parents[tier]) + + # Reset the per-step gate AFTER serve_external_requests so that + # lookup() calls within it skip redundant _process_finished_jobs(). self._processed_jobs_this_step = False + self._flush_pending_promotions() for tier in self.secondary_tiers: - tier.on_schedule_end() + tier.on_schedule_end(context) + + @override + def has_pending_work(self) -> bool: + # In-flight primary<->secondary transfers (pending promotions are + # translated to transfer jobs in on_schedule_end), plus any work the + # secondary tiers themselves still have outstanding. + return bool(self._transfer_jobs) or any( + tier.has_pending_work() for tier in self.secondary_tiers + ) @override def take_events(self) -> Iterable[OffloadingEvent]: - """Yield offloading events collected since the last call. + """Yield events owned by the primary and secondary tiers. Yields: - New OffloadingEvents collected since the last call. + New OffloadingEvents collected by each tier since the last call. """ - if self.events is not None: - yield from self.events - self.events.clear() - yield from self.primary_tier.take_events() + for tier in self.secondary_tiers: + yield from tier.take_events() + + @override + def reset_cache(self) -> None: + """Reset transfer bookkeeping and primary-tier cache. + + Called during sleep, weight update, or resume. Each secondary tier + drains its in-flight transfers via drain_jobs() so no tier I/O is + touching primary memory before the primary tier is reset. A stuck + tier will block here visibly — preferable to silent corruption + from reusing primary slots while a transfer is mid-copy. + + Secondary tiers are intentionally not reset: persistent stores + (FS, network) keep their data across resets. Active request state is + retained so those requests can continue after the reset; finished + requests are finalized and removed. + """ + for tier in self.secondary_tiers: + tier.drain_jobs() + # All tier I/O has stopped; consume their completion notifications + # so manager bookkeeping is consistent before the primary reset. + self._process_finished_jobs() + + # Deferred promotion submissions reserve primary slots that the + # reset below invalidates; their submit_load() has not yet been + # called so no tier I/O is touching that memory. + self._pending_load_submissions.clear() + + finished_req_ids = [] + for req_id, state in self._req_state.items(): + state.pending_primary_stores = 0 + if not state.is_finished: + continue + for tier in self.secondary_tiers: + tier.on_request_finished(state.req_context) + finished_req_ids.append(req_id) + + self.primary_tier.reset_cache() + + for req_id in finished_req_ids: + del self._req_state[req_id] + self._processed_jobs_this_step = False + + @override + def get_stats(self) -> OffloadingConnectorStats | None: + stats = self.primary_tier.get_stats() + + if stats is not None and stats.is_empty(): + stats = None + + for tier in self.secondary_tiers: + tier_stats = tier.get_stats() + if tier_stats is None or tier_stats.is_empty(): + continue + if stats is None: + stats = tier_stats + else: + stats.aggregate(tier_stats) + + return stats @override def shutdown(self) -> None: diff --git a/vllm/v1/kv_offload/tiering/obj/__init__.py b/vllm/v1/kv_offload/tiering/obj/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/vllm/v1/kv_offload/tiering/obj/config.py b/vllm/v1/kv_offload/tiering/obj/config.py new file mode 100644 index 000000000000..76a1ae29e555 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/obj/config.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Connection configuration for the object store secondary tier.""" + +from dataclasses import dataclass, field + + +@dataclass +class ObjStoreConfig: + """Connection parameters for an object store backend. + + When ``access_key`` and ``secret_key`` are left empty the NIXL OBJ + plugin falls back to the AWS SDK default credential provider chain + (IAM roles, environment variables, credential files, etc.), which + enables workload-identity based auth on Kubernetes. + """ + + bucket: str + endpoint_override: str + access_key: str = field(default="", repr=False) + secret_key: str = field(default="", repr=False) + session_token: str = field(default="", repr=False) + region: str = "" + scheme: str = "http" + ca_bundle: str = "" + + def to_nixl_params(self) -> dict[str, str]: + """Build the NIXL backend params dict. + + Credential and optional fields are only included when non-empty + so that the AWS SDK default credential chain can activate. + """ + params: dict[str, str] = { + "bucket": self.bucket, + "endpoint_override": self.endpoint_override, + "scheme": self.scheme, + } + # Omit empty optional fields so the NIXL OBJ plugin's underlying + # AWS SDK can fall back to its default credential provider chain + # (IAM roles, env vars, credential files, etc.). + # https://github.com/ai-dynamo/nixl/blob/main/src/plugins/obj/README.md + for key in ("access_key", "secret_key", "session_token", "region", "ca_bundle"): + value = getattr(self, key) + if value: + params[key] = value + return params diff --git a/vllm/v1/kv_offload/tiering/obj/manager.py b/vllm/v1/kv_offload/tiering/obj/manager.py new file mode 100644 index 000000000000..6060370ea067 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/obj/manager.py @@ -0,0 +1,337 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Object store secondary tier implementation.""" + +import ctypes +import time +from collections.abc import Iterable +from typing import TYPE_CHECKING, NamedTuple + +from vllm.distributed.nixl_utils import NixlWrapper as nixl_agent +from vllm.distributed.nixl_utils import nixl_agent_config +from vllm.logger import init_logger +from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext +from vllm.v1.kv_offload.file_mapper import FileMapper +from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager +from vllm.v1.kv_offload.tiering.base import ( + JobMetadata, + JobResult, + RequestOffloadingContext, + ScheduleEndContext, + SecondaryTierManager, +) +from vllm.v1.kv_offload.tiering.obj.config import ObjStoreConfig + +if TYPE_CHECKING: + from nixl._api import nixl_prepped_dlist_handle, nixl_xfer_handle + + from vllm.v1.kv_offload.base import OffloadingSpec + +logger = init_logger(__name__) + +NIXL_WRITE = "WRITE" +NIXL_READ = "READ" +NIXL_PROC = "PROC" +NIXL_DONE = "DONE" + +# Device ID for CPU DRAM descriptors. DRAM is not a multi-device resource so +# the device ID is always 0. +NIXL_DEV_ID: int = 0 + +# Fields for NIXL OBJ descriptors: (addr, len, dev_id, obj_key). +# For existence probes addr and len are placeholders — no data is read. +# dev_id=0 is reserved for probes; transfers start from 1. +_PROBE_ADDR: int = 0 +_PROBE_LEN: int = 1 +_PROBE_DEV_ID: int = 0 + + +class TransferEntry(NamedTuple): + xfer_handle: "nixl_xfer_handle" + files_desc: object + obj_handle: "nixl_prepped_dlist_handle" + + +class ObjAsyncLookupManager(AsyncLookupManager): + """Async lookup manager for ObjectStoreSecondaryTierManager. + + Batches existence probes into a single query_memory() call so the + background thread issues one round-trip per step instead of one per key. + """ + + def __init__( + self, + tier: "ObjectStoreSecondaryTierManager", + tier_type: str, + ) -> None: + super().__init__(tier_type=tier_type) + self._tier = tier + + def batch_lookup( + self, keys: list[OffloadKey], req_context: ReqContext + ) -> Iterable[bool]: + descriptors = [ + ( + _PROBE_ADDR, + _PROBE_LEN, + _PROBE_DEV_ID, + self._tier._file_mapper.get_file_name(k), + ) + for k in keys + ] + results = self._tier._agent.query_memory(descriptors, "OBJ", "OBJ") + return (r is not None for r in results) + + +class ObjectStoreSecondaryTierManager(SecondaryTierManager): + """Secondary tier that offloads KV cache blocks to an S3-compatible store. + + Handles CPU DRAM <-> S3 transfers only. GPU <-> CPU is managed by the + primary tier. Object keys are formed as ``{prefix}/{hash_shard}/{hash}.bin``. + """ + + def __init__( + self, + offloading_spec: "OffloadingSpec", + primary_kv_view: memoryview, + tier_type: str, + store_config: dict, + prefix: str = "", + io_threads: int = 4, + ): + super().__init__(offloading_spec, primary_kv_view, tier_type) + agent_config = nixl_agent_config(backends=[]) + self._agent = nixl_agent("ObjAgent", agent_config) + obj_config = ObjStoreConfig(**store_config) + params = {**obj_config.to_nixl_params(), "num_threads": str(io_threads)} + self._agent.create_backend("OBJ", params) + self._transfers: dict[int, TransferEntry] = {} + # Buffered results awaiting the next get_finished_jobs() call: + # submission-time failures + poll-time completions accumulated + # during drain_jobs(). + self._pending_results: list[JobResult] = [] + self._primary_reg = None + self._block_size_bytes: int = 0 + root_dir = f"{prefix}/" if prefix else "" + # Opt in; FileMapper enables it only for a parallelism-invariant block. + self._file_mapper = FileMapper.from_offloading_spec( + root_dir, offloading_spec, parallel_agnostic=True + ) + self._next_obj_dev_id: int = 1 # dev_id=0 is reserved for _exists() probes + + self._probe_connectivity() + + base_addr = ctypes.addressof(ctypes.c_char.from_buffer(primary_kv_view)) + assert primary_kv_view.strides is not None + stride = primary_kv_view.strides[0] + self._primary_reg = self._agent.register_memory( + [(base_addr, primary_kv_view.nbytes, NIXL_DEV_ID, "")], "DRAM" + ) + self._block_size_bytes = stride + all_blocks = [ + (base_addr + i * stride, stride, NIXL_DEV_ID) + for i in range(len(primary_kv_view)) + ] + # NIXL_INIT_AGENT marks this as the local side; make_prepped_xfer requires + # local_xfer_side tagged with NIXL_INIT_AGENT and remote_xfer_side tagged + # with the peer agent name ("ObjAgent"). + self._dram_prepped_handle: nixl_prepped_dlist_handle = ( + self._agent.prep_xfer_dlist("NIXL_INIT_AGENT", all_blocks, "DRAM") + ) + + self._lookup_manager = ObjAsyncLookupManager( + tier=self, tier_type=self.tier_type + ) + + def _probe_connectivity(self) -> None: + """Verify object store connectivity at startup via a NIXL lookup probe. + + Performs a single exists() check against a synthetic key that will + never exist. A True/False result confirms the bucket is reachable; + an exception indicates misconfigured obj store params and raises RuntimeError. + """ + probe_key = "__nixl_probe__/connectivity_test" + try: + self._exists(probe_key) + logger.info("Object store tier connectivity probe succeeded") + except Exception as e: + raise RuntimeError( + f"Object store tier connectivity probe failed — check bucket, " + f"endpoint_override, and scheme. If using explicit credentials " + f"verify access_key and secret_key; otherwise ensure the AWS " + f"SDK default credential chain is configured (IAM role, env " + f"vars, credential file). Error: {e}" + ) from e + + def _exists(self, obj_key: str) -> bool: + results = self._agent.query_memory( + [(_PROBE_ADDR, _PROBE_LEN, _PROBE_DEV_ID, obj_key)], "OBJ", "OBJ" + ) + return results[0] is not None + + def _submit_transfer( + self, + job_id: int, + block_ids: Iterable[int], + obj_keys: Iterable[str], + op: str, + ) -> None: + """Submit an async transfer. op is 'WRITE' (store) or 'READ' (load).""" + block_ids_list = [int(bid) for bid in block_ids] + # The OBJ backend maps devId -> obj_key. All descriptors must have + # unique devIds or later registrations overwrite earlier ones. + nixl_files = [ + (0, self._block_size_bytes, dev_id, key) + for dev_id, key in enumerate(obj_keys, self._next_obj_dev_id) + ] + self._next_obj_dev_id += len(nixl_files) + + files_desc = self._agent.register_memory(nixl_files, "OBJ") + if files_desc is None: + logger.warning("register_memory (OBJ) failed for job %d", job_id) + self._pending_results.append(JobResult(job_id=job_id, success=False)) + return + + obj_handle = self._agent.prep_xfer_dlist("ObjAgent", files_desc.trim()) + if not obj_handle: + logger.warning("prep_xfer_dlist (OBJ) failed for job %d", job_id) + self._agent.deregister_memory(files_desc) + self._pending_results.append(JobResult(job_id=job_id, success=False)) + return + + xfer_handle = self._agent.make_prepped_xfer( + op, + self._dram_prepped_handle, + block_ids_list, + obj_handle, + list(range(len(nixl_files))), + ) + if not xfer_handle: + logger.warning("make_prepped_xfer failed for job %d", job_id) + self._agent.release_dlist_handle(obj_handle) + self._agent.deregister_memory(files_desc) + self._pending_results.append(JobResult(job_id=job_id, success=False)) + return + + state = self._agent.transfer(xfer_handle) + if state == "ERR": + logger.warning("agent.transfer failed for job %d", job_id) + self._agent.release_dlist_handle(obj_handle) + self._agent.deregister_memory(files_desc) + self._agent.release_xfer_handle(xfer_handle) + self._pending_results.append(JobResult(job_id=job_id, success=False)) + return + + self._transfers[job_id] = TransferEntry(xfer_handle, files_desc, obj_handle) + + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: + result = self._lookup_manager.lookup(key, req_context) + if result is None: + return LookupResult.RETRY + return LookupResult.HIT if result else LookupResult.MISS + + def submit_store(self, job_metadata: JobMetadata) -> None: + obj_keys = (self._file_mapper.get_file_name(k) for k in job_metadata.keys) + self._submit_transfer( + job_metadata.job_id, job_metadata.block_ids, obj_keys, NIXL_WRITE + ) + + def submit_load(self, job_metadata: JobMetadata) -> None: + obj_keys = (self._file_mapper.get_file_name(k) for k in job_metadata.keys) + self._submit_transfer( + job_metadata.job_id, job_metadata.block_ids, obj_keys, NIXL_READ + ) + + def on_request_finished(self, req_context: ReqContext) -> None: + self._lookup_manager.cleanup(req_context.req_id) + + def on_schedule_end(self, context: ScheduleEndContext) -> None: + self._lookup_manager.flush() + + def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: + return RequestOffloadingContext() + + def _poll_active_transfers(self) -> None: + """Poll all in-flight transfers once; move newly-completed (success or + failure) into ``_pending_results`` and release their NIXL handles.""" + for job_id, entry in list(self._transfers.items()): + try: + state = self._agent.check_xfer_state(entry.xfer_handle) + except Exception as exc: + success = False + logger.warning("check_xfer_state raised for job %d: %s", job_id, exc) + else: + if state == NIXL_PROC: + continue + elif state == NIXL_DONE: + success = True + else: + success = False + logger.warning("transfer failed job=%d state=%s", job_id, state) + del self._transfers[job_id] + self._agent.release_xfer_handle(entry.xfer_handle) + self._agent.release_dlist_handle(entry.obj_handle) + self._agent.deregister_memory(entry.files_desc) + self._pending_results.append(JobResult(job_id=job_id, success=success)) + + def get_finished_jobs(self) -> Iterable[JobResult]: + """Poll in-flight transfers; return completed (job_id, success) pairs.""" + self._poll_active_transfers() + results = self._pending_results + self._pending_results = [] + return results + + def drain_jobs(self) -> None: + """Block until every submitted transfer has completed or failed. + + nixl exposes only ``check_xfer_state`` (poll-based), so this loops + until ``_transfers`` is empty. Results accumulate in + ``_pending_results`` and are surfaced by the next + ``get_finished_jobs()`` call. + """ + start = time.monotonic() + warned = False + while self._transfers: + self._poll_active_transfers() + if not self._transfers: + break + if not warned and time.monotonic() - start > 5.0: + logger.warning( + "ObjectStoreSecondaryTierManager.drain_jobs: still " + "draining after 5s (%d transfers in flight); a stuck " + "transfer will block the engine.", + len(self._transfers), + ) + warned = True + time.sleep(0.001) + + def shutdown(self) -> None: + self._lookup_manager.shutdown() + for job_id, entry in self._transfers.items(): + try: + self._agent.release_xfer_handle(entry.xfer_handle) + except Exception as exc: + logger.warning("release_xfer_handle failed for job %d: %s", job_id, exc) + try: + self._agent.release_dlist_handle(entry.obj_handle) + except Exception as exc: + logger.warning( + "release_dlist_handle failed for job %d: %s", job_id, exc + ) + try: + self._agent.deregister_memory(entry.files_desc) + except Exception as exc: + logger.warning("deregister_memory failed for job %d: %s", job_id, exc) + self._transfers.clear() + if self._dram_prepped_handle is not None: + try: + self._agent.release_dlist_handle(self._dram_prepped_handle) + except Exception as exc: + logger.warning("failed to release DRAM prepped handle: %s", exc) + self._dram_prepped_handle = None + if self._primary_reg is not None: + try: + self._agent.deregister_memory(self._primary_reg) + except Exception as exc: + logger.warning("failed to deregister primary buffer: %s", exc) + self._primary_reg = None diff --git a/vllm/v1/kv_offload/tiering/p2p/__init__.py b/vllm/v1/kv_offload/tiering/p2p/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/vllm/v1/kv_offload/tiering/p2p/control/__init__.py b/vllm/v1/kv_offload/tiering/p2p/control/__init__.py new file mode 100644 index 000000000000..e8dbbe391bb1 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/control/__init__.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from vllm.v1.kv_offload.tiering.p2p.control.base import ( + ControlConnection, + ControlTransport, +) +from vllm.v1.kv_offload.tiering.p2p.control.zmq import ( + ZmqConnection, + ZmqTransport, +) + +__all__ = [ + "ControlConnection", + "ControlTransport", + "ZmqConnection", + "ZmqTransport", +] diff --git a/vllm/v1/kv_offload/tiering/p2p/control/base.py b/vllm/v1/kv_offload/tiering/p2p/control/base.py new file mode 100644 index 000000000000..6b4d4cfcb591 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/control/base.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Abstract base classes for the P2P control-plane transport. + +The control plane handles peer discovery, connection lifecycle, and +message routing. It is message-content agnostic — it moves opaque +dicts between peers without interpreting them. + +Architecture +------------ + + ControlTransport (one per node) + ├── listen for inbound connections + ├── connect() to outbound peers + └── poll() → new ControlConnections + + ControlConnection (one per peer) + ├── send(msg) — enqueue a message to the peer + ├── recv() — drain buffered inbound messages + ├── mark_dead() — signal that the peer is gone + └── close() — tear down the connection + +Threading model: all I/O is driven by the caller invoking poll(). +No background threads. poll() must be called periodically to: + - receive messages (buffered per-connection) + - accept new inbound peers + - detect disconnections + +Implementor contracts +--------------------- + +- ControlConnection.send() must not block. Messages are serialized + and queued for the next I/O pass. +- ControlConnection.recv() returns all messages received since the + last call (may be empty). Messages are dicts (already deserialized). +- ControlConnection.alive returns False after mark_dead() or close(). +- ControlTransport.poll() returns newly accepted connections only — + not previously returned ones. Each connection appears exactly once. +- ControlTransport.connect() creates an outbound connection to a peer + identified by peer_id (format: "host:port"). Raises on failure. +- Messages may arrive from unknown peers (new inbound connections). + The transport creates a ControlConnection and returns it from poll() + with the first message(s) already in its recv() buffer. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence + + +class ControlConnection(ABC): + """Bidirectional message channel to a single remote peer. + + Lifecycle: + 1. Created by ControlTransport (connect() or poll()) + 2. Used for send/recv by sessions + 3. Marked dead on disconnect (mark_dead()) + 4. Cleaned up with close() + + Once mark_dead() is called, alive becomes False and the owning + session should stop using this connection. close() releases + underlying resources (sockets, monitors). + """ + + def __init__(self, peer_id: str) -> None: + self.peer_id = peer_id + + @property + @abstractmethod + def alive(self) -> bool: + """True if the connection is usable. False after mark_dead/close.""" + ... + + @abstractmethod + def send(self, msg: dict) -> None: + """Enqueue a message for delivery to the peer. + + Must not block. Serialization happens internally. + Raises on closed connection. + """ + ... + + @abstractmethod + def recv(self) -> Sequence[dict]: + """Drain and return all messages received since the last call. + + Returns an empty sequence if no messages are pending. + The returned sequence is read-only — callers must not mutate it. + Messages are dicts deserialized from the wire format. + """ + ... + + @abstractmethod + def mark_dead(self) -> None: + """Mark this connection as dead (peer disconnected). + + After this call, alive returns False. The session should + stop using this connection and the transport will clean it up. + """ + ... + + @abstractmethod + def close(self) -> None: + """Release all resources (sockets, monitors). + + Idempotent. After close(), alive returns False. + """ + ... + + +class ControlTransport(ABC): + """Manages peer connections and drives all control-plane I/O. + + Owns the listening socket and all active connections. + The caller must invoke poll() periodically to process I/O. + + Lifecycle: + 1. Constructed with a local identity and listen address + 2. connect() to reach remote peers + 3. poll() to accept inbound peers and process messages + 4. close() to shut down + """ + + @abstractmethod + def connect(self, peer_id: str) -> ControlConnection: + """Create an outbound connection to a remote peer. + + Args: + peer_id: Remote peer identity (format: "host:port"). + + Returns: + A new ControlConnection ready for send/recv. + + The connection's send queue is live immediately — messages + sent before the remote peer's poll() will be buffered. + """ + ... + + @abstractmethod + def poll(self) -> Sequence[ControlConnection]: + """Process all pending I/O and return newly accepted connections. + + This is the main I/O driver. Each call: + - Receives messages from all connected peers (buffered in + each connection's recv() queue) + - Accepts new inbound peers and creates connections for them + (first message already in recv() buffer) + - Detects disconnections and marks connections dead + + Returns: + Newly accepted inbound connections (not previously returned). + The returned sequence is read-only — callers must not mutate + it. The caller is responsible for creating sessions for + these connections. + """ + ... + + @abstractmethod + def close(self) -> None: + """Shut down the transport and all connections. + + Closes the listening socket and all active connections. + Idempotent. + """ + ... diff --git a/vllm/v1/kv_offload/tiering/p2p/control/zmq.py b/vllm/v1/kv_offload/tiering/p2p/control/zmq.py new file mode 100644 index 000000000000..4e9069a471c9 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/control/zmq.py @@ -0,0 +1,307 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +ZMQ-based transport layer for P2P KV cache sharing. + +Provides ZmqConnection (per-peer messaging) and ZmqTransport (connection +management). Message-content agnostic. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import msgspec +import zmq +import zmq.utils.monitor + +from vllm.logger import init_logger +from vllm.v1.kv_offload.tiering.p2p.control.base import ( + ControlConnection, + ControlTransport, +) + +logger = init_logger(__name__) + +_HEARTBEAT_IVL_MS = 2000 +_HEARTBEAT_TIMEOUT_MS = 10000 +_HEARTBEAT_TTL_MS = 10000 + +# Shared sentinels returned when there is nothing to report. +_EMPTY_INBOX: tuple[dict, ...] = () +_EMPTY_NEW_CONNECTIONS: tuple[ControlConnection, ...] = () + + +def _tcp_addr(host: str, port: int | str) -> str: + return f"tcp://{host}:{port}" + + +def _apply_heartbeat(sock: zmq.Socket) -> None: + sock.setsockopt(zmq.HEARTBEAT_IVL, _HEARTBEAT_IVL_MS) + sock.setsockopt(zmq.HEARTBEAT_TIMEOUT, _HEARTBEAT_TIMEOUT_MS) + sock.setsockopt(zmq.HEARTBEAT_TTL, _HEARTBEAT_TTL_MS) + + +@dataclass +class _Sockets: + dealer: zmq.Socket + monitor: zmq.Socket + + +class ZmqConnection(ControlConnection): + """Bidirectional message channel to a single remote peer.""" + + def __init__(self, peer_id: str, sockets: _Sockets) -> None: + super().__init__(peer_id) + self._sockets = sockets + self._closed = False + self._inbox: list[dict] = [] + + def send(self, msg: dict) -> None: + """Send a msgpack-encoded message to this peer.""" + if self._closed: + raise RuntimeError( + f"ZmqConnection: send on closed connection to {self.peer_id}" + ) + data = msgspec.msgpack.encode(msg) + self._sockets.dealer.send(data) + + def recv(self) -> Sequence[dict]: + """Drain and return all buffered incoming messages.""" + if not self._inbox: + return _EMPTY_INBOX + msgs = self._inbox + self._inbox = [] + return msgs + + @property + def alive(self) -> bool: + return not self._closed + + def close(self) -> None: + if self._closed: + return + self._closed = True + logger.info("ZmqConnection: closing connection to %s", self.peer_id) + self._sockets.monitor.close() + self._sockets.dealer.close() + + def enqueue(self, msg: dict) -> None: + """Buffer an incoming message.""" + self._inbox.append(msg) + + def mark_dead(self) -> None: + """Mark connection as disconnected.""" + self._closed = True + + @property + def monitor_socket(self) -> zmq.Socket: + """Monitor socket for disconnect detection (used by ZmqTransport).""" + return self._sockets.monitor + + +class ZmqTransport(ControlTransport): + """ZMQ implementation of ControlTransport. + + Manages a ROUTER socket for accepting connections and DEALER sockets + for outbound connections. Message-content agnostic. + """ + + def __init__(self, local_id: str, host: str, port: int) -> None: + self._local_id = local_id + self._closed = False + + self._connections: dict[str, ZmqConnection] = {} + self._pending_inbound: list[tuple[str, dict]] = [] + + self._zmq_ctx = zmq.Context() + self._router: zmq.Socket = self._zmq_ctx.socket(zmq.ROUTER) + _apply_heartbeat(self._router) + bind_addr = _tcp_addr(host, port) + self._router.bind(bind_addr) + logger.info("ZmqTransport %s: ROUTER bound on %s", self._local_id, bind_addr) + + # ------------------------------------------------------------------ + # ZmqConnection lifecycle + # ------------------------------------------------------------------ + + def connect(self, peer_id: str) -> ZmqConnection: + """Open an outbound connection to a remote peer.""" + assert peer_id not in self._connections, ( + f"ZmqConnection to {peer_id} already exists" + ) + logger.info( + "ZmqTransport %s: opening OUTBOUND connection to %s", + self._local_id, + peer_id, + ) + return self._open_connection(peer_id, direction="outbound") + + def poll(self) -> Sequence[ControlConnection]: + """Process all pending I/O. Returns newly accepted connections. + + - Receives messages (buffered in each connection's inbox) + - Creates connections for new inbound peers (connect msg in inbox) + - Checks monitors for disconnections + - Removes and closes dead connections + """ + self._recv_router() + self._check_monitors() + + # Create connections for new inbound peers + new_connections: list[ControlConnection] | None = None + for sender_id, msg in self._pending_inbound: + conn = self._connections.get(sender_id) + if conn is None: + logger.info( + "ZmqTransport %s: accepting INBOUND connection from %s", + self._local_id, + sender_id, + ) + conn = self._open_connection(sender_id, direction="inbound") + if new_connections is None: + new_connections = [] + new_connections.append(conn) + conn.enqueue(msg) + self._pending_inbound.clear() + + # Remove dead connections + for pid in [p for p, c in self._connections.items() if not c.alive]: + self._connections.pop(pid).close() + + return ( + new_connections if new_connections is not None else _EMPTY_NEW_CONNECTIONS + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + + for conn in self._connections.values(): + conn.close() + self._connections.clear() + + self._router.setsockopt(zmq.LINGER, 0) + self._router.close() + self._zmq_ctx.destroy(linger=0) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _open_connection( + self, peer_id: str, direction: str = "outbound" + ) -> ZmqConnection: + """Create a DEALER socket + monitor and register the connection.""" + host, port_str = peer_id.rsplit(":", 1) + dealer_addr = _tcp_addr(host, port_str) + + logger.debug( + "ZmqTransport %s: creating DEALER for %s peer %s -> %s", + self._local_id, + direction, + peer_id, + dealer_addr, + ) + + dealer = self._zmq_ctx.socket(zmq.DEALER) + _apply_heartbeat(dealer) + dealer.identity = self._local_id.encode() + + safe_id = peer_id.replace(":", "-").replace("/", "-") + monitor_addr = f"inproc://p2p-monitor-{safe_id}" + dealer.monitor(monitor_addr, zmq.EVENT_DISCONNECTED) + + monitor_sock = self._zmq_ctx.socket(zmq.PAIR) + monitor_sock.connect(monitor_addr) + + dealer.connect(dealer_addr) + + sockets = _Sockets(dealer=dealer, monitor=monitor_sock) + conn = ZmqConnection(peer_id, sockets) + self._connections[peer_id] = conn + logger.info( + "ZmqTransport %s: %s connection established to %s (active connections: %d)", + self._local_id, + direction, + peer_id, + len(self._connections), + ) + return conn + + def _recv_router(self) -> None: + """Non-blocking: receive all pending messages from ROUTER.""" + while True: + try: + frames = self._router.recv_multipart(zmq.NOBLOCK) + except zmq.Again: + break + except zmq.ZMQError as exc: + logger.warning("ZmqTransport %s: recv error: %s", self._local_id, exc) + break + + if len(frames) != 2: + logger.warning( + "ZmqTransport %s: dropping message with %d frames (expected 2)", + self._local_id, + len(frames), + ) + continue + + identity, data = frames + sender_id = identity.decode() + + logger.debug( + "ZmqTransport %s: ROUTER recv from %s (%d bytes)", + self._local_id, + sender_id, + len(data), + ) + + try: + msg = msgspec.msgpack.decode(data) + except Exception as exc: + logger.warning( + "ZmqTransport %s: failed to decode message from %s: %s", + self._local_id, + sender_id, + exc, + ) + continue + + conn = self._connections.get(sender_id) + + if conn is not None: + conn.enqueue(msg) + else: + self._pending_inbound.append((sender_id, msg)) + + def _check_monitors(self) -> None: + """Non-blocking: check all monitor sockets for disconnection.""" + for conn in self._connections.values(): + if not conn.alive: + continue + try: + event = zmq.utils.monitor.recv_monitor_message( + conn.monitor_socket, zmq.NOBLOCK + ) + except zmq.Again: + continue + except zmq.ZMQError as exc: + logger.warning( + "ZmqTransport %s: monitor error for peer %s: %s", + self._local_id, + conn.peer_id, + exc, + ) + continue + + if event["event"] == zmq.EVENT_DISCONNECTED: + logger.debug( + "ZmqTransport %s: peer %s disconnected", + self._local_id, + conn.peer_id, + ) + conn.mark_dead() diff --git a/vllm/v1/kv_offload/tiering/p2p/data/__init__.py b/vllm/v1/kv_offload/tiering/p2p/data/__init__.py new file mode 100644 index 000000000000..5c7715e30c93 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/data/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from vllm.v1.kv_offload.tiering.p2p.data.base import DataTransport, PollResult +from vllm.v1.kv_offload.tiering.p2p.data.nixl import NixlTransport + +__all__ = [ + "DataTransport", + "NixlTransport", + "PollResult", +] diff --git a/vllm/v1/kv_offload/tiering/p2p/data/base.py b/vllm/v1/kv_offload/tiering/p2p/data/base.py new file mode 100644 index 000000000000..a18b1ca0b3c9 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/data/base.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Abstract base class for the P2P data-plane transport. + +The data plane handles RDMA (or similar) block transfers between peers. +It is independent of the control plane — the control plane establishes +who can talk to whom, then the data plane moves blocks at wire speed. + +Architecture +------------ + + DataTransport (one per node) + ├── owns the local KV block memory region + ├── registers remote peers (add_remote_peer / remove_remote_peer) + ├── submits block writes (write_blocks → transfer_id) + ├── polls for completion (poll → done/failed IDs) + └── cancels inflight transfers (cancel) + +Memory model +------------ + +The local node exposes a contiguous block region: + + base_addr ──► ┌─────────────┐ block 0 + ├─────────────┤ block 1 + ├─────────────┤ ... + └─────────────┘ block (num_blocks - 1) + + Each block is block_len bytes. + +Remote peers expose the same layout. write_blocks() copies local +blocks to a remote peer's block region by index: + + write_blocks("peer:1", local_idxs=[0, 3], remote_idxs=[5, 7]) + → writes local block 0 → remote block 5 + local block 3 → remote block 7 + +Transfer lifecycle +------------------ + +1. Register remote peer: add_remote_peer(peer_id, metadata, ...) + - Provides the remote memory layout so transfers can target it +2. Submit: write_blocks(peer_id, local_idxs, remote_idxs) → int + - Returns a transfer_id (opaque int) for tracking + - Returns None if peer not registered or submission fails +3. Poll: poll() → PollResult(done=[...], failed=[...]) + - Returns transfer_ids that completed or failed since last poll + - Completed transfers are automatically cleaned up +4. Cancel: cancel(transfer_ids, mode="immediate" | "wait") + - Best-effort cancellation of inflight transfers + - mode="wait" returns ids still in PROC/PEND so the caller can poll + them to completion + +Implementor contracts +--------------------- + +- write_blocks() must not block. The transfer runs asynchronously. +- poll() must be called periodically. It drives completion checking. +- transfer_ids are unique across the lifetime of the transport. +- add_remote_peer() must be called before write_blocks() to that peer. +- get_agent_metadata() returns opaque bytes that the remote peer + needs to call add_remote_peer() (e.g., RDMA connection info). +- config_fingerprint is a content hash of the model configuration. + Peers with different fingerprints are incompatible and must not + exchange blocks (validated during the control-plane handshake). +- close() releases all resources (memory registrations, handles). + After close(), no other methods may be called. + +Threading model: no background threads. All I/O driven by poll(). +""" + +from __future__ import annotations + +import ctypes +import hashlib +import json +from abc import ABC, abstractmethod +from collections.abc import Iterable, Sequence +from typing import Literal, NamedTuple + +CancelMode = Literal["immediate", "wait"] + + +class PollResult(NamedTuple): + """Result of polling inflight transfers. + + Attributes: + done: Transfer IDs that completed successfully. + failed: Transfer IDs that failed (error, timeout, etc.). + """ + + done: Sequence[int] + failed: Sequence[int] + + +class DataTransport(ABC): + """Abstract data-plane transport for RDMA-style block transfers. + + Owns the local KV block memory region and manages transfers to/from + registered remote peers. + + Construction: + view: A 2D memoryview (num_blocks × block_len bytes) over the + local KV cache block storage. + config_fields: Dict of model config values used to compute the + compatibility fingerprint. None → empty fingerprint + (compatible with any peer). + """ + + def __init__(self, view: memoryview, config_fields: dict | None = None) -> None: + assert view.shape is not None + self._view = view + self._base_addr = ctypes.addressof(ctypes.c_char.from_buffer(view)) + self._num_blocks = view.shape[0] + self._block_len = view.shape[1] + self._config_fingerprint = self._compute_fingerprint(config_fields) + + @property + def base_addr(self) -> int: + """Base address of the local block memory region.""" + return self._base_addr + + @property + def num_blocks(self) -> int: + """Number of blocks in the local region.""" + return self._num_blocks + + @property + def block_len(self) -> int: + """Size of each block in bytes.""" + return self._block_len + + @property + def config_fingerprint(self) -> str: + """Content-hash of the model configuration (hex string). + + Peers must have matching fingerprints to exchange blocks. + Empty string means no fingerprint (always compatible). + """ + return self._config_fingerprint + + @staticmethod + def _compute_fingerprint(config_fields: dict | None) -> str: + if not config_fields: + return "" + canonical = json.dumps(config_fields, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest()[:16] + + @abstractmethod + def get_agent_metadata(self) -> bytes: + """Return opaque metadata needed by remote peers to connect. + + The returned bytes are sent during the control-plane handshake + and passed to the remote peer's add_remote_peer(). + """ + ... + + @abstractmethod + def add_remote_peer( + self, + peer_id: str, + agent_metadata: bytes, + base_addr: int, + num_blocks: int, + block_len: int, + ) -> None: + """Register a remote peer for block transfers. + + Must be called before write_blocks() to this peer. + + Args: + peer_id: Unique identifier for the remote peer. + agent_metadata: Opaque bytes from the peer's get_agent_metadata(). + base_addr: Base address of the peer's block memory region. + num_blocks: Number of blocks in the peer's region. + block_len: Size of each block (must match local block_len). + """ + ... + + @abstractmethod + def remove_remote_peer(self, peer_id: str) -> None: + """Unregister a remote peer and release associated resources. + + Inflight transfers to this peer should be cancelled first. + """ + ... + + @abstractmethod + def write_blocks( + self, + peer_id: str, + local_idxs: list[int], + remote_idxs: list[int], + ) -> int | None: + """Submit a WRITE transfer: local blocks → remote peer's blocks. + + Args: + peer_id: Target peer (must be registered via add_remote_peer). + local_idxs: Indexes of local blocks to read from. + remote_idxs: Indexes of remote blocks to write to. + Must be same length as local_idxs. + + Returns: + A unique transfer_id (int) to track this transfer, or + None if the peer is not registered or submission failed. + """ + ... + + @abstractmethod + def poll(self) -> PollResult: + """Poll all inflight transfers for completion. + + Returns: + PollResult with lists of completed and failed transfer_ids. + Completed/failed transfers are removed from the inflight set. + + Must be called periodically to drive progress checking. + """ + ... + + @abstractmethod + def cancel( + self, + transfer_ids: Iterable[int], + mode: CancelMode = "immediate", + ) -> list[int]: + """Cancel inflight transfers by their IDs. + + Best-effort: transfers that already completed are ignored. + + Args: + transfer_ids: IDs to cancel. Unknown IDs are ignored. + mode: + "immediate" (default): pop and release each handle and + return []. Matches the legacy fire-and-forget + behavior — the caller does not wait for the + underlying transfer to drain. + "wait": attempt to release each handle. If the release + cannot complete because the transfer is still + PROC/PEND, the entry stays in the inflight set and + its id is included in the returned list. The + caller is expected to keep calling poll() until + every returned id surfaces in done/failed. + + Returns: + For mode="wait", the subset of *transfer_ids* still + tracked as inflight after the cancel attempt. For + mode="immediate", always []. + """ + ... + + @abstractmethod + def close(self) -> None: + """Release all resources (registrations, handles, memory). + + Cancels any remaining inflight transfers. Idempotent. + After close(), no other methods may be called. + """ + ... diff --git a/vllm/v1/kv_offload/tiering/p2p/data/nixl.py b/vllm/v1/kv_offload/tiering/p2p/data/nixl.py new file mode 100644 index 000000000000..5f283c4815c6 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/data/nixl.py @@ -0,0 +1,297 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +NixlTransport: Data-plane transport for RDMA-based KV block transfers via NIXL. +""" + +from __future__ import annotations + +import itertools +from collections.abc import Iterable +from typing import Any + +from vllm.distributed.nixl_utils import NixlWrapper as _NixlAgent +from vllm.distributed.nixl_utils import nixl_agent_config as _NixlAgentConfig +from vllm.logger import init_logger +from vllm.v1.kv_offload.tiering.p2p.data.base import ( + CancelMode, + DataTransport, + PollResult, +) + +logger = init_logger(__name__) + +# Shared sentinel returned by poll() in the steady state (no inflight, or +# no transfer changed state since the last poll). Tuples make it immutable; +# callers only iterate / membership-test / equality-check. +_EMPTY_POLL_RESULT: PollResult = PollResult(done=(), failed=()) + + +class NixlTransport(DataTransport): + """Manages a NIXL agent, memory registration, and block transfers. + + Wraps the NIXL C library behind a Python interface so the rest of the + P2P tier code never touches NIXL types directly. Tracks inflight + handles internally and returns completed/failed tags on poll. + """ + + def __init__( + self, + local_id: str, + view: memoryview, + config_fields: dict | None = None, + backends: list[str] | None = None, + num_threads: int = 4, + ) -> None: + super().__init__(view, config_fields=config_fields) + self._local_id = local_id + self._backends = list(backends) if backends else ["UCX"] + self._num_threads = num_threads + self._agent: Any = None + self._reg: Any = None + self._local_dlist: Any = None + self._remote_dlists: dict[str, object] = {} + self._peer_nixl_names: dict[str, str] = {} + self._inflight: dict[int, object] = {} # transfer_id → handle + self._next_id = itertools.count() + + self._init(view) + + @property + def available(self) -> bool: + return self._agent is not None + + def _init(self, view: memoryview) -> None: + if _NixlAgent is None: + return + + non_ucx_backends = [b for b in self._backends if b != "UCX"] + if non_ucx_backends: + cfg = _NixlAgentConfig(backends=self._backends, capture_telemetry=True) + logger.info( + "NixlTransport %s: NIXL backends=%s", + self._local_id, + self._backends, + ) + else: + cfg = _NixlAgentConfig( + num_threads=self._num_threads, capture_telemetry=True + ) + logger.info( + "NixlTransport %s: NIXL backends=[UCX] num_threads=%d", + self._local_id, + self._num_threads, + ) + self._agent = _NixlAgent(self._local_id, cfg) + + total_size = self._num_blocks * self._block_len + reg_descs = [(self._base_addr, total_size, 0, "")] + self._reg = self._agent.register_memory(reg_descs, mem_type="DRAM") + + block_tuples = [ + (self._base_addr + i * self._block_len, self._block_len, 0) + for i in range(self._num_blocks) + ] + xfer_dlist = self._agent.get_xfer_descs(block_tuples, mem_type="DRAM") + self._local_dlist = self._agent.prep_xfer_dlist("NIXL_INIT_AGENT", xfer_dlist) + logger.info( + "NixlTransport %s: registered %d blocks", self._local_id, self._num_blocks + ) + + def get_agent_metadata(self) -> bytes: + assert self._agent is not None + return self._agent.get_agent_metadata() + + # ------------------------------------------------------------------ + # Peer management + # ------------------------------------------------------------------ + + def add_remote_peer( + self, + peer_id: str, + agent_metadata: bytes, + base_addr: int, + num_blocks: int, + block_len: int, + ) -> None: + nixl_name = self._agent.add_remote_agent(agent_metadata) + block_descs = [ + (base_addr + i * block_len, block_len, 0) for i in range(num_blocks) + ] + xfer_dlist = self._agent.get_xfer_descs(block_descs, mem_type="DRAM") + remote_dlist = self._agent.prep_xfer_dlist(nixl_name, xfer_dlist) + self._peer_nixl_names[peer_id] = nixl_name + self._remote_dlists[peer_id] = remote_dlist + + def remove_remote_peer(self, peer_id: str) -> None: + nixl_name = self._peer_nixl_names.pop(peer_id, None) + dlist = self._remote_dlists.pop(peer_id, None) + if self._agent is not None: + if dlist is not None: + self._agent.release_dlist_handle(dlist) + if nixl_name: + self._agent.remove_remote_agent(nixl_name) + + # ------------------------------------------------------------------ + # Transfer submission and polling + # ------------------------------------------------------------------ + + def write_blocks( + self, + peer_id: str, + local_idxs: list[int], + remote_idxs: list[int], + ) -> int | None: + """Submit a WRITE transfer to *peer_id*. + + Returns a transfer ID, or None if the peer is not registered. + The ID is returned via poll() when the transfer completes or fails. + """ + remote_dlist = self._remote_dlists.get(peer_id) + if remote_dlist is None: + logger.warning( + "NixlTransport %s: write_blocks NO REMOTE DLIST for peer=%s " + "(known peers=%s)", + self._local_id, + peer_id, + list(self._remote_dlists.keys()), + ) + return None + logger.debug( + "NixlTransport %s: write_blocks NIXL.transfer peer=%s blocks=%d", + self._local_id, + peer_id, + len(local_idxs), + ) + handle = self._agent.make_prepped_xfer( + "WRITE", + self._local_dlist, + local_idxs, + remote_dlist, + remote_idxs, + ) + self._agent.transfer(handle) + transfer_id = next(self._next_id) + self._inflight[transfer_id] = handle + return transfer_id + + def poll(self) -> PollResult: + """Poll all inflight transfers. + + Returns PollResult(done=..., failed=...) with transfer IDs. + Completed handles are released automatically. + """ + if not self._inflight: + return _EMPTY_POLL_RESULT + + done_ids: list[int] | None = None + failed_ids: list[int] | None = None + + for transfer_id, handle in self._inflight.items(): + try: + state = self._agent.check_xfer_state(handle) + except Exception as exc: + logger.warning( + "NixlTransport %s: check_xfer_state failed for transfer_id=%d: %s", + self._local_id, + transfer_id, + exc, + ) + continue + if state == "DONE": + if done_ids is None: + done_ids = [] + done_ids.append(transfer_id) + elif state not in ("PROC", "PEND"): + if failed_ids is None: + failed_ids = [] + failed_ids.append(transfer_id) + + if done_ids is None and failed_ids is None: + return _EMPTY_POLL_RESULT + + handles_to_release = [] + for tid in done_ids or (): + handles_to_release.append(self._inflight.pop(tid)) + for tid in failed_ids or (): + handles_to_release.append(self._inflight.pop(tid)) + self._release_handles(handles_to_release) + + return PollResult( + done=done_ids if done_ids is not None else _EMPTY_POLL_RESULT.done, + failed=failed_ids if failed_ids is not None else _EMPTY_POLL_RESULT.failed, + ) + + def cancel( + self, + transfer_ids: Iterable[int], + mode: CancelMode = "immediate", + ) -> list[int]: + """Cancel inflight transfers by their IDs. + + See ``DataTransport.cancel`` for the contract. In "wait" mode, + transfers whose ``release_xfer_handle`` raises (NIXL could not + complete the abort because the backend is still draining) stay + in ``self._inflight`` so a later ``poll()`` will observe them. + """ + if mode == "immediate": + handles = [ + self._inflight.pop(tid) for tid in transfer_ids if tid in self._inflight + ] + self._release_handles(handles) + return [] + + still_inflight: list[int] = [] + for tid in transfer_ids: + handle = self._inflight.get(tid) + if handle is None: + continue + try: + self._agent.release_xfer_handle(handle) + except Exception as exc: + logger.debug( + "NixlTransport %s: cancel pending for transfer_id=%d: %s", + self._local_id, + tid, + exc, + ) + still_inflight.append(tid) + continue + del self._inflight[tid] + return still_inflight + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + if self._agent is None: + return + self._release_handles(list(self._inflight.values())) + self._inflight.clear() + for peer_id in list(self._remote_dlists): + self.remove_remote_peer(peer_id) + if self._local_dlist is not None: + self._agent.release_dlist_handle(self._local_dlist) + self._local_dlist = None + if self._reg is not None: + self._agent.deregister_memory(self._reg) + self._reg = None + self._agent = None + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _release_handles(self, handles: list[object]) -> None: + if self._agent is None: + return + for handle in handles: + try: + self._agent.release_xfer_handle(handle) + except Exception as exc: + logger.warning( + "NixlTransport %s: release_xfer_handle failed: %s", + self._local_id, + exc, + ) diff --git a/vllm/v1/kv_offload/tiering/p2p/manager.py b/vllm/v1/kv_offload/tiering/p2p/manager.py new file mode 100644 index 000000000000..6949686f4482 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/manager.py @@ -0,0 +1,665 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +P2PSecondaryTierManager: Secondary tier for P2P KV cache sharing. + +Owns transports and a single bidirectional P2PSession per remote peer. +""" + +from __future__ import annotations + +import time +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from typing_extensions import override + +from vllm.logger import init_logger +from vllm.v1.kv_offload.base import ( + LookupResult, + OffloadKey, + ReqContext, + RequestOffloadingContext, +) +from vllm.v1.kv_offload.file_mapper import FileMapper +from vllm.v1.kv_offload.tiering.base import ( + JobMetadata, + JobResult, + ScheduleEndContext, + SecondaryTierManager, +) +from vllm.v1.kv_offload.tiering.p2p.control import ControlTransport, ZmqTransport +from vllm.v1.kv_offload.tiering.p2p.data import DataTransport, NixlTransport +from vllm.v1.kv_offload.tiering.p2p.session import P2PSession + +if TYPE_CHECKING: + from vllm.v1.kv_offload.base import OffloadingSpec + from vllm.v1.kv_offload.tiering.p2p.control.base import ControlConnection + +logger = init_logger(__name__) + +# Reap unbound store batches that have been parked without a FetchMsg +# binding them to a session for longer than this. Protects against the +# prefiller buffering blocks for a decoder that never asks (decoder died, +# network partition, lost kv_request_id). Must be longer than the per-store +# deadline so the store-timeout path fires first for individual jobs. +_UNBOUND_STORE_TIMEOUT_S = 60.0 + +# Time we wait during shutdown for inflight transfers to drain via +# cancel(mode="wait") before falling back to mode="immediate". Bounded +# so a wedged peer can't hang shutdown. +_SHUTDOWN_DRAIN_TIMEOUT_S = 3.0 + +# Sleep between iterations of the bounded drain loops in drain_jobs() and +# _drain_inflight_for_shutdown(). Short enough to keep latency low, long +# enough to avoid busy-spinning the scheduler thread. +_DRAIN_SLEEP_S = 0.001 + + +def _prefill_params(kv_params: dict | None) -> dict | None: + """Return the ``prefill`` sub-dict, or None if absent. + + Set on decoder requests; carries kv_request_id, remote_host, remote_port. + """ + if not kv_params: + return None + return kv_params.get("prefill") + + +def _decode_params(kv_params: dict | None) -> dict | None: + """Return the ``decode`` sub-dict, or None if absent. + + Set on prefiller requests; carries kv_request_id. + """ + if not kv_params: + return None + return kv_params.get("decode") + + +@dataclass +class _UnboundStoreBatch: + """A submit_store batch parked at the manager before any peer has fetched. + + Indexed by kv_request_id only — the prefiller no longer learns the peer + identity at store time. When a FetchMsg(kv_request_id) arrives on some + session, the manager binds the kv_request_id to that session and replays + every parked batch into ServerRole via session.add_stored_blocks. + """ + + job_id: int + keys: list[OffloadKey] + block_ids: Sequence[int] + submitted_at: float = field(default_factory=time.monotonic) + + +class P2PSecondaryTierManager(SecondaryTierManager): + """Secondary tier for P2P KV cache sharing. + + A single P2PSession per remote peer handles both client-role (loading + blocks from the peer) and server-role (serving blocks to the peer) + over the same control connection. + + Single-threaded: every public method runs on the scheduler thread, and + the engine drives polling via ``get_finished_jobs()`` once per step. + ``has_pending_work()`` keeps the engine ticking so the control transport + and existing sessions are polled even when no requests are scheduled. + """ + + def __init__( + self, + offloading_spec: OffloadingSpec, + primary_kv_view: memoryview, + tier_type: str = "p2p", + host: str = "0.0.0.0", + port: int = 7777, + backends: list[str] | None = None, + num_threads: int = 4, + **kwargs: Any, + ) -> None: + """Initialize the P2P secondary tier manager. + + All keyword arguments after ``primary_kv_view`` come from the + ``secondary_tiers`` entry in ``kv_connector_extra_config``. See + ``docs/features/kv_offloading_usage.md`` for the user-facing + configuration reference. + + Args: + offloading_spec: Owning ``OffloadingSpec`` (provides + ``vllm_config`` and the offloaded block layout). + primary_kv_view: Memoryview over the CPU primary tier; the + NIXL agent registers this region for RDMA transfers. + tier_type: Tier identifier (defaults to ``"p2p"``). + host: Address the ZMQ control socket binds to. + port: Port for the ZMQ control socket. Must be reachable + from peers. + backends: NIXL transport backends (e.g. ``["UCX"]``, + ``["MOONCAKE"]``, ``["LIBFABRIC"]``). Defaults to + ``["UCX"]``. When any non-UCX backend is requested, the + NIXL agent is initialized with ``backends=...``; + otherwise it falls back to a UCX-only agent with + ``num_threads`` threads. + num_threads: NIXL agent worker threads for the UCX-only + branch. Ignored when ``backends`` contains a non-UCX + entry. + **kwargs: Reserved for future tier-specific options. + """ + super().__init__(offloading_spec, primary_kv_view, tier_type) + port = int(port) + self._local_id = f"{host}:{port}" + + config_fields = FileMapper.from_offloading_spec( + root_dir="", + offloading_spec=offloading_spec, + gpu_blocks_per_file=offloading_spec.block_size_factor, + parallel_agnostic=True, + ).get_run_config() + self._data: DataTransport = NixlTransport( + self._local_id, + primary_kv_view, + config_fields=config_fields, + backends=backends, + num_threads=int(num_threads), + ) + self._control: ControlTransport = ZmqTransport(self._local_id, host, port) + + self._sessions: dict[str, P2PSession] = {} + # kv_request_id → session, set when the bound session has received + # FetchMsg for that id. submit_store after binding routes directly + # to the session; before binding, batches are parked in + # _unbound_stores below. Stays in sync with _sessions: entries + # pointing to a reaped session are purged in _reap_dead_sessions. + self._kv_to_session: dict[str, P2PSession] = {} + # kv_request_id → list of batches submit_store'd before any peer + # asked for that id. Drained into a session by _on_session_fetch + # when the corresponding FetchMsg arrives, or surfaced as failures + # by _reap_unbound_stores after _UNBOUND_STORE_TIMEOUT_S. + self._unbound_stores: dict[str, list[_UnboundStoreBatch]] = {} + + self._finished_jobs: list[JobResult] = [] + # kv_request_ids that hit a transport/session failure; On load lookup() + # rejects them so the request falls back to local prefill. + self._failed_req_ids: set[str] = set() + + # ------------------------------------------------------------------ + # SecondaryTierManager interface + # ------------------------------------------------------------------ + + @override + def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult: + prefill = _prefill_params(req_context.kv_transfer_params) + if ( + not prefill + or not prefill.get("remote_host") + or not prefill.get("remote_port") + or not prefill.get("kv_request_id") + ): + return LookupResult.MISS + + kv_request_id = prefill["kv_request_id"] + if kv_request_id in self._failed_req_ids: + return LookupResult.MISS + return LookupResult.HIT + + @override + def on_new_request(self, req_context: ReqContext) -> RequestOffloadingContext: + """Open the outbound session toward the producer if needed. + + On the decoder side (``prefill`` set), open a session toward the + producer at remote_host:remote_port so submit_load can issue + FetchMsg as soon as it fires. On the prefiller side, sessions + are created when the consumer's inbound connection arrives in + _accept_new_peers — submit_store no longer pre-creates anything. + """ + prefill = _prefill_params(req_context.kv_transfer_params) + if prefill: + peer_id = self._remote_id_from_params(prefill) + if peer_id: + self._get_or_create_session(peer_id) + return RequestOffloadingContext() + + @override + def on_request_finished(self, req_context: ReqContext) -> None: + """Cancels pending loads and prunes session-scoped state. + + Decoder side (``prefill`` set): looks up the session by peer_id + because the producer's address is what addresses the client-role + load to cancel. Prefiller side (``decode`` set): looks up via + kv_request_id because peer_id is no longer carried on store-time + kv_transfer_params; if a session has bound the id, finish it. If + no session has bound the id yet, this is a no-op: parked batches + in `_unbound_stores` are left in place and cleaned up only by + `_reap_unbound_stores` after `_UNBOUND_STORE_TIMEOUT_S`. + """ + kv_params = req_context.kv_transfer_params + if not kv_params: + return + prefill = _prefill_params(kv_params) + decode = _decode_params(kv_params) + kv_request_id = (prefill or decode or {}).get("kv_request_id") + if not kv_request_id: + return + self._failed_req_ids.discard(kv_request_id) + + if prefill: + peer_id = self._remote_id_from_params(prefill) + if peer_id: + session = self._sessions.get(peer_id) + if session is not None: + session.finish_request(kv_request_id) + return + + # Prefiller-side finish: identify the session via kv_request_id. + session = self._kv_to_session.pop(kv_request_id, None) + if session is not None: + session.finish_request(kv_request_id) + return + + @override + def submit_store(self, job_metadata: JobMetadata) -> None: + job_id = job_metadata.job_id + keys = list(job_metadata.keys) + block_ids = job_metadata.block_ids + + assert len(keys) == len(block_ids) + + kv_params = job_metadata.req_context.kv_transfer_params + decode = _decode_params(kv_params) + logger.debug( + "P2P %s: submit_store ENTRY job_id=%d blocks=%d decode=%s kv_request_id=%s", + self._local_id, + job_id, + len(block_ids), + decode is not None, + (decode or {}).get("kv_request_id"), + ) + # Absent ``decode`` block => not a remote-decode request: succeed + # locally without parking. An empty/malformed dict is still a + # remote-decode signal and must fail the missing-id check below. + if decode is None: + self._finished_jobs.append(JobResult(job_id=job_id, success=True)) + return + + kv_request_id = decode.get("kv_request_id") + if not kv_request_id: + logger.warning( + "P2P %s: submit_store missing kv_request_id", + self._local_id, + ) + self._finished_jobs.append(JobResult(job_id=job_id, success=False)) + return + + # Fast path: a session has already received FetchMsg for this id, + # so we can route the batch straight into its ServerRole. + session = self._kv_to_session.get(kv_request_id) + if session is not None: + session.add_stored_blocks(kv_request_id, keys, block_ids, job_id) + return + + # No session bound yet — park the batch keyed by kv_request_id. + # _on_session_fetch drains it on the first FetchMsg; if no peer + # ever asks, _reap_unbound_stores surfaces the job as failed. + self._unbound_stores.setdefault(kv_request_id, []).append( + _UnboundStoreBatch( + job_id=job_id, + keys=keys, + block_ids=block_ids, + ) + ) + logger.debug( + "P2P %s: parked submit_store kv_request_id=%s job_id=%d blocks=%d", + self._local_id, + kv_request_id, + job_id, + len(block_ids), + ) + + @override + def submit_load(self, job_metadata: JobMetadata) -> None: + job_id = job_metadata.job_id + keys = list(job_metadata.keys) + block_ids = job_metadata.block_ids + + prefill = _prefill_params(job_metadata.req_context.kv_transfer_params) + logger.debug( + "P2P %s: submit_load ENTRY job_id=%d blocks=%d kv_request_id=%s peer=%s", + self._local_id, + job_id, + len(block_ids), + (prefill or {}).get("kv_request_id"), + self._remote_id_from_params(prefill or {}), + ) + if ( + not prefill + or not prefill.get("remote_host") + or not prefill.get("remote_port") + or not prefill.get("kv_request_id") + ): + logger.debug( + "P2P %s: submit_load job_id=%d FAILED missing prefill params", + self._local_id, + job_id, + ) + self._finished_jobs.append(JobResult(job_id=job_id, success=False)) + return + + kv_request_id = prefill["kv_request_id"] + peer_id = self._remote_id_from_params(prefill) + assert peer_id is not None # guaranteed by prefill checks above + + if not keys: + logger.debug( + "P2P %s: submit_load job_id=%d short-circuit success (no keys)", + self._local_id, + job_id, + ) + self._finished_jobs.append(JobResult(job_id=job_id, success=True)) + return + + session = self._sessions.get(peer_id) + if session is None: + logger.warning( + "P2P %s: submit_load job_id=%d NO SESSION for peer=%s", + self._local_id, + job_id, + peer_id, + ) + self._finished_jobs.append(JobResult(job_id=job_id, success=False)) + self._failed_req_ids.add(kv_request_id) + return + logger.debug( + "P2P %s: submit_load job_id=%d -> request_blocks peer=%s " + "kv_request_id=%s blocks=%d session_ready=%s", + self._local_id, + job_id, + peer_id, + kv_request_id, + len(block_ids), + session.ready, + ) + session.request_blocks(job_id, kv_request_id, keys, block_ids) + + @override + def get_finished_jobs(self) -> Iterable[JobResult]: + # Drive one polling sweep on the scheduler thread, then hand off + # whatever has accumulated. The engine calls this once per step + # (and keeps stepping while has_pending_work() is True). + self._poll_once() + result = self._finished_jobs + self._finished_jobs = [] + return result + + @override + def has_pending_work(self) -> bool: + # The engine tick is the only driver of _control.poll() and + # session.poll(); without it we miss new peer connects and + # inbound fetch messages on existing sessions. Keep the engine + # ticking for the lifetime of this manager. + return True + + @override + def drain_jobs(self) -> None: + """Block until every submitted load/store job has completed or failed. + + Loops calling ``_poll_once()`` until no session has outstanding + inbound loads or in-flight outbound stores. Mid-flight transfers + are NOT cancelled — the caller (``TieringOffloadingManager.reset_cache``) + needs the primary memoryview to be quiescent, not aborted. Results + accumulate in ``_finished_jobs`` and are surfaced by the next + ``get_finished_jobs()`` call. + """ + start = time.monotonic() + warned = False + while True: + self._poll_once() + pending = any( + s._client._inbound or s._server._inflight + for s in self._sessions.values() + ) + if not pending: + return + if not warned and time.monotonic() - start > 5.0: + logger.warning( + "P2PSecondaryTierManager.drain_jobs: still draining " + "after 5s; a stuck transfer will block the engine.", + ) + warned = True + time.sleep(_DRAIN_SLEEP_S) + + @override + def on_schedule_end(self, context: ScheduleEndContext) -> None: + return + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + @staticmethod + def _remote_id_from_params(role_params: dict) -> str | None: + """Build peer_id from a role-scoped sub-dict (``prefill``/``p2p``).""" + host = role_params.get("remote_host") + port = role_params.get("remote_port") + if host and port: + return f"{host}:{port}" + return None + + def _get_or_create_session(self, peer_id: str) -> P2PSession: + """Return the existing session for peer_id, or open one outbound. + + Decoder-side helper for on_new_request: when ``prefill`` is set, + the consumer must reach the producer at peer_id. If we already + have a session toward that peer (from a prior load or a + peer-initiated inbound), reuse it; otherwise open an outbound + ControlConnection and build a connected session. + """ + session = self._sessions.get(peer_id) + if session is not None: + return session + conn = self._control.connect(peer_id) + session = P2PSession( + peer_id=peer_id, + local_id=self._local_id, + transport=self._data, + local_block_len=self._data.block_len, + conn=conn, + ) + self._sessions[peer_id] = session + return session + + def _accept_new_peers(self, new_connections: Sequence[ControlConnection]) -> None: + for conn in new_connections: + logger.info( + "P2P %s: accepting incoming connection from %s", + self._local_id, + conn.peer_id, + ) + try: + existing = self._sessions.get(conn.peer_id) + if existing is not None: + raise ValueError(f"duplicate connection from {conn.peer_id}") + self._sessions[conn.peer_id] = P2PSession( + peer_id=conn.peer_id, + local_id=self._local_id, + transport=self._data, + local_block_len=self._data.block_len, + conn=conn, + ) + logger.info( + "P2P %s: created connected session for %s", + self._local_id, + conn.peer_id, + ) + except (ValueError, KeyError, TypeError, AssertionError) as exc: + logger.error("P2P %s: rejecting peer: %s", self._local_id, exc) + conn.close() + + def _reap_dead_sessions(self) -> None: + # Reap connected sessions whose connection died — peer is gone. + # Stranded prefiller-side stores are no longer tracked through a + # session (they live in _unbound_stores keyed by kv_request_id); + # _reap_unbound_stores handles their timeout independently. + dead: list[str] | None = None + for pid, s in self._sessions.items(): + if s.connected and not s.alive: + if dead is None: + dead = [] + dead.append(pid) + if dead is None: + return + for pid in dead: + session = self._sessions.pop(pid) + # Purge any kv_request_id → session entries pointing at this + # session so subsequent submit_stores fall back to the unbound + # path (which will time out into failure if no peer rebinds). + stale_kv_ids = [ + kid for kid, s in self._kv_to_session.items() if s is session + ] + for kid in stale_kv_ids: + del self._kv_to_session[kid] + failed_loads, failed_stores = session.close() + for job_id, kv_request_id in failed_loads: + self._finished_jobs.append(JobResult(job_id=job_id, success=False)) + self._failed_req_ids.add(kv_request_id) + for job_id in failed_stores: + self._finished_jobs.append(JobResult(job_id=job_id, success=False)) + self._data.remove_remote_peer(pid) + logger.warning("P2P %s: peer %s down", self._local_id, pid) + + def _reap_unbound_stores(self) -> None: + """Time out submit_store batches that no peer has ever fetched. + + Walks `_unbound_stores` for entries whose oldest batch is older + than `_UNBOUND_STORE_TIMEOUT_S`. Drops the kv_request_id, surfaces + every batched job as failed, and adds the id to `_failed_req_ids` + so a late inbound FetchMsg short-circuits to a clean rejection. + """ + if not self._unbound_stores: + return + deadline = time.monotonic() - _UNBOUND_STORE_TIMEOUT_S + expired: list[str] | None = None + for kid, batches in self._unbound_stores.items(): + # Batches are appended in arrival order, so the head is oldest. + if batches and batches[0].submitted_at <= deadline: + if expired is None: + expired = [] + expired.append(kid) + if expired is None: + return + for kid in expired: + batches = self._unbound_stores.pop(kid) + self._failed_req_ids.add(kid) + for batch in batches: + self._finished_jobs.append( + JobResult(job_id=batch.job_id, success=False) + ) + logger.warning( + "P2P %s: unbound store kv_request_id=%s timed out after %.0fs " + "without a fetch — failing %d job(s)", + self._local_id, + kid, + _UNBOUND_STORE_TIMEOUT_S, + len(batches), + ) + + # ------------------------------------------------------------------ + # Polling + # ------------------------------------------------------------------ + + def _poll_once(self) -> None: + """One sweep of the polling work. + + Drains the control transport, polls every session, accumulates + their results into ``_finished_jobs``, and reaps any dead sessions. + Runs on the scheduler thread. + """ + new_connections = self._control.poll() + if new_connections: + logger.info( + "P2P %s: _poll_once got %d new connection(s): %s", + self._local_id, + len(new_connections), + [c.peer_id for c in new_connections], + ) + + self._accept_new_peers(new_connections) + + for session in self._sessions.values(): + result = session.poll() + for lr in result.loads: + self._finished_jobs.append( + JobResult(job_id=lr.job_id, success=lr.success) + ) + if not lr.success: + self._failed_req_ids.add(lr.kv_request_id) + for sr in result.stores: + self._finished_jobs.append( + JobResult(job_id=sr.job_id, success=sr.success) + ) + # Bind kv_request_id → session for any FetchMsg this tick and + # replay any submit_store batches parked while no peer was + # asking. ServerRole.on_fetch already recorded the demand + # inline in dispatch, so the replayed add_stored_blocks calls + # match that demand and submit transfers immediately. + for kv_request_id in result.new_fetch_ids: + self._kv_to_session[kv_request_id] = session + for batch in self._unbound_stores.pop(kv_request_id, ()): + session.add_stored_blocks( + kv_request_id, batch.keys, batch.block_ids, batch.job_id + ) + + self._reap_dead_sessions() + self._reap_unbound_stores() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + @override + def shutdown(self) -> None: + self._drain_inflight_for_shutdown() + for session in self._sessions.values(): + session.close() + self._sessions.clear() + self._kv_to_session.clear() + # Surface buffered store jobs as failed so the engine doesn't + # leak them; the manager is going away after this call. + for batches in self._unbound_stores.values(): + for batch in batches: + self._finished_jobs.append( + JobResult(job_id=batch.job_id, success=False) + ) + self._unbound_stores.clear() + self._control.close() + self._data.close() + + def _drain_inflight_for_shutdown(self) -> None: + """Best-effort drain of inflight transfers before closing _data. + + Mirrors session._drain_abort but as a single bounded loop. Collects + inflight transfer_ids from each session, repeatedly calls + _data.cancel(..., mode="wait") and _data.poll() so handles can + surface as done/failed, and falls back to mode="immediate" once + _SHUTDOWN_DRAIN_TIMEOUT_S elapses so a wedged peer can't hang us. + """ + ids = [tid for s in self._sessions.values() for tid in s._server._inflight] + if not ids: + return + deadline = time.monotonic() + _SHUTDOWN_DRAIN_TIMEOUT_S + still: list[int] = ids + while still and time.monotonic() < deadline: + still = list(self._data.cancel(still, mode="wait")) + if not still: + break + # poll() advances NIXL handle state so the next wait-cancel + # has a chance to release the handles. + self._data.poll() + time.sleep(_DRAIN_SLEEP_S) + if still: + logger.warning( + "P2P %s: shutdown drain timed out after %.1fs with %d " + "transfers still inflight — force-cancelling", + self._local_id, + _SHUTDOWN_DRAIN_TIMEOUT_S, + len(still), + ) + self._data.cancel(still, mode="immediate") diff --git a/vllm/v1/kv_offload/tiering/p2p/session/__init__.py b/vllm/v1/kv_offload/tiering/p2p/session/__init__.py new file mode 100644 index 000000000000..82148e8340de --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/session/__init__.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from vllm.v1.kv_offload.tiering.p2p.session.session import ( + LoadResult, + P2PSession, + SessionPollResult, + StoreResult, +) + +__all__ = [ + "LoadResult", + "P2PSession", + "SessionPollResult", + "StoreResult", +] diff --git a/vllm/v1/kv_offload/tiering/p2p/session/client.py b/vllm/v1/kv_offload/tiering/p2p/session/client.py new file mode 100644 index 000000000000..fce5008deb18 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/session/client.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Client-role state machine for a single peer session. + +Handles outgoing fetch requests, abort-on-timeout, abort-ack timeout, +and produces ``LoadResult`` for completed loads. The session coordinator +parses wire messages and dispatches typed arguments here; this module +never touches ``ControlConnection`` directly — it emits via the ``send`` +callback injected by the coordinator (which gates on ConnectAck). +""" + +from __future__ import annotations + +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, NamedTuple + +from vllm.logger import init_logger +from vllm.v1.kv_offload.tiering.p2p.session.protocol import ( + TYPE_KEY, + AbortFetchMsg, + FetchMsg, +) + +if TYPE_CHECKING: + from vllm.v1.kv_offload.tiering.base import JobId + +logger = init_logger(__name__) + +_LOAD_TIMEOUT_S = 30.0 +_ABORT_ACK_TIMEOUT_S = 10.0 + + +@dataclass +class _InboundRequestState: + """Client-role state for a single load request.""" + + job_id: int # opaque ID assigned by the manager to this load request + kv_request_id: str + submitted_at: float + aborted_at: float | None = None + + +class LoadResult(NamedTuple): + """Result from a session poll, client side.""" + + job_id: int + kv_request_id: str + success: bool + + +class ClientRole: + """Client-side load state machine for one peer session. + + The coordinator owns the connection and the send-gating; this role + is given a ``send`` callback and a ``peer_id`` for log messages and + is otherwise self-contained. + """ + + def __init__(self, peer_id: str, send: Callable[[dict], None]) -> None: + self._peer_id = peer_id + self._send = send + self._inbound: dict[str, _InboundRequestState] = {} + self._completed_loads: list[LoadResult] = [] + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def request_blocks( + self, + job_id: JobId, + kv_request_id: str, + keys: Sequence[bytes], + block_ids: Sequence[int], + send_ready: bool, + ) -> None: + """Register a load request and send the FetchMsg.""" + logger.debug( + "P2PSession %s: request_blocks job_id=%d kv_request_id=%s " + "blocks=%d ready=%s", + self._peer_id, + job_id, + kv_request_id, + len(block_ids), + send_ready, + ) + self._inbound[kv_request_id] = _InboundRequestState( + job_id=job_id, + kv_request_id=kv_request_id, + submitted_at=time.monotonic(), + ) + self._send( + { + TYPE_KEY: FetchMsg.TYPE, + FetchMsg.KV_REQUEST_ID: kv_request_id, + FetchMsg.BLOCK_HASHES: list(keys), + FetchMsg.BLOCK_INDEXES: [int(idx) for idx in block_ids], + } + ) + + def cancel(self, kv_request_id: str) -> None: + """Cancel a pending load. Sends AbortFetchMsg if still active.""" + req = self._inbound.pop(kv_request_id, None) + if req is not None and req.aborted_at is None: + self._send( + { + TYPE_KEY: AbortFetchMsg.TYPE, + AbortFetchMsg.KV_REQUEST_ID: kv_request_id, + } + ) + + def on_transfer_done(self, kv_request_id: str, success: bool) -> None: + """Handle a TransferDoneMsg from the peer.""" + req = self._inbound.pop(kv_request_id, None) + if req is not None: + self._completed_loads.append( + LoadResult( + job_id=req.job_id, + kv_request_id=kv_request_id, + success=success, + ) + ) + else: + # No matching _inbound entry: either a duplicate + # transfer_done from the peer (protocol violation) or a + # benign race with a local cancel/abort/timeout that + # already popped the entry. We don't track terminated ids, + # so we can't tell — log so it's findable. + logger.warning( + "P2PSession %s: transfer_done for unknown kv_request_id=%s " + "(duplicate from peer, or raced with local cancel/timeout)", + self._peer_id, + kv_request_id, + ) + + def on_abort_ack(self, kv_request_id: str) -> None: + """Handle an AbortAckMsg from the peer.""" + req = self._inbound.pop(kv_request_id, None) + if req is not None: + self._completed_loads.append( + LoadResult( + job_id=req.job_id, + kv_request_id=kv_request_id, + success=False, + ) + ) + else: + # See on_transfer_done: same ambiguity (duplicate ack + # vs. raced with local cancel/timeout that already popped). + logger.warning( + "P2PSession %s: abort_ack for unknown kv_request_id=%s " + "(duplicate from peer, or raced with local cancel/timeout)", + self._peer_id, + kv_request_id, + ) + + def collect_results(self) -> list[LoadResult]: + """Walk timeouts and drain completed loads. + + Active requests past ``_LOAD_TIMEOUT_S`` get an AbortFetchMsg + sent and enter the aborting phase. Aborting requests past + ``_ABORT_ACK_TIMEOUT_S`` are surfaced as failed loads. + """ + now = time.monotonic() + to_remove: list[str] = [] + for req_id, req in self._inbound.items(): + if req.aborted_at is None: + if now - req.submitted_at >= _LOAD_TIMEOUT_S: + req.aborted_at = now + logger.warning( + "P2PSession %s: %s timed out, sending abort", + self._peer_id, + req_id, + ) + self._send( + { + TYPE_KEY: AbortFetchMsg.TYPE, + AbortFetchMsg.KV_REQUEST_ID: req_id, + } + ) + else: + if now - req.aborted_at >= _ABORT_ACK_TIMEOUT_S: + to_remove.append(req_id) + self._completed_loads.append( + LoadResult( + job_id=req.job_id, + kv_request_id=req_id, + success=False, + ) + ) + logger.warning( + "P2PSession %s: abort_ack timed out for kv_request_id=%s", + self._peer_id, + req_id, + ) + for req_id in to_remove: + self._inbound.pop(req_id) + + results = self._completed_loads + self._completed_loads = [] + return results + + def close(self) -> list[tuple[int, str]]: + """Tear down. Returns ``(job_id, kv_request_id)`` for pending loads.""" + failed = [(req.job_id, req.kv_request_id) for req in self._inbound.values()] + self._inbound.clear() + self._completed_loads.clear() + return failed diff --git a/vllm/v1/kv_offload/tiering/p2p/session/protocol.py b/vllm/v1/kv_offload/tiering/p2p/session/protocol.py new file mode 100644 index 000000000000..3a2ab5fd88a0 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/session/protocol.py @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +P2P KV cache sharing protocol constants and documentation. + +Protocol Overview +================= + +Two session types communicate over a bidirectional message channel: +- P2PClientSession (requests blocks from a server) +- P2PServerSession (serves blocks to a client) + +Connection Lifecycle +-------------------- + +1. Client opens a connection and sends ConnectMsg with its identity, + RDMA metadata, memory layout, and config fingerprint. +2. Server validates block_len and config_fingerprint, registers the + RDMA peer, and replies with ConnectAckMsg. +3. Client receives ConnectAckMsg and transitions to ready state + (flushes any queued messages). +4. Either side may send DisconnectMsg to gracefully close. + +Block Transfer Flow (happy path) +--------------------------------- + +1. Client sends FetchMsg with a kv_request_id and lists of + block keys + remote indexes where it wants the data written. +2. Server matches requested blocks against locally stored blocks: + - Blocks already available are transferred immediately via RDMA. + - Blocks not yet available are recorded as "demanded" and + transferred when the server later stores them. +3. When all blocks for a kv_request_id are transferred, the server + sends TransferDoneMsg (success=True) to the client. +4. Client reports the load job as complete. + +Abort Flow (timeout path) +-------------------------- + +1. If the client times out waiting for TransferDoneMsg, it sends + AbortFetchMsg to cancel the request. +2. Server cancels inflight transfers for that kv_request_id and + replies with AbortAckMsg. +3. Client receives AbortAckMsg and reports the load job as failed. +4. If AbortAckMsg itself times out, the client fails the job anyway. + +Message Format +-------------- + +All messages are dicts serialized with msgpack. Every message has a +TYPE_KEY key identifying its type. Additional fields depend on the +message type (see per-message class docstrings below). + +Security +-------- + +Sessions wrap all incoming message handling in try/except to guard +against malformed messages from adversarial or buggy peers. Invalid +messages are logged and dropped without crashing the session. + +The config_fingerprint field in ConnectMsg ensures peers have +compatible model configurations (model, dtype, block sizes). Mismatches +are rejected during the handshake. +""" + +TYPE_KEY = "type" + + +# --------------------------------------------------------------------------- +# Validation helpers +# --------------------------------------------------------------------------- + + +def _require(msg: dict, key: str, typ: type, *, name: str = "") -> None: + """Raise ValueError if msg[key] is missing or not isinstance(typ).""" + val = msg.get(key) + if not isinstance(val, typ): + label = name or key + raise ValueError(f"{label}: expected {typ.__name__}, got {type(val).__name__}") + + +def _require_pos_int(msg: dict, key: str, *, name: str = "") -> None: + """Raise ValueError if msg[key] is not a positive int.""" + val = msg.get(key) + if not isinstance(val, int) or val <= 0: + label = name or key + raise ValueError(f"{label}: expected positive int, got {val!r}") + + +def _require_non_neg_int(msg: dict, key: str, *, name: str = "") -> None: + """Raise ValueError if msg[key] is not a non-negative int.""" + val = msg.get(key) + if not isinstance(val, int) or val < 0: + label = name or key + raise ValueError(f"{label}: expected non-negative int, got {val!r}") + + +def _require_list(msg: dict, key: str, *, name: str = "") -> None: + """Raise ValueError if msg[key] is not a list.""" + val = msg.get(key) + if not isinstance(val, list): + label = name or key + raise ValueError(f"{label}: expected list, got {type(val).__name__}") + + +# --------------------------------------------------------------------------- +# Message classes +# --------------------------------------------------------------------------- + + +class ConnectMsg: + """Client → Server: initial handshake request. + + Fields: + PEER_ID: Local peer identity string. + AGENT_METADATA: RDMA agent metadata (opaque bytes). + BASE_ADDR: Base memory address of the KV block region. + NUM_BLOCKS: Number of blocks in the KV block region. + BLOCK_LEN: Size in bytes of each block (must match between peers). + CONFIG_FINGERPRINT: SHA-256 prefix of the model configuration. + Peers with different fingerprints are incompatible. + """ + + TYPE = "connect" + PEER_ID = "peer_id" + AGENT_METADATA = "agent_metadata" + BASE_ADDR = "base_addr" + NUM_BLOCKS = "num_blocks" + BLOCK_LEN = "block_len" + CONFIG_FINGERPRINT = "config_fingerprint" + + @staticmethod + def validate(msg: dict) -> None: + """Raise ValueError if any field has an invalid type or value.""" + _require(msg, ConnectMsg.PEER_ID, str) + _require(msg, ConnectMsg.AGENT_METADATA, bytes) + _require_non_neg_int(msg, ConnectMsg.BASE_ADDR) + _require_pos_int(msg, ConnectMsg.NUM_BLOCKS) + _require_pos_int(msg, ConnectMsg.BLOCK_LEN) + + +class ConnectAckMsg: + """Server → Client: handshake acknowledgement. + + Fields: + PEER_ID: Server's peer identity string. + """ + + TYPE = "connect_ack" + PEER_ID = "peer_id" + + @staticmethod + def validate(msg: dict) -> None: + """Raise ValueError if any field has an invalid type or value.""" + _require(msg, ConnectAckMsg.PEER_ID, str) + + +class DisconnectMsg: + """Either → Either: graceful connection close. + + No additional fields beyond TYPE_KEY. + """ + + TYPE = "disconnect" + + +class FetchMsg: + """Client → Server: request blocks by key. + + Fields: + KV_REQUEST_ID: Identifies this block transfer request. + BLOCK_HASHES: List of block keys (OffloadKey bytes). + BLOCK_INDEXES: List of remote block indexes (same length as BLOCK_HASHES). + """ + + TYPE = "fetch" + KV_REQUEST_ID = "kv_request_id" + BLOCK_HASHES = "block_hashes" + BLOCK_INDEXES = "block_indexes" + + @staticmethod + def validate(msg: dict) -> None: + """Raise ValueError if any field has an invalid type or value.""" + _require(msg, FetchMsg.KV_REQUEST_ID, str) + _require_list(msg, FetchMsg.BLOCK_HASHES) + _require_list(msg, FetchMsg.BLOCK_INDEXES) + hashes = msg[FetchMsg.BLOCK_HASHES] + indexes = msg[FetchMsg.BLOCK_INDEXES] + if len(hashes) != len(indexes): + raise ValueError( + f"block_hashes/block_indexes length mismatch: " + f"{len(hashes)} vs {len(indexes)}" + ) + for idx in indexes: + if not isinstance(idx, int) or idx < 0: + raise ValueError(f"block_indexes: invalid index {idx!r}") + + +class TransferDoneMsg: + """Server → Client: all blocks transferred for a request. + + Fields: + KV_REQUEST_ID: The request that completed. + SUCCESS: Whether the transfer completed successfully. + """ + + TYPE = "transfer_done" + KV_REQUEST_ID = "kv_request_id" + SUCCESS = "success" + + @staticmethod + def validate(msg: dict) -> None: + """Raise ValueError if any field has an invalid type or value.""" + _require(msg, TransferDoneMsg.KV_REQUEST_ID, str) + _require(msg, TransferDoneMsg.SUCCESS, bool) + + +class AbortFetchMsg: + """Client → Server: cancel a pending request. + + Fields: + KV_REQUEST_ID: The request to cancel. + """ + + TYPE = "abort_fetch" + KV_REQUEST_ID = "kv_request_id" + + @staticmethod + def validate(msg: dict) -> None: + """Raise ValueError if any field has an invalid type or value.""" + _require(msg, AbortFetchMsg.KV_REQUEST_ID, str) + + +class AbortAckMsg: + """Server → Client: acknowledge cancellation. + + Fields: + KV_REQUEST_ID: The request that was cancelled. + """ + + TYPE = "abort_ack" + KV_REQUEST_ID = "kv_request_id" + + @staticmethod + def validate(msg: dict) -> None: + """Raise ValueError if any field has an invalid type or value.""" + _require(msg, AbortAckMsg.KV_REQUEST_ID, str) diff --git a/vllm/v1/kv_offload/tiering/p2p/session/server.py b/vllm/v1/kv_offload/tiering/p2p/session/server.py new file mode 100644 index 000000000000..6f5059e5503b --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/session/server.py @@ -0,0 +1,601 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Server-role state machine for a single peer session. + +Owns block matching (supply vs. demand), inflight RDMA transfers, store-job +timeouts, abort-drain, and produces ``StoreResult`` for completed stores. +The session coordinator parses wire messages and dispatches typed +arguments here; this module never touches ``ControlConnection`` directly +— it emits via the ``send`` callback injected by the coordinator (which +gates on ConnectAck). + +Protocol violations the role can detect (today: duplicate ``FetchMsg`` +for the same ``kv_request_id``) are surfaced as ``ValueError`` so the +coordinator's ``_dispatch_message`` can reuse its existing +``_protocol_error`` path. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, NamedTuple + +from vllm.logger import init_logger +from vllm.v1.kv_offload.base import OffloadKey +from vllm.v1.kv_offload.tiering.p2p.session.protocol import ( + TYPE_KEY, + AbortAckMsg, + TransferDoneMsg, +) + +if TYPE_CHECKING: + from vllm.v1.kv_offload.tiering.base import JobId + from vllm.v1.kv_offload.tiering.p2p.data import DataTransport + +logger = init_logger(__name__) + +_STORE_TIMEOUT_S = 30.0 +_CANCEL_DRAIN_TIMEOUT_S = 10.0 + + +class StoreResult(NamedTuple): + """Result from a session poll, server side.""" + + job_id: int + success: bool + + +class _InflightXfer(NamedTuple): + """Metadata for a single inflight RDMA transfer, keyed by transfer_id.""" + + kv_request_id: str + block_count: int + # The set of store job IDs that contributed blocks to this transfer. + job_ids: set[int] + + +class _MatchResult(NamedTuple): + """Result of block matching: pairs ready for transfer.""" + + local_idxs: list[int] + remote_idxs: list[int] + # The set of store job IDs that contributed blocks + job_ids: set[int] + + +@dataclass +class _OutboundRequestState: + """Server-role state for a single peer fetch request. + + The owning ``kv_request_id`` is the dict key in + ``ServerRole._outbound`` and is not duplicated on the value. + """ + + demand_received: bool = False + available: dict[OffloadKey, tuple[int, int]] = field( + default_factory=dict + ) # key → (job_id, local_block_idx): blocks we have, awaiting demand + demanded: dict[OffloadKey, int] = field( + default_factory=dict + ) # key → remote_block_idx: blocks peer wants, awaiting supply + remaining: int = 0 # blocks that need to be transferred to client + finishing: bool = False # Signal finish request ASAP + # Job IDs that submit_store'd blocks for this request and have not + # yet emitted a StoreResult. The terminal-finalize helper drains + # this set; poll-done and poll-failed discard entries as their + # StoreResults fire. + pending_job_ids: set[int] = field(default_factory=set) + + def add_stored_blocks( + self, + block_hashes: Sequence[OffloadKey], + block_ids: Sequence[int], + job_id: int, + ) -> _MatchResult: + """Add locally-stored blocks. Returns matched pairs.""" + self.pending_job_ids.add(job_id) + local_idxs: list[int] = [] + remote_idxs: list[int] = [] + for block_hash, local_idx in zip(block_hashes, block_ids): + remote_idx = self.demanded.pop(block_hash, None) + if remote_idx is not None: + local_idxs.append(local_idx) + remote_idxs.append(remote_idx) + else: + self.available[block_hash] = (job_id, local_idx) + return _MatchResult( + local_idxs=local_idxs, + remote_idxs=remote_idxs, + job_ids={job_id} if local_idxs else set(), + ) + + def add_fetch_demand( + self, + block_hashes: Sequence[OffloadKey], + block_indexes: Sequence[int], + ) -> _MatchResult: + """Register the peer's fetch demand. Returns matched pairs.""" + self.demand_received = True + self.remaining = len(block_hashes) + + local_idxs: list[int] = [] + remote_idxs: list[int] = [] + job_ids: set[int] = set() + for block_hash, remote_idx in zip(block_hashes, block_indexes): + stored_entry = self.available.pop(block_hash, None) + if stored_entry is not None: + stored_job_id, local_idx = stored_entry + local_idxs.append(local_idx) + remote_idxs.append(remote_idx) + job_ids.add(stored_job_id) + else: + self.demanded[block_hash] = remote_idx + return _MatchResult( + local_idxs=local_idxs, + remote_idxs=remote_idxs, + job_ids=job_ids, + ) + + +class ServerRole: + """Server-side store/serve state machine for one peer session. + + The coordinator owns the connection and the send-gating; this role + is given a ``send`` callback, the ``DataTransport``, and the + ``peer_id`` for transport calls and log messages. + """ + + def __init__( + self, + peer_id: str, + transport: DataTransport, + send: Callable[[dict], None], + ) -> None: + self._peer_id = peer_id + self._transport = transport + self._send = send + + self._outbound: dict[str, _OutboundRequestState] = {} + # transfer_id → xfer. Mutate ONLY via _inflight_add / _inflight_pop + # so the per-request count below stays in sync. + self._inflight: dict[int, _InflightXfer] = {} + # kv_request_id → number of entries in _inflight for that id. + # Kept in sync with _inflight; entries that hit zero are removed + # so `kv_request_id in self._inflight_per_req` is an exact + # "has any inflight transfer" predicate (O(1) replacement for + # the previous O(N) scan). + self._inflight_per_req: dict[str, int] = {} + self._store_jobs: dict[int, float] = {} # job_id → submitted_at + self._pending_aborts: dict[str, float] = {} # kv_request_id → start + # StoreResults queued by _finalize_outbound for the next poll + # tick to surface. Mirrors the deferred-result pattern used for + # load timeouts. + self._pending_store_results: list[StoreResult] = [] + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def add_stored_blocks( + self, + kv_request_id: str, + keys: Sequence[OffloadKey], + block_ids: Sequence[int], + job_id: JobId, + ) -> None: + """New blocks stored locally — match against pending fetch demand.""" + self._store_jobs[job_id] = time.monotonic() + req = self._outbound.setdefault(kv_request_id, _OutboundRequestState()) + result = req.add_stored_blocks(keys, block_ids, job_id) + if result.local_idxs and req.demand_received: + self._submit_transfer(kv_request_id, result) + + def on_fetch( + self, + kv_request_id: str, + block_hashes: Sequence[OffloadKey], + block_indexes: Sequence[int], + ) -> None: + """Handle a FetchMsg from the peer. + + Raises ``ValueError`` on a duplicate fetch for the same + ``kv_request_id``; the coordinator's dispatch loop turns that + into a protocol-error disconnect. + """ + logger.debug( + "P2PSession %s: fetch RECEIVED kv_request_id=%s blocks=%d", + self._peer_id, + kv_request_id, + len(block_hashes), + ) + existing = self._outbound.get(kv_request_id) + if existing is not None and existing.demand_received: + # A second fetch for the same kv_request_id would overwrite + # `remaining` and leak inflight bookkeeping. Treat as a + # protocol violation. + raise ValueError(f"duplicate fetch for kv_request_id={kv_request_id}") + req = self._outbound.setdefault(kv_request_id, _OutboundRequestState()) + result = req.add_fetch_demand(block_hashes, block_indexes) + if result.local_idxs: + self._submit_transfer(kv_request_id, result) + # Prefiller-first mode: finish_request may have run before + # fetch arrived. If so, finalize once we know what was + # demanded — fully satisfied → success, else early-fail. + if req.finishing and not self._has_inflight_for(kv_request_id): + self._finalize_outbound(kv_request_id) + + def on_abort_fetch(self, kv_request_id: str) -> None: + """Handle an AbortFetchMsg from the peer.""" + # Abort for an unknown id may be a benign race/duplicate or a + # real protocol violation; we don't track completed ids, so warn. + if kv_request_id not in self._outbound and not self._has_inflight_for( + kv_request_id + ): + logger.warning( + "P2PSession %s: abort_fetch for unknown kv_request_id=%s " + "(no outbound or inflight state); benign race or stale", + self._peer_id, + kv_request_id, + ) + # Idempotent: receiving AbortFetchMsg again before we've sent the + # ack just triggers another drain attempt without resetting the + # deadline. + self._pending_aborts.setdefault(kv_request_id, time.monotonic()) + self._drain_abort(kv_request_id) + + def finish(self, kv_request_id: str) -> None: + """Mark an outbound request finishing. + + No more submit_store calls will arrive for this id. Any blocks + the peer demanded but we never stored will never come; tell the + peer to stop waiting (TransferDoneMsg success=False) instead of + letting it hit _LOAD_TIMEOUT_S. + + If the decoder hasn't sent fetch yet (no demand received), + defer — on_fetch will finalize once demand arrives. + + If inflight transfers exist for this id, defer — the last + completing transfer in collect_results will fire the message. + """ + req = self._outbound.get(kv_request_id) + if req is None: + return + req.finishing = True + if not req.demand_received: + return + if self._has_inflight_for(kv_request_id): + return + # Remaining > 0 here: if it had hit 0, the poll-done success + # branch would have already popped _outbound and we'd have + # returned at `req is None` above. Helper derives success from + # remaining and emits StoreResult(success=False) for any + # leftover pending jobs. + self._finalize_outbound(kv_request_id) + + def collect_results(self) -> list[StoreResult]: + """Drain timeouts, deferred results, and transport completions.""" + results: list[StoreResult] = self._timeout_pending_store_jobs() + + if self._pending_store_results: + results.extend(self._pending_store_results) + self._pending_store_results.clear() + + poll_result = self._transport.poll() + + for tid in poll_result.done: + xfer = self._inflight_pop(tid) + if xfer is None: + # Bug signal: transport reported a transfer we have no + # bookkeeping for. Likely a double-completion in the + # transport or a stale removal in the session. The + # attached job(s) still live in _store_jobs and will be + # surfaced as failures by _timeout_pending_store_jobs + # after _STORE_TIMEOUT_S, but log loudly so the + # underlying bug is findable. + logger.error( + "P2PSession %s: transport reported done for unknown " + "transfer_id=%d; attached job(s) will fail via " + "store-timeout instead of completing now", + self._peer_id, + tid, + ) + continue + req = self._outbound.get(xfer.kv_request_id) + for job_id in xfer.job_ids: + if self._store_jobs.pop(job_id, None) is None: + # Already reported (timeout, cancellation, etc.) — + # don't double-emit a contradictory success result. + continue + results.append(StoreResult(job_id=job_id, success=True)) + if req is not None: + req.pending_job_ids.discard(job_id) + if req is not None and req.demand_received: + req.remaining -= xfer.block_count + assert req.remaining >= 0, ( + f"remaining went negative for kv_request_id={xfer.kv_request_id}" + ) + if req.remaining == 0: + self._finalize_outbound(xfer.kv_request_id, success=True) + elif req.finishing and not self._has_inflight_for(xfer.kv_request_id): + self._finalize_outbound(xfer.kv_request_id, success=False) + + failed_kv_request_ids: set[str] | None = None + for tid in poll_result.failed: + xfer = self._inflight_pop(tid) + if xfer is None: + # See the matching error log in the done branch above. + logger.error( + "P2PSession %s: transport reported failed for unknown " + "transfer_id=%d; attached job(s) will fail via " + "store-timeout instead of completing now", + self._peer_id, + tid, + ) + continue + if failed_kv_request_ids is None: + failed_kv_request_ids = set() + failed_kv_request_ids.add(xfer.kv_request_id) + req_for_xfer = self._outbound.get(xfer.kv_request_id) + for job_id in xfer.job_ids: + if self._store_jobs.pop(job_id, None) is None: + # Already reported (timeout, cancellation, etc.) — + # don't double-emit. + continue + results.append(StoreResult(job_id=job_id, success=False)) + if req_for_xfer is not None: + req_for_xfer.pending_job_ids.discard(job_id) + req = self._outbound.pop(xfer.kv_request_id, None) + if req is not None and req.demand_received: + self._send( + { + TYPE_KEY: TransferDoneMsg.TYPE, + TransferDoneMsg.KV_REQUEST_ID: xfer.kv_request_id, + TransferDoneMsg.SUCCESS: False, + } + ) + + # Cancel other inflight for the same failed kv_request_ids + if failed_kv_request_ids: + ids_to_cancel = [ + tid + for tid, xfer in self._inflight.items() + if xfer.kv_request_id in failed_kv_request_ids + ] + for tid in ids_to_cancel: + self._inflight_pop(tid) + self._transport.cancel(ids_to_cancel) + + return results + + def collect_idle_timeouts(self) -> list[StoreResult]: + """Run only the store-job timeout sweep. + + Used by the coordinator's no-conn poll path: a pending session + cannot have inflight transfers (no peer registered yet), so we + skip the transport poll and the deferred-result drain. + """ + return self._timeout_pending_store_jobs() + + def drain_pending_aborts(self) -> None: + """Re-attempt every parked abort once per poll tick.""" + if not self._pending_aborts: + return + for kv_request_id in list(self._pending_aborts): + self._drain_abort(kv_request_id) + + def close(self) -> list[int]: + """Tear down. Cancels inflight, returns failed store job ids.""" + failed_stores = list(self._store_jobs.keys()) + self._store_jobs.clear() + if self._inflight: + self._transport.cancel(list(self._inflight.keys())) + self._inflight.clear() + self._inflight_per_req.clear() + self._outbound.clear() + self._pending_aborts.clear() + self._pending_store_results.clear() + return failed_stores + + # ------------------------------------------------------------------ + # Internal — inflight bookkeeping + # ------------------------------------------------------------------ + + def _has_inflight_for(self, kv_request_id: str) -> bool: + return kv_request_id in self._inflight_per_req + + def _inflight_add(self, tid: int, xfer: _InflightXfer) -> None: + """Insert an inflight transfer and bump the per-request count.""" + self._inflight[tid] = xfer + self._inflight_per_req[xfer.kv_request_id] = ( + self._inflight_per_req.get(xfer.kv_request_id, 0) + 1 + ) + + def _inflight_pop(self, tid: int) -> _InflightXfer | None: + """Pop an inflight transfer and decrement the per-request count. + + Removes the per-request entry once the count hits zero so the + dict stays bounded and `_has_inflight_for` remains exact. + """ + xfer = self._inflight.pop(tid, None) + if xfer is None: + return None + new_count = self._inflight_per_req.get(xfer.kv_request_id, 0) - 1 + if new_count > 0: + self._inflight_per_req[xfer.kv_request_id] = new_count + else: + self._inflight_per_req.pop(xfer.kv_request_id, None) + return xfer + + # ------------------------------------------------------------------ + # Internal — finalize / abort drain + # ------------------------------------------------------------------ + + def _finalize_outbound( + self, + kv_request_id: str, + success: bool | None = None, + ) -> None: + """Pop the outbound state and emit terminal results. + + Called when no further work will happen for this kv_request_id + on the server side: either request_finish has fired and there + are no inflight transfers, or the last inflight just completed + while finishing. + + If ``success`` is None, derive it from ``req.remaining == 0``. + The same flag is used for both the peer's TransferDoneMsg and + the StoreResult(s) emitted for any leftover pending job_ids. + """ + req = self._outbound.pop(kv_request_id, None) + if req is None: + return + if success is None: + success = req.remaining == 0 + for job_id in req.pending_job_ids: + self._store_jobs.pop(job_id, None) + self._pending_store_results.append( + StoreResult(job_id=job_id, success=success) + ) + self._send( + { + TYPE_KEY: TransferDoneMsg.TYPE, + TransferDoneMsg.KV_REQUEST_ID: kv_request_id, + TransferDoneMsg.SUCCESS: success, + } + ) + + def _drain_abort(self, kv_request_id: str) -> None: + """One drain attempt for a pending abort. + + Stops accepting more blocks for ``kv_request_id``, then asks the + transport to cancel any matching inflight transfers in + ``mode="wait"``. Sends ``AbortAckMsg`` once nothing remains + inflight, or after ``_CANCEL_DRAIN_TIMEOUT_S`` falls back to + ``mode="immediate"`` and acks anyway. + """ + self._outbound.pop(kv_request_id, None) + ids = [ + tid + for tid, xfer in self._inflight.items() + if xfer.kv_request_id == kv_request_id + ] + if not ids: + self._finalize_abort(kv_request_id) + return + + started_at = self._pending_aborts.get(kv_request_id) + expired = ( + started_at is not None + and time.monotonic() - started_at >= _CANCEL_DRAIN_TIMEOUT_S + ) + if expired: + for tid in ids: + self._inflight_pop(tid) + self._transport.cancel(ids, mode="immediate") + logger.warning( + "P2PSession %s: cancel drain timed out for kv_request_id=%s," + " force-canceled %d transfers", + self._peer_id, + kv_request_id, + len(ids), + ) + self._finalize_abort(kv_request_id) + return + + still = self._transport.cancel(ids, mode="wait") + # Tids the transport successfully released are gone from its + # _inflight; mirror that in session bookkeeping so they don't + # block the drain forever waiting for a poll() event that will + # never come. + still_set = set(still) + for tid in ids: + if tid not in still_set: + self._inflight_pop(tid) + if not still: + self._finalize_abort(kv_request_id) + + def _finalize_abort(self, kv_request_id: str) -> None: + self._pending_aborts.pop(kv_request_id, None) + self._send( + { + TYPE_KEY: AbortAckMsg.TYPE, + AbortAckMsg.KV_REQUEST_ID: kv_request_id, + } + ) + + # ------------------------------------------------------------------ + # Internal — transfers and store-job timeouts + # ------------------------------------------------------------------ + + def _submit_transfer(self, kv_request_id: str, result: _MatchResult) -> None: + logger.debug( + "P2PSession %s: NIXL write_blocks CALL kv_request_id=%s " + "local_idxs=%d remote_idxs=%d", + self._peer_id, + kv_request_id, + len(result.local_idxs), + len(result.remote_idxs), + ) + transfer_id = self._transport.write_blocks( + self._peer_id, result.local_idxs, result.remote_idxs + ) + if transfer_id is not None: + logger.debug( + "P2PSession %s: NIXL write_blocks SUBMITTED kv_request_id=%s " + "transfer_id=%d blocks=%d", + self._peer_id, + kv_request_id, + transfer_id, + len(result.local_idxs), + ) + self._inflight_add( + transfer_id, + _InflightXfer( + kv_request_id=kv_request_id, + block_count=len(result.local_idxs), + job_ids=result.job_ids, + ), + ) + else: + logger.warning( + "P2PSession %s: write_blocks failed for %s (%d blocks)", + self._peer_id, + kv_request_id, + len(result.local_idxs), + ) + # The matched blocks were popped from req.demanded / + # req.available, but no inflight will satisfy them, so + # remaining will never reach 0 on its own. Mark the + # request as finishing so the existing terminal paths + # clean up: if other inflight is in flight, the last one + # to drain will fire _finalize_outbound(success=False) + # via the elif branch in collect_results. If + # nothing else is in flight, finalize now so the peer + # and the local store jobs don't wait for finish_request + # or for _STORE_TIMEOUT_S / _LOAD_TIMEOUT_S. + req = self._outbound.get(kv_request_id) + if req is not None: + req.finishing = True + if not self._has_inflight_for(kv_request_id): + self._finalize_outbound(kv_request_id, success=False) + + def _timeout_pending_store_jobs(self) -> list[StoreResult]: + if not self._store_jobs: + return [] + deadline = time.monotonic() - _STORE_TIMEOUT_S + timed_out: list[int] | None = None + for jid, submitted_at in self._store_jobs.items(): + if submitted_at <= deadline: + if timed_out is None: + timed_out = [] + timed_out.append(jid) + if timed_out is None: + return [] + results: list[StoreResult] = [] + for jid in timed_out: + del self._store_jobs[jid] + results.append(StoreResult(job_id=jid, success=False)) + logger.warning("P2PSession %s: store job %d timed out", self._peer_id, jid) + return results diff --git a/vllm/v1/kv_offload/tiering/p2p/session/session.py b/vllm/v1/kv_offload/tiering/p2p/session/session.py new file mode 100644 index 000000000000..fc5f1feab583 --- /dev/null +++ b/vllm/v1/kv_offload/tiering/p2p/session/session.py @@ -0,0 +1,440 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +P2PSession — bidirectional session combining client + server roles. + +A single P2PSession per remote peer handles BOTH directions of the P2P +protocol on one ControlConnection: it can request blocks from the peer +("client" role, in :mod:`.client`) AND serve blocks to the peer +("server" role, in :mod:`.server`). This module is the thin coordinator +that owns the connection, the handshake, send-gating, and the message +dispatch — each parsed message is forwarded to the corresponding role. + +Wire protocol is unchanged. Both sides advertise their NIXL metadata +via ConnectMsg when their session is connected; the peer's ConnectMsg +triggers transport.add_remote_peer; ConnectAckMsg confirms the peer +received our ConnectMsg, after which queued outgoing messages are flushed. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Sequence +from typing import TYPE_CHECKING, NamedTuple + +from vllm.logger import init_logger +from vllm.v1.kv_offload.base import OffloadKey +from vllm.v1.kv_offload.tiering.p2p.control.base import ControlConnection +from vllm.v1.kv_offload.tiering.p2p.session.client import ClientRole, LoadResult +from vllm.v1.kv_offload.tiering.p2p.session.protocol import ( + TYPE_KEY, + AbortAckMsg, + AbortFetchMsg, + ConnectAckMsg, + ConnectMsg, + DisconnectMsg, + FetchMsg, + TransferDoneMsg, +) +from vllm.v1.kv_offload.tiering.p2p.session.server import ( + ServerRole, + StoreResult, +) + +if TYPE_CHECKING: + from vllm.v1.kv_offload.tiering.base import JobId + from vllm.v1.kv_offload.tiering.p2p.data import DataTransport + +logger = init_logger(__name__) + +# Cap on consecutive non-protocol dispatch exceptions before we tear +# down the session. Protocol violations (ValueError) disconnect on the +# first occurrence; this threshold protects against repeated internal +# bugs that may indicate a peer-induced bad state. Reset on any +# successful dispatch. +_MAX_CONSECUTIVE_DISPATCH_ERRORS = 5 + + +class SessionPollResult(NamedTuple): + """Result of one P2PSession.poll() tick. + + `loads`/`stores` are the same per-role results the manager has always + consumed. `new_fetch_ids` reports kv_request_ids whose FetchMsg + arrived this tick — the manager uses them to bind kv_request_id → + session and replay any submit_store batches parked while no peer had + asked yet. Reporting (rather than calling back into the manager + mid-dispatch) keeps the dependency strictly top-down. + """ + + loads: list[LoadResult] + stores: list[StoreResult] + new_fetch_ids: list[str] + + +class P2PSession: + """Bidirectional session — coordinator over ClientRole + ServerRole. + + Lifecycle: + - Constructor with conn=None ⇒ pending. Accepts add_stored_blocks + but cannot send (used by the prefiller to buffer blocks before + the decoder connects). + - Constructor with conn != None ⇒ connected. Sends our own ConnectMsg + immediately; the peer's ConnectMsg arrives in poll() and is + dispatched to _on_connect (which calls transport.add_remote_peer + and replies with ConnectAckMsg). Outgoing sends are queued until + ConnectAckMsg confirms our metadata reached the peer. + - attach_connection(conn) on a pending session ⇒ same as above, + starting from pending. + """ + + def __init__( + self, + peer_id: str, + local_id: str, + transport: DataTransport, + local_block_len: int, + conn: ControlConnection | None = None, + ) -> None: + self.peer_id = peer_id + self._local_id = local_id + self._transport = transport + self._local_block_len = local_block_len + self._conn: ControlConnection | None = None + + self._send_ready = False # True after the peer acked our ConnectMsg + # Msgs waiting to be sent on connection establishment + self._queued: list[dict] = [] + + # Consecutive non-protocol dispatch errors. Reset on success. + self._dispatch_error_count: int = 0 + + # kv_request_ids whose FetchMsg arrived during the current poll + # tick. Drained and returned in the next poll() result so the + # manager can bind kv_request_id → session and replay any + # submit_store batches parked before the binding existed. + self._new_fetch_ids: list[str] = [] + + self._client = ClientRole(peer_id=peer_id, send=self._send) + self._server = ServerRole(peer_id=peer_id, transport=transport, send=self._send) + + if conn is not None: + self.attach_connection(conn) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def alive(self) -> bool: + # Pending sessions (awaiting connection) are alive — only a + # closed real connection counts as dead. + return self._conn is None or self._conn.alive + + @property + def connected(self) -> bool: + return self._conn is not None + + @property + def ready(self) -> bool: + """True after the peer acked our ConnectMsg (we may send freely).""" + return self._send_ready + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + def attach_connection(self, conn: ControlConnection) -> None: + """Attach a connection to a pending session and announce ourselves. + + Symmetric: every side advertises its NIXL metadata on connect, so + whichever peer receives a session first can register the other. + """ + if self._conn is not None: + raise ValueError(f"P2PSession {self.peer_id}: already connected") + self._conn = conn + self._send_connect() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def request_blocks( + self, + job_id: JobId, + kv_request_id: str, + keys: Sequence[bytes], + block_ids: Sequence[int], + ) -> None: + """Send fetch to the peer.""" + self._client.request_blocks( + job_id, kv_request_id, keys, block_ids, send_ready=self._send_ready + ) + + def add_stored_blocks( + self, + kv_request_id: str, + keys: Sequence[OffloadKey], + block_ids: Sequence[int], + job_id: JobId, + ) -> None: + """New blocks stored locally — match against pending fetch demand.""" + self._server.add_stored_blocks(kv_request_id, keys, block_ids, job_id) + + def finish_request(self, kv_request_id: str) -> None: + """Called when the request is finishing locally. + + Cancels any inbound load (client role) and finalizes any + outbound serving (server role) for this id. Roles that aren't + active for this id are silent no-ops. + """ + self._client.cancel(kv_request_id) + self._server.finish(kv_request_id) + + def poll(self) -> SessionPollResult: + """Process incoming messages, drive transfers, apply timeouts.""" + if self._conn is None: + # Pending session — store-job timeouts still apply so buffered + # jobs that never get picked up are surfaced as failures. + return SessionPollResult( + loads=[], + stores=self._server.collect_idle_timeouts(), + new_fetch_ids=[], + ) + + for msg in self._conn.recv(): + self._on_message(msg) + + loads = self._client.collect_results() + stores = self._server.collect_results() + self._server.drain_pending_aborts() + + new_fetch_ids = self._new_fetch_ids + self._new_fetch_ids = [] + return SessionPollResult( + loads=loads, stores=stores, new_fetch_ids=new_fetch_ids + ) + + def close(self) -> tuple[list[tuple[int, str]], list[int]]: + """Shut down. Returns (failed_loads, failed_stores). + + failed_loads: list of (job_id, kv_request_id) pairs. + failed_stores: list of job_ids. + """ + failed_loads = self._client.close() + failed_stores = self._server.close() + + if self._conn is not None: + with contextlib.suppress(Exception): + self._conn.send({TYPE_KEY: DisconnectMsg.TYPE}) + self._conn.close() + self._conn = None + + return failed_loads, failed_stores + + # ------------------------------------------------------------------ + # Message dispatch + # ------------------------------------------------------------------ + + def _on_message(self, msg: dict) -> None: + msg_type = msg.get(TYPE_KEY) if isinstance(msg, dict) else msg + try: + self._dispatch_message(msg) + except ValueError as exc: + # Protocol contract violation from the peer — *Msg.validate() + # and handler-level checks raise ValueError. Retrying won't + # help and may corrupt session state, so disconnect now. + self._protocol_error(f"malformed {msg_type!r}: {exc}") + return + except Exception as exc: + # Anything else is most likely an internal bug rather than a + # peer fault. Log loudly with a traceback so it doesn't + # disappear, but don't kill the session on a single hiccup. + # Disconnect only if errors keep arriving — that pattern is + # consistent with a peer wedging us into a broken state. + self._dispatch_error_count += 1 + logger.exception( + "P2PSession %s: error handling message %r (count=%d): %s", + self.peer_id, + msg_type, + self._dispatch_error_count, + exc, + ) + if self._dispatch_error_count >= _MAX_CONSECUTIVE_DISPATCH_ERRORS: + self._protocol_error( + f"too many consecutive dispatch errors " + f"({self._dispatch_error_count})" + ) + return + self._dispatch_error_count = 0 + + def _protocol_error(self, reason: str) -> None: + """Log a protocol violation and disconnect. + + Best-effort sends ``DisconnectMsg`` so the peer learns why we're + going away, then marks the connection dead. The manager reaps + the session on the next poll via ``alive``. + """ + logger.error( + "P2PSession %s: protocol error: %s — disconnecting", + self.peer_id, + reason, + ) + if self._conn is not None: + with contextlib.suppress(Exception): + self._conn.send({TYPE_KEY: DisconnectMsg.TYPE}) + self._conn.mark_dead() + + def _dispatch_message(self, msg: dict) -> None: + # Drop messages buffered before disconnect: a poll batch can + # contain msg-after-DisconnectMsg, and dispatching them would + # mutate state on a dead session. + if self._conn is not None and not self._conn.alive: + return + msg_type = msg.get(TYPE_KEY) if isinstance(msg, dict) else None + if msg_type == ConnectMsg.TYPE: + self._on_connect(msg) + elif msg_type == ConnectAckMsg.TYPE: + ConnectAckMsg.validate(msg) + self._on_connect_ack() + elif msg_type == FetchMsg.TYPE: + FetchMsg.validate(msg) + kv_request_id = msg[FetchMsg.KV_REQUEST_ID] + block_hashes = [ + OffloadKey(bh if isinstance(bh, bytes) else bytes(bh)) + for bh in msg[FetchMsg.BLOCK_HASHES] + ] + block_indexes = msg[FetchMsg.BLOCK_INDEXES] + # Run the server-role state machine inline as today — + # add_fetch_demand records demand against any blocks we've + # already seen in `available`. Report the kv_request_id so + # the manager (after poll() returns) can replay any parked + # submit_store batches; their add_stored_blocks calls hit + # the demand recorded here and submit transfers immediately. + self._server.on_fetch(kv_request_id, block_hashes, block_indexes) + self._new_fetch_ids.append(kv_request_id) + elif msg_type == AbortFetchMsg.TYPE: + AbortFetchMsg.validate(msg) + self._server.on_abort_fetch(msg[AbortFetchMsg.KV_REQUEST_ID]) + elif msg_type == TransferDoneMsg.TYPE: + TransferDoneMsg.validate(msg) + self._client.on_transfer_done( + msg[TransferDoneMsg.KV_REQUEST_ID], + msg[TransferDoneMsg.SUCCESS], + ) + elif msg_type == AbortAckMsg.TYPE: + AbortAckMsg.validate(msg) + self._client.on_abort_ack(msg[AbortAckMsg.KV_REQUEST_ID]) + elif msg_type == DisconnectMsg.TYPE: + if self._conn is not None: + self._conn.mark_dead() + else: + logger.warning( + "P2PSession %s: unknown message type %r", self.peer_id, msg_type + ) + + # ------------------------------------------------------------------ + # Handshake + # ------------------------------------------------------------------ + + def _on_connect(self, msg: dict) -> None: + # Validation failures here mean an incompatible or malicious peer. + # Mark the connection dead so the manager reaps the session; + # don't call add_remote_peer or send connect_ack. + if self._send_ready: + # We've already received connect_ack, so the handshake is + # complete. A second connect from the peer is a protocol + # violation — re-registering would corrupt transport state. + self._protocol_error("duplicate connect after handshake") + return + try: + ConnectMsg.validate(msg) + if msg[ConnectMsg.BLOCK_LEN] != self._local_block_len: + raise ValueError( + f"block_len mismatch from {self.peer_id}: " + f"remote={msg[ConnectMsg.BLOCK_LEN]}, " + f"local={self._local_block_len}" + ) + remote_fp = msg.get(ConnectMsg.CONFIG_FINGERPRINT, "") + local_fp = self._transport.config_fingerprint + if local_fp and remote_fp and remote_fp != local_fp: + raise ValueError( + f"config fingerprint mismatch from {self.peer_id}: " + f"remote={remote_fp!r}, local={local_fp!r}" + ) + self._transport.add_remote_peer( + self.peer_id, + agent_metadata=msg[ConnectMsg.AGENT_METADATA], + base_addr=msg[ConnectMsg.BASE_ADDR], + num_blocks=msg[ConnectMsg.NUM_BLOCKS], + block_len=msg[ConnectMsg.BLOCK_LEN], + ) + except ValueError as exc: + logger.error("P2PSession %s: rejecting peer connect: %s", self.peer_id, exc) + if self._conn is not None: + self._conn.mark_dead() + return + + if self._conn is not None: + self._conn.send( + { + TYPE_KEY: ConnectAckMsg.TYPE, + ConnectAckMsg.PEER_ID: self._local_id, + } + ) + + def _on_connect_ack(self) -> None: + if self._queued: + logger.debug( + "P2PSession %s: connect_ack received, flushing %d queued msg(s)", + self.peer_id, + len(self._queued), + ) + self._send_ready = True + for queued in self._queued: + self._do_send(queued) + self._queued.clear() + + # ------------------------------------------------------------------ + # Send helpers + # ------------------------------------------------------------------ + + def _send_connect(self) -> None: + """Send our ConnectMsg announcing local NIXL metadata.""" + assert self._conn is not None + self._conn.send( + { + TYPE_KEY: ConnectMsg.TYPE, + ConnectMsg.PEER_ID: self._local_id, + ConnectMsg.AGENT_METADATA: self._transport.get_agent_metadata(), + ConnectMsg.BASE_ADDR: self._transport.base_addr, + ConnectMsg.NUM_BLOCKS: self._transport.num_blocks, + ConnectMsg.BLOCK_LEN: self._transport.block_len, + ConnectMsg.CONFIG_FINGERPRINT: self._transport.config_fingerprint, + } + ) + + def _send(self, msg: dict) -> None: + if self._conn is None or not self._send_ready: + logger.debug( + "P2PSession %s: queueing %s (ready=%s queue_depth=%d)", + self.peer_id, + msg.get(TYPE_KEY), + self._send_ready, + len(self._queued) + 1, + ) + self._queued.append(msg) + return + self._do_send(msg) + + def _do_send(self, msg: dict) -> None: + if self._conn is None: + return + try: + self._conn.send(msg) + logger.debug("P2PSession %s: sent %s", self.peer_id, msg.get(TYPE_KEY)) + except Exception: + logger.warning( + "P2PSession %s: failed to send %s", + self.peer_id, + msg.get(TYPE_KEY), + ) diff --git a/vllm/v1/kv_offload/tiering/spec.py b/vllm/v1/kv_offload/tiering/spec.py index a4ea46e08eb9..3dc31a3622ee 100644 --- a/vllm/v1/kv_offload/tiering/spec.py +++ b/vllm/v1/kv_offload/tiering/spec.py @@ -31,14 +31,20 @@ } """ +from typing import Any + import torch from typing_extensions import override from vllm.config import VllmConfig from vllm.logger import init_logger from vllm.v1.kv_cache_interface import KVCacheConfig -from vllm.v1.kv_offload.base import CanonicalKVCaches, OffloadingManager -from vllm.v1.kv_offload.cpu.gpu_worker import CpuGpuOffloadingHandlers +from vllm.v1.kv_offload.base import ( + CanonicalKVCaches, + OffloadingManager, + OffloadingMetricMetadata, +) +from vllm.v1.kv_offload.cpu.gpu_worker import CPUOffloadingWorker from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion from vllm.v1.kv_offload.cpu.spec import CPUOffloadingSpec from vllm.v1.kv_offload.tiering.factory import SecondaryTierFactory @@ -65,10 +71,34 @@ class TieringOffloadingSpec(CPUOffloadingSpec): BLOCK_SIZE_ALIGNMENT = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT + @classmethod + @override + def build_metric_definitions( + cls, extra_config: dict[str, Any] + ) -> dict[str, OffloadingMetricMetadata]: + metrics = super().build_metric_definitions(extra_config) + secondary_tier_configs = extra_config.get("secondary_tiers", []) + if not isinstance(secondary_tier_configs, list): + raise ValueError("secondary_tiers must be a list of tier configurations") + + for tier_config in secondary_tier_configs: + assert isinstance(tier_config, dict) + tier_cls = SecondaryTierFactory.get_tier_class(tier_config) + metrics.update(tier_cls.build_metric_definitions(tier_config)) + return metrics + def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig): super().__init__(vllm_config, kv_cache_config) # Redeclare for mypy: parent sets this but `--follow-imports skip` hides it self._manager: OffloadingManager | None = None + if self.kv_events_config.self_describing_kv_events: + raise ValueError( + "self_describing_kv_events is not supported by " + "TieringOffloadingSpec. Tier promotions can emit primary-tier " + "store events that do not correspond to GPU store jobs, so the " + "current self-describing side table cannot describe them " + "correctly." + ) # Parse secondary tier configurations self.secondary_tier_configs = self.extra_config.get("secondary_tiers", []) @@ -91,11 +121,6 @@ def get_manager(self) -> OffloadingManager: TieringOffloadingManager instance """ if not self._manager: - kv_events_config = self.vllm_config.kv_events_config - enable_events = ( - kv_events_config is not None and kv_events_config.enable_kv_cache_events - ) - # Create scheduler-side SharedOffloadRegion (rank=None) so the # primary tier can eagerly create a memoryview over _base. scheduler_mmap = SharedOffloadRegion( @@ -111,7 +136,7 @@ def get_manager(self) -> OffloadingManager: primary_tier = CPUPrimaryTierOffloadingManager( num_blocks=self.num_blocks, cache_policy=self.eviction_policy, # type: ignore[arg-type] - enable_events=enable_events, + enable_events=self.kv_events_config.enable_kv_cache_events, mmap_region=scheduler_mmap, ) @@ -131,19 +156,18 @@ def get_manager(self) -> OffloadingManager: ) except Exception as e: logger.error( - "Failed to create secondary tier from config %s: %s", - tier_config, + "Failed to create secondary tier from config index %i: %s", + i, e, ) raise # Create TieringOffloadingManager. GPU↔CPU transfers use the inherited - # get_handlers(); secondary tier transfers are handled by the - # secondary tier managers and need no additional handlers here. + # get_worker(). Secondary tier transfers are handled by the + # secondary tier managers and need no additional workers here. tiering_manager = TieringOffloadingManager( primary_tier=primary_tier, secondary_tiers=secondary_tiers, - enable_events=enable_events, ) if int(self.extra_config.get("store_threshold", 0)) >= 2: raise ValueError( @@ -162,7 +186,7 @@ def get_manager(self) -> OffloadingManager: return self._manager @override - def create_handlers(self, kv_caches: CanonicalKVCaches) -> CpuGpuOffloadingHandlers: + def create_worker(self, kv_caches: CanonicalKVCaches) -> CPUOffloadingWorker: rank = torch.accelerator.current_device_index() worker_mmap = SharedOffloadRegion( instance_id=self.vllm_config.instance_id, @@ -171,7 +195,7 @@ def create_handlers(self, kv_caches: CanonicalKVCaches) -> CpuGpuOffloadingHandl kv_bytes_per_block=self.kv_bytes_per_offloaded_block, cpu_page_size=self.cpu_page_size_per_worker, ) - return CpuGpuOffloadingHandlers( + return CPUOffloadingWorker( kv_caches=kv_caches, block_size_factor=self.block_size_factor, num_cpu_blocks=self.num_blocks, diff --git a/vllm/v1/kv_offload/worker/worker.py b/vllm/v1/kv_offload/worker/worker.py deleted file mode 100644 index 2f0dd2471631..000000000000 --- a/vllm/v1/kv_offload/worker/worker.py +++ /dev/null @@ -1,176 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from abc import ABC, abstractmethod -from dataclasses import dataclass - -from vllm.logger import init_logger -from vllm.v1.kv_offload.base import LoadStoreSpec - -# a single transfer spec (src_blocks_spec, dst_blocks_spec) -TransferSpec = tuple[LoadStoreSpec, LoadStoreSpec] -# transfers are forwarded to workers by (src_medium, dst_medium) -TransferType = tuple[str, str] - -logger = init_logger(__name__) - - -@dataclass -class TransferResult: - job_id: int - success: bool - transfer_size: int | None = None # Size in bytes - transfer_time: float | None = None - transfer_type: TransferType | None = None - - -class OffloadingHandler(ABC): - """ - OffloadingHandler class for managing asynchronous KV data transfers - - This class runs in the worker. - It kicks off async KV data transfer requests, and allows - collecting back completion statuses. - - The class provides the following primitives: - transfer_async() - kicks off a new transfer job - get_finished() - returns a list of newly finished job IDs. - """ - - @abstractmethod - def transfer_async(self, job_id: int, spec: TransferSpec) -> bool: - """ - Initiates an asynchronous transfer of KV data. - - Args: - job_id: a unique ID that will be used when notifying back on - transfer completion. - spec: the (src, dst) spec of the KV data transfer. - - Returns: - True if transfer was submitted successfully. - """ - pass - - @abstractmethod - def get_finished(self) -> list[TransferResult]: - """ - Get transfers finished since last call. - - Returns: - A list of (job_id, success) of transfers. - """ - pass - - @abstractmethod - def wait(self, job_ids: set[int]) -> None: - """ - Wait for jobs to finish (blocking). - Args: - job_ids: The set of job IDs to wait for. - """ - - def shutdown(self) -> None: - """Shutdown the handler and release any resources.""" - return - - -class OffloadingWorker: - """ - OffloadingWorker class for managing asynchronous KV data transfers - using multiple OffloadingHandlers - - This class runs in the worker. - It kicks off async KV data transfer requests, by delegating - to one of its registered OffloadingHandlers, based on the transfer type. - - The class provides the following primitives: - register_handler() - registers a new handler to handle - a specific transfer type - transfer_async() - kicks off a new transfer job - using one of the registered handlers. - get_finished() - returns a list of newly finished job IDs - from all handlers. - """ - - def __init__(self): - self.handlers: set[OffloadingHandler] = set() - self.transfer_type_to_handler: dict[TransferType, OffloadingHandler] = {} - - def register_handler( - self, - src_cls: type[LoadStoreSpec], - dst_cls: type[LoadStoreSpec], - handler: OffloadingHandler, - ) -> None: - """ - Registers a new handler. - - Args: - src_cls: the source type of transfers handled by this handler. - dst_cls: the destination type of transfers handled by this handler. - handler: the handler that will handle transfers. - """ - transfer_type = (src_cls.medium(), dst_cls.medium()) - assert transfer_type not in self.transfer_type_to_handler - self.handlers.add(handler) - self.transfer_type_to_handler[transfer_type] = handler - - def transfer_async(self, job_id: int, spec: TransferSpec) -> bool: - """ - Initiates an asynchronous transfer of KV data. - - Args: - job_id: a unique ID that will be used when notifying back on - transfer completion. - spec: the (src, dst) spec of the KV data transfer. - - Returns: - True if transfer was submitted successfully. - """ - src, dst = spec - transfer_type = (src.medium(), dst.medium()) - handler = self.transfer_type_to_handler.get(transfer_type) - assert handler is not None - try: - success = handler.transfer_async(job_id, spec) - except Exception as e: - logger.warning( - "Exception in %r transfer %d: %r", - transfer_type, - job_id, - e, - exc_info=True, - ) - return False - - if not success: - logger.warning("Failed to submit %r transfer %d", transfer_type, job_id) - else: - logger.debug("Submitted %r transfer %d: %r", transfer_type, job_id, spec) - return success - - def get_finished(self) -> list[TransferResult]: - """ - Get transfers finished since last call. - - Returns: - A list of TransferResults - """ - finished = [] - for handler in self.handlers: - finished.extend(handler.get_finished()) - return finished - - def wait(self, job_ids: set[int]) -> None: - """ - Wait for jobs to finish (blocking). - - Args: - job_ids: The set of job IDs to wait for. - """ - for handler in self.handlers: - handler.wait(job_ids) - - def shutdown(self) -> None: - for handler in self.handlers: - handler.shutdown() diff --git a/vllm/v1/metrics/loggers.py b/vllm/v1/metrics/loggers.py index 0052a35366a3..021019dc1cdc 100644 --- a/vllm/v1/metrics/loggers.py +++ b/vllm/v1/metrics/loggers.py @@ -110,7 +110,9 @@ def __init__(self, vllm_config: VllmConfig, engine_index: int = 0): self.connector_prefix_caching_metrics = CachingMetrics() self.mm_caching_metrics = CachingMetrics() - self.spec_decoding_logging = SpecDecodingLogging() + model_config = self.vllm_config.model_config + is_diffusion = model_config is not None and model_config.is_diffusion + self.spec_decoding_logging = SpecDecodingLogging(is_diffusion=is_diffusion) kv_transfer_config = self.vllm_config.kv_transfer_config self.kv_connector_logging = KVConnectorLogging(kv_transfer_config) self.cudagraph_logging = None @@ -436,7 +438,10 @@ def __init__( per_engine_labelvalues = self.per_engine_labelvalues self.spec_decoding_prom = self._spec_decoding_cls( - vllm_config.speculative_config, labelnames, per_engine_labelvalues + vllm_config.speculative_config, + labelnames, + per_engine_labelvalues, + is_diffusion=vllm_config.model_config.is_diffusion, ) self.kv_connector_prom = self._kv_connector_cls( vllm_config, labelnames, per_engine_labelvalues diff --git a/vllm/v1/metrics/perf.py b/vllm/v1/metrics/perf.py index 38135b9b1584..a1dceeab461b 100644 --- a/vllm/v1/metrics/perf.py +++ b/vllm/v1/metrics/perf.py @@ -66,7 +66,6 @@ class InvalidComponent(Exception): "bitsandbytes": 0.5, "modelopt_fp4": 0.5, "petit_nvfp4": 0.5, - "gguf": 0.5, "compressed-tensors": 0.5, "torchao": 0.5, "quark": 0.5, @@ -396,6 +395,20 @@ def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs: return args +class AttentionDetectionParser(Parser): + """ + Prevents standard AttentionMetrics from being instantiated for MLA models. + MLA models should use MLAAttentionMetrics instead. + """ + + def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs: + if vllm_config.model_config.is_deepseek_mla: + raise InvalidComponent( + "Model uses MLA attention; use MLAAttentionMetrics instead" + ) + return args + + class AttentionMetrics(ComponentMetrics): # From BaseConfigParser num_hidden_layers: int = Field(..., gt=0) @@ -423,6 +436,7 @@ def component_type(cls) -> str: @classmethod def get_parser(cls) -> ParserChain: return ParserChain( + AttentionDetectionParser(), BaseConfigParser(), BaseAttentionConfigParser(), AttentionQuantizationConfigParser(), @@ -525,6 +539,276 @@ def get_write_bytes_breakdown( } +#### MLA Attention #### + + +class MLADetectionParser(Parser): + """ + Validates that the model uses MLA attention. + Raises InvalidComponent if the model does not use MLA, + so MLAAttentionMetrics is silently skipped for non-MLA models. + """ + + def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs: + if not vllm_config.model_config.is_deepseek_mla: + raise InvalidComponent("Model does not use MLA attention") + return args + + +class MLAConfigParser(Parser): + """ + Parses MLA-specific configuration fields. + Provides: kv_lora_rank, qk_nope_head_dim, qk_rope_head_dim, + v_head_dim, q_lora_rank + """ + + def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs: + model_config = vllm_config.model_config + cfg = model_config.hf_text_config + + args.kv_lora_rank = get_required(cfg, "kv_lora_rank") + args.qk_nope_head_dim = get_required(cfg, "qk_nope_head_dim") + args.qk_rope_head_dim = get_required(cfg, "qk_rope_head_dim") + args.v_head_dim = get_required(cfg, "v_head_dim") + args.q_lora_rank = getattr(cfg, "q_lora_rank", None) + + model_dtype = vllm_config.model_config.dtype + cache_dtype = vllm_config.cache_config.cache_dtype + kv_cache_torch_dtype = get_kv_cache_torch_dtype(cache_dtype, model_dtype) + args.cache_byte_size = get_dtype_size(kv_cache_torch_dtype) + + return args + + +class MLAAttentionMetrics(ComponentMetrics): + """ + Performance metrics for Multi-Latent Attention (MLA) layers. + + MLA uses a compressed latent representation for KV cache: + - KV cache stores a single compressed vector of size + (kv_lora_rank + qk_rope_head_dim) per token per layer, + instead of 2 * num_kv_heads * head_dim as in standard MHA/GQA. + - Q path uses optional low-rank compression: + h -> q_lora_rank -> num_heads * qk_head_dim + - KV path: h -> (kv_lora_rank + qk_rope_head_dim), + then kv_lora_rank -> num_heads * (qk_nope_head_dim + v_head_dim) + + Used by DeepSeek-V2, DeepSeek-V3, DeepSeek-R1, and similar models. + """ + + # From BaseConfigParser + num_hidden_layers: int = Field(..., gt=0) + hidden_size: int = Field(..., gt=0) + num_attention_heads: int = Field(..., gt=0) + activation_byte_size: int = Field(..., gt=0) + tp_size: int = Field(..., gt=0) + pp_size: int = Field(..., gt=0) + + # From BaseConfigParser, can be overridden by AttentionQuantizationConfigParser + weight_byte_size: int | float = Field(..., gt=0) + + # From MLAConfigParser + kv_lora_rank: int = Field(..., gt=0) + qk_nope_head_dim: int = Field(..., gt=0) + qk_rope_head_dim: int = Field(..., gt=0) + v_head_dim: int = Field(..., gt=0) + q_lora_rank: int | None = Field(None) + cache_byte_size: int = Field(..., gt=0) + + @classmethod + def component_type(cls) -> str: + return "mla_attn" + + @classmethod + def get_parser(cls) -> ParserChain: + return ParserChain( + MLADetectionParser(), + BaseConfigParser(), + MLAConfigParser(), + AttentionQuantizationConfigParser(), + ) + + def get_num_flops_breakdown( + self, ctx: ExecutionContext, per_gpu: bool = True + ) -> dict[str, int]: + """Calculate flops breakdown for MLA attention layers. + + MLA projection structure: + - Q path: h -> q_lora_rank -> num_heads * qk_head_dim + (or h -> num_heads * qk_head_dim if q_lora_rank is None) + - KV path: h -> (kv_lora_rank + qk_rope_head_dim), + then kv_lora_rank -> num_heads * (qk_nope_head_dim + v_head_dim) + - Attention: Q @ K^T and attn @ V + - Output: num_heads * v_head_dim -> h + """ + L = self.num_hidden_layers + D = self.hidden_size + q = self.num_attention_heads + qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim + v_d = self.v_head_dim + c = self.kv_lora_rank + r = self.qk_rope_head_dim + q_rank = self.q_lora_rank + + T = ctx.total_num_tokens() + TC = ctx.total_token_context_product() + + if per_gpu: + L //= self.pp_size + q = max(1, q // self.tp_size) + + flops: dict[str, int] = {} + + # Q projection + if q_rank is not None: + # Two-stage: h -> q_lora_rank -> num_heads * qk_head_dim + flops["q_a_proj"] = 2 * T * D * q_rank * L + flops["q_b_proj"] = 2 * T * q_rank * q * qk_head_dim * L + else: + # Direct: h -> num_heads * qk_head_dim + flops["q_proj"] = 2 * T * D * q * qk_head_dim * L + + # KV projection (always compressed, shared across heads) + # kv_a: h -> (kv_lora_rank + qk_rope_head_dim) [replicated] + flops["kv_a_proj"] = 2 * T * D * (c + r) * L + # kv_b: kv_lora_rank -> num_heads * (qk_nope + v_head_dim) + flops["kv_b_proj"] = 2 * T * c * q * (self.qk_nope_head_dim + v_d) * L + + # Attention core + flops["attn_qk"] = 2 * q * TC * qk_head_dim * L + flops["attn_av"] = 2 * q * TC * v_d * L + + # Output projection: num_heads * v_head_dim -> h + flops["out_proj"] = 2 * T * q * v_d * D * L + + return flops + + def get_read_bytes_breakdown( + self, ctx: ExecutionContext, per_gpu: bool = True + ) -> dict[str, int]: + """Calculate read memory traffic for MLA attention layers.""" + L = self.num_hidden_layers + D = self.hidden_size + q = self.num_attention_heads + qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim + v_d = self.v_head_dim + c = self.kv_lora_rank + r = self.qk_rope_head_dim + q_rank = self.q_lora_rank + + T = ctx.total_num_tokens() + # Compressed KV cache size per token + kv_compressed_dim = c + r + + if per_gpu: + L //= self.pp_size + q = max(1, q // self.tp_size) + + read_bytes: dict[str, int] = {} + + # Q projection weight + input reads + if q_rank is not None: + read_bytes["q_a_input"] = T * D * self.activation_byte_size * L + read_bytes["q_a_weight"] = int(D * q_rank * self.weight_byte_size * L) + read_bytes["q_b_input"] = T * q_rank * self.activation_byte_size * L + read_bytes["q_b_weight"] = int( + q_rank * q * qk_head_dim * self.weight_byte_size * L + ) + else: + read_bytes["q_input"] = T * D * self.activation_byte_size * L + read_bytes["q_weight"] = int( + D * q * qk_head_dim * self.weight_byte_size * L + ) + + # KV projection weight + input reads + # kv_a is replicated (not TP-sharded) + read_bytes["kv_a_input"] = T * D * self.activation_byte_size * L + read_bytes["kv_a_weight"] = int( + D * kv_compressed_dim * self.weight_byte_size * L + ) + # kv_b is TP-sharded along heads + read_bytes["kv_b_input"] = T * c * self.activation_byte_size * L + read_bytes["kv_b_weight"] = int( + c * q * (self.qk_nope_head_dim + v_d) * self.weight_byte_size * L + ) + + # Attention input reads + # Prefill: read Q activations + K,V from kv_b_proj output + if ctx.prefill_num_tokens > 0: + read_bytes["attn_input"] = ( + ctx.prefill_num_tokens * q * qk_head_dim * self.activation_byte_size * L + + ctx.prefill_context_len + * q + * (qk_head_dim + v_d) + * self.activation_byte_size + * L + ) + + # Decode: read Q activations + read compressed KV from cache + if ctx.decode_num_tokens > 0: + read_bytes["attn_input"] = read_bytes.get("attn_input", 0) + ( + ctx.decode_num_tokens * q * qk_head_dim * self.activation_byte_size * L + + ctx.decode_context_len * kv_compressed_dim * self.cache_byte_size * L + ) + + # Output projection reads + read_bytes["out_input"] = T * q * v_d * self.activation_byte_size * L + read_bytes["out_weight"] = int(q * v_d * D * self.weight_byte_size * L) + + return read_bytes + + def get_write_bytes_breakdown( + self, ctx: ExecutionContext, per_gpu: bool = True + ) -> dict[str, int]: + """Calculate write memory traffic for MLA attention layers.""" + L = self.num_hidden_layers + D = self.hidden_size + q = self.num_attention_heads + qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim + v_d = self.v_head_dim + c = self.kv_lora_rank + r = self.qk_rope_head_dim + q_rank = self.q_lora_rank + + T = ctx.total_num_tokens() + kv_compressed_dim = c + r + + if per_gpu: + L //= self.pp_size + q = max(1, q // self.tp_size) + + write_bytes: dict[str, int] = {} + + # Q projection outputs + if q_rank is not None: + write_bytes["q_a_output"] = T * q_rank * self.activation_byte_size * L + write_bytes["q_b_output"] = ( + T * q * qk_head_dim * self.activation_byte_size * L + ) + else: + write_bytes["q_output"] = ( + T * q * qk_head_dim * self.activation_byte_size * L + ) + + # KV projection outputs + write_bytes["kv_a_output"] = ( + T * kv_compressed_dim * self.activation_byte_size * L + ) + write_bytes["kv_b_output"] = ( + T * q * (self.qk_nope_head_dim + v_d) * self.activation_byte_size * L + ) + + # KV cache write: one compressed vector per token + # (kv_lora_rank + qk_rope_head_dim) instead of + # 2 * num_kv_heads * head_dim in standard MHA + write_bytes["kv_cache"] = T * kv_compressed_dim * self.cache_byte_size * L + + # Output projection + write_bytes["out_output"] = T * D * self.activation_byte_size * L + + return write_bytes + + #### Ffn #### diff --git a/vllm/v1/metrics/prometheus.py b/vllm/v1/metrics/prometheus.py index 1eacb785aa84..c8740276713b 100644 --- a/vllm/v1/metrics/prometheus.py +++ b/vllm/v1/metrics/prometheus.py @@ -64,7 +64,7 @@ def unregister_vllm_metrics(): registry = REGISTRY # Unregister any existing vLLM collectors for collector in list(registry._collector_to_names): - if hasattr(collector, "_name") and "vllm" in collector._name: + if hasattr(collector, "_name") and collector._name.startswith("vllm:"): registry.unregister(collector) diff --git a/vllm/v1/worker/gpu/pool/late_interaction_runner.py b/vllm/v1/pool/late_interaction_runner.py similarity index 100% rename from vllm/v1/worker/gpu/pool/late_interaction_runner.py rename to vllm/v1/pool/late_interaction_runner.py diff --git a/vllm/v1/pool/metadata.py b/vllm/v1/pool/metadata.py index f772c850f0dc..9a9bb2b0e710 100644 --- a/vllm/v1/pool/metadata.py +++ b/vllm/v1/pool/metadata.py @@ -7,9 +7,7 @@ from vllm.pooling_params import PoolingParams from vllm.tasks import PoolingTask -from vllm.utils.platform_utils import is_pin_memory_available - -pin_memory = is_pin_memory_available() +from vllm.utils.torch_utils import PIN_MEMORY @dataclass @@ -134,7 +132,7 @@ def build_pooling_cursor( num_scheduled_tokens_cpu = torch.from_numpy(num_scheduled_tokens_np) if query_start_loc_gpu is None: cumsum = torch.zeros( - n_seq + 1, dtype=torch.int64, pin_memory=pin_memory, device="cpu" + n_seq + 1, dtype=torch.int64, pin_memory=PIN_MEMORY, device="cpu" ) torch.cumsum(num_scheduled_tokens_cpu, dim=0, out=cumsum[1:]) cumsum = cumsum.to(device, non_blocking=True) diff --git a/vllm/v1/request.py b/vllm/v1/request.py index 44246e70a8bb..e9946a7f76b8 100644 --- a/vllm/v1/request.py +++ b/vllm/v1/request.py @@ -141,10 +141,19 @@ def __init__( self.num_output_placeholders = 0 self.async_tokens_to_discard = 0 + # Tokens of steps whose output is not yet processed (async scheduling + # and PP run ahead of the GPU); `num_computed_tokens` counts them + # optimistically. + self.num_in_flight_tokens = 0 + # V2+PP+async: Enforces `pp_size` cadence between same-request decode steps # so the worker's broadcast slot ring stays consistent. self.next_decode_eligible_step = 0 + # Seq of the most recent step this request was scheduled in; fences + # deferred block freeing (see Scheduler._free_request_blocks). + self.last_sched_seq = 0 + self.spec_token_ids: list[int] = [] self.num_computed_tokens = 0 self.cache_salt: str | None = cache_salt diff --git a/vllm/v1/sample/logits_processor/builtin.py b/vllm/v1/sample/logits_processor/builtin.py index 11a52711d671..d7c9444380b8 100644 --- a/vllm/v1/sample/logits_processor/builtin.py +++ b/vllm/v1/sample/logits_processor/builtin.py @@ -7,6 +7,7 @@ import torch from vllm import SamplingParams +from vllm.utils.torch_utils import async_tensor_h2d from vllm.v1.sample.logits_processor.interface import ( BatchUpdate, LogitsProcessor, @@ -118,7 +119,6 @@ def apply(self, logits: torch.Tensor) -> torch.Tensor: class LogitBiasLogitsProcessor(LogitsProcessor): def __init__(self, _, device: torch.device, is_pin_memory: bool): self.device = device - self.pin_memory = is_pin_memory self.biases: dict[int, dict[int, float]] = {} self.bias_tensor: torch.Tensor = torch.tensor(()) @@ -154,9 +154,7 @@ def update_state(self, batch_update: BatchUpdate | None): ) def _device_tensor(self, data: list, dtype: torch.dtype) -> torch.Tensor: - return torch.tensor( - data, device="cpu", dtype=dtype, pin_memory=self.pin_memory - ).to(device=self.device, non_blocking=True) + return async_tensor_h2d(data, device=self.device, dtype=dtype) def apply(self, logits: torch.Tensor) -> torch.Tensor: if self.biases: @@ -170,7 +168,6 @@ def __init__( ): # index -> (min_toks, output_token_ids, stop_token_ids) self.device = device - self.pin_memory = is_pin_memory self.min_toks: dict[int, tuple[int, Sequence[int], set[int]]] = {} # (req_idx_tensor,eos_tok_id_tensor) @@ -227,9 +224,7 @@ def update_state(self, batch_update: BatchUpdate | None): ) def _device_tensor(self, data: list, dtype: torch.dtype) -> torch.Tensor: - return torch.tensor( - data, device="cpu", dtype=dtype, pin_memory=self.pin_memory - ).to(device=self.device, non_blocking=True) + return async_tensor_h2d(data, device=self.device, dtype=dtype) def apply(self, logits: torch.Tensor) -> torch.Tensor: if self.min_toks: @@ -283,8 +278,8 @@ def apply_with_spec_decode( toks_arr = np.concatenate(all_toks) # (row_indices, token_indices) for index_put_ to set -inf. logits_slice = ( - torch.from_numpy(rows_arr).to(self.device, non_blocking=True), - torch.from_numpy(toks_arr).to(self.device, non_blocking=True), + async_tensor_h2d(rows_arr, device=self.device), + async_tensor_h2d(toks_arr, device=self.device), ) logits.index_put_(logits_slice, self.neg_inf_tensor) diff --git a/vllm/v1/sample/ops/penalties.py b/vllm/v1/sample/ops/penalties.py index 241d9de957ea..7bc6ec7ab893 100644 --- a/vllm/v1/sample/ops/penalties.py +++ b/vllm/v1/sample/ops/penalties.py @@ -4,8 +4,7 @@ import torch from vllm.model_executor.layers.utils import apply_penalties -from vllm.utils.platform_utils import is_pin_memory_available -from vllm.utils.torch_utils import make_tensor_with_pad +from vllm.utils.torch_utils import PIN_MEMORY, make_tensor_with_pad def apply_all_penalties( @@ -52,6 +51,6 @@ def _convert_to_tensors( pad=vocab_size, device="cpu", dtype=torch.int64, - pin_memory=is_pin_memory_available(), + pin_memory=PIN_MEMORY, ) return output_tokens_tensor.to(device, non_blocking=True) diff --git a/vllm/v1/sample/ops/topk_topp_sampler.py b/vllm/v1/sample/ops/topk_topp_sampler.py index baa0e77119bf..69b35830add2 100644 --- a/vllm/v1/sample/ops/topk_topp_sampler.py +++ b/vllm/v1/sample/ops/topk_topp_sampler.py @@ -75,9 +75,14 @@ class TopKTopPSampler(nn.Module): Implementations may update the logits tensor in-place. """ - def __init__(self, logprobs_mode: LogprobsMode = "raw_logprobs") -> None: + def __init__( + self, + logprobs_mode: LogprobsMode = "raw_logprobs", + use_fp64_gumbel: bool = False, + ) -> None: super().__init__() self.logprobs_mode = logprobs_mode + self.use_fp64_gumbel = use_fp64_gumbel if current_platform.is_cuda(): # FlashInfer doesn't expose post-top-k/top-p logits/logprobs, # so it can't be used when the configured mode requires them. @@ -106,20 +111,12 @@ def __init__(self, logprobs_mode: LogprobsMode = "raw_logprobs") -> None: logprobs_mode not in ("processed_logits", "processed_logprobs") and rocm_aiter_ops.is_enabled() ): - try: - import aiter.ops.sampling # noqa: F401 - - self.aiter_ops = torch.ops.aiter - logger.info_once( - "Using aiter sampler on ROCm (lazy import, sampling-only)." - ) - self.forward = self.forward_hip - except ImportError: - logger.warning_once( - "aiter.ops.sampling is not available on ROCm. " - "Falling back to forward_native implementation." - ) - self.forward = self.forward_native + self.aiter_ops = None + self._aiter_ops_import_failed = False + logger.info_once( + "Using aiter sampler on ROCm (lazy import, sampling-only)." + ) + self.forward = self.forward_hip else: self.forward = self.forward_native @@ -142,7 +139,10 @@ def forward_native( elif self.logprobs_mode == "processed_logprobs": logits_to_return = logits.log_softmax(dim=-1, dtype=torch.float32) probs = logits.softmax(dim=-1, dtype=torch.float32) - return random_sample(probs, generators), logits_to_return + return ( + random_sample(probs, generators, self.use_fp64_gumbel), + logits_to_return, + ) def forward_cuda( self, @@ -163,6 +163,8 @@ def forward_cuda( "PyTorch-native implementation." ) return self.forward_native(logits, generators, k, p) + if self.use_fp64_gumbel: + return self.forward_native(logits, generators, k, p) assert self.logprobs_mode not in ("processed_logits", "processed_logprobs"), ( "FlashInfer does not support returning logits/logprobs" ) @@ -190,16 +192,32 @@ def forward_cpu( elif self.logprobs_mode == "processed_logprobs": logits_to_return = logits.log_softmax(dim=-1, dtype=torch.float32) - if len(generators) != logits.shape[0]: + if len(generators) != logits.shape[0] and not self.use_fp64_gumbel: return compiled_random_sample(logits), logits_to_return probs = logits.softmax(dim=-1, dtype=torch.float32) - q = torch.empty_like(probs) + q = empty_exponential_noise_like(probs, self.use_fp64_gumbel) q.exponential_() for i, generator in generators.items(): q[i].exponential_(generator=generator) - return probs.div_(q).argmax(dim=-1).view(-1), logits_to_return + return sample_with_exponential_noise(probs, q), logits_to_return + + def _init_aiter_ops(self) -> bool: + if self._aiter_ops_import_failed: + return False + try: + import aiter.ops.sampling # noqa: F401 + except ImportError: + self._aiter_ops_import_failed = True + self.forward = self.forward_native + logger.warning_once( + "aiter.ops.sampling is not available on ROCm. " + "Falling back to PyTorch-native implementation." + ) + return False + self.aiter_ops = torch.ops.aiter + return True def forward_hip( self, @@ -216,10 +234,14 @@ def forward_hip( "falling back to PyTorch-native." ) return self.forward_native(logits, generators, k, p) + if self.use_fp64_gumbel: + return self.forward_native(logits, generators, k, p) assert self.logprobs_mode not in ( "processed_logits", "processed_logprobs", ), "aiter sampler does not support returning logits/logprobs." + if self.aiter_ops is None and not self._init_aiter_ops(): + return self.forward_native(logits, generators, k, p) return self.aiter_sample(logits, k, p, generators), None def aiter_sample( @@ -230,6 +252,7 @@ def aiter_sample( generators: dict[int, torch.Generator], ) -> torch.Tensor: """Sample from logits using aiter ops.""" + assert self.aiter_ops is not None use_top_k = k is not None use_top_p = p is not None # Joint k+p path @@ -404,16 +427,33 @@ def apply_top_k_only(logits: torch.Tensor, k: torch.Tensor) -> torch.Tensor: return logits.masked_fill_(logits < top_k_mask, -float("inf")) +def empty_exponential_noise_like( + probs: torch.Tensor, use_fp64_gumbel: bool +) -> torch.Tensor: + dtype = torch.float64 if use_fp64_gumbel else probs.dtype + return torch.empty(probs.shape, dtype=dtype, device=probs.device) + + +def sample_with_exponential_noise(probs: torch.Tensor, q: torch.Tensor) -> torch.Tensor: + if q.dtype == probs.dtype: + scores = probs.div_(q) + else: + scores = q.reciprocal_() + scores.mul_(probs) + return scores.argmax(dim=-1).view(-1) + + def random_sample( probs: torch.Tensor, generators: dict[int, torch.Generator], + use_fp64_gumbel: bool = False, ) -> torch.Tensor: """Randomly sample from the probabilities. We use this function instead of torch.multinomial because torch.multinomial causes CPU-GPU synchronization. """ - q = torch.empty_like(probs) + q = empty_exponential_noise_like(probs, use_fp64_gumbel) # NOTE(woosuk): To batch-process the requests without their own seeds, # which is the common case, we first assume that every request does # not have its own seed. Then, we overwrite the values for the requests @@ -425,7 +465,7 @@ def random_sample( # one by one. Optimize this. for i, generator in generators.items(): q[i].exponential_(generator=generator) - return probs.div_(q).argmax(dim=-1).view(-1) + return sample_with_exponential_noise(probs, q) def flashinfer_sample( diff --git a/vllm/v1/sample/ops/topk_topp_triton.py b/vllm/v1/sample/ops/topk_topp_triton.py old mode 100644 new mode 100755 index bfe6fd6ae526..c284ff61876b --- a/vllm/v1/sample/ops/topk_topp_triton.py +++ b/vllm/v1/sample/ops/topk_topp_triton.py @@ -111,7 +111,7 @@ def _topk_topp_kernel( pid = tl.program_id(0) num_programs = tl.num_programs(0) for row_id in tl.range(pid, BATCH_SIZE, num_programs): - LOGITS_ROW = LOGITS + row_id * LOGITS_STRIDE_0 + LOGITS_ROW = LOGITS + row_id.to(tl.int64) * LOGITS_STRIDE_0 BUFFER_ROW = BUFFER + pid * VOCAB_SIZE final_pivot = -float("inf") @@ -929,8 +929,12 @@ def apply_top_k_top_p_triton( normal_cdf_to_sigma_table, percentile_to_std_table = tables # Smaller tiles compile and run faster on CPU; GPU benefits from larger tiles. + # On XPU, large BLOCK_SIZE causes precision loss in the single-pass pivot + # approximation; use smaller tiles for accurate top-p results. if logits.device.type == "cpu": block_size, block_size_trunc = 256, 128 + elif logits.device.type == "xpu": + block_size, block_size_trunc = 4096, 2048 else: block_size, block_size_trunc = 8192, 4096 diff --git a/vllm/v1/sample/rejection_sampler.py b/vllm/v1/sample/rejection_sampler.py index 678654cb78a4..1324191be74c 100644 --- a/vllm/v1/sample/rejection_sampler.py +++ b/vllm/v1/sample/rejection_sampler.py @@ -65,6 +65,7 @@ def __init__( ): super().__init__() self.sampler = sampler + self.use_fp64_gumbel = getattr(sampler, "use_fp64_gumbel", False) logprobs_mode = self.sampler.logprobs_mode self.is_processed_logprobs_mode = logprobs_mode.startswith("processed") self.is_logits_logprobs_mode = logprobs_mode.endswith("logits") @@ -176,6 +177,7 @@ def forward( sampling_metadata, synthetic_mode=self.synthetic_mode, synthetic_conditional_rates=self.synthetic_conditional_rates, + use_fp64_gumbel=self.use_fp64_gumbel, ) logprobs_tensors = None @@ -406,6 +408,7 @@ def rejection_sample( sampling_metadata: SamplingMetadata, synthetic_mode: bool = False, synthetic_conditional_rates: torch.Tensor | None = None, + use_fp64_gumbel: bool = False, ) -> torch.Tensor: assert draft_token_ids.ndim == 1 assert draft_probs is None or draft_probs.ndim == 2 @@ -480,6 +483,7 @@ def rejection_sample( target_probs, sampling_metadata, device, + use_fp64_gumbel, ) # Rejection sampling for random sampling requests. @@ -669,13 +673,15 @@ def sample_recovered_tokens( target_probs: torch.Tensor, sampling_metadata: SamplingMetadata, device: torch.device, + use_fp64_gumbel: bool = False, ) -> torch.Tensor: # NOTE(woosuk): Create only one distribution for each request. batch_size = len(num_draft_tokens) vocab_size = target_probs.shape[-1] + q_dtype = torch.float64 if use_fp64_gumbel else torch.float32 q = torch.empty( (batch_size, vocab_size), - dtype=torch.float32, + dtype=q_dtype, device=device, ) q.exponential_() @@ -699,6 +705,7 @@ def sample_recovered_tokens( vocab_size, BLOCK_SIZE, NO_DRAFT_PROBS=draft_probs is None, + USE_FP64_GUMBEL=use_fp64_gumbel, ) return recovered_token_ids @@ -725,7 +732,11 @@ def rejection_greedy_sample_kernel( # Early exit for non-greedy sampling requests. return - start_idx = 0 if req_idx == 0 else tl.load(cu_num_draft_tokens_ptr + req_idx - 1) + start_idx = ( + tl.zeros([], dtype=cu_num_draft_tokens_ptr.dtype.element_ty) + if req_idx == 0 + else tl.load(cu_num_draft_tokens_ptr + req_idx - 1) + ) end_idx = tl.load(cu_num_draft_tokens_ptr + req_idx) num_draft_tokens = end_idx - start_idx @@ -737,7 +748,8 @@ def rejection_greedy_sample_kernel( if SYNTHETIC_MODE: uniform_prob = tl.load(uniform_probs_ptr + start_idx + pos) rate = tl.load(synthetic_conditional_rates_ptr + pos) - accepted = uniform_prob < rate + # -1 is used for padded draft token ids that should be rejected. + accepted = (uniform_prob < rate) and draft_token_id >= 0 token_id = draft_token_id if accepted else target_argmax_id rejected = not accepted else: @@ -781,7 +793,11 @@ def rejection_random_sample_kernel( # Early exit for greedy sampling requests. return - start_idx = 0 if req_idx == 0 else tl.load(cu_num_draft_tokens_ptr + req_idx - 1) + start_idx = ( + tl.zeros([], dtype=cu_num_draft_tokens_ptr.dtype.element_ty) + if req_idx == 0 + else tl.load(cu_num_draft_tokens_ptr + req_idx - 1) + ) end_idx = tl.load(cu_num_draft_tokens_ptr + req_idx) num_draft_tokens = end_idx - start_idx @@ -790,7 +806,10 @@ def rejection_random_sample_kernel( if not rejected: draft_token_id = tl.load(draft_token_ids_ptr + start_idx + pos) uniform_prob = tl.load(uniform_probs_ptr + start_idx + pos) - if SYNTHETIC_MODE: + if draft_token_id < 0: + # -1 is used for padded draft token ids that should be rejected. + accepted = False + elif SYNTHETIC_MODE: rate = tl.load(synthetic_conditional_rates_ptr + pos) accepted = uniform_prob < rate else: @@ -837,8 +856,8 @@ def expand_kernel( MAX_NUM_TOKENS: tl.constexpr, ): req_idx = tl.program_id(0) - if req_idx == 0: # noqa: SIM108 - start_idx = 0 + if req_idx == 0: + start_idx = tl.zeros([], dtype=cu_num_tokens_ptr.dtype.element_ty) else: start_idx = tl.load(cu_num_tokens_ptr + req_idx - 1) end_idx = tl.load(cu_num_tokens_ptr + req_idx) @@ -861,9 +880,14 @@ def sample_recovered_tokens_kernel( vocab_size, BLOCK_SIZE: tl.constexpr, NO_DRAFT_PROBS: tl.constexpr, + USE_FP64_GUMBEL: tl.constexpr, ): req_idx = tl.program_id(0) - start_idx = 0 if req_idx == 0 else tl.load(cu_num_draft_tokens_ptr + req_idx - 1) + start_idx = ( + tl.zeros([], dtype=cu_num_draft_tokens_ptr.dtype.element_ty) + if req_idx == 0 + else tl.load(cu_num_draft_tokens_ptr + req_idx - 1) + ) end_idx = tl.load(cu_num_draft_tokens_ptr + req_idx) num_draft_tokens = end_idx - start_idx @@ -877,7 +901,10 @@ def sample_recovered_tokens_kernel( if NO_DRAFT_PROBS: draft_token_id = tl.load(draft_token_ids_ptr + token_idx) - max_val = float("-inf") + if USE_FP64_GUMBEL: + max_val = tl.full((), float("-inf"), tl.float64) + else: + max_val = tl.full((), float("-inf"), tl.float32) recovered_id = 0 for v in range(0, vocab_size, BLOCK_SIZE): vocab_offset = v + tl.arange(0, BLOCK_SIZE) @@ -910,12 +937,17 @@ def sample_recovered_tokens_kernel( other=0.0, ) - # Local tile reduction + # Local tile reduction. + # Mask out-of-vocabulary entries to -inf so they can never win + # the argmax — prevents producing recovered_id >= vocab_size + # when all valid entries in the last tile have zero probability. score = prob * inv_q + score = tl.where(vocab_mask, score, float("-inf")) local_max, local_id = tl.max(score, axis=0, return_indices=True) if local_max > max_val: max_val = local_max recovered_id = v + local_id + recovered_id = tl.minimum(recovered_id, vocab_size - 1) tl.store(output_token_ids_ptr + token_idx, recovered_id) diff --git a/vllm/v1/sample/sampler.py b/vllm/v1/sample/sampler.py index 9ac3821a3261..bb20432a0815 100644 --- a/vllm/v1/sample/sampler.py +++ b/vllm/v1/sample/sampler.py @@ -6,7 +6,7 @@ import torch.nn as nn from vllm.config.model import LogprobsMode -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.outputs import LogprobsTensors, SamplerOutput from vllm.v1.sample.metadata import SamplingMetadata from vllm.v1.sample.ops.bad_words import apply_bad_words @@ -58,11 +58,16 @@ class Sampler(nn.Module): 9. Return the final `SamplerOutput`. """ - def __init__(self, logprobs_mode: LogprobsMode = "raw_logprobs"): + def __init__( + self, + logprobs_mode: LogprobsMode = "raw_logprobs", + use_fp64_gumbel: bool = False, + ): super().__init__() - self.topk_topp_sampler = TopKTopPSampler(logprobs_mode) - self.pin_memory = is_pin_memory_available() + self.topk_topp_sampler = TopKTopPSampler(logprobs_mode, use_fp64_gumbel) + self.pin_memory = PIN_MEMORY self.logprobs_mode = logprobs_mode + self.use_fp64_gumbel = use_fp64_gumbel def forward( self, diff --git a/vllm/v1/sample/thinking_budget_state.py b/vllm/v1/sample/thinking_budget_state.py index 8789e6afdc47..95c3406b02c4 100644 --- a/vllm/v1/sample/thinking_budget_state.py +++ b/vllm/v1/sample/thinking_budget_state.py @@ -7,7 +7,7 @@ import torch from vllm.platforms import current_platform -from vllm.utils.torch_utils import async_tensor_h2d +from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d from vllm.v1.sample.logits_processor.interface import ( BatchUpdate, MoveDirectionality, @@ -22,12 +22,11 @@ def maybe_create_thinking_budget_state_holder( max_num_seqs: int, num_spec_tokens: int, device: torch.device, - is_pin_memory: bool, ) -> "ThinkingBudgetStateHolder | None": if reasoning_config is None: return None return ThinkingBudgetStateHolder( - reasoning_config, max_num_seqs, num_spec_tokens, device, is_pin_memory + reasoning_config, max_num_seqs, num_spec_tokens, device, PIN_MEMORY ) @@ -174,6 +173,19 @@ def _find_last_sequence_index(target_list: list[int], token_ids: list[int]) -> i return i return -1 + @staticmethod + def _find_last_sequence_index_from( + target_list: list[int], token_ids: list[int], search_start: int + ) -> int: + """Last occurrence of ``token_ids`` at or after ``search_start``.""" + if not token_ids: + return -1 + lo = max(0, search_start) + for i in range(len(target_list) - len(token_ids), lo - 1, -1): + if target_list[i : i + len(token_ids)] == token_ids: + return i + return -1 + def _init_state_entry( self, prompt_tok_ids: list[int] | None, thinking_token_budget: int ) -> dict[str, Any]: @@ -227,9 +239,12 @@ def _init_state_entry( "force_index": [], "start_thinking": start_thinking, "end_thinking": -1, + "start_search_pos": 0, + "end_search_pos": 0, "in_spec_mode": False, "bonus_token_forced": False, "continue_thinking": continue_thinking, + "scan_offset": 0, } def _update_think_state(self, state: dict[str, Any]) -> None: @@ -241,16 +256,41 @@ def _update_think_state(self, state: dict[str, Any]) -> None: state["force_index"] = [] return + output_tok_ids = state.get("output_tok_ids", []) if state["start_thinking"] == -1: - start_thinking = self._find_last_sequence_index( - state.get("output_tok_ids", []), self.think_start_token_ids + seq_len = len(self.think_start_token_ids) + scan_offset = state.get("scan_offset", 0) + start_thinking = self._find_last_sequence_index_from( + output_tok_ids, + self.think_start_token_ids, + max(scan_offset, state["start_search_pos"] - (seq_len - 1)), ) + if start_thinking >= 0 and scan_offset > 0: + # Re-entry after a forced end: budget was already exhausted + # in a prior block, so immediately force-close this one. + # scan_offset > 0 is only set after forced-end completion + # (never after natural end), so this won't block legitimate + # re-entries where budget remains. + state["start_thinking"] = start_thinking + state["in_think"] = False + state["in_end"] = True + state["end_count"] = 0 + state["force_index"] = [0] + return state["start_thinking"] = start_thinking + if start_thinking == -1: + state["start_search_pos"] = len(output_tok_ids) if state["end_thinking"] == -1: - end_thinking = self._find_last_sequence_index( - state.get("output_tok_ids", []), self.think_end_token_ids + seq_len = len(self.think_end_token_ids) + scan_offset = state.get("scan_offset", 0) + end_thinking = self._find_last_sequence_index_from( + output_tok_ids, + self.think_end_token_ids, + max(scan_offset, state["end_search_pos"] - (seq_len - 1)), ) state["end_thinking"] = end_thinking + if end_thinking == -1: + state["end_search_pos"] = len(output_tok_ids) if state["start_thinking"] == -1: return @@ -434,6 +474,11 @@ def _update_think_state(self, state: dict[str, Any]) -> None: "in_end": False, "end_count": 0, "check_count_down": state["thinking_token_budget"], + "start_thinking": -1, + "end_thinking": -1, + "think_count": 0, + "continue_thinking": False, + "scan_offset": len(state.get("output_tok_ids", [])), } ) diff --git a/vllm/v1/serial_utils.py b/vllm/v1/serial_utils.py index 204c8bd0e411..bc4619a7eb3d 100644 --- a/vllm/v1/serial_utils.py +++ b/vllm/v1/serial_utils.py @@ -33,7 +33,7 @@ MultiModalSharedField, NestedTensors, ) -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.utils import tensor_data logger = init_logger(__name__) @@ -327,7 +327,7 @@ def __init__( oob_tensor_provider: OOBTensorProvider | None = None, ): self.share_mem = share_mem - self.pin_tensors = is_pin_memory_available() + self.pin_tensors = PIN_MEMORY args = () if t is None else (t,) self.decoder = msgpack.Decoder( *args, ext_hook=self.ext_hook, dec_hook=self.dec_hook diff --git a/vllm/v1/simple_kv_offload/copy_backend.py b/vllm/v1/simple_kv_offload/copy_backend.py index 114f26973767..58de7a7e9eff 100644 --- a/vllm/v1/simple_kv_offload/copy_backend.py +++ b/vllm/v1/simple_kv_offload/copy_backend.py @@ -12,6 +12,8 @@ from vllm.logger import init_logger from vllm.platforms import current_platform from vllm.v1.simple_kv_offload.cuda_mem_ops import ( + CU_MEMCPY_SRC_ACCESS_ORDER_ANY, + CU_MEMCPY_SRC_ACCESS_ORDER_STREAM, BatchMemcpyParams, build_params, copy_blocks, @@ -43,8 +45,20 @@ def init( self._load_stream = load_stream self._store_stream = store_stream - self._store_params = build_params(gpu_caches, cpu_caches, store_stream) - self._load_params = build_params(cpu_caches, gpu_caches, load_stream) + # Stores read the live KV cache -> STREAM (paired with the compute-done + # wait in get_finished); loads read stable pinned host memory -> ANY. + self._store_params = build_params( + gpu_caches, + cpu_caches, + store_stream, + src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_STREAM, + ) + self._load_params = build_params( + cpu_caches, + gpu_caches, + load_stream, + src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_ANY, + ) self._queue = queue.SimpleQueue() self._thread = threading.Thread( @@ -61,11 +75,20 @@ def launch_copy( is_store: bool, event_idx: int, events_list: list[tuple[int, torch.Event]], + wait_event: torch.Event | None = None, ) -> None: params = self._store_params if is_store else self._load_params assert params is not None and self._queue is not None self._queue.put( - (src_blocks, dst_blocks, params, is_store, event_idx, events_list) + ( + src_blocks, + dst_blocks, + params, + is_store, + event_idx, + events_list, + wait_event, + ) ) def shutdown(self) -> None: @@ -89,9 +112,19 @@ def _copy_loop( item = q.get() if item is None: return - src_blocks, dst_blocks, params, is_store, event_idx, events_list = item - copy_blocks(src_blocks, dst_blocks, params) + ( + src_blocks, + dst_blocks, + params, + is_store, + event_idx, + events_list, + wait_event, + ) = item stream = store_stream if is_store else load_stream + if wait_event is not None: + stream.wait_event(wait_event) + copy_blocks(src_blocks, dst_blocks, params) event = torch.Event() event.record(stream) events_list.append((event_idx, event)) diff --git a/vllm/v1/simple_kv_offload/cuda_mem_ops.py b/vllm/v1/simple_kv_offload/cuda_mem_ops.py index b4c68aff3ca9..69b1677e0ac9 100644 --- a/vllm/v1/simple_kv_offload/cuda_mem_ops.py +++ b/vllm/v1/simple_kv_offload/cuda_mem_ops.py @@ -13,6 +13,12 @@ logger = init_logger(__name__) +# CUmemcpySrcAccessOrder values (CUDA driver API). STREAM(1): source read in +# stream order, safe when the source may still be written. ANY(3): source may +# be read early, only safe for a stable source (e.g. pinned host memory). +CU_MEMCPY_SRC_ACCESS_ORDER_STREAM = 1 +CU_MEMCPY_SRC_ACCESS_ORDER_ANY = 3 + def pin_tensor(tensor: torch.Tensor) -> None: """Pin a CPU tensor via cudaHostRegister. @@ -106,8 +112,8 @@ class BatchMemcpyParams(NamedTuple): dst_bases: np.ndarray # [num_layers] uint64 bpb: np.ndarray # [num_layers] uint64 — bytes per block num_layers: int - # CUDA only: one attributes entry with srcAccessOrder=ANY. Unused on - # ROCm (7.2.1 or 7.2.2) because the current runtime rejects numAttrs > 0. + # CUDA only: one attributes entry carrying srcAccessOrder. Unused on ROCm + # (7.2.1 or 7.2.2) because the current runtime rejects numAttrs > 0. attrs: _CUmemcpyAttributes attrs_idx: ctypes.c_size_t # NOTE: cuMemcpyBatchAsync_v2() removed fail_idx field, but we use @@ -120,6 +126,7 @@ def build_params( src_caches: dict[str, torch.Tensor], dst_caches: dict[str, torch.Tensor], stream: torch.cuda.Stream, + src_access_order: int = CU_MEMCPY_SRC_ACCESS_ORDER_ANY, ) -> BatchMemcpyParams: global _batch_memcpy_fn if _batch_memcpy_fn is None: @@ -137,10 +144,7 @@ def build_params( dst_bases.append(d.data_ptr()) bpb.append(s_bpb) - # ``srcAccessOrder=3`` == CU_MEMCPY_SRC_ACCESS_ORDER_ANY / - # hipMemcpySrcAccessOrderAny. See - # https://docs.nvidia.com/cuda/cuda-driver-api/group__CUDA__MEM.html#group__CUDA__MEM_1g6f1ff58e3065df3eb4b573dba77ad31f # noqa: E501 - attrs = _CUmemcpyAttributes(srcAccessOrder=3) + attrs = _CUmemcpyAttributes(srcAccessOrder=src_access_order) return BatchMemcpyParams( src_bases=np.array(src_bases, dtype=np.uint64), diff --git a/vllm/v1/simple_kv_offload/manager.py b/vllm/v1/simple_kv_offload/manager.py index f61c4320dffd..f451f503d922 100644 --- a/vllm/v1/simple_kv_offload/manager.py +++ b/vllm/v1/simple_kv_offload/manager.py @@ -82,6 +82,9 @@ def __init__( vllm_config.kv_events_config is not None and vllm_config.kv_events_config.enable_kv_cache_events ) + dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size + pcp_world_size = vllm_config.parallel_config.prefill_context_parallel_size + self.cp_world_size = dcp_world_size * pcp_world_size self.block_size = scheduler_block_size self.hash_block_size = hash_block_size assert self.block_size % self.hash_block_size == 0 @@ -100,9 +103,12 @@ def __init__( assert 0 <= self.fa_gidx < len(self.cpu_kv_cache_config.kv_cache_groups) # FA group's own block_size; divides scheduler_block_size (the LCM) # but is NOT assumed to equal it. - self.fa_block_size: int = self.cpu_kv_cache_config.kv_cache_groups[ - self.fa_gidx - ].kv_cache_spec.block_size + self.fa_block_size: int = ( + self.cpu_kv_cache_config.kv_cache_groups[ + self.fa_gidx + ].kv_cache_spec.block_size + * self.cp_world_size + ) assert self.block_size % self.fa_block_size == 0 logger.info( @@ -113,15 +119,10 @@ def __init__( ) # TODO (yifan): maybe need to enable kv_cache_events and metrics_collector here. - dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size - pcp_world_size = vllm_config.parallel_config.prefill_context_parallel_size - assert dcp_world_size == 1 and pcp_world_size == 1 self.cpu_coordinator: KVCacheCoordinator = get_kv_cache_coordinator( kv_cache_config=self.cpu_kv_cache_config, max_model_len=vllm_config.model_config.max_model_len, - max_num_batched_tokens=( - vllm_config.scheduler_config.max_num_batched_tokens - ), + max_in_flight_tokens=vllm_config.max_in_flight_tokens, use_eagle=False, enable_caching=True, enable_kv_cache_events=self.enable_kv_cache_events, @@ -155,14 +156,17 @@ def __init__( self._target_free = self._estimate_lazy_target_blocks( kv_cache_config, vllm_config.scheduler_config.max_num_batched_tokens, + self.cp_world_size, ) else: self._target_free = 0 self._store_event_to_blocks: dict[int, TransferMeta] = {} + self._abandoned_store_event_to_blocks: dict[int, TransferMeta] = {} # Eager mode only self._reqs_to_store: dict[str, StoreRequestState] = {} self._store_event_to_reqs: dict[int, list[str]] = {} self._in_flight_store_gpu_blocks: set[int] = set() + self._abandoned_reqs_to_load: dict[str, LoadRequestState] = {} # Event counters self._load_event_counter: int = 0 @@ -185,7 +189,13 @@ def _derive_cpu_config( assert len(gpu_config.kv_cache_tensors) > 0 - gpu_total_bytes = sum(t.size for t in gpu_config.kv_cache_tensors) + is_packed = any(t.block_stride for t in gpu_config.kv_cache_tensors) + assert not is_packed or all(t.block_stride for t in gpu_config.kv_cache_tensors) + gpu_total_bytes = ( + gpu_config.kv_cache_tensors[0].size + if is_packed + else sum(t.size for t in gpu_config.kv_cache_tensors) + ) num_gpu_blocks = gpu_config.num_blocks num_cpu_blocks = max(1, num_gpu_blocks * cpu_capacity_bytes // gpu_total_bytes) # Create CPU kv_cache_tensors mirroring GPU by scaling size proportionally. @@ -193,6 +203,8 @@ def _derive_cpu_config( KVCacheTensor( size=t.size // num_gpu_blocks * num_cpu_blocks, shared_by=list(t.shared_by), + offset=t.offset, + block_stride=t.block_stride, ) for t in gpu_config.kv_cache_tensors ] @@ -205,19 +217,22 @@ def _derive_cpu_config( @staticmethod def _estimate_lazy_target_blocks( - kv_cache_config: "KVCacheConfig", max_num_batched_tokens: int + kv_cache_config: "KVCacheConfig", + max_num_batched_tokens: int, + cp_world_size: int = 1, ) -> int: """GPU blocks to keep available (free/offloaded) per step in lazy mode.""" WATERMARK_RATIO = 1.0 # Reserve larger space to avoid running out of GPU blocks target = 0 for g in kv_cache_config.kv_cache_groups: spec = g.kv_cache_spec + block_size = spec.block_size * cp_world_size if isinstance(spec, MambaSpec): target += 2 elif isinstance(spec, SlidingWindowSpec): - target += cdiv(spec.sliding_window, spec.block_size) + 1 + target += cdiv(spec.sliding_window, block_size) + 1 else: - target += cdiv(max_num_batched_tokens, spec.block_size) + target += cdiv(max_num_batched_tokens, block_size) return int(target * (1 + WATERMARK_RATIO)) def bind_gpu_block_pool(self, gpu_block_pool: BlockPool) -> None: @@ -334,10 +349,12 @@ def update_state_after_alloc( # the rest will be released along with the temp pin below. cpu_hit_blocks: list[list[KVCacheBlock]] = [] for g in range(num_groups): - g_block_size = kv_cache_groups[g].kv_cache_spec.block_size + g_block_size = ( + kv_cache_groups[g].kv_cache_spec.block_size * self.cp_world_size + ) assert num_external_tokens % g_block_size == 0, ( f"num_external_tokens={num_external_tokens} not aligned to " - f"group {g} block_size={g_block_size}" + f"group {g} effective block_size={g_block_size}" ) n_take_g = num_external_tokens // g_block_size cpu_hit_blocks.append(cpu_hit_blocks_full[g][:n_take_g]) @@ -353,7 +370,9 @@ def update_state_after_alloc( continue # Number of blocks in the computed range for this group. - g_block_size = kv_cache_groups[g].kv_cache_spec.block_size + g_block_size = ( + kv_cache_groups[g].kv_cache_spec.block_size * self.cp_world_size + ) n_computed_g = cdiv(total_computed_tokens, g_block_size) # Back-trace: ext blocks sit at the tail of the computed range. @@ -427,7 +446,10 @@ def build_connector_meta( load_event=load_event, load_gpu_blocks=load_gpu, load_cpu_blocks=load_cpu, - load_event_to_reqs=self._load_event_to_reqs, + load_event_to_reqs={ + event_idx: list(req_ids) + for event_idx, req_ids in self._load_event_to_reqs.items() + }, store_event=store_event, store_gpu_blocks=store_gpu, store_cpu_blocks=store_cpu, @@ -465,24 +487,14 @@ def _prepare_lazy_store_specs( if self._cursor is not None and self._cursor.ref_cnt > 0: self._cursor = None - # Determine start node. - if self._cursor is None: - node = free_queue.fake_free_list_head.next_free_block - else: - node = self._cursor.next_free_block - - tail = free_queue.fake_free_list_tail gpu_ids: list[int] = [] block_hashes: list[bytes] = [] - covered = 0 last_visited = self._cursor - while ( - node is not None - and node is not tail - and covered < self._target_free - and len(gpu_ids) < num_cpu_free - ): + for covered, node in enumerate(free_queue.iter_blocks_after(self._cursor)): + if covered >= self._target_free or len(gpu_ids) >= num_cpu_free: + break + last_visited = node bhash = node.block_hash @@ -494,9 +506,6 @@ def _prepare_lazy_store_specs( gpu_ids.append(node.block_id) block_hashes.append(bhash) - covered += 1 - node = node.next_free_block - self._cursor = last_visited # Batch-allocate CPU blocks and stamp hashes. @@ -580,7 +589,9 @@ def _prepare_eager_store_specs( already_stored_g = state.num_stored_blocks[g] group_gpu_ids = block_ids_by_group[g] - g_block_size = kv_cache_groups[g].kv_cache_spec.block_size + g_block_size = ( + kv_cache_groups[g].kv_cache_spec.block_size * self.cp_world_size + ) ready_blocks_g = aligned_tokens // g_block_size scannable = group_gpu_ids[already_stored_g:ready_blocks_g] @@ -680,9 +691,17 @@ def update_connector_output(self, connector_output: KVConnectorOutput) -> None: def _process_store_event(self, event_idx: int) -> None: """Process a fully-completed store event.""" - transfer = self._store_event_to_blocks.pop(event_idx) + transfer = self._store_event_to_blocks.pop(event_idx, None) + if transfer is None: + transfer = self._abandoned_store_event_to_blocks.pop(event_idx, None) + if transfer is None: + return # guard stale events from before a reset() call + self._release_transfer_refs(transfer) + return + if not self._lazy_mode: self._in_flight_store_gpu_blocks.difference_update(transfer.gpu_block_ids) + self._process_store_completion(transfer.gpu_block_ids, transfer.cpu_block_ids) logger.debug( "Store event %d completed: cached %d blocks to CPU", @@ -725,9 +744,22 @@ def _process_store_completion( self._gpu_block_pool.blocks[bid] for bid in gpu_block_ids ) + def _release_transfer_refs(self, transfer: TransferMeta) -> None: + """Release transfer refs without making copied data cacheable.""" + cpu_blocks = [self.cpu_block_pool.blocks[bid] for bid in transfer.cpu_block_ids] + for cpu_block in cpu_blocks: + cpu_block.reset_hash() + self.cpu_block_pool.free_blocks(cpu_blocks) + assert self._gpu_block_pool is not None + self._gpu_block_pool.free_blocks( + self._gpu_block_pool.blocks[bid] for bid in transfer.gpu_block_ids + ) + def has_pending_stores(self) -> bool: """Return True if there are in-flight store transfers.""" - return bool(self._store_event_to_blocks) + return bool( + self._store_event_to_blocks or self._abandoned_store_event_to_blocks + ) def request_finished( self, @@ -787,6 +819,8 @@ def _cleanup_load_request(self, req_id: str) -> None: and frees CPU/GPU touch refs. """ state = self._reqs_to_load.pop(req_id, None) + if state is None: + state = self._abandoned_reqs_to_load.pop(req_id, None) if state is None: return # Remove from load event mapping (only this req, not whole event) @@ -830,3 +864,43 @@ def _cleanup_store_request(self, req_id: str) -> None: def take_events(self) -> Iterable[KVCacheEvent]: return self.cpu_block_pool.take_events() + + def reset(self) -> bool: + """Abandon pending transfers and reset the CPU cache when safe. + + Worker-side DMA may still be using blocks after reset is requested. + Keep those block refs pinned until the existing completion path reports + the transfer finished, then release refs without caching abandoned + store results. + """ + + self._abandoned_store_event_to_blocks.update(self._store_event_to_blocks) + self._store_event_to_blocks.clear() + self._in_flight_store_gpu_blocks.clear() + + # Loads that have not been sent to the worker cannot have running DMA. + # In-flight loads stay pinned and are cleaned up on completion. + for req_id in list(self._reqs_to_load): + state = self._reqs_to_load.pop(req_id) + if state.load_event is None: + self._reqs_to_load[req_id] = state + self._cleanup_load_request(req_id) + else: + self._abandoned_reqs_to_load[req_id] = state + + self._reqs_to_store.clear() + self._store_event_to_reqs.clear() + self._store_event_pending_counts = { + event_idx: count + for event_idx, count in self._store_event_pending_counts.items() + if event_idx in self._abandoned_store_event_to_blocks + } + self._cursor = None + # NOTE: _load_event_counter / _store_event_counter are not + # reset as they are monotonic and must stay ahead of the workers + # high-water marks to avoid event index collisions + + if self._abandoned_store_event_to_blocks or self._abandoned_reqs_to_load: + return False + + return self.cpu_block_pool.reset_prefix_cache() diff --git a/vllm/v1/simple_kv_offload/worker.py b/vllm/v1/simple_kv_offload/worker.py index c23b44f29173..9cb9c02ed7c5 100644 --- a/vllm/v1/simple_kv_offload/worker.py +++ b/vllm/v1/simple_kv_offload/worker.py @@ -8,7 +8,7 @@ from vllm.config import VllmConfig from vllm.logger import init_logger -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.simple_kv_offload.copy_backend import DmaCopyBackend from vllm.v1.simple_kv_offload.cuda_mem_ops import pin_tensor from vllm.v1.simple_kv_offload.metadata import ( @@ -57,6 +57,10 @@ def __init__( # Metadata for the current step self._connector_metadata: SimpleCPUOffloadMetadata | None = None + # Compute-done event recorded before each store; reused across steps + # (get_finished runs once per step, copy queue is FIFO). + self._store_compute_done: torch.Event | None = None + # Pending event index sets, populated in bind_connector_metadata self._pending_load_event_indices: set[int] = set() self._pending_store_event_indices: set[int] = set() @@ -149,7 +153,7 @@ def _repr_tensor(v: torch.Tensor | list[torch.Tensor]) -> torch.Tensor: (self.num_cpu_blocks * total_bytes_per_block) / (1024**3), ) - pin_memory = is_pin_memory_available() + pin_memory = PIN_MEMORY if not pin_memory: logger.warning( "Pinned memory not available. CPU offload performance may be degraded." @@ -206,9 +210,11 @@ def get_finished( ) -> tuple[set[str] | None, set[str] | None]: """Submit transfers and report completed events to the scheduler. - Called after model execution. The manager only schedules stores for - blocks whose KV data is confirmed computed, so we launch both loads - and stores immediately — no deferral or cross-stream sync needed. + Stores (GPU->CPU) read the live KV cache, which the compute stream may + still be writing under v1 overlapped execution, so they are ordered + after a compute-done event recorded on the current stream. Loads + (CPU->GPU) read stable pinned host memory and launch immediately. See + #45704 for the bug and #39306 for the srcAccessOrder rationale. Returns: tuple of (finished_sending, finished_recving). @@ -218,7 +224,6 @@ def get_finished( # (1) Submit transfers metadata = self._connector_metadata if metadata is not None: - # Launch loads (CPU->GPU). if metadata.load_cpu_blocks: self._backend.launch_copy( metadata.load_cpu_blocks, @@ -227,14 +232,17 @@ def get_finished( event_idx=metadata.load_event, events_list=self._load_events, ) - # Launch stores (GPU->CPU). if metadata.store_gpu_blocks: + if self._store_compute_done is None: + self._store_compute_done = torch.Event() + self._store_compute_done.record(torch.cuda.current_stream()) self._backend.launch_copy( metadata.store_gpu_blocks, metadata.store_cpu_blocks, is_store=True, event_idx=metadata.store_event, events_list=self._store_events, + wait_event=self._store_compute_done, ) # (2) Track completed transfer events diff --git a/vllm/v1/spec_decode/dflash.py b/vllm/v1/spec_decode/dflash.py index 72d0f99d07d2..bae6935cef8f 100644 --- a/vllm/v1/spec_decode/dflash.py +++ b/vllm/v1/spec_decode/dflash.py @@ -10,10 +10,12 @@ from vllm.config import VllmConfig from vllm.forward_context import set_forward_context from vllm.logger import init_logger -from vllm.triton_utils import triton from vllm.v1.attention.backend import CommonAttentionMetadata from vllm.v1.spec_decode.llm_base_proposer import SpecDecodeBaseProposer -from vllm.v1.spec_decode.utils import copy_and_expand_dflash_inputs_kernel +from vllm.v1.spec_decode.utils import ( + copy_and_expand_dflash_inputs_kernel, + next_power_of_2, +) logger = init_logger(__name__) @@ -73,6 +75,11 @@ def __init__( @override def _create_draft_vllm_config(self) -> VllmConfig: base = super()._create_draft_vllm_config() + # The draft model is text-only — clear the target's multimodal + # flag so flash_attn is not rejected for mm_prefix support. + arch = base.model_config.model_arch_config + if arch.is_mm_prefix_lm: + base.model_config.model_arch_config = replace(arch, is_mm_prefix_lm=False) return replace( base, attention_config=replace( @@ -121,8 +128,8 @@ def set_inputs_first_pass( # and token_indices_to_sample max_ctx_per_req = cad.max_query_len max_tokens_per_req = max_ctx_per_req + num_query_per_req - BLOCK_SIZE = min(256, triton.next_power_of_2(max_tokens_per_req)) - num_blocks = triton.cdiv(max_tokens_per_req, BLOCK_SIZE) + BLOCK_SIZE = min(256, next_power_of_2(max_tokens_per_req)) + num_blocks = (max_tokens_per_req + BLOCK_SIZE - 1) // BLOCK_SIZE grid = (batch_size, num_blocks) has_num_rejected = num_rejected_tokens_gpu is not None diff --git a/vllm/v1/spec_decode/draft_model.py b/vllm/v1/spec_decode/draft_model.py index a8c8ab03b615..08542f03a1a0 100644 --- a/vllm/v1/spec_decode/draft_model.py +++ b/vllm/v1/spec_decode/draft_model.py @@ -9,7 +9,9 @@ from vllm.config.utils import replace from vllm.logger import init_logger from vllm.model_executor.model_loader import get_model +from vllm.tokenizers.registry import get_tokenizer from vllm.v1.spec_decode.llm_base_proposer import SpecDecodeBaseProposer +from vllm.v1.spec_decode.vocab_mapping import VocabMapping logger = init_logger(__name__) @@ -27,9 +29,34 @@ def __init__( pass_hidden_states_to_model=False, runner=runner, ) - self._raise_if_vocab_size_mismatch() self._raise_if_draft_tp_mismatch() + self.use_heterogeneous_vocab = self.speculative_config.use_heterogeneous_vocab + + spec = self.speculative_config + if self.use_heterogeneous_vocab: + # Heterogeneous vocabularies: build a VocabMapping to translate + # token IDs between the two tokenizers and constrain draft logits + # to the intersection so rejection sampling stays lossless. + target_tokenizer = get_tokenizer( + spec.target_model_config.tokenizer, + trust_remote_code=spec.target_model_config.trust_remote_code, + ) + draft_tokenizer = get_tokenizer( + spec.draft_model_config.model, + trust_remote_code=spec.draft_model_config.trust_remote_code, + ) + self.vocab_mapping: VocabMapping | None = VocabMapping( + target_tokenizer=target_tokenizer, + draft_tokenizer=draft_tokenizer, + target_vocab_size=spec.target_model_config.get_vocab_size(), + draft_vocab_size=spec.draft_model_config.get_vocab_size(), + device=device, + ) + else: + self._raise_if_vocab_size_mismatch() + self.vocab_mapping = None + def _raise_if_vocab_size_mismatch(self): self.speculative_config.verify_equal_vocab_size_if_draft_model() diff --git a/vllm/v1/spec_decode/dynamic/__init__.py b/vllm/v1/spec_decode/dynamic/__init__.py new file mode 100644 index 000000000000..208f01a7cb5e --- /dev/null +++ b/vllm/v1/spec_decode/dynamic/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project diff --git a/vllm/v1/spec_decode/dynamic/utils.py b/vllm/v1/spec_decode/dynamic/utils.py new file mode 100644 index 000000000000..de869b19a728 --- /dev/null +++ b/vllm/v1/spec_decode/dynamic/utils.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +DynamicSDSchedule = list[tuple[int, int, int]] + + +def validate_and_normalize_dynamic_sd_schedule( + num_speculative_tokens_per_batch_size: object, +) -> DynamicSDSchedule: + """Validate and normalize a Dynamic SD batch-size schedule. + + The schedule is expressed as a list of inclusive ranges: + + ``[(range_start, range_end, num_speculative_tokens), ...]`` + """ + if num_speculative_tokens_per_batch_size is None: + raise ValueError( + "num_speculative_tokens_per_batch_size is required for " + "dynamic speculative decoding." + ) + if not isinstance(num_speculative_tokens_per_batch_size, list): + raise ValueError( + "num_speculative_tokens_per_batch_size must be a non-empty list of " + "(range_start, range_end, num_speculative_tokens) entries." + ) + if not num_speculative_tokens_per_batch_size: + raise ValueError("num_speculative_tokens_per_batch_size must not be empty.") + + parsed_schedule: DynamicSDSchedule = [] + for entry in num_speculative_tokens_per_batch_size: + if not isinstance(entry, list | tuple) or len(entry) != 3: + raise ValueError( + "Each num_speculative_tokens_per_batch_size entry must be a " + "3-item sequence: (range_start, range_end, num_speculative_tokens)." + ) + + range_start, range_end, num_speculative_tokens = ( + int(entry[0]), + int(entry[1]), + int(entry[2]), + ) + + if range_start <= 0 or range_end <= 0: + raise ValueError( + f"Batch-size range ({range_start}, {range_end}) must be positive." + ) + if range_start > range_end: + raise ValueError( + "Batch-size range start must be <= end for " + f"({range_start}, {range_end}, {num_speculative_tokens})." + ) + if num_speculative_tokens < 0: + raise ValueError( + "num_speculative_tokens_per_batch_size values must be >= 0." + ) + + parsed_schedule.append((range_start, range_end, num_speculative_tokens)) + + parsed_schedule.sort(key=lambda entry: entry[0]) + + previous_end = 0 + for range_start, range_end, _ in parsed_schedule: + if range_start <= previous_end: + raise ValueError("Batch-size ranges must be non-overlapping and sorted.") + previous_end = range_end + + first_range_start = parsed_schedule[0][0] + if first_range_start != 1: + raise ValueError( + "The first batch-size range must start at 1 so every runtime " + "batch size has a defined schedule." + ) + + return parsed_schedule + + +def build_dynamic_sd_schedule_lookup( + num_speculative_tokens_per_batch_size: object, + vllm_max_batch_size: int, + vllm_num_speculative_tokens: int, +) -> list[int]: + """Expand the configured schedule into a dense batch_size -> K lookup. + + "dense_schedule" means a 1-indexed lookup table where index ``batch_size`` + stores the exact K to use for that runtime batch size. This lets the + scheduler do a simple array lookup instead of searching the configured + ranges on every scheduling step. + """ + if vllm_max_batch_size <= 0: + raise ValueError("vllm_max_batch_size must be > 0.") + if vllm_num_speculative_tokens <= 0: + raise ValueError("vllm_num_speculative_tokens must be > 0.") + + parsed_schedule = validate_and_normalize_dynamic_sd_schedule( + num_speculative_tokens_per_batch_size + ) + + # Index 0 is intentionally unused so that valid runtime batch sizes can be + # looked up directly as dense_schedule[batch_size]. + dense_schedule = [0] * (vllm_max_batch_size + 1) + next_batch_size = 1 + last_num_speculative_tokens: int | None = None + + for range_start, range_end, num_speculative_tokens in parsed_schedule: + if range_start > next_batch_size and last_num_speculative_tokens is not None: + # Fill any gap before the next configured range by carrying forward + # the previous K. For example, [(1, 16, 3), (32, 128, 2)] should map + # batch sizes 17-31 to K=3. + for batch_size in range( + next_batch_size, + min(range_start, vllm_max_batch_size + 1), + ): + dense_schedule[batch_size] = min( + vllm_num_speculative_tokens, + last_num_speculative_tokens, + ) + + # Fill the current configured inclusive range with its K value. + for batch_size in range( + max(range_start, next_batch_size), + min(range_end, vllm_max_batch_size) + 1, + ): + dense_schedule[batch_size] = min( + vllm_num_speculative_tokens, + num_speculative_tokens, + ) + + next_batch_size = max(next_batch_size, range_end + 1) + last_num_speculative_tokens = num_speculative_tokens + + if next_batch_size > vllm_max_batch_size: + break + + if last_num_speculative_tokens is None: + raise ValueError( + "num_speculative_tokens_per_batch_size must contain at least " + "one valid batch-size range." + ) + + # Fill the tail after the final configured range by carrying forward the + # last K through vllm_max_batch_size. + for batch_size in range(next_batch_size, vllm_max_batch_size + 1): + dense_schedule[batch_size] = min( + vllm_num_speculative_tokens, + last_num_speculative_tokens, + ) + + return dense_schedule diff --git a/vllm/v1/spec_decode/extract_hidden_states.py b/vllm/v1/spec_decode/extract_hidden_states.py index c3cb3c8aaeaf..de7a075e2f78 100644 --- a/vllm/v1/spec_decode/extract_hidden_states.py +++ b/vllm/v1/spec_decode/extract_hidden_states.py @@ -9,10 +9,11 @@ import torch.nn as nn from vllm.config import CUDAGraphMode, VllmConfig, get_layers_from_vllm_config +from vllm.distributed.eplb.eplb_state import EplbState from vllm.forward_context import set_forward_context from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase from vllm.model_executor.model_loader import get_model -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.attention.backend import AttentionMetadataBuilder, CommonAttentionMetadata from vllm.v1.cudagraph_dispatcher import CudagraphDispatcher from vllm.v1.utils import CpuGpuBuffer @@ -29,7 +30,10 @@ class ExtractHiddenStatesProposer: def __init__(self, vllm_config: VllmConfig, device): assert vllm_config.speculative_config is not None - assert vllm_config.speculative_config.num_speculative_tokens == 1 + self.num_speculative_tokens = ( + vllm_config.speculative_config.num_speculative_tokens + ) + assert self.num_speculative_tokens == 1 if vllm_config.speculative_config.disable_padded_drafter_batch: raise ValueError( "disable_padded_drafter_batch is not supported with " @@ -40,6 +44,8 @@ def __init__(self, vllm_config: VllmConfig, device): self.dtype = vllm_config.model_config.dtype self.dp_rank = vllm_config.parallel_config.data_parallel_rank + self.eplb_state: EplbState | None = None + # Model and attention layer tracking (initialized in load_model) self.model: nn.Module | None = None self.attn_layer_names: list[str] = [] @@ -55,7 +61,7 @@ def __init__(self, vllm_config: VllmConfig, device): self.backup_next_token_ids = CpuGpuBuffer( max_batch_size, dtype=torch.int32, - pin_memory=is_pin_memory_available(), + pin_memory=PIN_MEMORY, device=device, with_numpy=True, ) @@ -80,8 +86,13 @@ def __init__(self, vllm_config: VllmConfig, device): self.max_num_tokens, dtype=torch.int64, device=device ) + def set_eplb_state(self, eplb_state: EplbState) -> None: + """Inject EPLB state after construction.""" + self.eplb_state = eplb_state + def propose( self, + num_speculative_tokens: int, sampled_token_ids: torch.Tensor, target_hidden_states: list[torch.Tensor], common_attn_metadata: CommonAttentionMetadata, @@ -112,6 +123,7 @@ def propose( - Draft tokens matching sampled tokens, shape [batch_size, 1] - KV connector output (if KV transfer is active), else None """ + assert num_speculative_tokens == self.num_speculative_tokens assert self.model is not None and isinstance(target_hidden_states, list) # target_hidden_states is a list of tensors (one per layer) @@ -140,6 +152,12 @@ def propose( if num_tokens_across_dp is not None: num_tokens_across_dp[self.dp_rank] = num_input_tokens + if self.eplb_state is not None: + assert self.vllm_config.speculative_config is not None + self.eplb_state.prepare_forward( + self.vllm_config.speculative_config.draft_model_config, + num_tokens, + ) with set_forward_context( per_layer_attn_metadata, self.vllm_config, @@ -312,7 +330,6 @@ def prepare_next_token_ids_padded( (batch_size, 1). For each request we either use the sampled token (if valid and not discarded) or a backup token from the request state. """ - num_reqs = gpu_input_batch.num_reqs # Precompute backup token IDs for discarded requests. num_reqs = gpu_input_batch.num_reqs diff --git a/vllm/v1/spec_decode/llm_base_proposer.py b/vllm/v1/spec_decode/llm_base_proposer.py index aa1bf270c1cc..756c5f3b3717 100644 --- a/vllm/v1/spec_decode/llm_base_proposer.py +++ b/vllm/v1/spec_decode/llm_base_proposer.py @@ -1,18 +1,25 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import dataclasses from importlib.util import find_spec -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import numpy as np import torch import torch.nn as nn +from vllm.compilation.breakable_cudagraph import BreakableCUDAGraphWrapper from vllm.config import ( CUDAGraphMode, VllmConfig, get_layers_from_vllm_config, replace, ) + +if TYPE_CHECKING: + from vllm.v1.spec_decode.vocab_mapping import VocabMapping + +from vllm.distributed.eplb.eplb_state import EplbState from vllm.distributed.parallel_state import get_pp_group from vllm.forward_context import set_forward_context from vllm.logger import init_logger @@ -21,17 +28,23 @@ from vllm.model_executor.models import supports_multimodal from vllm.model_executor.models.deepseek_eagle3 import Eagle3DeepseekV2ForCausalLM from vllm.model_executor.models.interfaces import SupportsMultiModal +from vllm.model_executor.models.laguna_dflash import DFlashLagunaForCausalLM from vllm.model_executor.models.llama_eagle3 import Eagle3LlamaForCausalLM from vllm.model_executor.models.qwen3_dflash import DFlashQwen3ForCausalLM +from vllm.model_executor.models.qwen3_eagle3 import Eagle3Qwen3ForCausalLM from vllm.multimodal import MULTIMODAL_REGISTRY from vllm.platforms import current_platform -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d from vllm.v1.attention.backend import CommonAttentionMetadata from vllm.v1.attention.backends.registry import AttentionBackendEnum from vllm.v1.attention.backends.triton_attn import TritonAttentionMetadata from vllm.v1.cudagraph_dispatcher import CudagraphDispatcher from vllm.v1.kv_cache_interface import KVCacheConfig, UniformTypeKVCacheSpecs from vllm.v1.sample.metadata import SamplingMetadata +from vllm.v1.sample.ops.topk_topp_sampler import ( + empty_exponential_noise_like, + sample_with_exponential_noise, +) from vllm.v1.sample.sampler import _SAMPLING_EPS from vllm.v1.spec_decode.metadata import SpecDecodeMetadata from vllm.v1.spec_decode.utils import ( @@ -66,11 +79,13 @@ def __init__( self.draft_model_config = self.speculative_config.draft_model_config self.method = self.speculative_config.method self.pass_hidden_states_to_model = pass_hidden_states_to_model + self._share_mtp_indices = False self.device = device self.dtype = vllm_config.model_config.dtype self.max_model_len = vllm_config.model_config.max_model_len self.dp_rank = vllm_config.parallel_config.data_parallel_rank + self.eplb_state: EplbState | None = None self.num_speculative_tokens = self.speculative_config.num_speculative_tokens # We need to get the hidden size from the draft model config because @@ -113,6 +128,12 @@ def __init__( self.use_local_argmax_reduction: bool = ( self.speculative_config.use_local_argmax_reduction ) + self.use_fp64_gumbel = vllm_config.model_config.use_fp64_gumbel + + self.use_heterogeneous_vocab: bool = ( + self.speculative_config.use_heterogeneous_vocab + ) + self.vocab_mapping: VocabMapping | None = None self.max_batch_size = vllm_config.scheduler_config.max_num_seqs self.max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens @@ -221,7 +242,7 @@ def __init__( self.backup_next_token_ids = CpuGpuBuffer( self.max_batch_size, dtype=torch.int32, - pin_memory=is_pin_memory_available(), + pin_memory=PIN_MEMORY, device=device, with_numpy=True, ) @@ -232,9 +253,7 @@ def __init__( self._last_draft_probs: torch.Tensor | None = None self._slot_mapping_buffer = torch.zeros( - self.max_positions, - dtype=torch.int64, - device=device, + self.max_positions, dtype=torch.int64, device=device ) # Determine allowed attention backends once during initialization. @@ -244,6 +263,13 @@ def __init__( DeepseekV4ROCMAiterMLASparseMetadata, DeepseekV4ROCMAiterSparseSWAMetadata, ) + + # MiniMax-M3 sparse (lightning-indexer) attention. The multi-step + # drafting machinery is shared code at num_speculative_tokens>1. + # this just opts the metadata into the ROCm allowlist. + from vllm.models.minimax_m3.common.sparse_attention import ( + MiniMaxM3SparseMetadata, + ) from vllm.v1.attention.backends.mla.indexer import ( DeepseekV32IndexerMetadata, ) @@ -259,6 +285,7 @@ def __init__( DeepseekV4ROCMAiterMLASparseMetadata, DeepseekV4ROCMAiterSparseSWAMetadata, DeepseekV32IndexerMetadata, + MiniMaxM3SparseMetadata, ] # ROCM_AITER_FA is an optional backend # We check is_enabled() here to avoid importing the backend module during @@ -313,6 +340,10 @@ def _raise_if_mrope(self): "does not support M-RoPE yet" ) + def set_eplb_state(self, eplb_state: EplbState) -> None: + """Inject EPLB state after construction.""" + self.eplb_state = eplb_state + def _init_parallel_drafting_params(self): # For parallel drafting, we need the token ID to use for masked slots # And for EAGLE + parallel drafting, we need the hidden state tensor to use @@ -398,6 +429,12 @@ def _greedy_sample(self, hidden_states: torch.Tensor) -> torch.Tensor: """Greedy-sample draft tokens from hidden states.""" if self.use_local_argmax_reduction: return self.model.get_top_tokens(hidden_states) + if self.use_heterogeneous_vocab: + logits = self.model.compute_logits(hidden_states) + assert self.vocab_mapping is not None + logits = self.vocab_mapping.constrain_draft_logits(logits) + draft_token_ids = logits.argmax(dim=-1) + return self.vocab_mapping.map_draft_to_target_ids(draft_token_ids) return self.model.compute_logits(hidden_states).argmax(dim=-1) def _sample_from_logits( @@ -409,7 +446,24 @@ def _sample_from_logits( return logits.argmax(dim=-1), None if sampling_metadata.all_greedy: return logits.argmax(dim=-1), None - return compute_probs_and_sample_next_token(logits, sampling_metadata) + + # Parallel drafting (e.g. DFlash) samples num_speculative_tokens rows + # per request in a single pass, so logits has batch_size * K rows while + # the sampling metadata is per-request. The rows are request-major + # (K consecutive slots per request), so repeat_interleave the + # per-request temperature to match before probabilistic sampling. + temperature = sampling_metadata.temperature + if temperature is not None and temperature.shape[0] != logits.shape[0]: + assert logits.shape[0] % temperature.shape[0] == 0 + factor = logits.shape[0] // temperature.shape[0] + sampling_metadata = dataclasses.replace( + sampling_metadata, + temperature=temperature.repeat_interleave(factor, dim=0), + ) + + return compute_probs_and_sample_next_token( + logits, sampling_metadata, self.use_fp64_gumbel + ) def _sample_draft_tokens( self, @@ -419,13 +473,35 @@ def _sample_draft_tokens( if not self._enable_probabilistic_draft_probs or sampling_metadata.all_greedy: return self._greedy_sample(hidden_states), None logits = self.model.compute_logits(hidden_states) - return self._sample_from_logits(logits, sampling_metadata) + if self.use_heterogeneous_vocab: + assert self.vocab_mapping is not None + logits = self.vocab_mapping.constrain_draft_logits(logits) + draft_token_ids, draft_probs = self._sample_from_logits( + logits, sampling_metadata + ) + if self.use_heterogeneous_vocab: + assert self.vocab_mapping is not None + draft_token_ids = self.vocab_mapping.map_draft_to_target_ids( + draft_token_ids + ) + # Config validation ensures draft_sample_method == "greedy" when + # use_heterogeneous_vocab is True, so this branch should never be + # reached. Kept as a safety fallback until probabilistic rejection + # sampling with heterogeneous vocabularies is implemented. + # TODO: remap draft_probs to target-vocab space for lossless + # probabilistic rejection sampling with heterogeneous vocabularies. + assert draft_probs is None, ( + "probabilistic draft sampling is not supported with " + "use_heterogeneous_vocab" + ) + return draft_token_ids, draft_probs def take_last_draft_probs(self) -> torch.Tensor | None: return self._last_draft_probs def propose( self, + num_speculative_tokens, # [num_tokens] target_token_ids: torch.Tensor, # [num_tokens] or [3, num_tokens] when M-RoPE is enabled @@ -443,16 +519,22 @@ def propose( | list[dict[str, torch.Tensor]] | None = None, ) -> torch.Tensor: + self.num_speculative_tokens = num_speculative_tokens self._last_draft_probs = None batch_size = common_attn_metadata.batch_size() if self.method in ("eagle3", "dflash"): + model = self.model + if isinstance(model, BreakableCUDAGraphWrapper): + model = model.unwrap() assert isinstance( - self.model, + model, ( Eagle3LlamaForCausalLM, Eagle3DeepseekV2ForCausalLM, DFlashQwen3ForCausalLM, + Eagle3Qwen3ForCausalLM, + DFlashLagunaForCausalLM, ), ) target_hidden_states = self.model.combine_hidden_states( @@ -483,6 +565,17 @@ def propose( model_kwargs, slot_mapping_size = self.build_model_inputs_first_pass( num_tokens, num_input_tokens, mm_embed_inputs ) + # Step 0 of index_share_for_mtp_iteration: let the MTP layer + # compute its own indices (skip_topk=False) so subsequent steps + # can reuse them. + if self._share_mtp_indices and hasattr(self.model.model, "set_skip_topk"): + self.model.model.set_skip_topk(False) + + if self.eplb_state is not None: + self.eplb_state.prepare_forward( + self.draft_model_config, + num_tokens, + ) with set_forward_context( per_layer_attn_metadata, @@ -501,8 +594,27 @@ def propose( else: last_hidden_states, hidden_states = ret_hidden_states + # After step 0: switch to reuse mode so steps 1+ skip the indexer + # and read the indices that step 0 just wrote into the shared buffer. + if self._share_mtp_indices and hasattr(self.model.model, "set_skip_topk"): + self.model.model.set_skip_topk(True) + # The topk indices were written for each query token in the multi-token + # batch. Compact the topk indices for each request's last token. + self.model.model.compact_topk_indices(token_indices_to_sample) + sample_hidden_states = last_hidden_states[token_indices_to_sample] + # No draft tokens requested (e.g. Dynamic SD decided K=0). + # The prefill forward pass above already ran to keep the drafter + # KV cache in sync, so just return an empty tensor. + if self.num_speculative_tokens == 0: + return torch.empty( + batch_size, + 0, + device=sample_hidden_states.device, + dtype=torch.int64, + ) + # Early exit if there is only one draft token to be generated. if self.num_speculative_tokens == 1 or self.parallel_drafting: draft_token_ids, draft_probs = self._sample_draft_tokens( @@ -573,6 +685,11 @@ def propose( # tensor.argmax() returns int64 by default. input_ids = draft_token_ids_list[-1].int() + if self.use_heterogeneous_vocab: + # Map target token IDs to draft vocab space (TLI algorithm) + assert self.vocab_mapping is not None + input_ids = self.vocab_mapping.map_target_to_draft_ids(input_ids) + if not self.constant_draft_positions: positions = self._update_positions_dependent_metadata( positions, @@ -613,6 +730,12 @@ def propose( if self.pass_hidden_states_to_model: model_kwargs["hidden_states"] = self.hidden_states[:input_batch_size] + if self.eplb_state is not None: + self.eplb_state.prepare_forward( + self.draft_model_config, + batch_size, + ) + with set_forward_context( per_layer_attn_metadata, self.vllm_config, @@ -705,6 +828,13 @@ def set_inputs_first_pass( cad: CommonAttentionMetadata, num_rejected_tokens_gpu: torch.Tensor | None, ) -> tuple[int, torch.Tensor, CommonAttentionMetadata]: + # Map target token IDs to draft vocab space (TLI algorithm) + if self.use_heterogeneous_vocab: + assert self.vocab_mapping is not None + target_token_ids = self.vocab_mapping.map_target_to_draft_ids( + target_token_ids + ) + next_token_ids = self.vocab_mapping.map_target_to_draft_ids(next_token_ids) if not self.needs_extra_input_slots: # Default EAGLE pathway: no reshaping of input tensors needed. # Simply rotate the input ids and leave the positions unchanged, @@ -875,6 +1005,13 @@ def build_per_group_and_layer_attn_metadata( return per_group_attn_metadata, per_layer_attn_metadata def model_returns_tuple(self) -> bool: + if self.method == "mtp": + # DeepSeek-family MTP (deepseek_mtp.py) recycles the post-final- + # norm hidden, so its forward returns (logit_hidden, + # recycle_hidden). Other MTP families return a single tensor. + return "DeepSeekMTPModel" in ( + self.draft_model_config.hf_config.architectures or [] + ) return self.method not in ("mtp", "draft_model", "dflash") def prepare_next_token_ids_cpu( @@ -1077,7 +1214,7 @@ def prepare_inputs( new_query_start_loc_cpu = torch.zeros( query_start_loc_cpu.shape, dtype=torch.int32, - pin_memory=is_pin_memory_available(), + pin_memory=PIN_MEMORY, ) new_query_start_loc_np = new_query_start_loc_cpu.numpy() np.cumsum(new_num_tokens_per_req_np, out=new_query_start_loc_np[1:]) @@ -1110,11 +1247,11 @@ def prepare_inputs( # q1 + 0, q1 + 1, q1 + 2, q1 + 3, // req 2 # q1 + q2 + 0, q1 + q2 + 1, q1 + q2 + 2] // req 3 token_indices_np = token_offsets + old_query_start_locs_expanded - token_indices = torch.from_numpy(token_indices_np).to(device, non_blocking=True) + token_indices = async_tensor_h2d(token_indices_np, device=device) spec_common_attn_metadata = CommonAttentionMetadata( - query_start_loc=new_query_start_loc_cpu.to(device, non_blocking=True), - seq_lens=new_seq_lens_cpu.to(device, non_blocking=True), + query_start_loc=async_tensor_h2d(new_query_start_loc_cpu, device=device), + seq_lens=async_tensor_h2d(new_seq_lens_cpu, device=device), query_start_loc_cpu=new_query_start_loc_cpu, _seq_lens_cpu=new_seq_lens_cpu, _num_computed_tokens_cpu=common_attn_metadata._num_computed_tokens_cpu, @@ -1331,6 +1468,26 @@ def _maybe_share_embeddings(self, target_language_model: nn.Module) -> None: "Sharing target model embedding weights with the draft model." ) + if share_embeddings: + draft_embed = self.model.model.embed_tokens + # Only share when both models use the same embedding width. + # Guard with isinstance so non-Tensor weights (e.g. in tests) + # are not affected — mirrors the weight-equality check above. + if isinstance(target_embed_tokens.weight, torch.Tensor) and isinstance( + draft_embed.weight, torch.Tensor + ): + target_dim = target_embed_tokens.weight.shape[-1] + draft_dim = draft_embed.weight.shape[-1] + if target_dim != draft_dim: + share_embeddings = False + logger.info( + "Target embedding dim (%d) differs from draft " + "embedding dim (%d). Keeping separate embedding " + "weights.", + target_dim, + draft_dim, + ) + if share_embeddings: if hasattr(self.model.model, "embed_tokens"): del self.model.model.embed_tokens @@ -1411,16 +1568,34 @@ def _maybe_share_lm_head(self, target_language_model: nn.Module) -> None: ) if hasattr(target_language_model.model, "topk_indices_buffer"): + target_buffer = target_language_model.model.topk_indices_buffer if hasattr(self.model.model, "topk_indices_buffer"): del self.model.model.topk_indices_buffer - self.model.model.topk_indices_buffer = ( - target_language_model.model.topk_indices_buffer - ) + self.model.model.topk_indices_buffer = target_buffer + # Also share at per-module level so that the indexer and + # sparse-attention backends in each MTP layer read from + # the target model's buffer. + for _, module in self.model.model.named_modules(): + if hasattr(module, "topk_indices_buffer"): + module.topk_indices_buffer = target_buffer logger.info( "Detected MTP model with topk_indices_buffer. " "Sharing target model topk_indices_buffer with the draft model." ) + # Detect index_share_for_mtp_iteration: when True, the proposer + # toggles skip_topk so step 0 computes MTP's own indices and + # steps 1+ reuse them. + spec_config = self.vllm_config.speculative_config + draft_hf_config = ( + spec_config.draft_model_config.hf_config + if spec_config is not None + else None + ) + self._share_mtp_indices = getattr( + draft_hf_config, "index_share_for_mtp_iteration", False + ) + if self.use_local_argmax_reduction: if not hasattr(self.model, "get_top_tokens"): raise ValueError( @@ -1428,23 +1603,10 @@ def _maybe_share_lm_head(self, target_language_model: nn.Module) -> None: f"{self.model.__class__.__name__} does not implement " "get_top_tokens()." ) - # Warn if draft model has vocab remapping, which forces fallback - # to the full-logits path (negating the optimization). - if ( - hasattr(self.model, "draft_id_to_target_id") - and self.model.draft_id_to_target_id is not None - ): - logger.warning( - "use_local_argmax_reduction is enabled but draft model " - "uses draft_id_to_target_id vocab remapping. The " - "optimization will be bypassed (falling back to full " - "logits gather + argmax)." - ) - else: - logger.info( - "Using local argmax reduction for draft token generation " - "(communication: O(2*tp_size) vs O(vocab_size))." - ) + logger.info( + "Using local argmax reduction for draft token generation " + "(communication: O(2*tp_size) vs O(vocab_size))." + ) @torch.inference_mode() def dummy_run( @@ -1656,6 +1818,7 @@ def _determine_batch_execution_and_padding( def compute_probs_and_sample_next_token( logits: torch.Tensor, sampling_metadata: SamplingMetadata, + use_fp64_gumbel: bool = False, ) -> tuple[torch.Tensor, torch.Tensor]: if sampling_metadata.all_greedy: # For greedy requests, draft_probs is not used in rejection sampling. @@ -1682,11 +1845,11 @@ def compute_probs_and_sample_next_token( # of the generated tokens after rejection sampling. # TODO(woosuk): Consider seeds. - q = torch.empty_like(probs) + q = empty_exponential_noise_like(probs, use_fp64_gumbel) q.exponential_() # NOTE(woosuk): We shouldn't use `probs.div_(q)` because the draft_probs # will be used later for rejection sampling. - next_token_ids = probs.div(q).argmax(dim=-1).view(-1) + next_token_ids = sample_with_exponential_noise(probs.clone(), q) if not sampling_metadata.all_random: greedy_token_ids = probs.argmax(dim=-1) next_token_ids = torch.where(is_greedy, greedy_token_ids, next_token_ids) diff --git a/vllm/v1/spec_decode/medusa.py b/vllm/v1/spec_decode/medusa.py index 80b0f0a9870a..7adf7cff5f77 100644 --- a/vllm/v1/spec_decode/medusa.py +++ b/vllm/v1/spec_decode/medusa.py @@ -35,15 +35,18 @@ def __init__( self.max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens self.hidden_size = self.spec_config.draft_model_config.get_hidden_size() self.dtype = vllm_config.model_config.dtype + self.num_speculative_tokens = self.spec_config.num_speculative_tokens def propose( self, + num_speculative_tokens: int, target_hidden_states: torch.Tensor, sampling_metadata: SamplingMetadata, slot_mappings: dict[str, torch.Tensor] | list[dict[str, torch.Tensor]] | None = None, # unused ) -> torch.Tensor: + assert num_speculative_tokens == self.num_speculative_tokens # Generate blocks and compute logits blocks = self.model(target_hidden_states) logits = self.model.compute_logits(blocks) diff --git a/vllm/v1/spec_decode/metrics.py b/vllm/v1/spec_decode/metrics.py index 9a41ff5c818c..a3ccfb29e737 100644 --- a/vllm/v1/spec_decode/metrics.py +++ b/vllm/v1/spec_decode/metrics.py @@ -28,12 +28,14 @@ class SpecDecodingStats: num_draft_tokens: int = 0 num_accepted_tokens: int = 0 num_accepted_tokens_per_pos: list[int] = field(default_factory=list) + num_draft_tokens_per_pos: list[int] = field(default_factory=list) @classmethod def new(cls, num_spec_tokens: int) -> "SpecDecodingStats": return cls( num_spec_tokens=num_spec_tokens, num_accepted_tokens_per_pos=[0] * num_spec_tokens, + num_draft_tokens_per_pos=[0] * num_spec_tokens, ) def observe_draft(self, num_draft_tokens: int, num_accepted_tokens: int): @@ -43,6 +45,8 @@ def observe_draft(self, num_draft_tokens: int, num_accepted_tokens: int): assert num_accepted_tokens <= self.num_spec_tokens for i in range(num_accepted_tokens): self.num_accepted_tokens_per_pos[i] += 1 + for i in range(num_draft_tokens): + self.num_draft_tokens_per_pos[i] += 1 class SpecDecodingLogging: @@ -53,7 +57,11 @@ class SpecDecodingLogging: before resetting to zero. """ - def __init__(self): + def __init__(self, is_diffusion: bool = False): + # Diffusion (dLLM) models reuse the spec-decode data path with + # overloaded semantics, so the raw spec-decode framing (drafts, bonus + # token, per-position vector) is logged with diffusion-native terms. + self.is_diffusion = is_diffusion self.reset() def reset(self): @@ -85,6 +93,17 @@ def log(self, log_fn=logger.info): draft_throughput = num_draft_tokens / elapsed_time accepted_throughput = num_accepted_tokens / elapsed_time + if self.is_diffusion: + self._log_diffusion( + log_fn, + num_denoising_steps=num_drafts, + num_canvas_tokens=num_draft_tokens, + num_committed_tokens=num_accepted_tokens, + committed_throughput=accepted_throughput, + ) + self.reset() + return + draft_acceptance_rate = ( num_accepted_tokens / num_draft_tokens * 100 if num_draft_tokens > 0 @@ -117,6 +136,43 @@ def log(self, log_fn=logger.info): ) self.reset() + def _log_diffusion( + self, + log_fn, + num_denoising_steps: int, + num_canvas_tokens: int, + num_committed_tokens: int, + committed_throughput: float, + ): + # Each "draft" is one denoising step that re-evaluates the canvas block + # and finalizes some of its positions. + mean_committed_per_step = ( + num_committed_tokens / num_denoising_steps + if num_denoising_steps > 0 + else float("nan") + ) + mean_steps_per_canvas = ( + num_canvas_tokens / num_committed_tokens + if num_committed_tokens > 0 + else float("nan") + ) + + log_fn( + "DiffusionDecoding metrics: " + "Committed token throughput: %.2f tokens/s, " + "Mean denoising steps per canvas: %.2f, " + "Mean tokens committed per denoising step: %.2f, " + "Committed: %d tokens, " + "Denoising steps: %d, " + "Canvas positions evaluated: %d", + committed_throughput, + mean_steps_per_canvas, + mean_committed_per_step, + num_committed_tokens, + num_denoising_steps, + num_canvas_tokens, + ) + class SpecDecodingProm: """Record spec decoding metrics in Prometheus. @@ -146,56 +202,66 @@ def __init__( speculative_config: SpeculativeConfig | None, labelnames: list[str], per_engine_labelvalues: dict[int, list[object]], + is_diffusion: bool = False, ): - self.spec_decoding_enabled = speculative_config is not None + # Diffusion (dLLM) models reuse the spec-decode counters but expose them + # under diffusion-native names; the per-position acceptance vector does + # not apply, so it is omitted. + self.is_diffusion = is_diffusion + self.spec_decoding_enabled = speculative_config is not None or is_diffusion if not self.spec_decoding_enabled: return - counter_drafts = self._counter_cls( - name="vllm:spec_decode_num_drafts", - documentation="Number of spec decoding drafts.", - labelnames=labelnames, - ) - self.counter_spec_decode_num_drafts = create_metric_per_engine( - counter_drafts, per_engine_labelvalues - ) - - counter_draft_tokens = self._counter_cls( - name="vllm:spec_decode_num_draft_tokens", - documentation="Number of draft tokens.", - labelnames=labelnames, - ) - self.counter_spec_decode_num_draft_tokens = create_metric_per_engine( - counter_draft_tokens, per_engine_labelvalues - ) + if is_diffusion: + counter_specs = [ + ("vllm:diffusion_num_denoising_steps", "Number of denoising steps."), + ( + "vllm:diffusion_num_canvas_positions", + "Number of canvas positions evaluated.", + ), + ( + "vllm:diffusion_num_committed_tokens", + "Number of committed (finalized) tokens.", + ), + ] + else: + counter_specs = [ + ("vllm:spec_decode_num_drafts", "Number of spec decoding drafts."), + ("vllm:spec_decode_num_draft_tokens", "Number of draft tokens."), + ("vllm:spec_decode_num_accepted_tokens", "Number of accepted tokens."), + ] + + counters = [ + create_metric_per_engine( + self._counter_cls(name=name, documentation=doc, labelnames=labelnames), + per_engine_labelvalues, + ) + for name, doc in counter_specs + ] + # num_drafts/num_draft_tokens/num_accepted_tokens map onto denoising + # steps/canvas positions/committed tokens in the diffusion path. + self.counter_spec_decode_num_drafts = counters[0] + self.counter_spec_decode_num_draft_tokens = counters[1] + self.counter_spec_decode_num_accepted_tokens = counters[2] - counter_accepted_tokens = self._counter_cls( - name="vllm:spec_decode_num_accepted_tokens", - documentation="Number of accepted tokens.", - labelnames=labelnames, - ) - self.counter_spec_decode_num_accepted_tokens = create_metric_per_engine( - counter_accepted_tokens, per_engine_labelvalues - ) - - assert speculative_config is not None - num_spec_tokens = ( - speculative_config.num_speculative_tokens - if self.spec_decoding_enabled - else 0 - ) - pos_labelnames = labelnames + ["position"] - base_counter = self._counter_cls( - name="vllm:spec_decode_num_accepted_tokens_per_pos", - documentation="Accepted tokens per draft position.", - labelnames=pos_labelnames, - ) self.counter_spec_decode_num_accepted_tokens_per_pos: dict[ int, list[prometheus_client.Counter] - ] = { - idx: [base_counter.labels(*lv, str(pos)) for pos in range(num_spec_tokens)] - for idx, lv in per_engine_labelvalues.items() - } + ] = {} + if not is_diffusion: + assert speculative_config is not None + num_spec_tokens = speculative_config.num_speculative_tokens + pos_labelnames = labelnames + ["position"] + base_counter = self._counter_cls( + name="vllm:spec_decode_num_accepted_tokens_per_pos", + documentation="Accepted tokens per draft position.", + labelnames=pos_labelnames, + ) + self.counter_spec_decode_num_accepted_tokens_per_pos = { + idx: [ + base_counter.labels(*lv, str(pos)) for pos in range(num_spec_tokens) + ] + for idx, lv in per_engine_labelvalues.items() + } def observe(self, spec_decoding_stats: SpecDecodingStats, engine_idx: int = 0): if not self.spec_decoding_enabled: @@ -210,6 +276,6 @@ def observe(self, spec_decoding_stats: SpecDecodingStats, engine_idx: int = 0): spec_decoding_stats.num_accepted_tokens ) for pos, counter in enumerate( - self.counter_spec_decode_num_accepted_tokens_per_pos[engine_idx] + self.counter_spec_decode_num_accepted_tokens_per_pos.get(engine_idx, []) ): counter.inc(spec_decoding_stats.num_accepted_tokens_per_pos[pos]) diff --git a/vllm/v1/spec_decode/ngram_proposer.py b/vllm/v1/spec_decode/ngram_proposer.py index 53199d0ce217..e0240d0e66b8 100644 --- a/vllm/v1/spec_decode/ngram_proposer.py +++ b/vllm/v1/spec_decode/ngram_proposer.py @@ -55,6 +55,7 @@ def __init__(self, vllm_config: VllmConfig): # Trigger Numba JIT compilation for N-gram proposer. # This usually takes less than 1 second. self.propose( + self.k, [[]] * 1024, np.zeros(1024, dtype=np.int32), np.zeros((1024, self.max_model_len), dtype=np.int32), @@ -66,6 +67,7 @@ def batch_propose( valid_ngram_requests: list, num_tokens_no_spec: np.ndarray, token_ids_cpu: np.ndarray, + k: int, ) -> list[list[int]]: """Batch version of ngram proposer using numba for acceleration. @@ -78,6 +80,8 @@ def batch_propose( token_ids_cpu: Numpy array of shape (batch_size, max_model_len) representing the token IDs for each request. + k: + Number of speculative tokens to propose. Returns: list[list[int]]: @@ -110,7 +114,7 @@ def batch_propose( self.min_n, self.max_n, self.max_model_len, - self.k, + k, self.valid_ngram_draft, self.valid_ngram_num_drafts, ) @@ -130,6 +134,7 @@ def batch_propose( def propose( self, + num_speculative_tokens: int, sampled_token_ids: list[list[int]], num_tokens_no_spec: np.ndarray, token_ids_cpu: np.ndarray, @@ -137,6 +142,8 @@ def propose( | list[dict[str, torch.Tensor]] | None = None, # unused ) -> list[list[int]]: + assert num_speculative_tokens <= self.k + # find which requests need ngram proposals valid_ngram_requests = [] for i, sampled_ids in enumerate(sampled_token_ids): @@ -157,6 +164,7 @@ def propose( valid_ngram_requests, num_tokens_no_spec, token_ids_cpu, + num_speculative_tokens, ) return draft_token_ids diff --git a/vllm/v1/spec_decode/ngram_proposer_gpu.py b/vllm/v1/spec_decode/ngram_proposer_gpu.py index 7759d5c32f60..ed544bb27c1c 100644 --- a/vllm/v1/spec_decode/ngram_proposer_gpu.py +++ b/vllm/v1/spec_decode/ngram_proposer_gpu.py @@ -314,6 +314,7 @@ def _generate_dummy_data( def propose( self, + num_speculative_tokens: int, num_tokens_no_spec: torch.Tensor, # [batch_size] token_ids_gpu: torch.Tensor, # [batch_size, max_len] valid_sampled_token_ids_gpu: torch.Tensor, # [batch_size, num_spec_tokens + 1] @@ -326,6 +327,7 @@ def propose( updated lengths, then run the kernel. Args: + num_speculative_tokens: Number of speculative tokens to propose. num_tokens_no_spec: Number of tokens per sequence (read-only) token_ids_gpu: Token IDs tensor (modified in-place with new tokens) valid_sampled_token_ids_gpu: Newly sampled tokens to scatter @@ -336,6 +338,7 @@ def propose( num_valid_draft_tokens: Count of leading valid draft tokens per request [batch_size] """ + assert num_speculative_tokens == self.k assert token_ids_gpu.device == self.device assert num_tokens_no_spec.device == self.device @@ -542,7 +545,7 @@ def update_ngram_gpu_tensors_incremental( num_tokens = input_batch.num_tokens_no_spec[idx] if num_tokens > 0: token_ids_gpu_tensor[idx, :num_tokens].copy_( - input_batch.token_ids_cpu_tensor[idx, :num_tokens], + input_batch.token_ids_cpu_tensor[idx, :num_tokens].pin_memory(), non_blocking=True, ) @@ -588,7 +591,7 @@ def update_ngram_gpu_tensors_incremental( num_tokens = input_batch.num_tokens_no_spec[new_req_idx] if num_tokens > 0: token_ids_gpu_tensor[new_req_idx, :num_tokens].copy_( - input_batch.token_ids_cpu_tensor[new_req_idx, :num_tokens], + input_batch.token_ids_cpu_tensor[new_req_idx, :num_tokens].pin_memory(), non_blocking=True, ) diff --git a/vllm/v1/spec_decode/step3p5.py b/vllm/v1/spec_decode/step3p5.py index ccca17a31883..043f3f2be2bb 100644 --- a/vllm/v1/spec_decode/step3p5.py +++ b/vllm/v1/spec_decode/step3p5.py @@ -273,6 +273,7 @@ def _sample_draft_tokens_for_step( def propose( self, + num_speculative_tokens: int, target_token_ids: torch.Tensor, target_positions: torch.Tensor, target_hidden_states: torch.Tensor, @@ -286,6 +287,7 @@ def propose( | list[dict[str, torch.Tensor]] | None = None, ) -> torch.Tensor: + self.num_speculative_tokens = num_speculative_tokens self._last_draft_probs = None batch_size = common_attn_metadata.batch_size() diff --git a/vllm/v1/spec_decode/suffix_decoding.py b/vllm/v1/spec_decode/suffix_decoding.py index fee5d97468f3..66137a006316 100644 --- a/vllm/v1/spec_decode/suffix_decoding.py +++ b/vllm/v1/spec_decode/suffix_decoding.py @@ -34,12 +34,14 @@ def __init__(self, vllm_config: VllmConfig): def propose( self, + num_speculative_tokens: int, input_batch: InputBatch, sampled_token_ids: list[list[int]], slot_mappings: dict[str, torch.Tensor] | list[dict[str, torch.Tensor]] | None = None, # unused ) -> list[list[int]]: + assert num_speculative_tokens == self.num_speculative_tokens """ Propose speculative tokens for each request in the input batch. Suffix Decoding will speculate a dynamic number of tokens for each request every decoding step, diff --git a/vllm/v1/spec_decode/utils.py b/vllm/v1/spec_decode/utils.py index e046f0136152..65b9408a8901 100644 --- a/vllm/v1/spec_decode/utils.py +++ b/vllm/v1/spec_decode/utils.py @@ -156,7 +156,6 @@ def eagle_prepare_inputs_padded_kernel( # cumulative sum (first entry is the first value, not zero). cu_draft_curr = tl.load(cu_num_draft_tokens_ptr + req_idx) - num_draft_tokens = 0 if req_idx == 0: num_draft_tokens = cu_draft_curr else: diff --git a/vllm/v1/spec_decode/vocab_mapping.py b/vllm/v1/spec_decode/vocab_mapping.py new file mode 100644 index 000000000000..9a6bbe7cfaf4 --- /dev/null +++ b/vllm/v1/spec_decode/vocab_mapping.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import torch + +from vllm.logger import init_logger + +logger = init_logger(__name__) + + +def _detect_space_prefix(tokenizer) -> tuple[str, ...]: + """Detect the space-prefix character(s) by tokenizing a literal space. + + Different tokenizer families mark word-initial spaces differently: + BPE uses 'Ġ' (U+0120), SentencePiece uses '▁' (U+2581). Probing at + runtime avoids hardcoding assumptions and correctly handles mixed-family + pairs (e.g. BPE draft + SentencePiece target). + """ + try: + space_ids = tokenizer.encode(" a", add_special_tokens=False) + if space_ids: + tok_str = tokenizer.convert_ids_to_tokens(space_ids[0]) + if ( + isinstance(tok_str, str) + and len(tok_str) > 1 + and tok_str.endswith("a") + and tok_str[0] not in (" ", " ") + ): + return (tok_str[:-1],) + except Exception: + pass + # Fallback: cover both BPE (Ġ U+0120) and SentencePiece (▁ U+2581) + return ("\u0120", "\u2581") + + +def _normalize_token(token: str, space_prefixes: tuple[str, ...]) -> str: + for prefix in space_prefixes: + if token.startswith(prefix): + return " " + token[len(prefix) :] + return token + + +def _get_unk_token_id(tokenizer, role: str) -> int: + """Return a safe fallback token ID for out-of-intersection tokens. + + Preferred: unk_token_id → eos_token_id → ValueError. + Checking with ``is not None`` is required because token ID 0 is a valid + (and common) unk ID on many tokenizers; using ``or 0`` would silently + mishandle those cases. + """ + unk = getattr(tokenizer, "unk_token_id", None) + if unk is not None: + return unk + eos = getattr(tokenizer, "eos_token_id", None) + if eos is not None: + logger.warning( + "VocabMapping: %s has no unk_token_id; " + "falling back to eos_token_id=%d for out-of-intersection tokens", + role, + eos, + ) + return eos + raise ValueError( + f"VocabMapping: {role} has neither unk_token_id nor eos_token_id; " + "cannot safely map out-of-intersection tokens" + ) + + +class VocabMapping: + def __init__( + self, + target_tokenizer, + draft_tokenizer, + target_vocab_size, + draft_vocab_size, + device, + ): + self.target_vocab_size = target_vocab_size + self.draft_vocab_size = draft_vocab_size + self.device = device + self.target_unk_token_id = _get_unk_token_id( + target_tokenizer, "target tokenizer" + ) + self.draft_unk_token_id = _get_unk_token_id(draft_tokenizer, "draft tokenizer") + + target_prefixes = _detect_space_prefix(target_tokenizer) + draft_prefixes = _detect_space_prefix(draft_tokenizer) + + target_vocab = target_tokenizer.get_vocab() + draft_vocab = draft_tokenizer.get_vocab() + + target_normalized = {} + for token, tid in target_vocab.items(): + norm = _normalize_token(token, target_prefixes) + if norm not in target_normalized: + target_normalized[norm] = tid + + draft_normalized = {} + for token, tid in draft_vocab.items(): + norm = _normalize_token(token, draft_prefixes) + if norm not in draft_normalized: + draft_normalized[norm] = tid + + common_tokens = set(target_normalized.keys()) & set(draft_normalized.keys()) + + draft_to_target = torch.full((draft_vocab_size,), -1, dtype=torch.long) + target_to_draft = torch.full((target_vocab_size,), -1, dtype=torch.long) + intersection_mask_draft = torch.zeros(draft_vocab_size, dtype=torch.bool) + + for norm_token in common_tokens: + t_id = target_normalized[norm_token] + d_id = draft_normalized[norm_token] + if t_id < target_vocab_size and d_id < draft_vocab_size: + draft_to_target[d_id] = t_id + target_to_draft[t_id] = d_id + intersection_mask_draft[d_id] = True + + self.draft_to_target_ids = draft_to_target.to(device) + self.target_to_draft_ids = target_to_draft.to(device) + self.intersection_mask_draft = intersection_mask_draft.to(device) + self.intersection_size = int(intersection_mask_draft.sum().item()) + + logger.info( + "VocabMapping initialized: target_vocab=%d, draft_vocab=%d, " + "intersection=%d (%.1f%% of draft, %.1f%% of target)", + target_vocab_size, + draft_vocab_size, + self.intersection_size, + 100.0 * self.intersection_size / max(draft_vocab_size, 1), + 100.0 * self.intersection_size / max(target_vocab_size, 1), + ) + + if self.intersection_size < 100: + logger.warning( + "Very small vocabulary intersection (%d tokens).", + self.intersection_size, + ) + + def map_target_to_draft_ids(self, target_ids): + draft_ids = self.target_to_draft_ids[target_ids] # new tensor; no clone needed + missing = draft_ids == -1 + if missing.any(): + draft_ids[missing] = self.draft_unk_token_id + return draft_ids.to(target_ids.dtype) + + def map_draft_to_target_ids(self, draft_ids): + target_ids = self.draft_to_target_ids[draft_ids] # new tensor; no clone needed + missing = target_ids == -1 + if missing.any(): + target_ids[missing] = self.target_unk_token_id + return target_ids.to(draft_ids.dtype) + + def constrain_draft_logits(self, logits): + # masked_fill returns a new tensor; no clone needed + return logits.masked_fill(~self.intersection_mask_draft, float("-inf")) diff --git a/vllm/v1/structured_output/__init__.py b/vllm/v1/structured_output/__init__.py index 6a4fcbb629ff..34f775257be1 100644 --- a/vllm/v1/structured_output/__init__.py +++ b/vllm/v1/structured_output/__init__.py @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import itertools import multiprocessing -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from concurrent.futures import Future, ThreadPoolExecutor from typing import TYPE_CHECKING @@ -211,11 +211,8 @@ def grammar_bitmask( if not structured_output_request_ids: return None - max_num_spec_tokens = 0 - if self.vllm_config.speculative_config is not None: - max_num_spec_tokens = ( - self.vllm_config.speculative_config.num_speculative_tokens - ) + # Covers both speculative decoding and diffusion LLMs (canvas_length). + max_num_spec_tokens = self.vllm_config.num_speculative_tokens if self._grammar_bitmask is None: assert self.backend is not None @@ -275,17 +272,69 @@ def grammar_bitmask( grammar = structured_output_request.grammar apply_bitmask = self.should_fill_bitmask(request) + reasoner = self._get_reasoner(request) + detect_reasoning_end = ( + not apply_bitmask + and reasoner is not None + and not self.enable_in_reasoning + ) + simulated_buf: list[int] | None = None + history_len = 0 + state_advancements = 0 + post_reasoning_end_in_window = False req_tokens = scheduled_spec_decode_tokens.get(req_id, ()) - for token in itertools.chain(req_tokens, (-1,)): + for i, token in enumerate(req_tokens): self._fill_bitmasks(((grammar, cumulative_index, apply_bitmask),)) + advance_grammar = apply_bitmask if token == -1: - # Stop advancing the grammar once we hit a padding token. apply_bitmask = False - if apply_bitmask and not grammar.is_terminated(): + advance_grammar = False + elif ( + detect_reasoning_end + and reasoner is not None + and not apply_bitmask + ): + if simulated_buf is None: + history = list(request.all_token_ids) + history_len = len(history) + simulated_buf = history + list(req_tokens) + simulated = simulated_buf[: history_len + i + 1] + if reasoner.is_reasoning_end_streaming(simulated, [token]): + # Reasoning ended mid-window. Constrain the rest + # of the window via bitmask. Skip grammar advance + # through the marker (it is reasoning content); + # try to advance through subsequent drafts so the + # next bitmask row reflects the post-advance state, + # but tolerate rejection since those drafts predate + # the bitmask and are not guaranteed valid. + apply_bitmask = True + advance_grammar = False + post_reasoning_end_in_window = True + if advance_grammar and not grammar.is_terminated(): accepted = grammar.accept_tokens(req_id, [token]) - assert accepted, (token, req_id, scheduled_spec_decode_tokens) - state_advancements += 1 + if accepted: + state_advancements += 1 + elif not post_reasoning_end_in_window: + raise AssertionError( + (token, req_id, scheduled_spec_decode_tokens) + ) + cumulative_index += 1 + # Diffusion LLMs don't sample a bonus token after the + # scheduled positions, so skip its bitmask in that case. + if not (self.vllm_config.model_config.is_diffusion and req_tokens): + # bonus_apply must be True when the bonus-row position + # should be grammar-constrained. Two triggers: + # - should_fill_bitmask(request): reasoning was already + # over at step start (or no reasoner / + # enable_in_reasoning). + # - apply_bitmask: reasoning ended mid-window in this + # call and was flipped True after the marker; + # should_fill_bitmask still returns False here because + # reasoning_ended is only persisted later by + # should_advance. + bonus_apply = self.should_fill_bitmask(request) or apply_bitmask + self._fill_bitmasks(((grammar, cumulative_index, bonus_apply),)) cumulative_index += 1 if state_advancements > 0: grammar.rollback(state_advancements) @@ -365,10 +414,64 @@ def should_advance(self, request: "Request") -> bool: and structured_req.structured_output_key[0] == StructuredOutputOptions.STRUCTURAL_TAG ): + # The scheduler will advance the grammar with this step's + # tokens right away, but the step still contains reasoning + # content up to and including the end marker. Record where + # it ends so trim_reasoning_for_advance() can drop it. + structured_req.reasoning_end_token_index = ( + self._find_reasoning_end_index(reasoner, all_token_ids, start) + ) return True return False + @staticmethod + def _find_reasoning_end_index( + reasoner: "ReasoningParser", all_token_ids: Sequence[int], start: int + ) -> int: + """Locates the last reasoning token within ``all_token_ids[start:]``. + + Returns: + The absolute index of the token at which + ``is_reasoning_end_streaming`` first fires. Falls back to the + final index when no single token triggers the detection (e.g. + a multi-token marker only recognized on the full delta), which + conservatively treats the whole step as reasoning content. + """ + prefix = list(itertools.islice(all_token_ids, start)) + for idx in range(start, len(all_token_ids)): + token = all_token_ids[idx] + prefix.append(token) + if reasoner.is_reasoning_end_streaming(prefix, [token]): + return idx + return len(all_token_ids) - 1 + + def trim_reasoning_for_advance( + self, request: "Request", new_token_ids: list[int] + ) -> list[int]: + """Drops reasoning content from tokens about to advance the grammar. + + When reasoning ends mid-step (see should_advance), the step's output + still contains reasoning tokens up to and including the end marker. + Those are not grammar content: feeding them to accept_tokens makes + the grammar reject the marker and kills the request (#44006). + + Returns: + The suffix of ``new_token_ids`` that follows the reasoning-end + marker. Steps fully after the boundary are returned unchanged. + """ + structured_req = request.structured_output_request + if structured_req is None: + return new_token_ids + end_idx = structured_req.reasoning_end_token_index + if end_idx is None: + return new_token_ids + first_idx = len(request.all_token_ids) - len(new_token_ids) + num_reasoning = end_idx + 1 - first_idx + if num_reasoning <= 0: + return new_token_ids + return new_token_ids[num_reasoning:] + def clear_backend(self) -> None: if self.backend is not None: self.backend.destroy() diff --git a/vllm/v1/structured_output/backend_guidance.py b/vllm/v1/structured_output/backend_guidance.py index 31178e9f2462..19b2c76e9d3d 100644 --- a/vllm/v1/structured_output/backend_guidance.py +++ b/vllm/v1/structured_output/backend_guidance.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any import torch +from transformers import MistralCommonBackend from vllm.logger import init_logger from vllm.sampling_params import SamplingParams @@ -95,6 +96,10 @@ def __post_init__(self): if is_mistral_tokenizer(self.tokenizer): self.ll_tokenizer = self.tokenizer.llg_tokenizer + elif isinstance(self.tokenizer, MistralCommonBackend): + from mistral_common.guidance.tokenizer import from_mistral_tokenizer + + self.ll_tokenizer = from_mistral_tokenizer(self.tokenizer.tokenizer) else: self.ll_tokenizer = llguidance_hf.from_tokenizer( self.tokenizer, max(self.vocab_size, len(self.tokenizer)) diff --git a/vllm/v1/structured_output/backend_lm_format_enforcer.py b/vllm/v1/structured_output/backend_lm_format_enforcer.py index 94568b09a7f3..bbda96e60b22 100644 --- a/vllm/v1/structured_output/backend_lm_format_enforcer.py +++ b/vllm/v1/structured_output/backend_lm_format_enforcer.py @@ -11,7 +11,7 @@ from vllm.sampling_params import SamplingParams from vllm.utils.import_utils import LazyLoader -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.structured_output.backend_types import ( StructuredOutputBackend, StructuredOutputGrammar, @@ -139,7 +139,7 @@ def allocate_token_bitmask(self, max_num_seqs: int) -> torch.Tensor: (max_num_seqs, (self.vocab_size + 31) // 32), -1, dtype=torch.int32, - pin_memory=is_pin_memory_available(), + pin_memory=PIN_MEMORY, ) def destroy(self): diff --git a/vllm/v1/structured_output/backend_outlines.py b/vllm/v1/structured_output/backend_outlines.py index 20f604a53390..91627ff154c2 100644 --- a/vllm/v1/structured_output/backend_outlines.py +++ b/vllm/v1/structured_output/backend_outlines.py @@ -15,7 +15,7 @@ from vllm.sampling_params import SamplingParams from vllm.utils.import_utils import LazyLoader -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.structured_output.backend_types import ( StructuredOutputBackend, StructuredOutputGrammar, @@ -23,6 +23,7 @@ ) from vllm.v1.structured_output.utils import ( OutlinesVocabulary, + compile_regex_with_timeout, get_outlines_cache, get_outlines_vocabulary, ) @@ -61,7 +62,10 @@ def _compile_index( if cache_key in self.cache: return self.cache[cache_key] - index = oc.Index(regex_string, vocabulary.inner) + index = compile_regex_with_timeout( + lambda pat: oc.Index(pat, vocabulary.inner), + regex_string, + ) self.cache[cache_key] = index return index @@ -97,7 +101,7 @@ def allocate_token_bitmask(self, max_num_seqs: int) -> torch.Tensor: (max_num_seqs, (self.vocab_size + 31) // 32), -1, dtype=torch.int32, - pin_memory=is_pin_memory_available(), + pin_memory=PIN_MEMORY, ) def destroy(self): diff --git a/vllm/v1/structured_output/backend_xgrammar.py b/vllm/v1/structured_output/backend_xgrammar.py index a92be3d44320..4f199a1a2735 100644 --- a/vllm/v1/structured_output/backend_xgrammar.py +++ b/vllm/v1/structured_output/backend_xgrammar.py @@ -19,6 +19,7 @@ ) from vllm.v1.structured_output.utils import ( choice_as_grammar, + compile_regex_with_timeout, convert_lark_to_ebnf, grammar_is_likely_lark, ) @@ -88,7 +89,10 @@ def compile_grammar( elif request_type == StructuredOutputOptions.GRAMMAR: ctx = self.compiler.compile_grammar(grammar_spec) elif request_type == StructuredOutputOptions.REGEX: - ctx = self.compiler.compile_regex(grammar_spec) + ctx = compile_regex_with_timeout( + self.compiler.compile_regex, + grammar_spec, + ) elif request_type == StructuredOutputOptions.STRUCTURAL_TAG: s_tag = json.loads(grammar_spec) if "structures" in s_tag: @@ -277,7 +281,10 @@ def validate_xgrammar_grammar(sampling_params: SamplingParams) -> None: if so_params.regex: try: - xgr.Grammar.from_regex(so_params.regex) + compile_regex_with_timeout( + xgr.Grammar.from_regex, + so_params.regex, + ) except Exception as err: raise ValueError( f"Failed to transform regex into a grammar: {err}" diff --git a/vllm/v1/structured_output/request.py b/vllm/v1/structured_output/request.py index dfa8c7efcae4..f9ab54a0471c 100644 --- a/vllm/v1/structured_output/request.py +++ b/vllm/v1/structured_output/request.py @@ -23,6 +23,12 @@ class StructuredOutputRequest: params: StructuredOutputsParams _grammar: Future[StructuredOutputGrammar] | StructuredOutputGrammar | None = None reasoning_ended: bool | None = None + # Absolute index into the request's all_token_ids of the last reasoning + # token (the reasoning-end marker). Tokens at or before this index are + # reasoning content and must never be fed to the grammar. Only set when + # reasoning ends in a step whose tokens the scheduler advances immediately + # (structural tags + speculative decoding, see #42452). + reasoning_end_token_index: int | None = None reasoning_parser_kwargs: dict[str, Any] | None = None # Cached per request; do not share reasoning parsers across requests because # their behavior can depend on reasoning_parser_kwargs. diff --git a/vllm/v1/structured_output/utils.py b/vllm/v1/structured_output/utils.py index f149ae845e31..cde31e0fd5ce 100644 --- a/vllm/v1/structured_output/utils.py +++ b/vllm/v1/structured_output/utils.py @@ -6,9 +6,10 @@ import importlib.metadata import os import tempfile -from typing import TYPE_CHECKING +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor, TimeoutError +from typing import TYPE_CHECKING, TypeVar -import numpy as np import regex as re import torch from cachetools import LRUCache @@ -16,7 +17,7 @@ import vllm.envs as envs from vllm.logger import init_logger from vllm.utils.import_utils import LazyLoader -from vllm.utils.platform_utils import is_pin_memory_available +from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput if TYPE_CHECKING: @@ -38,9 +39,49 @@ logger = init_logger(__name__) +_T = TypeVar("_T") + CACHE = None +def compile_regex_with_timeout(fn: Callable[[str], _T], pattern: str) -> _T: + """Run a regex compilation callable with a timeout. + + Prevents ReDoS attacks where adversarial regex patterns (e.g. nested + quantifiers like ``(a+)+b``) cause exponential DFA state-space explosion, + hanging the inference worker indefinitely. + + Args: + fn: Single-argument callable that takes the pattern and performs + the regex compilation. + pattern: The regex pattern string, passed to *fn* and included in + timeout error messages. + + Raises: + ValueError: If compilation exceeds the configured timeout. + """ + timeout = envs.VLLM_REGEX_COMPILATION_TIMEOUT_S + if timeout <= 0: + return fn(pattern) + + executor = ThreadPoolExecutor(max_workers=1) + future = executor.submit(fn, pattern) + try: + result = future.result(timeout=timeout) + except TimeoutError: + future.cancel() + executor.shutdown(wait=False, cancel_futures=True) + raise ValueError( + f"Regex compilation timed out after {timeout}s. " + "The pattern may be too complex or contain constructs that " + "cause exponential state-space explosion (e.g. nested " + f"quantifiers). Pattern: {pattern[:200]}" + ) from None + else: + executor.shutdown(wait=False) + return result + + def apply_grammar_bitmask( scheduler_output: SchedulerOutput, grammar_output: GrammarOutput, @@ -81,11 +122,13 @@ def apply_grammar_bitmask( out_indices = [] # Reorder the bitmask to match the order of the requests in the batch. - sorted_bitmask = np.full( - shape=(logits.shape[0], grammar_bitmask.shape[1]), - fill_value=-1, - dtype=grammar_bitmask.dtype, + sorted_bitmask_tensor = torch.full( + (logits.shape[0], grammar_bitmask.shape[1]), + -1, + dtype=torch.from_numpy(grammar_bitmask[:0]).dtype, + pin_memory=PIN_MEMORY, ) + sorted_bitmask = sorted_bitmask_tensor.numpy() cumulative_index = 0 for req_id in grammar_output.structured_output_request_ids: num_spec_tokens = len(spec_tokens.get(req_id, ())) @@ -96,10 +139,8 @@ def apply_grammar_bitmask( out_indices.append(bitmask_index) cumulative_index += 1 + num_spec_tokens - # Copy async to device as tensor. - grammar_bitmask = torch.from_numpy(sorted_bitmask).to( - logits.device, non_blocking=True - ) + # Copy async to device. + grammar_bitmask = sorted_bitmask_tensor.to(logits.device, non_blocking=True) # If the length of out indices and the logits have the same shape # we don't need to pass indices to the kernel, @@ -112,11 +153,9 @@ def apply_grammar_bitmask( # xgrammar expects a python list of indices but it will actually work with # a tensor. If we copy the tensor ourselves here we can do it in a # non_blocking manner and there should be no cpu sync within xgrammar. - pin_memory = is_pin_memory_available() - index_tensor = torch.tensor( - out_indices, dtype=torch.int32, device="cpu", pin_memory=pin_memory + index_tensor = async_tensor_h2d( + out_indices, dtype=torch.int32, device=logits.device ) - index_tensor = index_tensor.to(logits.device, non_blocking=True) xgr.apply_token_bitmask_inplace(logits, grammar_bitmask, indices=index_tensor) return diff --git a/vllm/v1/utils.py b/vllm/v1/utils.py index efbf2daf398a..b485d838f3e8 100644 --- a/vllm/v1/utils.py +++ b/vllm/v1/utils.py @@ -31,6 +31,7 @@ from vllm.usage.usage_lib import UsageContext, is_usage_stats_enabled, usage_message from vllm.utils.network_utils import get_open_zmq_ipc_path, get_tcp_uri from vllm.utils.system_utils import decorate_logs, kill_process_tree, set_process_title +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.core.sched.output import SchedulerOutput if TYPE_CHECKING: @@ -114,7 +115,7 @@ def __init__( *size: int | torch.SymInt, dtype: torch.dtype, device: torch.device, - pin_memory: bool, + pin_memory: bool = PIN_MEMORY, with_numpy: bool = True, ) -> None: # these buffers are mutable runtime state, so allocate them as normal @@ -336,6 +337,7 @@ def __init__( args: argparse.Namespace, input_address: str, output_address: str, + engine_start_index: int, engine_count: int, stats_update_address: str | None = None, ): @@ -354,17 +356,34 @@ def __init__( input_address, "--output-address", output_address, + "--engine-start-index", + str(engine_start_index), "--engine-count", str(engine_count), ] if stats_update_address is not None: cmd.extend(["--coordinator-address", stats_update_address]) - from vllm.entrypoints.utils import jsonify_non_default_args - - args_json = json.dumps( - jsonify_non_default_args(args, exclude={"api_server_count"}), - sort_keys=True, + from vllm.entrypoints.serve.utils.api_utils import jsonify_non_default_args + + args_dict = jsonify_non_default_args( + args, + exclude={ + "api_server_count", + # Python passes the bootstrapped engine range explicitly. + "data_parallel_rank", + "data_parallel_external_lb", + "data_parallel_hybrid_lb", + }, ) + # The Rust `frontend` subcommand parses --args-json via serde_json, + # which bypasses clap and therefore ignores any `#[arg(env = ...)]` + # declarations on SharedRuntimeArgs fields. Forward the env-driven + # values explicitly so VLLM_ENGINE_READY_TIMEOUT_S and + # VLLM_HTTP_TIMEOUT_KEEP_ALIVE behave the same on both Python and Rust + # frontends. + args_dict["engine_ready_timeout_secs"] = envs.VLLM_ENGINE_READY_TIMEOUT_S + args_dict["http_timeout_keep_alive"] = envs.VLLM_HTTP_TIMEOUT_KEEP_ALIVE + args_json = json.dumps(args_dict, sort_keys=True) cmd.extend(["--args-json", args_json]) logger.info("Launching Rust frontend: %s", " ".join(cmd)) @@ -444,6 +463,12 @@ def _shutdown_subprocesses( timeout = 0.0 timeout = max(timeout, 5.0) + logger.debug( + "[shutdown] Subprocess manager: start process_count=%d timeout=%ss", + len(procs), + timeout, + ) + for proc in procs: if proc.is_alive(): proc.terminate() @@ -456,9 +481,18 @@ def _shutdown_subprocesses( if proc.is_alive(): proc.join(remaining) - for proc in procs: - if proc.is_alive() and (pid := proc.pid) is not None: - kill_process_tree(pid) + remaining_pids = [ + proc.pid for proc in procs if proc.is_alive() and proc.pid is not None + ] + if remaining_pids: + logger.warning( + "[shutdown] Subprocess manager: force killing remaining processes count=%d", + len(remaining_pids), + ) + for pid in remaining_pids: + kill_process_tree(pid) + + logger.debug_once("[shutdown] Subprocess manager: complete") def run_api_server_worker_proc( @@ -565,6 +599,12 @@ def shutdown(procs: list[BaseProcess], timeout: float | None = None) -> None: # have a user-configured shutdown timeout. timeout = 5.0 + logger.debug( + "[shutdown] Process manager: start process_count=%d timeout=%ss", + len(procs), + timeout, + ) + # Shutdown the process. for proc in procs: if proc.is_alive(): @@ -579,9 +619,18 @@ def shutdown(procs: list[BaseProcess], timeout: float | None = None) -> None: if proc.is_alive(): proc.join(remaining) - for proc in procs: - if proc.is_alive() and (pid := proc.pid) is not None: - kill_process_tree(pid) + remaining_pids = [ + proc.pid for proc in procs if proc.is_alive() and proc.pid is not None + ] + if remaining_pids: + logger.warning( + "[shutdown] Process manager: force killing remaining processes count=%d", + len(remaining_pids), + ) + for pid in remaining_pids: + kill_process_tree(pid) + + logger.debug_once("[shutdown] Process manager: complete") def copy_slice( @@ -608,29 +657,61 @@ def report_usage_stats( from vllm.model_executor.model_loader import get_architecture_class_name + model_config = vllm_config.model_config + scheduler_config = vllm_config.scheduler_config parallel_config = vllm_config.parallel_config + attention_config = vllm_config.attention_config + compilation_config = vllm_config.compilation_config + speculative_config = vllm_config.speculative_config # Prepare KV connector string if applicable kv_connector = None if vllm_config.kv_transfer_config is not None: kv_connector = vllm_config.kv_transfer_config.kv_connector + # Attention backend is None when set to "auto" (resolved at runtime per platform). + attention_backend = ( + attention_config.backend.name if attention_config.backend is not None else None + ) + + # CompilationMode is an IntEnum; report the name for readability in dashboards. + compilation_mode = ( + compilation_config.mode.name if compilation_config.mode is not None else None + ) + + # Speculative decoding fields default to None when spec decode is disabled. + spec_decode_method = ( + speculative_config.method if speculative_config is not None else None + ) + num_speculative_tokens = ( + speculative_config.num_speculative_tokens + if speculative_config is not None + else None + ) + + if model_config.using_transformers_backend(): + backend_cls = model_config._model_info.architecture + # Show what was wrapped e.g. TransformersForCausalLM(Starcoder2ForCausalLM) + architecture = f"{backend_cls}({model_config.architectures[0]})" + else: + architecture = get_architecture_class_name(model_config) + usage_message.report_usage( - get_architecture_class_name(vllm_config.model_config), + architecture, usage_context, extra_kvs={ # Common configuration - "dtype": str(vllm_config.model_config.dtype), + "dtype": str(model_config.dtype), "block_size": vllm_config.cache_config.block_size, "gpu_memory_utilization": vllm_config.cache_config.gpu_memory_utilization, "kv_cache_memory_bytes": vllm_config.cache_config.kv_cache_memory_bytes, # Quantization - "quantization": vllm_config.model_config.quantization, + "quantization": model_config.quantization, "kv_cache_dtype": str(vllm_config.cache_config.cache_dtype), # Feature flags "enable_lora": bool(vllm_config.lora_config), "enable_prefix_caching": vllm_config.cache_config.enable_prefix_caching, - "enforce_eager": vllm_config.model_config.enforce_eager, + "enforce_eager": model_config.enforce_eager, "disable_custom_all_reduce": parallel_config.disable_custom_all_reduce, # Distributed parallelism settings "tensor_parallel_size": parallel_config.tensor_parallel_size, @@ -641,6 +722,21 @@ def report_usage_stats( "all2all_backend": parallel_config.all2all_backend, # KV connector used "kv_connector": kv_connector, + # Batching limits — tuning knobs operators commonly override + "max_model_len": model_config.max_model_len, + "max_num_seqs": scheduler_config.max_num_seqs, + "max_num_batched_tokens": scheduler_config.max_num_batched_tokens, + # Attention backend (user-requested; None = auto-selected at runtime) + "attention_backend": attention_backend, + # torch.compile mode (e.g. NONE, STOCK_TORCH_COMPILE, VLLM_COMPILE) + "compilation_mode": compilation_mode, + # Speculative decoding configuration + "spec_decode_method": spec_decode_method, + "num_speculative_tokens": num_speculative_tokens, + # Wide expert parallel: load balancer + redundant/total expert counts + "enable_eplb": parallel_config.enable_eplb, + "num_redundant_experts": parallel_config.eplb_config.num_redundant_experts, + "num_experts": model_config.get_num_experts(), }, ) diff --git a/vllm/v1/worker/block_table.py b/vllm/v1/worker/block_table.py index 87a2aac9d4ca..d9c041ba0b89 100644 --- a/vllm/v1/worker/block_table.py +++ b/vllm/v1/worker/block_table.py @@ -322,7 +322,7 @@ def __getitem__(self, idx: int) -> "BlockTable": return self.block_tables[idx] -@triton.jit +@triton.jit(do_not_specialize=["num_tokens", "max_num_tokens"]) def _compute_slot_mapping_kernel( num_tokens, max_num_tokens, diff --git a/vllm/v1/worker/cpu/shm.py b/vllm/v1/worker/cpu/shm.py index 92aa1b5b95f3..bd1f96c71edc 100644 --- a/vllm/v1/worker/cpu/shm.py +++ b/vllm/v1/worker/cpu/shm.py @@ -7,6 +7,8 @@ from typing import Any +import numpy as np + # Patch torch APIs import torch @@ -15,6 +17,10 @@ def noop(*args: Any, **kwargs: Any) -> None: pass +def fake_pin_memory(self: torch.Tensor, *args: Any, **kwargs: Any) -> torch.Tensor: + return self + + class _EventPlaceholder: def __init__(self, *args, **kwargs) -> None: self.record = noop @@ -24,6 +30,7 @@ def __init__(self, *args, **kwargs) -> None: class _StreamPlaceholder: def __init__(self, *args, **kwargs) -> None: self.wait_stream = noop + self.device = torch.device("cpu") def __enter__(self, *args, **kwargs): return self @@ -32,6 +39,14 @@ def __exit__(self, exc_type, exc_val, exc_tb): pass +from vllm.utils.cpu_resource_utils import get_memory_node_info + + +def get_memory_info(*args: Any, **kwargs: Any) -> tuple[int, int]: + meminfo = get_memory_node_info() + return meminfo.available_memory, meminfo.total_memory + + torch.Event = _EventPlaceholder torch.cuda.Event = _EventPlaceholder torch.cuda.Stream = _StreamPlaceholder @@ -39,17 +54,22 @@ def __exit__(self, exc_type, exc_val, exc_tb): torch.cuda.current_stream = lambda *args, **kwargs: _StreamPlaceholder() torch.accelerator.synchronize = noop torch.accelerator.empty_cache = noop +torch.Tensor.pin_memory = fake_pin_memory +torch.accelerator.get_memory_info = get_memory_info # Patch vLLM torch utils import vllm.utils.torch_utils as torch_utils def async_tensor_h2d( - data: list, - dtype: torch.dtype, + data: list | np.ndarray | torch.Tensor, device: str | torch.device, - pin_memory: bool = False, + dtype: torch.dtype | None = None, ) -> torch.Tensor: + if isinstance(data, np.ndarray): + data = torch.from_numpy(data) + if isinstance(data, torch.Tensor): + return data.to(dtype=dtype) return torch.tensor(data, dtype=dtype, device="cpu") diff --git a/vllm/v1/worker/cpu_model_runner.py b/vllm/v1/worker/cpu_model_runner.py index 6afffa424d42..e6fcac8fc5a0 100644 --- a/vllm/v1/worker/cpu_model_runner.py +++ b/vllm/v1/worker/cpu_model_runner.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import sys from contextlib import contextmanager from typing import Any @@ -11,8 +12,7 @@ from vllm.logger import init_logger from vllm.model_executor.model_loader import get_model from vllm.tracing import instrument -from vllm.v1.core.sched.output import SchedulerOutput -from vllm.v1.kv_cache_interface import KVCacheConfig +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig from vllm.v1.utils import CpuGpuBuffer from vllm.v1.worker.gpu_model_runner import GPUModelRunner @@ -60,6 +60,15 @@ def replace_tensor(obj: Any, cpu_attr_name: str, device_attr_name) -> None: v.gpu = v.cpu def _postprocess_triton(self) -> None: + from vllm.triton_utils import HAS_TRITON + + if HAS_TRITON: + logger.info( + "Triton-CPU backend is available; skipping C++ monkey-patches " + "for Triton kernels." + ) + return + import vllm.v1.worker.block_table vllm.v1.worker.block_table._compute_slot_mapping_kernel = ( @@ -69,7 +78,7 @@ def _postprocess_triton(self) -> None: # Speculative decoding fallbacks import vllm.v1.sample.rejection_sampler import vllm.v1.spec_decode.llm_base_proposer - import vllm.v1.spec_decode.utils + import vllm.v1.spec_decode.utils as spec_decode_utils vllm.v1.spec_decode.llm_base_proposer.eagle_prepare_inputs_padded_kernel = ( cpu_tl.eagle_prepare_inputs_padded_kernel @@ -80,7 +89,18 @@ def _postprocess_triton(self) -> None: vllm.v1.spec_decode.llm_base_proposer.copy_and_expand_eagle_inputs_kernel = ( cpu_tl.copy_and_expand_eagle_inputs_kernel ) - vllm.v1.spec_decode.utils.eagle_step_slot_mapping_metadata_kernel = ( + spec_decode_utils.copy_and_expand_dflash_inputs_kernel = ( + cpu_tl.copy_and_expand_dflash_inputs_kernel + ) + dflash_module = sys.modules.get("vllm.v1.spec_decode.dflash") + if dflash_module is not None: + dflash_kernel_name = "copy_and_expand_dflash_inputs_kernel" + setattr( + dflash_module, + dflash_kernel_name, + cpu_tl.copy_and_expand_dflash_inputs_kernel, + ) + spec_decode_utils.eagle_step_slot_mapping_metadata_kernel = ( cpu_tl.eagle_step_slot_mapping_metadata_kernel ) vllm.v1.sample.rejection_sampler.rejection_greedy_sample_kernel = ( @@ -94,6 +114,10 @@ def _postprocess_triton(self) -> None: cpu_tl.sample_recovered_tokens_kernel ) + import vllm.v1.worker.mamba_utils + + vllm.v1.worker.mamba_utils.batch_memcpy_kernel = cpu_tl.batch_memcpy_kernel + @instrument(span_name="Loading (CPU)") def load_model(self, load_dummy_weights: bool = False) -> None: if load_dummy_weights: @@ -144,70 +168,25 @@ def _sync_device(self) -> None: pass def _zero_block_ids(self, block_ids: list[int]) -> None: - # CPU attention assigns -INF to logits at invalid positions, - # so stale KV cache data never affects computation. - pass - - # ========================================================================= - # CPU-safe overrides for speculative decoding methods - # These methods override GPU-specific implementations that use CUDA streams - # ========================================================================= - - def _copy_draft_token_ids_to_cpu( - self, scheduler_output: "SchedulerOutput", zeros_only: bool = False - ) -> None: - """CPU-safe version: no async copy needed, tensors already on CPU.""" - if self.use_async_scheduling and not ( - scheduler_output.has_structured_output_requests - or self.input_batch.sampling_metadata.output_token_ids - ): - return - self._draft_token_req_ids = self.input_batch.req_ids.copy() - - draft_token_ids: torch.Tensor = self._draft_token_ids - if not torch.is_tensor(draft_token_ids): - return - - num_reqs = draft_token_ids.shape[0] - if self.draft_token_ids_cpu is not None: - if not zeros_only: - self.draft_token_ids_cpu[:num_reqs].copy_(draft_token_ids) - else: - self.draft_token_ids_cpu[:num_reqs] = 0 - - def _get_draft_token_ids_cpu(self) -> tuple[list[list[int]], list[str]]: - """CPU-safe version: no event synchronization needed.""" - if isinstance(self._draft_token_ids, list): - return self._draft_token_ids, self.input_batch.req_ids - req_ids = self._draft_token_req_ids - if req_ids is None: - return [], [] - if self.draft_token_ids_cpu is not None: - return self.draft_token_ids_cpu[: len(req_ids)].tolist(), req_ids - return [], [] - - def _copy_valid_sampled_token_count( - self, next_token_ids: torch.Tensor, valid_sampled_tokens_count: torch.Tensor - ) -> None: - """CPU-safe version: direct copy without CUDA streams.""" - if self.valid_sampled_token_count_cpu is None: - return - - counts = valid_sampled_tokens_count - counts_cpu = self.valid_sampled_token_count_cpu - counts_cpu[: counts.shape[0]].copy_(counts) - self.input_batch.prev_sampled_token_ids = next_token_ids.unsqueeze(1) - - def _get_valid_sampled_token_count(self) -> list[int]: - """CPU-safe version: no event synchronization needed.""" - prev_sampled_token_ids = self.input_batch.prev_sampled_token_ids - if prev_sampled_token_ids is None: - return [] - - counts_cpu = self.valid_sampled_token_count_cpu - if counts_cpu is None: - return [] - return counts_cpu[: prev_sampled_token_ids.shape[0]].tolist() + # Zero full-attention blocks to prevent stale data corruption on partial writes. + # Encoder-only (runner-only) layers are not FullAttentionSpec, so the + # spec filter below already excludes them; no runner-only skip needed. + seen_ptrs: set[int] = set() + for group in self.kv_cache_config.kv_cache_groups: + if not isinstance(group.kv_cache_spec, FullAttentionSpec): + continue + for layer_name in group.layer_names: + ctx = self.compilation_config.static_forward_context.get(layer_name) + if ctx is None: + continue + kv = ctx.kv_cache + if not isinstance(kv, torch.Tensor): + continue + if kv.data_ptr() in seen_ptrs: + continue + seen_ptrs.add(kv.data_ptr()) + for block_id in block_ids: + kv[block_id].zero_() def _to_list(self, sampled_token_ids: torch.Tensor) -> list[list[int]]: """CPU-safe version: direct tolist() without CUDA events.""" @@ -223,7 +202,8 @@ def __init__(self, *args, **kwargs) -> None: class _StreamPlaceholder: def __init__(self, *args, **kwargs) -> None: - pass + self.wait_stream = lambda *a, **kw: None + self.device = torch.device("cpu") cuda_event = torch.Event cuda_stream = torch.cuda.Stream diff --git a/vllm/v1/worker/cpu_worker.py b/vllm/v1/worker/cpu_worker.py index 9edb870a03a1..2433bc8a10ba 100644 --- a/vllm/v1/worker/cpu_worker.py +++ b/vllm/v1/worker/cpu_worker.py @@ -108,7 +108,7 @@ def init_device(self): self.device = torch.device("cpu") # Check whether critical libraries are loaded - def check_preloaded_libs(name: str): + def check_preloaded_libs(name: str) -> bool: ld_preload_list = os.environ.get("LD_PRELOAD", "") if name not in ld_preload_list: logger.warning( @@ -119,11 +119,22 @@ def check_preloaded_libs(name: str): "to setup required pre-loaded libraries.", name, ) + return False + return True if sys.platform.startswith("linux"): check_preloaded_libs("libtcmalloc") if current_platform.get_cpu_architecture() == CpuArchEnum.X86: - check_preloaded_libs("libiomp") + iomp_loaded = check_preloaded_libs("libiomp") + if not iomp_loaded and self.vllm_config.speculative_config is not None: + logger.warning( + "Speculative decoding on CPU without Intel OpenMP in " + "LD_PRELOAD will cause significant performance loss. " + "Please follow the section `set LD_PRELOAD` in " + "https://docs.vllm.ai/en/latest/getting_started/" + "installation/cpu/ " + "to setup libiomp5.", + ) def skip_set_num_threads(x: int): logger.warning( diff --git a/vllm/v1/worker/encoder_cudagraph.py b/vllm/v1/worker/encoder_cudagraph.py index 583fd78ced0e..d3c54812894f 100644 --- a/vllm/v1/worker/encoder_cudagraph.py +++ b/vllm/v1/worker/encoder_cudagraph.py @@ -152,20 +152,40 @@ def __init__( and vllm_config.parallel_config.tensor_parallel_size > 1 ) - self.budget_graphs: dict[int, BudgetGraphMetadata] = {} + self.budget_graphs: dict[str, dict[int, BudgetGraphMetadata]] = {} self.graph_pool: Any | None = None self.graph_hits = 0 self.graph_misses = 0 self.log_stats_interval = 100 - logger.info( - "EncoderCudaGraphManager initialized with " - "budgets=%s, max_batch_size=%d, max_frames_per_batch=%s, use_dp=%s", - self.token_budgets, - self.max_batch_size, - self.max_frames_per_batch, - self.use_dp, - ) + if self.config.enable_dual_path_graph: + max_budget = self.token_budgets[-1] + self.global_token_budgets = self._generate_budgets( + self.config.global_token_per_image, + max_budget, + ) + self.local_token_budgets = self._generate_budgets( + self.config.local_token_per_patch, + max_budget, + ) + # When `image_width <= 640 and image_height <= 640`, the mm inputs + # will only contain global image, without generating local patches. + self.local_token_budgets.insert(0, 0) + logger.info( + "EncoderCudaGraphManager dual-path mode: " + "global_budgets=%s, local_budgets=%s", + self.global_token_budgets, + self.local_token_budgets, + ) + else: + logger.info( + "EncoderCudaGraphManager initialized with " + "budgets=%s, max_batch_size=%d, max_frames_per_batch=%s, use_dp=%s", + self.token_budgets, + self.max_batch_size, + self.max_frames_per_batch, + self.use_dp, + ) @staticmethod def _generate_budgets(min_budget: int, max_budget: int) -> list[int]: @@ -186,25 +206,50 @@ def supports_modality(self, modality: str) -> bool: def clear(self) -> None: """Release captured encoder CUDA graphs and the manager-local pool.""" - self.budget_graphs.clear() + for graph_set in self.budget_graphs.values(): + graph_set.clear() self.graph_pool = None def capture(self, graph_pool: Any): """Capture CUDA graphs for all token budgets.""" self.graph_pool = graph_pool + if self.config.enable_dual_path_graph: + for token_budget in sorted(self.global_token_budgets, reverse=True): + self._capture_budget_graph(token_budget, path="global") + for token_budget in sorted(self.local_token_budgets, reverse=True): + if token_budget == 0: + continue + self._capture_budget_graph(token_budget, path="local") + logger.info( + "Encoder CUDA graph capture complete. " + "Captured %d global + %d local budget graphs.", + len(self.budget_graphs["global"]), + len(self.budget_graphs["local"]), + ) + return + for token_budget in sorted(self.token_budgets, reverse=True): self._capture_budget_graph(token_budget) logger.info( "Encoder CUDA graph capture complete. Captured %d budget graphs.", - len(self.budget_graphs), + len(self.budget_graphs["default"]), ) def get_num_graphs_to_capture(self) -> int: + if self.config.enable_dual_path_graph: + return len(self.global_token_budgets) + len(self.local_token_budgets) return len(self.token_budgets) - def _capture_budget_graph(self, token_budget: int): + def _get_graph_set(self, path: str = "default") -> dict[int, BudgetGraphMetadata]: + # Lazy init global/local graph sets for dual-path models, or default graph + # set for single-path models. + if path not in self.budget_graphs: + self.budget_graphs[path] = {} + return self.budget_graphs[path] + + def _capture_budget_graph(self, token_budget: int, path: str = "default"): """Capture CUDA graph for a single token budget.""" logger.debug( "Capturing encoder cudagraph for budget=%d, max_batch_size=%d, " @@ -214,26 +259,29 @@ def _capture_budget_graph(self, token_budget: int): self.max_frames_per_batch, ) + graph_set = self._get_graph_set(path) + capture_inputs = self.model.prepare_encoder_cudagraph_capture_inputs( token_budget, self.max_batch_size, self.max_frames_per_batch, self.device, self.dtype, + path, ) values = capture_inputs.values with torch.inference_mode(): - output = self.model.encoder_cudagraph_forward({**values}) + output = self.model.encoder_cudagraph_forward({**values}, path=path) output_buffer = torch.empty_like(output) graph = torch.cuda.CUDAGraph() with torch.inference_mode(), torch.cuda.graph(graph, pool=self.graph_pool): - output = self.model.encoder_cudagraph_forward({**values}) + output = self.model.encoder_cudagraph_forward({**values}, path=path) output_buffer.copy_(output) - self.budget_graphs[token_budget] = BudgetGraphMetadata( + graph_set[token_budget] = BudgetGraphMetadata( token_budget=token_budget, max_batch_size=self.max_batch_size, max_frames_per_batch=self.max_frames_per_batch, @@ -243,14 +291,15 @@ def _capture_budget_graph(self, token_budget: int): ) def _find_smallest_fitting_budget_given_tokens( - self, total_tokens: int + self, total_tokens: int, budgets: list[int] | None = None ) -> int | None: """Find smallest budget >= total_tokens. Returns: Token budget if found, None if no fitting budget. """ - for budget in self.token_budgets: + budgets = budgets if budgets is not None else self.token_budgets + for budget in budgets: if budget >= total_tokens: return budget return None @@ -275,35 +324,40 @@ def _run_budget_graph( self, mm_kwargs: dict[str, Any], token_budget: int, + path: str = "default", ) -> torch.Tensor | None: """Execute budget graph. Args: mm_kwargs: Multimodal inputs for the batch. token_budget: Token budget to use. - + path: Path for the graph. Should be one of ["default", "global", "local"]. Returns: Encoder outputs, or None if graph not captured. """ + graph_set = self._get_graph_set(path) num_items = len(self._get_item_specs(mm_kwargs)) - if token_budget not in self.budget_graphs: + + if token_budget not in graph_set: self.graph_misses += num_items return None - graph_meta = self.budget_graphs[token_budget] + graph_meta = graph_set[token_budget] replay = self.model.prepare_encoder_cudagraph_replay_buffers( mm_kwargs, self.max_batch_size, self.max_frames_per_batch, + path, ) - # Copy metadata buffers using keys from config.buffer_keys. - for key in self.config.buffer_keys: + # Copy replay buffers into graph input buffers. Iterate over the + # graph's own buffer keys (which may differ per path for dual-path + # models) rather than the global config.buffer_keys. + for key, buf in graph_meta.input_buffers.items(): src = replay.values.get(key) if src is None: continue - buf = graph_meta.input_buffers[key] if src.ndim == 0: buf.copy_(src) else: @@ -329,6 +383,11 @@ def _execute_local( image would overflow either constraint), find the smallest fitting budget once for that batch. + For dual-path models (``enable_dual_path_graph=True``), two independent + graph sets are used: one for global images, one for local patches. + Budgets are found independently per path; if only one path fits, the + other falls back to eager via partial fallback. + By exchange argument, greedy smallest-first packing minimises eager fallbacks -- any other ordering yields a higher token sum in some batch, making that batch more likely to exceed the budget. @@ -340,6 +399,15 @@ def _execute_local( always satisfy total_tokens <= max_budget and therefore always find a valid budget (no miss). """ + if self.config.enable_dual_path_graph: + return self._execute_local_dual_path(mm_kwargs) + return self._execute_local_single_path(mm_kwargs) + + def _execute_local_single_path( + self, + mm_kwargs: dict[str, Any], + ) -> list[torch.Tensor]: + """Single-path greedy-packing execution (original behaviour).""" item_specs = self._get_item_specs(mm_kwargs) num_items = len(item_specs) max_budget = self.token_budgets[-1] @@ -441,6 +509,166 @@ def _execute_local( # Return in original batch order (caller maps outputs to token positions) return [outputs_by_orig_idx[i] for i in range(num_items)] + def _execute_local_dual_path( + self, + mm_kwargs: dict[str, Any], + ) -> list[torch.Tensor]: + """Dual-path greedy-packing execution. + + Each image contributes both global tokens (constant per image) + and local tokens (patches * patch_tokens). Greedy packing + respects both budgets independently, then selects the smallest + fitting budget per path with partial eager fallback. + """ + item_specs = self._get_item_specs(mm_kwargs) + num_items = len(item_specs) + + max_global_budget = self.global_token_budgets[-1] + max_local_budget = self.local_token_budgets[-1] + + per_item_global_tokens = [spec.global_output_tokens for spec in item_specs] + per_item_local_tokens = [spec.local_output_tokens for spec in item_specs] + per_item_total_tokens = [spec.output_tokens for spec in item_specs] + + # Sort ascending by total output tokens + sorted_indices = sorted( + range(num_items), key=lambda i: per_item_total_tokens[i] + ) + + # Each batch is a tuple of (indices, global_budget, local_budget). + batches: list[tuple[list[int], int | None, int | None]] = [] + current_batch: list[int] = [] + current_global_tokens = 0 + current_local_tokens = 0 + + for orig_idx in sorted_indices: + global_token = per_item_global_tokens[orig_idx] + local_token = per_item_local_tokens[orig_idx] + if ( + current_global_tokens + global_token <= max_global_budget + and current_local_tokens + local_token <= max_local_budget + and len(current_batch) < self.max_batch_size + ): + current_batch.append(orig_idx) + current_global_tokens += global_token + current_local_tokens += local_token + else: + if current_batch: + batches.append( + ( + current_batch, + self._find_smallest_fitting_budget_given_tokens( + current_global_tokens, self.global_token_budgets + ), + self._find_smallest_fitting_budget_given_tokens( + current_local_tokens, self.local_token_budgets + ), + ) + ) + current_batch = [orig_idx] + current_global_tokens = global_token + current_local_tokens = local_token + + if current_batch: + batches.append( + ( + current_batch, + self._find_smallest_fitting_budget_given_tokens( + current_global_tokens, self.global_token_budgets + ), + self._find_smallest_fitting_budget_given_tokens( + current_local_tokens, self.local_token_budgets + ), + ) + ) + + outputs_by_orig_idx: dict[int, torch.Tensor] = {} + + for batch_orig_indices, global_budget, local_budget in batches: + batch_mm_kwargs = self.model.select_encoder_cudagraph_items( + mm_kwargs, batch_orig_indices + ) + batch_global_tokens = sum( + per_item_global_tokens[i] for i in batch_orig_indices + ) + batch_local_tokens = sum( + per_item_local_tokens[i] for i in batch_orig_indices + ) + + both_eager = global_budget is None and local_budget is None + + if both_eager: + logger.debug( + "Encoder CUDA graph dual-path full eager fallback: " + "%d global + %d local tokens from %d images", + batch_global_tokens, + batch_local_tokens, + len(batch_orig_indices), + ) + self.graph_misses += len(batch_orig_indices) + with torch.inference_mode(): + raw = self.model.encoder_eager_forward(batch_mm_kwargs) + per_item_total = [ + per_item_global_tokens[i] + per_item_local_tokens[i] + 1 + for i in batch_orig_indices + ] + scatter_output_slices( + raw, batch_orig_indices, per_item_total, outputs_by_orig_idx + ) + continue + + logger.debug( + "Encoder CUDA graph dual-path: batch_size=%d, " + "global=%d (budget=%s), local=%d (budget=%s)", + len(batch_orig_indices), + batch_global_tokens, + global_budget, + batch_local_tokens, + local_budget, + ) + + # Execute global path: graph or eager fallback + if global_budget is not None: + global_output = self._run_budget_graph( + batch_mm_kwargs, + global_budget, + path="global", + ) + assert global_output is not None + else: + with torch.inference_mode(): + global_output = self.model.encoder_eager_forward( + batch_mm_kwargs, path="global" + ) + + # Execute local path: graph or eager fallback + if local_budget is not None and batch_local_tokens > 0: + local_output = self._run_budget_graph( + batch_mm_kwargs, + local_budget, + path="local", + ) + assert local_output is not None + elif batch_local_tokens > 0: + with torch.inference_mode(): + local_output = self.model.encoder_eager_forward( + batch_mm_kwargs, path="local" + ) + else: + local_output = None + + self.model.postprocess_encoder_output( + global_output, + batch_orig_indices, + per_item_global_tokens, + outputs_by_orig_idx, + clone=True, + batch_mm_kwargs=batch_mm_kwargs, + local_output=local_output, + ) + + return [outputs_by_orig_idx[i] for i in range(num_items)] + def _dp_shard( self, mm_kwargs: dict[str, Any], @@ -626,10 +854,12 @@ def get_cumulative_stats(self) -> dict[str, Any]: total_requests = self.graph_hits + self.graph_misses hit_rate = self.graph_hits / total_requests if total_requests > 0 else 0.0 + num_budgets = sum(len(g) for g in self.budget_graphs.values()) + return { "graph_hits": self.graph_hits, "graph_misses": self.graph_misses, "hit_rate": hit_rate, - "num_budgets": len(self.budget_graphs), + "num_budgets": num_budgets, "token_budgets": self.token_budgets, } diff --git a/vllm/v1/worker/encoder_cudagraph_defs.py b/vllm/v1/worker/encoder_cudagraph_defs.py index 7fb08f63aafd..ae7902430275 100644 --- a/vllm/v1/worker/encoder_cudagraph_defs.py +++ b/vllm/v1/worker/encoder_cudagraph_defs.py @@ -26,6 +26,14 @@ class EncoderItemSpec: """Number of output tokens after encoder processing (e.g. after spatial merge).""" + global_output_tokens: int = 0 + """Number of output tokens from the global image path. + Only used when ``EncoderCudaGraphConfig.enable_dual_path_graph`` is True.""" + + local_output_tokens: int = 0 + """Number of output tokens from the local patch path. + Only used when ``EncoderCudaGraphConfig.enable_dual_path_graph`` is True.""" + @dataclass class EncoderCudaGraphConfig: @@ -60,6 +68,18 @@ class EncoderCudaGraphConfig: Only relevant when "video" is in ``modalities``. Image-only models can use the default of 1.""" + enable_dual_path_graph: bool = False + """If True, the manager captures two independent graph sets + (global + local) and runs dual-path graph selection during inference.""" + + global_token_per_image: int = 0 + """Tokens per global image (e.g. 272 for DeepSeek-OCR). + Only used when ``enable_dual_path_graph`` is True.""" + + local_token_per_patch: int = 0 + """Tokens per local patch (e.g. 100 for DeepSeek-OCR). + Only used when ``enable_dual_path_graph`` is True.""" + @dataclass class EncoderCudaGraphCaptureInputs: diff --git a/vllm/v1/worker/gpu/async_utils.py b/vllm/v1/worker/gpu/async_utils.py index b3d6f5e4d901..e4659104f49e 100644 --- a/vllm/v1/worker/gpu/async_utils.py +++ b/vllm/v1/worker/gpu/async_utils.py @@ -24,7 +24,8 @@ def __init__( self.model_runner_output = model_runner_output self.sampler_output = sampler_output self.num_sampled_tokens = num_sampled_tokens - self.copy_event = torch.cuda.Event() + # Blocking (sleep) event to avoid busy-polling the CUDA driver lock. + self.copy_event = torch.cuda.Event(blocking=True) with stream(copy_stream, main_stream): copy_stream.wait_stream(main_stream) @@ -81,7 +82,8 @@ def __init__( self.model_runner_output = model_runner_output self.pooler_output = pooler_output self.is_valid = is_valid - self.copy_event = torch.cuda.Event() + # Blocking (sleep) event to avoid busy-polling the CUDA driver lock. + self.copy_event = torch.cuda.Event(blocking=True) with stream(copy_stream, main_stream): copy_stream.wait_stream(main_stream) diff --git a/vllm/v1/worker/gpu/attn_utils.py b/vllm/v1/worker/gpu/attn_utils.py index 6fc55ee32030..228fd08a751b 100644 --- a/vllm/v1/worker/gpu/attn_utils.py +++ b/vllm/v1/worker/gpu/attn_utils.py @@ -1,14 +1,21 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -from collections.abc import Iterable, Sequence -from dataclasses import dataclass +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass, replace +from math import prod from typing import Any, cast import torch -from vllm.config import VllmConfig, get_layers_from_vllm_config +from vllm.config import ( + VllmConfig, + get_layers_from_vllm_config, + set_current_vllm_config, +) +from vllm.logger import init_logger from vllm.model_executor.layers.attention import Attention from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase +from vllm.multimodal.inputs import MultiModalFeatureSpec from vllm.utils.torch_utils import get_dtype_size from vllm.v1.attention.backend import ( AttentionCGSupport, @@ -18,7 +25,9 @@ AttentionSpec, KVCacheConfig, KVCacheSpec, + KVQuantMode, MambaSpec, + TQFullAttentionSpec, UniformTypeKVCacheSpecs, ) from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata @@ -29,6 +38,8 @@ prepare_kernel_block_sizes, ) +logger = init_logger(__name__) + @dataclass(frozen=True) class AttentionCGSupportInfo: @@ -46,6 +57,13 @@ def get_kv_cache_spec(vllm_config: VllmConfig) -> dict[str, KVCacheSpec]: continue # Skip modules that don't need KV cache (eg encoder-only attention) if spec := attn_module.get_kv_cache_spec(vllm_config): + if isinstance(spec, AttentionSpec): + backend = attn_module.get_attn_backend() + # indexes_kv_by_block_stride() -> get_kv_cache_stride_order() -> + # get_kv_cache_layout() needs the current vLLM config. + with set_current_vllm_config(vllm_config): + indexes = backend.indexes_kv_by_block_stride() + spec = replace(spec, indexes_kv_by_block_stride=indexes) kv_cache_spec[layer_name] = spec return kv_cache_spec @@ -85,8 +103,8 @@ def init_attn_backend( layer_type = cast(type[Any], AttentionLayerBase) attn_layers = get_layers_from_vllm_config(vllm_config, layer_type, layer_names) - group_map: dict[tuple[tuple[str, str], KVCacheSpec], AttentionGroup] = {} - group_order: list[tuple[tuple[str, str], KVCacheSpec]] = [] + group_map: dict[tuple[tuple[str, str], KVCacheSpec, int], AttentionGroup] = {} + group_order: list[tuple[tuple[str, str], KVCacheSpec, int]] = [] for layer_name in layer_names: attn_backend = attn_layers[layer_name].get_attn_backend() @@ -95,7 +113,11 @@ def init_attn_backend( if isinstance(layer_kv_cache_spec, UniformTypeKVCacheSpecs): layer_kv_cache_spec = layer_kv_cache_spec.kv_cache_specs[layer_name] - key = (attn_backend.full_cls_name(), layer_kv_cache_spec) + # Split on per-rank num_heads_q so layers with different Q-head + # counts (e.g. a spec-decode draft head and its target) get separate + # metadata builders. + num_heads_q = getattr(attn_layers[layer_name], "num_heads", 0) + key = (attn_backend.full_cls_name(), layer_kv_cache_spec, num_heads_q) if key not in group_map: group_map[key] = AttentionGroup( attn_backend, [layer_name], layer_kv_cache_spec, kv_cache_group_id @@ -151,8 +173,17 @@ def _allocate_kv_cache( kv_cache_config: KVCacheConfig, shared_layers: dict[str, str], device: torch.device ): kv_cache_raw_tensors: dict[str, torch.Tensor] = {} + packed_backing: torch.Tensor | None = None for kv_cache_tensor in kv_cache_config.kv_cache_tensors: - tensor = torch.zeros(kv_cache_tensor.size, dtype=torch.int8, device=device) + if kv_cache_tensor.block_stride > 0: + # Allocate once; all packed tensors alias the same backing. + if packed_backing is None: + packed_backing = torch.zeros( + kv_cache_tensor.size, dtype=torch.int8, device=device + ) + tensor = packed_backing + else: + tensor = torch.zeros(kv_cache_tensor.size, dtype=torch.int8, device=device) for layer_name in kv_cache_tensor.shared_by: kv_cache_raw_tensors[layer_name] = tensor @@ -166,16 +197,80 @@ def _allocate_kv_cache( return kv_cache_raw_tensors +def _reshape_attention_kv_cache( + kv_raw_tensor: torch.Tensor, + kv_cache_spec: AttentionSpec, + kv_cache_shape: tuple[int, ...], + kv_cache_stride_order: tuple[int, ...], + num_blocks: int, + packing: tuple[int, int] | None, +) -> torch.Tensor: + permuted_kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order) + inv_order = [ + kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order)) + ] + dtype = kv_cache_spec.dtype + + if packing is not None: + offset, block_stride = packing + assert inv_order[0] == 0 + page_bytes = prod(kv_cache_shape[1:]) * get_dtype_size(dtype) + kv_cache = ( + kv_raw_tensor.view(-1, block_stride)[:, offset : offset + page_bytes] + .view(dtype) + .view(kv_cache_shape) + ) + elif kv_cache_spec.page_size_padded is not None: + # Use a strided view to skip the padding between physical pages. + # + # Only num-blocks-first layouts are supported (the block dimension is + # dim 0 of the unpermuted shape). kv-first layouts such as ROCm's + # ``(2, num_blocks, ...)`` are intentionally not supported here. For a + # num-blocks-first layout the only stride that must change is the block + # stride: every other (contiguous) stride already steps within the + # unpadded region of a page, so no further adjustment is needed. + assert kv_cache_shape[0] == num_blocks, ( + "Padded KV pages require a num-blocks-first KV cache layout (got " + f"shape {kv_cache_shape} with num_blocks={num_blocks}); " + "kv-first layouts are not supported." + ) + dtype_size = get_dtype_size(kv_cache_spec.dtype) + page_stride = kv_cache_spec.page_size_bytes // dtype_size + + num_blocks_dim = inv_order[0] + strides = list(torch.empty(permuted_kv_cache_shape).stride()) + strides[num_blocks_dim] = page_stride + + kv_cache = torch.as_strided( + kv_raw_tensor.view(dtype), + size=permuted_kv_cache_shape, + stride=tuple(strides), + ) + else: + # No padding — safe to use a contiguous view. + kv_cache = kv_raw_tensor.view(dtype).view(permuted_kv_cache_shape) + + return kv_cache.permute(*inv_order) + + def _reshape_kv_cache( attn_groups: Sequence[AttentionGroup], kv_cache_raw_tensors: dict[str, torch.Tensor], cache_dtype: str, kernel_block_sizes: list[int], shared_kv_cache_layers: dict[str, str], + kv_cache_config: "KVCacheConfig | None" = None, ) -> dict[str, Any]: kv_caches: dict[str, Any] = {} has_attn, has_mamba = False, False + layer_packing: dict[str, tuple[int, int]] = {} + if kv_cache_config is not None: + for kv_tensor in kv_cache_config.kv_cache_tensors: + if kv_tensor.block_stride > 0: + for ln in kv_tensor.shared_by: + layer_packing[ln] = (kv_tensor.offset, kv_tensor.block_stride) + for group in attn_groups: if group.kv_cache_group_id >= len(kernel_block_sizes): continue @@ -194,8 +289,13 @@ def _reshape_kv_cache( continue kv_raw_tensor = kv_cache_raw_tensors[layer_name] - assert kv_raw_tensor.numel() % kv_cache_spec.page_size_bytes == 0 - num_blocks = kv_raw_tensor.numel() // kv_cache_spec.page_size_bytes + packing = layer_packing.get(layer_name) + if packing is not None: + _, blk_stride = packing + num_blocks = kv_raw_tensor.numel() // blk_stride + else: + assert kv_raw_tensor.numel() % kv_cache_spec.page_size_bytes == 0 + num_blocks = kv_raw_tensor.numel() // kv_cache_spec.page_size_bytes if isinstance(kv_cache_spec, AttentionSpec): has_attn = True @@ -206,12 +306,21 @@ def _reshape_kv_cache( kv_cache_spec.storage_block_size // kernel_block_size ) kernel_num_blocks = num_blocks * num_blocks_per_kv_block + # Skipped layers (--kv-cache-dtype-skip-layers) keep the + # unquantized shape; only the quantized primary uses the + # quantized cache dtype's (possibly packed) layout. + layer_cache_dtype = ( + "auto" + if kv_cache_spec.kv_quant_mode == KVQuantMode.NONE + and not isinstance(kv_cache_spec, TQFullAttentionSpec) + else cache_dtype + ) kv_cache_shape = group.backend.get_kv_cache_shape( kernel_num_blocks, kernel_block_size, kv_cache_spec.num_kv_heads, kv_cache_spec.head_size, - cache_dtype_str=cache_dtype, + cache_dtype_str=layer_cache_dtype, ) # FIXME(woosuk): Add kv_cache_stride_order to all attention backends. @@ -221,35 +330,14 @@ def _reshape_kv_cache( except (AttributeError, NotImplementedError): kv_cache_stride_order = tuple(range(len(kv_cache_shape))) - kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order) - inv_order = [ - kv_cache_stride_order.index(i) - for i in range(len(kv_cache_stride_order)) - ] - - dtype = kv_cache_spec.dtype - kv_tensor = kv_raw_tensor.view(dtype) - if kv_cache_spec.page_size_padded is not None: - # Use strided view to handle page_size_bytes that - # include padding. This follows the same pattern as - # MambaSpec handling in gpu_model_runner.py. - # NOTE: This assumes kv_cache_shape[0] == num_blocks - # (i.e. the first physical dimension is the block - # index), which holds for all current backends - # (MLA, FlashAttention, TritonAttention, etc.). - dtype_size = get_dtype_size(dtype) - page_stride = kv_cache_spec.page_size_bytes // dtype_size - strides = list(torch.empty(kv_cache_shape).stride()) - strides[inv_order[0]] = page_stride - kv_cache = torch.as_strided( - kv_tensor, - size=kv_cache_shape, - stride=tuple(strides), - ) - else: - # No padding — safe to use a contiguous view. - kv_cache = kv_tensor.view(kv_cache_shape) - kv_caches[layer_name] = kv_cache.permute(*inv_order) + kv_caches[layer_name] = _reshape_attention_kv_cache( + kv_raw_tensor, + kv_cache_spec, + kv_cache_shape, + kv_cache_stride_order, + kernel_num_blocks, + packing, + ) elif isinstance(kv_cache_spec, MambaSpec): has_mamba = True @@ -283,6 +371,14 @@ def _reshape_kv_cache( kernel_block_sizes=kernel_block_sizes, cache_dtype=cache_dtype, ) + elif has_attn and kv_cache_config is not None: + _align_mixed_attention_kv_cache_views( + attn_groups=attn_groups, + kv_caches=kv_caches, + kernel_block_sizes=kernel_block_sizes, + cache_dtype=cache_dtype, + kv_cache_config=kv_cache_config, + ) # Map any sharing layers to their target layer's KV cache. for layer_name, target_layer_name in shared_kv_cache_layers.items(): @@ -291,6 +387,77 @@ def _reshape_kv_cache( return kv_caches +def _align_mixed_attention_kv_cache_views( + attn_groups: Iterable[AttentionGroup], + kv_caches: dict[str, Any], + kernel_block_sizes: list[int], + cache_dtype: str, + kv_cache_config: KVCacheConfig, +) -> None: + """Align shared attention KV views when backends disagree on layout. + + Encoder-decoder models can share one raw allocation between decoder + self-attention (K/V-first ROCM_ATTN, block dim 1) and cross-attention + (blocks-first backends, block dim 0). Keep the physical storage in the + K/V-first layout expected by ROCM_ATTN, and restride the blocks-first + logical views so block IDs address the same bytes. + """ + block_dims_by_layer: dict[str, int] = {} + for group in attn_groups: + kv_cache_spec = group.kv_cache_spec + if not isinstance(kv_cache_spec, AttentionSpec): + continue + if group.kv_cache_group_id >= len(kernel_block_sizes): + continue + block_dim = group.backend.get_kv_cache_block_dim( + kernel_block_sizes[group.kv_cache_group_id], + kv_cache_spec.num_kv_heads, + kv_cache_spec.head_size, + cache_dtype_str=cache_dtype, + ) + for layer_name in group.layer_names: + if layer_name in kv_caches: + block_dims_by_layer[layer_name] = block_dim + + for kv_tensor in kv_cache_config.kv_cache_tensors: + if kv_tensor.block_stride > 0: + continue + shared_block_dims = { + block_dims_by_layer[layer_name] + for layer_name in kv_tensor.shared_by + if layer_name in block_dims_by_layer + } + if 0 not in shared_block_dims or 1 not in shared_block_dims: + continue + + for layer_name in kv_tensor.shared_by: + if block_dims_by_layer.get(layer_name) == 0: + _restride_blocks_first_kv_cache_to_kv_first_storage( + kv_caches[layer_name] + ) + + +def _restride_blocks_first_kv_cache_to_kv_first_storage( + kv_cache: torch.Tensor, +) -> None: + assert kv_cache.ndim >= 3 + assert kv_cache.shape[1] == 2 + page_size = kv_cache.shape[2:].numel() + num_blocks = kv_cache.shape[0] + expected_tail_stride = torch.empty(kv_cache.shape[2:]).stride() + if kv_cache.stride()[2:] != expected_tail_stride: + logger.warning_once( + "Skipping mixed KV-cache layout alignment for a non-NHD " + "blocks-first attention view with stride %s.", + kv_cache.stride(), + ) + return + kv_cache.as_strided_( + size=kv_cache.shape, + stride=(page_size, num_blocks * page_size, *expected_tail_stride), + ) + + def _update_hybrid_attention_layout( attn_groups: Iterable[AttentionGroup], kv_caches: dict[str, Any], @@ -304,11 +471,21 @@ def _update_hybrid_attention_layout( kv_cache_spec = group.kv_cache_spec if not isinstance(kv_cache_spec, AttentionSpec): continue + # Mirror the per-layer dtype selection used when building the shape + # above. The block-dim index is dtype-independent for current backends + # (quantization only changes the last dim), so this is a no-op today, + # but it keeps both call sites consistent for skip layers. + layer_cache_dtype = ( + "auto" + if kv_cache_spec.kv_quant_mode == KVQuantMode.NONE + and not isinstance(kv_cache_spec, TQFullAttentionSpec) + else cache_dtype + ) block_dim = group.backend.get_kv_cache_block_dim( kernel_block_sizes[group.kv_cache_group_id], kv_cache_spec.num_kv_heads, kv_cache_spec.head_size, - cache_dtype_str=cache_dtype, + cache_dtype_str=layer_cache_dtype, ) # if the first dim of the kvcache's layout is already num_blocks, continue if block_dim == 0: @@ -361,6 +538,7 @@ def init_kv_cache( kernel_block_sizes=kernel_block_sizes, cache_dtype=cache_dtype, shared_kv_cache_layers=shared_kv_cache_layers, + kv_cache_config=kv_cache_config, ) bind_kv_cache(kv_caches, forward_context, runner_kv_caches) return kv_caches @@ -392,8 +570,11 @@ def build_attn_metadata( seq_lens_cpu_upper_bound: torch.Tensor | None = None, dcp_local_seq_lens: torch.Tensor | None = None, positions: torch.Tensor | None = None, + mm_req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None, model_specific_attn_metadata: ModelSpecificAttnMetadata | None = None, for_cudagraph_capture: bool = False, + causal: bool | Mapping[int, bool] = True, + rswa_prefix_lens: torch.Tensor | None = None, ) -> dict[str, Any]: seq_lens = seq_lens[:num_reqs] if dcp_local_seq_lens is not None: @@ -406,6 +587,8 @@ def build_attn_metadata( for i in range(num_kv_cache_groups): block_table = block_tables[i] slot_mapping = slot_mappings[i] + # Per-group causal for hybrid drafters (mixed SWA/full attention). + group_causal = causal if isinstance(causal, bool) else causal.get(i, True) common_attn_metadata_extra_kwargs = ( model_specific_attn_metadata.get_extra_common_attn_kwargs(i, num_reqs) @@ -423,9 +606,11 @@ def build_attn_metadata( max_query_len=max_query_len, block_table_tensor=block_table, slot_mapping=slot_mapping, - causal=True, + causal=group_causal, dcp_local_seq_lens=dcp_local_seq_lens, positions=positions, + mm_req_doc_ranges=mm_req_doc_ranges, + rswa_prefix_lens=rswa_prefix_lens, **common_attn_metadata_extra_kwargs, ) @@ -452,3 +637,27 @@ def build_attn_metadata( for layer_name in attn_group.layer_names: attn_metadata[layer_name] = metadata return attn_metadata + + +def compute_mm_prefix_ranges( + req_ids: list[str], + mm_features: dict[str, list[MultiModalFeatureSpec]], + sliding_window: int | None = None, +) -> dict[int, list[tuple[int, int]]]: + """Compute PrefixLM bidirectional ranges for multimodal tokens. + + Ranges exceeding sliding_window are skipped to prevent early tokens + from attending across the entire image span. + """ + req_doc_ranges: dict[int, list[tuple[int, int]]] = {} + for req_idx, req_id in enumerate(req_ids): + image_doc_ranges = [] + for mm_feature in mm_features.get(req_id, ()): + if mm_feature.modality not in ("image", "video"): + continue + for r in mm_feature.mm_position.extract_embeds_range(): + if sliding_window is not None and (r[1] - r[0] + 1) > sliding_window: + continue + image_doc_ranges.append(r) + req_doc_ranges[req_idx] = image_doc_ranges + return req_doc_ranges diff --git a/vllm/v1/worker/gpu/block_table.py b/vllm/v1/worker/gpu/block_table.py index 41692f58e7ee..8d41ba5a36ab 100644 --- a/vllm/v1/worker/gpu/block_table.py +++ b/vllm/v1/worker/gpu/block_table.py @@ -6,7 +6,12 @@ from vllm.triton_utils import tl, triton from vllm.v1.attention.backends.utils import PAD_SLOT_ID -from vllm.v1.worker.gpu.buffer_utils import StagedWriteTensor, UvaBackedTensor +from vllm.v1.worker.gpu.buffer_utils import ( + FusedStagedWriter, + StagedWriteTensor, + UvaBackedTensor, + _load_ptr, +) class BlockTables: @@ -52,6 +57,12 @@ def __init__( (self.num_kv_cache_groups, self.max_num_reqs), dtype=torch.int32, ) + self.fused_writer: FusedStagedWriter | None = None + if self.num_kv_cache_groups > 1: + # Only the multi-group path uses the fused writer. + self.fused_writer = FusedStagedWriter( + self.device, self.num_kv_cache_groups * self.max_num_reqs + ) # Block tables used for model's forward pass. # num_kv_cache_groups x [max_num_reqs, max_num_blocks] @@ -109,10 +120,15 @@ def append_block_ids( self.num_blocks.np[i, req_index] = start + len(block_ids) def apply_staged_writes(self) -> None: - # TODO(woosuk): This can be inefficient since it launches one kernel per - # block table. Implement a kernel to handle all block tables at once. - for block_table in self.block_tables: - block_table.apply_write() + if self.num_kv_cache_groups == 1: + # Single group: write directly, skipping the per-write group lookup. + self.block_tables[0].apply_write() + else: + # Multiple groups: apply all block tables with one fused kernel. + assert self.fused_writer is not None + self.fused_writer.apply( + self.block_tables, self.block_table_ptrs, self.block_table_strides + ) self.num_blocks.copy_to_uva() def gather_block_tables( @@ -283,10 +299,3 @@ def _compute_slot_mappings_kernel( slot_ids = tl.where(is_local, slot_ids, PAD_ID) tl.store(slot_mapping_ptr + offset, slot_ids, mask=offset < end_idx) - - -@triton.jit -def _load_ptr(ptr_to_ptr, elem_dtype): - ptr = tl.load(ptr_to_ptr) - ptr = tl.cast(ptr, tl.pointer_type(elem_dtype)) - return tl.multiple_of(ptr, 16) diff --git a/vllm/v1/worker/gpu/buffer_utils.py b/vllm/v1/worker/gpu/buffer_utils.py index e4497de43a77..f8336fa0749d 100644 --- a/vllm/v1/worker/gpu/buffer_utils.py +++ b/vllm/v1/worker/gpu/buffer_utils.py @@ -36,10 +36,9 @@ def async_copy_to_gpu( assert device is not None out = torch.empty_like(x, device=device) - # Copy directly to GPU — explicit pin_memory() causes sporadic stalls - # under high concurrency due to CUDA driver contention. The driver - # handles the transfer efficiently without manual pinning. - return out.copy_(x, non_blocking=True) + # pin_memory() is no-op if the memory is already pinned. + pinned = x.pin_memory() + return out.copy_(pinned, non_blocking=True) class UvaBuffer: @@ -183,7 +182,7 @@ def apply_write(self) -> None: # Special handling for write_contents write_contents = async_tensor_h2d( - self._staged_write_contents, self.dtype, self.device + self._staged_write_contents, device=self.device, dtype=self.dtype ) # Write diffs to the GPU buffer @@ -194,7 +193,9 @@ def apply_write(self) -> None: starts_uva, write_contents, cu_lens_uva, + None, BLOCK_SIZE=1024, + MULTI_GROUP=False, ) # Clear the staged writes self.clear_staged_writes() @@ -206,15 +207,81 @@ def clear_staged_writes(self) -> None: self._staged_write_cu_lens.clear() +class FusedStagedWriter: + """Applies the staged writes of several `StagedWriteTensor`s at once.""" + + def __init__( + self, device: torch.device, max_writes: int, max_concurrency: int | None = None + ): + new_pool = partial( + UvaBufferPool, dtype=torch.int32, max_concurrency=max_concurrency + ) + self.group_ids = new_pool(max_writes) + self.indices = new_pool(max_writes) + self.starts = new_pool(max_writes) + self.cu_lens = new_pool(max_writes) + self.device = device + + def apply( + self, + tensors: Sequence[StagedWriteTensor], + output_ptrs: torch.Tensor, + output_strides: torch.Tensor, + ) -> None: + """Apply and clear the staged writes of `tensors` with one kernel.""" + group_ids: list[int] = [] + indices: list[int] = [] + starts: list[int] = [] + contents: list[int | float] = [] + cu_lens: list[int] = [] + + for group_id, t in enumerate(tensors): + n = len(t._staged_write_indices) + if n == 0: + continue + + group_ids.extend([group_id] * n) + indices.extend(t._staged_write_indices) + starts.extend(t._staged_write_starts) + content_base = len(contents) + contents.extend(t._staged_write_contents) + cu_lens.extend(content_base + cu_len for cu_len in t._staged_write_cu_lens) + + if not group_ids: + return + + group_ids_uva = self.group_ids.copy_to_uva(group_ids) + indices_uva = self.indices.copy_to_uva(indices) + starts_uva = self.starts.copy_to_uva(starts) + cu_lens_uva = self.cu_lens.copy_to_uva(cu_lens) + contents_gpu = async_tensor_h2d(contents, device=self.device, dtype=torch.int32) + + _apply_write_kernel[(len(group_ids),)]( + output_ptrs, + output_strides, + indices_uva, + starts_uva, + contents_gpu, + cu_lens_uva, + group_ids_uva, + BLOCK_SIZE=1024, + MULTI_GROUP=True, + ) + for t in tensors: + t.clear_staged_writes() + + @triton.jit def _apply_write_kernel( - output_ptr, - output_stride, + output_ptr, # MULTI_GROUP: ptr-to-ptrs [num_groups]; else: data ptr + output_stride, # MULTI_GROUP: ptr-to-strides [num_groups]; else: row stride write_indices_ptr, write_starts_ptr, write_contents_ptr, write_cu_lens_ptr, + write_group_ids_ptr, # [num_writes], used only when MULTI_GROUP BLOCK_SIZE: tl.constexpr, + MULTI_GROUP: tl.constexpr, ): pid = tl.program_id(0) row_idx = tl.load(write_indices_ptr + pid) @@ -224,10 +291,26 @@ def _apply_write_kernel( cu_end = tl.load(write_cu_lens_ptr + pid) content_len = cu_end - cu_start + if MULTI_GROUP: + # Each write targets a different output tensor (KV cache group); + # resolve its base pointer and row stride per write. + group_id = tl.load(write_group_ids_ptr + pid) + row_ptr = _load_ptr(output_ptr + group_id, tl.int32) + row_stride = tl.load(output_stride + group_id) + else: + row_ptr = output_ptr + row_stride = output_stride + row_ptr += row_idx * row_stride + start_idx + for i in range(0, content_len, BLOCK_SIZE): block = i + tl.arange(0, BLOCK_SIZE) mask = block < content_len content = tl.load(write_contents_ptr + cu_start + block, mask=mask) - tl.store( - output_ptr + row_idx * output_stride + start_idx + block, content, mask=mask - ) + tl.store(row_ptr + block, content, mask=mask) + + +@triton.jit +def _load_ptr(ptr_to_ptr, elem_dtype): + ptr = tl.load(ptr_to_ptr) + ptr = tl.cast(ptr, tl.pointer_type(elem_dtype)) + return tl.multiple_of(ptr, 16) diff --git a/vllm/v1/worker/gpu/cudagraph_utils.py b/vllm/v1/worker/gpu/cudagraph_utils.py index dff6047ecb2c..9b93d17035b1 100644 --- a/vllm/v1/worker/gpu/cudagraph_utils.py +++ b/vllm/v1/worker/gpu/cudagraph_utils.py @@ -3,6 +3,7 @@ from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass +from itertools import product from typing import Any, NamedTuple, Protocol import torch @@ -26,6 +27,7 @@ from vllm.model_executor.offloader.base import get_offloader from vllm.platforms import current_platform from vllm.sequence import IntermediateTensors +from vllm.utils.math_utils import round_up from vllm.v1.kv_cache_interface import KVCacheConfig from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer from vllm.v1.worker.gpu.block_table import BlockTables @@ -56,6 +58,7 @@ class BatchExecutionDescriptor: num_tokens: int num_reqs: int | None # None means no request padding is needed (PIECEWISE graphs) uniform_token_count: int | None = None + num_active_loras: int = 0 class CreateForwardFn(Protocol): @@ -75,6 +78,7 @@ def _is_compatible( num_reqs: int, num_tokens: int, uniform_token_count: int | None, + num_active_loras: int, ) -> bool: # desc.uniform_token_count=None (PIECEWISE) can handle any uniform_token_count # desc.num_reqs=None means no request padding needed (PIECEWISE) @@ -85,6 +89,7 @@ def _is_compatible( ) and (desc.num_reqs is None or desc.num_reqs >= num_reqs) and desc.num_tokens >= num_tokens + and desc.num_active_loras == num_active_loras ) @@ -111,6 +116,7 @@ def __init__( device: torch.device, cudagraph_mode: CUDAGraphMode, decode_query_len: int, + lora_capture_cases: list[int] | None = None, ): self.vllm_config = vllm_config self.device = device @@ -124,17 +130,19 @@ def __init__( self.tp_size = vllm_config.parallel_config.tensor_parallel_size self.is_first_pp_rank = get_pp_group().is_first_rank self.is_last_pp_rank = get_pp_group().is_last_rank + self.lora_capture_cases = lora_capture_cases or [0] + # Precompute actual num_active_loras -> captured case mapping so that + # dispatch() is a plain dict lookup instead of a per-call bisect. + self._lora_dispatch_map, self._max_lora_case = self._build_lora_dispatch_map() self.graphs: dict[BatchExecutionDescriptor, torch.cuda.CUDAGraph] = {} self.pool = current_platform.get_global_graph_pool() if cudagraph_mode else None self._graphs_captured = False - self._candidates: list[list[BatchExecutionDescriptor]] = [] + + self._candidates: dict[tuple[int, int], list[BatchExecutionDescriptor]] = {} self._capture_descs: dict[CUDAGraphMode, list[BatchExecutionDescriptor]] = {} - # adjust the cudagraph sizes to be a multiple of the uniform decode query length - self.compilation_config.adjust_cudagraph_sizes_for_spec_decode( - self.decode_query_len, self.tp_size - ) + self._init_candidates() # Breakable CUDA graph (PW CUDA graph without torch.compile) @@ -144,6 +152,32 @@ def __init__( ) self.breakable_cg_runner: BreakableCUDAGraphWrapper | None = None + def _build_lora_dispatch_map(self) -> tuple[dict[int, int], int]: + """Precompute actual num_active_loras -> effective captured case. + + Mirrors the num_tokens candidate expansion in ``_init_candidates``: + every possible active-LoRA count is mapped ahead of time to the + smallest captured case that can serve it, so ``dispatch`` is a plain + dict lookup instead of a per-call bisect. + """ + captured_with_lora = sorted(c for c in self.lora_capture_cases if c > 0) + if not captured_with_lora: + return {}, 0 + dispatch_map: dict[int, int] = {} + case_idx = 0 + for n in range(1, captured_with_lora[-1] + 1): + while captured_with_lora[case_idx] < n: + case_idx += 1 + dispatch_map[n] = captured_with_lora[case_idx] + return dispatch_map, captured_with_lora[-1] + + def _resolve_effective_loras(self, num_active_loras: int) -> int: + """Map an actual active-LoRA count to its captured graph case.""" + if num_active_loras <= 0 or not self._lora_dispatch_map: + return num_active_loras + # Counts above the largest captured case clamp to it. + return self._lora_dispatch_map.get(num_active_loras, self._max_lora_case) + def _init_candidates(self) -> None: """Build priority-ordered candidate lists for each token count.""" capture_sizes = self.compilation_config.cudagraph_capture_sizes @@ -155,26 +189,72 @@ def _init_candidates(self) -> None: decode_mode = self.cudagraph_mode.decode_mode() mixed_mode = self.cudagraph_mode.mixed_mode() separate_decode_routine = self.cudagraph_mode.separate_routine() + max_cg_capture_size = self.compilation_config.max_cudagraph_capture_size - descs_by_token_count = defaultdict(list) - descs_by_mode = defaultdict(list) + descs_by_token_lora: dict[tuple[int, int], list[BatchExecutionDescriptor]] = ( + defaultdict(list) + ) + descs_by_mode: defaultdict[CUDAGraphMode, list[BatchExecutionDescriptor]] = ( + defaultdict(list) + ) - for num_tokens in capture_sizes: + # When using Dynamic SD, num_speculative_tokens is the max number of + # draft tokens. The scheduler might use a smaller number so we need + # to capture graphs for all possible values during decode. + speculative_config = self.vllm_config.speculative_config + if ( + speculative_config + and speculative_config.uses_dynamic_speculative_decoding() + ): + num_spec_per_batch_size = ( + speculative_config.num_speculative_tokens_per_batch_size + ) + # uses_dynamic_speculative_decoding() guarantees this is set. + assert num_spec_per_batch_size is not None + # decode_query_len = num_speculative_steps + num_new_sampled_tokens + # _per_step. Recover num_new_sampled_tokens_per_step + # from the values the manager already has. + num_new_sampled_tokens_per_step = ( + self.decode_query_len - self.vllm_config.num_speculative_tokens + ) + # Each entry is (range_start, range_end, num_speculative_tokens). + decode_query_lens = [ + x[2] + num_new_sampled_tokens_per_step for x in num_spec_per_batch_size + ] + else: + decode_query_lens = [self.decode_query_len] + + for num_tokens, num_active_loras in product( + capture_sizes, self.lora_capture_cases + ): # Capture uniform decode specfifc graphs if required # (i.e. separate decode routine) - if ( - separate_decode_routine - and decode_mode - and self.decode_query_len <= num_tokens <= max_decode_tokens - ): - desc = BatchExecutionDescriptor( - cg_mode=decode_mode, - num_tokens=num_tokens, - num_reqs=num_tokens // self.decode_query_len, - uniform_token_count=self.decode_query_len, - ) - descs_by_mode[decode_mode].append(desc) - descs_by_token_count[num_tokens].append(desc) + if separate_decode_routine and decode_mode: + for decode_query_len in decode_query_lens: + rounded_num_tokens = round_up(num_tokens, decode_query_len) + rounded_num_reqs = rounded_num_tokens // decode_query_len + + if ( + rounded_num_tokens > max_decode_tokens + or rounded_num_tokens > max_cg_capture_size + or rounded_num_reqs > self.max_num_reqs + ): + continue + + desc = BatchExecutionDescriptor( + cg_mode=decode_mode, + num_tokens=rounded_num_tokens, + num_reqs=rounded_num_reqs, + uniform_token_count=decode_query_len, + num_active_loras=num_active_loras, + ) + + # avoid duplicate graphs + if desc not in descs_by_mode[decode_mode]: + descs_by_mode[decode_mode].append(desc) + descs_by_token_lora[ + (rounded_num_tokens, num_active_loras) + ].append(desc) if mixed_mode: # for PIECEWISE graphs there is no limit on requests when replaying @@ -189,21 +269,25 @@ def _init_candidates(self) -> None: cg_mode=mixed_mode, num_tokens=num_tokens, num_reqs=num_reqs, + num_active_loras=num_active_loras, ) descs_by_mode[mixed_mode].append(desc) - descs_by_token_count[num_tokens].append(desc) + descs_by_token_lora[(num_tokens, num_active_loras)].append(desc) - if not descs_by_token_count: + if not descs_by_token_lora: return - sorted_padded = sorted(descs_by_token_count.keys()) - self._candidates = [[] for _ in range(sorted_padded[-1] + 1)] - + all_token_counts = sorted({k[0] for k in descs_by_token_lora}) current_range_start = 0 - for cg_size in sorted_padded: - for i in range(current_range_start, cg_size + 1): - self._candidates[i] = descs_by_token_count[cg_size] - current_range_start = cg_size + 1 + for token_cg_size in all_token_counts: + for i in range(current_range_start, token_cg_size + 1): + for num_active_loras in self.lora_capture_cases: + staging_key = (token_cg_size, num_active_loras) + if staging_key in descs_by_token_lora: + self._candidates[(i, num_active_loras)] = descs_by_token_lora[ + staging_key + ] + current_range_start = token_cg_size + 1 for mode, descs in descs_by_mode.items(): descs.sort(key=lambda d: d.num_tokens, reverse=True) @@ -289,14 +373,27 @@ def dispatch( num_reqs: int, num_tokens: int, uniform_token_count: int | None, + num_active_loras: int, ) -> BatchExecutionDescriptor: """Find matching cudagraph descriptor from priority-ordered candidates.""" - if self._graphs_captured and 0 < num_tokens < len(self._candidates): - for desc in self._candidates[num_tokens]: - if _is_compatible(desc, num_reqs, num_tokens, uniform_token_count): + + effective_loras = self._resolve_effective_loras(num_active_loras) + key = (num_tokens, effective_loras) + if self._graphs_captured and num_tokens > 0 and key in self._candidates: + for desc in self._candidates[key]: + if _is_compatible( + desc, + num_reqs, + num_tokens, + uniform_token_count, + effective_loras, + ): return desc return BatchExecutionDescriptor( - cg_mode=CUDAGraphMode.NONE, num_tokens=num_tokens, num_reqs=num_reqs + cg_mode=CUDAGraphMode.NONE, + num_tokens=num_tokens, + num_reqs=num_reqs, + num_active_loras=effective_loras, ) def run_fullgraph(self, desc: BatchExecutionDescriptor): @@ -337,9 +434,15 @@ def __init__( device: torch.device, cudagraph_mode: CUDAGraphMode, decode_query_len: int, + lora_capture_cases: list[int] | None = None, ): - super().__init__(vllm_config, device, cudagraph_mode, decode_query_len) - # Used for FULL CUDA graphs. PW CUDA graphs do not use these. + super().__init__( + vllm_config, + device, + cudagraph_mode, + decode_query_len, + lora_capture_cases=lora_capture_cases, + ) self.hidden_states: torch.Tensor | None = None self.aux_hidden_states: list[torch.Tensor] = [] self.use_aux_hidden_state_outputs = False @@ -356,6 +459,7 @@ def capture( kv_cache_config: KVCacheConfig, has_lora: bool = False, use_aux_hidden_state_outputs: bool = False, + lora_capture_hook: Callable[[int, int, int], None] | None = None, progress_bar_desc: str = "Capturing CUDA graphs", ) -> dict[BatchExecutionDescriptor, AttentionStatePair]: """Capture CUDA graphs for model forward pass.""" @@ -372,6 +476,11 @@ def create_forward_fn( ]: num_tokens = desc.num_tokens num_reqs = desc.num_reqs or min(num_tokens, self.max_num_reqs) + + # Set LoRA state before capture so kernels see correct adapters. + if lora_capture_hook is not None: + lora_capture_hook(desc.num_active_loras, num_reqs, num_tokens) + num_tokens_across_dp = ( torch.full((self.dp_size,), num_tokens, dtype=torch.int32, device="cpu") if self.dp_size > 1 @@ -401,12 +510,17 @@ def create_forward_fn( skip_attn=(desc.cg_mode == CUDAGraphMode.PIECEWISE), ) + # Capture with dummy rows marked as padding. + input_buffers.is_padding.fill_(True) + def forward_fn(cg_mode: CUDAGraphMode) -> None: batch_descriptor = None if cg_mode == CUDAGraphMode.PIECEWISE: assert attn_metadata is None batch_descriptor = BatchDescriptor( - num_tokens=num_tokens, has_lora=has_lora + num_tokens=num_tokens, + has_lora=has_lora, + num_active_loras=desc.num_active_loras, ) with set_forward_context( attn_metadata, @@ -416,6 +530,7 @@ def forward_fn(cg_mode: CUDAGraphMode) -> None: num_tokens_across_dp=num_tokens_across_dp, slot_mapping=slot_mappings, batch_descriptor=batch_descriptor, + is_padding=input_buffers.is_padding[:num_tokens], ): if cg_mode == CUDAGraphMode.PIECEWISE: # PIECEWISE graph (compiled PW or breakable, chosen inside diff --git a/vllm/v1/worker/gpu/dp_utils.py b/vllm/v1/worker/gpu/dp_utils.py index b3c172738c3a..ee9b924ba13a 100644 --- a/vllm/v1/worker/gpu/dp_utils.py +++ b/vllm/v1/worker/gpu/dp_utils.py @@ -21,6 +21,7 @@ def sync_cudagraph_and_dp_padding( uniform_token_count: int | None, dp_size: int, dp_rank: int, + num_active_loras: int = 0, ) -> tuple[BatchExecutionDescriptor, torch.Tensor | None]: """ Coordinates the batch descriptor and DP padding across all ranks. @@ -53,6 +54,7 @@ def sync_cudagraph_and_dp_padding( cg_mode=CUDAGraphMode.NONE, num_tokens=num_tokens, num_reqs=num_reqs, + num_active_loras=desired_batch_desc.num_active_loras, ), num_tokens_across_dp assert cudagraph_manager is not None, ( @@ -68,9 +70,13 @@ def sync_cudagraph_and_dp_padding( synced_uniform_token_count = None # Dispatch for the final synced values, use num_reqs instead of synced_num_reqs - # so we don't perform request padding for PIECEWISE graphs + # so we don't perform request padding for PIECEWISE graphs. + # num_active_loras is per-rank and doesn't need cross-rank agreement. synced_desc = cudagraph_manager.dispatch( - num_reqs, synced_num_tokens, synced_uniform_token_count + num_reqs, + synced_num_tokens, + synced_uniform_token_count, + num_active_loras=num_active_loras, ) # Update num_tokens_across_dp to reflect padded size. @@ -87,12 +93,14 @@ def dispatch_cg_and_sync_dp( dp_size: int, dp_rank: int, need_eager: bool = False, + num_active_loras: int = 0, ) -> tuple[BatchExecutionDescriptor, torch.Tensor | None]: if need_eager: batch_desc = BatchExecutionDescriptor( cg_mode=CUDAGraphMode.NONE, num_tokens=num_tokens, num_reqs=num_reqs, + num_active_loras=num_active_loras, ) else: assert cudagraph_manager is not None, ( @@ -100,7 +108,10 @@ def dispatch_cg_and_sync_dp( "where need_eager must be True" ) batch_desc = cudagraph_manager.dispatch( - num_reqs, num_tokens, uniform_token_count + num_reqs, + num_tokens, + uniform_token_count, + num_active_loras=num_active_loras, ) if dp_size == 1: @@ -114,4 +125,5 @@ def dispatch_cg_and_sync_dp( uniform_token_count, dp_size, dp_rank, + num_active_loras=num_active_loras, ) diff --git a/vllm/v1/worker/gpu/eplb_utils.py b/vllm/v1/worker/gpu/eplb_utils.py index 8f04ce3577c5..aea6fdeff83e 100644 --- a/vllm/v1/worker/gpu/eplb_utils.py +++ b/vllm/v1/worker/gpu/eplb_utils.py @@ -8,6 +8,7 @@ import torch import torch.nn as nn +from vllm.config import ModelConfig from vllm.distributed.eplb.eplb_state import EplbState from vllm.logger import init_logger from vllm.model_executor.models.interfaces import ( @@ -90,6 +91,7 @@ def maybe_register_speculator( draft_model, speculative_config.draft_model_config, ) + speculator.set_eplb_state(self.state) self._has_registered_models = True return True @@ -135,6 +137,16 @@ def step( log_stats=self.parallel_config.eplb_config.log_balancedness, ) + def prepare_forward( + self, + model_config: ModelConfig, + num_unpadded_tokens: int, + ubatch_slices: list | None = None, + ) -> None: + if self.state is None or not self.parallel_config.enable_eplb: + return + self.state.prepare_forward(model_config, num_unpadded_tokens, ubatch_slices) + def setup_from_mapping( self, model: nn.Module, diff --git a/vllm/v1/worker/gpu/input_batch.py b/vllm/v1/worker/gpu/input_batch.py index f905d09e45f9..64c1096dfbec 100644 --- a/vllm/v1/worker/gpu/input_batch.py +++ b/vllm/v1/worker/gpu/input_batch.py @@ -22,6 +22,7 @@ def __init__( self.input_ids = torch.zeros(max_num_tokens, dtype=torch.int32, device=device) self.positions = torch.zeros(max_num_tokens, dtype=torch.int64, device=device) + self.is_padding = torch.zeros(max_num_tokens, dtype=torch.bool, device=device) self.query_start_loc = torch.zeros( max_num_reqs + 1, dtype=torch.int32, device=device ) @@ -83,6 +84,8 @@ class InputBatch: input_ids: torch.Tensor # [num_tokens_after_padding] positions: torch.Tensor + # [num_tokens_after_padding] + is_padding: torch.Tensor # [total_num_logits] logits_indices: torch.Tensor @@ -93,6 +96,9 @@ class InputBatch: # Whether any requests in batch use structured output. has_structured_output_reqs: bool + # [num_reqs] per-request prompt length, only populated for R-SWA. + prompt_lens: torch.Tensor | None + @classmethod def make_dummy( cls, @@ -134,6 +140,9 @@ def make_dummy( input_ids = input_buffers.input_ids[:num_tokens].zero_() positions = input_buffers.positions[:num_tokens].zero_() + input_buffers.is_padding[:num_tokens].fill_(True) + is_padding = input_buffers.is_padding[:num_tokens] + logits_indices = query_start_loc[1:] - 1 cu_num_logits = torch.arange(num_reqs + 1, device=device, dtype=torch.int32) cu_num_logits_np = np.arange(num_reqs + 1, dtype=np.int32) @@ -164,10 +173,12 @@ def make_dummy( max_seq_len_np=None, input_ids=input_ids, positions=positions, + is_padding=is_padding, logits_indices=logits_indices, cu_num_logits=cu_num_logits, cu_num_logits_np=cu_num_logits_np, has_structured_output_reqs=False, + prompt_lens=None, ) @@ -302,6 +313,7 @@ def _combine_sampled_and_draft_tokens_kernel( cu_num_logits_ptr, logits_indices_ptr, BLOCK_SIZE: tl.constexpr, + NUM_NEW_SAMPLED_TOKENS: tl.constexpr = 1, ): batch_idx = tl.program_id(0) req_state_idx = tl.load(idx_mapping_ptr + batch_idx) @@ -310,7 +322,7 @@ def _combine_sampled_and_draft_tokens_kernel( cu_num_logits_start = tl.load(cu_num_logits_ptr + batch_idx) cu_num_logits_end = tl.load(cu_num_logits_ptr + batch_idx + 1) num_logits = cu_num_logits_end - cu_num_logits_start - num_draft_tokens = num_logits - 1 + num_draft_tokens = num_logits - NUM_NEW_SAMPLED_TOKENS # Compute the logits indices. block = tl.arange(0, BLOCK_SIZE) @@ -328,9 +340,12 @@ def _combine_sampled_and_draft_tokens_kernel( # Handling prefill tokens. No sampled or draft tokens. return - # Write the last sampled token ID to input_ids. - last_token_id = tl.load(last_sampled_tokens_ptr + req_state_idx) - tl.store(input_ids_ptr + query_end - num_logits, last_token_id) + # Keep prompt-tail slots intact; only rewrite generated-token slots. + first_logit_seq_pos = seq_len - num_logits + if NUM_NEW_SAMPLED_TOKENS > 0 and first_logit_seq_pos >= prefill_len: + # Write the last sampled token ID to input_ids. + last_token_id = tl.load(last_sampled_tokens_ptr + req_state_idx) + tl.store(input_ids_ptr + logits_start, last_token_id) # Write the draft tokens (if any) to input_ids. if num_draft_tokens > 0: @@ -356,7 +371,11 @@ def combine_sampled_and_draft_tokens( draft_tokens: torch.Tensor, cu_num_logits: torch.Tensor, num_logits: int, + num_new_sampled_tokens: int = 1, # excl accepted draft tokens, a.k.a bonus tokens ) -> torch.Tensor: + assert num_new_sampled_tokens in (0, 1), ( + f"num_new_sampled_tokens must be 0 or 1, got {num_new_sampled_tokens}" + ) # use idx_mapping.shape[0] for actual request count num_reqs = idx_mapping.shape[0] num_speculative_steps = draft_tokens.shape[-1] @@ -377,9 +396,12 @@ def combine_sampled_and_draft_tokens( draft_tokens.stride(0), cu_num_logits, logits_indices, - # NOTE(woosuk): Add 1 to ensure the block can cover the last sampled token - # in addition to all draft tokens. - BLOCK_SIZE=triton.next_power_of_2(num_speculative_steps + 1), + NUM_NEW_SAMPLED_TOKENS=num_new_sampled_tokens, + # NOTE(woosuk): Add num_new_sampled_tokens to ensure the block covers the + # last sampled token in addition to all draft tokens. + BLOCK_SIZE=triton.next_power_of_2( + num_speculative_steps + num_new_sampled_tokens + ), ) return logits_indices diff --git a/vllm/v1/worker/gpu/lora_utils.py b/vllm/v1/worker/gpu/lora_utils.py index bbbfeffbb66d..fa281f6817ba 100644 --- a/vllm/v1/worker/gpu/lora_utils.py +++ b/vllm/v1/worker/gpu/lora_utils.py @@ -1,12 +1,74 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""LoRA utilities for the Model Runner V2 and cudagraph.""" + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + import numpy as np from vllm.lora.request import LoRARequest +from vllm.lora.utils import get_captured_lora_counts + +if TYPE_CHECKING: + from vllm.config.compilation import CompilationConfig + from vllm.config.lora import LoRAConfig NO_LORA_ID = 0 +def get_lora_capture_cases( + lora_config: "LoRAConfig | None", + compilation_config: "CompilationConfig", +) -> list[int]: + """ + Return num_active_loras values for cudagraph capture. + + When cudagraph_specialize_lora=True: powers of 2 up to max_loras, plus + max_loras+1. When False: [0, max_loras+1]. When LoRA disabled: [0]. + """ + if lora_config is None: + return [0] + if compilation_config.cudagraph_specialize_lora: + specialize = getattr(lora_config, "specialize_active_lora", False) + captured = get_captured_lora_counts(lora_config.max_loras, specialize) + return [0] + [c for c in captured if c > 0] + return [0, lora_config.max_loras + 1] + + +def get_num_active_loras_for_dispatch( + lora_config: "LoRAConfig | None", + lora_state: "LoraState", + req_ids: list[str], + dummy_run: bool, +) -> int: + """Compute num_active_loras for cudagraph dispatch.""" + if lora_config and not dummy_run: + return len(lora_state.get_activate_loras(req_ids)) + if dummy_run and lora_config: + return lora_config.max_loras + 1 + return 0 + + +def create_lora_capture_hook( + lora_config: "LoRAConfig | None", + runner: Any, +) -> Callable[[int, int, int], None] | None: + """Create a hook to set up LoRA state before each cudagraph capture.""" + if lora_config is None: + return None + + def hook(num_active_loras: int, num_reqs: int, num_tokens: int) -> None: + num_scheduled = np.full(num_reqs, num_tokens // num_reqs, dtype=np.int32) + num_scheduled[-1] += num_tokens % num_reqs + with runner.maybe_select_dummy_loras( + lora_config, num_scheduled, num_active_loras=num_active_loras + ): + pass + + return hook + + class LoraState: def __init__(self, max_num_reqs: int): self.lora_ids = np.zeros(max_num_reqs, dtype=np.int32) @@ -35,10 +97,13 @@ def make_lora_inputs( lora_ids = self.lora_ids[idx_mapping] prompt_lora_mapping = tuple(lora_ids) token_lora_mapping = tuple(lora_ids.repeat(num_scheduled_tokens)) + active_lora_requests: set[LoRARequest] = self.get_activate_loras(req_ids) + return prompt_lora_mapping, token_lora_mapping, active_lora_requests + def get_activate_loras(self, req_ids: list[str]) -> set[LoRARequest]: active_lora_requests: set[LoRARequest] = set() for req_id in req_ids: lora_request = self.lora_requests.get(req_id) if lora_request is not None: active_lora_requests.add(lora_request) - return prompt_lora_mapping, token_lora_mapping, active_lora_requests + return active_lora_requests diff --git a/vllm/v1/worker/gpu/mm/encoder_cache.py b/vllm/v1/worker/gpu/mm/encoder_cache.py index 1fcbe6429943..065df2975c49 100644 --- a/vllm/v1/worker/gpu/mm/encoder_cache.py +++ b/vllm/v1/worker/gpu/mm/encoder_cache.py @@ -12,6 +12,9 @@ def __init__(self): # MM hash -> encoder outputs self.encoder_outputs: dict[str, torch.Tensor] = {} + def __len__(self) -> int: + return len(self.encoder_outputs) + def add_request( self, req_id: str, mm_features: list[MultiModalFeatureSpec] ) -> None: diff --git a/vllm/v1/worker/gpu/mm/encoder_runner.py b/vllm/v1/worker/gpu/mm/encoder_runner.py index 1000dbe05a80..48a3af25053f 100644 --- a/vllm/v1/worker/gpu/mm/encoder_runner.py +++ b/vllm/v1/worker/gpu/mm/encoder_runner.py @@ -3,9 +3,9 @@ import numpy as np import torch -from vllm.model_executor.models.interfaces import SupportsMultiModal +from vllm.model_executor.models.interfaces import SupportsMultiModal, supports_realtime from vllm.multimodal.inputs import MultiModalKwargsItem -from vllm.multimodal.utils import group_and_batch_mm_kwargs +from vllm.multimodal.utils import get_mm_features_in_window, group_and_batch_mm_kwargs from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache from vllm.v1.worker.utils import sanity_check_mm_encoder_outputs @@ -26,6 +26,7 @@ def __init__( self.encoder_cache = encoder_cache self.dtype = dtype self.device = device + self.is_realtime = supports_realtime(model) self.inputs_embeds = torch.zeros( max_num_tokens, hidden_size, dtype=dtype, device=device @@ -49,12 +50,11 @@ def prepare_mm_inputs( @torch.inference_mode() def execute_mm_encoder( - self, - mm_kwargs: list[tuple[str, MultiModalKwargsItem]], + self, mm_kwargs: list[tuple[str, MultiModalKwargsItem]] ) -> list[torch.Tensor]: encoder_outputs: list[torch.Tensor] = [] for modality, num_items, mm_kwargs_batch in group_and_batch_mm_kwargs( - mm_kwargs, device=self.device, pin_memory=False + mm_kwargs, device=self.device, pin_memory=True ): batch_outputs = self.model.embed_multimodal(**mm_kwargs_batch) sanity_check_mm_encoder_outputs(batch_outputs, expected_num_items=num_items) @@ -68,44 +68,49 @@ def gather_mm_embeddings( num_scheduled_tokens: np.ndarray, query_start_loc: np.ndarray, prefill_lens: np.ndarray, - computed_prefill_lens: np.ndarray, + num_computed_tokens: np.ndarray, + draft_lookahead: int = 0, ) -> tuple[list[torch.Tensor], torch.Tensor]: - is_prefilling = (computed_prefill_lens < prefill_lens).tolist() - all_decode = not any(is_prefilling) - if all_decode: - # All decode requests, so no need to gather any embeddings. - return [], torch.zeros( - total_num_scheduled_tokens, dtype=torch.bool, device=self.device - ) - - query_start = computed_prefill_lens.tolist() - query_end = (computed_prefill_lens + num_scheduled_tokens).tolist() + if draft_lookahead: + num_computed_tokens = num_computed_tokens + draft_lookahead - mm_embeds: list[torch.Tensor] = [] is_mm_embed = torch.zeros( total_num_scheduled_tokens, dtype=torch.bool, device="cpu" ) + + # Whether to gather media embeddings this step. + exclude_embeddings: list[bool] | None = None + if not self.is_realtime: + # Non-realtime models only have media embeddings within the prompt. + is_decode = num_computed_tokens >= prefill_lens + if is_decode.all(): + # All decode requests, so no need to gather any embeddings. + return [], is_mm_embed + exclude_embeddings = is_decode.tolist() + + query_start = num_computed_tokens.tolist() + query_end = (num_computed_tokens + num_scheduled_tokens).tolist() + + mm_embeds: list[torch.Tensor] = [] for i, req_id in enumerate(req_ids): - if not is_prefilling[i]: - # OPTIMIZATION: Skip decode requests. + if exclude_embeddings is not None and exclude_embeddings[i]: continue + cur_query_start = query_start[i] + cur_query_end = query_end[i] + mm_features = self.encoder_cache.mm_features[req_id] - for mm_feature in mm_features: + lo, hi = get_mm_features_in_window( + mm_features, start=cur_query_start, end=cur_query_end + ) + for idx in range(lo, hi): + mm_feature = mm_features[idx] pos_info = mm_feature.mm_position start_pos = pos_info.offset num_encoder_tokens = pos_info.length - if start_pos >= query_end[i]: - # The encoder output is not needed in this step. - break - if start_pos + num_encoder_tokens <= query_start[i]: - # The encoder output is already processed and stored - # in the decoder's KV cache. - continue - - start_idx = max(query_start[i] - start_pos, 0) - end_idx = min(query_end[i] - start_pos, num_encoder_tokens) + start_idx = max(cur_query_start - start_pos, 0) + end_idx = min(cur_query_end - start_pos, num_encoder_tokens) assert start_idx < end_idx curr_embeds_start, curr_embeds_end = ( pos_info.get_embeds_indices_in_range(start_idx, end_idx) @@ -117,7 +122,13 @@ def gather_mm_embeddings( mm_hash = mm_feature.identifier encoder_output = self.encoder_cache.encoder_outputs.get(mm_hash, None) - assert encoder_output is not None, f"Encoder cache miss for {mm_hash}." + if encoder_output is None: + # A feature starting at/after the processed boundary is only + # reached via the drafter's +1 look-ahead and might not be + # encoded yet; fall back to the token embedding for drafting. + if start_pos + draft_lookahead >= cur_query_end: + continue + raise RuntimeError(f"Encoder cache miss for {mm_hash}.") if (is_embed := pos_info.is_embed) is not None: is_embed = is_embed[start_idx:end_idx] @@ -125,8 +136,8 @@ def gather_mm_embeddings( else: mm_embeds_item = encoder_output[start_idx:end_idx] - req_start_pos = query_start_loc[i] + start_pos - query_start[i] - is_mm_embed[req_start_pos + start_idx : req_start_pos + end_idx] = ( + req_start_pos = query_start_loc[i] + start_pos - cur_query_start + is_mm_embed[req_start_pos + start_idx : req_start_pos + end_idx] |= ( True if is_embed is None else is_embed ) mm_embeds.append(mm_embeds_item) diff --git a/vllm/v1/worker/gpu/mm/lora.py b/vllm/v1/worker/gpu/mm/lora.py new file mode 100644 index 000000000000..492914a74b6c --- /dev/null +++ b/vllm/v1/worker/gpu/mm/lora.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from typing import Any + +import numpy as np + +from vllm.lora.layers import LoRAMapping, LoRAMappingType +from vllm.lora.worker_manager import WorkerLoRAManager +from vllm.v1.worker.gpu.lora_utils import LoraState +from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache + + +def set_active_mm_loras( + model: Any, + lora_manager: WorkerLoRAManager, + encoder_cache: EncoderCache | None, + req_id_to_index: dict[str, int], + lora_state: LoraState, + scheduled_encoder_inputs: dict[str, list[int]], +) -> None: + if ( + not scheduled_encoder_inputs + or encoder_cache is None + or not lora_manager.supports_tower_connector_lora() + ): + return + + prompt_lora_mapping: list[int] = [] + token_lora_mapping: list[int] = [] + lora_requests = set() + encoder_token_counts: list[int] = [] + + # iterate through images + for req_id, encoder_input_ids in scheduled_encoder_inputs.items(): + req_idx = req_id_to_index.get(req_id) + if req_idx is None: + continue + + lora_id = int(lora_state.lora_ids[req_idx]) + mm_features = encoder_cache.mm_features[req_id] + + # iterate through visual tokens + for mm_input_id in encoder_input_ids: + pos_info = mm_features[mm_input_id].mm_position + num_tokens = model.get_num_mm_encoder_tokens(pos_info.get_num_embeds()) + prompt_lora_mapping.append(lora_id) + token_lora_mapping.extend([lora_id] * num_tokens) + encoder_token_counts.append(num_tokens) + + if lora_id > 0: + lora_request = lora_state.lora_requests.get(req_id) + if lora_request is not None: + lora_requests.add(lora_request) + + if not prompt_lora_mapping: + return + + lora_manager.set_active_adapters( + lora_requests, + LoRAMapping( + tuple(token_lora_mapping), + tuple(prompt_lora_mapping), + is_prefill=True, + type=LoRAMappingType.TOWER, + ), + ) + + mm_mapping = model.get_mm_mapping() if hasattr(model, "get_mm_mapping") else None + if ( + mm_mapping is None + or not mm_mapping.connector + or not hasattr(model, "get_num_mm_connector_tokens") + ): + return + + connector_token_mapping = np.repeat( + np.array(prompt_lora_mapping, dtype=np.int32), + np.array( + [ + model.get_num_mm_connector_tokens(num_tokens) + for num_tokens in encoder_token_counts + ], + dtype=np.int32, + ), + ) + lora_manager.set_active_adapters( + lora_requests, + LoRAMapping( + index_mapping=tuple(connector_token_mapping.tolist()), + prompt_mapping=tuple(prompt_lora_mapping), + is_prefill=True, + type=LoRAMappingType.CONNECTOR, + ), + ) diff --git a/vllm/v1/worker/gpu/mm/rope.py b/vllm/v1/worker/gpu/mm/rope.py index 712f58af578f..e5de28223473 100644 --- a/vllm/v1/worker/gpu/mm/rope.py +++ b/vllm/v1/worker/gpu/mm/rope.py @@ -90,6 +90,23 @@ def apply_staged_writes(self) -> None: def get_positions(self, num_tokens: int) -> torch.Tensor: return self.positions[:, :num_tokens] + def read_prefill_positions(self, req_idx: int, length: int) -> torch.Tensor: + """Return staged per-request prefill positions as [num_dims, length].""" + base = self.num_dims * req_idx + return self.prefill_positions.gpu[base : base + self.num_dims, :length] + + def update_prefill_positions( + self, req_idx: int, positions: torch.Tensor, delta: int + ) -> None: + """Overwrite a request's staged prefill positions with recomputed values.""" + base = self.num_dims * req_idx + length = positions.shape[1] + self.prefill_positions.gpu[base : base + self.num_dims, :length].copy_( + positions + ) + if self.has_delta: + self.prefill_delta.np[req_idx] = delta + def prepare_positions( self, idx_mapping: torch.Tensor, diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py index 367147b0b4dd..1f5dd4d2fba2 100644 --- a/vllm/v1/worker/gpu/model_runner.py +++ b/vllm/v1/worker/gpu/model_runner.py @@ -27,6 +27,7 @@ import torch import torch.nn as nn +import vllm.envs as envs from vllm.compilation.counter import compilation_counter from vllm.config import VllmConfig from vllm.config.compilation import CUDAGraphMode @@ -37,7 +38,6 @@ ) from vllm.forward_context import BatchDescriptor, set_forward_context from vllm.logger import init_logger -from vllm.lora.layers import LoRAMapping from vllm.model_executor.layers.mamba.ops.ssu_dispatch import ( initialize_mamba_ssu_backend, ) @@ -47,8 +47,7 @@ from vllm.tasks import SupportedTask from vllm.utils.math_utils import cdiv from vllm.utils.mem_utils import DeviceMemoryProfiler, format_gib -from vllm.utils.platform_utils import is_pin_memory_available -from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE +from vllm.utils.torch_utils import PIN_MEMORY, STR_DTYPE_TO_TORCH_DTYPE from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput from vllm.v1.kv_cache_interface import KVCacheConfig, MambaSpec from vllm.v1.outputs import DraftTokenIds, ModelRunnerOutput @@ -78,7 +77,6 @@ InputBuffers, combine_sampled_and_draft_tokens, expand_idx_mapping, - get_num_sampled_and_rejected, post_update, post_update_num_computed_tokens, prepare_pos_seq_lens, @@ -89,8 +87,14 @@ KVConnector, get_kv_connector, ) -from vllm.v1.worker.gpu.lora_utils import LoraState +from vllm.v1.worker.gpu.lora_utils import ( + LoraState, + create_lora_capture_hook, + get_lora_capture_cases, + get_num_active_loras_for_dispatch, +) from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache +from vllm.v1.worker.gpu.mm.lora import set_active_mm_loras from vllm.v1.worker.gpu.model_states import init_model_state from vllm.v1.worker.gpu.pool.pooling_runner import PoolingRunner from vllm.v1.worker.gpu.pp_utils import PPHandler @@ -145,7 +149,6 @@ def __init__(self, vllm_config: VllmConfig, device: torch.device): self.max_num_reqs = self.scheduler_config.max_num_seqs self.is_encoder_decoder = self.model_config.is_encoder_decoder - self.use_async_scheduling = self.scheduler_config.async_scheduling self.output_copy_stream = torch.cuda.Stream(self.device) # Pipeline parallelism. @@ -184,23 +187,23 @@ def __init__(self, vllm_config: VllmConfig, device: torch.device): # Speculative decoding. self.speculator = None - self.num_speculative_steps = 0 self.use_aux_hidden_state_outputs = False + self.num_speculative_steps = vllm_config.num_speculative_tokens if self.speculative_config is not None: - self.num_speculative_steps = self.speculative_config.num_speculative_tokens - if self.is_last_pp_rank: self.speculator = init_speculator(self.vllm_config, self.device) - if self.speculative_config.method == "eagle3": - # EAGLE3 may require auxiliary hidden states from target model outputs. + if self.speculative_config.method in ("eagle3", "dflash", "dspark"): + # Drafting may require auxiliary hidden states from target model outputs self.use_aux_hidden_state_outputs = True if self.use_pp: - raise ValueError("EAGLE3 with pipeline parallel is not supported.") + raise ValueError( + f"{self.speculative_config.method} with pipeline parallel " + "is not supported." + ) # Draft tokens propagation - for spec-dec + struct outputs. self.draft_tokens_handler = DraftTokensHandler(self.device) - self.uniform_decode_query_len = 1 + self.num_speculative_steps # Pooling models. self.is_pooling_model = self.model_config.runner_type == "pooling" @@ -220,7 +223,6 @@ def __init__(self, vllm_config: VllmConfig, device: torch.device): max_num_tokens=self.max_num_tokens, device=self.device, ) - if self.use_pp: self.pp_handler = PPHandler( max_num_reqs=self.max_num_reqs, @@ -228,41 +230,22 @@ def __init__(self, vllm_config: VllmConfig, device: torch.device): device=self.device, ) + # Samplers and decode_query_len created in load_model() after + # model_state exists (num_new_sampled_tokens_per_step from ModelState). self.sampler: Sampler | None = None self.rejection_sampler: RejectionSampler | None = None self.prompt_logprobs_worker: PromptLogprobsWorker | None = None self.structured_outputs_worker: StructuredOutputsWorker | None = None - if self.is_last_pp_rank and not self.is_pooling_model: - # Initialize sampling-related workers. - # These components are only set up on the last PP rank and - # for generative (non-pooling) models. - self.sampler = Sampler( - max_num_reqs=self.max_num_reqs, - vocab_size=self.vocab_size, - device=self.device, - req_states=self.req_states, - logprobs_mode=self.model_config.logprobs_mode, - num_speculative_tokens=self.num_speculative_steps + 1, - use_fp64_gumbel=self.model_config.use_fp64_gumbel, - ) - if self.speculative_config is not None: - self.rejection_sampler = RejectionSampler( - self.sampler, - self.speculative_config, - self.device, - ) - self.prompt_logprobs_worker = PromptLogprobsWorker(self.max_num_reqs) - self.structured_outputs_worker = StructuredOutputsWorker( - max_num_logits=self.max_num_reqs * (self.num_speculative_steps + 1), - vocab_size=self.vocab_size, - device=self.device, - ) - - # For CUDA graphs, and will init cudagraph_manager after init_attn_backend. - self.decode_query_len = self.num_speculative_steps + 1 self.cudagraph_manager: ModelCudaGraphManager | None = None + # LoRA-related workers. self.lora_state = LoraState(max_num_reqs=self.max_num_reqs) + self.lora_capture_cases = [0] + if self.lora_config: + self.lora_capture_cases = get_lora_capture_cases( + self.lora_config, self.compilation_config + ) + # KV Connector if configured. self.kv_connector: KVConnector = NO_OP_KV_CONNECTOR @@ -331,6 +314,40 @@ def load_model(self, load_dummy_weights: bool = False, *args, **kwargs) -> None: self.model_state = init_model_state( self.vllm_config, self.model, self.encoder_cache, self.device ) + + self.decode_query_len = ( + self.num_speculative_steps + + self.model_state.num_new_sampled_tokens_per_step + ) + + # Initialize samplers. Model states may override via custom_sampler(). + if self.is_last_pp_rank and not self.is_pooling_model: + self.sampler = Sampler( + max_num_reqs=self.max_num_reqs, + vocab_size=self.vocab_size, + device=self.device, + req_states=self.req_states, + logprobs_mode=self.model_config.logprobs_mode, + num_speculative_tokens=self.decode_query_len, + use_fp64_gumbel=self.model_config.use_fp64_gumbel, + ) + custom = self.model_state.custom_sampler(self.sampler) + + if custom: + self.sampler, self.rejection_sampler = custom + elif self.speculative_config is not None: + self.rejection_sampler = RejectionSampler( + self.sampler, + self.speculative_config, + self.device, + ) + self.prompt_logprobs_worker = PromptLogprobsWorker(self.max_num_reqs) + self.structured_outputs_worker = StructuredOutputsWorker( + max_num_logits=self.max_num_reqs * self.decode_query_len, + vocab_size=self.vocab_size, + device=self.device, + ) + if self.is_pooling_model and self.is_last_pp_rank: self.pooling_runner = PoolingRunner(self.model) eplb_models_added |= self.eplb.maybe_register_model( @@ -359,14 +376,6 @@ def reload_weights(self, *args, **kwargs) -> None: from vllm.v1.worker.gpu_model_runner import GPUModelRunner as GPUModelRunnerV1 GPUModelRunnerV1.reload_weights(self, *args, **kwargs) # type: ignore[arg-type] - self.reset_encoder_cache() - self.reset_mm_cache() - - def apply_sparse_weight_patches(self, *args, **kwargs) -> None: - # TODO: Use full version instead of import when fully migrated to v2 - from vllm.v1.worker.gpu_model_runner import GPUModelRunner as GPUModelRunnerV1 - - GPUModelRunnerV1.apply_sparse_weight_patches(self, *args, **kwargs) # type: ignore[arg-type] def update_config(self, *args, **kwargs) -> None: # TODO(Wentao): Use full version instead of import when fully migrated to v2 @@ -393,10 +402,11 @@ def initialize_kv_cache(self, kv_cache_config: KVCacheConfig) -> None: block_table_max_model_len = self.max_model_len if self.is_encoder_decoder: - # Cross-attention block tables need to index encoder tokens - # (e.g., Whisper ~1500), which can exceed decoder max_model_len. + # Cross-attention block tables need to index encoder tokens, which + # can exceed the decoder's max_model_len. block_table_max_model_len = max( block_table_max_model_len, + self.scheduler_config.max_num_encoder_input_tokens, getattr(self.model_config.hf_config, "max_source_positions", 0), ) @@ -443,16 +453,18 @@ def initialize_kv_cache(self, kv_cache_config: KVCacheConfig) -> None: cudagraph_mode = self.compilation_config.resolve_cudagraph_mode_and_sizes( attn_cg_support.min_cg_support, attn_cg_support.min_cg_attn_backend, - self.uniform_decode_query_len, - self.parallel_config.tensor_parallel_size, - self.kv_cache_config, - self.max_num_reqs, + self.decode_query_len, + use_v2_model_runner=True, + tensor_parallel_size=self.parallel_config.tensor_parallel_size, + kv_cache_config=self.kv_cache_config, + max_num_reqs=self.max_num_reqs, ) self.cudagraph_manager = ModelCudaGraphManager( self.vllm_config, self.device, cudagraph_mode, decode_query_len=self.decode_query_len, + lora_capture_cases=self.lora_capture_cases, ) if self.speculator is not None: self.speculator.init_cudagraph_manager(cudagraph_mode) @@ -481,11 +493,12 @@ def _init_kv_zero_meta(self) -> None: """Build KV-block zeroing metadata; invoked from gpu_worker.""" self.kv_block_zeroer = KVBlockZeroer( self.device, - is_pin_memory_available(), + pin_memory=PIN_MEMORY, attn_groups_iter=(g for groups in self.attn_groups for g in groups), kernel_block_sizes=self.kernel_block_sizes, cache_dtype=self.cache_config.cache_dtype, static_forward_context=self.compilation_config.static_forward_context, + max_concurrency=self.vllm_config.max_concurrent_batches, ) @torch.inference_mode() @@ -535,14 +548,22 @@ def _dummy_run( assert self.intermediate_tensors is not None intermediate_tensors = self.intermediate_tensors[:num_tokens] - # Execute the model. - self.execute_model( - dummy_scheduler_output, - intermediate_tensors=intermediate_tensors, - dummy_run=True, - skip_attn_for_dummy_run=skip_attn, - is_profile=is_profile, - ) + max_loras = self.lora_config.max_loras if self.lora_config is not None else 0 + with self.maybe_dummy_run_with_lora( + self.lora_config, + num_scheduled_tokens=np.array(num_tokens_per_request, dtype=np.int32), + num_sampled_tokens=None, + remove_lora=True, + num_active_loras=max_loras, + ): + # Execute the model. + self.execute_model( + dummy_scheduler_output, + intermediate_tensors=intermediate_tensors, + dummy_run=True, + skip_attn_for_dummy_run=skip_attn, + is_profile=is_profile, + ) self.kv_connector.set_disabled(False) # Non-last PP ranks don't produce output for sampling. @@ -676,7 +697,7 @@ def capture_model(self) -> int: start_time = time.perf_counter() gc.collect() torch.accelerator.empty_cache() - start_free_gpu_memory = torch.cuda.mem_get_info()[0] + start_free_gpu_memory = torch.accelerator.get_memory_info()[0] with self.maybe_setup_dummy_loras(self.lora_config): attn_states = self.cudagraph_manager.capture( @@ -689,12 +710,13 @@ def capture_model(self) -> int: self.kv_cache_config, has_lora=self.lora_config is not None, use_aux_hidden_state_outputs=self.use_aux_hidden_state_outputs, + lora_capture_hook=create_lora_capture_hook(self.lora_config, self), ) if self.speculator is not None: self.speculator.capture(attn_states) end_time = time.perf_counter() - end_free_gpu_memory = torch.cuda.mem_get_info()[0] + end_free_gpu_memory = torch.accelerator.get_memory_info()[0] elapsed_time = end_time - start_time cuda_graph_size = start_free_gpu_memory - end_free_gpu_memory # This usually takes 5~20 seconds. @@ -706,6 +728,9 @@ def capture_model(self) -> int: return cuda_graph_size def _remove_request(self, req_id: str) -> bool: + # Call model_state.remove_request *before* req_states.remove_request + # so the model_state can still look up the slot index. + self.model_state.remove_request(req_id) req_idx = self.req_states.remove_request(req_id) if req_idx is None: return False @@ -819,12 +844,18 @@ def prepare_inputs( num_tokens = scheduler_output.total_num_scheduled_tokens num_tokens_after_padding = batch_desc.num_tokens assert num_tokens > 0 + if envs.VLLM_MOE_SKIP_PADDING: + # Mark trailing cudagraph-padding rows so kernels can skip work for + # them when supported. + self.input_buffers.is_padding[:num_tokens].fill_(False) + self.input_buffers.is_padding[num_tokens:num_tokens_after_padding].fill_( + True + ) num_tokens_per_req = scheduler_output.num_scheduled_tokens num_reqs = len(num_tokens_per_req) - # Decode first, then prefill. # batch_idx -> req_id - req_ids = sorted(num_tokens_per_req, key=num_tokens_per_req.get) # type: ignore[arg-type] + req_ids = sort_batch_req_ids(num_tokens_per_req, self.decode_query_len) numtoks_iter = map(num_tokens_per_req.get, req_ids) num_scheduled_tokens = np.fromiter(numtoks_iter, dtype=np.int32, count=num_reqs) @@ -853,16 +884,16 @@ def prepare_inputs( dtype=np.int32, count=num_reqs, ) + num_bonus_tokens = self.model_state.num_new_sampled_tokens_per_step total_num_draft_tokens = int(num_draft_tokens_per_req.sum()) - total_num_logits = num_reqs + total_num_draft_tokens - - num_logits = num_draft_tokens_per_req + 1 + total_num_logits = num_reqs * num_bonus_tokens + total_num_draft_tokens + num_logits = num_draft_tokens_per_req + num_bonus_tokens cu_num_logits_np = np.empty(num_reqs + 1, dtype=np.int32) cu_num_logits_np[0] = 0 np.cumsum(num_logits, out=cu_num_logits_np[1:]) cu_num_logits = async_copy_to_gpu(cu_num_logits_np, device=self.device) - max_expand_len = self.num_speculative_steps + 1 + max_expand_len = self.decode_query_len expanded_idx_mapping, expanded_local_pos = expand_idx_mapping( idx_mapping, total_num_logits, cu_num_logits, max_expand_len ) @@ -931,6 +962,7 @@ def prepare_inputs( self.req_states.draft_tokens, cu_num_logits, total_num_logits, + self.model_state.num_new_sampled_tokens_per_step, ) # CPU upper bound on seq_lens; padded entries left at zero. @@ -947,6 +979,12 @@ def prepare_inputs( if self.use_pp: # max_seq_len is only consumed by the PP `compute_need_sampled_mask` max_seq_len_np = self.req_states.max_seq_len[idx_mapping_np] + + prompt_lens = None + if self.model_config.rswa_window is not None: + # prompt_lens is only used in R-SWA case. + prompt_lens = self.req_states.prompt_len.gpu[idx_mapping] + return InputBatch( req_ids=req_ids, num_reqs=num_reqs, @@ -972,10 +1010,12 @@ def prepare_inputs( max_seq_len_np=max_seq_len_np, input_ids=self.input_buffers.input_ids[:num_tokens_after_padding], positions=self.input_buffers.positions[:num_tokens_after_padding], + is_padding=self.input_buffers.is_padding[:num_tokens_after_padding], logits_indices=logits_indices, cu_num_logits=cu_num_logits, cu_num_logits_np=cu_num_logits_np, has_structured_output_reqs=scheduler_output.has_structured_output_requests, + prompt_lens=prompt_lens, ) def prepare_attn( @@ -1023,8 +1063,7 @@ def sample( grammar_output.grammar_bitmask, ) - if input_batch.num_draft_tokens == 0: - # No draft tokens (common case). + if input_batch.num_draft_tokens == 0 or self.rejection_sampler is None: assert self.sampler is not None sampler_output = self.sampler(logits, input_batch) else: @@ -1038,16 +1077,7 @@ def sample( self.speculator.draft_logits, ) - # Get the number of sampled and rejected tokens. - # For chunked prefills, num_sampled and num_rejected are both 0. - num_sampled, num_rejected = get_num_sampled_and_rejected( - sampler_output.num_sampled, - input_batch.seq_lens, - input_batch.cu_num_logits, - input_batch.idx_mapping, - self.req_states.prefill_len.gpu, - ) - return sampler_output, num_sampled, num_rejected + return sampler_output, sampler_output.num_sampled, sampler_output.num_rejected def postprocess_sampled( self, @@ -1076,7 +1106,9 @@ def postprocess_sampled( self.req_states.total_len.gpu, ) - self.model_state.postprocess_state(idx_mapping, num_sampled) + self.model_state.postprocess_state( + idx_mapping, num_sampled, self.req_states.num_computed_tokens.gpu + ) @torch.inference_mode() def execute_model( @@ -1106,6 +1138,13 @@ def execute_model( max_query_len = max(scheduler_output.num_scheduled_tokens.values()) uniform_tok_count = get_uniform_token_count(num_reqs, num_toks, max_query_len) + num_active_loras = 0 + if self.lora_config: + req_ids = list(scheduler_output.num_scheduled_tokens.keys()) + num_active_loras = get_num_active_loras_for_dispatch( + self.lora_config, self.lora_state, req_ids, dummy_run + ) + skip_compiled = False if self.is_encoder_decoder and scheduler_output.scheduled_encoder_inputs: # Encoder-decoder models such as Whisper should run eager/non-compiled @@ -1121,6 +1160,7 @@ def execute_model( self.dp_size, self.dp_rank, need_eager=is_profile or skip_compiled, + num_active_loras=num_active_loras, ) if batch_desc.num_tokens == 0: @@ -1133,6 +1173,16 @@ def execute_model( # Prepare all the inputs and copy to the input buffers. input_batch = self.prepare_inputs(scheduler_output, batch_desc) block_tables, slot_mappings = self.prepare_attn(input_batch) + # Mamba "align" pre-copy: migrate recurrent state across block + # boundaries before the forward. Runs only on real batches, and + # before model_state.prepare_attn gathers num_accepted_tokens so the + # boundary reset is visible to the attention metadata. + self.model_state.preprocess_state( + input_batch, + block_tables, + self.kv_cache_config, + self.req_states.num_computed_tokens.gpu, + ) if self.lora_config: # Activate LoRA adapters. @@ -1158,31 +1208,6 @@ def execute_model( ) block_tables = None slot_mappings = None - if self.lora_config: - # program a no-LoRA mapping here so kernels early-exit instead of - # reading uninitialized metadata during dummy runs. - # FIXME: Replace this with LoRA warmup: - # https://github.com/vllm-project/vllm/pull/35536 - assert hasattr(self, "lora_manager") - adapter_manager = self.lora_manager._adapter_manager - adapter_manager.set_adapter_mapping( - LoRAMapping( - index_mapping=(0,) * input_batch.num_tokens_after_padding, - prompt_mapping=(0,) * input_batch.num_reqs, - is_prefill=True, - ) - ) - seen_wrappers: set[int] = set() - for punica_wrapper in adapter_manager.punica_wrapper_mapping.values(): - if id(punica_wrapper) in seen_wrappers: - continue - seen_wrappers.add(id(punica_wrapper)) - for kernel_meta in ( - punica_wrapper.token_mapping_meta, # type: ignore[attr-defined] - punica_wrapper.prompt_mapping_meta, # type: ignore[attr-defined] - ): - kernel_meta.no_lora_flag_cpu[0] = False - kernel_meta.num_active_loras_cpu[0] = 1 attn_metadata = None slot_mappings_by_layer = None @@ -1201,20 +1226,38 @@ def execute_model( self.kv_cache_config, ) + input_ids = input_batch.input_ids inputs_embeds = None if self.supports_mm_inputs and self.is_first_pp_rank: # Run MM encoder (if needed) and get multimodal embeddings. # Only first PP rank prepares multimodal embeddings. - # NOTE(woosuk): We must call get_mm_embeddings even during dummy runs - # to obtain inputs_embeds, because the compiled model expects this input. - inputs_embeds = self.model_state.get_mm_embeddings( - scheduler_output.scheduled_encoder_inputs, input_batch - ) + if dummy_run: + # Obtain mm embeddings of correct shape for compiled model. + inputs_embeds = self.model_state.dummy_inputs_embeds( + input_batch.num_tokens_after_padding + ) + else: + scheduled_encoder_inputs = scheduler_output.scheduled_encoder_inputs + if self.lora_config is not None: + set_active_mm_loras( + model=self.model, + lora_manager=self.lora_manager, + encoder_cache=self.encoder_cache, + req_id_to_index=self.req_states.req_id_to_index, + lora_state=self.lora_state, + scheduled_encoder_inputs=scheduled_encoder_inputs, + ) + inputs_embeds = self.model_state.get_mm_embeddings( + scheduled_encoder_inputs, input_batch, self.req_states + ) + if inputs_embeds is not None and not self.model.requires_raw_input_tokens: + input_ids = None model_inputs = { - "input_ids": input_batch.input_ids, + "input_ids": input_ids, "positions": input_batch.positions, "inputs_embeds": inputs_embeds, + "intermediate_tensors": None, # NOTE: Values returned by `prepare_inputs` will override the default # values above. **self.model_state.prepare_inputs(input_batch, self.req_states), @@ -1237,6 +1280,9 @@ def execute_model( model_inputs["intermediate_tensors"] = IntermediateTensors(new_tensors) del intermediate_tensors + # Update the EPLB meta. + self.eplb.prepare_forward(self.model_config, input_batch.num_tokens) + # Run model. if batch_desc.cg_mode == CUDAGraphMode.FULL: # Use explicit cudagraph replay for FULL mode. @@ -1250,6 +1296,7 @@ def execute_model( batch_descriptor = BatchDescriptor( num_tokens=input_batch.num_tokens_after_padding, has_lora=self.lora_config is not None, + num_active_loras=batch_desc.num_active_loras, ) with set_forward_context( @@ -1261,6 +1308,7 @@ def execute_model( batch_descriptor=batch_descriptor, slot_mapping=slot_mappings_by_layer, skip_compiled=skip_compiled, + is_padding=input_batch.is_padding, ): self.kv_connector.pre_forward(scheduler_output) if batch_desc.cg_mode == CUDAGraphMode.PIECEWISE: @@ -1387,14 +1435,9 @@ def sample_tokens( # Get cached multimodal embeddings for draft forward. # NOTE: This is done here because postprocess updates # num_computed_prefill_tokens. - mm_inputs = self.model_state.encoder_runner.gather_mm_embeddings( - input_batch.req_ids, - input_batch.num_tokens, - input_batch.num_scheduled_tokens, - input_batch.query_start_loc_np, - input_batch.prefill_len_np, - # +1 to consider the skew in eagle - input_batch.num_computed_prefill_tokens_np + 1, + # The EAGLE/MTP drafter reads one position ahead of the target. + mm_inputs = self.model_state.gather_mm_embeddings( + input_batch, draft_lookahead=1 ) # Postprocess results and update request states. @@ -1435,15 +1478,20 @@ def sample_tokens( mm_inputs=mm_inputs, ) self.req_states.draft_tokens[input_batch.idx_mapping] = draft_tokens - self.draft_tokens_handler.set_draft_tokens(input_batch, draft_tokens) + + if self.num_speculative_steps > 0: + # Spec-decode and diffusion LLMs both use draft tokens but the latter does + # not have a speculator (i.e. self.speculator is None) + self.draft_tokens_handler.set_draft_tokens( + input_batch, + self.req_states.draft_tokens[input_batch.idx_mapping], + ) # Post-step KV connector related operations. kv_connector_output = self.kv_connector.post_forward(finished_req_ids) model_runner_output.kv_connector_output = kv_connector_output - if self.use_async_scheduling: - return async_output - return async_output.get_output() + return async_output def take_draft_token_ids(self) -> DraftTokenIds | None: return self.draft_tokens_handler.get_draft_tokens() @@ -1487,9 +1535,7 @@ def pool(self) -> AsyncPoolingOutput | ModelRunnerOutput | None: ) self.postprocess_num_computed_tokens(input_batch) - if self.use_async_scheduling: - return async_output - return async_output.get_output() + return async_output def postprocess_num_computed_tokens(self, input_batch: InputBatch) -> None: # Update the number of computed tokens. @@ -1510,6 +1556,10 @@ def shutdown(self) -> None: if hasattr(self, "kv_cache_config"): del self.kv_cache_config free_before_shutdown(self.vllm_config) + if hasattr(self, "model_state"): + del self.model_state + if getattr(self, "speculator", None) is not None: + self.speculator = None if hasattr(self, "model"): del self.model @@ -1556,3 +1606,12 @@ class ExecuteModelState(NamedTuple): hidden_states: torch.Tensor | None aux_hidden_states: list[torch.Tensor] | None finished_req_ids: set[str] + + +def sort_batch_req_ids( + num_tokens_per_req: dict[str, int], decode_query_len: int +) -> list[str]: + # Order decode -> short_extend -> prefill; split_decodes_and_prefills + # relies on uniform decodes (query_len == decode_query_len) leading. + key = lambda r: ((num := num_tokens_per_req[r]) != decode_query_len, num) + return sorted(num_tokens_per_req, key=key) diff --git a/vllm/v1/worker/gpu/model_states/__init__.py b/vllm/v1/worker/gpu/model_states/__init__.py index 06b5a92c3952..dc52dc4ee57b 100644 --- a/vllm/v1/worker/gpu/model_states/__init__.py +++ b/vllm/v1/worker/gpu/model_states/__init__.py @@ -4,6 +4,7 @@ import torch.nn as nn from vllm.config import VllmConfig +from vllm.model_executor.layers.attention import CrossAttention from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache @@ -13,10 +14,18 @@ def init_model_state( encoder_cache: EncoderCache | None, device: torch.device, ): - if "WhisperForConditionalGeneration" in vllm_config.model_config.architectures: - from vllm.v1.worker.gpu.model_states.whisper import WhisperModelState - - return WhisperModelState(vllm_config, model, encoder_cache, device) + # Let the model provide its own ModelState if it defines one. + if hasattr(model, "get_model_state_cls"): + cls = model.get_model_state_cls() + return cls(vllm_config, model, encoder_cache, device) + + # Cross-attention encoder-decoder models (Whisper, CohereASR, NemotronParse, ...) + if any(isinstance(m, CrossAttention) for m in model.modules()): + from vllm.v1.worker.gpu.model_states.encoder_decoder import ( + EncoderDecoderModelState, + ) + + return EncoderDecoderModelState(vllm_config, model, encoder_cache, device) if vllm_config.model_config.is_hybrid: from vllm.v1.worker.gpu.model_states.mamba_hybrid import MambaHybridModelState diff --git a/vllm/v1/worker/gpu/model_states/default.py b/vllm/v1/worker/gpu/model_states/default.py index ee5d9384fa3c..854b71b69fcb 100644 --- a/vllm/v1/worker/gpu/model_states/default.py +++ b/vllm/v1/worker/gpu/model_states/default.py @@ -7,15 +7,17 @@ from vllm.config import VllmConfig from vllm.config.compilation import CUDAGraphMode -from vllm.tasks import GenerationTask from vllm.v1.core.sched.output import NewRequestData from vllm.v1.kv_cache_interface import KVCacheConfig -from vllm.v1.worker.gpu.attn_utils import build_attn_metadata +from vllm.v1.worker.gpu.attn_utils import ( + build_attn_metadata, + compute_mm_prefix_ranges, +) from vllm.v1.worker.gpu.input_batch import InputBatch from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache -from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner from vllm.v1.worker.gpu.mm.rope import get_rope_state from vllm.v1.worker.gpu.model_states.interface import ModelState +from vllm.v1.worker.gpu.model_states.mm_pruning import maybe_create_mm_pruner from vllm.v1.worker.gpu.states import RequestState from vllm.v1.worker.utils import AttentionGroup @@ -28,30 +30,7 @@ def __init__( encoder_cache: EncoderCache | None, device: torch.device, ): - self.vllm_config = vllm_config - self.model_config = vllm_config.model_config - self.scheduler_config = vllm_config.scheduler_config - self.model = model - self.device = device - - self.supports_mm_inputs = encoder_cache is not None - self.max_model_len = self.model_config.max_model_len - self.max_num_reqs = self.scheduler_config.max_num_seqs - self.max_num_tokens = self.scheduler_config.max_num_batched_tokens - self.inputs_embeds_size = self.model_config.get_inputs_embeds_size() - self.dtype = self.model_config.dtype - - if self.supports_mm_inputs: - assert encoder_cache is not None - self.encoder_cache = encoder_cache - self.encoder_runner = EncoderRunner( - model=self.model, - max_num_tokens=self.max_num_tokens, - hidden_size=self.inputs_embeds_size, - encoder_cache=encoder_cache, - dtype=self.dtype, - device=self.device, - ) + super().__init__(vllm_config, model, encoder_cache, device) self.rope_state = get_rope_state( self.model_config, @@ -62,27 +41,10 @@ def __init__( device=self.device, ) - def get_supported_generation_tasks(self) -> tuple[GenerationTask, ...]: - from vllm.model_executor.models.interfaces import ( - supports_realtime, - supports_transcription, + # Pruner is used for multimodal embedding pruning (EVS). + self.mm_pruner = maybe_create_mm_pruner( + self.model_config, model, self.rope_state, encoder_cache ) - from vllm.model_executor.models.interfaces_base import is_text_generation_model - - supported_tasks = list[GenerationTask]() - - if is_text_generation_model(self.model): - supported_tasks.append("generate") - - if supports_transcription(self.model): - if self.model.supports_transcription_only: - return ("transcription",) - supported_tasks.append("transcription") - - if supports_realtime(self.model): - supported_tasks.append("realtime") - - return tuple(supported_tasks) def add_request(self, req_index: int, new_req_data: NewRequestData) -> None: if self.rope_state is not None: @@ -98,10 +60,15 @@ def apply_staged_writes(self) -> None: if self.rope_state is not None: self.rope_state.apply_staged_writes() + def dummy_inputs_embeds(self, num_tokens: int) -> torch.Tensor: + """Pre-allocated inputs_embeds buffer for dummy runs (contents unused).""" + return self.encoder_runner.inputs_embeds[:num_tokens] + def get_mm_embeddings( self, scheduled_encoder_inputs: dict[str, list[int]], input_batch: InputBatch, + req_states: RequestState, ) -> torch.Tensor: mm_hashes, mm_kwargs = self.encoder_runner.prepare_mm_inputs( scheduled_encoder_inputs @@ -112,14 +79,13 @@ def get_mm_embeddings( # Cache the encoder outputs by mm_hash self.encoder_cache.encoder_outputs.update(zip(mm_hashes, encoder_outputs)) - mm_embeds, is_mm_embed = self.encoder_runner.gather_mm_embeddings( - input_batch.req_ids, - input_batch.num_tokens, - input_batch.num_scheduled_tokens, - input_batch.query_start_loc_np, - input_batch.prefill_len_np, - input_batch.num_computed_prefill_tokens_np, - ) + mm_embeds, is_mm_embed = super().gather_mm_embeddings(input_batch) + if self.mm_pruner is not None and mm_embeds: + # EVS: recompute mrope positions for pruned media. + mm_embeds = self.mm_pruner.recompute(mm_embeds, input_batch, req_states) + # We must flush the staged rope updates for prepare_inputs() to pick up. + self.apply_staged_writes() + # Use unpadded input_ids to match is_mm_embed size (num_tokens). # input_batch.input_ids may be padded for CUDA graphs. input_ids_unpadded = input_batch.input_ids[: input_batch.num_tokens] @@ -128,6 +94,17 @@ def get_mm_embeddings( ) return inputs_embeds[: input_batch.num_tokens_after_padding] + def gather_mm_embeddings( + self, input_batch: InputBatch, draft_lookahead: int = 0 + ) -> tuple[list[torch.Tensor], torch.Tensor]: + mm_embeds, is_mm_embed = super().gather_mm_embeddings( + input_batch, draft_lookahead + ) + if self.mm_pruner is not None: + # EVS: strip the appended mrope-position channels. + mm_embeds = self.mm_pruner.strip(mm_embeds) + return mm_embeds, is_mm_embed + def prepare_inputs( self, input_batch: InputBatch, req_states: RequestState ) -> dict[str, torch.Tensor | None]: @@ -178,6 +155,17 @@ def prepare_attn( max_seq_len = self.max_model_len else: max_seq_len = seq_lens_cpu_upper_bound[:num_reqs].max().item() + req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None + if ( + self.supports_mm_inputs + and self.encoder_cache is not None + and self.model_config.is_mm_prefix_lm + ): + req_doc_ranges = compute_mm_prefix_ranges( + req_ids=input_batch.req_ids, + mm_features=self.encoder_cache.mm_features, + sliding_window=self.model_config.get_sliding_window(), + ) attn_metadata = build_attn_metadata( attn_groups=attn_groups, num_reqs=num_reqs, @@ -193,6 +181,8 @@ def prepare_attn( seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound, dcp_local_seq_lens=input_batch.dcp_local_seq_lens, positions=input_batch.positions, + mm_req_doc_ranges=req_doc_ranges, for_cudagraph_capture=for_capture, + rswa_prefix_lens=input_batch.prompt_lens, ) return attn_metadata diff --git a/vllm/v1/worker/gpu/model_states/whisper.py b/vllm/v1/worker/gpu/model_states/encoder_decoder.py similarity index 76% rename from vllm/v1/worker/gpu/model_states/whisper.py rename to vllm/v1/worker/gpu/model_states/encoder_decoder.py index b38cdae9033d..f759c0b1e15d 100644 --- a/vllm/v1/worker/gpu/model_states/whisper.py +++ b/vllm/v1/worker/gpu/model_states/encoder_decoder.py @@ -13,7 +13,6 @@ from vllm.v1.worker.gpu.attn_utils import build_attn_metadata from vllm.v1.worker.gpu.input_batch import InputBatch from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache -from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner from vllm.v1.worker.gpu.model_states.interface import ( ModelSpecificAttnMetadata, ModelState, @@ -23,7 +22,7 @@ @dataclass -class WhisperAttnMetadata(ModelSpecificAttnMetadata): +class EncoderDecoderAttnMetadata(ModelSpecificAttnMetadata): encoder_seq_lens: dict[int, tuple[torch.Tensor, np.ndarray]] def get_extra_common_attn_kwargs( @@ -41,7 +40,11 @@ def get_extra_common_attn_kwargs( } -class WhisperModelState(ModelState): +class EncoderDecoderModelState(ModelState): + """ModelState for cross-attention encoder-decoder models + (Whisper, CohereASR, NemotronParse, FireRedLID, ...) + """ + def __init__( self, vllm_config: VllmConfig, @@ -49,25 +52,8 @@ def __init__( encoder_cache: EncoderCache | None, device: torch.device, ) -> None: - self.vllm_config = vllm_config - self.model_config = vllm_config.model_config - self.scheduler_config = vllm_config.scheduler_config - self.model = model - self.max_num_reqs = vllm_config.scheduler_config.max_num_seqs - self.max_num_tokens = self.scheduler_config.max_num_batched_tokens - self.max_model_len = self.model_config.max_model_len - self.device = device - assert encoder_cache is not None - self.encoder_cache = encoder_cache - self.encoder_runner = EncoderRunner( - model=self.model, - max_num_tokens=self.max_num_tokens, - hidden_size=self.model_config.get_inputs_embeds_size(), - encoder_cache=self.encoder_cache, - dtype=self.model_config.dtype, - device=self.device, - ) + super().__init__(vllm_config, model, encoder_cache, device) self.max_encoder_len = getattr( self.model_config.hf_config, @@ -80,11 +66,11 @@ def __init__( self.encoder_outputs: list[torch.Tensor] = [] - def get_supported_generation_tasks(self): - return ("transcription",) - def get_mm_embeddings( - self, scheduled_encoder_inputs: dict[str, list[int]], input_batch: InputBatch + self, + scheduled_encoder_inputs: dict[str, list[int]], + input_batch: InputBatch, + req_states: RequestState, ) -> None: # Ensure encoder inputs are ordered consistently with input_batch.req_ids. encoder_inputs: dict[str, list[int]] = {} @@ -94,11 +80,11 @@ def get_mm_embeddings( encoder_inputs[req_id] = req_encoder_inputs _, mm_kwargs = self.encoder_runner.prepare_mm_inputs(encoder_inputs) if mm_kwargs: - # Whisper consumes encoder outputs through `encoder_outputs`, not - # `inputs_embeds`. Single modality (audio) so execute_mm_encoder - # preserves request order; use its return value directly. - # No need to store in encoder_cache: cross-attention K/V are written - # to the KV cache on the first step; decode steps use the cache. + # Encoder-decoder models consume encoder outputs through the + # `encoder_outputs` forward kwarg, not `inputs_embeds`. Single modality + # so execute_mm_encoder preserves request order; use its return value + # directly. No need to store in encoder_cache: cross-attention K/V are + # written to the KV cache on the first step; decode steps use the cache. self.encoder_outputs = self.encoder_runner.execute_mm_encoder(mm_kwargs) else: # Decode steps: encoder K/V are in cross-attention KV cache. @@ -131,8 +117,10 @@ def prepare_attn( else: num_reqs = input_batch.num_reqs num_tokens = input_batch.num_tokens - whisper_attn_metadata = WhisperAttnMetadata( - self._get_encoder_seq_lens(input_batch.req_ids, attn_groups, for_capture) + enc_dec_attn_metadata = EncoderDecoderAttnMetadata( + self._get_encoder_seq_lens( + input_batch.req_ids, attn_groups, for_capture, num_reqs + ) ) query_start_loc_cpu = torch.from_numpy(input_batch.query_start_loc_np) @@ -156,8 +144,9 @@ def prepare_attn( kv_cache_config=kv_cache_config, seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound, dcp_local_seq_lens=input_batch.dcp_local_seq_lens, - model_specific_attn_metadata=whisper_attn_metadata, + model_specific_attn_metadata=enc_dec_attn_metadata, for_cudagraph_capture=for_capture, + rswa_prefix_lens=input_batch.prompt_lens, ) return attn_metadata @@ -166,9 +155,10 @@ def _get_encoder_seq_lens( req_ids: list[str], attn_groups: list[list[AttentionGroup]], for_capture: bool, + num_reqs: int, ) -> dict[int, tuple[torch.Tensor, np.ndarray]]: - num_reqs = len(req_ids) - encoder_seq_lens_np = np.zeros(num_reqs, dtype=np.int32) + encoder_seq_lens = torch.zeros(num_reqs, dtype=torch.int32, pin_memory=True) + encoder_seq_lens_np = encoder_seq_lens.numpy() if not for_capture: # During normal execution, use actual encoder lengths. for i, req_id in enumerate(req_ids): @@ -181,9 +171,7 @@ def _get_encoder_seq_lens( # is captured with the correct value for cross-attention. encoder_seq_lens_np[:] = self.max_encoder_len - self.encoder_seq_lens_gpu[:num_reqs].copy_( - torch.from_numpy(encoder_seq_lens_np), non_blocking=True - ) + self.encoder_seq_lens_gpu[:num_reqs].copy_(encoder_seq_lens, non_blocking=True) self.encoder_seq_lens_gpu[num_reqs:].fill_(0) encoder_seq_lens_gpu = self.encoder_seq_lens_gpu[:num_reqs] diff --git a/vllm/v1/worker/gpu/model_states/interface.py b/vllm/v1/worker/gpu/model_states/interface.py index 55bf8d473cce..df86efa4a795 100644 --- a/vllm/v1/worker/gpu/model_states/interface.py +++ b/vllm/v1/worker/gpu/model_states/interface.py @@ -13,6 +13,7 @@ from vllm.v1.kv_cache_interface import KVCacheConfig from vllm.v1.worker.gpu.input_batch import InputBatch from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache +from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner from vllm.v1.worker.gpu.states import RequestState from vllm.v1.worker.utils import AttentionGroup @@ -36,7 +37,6 @@ def get_extra_attn_kwargs( class ModelState(ABC): - @abstractmethod def __init__( self, vllm_config: VllmConfig, @@ -44,29 +44,104 @@ def __init__( encoder_cache: EncoderCache | None, device: torch.device, ) -> None: - raise NotImplementedError + self.vllm_config = vllm_config + self.model_config = vllm_config.model_config + self.scheduler_config = vllm_config.scheduler_config + self.model = model + self.device = device + + self.max_model_len = self.model_config.max_model_len + self.max_num_reqs = self.scheduler_config.max_num_seqs + self.max_num_tokens = self.scheduler_config.max_num_batched_tokens + self.inputs_embeds_size = self.model_config.get_inputs_embeds_size() + self.dtype = self.model_config.dtype + + self.supports_mm_inputs = encoder_cache is not None + if encoder_cache is not None: + self.encoder_cache = encoder_cache + self.encoder_runner = EncoderRunner( + model=self.model, + max_num_tokens=self.max_num_tokens, + hidden_size=self.inputs_embeds_size, + encoder_cache=encoder_cache, + dtype=self.dtype, + device=self.device, + ) - @abstractmethod def get_supported_generation_tasks(self) -> tuple[GenerationTask, ...]: - raise NotImplementedError + from vllm.model_executor.models.interfaces import ( + supports_realtime, + supports_transcription, + ) + from vllm.model_executor.models.interfaces_base import is_text_generation_model + + supported_tasks = list[GenerationTask]() + if is_text_generation_model(self.model): + supported_tasks.append("generate") + if supports_transcription(self.model): + if self.model.supports_transcription_only: + return ("transcription",) + supported_tasks.append("transcription") + if supports_realtime(self.model): + supported_tasks.append("realtime") + return tuple(supported_tasks) def add_request(self, req_index: int, new_req_data: NewRequestData) -> None: return None + def remove_request(self, req_id: str) -> None: + return None + def apply_staged_writes(self) -> None: return None + def preprocess_state( + self, + input_batch: InputBatch, + block_tables: tuple[torch.Tensor, ...], + kv_cache_config: KVCacheConfig, + num_computed_tokens: torch.Tensor, + ) -> None: + """Hook run on real batches before the forward pass (after block tables + are gathered). Used by mamba "align" prefix caching to pre-copy state + across block boundaries. No-op by default.""" + return None + def postprocess_state( - self, idx_mapping: torch.Tensor, num_sampled: torch.Tensor + self, + idx_mapping: torch.Tensor, + num_sampled: torch.Tensor, + num_computed_tokens: torch.Tensor | None = None, ) -> None: return None @abstractmethod def get_mm_embeddings( - self, scheduled_encoder_inputs: dict[str, list[int]], input_batch: InputBatch + self, + scheduled_encoder_inputs: dict[str, list[int]], + input_batch: InputBatch, + req_states: RequestState, ) -> torch.Tensor | None: raise NotImplementedError + def dummy_inputs_embeds(self, num_tokens: int) -> torch.Tensor | None: + """Pre-allocated inputs_embeds buffer for dummy runs (contents unused).""" + return None + + def gather_mm_embeddings( + self, input_batch: InputBatch, draft_lookahead: int = 0 + ) -> tuple[list[torch.Tensor], torch.Tensor]: + """Gather cached multimodal embeddings.""" + return self.encoder_runner.gather_mm_embeddings( + input_batch.req_ids, + input_batch.num_tokens, + input_batch.num_scheduled_tokens, + input_batch.query_start_loc_np, + input_batch.prefill_len_np, + input_batch.num_computed_tokens_np, + draft_lookahead=draft_lookahead, + ) + @abstractmethod def prepare_inputs( self, input_batch: InputBatch, req_states: RequestState @@ -89,3 +164,16 @@ def prepare_attn( for_capture: bool = False, ) -> dict[str, Any]: raise NotImplementedError + + def custom_sampler(self, sampler: Any) -> tuple[Any, Any] | None: + """Wrap or replace the default sampler. + + Called after model loading with the already-constructed base + ``Sampler``. Return ``None`` to keep the defaults, or + ``(sampler, rejection_sampler | None)`` to override. + """ + return None + + num_new_sampled_tokens_per_step: int = 1 + """New tokens sampled on each decode step + (excluding accepted draft tokens, a.k.a num bonus tokens).""" diff --git a/vllm/v1/worker/gpu/model_states/mamba_hybrid.py b/vllm/v1/worker/gpu/model_states/mamba_hybrid.py index ced97c4f277f..26439a985831 100644 --- a/vllm/v1/worker/gpu/model_states/mamba_hybrid.py +++ b/vllm/v1/worker/gpu/model_states/mamba_hybrid.py @@ -9,15 +9,25 @@ from vllm.config import VllmConfig from vllm.config.compilation import CUDAGraphMode +from vllm.model_executor.layers.mamba.mamba_utils import ( + get_conv_copy_spec, + is_conv_state_dim_first, +) from vllm.triton_utils import tl, triton from vllm.v1.attention.backends.gdn_attn import GDNAttentionMetadataBuilder from vllm.v1.attention.backends.mamba2_attn import Mamba2AttentionMetadataBuilder -from vllm.v1.kv_cache_interface import KVCacheConfig +from vllm.v1.core.sched.output import NewRequestData +from vllm.v1.kv_cache_interface import KVCacheConfig, MambaSpec +from vllm.v1.utils import CpuGpuBuffer from vllm.v1.worker.gpu.attn_utils import build_attn_metadata 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.model_states.interface import ModelSpecificAttnMetadata +from vllm.v1.worker.mamba_utils import ( + MambaSpecDecodeGPUContext, + preprocess_mamba_align_fused_kernel, +) from vllm.v1.worker.utils import AttentionGroup @@ -65,9 +75,142 @@ def __init__( device: torch.device, ) -> None: super().__init__(vllm_config, model, encoder_cache, device) + self.cache_config = vllm_config.cache_config self.num_accepted_tokens_gpu = torch.ones( self.max_num_reqs, dtype=torch.int32, device=self.device ) + # Pre-copy "align" prefix-cache state (V2). The migration of each + # request's mamba state across block boundaries runs as a fused GPU + # kernel reusing the postprocess copy machinery, so the per-step src + # columns and the running state_idx are kept GPU-resident. + self._align_mode = self.cache_config.mamba_cache_mode == "align" + if self._align_mode: + self._mamba_state_idx_gpu = torch.zeros( + self.max_num_reqs, dtype=torch.int32, device=self.device + ) + self._mamba_src_col_gpu = torch.full( + (self.max_num_reqs,), -1, dtype=torch.int32, device=self.device + ) + self._mamba_src_off_gpu = torch.zeros( + self.max_num_reqs, dtype=torch.int32, device=self.device + ) + self._mamba_ctx: MambaSpecDecodeGPUContext | None = None + self._mamba_group_ids: list[int] = [] + self._mamba_spec: MambaSpec | None = None + + def add_request(self, req_index: int, new_req_data: NewRequestData) -> None: + super().add_request(req_index, new_req_data) + if self._align_mode: + # Seed the running state block from the resumed/prefilled position. + self._mamba_state_idx_gpu[req_index] = ( + new_req_data.num_computed_tokens - 1 + ) // self.cache_config.block_size + self.num_accepted_tokens_gpu[req_index] = 1 + + def _get_mamba_group_info( + self, kv_cache_config: KVCacheConfig + ) -> tuple[list[int], MambaSpec]: + if self._mamba_spec is None: + group_ids: list[int] = [] + specs: list[MambaSpec] = [] + for i, group in enumerate(kv_cache_config.kv_cache_groups): + spec = group.kv_cache_spec + if isinstance(spec, MambaSpec): + group_ids.append(i) + specs.append(spec) + assert specs, "no mamba layers in the model" + assert all(specs[0] == s for s in specs) + self._mamba_group_ids = group_ids + self._mamba_spec = specs[0] + return self._mamba_group_ids, self._mamba_spec + + def _ensure_align_ctx( + self, + kv_cache_config: KVCacheConfig, + mamba_group_ids: list[int], + block_tables: tuple[torch.Tensor, ...], + ) -> MambaSpecDecodeGPUContext: + if self._mamba_ctx is None: + copy_funcs = self.model.get_mamba_state_copy_func() + # The fused copy kernels shift conv windows assuming the SD layout; + # the DS layout cannot express a >0 spec-decode shift as a single + # contiguous copy (mirrors get_conv_copy_spec's NotImplementedError). + if get_conv_copy_spec in copy_funcs and is_conv_state_dim_first(): + assert self.vllm_config.speculative_config is None, ( + "DS conv state layout does not support mamba align state " + "copies with speculative decoding" + ) + self._mamba_ctx = MambaSpecDecodeGPUContext.create( + max_num_reqs=self.max_num_reqs, + kv_cache_config=kv_cache_config, + num_state_types=len(copy_funcs), + device=self.device, + make_buffer=lambda n, dtype: CpuGpuBuffer( + n, dtype=dtype, device=self.device + ), + ) + ctx = self._mamba_ctx + if not ctx.is_initialized: + forward_context = self.vllm_config.compilation_config.static_forward_context + # block_tables are batch-order slices of the persistent + # input_block_tables (stable data_ptr), so the metadata is captured + # once here and reused across steps. + ctx.initialize_from_forward_context( + kv_cache_config, + forward_context, + self.model.get_mamba_state_copy_func(), + [block_tables[gid] for gid in mamba_group_ids], + ) + return ctx + + def preprocess_state( + self, + input_batch: InputBatch, + block_tables: tuple[torch.Tensor, ...], + kv_cache_config: KVCacheConfig, + num_computed_tokens: torch.Tensor, + ) -> None: + """Migrate each request's mamba state across block boundaries before the + forward (V1 align semantics, done on GPU). Runs on real batches only + (dummy DP/profiling runs skip preprocess_state), and before + ``prepare_attn`` gathers ``num_accepted_tokens``, so the boundary reset + is visible to the forward kernels. + """ + if not self._align_mode: + return + num_reqs = input_batch.num_reqs + if num_reqs == 0: + return + mamba_group_ids, mamba_spec = self._get_mamba_group_info(kv_cache_config) + ctx = self._ensure_align_ctx(kv_cache_config, mamba_group_ids, block_tables) + + # The state-advance + pre-copy kernels run every step; they fast-exit per + # request when src_col < 0 or src_col == dst_col, so no copy happens on + # steps that don't cross a block boundary. (Skipping the launch entirely + # would need a V1-style async-D2H of the actual num_computed, since + # num_computed_tokens_np is an optimistic mirror under async scheduling; + # the launch cost is ~0.3% of TPOT, so the GPU fast-exit suffices.) + block = 256 + grid = (triton.cdiv(num_reqs, block),) + preprocess_mamba_align_fused_kernel[grid]( + input_batch.idx_mapping, + self._mamba_state_idx_gpu, + num_computed_tokens, + input_batch.query_start_loc, + self.num_accepted_tokens_gpu, + self._mamba_src_col_gpu, + self._mamba_src_off_gpu, + num_reqs, + BLOCK_SIZE=block, + MAMBA_BLOCK_SIZE=mamba_spec.block_size, + ) + ctx.run_fused_precopy( + num_reqs, + self._mamba_state_idx_gpu, + self._mamba_src_col_gpu, + self._mamba_src_off_gpu, + input_batch.idx_mapping, + ) def prepare_attn( self, @@ -103,7 +246,7 @@ def prepare_attn( # compute them during actual (non-capture) forward execution. num_accepted_tokens = None num_decode_draft_tokens_cpu = None - if not for_capture: + if not for_capture and self.vllm_config.num_speculative_tokens > 0: num_accepted_tokens = self.num_accepted_tokens_gpu.new_ones(num_reqs) num_accepted_tokens[: input_batch.num_reqs] = self.num_accepted_tokens_gpu[ input_batch.idx_mapping @@ -112,11 +255,16 @@ def prepare_attn( # GDN uses >= 0 to select spec-decode rows, so non-decode rows # need the -1 sentinel rather than a raw zero draft count. num_decode_draft_tokens_np = np.full(num_reqs, -1, dtype=np.int32) - if input_batch.num_draft_tokens_per_req is not None: - has_draft_tokens = input_batch.num_draft_tokens_per_req > 0 - spec_decode_mask = has_draft_tokens & ~input_batch.is_prefilling_np + num_draft_tokens_per_req = input_batch.num_draft_tokens_per_req + if num_draft_tokens_per_req is not None: + # A row is a spec-decode row only when its whole prompt is already + # computed, i.e. exactly one non-draft (decode) token is scheduled. + is_decode = ( + input_batch.num_scheduled_tokens == num_draft_tokens_per_req + 1 + ) + spec_decode_mask = (num_draft_tokens_per_req > 0) & is_decode num_decode_draft_tokens_np[: input_batch.num_reqs] = np.where( - spec_decode_mask, input_batch.num_draft_tokens_per_req, -1 + spec_decode_mask, num_draft_tokens_per_req, -1 ) num_decode_draft_tokens_cpu = torch.from_numpy(num_decode_draft_tokens_np) @@ -137,28 +285,53 @@ def prepare_attn( block_tables=block_tables, slot_mappings=slot_mappings, kv_cache_config=kv_cache_config, + seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound, dcp_local_seq_lens=input_batch.dcp_local_seq_lens, model_specific_attn_metadata=mamba_attn_metadata, for_cudagraph_capture=for_capture, + rswa_prefix_lens=input_batch.prompt_lens, ) def postprocess_state( - self, idx_mapping: torch.Tensor, num_sampled: torch.Tensor | int + self, + idx_mapping: torch.Tensor, + num_sampled: torch.Tensor | int, + num_computed_tokens: torch.Tensor | None = None, ) -> None: # Chunked prefill does not sample a token, so num_sampled can be 0. # Mamba treats num_accepted_tokens=1 as the neutral non-spec value. if not isinstance(num_sampled, int): # idx_mapping may contain -1 sentinels (filtered rows) under PP; the # kernel skips them rather than scattering with a host-side gather. - num_reqs = idx_mapping.shape[0] - if num_reqs: - _scatter_num_accepted_kernel[(num_reqs,)]( + n = idx_mapping.shape[0] + if n: + _scatter_num_accepted_kernel[(n,)]( idx_mapping, num_sampled, self.num_accepted_tokens_gpu ) - return + else: + # Fill with single value. + self.num_accepted_tokens_gpu.index_fill_( + 0, idx_mapping, max(num_sampled, 1) + ) - # Fill with single value. - self.num_accepted_tokens_gpu.index_fill_(0, idx_mapping, max(num_sampled, 1)) + # Align: save the running state to the block-aligned position when + # spec-decode acceptance leaves the sequence non-block-aligned (mirrors + # the V1 align postprocess). num_computed_tokens already holds the + # post-step advanced count. + if ( + self._align_mode + and num_computed_tokens is not None + and self._mamba_ctx is not None + ): + num_reqs = idx_mapping.shape[0] + if num_reqs: + self._mamba_ctx.run_fused_postprocess_align( + num_reqs, + self.num_accepted_tokens_gpu, + self._mamba_state_idx_gpu, + num_computed_tokens, + idx_mapping, + ) @triton.jit diff --git a/vllm/v1/worker/gpu/model_states/mm_pruning.py b/vllm/v1/worker/gpu/model_states/mm_pruning.py new file mode 100644 index 000000000000..781baa6d15f7 --- /dev/null +++ b/vllm/v1/worker/gpu/model_states/mm_pruning.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import torch +import torch.nn as nn + +from vllm.config import ModelConfig +from vllm.model_executor.models.interfaces import supports_multimodal_pruning +from vllm.multimodal.utils import get_mm_features_in_window +from vllm.v1.worker.gpu.input_batch import InputBatch +from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache +from vllm.v1.worker.gpu.mm.rope import RopeState +from vllm.v1.worker.gpu.states import RequestState + + +class MultiModalPruner: + """Recomputes M-RoPE positions for multimodal models that prune embeddings + (e.g. Qwen2.5-VL / Qwen3-VL / Nemotron-Nano-VL Efficient Video Sampling). + + Pruning models append their mrope-position channels to the (variable-count) + media embeddings from `embed_multimodal`. Those channels must be split off and + used to recompute mrope positions before the embeddings are merged. + """ + + def __init__( + self, + model: nn.Module, + rope_state: RopeState, + encoder_cache: EncoderCache, + inputs_embeds_size: int, + ) -> None: + self.model = model + self.rope_state = rope_state + self.encoder_cache = encoder_cache + # The cleaned embedding width: pruning models append their mrope-position + # channels as trailing columns, so embeds[:, :inputs_embeds_size] strips them. + self.inputs_embeds_size = inputs_embeds_size + + def strip(self, mm_embeds: list[torch.Tensor]) -> list[torch.Tensor]: + """Draft forward: strip the appended position channels only. + + Stripping is per-embedding, so no per-request segmentation is needed. The + speculator reuses the target's already-recomputed positions, hence there is + no position write-back here. + """ + return [mm[:, : self.inputs_embeds_size] for mm in mm_embeds] + + def recompute( + self, + mm_embeds: list[torch.Tensor], + input_batch: InputBatch, + req_states: RequestState, + ) -> list[torch.Tensor]: + """Target forward: split the appended mrope-position channels off each + request's media embeddings, recompute the corrected mrope positions, and + stage them back into RopeState. Returns the cleaned, flattened embeddings. + """ + cleaned: list[torch.Tensor] = [] + pos = 0 + req_idx_list = input_batch.idx_mapping_np.tolist() + prefill_lens_list = input_batch.prefill_len_np.tolist() + num_computed_list = input_batch.num_computed_prefill_tokens_np.tolist() + num_scheduled_list = input_batch.num_scheduled_tokens.tolist() + for batch_idx, req_id in enumerate(input_batch.req_ids): + num_computed = num_computed_list[batch_idx] + query_end = num_computed + num_scheduled_list[batch_idx] + num_req_embeds = self._num_window_embeds(req_id, num_computed, query_end) + if num_req_embeds == 0: + continue + req_embeds = mm_embeds[pos : pos + num_req_embeds] + pos += num_req_embeds + + req_idx = req_idx_list[batch_idx] + prefill_len = prefill_lens_list[batch_idx] + input_ids = req_states.all_token_ids.gpu[req_idx, :prefill_len] + mrope_positions = self.rope_state.read_prefill_positions( + req_idx, prefill_len + ).long() + req_cleaned, new_positions, delta = self.model.recompute_mrope_positions( + input_ids=input_ids, + multimodal_embeddings=req_embeds, + mrope_positions=mrope_positions, + num_computed_tokens=num_computed, + ) + self.rope_state.update_prefill_positions(req_idx, new_positions, delta) + cleaned.extend(req_cleaned) + + assert pos == len(mm_embeds) + return cleaned + + def _num_window_embeds(self, req_id: str, query_start: int, query_end: int) -> int: + """Count the media items contributing embeddings to [query_start, + query_end), mirroring EncoderRunner.gather_mm_embeddings' per-request + windowing so the flat mm_embeds list can be re-segmented per request. + + Note: This logic is intentionally duplicated here rather than being emitted + from gather_mm_embeddings, to keep the main path cleaner, since this is a niche + feature. + """ + mm_features = self.encoder_cache.mm_features[req_id] + lo, hi = get_mm_features_in_window( + mm_features, start=query_start, end=query_end + ) + count = 0 + for mm_feature in mm_features[lo:hi]: + pos_info = mm_feature.mm_position + start_idx = max(query_start - pos_info.offset, 0) + end_idx = min(query_end - pos_info.offset, pos_info.length) + embeds_start, embeds_end = pos_info.get_embeds_indices_in_range( + start_idx, end_idx + ) + if embeds_start != embeds_end: + count += 1 + return count + + +def maybe_create_mm_pruner( + model_config: ModelConfig, + model: nn.Module, + rope_state: RopeState | None, + encoder_cache: EncoderCache | None, +) -> MultiModalPruner | None: + """Create a MultiModalPruner if the model prunes embeddings and uses M-RoPE.""" + if ( + rope_state is None + or not rope_state.has_delta + or encoder_cache is None + or model_config.multimodal_config is None + or not model_config.multimodal_config.is_multimodal_pruning_enabled() + or not supports_multimodal_pruning(model) + ): + return None + + return MultiModalPruner( + model, rope_state, encoder_cache, model_config.get_inputs_embeds_size() + ) diff --git a/vllm/v1/worker/gpu/pp_utils.py b/vllm/v1/worker/gpu/pp_utils.py index 9f5d4c2d8077..9b1786cbe4cf 100644 --- a/vllm/v1/worker/gpu/pp_utils.py +++ b/vllm/v1/worker/gpu/pp_utils.py @@ -9,6 +9,7 @@ import torch from vllm.distributed.parallel_state import get_pp_group +from vllm.platforms import current_platform from vllm.v1.worker.gpu.buffer_utils import async_copy_to_gpu from vllm.v1.worker.gpu.input_batch import InputBatch @@ -179,6 +180,10 @@ def broadcast( return assert sampled_token_ids.dtype == torch.int64 + + if current_platform.is_xpu(): + self.main_stream.synchronize() + with torch.cuda.stream(self.broadcast_stream): self.broadcast_stream.wait_stream(self.main_stream) torch.distributed.broadcast( diff --git a/vllm/v1/worker/gpu/sample/bad_words.py b/vllm/v1/worker/gpu/sample/bad_words.py index 6286cc38359c..b5517dee1b18 100644 --- a/vllm/v1/worker/gpu/sample/bad_words.py +++ b/vllm/v1/worker/gpu/sample/bad_words.py @@ -114,7 +114,7 @@ def _bad_words_kernel( input_ids_ptr, expanded_local_pos_ptr, ): - token_idx = tl.program_id(0) + token_idx = tl.program_id(0).to(tl.int64) bw_idx = tl.program_id(1) req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx) diff --git a/vllm/v1/worker/gpu/sample/gumbel.py b/vllm/v1/worker/gpu/sample/gumbel.py index aaa49283d32a..190307d5e75a 100644 --- a/vllm/v1/worker/gpu/sample/gumbel.py +++ b/vllm/v1/worker/gpu/sample/gumbel.py @@ -2,18 +2,16 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import torch -from vllm.triton_utils import HAS_TRITON, tl, triton +from vllm.triton_utils import HAS_TRITON, tl, tldevice, triton -# Smallest positive normal fp32 value. Used to clamp the uniform draw so that -# `log(u)` cannot produce -inf (and thus `-log(-log(u))` stays finite). +# Smallest positive value produced by Triton's fp32 `tl.rand`. Used to clamp +# zero draws before the flipped Gumbel transform below. # # Triton requires globals accessed from `@triton.jit` functions to be wrapped # in `tl.constexpr(...)`. We can only do that when Triton is actually # available — on the CPU worker path `tl` is a placeholder whose `constexpr` # attribute is `None`, and `tl.constexpr(...)` would crash at import time. -_FP32_TINY = ( - tl.constexpr(float.fromhex("0x1p-126")) if HAS_TRITON else float.fromhex("0x1p-126") -) +_TL_RAND_MIN = tl.constexpr(4.6566127342e-10) if HAS_TRITON else 4.6566127342e-10 @triton.jit @@ -25,7 +23,7 @@ def _temperature_kernel( vocab_size, BLOCK_SIZE: tl.constexpr, ): - token_idx = tl.program_id(0) + token_idx = tl.program_id(0).to(tl.int64) req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx) temperature = tl.load(temperature_ptr + req_state_idx).to(tl.float32) if temperature == 0.0 or temperature == 1.0: @@ -75,6 +73,14 @@ def tl_rand64(seed, offset, includes_zero: tl.constexpr): return u +@triton.jit +def tl_rand32(seed, offset, includes_zero: tl.constexpr): + u = tl.rand(seed, offset) + if not includes_zero: + u = tl.maximum(u, _TL_RAND_MIN) + return u + + @triton.jit def gumbel_block_argmax( logits, @@ -91,9 +97,13 @@ def gumbel_block_argmax( vocab_size, APPLY_TEMPERATURE: tl.constexpr, USE_FP64: tl.constexpr, + PER_TOKEN_COL: tl.constexpr = False, ): - req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx) - temp = tl.load(temp_ptr + req_state_idx).to(tl.float32) + req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx).to(tl.int64) + is_valid_req = req_state_idx >= 0 + temp = tl.load(temp_ptr + req_state_idx, mask=is_valid_req, other=0.0).to( + tl.float32 + ) if temp != 0.0 and APPLY_TEMPERATURE: # Apply temperature. # NOTE(woosuk): Match the behavior of _temperature_kernel. @@ -103,7 +113,10 @@ def gumbel_block_argmax( if processed_logits_ptr is not None: # Store the temperature-applied logits. if processed_logits_col_ptr is not None: - col = tl.load(processed_logits_col_ptr) + if PER_TOKEN_COL: + col = tl.load(processed_logits_col_ptr + token_idx) + else: + col = tl.load(processed_logits_col_ptr) else: col = 0 tl.store( @@ -112,7 +125,7 @@ def gumbel_block_argmax( + col * vocab_size + block, logits, - mask=mask, + mask=mask & is_valid_req, ) # fp32 is the default reduction dtype; fp64 is ~1/32–1/64x the throughput @@ -121,16 +134,22 @@ def gumbel_block_argmax( logits = logits.to(tl.float64) if temp != 0.0: # Calculate the seed for gumbel noise. - seed = tl.load(seeds_ptr + req_state_idx) + seed = tl.load(seeds_ptr + req_state_idx, mask=is_valid_req, other=0) pos = tl.load(pos_ptr + token_idx) gumbel_seed = tl.randint(seed, pos) if USE_FP64: u = tl_rand64(gumbel_seed, block, includes_zero=False) + gumbel_noise = -tl.log(-tl.log(u)) else: - u = tl.rand(gumbel_seed, block) - u = tl.maximum(u, _FP32_TINY) - gumbel_noise = -tl.log(-tl.log(u)) + u = tl_rand32(gumbel_seed, block, includes_zero=False) + # Draw the large-noise tail (which decides the argmax winner) from u -> 0, + # where fp32 has fine resolution, instead of u -> 1, where fp32 spacing is + # ~2**-24. The naive `-log(-log(u))` puts the winning tail at u -> 1, + # hard-capping the noise at ~16.6 and coarsely quantizing it; using + # `log1p(-u)` == `log(1 - u)` keeps the tail in the well-resolved region. + # Note `1 - u` would lose precision for small u, so `log1p` is required. + gumbel_noise = -tl.log(-tldevice.log1p(-u)) # Apply gumbel noise. logits = tl.where(mask, logits + gumbel_noise, float("-inf")) @@ -158,8 +177,9 @@ def _gumbel_sample_kernel( BLOCK_SIZE: tl.constexpr, APPLY_TEMPERATURE: tl.constexpr, USE_FP64: tl.constexpr, + PER_TOKEN_COL: tl.constexpr, ): - token_idx = tl.program_id(0) + token_idx = tl.program_id(0).to(tl.int64) block_idx = tl.program_id(1) block = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) mask = block < vocab_size @@ -185,6 +205,7 @@ def _gumbel_sample_kernel( vocab_size, APPLY_TEMPERATURE=APPLY_TEMPERATURE, USE_FP64=USE_FP64, + PER_TOKEN_COL=PER_TOKEN_COL, ) token_id = block_idx * BLOCK_SIZE + idx tl.store(local_argmax_ptr + token_idx * local_argmax_stride + block_idx, token_id) @@ -202,12 +223,21 @@ def gumbel_sample( output_processed_logits_col: torch.Tensor | None = None, use_fp64: bool = False, ) -> torch.Tensor: + # Enforce contiguity on non-strided input tensors + expanded_idx_mapping = expanded_idx_mapping.contiguous() + pos = pos.contiguous() + if output_processed_logits_col is not None: + output_processed_logits_col = output_processed_logits_col.contiguous() num_tokens, vocab_size = logits.shape BLOCK_SIZE = 1024 num_blocks = triton.cdiv(vocab_size, BLOCK_SIZE) local_argmax = logits.new_empty(num_tokens, num_blocks, dtype=torch.int64) local_max_dtype = torch.float64 if use_fp64 else torch.float32 local_max = logits.new_empty(num_tokens, num_blocks, dtype=local_max_dtype) + per_token_col = ( + output_processed_logits_col is not None + and output_processed_logits_col.dim() > 0 + ) _gumbel_sample_kernel[(num_tokens, num_blocks)]( local_argmax, local_argmax.stride(0), @@ -226,6 +256,7 @@ def gumbel_sample( BLOCK_SIZE=BLOCK_SIZE, APPLY_TEMPERATURE=apply_temperature, USE_FP64=use_fp64, + PER_TOKEN_COL=per_token_col, ) # NOTE(woosuk): Use int64 for later indexing. max_block_idx = local_max.argmax(dim=-1, keepdim=True) diff --git a/vllm/v1/worker/gpu/sample/logit_bias.py b/vllm/v1/worker/gpu/sample/logit_bias.py index cabb3fc11f8d..6c95ed7aacb3 100644 --- a/vllm/v1/worker/gpu/sample/logit_bias.py +++ b/vllm/v1/worker/gpu/sample/logit_bias.py @@ -169,7 +169,7 @@ def _bias_kernel( BLOCK_SIZE: tl.constexpr, LOGITS_BLOCK_SIZE: tl.constexpr, ): - token_idx = tl.program_id(0) + token_idx = tl.program_id(0).to(tl.int64) req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx) block = tl.arange(0, BLOCK_SIZE) @@ -189,6 +189,8 @@ def _bias_kernel( logits_ptr + token_idx * logits_stride + allowed_token_ids, mask=mask ) + tl.debug_barrier() # save must read original logits before the -inf overwrite + # Set logits to -inf for all tokens. for i in range(0, vocab_size, LOGITS_BLOCK_SIZE): offset = i + tl.arange(0, LOGITS_BLOCK_SIZE) @@ -198,6 +200,8 @@ def _bias_kernel( mask=offset < vocab_size, ) + tl.debug_barrier() # -inf overwrite must finish before restoring saved logits + # Restore logits for allowed token IDs. tl.store( logits_ptr + token_idx * logits_stride + allowed_token_ids, @@ -222,7 +226,7 @@ def _bias_kernel( num_stop_token_ids = tl.load(num_stop_token_ids_ptr + req_state_idx) pos = tl.load(pos_ptr + token_idx) min_len = tl.load(min_lens_ptr + req_state_idx) - if num_stop_token_ids > 0 and pos < min_len: + if num_stop_token_ids > 0 and pos + 1 < min_len: mask = block < num_stop_token_ids stop_token_ids = tl.load( stop_token_ids_ptr + req_state_idx * stop_token_ids_stride + block, diff --git a/vllm/v1/worker/gpu/sample/logprob.py b/vllm/v1/worker/gpu/sample/logprob.py index cf24c186e93a..cb2cf1a590e0 100644 --- a/vllm/v1/worker/gpu/sample/logprob.py +++ b/vllm/v1/worker/gpu/sample/logprob.py @@ -9,6 +9,9 @@ from vllm.v1.outputs import LogprobsTensors from vllm.v1.worker.gpu.buffer_utils import StagedWriteTensor, UvaBackedTensor +# Upper bound on the topk kernel's per-iteration gather width. +_MAX_TOPK_BLOCK = 1024 + @triton.jit def _topk_log_softmax_kernel( @@ -19,9 +22,9 @@ def _topk_log_softmax_kernel( topk, vocab_size, BLOCK_SIZE: tl.constexpr, - PADDED_TOPK: tl.constexpr, + TOPK_BLOCK_SIZE: tl.constexpr, ): - req_idx = tl.program_id(0) + req_idx = tl.program_id(0).to(tl.int64) row_ptr = logits_ptr + req_idx * logits_stride max_val = float("-inf") @@ -42,14 +45,16 @@ def _topk_log_softmax_kernel( se += tl.sum(e) lse = tl.log(se) - k_offset = tl.arange(0, PADDED_TOPK) - k_mask = k_offset < topk - topk_ids = tl.load(topk_ids_ptr + req_idx * topk + k_offset, mask=k_mask, other=0) - - logits = tl.load(row_ptr + topk_ids, mask=k_mask) - logits = logits.to(tl.float32) - o = logits - max_val - lse - tl.store(output_ptr + req_idx * topk + k_offset, o, mask=k_mask) + for j in range(0, topk, TOPK_BLOCK_SIZE): + k_offset = j + tl.arange(0, TOPK_BLOCK_SIZE) + k_mask = k_offset < topk + topk_ids = tl.load( + topk_ids_ptr + req_idx * topk + k_offset, mask=k_mask, other=0 + ) + logits = tl.load(row_ptr + topk_ids, mask=k_mask) + logits = logits.to(tl.float32) + o = logits - max_val - lse + tl.store(output_ptr + req_idx * topk + k_offset, o, mask=k_mask) @triton.jit @@ -61,7 +66,7 @@ def _ranks_kernel( vocab_size, BLOCK_SIZE: tl.constexpr, ): - req_idx = tl.program_id(0) + req_idx = tl.program_id(0).to(tl.int64) row_ptr = logits_ptr + req_idx * logits_stride token_id = tl.load(token_ids_ptr + req_idx) @@ -85,6 +90,9 @@ def compute_token_logprobs( token_ids = token_ids.to(torch.int64) num_logprobs = token_ids.shape[1] logprobs = logits.new_empty((batch_size, num_logprobs), dtype=torch.float32) + # Cap the kernel's per-iteration width so very large num_logprobs requests + # stream the gather in bounded-size chunks, avoiding excessive mem use. + topk_block_size = min(triton.next_power_of_2(num_logprobs), _MAX_TOPK_BLOCK) _topk_log_softmax_kernel[(batch_size,)]( logprobs, logits, @@ -93,7 +101,7 @@ def compute_token_logprobs( num_logprobs, vocab_size, BLOCK_SIZE=1024, # type: ignore - PADDED_TOPK=triton.next_power_of_2(num_logprobs), + TOPK_BLOCK_SIZE=topk_block_size, ) return logprobs diff --git a/vllm/v1/worker/gpu/sample/min_p.py b/vllm/v1/worker/gpu/sample/min_p.py index 4f08af2f5a5b..b71ae6f3addf 100644 --- a/vllm/v1/worker/gpu/sample/min_p.py +++ b/vllm/v1/worker/gpu/sample/min_p.py @@ -14,7 +14,7 @@ def _min_p_kernel( vocab_size, BLOCK_SIZE: tl.constexpr, ): - token_idx = tl.program_id(0) + token_idx = tl.program_id(0).to(tl.int64) req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx) min_p = tl.load(min_p_ptr + req_state_idx).to(tl.float32) if min_p == 0.0: diff --git a/vllm/v1/worker/gpu/sample/output.py b/vllm/v1/worker/gpu/sample/output.py index f38ac8affd88..130f4ddbf8a0 100644 --- a/vllm/v1/worker/gpu/sample/output.py +++ b/vllm/v1/worker/gpu/sample/output.py @@ -13,3 +13,4 @@ class SamplerOutput: logprobs_tensors: LogprobsTensors | None num_nans: torch.Tensor | None num_sampled: torch.Tensor | None + num_rejected: torch.Tensor | None = None diff --git a/vllm/v1/worker/gpu/sample/penalties.py b/vllm/v1/worker/gpu/sample/penalties.py index b2ce2fb812a1..25cb2f211d94 100644 --- a/vllm/v1/worker/gpu/sample/penalties.py +++ b/vllm/v1/worker/gpu/sample/penalties.py @@ -120,7 +120,7 @@ def _penalties_kernel( vocab_size, BLOCK_SIZE: tl.constexpr, ): - token_idx = tl.program_id(0) + token_idx = tl.program_id(0).to(tl.int64) req_state_idx = tl.load(expanded_idx_mapping_ptr + token_idx) rep_penalty = tl.load(repetition_penalty_ptr + req_state_idx) freq_penalty = tl.load(frequency_penalty_ptr + req_state_idx) diff --git a/vllm/v1/worker/gpu/sample/sampler.py b/vllm/v1/worker/gpu/sample/sampler.py index 6b545aef3a28..b269de9eaed0 100644 --- a/vllm/v1/worker/gpu/sample/sampler.py +++ b/vllm/v1/worker/gpu/sample/sampler.py @@ -12,7 +12,7 @@ flashinfer_sample, flashinfer_sampler_supported, ) -from vllm.v1.worker.gpu.input_batch import InputBatch +from vllm.v1.worker.gpu.input_batch import InputBatch, get_num_sampled_and_rejected from vllm.v1.worker.gpu.metrics.logits import get_num_nans from vllm.v1.worker.gpu.sample.bad_words import BadWordsState from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample @@ -44,6 +44,7 @@ def __init__( self.compute_nans = envs.VLLM_COMPUTE_NANS_IN_LOGITS # False by default. self.use_fp64_gumbel = use_fp64_gumbel + self.req_states = req_states self.sampling_states = SamplingStates(max_num_reqs, vocab_size) self.penalties_state = PenaltiesState(req_states) self.logit_bias_state = LogitBiasState(max_num_reqs, device) @@ -118,6 +119,17 @@ def __call__( else: logprobs_tensors = None + # 1 sampled token per request, except chunked-prefill requests + # (seq_len < prefill_len) which aren't done prefilling and produce no + # output token. num_rejected is always 0 here (one logit per request). + num_sampled, num_rejected = get_num_sampled_and_rejected( + input_batch.seq_lens.new_ones(input_batch.num_reqs), + input_batch.seq_lens, + input_batch.cu_num_logits, + input_batch.idx_mapping, + self.req_states.prefill_len.gpu, + ) + # These are GPU tensors. sampler_output = SamplerOutput( # The sampled tokens are expanded to 2D tensor with shape @@ -126,7 +138,8 @@ def __call__( sampled_token_ids=sampled.view(-1, 1), logprobs_tensors=logprobs_tensors, num_nans=num_nans, - num_sampled=input_batch.seq_lens.new_ones(input_batch.num_reqs), + num_sampled=num_sampled, + num_rejected=num_rejected, ) return sampler_output diff --git a/vllm/v1/worker/gpu/sample/states.py b/vllm/v1/worker/gpu/sample/states.py index bf2f1ce78feb..fe4dee6a6b10 100644 --- a/vllm/v1/worker/gpu/sample/states.py +++ b/vllm/v1/worker/gpu/sample/states.py @@ -56,6 +56,8 @@ def add_request(self, req_idx: int, sampling_params: SamplingParams) -> None: num_logprobs = sampling_params.logprobs if num_logprobs is None: num_logprobs = NO_LOGPROBS + elif num_logprobs == -1: + num_logprobs = self.vocab_size self.num_logprobs[req_idx] = num_logprobs def apply_staged_writes(self) -> None: diff --git a/vllm/v1/worker/gpu/spec_decode/__init__.py b/vllm/v1/worker/gpu/spec_decode/__init__.py index bafb28c5cc3e..c70f169f7be6 100644 --- a/vllm/v1/worker/gpu/spec_decode/__init__.py +++ b/vllm/v1/worker/gpu/spec_decode/__init__.py @@ -8,7 +8,19 @@ def init_speculator(vllm_config: VllmConfig, device: torch.device): speculative_config = vllm_config.speculative_config assert speculative_config is not None - if speculative_config.use_gemma4_mtp(): + if speculative_config.method == "dflash": + from vllm.v1.worker.gpu.spec_decode.dflash.speculator import ( + DFlashSpeculator, + ) + + return DFlashSpeculator(vllm_config, device) + elif speculative_config.method == "dspark": + from vllm.v1.worker.gpu.spec_decode.dspark.speculator import ( + DSparkSpeculator, + ) + + return DSparkSpeculator(vllm_config, device) + elif speculative_config.use_gemma4_mtp(): from vllm.v1.worker.gpu.spec_decode.gemma4.speculator import ( Gemma4Speculator, ) diff --git a/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py b/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py index 868540437b23..747fb3a3905f 100644 --- a/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py +++ b/vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py @@ -18,7 +18,6 @@ ) from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers -from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import ( DecodeSpeculatorCudaGraphManager, PrefillSpeculatorCudaGraphManager, @@ -61,16 +60,6 @@ def advance_draft_positions(self) -> bool: """ return True - @property - def model_returns_tuple(self) -> bool: - """ - Whether the draft model's forward() returns a tuple. - - True: returns (last_hidden_states, hidden_states) — Eagle, Gemma4 MTP. - False: returns a single tensor used for both — standard MTP (DeepSeek). - """ - return True - def init_cudagraph_manager(self, cudagraph_mode: CUDAGraphMode) -> None: # Initialize cudagraph manager for draft prefill (draft position 0). self.prefill_cudagraph_manager = PrefillSpeculatorCudaGraphManager( @@ -224,6 +213,8 @@ def propose( need_eager=is_profile, ) + self._prepare_eplb_forward(input_batch.num_tokens) + if prefill_batch_desc.cg_mode == CUDAGraphMode.FULL: # Replay the full graph for draft prefill. assert self.prefill_cudagraph_manager is not None @@ -279,34 +270,6 @@ def propose( return self.draft_tokens[:num_reqs] - def sample_draft( - self, - hidden_states: torch.Tensor, - positions: torch.Tensor, - idx_mapping: torch.Tensor, - temperature: torch.Tensor, - seeds: torch.Tensor, - draft_step: torch.Tensor, - draft_logits: torch.Tensor | None, - ) -> torch.Tensor: - logits = self.model.compute_logits(hidden_states) - if draft_logits is not None: - # NOTE(woosuk): We must add 1 to the positions to match the Gumbel noise - # used for draft and target sampling. - return gumbel_sample( - logits, - idx_mapping, - temperature, - seeds, - positions + 1, - apply_temperature=True, - output_processed_logits=draft_logits, - output_processed_logits_col=draft_step, - use_fp64=self.use_fp64_gumbel, - ) - else: - return logits.argmax(dim=-1) - @torch.inference_mode() def _run_model( self, @@ -357,7 +320,9 @@ def _run_model( else: # Eager (NONE): call the raw model directly. ret_hidden_states = self.model(**model_inputs) - if self.model_returns_tuple: + # Some MTP models declare a single-tensor contract but return + # (logits_hidden, feedback_hidden) for final-norm correctness. + if isinstance(ret_hidden_states, tuple): last_hidden_states, hidden_states = ret_hidden_states else: last_hidden_states = ret_hidden_states @@ -397,7 +362,10 @@ def _prefill( self.current_draft_step, self.draft_logits, ) - self.hidden_states[:num_reqs] = hidden_states[last_token_indices] + if last_hidden_states is hidden_states: + self.hidden_states[:num_reqs] = sample_hidden_states + else: + self.hidden_states[:num_reqs] = hidden_states[last_token_indices] self.input_buffers.positions[:num_reqs] = positions def _multi_step_decode( @@ -458,6 +426,8 @@ def _generate_draft( num_tokens_across_dp: torch.Tensor | None, cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, ) -> None: + self._prepare_eplb_forward(num_reqs) + idx_mapping = self.idx_mapping[:num_reqs] positions = self.input_buffers.positions[:num_reqs] # Run the draft model forward pass. diff --git a/vllm/v1/worker/gpu/spec_decode/dflash/__init__.py b/vllm/v1/worker/gpu/spec_decode/dflash/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/vllm/v1/worker/gpu/spec_decode/dflash/cudagraph.py b/vllm/v1/worker/gpu/spec_decode/dflash/cudagraph.py new file mode 100644 index 000000000000..a4b4033cafa9 --- /dev/null +++ b/vllm/v1/worker/gpu/spec_decode/dflash/cudagraph.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from collections.abc import Callable, Mapping + +import torch + +from vllm.config.compilation import CUDAGraphMode +from vllm.v1.kv_cache_interface import KVCacheConfig +from vllm.v1.worker.gpu.attn_utils import ( + build_attn_metadata, + build_slot_mappings_by_layer, +) +from vllm.v1.worker.gpu.block_table import BlockTables +from vllm.v1.worker.gpu.cudagraph_utils import ( + AttentionState, + BatchExecutionDescriptor, + CudaGraphManager, +) +from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers +from vllm.v1.worker.utils import AttentionGroup + + +def _prepare_dflash_inputs_to_capture( + num_reqs: int, + num_tokens: int, + input_buffers: InputBuffers, + block_tables: BlockTables, + attn_groups: list[list[AttentionGroup]], + kv_cache_config: KVCacheConfig, + max_model_len: int, + skip_attn: bool, + causal: bool | Mapping[int, bool], +) -> AttentionState: + input_batch = InputBatch.make_dummy(num_reqs, num_tokens, input_buffers) + input_block_tables = block_tables.get_dummy_block_tables(num_reqs) + slot_mappings = block_tables.get_dummy_slot_mappings(num_tokens) + slot_mappings_by_layer = build_slot_mappings_by_layer( + slot_mappings, kv_cache_config + ) + + attn_metadata = None + if not skip_attn: + query_start_loc_cpu = torch.from_numpy(input_batch.query_start_loc_np) + attn_metadata = 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=num_tokens // num_reqs, + seq_lens=input_batch.seq_lens, + max_seq_len=max_model_len, + block_tables=input_block_tables, + slot_mappings=slot_mappings, + kv_cache_config=kv_cache_config, + for_cudagraph_capture=True, + causal=causal, + ) + return AttentionState(attn_metadata, slot_mappings_by_layer) + + +class DFlashCudaGraphManager(CudaGraphManager): + """DFlash CudaGraphManager for the parallel-drafting query forward, + building its own attention metadata from scratch.""" + + def capture( + self, + forward_fn: Callable, + input_buffers: InputBuffers, + block_tables: BlockTables, + attn_groups: list[list[AttentionGroup]], + kv_cache_config: KVCacheConfig, + max_model_len: int, + causal: bool | Mapping[int, bool], + progress_bar_desc: str = "Capturing CUDA graphs", + ) -> None: + def create_forward_fn( + desc: BatchExecutionDescriptor, + warmup: bool, + ) -> tuple[Callable[[CUDAGraphMode], None], AttentionState]: + num_tokens = desc.num_tokens + num_reqs = desc.num_reqs or min(num_tokens, self.max_num_reqs) + num_tokens_across_dp = ( + torch.full((self.dp_size,), num_tokens, dtype=torch.int32, device="cpu") + if self.dp_size > 1 + else None + ) + attn_state = _prepare_dflash_inputs_to_capture( + num_reqs, + num_tokens, + input_buffers, + block_tables, + attn_groups, + kv_cache_config, + max_model_len, + skip_attn=(desc.cg_mode == CUDAGraphMode.PIECEWISE), + causal=causal, + ) + attn_metadata, slot_mappings = attn_state + + fwd = lambda cg_mode: forward_fn( + num_reqs, + num_tokens, + attn_metadata, + slot_mappings, + num_tokens_across_dp, + cg_mode, + ) + return fwd, attn_state + + super().capture(create_forward_fn, progress_bar_desc) diff --git a/vllm/v1/worker/gpu/spec_decode/dflash/speculator.py b/vllm/v1/worker/gpu/spec_decode/dflash/speculator.py new file mode 100644 index 000000000000..f9ffd3135d92 --- /dev/null +++ b/vllm/v1/worker/gpu/spec_decode/dflash/speculator.py @@ -0,0 +1,638 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from collections.abc import Mapping +from typing import Any + +import torch +import torch.nn as nn + +from vllm.config import VllmConfig +from vllm.config.compilation import CUDAGraphMode +from vllm.forward_context import BatchDescriptor, set_forward_context +from vllm.logger import init_logger +from vllm.triton_utils import tl, triton +from vllm.v1.attention.backends.utils import PAD_SLOT_ID +from vllm.v1.kv_cache_interface import KVCacheConfig +from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer +from vllm.v1.worker.gpu.block_table import BlockTables +from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp +from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers +from vllm.v1.worker.gpu.model_states.interface import ModelState +from vllm.v1.worker.gpu.spec_decode.dflash.cudagraph import DFlashCudaGraphManager +from vllm.v1.worker.gpu.spec_decode.dflash.utils import ( + get_dflash_causal, + load_dflash_model, +) +from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator +from vllm.v1.worker.gpu.spec_decode.utils import get_parallel_drafting_token_id + +logger = init_logger(__name__) + + +class DFlashSpeculator(DraftModelSpeculator): + _speculator_name = "DFlash" # For logging, so we can share methods with subclasses + + def __init__(self, vllm_config: VllmConfig, device: torch.device): + super().__init__(vllm_config, device) + + self.hidden_states = torch.zeros( + self.max_num_tokens, self.hidden_size, dtype=self.dtype, device=device + ) + + # Multimodal inputs not currently supported. + self.supports_mm_inputs = False + + # Each request emits exactly (bonus + N mask) query tokens per step. + self.num_query_per_req = 1 + self.num_speculative_steps + + self.parallel_drafting_token_id = get_parallel_drafting_token_id( + self.draft_model_config.hf_config + ) + + self.dflash_causal = get_dflash_causal(self.draft_model_config) + + # Whether the anchor query position is itself a prediction. DFlash default uses + # the anchor as the bonus token (only mask tokens predict); DSpark samples from + # the anchor and the N-1 mask token positions. See _prepare_dflash_inputs_kernel + self.sample_from_anchor = False + + # Context positions for the K/V precompute. Populated by + # prepare_dflash_inputs, and processed by the model's + # precompute_and_store_context_kv method. NOT captured by CUDA graphs. + self.context_positions = torch.zeros( + self.max_num_tokens, dtype=torch.int64, device=device + ) + + # Per-mask-token sampling buffers. Flattened from (num_reqs, num_spec_tokens). + max_num_sampled_tokens = self.max_num_reqs * self.num_speculative_steps + self.sample_indices = torch.zeros( + max_num_sampled_tokens, dtype=torch.int64, device=device + ) + self.sample_pos = torch.zeros( + max_num_sampled_tokens, dtype=torch.int64, device=device + ) + self.sample_idx_mapping = torch.zeros( + max_num_sampled_tokens, dtype=torch.int32, device=device + ) + # [0, 1, ..., N-1, 0, 1, ..., N-1, ...] -> the per-token column index into + # draft_logits[req, step, :]. + self.sample_col = torch.arange( + self.num_speculative_steps, dtype=torch.int32, device=device + ).repeat(self.max_num_reqs) + + self.query_cudagraph_manager: DFlashCudaGraphManager | None = None + self.draft_kv_cache_group_id: int = -1 + + def init_cudagraph_manager(self, cudagraph_mode: CUDAGraphMode) -> None: + # PIECEWISE cudagraphs are not supported for dflash + if cudagraph_mode.decode_mode() == CUDAGraphMode.FULL: + cudagraph_mode = CUDAGraphMode.FULL_DECODE_ONLY + else: + cudagraph_mode = CUDAGraphMode.NONE + + self.query_cudagraph_manager = DFlashCudaGraphManager( + self.vllm_config, + self.device, + cudagraph_mode, + decode_query_len=self.num_query_per_req, + ) + + def capture(self, attn_states: dict | None = None) -> None: + logger.info("Capturing model for %s speculator...", self._speculator_name) + # Reset sampling indices to zero to prevent stale values from prior + # dummy runs from being baked into the captured graph. + self.sample_indices.zero_() + self.sample_pos.zero_() + self.sample_idx_mapping.zero_() + assert self.query_cudagraph_manager is not None + self.query_cudagraph_manager.capture( + self._generate_draft, + self.input_buffers, + self.block_tables, + self.attn_groups, + self.kv_cache_config, + self.max_model_len, + causal=self._group_causal, + progress_bar_desc=f"Capturing {self._speculator_name.lower()} CUDA graphs", + ) + + def load_draft_model( + self, + target_model: nn.Module, + target_attn_layer_names: set[str], + ) -> nn.Module: + return load_dflash_model(target_model, self.vllm_config) + + def set_attn( + self, + model_state: ModelState, + kv_cache_config: KVCacheConfig, + block_tables: BlockTables, + ) -> None: + super().set_attn(model_state, kv_cache_config, block_tables) + + self.draft_kv_cache_group_ids = [ + gid for gid, g in enumerate(self.attn_groups) if g + ] + assert self.draft_kv_cache_group_ids, "No draft attention groups found." + self.draft_kv_cache_group_id = self.draft_kv_cache_group_ids[0] + + # Per-group context slot buffers for the precompute (one row per group). + self._context_slot_mappings = torch.zeros( + len(self.draft_kv_cache_group_ids), + self.max_num_tokens, + dtype=torch.int64, + device=self.device, + ) + + # Map each draft decoder layer to the index (within draft_kv_cache_group_ids) + # of the kv-cache group its cache belongs to. Models that share a single group + # leave this as None and share one context slot mapping. + self._layer_group_idx: list[int] | None = None + # Per-KV-group causal, falling back to the scalar dflash_causal. + self._group_causal: dict[int, bool] | bool = self.dflash_causal + if hasattr(self.model, "get_draft_kv_cache_layer_names"): + layer_names = self.model.get_draft_kv_cache_layer_names() + name_to_gid = { + ln: gid + for gid, group in enumerate(kv_cache_config.kv_cache_groups) + for ln in group.layer_names + } + gid_to_idx = {gid: i for i, gid in enumerate(self.draft_kv_cache_group_ids)} + self._layer_group_idx = [ + gid_to_idx[name_to_gid[name]] for name in layer_names + ] + if hasattr(self.model, "get_draft_attn_causal"): + self._group_causal = { + name_to_gid[name]: layer_causal + for name, layer_causal in zip( + layer_names, self.model.get_draft_attn_causal() + ) + } + + @torch.inference_mode() + def _run_model( + self, + num_tokens: int, + attn_metadata: dict[str, Any] | None, + slot_mappings: dict[str, torch.Tensor] | None, + num_tokens_across_dp: torch.Tensor | None, + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, + ) -> torch.Tensor: + batch_descriptor = BatchDescriptor(num_tokens=num_tokens) + with set_forward_context( + attn_metadata, + self.vllm_config, + num_tokens=num_tokens, + cudagraph_runtime_mode=cudagraph_runtime_mode, + num_tokens_across_dp=num_tokens_across_dp, + slot_mapping=slot_mappings, + batch_descriptor=batch_descriptor, + ): + last_hidden_states = self.model( + input_ids=self.input_buffers.input_ids[:num_tokens], + positions=self.input_buffers.positions[:num_tokens], + inputs_embeds=None, + ) + return last_hidden_states + + def _generate_draft( + self, + num_reqs: int, + num_tokens_padded: int, + attn_metadata: dict[str, Any] | None, + slot_mappings: dict[str, torch.Tensor] | None, + num_tokens_across_dp: torch.Tensor | None, + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, + ) -> None: + last_hidden_states = self._run_model( + num_tokens_padded, + attn_metadata, + slot_mappings, + num_tokens_across_dp, + cudagraph_runtime_mode, + ) + + num_sample = num_reqs * self.num_speculative_steps + sample_hidden_states = last_hidden_states[self.sample_indices[:num_sample]] + # sample_pos is the predicted token's position Q; verification keys + # Gumbel by the predecessor (Q-1). sample_draft adds +1, so pass Q-2. + draft_tokens = self.sample_draft( + sample_hidden_states, + self.sample_pos[:num_sample] - 2, + self.sample_idx_mapping[:num_sample], + self.temperature, + self.seeds, + self.sample_col[:num_sample], + self.draft_logits, + ) + self.draft_tokens[:num_reqs] = draft_tokens.view( + num_reqs, self.num_speculative_steps + ) + + def _build_draft_attn_metadata( + self, + num_reqs: int, + num_reqs_padded: int, + num_tokens_padded: int, + num_query_per_req: int | None = None, + causal: bool | Mapping[int, bool] = False, + ) -> dict[str, Any] | None: + if not self.draft_attn_layer_names: + return None + assert num_query_per_req is None # Omitted for DFlash, read from self instead + return super()._build_draft_attn_metadata( + num_reqs, + num_reqs_padded, + num_tokens_padded, + num_query_per_req=self.num_query_per_req, + causal=causal, + ) + + @torch.inference_mode() + def propose( + self, + input_batch: InputBatch, + attn_metadata: dict[str, Any], + slot_mappings: dict[str, torch.Tensor], + # [num_tokens, hidden_size] + last_hidden_states: torch.Tensor, + # num_layers x [num_tokens, hidden_size] + aux_hidden_states: list[torch.Tensor] | None, + # [num_reqs] + num_sampled: torch.Tensor, + # [num_reqs] + num_rejected: torch.Tensor, + # [max_num_reqs] + last_sampled: torch.Tensor, + # [max_num_reqs] + next_prefill_tokens: torch.Tensor, + # [max_num_reqs] + temperature: torch.Tensor, + # [max_num_reqs] + seeds: torch.Tensor, + num_tokens_across_dp: torch.Tensor | None = None, + dummy_run: bool = False, + skip_attn_for_dummy_run: bool = False, + mm_inputs: tuple[list[torch.Tensor], torch.Tensor] | None = None, + is_profile: bool = False, + ) -> torch.Tensor: + num_reqs = input_batch.num_reqs + num_target_tokens = input_batch.num_tokens + num_query_tokens = num_reqs * self.num_query_per_req + max_seq_len = input_batch.seq_lens_cpu_upper_bound[:num_reqs].max().item() + self.draft_max_seq_len = min( + max_seq_len + self.num_query_per_req, self.max_model_len + ) + + # NOTE: To avoid CPU-GPU synchronization without CPU knowing the + # number of rejected tokens, we maintain the size of input_ids and + # hidden_states the same as the target model's. This means, we pad each + # request's query length to include any rejected positions. + if aux_hidden_states: + hidden_states = self.model.combine_hidden_states( + torch.cat(aux_hidden_states, dim=-1) + ) + else: + hidden_states = last_hidden_states + self.hidden_states[:num_target_tokens].copy_(hidden_states[:num_target_tokens]) + + self._copy_request_inputs( + num_reqs, + input_batch.idx_mapping, + temperature, + seeds, + ) + + if dummy_run and skip_attn_for_dummy_run: + # Memory profiling path: block_tables / kv_cache_config are not initialized. + # Since DFlash needs to build its own attention metadata, we must skip the + # preparation in this path and run a minimal forward pass. + self.model.precompute_and_store_context_kv( + self.hidden_states[:num_target_tokens], + self.context_positions[:num_target_tokens], + ) + # DFlash processes all speculative tokens in one forward pass, + # so the real token count is num_query_tokens. + self._prepare_eplb_forward(num_query_tokens) + self._generate_draft( + num_reqs, + num_query_tokens, + attn_metadata=None, + slot_mappings=None, + num_tokens_across_dp=num_tokens_across_dp, + cudagraph_runtime_mode=CUDAGraphMode.NONE, + ) + return self.draft_tokens[:num_reqs] + + # The query slot mapping is written into the shared BlockTables slot_mappings. + # That buffer's address is what the captured CUDA graph reads from at replay. + assert self.draft_kv_cache_group_id >= 0 + # Support multiple draft KV cache groups by preparing inputs once for each + for i, gid in enumerate(self.draft_kv_cache_group_ids): + prepare_dflash_inputs( + self.input_buffers, + self.block_tables.slot_mappings[gid], + self.context_positions, + self._context_slot_mappings[i], + self.sample_indices, + self.sample_pos, + self.sample_idx_mapping, + input_batch, + num_sampled, + num_rejected, + last_sampled, + next_prefill_tokens, + self.block_tables.input_block_tables[gid], + self.block_tables.kernel_block_sizes[gid], + self.parallel_drafting_token_id, + self.num_query_per_req, + self.num_speculative_steps, + self.max_num_reqs, + self.max_num_tokens, + self.max_model_len, + self.sample_from_anchor, + ) + + # Pre-insert context K/V into the cache. Runs eagerly outside the captured graph + # because the context shape varies per step. During dummy runs the block tables + # are placeholders, so we skip the cache write to avoid clobbering real entries. + # Each layer uses the context slots of its own kv-cache group. + if dummy_run: + context_slots: torch.Tensor | list[torch.Tensor | None] | None = None + elif self._layer_group_idx is not None: + context_slots = [ + self._context_slot_mappings[gidx][:num_target_tokens] + for gidx in self._layer_group_idx + ] + else: + context_slots = self._context_slot_mappings[0][:num_target_tokens] + self.model.precompute_and_store_context_kv( + self.hidden_states[:num_target_tokens], + self.context_positions[:num_target_tokens], + context_slots, + ) + + # Every DFlash step has exactly num_query_per_req tokens, so we can use FULL CGs + batch_desc, num_tokens_across_dp = dispatch_cg_and_sync_dp( + self.query_cudagraph_manager, + num_reqs, + num_query_tokens, + uniform_token_count=self.num_query_per_req, + dp_size=self.dp_size, + dp_rank=self.dp_rank, + need_eager=is_profile, + ) + + num_reqs_padded = batch_desc.num_reqs or num_reqs + num_tokens_padded = batch_desc.num_tokens + + # Rebuild the draft attention metadata even when replaying the FULL + # graph so that any attention metadata builder state is updated. + draft_attn_metadata = self._build_draft_attn_metadata( + num_reqs=num_reqs, + num_reqs_padded=num_reqs_padded, + num_tokens_padded=num_tokens_padded, + causal=self._group_causal, + ) + draft_slot_mappings_by_layer = build_slot_mappings_by_layer( + self.block_tables.slot_mappings[:, :num_tokens_padded], + self.kv_cache_config, + ) + + # DFlash processes all speculative tokens in one forward pass, + # so the real token count is num_query_tokens. + self._prepare_eplb_forward(num_query_tokens) + + if batch_desc.cg_mode == CUDAGraphMode.FULL: + assert self.query_cudagraph_manager is not None + self.query_cudagraph_manager.run_fullgraph(batch_desc) + else: + self._generate_draft( + num_reqs, + num_tokens_padded, + draft_attn_metadata, + draft_slot_mappings_by_layer, + num_tokens_across_dp=num_tokens_across_dp, + cudagraph_runtime_mode=batch_desc.cg_mode, + ) + + return self.draft_tokens[:num_reqs] + + +@triton.jit +def _prepare_dflash_inputs_kernel( + # Outputs + out_input_ids_ptr, + out_query_positions_ptr, + out_query_start_loc_ptr, + out_seq_lens_ptr, + out_query_slot_mapping_ptr, + out_context_positions_ptr, + out_context_slot_mapping_ptr, + out_sample_indices_ptr, + out_sample_pos_ptr, + out_sample_idx_mapping_ptr, + # Inputs from target batch + target_positions_ptr, + target_query_start_loc_ptr, + idx_mapping_ptr, + last_sampled_ptr, + next_prefill_tokens_ptr, + num_sampled_ptr, + num_rejected_ptr, + # Block table for slot mapping lookup. + block_table_ptr, + block_table_stride, + # Scalars + parallel_drafting_token_id, + block_size, + num_query_per_req, + num_speculative_steps, + max_num_reqs, + max_num_tokens, + max_model_len, + SAMPLE_FROM_ANCHOR: tl.constexpr, + PAD_SLOT_ID: tl.constexpr, + BLOCK_SIZE: tl.constexpr, +): + req_idx = tl.program_id(0) + block_idx = tl.program_id(1) + num_reqs = tl.num_programs(0) + req_state_idx = tl.load(idx_mapping_ptr + req_idx) + + ctx_start = tl.load(target_query_start_loc_ptr + req_idx) + ctx_end = tl.load(target_query_start_loc_ptr + req_idx + 1) + num_ctx = ctx_end - ctx_start + + num_rejected = tl.load(num_rejected_ptr + req_idx) + valid_ctx_end = ctx_end - num_rejected + + num_sampled = tl.load(num_sampled_ptr + req_idx) + if num_sampled > 0: + bonus_token = tl.load(last_sampled_ptr + req_state_idx).to(tl.int32) + else: + # Chunked prefilling: splice in the next prefill token. + bonus_token = tl.load(next_prefill_tokens_ptr + req_state_idx).to(tl.int32) + + last_valid_pos = tl.load(target_positions_ptr + valid_ctx_end - 1) + query_base = req_idx * num_query_per_req + + j = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) + is_ctx = j < num_ctx + is_query = (j >= num_ctx) & (j < num_ctx + num_query_per_req) + query_off = j - num_ctx + + # --- Context positions / slots --- + ctx_pos_idx = ctx_start + tl.where(is_ctx, j, 0) + ctx_pos = tl.load(target_positions_ptr + ctx_pos_idx, mask=is_ctx, other=0) + ctx_block_num = ctx_pos // block_size + ctx_block_num = tl.minimum(ctx_block_num, block_table_stride - 1) + ctx_block_id = tl.load( + block_table_ptr + req_idx * block_table_stride + ctx_block_num, + mask=is_ctx, + other=0, + ).to(tl.int64) + ctx_slot = ctx_block_id * block_size + (ctx_pos % block_size) + tl.store(out_context_positions_ptr + ctx_start + j, ctx_pos, mask=is_ctx) + tl.store(out_context_slot_mapping_ptr + ctx_start + j, ctx_slot, mask=is_ctx) + + # --- Query positions / input_ids / slots --- + query_pos = last_valid_pos + 1 + query_off + query_idx = query_base + query_off + is_bonus = is_query & (query_off == 0) + input_id = tl.where(is_bonus, bonus_token, parallel_drafting_token_id) + + q_block_num = query_pos // block_size + q_block_num = tl.minimum(q_block_num, block_table_stride - 1) + q_block_id = tl.load( + block_table_ptr + req_idx * block_table_stride + q_block_num, + mask=is_query, + other=0, + ).to(tl.int64) + q_slot = q_block_id * block_size + (query_pos % block_size) + + tl.store(out_input_ids_ptr + query_idx, input_id, mask=is_query) + clamped_query_pos = tl.minimum(query_pos, max_model_len - 1) + tl.store(out_query_positions_ptr + query_idx, clamped_query_pos, mask=is_query) + tl.store(out_query_slot_mapping_ptr + query_idx, q_slot, mask=is_query) + + # --- Sample indices / positions / idx_mapping --- + # When SAMPLE_FROM_ANCHOR (DSpark), so we sample at EVERY query position + # and each position k predicts the NEXT token (sampled position = query_pos + 1). + # Otherwise (DFlash default) the anchor is the bonus token and only the mask tokens + # at offsets > 0 are sampled from, each AT its own position. + sample_off = 0 if SAMPLE_FROM_ANCHOR else 1 + is_sample = is_query & (query_off >= sample_off) + sample_idx = req_idx * num_speculative_steps + (query_off - sample_off) + sample_pos = query_pos + 1 if SAMPLE_FROM_ANCHOR else query_pos + tl.store(out_sample_indices_ptr + sample_idx, query_idx, mask=is_sample) + tl.store(out_sample_pos_ptr + sample_idx, sample_pos, mask=is_sample) + tl.store(out_sample_idx_mapping_ptr + sample_idx, req_state_idx, mask=is_sample) + + if block_idx == 0: + tl.store(out_query_start_loc_ptr + req_idx, query_base) + # seq_lens is the absolute sequence length the draft attention + # reads up to (context + query), not just the count of accepted + # tokens this step. + tl.store(out_seq_lens_ptr + req_idx, last_valid_pos + 1 + num_query_per_req) + if req_idx == num_reqs - 1: + # Pad per-request buffers to max_num_reqs for CUDA graph safety. + last_query_end = num_reqs * num_query_per_req + for i in range(num_reqs, max_num_reqs + 1, BLOCK_SIZE): + block = i + tl.arange(0, BLOCK_SIZE) + mask = block < max_num_reqs + 1 + tl.store(out_query_start_loc_ptr + block, last_query_end, mask=mask) + for i in range(num_reqs, max_num_reqs, BLOCK_SIZE): + block = i + tl.arange(0, BLOCK_SIZE) + mask = block < max_num_reqs + tl.store(out_seq_lens_ptr + block, 0, mask=mask) + # Padded sample slots point at query index 0 (a valid row in + # last_hidden_states) so CG replay never reads OOB. Padded + # sample idx mappings point to -1, which is ignored during + # sampling to prevent writing stale values to draft logits. + pad_start = num_reqs * num_speculative_steps + pad_end = max_num_reqs * num_speculative_steps + for i in range(pad_start, pad_end, BLOCK_SIZE): + block = i + tl.arange(0, BLOCK_SIZE) + mask = block < pad_end + tl.store(out_sample_indices_ptr + block, 0, mask=mask) + tl.store(out_sample_pos_ptr + block, 0, mask=mask) + tl.store(out_sample_idx_mapping_ptr + block, -1, mask=mask) + # Pad query slot mappings past num_query_tokens with PAD so the + # captured CG sees PAD slots (no K/V write) for replay sizes + # larger than the current request count. + q_pad_start = num_reqs * num_query_per_req + for i in range(q_pad_start, max_num_tokens, BLOCK_SIZE): + block = i + tl.arange(0, BLOCK_SIZE) + mask = block < max_num_tokens + tl.store(out_query_slot_mapping_ptr + block, PAD_SLOT_ID, mask=mask) + + +def prepare_dflash_inputs( + input_buffers: InputBuffers, + query_slot_mapping: torch.Tensor, + context_positions: torch.Tensor, + context_slot_mapping: torch.Tensor, + sample_indices: torch.Tensor, + sample_pos: torch.Tensor, + sample_idx_mapping: torch.Tensor, + input_batch: InputBatch, + # [num_reqs] + num_sampled: torch.Tensor, + # [num_reqs] + num_rejected: torch.Tensor, + # [max_num_reqs] + last_sampled: torch.Tensor, + # [max_num_reqs] + next_prefill_tokens: torch.Tensor, + # [max_num_reqs, max_num_blocks] + block_table: torch.Tensor, + block_size: int, + parallel_drafting_token_id: int, + num_query_per_req: int, + num_speculative_steps: int, + max_num_reqs: int, + max_num_tokens: int, + max_model_len: int, + sample_from_anchor: bool = False, +) -> None: + num_reqs = input_batch.num_reqs + assert num_reqs > 0 + # Cover the longest possible per-request span (ctx + query). Use the max + # per-request query length, not the total token count across the batch. + max_target_query_len = int(input_batch.num_scheduled_tokens.max()) + max_tokens_per_req = max_target_query_len + num_query_per_req + BLOCK_SIZE = min(256, triton.next_power_of_2(max(1, max_tokens_per_req))) + num_blocks = triton.cdiv(max_tokens_per_req, BLOCK_SIZE) + _prepare_dflash_inputs_kernel[(num_reqs, num_blocks)]( + input_buffers.input_ids, + input_buffers.positions, + input_buffers.query_start_loc, + input_buffers.seq_lens, + query_slot_mapping, + context_positions, + context_slot_mapping, + sample_indices, + sample_pos, + sample_idx_mapping, + input_batch.positions, + input_batch.query_start_loc, + input_batch.idx_mapping, + last_sampled, + next_prefill_tokens, + num_sampled, + num_rejected, + block_table, + block_table.stride(0), + parallel_drafting_token_id, + block_size, + num_query_per_req, + num_speculative_steps, + max_num_reqs, + max_num_tokens, + max_model_len, + SAMPLE_FROM_ANCHOR=sample_from_anchor, + PAD_SLOT_ID=PAD_SLOT_ID, + BLOCK_SIZE=BLOCK_SIZE, + ) diff --git a/vllm/v1/worker/gpu/spec_decode/dflash/utils.py b/vllm/v1/worker/gpu/spec_decode/dflash/utils.py new file mode 100644 index 000000000000..37fe693bbbdd --- /dev/null +++ b/vllm/v1/worker/gpu/spec_decode/dflash/utils.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import torch.nn as nn + +from vllm.config import ModelConfig, VllmConfig, replace +from vllm.distributed.parallel_state import get_pp_group +from vllm.model_executor.model_loader import get_model +from vllm.v1.worker.gpu.spec_decode.eagle.utils import ( + _should_share, + get_target_lm_head, +) + + +def get_dflash_causal(draft_model_config: ModelConfig) -> bool: + """Whether the DFlash draft uses causal (vs non-causal) attention.""" + dflash_config = getattr(draft_model_config.hf_config, "dflash_config", None) or {} + return dflash_config.get("causal", False) + + +def load_dflash_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module: + from vllm.compilation.backends import set_model_tag + + speculative_config = vllm_config.speculative_config + assert speculative_config is not None + draft_model_config = speculative_config.draft_model_config + # Modify the attention config so that we select an attention backend that matches + # the causal/non-causal mode of the dflash model. + causal = get_dflash_causal(draft_model_config) + draft_vllm_config = replace( + vllm_config, + attention_config=replace( + vllm_config.attention_config, + use_non_causal=not causal, + backend=speculative_config.attention_backend, + ), + ) + with set_model_tag("dflash_head"): + dflash_model = get_model( + vllm_config=draft_vllm_config, model_config=draft_model_config + ) + + target_language_model = ( + target_model.get_language_model() + if hasattr(target_model, "get_language_model") + else target_model + ) + target_inner = target_language_model.model + draft_inner = dflash_model.model + + # Skip embedding sharing under PP — each rank owns its own embedding. + if get_pp_group().world_size == 1: + target_embed = getattr(target_inner, "embed_tokens", None) or getattr( + target_inner, "embedding", None + ) + draft_embed = getattr(draft_inner, "embed_tokens", None) + if target_embed is not None and _should_share( + dflash_model, "has_own_embed_tokens", draft_embed, target_embed + ): + if draft_embed is not None: + del draft_inner.embed_tokens + draft_inner.embed_tokens = target_embed + + target_lm_head = get_target_lm_head(target_model, target_language_model) + draft_lm_head = getattr(dflash_model, "lm_head", None) + if target_lm_head is not None and _should_share( + dflash_model, "has_own_lm_head", draft_lm_head, target_lm_head + ): + if draft_lm_head is not None: + del dflash_model.lm_head + dflash_model.lm_head = target_lm_head + + return dflash_model diff --git a/vllm/v1/worker/gpu/spec_decode/dspark/__init__.py b/vllm/v1/worker/gpu/spec_decode/dspark/__init__.py new file mode 100644 index 000000000000..208f01a7cb5e --- /dev/null +++ b/vllm/v1/worker/gpu/spec_decode/dspark/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project diff --git a/vllm/v1/worker/gpu/spec_decode/dspark/speculator.py b/vllm/v1/worker/gpu/spec_decode/dspark/speculator.py new file mode 100644 index 000000000000..0236017cf2fc --- /dev/null +++ b/vllm/v1/worker/gpu/spec_decode/dspark/speculator.py @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""DSpark speculator: semi-autoregressive parallel drafting. + +DSpark drafts a block of ``num_speculative_tokens`` tokens in one parallel pass +(reusing the DFlash machinery: context-KV precompute + a query-block forward), +then injects intra-block dependency with a lightweight sequential Markov head. + +Differences from DFlash: + * Anchor-as-first-prediction: each request emits exactly ``N = + num_speculative_tokens`` query tokens (anchor + N-1 noise), NOT ``1 + N``. + Every query position is a prediction (the anchor predicts the first draft + token), so we sample at all N positions and ``sample_pos = query_pos + 1`` + (standard next-token), whereas DFlash's masks sit AT the predicted position. + This is the ``sample_from_anchor`` path in the shared prepare-inputs kernel. + Speculators-format checkpoints instead use the DFlash ``1 + N`` fill-in + layout (anchor is the bonus token). + * Sequential Markov sampling: instead of DFlash's single parallel sample, we + sample left-to-right, adding a prefix-dependent Markov bias derived from the + previously sampled token at each step. + +CUDA graphs (FULL, mirroring DFlash) cover the whole draft step: the parallel +backbone forward AND the sequential Markov sampling. +""" + +from typing import Any + +import torch + +from vllm.config import VllmConfig +from vllm.config.compilation import CUDAGraphMode +from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample +from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator +from vllm.v1.worker.gpu.spec_decode.dspark.utils import load_dspark_model + + +class DSparkSpeculator(DFlashSpeculator): + _speculator_name = "DSpark" + + def __init__(self, vllm_config: VllmConfig, device: torch.device): + super().__init__(vllm_config, device) + + # Anchor-as-first (N slots) unless the checkpoint uses the 1+N fill-in + # block, where the anchor is a separate bonus token. + self.sample_from_anchor = not getattr( + self.draft_model_config.hf_config, "dspark_bonus_anchor", False + ) + if self.sample_from_anchor: + self.num_query_per_req = self.num_speculative_steps + else: + self.num_query_per_req = 1 + self.num_speculative_steps + + # DSpark consumes mean-pooled target aux hidden states at the target + # layers, combined to hidden_size via main_proj. Store that combined + # main_x (hidden_size wide). DSpark does not use the same pre-allocated buffer + # that DeepSeek-V4's MTP uses. + draft_hidden = self.draft_model_config.get_hidden_size() + self.hidden_states = torch.zeros( + self.max_num_tokens, draft_hidden, dtype=self.dtype, device=device + ) + + self.dflash_causal = False + + self._step_cols = torch.arange( + self.num_speculative_steps, dtype=torch.int32, device=device + ) + + self._anchor_idx = ( + torch.arange(self.max_num_reqs, dtype=torch.int64, device=device) + * self.num_query_per_req + ) + + # Reduced-vocab probabilistic drafting only; set in load_draft_model. + self._d2t_scatter_index: torch.Tensor | None = None + self._draft_scatter_buf: torch.Tensor | None = None + + def load_draft_model( + self, + target_model: torch.nn.Module, + target_attn_layer_names: set[str], + ) -> torch.nn.Module: + model = load_dspark_model(target_model, self.vllm_config) + # Reduced draft vocab: probabilistic rejection sampling indexes draft + # logits by target id, so precompute the draft->target column map and a + # scratch buffer to scatter logits into target vocab before sampling. + if self.draft_logits is not None and model.draft_id_to_target_id is not None: + d2t = model.draft_id_to_target_id + self._d2t_scatter_index = ( + torch.arange(d2t.shape[0], device=d2t.device) + d2t + ) + # -inf once; the per-step scatter overwrites the draft->target + # columns. Kept separate from draft_logits to avoid aliasing. + self._draft_scatter_buf = torch.full( + (self.max_num_reqs, self.vocab_size), + float("-inf"), + dtype=self.draft_logits.dtype, + device=self.device, + ) + return model + + def _sample_sequential(self, num_reqs: int, head_hidden: torch.Tensor) -> None: + # Sequential Markov sampling over the backbone's output hidden states. + n_spec = self.num_speculative_steps + num_sample = num_reqs * n_spec + # Per-(req, position) head hidden, ordered (req, step). + sample_hidden = head_hidden[self.sample_indices[:num_sample]] + # Draft-vocab logits; sampled ids are remapped to target vocab below. + base_logits = self.model.compute_draft_logits(sample_hidden) + vocab_size = base_logits.shape[-1] + base_logits = base_logits.view(num_reqs, n_spec, vocab_size) + + idx_map = self.sample_idx_mapping[:num_sample].view(num_reqs, n_spec) + sample_pos = self.sample_pos[:num_sample].view(num_reqs, n_spec) + + # Anchor (bonus) token per request = the input id at query offset 0, + # read via the precomputed persistent index (fixed buffer for capture). + prev = self.input_buffers.input_ids[self._anchor_idx[:num_reqs]] + + for i in range(n_spec): + # Sequential stage: Markov bias from the previously sampled token. + markov_embed = self.model.markov_embed(prev) + bias = self.model.markov_bias(markov_embed) + logits_i = base_logits[:, i] + bias + if self.draft_logits is not None: + # Probabilistic: sample in target vocab (a reduced draft vocab is + # scattered into its target columns; full vocab is already there). + if self._d2t_scatter_index is not None: + assert self._draft_scatter_buf is not None + buf = self._draft_scatter_buf[:num_reqs] + buf.index_copy_(1, self._d2t_scatter_index, logits_i.to(buf.dtype)) + logits_i = buf + # sample_pos is the predicted token's position Q; the target + # verifies it with the predecessor's Gumbel key (Q-1). Pass Q-1. + draft_sampled_i = gumbel_sample( + logits_i, + idx_map[:, i], + self.temperature, + self.seeds, + sample_pos[:, i] - 1, + apply_temperature=True, + output_processed_logits=self.draft_logits, + output_processed_logits_col=self._step_cols[i], + use_fp64=self.use_fp64_gumbel, + ) + else: + draft_sampled_i = self.model.map_draft_to_target( + logits_i.argmax(dim=-1) + ) + self.draft_tokens[:num_reqs, i] = draft_sampled_i + prev = draft_sampled_i + + def _generate_draft( + self, + num_reqs: int, + num_tokens_padded: int, + attn_metadata: dict[str, Any] | None, + slot_mappings: dict[str, torch.Tensor] | None, + num_tokens_across_dp: torch.Tensor | None, + cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE, + ) -> None: + # Full draft step (captured under CUDA graph): parallel backbone forward + # then sequential Markov sampling over its hidden state outputs. + head_hidden = self._run_model( + num_tokens_padded, + attn_metadata, + slot_mappings, + num_tokens_across_dp, + cudagraph_runtime_mode, + ) + self._sample_sequential(num_reqs, head_hidden) diff --git a/vllm/v1/worker/gpu/spec_decode/dspark/utils.py b/vllm/v1/worker/gpu/spec_decode/dspark/utils.py new file mode 100644 index 000000000000..08ff30e5fdfd --- /dev/null +++ b/vllm/v1/worker/gpu/spec_decode/dspark/utils.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import torch.nn as nn + +from vllm.config import VllmConfig, replace +from vllm.distributed.parallel_state import get_pp_group +from vllm.model_executor.model_loader import get_model +from vllm.v1.worker.gpu.spec_decode.eagle.utils import ( + _should_share, + get_target_lm_head, +) + + +def load_dspark_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module: + speculative_config = vllm_config.speculative_config + assert speculative_config is not None + draft_model_config = speculative_config.draft_model_config + + from vllm.compilation.backends import set_model_tag + + # DSpark uses non-causal attention. + causal = False + draft_vllm_config = replace( + vllm_config, + attention_config=replace( + vllm_config.attention_config, + use_non_causal=not causal, + backend=speculative_config.attention_backend, + ), + ) + + with set_model_tag("dspark_head"): + draft_model = get_model( + vllm_config=draft_vllm_config, model_config=draft_model_config + ) + + if get_pp_group().world_size != 1: + raise NotImplementedError("DSpark does not support pipeline parallelism.") + + target_language_model = ( + target_model.get_language_model() + if hasattr(target_model, "get_language_model") + else target_model + ) + target_inner = target_language_model.model + draft_inner = draft_model.model + + target_embed = getattr(target_inner, "embed_tokens", None) + draft_embed = getattr(draft_inner, "embed_tokens", None) + if target_embed is not None and _should_share( + draft_model, "has_own_embed_tokens", draft_embed, target_embed + ): + if draft_embed is not None: + del draft_inner.embed_tokens + draft_inner.embed_tokens = target_embed + + target_lm_head = get_target_lm_head(target_model, target_language_model) + draft_lm_head = getattr(draft_model, "lm_head", None) + if target_lm_head is not None and _should_share( + draft_model, "has_own_lm_head", draft_lm_head, target_lm_head + ): + if draft_lm_head is not None: + del draft_model.lm_head + draft_model.lm_head = target_lm_head + + return draft_model diff --git a/vllm/v1/worker/gpu/spec_decode/eagle/eagle3_utils.py b/vllm/v1/worker/gpu/spec_decode/eagle/eagle3_utils.py index d805c8858215..66d0ba8b43c3 100644 --- a/vllm/v1/worker/gpu/spec_decode/eagle/eagle3_utils.py +++ b/vllm/v1/worker/gpu/spec_decode/eagle/eagle3_utils.py @@ -38,9 +38,21 @@ def get_eagle3_aux_layers_from_config( if not (spec_config and spec_config.draft_model_config): return None hf_config = spec_config.draft_model_config.hf_config - if not hasattr(hf_config, "eagle_aux_hidden_state_layer_ids"): - return None - layer_ids = hf_config.eagle_aux_hidden_state_layer_ids + layer_ids = getattr(hf_config, "eagle_aux_hidden_state_layer_ids", None) + if not layer_ids: + dflash_config = getattr(hf_config, "dflash_config", None) + if dflash_config and isinstance(dflash_config, dict): + # Add 1 to convert DFlash's aux layer id semantics + layer_ids = [i + 1 for i in (dflash_config.get("target_layer_ids") or [])] + if not layer_ids: + dspark_layer_ids = getattr(hf_config, "dspark_target_layer_ids", None) + if dspark_layer_ids: + layer_ids = [i + 1 for i in dspark_layer_ids] + if not layer_ids: + # Dense DSpark (e.g. Qwen3) also uses different aux layer semantics. + target_layer_ids = getattr(hf_config, "target_layer_ids", None) + if target_layer_ids: + layer_ids = [i + 1 for i in target_layer_ids] if layer_ids and isinstance(layer_ids, (list, tuple)): return tuple(layer_ids) return None diff --git a/vllm/v1/worker/gpu/spec_decode/eagle/utils.py b/vllm/v1/worker/gpu/spec_decode/eagle/utils.py index fcbfc5569ef3..bdd588e5786d 100644 --- a/vllm/v1/worker/gpu/spec_decode/eagle/utils.py +++ b/vllm/v1/worker/gpu/spec_decode/eagle/utils.py @@ -5,6 +5,7 @@ from vllm.config import VllmConfig from vllm.distributed.parallel_state import get_pp_group +from vllm.lora.layers.base import BaseLayerWithLoRA from vllm.model_executor.model_loader import get_model @@ -19,11 +20,19 @@ def _should_share(eagle: nn.Module, flag: str, draft, target) -> bool: # Use the faster GPU path when there is plenty of headroom; # otherwise compare on CPU. w = draft.weight - if w.is_cuda and torch.cuda.mem_get_info(w.device)[0] < w.numel() * 2: + if w.is_cuda and torch.accelerator.get_memory_info(w.device)[0] < w.numel() * 2: return torch.equal(w.cpu(), target.weight.cpu()) return torch.equal(w, target.weight) +def get_target_lm_head(target_model: nn.Module, target_language_model: nn.Module): + """The target's lm_head — from get_language_model() for + *ForConditionalGeneration targets, else the top-level module.""" + return getattr(target_language_model, "lm_head", None) or getattr( + target_model, "lm_head", None + ) + + def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Module: from vllm.compilation.backends import set_model_tag @@ -48,6 +57,13 @@ def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mod target_embed = getattr(target_inner, "embed_tokens", None) or getattr( target_inner, "embedding", None ) + # If the target's embedding is LoRA-wrapped, share the underlying base + # layer. The draft is not part of the LoRA adapter; sharing the wrapper + # would make the draft run the LoRA embedding kernel with the target's + # punica metadata (sized for the target's token count), causing an + # out-of-bounds GPU access during multi-step draft decode. + if isinstance(target_embed, BaseLayerWithLoRA): + target_embed = target_embed.base_layer draft_embed = getattr(draft_inner, "embed_tokens", None) if target_embed is not None and _should_share( eagle_model, "has_own_embed_tokens", draft_embed, target_embed @@ -56,7 +72,7 @@ def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mod del draft_inner.embed_tokens draft_inner.embed_tokens = target_embed - target_lm_head = getattr(target_model, "lm_head", None) + target_lm_head = get_target_lm_head(target_model, target_language_model) draft_lm_head = getattr(eagle_model, "lm_head", None) if target_lm_head is not None and _should_share( eagle_model, "has_own_lm_head", draft_lm_head, target_lm_head @@ -76,10 +92,15 @@ def load_eagle_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mod del sh.head sh.head = target_lm_head - # MTP also shares a topk_indices_buffer between target and draft. + # MTP shares topk_indices_buffer with the target model. We update + # every module in the draft that holds a buffer reference so that + # the per-layer indexer and sparse-attention backends all point to + # the target's buffer. if hasattr(target_inner, "topk_indices_buffer"): - if hasattr(draft_inner, "topk_indices_buffer"): - del draft_inner.topk_indices_buffer - draft_inner.topk_indices_buffer = target_inner.topk_indices_buffer + target_buffer = target_inner.topk_indices_buffer + if target_buffer is not None: + for _, module in draft_inner.named_modules(): + if hasattr(module, "topk_indices_buffer"): + module.topk_indices_buffer = target_buffer return eagle_model diff --git a/vllm/v1/worker/gpu/spec_decode/gemma4/speculator.py b/vllm/v1/worker/gpu/spec_decode/gemma4/speculator.py index fcbea5d1012d..dfa2c680109d 100644 --- a/vllm/v1/worker/gpu/spec_decode/gemma4/speculator.py +++ b/vllm/v1/worker/gpu/spec_decode/gemma4/speculator.py @@ -30,13 +30,6 @@ def advance_draft_positions(self) -> bool: # No new KV slots are written, so positions and seq_lens stay fixed. return False - @property - def model_returns_tuple(self) -> bool: - # forward() returns (draft_hidden_states, backbone_hidden_states). - # The proposer uses draft_hidden_states for compute_logits and - # backbone_hidden_states for the hidden-state feedback buffer. - return True - def load_draft_model( self, target_model: nn.Module, diff --git a/vllm/v1/worker/gpu/spec_decode/mtp/speculator.py b/vllm/v1/worker/gpu/spec_decode/mtp/speculator.py index e6abb0be83ae..4b9354f23e70 100644 --- a/vllm/v1/worker/gpu/spec_decode/mtp/speculator.py +++ b/vllm/v1/worker/gpu/spec_decode/mtp/speculator.py @@ -10,10 +10,6 @@ class MTPSpeculator(AutoRegressiveSpeculator): - @property - def model_returns_tuple(self) -> bool: - return False - def load_draft_model( self, target_model: nn.Module, diff --git a/vllm/v1/worker/gpu/spec_decode/rejection_sampler.py b/vllm/v1/worker/gpu/spec_decode/rejection_sampler.py index 1fe079a43e77..c56252d55d7a 100644 --- a/vllm/v1/worker/gpu/spec_decode/rejection_sampler.py +++ b/vllm/v1/worker/gpu/spec_decode/rejection_sampler.py @@ -6,7 +6,10 @@ from vllm.triton_utils import tl, triton from vllm.v1.outputs import LogprobsTensors from vllm.v1.spec_decode.utils import unconditional_to_conditional_rates -from vllm.v1.worker.gpu.input_batch import InputBatch +from vllm.v1.worker.gpu.input_batch import ( + InputBatch, + get_num_sampled_and_rejected, +) from vllm.v1.worker.gpu.metrics.logits import get_num_nans from vllm.v1.worker.gpu.sample.logprob import compute_topk_logprobs from vllm.v1.worker.gpu.sample.output import SamplerOutput @@ -46,9 +49,10 @@ def __init__( ): self.sampler = sampler self.num_speculative_steps = spec_config.num_speculative_tokens - self.rejection_sample_method = spec_config.rejection_sample_method + rejection_sample_method = spec_config.rejection_sample_method + self.use_block_verification: bool = False self.synthetic_conditional_rates: torch.Tensor | None = None - if self.rejection_sample_method == "synthetic": + if rejection_sample_method == "synthetic": assert spec_config.synthetic_acceptance_rates is not None self.synthetic_conditional_rates = torch.tensor( unconditional_to_conditional_rates( @@ -57,6 +61,8 @@ def __init__( dtype=torch.float32, device=device, ) + elif rejection_sample_method == "block": + self.use_block_verification = True def _get_logprobs_tensors( self, @@ -126,6 +132,7 @@ def __call__( self.num_speculative_steps, self.synthetic_conditional_rates, use_fp64=self.sampler.use_fp64_gumbel, + use_block_verification=self.use_block_verification, ) logprobs_tensors = self._get_logprobs_tensors( input_batch, @@ -136,9 +143,18 @@ def __call__( else logits, ) + num_sampled, num_rejected = get_num_sampled_and_rejected( + num_sampled, + input_batch.seq_lens, + input_batch.cu_num_logits, + input_batch.idx_mapping, + self.sampler.req_states.prefill_len.gpu, + ) + return SamplerOutput( sampled_token_ids=sampled, logprobs_tensors=logprobs_tensors, num_nans=num_nans, num_sampled=num_sampled, + num_rejected=num_rejected, ) diff --git a/vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py b/vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py index 0cfbdf4182be..f700ea846cfd 100644 --- a/vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py +++ b/vllm/v1/worker/gpu/spec_decode/rejection_sampler_utils.py @@ -2,23 +2,23 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import torch -from vllm.triton_utils import tl, triton -from vllm.v1.worker.gpu.sample.gumbel import gumbel_block_argmax, tl_rand64 +from vllm.triton_utils import tl, tldevice, triton +from vllm.v1.worker.gpu.sample.gumbel import gumbel_block_argmax, tl_rand32 @triton.jit -def _compute_block_max_and_sumexp(logits): - block_max = tl.max(logits, axis=0) - block_sumexp = tl.where( - block_max > float("-inf"), - tl.sum(tl.exp(logits - block_max)), +def _compute_max_and_sumexp(logits): + max = tl.max(logits, axis=0) + sumexp = tl.where( + max > float("-inf"), + tl.sum(tl.exp(logits - max)), 0.0, ) - return block_max, block_sumexp + return max, sumexp @triton.jit -def _compute_global_lse( +def _compute_global_logsumexp( local_max_ptr, local_max_stride, local_sumexp_ptr, @@ -45,7 +45,146 @@ def _compute_global_lse( @triton.jit -def _compute_block_stats_kernel( +def _compute_global_residual_mass( + local_residual_mass_ptr, + local_residual_mass_stride, + prefix_joint_ratio, + target_logits_ptr, + target_logits_stride, + target_local_max_ptr, + target_local_max_stride, + target_local_sumexp_ptr, + target_local_sumexp_stride, + draft_sampled_ptr, + logit_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS: tl.constexpr, + HAS_DRAFT_LOGITS: tl.constexpr, +): + if HAS_DRAFT_LOGITS: + blocks = tl.arange(0, PADDED_VOCAB_NUM_BLOCKS) + mask = blocks < vocab_num_blocks + partials = tl.load( + local_residual_mass_ptr + logit_idx * local_residual_mass_stride + blocks, + mask=mask, + other=0.0, + ) + return tl.sum(partials, axis=0) + else: + # One-hot draft. M_s is a point mass at this draft token + # so the residual mass reduces to the closed form: + # p * (1 - M_b(draft_token)). + draft_token = tl.load(draft_sampled_ptr + logit_idx + 1).to(tl.int64) + target_lse = _compute_global_logsumexp( + target_local_max_ptr, + target_local_max_stride, + target_local_sumexp_ptr, + target_local_sumexp_stride, + logit_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS, + ) + target_logit = tl.load( + target_logits_ptr + logit_idx * target_logits_stride + draft_token, + ).to(tl.float32) + m_b = tl.exp(target_logit - target_lse) + return prefix_joint_ratio * (1.0 - m_b) + + +@triton.jit +def _compute_global_target_argmax( + target_local_max_ptr, + target_local_max_stride, + target_local_argmax_ptr, + target_local_argmax_stride, + logit_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS: tl.constexpr, +): + blocks = tl.arange(0, PADDED_VOCAB_NUM_BLOCKS) + blocks_mask = blocks < vocab_num_blocks + local_max = tl.load( + target_local_max_ptr + logit_idx * target_local_max_stride + blocks, + mask=blocks_mask, + other=float("-inf"), + ) + max_block_idx = tl.argmax(local_max, axis=0) + return tl.load( + target_local_argmax_ptr + logit_idx * target_local_argmax_stride + max_block_idx + ).to(tl.int64) + + +@triton.jit +def _compute_global_logprobs_and_logsumexp( + token, + mask, + logit_idx, + req_state_idx, + draft_step, + # [num_logits, V] + target_logits_ptr, + target_logits_stride, + # [num_logits, num_blocks] + target_local_max_ptr, + target_local_max_stride, + target_local_sumexp_ptr, + target_local_sumexp_stride, + # [max_num_reqs, num_speculative_steps, V] + draft_logits_ptr, + draft_logits_stride_0, + draft_logits_stride_1, + # [num_logits, num_blocks] + draft_local_max_ptr, + draft_local_max_stride, + draft_local_sumexp_ptr, + draft_local_sumexp_stride, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS: tl.constexpr, + HAS_DRAFT_LOGITS: tl.constexpr, +): + target_logit = tl.load( + target_logits_ptr + logit_idx * target_logits_stride + token, + mask=mask, + other=float("-inf"), + ).to(tl.float32) + target_lse = _compute_global_logsumexp( + target_local_max_ptr, + target_local_max_stride, + target_local_sumexp_ptr, + target_local_sumexp_stride, + logit_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS, + ) + target_log_prob = target_logit - target_lse + if HAS_DRAFT_LOGITS: + draft_logit = tl.load( + draft_logits_ptr + + req_state_idx * draft_logits_stride_0 + + draft_step * draft_logits_stride_1 + + token, + mask=mask, + other=float("-inf"), + ).to(tl.float32) + draft_lse = _compute_global_logsumexp( + draft_local_max_ptr, + draft_local_max_stride, + draft_local_sumexp_ptr, + draft_local_sumexp_stride, + logit_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS, + ) + draft_log_prob = draft_logit - draft_lse + else: + # One-hot draft: q(token) = 1, log_q = 0. + draft_log_prob = 0.0 + draft_lse = 0.0 + return target_log_prob, draft_log_prob, target_lse, draft_lse + + +@triton.jit +def _compute_local_logits_stats_kernel( # [num_logits, num_blocks] target_local_argmax_ptr, target_local_argmax_stride, @@ -79,14 +218,14 @@ def _compute_block_stats_kernel( BLOCK_SIZE: tl.constexpr, HAS_DRAFT_LOGITS: tl.constexpr, ): - logit_idx = tl.program_id(0) + logit_idx = tl.program_id(0).to(tl.int64) draft_step_idx = tl.load(expanded_local_pos_ptr + logit_idx) if draft_step_idx >= num_speculative_steps: # Bonus token. Max/argmax and summed exponentials are not needed. return - req_state_idx = tl.load(expanded_idx_mapping_ptr + logit_idx) + req_state_idx = tl.load(expanded_idx_mapping_ptr + logit_idx).to(tl.int64) temp = tl.load(temp_ptr + req_state_idx).to(tl.float32) block_idx = tl.program_id(1) @@ -119,7 +258,7 @@ def _compute_block_stats_kernel( mask=mask, other=float("-inf"), ).to(tl.float32) - target_max, target_sumexp = _compute_block_max_and_sumexp(target_logits) + target_max, target_sumexp = _compute_max_and_sumexp(target_logits) tl.store( target_local_max_ptr + logit_idx * target_local_max_stride + block_idx, target_max, @@ -140,7 +279,7 @@ def _compute_block_stats_kernel( mask=mask, other=float("-inf"), ).to(tl.float32) - draft_max, draft_sumexp = _compute_block_max_and_sumexp(draft_logits) + draft_max, draft_sumexp = _compute_max_and_sumexp(draft_logits) tl.store( draft_local_max_ptr + logit_idx * draft_local_max_stride + block_idx, draft_max, @@ -153,6 +292,170 @@ def _compute_block_stats_kernel( ) +@triton.jit +def _compute_cumulative_log_p_kernel( + # [num_logits] + cumulative_log_p_ptr, + # [num_logits, V] + target_logits_ptr, + target_logits_stride, + # [num_logits, num_blocks] + target_local_max_ptr, + target_local_max_stride, + # [num_logits, num_blocks] + target_local_sumexp_ptr, + target_local_sumexp_stride, + # [num_logits] + draft_sampled_ptr, + # [max_num_reqs, num_speculative_steps, V] + draft_logits_ptr, + draft_logits_stride_0, + draft_logits_stride_1, + # [num_logits, num_blocks] + draft_local_max_ptr, + draft_local_max_stride, + # [num_logits, num_blocks] + draft_local_sumexp_ptr, + draft_local_sumexp_stride, + # [num_reqs + 1] + cu_num_logits_ptr, + # [num_reqs] + idx_mapping_ptr, + # [max_num_reqs] + temp_ptr, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS: tl.constexpr, + HAS_DRAFT_LOGITS: tl.constexpr, +): + req_idx = tl.program_id(0) + req_state_idx = tl.load(idx_mapping_ptr + req_idx).to(tl.int64) + start_idx = tl.load(cu_num_logits_ptr + req_idx).to(tl.int64) + end_idx = tl.load(cu_num_logits_ptr + req_idx + 1) + num_draft_tokens = end_idx - start_idx - 1 + temp = tl.load(temp_ptr + req_state_idx).to(tl.float32) + if temp == 0.0: + return + + log_p = 0.0 + for step in range(num_draft_tokens): + logit_idx = start_idx + step + draft_token = tl.load(draft_sampled_ptr + logit_idx + 1).to(tl.int64) + target_logprob, draft_logprob, _, _ = _compute_global_logprobs_and_logsumexp( + draft_token, + True, # mask + logit_idx, + req_state_idx, + step, + target_logits_ptr, + target_logits_stride, + target_local_max_ptr, + target_local_max_stride, + target_local_sumexp_ptr, + target_local_sumexp_stride, + draft_logits_ptr, + draft_logits_stride_0, + draft_logits_stride_1, + draft_local_max_ptr, + draft_local_max_stride, + draft_local_sumexp_ptr, + draft_local_sumexp_stride, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS, + HAS_DRAFT_LOGITS, + ) + log_p = tl.minimum(log_p + (target_logprob - draft_logprob), 0.0) + tl.store(cumulative_log_p_ptr + logit_idx, log_p) + + +@triton.jit +def _compute_local_residual_mass_kernel( + # [num_logits, num_blocks] + local_residual_mass_ptr, + local_residual_mass_stride, + # [num_logits] + cumulative_log_p_ptr, + # [num_logits, V] + target_logits_ptr, + target_logits_stride, + # [num_logits, num_blocks] + target_local_max_ptr, + target_local_max_stride, + # [num_logits, num_blocks] + target_local_sumexp_ptr, + target_local_sumexp_stride, + # [max_num_reqs, num_speculative_steps, V] + draft_logits_ptr, + draft_logits_stride_0, + draft_logits_stride_1, + # [num_logits, num_blocks] + draft_local_max_ptr, + draft_local_max_stride, + # [num_logits, num_blocks] + draft_local_sumexp_ptr, + draft_local_sumexp_stride, + # [num_logits] + expanded_idx_mapping_ptr, + # [num_logits] + expanded_local_pos_ptr, + # [max_num_reqs] + temp_ptr, + vocab_size, + num_speculative_steps, + vocab_num_blocks, + BLOCK_SIZE: tl.constexpr, + PADDED_VOCAB_NUM_BLOCKS: tl.constexpr, +): + logit_idx = tl.program_id(0).to(tl.int64) + draft_step_idx = tl.load(expanded_local_pos_ptr + logit_idx) + if draft_step_idx == 0 or draft_step_idx >= num_speculative_steps: + # The acceptance threshold, h, looks one position ahead and sums + # over: max(p_i * M_b(x|x_{ 0.0, residual_mass / denom, 1.0) + else: + h = prefix_joint_ratio + accepted_length = tl.where(u <= h, i + 1, accepted_length) + tl.store(sampled_ptr + req_idx * sampled_stride + i, draft_sampled) + elif accepted: + if is_greedy: # Greedy sampling. Accept IFF draft matches target argmax. # NOTE: Target argmax is stored directly so that resampling # can be skipped upon rejection. - target_blocks = tl.arange(0, PADDED_VOCAB_NUM_BLOCKS) - target_blocks_mask = target_blocks < vocab_num_blocks - target_local_max = tl.load( - target_local_max_ptr - + logit_idx * target_local_max_stride - + target_blocks, - mask=target_blocks_mask, - other=float("-inf"), + target_argmax = _compute_global_target_argmax( + target_local_max_ptr, + target_local_max_stride, + target_local_argmax_ptr, + target_local_argmax_stride, + logit_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS, ) - max_target_block_idx = tl.argmax(target_local_max, axis=0) - target_argmax = tl.load( - target_local_argmax_ptr - + logit_idx * target_local_argmax_stride - + max_target_block_idx - ).to(tl.int64) - if SYNTHETIC_MODE: - pos = tl.load(pos_ptr + logit_idx) - u = tl_rand64(seed, pos, includes_zero=False) rate = tl.load(synthetic_conditional_rates_ptr + i) - accepted &= u < rate + # -1 is used for padded draft token ids that should be rejected. + accepted &= (u < rate) & (draft_sampled >= 0) else: accepted &= target_argmax == draft_sampled tl.store( @@ -253,52 +585,70 @@ def _rejection_kernel( draft_sampled if accepted else target_argmax, ) else: - target_logit = tl.load( - target_logits_ptr + logit_idx * target_logits_stride + draft_sampled - ).to(tl.float32) - target_lse = _compute_global_lse( - target_local_max_ptr, - target_local_max_stride, - target_local_sumexp_ptr, - target_local_sumexp_stride, - logit_idx, - vocab_num_blocks, - PADDED_VOCAB_NUM_BLOCKS, - ) - target_log_prob = target_logit - target_lse - pos = tl.load(pos_ptr + logit_idx) - u = tl_rand64(seed, pos, includes_zero=False) - if HAS_DRAFT_LOGITS: - draft_logit = tl.load( - draft_logits_ptr - + req_state_idx * draft_logits_stride_0 - + i * draft_logits_stride_1 - + draft_sampled - ).to(tl.float32) - draft_lse = _compute_global_lse( + # Speculative decoding (Leviathan et al., 2023): https://arxiv.org/abs/2211.17192 + # -1 is used for padded draft token ids that should be rejected. + is_valid_draft = draft_sampled >= 0 + # Avoid possible OOB ptr access. + draft_sampled = tl.maximum(0, draft_sampled) + target_logprob, draft_logprob, target_lse, draft_lse = ( + _compute_global_logprobs_and_logsumexp( + draft_sampled, + True, # mask + logit_idx, + req_state_idx, + i, + target_logits_ptr, + target_logits_stride, + target_local_max_ptr, + target_local_max_stride, + target_local_sumexp_ptr, + target_local_sumexp_stride, + draft_logits_ptr, + draft_logits_stride_0, + draft_logits_stride_1, draft_local_max_ptr, draft_local_max_stride, draft_local_sumexp_ptr, draft_local_sumexp_stride, - logit_idx, vocab_num_blocks, PADDED_VOCAB_NUM_BLOCKS, + HAS_DRAFT_LOGITS, ) - draft_log_prob = draft_logit - draft_lse - else: - # One-hot draft: q(draft_token) = 1, log_q = 0. - draft_log_prob = 0 - + ) if SYNTHETIC_MODE: rate = tl.load(synthetic_conditional_rates_ptr + i) accepted &= u < rate else: # Probability ratio test: p(x) > u * q(x) # Equivalent log form: log_p(x) > log(u) + log_q(x) - accepted &= target_log_prob > tl.log(u) + draft_log_prob + accepted &= target_logprob > tl.log(u) + draft_logprob + accepted &= is_valid_draft tl.store(sampled_ptr + req_idx * sampled_stride + i, draft_sampled) - rejected_step += accepted - tl.store(rejected_steps_ptr + req_idx, rejected_step) + accepted_length += accepted + tl.store(rejected_steps_ptr + req_idx, accepted_length) + if USE_BLOCK_VERIFICATION and not is_greedy and accepted_length < num_draft_tokens: + # Compute the target and draft log exponential sums for the + # rejected token. + rejected_idx = start_idx + accepted_length + target_lse = _compute_global_logsumexp( + target_local_max_ptr, + target_local_max_stride, + target_local_sumexp_ptr, + target_local_sumexp_stride, + rejected_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS, + ) + if HAS_DRAFT_LOGITS: + draft_lse = _compute_global_logsumexp( + draft_local_max_ptr, + draft_local_max_stride, + draft_local_sumexp_ptr, + draft_local_sumexp_stride, + rejected_idx, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS, + ) tl.store(target_rejected_logsumexp_ptr + req_idx, target_lse) tl.store(draft_rejected_logsumexp_ptr + req_idx, draft_lse) @@ -336,17 +686,20 @@ def _resample_kernel( seed_ptr, # [num_logits] pos_ptr, + # [num_logits] + cumulative_log_p_ptr, vocab_size, BLOCK_SIZE: tl.constexpr, HAS_DRAFT_LOGITS: tl.constexpr, USE_FP64: tl.constexpr, + USE_BLOCK_VERIFICATION: tl.constexpr, ): req_idx = tl.program_id(0) resample_idx = tl.load(rejected_step_ptr + req_idx) - start_idx = tl.load(cu_num_logits_ptr + req_idx) + start_idx = tl.load(cu_num_logits_ptr + req_idx).to(tl.int64) end_idx = tl.load(cu_num_logits_ptr + req_idx + 1) resample_token_idx = start_idx + resample_idx - req_state_idx = tl.load(expanded_idx_mapping_ptr + resample_token_idx) + req_state_idx = tl.load(expanded_idx_mapping_ptr + resample_token_idx).to(tl.int64) temp = tl.load(temp_ptr + req_state_idx).to(tl.float32) is_bonus = resample_token_idx == end_idx - 1 @@ -380,20 +733,38 @@ def _resample_kernel( target_lse = tl.load(target_rejected_logsumexp_ptr + req_idx) draft_lse = tl.load(draft_rejected_logsumexp_ptr + req_idx) target_log_probs = target_logits - target_lse + if USE_BLOCK_VERIFICATION: + # Block residual is: + # max(p_tau * M_b(x) - M_s(x), 0) / Z. + # Scale the target logprobs by log(p_tau). p_0 = 1, so skip + # shifting when nothing was accepted (tau == 0). + log_p_tau = 0.0 + if resample_idx > 0: + log_p_tau = tl.load(cumulative_log_p_ptr + resample_token_idx - 1).to( + tl.float32 + ) + target_log_probs += log_p_tau draft_log_probs = draft_logits - draft_lse - # Compute the residual: max(p(x) - q(x), 0) - # Equivalent log form: log(max(exp(log_p(x)) - exp(log_q(x)), 0)) + # Compute the residual: + # r(x) = max(p(x) - q(x), 0) + # Gumbel sampling needs logits, so we compute it in log space: + # log(r(x)) = log(max(exp(log_p(x)) - exp(log_q(x)), 0)) # The more numerically stable form is: - # log(max(exp(a) - exp(b), 0)) = a + log(max(1 - exp(b - a), 0)) + # log(max(exp(a) - exp(b), 0)) = a + log(max(1 - exp(b - a), 0)) ratio = tl.exp(draft_log_probs - target_log_probs) residual_logits = tl.where( ratio < 1.0, - target_log_probs + tl.log(1 - ratio), + target_log_probs + tldevice.log1p(-ratio), float("-inf"), ).to(tl.float32) else: # One-hot draft. The residual is just the target distribution with # the rejected draft token probability zeroed out. + # NOTE: During block verification, the residual becomes: + # 0 if x == rejected_draft_token + # p_tau * M_b(x) / Z otherwise + # Therefore p_tau is a constant that cancels under normalization, + # and does not need to be applied. rejected_draft_token = tl.load(draft_sampled_ptr + resample_token_idx + 1) residual_logits = tl.where( block != rejected_draft_token, @@ -515,18 +886,20 @@ def rejection_sample( # [num_speculative_steps] synthetic_conditional_rates: torch.Tensor | None = None, use_fp64: bool = False, + use_block_verification: bool = False, ) -> tuple[torch.Tensor, torch.Tensor]: num_reqs = cu_num_logits.shape[0] - 1 num_logits, vocab_size = target_logits.shape - has_draft_logits = draft_logits is not None - - if draft_logits is None: - # When draft_logits is None, create a dummy tensor so that Triton - # kernel signatures receive valid pointers/strides. The kernels - # will never read from it when HAS_DRAFT_LOGITS=False. - draft_logits = target_logits.new_empty(1, 1, 1) + draft_logits_stride_0 = 0 + draft_logits_stride_1 = 0 + if has_draft_logits := draft_logits is not None: + draft_logits_stride_0 = draft_logits.stride(0) + draft_logits_stride_1 = draft_logits.stride(1) + # In some cases (e.g. MiMo v2.5 Pro + DFlash) the target model's + # vocab size is larger than the draft's due to padding. + vocab_size = min(vocab_size, draft_logits.size(-1)) - # Compute the block-level logits stats, such as target argmax + # Compute the per-vocab-block logits stats, such as target argmax # (for greedy requests), and target max + softmax exponential # (for non-greedy requests). VOCAB_BLOCK_SIZE = 8192 @@ -547,7 +920,7 @@ def rejection_sample( draft_local_sumexp = target_logits.new_empty( num_logits, vocab_num_blocks, dtype=torch.float32 ) - _compute_block_stats_kernel[(num_logits, vocab_num_blocks)]( + _compute_local_logits_stats_kernel[(num_logits, vocab_num_blocks)]( target_local_argmax, target_local_argmax.stride(0), target_local_max, @@ -561,8 +934,8 @@ def rejection_sample( target_logits, target_logits.stride(0), draft_logits, - draft_logits.stride(0), - draft_logits.stride(1), + draft_logits_stride_0, + draft_logits_stride_1, expanded_idx_mapping, expanded_local_pos, temperature, @@ -572,6 +945,82 @@ def rejection_sample( HAS_DRAFT_LOGITS=has_draft_logits, ) + # Precompute the running joint ratio and residual mass for block + # verification. + if use_block_verification: + assert synthetic_conditional_rates is None, ( + "Block verification is incompatible with synthetic acceptance rates." + ) + + # Compute the log of the running joint ratio, p_i. + # cumulative_log_p[start + i] = log(p_{i+1}), the cumulative ratio after + # the (i+1)-th draft token. + cumulative_log_p = target_logits.new_empty(num_logits, dtype=torch.float32) + _compute_cumulative_log_p_kernel[(num_reqs,)]( + cumulative_log_p, + target_logits, + target_logits.stride(0), + target_local_max, + target_local_max.stride(0), + target_local_sumexp, + target_local_sumexp.stride(0), + draft_sampled, + draft_logits, + draft_logits_stride_0, + draft_logits_stride_1, + draft_local_max, + draft_local_max.stride(0), + draft_local_sumexp, + draft_local_sumexp.stride(0), + cu_num_logits, + idx_mapping, + temperature, + vocab_num_blocks, + PADDED_VOCAB_NUM_BLOCKS=padded_vocab_num_blocks, + HAS_DRAFT_LOGITS=has_draft_logits, + num_warps=1, + ) + + # Compute the per-vocab-block partials of the residual mass, later reduced + # to the total by _compute_global_residual_mass. Only launched for full + # draft logits distributions. One-hot drafts used a closed-form residual + # mass instead. + if has_draft_logits: + local_residual_mass = target_logits.new_empty( + num_logits, vocab_num_blocks, dtype=torch.float32 + ) + _compute_local_residual_mass_kernel[(num_logits, vocab_num_blocks)]( + local_residual_mass, + local_residual_mass.stride(0), + cumulative_log_p, + target_logits, + target_logits.stride(0), + target_local_max, + target_local_max.stride(0), + target_local_sumexp, + target_local_sumexp.stride(0), + draft_logits, + draft_logits_stride_0, + draft_logits_stride_1, + draft_local_max, + draft_local_max.stride(0), + draft_local_sumexp, + draft_local_sumexp.stride(0), + expanded_idx_mapping, + expanded_local_pos, + temperature, + vocab_size, + num_speculative_steps, + vocab_num_blocks, + BLOCK_SIZE=VOCAB_BLOCK_SIZE, + PADDED_VOCAB_NUM_BLOCKS=padded_vocab_num_blocks, + ) + else: + local_residual_mass = None + else: + cumulative_log_p = None + local_residual_mass = None + # Sample up until the first rejected/bonus token, and store # the step. sampled = draft_sampled.new_empty( @@ -596,8 +1045,8 @@ def rejection_sample( target_local_sumexp.stride(0), draft_sampled, draft_logits, - draft_logits.stride(0), - draft_logits.stride(1), + draft_logits_stride_0, + draft_logits_stride_1, draft_local_max, draft_local_max.stride(0), draft_local_sumexp, @@ -608,10 +1057,14 @@ def rejection_sample( seed, pos, synthetic_conditional_rates, + cumulative_log_p, + local_residual_mass, + local_residual_mass.stride(0) if local_residual_mass is not None else 0, vocab_num_blocks, PADDED_VOCAB_NUM_BLOCKS=padded_vocab_num_blocks, HAS_DRAFT_LOGITS=has_draft_logits, SYNTHETIC_MODE=synthetic_conditional_rates is not None, + USE_BLOCK_VERIFICATION=use_block_verification, num_warps=1, ) @@ -636,8 +1089,8 @@ def rejection_sample( target_logits.stride(0), target_rejected_logsumexp, draft_logits, - draft_logits.stride(0), - draft_logits.stride(1), + draft_logits_stride_0, + draft_logits_stride_1, draft_rejected_logsumexp, num_sampled, cu_num_logits, @@ -646,10 +1099,12 @@ def rejection_sample( temperature, seed, pos, + cumulative_log_p, vocab_size, BLOCK_SIZE=RESAMPLE_BLOCK_SIZE, HAS_DRAFT_LOGITS=has_draft_logits, USE_FP64=use_fp64, + USE_BLOCK_VERIFICATION=use_block_verification, ) # Insert the resampled tokens into the output sampled. diff --git a/vllm/v1/worker/gpu/spec_decode/speculator.py b/vllm/v1/worker/gpu/spec_decode/speculator.py index e8fa8af53bca..4b7d7fef410f 100644 --- a/vllm/v1/worker/gpu/spec_decode/speculator.py +++ b/vllm/v1/worker/gpu/spec_decode/speculator.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from abc import ABC, abstractmethod +from collections.abc import Mapping from typing import Any import torch @@ -8,6 +9,8 @@ from vllm.config import VllmConfig, get_layers_from_vllm_config from vllm.config.compilation import CUDAGraphMode +from vllm.distributed.eplb.eplb_state import EplbState +from vllm.logger import init_logger from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase from vllm.v1.kv_cache_interface import KVCacheConfig from vllm.v1.worker.gpu.attn_utils import ( @@ -21,6 +24,9 @@ ) from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers from vllm.v1.worker.gpu.model_states.interface import ModelState +from vllm.v1.worker.gpu.sample.gumbel import gumbel_sample + +logger = init_logger(__name__) class BaseSpeculator(ABC): @@ -94,11 +100,16 @@ def __init__(self, vllm_config: VllmConfig, device: torch.device): self.vocab_size = self.draft_model_config.get_vocab_size() self.dtype = vllm_config.model_config.dtype self.use_fp64_gumbel = vllm_config.model_config.use_fp64_gumbel + self.use_local_argmax_reduction = ( + self.speculative_config.use_local_argmax_reduction + ) # DP configuration self.dp_size = vllm_config.parallel_config.data_parallel_size self.dp_rank = vllm_config.parallel_config.data_parallel_rank + self.eplb_state: EplbState | None = None + self.input_buffers = InputBuffers( max_num_reqs=self.max_num_reqs, max_num_tokens=self.max_num_tokens, @@ -148,6 +159,7 @@ def load_model(self, target_model: nn.Module) -> None: ) self.model = self.load_draft_model(target_model, target_attn_layer_names) + self._validate_local_argmax_reduction() all_attn_layers = set[str]( get_layers_from_vllm_config( @@ -157,6 +169,18 @@ def load_model(self, target_model: nn.Module) -> None: ) self.draft_attn_layer_names = all_attn_layers - target_attn_layer_names + def set_eplb_state(self, eplb_state: EplbState) -> None: + """Inject EPLB state after construction.""" + self.eplb_state = eplb_state + + def _prepare_eplb_forward(self, num_unpadded_tokens: int) -> None: + """Call EPLB prepare_forward if EPLB is active for the draft model.""" + if self.eplb_state is not None: + self.eplb_state.prepare_forward( + self.speculative_config.draft_model_config, + num_unpadded_tokens, + ) + def set_attn( self, model_state: ModelState, @@ -178,9 +202,15 @@ def _build_draft_attn_metadata( num_reqs: int, num_reqs_padded: int, num_tokens_padded: int, + num_query_per_req: int = 1, + causal: bool | Mapping[int, bool] = True, ) -> dict[str, Any] | None: - query_start_loc_cpu = torch.clamp( - self.arange[: num_reqs_padded + 1], max=num_reqs + # Uniform query: query_start_loc[i] = min(i, num_reqs) * num_query_per_req. + # Clamp keeps the series non-decreasing past num_reqs, which some + # attention backends require. + query_start_loc_cpu = ( + torch.clamp(self.arange[: num_reqs_padded + 1], max=num_reqs) + * num_query_per_req ) block_tables = [ x[:num_reqs_padded] for x in self.block_tables.input_block_tables @@ -194,15 +224,68 @@ def _build_draft_attn_metadata( : num_reqs_padded + 1 ], query_start_loc_cpu=query_start_loc_cpu, - max_query_len=1, + max_query_len=num_query_per_req, seq_lens=self.input_buffers.seq_lens[:num_reqs_padded], max_seq_len=self.draft_max_seq_len, block_tables=block_tables, slot_mappings=slot_mappings, kv_cache_config=self.kv_cache_config, + causal=causal, ) return attn_metadata + def _validate_local_argmax_reduction(self) -> None: + if not self.use_local_argmax_reduction: + return + if self.speculative_config.draft_sample_method == "probabilistic": + raise ValueError( + "use_local_argmax_reduction is not compatible with " + "draft_sample_method='probabilistic'." + ) + if not hasattr(self.model, "get_top_tokens"): + raise ValueError( + "use_local_argmax_reduction is enabled but draft model " + f"{self.model.__class__.__name__} does not implement " + "get_top_tokens()." + ) + logger.info( + "Using local argmax reduction for draft token generation " + "(communication: O(2*tp_size) vs O(vocab_size))." + ) + + def _greedy_sample_draft(self, hidden_states: torch.Tensor) -> torch.Tensor: + if self.use_local_argmax_reduction: + return self.model.get_top_tokens(hidden_states) + logits = self.model.compute_logits(hidden_states) + return logits.argmax(dim=-1) + + def sample_draft( + self, + hidden_states: torch.Tensor, + positions: torch.Tensor, + idx_mapping: torch.Tensor, + temperature: torch.Tensor, + seeds: torch.Tensor, + draft_step: torch.Tensor, + draft_logits: torch.Tensor | None, + ) -> torch.Tensor: + if draft_logits is not None: + logits = self.model.compute_logits(hidden_states) + # NOTE(woosuk): We must add 1 to the positions to match the Gumbel noise + # used for draft and target sampling. + return gumbel_sample( + logits, + idx_mapping, + temperature, + seeds, + positions + 1, + apply_temperature=True, + output_processed_logits=draft_logits, + output_processed_logits_col=draft_step, + use_fp64=self.use_fp64_gumbel, + ) + return self._greedy_sample_draft(hidden_states) + def _copy_request_inputs( self, num_reqs: int, @@ -222,3 +305,7 @@ def _copy_request_inputs( self.temperature.copy_(temperature) self.seeds.copy_(seeds) self.idx_mapping[:num_reqs].copy_(idx_mapping) + if self.draft_logits is not None: + # idx_mapping for CG padded requests points to -1, which is ignored + # during sampling to prevent writing stale values to draft logits. + self.idx_mapping[num_reqs:].fill_(-1) diff --git a/vllm/v1/worker/gpu/spec_decode/utils.py b/vllm/v1/worker/gpu/spec_decode/utils.py index e1fa21aeb8ae..e25672e953bd 100644 --- a/vllm/v1/worker/gpu/spec_decode/utils.py +++ b/vllm/v1/worker/gpu/spec_decode/utils.py @@ -12,7 +12,8 @@ class DraftTokensHandler: def __init__(self, device: torch.device | None = None): self.device = device self.copy_stream = torch.cuda.Stream(device) - self.copy_event = torch.cuda.Event() + # Blocking (sleep) event to avoid busy-polling the CUDA driver lock. + self.copy_event = torch.cuda.Event(blocking=True) self.req_ids: list[str] = [] self.draft_tokens_np: np.ndarray | None = None @@ -35,6 +36,10 @@ def set_draft_tokens( self.copy_stream.wait_stream(current_stream) with torch.cuda.stream(self.copy_stream): self.draft_tokens_np = async_copy_to_np(draft_tokens) + # draft_tokens is a temporary allocation on the main stream and read here on + # copy_stream; without record_stream, the caching allocator may reuse its + # memory before the async copy executes. + draft_tokens.record_stream(self.copy_stream) self.copy_event.record() def get_draft_tokens(self) -> DraftTokenIds | None: @@ -45,3 +50,28 @@ def get_draft_tokens(self) -> DraftTokenIds | None: # This case only happens when async scheduling is disabled. draft_token_ids = [[-1] * self.num_draft_tokens for _ in self.req_ids] return DraftTokenIds(self.req_ids, draft_token_ids) + + +def get_parallel_drafting_token_id(hf_config) -> int: + """Resolve the mask token id used for parallel drafting slots. + + Checks (in order): `dflash_config.mask_token_id`, top-level `mask_token_id`, + `dspark_noise_token_id`, `pard_token`, `ptd_token_id`. Raises ValueError if + none are present. + """ + dflash_config = getattr(hf_config, "dflash_config", None) or {} + if "mask_token_id" in dflash_config: + return int(dflash_config["mask_token_id"]) + if getattr(hf_config, "mask_token_id", None) is not None: + return int(hf_config.mask_token_id) + if hasattr(hf_config, "dspark_noise_token_id"): + return int(hf_config.dspark_noise_token_id) + if hasattr(hf_config, "pard_token"): + return int(hf_config.pard_token) + if hasattr(hf_config, "ptd_token_id"): + return int(hf_config.ptd_token_id) + raise ValueError( + "Model config must specify `dflash_config.mask_token_id`," + " `mask_token_id`, `dspark_noise_token_id`, `pard_token`, or" + " `ptd_token_id` for parallel drafting." + ) diff --git a/vllm/v1/worker/gpu/states.py b/vllm/v1/worker/gpu/states.py index be7bae7f17ed..7f0ae33c8099 100644 --- a/vllm/v1/worker/gpu/states.py +++ b/vllm/v1/worker/gpu/states.py @@ -110,16 +110,6 @@ def add_request( self.num_computed_tokens_np[req_idx] = num_computed_tokens self.num_computed_tokens.stage_write_elem(req_idx, num_computed_tokens) - if 0 < num_computed_tokens <= prefill_len: - # For PD disagg or resumed requests: set last_sampled to the last - # computed token so the first decode step gets the right input_id. - # For fresh prefill requests (num_computed_tokens == 0) the tensor - # is not read by combine_sampled_and_draft_tokens so we skip the - # write. Use a slice assignment rather than scalar indexing so the - # write is dispatched through fill_ without a host/device sync. - self.last_sampled_tokens[req_idx : req_idx + 1] = all_token_ids[ - num_computed_tokens - 1 - ] self.draft_tokens[req_idx].zero_() def apply_staged_writes(self) -> None: diff --git a/vllm/v1/worker/gpu/warmup.py b/vllm/v1/worker/gpu/warmup.py index 83d87c74a4a0..f4047a0be8ad 100644 --- a/vllm/v1/worker/gpu/warmup.py +++ b/vllm/v1/worker/gpu/warmup.py @@ -2,12 +2,15 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from collections.abc import Callable +from contextlib import AbstractContextManager, nullcontext from typing import Any import numpy as np import torch from vllm import PoolingParams, SamplingParams +from vllm.logger import init_logger +from vllm.multimodal.inputs import MultiModalFeatureSpec, PlaceholderRange from vllm.utils.math_utils import cdiv from vllm.v1.core.sched.output import ( CachedRequestData, @@ -15,9 +18,138 @@ NewRequestData, SchedulerOutput, ) +from vllm.v1.kv_cache_interface import CrossAttentionSpec, MambaSpec from vllm.v1.request import Request from vllm.v1.worker.gpu.model_runner import GPUModelRunner +logger = init_logger(__name__) + + +def run_mixed_prefill_decode_warmup( + model_runner: GPUModelRunner, + worker_execute_model: Callable[[SchedulerOutput], Any], + worker_sample_tokens: Callable[[GrammarOutput | None], Any], + num_tokens: int, + *, + mixed_step_context: AbstractContextManager[object] | None = None, + req_id_prefix: str = "_v2_mixed_warmup", +) -> bool: + """Run a V2 mixed prefill+decode step through normal scheduler inputs.""" + if model_runner.is_pooling_model or model_runner.max_num_reqs < 2 or num_tokens < 3: + return False + + decode_req_id = f"{req_id_prefix}_decode_" + prefill_req_id = f"{req_id_prefix}_prefill_" + decode_prompt_len = 2 + decode_scheduled_tokens = 1 + prefill_len = num_tokens - decode_scheduled_tokens + decode_token_ids = list(range(decode_prompt_len)) + prefill_token_ids = list(range(prefill_len)) + + kv_cache_groups = model_runner.kv_cache_config.kv_cache_groups + num_kv_cache_groups = len(kv_cache_groups) + group_block_sizes = [g.kv_cache_spec.block_size for g in kv_cache_groups] + decode_prefill_block_counts = [ + cdiv(decode_prompt_len, block_size) for block_size in group_block_sizes + ] + decode_block_counts = [ + cdiv(decode_prompt_len + decode_scheduled_tokens, block_size) + for block_size in group_block_sizes + ] + decode_block_deltas = [ + decode - prefill + for decode, prefill in zip(decode_block_counts, decode_prefill_block_counts) + ] + prefill_block_counts = [ + cdiv(prefill_len, block_size) for block_size in group_block_sizes + ] + required_blocks = sum(decode_block_counts) + sum(prefill_block_counts) + if model_runner.kv_cache_config.num_blocks <= required_blocks: + logger.warning( + "Skipping V2 mixed prefill+decode warmup because only %d KV blocks " + "are available for %d required warmup blocks.", + model_runner.kv_cache_config.num_blocks, + required_blocks, + ) + return False + + next_block_id = 1 + + def _alloc_blocks(num_blocks: int) -> list[int]: + nonlocal next_block_id + block_ids = list(range(next_block_id, next_block_id + num_blocks)) + next_block_id += num_blocks + return block_ids + + sampling_params = SamplingParams(max_tokens=2, temperature=0.0) + + decode_prefill_output = SchedulerOutput.make_empty() + decode_prefill_output.scheduled_new_reqs = [ + NewRequestData( + req_id=decode_req_id, + prompt_token_ids=decode_token_ids, + mm_features=[], + sampling_params=sampling_params, + pooling_params=None, + block_ids=tuple(_alloc_blocks(n) for n in decode_prefill_block_counts), + num_computed_tokens=0, + lora_request=None, + prefill_token_ids=decode_token_ids, + ), + ] + decode_prefill_output.num_scheduled_tokens = { + decode_req_id: decode_prompt_len, + } + decode_prefill_output.total_num_scheduled_tokens = decode_prompt_len + decode_prefill_output.num_common_prefix_blocks = [0] * num_kv_cache_groups + + decode_new_blocks = tuple(_alloc_blocks(n) for n in decode_block_deltas) + cached_decode_req = CachedRequestData.make_empty() + cached_decode_req.req_ids = [decode_req_id] + cached_decode_req.num_computed_tokens = [decode_prompt_len] + cached_decode_req.num_output_tokens = [1] + cached_decode_req.new_block_ids = [ + decode_new_blocks if any(decode_block_deltas) else None + ] + + mixed_output = SchedulerOutput.make_empty() + mixed_output.scheduled_cached_reqs = cached_decode_req + mixed_output.scheduled_new_reqs = [ + NewRequestData( + req_id=prefill_req_id, + prompt_token_ids=prefill_token_ids, + mm_features=[], + sampling_params=sampling_params, + pooling_params=None, + block_ids=tuple(_alloc_blocks(n) for n in prefill_block_counts), + num_computed_tokens=0, + lora_request=None, + prefill_token_ids=prefill_token_ids, + ), + ] + mixed_output.num_scheduled_tokens = { + decode_req_id: decode_scheduled_tokens, + prefill_req_id: prefill_len, + } + mixed_output.total_num_scheduled_tokens = num_tokens + mixed_output.num_common_prefix_blocks = [0] * num_kv_cache_groups + + cleanup_output = SchedulerOutput.make_empty() + cleanup_output.finished_req_ids = {decode_req_id, prefill_req_id} + + context = mixed_step_context or nullcontext() + model_runner.kv_connector.set_disabled(True) + try: + worker_execute_model(decode_prefill_output) + worker_sample_tokens(None) + with context: + worker_execute_model(mixed_output) + worker_sample_tokens(None) + worker_execute_model(cleanup_output) + finally: + model_runner.kv_connector.set_disabled(False) + return True + @torch.inference_mode() def warmup_kernels( @@ -30,25 +162,52 @@ def warmup_kernels( pipeline parallel coordination. The first iteration simulates a prefill with requests of - 2 + num_spec_steps prompt tokens each. The second iteration simulates - a decode step with all requests generating 1 + num_spec_steps tokens. + decode_query_len + 1 prompt tokens each. The second iteration simulates + a decode step with all requests generating decode_query_len tokens. """ num_spec_steps = model_runner.num_speculative_steps - # Use 1 + num_spec_steps + 1 tokens so the prefill batch's per-request - # query length exceeds decode_query_len (= 1 + num_spec_steps), preventing - # it from being misclassified as a uniform decode batch. - prompt_len = 2 + num_spec_steps + decode_query_len = model_runner.decode_query_len + # Use decode_query_len + 1 tokens so the prefill batch's per-request query + # length exceeds decode_query_len, preventing it from being misclassified as + # a uniform decode batch. + prompt_len = decode_query_len + 1 prompt_token_ids = list(range(prompt_len)) - # After prefill, decode generates 1 verified + num_spec_steps draft tokens. - decode_len = prompt_len + 1 + num_spec_steps + # After prefill, decode generates decode_query_len tokens. + decode_len = prompt_len + decode_query_len kv_cache_groups = model_runner.kv_cache_config.kv_cache_groups num_kv_cache_groups = len(kv_cache_groups) + # Encoder-decoder models: give each warmup request a dummy encoder input so + # cross-attention warms up over a realistic, non-empty key sequence. + # The dummy mm_feature is registered in the encoder cache and only its encoder + # length is read (not the inputs themselves); the encoder itself is not scheduled. + max_encoder_len = getattr(model_runner.model_state, "max_encoder_len", 0) + warmup_mm_features: list[MultiModalFeatureSpec] = [] + if model_runner.is_encoder_decoder and max_encoder_len: + warmup_mm_features = [ + MultiModalFeatureSpec( + data=None, + modality="", + identifier="_warmup_encoder", + mm_position=PlaceholderRange(offset=0, length=max_encoder_len), + ) + ] + # Compute per-request block counts for each KV cache group. - group_block_sizes = [g.kv_cache_spec.block_size for g in kv_cache_groups] - prefill_block_counts = [cdiv(prompt_len, bs) for bs in group_block_sizes] - decode_block_counts = [cdiv(decode_len, bs) for bs in group_block_sizes] + def _warmup_block_count(num_tokens: int, spec: Any) -> int: + if isinstance(spec, CrossAttentionSpec): + num_tokens = max_encoder_len + num_blocks = cdiv(num_tokens, spec.block_size) + if isinstance(spec, MambaSpec) and spec.mamba_cache_mode == "align": + # Align mode reserves extra blocks beyond the token range for the + # speculative-decode running-state snapshots. + num_blocks += spec.num_speculative_blocks + return num_blocks + + kv_cache_specs = [g.kv_cache_spec for g in kv_cache_groups] + prefill_block_counts = [_warmup_block_count(prompt_len, s) for s in kv_cache_specs] + decode_block_counts = [_warmup_block_count(decode_len, s) for s in kv_cache_specs] decode_block_deltas = [ d - p for d, p in zip(decode_block_counts, prefill_block_counts) ] @@ -57,7 +216,7 @@ def warmup_kernels( num_reqs = min( model_runner.scheduler_config.max_num_seqs, model_runner.scheduler_config.max_num_batched_tokens - // max(prompt_len, 1 + num_spec_steps), + // max(prompt_len, decode_query_len), # Reserve block 0 (null block) and ensure we have enough blocks. max(1, (model_runner.kv_cache_config.num_blocks - 1) // max_blocks_per_req), ) @@ -79,10 +238,16 @@ def _alloc_blocks(num_blocks: int) -> list[int]: nonlocal next_block_id return list(range(next_block_id, next_block_id := next_block_id + num_blocks)) - # Step 1: Prefill all requests with 2 + num_spec_steps prompt tokens each. + # Step 1: Prefill all requests with 1 + decode_query_len prompt tokens each. new_reqs = [ NewRequestData.from_request( - Request(req_ids[i], prompt_token_ids, sampling_params, pooling_params), + Request( + req_ids[i], + prompt_token_ids, + sampling_params, + pooling_params, + mm_features=warmup_mm_features, + ), block_ids=tuple(_alloc_blocks(n) for n in prefill_block_counts), prefill_token_ids=prompt_token_ids, ) @@ -117,7 +282,7 @@ def _alloc_blocks(num_blocks: int) -> list[int]: worker_sample_tokens(grammar_output) - # Step 2: Decode all requests with 1 + num_spec_steps tokens each. + # Step 2: Decode all requests with decode_query_len tokens each. cached_req_data = CachedRequestData.make_empty() cached_req_data.req_ids = list(req_ids) cached_req_data.num_computed_tokens = [prompt_len] * num_reqs @@ -131,7 +296,7 @@ def _alloc_blocks(num_blocks: int) -> list[int]: decode_output = SchedulerOutput.make_empty() decode_output.scheduled_cached_reqs = cached_req_data decode_output.num_scheduled_tokens = { - req_id: 1 + num_spec_steps for req_id in req_ids + req_id: decode_query_len for req_id in req_ids } if num_spec_steps > 0: decode_output.scheduled_spec_decode_tokens = { diff --git a/vllm/v1/worker/gpu_input_batch.py b/vllm/v1/worker/gpu_input_batch.py index 89d69c0bde64..28d1a04b780c 100644 --- a/vllm/v1/worker/gpu_input_batch.py +++ b/vllm/v1/worker/gpu_input_batch.py @@ -15,6 +15,7 @@ from vllm.sampling_params import SamplingParams, SamplingType from vllm.utils import length_from_prompt_token_ids_or_embeds from vllm.utils.collection_utils import swap_dict_values +from vllm.utils.torch_utils import PIN_MEMORY from vllm.v1.outputs import LogprobsTensors from vllm.v1.pool.metadata import PoolingMetadata, PoolingStates from vllm.v1.sample.logits_processor import ( @@ -95,7 +96,6 @@ def __init__( max_model_len: int, max_num_batched_tokens: int, device: torch.device, - pin_memory: bool, vocab_size: int, block_sizes: list[int], # The block_size of each kv cache group kernel_block_sizes: list[int], @@ -112,7 +112,6 @@ def __init__( max_num_reqs, num_spec_tokens, device, - pin_memory, ) self.thinking_token_budget_reqs: set[str] = set() self.is_pooling_model = is_pooling_model @@ -120,7 +119,6 @@ def __init__( self.max_model_len = max_model_len self.max_num_batched_tokens = max_num_batched_tokens self.device = device - self.pin_memory = pin_memory self.vocab_size = vocab_size self._req_ids: list[str | None] = [] @@ -138,7 +136,10 @@ def __init__( ) self.token_ids_cpu = self.token_ids_cpu_tensor.numpy() self.is_token_ids_tensor = torch.zeros( - (max_num_reqs, max_model_len), device="cpu", dtype=bool, pin_memory=False + (max_num_reqs, max_model_len), + device="cpu", + dtype=bool, + pin_memory=False, ) self.is_token_ids = self.is_token_ids_tensor.numpy() # Store prompt embeddings per request to avoid OOM from large upfront @@ -149,21 +150,21 @@ def __init__( (max_num_reqs,), device="cpu", dtype=torch.int32, - pin_memory=pin_memory, + pin_memory=PIN_MEMORY, ) self.num_tokens_no_spec = self.num_tokens_no_spec_cpu_tensor.numpy() self.num_prompt_tokens_cpu_tensor = torch.zeros( (max_num_reqs,), device="cpu", dtype=torch.int32, - pin_memory=pin_memory, + pin_memory=PIN_MEMORY, ) self.num_prompt_tokens = self.num_prompt_tokens_cpu_tensor.numpy() self.num_computed_tokens_cpu_tensor = torch.zeros( (max_num_reqs,), device="cpu", dtype=torch.int32, - pin_memory=pin_memory, + pin_memory=PIN_MEMORY, ) self.num_computed_tokens_cpu = self.num_computed_tokens_cpu_tensor.numpy() @@ -172,7 +173,7 @@ def __init__( max_num_reqs=max_num_reqs, max_model_len=max_model_len, max_num_batched_tokens=max_num_batched_tokens, - pin_memory=pin_memory, + pin_memory=PIN_MEMORY, device=device, block_sizes=block_sizes, kernel_block_sizes=kernel_block_sizes, @@ -185,7 +186,7 @@ def __init__( (max_num_reqs,), dtype=torch.float32, device=device ) self.temperature_cpu_tensor = torch.empty( - (max_num_reqs,), dtype=torch.float32, device="cpu", pin_memory=pin_memory + (max_num_reqs,), dtype=torch.float32, device="cpu", pin_memory=PIN_MEMORY ) self.temperature_cpu = self.temperature_cpu_tensor.numpy() self.greedy_reqs: set[str] = set() @@ -193,14 +194,14 @@ def __init__( self.top_p = torch.empty((max_num_reqs,), dtype=torch.float32, device=device) self.top_p_cpu_tensor = torch.empty( - (max_num_reqs,), dtype=torch.float32, device="cpu", pin_memory=pin_memory + (max_num_reqs,), dtype=torch.float32, device="cpu", pin_memory=PIN_MEMORY ) self.top_p_cpu = self.top_p_cpu_tensor.numpy() self.top_p_reqs: set[str] = set() self.top_k = torch.empty((max_num_reqs,), dtype=torch.int32, device=device) self.top_k_cpu_tensor = torch.empty( - (max_num_reqs,), dtype=torch.int32, device="cpu", pin_memory=pin_memory + (max_num_reqs,), dtype=torch.int32, device="cpu", pin_memory=PIN_MEMORY ) self.top_k_cpu = self.top_k_cpu_tensor.numpy() self.top_k_reqs: set[str] = set() @@ -210,7 +211,7 @@ def __init__( (max_num_reqs,), dtype=torch.float, device=device ) self.frequency_penalties_cpu_tensor = torch.empty( - (max_num_reqs,), dtype=torch.float, device="cpu", pin_memory=pin_memory + (max_num_reqs,), dtype=torch.float, device="cpu", pin_memory=PIN_MEMORY ) self.frequency_penalties_cpu = self.frequency_penalties_cpu_tensor.numpy() self.frequency_penalties_reqs: set[str] = set() @@ -220,7 +221,7 @@ def __init__( (max_num_reqs,), dtype=torch.float, device=device ) self.presence_penalties_cpu_tensor = torch.empty( - (max_num_reqs,), dtype=torch.float, device="cpu", pin_memory=pin_memory + (max_num_reqs,), dtype=torch.float, device="cpu", pin_memory=PIN_MEMORY ) self.presence_penalties_cpu = self.presence_penalties_cpu_tensor.numpy() self.presence_penalties_reqs: set[str] = set() @@ -230,14 +231,14 @@ def __init__( (max_num_reqs,), dtype=torch.float, device=device ) self.repetition_penalties_cpu_tensor = torch.empty( - (max_num_reqs,), dtype=torch.float, device="cpu", pin_memory=pin_memory + (max_num_reqs,), dtype=torch.float, device="cpu", pin_memory=PIN_MEMORY ) self.repetition_penalties_cpu = self.repetition_penalties_cpu_tensor.numpy() self.repetition_penalties_reqs: set[str] = set() # Speculative decoding self.num_accepted_tokens_cpu_tensor = torch.ones( - (max_num_reqs,), dtype=torch.int32, device="cpu", pin_memory=pin_memory + (max_num_reqs,), dtype=torch.int32, device="cpu", pin_memory=PIN_MEMORY ) self.num_accepted_tokens_cpu = self.num_accepted_tokens_cpu_tensor.numpy() @@ -963,7 +964,7 @@ def _make_prompt_token_ids_cpu_tensor(self) -> torch.Tensor: (self.num_reqs, max_prompt_len), device="cpu", dtype=torch.int64, - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) prompt_token_ids = prompt_token_ids_cpu_tensor.numpy() prompt_token_ids[:] = self.token_ids_cpu[:num_reqs, :max_prompt_len] diff --git a/vllm/v1/worker/gpu_model_runner.py b/vllm/v1/worker/gpu_model_runner.py index 261995f4b018..38500ab0514d 100644 --- a/vllm/v1/worker/gpu_model_runner.py +++ b/vllm/v1/worker/gpu_model_runner.py @@ -49,7 +49,6 @@ is_global_first_rank, prepare_communication_buffer_for_model, ) -from vllm.distributed.weight_transfer.base import SparseWeightPatch from vllm.forward_context import ( BatchDescriptor, set_forward_context, @@ -58,6 +57,7 @@ from vllm.lora.layers import LoRAMapping, LoRAMappingType from vllm.model_executor.layers.attention import Attention, MLAAttention from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase +from vllm.model_executor.layers.fused_moe.all2all_utils import get_ep_all2all_manager from vllm.model_executor.layers.fused_moe.routed_experts_capturer import ( RoutedExpertsCapturer, ) @@ -115,8 +115,10 @@ from vllm.utils.math_utils import cdiv, round_up from vllm.utils.mem_utils import DeviceMemoryProfiler, format_gib from vllm.utils.nvtx_pytorch_hooks import PytHooks -from vllm.utils.platform_utils import is_pin_memory_available, num_compute_units +from vllm.utils.platform_utils import num_compute_units from vllm.utils.torch_utils import ( + PIN_MEMORY, + async_tensor_h2d, get_dtype_size, is_quantized_kv_cache, kv_cache_dtype_str_to_dtype, @@ -130,6 +132,9 @@ CommonAttentionMetadata, ) from vllm.v1.attention.backends.gdn_attn import GDNAttentionMetadataBuilder +from vllm.v1.attention.backends.linear_attn import ( + BailingLinearAttentionMetadataBuilder, +) from vllm.v1.attention.backends.mamba2_attn import Mamba2AttentionMetadataBuilder from vllm.v1.attention.backends.utils import ( NULL_BLOCK_ID, @@ -148,6 +153,7 @@ KVCacheConfig, KVCacheGroupSpec, KVCacheSpec, + KVQuantMode, MambaSpec, SlidingWindowSpec, UniformTypeKVCacheSpecs, @@ -168,6 +174,7 @@ SamplerOutput, make_empty_encoder_model_runner_output, ) +from vllm.v1.pool.late_interaction_runner import LateInteractionRunner from vllm.v1.pool.metadata import PoolingMetadata, PoolingStates from vllm.v1.sample.logits_processor import LogitsProcessors, build_logitsprocs from vllm.v1.sample.logits_processor.interface import LogitsProcessor @@ -200,7 +207,7 @@ ) from vllm.v1.worker.dp_utils import coordinate_batch_across_dp from vllm.v1.worker.ec_connector_model_runner_mixin import ECConnectorModelRunnerMixin -from vllm.v1.worker.gpu.pool.late_interaction_runner import LateInteractionRunner +from vllm.v1.worker.gpu.attn_utils import _reshape_attention_kv_cache from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch from vllm.v1.worker.gpu_ubatch_wrapper import UBatchWrapper from vllm.v1.worker.kv_connector_model_runner_mixin import KVConnectorModelRunnerMixin @@ -246,12 +253,14 @@ def __init__( async_output_copy_stream: torch.cuda.Stream, vocab_size: int, routed_experts: RoutedExpertsTensors | None = None, + check_ep_fault: bool = False, ): self._model_runner_output = model_runner_output self._invalid_req_indices = invalid_req_indices # Event on the copy stream so we can synchronize the non-blocking copy. - self.async_copy_ready_event = torch.Event() + # Blocking (sleep) event to avoid busy-polling the CUDA driver lock. + self.async_copy_ready_event = torch.cuda.Event(blocking=True) # Keep a reference to the device tensor to avoid it being # deallocated until we finish copying it to the host. @@ -259,6 +268,7 @@ def __init__( self.vocab_size = vocab_size self._logprobs_tensors = logprobs_tensors self._routed_experts = routed_experts + self._has_fault: torch.Tensor | None = None # Initiate the copy on a separate stream, but do not synchronize it. default_stream = torch.cuda.current_stream() @@ -277,6 +287,9 @@ def __init__( if self._routed_experts is not None else None ) + if check_ep_fault: + has_fault = get_ep_all2all_manager().query_fault() + self._has_fault = has_fault.to("cpu", non_blocking=True) self.async_copy_ready_event.record() def get_output(self) -> ModelRunnerOutput: @@ -313,6 +326,14 @@ def get_output(self) -> ModelRunnerOutput: output.routed_experts = self._routed_experts_cpu.tolists() del self._routed_experts + if self._has_fault is not None and self._has_fault.item(): + mask = get_ep_all2all_manager().query_active_mask() + raise RuntimeError( + "Fault detected in EP all2all communication: " + "one or more ranks timed out during dispatch/combine. " + f"Mask: {mask.cpu().tolist()}" + ) + return output @@ -372,7 +393,8 @@ def __init__( self._model_runner_output = model_runner_output # Event on the copy stream so we can synchronize the non-blocking copy. - self.async_copy_ready_event = torch.Event() + # Blocking (sleep) event to avoid busy-polling the CUDA driver lock. + self.async_copy_ready_event = torch.cuda.Event(blocking=True) # Keep a reference to the device tensors to avoid them being # deallocated until we finish copying it to the host. @@ -440,9 +462,12 @@ def __init__( scheduler_config = self.scheduler_config parallel_config = self.parallel_config self.device = device - self.pin_memory = is_pin_memory_available() self.dtype = self.model_config.dtype + self.check_ep_fault = False + if parallel_config.data_parallel_size > 1 and self.model_config.is_moe: + self.check_ep_fault = get_ep_all2all_manager().support_fault_tolerance + self.kv_cache_dtype = kv_cache_dtype_str_to_dtype( cache_config.cache_dtype, self.model_config ) @@ -504,7 +529,10 @@ def __init__( self.use_async_scheduling = self.scheduler_config.async_scheduling # Sampler - self.sampler = Sampler(logprobs_mode=self.model_config.logprobs_mode) + self.sampler = Sampler( + logprobs_mode=self.model_config.logprobs_mode, + use_fp64_gumbel=self.model_config.use_fp64_gumbel, + ) self.eplb_state: EplbState | None = None self._moe_model: MixtureOfExperts | None = None @@ -617,9 +645,11 @@ def __init__( ) self.num_spec_tokens = 0 + self.prev_num_spec_tokens = 0 self.valid_sampled_token_count_gpu: torch.Tensor | None = None if self.speculative_config: self.num_spec_tokens = self.speculative_config.num_speculative_tokens + self.prev_num_spec_tokens = self.num_spec_tokens draft_config = self.speculative_config.draft_model_config if draft_config is not None and draft_config.max_model_len is not None: self.effective_drafter_max_model_len = draft_config.max_model_len @@ -660,7 +690,6 @@ def __init__( max_model_len=max(self.max_model_len, self.max_encoder_len), max_num_batched_tokens=self.max_num_tokens, device=self.device, - pin_memory=self.pin_memory, vocab_size=self.model_config.get_vocab_size(), block_sizes=[placeholder_block_size], kernel_block_sizes=[placeholder_block_size], @@ -668,16 +697,14 @@ def __init__( logitsprocs=build_logitsprocs( self.vllm_config, self.device, - self.pin_memory, + PIN_MEMORY, self.is_pooling_model, custom_logitsprocs, ), # We currently don't know whether a particular custom logits processor - # uses output token ids so we set this conservatively. - # ThinkingTokenBudgetLogitsProcessor also needs output token ids to - # correctly track think start/end token sequences in async scheduling. - logitsprocs_need_output_token_ids=bool(custom_logitsprocs) - or self.vllm_config.reasoning_config is not None, + # uses output token ids so we set this conservatively. Thinking-budget + # tracking is requested dynamically when a budgeted request is in the batch. + logitsprocs_need_output_token_ids=bool(custom_logitsprocs), is_pooling_model=self.is_pooling_model, cp_kv_cache_interleave_size=self.parallel_config.cp_kv_cache_interleave_size, reasoning_config=self.vllm_config.reasoning_config, @@ -691,7 +718,9 @@ def __init__( self.prepare_inputs_event: torch.Event | None = None if self.use_async_scheduling: self.async_output_copy_stream = torch.cuda.Stream() - self.prepare_inputs_event = torch.Event() + # Blocking (sleep) event to avoid busy-polling the CUDA driver lock; + # under TP contention that spin can balloon and make the rank a straggler. + self.prepare_inputs_event = torch.cuda.Event(blocking=True) # self.cudagraph_batch_sizes sorts in ascending order. if ( @@ -723,7 +752,7 @@ def __init__( self.max_num_reqs, dtype=torch.int32, device=self.device ) self.optimistic_seq_lens_cpu = torch.zeros( - self.max_num_reqs, dtype=torch.int32, pin_memory=self.pin_memory + self.max_num_reqs, dtype=torch.int32, pin_memory=PIN_MEMORY ) self.num_computed_tokens = torch.zeros( self.max_num_reqs, dtype=torch.int32, device=self.device @@ -840,7 +869,7 @@ def __init__( and self.speculative_config.use_ngram_gpu() ): self._num_valid_draft_tokens_cpu = torch.empty( - self.max_num_reqs, dtype=torch.int32, pin_memory=self.pin_memory + self.max_num_reqs, dtype=torch.int32, pin_memory=PIN_MEMORY ) self._num_valid_draft_tokens_event = torch.cuda.Event() self._num_valid_draft_tokens_copy_stream = torch.cuda.Stream() @@ -851,7 +880,7 @@ def __init__( (self.max_num_reqs, 1), dtype=torch.int64, device="cpu", - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) # Pre-allocated tensor for copying valid sampled token counts to CPU, @@ -873,7 +902,7 @@ def __init__( (self.max_num_reqs, self.num_spec_tokens), dtype=torch.int64, device="cpu", - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) if self.use_async_scheduling: self.valid_sampled_token_count_event = torch.Event() @@ -882,7 +911,7 @@ def __init__( self.max_num_reqs, dtype=torch.int32, device="cpu", - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) # Model weight offloader @@ -994,7 +1023,6 @@ def _make_buffer( *size, dtype=dtype, device=self.device, - pin_memory=self.pin_memory, with_numpy=numpy, ) @@ -1049,7 +1077,7 @@ def _init_model_kwargs(self): token_type_ids.append(ids) token_type_ids_cpu = torch.empty( - sum(seq_lens_cpu), dtype=torch.int32, pin_memory=self.pin_memory + sum(seq_lens_cpu), dtype=torch.int32, pin_memory=PIN_MEMORY ) torch.cat(token_type_ids, out=token_type_ids_cpu) model_kwargs["token_type_ids"] = token_type_ids_cpu.to( @@ -1089,12 +1117,13 @@ def _init_kv_zero_meta(self) -> None: """ self._kv_block_zeroer = KVBlockZeroer( self.device, - self.pin_memory, + pin_memory=PIN_MEMORY, attn_groups_iter=self._kv_cache_spec_attn_group_iterator(), kernel_block_sizes=self._kernel_block_sizes, cache_dtype=self.cache_config.cache_dtype, runner_only_attn_layers=self.runner_only_attn_layers, - static_forward_context=(self.compilation_config.static_forward_context), + static_forward_context=self.compilation_config.static_forward_context, + max_concurrency=self.vllm_config.max_concurrent_batches, ) def _zero_block_ids(self, block_ids: list[int]) -> None: @@ -1585,9 +1614,6 @@ def _update_streaming_request( def _init_mrope_positions(self, req_state: CachedRequestState): model = self.get_model() assert supports_mrope(model), "M-RoPE support is not implemented." - assert req_state.prompt_token_ids is not None, ( - "M-RoPE requires prompt_token_ids to be available." - ) mrope_model = cast(SupportsMRoPE, model) # `prompt_embeds` is a passthrough modality (no grid_thw), models' @@ -1596,9 +1622,23 @@ def _init_mrope_positions(self, req_state: CachedRequestState): mrope_features = [ f for f in req_state.mm_features if f.modality != "prompt_embeds" ] + + if req_state.prompt_token_ids is not None: + input_tokens = req_state.prompt_token_ids + elif req_state.prompt_embeds is not None: + # For embeddings-only inputs, get_mrope_input_positions only + # needs the sequence length when mm_features is empty (which is + # the case here since prompt_embeds are filtered out above). + seq_len = req_state.prompt_embeds.shape[0] + input_tokens = list(range(seq_len)) + else: + raise ValueError( + "M-RoPE requires either prompt_token_ids or prompt_embeds." + ) + req_state.mrope_positions, req_state.mrope_position_delta = ( mrope_model.get_mrope_input_positions( - req_state.prompt_token_ids, + input_tokens, mrope_features, ) ) @@ -1634,7 +1674,7 @@ def _extract_mm_kwargs( for _, _, mm_kwargs_batch in group_and_batch_mm_kwargs( mm_kwargs, device=self.device, - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ): mm_kwargs_combined.update(mm_kwargs_batch) @@ -1750,7 +1790,7 @@ def _prepare_input_ids( spec_flattened_indices.extend( range(flattened_index - draft_len + 1, flattened_index + 1) ) - start = prev_index * self.num_spec_tokens + start = prev_index * self.prev_num_spec_tokens # prev_draft_token_indices is used to find which draft_tokens_id # should be copied to input_ids # example: prev draft_tokens_id [[1,2], [3,4], [5, 6]] @@ -1763,13 +1803,17 @@ def _prepare_input_ids( num_common_tokens = len(sample_flattened_indices) total_without_spec = total_num_scheduled_tokens - total_num_spec_tokens + if self.enable_prompt_embeds: + # The multimodal embed path reads is_token_ids.gpu; its .cpu copy is + # refreshed every step but the async fast paths below only scatter + # input_ids.gpu, so refresh is_token_ids.gpu here too. + self.is_token_ids.copy_to_gpu(total_num_scheduled_tokens) if num_common_tokens < total_without_spec: # If not all requests are decodes from the last iteration, # we need to copy the input_ids_cpu to the GPU first. self.input_ids.copy_to_gpu(total_num_scheduled_tokens) if self.enable_prompt_embeds: self.inputs_embeds.copy_to_gpu(total_num_scheduled_tokens) - self.is_token_ids.copy_to_gpu(total_num_scheduled_tokens) if num_common_tokens == 0: # No requests in common with the previous iteration # So input_ids.cpu will have all the input ids. @@ -1786,10 +1830,10 @@ def _prepare_input_ids( return # Upload the index tensors asynchronously so the scatter can be non-blocking. sampled_tokens_index_tensor = torch.tensor( - sample_flattened_indices, dtype=torch.int64, pin_memory=self.pin_memory + sample_flattened_indices, dtype=torch.int64, pin_memory=PIN_MEMORY ).to(self.device, non_blocking=True) prev_common_req_indices_tensor = torch.tensor( - prev_indices, dtype=torch.int64, pin_memory=self.pin_memory + prev_indices, dtype=torch.int64, pin_memory=PIN_MEMORY ).to(self.device, non_blocking=True) self.input_ids.gpu.scatter_( dim=0, @@ -1805,10 +1849,10 @@ def _prepare_input_ids( assert isinstance(self._draft_token_ids, torch.Tensor) draft_tokens_index_tensor = torch.tensor( - spec_flattened_indices, dtype=torch.int64, pin_memory=self.pin_memory + spec_flattened_indices, dtype=torch.int64, pin_memory=PIN_MEMORY ).to(self.device, non_blocking=True) prev_draft_token_indices_tensor = torch.tensor( - prev_draft_token_indices, dtype=torch.int64, pin_memory=self.pin_memory + prev_draft_token_indices, dtype=torch.int64, pin_memory=PIN_MEMORY ).to(self.device, non_blocking=True) # because input_ids dtype is torch.int32, @@ -1875,9 +1919,8 @@ def _prepare_inputs( SpecDecodeMetadata | None, ]: """ - :return: tuple[ - logits_indices, spec_decode_metadata, - ] + Returns: + tuple[logits_indices, spec_decode_metadata] """ total_num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens assert total_num_scheduled_tokens > 0 @@ -2016,7 +2059,13 @@ def _prepare_inputs( # Sync num_accepted_tokens from CPU (set by # _update_states_after_model_execute for hybrid models). - if self.num_accepted_tokens_event is not None: + # Skipped under async scheduling (non-align): the CPU copy races with + # the in-flight D2H copy and with input-batch row moves. + needs_cpu_accepted_counts = self.num_accepted_tokens_event is not None and not ( + self.use_async_scheduling and self.cache_config.mamba_cache_mode != "align" + ) + if needs_cpu_accepted_counts: + assert self.num_accepted_tokens_event is not None self.num_accepted_tokens_event.synchronize() # Async mode: condense() reordered indices, use prev_positions mapping if self.use_async_scheduling and prev_req_id_to_index: @@ -2039,6 +2088,8 @@ def _prepare_inputs( self.num_accepted_tokens.np[num_reqs:].fill(1) self.num_accepted_tokens.copy_to_gpu() else: + # Default to 1; update_num_computed_tokens_for_batch_change below + # corrects rows that had drafts from valid_sampled_token_count. self.num_accepted_tokens.np.fill(1) self.num_accepted_tokens.gpu.fill_(1) @@ -2156,10 +2207,7 @@ def _prepare_inputs( req_idx = self.input_batch.req_id_to_index[req_id] draft_len = len(draft_token_ids) num_draft_tokens[req_idx] = draft_len - if ( - self.input_batch.num_computed_tokens_cpu[req_idx] - >= self.input_batch.num_prompt_tokens[req_idx] - ): + if num_scheduled_tokens[req_idx] == draft_len + 1: num_decode_draft_tokens[req_idx] = draft_len spec_decode_metadata = self._calc_spec_decode_metadata( num_draft_tokens, cu_num_tokens @@ -2202,7 +2250,8 @@ def _build_attention_metadata( slot_mappings: dict[int, torch.Tensor] | None = None, ) -> tuple[PerLayerAttnMetadata, CommonAttentionMetadata | None]: """ - :return: tuple[attn_metadata, spec_decode_common_attn_metadata] + Returns: + tuple[attn_metadata, spec_decode_common_attn_metadata] """ # Attention metadata is not needed for attention free models if len(self.kv_cache_config.kv_cache_groups) == 0: @@ -2283,6 +2332,48 @@ def _get_block_table(kv_cache_gid: int): seq_lens_cpu = None num_computed_tokens_cpu = None + # Compute mm_prefix bidirectional ranges before building + # attention metadata so builders handle them during build(). + # By default, ranges exceeding sliding_window are skipped to prevent + # early tokens from attending across the entire image span. Models that + # clamp mm_prefix to the sliding window *in-kernel* (e.g. Gemma4, which + # needs HF's (causal OR blockwise) AND sliding_window on sliding layers) + # opt out of the skip so the bidirectional range survives for images + # larger than the window; the kernel then bounds it per-query. + req_doc_ranges: dict[int, list[tuple[int, int]]] | None = None + if self.is_mm_prefix_lm: + req_doc_ranges = {} + hf_text_config = self.model_config.hf_text_config + _bidi_sw = getattr(hf_text_config, "sliding_window", None) + _clamps_in_kernel = getattr( + self.model, "mm_prefix_clamp_sliding_window", False + ) + for req_id in self.input_batch.req_ids: + image_doc_ranges = [] + req_state = self.requests[req_id] + for mm_feature in req_state.mm_features: + if mm_feature.modality == "audio": + continue + pos_info = mm_feature.mm_position + img_doc_range = pos_info.extract_embeds_range() + for r in img_doc_range: + if ( + not _clamps_in_kernel + and _bidi_sw is not None + and (r[1] - r[0] + 1) > _bidi_sw + ): + continue + image_doc_ranges.append(r) + req_idx = self.input_batch.req_id_to_index[req_id] + req_doc_ranges[req_idx] = image_doc_ranges + + # Reference Sliding Window Attention (R-SWA): pass per-request prompt + # lengths so the attention backend can keep the prefix globally visible. + # The backend owns the persistent CUDA-graph-safe GPU buffer. + rswa_prefix_lens = None + if self.model_config.rswa_window is not None: + rswa_prefix_lens = num_prompt_tokens_cpu + cm_base = CommonAttentionMetadata( query_start_loc=self.query_start_loc.gpu[: num_reqs_padded + 1], query_start_loc_cpu=self.query_start_loc.cpu[: num_reqs_padded + 1], @@ -2299,6 +2390,8 @@ def _get_block_table(kv_cache_gid: int): causal=True, is_prefilling=is_prefilling, positions=self.positions[:num_tokens_padded], + mm_req_doc_ranges=req_doc_ranges, + rswa_prefix_lens=rswa_prefix_lens, ) if self.dcp_world_size > 1: @@ -2352,9 +2445,16 @@ def _build_attn_group_metadata( extra_attn_metadata_args = {} if use_spec_decode and isinstance( - builder, (Mamba2AttentionMetadataBuilder, GDNAttentionMetadataBuilder) + builder, + ( + Mamba2AttentionMetadataBuilder, + GDNAttentionMetadataBuilder, + BailingLinearAttentionMetadataBuilder, + ), ): - assert ubid is None, "UBatching not supported with GDN yet" + assert ubid is None, ( + "UBatching not supported with GDN or linear attn yet" + ) extra_attn_metadata_args = dict( num_accepted_tokens=self.num_accepted_tokens.gpu[:num_reqs_padded], num_decode_draft_tokens_cpu=self.num_decode_draft_tokens.cpu[ @@ -2451,36 +2551,6 @@ def _build_attn_group_metadata( else: _build_attn_group_metadata(kv_cache_gid, attn_gid, cm) - if self.is_mm_prefix_lm: - req_doc_ranges = {} - - # Gemma4 bidi: skip ranges that exceed the sliding - # window. When image tokens > sliding_window, bidi causes - # early image tokens to attend to the entire image - # (e.g. 6 → 1092 targets), degrading spatial precision. - # Per-range filtering keeps bidi for small images/video - # frames while skipping oversized images. - hf_text_config = self.model_config.hf_text_config - _bidi_sw = getattr(hf_text_config, "sliding_window", None) - - for req_id in self.input_batch.req_ids: - image_doc_ranges = [] - req_state = self.requests[req_id] - for mm_feature in req_state.mm_features: - if mm_feature.modality == "audio": - continue - pos_info = mm_feature.mm_position - img_doc_range = pos_info.extract_embeds_range() - for r in img_doc_range: - if _bidi_sw is not None and (r[1] - r[0] + 1) > _bidi_sw: - continue - image_doc_ranges.append(r) - req_idx = self.input_batch.req_id_to_index[req_id] - req_doc_ranges[req_idx] = image_doc_ranges - - # Set mm_prefix_range for all attention metadata - self._set_mm_prefix_range_for_metadata(attn_metadata, req_doc_ranges) - if spec_decode_common_attn_metadata is not None and ( num_reqs != num_reqs_padded or num_tokens != num_tokens_padded ): @@ -2500,9 +2570,11 @@ def _compute_cascade_attn_prefix_lens( num_common_prefix_blocks: list[int], ) -> list[list[int]] | None: """ - :return: Optional[cascade_attn_prefix_lens] - cascade_attn_prefix_lens is 2D: ``[kv_cache_group_id][attn_group_idx]``, - None if we should not use cascade attention + Returns: + Optional[cascade_attn_prefix_lens] + cascade_attn_prefix_lens is 2D: + ``[kv_cache_group_id][attn_group_idx]``, + None if we should not use cascade attention """ use_cascade_attn = False @@ -2770,21 +2842,16 @@ def _calc_spec_decode_metadata( # [0, 1, 2, 5, 6, 9] target_logits_indices += self._arange_scratch[: cu_num_draft_tokens[-1]] - # TODO: Optimize the CPU -> GPU copy. - cu_num_draft_tokens = torch.from_numpy(cu_num_draft_tokens).to( - self.device, non_blocking=True + cu_num_draft_tokens = async_tensor_h2d(cu_num_draft_tokens, device=self.device) + cu_num_sampled_tokens = async_tensor_h2d( + cu_num_sampled_tokens, device=self.device ) - cu_num_sampled_tokens = torch.from_numpy(cu_num_sampled_tokens).to( - self.device, non_blocking=True - ) - logits_indices = torch.from_numpy(logits_indices).to( - self.device, non_blocking=True + logits_indices = async_tensor_h2d(logits_indices, device=self.device) + target_logits_indices = async_tensor_h2d( + target_logits_indices, device=self.device ) - target_logits_indices = torch.from_numpy(target_logits_indices).to( - self.device, non_blocking=True - ) - bonus_logits_indices = torch.from_numpy(bonus_logits_indices).to( - self.device, non_blocking=True + bonus_logits_indices = async_tensor_h2d( + bonus_logits_indices, device=self.device ) # Compute the draft token ids. @@ -2994,9 +3061,7 @@ def _execute_mm_encoder( # Track the current index in mm_kwargs/mm_lora_refs to map groups to request IDs current_item_idx = 0 for modality, num_items, mm_kwargs_batch in group_and_batch_mm_kwargs( - mm_kwargs, - device=self.device, - pin_memory=self.pin_memory, + mm_kwargs, device=self.device, pin_memory=PIN_MEMORY ): batch_outputs: MultiModalEmbeddings @@ -3030,7 +3095,7 @@ def _execute_mm_encoder( group_and_batch_mm_kwargs( [video_mm_kwargs_item], device=self.device, - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) ) @@ -3089,7 +3154,10 @@ def _gather_mm_embeddings( mm_embeds = list[torch.Tensor]() is_mm_embed = torch.zeros( - total_num_scheduled_tokens, dtype=torch.bool, device="cpu" + total_num_scheduled_tokens, + dtype=torch.bool, + device="cpu", + pin_memory=PIN_MEMORY, ) req_start_idx = 0 @@ -3131,7 +3199,16 @@ def _gather_mm_embeddings( mm_hash = mm_feature.identifier encoder_output = self.encoder_cache.get(mm_hash, None) - assert encoder_output is not None, f"Encoder cache miss for {mm_hash}." + if encoder_output is None: + # A feature starting at/after the processed boundary is only + # reached via the drafter's +1 look-ahead and might not be + # encoded yet; fall back to the token embedding for drafting. + if ( + start_pos + >= req_state.num_computed_tokens + num_scheduled_tokens + ): + continue + raise RuntimeError(f"Encoder cache miss for {mm_hash}.") if (is_embed := pos_info.is_embed) is not None: is_embed = is_embed[start_idx:end_idx] @@ -3188,44 +3265,6 @@ def get_model(self) -> nn.Module: return self.model.unwrap() return self.model - def apply_sparse_weight_patches(self, patches: Iterable[SparseWeightPatch]) -> None: - """Apply sparse flat-index patches directly to existing model params.""" - model = self.get_model() - for patch in patches: - param = model.get_parameter(patch.name) - if not param.data.is_contiguous(): - raise NotImplementedError( - "Sparse weight updates currently require contiguous params: " - f"{patch.name}" - ) - - if patch.indices.dtype != torch.int32: - raise ValueError( - "Sparse weight updates currently require int32 indices: " - f"{patch.name}" - ) - if patch.indices.ndim != 1 or patch.values.ndim != 1: - raise ValueError( - f"Sparse weight patches must be 1D flattened updates: {patch.name}" - ) - if patch.indices.numel() != patch.values.numel(): - raise ValueError( - "`indices` and `values` must have matching lengths for " - f"{patch.name}" - ) - if patch.values.dtype != param.dtype: - raise ValueError( - f"Sparse values dtype {patch.values.dtype} does not match " - f"parameter dtype {param.dtype} for {patch.name}" - ) - - flat_param = param.data.view(-1) - flat_param.index_copy_( - 0, - patch.indices.to(device=flat_param.device, dtype=torch.long), - patch.values.to(device=flat_param.device), - ) - def get_supported_generation_tasks(self) -> list[GenerationTask]: model = self.get_model() supported_tasks = list[GenerationTask]() @@ -3423,6 +3462,10 @@ def _preprocess( is_first_rank = get_pp_group().is_first_rank is_encoder_decoder = self.model_config.is_encoder_decoder + # Clamp speculative scheduler placeholders (-1) before embedding lookup. + if self.speculative_config is not None: + self.input_ids.gpu[:num_input_tokens].clamp_(min=0) + # _prepare_inputs may reorder the batch, so we must gather multi # modal outputs after that to ensure the correct order ec_connector_output = None @@ -3439,14 +3482,41 @@ def _preprocess( # NOTE(woosuk): To unify token ids and soft tokens (vision # embeddings), we always use embeddings (rather than token ids) # as input to the multimodal model, even when the input is text. - inputs_embeds_scheduled = self.model.embed_input_ids( - self.input_ids.gpu[:num_scheduled_tokens], - multimodal_embeddings=mm_embeds, - is_multimodal=is_mm_embed, - ) + if self.enable_prompt_embeds and self.input_batch.req_prompt_embeds: + # Some positions carry precomputed prompt_embeds: they are + # already in self.inputs_embeds and marked is_token_ids=False. + # Embed only the token-id positions (zeroing the placeholder ids + # at prompt_embeds positions so the embedding gather cannot read + # out-of-range ids), and write them back without clobbering the + # prompt_embeds positions. + is_token_ids = self.is_token_ids.gpu[:num_scheduled_tokens] + safe_input_ids = torch.where( + is_token_ids, + self.input_ids.gpu[:num_scheduled_tokens], + 0, + ) + inputs_embeds_scheduled = self.model.embed_input_ids( + safe_input_ids, + multimodal_embeddings=mm_embeds, + is_multimodal=is_mm_embed, + ) + target = self.inputs_embeds.gpu[:num_scheduled_tokens] + self.inputs_embeds.gpu[:num_scheduled_tokens] = torch.where( + is_token_ids.unsqueeze(-1), + inputs_embeds_scheduled, + target, + ) + else: + inputs_embeds_scheduled = self.model.embed_input_ids( + self.input_ids.gpu[:num_scheduled_tokens], + multimodal_embeddings=mm_embeds, + is_multimodal=is_mm_embed, + ) - # TODO(woosuk): Avoid the copy. Optimize. - self.inputs_embeds.gpu[:num_scheduled_tokens].copy_(inputs_embeds_scheduled) + # TODO(woosuk): Avoid the copy. Optimize. + self.inputs_embeds.gpu[:num_scheduled_tokens].copy_( + inputs_embeds_scheduled + ) input_ids, inputs_embeds = self._prepare_mm_inputs(num_input_tokens) model_kwargs = { @@ -3470,8 +3540,7 @@ def _preprocess( token_ids_idx_np = np.nonzero(is_token_ids)[0] # Some tokens ids may need to become embeds if token_ids_idx_np.size > 0: - token_ids_idx = torch.from_numpy(token_ids_idx_np) - token_ids_idx = token_ids_idx.to(self.device, non_blocking=True) + token_ids_idx = async_tensor_h2d(token_ids_idx_np, device=self.device) token_ids = self.input_ids.gpu[token_ids_idx] tokens_to_embeds = self.model.embed_input_ids(input_ids=token_ids) self.inputs_embeds.gpu[token_ids_idx] = tokens_to_embeds @@ -4255,6 +4324,13 @@ def execute_model( # When spec decode is enabled, defer connector finalization # (wait_for_save + clear metadata) until after draft model runs. defer_kv_connector_finalize = self.speculative_config is not None + # Update the EPLB meta. + if self.eplb_state is not None: + self.eplb_state.prepare_forward( + self.model_config, + num_tokens_unpadded, + ubatch_slices_padded, + ) with ( set_forward_context( attn_metadata, @@ -4451,17 +4527,23 @@ def propose_draft_token_ids(sampled_token_ids): self._copy_draft_token_ids_to_cpu(scheduler_output) spec_config = self.speculative_config - propose_drafts_after_bookkeeping = False + draft_after_bookkeeping = False if spec_config is not None: # Decide whether to run the drafter or zero out draft tokens. input_fits_in_drafter = self._input_fits_in_drafter( spec_decode_common_attn_metadata ) - use_gpu_toks = ( + # Whether the drafter runs a GPU model forward (and thus carries + # TP/EP/DP collectives), independent of padded-batch timing. + drafter_runs_model_forward = ( spec_config.use_eagle() or spec_config.uses_draft_model() or spec_config.uses_extract_hidden_states() - ) and not spec_config.disable_padded_drafter_batch + ) + use_gpu_toks = ( + drafter_runs_model_forward + and not spec_config.disable_padded_drafter_batch + ) if use_gpu_toks: # EAGLE/DraftModel speculative decoding can use the GPU sampled tokens # as inputs, and does not need to wait for bookkeeping to finish. @@ -4476,19 +4558,23 @@ def propose_draft_token_ids(sampled_token_ids): sampled_token_ids = sampler_output.sampled_token_ids if input_fits_in_drafter: propose_draft_token_ids(sampled_token_ids) - elif self.valid_sampled_token_count_event is not None: - assert spec_decode_common_attn_metadata is not None - next_token_ids, valid_sampled_tokens_count = ( - self.drafter.prepare_next_token_ids_padded( - sampled_token_ids, - self.requests, - self.input_batch, - self.discard_request_mask.gpu, + else: + if self.valid_sampled_token_count_event is not None: + assert spec_decode_common_attn_metadata is not None + next_token_ids, valid_sampled_tokens_count = ( + self.drafter.prepare_next_token_ids_padded( + sampled_token_ids, + self.requests, + self.input_batch, + self.discard_request_mask.gpu, + ) ) - ) - self._copy_valid_sampled_token_count( - next_token_ids, valid_sampled_tokens_count - ) + self._copy_valid_sampled_token_count( + next_token_ids, valid_sampled_tokens_count + ) + if self.parallel_config.data_parallel_size > 1: + # Prevent hang when DP ranks disagree on input_fits_in_drafter + self.drafter.dummy_run(num_tokens=1) elif ( spec_config.use_ngram_gpu() and not spec_config.disable_padded_drafter_batch @@ -4512,7 +4598,9 @@ def propose_draft_token_ids(sampled_token_ids): next_token_ids, valid_sampled_tokens_count ) else: - propose_drafts_after_bookkeeping = input_fits_in_drafter + # These drafters consume CPU sampled tokens, so they run + # after bookkeeping. + draft_after_bookkeeping = True if not input_fits_in_drafter: # Zero out draft tokens so the scheduler doesn't schedule @@ -4544,10 +4632,25 @@ def propose_draft_token_ids(sampled_token_ids): scheduler_output.total_num_scheduled_tokens, ) - if propose_drafts_after_bookkeeping: + if draft_after_bookkeeping: # ngram and other speculative decoding methods use the sampled # tokens on the CPU, so they are run after bookkeeping. - propose_draft_token_ids(valid_sampled_token_ids) + if input_fits_in_drafter: + propose_draft_token_ids(valid_sampled_token_ids) + elif ( + drafter_runs_model_forward + and self.parallel_config.data_parallel_size > 1 + ): + # Prevent hang when DP ranks disagree on input_fits_in_drafter + assert isinstance( + self.drafter, + EagleProposer + | DFlashProposer + | DraftModelProposer + | ExtractHiddenStatesProposer + | Gemma4Proposer, + ) + self.drafter.dummy_run(num_tokens=1) # Finalize KV connector (wait_for_save + clear metadata) after # draft model runs. Deferred from target model forward to allow @@ -4624,6 +4727,7 @@ def propose_draft_token_ids(sampled_token_ids): async_output_copy_stream=self._get_or_create_async_output_copy_stream(), vocab_size=self.input_batch.vocab_size, routed_experts=routed_experts_snapshot, + check_ep_fault=self.check_ep_fault, ) with record_function_or_nullcontext( "gpu_model_runner: set_async_sampled_token_ids" @@ -4693,6 +4797,9 @@ def take_draft_token_ids(self) -> DraftTokenIds | None: def _copy_draft_token_ids_to_cpu( self, scheduler_output: "SchedulerOutput", zeros_only: bool = False ) -> None: + if torch.is_tensor(self._draft_token_ids): + assert isinstance(self._draft_token_ids, torch.Tensor) + self.prev_num_spec_tokens = self._draft_token_ids.shape[1] # Check if we need to copy draft tokens to CPU. In async scheduling, # we only copy when needed for structured output, penalties or bad_words. if self.use_async_scheduling and not ( @@ -4711,16 +4818,17 @@ def _copy_draft_token_ids_to_cpu( assert self.draft_token_ids_cpu is not None default_stream = torch.cuda.current_stream() num_reqs = draft_token_ids.shape[0] + num_spec_tokens = draft_token_ids.shape[1] with torch.cuda.stream(self.draft_token_ids_copy_stream): if not zeros_only: # Trigger async copy of draft token ids to cpu. self.draft_token_ids_copy_stream.wait_stream(default_stream) - self.draft_token_ids_cpu[:num_reqs].copy_( + self.draft_token_ids_cpu[:num_reqs, :num_spec_tokens].copy_( draft_token_ids, non_blocking=True ) else: # No copy needed, just zero-out cpu tensor. - self.draft_token_ids_cpu[:num_reqs] = 0 + self.draft_token_ids_cpu[:num_reqs, :num_spec_tokens] = 0 self.draft_token_ids_event.record() def _get_draft_token_ids_cpu(self) -> tuple[list[list[int]], list[str]]: @@ -4732,7 +4840,11 @@ def _get_draft_token_ids_cpu(self) -> tuple[list[list[int]], list[str]]: assert self.draft_token_ids_event is not None assert self.draft_token_ids_cpu is not None self.draft_token_ids_event.synchronize() - return self.draft_token_ids_cpu[: len(req_ids)].tolist(), req_ids + assert isinstance(self._draft_token_ids, torch.Tensor) + num_spec_tokens = self._draft_token_ids.shape[1] + return self.draft_token_ids_cpu[ + : len(req_ids), :num_spec_tokens + ].tolist(), req_ids def _copy_valid_sampled_token_count( self, next_token_ids: torch.Tensor, valid_sampled_tokens_count: torch.Tensor @@ -4812,6 +4924,7 @@ def propose_draft_token_ids( num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens spec_config = self.speculative_config assert spec_config is not None + num_spec_tokens_to_schedule = scheduler_output.num_spec_tokens_to_schedule self._draft_probs = None self._draft_prob_req_ids = None if spec_config.method == "ngram": @@ -4820,6 +4933,7 @@ def propose_draft_token_ids( assert isinstance(sampled_token_ids, list) assert isinstance(self.drafter, NgramProposer) draft_token_ids = self.drafter.propose( + num_spec_tokens_to_schedule, sampled_token_ids, self.input_batch.num_tokens_no_spec, self.input_batch.token_ids_cpu, @@ -4853,6 +4967,7 @@ def propose_draft_token_ids( batch_size = next_token_ids.shape[0] draft_token_ids, num_valid_draft_tokens = self.drafter.propose( + num_spec_tokens_to_schedule, self.num_tokens_no_spec_gpu[:batch_size], self.token_ids_gpu_tensor[:batch_size], valid_sampled_token_ids_gpu, @@ -4874,7 +4989,10 @@ def propose_draft_token_ids( assert isinstance(sampled_token_ids, list) assert isinstance(self.drafter, SuffixDecodingProposer) draft_token_ids = self.drafter.propose( - self.input_batch, sampled_token_ids, slot_mappings=slot_mappings + num_spec_tokens_to_schedule, + self.input_batch, + sampled_token_ids, + slot_mappings=slot_mappings, ) elif spec_config.method == "medusa": assert isinstance(sampled_token_ids, list) @@ -4894,10 +5012,11 @@ def propose_draft_token_ids( ): indices.append(offset + len(tokens) - 1) offset += num_draft + 1 - indices = torch.tensor(indices, device=self.device) + indices = async_tensor_h2d(indices, device=self.device) hidden_states = sample_hidden_states[indices] draft_token_ids = self.drafter.propose( + num_speculative_tokens=num_spec_tokens_to_schedule, target_hidden_states=hidden_states, sampling_metadata=sampling_metadata, slot_mappings=slot_mappings, @@ -4915,6 +5034,7 @@ def propose_draft_token_ids( target_hidden_states = [h[:num_scheduled_tokens] for h in aux_hidden_states] draft_token_ids = self.drafter.propose( + num_speculative_tokens=num_spec_tokens_to_schedule, sampled_token_ids=sampled_token_ids, target_hidden_states=target_hidden_states, common_attn_metadata=common_attn_metadata, @@ -5048,6 +5168,7 @@ def propose_draft_token_ids( mm_embed_inputs = None draft_token_ids = self.drafter.propose( + num_speculative_tokens=num_spec_tokens_to_schedule, target_token_ids=target_token_ids, target_positions=target_positions, target_hidden_states=target_hidden_states, @@ -5134,6 +5255,8 @@ def load_model(self, load_dummy_weights: bool = False) -> None: self.drafter.model, spec_config.draft_model_config, ) + assert hasattr(self.drafter, "set_eplb_state") + self.drafter.set_eplb_state(self.eplb_state) eplb_models += 1 self._setup_eagle3_aux_hidden_state_outputs() @@ -5321,11 +5444,12 @@ def reload_weights( """ Reload weights from a weights iterator or from disk - :param weights_iterator: weights to load into model - :param weights_path: path to load weights from if weights_iterator is not - provided. Use path of original model if neither is provided. - :param is_checkpoint_format: set to False if weights have already been processed - into kernel format (repacking, renaming, etc.) + Args: + weights_iterator: weights to load into model + weights_path: path to load weights from if weights_iterator is not + provided. Use path of original model if neither is provided. + is_checkpoint_format: set to False if weights have already been + processed into kernel format (repacking, renaming, etc.) """ # TODO(@kylesayrs): generalize to all runners and loaders # argument validation @@ -5390,6 +5514,9 @@ def reload_weights( weights_not_loaded, ) + self.reset_encoder_cache() + self.reset_mm_cache() + def _get_prompt_logprobs_dict( self, hidden_states: torch.Tensor, @@ -5417,8 +5544,8 @@ def _get_prompt_logprobs_dict( continue num_prompt_tokens = len(request.prompt_token_ids) - prompt_token_ids = torch.tensor(request.prompt_token_ids).to( - self.device, non_blocking=True + prompt_token_ids = async_tensor_h2d( + request.prompt_token_ids, device=self.device ) # Set up target LogprobsTensors object. @@ -5585,7 +5712,7 @@ def _get_mm_dummy_batch( for _, _, mm_kwargs_batch in group_and_batch_mm_kwargs( [(modality, dummy_mm_item)] * max_items_per_batch, device=self.device, - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) ) @@ -5787,6 +5914,9 @@ def _dummy_run( num_scheduled_tokens, self.query_pos.np ) self.query_start_loc.np[1 : num_reqs + 1] = cum_num_tokens + self.query_start_loc.np[num_reqs + 1 : num_reqs_padded + 1].fill( + cum_num_tokens[-1] + ) self.query_start_loc.copy_to_gpu() # Sync block table CPU->GPU so cleared rows from @@ -6288,7 +6418,7 @@ def shutdown(self) -> None: _ROPE_DICT.clear() reset_workspace_manager() - if current_platform.is_rocm(): + if current_platform.is_rocm() or current_platform.is_xpu(): gc.collect() torch.accelerator.empty_cache() torch.accelerator.synchronize() @@ -6430,7 +6560,7 @@ def profile_cudagraph_memory(self) -> int: mem_samples: list[int] = [] for i, desc in enumerate(profile_descs): - mem_before = torch.cuda.mem_get_info()[0] + mem_before = torch.accelerator.get_memory_info()[0] self._warmup_and_capture( desc, cudagraph_runtime_mode=mode, @@ -6444,7 +6574,7 @@ def profile_cudagraph_memory(self) -> int: ), ) torch.accelerator.synchronize() - free_after = torch.cuda.mem_get_info()[0] + free_after = torch.accelerator.get_memory_info()[0] mem_samples.append(mem_before - free_after) first_capture = mem_samples[0] @@ -6466,10 +6596,10 @@ def profile_cudagraph_memory(self) -> int: ) if encoder_cudagraph_manager is not None: - mem_before = torch.cuda.mem_get_info()[0] + mem_before = torch.accelerator.get_memory_info()[0] encoder_cudagraph_manager.capture(graph_pool=encoder_profiling_pool) torch.accelerator.synchronize() - free_after = torch.cuda.mem_get_info()[0] + free_after = torch.accelerator.get_memory_info()[0] encoder_memory_estimate = max(mem_before - free_after, 0) logger.debug( @@ -6535,7 +6665,7 @@ def capture_model(self) -> int: with self._freeze_gc(), graph_capture(device=self.device): torch.accelerator.synchronize() torch.accelerator.empty_cache() - start_free_gpu_memory = torch.cuda.mem_get_info()[0] + start_free_gpu_memory = torch.accelerator.get_memory_info()[0] for ( runtime_mode, @@ -6553,7 +6683,7 @@ def capture_model(self) -> int: self.encoder_cudagraph_manager.capture(graph_pool=encoder_graph_pool) torch.accelerator.synchronize() - end_free_gpu_memory = torch.cuda.mem_get_info()[0] + end_free_gpu_memory = torch.accelerator.get_memory_info()[0] # Disable cudagraph capturing globally, so any unexpected cudagraph # capturing will be detected and raise an error after here. @@ -6837,9 +6967,10 @@ def _check_and_update_cudagraph_mode( min_cg_support, min_cg_attn_backend, self.uniform_decode_query_len, - self.parallel_config.tensor_parallel_size, - self.kv_cache_config, - self.max_num_reqs, + use_v2_model_runner=False, + tensor_parallel_size=self.parallel_config.tensor_parallel_size, + kv_cache_config=self.kv_cache_config, + max_num_reqs=self.max_num_reqs, is_profiling=is_profiling, ) # Trigger cudagraph dispatching keys initialization after @@ -6882,46 +7013,6 @@ def calculate_reorder_batch_threshold(self) -> None: return self.reorder_batch_threshold = reduce(min_none_high, reorder_batch_thresholds) # type: ignore[assignment] - def _set_mm_prefix_range_for_metadata( - self, - attn_metadata: Any, - req_doc_ranges: dict[int, list[tuple[int, int]]], - ) -> None: - """Set mm_prefix_range for all attention metadata objects. - - This method handles both list and non-list attention metadata, - computing mm_prefix_range_tensor once and sharing it across all - metadata objects to avoid redundant host-to-device transfers. - """ - from vllm.v1.attention.backends.triton_attn import ( - TritonAttentionMetadata, - ) - - # Get all metadata objects from either list or dict structure - metadata_list = [] - if isinstance(attn_metadata, list): - for ub_metadata in attn_metadata: - metadata_list.extend(ub_metadata.values()) - else: - metadata_list.extend(attn_metadata.values()) - - # Set mm_prefix_range for all metadata and compute tensor once - shared_tensor = None - for metadata in metadata_list: - metadata.mm_prefix_range = req_doc_ranges # type: ignore[attr-defined] - - # Only compute tensor for TritonAttentionMetadata - if isinstance(metadata, TritonAttentionMetadata): - if shared_tensor is None: - shared_tensor = ( - TritonAttentionMetadata.compute_mm_prefix_range_tensor( - req_doc_ranges, - metadata.seq_lens.shape[0], # type: ignore[attr-defined] - metadata.seq_lens.device, # type: ignore[attr-defined] - ) - ) - metadata.mm_prefix_range_tensor = shared_tensor - def may_reinitialize_input_batch( self, kv_cache_config: KVCacheConfig, kernel_block_sizes: list[int] ) -> None: @@ -6966,7 +7057,6 @@ def may_reinitialize_input_batch( max_model_len=max_model_len, max_num_batched_tokens=self.max_num_tokens, device=self.device, - pin_memory=self.pin_memory, vocab_size=self.model_config.get_vocab_size(), block_sizes=block_sizes, kernel_block_sizes=kernel_block_sizes, @@ -7001,10 +7091,21 @@ def _allocate_kv_cache_tensors( corresponding memory buffer for KV cache. """ kv_cache_raw_tensors: dict[str, torch.Tensor] = {} + packed_backing: torch.Tensor | None = None for kv_cache_tensor in kv_cache_config.kv_cache_tensors: - tensor = torch.zeros( - kv_cache_tensor.size, dtype=torch.int8, device=self.device - ) + if kv_cache_tensor.block_stride > 0: + # Allocate once; all packed tensors alias the same backing. + if packed_backing is None: + packed_backing = torch.zeros( + kv_cache_tensor.size, + dtype=torch.int8, + device=self.device, + ) + tensor = packed_backing + else: + tensor = torch.zeros( + kv_cache_tensor.size, dtype=torch.int8, device=self.device + ) for layer_name in kv_cache_tensor.shared_by: kv_cache_raw_tensors[layer_name] = tensor @@ -7046,6 +7147,14 @@ def _reshape_kv_cache_tensors( """ kv_caches: dict[str, torch.Tensor] = {} has_attn, has_mamba = False, False + + # Map layer names to (offset, block_stride) within the packed + # backing tensor so we can create strided views per layer. + layer_packing: dict[str, tuple[int, int]] = {} + for kv_tensor in self.kv_cache_config.kv_cache_tensors: + if kv_tensor.block_stride > 0: + for ln in kv_tensor.shared_by: + layer_packing[ln] = (kv_tensor.offset, kv_tensor.block_stride) for group in self._kv_cache_spec_attn_group_iterator(): kv_cache_spec = group.kv_cache_spec attn_backend = group.backend @@ -7057,8 +7166,13 @@ def _reshape_kv_cache_tensors( if layer_name in self.runner_only_attn_layers: continue raw_tensor = kv_cache_raw_tensors[layer_name] - assert raw_tensor.numel() % kv_cache_spec.page_size_bytes == 0 - num_blocks = raw_tensor.numel() // kv_cache_spec.page_size_bytes + packing = layer_packing.get(layer_name) + if packing is not None: + _, blk_stride = packing + num_blocks = raw_tensor.numel() // blk_stride + else: + assert raw_tensor.numel() % kv_cache_spec.page_size_bytes == 0 + num_blocks = raw_tensor.numel() // kv_cache_spec.page_size_bytes if isinstance(kv_cache_spec, AttentionSpec): has_attn = True num_blocks_per_kv_block = ( @@ -7072,56 +7186,39 @@ def _reshape_kv_cache_tensors( else: shape_block_size = kernel_block_size + # Skipped layers (--kv-cache-dtype-skip-layers) need + # the unquantized shape. + layer_cache_dtype_str = ( + "auto" + if kv_cache_spec.kv_quant_mode == KVQuantMode.NONE + else getattr( + kv_cache_spec, + "cache_dtype_str", + None, + ) + or self.cache_config.cache_dtype + ) kv_cache_shape = attn_backend.get_kv_cache_shape( kernel_num_blocks, shape_block_size, kv_cache_spec.num_kv_heads, kv_cache_spec.head_size, - cache_dtype_str=self.cache_config.cache_dtype, + cache_dtype_str=layer_cache_dtype_str, ) - dtype = kv_cache_spec.dtype try: kv_cache_stride_order = attn_backend.get_kv_cache_stride_order() assert len(kv_cache_stride_order) == len(kv_cache_shape) except (AttributeError, NotImplementedError): kv_cache_stride_order = tuple(range(len(kv_cache_shape))) - # The allocation respects the backend-defined stride order - # to ensure the semantic remains consistent for each - # backend. We first obtain the generic kv cache shape and - # then permute it according to the stride order which could - # result in a non-contiguous tensor. - kv_cache_shape = tuple( - kv_cache_shape[i] for i in kv_cache_stride_order + raw_tensor = kv_cache_raw_tensors[layer_name] + kv_caches[layer_name] = _reshape_attention_kv_cache( + raw_tensor, + kv_cache_spec, + kv_cache_shape, + kv_cache_stride_order, + kernel_num_blocks, + packing, ) - # Maintain original KV shape view. - inv_order = [ - kv_cache_stride_order.index(i) - for i in range(len(kv_cache_stride_order)) - ] - - raw_tensor = kv_cache_raw_tensors[layer_name].view(dtype) - if kv_cache_spec.page_size_padded is not None: - # Use strided view to handle page_size_bytes that - # include padding. This follows - # the same pattern as MambaSpec handling below. - # NOTE: This assumes kv_cache_shape[0] == num_blocks - # (i.e. the first physical dimension is the block - # index), which holds for MLA backends but NOT for - # standard attention backends whose shape starts with - # a K/V dimension of size 2. - dtype_size = get_dtype_size(dtype) - page_stride = kv_cache_spec.page_size_bytes // dtype_size - strides = list(torch.empty(kv_cache_shape).stride()) - strides[inv_order[0]] = page_stride - kv_cache = torch.as_strided( - raw_tensor, - size=kv_cache_shape, - stride=tuple(strides), - ) - else: - # No padding — safe to use a contiguous view. - kv_cache = raw_tensor.view(kv_cache_shape) - kv_caches[layer_name] = kv_cache.permute(*inv_order) elif isinstance(kv_cache_spec, MambaSpec): has_mamba = True @@ -7150,11 +7247,43 @@ def _reshape_kv_cache_tensors( else: raise NotImplementedError - if has_attn and has_mamba: + # Reconcile divergent KV layouts to blocks-first. Triggered by hybrid + # attention/mamba models, and by encoder-decoder models whose shared + # decoder/cross-attention allocation mixes K/V-first and blocks-first + # backends (see _has_mixed_attention_kv_layout). + if has_attn and ( + has_mamba or self._has_mixed_attention_kv_layout(kernel_block_sizes) + ): self._update_hybrid_attention_mamba_layout(kv_caches, kernel_block_sizes) return kv_caches + def _has_mixed_attention_kv_layout(self, kernel_block_sizes: list[int]) -> bool: + """Whether attention groups disagree on the physical KV cache layout. + + Encoder-decoder models (e.g. Whisper) share one raw KV allocation + between a decoder self-attention layer (K/V-first ROCM_ATTN, block dim + 1) and a cross-attention layer (blocks-first, block dim 0). Mixed block + dims mean a block ID maps to different bytes per layer, so the shared + buffer must be normalized to a single (blocks-first) layout. + """ + block_dims: set[int] = set() + for group in self._kv_cache_spec_attn_group_iterator(): + kv_cache_spec = group.kv_cache_spec + if not isinstance(kv_cache_spec, AttentionSpec): + continue + if group.kv_cache_group_id == len(kernel_block_sizes): + continue + block_dims.add( + group.backend.get_kv_cache_block_dim( + kernel_block_sizes[group.kv_cache_group_id], + kv_cache_spec.num_kv_heads, + kv_cache_spec.head_size, + cache_dtype_str=self.cache_config.cache_dtype, + ) + ) + return len(block_dims) > 1 + def _update_hybrid_attention_mamba_layout( self, kv_caches: dict[str, torch.Tensor], kernel_block_sizes: list[int] ) -> None: @@ -7206,7 +7335,7 @@ def initialize_kv_cache_tensors( # Try creating KV caches optimized for kv-connector transfers cache_dtype = self.cache_config.cache_dtype - if self.use_uniform_kv_cache(self.attn_groups, cache_dtype): + if self.use_uniform_kv_cache(self.attn_groups): kv_caches, cross_layers_kv_cache, attn_backend = ( self.allocate_uniform_kv_caches( kv_cache_config, @@ -7364,7 +7493,7 @@ def init_routed_experts_capturer(self): self.routed_experts_capturer.device_buffer.shape, dtype=self.routed_experts_capturer.device_buffer.dtype, device="cpu", - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) # ``slot_mapping`` dtype is fixed to int64 by # ``block_table.slot_mapping``; we mirror that here. @@ -7373,7 +7502,7 @@ def init_routed_experts_capturer(self): (max_tokens,), dtype=torch.int64, device="cpu", - pin_memory=self.pin_memory, + pin_memory=PIN_MEMORY, ) # Private device buffer so the shared ``block_table.slot_mapping`` # can be overwritten by the next ``_prepare_inputs`` while the @@ -7388,13 +7517,13 @@ def init_routed_experts_capturer(self): self.routed_experts_initialized = True def _bind_routed_experts_capturer(self, capturer: RoutedExpertsCapturer) -> None: - from vllm.model_executor.layers.fused_moe.layer import FusedMoE + from vllm.model_executor.layers.fused_moe.layer import MoERunner from vllm.model_executor.layers.fused_moe.router.base_router import ( BaseRouter, ) for module in self.compilation_config.static_forward_context.values(): - if isinstance(module, FusedMoE) and isinstance(module.router, BaseRouter): + if isinstance(module, MoERunner) and isinstance(module.router, BaseRouter): layer_id = module.layer_id def _capture_fn(topk_ids, _layer_id=layer_id, _capturer=capturer): @@ -7456,6 +7585,13 @@ def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]: continue # Skip modules that don't need KV cache (eg encoder-only attention) if spec := attn_module.get_kv_cache_spec(self.vllm_config): + if isinstance(spec, AttentionSpec): + backend = attn_module.get_attn_backend() + # indexes_kv_by_block_stride() -> get_kv_cache_stride_order() + # -> get_kv_cache_layout() needs the current vLLM config. + with set_current_vllm_config(self.vllm_config): + indexes = backend.indexes_kv_by_block_stride() + spec = replace(spec, indexes_kv_by_block_stride=indexes) kv_cache_spec[layer_name] = spec return kv_cache_spec diff --git a/vllm/v1/worker/gpu_ubatch_wrapper.py b/vllm/v1/worker/gpu_ubatch_wrapper.py index 657fc8267345..7b619998435b 100644 --- a/vllm/v1/worker/gpu_ubatch_wrapper.py +++ b/vllm/v1/worker/gpu_ubatch_wrapper.py @@ -154,6 +154,16 @@ def clear_graphs(self) -> None: @staticmethod def _create_sm_control_context(vllm_config: VllmConfig): comm_sms: int = envs.VLLM_DBO_COMM_SMS + rocm_deepep_ht_dbo = ( + current_platform.is_rocm() + and vllm_config.parallel_config.enable_dbo + and vllm_config.parallel_config.all2all_backend == "deepep_high_throughput" + ) + if rocm_deepep_ht_dbo: + # On ROCm, reserving CUs for DeepEP HT communication under DBO + # corrupts DP+EP generation accuracy. Keep the backend active, but + # leave all CUs visible to the compute and communication kernels. + comm_sms = 0 set_comm_sms = lambda sms: None if vllm_config.parallel_config.enable_expert_parallel: @@ -242,7 +252,7 @@ def _capture_ubatch_thread(results, ubatch_metadata): results: list[tuple[int, torch.Tensor]] = [] compute_stream = ubatch_metadata[0].context.compute_stream - num_tokens = ubatch_metadata[0].num_tokens + ubatch_metadata[1].num_tokens + num_tokens = sum(m.num_tokens for m in ubatch_metadata) # Ubatches will manually manage the forward context, so we override # it to None here so we can have it restored correctly later @@ -258,7 +268,7 @@ def _capture_ubatch_thread(results, ubatch_metadata): ) ubatch_threads.append(thread) thread.start() - self.ready_barrier.wait() # Wait for both threads to be ready + self.ready_barrier.wait() # Wait for all ubatch threads to be ready # Capture the cudagraph cudagraph_metadata = CUDAGraphMetaData( @@ -322,7 +332,7 @@ def _ubatch_thread(results, model, ubatch_metadata): ) ubatch_threads.append(thread) thread.start() - self.ready_barrier.wait() # Wait for both threads to be ready + self.ready_barrier.wait() # Wait for all ubatch threads to be ready ubatch_metadata[0].context.cpu_wait_event.set() for thread in ubatch_threads: thread.join() diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py index 259cd05554cb..327ba6195f5b 100644 --- a/vllm/v1/worker/gpu_worker.py +++ b/vllm/v1/worker/gpu_worker.py @@ -4,6 +4,7 @@ import gc import os +import time from collections.abc import Callable from contextlib import AbstractContextManager, contextmanager, nullcontext from datetime import timedelta @@ -18,6 +19,7 @@ import vllm.envs as envs from vllm.config import CUDAGraphMode, VllmConfig, set_current_vllm_config from vllm.config.compilation import CompilationMode +from vllm.device_allocator import get_mem_allocator_instance from vllm.distributed import ( ensure_model_parallel_initialized, init_distributed_environment, @@ -34,6 +36,9 @@ get_kv_transfer_group, has_kv_transfer_group, ) +from vllm.distributed.kv_transfer.kv_connector.v1.base import ( + KVConnectorHandshakeMetadata, +) from vllm.distributed.parallel_state import ( Handle, get_pp_group, @@ -46,11 +51,19 @@ from vllm.logger import init_logger from vllm.lora.request import LoRARequest from vllm.model_executor.warmup.kernel_warmup import kernel_warmup +from vllm.multimodal.video import ( + PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES, + PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES, + PYNVVIDEOCODEC_MAX_RETAINED_DECODERS, + VIDEO_LOADER_REGISTRY, +) from vllm.platforms import current_platform from vllm.profiler.wrapper import CudaProfilerWrapper, TorchProfilerWrapper from vllm.sequence import IntermediateTensors from vllm.tasks import SupportedTask from vllm.tracing import instrument +from vllm.utils.gc_utils import freeze_gc_heap, maybe_attach_gc_debug_callback +from vllm.utils.gpu_sync_debug import enable_gpu_sync_check, with_gpu_sync_check from vllm.utils.mem_constants import GiB_bytes from vllm.utils.mem_utils import MemorySnapshot, format_gib, memory_profiling from vllm.utils.torch_utils import set_random_seed @@ -62,6 +75,10 @@ ModelRunnerOutput, ) from vllm.v1.utils import compute_iteration_details, report_usage_stats +from vllm.v1.worker.startup_plan import ( + maybe_apply_startup_plan, + maybe_save_startup_plan, +) from vllm.v1.worker.utils import is_residual_scattered_for_sp from vllm.v1.worker.worker_base import CompilationTimes, WorkerBase from vllm.v1.worker.workspace import init_workspace_manager @@ -73,6 +90,7 @@ logger = init_logger(__name__) if TYPE_CHECKING: + from vllm.device_allocator.sleep_mode_backend import SleepModeBackend from vllm.model_executor.model_loader.tensorizer import TensorizerConfig from vllm.v1.worker.gpu_model_runner import GPUModelRunner @@ -141,7 +159,6 @@ def __init__( # is available, since the engine needs a reference to the model. self.weight_transfer_engine: WeightTransferEngine | None = None self._weight_update_active = False - self._is_checkpoint_format = True # Torch/CUDA profiler. Enabled and configured through profiler_config. # Profiler wrapper is created lazily in profile() when start is called, @@ -157,10 +174,23 @@ def __init__( # pending non-blocking PP send work from the previous iteration self._pp_send_work: list[Handle] = [] - def sleep(self, level: int = 1) -> None: - from vllm.device_allocator.cumem import CuMemAllocator + # Resolved lazily on first sleep/wake; persists worker-process state. + self._sleep_mode_backend: SleepModeBackend | None = None - free_bytes_before_sleep = torch.cuda.mem_get_info()[0] + def _get_sleep_mode_backend(self) -> "SleepModeBackend": + if self._sleep_mode_backend is None: + from vllm.device_allocator.sleep_mode_backend import ( + SleepModeBackendFactory, + ) + + self._sleep_mode_backend = SleepModeBackendFactory.create_backend( + self.vllm_config.model_config + ) + return self._sleep_mode_backend + + def sleep(self, level: int = 1) -> None: + torch.accelerator.synchronize() + free_bytes_before_sleep = torch.accelerator.get_memory_info()[0] # Save the buffers before level 2 sleep if level == 2: @@ -169,10 +199,17 @@ def sleep(self, level: int = 1) -> None: name: buffer.cpu().clone() for name, buffer in model.named_buffers() } - allocator = CuMemAllocator.get_instance() - allocator.sleep(offload_tags=("weights",) if level == 1 else tuple()) - free_bytes_after_sleep, total = torch.cuda.mem_get_info() - freed_bytes = free_bytes_after_sleep - free_bytes_before_sleep + self._get_sleep_mode_backend().suspend(level) + + torch.accelerator.synchronize() + deadline = time.monotonic() + (5.0 if current_platform.is_rocm() else 0) + while True: + free_bytes_after_sleep, total = torch.accelerator.get_memory_info() + freed_bytes = free_bytes_after_sleep - free_bytes_before_sleep + if freed_bytes >= 0 or time.monotonic() >= deadline: + break + time.sleep(0.1) + used_bytes = total - free_bytes_after_sleep assert freed_bytes >= 0, "Memory usage increased after sleeping." logger.info( @@ -182,10 +219,7 @@ def sleep(self, level: int = 1) -> None: ) def wake_up(self, tags: list[str] | None = None) -> None: - from vllm.device_allocator.cumem import CuMemAllocator - - allocator = CuMemAllocator.get_instance() - allocator.wake_up(tags) + self._get_sleep_mode_backend().resume(tags) # Restore the buffers after level 2 sleep if len(self._sleep_saved_buffers): @@ -199,12 +233,22 @@ def wake_up(self, tags: list[str] | None = None) -> None: self.model_runner.post_kv_cache_wake_up() def _maybe_get_memory_pool_context(self, tag: str) -> AbstractContextManager: - if not self.vllm_config.model_config.enable_cumem_allocator: + if ( + current_platform.is_cuda_alike() + and not self.vllm_config.model_config.enable_cumem_allocator + ): return nullcontext() - from vllm.device_allocator.cumem import CuMemAllocator + if ( + current_platform.is_xpu() + and not self.vllm_config.model_config.enable_sleep_mode + ): + return nullcontext() - allocator = CuMemAllocator.get_instance() + if current_platform.is_cpu(): + return nullcontext() + + allocator = get_mem_allocator_instance() if tag == "weights": assert allocator.get_current_usage() == 0, ( "CuMem allocator can only be used for one instance per process." @@ -259,19 +303,47 @@ def init_device(self): # DP_LOCAL_RANK * TP_PP_WORLD_SIZE + TP_LOCAL_RANK self.local_rank += dp_local_rank * tp_pp_world_size - assert self.local_rank < torch.accelerator.device_count(), ( - f"DP adjusted local rank {self.local_rank} is out of bounds. " - ) - visible_device_count = ( - torch.accelerator.device_count() if torch.cuda.is_available() else 0 + + # Publish the logical-to-physical mapping for topology queries + # such as NIC affinity and P2P checks. + assigned_physical_gpu_ids = parallel_config.assigned_physical_gpu_ids + if assigned_physical_gpu_ids is not None: + from vllm.platforms.interface import set_assigned_physical_gpu_ids + + set_assigned_physical_gpu_ids(assigned_physical_gpu_ids) + assert self.local_rank < len(assigned_physical_gpu_ids), ( + f"local_rank {self.local_rank} is out of bounds for " + f"assigned_physical_gpu_ids {assigned_physical_gpu_ids}" ) - assert self.parallel_config.local_world_size <= visible_device_count, ( - f"local_world_size ({self.parallel_config.local_world_size}) must " - f"be less than or equal to the number of visible devices " - f"({visible_device_count})." + # NOTE(patch pr45026): local_world_size is derived from + # parallel_config.nnodes, which is only set for the "mp" + # multi-node backend. With the "ray"/"external_launcher" + # backends nnodes stays 1, so local_world_size collapses to + # the full world_size and this check wrongly fires on + # cross-node deployments. assigned_physical_gpu_ids is already + # per-node and the local_rank bound above fully validates the + # mapping for these backends, so skip the check for them. + if parallel_config.distributed_executor_backend not in ( + "ray", + "external_launcher", + ): + assert self.parallel_config.local_world_size <= len( + assigned_physical_gpu_ids + ), ( + f"local_world_size ({self.parallel_config.local_world_size})" + " exceeds assigned_physical_gpu_ids count " + f"({len(assigned_physical_gpu_ids)})" + ) + else: + assert self.local_rank < torch.accelerator.device_count(), ( + f"DP adjusted local rank {self.local_rank} is out of " + f"bounds for {torch.accelerator.device_count()} devices." ) - self.device = torch.device(f"cuda:{self.local_rank}") + visible_device_index = ( + current_platform.logical_device_id_to_visible_device_id(self.local_rank) + ) + self.device = torch.device(f"cuda:{visible_device_index}") torch.accelerator.set_device_index(self.device) current_platform.check_if_supports_dtype(self.model_config.dtype) @@ -306,7 +378,7 @@ def init_device(self): "worker requested memory: %sGiB", format_gib(self.requested_memory) ) else: - raise RuntimeError(f"Not support device type: {self.device_config.device}") + raise RuntimeError(f"Unsupported device type: {self.device_config.device}") # Initialize workspace manager num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1 @@ -347,7 +419,8 @@ def load_model(self, *, load_dummy_weights: bool = False) -> None: if self.vllm_config.weight_transfer_config is not None: self.weight_transfer_engine = WeightTransferEngineFactory.create_engine( self.vllm_config.weight_transfer_config, - self.vllm_config.parallel_config, + self.vllm_config, + self.device, self.model_runner.get_model(), ) @@ -370,6 +443,8 @@ def determine_available_memory(self) -> int: You may limit the usage of GPU memory by adjusting the `gpu_memory_utilization` parameter. """ + maybe_apply_startup_plan(self) + if kv_cache_memory_bytes := self.cache_config.kv_cache_memory_bytes: # still need a profile run which compiles the model for # max_num_batched_tokens @@ -388,7 +463,7 @@ def determine_available_memory(self) -> int: "correspondingly." ) logger.info(msg) - return kv_cache_memory_bytes + return self._reserve_mm_ipc_gpu_memory(kv_cache_memory_bytes) # Execute a forward pass with dummy inputs to profile the memory usage # of the model. @@ -403,8 +478,8 @@ def determine_available_memory(self) -> int: ) # Profile CUDA graph memory if graphs will be captured. - # Skip on ROCm/HIP/XPU as graph pool handles and mem_get_info behave - # differently and can produce incorrect/negative estimates. + # Skip on ROCm/HIP/XPU as graph pool handles and get_memory_info + # behave differently and can produce incorrect/negative estimates. cudagraph_memory_estimate = 0 if ( current_platform.is_cuda() @@ -510,10 +585,89 @@ def determine_available_memory(self) -> int: suggested_util, ) - return int(self.available_kv_cache_memory_bytes) + return self._reserve_mm_ipc_gpu_memory( + int(self.available_kv_cache_memory_bytes) + ) + + @staticmethod + def _uses_gpu_video_backend(mm_config) -> bool: + video_kwargs = mm_config.media_io_kwargs.get("video", {}) + video_loader_backend = ( + video_kwargs.get("video_backend") or envs.VLLM_VIDEO_LOADER_BACKEND + ) + codec_backend = video_kwargs.get("backend") + return VIDEO_LOADER_REGISTRY.backend_requires_gpu(video_loader_backend) or ( + codec_backend is not None + and VIDEO_LOADER_REGISTRY.backend_requires_gpu(codec_backend) + ) + + def _reserve_mm_ipc_gpu_memory(self, available_kv_cache_memory_bytes: int) -> int: + """Carve frontend multimodal GPU memory out of the KV cache. + + The frontend (API-server) process allocates GPU memory for hardware + multimodal decoding. Raw decoded frames are bounded by + ``mm_ipc_gpu_memory_gb`` and acquired by the frontend semaphore. Some + decoders also keep persistent surfaces around; reserve a fixed upper + bound for those when the corresponding backend is configured. + """ + mm_config = self.model_config.multimodal_config + if mm_config is None: + return available_kv_cache_memory_bytes + + raw_frame_reserved_bytes = int(mm_config.mm_ipc_gpu_memory_gb * GiB_bytes) + # Each api_server_count process runs its OWN decoder surfaces + NVDEC/CUVID + # CUDA context on the GPU, outside this (worker) memory pool. Reserve that + # per-server footprint x api_server_count so gpu_memory_utilization bounds + # TOTAL GPU usage across all API-server processes. Without the multiply, + # HW decode overshoots the budget by ~(api_server_count-1) x per-server and + # OOMs at high gmu, while SW decode (no per-server GPU allocation) does not. + num_api_servers = max(1, getattr(self.parallel_config, "_api_process_count", 1)) + per_server_decoder_bytes = ( + PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES + * PYNVVIDEOCODEC_MAX_RETAINED_DECODERS + + PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES + ) + decoder_reserved_bytes = ( + num_api_servers * per_server_decoder_bytes + if self._uses_gpu_video_backend(mm_config) + else 0 + ) + reserved_bytes = raw_frame_reserved_bytes + decoder_reserved_bytes + if reserved_bytes <= 0: + return available_kv_cache_memory_bytes + + remaining = available_kv_cache_memory_bytes - reserved_bytes + if remaining <= 0: + raise ValueError( + f"frontend multimodal GPU decoding reserves " + f"{format_gib(reserved_bytes)} GiB " + f"({format_gib(raw_frame_reserved_bytes)} GiB raw-frame budget, " + f"{format_gib(decoder_reserved_bytes)} GiB decoder cache budget), " + f"but only {format_gib(available_kv_cache_memory_bytes)} GiB is " + "available for the KV cache. Reduce mm_ipc_gpu_memory_gb, use a " + "different video backend, or increase gpu_memory_utilization." + ) + logger.info_once( + "Reserving %s GiB of GPU memory for frontend multimodal decoding " + "(%s GiB raw-frame semaphore budget, %s GiB decoder+CUDA-context " + "across %d API server(s) @ %s GiB/server); " + "KV cache memory reduced to %s GiB.", + format_gib(reserved_bytes), + format_gib(raw_frame_reserved_bytes), + format_gib(decoder_reserved_bytes), + num_api_servers, + format_gib(per_server_decoder_bytes), + format_gib(remaining), + ) + return remaining + + def get_kv_connector_handshake_metadata( + self, + ) -> dict[tuple[int, int], KVConnectorHandshakeMetadata] | None: + """Get KV connector metadata from this worker if available. - def get_kv_connector_handshake_metadata(self) -> dict | None: - """Get KV connector metadata from this worker if available.""" + Returned dict is keyed by `(pp_rank, tp_rank)`. + """ if not has_kv_transfer_group(): return None @@ -524,8 +678,9 @@ def get_kv_connector_handshake_metadata(self) -> dict | None: if (metadata := connector.get_handshake_metadata()) is None: return None + pp_rank = get_pp_group().rank_in_group tp_rank = get_tp_group().rank_in_group - return {tp_rank: metadata} + return {(pp_rank, tp_rank): metadata} def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]: return self.model_runner.get_kv_cache_spec() @@ -574,6 +729,11 @@ def initialize_from_config(self, kv_cache_config: KVCacheConfig) -> None: @instrument(span_name="Warmup (GPU)") def compile_or_warm_up_model(self) -> CompilationTimes: warmup_sizes: list[int] = [] + cg_capture_sizes: list[int] = [] + + if self.vllm_config.compilation_config.cudagraph_mode != CUDAGraphMode.NONE: + cg_sizes = self.vllm_config.compilation_config.cudagraph_capture_sizes + cg_capture_sizes = [] if cg_sizes is None else cg_sizes if self.vllm_config.compilation_config.mode == CompilationMode.VLLM_COMPILE: # warm up sizes that are not in cudagraph capture sizes, @@ -581,11 +741,8 @@ def compile_or_warm_up_model(self) -> CompilationTimes: # e.g. for the max-num-batched token size in chunked prefill. compile_sizes = self.vllm_config.compilation_config.compile_sizes warmup_sizes = compile_sizes.copy() if compile_sizes is not None else [] # type: ignore[assignment] - cg_capture_sizes: list[int] = [] if self.vllm_config.compilation_config.cudagraph_mode != CUDAGraphMode.NONE: - cg_sizes = self.vllm_config.compilation_config.cudagraph_capture_sizes - cg_capture_sizes = [] if cg_sizes is None else cg_sizes warmup_sizes = [x for x in warmup_sizes if x not in cg_capture_sizes] compile_ranges = self.vllm_config.compilation_config.get_compile_ranges() @@ -598,6 +755,23 @@ def compile_or_warm_up_model(self) -> CompilationTimes: if not any(x in compile_range for x in all_sizes): warmup_sizes.append(compile_range.end) + # TODO(LucasWilkinson, akaratza): Remove when MRV1 is deprecated + if ( + current_platform.is_rocm() + and not self.use_v2_model_runner + and self.vllm_config.compilation_config.cudagraph_mode != CUDAGraphMode.NONE + and get_pp_group().is_last_rank + ): + max_num_reqs = min( + self.scheduler_config.max_num_seqs, + self.scheduler_config.max_num_batched_tokens, + ) + if ( + max_num_reqs not in cg_capture_sizes + and max_num_reqs not in warmup_sizes + ): + warmup_sizes.append(max_num_reqs) + # We skip EPLB here since we don't want to record dummy metrics for size in sorted(warmup_sizes, reverse=True): logger.info("Compile and warming up model for size %d", size) @@ -683,7 +857,9 @@ def compile_or_warm_up_model(self) -> CompilationTimes: f"{format_gib(self.available_kv_cache_memory_bytes)} GiB." ) - logger.debug(msg) + logger.info(msg) + + maybe_save_startup_plan(self, kv_cache_memory_bytes_to_requested_limit) if self.use_v2_model_runner: # V2: Run full execute_model + sample_tokens to JIT compile triton kernels. @@ -714,13 +890,33 @@ def compile_or_warm_up_model(self) -> CompilationTimes: # the model initialization and profiling. set_random_seed(self.model_config.seed) + # Eagerly trigger inductor's once-per-process lazy inits during + # warmup (rather than on a later compile cache-miss at runtime). + c_config = self.compilation_config + if c_config.mode != CompilationMode.NONE and c_config.backend == "inductor": + from vllm.compilation.compiler_interface import ( + trigger_inductor_lazy_init, + ) + + trigger_inductor_lazy_init(self.device) + # All warmup is done — start monitoring for unexpected JIT # compilations that would cause latency spikes during inference. - from vllm.triton_utils.jit_monitor import ( - activate as activate_triton_jit_monitor, + from vllm.utils.jit_monitor import activate as activate_jit_monitor + + activate_jit_monitor( + mode=self.observability_config.jit_monitor_mode, + verbose=self.observability_config.jit_monitor_verbose, ) - activate_triton_jit_monitor() + # Freeze the worker heap so the GC won't scan static objects + # (model weights, KV caches, CUDA graphs) during inference. + freeze_gc_heap() + maybe_attach_gc_debug_callback() + + # Warmup / first-compile is done — activate the `VLLM_GPU_SYNC_CHECK` + # gate so subsequent `execute_model` / `sample_tokens` calls enforce it. + enable_gpu_sync_check() return CompilationTimes( language_model=self.compilation_config.compilation_time, @@ -775,12 +971,14 @@ def annotate_profile(self, scheduler_output): return self.profiler.annotate_context_manager(annotation) @torch.inference_mode() + @with_gpu_sync_check def sample_tokens( self, grammar_output: "GrammarOutput | None" ) -> ModelRunnerOutput | AsyncModelRunnerOutput: return self.model_runner.sample_tokens(grammar_output) @torch.inference_mode() + @with_gpu_sync_check def execute_model( self, scheduler_output: "SchedulerOutput" ) -> ModelRunnerOutput | AsyncModelRunnerOutput | None: @@ -990,16 +1188,16 @@ def init_weight_transfer_engine(self, init_info: dict) -> None: typed_init_info = self.weight_transfer_engine.parse_init_info(init_info) self.weight_transfer_engine.init_transfer_engine(typed_init_info) - def start_weight_update(self, is_checkpoint_format: bool = True) -> None: + def start_weight_update(self) -> None: """ Start a new weight update session. - Args: - is_checkpoint_format: Whether incoming weights are in checkpoint - format (need layerwise processing) or kernel format (direct - copy / sparse patch application). + Delegates engine-specific preparation (e.g. layerwise reload setup) to + the configured weight transfer engine. The worker only tracks that a + session is active. """ self._check_weight_transfer_engine() + assert self.weight_transfer_engine is not None if self._weight_update_active: raise RuntimeError( @@ -1007,16 +1205,7 @@ def start_weight_update(self, is_checkpoint_format: bool = True) -> None: "active. Call finish_weight_update first." ) - if is_checkpoint_format: - from vllm.model_executor.model_loader.reload import ( - initialize_layerwise_reload, - ) - - model = self.model_runner.model - with torch.device(self.device): - initialize_layerwise_reload(model) - - self._is_checkpoint_format = is_checkpoint_format + self.weight_transfer_engine.start_weight_update() self._weight_update_active = True def update_weights(self, update_info: dict) -> None: @@ -1037,84 +1226,28 @@ def update_weights(self, update_info: dict) -> None: "start_weight_update must be called before update_weights." ) - update_succeeded = False try: - # Parse dict into backend-specific typed dataclass - typed_update_info = self.weight_transfer_engine.parse_update_info( - update_info - ) - - with torch.device(self.device): - if self._is_checkpoint_format: - if typed_update_info.update_kind != "dense": - raise ValueError( - "Sparse weight updates require " - "`start_weight_update(is_checkpoint_format=False)`." - ) - - model = self.model_runner.model - - # Use layerwise reload pattern for checkpoint format weights - self.weight_transfer_engine.receive_weights( - typed_update_info, - load_weights=model.load_weights, - ) - elif typed_update_info.update_kind == "sparse_flat": - if self.parallel_config.world_size != 1: - raise NotImplementedError( - "Sparse weight updates currently require TP=1 and PP=1" - ) - self.weight_transfer_engine.receive_sparse_weights( - typed_update_info, - apply_patches=self.model_runner.apply_sparse_weight_patches, - ) - else: - model = self.model_runner.model - - # Weights are already in kernel format, copy directly. - def load_weights_direct( - weights: list[tuple[str, torch.Tensor]], - ) -> None: - for name, weight in weights: - param = model.get_parameter(name) - param.copy_(weight) - - self.weight_transfer_engine.receive_weights( - typed_update_info, - load_weights=load_weights_direct, - ) - - # NCCL broadcast/packed path are asynchronous. - # Sync here so the next step uses the new weights. - torch.accelerator.synchronize() - update_succeeded = True - finally: - if not update_succeeded: - self._weight_update_active = False - self._is_checkpoint_format = True + self.weight_transfer_engine.update_weights(update_info) + except BaseException: + self._weight_update_active = False + raise def finish_weight_update(self) -> None: """Finish the current weight update session.""" self._check_weight_transfer_engine() + assert self.weight_transfer_engine is not None if not self._weight_update_active: raise RuntimeError( "finish_weight_update called without a matching start_weight_update." ) - if self._is_checkpoint_format: - from vllm.model_executor.model_loader.reload import ( - finalize_layerwise_reload, - ) - - model = self.model_runner.model - with torch.device(self.device): - finalize_layerwise_reload(model, self.model_config) - + self.weight_transfer_engine.finish_weight_update() self._weight_update_active = False - self._is_checkpoint_format = True def shutdown(self) -> None: + gc.unfreeze() + # has_kv_transfer_group can be None during interpreter shutdown. if ensure_kv_transfer_shutdown is not None: ensure_kv_transfer_shutdown() @@ -1131,6 +1264,15 @@ def shutdown(self) -> None: if model_runner := getattr(self, "model_runner", None): model_runner.shutdown() + # Release kept-alive cumem pools while the pluggable allocator wrappers + # and callbacks are still alive, so MemPool teardown is not deferred to + # interpreter finalization (pytorch/pytorch#145168). + if current_platform.is_cuda_alike(): + from vllm.device_allocator.cumem import CuMemAllocator + + if CuMemAllocator.instance is not None: + CuMemAllocator.instance.release_pools() + def elastic_ep_execute(self, execute_method: str, *args, **kwargs): return self.elastic_ep_executor.execute(execute_method, *args, **kwargs) diff --git a/vllm/v1/worker/kv_connector_model_runner_mixin.py b/vllm/v1/worker/kv_connector_model_runner_mixin.py index 797e59c02909..c2c54e647dfe 100644 --- a/vllm/v1/worker/kv_connector_model_runner_mixin.py +++ b/vllm/v1/worker/kv_connector_model_runner_mixin.py @@ -114,7 +114,6 @@ def _get_kv_connector_output( @staticmethod def use_uniform_kv_cache( attn_groups: list[list[AttentionGroup]], - cache_dtype: CacheDType, ) -> bool: """ Determines whether a uniform KV layout should be used. @@ -128,9 +127,9 @@ def use_uniform_kv_cache( have the same page size. 2. A KV connector is configured, and the KV connector instance prefers to use this layout (prefer_cross_layer_blocks() returns True) - 2. The flash attention backend supports this layout - (get_kv_cache_stride_order(True) includes a placement for a - num_layers dimension) + 3. The attention backend indexes KV by the block stride + (kv_cache_spec.indexes_kv_by_block_stride), i.e. num_blocks is the + outermost physical dim so per-block all-layers data is contiguous. Note that the actual placement of the num_layers dimensions in the unified layers tensors will be determined by the attention @@ -140,7 +139,6 @@ def use_uniform_kv_cache( Args: attn_groups: The list of attention groups for this model - cache_dtype: The KV cache dtype Returns: True if we should use a uniform KV cache layout. """ @@ -157,30 +155,7 @@ def use_uniform_kv_cache( kv_cache_spec = attn_group.kv_cache_spec if not isinstance(kv_cache_spec, AttentionSpec): return False - - attn_backend = attn_group.backend - kv_cache_shape = attn_backend.get_kv_cache_shape( - 1234, - kv_cache_spec.block_size, - kv_cache_spec.num_kv_heads, - kv_cache_spec.head_size, - cache_dtype_str=cache_dtype, - ) - - try: - kv_cache_stride_order = attn_backend.get_kv_cache_stride_order( - include_num_layers_dimension=True - ) - except (AttributeError, NotImplementedError): - return False - - # check that attention backend includes a layers dimension - if len(kv_cache_stride_order) != len(kv_cache_shape) + 1: - return False - - # stride_order[0] == 0 means num_layers stays first in physical - # layout (identity permutation), so cross-layer is unsupported. - return kv_cache_stride_order[0] != 0 + return kv_cache_spec.indexes_kv_by_block_stride @staticmethod def allocate_uniform_kv_caches( diff --git a/vllm/v1/worker/mamba_utils.py b/vllm/v1/worker/mamba_utils.py index 485b274eabdf..8d8d3e62a9dd 100644 --- a/vllm/v1/worker/mamba_utils.py +++ b/vllm/v1/worker/mamba_utils.py @@ -12,6 +12,7 @@ MambaStateCopyFunc, get_conv_copy_spec, get_temporal_copy_spec, + is_conv_state_dim_first, ) from vllm.triton_utils import tl, triton from vllm.utils.math_utils import cdiv @@ -22,6 +23,112 @@ from vllm.v1.worker.lora_model_runner_mixin import GPUInputBatch +@triton.jit +def _copy_mamba_state_block( + state_idx, + bt_row_idx, + src_col, + dst_col, + token_bias, + block_table_ptrs_ptr, + block_table_stride_req, + state_base_addrs_ptr, + state_block_strides_ptr, + state_elem_sizes_ptr, + state_inner_sizes_ptr, + state_conv_widths_ptr, + state_group_indices_ptr, + # DS conv row metadata. Zero keeps the single-region copy path. + state_dim_row_count_ptr, + state_dim_row_stride_ptr, + COPY_BLOCK_SIZE: tl.constexpr, + CONV_STATE_DIM_FIRST: tl.constexpr, +): + """Copy one (layer, state-type) mamba state block between block columns. + + Shared copy body of ``postprocess_mamba_fused_kernel`` and + ``precopy_mamba_align_fused_kernel``, mirroring the V1 copy specs + (``get_conv_copy_spec`` / ``get_temporal_copy_spec``): + - conv state (conv_width > 0): shift the window by ``token_bias`` tokens, + ``state[bt[src_col], token_bias:] -> + state[bt[dst_col], :conv_width - token_bias]`` + - temporal state: ``token_bias`` selects the accepted speculative column, + ``state[bt[src_col + token_bias]] -> state[bt[dst_col]]`` + + The caller owns the decision logic (which columns, whether to copy); this + device function only performs the byte copy for the given metadata slot. + """ + state_base_addr = tl.load(state_base_addrs_ptr + state_idx) + state_block_stride = tl.load(state_block_strides_ptr + state_idx) + state_elem_size = tl.load(state_elem_sizes_ptr + state_idx) + state_inner_size = tl.load(state_inner_sizes_ptr + state_idx) + conv_width = tl.load(state_conv_widths_ptr + state_idx) + + # Load the group index for this state, then index into the correct + # group's block table. Each mamba group has independently allocated + # physical blocks. Reinterpret as int32* since block ids are int32. + group_idx = tl.load(state_group_indices_ptr + state_idx).to(tl.int64) + group_base_addr = tl.load(block_table_ptrs_ptr + group_idx) + block_table_typed = group_base_addr.to(tl.pointer_type(tl.int32)) + block_table_base = block_table_typed + bt_row_idx * block_table_stride_req + + # Widen block ids to int64 before they reach `block_id * state_block_stride` + # below: state_block_stride can exceed 2**31 bytes for large mamba caches, + # and Triton would otherwise do the multiply in int32 and wrap. + dest_block_id = tl.load(block_table_base + dst_col).to(tl.int64) + dst_addr = state_base_addr + dest_block_id * state_block_stride + + is_conv_state = conv_width > 0 + + if CONV_STATE_DIM_FIRST and is_conv_state: + # DS conv layout: state_len is the slide axis; copy per dim row. + src_block_id = tl.load(block_table_base + src_col).to(tl.int64) + dim_rows = tl.load(state_dim_row_count_ptr + state_idx) + row_stride = tl.load(state_dim_row_stride_ptr + state_idx) + per_row_bytes = (conv_width - token_bias).to(tl.int64) * state_elem_size + bias_bytes = token_bias.to(tl.int64) * state_elem_size + src_block_addr = state_base_addr + src_block_id * state_block_stride + offsets = tl.arange(0, COPY_BLOCK_SIZE) + for d in range(0, dim_rows): + row_src = src_block_addr + d * row_stride + bias_bytes + row_dst = dst_addr + d * row_stride + for i in range(0, per_row_bytes, COPY_BLOCK_SIZE): + mask = (i + offsets) < per_row_bytes + curr_src = (row_src + i + offsets).to(tl.pointer_type(tl.uint8)) + curr_dst = (row_dst + i + offsets).to(tl.pointer_type(tl.uint8)) + data = tl.load(curr_src, mask=mask) + tl.store(curr_dst, data, mask=mask) + return + + if is_conv_state: + # SD conv: copy + # state[bt[src_col], token_bias:] -> + # state[bt[dst_col], :conv_width - token_bias] + src_block_id = tl.load(block_table_base + src_col).to(tl.int64) + src_offset = token_bias.to(tl.int64) * state_inner_size * state_elem_size + src_addr = state_base_addr + src_block_id * state_block_stride + src_offset + num_elems_to_copy = (conv_width - token_bias).to(tl.int64) * state_inner_size + copy_size = num_elems_to_copy * state_elem_size + else: + # Temporal state: copy state[bt[src_col + token_bias]] -> state[bt[dst_col]] + actual_src_block_id = tl.load(block_table_base + src_col + token_bias).to( + tl.int64 + ) + src_addr = state_base_addr + actual_src_block_id * state_block_stride + # Use natural block data size (inner_size * elem_size), NOT + # state_block_stride which is the page stride and can exceed the + # actual data when the state tensor uses as_strided page padding. + copy_size = state_inner_size * state_elem_size + + offsets = tl.arange(0, COPY_BLOCK_SIZE) + for i in range(0, copy_size, COPY_BLOCK_SIZE): + mask = (i + offsets) < copy_size + curr_src = (src_addr + i + offsets).to(tl.pointer_type(tl.uint8)) + curr_dst = (dst_addr + i + offsets).to(tl.pointer_type(tl.uint8)) + data = tl.load(curr_src, mask=mask) + tl.store(curr_dst, data, mask=mask) + + @triton.jit def postprocess_mamba_fused_kernel( # Decision inputs (per-request) @@ -43,8 +150,15 @@ def postprocess_mamba_fused_kernel( state_inner_sizes_ptr, # number of elements in inner dimensions state_conv_widths_ptr, # conv width for conv states (0 for temporal) state_group_indices_ptr, # maps state_idx to group index in block table + # DS conv row metadata. Zero keeps the single-region copy path. + state_dim_row_count_ptr, # int32: per-block dim row count for DS conv + state_dim_row_stride_ptr, # int64: bytes between rows for DS conv # Output: num_accepted_tokens update (for src==dst case) num_accepted_tokens_out_ptr, + # Optional: batch_idx -> req_idx mapping (V2 model runner / PP). The + # per-request decision arrays are in req-state-slot order; the block table + # is in batch order, so HAS_IDX_MAPPING splits the two indexings. + idx_mapping_ptr, # Runtime parameter (varies per batch - NOT constexpr to avoid recompilation) num_reqs, # Compile-time constants (fixed after model initialization) @@ -52,35 +166,54 @@ def postprocess_mamba_fused_kernel( block_size: tl.constexpr, # COPY_BLOCK_SIZE: fixed tuning parameter for memory copy loop COPY_BLOCK_SIZE: tl.constexpr, + CONV_STATE_DIM_FIRST: tl.constexpr, + # HAS_IDX_MAPPING: when True, program_id(0) is a batch index resolved to a + # req-state slot via idx_mapping_ptr (V2). When False, it is the req index. + HAS_IDX_MAPPING: tl.constexpr = False, + # PRECOMPUTED_NEW_COMPUTED: when True, num_computed_tokens_ptr already holds + # the post-step new_num_computed value (V2 supplies the advanced count). + PRECOMPUTED_NEW_COMPUTED: tl.constexpr = False, ): """ Fused GPU kernel for postprocess_mamba that computes decisions AND performs mamba state copies without any CPU-GPU synchronization. Grid: (num_reqs, num_layers * num_state_types) - - program_id(0) = request index + - program_id(0) = request/batch index - program_id(1) = state_idx (flattened index into layer/state_type metadata) Note: num_layers and num_state_types are not passed as kernel parameters because the kernel indexes directly into pre-flattened metadata arrays using program_id(1). The grid dimensions encode the total state count. """ - req_idx = tl.program_id(0) + batch_idx = tl.program_id(0) state_idx = tl.program_id(1) # Bounds check - if req_idx >= num_reqs: + if batch_idx >= num_reqs: return + if HAS_IDX_MAPPING: + req_idx = tl.load(idx_mapping_ptr + batch_idx) + if req_idx < 0: + return + else: + req_idx = batch_idx + # Compute decision logic (mirrors postprocess_mamba Python reference) num_accepted = tl.load(num_accepted_tokens_ptr + req_idx) src_block_idx = tl.load(mamba_state_idx_ptr + req_idx) - num_scheduled = tl.load(num_scheduled_tokens_ptr + req_idx) - num_computed = tl.load(num_computed_tokens_ptr + req_idx) - num_draft = tl.load(num_draft_tokens_ptr + req_idx) - num_tokens_running_state = num_computed + num_scheduled - num_draft - new_num_computed = num_tokens_running_state + num_accepted - 1 + if PRECOMPUTED_NEW_COMPUTED: + new_num_computed = tl.load(num_computed_tokens_ptr + req_idx) + num_tokens_running_state = new_num_computed - num_accepted + 1 + else: + num_scheduled = tl.load(num_scheduled_tokens_ptr + req_idx) + num_computed = tl.load(num_computed_tokens_ptr + req_idx) + num_draft = tl.load(num_draft_tokens_ptr + req_idx) + num_tokens_running_state = num_computed + num_scheduled - num_draft + new_num_computed = num_tokens_running_state + num_accepted - 1 + aligned_new_computed = (new_num_computed // block_size) * block_size needs_copy = aligned_new_computed >= num_tokens_running_state @@ -92,85 +225,158 @@ def postprocess_mamba_fused_kernel( accept_token_bias = aligned_new_computed - num_tokens_running_state dest_block_idx = aligned_new_computed // block_size - 1 - # Load state metadata for this layer/state_type - state_base_addr = tl.load(state_base_addrs_ptr + state_idx) - state_block_stride = tl.load(state_block_strides_ptr + state_idx) - state_elem_size = tl.load(state_elem_sizes_ptr + state_idx) - state_inner_size = tl.load(state_inner_sizes_ptr + state_idx) - conv_width = tl.load(state_conv_widths_ptr + state_idx) + # Update accepted-token count before early exits (per-request, so only + # state_idx == 0 writes). V2 updates in place; V1 writes the _out buffer. + if src_block_idx == dest_block_idx and state_idx == 0: + if HAS_IDX_MAPPING: + tl.store(num_accepted_tokens_ptr + req_idx, 1) + else: + tl.store(num_accepted_tokens_out_ptr + req_idx, 1) - # Load the group index for this state, then index into the correct - # group's block table. Each mamba group has independently allocated - # physical blocks. - group_idx = tl.load(state_group_indices_ptr + state_idx).to(tl.int64) + # Skip no-op self-copy. + if src_block_idx == dest_block_idx and accept_token_bias == 0: + return - # block_table_ptrs_ptr holds one pointer per group (each group owns its own - # block table). Reinterpret as int32* since block ids are int32. - group_base_addr = tl.load(block_table_ptrs_ptr + group_idx) - block_table_typed = group_base_addr.to(tl.pointer_type(tl.int32)) - block_table_base = block_table_typed + req_idx * block_table_stride_req + bt_row_idx = batch_idx if HAS_IDX_MAPPING else req_idx + _copy_mamba_state_block( + state_idx, + bt_row_idx, + src_block_idx, + dest_block_idx, + accept_token_bias, + block_table_ptrs_ptr, + block_table_stride_req, + state_base_addrs_ptr, + state_block_strides_ptr, + state_elem_sizes_ptr, + state_inner_sizes_ptr, + state_conv_widths_ptr, + state_group_indices_ptr, + state_dim_row_count_ptr, + state_dim_row_stride_ptr, + COPY_BLOCK_SIZE, + CONV_STATE_DIM_FIRST, + ) - # Widen block ids to int64 before they reach `block_id * state_block_stride` - # below: state_block_stride can exceed 2**31 bytes for large mamba caches, - # and Triton would otherwise do the multiply in int32 and wrap. - src_block_id = tl.load(block_table_base + src_block_idx).to(tl.int64) - dest_block_id = tl.load(block_table_base + dest_block_idx).to(tl.int64) - # Compute source and destination addresses based on state type - # conv_width > 0 means this is a conv state (get_conv_copy_spec logic) - # conv_width == 0 means this is a temporal state (get_temporal_copy_spec logic) - is_conv_state = conv_width > 0 +@triton.jit +def preprocess_mamba_align_fused_kernel( + idx_mapping_ptr, + state_idx_ptr, + num_computed_tokens_ptr, + query_start_loc_ptr, + num_accepted_tokens_ptr, + src_col_ptr, + src_off_ptr, + num_reqs, + BLOCK_SIZE: tl.constexpr, + MAMBA_BLOCK_SIZE: tl.constexpr, +): + """Fused align preprocess: emit the pre-copy src column/offset AND advance + state_idx (with accepted-token reset) in a single launch (V2 align). + + Per batch_idx (0..num_reqs-1), resolving req slot via idx_mapping: + 1. Read pre-advance state_idx and num_accepted (last step's values). + 2. Store the pre-copy src columns for ``precopy_mamba_align_fused_kernel``: + - src_col = state_idx (the previous running block column) + - src_off = max(num_accepted - 1, 0) (the accepted-token bias) + 3. Advance state_idx to the new running block, and reset num_accepted to 1 + when a block boundary is crossed (so the migrated state, now at the + start of the new block, is read with the neutral bias). + """ + offsets = tl.program_id(0) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE) + mask = offsets < num_reqs + req_indices = tl.load(idx_mapping_ptr + offsets, mask=mask, other=0) - if is_conv_state: - # Conv state: copy - # state[block_table[req_idx, src_block_idx], accept_token_bias:] - # to - # state[block_table[req_idx, dest_block_idx], :conv_width - accept_token_bias] - src_offset = accept_token_bias.to(tl.int64) * state_inner_size * state_elem_size - src_addr = state_base_addr + src_block_id * state_block_stride + src_offset - dst_addr = state_base_addr + dest_block_id * state_block_stride - # Number of elements to copy: - # (conv_width - accept_token_bias) * inner_size - num_elems_to_copy = (conv_width - accept_token_bias).to( - tl.int64 - ) * state_inner_size - copy_size = num_elems_to_copy * state_elem_size - else: - # Temporal state: copy - # state[block_table[req_idx, src_block_idx + accept_token_bias]] - # to - # state[block_table[req_idx, dest_block_idx]] - actual_src_block_idx = src_block_idx + accept_token_bias - actual_src_block_id = tl.load(block_table_base + actual_src_block_idx).to( - tl.int64 - ) - src_addr = state_base_addr + actual_src_block_id * state_block_stride - dst_addr = state_base_addr + dest_block_id * state_block_stride - # Use natural block data size (inner_size * elem_size), NOT - # state_block_stride which is the page stride and can exceed the - # actual data when the state tensor uses as_strided page padding. - copy_size = state_inner_size * state_elem_size + state_idx = tl.load(state_idx_ptr + req_indices, mask=mask, other=-1) + num_accepted = tl.load(num_accepted_tokens_ptr + req_indices, mask=mask, other=1) - # Mirror postprocess_mamba's trailing - # if src_block_idx == dest_block_idx: num_accepted_tokens_cpu[i] = 1 - # This runs whether or not the copy below is skipped (it's per-request, so - # only state_idx == 0 writes). - if src_block_idx == dest_block_idx and state_idx == 0: - tl.store(num_accepted_tokens_out_ptr + req_idx, 1) + src_off = tl.maximum(num_accepted - 1, 0) + tl.store(src_col_ptr + req_indices, state_idx, mask=mask) + tl.store(src_off_ptr + req_indices, src_off, mask=mask) - # Mirror collect_mamba_copy_meta's early return: src==dst with no token - # bias means source and destination ranges coincide, so the copy is a - # no-op. - if src_block_idx == dest_block_idx and accept_token_bias == 0: + num_computed = tl.load(num_computed_tokens_ptr + req_indices, mask=mask, other=0) + query_start = tl.load(query_start_loc_ptr + offsets, mask=mask, other=0) + query_end = tl.load(query_start_loc_ptr + offsets + 1, mask=mask, other=0) + computed_after = num_computed + query_end - query_start + new_state_idx = (computed_after + MAMBA_BLOCK_SIZE - 1) // MAMBA_BLOCK_SIZE - 1 + tl.store(state_idx_ptr + req_indices, new_state_idx, mask=mask) + should_reset = (state_idx >= 0) & (state_idx != new_state_idx) + tl.store(num_accepted_tokens_ptr + req_indices, 1, mask=mask & should_reset) + + +@triton.jit +def precopy_mamba_align_fused_kernel( + # Per-request-slot inputs (indexed by req_idx via idx_mapping), produced by + # the V2 fused align preprocess kernel for the current step: + mamba_state_idx_ptr, # post-advance dst block column + src_col_ptr, # pre-advance src block column (-1 = fresh) + token_bias_ptr, # accepted-token bias = num_accepted - 1 (pre-reset) + # Same flattened state-layout metadata as postprocess_mamba_fused_kernel + block_table_ptrs_ptr, + block_table_stride_req: tl.int64, + state_base_addrs_ptr, + state_block_strides_ptr, + state_elem_sizes_ptr, + state_inner_sizes_ptr, + state_conv_widths_ptr, + state_group_indices_ptr, + state_dim_row_count_ptr, + state_dim_row_stride_ptr, + idx_mapping_ptr, # [num_reqs] batch_idx -> req_state_idx (-1 to skip) + num_reqs, + COPY_BLOCK_SIZE: tl.constexpr, + CONV_STATE_DIM_FIRST: tl.constexpr, +): + """Pre-copy mamba "align" state across block boundaries on the V2 runner. + + Before the forward pass, copy each request's last SSM/conv state from its + previous block column into the new window block column, so the kernels read + the initial state from the write-side block as usual (V1 align semantics). + Same per-(layer, state) copy semantics as ``postprocess_mamba_fused_kernel`` + (shared ``_copy_mamba_state_block`` body, i.e. the V1 ``preprocess_mamba`` + copy specs), but driven by the GPU-resident src columns so it needs no + CPU-GPU sync (async-scheduling safe). + + Grid: (num_reqs, num_layers * num_state_types); block tables are indexed by + batch row, per-request state by req_idx via idx_mapping (V2 layout). + """ + batch_idx = tl.program_id(0) + state_idx = tl.program_id(1) + if batch_idx >= num_reqs: + return + req_idx = tl.load(idx_mapping_ptr + batch_idx) + if req_idx < 0: return - offsets = tl.arange(0, COPY_BLOCK_SIZE) - for i in range(0, copy_size, COPY_BLOCK_SIZE): - mask = (i + offsets) < copy_size - curr_src = (src_addr + i + offsets).to(tl.pointer_type(tl.uint8)) - curr_dst = (dst_addr + i + offsets).to(tl.pointer_type(tl.uint8)) - data = tl.load(curr_src, mask=mask) - tl.store(curr_dst, data, mask=mask) + src_col = tl.load(src_col_ptr + req_idx) + dst_col = tl.load(mamba_state_idx_ptr + req_idx) + # Fresh state, or still writing the same block: kernels locate the initial + # state in-block via num_accepted (preserved when no boundary is crossed), + # so there is nothing to copy. + if src_col < 0 or src_col == dst_col: + return + + token_bias = tl.load(token_bias_ptr + req_idx) + _copy_mamba_state_block( + state_idx, + batch_idx, + src_col, + dst_col, + token_bias, + block_table_ptrs_ptr, + block_table_stride_req, + state_base_addrs_ptr, + state_block_strides_ptr, + state_elem_sizes_ptr, + state_inner_sizes_ptr, + state_conv_widths_ptr, + state_group_indices_ptr, + state_dim_row_count_ptr, + state_dim_row_stride_ptr, + COPY_BLOCK_SIZE, + CONV_STATE_DIM_FIRST, + ) @triton.jit @@ -238,9 +444,10 @@ def create( for gid in mamba_group_ids ) * len(copy_funcs) n = max_num_reqs * entries_per_req + return cls( - src_ptrs=make_buffer(n, dtype=torch.int64), - dst_ptrs=make_buffer(n, dtype=torch.int64), + src_ptrs=make_buffer(n, dtype=torch.uint64), + dst_ptrs=make_buffer(n, dtype=torch.uint64), sizes=make_buffer(n, dtype=torch.int32), mamba_group_ids=mamba_group_ids, mamba_spec=mamba_spec, @@ -271,6 +478,9 @@ class MambaSpecDecodeGPUContext: state_inner_sizes: torch.Tensor # int64: elements in inner dimensions state_conv_widths: torch.Tensor # int32: conv width (0 for temporal states) state_group_indices: torch.Tensor # int32: maps state_idx to group index + # DS conv row metadata. Zero keeps the single-region copy path. + state_dim_row_count: torch.Tensor # int32: per-block dim row count + state_dim_row_stride: torch.Tensor # int64: bytes between rows # Configuration block_size: int @@ -338,6 +548,12 @@ def create( state_group_indices=torch.zeros( total_states, dtype=torch.int32, device=device ), + state_dim_row_count=torch.zeros( + total_states, dtype=torch.int32, device=device + ), + state_dim_row_stride=torch.zeros( + total_states, dtype=torch.int64, device=device + ), block_size=mamba_spec.block_size, num_layers=num_layers, num_state_types=num_state_types, @@ -430,17 +646,23 @@ def initialize_from_forward_context( or copy_func is get_temporal_copy_spec ), f"unexpected copy func: {copy_func}" if copy_func is get_conv_copy_spec: - # Conv state: conv_width is state.size(1) - # inner_size is stride(1) = elements per conv position, - # used to compute byte offset for state[block, offset:] - conv_w = state.size(1) if state.dim() > 1 else 0 - self.state_conv_widths[idx] = conv_w - if state.dim() > 2: - # stride(1) = product of dims[2:] for contiguous tensor - self.state_inner_sizes[idx] = state.stride(1) - else: - # 2D tensor: [num_blocks, conv_dim], no inner dims + if state.dim() != 3: + raise ValueError( + "Expected 3D conv state cache, got " + f"shape {tuple(state.shape)}" + ) + if is_conv_state_dim_first(): + # DS layout: state_len is the slide axis. + self.state_conv_widths[idx] = state.size(2) self.state_inner_sizes[idx] = 1 + self.state_dim_row_count[idx] = state.size(1) + self.state_dim_row_stride[idx] = ( + state.stride(1) * state.element_size() + ) + else: + # SD layout: dim is contiguous. + self.state_conv_widths[idx] = state.size(1) + self.state_inner_sizes[idx] = state.stride(1) else: # Temporal state: inner_size = natural elements per # block (prod of inner dims). The kernel uses this @@ -521,10 +743,102 @@ def run_fused_postprocess( self.state_inner_sizes, self.state_conv_widths, self.state_group_indices, + self.state_dim_row_count, + self.state_dim_row_stride, self.num_accepted_tokens_out, + None, # idx_mapping: V1 decision arrays are already in req order + num_reqs, + block_size=self.block_size, + COPY_BLOCK_SIZE=1024, + CONV_STATE_DIM_FIRST=is_conv_state_dim_first(), + ) + + def run_fused_precopy( + self, + num_reqs: int, + state_idx_gpu: torch.Tensor, + src_col_gpu: torch.Tensor, + token_bias_gpu: torch.Tensor, + idx_mapping: torch.Tensor, + ) -> None: + """Pre-copy each request's previous running block into its new window + block before the forward pass (V2 align boundary migration). + + Args: + num_reqs: Number of active requests (batch order). + state_idx_gpu: [max_reqs] post-advance dst block column per req slot. + src_col_gpu: [max_reqs] pre-advance src block column (-1 = fresh). + token_bias_gpu: [max_reqs] accepted-token bias (num_accepted - 1). + idx_mapping: [num_reqs] batch_idx -> req_state_idx (-1 to skip). + """ + if num_reqs == 0 or not self.is_initialized: + return + total_states = self.num_layers * self.num_state_types + grid = (num_reqs, total_states) + precopy_mamba_align_fused_kernel[grid]( + state_idx_gpu, + src_col_gpu, + token_bias_gpu, + self.block_table_ptrs, + self.block_table_stride_req, + self.state_base_addrs, + self.state_block_strides, + self.state_elem_sizes, + self.state_inner_sizes, + self.state_conv_widths, + self.state_group_indices, + self.state_dim_row_count, + self.state_dim_row_stride, + idx_mapping, + num_reqs, + COPY_BLOCK_SIZE=1024, + CONV_STATE_DIM_FIRST=is_conv_state_dim_first(), + ) + + def run_fused_postprocess_align( + self, + num_reqs: int, + num_accepted_tokens_gpu: torch.Tensor, + state_idx_gpu: torch.Tensor, + new_num_computed_tokens_gpu: torch.Tensor, + idx_mapping: torch.Tensor, + ) -> None: + """V2 align postprocess: save the running state to the block-aligned + position after spec-decode acceptance leaves the sequence non-aligned. + + ``num_accepted_tokens_gpu`` is updated in place (reset to 1 when the + accepted position stays in the running block); ``new_num_computed_tokens`` + already holds the post-step computed count (PRECOMPUTED_NEW_COMPUTED). + ``idx_mapping`` maps batch row -> req-state slot (HAS_IDX_MAPPING). + """ + if num_reqs == 0 or not self.is_initialized: + return + total_states = self.num_layers * self.num_state_types + grid = (num_reqs, total_states) + postprocess_mamba_fused_kernel[grid]( + num_accepted_tokens_gpu, + state_idx_gpu, + None, # num_scheduled: unused under PRECOMPUTED_NEW_COMPUTED + new_num_computed_tokens_gpu, + None, # num_draft: unused under PRECOMPUTED_NEW_COMPUTED + self.block_table_ptrs, + self.block_table_stride_req, + self.state_base_addrs, + self.state_block_strides, + self.state_elem_sizes, + self.state_inner_sizes, + self.state_conv_widths, + self.state_group_indices, + self.state_dim_row_count, + self.state_dim_row_stride, + None, # num_accepted_out: V2 updates num_accepted in place + idx_mapping, num_reqs, block_size=self.block_size, COPY_BLOCK_SIZE=1024, + CONV_STATE_DIM_FIRST=is_conv_state_dim_first(), + HAS_IDX_MAPPING=True, + PRECOMPUTED_NEW_COMPUTED=True, ) diff --git a/vllm/v1/worker/startup_plan.py b/vllm/v1/worker/startup_plan.py new file mode 100644 index 000000000000..2c4942070705 --- /dev/null +++ b/vllm/v1/worker/startup_plan.py @@ -0,0 +1,191 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Persist and reuse the memory-profiling result across engine boots. + +On startup, vLLM measures how much GPU memory the KV cache can use and +computes the ``--kv-cache-memory`` value that reproduces that allocation. +For a fixed (model, config, hardware, library) combination the result is +deterministic, yet it is re-measured on every boot. + +When ``VLLM_ENABLE_STARTUP_PLAN=1``, each worker persists that value under +``{VLLM_CACHE_ROOT}/startup_plan/`` (regenerable derived state, alongside +the torch.compile cache), keyed by a fingerprint of everything the value +depends on, and later boots apply it automatically -- skipping the +memory-profiling measurement and the CUDA-graph memory estimation pass -- +if and only if the fingerprint matches and the device has at least as much +free memory as when the plan was recorded. On any mismatch the worker +falls back to full profiling, so a stale plan costs nothing and is never +trusted. +""" + +import hashlib +import json +import os +from typing import TYPE_CHECKING + +import torch + +import vllm.envs as envs +from vllm.config import VllmConfig +from vllm.logger import init_logger +from vllm.platforms import current_platform + +if TYPE_CHECKING: + from vllm.v1.worker.gpu_worker import Worker + +logger = init_logger(__name__) + +PLAN_SCHEMA_VERSION = 1 + + +def compute_plan_fingerprint( + vllm_config: VllmConfig, rank: int, world_size: int +) -> str: + """Hash everything the profiled KV-cache memory value depends on. + + ``VllmConfig.compute_hash()`` covers the vLLM version and the model, + cache, parallel, and compilation configs, but deliberately contains no + device identity (``DeviceConfig.compute_hash`` is empty), so device + name, total memory, compute capability, and the torch/CUDA build are + added here. The vLLM version is also pinned as an explicit factor so + version invalidation holds no matter how ``compute_hash`` evolves. + Rank is included because per-rank memory use differs under TP/PP. + Driver-only changes are not part of the key; the free-memory gate at + apply time bounds the residual risk. + """ + # Imported here (as VllmConfig.compute_hash does) to avoid a cycle with + # the top-level vllm package. + from vllm import __version__ as vllm_version + + capability = current_platform.get_device_capability() + factors = { + "schema": PLAN_SCHEMA_VERSION, + "vllm": vllm_version, + "vllm_config": vllm_config.compute_hash(), + "device_name": current_platform.get_device_name(), + "device_total_memory": current_platform.get_device_total_memory(), + "device_capability": str(capability) if capability else "", + "torch": torch.__version__, + "cuda": torch.version.cuda or "", + "rank": rank, + "world_size": world_size, + } + digest = hashlib.sha256(json.dumps(factors, sort_keys=True).encode()).hexdigest() + return digest[:16] + + +def _plan_path(fingerprint: str) -> str: + """Plans are regenerable derived state, so they live under the standard + vLLM cache root (like the torch.compile cache) and relocate with + ``VLLM_CACHE_ROOT`` instead of needing a location knob of their own.""" + # VLLM_CACHE_ROOT is already user-expanded by envs.py. + return os.path.join( + envs.VLLM_CACHE_ROOT, "startup_plan", f"startup_plan_{fingerprint}.json" + ) + + +def _load_plan(fingerprint: str) -> dict | None: + """Load a plan for this fingerprint; None if absent or unreadable.""" + path = _plan_path(fingerprint) + try: + with open(path) as f: + plan = json.load(f) + except FileNotFoundError: + return None + except (OSError, json.JSONDecodeError) as e: + logger.warning("Ignoring unreadable startup plan %s: %s", path, e) + return None + if ( + plan.get("schema") != PLAN_SCHEMA_VERSION + or plan.get("fingerprint") != fingerprint + ): + return None + return plan + + +def _applicable_kv_cache_memory_bytes( + plan: dict, current_free_memory: int +) -> int | None: + """The apply-time OOM-safety gate. + + The recorded value is only valid if the device has at least as much + free memory now as when the plan was measured (co-tenants, leaked + allocations, or MIG changes all reduce it). Outside that envelope, + return None and let the caller re-profile. + """ + kv_bytes = plan.get("kv_cache_memory_bytes") + baseline = plan.get("free_memory_baseline") + if not isinstance(kv_bytes, int) or not isinstance(baseline, int): + return None + if kv_bytes <= 0: + return None + if current_free_memory < baseline: + logger.info( + "Startup plan not applied: current free memory (%.2f GiB) is " + "below the recorded baseline (%.2f GiB); falling back to full " + "memory profiling.", + current_free_memory / (1 << 30), + baseline / (1 << 30), + ) + return None + return kv_bytes + + +def maybe_apply_startup_plan(worker: "Worker") -> None: + """If enabled and ``--kv-cache-memory`` was not set explicitly, apply a + persisted plan by setting ``worker.cache_config.kv_cache_memory_bytes``. + No-op unless ``VLLM_ENABLE_STARTUP_PLAN=1``.""" + if ( + not envs.VLLM_ENABLE_STARTUP_PLAN + or worker.cache_config.kv_cache_memory_bytes is not None + ): + return + fingerprint = compute_plan_fingerprint( + worker.vllm_config, worker.rank, worker.parallel_config.world_size + ) + plan = _load_plan(fingerprint) + if plan is None: + return + current_free_memory = worker.init_snapshot.free_memory + kv_bytes = _applicable_kv_cache_memory_bytes(plan, current_free_memory) + if kv_bytes is None: + return + logger.info( + "Applying persisted startup plan (fingerprint %s): " + "kv_cache_memory_bytes=%d (%.2f GiB), recorded free-memory " + "baseline %.2f GiB, current %.2f GiB. Memory profiling will " + "be skipped.", + fingerprint, + kv_bytes, + kv_bytes / (1 << 30), + plan["free_memory_baseline"] / (1 << 30), + current_free_memory / (1 << 30), + ) + worker.cache_config.kv_cache_memory_bytes = kv_bytes + + +def maybe_save_startup_plan(worker: "Worker", kv_cache_memory_bytes: int) -> None: + """Atomically persist this boot's profiling result for future boots. + No-op unless ``VLLM_ENABLE_STARTUP_PLAN=1``; failures are logged, + never raised.""" + if not envs.VLLM_ENABLE_STARTUP_PLAN: + return + fingerprint = compute_plan_fingerprint( + worker.vllm_config, worker.rank, worker.parallel_config.world_size + ) + path = _plan_path(fingerprint) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + payload = { + "schema": PLAN_SCHEMA_VERSION, + "fingerprint": fingerprint, + "kv_cache_memory_bytes": int(kv_cache_memory_bytes), + "free_memory_baseline": int(worker.init_snapshot.free_memory), + } + tmp = f"{path}.tmp.{os.getpid()}" + with open(tmp, "w") as f: + json.dump(payload, f) + os.replace(tmp, path) + logger.info("Saved startup plan to %s", path) + except OSError as e: + logger.warning("Failed to save startup plan to %s: %s", path, e) diff --git a/vllm/v1/worker/utils.py b/vllm/v1/worker/utils.py index c0f44b6db0c3..2c2f930001b1 100644 --- a/vllm/v1/worker/utils.py +++ b/vllm/v1/worker/utils.py @@ -94,6 +94,7 @@ def __init__( cache_dtype: str, static_forward_context: dict[str, Any], runner_only_attn_layers: set[str] | None = None, + max_concurrency: int = 1, ) -> None: """Precompute the absolute-address table for the Triton zeroing kernel. @@ -109,10 +110,14 @@ def __init__( """ self.device = device self.pin_memory = pin_memory + if max_concurrency < 1: + raise ValueError("max_concurrency must be at least 1") + self.max_concurrency = max_concurrency self._meta: tuple[torch.Tensor, int, int, int] | None = None self._id_cap: int = 0 - self._ids_pinned: torch.Tensor | None = None - self._ids_gpu: torch.Tensor | None = None + self._ids_pinned: list[torch.Tensor] = [] + self._ids_gpu: list[torch.Tensor] = [] + self._id_buffer_index = 0 if runner_only_attn_layers is None: runner_only_attn_layers = set() @@ -175,12 +180,7 @@ def __init__( blk_size = min(largest_power_of_2_divisor(page_size_el), 1024) self._id_cap = 8192 - self._ids_pinned = torch.empty( - self._id_cap, - dtype=torch.int64, - pin_memory=self.pin_memory, - ) - self._ids_gpu = torch.empty(self._id_cap, dtype=torch.int64, device=self.device) + self._allocate_id_buffers() self._meta = ( torch.tensor(seg_addrs, dtype=torch.uint64, device=self.device), page_size_el, @@ -188,6 +188,21 @@ def __init__( len(seg_addrs), ) + def _allocate_id_buffers(self) -> None: + self._ids_pinned = [ + torch.empty( + self._id_cap, + dtype=torch.int64, + pin_memory=self.pin_memory, + ) + for _ in range(self.max_concurrency) + ] + self._ids_gpu = [ + torch.empty(self._id_cap, dtype=torch.int64, device=self.device) + for _ in range(self.max_concurrency) + ] + self._id_buffer_index = 0 + def zero_block_ids(self, block_ids: list[int]) -> None: """Zero the KV cache memory for the given block IDs.""" if not block_ids or self._meta is None: @@ -195,19 +210,21 @@ def zero_block_ids(self, block_ids: list[int]) -> None: seg_addrs, page_size_el, blk_size, n_segs = self._meta n_blocks = len(block_ids) if n_blocks > self._id_cap: + # The old pinned buffers may still be the source of an in-flight + # nonblocking copy. Growing is rare, so we don't mind the sync overhead + torch.accelerator.synchronize() self._id_cap = n_blocks * 2 - self._ids_pinned = torch.empty( - self._id_cap, - dtype=torch.int64, - pin_memory=self.pin_memory, - ) - self._ids_gpu = torch.empty( - self._id_cap, dtype=torch.int64, device=self.device - ) - assert self._ids_pinned is not None and self._ids_gpu is not None - self._ids_pinned[:n_blocks].numpy()[:] = block_ids - idx = self._ids_gpu[:n_blocks] - idx.copy_(self._ids_pinned[:n_blocks], non_blocking=True) + self._allocate_id_buffers() + + # The H2D copy is nonblocking, so its pinned source must not be mutated + # while this batch is in flight. Rotate through as many buffers as concurrent + # in-flight batches, to avoid collisions. + buffer_index = self._id_buffer_index + self._id_buffer_index = (buffer_index + 1) % self.max_concurrency + ids_pinned = self._ids_pinned[buffer_index] + ids_pinned[:n_blocks].numpy()[:] = block_ids + idx = self._ids_gpu[buffer_index][:n_blocks] + idx.copy_(ids_pinned[:n_blocks], non_blocking=True) grid = (n_blocks * n_segs * (page_size_el // blk_size),) _zero_kv_blocks_kernel[grid]( seg_addrs, diff --git a/vllm/v1/worker/worker_base.py b/vllm/v1/worker/worker_base.py index 19bb18bd39f7..9381d71913dc 100644 --- a/vllm/v1/worker/worker_base.py +++ b/vllm/v1/worker/worker_base.py @@ -286,6 +286,12 @@ def init_worker(self, all_kwargs: list[dict[str, Any]]) -> None: extended_calls, ) + assigned_physical_gpu_ids = kwargs.pop("assigned_physical_gpu_ids", None) + if assigned_physical_gpu_ids is not None: + vllm_config.parallel_config.assigned_physical_gpu_ids = ( + assigned_physical_gpu_ids + ) + shared_worker_lock = kwargs.pop("shared_worker_lock", None) if shared_worker_lock is None: msg = ( diff --git a/vllm/v1/worker/xpu_model_runner.py b/vllm/v1/worker/xpu_model_runner.py index f93d04395571..77edbba58f92 100644 --- a/vllm/v1/worker/xpu_model_runner.py +++ b/vllm/v1/worker/xpu_model_runner.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from contextlib import contextmanager +from functools import partial import torch @@ -40,16 +41,23 @@ def __init__( @contextmanager def _torch_cuda_wrapper(): - # replace cuda APIs with xpu APIs, this should work by default + # Replace cuda APIs with xpu APIs. Each callable gets its own functools.partial + # so it is not the same object as torch.xpu.* (Torch Dynamo _get_handlers() + # asserts on duplicate registration when cuda aliases xpu directly). torch.cuda.Stream = torch.xpu.Stream - torch.cuda.default_stream = torch.xpu.current_stream - torch.cuda.current_stream = torch.xpu.current_stream - torch.cuda.stream = torch.xpu.stream - torch.cuda.mem_get_info = torch.xpu.mem_get_info - torch.cuda.Event = torch.Event - torch.cuda.set_stream = torch.xpu.set_stream + torch.cuda.default_stream = partial(torch.xpu.current_stream) + torch.cuda.current_stream = partial(torch.xpu.current_stream) + torch.cuda.stream = partial(torch.xpu.stream) + torch.cuda.set_stream = partial(torch.xpu.set_stream) + + # torch.xpu.Event does not accept the ``blocking`` kwarg that + # torch.cuda.Event supports, so drop it here. + def _xpu_event(*args, blocking=None, **kwargs): + return torch.xpu.Event(*args, **kwargs) + + torch.cuda.Event = _xpu_event if supports_xpu_graph(): - torch.cuda.graph = torch.xpu.graph + torch.cuda.graph = partial(torch.xpu.graph) torch.cuda.CUDAGraph = torch.xpu.XPUGraph - torch.cuda.graph_pool_handle = torch.xpu.graph_pool_handle + torch.cuda.graph_pool_handle = partial(torch.xpu.graph_pool_handle) yield diff --git a/vllm/v1/worker/xpu_worker.py b/vllm/v1/worker/xpu_worker.py index 555c60227861..091406a22b13 100644 --- a/vllm/v1/worker/xpu_worker.py +++ b/vllm/v1/worker/xpu_worker.py @@ -82,7 +82,7 @@ def init_device(self): self.local_rank ).total_memory else: - raise RuntimeError(f"Not support device type: {self.device_config.device}") + raise RuntimeError(f"Unsupported device type: {self.device_config.device}") ENV_CCL_ATL_TRANSPORT = os.getenv("CCL_ATL_TRANSPORT", "ofi") ENV_LOCAL_WORLD_SIZE = os.getenv( @@ -162,3 +162,20 @@ def profile(self, is_start: bool = True, profile_prefix: str | None = None): logger.debug("Starting torch profiler with trace name: %s", trace_name) super().profile(is_start=is_start, profile_prefix=profile_prefix) + + def shutdown(self) -> None: + logger.info( + "XPUWorker shutdown: cleaning up (rank=%d, local_rank=%d)", + self.rank, + self.local_rank, + ) + super().shutdown() + from vllm.device_allocator.xpumem import XpuMemAllocator + + if XpuMemAllocator.instance is not None: + XpuMemAllocator.instance.release_pools() + logger.info( + "XPUWorker shutdown: done (rank=%d, local_rank=%d)", + self.rank, + self.local_rank, + ) diff --git a/vllm/vllm_flash_attn/__init__.py b/vllm/vllm_flash_attn/__init__.py index 7dea1f659b8c..6ea5b873615f 100644 --- a/vllm/vllm_flash_attn/__init__.py +++ b/vllm/vllm_flash_attn/__init__.py @@ -23,6 +23,7 @@ from vllm.vllm_flash_attn.flash_attn_interface import ( # noqa: E402 FA2_AVAILABLE, FA3_AVAILABLE, + compile_flash_attn_varlen_func_from_specs, fa_version_unsupported_reason, flash_attn_varlen_func, get_scheduler_metadata, @@ -36,6 +37,7 @@ ) __all__ = [ + "compile_flash_attn_varlen_func_from_specs", "fa_version_unsupported_reason", "flash_attn_varlen_func", "get_scheduler_metadata", diff --git a/vllm/vllm_flash_attn/flash_attn_interface.py b/vllm/vllm_flash_attn/flash_attn_interface.py index 33955bb239ef..bef811f6e61d 100644 --- a/vllm/vllm_flash_attn/flash_attn_interface.py +++ b/vllm/vllm_flash_attn/flash_attn_interface.py @@ -200,12 +200,18 @@ def flash_attn_varlen_func( k_descale=None, v_descale=None, num_splits: int = 0, + # FA4 Only + output_scale=None, # Version selector fa_version: int = DEFAULT_FA_VERSION, s_aux=None, cp_world_size=1, cp_rank=0, cp_tot_seqused_k=None, + # FA4 only + mask_mod=None, + aux_tensors=None, + dynamic_causal: "torch.Tensor | None" = None, ): """dropout_p should be set to 0.0 during evaluation Supports multi-query and grouped-query attention (MQA/GQA) by passing in K, V with fewer heads @@ -269,6 +275,11 @@ def flash_attn_varlen_func( "seqused_k must be provided if block_table is provided" ) + assert output_scale is None or fa_version == 4, ( + f"Fused FP8 output (output_scale) is only supported by FA4, " + f"got fa_version={fa_version}" + ) + if softmax_scale is None: softmax_scale = q.shape[-1] ** (-0.5) # custom op does not support non-tuple input @@ -297,6 +308,10 @@ def flash_attn_varlen_func( raise NotImplementedError("FA2 does not support s_aux") if num_splits > 1: raise NotImplementedError("FA2 does not support num_splits > 1") + if mask_mod is not None: + raise NotImplementedError("FA2 does not support mask_mod") + if aux_tensors is not None: + raise NotImplementedError("FA2 does not support aux_tensors") out, softmax_lse = torch.ops._vllm_fa2_C.varlen_fwd( q, k, @@ -325,6 +340,10 @@ def flash_attn_varlen_func( ) elif fa_version == 3: assert alibi_slopes is None, "Alibi is not supported in FA3" + if mask_mod is not None: + raise NotImplementedError("FA3 does not support mask_mod") + if aux_tensors is not None: + raise NotImplementedError("FA3 does not support aux_tensors") out, softmax_lse, _, _ = torch.ops._vllm_fa3_C.fwd( q, k, @@ -381,6 +400,7 @@ def flash_attn_varlen_func( page_table=block_table, softmax_scale=softmax_scale, causal=causal, + dynamic_causal=dynamic_causal, softcap=softcap, window_size_left=real_window_size[0] if real_window_size[0] >= 0 else None, window_size_right=real_window_size[1] if real_window_size[1] >= 0 else None, @@ -388,12 +408,75 @@ def flash_attn_varlen_func( return_lse=return_softmax_lse, out=out, learnable_sink=s_aux, + mask_mod=mask_mod, + aux_tensors=aux_tensors, + output_scale=output_scale, ) else: raise ValueError(f"Unsupported FA version: {fa_version}") return (out, softmax_lse) if return_softmax_lse else out +def compile_flash_attn_varlen_func_from_specs( + *, + q_shape: tuple[int, ...], + k_shape: tuple[int, ...], + v_shape: tuple[int, ...], + q_dtype: torch.dtype, + v_stride: tuple[int, ...] | None = None, + cu_seqlens_q_shape: tuple[int, ...] | None = None, + cu_seqlens_k_shape: tuple[int, ...] | None = None, + max_seqlen_q: int | None = None, + max_seqlen_k: int | None = None, + dropout_p: float = 0.0, + softmax_scale=None, + causal=False, + window_size: list[int] | None = None, + deterministic=False, + return_softmax_lse=False, + num_splits: int = 0, + fa_version: int = DEFAULT_FA_VERSION, +) -> None: + if fa_version != 4: + raise ValueError( + f"Compile-only FlashAttention is only supported for FA4, got FA{fa_version}" + ) + if dropout_p != 0.0: + raise NotImplementedError("FA4 compile-only wrapper does not support dropout") + del deterministic + + from vllm.vllm_flash_attn.cute.interface import ( + compile_flash_attn_varlen_func_from_specs as _fa4_compile_flash_attn_varlen_func_from_specs, + ) + + real_window_size: tuple[int, int] + if window_size is None: + real_window_size = (-1, -1) + else: + assert len(window_size) == 2 + real_window_size = (window_size[0], window_size[1]) + + if softmax_scale is None: + softmax_scale = q_shape[-1] ** (-0.5) + + return _fa4_compile_flash_attn_varlen_func_from_specs( + q_shape=q_shape, + k_shape=k_shape, + v_shape=v_shape, + q_dtype=q_dtype, + v_stride=v_stride, + cu_seqlens_q_shape=cu_seqlens_q_shape, + cu_seqlens_k_shape=cu_seqlens_k_shape, + max_seqlen_q=max_seqlen_q, + max_seqlen_k=max_seqlen_k, + softmax_scale=softmax_scale, + causal=causal, + window_size=real_window_size, + num_splits=num_splits, + return_lse=return_softmax_lse, + ) + + def sparse_attn_func( q, k,