diff --git a/.gitignore b/.gitignore index 667044d..3065eb6 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ stats*.txt /quant_test.py /profile_run.py /sweep.py +c/models.json diff --git a/README.md b/README.md index 8dff2d2..5ceeaed 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,18 @@ cd c It prints per-task accuracy (log-likelihood scoring, EleutherAI-harness style). Published full-precision GLM-5.2 scores on these tasks sit around 85–95%; if our int4 container lands within a few points, the quantization is validated — if it doesn't, we know to invest in mixed / grouped-scale quantization. **If you have the hardware to run this, please open an issue with the numbers** — it's the measurement the project is missing. +## More models — the roadmap + +colibrì's niche is precise: **MoE models too big for your RAM**. Dense models that fit in RAM (Qwen3.6-27B, etc.) are better served by llama.cpp/ollama — colibrì's streaming adds nothing there, and we won't pretend otherwise. But every big MoE is prey: + +| model | status | why it fits | +|---|---|---| +| **GLM-5.2** (744B, 40B active) | ✅ ready | the flagship: 370 GB streamed through 25 GB of RAM | +| **gpt-oss-120b** (117B, 5.1B active) | 🔨 next engine | ~63 GB int4, experts of 12 MB, **~1.8 GB/token — 6× lighter than GLM**: on the same dev box this should be the first *daily-drivable* colibrì model (~2 s/token cold, sub-second warm) | +| **Qwen3.6-35B-A3B** (35B MoE, 3B active) | 🔭 candidate | 256 tiny experts (1.6 MB): would serve 16 GB machines | + +Each architecture gets **its own engine file and binary** (like `glm.c` → `glm`), built with the same method: tiny-random oracle from `transformers`, token-exact validation, then real weights. Engines share the foundations (safetensors streaming, quant kernels, expert LRU + learning cache, tokenizer, serve protocol) but never touch each other. `coli models` manages the registry; with more than one model installed, `coli chat` asks which one to wake up. + ## Supporting the project colibrì is a one-person project, written and tested entirely on a 12-core laptop with 25 GB of RAM — the numbers above are the ceiling of what I can measure at home. If this project is useful or interesting to you and you'd like to support its development (better test hardware translates *directly* into a faster engine for everyone: real NVMe scaling data, bigger pinned caches, int2/int3 quality sweeps on real benchmarks), you can: diff --git a/c/coli b/c/coli index d56c810..9cdf8af 100644 --- a/c/coli +++ b/c/coli @@ -8,6 +8,7 @@ CLI per far girare GLM-5.2 (744B) in locale, su CPU, in ~15-26 GB di RAM. coli info stato: modello, RAM, disco, config coli bench [task...] benchmark di qualità (MMLU/HellaSwag/...) coli convert converte GLM-5.2-FP8 -> int4 (streaming) + coli models [add ] registro multi-modello (scelta all'avvio della chat) coli build compila il motore Config via env o flag (validi anche dopo il sottocomando): @@ -87,13 +88,70 @@ def hline(w): return f"{C.dgray}{'─'*w}{C.r}" # ---------- util ---------- def term_w(): return min(shutil.get_terminal_size((80,20)).columns, 100) +# ---------- registro multi-modello ---------- +# Ogni ARCHITETTURA ha il SUO motore (file C e binario separati): i modelli non si +# toccano tra loro. Il registro e' models.json accanto a coli: {nome: {dir, arch}}. +# coli models lista +# coli models add [nome] registra (arch letta da config.json) +# coli chat 1 modello -> parte; piu' modelli -> scegli all'avvio +ENGINES = { # arch (config.json) -> binario del motore + "glm_moe_dsa": "glm", # GLM-5.2 (MLA + DSA + MTP) — PRONTO + "gpt_oss": "gptoss", # gpt-oss-120b (GQA+sinks, MoE 128e) — in arrivo + "qwen3_5_moe": "qwenmoe", # Qwen3.6-A3B (MoE 256e) — in arrivo +} +def model_arch(d): + try: + c=json.load(open(os.path.join(d,"config.json"))) + return c.get("model_type") or c.get("text_config",{}).get("model_type") or "?" + except Exception: return "?" +def models_load(): + reg={} + p=os.path.join(HERE,"models.json") + if os.path.exists(p): + try: reg=json.load(open(p)) + except Exception: reg={} + # il modello di default e' sempre registrato se esiste su disco + if DEF_MODEL and os.path.isdir(DEF_MODEL) and not any(v.get("dir")==DEF_MODEL for v in reg.values()): + reg.setdefault(os.path.basename(DEF_MODEL), {"dir":DEF_MODEL, "arch":model_arch(DEF_MODEL)}) + return reg +def models_save(reg): + json.dump(reg, open(os.path.join(HERE,"models.json"),"w"), indent=1) +def engine_for(model_dir): + arch=model_arch(model_dir) + binname=ENGINES.get(arch) + if not binname: + sys.exit(f"{C.yel}architettura '{arch}' non (ancora) supportata.{C.r} Motori pronti: " + +", ".join(f"{k}->{v}" for k,v in ENGINES.items())) + path=os.path.join(HERE,binname) + if not os.path.exists(path): + sys.exit(f"{C.yel}motore '{binname}' non compilato per l'architettura {arch}.{C.r}" + +(" Esegui: coli build" if binname=="glm" else " (motore in sviluppo)")) + return path +def pick_model(a): + """se --model/COLI_MODEL e' esplicito usa quello; altrimenti: 1 registrato -> quello, + piu' d'uno -> menu di scelta all'avvio della chat (zero passaggi extra).""" + if a.model != DEF_MODEL or not sys.stdin.isatty(): + return a.model + reg=models_load() + avail=[(n,v) for n,v in reg.items() if os.path.isdir(v.get("dir",""))] + if len(avail)<=1: + return avail[0][1]["dir"] if avail else a.model + print(f" {C.dim}modelli disponibili:{C.r}") + for i,(n,v) in enumerate(avail): + eng=ENGINES.get(v.get("arch","?"),"?") + ok = "✓" if os.path.exists(os.path.join(HERE,eng)) else "motore in sviluppo" + print(f" {C.teal}{i+1}{C.r}) {n} {C.dgray}· {v.get('arch','?')} · {ok}{C.r}") + try: ch=input(f" {C.teal}›{C.r} quale? [1] ").strip() or "1" + except EOFError: ch="1" + try: return avail[max(0,min(len(avail)-1,int(ch)-1))][1]["dir"] + except ValueError: return avail[0][1]["dir"] + def need_model(model): if not os.path.isdir(model): sys.exit(f"{C.yel}modello non trovato:{C.r} {model}\n imposta COLI_MODEL o usa --model") if not os.path.exists(os.path.join(model,"tokenizer.json")): sys.exit(f"{C.yel}manca tokenizer.json in {model}{C.r}") - if not os.path.exists(GLM): - sys.exit(f"{C.yel}motore non compilato.{C.r} Esegui: coli build") + engine_for(model) # verifica che il motore per QUESTA architettura esista def env_for(a): e = dict(os.environ, SNAP=a.model) @@ -180,19 +238,21 @@ def cmd_info(a): print() def cmd_run(a): + a.model=pick_model(a) need_model(a.model) prompt=" ".join(a.prompt) if a.prompt else sys.exit('uso: coli run "il tuo prompt"') banner("run") # template ufficiale GLM-5.2: niente \n dopo i ruoli; = risposta diretta (nothink) e=env_for(a); e["PROMPT"]=f"[gMASK]<|user|>{prompt}<|assistant|>" - sys.exit(subprocess.call([GLM, str(a.cap)], env=e)) + sys.exit(subprocess.call([engine_for(a.model), str(a.cap)], env=e)) def cmd_chat(a): + a.model=pick_model(a) need_model(a.model) banner(f"chat · {os.path.basename(a.model)} · ram {a.ram or '-'}GB · topp {a.topp or 'off'}") errlog=tempfile.NamedTemporaryFile(mode="w+", suffix=".log", delete=False) e=env_for(a); e["SERVE"]="1" - p=subprocess.Popen([GLM,str(a.cap)], env=e, stdin=subprocess.PIPE, + p=subprocess.Popen([engine_for(a.model),str(a.cap)], env=e, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errlog, bufsize=0) sp=Spinner("sveglio il gigante (744B)…"); sp.start() st=stream_turn(p, READY, lambda b: None) @@ -307,6 +367,28 @@ def cmd_bench(a): print(f" {C.dim}decode disk-bound: su hardware lento questo richiede ORE. Alza --limit su macchine veloci.{C.r}\n") sys.exit(subprocess.call(cmd, env=e)) +def cmd_models(a): + banner("models") + reg=models_load() + if a.action and a.action[0]=="add": + if len(a.action)<2: sys.exit("uso: coli models add [nome]") + d=os.path.abspath(a.action[1]) + if not os.path.isdir(d): sys.exit(f"directory non trovata: {d}") + name=a.action[2] if len(a.action)>2 else os.path.basename(d) + reg[name]={"dir":d,"arch":model_arch(d)} + models_save(reg) + print(f" {C.grn}✓{C.r} registrato: {name} · {reg[name]['arch']}\n") + return + if not reg: print(f" {C.dim}nessun modello registrato (coli models add ){C.r}\n"); return + for n,v in reg.items(): + eng=ENGINES.get(v.get("arch","?")) + st = "pronto ✓" if eng and os.path.exists(os.path.join(HERE,eng)) else \ + ("motore in sviluppo" if eng else "architettura non supportata") + ex = "" if os.path.isdir(v.get("dir","")) else " · DIR MANCANTE" + print(f" {C.teal}{n:<18}{C.r} {C.gray}{v.get('arch','?'):<14}{C.r} {st}{C.yel}{ex}{C.r}") + print(f" {C.dgray}{'':<18} {v.get('dir','')}{C.r}") + print() + def cmd_convert(a): banner("convert") # python con torch/safetensors: l'ambiente del progetto se c'e', altrimenti quello corrente @@ -335,6 +417,7 @@ def main(): sub.add_parser("build", parents=[common]); sub.add_parser("info", parents=[common]) pr=sub.add_parser("run", parents=[common]); pr.add_argument("prompt", nargs="*") sub.add_parser("chat", parents=[common]) + pm=sub.add_parser("models", parents=[common]); pm.add_argument("action", nargs="*") pb=sub.add_parser("bench", parents=[common]); pb.add_argument("tasks", nargs="*") pb.add_argument("--limit",type=int,default=40); pb.add_argument("--data",default=os.path.join(HERE,"bench")) pc=sub.add_parser("convert", parents=[common]); pc.add_argument("--repo",default="zai-org/GLM-5.2-FP8") @@ -342,7 +425,7 @@ def main(): pc.add_argument("--no-mtp",action="store_true",help="salta la testa MTP (niente draft speculativi)") a=ap.parse_args() {"build":cmd_build,"info":cmd_info,"run":cmd_run,"chat":cmd_chat,"bench":cmd_bench, - "convert":cmd_convert}.get(a.cmd, lambda _:(banner(),print(__doc__)))(a) + "convert":cmd_convert,"models":cmd_models}.get(a.cmd, lambda _:(banner(),print(__doc__)))(a) if __name__=="__main__": signal.signal(signal.SIGINT, signal.default_int_handler)