55from __future__ import annotations
66
77import logging
8+ from copy import deepcopy
89from dataclasses import dataclass
910from typing import TYPE_CHECKING , Callable
1011
1415from olive .common .quant .hf_utils import (
1516 OliveHfQuantizationConfig ,
1617 OliveHfQuantizationMethod ,
18+ OliveHfQuantizationOverrideConfig ,
1719 replace_matching_submodules ,
1820 tie_quant_word_embeddings ,
1921)
@@ -78,6 +80,106 @@ def get_quantizer_config(allow_embeds: bool = False) -> dict[str, PassConfigPara
7880 }
7981
8082
83+ def get_qkv_quantization_groups (wrapper : ModelWrapper , module_names : set [str ] | None = None ) -> list [tuple [str , ...]]:
84+ """Get attention input projection groups that must share quantization settings.
85+
86+ Names are resolved from ``wrapper.model.named_modules()`` to stay correct for any layer
87+ container (``ModuleList``, ``ModuleDict``, custom containers) and for unpacked QKV
88+ submodules. When ``module_names`` is provided, attention inputs not in the set are
89+ dropped from the group. Groups with fewer than two members are skipped.
90+ """
91+ module_to_name = {id (module ): name for name , module in wrapper .model .named_modules ()}
92+ qkv_groups = []
93+ for layer_wrapper in wrapper .get_layer_wrappers ():
94+ attn_inputs , _ = layer_wrapper .get_attention_inputs ()
95+ group = tuple (
96+ name
97+ for name in (module_to_name .get (id (module )) for module in attn_inputs )
98+ if name is not None and (module_names is None or name in module_names )
99+ )
100+ if len (group ) > 1 :
101+ qkv_groups .append (group )
102+ return qkv_groups
103+
104+
105+ def _quant_config_rank (qargs : dict [str , int | bool ]) -> tuple [int , int , int ]:
106+ """Rank quantization configs by precision; higher rank means more precise.
107+
108+ Ordering: higher ``bits`` wins; among equal bits, smaller positive ``group_size`` wins;
109+ per-channel (``-1``) wins over per-tensor (``0``) but loses to positive group sizes.
110+ ``symmetric`` is intentionally not part of the ordering since it is a representation
111+ choice rather than a strict precision axis.
112+ """
113+ bits = qargs ["bits" ].value if hasattr (qargs ["bits" ], "value" ) else qargs ["bits" ]
114+ group_size = qargs ["group_size" ]
115+ if group_size > 0 :
116+ group_size_rank = (2 , - group_size )
117+ elif group_size == - 1 :
118+ group_size_rank = (1 , 0 )
119+ else :
120+ group_size_rank = (0 , 0 )
121+ return bits , * group_size_rank
122+
123+
124+ def normalize_qkv_quant_config (
125+ wrapper : ModelWrapper ,
126+ qcfg : OliveHfQuantizationConfig ,
127+ locked_modules : set [str ] | None = None ,
128+ ) -> OliveHfQuantizationConfig :
129+ """Promote split QKV projection overrides to one shared quantization config.
130+
131+ Groups span all attention input projections of a layer regardless of whether the current
132+ pass quantizes them; follow-up passes (e.g. RTN after AutoClip) will pick up the shared
133+ settings via the recorded overrides so downstream QKV fusion remains valid.
134+
135+ ``locked_modules`` are modules whose overrides must not be rewritten -- typically the
136+ pre-existing overrides of an already-quantized checkpoint. For a group containing a
137+ locked member, the shared config is forced to that locked member's config; if multiple
138+ locked members of one group disagree, the group is left untouched.
139+ """
140+ locked_modules = locked_modules or set ()
141+ for group in get_qkv_quantization_groups (wrapper ):
142+ group_qargs = {name : qcfg .get_qlinear_init_args (name ) for name in group }
143+ if len ({tuple (qargs .items ()) for qargs in group_qargs .values ()}) == 1 :
144+ continue
145+
146+ locked_in_group = [name for name in group if name in locked_modules ]
147+ locked_configs = {tuple (group_qargs [name ].items ()) for name in locked_in_group }
148+ if len (locked_configs ) > 1 :
149+ logger .debug (
150+ "QKV group %s contains already-quantized members with conflicting configs; "
151+ "skipping (downstream QKV fusion may be inhibited)." ,
152+ group ,
153+ )
154+ continue
155+ promoted_qargs = (
156+ group_qargs [locked_in_group [0 ]] if locked_in_group else max (group_qargs .values (), key = _quant_config_rank )
157+ )
158+
159+ logger .debug ("Promoting QKV group %s to shared quantization config %s" , group , promoted_qargs )
160+ for name in group :
161+ if name in locked_modules :
162+ continue
163+ override = {k : v for k , v in promoted_qargs .items () if getattr (qcfg , k ) != v }
164+ if override :
165+ qcfg .overrides [name ] = OliveHfQuantizationOverrideConfig (** override )
166+ else :
167+ qcfg .overrides .pop (name , None )
168+
169+ return qcfg
170+
171+
172+ def _collect_excluded_attn_inputs (wrapper : ModelWrapper ) -> set [torch .nn .Module ]:
173+ excluded : set [torch .nn .Module ] = set ()
174+ for layer_wrapper in wrapper .get_layer_wrappers ():
175+ attn_inputs , _ = layer_wrapper .get_attention_inputs ()
176+ if len (attn_inputs ) == 1 :
177+ excluded .add (attn_inputs [0 ])
178+ else :
179+ excluded .update (attn_inputs [:2 ])
180+ return excluded
181+
182+
81183def prepare_model (
82184 model : HfModelHandler ,
83185 config : type [BasePassConfig ],
@@ -99,42 +201,65 @@ def prepare_model(
99201 if existing_qcfg := getattr (model .get_hf_model_config (), "quantization_config" , None ):
100202 if not allow_quantized :
101203 raise ValueError ("Model is already quantized. Cannot quantize again using this pass." )
102- if not isinstance (existing_qcfg , dict ):
103- existing_qcfg = existing_qcfg .to_dict ()
204+ # Always work on a fresh copy: the underlying HF config holds the original object
205+ # (dict or dataclass) and we mutate ``existing_qcfg`` heavily below.
206+ existing_qcfg = deepcopy (existing_qcfg ) if isinstance (existing_qcfg , dict ) else existing_qcfg .to_dict ()
104207 if existing_qcfg .get ("quant_method" , None ) != OliveHfQuantizationMethod .OLIVE :
105208 raise ValueError ("Model has an existing quantization configuration that is not compatible with this pass." )
106209
107210 wrapper = ModelWrapper .from_model (load_hf_base_model (model ))
108211 wrapper .model .eval ()
109212
110- qcfg = get_quant_config (model , config )
213+ excluded_attn_inputs = _collect_excluded_attn_inputs (wrapper ) if exclude_attn_inputs else set ()
214+
215+ fresh_qcfg = normalize_qkv_quant_config (wrapper , get_quant_config (model , config ))
111216
112217 originally_tied_embeddings = wrapper .config .tie_word_embeddings
113- if qcfg .lm_head or qcfg .embeds :
218+ if fresh_qcfg .lm_head or fresh_qcfg .embeds :
114219 wrapper .maybe_untie_word_embeddings ()
115220
116221 lm_head_name = wrapper .get_lm_head ()[1 ]
117222 embeds_name = wrapper .get_embeds ()[1 ][0 ]
118- new_qargs : dict [str , dict [str , int | bool ]] = {}
119-
120- excluded_attn_inputs : set [torch .nn .Module ] = set ()
121- if exclude_attn_inputs :
122- for layer_wrapper in wrapper .get_layer_wrappers ():
123- attn_inputs , _ = layer_wrapper .get_attention_inputs ()
124- if len (attn_inputs ) == 1 :
125- excluded_attn_inputs .add (attn_inputs [0 ])
126- else :
127- excluded_attn_inputs .update (attn_inputs [:2 ])
128223
129224 def should_quantize (module : torch .nn .Module , name : str ) -> bool :
130225 if module in excluded_attn_inputs :
131226 return False
132227 if isinstance (module , torch .nn .Linear ):
133- return name != lm_head_name or qcfg .lm_head
134- if qcfg .embeds and isinstance (module , torch .nn .Embedding ):
228+ return name != lm_head_name or fresh_qcfg .lm_head
229+ if fresh_qcfg .embeds and isinstance (module , torch .nn .Embedding ):
135230 return name == embeds_name
136231 return False
137232
233+ # Pre-existing quantized weights are immutable. If we're merging with an existing
234+ # checkpoint, build the final qcfg first (merge fresh into existing, then renormalize
235+ # QKV with already-quantized modules locked) so that the quant_info we attach below
236+ # uses the same settings the on-disk fusion will require. Every module that is already
237+ # a QuantLinear/QuantEmbedding after load is on-disk-immutable, including those that
238+ # used the existing config's defaults (no explicit override entry).
239+ on_disk_overrides : set [str ] = set ()
240+ already_quantized : set [str ] = set ()
241+ if existing_qcfg :
242+ on_disk_overrides = set ((existing_qcfg .get ("overrides" ) or {}).keys ())
243+ already_quantized = {
244+ name for name , module in wrapper .model .named_modules () if isinstance (module , (QuantLinear , QuantEmbedding ))
245+ }
246+ fresh_names = {name for name , module in wrapper .model .named_modules () if should_quantize (module , name )}
247+ merged = existing_qcfg
248+ merged ["overrides" ] = existing_qcfg .get ("overrides" ) or {}
249+ for name in fresh_names :
250+ qargs = fresh_qcfg .get_qlinear_init_args (name )
251+ override = {k : v for k , v in qargs .items () if merged [k ] != v }
252+ if override :
253+ merged ["overrides" ][name ] = override
254+ merged ["lm_head" ] |= fresh_qcfg .lm_head
255+ merged ["embeds" ] |= fresh_qcfg .embeds
256+ qcfg = OliveHfQuantizationConfig (** merged )
257+ qcfg = normalize_qkv_quant_config (wrapper , qcfg , locked_modules = already_quantized )
258+ else :
259+ qcfg = fresh_qcfg
260+
261+ new_qargs : dict [str , dict [str , int | bool ]] = {}
262+
138263 def add_quant_info (module : torch .nn .Module , name : str ) -> torch .nn .Module :
139264 # TODO(jambayk): validate that the module and config are compatible
140265 qargs = qcfg .get_qlinear_init_args (name )
@@ -144,23 +269,15 @@ def add_quant_info(module: torch.nn.Module, name: str) -> torch.nn.Module:
144269
145270 replace_matching_submodules (wrapper .model , should_quantize , add_quant_info , description = "Preparing model" )
146271
147- # remove overrides for modules not being quantized
272+ # Drop overrides for modules that won't be quantized this pass. Pre-existing (on-disk)
273+ # overrides are preserved verbatim since they describe already-quantized weights.
274+ # QKV-group overrides for modules excluded from this pass are not kept: when the
275+ # follow-up pass runs, the quantized members in the group will be locked and pull the
276+ # remaining members back into the shared config via ``normalize_qkv_quant_config``.
148277 for name in list (qcfg .overrides or {}):
149- if name not in new_qargs :
278+ if name not in new_qargs and name not in on_disk_overrides :
150279 qcfg .overrides .pop (name )
151280
152- # merge the new_quant_settings into the existing quant_config
153- if existing_qcfg :
154- merged_qcfg_dict = existing_qcfg
155- merged_qcfg_dict ["overrides" ] = existing_qcfg .get ("overrides" ) or {}
156- for name , qargs in new_qargs .items ():
157- override = {k : v for k , v in qargs .items () if merged_qcfg_dict [k ] != v }
158- if override :
159- merged_qcfg_dict ["overrides" ][name ] = override
160- merged_qcfg_dict ["lm_head" ] |= qcfg .lm_head
161- merged_qcfg_dict ["embeds" ] |= qcfg .embeds
162- qcfg = OliveHfQuantizationConfig (** merged_qcfg_dict )
163-
164281 word_embeddings_eligible_for_tieing = (
165282 originally_tied_embeddings
166283 and embeds_name in new_qargs
0 commit comments