v0.3: NPU-validated voice ear (111KB head + trainer + runtime), start.bat menu, 47.8 tok/s baseline, snag #8
Browse files- README.md +107 -89
- bin/ear.js +90 -0
- bin/menu.js +57 -0
- modal/train_ear_head.py +88 -0
- start.bat +11 -0
README.md
CHANGED
|
@@ -1,89 +1,107 @@
|
|
| 1 |
-
---
|
| 2 |
-
license: mit
|
| 3 |
-
tags:
|
| 4 |
-
- amd
|
| 5 |
-
- ryzen-ai
|
| 6 |
-
- npu
|
| 7 |
-
- xdna2
|
| 8 |
-
- fastflowlm
|
| 9 |
-
- strix-halo
|
| 10 |
-
- lora
|
| 11 |
-
- fine-tuning
|
| 12 |
-
- tools
|
| 13 |
-
pipeline_tag: text-generation
|
| 14 |
-
---
|
| 15 |
-
|
| 16 |
-
# NPU-Forge 🔥 — fine-tune a model and put it on your AMD Ryzen AI NPU in ~3 minutes
|
| 17 |
-
|
| 18 |
-
**Measured, on a Strix Halo (Ryzen AI MAX+ 395), June 2026:** a LoRA fine-tune of
|
| 19 |
-
Llama-3.2-1B on 300+ real chat exchanges — trained, merged, behavior-verified,
|
| 20 |
-
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 -> GGUF (q8_0) 29 s
|
| 29 |
-
└─ GGUF -> Q4NX (NPU format) 24 s
|
| 30 |
-
forge register (one UAC click)
|
| 31 |
-
flm run grandma-forge:1b
|
| 32 |
-
```
|
| 33 |
-
|
| 34 |
-
The "voice proof" stage generates a sample from the merged model *inside the
|
| 35 |
-
training job*, before any conversion — so you know the tune actually took.
|
| 36 |
-
Ours came back with the persona's exact ritual phrases after 2 minutes of
|
| 37 |
-
training. That's the bar.
|
| 38 |
-
|
| 39 |
-
## What's in this repo
|
| 40 |
-
|
| 41 |
-
- **`forge.js` / `forge.bat`** — the CLI: `tune`, `convert`, `register`,
|
| 42 |
-
`list`, `doctor`, `serve`
|
| 43 |
-
- **`modal/tune_npu.py`** — the whole tune→NPU pipeline as one
|
| 44 |
-
[Modal](https://modal.com) job (bring your own Modal account; T4 is plenty)
|
| 45 |
-
- **`modal/convert_q4nx.py`** — just the GGUF→Q4NX stage (65 s for a 1B)
|
| 46 |
-
- **`bin/assemble.js`** — downloads results and stages the FLM model folder
|
| 47 |
-
- **`bin/register.js` + `register-admin.bat`** — the permanent custom-model
|
| 48 |
-
registry that survives FLM updates (see below)
|
| 49 |
-
- **`registry.example.json`** — entry template
|
| 50 |
-
|
| 51 |
-
Chat data format: one JSON per line, `{"messages":[{"role":"user","content":...},{"role":"assistant","content":...}]}`.
|
| 52 |
-
|
| 53 |
-
## The registry problem (why `forge register` exists)
|
| 54 |
-
|
| 55 |
-
FLM's `model_list.json` lives in `C:\Program Files\flm\` and **every FLM update
|
| 56 |
-
resets it**, silently de-registering all your custom models. Your model files
|
| 57 |
-
survive (they're in `Documents\flm\models\`) but they vanish from `flm list`.
|
| 58 |
-
Forge keeps its own user-space `registry.json` forever and re-merges with one
|
| 59 |
-
click. `forge doctor` tells you when an update has eaten your registrations.
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- amd
|
| 5 |
+
- ryzen-ai
|
| 6 |
+
- npu
|
| 7 |
+
- xdna2
|
| 8 |
+
- fastflowlm
|
| 9 |
+
- strix-halo
|
| 10 |
+
- lora
|
| 11 |
+
- fine-tuning
|
| 12 |
+
- tools
|
| 13 |
+
pipeline_tag: text-generation
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# NPU-Forge 🔥 — fine-tune a model and put it on your AMD Ryzen AI NPU in ~3 minutes
|
| 17 |
+
|
| 18 |
+
**Measured, on a Strix Halo (Ryzen AI MAX+ 395), June 2026:** a LoRA fine-tune of
|
| 19 |
+
Llama-3.2-1B on 300+ real chat exchanges — trained, merged, behavior-verified,
|
| 20 |
+
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 -> GGUF (q8_0) 29 s
|
| 29 |
+
└─ GGUF -> Q4NX (NPU format) 24 s
|
| 30 |
+
forge register (one UAC click)
|
| 31 |
+
flm run grandma-forge:1b
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
The "voice proof" stage generates a sample from the merged model *inside the
|
| 35 |
+
training job*, before any conversion — so you know the tune actually took.
|
| 36 |
+
Ours came back with the persona's exact ritual phrases after 2 minutes of
|
| 37 |
+
training. That's the bar.
|
| 38 |
+
|
| 39 |
+
## What's in this repo
|
| 40 |
+
|
| 41 |
+
- **`forge.js` / `forge.bat`** — the CLI: `tune`, `convert`, `register`,
|
| 42 |
+
`list`, `doctor`, `serve`
|
| 43 |
+
- **`modal/tune_npu.py`** — the whole tune→NPU pipeline as one
|
| 44 |
+
[Modal](https://modal.com) job (bring your own Modal account; T4 is plenty)
|
| 45 |
+
- **`modal/convert_q4nx.py`** — just the GGUF→Q4NX stage (65 s for a 1B)
|
| 46 |
+
- **`bin/assemble.js`** — downloads results and stages the FLM model folder
|
| 47 |
+
- **`bin/register.js` + `register-admin.bat`** — the permanent custom-model
|
| 48 |
+
registry that survives FLM updates (see below)
|
| 49 |
+
- **`registry.example.json`** — entry template
|
| 50 |
+
|
| 51 |
+
Chat data format: one JSON per line, `{"messages":[{"role":"user","content":...},{"role":"assistant","content":...}]}`.
|
| 52 |
+
|
| 53 |
+
## The registry problem (why `forge register` exists)
|
| 54 |
+
|
| 55 |
+
FLM's `model_list.json` lives in `C:\Program Files\flm\` and **every FLM update
|
| 56 |
+
resets it**, silently de-registering all your custom models. Your model files
|
| 57 |
+
survive (they're in `Documents\flm\models\`) but they vanish from `flm list`.
|
| 58 |
+
Forge keeps its own user-space `registry.json` forever and re-merges with one
|
| 59 |
+
click. `forge doctor` tells you when an update has eaten your registrations.
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
## NEW in v0.3 — a voice-verifier "ear" that runs on the NPU
|
| 63 |
+
|
| 64 |
+
Train a ~111KB classification head over EmbeddingGemma-300m embeddings
|
| 65 |
+
(`modal/train_ear_head.py`, bring your own labeled texts), then run it locally
|
| 66 |
+
with `bin/ear.js` against FLM's `/v1/embeddings` (`flm serve <model> --embed 1`).
|
| 67 |
+
The embeddings come off the NPU; the head is plain JS. In our tests the
|
| 68 |
+
111KB head **matched a fine-tuned 268MB DistilBERT on real-voice accuracy
|
| 69 |
+
(95.9%) and beat it on the hard boundary cases**, live on a Strix Halo NPU.
|
| 70 |
+
|
| 71 |
+
Also measured: llama3.2:1b chat on the NPU = **47.8 tokens/s** including
|
| 72 |
+
prefill (FLM, performance pmode).
|
| 73 |
+
|
| 74 |
+
Snag #8: FLM's embeddings endpoint closes the TCP connection per request —
|
| 75 |
+
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 — six 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:
|
| 83 |
+
`from q4nx import create_converter; create_converter(gguf, "").convert(q4nx_path=out, weights_type="language")`
|
| 84 |
+
2. Converter needs `einops` and `tqdm` beyond its README list, and **must run
|
| 85 |
+
with cwd = its repo root** (relative `configs/<arch>.json` loads).
|
| 86 |
+
3. **Llama-3.2 tokenizers need `transformers>=4.46`** — the error
|
| 87 |
+
`untagged enum ModelWrapper` is that wall exactly.
|
| 88 |
+
4. **`transformers 4.46` needs `accelerate>=1.0`** — the error
|
| 89 |
+
`'AdamW' object has no attribute 'train'` at step 0 is that skew.
|
| 90 |
+
5. **T4 + Llama-3.2's 128k vocab OOMs at batch 4** (loss-logits blowup).
|
| 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 · protobuf`
|
| 98 |
+
|
| 99 |
+
## Requirements
|
| 100 |
+
|
| 101 |
+
- AMD Ryzen AI machine with XDNA2 NPU (Strix, Strix Halo, Kraken…) +
|
| 102 |
+
[FastFlowLM](https://github.com/FastFlowLM/FastFlowLM)
|
| 103 |
+
- Node.js (the CLI), Python + a [Modal](https://modal.com) account (the cloud legs)
|
| 104 |
+
- NPU driver ≥ 32.0.203.304
|
| 105 |
+
|
| 106 |
+
Part of an ongoing project to make local NPUs a first-class home for personal
|
| 107 |
+
AI — voices you own, on silicon you own.
|
bin/ear.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// ear.js — the NPU-accelerated trained ear. Embeddings come from FLM's
|
| 3 |
+
// embed-gemma:300m running on the NPU (/v1/embeddings); the voice head is a
|
| 4 |
+
// 111KB JSON applied right here in JS. No python, no extra runtimes.
|
| 5 |
+
//
|
| 6 |
+
// node bin/ear.js "some text to identify" (one-shot)
|
| 7 |
+
// node bin/ear.js --server-check (is an embed-capable flm up?)
|
| 8 |
+
//
|
| 9 |
+
// Programmatic: const { identify } = require('./ear'); await identify(text)
|
| 10 |
+
'use strict';
|
| 11 |
+
const fs = require('fs');
|
| 12 |
+
const path = require('path');
|
| 13 |
+
const http = require('http');
|
| 14 |
+
|
| 15 |
+
const HEAD = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ear', 'head.json'), 'utf8'));
|
| 16 |
+
const BASE = process.env.FLM_BASE || 'http://localhost:52625';
|
| 17 |
+
|
| 18 |
+
function post(urlStr, body) {
|
| 19 |
+
return new Promise((resolve, reject) => {
|
| 20 |
+
const u = new URL(urlStr);
|
| 21 |
+
const data = JSON.stringify(body);
|
| 22 |
+
const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
|
| 23 |
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, timeout: 60000 },
|
| 24 |
+
res => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { reject(new Error('bad json: ' + b.slice(0, 200))); } }); });
|
| 25 |
+
req.on('error', reject);
|
| 26 |
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
| 27 |
+
req.write(data); req.end();
|
| 28 |
+
});
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
async function embed(text) {
|
| 32 |
+
let r;
|
| 33 |
+
try {
|
| 34 |
+
r = await post(BASE + '/v1/embeddings', { model: 'embed-gemma:300m', input: text });
|
| 35 |
+
} catch (e) {
|
| 36 |
+
if (/ECONNRESET|socket hang up/i.test(e.message)) { // FLM closes TCP per request; back off once
|
| 37 |
+
await new Promise(res => setTimeout(res, 400));
|
| 38 |
+
r = await post(BASE + '/v1/embeddings', { model: 'embed-gemma:300m', input: text });
|
| 39 |
+
} else throw e;
|
| 40 |
+
}
|
| 41 |
+
const v = r.data && r.data[0] && r.data[0].embedding;
|
| 42 |
+
if (!v) throw new Error('no embedding in response: ' + JSON.stringify(r).slice(0, 200));
|
| 43 |
+
// normalize (head was trained on normalized embeddings)
|
| 44 |
+
const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1;
|
| 45 |
+
return v.map(x => x / n);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function applyHead(v) {
|
| 49 |
+
const { W, b, labels } = HEAD;
|
| 50 |
+
const logits = b.slice();
|
| 51 |
+
for (let i = 0; i < v.length; i++) {
|
| 52 |
+
const wi = W[i];
|
| 53 |
+
for (let j = 0; j < logits.length; j++) logits[j] += v[i] * wi[j];
|
| 54 |
+
}
|
| 55 |
+
const m = Math.max(...logits);
|
| 56 |
+
const exps = logits.map(x => Math.exp(x - m));
|
| 57 |
+
const Z = exps.reduce((a, x) => a + x, 0);
|
| 58 |
+
const probs = exps.map(x => x / Z);
|
| 59 |
+
const top = probs.indexOf(Math.max(...probs));
|
| 60 |
+
return { label: labels[top], confidence: +probs[top].toFixed(3),
|
| 61 |
+
all: Object.fromEntries(labels.map((l, j) => [l, +probs[j].toFixed(3)])) };
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function identify(text) {
|
| 65 |
+
const t0 = Date.now();
|
| 66 |
+
const v = await embed(text);
|
| 67 |
+
const out = applyHead(v);
|
| 68 |
+
out.ms = Date.now() - t0;
|
| 69 |
+
return out;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
module.exports = { identify, applyHead, embed };
|
| 73 |
+
|
| 74 |
+
if (require.main === module) {
|
| 75 |
+
(async () => {
|
| 76 |
+
const arg = process.argv.slice(2).join(' ');
|
| 77 |
+
if (arg === '--server-check') {
|
| 78 |
+
try { await embed('hello'); console.log('embed endpoint OK at ' + BASE); }
|
| 79 |
+
catch (e) { console.log('embed endpoint unavailable: ' + e.message.slice(0, 120)); console.log('start one with: flm serve embed-gemma:300m --embed 1 (or flm serve <chat-model> --embed 1)'); }
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
if (!arg) { console.error('usage: node bin/ear.js "text" | --server-check'); process.exit(1); }
|
| 83 |
+
try {
|
| 84 |
+
console.log(JSON.stringify(await identify(arg), null, 2));
|
| 85 |
+
} catch (e) {
|
| 86 |
+
console.error('ear error: ' + e.message);
|
| 87 |
+
process.exit(1);
|
| 88 |
+
}
|
| 89 |
+
})();
|
| 90 |
+
}
|
bin/menu.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// menu.js — npu-forge front door (start.bat runs this).
|
| 3 |
+
'use strict';
|
| 4 |
+
const readline = require('readline');
|
| 5 |
+
const path = require('path');
|
| 6 |
+
const { spawnSync, spawn } = require('child_process');
|
| 7 |
+
|
| 8 |
+
const FORGE = path.join(__dirname, '..');
|
| 9 |
+
const ENV = { ...process.env, PYTHONIOENCODING: 'utf-8' };
|
| 10 |
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
| 11 |
+
const pending = [], waiting = [];
|
| 12 |
+
let closed = false;
|
| 13 |
+
rl.on('line', l => { const w = waiting.shift(); if (w) w(l); else pending.push(l); });
|
| 14 |
+
rl.on('close', () => { closed = true; while (waiting.length) waiting.shift()(null); });
|
| 15 |
+
const ask = q => { process.stdout.write(q); return new Promise(r => { const b = pending.shift(); if (b !== undefined) { console.log(b); r(b); } else if (closed) r(null); else waiting.push(r); }); };
|
| 16 |
+
|
| 17 |
+
function forge(args) {
|
| 18 |
+
rl.pause();
|
| 19 |
+
spawnSync('node', [path.join(FORGE, 'forge.js'), ...args], { stdio: 'inherit', env: ENV, shell: true });
|
| 20 |
+
rl.resume();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
(async () => {
|
| 24 |
+
while (true) {
|
| 25 |
+
console.log('\n==============================================');
|
| 26 |
+
console.log(' NPU-FORGE — your models, on your NPU');
|
| 27 |
+
console.log('==============================================');
|
| 28 |
+
console.log(' 1. doctor — NPU / driver / registry health');
|
| 29 |
+
console.log(' 2. list — models + your forge customs');
|
| 30 |
+
console.log(' 3. register — activate customs (UAC, one click)');
|
| 31 |
+
console.log(' 4. serve <tag> — start an NPU server');
|
| 32 |
+
console.log(' t. tune — how to fine-tune your own (guide)');
|
| 33 |
+
console.log(' q. quit');
|
| 34 |
+
const pick = ((await ask('\nPick: ')) || '').trim().toLowerCase();
|
| 35 |
+
if (pick === null || pick === 'q' || pick === '') break;
|
| 36 |
+
if (pick === '1') forge(['doctor']);
|
| 37 |
+
else if (pick === '2') forge(['list']);
|
| 38 |
+
else if (pick === '3') {
|
| 39 |
+
spawn('cmd', ['/c', 'start', '', path.join(FORGE, 'register-admin.bat')], { detached: true });
|
| 40 |
+
console.log('(UAC window launched — click Yes; results land in register-cmd-log.txt)');
|
| 41 |
+
}
|
| 42 |
+
else if (pick.startsWith('4')) {
|
| 43 |
+
const tag = pick.split(/\s+/)[1] || (await ask('model tag (e.g. llama3.2:1b): ')) || '';
|
| 44 |
+
if (tag.trim()) { console.log('(Ctrl+C stops the server and returns here)'); forge(['serve', tag.trim()]); }
|
| 45 |
+
}
|
| 46 |
+
else if (pick === 't') {
|
| 47 |
+
console.log('\nFine-tune your own voice onto the NPU (needs a free modal.com account):');
|
| 48 |
+
console.log(' 1. Make chats.jsonl — one line per exchange:');
|
| 49 |
+
console.log(' {"messages":[{"role":"user","content":"..."},{"role":"assistant","content":"..."}]}');
|
| 50 |
+
console.log(' 2. node forge.js tune chats.jsonl --name myvoice');
|
| 51 |
+
console.log(' 3. node forge.js register (one UAC click)');
|
| 52 |
+
console.log(' 4. flm run myvoice-forge:1b — your voice, on your NPU.');
|
| 53 |
+
console.log(' (~3 minutes and ~$0.10 of cloud time for a 1B; see README.md)');
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
rl.close();
|
| 57 |
+
})();
|
modal/train_ear_head.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""npu-forge-ear: train a tiny voice-verifier HEAD on EmbeddingGemma-300m
|
| 2 |
+
embeddings. At runtime the embeddings come from FLM's NPU (embed-gemma:300m,
|
| 3 |
+
unquantized) and the head is ~20KB of JSON applied in plain JS — a fully
|
| 4 |
+
NPU-accelerated 'trained ear' with zero extra runtimes.
|
| 5 |
+
|
| 6 |
+
modal run train_ear_head.py
|
| 7 |
+
"""
|
| 8 |
+
import modal
|
| 9 |
+
|
| 10 |
+
app = modal.App("npu-forge-ear")
|
| 11 |
+
image = (
|
| 12 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 13 |
+
.pip_install("torch==2.5.1", "transformers==4.56.2", "sentence-transformers==5.1.0", "numpy", "huggingface_hub")
|
| 14 |
+
.add_local_dir("C:/Users/Forgemind/Desktop/voice-harness/scratch/verifier-data", remote_path="/data")
|
| 15 |
+
)
|
| 16 |
+
vol = modal.Volume.from_name("npu-forge-out", create_if_missing=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@app.function(image=image, gpu="T4", timeout=2400, volumes={"/out": vol}, secrets=[modal.Secret.from_name("huggingface-token")])
|
| 20 |
+
def train():
|
| 21 |
+
import json, numpy as np, torch
|
| 22 |
+
from sentence_transformers import SentenceTransformer
|
| 23 |
+
|
| 24 |
+
def load(p):
|
| 25 |
+
return [json.loads(l) for l in open(p, encoding="utf-8") if l.strip()]
|
| 26 |
+
|
| 27 |
+
labels = json.load(open("/data/labels.json"))
|
| 28 |
+
lab2id = {l: i for i, l in enumerate(labels)}
|
| 29 |
+
train_rows, eval_rows, probe_rows = load("/data/train.jsonl"), load("/data/eval.jsonl"), load("/data/probe.jsonl")
|
| 30 |
+
|
| 31 |
+
candidates = ["google/embeddinggemma-300m", "unsloth/embeddinggemma-300m", "onnx-community/embeddinggemma-300m-ONNX"]
|
| 32 |
+
model = None
|
| 33 |
+
for cid in candidates:
|
| 34 |
+
try:
|
| 35 |
+
model = SentenceTransformer(cid, device="cuda")
|
| 36 |
+
print("[embed model] " + cid)
|
| 37 |
+
break
|
| 38 |
+
except Exception as e:
|
| 39 |
+
print(f"[skip] {cid}: {str(e)[:120]}")
|
| 40 |
+
if model is None:
|
| 41 |
+
raise RuntimeError("no embeddinggemma variant loadable")
|
| 42 |
+
|
| 43 |
+
def embed(rows):
|
| 44 |
+
return np.asarray(model.encode([r["text"] for r in rows], batch_size=64, show_progress_bar=False, normalize_embeddings=True))
|
| 45 |
+
|
| 46 |
+
Xtr, Xev, Xpr = embed(train_rows), embed(eval_rows), embed(probe_rows)
|
| 47 |
+
ytr = np.array([lab2id[r["label"]] for r in train_rows])
|
| 48 |
+
|
| 49 |
+
# multinomial logistic head, full-batch
|
| 50 |
+
d, k = Xtr.shape[1], len(labels)
|
| 51 |
+
W = torch.zeros(d, k, requires_grad=True)
|
| 52 |
+
b = torch.zeros(k, requires_grad=True)
|
| 53 |
+
Xt = torch.tensor(Xtr, dtype=torch.float32)
|
| 54 |
+
yt = torch.tensor(ytr)
|
| 55 |
+
opt = torch.optim.Adam([W, b], lr=0.05)
|
| 56 |
+
for i in range(400):
|
| 57 |
+
loss = torch.nn.functional.cross_entropy(Xt @ W + b, yt) + 1e-4 * W.pow(2).sum()
|
| 58 |
+
opt.zero_grad(); loss.backward(); opt.step()
|
| 59 |
+
print(f"[head] final loss {loss.item():.4f}, dim={d}")
|
| 60 |
+
|
| 61 |
+
def acc(X, rows):
|
| 62 |
+
pred = (torch.tensor(X, dtype=torch.float32) @ W + b).argmax(-1).numpy()
|
| 63 |
+
per = {}
|
| 64 |
+
for r, p in zip(rows, pred):
|
| 65 |
+
s = per.setdefault(r["label"], [0, 0]); s[1] += 1
|
| 66 |
+
if labels[p] == r["label"]: s[0] += 1
|
| 67 |
+
overall = sum(v[0] for v in per.values()) / max(1, len(rows))
|
| 68 |
+
return overall, {l: f"{v[0]}/{v[1]}" for l, v in per.items()}
|
| 69 |
+
|
| 70 |
+
ev_overall, ev_per = acc(Xev, eval_rows)
|
| 71 |
+
pr_overall, pr_per = acc(Xpr, probe_rows)
|
| 72 |
+
|
| 73 |
+
head = {"labels": labels, "dim": d, "normalize": True,
|
| 74 |
+
"W": W.detach().numpy().round(5).tolist(), "b": b.detach().numpy().round(5).tolist()}
|
| 75 |
+
import os
|
| 76 |
+
os.makedirs("/out/ear-head", exist_ok=True)
|
| 77 |
+
with open("/out/ear-head/head.json", "w") as f:
|
| 78 |
+
json.dump(head, f)
|
| 79 |
+
vol.commit()
|
| 80 |
+
return {"eval_overall": round(ev_overall, 4), "eval_per_class": ev_per,
|
| 81 |
+
"probe_recognized": pr_per, "probe_overall": round(pr_overall, 4),
|
| 82 |
+
"head_kb": round(len(json.dumps(head)) / 1024)}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@app.local_entrypoint()
|
| 86 |
+
def main():
|
| 87 |
+
import json
|
| 88 |
+
print(json.dumps(train.remote(), indent=2))
|
start.bat
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
title NPU-Forge
|
| 3 |
+
cd /d "%~dp0"
|
| 4 |
+
where node >nul 2>nul
|
| 5 |
+
if errorlevel 1 (
|
| 6 |
+
echo [!] Node.js is required. Free download: https://nodejs.org - pick LTS, one click.
|
| 7 |
+
pause
|
| 8 |
+
exit /b 1
|
| 9 |
+
)
|
| 10 |
+
node "%~dp0bin\menu.js"
|
| 11 |
+
pause
|