Skip to content

Commit 8998f4e

Browse files
authored
Add step-level performance profiling to the trainer (#722)
<!-- markdownlint-disable --> <!-- PLEASE FILL IN THE PR DESCRIPTION HERE ENSURING ALL CHECKLIST ITEMS (AT THE BOTTOM) HAVE BEEN CONSIDERED. --> ## Purpose Add step-level performance profiling to the trainer (resolves #717, trainer portion). Emits 7 new metrics through the existing `speculators.metrics` logger: `profile/fetch_ms`, `profile/fwd_ms`, `profile/bwd_ms`, `profile/opt_ms`, `profile/step_ms`, `profile/tokens_per_s`, `profile/fetch_frac`. ## Tests Add unit tests, can be run with: ```bash python3 -m pytest tests/unit/train/test_step_timer.py -v ``` ## Checklist I have filled in: - [x] The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)". - [x] The test plan/results, such as providing test command and pasting the results. - [ ] (Optional) The necessary documentation update. - [x] I (a human) have written or reviewed the code in this pr to the best of my ability. Signed-off-by: Omer Aplatony <omerap12@gmail.com>
1 parent 4e10de4 commit 8998f4e

2 files changed

Lines changed: 187 additions & 1 deletion

File tree

src/speculators/train/trainer.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
import time
34
import warnings
45
from pathlib import Path
56
from typing import Literal, NamedTuple
@@ -37,6 +38,57 @@
3738
root_logger = logging.getLogger("speculators")
3839
metric_logger = logging.getLogger("speculators.metrics")
3940

41+
42+
class _StepTimer:
43+
# Each mark()/now() forces a cuda.synchronize to capture true GPU time.
44+
# This serialises the CUDA pipeline, so profiled steps are slower; keep
45+
# log_freq > 1 in perf-sensitive runs.
46+
def __init__(self, enabled: bool = False):
47+
self.enabled = enabled
48+
self._marks: dict[str, float] = {}
49+
50+
def reset(self, enabled: bool) -> None:
51+
self.enabled = enabled
52+
self._marks.clear()
53+
54+
def mark(self, name: str) -> None:
55+
if self.enabled:
56+
torch.cuda.synchronize()
57+
self._marks[name] = time.perf_counter()
58+
59+
def mark_value(self, name: str, value: float) -> None:
60+
if self.enabled:
61+
self._marks[name] = value
62+
63+
def now(self) -> float | None:
64+
if not self.enabled:
65+
return None
66+
torch.cuda.synchronize()
67+
return time.perf_counter()
68+
69+
def profile(self, num_tokens: int) -> dict[str, float] | None:
70+
if not self.enabled:
71+
return None
72+
m = self._marks
73+
has_start = "start" in m
74+
fwd_ms = (m["fwd"] - m["fetch"]) * 1000
75+
bwd_ms = (m["bwd"] - m["fwd"]) * 1000
76+
opt_ms = (m["opt"] - m["bwd"]) * 1000
77+
fetch_ms = (m["fetch"] - m["start"]) * 1000 if has_start else 0.0
78+
step_ms = (m["opt"] - m["start"]) * 1000 if has_start else 0.0
79+
tokens_per_s = num_tokens / (step_ms / 1000) if step_ms > 0 else 0.0
80+
fetch_frac = fetch_ms / step_ms if step_ms > 0 else 0.0
81+
return {
82+
"fetch_ms": fetch_ms,
83+
"fwd_ms": fwd_ms,
84+
"bwd_ms": bwd_ms,
85+
"opt_ms": opt_ms,
86+
"step_ms": step_ms,
87+
"tokens_per_s": tokens_per_s,
88+
"fetch_frac": fetch_frac,
89+
}
90+
91+
4092
warnings.filterwarnings("ignore", category=TqdmExperimentalWarning)
4193
MIN_STEP_PCT = 0.25
4294

@@ -326,31 +378,45 @@ def train_epoch(self, epoch: int):
326378
if self.config.checkpoint_freq < 1
327379
else None
328380
)
381+
t_before_fetch = time.perf_counter()
382+
timer = _StepTimer()
329383
for local_step_rel, batch in enumerate(train_loader, 1):
330384
# local_step is 1-based index into the *full* epoch (not the slice).
331385
local_step = local_step_rel + skip_steps
386+
timer.reset(self.global_step % self.config.log_freq == 0)
387+
388+
timer.mark_value("start", t_before_fetch)
332389
gpu_batch = {
333390
k: v.to(self.local_rank, non_blocking=True)
334391
if isinstance(v, torch.Tensor)
335392
else v
336393
for k, v in batch.items()
337394
}
338395

396+
timer.mark("fetch")
339397
_draft_tokens, loss, metrics = self.model(
340398
**gpu_batch, **self.config.train_call_kwargs
341399
)
342400

401+
timer.mark("fwd")
343402
self._optimizers_zero_grad()
344403
loss.backward()
345404
torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0)
405+
406+
timer.mark("bwd")
346407
self._optimizers_step()
347408

348409
current_lrs = {
349410
type(opt).__name__: opt.param_groups[0]["lr"] for opt in self.optimizers
350411
}
351412
self._schedulers_step()
413+
timer.mark("opt")
414+
t_before_fetch = timer.now() or time.perf_counter()
352415

353-
if self.global_step % self.config.log_freq == 0:
416+
profile = None
417+
if timer.enabled:
418+
num_tokens = int((gpu_batch["document_ids"] != -1).sum().item())
419+
profile = timer.profile(num_tokens)
354420
if self.is_distributed:
355421
for v in metrics.values():
356422
dist.reduce(v, dst=0, op=dist.ReduceOp.SUM)
@@ -366,6 +432,7 @@ def train_epoch(self, epoch: int):
366432
metric_logger.info(
367433
{
368434
"train": metrics,
435+
"profile": profile,
369436
"epoch": epoch,
370437
"lr": lr_info,
371438
"global_step": self.global_step,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from unittest.mock import patch
2+
3+
import pytest
4+
5+
from speculators.train.trainer import _StepTimer
6+
7+
8+
def test_disabled_timer_returns_none():
9+
timer = _StepTimer(enabled=False)
10+
timer.mark_value("start", 0.0)
11+
timer.mark("fetch")
12+
timer.mark("fwd")
13+
timer.mark("bwd")
14+
timer.mark("opt")
15+
assert timer.now() is None
16+
assert timer.profile(num_tokens=1024) is None
17+
18+
19+
@patch("speculators.train.trainer.torch.cuda.synchronize")
20+
def test_enabled_timer_returns_profile(mock_sync):
21+
timer = _StepTimer(enabled=True)
22+
23+
with patch(
24+
"speculators.train.trainer.time.perf_counter",
25+
side_effect=[
26+
0.1,
27+
0.3,
28+
0.5,
29+
0.6,
30+
0.6,
31+
],
32+
):
33+
timer.mark_value("start", 0.0)
34+
timer.mark("fetch")
35+
timer.mark("fwd")
36+
timer.mark("bwd")
37+
timer.mark("opt")
38+
t_next = timer.now()
39+
40+
assert mock_sync.call_count == 5
41+
assert t_next == 0.6
42+
43+
profile = timer.profile(num_tokens=4096)
44+
assert profile is not None
45+
assert profile["fetch_ms"] == (0.1 - 0.0) * 1000
46+
assert profile["fwd_ms"] == (0.3 - 0.1) * 1000
47+
assert profile["bwd_ms"] == (0.5 - 0.3) * 1000
48+
assert profile["opt_ms"] == (0.6 - 0.5) * 1000
49+
assert profile["step_ms"] == (0.6 - 0.0) * 1000
50+
assert profile["tokens_per_s"] == 4096 / 0.6
51+
assert profile["fetch_frac"] == 100 / 600
52+
53+
54+
def test_disabled_to_enabled_transition():
55+
"""Simulate log_freq=2: a disabled step followed by an enabled step.
56+
57+
The disabled step's ``timer.now()`` returns None so the training loop
58+
falls back to ``time.perf_counter()`` for ``t_before_fetch``. The
59+
subsequent enabled step feeds that value into ``mark_value("start", ...)``.
60+
Verify the profile is valid with a realistic ``start`` mark.
61+
"""
62+
timer = _StepTimer()
63+
64+
# --- disabled step (global_step=1, log_freq=2 → 1%2 != 0) ---
65+
timer.reset(enabled=False)
66+
timer.mark_value("start", 1.0)
67+
timer.mark("fetch")
68+
timer.mark("fwd")
69+
timer.mark("bwd")
70+
timer.mark("opt")
71+
assert timer.now() is None
72+
assert timer.profile(num_tokens=512) is None
73+
74+
# Simulate the fallback: t_before_fetch = timer.now() or time.perf_counter()
75+
t_before_fetch = 2.0 # stands in for the perf_counter() fallback
76+
77+
# --- enabled step (global_step=2, log_freq=2 → 2%2 == 0) ---
78+
timer.reset(enabled=True)
79+
timer.mark_value("start", t_before_fetch)
80+
81+
with (
82+
patch("speculators.train.trainer.torch.cuda.synchronize"),
83+
patch(
84+
"speculators.train.trainer.time.perf_counter",
85+
side_effect=[2.1, 2.4, 2.5, 2.6, 2.6],
86+
),
87+
):
88+
timer.mark("fetch")
89+
timer.mark("fwd")
90+
timer.mark("bwd")
91+
timer.mark("opt")
92+
t_next = timer.now()
93+
94+
assert t_next == 2.6
95+
profile = timer.profile(num_tokens=2048)
96+
assert profile is not None
97+
assert profile["fetch_ms"] == (2.1 - 2.0) * 1000
98+
assert profile["fwd_ms"] == (2.4 - 2.1) * 1000
99+
assert profile["bwd_ms"] == (2.5 - 2.4) * 1000
100+
assert profile["opt_ms"] == (2.6 - 2.5) * 1000
101+
assert profile["step_ms"] == (2.6 - 2.0) * 1000
102+
assert profile["tokens_per_s"] == pytest.approx(2048 / 0.6)
103+
104+
105+
def test_zero_step_ms_returns_zero_throughput():
106+
timer = _StepTimer(enabled=True)
107+
timer.mark_value("start", 1.0)
108+
with (
109+
patch("speculators.train.trainer.torch.cuda.synchronize"),
110+
patch("speculators.train.trainer.time.perf_counter", return_value=1.0),
111+
):
112+
timer.mark("fetch")
113+
timer.mark("fwd")
114+
timer.mark("bwd")
115+
timer.mark("opt")
116+
profile = timer.profile(num_tokens=4096)
117+
assert profile is not None
118+
assert profile["tokens_per_s"] == 0.0
119+
assert profile["fetch_frac"] == 0.0

0 commit comments

Comments
 (0)