Flamegraph: Update threshold for collapsing and fix flickering (#78206)

This commit is contained in:
Andrej Ocenas 2023-11-15 17:59:26 +01:00 committed by GitHub
parent 7cec741bae
commit 9777da5502
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 1056 additions and 1018 deletions

View File

@ -17,7 +17,7 @@
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF // TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE. // THIS SOFTWARE.
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Icon } from '@grafana/ui'; import { Icon } from '@grafana/ui';
@ -26,7 +26,7 @@ import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../typ
import FlameGraphCanvas from './FlameGraphCanvas'; import FlameGraphCanvas from './FlameGraphCanvas';
import FlameGraphMetadata from './FlameGraphMetadata'; import FlameGraphMetadata from './FlameGraphMetadata';
import { CollapsedMap, FlameGraphDataContainer } from './dataTransform'; import { CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
type Props = { type Props = {
data: FlameGraphDataContainer; data: FlameGraphDataContainer;
@ -66,30 +66,42 @@ const FlameGraph = ({
}: Props) => { }: Props) => {
const styles = getStyles(); const styles = getStyles();
const [collapsedMap, setCollapsedMap] = useState<CollapsedMap>(data ? data.getCollapsedMap() : new Map()); const [collapsedMap, setCollapsedMap] = useState<CollapsedMap>(new Map());
const [levels, setLevels] = useState<LevelItem[][]>();
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
const [totalProfileTicks, setTotalProfileTicks] = useState<number>(0);
const [totalProfileTicksRight, setTotalProfileTicksRight] = useState<number>();
const [totalViewTicks, setTotalViewTicks] = useState<number>(0);
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setCollapsedMap(data.getCollapsedMap()); setCollapsedMap(data.getCollapsedMap());
}
}, [data]);
const [levels, levelsCallers, totalProfileTicks, totalProfileTicksRight, totalViewTicks] = useMemo(() => { let levels = data.getLevels();
let levels = data.getLevels(); let totalProfileTicks = levels.length ? levels[0][0].value : 0;
let totalProfileTicks = levels.length ? levels[0][0].value : 0; let totalProfileTicksRight = levels.length ? levels[0][0].valueRight : undefined;
let totalProfileTicksRight = levels.length ? levels[0][0].valueRight : undefined; let totalViewTicks = totalProfileTicks;
let totalViewTicks = totalProfileTicks; let levelsCallers = undefined;
let levelsCallers = undefined;
if (sandwichItem) { if (sandwichItem) {
const [callers, callees] = data.getSandwichLevels(sandwichItem); const [callers, callees] = data.getSandwichLevels(sandwichItem);
levels = callees; levels = callees;
levelsCallers = callers; levelsCallers = callers;
// We need this separate as in case of diff profile we want to compute diff colors based on the original ticks. // We need this separate as in case of diff profile we want to compute diff colors based on the original ticks.
totalViewTicks = callees[0]?.[0]?.value ?? 0; totalViewTicks = callees[0]?.[0]?.value ?? 0;
}
setLevels(levels);
setLevelsCallers(levelsCallers);
setTotalProfileTicks(totalProfileTicks);
setTotalProfileTicksRight(totalProfileTicksRight);
setTotalViewTicks(totalViewTicks);
} }
return [levels, levelsCallers, totalProfileTicks, totalProfileTicksRight, totalViewTicks];
}, [data, sandwichItem]); }, [data, sandwichItem]);
if (!levels) {
return null;
}
const commonCanvasProps = { const commonCanvasProps = {
data, data,
rangeMin, rangeMin,

View File

@ -1,6 +1,6 @@
import { createDataFrame, FieldType } from '@grafana/data'; import { createDataFrame, FieldType } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './dataTransform'; import { CollapsedMapContainer, FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './dataTransform';
import { textToDataContainer } from './testHelpers'; import { textToDataContainer } from './testHelpers';
describe('nestedSetToLevels', () => { describe('nestedSetToLevels', () => {
@ -90,6 +90,20 @@ describe('FlameGraphDataContainer', () => {
}); });
}); });
it('creates correct collapse map 2', () => {
// Should not create any groups because even though the 1 is within threshold it has a sibling
const container = textToDataContainer(
`
[0////////////////////////////////]
[1/////////////////////////////][2]
`,
{ collapsing: true, collapsingThreshold: 0.5 }
)!;
const collapsedMap = container.getCollapsedMap();
expect(Array.from(collapsedMap.keys()).length).toEqual(0);
});
it('creates empty collapse map if no items are similar', () => { it('creates empty collapse map if no items are similar', () => {
const container = textToDataContainer(` const container = textToDataContainer(`
[0//////////////] [0//////////////]
@ -101,3 +115,81 @@ describe('FlameGraphDataContainer', () => {
expect(Array.from(collapsedMap.keys()).length).toEqual(0); expect(Array.from(collapsedMap.keys()).length).toEqual(0);
}); });
}); });
describe('CollapsedMapContainer', () => {
const defaultItem: LevelItem = {
itemIndexes: [0],
value: 100,
level: 0,
children: [],
start: 0,
};
it('groups items if they are within value threshold', () => {
const container = new CollapsedMapContainer();
const child2: LevelItem = {
...defaultItem,
itemIndexes: [2],
value: 99.1,
};
const child1: LevelItem = {
...defaultItem,
itemIndexes: [1],
children: [child2],
};
const parent: LevelItem = {
...defaultItem,
children: [child1],
};
container.addItem(child1, parent);
container.addItem(child2, child1);
expect(container.getMap().get(child1)).toMatchObject({ collapsed: true, items: [parent, child1, child2] });
expect(container.getMap().get(child2)).toMatchObject({ collapsed: true, items: [parent, child1, child2] });
expect(container.getMap().get(parent)).toMatchObject({ collapsed: true, items: [parent, child1, child2] });
});
it("doesn't group items if they are outside value threshold", () => {
const container = new CollapsedMapContainer();
const parent: LevelItem = {
...defaultItem,
};
const child: LevelItem = {
...defaultItem,
itemIndexes: [1],
value: 98,
};
container.addItem(child, parent);
expect(container.getMap().size).toBe(0);
});
it("doesn't group items if parent has multiple children", () => {
const container = new CollapsedMapContainer();
const child1: LevelItem = {
...defaultItem,
itemIndexes: [1],
value: 99.1,
};
const child2: LevelItem = {
...defaultItem,
itemIndexes: [2],
value: 0.09,
};
const parent: LevelItem = {
...defaultItem,
children: [child1, child2],
};
container.addItem(child1, parent);
expect(container.getMap().size).toBe(0);
});
});

View File

@ -34,24 +34,19 @@ export type CollapseConfig = {
collapsed: boolean; collapsed: boolean;
}; };
export type CollapsedMap = Map<LevelItem, CollapseConfig>;
/** /**
* Convert data frame with nested set format into array of level. This is mainly done for compatibility with current * Convert data frame with nested set format into array of level. This is mainly done for compatibility with current
* rendering code. * rendering code.
*/ */
export function nestedSetToLevels( export function nestedSetToLevels(
container: FlameGraphDataContainer, container: FlameGraphDataContainer,
options?: { options?: Options
collapsing: boolean;
}
): [LevelItem[][], Record<string, LevelItem[]>, CollapsedMap] { ): [LevelItem[][], Record<string, LevelItem[]>, CollapsedMap] {
const levels: LevelItem[][] = []; const levels: LevelItem[][] = [];
let offset = 0; let offset = 0;
let parent: LevelItem | undefined = undefined; let parent: LevelItem | undefined = undefined;
const uniqueLabels: Record<string, LevelItem[]> = {}; const uniqueLabels: Record<string, LevelItem[]> = {};
const collapsedMap: CollapsedMap = new Map();
for (let i = 0; i < container.data.length; i++) { for (let i = 0; i < container.data.length; i++) {
const currentLevel = container.getLevel(i); const currentLevel = container.getLevel(i);
@ -82,22 +77,6 @@ export function nestedSetToLevels(
level: currentLevel, level: currentLevel,
}; };
if (options?.collapsing) {
// We collapse similar items here, where it seems like parent and child are the same thing and so the distinction
// isn't that important. We create a map of items that should be collapsed together.
if (parent && newItem.value === parent.value) {
if (collapsedMap.has(parent)) {
const config = collapsedMap.get(parent)!;
collapsedMap.set(newItem, config);
config.items.push(newItem);
} else {
const config = { items: [parent, newItem], collapsed: true };
collapsedMap.set(parent, config);
collapsedMap.set(newItem, config);
}
}
}
if (uniqueLabels[container.getLabel(i)]) { if (uniqueLabels[container.getLabel(i)]) {
uniqueLabels[container.getLabel(i)].push(newItem); uniqueLabels[container.getLabel(i)].push(newItem);
} else { } else {
@ -107,12 +86,68 @@ export function nestedSetToLevels(
if (parent) { if (parent) {
parent.children.push(newItem); parent.children.push(newItem);
} }
parent = newItem;
parent = newItem;
levels[currentLevel].push(newItem); levels[currentLevel].push(newItem);
} }
return [levels, uniqueLabels, collapsedMap]; const collapsedMapContainer = new CollapsedMapContainer(options?.collapsingThreshold);
if (options?.collapsing) {
// We collapse similar items here, where it seems like parent and child are the same thing and so the distinction
// isn't that important. We create a map of items that should be collapsed together. We need to do it with complete
// tree as we need to know how many children an item has to know if we can collapse it.
collapsedMapContainer.addTree(levels[0][0]);
}
return [levels, uniqueLabels, collapsedMapContainer.getMap()];
}
export type CollapsedMap = Map<LevelItem, CollapseConfig>;
export class CollapsedMapContainer {
private map = new Map();
private threshold = 0.99;
constructor(threshold?: number) {
if (threshold !== undefined) {
this.threshold = threshold;
}
}
addTree(root: LevelItem) {
const stack = [root];
while (stack.length) {
const current = stack.shift()!;
if (current.parents?.length) {
this.addItem(current, current.parents[0]);
}
if (current.children.length) {
stack.unshift(...current.children);
}
}
}
addItem(item: LevelItem, parent?: LevelItem) {
// The heuristics here is pretty simple right now. Just check if it's single child and if we are within threshold.
// We assume items with small self just aren't too important while we cannot really collapse items with siblings
// as it's not clear what to do with said sibling.
if (parent && item.value > parent.value * this.threshold && parent.children.length === 1) {
if (this.map.has(parent)) {
const config = this.map.get(parent)!;
this.map.set(item, config);
config.items.push(item);
} else {
const config = { items: [parent, item], collapsed: true };
this.map.set(parent, config);
this.map.set(item, config);
}
}
}
getMap() {
return new Map(this.map);
}
} }
export function getMessageCheckFieldsResult(wrongFields: CheckFieldsResult) { export function getMessageCheckFieldsResult(wrongFields: CheckFieldsResult) {
@ -166,9 +201,14 @@ export function checkFields(data: DataFrame): CheckFieldsResult | undefined {
return undefined; return undefined;
} }
export type Options = {
collapsing: boolean;
collapsingThreshold?: number;
};
export class FlameGraphDataContainer { export class FlameGraphDataContainer {
data: DataFrame; data: DataFrame;
options: { collapsing: boolean }; options: Options;
labelField: Field; labelField: Field;
levelField: Field; levelField: Field;
@ -187,7 +227,7 @@ export class FlameGraphDataContainer {
private uniqueLabelsMap: Record<string, LevelItem[]> | undefined; private uniqueLabelsMap: Record<string, LevelItem[]> | undefined;
private collapsedMap: Map<LevelItem, CollapseConfig> | undefined; private collapsedMap: Map<LevelItem, CollapseConfig> | undefined;
constructor(data: DataFrame, options: { collapsing: boolean }, theme: GrafanaTheme2 = createTheme()) { constructor(data: DataFrame, options: Options, theme: GrafanaTheme2 = createTheme()) {
this.data = data; this.data = data;
this.options = options; this.options = options;

View File

@ -28,7 +28,7 @@ describe('walkTree', () => {
it('correctly compute sizes for a single item', () => { it('correctly compute sizes for a single item', () => {
const root: LevelItem = { start: 0, itemIndexes: [0], children: [], value: 100, level: 0 }; const root: LevelItem = { start: 0, itemIndexes: [0], children: [], value: 100, level: 0 };
const container = new FlameGraphDataContainer( const container = new FlameGraphDataContainer(
makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }), makeDataFrame({ value: [100], level: [0], label: ['1'], self: [0] }),
{ collapsing: true } { collapsing: true }
); );

View File

@ -1,6 +1,6 @@
import { arrayToDataFrame, FieldType } from '@grafana/data'; import { arrayToDataFrame, FieldType } from '@grafana/data';
import { FlameGraphDataContainer, LevelItem } from './dataTransform'; import { FlameGraphDataContainer, LevelItem, Options } from './dataTransform';
// Convert text to a FlameGraphDataContainer for testing. The format representing the flamegraph for example: // Convert text to a FlameGraphDataContainer for testing. The format representing the flamegraph for example:
// [0///////] // [0///////]
@ -9,7 +9,7 @@ import { FlameGraphDataContainer, LevelItem } from './dataTransform';
// [3] [6] // [3] [6]
// [7] // [7]
// Each node starts with [ ends with ], single digit is used for label and the length of a node is it's value. // Each node starts with [ ends with ], single digit is used for label and the length of a node is it's value.
export function textToDataContainer(text: string) { export function textToDataContainer(text: string, options?: Options) {
const levels = text.split('\n'); const levels = text.split('\n');
if (levels.length === 0) { if (levels.length === 0) {
@ -80,7 +80,7 @@ export function textToDataContainer(text: string) {
const df = arrayToDataFrame(dfSorted); const df = arrayToDataFrame(dfSorted);
const labelField = df.fields.find((f) => f.name === 'label')!; const labelField = df.fields.find((f) => f.name === 'label')!;
labelField.type = FieldType.string; labelField.type = FieldType.string;
return new FlameGraphDataContainer(df, { collapsing: true }); return new FlameGraphDataContainer(df, options || { collapsing: true });
} }
export function trimLevelsString(s: string) { export function trimLevelsString(s: string) {