Add Compare Performance command (WIP)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -180,6 +180,7 @@ export type QueryHistoryCommands = {
|
||||
"codeQLQueryHistory.removeHistoryItemContextInline": TreeViewContextMultiSelectionCommandFunction<QueryHistoryInfo>;
|
||||
"codeQLQueryHistory.renameItem": TreeViewContextMultiSelectionCommandFunction<QueryHistoryInfo>;
|
||||
"codeQLQueryHistory.compareWith": TreeViewContextMultiSelectionCommandFunction<QueryHistoryInfo>;
|
||||
"codeQLQueryHistory.comparePerformanceWith": TreeViewContextMultiSelectionCommandFunction<QueryHistoryInfo>;
|
||||
"codeQLQueryHistory.showEvalLog": TreeViewContextMultiSelectionCommandFunction<QueryHistoryInfo>;
|
||||
"codeQLQueryHistory.showEvalLogSummary": TreeViewContextMultiSelectionCommandFunction<QueryHistoryInfo>;
|
||||
"codeQLQueryHistory.showEvalLogViewer": TreeViewContextMultiSelectionCommandFunction<QueryHistoryInfo>;
|
||||
|
||||
@@ -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,16 @@ export interface SetComparisonsMessage {
|
||||
readonly message: string | undefined;
|
||||
}
|
||||
|
||||
export type ToComparePerformanceViewMessage = SetPerformanceComparisonQueries;
|
||||
|
||||
export interface SetPerformanceComparisonQueries {
|
||||
readonly t: "setPerformanceComparison";
|
||||
readonly from: PerformanceComparisonDataFromLog;
|
||||
readonly to: PerformanceComparisonDataFromLog;
|
||||
}
|
||||
|
||||
export type FromComparePerformanceViewMessage = CommonFromViewMessages;
|
||||
|
||||
export type QueryCompareResult =
|
||||
| RawQueryCompareResult
|
||||
| InterpretedQueryCompareResult;
|
||||
|
||||
@@ -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<void> {
|
||||
this.panel = panel;
|
||||
const config = await this.getPanelConfig();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { App } from "../app";
|
||||
export type WebviewKind =
|
||||
| "results"
|
||||
| "compare"
|
||||
| "compare-performance"
|
||||
| "variant-analysis"
|
||||
| "data-flow-paths"
|
||||
| "model-editor"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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 { 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();
|
||||
|
||||
// TODO: try processing in (async) parallel once readJsonl is streaming
|
||||
const fromPerf = await scanLog(
|
||||
fromJsonLog,
|
||||
new PerformanceOverviewScanner(),
|
||||
);
|
||||
const toPerf = await scanLog(toJsonLog, new PerformanceOverviewScanner());
|
||||
|
||||
await this.postMessage({
|
||||
t: "setPerformanceComparison",
|
||||
from: fromPerf.getData(),
|
||||
to: toPerf.getData(),
|
||||
});
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> => showResultsForComparison(compareView, from, to),
|
||||
async (
|
||||
from: CompletedLocalQueryInfo,
|
||||
to: CompletedLocalQueryInfo,
|
||||
): Promise<void> =>
|
||||
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,25 @@ async function showResultsForComparison(
|
||||
}
|
||||
}
|
||||
|
||||
async function showPerformanceComparison(
|
||||
view: ComparePerformanceView,
|
||||
from: CompletedLocalQueryInfo,
|
||||
to: CompletedLocalQueryInfo,
|
||||
): Promise<void> {
|
||||
const fromLog = from.evaluatorLogPaths?.jsonSummary;
|
||||
const toLog = to.evaluatorLogPaths?.jsonSummary;
|
||||
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()}`,
|
||||
);
|
||||
|
||||
await view.showResults(fromLog, toLog);
|
||||
}
|
||||
|
||||
function addUnhandledRejectionListener() {
|
||||
const handler = (error: unknown) => {
|
||||
// This listener will be triggered for errors from other extensions as
|
||||
|
||||
@@ -112,3 +112,20 @@ export class EvaluationLogScannerSet {
|
||||
scanners.forEach((scanner) => scanner.onDone());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the evaluator summary log using the given scanner. For conveience, 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<T extends EvaluationLogScanner>(
|
||||
jsonSummaryLocation: string,
|
||||
scanner: T,
|
||||
): Promise<T> {
|
||||
await readJsonlFile<SummaryEvent>(jsonSummaryLocation, async (obj) => {
|
||||
scanner.onEvent(obj);
|
||||
});
|
||||
scanner.onDone();
|
||||
return scanner;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
177
extensions/ql-vscode/src/log-insights/performance-comparison.ts
Normal file
177
extensions/ql-vscode/src/log-insights/performance-comparison.ts
Normal file
@@ -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<Record<string, PipelineSummary>>;
|
||||
}
|
||||
|
||||
export class PerformanceOverviewScanner implements EvaluationLogScanner {
|
||||
private readonly nameToIndex = new Map<string, number>();
|
||||
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 {}
|
||||
}
|
||||
@@ -149,6 +149,10 @@ export class QueryHistoryManager extends DisposableObject {
|
||||
from: CompletedLocalQueryInfo,
|
||||
to: CompletedLocalQueryInfo,
|
||||
) => Promise<void>,
|
||||
private readonly doComparePerformanceCallback: (
|
||||
from: CompletedLocalQueryInfo,
|
||||
to: CompletedLocalQueryInfo,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
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,40 @@ 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.findOtherQueryToCompare(fromItem, multiSelect);
|
||||
} catch (e) {
|
||||
void showAndLogErrorMessage(
|
||||
this.app.logger,
|
||||
`Failed to compare queries: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (toItem !== undefined) {
|
||||
await this.doComparePerformanceCallback(fromItem, toItem);
|
||||
}
|
||||
}
|
||||
|
||||
async handleItemClicked(item: QueryHistoryInfo) {
|
||||
this.treeDataProvider.setCurrentItem(item);
|
||||
|
||||
|
||||
31
extensions/ql-vscode/src/view/common/WarningBox.tsx
Normal file
31
extensions/ql-vscode/src/view/common/WarningBox.tsx
Normal file
@@ -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 (
|
||||
<WarningBoxDiv>
|
||||
<IconPane>
|
||||
<WarningIcon />
|
||||
</IconPane>
|
||||
<p>{props.children}</p>
|
||||
</WarningBoxDiv>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from "./HorizontalSpace";
|
||||
export * from "./SectionTitle";
|
||||
export * from "./VerticalSpace";
|
||||
export * from "./ViewTitle";
|
||||
export * from "./WarningBox";
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useMemo, useState, Fragment } 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 { abbreviateRASteps } from "./RAPrettyPrinter";
|
||||
|
||||
const enum AbsentReason {
|
||||
NotSeen = "NotSeen",
|
||||
CacheHit = "CacheHit",
|
||||
Sentinel = "Sentinel",
|
||||
}
|
||||
|
||||
interface OptionalValue {
|
||||
absentReason: AbsentReason | undefined;
|
||||
tuples: number;
|
||||
}
|
||||
|
||||
interface PredicateInfo extends OptionalValue {
|
||||
pipelines: Record<string, PipelineSummary>;
|
||||
}
|
||||
|
||||
class ComparisonDataset {
|
||||
public nameToIndex = new Map<string, number>();
|
||||
public cacheHitIndices: Set<number>;
|
||||
public sentinelEmptyIndices: Set<number>;
|
||||
|
||||
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): PredicateInfo {
|
||||
const { data, nameToIndex, cacheHitIndices, sentinelEmptyIndices } = this;
|
||||
const index = nameToIndex.get(name);
|
||||
if (index == null) {
|
||||
return {
|
||||
tuples: 0,
|
||||
absentReason: AbsentReason.NotSeen,
|
||||
pipelines: {},
|
||||
};
|
||||
}
|
||||
const tupleCost = data.tupleCosts[index];
|
||||
let absentReason: AbsentReason | undefined;
|
||||
if (tupleCost === 0) {
|
||||
if (sentinelEmptyIndices.has(index)) {
|
||||
absentReason = AbsentReason.Sentinel;
|
||||
} else if (cacheHitIndices.has(index)) {
|
||||
absentReason = AbsentReason.CacheHit;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tuples: tupleCost,
|
||||
absentReason,
|
||||
pipelines: data.pipelineSummaryList[index],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function renderAbsoluteValue(x: OptionalValue) {
|
||||
switch (x.absentReason) {
|
||||
case AbsentReason.NotSeen:
|
||||
return <AbsentNumberCell>n/a</AbsentNumberCell>;
|
||||
case AbsentReason.CacheHit:
|
||||
return <AbsentNumberCell>cache hit</AbsentNumberCell>;
|
||||
case AbsentReason.Sentinel:
|
||||
return <AbsentNumberCell>sentinel empty</AbsentNumberCell>;
|
||||
default:
|
||||
return <NumberCell>{formatDecimal(x.tuples)}</NumberCell>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDelta(x: number) {
|
||||
const sign = x > 0 ? "+" : "";
|
||||
return (
|
||||
<NumberCell>
|
||||
{sign}
|
||||
{formatDecimal(x)}
|
||||
</NumberCell>
|
||||
);
|
||||
}
|
||||
|
||||
function orderBy<T>(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;
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
interface PipelineStepProps {
|
||||
before: number | undefined;
|
||||
after: number | undefined;
|
||||
step: string;
|
||||
}
|
||||
|
||||
function PipelineStep(props: PipelineStepProps) {
|
||||
let { before, after, 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 (
|
||||
<PipelineStepTR>
|
||||
<ChevronCell />
|
||||
<NumberCell>{before != null ? formatDecimal(before) : ""}</NumberCell>
|
||||
<NumberCell>{after != null ? formatDecimal(after) : ""}</NumberCell>
|
||||
{delta != null ? renderDelta(delta) : <td></td>}
|
||||
<NameCell>{step}</NameCell>
|
||||
</PipelineStepTR>
|
||||
);
|
||||
}
|
||||
|
||||
function Chevron({ expanded }: { expanded: boolean }) {
|
||||
return <Codicon name={expanded ? "chevron-down" : "chevron-right"} />;
|
||||
}
|
||||
|
||||
function withToggledValue<T>(set: Set<T>, value: T) {
|
||||
const result = new Set(set);
|
||||
if (result.has(value)) {
|
||||
result.delete(value);
|
||||
} else {
|
||||
result.add(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ComparePerformance(_: Record<string, never>) {
|
||||
const [data, setData] = useState<
|
||||
SetPerformanceComparisonQueries | undefined
|
||||
>();
|
||||
|
||||
useMessageFromExtension<ToComparePerformanceViewMessage>(
|
||||
(msg) => {
|
||||
setData(msg);
|
||||
},
|
||||
[setData],
|
||||
);
|
||||
|
||||
const datasets = useMemo(
|
||||
() =>
|
||||
data == null
|
||||
? undefined
|
||||
: {
|
||||
from: new ComparisonDataset(data.from),
|
||||
to: new ComparisonDataset(data.to),
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const [expandedPredicates, setExpandedPredicates] = useState<Set<string>>(
|
||||
() => new Set<string>(),
|
||||
);
|
||||
|
||||
const [hideCacheHits, setHideCacheHits] = useState(false);
|
||||
|
||||
if (!datasets) {
|
||||
return <div>Loading performance comparison...</div>;
|
||||
}
|
||||
|
||||
const { from, to } = datasets;
|
||||
|
||||
const nameSet = new Set(from.data.names);
|
||||
for (const name of to.data.names) {
|
||||
nameSet.add(name);
|
||||
}
|
||||
|
||||
let hasCacheHitMismatch = false;
|
||||
|
||||
const rows = Array.from(nameSet)
|
||||
.map((name) => {
|
||||
const before = from.getTupleCountInfo(name);
|
||||
const after = to.getTupleCountInfo(name);
|
||||
if (before.tuples === after.tuples) {
|
||||
return undefined!;
|
||||
}
|
||||
if (
|
||||
before.absentReason === AbsentReason.CacheHit ||
|
||||
after.absentReason === AbsentReason.CacheHit
|
||||
) {
|
||||
hasCacheHitMismatch = true;
|
||||
if (hideCacheHits) {
|
||||
return undefined!;
|
||||
}
|
||||
}
|
||||
const diff = after.tuples - before.tuples;
|
||||
return { name, before, after, diff };
|
||||
})
|
||||
.filter((x) => !!x)
|
||||
.sort(orderBy((row) => row.diff));
|
||||
|
||||
let totalBefore = 0;
|
||||
let totalAfter = 0;
|
||||
let totalDiff = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
totalBefore += row.before.tuples;
|
||||
totalAfter += row.after.tuples;
|
||||
totalDiff += row.diff;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewTitle>Performance comparison</ViewTitle>
|
||||
{hasCacheHitMismatch && (
|
||||
<WarningBox>
|
||||
<strong>Inconsistent cache hits</strong>
|
||||
<br />
|
||||
Some predicates had a cache hit on one side but not the other. For
|
||||
more accurate results, try running the{" "}
|
||||
<strong>CodeQL: Clear Cache</strong> command before each query.
|
||||
<br />
|
||||
<br />
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideCacheHits}
|
||||
onChange={() => setHideCacheHits(!hideCacheHits)}
|
||||
/>
|
||||
Hide predicates with cache hits
|
||||
</label>
|
||||
</WarningBox>
|
||||
)}
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<ChevronCell />
|
||||
<NumberHeader>Before</NumberHeader>
|
||||
<NumberHeader>After</NumberHeader>
|
||||
<NumberHeader>Delta</NumberHeader>
|
||||
<NameHeader>Predicate</NameHeader>
|
||||
</tr>
|
||||
</thead>
|
||||
</Table>
|
||||
{rows.map((row) => (
|
||||
<Table
|
||||
key={row.name}
|
||||
className={expandedPredicates.has(row.name) ? "expanded" : ""}
|
||||
>
|
||||
<tbody>
|
||||
<PredicateTR
|
||||
className={expandedPredicates.has(row.name) ? "expanded" : ""}
|
||||
key={"main"}
|
||||
onClick={() =>
|
||||
setExpandedPredicates(
|
||||
withToggledValue(expandedPredicates, row.name),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronCell>
|
||||
<Chevron expanded={expandedPredicates.has(row.name)} />
|
||||
</ChevronCell>
|
||||
{renderAbsoluteValue(row.before)}
|
||||
{renderAbsoluteValue(row.after)}
|
||||
{renderDelta(row.diff)}
|
||||
<NameCell>{row.name}</NameCell>
|
||||
</PredicateTR>
|
||||
{expandedPredicates.has(row.name) && (
|
||||
<>
|
||||
{collatePipelines(
|
||||
row.before.pipelines,
|
||||
row.after.pipelines,
|
||||
).map(({ name, first, second }, pipelineIndex) => (
|
||||
<Fragment key={pipelineIndex}>
|
||||
<tr>
|
||||
<td></td>
|
||||
<NumberHeader>{first != null && "Before"}</NumberHeader>
|
||||
<NumberHeader>{second != null && "After"}</NumberHeader>
|
||||
<NumberHeader>
|
||||
{first != null && second != null && "Delta"}
|
||||
</NumberHeader>
|
||||
<NameHeader>
|
||||
Tuple counts for '{name}' pipeline
|
||||
{first == null
|
||||
? " (after)"
|
||||
: second == null
|
||||
? " (before)"
|
||||
: ""}
|
||||
</NameHeader>
|
||||
</tr>
|
||||
{abbreviateRASteps(first?.steps ?? second!.steps).map(
|
||||
(step, index) => (
|
||||
<PipelineStep
|
||||
key={index}
|
||||
before={first?.counts[index]}
|
||||
after={second?.counts[index]}
|
||||
step={step}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
))}
|
||||
<Table>
|
||||
<tfoot>
|
||||
<tr key="spacing">
|
||||
<td colSpan={5} style={{ height: "1em" }}></td>
|
||||
</tr>
|
||||
<tr key="total">
|
||||
<ChevronCell />
|
||||
<NumberCell>{formatDecimal(totalBefore)}</NumberCell>
|
||||
<NumberCell>{formatDecimal(totalAfter)}</NumberCell>
|
||||
{renderDelta(totalDiff)}
|
||||
<NameCell>TOTAL</NameCell>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PipelinePair {
|
||||
name: string;
|
||||
first: PipelineSummary | undefined;
|
||||
second: PipelineSummary | undefined;
|
||||
}
|
||||
|
||||
function collatePipelines(
|
||||
before: Record<string, PipelineSummary>,
|
||||
after: Record<string, PipelineSummary>,
|
||||
): 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]);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* A set of names, for generating unambiguous abbreviations.
|
||||
*/
|
||||
class NameSet {
|
||||
private readonly abbreviations = new Map<string, string>();
|
||||
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
public getAbbreviation(name: string) {
|
||||
return this.abbreviations.get(name) ?? name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Name parsed into the form `prefix::name<args>` */
|
||||
interface QualifiedName {
|
||||
prefix?: QualifiedName;
|
||||
name: string;
|
||||
args?: QualifiedName[];
|
||||
}
|
||||
|
||||
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 (peek() !== "<") {
|
||||
args.push(parseQName());
|
||||
skipToken(",");
|
||||
}
|
||||
args.reverse();
|
||||
skipToken("<");
|
||||
}
|
||||
const name = 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<string, TrieNode>();
|
||||
constructor(readonly index: number) {}
|
||||
}
|
||||
|
||||
interface VisitResult {
|
||||
node: TrieNode;
|
||||
abbreviate: () => string;
|
||||
}
|
||||
|
||||
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: () => {
|
||||
let result = "";
|
||||
if (prefix != null) {
|
||||
result += prefix.abbreviate();
|
||||
result += "::";
|
||||
}
|
||||
result += qname.name;
|
||||
if (args != null) {
|
||||
result += "<";
|
||||
if (trieNodeBeforeArgs.children.size === 1) {
|
||||
result += "...";
|
||||
} else {
|
||||
result += args.map((arg) => arg.abbreviate()).join(",");
|
||||
}
|
||||
result += ">";
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nameTokenRegex = /\b[^ ]+::[^ (]+\b/g;
|
||||
|
||||
export function abbreviateRASteps(steps: string[]): string[] {
|
||||
const nameTokens = steps.flatMap((step) =>
|
||||
Array.from(step.matchAll(nameTokenRegex)).map((tok) => tok[0]),
|
||||
);
|
||||
const nameSet = new NameSet(nameTokens);
|
||||
return steps.map((step) =>
|
||||
step.replace(nameTokenRegex, (match) => nameSet.getAbbreviation(match)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { WebviewDefinition } from "../webview-definition";
|
||||
import { ComparePerformance } from "./ComparePerformance";
|
||||
|
||||
const definition: WebviewDefinition = {
|
||||
component: <ComparePerformance />,
|
||||
};
|
||||
|
||||
export default definition;
|
||||
@@ -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<string, WebviewDefinition> = {
|
||||
compare: compareView,
|
||||
"compare-performance": comparePerformance,
|
||||
"data-flow-paths": dataFlowPathsView,
|
||||
"method-modeling": methodModelingView,
|
||||
"model-editor": modelEditorView,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<any>
|
||||
@@ -939,6 +940,7 @@ describe("QueryHistoryManager", () => {
|
||||
}),
|
||||
new LanguageContextStore(mockApp),
|
||||
doCompareCallback,
|
||||
doComparePerformanceCallback,
|
||||
);
|
||||
(qhm.treeDataProvider as any).history = [...allHistory];
|
||||
await workspace.saveAll();
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("Variant Analyses and QueryHistoryManager", () => {
|
||||
}),
|
||||
new LanguageContextStore(app),
|
||||
asyncNoop,
|
||||
asyncNoop,
|
||||
);
|
||||
disposables.push(qhm);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user