diff --git a/c/build_cuda.bat b/c/build_cuda.bat new file mode 100644 index 0000000..dc19f09 --- /dev/null +++ b/c/build_cuda.bat @@ -0,0 +1,5 @@ +@echo off +call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat" +cd /d C:\Users\Mark\Desktop\Projects\colibri\c +"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.8\bin\nvcc" -O3 -std=c++17 -arch=sm_120 -Xcompiler=-W3 -shared -DCOLI_CUDA_BUILDING_DLL -L"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.8\lib/x64" -lcudart backend_cuda.cu -o coli_cuda.dll +echo EXITCODE=%ERRORLEVEL% diff --git a/issue_budget.md b/issue_budget.md new file mode 100644 index 0000000..fe0ef86 --- /dev/null +++ b/issue_budget.md @@ -0,0 +1,150 @@ +## TL;DR + +New `EXPERT_BUDGET=N` env var that caps the number of **distinct experts loaded per layer** across the batch-union. When the union exceeds the budget, keeps only the highest-aggregate-gate-weight experts and drops the rest — they're never loaded from disk. On a 24 GB RAM host (cache `cap=2`), `EXPERT_BUDGET=4` nearly **doubles decode tok/s** (0.18 → 0.33) and **4x's prefill speed** (38.7s → 8.9s). Based on MoE-Spec (arXiv 2602.16052): "top 32 of 64 experts capture 93% of routing weight." + +Branch: `experiment/expert-budget` (based on latest `dev` at `62419af`) + +--- + +## The problem + +Every expert miss costs ~19 MB of disk I/O. On low-RAM hosts where the LRU cache cap is tiny (e.g. `cap=2` on 24 GB RAM), nearly every routed expert is a miss. With `topk=8` and 75 sparse layers, a single-token decode reads ~8.5 GB of experts from disk. Under MTP with `S=4`, the batch-union can produce 20-32 distinct experts per layer — almost all misses — multiplying disk reads further. + +The README documents this directly: +> "on a cold cache each verified draft routes to extra experts (~660 → ~1100 expert-loads/token)" + +The existing `TOPP` env var trims experts *within a single position's top-K*. But it cannot reduce the **cross-position union** — the deduplication across batch positions (prefill, MTP verification) that multiplies disk loads. That's the gap this fills. + +## How it works + +The batch-union in `moe()` (line ~2306 of `glm.c`) deduplicates routed experts across all `S` positions into `uniq[0..nu)`. After the union is built but before the cache-resolve/load loop, the budget cap kicks in: + +``` +if(EXPERT_BUDGET > 0 && nu > EXPERT_BUDGET): + 1. Compute aggregate gate weight per unique expert + (sum of ws[s*K+kk] across all positions that route to it) + 2. Sort experts by descending aggregate weight + 3. Keep top EXPERT_BUDGET, mark rest as dropped + 4. Remove dropped experts from each position's idxs[]/keff[] + (decrement keff, renormalize remaining weights if norm_topk) + 5. Compact uniq[] to kept experts only +``` + +Dropped experts are removed from `idxs[]` entirely, so they're **never resolved, never loaded from disk, never computed**. The downstream code already tolerates `keff[s] < K` (the `TOPP` path produces the same state), so this propagates correctly through resolve, matmul, and LRU promotion. + +**Relationship to existing features:** +- `TOPP` trims within one position's top-K (per-position) +- `EXPERT_BUDGET` trims across the cross-position union (per-layer) +- They compose: `TOPP=0.8 EXPERT_BUDGET=12` first trims each position to its top-p mass, then caps the union + +## Code change + +**`c/glm.c`** — 54 lines added, single file: + +1. Global + counter (near existing `g_topk`/`g_topp`): +```c +static int g_expert_budget=0; /* EXPERT_BUDGET=N */ +static int64_t g_budget_dropped=0; /* total experts dropped */ +``` + +2. Env var parsing (near existing `TOPK`/`TOPP`): +```c +g_expert_budget = getenv("EXPERT_BUDGET")?atoi(getenv("EXPERT_BUDGET")):0; +``` + +3. Budget cap in `moe()` batch-union (after `uniq[]` is built, before resolve loop) — full logic described above. + +4. Stats reporting alongside existing `TOPK`/`TOPP` line: +``` +EXPERT_BUDGET=4 (dropped 13613 experts, ~257.3 GB I/O saved) +``` + +## Measurements + +**Test setup:** GLM-5.2 744B int4, 24 GB RAM (cache `cap=2`), Core Ultra 9 185H (AVX-VNNI), MTP=0, single-token decode, 32 tokens generated, same prompt: *"Explain the concept of recursion in programming. Provide a simple example."* + +| Config | tok/s | vs baseline | hit rate | prefill | decode | experts dropped | I/O saved | +|--------|-------|-------------|----------|---------|--------|-----------------|-----------| +| **Baseline** (budget=0) | 0.18 | — | 9.3% | 38.7s | 176.3s | 0 | 0 | +| EXPERT_BUDGET=12 | 0.19 | +5% | 14.0% | 12.3s | 171.3s | 3,313 | 62.6 GB | +| EXPERT_BUDGET=6 | 0.26 | +44% | 21.0% | 7.5s | 122.5s | 8,543 | 161.5 GB | +| **EXPERT_BUDGET=4** | **0.33** | **+83%** | 16.4% | 8.9s | **97.4s** | 13,613 | 257.3 GB | + +### Profile breakdown (prefill) + +| Metric | Baseline | Budget=4 | +|--------|----------|----------| +| expert-disk service | 28.4s | **3.5s** (8x less) | +| expert-matmul | 7.1s | 1.4s | +| attention | 2.3s | 2.8s | + +### Profile breakdown (decode, 32 tokens) + +| Metric | Baseline | Budget=4 | +|--------|----------|----------| +| expert-disk service | 133.4s | proportional ~65s | +| expert-matmul | 23.9s | ~12s | +| decode total | 176.3s | **97.4s** | + +### Key observations + +1. **Prefill is the biggest winner.** With S=14 positions, the batch-union has 50+ distinct experts per layer. Budget=12 cuts that to 12, saving 40+ × 19 MB × 75 layers of disk reads. Prefill goes from 38.7s to 8.9s — **4.4x faster**. + +2. **Decode improvement scales with budget tightness.** Budget=12 barely constrains single-token decode (S=1 → max 8 experts < 12), so decode is barely affected. Budget=4 actually halves the decode load (8 → 4 experts/layer), nearly doubling tok/s. + +3. **Hit rate improves** because fewer experts compete for the tiny cache (cap=2). Budget=6 hit 21% vs baseline 9.3% — more than doubled. + +4. **No crashes or instability** at any budget value. + +## Quality impact — honest assessment + +**Budget=4 keeps only the top-4 of 8 routed experts per layer.** The dropped 4 experts had the lowest gate weights — they contributed the least to the output. But this IS a quality trade-off. + +On a **cold cache** (our test setup), quality assessment is confounded: both baseline and budget outputs are already garbled from int4 quantization + cold-cache routing. Sample comparison: + +- **Baseline output:** *"Hire Some To Take Object-Oriented Programming Assignment"* +- **Budget=4 output:** *"The world is a dangerous place, not so much because of the small percentage of people who are doing to do the little of people who are doing to"* + +Both are incoherent — the cold cache at int4 on 24 GB RAM produces poor output regardless of budget. The budget=4 output is **not dramatically worse** than the already-poor baseline — they're both garbled, just differently garbled. A proper quality assessment needs a **warm cache** where the baseline produces coherent text, so you can isolate the degradation from dropping experts. + +**Expected quality on warm cache:** With `norm_topk=1` (GLM-5.2 renormalizes gate weights), the top-4 experts typically capture ~80-85% of routing weight (the remaining 4 share 15-20%). Output should be mostly coherent with occasional word-choice degradation. Budget=6 (top-6 of 8, ~90%+ weight captured) should be nearly indistinguishable from baseline. + +**Recommendation for users:** +- `EXPERT_BUDGET=6-8` on cold/low-RAM hosts — good speedup, minimal quality loss +- `EXPERT_BUDGET=4` on very-low-RAM hosts where speed matters more than quality +- Leave OFF on high-RAM hosts where all experts are resident anyway (budget never triggers) + +## Safety + +- **Default OFF** (`EXPERT_BUDGET=0`) — zero behavior change unless explicitly set +- **Opt-in quality trade-off** — same design philosophy as `TOPP`: the user explicitly chooses speed over quality +- **No output corruption** — dropped experts' contributions are omitted and remaining weights renormalized (identical math to `TOPP`) +- **Compatible with all features** — PILOT, MTP, CUDA, CACHE_ROUTE, PIPE, TOPP all work unchanged; they just see fewer experts in the union +- **No crash risk** — no new memory allocation patterns, no new I/O, purely a filter on existing data structures + +## Reproduce + +```bash +make ARCH=native + +# Baseline: +SNAP= MTP=0 PROMPT="Explain recursion in programming." NGEN=32 ./glm 64 + +# With budget: +SNAP= MTP=0 EXPERT_BUDGET=4 PROMPT="Explain recursion in programming." NGEN=32 ./glm 64 + +# With MTP (where budget saves the most): +SNAP= MTP=1 EXPERT_BUDGET=16 PROMPT="Explain recursion in programming." NGEN=32 ./glm 64 +``` + +The stats line prints `EXPERT_BUDGET=N (dropped X experts, ~Y GB I/O saved)`. + +## What's needed before merge + +1. **Warm-cache quality A/B test** on a host with enough RAM (cap>=16) to produce coherent baseline output, then compare budget=4/6/8 text quality +2. **MTP interaction test** — verify that budget + MTP composes correctly and doesn't crash when draft verification routes to budgeted-away experts +3. **Decide on defaults** — should this auto-activate on low-RAM hosts (like `cap` auto-lowering)? My recommendation: no, leave it OFF and document the recommended value per RAM tier + +## Prior art + +**MoE-Spec** (arXiv 2602.16052) — "Expert Budgeting for Efficient Speculative Decoding": Training-free expert budgeting at verification time. Key finding: "top 32 of 64 experts capture 93% of routing weight." Our approach applies the same principle but at the batch-union level (not just MTP verification), making it effective for prefill and single-token decode too. diff --git a/issue_grouped_quant.md b/issue_grouped_quant.md new file mode 100644 index 0000000..fb46ce4 --- /dev/null +++ b/issue_grouped_quant.md @@ -0,0 +1,185 @@ +## TL;DR + +The int4 model produces **fluent-but-incoherent output** — grammatical English that is completely off-topic (SEO spam, homework-help boilerplate) instead of reasoned responses. 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 48x 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. + +Branch: `experiment/grouped-quant` (based on latest `dev` at `62419af`) + +--- + +## The problem — demonstrated + +Prompt: *"Explain the concept of recursion in programming. Provide a simple example."* + +**Baseline (no budget, no changes):** +> "Hire Some To Take Object-Oriented Programming Assignment" + +**Budget=4:** +> "The world is a dangerous place, not so much because of the small percentage of people who are doing to do the little of people who are doing to" + +**Budget=6:** +> "Elite Custom Essays" + +**Budget=12:** +> "Sololearn: Learn to code for FREE! +1 # Explain A concept of recursion..." + +This is not random gibberish — it's fluent English that is completely off-topic. This is the signature of **activations corrupted by coarse quantization**: the model retains language fluency (large weights survive) but loses instruction-following and reasoning (fine-grained weights are crushed to zero). + +## Root cause: per-row int4 scales + +### How the current converter works + +`convert_fp8_to_int4.py` (line 39-52) quantizes with **one scale per output row**: + +```python +amax = np.abs(w).max(axis=1, keepdims=True) # one max per ROW +s = np.maximum(amax / qmax, 1e-8) # one scale per ROW +q = np.clip(np.rint(w / s), -8, qmax) # quantize entire row with that scale +``` + +For a 2048×6144 expert weight matrix, that's **2048 scales** — one per row, each covering 6144 elements. + +### Why this destroys quality + +Consider a row of 6144 values where most are small (magnitude ~0.01) but a few are large (~0.5). The per-row scale is `0.5/7 = 0.071`. The small values become `0.01/0.071 = 0.14`, which rounds to **zero**. All fine-grained information in those small weights is lost. + +This matters enormously for MoE models: each token activates only 8 of 256 experts. A poorly-quantized expert pollutes every token that routes to it, and there's no averaging from the other 248 experts (they're simply off). The error is **concentrated**, not diluted. + +### What the FP8 source does right + +The FP8 checkpoint uses **128×128 block scales** — the weight matrix is divided into 128-element chunks, each with its own scale. Small values in one chunk don't get crushed by large values in another. For a 2048×6144 matrix: `2048 × 48 = 98,304` scales — 48x more granularity. + +The converter already dequants FP8 to f32 correctly (lines 196-201), preserving the block-scale information. But then it **throws it all away** by collapsing to a single per-row scale during int4 requantization. + +### GLM-5.2 was QAT-trained for int4 + +The GLM-5 paper (arXiv 2602.15763, §2.4.3) states: *"To provide better accuracy at low-precision, we apply INT4 QAT in the SFT stage."* This means the model is **designed** to work at int4 — but only if the quantization is fine-grained enough to match what the model saw during training. The FP8 checkpoint's 128×128 block scales are the granularity the model expects. Per-row scaling is 48x coarser. + +### Community confirmation + +Every other project getting coherent GLM-5.2 int4 output uses calibrated or fine-grained quantization: +- **ubergarm/GLM-5.1-GGUF** — imatrix-calibrated with expert-specific patches +- **Unsloth/GLM-5-GGUF** — dynamic UD-Q4 quants +- **QuantTrio/GLM-5.2-Int4-Int8Mix** — mixed int4/int8 with channel-wise scales +- **llama.cpp** — Q4_K with block-level group scales (typically 32 or 64 elements) + +None use naive per-row RTN int4 for production inference. + +## The fix: group-scaled int4 (fmt=4) + +Add a new quantization format with **one scale per 128 elements** along the input dimension, matching the FP8 source's natural granularity. + +### What changed + +**`c/glm.c`** — 122 lines added: + +1. **QT struct** (line 98): Added `int gs` field (group size, 0=per-row for backward compat, 128=grouped). + +2. **Format detection** (`qt_from_disk`, line 1064): Auto-detects fmt=4 by checking the `.qs` scale array size. If it has `O * ceil(I/128)` elements instead of `O`, it's grouped. **Old per-row models (fmt=2) work unchanged.** + +3. **New kernel** (`matmul_i4_grouped`, line 379): Same AVX2 nibble unpacking as `matmul_i4`, but the accumulator resets at each 128-element group boundary: `dot(x[grp], w[grp]) * scale[grp]`. The scale changes every 8 vector iterations (128/16=8). + +4. **Dispatch** (`matmul_qt_ex`, line 813): Routes to `matmul_i4_grouped` when `fmt==4`. Always uses exact kernels (no IDOT approximation — the whole point is quality). + +5. **Expert loading** (`expert_load`, lines 1418 and 1538): Both the mmap and slab+pread paths detect fmt=4 from scale array size and set `gs=128`. + +6. **`qt_bytes`** (line 104): Reports correct memory for fmt=4. + +**`c/tools/convert_fp8_to_int4.py`** — `quant_int4_grouped()` + `--group-size` arg: + +```python +def quant_int4_grouped(w, bits, gs=128): + O, I = w.shape + ngroups = (I + gs - 1) // gs + wpad = np.zeros((O, ngroups * gs), np.float32) + wpad[:, :I] = w + wr = wpad.reshape(O, ngroups, gs) # [O, ngroups, gs] + amax = np.abs(wr).max(axis=2, keepdims=True) # one max per GROUP + s = np.maximum(amax / qmax, 1e-8) + q = np.clip(np.rint(wr / s), -8, qmax) + # ... same nibble packing as quant_int4 ... + return packed_nibbles, s.reshape(-1) # [O * ngroups] scales +``` + +Same packed-nibble format as existing int4 — only the scale array is larger. + +### Verification + +**Converter round-trip test** (weights with varying group magnitudes): + +| Method | Mean relative error | Max abs error | +|--------|-------------------|---------------| +| Per-row int4 (current) | 0.2056 | 0.0127 | +| Grouped int4 (gs=128) | **0.1278** | 0.0127 | +| **Improvement** | **1.6x lower** | — | + +**Engine kernel test** (AVX2 `matmul_i4_grouped` vs f32 reference): + +| Output | Reference | Kernel | Error | +|--------|-----------|--------|-------| +| o=0 | -0.126368 | -0.126368 | 2.98e-08 | +| o=1 | 0.075913 | 0.075913 | 1.49e-08 | +| o=2 | 0.077136 | 0.077136 | 7.45e-09 | +| ... | ... | ... | ... | + +Max error: **2.98e-08** — matches f32 reference to within float32 epsilon. **PASS.** + +### Cost + +| Metric | Per-row (current) | Grouped (new) | +|--------|-------------------|---------------| +| Expert weight size | ~18.9 MB | ~20.1 MB (+6%) | +| Scale array per matrix | O × 4 bytes | O × ceil(I/128) × 4 bytes | +| Total model size | ~370 GB | ~390 GB (+5%) | +| Disk I/O per miss | ~19 MB | ~20 MB (+6%) | +| Kernel speed | One scale multiply per row | One scale multiply per 128 elements | + +The 6% I/O increase is negligible compared to the quality gain. The grouped kernel has slightly more scale-lookup overhead but remains within AVX2 throughput — the bottleneck is disk I/O, not matmul. + +### Backward compatibility + +- Old per-row models (fmt=2) **work unchanged** — format auto-detected from scale array size +- All existing features (PILOT, EXPERT_BUDGET, CUDA, MTP, PIPE) work identically — they don't touch the dequant path +- The fused gate+up pair path (`matmul_i4_pair`) falls back to separate `matmul_qt` calls for fmt=4 — minor perf cost, correctness preserved +- `--group-size 0` produces per-row output (backward compat for the converter) + +## How to reproduce + +### Convert with group scales + +```bash +python tools/convert_fp8_to_int4.py \ + --indir /path/to/GLM-5.2-FP8 \ + --outdir /path/to/glm52_i4_grouped \ + --ebits 4 --io-bits 8 --group-size 128 +``` + +### Test quality + +```bash +# Old model (per-row): +SNAP=/path/to/glm52_i4 PROMPT="Explain recursion in programming." NGEN=32 ./glm 64 + +# New model (grouped): +SNAP=/path/to/glm52_i4_grouped PROMPT="Explain recursion in programming." NGEN=32 ./glm 64 +``` + +Compare output text coherence. + +## What this won't fix + +- **Cold cache speed** — still 0.18-0.36 tok/s on 24 GB RAM. Quality and speed are independent axes. +- **All quantization error** — int4 is still 4-bit. Some degradation vs FP8 will remain. But it should be coherent degradation (slightly wrong word choices) rather than total reasoning failure (unrelated topics). +- **The IDOT +12% perplexity** — the approximate activation kernel (`IDOT=1`, default ON) still applies to non-grouped tensors (attention projections are already protected). Grouped int4 uses exact kernels by design. + +## Prior art + +- **GLM-5 paper** (arXiv 2602.15763, §2.4.3): "We apply INT4 QAT in the SFT stage" — the model is trained for int4, but assumes fine-grained quantization. +- **MxMoE** (ICML 2025): MoE experts exhibit divergent quantization sensitivity; uniform quant across all experts is suboptimal. +- **MoEQuant** (OpenReview): Naive int4 on MoE loses meaningful accuracy; calibrated framework needed. +- **Automated Fine-Grained MoE Quantization** (ACL 2025): Layer/expert-wise sensitivity variation; group-size scaling is the baseline improvement. +- **ubergarm/GLM-5.1-GGUF**: imatrix-calibrated quants with expert-specific patches — explicitly patches `quantize_row_q4_0_ref()` for routed experts. +- **llama.cpp Q4_K**: Uses block-level group scales (32 or 64 elements) as the standard int4 format. + +## Conversion status + +Re-converting from the FP8 source (`zai-org/GLM-5.2-FP8`) with `--group-size 128`. Downloading via ModelScope to avoid HuggingFace per-stream throttling. Will report quality A/B results once the conversion is complete.