Skip to content

Commit 91bdd02

Browse files
committed
Merge branch 'main' of github.com:pytorch/executorch into HEAD
2 parents 4b0c516 + 55c54c7 commit 91bdd02

193 files changed

Lines changed: 10300 additions & 1203 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.ci/scripts/export_model_artifact.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,8 +422,9 @@ if [ "$MODEL_NAME" = "qwen3_5_moe" ]; then
422422
--no-compile
423423
echo "::endgroup::"
424424

425-
# Copy tokenizer for the runner
425+
# Copy tokenizer files for the runner and model-specific serving launcher.
426426
cp "$LOCAL_MODEL_DIR/tokenizer.json" "${OUTPUT_DIR}/tokenizer.json"
427+
cp "$LOCAL_MODEL_DIR/tokenizer_config.json" "${OUTPUT_DIR}/tokenizer_config.json"
427428

428429
# Export to .pte/.ptd (short cache dir avoids objcopy symbol length issues)
429430
echo "::group::Export"

.ci/scripts/test_model_e2e.sh

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,4 +447,105 @@ case "$MODEL_NAME" in
447447
esac
448448
echo "::endgroup::"
449449

450+
if [ "$DEVICE" = "cuda" ] && [ "$MODEL_NAME" = "qwen3_5_moe" ]; then
451+
echo "::group::Run $MODEL_NAME OpenAI serving smoke"
452+
pip install -r examples/llm_server/python/requirements.txt "transformers==5.0.0rc1"
453+
python -m pip install --no-deps --no-build-isolation --editable . -v
454+
455+
PORT=$(python - <<'PY'
456+
import socket
457+
458+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
459+
s.bind(("127.0.0.1", 0))
460+
print(s.getsockname()[1])
461+
PY
462+
)
463+
SERVER_LOG=$(mktemp)
464+
WORKER_BIN="cmake-out/examples/models/qwen3_5_moe/qwen3_5_moe_worker"
465+
python -u -m executorch.examples.models.qwen3_5_moe.serve \
466+
--model-path "${MODEL_DIR}/model.pte" \
467+
--data-path "${MODEL_DIR}/aoti_cuda_blob.ptd" \
468+
--tokenizer-path "${MODEL_DIR}/tokenizer.json" \
469+
--hf-tokenizer "${MODEL_DIR}" \
470+
--model-id qwen3.5-moe \
471+
--max-context 4096 \
472+
--max-sessions 2 \
473+
--no-think \
474+
--worker-bin "$WORKER_BIN" \
475+
--host 127.0.0.1 \
476+
--port "$PORT" >"$SERVER_LOG" 2>&1 &
477+
SERVER_PID=$!
478+
479+
cleanup_qwen_server() {
480+
if kill -0 "$SERVER_PID" 2>/dev/null; then
481+
kill "$SERVER_PID" 2>/dev/null || true
482+
wait "$SERVER_PID" 2>/dev/null || true
483+
fi
484+
rm -f "$SERVER_LOG"
485+
}
486+
trap cleanup_qwen_server EXIT
487+
488+
if ! python - "$PORT" "$SERVER_LOG" <<'PY'
489+
import json
490+
import sys
491+
import time
492+
import urllib.request
493+
494+
port = sys.argv[1]
495+
log_path = sys.argv[2]
496+
base = f"http://127.0.0.1:{port}"
497+
498+
499+
def request(path, payload=None):
500+
data = None
501+
headers = {}
502+
if payload is not None:
503+
data = json.dumps(payload).encode("utf-8")
504+
headers["Content-Type"] = "application/json"
505+
req = urllib.request.Request(base + path, data=data, headers=headers)
506+
with urllib.request.urlopen(req, timeout=120) as resp:
507+
return json.loads(resp.read().decode("utf-8"))
508+
509+
510+
last = None
511+
for _ in range(180):
512+
try:
513+
request("/health")
514+
break
515+
except Exception as e:
516+
last = e
517+
time.sleep(1)
518+
else:
519+
print(open(log_path, encoding="utf-8", errors="replace").read())
520+
raise RuntimeError(f"server did not become healthy: {last}")
521+
522+
models = request("/v1/models")
523+
ids = {m["id"] for m in models["data"]}
524+
if "qwen3.5-moe" not in ids:
525+
raise AssertionError(f"qwen3.5-moe missing from /v1/models: {ids}")
526+
527+
body = {
528+
"model": "qwen3.5-moe",
529+
"messages": [{"role": "user", "content": "What is the capital of France?"}],
530+
"max_tokens": 32,
531+
"temperature": 0,
532+
}
533+
resp = request("/v1/chat/completions", body)
534+
content = resp["choices"][0]["message"].get("content") or ""
535+
if "Paris" not in content:
536+
raise AssertionError(f"expected Paris in serving response, got: {content!r}")
537+
538+
print("Qwen3.5-MoE serving smoke passed")
539+
PY
540+
then
541+
echo "Qwen3.5-MoE serving smoke failed; server log:"
542+
cat "$SERVER_LOG"
543+
exit 1
544+
fi
545+
546+
cleanup_qwen_server
547+
trap - EXIT
548+
echo "::endgroup::"
549+
fi
550+
450551
popd

.claude/skills/qualcomm/new_op_development.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,17 @@ class DecomposeMyOp(ExportPass):
217217

218218
### Registration (all decompose passes)
219219
1. `_passes/__init__.py` — import + `__all__`
220-
2. `_passes/qnn_pass_manager.py` — import + `transform_for_annotation_pipeline` + `transform_for_export_pipeline` + `get_capture_program_passes`
221-
3. `_passes/utils.py` — add to `get_passes_dependency_for_capture_program()` with `[RemoveRedundancy]` dependency
220+
2. `_passes/qnn_pass_manager.py` — The pass manager uses classmethods for pipeline definitions:
221+
- **Import** — add to the import block at top of file
222+
- **`get_annotation_passes()`** — add pass class to the returned list (runs before quantizer, ATen IR)
223+
- **`get_export_passes()`** — add pass class if needed for float-only path (runs after quantization, before to-edge)
224+
- **`get_default_pass_activations()`** — add `(PassClass, True)` ONLY if the pass also needs to run in the to-edge pipeline
225+
- **`get_passes_dependency_for_capture_program()`** — add `PassClass: [RemoveRedundancy]` dependency ONLY if also in `get_default_pass_activations`
226+
227+
**When to add to which pipeline:**
228+
- **Annotation only** (most common for decompose passes): `get_annotation_passes()` — pass decomposes the op before the quantizer sees it
229+
- **Export pipeline** too: if the float-only test fails without it (op doesn't get handled by PyTorch's built-in decomposition during to-edge)
230+
- **Capture program** (to-edge) too: if the op can appear in edge dialect and needs decomposition there (e.g., `DecomposeVar`, `DecomposeCDist`, `DecomposeDiagonal`)
222231

223232
---
224233

@@ -255,4 +264,4 @@ class DecomposeMyOp(ExportPass):
255264

256265
**Native QNN Op:** `qnn_constants.py``op_my_op.py``builders/__init__.py``htp_rules.py``lpai_rules.py``layout_transform.py``tests/models.py``test_qnn_delegate.py``partition/utils.py` (skip decomp) → `common_defs.py` (remove to_be_implemented) → `builders/README.md`
257266

258-
**Decompose Pass:** `_passes/decompose_my_op.py``_passes/__init__.py``qnn_pass_manager.py` (annotation + export + capture) → `_passes/utils.py` (dependency) → `tests/models.py``test_qnn_delegate.py``common_defs.py``builders/README.md`
267+
**Decompose Pass:** `_passes/decompose_my_op.py``_passes/__init__.py``qnn_pass_manager.py` (`get_annotation_passes` + optionally `get_export_passes`; if also needed in to-edge: `get_default_pass_activations` + `get_passes_dependency_for_capture_program`) → `tests/models.py``test_qnn_delegate.py``common_defs.py``builders/README.md`

.flake8

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ exclude =
7575
./configurations,
7676
./docs,
7777
./exir/_serialize/generated/executorch_flatbuffer,
78+
./devtools/bundled_program/serialize/generated,
7879
./third_party,
7980
*.pyi
8081

.github/workflows/validate_flatbuffer_gen.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ on:
55
pull_request:
66
paths:
77
- "schema/**"
8-
- "exir/_serialize/generated/executorch_flatbuffer/**"
8+
- "devtools/bundled_program/schema/**"
9+
- "exir/_serialize/generated/**"
10+
- "devtools/bundled_program/serialize/generated/**"
911

1012
jobs:
1113
exir-flatbuffer:
@@ -33,3 +35,15 @@ jobs:
3335
echo "Please run 'python exir/_serialize/generate_program.py' to regenerate the files and commit the changes."
3436
exit 1
3537
fi
38+
39+
- name: Generate bundled program flatbuffer Python
40+
run: python devtools/bundled_program/serialize/generate_bundled_program.py
41+
42+
- name: Validate bundled_program_flatbuffer is unchanged
43+
run: |
44+
git add -A devtools/bundled_program/serialize/generated
45+
if ! git diff --cached --quiet -- devtools/bundled_program/serialize/generated; then
46+
echo "Error: bundled_program_flatbuffer has uncommitted changes."
47+
echo "Please run 'python devtools/bundled_program/serialize/generate_bundled_program.py' to regenerate the files and commit the changes."
48+
exit 1
49+
fi

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ arm-scratch/
2626
executorch.egg-info
2727
pip-out/
2828
build-profiling/
29+
**/ddr_*_temp
2930

3031
# Any exported models and profiling outputs
3132
*.bin

.lintrunner.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ exclude_patterns = [
99
'.github/scripts/**',
1010
'exir/serde/**',
1111
'exir/_serialize/generated/executorch_flatbuffer/**',
12+
'devtools/bundled_program/serialize/generated/**',
1213
]
1314
command = [
1415
'python',
@@ -41,6 +42,7 @@ exclude_patterns = [
4142
'**/third-party/**',
4243
'exir/serde/**',
4344
'exir/_serialize/generated/executorch_flatbuffer/**',
45+
'devtools/bundled_program/serialize/generated/**',
4446
]
4547
command = [
4648
'python',
@@ -389,6 +391,7 @@ exclude_patterns = [
389391
'**/*.gif',
390392
'extension/llm/tokenizers',
391393
'extension/llm/tokenizers/**',
394+
'examples/llm_server',
392395
'backends/cadence/utils/FACTO',
393396
'examples/cuda',
394397
'examples/qualcomm',

backends/aoti/aoti_partitioner.py

Lines changed: 67 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
from typing import Callable, Dict, List, Optional, Tuple
7+
from typing import Callable, Dict, List, Mapping, Optional, Tuple
88

99
import torch
1010
from executorch.exir._warnings import experimental
@@ -21,6 +21,8 @@
2121
)
2222
from torch._export.utils import is_buffer, is_lifted_tensor_constant, is_param
2323
from torch.export.exported_program import ExportedProgram
24+
from torch.fx.passes.infra.partitioner import CapabilityBasedPartitioner
25+
from torch.fx.passes.operator_support import OperatorSupportBase
2426

2527

2628
@experimental(
@@ -30,12 +32,10 @@ class AotiPartitioner(Partitioner):
3032
"""
3133
Base partitioner for AOTInductor-driven backend integration.
3234
33-
This partitioner creates a single partition containing all operators from the input graph.
34-
It skips core ATen decomposition, allowing the backend to handle decomposition using
35+
Delegates the non-lowered operators to AOTInductor as one or more convex
36+
partitions (a single partition when nothing else has claimed part of the
37+
graph). It skips core ATen decomposition, letting the backend decompose via
3538
AOTInductor's backend-specific decomposition table.
36-
37-
Only operators that cannot be handled by the aoti library will be excluded from
38-
the partition and fall back to ExecuTorch's default or custom handling.
3939
"""
4040

4141
def __init__(self, backend_name: str, compile_spec: List[CompileSpec]) -> None:
@@ -49,62 +49,76 @@ def __init__(self, backend_name: str, compile_spec: List[CompileSpec]) -> None:
4949
self.delegation_spec = DelegationSpec(backend_name, compile_spec)
5050

5151
def partition(self, exported_program: ExportedProgram) -> PartitionResult:
52-
"""
53-
Fully delegate the graph to AOTInductor by tagging all nodes as a single partition.
54-
"""
52+
"""Delegate the non-lowered ops to AOTInductor.
5553
56-
partition_tags: Dict[str, DelegationSpec] = {}
57-
tag = "tag0"
58-
59-
# Tag torch.cond and other control flow operations
60-
def is_control_flow(node: torch.fx.Node) -> bool:
61-
return node.op == "call_function" and node.target in [
62-
torch.ops.higher_order.cond,
63-
torch.ops.higher_order.map_impl,
64-
torch.ops.higher_order.while_loop,
65-
]
66-
67-
# Nodes already lowered by an earlier partitioner (e.g. a preceding
68-
# TensorRT partition) appear as executorch_call_delegate calls and their
69-
# output getitems; re-delegating them would nest a foreign delegate. Tag
70-
# only the remaining non-lowered ops so this partitioner composes after
71-
# others.
54+
Uses CapabilityBasedPartitioner rather than a single tag because a
55+
delegated submodule must be convex: if a node that is not delegated sits
56+
between the delegated ops, one tag would span a non-convex set and fusion
57+
would fail with a dependency cycle.
58+
"""
59+
# Only nodes not already lowered are candidates for this backend.
7260
non_lowered_nodes = set(get_non_lowered_nodes(exported_program.graph))
7361

74-
for node in exported_program.graph.nodes:
75-
if node.op == "call_function":
76-
if node not in non_lowered_nodes:
77-
continue
62+
control_flow_targets = [
63+
torch.ops.higher_order.cond,
64+
torch.ops.higher_order.map_impl,
65+
torch.ops.higher_order.while_loop,
66+
torch.ops.higher_order.scan,
67+
]
68+
69+
class AotiOperatorSupport(OperatorSupportBase):
70+
def is_node_supported(
71+
self, submodules: Mapping[str, torch.nn.Module], node: torch.fx.Node
72+
) -> bool:
73+
return node.op == "call_function" and node in non_lowered_nodes
74+
75+
partitioner = CapabilityBasedPartitioner(
76+
exported_program.graph_module,
77+
AotiOperatorSupport(),
78+
allows_single_node_partition=True,
79+
)
80+
81+
partition_tags: Dict[str, DelegationSpec] = {}
82+
for partition in partitioner.propose_partitions():
83+
tag = f"aoti_{partition.id}"
84+
partition_tags[tag] = self.delegation_spec
85+
for node in partition.nodes:
7886
node.meta["delegation_tag"] = tag
79-
# Tag get_attr nodes that are used by control flow operations
80-
elif node.op == "get_attr":
81-
# Check if any user is a control flow operation
82-
for user in node.users:
83-
if is_control_flow(user):
84-
node.meta["delegation_tag"] = tag
85-
break
8687

87-
partition_tags[tag] = self.delegation_spec
88+
# A control-flow op carries its branch GraphModules as get_attr operands;
89+
# they must share the op's tag so they land inside the same submodule. A
90+
# branch module feeds a single control-flow op, so first match wins.
91+
for node in exported_program.graph.nodes:
92+
if node.op != "get_attr":
93+
continue
94+
for user in node.users:
95+
if (
96+
user.op == "call_function"
97+
and user.target in control_flow_targets
98+
and "delegation_tag" in user.meta
99+
):
100+
node.meta["delegation_tag"] = user.meta["delegation_tag"]
101+
break
88102

89103
tag_constant_data(exported_program)
90104
tag_mutated_buffer(exported_program)
91105

92-
# A constant that still has users feeds only a prior delegate; tagging it
93-
# would fail backend lowering's same-tag check (its user keeps the prior
94-
# tag). tag_constant_data already claimed the ones this partition uses, so
95-
# tag only the genuinely unused constants here.
96-
for node in exported_program.graph.nodes:
97-
if (
98-
node.op == "placeholder"
99-
and not node.users
100-
and "delegation_tag" not in node.meta
101-
and (
102-
is_param(exported_program, node)
103-
or is_buffer(exported_program, node)
104-
or is_lifted_tensor_constant(exported_program, node)
105-
)
106-
):
107-
node.meta["delegation_tag"] = tag
106+
# tag_constant_data only tags constants that have users; tag the
107+
# genuinely unused ones too so none are left dangling.
108+
if partition_tags:
109+
fallback_tag = next(iter(partition_tags))
110+
for node in exported_program.graph.nodes:
111+
if (
112+
node.op == "placeholder"
113+
and not node.users
114+
and "delegation_tag" not in node.meta
115+
and (
116+
is_param(exported_program, node)
117+
or is_buffer(exported_program, node)
118+
or is_lifted_tensor_constant(exported_program, node)
119+
)
120+
):
121+
node.meta["delegation_tag"] = fallback_tag
108122

109123
return PartitionResult(
110124
tagged_exported_program=exported_program, partition_tags=partition_tags

backends/arm/_passes/fuse_constant_ops_pass.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,10 @@ def call(self, graph_module):
174174
for node in graph_module.graph.nodes:
175175
if node.op != "call_function":
176176
continue
177-
# Don't fuse TOSA dialect ops as they do not have eager forward functions.
178-
# Also don't fuse ops whose explicit args/kwargs include symbolic shape values.
179-
if (
180-
self._is_tosa_dialect_op(node.target)
181-
or self._arg_contains_symbolic_shape(node.args)
182-
or self._arg_contains_symbolic_shape(node.kwargs)
183-
):
177+
# Don't fuse ops whose explicit args/kwargs include symbolic shape values.
178+
if self._arg_contains_symbolic_shape(
179+
node.args
180+
) or self._arg_contains_symbolic_shape(node.kwargs):
184181
continue
185182

186183
input_nodes = node.all_input_nodes

0 commit comments

Comments
 (0)