Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f7bb1c5
[XNNPACK] Lower upsample_nearest2d x2 and keep NHWC across scalar / l…
borys3kk Oct 22, 2026
d6e8816
Make delete_constant_placeholder edit the graph signature in place
borys3kk Oct 21, 2026
b929250
[XNNPACK] Lower depthwise transposed convs as polyphase depthwise con…
borys3kk Oct 20, 2026
743c690
Remove the deleted spec with a slice assignment
borys3kk Oct 26, 2026
1c8a70d
Use a Software Mansion copyright header on the new files
borys3kk Oct 27, 2026
445b1d5
Drop the channels-last broadcast operand changes that moved out of #2…
borys3kk Oct 27, 2026
c22288b
Key the phase weight placeholders by stride and padding as well
borys3kk Oct 29, 2026
12f2694
Merge remote-tracking branch 'origin/main' into xnnpack-depthwise-con…
borys3kk Oct 30, 2026
850c976
Keep the external-constant tag on the phase weights
borys3kk Nov 2, 2026
4ffc1ba
Test that the pass runs during lowering, its order against FuseActiva…
borys3kk Nov 2, 2026
51edc2e
Merge remote-tracking branch 'origin/main' into xnnpack-depthwise-con…
borys3kk Nov 2, 2026
7ad83c0
Merge branch 'main' into xnnpack-depthwise-conv-transpose-main
JakeStevens Nov 5, 2026
d7d0a0b
Add Phonon (Gradium 31M TTS) example with multi-backend export (#24810)
psiddh Nov 12, 2026
8ba6126
Phonon: streaming output and per-backend weight quantization (#24929)
psiddh Nov 12, 2026
423496d
Fix the linear_q4gsw reference implementation for output channels not…
psiddh Nov 12, 2026
518777b
Do not delegate quantized linears that have no Vulkan kernel (#24971)
psiddh Nov 12, 2026
3495700
Phonon: run the Mimi decoder's transposed convolutions as convolution…
psiddh Nov 12, 2026
a682498
Merge #24114 into #25004
JakeStevens Nov 13, 2026
aa1d717
[XNNPACK] Lower dense transposed convs as one polyphase conv + depth …
JakeStevens Nov 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/cuda.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ on:
- .ci/scripts/tests/test_muse_glimmer_batching_workload.py
- .ci/scripts/tests/muse_glimmer_batching_workload_test.cpp
- examples/models/muse-glimmer/**
- examples/models/phonon/**
- extension/pybindings/**
- runtime/__init__.py
- runtime/cuda.py
Expand Down Expand Up @@ -807,6 +808,53 @@ jobs:
ctest --test-dir cmake-out -R test_cuda_guard --output-on-failure -V
ctest --test-dir cmake-out -R test_cuda_stream_guard --output-on-failure -V

test-cuda-phonon:
name: test-cuda-phonon
needs: [changed-files, run-decision]
if: |
contains(needs.changed-files.outputs.changed-files, 'backends/cuda') ||
contains(needs.changed-files.outputs.changed-files, 'backends/aoti') ||
contains(needs.changed-files.outputs.changed-files, 'examples/models/phonon') ||
contains(needs.changed-files.outputs.changed-files, '.github/workflows/cuda.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: 90
runner: mt-l-x86aavx2-11-41-a10g
gpu-arch-type: cuda
gpu-arch-version: "13.0"
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
script: |
set -eux

# OSDC mounts HF_HOME read-only at /mnt/hf_cache; redirect to a writable dir
# (RUNNER_TEMP, or /tmp when RUNNER_TEMP isn't writable inside the container).
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}"
# The runner also points HF_HUB_CACHE at the read-only mount.
export HF_HUB_CACHE="${HF_HOME}/hub"

bash ./install_executorch.sh
conda install -y -c conda-forge 'libstdcxx-ng>=12'
export LD_LIBRARY_PATH=/opt/conda/lib:$LD_LIBRARY_PATH
pip install safetensors tokenizers huggingface_hub
python -m pytest examples/models/phonon/tests -q
python -m executorch.examples.models.phonon.export_phonon \
--backend cuda --output-dir phonon_exports/cuda
make phonon-cuda
cmake-out/examples/models/phonon/phonon_runner \
--model_path phonon_exports/cuda/phonon.pte \
--data_path phonon_exports/cuda/aoti_cuda_blob.ptd \
--text "Hello from ExecuTorch." \
--output phonon.wav
# 16-bit mono WAV with at least 0.5 s of audio at 24 kHz.
test "$(wc -c < phonon.wav)" -gt $((44 + 24000))

test-model-cuda-e2e:
name: test-model-cuda-e2e-${{ matrix.model.name }}-${{ matrix.quant }}
# Skip this job if the pull request is from a fork (HuggingFace secrets are not available).
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/mlx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ on:
- examples/models/muse-glimmer/**
- examples/models/nemotron3_diarization/**
- examples/models/parakeet/**
- examples/models/phonon/**
- examples/models/supertonic/**
- examples/models/voxtral_realtime/**
- examples/models/qwen3_5_moe/**
Expand Down Expand Up @@ -409,6 +410,41 @@ jobs:
test -f cmake-out/lib/libextension_asr_runner.a
test -f cmake-out/lib/libextension_llm_runner.a
test-mlx-phonon:
needs: run-decision
if: |
github.event_name == 'pull_request' ||
needs.run-decision.outputs.is-full-run == 'true'
uses: pytorch/test-infra/.github/workflows/macos_job.yml@main
with:
default-packages: ""
job-name: test-mlx-phonon
runner: macos-15-xlarge
python-version: "3.12"
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
timeout: 90
script: |
set -eux
# Top-level presets build with unlimited make -j and per-model presets serially.
export CMAKE_BUILD_PARALLEL_LEVEL=$(( $(sysctl -n hw.ncpu) - 1 ))
# Disable HF Xet storage to avoid stalled downloads on CI runners
export HF_HUB_DISABLE_XET=1
${CONDA_RUN} python install_executorch.py > /dev/null
${CONDA_RUN} pip install safetensors tokenizers huggingface_hub
${CONDA_RUN} python -m pytest examples/models/phonon/tests -q
${CONDA_RUN} python -m executorch.examples.models.phonon.export_phonon \
--backend mlx --output-dir phonon_exports/mlx
${CONDA_RUN} make phonon-mlx
BIN_DIR=cmake-out/examples/models/phonon
test -f "${BIN_DIR}/mlx.metallib"
"${BIN_DIR}/phonon_runner" \
--model_path phonon_exports/mlx/phonon.pte \
--text "Hello from ExecuTorch." \
--output phonon.wav
# 16-bit mono WAV with at least 0.5 s of audio at 24 kHz.
test "$(wc -c < phonon.wav)" -gt $((44 + 24000))
backend-tester:
needs: run-decision
if: |
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/pull.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,47 @@ jobs:
bash .ci/scripts/test_model_e2e.sh xnnpack "nvidia/parakeet-tdt" "quantized-8da4w" "./parakeet_output"
echo "::endgroup::"
test-phonon-xnnpack-linux:
needs: docker-image
name: test-phonon-xnnpack-linux
uses: pytorch/test-infra/.github/workflows/linux_job_v3.yml@main
permissions:
id-token: write
contents: read
with:
runner: mt-l-x86iavx512-16-128
docker-image: ${{ needs.docker-image.outputs.docker-registry }}/ci-image:executorch-ubuntu-22.04-clang12-${{ needs.docker-image.outputs.ci-docker-hash }}
submodules: 'recursive'
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
timeout: 90
script: |
set -eux
# The generic Linux job chooses to use base env, not the one setup by the image
CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]")
conda activate "${CONDA_ENV}"
# OSDC mounts HF_HOME read-only at /mnt/hf_cache; redirect to a writable dir
# (RUNNER_TEMP, or /tmp when RUNNER_TEMP isn't writable inside the container).
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}"
# The runner also points HF_HUB_CACHE at the read-only mount.
export HF_HUB_CACHE="${HF_HOME}/hub"
./install_executorch.sh
pip install safetensors tokenizers huggingface_hub
python -m pytest examples/models/phonon/tests -q
python -m executorch.examples.models.phonon.export_phonon \
--backend xnnpack --output-dir phonon_exports/xnnpack
make phonon-cpu
cmake-out/examples/models/phonon/phonon_runner \
--model_path phonon_exports/xnnpack/phonon.pte \
--text "Hello from ExecuTorch." \
--output phonon.wav
# 16-bit mono WAV with at least 0.5 s of audio at 24 kHz.
test "$(wc -c < phonon.wav)" -gt $((44 + 24000))
test-voxtral-realtime-xnnpack-linux:
needs: docker-image
name: test-voxtral-realtime-xnnpack-linux
Expand Down
26 changes: 26 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
# - parakeet: Speech recognition model (CPU, CUDA, Metal, MLX)
# - sortformer: Speaker diarization model (CPU, CUDA)
# - nemotron3-diarization: Speaker diarization model (MLX, XNNPACK, CUDA, Vulkan)
# - phonon: Text-to-speech model (XNNPACK, CUDA, MLX, Vulkan)
# - supertonic: Text-to-speech model (MLX)
# - silero_vad: Voice activity detection model (CPU)
# - llama: Text generation model (CPU)
Expand Down Expand Up @@ -128,6 +129,10 @@ help:
@echo " nemotron3-diarization-cpu - Build Nemotron 3 Diarization runner with XNNPACK backend"
@echo " nemotron3-diarization-cuda - Build Nemotron 3 Diarization runner with CUDA backend"
@echo " nemotron3-diarization-vulkan - Build Nemotron 3 Diarization runner with Vulkan backend"
@echo " phonon-cpu - Build Phonon runner with XNNPACK backend"
@echo " phonon-cuda - Build Phonon runner with CUDA backend"
@echo " phonon-mlx - Build Phonon runner with MLX backend"
@echo " phonon-vulkan - Build Phonon runner with Vulkan backend"
@echo " supertonic-mlx - Build Supertonic runner with MLX backend"
@echo " silero-vad-cpu - Build Silero VAD runner with CPU backend"
@echo " llama-cuda - Build Llama runner with CUDA backend"
Expand Down Expand Up @@ -317,6 +322,27 @@ nemotron3-diarization-vulkan:
cmake --build --preset llm-release-install
cd examples/models/nemotron3_diarization && cmake --workflow --preset nemotron3-diarization-vulkan

.PHONY: phonon-cpu
phonon-cpu:
cmake --workflow --preset llm-release
cd examples/models/phonon && cmake --workflow --preset phonon-cpu

.PHONY: phonon-cuda
phonon-cuda:
cmake --workflow --preset llm-release-cuda
cd examples/models/phonon && cmake --workflow --preset phonon-cuda

.PHONY: phonon-mlx
phonon-mlx:
cmake --workflow --preset mlx-release
cd examples/models/phonon && cmake --workflow --preset phonon-mlx

.PHONY: phonon-vulkan
phonon-vulkan:
cmake --preset llm-release -DEXECUTORCH_BUILD_VULKAN=ON
cmake --build --preset llm-release-install
cd examples/models/phonon && cmake --workflow --preset phonon-vulkan

sortformer-cuda:
@echo "==> Building and installing ExecuTorch with CUDA..."
cmake --workflow --preset llm-release-cuda
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ and Messenger**, serving billions of people. It also runs AI features on
| Workload | Representative capabilities | Start here |
|---|---|---|
| **Local LLM and agent building blocks** | Quantized text models, long context, tool calling, speculative decoding, multi-session execution, and an experimental OpenAI-compatible local server | [Muse Glimmer](examples/models/muse-glimmer/README.md) · [LLM guide](https://docs.pytorch.org/executorch/main/llm/working-with-llms.html) · [LLM server](examples/llm_server/README.md) |
| **Voice** | Streaming and offline speech recognition, speech synthesis, voice activity detection, and speaker diarization | [Voxtral Realtime](examples/models/voxtral_realtime/README.md) · [Parakeet](examples/models/parakeet/README.md) · [Sortformer diarization](examples/models/sortformer/README.md) · [Voxtral TTS](examples/models/voxtral_tts/README.md) · [Nemotron 3 Diarization](examples/models/nemotron3_diarization/README.md) |
| **Voice** | Streaming and offline speech recognition, speech synthesis, voice activity detection, and speaker diarization | [Voxtral Realtime](examples/models/voxtral_realtime/README.md) · [Parakeet](examples/models/parakeet/README.md) · [Sortformer diarization](examples/models/sortformer/README.md) · [Voxtral TTS](examples/models/voxtral_tts/README.md) · [Nemotron 3 Diarization](examples/models/nemotron3_diarization/README.md) · [Phonon TTS](examples/models/phonon/README.md) |
| **Multimodal** | Text, image, and audio runners; mobile vision-language models | [Gemma 4](examples/models/gemma4/README.md) · [Multimodal runner](extension/llm/runner/README.md) |
| **Computer vision** | Image classification, object detection, semantic segmentation, and promptable segmentation | [MobileNet V2](https://docs.pytorch.org/executorch/main/getting-started.html#preparing-the-model) · [YOLO26](examples/models/yolo26/README.md) · [DeepLabV3](https://github.com/meta-pytorch/executorch-examples/tree/main/dl3/android/DeepLabV3Demo) · [EfficientSAM](examples/models/efficient_sam/README.md) |
| **Embedded AI** | Cortex-M CPU kernels, Ethos-U and NXP NPUs, Cadence DSPs, Zephyr, Arduino, and Raspberry Pi Pico workflows | [Embedded guide](https://docs.pytorch.org/executorch/main/embedded-section.html) · [Cortex-M](https://docs.pytorch.org/executorch/main/backends/arm-cortex-m/arm-cortex-m-overview.html) · [Arduino](examples/arduino/README.md) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
duplicate_constant_node,
)
from executorch.exir.dialects._ops import ops as exir_ops
from executorch.exir.pass_base import PassResult
from executorch.exir.pass_manager import ExportedProgramPassManager
from torch.export import export
from torch.export.graph_signature import InputKind

Expand Down Expand Up @@ -251,3 +253,59 @@ def test_create_delete_non_persistent_buffer():

def test_create_delete_constant_tensor():
_test_create_delete(InputKind.CONSTANT_TENSOR)


def _test_delete_inside_a_pass(kind: InputKind, persistent_buffer: bool = None):
"""Backend passes keep a reference to the program they were built from,
while ExportedProgramPassManager runs them on a shallow copy of it. The
deletion has to edit the shared signature in place, or the copy that the
manager verifies and returns still lists the deleted placeholder."""
module = EmptyNetwork()
exported_program = export(module, args=module.test_data, strict=True)
exported_program = to_edge(exported_program).exported_program()
graph = exported_program.graph_module.graph

const_name = "test_node"
input_node = list(graph.nodes)[0]
with graph.inserting_before(input_node):
const_node = create_constant_placeholder(
exp_program=exported_program,
graph=graph,
kind=kind,
name=const_name,
data=torch.ones(1),
persistent_buffer=persistent_buffer,
)
assert len(exported_program.graph_signature.input_specs) == 2

def delete_unused_constant(graph_module: torch.fx.GraphModule) -> PassResult:
delete_constant_placeholder(exported_program, const_node)
return PassResult(graph_module, True)

result = ExportedProgramPassManager([delete_unused_constant])(exported_program)
transformed = result.exported_program
signature = transformed.graph_signature

assert len(signature.input_specs) == 1
assert const_name not in signature.inputs_to_parameters
assert const_name not in signature.inputs_to_buffers
assert const_name not in signature.inputs_to_lifted_tensor_constants
assert len(transformed.state_dict) == 0
assert len(transformed.constants) == 0
assert transformed.module()(torch.zeros(1)) == 0


def test_delete_inside_a_pass_parameter():
_test_delete_inside_a_pass(InputKind.PARAMETER)


def test_delete_inside_a_pass_persistent_buffer():
_test_delete_inside_a_pass(InputKind.BUFFER, True)


def test_delete_inside_a_pass_non_persistent_buffer():
_test_delete_inside_a_pass(InputKind.BUFFER, False)


def test_delete_inside_a_pass_constant_tensor():
_test_delete_inside_a_pass(InputKind.CONSTANT_TENSOR)
14 changes: 4 additions & 10 deletions backends/transforms/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,16 +456,10 @@ def delete_constant_placeholder(exp_program: ExportedProgram, node: torch.fx.Nod
f"Cannot delete input node {node.name} since it is not a parameter, a buffer, nor a lifted tensor constant."
)

# Remove input from graph signature
input_specs = [
spec
for spec in exp_program.graph_signature.input_specs
if spec.arg.name != node.name
]
new_graph_signature = ExportGraphSignature(
input_specs, exp_program.graph_signature.output_specs
)
exp_program._graph_signature = new_graph_signature
# Edit the spec list in place: ExportedProgramPassManager runs passes on a
# shallow copy of the program, which would not see a replaced signature.
input_specs = exp_program.graph_signature.input_specs
input_specs[:] = [spec for spec in input_specs if spec.arg.name != node.name]

# Copies made by duplicate_constant_node keep the original target, so keep
# the tensor while another placeholder still refers to it.
Expand Down
8 changes: 6 additions & 2 deletions backends/vulkan/custom_ops_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,12 @@ def linear_q4gsw(
# Unpack the packed weights
weights = unpack_4bit_weight_tensor(weights, x)

# Un-transpose the weight scales
weight_scales = weight_scales.transpose(0, 1)
# Un-transpose the weight scales. The scales and the bias are padded to a
# multiple of 4 output channels; the packed weights are not.
out_channels = weights.shape[0]
weight_scales = weight_scales.transpose(0, 1)[:out_channels]
if bias is not None:
bias = bias[:out_channels]
weight_zeros = torch.zeros_like(weight_scales, dtype=torch.int32)

weights = torch.ops.torchao.dequantize_affine(
Expand Down
13 changes: 0 additions & 13 deletions backends/vulkan/op_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,19 +568,6 @@ def register_quantizedequantize_cpp_ops():
)


@update_features(
[
exir_ops.edge.torchao.quantize_affine.default,
exir_ops.edge.torchao.dequantize_affine.default,
]
)
def register_torchao_quantize_dequantize():
return OpFeatures(
inputs_storage=utils.CONTIGUOUS_BUFFER,
supports_resize=True,
)


# =============================================================================
# Q8taQuantizeDequantize.cpp
# =============================================================================
Expand Down
36 changes: 35 additions & 1 deletion backends/vulkan/patterns/quantized_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def __init__(self, mm_node: torch.fx.Node) -> None: # noqa: C901
self.input_scales_node
):
self.quantize_input_node = input_to_dq_node
self.all_nodes.append(self.quantize_input_node)
self.pattern_input_node = self.quantize_input_node.args[0]

# The implementation has a limitation that input channels must be a
Expand Down Expand Up @@ -267,6 +268,39 @@ def is_weight_perchannel_quantized(self) -> bool:
# scales should have same size as weight's output channels dim
return scales_shape[0] == weight_shape[-2]

def is_weight_4bit_quantized(self) -> bool:
# Read the quant range from the dequantize node's arguments, since the
# detector has no access to the weight data.
node = self.dequantize_weight_node
arg_names = [arg.name for arg in node.target._schema.arguments]
args = {**dict(zip(arg_names, node.args)), **node.kwargs}
quant_min, quant_max = args.get("quant_min"), args.get("quant_max")
if quant_min is None or quant_max is None:
return False
return quant_min >= -8 and quant_max <= 7

def has_fused_op(self) -> bool:
"""Whether this match becomes an op the runtime can run, through
replace_quantized_linear_patterns or FuseQuantizedOpsTransform. The
partitioner delegates every node of a match, so a match without one
would leave its dequantize nodes, which have no Vulkan implementation,
in the delegate."""
if self.is_input_static_per_tensor_quantized():
return self.is_weight_perchannel_quantized()
if self.is_weight_only_quantized() and utils.is_dequant_per_channel_node(
self.dequantize_weight_node
):
# FuseQuantizedOpsTransform: _weight_int8pack_mm or linear_qcs4w.
return self.is_weight_perchannel_quantized()
if not (
self.is_weight_pergroup_quantized() and self.is_weight_4bit_quantized()
):
return False
if self.is_weight_only_quantized():
# linear_q4gsw packs the weights in blocks of 4 output channels.
return self.weight_node.meta["val"].shape[-2] % 4 == 0
return self.is_input_dynamic_perchannel_quantized()

def is_input_static_per_tensor_quantized(self) -> bool:
if self.dequantize_input_node is None:
return False
Expand Down Expand Up @@ -313,7 +347,7 @@ def find_quantized_linear_patterns(
return None

matched_pattern = QuantizedLinearMatch(node)
if matched_pattern.match_found:
if matched_pattern.match_found and matched_pattern.has_fused_op():
return matched_pattern

return None
Expand Down
Loading