Files
wildminder-ComfyUI-KEEP/tools/prof_paste_host_cpu.py
T
WildAi fadee7c91f chore(tools): add the F1 output-mode comparison to the paste attribution
The instrument now times the same paste_faces_batch call in the host and
the device output mode, one session, one fixture, and asserts the two
are bit-identical. On the CPU fixture (8 frames, 360x640, stub nets):
the output assembly's host<->device traffic is gone (1.06 MB each way
becomes 0, the ROI-sized staging recorded as roi_staged_bytes), wall
-8.8%, process CPU -8.7%. The delta dilutes the CUDA effect - on the
owner's GPU the kernel work stays on the GPU and the removed portion is
pure host time - so the CUDA measurement of the same comparison stays
the owner-gated V2.
2026-10-27 19:23:21 +03:00

602 lines
25 KiB
Python

"""The host-CPU attribution of the "Pasting faces and finalizing frames" step.
The instrument of task t1 of ``.dev/docs/2026-10-27-keep-paste-step-host-cpu-design.md``
(section 2.1). It measures the **host** cost of the paste step of
``src/keep_processor.py::process_image_sequence`` - the step the owner reported
as the remaining CPU load after the model finished.
What it reuses: the ``Timer``, ``_StageProbe``, ``_PasteProbe`` and
``_TransferCounter`` of ``tools/prof_gpu_video_pipeline.py`` (imported by path,
never copied) so the numbers of the two tools are comparable.
What it adds:
1. **The stub-net pack** - a vendored ``FaceRestoreHelper`` whose detector is a
synthetic detection row and whose parse net is a 1x1 convolution (the fixture
of ``tests/test_gpu_paste_equivalence.py:93-107``), plus a pass-through KEEP
net. No weights are read and no architecture is constructed, on any device:
the ``--device cuda`` path of the probe (the real RetinaFace / ParseNet /
KEEP architectures) is **not** used here and stays gated on the owner's
written go-ahead.
2. **The routing recorder** - the per-frame answer to the plan's first
question: which frames take the batched device paste, which take the
vendored per-frame fallback, and for which reason. It captures the
arguments of ``_batched_paste``, counts the ``add_restored_face`` calls of
the paste loop and recomputes every predicate of
``src/keep_processor.py:171-189`` frame by frame.
3. **The extended sub-stage set** - the probe's paste sub-stages plus the ones
the CPU attribution needs: the ROI face warp, the ROI box, the fixed-point
terms, the background maximum, the full-canvas fallback, the ParseNet
forward itself (wrapped through ``__call__`` - the stub has no ``forward``)
and the progress reports.
4. **The repeat-to-budget micro benches** for the sub-tick items
(``time.process_time`` has one system tick of granularity on Windows - a
sub-2-tick op reports 0 CPU). The host copies of the ROI patch, the
download and the final ``np.stack`` are repeated until each accrues
>= 200 ms of wall time and reported with their budget.
Run (embedded interpreter):
COMFYUI_ROOT=<ComfyUI source tree> python tools/prof_paste_host_cpu.py \\
--device cpu --frames 8 --frame-hw 360 640 \\
--json .dev/prof-paste-host-cpu.json
COMFYUI_ROOT=<ComfyUI source tree> python tools/prof_paste_host_cpu.py \\
--device cpu --frames 4 --frame-hw 720 1280 \\
--json .dev/prof-paste-host-cpu-720p.json
Exit code 0 when the attribution ran; the report content is the evidence.
This tool changes no production code.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import sys
import time
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
REPO_ROOT = Path(__file__).resolve().parent.parent
PROBE_PATH = REPO_ROOT / "tools" / "prof_gpu_video_pipeline.py"
FACE_TEMPLATE = np.array(
[[192.98138, 239.94708], [318.90277, 240.1936], [256.63416, 314.01935],
[201.26117, 371.41043], [313.08905, 371.15118]])
#: the repeat budget of the micro benches: an op is repeated until it accrues
#: this much wall time, so its process-CPU number covers several system ticks
#: (``time.process_time`` has ~15.6 ms of granularity on Windows)
MICRO_BUDGET_S = 0.2
def load_probe():
"""The profiling probe as a module - its measurement classes are reused."""
spec = importlib.util.spec_from_file_location("keep_prof_probe", PROBE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
# ---------------------------------------------------------------------------
# The stub pack (no weights, no architectures - t1 fixture of the design)
# ---------------------------------------------------------------------------
class _StubParseNet:
"""The fixture of ``tests/test_gpu_paste_equivalence.py:93-107``.
A 1x1 convolution over the color channels: the class map is a function of
the pixels alone, so the forward is batch invariant by construction.
"""
def __init__(self, seed: int = 5) -> None:
generator = torch.Generator().manual_seed(seed)
self.weight = torch.randn(19, 3, 1, 1, generator=generator) * 0.35
self.calls = 0
def __call__(self, samples):
self.calls += 1
return (F.conv2d(samples, self.weight), None)
class _PassThroughKeep:
"""A KEEP net that returns its input: the paste stage never sees pixels
of the keep net, only the copy traffic around them."""
def __init__(self) -> None:
self.calls: list[tuple] = []
def __call__(self, frames, need_upscale=False):
self.calls.append((tuple(frames.shape), need_upscale))
return frames
class _SyntheticDetector:
"""A detector that returns the saved landmark row of the current frame.
The vendored ``get_face_landmarks_5`` call path is the real one; only the
detection forward is replaced (the probe does the same, with the real
RetinaFace forward in front - this instrument does not need it).
"""
def __init__(self, landmarks: np.ndarray) -> None:
self.frame_idx = 0
self.landmarks = landmarks
def detect_faces(self, image, conf_threshold=0.8, **_kwargs):
row = _SyntheticDetector._row(self.landmarks[self.frame_idx % len(self.landmarks)])
self.frame_idx += 1
return row
@staticmethod
def _row(landmarks_5x2: np.ndarray) -> np.ndarray:
"""The (1, 15) RetinaFace decode row: box + score + 5 landmarks."""
x_min, y_min = landmarks_5x2.min(axis=0) - 40.0
x_max, y_max = landmarks_5x2.max(axis=0) + 60.0
row = [x_min, y_min, x_max, y_max, 0.99]
for point in landmarks_5x2:
row.extend([point[0], point[1]])
return np.array([row], dtype=np.float32)
def build_stub_pack(device: str, landmarks: np.ndarray):
"""The owner's default configuration: parse net on, no upscale models."""
import importlib
import src.keep_processor as processor_mod # noqa: F401 - the import path
from src.keep_processor import KEEPFaceProcessor
helper_module = importlib.import_module(
"modules.deps.facelib.utils.face_restoration_helper")
detector = _SyntheticDetector(landmarks)
parser = _StubParseNet()
keep_net = _PassThroughKeep()
original_init_det = helper_module.init_detection_model
original_init_parse = helper_module.init_parsing_model
helper_module.init_detection_model = lambda *a, **k: detector
helper_module.init_parsing_model = lambda *a, **k: parser
try:
face_helper = helper_module.FaceRestoreHelper(
upscale_factor=1, face_size=512, crop_ratio=(1, 1),
det_model="retinaface_resnet50", save_ext="png", use_parse=True,
device=device, model_rootpath=REPO_ROOT / "modules" / "deps" / "facelib")
finally:
helper_module.init_detection_model = original_init_det
helper_module.init_parsing_model = original_init_parse
pack = type(
"Pack", (),
{
"keep_net": keep_net,
"face_helper": face_helper,
"bg_upscale_model": None, # the Load node default
"face_upscale_model": None,
"device": torch.device(device),
"model_type_str": "KEEP-stub",
},
)()
processor = KEEPFaceProcessor(pack)
return pack, processor, parser, keep_net
def synthetic_landmarks(num_frames: int, height: int, width: int, seed: int) -> np.ndarray:
"""One face per frame: the 512 template at a jittered in-frame position."""
rng = np.random.default_rng(seed)
base_scale = min(height, width) / 512.0 * 0.72
landmarks = np.zeros((num_frames, 5, 2), dtype=np.float64)
for i in range(num_frames):
scale = base_scale + rng.uniform(-0.005, 0.005)
center = np.array([width * 0.5 + rng.uniform(-8, 8), height * 0.46 + rng.uniform(-6, 6)])
landmarks[i] = (FACE_TEMPLATE - 256.0) * scale + center
return landmarks
def smooth_frames(num_frames: int, height: int, width: int, seed: int) -> np.ndarray:
"""The comfy IMAGE batch (N, H, W, 3) float32 [0, 1], smooth content.
Low-frequency content is the realistic case for the paste; the frame size
is the caller's, so the host-only items are priced at production size.
"""
rng = np.random.default_rng(seed)
base = rng.uniform(0.05, 0.95, size=(height, width, 3)).astype(np.float32)
yy, xx = np.mgrid[0:height, 0:width]
face_mask = ((xx - width * 0.5) ** 2 + ((yy - height * 0.46) * 1.25 ** 2) ** 2) < (150.0**2)
base[face_mask] *= 0.7
frames = np.repeat(base[None, ...], num_frames, axis=0)
frames += rng.normal(0, 0.01, size=frames.shape).astype(np.float32)
return np.clip(frames, 0.0, 1.0)
# ---------------------------------------------------------------------------
# The routing recorder (the plan's first question, answered per frame)
# ---------------------------------------------------------------------------
def frame_routes(frames_bgr, restored_faces, affine_matrices, landmarks_sequence,
gray_flags, draw_box, face_upscaler, parse_net, use_parse,
has_aligned, eligible) -> list[dict]:
"""The route and the reason of every frame - the predicates of
``src/keep_processor.py:171-189`` recomputed from the captured arguments."""
routes = []
for i in range(len(frames_bgr)):
if parse_net is None or not use_parse:
reason = "no parse net / use_parse off"
elif draw_box or face_upscaler is not None:
reason = "draw_box or face upsampler (whole batch)"
elif landmarks_sequence is None:
reason = "avg_landmarks is None (whole batch)"
elif has_aligned:
reason = "has_aligned_frames (the aligned branch)"
elif i >= len(affine_matrices) or affine_matrices[i] is None:
reason = "no affine matrix"
elif i >= len(landmarks_sequence) or np.isnan(landmarks_sequence[i]).any():
reason = "NaN landmark row"
elif gray_flags[i]:
reason = "gray frame"
elif i >= len(restored_faces):
reason = "no restored face (background only)"
elif restored_faces[i].shape[:2] != (512, 512):
reason = "restored face is not 512x512"
else:
reason = None
routes.append({
"frame": i,
"route": "batched" if i in eligible else "vendored_fallback",
"reason": reason,
})
return routes
class _RoutingRecorder:
"""Captures the eligible set, the ``_batched_paste`` arguments and the
``add_restored_face`` calls of the paste loop."""
def __init__(self, processor, helper) -> None:
self.processor = processor
self.helper = helper
self.eligible: list[int] = []
self.args: dict = {}
self.add_restored_calls = 0
self._saved: list[tuple] = []
def __enter__(self) -> "_RoutingRecorder":
original = self.processor._batched_paste
def wrapper(*args, **kwargs):
bound = dict(zip(
("input_frames_bgr", "restored_faces", "affine_matrices",
"avg_landmarks_sequence", "final_upscale_factor", "draw_box",
"gray_flags", "canvas_hw"), args))
bound.update(kwargs)
self.args = bound
result = original(*args, **kwargs)
self.eligible = sorted(result)
return result
self._saved.append(("_batched_paste", original))
self.processor._batched_paste = wrapper
original_add = self.helper.add_restored_face
def add_wrapper(face, **kwargs):
self.add_restored_calls += 1
return original_add(face, **kwargs)
self._saved.append(("add_restored_face", original_add))
self.helper.add_restored_face = add_wrapper
return self
def __exit__(self, *exc_info) -> bool:
for name, original in self._saved:
setattr(self.processor if name == "_batched_paste" else self.helper, name, original)
return False
def restore(self) -> None:
self.__exit__()
# ---------------------------------------------------------------------------
# The repeat-to-budget micro benches (the sub-tick host items)
# ---------------------------------------------------------------------------
def _repeat(timer, name: str, fn, budget_s: float = MICRO_BUDGET_S) -> int:
"""Repeat ``fn`` until it accrues ``budget_s`` of wall time; the process
CPU time of every repetition is accumulated under ``name``."""
repetitions = 0
timer.sync()
cpu_start = time.process_time()
start = time.perf_counter()
while True:
fn()
repetitions += 1
elapsed = time.perf_counter() - start
if elapsed >= budget_s and repetitions >= 3:
break
if elapsed >= 20.0 * budget_s: # a slow op: stop growing
break
cpu_elapsed = time.process_time() - cpu_start
timer.totals[name] = timer.totals.get(name, 0.0) + elapsed
timer.cpu_totals[name] = timer.cpu_totals.get(name, 0.0) + cpu_elapsed
return repetitions
def host_copy_benches(timer, device: str, height: int, width: int) -> dict:
"""The host copies of the ROI paste, at the given frame size.
The copies are inline in ``_paste_chunk_roi`` (``src/keep_gpu_paste.py``)
- they cannot be wrapped by name, so they are priced here with the exact
op sequence of that code path, repeated to the tick budget. The ROI is
the face footprint plus one pixel: a realistic patch of a 512 face at a
0.72 scale of a 360x640 frame.
"""
face_scale = min(height, width) / 512.0 * 0.72
roi_h = roi_w = max(16, int(512 * face_scale) + 8)
roi_h = min(roi_h, height)
roi_w = min(roi_w, width)
patch = np.random.default_rng(7).integers(
0, 256, size=(roi_h, roi_w, 3), dtype=np.uint8)
background = np.random.default_rng(8).integers(
0, 256, size=(height, width, 3), dtype=np.uint8)
quantized = torch.randint(0, 256, (3, roi_h, roi_w), dtype=torch.uint8)
def upload():
raw = np.ascontiguousarray(patch)
tensor = torch.from_numpy(raw).to(device)
return tensor.permute(2, 0, 1).to(torch.float32)
def download():
rows = quantized.permute(1, 2, 0).contiguous().cpu().numpy()
frame = background.copy()
frame[8:8 + roi_h, 8:8 + roi_w] = rows
return frame
def whole_stack(num=8):
frames = [np.random.default_rng(9 + i).integers(
0, 256, size=(height, width, 3), dtype=np.uint8) for i in range(num)]
return np.stack(frames)
benches = {}
benches["p4_bg_upload_reps"] = _repeat(timer, "p4_bg_upload", upload)
benches["p5_result_download_reps"] = _repeat(timer, "p5_result_download", download)
benches["p10_output_stack_reps"] = _repeat(
timer, "p10_output_stack", lambda: whole_stack(8))
benches["roi_patch_bytes"] = int(patch.nbytes)
benches["canvas_bytes"] = int(background.nbytes)
benches["frame_hw"] = [height, width]
return benches
# ---------------------------------------------------------------------------
# The attribution run
# ---------------------------------------------------------------------------
def attribute(device: str, num_frames: int, height: int, width: int, warmup: int = 2) -> dict:
probe = load_probe()
landmarks = synthetic_landmarks(num_frames, height, width, seed=17)
pack, processor, parser, keep_net = build_stub_pack(device, landmarks)
frames = smooth_frames(num_frames, height, width, seed=17)
image_sequence = torch.from_numpy(frames).to(device)
if warmup:
processor.process_image_sequence(
image_sequence[:min(warmup, num_frames)].clone(), 1.0, False, True, False,
max_clip_length=20)
stage_timer = probe.Timer(device)
paste_timer = probe.Timer(device)
import src.keep_processor as processor_mod
import src.keep_gpu_paste as paste_mod
import src.keep_progress as progress_mod
# the extended paste sub-stages: the per-frame ROI prep, the ParseNet
# forward of the stub (through ``__call__``) and the progress reports
extras: list[tuple[object, str, str]] = [
(paste_mod, "warp_face_roi", "p1b_roi_face_warp"),
(paste_mod, "face_roi_box", "p1c_roi_box"),
(paste_mod, "warp_fixed_point_terms", "p1d_fixed_point_terms"),
(paste_mod, "_background_max_outside", "p1e_bg_max_outside"),
(paste_mod, "_paste_frame_full_canvas", "p1f_full_canvas"),
(parser, "__call__", "p8a_parse_forward"),
(progress_mod.ProgressReporter, "update", "p9_progress"),
]
saved_extras: list[tuple[object, str, object]] = []
for owner, name, stage in extras:
original = getattr(owner, name, None)
if original is None:
continue
def make_wrapper(original, stage):
def wrapper(*args, **kwargs):
return paste_timer.measure(stage, lambda: original(*args, **kwargs))
return wrapper
saved_extras.append((owner, name, original))
setattr(owner, name, make_wrapper(original, stage))
routing = _RoutingRecorder(processor, processor.face_helper)
try:
with probe._StageProbe(stage_timer, processor, processor_mod) as stage_probe:
with probe._PasteProbe(paste_timer, processor, processor_mod) as paste_probe:
with routing:
processor.process_image_sequence(
image_sequence, 1.0, False, True, False, max_clip_length=20)
finally:
for owner, name, original in saved_extras:
setattr(owner, name, original)
# the F1 before/after: the SAME paste call in the host-output and the
# device-output mode, one session, one fixture. On a CPU device the
# arithmetic of the two modes is identical, so the delta is exactly the
# removed host assembly (the upload, the download, the canvas copy and
# the stack). The CUDA run of the same comparison is the owner-gated
# V2; this one is honest at the host-copy level on the CPU fixture.
captured = getattr(paste_probe, "captured", {})
comparison: dict = {
"note": (
"the same paste_faces_batch call in both output modes - the "
"device-output delta is the removed host assembly (the CPU "
"fixture prices it exactly; the CUDA run is the owner-gated V2)"
),
}
if captured.get("faces_bgr") is not None:
call_kwargs = dict(
faces_bgr=captured["faces_bgr"],
inverse_affines=captured["inverse_affines"],
background_bgr=captured["background_bgr"],
device=captured.get("device", processor.device),
parse_net=captured.get("parse_net"),
frame_chunk=None,
)
mode_timer = probe.Timer(device)
results = {}
for label, extra in (("host_output", {}), ("device_output", {"device_output": True})):
paste_mod.reset_paste_transfer_stats()
results[label] = mode_timer.measure(
label, lambda k=dict(call_kwargs, **extra): paste_mod.paste_faces_batch(**k))
stats = dict(paste_mod.PASTE_TRANSFER_STATS)
comparison[label] = {
"per_frame_ms": round(mode_timer.totals[label] * 1000.0 / num_frames, 2),
"cpu_per_frame_ms": round(mode_timer.cpu_totals[label] * 1000.0 / num_frames, 2),
"counters": stats,
}
host_frames = results["host_output"]
canvas = results["device_output"]
if torch.is_tensor(canvas):
comparison["bit_identical"] = bool(
np.array_equal(np.stack(host_frames), canvas.cpu().numpy()))
# the transfer accounting (the same pass the probe runs)
transfer: dict = {"the_batched_paste_ran": bool(routing.args)}
if routing.args:
if captured.get("faces_bgr") is not None:
counter = probe._TransferCounter()
with counter:
processor_mod.paste_faces_batch(
faces_bgr=captured["faces_bgr"],
inverse_affines=captured["inverse_affines"],
background_bgr=captured["background_bgr"],
device=captured.get("device", processor.device),
parse_net=captured.get("parse_net"))
background = np.asarray(captured["background_bgr"])
transfer.update({
"frames": int(np.asarray(captured["faces_bgr"]).shape[0]),
"h2d_bytes": counter.h2d_bytes,
"d2h_bytes": counter.d2h_bytes,
"h2d_bytes_per_frame": counter.h2d_bytes // max(int(np.asarray(captured["faces_bgr"]).shape[0]), 1),
"d2h_bytes_per_frame": counter.d2h_bytes // max(int(np.asarray(captured["faces_bgr"]).shape[0]), 1),
"canvas_bytes_per_frame": int(background[0].nbytes),
})
# the host-copy micro benches (the sub-tick items)
copy_timer = probe.Timer(device)
benches = host_copy_benches(copy_timer, device, height, width)
stage_rows, stages_total_s = probe.stage_rows(stage_timer, num_frames)
paste_rows = paste_timer.table(num_frames)
copy_rows = copy_timer.table(num_frames)
# the routes: the eligible set of the run, the reasons recomputed
args = routing.args
routes = []
if args:
routes = frame_routes(
args["input_frames_bgr"], args["restored_faces"], args["affine_matrices"],
args["avg_landmarks_sequence"], args["gray_flags"],
args["draw_box"], None, getattr(processor.face_helper, "face_parse", None),
bool(getattr(processor.face_helper, "use_parse", False)),
False, set(routing.eligible))
return {
"device": device,
"torch": torch.__version__,
"gpu": torch.cuda.get_device_name(0) if device == "cuda" else None,
"frames": num_frames,
"frame_hw": [height, width],
"fixture": "stub nets - no weights, no architectures",
"stages": {"rows": stage_rows, "total_s": stages_total_s,
"unwrapped_ops": stage_probe.missing},
"paste_substages": {
"note": (
"p1b-p1f and p8a are this instrument's extensions; p4/p5/p10 "
"are the repeat-to-budget micro benches of the host copies "
"(they cannot be wrapped by name) and live in copy_benches"
),
"rows": paste_rows,
"unwrapped_ops": paste_probe.missing,
"wall_total_s": round(sum(paste_timer.totals.values()), 3),
"cpu_total_s": round(sum(paste_timer.cpu_totals.values()), 3),
},
"routing": {
"eligible_frames": routing.eligible,
"add_restored_face_calls": routing.add_restored_calls,
"parse_forward_calls": parser.calls,
"rows": routes,
},
"copy_benches": {
"note": (
"the host copies of the ROI paste and the final np.stack, at "
"this frame size - the process CPU granularity is one system "
"tick (~15.6 ms), so every row is repeated to >= 200 ms of "
"wall and the repetition count rides along"
),
"rows": copy_rows,
"bench": benches,
},
"output_mode_comparison": comparison,
"paste_transfer_bytes": transfer,
"production_counters": dict(paste_mod.PASTE_TRANSFER_STATS),
}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda", "auto"])
parser.add_argument("--frames", type=int, default=8)
parser.add_argument("--frame-hw", type=int, nargs=2, default=[360, 640],
metavar=("H", "W"))
parser.add_argument("--warmup", type=int, default=2)
parser.add_argument("--comfyui-root", default=None)
parser.add_argument("--json", dest="json_path", default=None)
args = parser.parse_args(argv)
if args.comfyui_root:
os.environ["COMFYUI_ROOT"] = args.comfyui_root
probe = load_probe()
comfyui_root = probe.resolve_comfyui_root(args.comfyui_root)
if comfyui_root is None:
print("No ComfyUI tree found: set COMFYUI_ROOT or pass --comfyui-root")
return 2
sys.path.insert(0, comfyui_root)
sys.path.insert(0, str(REPO_ROOT))
device = args.device
if device == "auto":
device = "cuda" if torch.cuda.is_available() else "cpu"
if device == "cuda" and not torch.cuda.is_available():
print("cuda requested but not available")
return 2
report = attribute(device, args.frames, args.frame_hw[0], args.frame_hw[1],
warmup=args.warmup)
text = json.dumps(report, indent=2)
if args.json_path:
Path(args.json_path).parent.mkdir(parents=True, exist_ok=True)
Path(args.json_path).write_text(text + "\n", encoding="utf-8")
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())