Skip to content

Commit 8449c90

Browse files
authored
Add source transform hooks to ExportRecipe (pytorch#21747) (pytorch#21747)
Summary: Add recipe-configurable source transforms and per-method pre-trace hooks. Source transforms run once per distinct model, including shared models, while pre-trace hooks run immediately before each method is exported. Recipe combination preserves hooks and in-place behavior without breaking existing positional arguments. Differential Revision: D115602123
1 parent 3aefd01 commit 8449c90

5 files changed

Lines changed: 366 additions & 44 deletions

File tree

‎export/export.py‎

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

8+
from __future__ import annotations
9+
810
import logging
911
import time
1012
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
@@ -306,18 +308,21 @@ def _build_stages(self, stages: List[StageType]) -> Dict[StageType, Stage]:
306308
if stage_type == StageType.SOURCE_TRANSFORM:
307309
stage = SourceTransformStage(
308310
self._quant_recipe,
311+
source_transform_passes=list(
312+
self._export_recipe.source_transform_passes or ()
313+
),
309314
in_place=self._export_recipe.source_transform_in_place,
310315
)
311316
elif stage_type == StageType.QUANTIZE:
312-
stage = QuantizeStage(self._quant_recipe)
317+
stage = QuantizeStage(
318+
self._quant_recipe,
319+
pre_trace_hooks=list(self._export_recipe.pre_trace_hooks or ()),
320+
)
313321
elif stage_type == StageType.TORCH_EXPORT:
314-
aten_transform_passes = None
315-
if self._export_recipe.aten_transform_passes is not None:
316-
aten_transform_passes = list(
317-
self._export_recipe.aten_transform_passes
318-
)
319322
stage = TorchExportStage(
320-
aten_transform_passes, strict=self._export_recipe.strict
323+
list(self._export_recipe.aten_transform_passes or ()),
324+
strict=self._export_recipe.strict,
325+
pre_trace_hooks=list(self._export_recipe.pre_trace_hooks or ()),
321326
)
322327
elif stage_type == StageType.TO_EDGE_TRANSFORM_AND_LOWER:
323328
stage = EdgeTransformAndLowerStage.from_recipe(self._lowering_recipe)

‎export/recipe.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
# This source code is licensed under the BSD-style license found in the
77
# LICENSE file in the root directory of this source tree.
88

9+
from __future__ import annotations
10+
911
import copy
1012
import dataclasses
1113
import logging
@@ -239,6 +241,8 @@ class _CombineAccumulator:
239241
quantizers: list = field(default_factory=list)
240242
ao_quantization_configs: list = field(default_factory=list)
241243
pre_edge_passes: list = field(default_factory=list)
244+
source_transform_passes: list = field(default_factory=list)
245+
pre_trace_hooks: list = field(default_factory=list)
242246
edge_transform_passes: list = field(default_factory=list)
243247
edge_manager_transform_passes: list = field(default_factory=list)
244248
pre_prepare_passes: list = field(default_factory=list)
@@ -273,6 +277,21 @@ class ExportRecipe:
273277
quantization_recipe: Optional quantization recipe for model quantization
274278
aten_transform_passes: Optional list of functions to apply transformation passes to the program before edge lowering.
275279
These callables are invoked to modify and return the transformed program.
280+
source_transform_passes: Optional list of nn.Module transforms applied once
281+
during the SOURCE_TRANSFORM stage, before quantization.
282+
Each is applied once per distinct model object, so a
283+
model shared by several methods is transformed once.
284+
Each pass must return the nn.Module to use, even when
285+
it mutates its input in place.
286+
pre_trace_hooks: Optional list of (method_name, model) callables invoked
287+
before each method's first trace: in QUANTIZE for PT2E,
288+
otherwise in TORCH_EXPORT.
289+
Hooks may only change Python configuration, such as
290+
kernel selection, never tensor state or module structure.
291+
Tensor storage remains shared. Configuration persists
292+
on shared models, so hooks must overwrite the settings
293+
they own for each method. Return values are ignored.
294+
Hooks are not repeated on converted PT2E graphs.
276295
source_transform_in_place: Skip the defensive deepcopy in the SOURCE_TRANSFORM
277296
stage and mutate the caller's model. Necessary for models
278297
large enough that a second copy will not fit in memory.
@@ -295,6 +314,10 @@ class ExportRecipe:
295314
pipeline_stages: Optional[List[StageType]] = None
296315
mode: Mode = Mode.RELEASE
297316
strict: bool = True
317+
source_transform_passes: Optional[
318+
List[Callable[[torch.nn.Module], torch.nn.Module]]
319+
] = None
320+
pre_trace_hooks: Optional[List[Callable[[str, torch.nn.Module], None]]] = None
298321

299322
@classmethod
300323
def get_recipe(cls, recipe: "RecipeType", **kwargs) -> "ExportRecipe":
@@ -573,6 +596,10 @@ def _collect_recipe_fields(
573596
for recipe in backend_recipes:
574597
if recipe.aten_transform_passes:
575598
acc.pre_edge_passes.extend(recipe.aten_transform_passes)
599+
if recipe.source_transform_passes:
600+
acc.source_transform_passes.extend(recipe.source_transform_passes)
601+
if recipe.pre_trace_hooks:
602+
acc.pre_trace_hooks.extend(recipe.pre_trace_hooks)
576603

577604
if lr := recipe.lowering_recipe:
578605
cls._collect_lowering_fields(acc, lr)
@@ -652,6 +679,8 @@ def _combine_recipes(
652679
name=recipe_name,
653680
quantization_recipe=combined_quantization_recipe,
654681
aten_transform_passes=acc.pre_edge_passes or None,
682+
source_transform_passes=acc.source_transform_passes or None,
683+
pre_trace_hooks=acc.pre_trace_hooks or None,
655684
lowering_recipe=combined_lowering_recipe,
656685
executorch_backend_config=acc.backend_config,
657686
pipeline_stages=shared_pipeline_stages,

‎export/stages.py‎

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
# This source code is licensed under the BSD-style license found in the
77
# LICENSE file in the root directory of this source tree.
88

9+
from __future__ import annotations
10+
911
import copy
1012
import logging
1113
from abc import ABC, abstractmethod
@@ -42,7 +44,7 @@
4244

4345

4446
def _drop_empty(
45-
passes_by_method: Dict[str, List[PassType]]
47+
passes_by_method: Dict[str, List[PassType]],
4648
) -> Dict[str, List[PassType]]:
4749
return {method: p for method, p in passes_by_method.items() if p}
4850

@@ -130,10 +132,12 @@ def __init__(
130132
List[Callable[[str, ExportedProgram], ExportedProgram]]
131133
] = None,
132134
strict=True,
135+
pre_trace_hooks: Optional[List[Callable[[str, nn.Module], None]]] = None,
133136
) -> None:
134137
super().__init__()
135138
self._aten_transform_passes = aten_transform_passes
136139
self.strict = strict
140+
self._pre_trace_hooks = pre_trace_hooks
137141

138142
@property
139143
def stage_type(self) -> str:
@@ -151,6 +155,11 @@ def run(self, artifact: PipelineArtifact) -> None:
151155
models = artifact.data
152156
example_inputs = artifact.get_context("example_inputs")
153157
dynamic_shapes = artifact.get_context("dynamic_shapes", {})
158+
pre_trace_hooks = (
159+
None
160+
if artifact.get_context("pre_trace_hooks_applied", False)
161+
else self._pre_trace_hooks
162+
)
154163

155164
exported_programs = {}
156165

@@ -163,6 +172,9 @@ def run(self, artifact: PipelineArtifact) -> None:
163172

164173
method_dynamic_shapes = dynamic_shapes.get(method_name)
165174

175+
for hook in pre_trace_hooks or []:
176+
hook(method_name, model)
177+
166178
# Export the model
167179
exported_programs[method_name] = torch.export.export(
168180
model,
@@ -373,15 +385,21 @@ def run(self, artifact: PipelineArtifact) -> None:
373385

374386
class SourceTransformStage(Stage):
375387
"""
376-
Optional stage: Source transform stage: Apply source transformations to the model.
388+
Apply source transformations and optional AO quantization to the model.
389+
390+
Each source transform must return the nn.Module to use.
377391
"""
378392

379393
def __init__(
380394
self,
381395
quantization_recipe: Optional[QuantizationRecipe],
382396
in_place: bool = False,
397+
source_transform_passes: Optional[
398+
List[Callable[[nn.Module], nn.Module]]
399+
] = None,
383400
) -> None:
384401
self._quantization_recipe = quantization_recipe
402+
self._source_transform_passes = source_transform_passes
385403
self._in_place = in_place
386404
self._transformed_models: Dict[str, nn.Module] = {}
387405

@@ -401,46 +419,67 @@ def run(self, artifact: PipelineArtifact) -> None:
401419
"""
402420
Apply source transformations to the model.
403421
"""
404-
if (
405-
not self._quantization_recipe
406-
or not self._quantization_recipe.ao_quantization_configs
407-
):
422+
ao_configs = (
423+
self._quantization_recipe.ao_quantization_configs
424+
if self._quantization_recipe
425+
else None
426+
)
427+
if not ao_configs and not self._source_transform_passes:
408428
logging.info(
409-
"Quantization recipe is invalid to run SourceTransform, returning original artifact"
429+
"No source transform passes and no AO quantization configs for SourceTransformStage, returning original artifact"
410430
)
411431
self._artifact = artifact
412432
return
413433

414434
assert isinstance(artifact.data, dict)
415435

436+
if ao_configs and len(ao_configs) > 1:
437+
raise ValueError(
438+
"AO quantization configs cannot be reliably composed together, multiple quantization configs are disallowed for source transform at this point"
439+
)
440+
416441
# A second copy of the model is not affordable for every caller, so
417442
# large models can opt out and have their own model mutated instead.
418-
self._transformed_models = (
419-
artifact.data if self._in_place else copy.deepcopy(artifact.data)
420-
)
421-
422-
# Apply torchao quantize_ to each model
423-
for _, model in self._transformed_models.items():
424-
# pyre-ignore
425-
if len(self._quantization_recipe.ao_quantization_configs) > 1:
426-
raise ValueError(
427-
"AO quantization configs cannot be reliably composed together, multiple quantization configs are disallowed for source transform at this point"
428-
)
429-
430-
ao_config = self._quantization_recipe.ao_quantization_configs[0]
431-
quantize_(model, ao_config.ao_base_config, ao_config.filter_fn)
432-
unwrap_tensor_subclass(model)
443+
models = artifact.data if self._in_place else copy.deepcopy(artifact.data)
444+
445+
# Several methods commonly share one model object. Transform each
446+
# distinct object once, or a shared model is quantized once per method.
447+
transformed_by_id: Dict[int, nn.Module] = {}
448+
for method_name, model in list(models.items()):
449+
original_id = id(model)
450+
if original_id not in transformed_by_id:
451+
for transform in self._source_transform_passes or []:
452+
if not callable(transform):
453+
raise TypeError("Source transform passes must be callable")
454+
model = transform(model)
455+
if not isinstance(model, nn.Module):
456+
raise TypeError(
457+
f"Source transform {transform!r} must return an nn.Module, "
458+
f"got {type(model).__name__}"
459+
)
460+
if ao_configs:
461+
ao_config = ao_configs[0]
462+
quantize_(model, ao_config.ao_base_config, ao_config.filter_fn)
463+
unwrap_tensor_subclass(model)
464+
transformed_by_id[original_id] = model
465+
models[method_name] = transformed_by_id[original_id]
433466

434-
self._artifact = artifact.copy_with_new_data(self._transformed_models)
467+
self._transformed_models = models
468+
self._artifact = artifact.copy_with_new_data(models)
435469

436470

437471
class QuantizeStage(Stage):
438472
"""
439473
Optional stage: Perform post-training quantization on the model.
440474
"""
441475

442-
def __init__(self, quantization_recipe: Optional[QuantizationRecipe]) -> None:
476+
def __init__(
477+
self,
478+
quantization_recipe: Optional[QuantizationRecipe],
479+
pre_trace_hooks: Optional[List[Callable[[str, nn.Module], None]]] = None,
480+
) -> None:
443481
self._quantization_recipe = quantization_recipe
482+
self._pre_trace_hooks = pre_trace_hooks
444483

445484
@property
446485
def stage_type(self) -> str:
@@ -539,6 +578,10 @@ def run(self, artifact: PipelineArtifact) -> None:
539578
if recipe.is_qat:
540579
model.train()
541580

581+
with torch.no_grad():
582+
for hook in self._pre_trace_hooks or []:
583+
hook(method_name, model)
584+
542585
captured_graph = torch.export.export(
543586
model, inputs, dynamic_shapes=export_dynamic_shapes, strict=True
544587
).module()
@@ -596,6 +639,9 @@ def run(self, artifact: PipelineArtifact) -> None:
596639
quantized_models[method_name] = quantized_model
597640

598641
self._artifact = artifact.copy_with_new_data(quantized_models)
642+
self._artifact.add_context(
643+
"pre_trace_hooks_applied", bool(self._pre_trace_hooks)
644+
)
599645

600646

601647
class ToEdgeStage(Stage):

‎export/tests/test_export_recipe.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,44 @@ def test_combine_keeps_edge_transform_passes(self) -> None:
341341
combined.lowering_recipe.edge_transform_passes, [first, second]
342342
)
343343

344+
def test_new_hooks_do_not_change_existing_positional_arguments(self) -> None:
345+
recipe = ExportRecipe(None, None, None, True)
346+
347+
self.assertTrue(recipe.source_transform_in_place)
348+
self.assertIsNone(recipe.source_transform_passes)
349+
self.assertIsNone(recipe.pre_trace_hooks)
350+
351+
def test_preserves_source_transform_passes_and_pre_trace_hooks(self) -> None:
352+
first_source_transform = Mock()
353+
second_source_transform = Mock()
354+
first_pre_trace_hook = Mock()
355+
second_pre_trace_hook = Mock()
356+
357+
combined = ExportRecipe.combine(
358+
[
359+
ExportRecipe(
360+
source_transform_passes=[first_source_transform],
361+
pre_trace_hooks=[first_pre_trace_hook],
362+
source_transform_in_place=True,
363+
),
364+
ExportRecipe(
365+
source_transform_passes=[second_source_transform],
366+
pre_trace_hooks=[second_pre_trace_hook],
367+
source_transform_in_place=True,
368+
),
369+
]
370+
)
371+
372+
self.assertEqual(
373+
combined.source_transform_passes,
374+
[first_source_transform, second_source_transform],
375+
)
376+
self.assertEqual(
377+
combined.pre_trace_hooks,
378+
[first_pre_trace_hook, second_pre_trace_hook],
379+
)
380+
self.assertTrue(combined.source_transform_in_place)
381+
344382

345383
# ---------------------------------------------------------------------------
346384
# Helpers shared by combine-recipe tests

0 commit comments

Comments
 (0)