Add support for Safari profiles (#313)

Closes #294 

This adds import for Safari/webkit profiler. Well, for Safari 13.1 for sure, I haven't done any work to check if there's been changes to the syntax.

It seems to work OK, and is already a huge improvement over profiling in Safari (which doesn't even have a flame graph, let alone something like left heavy). Sadly, the sampler resolution is only 1kHz, which is not super useful for a lot of profiling work. I made a ticket on webkit bug tracker to ask for 10kHz/configurable sampling rate: https://bugs.webkit.org/show_bug.cgi?id=214866

Another thing that's missing is that I cut out all the idle time. We could also insert layout/paint samples into the timeline by parsing `events`. But I'll leave that for another time.

<img width="1280" alt="Captura de pantalla 2020-07-28 a las 11 02 06" src="https://user-images.githubusercontent.com/183747/88643560-20c16700-d0c2-11ea-9c73-d9159e68fab9.png">
This commit is contained in:
Jamie Wong 2020-09-29 14:26:01 -07:00 committed by GitHub
parent 069c0194a6
commit f3a1c09c9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 235 additions and 0 deletions

View File

@ -36,6 +36,7 @@ speedscope is designed to ingest profiles from a variety of different profilers
- JavaScript
- [Importing from Chrome](https://github.com/jlfwong/speedscope/wiki/Importing-from-Chrome)
- [Importing from Firefox](https://github.com/jlfwong/speedscope/wiki/Importing-from-Firefox)
- [Importing from Safari](https://github.com/jlfwong/speedscope/wiki/Importing-from-Safari)
- [Importing from Node.js](https://github.com/jlfwong/speedscope/wiki/Importing-from-Node.js)
- Ruby
- [Importing from stackprof](https://github.com/jlfwong/speedscope/wiki/Importing-from-stackprof-(ruby))

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`importFromSafari 1`] = `
Object {
"frames": Array [
Frame {
"col": 13,
"file": "__InjectedScript_InjectedScriptSource.js",
"key": "injectModule:__InjectedScript_InjectedScriptSource.js:109:13",
"line": 109,
"name": "injectModule",
"selfWeight": 0,
"totalWeight": 0.001,
},
Frame {
"col": 10,
"file": "__InjectedScript_CommandLineAPIModuleSource.js",
"key": ":__InjectedScript_CommandLineAPIModuleSource.js:2:10",
"line": 2,
"name": "(anonymous)",
"selfWeight": 0.001,
"totalWeight": 0.001,
},
Frame {
"col": 1,
"file": "file:///speedscope/sample/programs/javascript/simple.js",
"key": "(program):file:///speedscope/sample/programs/javascript/simple.js:1:1",
"line": 1,
"name": "(program)",
"selfWeight": 0,
"totalWeight": 0.03248933597933502,
},
Frame {
"col": 15,
"file": "file:///speedscope/sample/programs/javascript/simple.js",
"key": "alpha:file:///speedscope/sample/programs/javascript/simple.js:1:15",
"line": 1,
"name": "alpha",
"selfWeight": 0,
"totalWeight": 0.03248933597933502,
},
Frame {
"col": 15,
"file": "file:///speedscope/sample/programs/javascript/simple.js",
"key": "delta:file:///speedscope/sample/programs/javascript/simple.js:14:15",
"line": 14,
"name": "delta",
"selfWeight": 0.003094222474222382,
"totalWeight": 0.020112446082445484,
},
Frame {
"col": 15,
"file": "file:///speedscope/sample/programs/javascript/simple.js",
"key": "gamma:file:///speedscope/sample/programs/javascript/simple.js:20:15",
"line": 20,
"name": "gamma",
"selfWeight": 0.029395113505112636,
"totalWeight": 0.029395113505112636,
},
Frame {
"col": 14,
"file": "file:///speedscope/sample/programs/javascript/simple.js",
"key": "beta:file:///speedscope/sample/programs/javascript/simple.js:8:14",
"line": 8,
"name": "beta",
"selfWeight": 0,
"totalWeight": 0.012376889896889526,
},
Frame {
"col": 102,
"file": "",
"key": "firstOpenSearchURLString::4:102",
"line": 4,
"name": "firstOpenSearchURLString",
"selfWeight": 0.0005174240213818848,
"totalWeight": 0.0005174240213818848,
},
],
"name": "Grabación de Control temporal 1",
"stacks": Array [
"injectModule;(anonymous) 1.00ms",
" 39.93ms",
"(program);alpha;delta;gamma 10.83ms",
" 2.46ms",
"(program);alpha;delta 3.09ms",
"(program);alpha;beta;gamma 4.64ms",
"(program);alpha;delta;gamma 1.55ms",
"(program);alpha;beta;gamma 1.55ms",
"(program);alpha;delta;gamma 3.09ms",
"(program);alpha;beta;gamma 4.64ms",
"(program);alpha;delta;gamma 1.55ms",
"(program);alpha;beta;gamma 1.55ms",
" 253.50ms",
"firstOpenSearchURLString 517.42µs",
],
}
`;
exports[`importFromSafari: indexToView 1`] = `0`;
exports[`importFromSafari: profileGroup.name 1`] = `"Grabación de Control temporal 1"`;

View File

@ -15,6 +15,7 @@ import {importSpeedscopeProfiles} from '../lib/file-format'
import {importFromV8ProfLog} from './v8proflog'
import {importFromLinuxPerf} from './linux-tools-perf'
import {importFromHaskell} from './haskell'
import {importFromSafari} from './safari'
import {ProfileDataSource, TextProfileDataSource, MaybeCompressedDataReader} from './utils'
import {importAsPprofProfile} from './pprof'
import {decodeBase64} from '../lib/utils'
@ -131,6 +132,9 @@ async function _importProfileGroup(dataSource: ProfileDataSource): Promise<Profi
} else if (fileName.endsWith('.heapprofile')) {
console.log('Importing as Chrome Heap Profile')
return toGroup(importFromChromeHeapProfile(JSON.parse(contents)))
} else if (fileName.endsWith('-recording.json')) {
console.log('Importing as Safari profile')
return toGroup(importFromSafari(JSON.parse(contents)))
}
// Second pass: Try to guess what file format it is based on structure
@ -169,6 +173,9 @@ async function _importProfileGroup(dataSource: ProfileDataSource): Promise<Profi
} else if ('rts_arguments' in parsed && 'initial_capabilities' in parsed) {
console.log('Importing as Haskell GHC JSON Profile')
return importFromHaskell(parsed)
} else if ('recording' in parsed && 'sampleStackTraces' in parsed.recording) {
console.log('Importing as Safari profile')
return toGroup(importFromSafari(JSON.parse(contents)))
}
} else {
// Format is not JSON

View File

@ -0,0 +1,5 @@
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromSafari', async () => {
await checkProfileSnapshot('./sample/profiles/Safari/13.1/simple.html-recording.json')
})

120
src/import/safari.ts Normal file
View File

@ -0,0 +1,120 @@
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
import {TimeFormatter} from '../lib/value-formatters'
interface Record {
type: string
eventType?: string
startTime?: number
endTime?: number
// timeline-record-type-cpu
timestamp?: number
usage?: number
threads?: any[]
// timeline-record-type-script
details?: number | string | any
extraDetails?: null | any
// timeline-record-type-network
archiveStartTime?: number
entry?: any
// timeline-record-type-layout
quad?: number[]
}
interface ExprLocation {
line: number
column: number
}
interface StackFrame {
sourceID: string
name: string
line: number
column: number
url: string
expressionLocation?: ExprLocation
}
interface Sample {
timestamp: number
stackFrames: StackFrame[]
}
interface Recording {
displayName: string
startTime: number
endTime: number
discontinuities: any[]
instrumentTypes: string[]
records: Record[]
markers: any[]
memoryPressureEvents: any[]
sampleStackTraces: Sample[]
sampleDurations: number[]
}
interface Overview {
secondsPerPixel: number
scrollStartTime: number
selectionStartTime: number
selectionDuration: number
}
interface SafariProfile {
version: number
recording: Recording
overview: Overview
}
function makeStack(frames: StackFrame[]): FrameInfo[] {
return frames
.map(({name, url, line, column}) => ({
key: `${name}:${url}:${line}:${column}`,
file: url,
line,
col: column,
name: name || '(anonymous)',
}))
.reverse()
}
export function importFromSafari(contents: SafariProfile): Profile | null {
if (contents.version !== 1) {
console.warn(`Unknown Safari profile version ${contents.version}... Might be incompatible.`)
}
const {recording} = contents
const {sampleStackTraces, sampleDurations} = recording
const count = sampleStackTraces.length
if (count < 1) {
console.warn('Empty profile')
return null
}
const profileDuration =
sampleStackTraces[count - 1].timestamp - sampleStackTraces[0].timestamp + sampleDurations[0]
const profile = new StackListProfileBuilder(profileDuration)
let previousEndTime = Number.MAX_VALUE
sampleStackTraces.forEach((sample, i) => {
const endTime = sample.timestamp
const duration = sampleDurations[i]
const startTime = endTime - duration
const idleDurationBefore = startTime - previousEndTime
// FIXME: 2ms is a lot, but Safari's timestamps and durations don't line up very well and will create
// phantom idle time
if (idleDurationBefore > 0.002) {
profile.appendSampleWithWeight([], idleDurationBefore)
}
profile.appendSampleWithWeight(makeStack(sample.stackFrames), duration)
previousEndTime = endTime
})
profile.setValueFormatter(new TimeFormatter('seconds'))
profile.setName(recording.displayName)
return profile.build()
}