motif64

m64_a970a7a75864 / minimax_h3_image_server.py

189 lines, 7.9 KBraw
1"""OpenAI-style /v1/images/generations server for MiniMax-H3-Image on one GB10 (m64 capsule).23Written for this capsule (gx10 has no start script for this model). It follows diffusers'4MiniMax-H3-Image docs (v0.43.0, the first release with the pipeline) with one change for the5GB10's 119 GB of unified memory: the transformer (~62 GB) and the Qwen3-VL-32B text encoder6(~62 GB) do not both fit in bf16, and CPU offload frees nothing when CPU and GPU share one7memory. So the text encoder is cut down to what the pipeline reads and quantized:8- the pipeline conditions on hidden_states[50] of its 64 decoder layers and never runs the9  language-model head, so only the first 51 layers are loaded (diffusers' encoder step accepts10  any stack of more than 50 layers; hidden_states[50] is unchanged) and the head is dropped;11- those layers load in 4-bit (bitsandbytes nf4, bf16 compute), about 15 GB in all.12The transformer and the VAE stay bf16, as in the docs. All components stay on the GPU, and the13whole pipeline peaks around 85 GB.1415The checkpoint is guidance-distilled and samples on a fixed 6-step grid with one output head16per step: there is no num_inference_steps, guidance_scale or negative_prompt. The size is a17baked constant (the capsule's [params]); a request carries only `prompt` and `seed`.18Progress is reported as JSON lines appended to M64_STATUS.19"""2021import base6422import io23import json24import os25import secrets26import threading27import time28import traceback29from typing import Optional3031import torch32import uvicorn33from fastapi import FastAPI, HTTPException34from fastapi.responses import JSONResponse35from pydantic import BaseModel, Field3637SERVED_MODEL_ID = "minimax-h3-image"38WEIGHTS = "/weights/minimax-h3-image"39PORT = int(os.environ["M64_PORT"])40STATUS = os.environ["M64_STATUS"]4142# Baked generation settings. Keep in sync with [params] in capsule.toml.43SIZE = "2048x2048"44STEPS = 6  # fixed by the checkpoint (its sample_sigmas); reported, not passed45TEXT_ENCODER_LAYERS = 51  # of 64: hidden_states[50] is the conditioning46TEXT_ENCODER_QUANT = "nf4 (bitsandbytes), first 51 of 64 layers"47WIDTH, HEIGHT = (int(x) for x in SIZE.split("x"))48MAX_SEED = 2**63 - 14950app = FastAPI()51pipe = None52lock = threading.Lock()  # one generation at a time on the single pipeline535455def status(phase: str, **extra) -> None:56    with open(STATUS, "a") as f:57        f.write(json.dumps({"phase": phase, **extra}) + "\n")585960def run(prompt: str, seed: int, height: int = HEIGHT, width: int = WIDTH):61    # The docs seed a CPU generator.62    return pipe(prompt=prompt, height=height, width=width,63                generator=torch.Generator().manual_seed(seed), output="images")646566def load_model() -> None:67    global pipe68    from diffusers import (AutoencoderKLMiniMaxH3Image, MiniMaxH3ImageTransformer2DModel,69                           MiniMaxH3Scheduler, ModularPipeline)70    from transformers import (AutoConfig, BitsAndBytesConfig, Qwen2TokenizerFast,71                              Qwen3VLForConditionalGeneration, Qwen3VLProcessor)7273    t0 = time.time()74    # The pipeline's config (its sample_sigmas) comes from modular_model_index.json; its component75    # specs point at the Hub, so every component is loaded here from the mount and handed over.76    p = ModularPipeline.from_pretrained(WEIGHTS)7778    status("loading", progress=0.0, detail="loading the Qwen3-VL-32B text encoder (51 layers, nf4, ~15 GB)")79    print("Loading the text encoder (first 51 layers, nf4)...", flush=True)80    te_dir = os.path.join(WEIGHTS, "text_encoder")81    cfg = AutoConfig.from_pretrained(te_dir)82    full_layers = cfg.text_config.num_hidden_layers83    cfg.text_config.num_hidden_layers = TEXT_ENCODER_LAYERS  # the weights of the rest are skipped84    text_encoder = Qwen3VLForConditionalGeneration.from_pretrained(85        te_dir, config=cfg, dtype=torch.bfloat16, device_map="cuda",86        quantization_config=BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",87                                               bnb_4bit_compute_dtype=torch.bfloat16))88    text_encoder.lm_head = torch.nn.Identity()  # never run: the pipeline reads hidden states only89    text_encoder.eval()90    torch.cuda.empty_cache()91    print(f"  {TEXT_ENCODER_LAYERS} of {full_layers} layers; GPU memory "92          f"{torch.cuda.memory_allocated() / 2**30:.1f} GiB", flush=True)93    print(f"  text encoder in {time.time() - t0:.0f}s", flush=True)9495    status("loading", progress=0.35, detail="loading the 33B transformer in bf16 (~62 GB)")96    print("Loading the transformer (bf16)...", flush=True)97    transformer = MiniMaxH3ImageTransformer2DModel.from_pretrained(98        WEIGHTS, subfolder="transformer_diffusers", torch_dtype=torch.bfloat16).to("cuda")99    print(f"  transformer done at {time.time() - t0:.0f}s", flush=True)100101    status("loading", progress=0.9, detail="loading the VAE, tokenizer and scheduler")102    vae = AutoencoderKLMiniMaxH3Image.from_pretrained(103        WEIGHTS, subfolder="vae_diffusers", torch_dtype=torch.bfloat16).to("cuda")104    p.update_components(105        text_encoder=text_encoder, transformer=transformer, vae=vae,106        tokenizer=Qwen2TokenizerFast.from_pretrained(os.path.join(WEIGHTS, "tokenizer")),107        processor=Qwen3VLProcessor.from_pretrained(os.path.join(WEIGHTS, "processor")),108        scheduler=MiniMaxH3Scheduler.from_pretrained(WEIGHTS, subfolder="scheduler"),109    )110    pipe = p111    print(f"Model loaded in {time.time() - t0:.1f}s | GPU memory allocated "112          f"{torch.cuda.memory_allocated() / 2**30:.1f} GiB", flush=True)113114    status("warming", detail="one small warm-up generation")115    t0 = time.time()116    with lock:117        # Generated at the 4 MP minimum canvas whatever is asked; this only warms the kernels.118        run("warm-up", 0)119    print(f"Warm-up done in {time.time() - t0:.1f}s.", flush=True)120121122class ImageRequest(BaseModel):123    prompt: str = Field(..., min_length=1, description="Text prompt")124    seed: Optional[int] = Field(None, ge=0, le=MAX_SEED, description="RNG seed. null = random; the one used is returned.")125126127def baked() -> dict:128    return {"size": SIZE, "num_inference_steps": STEPS, "guidance": "distilled (none)",129            "text_encoder": TEXT_ENCODER_QUANT}130131132@app.get("/health")133def health():134    if pipe is None:135        raise HTTPException(503, "Model not loaded")136    return {"status": "ok", "model": SERVED_MODEL_ID}137138139@app.get("/v1/config")140def config():141    return {142        "model": SERVED_MODEL_ID,143        "params": baked(),144        "request_fields": {145            "prompt": {"required": True, "description": "Text prompt, used verbatim (Qwen3-VL-32B encoder, "146                                                        "up to 4096 tokens); long, complete descriptions work best"},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()