• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2021 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16
17import {searchSegment} from '../../base/binary_search';
18import {assertTrue} from '../../base/logging';
19import {Actions} from '../../common/actions';
20import {
21  EngineProxy,
22  LONG_NULL,
23  NUM,
24  PluginContext,
25  STR,
26  TrackInfo,
27} from '../../common/plugin_api';
28import {
29  fromNs,
30  TPDuration,
31  TPTime,
32  tpTimeFromSeconds,
33} from '../../common/time';
34import {TrackData} from '../../common/track_data';
35import {
36  TrackController,
37} from '../../controller/track_controller';
38import {checkerboardExcept} from '../../frontend/checkerboard';
39import {globals} from '../../frontend/globals';
40import {NewTrackArgs, Track} from '../../frontend/track';
41import {Button} from '../../frontend/widgets/button';
42import {MenuItem, PopupMenu2} from '../../frontend/widgets/menu';
43
44export const COUNTER_TRACK_KIND = 'CounterTrack';
45
46// TODO(hjd): Convert to enum.
47export type CounterScaleOptions =
48    'ZERO_BASED'|'MIN_MAX'|'DELTA_FROM_PREVIOUS'|'RATE';
49
50export interface Data extends TrackData {
51  maximumValue: number;
52  minimumValue: number;
53  maximumDelta: number;
54  minimumDelta: number;
55  maximumRate: number;
56  minimumRate: number;
57  timestamps: Float64Array;
58  lastIds: Float64Array;
59  minValues: Float64Array;
60  maxValues: Float64Array;
61  lastValues: Float64Array;
62  totalDeltas: Float64Array;
63  rate: Float64Array;
64}
65
66export interface Config {
67  name: string;
68  maximumValue?: number;
69  minimumValue?: number;
70  startTs?: TPTime;
71  endTs?: TPTime;
72  namespace: string;
73  trackId: number;
74  scale?: CounterScaleOptions;
75}
76
77class CounterTrackController extends TrackController<Config, Data> {
78  static readonly kind = COUNTER_TRACK_KIND;
79  private setup = false;
80  private maximumValueSeen = 0;
81  private minimumValueSeen = 0;
82  private maximumDeltaSeen = 0;
83  private minimumDeltaSeen = 0;
84  private maxDurNs: TPDuration = 0n;
85
86  async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration):
87      Promise<Data> {
88    const pxSize = this.pxSize();
89
90    // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to
91    // be an even number, so we can snap in the middle.
92    const bucketNs =
93        Math.max(Math.round(Number(resolution) * pxSize / 2) * 2, 1);
94
95    if (!this.setup) {
96      if (this.config.namespace === undefined) {
97        await this.query(`
98          create view ${this.tableName('counter_view')} as
99          select
100            id,
101            ts,
102            dur,
103            value,
104            delta
105          from experimental_counter_dur
106          where track_id = ${this.config.trackId};
107        `);
108      } else {
109        await this.query(`
110          create view ${this.tableName('counter_view')} as
111          select
112            id,
113            ts,
114            lead(ts, 1, ts) over (order by ts) - ts as dur,
115            lead(value, 1, value) over (order by ts) - value as delta,
116            value
117          from ${this.namespaceTable('counter')}
118          where track_id = ${this.config.trackId};
119        `);
120      }
121
122      const maxDurResult = await this.query(`
123          select
124            max(
125              iif(dur != -1, dur, (select end_ts from trace_bounds) - ts)
126            ) as maxDur
127          from ${this.tableName('counter_view')}
128      `);
129      this.maxDurNs = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
130
131      const queryRes = await this.query(`
132        select
133          ifnull(max(value), 0) as maxValue,
134          ifnull(min(value), 0) as minValue,
135          ifnull(max(delta), 0) as maxDelta,
136          ifnull(min(delta), 0) as minDelta
137        from ${this.tableName('counter_view')}`);
138      const row = queryRes.firstRow(
139          {maxValue: NUM, minValue: NUM, maxDelta: NUM, minDelta: NUM});
140      this.maximumValueSeen = row.maxValue;
141      this.minimumValueSeen = row.minValue;
142      this.maximumDeltaSeen = row.maxDelta;
143      this.minimumDeltaSeen = row.minDelta;
144
145      this.setup = true;
146    }
147
148    const queryRes = await this.query(`
149      select
150        (ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs} as tsq,
151        min(value) as minValue,
152        max(value) as maxValue,
153        sum(delta) as totalDelta,
154        value_at_max_ts(ts, id) as lastId,
155        value_at_max_ts(ts, value) as lastValue
156      from ${this.tableName('counter_view')}
157      where ts >= ${start - this.maxDurNs} and ts <= ${end}
158      group by tsq
159      order by tsq
160    `);
161
162    const numRows = queryRes.numRows();
163
164    const data: Data = {
165      start,
166      end,
167      length: numRows,
168      maximumValue: this.maximumValue(),
169      minimumValue: this.minimumValue(),
170      maximumDelta: this.maximumDeltaSeen,
171      minimumDelta: this.minimumDeltaSeen,
172      maximumRate: 0,
173      minimumRate: 0,
174      resolution,
175      timestamps: new Float64Array(numRows),
176      lastIds: new Float64Array(numRows),
177      minValues: new Float64Array(numRows),
178      maxValues: new Float64Array(numRows),
179      lastValues: new Float64Array(numRows),
180      totalDeltas: new Float64Array(numRows),
181      rate: new Float64Array(numRows),
182    };
183
184    const it = queryRes.iter({
185      'tsq': NUM,
186      'lastId': NUM,
187      'minValue': NUM,
188      'maxValue': NUM,
189      'lastValue': NUM,
190      'totalDelta': NUM,
191    });
192    let lastValue = 0;
193    let lastTs = 0;
194    for (let row = 0; it.valid(); it.next(), row++) {
195      const ts = fromNs(it.tsq);
196      const value = it.lastValue;
197      const rate = (value - lastValue) / (ts - lastTs);
198      lastTs = ts;
199      lastValue = value;
200
201      data.timestamps[row] = ts;
202      data.lastIds[row] = it.lastId;
203      data.minValues[row] = it.minValue;
204      data.maxValues[row] = it.maxValue;
205      data.lastValues[row] = value;
206      data.totalDeltas[row] = it.totalDelta;
207      data.rate[row] = rate;
208      if (row > 0) {
209        data.rate[row - 1] = rate;
210        data.maximumRate = Math.max(data.maximumRate, rate);
211        data.minimumRate = Math.min(data.minimumRate, rate);
212      }
213    }
214    return data;
215  }
216
217  private maximumValue() {
218    if (this.config.maximumValue === undefined) {
219      return this.maximumValueSeen;
220    } else {
221      return this.config.maximumValue;
222    }
223  }
224
225  private minimumValue() {
226    if (this.config.minimumValue === undefined) {
227      return this.minimumValueSeen;
228    } else {
229      return this.config.minimumValue;
230    }
231  }
232}
233
234
235// 0.5 Makes the horizontal lines sharp.
236const MARGIN_TOP = 3.5;
237const RECT_HEIGHT = 24.5;
238
239class CounterTrack extends Track<Config, Data> {
240  static readonly kind = COUNTER_TRACK_KIND;
241  static create(args: NewTrackArgs): CounterTrack {
242    return new CounterTrack(args);
243  }
244
245  private mousePos = {x: 0, y: 0};
246  private hoveredValue: number|undefined = undefined;
247  private hoveredTs: number|undefined = undefined;
248  private hoveredTsEnd: number|undefined = undefined;
249
250  constructor(args: NewTrackArgs) {
251    super(args);
252  }
253
254  getHeight() {
255    return MARGIN_TOP + RECT_HEIGHT;
256  }
257
258  getContextMenu(): m.Vnode<any> {
259    const currentScale = this.config.scale;
260    const scales: {name: CounterScaleOptions, humanName: string}[] = [
261      {name: 'ZERO_BASED', humanName: 'Zero based'},
262      {name: 'MIN_MAX', humanName: 'Min/Max'},
263      {name: 'DELTA_FROM_PREVIOUS', humanName: 'Delta'},
264      {name: 'RATE', humanName: 'Rate'},
265    ];
266    const menuItems = scales.map((scale) => {
267      return m(MenuItem, {
268        label: scale.humanName,
269        active: currentScale === scale.name,
270        onclick: () => {
271          this.config.scale = scale.name;
272          Actions.updateTrackConfig({
273            id: this.trackState.id,
274            config: this.config,
275          });
276        },
277      });
278    });
279
280    return m(
281        PopupMenu2,
282        {
283          trigger: m(Button, {icon: 'show_chart', minimal: true}),
284        },
285        menuItems,
286    );
287  }
288
289  renderCanvas(ctx: CanvasRenderingContext2D): void {
290    // TODO: fonts and colors should come from the CSS and not hardcoded here.
291    const {
292      visibleTimeScale: timeScale,
293      windowSpan,
294    } = globals.frontendLocalState;
295    const data = this.data();
296
297    // Can't possibly draw anything.
298    if (data === undefined || data.timestamps.length === 0) {
299      return;
300    }
301
302    assertTrue(data.timestamps.length === data.minValues.length);
303    assertTrue(data.timestamps.length === data.maxValues.length);
304    assertTrue(data.timestamps.length === data.lastValues.length);
305    assertTrue(data.timestamps.length === data.totalDeltas.length);
306    assertTrue(data.timestamps.length === data.rate.length);
307
308    const scale: CounterScaleOptions = this.config.scale || 'ZERO_BASED';
309
310    let minValues = data.minValues;
311    let maxValues = data.maxValues;
312    let lastValues = data.lastValues;
313    let maximumValue = data.maximumValue;
314    let minimumValue = data.minimumValue;
315    if (scale === 'DELTA_FROM_PREVIOUS') {
316      lastValues = data.totalDeltas;
317      minValues = data.totalDeltas;
318      maxValues = data.totalDeltas;
319      maximumValue = data.maximumDelta;
320      minimumValue = data.minimumDelta;
321    }
322    if (scale === 'RATE') {
323      lastValues = data.rate;
324      minValues = data.rate;
325      maxValues = data.rate;
326      maximumValue = data.maximumRate;
327      minimumValue = data.minimumRate;
328    }
329
330    const endPx = windowSpan.end;
331    const zeroY = MARGIN_TOP + RECT_HEIGHT / (minimumValue < 0 ? 2 : 1);
332
333    // Quantize the Y axis to quarters of powers of tens (7.5K, 10K, 12.5K).
334    const maxValue = Math.max(maximumValue, 0);
335
336    let yMax = Math.max(Math.abs(minimumValue), maxValue);
337    const kUnits = ['', 'K', 'M', 'G', 'T', 'E'];
338    const exp = Math.ceil(Math.log10(Math.max(yMax, 1)));
339    const pow10 = Math.pow(10, exp);
340    yMax = Math.ceil(yMax / (pow10 / 4)) * (pow10 / 4);
341    let yRange = 0;
342    const unitGroup = Math.floor(exp / 3);
343    let yMin = 0;
344    let yLabel = '';
345    if (scale === 'MIN_MAX') {
346      yRange = maximumValue - minimumValue;
347      yMin = minimumValue;
348      yLabel = 'min - max';
349    } else {
350      yRange = minimumValue < 0 ? yMax * 2 : yMax;
351      yMin = minimumValue < 0 ? -yMax : 0;
352      yLabel = `${yMax / Math.pow(10, unitGroup * 3)} ${kUnits[unitGroup]}`;
353      if (scale === 'DELTA_FROM_PREVIOUS') {
354        yLabel += '\u0394';
355      } else if (scale === 'RATE') {
356        yLabel += '\u0394/t';
357      }
358    }
359
360    // There are 360deg of hue. We want a scale that starts at green with
361    // exp <= 3 (<= 1KB), goes orange around exp = 6 (~1MB) and red/violet
362    // around exp >= 9 (1GB).
363    // The hue scale looks like this:
364    // 0                              180                                 360
365    // Red        orange         green | blue         purple          magenta
366    // So we want to start @ 180deg with pow=0, go down to 0deg and then wrap
367    // back from 360deg back to 180deg.
368    const expCapped = Math.min(Math.max(exp - 3), 9);
369    const hue = (180 - Math.floor(expCapped * (180 / 6)) + 360) % 360;
370
371    ctx.fillStyle = `hsl(${hue}, 45%, 75%)`;
372    ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`;
373
374    const calculateX = (ts: number) => {
375      return Math.floor(timeScale.secondsToPx(ts));
376    };
377    const calculateY = (value: number) => {
378      return MARGIN_TOP + RECT_HEIGHT -
379          Math.round(((value - yMin) / yRange) * RECT_HEIGHT);
380    };
381
382    ctx.beginPath();
383    ctx.moveTo(calculateX(data.timestamps[0]), zeroY);
384    let lastDrawnY = zeroY;
385    for (let i = 0; i < data.timestamps.length; i++) {
386      const x = calculateX(data.timestamps[i]);
387      const minY = calculateY(minValues[i]);
388      const maxY = calculateY(maxValues[i]);
389      const lastY = calculateY(lastValues[i]);
390
391      ctx.lineTo(x, lastDrawnY);
392      if (minY === maxY) {
393        assertTrue(lastY === minY);
394        ctx.lineTo(x, lastY);
395      } else {
396        ctx.lineTo(x, minY);
397        ctx.lineTo(x, maxY);
398        ctx.lineTo(x, lastY);
399      }
400      lastDrawnY = lastY;
401    }
402    ctx.lineTo(endPx, lastDrawnY);
403    ctx.lineTo(endPx, zeroY);
404    ctx.closePath();
405    ctx.fill();
406    ctx.stroke();
407
408    // Draw the Y=0 dashed line.
409    ctx.strokeStyle = `hsl(${hue}, 10%, 71%)`;
410    ctx.beginPath();
411    ctx.setLineDash([2, 4]);
412    ctx.moveTo(0, zeroY);
413    ctx.lineTo(endPx, zeroY);
414    ctx.closePath();
415    ctx.stroke();
416    ctx.setLineDash([]);
417
418    ctx.font = '10px Roboto Condensed';
419
420    if (this.hoveredValue !== undefined && this.hoveredTs !== undefined) {
421      // TODO(hjd): Add units.
422      let text: string;
423      if (scale === 'DELTA_FROM_PREVIOUS') {
424        text = 'delta: ';
425      } else if (scale === 'RATE') {
426        text = 'delta/t: ';
427      } else {
428        text = 'value: ';
429      }
430
431      text += `${this.hoveredValue.toLocaleString()}`;
432
433      ctx.fillStyle = `hsl(${hue}, 45%, 75%)`;
434      ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`;
435
436      const xStart = Math.floor(timeScale.secondsToPx(this.hoveredTs));
437      const xEnd = this.hoveredTsEnd === undefined ?
438          endPx :
439          Math.floor(timeScale.secondsToPx(this.hoveredTsEnd));
440      const y = MARGIN_TOP + RECT_HEIGHT -
441          Math.round(((this.hoveredValue - yMin) / yRange) * RECT_HEIGHT);
442
443      // Highlight line.
444      ctx.beginPath();
445      ctx.moveTo(xStart, y);
446      ctx.lineTo(xEnd, y);
447      ctx.lineWidth = 3;
448      ctx.stroke();
449      ctx.lineWidth = 1;
450
451      // Draw change marker.
452      ctx.beginPath();
453      ctx.arc(
454          xStart, y, 3 /* r*/, 0 /* start angle*/, 2 * Math.PI /* end angle*/);
455      ctx.fill();
456      ctx.stroke();
457
458      // Draw the tooltip.
459      this.drawTrackHoverTooltip(ctx, this.mousePos, text);
460    }
461
462    // Write the Y scale on the top left corner.
463    ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
464    ctx.fillRect(0, 0, 42, 16);
465    ctx.fillStyle = '#666';
466    ctx.textAlign = 'left';
467    ctx.textBaseline = 'alphabetic';
468    ctx.fillText(`${yLabel}`, 5, 14);
469
470    // TODO(hjd): Refactor this into checkerboardExcept
471    {
472      let counterEndPx = Infinity;
473      if (this.config.endTs) {
474        counterEndPx = Math.min(timeScale.tpTimeToPx(this.config.endTs), endPx);
475      }
476
477      // Grey out RHS.
478      if (counterEndPx < endPx) {
479        ctx.fillStyle = '#0000001f';
480        ctx.fillRect(counterEndPx, 0, endPx - counterEndPx, this.getHeight());
481      }
482    }
483
484    // If the cached trace slices don't fully cover the visible time range,
485    // show a gray rectangle with a "Loading..." label.
486    checkerboardExcept(
487        ctx,
488        this.getHeight(),
489        windowSpan.start,
490        windowSpan.end,
491        timeScale.tpTimeToPx(data.start),
492        timeScale.tpTimeToPx(data.end));
493  }
494
495  onMouseMove(pos: {x: number, y: number}) {
496    const data = this.data();
497    if (data === undefined) return;
498    this.mousePos = pos;
499    const {visibleTimeScale} = globals.frontendLocalState;
500    const time = visibleTimeScale.pxToHpTime(pos.x).seconds;
501
502    const values = this.config.scale === 'DELTA_FROM_PREVIOUS' ?
503        data.totalDeltas :
504        data.lastValues;
505    const [left, right] = searchSegment(data.timestamps, time);
506    this.hoveredTs = left === -1 ? undefined : data.timestamps[left];
507    this.hoveredTsEnd = right === -1 ? undefined : data.timestamps[right];
508    this.hoveredValue = left === -1 ? undefined : values[left];
509  }
510
511  onMouseOut() {
512    this.hoveredValue = undefined;
513    this.hoveredTs = undefined;
514  }
515
516  onMouseClick({x}: {x: number}) {
517    const data = this.data();
518    if (data === undefined) return false;
519    const {visibleTimeScale} = globals.frontendLocalState;
520    const time = visibleTimeScale.pxToHpTime(x).seconds;
521    const [left, right] = searchSegment(data.timestamps, time);
522    if (left === -1) {
523      return false;
524    } else {
525      const counterId = data.lastIds[left];
526      if (counterId === -1) return true;
527      globals.makeSelection(Actions.selectCounter({
528        leftTs: tpTimeFromSeconds(data.timestamps[left]),
529        rightTs: tpTimeFromSeconds(right !== -1 ? data.timestamps[right] : -1),
530        id: counterId,
531        trackId: this.trackState.id,
532      }));
533      return true;
534    }
535  }
536}
537
538async function globalTrackProvider(engine: EngineProxy): Promise<TrackInfo[]> {
539  const result = await engine.query(`
540    select name, id
541    from (
542      select name, id
543      from counter_track
544      where type = 'counter_track'
545      union
546      select name, id
547      from gpu_counter_track
548      where name != 'gpufreq'
549    )
550    order by name
551  `);
552
553  // Add global or GPU counter tracks that are not bound to any pid/tid.
554  const it = result.iter({
555    name: STR,
556    id: NUM,
557  });
558
559  const tracks: TrackInfo[] = [];
560  for (; it.valid(); it.next()) {
561    const name = it.name;
562    const trackId = it.id;
563    tracks.push({
564      trackKind: COUNTER_TRACK_KIND,
565      name,
566      config: {
567        name,
568        trackId,
569      },
570    });
571  }
572  return tracks;
573}
574
575export function activate(ctx: PluginContext) {
576  ctx.registerTrackController(CounterTrackController);
577  ctx.registerTrack(CounterTrack);
578  ctx.registerTrackProvider(globalTrackProvider);
579}
580
581export const plugin = {
582  pluginId: 'perfetto.Counter',
583  activate,
584};
585