71c262ce1a
Two independent fixes validated end-to-end on fresh fixtures: 1. KV cache disk I/O (issue_diskio.md opportunities #1 + #4): - kv_disk_append: fopen/fclose every turn -> persistent FILE* kept open for the engine lifetime, lazy open on first append, closed in serve_ctx_free. Eliminates per-turn handle creation overhead. - kv_disk_append: ~157 small fwrites per position -> one contiguous record memcpy'd into a staging buffer then a single fwrite per position. The staging buffer grows on demand via realloc. - kv_disk_truncate: closes the persistent handle before truncating so the file actually shrinks on disc, then reopens lazily. - KVState gains disk_fp, disk_buf, disk_buf_cap fields. - Verified: serve-mode round-trip, write 11 tokens then reload and resume with no re-prefill, then append 8 more and reload to 19. 2. Expert weight unfusing in test-model generators: - The real GLM-5.2-FP8 checkpoint stores routed experts UNFUSED as per-expert 2-D tensors, each with its own _scale_inv. HF fuses gate+up into a single 3-D gate_up_proj for compute efficiency. - The converter and C engine both expect the unfused layout. The fused 3-D tensors were silently skipped by the converter, and the engine crashed with missing-tensor errors. - New unfuse_experts in glm_fp8_emit.py splits gate_up_proj and down_proj into per-expert 2-D tensors. Called after reference generation but before saving, in both generators, both FP8 and bf16. - Also fixed: make_glm_oracle.py FP8 round-trip guard used p.dim()<2 which let 3-D fused experts through and crashed fp8_block_quantize. Changed to p.dim()!=2 to match the converter ndim!=2 guard. Validated full chain on fresh fixtures: generator --fp8 -> 570 e4m3 tensors + 629 scale_inv, was 90 when fused converter --group-size 0 -> per-row int4 fmt=2, engine loads clean converter --group-size 128 -> grouped int4 fmt=4, 8-16x more scales, engine loads clean, fmt=4 auto-detected in both mmap and slab paths dequant error: grouped 1.14-1.22x lower than per-row vs FP8 source
138 lines
7.0 KiB
Python
138 lines
7.0 KiB
Python
"""Helper: salva pesi in FP8 e4m3 + scale a blocchi 128x128, nello STESSO layout del
|
|
checkpoint reale GLM-5.2-FP8 che `convert_fp8_to_int4.py` legge.
|
|
|
|
Layout (deve combaciare col `dequant()` del converter, convert_fp8_to_int4.py:164-169):
|
|
- `name` F8_E4M3 [O, I]
|
|
- `name_scale_inv` F32 [ceil(O/128), ceil(I/128)] (NOTA: '_scale_inv', underscore)
|
|
dequant: W = q.float() * scale.repeat_interleave(128,0).repeat_interleave(128,1)[:O,:I]
|
|
|
|
Convenzione FBGEMM/TransformerEngine: scale = amax(blocco)/448 (448 = max e4m3),
|
|
si MEMORIZZA il valore e si MOLTIPLICA in dequant. Malgrado il nome "_scale_inv" il
|
|
checkpoint memorizza la scala (non il reciproco): e' un MOLTIPLIER.
|
|
|
|
EN: Helper that writes weights as FP8 e4m3 with 128x128 block scales, in the SAME layout
|
|
EN: as the real GLM-5.2-FP8 checkpoint that `convert_fp8_to_int4.py` reads.
|
|
EN: FBGEMM/TransformerEngine convention: scale = amax(block)/448, stored (not its
|
|
EN: reciprocal) and MULTIPLIED on dequant. Despite the name "_scale_inv" it is a multiplier.
|
|
"""
|
|
import torch
|
|
|
|
E4M3_MAX = 448.0 # max valore rappresentabile in float8_e4m3fn / max representable value
|
|
BLOCK = 128 # granularita' delle scale a blocchi del checkpoint FP8 / FP8 block scale granularity
|
|
|
|
|
|
def keep_f32(name, t):
|
|
"""Stesso set F32 di `classify()` in convert_fp8_to_int4.py (norme, router, bias 1-D).
|
|
Tutti gli altri tensori 2-D vengono quantizzati FP8 (attn/mlp/shared/expert/embed/lm_head).
|
|
EN: Same F32 set as the converter's classify(): norms, router, 1-D biases. All other 2-D
|
|
EN: tensors are FP8-quantized (attn/mlp/shared/expert/embed/lm_head)."""
|
|
if t.dim() < 2:
|
|
return True # bias 1-D, e_score_correction_bias
|
|
if name.endswith("e_score_correction_bias"):
|
|
return True
|
|
if name.endswith("mlp.gate.weight"):
|
|
return True # router (NON gate_proj): tenuto F32 / kept F32
|
|
if name.endswith("norm.weight") or name == "model.norm.weight":
|
|
return True # RMSNorm
|
|
return False
|
|
|
|
|
|
def fp8_block_quantize(w):
|
|
"""w: [O,I] f32 -> (w_fp8 float8_e4m3fn [O,I], scale_inv f32 [ceil(O/128),ceil(I/128)]).
|
|
Identica matematica al `--selftest` del converter (scale = amax(blocco)/448). Padda a
|
|
multipli di 128 internamente (gli zeri non alzano l'amax) e fa slice al risultato.
|
|
EN: same math as the converter's --selftest. Pads to 128 multiples internally (zeros do
|
|
EN: not raise amax), slices the result back to [O,I]."""
|
|
O, I = w.shape
|
|
nbO, nbI = (O + BLOCK - 1) // BLOCK, (I + BLOCK - 1) // BLOCK
|
|
Op, Ip = nbO * BLOCK, nbI * BLOCK
|
|
wpad = torch.zeros(Op, Ip, dtype=torch.float32, device=w.device)
|
|
wpad[:O, :I] = w
|
|
wb = wpad.view(nbO, BLOCK, nbI, BLOCK) # [nbO, BLOCK, nbI, BLOCK]
|
|
amax = wb.abs().amax(dim=(1, 3)) # [nbO, nbI]
|
|
scale = amax / E4M3_MAX # FBGEMM/TE: memorizza la scala / store the scale
|
|
scale = torch.where(scale == 0, torch.ones_like(scale), scale) # blocco tutto-zero -> no div0
|
|
scale = scale.to(torch.float32)
|
|
q = (wpad / scale.repeat_interleave(BLOCK, 0).repeat_interleave(BLOCK, 1)).clamp(-E4M3_MAX, E4M3_MAX)
|
|
w_fp8 = q.to(torch.float8_e4m3fn)
|
|
return w_fp8[:O, :I].contiguous(), scale.contiguous()
|
|
|
|
|
|
def fp8_block_dequantize(w_fp8, scale):
|
|
"""Esatto inverso di fp8_block_quantize, e identico al `dequant()` del converter.
|
|
EN: exact inverse of fp8_block_quantize, identical to the converter's dequant()."""
|
|
O, I = w_fp8.shape
|
|
qf = w_fp8.to(torch.float32)
|
|
return qf * scale.repeat_interleave(BLOCK, 0).repeat_interleave(BLOCK, 1)[:O, :I]
|
|
|
|
|
|
def unfuse_experts(sd):
|
|
"""Split HF's fused 3-D `experts.gate_up_proj` [E, 2*M, I] into per-expert 2-D
|
|
`experts.{e}.gate_proj` [M, I] + `experts.{e}.up_proj` [M, I], and
|
|
`experts.down_proj` [E, I, M] -> `experts.{e}.down_proj` [M_out, I].
|
|
|
|
The real GLM-5.2-FP8 checkpoint stores experts UNFUSED as per-expert 2-D tensors
|
|
(gate_proj, up_proj, down_proj), each with its own _scale_inv. HF's
|
|
GlmMoeDsaForCausalLM fuses gate+up into a single 3-D gate_up_proj for efficiency.
|
|
The converter (classify + ndim!=2 guard) and the C engine both expect the unfused
|
|
layout, so we split before saving.
|
|
|
|
Idempotent: if experts are already unfused (no 3-D gate_up_proj), returns sd as-is.
|
|
EN: split HF's fused 3-D expert weights into the per-expert 2-D layout that the real
|
|
EN: checkpoint uses and the converter/engine expect. No-op if already unfused."""
|
|
keys_to_remove = []
|
|
new_entries = {}
|
|
for name, t in sd.items():
|
|
if not name.endswith(".mlp.experts.gate_up_proj"):
|
|
continue
|
|
# prefix = everything before ".mlp.experts.gate_up_proj"
|
|
prefix = name[:-len(".mlp.experts.gate_up_proj")]
|
|
E, twoM, I = t.shape # [E, 2*intermediate, input]
|
|
M = twoM // 2
|
|
for e in range(E):
|
|
new_entries[f"{prefix}.mlp.experts.{e}.gate_proj.weight"] = t[e, :M, :].contiguous()
|
|
new_entries[f"{prefix}.mlp.experts.{e}.up_proj.weight"] = t[e, M:, :].contiguous()
|
|
keys_to_remove.append(name)
|
|
# down_proj may be 3-D [E, I, M] in the fused form, or already per-expert
|
|
for name, t in sd.items():
|
|
if not name.endswith(".mlp.experts.down_proj") or t.dim() != 3:
|
|
continue
|
|
prefix = name[:-len(".mlp.experts.down_proj")]
|
|
E = t.shape[0]
|
|
for e in range(E):
|
|
new_entries[f"{prefix}.mlp.experts.{e}.down_proj.weight"] = t[e].contiguous()
|
|
keys_to_remove.append(name)
|
|
for k in keys_to_remove:
|
|
sd.pop(k, None)
|
|
sd.update(new_entries)
|
|
return sd
|
|
|
|
|
|
def state_dict_to_fp8(sd):
|
|
"""Converte uno state_dict HuggingFace nel layout FP8 del checkpoint reale:
|
|
per ogni tensore quantizzabile 2-D scrive `{name}` (F8_E4M3) + `{name}_scale_inv` (F32);
|
|
norme/router/bias e qualsiasi tensore NON 2-D (es. pesi MLA impaccati 3-D) restano nel
|
|
dtype originale. Questo rispecchia il guard `w.ndim != 2 -> f32` del converter
|
|
(convert_fp8_to_int4.py:184). EN: builds the real-checkpoint FP8 layout. Only exactly-2-D
|
|
tensors are FP8-quantized; anything else (1-D, 3-D packed MLA weights, ...) is kept, exactly
|
|
like the converter's `ndim != 2 -> f32` guard."""
|
|
out = {}
|
|
for name, t in sd.items():
|
|
if keep_f32(name, t) or t.dim() != 2:
|
|
out[name] = t # f32 / 1-D / 3-D+: tieni / keep
|
|
else:
|
|
w_fp8, scale = fp8_block_quantize(t.float())
|
|
out[name] = w_fp8
|
|
out[name + "_scale_inv"] = scale
|
|
return out
|
|
|
|
|
|
def save_fp8_safetensors(sd, path):
|
|
"""Quantizza a blocchi FP8 e salva in un singolo safetensors leggibile dal converter
|
|
via `--indir`. EN: block-quantize to FP8 and save a single safetensors for the converter."""
|
|
from safetensors.torch import save_file
|
|
out = state_dict_to_fp8(sd)
|
|
save_file({k: v.contiguous() for k, v in out.items()}, str(path))
|
|
n_fp8 = sum(1 for v in out.values() if v.dtype == torch.float8_e4m3fn)
|
|
return n_fp8, len(out)
|