Skip to content

Commit 73f5a26

Browse files
authored
Merge pull request #144 from ArdaEkiz0/windows-bench-submission
windows: one-command benchmark submission block (fixes #139)
2 parents 412116a + ed627fc commit 73f5a26

2 files changed

Lines changed: 237 additions & 11 deletions

File tree

‎windows/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,27 @@ Context to keep when reading these numbers:
129129
- Same-condition A/B against unpatched vanilla llama.cpp (vendor tree with the patch bands skipped) shows the patch set costs nothing (8B: 41.5 vs 39.5 decode; 35B: 27.2 vs 27.4 — within machine drift).
130130
- Expect day-to-day drift of a few percent on this class of machine; compare like-for-like, same day, same process if you benchmark.
131131

132+
### 2.1 Community benchmark submission (Hardware Benchmarks #142)
133+
134+
To add a Windows row to the Hardware Benchmarks discussion (#142), build the
135+
engine and download a tier as in §1, then run:
136+
137+
python tools/r3_bench.py --tier 8b --submission --reporter @you
138+
139+
The script discards the short warmup, runs 2 timed reps (`--reps N` to
140+
override) on the ~3k-token long-context prompt (same construction as the
141+
Python bench's BENCH_LONG), and prints a ready-to-paste submission block —
142+
the mean of the timed reps, with `device` / `chip` / `gpu` (the Vulkan device
143+
from the engine log) / `ram_gb` / `os` / `engine_commit` (vendor.llama.pin)
144+
filled in automatically. `--json` additionally prints the block as a single
145+
JSON line for aggregation, and `benchmarks/r3/bench_<tier>_submission.json`
146+
stores the per-rep detail.
147+
148+
On the memory field: `peak_active_gib` on Windows is the engine process's
149+
peak working set during the timed reps (OS-level high-water mark). Windows
150+
has no allocator-level counter, so this is not directly comparable with the
151+
python-mlx column's `mlx.core.get_peak_memory()`.
152+
132153
---
133154

134155
## 3. Technical Details

‎windows/tools/r3_bench.py‎

Lines changed: 216 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@
88
window when compared against the reference — cross-window comparison is always
99
suspect (measure on the same day / same process, or treat numbers as shape-only).
1010
11+
Community submission mode (Hardware Benchmarks discussion #142, issue #139):
12+
`--submission` discards the same short warmup, then runs `reps` timed reps
13+
(default 2) on the ~3k-token long-context prompt, averages the numbers, and
14+
prints the ready-to-paste submission block; `--json` prints the same data as
15+
JSON for aggregation.
16+
1117
Usage: python tools/r3_bench.py --tier 35b [--reps 3]
18+
python tools/r3_bench.py --tier 8b --submission --reporter @you
1219
"""
13-
import argparse, json, os, re, subprocess, sys, time
20+
import argparse, ctypes, json, os, re, subprocess, sys, time
1421

1522
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
1623

@@ -44,6 +51,21 @@ def models_root(tier):
4451

4552

4653
PROMPT = ("混合专家模型(MoE)通过稀疏激活把多个专家网络与门控路由组合起来:" * 12)
54+
55+
# Long-context prompt for the submission block (~3k tokens after tokenization):
56+
# identical construction and text to the Python bench's BENCH_LONG (see
57+
# python/examples/bench.py), so prefill numbers on both runtimes refer to the
58+
# same measured work. Short prompts only measure fixed overhead.
59+
_PARA_ZH = '\u6d41\u5f0f\u63a8\u7406\u6846\u67b6\u7684\u8bbe\u8ba1\u8981\u70b9\u5728\u4e8e\u628a\u78c1\u76d8\u5e26\u5bbd\u3001\u7f13\u5b58\u5c42\u7ea7\u4e0e\u8ba1\u7b97\u6838\u5fc3\u4e09\u8005\u91cd\u53e0\u8d77\u6765\u3002\u6df7\u5408\u4e13\u5bb6\u6a21\u578b\u6bcf\u4e00\u5c42\u53ea\u5728\u5c11\u6570\u4e13\u5bb6\u4e0a\u6fc0\u6d3b\uff0c\u8def\u7531\u5668\u7ed9\u51fa\u7684\u7a00\u758f\u9009\u62e9\u6070\u597d\u4e3a\u6309\u9700\u52a0\u8f7d\u63d0\u4f9b\u4e86\u5929\u7136\u7684\u6279\u7c92\u5ea6\u3002\u82e5\u80fd\u5728\u524d\u4e00\u6b65\u7684\u9690\u72b6\u6001\u57fa\u7840\u4e0a\u9884\u5224\u4e0b\u4e00\u6b65\u7684\u4e13\u5bb6\u96c6\u5408\uff0c\u56fa\u6001\u786c\u76d8\u7684\u8bfb\u53d6\u5ef6\u8fdf\u5c31\u80fd\u88ab\u8ba1\u7b97\u5b8c\u5168\u63a9\u76d6\uff0c\u8fd9\u5bf9\u8fb9\u7f18\u8bbe\u5907\u4e0a\u7684\u5927\u6a21\u578b\u90e8\u7f72\u5177\u6709\u76f4\u63a5\u610f\u4e49\u3002'
60+
_PARA_EN = 'The design of a streaming inference framework hinges on overlapping disk bandwidth, cache hierarchy, and compute. A mixture-of-experts layer activates only a handful of experts per token, and the router sparsity provides a natural granularity for on-demand loading. If the expert set of the next step can be predicted from the previous hidden state, SSD read latency hides behind compute entirely, which matters for large-model deployment on edge hardware.'
61+
_LONG_HEADER = '\u8bf7\u4ed4\u7ec6\u9605\u8bfb\u4ee5\u4e0b\u6750\u6599\u5e76\u7528\u4e00\u53e5\u8bdd\u603b\u7ed3\u5176\u6838\u5fc3\u601d\u60f3\uff1a'
62+
63+
64+
def long_prompt():
65+
paras = [_PARA_ZH if i % 2 == 0 else _PARA_EN for i in range(40)]
66+
return _LONG_HEADER + " ".join(paras)
67+
68+
4769
PAT = {
4870
"prompt": re.compile(r"prompt eval time =.*?,\s*([\d.]+) tokens per second"),
4971
"eval": re.compile(r"(?<!prompt )eval time =.*?,\s*([\d.]+) tokens per second"),
@@ -53,35 +75,198 @@ def models_root(tier):
5375
}
5476

5577

56-
def one(gguf, lora, n, tier):
78+
# ---------------------------------------------------------------- metadata --
79+
def cpu_name():
80+
"""CPU marketing name from the registry (no wmic/sysinfo dependency)."""
81+
try:
82+
import winreg
83+
k = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
84+
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0")
85+
name, _ = winreg.QueryValueEx(k, "ProcessorNameString")
86+
winreg.CloseKey(k)
87+
return " ".join(str(name).split())
88+
except OSError:
89+
return None
90+
91+
92+
def total_ram_gb():
93+
try:
94+
class MEMORYSTATUSEX(ctypes.Structure):
95+
_fields_ = [("dwLength", ctypes.c_uint),
96+
("dwMemoryLoad", ctypes.c_uint),
97+
("ullTotalPhys", ctypes.c_uint64),
98+
("ullAvailPhys", ctypes.c_uint64),
99+
("ullTotalPageFile", ctypes.c_uint64),
100+
("ullAvailPageFile", ctypes.c_uint64),
101+
("ullTotalVirtual", ctypes.c_uint64),
102+
("ullAvailVirtual", ctypes.c_uint64),
103+
("ullAvailExtendedVirtual", ctypes.c_uint64)]
104+
st = MEMORYSTATUSEX()
105+
st.dwLength = ctypes.sizeof(st)
106+
if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(st)):
107+
return None
108+
return st.ullTotalPhys / (1024 ** 3)
109+
except (OSError, AttributeError):
110+
return None
111+
112+
113+
def windows_build():
114+
v = sys.getwindowsversion()
115+
label = "11" if v.build >= 22000 else "10"
116+
return f"Windows {label} (build {v.build})"
117+
118+
119+
def machine_name():
120+
"""Computer name (device) from the registry; a human brand is not exposed by winreg."""
121+
try:
122+
import winreg
123+
k = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
124+
r"SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName")
125+
name, _ = winreg.QueryValueEx(k, "ComputerName")
126+
winreg.CloseKey(k)
127+
return str(name)
128+
except OSError:
129+
return None
130+
131+
132+
def engine_commit():
133+
"""Head of vendor.llama.pin at the repo root (the pinned llama.cpp fork)."""
134+
pin = os.path.join(ROOT, "..", "vendor.llama.pin")
135+
try:
136+
with open(pin, encoding="utf-8") as f:
137+
return f.read().splitlines()[0].strip()
138+
except OSError:
139+
return None
140+
141+
142+
def vulkan_device(err):
143+
"""Vulkan device name from the engine's own startup log, e.g:
144+
'ggml_vulkan: 0 = AMD Radeon RX 9070 GRE (vendor) | uma: ...'."""
145+
for line in err.splitlines():
146+
if not re.search(r"vulkan", line, re.I):
147+
continue
148+
m = re.search(r"\d+\s*=\s*([^\n|()]+)", line)
149+
if m:
150+
return m.group(1).strip()
151+
m = re.search(r"Device\s*\d*\s*[:=]\s*([^\n|(),]+)", line, re.I)
152+
if m:
153+
return m.group(1).strip()
154+
return None
155+
156+
157+
def open_handle(pid):
158+
"""Engine-process handle for the memory probe (Windows only; None otherwise)."""
159+
if sys.platform != "win32":
160+
return None
161+
for _ in range(10): # the child may still be spawning
162+
h = ctypes.windll.kernel32.OpenProcess(0x0410, False, pid) # QUERY_INFORMATION|VM_READ
163+
if h:
164+
return h
165+
time.sleep(0.01)
166+
return None
167+
168+
169+
def peak_working_set_gib(handle):
170+
"""Peak process working set (GiB) — the OS keeps the high-water mark while
171+
any handle to the process is open, so probe after communicate(), then close."""
172+
if not handle:
173+
return None
174+
try:
175+
class PMC(ctypes.Structure):
176+
_fields_ = [("cb", ctypes.c_uint),
177+
("PageFaultCount", ctypes.c_uint),
178+
("PeakWorkingSetSize", ctypes.c_size_t),
179+
("WorkingSetSize", ctypes.c_size_t),
180+
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
181+
("QuotaPagedPoolUsage", ctypes.c_size_t),
182+
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
183+
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
184+
("PagefileUsage", ctypes.c_size_t),
185+
("PeakPagefileUsage", ctypes.c_size_t)]
186+
pmc = PMC()
187+
pmc.cb = ctypes.sizeof(pmc)
188+
if not ctypes.WinDLL("psapi").GetProcessMemoryInfo(handle, ctypes.byref(pmc), pmc.cb):
189+
return None
190+
return pmc.PeakWorkingSetSize / (1024 ** 3)
191+
except OSError:
192+
return None
193+
194+
195+
def one(gguf, lora, n, tier, prompt=None):
57196
cmd = [os.path.join(SERVER_BIN, "llama-completion.exe"), "-m", gguf,
58197
"-ngl", "99", "-cmoe", "--temp", "0", "-n", str(n), "--seed", "7",
59-
"-no-cnv", "--no-display-prompt", "-p", PROMPT]
198+
"-no-cnv", "--no-display-prompt", "-p", prompt or PROMPT]
60199
if lora:
61200
cmd[3:3] = ["--lora", lora]
62201
t0 = time.time()
63-
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf8", errors="replace")
64-
err = r.stderr or ""
65-
out = {"wall_s": round(time.time() - t0, 1)}
202+
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
203+
text=True, encoding="utf8", errors="replace")
204+
h = open_handle(p.pid)
205+
_, err = p.communicate()
206+
peak = peak_working_set_gib(h) # probed after wait while the handle is live
207+
if h:
208+
ctypes.windll.kernel32.CloseHandle(h)
209+
out = {"wall_s": round(time.time() - t0, 1),
210+
"peak_working_set_gib": round(peak, 2) if peak is not None else None,
211+
"gpu": vulkan_device(err or "")}
66212
for k, pat in PAT.items():
67-
m = pat.search(err)
213+
m = pat.search(err or "")
68214
if m:
69215
out[k] = float(m.group(1))
70-
ev = re.search(r"n_ctx = (\d+)", err)
216+
ev = re.search(r"n_ctx = (\d+)", err or "")
71217
out["n_ctx"] = int(ev.group(1)) if ev else None
72-
if "error" in err.lower() and "eval time" not in err:
218+
if "error" in (err or "").lower() and "eval time" not in err:
73219
out["error"] = [l for l in err.splitlines() if "error" in l.lower()][:2]
74220
if "total_ms" in out and "prompt_ms" in out:
75221
dec_ms = out["total_ms"] - out["prompt_ms"] # total = generation end-to-end, excludes load (verified)
76222
out["decode_tps_derived"] = round(n * 1000 / dec_ms, 2) if dec_ms > 0 else None
77223
return out
78224

79225

226+
def submission_block(tier, reporter, notes, rows):
227+
"""Ready-to-paste Hardware Benchmarks block; numbers are the mean of the timed reps."""
228+
evs = [r["eval"] for r in rows if "eval" in r]
229+
prs = [r["prompt"] for r in rows if "prompt" in r]
230+
peaks = [r["peak_working_set_gib"] for r in rows if r.get("peak_working_set_gib") is not None]
231+
gpus = sorted({r["gpu"] for r in rows if r.get("gpu")})
232+
dev, chip = machine_name(), cpu_name()
233+
ram = total_ram_gb()
234+
data = {
235+
"tier": f"edge0-{tier}",
236+
"runtime": "windows",
237+
"device": dev or "(unknown)",
238+
"chip": chip or "(unknown)",
239+
"gpu": gpus[0] if gpus else "(not detected)",
240+
"ram_gb": round(ram) if ram else None,
241+
"os": windows_build(),
242+
"decode_tok_s": round(sum(evs) / len(evs), 2) if evs else None,
243+
"prefill_tok_s": round(sum(prs) / len(prs), 2) if prs else None,
244+
"peak_active_gib": round(max(peaks), 2) if peaks else None,
245+
"engine_commit": engine_commit(),
246+
"reporter": reporter or "",
247+
"notes": notes or "",
248+
}
249+
return data
250+
251+
252+
def print_block(d):
253+
for k, v in d.items():
254+
print(f"{k:<16} {'' if v is None else v}".rstrip())
255+
256+
80257
def main():
81258
ap = argparse.ArgumentParser()
82259
ap.add_argument("--tier", choices=["35b", "8b"], required=True)
83-
ap.add_argument("--reps", type=int, default=3)
260+
ap.add_argument("--reps", type=int, default=None,
261+
help="timed reps (default: 2 with --submission, 3 otherwise)")
262+
ap.add_argument("--submission", action="store_true",
263+
help="print the Hardware Benchmarks submission block (long-context prefill)")
264+
ap.add_argument("--json", action="store_true",
265+
help="with --submission, also print the block as JSON")
266+
ap.add_argument("--reporter", default="", help="@handle for the submission block")
267+
ap.add_argument("--notes", default="", help="free-form notes field")
84268
a = ap.parse_args()
269+
reps = a.reps or (2 if a.submission else 3)
85270
exe = os.path.join(SERVER_BIN, "llama-completion.exe")
86271
if not os.path.isfile(exe):
87272
raise SystemExit(f"llama-completion.exe not found in {SERVER_BIN} "
@@ -90,10 +275,30 @@ def main():
90275
gguf = os.path.join(md, f"edge0-{a.tier}-gguf", f"edge0-{a.tier}.gguf")
91276
lora = os.path.join(md, f"edge0-{a.tier}",
92277
f"lora_edge0_{a.tier}-gguf.gguf")
278+
if a.submission:
279+
warm = one(gguf, lora, 64, a.tier) # warmup (page cache + mmap fault-in), discarded
280+
print(f"warm(discard): {warm}", flush=True)
281+
longp = long_prompt()
282+
rows = []
283+
for i in range(reps):
284+
time.sleep(5)
285+
m = one(gguf, lora, 256, a.tier, prompt=longp)
286+
m["rep"] = i
287+
rows.append(m)
288+
print(f"rep{i}: {m}", flush=True)
289+
data = submission_block(a.tier, a.reporter, a.notes, rows)
290+
outp = os.path.join(ROOT, "benchmarks", "r3", f"bench_{a.tier}_submission.json")
291+
os.makedirs(os.path.dirname(outp), exist_ok=True)
292+
with open(outp, "w", encoding="utf8") as f:
293+
json.dump({**data, "timed_reps": rows}, f, ensure_ascii=False, indent=1)
294+
print_block(data)
295+
if a.json:
296+
print("SUBMIT_JSON " + json.dumps(data, ensure_ascii=False))
297+
return
93298
rows = []
94299
warm = one(gguf, lora, 64, a.tier) # warmup (page cache + mmap fault-in), discarded
95300
print(f"warm(discard): {warm}", flush=True)
96-
for i in range(a.reps):
301+
for i in range(reps):
97302
time.sleep(5)
98303
m = one(gguf, lora, 256, a.tier)
99304
m["rep"] = i

0 commit comments

Comments
 (0)