mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 06:58:14 +02:00
La relecture n'a trouvé aucun défaut dans le lecteur lui-même : bornes, versions, tranches, cache et vérification du début des données tiennent. Trois cas réels restaient pourtant sans test. - GGUF v2 : même disposition que v3, désormais vérifié avec v1 et gros-boutiste. - gguf-split --no-tensor-first-split : la première tranche ne porte que les clés, aucun tenseur ; la tête MTP est trouvée dans la suivante. - Un octet corrompu à chaque position de l'en-tête (v1 et v3, valeurs 0xFF, 0x7F, 0x00) : le lecteur rend la main sans jamais tomber dans le filet du panic rattrapé, qui ne doit rester qu'un filet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
13 KiB
Go
418 lines
13 KiB
Go
package loki
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ggufBuilder écrit un GGUF synthétique : en-tête, clés typées, table des
|
|
// tenseurs, sans aucune donnée de poids (le lecteur n'en lit jamais).
|
|
type ggufBuilder struct {
|
|
bo binary.ByteOrder
|
|
version uint32
|
|
nKV int
|
|
nT int
|
|
kv bytes.Buffer
|
|
tensors bytes.Buffer
|
|
}
|
|
|
|
func newGGUF() *ggufBuilder { return &ggufBuilder{bo: binary.LittleEndian, version: 3} }
|
|
|
|
func (b *ggufBuilder) cnt(w *bytes.Buffer, n uint64) {
|
|
if b.version == 1 {
|
|
binary.Write(w, b.bo, uint32(n))
|
|
} else {
|
|
binary.Write(w, b.bo, n)
|
|
}
|
|
}
|
|
|
|
func (b *ggufBuilder) str(w *bytes.Buffer, s string) {
|
|
b.cnt(w, uint64(len(s)))
|
|
w.WriteString(s)
|
|
}
|
|
|
|
func (b *ggufBuilder) key(k string, t uint32) {
|
|
b.nKV++
|
|
b.str(&b.kv, k)
|
|
binary.Write(&b.kv, b.bo, t)
|
|
}
|
|
|
|
func (b *ggufBuilder) u32(k string, v uint32) *ggufBuilder {
|
|
b.key(k, ggufUint32)
|
|
binary.Write(&b.kv, b.bo, v)
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) i64(k string, v int64) *ggufBuilder {
|
|
b.key(k, ggufInt64)
|
|
binary.Write(&b.kv, b.bo, v)
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) f32(k string, v float32) *ggufBuilder {
|
|
b.key(k, ggufFloat32)
|
|
binary.Write(&b.kv, b.bo, math.Float32bits(v))
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) boolean(k string, v bool) *ggufBuilder {
|
|
b.key(k, ggufBool)
|
|
if v {
|
|
b.kv.WriteByte(1)
|
|
} else {
|
|
b.kv.WriteByte(0)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) s(k, v string) *ggufBuilder {
|
|
b.key(k, ggufString)
|
|
b.str(&b.kv, v)
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) strArr(k string, vs []string) *ggufBuilder {
|
|
b.key(k, ggufArray)
|
|
binary.Write(&b.kv, b.bo, ggufString)
|
|
b.cnt(&b.kv, uint64(len(vs)))
|
|
for _, v := range vs {
|
|
b.str(&b.kv, v)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) i32Arr(k string, vs []int32) *ggufBuilder {
|
|
b.key(k, ggufArray)
|
|
binary.Write(&b.kv, b.bo, ggufInt32)
|
|
b.cnt(&b.kv, uint64(len(vs)))
|
|
for _, v := range vs {
|
|
binary.Write(&b.kv, b.bo, v)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// nested : tableau de deux tableaux de u8, pour vérifier le saut récursif.
|
|
func (b *ggufBuilder) nested(k string) *ggufBuilder {
|
|
b.key(k, ggufArray)
|
|
binary.Write(&b.kv, b.bo, ggufArray)
|
|
b.cnt(&b.kv, 2)
|
|
for i := 0; i < 2; i++ {
|
|
binary.Write(&b.kv, b.bo, ggufUint8)
|
|
b.cnt(&b.kv, 3)
|
|
b.kv.Write([]byte{1, 2, 3})
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) tensor(name string, dims ...uint64) *ggufBuilder {
|
|
b.str(&b.tensors, name)
|
|
binary.Write(&b.tensors, b.bo, uint32(len(dims)))
|
|
for _, d := range dims {
|
|
if b.version == 1 {
|
|
binary.Write(&b.tensors, b.bo, uint32(d))
|
|
} else {
|
|
binary.Write(&b.tensors, b.bo, d)
|
|
}
|
|
}
|
|
binary.Write(&b.tensors, b.bo, uint32(0)) // type F32
|
|
binary.Write(&b.tensors, b.bo, uint64(b.nT*32)) // position des données
|
|
b.nT++
|
|
return b
|
|
}
|
|
|
|
func (b *ggufBuilder) bytes() []byte {
|
|
var out bytes.Buffer
|
|
out.WriteString("GGUF")
|
|
binary.Write(&out, b.bo, b.version)
|
|
b.cnt(&out, uint64(b.nT))
|
|
b.cnt(&out, uint64(b.nKV))
|
|
out.Write(b.kv.Bytes())
|
|
out.Write(b.tensors.Bytes())
|
|
// bourrage jusqu'à l'alignement par défaut, puis 32 octets de « poids » par
|
|
// tenseur
|
|
if b.nT > 0 {
|
|
out.Write(make([]byte, (32-out.Len()%32)%32+32*b.nT))
|
|
}
|
|
return out.Bytes()
|
|
}
|
|
|
|
func writeGGUF(t *testing.T, dir, name string, data []byte) string {
|
|
t.Helper()
|
|
p := filepath.Join(dir, name)
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// qwenMTP : modèle hybride à 4 couches, têtes KV par couche (0 sur les couches
|
|
// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter.
|
|
func qwenMTP(b *ggufBuilder) *ggufBuilder {
|
|
return b.u32("general.alignment", 32).
|
|
strArr("tokenizer.ggml.tokens", []string{"<s>", "a", "bb", strings.Repeat("c", 300)}).
|
|
s("general.architecture", "qwen35").
|
|
s("general.name", "Qwen synthétique").
|
|
u32("qwen35.block_count", 4).
|
|
u32("qwen35.nextn_predict_layers", 1).
|
|
i64("qwen35.context_length", 262144).
|
|
u32("qwen35.attention.key_length", 256).
|
|
u32("qwen35.attention.value_length", 256).
|
|
i32Arr("qwen35.attention.head_count_kv", []int32{0, 0, 0, 4}).
|
|
u32("qwen35.full_attention_interval", 4).
|
|
u32("qwen35.ssm.conv_kernel", 4).
|
|
f32("qwen35.rope.freq_base", 1e7).
|
|
boolean("tokenizer.ggml.add_bos_token", false).
|
|
nested("bidon.imbrique").
|
|
tensor("token_embd.weight", 1024, 32000).
|
|
tensor("blk.0.attn_norm.weight", 1024).
|
|
tensor("blk.3.nextn.eh_proj.weight", 2048, 1024)
|
|
}
|
|
|
|
func TestGGUFMetaQwenHybrideMTP(t *testing.T) {
|
|
p := writeGGUF(t, t.TempDir(), "qwen.gguf", qwenMTP(newGGUF()).bytes())
|
|
got, err := ggufMeta(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := GGUFInfo{
|
|
Version: 3, Arch: "qwen35", BlockCount: 4, NextN: 1, ContextLength: 262144,
|
|
KeyLen: 256, ValLen: 256, HeadCountKV: 4, HeadCountKVLayers: []int{0, 0, 0, 4},
|
|
FullAttnInterval: 4, Hybrid: true, HasNextNTensor: true, TensorCount: 3,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ggufMeta =\n%+v\nattendu\n%+v", got, want)
|
|
}
|
|
}
|
|
|
|
// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la DERNIÈRE
|
|
// couche compte, comme dans llama.cpp.
|
|
func TestGGUFMetaTeteMTPAbsenteOuMalPlacee(t *testing.T) {
|
|
dir := t.TempDir()
|
|
base := func() *ggufBuilder {
|
|
return newGGUF().s("general.architecture", "glm4moe").
|
|
u32("glm4moe.block_count", 47).
|
|
u32("glm4moe.nextn_predict_layers", 1).
|
|
u32("glm4moe.expert_count", 128).
|
|
u32("glm4moe.attention.head_count_kv", 8)
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
b *ggufBuilder
|
|
}{
|
|
{"sans-tenseur.gguf", base().tensor("blk.45.ffn_up.weight", 8, 8)},
|
|
{"mauvaise-couche.gguf", base().tensor("blk.45.nextn.eh_proj.weight", 8, 8)},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := ggufMeta(writeGGUF(t, dir, c.name, c.b.bytes()))
|
|
if err != nil {
|
|
t.Fatalf("%s : %v", c.name, err)
|
|
}
|
|
if got.HasNextNTensor || got.NextN != 1 || got.ExpertCount != 128 || got.HeadCountKV != 8 ||
|
|
got.HeadCountKVLayers != nil || got.Hybrid {
|
|
t.Fatalf("%s : %+v", c.name, got)
|
|
}
|
|
}
|
|
got, err := ggufMeta(writeGGUF(t, dir, "ok.gguf", base().tensor("blk.46.nextn.eh_proj.weight", 8, 8).bytes()))
|
|
if err != nil || !got.HasNextNTensor {
|
|
t.Fatalf("tête MTP en dernière couche non vue : %+v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
// Modèle en tranches : les clés sont dans la première, la tête MTP dans la
|
|
// dernière. N'importe quelle tranche peut être désignée.
|
|
func TestGGUFMetaTranches(t *testing.T) {
|
|
dir := t.TempDir()
|
|
first := newGGUF().s("general.architecture", "qwen35").u32("qwen35.block_count", 4).
|
|
u32("split.count", 2).tensor("token_embd.weight", 8, 8)
|
|
second := newGGUF().u32("split.no", 1).u32("split.count", 2).
|
|
tensor("blk.3.nextn.eh_proj.weight", 8, 8).tensor("output.weight", 8, 8)
|
|
p1 := writeGGUF(t, dir, "m-00001-of-00002.gguf", first.bytes())
|
|
writeGGUF(t, dir, "m-00002-of-00002.gguf", second.bytes())
|
|
for _, p := range []string{p1, filepath.Join(dir, "m-00002-of-00002.gguf")} {
|
|
got, err := ggufMeta(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Arch != "qwen35" || !got.HasNextNTensor || got.TensorCount != 3 {
|
|
t.Fatalf("%s : %+v", baseName(p), got)
|
|
}
|
|
}
|
|
// tranche manquante : on ne sait pas, donc erreur
|
|
writeGGUF(t, dir, "n-00001-of-00002.gguf", first.bytes())
|
|
if _, err := ggufMeta(filepath.Join(dir, "n-00001-of-00002.gguf")); err == nil {
|
|
t.Fatal("tranche manquante acceptée")
|
|
}
|
|
// gguf-split --no-tensor-first-split : la première tranche ne porte que les
|
|
// clés, aucun tenseur ni aucune donnée. Elle doit passer telle quelle.
|
|
meta := newGGUF().s("general.architecture", "glm4moe").u32("glm4moe.block_count", 47).
|
|
u32("split.count", 2)
|
|
rest := newGGUF().u32("split.no", 1).tensor("blk.46.nextn.eh_proj.weight", 8, 8)
|
|
writeGGUF(t, dir, "s-00001-of-00002.gguf", meta.bytes())
|
|
writeGGUF(t, dir, "s-00002-of-00002.gguf", rest.bytes())
|
|
got, err := ggufMeta(filepath.Join(dir, "s-00001-of-00002.gguf"))
|
|
if err != nil || got.Arch != "glm4moe" || !got.HasNextNTensor || got.TensorCount != 1 {
|
|
t.Fatalf("première tranche sans tenseur : %+v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3 ordinaire.
|
|
func TestGGUFMetaVersionsEtOrdre(t *testing.T) {
|
|
dir := t.TempDir()
|
|
v1 := newGGUF()
|
|
v1.version = 1
|
|
v2 := newGGUF()
|
|
v2.version = 2
|
|
be := newGGUF()
|
|
be.bo = binary.BigEndian
|
|
for name, b := range map[string]*ggufBuilder{"v1.gguf": v1, "v2.gguf": v2, "be.gguf": be} {
|
|
got, err := ggufMeta(writeGGUF(t, dir, name, qwenMTP(b).bytes()))
|
|
if err != nil {
|
|
t.Fatalf("%s : %v", name, err)
|
|
}
|
|
if got.Arch != "qwen35" || got.BlockCount != 4 || !got.HasNextNTensor ||
|
|
!reflect.DeepEqual(got.HeadCountKVLayers, []int{0, 0, 0, 4}) || got.Version != int(b.version) {
|
|
t.Fatalf("%s : %+v", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fichier en cours de téléchargement : chaque coupure donne une erreur, jamais
|
|
// un panic ni un faux résultat.
|
|
func TestGGUFMetaTronque(t *testing.T) {
|
|
dir := t.TempDir()
|
|
full := qwenMTP(newGGUF()).bytes()
|
|
// Toutes les coupures jusqu'au premier octet du dernier tenseur (le seul que
|
|
// le lecteur sait situer sans la table des types ggml).
|
|
for cut := 0; cut <= len(full)-32; cut++ {
|
|
if _, err := parseGGUF(bytes.NewReader(full[:cut]), int64(cut)); err == nil {
|
|
t.Fatalf("coupure à %d/%d acceptée", cut, len(full))
|
|
}
|
|
}
|
|
if _, err := parseGGUF(bytes.NewReader(full), int64(len(full))); err != nil {
|
|
t.Fatalf("fichier complet refusé : %v", err)
|
|
}
|
|
// et par le chemin fichier, pour une coupure dans l'en-tête
|
|
if _, err := ggufMeta(writeGGUF(t, dir, "cut.gguf", full[:len(full)/3])); err == nil {
|
|
t.Fatal("fichier coupé accepté")
|
|
}
|
|
}
|
|
|
|
func TestGGUFMetaFichiersAberrants(t *testing.T) {
|
|
dir := t.TempDir()
|
|
hdr := func(ver uint32, nT, nKV uint64) *bytes.Buffer {
|
|
var w bytes.Buffer
|
|
w.WriteString("GGUF")
|
|
binary.Write(&w, binary.LittleEndian, ver)
|
|
binary.Write(&w, binary.LittleEndian, nT)
|
|
binary.Write(&w, binary.LittleEndian, nKV)
|
|
return &w
|
|
}
|
|
// clé annoncée à 1 Eo
|
|
hugeKey := hdr(3, 0, 1)
|
|
binary.Write(hugeKey, binary.LittleEndian, uint64(1)<<60)
|
|
// tableau de chaînes annoncé à 2^62 éléments
|
|
hugeArr := hdr(3, 0, 1)
|
|
binary.Write(hugeArr, binary.LittleEndian, uint64(1))
|
|
hugeArr.WriteString("k")
|
|
binary.Write(hugeArr, binary.LittleEndian, ggufArray)
|
|
binary.Write(hugeArr, binary.LittleEndian, ggufString)
|
|
binary.Write(hugeArr, binary.LittleEndian, uint64(1)<<62)
|
|
// type de valeur inconnu
|
|
badType := hdr(3, 0, 1)
|
|
binary.Write(badType, binary.LittleEndian, uint64(1))
|
|
badType.WriteString("k")
|
|
binary.Write(badType, binary.LittleEndian, uint32(99))
|
|
binary.Write(badType, binary.LittleEndian, uint64(0))
|
|
// tenseur à 1000 dimensions
|
|
badDims := hdr(3, 1, 0)
|
|
binary.Write(badDims, binary.LittleEndian, uint64(1))
|
|
badDims.WriteString("t")
|
|
binary.Write(badDims, binary.LittleEndian, uint32(1000))
|
|
badDims.Write(make([]byte, 64))
|
|
|
|
cases := map[string][]byte{
|
|
"vide": nil,
|
|
"alignement": newGGUF().u32("general.alignment", 24).tensor("t", 1).bytes(),
|
|
"signature": []byte("GGML\x03\x00\x00\x00" + strings.Repeat("\x00", 16)),
|
|
"version": hdr(4, 0, 0).Bytes(),
|
|
"compteurs": hdr(3, 1<<40, 1<<40).Bytes(),
|
|
"cle-geante": hugeKey.Bytes(),
|
|
"tableau": hugeArr.Bytes(),
|
|
"type-inconnu": badType.Bytes(),
|
|
"dimensions": badDims.Bytes(),
|
|
}
|
|
for name, data := range cases {
|
|
if _, err := ggufMeta(writeGGUF(t, dir, name+".gguf", data)); err == nil {
|
|
t.Errorf("%s : accepté", name)
|
|
}
|
|
}
|
|
if _, err := ggufMeta(filepath.Join(dir, "absent.gguf")); err == nil {
|
|
t.Error("fichier absent accepté")
|
|
}
|
|
}
|
|
|
|
// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit rendre
|
|
// la main vite, sans panic remonté ni allocation démesurée. Les valeurs 0xFF et
|
|
// 0x7F transforment un compteur en nombre géant, le cas qui ferait mal.
|
|
func TestGGUFMetaOctetCorrompu(t *testing.T) {
|
|
v1 := newGGUF()
|
|
v1.version = 1
|
|
for _, b := range []*ggufBuilder{newGGUF(), v1} {
|
|
full := qwenMTP(b).bytes()
|
|
hdr := len(full) - 3*32 // le bourrage et les « poids » ne sont jamais lus
|
|
for i := 0; i < hdr; i++ {
|
|
for _, v := range []byte{0xFF, 0x7F, 0x00} {
|
|
mut := append([]byte(nil), full...)
|
|
mut[i] = v
|
|
// le filet de sécurité existe, mais aucune entrée ne doit y tomber
|
|
if _, err := parseGGUF(bytes.NewReader(mut), int64(len(mut))); err != nil &&
|
|
strings.Contains(err.Error(), "illisible") {
|
|
t.Fatalf("v%d, octet %d = %#x : %v", b.version, i, v, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Le cache tient tant que taille et date sont les mêmes, et se renouvelle dès
|
|
// qu'elles changent (téléchargement terminé, fichier remplacé).
|
|
func TestGGUFMetaCache(t *testing.T) {
|
|
dir := t.TempDir()
|
|
data := qwenMTP(newGGUF()).bytes()
|
|
p := writeGGUF(t, dir, "c.gguf", data)
|
|
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
if err := os.Chtimes(p, stamp, stamp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, err := ggufMeta(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.HeadCountKVLayers[0] = 99 // ne doit pas atteindre le cache
|
|
|
|
// même taille, même date, contenu illisible : le cache répond
|
|
writeGGUF(t, dir, "c.gguf", make([]byte, len(data)))
|
|
if err := os.Chtimes(p, stamp, stamp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := ggufMeta(p)
|
|
if err != nil || b.HeadCountKVLayers[0] != 0 || !b.HasNextNTensor {
|
|
t.Fatalf("cache non utilisé ou altéré : %+v, %v", b, err)
|
|
}
|
|
|
|
// date différente : relecture, donc erreur sur le contenu illisible
|
|
if err := os.Chtimes(p, stamp.Add(time.Second), stamp.Add(time.Second)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ggufMeta(p); err == nil {
|
|
t.Fatal("fichier modifié servi depuis le cache")
|
|
}
|
|
}
|