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:
Jamie Wong
2018-07-14 00:35:26 -07:00
committed by GitHub
parent 6aa66bead2
commit 75b57ad8c0
8 changed files with 253 additions and 42 deletions

View 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",
],
}
`;

View File

@@ -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
View 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')
})
})

View File

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

View File

@@ -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')) {

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

View File

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

View File

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