3v324v23 Claude commited on
Commit
b817c98
·
1 Parent(s): 166da81

Fall back to summed tensor element counts for params

Browse files

Many 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>

Files changed (3) hide show
  1. tests/test_vramcalc.py +83 -2
  2. vramcalc/__init__.py +2 -0
  3. vramcalc/gguf.py +90 -8
tests/test_vramcalc.py CHANGED
@@ -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
vramcalc/__init__.py CHANGED
@@ -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",
vramcalc/gguf.py CHANGED
@@ -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(meta: dict[str, object]) -> GGUFMetadata:
164
- """Project raw GGUF metadata into the architecture fields we need."""
 
 
 
 
 
 
 
 
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 << 20) -> GGUFMetadata:
192
  with open(path, "rb") as f:
193
  buf = f.read(max_bytes)
194
- return metadata_to_arch(parse_header_bytes(buf))
 
195
 
196
 
197
  def parse_hf_range(
198
  repo_id: str,
199
  filename: str,
200
  *,
201
- max_bytes: int = 1 << 20,
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
- return metadata_to_arch(parse_header_bytes(buf))
 
 
 
 
 
 
 
 
 
 
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)