File size: 6,766 Bytes
678f3ac
 
 
d7bfd39
 
 
 
678f3ac
f3eac0a
 
 
 
 
ea4ada4
 
 
 
 
678f3ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea4ada4
 
f3eac0a
 
678f3ac
 
 
f3eac0a
 
678f3ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea4ada4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678f3ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea4ada4
 
 
 
 
 
 
 
 
 
 
 
678f3ac
f3eac0a
 
 
 
 
 
678f3ac
f3eac0a
 
678f3ac
 
 
f3eac0a
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""Optional profiling for `upscale_frames()`, gated by `UPSCALE_PROFILE=1` (see `upscale.PROFILE`).

Buckets each tiled `model()` call into "first call at this (tile-shape, batch-size)" vs. "steady
call" (a repeat of a shape/batch-size combo already seen this run): the first call at a new
shape is where one-off costs land (CUDA/cuDNN lazy init today; per-shape recompiles back when the
model was torch.compile'd — dropped per issue #50). Also tracks peak CUDA memory for the run. See
issue #48.

The memory line also reports what was already allocated when the run started (e.g. the diffusion
pipeline's weights, resident in the same process — `reset_peak_memory_stats` resets the peak, not
the baseline), the upscale run's own delta above that, and the device's total/free VRAM, so
`TILE_SIZE`/`FRAME_BATCH_SIZE`/`MAX_TILE_BATCH` can be tuned against real headroom. See issue #51.

Also buckets the non-model spans identified in issue #61 (frame/tensor prep, tile-shape grouping,
tile merge, the post-tile host transfer, and per-frame PIL conversion) via `span()`, so a dev-Space
run localizes exactly where the ~74%-of-wall-time "non_model" cost goes instead of leaving it as
one opaque number.

Off by default — the `synchronize()` calls this needs for accurate per-call timing would
otherwise skew real request latency by killing async kernel overlap.
"""

from __future__ import annotations

import time as _time
from contextlib import contextmanager
from typing import Iterator

import torch


class UpscaleProfiler:
    def __init__(self, enabled: bool, device: torch.device) -> None:
        self.enabled = enabled
        self._device = device
        self._seen_keys: set[tuple[tuple[int, int], int]] = set()
        self._first_call_count = 0
        self._first_call_time = 0.0
        self._steady_call_count = 0
        self._steady_call_time = 0.0
        self._span_times: dict[str, float] = {}
        self._span_counts: dict[str, int] = {}
        self._baseline_allocated = 0
        self._baseline_reserved = 0
        self._wall_t0 = _time.perf_counter()
        if self.enabled and device.type == "cuda":
            torch.cuda.reset_peak_memory_stats(device)
            self._baseline_allocated = torch.cuda.memory_allocated(device)
            self._baseline_reserved = torch.cuda.memory_reserved(device)

    @contextmanager
    def timed(self, shape: tuple[int, int], batch_size: int) -> Iterator[None]:
        """Wrap a single tiled model() call, bucketing its elapsed time by (shape, batch_size)."""
        if not self.enabled:
            yield
            return
        if self._device.type == "cuda":
            torch.cuda.synchronize()
        t0 = _time.perf_counter()
        yield
        if self._device.type == "cuda":
            torch.cuda.synchronize()
        elapsed = _time.perf_counter() - t0

        key = (shape, batch_size)
        if key in self._seen_keys:
            self._steady_call_count += 1
            self._steady_call_time += elapsed
        else:
            self._seen_keys.add(key)
            self._first_call_count += 1
            self._first_call_time += elapsed

    @contextmanager
    def span(self, name: str) -> Iterator[None]:
        """Wrap an arbitrary non-model span (e.g. tile merge, host transfer, PIL conversion),
        accumulating elapsed time under `name` for the non_model breakdown in `report()`."""
        if not self.enabled:
            yield
            return
        if self._device.type == "cuda":
            torch.cuda.synchronize()
        t0 = _time.perf_counter()
        yield
        if self._device.type == "cuda":
            torch.cuda.synchronize()
        elapsed = _time.perf_counter() - t0
        self._span_times[name] = self._span_times.get(name, 0.0) + elapsed
        self._span_counts[name] = self._span_counts.get(name, 0) + 1

    def report(self, frame_count: int, tile_size: int, frame_batch_size: int, max_tile_batch: int) -> None:
        if not self.enabled:
            return
        wall_elapsed = _time.perf_counter() - self._wall_t0
        model_time = self._first_call_time + self._steady_call_time
        steady_avg = (
            self._steady_call_time / self._steady_call_count if self._steady_call_count else float("nan")
        )
        print(
            f"[upscale] profile: frames={frame_count} wall={wall_elapsed:.2f}s model_time={model_time:.2f}s "
            f"(non_model={wall_elapsed - model_time:.2f}s) | first-at-shape calls={self._first_call_count} "
            f"total={self._first_call_time:.2f}s avg={self._first_call_time / max(self._first_call_count, 1):.3f}s/call "
            f"| steady calls={self._steady_call_count} total={self._steady_call_time:.2f}s avg={steady_avg:.3f}s/call",
            flush=True,
        )
        if self._span_times:
            non_model = wall_elapsed - model_time
            span_total = sum(self._span_times.values())
            breakdown = " ".join(
                f"{name}={t:.2f}s(n={self._span_counts[name]})"
                for name, t in sorted(self._span_times.items(), key=lambda kv: -kv[1])
            )
            print(
                f"[upscale] profile: non_model breakdown: {breakdown} | "
                f"tracked_total={span_total:.2f}s unaccounted={non_model - span_total:.2f}s",
                flush=True,
            )
        if self._device.type == "cuda":
            gib = 1024**3
            peak_allocated = torch.cuda.max_memory_allocated(self._device)
            peak_reserved = torch.cuda.max_memory_reserved(self._device)
            # mem_get_info is driver-level: free/total across every process on the device, not just
            # this caching allocator — "free" is what's actually left right now, after the run.
            free, total = torch.cuda.mem_get_info(self._device)
            print(
                f"[upscale] profile: peak CUDA memory allocated={peak_allocated / gib:.2f} GiB "
                f"reserved={peak_reserved / gib:.2f} GiB (TILE_SIZE={tile_size}, "
                f"FRAME_BATCH_SIZE={frame_batch_size}, MAX_TILE_BATCH={max_tile_batch})",
                flush=True,
            )
            print(
                f"[upscale] profile: baseline at start allocated={self._baseline_allocated / gib:.2f} GiB "
                f"reserved={self._baseline_reserved / gib:.2f} GiB | upscale delta "
                f"allocated=+{(peak_allocated - self._baseline_allocated) / gib:.2f} GiB "
                f"reserved=+{(peak_reserved - self._baseline_reserved) / gib:.2f} GiB | device "
                f"total={total / gib:.2f} GiB free_now={free / gib:.2f} GiB "
                f"headroom_vs_peak_reserved={(total - peak_reserved) / gib:.2f} GiB",
                flush=True,
            )