Repository navigation
Expand file tree
/
Copy pathvisualize_interactive.py
More file actions
2546 lines (2286 loc) · 115 KB
/
Copy pathvisualize_interactive.py
File metadata and controls
2546 lines (2286 loc) · 115 KB
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Interactive Bokeh visualizer for a cupti_profiler run.
Static mode (default):
python tools/visualize_interactive.py profiling_output/session_metadata.pb
# → writes the page and serves it on http://127.0.0.1:8000 (it never
# opens a browser: point yours at the URL it logs; --host 0.0.0.0
# serves on every interface).
Live mode (--live):
python tools/visualize_interactive.py --live \\
profiling_output/session_metadata.pb
# → starts a Bokeh server that tails the .pb files as the suite
# writes them, streaming new samples into the running document.
Catalog + panel layout are loaded the same way as visualize_all.py:
the catalog is inlined into session_metadata.pb; the layout defaults
to configs/visualizer_panels.pbtxt.
Live-mode dynamic series allocation (mid-run PID join, removal
markers) lands in the follow-up commit.
"""
from __future__ import annotations
import argparse
import http.server
import os
import socketserver
import sys
import time
from pathlib import Path
# Missing packages or generated protobuf modules: say what to run, not an
# ImportError traceback (tools/ is on sys.path: this script's directory).
from gen_protos import require_viewer_modules # noqa: E402
require_viewer_modules("visualize_interactive.py", ["numpy", "bokeh"])
import numpy as np
# Sibling tools + generated proto packages.
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE))
sys.path.insert(0, str(_HERE.parent / "generated" / "proto"))
from google.protobuf.internal.decoder import _DecodeVarint32 # noqa: E402
import metric_catalog_pb2 as mc_pb # noqa: E402
import panels_pb2 as panels_pb # noqa: E402
import gpu_metrics_pb2 # noqa: E402
import system_metrics_pb2 # noqa: E402
import disk_metrics_pb2 # noqa: E402
import events_pb2 # noqa: E402
import session_metadata_pb2 # noqa: E402
import metric_catalog # noqa: E402
import metric_layout # noqa: E402
import metric_suffix # noqa: E402
import panel_legend # noqa: E402
import units # noqa: E402
import write_rate # noqa: E402
import process_timeline # noqa: E402
import label_spread # noqa: E402
import decimate # noqa: E402
from metric_projector import TraceProjector # noqa: E402
from trace_paths import probe_file, resolve_probe_path, warn_on_version_mismatch # noqa: E402
from bokeh.application import Application # noqa: E402
from bokeh.application.handlers.function import FunctionHandler # noqa: E402
from bokeh.embed import file_html # noqa: E402
from bokeh.themes import built_in_themes # noqa: E402
from bokeh.layouts import column, row # noqa: E402
from bokeh.models import (BoxAnnotation, BoxZoomTool, ColumnDataSource, # noqa: E402
Button, CustomJS, CustomJSHover, FixedTicker, HoverTool, InlineStyleSheet, Label,
Legend, Spacer,
LegendItem, PanTool,
Range1d, ResetTool, SaveTool, Span, WheelZoomTool)
from bokeh.palettes import Category10 # noqa: E402
from bokeh.plotting import figure # noqa: E402
from bokeh.resources import INLINE # noqa: E402
from bokeh.server.server import Server # noqa: E402
_T0 = time.perf_counter()
def _log(msg: str) -> None:
now = time.localtime()
ms = int((time.time() - int(time.time())) * 1000)
ts = f"{now.tm_hour:02d}:{now.tm_min:02d}:{now.tm_sec:02d}.{ms:03d}"
print(f"[{ts}] (+{time.perf_counter() - _T0:6.3f}s) {msg}", flush=True)
# ---------------------------------------------------------------------------
# Wire reading (same helpers as visualize_all.py)
# ---------------------------------------------------------------------------
def _read_delimited(path: str | Path, msg_cls) -> list:
with open(path, "rb") as f:
buf = f.read()
out, pos = [], 0
while pos < len(buf):
size, new_pos = _DecodeVarint32(buf, pos)
pos = new_pos
m = msg_cls()
m.ParseFromString(buf[pos:pos + size])
out.append(m)
pos += size
return out
def _load_session_metadata(path: str | Path) -> session_metadata_pb2.SessionMetadata:
with open(path, "rb") as f:
meta = session_metadata_pb2.SessionMetadata()
meta.ParseFromString(f.read())
warn_on_version_mismatch(meta, path, "visualize_interactive.py")
return meta
def _ingest_probes(
projector: TraceProjector,
meta: session_metadata_pb2.SessionMetadata,
metadata_path: Path,
) -> tuple[dict[str, int], dict[str, dict]]:
"""Ingest each probe's .pb file into the projector.
Returns (sample_freqs, probes_info) where probes_info holds the
per-probe path + first-trace scope info + sample count needed to
render the "Write rate" footer. The first trace suffices because
ScopeMetricNames is emitted identically on every flush.
"""
sample_freqs: dict[str, int] = {}
probes_info: dict[str, dict] = {}
for probe in meta.probes:
out = probe_file(metadata_path, probe, "visualize_interactive.py")
if out is None:
continue
if probe.kind == session_metadata_pb2.PROBE_KIND_GPU:
traces = _read_delimited(out, gpu_metrics_pb2.GPUMetricsTrace)
for t in traces:
projector.ingest_gpu(t)
sample_freqs["gpu"] = probe.sampling_frequency_hz
probes_info["gpu"] = {
"path": out,
"traces_head": traces[0] if traces else None,
"n_samples": sum(len(t.samples) for t in traces),
}
elif probe.kind == session_metadata_pb2.PROBE_KIND_SYSTEM:
traces = _read_delimited(out, system_metrics_pb2.SystemMetricsTrace)
for t in traces:
projector.ingest_system(t)
sample_freqs["system"] = probe.sampling_frequency_hz
n_sys = sum(len(t.system_samples) for t in traces)
n_proc = sum(len(t.process_samples) for t in traces)
probes_info["system"] = {
"path": out,
"traces_head": traces[0] if traces else None,
"n_samples": max(n_sys, n_proc),
}
elif probe.kind == session_metadata_pb2.PROBE_KIND_DISK:
traces = _read_delimited(out, disk_metrics_pb2.DiskMetricsTrace)
for t in traces:
projector.ingest_disk(t)
sample_freqs["disk"] = probe.sampling_frequency_hz
n_dev = sum(len(t.device_samples) for t in traces)
n_proc = sum(len(t.process_samples) for t in traces)
probes_info["disk"] = {
"path": out,
"traces_head": traces[0] if traces else None,
"n_samples": max(n_dev, n_proc),
}
elif probe.kind == session_metadata_pb2.PROBE_KIND_EVENTS:
probes_info["events"] = {
"path": out,
"traces_head": None,
"n_samples": 0, # regions + events counted at render time
}
return sample_freqs, probes_info
# ---------------------------------------------------------------------------
# Write-rate footer: its rows (the table: tools/write_rate.py, shared with
# visualize_all.py)
# ---------------------------------------------------------------------------
def _est_bytes_per_sample(n_metrics: int, extra_tag: int = 0) -> int:
# rough: 2 varint timestamps (~10 B) + N doubles (9 B) + ~3 B tag
return 2 * 10 + n_metrics * 9 + 3 + extra_tag
def _probe_duration_s(projector: TraceProjector, proj, probe_key: str) -> float:
ts_min = ts_max = None
for (fqn, _k), (ts, _v) in proj.items():
src = projector.fqn_to_probe.get(fqn)
if src != probe_key or ts.size == 0:
continue
a, b = int(ts[0]), int(ts[-1])
if ts_min is None or a < ts_min: ts_min = a
if ts_max is None or b > ts_max: ts_max = b
if ts_min is None or ts_max is None or ts_max <= ts_min:
return 0.0
return (ts_max - ts_min) / 1e9
def _build_write_rate_rows(
projector: TraceProjector,
proj,
sample_freqs: dict[str, int],
probes_info: dict[str, dict],
events_regions: int,
events_ns: int,
xmax_s: float,
) -> list[tuple]:
"""Same rows as visualize_all.py's footer: (label, sampling_hz,
est_bps, meas_bps, n_samples)."""
rows: list[tuple] = []
# GPU
info = probes_info.get("gpu")
if info and info["traces_head"] is not None:
head = info["traces_head"]
n_metrics = sum(len(smn.fqns) for smn in head.scope_metric_names)
est = sample_freqs.get("gpu", 0) * _est_bytes_per_sample(n_metrics)
dur = _probe_duration_s(projector, proj, "gpu")
meas = os.path.getsize(info["path"]) / dur if dur > 0 else 0.0
rows.append(("GPU", sample_freqs.get("gpu", 0), est, meas, info["n_samples"]))
# System
info = probes_info.get("system")
if info and info["traces_head"] is not None:
head = info["traces_head"]
n_sys_fqns = n_proc_fqns = 0
for smn in head.scope_metric_names:
if smn.scope == mc_pb.SCOPE_SYSTEM: n_sys_fqns = len(smn.fqns)
if smn.scope == mc_pb.SCOPE_PROCESS: n_proc_fqns = len(smn.fqns)
n_pids = len(head.tracked_processes) or 1
bytes_per_tick = (_est_bytes_per_sample(n_sys_fqns)
+ n_pids * _est_bytes_per_sample(n_proc_fqns, extra_tag=4))
est = sample_freqs.get("system", 0) * bytes_per_tick
dur = _probe_duration_s(projector, proj, "system")
meas = os.path.getsize(info["path"]) / dur if dur > 0 else 0.0
rows.append(("System", sample_freqs.get("system", 0), est, meas, info["n_samples"]))
# Disk
info = probes_info.get("disk")
if info and info["traces_head"] is not None:
head = info["traces_head"]
n_dev_fqns = n_proc_fqns = 0
for smn in head.scope_metric_names:
if smn.scope == mc_pb.SCOPE_DEVICE: n_dev_fqns = len(smn.fqns)
if smn.scope == mc_pb.SCOPE_PROCESS: n_proc_fqns = len(smn.fqns)
n_devs = len(head.tracked_devices) or 1
n_pids = len(head.tracked_processes) or 0
bytes_per_tick = (n_devs * _est_bytes_per_sample(n_dev_fqns, extra_tag=8)
+ n_pids * _est_bytes_per_sample(n_proc_fqns, extra_tag=4))
est = sample_freqs.get("disk", 0) * bytes_per_tick
dur = _probe_duration_s(projector, proj, "disk")
meas = os.path.getsize(info["path"]) / dur if dur > 0 else 0.0
rows.append(("Disk", sample_freqs.get("disk", 0), est, meas, info["n_samples"]))
# Events — no estimated rate (user-driven emission)
info = probes_info.get("events")
if info and info["path"] and info["path"].exists():
n_samp = events_regions + events_ns
dur = xmax_s
meas = os.path.getsize(info["path"]) / dur if dur > 0 else 0.0
rows.append(("Events", None, 0.0, meas, n_samp))
return rows
# ---------------------------------------------------------------------------
# Unit scaling — same logic as visualize_all.py
# ---------------------------------------------------------------------------
def _format_unit_axis(unit: int, data_max: float | None):
"""Return (scale_fn, ylabel). Bytes and bytes/s: the unit from the
largest value plotted on the axis (units.byte_unit: 2x thresholds)."""
if unit in (mc_pb.UNIT_PCT, mc_pb.UNIT_PCT_OF_CORE):
return (lambda v: v, "%")
if unit == mc_pb.UNIT_RATIO:
return (lambda v: v, "ratio")
if unit == mc_pb.UNIT_REQUESTS:
return (lambda v: v, "requests in-flight")
if unit == mc_pb.UNIT_HZ:
return (lambda v: v / 1e6, "MHz")
if unit in (mc_pb.UNIT_BYTES, mc_pb.UNIT_BYTES_PER_SEC):
div, label = units.byte_unit(data_max, rate=unit == mc_pb.UNIT_BYTES_PER_SEC)
return (lambda v: v / div, label)
return (lambda v: v, "")
def _smooth(vals: np.ndarray, k: int) -> np.ndarray:
"""Boxcar smoothing with a window of `k` samples — mirrors
visualize_all._smooth. Used to suppress per-sample noise on
smoothable metrics when the user opts in via --smooth-window-s."""
n = vals.size
if k <= 1 or n == 0:
return vals
k = min(k, n)
half_l = k // 2
half_r = k - half_l - 1
csum = np.concatenate(([0.0], np.cumsum(vals, dtype=np.float64)))
idx = np.arange(n, dtype=np.int64)
lo = np.maximum(idx - half_l, 0)
hi = np.minimum(idx + half_r + 1, n)
win = (hi - lo).astype(np.float64)
return ((csum[hi] - csum[lo]) / win).astype(vals.dtype, copy=False)
def _kernel_size(sample_freq_hz: float, window_s: float) -> int:
return max(1, int(round(sample_freq_hz * window_s)))
def _make_plot_tools(include_save: bool = True) -> tuple[list, BoxZoomTool, WheelZoomTool]:
"""Build the per-figure tool set:
- PanTool (toolbar only — not the active drag)
- WheelZoomTool ctrl-modifier gated, so plain scroll falls
through to the browser (page scroll) and
ctrl+scroll triggers x-axis zoom; maintain_focus
off so cursor-anchored zoom near an edge that
already touches a bound absorbs the remaining
zoom on the other side instead of being dropped
- BoxZoomTool click-drag rectangle on the plot zooms the
x-axis to the selected region (rubber-band)
- ResetTool
- SaveTool (optional — skipped on strips)
Returns (tools, box_zoom, wheel_zoom) so callers can pin
active_drag and active_scroll to the right instances."""
pan = PanTool(dimensions="width")
wheel = WheelZoomTool(dimensions="width",
modifiers={"ctrl": True},
maintain_focus=False)
boxz = BoxZoomTool(dimensions="width")
tools: list = [pan, wheel, boxz, ResetTool()]
if include_save:
tools.append(SaveTool())
return tools, boxz, wheel
def _decimate_to_hz(ts_ns: np.ndarray, vals: np.ndarray,
source_hz: float, target_hz: float
) -> tuple[np.ndarray, np.ndarray]:
"""Stride-based downsampling: keep every Nth sample where
N = source_hz / target_hz. No-op when target_hz <= 0, source_hz
<= 0, or source_hz <= target_hz."""
if target_hz <= 0 or source_hz <= 0 or source_hz <= target_hz:
return ts_ns, vals
step = max(1, int(round(source_hz / target_hz)))
return ts_ns[::step], vals[::step]
def _resolve_panel_peak(panel, descriptor, projector: TraceProjector) -> float | None:
which = panel.WhichOneof("peak")
if which == "peak_constant":
return float(panel.peak_constant)
if which == "peak_from_descriptor_ref":
return projector.lookup_first_value(panel.peak_from_descriptor_ref)
if which == "peak_from_expr":
fn = metric_catalog.PEAK_EXPRS.get(panel.peak_from_expr)
return float(fn(projector.host)) if fn else None
if which == "peak_from_gpu_info":
if not projector.gpu_info:
return None
first = next(iter(projector.gpu_info.values()))
v = float(getattr(first, panel.peak_from_gpu_info, 0.0))
return v if v > 0 else None
return metric_catalog.resolve_peak(descriptor, projector.host,
projector.lookup_first_value)
# Legend labels: shared with visualize_all.py.
_series_label = panel_legend.series_label
# ---------------------------------------------------------------------------
# Panel building (Bokeh)
# ---------------------------------------------------------------------------
# Bokeh color palette — Category10 has 10 distinct colors; cycle past that.
_PALETTE = list(Category10[10])
# Every figure on the page — strips, timeline, panels — has the same
# plot-frame left edge and width, so a time is at the same x everywhere.
# The left border is fixed, not fitted: Bokeh would size it to the widest
# y tick labels (+ the toolbar) of each figure, which differ from panel to
# panel. 140 px holds the toolbar (30 px), the y label and ticks of up to
# 9 characters (measured in headless Chrome: every frame 140 -> 1000 px).
_BORDER_LEFT_PX = 140
_STRIP_LEFT_PX = _BORDER_LEFT_PX
_FRAME_WIDTH = 860
# Plot-frame height of a metric panel; the title, the legend above and
# the axis below add to it (so a long legend never squeezes the plot).
_FRAME_HEIGHT = 150
# Bokeh `output_backend` applied to every figure. main() overrides via
# --render-backend. canvas became the default on a page of ~6k points
# over ~15 panels, where it was roughly 4-5× faster to first paint than
# webgl (canvas 585 ms vs webgl 2687 ms, headless Chromium; some GPU
# drivers stall on webgl ReadPixels and blow up by 20-50×). Today's pages
# are larger: the full-system example (100 Hz GPU) is ~14k points over
# 21 figures (at most 1.4k in one), the vLLM serving example (1 kHz GPU)
# ~3.1M points over 17 figures (up to ~0.5M in one), counted 2026-09-29
# without --display-hz. webgl was not re-measured at these sizes, nor in
# a real (non-headless) browser; it wins on pan/zoom repaints and stays
# available as an opt-in. Long series are now drawn reduced to the view's
# pixel columns (_decimate_lines): the vLLM page draws ~0.22 M of its
# ~3.1 M points.
_RENDER_BACKEND = "canvas"
# Headroom above a known peak when sizing y_range, so the dashed
# peak-reference line isn't drawn right at the plot edge.
_YLIM_HEADROOM = 1.10
# --no-exit-lines turns this off (see panel_legend.end_lines). Set by
# main() / build_static().
_EXIT_LINES = True
# --fit-axis-to-data (see units.OFFSCALE_FACTOR). Set by main() / build_static().
_FIT_AXIS = False
# Per-theme colors. The plot internals are handled by Bokeh's
# `dark_minimal` theme; this table covers the bits Bokeh doesn't
# touch (page background, loading overlay, sticky-strip fills,
# dashed separator). Mutated by main() from --theme.
_THEMES = {
"light": {
"bokeh_theme": None,
"page_bg": "#ffffff",
"page_fg": "#333333",
"strip_bg": "#ffffff",
"strip_border": "#888888",
"overlay_track": "#e0e0e0",
"overlay_accent": "#3870c4",
# Drawn marks in the text colour's family (the page's ink): the
# timeline's root / orphan outlines, fork links, labels outside
# the bars and their leaders, the line-style key's swatches, the
# legends' frames and the fold headers.
"ink": "black",
"link": "#444444",
"label": "#333333",
"leader": "#aaaaaa",
"legend_border": "#cccccc",
"after_stop": "#d4d4d4", # the time after the session's stop (fill_alpha 0.6)
},
"dark": {
"bokeh_theme": "dark_minimal",
# Page bg = dark_minimal's `border_fill_color`; strip bg =
# dark_minimal's `background_fill_color`. Keeping them in sync
# so the sticky strips and metric panels share one continuous
# dark surface with no visual seam.
"page_bg": "#15191C",
"page_fg": "#E0E0E0",
"strip_bg": "#20262B",
"strip_border": "#555555",
"overlay_track": "#2a3036",
"overlay_accent": "#5b9bd5",
# As above, from the dark theme's text colour (page_fg = Bokeh
# dark_minimal's axis labels, #E0E0E0) toward its background.
"ink": "#E0E0E0",
"link": "#B8B8B8",
"label": "#E0E0E0",
"leader": "#6E7780",
"legend_border": "#555555",
"after_stop": "#3c444c",
},
}
_THEME = "light"
# A panel's series legend sits to the right of the plot frame, one entry
# per row, rows _LEGEND_ROW_PX apart; its line-style key (a panel with
# several metrics per process or device) on one row above the plot.
# Which entries: panel_legend (at most LEGEND_MAX_ENTRIES, the rest
# drawn in OTHER_COLOR and toggled together by one "+k more" entry), so
# a legend is at most 11 rows, within the frame's height.
#
# Every figure on the page reserves the same right border, the widest
# series legend's (estimated from the longest label, over rather than
# under), so the page has one right edge as well as one frame. In a
# window too narrow for it all, Bokeh narrows every frame alike, so they
# stay aligned.
_LEGEND_FONT_PX = 8 * 96 / 72 # 8pt
_LEGEND_CHAR_EM = 0.52 # the UI font's glyph width, in em, over (measured 0.46)
_LEGEND_ROW_PX = 12
_LEGEND_ENTRY_PAD = 20 + 5 + 2 * 4 + 2 * 1 + 10 # glyph + standoff + padding + border + slack
_LEGEND_MARGIN = 8 # between the frame and the legend
def _legend_width_px(labels: list[str]) -> int:
"""Upper estimate of a one-column legend's width, margin included."""
if not labels:
return 0
widest = max(len(lab) for lab in labels) * _LEGEND_CHAR_EM * _LEGEND_FONT_PX
return int(widest + _LEGEND_ENTRY_PAD + _LEGEND_MARGIN + 0.5)
def _reserve_right(figs: list) -> int:
"""Give every figure the same right border: room for the widest
series legend among them (those in fig.right). Returns it (px)."""
width = max([_legend_width_px([it.label.value for it in r.items])
for f in figs for r in f.right if isinstance(r, Legend)] + [0])
for f in figs:
f.min_border_right = width
return width
# Series line widths (70% of the earlier 1.2 / 0.8; the static PNG's
# are scaled the same way).
_SERIES_LINE_WIDTH = 0.84
_OTHER_LINE_WIDTH = 0.56
# panel_legend's line style index -> Bokeh line_dash.
_METRIC_DASHES = ["solid", "dashed", "dotted", "dashdot"]
def _legend(entries: list, ncols: int, **kw) -> "Legend":
return Legend(items=[LegendItem(label=lab, renderers=list(rs)) for lab, rs in entries],
ncols=ncols, location="top_left", click_policy="hide",
label_text_font_size="8pt", padding=4,
border_line_color=_THEMES[_THEME]["legend_border"],
border_line_alpha=1.0, **kw)
def _legend_right(fig, items: list[tuple[str, list]], key: list | None = None) -> None:
"""A panel's legend: items = [(label, renderers)] to the right of the
plot, one per row; an entry's swatch draws every renderer's glyph,
the last on top. Click an entry to hide its lines. `key` (the
line-style entries, same form) on one row above the plot."""
if items:
fig.add_layout(_legend(items, 1, margin=_LEGEND_MARGIN, spacing=0,
label_height=_LEGEND_ROW_PX, glyph_height=_LEGEND_ROW_PX),
"right")
if key:
fig.add_layout(_legend(key, len(key), margin=2, spacing=3), "above")
def _draw_end_lines(fig, series_list, plotted: dict, projector, p: panel_legend.Plan,
t0_ns: int, scale_fn) -> None:
"""Each exited process's end: a dashed line from 0 to its series' last
value, in the theme's ink (black light, #E0E0E0 dark), one segment
glyph for the panel (panel_legend.end_lines)."""
ends = panel_legend.end_lines(series_list, plotted, projector, p)
if ends:
x = [(t - t0_ns) / 1e9 for _k, t, _v in ends]
fig.segment(x0=x, x1=x, y0=[0.0] * len(ends), y1=[scale_fn(v) for _k, _t, v in ends],
line_color=_THEMES[_THEME]["ink"], line_dash="dashed",
line_width=_SERIES_LINE_WIDTH, name="end-lines")
def _draw_plan(fig, p: panel_legend.Plan, sources: dict) -> tuple[list, list]:
"""Draw every series of a panel_legend.Plan (sources: (fqn,
scope_key) -> ColumnDataSource) and return its legend items: listed
series in their colour and line style, the rest light grey. Every
entry but "+k more" gets a swatch of its own, 1.5 px wide as the PNG's
legend handles (the series lines are thinner) (a one-point
line at negative time, outside the x range's bounds) so it shows the
process's colour, or the style in black; clicking it hides all its
lines.
The grey (unlisted) series are drawn as one multi-line per line style,
under the listed ones: the legend entries that stand for them ("+k
more", a line-style key) then hold one renderer each instead of one
per series (BokehJS looks every legend renderer's view up on each
paint by a scan of all views: with ~770 grey lines on a cold vLLM
page that was ~1/3 of a pan step)."""
lines = {}
grey: dict = {} # style -> [keys]
for key in p.order:
color, st, listed = p.styles[key]
if not listed:
grey.setdefault(st, []).append(key)
for st, keys in grey.items():
src = ColumnDataSource(dict(xs=[sources[k].data["x"] for k in keys],
ys=[sources[k].data["y"] for k in keys]))
r = fig.multi_line("xs", "ys", source=src, color=panel_legend.OTHER_COLOR,
line_dash=_METRIC_DASHES[st], line_width=_OTHER_LINE_WIDTH,
name="grey-lines")
for k in keys:
lines[k] = r
for key in p.order:
color, st, listed = p.styles[key]
if listed:
lines[key] = fig.line("x", "y", source=sources[key], color=color,
line_dash=_METRIC_DASHES[st], line_width=_SERIES_LINE_WIDTH)
items, key = [], []
for label, color, st, keys in p.entries:
rs = list(dict.fromkeys(lines[k] for k in keys if k in lines))
if color != panel_legend.OTHER_COLOR:
# Last: a legend item draws all its renderers' swatches, in order.
ink = _THEMES[_THEME]["ink"] if color == panel_legend.METRIC_COLOR else color
rs.append(fig.line(x=[-2.0, -1.0], y=[0.0, 0.0], color=ink,
line_dash=_METRIC_DASHES[st], line_width=1.5))
(key if color == panel_legend.METRIC_COLOR else items).append((label, rs))
return items, key
def _attach_unified_hover(
fig,
series_list: list[metric_layout.ResolvedSeries],
projection: dict,
scale_fn,
t0_ns: int,
label_bases: dict[tuple, str],
projector: TraceProjector,
value_fmt: str = "0.000",
unit_suffix: str = "",
) -> None:
"""Wire up a HoverTool that shows every series in this panel in a
single popup, regardless of which series the cursor sits over (or
whether some have been legend-hidden).
Implementation: build an invisible 'anchor' line glyph whose CDS
has x = union of every series's timestamps and one y_i column per
series (np.interp aligns series with different sampling). Bind the
HoverTool to *only* that anchor. Its body is built at hover time: one
row per series that has a value there — a process not started yet or
already gone (NaN) gets no row."""
aligned: list[tuple[metric_layout.ResolvedSeries, np.ndarray, np.ndarray]] = []
for s in series_list:
ts_ns, vals = projection[(s.fqn, s.scope_key)]
if ts_ns.size == 0:
continue
aligned.append((s, ts_ns, vals))
if not aligned:
return
union_ts_ns = np.unique(np.concatenate([ts for _, ts, _ in aligned]))
union_x_s = (union_ts_ns.astype(np.int64) - t0_ns) / 1e9
data: dict = {"x": union_x_s, "_anchor_y": np.zeros_like(union_x_s)}
rows: list[list[str]] = [] # [column, label]
for i, (s, ts_ns, vals) in enumerate(aligned):
ts_s = (ts_ns.astype(np.int64) - t0_ns) / 1e9
scaled = scale_fn(vals.astype(np.float64))
if union_ts_ns.size == ts_ns.size and np.array_equal(union_ts_ns, ts_ns):
y_aligned = scaled
else:
y_aligned = np.interp(union_x_s, ts_s, scaled,
left=np.nan, right=np.nan)
col = f"y_{i}"
data[col] = y_aligned
rows.append([col, _series_label(s, projector,
base=label_bases[(s.fqn, s.scope_key)])])
anchor_cds = ColumnDataSource(data=data)
anchor = fig.line("x", "_anchor_y", source=anchor_cds,
line_alpha=0, line_width=0)
decimals = len(value_fmt.split(".")[1]) if "." in value_fmt else 0
present = CustomJSHover(args=dict(src=anchor_cds, rows=rows, dec=decimals,
unit=f" {unit_suffix}" if unit_suffix else ""), code="""
const i = special_vars.index, d = src.data, out = [];
for (const [col, label] of rows) {
const v = d[col][i];
if (v !== null && Number.isFinite(v))
out.push(label + ": " + v.toLocaleString("en-US", {useGrouping: false, minimumFractionDigits: dec,
maximumFractionDigits: dec}) + unit);
}
return out.join("\\n");
""")
fig.add_tools(HoverTool(
renderers=[anchor], mode="vline", attachment="below",
tooltips='<div style="font-size:11px;white-space:pre">t: @x{0.000}s\n'
'@_anchor_y{present}</div>',
formatters={"@_anchor_y": present}))
def _panel_title(panel, series_list: list[metric_layout.ResolvedSeries]) -> str:
"""The layout's title if it gives one; otherwise what the panel's
series share (metric_layout.shared_title)."""
if panel.title:
return panel.title
if not series_list:
return panel.series_glob
return metric_layout.shared_title(series_list)
def _build_panel(
panel,
series_list: list[metric_layout.ResolvedSeries],
projector: TraceProjector,
projection: dict,
t0_ns: int,
x_range=None,
pid_colors: dict | None = None,
metric_colors: dict | None = None,
) -> tuple:
"""Build one Bokeh figure for one panel. Returns
(figure, dict[(fqn, scope_key) -> ColumnDataSource]) so live mode
can stream new rows in. pid_colors: panel_legend.pid_color_map."""
unit = panel.unit_override if panel.unit_override != mc_pb.UNIT_UNSPECIFIED \
else series_list[0].descriptor.unit
peak_hint = _resolve_panel_peak(panel, series_list[0].descriptor, projector)
# The unit from what is plotted (not the ceiling).
scale_fn, ylabel = _format_unit_axis(
unit, units.largest(projection[(s.fqn, s.scope_key)][1] for s in series_list))
tools, box_zoom, wheel_zoom = _make_plot_tools()
fig_kwargs = dict(
title=_panel_title(panel, series_list),
x_axis_label="time (s)",
y_axis_label=ylabel,
width=1200, frame_height=_FRAME_HEIGHT, frame_width=_FRAME_WIDTH,
min_border_left=_BORDER_LEFT_PX,
tools=tools,
toolbar_location="left",
active_drag=box_zoom,
active_scroll=wheel_zoom,
output_backend=_RENDER_BACKEND,
)
if x_range is not None:
fig_kwargs["x_range"] = x_range
fig = figure(**fig_kwargs)
label_bases = metric_layout.disambiguate_short_labels(series_list)
plan = panel_legend.plan(series_list, projector, projection, pid_colors or {},
metric_colors=metric_colors)
cds_by_key: dict[tuple, ColumnDataSource] = {}
for key in plan.styles:
ts_ns, vals = projection[key]
time_s = (ts_ns.astype(np.int64) - t0_ns) / 1e9
cds_by_key[key] = ColumnDataSource(data=dict(
x=time_s, y=scale_fn(vals.astype(np.float64)),
))
legend_items, legend_key = _draw_plan(fig, plan, cds_by_key)
if _EXIT_LINES and panel_legend.wants_end_lines("metric", unit):
_draw_end_lines(fig, series_list, projection, projector, plan, t0_ns, scale_fn)
# Peak reference line + y-range. When the panel has a known peak,
# pin the view to [0, peak*headroom] with hard bounds so pan/zoom
# can't drift past the meaningful range. When no peak is known,
# only clamp the floor at 0 — every metric this profiler emits is
# non-negative, and a drifting negative axis just wastes space.
data_max = units.largest(projection[(s.fqn, s.scope_key)][1] for s in series_list)
if _FIT_AXIS and units.off_scale(peak_hint, data_max):
# The axis fits the data; the ceiling, far above it, is written.
unit_str = f" {ylabel}" if ylabel else ""
fig.y_range.bounds = (0.0, None)
fig.add_layout(Label(x=4, x_units="screen", y=_FRAME_HEIGHT - 4, y_units="screen",
text=f"Peak: {_fmt_total(scale_fn(peak_hint))}{unit_str} (off-scale)",
text_font_size="8pt", text_font_style="bold",
text_baseline="top",
text_color=_THEMES[_THEME]["label"]))
elif peak_hint is not None and peak_hint > 0:
scaled_peak = scale_fn(peak_hint)
upper = scaled_peak * _YLIM_HEADROOM
fig.y_range = Range1d(start=0.0, end=upper, bounds=(0.0, upper))
fig.add_layout(Span(location=scaled_peak, dimension="width",
line_color="red", line_dash="dashed",
line_alpha=0.6, line_width=1.5))
# The line is the panel's ceiling (catalog / layout peak), not
# the data's maximum: say so, as visualize_all.py does.
unit_str = f" {ylabel}" if ylabel else ""
fig.add_layout(Label(x=4, x_units="screen", y=scaled_peak, y_units="data",
text=f"Peak: {_fmt_total(scaled_peak)}{unit_str}",
text_font_size="8pt", text_font_style="bold",
text_baseline="bottom",
text_color=_THEMES[_THEME]["label"]))
else:
fig.y_range.bounds = (0.0, None)
# Per-panel y_min/y_max overrides (pbtxt).
if panel.y_min != 0.0:
fig.y_range.start = panel.y_min
if panel.y_max != 0.0:
fig.y_range.end = panel.y_max
# Single-popup hover: one tooltip listing every series' value at
# the cursor x (interpolated where sampling rates differ).
_attach_unified_hover(
fig, series_list, projection, scale_fn, t0_ns,
label_bases, projector,
unit_suffix=ylabel,
)
_legend_right(fig, legend_items, legend_key)
return fig, cds_by_key
# ---------------------------------------------------------------------------
# Aggregation companion panels
# ---------------------------------------------------------------------------
# Source unit → integrated unit. Mirrors the table in visualize_all.py.
_INTEGRATED_UNIT = {
mc_pb.UNIT_BYTES_PER_SEC: mc_pb.UNIT_BYTES,
}
def _trapz_cumulative(ts_ns: np.ndarray, vals: np.ndarray) -> np.ndarray:
"""Trapezoidal cumulative integral of (timestamps_ns, values)."""
if ts_ns.size < 2:
return np.zeros_like(vals, dtype=np.float64)
dt_s = np.diff(ts_ns.astype(np.int64)) / 1e9
avg = 0.5 * (vals[:-1].astype(np.float64) + vals[1:].astype(np.float64))
inc = avg * dt_s
out = np.empty(vals.size, dtype=np.float64)
out[0] = 0.0
out[1:] = np.cumsum(inc)
return out
def _fmt_total(v: float) -> str:
"""Plain (non-scientific) number for a run total."""
if v == int(v):
return f"{int(v):,}"
if abs(v) >= 100:
return f"{v:,.0f}"
if abs(v) >= 1:
return f"{v:.2f}".rstrip("0").rstrip(".")
return f"{v:.4f}".rstrip("0").rstrip(".")
def _panel_has_cumulative_companion(panel,
series_list: list[metric_layout.ResolvedSeries]
) -> bool:
"""Whether `panel` opted into PANEL_AGGREGATION_INTEGRATE AND the
source unit has a mapped integrated unit. Returns False (with no
warning) for non-integrable units — visualize_all.py logs the
skip; here we keep the live-render path quiet since this gets
called every tick."""
if panel.aggregation != panels_pb.PANEL_AGGREGATION_INTEGRATE:
return False
if not series_list:
return False
src_unit = (panel.unit_override
if panel.unit_override != mc_pb.UNIT_UNSPECIFIED
else series_list[0].descriptor.unit)
return src_unit in _INTEGRATED_UNIT
def _build_cumulative_panel(
panel,
series_list: list[metric_layout.ResolvedSeries],
projector: TraceProjector,
projection: dict,
t0_ns: int,
x_range=None,
display_hz: float = 0.0,
source_hzs: dict[tuple[str, object], float] | None = None,
pid_colors: dict | None = None,
metric_colors: dict | None = None,
) -> tuple:
"""Bokeh equivalent of visualize_all._render_integrated_panel.
Returns (figure, cds_by_key) — same shape as _build_panel so the
static / live machinery treats it like a regular panel for layout
purposes. The CDS values are pre-cumulated; in live mode the
coordinator doesn't yet stream into these (cumulative requires
per-series running-total state — tracked as a follow-up).
Cumulation runs on the full-resolution series so the displayed
total reflects the actual integrated value; `display_hz` only
affects how many points are sent to the browser for plotting.
"""
src_unit = (panel.unit_override
if panel.unit_override != mc_pb.UNIT_UNSPECIFIED
else series_list[0].descriptor.unit)
integrated_unit = _INTEGRATED_UNIT.get(src_unit, mc_pb.UNIT_UNSPECIFIED)
# First pass: cumulate every series so we can size the byte-axis.
# Cumulate on the full-resolution arrays for an accurate total,
# then optionally decimate the (ts, cum) pair for display only.
cumulatives: list[tuple[metric_layout.ResolvedSeries, np.ndarray, np.ndarray]] = []
full_totals: list[tuple[metric_layout.ResolvedSeries, np.ndarray]] = []
max_total = 0.0
for series in series_list:
ts_ns, vals = projection[(series.fqn, series.scope_key)]
if ts_ns.size == 0:
continue
cum = _trapz_cumulative(ts_ns, vals.astype(np.float64))
full_totals.append((series, cum))
if cum.size and cum[-1] > max_total:
max_total = float(cum[-1])
if display_hz > 0 and source_hzs is not None:
src_hz = source_hzs.get((series.fqn, series.scope_key), 0.0)
ts_ns, cum = _decimate_to_hz(ts_ns, cum, src_hz, display_hz)
cumulatives.append((series, ts_ns, cum))
scale_fn, ylabel = _format_unit_axis(integrated_unit, max_total)
base_title = _panel_title(panel, series_list)
tools, box_zoom, wheel_zoom = _make_plot_tools()
fig_kwargs = dict(
title=f"{base_title} (cumulative)",
x_axis_label="time (s)",
y_axis_label=ylabel,
width=1200, frame_height=_FRAME_HEIGHT, frame_width=_FRAME_WIDTH,
min_border_left=_BORDER_LEFT_PX,
tools=tools,
toolbar_location="left",
active_drag=box_zoom,
active_scroll=wheel_zoom,
output_backend=_RENDER_BACKEND,
)
if x_range is not None:
fig_kwargs["x_range"] = x_range
fig = figure(**fig_kwargs)
label_bases = metric_layout.disambiguate_short_labels(series_list)
cds_by_key: dict[tuple, ColumnDataSource] = {}
# Stash pre-cumulated values so the unified hover sees the
# cumulative curve rather than the source rate.
cumulative_projection: dict[tuple[str, object], tuple[np.ndarray, np.ndarray]] = {}
# Legend: ranked by run total (full resolution), names only.
plan = panel_legend.plan(
series_list, projector, projection, pid_colors or {},
totals={(s.fqn, s.scope_key): float(c[-1]) if c.size else 0.0 for s, c in full_totals},
metric_colors=metric_colors)
for series, ts_ns, cum in cumulatives:
key = (series.fqn, series.scope_key)
time_s = (ts_ns.astype(np.int64) - t0_ns) / 1e9
cds_by_key[key] = ColumnDataSource(data=dict(x=time_s, y=scale_fn(cum)))
cumulative_projection[key] = (ts_ns, cum)
legend_items, legend_key = _draw_plan(fig, plan, cds_by_key)
if _EXIT_LINES and panel_legend.wants_end_lines("cumulative", mc_pb.UNIT_UNSPECIFIED):
_draw_end_lines(fig, series_list, cumulative_projection, projector, plan, t0_ns, scale_fn)
# Cumulative curves are non-negative monotonic — clamp the floor
# at 0 and let the upper auto-fit.
fig.y_range.start = 0.0
fig.y_range.bounds = (0.0, None)
_attach_unified_hover(
fig, [s for s, _ts, _cum in cumulatives],
cumulative_projection, scale_fn, t0_ns,
label_bases, projector,
unit_suffix=ylabel,
)
_legend_right(fig, legend_items, legend_key)
return fig, cds_by_key
# ---------------------------------------------------------------------------
# Event / region strips
# ---------------------------------------------------------------------------
def _gpu_to_steady(ts_ns: int, cupti_ref_ns: int, steady_ref_ns: int) -> int:
if cupti_ref_ns == 0:
return ts_ns
return ts_ns - cupti_ref_ns + steady_ref_ns
def _load_events(path: Path):
"""Load events.pb and return (regions, events). Regions are
(name, start_ns, end_ns); events are (name, ts_ns). GPU-domain
entries are converted to steady_clock via the per-trace anchor."""
traces = _read_delimited(path, events_pb2.EventTrace)
if not traces:
return [], []
meta = None
for t in traces:
if t.HasField("metadata") and t.metadata.steady_clock_reference_ns:
meta = t.metadata
break
regions: list[tuple[str, int, int]] = []
events: list[tuple[str, int]] = []
cupti_ref = meta.cupti_reference_ns if meta else 0
steady_ref = meta.steady_clock_reference_ns if meta else 0
for t in traces:
for buf in t.buffers:
is_gpu = buf.domain == events_pb2.TIME_DOMAIN_GPU
for r in buf.regions:
s = _gpu_to_steady(r.start_timestamp_ns, cupti_ref, steady_ref) if is_gpu \
else r.start_timestamp_ns
e = _gpu_to_steady(r.end_timestamp_ns, cupti_ref, steady_ref) if is_gpu \
else r.end_timestamp_ns
regions.append((r.name, int(s), int(e)))
for ev in buf.events:
ts = _gpu_to_steady(ev.timestamp_ns, cupti_ref, steady_ref) if is_gpu \
else ev.timestamp_ns
events.append((ev.name, int(ts)))
return regions, events
def _load_events_for_session(meta: session_metadata_pb2.SessionMetadata,
metadata_path: Path):
"""Find the events probe in the session metadata, load it, and
return (regions, events). Returns ([], []) if no events probe was
configured or its .pb is missing."""
for probe in meta.probes:
if probe.kind != session_metadata_pb2.PROBE_KIND_EVENTS:
continue
# Quietly: _ingest_probes already reported a missing file.
try:
out = resolve_probe_path(metadata_path, probe.output_file)
except ValueError:
return [], []
if not out.is_file():
return [], []
return _load_events(out)
return [], []
# Bar labels that follow the view (process timeline, region and event
# strips): on its bar when the label fits the bar's visible part, else
# off the bar in a label row with a leader, off-bar labels never