pH/sommelier/scripts/get_frame_summary.py

280 lines
9.7 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright 2022 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Summarizes Sommelier timing information."""
import argparse
from enum import Enum
import statistics
from typing import NamedTuple
_MS_PER_SEC = 1000
_US_PER_SEC = 1000000
# Floating point error bounds
_FP_ERROR = 0.01
class EventType(Enum):
"""Wayland event type."""
COMMIT = 1
ATTACH = 2
RELEASE = 3
UNKNOWN = 4
class EventInfo(NamedTuple):
"""Stores information of an event."""
event_type: EventType
surface_id: int
buffer_id: int
time: float
def parse_event_type(event_type):
EVENT_MAP = {
"a": EventType.ATTACH,
"c": EventType.COMMIT,
"r": EventType.RELEASE,
}
return EVENT_MAP.get(event_type, EventType.UNKNOWN)
class FrameLog:
"""Manages access to the Sommelier timing logs."""
def __init__(self, filename):
"""Parse Sommelier timing log.
Format of log (header line might be truncated):
Type Surface_ID Buffer_ID Delta_Time # header line 1
a 12 20 4237.44 # line 2
....
EndTime 3972 1655330324.7 # last line
Last line format: (EndTime, last event id, time since epoch (s))
"""
self.frame_log = []
self.surfaces = set()
with open(filename, "r") as f:
lines = f.read().splitlines()
total_delta_time = 0
last_line = lines[-1].split(" ")
if len(last_line) != 3 or last_line[0] != "EndTime":
print(f"Invalid EndTime: {lines[-1]}")
return
self.end_time = float(last_line[2])
for l in reversed(lines[1:-1]):
line = l.rstrip().split(" ")
# Skip parsing line that is improperly formatted
if len(line) != 4:
continue
total_delta_time += float(line[3]) / _US_PER_SEC
surface_id = int(line[1])
info = EventInfo(
event_type=parse_event_type(line[0]),
surface_id=surface_id,
buffer_id=int(line[2]),
time=self.end_time - total_delta_time,
)
self.frame_log.append(info)
self.surfaces.add(surface_id)
def get_target_ft(self, target_fps, avg_fps):
"""Outputs target frame time given a target fps.
If target_fps is None, determine automatically based on
average FPS.
Args:
target_fps: Target FPS (30, 60, None).
avg_fps: Average FPS over time window.
"""
if not target_fps:
# determines appropriate target FPS by finding whether the
# average is closer to 30 or 60.
# TODO(asriniva): Revisit this methodology. What should a title
# averaging 31-33 FPS target? Non 30/60 FPS targets?
target_fps = min([30, 60], key=lambda x: abs(x - avg_fps))
# Acceptable frame time ranges, based on Battlestar's metrics.
# The +/-3 bounds do not scale with FPS (small variance for high FPS,
# large variance for low FPS) but can account for variability in
# hardware.
return [_MS_PER_SEC / target_fps - 3, _MS_PER_SEC / target_fps + 3]
def output_fps(self, surface, windows, max_ft_ms, target_fps):
"""Outputs the summarized fps information based on frame log.
Args:
surface: Surface ID
windows: List of time windows (in seconds) to summarize.
max_ft_ms: Max frame time threshold (ms).
target_fps: Target FPS, either 30, 60, or
None (automatically determined).
"""
max_frame_ms = 0
# only check for commit events on the given surface
# events are in reverse chronological order
events = [
e
for e in self.frame_log
if e.surface_id == surface and e.event_type == EventType.COMMIT
]
if not events:
print(f"No commit events found for surface {surface}\n")
return
total_sec = self.end_time - events[-1].time
ft_target_ms = self.get_target_ft(target_fps, len(events) / total_sec)
for w_sec in windows + [total_sec]:
# num frames in acceptable range
target_frames = 0
# num frames exceeding max_ft_ms
max_ft_events = 0
prev_sec = self.end_time
frame_count = 0
frames_ms = []
for event in events:
frame_ms = (prev_sec - event.time) * _MS_PER_SEC
frames_ms.append(frame_ms)
max_frame_ms = max(max_frame_ms, frame_ms)
if ft_target_ms[0] < frame_ms < ft_target_ms[1]:
target_frames += 1
if frame_ms > max_ft_ms:
max_ft_events += 1
current_sec = self.end_time - event.time
frame_count += 1
# handles floating point error in the case when
# w_sec == total_sec
if current_sec > w_sec - _FP_ERROR:
print(f"Summary for last {w_sec} seconds")
print("-------------------------------")
print(f"FPS: {frame_count / current_sec}")
print(f"Max frame time: {max_frame_ms} ms")
print(f"Frame count: {frame_count} frames")
print(
f"Percentage frames within acceptable target "
f"{ft_target_ms} ms: "
f"{target_frames * 100/frame_count}%"
)
if len(frames_ms) > 1:
c_var = statistics.stdev(frames_ms) / statistics.mean(
frames_ms
)
print(f"Coefficient of variation: {c_var}")
print(
f"Frames exceeding max frame time threshold"
f" {max_ft_ms} ms:"
f" {max_ft_events} frames"
)
print()
break
prev_sec = event.time
print()
def output_fps_sliding(self, surface, window, max_ft_ms):
"""Outputs the summarized fps information based on frame log.
Args:
surface: Surface ID
window: Window size (in num of frames).
max_ft_ms: Max frame time threshold (ms).
target_fps: Target FPS, either 30, 60, or
None (automatically determined).
"""
print(f"Sliding window aggregates for {window} events")
print("-------------------------------")
# only check for commit events on the given surface
# events are in reverse chronological order
events = [
e
for e in self.frame_log
if e.surface_id == surface and e.event_type == EventType.COMMIT
]
if not events:
print(f"No commit events found for surface {surface}\n")
return
# For a sliding window, aggregate the following:
# cvars: Coefficient of variation over window
# max_ft_events: Number of frames over max_ft_ms
cvars = []
max_ft_events = []
for i in range(window, len(events)):
sl_window = events[i - window : i]
frames_ms = [
(sl_window[x].time - sl_window[x + 1].time) * _MS_PER_SEC
for x in range(window - 1)
]
max_fts = sum([1 for x in frames_ms if x > max_ft_ms])
c_var = statistics.stdev(frames_ms) / statistics.mean(frames_ms)
cvars.append(c_var)
max_ft_events.append(max_fts)
if len(cvars) > 1:
print(
"Arithmetic mean of coefficient of variation:",
statistics.fmean(cvars),
)
print(
"Geometric mean of coefficient of variation:",
statistics.geometric_mean(cvars),
)
if len(max_ft_events) > 1:
print(
f"Average number of frame time events over threshold"
f" {max_ft_ms} ms:",
statistics.mean(max_ft_events),
)
print()
print()
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Return frame summary based on Sommelier timing log."
)
parser.add_argument("file", help="Filename of timing log")
parser.add_argument(
"--windows",
action="extend",
type=int,
nargs="+",
help="Time windows for summary (in seconds)",
default=[10, 60, 300],
)
parser.add_argument(
"--target-fps", type=int, help="Target FPS", default=None
)
parser.add_argument(
"--max-frame-time",
type=float,
help="Max frame time threshold (in milliseconds)",
default=200,
)
parser.add_argument(
"--sliding",
type=int,
help="Use sliding window with size in number of frames",
default=300,
)
args = parser.parse_args()
if args.target_fps and args.target_fps < 20:
parser.error("Choose target FPS above 20 FPS")
log = FrameLog(args.file)
for s in log.surfaces:
print(f"Summary for surface {s}")
print("-------------------------------")
log.output_fps_sliding(
s, max_ft_ms=args.max_frame_time, window=args.sliding
)
log.output_fps(
s,
windows=sorted(args.windows),
max_ft_ms=args.max_frame_time,
target_fps=args.target_fps,
)