m64_ea65644cdec9 / kromoss_server.py
187 lines, 6.8 KBraw
1"""OpenAI-style /v1/images/generations server for Kromoss + the 8-step DMD2 LoRA (m64 capsule).23Ported from an earlier standalone server, run there with MOSS_LORA=dmd2.4Differences from the original:5- weights (and the LoRA, which ships in the same repo) load from /weights/kromoss, an6 m64 artifact mount; port from M64_PORT;7- only the DMD2 variant: size, steps, guidance and shift are baked constants (the capsule's8 [params]); a request carries only `prompt` and `seed` (the m64 front rejects9 anything else with a 400);10- progress is reported as JSON lines appended to M64_STATUS.1112Kept exactly from the original (and the model card): the pipeline is the repo's own13pipeline_moss_image.py via trust_remote_code; transformer and text encoder bf16, VAE fp32;14the scheduler is rebuilt from its own config (flow-matching Euler, shift 5); the LoRA is15applied UNMERGED through forward hooks, because its delta is close to bf16's rounding step16and merging would lose most of it; the initial noise comes from a CPU generator; under17CFG 1 there is no negative branch, so uncond_skip_layers is None.18"""1920import base6421import io22import json23import os24import secrets25import threading26import time27import traceback28from typing import Optional2930import torch31import uvicorn32from fastapi import FastAPI, HTTPException33from fastapi.responses import JSONResponse34from pydantic import BaseModel, Field35from safetensors.torch import load_file3637SERVED_MODEL_ID = "kromoss-dmd2"38WEIGHTS = "/weights/kromoss"39LORA_NAME = "8-steps-dmd2-lora"40LORA_PATH = os.path.join(WEIGHTS, LORA_NAME + ".safetensors")41PORT = int(os.environ["M64_PORT"])42STATUS = os.environ["M64_STATUS"]4344# Baked generation settings: the README's DMD2 row. Keep in sync with [params] in capsule.toml.45SIZE = "1024x1024"46NUM_INFERENCE_STEPS = 847GUIDANCE_SCALE = 1.048SHIFT = 5.049WIDTH, HEIGHT = (int(x) for x in SIZE.split("x"))50MAX_SEED = 2**63 - 15152app = FastAPI()53pipe = None54lock = threading.Lock() # one generation at a time on the single pipeline555657def status(phase: str, **extra) -> None:58 with open(STATUS, "a") as f:59 f.write(json.dumps({"phase": phase, **extra}) + "\n")606162def apply_lora(transformer, path: str, strength: float = 1.0) -> int:63 """Kromoss's own apply_lora: one forward hook per adapted module, never merged."""64 state = load_file(path, device="cuda")65 modules = dict(transformer.named_modules())66 n = 067 for key in [k for k in state if k.endswith(".lora_down.weight")]:68 name = key[len("diffusion_model."):-len(".lora_down.weight")]69 down, up = state[key].float(), state[key.replace("lora_down", "lora_up")].float()70 scale = strength * state[f"diffusion_model.{name}.alpha"].item() / down.shape[0]7172 def hook(module, args, out, down=down, up=up, scale=scale):73 return out + (scale * (args[0].float() @ down.T) @ up.T).to(out.dtype)7475 modules[name].register_forward_hook(hook)76 n += 177 return n787980def run(prompt: str, seed: int, steps: int = NUM_INFERENCE_STEPS):81 # CPU generator: the card generates the initial noise on CPU in float32.82 return pipe(83 prompt, height=HEIGHT, width=WIDTH, num_inference_steps=steps,84 guidance_scale=GUIDANCE_SCALE, negative_prompt="", num_images_per_prompt=1,85 generator=torch.Generator("cpu").manual_seed(seed),86 uncond_skip_layers=None, # CFG 1: no negative branch at all87 ).images888990def load_model() -> None:91 global pipe92 from diffusers import DiffusionPipeline9394 status("loading", progress=0.0, detail="loading Kromoss (10 GB transformer + 17 GB Qwen3.5-9B encoder)")95 print("Loading Kromoss...", flush=True)96 t0 = time.time()97 p = DiffusionPipeline.from_pretrained(98 WEIGHTS, custom_pipeline="pipeline_moss_image", trust_remote_code=True,99 torch_dtype={"default": torch.bfloat16, "vae": torch.float32},100 )101 # The card rebuilds the scheduler from its own config before use.102 p.scheduler = type(p.scheduler).from_config(p.scheduler.config)103 shift = p.scheduler.config.get("shift") if isinstance(p.scheduler.config, dict) else getattr(p.scheduler.config, "shift", None)104 if shift != SHIFT:105 raise RuntimeError(f"scheduler shift is {shift}, expected {SHIFT}")106 status("loading", progress=0.8, detail="moving to GPU, hooking the DMD2 LoRA")107 p.to("cuda")108 hooked = apply_lora(p.transformer, LORA_PATH)109 if hooked == 0:110 raise RuntimeError("DMD2 LoRA hooked no modules")111 pipe = p112 print(f"Model loaded in {time.time() - t0:.1f}s | LoRA modules hooked: {hooked} | shift {shift}", flush=True)113114 status("warming", detail="one short warm-up generation")115 t0 = time.time()116 with lock:117 run("warm-up", 0, steps=2)118 print(f"Warm-up done in {time.time() - t0:.1f}s.", flush=True)119120121class ImageRequest(BaseModel):122 prompt: str = Field(..., min_length=1, description="Text prompt")123 seed: Optional[int] = Field(None, ge=0, le=MAX_SEED, description="RNG seed. null = random; the one used is returned.")124125126def baked() -> dict:127 return {"size": SIZE, "num_inference_steps": NUM_INFERENCE_STEPS,128 "guidance_scale": GUIDANCE_SCALE, "shift": SHIFT, "lora": LORA_NAME}129130131@app.get("/health")132def health():133 if pipe is None:134 raise HTTPException(503, "Model not loaded")135 return {"status": "ok", "model": SERVED_MODEL_ID}136137138@app.get("/v1/config")139def config():140 return {141 "model": SERVED_MODEL_ID,142 "params": baked(),143 "request_fields": {144 "prompt": {"required": True, "description": "Text prompt (Qwen3.5-9B encoder, up to 4096 tokens)"},145 "seed": {"required": False, "default": None, "description": "RNG seed; null = random"},146 },147 }148149150@app.post("/v1/images/generations")151def generate(req: ImageRequest):152 if pipe is None:153 raise HTTPException(503, "Model not loaded")154 seed = req.seed if req.seed is not None else secrets.randbelow(2**31)155 t0 = time.time()156 with lock:157 images = run(req.prompt, seed)158 elapsed = time.time() - t0159 print(f"Generated {len(images)} image(s) in {elapsed:.1f}s (seed {seed})", flush=True)160 data = []161 for img in images:162 buf = io.BytesIO()163 img.save(buf, format="PNG")164 data.append({"b64_json": base64.b64encode(buf.getvalue()).decode()})165 return JSONResponse({166 "created": int(time.time()),167 "model": SERVED_MODEL_ID,168 "seed": seed,169 "elapsed_seconds": round(elapsed, 2),170 "data": data,171 "params_used": {**baked(), "seed": seed},172 })173174175def main() -> None:176 try:177 load_model()178 except Exception:179 traceback.print_exc()180 status("error", detail="model load failed; see container logs")181 raise182 status("ready")183 uvicorn.run(app, host="0.0.0.0", port=PORT) # the gate reaches us over the capsule network184185186if __name__ == "__main__":187 main()