From d659eb0159b65643cc3ab40ad6d768aefab71383 Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Thu, 21 Jun 2018 13:56:58 -0700 Subject: [PATCH] Add a hotkey to flatten recursion (#68) This makes the left-heavy view much more useful since recursive calls are all collapsed together. Press `r` to activate Fixes #37 --- .vscode/settings.json | 3 + application.tsx | 102 +++++++++----- flamechart-renderer.ts | 141 ++++++-------------- flamechart.ts | 4 +- profile.test.ts | 36 ++++- profile.ts | 85 ++++++++---- row-atlas.ts | 94 +++++++++++++ sample/profiles/stackcollapse/recursion.txt | 10 ++ test-utils.ts | 2 +- utils.test.ts | 26 ++++ utils.ts | 35 +++++ 11 files changed, 374 insertions(+), 164 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 row-atlas.ts create mode 100644 sample/profiles/stackcollapse/recursion.txt diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ebfde9a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true, +} \ No newline at end of file diff --git a/application.tsx b/application.tsx index 36bd527..95bd673 100644 --- a/application.tsx +++ b/application.tsx @@ -13,7 +13,7 @@ import { FileSystemDirectoryEntry, } from './import/instruments' -import {FlamechartRenderer} from './flamechart-renderer' +import {FlamechartRenderer, FlamechartRowAtlasKey} from './flamechart-renderer' import {CanvasContext} from './canvas-context' import {Profile, Frame} from './profile' @@ -24,6 +24,7 @@ import {getHashParams, HashParams} from './hash-params' import {ProfileTableView, SortMethod, SortField, SortDirection} from './profile-table-view' import {triangle} from './utils' import {Color} from './color' +import {RowAtlas} from './row-atlas' declare function require(x: string): any const exampleProfileURL = require('./sample/profiles/stackcollapse/perf-vertx-stacks-01-collapsed-all.txt') @@ -36,6 +37,8 @@ const enum ViewMode { interface ApplicationState { profile: Profile | null + activeProfile: Profile | null + flattenRecursion: boolean chronoFlamechart: Flamechart | null chronoFlamechartRenderer: FlamechartRenderer | null @@ -255,6 +258,8 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { dragActive: false, error: false, profile: null, + activeProfile: null, + flattenRecursion: false, chronoFlamechart: null, chronoFlamechartRenderer: null, @@ -281,11 +286,16 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { rehydrate(serialized: SerializedComponent) { super.rehydrate(serialized) const {chronoFlamechart, leftHeavyFlamegraph} = serialized.state - if (this.canvasContext && chronoFlamechart && leftHeavyFlamegraph) { + if (this.canvasContext && this.rowAtlas && chronoFlamechart && leftHeavyFlamegraph) { this.setState({ - chronoFlamechartRenderer: new FlamechartRenderer(this.canvasContext, chronoFlamechart), + chronoFlamechartRenderer: new FlamechartRenderer( + this.canvasContext, + this.rowAtlas, + chronoFlamechart, + ), leftHeavyFlamegraphRenderer: new FlamechartRenderer( this.canvasContext, + this.rowAtlas, leftHeavyFlamegraph, ), }) @@ -296,7 +306,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { await new Promise(resolve => this.setState({loading: true}, resolve)) await new Promise(resolve => setTimeout(resolve, 0)) - if (!this.canvasContext) return + if (!this.canvasContext || !this.rowAtlas) return console.time('import') @@ -316,13 +326,21 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { return } - console.log('PROFILE', profile) - await profile.demangle() const title = this.hashParams.title || profile.getName() profile.setName(title) - document.title = `${title} - speedscope` + + await this.setActiveProfile(profile) + + console.timeEnd('import') + this.setState({profile}) + } + + async setActiveProfile(profile: Profile) { + if (!this.canvasContext || !this.rowAtlas) return + + document.title = `${profile.getName()} - speedscope` const frames: Frame[] = [] profile.forEachFrame(f => frames.push(f)) @@ -333,12 +351,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { return key(a) > key(b) ? 1 : -1 } frames.sort(compare) - const frameToColorBucket = new Map() + const frameToColorBucket = new Map() for (let i = 0; i < frames.length; i++) { - frameToColorBucket.set(frames[i], Math.floor(255 * i / frames.length)) + frameToColorBucket.set(frames[i].key, Math.floor(255 * i / frames.length)) } function getColorBucketForFrame(frame: Frame) { - return frameToColorBucket.get(frame) || 0 + return frameToColorBucket.get(frame.key) || 0 } const chronoFlamechart = new Flamechart({ @@ -347,7 +365,11 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { formatValue: profile.formatValue.bind(profile), getColorBucketForFrame, }) - const chronoFlamechartRenderer = new FlamechartRenderer(this.canvasContext, chronoFlamechart) + const chronoFlamechartRenderer = new FlamechartRenderer( + this.canvasContext, + this.rowAtlas, + chronoFlamechart, + ) const leftHeavyFlamegraph = new Flamechart({ getTotalWeight: profile.getTotalNonIdleWeight.bind(profile), @@ -357,28 +379,26 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { }) const leftHeavyFlamegraphRenderer = new FlamechartRenderer( this.canvasContext, + this.rowAtlas, leftHeavyFlamegraph, ) - console.timeEnd('import') + await new Promise(resolve => { + this.setState( + { + activeProfile: profile, - console.time('first setState') - this.setState( - { - profile, + chronoFlamechart, + chronoFlamechartRenderer, - chronoFlamechart, - chronoFlamechartRenderer, + leftHeavyFlamegraph, + leftHeavyFlamegraphRenderer, - leftHeavyFlamegraph, - leftHeavyFlamegraphRenderer, - - loading: false, - }, - () => { - console.timeEnd('first setState') - }, - ) + loading: false, + }, + resolve, + ) + }) } loadFromFile(file: File) { @@ -388,8 +408,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { const reader = new FileReader() reader.addEventListener('loadend', () => { const profile = importProfile(file.name, reader.result) - if (profile) resolve(profile) - else reject() + if (profile) { + if (!profile.getName()) { + profile.setName(file.name) + } + resolve(profile) + } else reject() }) reader.readAsText(file) }), @@ -450,6 +474,16 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { this.setState({ viewMode: ViewMode.TABLE_VIEW, }) + } else if (ev.key === 'r') { + const {flattenRecursion, profile} = this.state + if (!profile) return + if (flattenRecursion) { + this.setActiveProfile(profile) + this.setState({flattenRecursion: false}) + } else { + this.setActiveProfile(profile.flattenRecursion()) + this.setState({flattenRecursion: true}) + } } } @@ -586,8 +620,14 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { } private canvasContext: CanvasContext | null = null + private rowAtlas: RowAtlas | null = null private setCanvasContext = (canvasContext: CanvasContext | null) => { this.canvasContext = canvasContext + if (canvasContext) { + this.rowAtlas = new RowAtlas(canvasContext) + } else { + this.rowAtlas = null + } } getCSSColorForFrame = (frame: Frame): string => { @@ -614,7 +654,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { return this.renderLoadingBar() } - if (!this.state.profile) { + if (!this.state.activeProfile) { return this.renderLanding() } @@ -654,7 +694,7 @@ export class Application extends ReloadableComponent<{}, ApplicationState> { case ViewMode.TABLE_VIEW: { return ( { - texture: regl.Texture - private framebuffer: regl.Framebuffer - private renderToFramebuffer: regl.Command<{}> - private rowCache: LRUCache - private clearLineBatch: RectangleBatch - - constructor(private canvasContext: CanvasContext) { - this.texture = canvasContext.gl.texture({ - width: Math.min(canvasContext.getMaxTextureSize(), 4096), - height: Math.min(canvasContext.getMaxTextureSize(), 1024), - wrapS: 'clamp', - wrapT: 'clamp', - }) - this.framebuffer = canvasContext.gl.framebuffer({color: [this.texture]}) - this.rowCache = new LRUCache(this.texture.height) - this.renderToFramebuffer = canvasContext.gl({ - framebuffer: this.framebuffer, - }) - this.clearLineBatch = canvasContext.createRectangleBatch() - this.clearLineBatch.addRect(Rect.unit, new Color(0, 0, 0, 0)) - } - - has(key: K) { - return this.rowCache.has(key) - } - getResolution() { - return this.texture.width - } - getCapacity() { - return this.texture.height - } - - private allocateLine(key: K): number { - if (this.rowCache.getSize() < this.rowCache.getCapacity()) { - // Not in cache, but cache isn't full - const row = this.rowCache.getSize() - this.rowCache.insert(key, row) - return row - } else { - // Not in cache, and cache is full. Evict something. - const [, row] = this.rowCache.removeLRU()! - this.rowCache.insert(key, row) - return row - } - } - - writeToAtlasIfNeeded(keys: K[], render: (textureDstRect: Rect, key: K) => void) { - this.renderToFramebuffer((context: regl.Context) => { - for (let key of keys) { - let row = this.rowCache.get(key) - if (row != null) { - // Already cached! - continue - } - // Not cached -- we'll have to actually render - row = this.allocateLine(key) - - const textureRect = new Rect(new Vec2(0, row), new Vec2(this.texture.width, 1)) - this.canvasContext.drawRectangleBatch({ - batch: this.clearLineBatch, - configSpaceSrcRect: Rect.unit, - physicalSpaceDstRect: textureRect, - }) - render(textureRect, key) - } - }) - } - - renderViaAtlas(key: K, dstRect: Rect): boolean { - let row = this.rowCache.get(key) - if (row == null) { - return false - } - - const textureRect = new Rect(new Vec2(0, row), new Vec2(this.texture.width, 1)) - - // At this point, we have the row in cache, and we can - // paint directly from it into the framebuffer. - this.canvasContext.drawTexture({ - texture: this.texture, - srcRect: textureRect, - dstRect: dstRect, - }) - return true - } -} - interface RangeTreeNode { getBounds(): Rect getRectCount(): number @@ -185,21 +97,41 @@ export interface FlamechartRendererProps { renderOutlines: boolean } -interface FlamechartRowAtlasKey { +interface FlamechartRowAtlasKeyInfo { stackDepth: number zoomLevel: number index: number } +export class FlamechartRowAtlasKey { + readonly stackDepth: number + readonly zoomLevel: number + readonly index: number + + get key() { + return `${this.stackDepth}_${this.index}_${this.zoomLevel}` + } + private constructor(options: FlamechartRowAtlasKeyInfo) { + this.stackDepth = options.stackDepth + this.zoomLevel = options.zoomLevel + this.index = options.index + } + static getOrInsert(set: KeyedSet, info: FlamechartRowAtlasKeyInfo) { + return set.getOrInsert(new FlamechartRowAtlasKey(info)) + } +} + export class FlamechartRenderer { private layers: RangeTreeNode[] = [] - private rowAtlas: RowAtlas private rectInfoTexture: regl.Texture private framebuffer: regl.Framebuffer - constructor(private canvasContext: CanvasContext, private flamechart: Flamechart) { + constructor( + private canvasContext: CanvasContext, + private rowAtlas: RowAtlas, + private flamechart: Flamechart, + ) { const nLayers = flamechart.getLayers().length - this.rowAtlas = new RowAtlas(canvasContext) for (let stackDepth = 0; stackDepth < nLayers; stackDepth++) { const leafNodes: RangeTreeLeafNode[] = [] const y = stackDepth @@ -267,11 +199,7 @@ export class FlamechartRenderer { }) } - private atlasKeys = new Map() - getOrInsertKey(key: FlamechartRowAtlasKey): FlamechartRowAtlasKey { - const hash = `${key.stackDepth}_${key.index}_${key.zoomLevel}` - return getOrInsert(this.atlasKeys, hash, () => key) - } + private atlasKeys = new KeyedSet() configSpaceBoundsForKey(key: FlamechartRowAtlasKey): Rect { const {stackDepth, zoomLevel, index} = key @@ -285,7 +213,7 @@ export class FlamechartRenderer { render(props: FlamechartRendererProps) { const {configSpaceSrcRect, physicalSpaceDstRect} = props - const atlasKeysToRender: {stackDepth: number; zoomLevel: number; index: number}[] = [] + const atlasKeysToRender: FlamechartRowAtlasKey[] = [] // We want to render the lowest resolution we can while still guaranteeing that the // atlas line is higher resolution than its corresponding destination rectangle on @@ -298,7 +226,12 @@ export class FlamechartRenderer { let zoomLevel = 0 while (true) { - const configSpaceBounds = this.configSpaceBoundsForKey({stackDepth: 0, zoomLevel, index: 0}) + const key = FlamechartRowAtlasKey.getOrInsert(this.atlasKeys, { + stackDepth: 0, + zoomLevel, + index: 0, + }) + const configSpaceBounds = this.configSpaceBoundsForKey(key) const physicalBounds = configToPhysical.transformRect(configSpaceBounds) if (physicalBounds.width() < this.rowAtlas.getResolution()) { break @@ -320,7 +253,11 @@ export class FlamechartRenderer { for (let stackDepth = top; stackDepth < bottom; stackDepth++) { for (let index = left; index <= right; index++) { - const key = this.getOrInsertKey({stackDepth, zoomLevel, index}) + const key = FlamechartRowAtlasKey.getOrInsert(this.atlasKeys, { + stackDepth, + zoomLevel, + index, + }) const configSpaceBounds = this.configSpaceBoundsForKey(key) if (!configSpaceBounds.hasIntersectionWith(configSpaceSrcRect)) continue atlasKeysToRender.push(key) diff --git a/flamechart.ts b/flamechart.ts index f5e34c9..11666f6 100644 --- a/flamechart.ts +++ b/flamechart.ts @@ -19,7 +19,7 @@ interface FlamechartDataSource { forEachCall( openFrame: (node: CallTreeNode, value: number) => void, - closeFrame: (value: number) => void, + closeFrame: (node: CallTreeNode, value: number) => void, ): void getColorBucketForFrame(f: Frame): number @@ -65,7 +65,7 @@ export class Flamechart { } this.minFrameWidth = Infinity - const closeFrame = (value: number) => { + const closeFrame = (node: CallTreeNode, value: number) => { console.assert(stack.length > 0) const stackTop = stack.pop()! stackTop.end = value diff --git a/profile.test.ts b/profile.test.ts index 2f00140..e09c8fc 100644 --- a/profile.test.ts +++ b/profile.test.ts @@ -29,8 +29,8 @@ function verifyProfile(profile: Profile) { let stackList: string[] = [] const curStack: (number | string)[] = [] - let lastValue = 0 + function openFrame(node: CallTreeNode, value: number) { if (lastValue != value) { stackList.push(curStack.map(k => `${k}`).join(';')) @@ -39,7 +39,7 @@ function verifyProfile(profile: Profile) { curStack.push(node.frame.key) } - function closeFrame(value: number) { + function closeFrame(node: CallTreeNode, value: number) { if (lastValue != value) { stackList.push(curStack.map(k => `${k}`).join(';')) lastValue = value @@ -62,11 +62,11 @@ function verifyProfile(profile: Profile) { 'a', ]) + lastValue = 0 stackList = [] profile.forEachCallGrouped(openFrame, closeFrame) expect(stackList).toEqual([ // prettier-ignore - '', 'a;b;e', 'a;b;b', 'a;b;c', @@ -74,6 +74,36 @@ function verifyProfile(profile: Profile) { 'a;b', 'a', ]) + + const flattened = profile.flattenRecursion() + + lastValue = 0 + stackList = [] + flattened.forEachCall(openFrame, closeFrame) + expect(stackList).toEqual([ + // prettier-ignore + 'a', + 'a;b', + 'a;b;d', + 'a;b;c', + '', + 'a', + 'a;b', + 'a;b;e', + 'a', + ]) + + lastValue = 0 + stackList = [] + flattened.forEachCallGrouped(openFrame, closeFrame) + expect(stackList).toEqual([ + // prettier-ignore + 'a;b;e', + 'a;b;c', + 'a;b;d', + 'a;b', + 'a', + ]) } test('StackListProfileBuilder', () => { diff --git a/profile.ts b/profile.ts index 6e2b0c3..2161942 100644 --- a/profile.ts +++ b/profile.ts @@ -1,4 +1,4 @@ -import {lastOf, getOrInsert} from './utils' +import {lastOf, KeyedSet} from './utils' import {ValueFormatter, RawValueFormatter} from './value-formatters' const demangleCppModule = import('./demangle-cpp') @@ -57,7 +57,7 @@ export class Frame extends HasWeights { // Column in the file col?: number - constructor(info: FrameInfo) { + private constructor(info: FrameInfo) { super() this.key = info.key this.name = info.name @@ -65,13 +65,22 @@ export class Frame extends HasWeights { this.line = info.line this.col = info.col } + + static root = new Frame({ + key: '(speedscope root)', + name: '(speedscope root)', + }) + + static getOrInsert(set: KeyedSet, info: FrameInfo) { + return set.getOrInsert(new Frame(info)) + } } export class CallTreeNode extends HasWeights { children: CallTreeNode[] = [] isRoot() { - return this.frame === rootFrame + return this.frame === Frame.root } constructor(readonly frame: Frame, readonly parent: CallTreeNode | null) { @@ -79,19 +88,14 @@ export class CallTreeNode extends HasWeights { } } -const rootFrame = new Frame({ - key: '(speedscope root)', - name: '(speedscope root)', -}) - export class Profile { protected name: string = '' protected totalWeight: number - protected frames = new Map() - protected appendOrderCalltreeRoot = new CallTreeNode(rootFrame, null) - protected groupedCalltreeRoot = new CallTreeNode(rootFrame, null) + protected frames = new KeyedSet() + protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null) + protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null) // List of references to CallTreeNodes at the top of the // stack at the time of the sample. @@ -135,10 +139,10 @@ export class Profile { forEachCallGrouped( openFrame: (node: CallTreeNode, value: number) => void, - closeFrame: (value: number) => void, + closeFrame: (node: CallTreeNode, value: number) => void, ) { function visit(node: CallTreeNode, start: number) { - if (node.frame !== rootFrame) { + if (node.frame !== Frame.root) { openFrame(node, start) } @@ -152,8 +156,8 @@ export class Profile { childTime += child.getTotalWeight() }) - if (node.frame !== rootFrame) { - closeFrame(start + node.getTotalWeight()) + if (node.frame !== Frame.root) { + closeFrame(node, start + node.getTotalWeight()) } } visit(this.groupedCalltreeRoot, 0) @@ -161,7 +165,7 @@ export class Profile { forEachCall( openFrame: (node: CallTreeNode, value: number) => void, - closeFrame: (value: number) => void, + closeFrame: (node: CallTreeNode, value: number) => void, ) { let prevStack: CallTreeNode[] = [] let value = 0 @@ -175,21 +179,21 @@ export class Profile { // so hopefully this isn't much of a problem for ( lca = stackTop; - lca && lca.frame != rootFrame && prevStack.indexOf(lca) === -1; + lca && lca.frame != Frame.root && prevStack.indexOf(lca) === -1; lca = lca.parent ) {} // Close frames that are no longer open while (prevStack.length > 0 && lastOf(prevStack) != lca) { - prevStack.pop() - closeFrame(value) + const node = prevStack.pop()! + closeFrame(node, value) } // Open frames that are now becoming open const toOpen: CallTreeNode[] = [] for ( let node: CallTreeNode | null = stackTop; - node && node.frame != rootFrame && node != lca; + node && node.frame != Frame.root && node != lca; node = node.parent ) { toOpen.push(node) @@ -206,7 +210,7 @@ export class Profile { // Close frames that are open at the end of the trace for (let i = prevStack.length - 1; i >= 0; i--) { - closeFrame(value) + closeFrame(prevStack[i], value) } } @@ -214,11 +218,42 @@ export class Profile { this.frames.forEach(fn) } + flattenRecursion(): Profile { + const builder = new CallTreeProfileBuilder() + + const stack: (CallTreeNode | null)[] = [] + const framesInStack = new Set() + + function openFrame(node: CallTreeNode, value: number) { + if (framesInStack.has(node.frame)) { + stack.push(null) + } else { + framesInStack.add(node.frame) + stack.push(node) + builder.enterFrame(node.frame, value) + } + } + function closeFrame(node: CallTreeNode, value: number) { + const stackTop = stack.pop() + if (stackTop) { + framesInStack.delete(stackTop.frame) + builder.leaveFrame(stackTop.frame, value) + } + } + + this.forEachCall(openFrame, closeFrame) + + const flattenedProfile = builder.build() + flattenedProfile.name = this.name + flattenedProfile.valueFormatter = this.valueFormatter + return flattenedProfile + } + // Demangle symbols for readability async demangle() { let demangleCpp: ((name: string) => string) | null = null - for (let frame of this.frames.values()) { + for (let frame of this.frames) { // This function converts a mangled C++ name such as "__ZNK7Support6ColorFeqERKS0_" // into a human-readable symbol (in this case "Support::ColorF::==(Support::ColorF&)") if (frame.name.startsWith('__Z')) { @@ -239,7 +274,7 @@ export class StackListProfileBuilder extends Profile { let framesInStack = new Set() for (let frameInfo of stack) { - const frame = getOrInsert(this.frames, frameInfo.key, () => new Frame(frameInfo)) + const frame = Frame.getOrInsert(this.frames, frameInfo) const last = useAppendOrder ? lastOf(node.children) : node.children.find(c => c.frame === frame) @@ -346,7 +381,7 @@ export class CallTreeProfileBuilder extends Profile { } } enterFrame(frameInfo: FrameInfo, value: number) { - const frame = getOrInsert(this.frames, frameInfo.key, () => new Frame(frameInfo)) + const frame = Frame.getOrInsert(this.frames, frameInfo) this.addWeightsToFrames(value) this._enterFrame(frame, value, true) this._enterFrame(frame, value, false) @@ -374,7 +409,7 @@ export class CallTreeProfileBuilder extends Profile { } leaveFrame(frameInfo: FrameInfo, value: number) { - const frame = getOrInsert(this.frames, frameInfo.key, () => new Frame(frameInfo)) + const frame = Frame.getOrInsert(this.frames, frameInfo) this.addWeightsToFrames(value) this._leaveFrame(frame, value, true) diff --git a/row-atlas.ts b/row-atlas.ts new file mode 100644 index 0000000..b564d16 --- /dev/null +++ b/row-atlas.ts @@ -0,0 +1,94 @@ +import regl from 'regl' +import {LRUCache} from './lru-cache' +import {RectangleBatch} from './rectangle-batch-renderer' +import {CanvasContext} from './canvas-context' +import {Rect, Vec2} from './math' +import {Color} from './color' + +export class RowAtlas { + texture: regl.Texture + private framebuffer: regl.Framebuffer + private renderToFramebuffer: regl.Command<{}> + private rowCache: LRUCache + private clearLineBatch: RectangleBatch + + constructor(private canvasContext: CanvasContext) { + this.texture = canvasContext.gl.texture({ + width: Math.min(canvasContext.getMaxTextureSize(), 4096), + height: Math.min(canvasContext.getMaxTextureSize(), 4096), + wrapS: 'clamp', + wrapT: 'clamp', + }) + this.framebuffer = canvasContext.gl.framebuffer({color: [this.texture]}) + this.rowCache = new LRUCache(this.texture.height) + this.renderToFramebuffer = canvasContext.gl({ + framebuffer: this.framebuffer, + }) + this.clearLineBatch = canvasContext.createRectangleBatch() + this.clearLineBatch.addRect(Rect.unit, new Color(0, 0, 0, 0)) + } + + has(key: K) { + return this.rowCache.has(key) + } + getResolution() { + return this.texture.width + } + getCapacity() { + return this.texture.height + } + + private allocateLine(key: K): number { + if (this.rowCache.getSize() < this.rowCache.getCapacity()) { + // Not in cache, but cache isn't full + const row = this.rowCache.getSize() + this.rowCache.insert(key, row) + return row + } else { + // Not in cache, and cache is full. Evict something. + const [, row] = this.rowCache.removeLRU()! + this.rowCache.insert(key, row) + return row + } + } + + writeToAtlasIfNeeded(keys: K[], render: (textureDstRect: Rect, key: K) => void) { + this.renderToFramebuffer((context: regl.Context) => { + for (let key of keys) { + let row = this.rowCache.get(key) + if (row != null) { + // Already cached! + continue + } + // Not cached -- we'll have to actually render + row = this.allocateLine(key) + + const textureRect = new Rect(new Vec2(0, row), new Vec2(this.texture.width, 1)) + this.canvasContext.drawRectangleBatch({ + batch: this.clearLineBatch, + configSpaceSrcRect: Rect.unit, + physicalSpaceDstRect: textureRect, + }) + render(textureRect, key) + } + }) + } + + renderViaAtlas(key: K, dstRect: Rect): boolean { + let row = this.rowCache.get(key) + if (row == null) { + return false + } + + const textureRect = new Rect(new Vec2(0, row), new Vec2(this.texture.width, 1)) + + // At this point, we have the row in cache, and we can + // paint directly from it into the framebuffer. + this.canvasContext.drawTexture({ + texture: this.texture, + srcRect: textureRect, + dstRect: dstRect, + }) + return true + } +} diff --git a/sample/profiles/stackcollapse/recursion.txt b/sample/profiles/stackcollapse/recursion.txt new file mode 100644 index 0000000..8e45dae --- /dev/null +++ b/sample/profiles/stackcollapse/recursion.txt @@ -0,0 +1,10 @@ +a;a 1 +b;b 1 +a;b 1 +a;b;a;b 1 +a;b;a;c 1 +a;b;a;b;a;b 1 +a;b;a;b;c 1 +a;b;a;b 1 +a;b;a;c 1 +a;b;a;b;c 1 diff --git a/test-utils.ts b/test-utils.ts index bf24688..b23785e 100644 --- a/test-utils.ts +++ b/test-utils.ts @@ -30,7 +30,7 @@ export function dumpProfile(profile: Profile): any { curStack.push(node.frame.name) } - function closeFrame(value: number) { + function closeFrame(node: CallTreeNode, value: number) { maybeEmit(value) curStack.pop() } diff --git a/utils.test.ts b/utils.test.ts index 0c8dbf8..b2f6694 100644 --- a/utils.test.ts +++ b/utils.test.ts @@ -8,6 +8,7 @@ import { itReduce, zeroPad, formatPercent, + KeyedSet, } from './utils' test('sortBy', () => { @@ -25,6 +26,31 @@ test('getOrInsert', () => { expect(m.get('x')).toBe(1) }) +class ValueType { + private constructor(readonly a: string, readonly num: number) {} + get key() { + return `${this.a}_${this.num}` + } + static getOrInsert(set: KeyedSet, a: string, num: number) { + return set.getOrInsert(new ValueType(a, num)) + } +} + +test('KeyedSet', () => { + const set = new KeyedSet() + + const x1 = ValueType.getOrInsert(set, 'x', 1) + const x2 = ValueType.getOrInsert(set, 'x', 1) + const y = ValueType.getOrInsert(set, 'y', 1) + + expect(x1).toBe(x2) + expect(y).not.toBe(x1) + + const set2 = new KeyedSet() + const x3 = ValueType.getOrInsert(set2, 'x', 1) + expect(x1).not.toBe(x3) +}) + test('getOrElse', () => { const m = new Map() expect(getOrElse(m, 'hello', k => k.length)).toBe(5) diff --git a/utils.ts b/utils.ts index 124664c..9dc8091 100644 --- a/utils.ts +++ b/utils.ts @@ -26,6 +26,41 @@ export function getOrThrow(map: Map, k: K): V { return map.get(k)! } +// Intended to be used to de-duplicate objects based on a key property. This +// allows value comparisons to be done efficiently and for the returned objects +// to be used intuitively in Map objects. +// +// Example usage: +// +// export class Frame { +// private constructor(readonly file: string, readonly name: string) {} +// get key() { return `${this.file}:${this.name}` } +// static getOrInsert(set: KeyedSet, file: string, name: string) { +// return set.getOrInsert(set, new Frame(file, name)) +// } +// } +// +export interface HasKey { + readonly key: string | number +} +export class KeyedSet implements Iterable { + private map = new Map() + + getOrInsert(t: T): T { + const key = t.key + const existing = this.map.get(key) + if (existing) return existing + this.map.set(key, t) + return t + } + forEach(fn: (t: T) => void) { + this.map.forEach(fn) + } + [Symbol.iterator]() { + return this.map.values() + } +} + export function* itMap(it: Iterable, f: (t: T) => U): Iterable { for (let t of it) { yield f(t)