Skip to content

Commit eea8244

Browse files
committed
Move the macOS export graph contract onto the model classes
`export/macos.py` hardcoded the standard text-LLM forward -- two inputs, two KV cache states, one logits output -- so a model needing anything else cannot use the pipeline at all. A model threading extra conv or recurrent state alongside the KV cache is stuck, and one mixing sliding and full attention hits the same wall, because the two layer types need separate cache tensors with different head counts and head dims. Adds overridable hooks to `BaseForCausalLM`, keyed by graph name: export_input_names() -> {graph: (name, ...)} export_state_names() -> {graph: (name, ...)} export_output_names() -> {graph: (name, ...)} build_reference_inputs(config, target_dtype, spec) -> {graph: {param: tensor}} build_dynamic_shapes(config, spec) -> {graph: shapes} A macOS model has one graph, `main`. The defaults are exactly what `macos.py` hardcoded, so `export_macos_model` and the pipeline's quantization step now just call them. Two ordering rules, because the two are consumed differently. Reference inputs bind to the traced signature, so they must be in its exact order, interleaved where the signature interleaves inputs and states. The name lists are looked up by name at runtime, so each carries only the relative order of its own kind. Also in here: * `quantize_for_export` builds the calibration trace from the hooks, so the pipeline and any standalone recipe share it. `quantize_pytorch_model` now requires `cache_seq_len` and `state_indices` rather than guessing them from input positions. * `KVCache.create_cache_tensors` takes an explicit `seq_len`, replacing a mutate-`config.max_position_embeddings`-and-restore hack. `cache_scatter`'s copy gets it too, since its docstring promises the two are drop-in interchangeable. * `export/_constants.py` moves to `coreai_models/_constants.py`. These are graph/runner contract constants that `models/` now needs, and importing them from `export/` would reverse the package dependency direction. * Fixes a crash for contexts at or below the trace cache length: the cache dim was declared `Dim(min=TRACE_KV_CACHE_SEQ_LEN, max=max_context_length)` unconditionally, so `--max-context-length 2048` raised "Cannot create Dim with inconsistent min/max" from inside torch.export. `TraceSpec` now requires `cache_seq_len <= max_context_length` -- a cache longer than the context it serves is meaningless, so callers cap it at the context they are exporting -- and pins the cache dims when the two are equal, since a cache traced at the full context has nowhere to grow. No behavior change for anything that exports today: all 8 registered macOS model classes produce bit-identical reference inputs, dynamic-shape bounds, and graph names.
1 parent 1677713 commit eea8244

11 files changed

Lines changed: 829 additions & 170 deletions

File tree

python/src/coreai_models/export/_constants.py renamed to python/src/coreai_models/_constants.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@
33
# Use of this source code is governed by a BSD-3-clause license that can
44
# be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
55

6-
"""Constants for the export pipeline."""
6+
"""Graph and runner contract constants.
7+
8+
A leaf module: imported by both ``models/`` and ``export/``, imports nothing from
9+
either.
10+
"""
11+
12+
# Graph name for a single-graph (macOS) export. iOS uses its own entrypoint names.
13+
MAIN_GRAPH_NAME = "main"
714

815
# KV cache names used by the Swift runner
916
KEY_CACHE_NAME = "keyCache"
1017
VALUE_CACHE_NAME = "valueCache"
1118

12-
# Trace-time KV cache sequence length. Used only for export/quantization tracing
13-
# to bound peak memory; at inference the actual cache size is determined
14-
# dynamically.
19+
# Trace-time KV cache sequence length, to bound peak trace memory. At inference the
20+
# cache size is dynamic.
1521
TRACE_KV_CACHE_SEQ_LEN = 2048
1622

1723
# Trace-time `input_ids` length and `position_ids` offset for export/quantization

python/src/coreai_models/export/compression.py

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@
1111
"""
1212

1313
import logging
14-
from collections.abc import Callable
14+
from collections.abc import Callable, Sequence
1515

1616
import torch
1717
import torch.nn as nn
1818

19-
from coreai_models.export._constants import (
19+
from coreai_models._constants import (
20+
MAIN_GRAPH_NAME,
2021
QUANT_TRACE_OFFSET,
2122
QUANT_TRACE_QUERY_LEN,
23+
TRACE_KV_CACHE_SEQ_LEN,
2224
)
25+
from coreai_models.models.base import BaseForCausalLM, TraceSpec
2326

2427
logger = logging.getLogger(__name__)
2528

@@ -100,6 +103,8 @@ def quantize_pytorch_model(
100103
inputs: tuple,
101104
dynamic_shapes: dict,
102105
quantization_config: dict,
106+
cache_seq_len: int,
107+
state_indices: Sequence[int],
103108
calibration_data_fn: Callable[[], list] | None = None,
104109
export_backend: object | None = None,
105110
mmap_dir: str | None = None,
@@ -119,6 +124,10 @@ def quantize_pytorch_model(
119124
coreai-opt expects under `quantization_config`. Includes a
120125
`calibrate_activations` key (popped here before constructing the
121126
coreai-opt config).
127+
cache_seq_len: Sequence-dim length the caches in ``inputs`` were traced at,
128+
used to bound the calibration query length.
129+
state_indices: Positions in ``inputs`` that are state and must be reset
130+
between calibration samples.
122131
calibration_data_fn: Optional function that returns calibration data samples.
123132
Required when calibrate_activations is enabled.
124133
export_backend: Backend for the finalized quantized model.
@@ -142,7 +151,11 @@ def quantize_pytorch_model(
142151

143152
# When doing activation quantization, run real calibration data through the
144153
# prepared model so the activation observers see representative ranges.
145-
# `inputs` follows the model forward contract: (input_ids, position_ids, k_cache, v_cache).
154+
#
155+
# `inputs[0]` must be input_ids and `inputs[1]` position_ids -- token-based
156+
# calibration cannot do anything else. Which of the rest are state comes from
157+
# `state_indices`; a non-state input keeps its traced tensor, which is only correct
158+
# if its shape does not depend on query length.
146159
if run_calibration:
147160
if calibration_data_fn is None:
148161
raise ValueError(
@@ -151,9 +164,18 @@ def quantize_pytorch_model(
151164
calibration_data = calibration_data_fn()
152165
device = next(model.parameters()).device
153166

154-
cache_seq_len = inputs[2].shape[-2]
155-
# Match the dynamic-shape upper bound declared by the caller:
156-
# position_ids.shape[1] <= cache_seq_len - 1 (see pipeline.py `seq_pos` Dim)
167+
reset_positions = set(state_indices)
168+
for pos in reset_positions:
169+
assert pos >= 2, (
170+
"States cannot occupy the first two input positions. "
171+
"Those must be reserved for input_ids and position_ids"
172+
)
173+
assert pos < len(inputs), (
174+
f"State index out of bounds, got {pos}, while the number of inputs is {len(inputs)}"
175+
)
176+
177+
# Match the caller's declared bound: position_ids.shape[1] <= cache_seq_len - 1
178+
# (the `seq_pos` Dim in `BaseForCausalLM.build_dynamic_shapes`).
157179
# position_ids has length QUANT_TRACE_OFFSET + query_len, so:
158180
# query_len <= cache_seq_len - QUANT_TRACE_OFFSET - 1
159181
max_calib_query_len = cache_seq_len - QUANT_TRACE_OFFSET - 1
@@ -162,16 +184,20 @@ def quantize_pytorch_model(
162184
min_calib_query_len = QUANT_TRACE_QUERY_LEN - QUANT_TRACE_OFFSET
163185

164186
def _prep_calib_inputs(sample: torch.Tensor) -> tuple:
165-
sample = sample[:, :max_calib_query_len].to(device)
166-
position_ids = (
167-
torch.arange(QUANT_TRACE_OFFSET + sample.shape[1], dtype=torch.int32)
187+
prepared = list(inputs)
188+
prepared[0] = sample[:, :max_calib_query_len].to(device)
189+
prepared[1] = (
190+
torch.arange(QUANT_TRACE_OFFSET + prepared[0].shape[1], dtype=torch.int32)
168191
.unsqueeze(0)
169192
.to(device)
170193
)
171-
zero_cache = tuple(
172-
torch.zeros(inp.shape, dtype=inp.dtype, device=device) for inp in inputs[2:]
173-
)
174-
return (sample, position_ids, *zero_cache)
194+
for i in range(2, len(prepared)):
195+
inp = inputs[i]
196+
if i in reset_positions:
197+
prepared[i] = torch.zeros(inp.shape, dtype=inp.dtype, device=device)
198+
elif isinstance(inp, torch.Tensor):
199+
prepared[i] = inp.to(device)
200+
return tuple(prepared)
175201

176202
calibration_data = [s for s in calibration_data if s.shape[1] >= min_calib_query_len]
177203
if not calibration_data:
@@ -203,6 +229,66 @@ def _prep_calib_inputs(sample: torch.Tensor) -> tuple:
203229
return finalized_model
204230

205231

232+
def quantize_for_export(
233+
model: BaseForCausalLM,
234+
config,
235+
target_dtype: torch.dtype,
236+
quantization_config: dict,
237+
calibration_data_fn: Callable[[], list] | None = None,
238+
mmap_dir: str | None = None,
239+
) -> nn.Module:
240+
"""Apply pre-export torch quantization using the model's own graph contract.
241+
242+
Builds the calibration trace from the export hooks rather than hardcoding a forward
243+
signature, so a model with extra inputs or states calibrates without the caller
244+
knowing about them, and activation calibration resets exactly the states.
245+
246+
Args:
247+
model: The loaded model, in eval mode.
248+
config: The config the model was built from.
249+
target_dtype: Dtype for the trace's cache tensors.
250+
quantization_config: Inner coreai-opt ``quantization_config`` dict.
251+
calibration_data_fn: Calibration samples; required when the recipe enables
252+
``calibrate_activations``.
253+
mmap_dir: Directory for the quantizer's disk checkpointing.
254+
"""
255+
spec = TraceSpec(max_context_length=TRACE_KV_CACHE_SEQ_LEN)
256+
reference_inputs = model.build_reference_inputs(config, target_dtype, spec)
257+
dynamic_shapes = model.build_dynamic_shapes(config, spec)
258+
# Same check the export path runs, so a bad contract fails identically on both.
259+
model.validate_export_contract(reference_inputs, dynamic_shapes)
260+
261+
graph_inputs = reference_inputs[MAIN_GRAPH_NAME]
262+
keys = list(graph_inputs)
263+
if quantization_config.get("calibrate_activations") and keys[:2] != [
264+
"input_ids",
265+
"position_ids",
266+
]:
267+
raise ValueError(
268+
f"{type(model).__name__}: activation calibration feeds tokenized samples as "
269+
f"input_ids and rebuilds position_ids, so those must be the first two "
270+
f"parameters of forward; got {tuple(keys[:2])}."
271+
)
272+
273+
# Which *positions* are state cannot be read off the contract: the name lists carry
274+
# only relative order. So this assumes the declared inputs precede the states, which
275+
# holds for the macOS graph -- the only graph calibration runs on. A model that
276+
# interleaved a non-state parameter after a cache would need them passed in.
277+
n_inputs = len(model.export_input_names()[MAIN_GRAPH_NAME])
278+
state_indices = tuple(range(n_inputs, len(keys)))
279+
280+
return quantize_pytorch_model(
281+
model,
282+
model.reference_inputs_as_args(graph_inputs),
283+
dynamic_shapes[MAIN_GRAPH_NAME],
284+
quantization_config,
285+
calibration_data_fn=calibration_data_fn,
286+
mmap_dir=mmap_dir,
287+
cache_seq_len=spec.cache_seq_len,
288+
state_indices=state_indices,
289+
)
290+
291+
206292
def palettize_pytorch_model(
207293
model: nn.Module,
208294
example_inputs: tuple,

python/src/coreai_models/export/macos.py

Lines changed: 24 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,19 @@
1111
"""
1212

1313
import logging
14+
from typing import Any
1415

1516
import coreai_torch
1617
import coreai_torch.composite_ops
1718
import torch
1819
from coreai.authoring import AIProgram
1920

20-
from coreai_models.export._constants import (
21-
KEY_CACHE_NAME,
22-
QUANT_TRACE_OFFSET,
23-
QUANT_TRACE_QUERY_LEN,
24-
TRACE_KV_CACHE_SEQ_LEN,
25-
VALUE_CACHE_NAME,
26-
)
21+
from coreai_models._constants import MAIN_GRAPH_NAME, TRACE_KV_CACHE_SEQ_LEN
2722
from coreai_models.export.mlir_ops import (
2823
register_custom_torch_lowering,
2924
remove_functionalization,
3025
)
31-
from coreai_models.primitives.macos.cache import KVCache
26+
from coreai_models.models.base import BaseForCausalLM, TraceSpec
3227

3328
logger = logging.getLogger(__name__)
3429

@@ -64,69 +59,31 @@
6459

6560

6661
def _build_reference_inputs(
67-
model: torch.nn.Module,
62+
model: BaseForCausalLM,
6863
config,
6964
target_dtype: torch.dtype,
7065
max_context_length: int,
71-
) -> tuple[dict[str, torch.Tensor], dict]:
72-
"""Build reference inputs and dynamic shapes for macOS model export.
73-
74-
Args:
75-
model: The PyTorch model (used only to read config).
76-
config: HuggingFace model config.
77-
target_dtype: Data type for cache tensors.
78-
max_context_length: Maximum context length for the model.
66+
) -> tuple[dict[str, Any], dict]:
67+
"""Reference inputs and dynamic shapes for macOS export.
7968
80-
Returns:
81-
Tuple of (reference_inputs dict, dynamic_shapes dict).
69+
Thin wrapper over the model's export-contract hooks, where the per-model variation
70+
lives. Returns ``(reference_inputs, dynamic_shapes)``.
8271
"""
83-
batch_size = 1
84-
vocab_size = config.vocab_size
85-
86-
input_ids = torch.randint(1, vocab_size, (batch_size, QUANT_TRACE_QUERY_LEN), dtype=torch.int32)
87-
position_ids = (
88-
torch.arange(QUANT_TRACE_QUERY_LEN + QUANT_TRACE_OFFSET, dtype=torch.int32)
89-
.unsqueeze(0)
90-
.expand(batch_size, QUANT_TRACE_QUERY_LEN + QUANT_TRACE_OFFSET)
72+
# The trace cache length only bounds peak memory, so cap it at the context it serves.
73+
spec = TraceSpec(
74+
max_context_length=max_context_length,
75+
cache_seq_len=min(TRACE_KV_CACHE_SEQ_LEN, max_context_length),
9176
)
92-
93-
# Clamp `max_position_embeddings` so KVCache.create_cache_tensors doesn't
94-
# allocate a full-context cache for huge models
95-
saved_max_pos = config.max_position_embeddings
96-
config.max_position_embeddings = TRACE_KV_CACHE_SEQ_LEN
97-
k_cache, v_cache = KVCache.create_cache_tensors(config, dtype=target_dtype)
98-
config.max_position_embeddings = saved_max_pos
99-
100-
reference_inputs = {
101-
"input_ids": input_ids,
102-
"position_ids": position_ids,
103-
"k_cache": k_cache,
104-
"v_cache": v_cache,
105-
}
106-
107-
dynamic_shapes = {
108-
"input_ids": {1: torch.export.Dim("seq_ids", max=max_context_length - 2)},
109-
"position_ids": {
110-
1: torch.export.Dim("seq_pos", min=QUANT_TRACE_QUERY_LEN, max=max_context_length - 1)
111-
},
112-
"k_cache": {
113-
KVCache.seq_len_dim(): torch.export.Dim(
114-
"k_seq_len", min=TRACE_KV_CACHE_SEQ_LEN, max=max_context_length
115-
)
116-
},
117-
"v_cache": {
118-
KVCache.seq_len_dim(): torch.export.Dim(
119-
"v_seq_len", min=TRACE_KV_CACHE_SEQ_LEN, max=max_context_length
120-
)
121-
},
122-
}
123-
124-
return reference_inputs, dynamic_shapes
77+
reference_inputs = model.build_reference_inputs(config, target_dtype, spec)
78+
dynamic_shapes = model.build_dynamic_shapes(config, spec)
79+
model.validate_export_contract(reference_inputs, dynamic_shapes)
80+
# A macOS model has exactly one graph.
81+
return reference_inputs[MAIN_GRAPH_NAME], dynamic_shapes[MAIN_GRAPH_NAME]
12582

12683

12784
def export_to_coreai(
12885
model: torch.nn.Module,
129-
reference_inputs: dict[str, torch.Tensor],
86+
reference_inputs: dict[str, Any],
13087
dynamic_shapes: dict | None = None,
13188
input_names: tuple[str, ...] | None = None,
13289
output_names: tuple[str, ...] | None = None,
@@ -197,7 +154,7 @@ def export_fn(module: torch.nn.Module) -> torch.export.ExportedProgram:
197154

198155

199156
def export_macos_model(
200-
model: torch.nn.Module,
157+
model: BaseForCausalLM,
201158
config,
202159
export_config,
203160
) -> AIProgram:
@@ -209,7 +166,8 @@ def export_macos_model(
209166
3. Optimizes the resulting AIProgram
210167
211168
Args:
212-
model: A loaded PyTorch model (already in the correct dtype).
169+
model: A loaded PyTorch model (already in the correct dtype). Its
170+
export-contract hooks supply the graph's inputs, states, and names.
213171
config: HuggingFace model config (used for cache dimensions, vocab size, etc.).
214172
export_config: An ExportConfig instance (used for max_context_length, etc.).
215173
@@ -231,18 +189,14 @@ def export_macos_model(
231189
model, config, target_dtype, max_context_length
232190
)
233191

234-
input_names = ("input_ids", "position_ids")
235-
output_names = ("logits",)
236-
state_names = (KEY_CACHE_NAME, VALUE_CACHE_NAME)
237-
238192
logger.info("Exporting model to Core AI dialect...")
239193
coreai_program = export_to_coreai(
240194
model,
241195
reference_inputs,
242196
dynamic_shapes=dynamic_shapes,
243-
input_names=input_names,
244-
output_names=output_names,
245-
state_names=state_names,
197+
input_names=model.export_input_names()[MAIN_GRAPH_NAME],
198+
output_names=model.export_output_names()[MAIN_GRAPH_NAME],
199+
state_names=model.export_state_names()[MAIN_GRAPH_NAME],
246200
)
247201

248202
logger.info("Optimizing AIProgram...")

0 commit comments

Comments
 (0)