quantprobe 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantprobe/__init__.py +11 -0
- quantprobe/cli.py +124 -0
- quantprobe/dashboard.py +179 -0
- quantprobe/detect.py +163 -0
- quantprobe/fetch.py +88 -0
- quantprobe/plan.py +208 -0
- quantprobe/probe.py +149 -0
- quantprobe/runtime.py +187 -0
- quantprobe/spec.py +89 -0
- quantprobe/target.py +88 -0
- quantprobe-1.3.0.dist-info/METADATA +296 -0
- quantprobe-1.3.0.dist-info/RECORD +16 -0
- quantprobe-1.3.0.dist-info/WHEEL +5 -0
- quantprobe-1.3.0.dist-info/entry_points.txt +2 -0
- quantprobe-1.3.0.dist-info/licenses/LICENSE +21 -0
- quantprobe-1.3.0.dist-info/top_level.txt +1 -0
quantprobe/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""quantprobe — probe-then-quantize for LLMs on commodity hardware.
|
|
2
|
+
|
|
3
|
+
Three commands, all grounded in measured laws (see LAWS.md in the repo):
|
|
4
|
+
quantprobe probe — measure a GGUF's depth-fragility curve, emit the depth-aware recipe (Law 3)
|
|
5
|
+
quantprobe plan — evaluate every bit/tier placement for a model on your machine, predict tok/s (Law 4)
|
|
6
|
+
quantprobe fetch — robust HuggingFace downloader (Range-resume, retry)
|
|
7
|
+
|
|
8
|
+
Every constant in `plan` is fitted from measurements published in the repo, including two
|
|
9
|
+
pre-registered hardware predictions confirmed within 8%.
|
|
10
|
+
"""
|
|
11
|
+
__version__ = "1.3.0"
|
quantprobe/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""quantprobe CLI — probe / plan / fetch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from .plan import numlist
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
import sys
|
|
10
|
+
try:
|
|
11
|
+
sys.stdout.reconfigure(errors="replace")
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
|
14
|
+
ap = argparse.ArgumentParser(
|
|
15
|
+
prog="quantprobe",
|
|
16
|
+
description="Probe-then-quantize for LLMs: fragility curves, placement plans, recipes. "
|
|
17
|
+
"Laws + evidence: github.com/FedericoTs/quantprobe")
|
|
18
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
19
|
+
|
|
20
|
+
p = sub.add_parser("probe", help="measure a GGUF's depth-fragility curve, emit the recipe (Law 3)")
|
|
21
|
+
p.add_argument("--gguf", required=True, help="f16/bf16 (or high-precision) source GGUF")
|
|
22
|
+
p.add_argument("--bands", type=int, default=4)
|
|
23
|
+
p.add_argument("--chunks", type=int, default=32)
|
|
24
|
+
p.add_argument("--eval", required=True, help="raw text eval file (e.g. wikitext-2 test)")
|
|
25
|
+
p.add_argument("--ngl", type=int, default=99, help="GPU layers for perplexity (0 for CPU)")
|
|
26
|
+
p.add_argument("--workdir", default=None)
|
|
27
|
+
p.add_argument("--llama-dir", default=None, help="dir containing llama-quantize/llama-perplexity")
|
|
28
|
+
p.add_argument("--apply", action="store_true", help="after probing, BUILD the recommended depth-aware GGUF")
|
|
29
|
+
p.add_argument("--out", default=None, help="output path for --apply (default: <model>-depthaware.gguf)")
|
|
30
|
+
p.add_argument("--dry-run", action="store_true")
|
|
31
|
+
|
|
32
|
+
q = sub.add_parser("plan", help="evaluate every bit/tier placement, predict tok/s (Law 4)")
|
|
33
|
+
q.add_argument("--model", default=None, help="preset: qwen3-30b deepseek-16b gemma-12b mistral-7b glm-air glm-744b")
|
|
34
|
+
q.add_argument("--machine", default=None, help="preset: 2016-xmp 2016 gaming ddr5 colibri")
|
|
35
|
+
q.add_argument("--bits", type=float, default=None, help="effective bits/weight (default: read from --gguf, else 2.5)")
|
|
36
|
+
q.add_argument("--total", type=float, help="total params (B)")
|
|
37
|
+
q.add_argument("--active", type=float, help="active params per token (B)")
|
|
38
|
+
q.add_argument("--always-active", type=float, help="always-active (attention/embed) params (B)")
|
|
39
|
+
q.add_argument("--vram", type=numlist, help="GB; comma-list for multi-GPU: 24,24"); q.add_argument("--vram-bw", type=numlist, help="GB/s; comma-list aggregates x0.85")
|
|
40
|
+
q.add_argument("--ram", type=float); q.add_argument("--ram-bw", type=float)
|
|
41
|
+
q.add_argument("--disk-bw", type=numlist, help="GB/s; comma-list (RAID) aggregates x0.75")
|
|
42
|
+
q.add_argument("--gguf", default=None, help="optional: read total/active/bits/KV exactly from this GGUF")
|
|
43
|
+
q.add_argument("--ctx", type=int, default=0, help="context depth: adds KV reads/token + KV memory to the prediction (Law 4 v2)")
|
|
44
|
+
q.add_argument("--kv-per-pos", type=float, default=None, help="KV bytes per position in KB (default: model preset, or 96)")
|
|
45
|
+
|
|
46
|
+
def hwargs(sp):
|
|
47
|
+
sp.add_argument("--model", default=None); sp.add_argument("--machine", default=None)
|
|
48
|
+
sp.add_argument("--bits", type=float, default=None, help="effective bits/weight (default: read from --gguf, else 2.5)")
|
|
49
|
+
sp.add_argument("--total", type=float); sp.add_argument("--active", type=float)
|
|
50
|
+
sp.add_argument("--always-active", type=float)
|
|
51
|
+
sp.add_argument("--vram", type=numlist); sp.add_argument("--vram-bw", type=numlist)
|
|
52
|
+
sp.add_argument("--ram", type=float); sp.add_argument("--ram-bw", type=float)
|
|
53
|
+
sp.add_argument("--disk-bw", type=numlist)
|
|
54
|
+
sp.add_argument("--ctx", type=int, default=0, help="context depth for the prediction (Law 4 v2)")
|
|
55
|
+
sp.add_argument("--kv-per-pos", type=float, default=None, help="KV bytes per position in KB")
|
|
56
|
+
sp.add_argument("--llama-dir", default=None); sp.add_argument("--dry", action="store_true")
|
|
57
|
+
|
|
58
|
+
r = sub.add_parser("run", help="plan the best placement, then launch llama.cpp chat with those flags")
|
|
59
|
+
r.add_argument("--gguf", required=True); hwargs(r)
|
|
60
|
+
r.add_argument("--serve", action="store_true", help="launch llama-server instead of interactive chat")
|
|
61
|
+
r.add_argument("--extra", default=None, help="extra flags passed through to llama.cpp")
|
|
62
|
+
|
|
63
|
+
b = sub.add_parser("bench", help="measure real tok/s with the planned flags; print predicted vs measured")
|
|
64
|
+
b.add_argument("--gguf", required=True); hwargs(b)
|
|
65
|
+
b.add_argument("--reps", type=int, default=3)
|
|
66
|
+
b.add_argument("--depth", type=int, default=None, help="bench at KV depth N (llama-bench -d): measures the Law 4 v2 context term on YOUR box")
|
|
67
|
+
b.add_argument("--contribute", action="store_true", help="print a pre-filled, opt-in GitHub issue with your predicted-vs-measured point (you review before submitting; nothing auto-sent)")
|
|
68
|
+
|
|
69
|
+
d = sub.add_parser("dashboard", help="launch llama-server with planned flags + a live predicted-vs-measured chat page")
|
|
70
|
+
d.add_argument("--gguf", required=True); hwargs(d)
|
|
71
|
+
d.add_argument("--port", type=int, default=8077); d.add_argument("--server-port", type=int, default=8090)
|
|
72
|
+
d.add_argument("--no-open", action="store_true")
|
|
73
|
+
|
|
74
|
+
g = sub.add_parser("target", help="INVERSE planning: give a tok/s target, get the smartest feasible model + the speed-intelligence ladder")
|
|
75
|
+
g.add_argument("--tps", type=float, required=True, help="minimum tok/s you need")
|
|
76
|
+
hwargs(g)
|
|
77
|
+
g.add_argument("--ladder", action="store_true", help="print the full speed-vs-intelligence ladder")
|
|
78
|
+
|
|
79
|
+
z = sub.add_parser("quantize", help="COMPRESS: build a depth-aware GGUF from a model (protect the fragile band, rest 2-bit)")
|
|
80
|
+
z.add_argument("--gguf", required=True, help="high-precision source GGUF (f16/bf16/Q8)")
|
|
81
|
+
z.add_argument("--out", default=None, help="output path (default: <model>-depthaware.gguf)")
|
|
82
|
+
z.add_argument("--protect-late", type=int, default=12, help="protect the last N layers at 4-bit (default 12)")
|
|
83
|
+
z.add_argument("--protect", default=None, help="protect an explicit band LO-HI (e.g. 36-47) instead of --protect-late")
|
|
84
|
+
z.add_argument("--llama-dir", default=None)
|
|
85
|
+
z.add_argument("--dry", action="store_true")
|
|
86
|
+
|
|
87
|
+
hwp = sub.add_parser("hw", help="detect THIS machine's memory tiers (no flags needed); every value tagged with its source")
|
|
88
|
+
hwp.add_argument("--measure", default=None, metavar="FILE", help="also MEASURE sequential disk read on a real file (e.g. a GGUF)")
|
|
89
|
+
|
|
90
|
+
f = sub.add_parser("fetch", help="robust HF download (Range-resume, retry)")
|
|
91
|
+
f.add_argument("repo", help="HF repo, or a preset: qwen3-30b, glm-air, deepseek-16b, qwen3-0.6b"); f.add_argument("dest"); f.add_argument("files", nargs="*")
|
|
92
|
+
|
|
93
|
+
a = ap.parse_args()
|
|
94
|
+
if a.cmd == "probe":
|
|
95
|
+
from . import probe
|
|
96
|
+
probe.run(a)
|
|
97
|
+
elif a.cmd == "plan":
|
|
98
|
+
from . import plan
|
|
99
|
+
plan.run(a)
|
|
100
|
+
elif a.cmd == "run":
|
|
101
|
+
from . import runtime
|
|
102
|
+
runtime.run(a)
|
|
103
|
+
elif a.cmd == "bench":
|
|
104
|
+
from . import runtime
|
|
105
|
+
runtime.bench(a)
|
|
106
|
+
elif a.cmd == "dashboard":
|
|
107
|
+
from . import dashboard
|
|
108
|
+
dashboard.dashboard(a)
|
|
109
|
+
elif a.cmd == "target":
|
|
110
|
+
from . import target
|
|
111
|
+
target.run(a)
|
|
112
|
+
elif a.cmd == "hw":
|
|
113
|
+
from . import detect
|
|
114
|
+
detect.run(a)
|
|
115
|
+
elif a.cmd == "quantize":
|
|
116
|
+
from . import probe
|
|
117
|
+
probe.quantize(a)
|
|
118
|
+
else:
|
|
119
|
+
from . import fetch
|
|
120
|
+
fetch.run(a)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
quantprobe/dashboard.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""quantprobe dashboard — the law, live.
|
|
2
|
+
|
|
3
|
+
One command: plans the best placement, launches llama-server with those flags, and serves a local
|
|
4
|
+
page where you chat with the model while every reply is scored against the law's prediction —
|
|
5
|
+
predicted vs measured tok/s, live. Colibri-style dashboard energy, but the star is falsifiability:
|
|
6
|
+
the page is a running experiment, not a status screen.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import json, os, subprocess, sys, threading, time, urllib.request, webbrowser
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
|
|
12
|
+
from . import runtime
|
|
13
|
+
|
|
14
|
+
DASH_HTML = """<!doctype html><html><head><meta charset="utf-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
16
|
+
<title>quantprobe — the law, live</title>
|
|
17
|
+
<style>
|
|
18
|
+
:root{--g:#fafaf7;--p:#fff;--ink:#16181d;--sub:#5c6066;--line:#e5e4df;--acc:#0f766e;
|
|
19
|
+
--accsoft:#e6f2f0;--warn:#b45309;--mono:Consolas,ui-monospace,monospace}
|
|
20
|
+
@media(prefers-color-scheme:dark){:root{--g:#14161a;--p:#1c1f24;--ink:#e8e7e2;--sub:#9aa0a8;
|
|
21
|
+
--line:#2e3238;--acc:#35b8a6;--accsoft:#12332f;--warn:#e0a458}}
|
|
22
|
+
body{margin:0;background:var(--g);color:var(--ink);font-family:"Segoe UI",system-ui,sans-serif}
|
|
23
|
+
.wrap{max-width:980px;margin:0 auto;padding:22px 16px}
|
|
24
|
+
h1{font-size:21px;margin:0 0 4px}.sub{color:var(--sub);font-size:13px;margin-bottom:16px}
|
|
25
|
+
.law{font-family:var(--mono);font-size:12.5px;color:var(--acc);margin-bottom:14px}
|
|
26
|
+
.cols{display:grid;grid-template-columns:340px 1fr;gap:16px}
|
|
27
|
+
@media(max-width:760px){.cols{grid-template-columns:1fr}}
|
|
28
|
+
.panel{background:var(--p);border:1px solid var(--line);border-radius:10px;padding:14px}
|
|
29
|
+
.k{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--sub);font-weight:600;margin-bottom:8px}
|
|
30
|
+
.gauge{display:flex;gap:18px;align-items:baseline}
|
|
31
|
+
.gauge .n{font-family:var(--mono);font-size:44px;font-weight:700;color:var(--acc);font-variant-numeric:tabular-nums}
|
|
32
|
+
.gauge .lab{font-size:11px;color:var(--sub)}
|
|
33
|
+
.delta{font-family:var(--mono);font-size:15px;font-weight:600}
|
|
34
|
+
.tier{margin:8px 0}.tier .l{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11px;color:var(--sub);margin-bottom:3px}
|
|
35
|
+
.bar{height:11px;background:var(--g);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
36
|
+
.fill{height:100%;background:var(--acc)}
|
|
37
|
+
.log{font-family:var(--mono);font-size:11.5px;color:var(--sub);max-height:120px;overflow-y:auto;margin-top:8px}
|
|
38
|
+
.chat{display:flex;flex-direction:column;height:520px}
|
|
39
|
+
.msgs{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding-bottom:8px}
|
|
40
|
+
.m{max-width:85%;padding:8px 12px;border-radius:10px;font-size:14px;line-height:1.5;white-space:pre-wrap}
|
|
41
|
+
.m.u{align-self:flex-end;background:var(--accsoft);color:var(--ink)}
|
|
42
|
+
.m.a{align-self:flex-start;background:var(--g);border:1px solid var(--line)}
|
|
43
|
+
.m .t{display:block;margin-top:6px;font-family:var(--mono);font-size:10.5px;color:var(--acc)}
|
|
44
|
+
.row{display:flex;gap:8px}
|
|
45
|
+
textarea{flex:1;resize:none;height:54px;padding:9px;font-size:14px;font-family:inherit;
|
|
46
|
+
background:var(--g);color:var(--ink);border:1px solid var(--line);border-radius:8px}
|
|
47
|
+
button{padding:0 18px;background:var(--acc);color:#fff;border:0;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer}
|
|
48
|
+
button:disabled{opacity:.5}
|
|
49
|
+
.foot{margin-top:14px;font-size:11.5px;color:var(--sub)}.foot a{color:var(--acc)}
|
|
50
|
+
</style></head><body><div class="wrap">
|
|
51
|
+
<h1>The law, live</h1>
|
|
52
|
+
<div class="sub">{{MODEL}} · {{PLACEMENT}} · every reply below is scored against the pre-registered prediction</div>
|
|
53
|
+
<div class="law">tok/s = η(tier) × bandwidth ÷ active-bytes-per-token → predicted {{PRED}} tok/s for this box</div>
|
|
54
|
+
<div class="cols">
|
|
55
|
+
<div>
|
|
56
|
+
<div class="panel"><div class="k">Prediction vs reality</div>
|
|
57
|
+
<div class="gauge">
|
|
58
|
+
<div><div class="n" id="pred">{{PRED}}</div><div class="lab">predicted tok/s</div></div>
|
|
59
|
+
<div><div class="n" id="meas">—</div><div class="lab">measured (running avg)</div></div>
|
|
60
|
+
<div class="delta" id="delta"></div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="log" id="log">each reply appends a data point…</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="panel" style="margin-top:14px"><div class="k">Placement (planned)</div>{{TIERS}}
|
|
65
|
+
<div class="foot">Flags: <span style="font-family:var(--mono)">{{FLAGS}}</span></div></div>
|
|
66
|
+
<div class="panel" style="margin-top:14px"><div class="k">About</div>
|
|
67
|
+
<div class="foot">This page is a running experiment: the tiered decode law predicted this machine's speed
|
|
68
|
+
before the model loaded. Laws, probes, evidence: <a href="https://github.com/FedericoTs/quantprobe">repo</a>
|
|
69
|
+
· <a href="https://x.com/federico_sciuca">@federico_sciuca</a></div></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="panel chat"><div class="k">Chat — each reply is a measurement</div>
|
|
72
|
+
<div class="msgs" id="msgs"></div>
|
|
73
|
+
<div class="row"><textarea id="inp" placeholder="Say something… (Enter to send)"></textarea>
|
|
74
|
+
<button id="send">Send</button></div>
|
|
75
|
+
</div>
|
|
76
|
+
</div></div>
|
|
77
|
+
<script>
|
|
78
|
+
const hist=[],pts=[];const $=id=>document.getElementById(id);
|
|
79
|
+
function esc(s){return s.replace(/&/g,"&").replace(/</g,"<")}
|
|
80
|
+
async function send(){
|
|
81
|
+
const t=$("inp").value.trim(); if(!t)return;
|
|
82
|
+
$("inp").value=""; $("send").disabled=true;
|
|
83
|
+
hist.push({role:"user",content:t});
|
|
84
|
+
$("msgs").insertAdjacentHTML("beforeend",'<div class="m u">'+esc(t)+'</div>');
|
|
85
|
+
const hold=document.createElement("div");hold.className="m a";hold.textContent="…";
|
|
86
|
+
$("msgs").appendChild(hold);$("msgs").scrollTop=1e9;
|
|
87
|
+
try{
|
|
88
|
+
const r=await fetch("/api/chat",{method:"POST",headers:{"Content-Type":"application/json"},
|
|
89
|
+
body:JSON.stringify({messages:hist.slice(-12),max_tokens:256})});
|
|
90
|
+
const j=await r.json();
|
|
91
|
+
const msg=(j.choices&&j.choices[0].message.content)||"(no reply)";
|
|
92
|
+
const tps=j.timings?j.timings.predicted_per_second:null;
|
|
93
|
+
hist.push({role:"assistant",content:msg});
|
|
94
|
+
hold.innerHTML=esc(msg)+(tps?'<span class="t">'+tps.toFixed(1)+' tok/s · predicted {{PRED}}</span>':'');
|
|
95
|
+
if(tps){pts.push(tps);
|
|
96
|
+
const avg=pts.reduce((a,b)=>a+b,0)/pts.length,pred=parseFloat("{{PRED}}");
|
|
97
|
+
$("meas").textContent=avg.toFixed(1);
|
|
98
|
+
const d=(avg/pred-1)*100;
|
|
99
|
+
$("delta").textContent=(d>=0?"+":"")+d.toFixed(0)+"%";
|
|
100
|
+
$("delta").style.color=Math.abs(d)<25?"var(--acc)":"var(--warn)";
|
|
101
|
+
$("log").insertAdjacentHTML("afterbegin","#"+pts.length+": "+tps.toFixed(1)+" tok/s<br>");}
|
|
102
|
+
}catch(e){hold.textContent="error: "+e}
|
|
103
|
+
$("send").disabled=false;$("msgs").scrollTop=1e9;
|
|
104
|
+
}
|
|
105
|
+
$("send").onclick=send;
|
|
106
|
+
$("inp").addEventListener("keydown",e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send();}});
|
|
107
|
+
</script></body></html>"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def make_handler(upstream, page):
|
|
111
|
+
class H(BaseHTTPRequestHandler):
|
|
112
|
+
def log_message(self, *a):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def do_GET(self):
|
|
116
|
+
self.send_response(200)
|
|
117
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
118
|
+
self.end_headers()
|
|
119
|
+
self.wfile.write(page.encode("utf-8"))
|
|
120
|
+
|
|
121
|
+
def do_POST(self):
|
|
122
|
+
if self.path != "/api/chat":
|
|
123
|
+
self.send_response(404); self.end_headers(); return
|
|
124
|
+
n = int(self.headers.get("Content-Length", 0))
|
|
125
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
126
|
+
body.setdefault("timings_per_token", True)
|
|
127
|
+
req = urllib.request.Request(upstream + "/v1/chat/completions",
|
|
128
|
+
data=json.dumps(body).encode(),
|
|
129
|
+
headers={"Content-Type": "application/json"})
|
|
130
|
+
try:
|
|
131
|
+
with urllib.request.urlopen(req, timeout=600) as r:
|
|
132
|
+
data = r.read()
|
|
133
|
+
self.send_response(200)
|
|
134
|
+
self.send_header("Content-Type", "application/json")
|
|
135
|
+
self.end_headers()
|
|
136
|
+
self.wfile.write(data)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
self.send_response(502); self.end_headers()
|
|
139
|
+
self.wfile.write(json.dumps({"error": str(e)}).encode())
|
|
140
|
+
return H
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def dashboard(a):
|
|
144
|
+
best, flags = runtime.best_flags(a)
|
|
145
|
+
binp = runtime.find_llama(a.llama_dir, "llama-server")
|
|
146
|
+
sport = a.server_port
|
|
147
|
+
cmd = [binp, "-m", a.gguf, "--port", str(sport)] + flags
|
|
148
|
+
print(f"[quantprobe] placement: {best[0]} (predicted {best[1]:.1f} tok/s)")
|
|
149
|
+
print("[quantprobe] llama-server:", " ".join(cmd))
|
|
150
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
151
|
+
upstream = f"http://127.0.0.1:{sport}"
|
|
152
|
+
for _ in range(120):
|
|
153
|
+
try:
|
|
154
|
+
urllib.request.urlopen(upstream + "/health", timeout=2)
|
|
155
|
+
break
|
|
156
|
+
except Exception:
|
|
157
|
+
if proc.poll() is not None:
|
|
158
|
+
raise SystemExit("llama-server exited during startup — check the model path/flags")
|
|
159
|
+
time.sleep(1)
|
|
160
|
+
tiers = ""
|
|
161
|
+
for name, cap, used in runtime.tier_view(a, best):
|
|
162
|
+
pct = min(100, used / cap * 100) if cap else 0
|
|
163
|
+
tiers += (f'<div class="tier"><div class="l"><span>{name}</span>'
|
|
164
|
+
f'<span>{used:.1f} / {cap:.0f} GB</span></div>'
|
|
165
|
+
f'<div class="bar"><div class="fill" style="width:{pct:.0f}%"></div></div></div>')
|
|
166
|
+
page = (DASH_HTML.replace("{{MODEL}}", os.path.basename(a.gguf))
|
|
167
|
+
.replace("{{PLACEMENT}}", best[0]).replace("{{PRED}}", f"{best[1]:.1f}")
|
|
168
|
+
.replace("{{FLAGS}}", " ".join(flags)).replace("{{TIERS}}", tiers))
|
|
169
|
+
srv = ThreadingHTTPServer(("127.0.0.1", a.port), make_handler(upstream, page))
|
|
170
|
+
url = f"http://127.0.0.1:{a.port}"
|
|
171
|
+
print(f"[quantprobe] dashboard: {url} (Ctrl-C stops both)")
|
|
172
|
+
if not a.no_open:
|
|
173
|
+
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
|
174
|
+
try:
|
|
175
|
+
srv.serve_forever()
|
|
176
|
+
except KeyboardInterrupt:
|
|
177
|
+
pass
|
|
178
|
+
finally:
|
|
179
|
+
proc.terminate()
|
quantprobe/detect.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""quantprobe hw — read the local machine's memory tiers, no questions asked.
|
|
2
|
+
|
|
3
|
+
Every printed value is tagged with its source:
|
|
4
|
+
[os] read from the operating system / driver (capacity, stick speed, GPU name)
|
|
5
|
+
[table] looked up from the device name (bandwidth spec, eta class)
|
|
6
|
+
[default] a conservative fallback — override it with flags
|
|
7
|
+
Bandwidths are THEORETICAL peaks (the law's eta absorbs realism, same convention as the presets).
|
|
8
|
+
Nothing is sent anywhere; this only reads local OS interfaces.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import os, platform, re, shutil, subprocess
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(cmd, timeout=10):
|
|
15
|
+
try:
|
|
16
|
+
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout
|
|
17
|
+
except Exception:
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# name-fragment -> (VRAM bandwidth GB/s, geta, gl). 1060 measured on the reference box; rest spec-sheet [table].
|
|
22
|
+
GPU_TABLE = [
|
|
23
|
+
("5090", 1792, 0.62, 0.42), ("4090", 1008, 0.62, 0.42), ("4080", 717, 0.6, 0.4),
|
|
24
|
+
("4070", 504, 0.55, 0.35), ("4060", 272, 0.5, 0.3),
|
|
25
|
+
("3090", 936, 0.6, 0.4), ("3080", 760, 0.58, 0.38), ("3070", 448, 0.52, 0.32),
|
|
26
|
+
("3060 ti", 448, 0.5, 0.3), ("3060", 360, 0.5, 0.3), ("3050", 224, 0.45, 0.28),
|
|
27
|
+
("2080", 448, 0.45, 0.25), ("2070", 448, 0.45, 0.25), ("2060", 336, 0.42, 0.22),
|
|
28
|
+
("1080", 320, 0.38, 0.06), ("1070", 256, 0.36, 0.05), ("1060", 192, 0.35, 0.04),
|
|
29
|
+
("a100", 1935, 0.7, 0.55), ("h100", 3350, 0.75, 0.6), ("rtx 6000", 960, 0.62, 0.42),
|
|
30
|
+
]
|
|
31
|
+
MAC_BW = {"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
|
|
32
|
+
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
|
|
33
|
+
"m3 ultra": 819, "m3 max": 400, "m3 pro": 150, "m3": 100,
|
|
34
|
+
"m4 max": 546, "m4 pro": 273, "m4": 120}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def gpus():
|
|
38
|
+
"""[(name, vram_gb)] via nvidia-smi; empty list if none/AMD (AMD: pass flags for now)."""
|
|
39
|
+
out = _run(["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
|
|
40
|
+
gs = []
|
|
41
|
+
for line in out.strip().splitlines():
|
|
42
|
+
if "," in line:
|
|
43
|
+
name, mem = line.rsplit(",", 1)
|
|
44
|
+
try:
|
|
45
|
+
gs.append((name.strip(), float(mem) / 1024))
|
|
46
|
+
except ValueError:
|
|
47
|
+
pass
|
|
48
|
+
return gs
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def gpu_lookup(name):
|
|
52
|
+
n = name.lower()
|
|
53
|
+
for frag, bw, geta, gl in GPU_TABLE:
|
|
54
|
+
if frag in n:
|
|
55
|
+
return bw, geta, gl, "[table]"
|
|
56
|
+
return 300, 0.45, 0.27, "[default: unknown GPU, pass --vram-bw]"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ram_windows():
|
|
60
|
+
"""(total_gb[os], mts[os], channels[os]) via CIM (wmic fallback)."""
|
|
61
|
+
ps = _run(["powershell", "-NoProfile", "-c",
|
|
62
|
+
"$m=Get-CimInstance Win32_PhysicalMemory; "
|
|
63
|
+
"($m|Measure-Object Capacity -Sum).Sum; "
|
|
64
|
+
"($m|Select-Object -First 1).ConfiguredClockSpeed; ($m|Measure-Object).Count"])
|
|
65
|
+
vals = [v.strip() for v in ps.strip().splitlines() if v.strip()]
|
|
66
|
+
if len(vals) >= 3:
|
|
67
|
+
try:
|
|
68
|
+
return float(vals[0]) / 2**30, float(vals[1]), int(vals[2])
|
|
69
|
+
except ValueError:
|
|
70
|
+
pass
|
|
71
|
+
return None, None, None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def detect():
|
|
75
|
+
"""Return the machine as quantprobe hardware kwargs + a provenance report."""
|
|
76
|
+
sysname = platform.system()
|
|
77
|
+
hw, notes = {}, []
|
|
78
|
+
|
|
79
|
+
if sysname == "Darwin":
|
|
80
|
+
mem = _run(["sysctl", "-n", "hw.memsize"]).strip()
|
|
81
|
+
chip = _run(["sysctl", "-n", "machdep.cpu.brand_string"]).lower()
|
|
82
|
+
total = float(mem) / 2**30 if mem else 16.0
|
|
83
|
+
bw = next((b for frag, b in sorted(MAC_BW.items(), key=lambda x: -len(x[0])) if frag in chip), 100)
|
|
84
|
+
hw = dict(vram=round(total * 0.8), vram_bw=bw, ram=8, ram_bw=bw, disk_bw=3.5,
|
|
85
|
+
geta=0.26, gl=0.24)
|
|
86
|
+
notes.append(f"Apple unified memory: {total:.0f} GB [os], {bw} GB/s [table, est eta 0.26 - unvalidated: bench me]")
|
|
87
|
+
return hw, notes
|
|
88
|
+
|
|
89
|
+
# RAM
|
|
90
|
+
total, mts, sticks = (ram_windows() if os.name == "nt" else (None, None, None))
|
|
91
|
+
if total is None and os.path.exists("/proc/meminfo"):
|
|
92
|
+
with open("/proc/meminfo") as f:
|
|
93
|
+
kb = int(re.search(r"MemTotal:\s*(\d+)", f.read()).group(1))
|
|
94
|
+
total, mts, sticks = kb / 2**20, None, None
|
|
95
|
+
if total is None:
|
|
96
|
+
total = 16.0; notes.append("RAM capacity: 16 GB [default - detection failed]")
|
|
97
|
+
channels = max(1, min(sticks or 2, 8)) if sticks else 2
|
|
98
|
+
if mts:
|
|
99
|
+
ram_bw = round(channels * mts * 8 / 1000) # theoretical peak, preset convention
|
|
100
|
+
notes.append(f"RAM: {total:.0f} GB, {sticks} stick(s) @ {mts:.0f} MT/s [os] -> {ram_bw} GB/s peak "
|
|
101
|
+
f"(assumes {channels}-channel)")
|
|
102
|
+
else:
|
|
103
|
+
ram_bw = 48
|
|
104
|
+
notes.append(f"RAM: {total:.0f} GB [os]; speed unknown -> 48 GB/s [default: DDR4-3000 dual, pass --ram-bw]")
|
|
105
|
+
|
|
106
|
+
# GPU(s)
|
|
107
|
+
gs = gpus()
|
|
108
|
+
if gs:
|
|
109
|
+
vram = sum(g[1] for g in gs)
|
|
110
|
+
bw0, geta, gl, src = gpu_lookup(gs[0][0])
|
|
111
|
+
vram_bw = bw0 * len(gs) * (1.0 if len(gs) == 1 else 0.85) # aggregate w/ tensor-parallel loss
|
|
112
|
+
names = " + ".join(g[0] for g in gs)
|
|
113
|
+
notes.append(f"GPU: {names}, {vram:.0f} GB total [os], {vram_bw:.0f} GB/s {src}"
|
|
114
|
+
+ (f" (x{len(gs)} aggregate, 0.85 TP efficiency [est])" if len(gs) > 1 else ""))
|
|
115
|
+
hw.update(vram=vram, vram_bw=round(vram_bw), geta=geta, gl=gl)
|
|
116
|
+
else:
|
|
117
|
+
hw.update(vram=0, vram_bw=0)
|
|
118
|
+
notes.append("GPU: none detected (nvidia-smi absent/empty; AMD/Intel: pass --vram/--vram-bw) [os]")
|
|
119
|
+
|
|
120
|
+
# disk: class default; a real measured number needs `quantprobe hw --measure` (reads a large file)
|
|
121
|
+
hw.update(ram=round(total), ram_bw=ram_bw, disk_bw=0.5)
|
|
122
|
+
notes.append("disk: 0.5 GB/s [default: SATA-class; NVMe ~3.5, Gen4 ~7 - pass --disk-bw or run hw --measure]")
|
|
123
|
+
return hw, notes
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def measure_disk(path, mb=512):
|
|
127
|
+
"""Sequential read of a real file region, uncached-ish: the streaming pattern that matters."""
|
|
128
|
+
import time
|
|
129
|
+
size = os.path.getsize(path)
|
|
130
|
+
span = min(mb * 1024 * 1024, size)
|
|
131
|
+
off = max(0, size - span - (os.urandom(1)[0] % 7) * 1024 * 1024) # tail region, jittered
|
|
132
|
+
t0 = time.perf_counter()
|
|
133
|
+
with open(path, "rb", buffering=0) as f:
|
|
134
|
+
f.seek(off)
|
|
135
|
+
left = span
|
|
136
|
+
while left > 0:
|
|
137
|
+
chunk = f.read(min(1 << 24, left))
|
|
138
|
+
if not chunk:
|
|
139
|
+
break
|
|
140
|
+
left -= len(chunk)
|
|
141
|
+
dt = time.perf_counter() - t0
|
|
142
|
+
return (span - left) / 1e9 / dt
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run(a):
|
|
146
|
+
hw, notes = detect()
|
|
147
|
+
print("quantprobe hw - this machine, as the law sees it\n")
|
|
148
|
+
for n in notes:
|
|
149
|
+
print(" " + n)
|
|
150
|
+
if getattr(a, "measure", None):
|
|
151
|
+
p = a.measure
|
|
152
|
+
if os.path.isfile(p):
|
|
153
|
+
bw = measure_disk(p)
|
|
154
|
+
hw["disk_bw"] = round(bw, 2)
|
|
155
|
+
print(f" disk MEASURED on {os.path.basename(p)}: {bw:.2f} GB/s sequential [measured]")
|
|
156
|
+
else:
|
|
157
|
+
print(f" --measure: file not found: {p}")
|
|
158
|
+
flags = (f"--vram {hw['vram']:g} --vram-bw {hw['vram_bw']:g} --ram {hw['ram']:g} "
|
|
159
|
+
f"--ram-bw {hw['ram_bw']:g} --disk-bw {hw['disk_bw']:g}")
|
|
160
|
+
print(f"\n equivalent flags (for sharing / estimating this box elsewhere):\n {flags}")
|
|
161
|
+
print("\n every command now uses these automatically when you pass no hardware flags;")
|
|
162
|
+
print(" pass --machine or explicit flags to estimate a DIFFERENT machine instead.")
|
|
163
|
+
return hw
|
quantprobe/fetch.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""hf_fetch.py -- robust multi-file HF downloader (manual HTTP Range, retry-on-break), bypassing the
|
|
2
|
+
hf CLI's Xet-backend stalls. Usage: python -m weights.hf_fetch <repo_id> <dest_dir> <file1> [file2 ...]
|
|
3
|
+
Resumes partial .part files; token from HF_TOKEN env or ~/.cache/huggingface/token.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import os, sys, time
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PRESETS = {
|
|
11
|
+
"qwen3-30b": ("unsloth/Qwen3-30B-A3B-GGUF", "Qwen3-30B-A3B-Q2_K.gguf"),
|
|
12
|
+
"glm-air": ("unsloth/GLM-4.5-Air-GGUF", "GLM-4.5-Air-UD-IQ2_XXS.gguf"),
|
|
13
|
+
"deepseek-16b": ("bartowski/DeepSeek-Coder-V2-Lite-Base-GGUF", "DeepSeek-Coder-V2-Lite-Base-IQ2_XS.gguf"),
|
|
14
|
+
"qwen3-0.6b": ("unsloth/Qwen3-0.6B-GGUF", "Qwen3-0.6B-Q8_0.gguf"),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def token():
|
|
19
|
+
t = os.environ.get("HF_TOKEN")
|
|
20
|
+
if t:
|
|
21
|
+
return t.strip()
|
|
22
|
+
p = os.path.expanduser("~/.cache/huggingface/token")
|
|
23
|
+
return open(p).read().strip() if os.path.exists(p) else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch(repo, dest, fname, tok, tries=100):
|
|
27
|
+
url = f"https://huggingface.co/{repo}/resolve/main/{fname}"
|
|
28
|
+
out = os.path.join(dest, fname)
|
|
29
|
+
part = out + ".part"
|
|
30
|
+
if os.path.exists(out):
|
|
31
|
+
print(f" {fname}: already complete", flush=True)
|
|
32
|
+
return True
|
|
33
|
+
hdr0 = {"Authorization": f"Bearer {tok}"} if tok else {}
|
|
34
|
+
r = requests.head(url, headers=hdr0, allow_redirects=True, timeout=60)
|
|
35
|
+
total = int(r.headers.get("Content-Length", 0))
|
|
36
|
+
print(f" {fname}: {total/1e9:.2f} GB", flush=True)
|
|
37
|
+
t = 0
|
|
38
|
+
while t < tries:
|
|
39
|
+
have = os.path.getsize(part) if os.path.exists(part) else 0
|
|
40
|
+
if total and have >= total:
|
|
41
|
+
break
|
|
42
|
+
try:
|
|
43
|
+
h = dict(hdr0)
|
|
44
|
+
if have:
|
|
45
|
+
h["Range"] = f"bytes={have}-"
|
|
46
|
+
r = requests.get(url, headers=h, stream=True, timeout=(30, 120), allow_redirects=True)
|
|
47
|
+
if r.status_code not in (200, 206):
|
|
48
|
+
print(f" status {r.status_code}, retry", flush=True); time.sleep(5); t += 1; continue
|
|
49
|
+
mode = "ab" if (have and r.status_code == 206) else "wb"
|
|
50
|
+
t0 = last = time.time(); base = have if mode == "ab" else 0
|
|
51
|
+
with open(part, mode) as f:
|
|
52
|
+
for chunk in r.iter_content(1 << 22):
|
|
53
|
+
if chunk:
|
|
54
|
+
f.write(chunk)
|
|
55
|
+
if time.time() - last > 20:
|
|
56
|
+
sz = os.path.getsize(part)
|
|
57
|
+
print(f" {sz/1e9:.2f}/{total/1e9:.2f} GB ({(sz-base)/1e6/max(1e-6,time.time()-t0):.1f} MB/s)", flush=True)
|
|
58
|
+
last = time.time()
|
|
59
|
+
except (requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError,
|
|
60
|
+
requests.exceptions.ReadTimeout, requests.exceptions.Timeout) as e:
|
|
61
|
+
print(f" break at {os.path.getsize(part) if os.path.exists(part) else 0:,}, retry {t+1}: {str(e)[:60]}", flush=True)
|
|
62
|
+
time.sleep(3); t += 1
|
|
63
|
+
if total and os.path.exists(part) and os.path.getsize(part) == total:
|
|
64
|
+
os.replace(part, out)
|
|
65
|
+
print(f" {fname}: DONE", flush=True)
|
|
66
|
+
return True
|
|
67
|
+
print(f" {fname}: INCOMPLETE", flush=True)
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run(a):
|
|
72
|
+
import sys as _s
|
|
73
|
+
repo, files = a.repo, a.files
|
|
74
|
+
if repo in PRESETS and not files:
|
|
75
|
+
repo, f = PRESETS[repo]
|
|
76
|
+
files = [f]
|
|
77
|
+
print(f"[quantprobe] preset '{a.repo}' -> {repo}/{f}")
|
|
78
|
+
if not files:
|
|
79
|
+
_s.exit("no files given (or use a preset: " + ", ".join(PRESETS) + ")")
|
|
80
|
+
ok = all(fetch(repo, a.dest, fn, token()) for fn in files)
|
|
81
|
+
_s.exit(0 if ok else 1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
repo, dest = sys.argv[1], sys.argv[2]
|
|
86
|
+
os.makedirs(dest, exist_ok=True)
|
|
87
|
+
ok = all(fetch(repo, dest, f, token()) for f in sys.argv[3:])
|
|
88
|
+
sys.exit(0 if ok else 1)
|