diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 0f01bf4a5be..bb0bee713ac 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -959,6 +959,10 @@ "command": "codeQLQueryHistory.compareWith", "title": "Compare Results" }, + { + "command": "codeQLQueryHistory.comparePerformanceWith", + "title": "Compare Performance" + }, { "command": "codeQLQueryHistory.openOnGithub", "title": "View Logs" @@ -1230,6 +1234,11 @@ "group": "3_queryHistory@0", "when": "viewItem == rawResultsItem || viewItem == interpretedResultsItem" }, + { + "command": "codeQLQueryHistory.comparePerformanceWith", + "group": "3_queryHistory@1", + "when": "viewItem == rawResultsItem || viewItem == interpretedResultsItem" + }, { "command": "codeQLQueryHistory.showQueryLog", "group": "4_queryHistory@4", @@ -1733,6 +1742,10 @@ "command": "codeQLQueryHistory.compareWith", "when": "false" }, + { + "command": "codeQLQueryHistory.comparePerformanceWith", + "when": "false" + }, { "command": "codeQLQueryHistory.sortByName", "when": "false" diff --git a/extensions/ql-vscode/src/common/commands.ts b/extensions/ql-vscode/src/common/commands.ts index 302ca6fe0a9..2fd8a1995d4 100644 --- a/extensions/ql-vscode/src/common/commands.ts +++ b/extensions/ql-vscode/src/common/commands.ts @@ -180,6 +180,7 @@ export type QueryHistoryCommands = { "codeQLQueryHistory.removeHistoryItemContextInline": TreeViewContextMultiSelectionCommandFunction; "codeQLQueryHistory.renameItem": TreeViewContextMultiSelectionCommandFunction; "codeQLQueryHistory.compareWith": TreeViewContextMultiSelectionCommandFunction; + "codeQLQueryHistory.comparePerformanceWith": TreeViewContextMultiSelectionCommandFunction; "codeQLQueryHistory.showEvalLog": TreeViewContextMultiSelectionCommandFunction; "codeQLQueryHistory.showEvalLogSummary": TreeViewContextMultiSelectionCommandFunction; "codeQLQueryHistory.showEvalLogViewer": TreeViewContextMultiSelectionCommandFunction; diff --git a/extensions/ql-vscode/src/common/interface-types.ts b/extensions/ql-vscode/src/common/interface-types.ts index 81ef4e612e6..2a0fb24c811 100644 --- a/extensions/ql-vscode/src/common/interface-types.ts +++ b/extensions/ql-vscode/src/common/interface-types.ts @@ -27,6 +27,7 @@ import type { } from "./raw-result-types"; import type { AccessPathSuggestionOptions } from "../model-editor/suggestions"; import type { ModelEvaluationRunState } from "../model-editor/shared/model-evaluation-run-state"; +import type { PerformanceComparisonDataFromLog } from "../log-insights/performance-comparison"; /** * This module contains types and code that are shared between @@ -396,6 +397,17 @@ export interface SetComparisonsMessage { readonly message: string | undefined; } +export type ToComparePerformanceViewMessage = SetPerformanceComparisonQueries; + +export interface SetPerformanceComparisonQueries { + readonly t: "setPerformanceComparison"; + readonly from: PerformanceComparisonDataFromLog; + readonly to: PerformanceComparisonDataFromLog; + readonly comparison: boolean; +} + +export type FromComparePerformanceViewMessage = CommonFromViewMessages; + export type QueryCompareResult = | RawQueryCompareResult | InterpretedQueryCompareResult; diff --git a/extensions/ql-vscode/src/common/vscode/abstract-webview.ts b/extensions/ql-vscode/src/common/vscode/abstract-webview.ts index c38590e4feb..87c0583af1d 100644 --- a/extensions/ql-vscode/src/common/vscode/abstract-webview.ts +++ b/extensions/ql-vscode/src/common/vscode/abstract-webview.ts @@ -41,6 +41,13 @@ export abstract class AbstractWebview< constructor(protected readonly app: App) {} + public hidePanel() { + if (this.panel !== undefined) { + this.panel.dispose(); + this.panel = undefined; + } + } + public async restoreView(panel: WebviewPanel): Promise { this.panel = panel; const config = await this.getPanelConfig(); diff --git a/extensions/ql-vscode/src/common/vscode/webview-html.ts b/extensions/ql-vscode/src/common/vscode/webview-html.ts index 7ad1f5d08e4..9a02714f726 100644 --- a/extensions/ql-vscode/src/common/vscode/webview-html.ts +++ b/extensions/ql-vscode/src/common/vscode/webview-html.ts @@ -7,6 +7,7 @@ import type { App } from "../app"; export type WebviewKind = | "results" | "compare" + | "compare-performance" | "variant-analysis" | "data-flow-paths" | "model-editor" diff --git a/extensions/ql-vscode/src/compare-performance/compare-performance-view.ts b/extensions/ql-vscode/src/compare-performance/compare-performance-view.ts new file mode 100644 index 00000000000..c1633801ed6 --- /dev/null +++ b/extensions/ql-vscode/src/compare-performance/compare-performance-view.ts @@ -0,0 +1,108 @@ +import { statSync } from "fs"; +import { ViewColumn } from "vscode"; + +import type { App } from "../common/app"; +import { redactableError } from "../common/errors"; +import type { + FromComparePerformanceViewMessage, + ToComparePerformanceViewMessage, +} from "../common/interface-types"; +import type { Logger } from "../common/logging"; +import { showAndLogExceptionWithTelemetry } from "../common/logging"; +import { extLogger } from "../common/logging/vscode"; +import type { WebviewPanelConfig } from "../common/vscode/abstract-webview"; +import { AbstractWebview } from "../common/vscode/abstract-webview"; +import { withProgress } from "../common/vscode/progress"; +import { telemetryListener } from "../common/vscode/telemetry"; +import type { HistoryItemLabelProvider } from "../query-history/history-item-label-provider"; +import { PerformanceOverviewScanner } from "../log-insights/performance-comparison"; +import { scanLog } from "../log-insights/log-scanner"; +import type { ResultsView } from "../local-queries"; + +export class ComparePerformanceView extends AbstractWebview< + ToComparePerformanceViewMessage, + FromComparePerformanceViewMessage +> { + constructor( + app: App, + public logger: Logger, + public labelProvider: HistoryItemLabelProvider, + private resultsView: ResultsView, + ) { + super(app); + } + + async showResults(fromJsonLog: string, toJsonLog: string) { + const panel = await this.getPanel(); + panel.reveal(undefined, false); + + // Close the results viewer as it will have opened when the user clicked the query in the history view + // (which they must do as part of the UI interaction for opening the performance view). + // The performance view generally needs a lot of width so it's annoying to have the result viewer open. + this.resultsView.hidePanel(); + + await this.waitForPanelLoaded(); + + function scanLogWithProgress(log: string, logDescription: string) { + const bytes = statSync(log).size; + return withProgress( + async (progress) => + scanLog(log, new PerformanceOverviewScanner(), progress), + + { + title: `Scanning evaluator log ${logDescription} (${(bytes / 1024 / 1024).toFixed(1)} MB)`, + }, + ); + } + + const [fromPerf, toPerf] = await Promise.all([ + fromJsonLog === "" + ? new PerformanceOverviewScanner() + : scanLogWithProgress(fromJsonLog, "1/2"), + scanLogWithProgress(toJsonLog, fromJsonLog === "" ? "1/1" : "2/2"), + ]); + + await this.postMessage({ + t: "setPerformanceComparison", + from: fromPerf.getData(), + to: toPerf.getData(), + comparison: fromJsonLog !== "", + }); + } + + protected getPanelConfig(): WebviewPanelConfig { + return { + viewId: "comparePerformanceView", + title: "Compare CodeQL Performance", + viewColumn: ViewColumn.Active, + preserveFocus: true, + view: "compare-performance", + }; + } + + protected onPanelDispose(): void {} + + protected async onMessage( + msg: FromComparePerformanceViewMessage, + ): Promise { + switch (msg.t) { + case "viewLoaded": + this.onWebViewLoaded(); + break; + + case "telemetry": + telemetryListener?.sendUIInteraction(msg.action); + break; + + case "unhandledError": + void showAndLogExceptionWithTelemetry( + extLogger, + telemetryListener, + redactableError( + msg.error, + )`Unhandled error in performance comparison view: ${msg.error.message}`, + ); + break; + } + } +} diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index fde7cbec42a..7d30ecb3a54 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -135,6 +135,7 @@ import { LanguageContextStore } from "./language-context-store"; import { LanguageSelectionPanel } from "./language-selection-panel/language-selection-panel"; import { GitHubDatabasesModule } from "./databases/github-databases"; import { DatabaseFetcher } from "./databases/database-fetcher"; +import { ComparePerformanceView } from "./compare-performance/compare-performance-view"; /** * extension.ts @@ -924,6 +925,11 @@ async function activateWithInstalledDistribution( from: CompletedLocalQueryInfo, to: CompletedLocalQueryInfo, ): Promise => showResultsForComparison(compareView, from, to), + async ( + from: CompletedLocalQueryInfo, + to: CompletedLocalQueryInfo | undefined, + ): Promise => + showPerformanceComparison(comparePerformanceView, from, to), ); ctx.subscriptions.push(qhm); @@ -949,6 +955,15 @@ async function activateWithInstalledDistribution( ); ctx.subscriptions.push(compareView); + void extLogger.log("Initializing performance comparison view."); + const comparePerformanceView = new ComparePerformanceView( + app, + queryServerLogger, + labelProvider, + localQueryResultsView, + ); + ctx.subscriptions.push(comparePerformanceView); + void extLogger.log("Initializing source archive filesystem provider."); archiveFilesystemProvider_activate(ctx, dbm); @@ -1190,6 +1205,30 @@ async function showResultsForComparison( } } +async function showPerformanceComparison( + view: ComparePerformanceView, + from: CompletedLocalQueryInfo, + to: CompletedLocalQueryInfo | undefined, +): Promise { + let fromLog = from.evaluatorLogPaths?.jsonSummary; + let toLog = to?.evaluatorLogPaths?.jsonSummary; + + if (to === undefined) { + toLog = fromLog; + fromLog = ""; + } + if (fromLog === undefined || toLog === undefined) { + return extLogger.showWarningMessage( + `Cannot compare performance as the structured logs are missing. Did they queries complete normally?`, + ); + } + await extLogger.log( + `Comparing performance of ${from.getQueryName()} and ${to?.getQueryName() ?? "baseline"}`, + ); + + await view.showResults(fromLog, toLog); +} + function addUnhandledRejectionListener() { const handler = (error: unknown) => { // This listener will be triggered for errors from other extensions as diff --git a/extensions/ql-vscode/src/log-insights/log-scanner.ts b/extensions/ql-vscode/src/log-insights/log-scanner.ts index 7a8efcd5605..55b655c2580 100644 --- a/extensions/ql-vscode/src/log-insights/log-scanner.ts +++ b/extensions/ql-vscode/src/log-insights/log-scanner.ts @@ -1,6 +1,7 @@ -import type { SummaryEvent } from "./log-summary"; -import { readJsonlFile } from "../common/jsonl-reader"; import type { Disposable } from "../common/disposable-object"; +import { readJsonlFile } from "../common/jsonl-reader"; +import type { ProgressCallback } from "../common/vscode/progress"; +import type { SummaryEvent } from "./log-summary"; /** * Callback interface used to report diagnostics from a log scanner. @@ -112,3 +113,27 @@ export class EvaluationLogScannerSet { scanners.forEach((scanner) => scanner.onDone()); } } + +/** + * Scan the evaluator summary log using the given scanner. For convenience, returns the scanner. + * + * @param jsonSummaryLocation The file path of the JSON summary log. + * @param scanner The scanner to process events from the log + */ +export async function scanLog( + jsonSummaryLocation: string, + scanner: T, + progress?: ProgressCallback, +): Promise { + progress?.({ + // XXX all scans have step 1 - the backing progress tracker allows increments instead of steps - but for now we are happy with a tiny UI that says what is happening + message: `Scanning ...`, + step: 1, + maxStep: 2, + }); + await readJsonlFile(jsonSummaryLocation, async (obj) => { + scanner.onEvent(obj); + }); + scanner.onDone(); + return scanner; +} diff --git a/extensions/ql-vscode/src/log-insights/log-summary.ts b/extensions/ql-vscode/src/log-insights/log-summary.ts index 50435d51e1e..5fa4bda58b8 100644 --- a/extensions/ql-vscode/src/log-insights/log-summary.ts +++ b/extensions/ql-vscode/src/log-insights/log-summary.ts @@ -33,6 +33,7 @@ interface ResultEventBase extends SummaryEventBase { export interface ComputeSimple extends ResultEventBase { evaluationStrategy: "COMPUTE_SIMPLE"; ra: Ra; + millis: number; pipelineRuns?: [PipelineRun]; queryCausingWork?: string; dependencies: { [key: string]: string }; @@ -42,6 +43,7 @@ export interface ComputeRecursive extends ResultEventBase { evaluationStrategy: "COMPUTE_RECURSIVE"; deltaSizes: number[]; ra: Ra; + millis: number; pipelineRuns: PipelineRun[]; queryCausingWork?: string; dependencies: { [key: string]: string }; diff --git a/extensions/ql-vscode/src/log-insights/performance-comparison.ts b/extensions/ql-vscode/src/log-insights/performance-comparison.ts new file mode 100644 index 00000000000..14f4d343b8a --- /dev/null +++ b/extensions/ql-vscode/src/log-insights/performance-comparison.ts @@ -0,0 +1,177 @@ +import type { EvaluationLogScanner } from "./log-scanner"; +import type { SummaryEvent } from "./log-summary"; + +export interface PipelineSummary { + steps: string[]; + /** Total counts for each step in the RA array, across all iterations */ + counts: number[]; +} + +/** + * Data extracted from a log for the purpose of doing a performance comparison. + * + * Memory compactness is important since we keep this data in memory; once for + * each side of the comparison. + * + * This object must be able to survive a `postMessage` transfer from the extension host + * to a web view (which rules out `Map` values, for example). + */ +export interface PerformanceComparisonDataFromLog { + /** Names of predicates mentioned in the log */ + names: string[]; + + /** Number of milliseconds spent evaluating the `i`th predicate from the `names` array. */ + timeCosts: number[]; + + /** Number of tuples seen in pipelines evaluating the `i`th predicate from the `names` array. */ + tupleCosts: number[]; + + /** Number of iterations seen when evaluating the `i`th predicate from the `names` array. */ + iterationCounts: number[]; + + /** Number of executions of pipelines evaluating the `i`th predicate from the `names` array. */ + evaluationCounts: number[]; + + /** + * List of indices into the `names` array for which we have seen a cache hit. + * + * TODO: only count cache hits prior to first evaluation? + */ + cacheHitIndices: number[]; + + /** + * List of indices into the `names` array where the predicate was deemed empty due to a sentinel check. + */ + sentinelEmptyIndices: number[]; + + /** + * All the pipeline runs seen for the `i`th predicate from the `names` array. + * + * TODO: replace with more compact representation + */ + pipelineSummaryList: Array>; +} + +export class PerformanceOverviewScanner implements EvaluationLogScanner { + private readonly nameToIndex = new Map(); + private readonly data: PerformanceComparisonDataFromLog = { + names: [], + timeCosts: [], + tupleCosts: [], + cacheHitIndices: [], + sentinelEmptyIndices: [], + pipelineSummaryList: [], + evaluationCounts: [], + iterationCounts: [], + }; + + private getPredicateIndex(name: string): number { + const { nameToIndex } = this; + let index = nameToIndex.get(name); + if (index === undefined) { + index = nameToIndex.size; + nameToIndex.set(name, index); + const { + names, + timeCosts, + tupleCosts, + iterationCounts, + evaluationCounts, + pipelineSummaryList, + } = this.data; + names.push(name); + timeCosts.push(0); + tupleCosts.push(0); + iterationCounts.push(0); + evaluationCounts.push(0); + pipelineSummaryList.push({}); + } + return index; + } + + getData(): PerformanceComparisonDataFromLog { + return this.data; + } + + onEvent(event: SummaryEvent): void { + if ( + event.completionType !== undefined && + event.completionType !== "SUCCESS" + ) { + return; // Skip any evaluation that wasn't successful + } + + switch (event.evaluationStrategy) { + case "EXTENSIONAL": + case "COMPUTED_EXTENSIONAL": { + break; + } + case "CACHE_HIT": + case "CACHACA": { + this.data.cacheHitIndices.push( + this.getPredicateIndex(event.predicateName), + ); + break; + } + case "SENTINEL_EMPTY": { + this.data.sentinelEmptyIndices.push( + this.getPredicateIndex(event.predicateName), + ); + break; + } + case "COMPUTE_RECURSIVE": + case "COMPUTE_SIMPLE": + case "IN_LAYER": { + const index = this.getPredicateIndex(event.predicateName); + let totalTime = 0; + let totalTuples = 0; + if (event.evaluationStrategy !== "IN_LAYER") { + totalTime += event.millis; + } else { + // IN_LAYER events do no record of their total time. + // Make a best-effort estimate by adding up the positive iteration times (they can be negative). + for (const millis of event.predicateIterationMillis ?? []) { + if (millis > 0) { + totalTime += millis; + } + } + } + const { + timeCosts, + tupleCosts, + iterationCounts, + evaluationCounts, + pipelineSummaryList, + } = this.data; + const pipelineSummaries = pipelineSummaryList[index]; + for (const { counts, raReference } of event.pipelineRuns ?? []) { + // Get or create the pipeline summary for this RA + const pipelineSummary = (pipelineSummaries[raReference] ??= { + steps: event.ra[raReference], + counts: counts.map(() => 0), + }); + const { counts: totalTuplesPerStep } = pipelineSummary; + for (let i = 0, length = counts.length; i < length; ++i) { + // TODO: possibly exclude unions here + const count = counts[i]; + if (count < 0) { + // Empty RA lines have a tuple count of -1. Do not count them when aggregating. + // But retain the fact that this step had a negative count for rendering purposes. + totalTuplesPerStep[i] = count; + continue; + } + totalTuples += count; + totalTuplesPerStep[i] += count; + } + } + timeCosts[index] += totalTime; + tupleCosts[index] += totalTuples; + iterationCounts[index] += event.pipelineRuns?.length ?? 0; + evaluationCounts[index] += 1; + break; + } + } + } + + onDone(): void {} +} diff --git a/extensions/ql-vscode/src/query-history/query-history-manager.ts b/extensions/ql-vscode/src/query-history/query-history-manager.ts index 45d37e7eba9..35f241c52de 100644 --- a/extensions/ql-vscode/src/query-history/query-history-manager.ts +++ b/extensions/ql-vscode/src/query-history/query-history-manager.ts @@ -149,6 +149,10 @@ export class QueryHistoryManager extends DisposableObject { from: CompletedLocalQueryInfo, to: CompletedLocalQueryInfo, ) => Promise, + private readonly doComparePerformanceCallback: ( + from: CompletedLocalQueryInfo, + to: CompletedLocalQueryInfo | undefined, + ) => Promise, ) { super(); @@ -263,6 +267,8 @@ export class QueryHistoryManager extends DisposableObject { "query", ), "codeQLQueryHistory.compareWith": this.handleCompareWith.bind(this), + "codeQLQueryHistory.comparePerformanceWith": + this.handleComparePerformanceWith.bind(this), "codeQLQueryHistory.showEvalLog": createSingleSelectionCommand( this.app.logger, this.handleShowEvalLog.bind(this), @@ -679,6 +685,41 @@ export class QueryHistoryManager extends DisposableObject { } } + async handleComparePerformanceWith( + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] | undefined, + ) { + // TODO: reduce duplication with 'handleCompareWith' + multiSelect ||= [singleItem]; + + if ( + !this.isSuccessfulCompletedLocalQueryInfo(singleItem) || + !multiSelect.every(this.isSuccessfulCompletedLocalQueryInfo) + ) { + // TODO: support performance comparison with partially-evaluated query (technically possible) + throw new Error( + "Please only select local queries that have completed successfully.", + ); + } + + const fromItem = this.getFromQueryToCompare(singleItem, multiSelect); + + let toItem: CompletedLocalQueryInfo | undefined = undefined; + try { + toItem = await this.findOtherQueryToComparePerformance( + fromItem, + multiSelect, + ); + } catch (e) { + void showAndLogErrorMessage( + this.app.logger, + `Failed to compare queries: ${getErrorMessage(e)}`, + ); + } + + await this.doComparePerformanceCallback(fromItem, toItem); + } + async handleItemClicked(item: QueryHistoryInfo) { this.treeDataProvider.setCurrentItem(item); @@ -1076,6 +1117,7 @@ export class QueryHistoryManager extends DisposableObject { detail: item.completedQuery.message, query: item, })); + if (comparableQueryLabels.length < 1) { throw new Error("No other queries available to compare with."); } @@ -1084,6 +1126,60 @@ export class QueryHistoryManager extends DisposableObject { return choice?.query; } + private async findOtherQueryToComparePerformance( + fromItem: CompletedLocalQueryInfo, + allSelectedItems: CompletedLocalQueryInfo[], + ): Promise { + const dbName = fromItem.databaseName; + + // If exactly 2 items are selected, return the one that + // isn't being used as the "from" item. + if (allSelectedItems.length === 2) { + const otherItem = + fromItem === allSelectedItems[0] + ? allSelectedItems[1] + : allSelectedItems[0]; + if (otherItem.databaseName !== dbName) { + throw new Error("Query databases must be the same."); + } + return otherItem; + } + + if (allSelectedItems.length > 2) { + throw new Error("Please select no more than 2 queries."); + } + + // Otherwise, present a dialog so the user can choose the item they want to use. + const comparableQueryLabels = this.treeDataProvider.allHistory + .filter(this.isSuccessfulCompletedLocalQueryInfo) + .filter( + (otherItem) => + otherItem !== fromItem && otherItem.databaseName === dbName, + ) + .map((item) => ({ + label: this.labelProvider.getLabel(item), + description: item.databaseName, + detail: item.completedQuery.message, + query: item, + })); + const comparableQueryLabelsWithDefault = [ + { + label: "Single run", + description: + "Look at the performance of this run, compared to a trivial baseline", + detail: undefined, + query: undefined, + }, + ...comparableQueryLabels, + ]; + if (comparableQueryLabelsWithDefault.length < 1) { + throw new Error("No other queries available to compare with."); + } + const choice = await window.showQuickPick(comparableQueryLabelsWithDefault); + + return choice?.query; + } + /** * Updates the compare with source query. This ensures that all compare command invocations * when exactly 2 queries are selected always have the proper _from_ query. Always use diff --git a/extensions/ql-vscode/src/view/common/WarningBox.tsx b/extensions/ql-vscode/src/view/common/WarningBox.tsx new file mode 100644 index 00000000000..b8003d83e57 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/WarningBox.tsx @@ -0,0 +1,31 @@ +import { styled } from "styled-components"; +import { WarningIcon } from "./icon/WarningIcon"; + +const WarningBoxDiv = styled.div` + max-width: 100em; + padding: 0.5em 1em; + border: 1px solid var(--vscode-widget-border); + box-shadow: var(--vscode-widget-shadow) 0px 3px 8px; + display: flex; +`; + +const IconPane = styled.p` + width: 3em; + flex-shrink: 0; + text-align: center; +`; + +export interface WarningBoxProps { + children: React.ReactNode; +} + +export function WarningBox(props: WarningBoxProps) { + return ( + + + + +

{props.children}

+
+ ); +} diff --git a/extensions/ql-vscode/src/view/common/index.ts b/extensions/ql-vscode/src/view/common/index.ts index 7d3564bed43..fe1f4a6ec63 100644 --- a/extensions/ql-vscode/src/view/common/index.ts +++ b/extensions/ql-vscode/src/view/common/index.ts @@ -6,3 +6,4 @@ export * from "./HorizontalSpace"; export * from "./SectionTitle"; export * from "./VerticalSpace"; export * from "./ViewTitle"; +export * from "./WarningBox"; diff --git a/extensions/ql-vscode/src/view/compare-performance/ComparePerformance.tsx b/extensions/ql-vscode/src/view/compare-performance/ComparePerformance.tsx new file mode 100644 index 00000000000..cff08fb3881 --- /dev/null +++ b/extensions/ql-vscode/src/view/compare-performance/ComparePerformance.tsx @@ -0,0 +1,862 @@ +import type { ChangeEvent } from "react"; +import { + Fragment, + memo, + useDeferredValue, + useMemo, + useRef, + useState, +} from "react"; +import type { + SetPerformanceComparisonQueries, + ToComparePerformanceViewMessage, +} from "../../common/interface-types"; +import { useMessageFromExtension } from "../common/useMessageFromExtension"; +import type { + PerformanceComparisonDataFromLog, + PipelineSummary, +} from "../../log-insights/performance-comparison"; +import { formatDecimal } from "../../common/number"; +import { styled } from "styled-components"; +import { Codicon, ViewTitle, WarningBox } from "../common"; +import { abbreviateRANames, abbreviateRASteps } from "./RAPrettyPrinter"; +import { Renaming, RenamingInput } from "./RenamingInput"; + +const enum AbsentReason { + NotSeen = "NotSeen", + CacheHit = "CacheHit", + Sentinel = "Sentinel", +} + +type Optional = AbsentReason | T; + +function isPresent(x: Optional): x is T { + return typeof x !== "string"; +} + +interface PredicateInfo { + tuples: number; + evaluationCount: number; + iterationCount: number; + timeCost: number; + pipelines: Record; +} + +class ComparisonDataset { + public nameToIndex = new Map(); + public cacheHitIndices: Set; + public sentinelEmptyIndices: Set; + + constructor(public data: PerformanceComparisonDataFromLog) { + const { names } = data; + const { nameToIndex } = this; + for (let i = 0; i < names.length; i++) { + nameToIndex.set(names[i], i); + } + this.cacheHitIndices = new Set(data.cacheHitIndices); + this.sentinelEmptyIndices = new Set(data.sentinelEmptyIndices); + } + + getTupleCountInfo(name: string): Optional { + const { data, nameToIndex, cacheHitIndices, sentinelEmptyIndices } = this; + const index = nameToIndex.get(name); + if (index == null) { + return AbsentReason.NotSeen; + } + const tupleCost = data.tupleCosts[index]; + if (tupleCost === 0) { + if (sentinelEmptyIndices.has(index)) { + return AbsentReason.Sentinel; + } else if (cacheHitIndices.has(index)) { + return AbsentReason.CacheHit; + } + } + return { + evaluationCount: data.evaluationCounts[index], + iterationCount: data.iterationCounts[index], + timeCost: data.timeCosts[index], + tuples: tupleCost, + pipelines: data.pipelineSummaryList[index], + }; + } +} + +function renderOptionalValue(x: Optional, unit?: string) { + switch (x) { + case AbsentReason.NotSeen: + return n/a; + case AbsentReason.CacheHit: + return cache hit; + case AbsentReason.Sentinel: + return sentinel empty; + default: + return ( + + {formatDecimal(x)} + {renderUnit(unit)} + + ); + } +} + +function renderPredicateMetric( + x: Optional, + metric: Metric, + isPerEvaluation: boolean, +) { + return renderOptionalValue( + metricGetOptional(metric, x, isPerEvaluation), + metric.unit, + ); +} + +function renderDelta(x: number, unit?: string) { + const sign = x > 0 ? "+" : ""; + return ( + 0 ? "bad-value" : x < 0 ? "good-value" : ""}> + {sign} + {formatDecimal(x)} + {renderUnit(unit)} + + ); +} + +function renderUnit(unit: string | undefined) { + return unit == null ? "" : ` ${unit}`; +} + +function orderBy(fn: (x: T) => number | string) { + return (x: T, y: T) => { + const fx = fn(x); + const fy = fn(y); + return fx === fy ? 0 : fx < fy ? -1 : 1; + }; +} + +const ChevronCell = styled.td` + width: 1em !important; +`; + +const NameHeader = styled.th` + text-align: left; +`; + +const NumberHeader = styled.th` + text-align: right; + width: 10em !important; +`; + +const NameCell = styled.td``; + +const NumberCell = styled.td` + text-align: right; + width: 10em !important; + + &.bad-value { + color: var(--vscode-problemsErrorIcon-foreground); + tr.expanded & { + color: inherit; + } + } + &.good-value { + color: var(--vscode-problemsInfoIcon-foreground); + tr.expanded & { + color: inherit; + } + } +`; + +const AbsentNumberCell = styled.td` + text-align: right; + color: var(--vscode-disabledForeground); + + tr.expanded & { + color: inherit; + } + width: 10em !important; +`; + +const Table = styled.table` + border-collapse: collapse; + width: 100%; + border-spacing: 0; + background-color: var(--vscode-background); + color: var(--vscode-foreground); + & td { + padding: 0.5em; + } + & th { + padding: 0.5em; + } + &.expanded { + border: 1px solid var(--vscode-list-activeSelectionBackground); + margin-bottom: 1em; + } + word-break: break-all; +`; + +const PredicateTR = styled.tr` + cursor: pointer; + + &.expanded { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + position: sticky; + top: 0; + } + + & .codicon-chevron-right { + visibility: hidden; + } + + &:hover:not(.expanded) { + background-color: var(--vscode-list-hoverBackground); + & .codicon-chevron-right { + visibility: visible; + } + } +`; + +const PipelineStepTR = styled.tr` + & td { + padding-top: 0.3em; + padding-bottom: 0.3em; + } +`; + +const Dropdown = styled.select``; + +interface PipelineStepProps { + before: number | undefined; + after: number | undefined; + comparison: boolean; + step: React.ReactNode; +} + +/** + * Row with details of a pipeline step, or one of the high-level stats appearing above the pipelines (evaluation/iteration counts). + */ +function PipelineStep(props: PipelineStepProps) { + let { before, after, comparison, step } = props; + if (before != null && before < 0) { + before = undefined; + } + if (after != null && after < 0) { + after = undefined; + } + const delta = before != null && after != null ? after - before : undefined; + return ( + + + {comparison && ( + {before != null ? formatDecimal(before) : ""} + )} + {after != null ? formatDecimal(after) : ""} + {comparison && (delta != null ? renderDelta(delta) : )} + {step} + + ); +} + +const HeaderTR = styled.tr` + background-color: var(--vscode-sideBar-background); +`; + +interface HeaderRowProps { + hasBefore?: boolean; + hasAfter?: boolean; + comparison: boolean; + title: React.ReactNode; +} + +function HeaderRow(props: HeaderRowProps) { + const { comparison, hasBefore, hasAfter, title } = props; + return ( + + + {comparison ? ( + <> + {hasBefore ? "Before" : ""} + {hasAfter ? "After" : ""} + {hasBefore && hasAfter ? "Delta" : ""} + + ) : ( + Value + )} + {title} + + ); +} + +interface HighLevelStatsProps { + before: Optional; + after: Optional; + comparison: boolean; +} + +function HighLevelStats(props: HighLevelStatsProps) { + const { before, after, comparison } = props; + const hasBefore = isPresent(before); + const hasAfter = isPresent(after); + const showEvaluationCount = + (hasBefore && before.evaluationCount > 1) || + (hasAfter && after.evaluationCount > 1); + return ( + <> + + {showEvaluationCount && ( + + )} + + + ); +} + +interface Row { + name: string; + before: Optional; + after: Optional; + diff: number; +} + +/** + * A set of predicates that have been grouped together because their names have the same fingerprint. + */ +interface RowGroup { + name: string; + rows: Row[]; + before: Optional; + after: Optional; + diff: number; +} + +function getSortOrder(sortOrder: "delta" | "absDelta") { + if (sortOrder === "absDelta") { + return orderBy((row: { diff: number }) => -Math.abs(row.diff)); + } + return orderBy((row: { diff: number }) => row.diff); +} + +interface Metric { + title: string; + get(info: PredicateInfo): number; + unit?: string; +} + +const metrics: Record = { + tuples: { + title: "Tuple count", + get: (info) => info.tuples, + }, + time: { + title: "Time spent", + get: (info) => info.timeCost, + unit: "ms", + }, + evaluations: { + title: "Evaluations", + get: (info) => info.evaluationCount, + }, + iterationsTotal: { + title: "Iterations", + get: (info) => info.iterationCount, + }, +}; + +function metricGetOptional( + metric: Metric, + info: Optional, + isPerEvaluation: boolean, +): Optional { + if (!isPresent(info)) { + return info; + } + const value = metric.get(info); + return isPerEvaluation ? (value / info.evaluationCount) | 0 : value; +} + +function addOptionals(a: Optional, b: Optional) { + if (isPresent(a) && isPresent(b)) { + return a + b; + } + if (isPresent(a)) { + return a; + } + if (isPresent(b)) { + return b; + } + if (a === b) { + return a; // If absent for the same reason, preserve that reason + } + return 0; // Otherwise collapse to zero +} + +/** + * Returns a "fingerprint" from the given name, which is used to group together similar names. + */ +function getNameFingerprint(name: string, renamings: Renaming[]) { + for (const { patternRegexp, replacement } of renamings) { + if (patternRegexp != null) { + name = name.replace(patternRegexp, replacement); + } + } + return name; +} + +function Chevron({ expanded }: { expanded: boolean }) { + return ; +} + +function union(a: Set | T[], b: Set | T[]) { + const result = new Set(a); + for (const x of b) { + result.add(x); + } + return result; +} + +export function ComparePerformance(_: Record) { + const [data, setData] = useState< + SetPerformanceComparisonQueries | undefined + >(); + + useMessageFromExtension( + (msg) => { + setData(msg); + }, + [setData], + ); + + if (!data) { + return
Loading performance comparison...
; + } + + return ; +} + +function ComparePerformanceWithData(props: { + data: SetPerformanceComparisonQueries; +}) { + const { data } = props; + + const { from, to } = useMemo( + () => ({ + from: new ComparisonDataset(data.from), + to: new ComparisonDataset(data.to), + }), + [data], + ); + + const comparison = data?.comparison; + + const [hideCacheHits, setHideCacheHits] = useState(false); + + const [sortOrder, setSortOrder] = useState<"delta" | "absDelta">("absDelta"); + + const [metric, setMetric] = useState(metrics.tuples); + + const [isPerEvaluation, setPerEvaluation] = useState(false); + + const nameSet = useMemo( + () => union(from.data.names, to.data.names), + [from, to], + ); + + const hasCacheHitMismatch = useRef(false); + + const rows: Row[] = useMemo(() => { + hasCacheHitMismatch.current = false; + return Array.from(nameSet) + .map((name) => { + const before = from.getTupleCountInfo(name); + const after = to.getTupleCountInfo(name); + const beforeValue = metricGetOptional(metric, before, isPerEvaluation); + const afterValue = metricGetOptional(metric, after, isPerEvaluation); + if (beforeValue === afterValue) { + return undefined!; + } + if ( + before === AbsentReason.CacheHit || + after === AbsentReason.CacheHit + ) { + hasCacheHitMismatch.current = true; + if (hideCacheHits) { + return undefined!; + } + } + const diff = + (isPresent(afterValue) ? afterValue : 0) - + (isPresent(beforeValue) ? beforeValue : 0); + return { name, before, after, diff } satisfies Row; + }) + .filter((x) => !!x) + .sort(getSortOrder(sortOrder)); + }, [nameSet, from, to, metric, hideCacheHits, sortOrder, isPerEvaluation]); + + const { totalBefore, totalAfter, totalDiff } = useMemo(() => { + let totalBefore = 0; + let totalAfter = 0; + let totalDiff = 0; + for (const row of rows) { + totalBefore += isPresent(row.before) ? metric.get(row.before) : 0; + totalAfter += isPresent(row.after) ? metric.get(row.after) : 0; + totalDiff += row.diff; + } + return { totalBefore, totalAfter, totalDiff }; + }, [rows, metric]); + + const [renamings, setRenamings] = useState(() => [ + new Renaming("#[0-9a-f]{8}(?![0-9a-f])", "#"), + ]); + + // Use deferred value to avoid expensive re-rendering for every keypress in the renaming editor + const deferredRenamings = useDeferredValue(renamings); + + const rowGroups = useMemo(() => { + const groupedRows = new Map(); + for (const row of rows) { + const fingerprint = getNameFingerprint(row.name, deferredRenamings); + const rows = groupedRows.get(fingerprint); + if (rows) { + rows.push(row); + } else { + groupedRows.set(fingerprint, [row]); + } + } + return Array.from(groupedRows.entries()) + .map(([fingerprint, rows]) => { + const before = rows + .map((row) => metricGetOptional(metric, row.before, isPerEvaluation)) + .reduce(addOptionals); + const after = rows + .map((row) => metricGetOptional(metric, row.after, isPerEvaluation)) + .reduce(addOptionals); + return { + name: rows.length === 1 ? rows[0].name : fingerprint, + before, + after, + diff: + (isPresent(after) ? after : 0) - (isPresent(before) ? before : 0), + rows, + } satisfies RowGroup; + }) + .sort(getSortOrder(sortOrder)); + }, [rows, metric, sortOrder, deferredRenamings, isPerEvaluation]); + + const rowGroupNames = useMemo( + () => abbreviateRANames(rowGroups.map((group) => group.name)), + [rowGroups], + ); + + return ( + <> + Performance comparison + {comparison && hasCacheHitMismatch.current && ( + + Inconsistent cache hits +
+ Some predicates had a cache hit on one side but not the other. For + more accurate results, try running the{" "} + CodeQL: Clear Cache command before each query. +
+
+ +
+ )} + + Compare{" "} + ) => + setMetric(metrics[e.target.value]) + } + > + {Object.entries(metrics).map(([key, value]) => ( + + ))} + {" "} + ) => + setPerEvaluation(e.target.value === "per-evaluation") + } + > + + + {" "} + {comparison && ( + <> + sorted by{" "} + ) => + setSortOrder(e.target.value as "delta" | "absDelta") + } + value={sortOrder} + > + + + + + )} + + + + + + + + {comparison && renderOptionalValue(totalBefore, metric.unit)} + {renderOptionalValue(totalAfter, metric.unit)} + {comparison && renderDelta(totalDiff, metric.unit)} + + TOTAL + + + + + + +
+ + + ); +} + +interface PredicateTableProps { + rowGroups: RowGroup[]; + rowGroupNames: React.ReactNode[]; + comparison: boolean; + metric: Metric; + isPerEvaluation: boolean; +} + +function PredicateTableRaw(props: PredicateTableProps) { + const { comparison, metric, rowGroupNames, rowGroups, isPerEvaluation } = + props; + return rowGroups.map((rowGroup, rowGroupIndex) => ( + + )); +} + +const PredicateTable = memo(PredicateTableRaw); + +interface PredicateRowGroupProps { + renderedName: React.ReactNode; + rowGroup: RowGroup; + comparison: boolean; + metric: Metric; + isPerEvaluation: boolean; +} + +function PredicateRowGroup(props: PredicateRowGroupProps) { + const { renderedName, rowGroup, comparison, metric, isPerEvaluation } = props; + const [isExpanded, setExpanded] = useState(false); + const rowNames = useMemo( + () => abbreviateRANames(rowGroup.rows.map((row) => row.name)), + [rowGroup], + ); + if (rowGroup.rows.length === 1) { + return ; + } + return ( + + + setExpanded(!isExpanded)} + > + + + + {comparison && renderOptionalValue(rowGroup.before)} + {renderOptionalValue(rowGroup.after)} + {comparison && renderDelta(rowGroup.diff, metric.unit)} + + {renderedName} ({rowGroup.rows.length} predicates) + + + {isExpanded && + rowGroup.rows.map((row, rowIndex) => ( + + + + ))} + +
+ +
+ ); +} + +interface PredicateRowProps { + renderedName: React.ReactNode; + row: Row; + comparison: boolean; + metric: Metric; + isPerEvaluation: boolean; +} + +function PredicateRow(props: PredicateRowProps) { + const [isExpanded, setExpanded] = useState(false); + const { renderedName, row, comparison, metric, isPerEvaluation } = props; + const evaluationFactorBefore = + isPerEvaluation && isPresent(row.before) ? row.before.evaluationCount : 1; + const evaluationFactorAfter = + isPerEvaluation && isPresent(row.after) ? row.after.evaluationCount : 1; + return ( + + + setExpanded(!isExpanded)} + > + + + + {comparison && + renderPredicateMetric(row.before, metric, isPerEvaluation)} + {renderPredicateMetric(row.after, metric, isPerEvaluation)} + {comparison && renderDelta(row.diff, metric.unit)} + {renderedName} + + {isExpanded && ( + <> + + {collatePipelines( + isPresent(row.before) ? row.before.pipelines : {}, + isPresent(row.after) ? row.after.pipelines : {}, + ).map(({ name, first, second }, pipelineIndex) => ( + + + Tuple counts for '{name}' pipeline + {comparison && + (first == null + ? " (after)" + : second == null + ? " (before)" + : "")} + + } + /> + {abbreviateRASteps(first?.steps ?? second!.steps).map( + (step, index) => ( + + ), + )} + + ))} + + )} + +
+ ); +} + +interface PipelinePair { + name: string; + first: PipelineSummary | undefined; + second: PipelineSummary | undefined; +} + +function collatePipelines( + before: Record, + after: Record, +): PipelinePair[] { + const result: PipelinePair[] = []; + + for (const [name, first] of Object.entries(before)) { + const second = after[name]; + if (second == null) { + result.push({ name, first, second: undefined }); + } else if (samePipeline(first.steps, second.steps)) { + result.push({ name, first, second }); + } else { + result.push({ name, first, second: undefined }); + result.push({ name, first: undefined, second }); + } + } + + for (const [name, second] of Object.entries(after)) { + if (before[name] == null) { + result.push({ name, first: undefined, second }); + } + } + + return result; +} + +function samePipeline(a: string[], b: string[]) { + return a.length === b.length && a.every((x, i) => x === b[i]); +} diff --git a/extensions/ql-vscode/src/view/compare-performance/RAPrettyPrinter.tsx b/extensions/ql-vscode/src/view/compare-performance/RAPrettyPrinter.tsx new file mode 100644 index 00000000000..f6dd7720e5a --- /dev/null +++ b/extensions/ql-vscode/src/view/compare-performance/RAPrettyPrinter.tsx @@ -0,0 +1,281 @@ +import { Fragment, useState } from "react"; +import { styled } from "styled-components"; + +/** + * A set of names, for generating unambiguous abbreviations. + */ +class NameSet { + private readonly abbreviations = new Map(); + + constructor(readonly names: string[]) { + const qnames = names.map(parseName); + const builder = new TrieBuilder(); + qnames + .map((qname) => builder.visitQName(qname)) + .forEach((r, index) => { + this.abbreviations.set(names[index], r.abbreviate(true)); + }); + } + + public getAbbreviation(name: string): React.ReactNode { + return this.abbreviations.get(name) ?? name; + } +} + +/** Name parsed into the form `prefix::name` */ +interface QualifiedName { + prefix?: QualifiedName; + name: string; + args?: QualifiedName[]; +} + +function qnameToString(name: QualifiedName): string { + const parts: string[] = []; + if (name.prefix != null) { + parts.push(qnameToString(name.prefix)); + parts.push("::"); + } + parts.push(name.name); + if (name.args != null && name.args.length > 0) { + parts.push("<"); + parts.push(name.args.map(qnameToString).join(",")); + parts.push(">"); + } + return parts.join(""); +} + +function tokeniseName(text: string) { + return Array.from(text.matchAll(/:+|<|>|,|"[^"]+"|`[^`]+`|[^:<>,"`]+/g)); +} + +function parseName(text: string): QualifiedName { + const tokens = tokeniseName(text); + + function next() { + return tokens.pop()![0]; + } + function peek() { + return tokens[tokens.length - 1][0]; + } + function skipToken(token: string) { + if (tokens.length > 0 && peek() === token) { + tokens.pop(); + return true; + } else { + return false; + } + } + + function parseQName(): QualifiedName { + let args: QualifiedName[] | undefined; + if (skipToken(">")) { + args = []; + while (tokens.length > 0 && peek() !== "<") { + args.push(parseQName()); + skipToken(","); + } + args.reverse(); + skipToken("<"); + } + const name = tokens.length === 0 ? "" : next(); + const prefix = skipToken("::") ? parseQName() : undefined; + return { + prefix, + name, + args, + }; + } + + const result = parseQName(); + if (tokens.length > 0) { + // It's a parse error if we did not consume all tokens. + // Just treat the whole text as the 'name'. + return { prefix: undefined, name: text, args: undefined }; + } + return result; +} + +class TrieNode { + children = new Map(); + constructor(readonly index: number) {} +} + +interface VisitResult { + node: TrieNode; + abbreviate: (isRoot?: boolean) => React.ReactNode; +} + +class TrieBuilder { + root = new TrieNode(0); + nextId = 1; + + getOrCreate(trieNode: TrieNode, child: string) { + const { children } = trieNode; + let node = children.get(child); + if (node == null) { + node = new TrieNode(this.nextId++); + children.set(child, node); + } + return node; + } + + visitQName(qname: QualifiedName): VisitResult { + const prefix = + qname.prefix != null ? this.visitQName(qname.prefix) : undefined; + const trieNodeBeforeArgs = this.getOrCreate( + prefix?.node ?? this.root, + qname.name, + ); + let trieNode = trieNodeBeforeArgs; + const args = qname.args?.map((arg) => this.visitQName(arg)); + if (args != null) { + const argKey = args.map((arg) => arg.node.index).join(","); + trieNode = this.getOrCreate(trieNodeBeforeArgs, argKey); + } + return { + node: trieNode, + abbreviate: (isRoot = false) => { + const result: React.ReactNode[] = []; + if (prefix != null) { + result.push(prefix.abbreviate()); + result.push("::"); + } + const { name } = qname; + const hash = name.indexOf("#"); + if (hash !== -1 && isRoot) { + const shortName = name.substring(0, hash); + result.push({shortName}); + result.push(name.substring(hash)); + } else { + result.push(isRoot ? {name} : name); + } + if (args != null) { + result.push("<"); + if (trieNodeBeforeArgs.children.size === 1) { + const argsText = qname + .args!.map((arg) => qnameToString(arg)) + .join(","); + result.push({argsText}); + } else { + let first = true; + for (const arg of args) { + result.push(arg.abbreviate()); + if (first) { + first = false; + } else { + result.push(","); + } + } + } + result.push(">"); + } + return result; + }, + }; + } +} + +const ExpandableTextButton = styled.button` + background: none; + border: none; + cursor: pointer; + padding: 0; + color: inherit; + &:hover { + background-color: rgba(128, 128, 128, 0.2); + } +`; + +interface ExpandableNamePartProps { + children: React.ReactNode; +} + +function ExpandableNamePart(props: ExpandableNamePartProps) { + const [isExpanded, setExpanded] = useState(false); + return ( + { + setExpanded(!isExpanded); + event.stopPropagation(); + }} + > + {isExpanded ? props.children : "..."} + + ); +} + +/** + * Span enclosing an entire qualified name. + * + * Can be used to gray out uninteresting parts of the name, though this looks worse than expected. + */ +const QNameSpan = styled.span` + /* color: var(--vscode-disabledForeground); */ +`; + +/** Span enclosing the innermost identifier, e.g. the `foo` in `A::B::foo#abc` */ +const IdentifierSpan = styled.span` + font-weight: 600; +`; + +/** Span enclosing keywords such as `JOIN` and `WITH`. */ +const KeywordSpan = styled.span` + font-weight: 500; +`; + +const nameTokenRegex = /\b[^ (]+\b/g; + +function traverseMatches( + text: string, + regex: RegExp, + callbacks: { + onMatch: (match: RegExpMatchArray) => void; + onText: (text: string) => void; + }, +) { + const matches = Array.from(text.matchAll(regex)); + let lastIndex = 0; + for (const match of matches) { + const before = text.substring(lastIndex, match.index); + if (before !== "") { + callbacks.onText(before); + } + callbacks.onMatch(match); + lastIndex = match.index + match[0].length; + } + const after = text.substring(lastIndex); + if (after !== "") { + callbacks.onText(after); + } +} + +export function abbreviateRASteps(steps: string[]): React.ReactNode[] { + const nameTokens = steps.flatMap((step) => + Array.from(step.matchAll(nameTokenRegex)).map((tok) => tok[0]), + ); + const nameSet = new NameSet(nameTokens.filter((name) => name.includes("::"))); + return steps.map((step, index) => { + const result: React.ReactNode[] = []; + traverseMatches(step, nameTokenRegex, { + onMatch(match) { + const text = match[0]; + if (text.includes("::")) { + result.push({nameSet.getAbbreviation(text)}); + } else if (/[A-Z]+/.test(text)) { + result.push({text}); + } else { + result.push(match[0]); + } + }, + onText(text) { + result.push(text); + }, + }); + return {result}; + }); +} + +export function abbreviateRANames(names: string[]): React.ReactNode[] { + const nameSet = new NameSet(names); + return names.map((name) => nameSet.getAbbreviation(name)); +} diff --git a/extensions/ql-vscode/src/view/compare-performance/RenamingInput.tsx b/extensions/ql-vscode/src/view/compare-performance/RenamingInput.tsx new file mode 100644 index 00000000000..6d86c7e8182 --- /dev/null +++ b/extensions/ql-vscode/src/view/compare-performance/RenamingInput.tsx @@ -0,0 +1,106 @@ +import type { ChangeEvent } from "react"; +import { styled } from "styled-components"; +import { + VSCodeButton, + VSCodeTextField, +} from "@vscode/webview-ui-toolkit/react"; +import { Codicon } from "../common"; + +export class Renaming { + patternRegexp: RegExp | undefined; + + constructor( + public pattern: string, + public replacement: string, + ) { + this.patternRegexp = tryCompilePattern(pattern); + } +} + +function tryCompilePattern(pattern: string): RegExp | undefined { + try { + return new RegExp(pattern, "i"); + } catch { + return undefined; + } +} + +const Input = styled(VSCodeTextField)` + width: 20em; +`; + +const Row = styled.div` + display: flex; + padding-bottom: 0.25em; +`; + +const Details = styled.details` + padding: 1em; +`; + +interface RenamingInputProps { + renamings: Renaming[]; + setRenamings: (renamings: Renaming[]) => void; +} + +export function RenamingInput(props: RenamingInputProps) { + const { renamings, setRenamings } = props; + return ( +
+ Predicate renaming +

+ The following regexp replacements are applied to every predicate name on + both sides. Predicates whose names clash after renaming are grouped + together. Can be used to correlate predicates that were renamed between + the two runs. +
+ Can also be used to group related predicates, for example, renaming{" "} + .*ssa.* to SSA will group all SSA-related + predicates together. +

+ {renamings.map((renaming, index) => ( + + ) => { + const newRenamings = [...renamings]; + newRenamings[index] = new Renaming( + e.target.value, + renaming.replacement, + ); + setRenamings(newRenamings); + }} + > + + + ) => { + const newRenamings = [...renamings]; + newRenamings[index] = new Renaming( + renaming.pattern, + e.target.value, + ); + setRenamings(newRenamings); + }} + > + + setRenamings(renamings.filter((_, i) => i !== index)) + } + > + + +
+
+ ))} + setRenamings([...renamings, new Renaming("", "")])} + > + Add renaming rule + +
+ ); +} diff --git a/extensions/ql-vscode/src/view/compare-performance/index.tsx b/extensions/ql-vscode/src/view/compare-performance/index.tsx new file mode 100644 index 00000000000..ad0bb8f889a --- /dev/null +++ b/extensions/ql-vscode/src/view/compare-performance/index.tsx @@ -0,0 +1,8 @@ +import type { WebviewDefinition } from "../webview-definition"; +import { ComparePerformance } from "./ComparePerformance"; + +const definition: WebviewDefinition = { + component: , +}; + +export default definition; diff --git a/extensions/ql-vscode/src/view/webview.tsx b/extensions/ql-vscode/src/view/webview.tsx index d3adadf74a1..5081dbaffe3 100644 --- a/extensions/ql-vscode/src/view/webview.tsx +++ b/extensions/ql-vscode/src/view/webview.tsx @@ -6,6 +6,7 @@ import { registerUnhandledErrorListener } from "./common/errors"; import type { WebviewDefinition } from "./webview-definition"; import compareView from "./compare"; +import comparePerformance from "./compare-performance"; import dataFlowPathsView from "./data-flow-paths"; import methodModelingView from "./method-modeling"; import modelEditorView from "./model-editor"; @@ -18,6 +19,7 @@ import "@vscode/codicons/dist/codicon.css"; const views: Record = { compare: compareView, + "compare-performance": comparePerformance, "data-flow-paths": dataFlowPathsView, "method-modeling": methodModelingView, "model-editor": modelEditorView, diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/history-tree-data-provider.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/history-tree-data-provider.test.ts index 7026e6c6684..a0305761e91 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/history-tree-data-provider.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/history-tree-data-provider.test.ts @@ -38,6 +38,7 @@ describe("HistoryTreeDataProvider", () => { let app: App; let configListener: QueryHistoryConfigListener; const doCompareCallback = jest.fn(); + const doComparePerformanceCallback = jest.fn(); let queryHistoryManager: QueryHistoryManager; @@ -506,6 +507,7 @@ describe("HistoryTreeDataProvider", () => { }), languageContext, doCompareCallback, + doComparePerformanceCallback, ); (qhm.treeDataProvider as any).history = [...allHistory]; await workspace.saveAll(); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts index b5176590822..38eb471c918 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/query-history-manager.test.ts @@ -40,6 +40,7 @@ describe("QueryHistoryManager", () => { typeof variantAnalysisManagerStub.cancelVariantAnalysis >; const doCompareCallback = jest.fn(); + const doComparePerformanceCallback = jest.fn(); let executeCommand: jest.MockedFn< (commandName: string, ...args: any[]) => Promise @@ -939,6 +940,7 @@ describe("QueryHistoryManager", () => { }), new LanguageContextStore(mockApp), doCompareCallback, + doComparePerformanceCallback, ); (qhm.treeDataProvider as any).history = [...allHistory]; await workspace.saveAll(); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts index 1719ee00971..5442d5e2c38 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/query-history/variant-analysis-history.test.ts @@ -105,6 +105,7 @@ describe("Variant Analyses and QueryHistoryManager", () => { }), new LanguageContextStore(app), asyncNoop, + asyncNoop, ); disposables.push(qhm);