File size: 7,837 Bytes
d1ecac0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""Time captioning latency through the shared Phase 3 :class:`Captioner` interface.

Implements the timing rules of ``docs/EVAL_METHODOLOGY.md`` § 9:

- One sample is the wall-clock time of one ``Captioner.caption()`` call on one
  batch of image paths, read from a monotonic clock. The call covers image
  reading and decoding, preprocessing, generation, token decoding and caption
  normalisation.
- Loading the model is timed once, separately, and is never part of a sample.
- For each batch size, the inputs are split into consecutive full batches in
  input order. Warmup passes caption every batch untimed, then each measured
  pass times every call.
- Nothing is filtered: every measured call is a sample, and any failed call
  ends the benchmark.

Nothing here imports TensorFlow, ``torch`` or ``transformers``.
"""

from __future__ import annotations

import importlib.metadata
import platform
import statistics
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from captioning.baselines import Captioner

Clock = Callable[[], float]

LATENCY_PROTOCOL = "docs/EVAL_METHODOLOGY.md § 9"
CLOCK_NAME = "time.perf_counter"
# Distributions whose installed versions every latency run records.
RUNTIME_PACKAGES = ("tensorflow", "tensorflow-cpu", "torch", "transformers")


class LatencyBenchmarkError(RuntimeError):
    """A timed call broke the benchmark's rules, so no result is produced."""


@dataclass(frozen=True)
class LatencySettings:
    """What one latency run measures.

    Attributes:
        num_images: How many images, from the start of the slice, form the inputs.
        batch_sizes: Batch sizes to time, unique and ascending. Each must divide
            ``num_images``, so every call captions a full batch.
        warmup_passes: Untimed passes over the inputs before measuring, per
            batch size. At least one.
        measured_passes: Timed passes over the inputs, per batch size.
    """

    num_images: int
    batch_sizes: tuple[int, ...]
    warmup_passes: int
    measured_passes: int

    def __post_init__(self) -> None:
        if self.num_images < 1:
            raise ValueError(f"num_images must be at least 1, got {self.num_images}")
        if not self.batch_sizes:
            raise ValueError("at least one batch size is needed")
        if any(size < 1 for size in self.batch_sizes):
            raise ValueError(f"batch sizes must be at least 1, got {list(self.batch_sizes)}")
        if list(self.batch_sizes) != sorted(set(self.batch_sizes)):
            raise ValueError(
                f"batch sizes must be unique and ascending, got {list(self.batch_sizes)}"
            )
        uneven = [size for size in self.batch_sizes if self.num_images % size]
        if uneven:
            raise ValueError(
                f"num_images {self.num_images} is not a multiple of batch size(s) {uneven}; "
                "every timed call must caption a full batch"
            )
        if self.warmup_passes < 1:
            raise ValueError(
                f"at least one warmup pass is needed, got {self.warmup_passes}; "
                "the first call must never be timed"
            )
        if self.measured_passes < 1:
            raise ValueError(f"measured_passes must be at least 1, got {self.measured_passes}")

    def to_dict(self) -> dict[str, object]:
        return {
            "num_images": self.num_images,
            "batch_sizes": list(self.batch_sizes),
            "warmup_passes": self.warmup_passes,
            "measured_passes": self.measured_passes,
        }


@dataclass(frozen=True)
class BatchLatency:
    """Every timed call for one batch size, in measurement order."""

    batch_size: int
    calls_per_pass: int
    samples: tuple[float, ...]

    def to_dict(self) -> dict[str, object]:
        return {
            "batch_size": self.batch_size,
            "calls_per_pass": self.calls_per_pass,
            "samples_seconds": list(self.samples),
            "summary_seconds": summarize(self.samples),
        }


def summarize(samples: Sequence[float]) -> dict[str, float | int]:
    """Return the § 9 statistics of ``samples``: count, mean, median, min and max."""
    if not samples:
        raise ValueError("no samples to summarise")
    return {
        "count": len(samples),
        "mean": statistics.fmean(samples),
        "median": statistics.median(samples),
        "min": min(samples),
        "max": max(samples),
    }


def time_load(
    factory: Callable[[], Captioner], *, clock: Clock = time.perf_counter
) -> tuple[Captioner, float]:
    """Build a captioner and load its weights, timing both steps together.

    The CNN + Transformer loads its checkpoint when it is constructed, and the
    Hugging Face adapters in ``load()``, so the load time covers both. It also
    includes any framework import or Hub download those steps trigger.
    """
    start = clock()
    captioner = factory()
    captioner.load()
    return captioner, _elapsed(start, clock())


def measure_latency(
    captioner: Captioner,
    image_paths: Sequence[str | Path],
    settings: LatencySettings,
    *,
    clock: Clock = time.perf_counter,
) -> list[BatchLatency]:
    """Time ``captioner`` on ``image_paths`` at every batch size in ``settings``.

    Batch sizes run in ascending order. For each one, the warmup passes run
    first and are never timed; each measured pass then times every batch, in
    input order. Only the ``caption()`` call sits between the two clock reads.

    Raises:
        ValueError: If ``image_paths`` doesn't hold exactly ``settings.num_images``
            paths.
        LatencyBenchmarkError: If a call returns the wrong number of captions,
            or the clock goes backwards.
        Exception: Whatever a failed ``caption()`` call raises, unchanged.
    """
    paths = [Path(p) for p in image_paths]
    if len(paths) != settings.num_images:
        raise ValueError(f"expected {settings.num_images} images, got {len(paths)}")

    results: list[BatchLatency] = []
    for batch_size in settings.batch_sizes:
        batches = [paths[i : i + batch_size] for i in range(0, len(paths), batch_size)]
        for _ in range(settings.warmup_passes):
            for batch in batches:
                _check_captions(captioner, batch, captioner.caption(batch))
        samples: list[float] = []
        for _ in range(settings.measured_passes):
            for batch in batches:
                start = clock()
                captions = captioner.caption(batch)
                end = clock()
                _check_captions(captioner, batch, captions)
                samples.append(_elapsed(start, end))
        results.append(BatchLatency(batch_size, len(batches), tuple(samples)))
    return results


def runtime_info() -> dict[str, object]:
    """Python, platform and installed framework versions (``None`` if not installed)."""
    return {
        "python": platform.python_version(),
        "platform": platform.platform(),
        "packages": {name: _installed_version(name) for name in RUNTIME_PACKAGES},
    }


def _installed_version(distribution: str) -> str | None:
    try:
        return importlib.metadata.version(distribution)
    except importlib.metadata.PackageNotFoundError:
        return None


def _check_captions(captioner: Captioner, batch: list[Path], captions: Sequence[str]) -> None:
    if len(captions) != len(batch):
        raise LatencyBenchmarkError(
            f"{captioner.identity.model_id}: {len(captions)} captions for {len(batch)} images"
        )


def _elapsed(start: float, end: float) -> float:
    if end < start:
        raise LatencyBenchmarkError(f"the clock went backwards ({start} -> {end})")
    return end - start