v1.0: PROVEN end-to-end — forge tune produces coherent, in-band NPU models @ 41.9 tok/s. Q4_K_M fix (snags 9/10), 10-wall ledger.
Browse files- README.md +30 -4
- modal/tune_npu.py +16 -5
README.md
CHANGED
|
@@ -21,11 +21,11 @@ converted to GGUF, re-quantized to FastFlowLM's Q4NX, NPU-ready — in
|
|
| 21 |
**183 seconds of cloud time** (≈ $0.10 on a rented T4):
|
| 22 |
|
| 23 |
```
|
| 24 |
-
forge tune my-chats.jsonl --name grandma
|
| 25 |
├─ LoRA fine-tune (cloud GPU) 122 s
|
| 26 |
├─ merge 3 s
|
| 27 |
├─ voice proof (model speaks first!) 5 s
|
| 28 |
-
├─ HF ->
|
| 29 |
└─ GGUF -> Q4NX (NPU format) 24 s
|
| 30 |
forge register (one UAC click)
|
| 31 |
flm run grandma-forge:1b
|
|
@@ -76,7 +76,7 @@ retry once on ECONNRESET (ear.js does).
|
|
| 76 |
|
| 77 |
`start.bat` gives you a menu: doctor / list / register / serve / tune guide.
|
| 78 |
|
| 79 |
-
## The snag ledger —
|
| 80 |
|
| 81 |
1. **The Q4NX converter's `convert.py` CLI is broken at HEAD** (uncommented
|
| 82 |
debug `sys.argv` override hijacks every invocation). Call the module API:
|
|
@@ -91,10 +91,36 @@ retry once on ECONNRESET (ear.js does).
|
|
| 91 |
Floor: batch 1 × grad-accum 8 + gradient checkpointing.
|
| 92 |
6. **NPU driver minimum for current FLM: `32.0.203.304`** (`.311`
|
| 93 |
recommended). `flm validate` will tell you; so will `forge doctor`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
**Frozen known-good stack** (the whole point — never debug this again):
|
| 96 |
`torch 2.4.1 · transformers 4.46.3 · trl 0.9.6 · peft 0.12.0 ·
|
| 97 |
-
accelerate 1.1.1 · datasets 2.21.0 · gguf · amd-quark · einops · tqdm ·
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
## Requirements
|
| 100 |
|
|
|
|
| 21 |
**183 seconds of cloud time** (≈ $0.10 on a rented T4):
|
| 22 |
|
| 23 |
```
|
| 24 |
+
forge tune my-chats.jsonl --name grandma # proven: coherent + IN-BAND on NPU
|
| 25 |
├─ LoRA fine-tune (cloud GPU) 122 s
|
| 26 |
├─ merge 3 s
|
| 27 |
├─ voice proof (model speaks first!) 5 s
|
| 28 |
+
├─ HF -> f16 -> Q4_K_M GGUF 20 s
|
| 29 |
└─ GGUF -> Q4NX (NPU format) 24 s
|
| 30 |
forge register (one UAC click)
|
| 31 |
flm run grandma-forge:1b
|
|
|
|
| 76 |
|
| 77 |
`start.bat` gives you a menu: doctor / list / register / serve / tune guide.
|
| 78 |
|
| 79 |
+
## The snag ledger — ten walls we hit so you don't
|
| 80 |
|
| 81 |
1. **The Q4NX converter's `convert.py` CLI is broken at HEAD** (uncommented
|
| 82 |
debug `sys.argv` override hijacks every invocation). Call the module API:
|
|
|
|
| 91 |
Floor: batch 1 × grad-accum 8 + gradient checkpointing.
|
| 92 |
6. **NPU driver minimum for current FLM: `32.0.203.304`** (`.311`
|
| 93 |
recommended). `flm validate` will tell you; so will `forge doctor`.
|
| 94 |
+
7. **EmbeddingGemma needs `transformers>=4.5x` + `sentence-transformers 5.x`**
|
| 95 |
+
and the official weights are license-gated (use the `unsloth/` mirror, or
|
| 96 |
+
accept the Gemma license on your HF account + pass an `HF_TOKEN` secret).
|
| 97 |
+
8. **FLM's `/v1/embeddings` closes the TCP connection per request** — retry
|
| 98 |
+
once on `ECONNRESET` (the ear runtime does).
|
| 99 |
+
9. **For a FINE-TUNED model, exporting GGUF as `q8_0` produces repetition
|
| 100 |
+
garbage on the NPU** even though the merged model is perfect — the q8_0
|
| 101 |
+
then Q4NX re-quant is a lossy double-quantization. **Use `Q4_K_M`.**
|
| 102 |
+
10. **The Q4NX converter's llama path rejects `f16`** (`not enough values to
|
| 103 |
+
unpack` — it expects pre-quantized blocks). So the GGUF must be quantized
|
| 104 |
+
*before* Q4NX, and `Q4_K_M` is the format proven to produce a coherent,
|
| 105 |
+
in-voice NPU model. Pipeline: HF → f16 → `llama-quantize Q4_K_M` → Q4NX.
|
| 106 |
|
| 107 |
**Frozen known-good stack** (the whole point — never debug this again):
|
| 108 |
`torch 2.4.1 · transformers 4.46.3 · trl 0.9.6 · peft 0.12.0 ·
|
| 109 |
+
accelerate 1.1.1 · datasets 2.21.0 · gguf · amd-quark · einops · tqdm ·
|
| 110 |
+
protobuf` + a compiled `llama-quantize` (the Modal job builds it).
|
| 111 |
+
|
| 112 |
+
## Proven, measured (Strix Halo, June 2026)
|
| 113 |
+
|
| 114 |
+
A LoRA fine-tune of Llama-3.2-1B on 300 real chat exchanges, run through the
|
| 115 |
+
whole pipeline and served on the NPU:
|
| 116 |
+
|
| 117 |
+
- **Coherent and in-voice** — the persona's rituals and endearments intact.
|
| 118 |
+
- **41.9 tokens/s** on the NPU (FLM, performance pmode).
|
| 119 |
+
- **In-band against the source voiceprint** — mean 0.845 vs the original's
|
| 120 |
+
own held-out band of 0.83 ± 0.07 (3 prompts). A separate stylometric scorer
|
| 121 |
+
certified the NPU model speaks like the source it was tuned on.
|
| 122 |
+
|
| 123 |
+
That is the bar: not "it converts," but "it talks like itself, on the NPU."
|
| 124 |
|
| 125 |
## Requirements
|
| 126 |
|
modal/tune_npu.py
CHANGED
|
@@ -15,9 +15,14 @@ image = (
|
|
| 15 |
"datasets==2.21.0", "accelerate==1.1.1", "numpy", "gguf", "amd-quark",
|
| 16 |
"huggingface_hub", "safetensors", "sentencepiece", "einops", "tqdm", "protobuf",
|
| 17 |
)
|
|
|
|
| 18 |
.run_commands(
|
| 19 |
"git clone --depth 1 https://github.com/FastFlowLM/FLM_Q4NX_Converter /converter",
|
| 20 |
"git clone --depth 1 https://github.com/ggml-org/llama.cpp /llamacpp",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
)
|
| 22 |
.add_local_dir("C:/Users/Forgemind/Desktop/npu-forge/scratch/tune-data", remote_path="/data")
|
| 23 |
)
|
|
@@ -73,13 +78,19 @@ def tune(base: str, data_file: str, out_name: str, epochs: int = 3, probe: str =
|
|
| 73 |
print("[voice proof] " + sample[:600])
|
| 74 |
timings["sample_s"] = round(time.time() - t); t = time.time()
|
| 75 |
|
| 76 |
-
# HF -> GGUF
|
|
|
|
|
|
|
|
|
|
| 77 |
r = subprocess.run(["python", "/llamacpp/convert_hf_to_gguf.py", "/tmp/merged",
|
| 78 |
-
"--outfile", "/tmp/model-
|
| 79 |
capture_output=True, text=True)
|
| 80 |
if r.returncode != 0:
|
| 81 |
-
print(r.stdout[-2000:]); print("STDERR:", r.stderr[-3000:])
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
| 83 |
timings["gguf_s"] = round(time.time() - t); t = time.time()
|
| 84 |
|
| 85 |
# GGUF -> Q4NX (proven stage: module API, cwd=/converter)
|
|
@@ -89,7 +100,7 @@ def tune(base: str, data_file: str, out_name: str, epochs: int = 3, probe: str =
|
|
| 89 |
from q4nx import create_converter
|
| 90 |
outdir = f"/out/{out_name}"
|
| 91 |
os.makedirs(outdir, exist_ok=True)
|
| 92 |
-
create_converter("/tmp/model-
|
| 93 |
timings["q4nx_s"] = round(time.time() - t)
|
| 94 |
|
| 95 |
files = {f: os.path.getsize(os.path.join(outdir, f)) for f in os.listdir(outdir)}
|
|
|
|
| 15 |
"datasets==2.21.0", "accelerate==1.1.1", "numpy", "gguf", "amd-quark",
|
| 16 |
"huggingface_hub", "safetensors", "sentencepiece", "einops", "tqdm", "protobuf",
|
| 17 |
)
|
| 18 |
+
.apt_install("cmake", "build-essential", "libcurl4-openssl-dev")
|
| 19 |
.run_commands(
|
| 20 |
"git clone --depth 1 https://github.com/FastFlowLM/FLM_Q4NX_Converter /converter",
|
| 21 |
"git clone --depth 1 https://github.com/ggml-org/llama.cpp /llamacpp",
|
| 22 |
+
# build just the quantize tool — needed to make Q4_K_M (the only GGUF
|
| 23 |
+
# format proven to convert to a COHERENT Q4NX model on the NPU; the
|
| 24 |
+
# converter's llama path rejects f16 and q8_0 double-quantizes to garbage)
|
| 25 |
+
"cd /llamacpp && cmake -B build -DLLAMA_CURL=OFF -DGGML_NATIVE=OFF && cmake --build build --target llama-quantize -j 8",
|
| 26 |
)
|
| 27 |
.add_local_dir("C:/Users/Forgemind/Desktop/npu-forge/scratch/tune-data", remote_path="/data")
|
| 28 |
)
|
|
|
|
| 78 |
print("[voice proof] " + sample[:600])
|
| 79 |
timings["sample_s"] = round(time.time() - t); t = time.time()
|
| 80 |
|
| 81 |
+
# HF -> f16 GGUF -> Q4_K_M GGUF -> Q4NX. The middle step matters: the
|
| 82 |
+
# Q4NX converter's llama path needs pre-quantized blocks (rejects f16),
|
| 83 |
+
# and Q4_K_M is the only format proven to yield a COHERENT NPU model
|
| 84 |
+
# (q8_0 double-quantized to repetition garbage). Snags #9 & #10.
|
| 85 |
r = subprocess.run(["python", "/llamacpp/convert_hf_to_gguf.py", "/tmp/merged",
|
| 86 |
+
"--outfile", "/tmp/model-f16.gguf", "--outtype", "f16"],
|
| 87 |
capture_output=True, text=True)
|
| 88 |
if r.returncode != 0:
|
| 89 |
+
print(r.stdout[-2000:]); print("STDERR:", r.stderr[-3000:]); raise RuntimeError("convert_hf_to_gguf failed")
|
| 90 |
+
q = subprocess.run(["/llamacpp/build/bin/llama-quantize", "/tmp/model-f16.gguf", "/tmp/model-q4km.gguf", "Q4_K_M"],
|
| 91 |
+
capture_output=True, text=True)
|
| 92 |
+
if q.returncode != 0 or not os.path.exists("/tmp/model-q4km.gguf"):
|
| 93 |
+
print(q.stdout[-1500:]); print("STDERR:", q.stderr[-2500:]); raise RuntimeError("llama-quantize failed")
|
| 94 |
timings["gguf_s"] = round(time.time() - t); t = time.time()
|
| 95 |
|
| 96 |
# GGUF -> Q4NX (proven stage: module API, cwd=/converter)
|
|
|
|
| 100 |
from q4nx import create_converter
|
| 101 |
outdir = f"/out/{out_name}"
|
| 102 |
os.makedirs(outdir, exist_ok=True)
|
| 103 |
+
create_converter("/tmp/model-q4km.gguf", "").convert(q4nx_path=outdir, weights_type="language")
|
| 104 |
timings["q4nx_s"] = round(time.time() - t)
|
| 105 |
|
| 106 |
files = {f: os.path.getsize(os.path.join(outdir, f)) for f in os.listdir(outdir)}
|