Group-scaled int4 (fmt=4): one scale per 128 elements — fixes incoherent output#242
Conversation
…ot per row Root cause of gibberish output: the int4 quantization uses one F32 scale per output row (2048 scales for a 2048x6144 matrix). The FP8 source has 128x128 block scales — 48x finer. This destroys reasoning while keeping surface fluency. Changes: - Converter: quant_int4_grouped() with --group-size 128 arg. Same nibble packing, but one scale per group of 128 elements along the input dim. - Engine QT struct: added 'gs' field (group size, 0=per-row backward compat) - Engine qt_from_disk: auto-detects fmt=4 when scale array is O*ceil(I/128) elements instead of O. Old per-row models (fmt=2) work unchanged. - Engine matmul_i4_grouped(): AVX2 kernel that applies per-group scales. Accumulator resets at each group boundary: dot(x[grp],w[grp]) * scale[grp]. - Engine matmul_qt_ex: dispatches to grouped kernel for fmt=4 (always exact, no IDOT approximation since the point is quality) - Engine expert_load: both mmap and slab+pread paths detect fmt=4 from scale array size and set gs=128 - qt_bytes: fmt=4 reports correct memory including group scales Backward compatible: existing per-row int4 models work unchanged. The fused gate+up pair path (matmul_i4_pair) falls back to separate matmul_qt calls for fmt=4 — minor perf cost, correctness preserved.
|
Merged to Correctness — Backward compatibility — proven, not assumed. I built the tiny oracle and ran the same snapshot through Two follow-ups (not blockers):
|
|
Thanks for the thorough review — and for independently verifying the backward compat (the byte-identical oracle match is exactly the right test, glad it held). On the two follow-ups:
|
…rter, tests New weight format: int3 with ONE f32 scale per 64-input group (3.5 bits/weight effective). Per group: 16B low plane (2 bits/val, int2 layout) + 8B high plane (1 bit/val), values [-4,3] stored v+4. Same quantization math as tools/quant_ablation.py _quant_last_dim(bits=3, group=64) from JustVugg#132, whose OLMoE ablation measured int3-g64 BEATING the shipped per-row int4 on quality (-7.5 vs -9.3pp) at ~14% fewer bits. Engine: pack_int3_g64 + matmul_i3 (NEON low-plane unpack reuses matmul_i2's pattern, high plane via vtst bit-expansion; scalar reference everywhere; exact f32 path only - no IDOT in v1), fmt=5 branches in qt_bytes/qt_alloc/qt_fill/ matmul_qt/embed_row/qt_addrow/qt_matvec_rows. Format detection: fmt is not stored on disk and row formats are inferred from byte counts, so the .qs scale tensor SIZE is the tag (O floats = row formats, O*ceil(I/64) = fmt5); qt_fmt_from_bytes centralizes the inference for the dense loader and both expert_load paths. Metal/CUDA fall back to CPU for fmt5 via existing gates. Converter: quant_int3_g64 in convert_fp8_to_int4.py; --ebits 3/--xbits 3 now emit it (previously bits=3 silently produced int4). Tests: tests/test_int3.c (bit-exact pack/unpack vs reference, matmul_i3 vs dequant-matmul incl. short tail groups and the real GLM I=7168, QT plumbing, format tag, outlier-rows RMS: int3-g64 3.3x lower error than per-row int4), tests/test_int3_load.c (hand-rolled .safetensors fixture through st_init + qt_from_disk: fmt5 detected and loaded next to an int4 control tensor), tests/test_int3_convert.py (NumPy pack round-trip vs independent decoder). Rebased onto current `dev`: renumbered int3-g64 to fmt=5 because JustVugg#242 (grouped int4) took fmt=4 in the meantime; on-disk detection is by the distinct int3 weight-byte count, so it coexists with grouped-int4's scale-size detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rter, tests New weight format: int3 with ONE f32 scale per 64-input group (3.5 bits/weight effective). Per group: 16B low plane (2 bits/val, int2 layout) + 8B high plane (1 bit/val), values [-4,3] stored v+4. Same quantization math as tools/quant_ablation.py _quant_last_dim(bits=3, group=64) from JustVugg#132, whose OLMoE ablation measured int3-g64 BEATING the shipped per-row int4 on quality (-7.5 vs -9.3pp) at ~14% fewer bits. Engine: pack_int3_g64 + matmul_i3 (NEON low-plane unpack reuses matmul_i2's pattern, high plane via vtst bit-expansion; scalar reference everywhere; exact f32 path only - no IDOT in v1), fmt=5 branches in qt_bytes/qt_alloc/qt_fill/ matmul_qt/embed_row/qt_addrow/qt_matvec_rows. Format detection: fmt is not stored on disk and row formats are inferred from byte counts, so the .qs scale tensor SIZE is the tag (O floats = row formats, O*ceil(I/64) = fmt5); qt_fmt_from_bytes centralizes the inference for the dense loader and both expert_load paths. Metal/CUDA fall back to CPU for fmt5 via existing gates. Converter: quant_int3_g64 in convert_fp8_to_int4.py; --ebits 3/--xbits 3 now emit it (previously bits=3 silently produced int4). Tests: tests/test_int3.c (bit-exact pack/unpack vs reference, matmul_i3 vs dequant-matmul incl. short tail groups and the real GLM I=7168, QT plumbing, format tag, outlier-rows RMS: int3-g64 3.3x lower error than per-row int4), tests/test_int3_load.c (hand-rolled .safetensors fixture through st_init + qt_from_disk: fmt5 detected and loaded next to an int4 control tensor), tests/test_int3_convert.py (NumPy pack round-trip vs independent decoder). Rebased onto current `dev`: renumbered int3-g64 to fmt=5 because JustVugg#242 (grouped int4) took fmt=4 in the meantime; on-disk detection is by the distinct int3 weight-byte count, so it coexists with grouped-int4's scale-size detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rter, tests New weight format: int3 with ONE f32 scale per 64-input group (3.5 bits/weight effective). Per group: 16B low plane (2 bits/val, int2 layout) + 8B high plane (1 bit/val), values [-4,3] stored v+4. Same quantization math as tools/quant_ablation.py _quant_last_dim(bits=3, group=64) from JustVugg#132, whose OLMoE ablation measured int3-g64 BEATING the shipped per-row int4 on quality (-7.5 vs -9.3pp) at ~14% fewer bits. Engine: pack_int3_g64 + matmul_i3 (NEON low-plane unpack reuses matmul_i2's pattern, high plane via vtst bit-expansion; scalar reference everywhere; exact f32 path only - no IDOT in v1), fmt=5 branches in qt_bytes/qt_alloc/qt_fill/ matmul_qt/embed_row/qt_addrow/qt_matvec_rows. Format detection: fmt is not stored on disk and row formats are inferred from byte counts, so the .qs scale tensor SIZE is the tag (O floats = row formats, O*ceil(I/64) = fmt5); qt_fmt_from_bytes centralizes the inference for the dense loader and both expert_load paths. Metal/CUDA fall back to CPU for fmt5 via existing gates. Converter: quant_int3_g64 in convert_fp8_to_int4.py; --ebits 3/--xbits 3 now emit it (previously bits=3 silently produced int4). Tests: tests/test_int3.c (bit-exact pack/unpack vs reference, matmul_i3 vs dequant-matmul incl. short tail groups and the real GLM I=7168, QT plumbing, format tag, outlier-rows RMS: int3-g64 3.3x lower error than per-row int4), tests/test_int3_load.c (hand-rolled .safetensors fixture through st_init + qt_from_disk: fmt5 detected and loaded next to an int4 control tensor), tests/test_int3_convert.py (NumPy pack round-trip vs independent decoder). Rebased onto current `dev`: renumbered int3-g64 to fmt=5 because JustVugg#242 (grouped int4) took fmt=4 in the meantime; on-disk detection is by the distinct int3 weight-byte count, so it coexists with grouped-int4's scale-size detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds a new int4 quantization format (fmt=4) with one scale per 128 elements along the input dimension, instead of one scale per output row. This matches the FP8 source checkpoint's 128×128 block-scale granularity and fixes the root cause of incoherent int4 output.
Closes #225.
Problem
The int4 model produces fluent-but-incoherent output — grammatical English that is completely off-topic (SEO spam, homework-help boilerplate). The root cause is per-row quantization scales: one F32 scale per output row (e.g. 2048 scales for a 2048×6144 matrix), which is 48× coarser than the FP8 source's 128×128 block scales. This destroys fine-grained weight information that handles reasoning and instruction-following while preserving the large-magnitude weights that handle grammar and vocabulary fluency.
GLM-5.2 was QAT-trained for int4 (arXiv 2602.15763, §2.4.3), but only with fine-grained quantization matching what the model saw during training. Per-row scaling is 48× too coarse.
Changes
c/glm.c(+122 lines):QTstruct: addedint gsfield (group size, 0=per-row for backward compat, 128=grouped)matmul_i4_groupedkernel: AVX2 nibble unpacking, accumulator resets at each 128-element group boundary — verified to 2.98e-08 vs f32 referenceqt_from_disk): auto-detects fmt=4 from.qsscale array size. Old per-row models (fmt=2) work unchanged.qt_bytes: fmt=4 case addedmatmul_qt_ex): routes tomatmul_i4_groupedwhenfmt==4, always exact (no IDOT)c/tools/convert_fp8_to_int4.py(+52 lines):quant_int4_grouped()function +--group-sizearg (0=per-row default, 128=grouped)Validation
Backward compatibility
--group-size 0produces per-row output (converter backward compat)Test plan
matmul_i4_groupedmatches f32 reference to 2.98e-08