aboutsummaryrefslogtreecommitdiff
path: root/catapult/telemetry/telemetry/web_perf/metrics/webrtc_rendering_stats.py
blob: 43276b6f5db293612b3eed785f7dcc3c4e95eadf (plain)
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
# Copyright 2015 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import logging

from telemetry.util import statistics

DISPLAY_HERTZ = 60.0
VSYNC_DURATION = 1e6 / DISPLAY_HERTZ
# When to consider a frame frozen (in VSYNC units): meaning 1 initial
# frame + 5 repeats of that frame.
FROZEN_THRESHOLD = 6
# Severity factor.
SEVERITY = 3

IDEAL_RENDER_INSTANT = 'Ideal Render Instant'
ACTUAL_RENDER_BEGIN = 'Actual Render Begin'
ACTUAL_RENDER_END = 'Actual Render End'
SERIAL = 'Serial'


class TimeStats(object):
  """Stats container for webrtc rendering metrics."""

  def __init__(self, drift_time=None, mean_drift_time=None,
    std_dev_drift_time=None, percent_badly_out_of_sync=None,
    percent_out_of_sync=None, smoothness_score=None, freezing_score=None,
    rendering_length_error=None, fps=None, frame_distribution=None):
    self.drift_time = drift_time
    self.mean_drift_time = mean_drift_time
    self.std_dev_drift_time = std_dev_drift_time
    self.percent_badly_out_of_sync = percent_badly_out_of_sync
    self.percent_out_of_sync = percent_out_of_sync
    self.smoothness_score = smoothness_score
    self.freezing_score = freezing_score
    self.rendering_length_error = rendering_length_error
    self.fps = fps
    self.frame_distribution = frame_distribution
    self.invalid_data = False



class WebMediaPlayerMsRenderingStats(object):
  """Analyzes events of WebMediaPlayerMs type."""

  def __init__(self, events):
    """Save relevant events according to their stream."""
    self.stream_to_events = self._MapEventsToStream(events)

  def _IsEventValid(self, event):
    """Check that the needed arguments are present in event.

    Args:
      event: event to check.

    Returns:
      True is event is valid, false otherwise."""
    if not event.args:
      return False
    mandatory = [ACTUAL_RENDER_BEGIN, ACTUAL_RENDER_END,
        IDEAL_RENDER_INSTANT, SERIAL]
    for parameter in mandatory:
      if not parameter in event.args:
        return False
    return True

  def _MapEventsToStream(self, events):
    """Build a dictionary of events indexed by stream.

    The events of interest have a 'Serial' argument which represents the
    stream ID. The 'Serial' argument identifies the local or remote nature of
    the stream with a least significant bit  of 0 or 1 as well as the hash
    value of the video track's URL. So stream::=hash(0|1} . The method will
    then list the events of the same stream in a frame_distribution on stream
    id. Practically speaking remote streams have an odd stream id and local
    streams have a even stream id.
    Args:
      events: Telemetry WebMediaPlayerMs events.

    Returns:
      A dict of stream IDs mapped to events on that stream.
    """
    stream_to_events = {}
    for event in events:
      if not self._IsEventValid(event):
        # This is not a render event, skip it.
        continue
      stream = event.args[SERIAL]
      events_for_stream = stream_to_events.setdefault(stream, [])
      events_for_stream.append(event)

    return stream_to_events

  def _GetCadence(self, relevant_events):
    """Calculate the apparent cadence of the rendering.

    In this paragraph I will be using regex notation. What is intended by the
    word cadence is a sort of extended instantaneous 'Cadence' (thus not
    necessarily periodic). Just as an example, a normal 'Cadence' could be
    something like [2 3] which means possibly an observed frame persistence
    progression of [{2 3}+] for an ideal 20FPS video source. So what we are
    calculating here is the list of frame persistence, kind of a
    'Proto-Cadence', but cadence is shorter so we abuse the word.

    Args:
      relevant_events: list of Telemetry events.

    Returns:
      a list of frame persistence values.
    """
    cadence = []
    frame_persistence = 0
    old_ideal_render = 0
    for event in relevant_events:
      if not self._IsEventValid(event):
        # This event is not a render event so skip it.
        continue
      if event.args[IDEAL_RENDER_INSTANT] == old_ideal_render:
        frame_persistence += 1
      else:
        cadence.append(frame_persistence)
        frame_persistence = 1
        old_ideal_render = event.args[IDEAL_RENDER_INSTANT]
    cadence.append(frame_persistence)
    cadence.pop(0)
    return cadence

  def _GetSourceToOutputDistribution(self, cadence):
    """Create distribution for the cadence frame display values.

    If the overall display distribution is A1:A2:..:An, this will tell us how
    many times a frame stays displayed during Ak*VSYNC_DURATION, also known as
    'source to output' distribution. Or in other terms:
    a distribution B::= let C be the cadence, B[k]=p with k in Unique(C)
    and p=Card(k in C).

    Args:
      cadence: list of frame persistence values.

    Returns:
      a dictionary containing the distribution
    """
    frame_distribution = {}
    for ticks in cadence:
      ticks_so_far = frame_distribution.setdefault(ticks, 0)
      frame_distribution[ticks] = ticks_so_far + 1
    return frame_distribution

  def _GetFpsFromCadence(self, frame_distribution):
    """Calculate the apparent FPS from frame distribution.

    Knowing the display frequency and the frame distribution, it is possible to
    calculate the video apparent frame rate as played by WebMediaPlayerMs
    module.

    Args:
      frame_distribution: the source to output distribution.

    Returns:
      the video apparent frame rate.
    """
    number_frames = sum(frame_distribution.values())
    number_vsyncs = sum([ticks * frame_distribution[ticks]
       for ticks in frame_distribution])
    mean_ratio = float(number_vsyncs) / number_frames
    return DISPLAY_HERTZ / mean_ratio

  def _GetFrozenFramesReports(self, frame_distribution):
    """Find evidence of frozen frames in distribution.

    For simplicity we count as freezing the frames that appear at least five
    times in a row counted from 'Ideal Render Instant' perspective. So let's
    say for 1 source frame, we rendered 6 frames, then we consider 5 of these
    rendered frames as frozen. But we mitigate this by saying anything under
    5 frozen frames will not be counted as frozen.

    Args:
      frame_distribution: the source to output distribution.

    Returns:
      a list of dicts whose keys are ('frozen_frames', 'occurrences').
    """
    frozen_frames = []
    frozen_frame_vsyncs = [ticks for ticks in frame_distribution if ticks >=
        FROZEN_THRESHOLD]
    for frozen_frames_vsync in frozen_frame_vsyncs:
      logging.debug('%s frames not updated after %s vsyncs',
          frame_distribution[frozen_frames_vsync], frozen_frames_vsync)
      frozen_frames.append(
          {'frozen_frames': frozen_frames_vsync - 1,
           'occurrences': frame_distribution[frozen_frames_vsync]})
    return frozen_frames

  def _FrozenPenaltyWeight(self, number_frozen_frames):
    """Returns the weighted penalty for a number of frozen frames.

    As mentioned earlier, we count for frozen anything above 6 vsync display
    duration for the same 'Initial Render Instant', which is five frozen
    frames.

    Args:
      number_frozen_frames: number of frozen frames.

    Returns:
      the penalty weight (int) for that number of frozen frames.
    """

    penalty = {
      0: 0,
      1: 0,
      2: 0,
      3: 0,
      4: 0,
      5: 1,
      6: 5,
      7: 15,
      8: 25
    }
    weight = penalty.get(number_frozen_frames, 8 * (number_frozen_frames - 4))
    return weight

  def _IsRemoteStream(self, stream):
    """Check if stream is remote."""
    return stream % 2

  def _GetDrifTimeStats(self, relevant_events, cadence):
    """Get the drift time statistics.

    This method will calculate drift_time stats, that is to say :
    drift_time::= list(actual render begin - ideal render).
    rendering_length error::= the rendering length error.

    Args:
      relevant_events: events to get drift times stats from.
      cadence: list of frame persistence values.

    Returns:
      a tuple of (drift_time, rendering_length_error).
    """
    drift_time = []
    old_ideal_render = 0
    discrepancy = []
    index = 0
    for event in relevant_events:
      current_ideal_render = event.args[IDEAL_RENDER_INSTANT]
      if current_ideal_render == old_ideal_render:
        # Skip to next event because we're looking for a source frame.
        continue
      actual_render_begin = event.args[ACTUAL_RENDER_BEGIN]
      drift_time.append(actual_render_begin - current_ideal_render)
      discrepancy.append(abs(current_ideal_render - old_ideal_render
          - VSYNC_DURATION * cadence[index]))
      old_ideal_render = current_ideal_render
      index += 1
    discrepancy.pop(0)
    last_ideal_render = relevant_events[-1].args[IDEAL_RENDER_INSTANT]
    first_ideal_render = relevant_events[0].args[IDEAL_RENDER_INSTANT]
    rendering_length_error = 100.0 * (sum([x for x in discrepancy]) /
        (last_ideal_render - first_ideal_render))

    return drift_time, rendering_length_error

  def _GetSmoothnessStats(self, norm_drift_time):
    """Get the smoothness stats from the normalized drift time.

    This method will calculate the smoothness score, along with the percentage
    of frames badly out of sync and the percentage of frames out of sync. To be
    considered badly out of sync, a frame has to have missed rendering by at
    least 2*VSYNC_DURATION. To be considered out of sync, a frame has to have
    missed rendering by at least one VSYNC_DURATION.
    The smoothness score is a measure of how out of sync the frames are.

    Args:
      norm_drift_time: normalized drift time.

    Returns:
      a tuple of (percent_badly_oos, percent_out_of_sync, smoothness_score)
    """
    # How many times is a frame later/earlier than T=2*VSYNC_DURATION. Time is
    # in microseconds.
    frames_severely_out_of_sync = len(
        [x for x in norm_drift_time if abs(x) > 2 * VSYNC_DURATION])
    percent_badly_oos = (
        100.0 * frames_severely_out_of_sync / len(norm_drift_time))

    # How many times is a frame later/earlier than VSYNC_DURATION.
    frames_out_of_sync = len(
        [x for x in norm_drift_time if abs(x) > VSYNC_DURATION])
    percent_out_of_sync = (
        100.0 * frames_out_of_sync / len(norm_drift_time))

    frames_oos_only_once = frames_out_of_sync - frames_severely_out_of_sync

    # Calculate smoothness metric. From the formula, we can see that smoothness
    # score can be negative.
    smoothness_score = 100.0 - 100.0 * (frames_oos_only_once +
        SEVERITY * frames_severely_out_of_sync) / len(norm_drift_time)

    # Minimum smoothness_score value allowed is zero.
    if smoothness_score < 0:
      smoothness_score = 0

    return (percent_badly_oos, percent_out_of_sync, smoothness_score)

  def _GetFreezingScore(self, frame_distribution):
    """Get the freezing score."""

    # The freezing score is based on the source to output distribution.
    number_vsyncs = sum([n * frame_distribution[n]
        for n in frame_distribution])
    frozen_frames = self._GetFrozenFramesReports(frame_distribution)

    # Calculate freezing metric.
    # Freezing metric can be negative if things are really bad. In that case we
    # change it to zero as minimum valud.
    freezing_score = 100.0
    for frozen_report in frozen_frames:
      weight = self._FrozenPenaltyWeight(frozen_report['frozen_frames'])
      freezing_score -= (
          100.0 * frozen_report['occurrences'] / number_vsyncs * weight)
    if freezing_score < 0:
      freezing_score = 0

    return freezing_score

  def GetTimeStats(self):
    """Calculate time stamp stats for all remote stream events."""
    stats = {}
    for stream, relevant_events in self.stream_to_events.iteritems():
      if len(relevant_events) == 1:
        logging.debug('Found a stream=%s with just one event', stream)
        continue
      if not self._IsRemoteStream(stream):
        logging.info('Skipping processing of local stream: %s', stream)
        continue

      cadence = self._GetCadence(relevant_events)
      if not cadence:
        stats = TimeStats()
        stats.invalid_data = True
        return stats

      frame_distribution = self._GetSourceToOutputDistribution(cadence)
      fps = self._GetFpsFromCadence(frame_distribution)

      drift_time_stats = self._GetDrifTimeStats(relevant_events, cadence)
      (drift_time, rendering_length_error) = drift_time_stats

      # Drift time normalization.
      mean_drift_time = statistics.ArithmeticMean(drift_time)
      norm_drift_time = [abs(x - mean_drift_time) for x in drift_time]

      smoothness_stats = self._GetSmoothnessStats(norm_drift_time)
      (percent_badly_oos, percent_out_of_sync,
          smoothness_score) = smoothness_stats

      freezing_score = self._GetFreezingScore(frame_distribution)

      stats = TimeStats(drift_time=drift_time,
          percent_badly_out_of_sync=percent_badly_oos,
          percent_out_of_sync=percent_out_of_sync,
          smoothness_score=smoothness_score, freezing_score=freezing_score,
          rendering_length_error=rendering_length_error, fps=fps,
          frame_distribution=frame_distribution)
    return stats