Compare commits

...
29 Commits
Author SHA1 Message Date
Jamie Wong 085d6298ec 0.6.0 2018-08-14 10:34:34 -07:00
Jamie Wong aa35b17d20 Downgrade to TypeScript 2.8.1 to fix JSON schema generation (#133)
Fixes #132
2018-08-14 09:37:42 -07:00
Jamie Wong a22ab9301f Add tests for redux store (#131)
This adds some baseline tests for the redux store, and also fixes some bugs I found along the way.

Bugs fixed:
- Changing selection after something has been selected within the sandwich view now works
- Table sort state is now preserved when switching between profiles

Fixes #124
2018-08-13 10:25:01 -07:00
Jamie Wong e404053837 Add support for profiles w/ multiples processes & threads (#130)
More broadly, this just supports multiple profiles loaded into the editor in the same time, which supports import from profiles which are multithreaded by importing each thread as a different profile.

For now, the only two file formats that support multiprocess import are Instruments .trace files and speedscope's own file format

In the process of doing this, I refactored the container code considerably and extracted all the dispatch calls into containers rather than them being part of the non-container view code. This is nice because it means that views don't have to be aware of which Flamechart they are or which profile index is being operated upon.

Fixes #66
Fixes #82
Fixes #91
2018-08-11 22:06:53 -07:00
Jamie Wong 558e98d24d 0.5.1 2018-08-11 22:05:39 -07:00
Jamie Wong 7368e15396 0.5.0 2018-08-09 09:38:55 -07:00
Jamie Wong 1bbe3de944 Update CHANGELOG.md 2018-08-09 09:27:08 -07:00
Jamie Wong 8e09be7f7f Update CHANGELOG.md 2018-08-09 09:26:26 -07:00
Jamie Wong 5ee94ec092 Support emscripten symbols containing dashes (#129)
Apparently emscripten symbols are allowed to contain dashes?

e.g. here's a line from a mapping that failed to import before this PR: `527:i32s-div`
2018-08-09 09:25:27 -07:00
Jamie Wong 4d23f1cdee Update CHANGELOG.md 2018-08-08 19:20:58 -07:00
Jamie Wong fc9260ba1d More firefox import fixes (#128) 2018-08-08 17:15:33 -07:00
Jamie Wong 182563dc7c Scroll selected node into sandwich view on mount (#126) 2018-08-08 08:37:54 -07:00
Jamie Wong e6d9994801 Update CHANGELOG.md 2018-08-07 18:42:56 -07:00
Jamie Wong 29c55c3921 Split CallTreeNodes that are non-contiguous (#123)
This PR exposed some bugs in the Firefox importer, which is also fixed in this PR.

Fixes #86
2018-08-07 18:41:56 -07:00
Jamie Wong 0b1050034f Update CHANGELOG.md 2018-08-05 14:12:31 -07:00
Jamie Wong a79beaa731 Ignore click event following a drag (#122) 2018-08-05 14:10:15 -07:00
Jamie Wong bca9c52ac9 Clamp zoom to prevent floating point issues (#121) 2018-08-05 13:57:29 -07:00
Jamie Wong 970f1b2107 Fix use of incorrect viewport size (#120) 2018-08-05 11:08:43 -07:00
Jamie Wong 72c05fe6ba Backfill some more tests (#112)
* Add tests for getHashParams

* Add tests for Color

* Cover a few more lines
2018-08-01 11:42:55 -07:00
Jamie Wong ee157c4139 Add a simple test for demangleCpp (#111) 2018-08-01 09:55:40 -07:00
Jamie Wong 94d0879417 Upgrade to TypeScript 3.0.1 (#110)
Fixes #103
2018-08-01 01:23:21 -07:00
Jamie Wong 608006232f Reorganize directory structure (#104)
While it was kind of nice having everything at the top level, the number of files is now getting a bit unwieldy and hard to understand, so I took a stab at organizing the directories without introducing too much nesting.

Test Plan:
- Ran `npm run serve` to ensure that local builds still work
- Ran `npm run prepack` then `open dist/release/index.html` to ensure that release builds still work
- Ran `scripts/deploy.sh` to ensure that the deployed version of the site will still work when I eventually redeploy
- Ran `npm run jest` to ensure that tests still work correctly
2018-08-01 00:41:45 -07:00
Jamie Wong 11a3a67dce Switch to redux for global state management (#100)
This should allow state to be more easily retained globally when switching views, and should make implementation of cross-view features like search easier too.

It also removes the need for `ReloadableComponent`

Fixes #78

Test Plan:
This changes a lot of how the app works, and a lot of stuff that isn't currently covered by tests, so here's a rough manual test plan:

1. Loaded up http://localhost:1234/, click to load the example profile
2. Switch between views, see that viewport & selection position is now retained when switching views
3. See that clicking on nodes selects them in Time Order & Left Heavy views
4. See that hitting Cmd+S saves a profile
5. See that dropping a profile in works
6. See that dropping a profile + a symbol map works
7. See that visiting localhost:1234/#profileURL=https://raw.githubusercontent.com/jlfwong/speedscope/master/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json works
8. See that hitting "r" to toggle recursion flattening works
2018-07-31 23:53:56 -07:00
Jamie Wong 2f95f77fcf 0.4.0 2018-07-21 19:44:57 -07:00
Jamie Wong fcc6808054 Optionally read from stdin via cli (#99)
Test Plan:
- `./cli.js` opens speedscope in browser with no file selected
- `./cli.js sample/profiles/Chrome/65/simple-timeline.json` opens profile
- `cat sample/profiles/Chrome/65/simple-timeline.json | ./cli.js -` opens profile
- `node --prof-process --preprocess -j sample/profiles/node/8.5.0/isolate-0x102802600-v8.log | ./cli.js -` opens profile

Fixes #95
2018-07-21 19:42:58 -07:00
Jamie Wong ea8f982c10 Import from node profiles via v8 logs (#98)
This is inspired by https://github.com/mapbox/flamebearer
2018-07-21 16:00:25 -07:00
Jamie Wong fb5d148780 Add tests ensuring that import still works even for unfamiliar filenames (#96)
When importing files, there are two different paths we use for determining the file format.

The first is to pattern match on the filename.

If that fails, we fall back to examining the structure of the file, which can be slower and therefore wasteful.

Before this PR, we only tests that the filename pattern matching was correctly identifying the format. This PR ensures that our file structure matching is working correctly too.
2018-07-18 23:44:31 -07:00
Jamie Wong d7969ac2b8 0.3.0 2018-07-18 08:54:53 -07:00
Evan Wallace 1b36a2e3f4 add support for "wasm-function" symbol maps (#93)
This is an improvement to #76, which added support for asm.js symbol maps. This PR expands this to also work for emscripten's WebAssembly symbol maps too. This currently only works in Firefox. Chrome would need to fix https://crbug.com/863205 for this to be useful in Chrome.
2018-07-18 08:47:58 -07:00
119 changed files with 6614 additions and 1481 deletions
+46
View File
@@ -0,0 +1,46 @@
## [Unreleased]
### Added
### Fixed
## [0.6.0]
### Added
* Added support for multiple threads/processes [#130]
* Import all runs & threads from Instruments .trace files instead of just main thread from selected run [#130]
### Fixed
* Ensure the JSON schema has actual contents [#133]
## [0.5.1] - 2018-08-09
### Fixed
* Fixed broken CLI
## [0.5.0] - 2018-08-09
### Fixed
* Fix emscripten remapping when symbols contain dashes, like `527:i32s-div` [#129]
* Improved firefox import speed and fixed bugs in it [#128]
* Prevent non-contiguous blocks in the time ordered flamechart from appearing as a single node for selection [#123]
* Prevent dragging from changing selection [#122]
* Clamp zoom to prevent floating point issues [#121]
* Preserve view state when switching tabs [#100]
## [0.4.0] - 2018-07-21
### Added
* Support for importing v8 logs from node [#98]
* Optionally read from stdin via cli [#99]
## [0.3.0] - 2018-07-18
### Added
* Support for remapping profiles using a wasm symbol file [#93]
+72
View File
@@ -0,0 +1,72 @@
This document describes processes needed by admins of this repository.
# Publishing
Publishing speedscope is a multi-step process:
1. Test the release
2. Prepare the release
3. Publish to npm
4. Deploy the website
At time of writing, deployment assumes you're running macOS. It probably
works if you're on a linux, and almost definitely does not work on Windows.
## Test the release
Speedscope is tested in CI, so all the automated tests should be passing. We'll
just be doing a few sanity checks to make sure the build & deployment machinery is working correctly.
Run `scripts/prepare-test-installation.sh`. This will do a mock publish &
installation to ensure that the version we're about to publish is going to
work. At the end of this command, it should echo a `cd` command to run in your shell
to switch to the installation directory. Something like this:
```
Run the following command to switch into the test directory
cd /var/folders/l0/qtd9z14973s2tw81vmzwkyp00000gp/T/speedscope-test-installation.9Ssdd2PZ/package
```
Run this command, to switch to the test directory.
Inside of here, run `bin/cli.js`. This should open a copy of speedscope in browser.
Try importing a profile from disk via the browse button and make sure it works.
Next, try running `bin/cli.js dist/release/perf-vertx*`. This should immediately open
speedscope in browser, and the perf-vertx file should load immediately.
If everything looks good, proceed to "Prepare the release".
## Prepare the release
1. Update the version manually in package.json (we intentionally don't use the `npm version` command)
2. Update CHANGELOG.md to indicate the changes that were made as part of this release
3. Commit the changes with the version name as the commit message, e.g. `git commit -m 0.6.0`
4. `git tag` the release. We use tags like `v0.6.0`, e.g. `git tag v0.6.0`
5. `git push`
## Publish to npm
Assuming everything went well in the previous two phases, publishing should just be
a matter of running `npm publish`.
## Deploying the website
https://www.speedscope.app/ is hosted on GitHub pages, and is published via pushing
to the `gh-pages` branch. The `gh-pages` branch has totally different contents than
other branches of this repository: https://github.com/jlfwong/speedscope/tree/gh-pages.
It's populated by a deploy script which is invoked by running `npm run deploy` script.
This will do a build of the static resources, and boot a local server for you to test
the compiled assets. Please do not skip the manual testing in this step.
If everything looks good, you should be able to hit Ctrl+C, and you should see this prompt:
```
Commit release? [yes/no]:
```
If everything looks good, type `yes` then enter. This will commit to the `gh-pages` branch, and the site should automatically deploy shortly after.
To check if a deploy has happened, you can check https://www.speedscope.app/release.txt
which includes the version, the date, and the commit of the deploy.
+13
View File
@@ -31,6 +31,17 @@ https://medium.com/@paul_irish/debugging-node-js-nightlies-with-chrome-devtools-
The `profile.json` file format output by Firefox can be saved and import into speedscope: https://developer.mozilla.org/en-US/docs/Tools/Performance
### Node
If you record profiling information like so:
node --prof /path/to/my/script.js
Then this will generate one or more `isolate*.log` files. You can open
the resulting profile in speedscope by running the following command:
node --prof-process --preprocess -j isolate*.log | speedscope -
### Instruments.app
You can import call trees from OSX Instruments.app into speedscope by
@@ -107,3 +118,5 @@ Once a profile has loaded, the main view is split into two: the top area is the
* `r`: Collapse recursion in the flamegraphs
* `Cmd+S`/`Ctrl+S` to save the current profile
* `Cmd+O`/`Ctrl+O` to open a new profile
* `n`: Go to next profile/thread if one is available
* `p`: Go to previous profile/thread if one is available
-99
View File
@@ -1,99 +0,0 @@
// 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",
],
}
`;
-26
View File
@@ -1,26 +0,0 @@
type AsmJsSymbolMap = Map<string, string>
// This imports symbol maps generated by emscripten using the "--emit-symbol-map" flag.
// It allows you to visualize a profile captured in a release build as long as you also
// have the associated symbol map. To do this, first drop the profile into speedscope
// and then drop the symbol map. After the second drop, the symbols will be remapped to
// their original names.
export function importAsmJsSymbolMap(contents: string): AsmJsSymbolMap | null {
const lines = contents.split('\n')
if (!lines.length) return null
// Remove a trailing blank line if there is one
if (lines[lines.length - 1] === '') lines.pop()
if (!lines.length) return null
const map: AsmJsSymbolMap = new Map()
const regex = /^([\$\w]+):([\$\w]+)$/
for (const line of lines) {
const match = regex.exec(line)
if (!match) return null
map.set(match[1], match[2])
}
return map
}
View File
+1 -2
View File
@@ -166,8 +166,7 @@
</head>
<body>
<div id="root"></div>
<script src="./speedscope.tsx"></script>
<script src="../src/speedscope.tsx"></script>
</body>
</html>
View File
Executable
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
const path = require('path')
const fs = require('fs')
const os = require('os')
const stream = require('stream')
const opn = require('opn')
const helpString = `Usage: speedscope [filepath]
If invoked with no arguments, will open a local copy of speedscope in your default browser.
Once open, you can browse for a profile to import.
If - is used as the filepath, will read from stdin instead.
cat /path/to/profile | speedscope -
`
function getProfileStream(relPath) {
const absPath = path.resolve(process.cwd(), relPath)
if (relPath === '-') {
// Read from stdin
return process.stdin
} else {
return fs.createReadStream(absPath)
}
}
function getProfileBuffer(relPath) {
const profileStream = getProfileStream(relPath)
const chunks = []
return new Promise((resolve, reject) => {
profileStream.pipe(
stream.Writable({
write(chunk, encoding, callback) {
chunks.push(chunk)
callback()
},
final() {
resolve(Buffer.concat(chunks))
},
}),
)
profileStream.on('error', ev => reject(ev))
})
}
async function main() {
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(helpString)
return
}
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log('v' + require('../package.json').version)
return
}
if (process.argv.length > 3) {
throw new Error('At most one argument expected')
}
let urlToOpen = 'file://' + path.resolve(__dirname, '../dist/release/index.html')
if (process.argv.length === 3) {
const relPath = process.argv[2]
const sourceBuffer = await getProfileBuffer(relPath)
const filename = path.basename(relPath)
const sourceBase64 = sourceBuffer.toString('base64')
const jsSource = `speedscope.loadFileFromBase64(${JSON.stringify(filename)}, ${JSON.stringify(
sourceBase64,
)})`
const filePrefix = `speedscope-${+new Date()}-${process.pid}`
const jsPath = path.join(os.tmpdir(), `${filePrefix}.js`)
console.log(`Creating temp file ${jsPath}`)
fs.writeFileSync(jsPath, jsSource)
urlToOpen += `#localProfilePath=${jsPath}`
// For some silly reason, the OS X open command ignores any query parameters or hash parameters
// passed as part of the URL. To get around this weird issue, we'll create a local HTML file
// that just redirects.
const htmlPath = path.join(os.tmpdir(), `${filePrefix}.html`)
console.log(`Creating temp file ${htmlPath}`)
fs.writeFileSync(htmlPath, `<script>window.location=${JSON.stringify(urlToOpen)}</script>`)
urlToOpen = `file://${htmlPath}`
}
console.log('Opening', urlToOpen, 'in your default browser')
await opn(urlToOpen, {wait: false})
}
main()
.then(() => {
process.exit(0)
})
.catch(e => {
console.log(e.stack + '\n')
console.log(helpString)
process.exit(1)
})
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env node
const path = require('path')
const fs = require('fs')
const os = require('os')
const opn = require('opn')
const helpString = `
Usage: speedscope [filepath]
If invoked with no arguments, will open a local copy of speedscope in your default browser.
Once open, you can browse for a profile to import.
`
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(helpString)
process.exit(0)
}
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log('v' + require('./package.json').version)
process.exit(0)
}
if (process.argv.length > 3) {
console.log('At most one argument expected')
console.log(helpString)
process.exit(1)
}
let urlToOpen = 'file://' + path.resolve(__dirname, './dist/release/index.html')
if (process.argv.length === 3) {
const absPath = path.resolve(process.cwd(), process.argv[2])
let sourceBuffer
try {
sourceBuffer = fs.readFileSync(absPath)
} catch (e) {
console.log(e)
console.log(helpString)
process.exit(1)
}
const filename = path.basename(absPath)
const sourceBase64 = sourceBuffer.toString('base64')
const jsSource = `speedscope.loadFileFromBase64(${JSON.stringify(filename)}, ${JSON.stringify(
sourceBase64,
)})`
const filePrefix = `speedscope-${+new Date()}-${process.pid}`
const jsPath = path.join(os.tmpdir(), `${filePrefix}.js`)
console.log(`Creating temp file ${jsPath}`)
fs.writeFileSync(jsPath, jsSource)
urlToOpen += `#localProfilePath=${jsPath}`
// For some silly reason, the OS X open command ignores any query parameters or hash parameters
// passed as part of the URL. To get around this weird issue, we'll create a local HTML file
// that just redirects.
const htmlPath = path.join(os.tmpdir(), `${filePrefix}.html`)
console.log(`Creating temp file ${htmlPath}`)
fs.writeFileSync(htmlPath, `<script>window.location=${JSON.stringify(urlToOpen)}</script>`)
urlToOpen = `file://${htmlPath}`
}
console.log('Opening', urlToOpen, 'in your default browser')
opn(urlToOpen, {wait: false}).then(
() => {
process.exit(0)
},
err => {
console.error(err)
console.exit(1)
},
)
-59
View File
@@ -1,59 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`importFromFirefox 1`] = `
Object {
"frames": Array [
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "a (file:///Users/jlfwong/code/speedscope/sample/simple.js:1)",
"line": 1,
"name": "a",
"selfWeight": 0,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "b (file:///Users/jlfwong/code/speedscope/sample/simple.js:8)",
"line": 8,
"name": "b",
"selfWeight": 0,
"totalWeight": 20.971447000018088,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "d (file:///Users/jlfwong/code/speedscope/sample/simple.js:20)",
"line": 20,
"name": "d",
"selfWeight": 31.992820999992546,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "c (file:///Users/jlfwong/code/speedscope/sample/simple.js:14)",
"line": 14,
"name": "c",
"selfWeight": 0,
"totalWeight": 11.021373999974458,
},
],
"stacks": Array [
"a;b;d 989.53µs",
"a;c;d 1.02ms",
"a;b;d 982.04µs",
"a;c;d 1.02ms",
"a;b;d 7.00ms",
"a;c;d 2.00ms",
"a;b;d 981.44µs",
"a;c;d 2.00ms",
"a;b;d 2.00ms",
"a;c;d 3.02ms",
"a;b;d 4.03ms",
"a;c;d 1.96ms",
"a;b;d 4.99ms",
],
}
`;
-5
View File
@@ -1,5 +0,0 @@
import {checkProfileSnapshot} from '../test-utils'
test('importFromFirefox', async () => {
await checkProfileSnapshot('./sample/profiles/Firefox/59/simple-firefox.json')
})
+37 -10
View File
@@ -1,6 +1,6 @@
{
"name": "speedscope",
"version": "0.2.0",
"version": "0.6.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -4555,7 +4555,8 @@
},
"fsevents": {
"version": "1.1.3",
"resolved": "",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.1.3.tgz",
"integrity": "sha512-WIr7iDkdmdbxu/Gh6eKEZJL6KPE74/5MEsf2whTOFNxbIoIixogroLdKYqB6FDav4Wavh/lZdzzd3b2KxIXC5Q==",
"dev": true,
"optional": true,
"requires": {
@@ -4767,7 +4768,8 @@
},
"debug": {
"version": "2.6.8",
"resolved": "",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz",
"integrity": "sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=",
"dev": true,
"optional": true,
"requires": {
@@ -5285,7 +5287,8 @@
},
"rc": {
"version": "1.2.1",
"resolved": "",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.1.tgz",
"integrity": "sha1-LgPo5C7kULjLPc5lvhv4l04d/ZU=",
"dev": true,
"optional": true,
"requires": {
@@ -5397,7 +5400,8 @@
},
"sshpk": {
"version": "1.13.0",
"resolved": "",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.0.tgz",
"integrity": "sha1-/yo+T9BEl1Vf7Zezmg/YL6+zozw=",
"dev": true,
"optional": true,
"requires": {
@@ -5443,7 +5447,8 @@
},
"stringstream": {
"version": "0.0.5",
"resolved": "",
"resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
"integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=",
"dev": true,
"optional": true
},
@@ -5493,7 +5498,8 @@
},
"tough-cookie": {
"version": "2.3.2",
"resolved": "",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz",
"integrity": "sha1-8IH3bkyFcg5sN6X6ztc3FQ2EByo=",
"dev": true,
"optional": true,
"requires": {
@@ -10611,6 +10617,11 @@
"integrity": "sha512-m34Ke8U32HyKRVzUOCAcaiIBLR2ye6syiuRclU5DxyixDPDFqdLbIElhERBrF6gDbPKQR+Vpv5bZ9CCbvN6pdQ==",
"dev": true
},
"preact-redux": {
"version": "github:jlfwong/preact-redux#a56dcc460f4993c8dd33b7c9caa5e4bde1fa72dd",
"from": "github:jlfwong/preact-redux#a56dcc4",
"dev": true
},
"prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@@ -11012,6 +11023,16 @@
}
}
},
"redux": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.0.tgz",
"integrity": "sha512-NnnHF0h0WVE/hXyrB6OlX67LYRuaf/rJcbWvnHHEPCF/Xa/AZpwhs/20WyqzQae5x4SD2F9nPObgBh2rxAgLiA==",
"dev": true,
"requires": {
"loose-envify": "^1.1.0",
"symbol-observable": "^1.2.0"
}
},
"regenerate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
@@ -12728,6 +12749,12 @@
}
}
},
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
"dev": true
},
"symbol-tree": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz",
@@ -13366,9 +13393,9 @@
"dev": true
},
"typescript-eslint-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/typescript-eslint-parser/-/typescript-eslint-parser-14.0.0.tgz",
"integrity": "sha512-xDvhgZX08RRKSyz8oWXI/V9gjXelh4/a9jGFmmHnn/j/GMXKOQ6E6NAGVjQSvGBrjT/TMobKqsrxnBcVr4sfIw==",
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/typescript-eslint-parser/-/typescript-eslint-parser-17.0.1.tgz",
"integrity": "sha512-EPCOjxjnGqu9kQwH3CAwa1Jjty2Dn0FnehksVEmfZxXtkEK2Jerb2lnd/htsj1XooqEkKC5AzCaK+qOxHaBDBQ==",
"dev": true,
"requires": {
"lodash.unescape": "4.0.1",
+16 -29
View File
@@ -1,30 +1,24 @@
{
"name": "speedscope",
"version": "0.2.0",
"version": "0.6.0",
"description": "",
"repository": "jlfwong/speedscope",
"main": "index.js",
"bin": {
"speedscope": "./cli.js"
"speedscope": "./bin/cli.js"
},
"scripts": {
"deploy": "./deploy.sh",
"prepack": "./build-release.sh",
"prettier": "prettier --write './**/*.ts' './**/*.tsx'",
"lint": "eslint './**/*.ts' './**/*.tsx'",
"jest": "jest",
"deploy": "./scripts/deploy.sh",
"prepack": "./scripts/build-release.sh",
"prettier": "prettier --write 'src/**/*.ts' 'src/**/*.tsx'",
"lint": "eslint 'src/**/*.ts' 'src/**/*.tsx'",
"jest": "./scripts/test-setup.sh && jest",
"coverage": "npm run jest -- --coverage && coveralls < coverage/lcov.info",
"test": "tsc --noEmit && npm run lint && npm run coverage",
"serve": "parcel index.html --open --no-autoinstall"
"serve": "parcel assets/index.html --open --no-autoinstall"
},
"files": [
"cli.js",
"dist/release/**",
"!*.map"
],
"browserslist": [
"last 2 Chrome versions",
"last 2 Firefox versions"
],
"files": ["bin/cli.js", "dist/release/**", "!*.map"],
"browserslist": ["last 2 Chrome versions", "last 2 Firefox versions"],
"author": "",
"license": "MIT",
"devDependencies": {
@@ -42,12 +36,14 @@
"pako": "1.0.6",
"parcel-bundler": "1.9.2",
"preact": "8.2.7",
"preact-redux": "jlfwong/preact-redux#a56dcc4",
"prettier": "1.12.0",
"quicktype": "15.0.45",
"redux": "^4.0.0",
"regl": "1.3.1",
"ts-jest": "22.4.6",
"typescript": "2.8.1",
"typescript-eslint-parser": "14.0.0",
"typescript-eslint-parser": "17.0.1",
"uglify-es": "3.2.2"
},
"jest": {
@@ -55,17 +51,8 @@
"^.+\\.tsx?$": "ts-jest"
},
"testRegex": "\\.test\\.tsx?$",
"collectCoverageFrom": [
"**/*.{ts,tsx}",
"!**/*.d.{ts,tsx}"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json"
]
"collectCoverageFrom": ["**/*.{ts,tsx}", "!**/*.d.{ts,tsx}"],
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json"]
},
"dependencies": {
"opn": "5.3.0"
-40
View File
@@ -1,40 +0,0 @@
import {Component} from 'preact'
export interface SerializedComponent<S> {
state: S
serializedSubcomponents: {[key: string]: any}
}
export abstract class ReloadableComponent<P, S> extends Component<P, S> {
serialize(): SerializedComponent<S> {
const serializedSubcomponents: {[key: string]: any} = Object.create(null)
const subcomponents = this.subcomponents()
for (const key in subcomponents) {
const val = subcomponents[key]
if (val && val instanceof ReloadableComponent) {
serializedSubcomponents[key] = val.serialize()
}
}
return {
state: this.state,
serializedSubcomponents,
}
}
rehydrate(serialized: SerializedComponent<S>) {
this.setState(serialized.state, () => {
const subcomponents = this.subcomponents()
for (const key in subcomponents) {
const val = subcomponents[key]
const data = serialized.serializedSubcomponents[key]
if (data && val && val instanceof ReloadableComponent) {
val.rehydrate(data)
}
}
})
}
subcomponents(): {[key: string]: any} {
return Object.create(null)
}
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
*.v8log.json
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -0,0 +1,29 @@
{
"exporter": "speedscope@0.6.0",
"$schema": "https://www.speedscope.app/file-format-schema.json",
"name": "Two Samples",
"activeProfileIndex": 1,
"profiles": [
{
"type": "sampled",
"name": "one",
"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]
},
{
"type": "sampled",
"name": "two",
"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"}]
}
}
@@ -0,0 +1 @@
<script src="./recursion.js"></script>
+41
View File
@@ -0,0 +1,41 @@
function alpha(depth) {
if (depth > 15 * Math.random()) {
return
}
beta(depth + 1)
delta()
gamma()
}
function beta(depth) {
alpha(depth + 1)
for (let i = 0; i < 10; i++) {
gamma()
}
}
function delta() {
for (let i = 0; i < 10; i++) {
gamma()
}
}
function gamma() {
let prod = 1
for (let i = 1; i < 1000; i++) {
prod *= i
}
return prod
}
function main() {
for (let i = 0; i < 100; i++) {
alpha(0)
}
}
console.profile('recursion')
main()
setTimeout(() => {
console.profileEnd('recursion')
}, 0)
+11 -13
View File
@@ -1,23 +1,23 @@
function a() {
function alpha() {
for (let i = 0; i < 1000; i++) {
b()
c()
beta()
delta()
}
}
function b() {
function beta() {
for (let i = 0; i < 10; i++) {
d()
gamma()
}
}
function c() {
function delta() {
for (let i = 0; i < 10; i++) {
d()
gamma()
}
}
function d() {
function gamma() {
let prod = 1
for (let i = 1; i < 1000; i++) {
prod *= i
@@ -25,10 +25,8 @@ function d() {
return prod
}
console.profile('a')
a()
console.profile('simple')
alpha()
setTimeout(() => {
console.profileEnd('a')
console.profileEnd('simple')
}, 0)
window.addEventListener('click', a)
-345
View File
@@ -1,345 +0,0 @@
import {ReloadableComponent} from './reloadable'
import {Profile, Frame, CallTreeNode} from './profile'
import {StyleSheet, css} from 'aphrodite'
import {SortMethod, ProfileTableView} from './profile-table-view'
import {h} from 'preact'
import {commonStyle, Sizes, Colors, FontSize} from './style'
import {CanvasContext} from './canvas-context'
import {FlamechartRenderer, FlamechartRowAtlasKey} from './flamechart-renderer'
import {Flamechart} from './flamechart'
import {RowAtlas} from './row-atlas'
import {Rect, AffineTransform, Vec2, clamp} from './math'
import {FlamechartPanZoomView, FlamechartPanZoomViewProps} from './flamechart-pan-zoom-view'
import {noop, formatPercent} from './utils'
import {Hovertip} from './hovertip'
interface FlamechartWrapperProps {
flamechart: Flamechart
canvasContext: CanvasContext
flamechartRenderer: FlamechartRenderer
renderInverted: boolean
}
interface FlamechartWrapperState {
hover: {
node: CallTreeNode
event: MouseEvent
} | null
configSpaceViewportRect: Rect
}
export class FlamechartWrapper extends ReloadableComponent<
FlamechartWrapperProps,
FlamechartWrapperState
> {
constructor(props: FlamechartWrapperProps) {
super(props)
this.state = {
hover: null,
configSpaceViewportRect: Rect.empty,
}
}
private clampViewportToFlamegraph(viewportRect: Rect, flamegraph: Flamechart, inverted: boolean) {
const configSpaceSize = new Vec2(flamegraph.getTotalWeight(), flamegraph.getLayers().length)
const width = clamp(
viewportRect.size.x,
Math.min(configSpaceSize.x, 3 * flamegraph.getMinFrameWidth()),
configSpaceSize.x,
)
const size = viewportRect.size.withX(width)
const origin = Vec2.clamp(
viewportRect.origin,
new Vec2(0, inverted ? 0 : -1),
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
)
return new Rect(origin, viewportRect.size.withX(width))
}
private setConfigSpaceViewportRect = (viewportRect: Rect) => {
this.setState({
configSpaceViewportRect: this.clampViewportToFlamegraph(
viewportRect,
this.props.flamechart,
this.props.renderInverted,
),
})
}
private transformViewport = (transform: AffineTransform) => {
this.setConfigSpaceViewportRect(transform.transformRect(this.state.configSpaceViewportRect))
}
private formatValue(weight: number) {
const totalWeight = this.props.flamechart.getTotalWeight()
const percent = 100 * weight / totalWeight
const formattedPercent = formatPercent(percent)
return `${this.props.flamechart.formatValue(weight)} (${formattedPercent})`
}
private renderTooltip() {
if (!this.container) return null
const {hover} = this.state
if (!hover) return null
const {width, height, left, top} = this.container.getBoundingClientRect()
const offset = new Vec2(hover.event.clientX - left, hover.event.clientY - top)
return (
<Hovertip containerSize={new Vec2(width, height)} offset={offset}>
<span className={css(style.hoverCount)}>
{this.formatValue(hover.node.getTotalWeight())}
</span>{' '}
{hover.node.frame.name}
</Hovertip>
)
}
container: HTMLDivElement | null = null
containerRef = (container?: Element) => {
this.container = (container as HTMLDivElement) || null
}
private setNodeHover = (hover: {node: CallTreeNode; event: MouseEvent} | null) => {
this.setState({hover})
}
render() {
const props: FlamechartPanZoomViewProps = {
...(this.props as FlamechartWrapperProps),
selectedNode: null,
onNodeHover: this.setNodeHover,
onNodeSelect: noop,
configSpaceViewportRect: this.state.configSpaceViewportRect,
setConfigSpaceViewportRect: this.setConfigSpaceViewportRect,
transformViewport: this.transformViewport,
}
return (
<div
className={css(commonStyle.fillY, commonStyle.fillX, commonStyle.vbox)}
ref={this.containerRef}
>
<FlamechartPanZoomView {...props} />
{this.renderTooltip()}
</div>
)
}
}
interface SandwichViewProps {
profile: Profile
flattenRecursion: boolean
// TODO(jlfwong): It's kind of awkward requiring both of these
getColorBucketForFrame: (frame: Frame) => number
getCSSColorForFrame: (frame: Frame) => string
sortMethod: SortMethod
setSortMethod: (sortMethod: SortMethod) => void
canvasContext: CanvasContext
rowAtlas: RowAtlas<FlamechartRowAtlasKey>
}
interface CallerCalleeState {
selectedFrame: Frame
invertedCallerFlamegraph: Flamechart
invertedCallerFlamegraphRenderer: FlamechartRenderer
calleeFlamegraph: Flamechart
calleeFlamegraphRenderer: FlamechartRenderer
}
interface SandwichViewState {
callerCallee: CallerCalleeState | null
}
export class SandwichView extends ReloadableComponent<SandwichViewProps, SandwichViewState> {
constructor(props: SandwichViewProps) {
super(props)
this.state = {
callerCallee: null,
}
}
private setSelectedFrame = (
selectedFrame: Frame | null,
props: SandwichViewProps = this.props,
) => {
const {profile, canvasContext, rowAtlas, getColorBucketForFrame, flattenRecursion} = props
if (!selectedFrame) {
this.setState({callerCallee: null})
return
}
let invertedCallerProfile = profile.getInvertedProfileForCallersOf(selectedFrame)
if (flattenRecursion) {
invertedCallerProfile = invertedCallerProfile.getProfileWithRecursionFlattened()
}
const invertedCallerFlamegraph = new Flamechart({
getTotalWeight: invertedCallerProfile.getTotalNonIdleWeight.bind(invertedCallerProfile),
forEachCall: invertedCallerProfile.forEachCallGrouped.bind(invertedCallerProfile),
formatValue: invertedCallerProfile.formatValue.bind(invertedCallerProfile),
getColorBucketForFrame,
})
const invertedCallerFlamegraphRenderer = new FlamechartRenderer(
canvasContext,
rowAtlas,
invertedCallerFlamegraph,
{inverted: true},
)
let calleeProfile = profile.getProfileForCalleesOf(selectedFrame)
if (flattenRecursion) {
calleeProfile = calleeProfile.getProfileWithRecursionFlattened()
}
const calleeFlamegraph = new Flamechart({
getTotalWeight: calleeProfile.getTotalNonIdleWeight.bind(calleeProfile),
forEachCall: calleeProfile.forEachCallGrouped.bind(calleeProfile),
formatValue: calleeProfile.formatValue.bind(calleeProfile),
getColorBucketForFrame,
})
const calleeFlamegraphRenderer = new FlamechartRenderer(
canvasContext,
rowAtlas,
calleeFlamegraph,
)
this.setState({
callerCallee: {
selectedFrame,
invertedCallerFlamegraph,
invertedCallerFlamegraphRenderer,
calleeFlamegraph,
calleeFlamegraphRenderer,
},
})
}
onWindowKeyPress = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
this.setState({callerCallee: null})
}
}
componentWillReceiveProps(nextProps: SandwichViewProps) {
if (this.props.flattenRecursion !== nextProps.flattenRecursion) {
if (this.state.callerCallee) {
this.setSelectedFrame(this.state.callerCallee.selectedFrame, nextProps)
}
}
}
componentDidMount() {
window.addEventListener('keydown', this.onWindowKeyPress)
}
componentWillUnmount() {
window.removeEventListener('keydown', this.onWindowKeyPress)
}
render() {
const {canvasContext} = this.props
const {callerCallee} = this.state
let selectedFrame: Frame | null = null
let flamegraphViews: JSX.Element | null = null
if (callerCallee) {
selectedFrame = callerCallee.selectedFrame
flamegraphViews = (
<div className={css(commonStyle.fillY, style.callersAndCallees, commonStyle.vbox)}>
<div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
<div className={css(style.flamechartLabelParent)}>
<div className={css(style.flamechartLabel)}>Callers</div>
</div>
<FlamechartWrapper
flamechart={callerCallee.invertedCallerFlamegraph}
canvasContext={canvasContext}
flamechartRenderer={callerCallee.invertedCallerFlamegraphRenderer}
renderInverted={true}
/>
</div>
<div className={css(style.divider)} />
<div className={css(commonStyle.hbox, style.panZoomViewWraper)}>
<div className={css(style.flamechartLabelParent, style.flamechartLabelParentBottom)}>
<div className={css(style.flamechartLabel, style.flamechartLabelBottom)}>Callees</div>
</div>
<FlamechartWrapper
flamechart={callerCallee.calleeFlamegraph}
canvasContext={canvasContext}
flamechartRenderer={callerCallee.calleeFlamegraphRenderer}
renderInverted={false}
/>
</div>
</div>
)
}
return (
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableView
selectedFrame={selectedFrame}
setSelectedFrame={this.setSelectedFrame}
profile={this.props.profile}
getCSSColorForFrame={this.props.getCSSColorForFrame}
sortMethod={this.props.sortMethod}
setSortMethod={this.props.setSortMethod}
/>
</div>
{flamegraphViews}
</div>
)
}
}
const style = StyleSheet.create({
tableView: {
flex: 1,
},
panZoomViewWraper: {
flex: 1,
},
flamechartLabelParent: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
alignItems: 'flex-start',
fontSize: FontSize.TITLE,
width: FontSize.TITLE * 1.2,
borderRight: `1px solid ${Colors.LIGHT_GRAY}`,
},
flamechartLabelParentBottom: {
justifyContent: 'flex-start',
},
flamechartLabel: {
transform: 'rotate(-90deg)',
transformOrigin: '50% 50% 0',
width: FontSize.TITLE * 1.2,
flexShrink: 1,
},
flamechartLabelBottom: {
transform: 'rotate(-90deg)',
display: 'flex',
justifyContent: 'flex-end',
},
callersAndCallees: {
flex: 1,
borderLeft: `${Sizes.SEPARATOR_HEIGHT}px solid ${Colors.LIGHT_GRAY}`,
},
divider: {
height: 2,
background: Colors.LIGHT_GRAY,
},
hoverCount: {
color: Colors.GREEN,
},
})
@@ -1,7 +1,7 @@
#!/bin/bash
# Fail on first error
set -e
set -euxo pipefail
OUTDIR=`pwd`/dist/release
# Typecheck
@@ -12,11 +12,12 @@ rm -rf "$OUTDIR"
mkdir -p "$OUTDIR"
# Place info about the current commit into the build dir to easily identify releases
date > "$OUTDIR"/release.txt
npm ls -depth -1 | head -n 1 | cut -d' ' -f 1 > "$OUTDIR"/release.txt
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
node scripts/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
node_modules/.bin/parcel build assets/index.html --no-cache --out-dir "$OUTDIR" --public-url "./" --detailed-report
+10 -3
View File
@@ -4,13 +4,12 @@
# repository into a temporary directory and copy the release build
# artifacts into there to commit & push to the gh-pages branch
# Fail on first error
set -e
set -euxo pipefail
OUTDIR=`pwd`/dist/release
echo $OUTDIR
./build-release.sh
./scripts/build-release.sh
# Create a shallow clone of the repository
TMPDIR=`mktemp -d -t speedscope-release`
@@ -29,7 +28,10 @@ echo www.speedscope.app > CNAME
# Set up a handler to run on Ctrl+C
trap ctrl_c INT
function ctrl_c() {
set +x
read -p "Commit release? [yes/no]: "
set -x
if [[ $REPLY =~ ^yes$ ]]
then
git add --all
@@ -39,7 +41,10 @@ function ctrl_c() {
rm -rf "$TMPDIR"
exit 0
else
set +x
echo "Aborting release."
set -x
popd
rm -rf "$TMPDIR"
exit 1
@@ -47,6 +52,7 @@ function ctrl_c() {
}
# Start a local server for verification of the build
set +x
echo
echo
echo "Build complete. Starting server on http://localhost:4444/"
@@ -54,3 +60,4 @@ echo "Hit Ctrl+C to complete or cancel the release"
echo
echo
python -m SimpleHTTPServer 4444 .
set +x
@@ -3,11 +3,18 @@ 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',
'node_modules/.bin/quicktype --lang schema ./src/lib/file-format-spec.ts',
{
encoding: 'utf8',
},
)
jsonSchema = JSON.parse(jsonSchema)
if (!jsonSchema.definitions['FileFormat.File']) {
console.error('SCHEMA GENERATION FAILURE: Could not find FileFormat.File in the definitions list')
console.error(jsonSchema)
process.exit(1)
}
jsonSchema['$ref'] = '#/definitions/FileFormat.File'
console.log(JSON.stringify(jsonSchema, null, 4))
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Prepare a mock installation of speedscope to test it before the actual npm
# publish
set -euxo pipefail
TMPDIR=`mktemp -d -t speedscope-test-installation`
PACKEDNAME=`npm pack | tail -n1`
mv "$PACKEDNAME" "$TMPDIR"
cd "$TMPDIR"
tar -xvvf "$PACKEDNAME"
cd package
npm install
set +x
echo
echo "Run the following command to switch into the test directory"
echo cd "$TMPDIR"/package
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
set -e
for f in `find sample/profiles -name '*.zip' | grep -v Instruments`; do
unzip -o $f -d $(dirname $f);
done
-26
View File
@@ -1,26 +0,0 @@
import {h, render} from 'preact'
import {Application} from './application'
console.log(`speedscope v${require('./package.json').version}`)
let app: Application | null = null
const retained = (window as any)['__retained__'] as any
declare const module: any
if (module.hot) {
module.hot.dispose(() => {
if (app) {
;(window as any)['__retained__'] = app.serialize()
}
})
module.hot.accept()
}
function ref(instance: Application | null) {
app = instance
if (instance && retained) {
console.log('rehydrating: ', retained)
instance.rehydrate(retained)
}
}
render(<Application ref={ref} />, document.body, document.body.lastElementChild || undefined)
@@ -13,9 +13,9 @@ import {
TextureRenderer,
TextureRendererProps,
} from './texture-cached-renderer'
import {StatsPanel} from './stats'
import {StatsPanel} from '../lib/stats'
import {Vec2, Rect} from './math'
import {Vec2, Rect} from '../lib/math'
import {
FlamechartColorPassRenderer,
FlamechartColorPassRenderProps,
@@ -53,6 +53,7 @@ export class CanvasContext {
}, version: ${this.gl.limits.version}`,
)
;(window as any)['CanvasContext'] = this
this.rectangleBatchRenderer = new RectangleBatchRenderer(this.gl)
this.viewportRectangleRenderer = new ViewportRectangleRenderer(this.gl)
this.textureRenderer = new TextureRenderer(this.gl)
@@ -1,5 +1,5 @@
import regl from 'regl'
import {Vec2, Rect, AffineTransform} from './math'
import {Vec2, Rect, AffineTransform} from '../lib/math'
export interface FlamechartColorPassRenderProps {
rectInfoTexture: regl.Texture
@@ -1,10 +1,10 @@
import regl from 'regl'
import {Flamechart} from './flamechart'
import {Flamechart} from '../lib/flamechart'
import {RectangleBatch} from './rectangle-batch-renderer'
import {CanvasContext} from './canvas-context'
import {Vec2, Rect, AffineTransform} from './math'
import {Color} from './color'
import {KeyedSet} from './utils'
import {Vec2, Rect, AffineTransform} from '../lib/math'
import {Color} from '../lib/color'
import {KeyedSet} from '../lib/utils'
import {RowAtlas} from './row-atlas'
const MAX_BATCH_SIZE = 10000
@@ -121,7 +121,7 @@ export class FlamechartRowAtlasKey {
}
}
interface RendererOptions {
export interface FlamechartRendererOptions {
inverted: boolean
}
@@ -134,7 +134,7 @@ export class FlamechartRenderer {
private canvasContext: CanvasContext,
private rowAtlas: RowAtlas<FlamechartRowAtlasKey>,
private flamechart: Flamechart,
private options: RendererOptions = {inverted: false},
private options: FlamechartRendererOptions = {inverted: false},
) {
const nLayers = flamechart.getLayers().length
for (let stackDepth = 0; stackDepth < nLayers; stackDepth++) {
@@ -1,5 +1,5 @@
import regl from 'regl'
import {AffineTransform, Rect} from './math'
import {AffineTransform, Rect} from '../lib/math'
export interface ViewportRectangleRendererProps {
configSpaceToPhysicalViewSpace: AffineTransform
@@ -1,6 +1,6 @@
import regl from 'regl'
import {Rect, Vec2, AffineTransform} from './math'
import {Color} from './color'
import {Rect, Vec2, AffineTransform} from '../lib/math'
import {Color} from '../lib/color'
export class RectangleBatch {
private rectCapacity = 1000
+3 -3
View File
@@ -1,9 +1,9 @@
import regl from 'regl'
import {LRUCache} from './lru-cache'
import {LRUCache} from '../lib/lru-cache'
import {RectangleBatch} from './rectangle-batch-renderer'
import {CanvasContext} from './canvas-context'
import {Rect, Vec2} from './math'
import {Color} from './color'
import {Rect, Vec2} from '../lib/math'
import {Color} from '../lib/color'
export class RowAtlas<K> {
texture: regl.Texture
@@ -1,5 +1,5 @@
import regl from 'regl'
import {Vec2, Rect, AffineTransform} from './math'
import {Vec2, Rect, AffineTransform} from '../lib/math'
export interface TextureRendererProps {
texture: regl.Texture
@@ -40,6 +40,7 @@ Object {
"totalWeight": 4,
},
],
"name": "simple.txt",
"stacks": Array [
"a;b;c 2",
"a;b;d 4",
@@ -48,3 +49,7 @@ Object {
],
}
`;
exports[`importFromBGFlameGraph: indexToView 1`] = `0`;
exports[`importFromBGFlameGraph: profileGroup.name 1`] = `"simple.txt"`;
@@ -49,6 +49,7 @@ Object {
"totalWeight": 17557,
},
],
"name": "simple.cpuprofile",
"stacks": Array [
"(anonymous) 11.57ms",
"(anonymous);a;b;d 16.79ms",
@@ -57,6 +58,10 @@ Object {
}
`;
exports[`importFromChromeCPUProfile: indexToView 1`] = `0`;
exports[`importFromChromeCPUProfile: profileGroup.name 1`] = `"simple.cpuprofile"`;
exports[`importFromChromeTimeline 1`] = `
Object {
"frames": Array [
@@ -97,6 +102,7 @@ Object {
"totalWeight": 16987,
},
],
"name": "simple-timeline.json",
"stacks": Array [
"(program) 10.16ms",
" 1.99ms",
@@ -255,3 +261,7 @@ Object {
],
}
`;
exports[`importFromChromeTimeline: indexToView 1`] = `0`;
exports[`importFromChromeTimeline: profileGroup.name 1`] = `"simple-timeline.json"`;
@@ -0,0 +1,132 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`importFromFirefox 1`] = `
Object {
"frames": Array [
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "a (file:///Users/jlfwong/code/speedscope/sample/simple.js:1)",
"line": 1,
"name": "a",
"selfWeight": 0,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "b (file:///Users/jlfwong/code/speedscope/sample/simple.js:8)",
"line": 8,
"name": "b",
"selfWeight": 0,
"totalWeight": 20.971447000018088,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "d (file:///Users/jlfwong/code/speedscope/sample/simple.js:20)",
"line": 20,
"name": "d",
"selfWeight": 31.992820999992546,
"totalWeight": 31.992820999992546,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/simple.js",
"key": "c (file:///Users/jlfwong/code/speedscope/sample/simple.js:14)",
"line": 14,
"name": "c",
"selfWeight": 0,
"totalWeight": 11.021373999974458,
},
],
"name": "simple-firefox.json",
"stacks": Array [
"a;b;d 989.53µs",
"a;c;d 1.02ms",
"a;b;d 982.04µs",
"a;c;d 1.02ms",
"a;b;d 7.00ms",
"a;c;d 2.00ms",
"a;b;d 981.44µs",
"a;c;d 2.00ms",
"a;b;d 2.00ms",
"a;c;d 3.02ms",
"a;b;d 4.03ms",
"a;c;d 1.96ms",
"a;b;d 4.99ms",
],
}
`;
exports[`importFromFirefox recursion 1`] = `
Object {
"frames": Array [
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "main (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:31)",
"line": 31,
"name": "main",
"selfWeight": 1.1944820000207983,
"totalWeight": 11.149112000013702,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "alpha (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:1)",
"line": 1,
"name": "alpha",
"selfWeight": 0,
"totalWeight": 9.954629999992903,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "beta (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:10)",
"line": 10,
"name": "beta",
"selfWeight": 0,
"totalWeight": 8.962158999987878,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "delta (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:17)",
"line": 17,
"name": "delta",
"selfWeight": 0,
"totalWeight": 6.859736999962479,
},
Frame {
"col": undefined,
"file": "file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js",
"key": "gamma (file:///Users/jlfwong/code/speedscope/sample/programs/javascript/recursion.js:23)",
"line": 23,
"name": "gamma",
"selfWeight": 9.954629999992903,
"totalWeight": 9.954629999992903,
},
],
"name": "recursion.json",
"stacks": Array [
"main;alpha;beta;alpha;beta;alpha;beta;alpha;delta;gamma 998.89µs",
"main 1.19ms",
"main;alpha;beta;alpha;delta;gamma 1.83ms",
"main;alpha;beta;alpha;beta;alpha;beta;gamma 1.16ms",
"main;alpha;beta;alpha;delta;gamma 1.84ms",
"main;alpha;beta;alpha;beta;alpha;beta;alpha;gamma 964.81µs",
"main;alpha;beta;alpha;beta;alpha;beta;alpha;delta;gamma 1.19ms",
"main;alpha;beta;gamma 965.25µs",
"main;alpha;delta;gamma 992.47µs",
],
}
`;
exports[`importFromFirefox recursion: indexToView 1`] = `0`;
exports[`importFromFirefox recursion: profileGroup.name 1`] = `"recursion.json"`;
exports[`importFromFirefox: indexToView 1`] = `0`;
exports[`importFromFirefox: profileGroup.name 1`] = `"simple-firefox.json"`;
@@ -85,6 +85,7 @@ Object {
"totalWeight": 74448896,
},
],
"name": "random-allocations-deep-copy.txt",
"stacks": Array [
"start;main;delta();alpha();leakMemory();malloc;malloc_zone_malloc 73.00 MB",
"start;main;delta();beta();leakMemory();malloc;malloc_zone_malloc 72.00 MB",
@@ -98,6 +99,10 @@ Object {
}
`;
exports[`importFromInstrumentsDeepCopy allocations profile: indexToView 1`] = `0`;
exports[`importFromInstrumentsDeepCopy allocations profile: profileGroup.name 1`] = `"random-allocations-deep-copy.txt"`;
exports[`importFromInstrumentsDeepCopy time profile 1`] = `
Object {
"frames": Array [
@@ -282,6 +287,7 @@ Object {
"totalWeight": 1,
},
],
"name": "simple-time-profile-deep-copy.txt",
"stacks": Array [
"start;main;delta();alpha();leakMemory();malloc;malloc_zone_malloc;szone_malloc_should_clear;small_malloc_from_free_list 4.00ms",
"start;main;delta();alpha();leakMemory();malloc;malloc_zone_malloc;szone_malloc_should_clear;allocate_pages_securely;mach_vm_map;_kernelrpc_mach_vm_map_trap 1.00ms",
@@ -326,6 +332,10 @@ Object {
}
`;
exports[`importFromInstrumentsDeepCopy time profile: indexToView 1`] = `0`;
exports[`importFromInstrumentsDeepCopy time profile: profileGroup.name 1`] = `"simple-time-profile-deep-copy.txt"`;
exports[`importFromInstrumentsTrace Instruments 8.3.3 1`] = `
Object {
"frames": Array [
@@ -681,6 +691,7 @@ Object {
"totalWeight": 1016403,
},
],
"name": "simple-time-profile.trace - thread 4",
"stacks": Array [
" 730.82ms",
"_dyld_start;dyldbootstrap::start(macho_header const*, int, char const**, long, macho_header const*, unsigned long*);dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*);dyld::link(ImageLoader*, bool, bool, ImageLoader::RPathChain const&, unsigned int);ImageLoader::link(ImageLoader::LinkContext const&, bool, bool, bool, ImageLoader::RPathChain const&, char const*);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoader::recursiveBind(ImageLoader::LinkContext const&, bool, bool);ImageLoaderMachOCompressed::doBind(ImageLoader::LinkContext const&, bool);ImageLoaderMachO::setupLazyPointerHandler(ImageLoader::LinkContext const&) 5.06ms",
@@ -4108,6 +4119,7 @@ Object {
"totalWeight": 1012057,
},
],
"name": "simple-time-profile.trace - thread 4",
"stacks": Array [
" 7.15ms",
"_dyld_start;dyldbootstrap::start(macho_header const*, int, char const**, long, macho_header const*, unsigned long*);dyld::_main(macho_header const*, unsigned long, int, char const**, char const**, char const**, unsigned long*);dyld::initializeMainExecutable();ImageLoader::runInitializers(ImageLoader::LinkContext const&, ImageLoader::InitializerTimingList&);ImageLoader::processInitializers(ImageLoader::LinkContext const&, unsigned int, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&);ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&);ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&);ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&);ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&);ImageLoaderMachO::doInitialization(ImageLoader::LinkContext const&);0x0000000110c55a79;libSystem_initializer;_libc_initializer;__chk_init;_dyld_register_func_for_add_image;_dyld_func_lookup 9.02ms",
@@ -76,6 +76,7 @@ Object {
"totalWeight": 774706,
},
],
"name": "simple-stackprof.json",
"stacks": Array [
"<main>;<main>;block in <main>;Object#a;Object#a;Object#b;Object#b;Object#d;Object#d 6.11ms",
"<main>;<main>;block in <main>;Object#a;Object#a;Object#b;Object#b;Object#d;Object#d;(garbage collection) 1.02ms",
@@ -849,3 +850,7 @@ Object {
],
}
`;
exports[`importFromStackprof: indexToView 1`] = `0`;
exports[`importFromStackprof: profileGroup.name 1`] = `"simple-stackprof.json"`;
@@ -0,0 +1,265 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`importFromV8ProfLog 1`] = `
Object {
"frames": Array [
Frame {
"col": 10,
"file": "bootstrap_node.js",
"key": " bootstrap_node.js:10:10",
"line": 10,
"name": "(anonymous)",
"selfWeight": 0,
"totalWeight": 116424,
},
Frame {
"col": 19,
"file": "bootstrap_node.js",
"key": "startup bootstrap_node.js:12:19",
"line": 12,
"name": "startup",
"selfWeight": 0,
"totalWeight": 116424,
},
Frame {
"col": 32,
"file": "bootstrap_node.js",
"key": "setupGlobalVariables bootstrap_node.js:265:32",
"line": 265,
"name": "setupGlobalVariables",
"selfWeight": 0,
"totalWeight": 29385,
},
Frame {
"col": 34,
"file": "bootstrap_node.js",
"key": "NativeModule.require bootstrap_node.js:534:34",
"line": 534,
"name": "NativeModule.require",
"selfWeight": 0,
"totalWeight": 52273,
},
Frame {
"col": 44,
"file": "bootstrap_node.js",
"key": "NativeModule.compile bootstrap_node.js:602:44",
"line": 602,
"name": "NativeModule.compile",
"selfWeight": 0,
"totalWeight": 52273,
},
Frame {
"col": 11,
"file": "util.js",
"key": " util.js:1:11",
"line": 1,
"name": "(anonymous)",
"selfWeight": 0,
"totalWeight": 29385,
},
Frame {
"col": 11,
"file": "internal/encoding.js",
"key": " internal/encoding.js:1:11",
"line": 1,
"name": "(anonymous)",
"selfWeight": 0,
"totalWeight": 29385,
},
Frame {
"col": undefined,
"file": undefined,
"key": "(c++) v8::internal::Runtime_CreateArrayLiteral",
"line": undefined,
"name": "(c++) v8::internal::Runtime_CreateArrayLiteral",
"selfWeight": 0,
"totalWeight": 29385,
},
Frame {
"col": undefined,
"file": undefined,
"key": "(c++) v8::internal::JSFunction::EnsureHasInitialMap",
"line": undefined,
"name": "(c++) v8::internal::JSFunction::EnsureHasInitialMap",
"selfWeight": 29385,
"totalWeight": 29385,
},
Frame {
"col": 30,
"file": "bootstrap_node.js",
"key": "setupGlobalConsole bootstrap_node.js:320:30",
"line": 320,
"name": "setupGlobalConsole",
"selfWeight": 0,
"totalWeight": 22888,
},
Frame {
"col": 40,
"file": "bootstrap_node.js",
"key": "setupInspectorCommandLineAPI bootstrap_node.js:364:40",
"line": 364,
"name": "setupInspectorCommandLineAPI",
"selfWeight": 0,
"totalWeight": 22888,
},
Frame {
"col": 11,
"file": "module.js",
"key": " module.js:1:11",
"line": 1,
"name": "(anonymous)",
"selfWeight": 0,
"totalWeight": 22888,
},
Frame {
"col": 11,
"file": "fs.js",
"key": " fs.js:1:11",
"line": 1,
"name": "(anonymous)",
"selfWeight": 0,
"totalWeight": 22888,
},
Frame {
"col": undefined,
"file": undefined,
"key": "(c++) v8::internal::Runtime_StoreIC_Miss",
"line": undefined,
"name": "(c++) v8::internal::Runtime_StoreIC_Miss",
"selfWeight": 0,
"totalWeight": 22888,
},
Frame {
"col": undefined,
"file": undefined,
"key": "(c++) v8::internal::Map::RawCopy",
"line": undefined,
"name": "(c++) v8::internal::Map::RawCopy",
"selfWeight": 22888,
"totalWeight": 22888,
},
Frame {
"col": 26,
"file": "module.js",
"key": "Module.runMain module.js:663:26",
"line": 663,
"name": "Module.runMain",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 24,
"file": "module.js",
"key": "Module._load module.js:443:24",
"line": 443,
"name": "Module._load",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 23,
"file": "module.js",
"key": "tryModuleLoad module.js:505:23",
"line": 505,
"name": "tryModuleLoad",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 33,
"file": "module.js",
"key": "Module.load module.js:536:33",
"line": 536,
"name": "Module.load",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 37,
"file": "module.js",
"key": "Module._extensions..js module.js:633:37",
"line": 633,
"name": "Module._extensions..js",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 37,
"file": "module.js",
"key": "Module._compile module.js:581:37",
"line": 581,
"name": "Module._compile",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 11,
"file": "/Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js",
"key": " /Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js:1:11",
"line": 1,
"name": "(anonymous)",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 73,
"file": "/Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js",
"key": "a /Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js:1:73",
"line": 1,
"name": "a",
"selfWeight": 0,
"totalWeight": 64151,
},
Frame {
"col": 11,
"file": "/Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js",
"key": "b /Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js:8:11",
"line": 8,
"name": "b",
"selfWeight": 10044,
"totalWeight": 53997,
},
Frame {
"col": 11,
"file": "/Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js",
"key": "d /Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js:20:11",
"line": 20,
"name": "d",
"selfWeight": 51562,
"totalWeight": 51562,
},
Frame {
"col": 11,
"file": "/Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js",
"key": "c /Users/jlfwong/code/speedscope/sample/programs/javascript/simple.js:14:11",
"line": 14,
"name": "c",
"selfWeight": 2545,
"totalWeight": 10154,
},
],
"name": "simple.v8log.json",
"stacks": Array [
"(anonymous);startup;setupGlobalVariables;NativeModule.require;NativeModule.compile;(anonymous);NativeModule.require;NativeModule.compile;(anonymous);(c++) v8::internal::Runtime_CreateArrayLiteral;(c++) v8::internal::JSFunction::EnsureHasInitialMap 29.38ms",
"(anonymous);startup;setupGlobalConsole;setupInspectorCommandLineAPI;NativeModule.require;NativeModule.compile;(anonymous);NativeModule.require;NativeModule.compile;(anonymous);(c++) v8::internal::Runtime_StoreIC_Miss;(c++) v8::internal::Map::RawCopy 22.89ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;b;d 37.52ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;c;d 1.28ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;b;d 2.55ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;c;d 1.27ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;b;d 1.28ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;c;d 1.27ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;b;d 1.29ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;c;d 1.26ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;b;d 1.31ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;c;d 2.52ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;c 1.27ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;b 3.62ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;c 1.28ms",
"(anonymous);startup;Module.runMain;Module._load;tryModuleLoad;Module.load;Module._extensions..js;Module._compile;(anonymous);a;b 6.42ms",
],
}
`;
exports[`importFromV8ProfLog: indexToView 1`] = `0`;
exports[`importFromV8ProfLog: profileGroup.name 1`] = `"simple.v8log.json"`;
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from '../test-utils'
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromBGFlameGraph', async () => {
await checkProfileSnapshot('./sample/profiles/stackcollapse/simple.txt')
@@ -1,6 +1,6 @@
// https://github.com/brendangregg/FlameGraph#2-fold-stacks
import {Profile, FrameInfo, StackListProfileBuilder} from '../profile'
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
interface BGSample {
stack: FrameInfo[]
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from '../test-utils'
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromChromeCPUProfile', async () => {
await checkProfileSnapshot('./sample/profiles/Chrome/65/simple.cpuprofile')
+3 -3
View File
@@ -1,6 +1,6 @@
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../profile'
import {getOrInsert, lastOf} from '../utils'
import {TimeFormatter} from '../value-formatters'
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../lib/profile'
import {getOrInsert, lastOf} from '../lib/utils'
import {TimeFormatter} from '../lib/value-formatters'
interface TimelineEvent {
pid: number
+9
View File
@@ -0,0 +1,9 @@
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromFirefox', async () => {
await checkProfileSnapshot('./sample/profiles/Firefox/59/simple-firefox.json')
})
test('importFromFirefox recursion', async () => {
await checkProfileSnapshot('./sample/profiles/Firefox/61/recursion.json')
})
+16 -21
View File
@@ -1,6 +1,6 @@
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../profile'
import {getOrInsert, lastOf} from '../utils'
import {TimeFormatter} from '../value-formatters'
import {Profile, FrameInfo, CallTreeProfileBuilder} from '../lib/profile'
import {getOrInsert} from '../lib/utils'
import {TimeFormatter} from '../lib/value-formatters'
interface Allocations {
frames: any[]
@@ -158,7 +158,7 @@ export function importFromFirefox(firefoxProfile: FirefoxProfile): Profile {
? cpuProfile.threads[0]
: cpuProfile.threads.filter(t => t.name === 'GeckoMain')[0]
const frameIdToFrameInfo = new Map<number, FrameInfo>()
const frameKeyToFrameInfo = new Map<string, FrameInfo>()
function extractStack(sample: Sample): FrameInfo[] {
let stackFrameId: number | null = sample[0]
@@ -185,7 +185,7 @@ export function importFromFirefox(firefoxProfile: FirefoxProfile): Profile {
return null
}
return getOrInsert(frameIdToFrameInfo, f, () => ({
return getOrInsert(frameKeyToFrameInfo, location, () => ({
key: location,
name: match[1]!,
file: match[2]!,
@@ -203,27 +203,22 @@ export function importFromFirefox(firefoxProfile: FirefoxProfile): Profile {
const value = sample[1]
// Find lowest common ancestor of the current stack and the previous one
let lca: FrameInfo | null = null
let lcaIndex = -1
// This is O(n^2), but n should be relatively small here (stack height),
// so hopefully this isn't much of a problem
for (let i = stack.length - 1; i >= 0 && prevStack.indexOf(stack[i]) === -1; i--) {}
for (let i = 0; i < Math.min(stack.length, prevStack.length); i++) {
if (prevStack[i] !== stack[i]) {
break
}
lcaIndex = i
}
// Close frames that are no longer open
while (prevStack.length > 0 && lastOf(prevStack) != lca) {
const closingFrame = prevStack.pop()!
profile.leaveFrame(closingFrame, value)
for (let i = prevStack.length - 1; i > lcaIndex; i--) {
profile.leaveFrame(prevStack[i], value)
}
// Open frames that are now becoming open
const toOpen: FrameInfo[] = []
for (let i = stack.length - 1; i >= 0 && stack[i] != lca; i--) {
toOpen.push(stack[i])
}
toOpen.reverse()
for (let frame of toOpen) {
profile.enterFrame(frame, value)
for (let i = lcaIndex + 1; i < stack.length; i++) {
profile.enterFrame(stack[i], value)
}
prevStack = stack
+45 -28
View File
@@ -1,4 +1,4 @@
import {Profile} from '../profile'
import {Profile, ProfileGroup} from '../lib/profile'
import {FileSystemDirectoryEntry} from './file-system-entry'
import {importFromChromeCPUProfile, importFromChromeTimeline} from './chrome'
@@ -6,45 +6,59 @@ import {importFromStackprof} from './stackprof'
import {importFromInstrumentsDeepCopy, importFromInstrumentsTrace} from './instruments'
import {importFromBGFlameGraph} from './bg-flamegraph'
import {importFromFirefox} from './firefox'
import {importSpeedscopeProfiles} from '../file-format'
import {FileFormat} from '../file-format-spec'
import {importSpeedscopeProfiles} from '../lib/file-format'
import {importFromV8ProfLog} from './v8proflog'
export async function importProfile(fileName: string, contents: string): Promise<Profile | null> {
const profile = await _importProfile(fileName, contents)
if (profile && !profile.getName()) {
profile.setName(fileName)
export async function importProfileGroup(
fileName: string,
contents: string,
): Promise<ProfileGroup | null> {
const profileGroup = await _importProfileGroup(fileName, contents)
if (profileGroup) {
if (!profileGroup.name) {
profileGroup.name = fileName
}
for (let profile of profileGroup.profiles) {
if (profile && !profile.getName()) {
profile.setName(fileName)
}
}
return profileGroup
}
return profile
return null
}
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]
function toGroup(profile: Profile | null): ProfileGroup | null {
if (!profile) return null
return {name: profile.getName(), indexToView: 0, profiles: [profile]}
}
async function _importProfile(fileName: string, contents: string): Promise<Profile | null> {
async function _importProfileGroup(
fileName: string,
contents: string,
): Promise<ProfileGroup | null> {
// First pass: Check known file format names to infer the file type
if (fileName.endsWith('.speedscope.json')) {
console.log('Importing as speedscope json file')
return importSingleSpeedscopeProfile(JSON.parse(contents))
return importSpeedscopeProfiles(JSON.parse(contents))
} else if (fileName.endsWith('.cpuprofile')) {
console.log('Importing as Chrome CPU Profile')
return importFromChromeCPUProfile(JSON.parse(contents))
return toGroup(importFromChromeCPUProfile(JSON.parse(contents)))
} else if (fileName.endsWith('.chrome.json') || /Profile-\d{8}T\d{6}/.exec(fileName)) {
console.log('Importing as Chrome Timeline')
return importFromChromeTimeline(JSON.parse(contents))
return toGroup(importFromChromeTimeline(JSON.parse(contents)))
} else if (fileName.endsWith('.stackprof.json')) {
console.log('Importing as stackprof profile')
return importFromStackprof(JSON.parse(contents))
return toGroup(importFromStackprof(JSON.parse(contents)))
} else if (fileName.endsWith('.instruments.txt')) {
console.log('Importing as Instruments.app deep copy')
return importFromInstrumentsDeepCopy(contents)
return toGroup(importFromInstrumentsDeepCopy(contents))
} else if (fileName.endsWith('.collapsedstack.txt')) {
console.log('Importing as collapsed stack format')
return importFromBGFlameGraph(contents)
return toGroup(importFromBGFlameGraph(contents))
} else if (fileName.endsWith('.v8log.json')) {
console.log('Importing as --prof-process v8 log')
return toGroup(importFromV8ProfLog(JSON.parse(contents)))
}
// Second pass: Try to guess what file format it is based on structure
@@ -55,19 +69,22 @@ async function _importProfile(fileName: string, contents: string): Promise<Profi
if (parsed) {
if (parsed['$schema'] === 'https://www.speedscope.app/file-format-schema.json') {
console.log('Importing as speedscope json file')
return importSingleSpeedscopeProfile(parsed)
return importSpeedscopeProfiles(JSON.parse(contents))
} else if (parsed['systemHost'] && parsed['systemHost']['name'] == 'Firefox') {
console.log('Importing as Firefox profile')
return importFromFirefox(parsed)
return toGroup(importFromFirefox(parsed))
} else if (Array.isArray(parsed) && parsed[parsed.length - 1].name === 'CpuProfile') {
console.log('Importing as Chrome CPU Profile')
return importFromChromeTimeline(parsed)
return toGroup(importFromChromeTimeline(parsed))
} else if ('nodes' in parsed && 'samples' in parsed && 'timeDeltas' in parsed) {
console.log('Importing as Chrome Timeline')
return importFromChromeCPUProfile(parsed)
return toGroup(importFromChromeCPUProfile(parsed))
} else if ('mode' in parsed && 'frames' in parsed) {
console.log('Importing as stackprof profile')
return importFromStackprof(parsed)
return toGroup(importFromStackprof(parsed))
} else if ('code' in parsed && 'functions' in parsed && 'ticks' in parsed) {
console.log('Importing as --prof-process v8 log')
return toGroup(importFromV8ProfLog(parsed))
}
} else {
// Format is not JSON
@@ -76,7 +93,7 @@ async function _importProfile(fileName: string, contents: string): Promise<Profi
// a deep copy from OS X Instruments.app
if (/^[\w \t\(\)]*\tSymbol Name/.exec(contents)) {
console.log('Importing as Instruments.app deep copy')
return importFromInstrumentsDeepCopy(contents)
return toGroup(importFromInstrumentsDeepCopy(contents))
}
// If every line ends with a space followed by a number, it's probably
@@ -84,7 +101,7 @@ async function _importProfile(fileName: string, contents: string): Promise<Profi
const lineCount = contents.split(/\n/).length
if (lineCount >= 1 && lineCount === contents.split(/ \d+\n/).length) {
console.log('Importing as collapsed stack format')
return importFromBGFlameGraph(contents)
return toGroup(importFromBGFlameGraph(contents))
}
}
@@ -1,6 +1,6 @@
import * as fs from 'fs'
import * as path from 'path'
import {dumpProfile, checkProfileSnapshot} from '../test-utils'
import {dumpProfile, checkProfileSnapshot} from '../lib/test-utils'
import * as JSZip from 'jszip'
import {FileSystemEntry} from './file-system-entry'
@@ -87,7 +87,8 @@ describe('importFromInstrumentsTrace', () => {
})
})
const root = new ZipBackedFileSystemEntry(zip, 'simple-time-profile.trace')
const profile = await importFromFileSystemDirectoryEntry(root)
const profileGroup = await importFromFileSystemDirectoryEntry(root)
const profile = profileGroup.profiles[profileGroup.indexToView]
expect(dumpProfile(profile)).toMatchSnapshot()
}
@@ -1,10 +1,16 @@
// This file contains methods to import data from OS X Instruments.app
// https://developer.apple.com/library/content/documentation/DeveloperTools/Conceptual/InstrumentsUserGuide/index.html
import {Profile, FrameInfo, CallTreeProfileBuilder, StackListProfileBuilder} from '../profile'
import {sortBy, getOrThrow, getOrInsert, lastOf, getOrElse, zeroPad} from '../utils'
import {
Profile,
FrameInfo,
CallTreeProfileBuilder,
StackListProfileBuilder,
ProfileGroup,
} from '../lib/profile'
import {sortBy, getOrThrow, getOrInsert, lastOf, getOrElse, zeroPad} from '../lib/utils'
import * as pako from 'pako'
import {ByteFormatter, TimeFormatter} from '../value-formatters'
import {ByteFormatter, TimeFormatter} from '../lib/value-formatters'
import {FileSystemDirectoryEntry, FileSystemEntry, FileSystemFileEntry} from './file-system-entry'
function parseTSV<T>(contents: string): T[] {
@@ -184,6 +190,9 @@ class MaybeCompressedFileReader {
this.fileData = new Promise(resolve => {
const reader = new FileReader()
reader.addEventListener('loadend', () => {
if (!(reader.result instanceof ArrayBuffer)) {
throw new Error('Expected reader.result to be an instance of ArrayBuffer')
}
resolve(reader.result)
})
reader.readAsArrayBuffer(file)
@@ -379,92 +388,126 @@ interface SymbolInfo {
addressToLine: Map<number, number>
}
interface FormTemplateData {
selectedRun: number
instrument: string
version: number
interface FormTemplateRunData {
number: number
addressToFrameMap: Map<number, FrameInfo>
}
interface FormTemplateData {
version: number
selectedRunNumber: number
instrument: string
runs: FormTemplateRunData[]
}
async function readFormTemplate(tree: TraceDirectoryTree): Promise<FormTemplateData> {
const formTemplate = getOrThrow(tree.files, 'form.template')
const archive = readInstrumentsKeyedArchive(await readAsArrayBuffer(formTemplate))
const version = archive['com.apple.xray.owner.template.version']
const selectedRun = archive['com.apple.xray.owner.template'].get('_selectedRunNumber')
const selectedRunNumber = archive['com.apple.xray.owner.template'].get('_selectedRunNumber')
let instrument = archive['$1']
if ('stubInfoByUUID' in archive) {
instrument = Array.from(archive['stubInfoByUUID'].keys())[0]
}
let allRunData = archive['com.apple.xray.run.data']
const runData = getOrThrow<number, Map<any, any>>(
allRunData.runData,
allRunData.runNumbers.pop()!,
)
const allRunData = archive['com.apple.xray.run.data']
const symbolsByPid = getOrThrow<string, Map<number, {symbols: SymbolInfo[]}>>(
runData,
'symbolsByPid',
)
const runs: FormTemplateRunData[] = []
for (let runNumber of allRunData.runNumbers) {
const runData = getOrThrow<number, Map<any, any>>(allRunData.runData, runNumber)
const addressToFrameMap = new Map<number, FrameInfo>()
const symbolsByPid = getOrThrow<string, Map<number, {symbols: SymbolInfo[]}>>(
runData,
'symbolsByPid',
)
// TODO(jlfwong): Deal with profiles with conflicts addresses?
for (let [_pid, symbols] of symbolsByPid.entries()) {
for (let symbol of symbols.symbols) {
if (!symbol) continue
const {sourcePath, symbolName, addressToLine} = symbol
for (let [address, _line] of addressToLine) {
getOrInsert(addressToFrameMap, address, () => {
const name = symbolName || `0x${zeroPad(address.toString(16), 16)}`
const frame: FrameInfo = {
key: `${sourcePath}:${name}`,
name: name,
}
if (sourcePath) {
frame.file = sourcePath
}
return frame
})
const addressToFrameMap = new Map<number, FrameInfo>()
// TODO(jlfwong): Deal with profiles with conflicting addresses?
for (let symbols of symbolsByPid.values()) {
for (let symbol of symbols.symbols) {
if (!symbol) continue
const {sourcePath, symbolName, addressToLine} = symbol
for (let address of addressToLine.keys()) {
getOrInsert(addressToFrameMap, address, () => {
const name = symbolName || `0x${zeroPad(address.toString(16), 16)}`
const frame: FrameInfo = {
key: `${sourcePath}:${name}`,
name: name,
}
if (sourcePath) {
frame.file = sourcePath
}
return frame
})
}
}
runs.push({
number: runNumber,
addressToFrameMap,
})
}
}
return {
version,
instrument,
selectedRun,
addressToFrameMap,
selectedRunNumber,
runs,
}
}
// Import from a .trace file saved from Mac Instruments.app
export async function importFromInstrumentsTrace(
entry: FileSystemDirectoryEntry,
): Promise<Profile> {
): Promise<ProfileGroup> {
const tree = await extractDirectoryTree(entry)
const {version, selectedRun, instrument, addressToFrameMap} = await readFormTemplate(tree)
const {version, runs, instrument, selectedRunNumber} = await readFormTemplate(tree)
if (instrument !== 'com.apple.xray.instrument-type.coresampler2') {
throw new Error(
`The only supported instrument from .trace import is "com.apple.xray.instrument-type.coresampler2". Got ${instrument}`,
)
}
console.log('version: ', version)
console.log(`Importing time profile from run ${selectedRun}`)
console.log(`Importing time profile`)
const core = getCoreDirForRun(tree, selectedRun)
const profiles: Profile[] = []
let indexToView = 0
for (let run of runs) {
const {addressToFrameMap, number} = run
const group = await importRunFromInstrumentsTrace({
fileName: entry.name,
tree,
addressToFrameMap,
runNumber: number,
})
if (run.number === selectedRunNumber) {
indexToView = profiles.length + group.indexToView
}
profiles.push(...group.profiles)
}
return {name: entry.name, indexToView, profiles}
}
export async function importRunFromInstrumentsTrace(args: {
fileName: string
tree: TraceDirectoryTree
addressToFrameMap: Map<number, FrameInfo>
runNumber: number
}): Promise<ProfileGroup> {
const {fileName, tree, addressToFrameMap, runNumber} = args
const core = getCoreDirForRun(tree, runNumber)
let samples = await getRawSampleList(core)
const arrays = await getIntegerArrays(samples, core)
const backtraceIDtoStack = new Map<number, FrameInfo[]>()
const profile = new StackListProfileBuilder(lastOf(samples)!.timestamp)
profile.setName(entry.name)
// For now, we can only display the flamechart for a single thread of execution,
// So let's choose whichever thread had the most sample hits.
//
// TODO(jlfwong): Support displaying flamecharts for multiple threads.
// We'll try to guess which thread is the main thread by assuming
// it's the one with the most samples.
const sampleCountByThreadID = new Map<number, number>()
for (let sample of samples) {
sampleCountByThreadID.set(
@@ -473,9 +516,38 @@ export async function importFromInstrumentsTrace(
)
}
const counts = Array.from(sampleCountByThreadID.entries())
sortBy(counts, c => c[1])
const mainThreadID = lastOf(counts)![0]
samples = samples.filter(s => s.threadID === mainThreadID)
sortBy(counts, c => -c[1])
const threadIDs = counts.map(c => c[0])
return {
name: fileName,
indexToView: 0,
profiles: threadIDs.map(threadID =>
importThreadFromInstrumentsTrace({
threadID,
fileName,
arrays,
addressToFrameMap,
samples,
}),
),
}
}
export function importThreadFromInstrumentsTrace(args: {
fileName: string
addressToFrameMap: Map<number, FrameInfo>
threadID: number
arrays: number[][]
samples: Sample[]
}): Profile {
let {fileName, addressToFrameMap, arrays, threadID, samples} = args
const backtraceIDtoStack = new Map<number, FrameInfo[]>()
samples = samples.filter(s => s.threadID === threadID)
const profile = new StackListProfileBuilder(lastOf(samples)!.timestamp)
profile.setName(`${fileName} - thread ${threadID}`)
function appendRecursive(k: number, stack: FrameInfo[]) {
const frame = addressToFrameMap.get(k)
@@ -1,4 +1,4 @@
import {checkProfileSnapshot} from '../test-utils'
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromStackprof', async () => {
await checkProfileSnapshot('./sample/profiles/stackprof/simple-stackprof.json')
@@ -1,7 +1,7 @@
// https://github.com/tmm1/stackprof
import {Profile, FrameInfo, StackListProfileBuilder} from '../profile'
import {TimeFormatter} from '../value-formatters'
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
import {TimeFormatter} from '../lib/value-formatters'
interface StackprofFrame {
name: string
+5
View File
@@ -0,0 +1,5 @@
import {checkProfileSnapshot} from '../lib/test-utils'
test('importFromV8ProfLog', async () => {
await checkProfileSnapshot('./sample/profiles/node/8.5.0/simple.v8log.json')
})
+188
View File
@@ -0,0 +1,188 @@
import {Profile, FrameInfo, StackListProfileBuilder} from '../lib/profile'
import {getOrInsert, sortBy} from '../lib/utils'
import {TimeFormatter} from '../lib/value-formatters'
// This imports profiles generated by a combination of the following commands:
//
// node --prof /path/to/my/script.js
// node --prof-process -preprocess -j isolate*.log > profile.v8log.json
// References:
// - https://github.com/nodejs/node/blob/7edd0a17af8d74dce7dd6c7554a8b8523f83efdc/lib/internal/v8_prof_processor.js#L5
// - https://github.com/nodejs/node/blob/7edd0a17af8d74dce7dd6c7554a8b8523f83efdc/deps/v8/tools/tickprocessor.js
// - https://github.com/nodejs/node/blob/2db2857c72c219e5ba1642a345e52cfdd8c44a66/deps/v8/tools/logreader.js#L147
// - https://github.com/mapbox/flamebearer/blob/a8d4d5c0061ed439660783c613c43ab28b751219/index.js#L53
interface Code {
name: string
type: 'CODE' | 'CPP' | 'JS' | 'SHARED_LIB'
timestamp?: number
kind?:
| 'Bultin'
| 'BytecodeHandler'
| 'Handler'
| 'KeyedLoadIC'
| 'KeyedStoreIC'
| 'LoadGlobalIC'
| 'LoadIC'
| 'Opt'
| 'StoreIC'
| 'Stub'
| 'Unopt'
| 'Builtin'
| 'RegExp'
func?: number
tm?: number
}
interface Function {
name: string
codes: number[]
}
interface Tick {
// Timestamp
tm: number
// Virtual machine state?
vm: number
// stack
s: number[]
}
interface V8LogProfile {
code: Code[]
functions: Function[]
ticks: Tick[]
}
function codeToFrameInfo(code: Code, v8log: V8LogProfile): FrameInfo {
if (!code || !code.type) {
return {
key: '(unknown type)',
name: '(unknown type)',
}
}
let name = code.name
switch (code.type) {
case 'CPP': {
const matches = name.match(/[tT] ([^(<]*)/)
if (matches) name = `(c++) ${matches[1]}`
break
}
case 'SHARED_LIB':
name = '(LIB) ' + name
break
case 'JS': {
const matches = name.match(/([a-zA-Z0-9\._\-$]*) ([a-zA-Z0-9\.\-_\/$]*):(\d+):(\d+)/)
if (matches) {
return {
key: name,
name: matches[1].length > 0 ? matches[1] : '(anonymous)',
file: matches[2].length > 0 ? matches[2] : '(unknown file)',
line: parseInt(matches[3], 10),
col: parseInt(matches[4], 10),
}
}
break
}
case 'CODE': {
switch (code.kind) {
case 'LoadIC':
case 'StoreIC':
case 'KeyedStoreIC':
case 'KeyedLoadIC':
case 'LoadGlobalIC':
case 'Handler':
name = '(IC) ' + name
break
case 'BytecodeHandler':
name = '(bytecode) ~' + name
break
case 'Stub':
name = '(stub) ' + name
break
case 'Builtin':
name = '(builtin) ' + name
break
case 'RegExp':
name = '(regexp) ' + name
break
}
break
}
default: {
name = `(${code.type}) ${name}`
break
}
}
return {key: name, name}
}
export function importFromV8ProfLog(v8log: V8LogProfile): Profile {
const profile = new StackListProfileBuilder()
const sToFrameInfo = new Map<number, FrameInfo>()
function getFrameInfo(t: number) {
return getOrInsert(sToFrameInfo, t, t => {
const code = v8log.code[t]
return codeToFrameInfo(code, v8log)
})
}
let lastTm = 0
sortBy(v8log.ticks, tick => tick.tm)
for (let tick of v8log.ticks) {
const stack: FrameInfo[] = []
// tick.s holds the call stack at the time the sample was taken. The
// structure is a little strange -- it seems to be capturing both the
// JavaScript stack & the parallel C++ stack by interleaving the two.
// Because the stacks might not be the same length, it looks like the
// shorter stack is padded with indices of -1, so we'll just ignore those
// stacks.
//
// If you change the start index to `let i = tick.s.length - 1` instead,
// you'll see the C++ stack instead.
//
// Mostly the numbers in the stack seem to be indices into the `v8log.code`
// array, but some of the numbers in the C++ stack seem to be raw memory
// addresses.
for (let i = tick.s.length - 2; i >= 0; i -= 2) {
const id = tick.s[i]
if (id === -1) continue
if (id > v8log.code.length) {
// Treat this like a memory address
stack.push({
key: id,
name: `0x${id.toString(16)}`,
})
continue
}
stack.push(getFrameInfo(id))
}
profile.appendSample(stack, tick.tm - lastTm)
lastTm = tick.tm
}
// Despite the code in the v8 processing library indicating that the
// timestamps come from a variable called "time_ns", from making empirical
// recordings, it really seems like these profiles are recording timestamps in
// microseconds, not nanoseconds.
// https://github.com/nodejs/node/blob/c39caa997c751473d0c8f50af8c6b14bcd389fa0/deps/v8/tools/profile.js#L1076
profile.setValueFormatter(new TimeFormatter('microseconds'))
return profile.build()
}
@@ -0,0 +1,213 @@
// 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,
},
],
"name": "simple.txt",
"stacks": Array [
"a;b;c 2",
"a;b;d 4",
"a;b;c 3",
"a;b 5",
],
}
`;
exports[`importSpeedscopeProfiles 0.0.1 evented profile: indexToView 1`] = `0`;
exports[`importSpeedscopeProfiles 0.0.1 evented profile: profileGroup.name 1`] = `"simple.txt"`;
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,
},
],
"name": "simple.speedscope.json",
"stacks": Array [
"a;b;c 2.00s",
"a;b;d 4.00s",
"a;b;c 3.00s",
"a;b 5.00s",
],
}
`;
exports[`importSpeedscopeProfiles 0.1.2 sampled profile: indexToView 1`] = `0`;
exports[`importSpeedscopeProfiles 0.1.2 sampled profile: profileGroup.name 1`] = `"simple.speedscope.json"`;
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles 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,
},
],
"name": "one",
"stacks": Array [
"a;b;c 2.00s",
"a;b;d 4.00s",
"a;b;c 3.00s",
"a;b 5.00s",
],
}
`;
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles 2`] = `
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,
},
],
"name": "two",
"stacks": Array [
"a;b;c 2.00s",
"a;b;d 4.00s",
"a;b;c 3.00s",
"a;b 5.00s",
],
}
`;
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles: indexToView 1`] = `1`;
exports[`importSpeedscopeProfiles 0.6.0 multiple profiles: profileGroup.name 1`] = `"Two Samples"`;
@@ -0,0 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallTreeProfileBuilder separates non-contiguous: append order 1`] = `
"((speedscope root):0:5
(a:1:5
(b:1:2
(c:1:1)
)
(b:1:2
(c:1:1)
)
)
)"
`;
exports[`CallTreeProfileBuilder separates non-contiguous: grouped 1`] = `
"((speedscope root):0:5
(a:1:5
(b:2:4
(c:2:2)
)
)
)"
`;
exports[`StackListProfileBuilder separates non-contiguous: append order 1`] = `
"((speedscope root):4:0
(a:1:5
(b:1:2
(c:1:1)
)
(b:1:2
(c:1:1)
)
)
)"
`;
exports[`StackListProfileBuilder separates non-contiguous: grouped 1`] = `
"((speedscope root):4:0
(a:1:5
(b:2:4
(c:2:2)
)
)
)"
`;
+29
View File
@@ -0,0 +1,29 @@
import {Color} from './color'
describe('Color', () => {
test('fromLumaChromaHue', () => {
expect(Color.fromLumaChromaHue(0, 0, 0).toCSS()).toEqual('rgba(0, 0, 0, 1.00)')
expect(Color.fromLumaChromaHue(1, 0, 0).toCSS()).toEqual('rgba(255, 255, 255, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 0, 30).toCSS()).toEqual('rgba(128, 128, 128, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 0, 60).toCSS()).toEqual('rgba(128, 128, 128, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 0.5, 0).toCSS()).toEqual('rgba(217, 89, 89, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 0).toCSS()).toEqual('rgba(255, 51, 51, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 60).toCSS()).toEqual('rgba(156, 156, 0, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 120).toCSS()).toEqual('rgba(0, 232, 0, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 180).toCSS()).toEqual('rgba(0, 204, 204, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 240).toCSS()).toEqual('rgba(99, 99, 255, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 300).toCSS()).toEqual('rgba(255, 23, 255, 1.00)')
expect(Color.fromLumaChromaHue(0.5, 1.0, 360).toCSS()).toEqual('rgba(255, 51, 51, 1.00)')
})
test('toCSS', () => {
expect(new Color().toCSS()).toEqual('rgba(0, 0, 0, 1.00)')
expect(new Color(1, 0, 0, 1).toCSS()).toEqual('rgba(255, 0, 0, 1.00)')
expect(new Color(0, 1, 0, 1).toCSS()).toEqual('rgba(0, 255, 0, 1.00)')
expect(new Color(0, 0, 1, 1).toCSS()).toEqual('rgba(0, 0, 255, 1.00)')
expect(new Color(0, 0, 1, 0.599).toCSS()).toEqual('rgba(0, 0, 255, 0.60)')
})
})
+6 -1
View File
@@ -1,3 +1,5 @@
import {clamp} from './math'
export class Color {
constructor(
readonly r: number = 0,
@@ -7,6 +9,9 @@ export class Color {
) {}
static fromLumaChromaHue(L: number, C: number, H: number) {
// 0 <= L <= 1
// 0 <= C <= 1
// 0 <= H <= 360
// https://en.wikipedia.org/wiki/HSL_and_HSV#From_luma/chroma/hue
const hPrime = H / 60
@@ -26,7 +31,7 @@ export class Color {
const m = L - (0.3 * R1 + 0.59 * G1 + 0.11 * B1)
return new Color(R1 + m, G1 + m, B1 + m, 1.0)
return new Color(clamp(R1 + m, 0, 1), clamp(G1 + m, 0, 1), clamp(B1 + m, 0, 1), 1.0)
}
toCSS(): string {
+13
View File
@@ -0,0 +1,13 @@
import {demangleCpp} from './demangle-cpp'
test('demangleCpp', () => {
expect(demangleCpp('a')).toBe('a')
expect(demangleCpp('someUnobfuscatedFunction')).toBe('someUnobfuscatedFunction')
expect(demangleCpp('__ZNK7Support6ColorFeqERKS0_')).toBe(
'Support::ColorF::operator==(Support::ColorF const&) const',
)
// Running a second time to test the cache
expect(demangleCpp('__ZNK7Support6ColorFeqERKS0_')).toBe(
'Support::ColorF::operator==(Support::ColorF const&) const',
)
})
File diff suppressed because one or more lines are too long
+31 -11
View File
@@ -1,9 +1,9 @@
import {importAsmJsSymbolMap} from './asm-js'
import {importEmscriptenSymbolMap} from './emscripten'
test('importAsmJSSymbolMap', () => {
test('importEmscriptenSymbolMap', () => {
// Valid symbol map
expect(
importAsmJsSymbolMap(
importEmscriptenSymbolMap(
[
/* prettier: ignore */
'a:A',
@@ -15,26 +15,46 @@ test('importAsmJSSymbolMap', () => {
// Valid symbol map with trailing newline
expect(
importAsmJsSymbolMap(
importEmscriptenSymbolMap(
[
/* prettier: ignore */
'a:A',
'b:B',
'c:C',
'd:D-D',
'',
].join('\n'),
),
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C']]))
).toEqual(new Map([['a', 'A'], ['b', 'B'], ['c', 'C'], ['d', 'D-D']]))
// Valid symbol map with non-alpha characters
expect(importAsmJsSymbolMap('u6:__ZN8tinyxml210XMLCommentD0Ev\n')).toEqual(
expect(importEmscriptenSymbolMap('u6:__ZN8tinyxml210XMLCommentD0Ev\n')).toEqual(
new Map([['u6', '__ZN8tinyxml210XMLCommentD0Ev']]),
)
// WebAssembly symbol map
expect(
importEmscriptenSymbolMap(
[
/* prettier: ignore */
'0:A',
'1:B',
'2:C',
'3:D-D',
].join('\n'),
),
).toEqual(
new Map([
['wasm-function[0]', 'A'],
['wasm-function[1]', 'B'],
['wasm-function[2]', 'C'],
['wasm-function[3]', 'D-D'],
]),
)
// Invalid symbol map
expect(
importAsmJsSymbolMap(
importEmscriptenSymbolMap(
[
/* prettier: ignore */
'a:A',
@@ -47,7 +67,7 @@ test('importAsmJSSymbolMap', () => {
// Collapsed stack format should not be imported as an asm.js symbol map
expect(
importAsmJsSymbolMap(
importEmscriptenSymbolMap(
[
/* prettier: ignore */
'a;b 1',
@@ -58,6 +78,6 @@ test('importAsmJSSymbolMap', () => {
).toEqual(null)
// Unrelated files
expect(importAsmJsSymbolMap('')).toEqual(null)
expect(importAsmJsSymbolMap('\n')).toEqual(null)
expect(importEmscriptenSymbolMap('')).toEqual(null)
expect(importEmscriptenSymbolMap('\n')).toEqual(null)
})
+39
View File
@@ -0,0 +1,39 @@
type EmscriptenSymbolMap = Map<string, string>
// This imports symbol maps generated by emscripten using the "--emit-symbol-map" flag.
// It allows you to visualize a profile captured in a release build as long as you also
// have the associated symbol map. To do this, first drop the profile into speedscope
// and then drop the symbol map. After the second drop, the symbols will be remapped to
// their original names.
export function importEmscriptenSymbolMap(contents: string): EmscriptenSymbolMap | null {
const lines = contents.split('\n')
if (!lines.length) return null
// Remove a trailing blank line if there is one
if (lines[lines.length - 1] === '') lines.pop()
if (!lines.length) return null
const map: EmscriptenSymbolMap = new Map()
const intRegex = /^(\d+):([\$\w-]+)$/
const idRegex = /^([\$\w]+):([\$\w-]+)$/
for (const line of lines) {
// Match lines like "103:__ZN8tinyxml210XMLCommentD0Ev"
const intMatch = intRegex.exec(line)
if (intMatch) {
map.set(`wasm-function[${intMatch[1]}]`, intMatch[2])
continue
}
// Match lines like "u6:__ZN8tinyxml210XMLCommentD0Ev"
const idMatch = idRegex.exec(line)
if (idMatch) {
map.set(idMatch[1], idMatch[2])
continue
}
return null
}
return map
}
@@ -4,12 +4,36 @@ export namespace FileFormat {
export type Profile = EventedProfile | SampledProfile
export interface File {
version: string
$schema: 'https://www.speedscope.app/file-format-schema.json'
// Data shared between profiles
shared: {
frames: Frame[]
}
// List of profile definitions
profiles: Profile[]
// The name of the contained profile group. If omitted, will use the name of
// the file itself.
// Added in 0.6.0
name?: string
// The index into the `profiles` array that should be displayed upon file
// load. If omitted, will default to displaying the first profile in the
// file.
//
// Added in 0.6.0
activeProfileIndex?: number
// The name of the the program which exported this profile. This isn't
// consumed but can be helpful for debugging generated data by seeing what
// was generating it! Recommended format is "name@version". e.g. when the
// file was exported by speedscope v0.6.0 itself, it will be
// "speedscope@0.6.0"
//
// Added in 0.6.0
exporter?: string
}
export interface Frame {
@@ -8,4 +8,8 @@ describe('importSpeedscopeProfiles', async () => {
test('0.1.2 sampled profile', async () => {
await checkProfileSnapshot('./sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json')
})
test('0.6.0 multiple profiles', async () => {
await checkProfileSnapshot('./sample/profiles/speedscope/0.6.0/two-sampled.speedscope.json')
})
})
+38 -25
View File
@@ -5,29 +5,14 @@ import {
CallTreeProfileBuilder,
FrameInfo,
StackListProfileBuilder,
ProfileGroup,
} from './profile'
import {TimeFormatter, ByteFormatter, RawValueFormatter} from './value-formatters'
import {FileFormat} from './file-format-spec'
export function exportProfile(profile: Profile): FileFormat.File {
export function exportProfileGroup(profileGroup: ProfileGroup): 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: require('./package.json').version,
$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)
@@ -38,7 +23,6 @@ export function exportProfile(profile: Profile): FileFormat.File {
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)
@@ -46,6 +30,31 @@ export function exportProfile(profile: Profile): FileFormat.File {
return index
}
const file: FileFormat.File = {
exporter: `speedscope@${require('../../package.json').version}`,
name: profileGroup.name,
activeProfileIndex: profileGroup.indexToView,
$schema: 'https://www.speedscope.app/file-format-schema.json',
shared: {frames},
profiles: [],
}
for (let profile of profileGroup.profiles) {
file.profiles.push(exportProfile(profile, getIndexForFrame))
}
return file
}
function exportProfile(profile: Profile, getIndexForFrame: (frame: Frame) => number) {
const eventedProfile: FileFormat.EventedProfile = {
type: FileFormat.ProfileType.EVENTED,
name: profile.getName(),
unit: profile.getWeightUnit(),
startValue: 0,
endValue: profile.getTotalWeight(),
events: [],
}
const openFrame = (node: CallTreeNode, value: number) => {
eventedProfile.events.push({
type: FileFormat.EventType.OPEN_FRAME,
@@ -61,8 +70,7 @@ export function exportProfile(profile: Profile): FileFormat.File {
})
}
profile.forEachCall(openFrame, closeFrame)
return file
return eventedProfile
}
function importSpeedscopeProfile(
@@ -144,14 +152,19 @@ function importSpeedscopeProfile(
}
}
export function importSpeedscopeProfiles(serialized: FileFormat.File): Profile[] {
return serialized.profiles.map(p => importSpeedscopeProfile(p, serialized.shared.frames))
export function importSpeedscopeProfiles(serialized: FileFormat.File): ProfileGroup {
return {
name: serialized.name || serialized.profiles[0].name || 'profile',
indexToView: serialized.activeProfileIndex || 0,
profiles: serialized.profiles.map(p => importSpeedscopeProfile(p, serialized.shared.frames)),
}
}
export function saveToFile(profile: Profile): void {
const blob = new Blob([JSON.stringify(exportProfile(profile))], {type: 'text/json'})
export function saveToFile(profileGroup: ProfileGroup): void {
const file = exportProfileGroup(profileGroup)
const blob = new Blob([JSON.stringify(file)], {type: 'text/json'})
const nameWithoutExt = profile.getName().split('.')[0]!
const nameWithoutExt = file.name ? file.name.split('.')[0]! : 'profile'
const filename = `${nameWithoutExt.replace(/\W+/g, '_')}.speedscope.json`
console.log('Saving', filename)
+43
View File
@@ -1,6 +1,7 @@
import {Frame, CallTreeNode} from './profile'
import {lastOf} from './utils'
import {clamp} from './math'
export interface FlamechartFrame {
node: CallTreeNode
@@ -47,6 +48,48 @@ export class Flamechart {
return this.source.formatValue(v)
}
getClampedViewportWidth(viewportWidth: number) {
const maxWidth = this.getTotalWeight()
// In order to avoid floating point error, we cap the maximum zoom. In
// particular, it's important that at the maximum zoom level, the total
// trace size + a viewport width is not equal to the trace size due to
// floating point rounding.
//
// For instance, if the profile's total weight is 2^60, and the viewport
// size is 1, trying to move one viewport width right will result in no
// change because 2^60 + 1 = 2^60 in floating point arithmetic. JavaScript
// numbers are 64 bit floats, and therefore have 53 mantissa bits. You can
// see this for yourself in the console. Try:
//
// > Math.pow(2, 60) + 1 === Math.pow(2, 60)
// true
// > Math.pow(2, 53) + 1 === Math.pow(2, 53)
// true
// > Math.pow(2, 52) + 1 === Math.pow(2, 52)
// false
//
// We use 2^40 as a cap instead, since we want to be able to make small
// adjustments within a viewport width.
//
// For reference, this will still allow you to zoom until 1 nanosecond fills
// the screen in a profile with a duration of over 18 minutes.
//
// > Math.pow(2, 40) / (60 * Math.pow(10, 9))
// 18.325193796266667
//
const maxZoom = Math.pow(2, 40)
// In addition to capping zoom to avoid floating point error, we further cap
// zoom to avoid letting you zoom in so that the smallest element more than
// fills the screen, since that probably isn't useful. The final zoom cap is
// determined by the minimum zoom of either 2^40x zoom or the necessary zoom
// for the smallest frame to fill the screen three times.
const minWidth = clamp(3 * this.getMinFrameWidth(), maxWidth / maxZoom, maxWidth)
return clamp(viewportWidth, minWidth, maxWidth)
}
constructor(private source: FlamechartDataSource) {
const stack: FlamechartFrame[] = []
const openFrame = (node: CallTreeNode, value: number) => {
+27
View File
@@ -0,0 +1,27 @@
import {getHashParams} from './hash-params'
test('getHashParams', () => {
expect(getHashParams('')).toEqual({})
expect(getHashParams('#')).toEqual({})
expect(getHashParams('#title=hello')).toEqual({title: 'hello'})
expect(getHashParams('#localProfilePath=file:///tmp/file.js')).toEqual({
localProfilePath: 'file:///tmp/file.js',
})
expect(
getHashParams(
'#profileURL=https://raw.githubusercontent.com/jlfwong/speedscope/master/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json',
),
).toEqual({
profileURL:
'https://raw.githubusercontent.com/jlfwong/speedscope/master/sample/profiles/speedscope/0.1.2/simple-sampled.speedscope.json',
})
expect(getHashParams('#title=hello&localProfilePath=file:///tmp/file.js')).toEqual({
title: 'hello',
localProfilePath: 'file:///tmp/file.js',
})
expect(getHashParams('#title=hello%20world')).toEqual({
title: 'hello world',
})
expect(getHashParams('#abc=bcd')).toEqual({})
expect(getHashParams('garbage')).toEqual({})
})
+6 -6
View File
@@ -4,22 +4,22 @@ export interface HashParams {
localProfilePath?: string
}
export function getHashParams(): HashParams {
export function getHashParams(hashContents = window.location.hash): HashParams {
try {
const hashContents = window.location.hash
if (!hashContents.startsWith('#')) {
return {}
}
const components = hashContents.substr(1).split('&')
const result: HashParams = {}
for (const component of components) {
const [key, value] = component.split('=')
let [key, value] = component.split('=')
value = decodeURIComponent(value)
if (key === 'profileURL') {
result.profileURL = decodeURIComponent(value)
result.profileURL = value
} else if (key === 'title') {
result.title = decodeURIComponent(value)
result.title = value
} else if (key === 'localProfilePath') {
result.localProfilePath = decodeURIComponent(value)
result.localProfilePath = value
}
}
return result
View File
@@ -50,6 +50,30 @@ function toStackList(profile: Profile, grouped: boolean): string[] {
return stackList
}
function flatten<T>(ts: T[][]): T[] {
let ret: T[] = []
return ret.concat(...ts)
}
function toTreeString(profile: Profile, grouped: boolean): string {
function visit(node: CallTreeNode): string[] {
const childLines = flatten(node.children.map(child => visit(child))).map(l => ` ${l}`)
const nodeStr = `${node.frame.key}:${node.getSelfWeight()}:${node.getTotalWeight()}`
if (childLines.length > 0) {
return [`(${nodeStr}`].concat(childLines).concat(')')
} else {
return [`(${nodeStr})`]
}
}
if (grouped) {
return visit(profile.getGroupedCalltreeRoot()).join('\n')
} else {
return visit(profile.getAppendOrderCalltreeRoot()).join('\n')
}
}
function verifyProfile(profile: Profile) {
const allFrameKeys = new Set([fa, fb, fc, fd, fe].map(f => f.key))
const framesInProfile = new Set<string | number>()
@@ -133,6 +157,28 @@ test('StackListProfileBuilder', () => {
verifyProfile(profile)
})
test('StackListProfileBuilder separates non-contiguous', () => {
const b = new StackListProfileBuilder()
const samples = [
// prettier-ignore
[fa, fb, fc],
[fa, fb],
[fa],
[fa, fb],
[fa, fb, fc],
]
samples.forEach(stack => {
b.appendSample(stack, 1)
})
b.appendSample([], 4)
const profile = b.build()
expect(toTreeString(profile, true)).toMatchSnapshot('grouped')
expect(toTreeString(profile, false)).toMatchSnapshot('append order')
})
test('CallTreeProfileBuilder', () => {
const b = new CallTreeProfileBuilder()
@@ -167,6 +213,30 @@ test('CallTreeProfileBuilder', () => {
verifyProfile(profile)
})
test('CallTreeProfileBuilder separates non-contiguous', () => {
const b = new CallTreeProfileBuilder()
b.enterFrame(fa, 0)
b.enterFrame(fb, 0)
b.enterFrame(fc, 0)
b.leaveFrame(fc, 1)
b.leaveFrame(fb, 2)
b.enterFrame(fb, 3)
b.enterFrame(fc, 4)
b.leaveFrame(fc, 5)
b.leaveFrame(fb, 5)
b.leaveFrame(fa, 5)
const profile = b.build()
expect(toTreeString(profile, true)).toMatchSnapshot('grouped')
expect(toTreeString(profile, false)).toMatchSnapshot('append order')
})
test('getInvertedProfileForCallersOf', () => {
const b = new StackListProfileBuilder()
+46 -5
View File
@@ -89,20 +89,43 @@ export class CallTreeNode extends HasWeights {
return this.frame === Frame.root
}
// If a node is "frozen", it means it should no longer be mutated.
private frozen = false
isFrozen() {
return this.frozen
}
freeze() {
this.frozen = true
}
constructor(readonly frame: Frame, readonly parent: CallTreeNode | null) {
super()
}
}
export interface ProfileGroup {
name: string
indexToView: number
profiles: Profile[]
}
export class Profile {
protected name: string = ''
protected totalWeight: number
protected frames = new KeyedSet<Frame>()
protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null)
protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null)
public getAppendOrderCalltreeRoot() {
return this.appendOrderCalltreeRoot
}
public getGroupedCalltreeRoot() {
return this.groupedCalltreeRoot
}
// List of references to CallTreeNodes at the top of the
// stack at the time of the sample.
protected samples: CallTreeNode[] = []
@@ -395,7 +418,7 @@ export class StackListProfileBuilder extends Profile {
const last = useAppendOrder
? lastOf(node.children)
: node.children.find(c => c.frame === frame)
if (last && last.frame == frame) {
if (last && !last.isFrozen() && last.frame == frame) {
node = last
} else {
const parent = node
@@ -414,6 +437,12 @@ export class StackListProfileBuilder extends Profile {
}
node.addToSelfWeight(weight)
if (useAppendOrder) {
for (let child of node.children) {
child.freeze()
}
}
if (useAppendOrder) {
node.frame.addToSelfWeight(weight)
@@ -493,7 +522,7 @@ export class CallTreeProfileBuilder extends Profile {
? lastOf(prevTop.children)
: prevTop.children.find(c => c.frame === frame)
let node: CallTreeNode
if (last && last.frame == frame) {
if (last && !last.isFrozen() && last.frame == frame) {
node = last
} else {
node = new CallTreeNode(frame, prevTop)
@@ -520,10 +549,17 @@ export class CallTreeProfileBuilder extends Profile {
if (useAppendOrder) {
const leavingStackTop = this.appendOrderStack.pop()
const delta = value - this.lastValue!
if (leavingStackTop == null) {
throw new Error(`Trying to leave ${frame.key} when stack is empty`)
}
if (this.lastValue == null) {
throw new Error(`Trying to leave a ${frame.key} before any have been entered`)
}
leavingStackTop.freeze()
const delta = value - this.lastValue
if (delta > 0) {
this.samples.push(leavingStackTop!)
this.weights.push(value - this.lastValue!)
this.samples.push(leavingStackTop)
this.weights.push(value - this.lastValue)
} else if (delta < 0) {
throw new Error(
`Samples must be provided in increasing order of cumulative value. Last sample was ${this
@@ -556,6 +592,11 @@ export class CallTreeProfileBuilder extends Profile {
}
build(): Profile {
// Each stack is expected to contain a single node which we initialize to be
// the root node.
if (this.appendOrderStack.length > 1 || this.groupedOrderStack.length > 1) {
throw new Error('Tried to complete profile construction with a non-empty stack')
}
return this
}
}
View File
+98
View File
@@ -0,0 +1,98 @@
import * as fs from 'fs'
import * as path from 'path'
import {Profile, CallTreeNode, Frame} from './profile'
import {importProfileGroup} from '../import'
import {exportProfileGroup, importSpeedscopeProfiles} from './file-format'
interface DumpedProfile {
name: string
stacks: string[]
frames: Frame[]
}
export function dumpProfile(profile: Profile): any {
const dump: DumpedProfile = {
name: profile.getName(),
stacks: [],
frames: [],
}
profile.forEachFrame(f => dump.frames.push(f))
let lastValue = 0
const curStack: (number | string)[] = []
function maybeEmit(value: number) {
if (lastValue != value) {
dump.stacks.push(
curStack.map(k => `${k}`).join(';') + ` ${profile.formatValue(value - lastValue)}`,
)
lastValue = value
}
}
function openFrame(node: CallTreeNode, value: number) {
maybeEmit(value)
curStack.push(node.frame.name)
}
function closeFrame(node: CallTreeNode, value: number) {
maybeEmit(value)
curStack.pop()
}
profile.forEachCall(openFrame, closeFrame)
return dump
}
export async function checkProfileSnapshot(filepath: string) {
const input = fs.readFileSync(filepath, 'utf8')
const profileGroup = await importProfileGroup(path.basename(filepath), input)
if (profileGroup) {
expect(profileGroup.name).toMatchSnapshot('profileGroup.name')
expect(profileGroup.indexToView).toMatchSnapshot('indexToView')
for (let profile of profileGroup.profiles) {
expect(dumpProfile(profile)).toMatchSnapshot()
}
} else {
fail('Failed to extract profile')
return
}
const profilesWithoutFilename = await importProfileGroup('unknown', input)
if (profilesWithoutFilename) {
expect(profilesWithoutFilename.profiles.length).toEqual(profileGroup.profiles.length)
profilesWithoutFilename.name = profileGroup.name
for (let i = 0; i < profileGroup.profiles.length; i++) {
const a = profileGroup.profiles[i]
const b = profilesWithoutFilename.profiles[i]
b.setName(a.getName())
}
expect(exportProfileGroup(profileGroup)).toEqual(exportProfileGroup(profilesWithoutFilename))
} else {
fail('Failed to extract profile when filename was "unknown"')
return
}
const exported = exportProfileGroup(profileGroup)
const reimportedGroup = importSpeedscopeProfiles(exported)
expect(reimportedGroup.name).toEqual(profileGroup.name)
expect(reimportedGroup.profiles.length).toEqual(profileGroup.profiles.length)
for (let i = 0; i < profileGroup.profiles.length; i++) {
const profile = profileGroup.profiles[i]
const reimported = reimportedGroup.profiles[i]
expect(reimported.getTotalWeight()).toEqual(profile.getTotalWeight())
expect(dumpProfile(reimported).stacks.join('\n')).toEqual(
dumpProfile(profile).stacks.join('\n'),
)
}
const reexported = exportProfileGroup(reimportedGroup)
expect(exported).toEqual(reexported)
}
+94
View File
@@ -0,0 +1,94 @@
import {connect} from 'preact-redux'
import * as redux from 'redux'
import {ComponentConstructor, Component} from 'preact'
export interface Action<TPayload> extends redux.Action<string> {
payload: TPayload
}
export interface ActionCreator<TPayload> {
// Returns an action with a non-empty payload
(payload: TPayload): Action<TPayload>
// Returns an action with an empty payload ({})
(): Action<TPayload>
matches(action: Action<any>): action is Action<TPayload>
}
const usedActionTypes = new Set<string>()
export function actionCreator(type: string): ActionCreator<void>
export function actionCreator<TPayload>(type: string): ActionCreator<TPayload>
export function actionCreator(type: string) {
if (usedActionTypes.has(type)) {
throw new Error(`Cannot re-use action type name: ${type}`)
}
const creator: any = (payload = {}) => {
return {type, payload}
}
creator.matches = (action: Action<any>) => {
return action.type === type
}
return creator
}
export type Reducer<T> = (state: T | undefined, action: Action<any>) => T
export type ReducerWithActionType<T, A> = (state: T | undefined, action: Action<A>) => T
export function setter<T>(
setterAction: ActionCreator<T>,
defaultVal: T,
): (state: T | undefined, action: Action<any>) => T {
return (state = defaultVal, action) => {
if (setterAction.matches(action)) {
return action.payload
}
return state
}
}
export type Dispatch = redux.Dispatch<Action<any>>
// We make this into a single function invocation instead of the connect(map, map)(Component)
// syntax to make better use of type inference.
//
// NOTE: To avoid this returning objects which do not compare shallow equal, it's the
// responsibility of the caller to ensure that the props returned by map compare shallow
// equal. This most importantly mean memoizing functions which wrap dispatch to avoid
// all callback props from being regenerated on every call.
export function createContainer<OwnProps, State, ComponentProps, ComponentType>(
component: {
new (props: ComponentProps): ComponentType
},
map: (state: State, dispatch: Dispatch, ownProps: OwnProps) => ComponentProps,
): ComponentConstructor<OwnProps, {}> {
const mapStateToProps = (state: State) => state
const mapDispatchToProps = (dispatch: Dispatch) => ({dispatch})
const mergeProps = (
stateProps: State,
dispatchProps: {dispatch: Dispatch},
ownProps: OwnProps,
) => {
return map(stateProps, dispatchProps.dispatch, ownProps)
}
return connect(mapStateToProps, mapDispatchToProps, mergeProps)(component)
}
export type VoidState = {
__dummyField: void
}
export abstract class StatelessComponent<P> extends Component<P, VoidState> {}
export function bindActionCreator<T>(
dispatch: Dispatch,
actionCreator: (payload: T) => Action<T>,
): (t: T) => void {
return (t: T) => {
dispatch(actionCreator(t))
}
}
+64
View File
@@ -10,6 +10,9 @@ import {
formatPercent,
KeyedSet,
binarySearch,
memoizeByReference,
memoizeByShallowEquality,
objectsHaveShallowEquality,
} from './utils'
test('sortBy', () => {
@@ -109,3 +112,64 @@ test('binarySearch', () => {
expect(lo).toBeLessThan(Math.E)
expect(hi).toBeGreaterThan(Math.E)
})
test('memoizeByReference', () => {
let hitCount = 0
const identity = memoizeByReference((arg: number) => {
hitCount++
return arg
})
expect(identity(3)).toBe(3)
expect(hitCount).toBe(1)
expect(identity(3)).toBe(3)
expect(hitCount).toBe(1)
expect(identity(5)).toBe(5)
expect(hitCount).toBe(2)
expect(identity(3)).toBe(3)
expect(hitCount).toBe(3)
})
test('memoizeByShallowEquality', () => {
let hitCount = 0
const identity = memoizeByShallowEquality((arg: {a: number; b: number}) => {
hitCount++
return arg
})
expect(identity({a: 1, b: 2})).toEqual({a: 1, b: 2})
expect(hitCount).toBe(1)
expect(identity({a: 1, b: 2})).toEqual({a: 1, b: 2})
expect(hitCount).toBe(1)
expect(identity({a: 2, b: 2})).toEqual({a: 2, b: 2})
expect(hitCount).toBe(2)
expect(identity({a: 2, b: 2})).toEqual({a: 2, b: 2})
expect(hitCount).toBe(2)
expect(identity({a: 2, b: 1})).toEqual({a: 2, b: 1})
expect(hitCount).toBe(3)
expect(identity({a: 2, b: 1})).toEqual({a: 2, b: 1})
expect(hitCount).toBe(3)
expect(identity({a: 1, b: 2})).toEqual({a: 1, b: 2})
expect(hitCount).toBe(4)
})
test('objectsHaveShallowEquality', () => {
expect(objectsHaveShallowEquality({}, {})).toBe(true)
expect(objectsHaveShallowEquality({a: 1, b: 2}, {a: 1, b: 2})).toBe(true)
expect(objectsHaveShallowEquality({a: 1, b: 2}, {a: 1, b: 3})).toBe(false)
expect(objectsHaveShallowEquality({a: 1, b: 2}, {a: 2, b: 2})).toBe(false)
expect(objectsHaveShallowEquality({a: 1}, {a: 1, b: 2})).toBe(false)
expect(objectsHaveShallowEquality({a: 1, b: 2}, {b: 2})).toBe(false)
expect(objectsHaveShallowEquality([], [])).toBe(true)
expect(objectsHaveShallowEquality([1, 2], [1, 2])).toBe(true)
expect(objectsHaveShallowEquality([1], [1, 2])).toBe(false)
expect(objectsHaveShallowEquality([1, 2], [1])).toBe(false)
})
+46
View File
@@ -121,3 +121,49 @@ export function binarySearch(
}
export function noop(...args: any[]) {}
export function objectsHaveShallowEquality<T extends object>(a: T, b: T): boolean {
for (let key in a) {
if (a[key] !== b[key]) return false
}
for (let key in b) {
if (a[key] !== b[key]) return false
}
return true
}
export function memoizeByShallowEquality<T extends object, U>(cb: (t: T) => U): (t: T) => U {
let last: {args: T; result: U} | null = null
return (args: T) => {
let result: U
if (last == null) {
result = cb(args)
last = {args, result}
return result
} else if (objectsHaveShallowEquality(last.args, args)) {
return last.result
} else {
last.args = args
last.result = cb(args)
return last.result
}
}
}
export function memoizeByReference<T, U>(cb: (t: T) => U): (t: T) => U {
let last: {args: T; result: U} | null = null
return (args: T) => {
let result: U
if (last == null) {
result = cb(args)
last = {args, result}
return result
} else if (last.args === args) {
return last.result
} else {
last.args = args
last.result = cb(args)
return last.result
}
}
}
@@ -22,7 +22,7 @@ export class TimeFormatter implements ValueFormatter {
else this.multiplier = 1
}
format(v: number) {
formatUnsigned(v: number) {
const s = v * this.multiplier
if (s / 60 >= 1) return `${(s / 60).toFixed(2)}min`
@@ -31,6 +31,10 @@ export class TimeFormatter implements ValueFormatter {
if (s / 1e-6 >= 1) return `${(s / 1e-6).toFixed(2)}µs`
else return `${(s / 1e-9).toFixed(2)}ns`
}
format(v: number) {
return `${v < 0 ? '-' : ''}${this.formatUnsigned(Math.abs(v))}`
}
}
export class ByteFormatter implements ValueFormatter {
+27
View File
@@ -0,0 +1,27 @@
import {h, render} from 'preact'
import {createApplicationStore} from './store'
import {Provider} from 'preact-redux'
import {ApplicationContainer} from './views/application-container'
console.log(`speedscope v${require('../package.json').version}`)
declare const module: any
if (module.hot) {
module.hot.dispose(() => {
// Force the old component go through teardown steps
render(<div />, document.body, document.body.lastElementChild || undefined)
})
module.hot.accept()
}
const lastStore: any = (window as any)['store']
const store = createApplicationStore(lastStore ? lastStore.getState() : {})
;(window as any)['store'] = store
render(
<Provider store={store}>
<ApplicationContainer />
</Provider>,
document.body,
document.body.lastElementChild || undefined,
)
+69
View File
@@ -0,0 +1,69 @@
import {actionCreator} from '../lib/typed-redux'
import {CallTreeNode, Frame, ProfileGroup} from '../lib/profile'
import {SortMethod} from '../views/profile-table-view'
import {ViewMode} from '.'
import {FlamechartID} from './flamechart-view-state'
import {Rect, Vec2} from '../lib/math'
import {HashParams} from '../lib/hash-params'
import {actionCreatorWithIndex} from './profiles-state'
export namespace actions {
// Set the top-level profile group from which other data will be derived
export const setProfileGroup = actionCreator<ProfileGroup>('setProfileGroup')
// Set the index into the profile group to view
export const setProfileIndexToView = actionCreator<number>('setProfileIndexToView')
export const setGLCanvas = actionCreator<HTMLCanvasElement | null>('setGLCanvas')
// Set which top-level view should be displayed
export const setViewMode = actionCreator<ViewMode>('setViewMode')
// Set whether or not recursion should be flattened when viewing flamegraphs
export const setFlattenRecursion = actionCreator<boolean>('setFlattenRecursion')
// Set whether a file drag is currently active. Used to indicate that the
// application is a valid drop target.
export const setDragActive = actionCreator<boolean>('setDragActive')
// Set whether the application is currently in a loading state. Used to
// display a loading progress bar.
export const setLoading = actionCreator<boolean>('setLoading')
// Set whether the application is in an errored state.
export const setError = actionCreator<boolean>('setError')
// Set whether parameters defined by the URL encoded k=v pairs after the # in the URL
export const setHashParams = actionCreator<HashParams>('setHashParams')
export namespace sandwichView {
// Set the table sorting method used for the sandwich view.
export const setTableSortMethod = actionCreator<SortMethod>('sandwichView.setTableSortMethod')
export const setSelectedFrame = actionCreatorWithIndex<Frame | null>(
'sandwichView.setSelectedFarmr',
)
}
export namespace flamechart {
export const setHoveredNode = actionCreatorWithIndex<{
id: FlamechartID
hover: {node: CallTreeNode; event: MouseEvent} | null
}>('flamechart.setHoveredNode')
export const setSelectedNode = actionCreatorWithIndex<{
id: FlamechartID
selectedNode: CallTreeNode | null
}>('flamechart.setSelectedNode')
export const setConfigSpaceViewportRect = actionCreatorWithIndex<{
id: FlamechartID
configSpaceViewportRect: Rect
}>('flamechart.setConfigSpaceViewportRect')
export const setLogicalSpaceViewportSize = actionCreatorWithIndex<{
id: FlamechartID
logicalSpaceViewportSize: Vec2
}>('flamechart.setLogicalSpaceViewportSpace')
}
}
+105
View File
@@ -0,0 +1,105 @@
import {storeTest, profileGroupTwoSampled} from './store-test-utils'
import {actions} from './actions'
import {Rect, Vec2} from '../lib/math'
import {FlamechartID} from './flamechart-view-state'
import {ViewMode} from '.'
describe('flamechart view state', () => {
storeTest('setHoveredNode', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
expect(getState().profileGroup!.profiles[0].chronoViewState.hover).toEqual(null)
const {profileGroup} = getState()
const hover = {
node: profileGroup!.profiles[0].profile.getAppendOrderCalltreeRoot().children[0],
event: {} as MouseEvent,
}
dispatch(
actions.flamechart.setHoveredNode({
profileIndex: 0,
args: {
id: FlamechartID.CHRONO,
hover,
},
}),
)
expect(getState().profileGroup!.profiles[0].chronoViewState.hover).toEqual(hover)
expect(getState().profileGroup!.profiles[0].leftHeavyViewState.hover).toEqual(null)
expect(getState().profileGroup!.profiles[1].chronoViewState.hover).toEqual(null)
// Changing view mode should clear the hover state
dispatch(actions.setViewMode(ViewMode.LEFT_HEAVY_FLAME_GRAPH))
expect(getState().profileGroup!.profiles[0].chronoViewState.hover).toEqual(null)
})
storeTest('setSelectedNode', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
expect(getState().profileGroup!.profiles[0].chronoViewState.selectedNode).toEqual(null)
const {profileGroup} = getState()
const selectedNode = profileGroup!.profiles[0].profile.getAppendOrderCalltreeRoot().children[0]
dispatch(
actions.flamechart.setSelectedNode({
profileIndex: 0,
args: {
id: FlamechartID.CHRONO,
selectedNode,
},
}),
)
expect(getState().profileGroup!.profiles[0].chronoViewState.selectedNode).toEqual(selectedNode)
expect(getState().profileGroup!.profiles[0].leftHeavyViewState.selectedNode).toEqual(null)
expect(getState().profileGroup!.profiles[1].chronoViewState.selectedNode).toEqual(null)
})
storeTest('setLogicalSpaceViewportSize', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
expect(getState().profileGroup!.profiles[0].chronoViewState.logicalSpaceViewportSize).toEqual(
Vec2.zero,
)
const logicalSpaceViewportSize = new Vec2(3, 5)
dispatch(
actions.flamechart.setLogicalSpaceViewportSize({
profileIndex: 0,
args: {
id: FlamechartID.CHRONO,
logicalSpaceViewportSize,
},
}),
)
expect(getState().profileGroup!.profiles[0].chronoViewState.logicalSpaceViewportSize).toEqual(
logicalSpaceViewportSize,
)
expect(
getState().profileGroup!.profiles[0].leftHeavyViewState.logicalSpaceViewportSize,
).toEqual(Vec2.zero)
expect(getState().profileGroup!.profiles[1].chronoViewState.logicalSpaceViewportSize).toEqual(
Vec2.zero,
)
})
storeTest('setConfigSpaceViewportRect', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
expect(getState().profileGroup!.profiles[0].chronoViewState.configSpaceViewportRect).toEqual(
Rect.empty,
)
dispatch(
actions.flamechart.setConfigSpaceViewportRect({
profileIndex: 0,
args: {
id: FlamechartID.CHRONO,
configSpaceViewportRect: new Rect(Vec2.zero, Vec2.unit),
},
}),
)
expect(getState().profileGroup!.profiles[0].chronoViewState.configSpaceViewportRect).toEqual(
new Rect(Vec2.zero, Vec2.unit),
)
expect(getState().profileGroup!.profiles[0].leftHeavyViewState.configSpaceViewportRect).toEqual(
Rect.empty,
)
expect(getState().profileGroup!.profiles[1].chronoViewState.configSpaceViewportRect).toEqual(
Rect.empty,
)
})
})
+62
View File
@@ -0,0 +1,62 @@
import {CallTreeNode} from '../lib/profile'
import {Rect, Vec2} from '../lib/math'
import {Reducer} from '../lib/typed-redux'
import {actions} from './actions'
export enum FlamechartID {
LEFT_HEAVY = 'LEFT_HEAVY',
CHRONO = 'CHRONO',
SANDWICH_INVERTED_CALLERS = 'SANDWICH_INVERTED_CALLERS',
SANDWICH_CALLEES = 'SANDWICH_CALLEES',
}
export interface FlamechartViewState {
hover: {
node: CallTreeNode
event: MouseEvent
} | null
selectedNode: CallTreeNode | null
logicalSpaceViewportSize: Vec2
configSpaceViewportRect: Rect
}
export function createFlamechartViewStateReducer(
id: FlamechartID,
profileIndex: number,
): Reducer<FlamechartViewState> {
let initialState: FlamechartViewState = {
hover: null,
selectedNode: null,
configSpaceViewportRect: Rect.empty,
logicalSpaceViewportSize: Vec2.zero,
}
function applies(action: {payload: {profileIndex: number; args: {id: FlamechartID}}}) {
const {payload} = action
return payload.args.id === id && payload.profileIndex === profileIndex
}
return (state = initialState, action) => {
if (actions.flamechart.setHoveredNode.matches(action) && applies(action)) {
const {hover} = action.payload.args
return {...state, hover}
}
if (actions.flamechart.setSelectedNode.matches(action) && applies(action)) {
const {selectedNode} = action.payload.args
return {...state, selectedNode}
}
if (actions.flamechart.setConfigSpaceViewportRect.matches(action) && applies(action)) {
const {configSpaceViewportRect} = action.payload.args
return {...state, configSpaceViewportRect}
}
if (actions.flamechart.setLogicalSpaceViewportSize.matches(action) && applies(action)) {
const {logicalSpaceViewportSize} = action.payload.args
return {...state, logicalSpaceViewportSize}
}
if (actions.setViewMode.matches(action)) {
// If we switch views, the hover information is no longer relevant
return {...state, hover: null}
}
return state
}
}
+67
View File
@@ -0,0 +1,67 @@
import {Frame, Profile} from '../lib/profile'
import {triangle, memoizeByReference, memoizeByShallowEquality} from '../lib/utils'
import {RowAtlas} from '../gl/row-atlas'
import {CanvasContext} from '../gl/canvas-context'
import {Color} from '../lib/color'
import {FlamechartRowAtlasKey} from '../gl/flamechart-renderer'
export const createGetColorBucketForFrame = memoizeByReference(
(frameToColorBucket: Map<number | string, number>) => {
return (frame: Frame): number => {
return frameToColorBucket.get(frame.key) || 0
}
},
)
export const createGetCSSColorForFrame = memoizeByReference(
(frameToColorBucket: Map<number | string, number>) => {
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
return (frame: Frame): string => {
const t = getColorBucketForFrame(frame) / 255
const x = triangle(30.0 * t)
const H = 360.0 * (0.9 * t)
const C = 0.25 + 0.2 * x
const L = 0.8 - 0.15 * x
return Color.fromLumaChromaHue(L, C, H).toCSS()
}
},
)
export const getCanvasContext = memoizeByReference((canvas: HTMLCanvasElement) => {
return new CanvasContext(canvas)
})
export const getRowAtlas = memoizeByReference((canvasContext: CanvasContext) => {
return new RowAtlas<FlamechartRowAtlasKey>(canvasContext)
})
export const getProfileWithRecursionFlattened = memoizeByReference((profile: Profile) =>
profile.getProfileWithRecursionFlattened(),
)
export const getProfileToView = memoizeByShallowEquality(
({profile, flattenRecursion}: {profile: Profile; flattenRecursion: boolean}): Profile => {
return flattenRecursion ? profile.getProfileWithRecursionFlattened() : profile
},
)
export const getFrameToColorBucket = memoizeByReference((profile: Profile): Map<
string | number,
number
> => {
const frames: Frame[] = []
profile.forEachFrame(f => frames.push(f))
function key(f: Frame) {
return (f.file || '') + f.name
}
function compare(a: Frame, b: Frame) {
return key(a) > key(b) ? 1 : -1
}
frames.sort(compare)
const frameToColorBucket = new Map<string | number, number>()
for (let i = 0; i < frames.length; i++) {
frameToColorBucket.set(frames[i].key, Math.floor(255 * i / frames.length))
}
return frameToColorBucket
})
+51
View File
@@ -0,0 +1,51 @@
import {ViewMode} from '.'
import {actions} from './actions'
import {storeTest} from './store-test-utils'
import {SortField, SortDirection} from '../views/profile-table-view'
storeTest('flattenRecursion', ({getState, dispatch}) => {
expect(getState().flattenRecursion).toBe(false)
dispatch(actions.setFlattenRecursion(true))
expect(getState().flattenRecursion).toBe(true)
})
storeTest('viewMode', ({getState, dispatch}) => {
expect(getState().viewMode).toBe(ViewMode.CHRONO_FLAME_CHART)
dispatch(actions.setViewMode(ViewMode.SANDWICH_VIEW))
expect(getState().viewMode).toBe(ViewMode.SANDWICH_VIEW)
})
storeTest('dragActive', ({getState, dispatch}) => {
expect(getState().dragActive).toBe(false)
dispatch(actions.setDragActive(true))
expect(getState().dragActive).toBe(true)
})
storeTest('loading', ({getState, dispatch}) => {
expect(getState().loading).toBe(false)
dispatch(actions.setLoading(true))
expect(getState().loading).toBe(true)
})
storeTest('error', ({getState, dispatch}) => {
expect(getState().error).toBe(false)
dispatch(actions.setError(true))
expect(getState().error).toBe(true)
})
storeTest('setTableSortMethod', ({getState, dispatch}) => {
expect(getState().tableSortMethod).toEqual({
field: SortField.SELF,
direction: SortDirection.DESCENDING,
})
dispatch(
actions.sandwichView.setTableSortMethod({
field: SortField.SYMBOL_NAME,
direction: SortDirection.ASCENDING,
}),
)
expect(getState().tableSortMethod).toEqual({
field: SortField.SYMBOL_NAME,
direction: SortDirection.ASCENDING,
})
})
+73
View File
@@ -0,0 +1,73 @@
import {actions} from './actions'
/**
* The root node of application state. We use redux (https://redux.js.org/)
* as our state management solution.
*/
import * as redux from 'redux'
import {setter, Reducer} from '../lib/typed-redux'
import {HashParams, getHashParams} from '../lib/hash-params'
import {ProfileGroupState, profileGroup} from './profiles-state'
import {SortMethod, SortField, SortDirection} from '../views/profile-table-view'
export const enum ViewMode {
CHRONO_FLAME_CHART,
LEFT_HEAVY_FLAME_GRAPH,
SANDWICH_VIEW,
}
export interface ApplicationState {
hashParams: HashParams
glCanvas: HTMLCanvasElement | null
flattenRecursion: boolean
viewMode: ViewMode
dragActive: boolean
loading: boolean
error: boolean
tableSortMethod: SortMethod
profileGroup: ProfileGroupState
}
const protocol = window.location.protocol
// Speedscope is usable both from a local HTML file being served
// from a file:// URL, and via websites. In the case of file:// URLs,
// however, XHR will be unavailable to fetching files in adjacent directories.
export const canUseXHR = protocol === 'http:' || protocol === 'https:'
export function createApplicationStore(
initialState: Partial<ApplicationState>,
): redux.Store<ApplicationState> {
const hashParams = getHashParams()
const loading = canUseXHR && hashParams.profileURL != null
const reducer: Reducer<ApplicationState> = redux.combineReducers({
profileGroup,
hashParams: setter<HashParams>(actions.setHashParams, hashParams),
flattenRecursion: setter<boolean>(actions.setFlattenRecursion, false),
viewMode: setter<ViewMode>(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART),
glCanvas: setter<HTMLCanvasElement | null>(actions.setGLCanvas, null),
dragActive: setter<boolean>(actions.setDragActive, false),
loading: setter<boolean>(actions.setLoading, loading),
error: setter<boolean>(actions.setError, false),
tableSortMethod: setter<SortMethod>(actions.sandwichView.setTableSortMethod, {
field: SortField.SELF,
direction: SortDirection.DESCENDING,
}),
})
return redux.createStore(reducer, initialState)
}
+36
View File
@@ -0,0 +1,36 @@
import {storeTest, profileGroupTwoSampled} from './store-test-utils'
import {actions} from './actions'
describe('profileGroup', () => {
storeTest('setProfileGroup', ({getState, dispatch}) => {
expect(getState().profileGroup).toBe(null)
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
const {profileGroup} = getState()
expect(profileGroup!.name).toBe(profileGroupTwoSampled.name)
expect(profileGroup!.indexToView).toBe(profileGroupTwoSampled.indexToView)
for (let i = 0; i < profileGroupTwoSampled.profiles.length; i++) {
expect(profileGroup!.profiles[i].profile).toEqual(profileGroupTwoSampled.profiles[i])
}
})
storeTest('setProfileIndexToView', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
expect(getState().profileGroup!.indexToView).toBe(1)
dispatch(actions.setProfileIndexToView(0))
expect(getState()!.profileGroup!.indexToView).toBe(0)
dispatch(actions.setProfileIndexToView(-1))
expect(getState()!.profileGroup!.indexToView).toBe(0)
dispatch(actions.setProfileIndexToView(2))
expect(getState().profileGroup!.indexToView).toBe(1)
})
storeTest('preserve state', ({getState, dispatch}) => {
// When an unrelated action occurs, we should not change the identiy of the profileGroup
// property
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
const profileGroup = getState().profileGroup
dispatch(actions.setDragActive(true))
expect(getState().profileGroup).toBe(profileGroup)
})
})
+100
View File
@@ -0,0 +1,100 @@
import {Profile} from '../lib/profile'
import {
FlamechartViewState,
createFlamechartViewStateReducer,
FlamechartID,
} from './flamechart-view-state'
import {SandwichViewState, createSandwichView} from './sandwich-view-state'
import {Reducer, actionCreator, setter} from '../lib/typed-redux'
import {actions} from './actions'
import {clamp} from '../lib/math'
import {objectsHaveShallowEquality} from '../lib/utils'
export type ProfileGroupState = {
name: string
indexToView: number
profiles: ProfileState[]
} | null
export interface ProfileWithIndex {
profile: Profile
index: number
}
export interface ProfileState {
profile: Profile
chronoViewState: FlamechartViewState
leftHeavyViewState: FlamechartViewState
sandwichViewState: SandwichViewState
}
export function actionCreatorWithIndex<T>(name: string) {
return actionCreator<{profileIndex: number; args: T}>(name)
}
function createProfileReducer(profile: Profile, index: number): Reducer<ProfileState> {
const chronoViewStateReducer = createFlamechartViewStateReducer(FlamechartID.CHRONO, index)
const leftHeavyViewStateReducer = createFlamechartViewStateReducer(FlamechartID.LEFT_HEAVY, index)
const sandwichViewStateReducer = createSandwichView(index)
return (state = undefined, action) => {
if (state === undefined) {
return {
profile,
chronoViewState: chronoViewStateReducer(undefined, action),
leftHeavyViewState: leftHeavyViewStateReducer(undefined, action),
sandwichViewState: sandwichViewStateReducer(undefined, action),
}
}
const nextState = {
profile,
chronoViewState: chronoViewStateReducer(state.chronoViewState, action),
leftHeavyViewState: leftHeavyViewStateReducer(state.leftHeavyViewState, action),
sandwichViewState: sandwichViewStateReducer(state.sandwichViewState, action),
}
if (objectsHaveShallowEquality(state, nextState)) {
return state
}
return nextState
}
}
export const profileGroup: Reducer<ProfileGroupState> = (state = null, action) => {
if (actions.setProfileGroup.matches(action)) {
const {indexToView, profiles, name} = action.payload
return {
indexToView,
name,
profiles: profiles.map((p, i) => {
return createProfileReducer(p, i)(undefined, action)
}),
}
}
if (state != null) {
const {indexToView, profiles} = state
const nextIndexToView = clamp(
setter(actions.setProfileIndexToView, 0)(indexToView, action),
0,
profiles.length - 1,
)
const nextProfiles = profiles.map((profileState, profileIndex) => {
return createProfileReducer(profileState.profile, profileIndex)(profileState, action)
})
if (indexToView === nextIndexToView && objectsHaveShallowEquality(profiles, nextProfiles)) {
return state
}
return {
...state,
indexToView: nextIndexToView,
profiles: nextProfiles,
}
}
return state
}
+70
View File
@@ -0,0 +1,70 @@
import {storeTest, profileGroupTwoSampled} from './store-test-utils'
import {actions} from './actions'
import {Vec2} from '../lib/math'
import {FlamechartID} from './flamechart-view-state'
describe('sandwich view state', () => {
storeTest('setSelectedFrame', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
expect(getState().profileGroup!.profiles[0].sandwichViewState.callerCallee).toBe(null)
const {profileGroup} = getState()
const selectedFrame = profileGroup!.profiles[0].profile.getAppendOrderCalltreeRoot().children[0]
.frame
dispatch(actions.sandwichView.setSelectedFrame({profileIndex: 0, args: selectedFrame}))
expect(getState().profileGroup!.profiles[0].sandwichViewState.callerCallee!.selectedFrame).toBe(
selectedFrame,
)
// Other profiles selection state should not change
expect(getState().profileGroup!.profiles[1].sandwichViewState.callerCallee).toBe(null)
// Changing selection when something is already selected should work
const selectedFrame2 = profileGroup!.profiles[0].profile.getAppendOrderCalltreeRoot()
.children[0].children[0].frame
dispatch(actions.sandwichView.setSelectedFrame({profileIndex: 0, args: selectedFrame2}))
expect(getState().profileGroup!.profiles[0].sandwichViewState.callerCallee!.selectedFrame).toBe(
selectedFrame2,
)
// Clear selection
dispatch(actions.sandwichView.setSelectedFrame({profileIndex: 0, args: null}))
expect(getState().profileGroup!.profiles[0].sandwichViewState.callerCallee).toBe(null)
})
storeTest('contained flamegraphs receive updates', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
const {profileGroup} = getState()
const selectedFrame = profileGroup!.profiles[0].profile.getAppendOrderCalltreeRoot().children[0]
.frame
dispatch(actions.sandwichView.setSelectedFrame({profileIndex: 0, args: selectedFrame}))
expect(
getState().profileGroup!.profiles[0].sandwichViewState.callerCallee!.calleeFlamegraph
.logicalSpaceViewportSize,
).toEqual(Vec2.zero)
dispatch(
actions.flamechart.setLogicalSpaceViewportSize({
profileIndex: 0,
args: {id: FlamechartID.SANDWICH_CALLEES, logicalSpaceViewportSize: Vec2.unit},
}),
)
expect(
getState().profileGroup!.profiles[0].sandwichViewState.callerCallee!.calleeFlamegraph
.logicalSpaceViewportSize,
).toEqual(Vec2.unit)
})
storeTest('preserve identity', ({getState, dispatch}) => {
dispatch(actions.setProfileGroup(profileGroupTwoSampled))
const {profileGroup} = getState()
const selectedFrame = profileGroup!.profiles[0].profile.getAppendOrderCalltreeRoot().children[0]
.frame
dispatch(actions.sandwichView.setSelectedFrame({profileIndex: 0, args: selectedFrame}))
// When an unrelated action is triggered, it should not change the identity of the sandwichViewState
const sandwichViewState = getState().profileGroup!.profiles[0].sandwichViewState
dispatch(actions.setLoading(true))
expect(getState().profileGroup!.profiles[0].sandwichViewState).toBe(sandwichViewState)
})
})
+78
View File
@@ -0,0 +1,78 @@
import {Frame} from '../lib/profile'
import {
FlamechartViewState,
FlamechartID,
createFlamechartViewStateReducer,
} from './flamechart-view-state'
import {Reducer} from '../lib/typed-redux'
import {actions} from './actions'
export interface SandwichViewState {
callerCallee: CallerCalleeState | null
}
export interface CallerCalleeState {
selectedFrame: Frame
invertedCallerFlamegraph: FlamechartViewState
calleeFlamegraph: FlamechartViewState
}
export function createSandwichView(profileIndex: number): Reducer<SandwichViewState> {
const calleesReducer = createFlamechartViewStateReducer(
FlamechartID.SANDWICH_CALLEES,
profileIndex,
)
const invertedCallersReducer = createFlamechartViewStateReducer(
FlamechartID.SANDWICH_INVERTED_CALLERS,
profileIndex,
)
function applies(action: {payload: {profileIndex: number}}) {
const {payload} = action
return payload.profileIndex === profileIndex
}
return (state = {callerCallee: null}, action) => {
if (actions.sandwichView.setSelectedFrame.matches(action) && applies(action)) {
if (action.payload.args == null) {
return {
...state,
callerCallee: null,
}
} else {
return {
...state,
callerCallee: {
selectedFrame: action.payload.args,
calleeFlamegraph: calleesReducer(undefined, action),
invertedCallerFlamegraph: invertedCallersReducer(undefined, action),
},
}
}
}
const {callerCallee} = state
if (callerCallee) {
const {calleeFlamegraph, invertedCallerFlamegraph} = callerCallee
const nextCalleeFlamegraph = calleesReducer(calleeFlamegraph, action)
const nextInvertedCallerFlamegraph = invertedCallersReducer(invertedCallerFlamegraph, action)
if (
nextCalleeFlamegraph === calleeFlamegraph &&
nextInvertedCallerFlamegraph === invertedCallerFlamegraph
) {
return state
}
return {
...state,
callerCallee: {
...callerCallee,
calleeFlamegraph: nextCalleeFlamegraph,
invertedCallerFlamegraph: nextInvertedCallerFlamegraph,
},
}
}
return state
}
}
+16
View File
@@ -0,0 +1,16 @@
import * as fs from 'fs'
import {Store, AnyAction} from '../../node_modules/redux'
import {ApplicationState, createApplicationStore} from '.'
import {importSpeedscopeProfiles} from '../lib/file-format'
export function storeTest(name: string, cb: (store: Store<ApplicationState, AnyAction>) => void) {
const store = createApplicationStore({})
test(name, () => {
cb(store)
})
}
const filepath = './sample/profiles/speedscope/0.6.0/two-sampled.speedscope.json'
const input = fs.readFileSync(filepath, 'utf8')
export const profileGroupTwoSampled = importSpeedscopeProfiles(JSON.parse(input))
+12
View File
@@ -0,0 +1,12 @@
declare module 'preact-redux' {
import {VNode, Component} from 'preact'
import {Store} from 'redux'
// We just export the bare minimum here because we're going
// to implement an API for readability convenience elsewhere
export function connect(...args: any[]): any
export class Provider extends Component<{store: Store<any>}, {}> {
render(): VNode
}
}

Some files were not shown because too many files have changed in this diff Show More