The engine turns a worker's race line into one TUNING {json} line in the app log (card model as the worker names it,
driver, arch, program class loads and wide loads, every variant's MH/s, winner, gain, the card's power cap and
draw, MH per watt), which the existing intake receives; the card state carries the variant for the dashboard and
one event per race. tools/tuning.mjs aggregates the records from miner_logs per card model (median MH/s or MH per
watt, at least 3 samples, de-duplicated per race) and writes tuning.json; publish-manifest.sh --tuning puts it in
the signed manifest (and now takes --override for consensus.override; both are carried over from the current
manifest when not given, --no-tuning drops it); manifest.rs parses it; ota.rs writes <app data>/tuning.json and
removes it when the manifest drops it; procs::spawn takes an environment and every miner starts with
IGNEUM_TUNING_FILE, which its worker reads at every prepare. Dry run of the publisher against a scratch folder:
tuning and override written, carried over, dropped, signature verified.
docs/plans/miner-perf.md: the signed jobs for PC 1 (fetch the race build of the NVRTC worker, then
relay/playbooks/race-5090.ps1 with the miners stopped: 17 variants, 3 rounds, twice) with the exact publish
commands for the main session; not published by the agent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
133 lines
7.1 KiB
JavaScript
133 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
// Fleet learning for the GPU workers' kernel variants (docs/design/miner-tuning.md). Runs on the Mac.
|
|
//
|
|
// Every app logs one `TUNING {json}` line per hourly race (src/engine.rs race_line: card model, driver, program
|
|
// class, every variant's MH/s, the winner, power cap, MH per watt). The app log reaches the intake (Neon table
|
|
// miner_logs, the same one tools/logs.mjs reads). This script aggregates those records per card model and writes
|
|
// the tuning object the over-the-air manifest carries back to every machine (publish-manifest.sh --tuning):
|
|
//
|
|
// node tools/tuning.mjs the table: per card model, per variant, samples, median MH/s, MH per watt
|
|
// node tools/tuning.mjs --write tuning.json [--days 7] [--min-samples 3] [--by mhs|mhw] [--pin]
|
|
// writes {"updated": ..., "cards": {"<card>": {"variant", "race", "candidates", "samples", "mhs", "base_mhs",
|
|
// "gain_pct", "mh_per_w"}}}. The winner is the variant with the best median over the window; "candidates" are
|
|
// the top three, which the workers race first at every prepare. --pin sets race false (the worker uses the
|
|
// variant without a race; the record then carries only the self-test), the default keeps racing (the fleet keeps
|
|
// learning while the card starts from the known best).
|
|
// node tools/tuning.mjs --records [--days 7] [--card <model>] the raw records, newest first
|
|
//
|
|
// Reads DATABASE_URL from ~/.config/igneum/env. No dependencies: Neon HTTP SQL over fetch.
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
|
|
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
|
|
|
|
const env = readFileSync(`${homedir()}/.config/igneum/env`, 'utf8');
|
|
const m = /^DATABASE_URL=(.*)$/m.exec(env);
|
|
if (!m) { console.error('DATABASE_URL not found in ~/.config/igneum/env'); process.exit(1); }
|
|
const url = m[1].trim().replace(/^['"]|['"]$/g, '');
|
|
const host = new URL(url).hostname.replace('-pooler', '');
|
|
|
|
async function sql(query, params = []) {
|
|
const r = await fetch(`https://${host}/sql`, {
|
|
method: 'POST',
|
|
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query, params }),
|
|
});
|
|
const j = await r.json();
|
|
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
|
return j.rows;
|
|
}
|
|
|
|
const args = process.argv.slice(2);
|
|
const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 && i + 1 < args.length ? args[i + 1] : def; };
|
|
const flag = name => args.includes(name);
|
|
const days = Number(opt('--days', 7));
|
|
const minSamples = Number(opt('--min-samples', 3));
|
|
const by = opt('--by', 'mhs');
|
|
const outFile = opt('--write', '');
|
|
const onlyCard = opt('--card', '');
|
|
|
|
// Every app-log upload of the window; the TUNING lines out of them. The same race is uploaded many times (the log
|
|
// is re-sent every minute), so records are de-duplicated on (machine, card, epoch).
|
|
const rows = await sql(
|
|
`SELECT machine, run_id, lines FROM miner_logs WHERE received_at > now() - ($1 || ' days')::interval AND lines LIKE '%TUNING {%' ORDER BY received_at DESC`,
|
|
[String(days)]);
|
|
const seen = new Set();
|
|
const records = [];
|
|
for (const r of rows) {
|
|
for (const line of r.lines.split('\n')) {
|
|
const i = line.indexOf('TUNING {');
|
|
if (i < 0) continue;
|
|
let rec;
|
|
try { rec = JSON.parse(line.slice(i + 7)); } catch { continue; }
|
|
if (!rec.card || !rec.winner) continue;
|
|
const key = `${rec.machine}|${rec.card}|${rec.epoch}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
rec.run_id = r.run_id;
|
|
records.push(rec);
|
|
}
|
|
}
|
|
records.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
|
|
if (flag('--records')) {
|
|
for (const r of records) if (!onlyCard || r.card === onlyCard) console.log(JSON.stringify(r));
|
|
process.exit(0);
|
|
}
|
|
|
|
const median = xs => { const s = [...xs].sort((a, b) => a - b); return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : 0; };
|
|
|
|
// Per card model (the worker's device name), per variant: every timed MH/s of every race (a race that could not time a
|
|
// variant leaves it null), plus MH per watt from the race's power draw when the card reported one.
|
|
const cards = {};
|
|
for (const r of records) {
|
|
if (onlyCard && r.card !== onlyCard) continue;
|
|
const c = cards[r.card] ??= { worker: r.worker, vendor: r.vendor, machines: new Set(), races: 0, variants: {}, base: [], power: [] };
|
|
c.machines.add(r.machine);
|
|
c.races += 1;
|
|
if (r.base_mhs > 0) c.base.push(r.base_mhs);
|
|
if (r.power_w > 0) c.power.push(r.power_w);
|
|
for (const [name, mhs] of Object.entries(r.variants || {})) {
|
|
if (typeof mhs !== 'number' || mhs <= 0) continue;
|
|
const v = c.variants[name] ??= { mhs: [], mhw: [] };
|
|
v.mhs.push(mhs);
|
|
if (r.power_w > 0) v.mhw.push(mhs / r.power_w);
|
|
}
|
|
}
|
|
|
|
const tuning = { updated: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), window_days: days, cards: {} };
|
|
const tableRows = [];
|
|
for (const [card, c] of Object.entries(cards)) {
|
|
const stats = Object.entries(c.variants).map(([name, v]) => ({ name, samples: v.mhs.length, mhs: median(v.mhs), mhw: v.mhw.length ? median(v.mhw) : 0 }));
|
|
const baseMhs = stats.find(s => s.name === 'base')?.mhs || median(c.base);
|
|
const eligible = stats.filter(s => s.samples >= minSamples);
|
|
const ranked = (eligible.length ? eligible : stats).sort((a, b) => (by === 'mhw' ? b.mhw - a.mhw : b.mhs - a.mhs));
|
|
for (const s of stats.sort((a, b) => b.mhs - a.mhs)) {
|
|
tableRows.push({ card, variant: s.name, samples: s.samples, 'median MH/s': Number(s.mhs.toFixed(3)), 'vs base %': baseMhs ? Number(((s.mhs / baseMhs - 1) * 100).toFixed(2)) : null, 'MH/W': s.mhw ? Number(s.mhw.toFixed(3)) : null });
|
|
}
|
|
if (!ranked.length) continue;
|
|
const best = ranked[0];
|
|
tuning.cards[card] = {
|
|
variant: best.name,
|
|
race: !flag('--pin'),
|
|
candidates: ranked.slice(0, 3).map(s => s.name),
|
|
samples: best.samples,
|
|
races: c.races,
|
|
machines: c.machines.size,
|
|
mhs: Number(best.mhs.toFixed(3)),
|
|
base_mhs: Number(baseMhs.toFixed(3)),
|
|
gain_pct: baseMhs ? Number(((best.mhs / baseMhs - 1) * 100).toFixed(2)) : 0,
|
|
mh_per_w: best.mhw ? Number(best.mhw.toFixed(3)) : 0,
|
|
worker: c.worker,
|
|
by,
|
|
};
|
|
}
|
|
|
|
if (!records.length) { console.log(`No TUNING records in the last ${days} day(s). The workers log one per hourly race once the app with the race is on the machines.`); process.exit(0); }
|
|
console.log(`${records.length} race record(s) from ${new Set(records.map(r => r.machine)).size} machine(s), last ${days} day(s), winner by ${by === 'mhw' ? 'MH per watt' : 'median MH/s'}, at least ${minSamples} sample(s) to be eligible`);
|
|
console.table(tableRows);
|
|
for (const [card, t] of Object.entries(tuning.cards)) console.log(`${card}: ${t.variant} (${t.samples} samples, ${t.mhs} MH/s, ${t.gain_pct >= 0 ? '+' : ''}${t.gain_pct}% over base ${t.base_mhs}${t.mh_per_w ? `, ${t.mh_per_w} MH/W` : ''}); candidates ${t.candidates.join(', ')}; race ${t.race}`);
|
|
if (outFile) {
|
|
writeFileSync(outFile, JSON.stringify(tuning, null, 2) + '\n');
|
|
console.log(`written ${outFile}; publish with: packaging/ota/publish-manifest.sh --version <current> --tuning ${outFile} [--deploy]`);
|
|
}
|