Specify file format for speedscope (#83)

This defines a JSON-based file format for speedscope.

The motivation for is primarily two things:
1. To enable others to write tools to output profiles which can be read by speedscope
2. To enable others to write tools to handle the output of speedscope, leveraging the variety of importers that speedscope supports

Fixes #65
This commit is contained in:
Jamie Wong
2018-07-07 18:37:28 -07:00
committed by GitHub
parent c4cdb39df2
commit e39381e498
13 changed files with 1335 additions and 274 deletions
+2
View File
@@ -98,3 +98,5 @@ Once a profile has loaded, the main view is split into two: the top area is the
* `2`: Switch to the "Left Heavy" view
* `3`: Switch to the "Sandwich" view
* `r`: Collapse recursion in the flamegraphs
* `Cmd+S`/`Ctrl+S` to save the current profile
* `Cmd+O`/`Ctrl+O` to open a new profile
+63 -11
View File
@@ -18,6 +18,7 @@ import {Color} from './color'
import {RowAtlas} from './row-atlas'
import {importAsmJsSymbolMap} from './asm-js'
import {SandwichView} from './sandwich-view'
import {saveToFile} from './file-format'
const importModule = import('./import')
// Force eager loading of the module
@@ -62,6 +63,8 @@ interface ApplicationState {
interface ToolbarProps extends ApplicationState {
setViewMode(order: ViewMode): void
browseForFile(): void
saveFile(): void
}
export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
@@ -78,6 +81,11 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
}
render() {
const importFile = (
<div className={css(style.toolbarTab)} onClick={this.props.browseForFile}>
<span className={css(style.emoji)}>⤵️</span>Import
</div>
)
const help = (
<div className={css(style.toolbarTab)}>
<a
@@ -94,7 +102,10 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
return (
<div className={css(style.toolbar)}>
🔬speedscope
<div className={css(style.toolbarRight)}>{help}</div>
<div className={css(style.toolbarRight)}>
{importFile}
{help}
</div>
</div>
)
}
@@ -130,7 +141,13 @@ export class Toolbar extends ReloadableComponent<ToolbarProps, void> {
</div>
</div>
{this.props.profile.getName()}
<div className={css(style.toolbarRight)}>{help}</div>
<div className={css(style.toolbarRight)}>
<div className={css(style.toolbarTab)} onClick={this.props.saveFile}>
<span className={css(style.emoji)}>⤴️</span>Export
</div>
{importFile}
{help}
</div>
</div>
)
}
@@ -381,9 +398,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
loadExample = () => {
this.loadProfile(async () => {
const filename = 'perf-vertx-stacks-01-collapsed-all.txt'
return await fetch(exampleProfileURL)
.then(resp => resp.text())
.then(data => importProfile(filename, data))
const data = await fetch(exampleProfileURL).then(resp => resp.text())
const profile = await importProfile(filename, data)
if (profile && !profile.getName()) {
profile.setName(filename)
}
return profile
})
}
@@ -445,6 +465,31 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
}
private saveFile = () => {
if (this.state.profile) {
saveToFile(this.state.profile)
}
}
private browseForFile = () => {
const input = document.createElement('input')
input.type = 'file'
input.addEventListener('change', this.onFileSelect)
input.click()
}
private onWindowKeyDown = async (ev: KeyboardEvent) => {
// This has to be handled on key down in order to prevent the default
// page save action.
if (ev.key === 's' && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault()
this.saveFile()
} else if (ev.key === 'o' && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault()
this.browseForFile()
}
}
onDocumentPaste = (ev: Event) => {
ev.preventDefault()
ev.stopPropagation()
@@ -454,11 +499,18 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
componentDidMount() {
window.addEventListener('keydown', this.onWindowKeyDown)
window.addEventListener('keypress', this.onWindowKeyPress)
document.addEventListener('paste', this.onDocumentPaste)
this.maybeLoadHashParamProfile()
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onWindowKeyDown)
window.removeEventListener('keypress', this.onWindowKeyPress)
document.removeEventListener('paste', this.onDocumentPaste)
}
async maybeLoadHashParamProfile() {
if (this.hashParams.profileURL) {
if (!canUseXHR) {
@@ -476,11 +528,6 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
}
}
componentWillUnmount() {
window.removeEventListener('keypress', this.onWindowKeyPress)
document.removeEventListener('paste', this.onDocumentPaste)
}
flamechartView: FlamechartView | null = null
flamechartRef = (view: FlamechartView | null) => (this.flamechartView = view)
subcomponents() {
@@ -692,7 +739,12 @@ export class Application extends ReloadableComponent<{}, ApplicationState> {
className={css(style.root, this.state.dragActive && style.dragTargetRoot)}
>
<GLCanvas setCanvasContext={this.setCanvasContext} />
<Toolbar setViewMode={this.setViewMode} {...this.state} />
<Toolbar
setViewMode={this.setViewMode}
saveFile={this.saveFile}
browseForFile={this.browseForFile}
{...this.state}
/>
<div className={css(style.contentContainer)}>{this.renderContent()}</div>
{this.state.dragActive && <div className={css(style.dragTarget)} />}
</div>
+4 -1
View File
@@ -17,10 +17,13 @@ node_modules/.bin/tsc --noEmit
rm -rf "$OUTDIR"
mkdir -p "$OUTDIR"
# Place info about the current commit into the build dir to easiy identify releases
# Place info about the current commit into the build dir to easily identify releases
date > "$OUTDIR"/release.txt
git rev-parse HEAD >> "$OUTDIR"/release.txt
# Place a json schema for the file format into the build directory too
node generate-file-format-schema-json.js > "$OUTDIR"/file-format-schema.json
# Build the compiled assets
node_modules/.bin/parcel build index.html --no-cache --out-dir "$OUTDIR" --public-url "./" --detailed-report
+87
View File
@@ -0,0 +1,87 @@
// This file contains types which specify the speedscope file format.
export namespace FileFormat {
export interface File {
version: string
$schema: 'https://www.speedscope.app/file-format-schema.json'
shared: {
frames: Frame[]
}
profiles: EventedProfile[]
}
export interface Frame {
name: string
file?: string
line?: number
col?: number
}
export enum ProfileType {
EVENTED = 'evented',
}
export interface IProfile {
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.
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 events that occured as part of this profile.
// The "at" field of every event must be in non-decreasing order.
events: (OpenFrameEvent | CloseFrameEvent)[]
}
export type ValueUnit =
| 'none'
| 'nanoseconds'
| 'microseconds'
| 'milliseconds'
| 'seconds'
| 'bytes'
export enum EventType {
OPEN_FRAME = 'O',
CLOSE_FRAME = 'C',
}
interface IEvent {
type: EventType
at: number
}
// Indicates a stack frame opened. Every opened stack frame must have a
// corresponding close frame event, and the ordering must be balanced.
interface OpenFrameEvent extends IEvent {
type: EventType.OPEN_FRAME
// An index into the frames array in the shared data within the profile
frame: number
}
interface CloseFrameEvent extends IEvent {
type: EventType.CLOSE_FRAME
// An index into the frames array in the shared data within the profile
frame: number
}
}
+129
View File
@@ -0,0 +1,129 @@
import {Profile, CallTreeNode, Frame, CallTreeProfileBuilder, FrameInfo} from './profile'
import {TimeFormatter, ByteFormatter, RawValueFormatter} from './value-formatters'
import {FileFormat} from './file-format-spec'
export function exportProfile(profile: Profile): FileFormat.File {
const frames: FileFormat.Frame[] = []
const eventedProfile: FileFormat.EventedProfile = {
type: FileFormat.ProfileType.EVENTED,
name: profile.getName(),
unit: profile.getWeightUnit(),
startValue: 0,
endValue: profile.getTotalWeight(),
events: [],
}
const file: FileFormat.File = {
version: '0.0.1',
$schema: 'https://www.speedscope.app/file-format-schema.json',
shared: {frames},
profiles: [eventedProfile],
}
const indexForFrame = new Map<Frame, number>()
function getIndexForFrame(frame: Frame): number {
let index = indexForFrame.get(frame)
if (index == null) {
const serializedFrame: FileFormat.Frame = {
name: frame.name,
}
if (frame.file != null) serializedFrame.file = frame.file
if (frame.line != null) serializedFrame.line = frame.line
if (frame.col != null) serializedFrame.col = frame.col
index = frames.length
indexForFrame.set(frame, index)
frames.push(serializedFrame)
}
return index
}
const openFrame = (node: CallTreeNode, value: number) => {
eventedProfile.events.push({
type: FileFormat.EventType.OPEN_FRAME,
frame: getIndexForFrame(node.frame),
at: value,
})
}
const closeFrame = (node: CallTreeNode, value: number) => {
eventedProfile.events.push({
type: FileFormat.EventType.CLOSE_FRAME,
frame: getIndexForFrame(node.frame),
at: value,
})
}
profile.forEachCall(openFrame, closeFrame)
return file
}
function importSpeedscopeProfile(
serialized: FileFormat.EventedProfile,
frames: FileFormat.Frame[],
): Profile {
const {startValue, endValue, name, unit, events} = serialized
const profile = new CallTreeProfileBuilder(endValue - startValue)
switch (unit) {
case 'nanoseconds':
case 'microseconds':
case 'milliseconds':
case 'seconds':
profile.setValueFormatter(new TimeFormatter(unit))
break
case 'bytes':
profile.setValueFormatter(new ByteFormatter())
break
case 'none':
profile.setValueFormatter(new RawValueFormatter())
break
}
profile.setName(name)
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()
}
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 saveToFile(profile: Profile): void {
const blob = new Blob([JSON.stringify(exportProfile(profile))], {type: 'text/json'})
const nameWithoutExt = profile.getName().split('.')[0]!
const filename = `${nameWithoutExt.replace(/\W+/g, '_')}.speedscope.json`
console.log('Saving', filename)
const a = document.createElement('a')
a.download = filename
a.href = window.URL.createObjectURL(blob)
a.dataset.downloadurl = ['text/json', a.download, a.href].join(':')
// For this to work in Firefox, the <a> must be in the DOM
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
+5 -1
View File
@@ -637,7 +637,11 @@ export class FlamechartPanZoomView extends ReloadableComponent<FlamechartPanZoom
} else if (ev.key === '-' || ev.key === '_') {
this.zoom(new Vec2(width / 2, height / 2), 2)
ev.preventDefault()
} else if (ev.key === '0') {
}
if (ev.ctrlKey || ev.shiftKey || ev.metaKey) return
if (ev.key === '0') {
this.zoom(new Vec2(width / 2, height / 2), 1e9)
} else if (ev.key === 'ArrowRight' || ev.key === 'd') {
this.pan(new Vec2(100, 0))
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
const child_process = require('child_process')
// Convert the file-format-spec.ts file into a json schema file
let jsonSchema = child_process.execSync(
'node_modules/.bin/quicktype --lang schema ./file-format-spec.ts',
{
encoding: 'utf8',
},
)
jsonSchema = JSON.parse(jsonSchema)
jsonSchema['$ref'] = '#/definitions/FileFormat.File'
console.log(JSON.stringify(jsonSchema, null, 4))
+9 -2
View File
@@ -6,10 +6,14 @@ import {importFromStackprof} from './stackprof'
import {importFromInstrumentsDeepCopy, importFromInstrumentsTrace} from './instruments'
import {importFromBGFlameGraph} from './bg-flamegraph'
import {importFromFirefox} from './firefox'
import {importSingleSpeedscopeProfile} from '../file-format'
export 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('.cpuprofile')) {
if (fileName.endsWith('.speedscope.json')) {
console.log('Importing as speedscope json file')
return importSingleSpeedscopeProfile(JSON.parse(contents))
} else if (fileName.endsWith('.cpuprofile')) {
console.log('Importing as Chrome CPU Profile')
return importFromChromeCPUProfile(JSON.parse(contents))
} else if (fileName.endsWith('.chrome.json') || /Profile-\d{8}T\d{6}/.exec(fileName)) {
@@ -32,7 +36,10 @@ export async function importProfile(fileName: string, contents: string): Promise
parsed = JSON.parse(contents)
} catch (e) {}
if (parsed) {
if (parsed['systemHost'] && parsed['systemHost']['name'] == 'Firefox') {
if (parsed['$schema'] === 'https://www.speedscope.app/file-format-schema.json') {
console.log('Importing as speedscope json file')
return importSingleSpeedscopeProfile(parsed)
} else if (parsed['systemHost'] && parsed['systemHost']['name'] == 'Firefox') {
console.log('Importing as Firefox profile')
return importFromFirefox(parsed)
} else if (Array.isArray(parsed) && parsed[parsed.length - 1].name === 'CpuProfile') {
+980 -246
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -22,25 +22,26 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@types/jest": "^22.2.3",
"@types/jszip": "^3.1.4",
"@types/node": "^10.1.4",
"@types/jest": "22.2.3",
"@types/jszip": "3.1.4",
"@types/node": "10.1.4",
"@types/pako": "1.0.0",
"aphrodite": "2.1.0",
"coveralls": "^3.0.1",
"eslint": "^4.19.1",
"eslint-plugin-prettier": "^2.6.0",
"jest": "^23.0.1",
"jsverify": "^0.8.3",
"jszip": "^3.1.5",
"coveralls": "3.0.1",
"eslint": "4.19.1",
"eslint-plugin-prettier": "2.6.0",
"jest": "23.0.1",
"jsverify": "0.8.3",
"jszip": "3.1.5",
"pako": "1.0.6",
"parcel-bundler": "1.9.2",
"preact": "8.2.7",
"prettier": "^1.12.0",
"prettier": "1.12.0",
"quicktype": "15.0.45",
"regl": "1.3.1",
"ts-jest": "^22.4.6",
"ts-jest": "22.4.6",
"typescript": "2.8.1",
"typescript-eslint-parser": "^14.0.0",
"typescript-eslint-parser": "14.0.0",
"uglify-es": "3.2.2"
},
"jest": {
+10
View File
@@ -1,5 +1,6 @@
import {lastOf, KeyedSet} from './utils'
import {ValueFormatter, RawValueFormatter} from './value-formatters'
import {FileFormat} from './file-format-spec'
const demangleCppModule = import('./demangle-cpp')
// Force eager loading of the module
@@ -119,6 +120,9 @@ export class Profile {
setValueFormatter(f: ValueFormatter) {
this.valueFormatter = f
}
getWeightUnit(): FileFormat.ValueUnit {
return this.valueFormatter.unit
}
getName() {
return this.name
@@ -223,6 +227,12 @@ export class Profile {
this.frames.forEach(fn)
}
forEachSample(fn: (sample: CallTreeNode, weight: number) => void) {
for (let i = 0; i < this.samples.length; i++) {
fn(this.samples[i], this.weights[i])
}
}
getProfileWithRecursionFlattened(): Profile {
const builder = new CallTreeProfileBuilder()
+13
View File
@@ -2,6 +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'
interface DumpedProfile {
stacks: string[]
@@ -45,10 +46,22 @@ export function dumpProfile(profile: Profile): any {
export async function checkProfileSnapshot(filepath: string) {
const input = fs.readFileSync(filepath, 'utf8')
const profile = await importProfile(path.basename(filepath), input)
if (profile) {
expect(dumpProfile(profile)).toMatchSnapshot()
} else {
fail('Failed to extract profile')
return
}
const exported = exportProfile(profile)
const reimported = importSingleSpeedscopeProfile(exported)
expect(reimported.getName()).toEqual(profile.getName())
expect(reimported.getTotalWeight()).toEqual(profile.getTotalWeight())
expect(dumpProfile(reimported).stacks.join('\n')).toEqual(dumpProfile(profile).stacks.join('\n'))
const reexported = exportProfile(reimported)
expect(exported).toEqual(reexported)
}
+7 -1
View File
@@ -1,8 +1,12 @@
import {FileFormat} from './file-format-spec'
export interface ValueFormatter {
unit: FileFormat.ValueUnit
format(v: number): string
}
export class RawValueFormatter implements ValueFormatter {
unit: FileFormat.ValueUnit = 'none'
format(v: number) {
return v.toLocaleString()
}
@@ -11,7 +15,7 @@ export class RawValueFormatter implements ValueFormatter {
export class TimeFormatter implements ValueFormatter {
private multiplier: number
constructor(unit: 'nanoseconds' | 'microseconds' | 'milliseconds' | 'seconds') {
constructor(public unit: 'nanoseconds' | 'microseconds' | 'milliseconds' | 'seconds') {
if (unit === 'nanoseconds') this.multiplier = 1e-9
else if (unit === 'microseconds') this.multiplier = 1e-6
else if (unit === 'milliseconds') this.multiplier = 1e-3
@@ -30,6 +34,8 @@ export class TimeFormatter implements ValueFormatter {
}
export class ByteFormatter implements ValueFormatter {
unit: FileFormat.ValueUnit = 'bytes'
format(v: number) {
if (v < 1024) return `${v.toFixed(0)} B`
v /= 1024