From 75b57ad8c021df0ddca2be3782f07ccaba2b0f0b Mon Sep 17 00:00:00 2001 From: Jamie Wong Date: Sat, 14 Jul 2018 00:35:26 -0700 Subject: [PATCH] Extend the speedscope file format to support sampled profiles as well (#92) This is being done in preparation for writing a format from rbspy to import into speedscope, whose internal file format is a list of stacks (https://github.com/rbspy/rbspy/blob/111689fe13f843ac6786855ac10ce66c3ad84ab7/src/storage/v1.rs#L13) For now, speedscope will always export the evented format rather than the sampled format, but will accept either as input. I also added tests for existing versions of the file format to ensure I don't accidentally drop support for a past version of the file format. --- __snapshots__/file-format.test.ts.snap | 99 ++++++++++++++++ file-format-spec.ts | 42 ++++++- file-format.test.ts | 11 ++ file-format.ts | 109 ++++++++++++------ import/index.ts | 11 +- .../speedscope/0.0.1/simple.speedscope.json | 1 + .../0.1.2/simple-sampled.speedscope.json | 18 +++ test-utils.ts | 4 +- 8 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 __snapshots__/file-format.test.ts.snap create mode 100644 file-format.test.ts create mode 100644 sample/profiles/speedscope/0.0.1/simple.speedscope.json create mode 100644 sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json diff --git a/__snapshots__/file-format.test.ts.snap b/__snapshots__/file-format.test.ts.snap new file mode 100644 index 0000000..1104ed4 --- /dev/null +++ b/__snapshots__/file-format.test.ts.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`importSpeedscopeProfiles 0.0.1 evented profile 1`] = ` +Object { + "frames": Array [ + Frame { + "col": undefined, + "file": undefined, + "key": 0, + "line": undefined, + "name": "a", + "selfWeight": 0, + "totalWeight": 14, + }, + Frame { + "col": undefined, + "file": undefined, + "key": 1, + "line": undefined, + "name": "b", + "selfWeight": 5, + "totalWeight": 14, + }, + Frame { + "col": undefined, + "file": undefined, + "key": 2, + "line": undefined, + "name": "c", + "selfWeight": 5, + "totalWeight": 5, + }, + Frame { + "col": undefined, + "file": undefined, + "key": 3, + "line": undefined, + "name": "d", + "selfWeight": 4, + "totalWeight": 4, + }, + ], + "stacks": Array [ + "a;b;c 2", + "a;b;d 4", + "a;b;c 3", + "a;b 5", + ], +} +`; + +exports[`importSpeedscopeProfiles 0.1.2 sampled profile 1`] = ` +Object { + "frames": Array [ + Frame { + "col": undefined, + "file": undefined, + "key": 0, + "line": undefined, + "name": "a", + "selfWeight": 0, + "totalWeight": 14, + }, + Frame { + "col": undefined, + "file": undefined, + "key": 1, + "line": undefined, + "name": "b", + "selfWeight": 5, + "totalWeight": 14, + }, + Frame { + "col": undefined, + "file": undefined, + "key": 2, + "line": undefined, + "name": "c", + "selfWeight": 5, + "totalWeight": 5, + }, + Frame { + "col": undefined, + "file": undefined, + "key": 3, + "line": undefined, + "name": "d", + "selfWeight": 4, + "totalWeight": 4, + }, + ], + "stacks": Array [ + "a;b;c 2.00s", + "a;b;d 4.00s", + "a;b;c 3.00s", + "a;b 5.00s", + ], +} +`; diff --git a/file-format-spec.ts b/file-format-spec.ts index d58b920..992e0cb 100644 --- a/file-format-spec.ts +++ b/file-format-spec.ts @@ -1,13 +1,15 @@ // This file contains types which specify the speedscope file format. export namespace FileFormat { + export type Profile = EventedProfile | SampledProfile + export interface File { version: string $schema: 'https://www.speedscope.app/file-format-schema.json' shared: { frames: Frame[] } - profiles: EventedProfile[] + profiles: Profile[] } export interface Frame { @@ -19,16 +21,17 @@ export namespace FileFormat { export enum ProfileType { EVENTED = 'evented', + SAMPLED = 'sampled', } export interface IProfile { + // Type of profile. This will future proof the file format to allow many + // different kinds of profiles to be contained and each type to be part of + // a discriminated union. type: ProfileType } export interface EventedProfile extends IProfile { - // Type of profile. This will future proof the file format to allow many - // different kinds of profiles to be contained and each type to be part of - // a discriminated union. type: ProfileType.EVENTED // Name of the profile. Typically a filename for the source of the profile. @@ -53,6 +56,37 @@ export namespace FileFormat { events: (OpenFrameEvent | CloseFrameEvent)[] } + // List of indices into the frame array + type SampledStack = number[] + + export interface SampledProfile extends IProfile { + type: ProfileType.SAMPLED + + // Name of the profile. Typically a filename for the source of the profile. + name: string + + // Unit which all value are specified using in the profile. + unit: ValueUnit + + // The starting value of the profile. This will typically be a timestamp. + // All event values will be relative to this startValue. + startValue: number + + // The final value of the profile. This will typically be a timestamp. This + // must be greater than or equal to the startValue. This is useful in + // situations where the recorded profile extends past the end of the recorded + // events, which may happen if nothing was happening at the end of the + // profile. + endValue: number + + // List of stacks + samples: SampledStack[] + + // The weight of the sample at the given index. Should have + // the same length as the samples array. + weights: number[] + } + export type ValueUnit = | 'none' | 'nanoseconds' diff --git a/file-format.test.ts b/file-format.test.ts new file mode 100644 index 0000000..f7f90f8 --- /dev/null +++ b/file-format.test.ts @@ -0,0 +1,11 @@ +import {checkProfileSnapshot} from './test-utils' + +describe('importSpeedscopeProfiles', async () => { + test('0.0.1 evented profile', async () => { + await checkProfileSnapshot('./sample/profiles/speedscope/0.0.1/simple.speedscope.json') + }) + + test('0.1.2 sampled profile', async () => { + await checkProfileSnapshot('./sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json') + }) +}) diff --git a/file-format.ts b/file-format.ts index c1dfd91..1ee4ff7 100644 --- a/file-format.ts +++ b/file-format.ts @@ -1,4 +1,11 @@ -import {Profile, CallTreeNode, Frame, CallTreeProfileBuilder, FrameInfo} from './profile' +import { + Profile, + CallTreeNode, + Frame, + CallTreeProfileBuilder, + FrameInfo, + StackListProfileBuilder, +} from './profile' import {TimeFormatter, ByteFormatter, RawValueFormatter} from './value-formatters' import {FileFormat} from './file-format-spec' @@ -59,54 +66,86 @@ export function exportProfile(profile: Profile): FileFormat.File { } function importSpeedscopeProfile( - serialized: FileFormat.EventedProfile, + serialized: FileFormat.Profile, frames: FileFormat.Frame[], ): Profile { - const {startValue, endValue, name, unit, events} = serialized + function setCommonProperties(p: Profile) { + const {name, unit} = serialized - const profile = new CallTreeProfileBuilder(endValue - startValue) + switch (unit) { + case 'nanoseconds': + case 'microseconds': + case 'milliseconds': + case 'seconds': + p.setValueFormatter(new TimeFormatter(unit)) + break - switch (unit) { - case 'nanoseconds': - case 'microseconds': - case 'milliseconds': - case 'seconds': - profile.setValueFormatter(new TimeFormatter(unit)) - break + case 'bytes': + p.setValueFormatter(new ByteFormatter()) + break - case 'bytes': - profile.setValueFormatter(new ByteFormatter()) - break - - case 'none': - profile.setValueFormatter(new RawValueFormatter()) - break + case 'none': + p.setValueFormatter(new RawValueFormatter()) + break + } + p.setName(name) } - profile.setName(name) - const frameInfos: FrameInfo[] = frames.map((frame, i) => ({key: i, ...frame})) + function importEventedProfile(evented: FileFormat.EventedProfile) { + const {startValue, endValue, events} = evented - for (let ev of events) { - switch (ev.type) { - case FileFormat.EventType.OPEN_FRAME: { - profile.enterFrame(frameInfos[ev.frame], ev.at - startValue) - break - } - case FileFormat.EventType.CLOSE_FRAME: { - profile.leaveFrame(frameInfos[ev.frame], ev.at - startValue) - break + const profile = new CallTreeProfileBuilder(endValue - startValue) + setCommonProperties(profile) + + const frameInfos: FrameInfo[] = frames.map((frame, i) => ({key: i, ...frame})) + + for (let ev of events) { + switch (ev.type) { + case FileFormat.EventType.OPEN_FRAME: { + profile.enterFrame(frameInfos[ev.frame], ev.at - startValue) + break + } + case FileFormat.EventType.CLOSE_FRAME: { + profile.leaveFrame(frameInfos[ev.frame], ev.at - startValue) + break + } } } + return profile.build() } - return profile.build() + function importSampledProfile(sampled: FileFormat.SampledProfile) { + const {startValue, endValue, samples, weights} = sampled + const profile = new StackListProfileBuilder(endValue - startValue) + setCommonProperties(profile) + + const frameInfos: FrameInfo[] = frames.map((frame, i) => ({key: i, ...frame})) + + if (samples.length !== weights.length) { + throw new Error( + `Expected samples.length (${samples.length}) to equal weights.length (${weights.length})`, + ) + } + + for (let i = 0; i < samples.length; i++) { + const stack = samples[i] + const weight = weights[i] + profile.appendSample(stack.map(n => frameInfos[n]), weight) + } + + return profile.build() + } + + switch (serialized.type) { + case FileFormat.ProfileType.EVENTED: + return importEventedProfile(serialized) + case FileFormat.ProfileType.SAMPLED: + return importSampledProfile(serialized) + } } -export function importSingleSpeedscopeProfile(serialized: FileFormat.File): Profile { - if (serialized.profiles.length !== 1) { - throw new Error(`Unexpected profiles length ${serialized.profiles}`) - } - return importSpeedscopeProfile(serialized.profiles[0], serialized.shared.frames) +export function importSpeedscopeProfiles(serialized: FileFormat.File): Profile[] { + return serialized.profiles.map(p => importSpeedscopeProfile(p, serialized.shared.frames)) } export function saveToFile(profile: Profile): void { diff --git a/import/index.ts b/import/index.ts index 2f9aac5..e27aace 100644 --- a/import/index.ts +++ b/import/index.ts @@ -6,7 +6,8 @@ import {importFromStackprof} from './stackprof' import {importFromInstrumentsDeepCopy, importFromInstrumentsTrace} from './instruments' import {importFromBGFlameGraph} from './bg-flamegraph' import {importFromFirefox} from './firefox' -import {importSingleSpeedscopeProfile} from '../file-format' +import {importSpeedscopeProfiles} from '../file-format' +import {FileFormat} from '../file-format-spec' export async function importProfile(fileName: string, contents: string): Promise { const profile = await _importProfile(fileName, contents) @@ -16,6 +17,14 @@ export async function importProfile(fileName: string, contents: string): Promise return profile } +function importSingleSpeedscopeProfile(serialized: FileFormat.File) { + const profiles = importSpeedscopeProfiles(serialized) + if (profiles.length === 0) { + throw new Error('Failed to extract any profiles from the imported speedscope profile') + } + return profiles[0] +} + async function _importProfile(fileName: string, contents: string): Promise { // First pass: Check known file format names to infer the file type if (fileName.endsWith('.speedscope.json')) { diff --git a/sample/profiles/speedscope/0.0.1/simple.speedscope.json b/sample/profiles/speedscope/0.0.1/simple.speedscope.json new file mode 100644 index 0000000..2d98b82 --- /dev/null +++ b/sample/profiles/speedscope/0.0.1/simple.speedscope.json @@ -0,0 +1 @@ +{"version":"0.0.1","$schema":"https://www.speedscope.app/file-format-schema.json","shared":{"frames":[{"name":"a"},{"name":"b"},{"name":"c"},{"name":"d"}]},"profiles":[{"type":"evented","name":"simple.txt","unit":"none","startValue":0,"endValue":14,"events":[{"type":"O","frame":0,"at":0},{"type":"O","frame":1,"at":0},{"type":"O","frame":2,"at":0},{"type":"C","frame":2,"at":2},{"type":"O","frame":3,"at":2},{"type":"C","frame":3,"at":6},{"type":"O","frame":2,"at":6},{"type":"C","frame":2,"at":9},{"type":"C","frame":1,"at":14},{"type":"C","frame":0,"at":14}]}]} \ No newline at end of file diff --git a/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json b/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json new file mode 100644 index 0000000..373827d --- /dev/null +++ b/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json @@ -0,0 +1,18 @@ +{ + "version": "0.1.2", + "$schema": "https://www.speedscope.app/file-format-schema.json", + "profiles": [ + { + "type": "sampled", + "name": "simple.speedscope.json", + "unit": "seconds", + "startValue": 0, + "endValue": 14, + "samples": [[0, 1, 2], [0, 1, 2], [0, 1, 3], [0, 1, 2], [0, 1]], + "weights": [1, 1, 4, 3, 5] + } + ], + "shared": { + "frames": [{"name": "a"}, {"name": "b"}, {"name": "c"}, {"name": "d"}] + } +} diff --git a/test-utils.ts b/test-utils.ts index 501f208..4219310 100644 --- a/test-utils.ts +++ b/test-utils.ts @@ -2,7 +2,7 @@ import * as fs from 'fs' import * as path from 'path' import {Profile, CallTreeNode, Frame} from './profile' import {importProfile} from './import' -import {exportProfile, importSingleSpeedscopeProfile} from './file-format' +import {exportProfile, importSpeedscopeProfiles} from './file-format' interface DumpedProfile { stacks: string[] @@ -56,7 +56,7 @@ export async function checkProfileSnapshot(filepath: string) { } const exported = exportProfile(profile) - const reimported = importSingleSpeedscopeProfile(exported) + const reimported = importSpeedscopeProfiles(exported)[0] expect(reimported.getName()).toEqual(profile.getName()) expect(reimported.getTotalWeight()).toEqual(profile.getTotalWeight())