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