Skip to content

Commit 5c8867b

Browse files
justinchubyCopilot
andcommitted
Reuse quantized embedding table for tied LM head in TieWordEmbeddings
Add a reuse mode to the TieWordEmbeddings graph surgery for the case where the embedding has been quantized to GatherBlockQuantized but the LM head is still a float MatMul (its weight is the tied embedding, reached through a Transpose). Previously TieWordEmbeddings only handled both-unquantized (Gather + MatMul) or both-quantized (GatherBlockQuantized + MatMulNBits). When only the embedding Gather is quantized (e.g. OnnxBlockWiseRtnQuantization while the body is left for OnnxKQuantQuantization), the tied word-embedding matrix ends up stored twice: once as INT4 (embedding) and once as float16 (LM head), which is larger than a fully float16 model. handle_reuse rebuilds the LM head as a MatMulNBits that shares the embedding's INT4 qweight / scales / zero-point (the byte-identical table, reshaped to the MatMulNBits layout), and prunes the now-dead Transpose and float embedding weight. reuse_weights_match gates this on the float LM head weight actually matching the dequantized embedding table, so an untied projection is never tied. This lets a K-Quant body + shared-INT4 tied embedding/LM head model reach the smallest on-disk size at the highest-quality body quantization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
1 parent 49c5096 commit 5c8867b

2 files changed

Lines changed: 323 additions & 5 deletions

File tree

olive/passes/onnx/graph_surgeries.py

Lines changed: 194 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2128,8 +2128,17 @@ def __call__(self, model: ModelProto):
21282128
class TieWordEmbeddings(ProtoSurgeon):
21292129
"""Tie word embeddings.
21302130
2131-
Only supports when the embeddings are not quantized or when both are quantized
2132-
using GatherBlockQuantized and MatMulNBits
2131+
Supports three cases:
2132+
2133+
* both unquantized (``Gather`` + ``MatMul``),
2134+
* both quantized (``GatherBlockQuantized`` + ``MatMulNBits``), and
2135+
* a *reuse* case where the embedding is quantized (``GatherBlockQuantized``)
2136+
but the LM head is still a float ``MatMul``. The LM head is rebuilt as a
2137+
``MatMulNBits`` that shares the embedding's quantized weight table, so the
2138+
large word-embedding matrix is stored once as INT4 instead of once as INT4
2139+
(embedding) plus once as float16 (LM head). This is useful after quantizing
2140+
only the embedding ``Gather`` (e.g. ``OnnxBlockWiseRtnQuantization`` with the
2141+
body left for a separate pass such as ``OnnxKQuantQuantization``).
21332142
"""
21342143

21352144
def __call__(self, model: onnx.ModelProto):
@@ -2151,9 +2160,19 @@ def __call__(self, model: onnx.ModelProto):
21512160
if embed_name is None:
21522161
return dag.model
21532162

2154-
lm_head_name, lm_head_op_type = self.get_name_op_type(
2155-
dag, [dag.get_producer("logits")], ["MatMul", "MatMulNBits"], 1
2156-
)
2163+
# Reuse case: embedding already quantized but the LM head is still a float
2164+
# MatMul. Its weight is the tied embedding, possibly reached through a
2165+
# Transpose, so the weight input is not necessarily a direct initializer
2166+
# (which get_name_op_type would require). Detect it directly here.
2167+
logits_producer = dag.get_producer("logits")
2168+
if (
2169+
embed_op_type == "GatherBlockQuantized"
2170+
and logits_producer is not None
2171+
and dag.get_node_op_type(logits_producer) == "MatMul"
2172+
):
2173+
return self.handle_reuse(dag, embed_name, logits_producer)
2174+
2175+
lm_head_name, lm_head_op_type = self.get_name_op_type(dag, [logits_producer], ["MatMul", "MatMulNBits"], 1)
21572176
if lm_head_name is None:
21582177
return dag.model
21592178

@@ -2348,6 +2367,176 @@ def handle_quantized(self, dag: OnnxDAG, embed_name: str, lm_head_name: str):
23482367
dag.update()
23492368
return dag.model
23502369

2370+
def handle_reuse(self, dag: OnnxDAG, embed_name: str, lm_head_name: str):
2371+
"""Rebuild a float ``MatMul`` LM head as a ``MatMulNBits`` reusing the embedding's INT4 table.
2372+
2373+
The embedding is a ``GatherBlockQuantized`` node with inputs
2374+
``[qweight, indices, scales, zero_point?]``; the LM head is a float
2375+
``MatMul(activation, W)`` whose weight ``W`` is the tied embedding matrix,
2376+
possibly reached through a ``Transpose``. Because ``W`` equals the
2377+
embedding table, its quantized form is exactly the embedding's INT4 table,
2378+
so we point the new ``MatMulNBits`` at the embedding's ``qweight`` / scales
2379+
/ zero-point instead of quantizing ``W`` a second time. The float ``W``
2380+
(and any feeding ``Transpose``) then becomes dead and is pruned.
2381+
"""
2382+
embed_inputs = dag.get_node_inputs(embed_name)
2383+
embed_qweight = embed_inputs[0]
2384+
embed_scales = embed_inputs[2]
2385+
embed_zero_point = embed_inputs[3] if len(embed_inputs) > 3 else None
2386+
2387+
embed_attrs = dag.get_node_attributes(embed_name)
2388+
bits = int(embed_attrs["bits"])
2389+
block_size = int(embed_attrs["block_size"])
2390+
2391+
# LM head weight: trace through an optional Transpose to the source initializer.
2392+
lm_head_inputs = dag.get_node_inputs(lm_head_name)
2393+
lm_head_activation = lm_head_inputs[0]
2394+
weight_input = lm_head_inputs[1]
2395+
fp16_weight = weight_input
2396+
transpose_name = None
2397+
if not dag.is_initializer(weight_input):
2398+
weight_producer = dag.get_producer(weight_input)
2399+
if weight_producer is not None and dag.get_node_op_type(weight_producer) == "Transpose":
2400+
transpose_name = weight_producer
2401+
fp16_weight = dag.get_node_inputs(weight_producer)[0]
2402+
if not dag.is_initializer(fp16_weight):
2403+
logger.debug("LM head weight is not a (transposed) initializer; cannot reuse embedding quantization.")
2404+
return dag.model
2405+
2406+
# embed qweight is [vocab, hidden * bits / 8]; derive N (vocab) and K (hidden).
2407+
embed_qweight_shape = dag.get_io_shape(embed_qweight)
2408+
vocab = int(embed_qweight_shape[0])
2409+
hidden = int(embed_qweight_shape[1]) * 8 // bits
2410+
2411+
# Correctness gate: only reuse when the float LM head weight really is the
2412+
# (dequantized) embedding table, so an untied projection is never tied.
2413+
if not self.reuse_weights_match(dag, embed_name, fp16_weight, vocab, hidden, bits, block_size):
2414+
logger.debug("LM head weight does not match the embedding table; skipping reuse tie.")
2415+
return dag.model
2416+
2417+
logger.debug("Reusing quantized embedding weights for the LM head")
2418+
2419+
graph_idx = dag.get_graph_idx(lm_head_name)
2420+
n_blocks = hidden // block_size
2421+
blob_size = block_size * bits // 8
2422+
2423+
# GatherBlockQuantized stores qweight 2D [vocab, hidden * bits / 8]; MatMulNBits
2424+
# expects 3D [N, n_blocks, blob_size]. The bytes are identical, only reshaped.
2425+
reshape_output = self.add_reshape_node(
2426+
dag,
2427+
graph_idx,
2428+
self.create_new_name(lm_head_name, "MatMul", "Reshape_tied"),
2429+
embed_qweight,
2430+
[vocab, n_blocks, blob_size],
2431+
dag.get_io_elem_type(embed_qweight),
2432+
)
2433+
2434+
mmnb_inputs = [lm_head_activation, reshape_output, embed_scales]
2435+
if embed_zero_point is not None:
2436+
mmnb_inputs.append(embed_zero_point)
2437+
2438+
lm_head_output = dag.get_node_outputs(lm_head_name)[0]
2439+
mmnb_name = self.create_new_name(lm_head_name, "MatMul", "MatMulNBits")
2440+
mmnb_output = f"{mmnb_name}_output"
2441+
dag.add_node(
2442+
onnx.helper.make_node(
2443+
"MatMulNBits",
2444+
mmnb_inputs,
2445+
[mmnb_output],
2446+
name=mmnb_name,
2447+
domain="com.microsoft",
2448+
K=hidden,
2449+
N=vocab,
2450+
bits=bits,
2451+
block_size=block_size,
2452+
),
2453+
graph_idx,
2454+
)
2455+
2456+
# Redirect consumers of the old logits, then swap the graph output over.
2457+
# remove_node deletes the output IO, so preserve the logits value_info by
2458+
# copying it onto the new output before renaming (as in handle_unquantized).
2459+
mmnb_output_vi = onnx.ValueInfoProto()
2460+
mmnb_output_vi.CopyFrom(dag.get_value_info_proto(lm_head_output))
2461+
mmnb_output_vi.name = mmnb_output
2462+
dag.add_value_info(mmnb_output_vi, graph_idx)
2463+
2464+
for consumer in dag.get_consumers(lm_head_name):
2465+
dag.replace_node_input(consumer, lm_head_output, mmnb_output)
2466+
dag.remove_output(lm_head_output)
2467+
dag.remove_node(lm_head_name)
2468+
# Drop the now-orphaned Transpose (if any) so the float embedding weight it
2469+
# held loses its last consumer and is pruned from the serialized model.
2470+
if transpose_name is not None and not dag.get_consumers(transpose_name):
2471+
dag.remove_node(transpose_name)
2472+
dag.rename_node_output(mmnb_name, mmnb_output, lm_head_output)
2473+
dag.make_output(lm_head_output)
2474+
2475+
dag.update()
2476+
return dag.model
2477+
2478+
def reuse_weights_match(
2479+
self,
2480+
dag: OnnxDAG,
2481+
embed_name: str,
2482+
fp16_weight_name: str,
2483+
vocab: int,
2484+
hidden: int,
2485+
bits: int,
2486+
block_size: int,
2487+
n_check: int = 256,
2488+
) -> bool:
2489+
"""Check that ``fp16_weight`` equals the dequantized embedding table.
2490+
2491+
Only a slice of ``n_check`` rows is compared, which is enough to reject an
2492+
untied projection while keeping the check cheap. Only 4-bit packing is
2493+
supported; other widths conservatively return ``False``.
2494+
"""
2495+
if bits != 4:
2496+
return False
2497+
2498+
embed_inputs = dag.get_node_inputs(embed_name)
2499+
qweight = dag.get_initializer_np_array(embed_inputs[0]) # [vocab, hidden * bits / 8] uint8
2500+
scales = dag.get_initializer_np_array(embed_inputs[2]).astype(np.float32) # [vocab, n_blocks]
2501+
zero_point = dag.get_initializer_np_array(embed_inputs[3]) if len(embed_inputs) > 3 else None
2502+
2503+
n = min(n_check, vocab)
2504+
n_blocks = hidden // block_size
2505+
2506+
# Unpack two 4-bit codes per byte (low nibble first), matching MatMulNBits packing.
2507+
q = qweight[:n]
2508+
codes = np.empty((n, hidden), np.float32)
2509+
codes[:, 0::2] = (q & 0x0F).astype(np.float32)
2510+
codes[:, 1::2] = (q >> 4).astype(np.float32)
2511+
codes = codes.reshape(n, n_blocks, block_size)
2512+
2513+
if zero_point is not None:
2514+
zp = zero_point[:n]
2515+
zcodes = np.empty((n, n_blocks), np.float32)
2516+
zcodes[:, 0::2] = (zp & 0x0F).astype(np.float32)
2517+
zcodes[:, 1::2] = (zp >> 4).astype(np.float32)
2518+
zcodes = zcodes.reshape(n, n_blocks, 1)
2519+
else:
2520+
# Symmetric quantization centers codes at the midpoint of the 4-bit range.
2521+
zcodes = np.float32(2 ** (bits - 1))
2522+
2523+
block_scales = scales[:n].reshape(n, n_blocks, 1)
2524+
dequant = ((codes - zcodes) * block_scales).reshape(n, hidden) # ~= embedding[:n]
2525+
2526+
fp16 = dag.get_initializer_np_array(fp16_weight_name).astype(np.float32)
2527+
if list(fp16.shape) == [hidden, vocab]:
2528+
ref = fp16[:, :n].T
2529+
elif list(fp16.shape) == [vocab, hidden]:
2530+
ref = fp16[:n]
2531+
else:
2532+
return False
2533+
2534+
# Quantization error per element is bounded by one block step; use a
2535+
# generous multiple so a genuinely tied weight always passes while an
2536+
# untied projection (differing by ~weight magnitude) is rejected.
2537+
atol = max(4.0 * float(block_scales.max()), 1e-2)
2538+
return np.allclose(dequant, ref, atol=atol, rtol=0.0)
2539+
23512540
def equal_weights(self, dag: OnnxDAG, init0: str, init1: str, transpose: bool = False) -> bool:
23522541
shape0, shape1 = dag.get_io_shape(init0), dag.get_io_shape(init1)
23532542
if np.prod(shape0) != np.prod(shape1):

test/passes/onnx/test_graph_surgeries.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,6 +2164,135 @@ def test_tie_word_embeddings(tmp_path, quantized):
21642164
)
21652165

21662166

2167+
def _quantize_blockwise_int4(weight, block_size):
2168+
"""Block-wise asymmetric 4-bit RTN quantization matching MatMulNBits packing.
2169+
2170+
Quantizes ``weight`` ([rows, cols], float) along ``cols`` in blocks and returns
2171+
``(qweight, scales, zero_point)`` with the same packing (two 4-bit codes per
2172+
byte, low nibble first) that ``TieWordEmbeddings.reuse_weights_match`` expects.
2173+
"""
2174+
rows, cols = weight.shape
2175+
n_blocks = cols // block_size
2176+
w = weight.reshape(rows, n_blocks, block_size)
2177+
w_min = w.min(axis=2)
2178+
w_max = w.max(axis=2)
2179+
scale = np.where((w_max - w_min) == 0, 1.0, (w_max - w_min) / 15.0)
2180+
zero = np.clip(np.rint(-w_min / scale), 0, 15)
2181+
codes = np.clip(np.rint(w / scale[:, :, None] + zero[:, :, None]), 0, 15).astype(np.uint8)
2182+
codes = codes.reshape(rows, cols)
2183+
# Pack two 4-bit codes per byte (even column -> low nibble).
2184+
qweight = (codes[:, 0::2] | (codes[:, 1::2] << 4)).astype(np.uint8) # [rows, cols/2]
2185+
zcodes = zero.astype(np.uint8) # [rows, n_blocks]
2186+
zero_point = (zcodes[:, 0::2] | (zcodes[:, 1::2] << 4)).astype(np.uint8) # [rows, n_blocks/2]
2187+
return qweight, scale.astype(np.float16), zero_point
2188+
2189+
2190+
def _build_quantized_embedding_tied_model(tmp_path, lm_head_weight):
2191+
"""Build a tiny model with a GatherBlockQuantized embedding and a float MatMul LM head.
2192+
2193+
The LM head computes ``hidden @ Transpose(lm_head_weight)`` so its weight is
2194+
reached through a Transpose, mirroring how mobius emits tied embeddings. The
2195+
embedding is quantized to INT4; ``lm_head_weight`` ([vocab, hidden], float16) is
2196+
the tied weight (equal to the embedding table) or an unrelated weight.
2197+
"""
2198+
vocab, hidden, block_size = 6, 8, 4
2199+
batch, seq = 1, 2
2200+
2201+
qweight, scales, zero_point = _quantize_blockwise_int4(lm_head_weight.astype(np.float32), block_size)
2202+
2203+
input_ids = helper.make_tensor_value_info("input_ids", TensorProto.INT64, [batch, seq])
2204+
hidden_states = helper.make_tensor_value_info("hidden_states", TensorProto.FLOAT16, [batch, seq, hidden])
2205+
logits = helper.make_tensor_value_info("logits", TensorProto.FLOAT16, [batch, seq, vocab])
2206+
embeds = helper.make_tensor_value_info("embeds", TensorProto.FLOAT16, [batch, seq, hidden])
2207+
2208+
initializers = [
2209+
numpy_helper.from_array(qweight, name="embed.weight_Q4"),
2210+
numpy_helper.from_array(scales, name="embed.weight_scales"),
2211+
numpy_helper.from_array(zero_point, name="embed.weight_zero_point"),
2212+
numpy_helper.from_array(lm_head_weight, name="embed.weight"),
2213+
]
2214+
nodes = [
2215+
helper.make_node(
2216+
"GatherBlockQuantized",
2217+
["embed.weight_Q4", "input_ids", "embed.weight_scales", "embed.weight_zero_point"],
2218+
["embeds"],
2219+
name="embed/GatherBlockQuantized",
2220+
domain=MSFT_DOMAIN,
2221+
gather_axis=0,
2222+
quantize_axis=1,
2223+
block_size=block_size,
2224+
bits=4,
2225+
),
2226+
helper.make_node("Transpose", ["embed.weight"], ["lm_head.weight_t"], name="lm_head/Transpose", perm=[1, 0]),
2227+
helper.make_node("MatMul", ["hidden_states", "lm_head.weight_t"], ["logits"], name="lm_head/MatMul"),
2228+
]
2229+
graph = helper.make_graph(
2230+
nodes=nodes,
2231+
name="TiedReuseGraph",
2232+
inputs=[input_ids, hidden_states],
2233+
outputs=[logits, embeds],
2234+
initializer=initializers,
2235+
)
2236+
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 21), helper.make_opsetid(MSFT_DOMAIN, 1)])
2237+
model.ir_version = 10
2238+
model_path = tmp_path / "model.onnx"
2239+
onnx.save(model, model_path)
2240+
return ONNXModelHandler(model_path=str(model_path))
2241+
2242+
2243+
def test_tie_word_embeddings_reuse_shares_quantized_embedding(tmp_path):
2244+
# setup: LM head weight equals the (quantized) embedding table -> tied
2245+
np.random.seed(0)
2246+
weight = np.random.randn(6, 8).astype(np.float16)
2247+
input_model = _build_quantized_embedding_tied_model(tmp_path, weight)
2248+
2249+
p = create_pass_from_dict(GraphSurgeries, {"surgeries": [{"surgeon": "TieWordEmbeddings"}]}, disable_search=True)
2250+
2251+
# execute
2252+
output_model = p.run(input_model, str(tmp_path / "output"))
2253+
2254+
# assert: the float MatMul LM head is now a MatMulNBits sharing the embedding's
2255+
# quantized tensors, and the redundant Transpose + float weight are pruned.
2256+
dag = OnnxDAG.from_model_path(output_model.model_path)
2257+
op_types = [dag.get_node_op_type(n) for n in dag.get_node_names()]
2258+
assert "MatMul" not in op_types
2259+
assert "Transpose" not in op_types
2260+
assert op_types.count("MatMulNBits") == 1
2261+
2262+
logits_producer = dag.get_producer("logits")
2263+
assert dag.get_node_op_type(logits_producer) == "MatMulNBits"
2264+
# scales / zero_point are shared with the embedding node
2265+
lm_head_inputs = dag.get_node_inputs(logits_producer)
2266+
assert "embed.weight_scales" in lm_head_inputs
2267+
assert "embed.weight_zero_point" in lm_head_inputs
2268+
# the float embedding weight is no longer serialized
2269+
assert "embed.weight" not in dag.ios
2270+
2271+
2272+
def test_tie_word_embeddings_reuse_skips_untied_lm_head(tmp_path):
2273+
# setup: LM head weight is unrelated to the embedding table -> must NOT tie
2274+
np.random.seed(0)
2275+
embed_weight = np.random.randn(6, 8).astype(np.float16)
2276+
unrelated = (embed_weight + 5.0).astype(np.float16)
2277+
# quantize/serialize using the embedding weight, but feed an unrelated float weight
2278+
input_model = _build_quantized_embedding_tied_model(tmp_path, embed_weight)
2279+
# rewrite the float lm_head weight to an unrelated tensor
2280+
model = onnx.load(input_model.model_path)
2281+
for init in model.graph.initializer:
2282+
if init.name == "embed.weight":
2283+
init.CopyFrom(numpy_helper.from_array(unrelated, name="embed.weight"))
2284+
onnx.save(model, input_model.model_path)
2285+
2286+
p = create_pass_from_dict(GraphSurgeries, {"surgeries": [{"surgeon": "TieWordEmbeddings"}]}, disable_search=True)
2287+
2288+
# execute
2289+
output_model = p.run(input_model, str(tmp_path / "output"))
2290+
2291+
# assert: gate rejects the untied weight, LM head stays a float MatMul
2292+
dag = OnnxDAG.from_model_path(output_model.model_path)
2293+
assert dag.get_node_op_type(dag.get_producer("logits")) == "MatMul"
2294+
2295+
21672296
def test_remove_gidx_from_matmulnbits(tmp_path):
21682297
# setup
21692298
a_tensor = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 3])

0 commit comments

Comments
 (0)