Skip to content

Commit 0447246

Browse files
authored
Core ML: stop the gather opt-out from failing tied embeddings (pytorch#21969)
Fixes pytorch#21856. ### What happens today `op_linear_quantizer_config` is applied with `op_type_configs={"gather": None}`, so that embedding tables are not compressed by the linear quantizer. When a model ties its embedding table to its output projection — one weight, two consumers — that constant reaches the compressor configured two ways at once: quantized as a `linear` weight, exempt as a `gather` table. coremltools refuses the conflict rather than resolving it: ``` ValueError: compression config conflict detected between ops %aten_embedding_default_...: gather(x=%p_out_weight_to_fp16, ...) and %aten_mm_default...: linear(x=..., weight=%p_out_weight_to_fp16, ...) ... has config None while ... has OpLinearQuantizerConfig(mode='LINEAR_SYMMETRIC', ...) ``` So the opt-out does not skip the table. It fails the whole lowering, and a very common LLM shape cannot be lowered at all with `coreml_quantize` set. ### The change Gathers whose constant is also consumed by an op of another type are named in `op_name_configs` with the same config as the op they share it with, so the constant is seen one way. Every other gather keeps the opt-out, and a model whose MIL program is not available returns no names, which leaves the behaviour exactly as it was. ### Checking it The new test in `test_coreml_partitioner.py` lowers a two-layer model twice, tied and untied. Against `main` the tied case raises the error above; with this change both lower. The untied case passes either way, which is the point — the opt-out still applies where it always did. Measured on macOS arm64, coremltools 9.0.
1 parent 422e07c commit 0447246

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

‎backends/apple/coreml/compiler/coreml_preprocess.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@
3636
logger.setLevel(get_coreml_log_level(default_level=logging.WARNING))
3737

3838

39+
def _gathers_sharing_a_weight(mlmodel: ct.models.MLModel) -> List[str]:
40+
"""
41+
Names of gather ops whose table is also consumed by an op of another type.
42+
43+
Such a constant reaches the compressor under two different configurations at once,
44+
which it rejects rather than resolves. Naming these gathers lets them be configured
45+
the same way as the op they share the constant with; every other gather keeps the
46+
opt-out. Returns nothing for a model whose program is not available, which leaves
47+
the behaviour as it was.
48+
"""
49+
program = getattr(mlmodel, "_mil_program", None)
50+
if program is None:
51+
return []
52+
53+
consumers: Dict[str, List[Any]] = {}
54+
for function in program.functions.values():
55+
for op in function.operations:
56+
for value in op.inputs.values():
57+
for var in value if isinstance(value, (list, tuple)) else [value]:
58+
source = getattr(var, "op", None)
59+
if source is not None and source.op_type == "const":
60+
consumers.setdefault(var.name, []).append(op)
61+
62+
shared: List[str] = []
63+
for ops in consumers.values():
64+
gathers = [op for op in ops if op.op_type == "gather"]
65+
if gathers and len(gathers) < len(ops):
66+
shared += [op.name for op in gathers]
67+
return shared
68+
69+
3970
class COMPILE_SPEC_KEYS(Enum):
4071
COMPUTE_UNITS = "compute_units"
4172
MODEL_TYPE = "model_type"
@@ -579,9 +610,16 @@ def _convert_to_mlmodel(
579610
logger.warning(
580611
"Core ML Backend op_linear_quantizer_config API is experimental"
581612
)
613+
# Gathers are left alone so that embedding tables are not quantized by this
614+
# config, except where the table is also some other op's weight. A tied
615+
# embedding is one constant feeding both a gather and a linear, and coremltools
616+
# refuses to compress a constant its consumers disagree about, so opting the
617+
# gather out there does not skip the table, it fails the whole lowering.
618+
tied = _gathers_sharing_a_weight(mlmodel)
582619
config = cto.coreml.OptimizationConfig(
583620
global_config=op_linear_quantizer_config,
584621
op_type_configs={"gather": None},
622+
op_name_configs={name: op_linear_quantizer_config for name in tied},
585623
)
586624
mlmodel = cto.coreml.linear_quantize_weights(mlmodel, config=config)
587625

‎backends/apple/coreml/test/test_coreml_partitioner.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,46 @@ def forward(self, x):
433433
self.assertIn("executorch_call_delegate", op_names)
434434
self.assertNotIn("aten.argmax.default", op_names)
435435

436+
def test_tied_embedding_is_quantized_with_its_linear(self):
437+
"""A weight that is both an embedding table and an output projection lowers.
438+
439+
The quantizer config opts gathers out so that embedding tables are not compressed
440+
by it. When the table is tied to a linear's weight, that opt-out leaves one
441+
constant with two configurations, which coremltools rejects — so the opt-out has
442+
to stop at the tie rather than fail the whole lowering.
443+
"""
444+
445+
class Tied(torch.nn.Module):
446+
def __init__(self, vocab=512, dim=64, tie=True):
447+
super().__init__()
448+
self.embedding = torch.nn.Embedding(vocab, dim)
449+
self.output = torch.nn.Linear(dim, vocab, bias=False)
450+
if tie:
451+
self.output.weight = self.embedding.weight
452+
453+
def forward(self, ids):
454+
return self.output(self.embedding(ids))
455+
456+
ids = torch.zeros(1, 4, dtype=torch.long)
457+
for tie in (True, False):
458+
compile_specs = CoreMLBackend.generate_compile_specs(
459+
minimum_deployment_target=ct.target.iOS18,
460+
compute_precision=ct.precision(ct.precision.FLOAT16.value),
461+
op_linear_quantizer_config={
462+
"mode": "linear_symmetric",
463+
"dtype": "int4",
464+
"granularity": "per_channel",
465+
},
466+
)
467+
exported = torch.export.export(Tied(tie=tie).eval(), (ids,), strict=True)
468+
delegated = executorch.exir.to_edge_transform_and_lower(
469+
exported,
470+
partitioner=[CoreMLPartitioner(compile_specs=compile_specs)],
471+
)
472+
# Reaching a program at all is the assertion: the failure this covers is
473+
# raised during lowering, not carried in the result.
474+
self.assertIsNotNone(delegated.to_executorch())
475+
436476
def test_deprecation_warning_for_to_backend_workflow(self):
437477
"""
438478
Test that the deprecated to_edge + to_backend workflow shows a deprecation warning.
@@ -532,5 +572,6 @@ def forward(self, x):
532572
test_runner.test_take_over_constant_data_false()
533573
test_runner.test_aten_rand_default_falls_back_to_portable()
534574
test_runner.test_aten_randn_is_still_delegated()
575+
test_runner.test_tied_embedding_is_quantized_with_its_linear()
535576
test_runner.test_deprecation_warning_for_to_backend_workflow()
536577
test_runner.test_no_warning_for_to_edge_transform_and_lower_workflow()

0 commit comments

Comments
 (0)