diff --git a/.ci/scripts/export_model_artifact.sh b/.ci/scripts/export_model_artifact.sh index 206b853242e..60b19574aba 100755 --- a/.ci/scripts/export_model_artifact.sh +++ b/.ci/scripts/export_model_artifact.sh @@ -5,16 +5,16 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -# Export model to CUDA/Metal/XNNPACK format with optional quantization +# Export model to CUDA/Metal/XNNPACK/Vulkan format with optional quantization show_help() { cat << EOF Usage: export_model_artifact.sh [quant_name] [output_dir] [mode] -Export a HuggingFace model to CUDA/Metal/XNNPACK format with optional quantization. +Export a HuggingFace model to CUDA/Metal/XNNPACK/Vulkan format with optional quantization. Arguments: - device cuda, metal, or xnnpack (required) + device cuda, metal, xnnpack, or vulkan (required) hf_model HuggingFace model ID (required) Supported models: @@ -26,14 +26,20 @@ Arguments: - nvidia/parakeet-tdt - facebook/dinov2-small-imagenet1k-1-layer - meta-models/Muse-Glimmer-30B-GGUF + - Qwen/Qwen3-0.6B + - unsloth/Llama-3.2-1B-Instruct + - microsoft/Phi-4-mini-instruct quant_name Quantization type (optional, default: non-quantized) Options: - non-quantized - quantized-int4-tile-packed (CUDA only) - - quantized-int4-weight-only (CUDA only) + - quantized-int4-weight-only (CUDA/Vulkan) - quantized-int4-metal (Metal only) - - quantized-8da4w (XNNPACK only) + - quantized-8da4w (XNNPACK/Vulkan) + - quantized-8da4w-fp16 (Vulkan only) + - quantized-8da4w-embedding (Vulkan only) + - quantized-8da4w-fp16-embedding (Vulkan only) - kquant-17gb (Muse Glimmer only) - kquant-dynamic (Muse Glimmer only) @@ -85,6 +91,9 @@ QUANT_NAME="${3:-non-quantized}" OUTPUT_DIR="${4:-.}" MODE="${5:-}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXECUTORCH_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + # Validate mode if specified if [ -n "$MODE" ]; then case "$MODE" in @@ -118,15 +127,18 @@ case "$DEVICE" in ;; metal) ;; - xnnpack) + xnnpack|vulkan) ;; *) echo "Error: Unsupported device '$DEVICE'" - echo "Supported devices: cuda, cuda-windows, metal, xnnpack" + echo "Supported devices: cuda, cuda-windows, metal, xnnpack, vulkan" exit 1 ;; esac +CHECKPOINT_SHA256="" +EXPORT_METADATA="" + # Determine model configuration based on HF model ID case "$HF_MODEL" in mistralai/Voxtral-Mini-3B-2507) @@ -168,6 +180,41 @@ case "$HF_MODEL" in EXTRA_PIP="" PREPROCESSOR_FEATURE_SIZE="" PREPROCESSOR_OUTPUT="" + if [ "$DEVICE" = "vulkan" ]; then + test "$QUANT_NAME" = quantized-8da4w-fp16-embedding + MODEL_NAME=qwen3_0_6b + REVISION=c1899de289a04d12100db370d81485cdf75e47ca + CONVERTER=executorch.examples.models.qwen3.convert_weights + PARAMS="$EXECUTORCH_ROOT/examples/models/qwen3/config/0_6b_config.json" + PROMPT=$'<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\n\n\n\n\n' + EXPECTED_OUTPUT=Paris + fi + ;; + unsloth/Llama-3.2-1B-Instruct) + if [ "$DEVICE" != "vulkan" ]; then + echo "Error: $HF_MODEL is only supported with Vulkan" + exit 1 + fi + MODEL_NAME=llama3_2 + REVISION=5a8abab4a5d6f164389b1079fb721cfab8d7126c + CONVERTER=executorch.examples.models.llama.convert_weights + CHECKPOINT_SHA256=1ff795ff6a07e6a68085d206fb84417da2f083f68391c2843cd2b8ac6df8538f + EXPORT_METADATA='{"append_eos_to_prompt":0,"get_bos_id":128000,"get_eos_ids":[128009,128001]}' + PROMPT=$'<|begin_of_text|><|start_header_id|>user<|end_header_id|>\n\nWhat is the capital of France?<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n' + EXPECTED_OUTPUT="The capital of France is Paris" + ;; + microsoft/Phi-4-mini-instruct) + if [ "$DEVICE" != "vulkan" ]; then + echo "Error: $HF_MODEL is only supported with Vulkan" + exit 1 + fi + test "$QUANT_NAME" = quantized-8da4w-embedding + MODEL_NAME=phi_4_mini + REVISION=cfbefacb99257ffa30c83adab238a50856ac3083 + CONVERTER=executorch.examples.models.phi_4_mini.convert_weights + PARAMS="$EXECUTORCH_ROOT/examples/models/phi_4_mini/config/config.json" + PROMPT=$'<|user|>\nWhat is the capital of France?<|end|>\n<|assistant|>\n' + EXPECTED_OUTPUT="The capital of France is Paris" ;; nvidia/parakeet-tdt) MODEL_NAME="parakeet" @@ -232,6 +279,10 @@ case "$HF_MODEL" in ;; esac +QMODE=8da4w +FORCE_FP16=false +EMBEDDING_QUANTIZE="" + # Determine quantization args based on quant name case "$QUANT_NAME" in non-quantized) @@ -250,6 +301,7 @@ case "$QUANT_NAME" in exit 1 fi EXTRA_ARGS="--qlinear_encoder 4w" + QMODE=4w ;; quantized-int4-metal) if [ "$DEVICE" != "metal" ]; then @@ -259,12 +311,25 @@ case "$QUANT_NAME" in EXTRA_ARGS="--qlinear fpa4w --qlinear_encoder fpa4w" ;; quantized-8da4w) - if [ "$DEVICE" != "xnnpack" ]; then - echo "Error: quantized-8da4w is only supported with xnnpack device" + if [ "$DEVICE" != "xnnpack" ] && [ "$DEVICE" != "vulkan" ]; then + echo "Error: quantized-8da4w is only supported with xnnpack or vulkan device" exit 1 fi EXTRA_ARGS="--qlinear 8da4w --qlinear_group_size 32 --qlinear_encoder 8da4w --qlinear_encoder_group_size 32" ;; + quantized-8da4w-fp16|quantized-8da4w-embedding|quantized-8da4w-fp16-embedding) + if [ "$DEVICE" != "vulkan" ]; then + echo "Error: Quantization '$QUANT_NAME' is only supported with Vulkan" + exit 1 + fi + case "$QUANT_NAME" in + *-fp16*) FORCE_FP16=true ;; + esac + case "$QUANT_NAME" in + *-embedding) EMBEDDING_QUANTIZE=4,64 ;; + esac + EXTRA_ARGS="" + ;; kquant-17gb|kquant-dynamic) if [ "$HF_MODEL" != "meta-models/Muse-Glimmer-30B-GGUF" ]; then echo "Error: Quantization '$QUANT_NAME' can only be used with Muse Glimmer model" @@ -295,6 +360,82 @@ if [ "$MODEL_NAME" = "muse_glimmer" ]; then fi fi +if [ "$DEVICE" = "vulkan" ]; then + case "$MODEL_NAME" in + qwen3_0_6b|llama3_2|phi_4_mini) ;; + *) echo "Error: Unsupported Vulkan model: $HF_MODEL"; exit 1 ;; + esac + case "$QUANT_NAME" in + quantized-8da4w|quantized-8da4w-fp16|quantized-8da4w-embedding|quantized-8da4w-fp16-embedding|quantized-int4-weight-only) ;; + *) echo "Error: Unsupported Vulkan quantization: $QUANT_NAME"; exit 1 ;; + esac + mkdir -p "$OUTPUT_DIR" + OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" + ( + cd "$EXECUTORCH_ROOT" + MAX_SIZE_MIB="" + if [ "$MODEL_NAME" = "llama3_2" ] && [ -n "$EMBEDDING_QUANTIZE" ]; then MAX_SIZE_MIB=770; fi + HASH_ARGS=(--metadata-path "$OUTPUT_DIR/metadata.json") + if [ -n "${CHECKPOINT_SHA256:-}" ]; then HASH_ARGS+=(--expected-sha256 "$CHECKPOINT_SHA256"); fi + python - "$OUTPUT_DIR" "$HF_MODEL" "$REVISION" "$QUANT_NAME" "$QMODE" "$FORCE_FP16" \ + "$EMBEDDING_QUANTIZE" "$MAX_SIZE_MIB" "$PROMPT" "$EXPECTED_OUTPUT" <<'PY' +import json +import sys +from pathlib import Path + +output, model, revision, variant, qmode, fp16, embedding, size, prompt, expected = sys.argv[1:] +metadata = { + "model": model, "revision": revision, "variant": variant, "quantization": qmode, + "group_size": 64, "embedding_quantize": embedding, "dtype": "fp32", + "vulkan_force_fp16": fp16 == "true", "max_seq_length": 2048, "max_context_length": 2048, + "use_kv_cache": True, "use_sdpa_with_kv_cache": True, "max_size_mib": int(size) if size else None, + "prompt": prompt, "expected": expected, "temperature": 0, "max_new_tokens": 50, +} +if model == "unsloth/Llama-3.2-1B-Instruct": + metadata["original_model"] = "meta-llama/Llama-3.2-1B-Instruct" +output = Path(output) +(output / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n") +(output / "prompt.txt").write_text(prompt) +(output / "expected_output.txt").write_text(expected + "\n") +PY + SNAPSHOT=$(python -m executorch.examples.models.llama.hf_download snapshot "$HF_MODEL" \ + --revision "$REVISION" "${HASH_ARGS[@]}" \ + 2> "$OUTPUT_DIR/download.log") + cp "$SNAPSHOT/tokenizer.json" "$SNAPSHOT/tokenizer_config.json" "$OUTPUT_DIR/" + VULKAN_CHECKPOINT_DIR=$(mktemp -d "${RUNNER_TEMP:-/tmp}/vulkan_llm_XXXXXX") + trap 'rm -rf "$VULKAN_CHECKPOINT_DIR"' EXIT + CONVERT_ARGS=("$SNAPSHOT" "$VULKAN_CHECKPOINT_DIR/checkpoint.pth") + if [ "$MODEL_NAME" = "llama3_2" ]; then + PARAMS="$OUTPUT_DIR/params.json" + CONVERT_ARGS=(hf-to-meta "${CONVERT_ARGS[@]}" --params-output "$PARAMS") + fi + python -m "$CONVERTER" "${CONVERT_ARGS[@]}" > "$OUTPUT_DIR/convert.log" 2>&1 + VULKAN_EXPORT_ARGS=( + "base.model_class=$MODEL_NAME" "base.checkpoint=\"$VULKAN_CHECKPOINT_DIR/checkpoint.pth\"" + "base.params=\"$PARAMS\"" "base.tokenizer_path=\"$OUTPUT_DIR/tokenizer.json\"" + model.dtype_override=fp32 model.use_kv_cache=true model.use_sdpa_with_kv_cache=true + model.enable_dynamic_shape=true backend.vulkan.enabled=true "backend.vulkan.force_fp16=$FORCE_FP16" + "quantization.qmode=$QMODE" quantization.group_size=64 + export.max_seq_length=2048 export.max_context_length=2048 + "export.output_dir=\"$OUTPUT_DIR\"" "export.output_name=\"$OUTPUT_DIR/model.pte\"" debug.verbose=true + ) + if [ -n "$EMBEDDING_QUANTIZE" ]; then + VULKAN_EXPORT_ARGS+=("quantization.embedding_quantize=\"$EMBEDDING_QUANTIZE\"") + fi + if [ -n "${EXPORT_METADATA:-}" ]; then VULKAN_EXPORT_ARGS+=("base.metadata='$EXPORT_METADATA'"); fi + rm -f "$OUTPUT_DIR/model.pte" + python -m extension.llm.export.export_llm "${VULKAN_EXPORT_ARGS[@]}" > "$OUTPUT_DIR/export.log" 2>&1 + test -f "$OUTPUT_DIR/model.pte" + python - "$OUTPUT_DIR/model.pte" "$QMODE" "$EMBEDDING_QUANTIZE" "$MAX_SIZE_MIB" <<'PY' +import sys +from executorch.backends.vulkan.test.utils import verify_vulkan_llm_pte + +verify_vulkan_llm_pte(sys.argv[1], sys.argv[2], bool(sys.argv[3]), int(sys.argv[4]) if sys.argv[4] else None) +PY + ) + exit 0 +fi + # Downloads and compiler caches go in scratch dirs outside OUTPUT_DIR because the CI job # templates upload OUTPUT_DIR even when the job fails. A failed export also empties # OUTPUT_DIR, but only if it started out empty, so a local run with output_dir=. cannot diff --git a/.ci/scripts/test_model_e2e.sh b/.ci/scripts/test_model_e2e.sh index 97689231877..f3d98997f44 100755 --- a/.ci/scripts/test_model_e2e.sh +++ b/.ci/scripts/test_model_e2e.sh @@ -5,16 +5,16 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -# Test CUDA/Metal/XNNPACK models end-to-end, optionally exporting artifacts first. +# Test CUDA/Metal/XNNPACK/Vulkan models end-to-end, optionally exporting artifacts first. show_help() { cat << EOF Usage: test_model_e2e.sh [model_dir] [mode] -Build and run end-to-end tests for CUDA/Metal/XNNPACK models. +Build and run end-to-end tests for CUDA/Metal/XNNPACK/Vulkan models. Arguments: - device cuda, metal, or xnnpack (required) + device cuda, metal, xnnpack, or vulkan (required) hf_model HuggingFace model ID (required) Supported models: @@ -27,13 +27,18 @@ Arguments: - facebook/dinov2-small-imagenet1k-1-layer - mistralai/Voxtral-Mini-4B-Realtime-2602 - meta-models/Muse-Glimmer-30B-GGUF + - unsloth/Llama-3.2-1B-Instruct (Vulkan only) + - microsoft/Phi-4-mini-instruct (Vulkan only) quant_name Quantization type (required) Options: - non-quantized - quantized-int4-tile-packed - quantized-int4-weight-only - - quantized-8da4w (XNNPACK only) + - quantized-8da4w (XNNPACK/Vulkan) + - quantized-8da4w-fp16 (Vulkan only) + - quantized-8da4w-embedding (Vulkan only) + - quantized-8da4w-fp16-embedding (Vulkan only) - kquant-17gb (Muse Glimmer only) - kquant-dynamic (Muse Glimmer only) @@ -51,6 +56,7 @@ Arguments: Environment: RUN_EXPORT Set to 1 to export artifacts with export_model_artifact.sh before testing + EXECUTORCH_E2E_RUNNER Executable runner override; skips requirement setup and build Examples: test_model_e2e.sh metal "openai/whisper-small" "non-quantized" @@ -94,6 +100,14 @@ MODE="${5:-}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" EXECUTORCH_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" RUN_EXPORT="${RUN_EXPORT:-0}" +PREBUILT_RUNNER="${EXECUTORCH_E2E_RUNNER:-}" +if [ -n "$PREBUILT_RUNNER" ]; then + if [ ! -x "$PREBUILT_RUNNER" ]; then + echo "Error: EXECUTORCH_E2E_RUNNER is not executable: $PREBUILT_RUNNER" + exit 1 + fi + PREBUILT_RUNNER="$(cd "$(dirname "$PREBUILT_RUNNER")" && pwd)/$(basename "$PREBUILT_RUNNER")" +fi if [ "$RUN_EXPORT" = "1" ]; then mkdir -p "$MODEL_DIR" fi @@ -191,17 +205,33 @@ case "$HF_MODEL" in AUDIO_FILE="" IMAGE_PATH="docs/source/_static/img/et-logo.png" ;; - Qwen/Qwen3-0.6B) + Qwen/Qwen3-0.6B|unsloth/Llama-3.2-1B-Instruct|microsoft/Phi-4-mini-instruct) MODEL_NAME="qwen3" RUNNER_TARGET="llama_main" RUNNER_PATH="llama" EXPECTED_OUTPUT="Paris" PREPROCESSOR="" - TOKENIZER_URL="https://huggingface.co/Qwen/Qwen3-0.6B/resolve/main" # @lint-ignore + TOKENIZER_URL="https://huggingface.co/${HF_MODEL}/resolve/main" # @lint-ignore TOKENIZER_FILE="" AUDIO_URL="" AUDIO_FILE="" IMAGE_PATH="" + if [ "$DEVICE" = "vulkan" ]; then + MODEL_NAME="vulkan_llm" + TOKENIZER_FILE="tokenizer.json" + RUNNER_VARIANTS="" + IMAGE_URL="" + EXPECTED_OUTPUT="$(cat "$MODEL_DIR/expected_output.txt")" + if ! grep -q '[^[:space:]]' "$MODEL_DIR/expected_output.txt"; then + echo "Error: expected_output.txt must contain an expected answer" + exit 1 + fi + test -f "$MODEL_DIR/prompt.txt" + test -f "$MODEL_DIR/tokenizer.json" + elif [ "$HF_MODEL" != "Qwen/Qwen3-0.6B" ]; then + echo "Error: $HF_MODEL is only supported with Vulkan" + exit 1 + fi ;; nvidia/parakeet-tdt) MODEL_NAME="parakeet" @@ -309,16 +339,23 @@ case "$HF_MODEL" in ;; esac -echo "::group::Setup ExecuTorch Requirements" -./install_requirements.sh -pip list -echo "::endgroup::" +if [ "$DEVICE" = "vulkan" ] && [ "$MODEL_NAME" != "vulkan_llm" ]; then + echo "Error: Unsupported Vulkan model: $HF_MODEL" + exit 1 +fi + +if [ -z "$PREBUILT_RUNNER" ]; then + echo "::group::Setup ExecuTorch Requirements" + ./install_requirements.sh + pip list + echo "::endgroup::" +fi echo "::group::Prepare $MODEL_NAME Artifacts" # Download tokenizer files (skip for models that bundle tokenizer in export or do not use one) -if [ "$MODEL_NAME" != "parakeet" ] && [ "$MODEL_NAME" != "voxtral_realtime" ] && [ "$MODEL_NAME" != "sortformer" ] && [ "$MODEL_NAME" != "dinov2" ] && [ "$MODEL_NAME" != "qwen3_5_moe" ] && [ "$MODEL_NAME" != "gemma4_31b" ]; then +if [ "$MODEL_NAME" != "vulkan_llm" ] && [ "$MODEL_NAME" != "parakeet" ] && [ "$MODEL_NAME" != "voxtral_realtime" ] && [ "$MODEL_NAME" != "sortformer" ] && [ "$MODEL_NAME" != "dinov2" ] && [ "$MODEL_NAME" != "qwen3_5_moe" ] && [ "$MODEL_NAME" != "gemma4_31b" ]; then if [ "$TOKENIZER_FILE" != "" ]; then curl -L --retry 3 --retry-all-errors $TOKENIZER_URL/$TOKENIZER_FILE -o $MODEL_DIR/$TOKENIZER_FILE else @@ -360,8 +397,8 @@ echo "::endgroup::" echo "::group::Build $MODEL_NAME Runner" -if [ "$DEVICE" != "cuda" ] && [ "$DEVICE" != "metal" ] && [ "$DEVICE" != "xnnpack" ]; then - echo "Error: Unsupported device '$DEVICE'. Must be 'cuda', 'metal', or 'xnnpack'." +if [ "$DEVICE" != "cuda" ] && [ "$DEVICE" != "metal" ] && [ "$DEVICE" != "xnnpack" ] && [ "$DEVICE" != "vulkan" ]; then + echo "Error: Unsupported device '$DEVICE'. Must be 'cuda', 'metal', 'xnnpack', or 'vulkan'." exit 1 fi @@ -371,7 +408,9 @@ if [ "$DEVICE" = "xnnpack" ]; then else MAKE_TARGET="${RUNNER_PATH}-${DEVICE}" fi -make "${MAKE_TARGET}" +if [ -z "$PREBUILT_RUNNER" ]; then + make "${MAKE_TARGET}" +fi echo "::endgroup::" echo "::group::Run $MODEL_NAME Runner" @@ -381,7 +420,7 @@ if [ "$DEVICE" = "cuda" ]; then fi # Build runner command with common arguments -RUNNER_BIN="cmake-out/examples/models/$RUNNER_PATH/$RUNNER_TARGET" +RUNNER_BIN="${PREBUILT_RUNNER:-cmake-out/examples/models/$RUNNER_PATH/$RUNNER_TARGET}" RUNNER_ARGS="--model_path ${MODEL_DIR}/model.pte --temperature 0" # Patch absolute libomp install name from some torch nightlies to rpath-based # lookup so the runner works on macOS images without /opt/llvm-openmp. @@ -491,6 +530,21 @@ run_and_validate() { echo "--- Runner pass: $VARIANT_ARGS ---" fi + if [ "$MODEL_NAME" = "vulkan_llm" ]; then + "$RUNNER_BIN" --model_path "$MODEL_DIR/model.pte" \ + --tokenizer_path "$MODEL_DIR/tokenizer.json" --prompt_file "$MODEL_DIR/prompt.txt" \ + --temperature 0 --max_new_tokens 50 \ + > "$MODEL_DIR/runner.stdout" 2> "$MODEL_DIR/runner.stderr" || exit $? + set -e + cat "$MODEL_DIR/runner.stdout" + if ! grep -Fiw -- "$EXPECTED_OUTPUT" "$MODEL_DIR/runner.stdout" > /dev/null; then + echo "Expected output '$EXPECTED_OUTPUT' not found in output" + exit 1 + fi + cp "$MODEL_DIR/runner.stdout" "$MODEL_DIR/response.txt" + return + fi + set +e OUTPUT=$(eval $RUNNER_BIN $RUNNER_ARGS $VARIANT_ARGS 2>&1) EXIT_CODE=$? diff --git a/.ci/scripts/tests/vulkan/test_export_vulkan_llm.py b/.ci/scripts/tests/vulkan/test_export_vulkan_llm.py new file mode 100644 index 00000000000..5aadf2572b0 --- /dev/null +++ b/.ci/scripts/tests/vulkan/test_export_vulkan_llm.py @@ -0,0 +1,330 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import hashlib +import importlib +import importlib.util +import json +import os +import runpy +import shlex +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[4] + + +def load_existing_module(relative_path): + path = ROOT / relative_path + spec = importlib.util.spec_from_file_location("existing_export_utils", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_ci_download_cache_is_writable_with_read_only_runner_cache(tmp_path): + workflow = yaml.safe_load((ROOT / ".github/workflows/vulkan.yml").read_text()) + script = workflow["jobs"]["test-model-vulkan-e2e"]["with"]["script"] + setup, _ = script.split("source .ci/scripts/setup-vulkan-linux-deps.sh", 1) + shared_cache = tmp_path / "shared-cache" + shared_hub = shared_cache / "hub" + shared_hub.mkdir(parents=True) + shared_hub.chmod(0o555) + shared_cache.chmod(0o555) + scratch = tmp_path / "scratch" + scratch.mkdir() + probe = """ +import json +from pathlib import Path +from huggingface_hub.constants import HF_HUB_CACHE +from huggingface_hub.utils._fixes import WeakFileLock + +cache = Path(HF_HUB_CACHE) +lock = cache / ".locks" / "test.lock" +lock.parent.mkdir(parents=True, exist_ok=True) +with WeakFileLock(str(lock)): + (cache / "download-probe").write_text("download") +print(json.dumps({"cache": str(cache), "wrote": True})) +""" + try: + result = subprocess.run( + [ + "bash", + "-c", + setup + f"\n{shlex.quote(sys.executable)} - <<'PY'\n{probe}\nPY", + ], + env={ + **os.environ, + "RUNNER_TEMP": str(scratch), + "HF_HOME": str(shared_cache), + "HF_HUB_CACHE": str(shared_hub), + }, + capture_output=True, + text=True, + ) + finally: + shared_cache.chmod(0o755) + shared_hub.chmod(0o755) + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout) == { + "cache": str(scratch / "hf_cache" / "hub"), + "wrote": True, + } + assert not (shared_hub / "download-probe").exists() + + +def test_existing_hf_download_accepts_pinned_revision_and_propagates_failure( + monkeypatch, +): + module = load_existing_module("examples/models/llama/hf_download.py") + + def failed_download(**kwargs): + assert kwargs["repo_id"] == "test/model" + assert kwargs["revision"] == "r" * 40 + assert kwargs["token"] is False + raise RuntimeError("download failed") + + monkeypatch.setattr(module, "snapshot_download", failed_download) + with pytest.raises(RuntimeError, match="download failed"): + module.download_hf_snapshot("test/model", "r" * 40) + + +def test_existing_hf_download_rejects_corrupt_weights_and_records_hash( + tmp_path, monkeypatch +): + module = load_existing_module("examples/models/llama/hf_download.py") + corrupt = b"corrupt weights" + (tmp_path / "model.safetensors").write_bytes(corrupt) + metadata = tmp_path / "metadata.json" + metadata.write_text('{"model": "test/model"}') + monkeypatch.setattr(module, "snapshot_download", lambda **kwargs: str(tmp_path)) + with pytest.raises(ValueError, match="SHA256"): + module.download_hf_snapshot( + "test/model", + "r" * 40, + expected_sha256=hashlib.sha256(b"original weights").hexdigest(), + metadata_path=metadata, + ) + assert json.loads(metadata.read_text())["observed_checkpoint_sha256"] == ( + hashlib.sha256(corrupt).hexdigest() + ) + + +def test_existing_hf_download_accepts_matching_hash(tmp_path, monkeypatch): + module = load_existing_module("examples/models/llama/hf_download.py") + weights = b"original weights" + (tmp_path / "model.safetensors").write_bytes(weights) + monkeypatch.setattr(module, "snapshot_download", lambda **kwargs: str(tmp_path)) + assert module.download_hf_snapshot( + "test/model", "r" * 40, expected_sha256=hashlib.sha256(weights).hexdigest() + ) == str(tmp_path) + + +def test_snapshot_cli_exposes_pinned_download_and_checksum( + tmp_path, monkeypatch, capsys +): + hub = importlib.import_module("huggingface_hub") + weights = b"checkpoint" + (tmp_path / "model.safetensors").write_bytes(weights) + metadata = tmp_path / "metadata.json" + checksum = hashlib.sha256(weights).hexdigest() + monkeypatch.setattr(hub, "snapshot_download", lambda **kwargs: str(tmp_path)) + monkeypatch.setattr( + sys, + "argv", + [ + "hf_download", + "snapshot", + "test/model", + "--revision", + "r" * 40, + "--expected-sha256", + checksum, + "--metadata-path", + str(metadata), + ], + ) + runpy.run_module( + "executorch.examples.models.llama.hf_download", run_name="__main__" + ) + assert capsys.readouterr().out.strip() == str(tmp_path) + assert json.loads(metadata.read_text())["observed_checkpoint_sha256"] == checksum + + +def test_download_and_convert_cli_exposes_converter_callback( + tmp_path, monkeypatch, capsys +): + import torch + from safetensors.torch import save_file + + hub = importlib.import_module("huggingface_hub") + snapshot = tmp_path / "snapshot" + snapshot.mkdir() + (snapshot / "config.json").write_text( + json.dumps( + { + "hidden_size": 8, + "num_attention_heads": 2, + "num_key_value_heads": 1, + "tie_word_embeddings": True, + } + ) + ) + embeddings = torch.arange(80, dtype=torch.float32).view(10, 8) + save_file({"model.embed_tokens.weight": embeddings}, snapshot / "model.safetensors") + cache = tmp_path / "cache" + monkeypatch.setenv("HF_HOME", str(cache)) + monkeypatch.setattr(hub, "snapshot_download", lambda **kwargs: str(snapshot)) + monkeypatch.setattr( + sys, + "argv", + [ + "hf_download", + "download-and-convert", + "test/model", + "--converter", + "executorch.examples.models.llama.convert_weights:convert_hf_to_meta", + ], + ) + runpy.run_module( + "executorch.examples.models.llama.hf_download", run_name="__main__" + ) + output = cache / ".cache/meta_checkpoints/test_model.pth" + assert capsys.readouterr().out.strip() == str(output) + torch.testing.assert_close( + torch.load(output, weights_only=True)["tok_embeddings.weight"], embeddings + ) + + def unexpected_download(**kwargs): + raise AssertionError("cached conversion must not redownload") + + monkeypatch.setattr(hub, "snapshot_download", unexpected_download) + runpy.run_module( + "executorch.examples.models.llama.hf_download", run_name="__main__" + ) + assert capsys.readouterr().out.strip() == str(output) + + +def test_vulkan_artifact_inspection_does_not_import_runtime_bindings(monkeypatch): + monkeypatch.setitem( + sys.modules, "executorch.extension.pybindings.portable_lib", None + ) + module = load_existing_module("backends/vulkan/test/utils.py") + assert callable(module.verify_vulkan_llm_pte) + + +@pytest.fixture +def artifact_inspector(tmp_path, monkeypatch): + module = load_existing_module("backends/vulkan/test/utils.py") + schema = importlib.import_module("executorch.exir.schema") + deserializer = importlib.import_module("executorch.exir._serialize._program") + serializer = importlib.import_module( + "executorch.backends.vulkan.serialization.vulkan_graph_serialize" + ) + forward = SimpleNamespace( + name="forward", + delegates=[ + SimpleNamespace(id="VulkanBackend", processed=SimpleNamespace(index=0)) + ], + chains=[ + SimpleNamespace( + instructions=[SimpleNamespace(instr_args=schema.DelegateCall(0, []))] + ) + ], + ) + program = SimpleNamespace( + execution_plan=[forward], + backend_delegate_data=[SimpleNamespace(data=b"vulkan")], + ) + operators = [ + SimpleNamespace(name="et_vk.linear_dq8ca_q4gsw.default"), + SimpleNamespace(name="et_vk.embedding_q4gsw.default"), + SimpleNamespace(name="sdpa_with_kv_cache.default"), + ] + monkeypatch.setattr( + deserializer, + "deserialize_pte_binary", + lambda _: SimpleNamespace(program=program), + ) + monkeypatch.setattr(serializer, "extract_vk_flatbuffer", lambda data: data) + monkeypatch.setattr( + serializer, "flatbuffer_to_vk_graph", lambda _: SimpleNamespace(chain=operators) + ) + model = tmp_path / "model.pte" + model.write_bytes(b"model") + return module, model, program, operators + + +def test_required_called_vulkan_operators_pass_and_record_counts(artifact_inspector): + module, model, _, _ = artifact_inspector + module.verify_vulkan_llm_pte(model, quantized_embedding=True) + assert json.loads((model.parent / "delegation.json").read_text()) == { + "forward_delegates": ["VulkanBackend"] + } + assert ( + json.loads((model.parent / "vulkan_operators.json").read_text())[ + "sdpa_with_kv_cache.default" + ] + == 1 + ) + + +@pytest.mark.parametrize("failure", ["cpu_only", "unused", "metadata_only"]) +def test_nonexecuted_vulkan_delegate_cannot_pass(artifact_inspector, failure): + module, model, program, _ = artifact_inspector + forward = program.execution_plan[0] + if failure == "unused": + forward.chains.clear() + else: + forward.delegates[0].id = "XnnpackBackend" + if failure == "metadata_only": + program.execution_plan.insert( + 0, + SimpleNamespace( + name="get_vocab_size", delegates=[SimpleNamespace(id="VulkanBackend")] + ), + ) + with pytest.raises(ValueError, match="called VulkanBackend"): + module.verify_vulkan_llm_pte(model) + + +@pytest.mark.parametrize( + "operator", + [ + "et_vk.linear_dq8ca_q4gsw.default", + "et_vk.embedding_q4gsw.default", + "sdpa_with_kv_cache.default", + ], +) +def test_partial_lowering_missing_required_operator_fails(artifact_inspector, operator): + module, model, _, operators = artifact_inspector + operators[:] = [op for op in operators if op.name != operator] + with pytest.raises(ValueError, match="Missing Vulkan operator family"): + module.verify_vulkan_llm_pte(model, quantized_embedding=True) + + +def test_weight_only_case_does_not_require_dynamic_quantization_or_embedding( + artifact_inspector, +): + module, model, _, operators = artifact_inspector + operators[:] = [ + SimpleNamespace(name="et_vk.linear_q4gsw.default"), + SimpleNamespace(name="sdpa_with_kv_cache.default"), + ] + module.verify_vulkan_llm_pte(model, qmode="4w") + + +def test_model_size_limit_is_enforced_before_deserialization(artifact_inspector): + module, model, _, _ = artifact_inspector + with model.open("wb") as output: + output.truncate(1024 * 1024 + 1) + with pytest.raises(ValueError, match="1 MiB"): + module.verify_vulkan_llm_pte(model, max_size_mib=1) diff --git a/.ci/scripts/tests/vulkan/test_vulkan_cmake_package.sh b/.ci/scripts/tests/vulkan/test_vulkan_cmake_package.sh new file mode 100755 index 00000000000..ca702175587 --- /dev/null +++ b/.ci/scripts/tests/vulkan/test_vulkan_cmake_package.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +set -euo pipefail + +if ! command -v cmake > /dev/null; then + printf 'SKIP: CMake is unavailable\n' + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/vulkan_cmake_tests.XXXXXX")" +CASE_NAME="" +PASSED=0 + +cleanup() { + local status=$? + set +e + if [ "$status" -ne 0 ]; then + printf 'FAIL: %s\n' "$CASE_NAME" >&2 + cat "$TEST_TMP/cmake.log" >&2 + fi + rm -rf "$TEST_TMP" + exit "$status" +} +trap cleanup EXIT + +mkdir -p "$TEST_TMP/producer" "$TEST_TMP/consumer" +printf 'void unused() {}\n' > "$TEST_TMP/producer/empty.cpp" +printf 'int main() { return 0; }\n' > "$TEST_TMP/consumer/main.cpp" + +cat > "$TEST_TMP/producer/CMakeLists.txt" <<'CMAKE' +cmake_minimum_required(VERSION 3.24) +project(producer LANGUAGES CXX) +set(targets executorch executorch_core portable_kernels) +if(TEST_WITH_VULKAN) + list(APPEND targets vulkan_backend) +endif() +foreach(target IN LISTS targets) + add_library(${target} STATIC empty.cpp) +endforeach() +if(TEST_WITH_BOOST) + add_library(Boost::stacktrace_addr2line INTERFACE IMPORTED) + target_link_libraries(vulkan_backend PRIVATE Boost::stacktrace_addr2line) +endif() +install(TARGETS ${targets} EXPORT ExecuTorchTargets ARCHIVE DESTINATION lib) +install(EXPORT ExecuTorchTargets DESTINATION lib/cmake/ExecuTorch) +install(FILES "${EXECUTORCH_CONFIG}" DESTINATION lib/cmake/ExecuTorch) +CMAKE + +cat > "$TEST_TMP/consumer/FindBoost.cmake" <<'CMAKE' +if(NOT Boost_FIND_VERSION STREQUAL "1.89") + message(FATAL_ERROR "Unexpected Boost version: ${Boost_FIND_VERSION}") +endif() +if(NOT Boost_FIND_COMPONENTS STREQUAL "stacktrace_basic;stacktrace_addr2line") + message(FATAL_ERROR "Unexpected Boost components: ${Boost_FIND_COMPONENTS}") +endif() +set(Boost_FOUND TRUE) +foreach(component IN LISTS Boost_FIND_COMPONENTS) + add_library(Boost::${component} INTERFACE IMPORTED) +endforeach() +file(WRITE "${CMAKE_BINARY_DIR}/boost-discovered" "found") +CMAKE + +cat > "$TEST_TMP/consumer/CMakeLists.txt" <<'CMAKE' +cmake_minimum_required(VERSION 3.24) +project(consumer LANGUAGES CXX) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}") +find_package(executorch CONFIG REQUIRED PATHS "${TEST_INSTALL}" NO_DEFAULT_PATH) +add_executable(consumer main.cpp) +target_link_libraries(consumer PRIVATE executorch executorch_core portable_kernels) +if(TEST_WITH_VULKAN) + target_link_libraries(consumer PRIVATE vulkan_backend) +endif() +CMAKE + +while read -r CASE_NAME with_vulkan with_boost; do + case_dir="$TEST_TMP/$CASE_NAME" + disable_boost=ON + if [ "$with_boost" = ON ]; then disable_boost=OFF; fi + + cmake -S "$TEST_TMP/producer" -B "$case_dir/producer-build" \ + "-DCMAKE_INSTALL_PREFIX=$case_dir/install" \ + "-DEXECUTORCH_CONFIG=$ROOT/tools/cmake/executorch-config.cmake" \ + "-DTEST_WITH_VULKAN=$with_vulkan" "-DTEST_WITH_BOOST=$with_boost" \ + > "$TEST_TMP/cmake.log" 2>&1 + cmake --build "$case_dir/producer-build" --target install --parallel 2 \ + >> "$TEST_TMP/cmake.log" 2>&1 + cmake -S "$TEST_TMP/consumer" -B "$case_dir/consumer-build" \ + "-DTEST_INSTALL=$case_dir/install" "-DTEST_WITH_VULKAN=$with_vulkan" \ + "-DCMAKE_DISABLE_FIND_PACKAGE_Boost=$disable_boost" \ + >> "$TEST_TMP/cmake.log" 2>&1 + + if [ "$with_boost" = ON ]; then + test -f "$case_dir/consumer-build/boost-discovered" + else + test ! -f "$case_dir/consumer-build/boost-discovered" + fi + cmake --build "$case_dir/consumer-build" --parallel 2 \ + >> "$TEST_TMP/cmake.log" 2>&1 + printf 'PASS: %s\n' "$CASE_NAME" + PASSED=$((PASSED + 1)) +done <<'CASES' +vulkan_with_boost ON ON +vulkan_without_boost ON OFF +without_vulkan OFF OFF +CASES + +test "$PASSED" -eq 3 +printf '%s passed, 0 failed\n' "$PASSED" diff --git a/.ci/scripts/unittest-linux-cmake.sh b/.ci/scripts/unittest-linux-cmake.sh index 83f2f1464ee..970a20e3a44 100755 --- a/.ci/scripts/unittest-linux-cmake.sh +++ b/.ci/scripts/unittest-linux-cmake.sh @@ -29,6 +29,8 @@ if ! python -c "import tosa_serializer" >/dev/null 2>&1; then python -c "import tosa_serializer" fi +bash .ci/scripts/tests/vulkan/test_vulkan_cmake_package.sh + # Run pytest with coverage pytest -n auto --cov=./ --cov-report=xml # Run gtest diff --git a/.ci/scripts/unittest-macos-cmake.sh b/.ci/scripts/unittest-macos-cmake.sh index 48f072a0cc1..bf6562a6aab 100755 --- a/.ci/scripts/unittest-macos-cmake.sh +++ b/.ci/scripts/unittest-macos-cmake.sh @@ -24,6 +24,8 @@ AOTI_SKIPS=( --deselect=extension/llm/modules/test/test_attention.py::AttentionTest::test_attention_aoti ) +${CONDA_RUN} bash .ci/scripts/tests/vulkan/test_vulkan_cmake_package.sh + ${CONDA_RUN} pytest -n auto --cov=./ --cov-report=xml "${AOTI_SKIPS[@]}" # Run gtest LLVM_PROFDATA="xcrun llvm-profdata" LLVM_COV="xcrun llvm-cov" \ diff --git a/.github/workflows/vulkan.yml b/.github/workflows/vulkan.yml index 9397707d979..1fce832be6a 100644 --- a/.github/workflows/vulkan.yml +++ b/.github/workflows/vulkan.yml @@ -17,10 +17,31 @@ on: - .github/workflows/vulkan.yml - backends/vulkan/** - examples/vulkan/** + - examples/models/llama/** + - examples/models/qwen3/** + - examples/models/phi_4_mini/** + - extension/llm/** + - extension/pybindings/** + - backends/transforms/** + - exir/** + - runtime/** + - schema/** + - install_requirements.py + - requirements-examples.txt + - setup.py + - CMakeLists.txt + - CMakePresets.json + - tools/cmake/** + - Makefile + - .ci/scripts/export_model_artifact.sh + - .ci/scripts/test_model_e2e.sh + - .ci/scripts/tests/vulkan/** - .ci/scripts/setup-vulkan-linux-deps.sh - .ci/scripts/setup-vulkan-windows-deps.ps1 - .ci/scripts/setup-windows-msvc-vulkan.ps1 workflow_dispatch: + schedule: + - cron: '30 2 * * *' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}-${{ github.ref_type == 'branch' && github.sha }}-${{ github.event_name == 'workflow_dispatch' }} @@ -116,6 +137,109 @@ jobs: python -m unittest backends/vulkan/test/test_vulkan_delegate.py -k "*pt2e*" python -m unittest backends/vulkan/test/test_vulkan_delegate.py -k "*torchao*" + test-model-vulkan-e2e: + needs: [changed-files, run-decision] + if: | + github.event_name == 'schedule' || + contains(needs.changed-files.outputs.changed-files, 'backends/vulkan/') || + contains(needs.changed-files.outputs.changed-files, 'examples/models/llama/') || + contains(needs.changed-files.outputs.changed-files, 'examples/models/qwen3/') || + contains(needs.changed-files.outputs.changed-files, 'examples/models/phi_4_mini/') || + contains(needs.changed-files.outputs.changed-files, 'extension/llm/') || + contains(needs.changed-files.outputs.changed-files, 'extension/pybindings/') || + contains(needs.changed-files.outputs.changed-files, 'backends/transforms/') || + contains(needs.changed-files.outputs.changed-files, 'exir/') || + contains(needs.changed-files.outputs.changed-files, 'runtime/') || + contains(needs.changed-files.outputs.changed-files, 'schema/') || + contains(needs.changed-files.outputs.changed-files, 'install_requirements.py') || + contains(needs.changed-files.outputs.changed-files, 'requirements-examples.txt') || + contains(needs.changed-files.outputs.changed-files, 'setup.py') || + contains(needs.changed-files.outputs.changed-files, 'CMakeLists.txt') || + contains(needs.changed-files.outputs.changed-files, 'CMakePresets.json') || + contains(needs.changed-files.outputs.changed-files, 'tools/cmake/') || + contains(needs.changed-files.outputs.changed-files, 'examples/vulkan/') || + contains(needs.changed-files.outputs.changed-files, 'Makefile') || + contains(needs.changed-files.outputs.changed-files, '.ci/scripts/export_model_artifact.sh') || + contains(needs.changed-files.outputs.changed-files, '.ci/scripts/test_model_e2e.sh') || + contains(needs.changed-files.outputs.changed-files, '.ci/scripts/tests/vulkan/') || + contains(needs.changed-files.outputs.changed-files, '.ci/scripts/setup-vulkan-linux-deps.sh') || + contains(needs.changed-files.outputs.changed-files, '.github/workflows/vulkan.yml') || + needs.run-decision.outputs.is-full-run == 'true' + uses: pytorch/test-infra/.github/workflows/linux_job_v3.yml@main + permissions: + id-token: write + contents: read + with: + timeout: 180 + runner: mt-l-x86aavx2-11-41-a10g + gpu-arch-type: cuda + gpu-arch-version: "12.6" + submodules: recursive + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + upload-artifact: vulkan-llm-e2e + script: | + set -euxo pipefail + # Runners pin HF_HUB_CACHE to a read-only mount independently of HF_HOME. + export HF_HOME="${RUNNER_TEMP:-/tmp}/hf_cache" + mkdir -p "${HF_HOME}" 2>/dev/null || export HF_HOME=/tmp/hf_cache + mkdir -p "${HF_HOME}" + export HF_HUB_CACHE="${HF_HOME}/hub" + export HF_HUB_DISABLE_XET=1 + + source .ci/scripts/setup-vulkan-linux-deps.sh real-gpu + vulkaninfo --summary > "${RUNNER_ARTIFACT_DIR}/vulkaninfo.txt" 2>&1 || true + + # GPU runners cannot fetch transitive wheel dependencies from the public CDN. + export PIP_EXTRA_INDEX_URL= + pip install filelock typing-extensions "setuptools<82" sympy networkx jinja2 "fsspec[http]<=2025.3.0" numpy pillow + export USE_MKL=OFF + CMAKE_ARGS="-DEXECUTORCH_BUILD_VULKAN=ON -DEXECUTORCH_BUILD_KERNELS_LLM_AOT=ON" \ + PYTHON_EXECUTABLE=python ./install_executorch.sh + python -m pip list --format=json > "${RUNNER_ARTIFACT_DIR}/python-packages.json" + + make llama-vulkan 2>&1 | tee "${RUNNER_ARTIFACT_DIR}/build.log" + export EXECUTORCH_E2E_RUNNER="${PWD}/cmake-out/examples/models/llama/llama_main" + test -x "${EXECUTORCH_E2E_RUNNER}" + + MODELS_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/vulkan_models_XXXXXX")" + collect_artifacts() { + for artifact in "${MODEL_DIR}"/*.json "${MODEL_DIR}"/*.log \ + "${MODEL_DIR}"/runner.stdout "${MODEL_DIR}"/runner.stderr \ + "${MODEL_DIR}"/prompt.txt "${MODEL_DIR}"/expected_output.txt; do + if [ -f "$artifact" ]; then + cp "$artifact" "${CASE_ARTIFACT_DIR}/" + fi + done + } + trap collect_artifacts EXIT + printf 'model\tquantization\tresult\n' > "${RUNNER_ARTIFACT_DIR}/results.tsv" + RESULT=0 + while read -r HF_MODEL QUANT_NAME; do + CASE_NAME="${HF_MODEL//\//_}-${QUANT_NAME}" + MODEL_DIR="${MODELS_DIR}/${CASE_NAME}" + CASE_ARTIFACT_DIR="${RUNNER_ARTIFACT_DIR}/${CASE_NAME}" + mkdir -p "${MODEL_DIR}" "${CASE_ARTIFACT_DIR}" + if RUN_EXPORT=1 bash .ci/scripts/test_model_e2e.sh vulkan \ + "${HF_MODEL}" "${QUANT_NAME}" "${MODEL_DIR}" 2>&1 | tee "${MODEL_DIR}/test.log"; then + printf '%s\t%s\tPASS\n' "${HF_MODEL}" "${QUANT_NAME}" >> "${RUNNER_ARTIFACT_DIR}/results.tsv" + else + printf '%s\t%s\tFAIL\n' "${HF_MODEL}" "${QUANT_NAME}" >> "${RUNNER_ARTIFACT_DIR}/results.tsv" + echo "::error::Vulkan LLM test failed: ${HF_MODEL} ${QUANT_NAME}" + RESULT=1 + fi + collect_artifacts + rm -f "${MODEL_DIR}/model.pte" + done <<'MODELS' + Qwen/Qwen3-0.6B quantized-8da4w-fp16-embedding + unsloth/Llama-3.2-1B-Instruct quantized-8da4w + unsloth/Llama-3.2-1B-Instruct quantized-8da4w-fp16 + unsloth/Llama-3.2-1B-Instruct quantized-8da4w-embedding + unsloth/Llama-3.2-1B-Instruct quantized-8da4w-fp16-embedding + unsloth/Llama-3.2-1B-Instruct quantized-int4-weight-only + microsoft/Phi-4-mini-instruct quantized-8da4w-embedding + MODELS + exit "${RESULT}" + build-vulkan-windows-msvc: needs: [changed-files, run-decision] if: | diff --git a/CMakePresets.json b/CMakePresets.json index 2b5e36fdb5c..3d08c2756e3 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -167,6 +167,14 @@ "CMAKE_INSTALL_PREFIX": "${sourceDir}/cmake-out" } }, + { + "name": "llm-release-vulkan", + "displayName": "LLM release build with Vulkan", + "inherits": ["llm-release"], + "cacheVariables": { + "EXECUTORCH_BUILD_VULKAN": "ON" + } + }, { "name": "llm-release-cuda", "displayName": "LLM release build with CUDA", @@ -429,6 +437,14 @@ ], "jobs": 0 }, + { + "name": "llm-release-vulkan-install", + "displayName": "Build and install LLM extension release artifacts (Vulkan)", + "configurePreset": "llm-release-vulkan", + "configuration": "Release", + "targets": ["install"], + "jobs": 0 + }, { "name": "llm-release-cuda-install", "displayName": "Build and install LLM extension release artifacts (CUDA)", @@ -538,6 +554,14 @@ } ] }, + { + "name": "llm-release-vulkan", + "displayName": "Configure, build and install ExecuTorch LLM extension with Vulkan enabled", + "steps": [ + {"type": "configure", "name": "llm-release-vulkan"}, + {"type": "build", "name": "llm-release-vulkan-install"} + ] + }, { "name": "llm-release-cuda", "displayName": "Configure, build and install ExecuTorch LLM extension with CUDA enabled", diff --git a/Makefile b/Makefile index 3f5ba32b0e4..97110a7883a 100644 --- a/Makefile +++ b/Makefile @@ -95,6 +95,8 @@ .PHONY: voxtral-cuda voxtral-cpu voxtral-metal voxtral-mlx voxtral_realtime-cuda voxtral_realtime-rocm voxtral_realtime-cpu voxtral_realtime-metal voxtral_realtime-mlx voxtral_tts-cpu voxtral_tts-cuda whisper-cuda whisper-cuda-debug whisper-cpu whisper-metal parakeet-cuda parakeet-cuda-debug parakeet-cpu parakeet-metal parakeet-mlx parakeet-vulkan dinov2-cuda dinov2-cuda-debug sortformer-cuda sortformer-cpu supertonic-mlx silero-vad-cpu llama-cuda llama-cuda-debug llama-cpu lfm_2_5-mlx llava-cpu gemma3-cuda gemma3-cpu gemma4_31b-cuda gemma4_31b-mlx muse-glimmer-cuda muse-glimmer-mlx qwen3_5_moe-cuda qwen3_5_moe-metal qwen3_5_moe-mlx clean help +.PHONY: llama-vulkan + help: @echo "This Makefile adds targets to build runners for various models on various backends. Run using \`make \`. Available targets:" @echo " voxtral-cuda - Build Voxtral runner with CUDA backend" @@ -131,6 +133,7 @@ help: @echo " llama-cuda - Build Llama runner with CUDA backend" @echo " llama-cuda-debug - Build Llama runner with CUDA backend (debug mode)" @echo " llama-cpu - Build Llama runner with CPU backend" + @echo " llama-vulkan - Build Llama runner with Vulkan backend" @echo " lfm_2_5-mlx - Build LFM2.5 runner with MLX backend" @echo " llava-cpu - Build Llava runner with CPU backend" @echo " gemma3-cuda - Build Gemma3 runner with CUDA backend" @@ -410,6 +413,15 @@ llama-cpu: @echo "✓ Build complete!" @echo " Binary: cmake-out/examples/models/llama/llama_main" +llama-vulkan: + @echo "==> Building and installing ExecuTorch with Vulkan..." + cmake --workflow --preset llm-release-vulkan + @echo "==> Building Llama runner with Vulkan..." + cd examples/models/llama && cmake --workflow --preset llama-release + @echo "" + @echo "✓ Build complete!" + @echo " Binary: cmake-out/examples/models/llama/llama_main" + llama-cuda: @echo "==> Building and installing ExecuTorch with CUDA..." cmake --workflow --preset llm-release-cuda diff --git a/backends/vulkan/test/utils.py b/backends/vulkan/test/utils.py index e6f20a68c7b..e417ed51366 100644 --- a/backends/vulkan/test/utils.py +++ b/backends/vulkan/test/utils.py @@ -5,10 +5,12 @@ # LICENSE file in the root directory of this source tree. +import json import logging -from collections import OrderedDict +from collections import Counter, OrderedDict from copy import deepcopy from enum import auto, Enum +from pathlib import Path from typing import Any, List, Optional, Tuple import executorch.backends.vulkan.utils as utils @@ -42,9 +44,6 @@ create_exported_program_from_submodule, create_submodule_from_nodes, ) -from executorch.extension.pybindings.portable_lib import ( # @manual - _load_for_executorch_from_buffer, -) from executorch.extension.pytree import tree_flatten from torch.export import export @@ -55,6 +54,60 @@ from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e +def verify_vulkan_llm_pte( + model_path: str | Path, + qmode: str = "8da4w", + quantized_embedding: bool = False, + max_size_mib: Optional[int] = None, +) -> None: + """Check exported LLM size and required operators in called Vulkan delegates.""" + from executorch.backends.vulkan.serialization.vulkan_graph_serialize import ( + extract_vk_flatbuffer, + flatbuffer_to_vk_graph, + ) + from executorch.exir._serialize._program import deserialize_pte_binary + from executorch.exir.schema import DelegateCall + + model_path = Path(model_path) + if max_size_mib and model_path.stat().st_size > max_size_mib * 1024 * 1024: + raise ValueError(f"Model exceeds {max_size_mib} MiB: {model_path}") + program = deserialize_pte_binary(model_path.read_bytes()).program + forward = next(plan for plan in program.execution_plan if plan.name == "forward") + called_delegates = { + instruction.instr_args.delegate_index + for chain in forward.chains + for instruction in chain.instructions + if isinstance(instruction.instr_args, DelegateCall) + } + delegate_ids = [forward.delegates[index].id for index in called_delegates] + (model_path.parent / "delegation.json").write_text( + json.dumps({"forward_delegates": delegate_ids}, indent=2) + "\n" + ) + if "VulkanBackend" not in delegate_ids: + raise ValueError( + f"Expected called VulkanBackend delegation, got {delegate_ids}" + ) + operator_names = [] + for index in called_delegates: + delegate = forward.delegates[index] + if delegate.id == "VulkanBackend": + payload = program.backend_delegate_data[delegate.processed.index].data + graph = flatbuffer_to_vk_graph(extract_vk_flatbuffer(payload)) + operator_names.extend(op.name for op in graph.chain) + (model_path.parent / "vulkan_operators.json").write_text( + json.dumps(dict(Counter(operator_names)), indent=2, sort_keys=True) + "\n" + ) + required_ops = [ + "linear_dq8ca_q4gsw" if qmode == "8da4w" else "linear_q4gsw", + "sdpa", + ] + if quantized_embedding: + required_ops.append("embedding_q4gsw") + for required in required_ops: + if not any(required in name for name in operator_names): + raise ValueError(f"Missing Vulkan operator family: {required}") + + class NodeFlagIsSetChecker(OperatorSupportBase): """ Check if a node is marked with a given field in node.meta["custom"] @@ -559,6 +612,10 @@ def run_and_check_output( Returns: bool: True if outputs match within tolerance, False otherwise """ + from executorch.extension.pybindings.portable_lib import ( # @manual + _load_for_executorch_from_buffer, + ) + # Load the ExecuTorch program executorch_module = _load_for_executorch_from_buffer(executorch_program.buffer) @@ -613,6 +670,10 @@ def lower_module_and_test_output( Returns: bool: True if all comparisons pass, False otherwise. """ + from executorch.extension.pybindings.portable_lib import ( # @manual + _load_for_executorch_from_buffer, + ) + # Export model to Vulkan using the helper function if xnnpack: executorch_program = export_model_to_xnnpack( diff --git a/examples/models/llama/convert_weights.py b/examples/models/llama/convert_weights.py index 60dcaf4d97c..e88dcaf3cf3 100644 --- a/examples/models/llama/convert_weights.py +++ b/examples/models/llama/convert_weights.py @@ -1,3 +1,6 @@ +import argparse +import json +from pathlib import Path from typing import Dict import torch @@ -59,3 +62,74 @@ def load_and_convert_unsloth_to_meta(checkpoint_path: str) -> Dict[str, torch.Te """ state_dict = load_file(checkpoint_path) return unsloth_to_meta(state_dict) + + +def convert_hf_to_meta( + input_dir: str, output_file: str, params_output: str | None = None +) -> None: + """Convert HuggingFace Llama weights to Meta format, including RoPE ordering.""" + from executorch.examples.models.qwen3.convert_weights import ( + load_checkpoint_from_safetensors, + ) + from torchtune.models.convert_weights import hf_to_tune, tune_to_meta + + config = json.loads((Path(input_dir) / "config.json").read_text()) + state_dict = load_checkpoint_from_safetensors(input_dir) + state_dict = tune_to_meta( + hf_to_tune( + state_dict, + num_heads=config["num_attention_heads"], + num_kv_heads=config["num_key_value_heads"], + dim=config["hidden_size"], + head_dim=config.get("head_dim"), + ) + ) + if config.get("tie_word_embeddings"): + state_dict["output.weight"] = state_dict["tok_embeddings.weight"] + torch.save(state_dict, output_file) + if params_output: + rope_scaling = config.get("rope_scaling") + params = { + "dim": config["hidden_size"], + "hidden_dim": config["intermediate_size"], + "n_heads": config["num_attention_heads"], + "n_kv_heads": config["num_key_value_heads"], + "n_layers": config["num_hidden_layers"], + "norm_eps": config["rms_norm_eps"], + "rope_theta": config["rope_theta"], + "use_scaled_rope": bool(rope_scaling), + "rope_scale_factor": rope_scaling["factor"] if rope_scaling else 8, + "vocab_size": config["vocab_size"], + } + Path(params_output).write_text(json.dumps(params, indent=2) + "\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Convert Llama checkpoints to Meta format." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + hf_parser = subparsers.add_parser( + "hf-to-meta", help="Convert a HuggingFace snapshot." + ) + hf_parser.add_argument("input_dir") + hf_parser.add_argument("output") + hf_parser.add_argument("--params-output") + for command, help_text in ( + ("unsloth-to-meta", "Convert a PyTorch Unsloth state dict."), + ( + "load-and-convert-unsloth-to-meta", + "Load and convert an Unsloth Safetensors checkpoint.", + ), + ): + command_parser = subparsers.add_parser(command, help=help_text) + command_parser.add_argument("input") + command_parser.add_argument("output") + args = parser.parse_args() + if args.command == "hf-to-meta": + convert_hf_to_meta(args.input_dir, args.output, args.params_output) + elif args.command == "unsloth-to-meta": + state_dict = torch.load(args.input, map_location="cpu", weights_only=True) + torch.save(unsloth_to_meta(state_dict), args.output) + else: + torch.save(load_and_convert_unsloth_to_meta(args.input), args.output) diff --git a/examples/models/llama/hf_download.py b/examples/models/llama/hf_download.py index 6c2c115ad4c..17d234c8ffb 100644 --- a/examples/models/llama/hf_download.py +++ b/examples/models/llama/hf_download.py @@ -5,14 +5,52 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +import argparse +import hashlib +import json import os +import sys +from contextlib import redirect_stdout +from importlib.metadata import EntryPoint from pathlib import Path from typing import Callable from huggingface_hub import snapshot_download +def download_hf_snapshot( + repo_id: str, + revision: str, + expected_sha256: str | None = None, + metadata_path: Path | None = None, +) -> str: + """Download a pinned Safetensors checkpoint and optionally verify its hash.""" + snapshot = snapshot_download( + repo_id=repo_id, + revision=revision, + allow_patterns=["*.safetensors", "*.json"], + token=False, + ) + if expected_sha256: + checksum = hashlib.sha256() + with (Path(snapshot) / "model.safetensors").open("rb") as checkpoint: + for chunk in iter(lambda: checkpoint.read(1024 * 1024), b""): + checksum.update(chunk) + if metadata_path: + metadata = ( + json.loads(metadata_path.read_text()) if metadata_path.exists() else {} + ) + metadata.update( + expected_checkpoint_sha256=expected_sha256, + observed_checkpoint_sha256=checksum.hexdigest(), + ) + metadata_path.write_text(json.dumps(metadata, indent=2) + "\n") + if checksum.hexdigest() != expected_sha256: + raise ValueError(f"Checkpoint SHA256 mismatch: {checksum.hexdigest()}") + return snapshot + + def download_and_convert_hf_checkpoint( repo_id: str, convert_weights: Callable[[str, str], None] ) -> str: @@ -51,3 +89,39 @@ def download_and_convert_hf_checkpoint( # 2. Convert weights to Meta format. convert_weights(checkpoint_path, str(converted_path)) return str(converted_path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Download HuggingFace checkpoints, optionally converting to Meta format." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + snapshot_parser = subparsers.add_parser( + "snapshot", help="Download a pinned snapshot." + ) + snapshot_parser.add_argument("repo_id") + snapshot_parser.add_argument("--revision", required=True) + snapshot_parser.add_argument("--expected-sha256") + snapshot_parser.add_argument("--metadata-path", type=Path) + convert_parser = subparsers.add_parser( + "download-and-convert", help="Download and cache a converted checkpoint." + ) + convert_parser.add_argument("repo_id") + convert_parser.add_argument( + "--converter", + required=True, + metavar="MODULE:FUNCTION", + help="Conversion callable accepting (input_dir, output_file).", + ) + args = parser.parse_args() + if args.command == "snapshot": + checkpoint_path = download_hf_snapshot( + args.repo_id, args.revision, args.expected_sha256, args.metadata_path + ) + else: + converter = EntryPoint(name="converter", value=args.converter, group="").load() + with redirect_stdout(sys.stderr): + checkpoint_path = download_and_convert_hf_checkpoint( + args.repo_id, converter + ) + print(checkpoint_path) diff --git a/examples/models/llama/tests/test_convert_weights.py b/examples/models/llama/tests/test_convert_weights.py new file mode 100644 index 00000000000..63b46f57a9a --- /dev/null +++ b/examples/models/llama/tests/test_convert_weights.py @@ -0,0 +1,159 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import json +import subprocess +import sys + +import pytest +import torch +from executorch.examples.models.llama import convert_weights +from safetensors.torch import save_file + + +def test_hf_attention_weights_use_meta_rope_order(tmp_path): + config = {"hidden_size": 8, "num_attention_heads": 2, "num_key_value_heads": 1} + (tmp_path / "config.json").write_text(json.dumps(config)) + query = torch.arange(64, dtype=torch.float32).view(8, 8) + key = torch.arange(32, dtype=torch.float32).view(4, 8) + save_file( + { + "model.layers.0.self_attn.q_proj.weight": query, + "model.layers.0.self_attn.k_proj.weight": key, + }, + tmp_path / "model.safetensors", + ) + output = tmp_path / "converted.pth" + convert_weights.convert_hf_to_meta(str(tmp_path), str(output)) + checkpoint = torch.load(output, weights_only=True) + torch.testing.assert_close( + checkpoint["layers.0.attention.wq.weight"], query[[0, 2, 1, 3, 4, 6, 5, 7]] + ) + torch.testing.assert_close( + checkpoint["layers.0.attention.wk.weight"], key[[0, 2, 1, 3]] + ) + + +def test_hf_tied_embeddings_remain_shared(tmp_path): + config = { + "hidden_size": 8, + "num_attention_heads": 2, + "num_key_value_heads": 1, + "tie_word_embeddings": True, + } + (tmp_path / "config.json").write_text(json.dumps(config)) + embeddings = torch.arange(80, dtype=torch.float32).view(10, 8) + save_file({"model.embed_tokens.weight": embeddings}, tmp_path / "model.safetensors") + output = tmp_path / "converted.pth" + convert_weights.convert_hf_to_meta(str(tmp_path), str(output)) + checkpoint = torch.load(output, weights_only=True) + torch.testing.assert_close(checkpoint["tok_embeddings.weight"], embeddings) + assert ( + checkpoint["output.weight"].data_ptr() + == checkpoint["tok_embeddings.weight"].data_ptr() + ) + + +def test_hf_conversion_can_emit_llama_params(tmp_path): + config = { + "hidden_size": 8, + "intermediate_size": 32, + "num_attention_heads": 2, + "num_key_value_heads": 1, + "num_hidden_layers": 2, + "rms_norm_eps": 1e-5, + "rope_theta": 500000, + "rope_scaling": {"factor": 32}, + "vocab_size": 10, + "tie_word_embeddings": True, + } + (tmp_path / "config.json").write_text(json.dumps(config)) + save_file( + {"model.embed_tokens.weight": torch.zeros(10, 8)}, + tmp_path / "model.safetensors", + ) + params = tmp_path / "params.json" + convert_weights.convert_hf_to_meta( + str(tmp_path), str(tmp_path / "weights.pth"), params_output=str(params) + ) + actual = json.loads(params.read_text()) + assert actual["dim"] == 8 + assert actual["hidden_dim"] == 32 + assert actual["n_heads"] == 2 + assert actual["n_kv_heads"] == 1 + assert actual["n_layers"] == 2 + assert actual["use_scaled_rope"] + assert actual["rope_scale_factor"] == 32 + + +def test_hf_to_meta_cli_emits_weights_and_params(tmp_path): + config = { + "hidden_size": 8, + "intermediate_size": 32, + "num_attention_heads": 2, + "num_key_value_heads": 1, + "num_hidden_layers": 2, + "rms_norm_eps": 1e-5, + "rope_theta": 500000, + "vocab_size": 10, + "tie_word_embeddings": True, + } + (tmp_path / "config.json").write_text(json.dumps(config)) + embeddings = torch.arange(80, dtype=torch.float32).view(10, 8) + save_file({"model.embed_tokens.weight": embeddings}, tmp_path / "model.safetensors") + output = tmp_path / "weights.pth" + params = tmp_path / "params.json" + result = subprocess.run( + [ + sys.executable, + "-m", + "executorch.examples.models.llama.convert_weights", + "hf-to-meta", + str(tmp_path), + str(output), + "--params-output", + str(params), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + checkpoint = torch.load(output, weights_only=True) + torch.testing.assert_close(checkpoint["tok_embeddings.weight"], embeddings) + assert json.loads(params.read_text())["dim"] == 8 + + +@pytest.mark.parametrize( + "command", ["unsloth-to-meta", "load-and-convert-unsloth-to-meta"] +) +def test_unsloth_cli_exposes_state_dict_and_safetensors_conversion(tmp_path, command): + weights = torch.arange(8, dtype=torch.float32).view(2, 4) + state = {"base_model.model.model.layers.0.self_attn.q_proj.lora_A.weight": weights} + source = tmp_path / ( + "adapter.pth" if command == "unsloth-to-meta" else "adapter.safetensors" + ) + if command == "unsloth-to-meta": + torch.save(state, source) + else: + save_file(state, source) + output = tmp_path / "converted.pth" + result = subprocess.run( + [ + sys.executable, + "-m", + "executorch.examples.models.llama.convert_weights", + command, + str(source), + str(output), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + checkpoint = torch.load(output, weights_only=True) + torch.testing.assert_close( + checkpoint["layers.0.attention.wq.lora_a.weight"], weights + ) diff --git a/tools/cmake/executorch-config.cmake b/tools/cmake/executorch-config.cmake index 2f5b2a85302..3d3b650f33e 100644 --- a/tools/cmake/executorch-config.cmake +++ b/tools/cmake/executorch-config.cmake @@ -38,6 +38,15 @@ set(_root "${CMAKE_CURRENT_LIST_DIR}/../../..") # runtime this install actually contains. include("${CMAKE_CURRENT_LIST_DIR}/ExecuTorchTargets.cmake") +if(TARGET vulkan_backend) + get_target_property( + _vulkan_link_libraries vulkan_backend INTERFACE_LINK_LIBRARIES + ) + if("${_vulkan_link_libraries}" MATCHES "Boost::stacktrace_") + find_dependency(Boost 1.89 COMPONENTS stacktrace_basic stacktrace_addr2line) + endif() +endif() + # A shared build installs libexecutorch.a and libexecutorch.so side by side, and # naming the static runtime as well as the shared one gives a consumer two # kernel registries, which aborts during registration before main. The shared