| // Copyright (C) 2025 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 type {Track} from '../../public/track'; | |
| import {SourceDataset, UnionDataset} from '../../trace_processor/dataset'; | |
| import type {Engine} from '../../trace_processor/engine'; | |
| import {LONG, NUM, STR_NULL} from '../../trace_processor/query_result'; | |
| export type ApproachType = | |
| 'uri_string' | 'track_index' | 'groupid' | 'no_lineage'; | |
| export interface BenchmarkResult { | |
| approach: ApproachType; | |
| trackCount: number; | |
| queryBuildTimeMs: number; | |
| queryExecuteTimeMs: number; | |
| totalTimeMs: number; | |
| rowCount: number; | |
| } | |
| const SLICELIKE_SPEC = { | |
| id: NUM, | |
| name: STR_NULL, | |
| ts: LONG, | |
| dur: LONG, | |
| }; | |
| const CTE_CHUNK_SIZE = 500; | |
| /** | |
| * Builds a query from an array of subqueries. | |
| * If over CTE_CHUNK_SIZE items, breaks into CTEs to help the query parser. | |
| */ | |
| function buildUnionQuery(queries: string[]): string { | |
| if (queries.length <= CTE_CHUNK_SIZE) { | |
| return queries.join('\nUNION ALL\n'); | |
| } | |
| // Break into chunks and create CTEs | |
| const ctes: string[] = []; | |
| const cteNames: string[] = []; | |
| for (let i = 0; i < queries.length; i += CTE_CHUNK_SIZE) { | |
| const chunk = queries.slice(i, i + CTE_CHUNK_SIZE); | |
| const cteName = `_chunk_${Math.floor(i / CTE_CHUNK_SIZE)}`; | |
| cteNames.push(cteName); | |
| ctes.push(`${cteName} AS (\n${chunk.join('\nUNION ALL\n')}\n)`); | |
| } | |
| const cteSection = `WITH ${ctes.join(',\n')}`; | |
| const finalUnion = cteNames | |
| .map((name) => `SELECT * FROM ${name}`) | |
| .join('\nUNION ALL\n'); | |
| return `${cteSection}\n${finalUnion}`; | |
| } | |
| interface TrackGroup { | |
| groupId: number; | |
| partitionCol: string; | |
| tracks: Track[]; | |
| datasets: SourceDataset[]; | |
| } | |
| /** | |
| * Groups tracks by their dataset source, extracting partition info. | |
| */ | |
| function groupTracksBySource(tracks: ReadonlyArray<Track>): TrackGroup[] { | |
| const sourceGroups = new Map<string, TrackGroup>(); | |
| let nextGroupId = 0; | |
| for (const track of tracks) { | |
| const dataset = track.renderer.getDataset?.(); | |
| if (!dataset || !(dataset instanceof SourceDataset)) continue; | |
| if (!dataset.implements(SLICELIKE_SPEC)) continue; | |
| let group = sourceGroups.get(dataset.src); | |
| if (!group) { | |
| group = { | |
| groupId: nextGroupId++, | |
| partitionCol: dataset.filter?.col ?? '', | |
| tracks: [], | |
| datasets: [], | |
| }; | |
| sourceGroups.set(dataset.src, group); | |
| } | |
| group.tracks.push(track); | |
| group.datasets.push(dataset); | |
| } | |
| return Array.from(sourceGroups.values()); | |
| } | |
| /** | |
| * Approach 1: URI String Literals | |
| * Each track's URI injected as a string literal in UNION ALL | |
| */ | |
| async function benchmarkUriString( | |
| engine: Engine, | |
| tracks: ReadonlyArray<Track>, | |
| ): Promise<BenchmarkResult> { | |
| const startBuild = performance.now(); | |
| // Build individual queries with URI string literals | |
| const queries: string[] = []; | |
| for (const track of tracks) { | |
| const dataset = track.renderer.getDataset?.(); | |
| if (!dataset || !(dataset instanceof SourceDataset)) continue; | |
| if (!dataset.implements(SLICELIKE_SPEC)) continue; | |
| const baseQuery = dataset.query(SLICELIKE_SPEC); | |
| queries.push(`SELECT *, '${track.uri}' AS track_uri FROM (${baseQuery})`); | |
| } | |
| if (queries.length === 0) { | |
| return { | |
| approach: 'uri_string', | |
| trackCount: 0, | |
| queryBuildTimeMs: 0, | |
| queryExecuteTimeMs: 0, | |
| totalTimeMs: 0, | |
| rowCount: 0, | |
| }; | |
| } | |
| const fullQuery = buildUnionQuery(queries); | |
| const buildTime = performance.now() - startBuild; | |
| // Execute the query | |
| const startExecute = performance.now(); | |
| const result = await engine.query( | |
| `SELECT COUNT(*) as cnt FROM (${fullQuery})`, | |
| ); | |
| const executeTime = performance.now() - startExecute; | |
| const rowCount = result.firstRow({cnt: NUM}).cnt; | |
| return { | |
| approach: 'uri_string', | |
| trackCount: tracks.length, | |
| queryBuildTimeMs: buildTime, | |
| queryExecuteTimeMs: executeTime, | |
| totalTimeMs: buildTime + executeTime, | |
| rowCount, | |
| }; | |
| } | |
| /** | |
| * Approach 2: Track Index (Integer per track) | |
| * Each track gets a unique integer index in UNION ALL | |
| */ | |
| async function benchmarkTrackIndex( | |
| engine: Engine, | |
| tracks: ReadonlyArray<Track>, | |
| ): Promise<BenchmarkResult> { | |
| const startBuild = performance.now(); | |
| // Build individual queries with integer track index | |
| const queries: string[] = []; | |
| let trackIndex = 0; | |
| for (const track of tracks) { | |
| const dataset = track.renderer.getDataset?.(); | |
| if (!dataset || !(dataset instanceof SourceDataset)) continue; | |
| if (!dataset.implements(SLICELIKE_SPEC)) continue; | |
| const baseQuery = dataset.query(SLICELIKE_SPEC); | |
| queries.push( | |
| `SELECT id, name, ts, dur, ${trackIndex} AS track_index FROM (${baseQuery})`, | |
| ); | |
| trackIndex++; | |
| } | |
| if (queries.length === 0) { | |
| return { | |
| approach: 'track_index', | |
| trackCount: 0, | |
| queryBuildTimeMs: 0, | |
| queryExecuteTimeMs: 0, | |
| totalTimeMs: 0, | |
| rowCount: 0, | |
| }; | |
| } | |
| const fullQuery = buildUnionQuery(queries); | |
| const buildTime = performance.now() - startBuild; | |
| // Execute the query | |
| const startExecute = performance.now(); | |
| const result = await engine.query( | |
| `SELECT COUNT(*) as cnt FROM (${fullQuery})`, | |
| ); | |
| const executeTime = performance.now() - startExecute; | |
| const rowCount = result.firstRow({cnt: NUM}).cnt; | |
| return { | |
| approach: 'track_index', | |
| trackCount: tracks.length, | |
| queryBuildTimeMs: buildTime, | |
| queryExecuteTimeMs: executeTime, | |
| totalTimeMs: buildTime + executeTime, | |
| rowCount, | |
| }; | |
| } | |
| /** | |
| * Approach 3: GroupID with partition columns | |
| * Group by source, inject numeric groupid + partition column | |
| */ | |
| async function benchmarkGroupId( | |
| engine: Engine, | |
| tracks: ReadonlyArray<Track>, | |
| ): Promise<BenchmarkResult> { | |
| const startBuild = performance.now(); | |
| const groups = groupTracksBySource(tracks); | |
| if (groups.length === 0) { | |
| return { | |
| approach: 'groupid', | |
| trackCount: 0, | |
| queryBuildTimeMs: 0, | |
| queryExecuteTimeMs: 0, | |
| totalTimeMs: 0, | |
| rowCount: 0, | |
| }; | |
| } | |
| // Build UNION query with groupid column | |
| // All branches must have the same columns: id, name, ts, dur, groupid, __partition | |
| const queries: string[] = []; | |
| for (const group of groups) { | |
| const union = UnionDataset.create(group.datasets); | |
| // Build schema that includes the partition column if available | |
| const querySchema = group.partitionCol | |
| ? {...SLICELIKE_SPEC, [group.partitionCol]: NUM} | |
| : SLICELIKE_SPEC; | |
| const baseQuery = union.query(querySchema); | |
| // Select partition column if available, otherwise use NULL | |
| const partitionExpr = group.partitionCol ? group.partitionCol : 'NULL'; | |
| // Explicitly select columns to ensure all branches have the same shape | |
| queries.push(` | |
| SELECT id, name, ts, dur, ${group.groupId} AS groupid, ${partitionExpr} AS __partition | |
| FROM (${baseQuery}) | |
| `); | |
| } | |
| const fullQuery = queries.join('\nUNION ALL\n'); | |
| const buildTime = performance.now() - startBuild; | |
| // Execute the query | |
| const startExecute = performance.now(); | |
| const result = await engine.query( | |
| `SELECT COUNT(*) as cnt FROM (${fullQuery})`, | |
| ); | |
| const executeTime = performance.now() - startExecute; | |
| const rowCount = result.firstRow({cnt: NUM}).cnt; | |
| return { | |
| approach: 'groupid', | |
| trackCount: tracks.length, | |
| queryBuildTimeMs: buildTime, | |
| queryExecuteTimeMs: executeTime, | |
| totalTimeMs: buildTime + executeTime, | |
| rowCount, | |
| }; | |
| } | |
| /** | |
| * Approach 3: No lineage (baseline) | |
| * Just the basic aggregation without any track info | |
| */ | |
| async function benchmarkNoLineage( | |
| engine: Engine, | |
| tracks: ReadonlyArray<Track>, | |
| ): Promise<BenchmarkResult> { | |
| const startBuild = performance.now(); | |
| const datasets: SourceDataset[] = []; | |
| for (const track of tracks) { | |
| const dataset = track.renderer.getDataset?.(); | |
| if (!dataset || !(dataset instanceof SourceDataset)) continue; | |
| if (!dataset.implements(SLICELIKE_SPEC)) continue; | |
| datasets.push(dataset); | |
| } | |
| if (datasets.length === 0) { | |
| return { | |
| approach: 'no_lineage', | |
| trackCount: 0, | |
| queryBuildTimeMs: 0, | |
| queryExecuteTimeMs: 0, | |
| totalTimeMs: 0, | |
| rowCount: 0, | |
| }; | |
| } | |
| const union = UnionDataset.create(datasets); | |
| const fullQuery = union.query(SLICELIKE_SPEC); | |
| const buildTime = performance.now() - startBuild; | |
| // Execute the query | |
| const startExecute = performance.now(); | |
| const result = await engine.query( | |
| `SELECT COUNT(*) as cnt FROM (${fullQuery})`, | |
| ); | |
| const executeTime = performance.now() - startExecute; | |
| const rowCount = result.firstRow({cnt: NUM}).cnt; | |
| return { | |
| approach: 'no_lineage', | |
| trackCount: tracks.length, | |
| queryBuildTimeMs: buildTime, | |
| queryExecuteTimeMs: executeTime, | |
| totalTimeMs: buildTime + executeTime, | |
| rowCount, | |
| }; | |
| } | |
| const RUNS_PER_APPROACH = 3; | |
| type BenchmarkFn = ( | |
| engine: Engine, | |
| tracks: ReadonlyArray<Track>, | |
| ) => Promise<BenchmarkResult>; | |
| /** | |
| * Shuffle an array in place using Fisher-Yates algorithm. | |
| */ | |
| function shuffle<T>(array: T[]): T[] { | |
| for (let i = array.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| [array[i], array[j]] = [array[j], array[i]]; | |
| } | |
| return array; | |
| } | |
| /** | |
| * Average multiple benchmark results for the same approach. | |
| */ | |
| function averageResults(results: BenchmarkResult[]): BenchmarkResult { | |
| const n = results.length; | |
| if (n === 0) throw new Error('No results to average'); | |
| return { | |
| approach: results[0].approach, | |
| trackCount: results[0].trackCount, | |
| rowCount: results[0].rowCount, | |
| queryBuildTimeMs: | |
| results.reduce((sum, r) => sum + r.queryBuildTimeMs, 0) / n, | |
| queryExecuteTimeMs: | |
| results.reduce((sum, r) => sum + r.queryExecuteTimeMs, 0) / n, | |
| totalTimeMs: results.reduce((sum, r) => sum + r.totalTimeMs, 0) / n, | |
| }; | |
| } | |
| /** | |
| * Run all benchmarks for the given tracks. | |
| * Each approach is run multiple times in random order, then averaged. | |
| */ | |
| export async function runBenchmarks( | |
| engine: Engine, | |
| tracks: ReadonlyArray<Track>, | |
| ): Promise<BenchmarkResult[]> { | |
| // Filter to only slice-like tracks | |
| const sliceTracks = tracks.filter((t) => { | |
| const dataset = t.renderer.getDataset?.(); | |
| return dataset?.implements(SLICELIKE_SPEC); | |
| }); | |
| // Define all benchmark functions | |
| const benchmarks: BenchmarkFn[] = [ | |
| benchmarkNoLineage, | |
| benchmarkGroupId, | |
| benchmarkTrackIndex, | |
| benchmarkUriString, | |
| ]; | |
| // Create list of runs: each benchmark repeated RUNS_PER_APPROACH times | |
| const runs: Array<{fn: BenchmarkFn; index: number}> = []; | |
| for (const fn of benchmarks) { | |
| for (let i = 0; i < RUNS_PER_APPROACH; i++) { | |
| runs.push({fn, index: i}); | |
| } | |
| } | |
| // Shuffle to randomize execution order | |
| shuffle(runs); | |
| // Run all benchmarks and collect results by approach | |
| const resultsByApproach = new Map<ApproachType, BenchmarkResult[]>(); | |
| for (const run of runs) { | |
| const result = await run.fn(engine, sliceTracks); | |
| const existing = resultsByApproach.get(result.approach) ?? []; | |
| existing.push(result); | |
| resultsByApproach.set(result.approach, existing); | |
| } | |
| // Average results for each approach in consistent order | |
| const approachOrder: ApproachType[] = [ | |
| 'no_lineage', | |
| 'groupid', | |
| 'track_index', | |
| 'uri_string', | |
| ]; | |
| const finalResults: BenchmarkResult[] = []; | |
| for (const approach of approachOrder) { | |
| const results = resultsByApproach.get(approach); | |
| if (results && results.length > 0) { | |
| finalResults.push(averageResults(results)); | |
| } | |
| } | |
| return finalResults; | |
| } |