None of this changes what a seed produces. All eleven golden fixtures are byte-identical and the suite passes untouched; that was the acceptance criterion for every change here. `travel_mode: jump` -- and the `jump` and `stamp` presets -- rebuilds every channel from one-channel draws and never reads the wide draw, but the wide draw was rendered first anyway and dropped on the next line. The guard that discards it turns only on values known before rendering, so it moves up into generate(). Measured on domain_warp at MiniMax H3's latent: 5.99s to 0.22s at 1344x768/124 frames, 1.44s to 0.05s at 608x352/56 frames. `drift` is unaffected -- it needs the wide draw to measure its rank, so it still pays. The draw now runs under torch.inference_mode(), worth about a tenth of it, and clones on the way out. That clone is not optional: an inference tensor raises "Inference tensors cannot be saved for backward" the moment it reaches a grad-recording region, and this noise is handed to whatever the workflow does next. domain_warp, curl_noise and temporal_coherent stopped reseeding the global RNG. All three are coordinate hashes of their seed argument, so the calls changed nothing -- verified by neutering torch.manual_seed and re-drawing -- and domain_warp made one per channel render, 888 times per draw at H3's default latent, each reseeding every CUDA device as well as the CPU. tensor_field keeps its reseed: it draws torch.randn_like inside its channel loop and that value reaches its output. Also here, because the batching work needs it: - shaders/simplex.py holds one copy of the 2D primitive, which was duplicated across three generators, and takes a tensor of seeds as well as an int so a whole channel axis can be drawn in one call. domain_warp delegates to it, bit-identical and at performance parity. The variants are kept rather than unified -- domain_warp rotates its coordinates by the seed and the others do not -- because collapsing them would silently change three generators. - tests/test_simplex.py pins how far batching stays exact. Without the rotation it is exact everywhere. With it, the coordinates differ per slice and have to be materialised, every downstream op then runs over N times as many elements, and where the per-slice count does not suit the vector width the tail differs by 3e-08 to 1.2e-07. So batching domain_warp will move the fixtures, and batching the other three will not. - verification/benchmark_draw.py, because none of the above was reproducible: the only per-draw timing in the repo predated the per-channel fill and understated the cost roughly tenfold. It refuses to let you raise the thread count quietly -- 8 threads on this 4-core box is 2.5x slower than 4. - SNK_TEST_CPU=1 pins the suite to the CPU, so it runs while a ComfyUI server on the same box is holding the GPU. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
6.2 KiB
Python
154 lines
6.2 KiB
Python
"""
|
|
How long one shader-noise draw takes, and how wide it comes out.
|
|
|
|
~/ComfyUI/venv/bin/python verification/benchmark_draw.py
|
|
~/ComfyUI/venv/bin/python verification/benchmark_draw.py --save after.json
|
|
~/ComfyUI/venv/bin/python verification/benchmark_draw.py --baseline after.json
|
|
|
|
There was no timing harness before this one, and the only per-draw number in the
|
|
repo -- HANDOFF's "about a second per draw" -- was measured before the generators
|
|
started filling their own channel axis, so it understated the real cost by roughly
|
|
an order of magnitude at video shapes. Numbers that nobody can reproduce go stale
|
|
without anyone noticing.
|
|
|
|
**Do not raise the thread count.** This box has 8 logical cores over 4 physical
|
|
ones, and forcing 8 threads makes the draw about 2.5x SLOWER -- the renders are
|
|
small enough that thread dispatch dominates. torch's default is what ComfyUI runs
|
|
with, so that is what this measures. --threads exists to demonstrate the trap, not
|
|
to tune around it.
|
|
|
|
The draw runs on the CPU in a real run: pipelines/standard.py takes its device
|
|
from the latent, and comfy's intermediate_device() is the CPU unless ComfyUI was
|
|
started with --gpu-only.
|
|
|
|
Reference run, 4 threads, torch 2.x, Ryzen-class 8-thread CPU, after Tier 0
|
|
(commit that added this file), domain_warp at octaves 3.0:
|
|
|
|
H3 608x352/56f walk 1.13s drift 1.31s jump 0.05s
|
|
H3 default walk 5.08s drift 5.97s jump 0.22s
|
|
|
|
The same shapes before Tier 0 were walk 1.38 / 5.70, drift 1.61 / 6.64,
|
|
jump 1.44 / 5.99.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def load_pack():
|
|
"""Import ComfyUI and this pack (as `snk`) the way the test suite does."""
|
|
tests = str(REPO / "tests")
|
|
if tests not in sys.path:
|
|
sys.path.insert(0, tests)
|
|
import helpers # noqa: F401
|
|
|
|
|
|
# Named for the models they come from, so a regression can be read as "H3 got slower".
|
|
SHAPES = {
|
|
"sd15 512x512": (1, 4, 64, 64),
|
|
"wan 16ch/16f": (1, 16, 16, 60, 104),
|
|
"h3 608x352/56f": (1, 24, 17, 22, 38),
|
|
"h3 1344x768/124f": (1, 24, 37, 48, 84),
|
|
"h3 audio 124f": (1, 32, 2, 207),
|
|
"ltxv 128ch": (1, 128, 3, 32, 32),
|
|
}
|
|
|
|
PARAMS = {
|
|
"scale": 1.0, "warp_strength": 0.5, "phase_shift": 0.5, "color_intensity": 0.8,
|
|
"color_scheme": "none", "shape_type": "none", "shape_mask_strength": 1.0, "time": 0.0,
|
|
}
|
|
|
|
|
|
def measure(generate, rank_of, shape, shader_type, mode, octaves, basis, repeats):
|
|
import torch
|
|
|
|
params = dict(PARAMS, octaves=octaves)
|
|
device = torch.device("cpu")
|
|
kwargs = dict(decorrelate=True, basis=basis, allow_sequence=len(shape) == 3)
|
|
|
|
noise = generate(shape, params, shader_type, 8888, device, **kwargs) # warm
|
|
best = float("inf")
|
|
for _ in range(repeats):
|
|
start = time.perf_counter()
|
|
noise = generate(shape, params, shader_type, 8888, device, **kwargs)
|
|
best = min(best, time.perf_counter() - start)
|
|
|
|
return best, rank_of(noise)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
|
|
ap.add_argument("--threads", type=int, help="override torch's default; see the module docstring")
|
|
ap.add_argument("--repeats", type=int, default=2)
|
|
ap.add_argument("--shapes", nargs="*", default=None, help="substrings of the shape names")
|
|
ap.add_argument("--shaders", nargs="*", default=None)
|
|
ap.add_argument("--modes", nargs="*", default=["walk", "drift", "jump"])
|
|
ap.add_argument("--octaves", nargs="*", type=float, default=[3.0],
|
|
help="a fractional value renders the stack twice; 3.4 shows what roam pays")
|
|
ap.add_argument("--save", type=Path)
|
|
ap.add_argument("--baseline", type=Path, help="a --save file to diff against")
|
|
ap.add_argument("--profile", action="store_true", help="cProfile one draw instead of timing all")
|
|
args = ap.parse_args()
|
|
|
|
load_pack()
|
|
import torch
|
|
from snk.core.shader_noise import effective_channel_rank, generate
|
|
from snk.core import presets
|
|
from snk.shaders.registry import list_shaders
|
|
|
|
if args.threads:
|
|
torch.set_num_threads(args.threads)
|
|
|
|
shapes = {k: v for k, v in SHAPES.items()
|
|
if not args.shapes or any(s in k for s in args.shapes)}
|
|
shaders = args.shaders or sorted(list_shaders())
|
|
|
|
print(f"torch {torch.__version__}, {torch.get_num_threads()} threads, "
|
|
f"best of {args.repeats}, cpu")
|
|
if args.threads:
|
|
print("!! --threads overrides ComfyUI's default; see the module docstring")
|
|
|
|
if args.profile:
|
|
import cProfile, pstats
|
|
shape = next(iter(shapes.values()))
|
|
pr = cProfile.Profile()
|
|
pr.enable()
|
|
generate(shape, dict(PARAMS, octaves=args.octaves[0]), shaders[0], 8888,
|
|
torch.device("cpu"), decorrelate=True, basis=presets.basis_for(args.modes[0]))
|
|
pr.disable()
|
|
pstats.Stats(pr).sort_stats("tottime").print_stats(20)
|
|
return
|
|
|
|
baseline = json.loads(args.baseline.read_text()) if args.baseline else {}
|
|
results = {}
|
|
header = f"{'shape':18s} {'shader':18s} {'mode':6s} {'oct':>4s} {'sec':>8s} {'rank':>7s}"
|
|
print(header + (" vs base" if baseline else ""))
|
|
print("-" * (len(header) + (10 if baseline else 0)))
|
|
for name, shape in shapes.items():
|
|
for shader_type in shaders:
|
|
for mode in args.modes:
|
|
for octaves in args.octaves:
|
|
key = f"{name}|{shader_type}|{mode}|{octaves}"
|
|
seconds, rank = measure(generate, effective_channel_rank, shape,
|
|
shader_type, mode, octaves,
|
|
presets.basis_for(mode), args.repeats)
|
|
results[key] = {"seconds": seconds, "rank": rank, "channels": shape[1]}
|
|
delta = ""
|
|
if key in baseline:
|
|
was = baseline[key]["seconds"]
|
|
delta = f" {was / seconds:5.2f}x" if seconds else ""
|
|
print(f"{name:18s} {shader_type:18s} {mode:6s} {octaves:4.1f} "
|
|
f"{seconds:8.2f} {rank:7.2f}{delta}")
|
|
|
|
if args.save:
|
|
args.save.write_text(json.dumps(results, indent=1))
|
|
print(f"\nwrote {args.save}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|