#!/usr/bin/env python3 """ split_gguf.py — Layer-aware GGUF splitter / byte-split / join / status Commands: split_layers — Split by transformer layers (primary, recommended) Each output is a valid GGUF compatible with llama.cpp split loading. llama.cpp / llama-cpp-python loads the first file — auto-discovers the rest. split — Split by fixed byte size (legacy .part files) join — Rejoin legacy .part files into one GGUF status — Show all chunk / GGUF state Usage: python split_gguf.py split_layers # layer split (default 100MB target) python split_gguf.py split_layers --mb 50 # smaller groups python split_gguf.py split # legacy byte split python split_gguf.py join # rejoin legacy .part files python split_gguf.py status # show all state """ import os, sys, json, argparse, struct # ── GGUF constants ───────────────────────────────────────────────────────────── GGUF_MAGIC = b'GGUF' GGUF_ALIGN = 32 # KV value types U8=0; I8=1; U16=2; I16=3; U32=4; I32=5; F32=6; BOOL=7; STRING=8; ARRAY=9 U64=10; I64=11; F64=12 TYPE_SIZES = {U8:1,I8:1,U16:2,I16:2,U32:4,I32:4,F32:4,BOOL:1,U64:8,I64:8,F64:8} def _align(n, a=GGUF_ALIGN): return ((n + a - 1) // a) * a def _load_cfg(): p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "install.json") try: with open(p) as f: c = json.load(f) g = c.get("gguf", {}) return { "dir": g.get("dir", "./model_gguf"), "default": g.get("default", "Qwen3-1.7B-Q4_K_M.gguf"), "quality": g.get("quality", "Qwen3-1.7B-Q8_0.gguf"), "chunk_mb": int(c.get("chunks", {}).get("chunk_mb", 100)), } except Exception: return {"dir":"./model_gguf","default":"Qwen3-1.7B-Q4_K_M.gguf", "quality":"Qwen3-1.7B-Q8_0.gguf","chunk_mb":100} # ══════════════════════════════════════════════════════════════════════════════ # GGUF Parser # ══════════════════════════════════════════════════════════════════════════════ def _read_str(f): n = int.from_bytes(f.read(8), 'little') return f.read(n).decode('utf-8', errors='replace') def _skip_value(f, vtype): if vtype == STRING: f.read(int.from_bytes(f.read(8), 'little')) elif vtype == ARRAY: atype = int.from_bytes(f.read(4), 'little') alen = int.from_bytes(f.read(8), 'little') if atype == STRING: for _ in range(alen): f.read(int.from_bytes(f.read(8), 'little')) else: f.read(alen * TYPE_SIZES.get(atype, 4)) else: f.read(TYPE_SIZES.get(vtype, 4)) def _read_kv_entries(path, n_kv, kv_start): """ Read KV entries one-by-one from a GGUF file. Returns list of (key_str, raw_bytes) tuples. raw_bytes = complete binary representation of that entry. """ entries = [] with open(path, 'rb') as f: f.seek(kv_start) for _ in range(n_kv): entry_start = f.tell() key_len = int.from_bytes(f.read(8), 'little') key = f.read(key_len).decode('utf-8', errors='replace') vtype = int.from_bytes(f.read(4), 'little') _skip_value(f, vtype) entry_end = f.tell() f.seek(entry_start) raw = f.read(entry_end - entry_start) entries.append((key, raw)) return entries def _parse_gguf(path): """ Parse a GGUF file. Returns dict: version, n_tensors, n_kv, kv_raw (bytes), tensors (list), data_start. Each tensor: {name, shape, dtype, offset, size} offset = byte offset from data_start (in source file). size = byte size of tensor data. """ file_size = os.path.getsize(path) with open(path, 'rb') as f: magic = f.read(4) if magic != GGUF_MAGIC: raise ValueError(f"Not a GGUF file (magic={magic!r})") version = int.from_bytes(f.read(4), 'little') n_tensors = int.from_bytes(f.read(8), 'little') n_kv = int.from_bytes(f.read(8), 'little') # Capture raw KV bytes (copied verbatim to each output split) kv_start = f.tell() for _ in range(n_kv): f.read(int.from_bytes(f.read(8), 'little')) # key _skip_value(f, int.from_bytes(f.read(4), 'little')) kv_end = f.tell() f.seek(kv_start) kv_raw = f.read(kv_end - kv_start) # Tensor infos — preserve original declaration order tensors = [] for _ in range(n_tensors): name = _read_str(f) n_dims = int.from_bytes(f.read(4), 'little') shape = [int.from_bytes(f.read(8), 'little') for _ in range(n_dims)] dtype = int.from_bytes(f.read(4), 'little') offset = int.from_bytes(f.read(8), 'little') tensors.append({'name': name, 'shape': shape, 'dtype': dtype, 'offset': offset}) # Data section starts at next 32-byte boundary after tensor infos data_start = _align(f.tell()) # Compute tensor data sizes from consecutive offsets (sort by offset) by_offset = sorted(tensors, key=lambda t: t['offset']) total_data = file_size - data_start for i, t in enumerate(by_offset): nxt = by_offset[i+1]['offset'] if i+1 < len(by_offset) else total_data t['size'] = nxt - t['offset'] return { '_path': path, 'version': version, 'n_tensors': n_tensors, 'n_kv': n_kv, 'kv_raw': kv_raw, 'tensors': tensors, 'data_start': data_start, 'file_size': file_size, } # ══════════════════════════════════════════════════════════════════════════════ # GGUF Writer (one split) # ══════════════════════════════════════════════════════════════════════════════ def _kv_entry(key, vtype, value): """Serialize one KV pair.""" k = key.encode('utf-8') b = len(k).to_bytes(8, 'little') + k + vtype.to_bytes(4, 'little') if vtype == U16: b += struct.pack(' 0: out.write(b'\x00' * pad) # Copy tensor data from source src.seek(data_start + t['offset']) remaining = t['size'] buf_size = 4 * 1024 * 1024 while remaining > 0: buf = src.read(min(buf_size, remaining)) if not buf: break out.write(buf) remaining -= len(buf) write_pos = t['new_offset'] + t['size'] # ══════════════════════════════════════════════════════════════════════════════ # Layer grouping # ══════════════════════════════════════════════════════════════════════════════ def _group_by_layers(tensors, target_mb): """ Classify tensors by transformer layer, then merge adjacent layers until each group reaches target_mb. Layer classes: embed — token_embd.*, token_embd_norm.* blk.N — blk.N.* (transformer blocks 0..N) output — output.*, output_norm.* Returns: list of (label_str, [tensor, ...]) """ # ── Classify ── buckets = {} # (category, sort_key) → [tensors] for t in tensors: name = t['name'] if name.startswith('blk.'): n = int(name.split('.')[1]) key = ('blk', n) elif name.startswith('token_embd'): key = ('embed', -1) else: key = ('output', 999999) buckets.setdefault(key, []).append(t) # Sort: embed first, then blk.0…blk.N in order, output last sorted_buckets = sorted(buckets.items(), key=lambda x: (0 if x[0][0]=='embed' else 1 if x[0][0]=='blk' else 2, x[0][1])) # ── Compute MB per bucket ── bucket_info = [] for (cat, n), tlist in sorted_buckets: mb = sum(t['size'] for t in tlist) / (1024**2) if cat == 'embed': label = 'embed' elif cat == 'blk': label = f'blk{n:02d}' else: label = 'output' bucket_info.append((label, tlist, mb)) # ── Merge buckets greedily to reach target_mb per group ── target = max(float(target_mb), 1.0) groups = [] cur_t, cur_mb, cur_label = [], 0.0, '' for label, tlist, mb in bucket_info: # Start new group if adding this bucket would exceed target (and we have something) if cur_t and cur_mb + mb > target: groups.append((cur_label, cur_t)) cur_t, cur_mb, cur_label = [], 0.0, '' cur_t.extend(tlist) cur_mb += mb cur_label = f"{cur_label}+{label}" if cur_label else label if cur_t: groups.append((cur_label, cur_t)) return groups # ══════════════════════════════════════════════════════════════════════════════ # Public split_layers # ══════════════════════════════════════════════════════════════════════════════ def split_gguf_layers(model_path, target_mb=100, out_dir=None): """ Layer-aware GGUF split. Each output file: - Is a valid standalone GGUF (can be inspected with gguf-dump etc.) - Contains split.no / split.count / split.tensors_count metadata - Is named -00001-of-NNNNN.gguf llama.cpp / llama-cpp-python auto-loads all splits when given the first file: Llama('./model_gguf/chunks/Model-00001-of-00010.gguf') Returns list of created output file paths. """ if not os.path.isfile(model_path): print(f"[split_layers] ERROR: File not found: {model_path}") return [] base = os.path.splitext(os.path.basename(model_path))[0] gguf_dir = os.path.dirname(model_path) if out_dir is None: out_dir = os.path.join(gguf_dir, "chunks") os.makedirs(out_dir, exist_ok=True) file_mb = os.path.getsize(model_path) / (1024**2) print(f"[split_layers] Parsing {os.path.basename(model_path)} ({file_mb:.0f}MB) ...") gguf = _parse_gguf(model_path) groups = _group_by_layers(gguf['tensors'], target_mb) n = len(groups) print(f"[split_layers] {gguf['n_tensors']} tensors → " f"{n} groups (target {target_mb}MB/group)") print(f"[split_layers] Output: {out_dir}/") print() parts = [] for i, (label, tensors_grp) in enumerate(groups): out_name = f"{base}-{i+1:05d}-of-{n:05d}.gguf" out_path = os.path.join(out_dir, out_name) grp_mb = sum(t['size'] for t in tensors_grp) / (1024**2) print(f" [{i+1:2d}/{n}] {out_name} " f"({len(tensors_grp):3d} tensors, {grp_mb:5.1f}MB) [{label}]", flush=True) _write_split(out_path, gguf, tensors_grp, split_no=i, split_count=n) parts.append(out_path) total_out_mb = sum(os.path.getsize(p) for p in parts) / (1024**2) print() print(f"[split_layers] Done! {n} files ({total_out_mb:.0f}MB total) → {out_dir}/") print(f"[split_layers] Load: Llama('{parts[0]}')") return parts # ══════════════════════════════════════════════════════════════════════════════ # Legacy byte-split (kept for backward compat) # ══════════════════════════════════════════════════════════════════════════════ def split_gguf(model_path, chunk_mb=100): """Split a GGUF file into raw MB .part files (legacy).""" if not os.path.isfile(model_path): print(f"[split] ERROR: File not found: {model_path}") return [] base = os.path.splitext(os.path.basename(model_path))[0] gguf_dir = os.path.dirname(model_path) out_dir = os.path.join(gguf_dir, "chunks") os.makedirs(out_dir, exist_ok=True) chunk_bytes = chunk_mb * 1024 * 1024 file_size = os.path.getsize(model_path) n_chunks = (file_size + chunk_bytes - 1) // chunk_bytes print(f"[split] {os.path.basename(model_path)} ({file_size//(1024**2)}MB)" f" → {n_chunks} × {chunk_mb}MB chunks") print(f"[split] Output: {out_dir}/") print() parts = [] with open(model_path, 'rb') as src: for i in range(1, n_chunks + 1): part_name = f"{base}-{i:05d}-of-{n_chunks:05d}.part" part_path = os.path.join(out_dir, part_name) written = 0 with open(part_path, 'wb') as dst: while written < chunk_bytes: buf = src.read(min(4 * 1024 * 1024, chunk_bytes - written)) if not buf: break dst.write(buf) written += len(buf) parts.append(part_path) pct = int(i / n_chunks * 40) bar = '█' * pct + '░' * (40 - pct) print(f"\r [{bar}] {i}/{n_chunks} {part_name}", end='', flush=True) print(f"\n\n[split] Done! {n_chunks} chunks → {out_dir}/") print(f"[split] Rejoin: cat {out_dir}/{base}-*.part > {model_path}") return parts # ══════════════════════════════════════════════════════════════════════════════ # Join layer-split GGUF files → single combined GGUF # ══════════════════════════════════════════════════════════════════════════════ def join_gguf_layers(split_paths, out_path): """ Merge layer-split GGUF files (e.g. Model-00001-of-00011.gguf, ...) into one combined GGUF at out_path. Steps: 1. Parse each split file to get tensor metadata + data layout. 2. Read KV entries from the first split, filter out split.* entries. 3. Collect all tensors from all splits in declaration order. 4. Assign new 32-byte-aligned offsets for the combined data section. 5. Write header → filtered KV → tensor infos → tensor data. Returns out_path on success, raises on failure. """ SPLIT_KEYS = {'split.no', 'split.count', 'split.tensors_count'} print(f"[join_layers] Parsing {len(split_paths)} splits ...", flush=True) splits = [_parse_gguf(p) for p in split_paths] first = splits[0] # ── Filtered KV (remove split.* entries from first split) ────────────── # KV section starts at byte 24 (4 magic + 4 version + 8 n_tensors + 8 n_kv) kv_start = 24 raw_entries = _read_kv_entries(first['_path'], first['n_kv'], kv_start) filtered = [(k, raw) for k, raw in raw_entries if k not in SPLIT_KEYS] filtered_bytes = b''.join(raw for _, raw in filtered) n_kv_filtered = len(filtered) # ── Collect all tensors in split order, then by original offset ───────── all_tensors = [] for split in splits: for t in sorted(split['tensors'], key=lambda x: x['offset']): all_tensors.append({**t, '_src': split}) total = len(all_tensors) # ── Assign new 32-byte-aligned offsets ────────────────────────────────── new_off = 0 for t in all_tensors: t['new_offset'] = new_off new_off = _align(new_off + t['size']) # ── Build tensor info bytes ────────────────────────────────────────────── ti_bytes = b'' for t in all_tensors: name_enc = t['name'].encode('utf-8') ti_bytes += len(name_enc).to_bytes(8, 'little') + name_enc ti_bytes += len(t['shape']).to_bytes(4, 'little') for dim in t['shape']: ti_bytes += dim.to_bytes(8, 'little') ti_bytes += t['dtype'].to_bytes(4, 'little') ti_bytes += t['new_offset'].to_bytes(8, 'little') out_mb = sum(t['size'] for t in all_tensors) / (1024**2) print(f"[join_layers] {total} tensors ({out_mb:.0f}MB data) " f"→ {os.path.basename(out_path)}", flush=True) src_handles: dict = {} try: with open(out_path, 'wb') as out: # ── GGUF Header ── out.write(GGUF_MAGIC) out.write(first['version'].to_bytes(4, 'little')) out.write(total.to_bytes(8, 'little')) out.write(n_kv_filtered.to_bytes(8, 'little')) # ── Filtered KV section ── out.write(filtered_bytes) # ── Tensor infos ── out.write(ti_bytes) # ── Pad to 32-byte data boundary ── cur = out.tell() pad = _align(cur) - cur if pad: out.write(b'\x00' * pad) # ── Tensor data ── write_pos = 0 for i, t in enumerate(all_tensors, 1): src_path = t['_src']['_path'] data_start = t['_src']['data_start'] # Pad to aligned offset gap = t['new_offset'] - write_pos if gap > 0: out.write(b'\x00' * gap) # Open source file lazily if src_path not in src_handles: src_handles[src_path] = open(src_path, 'rb') src = src_handles[src_path] src.seek(data_start + t['offset']) remaining = t['size'] buf_size = 4 * 1024 * 1024 while remaining > 0: buf = src.read(min(buf_size, remaining)) if not buf: break out.write(buf) remaining -= len(buf) write_pos = t['new_offset'] + t['size'] if i % 50 == 0 or i == total: pct = int(i / total * 40) bar = '█' * pct + '░' * (40 - pct) print(f"\r [{bar}] {i}/{total}", end='', flush=True) finally: for h in src_handles.values(): try: h.close() except Exception: pass size_mb = os.path.getsize(out_path) / (1024**2) print(f"\n[join_layers] Done! {size_mb:.0f}MB → {out_path}", flush=True) return out_path # ══════════════════════════════════════════════════════════════════════════════ # Join (legacy .part files only) # ══════════════════════════════════════════════════════════════════════════════ def join_gguf(model_path): """Rejoin legacy .part chunks into the original GGUF file.""" base = os.path.splitext(os.path.basename(model_path))[0] gguf_dir = os.path.dirname(model_path) chunk_dir = os.path.join(gguf_dir, "chunks") if not os.path.isdir(chunk_dir): print(f"[join] No chunks/ directory: {chunk_dir}") return False parts = sorted(f for f in os.listdir(chunk_dir) if f.startswith(base) and f.endswith('.part')) if not parts: print(f"[join] No .part files for: {base}") return False if os.path.isfile(model_path): print(f"[join] Already exists: {model_path} (skipping)") return True total_mb = sum(os.path.getsize(os.path.join(chunk_dir, p)) for p in parts) / (1024**2) print(f"[join] Joining {len(parts)} parts → {os.path.basename(model_path)} ({total_mb:.0f}MB)") with open(model_path, 'wb') as out: for i, part in enumerate(parts, 1): with open(os.path.join(chunk_dir, part), 'rb') as f: while True: buf = f.read(4 * 1024 * 1024) if not buf: break out.write(buf) pct = int(i / len(parts) * 40) bar = '█' * pct + '░' * (40 - pct) print(f"\r [{bar}] {i}/{len(parts)} {part}", end='', flush=True) print(f"\n\n[join] Done! {os.path.basename(model_path)} " f"({os.path.getsize(model_path)//(1024**2)}MB)") return True # ══════════════════════════════════════════════════════════════════════════════ # Status # ══════════════════════════════════════════════════════════════════════════════ def status(): cfg = _load_cfg() gguf_dir = cfg['dir'] chunk_dir = os.path.join(gguf_dir, 'chunks') print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print(" GGUF Status") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") for fname in [cfg['default'], cfg['quality']]: p = os.path.join(gguf_dir, fname) if os.path.isfile(p): mb = os.path.getsize(p) / (1024**2) print(f" ✓ {fname} ({mb:.0f}MB)") else: print(f" ✗ {fname} (not found)") print() if os.path.isdir(chunk_dir): # Layer-split .gguf files gguf_splits = sorted(f for f in os.listdir(chunk_dir) if f.endswith('.gguf')) part_chunks = sorted(f for f in os.listdir(chunk_dir) if f.endswith('.part')) if gguf_splits: total_mb = sum(os.path.getsize(os.path.join(chunk_dir, f)) for f in gguf_splits) / (1024**2) print(f" Layer splits: {len(gguf_splits)} .gguf files ({total_mb:.0f}MB total)") for f in gguf_splits: mb = os.path.getsize(os.path.join(chunk_dir, f)) / (1024**2) print(f" {f} ({mb:.1f}MB)") if part_chunks: total_mb = sum(os.path.getsize(os.path.join(chunk_dir, f)) for f in part_chunks) / (1024**2) print(f" Byte chunks: {len(part_chunks)} .part files ({total_mb:.0f}MB total)") if not gguf_splits and not part_chunks: print(" Chunks: none") print(f" Dir: {chunk_dir}/") else: print(" Chunks: none (no chunks/ directory)") print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") # ══════════════════════════════════════════════════════════════════════════════ # CLI # ══════════════════════════════════════════════════════════════════════════════ def main(): cfg = _load_cfg() default_file = os.path.join(cfg['dir'], cfg['default']) p = argparse.ArgumentParser(description="GGUF layer-aware splitter / joiner") sub = p.add_subparsers(dest='cmd') # split_layers sl = sub.add_parser('split_layers', help='Layer-aware split (recommended)') sl.add_argument('--file', default=default_file, help='GGUF file to split') sl.add_argument('--mb', type=int, default=cfg['chunk_mb'], help='Target MB per group (default from install.json)') # split (legacy) sp = sub.add_parser('split', help='Byte-size split (legacy .part files)') sp.add_argument('--file', default=default_file) sp.add_argument('--mb', type=int, default=cfg['chunk_mb']) # join_layers (merge layer-split .gguf files) jl = sub.add_parser('join_layers', help='Merge layer-split .gguf files into one') jl.add_argument('--file', default=default_file, help='Original model path (output destination)') jl.add_argument('--dir', default=None, help='chunks/ dir (default: /chunks)') # join (legacy .part files) jp = sub.add_parser('join', help='Rejoin legacy .part files') jp.add_argument('--file', default=default_file) # status sub.add_parser('status', help='Show GGUF and chunk state') args = p.parse_args() if args.cmd == 'split_layers': split_gguf_layers(args.file, target_mb=args.mb) elif args.cmd == 'split': split_gguf(args.file, chunk_mb=args.mb) elif args.cmd == 'join_layers': base = os.path.splitext(os.path.basename(args.file))[0] chunk_dir = args.dir or os.path.join(os.path.dirname(args.file), 'chunks') splits = sorted( f for f in os.listdir(chunk_dir) if f.startswith(base) and f.endswith('.gguf') ) if not splits: print(f"[join_layers] No layer-split .gguf files found in {chunk_dir}") sys.exit(1) paths = [os.path.join(chunk_dir, f) for f in splits] join_gguf_layers(paths, args.file) elif args.cmd == 'join': join_gguf(args.file) elif args.cmd == 'status': status() else: p.print_help() if __name__ == '__main__': main()