forge v0.2: one-command tune->NPU pipeline (183s proven), forge CLI, snag ledger, frozen known-good stack
Browse files- README.md +64 -69
- bin/assemble.js +75 -0
- forge.bat +2 -0
- forge.js +134 -0
- modal/convert_q4nx.py +56 -0
- modal/tune_npu.py +108 -0
README.md
CHANGED
|
@@ -7,88 +7,83 @@ tags:
|
|
| 7 |
- xdna2
|
| 8 |
- fastflowlm
|
| 9 |
- strix-halo
|
|
|
|
|
|
|
| 10 |
- tools
|
| 11 |
pipeline_tag: text-generation
|
| 12 |
---
|
| 13 |
|
| 14 |
-
# NPU-Forge 🔥 —
|
| 15 |
|
| 16 |
-
**
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
survive on disk — but `model_list.json` lives in `C:\Program Files\flm\` and
|
| 21 |
-
every update resets it, de-registering every custom entry you ever added.
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
-
custom model entry lives here forever. FLM updates can't touch it.
|
| 32 |
-
- **`bin/register.js`** — idempotently merges your registry into FLM's
|
| 33 |
-
`model_list.json`. Run it any time a model "disappears" — takes one second,
|
| 34 |
-
backs up FLM's list (timestamped) before every write.
|
| 35 |
-
- **`register-admin.bat`** — double-click version that self-elevates (the
|
| 36 |
-
one part that needs admin is writing into Program Files).
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
[FLM_Q4NX_Converter](https://github.com/FastFlowLM/FLM_Q4NX_Converter)
|
| 42 |
-
(GGUF in — any quant — Q4NX out). Supported families: LLaMA, Qwen 2/2.5/3/3.5,
|
| 43 |
-
Gemma 3, Phi-4, LFM2, GPT-OSS, and more.
|
| 44 |
-
2. Put the model folder in `C:\Users\<you>\Documents\flm\models\<YourModel>\`
|
| 45 |
-
with the standard four files: `config.json`, `model.q4nx`,
|
| 46 |
-
`tokenizer.json`, `tokenizer_config.json`. (No admin needed — model
|
| 47 |
-
storage is user-space.)
|
| 48 |
-
3. Add an entry to `registry.json` (copy `registry.example.json` and edit —
|
| 49 |
-
easiest is mirroring the entry of the same-architecture stock model from
|
| 50 |
-
FLM's own `model_list.json`, changing `name` to your folder name and
|
| 51 |
-
blanking `url`).
|
| 52 |
-
4. Double-click `register-admin.bat`. Done: `flm run yourmodel-forge:8b`.
|
| 53 |
-
5. FLM updated and your model vanished again? Double-click again. That's it.
|
| 54 |
|
| 55 |
-
``
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
"default_context_length": 8192,
|
| 65 |
-
"max_prefill_len": 4096,
|
| 66 |
-
"files": ["config.json", "model.q4nx", "tokenizer.json", "tokenizer_config.json"],
|
| 67 |
-
"details": { "family": "llama3", "parameter_size": "8B", "quantization_level": "Q4_1" },
|
| 68 |
-
"footprint": 4.7
|
| 69 |
-
}
|
| 70 |
-
}
|
| 71 |
-
}
|
| 72 |
-
}
|
| 73 |
-
```
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
- A user-level `Documents\flm\model_list.json` is **ignored** (we tested).
|
| 81 |
-
- Unregistered model folders can't be run by folder name (we tested) —
|
| 82 |
-
registration is mandatory.
|
| 83 |
-
- FLM compiles per-model NPU kernels (`.xclbin`) on first run; they live in
|
| 84 |
-
the model folder and survive alongside it.
|
| 85 |
|
| 86 |
-
##
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
|
|
|
| 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 |
+
## The snag ledger — six walls we hit so you don't
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
1. **The Q4NX converter's `convert.py` CLI is broken at HEAD** (uncommented
|
| 64 |
+
debug `sys.argv` override hijacks every invocation). Call the module API:
|
| 65 |
+
`from q4nx import create_converter; create_converter(gguf, "").convert(q4nx_path=out, weights_type="language")`
|
| 66 |
+
2. Converter needs `einops` and `tqdm` beyond its README list, and **must run
|
| 67 |
+
with cwd = its repo root** (relative `configs/<arch>.json` loads).
|
| 68 |
+
3. **Llama-3.2 tokenizers need `transformers>=4.46`** — the error
|
| 69 |
+
`untagged enum ModelWrapper` is that wall exactly.
|
| 70 |
+
4. **`transformers 4.46` needs `accelerate>=1.0`** — the error
|
| 71 |
+
`'AdamW' object has no attribute 'train'` at step 0 is that skew.
|
| 72 |
+
5. **T4 + Llama-3.2's 128k vocab OOMs at batch 4** (loss-logits blowup).
|
| 73 |
+
Floor: batch 1 × grad-accum 8 + gradient checkpointing.
|
| 74 |
+
6. **NPU driver minimum for current FLM: `32.0.203.304`** (`.311`
|
| 75 |
+
recommended). `flm validate` will tell you; so will `forge doctor`.
|
| 76 |
|
| 77 |
+
**Frozen known-good stack** (the whole point — never debug this again):
|
| 78 |
+
`torch 2.4.1 · transformers 4.46.3 · trl 0.9.6 · peft 0.12.0 ·
|
| 79 |
+
accelerate 1.1.1 · datasets 2.21.0 · gguf · amd-quark · einops · tqdm · protobuf`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
+
## Requirements
|
| 82 |
|
| 83 |
+
- AMD Ryzen AI machine with XDNA2 NPU (Strix, Strix Halo, Kraken…) +
|
| 84 |
+
[FastFlowLM](https://github.com/FastFlowLM/FastFlowLM)
|
| 85 |
+
- Node.js (the CLI), Python + a [Modal](https://modal.com) account (the cloud legs)
|
| 86 |
+
- NPU driver ≥ 32.0.203.304
|
| 87 |
|
| 88 |
+
Part of an ongoing project to make local NPUs a first-class home for personal
|
| 89 |
+
AI — voices you own, on silicon you own.
|
bin/assemble.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// assemble.js — local leg of forge convert: pull the converted .q4nx from the
|
| 3 |
+
// Modal volume, build the FLM model folder (donor config/tokenizer from the
|
| 4 |
+
// same-architecture stock model), and add the registry.json entry.
|
| 5 |
+
//
|
| 6 |
+
// node bin/assemble.js --out forge-llama32-1b --donor Llama-3.2-1B-NPU2 \
|
| 7 |
+
// --tag llama32-forge:1b --donor-tag llama3.2:1b
|
| 8 |
+
'use strict';
|
| 9 |
+
const fs = require('fs');
|
| 10 |
+
const path = require('path');
|
| 11 |
+
const { spawnSync } = require('child_process');
|
| 12 |
+
|
| 13 |
+
const FORGE = path.join(__dirname, '..');
|
| 14 |
+
const FLM_MODELS = path.join(process.env.USERPROFILE, 'Documents', 'flm', 'models');
|
| 15 |
+
const FLM_LIST = 'C:\\Program Files\\flm\\model_list.json';
|
| 16 |
+
|
| 17 |
+
function arg(name, def) {
|
| 18 |
+
const i = process.argv.indexOf('--' + name);
|
| 19 |
+
return i > 0 ? process.argv[i + 1] : def;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function main() {
|
| 23 |
+
const outName = arg('out'); // volume folder name
|
| 24 |
+
const donor = arg('donor'); // stock model folder to copy config/tokenizer from
|
| 25 |
+
const tag = arg('tag'); // e.g. llama32-forge:1b
|
| 26 |
+
const donorTag = arg('donor-tag'); // e.g. llama3.2:1b (registry donor entry)
|
| 27 |
+
if (!outName || !donor || !tag || !donorTag) {
|
| 28 |
+
console.error('usage: node bin/assemble.js --out <volumeName> --donor <StockFolder> --tag <key:size> --donor-tag <stockKey:size>');
|
| 29 |
+
process.exit(1);
|
| 30 |
+
}
|
| 31 |
+
const forgeName = 'Forge-' + outName.replace(/^forge-/, '') + '-NPU2';
|
| 32 |
+
const dest = path.join(FLM_MODELS, forgeName);
|
| 33 |
+
fs.mkdirSync(dest, { recursive: true });
|
| 34 |
+
|
| 35 |
+
// 1. download q4nx + tokenizer.json (the converter extracts it from GGUF)
|
| 36 |
+
console.log('[1/3] downloading from Modal volume npu-forge-out/' + outName);
|
| 37 |
+
for (const f of ['model.q4nx', 'tokenizer.json']) {
|
| 38 |
+
const r = spawnSync('modal', ['volume', 'get', 'npu-forge-out', outName + '/' + f, path.join(dest, f), '--force'],
|
| 39 |
+
{ encoding: 'utf8', env: { ...process.env, PYTHONIOENCODING: 'utf-8' }, shell: true, timeout: 900000 });
|
| 40 |
+
if (!fs.existsSync(path.join(dest, f))) {
|
| 41 |
+
console.error('download failed for ' + f + ':\n' + (r.stderr || r.stdout || '').slice(-800));
|
| 42 |
+
process.exit(1);
|
| 43 |
+
}
|
| 44 |
+
console.log(' ' + f + ': ' + (fs.statSync(path.join(dest, f)).size / 1e6).toFixed(1) + ' MB');
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// 2. donor config files (same architecture => identical config works)
|
| 48 |
+
console.log('[2/3] copying config.json + tokenizer_config.json from donor ' + donor);
|
| 49 |
+
const donorDir = path.join(FLM_MODELS, donor);
|
| 50 |
+
for (const f of ['config.json', 'tokenizer_config.json']) {
|
| 51 |
+
fs.copyFileSync(path.join(donorDir, f), path.join(dest, f));
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// 3. registry entry from the donor's model_list entry
|
| 55 |
+
console.log('[3/3] adding registry.json entry ' + tag);
|
| 56 |
+
const ml = JSON.parse(fs.readFileSync(FLM_LIST, 'utf8').replace(/^/, ''));
|
| 57 |
+
const [dKey, dSize] = donorTag.split(':');
|
| 58 |
+
const donorEntry = ml.models[dKey] && ml.models[dKey][dSize];
|
| 59 |
+
if (!donorEntry) { console.error('donor tag not in model_list: ' + donorTag); process.exit(1); }
|
| 60 |
+
const entry = JSON.parse(JSON.stringify(donorEntry));
|
| 61 |
+
entry.name = forgeName; entry.url = ''; entry.file_url = '';
|
| 62 |
+
entry.modified_at = new Date().toISOString();
|
| 63 |
+
const regPath = path.join(FORGE, 'registry.json');
|
| 64 |
+
const reg = fs.existsSync(regPath) ? JSON.parse(fs.readFileSync(regPath, 'utf8').replace(/^/, '')) : { models: {} };
|
| 65 |
+
const [key, size] = tag.split(':');
|
| 66 |
+
reg.models[key] = reg.models[key] || {};
|
| 67 |
+
reg.models[key][size] = entry;
|
| 68 |
+
fs.writeFileSync(regPath, JSON.stringify(reg, null, 2));
|
| 69 |
+
|
| 70 |
+
console.log('\nAssembled: ' + dest);
|
| 71 |
+
console.log('Registered in forge registry as ' + tag + ' — now double-click register-admin.bat, then:');
|
| 72 |
+
console.log(' flm run ' + tag);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
main();
|
forge.bat
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
node "%~dp0forge.js" %*
|
forge.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// forge — tune and convert models for AMD Ryzen AI NPUs, one command.
|
| 3 |
+
//
|
| 4 |
+
// forge tune <chats.jsonl> [--name grandma] [--base unsloth/Llama-3.2-1B-Instruct]
|
| 5 |
+
// [--donor-tag llama3.2:1b] [--epochs 3]
|
| 6 |
+
// forge convert <hf-repo> <file.gguf> [--name mymodel] [--donor-tag llama3.2:1b]
|
| 7 |
+
// forge register merge registry.json into FLM's list (UAC if needed)
|
| 8 |
+
// forge list stock models + forge customs with status
|
| 9 |
+
// forge doctor NPU stack / registry / port health checks
|
| 10 |
+
// forge serve <tag> flm serve passthrough (performance pmode)
|
| 11 |
+
'use strict';
|
| 12 |
+
const fs = require('fs');
|
| 13 |
+
const path = require('path');
|
| 14 |
+
const { spawnSync, spawn } = require('child_process');
|
| 15 |
+
|
| 16 |
+
const FORGE = __dirname;
|
| 17 |
+
const FLM_LIST = 'C:\\Program Files\\flm\\model_list.json';
|
| 18 |
+
const FLM_MODELS = path.join(process.env.USERPROFILE, 'Documents', 'flm', 'models');
|
| 19 |
+
const ENV = { ...process.env, PYTHONIOENCODING: 'utf-8' };
|
| 20 |
+
|
| 21 |
+
const argv = process.argv.slice(2);
|
| 22 |
+
const cmd = argv[0];
|
| 23 |
+
function flag(name, def) { const i = argv.indexOf('--' + name); return i > 0 ? argv[i + 1] : def; }
|
| 24 |
+
function run(exe, args, opts = {}) {
|
| 25 |
+
const r = spawnSync(exe, args, { stdio: 'inherit', env: ENV, shell: true, ...opts });
|
| 26 |
+
return r.status === 0;
|
| 27 |
+
}
|
| 28 |
+
function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, '')); } catch (_) { return null; } }
|
| 29 |
+
|
| 30 |
+
function assemble(outName, tag, donorTag, donor) {
|
| 31 |
+
return run('node', [path.join(FORGE, 'bin', 'assemble.js'), '--out', outName,
|
| 32 |
+
'--donor', donor, '--tag', tag, '--donor-tag', donorTag]);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function donorFolderFor(donorTag) {
|
| 36 |
+
const ml = readJson(FLM_LIST) || { models: {} };
|
| 37 |
+
const [k, s] = (donorTag || 'llama3.2:1b').split(':');
|
| 38 |
+
const e = ml.models[k] && ml.models[k][s];
|
| 39 |
+
if (!e) { console.error('donor tag not found in FLM list: ' + donorTag); process.exit(1); }
|
| 40 |
+
return e.name;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if (cmd === 'tune') {
|
| 44 |
+
const data = argv[1];
|
| 45 |
+
if (!data || !fs.existsSync(data)) { console.error('forge tune <chats.jsonl> — file not found'); process.exit(1); }
|
| 46 |
+
const name = flag('name', path.basename(data, path.extname(data)));
|
| 47 |
+
const base = flag('base', 'unsloth/Llama-3.2-1B-Instruct');
|
| 48 |
+
const donorTag = flag('donor-tag', 'llama3.2:1b');
|
| 49 |
+
const epochs = flag('epochs', '3');
|
| 50 |
+
const outName = 'forge-' + name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
| 51 |
+
// stage data where the modal mount expects it
|
| 52 |
+
const stage = path.join(FORGE, 'scratch', 'tune-data');
|
| 53 |
+
fs.mkdirSync(stage, { recursive: true });
|
| 54 |
+
fs.copyFileSync(data, path.join(stage, 'data.jsonl'));
|
| 55 |
+
console.log(`forge tune: ${data} -> ${base} (${epochs} epochs) -> NPU as ${name}-forge`);
|
| 56 |
+
if (!run('modal', ['run', path.join(FORGE, 'modal', 'tune_npu.py'),
|
| 57 |
+
'--base', base, '--data-file', 'data.jsonl', '--out-name', outName, '--epochs', epochs])) {
|
| 58 |
+
console.error('tune job failed — see output above'); process.exit(1);
|
| 59 |
+
}
|
| 60 |
+
if (!assemble(outName, name.toLowerCase() + '-forge:1b', donorTag, donorFolderFor(donorTag))) process.exit(1);
|
| 61 |
+
console.log('\nDone. Run: forge register then: flm run ' + name.toLowerCase() + '-forge:1b');
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
else if (cmd === 'convert') {
|
| 65 |
+
const repo = argv[1], file = argv[2];
|
| 66 |
+
if (!repo || !file) { console.error('forge convert <hf-repo> <file.gguf>'); process.exit(1); }
|
| 67 |
+
const name = flag('name', file.replace(/\.gguf$/i, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 30));
|
| 68 |
+
const donorTag = flag('donor-tag', 'llama3.2:1b');
|
| 69 |
+
const outName = 'forge-' + name;
|
| 70 |
+
console.log(`forge convert: ${repo}/${file} -> NPU as ${name}-forge`);
|
| 71 |
+
if (!run('modal', ['run', path.join(FORGE, 'modal', 'convert_q4nx.py'),
|
| 72 |
+
'--repo-id', repo, '--filename', file, '--out-name', outName])) {
|
| 73 |
+
console.error('convert job failed'); process.exit(1);
|
| 74 |
+
}
|
| 75 |
+
const size = donorTag.split(':')[1] || '1b';
|
| 76 |
+
if (!assemble(outName, name + '-forge:' + size, donorTag, donorFolderFor(donorTag))) process.exit(1);
|
| 77 |
+
console.log('\nDone. Run: forge register then: flm run ' + name + '-forge:' + size);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
else if (cmd === 'register') {
|
| 81 |
+
const r = spawnSync('node', [path.join(FORGE, 'bin', 'register.js')], { encoding: 'utf8', env: ENV, shell: true });
|
| 82 |
+
process.stdout.write(r.stdout || '');
|
| 83 |
+
if (r.status !== 0) {
|
| 84 |
+
console.log('(needs admin — launching UAC prompt via register-admin.bat)');
|
| 85 |
+
spawn('cmd', ['/c', 'start', '', path.join(FORGE, 'register-admin.bat')], { detached: true, shell: false });
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
else if (cmd === 'list') {
|
| 90 |
+
run('flm', ['list', '--filter', 'installed']);
|
| 91 |
+
const reg = readJson(path.join(FORGE, 'registry.json')) || { models: {} };
|
| 92 |
+
const ml = readJson(FLM_LIST) || { models: {} };
|
| 93 |
+
console.log('\nforge customs:');
|
| 94 |
+
for (const [k, sizes] of Object.entries(reg.models)) {
|
| 95 |
+
for (const [s, e] of Object.entries(sizes)) {
|
| 96 |
+
const registered = !!(ml.models[k] && ml.models[k][s]);
|
| 97 |
+
const folder = fs.existsSync(path.join(FLM_MODELS, e.name));
|
| 98 |
+
console.log(` ${k}:${s} files:${folder ? 'OK' : 'MISSING'} registered:${registered ? 'YES' : 'no — run forge register'}`);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
else if (cmd === 'doctor') {
|
| 104 |
+
console.log('— NPU device —');
|
| 105 |
+
run('powershell', ['-NoProfile', '-Command', '"Get-PnpDevice -Class ComputeAccelerator | Select-Object Status, FriendlyName | Format-Table -HideTableHeaders"']);
|
| 106 |
+
console.log('— NPU driver version (FLM needs >= 32.0.203.304, recommends .311) —');
|
| 107 |
+
const dv = spawnSync('powershell', ['-NoProfile', '-Command', '"Get-PnpDevice -Class ComputeAccelerator | ForEach-Object { (Get-PnpDeviceProperty -InstanceId $_.InstanceId -KeyName DEVPKEY_Device_DriverVersion).Data }"'], { encoding: 'utf8', shell: true });
|
| 108 |
+
const ver = (dv.stdout || '').trim().split('\n')[0] || 'unknown';
|
| 109 |
+
const last = parseInt((ver.match(/(\d+)$/) || [])[1] || '0', 10);
|
| 110 |
+
console.log(` installed: ${ver}` + (last && last < 304 ? ' -> TOO OLD: update the AMD NPU driver (AMD Software / amd.com Ryzen AI driver), then re-run doctor' : last ? ' -> OK' : ''));
|
| 111 |
+
console.log('— flm validate —');
|
| 112 |
+
run('flm', ['validate']);
|
| 113 |
+
console.log('— registry drift (customs lost to an flm update?) —');
|
| 114 |
+
const reg = readJson(path.join(FORGE, 'registry.json')) || { models: {} };
|
| 115 |
+
const ml = readJson(FLM_LIST) || { models: {} };
|
| 116 |
+
let drift = 0;
|
| 117 |
+
for (const [k, sizes] of Object.entries(reg.models)) for (const s of Object.keys(sizes)) {
|
| 118 |
+
if (!(ml.models[k] && ml.models[k][s])) { console.log(` LOST: ${k}:${s} — run forge register`); drift++; }
|
| 119 |
+
}
|
| 120 |
+
if (!drift) console.log(' all forge customs registered');
|
| 121 |
+
console.log('— port 52625 —');
|
| 122 |
+
const ns = spawnSync('netstat', ['-ano'], { encoding: 'utf8' });
|
| 123 |
+
console.log((ns.stdout || '').split('\n').some(l => l.includes(':52625') && l.includes('LISTENING')) ? ' flm serve is UP' : ' free (flm not serving)');
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
else if (cmd === 'serve') {
|
| 127 |
+
const tag = argv[1];
|
| 128 |
+
if (!tag) { console.error('forge serve <tag>'); process.exit(1); }
|
| 129 |
+
run('flm', ['serve', tag, '--pmode', 'performance']);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
else {
|
| 133 |
+
console.log(fs.readFileSync(__filename, 'utf8').split('\n').slice(1, 11).map(l => l.replace(/^\/\/ ?/, '')).join('\n'));
|
| 134 |
+
}
|
modal/convert_q4nx.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""npu-forge-convert: GGUF -> Q4NX on Modal (the remote leg of forge convert).
|
| 2 |
+
Clones the official FLM_Q4NX_Converter, pulls a GGUF from HF, converts, and
|
| 3 |
+
parks the .q4nx on a volume for the local box to download.
|
| 4 |
+
|
| 5 |
+
modal run convert_q4nx.py --repo-id bartowski/Llama-3.2-1B-Instruct-GGUF \
|
| 6 |
+
--filename Llama-3.2-1B-Instruct-Q4_K_M.gguf --out-name forge-llama32-1b
|
| 7 |
+
"""
|
| 8 |
+
import modal
|
| 9 |
+
|
| 10 |
+
app = modal.App("npu-forge-convert")
|
| 11 |
+
image = (
|
| 12 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 13 |
+
.apt_install("git")
|
| 14 |
+
.pip_install("torch==2.4.1", "transformers==4.44.2", "numpy", "gguf", "amd-quark", "huggingface_hub", "safetensors", "sentencepiece", "einops", "tqdm")
|
| 15 |
+
.run_commands(
|
| 16 |
+
"git clone --depth 1 https://github.com/FastFlowLM/FLM_Q4NX_Converter /converter",
|
| 17 |
+
"if [ -f /converter/requirements.txt ]; then pip install -r /converter/requirements.txt; fi",
|
| 18 |
+
)
|
| 19 |
+
)
|
| 20 |
+
vol = modal.Volume.from_name("npu-forge-out", create_if_missing=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@app.function(image=image, cpu=8, memory=16384, timeout=3600, volumes={"/out": vol})
|
| 24 |
+
def convert(repo_id: str, filename: str, out_name: str, family: str = ""):
|
| 25 |
+
import subprocess, os, time
|
| 26 |
+
from huggingface_hub import hf_hub_download
|
| 27 |
+
|
| 28 |
+
t0 = time.time()
|
| 29 |
+
gguf = hf_hub_download(repo_id=repo_id, filename=filename)
|
| 30 |
+
t1 = time.time()
|
| 31 |
+
print(f"[download] {filename} in {t1-t0:.0f}s ({os.path.getsize(gguf)/1e9:.2f} GB)")
|
| 32 |
+
|
| 33 |
+
outdir = f"/out/{out_name}"
|
| 34 |
+
os.makedirs(outdir, exist_ok=True)
|
| 35 |
+
# call the converter's Python API directly — upstream convert.py HEAD has
|
| 36 |
+
# an uncommented debug sys.argv override that hijacks every CLI invocation
|
| 37 |
+
import sys
|
| 38 |
+
sys.path.insert(0, "/converter")
|
| 39 |
+
os.chdir("/converter") # converter loads configs/<arch>.json relative to its repo root
|
| 40 |
+
from q4nx import create_converter
|
| 41 |
+
print(f"[convert] create_converter({gguf}, family={family!r})")
|
| 42 |
+
model = create_converter(gguf, family or "")
|
| 43 |
+
model.convert(q4nx_path=outdir, weights_type="language")
|
| 44 |
+
t2 = time.time()
|
| 45 |
+
files = {f: os.path.getsize(os.path.join(outdir, f)) for f in os.listdir(outdir)}
|
| 46 |
+
vol.commit()
|
| 47 |
+
return {"convert_seconds": round(t2 - t1), "download_seconds": round(t1 - t0), "outputs": files}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@app.local_entrypoint()
|
| 51 |
+
def main(repo_id: str = "bartowski/Llama-3.2-1B-Instruct-GGUF",
|
| 52 |
+
filename: str = "Llama-3.2-1B-Instruct-Q4_K_M.gguf",
|
| 53 |
+
out_name: str = "forge-llama32-1b",
|
| 54 |
+
family: str = ""):
|
| 55 |
+
import json
|
| 56 |
+
print(json.dumps(convert.remote(repo_id, filename, out_name, family), indent=2))
|
modal/tune_npu.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""npu-forge-tune: THE pipeline — LoRA fine-tune -> merge -> in-job voice
|
| 2 |
+
proof -> GGUF -> Q4NX, one container, one command. Output lands on the
|
| 3 |
+
npu-forge-out volume ready for bin/assemble.js on the local box.
|
| 4 |
+
|
| 5 |
+
modal run tune_npu.py (defaults: Llama-3.2-1B + grandma.jsonl)
|
| 6 |
+
"""
|
| 7 |
+
import modal
|
| 8 |
+
|
| 9 |
+
app = modal.App("npu-forge-tune")
|
| 10 |
+
image = (
|
| 11 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 12 |
+
.apt_install("git")
|
| 13 |
+
.pip_install(
|
| 14 |
+
"torch==2.4.1", "transformers==4.46.3", "peft==0.12.0", "trl==0.9.6",
|
| 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 |
+
)
|
| 24 |
+
vol = modal.Volume.from_name("npu-forge-out", create_if_missing=True)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@app.function(image=image, gpu="T4", cpu=8, memory=32768, timeout=5400, volumes={"/out": vol})
|
| 28 |
+
def tune(base: str, data_file: str, out_name: str, epochs: int = 3, probe: str = "Good morning grandma, how is the garden today?"):
|
| 29 |
+
import json, os, subprocess, time
|
| 30 |
+
import torch
|
| 31 |
+
from datasets import load_dataset
|
| 32 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM
|
| 33 |
+
from peft import LoraConfig
|
| 34 |
+
from trl import SFTTrainer, SFTConfig
|
| 35 |
+
|
| 36 |
+
timings = {}
|
| 37 |
+
t = time.time()
|
| 38 |
+
|
| 39 |
+
tok = AutoTokenizer.from_pretrained(base)
|
| 40 |
+
if tok.pad_token is None:
|
| 41 |
+
tok.pad_token = tok.eos_token
|
| 42 |
+
model = AutoModelForCausalLM.from_pretrained(base, torch_dtype=torch.float16, device_map="cuda")
|
| 43 |
+
|
| 44 |
+
ds = load_dataset("json", data_files=f"/data/{data_file}", split="train")
|
| 45 |
+
ds = ds.map(lambda r: {"text": tok.apply_chat_template(r["messages"], tokenize=False)})
|
| 46 |
+
print(f"[data] {len(ds)} examples")
|
| 47 |
+
|
| 48 |
+
trainer = SFTTrainer(
|
| 49 |
+
model=model,
|
| 50 |
+
train_dataset=ds,
|
| 51 |
+
peft_config=LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]),
|
| 52 |
+
args=SFTConfig(
|
| 53 |
+
output_dir="/tmp/sft", num_train_epochs=epochs, per_device_train_batch_size=1,
|
| 54 |
+
gradient_accumulation_steps=8, learning_rate=2e-4, logging_steps=20,
|
| 55 |
+
max_seq_length=1024, dataset_text_field="text", report_to=[], save_strategy="no",
|
| 56 |
+
fp16=True, gradient_checkpointing=True,
|
| 57 |
+
),
|
| 58 |
+
)
|
| 59 |
+
trainer.train()
|
| 60 |
+
timings["train_s"] = round(time.time() - t); t = time.time()
|
| 61 |
+
|
| 62 |
+
merged = trainer.model.merge_and_unload()
|
| 63 |
+
merged.save_pretrained("/tmp/merged", safe_serialization=True)
|
| 64 |
+
tok.save_pretrained("/tmp/merged")
|
| 65 |
+
timings["merge_s"] = round(time.time() - t); t = time.time()
|
| 66 |
+
|
| 67 |
+
# in-job voice proof: does the merged model actually speak the tune?
|
| 68 |
+
msgs = [{"role": "user", "content": probe}]
|
| 69 |
+
inp = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt").to("cuda")
|
| 70 |
+
with torch.no_grad():
|
| 71 |
+
gen = merged.generate(inp, max_new_tokens=160, temperature=0.8, do_sample=True, pad_token_id=tok.eos_token_id)
|
| 72 |
+
sample = tok.decode(gen[0][inp.shape[1]:], skip_special_tokens=True)
|
| 73 |
+
print("[voice proof] " + sample[:600])
|
| 74 |
+
timings["sample_s"] = round(time.time() - t); t = time.time()
|
| 75 |
+
|
| 76 |
+
# HF -> GGUF (q8_0 keeps quality into the Q4NX requant)
|
| 77 |
+
r = subprocess.run(["python", "/llamacpp/convert_hf_to_gguf.py", "/tmp/merged",
|
| 78 |
+
"--outfile", "/tmp/model-q8.gguf", "--outtype", "q8_0"],
|
| 79 |
+
capture_output=True, text=True)
|
| 80 |
+
if r.returncode != 0:
|
| 81 |
+
print(r.stdout[-2000:]); print("STDERR:", r.stderr[-3000:])
|
| 82 |
+
raise RuntimeError("convert_hf_to_gguf failed")
|
| 83 |
+
timings["gguf_s"] = round(time.time() - t); t = time.time()
|
| 84 |
+
|
| 85 |
+
# GGUF -> Q4NX (proven stage: module API, cwd=/converter)
|
| 86 |
+
import sys
|
| 87 |
+
sys.path.insert(0, "/converter")
|
| 88 |
+
os.chdir("/converter")
|
| 89 |
+
from q4nx import create_converter
|
| 90 |
+
outdir = f"/out/{out_name}"
|
| 91 |
+
os.makedirs(outdir, exist_ok=True)
|
| 92 |
+
create_converter("/tmp/model-q8.gguf", "").convert(q4nx_path=outdir, weights_type="language")
|
| 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)}
|
| 96 |
+
with open(os.path.join(outdir, "tune-report.json"), "w") as fh:
|
| 97 |
+
json.dump({"base": base, "data": data_file, "epochs": epochs, "sample": sample, "timings": timings}, fh, indent=2)
|
| 98 |
+
vol.commit()
|
| 99 |
+
return {"timings": timings, "outputs": files, "voice_sample": sample[:400]}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@app.local_entrypoint()
|
| 103 |
+
def main(base: str = "unsloth/Llama-3.2-1B-Instruct",
|
| 104 |
+
data_file: str = "grandma.jsonl",
|
| 105 |
+
out_name: str = "forge-grandma-1b",
|
| 106 |
+
epochs: int = 3):
|
| 107 |
+
import json
|
| 108 |
+
print(json.dumps(tune.remote(base, data_file, out_name, epochs), indent=2))
|