Skip to content

Reuse quantized embedding table for tied LM head in TieWordEmbeddings#2549

Open
justinchuby wants to merge 13 commits into
mainfrom
justinchu/tie-embeddings-reuse-quantized
Open

Reuse quantized embedding table for tied LM head in TieWordEmbeddings#2549
justinchuby wants to merge 13 commits into
mainfrom
justinchu/tie-embeddings-reuse-quantized

Conversation

@justinchuby

Copy link
Copy Markdown
Contributor

Describe your changes

Add a reuse mode to the TieWordEmbeddings graph surgery.

Previously TieWordEmbeddings handled two cases: both weights unquantized
(Gather + MatMul) or both quantized (GatherBlockQuantized + MatMulNBits).
There was no path for when only the embedding is quantized while the LM head
is still a float MatMul whose weight is the tied embedding (reached through a
Transpose). This happens naturally when the embedding Gather is quantized with
OnnxBlockWiseRtnQuantization while the transformer body is left for a separate
pass such as OnnxKQuantQuantization. In that state the tied word-embedding matrix
is stored twice — once as INT4 (embedding) and once as float16 (LM head) —
which is larger than an all-float16 model.

This PR adds:

  • handle_reuse — rebuilds the float LM head as a MatMulNBits that shares the
    embedding's INT4 qweight / scales / zero_point (the byte-identical table,
    reshaped from the 2D GatherBlockQuantized layout to the 3D MatMulNBits
    layout), then prunes the now-dead Transpose and the float embedding weight.
  • reuse_weights_match — a correctness gate that only ties when the float LM
    head weight actually equals the dequantized embedding table (comparing a slice),
    so an untied projection is never incorrectly tied.

Pipeline it enables (smallest size at the highest-quality body quantization):

MobiusBuilder(fp16)
  -> OnnxKQuantQuantization            # body -> Q4_K_M
  -> OnnxBlockWiseRtnQuantization      # embedding Gather -> GatherBlockQuantized
  -> GraphSurgeries[TieWordEmbeddings] # LM head reuses the embedding INT4 table

Each pass touches only its intended nodes (body MatMuls have initializer weights;
the embedding Gather has an initializer weight; the tied LM head's weight is
behind a Transpose, so it is skipped by both quantizers and handled here).

Measured on a 1.8B tied-embedding translation model (CUDA): on-disk size drops from
~1.39 GB (K-Quant body, float16 embedding/LM head) to ~1.03 GB, with equal or
better output fidelity vs float16 compared to the two-table INT4 variant.

Checklist before requesting a review

  • Add unit tests for this change.
  • Make sure all tests can pass.
  • Update documents if necessary.
  • Lint and apply fixes to your code by running lintrunner -a
  • Is this a user-facing change? If yes, give a description of this change to be included in the release notes.
    TieWordEmbeddings can now tie a float LM head onto an already-quantized
    (GatherBlockQuantized) embedding, storing the shared word-embedding matrix
    once as INT4 instead of INT4 + float16.

(Optional) Issue link

Copilot AI review requested due to automatic review settings July 1, 2026 17:33
justinchuby added a commit to microsoft/olive-recipes that referenced this pull request Jul 1, 2026
Replace the GPTQ+RTN CUDA INT4 pipeline with:
  MobiusBuilder(fp16)
    -> OnnxKQuantQuantization(body, Q4_K_M)
    -> OnnxBlockWiseRtnQuantization(embedding Gather -> GatherBlockQuantized)
    -> GraphSurgeries[TieWordEmbeddings]  (LM head reuses the embedding INT4 table)

Each pass touches only its intended nodes: K-Quant quantizes the body MatMuls,
ONNX RTN quantizes the embedding Gather, and TieWordEmbeddings rebuilds the tied
LM head as a MatMulNBits sharing the embedding's INT4 table (pruning the float
weight). This gives the smallest on-disk model (~1.03 GB) at K-Quant body quality,
with translation quality on par with the K-Quant baseline and better than GPTQ.

Requires the TieWordEmbeddings reuse mode from microsoft/Olive#2549.

Drop gptqmodel from requirements (no longer used); update info.yml and README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the TieWordEmbeddings ONNX graph surgery to support a new reuse mode where the embedding is already quantized (GatherBlockQuantized) but the tied LM head is still a float MatMul. In this case, the surgery rebuilds the LM head as MatMulNBits that reuses the embedding’s quantized tensors to avoid storing the same embedding table twice.

Changes:

  • Add a reuse path in TieWordEmbeddings to convert a float LM-head MatMul into MatMulNBits sharing the embedding’s quantized qweight/scales/zero_point.
  • Add a reuse_weights_match gate to verify the float LM-head weight matches the dequantized embedding table before tying.
  • Add unit tests covering both the successful reuse case and the “skip when not actually tied” case.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
olive/passes/onnx/graph_surgeries.py Adds the reuse-mode surgery + correctness gate and performs pruning/rewiring of the LM head to reuse embedding quantized tensors.
test/passes/onnx/test_graph_surgeries.py Adds unit tests that validate reuse-mode tying occurs only when the float LM head truly matches the embedding table.

Comment on lines +2435 to +2438
graph_idx = dag.get_graph_idx(lm_head_name)
n_blocks = hidden // block_size
blob_size = block_size * bits // 8

Comment on lines +2519 to +2537
n = min(n_check, vocab)
n_blocks = hidden // block_size

# Unpack two 4-bit codes per byte (low nibble first), matching MatMulNBits packing.
q = qweight[:n]
codes = np.empty((n, hidden), np.float32)
codes[:, 0::2] = (q & 0x0F).astype(np.float32)
codes[:, 1::2] = (q >> 4).astype(np.float32)
codes = codes.reshape(n, n_blocks, block_size)

if zero_point is not None:
zp = zero_point[:n]
zcodes = np.empty((n, n_blocks), np.float32)
zcodes[:, 0::2] = (zp & 0x0F).astype(np.float32)
zcodes[:, 1::2] = (zp >> 4).astype(np.float32)
zcodes = zcodes.reshape(n, n_blocks, 1)
else:
# Symmetric quantization centers codes at the midpoint of the 4-bit range.
zcodes = np.float32(2 ** (bits - 1))
Comment on lines +2486 to +2487
if transpose_name is not None and not dag.get_consumers(transpose_name):
dag.remove_node(transpose_name)
Introduce a RewriteRuleSurgeon base class that lets graph surgeries be expressed
as onnxscript rewrite rules over the ONNX IR model, instead of manual protobuf /
OnnxDAG manipulation. Subclasses implement rules() returning a RewriteRuleSet;
the base applies them via call_ir, so the rewriter handles operand commutativity,
use-count bookkeeping, and dead-node cleanup.

Port the first two pattern-based surgeries to this base:
- ReciprocalMulToDiv: a * Reciprocal(x) -> Div(a, x) (commute=True covers both
  operand orders).
- ReplaceErfWithTanh: Erf(x) -> Tanh(x * 605/503), emitting the scale as an
  initializer of the input's floating-point dtype.

This is the first batch of an incremental migration of graph_surgeries.py off the
protobuf/OnnxDAG approach; subsequent batches will port the remaining pattern-based
surgeries (Gemm<->MatMul+Add, QDQ, RMSNorm variants, decompositions, ...) and move
the whole-graph surgeries to plain onnx_ir.

Update the ReplaceErfWithTanh test to read the scale via numpy_helper.to_array so
it is agnostic to raw_data vs float_data tensor storage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@justinchuby justinchuby force-pushed the justinchu/tie-embeddings-reuse-quantized branch from 8d2f2fa to 5c8867b Compare July 1, 2026 18:02
@justinchuby justinchuby changed the base branch from main to justinchu/graph-surgeries-ir-rewriter July 1, 2026 18:02
justinchuby and others added 9 commits July 1, 2026 19:14
Convert RemoveGidxFromMatMulNBits from protobuf iteration to an onnx_ir call_ir
implementation: drop a sorted (identity-permutation) g_idx input via
resize_inputs and prune the now-unused g_idx initializer. Behavior unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
- InferShapes: delegate to onnx_ir ShapeInferencePass.
- RemoveShapes: clear type/shape on intermediate values (empties value_info).
- RemoveInputs: drop named graph inputs and their node references via onnx_ir,
  removing nodes left with no inputs.

Behavior unchanged; verified by existing surgery tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Rebuild ZeroOutInput on the onnx_ir API: read the target input's shape/dtype from
the IR value, emit a zero Constant, and rewire the node input. Update the test to
read the constant via numpy_helper.to_array (IR stores tensors as raw_data).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Reimplement RemoveMemcpy on onnx_ir: bypass 1-in/1-out MemcpyToHost/MemcpyFromHost
nodes via Value.replace_all_uses_with (which follows consumers into subgraphs),
recurse into Loop/If/Scan subgraphs, preserve public output names on the output
boundary, and re-order with TopologicalSortPass. Replaces ~185 lines of manual
proto bypass/rename/topo-sort logic with ~40. Behavior verified by the 4 existing
RemoveMemcpy tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Rewrite ReplaceAttentionMaskValue on onnx_ir: clamp below-threshold entries in
float Constant/ConstantOfShape node values and initializers whose consumers are
all mask-compatible ops. Behavior unchanged; verified by the existing test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Convert RMSNorm, SimplifiedLayerNorm, and Pow/ReduceSum norm graph surgeries to mutate onnx_ir directly while preserving weight scaling, all-ones weights, and ReduceMean opset handling. Full graph surgery tests pass and lint is clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Convert the shape-dependent MatMul/Add and Gemm rewrites to the onnx_ir Surgeon path while preserving reshape, Relu, and transB handling. Targeted and full graph surgery tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Convert the GQA RoPE cache, attention-mask sequence length,
and quantized-output exposure surgeries to operate through onnx_ir.
Behavior is unchanged; graph surgery tests and lint pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Convert both graph surgeons to implement call_ir using onnx_ir while preserving their quantized initializer creation, shared-weight rewiring, output-name handling, and cleanup behavior. Verified with ad-hoc tiny ONNX models for both surgeries plus the existing graph_surgeries pytest module.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@justinchuby justinchuby force-pushed the justinchu/tie-embeddings-reuse-quantized branch from 5c8867b to 2f8d752 Compare July 1, 2026 20:01
Add regression coverage for two previously untested migrated surgeries:
- PowReduceSumPowDiv2LpNorm: Pow(2)->ReduceSum->Pow(0.5)->Div collapses to LpNormalization.
- QuantizeEmbeddingInt8: an embed_tokens Gather over an FP16 weight becomes an INT8
  GatherBlockQuantized with a uint8 quantized table.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@justinchuby justinchuby force-pushed the justinchu/tie-embeddings-reuse-quantized branch from 2f8d752 to b148803 Compare July 1, 2026 21:22
justinchuby and others added 2 commits July 1, 2026 21:30
- RemoveShapes: iterate model.graph.all_nodes() so value_info is cleared in
  subgraphs too, preserving every graph's declared outputs.
- ReplaceErfWithTanh: restore BFLOAT16 support (via ml_dtypes) so the scale is
  emitted in the input's floating-point dtype as documented.
- ExposeQuantizedOutput: guard against missing/None scale/zero-point inputs
  instead of dereferencing .name on None (and assuming >=3 inputs).
- RemoveRopeMultiCache: only remove the If-condition producer when it is a
  Greater node with no remaining consumers (avoid removing an unrelated node
  or raising StopIteration).
- QuantizeEmbeddingInt8 / ShareEmbeddingLmHead: do not downgrade an existing
  com.microsoft opset version; bump up to at least 1.
- AttentionMaskToSequenceLengths: default batch dim to 1 when input_ids is
  missing or its shape is unknown (dynamic models).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
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>
@justinchuby justinchuby force-pushed the justinchu/tie-embeddings-reuse-quantized branch from b148803 to 3dcb3fa Compare July 1, 2026 21:31
Base automatically changed from justinchu/graph-surgeries-ir-rewriter to main July 2, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants