Files
invoke-ai--invokeai/scripts/calibrate_qwen_vae_working_memory.py
wehub-resource-sync cddb07a176
build container image / cpu (push) Waiting to run
build container image / cuda (push) Waiting to run
build container image / rocm (push) Waiting to run
frontend tests / frontend-tests (push) Waiting to run
openapi checks / openapi-checks (push) Waiting to run
python tests / py3.12: macos-default (push) Waiting to run
python tests / py3.11: windows-cpu (push) Waiting to run
python tests / py3.12: windows-cpu (push) Waiting to run
python tests / py3.11: linux-cpu (push) Waiting to run
python tests / py3.12: linux-cpu (push) Waiting to run
typegen checks / typegen-checks (push) Waiting to run
uv lock checks / uv-lock-checks (push) Waiting to run
frontend checks / frontend-checks (push) Waiting to run
lfs checks / lfs-check (push) Waiting to run
python checks / python-checks (push) Waiting to run
python tests / py3.11: macos-default (push) Waiting to run
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

306 lines
12 KiB
Python

"""Calibrate the Qwen Image VAE working-memory estimate against measured peak CUDA/HIP memory.
Background
----------
``estimate_vae_working_memory_qwen_image`` models peak working memory as a linear function of
spatial area::
working_memory = h * w * element_size * scaling_constant
This script measures the *actual* peak reserved memory the VAE consumes during decode/encode across
a grid of resolutions so the ``scaling_constant`` can be fit from several points instead of one, and
so we can check whether the pure-linear model holds or whether a super-linear (attention) term
appears at high resolution.
The estimate is consumed by the model cache via ``free >= estimate`` to decide whether to evict, so
it MUST be an upper bound: we measure peak *reserved* (not just allocated) memory, the conservative
quantity that includes caching-allocator overhead and kernel scratch/workspace.
Portability
-----------
Backend-agnostic: uses only ``torch.cuda.*``, which works on both NVIDIA/CUDA and AMD/ROCm (HIP)
builds of PyTorch. Run the SAME script on each backend and compare the ``implied_constant`` columns
-- the curve *shape* (linear vs. super-linear) is architectural and should match, but the absolute
constant can differ (cuDNN vs. MIOpen conv workspaces, flash-attention availability, allocator
rounding). Ship ``max`` across backends plus headroom.
Each (operation, resolution) point is measured in a FRESH SUBPROCESS so the caching allocator's
fragmentation history from earlier points cannot contaminate the reserved-delta reading. A point
that OOMs is recorded as ``oom`` rather than aborting the run, so the grid can probe up to the
card's ceiling safely.
Usage
-----
python scripts/calibrate_qwen_vae_working_memory.py [--vae /path/to/vae_dir] [--csv out.csv]
If ``--vae`` is omitted, the script auto-discovers an ``AutoencoderKLQwenImage`` under
``$INVOKEAI_ROOT/models``.
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
import torch
from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
LATENT_SCALE_FACTOR = 8
# (height, width) pixel-space resolutions. Squares to test linearity in area, plus non-square
# points (incl. the original 1248x832 calibration point) to confirm area = h*w is the right
# predictor rather than max(h, w) or perimeter. Subprocess isolation + OOM capture means we can
# list aggressive resolutions; ones that don't fit are simply recorded as oom.
DEFAULT_RESOLUTIONS = [
(512, 512),
(768, 768),
(832, 1248), # original single calibration point (as HxW)
(1024, 1024),
(1088, 1920),
(1280, 1280),
(1536, 1024),
(1536, 1536),
(1792, 1792),
(2048, 2048),
]
def discover_vae() -> Path:
"""Find an AutoencoderKLQwenImage VAE directory under $INVOKEAI_ROOT/models."""
root = os.environ.get("INVOKEAI_ROOT")
if not root:
raise SystemExit("INVOKEAI_ROOT not set; pass --vae explicitly.")
models = Path(root) / "models"
for config_path in models.glob("*/vae/config.json"):
try:
cfg = json.loads(config_path.read_text())
except Exception:
continue
if cfg.get("_class_name") == "AutoencoderKLQwenImage":
return config_path.parent
raise SystemExit(f"No AutoencoderKLQwenImage VAE found under {models}; pass --vae explicitly.")
DTYPES = {"float16": torch.float16, "bfloat16": torch.bfloat16, "float32": torch.float32}
def _load_vae(vae_path: str, dtype: torch.dtype) -> AutoencoderKLQwenImage:
"""Load an AutoencoderKLQwenImage from either a diffusers directory or a single .safetensors file.
Directory: standard ``from_pretrained``.
Single file: ``AutoencoderKLQwenImage`` has no single-file converter registered in diffusers,
so we instantiate with the default config and load the state dict directly. Two on-disk layouts
exist: the diffusers layout (``encoder.conv_in`` / ``down_blocks`` / ``mid_block`` keys, e.g. the
weights InvokeAI's VAELoader consumes) and the original Qwen-Image/Wan release layout
(``encoder.conv1`` / ``downsamples`` / ``middle`` / ``time_conv`` keys). We try a direct strict
load first, and on a key mismatch fall back to diffusers' Wan VAE converter -- the Qwen-Image VAE
shares the Wan VAE key structure -- before retrying.
"""
path = Path(vae_path)
if not path.is_file():
return AutoencoderKLQwenImage.from_pretrained(vae_path, local_files_only=True, torch_dtype=dtype)
from safetensors.torch import load_file
sd = load_file(str(path))
for k in list(sd.keys()):
if sd[k].is_floating_point():
sd[k] = sd[k].to(dtype)
vae = AutoencoderKLQwenImage()
try:
# diffusers-layout checkpoint: keys already match the model. State dict was converted to
# `dtype` above and is assigned in place, so params carry the correct dtype.
vae.load_state_dict(sd, strict=True, assign=True)
except RuntimeError:
# Original Qwen-Image/Wan release layout: convert keys to the diffusers layout, then retry.
from diffusers.loaders.single_file_utils import convert_wan_vae_to_diffusers
converted = convert_wan_vae_to_diffusers(sd)
for k in list(converted.keys()):
if converted[k].is_floating_point():
converted[k] = converted[k].to(dtype)
vae.load_state_dict(converted, strict=True, assign=True)
return vae
def _build_input(operation: str, h: int, w: int, z_dim: int, dtype: torch.dtype) -> torch.Tensor:
"""Construct the 5D (B, C, num_frames, H, W) input the invocation feeds the VAE.
decode: latents at latent resolution (H/8, W/8) with z_dim channels.
encode: image at pixel resolution (H, W) with 3 channels.
These mirror QwenImageLatentsToImageInvocation / QwenImageImageToLatentsInvocation exactly.
"""
device = torch.device("cuda")
if operation == "decode":
return torch.randn(1, z_dim, 1, h // LATENT_SCALE_FACTOR, w // LATENT_SCALE_FACTOR, device=device, dtype=dtype)
return torch.randn(1, 3, 1, h, w, device=device, dtype=dtype)
@torch.inference_mode()
def measure_one(vae_path: str, operation: str, h: int, w: int, dtype: torch.dtype) -> dict:
"""Measure peak reserved-memory growth for a single decode/encode. Runs in a child process."""
vae = _load_vae(vae_path, dtype)
vae.to("cuda")
vae.disable_tiling() # Qwen invocations never tile; match that.
param = next(vae.parameters())
dtype = param.dtype
element_size = param.element_size()
z_dim = int(vae.config.z_dim)
x = _build_input(operation, h, w, z_dim, dtype)
torch.cuda.synchronize()
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
baseline_reserved = torch.cuda.memory_reserved()
# Measure the COLD first call -- it includes conv-algorithm-search / attention workspace
# allocation, which is exactly what the real (single-shot) invocation pays.
try:
if operation == "decode":
vae.decode(x, return_dict=False)
else:
vae.encode(x).latent_dist.mode()
torch.cuda.synchronize()
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
if "out of memory" not in str(e).lower():
raise
return {"operation": operation, "h": h, "w": w, "oom": True}
peak_reserved = torch.cuda.max_memory_reserved()
peak_allocated = torch.cuda.max_memory_allocated()
reserved_delta = peak_reserved - baseline_reserved
area = h * w
return {
"operation": operation,
"h": h,
"w": w,
"area": area,
"element_size": element_size,
"dtype": str(dtype),
"reserved_delta": reserved_delta,
"allocated_peak": peak_allocated,
"reserved_baseline": baseline_reserved,
# The constant as the estimator parameterizes it: mem = area * element_size * k
"implied_constant": reserved_delta / (area * element_size),
"oom": False,
}
def run_grid(vae_path: str, resolutions: list[tuple[int, int]], dtype_name: str, csv_path: Path | None) -> None:
rows: list[dict] = []
print(f"VAE: {vae_path}")
print(
f"torch {torch.__version__} | device {torch.cuda.get_device_name(0)} | hip={torch.version.hip} | dtype={dtype_name}\n"
)
print(f"{'op':6} {'HxW':>11} {'area':>10} {'reserved(GiB)':>14} {'alloc(GiB)':>11} {'implied_k':>10}")
print("-" * 70)
for operation in ("decode", "encode"):
for h, w in resolutions:
# Fresh subprocess per point for an uncontaminated reserved-memory reading.
proc = subprocess.run(
[
sys.executable,
__file__,
"--single",
operation,
str(h),
str(w),
"--vae",
vae_path,
"--dtype",
dtype_name,
],
capture_output=True,
text=True,
)
line = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""
try:
row = json.loads(line)
except Exception:
print(f"{operation:6} {f'{h}x{w}':>11} FAILED: {proc.stderr.strip().splitlines()[-1:]}")
continue
rows.append(row)
if row.get("oom"):
print(f"{operation:6} {f'{h}x{w}':>11} {h * w:>10} {'OOM':>14}")
continue
gib = 1024**3
print(
f"{operation:6} {f'{h}x{w}':>11} {row['area']:>10} "
f"{row['reserved_delta'] / gib:>14.3f} {row['allocated_peak'] / gib:>11.3f} "
f"{row['implied_constant']:>10.1f}"
)
# Summary: the shippable constant is the MAX implied constant over fitting points (upper bound).
print("\n=== summary (max implied constant = candidate scaling_constant, before headroom) ===")
for operation in ("decode", "encode"):
ks = [r["implied_constant"] for r in rows if r["operation"] == operation and not r.get("oom")]
if ks:
print(
f"{operation:6}: n={len(ks)} min_k={min(ks):.1f} max_k={max(ks):.1f} "
f"-> use >= {max(ks):.0f} (+headroom)"
)
if csv_path:
import csv
fieldnames = [
"operation",
"h",
"w",
"area",
"element_size",
"dtype",
"reserved_delta",
"allocated_peak",
"reserved_baseline",
"implied_constant",
"oom",
]
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for r in rows:
writer.writerow(r)
print(f"\nWrote {csv_path}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--vae",
type=str,
default=None,
help="Path to an AutoencoderKLQwenImage diffusers dir OR a single .safetensors checkpoint.",
)
parser.add_argument("--csv", type=str, default=None, help="Optional path to write the raw results as CSV.")
parser.add_argument(
"--dtype",
choices=list(DTYPES),
default="float16",
help="Compute dtype. Default float16 to match InvokeAI's default precision on CUDA/ROCm.",
)
# Internal: measure a single point in this process and print one JSON line.
parser.add_argument("--single", nargs=3, metavar=("OP", "H", "W"), default=None, help=argparse.SUPPRESS)
args = parser.parse_args()
vae_path = args.vae or str(discover_vae())
dtype = DTYPES[args.dtype]
if args.single:
op, h, w = args.single[0], int(args.single[1]), int(args.single[2])
print(json.dumps(measure_one(vae_path, op, h, w, dtype)))
return
run_grid(vae_path, DEFAULT_RESOLUTIONS, args.dtype, Path(args.csv) if args.csv else None)
if __name__ == "__main__":
main()