m64_67cfbdf8a0d8 / kromoss_server.py
189 lines, 6.9 KBraw
1"""OpenAI-style /v1/images/generations server for Kromoss + the 8-step DMD2 LoRA.23What makes it an m64 capsule server:4- the weights (and the LoRA, which ships in the same repo) load from /weights/kromoss,5 the read-only mount of the capsule's [[artifacts]]; nothing is downloaded here;6- it starts listening on $M64_PORT only once the model is loaded; m64 polls /health7 until it answers 200;8- size, steps, guidance and shift are baked constants, the same values as [params]9 in capsule.toml; a request carries only `prompt` and `seed` (m64's front refuses10 anything else with a 400), and every answer returns them as `params_used`;11- progress goes to $M64_STATUS as JSON lines (loading, warming, ready, error), which12 `m64 status` and the /status route show.1314Kept exactly from the model card: the pipeline is the repo's own15pipeline_moss_image.py via trust_remote_code; transformer and text encoder bf16, VAE fp32;16the scheduler is rebuilt from its own config (flow-matching Euler, shift 5); the LoRA is17applied UNMERGED through forward hooks, because its delta is close to bf16's rounding step18and merging would lose most of it; the initial noise comes from a CPU generator; under19CFG 1 there is no negative branch, so uncond_skip_layers is None.20"""2122import base6423import io24import json25import os26import secrets27import threading28import time29import traceback30from typing import Optional3132import torch33import uvicorn34from fastapi import FastAPI, HTTPException35from fastapi.responses import JSONResponse36from pydantic import BaseModel, Field37from safetensors.torch import load_file3839SERVED_MODEL_ID = "kromoss-dmd2"40WEIGHTS = "/weights/kromoss"41LORA_NAME = "8-steps-dmd2-lora"42LORA_PATH = os.path.join(WEIGHTS, LORA_NAME + ".safetensors")43PORT = int(os.environ["M64_PORT"])44STATUS = os.environ["M64_STATUS"]4546# Baked generation settings: the README's DMD2 row. Keep in sync with [params] in capsule.toml.47SIZE = "1024x1024"48NUM_INFERENCE_STEPS = 849GUIDANCE_SCALE = 1.050SHIFT = 5.051WIDTH, HEIGHT = (int(x) for x in SIZE.split("x"))52MAX_SEED = 2**63 - 15354app = FastAPI()55pipe = None56lock = threading.Lock() # one generation at a time on the single pipeline575859def status(phase: str, **extra) -> None:60 with open(STATUS, "a") as f:61 f.write(json.dumps({"phase": phase, **extra}) + "\n")626364def apply_lora(transformer, path: str, strength: float = 1.0) -> int:65 """Kromoss's own apply_lora: one forward hook per adapted module, never merged."""66 state = load_file(path, device="cuda")67 modules = dict(transformer.named_modules())68 n = 069 for key in [k for k in state if k.endswith(".lora_down.weight")]:70 name = key[len("diffusion_model."):-len(".lora_down.weight")]71 down, up = state[key].float(), state[key.replace("lora_down", "lora_up")].float()72 scale = strength * state[f"diffusion_model.{name}.alpha"].item() / down.shape[0]7374 def hook(module, args, out, down=down, up=up, scale=scale):75 return out + (scale * (args[0].float() @ down.T) @ up.T).to(out.dtype)7677 modules[name].register_forward_hook(hook)78 n += 179 return n808182def run(prompt: str, seed: int, steps: int = NUM_INFERENCE_STEPS):83 # CPU generator: the card generates the initial noise on CPU in float32.84 return pipe(85 prompt, height=HEIGHT, width=WIDTH, num_inference_steps=steps,86 guidance_scale=GUIDANCE_SCALE, negative_prompt="", num_images_per_prompt=1,87 generator=torch.Generator("cpu").manual_seed(seed),88 uncond_skip_layers=None, # CFG 1: no negative branch at all89 ).images909192def load_model() -> None:93 global pipe94 from diffusers import DiffusionPipeline9596 status("loading", progress=0.0, detail="loading Kromoss (10 GB transformer + 17 GB Qwen3.5-9B encoder)")97 print("Loading Kromoss...", flush=True)98 t0 = time.time()99 p = DiffusionPipeline.from_pretrained(100 WEIGHTS, custom_pipeline="pipeline_moss_image", trust_remote_code=True,101 torch_dtype={"default": torch.bfloat16, "vae": torch.float32},102 )103 # The card rebuilds the scheduler from its own config before use.104 p.scheduler = type(p.scheduler).from_config(p.scheduler.config)105 shift = p.scheduler.config.get("shift") if isinstance(p.scheduler.config, dict) else getattr(p.scheduler.config, "shift", None)106 if shift != SHIFT:107 raise RuntimeError(f"scheduler shift is {shift}, expected {SHIFT}")108 status("loading", progress=0.8, detail="moving to GPU, hooking the DMD2 LoRA")109 p.to("cuda")110 hooked = apply_lora(p.transformer, LORA_PATH)111 if hooked == 0:112 raise RuntimeError("DMD2 LoRA hooked no modules")113 pipe = p114 print(f"Model loaded in {time.time() - t0:.1f}s | LoRA modules hooked: {hooked} | shift {shift}", flush=True)115116 status("warming", detail="one short warm-up generation")117 t0 = time.time()118 with lock:119 run("warm-up", 0, steps=2)120 print(f"Warm-up done in {time.time() - t0:.1f}s.", flush=True)121122123class ImageRequest(BaseModel):124 prompt: str = Field(..., min_length=1, description="Text prompt")125 seed: Optional[int] = Field(None, ge=0, le=MAX_SEED, description="RNG seed. null = random; the one used is returned.")126127128def baked() -> dict:129 return {"size": SIZE, "num_inference_steps": NUM_INFERENCE_STEPS,130 "guidance_scale": GUIDANCE_SCALE, "shift": SHIFT, "lora": LORA_NAME}131132133@app.get("/health")134def health():135 if pipe is None:136 raise HTTPException(503, "Model not loaded")137 return {"status": "ok", "model": SERVED_MODEL_ID}138139140@app.get("/v1/config")141def config():142 return {143 "model": SERVED_MODEL_ID,144 "params": baked(),145 "request_fields": {146 "prompt": {"required": True, "description": "Text prompt (Qwen3.5-9B encoder, up to 4096 tokens)"},147 "seed": {"required": False, "default": None, "description": "RNG seed; null = random"},148 },149 }150151152@app.post("/v1/images/generations")153def generate(req: ImageRequest):154 if pipe is None:155 raise HTTPException(503, "Model not loaded")156 seed = req.seed if req.seed is not None else secrets.randbelow(2**31)157 t0 = time.time()158 with lock:159 images = run(req.prompt, seed)160 elapsed = time.time() - t0161 print(f"Generated {len(images)} image(s) in {elapsed:.1f}s (seed {seed})", flush=True)162 data = []163 for img in images:164 buf = io.BytesIO()165 img.save(buf, format="PNG")166 data.append({"b64_json": base64.b64encode(buf.getvalue()).decode()})167 return JSONResponse({168 "created": int(time.time()),169 "model": SERVED_MODEL_ID,170 "seed": seed,171 "elapsed_seconds": round(elapsed, 2),172 "data": data,173 "params_used": {**baked(), "seed": seed},174 })175176177def main() -> None:178 try:179 load_model()180 except Exception:181 traceback.print_exc()182 status("error", detail="model load failed; see container logs")183 raise184 status("ready")185 uvicorn.run(app, host="0.0.0.0", port=PORT) # the gate reaches us over the capsule network186187188if __name__ == "__main__":189 main()