mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
llama-server garde en RAM hôte une copie exacte des états de slot qu'il quitte (KV, état récurrent, points de reprise, brouillon MTP) et la recharge octet pour octet. Mais son défaut de 8 Gio ne tient pas une conversation de 30 à 65 k jetons à côté de l'état d'un vérificateur, d'un sous-agent, d'une tâche ou d'un bench : à la requête suivante il évince la conversation pour sauver l'annexe, et tout est recalculé (30 à 80 s sur un 27B). Rien de ce que voit le modèle ne change ici : seul le bruit de découpage des lots, comme le cache_prompt actuel. - CACHE_RAM (Mio ; vide/auto, nombre passé tel quel, -1, 0) → --cache-ram, seulement si l'aide du moteur le connaît. Loki se tait devant -cram d'EXTRA_ARGS et LLAMA_ARG_CACHE_RAM. L'auto ne descend JAMAIS sous le défaut : il agrandit seulement un modèle tout-GPU (VRAM NVIDIA connue, aucun poids ni KV sur CPU, NGL complet), d'après l'état mesuré dans le GGUF (2,5 × KV à CTX + état récurrent et points de reprise des hybrides), plafonné à 30 % de la RAM (limite cgroup comprise) et à la moitié de la RAM libre. - --slot-save-path LOKI_HOME/slots (0700, purgé au lancement) uniquement si le moteur est en boucle locale ou protégé par clé, et si le dossier existe. Les proxys /v1 et du relais refusent désormais toute action /slots (405). - engineSideJob efface le slot 0 APRÈS le sous-agent, la passe de vérification, la tâche planifiée et le bench (pas après la compaction : rien à y gagner). Synchrone, borné à 2 s, et seulement si : moteur local, build ≥ 8660 lu dans /props, total_slots == 1, slot 0 au repos, aucune requête de Loki en vol (compteur tenu par le chat, les résumés, le bench et les proxys). CACHE_ISOLATE=off le coupe. - usage.prompt_tokens_details.cached_tokens : si la conversation revient avec moins de la moitié en cache, conseil (une fois) de relever CACHE_RAM. - Éditeur de preset : champ « Cache de prompts » à côté d'UBATCH, avec la valeur auto calculée par le serveur (/api/preset/cacheram). - Lecteur GGUF : embedding_length, head_count et dimensions ssm.*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
11 KiB
Go
265 lines
11 KiB
Go
package loki
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
"testing/fstest"
|
||
)
|
||
|
||
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
|
||
--slot-save-path PATH path to save slot kv cache (default: disabled)
|
||
`
|
||
|
||
// denseGPU : un 27B dense de 17 Go sur deux cartes de 24 Go, machine de 64 Go.
|
||
// État : 64 couches × 4 têtes KV × (256+256) × 2 octets = 0,25 Mio par jeton.
|
||
func denseGPU() serveSysInfo {
|
||
return serveSysInfo{
|
||
Help: helpCacheRAM,
|
||
Model: "/models/dense.gguf",
|
||
GGUF: &GGUFInfo{Arch: "qwen3", BlockCount: 64, HeadCountKV: 4, KeyLen: 256, ValLen: 256,
|
||
ContextLength: 262144},
|
||
ModelBytes: 17 << 30,
|
||
RAMMiB: 65536, RAMAvailMiB: 60000,
|
||
VRAMMiB: 2 * 24576,
|
||
ArgEnv: map[string]string{},
|
||
}
|
||
}
|
||
|
||
func TestCacheRAMArgs(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
cfg map[string]string
|
||
extra []string
|
||
si func(*serveSysInfo)
|
||
want []string
|
||
notes int
|
||
}{
|
||
{name: "dense sur GPU, 64 Go, 65k : besoin 40 Gio, plafonné à 30 % de la RAM",
|
||
cfg: map[string]string{"CTX": "65536"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||
{name: "contexte court : le défaut du moteur suffit",
|
||
cfg: map[string]string{"CTX": "8192"}},
|
||
{name: "cache q8_0 : l'état pèse deux fois moins, besoin sous le plafond",
|
||
cfg: map[string]string{"CTX": "32768", "KV_TYPE": "q8_0"}, want: []string{"--cache-ram", "10880"}, notes: 1},
|
||
{name: "même contexte en f16 : plafonné",
|
||
cfg: map[string]string{"CTX": "32768"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||
{name: "-c d'EXTRA_ARGS l'emporte sur CTX",
|
||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-c", "8192"}},
|
||
{name: "MoE, experts sur CPU par -ot : jamais touché (pas de 4096)",
|
||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "exps=CPU"}},
|
||
{name: "-ot mixte avec un élément sur CPU",
|
||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "a=CUDA0,b=CPU"}},
|
||
{name: "-ot vers une autre carte seulement : tout reste en VRAM",
|
||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "blk\\.6.*=CUDA1"},
|
||
want: []string{"--cache-ram", "19660"}, notes: 1},
|
||
{name: "--n-cpu-moe",
|
||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"--n-cpu-moe", "30"}},
|
||
{name: "LLAMA_ARG_N_CPU_MOE",
|
||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_N_CPU_MOE"] = "20" }},
|
||
{name: "cache KV sur CPU",
|
||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-nkvo"}},
|
||
{name: "NGL plus petit que le modèle",
|
||
cfg: map[string]string{"CTX": "65536", "NGL": "40"}},
|
||
{name: "NGL=999 : toutes les couches",
|
||
cfg: map[string]string{"CTX": "65536", "NGL": "999"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||
{name: "modèle trop gros pour la VRAM",
|
||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ModelBytes = 40 << 30 }},
|
||
{name: "pas de VRAM connue (macOS, AMD, sans nvidia-smi)",
|
||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.VRAMMiB = 0 }},
|
||
{name: "GGUF illisible",
|
||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||
{name: "RAM libre trop juste",
|
||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.RAMAvailMiB = 12000 }},
|
||
{name: "CACHE_RAM fixé : passé tel quel",
|
||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, want: []string{"--cache-ram", "12000"}},
|
||
{name: "CACHE_RAM=-1 : sans limite, passé tel quel",
|
||
cfg: map[string]string{"CACHE_RAM": "-1"}, want: []string{"--cache-ram", "-1"}},
|
||
{name: "CACHE_RAM=0 : cache coupé",
|
||
cfg: map[string]string{"CACHE_RAM": "0"}, want: []string{"--cache-ram", "0"}},
|
||
{name: "CACHE_RAM=auto",
|
||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "auto"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||
{name: "CACHE_RAM illisible : ignoré, et dit",
|
||
cfg: map[string]string{"CACHE_RAM": "beaucoup"}, notes: 1},
|
||
{name: "-cram d'EXTRA_ARGS : Loki se tait",
|
||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, extra: []string{"-cram", "2048"}},
|
||
{name: "LLAMA_ARG_CACHE_RAM : la ligne de commande l'écraserait, Loki se tait",
|
||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }},
|
||
{name: "LLAMA_ARG_CACHE_RAM et CACHE_RAM : la variable gagne, et on le dit",
|
||
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }, notes: 1},
|
||
{name: "moteur sans --cache-ram : rien, même fixé",
|
||
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.Help = helpRecent }, notes: 1},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
si := denseGPU()
|
||
if c.si != nil {
|
||
c.si(&si)
|
||
}
|
||
got, notes := cacheRAMArgs(c.cfg, c.extra, si)
|
||
if !reflect.DeepEqual(got, c.want) {
|
||
t.Fatalf("args = %v, attendu %v (notes %v)", got, c.want, notes)
|
||
}
|
||
if len(notes) != c.notes {
|
||
t.Fatalf("notes = %v, attendu %d", notes, c.notes)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Hybride (Qwen3.5) : 16 couches d'attention sur 64, état récurrent des 48
|
||
// autres, compté avec ses points de reprise.
|
||
func TestCacheRAMAutoHybride(t *testing.T) {
|
||
si := denseGPU()
|
||
layers := make([]int, 64)
|
||
for i := range layers {
|
||
if (i+1)%4 == 0 {
|
||
layers[i] = 4
|
||
}
|
||
}
|
||
si.GGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 64, HeadCountKV: 4, HeadCountKVLayers: layers,
|
||
KeyLen: 256, ValLen: 256, FullAttnInterval: 4, Hybrid: true,
|
||
SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16}
|
||
perTok, rec := ggufStateBytes(*si.GGUF, "", "")
|
||
if perTok != 65536 || rec != 156893184 {
|
||
t.Fatalf("état = %v o/jeton, %d o récurrents", perTok, rec)
|
||
}
|
||
// 4 Gio de KV + 10 états récurrents (9 points de reprise + l'état courant),
|
||
// × 2,5.
|
||
n, why := cacheRAMAuto(map[string]string{"CTX": "65536"}, nil, si)
|
||
if n != 13981 {
|
||
t.Fatalf("cacheRAMAuto = %d (%s), attendu 13981", n, why)
|
||
}
|
||
}
|
||
|
||
// Sans key_length, llama.cpp prend n_embd / n_head : nous aussi.
|
||
func TestGGUFStateBytesReplis(t *testing.T) {
|
||
g := GGUFInfo{BlockCount: 2, HeadCountKV: 2, EmbeddingLength: 1024, HeadCount: 8}
|
||
if p, _ := ggufStateBytes(g, "", ""); p != 2*2*(128+128)*2 {
|
||
t.Fatalf("repli n_embd/n_head : %v", p)
|
||
}
|
||
if p, _ := ggufStateBytes(GGUFInfo{BlockCount: 2, HeadCountKV: 2}, "", ""); p != 0 {
|
||
t.Fatalf("dimensions inconnues : %v, attendu 0", p)
|
||
}
|
||
}
|
||
|
||
func TestVRAMFromSMI(t *testing.T) {
|
||
const out = "0, 24576\n1, 16384\n"
|
||
cases := []struct {
|
||
cvd string
|
||
pci bool
|
||
want int64
|
||
}{
|
||
{"", false, 40960},
|
||
{"1", true, 16384},
|
||
{"0,1", true, 40960},
|
||
{"1", false, 16384}, // ordre inconnu : la plus petite carte
|
||
{"0", false, 16384},
|
||
{"GPU-1234", true, 0},
|
||
{"2", true, 0},
|
||
}
|
||
for _, c := range cases {
|
||
if got := vramFromSMI(out, c.cvd, c.pci); got != c.want {
|
||
t.Errorf("vramFromSMI(%q, %v) = %d, attendu %d", c.cvd, c.pci, got, c.want)
|
||
}
|
||
}
|
||
if got := vramFromSMI("[N/A]\n", "", false); got != 0 {
|
||
t.Errorf("sortie illisible : %d", got)
|
||
}
|
||
}
|
||
|
||
func TestCgroupMemMiB(t *testing.T) {
|
||
v2 := fstest.MapFS{
|
||
"sys/fs/cgroup/memory.max": {Data: []byte("17179869184\n")},
|
||
"sys/fs/cgroup/memory.current": {Data: []byte("4294967296\n")},
|
||
}
|
||
if l, c := cgroupMemMiB(v2); l != 16384 || c != 4096 {
|
||
t.Fatalf("v2 = %d/%d", l, c)
|
||
}
|
||
if l, _ := cgroupMemMiB(fstest.MapFS{"sys/fs/cgroup/memory.max": {Data: []byte("max\n")}}); l != 0 {
|
||
t.Fatalf("v2 sans limite = %d", l)
|
||
}
|
||
v1 := fstest.MapFS{"sys/fs/cgroup/memory/memory.limit_in_bytes": {Data: []byte("9223372036854771712\n")}}
|
||
if l, _ := cgroupMemMiB(v1); l != 0 {
|
||
t.Fatalf("v1 sans limite = %d", l)
|
||
}
|
||
}
|
||
|
||
func TestSlotSaveArgs(t *testing.T) {
|
||
want := []string{"--slot-save-path", "/data/slots"}
|
||
cases := []struct {
|
||
name string
|
||
cfg map[string]string
|
||
extra []string
|
||
si func(*serveSysInfo)
|
||
want []string
|
||
}{
|
||
{name: "boucle locale", cfg: map[string]string{"HOST": "127.0.0.1"}, want: want},
|
||
{name: "--host ::1 dans EXTRA_ARGS", extra: []string{"--host", "::1"}, want: want},
|
||
{name: "0.0.0.0 sans clé : ouvert à tous, pas de drapeau", cfg: map[string]string{}},
|
||
{name: "0.0.0.0 avec clé d'API", cfg: map[string]string{"HOST": "0.0.0.0"},
|
||
si: func(s *serveSysInfo) { s.APIKey = "k" }, want: want},
|
||
{name: "EXTRA_ARGS le pose déjà", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||
extra: []string{"--slot-save-path", "/ailleurs"}},
|
||
{name: "CACHE_ISOLATE=off", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_ISOLATE": "off"}},
|
||
{name: "cache coupé : rien à isoler", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}},
|
||
{name: "cache coupé par EXTRA_ARGS", cfg: map[string]string{"HOST": "127.0.0.1"}, extra: []string{"-cram", "0"}},
|
||
{name: "moteur sans le drapeau", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||
si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||
{name: "dossier indisponible", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||
si: func(s *serveSysInfo) { s.SlotDir = "" }},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
|
||
if c.si != nil {
|
||
c.si(&si)
|
||
}
|
||
if got := slotSaveArgs(c.cfg, c.extra, si); !reflect.DeepEqual(got, c.want) {
|
||
t.Fatalf("slotSaveArgs = %v, attendu %v", got, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Place dans la ligne complète : après --api-key, avant EXTRA_ARGS qui garde le
|
||
// dernier mot. Sans aide qui les connaisse, la ligne historique est intacte
|
||
// (TestBuildServeArgs).
|
||
func TestBuildServeArgsCacheRAM(t *testing.T) {
|
||
si := denseGPU()
|
||
si.APIKey, si.SlotDir = "k", "/data/slots"
|
||
args, _, _ := buildServeArgs(map[string]string{"CTX": "65536", "NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", si)
|
||
got := strings.Join(args, " ")
|
||
if !strings.HasSuffix(got, "--api-key k --cache-ram 19660 --slot-save-path /data/slots --jinja") {
|
||
t.Fatalf("ligne = %s", got)
|
||
}
|
||
}
|
||
|
||
func TestPrepareSlotDir(t *testing.T) {
|
||
home := t.TempDir()
|
||
dir := prepareSlotDir(home)
|
||
if dir != filepath.Join(home, "slots") {
|
||
t.Fatalf("dossier = %q", dir)
|
||
}
|
||
stray := filepath.Join(dir, "slot0.bin")
|
||
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if prepareSlotDir(home) == "" {
|
||
t.Fatal("second appel")
|
||
}
|
||
if _, err := os.Stat(stray); !os.IsNotExist(err) {
|
||
t.Fatal("un fichier laissé dans slots/ doit être purgé au lancement")
|
||
}
|
||
// Un fichier à la place du dossier : pas de drapeau, plutôt qu'un moteur
|
||
// qui refuse de démarrer.
|
||
bad := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := prepareSlotDir(bad); got != "" {
|
||
t.Fatalf("slots est un fichier : %q", got)
|
||
}
|
||
}
|