Files
wildminder-ComfyUI-DyPE/calibration/calibrate_hap.py
T
WildAi cb677c1794 refactor: hap_calibrate node module moves src→nodes, category unified
S2 of the 2026-09-08 layout plan: src/hap_calib_node.py → nodes/hap_calibrate.py
(git mv). Engine imports (.hap/.hap_calib/.spa/.spa_context) become dual-form
try/except (loader-relative ..src / flat src). Calibration CLI re-imports
DEFAULT_CALIBRATION_PROMPTS + orchestrator from nodes.hap_calibrate. Category →
WMNodes/image. 9 hap_calib test files repointed.
2026-09-08 21:01:58 +03:00

338 lines
13 KiB
Python

#!/usr/bin/env python
"""HAP scope-plan calibration script (plan T6.3).
One-command generation of a per-head HAP scope plan for a target model +
resolution. Runs the P6 gradient-path collector (T6.2) over a set of
calibration prompts, solves the multiple-choice knapsack (T5.5), and writes a
reference-format ``{"alphas", "betas"}`` JSON consumable by the ``HAP`` node
with zero conversion.
Two modes:
``--dry_run``
CI-safe end-to-end pipeline on a synthetic toy DiT (no GPU model, no real
ComfyUI weights). Validates collector → solver → JSON round-trip. This is
the only path exercised by automated tests (``-k dry_run``).
real model (default)
Loads a checkpoint through ComfyUI and calibrates at ONE representative
denoising step per prompt (paper: impact estimation on attention probs).
This path requires the user's ComfyUI venv and a GPU; it is a documented
manual step (plan §6 acceptance, checklist A5) and is NOT run in CI.
Usage (dry run)::
python calibration/calibrate_hap.py --dry_run --out tmp/scope_plan_dry.json
Usage (real model, in the ComfyUI venv)::
python calibration/calibrate_hap.py \
--model_path /path/to/flux.safetensors --model_type flux \
--width 4096 --height 4096 --num_prompts 30 --out configs/scope_plan_flux_4k.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
# Make the project root importable when run as a script.
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
import torch
from src.hap import ScopePlan, flops_ratio
from src.hap_calib import calibrate_scope_plan, collect_scope_scores
# Single source for default prompts (plan 2026-08-16 P6.1): the node module
# owns the list; the CLI re-imports it so the two never drift.
# Re-exported on purpose — tests/test_hap_calib_spec.py::TestCliSingleSource
# asserts ``mod.DEFAULT_PROMPTS is DEFAULT_CALIBRATION_PROMPTS``.
from nodes.hap_calibrate import DEFAULT_CALIBRATION_PROMPTS as DEFAULT_PROMPTS # noqa: F401
def _ensure_mock_attention_module():
"""Install a minimal ``comfy.ldm.modules.attention`` mock for ``--dry_run``.
The P6 collector patches the module-level ``optimized_attention``. In the
user's ComfyUI venv the real module exists; in CI / standalone dry-run it
does not, so we provide a plain SDPA shim (scale=1.0, matching the HRDiT
reference and the test conftest) to keep the dry-run fully self-contained.
"""
import types
if "comfy.ldm.modules.attention" in sys.modules:
return sys.modules["comfy.ldm.modules.attention"]
import torch.nn.functional as F
for name in ("comfy", "comfy.ldm", "comfy.ldm.modules"):
sys.modules.setdefault(name, types.ModuleType(name))
attn_mod = types.ModuleType("comfy.ldm.modules.attention")
# REAL ComfyUI signature (attention.py::attention_pytorch) — positional slots
# 5-8 == mask, attn_precision, skip_reshape, skip_output_reshape. Must mirror
# the test conftest mock (plan 2026-08-16 G2 mock-fidelity fix).
def _sdpa(q, k, v, heads, mask=None, attn_precision=None,
skip_reshape=False, skip_output_reshape=False, **kwargs):
return F.scaled_dot_product_attention(q, k, v, scale=1.0, dropout_p=0.0, is_causal=False)
attn_mod.optimized_attention = _sdpa
attn_mod.optimized_attention_masked = _sdpa # real alias (attention.py:883)
sys.modules["comfy.ldm.modules.attention"] = attn_mod
sys.modules["comfy.ldm.modules"].attention = attn_mod
return attn_mod
# ---------------------------------------------------------------------------
# Dry-run toy model (self-contained; mirrors tests/_hrdit_fixtures.ToyDiT)
# ---------------------------------------------------------------------------
class _DryRunToyDiT:
"""Minimal multi-layer attention-only transformer for ``--dry_run``.
Calls the module-level ``optimized_attention`` once per layer so the P6
collector can observe a deterministic per-forward call sequence. Uses fp64
for clean calibration gradients.
"""
def __init__(self, num_layers=4, heads=4, dim=16, text_len=16, img_hw=8, seed=0):
self.num_layers = num_layers
self.heads = heads
self.dim = dim
self.text_len = text_len
self.img_hw = img_hw
self.seed = seed
self.seq_len = text_len + img_hw * img_hw
def forward(self):
attn_mod = sys.modules["comfy.ldm.modules.attention"]
x = torch.zeros(1, self.seq_len, self.heads * self.dim, dtype=torch.float64)
h = x
g = torch.Generator().manual_seed(self.seed)
for _ in range(self.num_layers):
q = torch.randn(1, self.heads, self.seq_len, self.dim, generator=g, dtype=torch.float64)
k = torch.randn(1, self.heads, self.seq_len, self.dim, generator=g, dtype=torch.float64)
v = torch.randn(1, self.heads, self.seq_len, self.dim, generator=g, dtype=torch.float64)
out = attn_mod.optimized_attention(q, k, v, self.heads)
h = h + out.reshape(1, self.seq_len, self.heads * self.dim)
return h
def _dry_run_loss_fn(dit):
g = torch.Generator().manual_seed(999)
target = torch.randn(1, dit.seq_len, dit.heads * dit.dim, generator=g, dtype=torch.float64)
def loss_fn(output):
return torch.nn.functional.mse_loss(output, target)
return loss_fn
def run_dry_run(args) -> dict:
"""End-to-end pipeline on the toy model; returns the plan dict."""
_ensure_mock_attention_module()
# img_hw=16 -> 256 image tokens = 4 blocks of 64 (nbx=4), so the candidate
# scopes map to genuinely different band widths and the knapsack does real
# work. Still tiny enough to run on CPU in well under a second.
dit = _DryRunToyDiT(
num_layers=args.dry_layers, heads=args.dry_heads, dim=16,
text_len=16, img_hw=16, seed=0,
)
num_scopes = args.num_scopes
text_len = dit.text_len
# One calibration "prompt" per seed (toy: re-seed the toy per prompt).
quality_per_layer = [[] for _ in range(dit.num_layers)]
compute = None
for p in range(args.num_prompts):
toy = _DryRunToyDiT(
num_layers=args.dry_layers, heads=args.dry_heads, dim=16,
text_len=16, img_hw=16, seed=p,
)
quality, comp = collect_scope_scores(
toy.forward, _dry_run_loss_fn(toy), num_scopes,
text_len=text_len, chunk=args.chunk, scale=1.0,
)
for l in range(dit.num_layers):
quality_per_layer[l].append(quality[l])
if compute is None:
compute = comp[0] # prompt-independent (H, S)
plan_dict = calibrate_scope_plan(
quality_per_layer, [compute] * dit.num_layers,
budget_ratio=args.budget_ratio, bins=args.bins,
)
return plan_dict
# ---------------------------------------------------------------------------
# Real-model calibration (user's ComfyUI venv; manual step, checklist A5)
# ---------------------------------------------------------------------------
def _encode_conditioning(clip, prompt: str):
"""Build a ComfyUI CONDITIONING list for ``prompt`` using ``clip``.
Mirrors the CLIP Text Encode node: tokenize, encode, and wrap the pooled
output so ``comfy.samplers.sampling_function`` receives a valid cond.
"""
tokens = clip.tokenize(prompt)
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
return [[cond, {"pooled_output": pooled}]]
def run_real(args) -> dict: # pragma: no cover - requires ComfyUI venv + GPU
"""Calibrate a real checkpoint. Requires the ComfyUI runtime + a GPU.
This is a documented manual step (plan §6, checklist A5) and is not run in
CI. It loads the checkpoint via ComfyUI's standard loader, builds
conditioning from the calibration prompts, then delegates to the SAME
orchestrator the in-graph node uses (:func:`nodes.hap_calibrate.
run_hap_calibration`) so the CLI and the node can never drift.
"""
try:
import comfy.sd
import comfy.utils # noqa: F401
except Exception as exc:
raise RuntimeError(
"Real-model calibration requires the ComfyUI runtime. Run this "
"script inside your ComfyUI venv (checklist A5), or use --dry_run "
"to validate the pipeline without a model. Import failed: "
f"{exc!r}"
) from exc
from nodes.hap_calibrate import (
CalibrationSpec,
resolve_prompts,
run_hap_calibration,
)
if not args.model_path:
raise ValueError(
"run_real: --model_path is required for real-model calibration "
"(use --dry_run to validate the pipeline without a checkpoint)."
)
# Load the checkpoint (model + clip) through ComfyUI's canonical loader.
model, clip, _vae, *_rest = comfy.sd.load_checkpoint_guess_config(
args.model_path,
output_vae=False,
output_clip=True,
output_clipvision=False,
output_model=True,
)
# Resolve the calibration prompt list (file > defaults).
prompts = resolve_prompts(
prompts_text="",
prompts_file=args.prompts_file or "",
num_prompts=args.num_prompts,
pack_root=_PROJECT_ROOT,
)
# Condition on the FIRST prompt; each prompt index varies the noise seed
# inside the orchestrator (matches the node's behaviour).
positive = _encode_conditioning(clip, prompts[0])
negative = _encode_conditioning(clip, "")
spec = CalibrationSpec(
width=int(args.width),
height=int(args.height),
num_prompts=int(args.num_prompts),
num_scopes=int(args.num_scopes),
budget_ratio=float(args.budget_ratio),
bins=int(args.bins),
chunk=int(args.chunk),
text_len=512,
anchor_stride=int(args.anchor_stride),
calib_sigma=1.0,
seed=3407,
loss_type="output_norm",
prompts=prompts,
)
spec.validate()
plan_dict, _summary = run_hap_calibration(
model=model,
spec=spec,
model_type=args.model_type,
positive=positive,
negative=negative,
)
return plan_dict
# ---------------------------------------------------------------------------
# Summary + CLI
# ---------------------------------------------------------------------------
def summarize(plan_dict: dict, seq_len: int, text_len: int, anchor_stride: int) -> str:
"""Human-readable summary: per-layer mean beta + expected flops ratio."""
plan = ScopePlan.from_dict(plan_dict)
lines = [f"layers={plan.num_layers} heads={plan.num_heads} seq_len={seq_len}"]
mean_betas = []
for l in range(plan.num_layers):
mb = sum(plan_dict["betas"][l]) / max(len(plan_dict["betas"][l]), 1)
mean_betas.append(mb)
lines.append(f"per-layer mean beta: min={min(mean_betas):.3f} max={max(mean_betas):.3f}")
try:
fr = flops_ratio(plan, seq_len, text_len=text_len, anchor_stride=anchor_stride)
lines.append(f"expected retained flops ratio: {fr:.3f}")
except Exception as exc: # pragma: no cover - defensive
lines.append(f"flops ratio unavailable: {exc!r}")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Calibrate an HAP scope plan.")
p.add_argument("--model_path", type=str, default=None, help="Checkpoint path (real mode).")
p.add_argument("--model_type", type=str, default="flux",
choices=["flux", "qwen", "zimage", "anima", "nunchaku"])
p.add_argument("--width", type=int, default=4096)
p.add_argument("--height", type=int, default=4096)
p.add_argument("--num_prompts", type=int, default=30)
p.add_argument("--num_scopes", type=int, default=50)
p.add_argument("--budget_ratio", type=float, default=0.1)
p.add_argument("--anchor_stride", type=int, default=0)
p.add_argument("--bins", type=int, default=4000)
p.add_argument("--chunk", type=int, default=256)
p.add_argument("--prompts_file", type=str, default=None,
help="Optional text file with one prompt per line.")
p.add_argument("--out", type=str, default="configs/scope_plan_calibrated.json")
p.add_argument("--dry_run", action="store_true",
help="Run the pipeline on a synthetic toy model (CI-safe).")
p.add_argument("--dry_layers", type=int, default=4)
p.add_argument("--dry_heads", type=int, default=4)
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
if args.dry_run:
plan_dict = run_dry_run(args)
toy_seq = 16 + 16 * 16 # matches _DryRunToyDiT dry-run defaults
print(summarize(plan_dict, seq_len=toy_seq, text_len=16, anchor_stride=0))
else:
plan_dict = run_real(args)
seq_len = (args.width // 16) * (args.height // 16) + 512
print(summarize(plan_dict, seq_len=seq_len, text_len=512,
anchor_stride=args.anchor_stride))
out_path = args.out
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(plan_dict, fh)
# Validate the written plan round-trips.
ScopePlan.load(out_path)
print(f"wrote {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())