| // 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 {Track} from '../../public/track'; |
| import {SourceDataset, UnionDataset} from '../../trace_processor/dataset'; |
| import {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; |
| } |