Skip to content

Commit 39c0df6

Browse files
authored
[cuda backend] reduce memory consumption on gemma4_31b by running embedding in int8 (#20351)
Decode the tied GGUF token_embd/lm_head weight to a gatherable int8 IntxUnpackedToInt8Tensor for the embedding instead of dequantizing the whole ~1.4B-element weight to bf16 to reduce the memory consumption during exporation Halves the embedding's host + GPU-constant footprint (2 -> 1 B/elem) and reduce about 1.5 gb vram memory. The decode is lossless for Q6_K; for Q4_K the int8 embedding matches the precision of the tied lm_head and the other q4_k linears (same bf16 zero-point reconstruction), so it adds no error beyond the model's existing 4-bit level. Also create test case to verify it. lm_head keeps its packed format (Q6_K -> CudaDp4aPlanarInt6Tensor from the same int8 decode; Q4_K -> CudaCoalescedInt4Tensor via _resolve_tied_lm_head). MLX and non-Q4_K/Q6_K paths unchanged. Memory consumption: 28.3 GB with context length == 128k + turboquant
1 parent 99bce90 commit 39c0df6

2 files changed

Lines changed: 104 additions & 15 deletions

File tree

examples/models/gemma4_31b/gguf_loader.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
linear and embedding. ``embed_tokens`` and ``lm_head`` stay tied -- they share
1818
the one quantized tensor.
1919
* **CUDA**: Q4_K -> ``Int4Tensor``, Q6_K -> ``CudaDp4aPlanarInt6Tensor`` (a genuine
20-
6-bit packed weight, lossless, symmetric); ``lm_head`` keeps the quantized
21-
tensor but the token embedding is dequantized to bf16 (the packed tensors can't
22-
gather), so they are untied.
20+
6-bit packed weight, lossless, symmetric). ``embed_tokens`` and ``lm_head`` are
21+
untied: ``lm_head`` keeps a packed (int6/int4) matmul weight, while the token
22+
embedding becomes a gatherable ``IntxUnpackedToInt8Tensor`` (int8) -- the truly
23+
packed int4/int6 tensors can't gather. For the Q6_K tied weight the decode is
24+
done once and shared between the two, avoiding a whole-tensor bf16 dequant and
25+
a second decode (see ``_untie_embed_lm_head``).
2326
2427
Usage:
2528
model, config = load_gguf_model("model.gguf", backend="cuda")
@@ -116,6 +119,55 @@ def _resolve_tied_lm_head(model, lm_head_weight, packers):
116119
)
117120

118121

122+
def _untie_embed_lm_head(model, gtensor, weight, backend):
123+
"""Untie the GGUF token-embed / lm_head weight, returning ``(embed, lm_head)``.
124+
125+
GGUF ties ``embed_tokens`` and ``lm_head`` to one quantized weight. The
126+
returned ``lm_head`` is packed into ``model.lm_head`` after the streaming loop
127+
(``_resolve_tied_lm_head``), or is ``None`` when this function already
128+
assigned it.
129+
130+
* **MLX**: keep both tied on the raw ``ExportableGGUFTensor``.
131+
* **CUDA** (Q6_K or Q4_K): untie so ``lm_head`` keeps a packed low-bit matmul
132+
weight while the token embedding becomes a gatherable int8
133+
``IntxUnpackedToInt8Tensor`` -- the truly packed int4/int6 tensors can't
134+
gather. Instead of dequantizing the whole ~1.4 B-element weight to bf16
135+
(2 B/elem), decode it once to int8 (1 B/elem; the decode is lossless so the
136+
result is numerically identical), halving the embedding's host + GPU-constant
137+
footprint. The token embedding (Q4_K for the Gemma checkpoint) is the single
138+
biggest weight, so this is the dominant saving vs the bf16 path. ``lm_head``:
139+
- Q6_K -> ``CudaDp4aPlanarInt6Tensor`` from the *same* int8 decode and
140+
assigned here (``pack_linear_for_cuda`` would mis-route an int8 tensor to
141+
the int8 path), so the post-loop resolve is a no-op.
142+
- Q4_K -> kept as the native ``Int4Tensor`` and returned, so
143+
``_resolve_tied_lm_head`` packs it to ``CudaCoalescedInt4Tensor`` (same
144+
as a regular Q4_K linear).
145+
* **CUDA, other types**: fall back to the bf16 embedding.
146+
"""
147+
if backend == "mlx":
148+
return weight, gtensor
149+
150+
if gtensor.ggml_type in ("q6_k", "q4_k"):
151+
intx = gtensor.to_intx_unpacked_to_int8_tensor()
152+
if gtensor.ggml_type == "q6_k":
153+
import torch.nn as nn
154+
from executorch.backends.cuda.dp4a_planar_int6_tensor import (
155+
CudaDp4aPlanarInt6Tensor,
156+
)
157+
158+
model.lm_head.weight = nn.Parameter(
159+
CudaDp4aPlanarInt6Tensor._from_intx_int8(intx), requires_grad=False
160+
)
161+
return intx, None
162+
# Q4_K: ``weight`` is the native Int4Tensor; let _resolve_tied_lm_head
163+
# pack it to CudaCoalescedInt4Tensor. Only the embedding switches to int8.
164+
return intx, weight
165+
166+
from executorch.examples.models.gemma4_31b.quant import dequantize_weight
167+
168+
return dequantize_weight(weight, torch.bfloat16), weight
169+
170+
119171
def load_gguf_model(
120172
gguf_path: str,
121173
max_seq_len: int = 4096,
@@ -140,7 +192,7 @@ def load_gguf_model(
140192
Gemma4_31BConfig,
141193
materialize_runtime_buffers,
142194
)
143-
from executorch.examples.models.gemma4_31b.quant import dequantize_weight, pack_one
195+
from executorch.examples.models.gemma4_31b.quant import pack_one
144196
from executorch.extension.llm.export.gguf import ExportableGGUFTensor, iter_gguf
145197

146198
if backend == "cuda":
@@ -161,7 +213,7 @@ def load_gguf_model(
161213
with torch.device("meta"):
162214
model = Gemma4_31B(config)
163215

164-
lm_head_weight = None # weight reused for a tied lm_head
216+
lm_head_weight = None # tied weight resolved into lm_head after the loop
165217
n_processed = 0
166218

167219
print(f"Streaming GGUF from {gguf_path}...")
@@ -173,11 +225,9 @@ def load_gguf_model(
173225
if isinstance(value, ExportableGGUFTensor):
174226
weight = _convert_weight(model, model_key, value, backend)
175227
if model_key == "embed_tokens.weight":
176-
# Tied lm_head reuses the embedding weight: MLX wants the raw
177-
# ExportableGGUFTensor (linear pattern), CUDA the quant tensor.
178-
lm_head_weight = value if backend == "mlx" else weight
179-
if backend == "cuda":
180-
weight = dequantize_weight(weight, torch.bfloat16)
228+
weight, lm_head_weight = _untie_embed_lm_head(
229+
model, value, weight, backend
230+
)
181231
value = weight
182232
elif value.dtype == torch.float32:
183233
value = value.to(torch.bfloat16)

examples/models/gemma4_31b/tests/test_cuda_pipeline.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,14 @@ def _load(self, tmp):
246246

247247
def test_load_converts_weights(self):
248248
"""GGUF -> CUDA: Q4_K -> CudaCoalescedInt4Tensor, Q6_K -> CudaDp4aPlanarInt6Tensor,
249-
embedding bf16."""
249+
embedding int8 (gatherable)."""
250250
from executorch.backends.cuda.coalesced_int4_tensor import (
251251
CudaCoalescedInt4Tensor,
252252
)
253253
from executorch.backends.cuda.dp4a_planar_int6_tensor import (
254254
CudaDp4aPlanarInt6Tensor,
255255
)
256+
from torchao.quantization import IntxUnpackedToInt8Tensor
256257

257258
with tempfile.TemporaryDirectory() as tmp:
258259
model, _ = self._load(tmp)
@@ -263,11 +264,49 @@ def test_load_converts_weights(self):
263264
self.assertIsInstance(
264265
model.layers[0].mlp.down_proj.weight.data, CudaDp4aPlanarInt6Tensor
265266
)
266-
# Tied lm_head is repacked to int6 by pack_cuda (it keeps quantization,
267-
# unlike the token embedding which is dequantized for the gather).
267+
# Tied lm_head keeps a packed int6 matmul weight.
268268
self.assertIsInstance(model.lm_head.weight.data, CudaDp4aPlanarInt6Tensor)
269-
# Token embedding is dequantized to bf16 (Int4/packed-int6 can't gather).
270-
self.assertEqual(model.embed_tokens.weight.dtype, torch.bfloat16)
269+
# Token embedding is decoded to a gatherable int8 tensor (not bf16): the
270+
# Q6_K decode is lossless and shared with lm_head. Keeping it int8 (vs
271+
# bf16) avoids a ~5.6 GB fp32 dequant transient and ~1.4 GB resident at
272+
# export time.
273+
self.assertIsInstance(model.embed_tokens.weight.data, IntxUnpackedToInt8Tensor)
274+
275+
def test_int8_embedding_matches_bf16(self):
276+
"""Guard the bf16 -> int8 token-embedding switch.
277+
278+
The embedding is now loaded as a gatherable int8 ``IntxUnpackedToInt8Tensor``
279+
instead of being dequantized to bf16. Its gathered rows must match the bf16
280+
dequant of the *source* GGUF token embedding -- i.e. exactly what the old
281+
``dequantize_weight(..., bf16)`` path returned. The GGUF decode is lossless,
282+
so they agree to bf16 precision.
283+
"""
284+
from executorch.examples.models.gemma4_31b.gguf_loader import gguf_to_model_key
285+
from executorch.extension.llm.export.gguf import ExportableGGUFTensor, iter_gguf
286+
from torchao.quantization import IntxUnpackedToInt8Tensor
287+
288+
with tempfile.TemporaryDirectory() as tmp:
289+
path = os.path.join(tmp, "tiny.gguf")
290+
build_gguf_checkpoint(path)
291+
# Reference = bf16 dequant of the source GGUF token embedding (the
292+
# tensor the previous bf16 embedding path materialized).
293+
ref_bf16 = None
294+
for name, val in iter_gguf(path):
295+
if gguf_to_model_key(name) == "embed_tokens.weight":
296+
self.assertIsInstance(val, ExportableGGUFTensor)
297+
ref_bf16 = val.dequantize(torch.bfloat16)
298+
break
299+
self.assertIsNotNone(ref_bf16, "token_embd.weight not found in GGUF")
300+
model, _ = load_gguf_model(path, backend="cuda", config=GGUF_CONFIG)
301+
302+
self.assertIsInstance(model.embed_tokens.weight.data, IntxUnpackedToInt8Tensor)
303+
304+
ids = torch.tensor([0, 1, 7, GGUF_CONFIG.vocab_size - 1])
305+
out = model.embed_tokens(ids) # int8 gather + dequant
306+
ref = ref_bf16[ids]
307+
self.assertEqual(out.shape, ref.shape)
308+
rel_err = (out.float() - ref.float()).abs().mean() / ref.float().abs().mean()
309+
self.assertLess(rel_err.item(), 0.02)
271310

272311
def test_generate(self):
273312
"""GGUF -> CUDA -> eager generate produces valid tokens (inference.py)."""

0 commit comments

Comments
 (0)