Merge branch 'main' into wentao-optimize-per-token-group-quant

This commit is contained in:
mergify[bot]
2026-06-12 16:00:35 +00:00
committed by GitHub
283 changed files with 22825 additions and 8616 deletions
+1 -1
View File
@@ -91,7 +91,7 @@ steps:
- tests/quantization/test_cpu_wna16.py
commands:
- |
bash .buildkite/scripts/hardware_ci/run-cpu-test.sh 30m "
bash .buildkite/scripts/hardware_ci/run-cpu-test.sh 45m "
pytest -x -v -s tests/quantization/test_compressed_tensors.py::test_compressed_tensors_w8a8_logprobs
pytest -x -v -s tests/quantization/test_cpu_wna16.py"
+3
View File
@@ -13,5 +13,8 @@ INPUT_FILE="$1"
# Strip timestamps
sed -i 's/^\[[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}T[0-9]\{2\}:[0-9]\{2\}:[0-9]\{2\}Z\] //' "$INPUT_FILE"
# Strip Buildkite inline timestamp markers (ESC _bk;t=<ms> 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"
+151 -47
View File
@@ -1,74 +1,178 @@
#!/bin/bash
# Usage: ./ci-fetch-log.sh <buildkite_job_url> [output_file]
# ./ci-fetch-log.sh <build_number> <job_uuid> [output_file]
# Fetch vLLM Buildkite CI logs (public; no login required).
#
# Downloads the raw log for a Buildkite job from the public, unauthenticated
# /organizations/<org>/pipelines/<pipeline>/builds/<n>/jobs/<uuid>/download
# endpoint, then strips ANSI/timestamps via ci-clean-log.sh.
# Usage:
# ci-fetch-log.sh [--soft|--all] --pr [<PR>] failed jobs in the PR's latest
# build (current branch if omitted)
# ci-fetch-log.sh [--soft|--all] <build_url> failed jobs in that build
# ci-fetch-log.sh <job_url> [output] one job; both #<job_uuid> and
# ?sid=<id> URL forms work
# ci-fetch-log.sh <build> <job_uuid> [output]
#
# Find <build_number> and <job_uuid> via:
# gh pr checks <PR> --repo vllm-project/vllm
# Each failing row's URL is .../builds/<build_number>#<job_uuid>.
#
# Default output path: ci-<build>-<uuid_first_13_chars>.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-<build>-<job-name>.log (ANSI/timestamps stripped) and
# prints "<file>\t<job name>" 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 <buildkite_job_url> [output_file]"
echo " $0 <build_number> <job_uuid> [output_file]"
sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
exit 1
}
if [ $# -lt 1 ]; then usage; fi
die() {
echo "$1" >&2
exit 1
}
if [[ "$1" == https://* ]]; then
BUILD="" JOB="" SID="" OUT=""
SCOPE="failed"
while :; do
case "${1:-}" in
--soft) SCOPE="soft" ;;
--all) SCOPE="all" ;;
*) break ;;
esac
shift
done
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:-<current branch>}"
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
# 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"
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
fi
;;
esac
COOKIES=$(mktemp)
trap 'rm -f "$COOKIES"' EXIT
JOBS_TSV=$(mktemp)
trap 'rm -f "$COOKIES" "$JOBS_TSV"' EXIT
# Buildkite issues a session cookie on first hit; subsequent /download needs it.
curl -fsSL -c "$COOKIES" -A "vllm-ci-fetch-log" \
# 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
curl -fsSL -b "$COOKIES" -A "vllm-ci-fetch-log" \
"https://buildkite.com/organizations/${ORG}/pipelines/${PIPELINE}/builds/${BUILD}/jobs/${JOB}/download" \
-o "$OUT"
# 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
bash "$(dirname "$0")/ci-clean-log.sh" "$OUT"
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}"
echo "$OUT"
if [ -n "$SID" ] && [ -z "$JOB" ]; then
# The ?sid= in builds/<N>/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
fetch_job() { # <job_uuid> <output_file>
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
# Build-wide mode: fetch finished jobs matching $SCOPE.
[ -z "$OUT" ] || die "[output_file] is only valid when fetching a single job."
case "$SCOPE" in
failed) FILTER='$3 == "True" && $4 == "False" && $5 == "True"' ;;
soft) FILTER='$3 == "True" && $5 == "True"' ;;
all) FILTER='$5 == "True"' ;;
esac
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
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")
if [ "$FOUND" -eq 0 ]; then
echo "No matching jobs in build ${BUILD} (scope: ${SCOPE})." >&2
fi
+1 -1
View File
@@ -398,7 +398,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
+14 -1
View File
@@ -75,6 +75,19 @@ steps:
- pytest -v -s kernels/attention --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT
parallelism: 2
- 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
timeout_in_minutes: 90
@@ -224,7 +237,7 @@ 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/
+15
View File
@@ -138,11 +138,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
+20
View File
@@ -105,6 +105,26 @@ The line length limit for Python code is 88 characters. If you are not sure, use
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
Follow these rules for all code changes in this repository:
- Try to match existing code style.
- Code should be self-documenting and self-explanatory.
- Keep comments and docstrings minimal and concise.
- Assume the reader is familiar with vLLM.
### Diagnosing CI failures
Buildkite logs are public; no login needed. Details: [docs/contributing/ci/failures.md](docs/contributing/ci/failures.md).
```bash
# All failed-job logs for a PR's latest build (current branch's PR if omitted):
.buildkite/scripts/ci-fetch-log.sh --pr <PR>
# Any Buildkite build or job URL also works:
.buildkite/scripts/ci-fetch-log.sh "<buildkite_url>"
```
### Commit messages
Add attribution using commit trailers such as `Co-authored-by:` (other projects use `Assisted-by:` or `Generated-by:`). For example:
+208 -209
View File
@@ -358,145 +358,6 @@ if(VLLM_GPU_LANG STREQUAL "CUDA")
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}")
@@ -524,76 +385,6 @@ if(VLLM_GPU_LANG STREQUAL "CUDA")
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
@@ -672,10 +463,218 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
"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}")
# 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}")
+6
View File
@@ -792,6 +792,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]
+248
View File
@@ -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
@@ -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 803020a8fa15407871341d41eba4919ade2ee1ee
GIT_PROGRESS TRUE
# Don't share the vllm-flash-attn build between build types
BINARY_DIR ${CMAKE_BINARY_DIR}/vllm-flash-attn
@@ -57,13 +57,13 @@ VLLMDataTypeVLLMScalarTypeTag: dict[VLLMDataType | DataType, str] = {
}
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] = {
@@ -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 \
@@ -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) \
@@ -6,7 +6,7 @@
#include <iostream>
#include "quantization/marlin/marlin_dtypes.cuh"
#include "libtorch_stable/quantization/marlin/marlin_dtypes.cuh"
using marlin::MarlinScalarType2;
namespace allspark {
@@ -39,10 +39,10 @@ namespace machete {
{% 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 @@ torch::Tensor mm_dispatch_{{type_sig}}(MMArgs args) {
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<at::ScalarType> maybe_scalartype(
std::optional<at::Tensor> const& t) {
static inline std::optional<torch::headeronly::ScalarType> maybe_scalartype(
std::optional<torch::stable::Tensor> const& t) {
if (!t) {
return std::nullopt;
} else {
@@ -74,7 +74,7 @@ static inline std::optional<at::ScalarType> maybe_scalartype(
};
}
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 @@ torch::Tensor mm_dispatch(MMArgs args) {
}
{%- 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 @@ using Kernel_{{type_sig}} = MacheteKernelTemplate<
{% 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<Kernel_{{type_sig}}<sch_{{sch_sig}}>>(args);
}
@@ -212,7 +212,7 @@ PREPACK_TEMPLATE = """
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 @@ torch::Tensor prepack_B_dispatch(PrepackBArgs args) {
}
{%- 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
@@ -1,8 +1,6 @@
#pragma once
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <torch/all.h>
#include <torch/headeronly/util/Exception.h>
// clang-format off
// The cutlass include order matters (annoyingly)
@@ -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<torch::Tensor> const& maybe_g_scales, // scale_KxN matrix
std::optional<torch::Tensor> 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<torch::stable::Tensor> const&
maybe_g_scales, // scale_KxN matrix
std::optional<torch::stable::Tensor> const&
maybe_g_zeros, // scale_KxN matrix
std::optional<int64_t> maybe_group_size,
std::optional<torch::Tensor> const& maybe_ch_scales, // len N vector
std::optional<torch::Tensor> const& maybe_tok_scales) // len M vector
std::optional<torch::stable::Tensor> const&
maybe_ch_scales, // len N vector
std::optional<torch::stable::Tensor> 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<StrideA>(A, "A");
auto layout_D = make_cute_layout<StrideD>(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");
}
};
@@ -0,0 +1,80 @@
#pragma once
#include "machete_mm_kernel.cuh"
#include "cutlass_extensions/torch_utils.hpp"
#include "core/scalar_type.hpp"
#include "libtorch_stable/torch_utils.h"
#include <torch/csrc/stable/tensor.h>
#include <torch/headeronly/core/ScalarType.h>
#include <optional>
#include <string>
#include <vector>
namespace machete {
struct MMArgs {
torch::stable::Tensor const& A;
torch::stable::Tensor const& B;
vllm::ScalarType const& b_type;
std::optional<torch::headeronly::ScalarType> const& maybe_out_type;
std::optional<torch::stable::Tensor> const& maybe_group_scales;
std::optional<torch::stable::Tensor> const& maybe_group_zeros;
std::optional<int64_t> maybe_group_size;
std::optional<torch::stable::Tensor> const& maybe_channel_scales;
std::optional<torch::stable::Tensor> const& maybe_token_scales;
std::optional<std::string> maybe_schedule;
};
struct SupportedSchedulesArgs {
torch::headeronly::ScalarType a_type;
vllm::ScalarType b_type;
std::optional<torch::headeronly::ScalarType> maybe_group_scales_type;
std::optional<torch::headeronly::ScalarType> maybe_group_zeros_type;
std::optional<torch::headeronly::ScalarType> maybe_channel_scales_type;
std::optional<torch::headeronly::ScalarType> maybe_token_scales_type;
std::optional<torch::headeronly::ScalarType> maybe_out_type;
};
torch::stable::Tensor mm_dispatch(MMArgs args);
std::vector<std::string> supported_schedules_dispatch(
SupportedSchedulesArgs args);
template <typename MacheteKernel>
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<typename MacheteKernel::ElementD>,
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
@@ -3,6 +3,7 @@
#include "machete_mm_kernel.cuh"
#include "cutlass_extensions/cute_utils.cuh"
#include "cutlass_extensions/torch_utils.hpp"
#include <torch/headeronly/util/Exception.h>
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{});
@@ -3,39 +3,47 @@
#include "machete_prepack_kernel.cuh"
#include "cutlass_extensions/torch_utils.hpp"
#include "core/scalar_type.hpp"
#include "libtorch_stable/torch_utils.h"
#include <torch/csrc/stable/tensor.h>
#include <torch/headeronly/core/ScalarType.h>
#include <optional>
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<at::ScalarType> maybe_group_scales_type;
std::optional<torch::headeronly::ScalarType> maybe_group_scales_type;
};
template <typename PrepackedLayoutB>
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<ElementB const*>(B.const_data_ptr());
// elements per storage item for B
auto eles_per_storage =
(B.dtype().itemsize() * 8) / cute::sizeof_bits_v<ElementB>;
(B.element_size() * 8) / cute::sizeof_bits_v<ElementB>;
// 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<cutlass::layout::ColumnMajor>;
auto const l_Bt_packed = make_cute_layout<StrideB>(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<PrepackedLayoutB>(
stream, B_ptr, layout_Bt, static_cast<ElementB*>(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
@@ -1,9 +1,5 @@
#pragma once
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#include <torch/all.h>
// clang-format off
// The cutlass include order matters (annoyingly)
@@ -0,0 +1,77 @@
#include "machete_mm_launcher.cuh"
#include "machete_prepack_launcher.cuh"
#include "core/scalar_type.hpp"
#include <torch/csrc/stable/library.h>
#include <torch/csrc/stable/tensor.h>
#include <torch/headeronly/core/ScalarType.h>
namespace machete {
using namespace vllm;
std::vector<std::string> supported_schedules(
torch::headeronly::ScalarType a_type, int64_t b_type_id,
std::optional<torch::headeronly::ScalarType> maybe_group_scales_type,
std::optional<torch::headeronly::ScalarType> maybe_group_zeros_type,
std::optional<torch::headeronly::ScalarType> maybe_channel_scales_type,
std::optional<torch::headeronly::ScalarType> maybe_token_scales_type,
std::optional<torch::headeronly::ScalarType> 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<torch::headeronly::ScalarType> const& maybe_out_type,
std::optional<torch::stable::Tensor> const& maybe_group_scales,
std::optional<torch::stable::Tensor> const& maybe_group_zeros,
std::optional<int64_t> maybe_group_size,
std::optional<torch::stable::Tensor> const& maybe_channel_scales,
std::optional<torch::stable::Tensor> const& maybe_token_scales,
std::optional<std::string> 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<torch::headeronly::ScalarType> 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
@@ -1,6 +1,13 @@
#include "marlin.cuh"
#include "core/registration.h"
#include <torch/csrc/stable/accelerator.h>
#include <torch/csrc/stable/library.h>
#include <torch/csrc/stable/ops.h>
#include <torch/csrc/stable/tensor.h>
#include <torch/headeronly/core/ScalarType.h>
#include <torch/headeronly/util/Exception.h>
#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<uint32_t const*>(b_q_weight.data_ptr());
uint32_t* out_ptr = reinterpret_cast<uint32_t*>(out.data_ptr());
reinterpret_cast<uint32_t const*>(b_q_weight.const_data_ptr());
uint32_t* out_ptr = reinterpret_cast<uint32_t*>(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));
}
@@ -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.");'
)
@@ -1,6 +1,13 @@
#include "marlin.cuh"
#include "core/registration.h"
#include <torch/csrc/stable/accelerator.h>
#include <torch/csrc/stable/library.h>
#include <torch/csrc/stable/ops.h>
#include <torch/csrc/stable/tensor.h>
#include <torch/headeronly/core/ScalarType.h>
#include <torch/headeronly/util/Exception.h>
#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");
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");
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");
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<uint32_t const*>(b_q_weight.data_ptr());
uint32_t const* perm_ptr = reinterpret_cast<uint32_t const*>(perm.data_ptr());
uint32_t* out_ptr = reinterpret_cast<uint32_t*>(out.data_ptr());
reinterpret_cast<uint32_t const*>(b_q_weight.const_data_ptr());
uint32_t const* perm_ptr =
reinterpret_cast<uint32_t const*>(perm.const_data_ptr());
uint32_t* out_ptr = reinterpret_cast<uint32_t*>(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));
}
@@ -24,7 +24,15 @@
#endif
#include "kernel.h"
#include "core/registration.h"
#include <torch/csrc/stable/accelerator.h>
#include <torch/csrc/stable/library.h>
#include <torch/csrc/stable/ops.h>
#include <torch/csrc/stable/tensor.h>
#include <torch/headeronly/core/ScalarType.h>
#include <torch/headeronly/util/Exception.h>
#include "libtorch_stable/torch_utils.h"
#define STATIC_ASSERT_SCALAR_TYPE_VALID(scalar_t) \
static_assert(std::is_same<scalar_t, half>::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<torch::Tensor> c_or_none,
torch::Tensor& b_q_weight,
std::optional<torch::Tensor> const& b_bias_or_none, torch::Tensor& b_scales,
std::optional<torch::Tensor> const& b_zeros_or_none,
std::optional<torch::Tensor> const& g_idx_or_none,
std::optional<torch::Tensor> 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<torch::stable::Tensor> c_or_none,
torch::stable::Tensor& b_q_weight,
std::optional<torch::stable::Tensor> const& b_bias_or_none,
torch::stable::Tensor& b_scales,
std::optional<torch::stable::Tensor> const& a_scales_or_none,
std::optional<torch::stable::Tensor> const& global_scale_or_none,
std::optional<torch::stable::Tensor> const& b_zeros_or_none,
std::optional<torch::stable::Tensor> const& g_idx_or_none,
std::optional<torch::stable::Tensor> 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<torch::Tensor> c_or_none,
torch::Tensor& b_q_weight,
std::optional<torch::Tensor> const& b_bias_or_none, torch::Tensor& b_scales,
std::optional<torch::Tensor> const& a_scales_or_none,
std::optional<torch::Tensor> const& global_scale_or_none,
std::optional<torch::Tensor> const& b_zeros_or_none,
std::optional<torch::Tensor> const& g_idx_or_none,
std::optional<torch::Tensor> 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<torch::stable::Tensor> c_or_none,
torch::stable::Tensor& b_q_weight,
std::optional<torch::stable::Tensor> const& b_bias_or_none,
torch::stable::Tensor& b_scales,
std::optional<torch::stable::Tensor> const& a_scales_or_none,
std::optional<torch::stable::Tensor> const& global_scale_or_none,
std::optional<torch::stable::Tensor> const& b_zeros_or_none,
std::optional<torch::stable::Tensor> const& g_idx_or_none,
std::optional<torch::stable::Tensor> 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);
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::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));
}
@@ -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 <torch/all.h>
#include <ATen/cuda/CUDAContext.h>
#include <c10/cuda/CUDAGuard.h>
#endif
#include <cuda.h>
#include <cuda_fp16.h>
#include <cuda_runtime.h>
@@ -0,0 +1,118 @@
#include "marlin.cuh"
#include <torch/csrc/stable/accelerator.h>
#include <torch/csrc/stable/library.h>
#include <torch/csrc/stable/ops.h>
#include <torch/csrc/stable/tensor.h>
#include <torch/headeronly/core/ScalarType.h>
#include <torch/headeronly/util/Exception.h>
#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<torch::stable::Tensor> 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<<<blocks, 32, 0, stream>>>(
reinterpret_cast<const int32_t*>(qweight.const_data_ptr()),
reinterpret_cast<int32_t*>(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<<<blocks, 32, 0, stream>>>(
reinterpret_cast<const int32_t*>(qweight.const_data_ptr()),
reinterpret_cast<int32_t*>(output.mutable_data_ptr()),
reinterpret_cast<const int32_t*>(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));
}
+62
View File
@@ -33,6 +33,68 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
// TODO: Remove this once ROCm upgrade to torch 2.11.
ops.def("get_cuda_view_from_cpu_tensor(Tensor cpu_tensor) -> Tensor");
// 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
@@ -1,75 +0,0 @@
#pragma once
#include <torch/all.h>
#include <Python.h>
#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<at::ScalarType> const& maybe_out_type;
std::optional<torch::Tensor> const& maybe_group_scales;
std::optional<torch::Tensor> const& maybe_group_zeros;
std::optional<int64_t> maybe_group_size;
std::optional<torch::Tensor> const& maybe_channel_scales;
std::optional<torch::Tensor> const& maybe_token_scales;
std::optional<std::string> maybe_schedule;
};
struct SupportedSchedulesArgs {
at::ScalarType a_type;
vllm::ScalarType b_type;
std::optional<at::ScalarType> maybe_group_scales_type;
std::optional<at::ScalarType> maybe_group_zeros_type;
std::optional<at::ScalarType> maybe_channel_scales_type;
std::optional<at::ScalarType> maybe_token_scales_type;
std::optional<at::ScalarType> maybe_out_type;
};
torch::Tensor mm_dispatch(MMArgs args);
std::vector<std::string> supported_schedules_dispatch(
SupportedSchedulesArgs args);
template <typename MacheteKernel>
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<typename MacheteKernel::ElementD>)
.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
@@ -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<std::string> supported_schedules(
at::ScalarType a_type, int64_t b_type_id,
std::optional<at::ScalarType> maybe_group_scales_type,
std::optional<at::ScalarType> maybe_group_zeros_type,
std::optional<at::ScalarType> maybe_channel_scales_type,
std::optional<at::ScalarType> maybe_token_scales_type,
std::optional<at::ScalarType> 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<at::ScalarType> const& maybe_out_type,
std::optional<torch::Tensor> const& maybe_group_scales,
std::optional<torch::Tensor> const& maybe_group_zeros,
std::optional<int64_t> maybe_group_size,
std::optional<torch::Tensor> const& maybe_channel_scales,
std::optional<torch::Tensor> const& maybe_token_scales,
std::optional<std::string> 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<at::ScalarType> 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
@@ -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<torch::Tensor> 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<<<blocks, 32>>>(
(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<<<blocks, 32>>>(
(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);
}
-62
View File
@@ -68,68 +68,6 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) {
// 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
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 388 KiB

+2 -2
View File
@@ -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` |
@@ -532,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 \
+11 -5
View File
@@ -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-<build>-<job-name>.log`, stripped of timestamps and
ANSI codes:
```bash
# Find the failing job. Each row's URL is .../builds/<N>#<job_uuid>:
gh pr checks <PR> --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 <PR>
# Download + strip timestamps/ANSI in one step:
# 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/<N>"
# One job — `gh pr checks` URLs (#<job_uuid>) 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/<N>#<job_uuid>"
```
+2 -1
View File
@@ -180,7 +180,8 @@ Priority is **1 = highest** (tried first).
| `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_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`, `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`.
+9
View File
@@ -82,6 +82,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
| Architecture | Models | CG for Image | CG for Video |
| ------------ | ------ | ------------ | ------------ |
| `Llama4ForConditionalGeneration` | `Llama 4` | ✅︎ | - |
| `InternVLChatModel` | `InternVL3.5`, `InternVL3`, `InternVL2.5`, `InternVL2` | ✅︎ | ✅︎ |
| `Qwen2VLForConditionalGeneration` | `Qwen2-VL` | ✅︎ | ✅︎ |
| `Qwen2_5_VLForConditionalGeneration` | `Qwen2.5-VL` | ✅︎ | ✅︎ |
@@ -114,6 +115,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
+256
View File
@@ -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:<msgpack>`` (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-<uuid>-<index>`` 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:<msgpack-encoded dict>
```
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
`<request_id>:<tp_size>` 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:<msgpack>`).
Behavior on the engine main thread is otherwise unchanged. The writer
thread is event-driven and idle when there is no push work.
+48
View File
@@ -423,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": <seconds>}}'`.
## Example Scripts/Code
Refer to these example scripts in the vLLM repository:
+17 -7
View File
@@ -115,18 +115,28 @@ Whether vLLM enforces the tool parameter schema during generation depends on the
| --- | --- | --- |
| 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"` | Depends on the parser | Model-specific structural-tag parsers can constrain tool-call arguments with structured outputs. Other parsers generate freely and extract tool calls 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)
Strict tool calling makes function-call arguments adhere to the function schema instead of relying only on best-effort parsing. vLLM implements strict tool calling for structural-tag based tool parsers by using the structured outputs backend under the hood.
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 controls structural-tag strict tool calling with the `VLLM_ENFORCE_STRICT_TOOL_CALLING` environment variable. It defaults to `true`.
```bash
VLLM_ENFORCE_STRICT_TOOL_CALLING=false vllm serve ...
```
When this variable is `true`, structural-tag based tool parsers attach a structural tag to the request, so the structured outputs backend can constrain the model-specific tool-call format and function-call arguments. When it is `false`, vLLM does not attach structural tags for tool calling. In that case, `tool_choice="auto"` falls back to best-effort parser extraction from the raw model output, and no structural-tag constraint is applied.
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"`.
## Automatic Function Calling
@@ -146,7 +156,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 depends on the selected parser and `VLLM_ENFORCE_STRICT_TOOL_CALLING`. Structural-tag parsers can enforce tool-call constraints when it is `true`; when it is `false`, or when the selected parser has no structural-tag support, vLLM extracts tool calls from raw text, so arguments may occasionally be malformed or violate the function's parameter schema.
### Hermes Models (`hermes`)
+1 -2
View File
@@ -423,7 +423,6 @@ th {
| `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. | | |
@@ -578,7 +577,7 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen
| `InternS1ForConditionalGeneration` | Intern-S1 | T + I<sup>E+</sup> + V<sup>E+</sup> | `internlm/Intern-S1`, `internlm/Intern-S1-mini`, etc. | ✅︎ | ✅︎ |
| `InternS1ProForConditionalGeneration` | Intern-S1-Pro | T + I<sup>E+</sup> + V<sup>E+</sup> | `internlm/Intern-S1-Pro`, etc. | ✅︎ | ✅︎ |
| `InternS2PreviewForConditionalGeneration` | Intern-S2-Preview | T + I<sup>E+</sup> + V<sup>E+</sup> | `internlm/Intern-S2-Preview`, etc. | ✅︎ | ✅︎ |
| `InternVLChatModel` | InternVL 3.5, InternVL 3.0, InternVideo 2.5, InternVL 2.5, Mono-InternVL, InternVL 2.0 | T + I<sup>E+</sup> + (V<sup>E+</sup>) | `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 + I<sup>E+</sup> + (V<sup>E+</sup>) | `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 + I<sup>E+</sup> + V<sup>E+</sup> | `OpenGVLab/InternVL3-1B-hf`, etc. | ✅︎ | ✅︎ |
| `KananaVForConditionalGeneration` | Kanana-V | T + I<sup>+</sup> | `kakaocorp/kanana-1.5-v-3b-instruct`, etc. | | ✅︎ |
| `KeyeForConditionalGeneration` | Keye-VL-8B-Preview | T + I<sup>E+</sup> + V<sup>E+</sup> | `Kwai-Keye/Keye-VL-8B-Preview` | ✅︎ | ✅︎ |
@@ -0,0 +1,429 @@
# 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``)
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 \
--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,
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,
}
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"],
}
# ── 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)
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,
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",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
proxy_server = PushProxyServer(args=args)
proxy_server.run_server()
@@ -2532,6 +2532,7 @@ MODELS_NEED_VIDEO_METADATA = [
MODELS_SUPPORT_VIT_CUDA_GRAPH = [
"llama4",
"internvl_chat",
"qwen2_5_vl",
"qwen3_vl",
+1 -1
View File
@@ -25,7 +25,7 @@ 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
+1 -1
View File
@@ -1367,7 +1367,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
+2
View File
@@ -468,6 +468,8 @@ impl ServeArgs {
self.runtime.model.clone(),
self.runtime.max_model_len,
self.runtime.language_model_only,
self.runtime.disable_log_stats,
self.runtime.shutdown_timeout,
handshake_port,
)
}
+34
View File
@@ -100,6 +100,40 @@ fn serve_args_auto_forward_enable_lora_to_python() {
assert_eq!(args.managed_engine.python_args, vec!["--enable-lora"]);
}
#[test]
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);
assert_eq!(config.python_args, vec!["--shutdown-timeout", "60"]);
}
#[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);
assert_eq!(config.python_args, vec!["--disable-log-stats"]);
}
#[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();
+11
View File
@@ -72,6 +72,8 @@ impl ManagedEngineArgs {
model: String,
max_model_len: Option<u32>,
language_model_only: bool,
disable_log_stats: bool,
shutdown_timeout: u64,
handshake_port: u16,
) -> ManagedEngineConfig {
let mut python_args = self.python_args;
@@ -83,6 +85,15 @@ impl ManagedEngineArgs {
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());
+82
View File
@@ -1,6 +1,7 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use thiserror_ext::AsReport as _;
use thiserror_ext::{Construct, Macro};
use crate::routes::openai::utils::types::{ErrorDetail, ErrorResponse};
@@ -72,3 +73,84 @@ impl IntoResponse for ApiError {
(self.status_code(), Json(self.to_error_response())).into_response()
}
}
/// Classify a text-pipeline submit failure: tokenized-prompt validation
/// failures (the prompt is too long for the model, or empty after
/// tokenization) 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 is_prompt_validation_error(&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 {
match &error {
vllm_chat::Error::PromptTooLong { .. } => invalid_request!("{error}"),
vllm_chat::Error::Text(text_error) if is_prompt_validation_error(text_error) => {
invalid_request!("{error}")
}
_ => server_error!("{}: {}", context, error.to_report_string()),
}
}
fn is_prompt_validation_error(error: &vllm_text::Error) -> bool {
matches!(
error,
vllm_text::Error::PromptTooLong { .. }
| vllm_text::Error::EmptyPromptTokenIds { .. }
// An empty tokenized prompt detected later, at request prepare
// time, surfaces through the transparent Llm wrapper.
| vllm_text::Error::Llm(vllm_llm::Error::EmptyPromptTokenIds { .. })
)
}
#[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 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 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:"));
}
}
@@ -28,7 +28,7 @@ use self::types::{
GenerateResponseStreamChoice, GenerateStreamResponse,
};
use crate::config::ApiServerOptions;
use crate::error::{ApiError, bail_server_error, server_error};
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;
@@ -65,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();
}
};
@@ -25,7 +25,7 @@ use vllm_engine_core_client::protocol::StopReason;
use self::convert::{ResponseOptions, prepare_chat_request};
use crate::config::ApiServerOptions;
use crate::error::{ApiError, bail_server_error, server_error};
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,
@@ -37,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};
@@ -76,11 +77,7 @@ pub async fn chat_completions(
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();
}
};
@@ -129,6 +126,8 @@ async fn collect_chat_completion(
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,
@@ -249,6 +248,7 @@ async fn chat_completion_chunk_stream(
}: ApiServerOptions,
ResponseOptions {
include_usage,
include_continuous_usage,
requested_logprobs,
// Ignored: chat streaming prompt logprobs are rejected for Python parity.
include_prompt_logprobs: _,
@@ -265,33 +265,47 @@ async fn chat_completion_chunk_stream(
// 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, .. }) => {
@@ -301,14 +315,13 @@ async fn chat_completion_chunk_stream(
if let Some(pending_chunk) = pending_chunk.as_mut() {
pending_chunk.push_block_delta(kind, delta);
} else {
y.yield_ok(block_delta_chunk(
yield_chunk!(block_delta_chunk(
&request_id,
&response_model,
created,
kind,
delta,
))
.await;
));
}
} else {
suppress_current_update_metadata = true;
@@ -318,6 +331,8 @@ async fn chat_completion_chunk_stream(
logprobs,
token_ids,
}) => {
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;
@@ -339,16 +354,15 @@ async fn chat_completion_chunk_stream(
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, .. }) => {
@@ -376,15 +390,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 }) => {
@@ -392,21 +405,20 @@ 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 {
usage,
usage: final_usage,
finish_reason,
..
}) => {
@@ -414,18 +426,23 @@ async fn chat_completion_chunk_stream(
info!(
stream = true,
model = %response_model,
prompt_tokens = usage.prompt_token_count,
output_tokens = usage.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(
@@ -435,7 +452,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,
@@ -450,7 +467,7 @@ async fn chat_completion_chunk_stream(
&request_id,
&response_model,
created,
Usage::from_token_usage(usage, enable_prompt_tokens_details),
Usage::from_token_usage(final_usage, enable_prompt_tokens_details),
))
.await;
}
@@ -33,6 +33,8 @@ pub(super) struct PreparedRequest {
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.
@@ -82,6 +84,12 @@ pub(super) 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
@@ -154,6 +162,7 @@ pub(super) fn prepare_chat_request(
response_model,
options: ResponseOptions {
include_usage,
include_continuous_usage,
requested_logprobs,
include_prompt_logprobs,
include_reasoning,
@@ -375,8 +384,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};
@@ -456,6 +465,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(
@@ -137,15 +137,6 @@ pub(super) fn validate_request_compat(
"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(())
}
@@ -25,12 +25,13 @@ use super::utils::logprobs::{
};
use super::utils::types::Usage;
use crate::config::ApiServerOptions;
use crate::error::{ApiError, bail_server_error, server_error};
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};
@@ -74,11 +75,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();
}
};
@@ -127,6 +124,8 @@ async fn collect_completion(
ResponseOptions {
// Ignored: non-streaming responses always include usage.
include_usage: _,
// Ignored: non-streaming responses are collected before usage is attached.
include_continuous_usage: _,
echo,
requested_logprobs,
include_prompt_logprobs,
@@ -218,6 +217,7 @@ async fn completion_chunk_stream(
}: ApiServerOptions,
ResponseOptions {
include_usage,
include_continuous_usage,
echo,
requested_logprobs,
// Ignored: streaming prompt logprobs are rejected for Python parity.
@@ -230,6 +230,18 @@ async fn completion_chunk_stream(
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 {
@@ -237,6 +249,7 @@ async fn completion_chunk_stream(
prompt_token_ids, ..
}) => {
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 =
@@ -247,7 +260,7 @@ async fn completion_chunk_stream(
}
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 =
@@ -256,7 +269,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 {
@@ -281,10 +294,12 @@ 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 {
@@ -298,13 +313,17 @@ async fn completion_chunk_stream(
"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(
@@ -25,6 +25,8 @@ pub(super) struct PreparedRequest {
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,
/// Original text prompt that should be echoed back northbound when
/// `echo=true`.
pub echo: Option<String>,
@@ -74,6 +76,12 @@ pub(super) fn prepare_completion_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 include_prompt_logprobs = prompt_logprobs.is_some();
let echo = request.echo.then(|| request.prompt.as_text().cloned()).flatten();
@@ -129,6 +137,7 @@ pub(super) fn prepare_completion_request(
response_model,
options: ResponseOptions {
include_usage,
include_continuous_usage,
echo,
requested_logprobs: request.logprobs,
include_prompt_logprobs,
@@ -247,6 +256,55 @@ mod tests {
assert!(!prepared.text_request.decode_options.skip_special_tokens);
}
#[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(),
)
.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(),
)
.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!({
@@ -95,15 +95,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(())
}
@@ -2,4 +2,5 @@ pub mod logprobs;
pub mod structured_outputs;
pub mod token_ids;
pub mod types;
pub mod usage;
pub mod validated_json;
@@ -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)
}
}
+115
View File
@@ -151,6 +151,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<serde_json::Value> {
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<Box<dyn Future<Output = ()> + Send + 'a>>;
fn boxed_test_future<'a>(future: impl Future<Output = ()> + Send + 'a) -> TestFuture<'a> {
@@ -2341,6 +2349,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() {
@@ -3434,6 +3496,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() {
+1 -1
View File
@@ -1229,7 +1229,7 @@ setup(
# 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
+200
View File
@@ -0,0 +1,200 @@
#!/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)
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|>"
)
def test_asr_dataset_sample_handles_embedded_audio_bytes(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": None,
"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"
@@ -124,8 +124,6 @@ TEXT_GENERATION_MODELS = {
"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(),
@@ -6,13 +6,29 @@ Tests the image source handling and tool_result content parsing in
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``.
"""
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.openai.chat_completion.protocol import (
ChatCompletionResponseStreamChoice,
ChatCompletionStreamResponse,
)
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
UsageInfo,
)
_convert = AnthropicServingMessages._convert_anthropic_to_openai_request
_img_url = AnthropicServingMessages._convert_image_source_to_url
@@ -775,3 +791,208 @@ class TestInlineSystemMessageInMessagesArray:
assert result.messages[0]["role"] == "system"
assert result.messages[0]["content"] == "Top-level prompt.Inline hint."
assert result.messages[1]["role"] == "user"
# ======================================================================
# 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"
@@ -250,6 +250,7 @@ async def k2_client(k2_server):
@pytest.mark.asyncio
@pytest.mark.skip(reason="Skipping Kimi K2 tool ID test")
@pytest.mark.parametrize("model_name", [MODEL_NAME])
@pytest.mark.parametrize("stream", [True, False])
@pytest.mark.parametrize("tool_choice", ["required"])
@@ -442,7 +443,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
@@ -38,12 +38,12 @@ from vllm.entrypoints.serve.render.serving import OpenAIServingRender
from vllm.exceptions import VLLMValidationError
from vllm.inputs import TokensPrompt
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.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
GPT_OSS_MODEL_NAME = "openai/gpt-oss-20b"
@@ -575,7 +575,13 @@ def _build_serving_render(
)
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,
@@ -590,6 +596,9 @@ def _build_serving_chat(engine: AsyncLLM) -> OpenAIServingChat:
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
@@ -637,7 +646,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 +1219,21 @@ class TestServingChatWithHarmony:
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 +1292,7 @@ class TestServingChatWithHarmony:
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:
@@ -1304,11 +1320,12 @@ class TestServingChatWithHarmony:
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,
),
chat_template_kwargs=serving_chat._effective_chat_template_kwargs(req),
)
if stream:
@@ -1316,11 +1333,18 @@ class TestServingChatWithHarmony:
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)
req = ChatCompletionRequest(
model=MODEL_NAME,
messages=messages,
include_reasoning=include_reasoning,
)
input_messages, _ = (
serving_chat.openai_serving_render._make_request_with_harmony(req)
)
@@ -1342,7 +1366,11 @@ class TestServingChatWithHarmony:
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:
@@ -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
@@ -58,9 +58,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(
@@ -11,12 +11,10 @@ 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,
@@ -941,110 +939,6 @@ class TestAutoDropAnalysisMessages:
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"})
@@ -512,3 +512,46 @@ async def test_stream_prompt_tokens_details():
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
@@ -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(
@@ -16,10 +16,11 @@ from statistics import mean, median
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 @@ normalizer_tokenizer = get_tokenizer(
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)
@@ -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,7 @@ 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)
@@ -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)
@@ -10,6 +10,25 @@ pytestmark = pytest.mark.skipif(
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
@@ -156,6 +175,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]:
@@ -375,3 +408,110 @@ 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)
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)
@@ -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))}",
)
@@ -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
+3
View File
@@ -713,6 +713,7 @@ class TestHelionKernelWrapper:
new_op = Mock()
registered_ops: dict[str, Mock] = {}
mutates_args = ["y"]
class MockNamespace:
def __getattr__(self, name):
@@ -748,6 +749,7 @@ class TestHelionKernelWrapper:
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 @@ class TestHelionKernelWrapper:
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:
+30
View File
@@ -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")
@@ -496,5 +496,258 @@ 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 group-quantized 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)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+12 -9
View File
@@ -27,6 +27,7 @@ from vllm.model_executor.layers.fused_moe.modular_kernel import FusedMoEKernel
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
@@ -64,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)
@@ -105,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
@@ -216,10 +219,10 @@ def deep_ep_moe_impl(
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)
@@ -318,7 +321,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
@@ -367,7 +370,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)
@@ -441,7 +444,7 @@ MNKs = [
(222, 1024, 2048),
]
DTYPES = [torch.bfloat16, torch.float8_e4m3fn]
DTYPES = [torch.bfloat16, current_platform.fp8_dtype()]
@pytest.mark.parametrize("dtype", DTYPES)
@@ -496,7 +499,7 @@ MNKs = [
(64, 1024, 2560),
(222, 1024, 2560),
]
DTYPES = [torch.float8_e4m3fn, torch.bfloat16]
DTYPES = [current_platform.fp8_dtype(), torch.bfloat16]
USE_FP8_DISPATCH = [True, False]
@@ -1,11 +1,15 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
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 +57,24 @@ 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
@@ -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
@@ -604,8 +604,6 @@ VLM_TEST_SETTINGS = {
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
@@ -55,6 +55,26 @@ def step3_vl_chat_template(content: str) -> str:
MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
"llama4": VitCudagraphTestConfig(
model="meta-llama/Llama-4-Scout-17B-16E-Instruct",
modalities=["image"],
image_prompt=(
"<|begin_of_text|><|header_start|>user<|header_end|>\n\n"
"<|image|>What is in this image?<|eot|>"
"<|header_start|>assistant<|header_end|>\n\n"
),
max_model_len=4096,
max_tokens=32,
max_num_seqs=2,
vllm_runner_kwargs={
"load_format": "dummy",
"hf_overrides": partial(
dummy_hf_overrides,
model_arch="Llama4ForConditionalGeneration",
),
},
marks=[pytest.mark.core_model],
),
"internvl": VitCudagraphTestConfig(
model="OpenGVLab/InternVL3-1B",
num_video_frames=8,
@@ -5,12 +5,16 @@
Run `pytest tests/quantization/test_bitsandbytes.py`.
"""
import types
from unittest.mock import MagicMock, patch
import pytest
from packaging.version import Version
from transformers import BitsAndBytesConfig
from transformers import __version__ as TRANSFORMERS_VERSION
from tests.quantization.utils import is_quant_method_supported
from vllm.model_executor.model_loader import bitsandbytes_loader as bnb
from vllm.platforms import current_platform
from ...utils import compare_two_settings, multi_gpu_test
@@ -300,3 +304,27 @@ def validate_generated_texts(
f"HF Output: '{hf_str}'\n"
f"vLLM Output: '{vllm_str}'"
)
def test_bitsandbytes_passes_revision_by_name():
# revision must reach download_safetensors_index_file_from_hf as the
# ``revision`` keyword, not a positional slot.
fake_self = types.SimpleNamespace(
load_config=types.SimpleNamespace(download_dir="/cache"),
_get_weight_files=MagicMock(
return_value=("/folder", ["/folder/model.safetensors"], "*.safetensors")
),
)
with (
patch.object(bnb, "download_safetensors_index_file_from_hf") as mock_idx,
patch.object(
bnb,
"filter_duplicate_safetensors_files",
return_value=["/folder/model.safetensors"],
),
):
bnb.BitsAndBytesModelLoader._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
+4 -14
View File
@@ -338,23 +338,9 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
"naver-hyperclovax/HyperCLOVAX-SEED-Think-14B",
min_transformers_version="5.9.0",
),
"InternLMForCausalLM": _HfExamplesInfo(
"internlm/internlm-chat-7b", trust_remote_code=True
),
"InternLM2ForCausalLM": _HfExamplesInfo(
"internlm/internlm2-chat-7b", trust_remote_code=True
),
"InternLM2VEForCausalLM": _HfExamplesInfo(
"OpenGVLab/Mono-InternVL-2B",
trust_remote_code=True,
max_transformers_version="4.57",
transformers_version_reason={
"vllm": (
"Custom config cannot be loaded with Transformers "
"v5 because `vision_config` is not always set"
)
},
),
"InternLM3ForCausalLM": _HfExamplesInfo(
"internlm/internlm3-8b-instruct", trust_remote_code=True
),
@@ -901,6 +887,10 @@ _MULTIMODAL_EXAMPLE_MODELS = {
),
"FuyuForCausalLM": _HfExamplesInfo("adept/fuyu-8b"),
"Gemma3ForConditionalGeneration": _HfExamplesInfo("google/gemma-3-4b-it"),
"DiffusionGemmaForBlockDiffusion": _HfExamplesInfo(
"google/diffusiongemma-26B-A4B-it",
trust_remote_code=True,
),
"Gemma4ForConditionalGeneration": _HfExamplesInfo(
"google/gemma-4-E2B-it",
min_transformers_version="5.5.0",
+6 -3
View File
@@ -486,6 +486,7 @@ def dummy_hf_overrides(
"Gemma3nForConditionalGeneration",
"Gemma4ForCausalLM",
"Gemma4ForConditionalGeneration",
"DiffusionGemmaForBlockDiffusion",
)
else 1
)
@@ -506,12 +507,13 @@ def dummy_hf_overrides(
# Only set MoE related config when the model has MoE layers.
# Otherwise all models detected as MoE by _get_transformers_backend_cls.
if model_arch_config.num_experts > 0:
num_experts_per_tok = 1 if model_arch == "Llama4ForConditionalGeneration" else 2
update_dict.update(
{
"num_experts": num_experts,
"num_experts_per_tok": 2,
"num_experts_per_tok": num_experts_per_tok,
# Kimi uses `num_experts_per_token`.
"num_experts_per_token": 2,
"num_experts_per_token": num_experts_per_tok,
"num_local_experts": num_experts,
# Otherwise there will not be any expert layers
"first_k_dense_replace": 0,
@@ -558,7 +560,8 @@ def dummy_hf_overrides(
)
# e.g.: Qwen/Qwen2-Audio-7B-Instruct
if hasattr(hf_config, "audio_config"):
# audio_config may exist but be None (e.g. audio-less Gemma4 variants).
if getattr(hf_config, "audio_config", None) is not None:
hf_config.audio_config.update(
{
"num_layers": 1,
+736
View File
@@ -0,0 +1,736 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from collections.abc import Sequence
import pytest
from openai_harmony import (
Conversation,
Message,
RenderConversationConfig,
Role,
)
from transformers import AutoTokenizer
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.engine.protocol import FunctionCall
from vllm.entrypoints.openai.parser.harmony_utils import (
get_encoding,
)
from vllm.parser.harmony import HarmonyParser
from vllm.parser.parser_manager import ParserManager
REASONING_MODEL_NAME = "openai/gpt-oss-20b"
@pytest.fixture(scope="module")
def gpt_oss_tokenizer():
return AutoTokenizer.from_pretrained(REASONING_MODEL_NAME)
@pytest.fixture
def harmony_parser(gpt_oss_tokenizer):
parser_cls = ParserManager.get_parser(
tool_parser_name="openai",
reasoning_parser_name="openai_gptoss",
enable_auto_tools=True,
model_name=REASONING_MODEL_NAME,
is_harmony=True,
)
assert parser_cls is HarmonyParser
return parser_cls(gpt_oss_tokenizer)
@pytest.fixture
def chat_request():
return ChatCompletionRequest(
model="openai/gpt-oss-20b",
messages=[{"role": "user", "content": "Hello"}],
)
def encode_output(harmony_str: str) -> list[int]:
return get_encoding().encode(harmony_str, allowed_special="all")
def assistant(content: str, channel: str) -> Message:
return Message.from_role_and_content(Role.ASSISTANT, content).with_channel(channel)
def tool_call(
recipient: str,
content: str,
channel: str = "commentary",
content_type: str | None = "json",
) -> Message:
message = assistant(content, channel).with_recipient(recipient)
return message if content_type is None else message.with_content_type(content_type)
def get_model_output_tokens(
prompt_messages: Sequence[Message],
response_messages: Sequence[Message],
) -> list[int]:
enc = get_encoding()
# Keep analysis messages when synthesizing model-output-only token sequences
# for parser tests; the default render path drops them after a later final turn.
config = RenderConversationConfig(auto_drop_analysis=False)
prompt_ids = enc.render_conversation_for_completion(
Conversation.from_messages(list(prompt_messages)),
Role.ASSISTANT,
config=config,
)
full_ids = enc.render_conversation_for_completion(
Conversation.from_messages([*prompt_messages, *response_messages]),
Role.ASSISTANT,
config=config,
)
assert full_ids[: len(prompt_ids)] == prompt_ids
return full_ids[len(prompt_ids) :]
def get_text(msg: Message) -> str:
return msg.content[0].text if msg.content else ""
def tool_call_tuples(tool_calls: list[FunctionCall] | None) -> list[tuple[str, str]]:
return [] if tool_calls is None else [(tc.name, tc.arguments) for tc in tool_calls]
def tool_call_headers(delta_message) -> list:
if delta_message is None or not delta_message.tool_calls:
return []
return [
tool_call
for tool_call in delta_message.tool_calls
if tool_call.function and tool_call.function.name
]
def tool_call_payloads(delta_message) -> list:
if delta_message is None or not delta_message.tool_calls:
return []
return [
tool_call
for tool_call in delta_message.tool_calls
if tool_call.function and tool_call.function.arguments
]
def combined_tool_arguments(delta_message) -> dict[int, str]:
combined: dict[int, str] = {}
for tool_call in tool_call_payloads(delta_message):
combined.setdefault(tool_call.index, "")
combined[tool_call.index] += tool_call.function.arguments
return combined
class TestParse:
# Rendered conversation outputs.
def test_reasoning_only(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "Why?")]
response = [assistant("This is reasoning", "analysis")]
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert reasoning == "This is reasoning"
assert content is None
assert tool_calls is None
def test_content_only(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "Hello")]
response = [assistant("This is a test", "final")]
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert reasoning is None
assert content == "This is a test"
assert tool_calls is None
def test_reasoning_and_content(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "What is 2+2?")]
response = [
assistant("I should think first.", "analysis"),
assistant("The answer is 4.", "final"),
]
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert reasoning == "I should think first."
assert content == "The answer is 4."
assert tool_calls is None
@pytest.mark.parametrize(
"tool_args",
[
'{"location": "Tokyo"}',
'{\n"location": "Tokyo"\n}',
],
)
@pytest.mark.parametrize("tool_channel", ["commentary", "analysis"])
def test_single_tool_call(
self, harmony_parser, chat_request, tool_args, tool_channel
):
prompt = [
Message.from_role_and_content(Role.USER, "What is the weather in Tokyo?")
]
response = [tool_call("functions.get_current_weather", tool_args, tool_channel)]
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert reasoning is None
assert content is None
assert tool_call_tuples(tool_calls) == [
("get_current_weather", json.dumps({"location": "Tokyo"}))
]
def test_multiple_tool_calls_varied_formats(self, harmony_parser, chat_request):
prompt = [
Message.from_role_and_content(
Role.USER, "What is the weather in Tokyo based on where I'm at?"
)
]
response = [
tool_call("functions.get_current_weather", '{"location": "Tokyo"}'),
tool_call("functions.get_user_location", '{"location": "Tokyo"}'),
tool_call(
"functions.no_content_type",
'{"location": "Tokyo"}',
content_type=None,
),
tool_call("functions.not_json_no_content_type", "foo", content_type=None),
tool_call("functions.empty_args", "{}"),
tool_call("functions.no_args", ""),
]
_, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert content is None
assert tool_call_tuples(tool_calls) == [
("get_current_weather", json.dumps({"location": "Tokyo"})),
("get_user_location", json.dumps({"location": "Tokyo"})),
("no_content_type", json.dumps({"location": "Tokyo"})),
("not_json_no_content_type", "foo"),
("empty_args", json.dumps({})),
("no_args", ""),
]
def test_tool_call_bare_recipient(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "Weather?")]
response = [tool_call("get_current_weather", '{"location": "Tokyo"}')]
_, _, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert tool_call_tuples(tool_calls) == [
("get_current_weather", json.dumps({"location": "Tokyo"}))
]
def test_multiple_tool_calls_bare_recipients(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "Use both tools.")]
response = [
tool_call("get_current_weather", '{"location": "Tokyo"}'),
tool_call("get_user_location", "{}"),
]
_, _, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert tool_call_tuples(tool_calls) == [
("get_current_weather", json.dumps({"location": "Tokyo"})),
("get_user_location", json.dumps({})),
]
def test_assistant_recipient_not_tool(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "Hello")]
response = [
tool_call("assistant", "Some tool response", content_type=None),
assistant("Here is the answer", "final"),
]
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert reasoning is None
assert content == "Here is the answer"
assert tool_calls is None
def test_tool_call_dotted_name(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "Compute 2+3")]
response = [tool_call("math.sum", '{"a": 2, "b": 3}')]
_, _, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert tool_call_tuples(tool_calls) == [
("math.sum", json.dumps({"a": 2, "b": 3}))
]
def test_tool_calls_with_final_content(self, harmony_parser, chat_request):
prompt = [Message.from_role_and_content(Role.USER, "What is the weather?")]
response = [
assistant("User asked about the weather.", "analysis"),
tool_call("functions.get_current_weather", '{"location": "Tokyo"}'),
assistant("This tool call will get the weather.", "final"),
]
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=get_model_output_tokens(prompt, response),
)
assert reasoning == "User asked about the weather."
assert content == "This tool call will get the weather."
assert tool_call_tuples(tool_calls) == [
("get_current_weather", json.dumps({"location": "Tokyo"}))
]
# Raw/truncated Harmony output streams.
def test_interrupted_first_message(self, harmony_parser, chat_request):
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=encode_output(
"<|channel|>final<|message|>I'm in the middle of answering"
),
)
assert reasoning is None
assert content == "I'm in the middle of answering"
assert tool_calls is None
def test_interrupted_reasoning_first_message(self, harmony_parser, chat_request):
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=encode_output(
"<|channel|>analysis<|message|>I'm in the middle of thinking"
),
)
assert reasoning == "I'm in the middle of thinking"
assert content is None
assert tool_calls is None
def test_truncated_output(self, harmony_parser, chat_request):
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=encode_output(
"<|channel|>analysis<|message|>I'm thinking.<|end|>"
"<|start|>assistant<|channel|>final<|message|>"
"I'm in the middle of answering"
),
)
assert reasoning == "I'm thinking."
assert content == "I'm in the middle of answering"
assert tool_calls is None
@pytest.mark.parametrize(
("harmony_str", "expected_content"),
[
(
"<|channel|>commentary<|message|>I'll search for that",
"I'll search for that",
),
(
"<|channel|>commentary<|message|>Let me look that up.<|end|>"
"<|start|>assistant<|channel|>final<|message|>The answer is 42.<|end|>",
"Let me look that up.\nThe answer is 42.",
),
],
)
def test_commentary_preambles(
self,
harmony_parser,
chat_request,
harmony_str,
expected_content,
):
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=encode_output(harmony_str),
)
assert reasoning is None
assert content == expected_content
assert tool_calls is None
def test_commentary_with_recipient_excluded(self, harmony_parser, chat_request):
reasoning, content, tool_calls = harmony_parser.parse(
"",
chat_request,
model_output_token_ids=encode_output(
"<|channel|>commentary"
"<|message|>Let me check the weather.<|end|>"
"<|start|>assistant to=functions.get_weather"
"<|channel|>commentary"
'<|message|>{"location": "SF"}<|end|>'
),
)
assert reasoning is None
assert content == "Let me check the weather."
assert tool_call_tuples(tool_calls) == [
("get_weather", json.dumps({"location": "SF"}))
]
class TestParseDelta:
def test_basic(self, gpt_oss_tokenizer, chat_request):
parser = HarmonyParser(gpt_oss_tokenizer)
first_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output("<|channel|>analysis<|message|>Thinking"),
request=chat_request,
finished=False,
)
second_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|end|><|start|>assistant<|channel|>final<|message|>Answer"
),
request=chat_request,
finished=False,
)
assert first_delta is not None
assert first_delta.reasoning == "Thinking"
assert first_delta.content is None
assert second_delta is not None
assert second_delta.content == "Answer"
assert second_delta.reasoning is None
def test_multi_token(self, gpt_oss_tokenizer, chat_request):
parser = HarmonyParser(gpt_oss_tokenizer)
delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output("<|channel|>final<|message|>Hello, world!"),
request=chat_request,
finished=False,
)
assert delta is not None
assert delta.content == "Hello, world!"
assert delta.reasoning is None
assert not delta.tool_calls
@pytest.mark.parametrize("tool_channel", ["commentary", "analysis"])
def test_tool_call_split_across_deltas(
self, gpt_oss_tokenizer, chat_request, tool_channel
):
parser = HarmonyParser(gpt_oss_tokenizer)
first_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>analysis<|message|>Thinking<|end|>"
f"<|start|>assistant to=functions.get_weather<|channel|>{tool_channel}"
'<|constrain|>json<|message|>{"location": '
),
request=chat_request,
finished=False,
)
second_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output('"Paris"}<|call|>'),
request=chat_request,
finished=False,
)
assert first_delta is not None
assert first_delta.reasoning == "Thinking"
assert first_delta.content is None
assert [tool.function.name for tool in tool_call_headers(first_delta)] == [
"get_weather"
]
assert combined_tool_arguments(first_delta) == {0: '{"location": '}
assert {tool.index for tool in first_delta.tool_calls} == {0}
assert second_delta is not None
assert second_delta.reasoning is None
assert second_delta.content is None
assert not tool_call_headers(second_delta)
assert combined_tool_arguments(second_delta) == {0: '"Paris"}'}
assert {tool.index for tool in second_delta.tool_calls} == {0}
def test_commentary_preamble_streaming(self, gpt_oss_tokenizer, chat_request):
parser = HarmonyParser(gpt_oss_tokenizer)
delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>commentary<|message|>I'll search for that"
),
request=chat_request,
finished=False,
)
assert delta is not None
assert delta.content == "I'll search for that"
assert delta.reasoning is None
assert not delta.tool_calls
def test_multiple_choices(self, gpt_oss_tokenizer, chat_request):
parser_a = HarmonyParser(gpt_oss_tokenizer)
parser_b = HarmonyParser(gpt_oss_tokenizer)
delta_a = parser_a.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>analysis<|message|>Check weather<|end|>"
"<|start|>assistant to=functions.get_weather<|channel|>commentary"
'<|constrain|>json<|message|>{"location": "Paris"}'
),
request=chat_request,
finished=False,
)
delta_b = parser_b.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>analysis<|message|>Check time<|end|>"
"<|start|>assistant to=functions.get_time<|channel|>commentary"
'<|constrain|>json<|message|>{"timezone": "UTC"}'
),
request=chat_request,
finished=False,
)
assert [tool.function.name for tool in tool_call_headers(delta_a)] == [
"get_weather"
]
assert [tool.function.name for tool in tool_call_headers(delta_b)] == [
"get_time"
]
assert {tool.index for tool in delta_a.tool_calls} == {0}
assert {tool.index for tool in delta_b.tool_calls} == {0}
def test_dotted_function_name(self, gpt_oss_tokenizer, chat_request):
parser = HarmonyParser(gpt_oss_tokenizer)
delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>analysis<|message|>Compute this<|end|>"
"<|start|>assistant to=math.sum<|channel|>commentary"
'<|constrain|>json<|message|>{"a": 2, "b": 3}'
),
request=chat_request,
finished=False,
)
assert delta is not None
assert [tool.function.name for tool in tool_call_headers(delta)] == ["math.sum"]
assert {tool.index for tool in delta.tool_calls} == {0}
@pytest.mark.parametrize("recipient", ["assistant", "browser"])
def test_builtin_recipient_skipped(
self,
gpt_oss_tokenizer,
chat_request,
recipient,
):
parser = HarmonyParser(gpt_oss_tokenizer)
prompt = [Message.from_role_and_content(Role.USER, "Hello")]
response = [tool_call(recipient, "Ignore this", content_type=None)]
delta = parser.parse_delta(
delta_text="",
delta_token_ids=get_model_output_tokens(prompt, response),
request=chat_request,
finished=False,
)
assert delta is None
def test_cross_channel_with_tool(self, gpt_oss_tokenizer, chat_request):
parser = HarmonyParser(gpt_oss_tokenizer)
delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>analysis<|message|>Reasoning about query...<|end|>"
"<|start|>assistant to=functions.search<|channel|>commentary"
'<|constrain|>json<|message|>{"query": "vllm"}<|call|>'
"<|start|>assistant<|channel|>final<|message|>Done"
),
request=chat_request,
finished=False,
)
assert delta is not None
assert delta.reasoning == "Reasoning about query..."
assert delta.content == "Done"
assert [tool.function.name for tool in tool_call_headers(delta)] == ["search"]
assert combined_tool_arguments(delta) == {0: '{"query": "vllm"}'}
def test_tool_index_across_calls(self, gpt_oss_tokenizer, chat_request):
parser = HarmonyParser(gpt_oss_tokenizer)
first_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>analysis<|message|>Thinking<|end|>"
"<|start|>assistant to=functions.get_weather<|channel|>commentary"
'<|constrain|>json<|message|>{"location": "Paris"}<|call|>'
),
request=chat_request,
finished=False,
)
second_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|start|>assistant to=functions.get_time<|channel|>commentary"
'<|constrain|>json<|message|>{"timezone": "UTC"}<|call|>'
),
request=chat_request,
finished=False,
)
assert [tool.index for tool in tool_call_headers(first_delta)] == [0]
assert [tool.index for tool in tool_call_headers(second_delta)] == [1]
assert [tool.function.name for tool in tool_call_headers(second_delta)] == [
"get_time"
]
def test_multi_tool_interleaved(self, gpt_oss_tokenizer, chat_request):
parser = HarmonyParser(gpt_oss_tokenizer)
first_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"<|channel|>analysis<|message|>Plan<|end|>"
"<|start|>assistant to=functions.tool_a<|channel|>commentary"
'<|constrain|>json<|message|>{"a": 1}<|call|>'
"<|start|>assistant to=functions.tool_b<|channel|>commentary"
'<|constrain|>json<|message|>{"b": '
),
request=chat_request,
finished=False,
)
second_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output("2"),
request=chat_request,
finished=False,
)
third_delta = parser.parse_delta(
delta_text="",
delta_token_ids=encode_output(
"}<|call|><|start|>assistant<|channel|>final<|message|>Done<|end|>"
"<|start|>assistant to=functions.tool_c<|channel|>commentary"
'<|constrain|>json<|message|>{"c": 3}'
),
request=chat_request,
finished=False,
)
assert [tool.index for tool in tool_call_headers(first_delta)] == [0, 1]
assert combined_tool_arguments(first_delta) == {
0: '{"a": 1}',
1: '{"b": ',
}
assert second_delta is not None
assert [tool.index for tool in tool_call_payloads(second_delta)] == [1]
assert combined_tool_arguments(second_delta) == {1: "2"}
assert third_delta is not None
assert third_delta.content == "Done"
assert combined_tool_arguments(third_delta) == {
1: "}",
2: '{"c": 3}',
}
assert [tool.index for tool in tool_call_headers(third_delta)] == [2]
class TestProcessChunk:
def test_empty(self, harmony_parser):
result = harmony_parser.process_chunk([])
assert result.segments == []
assert result.reasoning_token_count == 0
def test_single_channel(self, harmony_parser):
result = harmony_parser.process_chunk(
encode_output("<|channel|>final<|message|>Hello")
)
assert [
(s.channel, s.recipient, s.delta) for s in result.segments if s.delta
] == [("final", None, "Hello")]
def test_cross_channel(self, harmony_parser):
result = harmony_parser.process_chunk(
encode_output(
"<|channel|>analysis<|message|>Think<|end|>"
"<|start|>assistant<|channel|>final<|message|>Answer"
)
)
assert [
(s.channel, s.recipient, s.delta) for s in result.segments if s.delta
] == [
("analysis", None, "Think"),
("final", None, "Answer"),
]
def test_multi_boundary(self, harmony_parser):
result = harmony_parser.process_chunk(
encode_output(
"<|channel|>analysis<|message|>One<|end|>"
"<|start|>assistant<|channel|>final<|message|>Two<|end|>"
)
)
boundary_segments = [
segment
for segment in result.segments
if segment.completed_message is not None
]
assert [
(segment.completed_message.channel, get_text(segment.completed_message))
for segment in boundary_segments
] == [
("analysis", "One"),
("final", "Two"),
]
+22 -4
View File
@@ -2,13 +2,31 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import os
import pytest
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.parser.abstract_parser import DelegatingParser
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
_STRICT_TOOL_CALLING_ENV = "VLLM_ENFORCE_STRICT_TOOL_CALLING"
_STRICT_TOOL_CALLING_ENV_VALUE = os.environ.get(_STRICT_TOOL_CALLING_ENV)
os.environ[_STRICT_TOOL_CALLING_ENV] = "0"
from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E402
ChatCompletionRequest,
)
from vllm.parser.abstract_parser import DelegatingParser # noqa: E402
from vllm.reasoning.basic_parsers import ( # noqa: E402
BaseThinkingReasoningParser,
)
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser # noqa: E402
@pytest.fixture(scope="module", autouse=True)
def restore_strict_tool_calling_env():
yield
if _STRICT_TOOL_CALLING_ENV_VALUE is None:
os.environ.pop(_STRICT_TOOL_CALLING_ENV, None)
else:
os.environ[_STRICT_TOOL_CALLING_ENV] = _STRICT_TOOL_CALLING_ENV_VALUE
class ThinkReasoningParser(BaseThinkingReasoningParser):

Some files were not shown because too many files have changed in this diff Show More