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
|