| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import m from 'mithril'; |
| |
| import {searchSegment} from '../base/binary_search'; |
| import {Disposable, NullDisposable} from '../base/disposable'; |
| import {assertTrue} from '../base/logging'; |
| import {duration, Span, Time, time} from '../base/time'; |
| import {uuidv4} from '../base/uuid'; |
| import {drawTrackHoverTooltip} from '../common/canvas_utils'; |
| import {HighPrecisionTime} from '../common/high_precision_time'; |
| import {raf} from '../core/raf_scheduler'; |
| import {LONG, NUM} from '../public'; |
| import {CounterScaleOptions} from '../tracks/counter'; |
| import {Button} from '../widgets/button'; |
| import {MenuItem, PopupMenu2} from '../widgets/menu'; |
| |
| import {checkerboardExcept} from './checkerboard'; |
| import {globals} from './globals'; |
| import {constraintsToQuerySuffix} from './sql_utils'; |
| import {NewTrackArgs, TrackBase} from './track'; |
| import {CacheKey, TrackCache} from './track_cache'; |
| |
| interface CounterData { |
| timestamps: BigInt64Array; |
| minValues: Float64Array; |
| maxValues: Float64Array; |
| lastValues: Float64Array; |
| totalDeltas: Float64Array; |
| rate: Float64Array; |
| maximumValue: number; |
| minimumValue: number; |
| maximumDelta: number; |
| minimumDelta: number; |
| maximumRate: number; |
| minimumRate: number; |
| } |
| |
| // 0.5 Makes the horizontal lines sharp. |
| const MARGIN_TOP = 3.5; |
| |
| export interface RenderOptions { |
| // Whether Y scale should cover all of the possible values (and therefore, be |
| // static) or whether it should be dynamic and cover only the visible values. |
| yRange: 'all'|'viewport'; |
| // Whether the range boundaries should be strict and use the precise min/max |
| // values or whether they should be rounded to the nearest human readable |
| // value. |
| yBoundaries: 'strict'|'human_readable'; |
| } |
| |
| export abstract class BaseCounterTrack<Config = {}> extends TrackBase<Config> { |
| protected readonly tableName: string; |
| |
| // This is the over-skirted cached bounds: |
| private countersKey: CacheKey = CacheKey.zero(); |
| |
| private counters: CounterData = { |
| timestamps: new BigInt64Array(0), |
| minValues: new Float64Array(0), |
| maxValues: new Float64Array(0), |
| lastValues: new Float64Array(0), |
| totalDeltas: new Float64Array(0), |
| rate: new Float64Array(0), |
| maximumValue: 0, |
| minimumValue: 0, |
| maximumDelta: 0, |
| minimumDelta: 0, |
| maximumRate: 0, |
| minimumRate: 0, |
| }; |
| |
| private cache: TrackCache<CounterData> = new TrackCache(5); |
| |
| private sqlState: 'UNINITIALIZED'|'INITIALIZING'|'QUERY_PENDING'| |
| 'QUERY_DONE' = 'UNINITIALIZED'; |
| private isDestroyed: boolean = false; |
| |
| // Cleanup hook for onInit. |
| private initState?: Disposable; |
| |
| private maximumValueSeen = 0; |
| private minimumValueSeen = 0; |
| private maximumDeltaSeen = 0; |
| private minimumDeltaSeen = 0; |
| private maxDurNs: duration = 0n; |
| |
| private mousePos = {x: 0, y: 0}; |
| private hoveredValue: number|undefined = undefined; |
| private hoveredTs: time|undefined = undefined; |
| private hoveredTsEnd: time|undefined = undefined; |
| |
| private scale?: CounterScaleOptions; |
| |
| // Extension points. |
| |
| // onInit hook lets you do asynchronous set up e.g. creating a table |
| // etc. We guarantee that this will be resolved before doing any |
| // queries using the result of getSqlSource(). All persistent |
| // state in trace_processor should be cleaned up when dispose is |
| // called on the returned hook. |
| async onInit(): Promise<Disposable> { |
| return new NullDisposable(); |
| } |
| |
| // This should be an SQL expression returning the columns `ts` and `value`. |
| abstract getSqlSource(): string; |
| |
| protected getRenderOptions(): RenderOptions { |
| return { |
| yRange: 'all', |
| yBoundaries: 'human_readable', |
| }; |
| } |
| |
| constructor(args: NewTrackArgs) { |
| super(args); |
| this.tableName = `track_${uuidv4().replace(/[^a-zA-Z0-9_]+/g, '_')}`; |
| } |
| |
| getHeight() { |
| return 30; |
| } |
| |
| // A method to render menu items for switching the rendering modes. |
| // Useful if a subclass wants to encorporate it as a submenu. |
| protected getCounterContextMenuItems(): m.Children { |
| const currentScale = this.scale; |
| const scales: {name: CounterScaleOptions, humanName: string}[] = [ |
| {name: 'ZERO_BASED', humanName: 'Zero based'}, |
| {name: 'MIN_MAX', humanName: 'Min/Max'}, |
| {name: 'DELTA_FROM_PREVIOUS', humanName: 'Delta'}, |
| {name: 'RATE', humanName: 'Rate'}, |
| ]; |
| return scales.map((scale) => { |
| return m(MenuItem, { |
| label: scale.humanName, |
| active: currentScale === scale.name, |
| onclick: () => { |
| this.scale = scale.name; |
| raf.scheduleFullRedraw(); |
| }, |
| }); |
| }); |
| } |
| |
| // A method to render a context menu corresponding to switching the rendering |
| // modes. By default, getTrackShellButtons renders it, but a subclass can call |
| // it manually, if they want to customise rendering track buttons. |
| protected getCounterContextMenu(): m.Child { |
| return m( |
| PopupMenu2, |
| { |
| trigger: m(Button, {icon: 'show_chart', minimal: true}), |
| }, |
| this.getCounterContextMenuItems(), |
| ); |
| } |
| |
| getTrackShellButtons(): m.Children { |
| return [ |
| this.getCounterContextMenu(), |
| ]; |
| } |
| |
| renderCanvas(ctx: CanvasRenderingContext2D) { |
| const { |
| visibleTimeScale: timeScale, |
| visibleWindowTime: vizTime, |
| windowSpan, |
| } = globals.frontendLocalState; |
| |
| { |
| const windowSizePx = Math.max(1, timeScale.pxSpan.delta); |
| const rawStartNs = vizTime.start.toTime(); |
| const rawEndNs = vizTime.end.toTime(); |
| const rawCountersKey = |
| CacheKey.create(rawStartNs, rawEndNs, windowSizePx); |
| |
| // If the visible time range is outside the cached area, requests |
| // asynchronously new data from the SQL engine. |
| this.maybeRequestData(rawCountersKey); |
| } |
| |
| // In any case, draw whatever we have (which might be stale/incomplete). |
| |
| if (this.counters === undefined || this.counters.timestamps.length === 0) { |
| return; |
| } |
| |
| const data = this.counters; |
| assertTrue(data.timestamps.length === data.minValues.length); |
| assertTrue(data.timestamps.length === data.maxValues.length); |
| assertTrue(data.timestamps.length === data.lastValues.length); |
| assertTrue(data.timestamps.length === data.totalDeltas.length); |
| assertTrue(data.timestamps.length === data.rate.length); |
| |
| const scale: CounterScaleOptions = this.scale ?? 'ZERO_BASED'; |
| |
| let minValues = data.minValues; |
| let maxValues = data.maxValues; |
| let lastValues = data.lastValues; |
| let maximumValue = data.maximumValue; |
| let minimumValue = data.minimumValue; |
| if (scale === 'DELTA_FROM_PREVIOUS') { |
| lastValues = data.totalDeltas; |
| minValues = data.totalDeltas; |
| maxValues = data.totalDeltas; |
| maximumValue = data.maximumDelta; |
| minimumValue = data.minimumDelta; |
| } |
| if (scale === 'RATE') { |
| lastValues = data.rate; |
| minValues = data.rate; |
| maxValues = data.rate; |
| maximumValue = data.maximumRate; |
| minimumValue = data.minimumRate; |
| } |
| |
| if (this.getRenderOptions().yRange === 'viewport') { |
| const visValuesRange = this.getVisibleValuesRange( |
| data.timestamps, minValues, maxValues, vizTime); |
| minimumValue = visValuesRange.minValue; |
| maximumValue = visValuesRange.maxValue; |
| } |
| |
| const effectiveHeight = this.getHeight() - MARGIN_TOP; |
| const endPx = windowSpan.end; |
| const zeroY = MARGIN_TOP + effectiveHeight / (minimumValue < 0 ? 2 : 1); |
| |
| // Quantize the Y axis to quarters of powers of tens (7.5K, 10K, 12.5K). |
| const {yMin, yMax, yLabel} = |
| this.computeYRange(minimumValue, Math.max(maximumValue, 0)); |
| const yRange = yMax - yMin; |
| |
| // There are 360deg of hue. We want a scale that starts at green with |
| // exp <= 3 (<= 1KB), goes orange around exp = 6 (~1MB) and red/violet |
| // around exp >= 9 (1GB). |
| // The hue scale looks like this: |
| // 0 180 360 |
| // Red orange green | blue purple magenta |
| // So we want to start @ 180deg with pow=0, go down to 0deg and then wrap |
| // back from 360deg back to 180deg. |
| const exp = Math.ceil(Math.log10(Math.max(yMax, 1))); |
| const expCapped = Math.min(Math.max(exp - 3), 9); |
| const hue = (180 - Math.floor(expCapped * (180 / 6)) + 360) % 360; |
| |
| ctx.fillStyle = `hsl(${hue}, 45%, 75%)`; |
| ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`; |
| |
| const calculateX = (ts: time) => { |
| return Math.floor(timeScale.timeToPx(ts)); |
| }; |
| const calculateY = (value: number) => { |
| return MARGIN_TOP + effectiveHeight - |
| Math.round(((value - yMin) / yRange) * effectiveHeight); |
| }; |
| |
| ctx.beginPath(); |
| const timestamp = Time.fromRaw(data.timestamps[0]); |
| ctx.moveTo(calculateX(timestamp), zeroY); |
| let lastDrawnY = zeroY; |
| for (let i = 0; i < this.counters.timestamps.length; i++) { |
| const timestamp = Time.fromRaw(data.timestamps[i]); |
| const x = calculateX(timestamp); |
| const minY = calculateY(minValues[i]); |
| const maxY = calculateY(maxValues[i]); |
| const lastY = calculateY(lastValues[i]); |
| |
| ctx.lineTo(x, lastDrawnY); |
| if (minY === maxY) { |
| assertTrue(lastY === minY); |
| ctx.lineTo(x, lastY); |
| } else { |
| ctx.lineTo(x, minY); |
| ctx.lineTo(x, maxY); |
| ctx.lineTo(x, lastY); |
| } |
| lastDrawnY = lastY; |
| } |
| ctx.lineTo(endPx, lastDrawnY); |
| ctx.lineTo(endPx, zeroY); |
| ctx.closePath(); |
| ctx.fill(); |
| ctx.stroke(); |
| |
| // Draw the Y=0 dashed line. |
| ctx.strokeStyle = `hsl(${hue}, 10%, 71%)`; |
| ctx.beginPath(); |
| ctx.setLineDash([2, 4]); |
| ctx.moveTo(0, zeroY); |
| ctx.lineTo(endPx, zeroY); |
| ctx.closePath(); |
| ctx.stroke(); |
| ctx.setLineDash([]); |
| |
| ctx.font = '10px Roboto Condensed'; |
| |
| if (this.hoveredValue !== undefined && this.hoveredTs !== undefined) { |
| // TODO(hjd): Add units. |
| let text: string; |
| if (scale === 'DELTA_FROM_PREVIOUS') { |
| text = 'delta: '; |
| } else if (scale === 'RATE') { |
| text = 'delta/t: '; |
| } else { |
| text = 'value: '; |
| } |
| |
| text += `${this.hoveredValue.toLocaleString()}`; |
| |
| ctx.fillStyle = `hsl(${hue}, 45%, 75%)`; |
| ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`; |
| |
| const xStart = Math.floor(timeScale.timeToPx(this.hoveredTs)); |
| const xEnd = this.hoveredTsEnd === undefined ? |
| endPx : |
| Math.floor(timeScale.timeToPx(this.hoveredTsEnd)); |
| const y = MARGIN_TOP + effectiveHeight - |
| Math.round(((this.hoveredValue - yMin) / yRange) * effectiveHeight); |
| |
| // Highlight line. |
| ctx.beginPath(); |
| ctx.moveTo(xStart, y); |
| ctx.lineTo(xEnd, y); |
| ctx.lineWidth = 3; |
| ctx.stroke(); |
| ctx.lineWidth = 1; |
| |
| // Draw change marker. |
| ctx.beginPath(); |
| ctx.arc( |
| xStart, y, 3 /* r*/, 0 /* start angle*/, 2 * Math.PI /* end angle*/); |
| ctx.fill(); |
| ctx.stroke(); |
| |
| // Draw the tooltip. |
| drawTrackHoverTooltip(ctx, this.mousePos, this.getHeight(), text); |
| } |
| |
| // Write the Y scale on the top left corner. |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; |
| ctx.fillRect(0, 0, 42, 16); |
| ctx.fillStyle = '#666'; |
| ctx.textAlign = 'left'; |
| ctx.textBaseline = 'alphabetic'; |
| ctx.fillText(`${yLabel}`, 5, 14); |
| |
| // TODO(hjd): Refactor this into checkerboardExcept |
| { |
| const counterEndPx = Infinity; |
| // Grey out RHS. |
| if (counterEndPx < endPx) { |
| ctx.fillStyle = '#0000001f'; |
| ctx.fillRect(counterEndPx, 0, endPx - counterEndPx, this.getHeight()); |
| } |
| } |
| |
| // If the cached trace slices don't fully cover the visible time range, |
| // show a gray rectangle with a "Loading..." label. |
| checkerboardExcept( |
| ctx, |
| this.getHeight(), |
| windowSpan.start, |
| windowSpan.end, |
| timeScale.timeToPx(this.countersKey.start), |
| timeScale.timeToPx(this.countersKey.end)); |
| } |
| |
| onMouseMove(pos: {x: number, y: number}) { |
| const data = this.counters; |
| if (data === undefined) return; |
| this.mousePos = pos; |
| const {visibleTimeScale} = globals.frontendLocalState; |
| const time = visibleTimeScale.pxToHpTime(pos.x); |
| |
| let values = data.lastValues; |
| if (this.scale === 'DELTA_FROM_PREVIOUS') { |
| values = data.totalDeltas; |
| } |
| if (this.scale === 'RATE') { |
| values = data.rate; |
| } |
| |
| const [left, right] = searchSegment(data.timestamps, time.toTime()); |
| this.hoveredTs = |
| left === -1 ? undefined : Time.fromRaw(data.timestamps[left]); |
| this.hoveredTsEnd = |
| right === -1 ? undefined : Time.fromRaw(data.timestamps[right]); |
| this.hoveredValue = left === -1 ? undefined : values[left]; |
| } |
| |
| onMouseOut() { |
| this.hoveredValue = undefined; |
| this.hoveredTs = undefined; |
| } |
| |
| // Depending on the rendering settings, the Y range would cover either the |
| // entire range of possible values or the values visible on the screen. This |
| // method computes the latter. |
| private getVisibleValuesRange( |
| timestamps: BigInt64Array, minValues: Float64Array, |
| maxValues: Float64Array, visibleWindowTime: Span<HighPrecisionTime>): |
| {minValue: number, maxValue: number} { |
| let minValue = undefined; |
| let maxValue = undefined; |
| for (let i = 0; i < timestamps.length; ++i) { |
| const next = i + 1 < timestamps.length ? |
| HighPrecisionTime.fromNanos(timestamps[i + 1]) : |
| HighPrecisionTime.fromTime(globals.state.traceTime.end); |
| if (visibleWindowTime.intersects( |
| HighPrecisionTime.fromNanos(timestamps[i]), next)) { |
| if (minValue === undefined) { |
| minValue = minValues[i]; |
| } else { |
| minValue = Math.min(minValue, minValues[i]); |
| } |
| if (maxValue === undefined) { |
| maxValue = maxValues[i]; |
| } else { |
| maxValue = Math.max(maxValue, maxValues[i]); |
| } |
| } |
| } |
| |
| return { |
| minValue: minValue ?? 0, |
| maxValue: maxValue ?? 0, |
| }; |
| } |
| |
| onDestroy() { |
| super.onDestroy(); |
| this.isDestroyed = true; |
| if (this.initState) { |
| this.initState.dispose(); |
| this.initState = undefined; |
| } |
| } |
| |
| // Compute the range of values to display, converting to human-readable scale |
| // if needed. |
| private computeYRange(minimumValue: number, maximumValue: number): { |
| yMin: number, |
| yMax: number, |
| yLabel: string, |
| } { |
| let yMax = Math.max(Math.abs(minimumValue), maximumValue); |
| const kUnits = ['', 'K', 'M', 'G', 'T', 'E']; |
| const exp = Math.ceil(Math.log10(Math.max(yMax, 1))); |
| const pow10 = Math.pow(10, exp); |
| if (this.getRenderOptions().yBoundaries === 'human_readable') { |
| yMax = Math.ceil(yMax / (pow10 / 4)) * (pow10 / 4); |
| } |
| const unitGroup = Math.floor(exp / 3); |
| let yMin = 0; |
| let yLabel = ''; |
| if (this.scale === 'MIN_MAX') { |
| yMin = minimumValue; |
| yLabel = 'min - max'; |
| } else { |
| yMin = minimumValue < 0 ? -yMax : 0; |
| yLabel = `${yMax / Math.pow(10, unitGroup * 3)} ${kUnits[unitGroup]}`; |
| if (this.scale === 'DELTA_FROM_PREVIOUS') { |
| yLabel += '\u0394'; |
| } else if (this.scale === 'RATE') { |
| yLabel += '\u0394/t'; |
| } |
| } |
| return { |
| yMin, |
| yMax, |
| yLabel, |
| }; |
| } |
| |
| // The underlying table has `ts` and `value` columns, but we also want to |
| // query `dur` and `delta` - we create a CTE to help with that. |
| private getSqlPreamble(): string { |
| return ` |
| WITH data AS ( |
| SELECT |
| ts, |
| value, |
| lead(ts, 1, ts) over (order by ts) - ts as dur, |
| lead(value, 1, value) over (order by ts) - value as delta |
| FROM (${this.getSqlSource()}) |
| ) |
| `; |
| } |
| |
| private async maybeRequestData(rawCountersKey: CacheKey) { |
| // Important: this method is async and is invoked on every frame. Care |
| // must be taken to avoid piling up queries on every frame, hence the FSM. |
| // TODO(altimin): Currently this is a copy of the logic in base_slice_track. |
| // Consider merging it. |
| if (this.sqlState === 'UNINITIALIZED') { |
| this.sqlState = 'INITIALIZING'; |
| |
| if (this.isDestroyed) { |
| return; |
| } |
| this.initState = await this.onInit(); |
| |
| if (this.isDestroyed) { |
| return; |
| } |
| |
| { |
| const queryRes = (await this.engine.query(` |
| ${this.getSqlPreamble()} |
| SELECT |
| ifnull(max(value), 0) as maxValue, |
| ifnull(min(value), 0) as minValue, |
| ifnull(max(delta), 0) as maxDelta, |
| ifnull(min(delta), 0) as minDelta, |
| max( |
| iif(dur != -1, dur, (select end_ts from trace_bounds) - ts) |
| ) as maxDur |
| FROM data |
| `)).firstRow({ |
| maxValue: NUM, |
| minValue: NUM, |
| maxDelta: NUM, |
| minDelta: NUM, |
| maxDur: LONG, |
| }); |
| |
| this.minimumValueSeen = queryRes.minValue; |
| this.maximumValueSeen = queryRes.maxValue; |
| this.minimumDeltaSeen = queryRes.minDelta; |
| this.maximumDeltaSeen = queryRes.maxDelta; |
| this.maxDurNs = queryRes.maxDur; |
| } |
| |
| this.sqlState = 'QUERY_DONE'; |
| } else if ( |
| this.sqlState === 'INITIALIZING' || this.sqlState === 'QUERY_PENDING') { |
| return; |
| } |
| |
| if (rawCountersKey.isCoveredBy(this.countersKey)) { |
| return; // We have the data already, no need to re-query. |
| } |
| |
| const countersKey = rawCountersKey.normalize(); |
| if (!rawCountersKey.isCoveredBy(countersKey)) { |
| throw new Error(`Normalization error ${countersKey.toString()} ${ |
| rawCountersKey.toString()}`); |
| } |
| |
| const maybeCachedCounters = this.cache.lookup(countersKey); |
| if (maybeCachedCounters) { |
| this.countersKey = countersKey; |
| this.counters = maybeCachedCounters; |
| } |
| |
| this.sqlState = 'QUERY_PENDING'; |
| const bucketNs = countersKey.bucketSize; |
| |
| const constraint = constraintsToQuerySuffix({ |
| filters: [ |
| `ts >= ${countersKey.start} - ${this.maxDurNs}`, |
| `ts <= ${countersKey.end}`, |
| ], |
| groupBy: [ |
| 'tsq', |
| ], |
| orderBy: [ |
| 'tsq', |
| ], |
| }); |
| |
| if (this.isDestroyed) { |
| this.sqlState = 'QUERY_DONE'; |
| return; |
| } |
| |
| const queryRes = await this.engine.query(` |
| ${this.getSqlPreamble()} |
| SELECT |
| (ts + ${bucketNs / 2n}) / ${bucketNs} * ${bucketNs} as tsq, |
| min(value) as minValue, |
| max(value) as maxValue, |
| sum(delta) as totalDelta, |
| value_at_max_ts(ts, value) as lastValue |
| FROM data |
| ${constraint} |
| `); |
| |
| const it = queryRes.iter({ |
| tsq: LONG, |
| minValue: NUM, |
| maxValue: NUM, |
| totalDelta: NUM, |
| lastValue: NUM, |
| }); |
| |
| const numRows = queryRes.numRows(); |
| const data: CounterData = { |
| maximumValue: this.maximumValueSeen, |
| minimumValue: this.minimumValueSeen, |
| maximumDelta: this.maximumDeltaSeen, |
| minimumDelta: this.minimumDeltaSeen, |
| maximumRate: 0, |
| minimumRate: 0, |
| timestamps: new BigInt64Array(numRows), |
| minValues: new Float64Array(numRows), |
| maxValues: new Float64Array(numRows), |
| lastValues: new Float64Array(numRows), |
| totalDeltas: new Float64Array(numRows), |
| rate: new Float64Array(numRows), |
| }; |
| |
| let lastValue = 0; |
| let lastTs = 0n; |
| for (let row = 0; it.valid(); it.next(), row++) { |
| const ts = Time.fromRaw(it.tsq); |
| const value = it.lastValue; |
| const rate = (value - lastValue) / (Time.toSeconds(Time.sub(ts, lastTs))); |
| lastTs = ts; |
| lastValue = value; |
| |
| data.timestamps[row] = ts; |
| data.minValues[row] = it.minValue; |
| data.maxValues[row] = it.maxValue; |
| data.lastValues[row] = value; |
| data.totalDeltas[row] = it.totalDelta; |
| data.rate[row] = rate; |
| if (row > 0) { |
| data.rate[row - 1] = rate; |
| data.maximumRate = Math.max(data.maximumRate, rate); |
| data.minimumRate = Math.min(data.minimumRate, rate); |
| } |
| } |
| |
| this.cache.insert(countersKey, data); |
| this.counters = data; |
| |
| this.sqlState = 'QUERY_DONE'; |
| raf.scheduleRedraw(); |
| } |
| } |