diff --git a/.ci/scripts/test_torchao_huggingface_checkpoints.sh b/.ci/scripts/test_torchao_huggingface_checkpoints.sh index f80eb9e9f44..574b9bab63b 100644 --- a/.ci/scripts/test_torchao_huggingface_checkpoints.sh +++ b/.ci/scripts/test_torchao_huggingface_checkpoints.sh @@ -11,7 +11,7 @@ MODEL_NAME="" # Parse args if [[ $# -lt 1 ]]; then echo "Usage: $0 [--test_with_runner]" - echo "Supported model_name values: qwen3_4b, phi_4_mini, lfm2_5_1_2b" + echo "Supported model_name values: qwen3_4b, phi_4_mini, lfm2_5_1_2b, spark_x2_5_1_7b, spark_x2_5_4b" exit 1 fi @@ -28,7 +28,7 @@ while [[ $# -gt 0 ]]; do ;; -h|--help) echo "Usage: $0 [--test_with_runner] [--use_torchao_kernels]" - echo " model_name: qwen3_4b | phi_4_mini | lfm2_5_1_2b" + echo " model_name: qwen3_4b | phi_4_mini | lfm2_5_1_2b | spark_x2_5_1_7b | spark_x2_5_4b" echo " --test_with_runner: build ET + run llama_main to sanity-check the export" echo " --use_torchao_kernels: use torchao kernels for linear and tied embedding" exit 0 @@ -125,9 +125,61 @@ case "$MODEL_NAME" in ${BACKEND_ARGS} ;; + spark_x2_5_1_7b) + echo "Running Spark-X2.5-1.7B export..." + HF_MODEL_DIR="${RUNNER_TEMP:-/tmp}/hf_cache/spark_x2_5_1_7b" + mkdir -p "${HF_MODEL_DIR}" + hf download --local-dir "${HF_MODEL_DIR}" XHToken/Spark-X2.5-1.7B + EXPECTED_MODEL_SIZE_UPPER_BOUND=$((2 * 1024 * 1024 * 1024)) # 2GB + $PYTHON_EXECUTABLE -m executorch.examples.models.spark_x2_5.convert_weights \ + $HF_MODEL_DIR \ + pytorch_model_converted.bin + + $PYTHON_EXECUTABLE -m executorch.examples.models.llama.export_llama \ + --model "spark_x2_5_1_7b" \ + --checkpoint pytorch_model_converted.bin \ + --params examples/models/spark_x2_5/config/spark_x2_5_1_7b_config.json \ + --output_name $MODEL_OUT \ + -kv \ + --use_sdpa_with_kv_cache \ + --max_context_length 1024 \ + --max_seq_length 1024 \ + --metadata '{"get_bos_id":0, "get_eos_ids":[1]}' \ + --verbose \ + --dtype fp32 \ + -qmode 8da4w \ + ${BACKEND_ARGS} + ;; + + spark_x2_5_4b) + echo "Running Spark-X2.5-4B export..." + HF_MODEL_DIR="${RUNNER_TEMP:-/tmp}/hf_cache/spark_x2_5_4b" + mkdir -p "${HF_MODEL_DIR}" + hf download --local-dir "${HF_MODEL_DIR}" XHToken/Spark-X2.5-4B + EXPECTED_MODEL_SIZE_UPPER_BOUND=$((5 * 1024 * 1024 * 1024)) # 5GB + $PYTHON_EXECUTABLE -m executorch.examples.models.spark_x2_5.convert_weights \ + $HF_MODEL_DIR \ + pytorch_model_converted.bin + + $PYTHON_EXECUTABLE -m executorch.examples.models.llama.export_llama \ + --model "spark_x2_5_4b" \ + --checkpoint pytorch_model_converted.bin \ + --params examples/models/spark_x2_5/config/spark_x2_5_4b_config.json \ + --output_name $MODEL_OUT \ + -kv \ + --use_sdpa_with_kv_cache \ + --max_context_length 1024 \ + --max_seq_length 1024 \ + --metadata '{"get_bos_id":0, "get_eos_ids":[1]}' \ + --verbose \ + --dtype fp32 \ + -qmode 8da4w \ + ${BACKEND_ARGS} + ;; + *) echo "Error: unsupported model_name '$MODEL_NAME'" - echo "Supported values: qwen3_4b, phi_4_mini, lfm2_5_1_2b" + echo "Supported values: qwen3_4b, phi_4_mini, lfm2_5_1_2b, spark_x2_5_1_7b, spark_x2_5_4b" exit 1 ;; esac diff --git a/.github/workflows/trunk.yml b/.github/workflows/trunk.yml index cea3f423700..2945fed7a2f 100644 --- a/.github/workflows/trunk.yml +++ b/.github/workflows/trunk.yml @@ -576,7 +576,7 @@ jobs: contents: read strategy: matrix: - model: [qwen3_4b, phi_4_mini, lfm2_5_1_2b] + model: [qwen3_4b, phi_4_mini, lfm2_5_1_2b, spark_x2_5_1_7b, spark_x2_5_4b] runner: [mt-l-x86iavx512-8-64] docker-image: [executorch-ubuntu-22.04-clang12] backend: [xnnpack] @@ -593,6 +593,14 @@ jobs: runner: mt-l-arm64g4-16-62 docker-image: executorch-ubuntu-22.04-gcc11-aarch64 backend: torchao + - model: spark_x2_5_1_7b + runner: mt-l-arm64g4-16-62 + docker-image: executorch-ubuntu-22.04-gcc11-aarch64 + backend: torchao + - model: spark_x2_5_4b + runner: mt-l-arm64g4-16-62 + docker-image: executorch-ubuntu-22.04-gcc11-aarch64 + backend: torchao fail-fast: false with: runner: ${{ matrix.runner }} diff --git a/examples/models/spark_x2_5/convert_weights.py b/examples/models/spark_x2_5/convert_weights.py index 64037fa16f9..ae6fc4b67fb 100644 --- a/examples/models/spark_x2_5/convert_weights.py +++ b/examples/models/spark_x2_5/convert_weights.py @@ -4,7 +4,6 @@ from typing import Dict import torch - from executorch.examples.models.checkpoint import get_mapped_key from safetensors.torch import load_file @@ -92,12 +91,37 @@ def load_checkpoint(input_dir: str) -> Dict[str, torch.Tensor]: raise FileNotFoundError(f"Could not find safetensors checkpoint in {input_dir}") +def sanitize_tokenizer(input_dir: str) -> None: + """Make tokenizer.json loadable by the C++ HF tokenizer. + + Spark-X2.5 ships a no-op normalizer ({"type": "Sequence", "normalizers": + []}) that the C++ tokenizer rejects; a null normalizer is equivalent and + accepted. + """ + path = os.path.join(input_dir, "tokenizer.json") + if not os.path.exists(path): + return + with open(path) as f: + tokenizer = json.load(f) + normalizer = tokenizer.get("normalizer") + if ( + isinstance(normalizer, dict) + and normalizer.get("type") == "Sequence" + and not normalizer.get("normalizers") + ): + tokenizer["normalizer"] = None + with open(path, "w") as f: + json.dump(tokenizer, f) + print("Replaced no-op normalizer in tokenizer.json with null") + + def convert_weights(input_dir: str, output_file: str) -> None: """Convert Spark-X2.5 HF weights to Meta format. Reads model hyperparameters from config.json in input_dir so the function signature stays compatible with download_and_convert_hf_checkpoint. """ + sanitize_tokenizer(input_dir) config_path = os.path.join(input_dir, "config.json") if os.path.exists(config_path): with open(config_path) as f: diff --git a/examples/models/spark_x2_5/test_spark_x2_5.py b/examples/models/spark_x2_5/test_spark_x2_5.py index c8c82d03751..392e44c4e28 100644 --- a/examples/models/spark_x2_5/test_spark_x2_5.py +++ b/examples/models/spark_x2_5/test_spark_x2_5.py @@ -115,6 +115,37 @@ def test_spark_x2_5_architecture_configs_match_expected_shapes() -> None: ) +def test_sanitize_tokenizer_replaces_noop_normalizer(tmp_path) -> None: + from executorch.examples.models.spark_x2_5.convert_weights import sanitize_tokenizer + + tokenizer = { + "normalizer": {"type": "Sequence", "normalizers": []}, + "model": {"type": "BPE"}, + } + tokenizer_path = tmp_path / "tokenizer.json" + tokenizer_path.write_text(json.dumps(tokenizer)) + + sanitize_tokenizer(str(tmp_path)) + + sanitized = json.loads(tokenizer_path.read_text()) + assert sanitized["normalizer"] is None + assert sanitized["model"] == {"type": "BPE"} + + +def test_sanitize_tokenizer_keeps_meaningful_normalizer(tmp_path) -> None: + from executorch.examples.models.spark_x2_5.convert_weights import sanitize_tokenizer + + normalizer = {"type": "Sequence", "normalizers": [{"type": "NFC"}]} + tokenizer = {"normalizer": normalizer, "model": {"type": "BPE"}} + tokenizer_path = tmp_path / "tokenizer.json" + tokenizer_path.write_text(json.dumps(tokenizer)) + + sanitize_tokenizer(str(tmp_path)) + + sanitized = json.loads(tokenizer_path.read_text()) + assert sanitized["normalizer"] == normalizer + + def test_spark_x2_5_xnnpack_q8da4w_config_enables_xnnpack_backend() -> None: cfg = OmegaConf.load(CONFIG_DIR / "spark_x2_5_xnnpack_q8da4w.yaml") assert cfg.base.metadata == '{"get_bos_id": 0, "get_eos_ids":[1]}'