convert_ds41_upstream.py
41.7 KB · 850 lines · python Raw
1 #!/usr/bin/env python3
2 """Convert the ISTA-DASLab DeepSeek-V4.1-Flash-1017 GSQ-RCO GGUFs to llama.cpp's own deepseek41 layout.
3
4 The files published before October 27, 2026 were written for ISTA's llama.cpp branch. llama.cpp's DeepSeek-V4.1
5 support (ggml-org/llama.cpp#31086, merged October 25, 2026) reads the layout of its converter
6 (conversion/deepseek41.py). This script writes that layout from the old files:
7
8 - metadata under the upstream key names; the Engram hash constants (token map, bucket counts, multipliers) move
9 from tensors into metadata, with absolute row offsets into the concatenated table
10 - the Engram tables of all Engram layers become one tensor, per_layer_token_embd. They stay in the shared
11 Engram file, which is converted in place: only its 416-byte header is rewritten, the 110 GB of table data stay
12 where they are. llama.cpp opens it as shard 6 of each build, through a link <build>-00006-of-00006.gguf
13 - blk.N.engram_wkv is renamed blk.N.engram_kv; blk.N.engram_q and blk.N.engram_k are folded into the F32
14 blk.N.engram_gate = q * k (exact: a product of two BF16 values fits in F32)
15 - the vision router bias blk.N.exp_probs_b_vl is added (the repository's exp_probs_b_vl.f32, F32 from the
16 original checkpoint); llama.cpp routes image tokens with it
17 - blk.N.attn_output_a as the 2D matrix of the checkpoint (it was stored as [4096, 1024, 8]; same bytes)
18 - tensors in converter order, split like llama-gguf-split --split-max-size 40G --no-tensor-first-split: shard 1
19 holds only metadata, so later metadata fixes replace a 6 MB file
20 - the chat template of the repository's chat_template.jinja, so downloads that were never patched get the
21 current one too
22 - mmproj: projector type deepseek41v (V4.1 image token layout) instead of deepseek4v
23
24 Quantized weights are copied byte for byte. Every written file is read back: each tensor is compared with its
25 source, and the file with the SHA-256 of the published one (the Engram tables too, before the header is rewritten),
26 so a damaged old download or an unfinished patch_chat_template.py run stops the conversion with an error instead of
27 giving wrong files. Standard library only (Python 3.8+). The model is not downloaded again;
28 the script only fetches chat_template.jinja and exp_probs_b_vl.f32 (80 KB) from the repository, unless their current
29 versions (checked by SHA-256) lie next to it or in the model folder (then it runs offline).
30
31 python3 convert_ds41_upstream.py MODEL_FOLDER [--out NEW_FOLDER]
32
33 MODEL_FOLDER holds the old shards (*-0000N-of-00004.gguf), DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf and optionally the
34 mmproj. Every build found there is converted: its new shards 1 to 5 are written next to the old ones (or to --out)
35 and need as much space as its old shards (121 to 155 GB). With --delete-old, the old shards of a build are deleted as
36 soon as its new ones are verified, so free space for one build suffices. On a folder of new builds the script only
37 adds missing links to the Engram file.
38 """
39 import argparse
40 import hashlib
41 import os
42 import re
43 import struct
44 import sys
45 import urllib.request
46
47 REPO = 'ISTA-DASLab/DeepSeek-V4.1-Flash-1017-GSQ-RCO-GGUF' # chat_template.jinja and exp_probs_b_vl.f32 come from here
48 OLD_REVISION = '653a9874aa38c356233c5f3cc8c96b19a43b57eb' # the repository's last revision with the old files
49 BUILD_PREFIX = 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-'
50 ARCH = 'deepseek41'
51 TABLE = 'per_layer_token_embd.weight'
52 ENGRAM_FILE = 'DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf'
53 N_SPLIT = 6 # shards per build; the last is the shared Engram file
54 SPLIT_MAX_SIZE = 40 * 1000 ** 3 # llama-gguf-split --split-max-size 40G
55 ALIGNMENT = 32
56 SPLIT_RE = re.compile(r'^(?P<prefix>.+)-(?P<no>\d{5})-of-(?P<count>\d{5})\.gguf$')
57 COPY_CHUNK = 64 << 20
58
59 # ---------------------------------------------------------------------------------------------------------------
60 # GGUF header reading and writing
61
62 GGUF_MAGIC = 0x46554747
63 T_U16, T_U32, T_I32, T_STRING, T_ARRAY, T_U64 = 2, 4, 5, 8, 9, 10
64 SCALAR = {0: '<B', 1: '<b', 2: '<H', 3: '<h', 4: '<I', 5: '<i', 6: '<f', 7: '<?', 10: '<Q', 11: '<q', 12: '<d'}
65 GGML_F32, GGML_IQ4_NL, GGML_I32, GGML_I64, GGML_BF16 = 0, 20, 26, 27, 30
66 # GGML type -> (elements per block, bytes per block), for the types these files contain
67 GGML_BLOCK = {0: (1, 4), 1: (1, 2), 8: (32, 34), 10: (256, 84), 11: (256, 110), 12: (256, 144), 13: (256, 176),
68 14: (256, 210), 16: (256, 66), 17: (256, 74), 18: (256, 98), 19: (256, 50), 20: (32, 18),
69 21: (256, 110), 22: (256, 82), 23: (256, 136), 26: (1, 4), 27: (1, 8), 29: (256, 56), 30: (1, 2),
70 39: (32, 17)}
71
72
73 def align(n, a=ALIGNMENT):
74 return (n + a - 1) // a * a
75
76
77 def tensor_nbytes(dims, ggml_type):
78 per_block, block_bytes = GGML_BLOCK[ggml_type]
79 n = 1
80 for d in dims:
81 n *= d
82 if dims[0] % per_block:
83 raise ValueError('row of %d elements is not a whole number of type %d blocks' % (dims[0], ggml_type))
84 return n // per_block * block_bytes
85
86
87 class _Reader:
88 def __init__(self, buf):
89 self.buf, self.p = buf, 0
90
91 def take(self, n):
92 if self.p + n > len(self.buf):
93 raise EOFError
94 b = self.buf[self.p:self.p + n]
95 self.p += n
96 return b
97
98 def u(self, fmt):
99 return struct.unpack(fmt, self.take(struct.calcsize(fmt)))[0]
100
101 def string(self):
102 return self.take(self.u('<Q')).decode('utf-8')
103
104 def skip_value(self, t):
105 if t == T_STRING:
106 self.take(self.u('<Q'))
107 elif t == T_ARRAY:
108 at, n = self.u('<I'), self.u('<Q')
109 if at in (T_STRING, T_ARRAY):
110 for _ in range(n):
111 self.skip_value(at)
112 else:
113 self.take(n * struct.calcsize(SCALAR[at]))
114 else:
115 self.take(struct.calcsize(SCALAR[t]))
116
117
118 def decode_value(t, raw):
119 r = _Reader(raw)
120 if t == T_STRING:
121 return r.string()
122 if t == T_ARRAY:
123 at, n = r.u('<I'), r.u('<Q')
124 if at == T_STRING:
125 return [r.string() for _ in range(n)]
126 fmt = SCALAR[at]
127 return list(struct.unpack('<%d%s' % (n, fmt[1]), r.take(n * struct.calcsize(fmt))))
128 return struct.unpack(SCALAR[t], raw)[0]
129
130
131 def encode_value(t, value, array_type=None):
132 if t == T_STRING:
133 b = value.encode('utf-8')
134 return struct.pack('<Q', len(b)) + b
135 if t == T_ARRAY:
136 head = struct.pack('<IQ', array_type, len(value))
137 if array_type == T_STRING:
138 return head + b''.join(encode_value(T_STRING, v) for v in value)
139 return head + struct.pack('<%d%s' % (len(value), SCALAR[array_type][1]), *value)
140 return struct.pack(SCALAR[t], value)
141
142
143 class GGUFFile:
144 """Header of a GGUF file: kv is a list of [key, type, raw value bytes], tensors maps a name to
145 (absolute file offset, dims, ggml type, byte size)."""
146
147 def __init__(self, path):
148 self.path = path
149 size = 1 << 20
150 while True:
151 with open(path, 'rb') as f:
152 buf = f.read(size)
153 try:
154 self._parse(buf)
155 break
156 except EOFError:
157 if len(buf) < size:
158 raise ValueError('%s: truncated GGUF header' % path)
159 size *= 4
160 self.size = os.path.getsize(path)
161
162 def _parse(self, buf):
163 r = _Reader(buf)
164 if r.u('<I') != GGUF_MAGIC:
165 raise ValueError('%s is not a GGUF file' % self.path)
166 self.version = r.u('<I')
167 n_tensors, n_kv = r.u('<Q'), r.u('<Q')
168 self.kv = []
169 for _ in range(n_kv):
170 key = r.string()
171 t = r.u('<I')
172 start = r.p
173 r.skip_value(t)
174 self.kv.append([key, t, buf[start:r.p]])
175 infos = []
176 for _ in range(n_tensors):
177 name = r.string()
178 dims = [r.u('<Q') for _ in range(r.u('<I'))]
179 infos.append((name, dims, r.u('<I'), r.u('<Q')))
180 self.header_size = r.p
181 self.data_start = align(r.p, self.get('general.alignment', ALIGNMENT))
182 self.tensors = {}
183 self.order = []
184 for name, dims, gt, off in infos:
185 self.tensors[name] = (self.data_start + off, dims, gt, tensor_nbytes(dims, gt))
186 self.order.append(name)
187
188 def get(self, key, default=None):
189 for k, t, raw in self.kv:
190 if k == key:
191 return decode_value(t, raw)
192 return default
193
194 def read_tensor(self, name):
195 off, _, _, n = self.tensors[name]
196 with open(self.path, 'rb') as f:
197 f.seek(off)
198 data = f.read(n)
199 if len(data) != n:
200 raise ValueError('%s: tensor %s is truncated' % (self.path, name))
201 return data
202
203
204 def serialize_header(kv, infos):
205 """kv: [key, type, raw]; infos: (name, dims, ggml type, relative offset). Padded to the data section."""
206 out = [struct.pack('<IIQQ', GGUF_MAGIC, 3, len(infos), len(kv))]
207 for key, t, raw in kv:
208 out += [encode_value(T_STRING, key), struct.pack('<I', t), raw]
209 for name, dims, gt, off in infos:
210 out += [encode_value(T_STRING, name), struct.pack('<I', len(dims)),
211 struct.pack('<%dQ' % len(dims), *dims), struct.pack('<IQ', gt, off)]
212 header = b''.join(out)
213 return header + b'\0' * (align(len(header)) - len(header))
214
215
216 # ---------------------------------------------------------------------------------------------------------------
217 # conversion
218
219
220 class Tensor:
221 """An output tensor: bytes from a file range or computed here."""
222
223 def __init__(self, name, dims, ggml_type, path=None, offset=None, data=None):
224 self.name, self.dims, self.type = name, list(dims), ggml_type
225 self.nbytes = tensor_nbytes(self.dims, ggml_type)
226 self.path, self.offset, self.data = path, offset, data
227 if data is not None and len(data) != self.nbytes:
228 raise ValueError('%s: %d bytes for a tensor of %d' % (name, len(data), self.nbytes))
229
230 def source_hash(self):
231 if self.data is not None:
232 return hashlib.sha256(self.data).hexdigest()
233 return hash_range(self.path, self.offset, self.nbytes)
234
235
236 def read_into(f, n, *hashes):
237 """Reads the next n bytes of f into the given hashes."""
238 while n:
239 b = f.read(min(n, COPY_CHUNK))
240 if not b:
241 raise ValueError('%s ends early' % f.name)
242 for h in hashes:
243 h.update(b)
244 n -= len(b)
245
246
247 def hash_range(path, offset, n):
248 h = hashlib.sha256()
249 with open(path, 'rb') as f:
250 f.seek(offset)
251 read_into(f, n, h)
252 return h.hexdigest()
253
254
255 # the old branch-specific metadata, replaced by the upstream keys
256 OLD_KEYS = ['attention.candidate_block_size', 'attention.candidate_topk_blocks', 'attention.candidate_source_layer_id',
257 'kv_source_layer_ids', 'index_source_layer_ids', 'dspark_target_layer_ids', 'expert_scoring_func',
258 'engram.present', 'engram.layer_ids', 'engram.num_embeddings', 'engram_head_dim', 'engram_n_heads',
259 'engram_max_ngram_size', 'engram_vocab_size', 'engram_compressed_vocab_size', 'engram_pad_token_id',
260 'engram.source', 'engram.file']
261 CONST_TENSORS = ['engram_token_map.weight', 'engram_primes.weight', 'engram_offsets.weight',
262 'engram_multipliers.weight']
263
264
265 def unpack(data, fmt):
266 return list(struct.unpack('<%d%s' % (len(data) // struct.calcsize(fmt), fmt[1]), data))
267
268
269 def convert_metadata(meta, tensor_source, chat_template):
270 """meta: GGUFFile with the old first-shard metadata. Returns the new kv list (without split keys)."""
271 A = ARCH + '.'
272 get = lambda k: meta.get(A + k)
273 if meta.get('general.architecture') != ARCH:
274 raise ValueError('%s: architecture %r, expected %s' % (meta.path, meta.get('general.architecture'), ARCH))
275 missing = [A + k for k in OLD_KEYS if meta.get(A + k) is None and k not in ('dspark_target_layer_ids',
276 'expert_scoring_func')]
277 if missing:
278 raise ValueError('%s: missing metadata %s' % (meta.path, ', '.join(missing)))
279
280 layers = get('engram.layer_ids')
281 n_gram, n_heads, head_dim = get('engram_max_ngram_size'), get('engram_n_heads'), get('engram_head_dim')
282 n_cols = (n_gram - 1) * n_heads
283 token_map = unpack(tensor_source('engram_token_map.weight'), '<i')
284 primes = unpack(tensor_source('engram_primes.weight'), '<q')
285 local_offsets = unpack(tensor_source('engram_offsets.weight'), '<q')
286 multipliers = unpack(tensor_source('engram_multipliers.weight'), '<q')
287 if len(primes) != len(layers) * n_cols or len(local_offsets) != len(primes) or \
288 len(multipliers) != len(layers) * n_gram:
289 raise ValueError('Engram constants do not match %d layers x %d heads' % (len(layers), n_cols))
290 for e, rows in enumerate(get('engram.num_embeddings')):
291 layer_primes = primes[e * n_cols:(e + 1) * n_cols]
292 if sum(layer_primes) != rows:
293 raise ValueError('Engram layer %d: bucket counts do not add up to its table rows' % layers[e])
294 if local_offsets[e * n_cols:(e + 1) * n_cols] != [sum(layer_primes[:i]) for i in range(n_cols)]:
295 raise ValueError('Engram layer %d: unexpected row offsets' % layers[e])
296 if max(token_map) + 1 != get('engram_compressed_vocab_size') or \
297 len(token_map) != len(meta.get('tokenizer.ggml.tokens')):
298 raise ValueError('Engram token map does not match the vocabulary')
299 if any(m <= 0 or m % 2 == 0 for m in multipliers):
300 raise ValueError('Engram multipliers must be odd and positive')
301
302 new = [
303 ('attention.kv_source_layers', T_ARRAY, get('kv_source_layer_ids'), T_U32),
304 ('attention.indexer.source_layers', T_ARRAY, get('index_source_layer_ids'), T_U32),
305 ('attention.indexer.candidate_source_layer', T_I32, get('attention.candidate_source_layer_id'), None),
306 ('attention.indexer.candidate_top_k_blocks', T_U32, get('attention.candidate_topk_blocks'), None),
307 ('attention.indexer.candidate_block_size', T_U32, get('attention.candidate_block_size'), None),
308 ('ple.layers', T_ARRAY, layers, T_U32),
309 ('ple.ngram_size', T_U32, n_gram, None),
310 ('ple.heads_per_ngram', T_U32, n_heads, None),
311 ('embedding_length_per_layer_input', T_U32, head_dim, None),
312 ('ple.layer_multipliers', T_ARRAY, multipliers, T_U64),
313 ('ple.head_offsets', T_ARRAY, [sum(primes[:i]) for i in range(len(primes))], T_U64),
314 ('ple.head_vocab_sizes', T_ARRAY, primes, T_U64),
315 ('engram.pad_token_id', T_U32, get('engram_pad_token_id'), None),
316 ('engram.token_map', T_ARRAY, token_map, T_I32),
317 ]
318 old = {A + k for k in OLD_KEYS}
319 kv, inserted = [], False
320 for key, t, raw in meta.kv:
321 if key.startswith('split.'):
322 continue
323 if key in old:
324 if not inserted:
325 kv += [[A + k, vt, encode_value(vt, v, at)] for k, vt, v, at in new]
326 inserted = True
327 continue
328 if key == 'tokenizer.chat_template':
329 raw = encode_value(T_STRING, chat_template) # the repository's, whether or not the old one was patched
330 kv.append([key, t, raw])
331 return kv
332
333
334 def bf16_to_f32(h):
335 return struct.unpack('<f', struct.pack('<I', h << 16))[0]
336
337
338 def engram_gate(q, k):
339 """F32 q * k from two BF16 tensors (the upstream converter's data_torch.float() * k().float())."""
340 n = len(q) // 2
341 qv, kv = struct.unpack('<%dH' % n, q), struct.unpack('<%dH' % n, k)
342 return struct.pack('<%df' % n, *[bf16_to_f32(a) * bf16_to_f32(b) for a, b in zip(qv, kv)])
343
344
345 def canonical_key(name):
346 """Converter order: global tensors, then the blocks in order, the Engram table last; names sorted."""
347 if name == 'per_layer_token_embd.weight':
348 return (2, 0, name)
349 m = re.match(r'blk\.(\d+)\.', name)
350 return (1, int(m.group(1)), name) if m else (0, 0, name)
351
352
353 def plan_tensors(meta, sources, engram, bias_vl):
354 """sources: GGUFFiles holding the old backbone tensors; engram: the old Engram GGUFFile."""
355 index = {}
356 for src in sources:
357 for name, (off, dims, gt, _) in src.tensors.items():
358 if name in index:
359 raise ValueError('tensor %s found in %s and %s' % (name, index[name][0].path, src.path))
360 index[name] = (src, off, dims, gt)
361 A = ARCH + '.'
362 n_layer, n_expert = meta.get(A + 'block_count'), meta.get(A + 'expert_count')
363 layers = meta.get(A + 'engram.layer_ids')
364 out = []
365 for name, (src, off, dims, gt) in index.items():
366 if name in CONST_TENSORS or re.match(r'blk\.\d+\.engram_[qk]\.weight$', name):
367 continue
368 if name.endswith('.attn_output_a.weight') and len(dims) == 3:
369 dims = [dims[0], dims[1] * dims[2]] # the checkpoint's 2D wo_a, as the converter writes it; same bytes
370 out.append(Tensor(name.replace('.engram_wkv.', '.engram_kv.'), dims, gt, src.path, off))
371
372 for layer in layers:
373 q, k = ('blk.%d.engram_%s.weight' % (layer, w) for w in 'qk')
374 if index[q][3] != GGML_BF16 or index[k][3] != GGML_BF16 or index[q][2] != index[k][2]:
375 raise ValueError('blk.%d engram_q / engram_k must be BF16 of the same shape' % layer)
376 out.append(Tensor('blk.%d.engram_gate.weight' % layer, index[q][2], GGML_F32,
377 data=engram_gate(index[q][0].read_tensor(q), index[k][0].read_tensor(k))))
378
379 if len(bias_vl) != n_layer * n_expert * 4:
380 raise ValueError('exp_probs_b_vl.f32 is for a different model')
381 for layer in range(n_layer):
382 if 'blk.%d.exp_probs_b.bias' % layer not in index:
383 raise ValueError('blk.%d.exp_probs_b.bias is missing' % layer)
384 row = bias_vl[layer * n_expert * 4:(layer + 1) * n_expert * 4]
385 out.append(Tensor('blk.%d.exp_probs_b_vl.bias' % layer, [n_expert], GGML_F32, data=row))
386
387 table, table_layers = engram_table(engram)
388 if table.dims[1] != sum(meta.get(A + 'engram.num_embeddings')) or table_layers not in (None, layers) or \
389 meta.get(A + 'engram.source') != engram.get(A + 'engram.source'):
390 raise ValueError('%s does not belong to this model (other tables or checkpoint revision)' % engram.path)
391 out.append(table)
392 out.sort(key=lambda t: canonical_key(t.name))
393 return out
394
395
396 def engram_table(engram):
397 """The Engram table as one tensor, and the layers of the old per-layer tables (None if already converted).
398 The old file holds the per-layer tables back to back, so their concatenation is one byte range."""
399 if TABLE in engram.tensors:
400 off, dims, gt, _ = engram.tensors[TABLE]
401 return Tensor(TABLE, dims, gt, engram.path, off), None
402 names = sorted((n for n in engram.tensors if re.match(r'blk\.\d+\.engram_embd\.weight$', n)),
403 key=lambda n: engram.tensors[n][0])
404 if not names or len(names) != len(engram.tensors):
405 raise ValueError('%s: unexpected tensors in the Engram file' % engram.path)
406 first_off, dims, gt, _ = engram.tensors[names[0]]
407 end, rows = first_off, 0
408 for name in names:
409 off, d, g, n = engram.tensors[name]
410 if off != end or g != gt or d[0] != dims[0]:
411 raise ValueError('%s: the Engram tables are not contiguous' % engram.path)
412 end, rows = off + n, rows + d[1]
413 layers = [int(re.match(r'blk\.(\d+)\.', n).group(1)) for n in names]
414 return Tensor(TABLE, [dims[0], rows], gt, engram.path, first_off), layers
415
416
417 def plan_splits(tensors, max_size):
418 """llama-gguf-split --split-max-size with --no-tensor-first-split: the first file holds only metadata."""
419 splits, cur = [[], []], 0
420 for t in tensors:
421 n = align(t.nbytes)
422 if cur + n > max_size:
423 if not splits[-1]:
424 raise ValueError('tensor %s is larger than the split size and comes first' % t.name)
425 splits.append([])
426 cur = n
427 else:
428 cur += n
429 splits[-1].append(t)
430 return splits
431
432
433 def split_kv(no, count, n_tensors):
434 return [['split.no', T_U16, encode_value(T_U16, no)], ['split.count', T_U16, encode_value(T_U16, count)],
435 ['split.tensors.count', T_I32, encode_value(T_I32, n_tensors)]]
436
437
438 def copy_range(src_path, offset, n, fout, buf):
439 """Plain reads and writes through buf, as on every file system (os.copy_file_range leaves the copy to the
440 file system's own implementation)."""
441 with open(src_path, 'rb') as fin:
442 fin.seek(offset)
443 while n:
444 got = fin.readinto(buf[:min(n, len(buf))])
445 if not got:
446 raise ValueError('%s ends early' % src_path)
447 fout.write(buf[:got])
448 n -= got
449
450
451 def write_gguf(path, kv, tensors):
452 infos, off = [], 0
453 for t in tensors:
454 infos.append((t.name, t.dims, t.type, off))
455 off += align(t.nbytes)
456 tmp = path + '.partial'
457 buf = memoryview(bytearray(COPY_CHUNK))
458 with open(tmp, 'wb') as f:
459 f.write(serialize_header(kv, infos))
460 for t in tensors:
461 if t.data is not None:
462 f.write(t.data)
463 else:
464 copy_range(t.path, t.offset, t.nbytes, f, buf)
465 f.write(b'\0' * (align(t.nbytes) - t.nbytes))
466 f.flush()
467 os.fsync(f.fileno())
468 os.replace(tmp, path)
469
470
471 def verify_gguf(path, kv, tensors, expected=None, origin=()):
472 """Re-read the written file: same metadata, tensor table and tensor bytes as planned, and the SHA-256 of the
473 published file (expected; None for a file this repository does not publish). origin: the old files it was made
474 from, for the error message."""
475 g = GGUFFile(path)
476 if g.kv != kv or g.order != [t.name for t in tensors]:
477 raise ValueError('%s: header differs from the plan' % path)
478 if g.size != g.data_start + sum(align(t.nbytes) for t in tensors):
479 raise ValueError('%s: unexpected file size' % path)
480 whole = hashlib.sha256()
481 with open(path, 'rb') as f:
482 read_into(f, g.data_start, whole)
483 for t in tensors:
484 off, dims, gt, n = g.tensors[t.name]
485 if dims != t.dims or gt != t.type or off != f.tell():
486 raise ValueError('%s: tensor %s differs from the plan' % (path, t.name))
487 part = hashlib.sha256()
488 read_into(f, n, whole, part)
489 if part.hexdigest() != t.source_hash():
490 raise ValueError('%s: tensor %s differs from its source%s. The copy or a read failed on this machine, '
491 'or the source changed during the conversion; a damaged download would have been '
492 'copied as it is' % (path, t.name, '' if t.path is None else ' (%s at %d)'
493 % (t.path, t.offset)))
494 read_into(f, align(n) - n, whole)
495 if expected is not None and whole.hexdigest() != expected:
496 raise ValueError('%s: every tensor equals its source, but the file is not the published one (SHA-256), so the '
497 'old files it was made from are not the published ones: %s. A damaged download, or a '
498 'patch_chat_template.py run that did not finish, gives this; download the new files instead'
499 % (path, ', '.join(os.path.basename(p) for p in origin)))
500
501
502 def plan_model(meta, sources, engram, chat_template, bias_vl):
503 """Metadata, all tensors (the Engram table last) and the backbone split of one build."""
504 kv = convert_metadata(meta, lambda t: next(s for s in sources if t in s.tensors).read_tensor(t), chat_template)
505 tensors = plan_tensors(meta, sources, engram, bias_vl)
506 splits = plan_splits(tensors[:-1], SPLIT_MAX_SIZE)
507 if tensors[-1].name != TABLE or len(splits) != N_SPLIT - 1:
508 raise ValueError('the backbone needs %d shards; the shared Engram file is shard %d of %d'
509 % (len(splits), N_SPLIT, N_SPLIT))
510 return kv, tensors, splits
511
512
513 def convert_model(meta, sources, engram, chat_template, bias_vl, name, out_dir, verify, log):
514 """Writes shards 1 to N_SPLIT - 1 of a build; shard N_SPLIT is the shared Engram file."""
515 kv, tensors, splits = plan_model(meta, sources, engram, chat_template, bias_vl)
516 names = ['%s-%05d-of-%05d.gguf' % (name, i + 1, N_SPLIT) for i in range(len(splits))]
517 if verify and not all(n in PUBLISHED for n in names):
518 log(' %s is not a build of this repository: each tensor is checked against its source, but the files cannot '
519 'be checked against published ones' % name)
520 paths = []
521 for i, part in enumerate(splits):
522 path = os.path.join(out_dir, names[i])
523 part_kv = (kv if i == 0 else []) + split_kv(i, N_SPLIT, len(tensors))
524 log(' %s: %d tensors, %.2f GB' % (names[i], len(part), sum(t.nbytes for t in part) / 1e9))
525 write_gguf(path, part_kv, part)
526 if verify:
527 origin = [meta.path] if i == 0 else sorted({t.path for t in part if t.path is not None})
528 verify_gguf(path, part_kv, part, PUBLISHED.get(names[i]), origin)
529 paths.append(path)
530 return paths, tensors
531
532
533 def engram_header(table, source, n_tensors):
534 kv = [['general.name', T_STRING, encode_value(T_STRING, 'DeepSeek-V4.1-Flash-1017 Engram tables')],
535 ['general.description', T_STRING, encode_value(T_STRING, 'Shard 6 of 6 of every GSQ-RCO build')],
536 [ARCH + '.engram.source', T_STRING, encode_value(T_STRING, source)]]
537 kv += split_kv(N_SPLIT - 1, N_SPLIT, n_tensors)
538 return kv, serialize_header(kv, [(TABLE, table.dims, table.type, 0)])
539
540
541 def convert_engram(engram, table, n_tensors, verify, log):
542 """Rewrite the old Engram file's header in place. The new header ends where the old one did, so the 110 GB of
543 table data stay where they are; the old header is kept in a side file until the new one is checked. With verify,
544 the tables are first checked against the published file: its SHA-256 is that of the new header and the tables."""
545 path = engram.path
546 backup = path + '.old-header'
547 kv, header = engram_header(table, engram.get(ARCH + '.engram.source'), n_tensors)
548 if TABLE in engram.tensors:
549 if engram.kv != kv or engram.data_start != len(header):
550 raise ValueError('%s: unexpected header' % path)
551 if os.path.exists(backup):
552 os.remove(backup)
553 return False
554 if len(header) != engram.data_start or table.offset != engram.data_start:
555 raise ValueError('%s: the new header does not fit the old one' % path)
556 if verify:
557 log('%s: checking the tables against the published file (%.0f GB)'
558 % (os.path.basename(path), engram.size / 1e9))
559 h = hashlib.sha256(header)
560 with open(path, 'rb') as f:
561 f.seek(engram.data_start)
562 read_into(f, engram.size - engram.data_start, h)
563 if h.hexdigest() != PUBLISHED[ENGRAM_FILE]:
564 raise ValueError('%s: its tables are not those of the published file (SHA-256), e.g. from a damaged '
565 'download; the file was left unchanged. Download %s again' % (path, ENGRAM_FILE))
566 with open(path, 'rb') as f:
567 old = f.read(engram.data_start)
568 with open(backup, 'wb') as f:
569 f.write(old)
570 os.fsync(f.fileno())
571 with open(path, 'r+b') as f:
572 f.write(header)
573 f.flush()
574 os.fsync(f.fileno())
575 g = GGUFFile(path)
576 if g.kv != kv or g.order != [TABLE] or g.tensors[TABLE][:3] != (table.offset, table.dims, table.type) or \
577 g.size != engram.size:
578 with open(path, 'r+b') as f:
579 f.write(old)
580 raise ValueError('%s: the rewritten header did not check out; the old one was restored' % path)
581 os.remove(backup)
582 log('%s: header rewritten in place (%d bytes), the table data are unchanged' % (os.path.basename(path), len(old)))
583 return True
584
585
586 def link_engram(engram_path, name, out_dir, log):
587 """llama.cpp opens <name>-0000N-of-0000N.gguf as the last shard: a link to the shared Engram file."""
588 path = os.path.join(out_dir, '%s-%05d-of-%05d.gguf' % (name, N_SPLIT, N_SPLIT))
589 if os.path.lexists(path):
590 if os.path.exists(path) and os.path.samefile(path, engram_path):
591 return
592 raise ValueError('%s exists and is not the Engram file' % path)
593 try:
594 os.symlink(os.path.relpath(engram_path, out_dir), path)
595 kind = 'link'
596 except (OSError, NotImplementedError):
597 os.link(engram_path, path) # e.g. Windows without symlink rights
598 kind = 'hard link'
599 log(' %s: %s to %s' % (os.path.basename(path), kind, os.path.basename(engram_path)))
600
601
602 def convert_mmproj(src, out_path, name, log):
603 """name: the file name the result gets (out_path may be a temporary name)."""
604 if src.get('clip.projector_type') != 'deepseek4v':
605 raise ValueError('%s: projector type %r' % (src.path, src.get('clip.projector_type')))
606 kv = []
607 for key, t, raw in src.kv:
608 if key == 'clip.projector_type':
609 raw = encode_value(T_STRING, 'deepseek41v')
610 elif key == 'clip.vision.image_max_pixels':
611 continue # deepseek41v fixes the image budget at 1024 tokens; the upstream converter writes no key
612 kv.append([key, t, raw])
613 tensors = [Tensor(n, src.tensors[n][1], src.tensors[n][2], src.path, src.tensors[n][0]) for n in src.order]
614 log(' %s: projector type deepseek41v' % name)
615 if name not in PUBLISHED:
616 log(' %s is not a file of this repository: each tensor is checked against its source, but the file cannot '
617 'be checked against a published one' % name)
618 write_gguf(out_path, kv, tensors)
619 verify_gguf(out_path, kv, tensors, PUBLISHED.get(name), [src.path])
620
621
622 # ---------------------------------------------------------------------------------------------------------------
623 # discovery
624
625
626 def find_sets(paths):
627 files = []
628 for p in paths:
629 if os.path.isdir(p):
630 files += [os.path.join(p, f) for f in sorted(os.listdir(p)) if f.endswith('.gguf')]
631 else:
632 files.append(p)
633 models, shards, engrams, mmprojs, converted, firsts = [], {}, {}, [], [], set()
634 for path in files:
635 for p in {path, os.path.realpath(path)}:
636 journal = os.path.join(os.path.dirname(p), '.%s.chat-template-recovery' % os.path.basename(p))
637 if os.path.isdir(journal):
638 raise ValueError('%s: a patch_chat_template.py run on this file did not finish (%s exists), so its '
639 'tensor data are partly moved. Finish it by running the same command again (the '
640 'script is in revision %s of the repository), or download the new files'
641 % (path, journal, OLD_REVISION))
642 g = GGUFFile(path)
643 arch = g.get('general.architecture')
644 m = SPLIT_RE.match(os.path.basename(path))
645 if arch == ARCH and m:
646 firsts.add((os.path.dirname(path), m.group('prefix')))
647 if arch == ARCH:
648 if g.get(ARCH + '.attention.kv_source_layers') is not None:
649 if m: # already in the upstream layout; it may still lack the link to the Engram file
650 converted.append((os.path.dirname(os.path.abspath(path)), m.group('prefix'), g))
651 continue
652 models.append(g)
653 elif arch == ARCH + '-engram' or TABLE in g.tensors:
654 engrams.setdefault(os.path.realpath(path), g) # links to the Engram file count once
655 elif g.get('clip.projector_type') == 'deepseek4v':
656 mmprojs.append(g)
657 elif m and g.get('split.no') is not None:
658 shards[(os.path.dirname(path), m.group('prefix'), int(m.group('no')))] = g
659 sets = []
660 for g in models:
661 m = SPLIT_RE.match(os.path.basename(g.path))
662 if m is None:
663 sets.append((g, [g], os.path.basename(g.path)[:-5]))
664 continue
665 prefix, count = m.group('prefix'), g.get('split.count')
666 parts = [g] + [shards.get((os.path.dirname(g.path), prefix, i)) for i in range(2, count + 1)]
667 if None in parts:
668 raise ValueError('%s: not all %d shards are present' % (g.path, count))
669 sets.append((g, parts, prefix))
670 orphans = sorted(g.path for (d, prefix, _), g in shards.items()
671 if (d, prefix) not in firsts and prefix.startswith(BUILD_PREFIX))
672 if orphans:
673 raise ValueError('the first shard of these files is missing, so their build cannot be converted: %s'
674 % ', '.join(orphans))
675 return sets, list(engrams.values()), mmprojs, converted
676
677
678 def repo_file(name, path, inputs, sha256, log):
679 """A small file of the repository with the given SHA-256: the given path, else a copy next to this script or in
680 the model folder (other versions, such as an older download, are skipped), else downloaded from the repository."""
681 if path is not None:
682 candidates = [path]
683 else:
684 folders = [os.path.dirname(os.path.abspath(__file__))]
685 folders += [p if os.path.isdir(p) else os.path.dirname(os.path.abspath(p)) for p in inputs]
686 candidates = list(dict.fromkeys(f for f in (os.path.join(d, name) for d in folders) if os.path.isfile(f)))
687 for f in candidates:
688 with open(f, 'rb') as fin:
689 data = fin.read()
690 if hashlib.sha256(data).hexdigest() == sha256:
691 return data
692 if path is not None:
693 raise ValueError('%s is not the current %s of %s (checksum)' % (path, name, REPO))
694 log('%s: not the current version (checksum), skipped' % f)
695 url = 'https://huggingface.co/%s/resolve/main/%s' % (REPO, name)
696 try:
697 with urllib.request.urlopen(url, timeout=120) as r:
698 data = r.read()
699 except OSError as e:
700 raise ValueError('%s could not be downloaded from %s (%s); put it next to this script' % (name, url, e))
701 if hashlib.sha256(data).hexdigest() != sha256:
702 raise ValueError('%s of %s has changed since this script was written; download the current '
703 'convert_ds41_upstream.py' % (name, REPO))
704 log('%s: downloaded from %s' % (name, REPO))
705 return data
706
707
708 def read_inputs(args, log):
709 """chat_template.jinja (embedded in the new first shards) and exp_probs_b_vl.f32 (the vision router bias)."""
710 template = repo_file('chat_template.jinja', args.chat_template, args.inputs, TEMPLATE_SHA256, log).decode('utf-8')
711 bias_vl = repo_file('exp_probs_b_vl.f32', args.vision_bias, args.inputs, BIAS_VL_SHA256, log)
712 return template, bias_vl
713
714
715 def main():
716 ap = argparse.ArgumentParser(description=__doc__.split('\n')[0])
717 ap.add_argument('inputs', nargs='+', help='model folder(s) or GGUF files')
718 ap.add_argument('--out', help='folder for the new shards (default: next to the old ones)')
719 ap.add_argument('--engram', help='the Engram GGUF (default: the one found among the inputs)')
720 ap.add_argument('--chat-template', help='chat_template.jinja of the repository (default: next to this script or '
721 'in the model folder)')
722 ap.add_argument('--vision-bias', help='exp_probs_b_vl.f32 of the repository (default: as --chat-template)')
723 ap.add_argument('--metadata', help=argparse.SUPPRESS) # header of another first shard (release builds)
724 ap.add_argument('--name', help='output name prefix for a single unsplit input')
725 ap.add_argument('--no-verify', action='store_true', help='skip re-reading and hashing the written shards')
726 ap.add_argument('--delete-old', action='store_true', help='delete the old shards after a verified conversion')
727 ap.add_argument('--dry-run', action='store_true', help='only print what would be done')
728 args = ap.parse_args()
729 log = lambda s: print(s, flush=True)
730 if args.no_verify and not args.dry_run:
731 log('warning: --no-verify: the written files are not read back, nor checked against the published ones')
732
733 sets, engrams, mmprojs, converted = find_sets(args.inputs)
734 if args.engram:
735 engrams = [GGUFFile(args.engram)]
736 if (sets or converted) and len(engrams) != 1:
737 raise SystemExit('expected one Engram file (%s), found %d; pass it with --engram' % (ENGRAM_FILE, len(engrams)))
738 if not sets and not mmprojs and not converted:
739 raise SystemExit('nothing to convert (no DeepSeek-V4.1 GSQ-RCO files found)')
740 engram = engrams[0] if engrams else None
741 chat_template = bias_vl = None
742 if sets:
743 chat_template, bias_vl = read_inputs(args, log)
744 written, old = [], []
745 builds = [(d, prefix) for d, prefix, _ in converted]
746 n_tensors = {g.get('split.tensors.count') for _, _, g in converted}
747 for first, parts, prefix in sets:
748 meta = GGUFFile(args.metadata) if args.metadata else first
749 name = args.name or prefix
750 out_dir = os.path.abspath(args.out or os.path.dirname(os.path.abspath(first.path)))
751 log('%s -> %s' % (prefix, os.path.join(out_dir, name + '-*.gguf')))
752 if args.dry_run:
753 _, tensors, splits = plan_model(meta, parts, engram, chat_template, bias_vl)
754 for i, part in enumerate(splits):
755 log(' shard %d: %d tensors, %.2f GB' % (i + 1, len(part), sum(t.nbytes for t in part) / 1e9))
756 log(' shard %d: %s' % (N_SPLIT, os.path.basename(engram.path)))
757 n_tensors.add(len(tensors))
758 continue
759 os.makedirs(out_dir, exist_ok=True)
760 paths, tensors = convert_model(meta, parts, engram, chat_template, bias_vl, name, out_dir, not args.no_verify, log)
761 written += paths
762 builds.append((out_dir, name))
763 n_tensors.add(len(tensors))
764 if args.delete_old:
765 for p in parts:
766 os.remove(p.path)
767 log(' deleted the %d old shards' % len(parts))
768 else:
769 old += [p.path for p in parts]
770
771 if engram is not None and (builds or sets):
772 if len(n_tensors) != 1:
773 raise ValueError('the builds differ in tensor count, so they cannot share one Engram file')
774 table, _ = engram_table(engram)
775 if args.dry_run:
776 if TABLE not in engram.tensors:
777 log('%s: header rewritten in place, the table data stay' % os.path.basename(engram.path))
778 else:
779 convert_engram(engram, table, n_tensors.pop(), not args.no_verify, log)
780 for out_dir, name in builds:
781 link_engram(engram.path, name, out_dir, log)
782 for src in mmprojs:
783 out_dir = args.out or os.path.dirname(os.path.abspath(src.path))
784 out_path = os.path.join(out_dir, os.path.basename(src.path))
785 if args.dry_run:
786 log('%s: projector type deepseek4v -> deepseek41v' % src.path)
787 continue
788 if os.path.abspath(out_path) == os.path.abspath(src.path):
789 convert_mmproj(src, out_path + '.new', os.path.basename(out_path), log)
790 os.replace(out_path + '.new', out_path)
791 else:
792 convert_mmproj(src, out_path, os.path.basename(out_path), log)
793 if args.dry_run:
794 log('dry run: only the headers were read; the tensor data are read and checked during the conversion')
795 elif old:
796 log('done. llama.cpp no longer needs the old shards:\n ' + '\n '.join(old))
797 else:
798 log('done.')
799
800
801 # exp_probs_b_vl.f32: blk.0..39.exp_probs_b_vl = layers.N.ffn.gate.bias_vl of deepseek-ai/DeepSeek-V4.1-Flash-1017
802 # (revision 1bae0e1088993b9b82de08f59de5be5a34e42170), 40 x 384 little-endian F32
803 BIAS_VL_SHA256 = '710b11267b4db392d1fdb558f36ba8e0e91a91f4e8180c953c912e18eacf843d'
804 # chat_template.jinja of the repository since October 25, 2026 (commit 1c100b9), as in the published first shards
805 TEMPLATE_SHA256 = '4d61d22c90ea0cdf15c7e298f7215f23eeffd20f8eed8fb517fda5fb64693d99'
806 # SHA-256 of the files this script writes, as published in the repository since October 27, 2026 (commit a419d74).
807 # Every published version of the old files converts to these.
808 PUBLISHED = {
809 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ1_M-00001-of-00006.gguf':
810 '3e8ddd11441edd4bc19bacaef5dbe38818af2cbdf0820fb70f1d2d851fcc192a',
811 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ1_M-00002-of-00006.gguf':
812 'be4ceb1caf1bed28e886860cf6a52adc8f5ca9eb0d2098ade39bb4179d48b323',
813 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ1_M-00003-of-00006.gguf':
814 '758c66ba65236e8ce0820c21a7b6f9b2947681df8cc509f672a77722e8191b9a',
815 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ1_M-00004-of-00006.gguf':
816 '04a5bea2b38eb576f49efc19c0a677768d6e2a8ea5772d72fb6cb79e1733a212',
817 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ1_M-00005-of-00006.gguf':
818 '7c8dbee99890f05dbc420eba6f44393f5228d06248fe61e1ae8ed002449c7549',
819 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XS-00001-of-00006.gguf':
820 '0182a2a1f42d319a0dbc6e34aa82a063880c069fe3aafbbea86018f386e64e5f',
821 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XS-00002-of-00006.gguf':
822 '5c88039fa640460a9fc3465a6dfcd162a4083dd738d55e5b52e025be14555a08',
823 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XS-00003-of-00006.gguf':
824 '0fddebfd05f0a36cfd107ba5dfd2547e64df61adbab39560a5a5f5521ca099a5',
825 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XS-00004-of-00006.gguf':
826 'c577a43c6e6e615838a1655a0b162541edeac6f36542f08c4a9593a70dcaf500',
827 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XS-00005-of-00006.gguf':
828 '851d8605bf504962054d17ea8a0d9e2e0a228d085d396bd865a4de9f16d90fbd',
829 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XXS-00001-of-00006.gguf':
830 'f356f198561f56df36f7ab92d7238a926646d76de992c34f15bcb939a5f529b9',
831 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XXS-00002-of-00006.gguf':
832 '25af3785ccf925f54137e1ead4c481a8952315dc0b1af6519a99407f67bce8f3',
833 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XXS-00003-of-00006.gguf':
834 '8168d0998fc494def2e1952d83c131425a108660aae9d29190f5a8670ab62b48',
835 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XXS-00004-of-00006.gguf':
836 '9eb9b4de1968f150dfc207eb31c27be9cd9b5666a9a6246bde8a22b184b4bee0',
837 'DeepSeek-V4.1-Flash-1017-GSQ-RCO-IQ2_XXS-00005-of-00006.gguf':
838 '7e35f6b4e6fc46f6c4b152b593d84d683c5ece695ae6ad734f556198c041bec1',
839 'DeepSeek-V4.1-Flash-Engram-IQ4_NL.gguf':
840 '3dac4c54893ad7aa89103c0028500eadc78e74112cef3a2930a97e67dc9fb4f3',
841 'mmproj-DeepSeek-V4.1-Flash-1017-BF16.gguf':
842 '0fdc4b7693f287e9665940cf42bd39c546ffe4f0def89c569d7d0e45d60d2df1',
843 }
844
845 if __name__ == '__main__':
846 try:
847 main()
848 except (ValueError, OSError) as e:
849 sys.exit('error: %s' % e)
850