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 (111689fe13/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.
This commit is contained in:
99
__snapshots__/file-format.test.ts.snap
Normal file
99
__snapshots__/file-format.test.ts.snap
Normal file
@@ -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",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -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'
|
||||
|
||||
11
file-format.test.ts
Normal file
11
file-format.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
109
file-format.ts
109
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 {
|
||||
|
||||
@@ -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<Profile | null> {
|
||||
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<Profile | null> {
|
||||
// First pass: Check known file format names to infer the file type
|
||||
if (fileName.endsWith('.speedscope.json')) {
|
||||
|
||||
1
sample/profiles/speedscope/0.0.1/simple.speedscope.json
Normal file
1
sample/profiles/speedscope/0.0.1/simple.speedscope.json
Normal file
@@ -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}]}]}
|
||||
@@ -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"}]
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user