Spaces:
Running on Zero
Fall back to summed tensor element counts for params
Browse filesMany GGUFs omit general.parameter_count, leaving params at 0 and
blocking the compute step with "incomplete architecture". Sum the
product of tensor dims from the GGUF tensor-info section and use it
as a params fallback when the explicit field is missing.
- parse_header_with_tensors: parse metadata + walk tensor infos,
returning (meta, total_elems) or -1 if the tensor section is
unreachable in the buffer.
- metadata_to_arch takes total_tensor_elems and falls back to it.
- Bump the range read from 1 MiB to 32 MiB (tokenizer-heavy GGUFs push
the tensor section past 10 MiB); parse_hf_range retries once at
64 MiB if the first read can't reach the tensors.
Verified against DavidAU/Qwen3.6-27B-... (26.90B) and
jcbtc/chadrock3.6-27B-... (27.32B) — both now auto-populate params.
Three new tests.
Co-Authored-By: Claude <noreply@anthropic.com>
- tests/test_vramcalc.py +83 -2
- vramcalc/__init__.py +2 -0
- vramcalc/gguf.py +90 -8
|
@@ -16,7 +16,7 @@ from vramcalc import (
|
|
| 16 |
format_bytes,
|
| 17 |
)
|
| 18 |
from vramcalc.presets import PRESETS
|
| 19 |
-
from vramcalc.gguf import parse_header_bytes, metadata_to_arch
|
| 20 |
|
| 21 |
|
| 22 |
def test_quant_table_has_common_types():
|
|
@@ -320,4 +320,85 @@ def test_gguf_parse_header_includes_general_name():
|
|
| 320 |
meta = parse_header_bytes(buf)
|
| 321 |
arch = metadata_to_arch(meta)
|
| 322 |
assert arch.name == "Test Model"
|
| 323 |
-
assert arch.architecture == "qwen35"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
format_bytes,
|
| 17 |
)
|
| 18 |
from vramcalc.presets import PRESETS
|
| 19 |
+
from vramcalc.gguf import parse_header_bytes, parse_header_with_tensors, metadata_to_arch
|
| 20 |
|
| 21 |
|
| 22 |
def test_quant_table_has_common_types():
|
|
|
|
| 320 |
meta = parse_header_bytes(buf)
|
| 321 |
arch = metadata_to_arch(meta)
|
| 322 |
assert arch.name == "Test Model"
|
| 323 |
+
assert arch.architecture == "qwen35"
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def test_gguf_params_fallback_from_tensor_elems():
|
| 327 |
+
# No general.parameter_count; metadata_to_arch should fall back to summed
|
| 328 |
+
# tensor element counts.
|
| 329 |
+
import struct
|
| 330 |
+
|
| 331 |
+
def s(x): return x.encode("utf-8")
|
| 332 |
+
buf = b""
|
| 333 |
+
buf += struct.pack("<I", 0x46554747) # GGUF
|
| 334 |
+
buf += struct.pack("<I", 3) # version
|
| 335 |
+
buf += struct.pack("<Q", 2) # tensor_count = 2
|
| 336 |
+
buf += struct.pack("<Q", 1) # kv_count = 1
|
| 337 |
+
# kv: "general.architecture" = "llama"
|
| 338 |
+
k = s("general.architecture")
|
| 339 |
+
buf += struct.pack("<Q", len(k)) + k
|
| 340 |
+
buf += struct.pack("<I", 8)
|
| 341 |
+
v = s("llama")
|
| 342 |
+
buf += struct.pack("<Q", len(v)) + v
|
| 343 |
+
# tensor 1: name "t1", 2 dims (4, 8) -> 32 elems
|
| 344 |
+
n1 = s("t1")
|
| 345 |
+
buf += struct.pack("<Q", len(n1)) + n1
|
| 346 |
+
buf += struct.pack("<I", 2) # n_dims
|
| 347 |
+
buf += struct.pack("<Q", 4)
|
| 348 |
+
buf += struct.pack("<Q", 8)
|
| 349 |
+
buf += struct.pack("<I", 0) # dtype F32
|
| 350 |
+
buf += struct.pack("<Q", 0) # offset
|
| 351 |
+
# tensor 2: name "t2", 1 dim (68) -> 68 elems
|
| 352 |
+
n2 = s("t2")
|
| 353 |
+
buf += struct.pack("<Q", len(n2)) + n2
|
| 354 |
+
buf += struct.pack("<I", 1)
|
| 355 |
+
buf += struct.pack("<Q", 68)
|
| 356 |
+
buf += struct.pack("<I", 0)
|
| 357 |
+
buf += struct.pack("<Q", 32)
|
| 358 |
+
meta, total = parse_header_with_tensors(buf)
|
| 359 |
+
assert total == 32 + 68
|
| 360 |
+
arch = metadata_to_arch(meta, total)
|
| 361 |
+
# no general.parameter_count -> fallback kicks in
|
| 362 |
+
assert arch.params == 100
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def test_gguf_params_prefers_explicit_parameter_count():
|
| 366 |
+
# When general.parameter_count is present, it wins over the tensor sum.
|
| 367 |
+
import struct
|
| 368 |
+
|
| 369 |
+
def s(x): return x.encode("utf-8")
|
| 370 |
+
buf = b""
|
| 371 |
+
buf += struct.pack("<I", 0x46554747) # GGUF
|
| 372 |
+
buf += struct.pack("<I", 3) # version
|
| 373 |
+
buf += struct.pack("<Q", 0) # tensor_count
|
| 374 |
+
buf += struct.pack("<Q", 1) # kv_count
|
| 375 |
+
k = s("general.parameter_count")
|
| 376 |
+
buf += struct.pack("<Q", len(k)) + k
|
| 377 |
+
buf += struct.pack("<I", 10) # UINT64
|
| 378 |
+
buf += struct.pack("<Q", 7_000_000_000)
|
| 379 |
+
meta, total = parse_header_with_tensors(buf)
|
| 380 |
+
assert meta["general.parameter_count"] == 7_000_000_000
|
| 381 |
+
arch = metadata_to_arch(meta, total_tensor_elems=123) # fallback ignored
|
| 382 |
+
assert arch.params == 7_000_000_000
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def test_gguf_tensor_section_unreachable_returns_minus_one():
|
| 386 |
+
# Buffer too small to reach the tensor section -> total == -1, no fallback.
|
| 387 |
+
import struct
|
| 388 |
+
|
| 389 |
+
def s(x): return x.encode("utf-8")
|
| 390 |
+
buf = b""
|
| 391 |
+
buf += struct.pack("<I", 0x46554747)
|
| 392 |
+
buf += struct.pack("<I", 3)
|
| 393 |
+
buf += struct.pack("<Q", 3) # tensor_count > 0
|
| 394 |
+
buf += struct.pack("<Q", 1) # kv_count
|
| 395 |
+
k = s("general.architecture")
|
| 396 |
+
buf += struct.pack("<Q", len(k)) + k
|
| 397 |
+
buf += struct.pack("<I", 8)
|
| 398 |
+
v = s("llama")
|
| 399 |
+
buf += struct.pack("<Q", len(v)) + v
|
| 400 |
+
# truncate so the tensor section is unreachable (don't append tensors)
|
| 401 |
+
meta, total = parse_header_with_tensors(buf)
|
| 402 |
+
assert total == -1
|
| 403 |
+
arch = metadata_to_arch(meta, total)
|
| 404 |
+
assert arch.params == 0 # negative fallback is treated as no fallback
|
|
@@ -10,6 +10,7 @@ from .gpu import GpuBudget, gpu_split, fit_gpus, GpuSplitResult
|
|
| 10 |
from .gguf import (
|
| 11 |
GGUFMetadata,
|
| 12 |
parse_header_bytes,
|
|
|
|
| 13 |
parse_local_file,
|
| 14 |
parse_hf_range,
|
| 15 |
metadata_to_arch,
|
|
@@ -38,6 +39,7 @@ __all__ = [
|
|
| 38 |
"fit_gpus",
|
| 39 |
"GGUFMetadata",
|
| 40 |
"parse_header_bytes",
|
|
|
|
| 41 |
"parse_local_file",
|
| 42 |
"parse_hf_range",
|
| 43 |
"metadata_to_arch",
|
|
|
|
| 10 |
from .gguf import (
|
| 11 |
GGUFMetadata,
|
| 12 |
parse_header_bytes,
|
| 13 |
+
parse_header_with_tensors,
|
| 14 |
parse_local_file,
|
| 15 |
parse_hf_range,
|
| 16 |
metadata_to_arch,
|
|
|
|
| 39 |
"fit_gpus",
|
| 40 |
"GGUFMetadata",
|
| 41 |
"parse_header_bytes",
|
| 42 |
+
"parse_header_with_tensors",
|
| 43 |
"parse_local_file",
|
| 44 |
"parse_hf_range",
|
| 45 |
"metadata_to_arch",
|
|
@@ -146,6 +146,64 @@ def parse_header_bytes(buf: bytes) -> dict[str, object]:
|
|
| 146 |
return meta
|
| 147 |
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
def _coerce_int(v: object) -> int:
|
| 150 |
if isinstance(v, bool):
|
| 151 |
return int(v)
|
|
@@ -160,8 +218,16 @@ def _coerce_float(v: object) -> float:
|
|
| 160 |
return 0.0
|
| 161 |
|
| 162 |
|
| 163 |
-
def metadata_to_arch(
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
arch_name = str(meta.get("general.architecture", "") or "")
|
| 166 |
p = f"{arch_name}." if arch_name else ""
|
| 167 |
|
|
@@ -183,22 +249,26 @@ def metadata_to_arch(meta: dict[str, object]) -> GGUFMetadata:
|
|
| 183 |
m.n_expert_used = _coerce_int(g("expert_used_count"))
|
| 184 |
# MTP: not always in GGUF metadata; leave 0 unless present
|
| 185 |
m.n_mtp = _coerce_int(g("mtp.count") or g("n_mtp"))
|
| 186 |
-
# parameter count
|
|
|
|
| 187 |
m.params = _coerce_int(meta.get("general.parameter_count", 0))
|
|
|
|
|
|
|
| 188 |
return m
|
| 189 |
|
| 190 |
|
| 191 |
-
def parse_local_file(path: str, max_bytes: int = 1 <<
|
| 192 |
with open(path, "rb") as f:
|
| 193 |
buf = f.read(max_bytes)
|
| 194 |
-
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
def parse_hf_range(
|
| 198 |
repo_id: str,
|
| 199 |
filename: str,
|
| 200 |
*,
|
| 201 |
-
max_bytes: int = 1 <<
|
| 202 |
token: str | None = None,
|
| 203 |
revision: str = "main",
|
| 204 |
timeout: float = 30.0,
|
|
@@ -206,7 +276,10 @@ def parse_hf_range(
|
|
| 206 |
"""Range-read the first `max_bytes` of a GGUF file from the HF Hub.
|
| 207 |
|
| 208 |
Uses a HTTP Range request against the resolve URL so we never download the
|
| 209 |
-
full model. Requires the `requests` package.
|
|
|
|
|
|
|
|
|
|
| 210 |
"""
|
| 211 |
import requests
|
| 212 |
|
|
@@ -217,4 +290,13 @@ def parse_hf_range(
|
|
| 217 |
resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
| 218 |
resp.raise_for_status()
|
| 219 |
buf = resp.content
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
return meta
|
| 147 |
|
| 148 |
|
| 149 |
+
def parse_header_with_tensors(buf: bytes):
|
| 150 |
+
"""Parse metadata KV and sum tensor element counts from the header.
|
| 151 |
+
|
| 152 |
+
Returns (meta_dict, total_tensor_elems). The tensor-info section follows
|
| 153 |
+
the metadata: each tensor is name(str) + n_dims(u32) + dims[n](u64) +
|
| 154 |
+
dtype(u32) + offset(u64). We sum product(dims) across all tensors. The
|
| 155 |
+
buffer must cover the metadata *and* the full tensor-info section for the
|
| 156 |
+
sum to be correct — tokenizer-heavy GGUFs push the tensor section past
|
| 157 |
+
10 MiB, so callers should read >= ~32 MiB. If the read is too small to
|
| 158 |
+
reach the tensor section we return -1 (unreachable) so callers can tell
|
| 159 |
+
"incomplete" from "zero tensors".
|
| 160 |
+
|
| 161 |
+
Used as a fallback for `params` when general.parameter_count is absent.
|
| 162 |
+
"""
|
| 163 |
+
r = _Reader(buf)
|
| 164 |
+
magic = r.u32()
|
| 165 |
+
if magic != GGUF_MAGIC:
|
| 166 |
+
raise ValueError(f"not a GGUF file (magic={magic:#x})")
|
| 167 |
+
version = r.u32()
|
| 168 |
+
if version < 1 or version > 3:
|
| 169 |
+
raise ValueError(f"unsupported GGUF version {version}")
|
| 170 |
+
tensor_count = r.u64()
|
| 171 |
+
kv_count = r.u64()
|
| 172 |
+
meta: dict[str, object] = {}
|
| 173 |
+
for _ in range(kv_count):
|
| 174 |
+
if r.eof():
|
| 175 |
+
return meta, 0
|
| 176 |
+
key = r.string()
|
| 177 |
+
t = r.u32()
|
| 178 |
+
try:
|
| 179 |
+
meta[key] = r.value(t)
|
| 180 |
+
except (EOFError, ValueError):
|
| 181 |
+
# ran out of buffer in metadata; tensor section unreachable
|
| 182 |
+
return meta, 0
|
| 183 |
+
total = 0
|
| 184 |
+
parsed = 0
|
| 185 |
+
for _ in range(tensor_count):
|
| 186 |
+
if r.eof():
|
| 187 |
+
break
|
| 188 |
+
try:
|
| 189 |
+
r.string() # tensor name
|
| 190 |
+
n_dims = r.u32()
|
| 191 |
+
elems = 1
|
| 192 |
+
for _ in range(n_dims):
|
| 193 |
+
elems *= r.u64() # dims multiply, not add
|
| 194 |
+
total += elems
|
| 195 |
+
r.u32() # dtype
|
| 196 |
+
r.u64() # data offset
|
| 197 |
+
parsed += 1
|
| 198 |
+
except (EOFError, ValueError):
|
| 199 |
+
break
|
| 200 |
+
# If we couldn't reach any tensor infos, signal failure with -1 so callers
|
| 201 |
+
# can distinguish "unreachable" from "0 tensors".
|
| 202 |
+
if parsed == 0 and tensor_count > 0:
|
| 203 |
+
return meta, -1
|
| 204 |
+
return meta, total
|
| 205 |
+
|
| 206 |
+
|
| 207 |
def _coerce_int(v: object) -> int:
|
| 208 |
if isinstance(v, bool):
|
| 209 |
return int(v)
|
|
|
|
| 218 |
return 0.0
|
| 219 |
|
| 220 |
|
| 221 |
+
def metadata_to_arch(
|
| 222 |
+
meta: dict[str, object], total_tensor_elems: int | None = None
|
| 223 |
+
) -> GGUFMetadata:
|
| 224 |
+
"""Project raw GGUF metadata into the architecture fields we need.
|
| 225 |
+
|
| 226 |
+
`total_tensor_elems` (from parse_header_with_tensors) is used as a fallback
|
| 227 |
+
for `params` when general.parameter_count is absent. A negative value means
|
| 228 |
+
the tensor section was unreachable (range read too small); treat as no
|
| 229 |
+
fallback.
|
| 230 |
+
"""
|
| 231 |
arch_name = str(meta.get("general.architecture", "") or "")
|
| 232 |
p = f"{arch_name}." if arch_name else ""
|
| 233 |
|
|
|
|
| 249 |
m.n_expert_used = _coerce_int(g("expert_used_count"))
|
| 250 |
# MTP: not always in GGUF metadata; leave 0 unless present
|
| 251 |
m.n_mtp = _coerce_int(g("mtp.count") or g("n_mtp"))
|
| 252 |
+
# parameter count: prefer general.parameter_count; fall back to summed
|
| 253 |
+
# tensor element counts when the GGUF omits it (many GGUFs do).
|
| 254 |
m.params = _coerce_int(meta.get("general.parameter_count", 0))
|
| 255 |
+
if not m.params and total_tensor_elems and total_tensor_elems > 0:
|
| 256 |
+
m.params = int(total_tensor_elems)
|
| 257 |
return m
|
| 258 |
|
| 259 |
|
| 260 |
+
def parse_local_file(path: str, max_bytes: int = 1 << 25) -> GGUFMetadata:
|
| 261 |
with open(path, "rb") as f:
|
| 262 |
buf = f.read(max_bytes)
|
| 263 |
+
meta, total = parse_header_with_tensors(buf)
|
| 264 |
+
return metadata_to_arch(meta, total)
|
| 265 |
|
| 266 |
|
| 267 |
def parse_hf_range(
|
| 268 |
repo_id: str,
|
| 269 |
filename: str,
|
| 270 |
*,
|
| 271 |
+
max_bytes: int = 1 << 25,
|
| 272 |
token: str | None = None,
|
| 273 |
revision: str = "main",
|
| 274 |
timeout: float = 30.0,
|
|
|
|
| 276 |
"""Range-read the first `max_bytes` of a GGUF file from the HF Hub.
|
| 277 |
|
| 278 |
Uses a HTTP Range request against the resolve URL so we never download the
|
| 279 |
+
full model. Requires the `requests` package. Reads up to 32 MiB of the
|
| 280 |
+
header by default (enough to cover tokenizer-heavy metadata plus the full
|
| 281 |
+
tensor-info section, so we can sum tensor element counts as a params
|
| 282 |
+
fallback when general.parameter_count is absent).
|
| 283 |
"""
|
| 284 |
import requests
|
| 285 |
|
|
|
|
| 290 |
resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
| 291 |
resp.raise_for_status()
|
| 292 |
buf = resp.content
|
| 293 |
+
meta, total = parse_header_with_tensors(buf)
|
| 294 |
+
# If the read was too small to reach the tensor section, retry once with a
|
| 295 |
+
# larger read so the params fallback can work for tokenizer-heavy GGUFs.
|
| 296 |
+
if not meta.get("general.parameter_count") and total == -1:
|
| 297 |
+
big = 1 << 26 # 64 MiB
|
| 298 |
+
headers["Range"] = f"bytes=0-{big - 1}"
|
| 299 |
+
resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
| 300 |
+
resp.raise_for_status()
|
| 301 |
+
meta, total = parse_header_with_tensors(resp.content)
|
| 302 |
+
return metadata_to_arch(meta, total)
|