Skip to content

Commit 4322502

Browse files
authored
Release intermediate export artifacts once they are consumed (pytorch#21748)
Summary: Add an opt-in `release_intermediate_artifacts` recipe setting that drops each stage’s output after the next stage consumes it, reducing peak memory usage for large models. Released artifacts produce descriptive accessor errors, and stages remain reusable across subsequent exports. Recipe combination and positional compatibility are preserved. Authored with Codex. Reviewed By: psiddh Differential Revision: D115607218 Pull Request resolved: pytorch#21748
1 parent 38d7ad0 commit 4322502

6 files changed

Lines changed: 271 additions & 10 deletions

File tree

‎export/export.py‎

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import logging
1111
import time
12-
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
12+
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union
1313

1414
import torch
1515
from executorch.exir import EdgeProgramManager
@@ -235,6 +235,7 @@ def __init__(
235235
}
236236

237237
self._stage_to_artifacts: Dict[StageType, PipelineArtifact] = {}
238+
self._released_stages: Set[StageType] = set()
238239

239240
def _detect_model_type(
240241
self, model: Union[nn.Module, GraphModule, ExportedProgram, Dict]
@@ -452,8 +453,11 @@ def _run_pipeline(self) -> None:
452453
# After validation: a rejected pipeline must not destroy the previous
453454
# run. In place, since get_stage_artifacts() hands out this dict.
454455
self._stage_to_artifacts.clear()
456+
self._released_stages.clear()
455457

456458
current_artifact = PipelineArtifact(data=self._model, context=self._run_context)
459+
release = self._export_recipe.release_intermediate_artifacts
460+
previous_stage_type = None
457461

458462
# Execute stages from registry in the order specified by pipeline_stages
459463
for stage_type in self._pipeline_stages:
@@ -473,6 +477,30 @@ def _run_pipeline(self) -> None:
473477

474478
self._stage_to_artifacts[stage_type] = current_artifact
475479

480+
# The stage that just ran has consumed its input, so the previous
481+
# stage's output is now unreachable except through the two
482+
# references we hold ourselves.
483+
if release and previous_stage_type is not None:
484+
self._release_stage(previous_stage_type)
485+
previous_stage_type = stage_type
486+
487+
def _release_stage(self, stage_type: StageType) -> None:
488+
self._stage_to_artifacts.pop(stage_type, None)
489+
stage = self._stage_registry.get(stage_type)
490+
if stage is not None:
491+
stage.release_artifact()
492+
self._released_stages.add(stage_type)
493+
logging.info(f"Released artifact for stage: {stage_type}")
494+
495+
def _raise_if_released(self, *stage_types: StageType) -> None:
496+
released = [s for s in stage_types if s in self._released_stages]
497+
if released:
498+
names = ", ".join(str(s) for s in released)
499+
raise RuntimeError(
500+
f"Artifact for {names} was released to free memory. Disable "
501+
"release_intermediate_artifacts on the recipe to retain it."
502+
)
503+
476504
def export(self) -> None:
477505
"""
478506
Execute the full export process.
@@ -503,6 +531,7 @@ def get_exported_program(self, method_name: str = "forward") -> ExportedProgram:
503531
RuntimeError: If torch export stage has not been run
504532
KeyError: If the method name is not found in exported programs
505533
"""
534+
self._raise_if_released(StageType.TORCH_EXPORT)
506535
artifact = self._stage_to_artifacts.get(StageType.TORCH_EXPORT)
507536
if artifact is None or artifact.data is None:
508537
raise RuntimeError(
@@ -535,17 +564,19 @@ def get_edge_program_manager(self) -> "EdgeProgramManager":
535564
RuntimeError: If no edge stage has been run
536565
"""
537566
# Check stages in order of preference
538-
for stage_type in [
567+
edge_stages = [
539568
StageType.TO_EDGE_TRANSFORM_AND_LOWER,
540569
StageType.TO_BACKEND,
541570
StageType.EDGE_PROGRAM_MANAGER_TRANSFORM,
542571
StageType.TO_EDGE,
543-
]:
572+
]
573+
for stage_type in edge_stages:
544574
artifact = self._stage_to_artifacts.get(stage_type)
545575
if artifact is not None and artifact.data is not None:
546576
logging.info(f"Returning edge program manager from stage {stage_type}")
547577
return artifact.data
548578

579+
self._raise_if_released(*edge_stages)
549580
raise RuntimeError(
550581
"Edge program manager is not available. "
551582
"Run one of the edge stages first: TO_EDGE_TRANSFORM_AND_LOWER, TO_EDGE, EDGE_PROGRAM_MANAGER_TRANSFORM, or TO_BACKEND."
@@ -681,13 +712,16 @@ def print_delegation_info(self) -> None:
681712
& {StageType.TO_EDGE_TRANSFORM_AND_LOWER, StageType.TO_BACKEND}
682713
)
683714
if not lowering_stage:
684-
RuntimeError(
715+
raise RuntimeError(
685716
"No delegation info available, atleast one of the lowering stages should be present"
686717
)
687718

688719
stage_artifact = self._stage_to_artifacts.get(lowering_stage[0])
689720
if stage_artifact is None:
690-
RuntimeError("No delegation info available, run the lowering stage first")
721+
self._raise_if_released(lowering_stage[0])
722+
raise RuntimeError(
723+
"No delegation info available, run the lowering stage first"
724+
)
691725

692726
delegation_info_by_method = stage_artifact.get_context(
693727
"delegation_info_by_method", None

‎export/recipe.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ class _CombineAccumulator:
257257
mode_values: list = field(default_factory=list)
258258
pipeline_stages_values: list = field(default_factory=list)
259259
source_transform_in_place_values: list = field(default_factory=list)
260+
release_intermediate_artifacts_values: list = field(default_factory=list)
260261
backend_config: object = None
261262
pre_partitioning_callbacks: list = field(default_factory=list)
262263

@@ -295,6 +296,12 @@ class ExportRecipe:
295296
source_transform_in_place: Skip the defensive deepcopy in the SOURCE_TRANSFORM
296297
stage and mutate the caller's model. Necessary for models
297298
large enough that a second copy will not fit in memory.
299+
release_intermediate_artifacts: Drop each stage's output once the next stage
300+
has consumed it, so only the final artifact stays
301+
resident. Necessary for models whose intermediates do
302+
not all fit in memory at once. Makes
303+
get_exported_program(), get_edge_program_manager(),
304+
and print_delegation_info() unavailable after export.
298305
lowering_recipe: Optional lowering recipe for model lowering and partitioning
299306
executorch_backend_config: Optional backend configuration for ExecuTorch
300307
pipeline_stages: Optional list of stages to execute, defaults to a standard pipeline.
@@ -318,6 +325,7 @@ class ExportRecipe:
318325
List[Callable[[torch.nn.Module], torch.nn.Module]]
319326
] = None
320327
pre_trace_hooks: Optional[List[Callable[[str, torch.nn.Module], None]]] = None
328+
release_intermediate_artifacts: bool = False
321329

322330
@classmethod
323331
def get_recipe(cls, recipe: "RecipeType", **kwargs) -> "ExportRecipe":
@@ -615,6 +623,9 @@ def _collect_recipe_fields(
615623
acc.source_transform_in_place_values.append(
616624
recipe.source_transform_in_place
617625
)
626+
acc.release_intermediate_artifacts_values.append(
627+
recipe.release_intermediate_artifacts
628+
)
618629

619630
# Use the executorch_backend_config from the first recipe that supplies one.
620631
if acc.backend_config is None and recipe.executorch_backend_config:
@@ -645,6 +656,10 @@ def _combine_recipes(
645656
cls._assert_scalar_fields_agree(
646657
"source_transform_in_place", acc.source_transform_in_place_values
647658
)
659+
cls._assert_scalar_fields_agree(
660+
"release_intermediate_artifacts",
661+
acc.release_intermediate_artifacts_values,
662+
)
648663

649664
combined_quantization_recipe = cls._combine_quantization_recipe(
650665
is_qat_values=acc.is_qat_values,
@@ -681,6 +696,11 @@ def _combine_recipes(
681696
aten_transform_passes=acc.pre_edge_passes or None,
682697
source_transform_passes=acc.source_transform_passes or None,
683698
pre_trace_hooks=acc.pre_trace_hooks or None,
699+
release_intermediate_artifacts=(
700+
acc.release_intermediate_artifacts_values[0]
701+
if acc.release_intermediate_artifacts_values
702+
else False
703+
),
684704
lowering_recipe=combined_lowering_recipe,
685705
executorch_backend_config=acc.backend_config,
686706
pipeline_stages=shared_pipeline_stages,

‎export/stages.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def __init__(self) -> None:
8181
Initialize the stage.
8282
"""
8383
self._artifact = None
84+
self._artifact_released = False
8485

8586
@property
8687
@abstractmethod
@@ -116,9 +117,26 @@ def run(self, artifact: PipelineArtifact) -> None:
116117
pass
117118

118119
def get_artifacts(self) -> "PipelineArtifact":
119-
if self._artifact is None:
120-
raise RuntimeError(f"Stage: {self.__class__.__name__} not executed")
121-
return self._artifact
120+
if self._artifact is not None:
121+
self._artifact_released = False
122+
return self._artifact
123+
if self._artifact_released:
124+
raise RuntimeError(
125+
f"Stage: {self.__class__.__name__} artifact was released to free "
126+
"memory. Disable release_intermediate_artifacts to retain it."
127+
)
128+
raise RuntimeError(f"Stage: {self.__class__.__name__} not executed")
129+
130+
def release_artifact(self) -> None:
131+
"""
132+
Drop this stage's reference to its output.
133+
134+
The pipeline holds the only other reference, so releasing both lets a
135+
stage's output be collected once the next stage has consumed it.
136+
Subclasses that retain their own references must extend this.
137+
"""
138+
self._artifact = None
139+
self._artifact_released = True
122140

123141

124142
class TorchExportStage(Stage):
@@ -348,6 +366,7 @@ class ExecutorchStage(Stage):
348366
"""
349367

350368
def __init__(self, backend_config: Any) -> None:
369+
super().__init__()
351370
self._backend_config = backend_config
352371

353372
@property
@@ -398,11 +417,16 @@ def __init__(
398417
List[Callable[[nn.Module], nn.Module]]
399418
] = None,
400419
) -> None:
420+
super().__init__()
401421
self._quantization_recipe = quantization_recipe
402422
self._source_transform_passes = source_transform_passes
403423
self._in_place = in_place
404424
self._transformed_models: Dict[str, nn.Module] = {}
405425

426+
def release_artifact(self) -> None:
427+
super().release_artifact()
428+
self._transformed_models = {}
429+
406430
@property
407431
def stage_type(self) -> str:
408432
return StageType.SOURCE_TRANSFORM
@@ -478,6 +502,7 @@ def __init__(
478502
quantization_recipe: Optional[QuantizationRecipe],
479503
pre_trace_hooks: Optional[List[Callable[[str, nn.Module], None]]] = None,
480504
) -> None:
505+
super().__init__()
481506
self._quantization_recipe = quantization_recipe
482507
self._pre_trace_hooks = pre_trace_hooks
483508

‎export/tests/test_export_recipe.py‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,25 @@ def test_new_hooks_do_not_change_existing_positional_arguments(self) -> None:
348348
self.assertIsNone(recipe.source_transform_passes)
349349
self.assertIsNone(recipe.pre_trace_hooks)
350350

351+
def test_release_option_does_not_change_existing_positional_arguments(
352+
self,
353+
) -> None:
354+
lowering_recipe = Mock()
355+
recipe = ExportRecipe(None, None, None, False, lowering_recipe)
356+
357+
self.assertIs(recipe.lowering_recipe, lowering_recipe)
358+
self.assertFalse(recipe.release_intermediate_artifacts)
359+
360+
def test_combine_preserves_release_intermediate_artifacts(self) -> None:
361+
combined = ExportRecipe.combine(
362+
[
363+
ExportRecipe(release_intermediate_artifacts=True),
364+
ExportRecipe(release_intermediate_artifacts=True),
365+
]
366+
)
367+
368+
self.assertTrue(combined.release_intermediate_artifacts)
369+
351370
def test_preserves_source_transform_passes_and_pre_trace_hooks(self) -> None:
352371
first_source_transform = Mock()
353372
second_source_transform = Mock()
@@ -432,17 +451,33 @@ def test_conflicting_source_transform_in_place_raises(self) -> None:
432451
ExportRecipe.combine([r1, r2])
433452
self.assertIn("source_transform_in_place", str(cm.exception))
434453

454+
def test_conflicting_release_intermediate_artifacts_raises(self) -> None:
455+
r1 = ExportRecipe(name="a", release_intermediate_artifacts=True)
456+
r2 = ExportRecipe(name="b", release_intermediate_artifacts=False)
457+
with self.assertRaises(ValueError) as cm:
458+
ExportRecipe.combine([r1, r2])
459+
self.assertIn("release_intermediate_artifacts", str(cm.exception))
460+
435461
def test_agreeing_scalar_fields_are_preserved(self) -> None:
436462
r1 = ExportRecipe(
437-
name="a", strict=False, mode=Mode.DEBUG, source_transform_in_place=True
463+
name="a",
464+
strict=False,
465+
mode=Mode.DEBUG,
466+
source_transform_in_place=True,
467+
release_intermediate_artifacts=True,
438468
)
439469
r2 = ExportRecipe(
440-
name="b", strict=False, mode=Mode.DEBUG, source_transform_in_place=True
470+
name="b",
471+
strict=False,
472+
mode=Mode.DEBUG,
473+
source_transform_in_place=True,
474+
release_intermediate_artifacts=True,
441475
)
442476
result = ExportRecipe.combine([r1, r2])
443477
self.assertFalse(result.strict)
444478
self.assertEqual(result.mode, Mode.DEBUG)
445479
self.assertTrue(result.source_transform_in_place)
480+
self.assertTrue(result.release_intermediate_artifacts)
446481

447482
def test_name_is_joined_from_input_recipe_names(self) -> None:
448483
r1 = ExportRecipe(name="backend_a")

‎export/tests/test_export_session.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,91 @@ def test_default_pipeline_execution_order(self) -> None:
111111
self.assertEqual(len(session._stage_to_artifacts), 5)
112112
self.assertEqual(set(session._stage_to_artifacts.keys()), set(stage_types))
113113

114+
def _run_five_stage_pipeline(self, recipe: ExportRecipe) -> ExportSession:
115+
stage_types = [
116+
StageType.SOURCE_TRANSFORM,
117+
StageType.QUANTIZE,
118+
StageType.TORCH_EXPORT,
119+
StageType.TO_EDGE_TRANSFORM_AND_LOWER,
120+
StageType.TO_EXECUTORCH,
121+
]
122+
session = ExportSession(
123+
model=self.model,
124+
example_inputs=self.example_inputs,
125+
export_recipe=recipe,
126+
)
127+
for stage_type in stage_types:
128+
session.register_stage(stage_type, self._create_mock_stage(stage_type))
129+
session.export()
130+
return session
131+
132+
def test_intermediate_artifacts_retained_by_default(self) -> None:
133+
session = self._run_five_stage_pipeline(ExportRecipe(name="test"))
134+
self.assertEqual(len(session._stage_to_artifacts), 5)
135+
self.assertEqual(session._released_stages, set())
136+
137+
def test_release_intermediate_artifacts_keeps_only_the_last(self) -> None:
138+
session = self._run_five_stage_pipeline(
139+
ExportRecipe(name="test", release_intermediate_artifacts=True)
140+
)
141+
142+
self.assertEqual(
143+
set(session._stage_to_artifacts.keys()), {StageType.TO_EXECUTORCH}
144+
)
145+
# Every stage but the last had both of its references dropped.
146+
for stage_type in [
147+
StageType.SOURCE_TRANSFORM,
148+
StageType.QUANTIZE,
149+
StageType.TORCH_EXPORT,
150+
StageType.TO_EDGE_TRANSFORM_AND_LOWER,
151+
]:
152+
self.assertIn(stage_type, session._released_stages)
153+
session.get_registered_stage(
154+
stage_type
155+
).release_artifact.assert_called_once()
156+
157+
def test_released_accessors_explain_why(self) -> None:
158+
session = self._run_five_stage_pipeline(
159+
ExportRecipe(name="test", release_intermediate_artifacts=True)
160+
)
161+
162+
for accessor in (
163+
session.get_exported_program,
164+
session.get_edge_program_manager,
165+
session.print_delegation_info,
166+
):
167+
with self.assertRaises(RuntimeError) as cm:
168+
accessor()
169+
self.assertIn("released to free memory", str(cm.exception))
170+
171+
def test_reexport_clears_released_stage_state(self) -> None:
172+
session = self._run_five_stage_pipeline(
173+
ExportRecipe(name="test", release_intermediate_artifacts=True)
174+
)
175+
self.assertIn(StageType.TORCH_EXPORT, session._released_stages)
176+
177+
exported_program = Mock()
178+
torch_export_stage = session.get_registered_stage(StageType.TORCH_EXPORT)
179+
assert torch_export_stage is not None
180+
torch_export_stage.get_artifacts.return_value.data = {
181+
"forward": exported_program
182+
}
183+
session._pipeline_stages = [
184+
StageType.SOURCE_TRANSFORM,
185+
StageType.QUANTIZE,
186+
StageType.TORCH_EXPORT,
187+
]
188+
189+
session.export()
190+
191+
self.assertIs(session.get_exported_program(), exported_program)
192+
193+
def test_release_does_not_touch_the_final_artifact(self) -> None:
194+
session = self._run_five_stage_pipeline(
195+
ExportRecipe(name="test", release_intermediate_artifacts=True)
196+
)
197+
self.assertIsNotNone(session.get_executorch_program_manager())
198+
114199
def test_overriden_pipeline_execution_order(self) -> None:
115200
# Test when pipeline stages that are passed through recipe
116201
stage_types = [

0 commit comments

Comments
 (0)