-
Notifications
You must be signed in to change notification settings - Fork 23
Update docs and add tests to validate externalization workflow with graph mode #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pkmandke
wants to merge
17
commits into
apple:main
Choose a base branch
from
pkmandke:dev/extern
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
11618af
use extern API
pkmandke 9e66ae1
inspect
pkmandke eb03e2c
subexport_and_restore
pkmandke 6200c57
output
pkmandke 26f7def
update to latest extern API and cleanup tests
pkmandke 15431ee
merge main
pkmandke 82eaa4f
remove notebook diff and refactor quant cfg util
pkmandke ee9107c
index select for composite input quantization and refactoring
pkmandke 2e94595
cleanup
pkmandke 5c3e15f
composite ops boundary quantization coverage
pkmandke 0f51025
add doc page
pkmandke 8d66ed1
update flowchart
pkmandke 7ec5d63
address review comments
pkmandke 95d1822
nit
pkmandke 8759dc7
doc
pkmandke 58c9293
Merge branch 'main' into dev/extern
pkmandke 4f4abf0
address review comments
pkmandke File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| # Quantizing Models with Core AI Composite Ops in Graph Mode | ||
|
|
||
| Core AI recognizes certain well-known building blocks, such as SDPA or RMSNorm, as _composite ops_ and applies optimized implementations for them. | ||
| `coreai-torch` establishes those boundaries through _externalization_. | ||
| Refer to the [Externalization](https://apple.github.io/coreai-torch/main/guides/externalization.html) guide for details. | ||
| Here, we will discuss the steps required to quantize a model in `graph` mode using `coreai-opt`. | ||
|
|
||
| `graph`-mode quantization invokes `torch.export.export` under the hood, which decomposes a submodule's `forward` into aten ops. | ||
| In order to preserve the composite op structure during this process for externalization, the following APIs are provided: | ||
|
|
||
| - `_patch_model_for_externalization`: Patch the model **before** `quantizer.prepare`, so that the composite op call sites survive export and all subsequent quantization passes as opaque nodes. | ||
| - `_subexport_and_restore`: The submodule bodies of the composite ops themselves are then exported and restored before lowering to `CoreAI`. | ||
|
|
||
| Quantization treats each composite op as opaque, i.e., no fake-quantize op is placed inside the composite body. | ||
| The composite's input and output boundary can still be quantized, see [Quantizing the composite op boundary](#quantizing-the-composite-op-boundary) below for details. | ||
|
|
||
| :::{warning} | ||
| The externalization APIs used below, `_patch_model_for_externalization` and `_subexport_and_restore` in `coreai-torch` are currently experimental. | ||
| ::: | ||
|
|
||
| ```mermaid | ||
| --- | ||
| title: "Graph mode Quantization Workflow with Externalization" | ||
| --- | ||
| flowchart LR | ||
| model["Full Precision<br>Model"] --> patch["Patch Model for<br>Externalization"] | ||
| patch --> prepare["Prepare and<br>Calibrate"] | ||
| prepare --> qfin["Finalize and<br>Export"] | ||
| qfin --> sub["Sub-export<br>and Restore"] | ||
| sub --> convert["Convert to<br>Core AI"] | ||
| style model fill:#f9f9f9,stroke:#999 | ||
| style patch fill:#e8f0fe,stroke:#4285f4 | ||
| style sub fill:#e8f0fe,stroke:#4285f4 | ||
| ``` | ||
|
|
||
| ## Step 1: Patch the model before prepare | ||
|
|
||
| `_patch_model_for_externalization` replaces the `forward` of every matching submodule in the model with a `torch.library.custom_op`, in place. | ||
| Call it before constructing the `Quantizer`. | ||
| The example below demonstrates this using the same `RMSNormComposite` op from the [Externalization](https://apple.github.io/coreai-torch/main/guides/externalization.html) guide, however, the same process applies for all composite ops with their respective `ExternalizeSpec`s. | ||
|
|
||
| ```python | ||
| import torch | ||
| import torch.nn as nn | ||
| from coreai_torch import ExternalizeSpec, _patch_model_for_externalization | ||
|
|
||
|
|
||
| # The composite op | ||
| class RMSNormComposite(nn.Module): | ||
| def __init__(self, axes=-1, eps=1e-5, version=1): | ||
| super().__init__() | ||
| self.axes = axes | ||
| self.eps = eps | ||
| self.version = version | ||
|
|
||
| def forward(self, input: torch.Tensor, scale: torch.Tensor) -> torch.Tensor: | ||
| x_f32 = input.to(torch.float32) | ||
| inv_rms = torch.rsqrt((x_f32 * x_f32).mean(self.axes, keepdim=True) + self.eps) | ||
| return (input * inv_rms).to(input.dtype) * scale | ||
|
|
||
|
|
||
| # A model that uses the composite op | ||
| class Model(nn.Module): | ||
| def __init__(self, dim=32): | ||
| super().__init__() | ||
| self.proj = nn.Linear(dim, dim) | ||
| self.norm = RMSNormComposite() | ||
| self.norm_weight = nn.Parameter(torch.ones(dim)) | ||
| self.out = nn.Linear(dim, dim) | ||
|
|
||
| def forward(self, x): | ||
| return self.out(self.norm(self.proj(x), self.norm_weight)) | ||
|
|
||
|
|
||
| model = Model().eval() | ||
| example_inputs = (torch.randn(1, 32),) | ||
|
|
||
| # Patch the model in-place | ||
| # to externalize the RMSNormComposite | ||
| _patch_model_for_externalization( | ||
| model, | ||
| targets=[ | ||
| ExternalizeSpec( | ||
| target_class=RMSNormComposite, | ||
| composite_op_name="rms_norm", | ||
| composite_attrs=["axes", "eps", "version"], | ||
| ) | ||
| ], | ||
| ) | ||
| ``` | ||
|
|
||
| ## Step 2: Prepare, calibrate and finalize | ||
|
|
||
| Nothing about the quantizer configuration or the calibration workflow changes. | ||
| The composite op holds no weights of its own here, so weight quantization applies to the surrounding `Linear` layers only. | ||
|
|
||
| ```python | ||
| import coreai_opt as opt | ||
| from coreai_opt.quantization import ModuleQuantizerConfig, Quantizer, QuantizerConfig | ||
| from coreai_opt.quantization.spec import ( | ||
| default_activation_quantization_spec, | ||
| default_weight_quantization_spec, | ||
| ) | ||
|
|
||
| global_config = ModuleQuantizerConfig( | ||
| op_state_spec={"weight": default_weight_quantization_spec()}, | ||
| op_input_spec={"*": default_activation_quantization_spec()}, | ||
| op_output_spec={"*": default_activation_quantization_spec()}, | ||
| ) | ||
| quant_config = QuantizerConfig(global_config=global_config) | ||
|
|
||
| quantizer = Quantizer(model, quant_config) | ||
| prepared_model = quantizer.prepare(example_inputs) | ||
|
|
||
| with quantizer.calibration_mode(): | ||
| for batch in calibration_dataloader: | ||
| prepared_model(batch) | ||
|
|
||
| final_model = quantizer.finalize(backend=opt.ExportBackend.CoreAI) | ||
| ``` | ||
|
|
||
| ## Step 3: Export and convert to Core AI | ||
|
|
||
| After quantization is complete and the model is finalized, `_subexport_and_restore` API exports each patched composite op and restores the original `forward` method in the model. | ||
| Note that the first argument to `_subexport_and_restore` is the original module that was patched in Step 1, not the finalized `GraphModule`. | ||
|
|
||
| ```python | ||
| import coreai_torch | ||
| from coreai_torch import TorchConverter, _subexport_and_restore | ||
|
|
||
| exported_program = torch.export.export(final_model, example_inputs).run_decompositions( | ||
| coreai_torch.get_decomp_table() | ||
| ) | ||
| externalized = _subexport_and_restore(model, exported_program) | ||
|
|
||
| coreai_program = ( | ||
| TorchConverter() | ||
| .add_exported_program( | ||
| exported_program, _externalized_exported_programs=externalized | ||
| ) | ||
| .to_coreai() | ||
| ) | ||
| ``` | ||
|
|
||
| In the Core AI graph, the composite op is emitted as a separate private graph that `@main` reaches through `coreai.invoke`: | ||
|
|
||
| ```text | ||
| // composite op body | ||
| coreai.graph private noinline @norm_57e2d4a8(%arg0: tensor<1x32xf32> {coreai.name = "input"}, %arg1: tensor<32xf32> {coreai.name = "scale"}) -> (tensor<1x32xf32>) attributes {composite_decl = ...} { | ||
| %2 = coreai.decomposable.broadcasting_mul %0, %1 : (tensor<1x32xf32>, tensor<1x32xf32>) -> tensor<1x32xf32> | ||
| %4 = coreai.reduce_mean %2, %3 : (tensor<1x32xf32>, tensor<1xsi32>) -> tensor<1x1xf32> | ||
| %8 = coreai.decomposable.broadcasting_add %6, %7 : (tensor<1x1xf32>, tensor<f32>) -> tensor<1x1xf32> | ||
| %9 = coreai.rsqrt %8 : tensor<1x1xf32> -> tensor<1x1xf32> | ||
| %12 = coreai.decomposable.broadcasting_mul %10, %11 : (tensor<1x32xf32>, tensor<1x1xf32>) -> tensor<1x32xf32> | ||
| %15 = coreai.decomposable.broadcasting_mul %13, %14 : (tensor<1x32xf32>, tensor<32xf32>) -> tensor<1x32xf32> | ||
| coreai.output %15 : tensor<1x32xf32> | ||
| } | ||
|
|
||
| coreai.graph @main(%arg0: tensor<1x32xf32> {coreai.name = "x"}) -> (tensor<1x32xf32>) { | ||
| %44 = coreai.decomposable.broadcasting_add %43, %2 : (tensor<1x32xf32>, tensor<32xf32>) -> tensor<1x32xf32> | ||
| %53 = coreai.quantize %44, ... : (tensor<1x32xf32>, ...) -> tensor<1x32xsi8> | ||
| %62 = coreai.dequantize %53, ... : (tensor<1x32xsi8>, ...) -> tensor<1x32xf32> | ||
|
|
||
| // externalized composite op invocation | ||
| %63 = coreai.invoke @norm_57e2d4a8(%62, %0) : (tensor<1x32xf32>, tensor<32xf32>) -> tensor<1x32xf32> | ||
| %72 = coreai.quantize %63, ... : (tensor<1x32xf32>, ...) -> tensor<1x32xsi8> | ||
| %81 = coreai.dequantize %72, ... : (tensor<1x32xsi8>, ...) -> tensor<1x32xf32> | ||
| %84 = coreai.decomposable.broadcasting_batch_matmul %81, %83 : (tensor<1x32xf32>, tensor<32x32xf32>) -> tensor<1x32xf32> | ||
| } | ||
| ``` | ||
|
|
||
| (`coreai.cast`, `coreai.constant` and `coreai.reshape` ops omitted above for brevity.) | ||
|
|
||
| The composite body carries no `coreai.quantize` or `coreai.dequantize` op and stays in full precision. | ||
|
|
||
| ## Quantizing the composite op boundary | ||
|
|
||
| The `coreai.quantize` pairs surrounding the `coreai.invoke` above come from the global config. They are the output quantizer of the preceding `Linear` and the input quantizer of the following one. | ||
| The composite op boundary itself is not targeted by the global config. | ||
|
|
||
| To target the boundary specifically, use `module_input_spec` and `module_output_spec` on a {class}`~coreai_opt.quantization.config.ModuleQuantizerConfig` scoped by `module_type_configs` or `module_name_configs`. | ||
|
|
||
| To see this in isolation, the following example uses a model with the composite op alone and specifies a module level spec to quantize it's boundary. | ||
|
|
||
| ```python | ||
| from coreai_opt.quantization.spec import ( | ||
| PerTensorGranularity, | ||
| QuantizationScheme, | ||
| QuantizationSpec, | ||
| ) | ||
|
|
||
|
|
||
| class RMSNormOnly(nn.Module): | ||
| def __init__(self, dim=32): | ||
| super().__init__() | ||
| self.norm = RMSNormComposite() | ||
| self.norm_weight = nn.Parameter(torch.ones(dim)) | ||
|
|
||
| def forward(self, x): | ||
| return self.norm(x, self.norm_weight) | ||
|
|
||
|
|
||
| boundary_spec = QuantizationSpec( | ||
| dtype=torch.int8, | ||
| qscheme=QuantizationScheme.SYMMETRIC, | ||
| granularity=PerTensorGranularity(), | ||
| ) | ||
| quant_config = QuantizerConfig( | ||
| module_type_configs={ | ||
| RMSNormComposite: ModuleQuantizerConfig( | ||
| module_input_spec={"*": boundary_spec}, | ||
| module_output_spec={"*": boundary_spec}, | ||
| ) | ||
| }, | ||
| ) | ||
| ``` | ||
|
|
||
| Running the same patch, prepare, calibrate, finalize and convert steps as above, gives a `@main` graph containing just the boundary quantization and the composite call. | ||
|
|
||
| ```text | ||
| coreai.graph private noinline @norm_20ea9665(%arg0: tensor<1x32xf32> {coreai.name = "input"}, %arg1: tensor<32xf32> {coreai.name = "scale"}) -> (tensor<1x32xf32>) attributes {composite_decl = ...} { | ||
| %2 = coreai.decomposable.broadcasting_mul %0, %1 : (tensor<1x32xf32>, tensor<1x32xf32>) -> tensor<1x32xf32> | ||
| %4 = coreai.reduce_mean %2, %3 : (tensor<1x32xf32>, tensor<1xsi32>) -> tensor<1x1xf32> | ||
| %8 = coreai.decomposable.broadcasting_add %6, %7 : (tensor<1x1xf32>, tensor<f32>) -> tensor<1x1xf32> | ||
| %9 = coreai.rsqrt %8 : tensor<1x1xf32> -> tensor<1x1xf32> | ||
| %12 = coreai.decomposable.broadcasting_mul %10, %11 : (tensor<1x32xf32>, tensor<1x1xf32>) -> tensor<1x32xf32> | ||
| %15 = coreai.decomposable.broadcasting_mul %13, %14 : (tensor<1x32xf32>, tensor<32xf32>) -> tensor<1x32xf32> | ||
| coreai.output %15 : tensor<1x32xf32> | ||
| } | ||
|
|
||
| coreai.graph @main(%arg0: tensor<1x32xf32> {coreai.name = "x"}) -> (tensor<1x32xf32>) { | ||
|
|
||
| // Input boundary quantizers for the composite op | ||
| %13 = coreai.quantize %arg0, ... : (tensor<1x32xf32>, ...) -> tensor<1x32xsi8> | ||
| %22 = coreai.dequantize %13, ... : (tensor<1x32xsi8>, ...) -> tensor<1x32xf32> | ||
|
|
||
| // externalized composite op invocation | ||
| %23 = coreai.invoke @norm_20ea9665(%22, %0) : (tensor<1x32xf32>, tensor<32xf32>) -> tensor<1x32xf32> | ||
|
|
||
| // Output boundary quantizers for the composite op | ||
| %32 = coreai.quantize %23, ... : (tensor<1x32xf32>, ...) -> tensor<1x32xsi8> | ||
| %41 = coreai.dequantize %32, ... : (tensor<1x32xsi8>, ...) -> tensor<1x32xf32> | ||
| coreai.output %41 : tensor<1x32xf32> | ||
| } | ||
| ``` | ||
|
|
||
| (`coreai.cast`, `coreai.constant` and `coreai.reshape` ops omitted above for brevity.) | ||
|
|
||
| ## Notes | ||
|
|
||
| - The same set of APIs and steps apply for Quantization Aware Training in `graph` mode as well. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file added
BIN
+401 KB
tests/_test_artifacts/mnist/mnist_composite_rmsnorm_pretrained_1epoch_08132026.pt
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -436,10 +436,14 @@ def convert( | |
| self, | ||
| traced_model: torch.export.ExportedProgram, | ||
| input_data: torch.Tensor, | ||
| externalized_model: Any = None, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason to have the type here as "Any" instead of being more specific? |
||
| **kwargs: Any, | ||
| ) -> AIProgram: | ||
| _, _ = input_data, kwargs | ||
| coreai_program = self._lower_to_coreai(traced_model) | ||
| coreai_program = self._lower_to_coreai( | ||
| traced_model, | ||
| externalized_model=externalized_model, | ||
| ) | ||
|
pkmandke marked this conversation as resolved.
|
||
| assert type(coreai_program) is AIProgram | ||
|
|
||
| return coreai_program | ||
|
|
@@ -526,10 +530,25 @@ def _verify_custom_ops_in_torch_program( | |
| @staticmethod | ||
| def _lower_to_coreai( | ||
| exported_program: torch.export.ExportedProgram, | ||
| externalized_model: Any = None, | ||
| ) -> AIProgram: | ||
| """Lower exported program to Core AI.""" | ||
| """Lower exported program to Core AI. | ||
|
|
||
| Args: | ||
| exported_program: The exported program to lower. | ||
| externalized_model: Optional ``torch.nn.Module`` that was marked in | ||
| place by ``coreai_torch._patch_model_for_externalization``. | ||
| """ | ||
| converter = coreai_torch.TorchConverter() | ||
| converter.add_exported_program(exported_program) | ||
| externalized_exported_programs = ( | ||
| coreai_torch._subexport_and_restore(externalized_model, exported_program) | ||
| if externalized_model is not None | ||
| else None | ||
| ) | ||
| converter.add_exported_program( | ||
| exported_program, | ||
| _externalized_exported_programs=externalized_exported_programs, | ||
| ) | ||
| return converter.to_coreai() | ||
|
|
||
|
|
||
|
|
@@ -554,6 +573,7 @@ def convert_and_verify( | |
| expected_ops: Mapping[str, int], | ||
| export_backend: ExportBackend, | ||
| prepared_model_output: torch.Tensor | tuple[torch.Tensor, ...], | ||
| externalized_model: torch.nn.Module | None = None, | ||
| snr_thresh: float = 20.0, | ||
| psnr_thresh: float = 22.0, | ||
| skip_finalized_model_verify: bool = False, | ||
|
|
@@ -568,6 +588,9 @@ def convert_and_verify( | |
| export_backend: Target inference stack (CoreML or CoreAI) | ||
| prepared_model_output: Pre-computed reference output from the prepared | ||
| PyTorch model (single tensor or tuple). | ||
| externalized_model: Optional ``torch.nn.Module`` that was patched in place by | ||
| ``coreai_torch._patch_model_for_externalization``. Only supported by the | ||
| CoreAI backend. | ||
| snr_thresh: Minimum acceptable SNR value | ||
| psnr_thresh: Minimum acceptable PSNR value | ||
| skip_finalized_model_verify: If True, skip forward pass verification on | ||
|
|
@@ -578,9 +601,20 @@ def convert_and_verify( | |
| Returns: | ||
| The converted model in the specified format | ||
|
|
||
| Raises: | ||
| ValueError: If externalized_model is given for a non-CoreAI backend. | ||
|
|
||
| """ | ||
| converter = create_converter(export_backend) | ||
|
|
||
| if externalized_model is not None: | ||
| if export_backend is not ExportBackend.CoreAI: | ||
| msg = ( | ||
| f"externalized_model is only supported by the CoreAI backend, got {export_backend}" | ||
| ) | ||
| raise ValueError(msg) | ||
| converter_kwargs["externalized_model"] = externalized_model | ||
|
|
||
| # Run finalized model forward pass BEFORE tracing. torch.export.export() | ||
| # (called in trace) may mutate the model (e.g., strip parametrizations on | ||
| # older PyTorch versions), so the forward pass must happen first. | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a few comments on this doc, mainly: we can do a better job of giving more context, as to when somebody has to worry about this flow. And then I think we can also shorten it quite a bit. For instance, there are basically 2 additional calls that need to be made by the user, the way it is described currently it seems a bit more complicated than it actually is :)
So we can omit the diagram, the coreai dialect code snippet etc. Instead focus on explaining at a high level for the user: what the API is, what does it do to the model, what does it return etc.
I have taken a stab at updating this doc, based on the above points. PTAL here and then you can update it by incorporating suggestions from there.