LJTSG commited on
Commit
06497ff
·
verified ·
1 Parent(s): 7d5a851

forge v0.2: one-command tune->NPU pipeline (183s proven), forge CLI, snag ledger, frozen known-good stack

Browse files
Files changed (6) hide show
  1. README.md +64 -69
  2. bin/assemble.js +75 -0
  3. forge.bat +2 -0
  4. forge.js +134 -0
  5. modal/convert_q4nx.py +56 -0
  6. 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 🔥 — keep your custom models alive on AMD Ryzen AI NPUs
15
 
16
- **The problem nobody tells you about:** you convert a fine-tuned model to
17
- [FastFlowLM](https://github.com/FastFlowLM/FastFlowLM)'s Q4NX format, register
18
- it in `model_list.json`, it runs beautifully on your NPU and then an FLM
19
- update ships and your custom model **silently disappears**. The model files
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
- We hit this on a Strix Halo box: a fully converted Llama-3 fine-tune
24
- (Stheno 8B), complete with compiled `.xclbin` NPU kernels, sitting in
25
- `Documents\flm\models\` invisible to `flm list` because an update had
26
- eaten its registration.
 
 
 
 
 
 
27
 
28
- **NPU-Forge fixes it permanently:**
 
 
 
29
 
30
- - **`registry.json`** — YOUR registry, in user space, owned by you. Every
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
- ## Usage
 
 
 
 
 
 
 
 
39
 
40
- 1. Convert your fine-tune to Q4NX with the official
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
- ```json
56
- // registry.example.json — one custom llama3-family 8B model
57
- {
58
- "models": {
59
- "mymodel-forge": {
60
- "8b": {
61
- "name": "MyModel-8B-NPU2",
62
- "url": "",
63
- "file_url": "",
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
- ## Facts we verified the hard way (Strix Halo, FLM v0.9.43)
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- - Model **files** are user-space (`Documents\flm\models\`)no admin needed.
78
- - The **registry** (`model_list.json`) is Program Files admin only, and
79
- reset by updates.
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
- ## Where this is going
87
 
88
- NPU-Forge is the first piece of a larger goal: **one-command fine-tune NPU**.
89
- The full pipeline (LoRA fine-tune in the cloud → GGUF → Q4NX → registered and
90
- serving on your NPU, single command) is in active development, along with
91
- encoder-model (BERT-class) NPU deployment. Watch this repo.
92
 
93
- Built on a Ryzen AI MAX+ 395 (Strix Halo, XDNA2) as part of an ongoing
94
- project to make local NPUs a first-class home for personal AI.
 
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 pointnever 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))