Files
olivv-cs-ComfyUI-FunPack/modules/sampling/block_influence/measure.py
T

180 lines
7.7 KiB
Python

"""The probe's arithmetic and its stored profile: no model, no routes."""
import torch
from ..._core import config, dit_hooks, registry
KIND = "block_influence"
MIN_PER_GROUP = 2 # liked AND disliked before a difference is one
SWITCH = config.ROOT / "block_influence.enabled" # on disk: a fresh rental resumes recording
# Why recording cannot or did not store anything, for the panel (shown only while recording is on).
problem = None
def enabled() -> bool:
try:
return SWITCH.read_text().strip() == "1"
except OSError:
return False
def set_enabled(on: bool) -> bool:
SWITCH.parent.mkdir(parents=True, exist_ok=True)
SWITCH.write_text("1" if on else "0")
return bool(on)
class Tally:
"""One run's per-block sums, kept on the device until the run ends.
No `.item()` per block per step (50 blocks x steps host syncs): sums are 0-dim
tensors and are read once, after sampling. Rows are strided down to
~`max_rows` before the norms so the cost does not grow with H3's sequence length.
"""
def __init__(self, n, max_rows=512):
self.n, self.max_rows = n, max_rows
self._sum = {k: [None] * n for k in ("raw", "ratio", "novelty")}
self._count = {k: [0] * n for k in ("raw", "ratio", "novelty")}
self._prev = (None, -1) # previous block's push, for the novelty cosine
self._spans = {}
def _span(self, args, src):
seq = int(src.shape[0])
if seq not in self._spans:
self._spans[seq] = dit_hooks.row_span(
dit_hooks.target_rows(args.get("mod_segments"), seq, src.device, "video"))
return self._spans[seq]
def _add(self, kind, block, value):
s = self._sum[kind][block]
self._sum[kind][block] = value if s is None else s + value
self._count[kind][block] += 1
def measure(self, block, args, original):
"""Run the block and record how far it moved the picture rows."""
src = args.get("img")
sel = before = None
if torch.is_tensor(src) and src.dim() == 2:
span = self._span(args, src)
if span is not None:
sel = slice(span[0], span[1], max(1, (span[1] - span[0]) // self.max_rows))
# A COPY, read before the block runs: a block that writes its residual in
# place leaves `src` already holding the result, and every delta is zero.
before = src[sel].detach().to(torch.float32, copy=True)
out = original(args)
if before is not None and torch.is_tensor(out.get("img")) \
and out["img"].shape[0] == src.shape[0]:
delta = out["img"][sel].detach().float() - before
push = delta.norm()
self._add("raw", block, push)
self._add("ratio", block, push / before.norm().clamp(min=1e-8))
last, last_block = self._prev
# A block index that does not exceed the last one means a new step began:
# pairing across it would compare the end of one step to the start of the next.
if last is not None and block > last_block and last.shape == delta.shape:
self._add("novelty", block, torch.nn.functional.cosine_similarity(
delta.flatten(), last.flatten(), dim=0))
self._prev = (delta, block)
return out
def blocks_seen(self) -> int:
return sum(1 for c in self._count["raw"] if c)
def rows(self):
"""{name: [n_blocks] tensor} (NaN where a block saw nothing), or None when
nothing was measured -- which must be said, not stored as a flat profile."""
if not self.blocks_seen():
return None
rows = {}
for name in ("ratio", "raw", "novelty"):
have = [i for i, c in enumerate(self._count[name]) if c]
vec = torch.full((self.n,), float("nan"))
if have:
means = torch.stack([self._sum[name][i].float() / self._count[name][i] for i in have])
vec[have] = means.cpu() # the one host sync
rows[name] = vec
return rows
def profile(rows) -> dict:
"""Depth profile from a key's rated rows.
`overall` is where the model does its work regardless of ratings; `share` is
each block's RAW push as a fraction of all blocks' (rank by this: the ratio
shrinks with depth by construction, since the stream grows); `difference` is
liked minus disliked; `flatness` is the spread of `overall` as a fraction of
its mean -- near 0 means every block moves the stream equally and there is
nothing to aim at. `novelty` ~1: a block amplifies its predecessor, ~0 it
adds something new, negative it partly undoes it.
"""
rows = [r for r in rows if {"ratio", "raw", "novelty"} <= set(r["rows"])]
empty = {"used": 0, "overall": {}, "difference": None, "share": {}, "novelty": {}, "mean_novelty": None,
"flatness": None, "n_liked": 0, "n_disliked": 0}
if not rows:
return empty
n = len(rows[-1]["rows"]["ratio"])
rows = [r for r in rows if len(r["rows"]["ratio"]) == n] # a different model's rows
reward = torch.tensor([float(r["reward"]) for r in rows])
stack = {k: torch.stack([r["rows"][k].float() for r in rows]) for k in ("ratio", "raw", "novelty")}
def mean(name, mask=None):
m = stack[name] if mask is None else stack[name][mask]
return torch.nanmean(m, dim=0) if len(m) else torch.full((n,), float("nan"))
def table(vec):
return {str(i): float(v) for i, v in enumerate(vec) if torch.isfinite(v)}
overall, raw, novelty = mean("ratio"), mean("raw"), mean("novelty")
seen = torch.isfinite(raw)
share = table(raw / raw[seen].sum()) if seen.any() and raw[seen].sum() > 1e-12 else {}
liked, disliked = reward > 0, reward < 0
difference = None
if int(liked.sum()) >= MIN_PER_GROUP and int(disliked.sum()) >= MIN_PER_GROUP:
difference = table(mean("ratio", liked) - mean("ratio", disliked))
vals = overall[torch.isfinite(overall)]
flat = float(vals.std(unbiased=False) / vals.mean()) if len(vals) and abs(float(vals.mean())) > 1e-12 else None
nov = novelty[torch.isfinite(novelty)]
return {"used": len(rows), "overall": table(overall), "difference": difference, "share": share,
"novelty": table(novelty), "mean_novelty": float(nov.mean()) if len(nov) else None,
"flatness": flat, "n_liked": int(liked.sum()), "n_disliked": int(disliked.sum())}
def _kind(key):
kind = registry.current().ask("taste_kind", key, KIND)
if kind is None:
raise ValueError("the Taste key module is off, so there is nowhere to read profiles from")
return kind
def resolve(key):
"""The key to show. The panel asks for a placeholder ("default") when it does not know
which Taste key the runs write to, so a key with nothing recorded falls back to the key
the latest capture went to -- and the answer says which one it is."""
if _kind(key).path().exists():
return key
latest = registry.current().ask("taste_latest_key")
return latest if latest and _kind(latest).path().exists() else key
def state(key, fallback=True) -> dict:
"""What Settings > Refinement & Taste shows for one key. `fallback` False answers for
exactly that key (after a Clear, which must not show another key's data)."""
key = resolve(key) if fallback else key
rows = _kind(key).rows()
prof = profile(rows)
shown = problem if enabled() else None
return {"key": key, "enabled": enabled(), "runs": len(rows), "problem": shown,
"skipped": len(rows) - prof["used"], "min_per_group": MIN_PER_GROUP,
**prof}
def clear(key):
_kind(key).clear()
def path_of(key):
return _kind(key).path()