Add Compare Performance command (WIP)

This commit is contained in:
Asger F
2024-06-26 15:18:58 +02:00
parent 60c15a0eb2
commit 3b0697771d
20 changed files with 1038 additions and 0 deletions

View File

@@ -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"

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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();

View File

@@ -7,6 +7,7 @@ import type { App } from "../app";
export type WebviewKind =
| "results"
| "compare"
| "compare-performance"
| "variant-analysis"
| "data-flow-paths"
| "model-editor"

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 };

View 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 {}
}

View File

@@ -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);

View 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>
);
}

View File

@@ -6,3 +6,4 @@ export * from "./HorizontalSpace";
export * from "./SectionTitle";
export * from "./VerticalSpace";
export * from "./ViewTitle";
export * from "./WarningBox";

View File

@@ -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 &apos;{name}&apos; 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]);
}

View File

@@ -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)),
);
}

View File

@@ -0,0 +1,8 @@
import type { WebviewDefinition } from "../webview-definition";
import { ComparePerformance } from "./ComparePerformance";
const definition: WebviewDefinition = {
component: <ComparePerformance />,
};
export default definition;

View File

@@ -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,

View File

@@ -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();

View File

@@ -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();

View File

@@ -105,6 +105,7 @@ describe("Variant Analyses and QueryHistoryManager", () => {
}),
new LanguageContextStore(app),
asyncNoop,
asyncNoop,
);
disposables.push(qhm);