File size: 8,793 Bytes
7ff3e96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
#!/usr/bin/env python3
"""Perplexity sweep over GGUF tiers using llama-perplexity, with optional wiki.test.raw reference corpus."""
import glob,os,re,shutil,subprocess,sys,threading,time,urllib.request,zipfile

if os.name=="nt":os.environ.setdefault("KMP_AFFINITY","disabled")
os.environ.setdefault("GGML_CUDA_ENABLE_UNIFIED_MEMORY","1")

SCRIPT_DIR=os.path.dirname(os.path.abspath(__file__))
LLAMA_CPP=os.path.join(SCRIPT_DIR,"llama-cpp")
WIKI_URL="https://huggingface.co/datasets/ggml-org/ci/resolve/main/wikitext-2-raw-v1.zip?download=true"
WIKI_PATH=os.path.join(SCRIPT_DIR,"wiki.test.raw")
FALLBACK_TXT=os.path.join(SCRIPT_DIR,"01_create-calibration-dataset-and-imatrix","experimental.txt")
CHUNKS=64
CTX=2048
THREADS=max(1,(os.cpu_count() or 4)-1)

def _detect_gpu():
	if sys.platform=="win32" or shutil.which("nvidia-smi"):
		try:
			out=subprocess.check_output(
				["nvidia-smi","--query-gpu=memory.total,memory.used","--format=csv,noheader,nounits"],
				stderr=subprocess.DEVNULL,text=True,timeout=5
			).strip().splitlines()[0]
			tot,usd=[int(x.strip()) for x in out.split(",")]
			return "cuda",tot,max(0,tot-usd)
		except Exception:pass
	try:
		out=subprocess.check_output(["rocm-smi","--showmeminfo","vram","--csv"],stderr=subprocess.DEVNULL,text=True,timeout=5)
		m=re.search(r"(\d+)\s*MiB",out)
		if m:
			tot=int(m.group(1))
			return "rocm",tot,tot
	except Exception:pass
	return None,0,0

GPU_BACKEND,GPU_TOTAL_VRAM,GPU_FREE_VRAM=_detect_gpu()

def _ngl_for_model(model_path,vram_free):
	if vram_free<=0:return 0
	model_mib=os.path.getsize(model_path)/1024/1024
	if model_mib+768<=vram_free:return 99
	headroom=max(0,vram_free-768)
	return max(1,min(99,int(99*(headroom/model_mib))))

def find_binary():
	names=("llama-perplexity.exe","llama-perplexity")
	if os.path.isdir(LLAMA_CPP):
		cand=[]
		for root,dirs,files in os.walk(LLAMA_CPP):
			dirs[:]=[d for d in dirs if d.lower() not in(".git","models","sources","vendor")]
			for n in names:
				if n in files:cand.append(os.path.join(root,n))
		if cand:
			def score(p):
				s=p.lower()
				return(0 if"release"in s else 1 if"debug"not in s else 2,len(s))
			cand.sort(key=score)
			return cand[0]
	for n in names:
		p=shutil.which(n)
		if p:return p
	return None

def fetch_wiki():
	if os.path.isfile(WIKI_PATH) and os.path.getsize(WIKI_PATH)>1_000_000:
		print(f"[Corpus] Using existing {WIKI_PATH}")
		return WIKI_PATH
	print("[Corpus] Downloading wiki.test.raw …")
	tmp=WIKI_PATH+".zip"
	try:
		req=urllib.request.Request(WIKI_URL,headers={"User-Agent":"perplexity-test/1.0"})
		with urllib.request.urlopen(req,timeout=300) as r,open(tmp,"wb") as f:
			shutil.copyfileobj(r,f)
		with zipfile.ZipFile(tmp) as z:
			member=next((n for n in z.namelist() if n.endswith("wiki.test.raw")),None)
			if member is None:raise ValueError("wiki.test.raw absent from ZIP")
			with z.open(member) as src,open(WIKI_PATH,"wb") as out:
				shutil.copyfileobj(src,out)
		print(f"[Corpus] Saved {WIKI_PATH} ({os.path.getsize(WIKI_PATH)/1024:.0f} KiB)")
		return WIKI_PATH
	except Exception as e:
		for junk in(tmp,WIKI_PATH):
			try:os.remove(junk)
			except OSError:pass
		print(f"[Corpus] ✗ Download failed ({e})")
		if os.path.isfile(FALLBACK_TXT):
			print(f"[Corpus] Falling back to {FALLBACK_TXT} — Δ between tiers stays valid, absolute PPL differs from published wiki benchmarks.")
			return FALLBACK_TXT
		return None

def pick_models():
	seen=set();out=[]
	for p in sorted(glob.glob(os.path.join(SCRIPT_DIR,"*.gguf"))):
		if p in seen:continue
		seen.add(p)
		bn=os.path.basename(p).lower()
		if any(bn.startswith(x) for x in("mmproj","imatrix")) or any(x in bn for x in("-bench","_bench",".tmp",".bak")):continue
		out.append(p)
	return out

def menu(models):
	print("\nAvailable models:")
	for i,p in enumerate(models,1):
		mib=os.path.getsize(p)/1024/1024
		ngl=_ngl_for_model(p,GPU_FREE_VRAM)
		tag=f"ngl={ngl}" if ngl>0 else "CPU"
		print(f"  {i:2d}. {os.path.basename(p):<48} ({mib:5.0f} MiB · {tag})")
	print("   a. ALL  ·  q. quit")
	while True:
		choice=input("Select model(s) [1/a/1,3,5/q]: ").strip().lower()
		if choice in("q","quit",""):return None
		if choice=="a":return models
		try:
			idx=[int(x) for x in re.split(r"[,\s]+",choice) if x]
			if idx and all(1<=i<=len(models) for i in idx):
				return [models[i-1] for i in dict.fromkeys(idx)]
		except ValueError:pass
		print("  Invalid selection.")

def parse_output(text):
	ppl=tps=None
	m=re.search(r"Final estimate:\s*PPL\s*=\s*([\d.]+)",text,re.I)
	if not m:m=re.search(r"final estimate of PPL:\s*([\d.]+)",text,re.I)
	if not m:m=re.search(r"^\s*ppl\s*=\s*([\d.]+)",text,re.M)
	if m:
		ppl=float(m.group(1))
	else:
		chunks=re.findall(r"\[\d+\]([\d.]+)",text)
		if chunks:ppl=float(chunks[-1])
	t=re.search(r"([\d.]+)\s*tokens per second",text,re.I)
	if t:tps=float(t.group(1))
	else:
		p=re.search(r"([\d.]+)\s*seconds per pass",text,re.I)
		if p:tps=CTX/max(float(p.group(1)),0.001)
	return ppl,tps

def _get_flags(binary):
	try:help_txt=subprocess.run([binary,"-h"],capture_output=True,text=True,timeout=5).stdout
	except Exception:help_txt=""
	extra=[]
	if "-fa" in help_txt or "--flash-attn" in help_txt:extra+=["-fa","on"]
	return extra

def _run_single(binary,model,corpus,ngl,flags,label):
	cmd=[binary,"-m",model,"-f",corpus,"-c",str(CTX),"-b","512","-ub","512","-t",str(THREADS),"--chunks",str(CHUNKS),"--no-warmup"]
	if ngl>0:cmd+=["-ngl",str(ngl)]
	cmd+=flags
	print(f"\n── {label} ──")
	print(f"  ngl={ngl} · ctx={CTX} · batch=512 · chunks={CHUNKS} · threads={THREADS}"+(" · Flash-Attention" if "-fa" in flags else ""))
	t0=time.perf_counter()
	try:
		proc=subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
	except FileNotFoundError:
		print("  ✗ binary not found");return None
	out_lines=[];err_lines=[]
	current_chunk=0
	last_ppl=""
	lock=threading.Lock()
	def on_text(line):
		nonlocal current_chunk,last_ppl
		m=re.search(r"\[(\d+)\]([\d.]+)?",line)
		if m:
			current_chunk=int(m.group(1))
			if m.group(2):last_ppl=f" PPL={m.group(2)}"
		with lock:
			if current_chunk>0:
				el=time.perf_counter()-t0
				speed=current_chunk/el if el>0 else 0
				rem=(CHUNKS-current_chunk)/speed if speed>0 else 0
				sys.stdout.write(f"\r  [{current_chunk:2d}/{CHUNKS}] {current_chunk/CHUNKS*100:4.1f}%{last_ppl} · {el:.0f}s elapsed · ETA {rem:.0f}s   ")
				sys.stdout.flush()
	def drain(stream,buf):
		for line in stream:
			buf.append(line)
			on_text(line)
	th_out=threading.Thread(target=drain,args=(proc.stdout,out_lines),daemon=True)
	th_err=threading.Thread(target=drain,args=(proc.stderr,err_lines),daemon=True)
	th_out.start();th_err.start()
	proc.wait()
	th_out.join(timeout=3);th_err.join(timeout=3)
	sys.stdout.write("\r"+" "*90+"\r")
	elapsed=time.perf_counter()-t0
	out="".join(out_lines)+"\n"+"".join(err_lines)
	ppl,tps=parse_output(out)
	if proc.returncode!=0 or ppl is None:
		return None
	tps=tps or (CHUNKS*CTX/elapsed if elapsed>0 else 0)
	print(f"  PPL={ppl:.4f} · {tps:.1f} tok/s · {elapsed:.1f}s")
	return ppl,tps

def run_ppl(binary,model,corpus,flags,label):
	ngl=_ngl_for_model(model,GPU_FREE_VRAM)
	res=_run_single(binary,model,corpus,ngl,flags,label)
	if res is not None:return res
	if ngl>0:
		print("  ⚠ Run failed on GPU — retrying CPU-only …")
		res=_run_single(binary,model,corpus,0,[],label)
		if res is not None:return res
	print("  ✗ Run failed.")
	return None

def main():
	binary=find_binary()
	if not binary:
		print("Error: llama-perplexity binary not found under llama-cpp/.");sys.exit(1)
	print(f"Binary : {binary}")
	if GPU_BACKEND:
		print(f"GPU    : {GPU_BACKEND} · {GPU_FREE_VRAM}/{GPU_TOTAL_VRAM} MiB VRAM available")
	else:
		print("GPU    : none detected — CPU only")
	corpus=fetch_wiki()
	if not corpus:
		print("Error: no corpus available.");sys.exit(1)
	models=pick_models()
	if not models:
		print("Error: no model-*.gguf found next to this script.");sys.exit(1)
	sel=menu(models)
	if not sel:return
	flags=_get_flags(binary)
	results=[]
	for m in sel:
		res=run_ppl(binary,m,corpus,flags,os.path.basename(m))
		if res:results.append((os.path.basename(m),res[0],res[1]))
	if len(results)<2:
		if results:print(f"\n  Final result: {results[0][0]} → PPL = {results[0][1]:.4f}")
		return
	base=results[0][1]
	print("\n"+"="*64)
	print("  PERPLEXITY SUMMARY (lower is better)")
	print("="*64)
	print(f" {'Model':<46}{'PPL':>9}{'+Δ':>9}{'Speed':>11}")
	print(f" {'-'*46}{'-'*9}{'-'*9}{'-'*11}")
	for name,ppl,tps in results:
		print(f" {name:<46}{ppl:>9.4f}{ppl-base:>+9.4f}{tps:>8.1f} t/s")
	print(f"\n  Δ relative to {results[0][0]} · corpus: {os.path.basename(corpus)}")
	if corpus==WIKI_PATH:
		print("  wiki.test.raw corpus: absolute PPL comparable to llama.cpp published benchmarks.")

if __name__=="__main__":
	main()