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 {BigintMath} from '../../base/bigint_math'; 16import {search, searchEq, searchSegment} from '../../base/binary_search'; 17import {assertTrue} from '../../base/logging'; 18import {Actions} from '../../common/actions'; 19import { 20 cropText, 21 drawDoubleHeadedArrow, 22 drawIncompleteSlice, 23} from '../../common/canvas_utils'; 24import {colorForThread} from '../../common/colorizer'; 25import {PluginContext} from '../../common/plugin_api'; 26import {NUM} from '../../common/query_result'; 27import { 28 fromNs, 29 toNs, 30 TPDuration, 31 TPTime, 32 tpTimeFromSeconds, 33 tpTimeToNanos, 34 tpTimeToString, 35} from '../../common/time'; 36import {TrackData} from '../../common/track_data'; 37import { 38 TrackController, 39} from '../../controller/track_controller'; 40import {checkerboardExcept} from '../../frontend/checkerboard'; 41import {globals} from '../../frontend/globals'; 42import {NewTrackArgs, Track} from '../../frontend/track'; 43 44export const CPU_SLICE_TRACK_KIND = 'CpuSliceTrack'; 45 46export interface Data extends TrackData { 47 // Slices are stored in a columnar fashion. All fields have the same length. 48 ids: Float64Array; 49 starts: Float64Array; 50 ends: Float64Array; 51 utids: Uint32Array; 52 isIncomplete: Uint8Array; 53 lastRowId: number; 54} 55 56export interface Config { 57 cpu: number; 58} 59 60class CpuSliceTrackController extends TrackController<Config, Data> { 61 static readonly kind = CPU_SLICE_TRACK_KIND; 62 63 private cachedBucketNs = Number.MAX_SAFE_INTEGER; 64 private maxDurNs = 0; 65 private lastRowId = -1; 66 67 async onSetup() { 68 await this.query(` 69 create view ${this.tableName('sched')} as 70 select 71 ts, 72 dur, 73 utid, 74 id, 75 dur = -1 as isIncomplete 76 from sched 77 where cpu = ${this.config.cpu} and utid != 0 78 `); 79 80 const queryRes = await this.query(` 81 select ifnull(max(dur), 0) as maxDur, count(1) as rowCount 82 from ${this.tableName('sched')} 83 `); 84 85 const queryLastSlice = await this.query(` 86 select max(id) as lastSliceId from ${this.tableName('sched')} 87 `); 88 this.lastRowId = queryLastSlice.firstRow({lastSliceId: NUM}).lastSliceId; 89 90 const row = queryRes.firstRow({maxDur: NUM, rowCount: NUM}); 91 this.maxDurNs = row.maxDur; 92 const rowCount = row.rowCount; 93 const bucketNs = this.cachedBucketSizeNs(rowCount); 94 if (bucketNs === undefined) { 95 return; 96 } 97 98 await this.query(` 99 create table ${this.tableName('sched_cached')} as 100 select 101 (ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs} as cached_tsq, 102 ts, 103 max(dur) as dur, 104 utid, 105 id, 106 isIncomplete 107 from ${this.tableName('sched')} 108 group by cached_tsq, isIncomplete 109 order by cached_tsq 110 `); 111 this.cachedBucketNs = bucketNs; 112 } 113 114 async onBoundsChange(start: TPTime, end: TPTime, resolution: TPDuration): 115 Promise<Data> { 116 assertTrue( 117 BigintMath.popcount(resolution) === 1, 118 `${resolution} is not a power of 2`); 119 const resolutionNs = Number(resolution); 120 121 // The resolution should always be a power of two for the logic of this 122 // function to make sense. 123 assertTrue(Math.log2(resolutionNs) % 1 === 0); 124 125 const boundStartNs = tpTimeToNanos(start); 126 const boundEndNs = tpTimeToNanos(end); 127 128 // ns per quantization bucket (i.e. ns per pixel). /2 * 2 is to force it to 129 // be an even number, so we can snap in the middle. 130 const bucketNs = 131 Math.max(Math.round(resolutionNs * this.pxSize() / 2) * 2, 1); 132 133 const isCached = this.cachedBucketNs <= bucketNs; 134 const queryTsq = isCached ? 135 `cached_tsq / ${bucketNs} * ${bucketNs}` : 136 `(ts + ${bucketNs / 2}) / ${bucketNs} * ${bucketNs}`; 137 const queryTable = 138 isCached ? this.tableName('sched_cached') : this.tableName('sched'); 139 const constraintColumn = isCached ? 'cached_tsq' : 'ts'; 140 141 const queryRes = await this.query(` 142 select 143 ${queryTsq} as tsq, 144 ts, 145 max(dur) as dur, 146 utid, 147 id, 148 isIncomplete 149 from ${queryTable} 150 where 151 ${constraintColumn} >= ${boundStartNs - this.maxDurNs} and 152 ${constraintColumn} <= ${boundEndNs} 153 group by tsq, isIncomplete 154 order by tsq 155 `); 156 157 const numRows = queryRes.numRows(); 158 const slices: Data = { 159 start, 160 end, 161 resolution, 162 length: numRows, 163 lastRowId: this.lastRowId, 164 ids: new Float64Array(numRows), 165 starts: new Float64Array(numRows), 166 ends: new Float64Array(numRows), 167 utids: new Uint32Array(numRows), 168 isIncomplete: new Uint8Array(numRows), 169 }; 170 171 const it = queryRes.iter( 172 {tsq: NUM, ts: NUM, dur: NUM, utid: NUM, id: NUM, isIncomplete: NUM}); 173 for (let row = 0; it.valid(); it.next(), row++) { 174 const startNsQ = it.tsq; 175 const startNs = it.ts; 176 const durNs = it.dur; 177 const endNs = startNs + durNs; 178 179 // If the slice is incomplete, the end calculated later. 180 if (!it.isIncomplete) { 181 let endNsQ = 182 Math.floor((endNs + bucketNs / 2 - 1) / bucketNs) * bucketNs; 183 endNsQ = Math.max(endNsQ, startNsQ + bucketNs); 184 slices.ends[row] = fromNs(endNsQ); 185 } 186 187 slices.starts[row] = fromNs(startNsQ); 188 slices.utids[row] = it.utid; 189 slices.ids[row] = it.id; 190 slices.isIncomplete[row] = it.isIncomplete; 191 } 192 193 // If the slice is incomplete and it is the last slice in the track, the end 194 // of the slice would be the end of the visible window. Otherwise we end the 195 // slice with the beginning the next one. 196 for (let row = 0; row < slices.length; row++) { 197 if (!slices.isIncomplete[row]) { 198 continue; 199 } 200 const endNs = 201 row === slices.length - 1 ? boundEndNs : toNs(slices.starts[row + 1]); 202 203 let endNsQ = Math.floor((endNs + bucketNs / 2 - 1) / bucketNs) * bucketNs; 204 endNsQ = Math.max(endNsQ, toNs(slices.starts[row]) + bucketNs); 205 slices.ends[row] = fromNs(endNsQ); 206 } 207 return slices; 208 } 209 210 async onDestroy() { 211 await this.query(`drop table if exists ${this.tableName('sched_cached')}`); 212 } 213} 214 215const MARGIN_TOP = 3; 216const RECT_HEIGHT = 24; 217const TRACK_HEIGHT = MARGIN_TOP * 2 + RECT_HEIGHT; 218 219class CpuSliceTrack extends Track<Config, Data> { 220 static readonly kind = CPU_SLICE_TRACK_KIND; 221 static create(args: NewTrackArgs): CpuSliceTrack { 222 return new CpuSliceTrack(args); 223 } 224 225 private mousePos?: {x: number, y: number}; 226 private utidHoveredInThisTrack = -1; 227 228 constructor(args: NewTrackArgs) { 229 super(args); 230 } 231 232 getHeight(): number { 233 return TRACK_HEIGHT; 234 } 235 236 renderCanvas(ctx: CanvasRenderingContext2D): void { 237 // TODO: fonts and colors should come from the CSS and not hardcoded here. 238 const {visibleTimeScale, windowSpan} = globals.frontendLocalState; 239 const data = this.data(); 240 241 if (data === undefined) return; // Can't possibly draw anything. 242 243 // If the cached trace slices don't fully cover the visible time range, 244 // show a gray rectangle with a "Loading..." label. 245 checkerboardExcept( 246 ctx, 247 this.getHeight(), 248 windowSpan.start, 249 windowSpan.end, 250 visibleTimeScale.tpTimeToPx(data.start), 251 visibleTimeScale.tpTimeToPx(data.end)); 252 253 this.renderSlices(ctx, data); 254 } 255 256 renderSlices(ctx: CanvasRenderingContext2D, data: Data): void { 257 const { 258 visibleTimeScale, 259 visibleWindowTime, 260 } = globals.frontendLocalState; 261 assertTrue(data.starts.length === data.ends.length); 262 assertTrue(data.starts.length === data.utids.length); 263 264 const visWindowEndPx = visibleTimeScale.hpTimeToPx(visibleWindowTime.end); 265 266 ctx.textAlign = 'center'; 267 ctx.font = '12px Roboto Condensed'; 268 const charWidth = ctx.measureText('dbpqaouk').width / 8; 269 270 const startSec = visibleWindowTime.start.seconds; 271 const endSec = visibleWindowTime.end.seconds; 272 273 const rawStartIdx = data.ends.findIndex((end) => end >= startSec); 274 const startIdx = rawStartIdx === -1 ? 0 : rawStartIdx; 275 276 const [, rawEndIdx] = searchSegment(data.starts, endSec); 277 const endIdx = rawEndIdx === -1 ? data.starts.length : rawEndIdx; 278 279 for (let i = startIdx; i < endIdx; i++) { 280 const tStart = data.starts[i]; 281 let tEnd = data.ends[i]; 282 const utid = data.utids[i]; 283 284 // If the last slice is incomplete, it should end with the end of the 285 // window, else it might spill over the window and the end would not be 286 // visible as a zigzag line. 287 if (data.ids[i] === data.lastRowId && data.isIncomplete[i]) { 288 tEnd = visibleWindowTime.end.seconds; 289 } 290 const rectStart = visibleTimeScale.secondsToPx(tStart); 291 const rectEnd = visibleTimeScale.secondsToPx(tEnd); 292 const rectWidth = Math.max(1, rectEnd - rectStart); 293 294 const threadInfo = globals.threads.get(utid); 295 const pid = threadInfo && threadInfo.pid ? threadInfo.pid : -1; 296 297 const isHovering = globals.state.hoveredUtid !== -1; 298 const isThreadHovered = globals.state.hoveredUtid === utid; 299 const isProcessHovered = globals.state.hoveredPid === pid; 300 const color = colorForThread(threadInfo); 301 if (isHovering && !isThreadHovered) { 302 if (!isProcessHovered) { 303 color.l = 90; 304 color.s = 0; 305 } else { 306 color.l = Math.min(color.l + 30, 80); 307 color.s -= 20; 308 } 309 } else { 310 color.l = Math.min(color.l + 10, 60); 311 color.s -= 20; 312 } 313 ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`; 314 if (data.isIncomplete[i]) { 315 drawIncompleteSlice(ctx, rectStart, MARGIN_TOP, rectWidth, RECT_HEIGHT); 316 } else { 317 ctx.fillRect(rectStart, MARGIN_TOP, rectWidth, RECT_HEIGHT); 318 } 319 320 // Don't render text when we have less than 5px to play with. 321 if (rectWidth < 5) continue; 322 323 // TODO: consider de-duplicating this code with the copied one from 324 // chrome_slices/frontend.ts. 325 let title = `[utid:${utid}]`; 326 let subTitle = ''; 327 if (threadInfo) { 328 if (threadInfo.pid) { 329 let procName = threadInfo.procName || ''; 330 if (procName.startsWith('/')) { // Remove folder paths from name 331 procName = procName.substring(procName.lastIndexOf('/') + 1); 332 } 333 title = `${procName} [${threadInfo.pid}]`; 334 subTitle = `${threadInfo.threadName} [${threadInfo.tid}]`; 335 } else { 336 title = `${threadInfo.threadName} [${threadInfo.tid}]`; 337 } 338 } 339 const right = Math.min(visWindowEndPx, rectEnd); 340 const left = Math.max(rectStart, 0); 341 const visibleWidth = Math.max(right - left, 1); 342 title = cropText(title, charWidth, visibleWidth); 343 subTitle = cropText(subTitle, charWidth, visibleWidth); 344 const rectXCenter = left + visibleWidth / 2; 345 ctx.fillStyle = '#fff'; 346 ctx.font = '12px Roboto Condensed'; 347 ctx.fillText(title, rectXCenter, MARGIN_TOP + RECT_HEIGHT / 2 - 1); 348 ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; 349 ctx.font = '10px Roboto Condensed'; 350 ctx.fillText(subTitle, rectXCenter, MARGIN_TOP + RECT_HEIGHT / 2 + 9); 351 } 352 353 const selection = globals.state.currentSelection; 354 const details = globals.sliceDetails; 355 if (selection !== null && selection.kind === 'SLICE') { 356 const [startIndex, endIndex] = searchEq(data.ids, selection.id); 357 if (startIndex !== endIndex) { 358 const tStart = data.starts[startIndex]; 359 const tEnd = data.ends[startIndex]; 360 const utid = data.utids[startIndex]; 361 const color = colorForThread(globals.threads.get(utid)); 362 const rectStart = visibleTimeScale.secondsToPx(tStart); 363 const rectEnd = visibleTimeScale.secondsToPx(tEnd); 364 const rectWidth = Math.max(1, rectEnd - rectStart); 365 366 // Draw a rectangle around the slice that is currently selected. 367 ctx.strokeStyle = `hsl(${color.h}, ${color.s}%, 30%)`; 368 ctx.beginPath(); 369 ctx.lineWidth = 3; 370 ctx.strokeRect(rectStart, MARGIN_TOP - 1.5, rectWidth, RECT_HEIGHT + 3); 371 ctx.closePath(); 372 // Draw arrow from wakeup time of current slice. 373 if (details.wakeupTs) { 374 const wakeupPos = visibleTimeScale.tpTimeToPx(details.wakeupTs); 375 const latencyWidth = rectStart - wakeupPos; 376 drawDoubleHeadedArrow( 377 ctx, 378 wakeupPos, 379 MARGIN_TOP + RECT_HEIGHT, 380 latencyWidth, 381 latencyWidth >= 20); 382 // Latency time with a white semi-transparent background. 383 const latency = tpTimeFromSeconds(tStart) - details.wakeupTs; 384 const displayText = tpTimeToString(latency); 385 const measured = ctx.measureText(displayText); 386 if (latencyWidth >= measured.width + 2) { 387 ctx.fillStyle = 'rgba(255,255,255,0.7)'; 388 ctx.fillRect( 389 wakeupPos + latencyWidth / 2 - measured.width / 2 - 1, 390 MARGIN_TOP + RECT_HEIGHT - 12, 391 measured.width + 2, 392 11); 393 ctx.textBaseline = 'bottom'; 394 ctx.fillStyle = 'black'; 395 ctx.fillText( 396 displayText, 397 wakeupPos + (latencyWidth) / 2, 398 MARGIN_TOP + RECT_HEIGHT - 1); 399 } 400 } 401 } 402 403 // Draw diamond if the track being drawn is the cpu of the waker. 404 if (this.config.cpu === details.wakerCpu && details.wakeupTs) { 405 const wakeupPos = 406 Math.floor(visibleTimeScale.tpTimeToPx(details.wakeupTs)); 407 ctx.beginPath(); 408 ctx.moveTo(wakeupPos, MARGIN_TOP + RECT_HEIGHT / 2 + 8); 409 ctx.fillStyle = 'black'; 410 ctx.lineTo(wakeupPos + 6, MARGIN_TOP + RECT_HEIGHT / 2); 411 ctx.lineTo(wakeupPos, MARGIN_TOP + RECT_HEIGHT / 2 - 8); 412 ctx.lineTo(wakeupPos - 6, MARGIN_TOP + RECT_HEIGHT / 2); 413 ctx.fill(); 414 ctx.closePath(); 415 } 416 } 417 418 const hoveredThread = globals.threads.get(this.utidHoveredInThisTrack); 419 if (hoveredThread !== undefined && this.mousePos !== undefined) { 420 const tidText = `T: ${hoveredThread.threadName} [${hoveredThread.tid}]`; 421 if (hoveredThread.pid) { 422 const pidText = `P: ${hoveredThread.procName} [${hoveredThread.pid}]`; 423 this.drawTrackHoverTooltip(ctx, this.mousePos, pidText, tidText); 424 } else { 425 this.drawTrackHoverTooltip(ctx, this.mousePos, tidText); 426 } 427 } 428 } 429 430 onMouseMove(pos: {x: number, y: number}) { 431 const data = this.data(); 432 this.mousePos = pos; 433 if (data === undefined) return; 434 const {visibleTimeScale} = globals.frontendLocalState; 435 if (pos.y < MARGIN_TOP || pos.y > MARGIN_TOP + RECT_HEIGHT) { 436 this.utidHoveredInThisTrack = -1; 437 globals.dispatch(Actions.setHoveredUtidAndPid({utid: -1, pid: -1})); 438 return; 439 } 440 const t = visibleTimeScale.pxToHpTime(pos.x).seconds; 441 let hoveredUtid = -1; 442 443 for (let i = 0; i < data.starts.length; i++) { 444 const tStart = data.starts[i]; 445 const tEnd = data.ends[i]; 446 const utid = data.utids[i]; 447 if (tStart <= t && t <= tEnd) { 448 hoveredUtid = utid; 449 break; 450 } 451 } 452 this.utidHoveredInThisTrack = hoveredUtid; 453 const threadInfo = globals.threads.get(hoveredUtid); 454 const hoveredPid = threadInfo ? (threadInfo.pid ? threadInfo.pid : -1) : -1; 455 globals.dispatch( 456 Actions.setHoveredUtidAndPid({utid: hoveredUtid, pid: hoveredPid})); 457 } 458 459 onMouseOut() { 460 this.utidHoveredInThisTrack = -1; 461 globals.dispatch(Actions.setHoveredUtidAndPid({utid: -1, pid: -1})); 462 this.mousePos = undefined; 463 } 464 465 onMouseClick({x}: {x: number}) { 466 const data = this.data(); 467 if (data === undefined) return false; 468 const {visibleTimeScale} = globals.frontendLocalState; 469 const time = visibleTimeScale.pxToHpTime(x).seconds; 470 const index = search(data.starts, time); 471 const id = index === -1 ? undefined : data.ids[index]; 472 if (!id || this.utidHoveredInThisTrack === -1) return false; 473 globals.makeSelection( 474 Actions.selectSlice({id, trackId: this.trackState.id})); 475 return true; 476 } 477} 478 479function activate(ctx: PluginContext) { 480 ctx.registerTrackController(CpuSliceTrackController); 481 ctx.registerTrack(CpuSliceTrack); 482} 483 484export const plugin = { 485 pluginId: 'perfetto.CpuSlices', 486 activate, 487}; 488